diff --git a/dns-switcher/README.md b/dns-switcher/README.md new file mode 100644 index 0000000..00fe714 --- /dev/null +++ b/dns-switcher/README.md @@ -0,0 +1,124 @@ +# DNS Switcher + +A [noctalia](https://github.com/noctalia-dev/noctalia) v5 bar plugin: switch the system DNS +between popular providers, your own servers, or the ISP default — from a panel +on the bar, no reconnect. Based on +[Ronin-CK's v4 DNS Switcher](https://github.com/noctalia-dev/legacy-v4-plugins), +rebuilt on the v5 Luau plugin API. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `nightwatch75/dns-switcher` | +| Entries | Bar widget: `dns-switcher`; panel: `panel`; service: `service` | + +## Features + +- **Instant, no-drop switching** — one `nmcli con mod` + `nmcli device + reapply` on the active connection profile; the network never disconnects +- **Pre-configured providers** (Google, Cloudflare, OpenDNS, AdGuard, Quad9) + plus up to 5 custom servers (`Name = address`, e.g. `Pi-hole = 192.168.1.5`) +- **Detection**, not guessing — reads the connection's own `ipv4.dns` / + `ipv4.ignore-auto-dns`, so a manually configured resolver (LAN ones + included) shows as its provider, DHCP-assigned DNS shows as *Default (ISP)* +- **DNS lookup tester** at the bottom of the panel: resolve any name against + the currently active provider's own address with `dig`/`nslookup`, to + confirm a switch took effect or check whether a provider blocks a domain +- **Fully rebindable gestures** — left click, right click and scroll are + declared in the manifest (`[widget.actions]`), so any of them can be + remapped from the bar's own gesture settings; scroll cycles providers +- **Singleton service** — one engine regardless of how many bars/monitors + show the widget; widget and panel are pure renderers over its shared state +- Live footer (connection name + active resolver IPs, with a copy button), + glyph-only mode for compact bars + +## Usage + +Add the `dns-switcher` widget from Noctalia's widget picker. Default gestures: + +| Action | Effect | +|--------------|--------------------------------------------------| +| Left click | Open/close the provider panel | +| Right click | Reset to the connection default (ISP) | +| Scroll | Cycle to the next/previous configured provider | + +All three are bar-level defaults and can be remapped from *Settings → Bar*. +The panel itself, and the plugin's settings page, also open from the CLI: + +```sh +noctalia msg panel-toggle nightwatch75/dns-switcher:panel +noctalia msg settings-open-plugin nightwatch75/dns-switcher +``` + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `providers` | `string` | `google,cloudflare,opendns,adguard,quad9` | Comma-separated built-in provider ids shown in the panel. Empty = none. | +| `custom_1` … `custom_5` | `string` | *(empty)* | One custom resolver each: `Name = address`, one or two IPv4 addresses. | +| `poll_seconds` | `int` | `10` | How often the active DNS is re-read with `nmcli` (2–120). | +| `privilege_command` | `string` | *(empty)* | Prefix to run `nmcli` changes as root (`pkexec`, `sudo -n`) — see *Privileges*. | +| `show_label` (widget) | `bool` | `true` | Show the provider name next to the glyph. | + +## IPC + +```sh +noctalia msg plugin nightwatch75/dns-switcher:service all apply cloudflare +noctalia msg plugin nightwatch75/dns-switcher:service all poll +noctalia msg plugin nightwatch75/dns-switcher:service all cycle next +``` + +`apply` takes a built-in id, `default` (ISP), or `custom:`; `poll` +forces an immediate re-check; `cycle next`/`cycle prev` step to the +neighbouring provider (what scroll sends). + +## Requirements + +- noctalia v5.0.0-beta.6 or newer for the core plugin; the gesture remapping + and lookup tester need a newer build still (`plugin_api = 22`) +- NetworkManager (`networkmanager`, provides `nmcli`) with an active connection +- Permission to modify system connections (see *Privileges* below) +- `dig` (bind-tools/dnsutils) or `nslookup`, optional — only the lookup + tester needs one of them; the rest of the plugin works without either + +## Privileges + +*Privilege command* is **empty by default**: NetworkManager's polkit policy +usually lets active local sessions modify system connections without a +password. If you get a "not authorized" error, set it to `pkexec` (shows +noctalia's own polkit prompt) or `sudo -n` with a matching sudoers rule: + +``` +# /etc/sudoers.d/nmcli-dns +youruser ALL=(root) NOPASSWD: /usr/bin/nmcli +``` + +Or grant it via a polkit rule and keep the setting empty: + +```js +// /etc/polkit-1/rules.d/50-nmcli-dns.rules +polkit.addRule(function(action, subject) { + if ((action.id == "org.freedesktop.NetworkManager.settings.modify.system" || + action.id == "org.freedesktop.NetworkManager.network-control") && + subject.isInGroup("wheel")) { + return polkit.Result.YES; + } +}); +``` + +Both widen what the account can do to NetworkManager system-wide — apply +your usual judgement on shared machines. + +## Notes + +- IPv4 DNS only, like the v4 plugin. +- Targets the first active wifi/ethernet connection (falling back to the + first active non-loopback one); a VPN's own DNS is not touched. +- Custom servers are five separate `string` settings rather than one list, + because Noctalia's list editor has no in-place row edit — a `string` + field does. A server name may not contain `=`. + +## License + +MIT. diff --git a/dns-switcher/dns-switcher.luau b/dns-switcher/dns-switcher.luau new file mode 100644 index 0000000..69a485f --- /dev/null +++ b/dns-switcher/dns-switcher.luau @@ -0,0 +1,78 @@ +--!nonstrict +-- dns-switcher — bar widget: renders the state published by the service +-- entry (service.luau). +-- +-- Click mapping is declared in plugin.toml's [widget.actions] (plugin_api >= +-- 14), not hard-coded here, so the user can remap it from the bar's own +-- gesture settings like any built-in widget: +-- Left click — panel-toggle nightwatch75/dns-switcher:panel +-- Right click — plugin nightwatch75/dns-switcher:service all apply default +-- Scroll up/down — plugin nightwatch75/dns-switcher:service all cycle next/prev +-- +-- No middle-click binding: every bar widget carries a built-in `middle` +-- binding that opens its own settings, and that default is exactly what's +-- wanted here. + +local STATE_KEY = "dns_state" +local GLYPH_UNKNOWN = "globe" + +local snapshot = nil -- last state published by the service + +local function tr(key, args) + return noctalia.tr(key, args) +end + +local function setLabel(text) + if noctalia.getConfig("show_label") == false then + barWidget.setText("") + else + barWidget.setText(text) + end +end + +local function paint(glyph, role, label) + barWidget.setGlyph(glyph) + barWidget.setGlyphColor(role) + barWidget.setColor(role) + setLabel(label) +end + +local function render() + local s = snapshot + if s == nil or (s.current == nil and s.error == nil) then + paint(GLYPH_UNKNOWN, "on_surface", tr("status_checking")) + barWidget.clearTooltip() + return + end + if s.changing == true then + paint("refresh", "secondary", tr("status_switching")) + barWidget.setTooltip(tr("tooltip_switching")) + return + end + if s.error ~= nil then + paint(GLYPH_UNKNOWN, "error", s.error) + barWidget.clearTooltip() + return + end + local role = s.current.id ~= "default" and "primary" or "on_surface" + paint(s.current.glyph, role, s.current.label) + local servers = s.current.ip + if servers == "" then + servers = s.servers ~= "" and s.servers or tr("status_auto") + end + barWidget.setTooltip({ + { key = tr("tooltip_provider"), value = s.current.label }, + { key = tr("tooltip_servers"), value = servers }, + { key = tr("tooltip_actions"), value = tr("tooltip_hints") }, + }) +end + +noctalia.state.watch(STATE_KEY, function(value) + if type(value) == "table" then + snapshot = value + render() + end +end) + +snapshot = noctalia.state.get(STATE_KEY) +render() diff --git a/dns-switcher/panel.luau b/dns-switcher/panel.luau new file mode 100644 index 0000000..2741d3c --- /dev/null +++ b/dns-switcher/panel.luau @@ -0,0 +1,345 @@ +--!nonstrict +-- dns-switcher — provider panel. Pure renderer over the shared state: the +-- service entry (service.luau) publishes "dns_state" and executes the +-- "apply_request" entries this panel emits. Picking a provider applies it +-- immediately (one nmcli change, no reactivation). + +local STATE_KEY = "dns_state" +local REQUEST_KEY = "apply_request" + +local RESOLVE_TIMEOUT_MS = 4000 + +-- Read out of the plugin's own manifest (readFile resolves a relative path +-- against the plugin directory), so the header cannot drift from the version +-- the store shows. Empty when unreadable — a missing version is not worth an +-- error line in the panel. +local pluginVersion = (function() + local text = noctalia.readFile("plugin.toml") + if type(text) ~= "string" then + return "" + end + return ("\n" .. text):match('\nversion%s*=%s*"([^"]+)"') or "" +end)() + +local snapshot = nil -- last state published by the service + +-- DNS lookup tester (bottom of the panel). Entirely panel-local: it neither +-- reads nor writes the shared state, since it never changes what DNS is +-- configured — it only asks "does a name resolve through the DNS that IS +-- configured right now", which is exactly the question this plugin otherwise +-- has no way to answer. +local resolveQuery = "" +local resolveBusy = false +local resolveError = nil +local resolveResult = nil -- { tool, serverIp?, serverLabel?, lines? (dig), raw? (nslookup) } + +local render + +local function tr(key, args) + return noctalia.tr(key, args) +end + +local function trim(value) + return (value:gsub("^%s+", ""):gsub("%s+$", "")) +end + +local function shellQuote(value) + return "'" .. value:gsub("'", "'\\''") .. "'" +end + +-- The nonce is monotonic across writers (widget instances and the panel): +-- each seeds from the last request already in the shared state. +local function requestApply(entry) + if snapshot == nil or snapshot.changing == true then + return + end + local prev = noctalia.state.get(REQUEST_KEY) + local nonce = (type(prev) == "table" and tonumber(prev.nonce) or 0) + 1 + noctalia.state.set(REQUEST_KEY, { nonce = nonce, id = entry.id, label = entry.label, ip = entry.ip }) +end + +-- A conservative hostname shape (letters/digits/dot/hyphen, no leading dot or +-- hyphen, 253 chars max — the DNS wire-format limit). shellQuote() below is +-- the actual safety net; this only keeps an obviously-wrong query from ever +-- reaching a shell as a "valid enough" no-op. +local function isValidHostname(name) + return name ~= "" and #name <= 253 and name:match("^[%w][%w%.%-]*$") ~= nil +end + +-- Name + first address of the currently active provider, if it has an +-- address of its own (built-in/custom entries do; "Default (ISP)" does not). +-- nil means "ask the system resolver", not "no server" — the lookup still +-- runs either way. +local function activeServerInfo() + if snapshot == nil or snapshot.current == nil or type(snapshot.current.ip) ~= "string" then + return nil + end + local ip = snapshot.current.ip:match("%S+") + if ip == nil then + return nil + end + return { ip = ip, label = snapshot.current.label } +end + +local function parseDigShort(stdout) + local lines = {} + for line in stdout:gmatch("[^\n]+") do + local clean = trim(line) + if clean ~= "" then + table.insert(lines, clean) + end + end + return lines +end + +-- Prefers `dig +short` (one line per answer, trivial to parse); falls back to +-- raw `nslookup` output, shown verbatim, when dig is not installed. Both are +-- pointed at the active provider's own address when it has one, so the +-- answer reflects that resolver specifically rather than whatever the system +-- resolver layer (systemd-resolved, etc.) does with it. +local function runResolve() + local name = trim(resolveQuery) + if not isValidHostname(name) then + resolveError = tr("resolve_invalid") + resolveResult = nil + render() + return + end + + local server = activeServerInfo() + local useDig = noctalia.commandExists("dig") + local useNslookup = not useDig and noctalia.commandExists("nslookup") + if not useDig and not useNslookup then + resolveError = tr("resolve_no_tool") + resolveResult = nil + render() + return + end + + resolveBusy = true + resolveError = nil + resolveResult = nil + render() + + local tool = useDig and "dig" or "nslookup" + local cmd + if useDig then + cmd = "dig +time=3 +tries=1 +short " + .. (server ~= nil and ("@" .. shellQuote(server.ip) .. " ") or "") + .. shellQuote(name) + else + cmd = "nslookup " .. shellQuote(name) .. (server ~= nil and (" " .. shellQuote(server.ip)) or "") + end + + local ok = noctalia.runAsync(cmd, function(result) + resolveBusy = false + if result.timedOut then + resolveError = tr("resolve_timeout") + elseif tool == "dig" then + local lines = parseDigShort(result.stdout or "") + if #lines == 0 then + resolveError = tr("resolve_empty") + else + resolveResult = { + tool = tool, + serverIp = server ~= nil and server.ip or nil, + serverLabel = server ~= nil and server.label or nil, + lines = lines, + } + end + else + local raw = trim(result.stdout or "") + if raw == "" then + resolveError = tr("resolve_empty") + else + resolveResult = { + tool = tool, + serverIp = server ~= nil and server.ip or nil, + serverLabel = server ~= nil and server.label or nil, + raw = raw, + } + end + end + render() + end, RESOLVE_TIMEOUT_MS) + if not ok then + resolveBusy = false + resolveError = tr("resolve_spawn_failed") + render() + end +end + +-- A row names its provider and the addresses it would set, so picking one is not +-- a guess about what it does. The ISP default has no fixed addresses -- whatever +-- the LAN hands out -- so it stays a bare label, and the footer shows what is +-- actually in use. A button carries one run of text, so the two are joined with a +-- separator rather than styled apart. +local function providerRow(entry) + local active = snapshot.current ~= nil and snapshot.current.id == entry.id + local text = entry.label + if entry.ip ~= nil and entry.ip ~= "" then + text = text .. " · " .. entry.ip + end + return ui.button({ + key = "dns-" .. entry.id .. (active and "-on" or ""), + glyph = entry.glyph, + text = text, + variant = active and "primary" or "ghost", + contentAlign = "start", + onClick = function() + if not active then + requestApply(entry) + end + end, + }) +end + +local function statusFooter() + if snapshot.changing == true then + return ui.label({ text = tr("status_switching"), fontSize = 11, color = "secondary" }) + end + local servers = snapshot.servers + if servers == nil or servers == "" then + servers = tr("status_auto") + end + local caption = servers + if snapshot.conName ~= nil and snapshot.conName ~= "" then + caption = snapshot.conName .. " · " .. servers + end + return ui.row({ gap = 6, align = "center" }, { + ui.label({ text = caption, fontSize = 11, color = "on_surface_variant", flexGrow = 1 }), + ui.button({ glyph = "copy", variant = "ghost", tooltip = tr("tip_copy"), onClick = "onCopyServers" }), + }) +end + +-- Bottom-of-panel lookup tester: a name, a button, and whatever the active +-- resolver (or the system one, with no provider address of its own) answers. +local function resolveSection() + local children = { + ui.separator({}), + ui.label({ key = "resolve-title", text = tr("resolve_title"), fontSize = 12, fontWeight = "semibold", color = "on_surface" }), + ui.row({ key = "resolve-row", gap = 6, align = "center" }, { + ui.input({ + key = "resolve-input", + value = resolveQuery, + placeholder = tr("resolve_placeholder"), + flexGrow = 1, + onChange = function(value) + resolveQuery = value + end, + onSubmit = function(value) + resolveQuery = value + runResolve() + end, + }), + ui.button({ + key = "resolve-go" .. (resolveBusy and "-off" or ""), + glyph = "search", + variant = "primary", + enabled = not resolveBusy, + tooltip = tr("tip_resolve"), + onClick = function() + runResolve() + end, + }), + }), + } + + if resolveBusy then + table.insert(children, ui.label({ key = "resolve-status", text = tr("resolve_busy"), fontSize = 11, color = "secondary" })) + elseif resolveError ~= nil then + table.insert(children, ui.label({ key = "resolve-status", text = resolveError, fontSize = 11, color = "error", maxLines = 2 })) + elseif resolveResult ~= nil then + local via + if resolveResult.serverIp ~= nil then + via = tr("resolve_via", { name = resolveResult.serverLabel or resolveResult.serverIp, server = resolveResult.serverIp }) + else + via = tr("resolve_via_system") + end + local body = resolveResult.lines ~= nil and table.concat(resolveResult.lines, ", ") or resolveResult.raw + -- The answer first, then which server gave it — the label is set + -- apart with a slightly larger font since it names what answered. + table.insert(children, ui.label({ key = "resolve-body", text = body, fontSize = 14, color = "primary", maxLines = 6 })) + table.insert(children, ui.label({ key = "resolve-via", text = via, fontSize = 13, fontWeight = "medium", color = "on_surface_variant" })) + end + + return ui.column({ key = "resolve", gap = 6 }, children) +end + +render = function() + local children = { + ui.row({ gap = 8, align = "center" }, { + ui.label({ + key = "title", + text = tr("title"), + fontSize = 16, + fontWeight = "bold", + color = "on_surface", + }), + -- Version off the manifest, small and dimmed: it answers "which + -- build am I running" without competing with the title. The spacer + -- rather than a flexGrow title keeps the two together on the left. + ui.label({ key = "version", text = pluginVersion, fontSize = 10, color = "on_surface_variant" }), + ui.spacer({ key = "gap", flexGrow = 1 }), + ui.button({ glyph = "settings", variant = "ghost", tooltip = tr("tip_settings"), onClick = "onOpenSettings" }), + ui.button({ glyph = "close", variant = "ghost", tooltip = tr("tip_close"), onClick = "onClosePanel" }), + }), + } + + if snapshot == nil or snapshot.list == nil then + table.insert(children, ui.label({ text = tr("status_checking"), color = "on_surface_variant" })) + elseif snapshot.error ~= nil then + table.insert(children, ui.label({ text = snapshot.error, color = "error" })) + else + local rows = {} + for _, entry in ipairs(snapshot.list) do + table.insert(rows, providerRow(entry)) + end + table.insert(children, ui.scroll({ flexGrow = 1, gap = 4 }, rows)) + table.insert(children, statusFooter()) + end + + -- Always present, independent of detection state: it asks a question + -- about a name, not about which provider is active. + table.insert(children, resolveSection()) + + panel.render(ui.column({ flexGrow = 1, gap = 12, align = "stretch" }, children)) +end + +function onOpen(_context) + snapshot = noctalia.state.get(STATE_KEY) + render() +end + +noctalia.state.watch(STATE_KEY, function(value) + if type(value) == "table" then + snapshot = value + render() + end +end) + +function onCopyServers() + if snapshot == nil then + return + end + local text = snapshot.servers + if (text == nil or text == "") and snapshot.current ~= nil then + text = snapshot.current.ip + end + if text == nil or text == "" then + return + end + noctalia.copyToClipboard(text, "text/plain") + noctalia.notify(tr("title"), tr("copied", { ip = text })) +end + +-- Opens the settings window on this plugin's own page (the host supplies the +-- plugin id, so a plugin can only ever open its own). It closes the panel on +-- the way, which is why nothing is rendered afterwards. +function onOpenSettings() + noctalia.openSettings() +end + +function onClosePanel() + panel.close() +end diff --git a/dns-switcher/plugin.toml b/dns-switcher/plugin.toml new file mode 100644 index 0000000..fbe8193 --- /dev/null +++ b/dns-switcher/plugin.toml @@ -0,0 +1,127 @@ +# DNS Switcher — switch the system DNS (NetworkManager) from the bar. +# Based on Ronin-CK's v4 "DNS Switcher", rebuilt on the v5 Luau API: the bar +# widget shows the active provider and toggles a panel listing the configured +# providers; picking one applies it immediately via `nmcli con mod` + +# `nmcli device reapply` (no reactivation, the connection never drops). + +id = "nightwatch75/dns-switcher" +name = "DNS Switcher" +version = "0.1.1" +plugin_api = 17 +author = "nightwatch75" +license = "MIT" +# dig (bind-tools/dnsutils) is preferred for the panel's lookup tester; +# nslookup is the fallback when dig is missing. Neither is required for the +# core switch/apply feature, only for that one panel section. +dependencies = ["networkmanager", "dig", "nslookup"] +tags = ["bar", "panel", "service", "network", "privacy"] +icon = "world" +description = "Switch the system DNS between popular providers, custom servers, or the ISP default (NetworkManager)." + +# Plugin-level settings: shared by the widget engine (detection/apply) and +# the panel (provider list). Custom servers are five `Name = 1.2.3.4 5.6.7.8` +# string fields, custom_1..custom_5; see the comment on them for why. + +[[setting]] +key = "providers" +type = "string" +label_key = "settings.providers.label" +description_key = "settings.providers.description" +default = "google,cloudflare,opendns,adguard,quad9" + +# Five separate string settings rather than one list, because Noctalia's list +# editor shows an existing row as a static label with remove/up/down buttons: a +# typo means deleting the row and typing it again. A string renders as a text +# field, so each server can be corrected in place. Five covers any realistic +# number of custom resolvers. +# +# The keys are new names on purpose, not a retyped custom_dns. A stored value +# whose type no longer matches its declaration makes Noctalia reject every +# settings write, so the whole file stops saving; a key that simply no longer +# exists is just a warning. That is what 0.0.8 was released to fix. +[[setting]] +key = "custom_1" +type = "string" +label_key = "settings.custom_1.label" +description_key = "settings.custom.description" +default = "" + +[[setting]] +key = "custom_2" +type = "string" +label_key = "settings.custom_2.label" +description_key = "settings.custom.description" +default = "" + +[[setting]] +key = "custom_3" +type = "string" +label_key = "settings.custom_3.label" +description_key = "settings.custom.description" +default = "" + +[[setting]] +key = "custom_4" +type = "string" +label_key = "settings.custom_4.label" +description_key = "settings.custom.description" +default = "" + +[[setting]] +key = "custom_5" +type = "string" +label_key = "settings.custom_5.label" +description_key = "settings.custom.description" +default = "" + +[[setting]] +key = "poll_seconds" +type = "int" +label_key = "settings.poll_seconds.label" +description_key = "settings.poll_seconds.description" +default = 10 +min = 2 +max = 120 + +[[setting]] +key = "privilege_command" +type = "string" +label_key = "settings.privilege_command.label" +description_key = "settings.privilege_command.description" +default = "" +advanced = true + +[[service]] +id = "service" +entry = "service.luau" + +[[panel]] +id = "panel" +entry = "panel.luau" +# Wide enough for the longest built-in row, "OpenDNS · 208.67.222.222 +# 208.67.220.220", without eliding the addresses. Height grew in 0.0.13 to +# fit the lookup tester below the provider list without starving it. +width = 430 +height = 460 +placement = "attached" +open_near_click = true + +[[widget]] +id = "dns-switcher" +entry = "dns-switcher.luau" + + # Declared here rather than hard-coded in onClick/onRightClick, so the user + # can remap any of them from the bar's own gesture settings. "right"/ + # "scroll_*" are the exact IPC lines service.luau's onIpc documents. + [widget.actions] + left = "panel-toggle nightwatch75/dns-switcher:panel" + right = "plugin nightwatch75/dns-switcher:service all apply default" + scroll_up = "plugin nightwatch75/dns-switcher:service all cycle next" + scroll_down = "plugin nightwatch75/dns-switcher:service all cycle prev" + + [[widget.setting]] + key = "show_label" + type = "bool" + label_key = "settings.show_label.label" + description_key = "settings.show_label.description" + default = true diff --git a/dns-switcher/service.luau b/dns-switcher/service.luau new file mode 100644 index 0000000..066ae92 --- /dev/null +++ b/dns-switcher/service.luau @@ -0,0 +1,497 @@ +--!nonstrict +-- dns-switcher — singleton DNS engine (detection + apply). +-- +-- Runs once regardless of how many bars show the widget. The widget and the +-- panel are pure renderers wired through the plugin's shared state: +-- engine publishes "dns_state" = { nonce, current?, servers, conName, +-- changing, error?, list } +-- UI entries send "apply_request" = { nonce, id, label?, ip? } +-- +-- Detection reads the active connection's ipv4.dns / ipv4.ignore-auto-dns +-- profile settings (manual DNS is matched against the providers, otherwise +-- shown as "Custom"); without a manual DNS the state is the ISP default. +-- Applying runs `nmcli con mod … && nmcli device reapply ` — +-- reapply pushes the change onto the live connection without reactivating +-- it, so the network never drops. The privilege command is empty by default: +-- NetworkManager's polkit policy lets active local sessions modify system +-- connections on most desktop distros. + +local STATE_KEY = "dns_state" +local REQUEST_KEY = "apply_request" + +local BUILTIN = { + { id = "google", label = "Google", ip = "8.8.8.8 8.8.4.4", glyph = "brand-google" }, + { id = "cloudflare", label = "Cloudflare", ip = "1.1.1.1 1.0.0.1", glyph = "cloud" }, + { id = "opendns", label = "OpenDNS", ip = "208.67.222.222 208.67.220.220", glyph = "world" }, + { id = "adguard", label = "AdGuard", ip = "94.140.14.14 94.140.15.15", glyph = "shield-check" }, + { id = "quad9", label = "Quad9", ip = "9.9.9.9 149.112.112.112", glyph = "lock" }, +} +local GLYPH_DEFAULT = "router" -- ISP / connection default +local GLYPH_UNKNOWN = "globe" -- unrecognized manual DNS +local GLYPH_CUSTOM = "server" -- user-defined servers + +local current = nil -- provider entry detected as active; nil = still checking +local lastSeen = "" -- runtime resolver IPs from the last successful poll +local conName = "" -- active connection name, shown in the panel footer +local errMsg = nil -- sticky error label (no nmcli / no connection) +local pollTicks = 0 +local changing = false +local checkInFlight = false +local nmcliMissing = false +local stateNonce = 0 + +local function cfg(key) + return noctalia.getConfig(key) +end + +local function tr(key, args) + return noctalia.tr(key, args) +end + +local function trim(value) + return (value:gsub("^%s+", ""):gsub("%s+$", "")) +end + +local function shellQuote(value) + return "'" .. value:gsub("'", "'\\''") .. "'" +end + +local function pollSeconds() + return math.max(2, tonumber(cfg("poll_seconds")) or 10) +end + +local function isValidIp(ip) + local a, b, c, d = ip:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$") + if a == nil then + return false + end + for _, part in ipairs({ a, b, c, d }) do + if #part > 3 or tonumber(part) > 255 then + return false + end + end + return true +end + +-- One or two space-separated IPv4 addresses, same shape nmcli accepts. +local function validDnsSpec(spec) + local count = 0 + for token in spec:gmatch("%S+") do + count += 1 + if count > 2 or not isValidIp(token) then + return false + end + end + return count > 0 +end + +-- Custom servers: five separate `Name = 1.2.3.4 5.6.7.8` string settings rather +-- than one list, so a wrong address can be corrected in the field instead of +-- deleted and retyped -- Noctalia's list editor has no per-row edit. Slot order +-- is panel order. The keys are listed as literals so `noctalia plugins lint` can +-- still match them against the manifest. +local CUSTOM_KEYS = { "custom_1", "custom_2", "custom_3", "custom_4", "custom_5" } + +-- The raw slots are the cache signature, so an invalid entry is logged when a +-- slot actually changes rather than on every poll. +local customCacheSig = nil +local customCacheList = {} +local function customProviders() + local rows = {} + for _, key in ipairs(CUSTOM_KEYS) do + local row = cfg(key) + if type(row) == "string" and trim(row) ~= "" then + table.insert(rows, row) + end + end + local sig = table.concat(rows, "\n") + if sig == customCacheSig then + return customCacheList + end + customCacheSig = sig + customCacheList = {} + for _, row in ipairs(rows) do + -- The name is everything before the first '='; the rest is the address + -- list. A name may therefore not contain '=' itself — such a row fails + -- the address check below and is skipped with a log line. + local name, spec = row:match("^([^=]*)=(.*)$") + local cleanName = trim(name or "") + local cleanSpec = trim(spec or "") + if cleanName ~= "" and validDnsSpec(cleanSpec) then + table.insert(customCacheList, { id = "custom:" .. cleanName, label = cleanName, ip = cleanSpec, glyph = GLYPH_CUSTOM }) + elseif trim(row) ~= "" then + noctalia.log("dns-switcher: ignoring invalid custom server row '" .. row .. "'") + end + end + return customCacheList +end + +local function enabledBuiltins() + local raw = cfg("providers") + if type(raw) ~= "string" then + return BUILTIN + end + -- Cleared setting = no built-ins: only custom servers and the ISP default. + if trim(raw) == "" then + return {} + end + local wanted = {} + for id in raw:gmatch("[^,%s]+") do + wanted[id:lower()] = true + end + local list = {} + for _, provider in ipairs(BUILTIN) do + if wanted[provider.id] then + table.insert(list, provider) + end + end + return list +end + +local function defaultEntry() + return { id = "default", label = tr("status_default"), ip = "", glyph = GLYPH_DEFAULT } +end + +-- Panel order: enabled built-ins, then custom servers, then the ISP default. +local function providerList() + local list = {} + for _, provider in ipairs(enabledBuiltins()) do + table.insert(list, provider) + end + for _, provider in ipairs(customProviders()) do + table.insert(list, provider) + end + table.insert(list, defaultEntry()) + return list +end + +local function publish() + stateNonce += 1 + noctalia.state.set(STATE_KEY, { + nonce = stateNonce, + current = current, + servers = lastSeen, + conName = conName, + changing = changing, + error = errMsg, + list = providerList(), + }) +end + +-- Picks the active connection: prefer wifi/ethernet, else the first +-- non-loopback entry. Emits KEY=value lines parsed by the poll callback; +-- UUID (colon-free) identifies the connection, DEV drives the reapply. +local DETECT_CMD = [[ +ACT=$(LC_ALL=C nmcli -t -f TYPE,DEVICE,UUID,NAME connection show --active 2>/dev/null) +LINE=$(printf '%s\n' "$ACT" | grep -E '^(802-11-wireless|802-3-ethernet):' | head -n 1) +[ -n "$LINE" ] || LINE=$(printf '%s\n' "$ACT" | grep -v '^loopback:' | head -n 1) +[ -n "$LINE" ] || { echo 'ERR=noconn'; exit 0; } +DEV=$(printf '%s' "$LINE" | cut -d: -f2) +UUID=$(printf '%s' "$LINE" | cut -d: -f3) +echo "NAME=$(printf '%s' "$LINE" | cut -d: -f4-)" +echo "CFG=$(LC_ALL=C nmcli -g ipv4.dns connection show uuid "$UUID" 2>/dev/null)" +echo "AUTO=$(LC_ALL=C nmcli -g ipv4.ignore-auto-dns connection show uuid "$UUID" 2>/dev/null)" +echo "RUN=$(nmcli -g IP4.DNS device show "$DEV" 2>/dev/null | tr '\n' ' ')" +]] + +local function updateDnsState(stdout) + local fields = {} + for line in stdout:gmatch("[^\n]+") do + local key, value = line:match("^(%u+)=(.*)$") + if key ~= nil then + fields[key] = value + end + end + + if fields.ERR == "noconn" then + current = nil + errMsg = tr("err_no_connection") + return + end + errMsg = nil + conName = fields.NAME or "" + + local runtime = {} + for token in (fields.RUN or ""):gmatch("%d+%.%d+%.%d+%.%d+") do + if isValidIp(token) then + table.insert(runtime, token) + end + end + lastSeen = table.concat(runtime, " ") + + -- Manual DNS lives in the profile (ipv4.dns + ignore-auto-dns yes); + -- anything else is the connection default, whatever the LAN hands out. + local manual = (fields.AUTO == "yes") + local cfgIps = {} + for token in (fields.CFG or ""):gmatch("%d+%.%d+%.%d+%.%d+") do + if isValidIp(token) then + table.insert(cfgIps, token) + end + end + if not manual or #cfgIps == 0 then + current = defaultEntry() + return + end + + -- Customs take precedence over built-ins, so a custom entry that reuses + -- a public IP (e.g. a forwarder) keeps its own label. + local lookup = {} + for _, provider in ipairs(BUILTIN) do + for ip in provider.ip:gmatch("%S+") do + lookup[ip] = provider + end + end + for _, provider in ipairs(customProviders()) do + for ip in provider.ip:gmatch("%S+") do + lookup[ip] = provider + end + end + for _, ip in ipairs(cfgIps) do + if lookup[ip] ~= nil then + current = lookup[ip] + return + end + end + current = { + id = "unknown", + label = tr("status_custom", { ip = cfgIps[1] }), + ip = table.concat(cfgIps, " "), + glyph = GLYPH_UNKNOWN, + } +end + +local function pollNow() + if checkInFlight or changing or nmcliMissing then + return + end + checkInFlight = true + local ok = noctalia.runAsync(DETECT_CMD, function(result) + checkInFlight = false + if result.exitCode == 0 and not result.timedOut then + updateDnsState(result.stdout) + elseif current == nil then + errMsg = tr("status_no_nmcli") + end + publish() + end, 4000) + if not ok then + checkInFlight = false + end +end + +local function apply(provider) + if changing then + return + end + -- No "already active" short-circuit: `current` is only ever refreshed by + -- the async poll, so it can be stale by up to a full pollSeconds() window + -- (shorter but still nonzero right after another apply/cycle). Skipping + -- here on a stale match would silently drop a legitimate request instead + -- of just doing one harmless idempotent nmcli round trip. + if provider.ip ~= "" and not validDnsSpec(provider.ip) then + noctalia.notifyError(tr("title"), tr("err_invalid_ip", { ip = provider.ip })) + return + end + + -- Safety net mirroring the v4 plugin: the spec is already validated, the + -- gsub guarantees nothing shell-relevant ever reaches the command line. + local safeIp = provider.ip:gsub("[^%d%. ]", "") + local mods + if safeIp == "" then + mods = 'ipv4.dns "" ipv4.ignore-auto-dns no' + else + mods = 'ipv4.dns "' .. safeIp .. '" ipv4.ignore-auto-dns yes' + end + local inner = 'ACT=$(LC_ALL=C nmcli -t -f TYPE,DEVICE,UUID connection show --active 2>/dev/null); ' + .. [[LINE=$(printf '%s\n' "$ACT" | grep -E '^(802-11-wireless|802-3-ethernet):' | head -n 1); ]] + .. [=[[ -n "$LINE" ] || LINE=$(printf '%s\n' "$ACT" | grep -v '^loopback:' | head -n 1); ]=] + .. [=[[ -n "$LINE" ] || exit 9; ]=] + .. 'DEV=$(printf \'%s\' "$LINE" | cut -d: -f2); ' + .. 'UUID=$(printf \'%s\' "$LINE" | cut -d: -f3); ' + .. 'nmcli con mod "$UUID" ' .. mods .. ' && nmcli device reapply "$DEV"' + + local priv = cfg("privilege_command") + if type(priv) ~= "string" then + priv = "" + end + priv = trim(priv) + local cmd = inner + if priv ~= "" then + cmd = priv .. " sh -c " .. shellQuote(inner) + end + + changing = true + publish() + -- 60s budget so an eventual polkit password prompt can be answered. + local ok = noctalia.runAsync(cmd, function(result) + changing = false + if result.exitCode == 0 and not result.timedOut then + noctalia.notify(tr("title"), tr("applied", { name = provider.label })) + elseif result.timedOut then + noctalia.notifyError(tr("title"), tr("err_timeout")) + elseif result.exitCode == 9 then + noctalia.notifyError(tr("title"), tr("err_no_connection")) + elseif result.exitCode == 126 then + noctalia.notifyError(tr("title"), tr("err_auth_dismissed")) + else + local detail = trim(result.stderr or "") + if #detail > 200 then + detail = detail:sub(1, 200) .. "…" + end + local body = tr("err_apply_failed") + if detail:lower():find("not authorized") or detail:lower():find("insufficient") then + body = tr("err_not_authorized") + elseif detail ~= "" then + body = body .. "\n" .. detail + end + noctalia.notifyError(tr("title"), body) + end + publish() + pollNow() + end, 60000) + if not ok then + changing = false + noctalia.notifyError(tr("title"), tr("err_spawn")) + publish() + end +end + +-- Resolves an apply request against the current provider list (so config +-- edits win over stale request payloads), falling back to the request's own +-- label/ip for entries that just left the list. +local function applyById(id, label, ip) + for _, entry in ipairs(providerList()) do + if entry.id == id then + apply(entry) + return true + end + end + if id == "default" or (type(ip) == "string" and validDnsSpec(ip)) then + apply({ id = id, label = label or id, ip = id == "default" and "" or ip }) + return true + end + return false +end + +-- Steps the active provider to its neighbour in providerList() (wrapping at +-- either end). Backs the bar widget's scroll_up/scroll_down gesture default; +-- with no detected current provider yet, "next" starts at the first entry +-- rather than doing nothing. +local function cycleTo(direction) + local list = providerList() + if #list == 0 then + return + end + local index = 1 + if current ~= nil then + for i, entry in ipairs(list) do + if entry.id == current.id then + index = i + break + end + end + index = index + (direction == "prev" and -1 or 1) + if index < 1 then + index = #list + elseif index > #list then + index = 1 + end + end + apply(list[index]) +end + +-- Apply requests from the widget/panel. The nonce is monotonic across +-- writers (each seeds from the last request) and guards against replaying a +-- stale request after a hot reload of this script. +local handledNonce = 0 +do + local pendingReq = noctalia.state.get(REQUEST_KEY) + if type(pendingReq) == "table" and type(pendingReq.nonce) == "number" then + handledNonce = pendingReq.nonce + end +end +noctalia.state.watch(REQUEST_KEY, function(req) + if type(req) ~= "table" or type(req.nonce) ~= "number" or req.nonce <= handledNonce then + return + end + handledNonce = req.nonce + if nmcliMissing then + return + end + applyById(req.id, req.label, req.ip) +end) + +-- Scriptable switching: +-- noctalia msg plugin nightwatch75/dns-switcher:service all apply +-- where is a provider id ("google", "default", "custom:"…); +-- "poll" forces an immediate re-detection; "cycle next"/"cycle prev" steps to +-- the neighbouring provider in the panel's own order (this is what the bar +-- widget's scroll_up/scroll_down gesture defaults send, [widget.actions] in +-- plugin.toml). +function onIpc(event, payload) + if nmcliMissing then + return + end + if event == "poll" then + pollNow() + elseif event == "apply" then + local id = type(payload) == "string" and trim(payload) or "" + if not applyById(id) then + noctalia.notifyError(tr("title"), tr("err_unknown_provider", { id = id })) + end + elseif event == "cycle" then + local direction = type(payload) == "string" and trim(payload) or "next" + cycleTo(direction) + end +end + +-- plugin_api >= 17. A hot reload (editing this file) tears this VM down and a +-- fresh one starts moments later, which republishes its own state on load — +-- nothing to do for "reload". On disable/uninstall/shutdown mid-apply, +-- though, nothing ever republishes again: without this, every widget/panel +-- instance reading the shared state would stay frozen on "changing" forever. +-- The in-flight nmcli command is not ours to cancel either way — runAsync +-- hands back no killable handle, and it is idempotent (con mod + reapply), so +-- letting it finish in the background is harmless. This only stops +-- describing it as in progress. The DNS choice itself is never touched here: +-- it lives in the NetworkManager connection profile, independent of whether +-- this plugin is enabled at all. +function onExit(_signal, reason) + if reason == "reload" then + return + end + if changing then + changing = false + publish() + end +end + +function onConfigChanged() + -- Settings edits reshape the provider list and may relabel the current + -- entry; re-publish and re-check right away. + publish() + pollNow() +end + +function update() + if nmcliMissing then + return + end + pollTicks += 1 + if pollTicks >= pollSeconds() then + pollTicks = 0 + pollNow() + end +end + +noctalia.setUpdateInterval(1000) +if not noctalia.commandExists("nmcli") then + nmcliMissing = true + errMsg = tr("status_no_nmcli") + current = nil + publish() + noctalia.notifyError(tr("title"), tr("err_no_nmcli")) +else + publish() + pollNow() +end diff --git a/dns-switcher/thumbnail.webp b/dns-switcher/thumbnail.webp new file mode 100644 index 0000000..b86d5cb Binary files /dev/null and b/dns-switcher/thumbnail.webp differ diff --git a/dns-switcher/translations/en.json b/dns-switcher/translations/en.json new file mode 100644 index 0000000..7495c51 --- /dev/null +++ b/dns-switcher/translations/en.json @@ -0,0 +1,75 @@ +{ + "applied": "DNS switched to {name}", + "copied": "DNS servers copied: {ip}", + "err_apply_failed": "Failed to apply the DNS settings", + "err_auth_dismissed": "Authorization dismissed", + "err_invalid_ip": "Invalid DNS address: {ip}", + "err_no_connection": "No active NetworkManager connection", + "err_no_nmcli": "nmcli not found — install NetworkManager", + "err_not_authorized": "NetworkManager refused the change (not authorized). Set the Privilege command setting to 'pkexec' or 'sudo -n'.", + "err_spawn": "Could not run nmcli", + "err_timeout": "Timed out applying the DNS change (authorization prompt left unanswered?)", + "err_unknown_provider": "Unknown provider id: {id}", + "resolve_busy": "Resolving…", + "resolve_empty": "No records found", + "resolve_invalid": "Enter a valid hostname", + "resolve_no_tool": "Neither dig nor nslookup is installed", + "resolve_placeholder": "hostname, e.g. example.com", + "resolve_spawn_failed": "Could not run the lookup", + "resolve_timeout": "Timed out", + "resolve_title": "DNS lookup", + "resolve_via": "via {name} ({server})", + "resolve_via_system": "via system resolver", + "settings": { + "custom": { + "description": "One custom resolver, written 'Name = address', with one or two IPv4 addresses: 'Pi-hole = 192.168.1.5' or 'NextDNS = 45.90.28.0 45.90.30.0'. The panel lists the slots in order and skips the empty ones; a row that is not a valid address is skipped and logged." + }, + "custom_1": { + "label": "Custom server 1" + }, + "custom_2": { + "label": "Custom server 2" + }, + "custom_3": { + "label": "Custom server 3" + }, + "custom_4": { + "label": "Custom server 4" + }, + "custom_5": { + "label": "Custom server 5" + }, + "poll_seconds": { + "description": "How often the active DNS is re-read with nmcli.", + "label": "Poll interval (seconds)" + }, + "privilege_command": { + "description": "Prefix to run nmcli changes as root (e.g. 'pkexec', 'sudo -n'). Empty (default) runs nmcli directly — NetworkManager's polkit policy allows this for active local sessions on most desktop distros.", + "label": "Privilege command" + }, + "providers": { + "description": "Comma-separated ids included in the scroll cycle: google, cloudflare, opendns, adguard, quad9. Empty = none (custom servers and ISP default only).", + "label": "Built-in providers" + }, + "show_label": { + "description": "Show the provider name next to the glyph (off = glyph only).", + "label": "Show provider name" + } + }, + "status_auto": "automatic", + "status_checking": "Checking…", + "status_custom": "Custom ({ip})", + "status_default": "Default (ISP)", + "status_no_nmcli": "nmcli unavailable", + "status_switching": "Switching…", + "tip_close": "Close", + "tip_copy": "Copy the active DNS servers", + "tip_resolve": "Resolve this name with the active DNS", + "tip_settings": "Plugin settings", + "title": "DNS Switcher", + "tooltip_actions": "Actions", + "tooltip_hints": "click: panel · right: reset to ISP · scroll: cycle", + "tooltip_provider": "DNS", + "tooltip_servers": "Servers", + "tooltip_switching": "Applying the DNS change — a password prompt may appear" +}