Add davemhammer/tailscale (#307)

Tailscale status, peers, exit nodes, and preference toggles for Noctalia v5.
This commit is contained in:
Dave Hammer
2026-08-08 20:22:15 -04:00
committed by GitHub
parent d7fab4e238
commit 1abe67bd4b
15 changed files with 2495 additions and 0 deletions
+857
View File
@@ -0,0 +1,857 @@
--!nonstrict
-- Tailscale backend: status, peers, exit nodes, preference toggles.
local STATE_KEY = "ts_snapshot"
local COMMAND_KEY = "ts_command"
local RESULT_KEY = "ts_action_result"
local MAX_PEERS = 200
local STUCK_SEC = 20
local snapshot = {
available = false,
loading = true,
busy = false,
installed = false,
backendState = "",
running = false,
hostname = "",
dnsName = "",
ipv4 = "",
ipv6 = "",
tailnet = "",
magicDNS = "",
version = "",
health = {},
peers = {},
exitNodes = {},
onlineCount = 0,
peerCount = 0,
offlineCount = 0,
exitNode = "",
exitNodeOnline = false,
shieldsUp = false,
acceptRoutes = false,
runSSH = false,
acceptDNS = false,
advertiseExitNode = false,
exitNodeAllowLAN = false,
operatorUser = "",
error = "",
updatedAt = 0,
revision = 0,
}
local refreshGeneration = 0
local refreshPending = false
local refreshAgain = false
local refreshStartedAt = 0
local actionBusy = false
local dataSignature = ""
local prevOnline = {} -- id -> true
local function trim(value)
return noctalia.string.trim(tostring(value or ""))
end
local function shellQuote(value)
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
end
local function shellCommand(args)
local quoted = {}
for _, value in ipairs(args) do
table.insert(quoted, shellQuote(value))
end
return table.concat(quoted, " ")
end
local function tsBin()
local bin = trim(noctalia.getConfig("tailscale_bin"))
if bin == "" then
return "tailscale"
end
return bin
end
local function refreshIntervalMs()
local seconds = tonumber(noctalia.getConfig("refresh_interval")) or 10
seconds = math.max(3, math.min(120, math.floor(seconds)))
return seconds * 1000
end
local function nowSec()
if type(noctalia.nowMs) == "function" then
local ms = noctalia.nowMs()
if type(ms) == "number" and ms > 0 then
return math.floor(ms / 1000)
end
end
return os.time()
end
local function runTs(args, callback, timeoutMs)
return noctalia.runAsync(shellCommand(args), callback, timeoutMs or 25000)
end
local function updateRevision(signature)
if signature ~= dataSignature then
dataSignature = signature
snapshot.revision += 1
end
end
local function publishSnapshot()
snapshot.busy = actionBusy
noctalia.state.set(STATE_KEY, snapshot)
end
local function actionResult(command, ok, message, extra)
local result = {
requestId = command and command.requestId or "",
action = command and command.action or "",
ok = ok,
message = message or "",
}
if type(extra) == "table" then
for k, v in pairs(extra) do
result[k] = v
end
end
noctalia.state.set(RESULT_KEY, result)
end
local function notifyOk(msg)
noctalia.notify(noctalia.tr("title"), msg)
end
local function notifyErr(msg)
noctalia.notifyError(noctalia.tr("title"), msg)
end
local function asString(v)
if v == nil then
return ""
end
if type(v) == "boolean" then
return v and "true" or "false"
end
return tostring(v)
end
local function firstIp(list)
if type(list) ~= "table" then
return "", ""
end
local v4, v6 = "", ""
for _, ip in ipairs(list) do
local s = asString(ip)
if s:find(":", 1, true) then
if v6 == "" then
v6 = s
end
elseif s ~= "" and v4 == "" then
v4 = s
end
end
return v4, v6
end
local function shortDns(dns)
dns = asString(dns):gsub("%.$", "")
return dns
end
local function hostLabel(peer)
local dns = shortDns(peer.DNSName or peer.dnsName or "")
if dns ~= "" then
local base = dns:match("^([^.]+)")
if base and base ~= "" then
return base
end
return dns
end
return asString(peer.HostName or peer.hostName or peer.id or "peer")
end
local function parseStatus(data)
local peers = {}
local onlineCount, offlineCount = 0, 0
local selfInfo = type(data.Self) == "table" and data.Self or {}
local ipv4, ipv6 = firstIp(data.TailscaleIPs or selfInfo.TailscaleIPs)
if ipv4 == "" then
ipv4, ipv6 = firstIp(selfInfo.TailscaleIPs)
end
local peerMap = data.Peer
if type(peerMap) == "table" then
for id, peer in pairs(peerMap) do
if type(peer) == "table" and #peers < MAX_PEERS then
local p4, p6 = firstIp(peer.TailscaleIPs)
local online = peer.Online == true
local active = peer.Active == true
local exitOpt = peer.ExitNodeOption == true
local isExit = peer.ExitNode == true
local name = hostLabel(peer)
local osName = asString(peer.OS)
local relay = asString(peer.Relay)
local lastSeen = asString(peer.LastSeen)
if online then
onlineCount += 1
else
offlineCount += 1
end
table.insert(peers, {
id = asString(peer.ID or id),
name = name,
hostName = asString(peer.HostName),
dnsName = shortDns(peer.DNSName),
ipv4 = p4,
ipv6 = p6,
online = online,
active = active,
os = osName,
relay = relay,
exitNode = isExit,
exitNodeOption = exitOpt,
rxBytes = tonumber(peer.RxBytes) or 0,
txBytes = tonumber(peer.TxBytes) or 0,
lastSeen = lastSeen,
ok = online,
})
end
end
end
table.sort(peers, function(a, b)
if a.online ~= b.online then
return a.online
end
if a.active ~= b.active then
return a.active
end
return a.name < b.name
end)
local health = {}
if type(data.Health) == "table" then
for _, h in ipairs(data.Health) do
local s = trim(h)
if s ~= "" then
table.insert(health, s)
end
end
end
local tailnet = ""
local magic = asString(data.MagicDNSSuffix)
if type(data.CurrentTailnet) == "table" then
tailnet = asString(data.CurrentTailnet.Name)
if magic == "" then
magic = asString(data.CurrentTailnet.MagicDNSSuffix)
end
end
local exitNode = ""
local exitOnline = false
if type(data.ExitNodeStatus) == "table" then
local ips = data.ExitNodeStatus.TailscaleIPs
if type(ips) == "table" and ips[1] then
exitNode = asString(ips[1]):gsub("/.*$", "")
end
exitOnline = data.ExitNodeStatus.Online == true
-- resolve name from peers if possible
local eid = asString(data.ExitNodeStatus.ID)
if eid ~= "" then
for _, p in ipairs(peers) do
if p.id == eid then
exitNode = p.name
break
end
end
if exitNode == "" or exitNode:match("^%d") then
for _, p in ipairs(peers) do
if p.id == eid then
exitNode = p.name
break
end
end
end
end
end
local backend = asString(data.BackendState)
local running = backend == "Running"
return {
backendState = backend,
running = running,
hostname = asString(selfInfo.HostName),
dnsName = shortDns(selfInfo.DNSName),
ipv4 = ipv4,
ipv6 = ipv6,
tailnet = tailnet,
magicDNS = magic,
version = asString(data.Version),
health = health,
peers = peers,
onlineCount = onlineCount,
peerCount = #peers,
offlineCount = offlineCount,
exitNode = exitNode,
exitNodeOnline = exitOnline,
advertiseExitNode = selfInfo.ExitNodeOption == true,
}
end
local function parseExitNodeList(stdout)
local list = {}
for line in (tostring(stdout or "") .. "\n"):gmatch("(.-)\n") do
line = trim(line)
if line ~= ""
and not line:match("^IP%s")
and not line:match("^#")
and not line:match("^To ")
and not line:match("^%-%-")
then
-- columns: IP HOSTNAME COUNTRY CITY STATUS...
local ip, host, rest = line:match("^(%S+)%s+(%S+)%s+(.*)$")
if ip and host and ip:match("^%d+%.%d+") then
local status = trim(rest)
local selected = status:lower():find("selected", 1, true) ~= nil
local offline = status:lower():find("offline", 1, true) ~= nil
table.insert(list, {
id = ip,
ip = ip,
name = host:match("^([^.]+)") or host,
host = host,
status = status,
selected = selected,
online = not offline,
ok = not offline,
})
end
end
end
table.sort(list, function(a, b)
if a.selected ~= b.selected then
return a.selected
end
if a.online ~= b.online then
return a.online
end
return a.name < b.name
end)
return list
end
local function routesIncludeDefaultExit(routes)
if type(routes) ~= "table" then
return false
end
local hasV4, hasV6 = false, false
for _, r in ipairs(routes) do
local s = asString(r)
if s == "0.0.0.0/0" then
hasV4 = true
elseif s == "::/0" then
hasV6 = true
end
end
-- Advertising as an exit node is stored as default routes, not ExitNodeOption.
return hasV4 or hasV6
end
local function parsePrefs(data)
if type(data) ~= "table" then
return {}
end
return {
shieldsUp = data.ShieldsUp == true,
acceptRoutes = data.RouteAll == true,
runSSH = data.RunSSH == true,
acceptDNS = data.CorpDNS == true,
exitNodeAllowLAN = data.ExitNodeAllowLANAccess == true,
-- Authoritative for "advertise exit node" (status Self.ExitNodeOption is often stale).
advertiseExitNode = routesIncludeDefaultExit(data.AdvertiseRoutes),
operatorUser = asString(data.OperatorUser),
wantRunning = data.WantRunning == true,
}
end
local function notifyPeerChanges(peers)
if noctalia.getConfig("notify_on_peer_change") == false then
return
end
local current = {}
for _, p in ipairs(peers) do
current[p.id] = p.online
local was = prevOnline[p.id]
if was == true and not p.online then
notifyErr(noctalia.tr("result.peer_offline", { name = p.name }))
elseif was == false and p.online then
notifyOk(noctalia.tr("result.peer_online", { name = p.name }))
end
end
-- only seed after first successful sample
if next(prevOnline) ~= nil or #peers > 0 then
prevOnline = {}
for id, online in pairs(current) do
prevOnline[id] = online
end
end
end
local refreshAll
local function forceUnstick(reason)
noctalia.log("tailscale: " .. reason)
refreshPending = false
refreshStartedAt = 0
snapshot.loading = false
if snapshot.error == "" then
snapshot.error = reason
end
noctalia.setUpdateInterval(refreshIntervalMs())
publishSnapshot()
end
local function applyBag(statusData, prefsData, exitStdout, errors)
local st = {}
local prefs = {}
local exits = {}
local okS, errS = pcall(function()
st = parseStatus(statusData or {})
end)
if not okS then
table.insert(errors, "status parse: " .. tostring(errS))
st = {}
end
local okP, errP = pcall(function()
prefs = parsePrefs(prefsData)
end)
if not okP then
table.insert(errors, "prefs parse: " .. tostring(errP))
end
local okE, errE = pcall(function()
exits = parseExitNodeList(exitStdout)
end)
if not okE then
table.insert(errors, "exit list: " .. tostring(errE))
end
-- resolve exit node display from selected exit list entry
local exitLabel = st.exitNode or ""
for _, e in ipairs(exits) do
if e.selected then
exitLabel = e.name
st.exitNodeOnline = e.online
break
end
end
pcall(notifyPeerChanges, st.peers or {})
local available = snapshot.installed and (st.backendState ~= "" or #(st.peers or {}) > 0 or st.hostname ~= "")
-- Even when Stopped, status JSON is valid.
if st.backendState ~= "" then
available = snapshot.installed
end
snapshot.available = available == true
snapshot.loading = false
snapshot.backendState = st.backendState or ""
snapshot.running = st.running == true
snapshot.hostname = st.hostname or ""
snapshot.dnsName = st.dnsName or ""
snapshot.ipv4 = st.ipv4 or ""
snapshot.ipv6 = st.ipv6 or ""
snapshot.tailnet = st.tailnet or ""
snapshot.magicDNS = st.magicDNS or ""
snapshot.version = st.version or ""
snapshot.health = st.health or {}
snapshot.peers = st.peers or {}
snapshot.exitNodes = exits
snapshot.onlineCount = st.onlineCount or 0
snapshot.peerCount = st.peerCount or 0
snapshot.offlineCount = st.offlineCount or 0
snapshot.exitNode = exitLabel
snapshot.exitNodeOnline = st.exitNodeOnline == true
snapshot.shieldsUp = prefs.shieldsUp == true
snapshot.acceptRoutes = prefs.acceptRoutes == true
snapshot.runSSH = prefs.runSSH == true
snapshot.acceptDNS = prefs.acceptDNS == true
-- Prefer prefs (AdvertiseRoutes); fall back to status Self.ExitNodeOption.
if prefs.advertiseExitNode ~= nil then
snapshot.advertiseExitNode = prefs.advertiseExitNode == true
else
snapshot.advertiseExitNode = st.advertiseExitNode == true
end
snapshot.exitNodeAllowLAN = prefs.exitNodeAllowLAN == true
snapshot.operatorUser = prefs.operatorUser or ""
snapshot.error = available and "" or (errors[1] or "no data")
if available and type(st.health) == "table" and #st.health > 0 and not st.running then
-- keep health visible without treating as hard error when stopped intentionally
if snapshot.error == "" and st.backendState == "Stopped" then
snapshot.error = ""
end
end
snapshot.updatedAt = nowSec()
refreshPending = false
refreshStartedAt = 0
noctalia.setUpdateInterval(refreshIntervalMs())
updateRevision(table.concat({
snapshot.backendState,
snapshot.ipv4,
tostring(snapshot.onlineCount),
tostring(snapshot.peerCount),
snapshot.exitNode,
asString(snapshot.shieldsUp),
asString(snapshot.runSSH),
asString(snapshot.advertiseExitNode),
}, "|"))
publishSnapshot()
end
refreshAll = function()
if refreshPending and refreshStartedAt > 0 and (nowSec() - refreshStartedAt) >= STUCK_SEC then
forceUnstick("refresh timed out")
end
if refreshPending then
refreshAgain = true
return
end
refreshPending = true
refreshAgain = false
refreshStartedAt = nowSec()
refreshGeneration += 1
local generation = refreshGeneration
local bin = tsBin()
snapshot.installed = noctalia.commandExists(bin) or noctalia.commandExists("tailscale")
if not snapshot.installed then
snapshot.available = false
snapshot.loading = false
snapshot.error = noctalia.tr("result.missing")
snapshot.peers = {}
snapshot.exitNodes = {}
snapshot.onlineCount = 0
snapshot.peerCount = 0
refreshPending = false
refreshStartedAt = 0
updateRevision("missing")
publishSnapshot()
return
end
if not snapshot.available then
snapshot.loading = true
publishSnapshot()
end
noctalia.setUpdateInterval(1000)
local pending = 3
local bag = { status = nil, prefs = nil, exits = "" }
local errors = {}
local finished = false
local function finish()
if generation ~= refreshGeneration then
return
end
pending -= 1
if pending > 0 or finished then
return
end
finished = true
local okApply, errApply = pcall(applyBag, bag.status, bag.prefs, bag.exits, errors)
if not okApply then
noctalia.log(`tailscale: apply failed: {tostring(errApply)}`)
snapshot.loading = false
snapshot.error = "refresh failed: " .. tostring(errApply)
refreshPending = false
refreshStartedAt = 0
noctalia.setUpdateInterval(refreshIntervalMs())
publishSnapshot()
end
if refreshAgain then
refreshAgain = false
refreshAll()
end
end
-- status --json (slim: only decode once; peer count is usually small)
runTs({ bin, "status", "--json" }, function(result)
if generation ~= refreshGeneration then
return
end
local okInner, errInner = pcall(function()
if not result or result.exitCode ~= 0 then
local err = trim(result and (result.stderr ~= "" and result.stderr or result.stdout) or "status failed")
table.insert(errors, err ~= "" and err or "status failed")
return
end
local data = noctalia.json.decode(result.stdout or "")
if data == nil then
table.insert(errors, "invalid status JSON")
return
end
bag.status = data
end)
if not okInner then
table.insert(errors, "status: " .. tostring(errInner))
end
finish()
end, 20000)
-- prefs without secrets (jq projects safe fields only)
local prefsCmd = shellCommand({ bin, "debug", "prefs" })
.. " | jq -c '{WantRunning,ShieldsUp,RunSSH,RouteAll,ExitNodeID,ExitNodeAllowLANAccess,CorpDNS,AdvertiseRoutes,OperatorUser,AdvertiseTags}'"
noctalia.runAsync(prefsCmd, function(result)
if generation ~= refreshGeneration then
return
end
local okInner, errInner = pcall(function()
if result and result.exitCode == 0 and trim(result.stdout) ~= "" then
bag.prefs = noctalia.json.decode(result.stdout)
end
end)
if not okInner then
table.insert(errors, "prefs: " .. tostring(errInner))
end
finish()
end, 15000)
runTs({ bin, "exit-node", "list" }, function(result)
if generation ~= refreshGeneration then
return
end
local okInner, errInner = pcall(function()
if result and result.exitCode == 0 then
bag.exits = result.stdout or ""
end
end)
if not okInner then
table.insert(errors, "exits: " .. tostring(errInner))
end
finish()
end, 15000)
end
local function finishAction(command, ok, message)
actionBusy = false
actionResult(command, ok, message)
if ok then
notifyOk(message)
else
notifyErr(message)
end
publishSnapshot()
refreshAll()
end
local function runAction(command, args, okMsg, failPrefix)
if actionBusy then
actionResult(command, false, noctalia.tr("result.busy"))
return
end
if not snapshot.installed then
actionResult(command, false, noctalia.tr("result.missing"))
return
end
actionBusy = true
publishSnapshot()
local bin = tsBin()
local full = { bin }
for _, a in ipairs(args) do
table.insert(full, a)
end
runTs(full, function(result)
local ok = result and result.exitCode == 0
if ok then
finishAction(command, true, okMsg)
else
local err = trim(result and (result.stderr ~= "" and result.stderr or result.stdout) or failPrefix)
if err == "" then
err = failPrefix
end
finishAction(command, false, noctalia.tr("result.failed", { error = err }))
end
end, 60000)
end
local function setBoolFlag(command, flag, enabled, okMsg)
-- tailscale set --flag / --flag=false (explicit false required to turn off)
local arg
if enabled then
arg = "--" .. flag .. "=true"
else
arg = "--" .. flag .. "=false"
end
runAction(command, { "set", arg }, okMsg, "set failed")
end
local function openAdmin()
local url = trim(noctalia.getConfig("admin_url"))
if url == "" then
url = "https://login.tailscale.com/admin/machines"
end
noctalia.runAsync("xdg-open " .. shellQuote(url))
end
local function executeAction(command)
if type(command) ~= "table" or type(command.action) ~= "string" then
return
end
local action = command.action
if action == "refresh" then
refreshAll()
return
end
if action == "up" then
runAction(command, { "up" }, noctalia.tr("result.up"), "up failed")
return
end
if action == "down" then
runAction(command, { "down" }, noctalia.tr("result.down"), "down failed")
return
end
if action == "set_exit_node" then
local node = trim(command.node or command.ip or command.id)
if node == "" then
actionResult(command, false, noctalia.tr("result.failed", { error = "missing exit node" }))
return
end
runAction(command, { "set", "--exit-node=" .. node }, noctalia.tr("result.exit_set", { name = node }), "exit node failed")
return
end
if action == "clear_exit_node" then
runAction(command, { "set", "--exit-node=" }, noctalia.tr("result.exit_cleared"), "clear exit failed")
return
end
if action == "set_shields" then
local on = command.enabled == true or command.enabled == "true"
setBoolFlag(command, "shields-up", on, on and noctalia.tr("result.shields_on") or noctalia.tr("result.shields_off"))
return
end
if action == "set_ssh" then
local on = command.enabled == true or command.enabled == "true"
setBoolFlag(command, "ssh", on, on and noctalia.tr("result.ssh_on") or noctalia.tr("result.ssh_off"))
return
end
if action == "set_accept_routes" then
local on = command.enabled == true or command.enabled == "true"
setBoolFlag(command, "accept-routes", on, on and noctalia.tr("result.routes_on") or noctalia.tr("result.routes_off"))
return
end
if action == "set_advertise_exit" then
-- Accept bool, string "true"/"false", or missing (treat as toggle off only when false).
local on = command.enabled == true or command.enabled == "true" or command.enabled == 1
if command.enabled == false or command.enabled == "false" or command.enabled == 0 then
on = false
end
setBoolFlag(
command,
"advertise-exit-node",
on,
on and noctalia.tr("result.advertise_on") or noctalia.tr("result.advertise_off")
)
return
end
if action == "set_allow_lan" then
local on = command.enabled == true or command.enabled == "true"
setBoolFlag(command, "exit-node-allow-lan-access", on, on and noctalia.tr("result.lan_on") or noctalia.tr("result.lan_off"))
return
end
if action == "ping" then
local host = trim(command.host or command.name or command.ip)
if host == "" then
actionResult(command, false, noctalia.tr("result.failed", { error = "missing host" }))
return
end
local bin = tsBin()
local cmd = shellCommand({ bin, "ping", "-c", "3", host })
if type(noctalia.runInTerminal) == "function" then
noctalia.runInTerminal(cmd)
else
noctalia.runAsync(cmd)
end
actionResult(command, true, noctalia.tr("result.ping_started", { name = host }))
notifyOk(noctalia.tr("result.ping_started", { name = host }))
return
end
if action == "ssh" then
local host = trim(command.host or command.name or command.dnsName)
if host == "" then
actionResult(command, false, noctalia.tr("result.failed", { error = "missing host" }))
return
end
local user = trim(command.user or noctalia.getConfig("ssh_user"))
local target = user ~= "" and (user .. "@" .. host) or host
local bin = tsBin()
local cmd = shellCommand({ bin, "ssh", target })
if type(noctalia.runInTerminal) == "function" then
noctalia.runInTerminal(cmd)
else
noctalia.runAsync(cmd)
end
actionResult(command, true, noctalia.tr("result.ssh_started", { name = target }))
notifyOk(noctalia.tr("result.ssh_started", { name = target }))
return
end
if action == "copy" then
local text = trim(command.text or command.name)
if text ~= "" then
noctalia.copyToClipboard(text, "text/plain")
actionResult(command, true, noctalia.tr("result.copied", { name = text }))
notifyOk(noctalia.tr("result.copied", { name = text }))
end
return
end
if action == "open_admin" then
openAdmin()
actionResult(command, true, noctalia.tr("result.admin_opened"))
return
end
actionResult(command, false, "Unknown action: " .. action)
end
noctalia.state.watch(COMMAND_KEY, executeAction)
noctalia.setUpdateInterval(refreshIntervalMs())
refreshAll()
function update()
refreshAll()
end
function onConfigChanged()
noctalia.setUpdateInterval(refreshIntervalMs())
refreshPending = false
refreshStartedAt = 0
refreshAll()
end
function onIpc(event, payload)
if event == "refresh" then
refreshPending = false
refreshStartedAt = 0
refreshAll()
elseif event == "up" then
executeAction({ action = "up", requestId = "ipc-up" })
elseif event == "down" then
executeAction({ action = "down", requestId = "ipc-down" })
elseif event == "toggle" then
if snapshot.running then
executeAction({ action = "down", requestId = "ipc-toggle" })
else
executeAction({ action = "up", requestId = "ipc-toggle" })
end
elseif type(payload) == "table" and type(payload.action) == "string" then
executeAction(payload)
end
end