Skip to content
Open
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
32 changes: 26 additions & 6 deletions dns-switcher/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ rebuilt on the v5 Luau plugin API.
- **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
- **DNS lookup tester** at the bottom of the panel: resolve a name with
`dig`/`nslookup` against the active provider, or against any other provider
from its row menu. Use it to confirm a switch, or to find out if a provider
blocks a domain before you switch to it
- **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
Expand All @@ -44,6 +45,18 @@ Add the `dns-switcher` widget from Noctalia's widget picker. Default gestures:
| Scroll | Cycle to the next/previous configured provider |

All three are bar-level defaults and can be remapped from *Settings → Bar*.

In the panel, right-click a provider to open its row menu:

| Entry | Effect |
| --- | --- |
| **Apply this provider** | Same as a left click. On the active row it applies the profile again. |
| **Copy these addresses** | Copies that provider's addresses to the clipboard. |
| **Look up *name* through this resolver** | Sends the hostname from the *DNS lookup* box to that provider. It does not change the system DNS. |

The lookup entry needs a valid hostname in the box, and a provider that has its
own addresses. It is disabled for *Default (ISP)*.

The panel itself, and the plugin's settings page, also open from the CLI:

```sh
Expand Down Expand Up @@ -75,9 +88,13 @@ neighbouring provider (what scroll sends).

## Requirements

- noctalia v5.0.0-beta.7 or newer (`plugin_api = 17`, for the `onExit`
lifecycle cleanup in `service.luau`)
- noctalia v5.0.0-beta.9 or newer — the first release that accepts
`plugin_api = 28`. The plugin needs 28 for the provider row menu
(`panel.openContextMenu`), and 24 for argv process execution: every command
it runs is an argument vector, so no shell parses a DNS address, a hostname
or the privilege command. On beta.8 the plugin store keeps serving 0.1.2
- NetworkManager (`networkmanager`, provides `nmcli`) with an active connection
- `env` (coreutils) — runs `nmcli` under `LC_ALL=C`
- 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
Expand All @@ -90,7 +107,10 @@ 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.
The privilege command is applied to the `nmcli con mod` and `nmcli device
reapply` calls individually — never to a wrapping shell — so the sudoers
rule only ever needs to name `nmcli` itself:
rule only ever needs to name `nmcli` itself. It is split on whitespace into
separate arguments (`sudo -n` is two), and `nmcli` stays the program it is
asked to run, which is what the rule below matches on; a privilege command
whose own path contains spaces is not supported — use a wrapper script.

```
# /etc/sudoers.d/nmcli-dns
Expand Down
112 changes: 96 additions & 16 deletions dns-switcher/panel.luau
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
-- 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).
--
-- Right-clicking a provider raises a native context menu (plugin_api >= 28):
-- apply it, copy its addresses, or send the name in the lookup box THROUGH it
-- without switching to it — which is the question the panel could not answer
-- before, since the tester only ever queried the resolver already in use.

local STATE_KEY = "dns_state" -- published by service.luau
local REQUEST_KEY = "apply_request" -- consumed by service.luau
Expand Down Expand Up @@ -43,10 +48,6 @@ 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)
Expand All @@ -59,9 +60,10 @@ local function requestApply(entry)
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.
-- hyphen, 253 chars max — the DNS wire-format limit). Since 0.2.0 the lookup
-- runs as an argv (plugin_api >= 24), so there is no shell to defend against
-- and no quoting to get right: the name is one argument, whatever is in it.
-- This check is now only about not sending an obviously-wrong query at all.
local function isValidHostname(name)
return name ~= "" and #name <= 253 and name:match("^[%w][%w%.%-]*$") ~= nil
end
Expand Down Expand Up @@ -97,7 +99,11 @@ end
-- 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()
--
-- `serverOverride` ({ ip, label }) aims the query at a provider that is NOT
-- active — the row menu's lookup entry — which is a read-only question about
-- that resolver and changes no configuration at all.
local function runResolve(serverOverride)
local name = trim(resolveQuery)
if not isValidHostname(name) then
resolveError = tr("resolve_invalid")
Expand All @@ -106,7 +112,7 @@ local function runResolve()
return
end

local server = activeServerInfo()
local server = serverOverride or activeServerInfo()
local useDig = noctalia.commandExists("dig")
local useNslookup = not useDig and noctalia.commandExists("nslookup")
if not useDig and not useNslookup then
Expand All @@ -124,11 +130,16 @@ local function runResolve()
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)
cmd = { "dig", "+time=3", "+tries=1", "+short" }
if server ~= nil then
table.insert(cmd, "@" .. server.ip)
end
table.insert(cmd, name)
else
cmd = "nslookup " .. shellQuote(name) .. (server ~= nil and (" " .. shellQuote(server.ip)) or "")
cmd = { "nslookup", name }
if server ~= nil then
table.insert(cmd, server.ip)
end
end

local ok = noctalia.runAsync(cmd, function(result)
Expand Down Expand Up @@ -169,6 +180,39 @@ local function runResolve()
end
end

-- The provider row's right-click menu (plugin_api >= 28). Only a ui.button's
-- onRightClick reports the pointer serial openContextMenu needs, and the row IS
-- a button, so it raises this itself. `onActivate` must NAME a global
-- (openContextMenu is a raw binding, not a UI tree prop, so closures are not
-- registered for it); the provider id rides along in `context` and onProviderMenu
-- looks the entry up again in the current snapshot rather than capturing it.
--
-- Apply stays enabled on the active row too: `nmcli con mod` + `device reapply`
-- is idempotent, so re-applying is a real action (it pushes the profile back
-- onto the live connection). That also guarantees the one enabled entry the
-- host requires, whatever the other two are doing.
local function openProviderMenu(entry, active, text)
local hasIp = type(entry.ip) == "string" and entry.ip ~= ""
local name = trim(resolveQuery)
panel.openContextMenu({
onActivate = "onProviderMenu",
context = entry.id,
items = {
{ kind = "header", label = text },
{ id = "apply", label = tr(active and "menu.reapply" or "menu.apply") },
{ id = "copy", label = tr("menu.copy"), enabled = hasIp },
-- Named after the hostname it would send, so the entry says what
-- it does; with nothing usable in the box it turns into the hint
-- for how to make it work, disabled.
{
id = "test",
label = isValidHostname(name) and tr("menu.test", { name = name }) or tr("menu.test_hint"),
enabled = hasIp and isValidHostname(name),
},
},
})
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
Expand All @@ -191,9 +235,20 @@ local function providerRow(entry)
requestApply(entry)
end
end,
onRightClick = function()
openProviderMenu(entry, active, text)
end,
})
end

local function copyServers(text)
if text == nil or text == "" then
return
end
noctalia.copyToClipboard(text, "text/plain")
noctalia.notify(tr("title"), tr("copied", { ip = text }))
end

local function statusFooter()
if snapshot.changing == true then
return ui.label({ text = tr("status_switching"), fontSize = 11, color = "secondary" })
Expand Down Expand Up @@ -326,11 +381,36 @@ function onCopyServers()
if (text == nil or text == "") and snapshot.current ~= nil then
text = snapshot.current.ip
end
if text == nil or text == "" then
copyServers(text)
end

-- Dispatch for a provider row's context menu. `action` is the id of the entry
-- picked, `context` the provider id it was opened on — looked up again here,
-- since a poll may have replaced the whole list in between.
function onProviderMenu(action, context)
if snapshot == nil or type(snapshot.list) ~= "table" then
return
end
noctalia.copyToClipboard(text, "text/plain")
noctalia.notify(tr("title"), tr("copied", { ip = text }))
local entry = nil
for _, candidate in ipairs(snapshot.list) do
if candidate.id == context then
entry = candidate
break
end
end
if entry == nil then
return
end
if action == "apply" then
requestApply(entry)
elseif action == "copy" then
copyServers(entry.ip)
elseif action == "test" then
local ip = type(entry.ip) == "string" and entry.ip:match("%S+") or nil
if ip ~= nil then
runResolve({ ip = ip, label = entry.label })
end
end
end

-- Opens the settings window on this plugin's own page (the host supplies the
Expand Down
14 changes: 11 additions & 3 deletions dns-switcher/plugin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,25 @@
# 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).
# Right-click a provider to apply it, copy its addresses, or run the panel's
# DNS lookup through it without switching to it.

id = "nightwatch75/dns-switcher"
name = "DNS Switcher"
version = "0.1.2"
plugin_api = 17
version = "0.3.0"
# 28 for panel.openContextMenu: the provider row's right-click menu. Also 24
# for direct argv process execution — every command this plugin runs is an
# argument vector, so no shell ever parses a DNS address, a hostname or the
# privilege command.
plugin_api = 28
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"]
# `env` runs nmcli under LC_ALL=C: with no shell in the picture it is the only
# way left to fix the locale, and apply() reads nmcli's stderr.
dependencies = ["networkmanager", "env", "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)."
Expand Down
Loading
Loading