Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion tailscale/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion tailscale/plugin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

id = "davemhammer/tailscale"
name = "Tailscale"
version = "1.0.6"
version = "1.0.7"
plugin_api = 10
author = "davemhammer"
license = "MIT"
Expand Down
68 changes: 56 additions & 12 deletions tailscale/service.luau
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -96,14 +99,24 @@ 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(
(.value.ExitNodeOption != true)
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
Expand Down Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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))
Expand All @@ -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
Expand Down