Add davemhammer/tailscale (#307)

Tailscale status, peers, exit nodes, and preference toggles for Noctalia v5.
This commit is contained in:
Dave Hammer
2026-08-08 20:22:15 -04:00
committed by GitHub
parent d7fab4e238
commit 1abe67bd4b
15 changed files with 2495 additions and 0 deletions
+68
View File
@@ -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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 937 B

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Tailscale</title><path fill="#73c936" d="M24 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm-9 9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm0-9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm6-6a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM3 24a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zm18 .5a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM6 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm9-9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm-3 2.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM6 3a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM3 5.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z"/></svg>

After

Width:  |  Height:  |  Size: 645 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 B

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Tailscale</title><path fill="#8a8a8a" d="M24 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm-9 9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm0-9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm6-6a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM3 24a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zm18 .5a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM6 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm9-9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm-3 2.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM6 3a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM3 5.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z"/></svg>

After

Width:  |  Height:  |  Size: 645 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 753 B

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Tailscale</title><path fill="#ffffff" d="M24 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm-9 9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm0-9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm6-6a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM3 24a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zm18 .5a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM6 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm9-9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm-3 2.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM6 3a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM3 5.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z"/></svg>

After

Width:  |  Height:  |  Size: 645 B

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Tailscale</title><path d="M24 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm-9 9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm0-9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm6-6a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM3 24a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zm18 .5a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM6 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm9-9a3 3 0 1 1-6 0 3 3 0 0 1 6 0zm-3 2.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zM6 3a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM3 5.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z"/></svg>

After

Width:  |  Height:  |  Size: 630 B

+415
View File
@@ -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
+789
View File
@@ -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
+86
View File
@@ -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
+857
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

+165
View File
@@ -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"
}
}
}
+111
View File
@@ -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