diff --git a/tailscale/README.md b/tailscale/README.md index a20f8369..03d9a6d5 100644 --- a/tailscale/README.md +++ b/tailscale/README.md @@ -61,7 +61,7 @@ noctalia msg plugin davemhammer/tailscale:service all toggle ## Notes -- Shells out to `tailscale status --json | jq …` (same Mullvad-exit filter as text `status`; those nodes stay on the Exit tab via `exit-node list`), `tailscale debug prefs | jq …` (safe field projection), `tailscale exit-node list`, `tailscale set …`, `tailscale up` / `down`, optional `tailscale ping` / `tailscale ssh` in a terminal, and `xdg-open` for the admin URL. A raw `--json` dump on a Mullvad-enabled tailnet is large enough to trip Noctalia's plugin CPU budget and auto-disable the service. +- Shells out to `tailscale status --json | jq …` (same Mullvad-exit filter as text `status`, then only the fields the service reads; unused Mullvad nodes stay on the Exit tab via `exit-node list`), `tailscale debug prefs | jq …` (safe field projection), `tailscale exit-node list`, `tailscale set …`, `tailscale up` / `down`, optional `tailscale ping` / `tailscale ssh` in a terminal, and `xdg-open` for the admin URL. A raw `--json` dump on a Mullvad-enabled tailnet is large enough to trip Noctalia's plugin CPU budget and auto-disable the service. - Advertise-exit state is read from prefs `AdvertiseRoutes` (`0.0.0.0/0` / `::/0`), not only `ExitNodeOption`. - Network: only through the Tailscale CLI/daemon (no separate HTTP client in the plugin). - Filesystem: no plugin-written credentials; uses local Tailscale state via the CLI. diff --git a/tailscale/plugin.toml b/tailscale/plugin.toml index ea2b56fa..2f32352d 100644 --- a/tailscale/plugin.toml +++ b/tailscale/plugin.toml @@ -2,7 +2,7 @@ id = "davemhammer/tailscale" name = "Tailscale" -version = "1.0.6" +version = "1.0.7" plugin_api = 10 author = "davemhammer" license = "MIT" diff --git a/tailscale/service.luau b/tailscale/service.luau index f8350916..243dbcc2 100644 --- a/tailscale/service.luau +++ b/tailscale/service.luau @@ -48,6 +48,9 @@ local refreshAgain = false local refreshStartedAt = 0 local actionBusy = false local dataSignature = "" +local lastStatusRaw = "" +local lastPrefsRaw = "" +local lastExitsRaw = "" local prevOnline = {} -- id -> true local function trim(value) @@ -96,7 +99,8 @@ end -- Same rule as `tailscale status` (text): unused Mullvad exits belong on -- `exit-node list`, not in Peer. `--json` does not apply this, and the raw --- dump is large enough to trip Noctalia's 25ms plugin CPU budget. +-- dump is large enough to trip Noctalia's 25ms plugin CPU budget. After the +-- Peer filter, drop unused fields (sshHostKeys, CapMap, …) so decode stays small. local function statusJsonCommand(bin) local jq = [[ .Peer |= with_entries(select( @@ -104,6 +108,15 @@ local function statusJsonCommand(bin) or (.value.ExitNode == true) or ((.value.DNSName // "") | endswith("mullvad.ts.net.") | not) )) + | { + BackendState, Version, TailscaleIPs, MagicDNSSuffix, Health, + CurrentTailnet, ExitNodeStatus, + Self: ((.Self // {}) | {ID, HostName, DNSName, Online, OS, Relay, ExitNodeOption, TailscaleIPs}), + Peer: ((.Peer // {}) | with_entries(.value |= { + ID, HostName, DNSName, Online, Active, OS, Relay, + ExitNode, ExitNodeOption, TailscaleIPs + })) + } ]] return shellCommand({ bin, "status", "--json" }) .. " | jq -c " .. shellQuote(jq) end @@ -430,7 +443,40 @@ local function forceUnstick(reason) publishSnapshot() end -local function applyBag(statusData, prefsData, exitStdout, errors) +local function applyBag(statusRaw, prefsRaw, exitStdout, errors) + statusRaw = statusRaw or "" + prefsRaw = prefsRaw or "" + exitStdout = exitStdout or "" + -- Traffic counters were in the JSON and changed every poll; the panel + -- does not show them. Same raw strings => skip decode/parse/state.set. + if statusRaw == lastStatusRaw and prefsRaw == lastPrefsRaw and exitStdout == lastExitsRaw then + snapshot.loading = false + refreshPending = false + refreshStartedAt = 0 + noctalia.setUpdateInterval(refreshIntervalMs()) + if snapshot.error ~= "" then + snapshot.error = "" + publishSnapshot() + end + return + end + + local statusData = nil + if statusRaw ~= "" then + statusData = noctalia.json.decode(statusRaw) + if statusData == nil then + table.insert(errors, "invalid status JSON") + end + end + local prefsData = nil + if prefsRaw ~= "" then + prefsData = noctalia.json.decode(prefsRaw) + end + + lastStatusRaw = statusRaw + lastPrefsRaw = prefsRaw + lastExitsRaw = exitStdout + local st = {} local prefs = {} local exits = {} @@ -518,6 +564,7 @@ local function applyBag(statusData, prefsData, exitStdout, errors) refreshStartedAt = 0 noctalia.setUpdateInterval(refreshIntervalMs()) + local prevRev = snapshot.revision updateRevision(table.concat({ snapshot.backendState, snapshot.ipv4, @@ -528,7 +575,9 @@ local function applyBag(statusData, prefsData, exitStdout, errors) asString(snapshot.runSSH), asString(snapshot.advertiseExitNode), }, "|")) - publishSnapshot() + if snapshot.revision ~= prevRev then + publishSnapshot() + end end refreshAll = function() @@ -569,7 +618,7 @@ refreshAll = function() noctalia.setUpdateInterval(1000) local pending = 3 - local bag = { status = nil, prefs = nil, exits = "" } + local bag = { statusRaw = "", prefsRaw = "", exits = "" } local errors = {} local finished = false @@ -582,7 +631,7 @@ refreshAll = function() return end finished = true - local okApply, errApply = pcall(applyBag, bag.status, bag.prefs, bag.exits, errors) + local okApply, errApply = pcall(applyBag, bag.statusRaw, bag.prefsRaw, bag.exits, errors) if not okApply then noctalia.log(`tailscale: apply failed: {tostring(errApply)}`) snapshot.loading = false @@ -609,12 +658,7 @@ refreshAll = function() 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 + bag.statusRaw = result.stdout or "" end) if not okInner then table.insert(errors, "status: " .. tostring(errInner)) @@ -631,7 +675,7 @@ refreshAll = function() end local okInner, errInner = pcall(function() if result and result.exitCode == 0 and trim(result.stdout) ~= "" then - bag.prefs = noctalia.json.decode(result.stdout) + bag.prefsRaw = result.stdout end end) if not okInner then