diff --git a/bin/omarchy-dns b/bin/omarchy-dns index 4e031678..fdb49183 100755 --- a/bin/omarchy-dns +++ b/bin/omarchy-dns @@ -28,8 +28,28 @@ provider_from_arg() { esac } -self_path() { - readlink -f "$OMARCHY_PATH/bin/${BASH_SOURCE[0]##*/}" +# The path etc/sudoers.d/omarchy-dns names. The privileged half always runs from +# there rather than from whichever copy was invoked, so the rule matches even +# where $OMARCHY_PATH points at a checkout. +PACKAGED_PATH=/usr/bin/omarchy-dns + +# True when etc/sudoers.d/omarchy-dns covers this invocation. Both halves of the +# rule have to hold -- one of the three providers, and %wheel -- or sudo asks +# for a password like any other command. +grant_covers() { + local provider="${1:-}" + local group + + case "$provider" in + Cloudflare | Google | DHCP) ;; + *) return 1 ;; + esac + + for group in $(id -nG 2>/dev/null); do + [[ $group == wheel ]] && return 0 + done + + return 1 } require_root() { @@ -37,11 +57,21 @@ require_root() { return fi - if [[ -t 0 ]]; then - exec sudo "$(self_path)" "$@" - else - exec pkexec "$(self_path)" "$@" + # A terminal can carry sudo's own password prompt. Without one, sudo is still + # right when etc/sudoers.d/omarchy-dns covers this invocation: that grant is + # what keeps the panel's one-click provider switch from raising a polkit + # prompt. Custom and a caller outside %wheel still go through polkit, which + # can at least offer to authenticate as someone else. + # + # Do not swap this for a `sudo -n -l` probe. That reports whether a command is + # permitted, not whether it is passwordless, so the blanket %wheel rule + # answers yes for every argument and Custom dies on `sudo -n` instead of + # falling through to pkexec. + if [[ -t 0 ]] || grant_covers "${1:-}"; then + exec sudo "$PACKAGED_PATH" "$@" fi + + exec pkexec "$PACKAGED_PATH" "$@" } networkmanager_global_dns() { diff --git a/etc/sudoers.d/omarchy-dns b/etc/sudoers.d/omarchy-dns new file mode 100644 index 00000000..4c2fc52c --- /dev/null +++ b/etc/sudoers.d/omarchy-dns @@ -0,0 +1,5 @@ +# Switching between the stock providers is a one-click toggle in the network +# panel, so it must not stop for a password. Custom is deliberately absent: it +# points the machine at servers the caller supplies, and it already runs in a +# terminal that can ask. +%wheel ALL=(root) NOPASSWD: /usr/bin/omarchy-dns Cloudflare, /usr/bin/omarchy-dns Google, /usr/bin/omarchy-dns DHCP diff --git a/test/shell.d/dns-sudoers-test.sh b/test/shell.d/dns-sudoers-test.sh new file mode 100755 index 00000000..25cb2272 --- /dev/null +++ b/test/shell.d/dns-sudoers-test.sh @@ -0,0 +1,97 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +dns="$ROOT/bin/omarchy-dns" +sudoers_file="$ROOT/etc/sudoers.d/omarchy-dns" +rule='%wheel ALL=(root) NOPASSWD: /usr/bin/omarchy-dns Cloudflare, /usr/bin/omarchy-dns Google, /usr/bin/omarchy-dns DHCP' + +# Exactly one rule, matched whole. A second line -- or the same command with its +# arguments dropped, which sudoers reads as "any arguments" -- would widen the +# grant while leaving this line in place. +rules=$(grep -vE '^[[:space:]]*(#|$)' "$sudoers_file") +[[ $rules == "$rule" ]] || + fail "dns sudoers file carries exactly the stock-provider rule and nothing else" "got: $rules" + +if command -v visudo >/dev/null; then + visudo -cf "$sudoers_file" >/dev/null || fail "dns sudoers rule parses" +fi + +grep -Fx 'PACKAGED_PATH=/usr/bin/omarchy-dns' "$dns" >/dev/null || + fail "omarchy-dns elevates the path the sudoers rule names" + +# sudo -l answers whether a command is permitted, not whether it is +# passwordless, and Omarchy ships a blanket %wheel rule that permits +# everything. A probe built on it sends Custom into `sudo -n`, which fails +# outright instead of falling through to pkexec. +! grep -E '^[[:space:]]*[^#[:space:]].*sudo -n -l' "$dns" >/dev/null || + fail "omarchy-dns does not decide elevation with a sudo -l probe" + +pass "dns sudoers rule is scoped to the stock providers" + +# require_root returns immediately for root, so the stubs below would not stand +# between the script and the host's real NetworkManager and resolved config. +if (( EUID == 0 )); then + pass "running as root; skipping the elevation checks, which would rewrite this machine's DNS" + exit 0 +fi + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +mkdir -p "$stub_bin" + +# Both stubs stand in for the exec at the end of require_root, so the DNS writes +# below it never run, and neither real sudo nor real pkexec is reached. +for command in sudo pkexec; do + cat >"$stub_bin/$command" <"\$ELEVATION_LOG" +SH + chmod +x "$stub_bin/$command" +done + +# The rule is %wheel-scoped, so group membership decides the route as much as +# the provider does. Stub id rather than reading the real groups: the suite has +# to give the same answer on a build user outside wheel. +cat >"$stub_bin/id" <<'SH' +#!/bin/bash +[[ ${1:-} == -nG ]] || exec /usr/bin/id "$@" +printf '%s\n' "${STUB_GROUPS-users wheel}" +SH +chmod +x "$stub_bin/id" + +elevation_for() { + : >"$test_tmp/elevation" + ELEVATION_LOG="$test_tmp/elevation" \ + PATH="$stub_bin:$PATH" \ + bash "$dns" "$1" /dev/null + cat "$test_tmp/elevation" +} + +for provider in Cloudflare Google DHCP; do + elevation=$(elevation_for "$provider") + [[ $elevation == "sudo /usr/bin/omarchy-dns $provider" ]] || + fail "omarchy-dns takes the passwordless sudo grant for $provider without a terminal" "got: $elevation" +done + +pass "omarchy-dns elevates the stock providers through sudo, not polkit" + +# A dev-linked checkout elevates the packaged path like everyone else, rather +# than handing sudo a path no rule can name and losing the grant. +dev_linked=$(OMARCHY_PATH="$test_tmp/checkout" elevation_for Cloudflare) +[[ $dev_linked == "sudo /usr/bin/omarchy-dns Cloudflare" ]] || + fail "omarchy-dns elevates the system install wherever OMARCHY_PATH points" "got: $dev_linked" + +custom=$(elevation_for Custom) +[[ $custom == "pkexec /usr/bin/omarchy-dns Custom" ]] || + fail "omarchy-dns leaves Custom on the polkit path, since no sudoers rule covers it" "got: $custom" + +non_wheel=$(STUB_GROUPS="users" elevation_for Cloudflare) +[[ $non_wheel == "pkexec /usr/bin/omarchy-dns Cloudflare" ]] || + fail "omarchy-dns leaves a user outside %wheel on the polkit path, since the rule cannot match them" "got: $non_wheel" + +pass "omarchy-dns falls back to polkit wherever the grant does not reach"