From 6b0dd5264cbc2ea72685325ef3d13b6916cf5f6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Bonada?= Date: Sat, 22 Aug 2026 12:35:31 -0400 Subject: [PATCH 1/2] fix(tailscale): project status JSON fields before Luau decode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the Mullvad Peer filter, leftover objects still carry unused fields (sshHostKeys, CapMap, …). Keep the same filter, then pick only the fields parseStatus reads. Skip state.set when nothing changed. --- tailscale/README.md | 2 +- tailscale/plugin.toml | 2 +- tailscale/service.luau | 17 +++++++++++++++-- 3 files changed, 17 insertions(+), 4 deletions(-) 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..e1f9851c 100644 --- a/tailscale/service.luau +++ b/tailscale/service.luau @@ -96,7 +96,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 +105,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, RxBytes, TxBytes, LastSeen + })) + } ]] return shellCommand({ bin, "status", "--json" }) .. " | jq -c " .. shellQuote(jq) end @@ -518,6 +528,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 +539,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() From f75a9290318cfc8a0f8ae6efe58e1519b6882b5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Bonada?= Date: Sun, 23 Aug 2026 09:42:17 -0400 Subject: [PATCH 2/2] fix(tailscale): skip apply when status jq output is unchanged Rx/Tx counters in the field projection changed every poll under traffic and forced a full decode even though the panel never shows them. Drop those fields and reuse the last parse when the raw command strings match. --- tailscale/service.luau | 53 +++++++++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/tailscale/service.luau b/tailscale/service.luau index e1f9851c..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) @@ -111,7 +114,7 @@ local function statusJsonCommand(bin) 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, RxBytes, TxBytes, LastSeen + ExitNode, ExitNodeOption, TailscaleIPs })) } ]] @@ -440,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 = {} @@ -582,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 @@ -595,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 @@ -622,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)) @@ -644,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