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 0000000..1f49af7 Binary files /dev/null and b/tailscale/assets/tailscale-green.png differ diff --git a/tailscale/assets/tailscale-green.svg b/tailscale/assets/tailscale-green.svg new file mode 100644 index 0000000..f9922d1 --- /dev/null +++ b/tailscale/assets/tailscale-green.svg @@ -0,0 +1 @@ +Tailscale \ 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 0000000..31b6071 Binary files /dev/null and b/tailscale/assets/tailscale-grey.png differ 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 0000000..495a384 Binary files /dev/null and b/tailscale/assets/tailscale-white.png differ 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 0000000..9807be4 Binary files /dev/null and b/tailscale/thumbnail.webp differ diff --git a/tailscale/translations/en.json b/tailscale/translations/en.json new file mode 100644 index 0000000..ec0e48a --- /dev/null +++ b/tailscale/translations/en.json @@ -0,0 +1,165 @@ +{ + "title": "Tailscale", + "settings": { + "refresh_interval": { + "label": "Refresh interval (seconds)", + "description": "How often to poll tailscale status." + }, + "notify_on_peer_change": { + "label": "Notify on peer online/offline", + "description": "Desktop notification when a peer changes connectivity." + }, + "tailscale_bin": { + "label": "tailscale binary", + "description": "Command name or absolute path to the tailscale CLI." + }, + "admin_url": { + "label": "Admin console URL", + "description": "Override the admin machines URL opened from the panel." + }, + "ssh_user": { + "label": "Default SSH user", + "description": "Optional user for tailscale ssh (empty uses the CLI default)." + }, + "show_counts": { + "label": "Show counts on bar", + "description": "Display online/total peer counts on the widget." + }, + "ok_color": { + "label": "Connected color (green)" + }, + "warn_color": { + "label": "Disconnected color (grey)" + } + }, + "colors": { + "tertiary": "Tertiary", + "primary": "Primary", + "secondary": "Secondary", + "error": "Error", + "muted": "Muted" + }, + "widget": { + "tooltip_ok": "{state} · {online}/{total} peers online · exit {exit}", + "tooltip_stopped": "Tailscale is stopped · {total} known peers", + "tooltip_missing": "tailscale CLI not found", + "tooltip_down": "Tailscale unavailable: {error}", + "refresh_requested": "Refreshing Tailscale…" + }, + "panel": { + "loading": "Querying Tailscale…", + "busy": "Working…", + "empty": "No items match the current filter.", + "select_hint": "Select an item for actions.", + "updated": "Updated {time}", + "host": "{host}", + "summary": "{state} · {online}/{total} online · exit {exit}", + "health": "{msg}", + "not_installed": "tailscale is not installed or not on PATH." + }, + "tabs": { + "status": "Status", + "peers": "Peers", + "exit": "Exit nodes" + }, + "filter": { + "placeholder": "Filter… e.g. edge or !online" + }, + "status": { + "state": "State: {state}", + "tailnet": "Tailnet: {name}", + "ips": "IPs: {v4} {v6}", + "dns": "DNS: {dns}", + "exit": "Exit node: {name}", + "version": "Version: {version}", + "prefs": "Shields {shields} · Routes {routes} · SSH {ssh} · DNS {dns}", + "on": "on", + "off": "off", + "none": "none" + }, + "peer": { + "detail": "{os} · {relay} · {v4}", + "traffic": "↓{rx} ↑{tx}" + }, + "exit": { + "detail": "{ip} · {status}" + }, + "actions": { + "up": "Connect", + "down": "Disconnect", + "refresh": "Refresh", + "open_admin": "Admin", + "clear_exit": "Clear exit", + "use_exit": "Use exit", + "copy_ip": "Copy IP", + "copy_dns": "Copy DNS", + "ping": "Ping", + "ssh": "SSH", + "shields_on": "Shields up", + "shields_off": "Shields down", + "routes_on": "Accept routes", + "routes_off": "Ignore routes", + "ssh_on": "Enable SSH", + "ssh_off": "Disable SSH", + "advertise_on": "Advertise exit", + "advertise_off": "Stop advertising", + "lan_on": "Allow LAN", + "lan_off": "Block LAN" + }, + "result": { + "success": "Done", + "failed": "Failed: {error}", + "busy": "Another operation is running.", + "missing": "tailscale CLI not found.", + "up": "Tailscale connected", + "down": "Tailscale disconnected", + "exit_set": "Exit node set to {name}", + "exit_cleared": "Exit node cleared", + "shields_on": "Shields up enabled", + "shields_off": "Shields up disabled", + "ssh_on": "Tailscale SSH enabled", + "ssh_off": "Tailscale SSH disabled", + "routes_on": "Accepting subnet routes", + "routes_off": "Not accepting subnet routes", + "advertise_on": "Advertising as exit node", + "advertise_off": "No longer advertising exit node", + "lan_on": "LAN access allowed with exit node", + "lan_off": "LAN access blocked with exit node", + "ping_started": "Pinging {name}…", + "ssh_started": "Opening SSH to {name}…", + "copied": "Copied {name}", + "admin_opened": "Opening admin console…", + "peer_online": "{name} is online", + "peer_offline": "{name} went offline" + }, + "launcher": { + "loading": "Loading Tailscale…", + "missing": "tailscale not found", + "cat": { + "status": "Status", + "status-sub": "Connection summary", + "peers": "Peers", + "peers-sub": "Browse machines on the tailnet", + "exit": "Exit nodes", + "exit-sub": "Choose or clear an exit node", + "up": "Connect", + "up-sub": "tailscale up", + "down": "Disconnect", + "down-sub": "tailscale down", + "panel": "Open panel", + "panel-sub": "Full Tailscale manager", + "refresh": "Refresh", + "refresh-sub": "Re-query status", + "admin": "Admin console", + "admin-sub": "Open Tailscale admin in browser" + }, + "action": { + "ping": "Ping", + "ssh": "SSH", + "copy_ip": "Copy IPv4", + "copy_dns": "Copy DNS name", + "use_exit": "Use as exit node", + "clear_exit": "Clear exit node" + } + } +} diff --git a/tailscale/widget.luau b/tailscale/widget.luau new file mode 100644 index 0000000..93cc2f1 --- /dev/null +++ b/tailscale/widget.luau @@ -0,0 +1,111 @@ +--!nonstrict + +local PANEL_ID = "davemhammer/tailscale:manager" +local STATE_KEY = "ts_snapshot" +local COMMAND_KEY = "ts_command" + +-- Brand mark (Simple Icons Tailscale 3×3 dots), tinted for connection state. +local COLOR_CONNECTED = "#73c936" +local COLOR_DISCONNECTED = "on_surface_variant" +local ICON_ON = "assets/tailscale-green.png" +local ICON_OFF = "assets/tailscale-grey.png" + +local snapshot = noctalia.state.get(STATE_KEY) or { + available = false, + running = false, + onlineCount = 0, + peerCount = 0, + backendState = "", + exitNode = "", + error = "", + installed = false, +} + +local requestId = 0 + +local function render() + local available = snapshot.available == true + local running = snapshot.running == true + local online = tonumber(snapshot.onlineCount) or 0 + local total = tonumber(snapshot.peerCount) or 0 + local showCounts = noctalia.getConfig("show_counts") ~= false + local exitNode = tostring(snapshot.exitNode or "") + local hasExit = exitNode ~= "" + + -- Connected = green logo, stopped = grey logo (never red). + local color = running and COLOR_CONNECTED or COLOR_DISCONNECTED + + local children = { + ui.image({ + path = running and ICON_ON or ICON_OFF, + width = 16, + height = 16, + fit = "contain", + }), + } + + if showCounts and available then + table.insert(children, ui.label({ + text = `{online}/{total}`, + fontWeight = "bold", + color = "on_surface", + })) + if hasExit then + table.insert(children, ui.glyph({ + name = "world", + size = 12, + color = (snapshot.exitNodeOnline and COLOR_CONNECTED) or COLOR_DISCONNECTED, + })) + end + table.insert(children, ui.box({ + width = 7, + height = 7, + radius = 4, + fill = color, + })) + end + + local container = barWidget.isVertical() and ui.column or ui.row + barWidget.render(container({ gap = 5, align = "center" }, children)) + + if not snapshot.installed then + barWidget.setTooltip(noctalia.tr("widget.tooltip_missing")) + elseif not available then + barWidget.setTooltip(noctalia.tr("widget.tooltip_down", { + error = tostring(snapshot.error or "unknown"), + })) + elseif not running then + barWidget.setTooltip(noctalia.tr("widget.tooltip_stopped", { total = total })) + else + barWidget.setTooltip(noctalia.tr("widget.tooltip_ok", { + state = tostring(snapshot.backendState or "Running"), + online = online, + total = total, + exit = hasExit and exitNode or "—", + })) + end +end + +noctalia.state.watch(STATE_KEY, function(value) + if type(value) == "table" then + snapshot = value + render() + end +end) + +noctalia.setUpdateInterval(8000) +render() + +function update() + render() +end + +function onClick() + noctalia.togglePanel(PANEL_ID) +end + +function onRightClick() + requestId += 1 + noctalia.state.set(COMMAND_KEY, { action = "refresh", requestId = `widget-{requestId}` }) + noctalia.notify(noctalia.tr("title"), noctalia.tr("widget.refresh_requested")) +end