Tailscale status, peers, exit nodes, and preference toggles for Noctalia v5.
858 lines
24 KiB
Luau
858 lines
24 KiB
Luau
--!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
|