Add davemhammer/tailscale (#307)
Tailscale status, peers, exit nodes, and preference toggles for Noctalia v5.
@@ -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).
|
||||||
|
After Width: | Height: | Size: 937 B |
@@ -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 |
|
After Width: | Height: | Size: 918 B |
@@ -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 |
|
After Width: | Height: | Size: 753 B |
@@ -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 |
@@ -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 |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
After Width: | Height: | Size: 48 KiB |
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||