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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions tailscale/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Tailscale

Tailscale status and control for Noctalia, built around one core flow: **see
which hosts are online and copy their IP or name in one gesture** — from the
launcher or from the panel. Plus connect/disconnect, exit-node management,
per-host ping and SSH, Taildrop receive, and a desktop widget. A port of the
v4 `tailscale` plugin to the v5 Luau plugin API.

> Community plugin built on top of the Tailscale CLI. Not affiliated with
> Tailscale Inc.

## Plugin

| Field | Value |
| --- | --- |
| ID | `rylos/tailscale` |
| Entries | Bar widget: `bar`; panel: `panel`; service: `poller`; shortcut: `toggle`; launcher: `hosts`; desktop widget: `desktop` |
| Launcher Prefix | `/ts` |

## Requirements

| Command | Needed for |
| --- | --- |
| `tailscale` | Everything: status polling, up/down, exit nodes, ping, Taildrop receive. |
| `ssh` | The panel's per-peer SSH button (opens `ssh <tailscale-ip>` in your terminal). |
| `xdg-user-dir` | Resolving the default Taildrop download folder when `taildrop_dir` is empty (`xdg-utils`). |
| `gio` | Opening the daemon's login URL (`glib2`). |
| `xdg-open` | Fallback for opening the login URL when `gio` is unavailable (`xdg-utils`). |

Only `tailscale` is required for the core flows; the others are used by the
corresponding optional actions. Install `tailscale` and authenticate the node.
For Taildrop receive without a root prompt, grant your user operator rights
once:

```sh
sudo tailscale set --operator=$USER
```

## Usage

- **Launcher** (`/ts`): type `/ts <query>` to fuzzy-search the tailnet by host
name, IP, or OS; activating a result **copies the host's Tailscale IP** to
the clipboard. Type `/ts n <query>` to copy the host **name** (the Tailscale
short name, the form `tailscale ssh`/`file cp` expect) instead. Online hosts
rank first.
- **Bar widget** (`bar`): add it from the Add-widget picker. Tailscale logo
with state badge, online/total peer count, and optionally this node's IP.
Left click opens the panel, right click toggles `tailscale up`/`down`.
- **Panel** (`panel`): connect/disconnect, Taildrop receive, and refresh in
the header; this node's row with copy buttons; a live filter box; then the
peer list sorted online-first with status dot, OS icon, last-seen time for
offline peers, and per-row buttons: **copy IP**, **copy name**, **ping**,
**SSH in terminal**, and **use as exit node** (on peers that advertise it).
When an exit node is active a banner shows it with a disable button.

```sh
noctalia msg panel-toggle rylos/tailscale:panel
```

- **Shortcut** (`toggle`): add it from Settings → Control Center shortcuts. It
toggles the Tailscale connection up/down.
- **Desktop widget** (`desktop`): add it from the desktop-widgets editor. A
compact card with state, this node's IP, online/total peers, and the active
exit node.

## Settings

| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `poll_interval` | `int` | `5` | Status refresh interval in seconds (1–60). |
| `hide_mullvad` | `bool` | `true` | Hide Mullvad exit nodes from the peer lists. |
| `hide_offline` | `bool` | `false` | Hide offline peers from the panel and launcher. |
| `taildrop_dir` | `folder` | `""` | Where Taildrop saves received files. Empty = the XDG download folder (advanced). |
| `ssh_username` | `string` | `""` | Username for the SSH button. Empty = system default (advanced). |
| `show_ip` | `bool` | `false` | Bar widget: show this node's Tailscale IP. |
| `show_count` | `bool` | `true` | Bar widget: show the online/total peer count. |

## IPC

```sh
noctalia msg plugin rylos/tailscale:poller all refresh # force a status refresh
noctalia msg plugin rylos/tailscale:poller all up # tailscale up
noctalia msg plugin rylos/tailscale:poller all down # tailscale down
noctalia msg panel-toggle rylos/tailscale:panel
```

## Notes

- **Processes**: the service polls `tailscale status --json`; actions spawn
`tailscale up|down`, `tailscale set --exit-node=…`, `tailscale ping -c 3
<ip>`, `tailscale file get <dir>`, and `xdg-user-dir DOWNLOAD` (to resolve
the default Taildrop folder). The login button opens the daemon's auth URL
with `gio open` (fallback `xdg-open`); the SSH button runs `ssh <tailscale-ip>` in
your terminal via the shell's run-in-terminal facility — the peer's Tailscale
IP, not its name, so it works whether or not MagicDNS is enabled. There is no direct
network access from the plugin itself — everything goes through the
Tailscale CLI.
- **Filesystem**: Taildrop receive lists the download directory before and
after `tailscale file get` to report exactly which files arrived. Nothing
else is written.
- **Privacy**: host names, IPs, and last-seen times come from your own
tailnet via the local CLI and are only shown/copied locally.
- Not ported from v4: Taildrop **send** (the plugin API has no file picker)
and multi-account switching.

## Credits

Based on the v4 [tailscale](https://github.com/noctalia-dev/legacy-v4-plugins/tree/main/tailscale)
plugin. MIT license.
110 changes: 110 additions & 0 deletions tailscale/bar.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
--!nonstrict
-- Tailscale bar widget — the [[widget]] entry.
--
-- State-colored glyph, plus (optionally) the online peer count and this
-- node's Tailscale IP. Left click opens the panel, right click toggles the
-- connection up/down.

local snapshot = nil
local cmdNonce = 0

-- Real Tailscale logo, shipped with the plugin: light fill for dark themes,
-- dark fill for light themes. Problem states add a small colored glyph overlay
-- since the image itself cannot be tinted.
local function logo(state)
return ui.image({
path = noctalia.isDarkMode() and "icon.svg" or "icon-light.svg",
width = 16,
height = 16,
fit = "contain",
opacity = state == "running" and 1 or 0.45,
})
end

local BADGES = {
["needs-login"] = { glyph = "lock-exclamation", color = "error" },
["error"] = { glyph = "alert-triangle", color = "error" },
["not-installed"] = { glyph = "x", color = "outline" },
}

local COLORS = {
["running"] = "primary",
["stopped"] = "outline",
["needs-login"] = "error",
["not-installed"] = "outline",
["error"] = "error",
}

local function sendCmd(action)
cmdNonce += 1
noctalia.state.set("ts.cmd", noctalia.json.encode({ n = cmdNonce, action = action }))
end

local function stateOf()
return snapshot and snapshot.state or "stopped"
end

local function render()
local state = stateOf()
local color = COLORS[state] or "outline"
local children = { logo(state) }
local badge = BADGES[state]
if badge then
table.insert(children, ui.glyph({ name = badge.glyph, size = 11, color = badge.color }))
end

if state == "running" and snapshot and not barWidget.isVertical() then
if noctalia.getConfig("show_count") ~= false then
table.insert(children, ui.label({
text = `{snapshot.online}/{snapshot.total}`,
fontWeight = "bold",
color = color,
}))
end
if noctalia.getConfig("show_ip") == true and snapshot.self.ip ~= "" then
table.insert(children, ui.label({ text = snapshot.self.ip, color = "on_surface/0.7" }))
end
end

local container = barWidget.isVertical() and ui.column or ui.row
barWidget.render(container({ gap = 5, align = "center" }, children))

local rows = { { key = noctalia.tr("tooltip.state"), value = noctalia.tr(`state.{state}`) } }
if snapshot and state == "running" then
table.insert(rows, { key = noctalia.tr("tooltip.self"), value = `{snapshot.self.name} ({snapshot.self.ip})` })
table.insert(rows, { key = noctalia.tr("tooltip.online"), value = `{snapshot.online}/{snapshot.total}` })
if snapshot.exitNode then
table.insert(rows, { key = noctalia.tr("tooltip.exit-node"), value = snapshot.exitNode.name })
end
end
barWidget.setTooltip(rows)
end

noctalia.state.watch("ts.snapshot", function(encoded)
snapshot = encoded and noctalia.json.decode(encoded) or nil
render()
end)

function update()
noctalia.setUpdateInterval(2000)
render()
end

function onClick()
noctalia.togglePanel("rylos/tailscale:panel")
end

function onRightClick()
local state = stateOf()
if state == "running" then
sendCmd("down")
elseif state == "stopped" then
sendCmd("up")
end
end

function onIpc(event)
if event == "refresh" then
sendCmd("refresh")
end
end
60 changes: 60 additions & 0 deletions tailscale/desktop.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
--!nonstrict
-- Tailscale desktop widget — a compact status card pinned to the desktop.
--
-- Logo + state, this node's IP, online/total peers, and the active exit
-- node. Pure display; open the panel for actions.

local snapshot = nil

local function render()
local state = snapshot and snapshot.state or "stopped"
local running = state == "running"
local color = running and "primary" or (state == "stopped" and "outline" or "error")

local children = {
ui.row({ gap = 8, align = "center" }, {
ui.image({
path = noctalia.isDarkMode() and "icon.svg" or "icon-light.svg",
width = 18,
height = 18,
fit = "contain",
opacity = running and 1 or 0.5,
}),
ui.label({ text = "Tailscale", fontSize = 14, fontWeight = "bold", flexGrow = 1 }),
ui.label({ text = noctalia.tr(`state.{state}`), fontSize = 11, color = color }),
}),
}

if running and snapshot then
table.insert(children, ui.row({ gap = 6, align = "center" }, {
ui.label({ text = snapshot.self.name, fontSize = 12, fontWeight = "bold", flexGrow = 1, maxLines = 1 }),
ui.label({ text = snapshot.self.ip, fontSize = 11, color = "on_surface/0.65" }),
}))
table.insert(children, ui.row({ gap = 6, align = "center" }, {
ui.label({
text = `{noctalia.tr("tooltip.online")}: {snapshot.online}/{snapshot.total}`,
fontSize = 11,
color = "on_surface/0.7",
flexGrow = 1,
}),
ui.label({
text = snapshot.exitNode and `⇢ {snapshot.exitNode.name}` or "",
fontSize = 11,
color = "secondary",
visible = snapshot.exitNode ~= nil,
}),
}))
end

desktopWidget.render(ui.column({ gap = 6, padding = 10, fill = "surface_variant/0.4", radius = 12 }, children))
end

noctalia.state.watch("ts.snapshot", function(encoded)
snapshot = encoded and noctalia.json.decode(encoded) or nil
render()
end)

function update()
noctalia.setUpdateInterval(5000)
render()
end
1 change: 1 addition & 0 deletions tailscale/icon-light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions tailscale/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
93 changes: 93 additions & 0 deletions tailscale/launcher.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
--!nonstrict
-- Tailscale launcher provider — type `/ts` in the launcher.
--
-- The fast path for "give me that host's IP": fuzzy-search the tailnet by
-- host name / IP / OS, Enter copies the peer's Tailscale IP. Prefix the query
-- with "n " (e.g. `/ts n pc-c`) to copy the host name instead.

local function decodeSnapshot()
local encoded = noctalia.state.get("ts.snapshot")
return encoded and noctalia.json.decode(encoded) or nil
end

local OS_GLYPHS = {
linux = "device-desktop",
windows = "device-desktop",
macos = "device-laptop",
ios = "device-mobile",
android = "device-mobile",
}

local function entries(snapshot)
local list = {}
table.insert(list, {
name = snapshot.self.name,
shortName = snapshot.self.dnsName,
ip = snapshot.self.ip,
online = true,
os = "",
isSelf = true,
})
for _, peer in snapshot.peers do
table.insert(list, peer)
end
return list
end

function onQuery(text)
local snapshot = decodeSnapshot()
if not snapshot or snapshot.state ~= "running" then
launcher.setResults(text, {
{
id = "noop",
title = noctalia.tr(`state.{snapshot and snapshot.state or "stopped"}`),
glyph = "topology-star-3",
},
})
return
end

local copyName = false
local query = noctalia.string.trim(text)
local rest = query:match("^[nN]%s+(.*)$")
if rest or query == "n" or query == "N" then
copyName = true
query = rest or ""
end

local results = {}
for _, host in entries(snapshot) do
local haystack = `{host.name} {host.ip} {host.os}`
local score = 0
if query ~= "" then
score = noctalia.fuzzyScore(query, haystack)
if not score then
continue
end
end
local target = copyName and host.shortName or host.ip
-- The state goes in the subtitle, not in `badge`: a badge is drawn in the
-- icon slot and scaled up, so a whole word there would hide the OS glyph.
local action = copyName and noctalia.tr("launcher.copy-name", { value = host.shortName })
or noctalia.tr("launcher.copy-ip", { value = host.ip })
local status = host.online and noctalia.tr("launcher.online") or noctalia.tr("launcher.offline")
table.insert(results, {
id = `{copyName and "name" or "ip"}|{target}|{host.name}`,
title = host.isSelf and `{host.name} ({noctalia.tr("launcher.this-host")})` or host.name,
subtitle = `{status} · {action}`,
glyph = OS_GLYPHS[host.os:lower()] or "device-desktop",
score = score + (host.online and 1 or 0),
})
end
launcher.setResults(text, results)
end

function onActivate(id)
local kind, value, name = id:match("^(%w+)|([^|]*)|(.*)$")
if not kind or value == "" then
return
end
noctalia.copyToClipboard(value, "text/plain")
noctalia.notify(noctalia.tr("toast.title"),
noctalia.tr(kind == "name" and "toast.copied-name" or "toast.copied-ip", { host = name, value = value }))
end
Loading