From 1abe67bd4b85c339f4c71dad0dfa07f74f99ae29 Mon Sep 17 00:00:00 2001 From: Dave Hammer Date: Sat, 8 Aug 2026 20:22:15 -0400 Subject: [PATCH] Add davemhammer/tailscale (#307) Tailscale status, peers, exit nodes, and preference toggles for Noctalia v5. --- tailscale/README.md | 68 +++ tailscale/assets/tailscale-green.png | Bin 0 -> 937 bytes tailscale/assets/tailscale-green.svg | 1 + tailscale/assets/tailscale-grey.png | Bin 0 -> 918 bytes tailscale/assets/tailscale-grey.svg | 1 + tailscale/assets/tailscale-white.png | Bin 0 -> 753 bytes tailscale/assets/tailscale-white.svg | 1 + tailscale/assets/tailscale.svg | 1 + tailscale/launcher.luau | 415 +++++++++++++ tailscale/panel.luau | 789 ++++++++++++++++++++++++ tailscale/plugin.toml | 86 +++ tailscale/service.luau | 857 +++++++++++++++++++++++++++ tailscale/thumbnail.webp | Bin 0 -> 49530 bytes tailscale/translations/en.json | 165 ++++++ tailscale/widget.luau | 111 ++++ 15 files changed, 2495 insertions(+) create mode 100644 tailscale/README.md create mode 100644 tailscale/assets/tailscale-green.png create mode 100644 tailscale/assets/tailscale-green.svg create mode 100644 tailscale/assets/tailscale-grey.png create mode 100644 tailscale/assets/tailscale-grey.svg create mode 100644 tailscale/assets/tailscale-white.png create mode 100644 tailscale/assets/tailscale-white.svg create mode 100644 tailscale/assets/tailscale.svg create mode 100644 tailscale/launcher.luau create mode 100644 tailscale/panel.luau create mode 100644 tailscale/plugin.toml create mode 100644 tailscale/service.luau create mode 100644 tailscale/thumbnail.webp create mode 100644 tailscale/translations/en.json create mode 100644 tailscale/widget.luau diff --git a/tailscale/README.md b/tailscale/README.md new file mode 100644 index 0000000..0384602 --- /dev/null +++ b/tailscale/README.md @@ -0,0 +1,68 @@ +# Tailscale + +Manage Tailscale connection state, peers, exit nodes, and preference toggles from Noctalia. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `davemhammer/tailscale` | +| Entries | Bar widget: `status`; panel: `manager`; service: `service`; launcher: `ts` | +| Launcher Prefix | `/ts` | + +## Requirements + +Install these on `PATH` (declared in `plugin.toml` `dependencies`): + +- `tailscale` — status, prefs, up/down, set, ping, ssh (requires a running `tailscaled`) +- `jq` — slim prefs extract from `tailscale debug prefs` +- `xdg-open` — open the admin console URL + +You need permission to operate the daemon (operator user or equivalent). + +## Usage + +Add the **status** bar widget (`davemhammer/tailscale:status`). Click for the panel. + +Panel tabs: + +- **Status** — connect/disconnect, shields, SSH, accept routes, advertise exit, allow LAN +- **Peers** — ping, SSH, copy IP/DNS, use as exit if offered +- **Exit nodes** — select or clear an exit node + +Toggle chips use a fixed label and highlight when the setting is **on**. + +Launcher: `/ts`, `/ts peers`, `/ts exit`, `/ts up`, `/ts down`. + +```sh +noctalia msg panel-toggle davemhammer/tailscale:manager +``` + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `refresh_interval` | `int` | `10` | Status poll interval in seconds. | +| `notify_on_peer_change` | `bool` | `true` | Notify when a peer goes online/offline. | +| `tailscale_bin` | `string` | `tailscale` | CLI path. | +| `admin_url` | `string` | _(empty)_ | Override admin console URL (default login.tailscale.com admin). | +| `ssh_user` | `string` | _(empty)_ | Default user for `tailscale ssh`. | +| `show_counts` | `bool` (widget) | `true` | Show online/total peers on the bar. | + +## IPC + +```sh +noctalia msg panel-toggle davemhammer/tailscale:manager +noctalia msg plugin davemhammer/tailscale:service all refresh +noctalia msg plugin davemhammer/tailscale:service all up +noctalia msg plugin davemhammer/tailscale:service all down +noctalia msg plugin davemhammer/tailscale:service all toggle +``` + +## Notes + +- Shells out to `tailscale status --json`, `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. +- 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. +- Brand mark assets are bundled under `assets/` (Simple Icons style 3×3 dots). diff --git a/tailscale/assets/tailscale-green.png b/tailscale/assets/tailscale-green.png new file mode 100644 index 0000000000000000000000000000000000000000..1f49af7f99cc4cc5c041d26f152b178b5e5a2b5a GIT binary patch literal 937 zcmeAS@N?(olHy`uVBq!ia0y~yU~m9o4mJh`hE0&Te@gD+$?NtIQ-BnFIeC{xN7B z%TQqx7HN+G|$c}mEJJ>S<(lX+FU%>1q8dE3l)7v^17 zUXz@$YJYgl&!!GJ#-7SF*C3G`DiUojUKeKS7l{_FUMSd_3v+ibJjmey;LN zpI7IXTTh+Lb+@B$>-JiCy>DmqmTR6_6|Z%2ZMSTHBlCf!L3*=XF5LGESR(M$Px}s2 z+O6_B>u1qRQui}w#J<_VCL<6or}1=gC+|syroJbOe?PS_X82MYnWgk?wbMW82 zdcyme&rSPuvE_W@;lOWeR@~9;ekXs{_k5mM_TBd}r%zQTteeuURy&DtUE1>ZoM)`3 ze)imW?JwU{#|H=H*ZbSocOnGtt&B zq^@$(`Au)i52TB}_0kTQs`p^B`-;r{&Rn7aTzWrUf z!}EBKJ-@z9`^&fgUbQZS_2Z8?)B1gTQ};WY?ED_b+^Bxiaob-%m*b5)`u;?OzEhD) zy0hO*+++6KZTb0E<88$&-p$uN|LVo<*Yv}{ipt)+j-VA@3>8>^b)G#7hEi6{QR4#cOLhQd*T^4ViI=0 z;lAQs{Dyl;Y4}B%-%OczHH+WAhx58uFS7i`x?*$w{gB^G`;LZY9II_~JI}i9{62;W jD+R^cYa4aH{b%vpBDlEwPQxn(1_lOCS3j3^P6Tailscale \ No newline at end of file diff --git a/tailscale/assets/tailscale-grey.png b/tailscale/assets/tailscale-grey.png new file mode 100644 index 0000000000000000000000000000000000000000..31b6071768ae28d4e9942ec294a861aa2376f676 GIT binary patch literal 918 zcmeAS@N?(olHy`uVBq!ia0y~yU~m9o4mJh`hEa~gn-B7qQ>$daL zY1MaTvM45s8!ho%W8@`VFh#**m5M9ZZH|?O*ZyyiF)M$3sQlT4(p%eS&ykpWKV0_G zneTF^#g_lfY*9{$G}BiqcX`5{upLI7d zz3Du$ZPh#GjuUfM-D7yx&|W$3zW3!Xp)OA2@3F^=&oU@2@|^VN#_O-^n8LHR-m0B{I`X^s zwA9b%cGfpObzA)L#`6PVtGBLMb^m+S-qWjBuRia$YU{i5b31Pzh?}GmZFiR8Yt>)& zg#Gucm9G8e+rjszny|Y+?LPyZTT-TO`ZDx35;Cx zca`pE=5tNiw#Feey>YtS(q(`7eu(afe5Sc9^HqcN-+M|vzvp)3FcrTot3DZSU~5~; zz2m{0+d3e-|Gq!QANtjCTIy%lvund%PwH5+`jy+>xcaz|{JZZNKH2PN|MPav@69)J z?peJn^}2k;boSZxpJNHWdzZmnt`26R(^%mRBpUa3{>#jdhig zzopr0Qzy!4gdfE literal 0 HcmV?d00001 diff --git a/tailscale/assets/tailscale-grey.svg b/tailscale/assets/tailscale-grey.svg new file mode 100644 index 0000000..f5ebc74 --- /dev/null +++ b/tailscale/assets/tailscale-grey.svg @@ -0,0 +1 @@ +Tailscale \ No newline at end of file diff --git a/tailscale/assets/tailscale-white.png b/tailscale/assets/tailscale-white.png new file mode 100644 index 0000000000000000000000000000000000000000..495a38440082e5a65f5eb92b70968598399198e7 GIT binary patch literal 753 zcmeAS@N?(olHy`uVBq!ia0y~yU~m9o4mJh`hEZL<$T z5i{2LADwOQ!!u{<7pF_gp7E-=Ucbcb_vyS0tEp9g>B;f1{Lzs=zZxSSaDNbvU}{>% zz_fwqOXK+iKBu0<{BGP{_+evvg3Xa%2iy*^{QdPp0@)e@` zd}6%K>yO_0+9Lbw8?*6&s9PCSdo9 z2}`sm<$3K=dAW7Ta>W+=Yn)}L_|GtB9}qL>xGlCJ>!+`j+O5p`r9mr0w5B#4jLfUg zs=Jl(lQ;K3g~Z_<9}L{D&HZ3}?&{hn&%W9H-LkVvYvs`*AqPTkdzl*hv zF>7}2jsE{)pM#z}uj#(OJ;h(=T=wwFdw<=(x^jkBR^I#T=PkmwPnv!E_gD9mZ0_=Y z&bOvct1O!P`SQm*|Caon>IHHw7u))se?5O&&RG4PLHoep9cwSxMc~ENQ`MvvtgmwG4ZIbuQsNj3^P;vYA kpP!%mA20qL^51?==n3Ze^XEr0FfcH9y85}Sb4q9e0JlVV_W%F@ literal 0 HcmV?d00001 diff --git a/tailscale/assets/tailscale-white.svg b/tailscale/assets/tailscale-white.svg new file mode 100644 index 0000000..f4395dc --- /dev/null +++ b/tailscale/assets/tailscale-white.svg @@ -0,0 +1 @@ +Tailscale \ No newline at end of file diff --git a/tailscale/assets/tailscale.svg b/tailscale/assets/tailscale.svg new file mode 100644 index 0000000..9a05aa6 --- /dev/null +++ b/tailscale/assets/tailscale.svg @@ -0,0 +1 @@ +Tailscale \ No newline at end of file diff --git a/tailscale/launcher.luau b/tailscale/launcher.luau new file mode 100644 index 0000000..216e77d --- /dev/null +++ b/tailscale/launcher.luau @@ -0,0 +1,415 @@ +--!nonstrict +-- /ts launcher: Tailscale peers, exit nodes, connect/disconnect. + +local STATE_KEY = "ts_snapshot" +local COMMAND_KEY = "ts_command" +local PANEL_ID = "davemhammer/tailscale:manager" +local MAX_ROWS = 40 + +local snapshot = noctalia.state.get(STATE_KEY) or { + available = false, + loading = true, + running = false, + installed = false, + peers = {}, + exitNodes = {}, + onlineCount = 0, + peerCount = 0, + backendState = "", + exitNode = "", + hostname = "", + error = "", +} + +noctalia.state.watch(STATE_KEY, function(value) + if type(value) == "table" then + snapshot = value + end +end) + +local function trim(s) + return noctalia.string.trim(tostring(s or "")) +end + +local function lower(s) + return string.lower(tostring(s or "")) +end + +local function send(action, values) + local command = { action = action, requestId = "launcher-" .. tostring(os.time()) } + if type(values) == "table" then + for k, v in pairs(values) do + command[k] = v + end + end + noctalia.state.set(COMMAND_KEY, command) +end + +local function scoreText(filter, ...) + if filter == "" then + return 1 + end + local best = nil + for i = 1, select("#", ...) do + local text = tostring(select(i, ...) or "") + if text ~= "" then + local s = noctalia.fuzzyScore(filter, text) + if s ~= nil and (best == nil or s > best) then + best = s + end + if best == nil and lower(text):find(lower(filter), 1, true) then + best = 0.5 + end + end + end + return best +end + +local function statusRow(title, subtitle, glyph) + return { + id = "", + title = title, + subtitle = subtitle, + glyph = glyph or "circles", + } +end + +local function ensureSnapshot() + if snapshot.loading or not snapshot.available then + send("refresh") + end +end + +local function topCategories() + local state = snapshot.backendState ~= "" and snapshot.backendState or "—" + local summary = `{state} · {snapshot.onlineCount or 0}/{snapshot.peerCount or 0} online` + if snapshot.exitNode and snapshot.exitNode ~= "" then + summary = summary .. " · exit " .. snapshot.exitNode + end + return { + { + id = "cat:peers", + title = noctalia.tr("launcher.cat.peers"), + subtitle = noctalia.tr("launcher.cat.peers-sub"), + glyph = "device-desktop", + score = 100, + }, + { + id = "cat:exit", + title = noctalia.tr("launcher.cat.exit"), + subtitle = noctalia.tr("launcher.cat.exit-sub"), + glyph = "world", + score = 95, + }, + { + id = "act:status", + title = noctalia.tr("launcher.cat.status"), + subtitle = summary, + glyph = "info-circle", + score = 90, + }, + { + id = "act:up", + title = noctalia.tr("launcher.cat.up"), + subtitle = noctalia.tr("launcher.cat.up-sub"), + glyph = "player-play", + score = 85, + }, + { + id = "act:down", + title = noctalia.tr("launcher.cat.down"), + subtitle = noctalia.tr("launcher.cat.down-sub"), + glyph = "player-stop", + score = 80, + }, + { + id = "act:panel", + title = noctalia.tr("launcher.cat.panel"), + subtitle = noctalia.tr("launcher.cat.panel-sub"), + glyph = "layout-dashboard", + score = 70, + }, + { + id = "act:admin", + title = noctalia.tr("launcher.cat.admin"), + subtitle = noctalia.tr("launcher.cat.admin-sub"), + glyph = "external-link", + score = 60, + }, + { + id = "act:refresh", + title = noctalia.tr("launcher.cat.refresh"), + subtitle = noctalia.tr("launcher.cat.refresh-sub"), + glyph = "refresh", + score = 50, + }, + } +end + +local function peerActions(peer) + local ref = peer.id + return { + { + id = "peeract:ping:" .. ref, + title = noctalia.tr("launcher.action.ping"), + subtitle = peer.name .. " · " .. (peer.ipv4 ~= "" and peer.ipv4 or peer.dnsName), + glyph = "activity", + score = 100, + }, + { + id = "peeract:ssh:" .. ref, + title = noctalia.tr("launcher.action.ssh"), + subtitle = peer.dnsName ~= "" and peer.dnsName or peer.name, + glyph = "terminal-2", + score = 95, + }, + { + id = "peeract:copyip:" .. ref, + title = noctalia.tr("launcher.action.copy_ip"), + subtitle = peer.ipv4, + glyph = "copy", + score = 90, + }, + { + id = "peeract:copydns:" .. ref, + title = noctalia.tr("launcher.action.copy_dns"), + subtitle = peer.dnsName, + glyph = "copy", + score = 85, + }, + } +end + +local function findPeer(id) + for _, p in ipairs(snapshot.peers or {}) do + if p.id == id then return p end + end + return nil +end + +local function findExit(id) + for _, e in ipairs(snapshot.exitNodes or {}) do + if e.id == id then return e end + end + return nil +end + +local function listPeers(filter) + local rows = {} + for _, p in ipairs(snapshot.peers or {}) do + local sc = scoreText(filter, p.name, p.hostName, p.dnsName, p.ipv4, p.os) + if sc then + table.insert(rows, { + id = "peer:" .. p.id, + title = p.name, + subtitle = (p.online and "online" or "offline") + .. (p.ipv4 ~= "" and (" · " .. p.ipv4) or "") + .. (p.exitNodeOption and " · exit" or ""), + glyph = p.online and "device-desktop" or "device-desktop-off", + score = sc + (p.online and 10 or 0), + }) + end + end + table.sort(rows, function(a, b) + return (a.score or 0) > (b.score or 0) + end) + while #rows > MAX_ROWS do + table.remove(rows) + end + return rows +end + +local function listExits(filter) + local rows = { + { + id = "act:clear-exit", + title = noctalia.tr("launcher.action.clear_exit"), + subtitle = snapshot.exitNode ~= "" and snapshot.exitNode or "—", + glyph = "x", + score = 200, + }, + } + for _, e in ipairs(snapshot.exitNodes or {}) do + local sc = scoreText(filter, e.name, e.host, e.ip, e.status) + if sc then + table.insert(rows, { + id = "exit:" .. e.id, + title = e.name, + subtitle = e.ip .. (e.selected and " · selected" or "") .. (e.online and "" or " · offline"), + glyph = "world", + score = sc + (e.selected and 20 or 0), + }) + end + end + table.sort(rows, function(a, b) + return (a.score or 0) > (b.score or 0) + end) + return rows +end + +function search(query) + ensureSnapshot() + query = trim(query) + + if not snapshot.installed then + launcher.setResults(query, { statusRow(noctalia.tr("launcher.missing"), "", "alert-triangle") }) + return + end + + if snapshot.loading and not snapshot.available then + launcher.setResults(query, { statusRow(noctalia.tr("launcher.loading"), snapshot.hostname, "loader") }) + return + end + + local head, rest = query:match("^(%S+)%s*(.-)$") + head = lower(head or "") + rest = trim(rest or "") + + if head == "" then + launcher.setResults(query, topCategories()) + return + end + + if head == "peers" or head == "peer" or head == "p" then + -- peer actions if exact match id after peers + local peerId = rest:match("^id:(%S+)") or "" + if peerId ~= "" then + local peer = findPeer(peerId) + if peer then + launcher.setResults(query, peerActions(peer)) + return + end + end + -- if rest matches a single peer name exactly, show actions + if rest ~= "" then + local matches = {} + for _, p in ipairs(snapshot.peers or {}) do + if lower(p.name) == lower(rest) or lower(p.dnsName) == lower(rest) or p.ipv4 == rest then + table.insert(matches, p) + end + end + if #matches == 1 then + launcher.setResults(query, peerActions(matches[1])) + return + end + end + launcher.setResults(query, listPeers(rest)) + return + end + + if head == "exit" or head == "exits" or head == "e" then + launcher.setResults(query, listExits(rest)) + return + end + + if head == "status" or head == "up" or head == "down" or head == "panel" or head == "refresh" or head == "admin" then + local map = { + status = "act:status", + up = "act:up", + down = "act:down", + panel = "act:panel", + refresh = "act:refresh", + admin = "act:admin", + } + -- still show categories filtered + local rows = {} + for _, row in ipairs(topCategories()) do + if row.id == map[head] or scoreText(query, row.title, row.subtitle) then + table.insert(rows, row) + end + end + launcher.setResults(query, #rows > 0 and rows or topCategories()) + return + end + + -- free text: peers + categories + local rows = listPeers(query) + for _, row in ipairs(topCategories()) do + local sc = scoreText(query, row.title, row.subtitle) + if sc then + row.score = sc + table.insert(rows, row) + end + end + table.sort(rows, function(a, b) + return (a.score or 0) > (b.score or 0) + end) + while #rows > MAX_ROWS do + table.remove(rows) + end + launcher.setResults(query, rows) +end + +function activate(id) + if type(id) ~= "string" or id == "" then + return + end + + if id == "cat:peers" then + launcher.setQuery("peers ") + return + end + if id == "cat:exit" then + launcher.setQuery("exit ") + return + end + if id == "act:status" then + local state = snapshot.backendState or "—" + noctalia.notify(noctalia.tr("title"), `{state} · {snapshot.onlineCount or 0}/{snapshot.peerCount or 0} online · {snapshot.ipv4 or ""}`) + return + end + if id == "act:up" then + send("up") + return + end + if id == "act:down" then + send("down") + return + end + if id == "act:panel" then + noctalia.togglePanel(PANEL_ID) + return + end + if id == "act:admin" then + send("open_admin") + return + end + if id == "act:refresh" then + send("refresh") + return + end + if id == "act:clear-exit" then + send("clear_exit_node") + return + end + + local peerId = id:match("^peer:(.+)$") + if peerId then + launcher.setQuery("peers id:" .. peerId) + return + end + + local exitId = id:match("^exit:(.+)$") + if exitId then + local e = findExit(exitId) + if e then + send("set_exit_node", { node = e.ip }) + end + return + end + + local act, ref = id:match("^peeract:([^:]+):(.+)$") + if act and ref then + local peer = findPeer(ref) + if not peer then return end + if act == "ping" then + send("ping", { host = peer.ipv4 ~= "" and peer.ipv4 or peer.name }) + elseif act == "ssh" then + send("ssh", { host = peer.dnsName ~= "" and peer.dnsName or peer.name }) + elseif act == "copyip" then + send("copy", { text = peer.ipv4 }) + elseif act == "copydns" then + send("copy", { text = peer.dnsName }) + end + end +end diff --git a/tailscale/panel.luau b/tailscale/panel.luau new file mode 100644 index 0000000..1aa53c6 --- /dev/null +++ b/tailscale/panel.luau @@ -0,0 +1,789 @@ +--!nonstrict +-- Tailscale manager panel. + +local STATE_KEY = "ts_snapshot" +local COMMAND_KEY = "ts_command" +local RESULT_KEY = "ts_action_result" + +local snapshot = noctalia.state.get(STATE_KEY) or { + available = false, + configured = false, + loading = true, + busy = false, + installed = false, + backendState = "", + running = false, + hostname = "", + dnsName = "", + ipv4 = "", + ipv6 = "", + tailnet = "", + peers = {}, + exitNodes = {}, + onlineCount = 0, + peerCount = 0, + exitNode = "", + shieldsUp = false, + acceptRoutes = false, + runSSH = false, + acceptDNS = false, + advertiseExitNode = false, + exitNodeAllowLAN = false, + health = {}, + error = "", + updatedAt = 0, + revision = 0, +} + +local tab = "status" -- status | peers | exit +local selectedId = "" +local filterText = "" +local filterKey = 0 +local requestCounter = 0 +local feedback = "" +local feedbackError = false +local dirty = true + +local render + +local function tr(key, subst) + return noctalia.tr(key, subst) +end + +local function nextRequestId() + requestCounter += 1 + return `panel-{requestCounter}` +end + +local function send(action, values) + local command = { action = action, requestId = nextRequestId() } + if type(values) == "table" then + for k, v in pairs(values) do + command[k] = v + end + end + noctalia.state.set(COMMAND_KEY, command) + return command.requestId +end + +local function lower(s) + return string.lower(tostring(s or "")) +end + +local function haystackContains(needle, ...) + if needle == "" then return true end + for i = 1, select("#", ...) do + local part = lower(select(i, ...)) + if part ~= "" and part:find(needle, 1, true) then + return true + end + end + return false +end + +local function matchesFilter(...) + local q = noctalia.string.trim(filterText) + if q == "" then return true end + for raw in q:gmatch("%S+") do + local neg = false + local term = raw + if term:sub(1, 1) == "!" then + neg = true + term = term:sub(2) + end + term = lower(term) + if term ~= "" then + local hit = haystackContains(term, ...) + if neg then + if hit then return false end + else + if not hit then return false end + end + end + end + return true +end + +local function formatBytes(n) + n = tonumber(n) or 0 + if n >= 1e9 then return string.format("%.1fG", n / 1e9) end + if n >= 1e6 then return string.format("%.1fM", n / 1e6) end + if n >= 1e3 then return string.format("%.1fK", n / 1e3) end + return tostring(math.floor(n)) +end + +-- Connected = green; stopped = grey. Never primary (yellow) or error (red). +local COLOR_OK = "#73c936" +local COLOR_OFF = "#8a8a8a" + +local function statusColor(ok) + return (ok == true) and COLOR_OK or COLOR_OFF +end + +-- Title/status: official-style Tailscale brand mark (3×3 dots), green/grey by state. +local function statusIcon(running, size) + size = size or 24 + local ok = running == true + return ui.image({ + path = ok and "assets/tailscale-green.png" or "assets/tailscale-grey.png", + width = size, + height = size, + fit = "contain", + }) +end + +local function listButton(props) + props.contentAlign = "start" + props.controlSize = props.controlSize or "md" + return ui.button(props) +end + +-- Toggle chip: fixed label; primary when on, outline when off (no selected — +-- selected can collapse the control to glyph-only in dense toolbars). +local function stateToggle(props) + local on = props.on == true + local text = props.label + if type(text) ~= "string" or text == "" then + text = "—" + end + return ui.button({ + text = text, + glyph = props.glyph, + variant = on and "primary" or "outline", + enabled = props.enabled ~= false, + onClick = props.onClick, + }) +end + +local function selectedPeer() + if tab ~= "peers" then return nil end + for _, p in ipairs(snapshot.peers or {}) do + if p.id == selectedId then return p end + end + return nil +end + +local function selectedExit() + if tab ~= "exit" then return nil end + for _, e in ipairs(snapshot.exitNodes or {}) do + if e.id == selectedId then return e end + end + return nil +end + +local function emptyList(msg) + return ui.column({ + key = "empty-" .. tab, + align = "center", + justify = "center", + padding = 24, + gap = 8, + flexGrow = 1, + }, { + ui.glyph({ name = "search", size = 36, color = "on_surface_variant" }), + ui.label({ text = msg, color = "on_surface_variant", textAlign = "center" }), + }) +end + +local function itemColumn(rows) + return ui.column({ + key = "items-" .. tab, + align = "stretch", + justify = "start", + gap = 8, + flexGrow = 1, + }, rows) +end + +local function onOff(v) + return v and tr("status.on") or tr("status.off") +end + +local function statusRows() + local rows = {} + local items = { + { + id = "st-state", + glyph = snapshot.running and "network" or "network-off", + text = tr("status.state", { state = snapshot.backendState ~= "" and snapshot.backendState or "—" }), + ok = snapshot.running == true, + }, + { + id = "st-tailnet", + glyph = "world", + text = tr("status.tailnet", { name = snapshot.tailnet ~= "" and snapshot.tailnet or "—" }), + ok = true, + }, + { + id = "st-ips", + glyph = "network", + text = tr("status.ips", { + v4 = snapshot.ipv4 ~= "" and snapshot.ipv4 or "—", + v6 = snapshot.ipv6 ~= "" and snapshot.ipv6 or "", + }), + ok = true, + }, + { + id = "st-dns", + glyph = "world-www", + text = tr("status.dns", { dns = snapshot.dnsName ~= "" and snapshot.dnsName or "—" }), + ok = true, + }, + { + id = "st-exit", + glyph = "world", + text = tr("status.exit", { + name = (snapshot.exitNode ~= "" and snapshot.exitNode) or tr("status.none"), + }), + ok = snapshot.exitNode == "" or snapshot.exitNodeOnline, + }, + { + id = "st-prefs", + glyph = "settings", + text = tr("status.prefs", { + shields = onOff(snapshot.shieldsUp), + routes = onOff(snapshot.acceptRoutes), + ssh = onOff(snapshot.runSSH), + dns = onOff(snapshot.acceptDNS), + }), + ok = true, + }, + } + if snapshot.version and snapshot.version ~= "" then + table.insert(items, { + id = "st-ver", + glyph = "info-circle", + text = tr("status.version", { version = snapshot.version }), + ok = true, + }) + end + for _, h in ipairs(snapshot.health or {}) do + table.insert(items, { + id = "st-health-" .. tostring(#items), + glyph = "alert-triangle", + text = tr("panel.health", { msg = h }), + ok = false, + }) + end + + for _, item in ipairs(items) do + if matchesFilter(item.text) then + local selected = item.id == selectedId + table.insert(rows, listButton({ + key = item.id, + text = item.text, + glyph = item.glyph, + variant = selected and "primary" or "outline", + selected = selected, + onClick = function() + selectedId = item.id + feedback = "" + render() + end, + })) + end + end + return rows +end + +local function peerRows() + local rows = {} + for _, p in ipairs(snapshot.peers or {}) do + local onlineTag = p.online and "online" or "offline" + local exitTag = p.exitNodeOption and "exit" or "" + if matchesFilter(p.name, p.hostName, p.dnsName, p.ipv4, p.os, p.relay, onlineTag, exitTag) then + local selected = p.id == selectedId + local text = `{p.name} · {p.online and "online" or "offline"} · {p.ipv4}` + .. (p.exitNodeOption and " · exit" or "") + .. (p.active and " · active" or "") + table.insert(rows, listButton({ + key = "peer-" .. p.id, + text = text, + glyph = p.online and "device-desktop" or "device-desktop-off", + variant = selected and "primary" or "outline", + selected = selected, + onClick = function() + selectedId = p.id + feedback = "" + render() + end, + })) + end + end + return rows +end + +local function exitRows() + local rows = {} + for _, e in ipairs(snapshot.exitNodes or {}) do + if matchesFilter(e.name, e.host, e.ip, e.status) then + local selected = e.id == selectedId + local text = `{e.name} · {e.ip}` + .. (e.selected and " · selected" or "") + .. (e.online and "" or " · offline") + table.insert(rows, listButton({ + key = "exit-" .. e.id, + text = text, + glyph = e.selected and "world" or "world-off", + variant = selected and "primary" or "outline", + selected = selected, + onClick = function() + selectedId = e.id + feedback = "" + render() + end, + })) + end + end + return rows +end + +local function itemList() + local rows + if tab == "status" then + rows = statusRows() + elseif tab == "peers" then + rows = peerRows() + else + rows = exitRows() + end + if #rows == 0 then + return emptyList(tr("panel.empty")) + end + return itemColumn(rows) +end + +local function toolbar() + local busy = snapshot.busy == true + + if tab == "status" then + local up = snapshot.running == true + return ui.column({ gap = 6, padding = 10, fill = "surface_variant/0.45", radius = 10, align = "stretch" }, { + ui.row({ gap = 8, align = "center" }, { + statusIcon(up, 18), + ui.label({ + text = snapshot.hostname ~= "" and snapshot.hostname or "Tailscale", + fontWeight = "bold", + flexGrow = 1, + maxLines = 1, + }), + ui.label({ + text = snapshot.backendState ~= "" and snapshot.backendState or "—", + color = statusColor(up), + fontSize = 12, + }), + }), + ui.row({ gap = 6 }, { + ui.button({ + text = tr("actions.up"), + glyph = "player-play", + variant = "primary", + enabled = not busy and not snapshot.running, + onClick = "onUp", + }), + ui.button({ + text = tr("actions.down"), + glyph = "player-stop", + variant = "outline", + enabled = not busy and snapshot.running, + onClick = "onDown", + }), + stateToggle({ + on = snapshot.shieldsUp == true, + label = tr("actions.shields_on"), + glyph = "shield", + enabled = not busy, + onClick = "onToggleShields", + }), + stateToggle({ + on = snapshot.runSSH == true, + label = tr("actions.ssh_on"), + glyph = "terminal-2", + enabled = not busy, + onClick = "onToggleSSH", + }), + }), + ui.row({ gap = 6 }, { + stateToggle({ + on = snapshot.acceptRoutes == true, + label = tr("actions.routes_on"), + glyph = "route", + enabled = not busy, + onClick = "onToggleRoutes", + }), + stateToggle({ + on = snapshot.advertiseExitNode == true, + label = tr("actions.advertise_on"), + glyph = "world-upload", + enabled = not busy, + onClick = "onToggleAdvertise", + }), + stateToggle({ + on = snapshot.exitNodeAllowLAN == true, + label = tr("actions.lan_on"), + glyph = "home", + enabled = not busy, + onClick = "onToggleLAN", + }), + ui.button({ + text = tr("actions.copy_ip"), + glyph = "copy", + variant = "ghost", + enabled = snapshot.ipv4 ~= "", + onClick = "onCopySelfIp", + }), + }), + }) + end + + if tab == "peers" then + local p = selectedPeer() + if not p then + return ui.label({ text = tr("panel.select_hint"), color = "on_surface_variant" }) + end + return ui.column({ gap = 4, padding = 10, fill = "surface_variant/0.45", radius = 10, align = "stretch" }, { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ + name = p.online and "device-desktop" or "device-desktop-off", + size = 18, + color = statusColor(p.online), + }), + ui.label({ text = p.name, fontWeight = "bold", flexGrow = 1, maxLines = 1 }), + ui.label({ + text = p.online and "online" or "offline", + color = statusColor(p.online), + fontSize = 12, + }), + }), + ui.label({ + text = tr("peer.detail", { + os = p.os ~= "" and p.os or "—", + relay = p.relay ~= "" and p.relay or "—", + v4 = p.ipv4 ~= "" and p.ipv4 or "—", + }), + color = "on_surface_variant", + fontSize = 12, + maxLines = 2, + }), + ui.label({ + text = p.dnsName, + color = "on_surface_variant", + fontSize = 11, + visible = p.dnsName ~= "", + maxLines = 1, + }), + ui.row({ gap = 6 }, { + ui.button({ text = tr("actions.ping"), glyph = "activity", variant = "primary", enabled = not busy, onClick = "onPing" }), + ui.button({ text = tr("actions.ssh"), glyph = "terminal-2", variant = "outline", enabled = not busy, onClick = "onSSH" }), + ui.button({ text = tr("actions.copy_ip"), glyph = "copy", variant = "ghost", enabled = p.ipv4 ~= "", onClick = "onCopyPeerIp" }), + ui.button({ text = tr("actions.copy_dns"), glyph = "copy", variant = "ghost", enabled = p.dnsName ~= "", onClick = "onCopyPeerDns" }), + ui.button({ + text = tr("actions.use_exit"), + glyph = "world", + variant = "ghost", + enabled = not busy and p.exitNodeOption, + visible = p.exitNodeOption == true, + onClick = "onUsePeerExit", + }), + }), + }) + end + + -- exit nodes tab + local e = selectedExit() + if not e then + return ui.column({ gap = 6, padding = 10, fill = "surface_variant/0.45", radius = 10, align = "stretch" }, { + ui.label({ text = tr("panel.select_hint"), color = "on_surface_variant" }), + ui.row({ gap = 6 }, { + ui.button({ + text = tr("actions.clear_exit"), + glyph = "x", + variant = "outline", + enabled = not busy and snapshot.exitNode ~= "", + onClick = "onClearExit", + }), + }), + }) + end + return ui.column({ gap = 4, padding = 10, fill = "surface_variant/0.45", radius = 10, align = "stretch" }, { + ui.row({ gap = 8, align = "center" }, { + ui.glyph({ name = "world", size = 18, color = statusColor(e.online) }), + ui.label({ text = e.name, fontWeight = "bold", flexGrow = 1, maxLines = 1 }), + ui.label({ + text = e.selected and "selected" or (e.online and "available" or "offline"), + color = e.selected and "primary" or statusColor(e.online), + fontSize = 12, + }), + }), + ui.label({ + text = tr("exit.detail", { ip = e.ip, status = e.status ~= "" and e.status or "—" }), + color = "on_surface_variant", + fontSize = 12, + maxLines = 2, + }), + ui.row({ gap = 6 }, { + ui.button({ + text = tr("actions.use_exit"), + glyph = "world", + variant = "primary", + enabled = not busy and not e.selected, + onClick = "onUseExit", + }), + ui.button({ + text = tr("actions.clear_exit"), + glyph = "x", + variant = "outline", + enabled = not busy and (e.selected or snapshot.exitNode ~= ""), + onClick = "onClearExit", + }), + ui.button({ + text = tr("actions.copy_ip"), + glyph = "copy", + variant = "ghost", + onClick = "onCopyExitIp", + }), + }), + }) +end + +local function tabButton(label, id, cb) + return ui.button({ + text = label, + selected = tab == id, + variant = tab == id and "primary" or "ghost", + onClick = cb, + }) +end + +render = function() + dirty = false + local notes = {} + if not snapshot.installed then + table.insert(notes, ui.label({ text = tr("panel.not_installed"), color = "error", maxLines = 3 })) + end + if snapshot.loading then + table.insert(notes, ui.label({ text = tr("panel.loading"), color = "on_surface_variant" })) + end + if snapshot.busy then + table.insert(notes, ui.label({ text = tr("panel.busy"), color = "primary" })) + end + if type(snapshot.error) == "string" and snapshot.error ~= "" then + table.insert(notes, ui.label({ text = snapshot.error, color = "error", maxLines = 3 })) + end + if feedback ~= "" then + table.insert(notes, ui.label({ + text = feedback, + color = feedbackError and "error" or "tertiary", + maxLines = 2, + })) + end + + local exitLabel = snapshot.exitNode ~= "" and snapshot.exitNode or "—" + local summary = tr("panel.summary", { + state = snapshot.backendState ~= "" and snapshot.backendState or "—", + online = snapshot.onlineCount or 0, + total = snapshot.peerCount or 0, + exit = exitLabel, + }) + + -- Title row: large status indicator next to "Tailscale" + local titleUp = snapshot.running == true + panel.render(ui.column({ flexGrow = 1, gap = 10 }, { + ui.row({ align = "center", gap = 10 }, { + statusIcon(titleUp, 28), + ui.column({ flexGrow = 1, gap = 0 }, { + ui.label({ text = tr("title"), fontSize = 18, fontWeight = "bold" }), + ui.label({ + text = tr("panel.host", { + host = snapshot.hostname ~= "" and snapshot.hostname + or (snapshot.tailnet ~= "" and snapshot.tailnet or "—"), + }) .. (snapshot.ipv4 ~= "" and (` · {snapshot.ipv4}`) or ""), + fontSize = 11, + color = "on_surface_variant", + }), + }), + ui.button({ text = tr("actions.open_admin"), glyph = "external-link", variant = "outline", onClick = "onAdmin" }), + ui.button({ glyph = "refresh", variant = "ghost", onClick = "onRefresh" }), + ui.button({ glyph = "close", onClick = "onClose" }), + }), + + ui.row({ gap = 4, align = "center" }, { + tabButton(tr("tabs.status"), "status", "onTabStatus"), + tabButton(tr("tabs.peers"), "peers", "onTabPeers"), + tabButton(tr("tabs.exit"), "exit", "onTabExit"), + }), + + ui.label({ + text = summary, + color = "on_surface_variant", + fontSize = 11, + maxLines = 1, + }), + + ui.row({ gap = 8, align = "center" }, { + ui.input({ + key = `filter-{tab}-{filterKey}`, + value = filterText, + placeholder = tr("filter.placeholder"), + flexGrow = 1, + controlSize = "sm", + onChange = "onFilterChange", + }), + ui.button({ + glyph = "x", + variant = "ghost", + visible = filterText ~= "", + onClick = "onClearFilter", + }), + }), + + toolbar(), + ui.column({ gap = 3, align = "stretch" }, notes), + ui.scroll({ + key = "scroll-" .. tab, + flexGrow = 1, + gap = 8, + align = "stretch", + }, { itemList() }), + ui.label({ + text = (snapshot.updatedAt or 0) > 0 + and tr("panel.updated", { time = noctalia.formatTime("%H:%M:%S", snapshot.updatedAt) }) + or "", + color = "on_surface_variant", + fontSize = 11, + }), + })) +end + +noctalia.state.watch(STATE_KEY, function(value) + if type(value) ~= "table" then return end + -- Always re-render: toggle flags (routes, advertise exit, LAN, …) live on the snapshot. + snapshot = value + if selectedId ~= "" then + if tab == "peers" and not selectedPeer() then + selectedId = "" + elseif tab == "exit" and not selectedExit() then + selectedId = "" + end + end + dirty = true +end) + +noctalia.state.watch(RESULT_KEY, function(result) + if type(result) ~= "table" then return end + if type(result.requestId) ~= "string" or not result.requestId:match("^panel%-") then return end + feedback = tostring(result.message or "") + feedbackError = result.ok ~= true + dirty = true +end) + +panel.setWantsSecondTicks(true) + +function onOpen(_context) + feedback = "" + send("refresh") + render() +end + +function update() + if dirty then render() end +end + +function onClose() panel.close() end +function onRefresh() send("refresh") end +function onAdmin() send("open_admin") end +function onUp() send("up") end +function onDown() send("down") end + +function onToggleShields() + send("set_shields", { enabled = not snapshot.shieldsUp }) +end +function onToggleSSH() + send("set_ssh", { enabled = not snapshot.runSSH }) +end +function onToggleRoutes() + send("set_accept_routes", { enabled = not snapshot.acceptRoutes }) +end +function onToggleAdvertise() + -- Explicit next state: prefs AdvertiseRoutes default routes mean "on". + local nextOn = not (snapshot.advertiseExitNode == true) + send("set_advertise_exit", { enabled = nextOn }) +end +function onToggleLAN() + send("set_allow_lan", { enabled = not snapshot.exitNodeAllowLAN }) +end + +function onCopySelfIp() + if snapshot.ipv4 ~= "" then + send("copy", { text = snapshot.ipv4 }) + end +end + +function onPing() + local p = selectedPeer() + if p then + send("ping", { host = p.ipv4 ~= "" and p.ipv4 or p.name }) + end +end +function onSSH() + local p = selectedPeer() + if p then + send("ssh", { host = p.dnsName ~= "" and p.dnsName or p.name }) + end +end +function onCopyPeerIp() + local p = selectedPeer() + if p and p.ipv4 ~= "" then + send("copy", { text = p.ipv4 }) + end +end +function onCopyPeerDns() + local p = selectedPeer() + if p and p.dnsName ~= "" then + send("copy", { text = p.dnsName }) + end +end +function onUsePeerExit() + local p = selectedPeer() + if p then + send("set_exit_node", { node = p.ipv4 ~= "" and p.ipv4 or p.name }) + end +end + +function onUseExit() + local e = selectedExit() + if e then + send("set_exit_node", { node = e.ip }) + end +end +function onClearExit() + send("clear_exit_node") +end +function onCopyExitIp() + local e = selectedExit() + if e then + send("copy", { text = e.ip }) + end +end + +local function switchTab(next) + tab = next + selectedId = "" + filterKey += 1 + render() +end + +function onTabStatus() switchTab("status") end +function onTabPeers() switchTab("peers") end +function onTabExit() switchTab("exit") end + +function onFilterChange(value) + filterText = if type(value) == "string" then value else "" + render() +end + +function onClearFilter() + filterText = "" + filterKey += 1 + render() +end diff --git a/tailscale/plugin.toml b/tailscale/plugin.toml new file mode 100644 index 0000000..a0bfa7e --- /dev/null +++ b/tailscale/plugin.toml @@ -0,0 +1,86 @@ +# Tailscale VPN status, peers, and exit-node control. + +id = "davemhammer/tailscale" +name = "Tailscale" +version = "1.0.5" +plugin_api = 10 +author = "davemhammer" +license = "MIT" +dependencies = ["tailscale", "jq", "xdg-open"] +tags = ["network", "utility", "bar", "panel", "service", "launcher"] +icon = "circles" +description = "Manage Tailscale connection, peers, exit nodes, and preference toggles." + +[[setting]] +key = "refresh_interval" +type = "int" +label_key = "settings.refresh_interval.label" +description_key = "settings.refresh_interval.description" +default = 10 +min = 3 +max = 120 + +[[setting]] +key = "notify_on_peer_change" +type = "bool" +label_key = "settings.notify_on_peer_change.label" +description_key = "settings.notify_on_peer_change.description" +default = true + +[[setting]] +key = "tailscale_bin" +type = "string" +label_key = "settings.tailscale_bin.label" +description_key = "settings.tailscale_bin.description" +default = "tailscale" +advanced = true + +[[setting]] +key = "admin_url" +type = "string" +label_key = "settings.admin_url.label" +description_key = "settings.admin_url.description" +default = "" +advanced = true + +[[setting]] +key = "ssh_user" +type = "string" +label_key = "settings.ssh_user.label" +description_key = "settings.ssh_user.description" +default = "" +advanced = true + +[[widget]] +id = "status" +entry = "widget.luau" + + [[widget.setting]] + key = "show_counts" + type = "bool" + label_key = "settings.show_counts.label" + description_key = "settings.show_counts.description" + default = true + +[[panel]] +id = "manager" +entry = "panel.luau" +width = 720 +height = 640 +placement = "floating" +position = "center" +open_near_click = true +keyboard_focus = "exclusive" +dismiss_on_outside_click = true + +[[service]] +id = "service" +entry = "service.luau" + +[[launcher_provider]] +id = "ts" +entry = "launcher.luau" +prefix = "ts" +glyph = "circles" +include_in_global_search = false +debounce_ms = 80 diff --git a/tailscale/service.luau b/tailscale/service.luau new file mode 100644 index 0000000..7997ca5 --- /dev/null +++ b/tailscale/service.luau @@ -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 diff --git a/tailscale/thumbnail.webp b/tailscale/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..9807be401e42fb6ae16742ecd2d98f332cdaeadf GIT binary patch literal 49530 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