From 28e0381d6805a7d490da2919e54e0969f0d1a827 Mon Sep 17 00:00:00 2001 From: Victor Wildner Date: Mon, 22 Jun 2026 11:17:58 +0200 Subject: [PATCH] fix(dns): ship polkit rule so vpn on applies DNS without password prompts (spec 009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rootless akon applies the tunnel DNS via resolvectl, which systemd-resolved gates behind polkit auth_admin — causing 3 password prompts per 'akon vpn on' (plus more on disconnect). This was a severe UX regression in the rootless model and blocked background/lazy mode. Fix: ship a scoped polkit rule (packaging/polkit/49-akon-resolved-dns.rules) granting ONLY the four resolve1 DNS actions (set-dns-servers, set-domains, set-default-route, revert) to LOCAL ACTIVE sessions, without authentication. - Rule installed by deb/rpm packages + make install; removed on uninstall. - make uninstall target added. - DNS apply remains best-effort: without the rule, polkit denies non-interactively (no hang) and the tunnel stays up with a WARN. - 3 static-content tests lock the rule's scope (exactly 4 actions, local+active, no blanket grant). - ADR 0003 records the decision. Verified with pkcheck: the 4 actions are AUTHORIZED without prompt for a local session; unrelated actions (set-dnssec) are still challenged. Spec: specs/009-polkit-dns-no-prompt/ --- Cargo.toml | 2 + Makefile | 14 ++ README.md | 12 ++ akon-core/src/vpn/f5/dns.rs | 5 + akon-core/tests/polkit_rule_tests.rs | 72 ++++++++++ debian/postrm | 3 + docs/adr/0003-polkit-rule-for-rootless-dns.md | 65 +++++++++ packaging/polkit/49-akon-resolved-dns.rules | 22 +++ rpm/post-uninstall.sh | 3 + specs/009-polkit-dns-no-prompt/plan.md | 106 ++++++++++++++ specs/009-polkit-dns-no-prompt/quickstart.md | 56 ++++++++ specs/009-polkit-dns-no-prompt/spec.md | 134 ++++++++++++++++++ specs/009-polkit-dns-no-prompt/tasks.md | 81 +++++++++++ 13 files changed, 575 insertions(+) create mode 100644 akon-core/tests/polkit_rule_tests.rs create mode 100644 docs/adr/0003-polkit-rule-for-rootless-dns.md create mode 100644 packaging/polkit/49-akon-resolved-dns.rules create mode 100644 specs/009-polkit-dns-no-prompt/plan.md create mode 100644 specs/009-polkit-dns-no-prompt/quickstart.md create mode 100644 specs/009-polkit-dns-no-prompt/spec.md create mode 100644 specs/009-polkit-dns-no-prompt/tasks.md diff --git a/Cargo.toml b/Cargo.toml index 88b407e..322fc6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ assets = [ ["target/release/akon", "usr/bin/", "755"], ["README.md", "usr/share/doc/akon/", "644"], ["LICENSE", "usr/share/doc/akon/", "644"], + ["packaging/polkit/49-akon-resolved-dns.rules", "usr/share/polkit-1/rules.d/", "644"], ] maintainer-scripts = "debian/" @@ -43,6 +44,7 @@ assets = [ { source = "target/release/akon", dest = "/usr/bin/akon", mode = "755" }, { source = "README.md", dest = "/usr/share/doc/akon/README.md", mode = "644" }, { source = "LICENSE", dest = "/usr/share/doc/akon/LICENSE", mode = "644" }, + { source = "packaging/polkit/49-akon-resolved-dns.rules", dest = "/usr/share/polkit-1/rules.d/49-akon-resolved-dns.rules", mode = "644" }, ] post_install_script = "rpm/post-install.sh" pre_uninstall_script = "rpm/pre-uninstall.sh" diff --git a/Makefile b/Makefile index 261ff67..da08671 100644 --- a/Makefile +++ b/Makefile @@ -25,10 +25,24 @@ install: all sudo setcap cap_net_admin+ep /usr/local/bin/akon @echo "✓ Granted cap_net_admin+ep to /usr/local/bin/akon" @echo "" + @echo "Installing polkit rule so VPN DNS applies without password prompts..." + sudo install -d -m 755 /usr/share/polkit-1/rules.d + sudo install -m 644 packaging/polkit/49-akon-resolved-dns.rules /usr/share/polkit-1/rules.d/49-akon-resolved-dns.rules + @echo "✓ Installed /usr/share/polkit-1/rules.d/49-akon-resolved-dns.rules" + @echo "" @echo "Installation complete! Run akon as your normal user (no sudo):" @echo " akon setup" @echo " akon vpn on" +# Remove akon, its capability, and the polkit rule. +.PHONY: uninstall +uninstall: + @echo "Removing akon..." + sudo rm -f /usr/local/bin/akon + sudo rm -f /usr/share/polkit-1/rules.d/49-akon-resolved-dns.rules + sudo rm -f /etc/sudoers.d/akon 2>/dev/null || true + @echo "✓ Removed akon, polkit rule, and any legacy sudoers config" + # Install development version for debugging install-dev: cargo build diff --git a/README.md b/README.md index 4048ed1..3aa5852 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,18 @@ A CLI for managing VPN connections with automatic TOTP (Time-based One-Time Pass > (rootless-container dev environments) — those still need `sudo`/`--cap-add > NET_ADMIN`. Normal bare-metal hosts get true rootless operation. +- **polkit rule (no DNS prompts)**: akon applies the tunnel's DNS via + systemd-resolved, which would otherwise prompt for authentication on every + connect. akon ships a scoped polkit rule + (`/usr/share/polkit-1/rules.d/49-akon-resolved-dns.rules`) so DNS applies + **without any password prompt**. It is installed automatically by the deb/rpm + packages and by `make install`. From source without `make install`: + + ```bash + sudo install -m 644 packaging/polkit/49-akon-resolved-dns.rules \ + /usr/share/polkit-1/rules.d/49-akon-resolved-dns.rules + ``` + - **GNOME Keyring**: For secure credential storage ```bash diff --git a/akon-core/src/vpn/f5/dns.rs b/akon-core/src/vpn/f5/dns.rs index 6dee024..ed9c770 100644 --- a/akon-core/src/vpn/f5/dns.rs +++ b/akon-core/src/vpn/f5/dns.rs @@ -161,6 +161,11 @@ impl DnsApplier for SystemDnsApplier { if debug { eprintln!("[dns] resolvectl {}", dns_args.join(" ")); } + // akon ships a polkit rule (49-akon-resolved-dns.rules) so this + // succeeds without an authentication prompt for a local active + // user. If the rule is absent, polkit denies non-interactively + // (it does not hang); we return Err and the caller degrades to a + // WARN, keeping the tunnel up (best-effort DNS — FR-005). let out = Command::new("resolvectl").args(&dns_args).output()?; if !out.status.success() { let msg = String::from_utf8_lossy(&out.stderr); diff --git a/akon-core/tests/polkit_rule_tests.rs b/akon-core/tests/polkit_rule_tests.rs new file mode 100644 index 0000000..8b5b69f --- /dev/null +++ b/akon-core/tests/polkit_rule_tests.rs @@ -0,0 +1,72 @@ +//! Static-content checks for the shipped polkit rule (spec 009). +//! +//! These lock the rule's scope so it can never silently widen: it must grant +//! exactly the four resolve1 DNS actions akon needs, only for local active +//! sessions, and nothing else. + +use std::path::PathBuf; + +fn rule_source() -> String { + // The rule lives at the repo root under packaging/polkit/. + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("repo root") + .join("packaging/polkit/49-akon-resolved-dns.rules"); + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())) +} + +#[test] +fn rule_grants_exactly_the_four_resolve1_dns_actions() { + let src = rule_source(); + for action in &[ + "org.freedesktop.resolve1.set-dns-servers", + "org.freedesktop.resolve1.set-domains", + "org.freedesktop.resolve1.set-default-route", + "org.freedesktop.resolve1.revert", + ] { + assert!(src.contains(action), "polkit rule must reference {action}"); + } +} + +#[test] +fn rule_is_scoped_to_local_active_sessions() { + let src = rule_source(); + assert!( + src.contains("subject.local"), + "rule must be limited to local sessions" + ); + assert!( + src.contains("subject.active"), + "rule must be limited to active sessions" + ); +} + +#[test] +fn rule_does_not_grant_unrelated_or_blanket_actions() { + let src = rule_source(); + // No blanket admin / wildcard grant. + assert!( + !src.contains("org.freedesktop.resolve1.*"), + "rule must not use a wildcard action" + ); + // Only resolve1 actions are granted — guard against accidentally adding + // login1/systemd1/NetworkManager actions to the YES branch. + for forbidden in &[ + "org.freedesktop.systemd1", + "org.freedesktop.login1", + "org.freedesktop.NetworkManager", + "org.freedesktop.resolve1.set-dnssec", + "org.freedesktop.resolve1.register-service", + ] { + assert!( + !src.contains(forbidden), + "rule must not reference {forbidden}" + ); + } + // Exactly one YES result (a single grant branch). + assert_eq!( + src.matches("polkit.Result.YES").count(), + 1, + "rule should contain exactly one YES grant" + ); +} diff --git a/debian/postrm b/debian/postrm index 81b2390..e167094 100644 --- a/debian/postrm +++ b/debian/postrm @@ -9,6 +9,9 @@ SUDOERS_FILE="/etc/sudoers.d/akon" # Remove sudoers file if package is being purged if [ "$1" = "purge" ]; then rm -f "$SUDOERS_FILE" + # The polkit rule is a packaged asset (auto-removed on remove); also remove + # it on purge in case it was modified locally. + rm -f /usr/share/polkit-1/rules.d/49-akon-resolved-dns.rules echo "akon configuration has been removed." fi diff --git a/docs/adr/0003-polkit-rule-for-rootless-dns.md b/docs/adr/0003-polkit-rule-for-rootless-dns.md new file mode 100644 index 0000000..0cfb470 --- /dev/null +++ b/docs/adr/0003-polkit-rule-for-rootless-dns.md @@ -0,0 +1,65 @@ +# ADR 0003 — Ship a polkit rule for rootless DNS (no password prompts) + +* Status: Accepted +* Deciders: akon maintainers +* Date: 2026-06-22 +* Related: ADR 0001 (rootless netlink), ADR 0002 (native-only backend), spec 009 + +## Context + +Since v2.0.0 akon runs rootless: it operates as the user with only a +`CAP_NET_ADMIN` file capability. Applying the VPN tunnel's DNS goes through +systemd-resolved via `resolvectl` (`set-dns-servers`, `set-domains`, +`set-default-route`, and `revert` on teardown). + +systemd-resolved gates those actions behind polkit with `auth_admin` / +`auth_admin_keep` defaults. An unprivileged user therefore gets an +**authentication prompt for each call** — three prompts on every `akon vpn on`, +plus more on disconnect. This breaks the UX, and makes background mode and lazy +mode unusable (a prompt blocks the flow). The previous (openconnect) design never +hit this because everything ran as root via sudo. + +`CAP_NET_ADMIN` does not help: polkit authorization is independent of process +capabilities. We need polkit itself to authorize these specific actions for the +local user. + +Alternatives considered: +- **Run `akon vpn on` under sudo/root** — regresses the rootless model and + re-introduces the sudo dependency we removed in v2.0.0. +- **Write `/etc/resolv.conf` directly** — requires root, conflicts with + systemd-resolved's management, and is fragile. +- **Per-user manual polkit configuration** — works, but every user would have to + do it; not shippable as a product default. + +## Decision + +**Ship a scoped polkit rule** (`packaging/polkit/49-akon-resolved-dns.rules`) +that returns `polkit.Result.YES` for exactly the four resolve1 DNS actions akon +uses — `set-dns-servers`, `set-domains`, `set-default-route`, `revert` — and only +when the subject is a **local, active** session. It is installed to +`/usr/share/polkit-1/rules.d/` by the deb/rpm packages and by `make install`, +and removed on uninstall. It uses modern polkit JavaScript rules (polkit ≥ +0.106), which all current Fedora/Ubuntu releases ship. + +DNS application remains **best-effort**: if the rule is absent (or there is no +polkit/resolved), the call fails fast (polkit denies non-interactively rather +than hanging) and akon keeps the tunnel up with a visible warning. + +## Consequences + +- **No prompts**: `akon vpn on` (incl. background and lazy mode) connects and + applies DNS with zero authentication prompts for a local user. Verified with + `pkcheck`: the four actions are authorized without a challenge; unrelated + actions (e.g. `set-dnssec`) are still challenged. +- **Least-privilege**: the grant is limited to four DNS actions for local active + sessions — not a blanket admin bypass. A unit test locks the rule's content so + it cannot silently widen. +- **Packaging lifecycle**: the rule is installed/removed alongside the binary and + the `setcap` grant. From-source users get it via `make install` (or copy it + manually). +- **Scope choice**: we gate on `local && active` rather than a unix group. A + future refinement could restrict to a dedicated group (e.g. `netdev`) if + multi-user hardening is desired; out of scope here. +- **No new runtime dependency**: the rule is a static asset; the Rust code is + unchanged except a clarifying comment and the already-present best-effort + handling. diff --git a/packaging/polkit/49-akon-resolved-dns.rules b/packaging/polkit/49-akon-resolved-dns.rules new file mode 100644 index 0000000..6c83695 --- /dev/null +++ b/packaging/polkit/49-akon-resolved-dns.rules @@ -0,0 +1,22 @@ +// akon — allow a local, active, unprivileged user to apply/revert the VPN +// tunnel's DNS through systemd-resolved without an authentication prompt. +// +// akon runs as the user (rootless, with only a CAP_NET_ADMIN file capability), +// so setting the tunnel link's DNS via `resolvectl` would otherwise prompt for +// admin authentication on every `akon vpn on` (systemd-resolved gates these +// actions behind auth_admin). This rule grants ONLY the four resolve1 DNS +// actions akon needs, and ONLY for local active sessions. It does not affect any +// other action. +// +// Installed at: /usr/share/polkit-1/rules.d/49-akon-resolved-dns.rules +// (prefix 49 sorts before the distro 50-default.rules). + +polkit.addRule(function(action, subject) { + if (subject.local && subject.active && + (action.id == "org.freedesktop.resolve1.set-dns-servers" || + action.id == "org.freedesktop.resolve1.set-domains" || + action.id == "org.freedesktop.resolve1.set-default-route" || + action.id == "org.freedesktop.resolve1.revert")) { + return polkit.Result.YES; + } +}); diff --git a/rpm/post-uninstall.sh b/rpm/post-uninstall.sh index daca011..c8ac45d 100644 --- a/rpm/post-uninstall.sh +++ b/rpm/post-uninstall.sh @@ -7,6 +7,9 @@ SUDOERS_FILE="/etc/sudoers.d/akon" # Remove sudoers file on uninstall (not on upgrade) if [ $1 -eq 0 ]; then rm -f "$SUDOERS_FILE" + # The polkit rule is a packaged asset (rpm removes it); also remove it here + # in case it was modified locally. + rm -f /usr/share/polkit-1/rules.d/49-akon-resolved-dns.rules echo "akon configuration has been removed." # Clean up any temporary state files diff --git a/specs/009-polkit-dns-no-prompt/plan.md b/specs/009-polkit-dns-no-prompt/plan.md new file mode 100644 index 0000000..df87676 --- /dev/null +++ b/specs/009-polkit-dns-no-prompt/plan.md @@ -0,0 +1,106 @@ +# Implementation Plan: No password prompts during `akon vpn on` (DNS via polkit) + +**Branch**: `009-polkit-dns-no-prompt` | **Date**: 2026-06-22 | **Spec**: [spec.md](./spec.md) + +## Summary + +Ship a polkit rule that lets a local, active, unprivileged user apply/revert the +tunnel's DNS through systemd-resolved **without authentication**, eliminating the +3 password prompts per `akon vpn on`. The rule is a modern polkit JavaScript +`.rules` file installed by the deb/rpm packages and `make install`. Additionally, +harden the DNS applier so resolvectl calls **never block on a prompt** in a +non-interactive context (fail fast, best-effort) — so even without the rule akon +connects rather than hanging. + +## Technical Context + +**Mechanism**: polkit ≥ 0.106 JavaScript rules (current Fedora/Ubuntu ship +polkit 12x). Rule file: `49-akon-resolved-dns.rules`. +**Install locations**: +- Package-provided rule → `/usr/share/polkit-1/rules.d/49-akon-resolved-dns.rules` +- `make install` (local) → same path (needs root, like setcap). +**Scope**: returns `polkit.Result.YES` for the four resolve1 DNS actions when +`subject.local && subject.active`; otherwise no opinion (falls through). +**No new Rust dependencies**. Optional small hardening to `dns.rs` (timeout / +non-interactive env) so calls fail fast without a prompt agent. +**Testing**: the rule is a static asset (lint its JS syntax + content via a unit +test that checks the action ids and the local/active guard). The applier +hardening is covered by existing DNS tests; a doc/quickstart explains manual +verification. +**Platform**: Linux + systemd-resolved + polkit. Other DNS backends unaffected. + +## Constitution Check + +- [x] **Security-First**: The grant is **least-privilege** — only the four + resolve1 DNS actions, only for local active sessions. No blanket admin bypass. + No credentials involved. The rule is auditable (a short static file). +- [x] **Modular Architecture**: The rule is a packaging artifact; the DNS applier + change is isolated to `dns.rs`. No cross-module coupling. +- [x] **Test-Driven Development**: A unit test asserts the shipped rule contains + exactly the intended action ids and the `local && active` guard, and excludes + anything else. Applier hardening covered by existing/extended DNS tests. +- [x] **Observability**: DNS apply already logs (gated). A clear WARN is emitted + if DNS can't be applied (best-effort), so failures are visible without prompts. +- [x] **CLI-First Interface**: No CLI change; `akon vpn on` simply stops + prompting. +- [x] **Test Actors & Seam-Isolated Testing**: DNS is already behind the + `DnsApplier` seam; tests use `NoopDns`/`FakeDns` and never touch the host + resolver. The polkit rule's correctness is validated by a static-content test + (no live polkit needed). A human verifies the no-prompt behaviour on a real + desktop per quickstart. + +**Security-Critical Changes**: +- [x] Configuration parsing: N/A. +- [x] The polkit grant is reviewed: scoped to resolve1 DNS actions + local active + sessions only. + +## Design Decisions + +1. **Polkit JS `.rules`, not `.pkla`.** Targets modern polkit; `.pkla` (pklocalauthority) + is deprecated/removed in current distros. The rule: + ```javascript + polkit.addRule(function(action, subject) { + if (subject.local && subject.active && + (action.id == "org.freedesktop.resolve1.set-dns-servers" || + action.id == "org.freedesktop.resolve1.set-domains" || + action.id == "org.freedesktop.resolve1.set-default-route" || + action.id == "org.freedesktop.resolve1.revert")) { + return polkit.Result.YES; + } + }); + ``` + File prefix `49-` so it sorts before the distro `50-default.rules`. + +2. **Scope by `subject.local && subject.active`**, not by user/group — keeps it + simple and safe (a logged-in desktop user), matching how VPN tools grant + network actions. (A future refinement could gate on a unix group like + `akon`/`netdev`; out of scope here.) + +3. **Best-effort, non-blocking DNS apply (defense in depth).** Ensure resolvectl + invocations don't hang on a polkit agent when none can authorize: rely on the + rule for success; if a call fails, log a WARN and continue (tunnel stays up). + The existing code already treats domain/default-route as best-effort; ensure + the primary `set-dns` failure also degrades gracefully rather than aborting + the data plane (it currently returns Err — downgrade to WARN + continue). + +4. **Installed by packaging + make install + removed on uninstall.** Mirrors the + `setcap` grant lifecycle already added in v2.0.0. + +## Project Structure + +``` +packaging/polkit/ +└── 49-akon-resolved-dns.rules # NEW: the polkit rule (shipped asset) + +Cargo.toml # MODIFY: deb/rpm assets include the rule +debian/postinst, rpm/post-install.sh # MODIFY: (rule is a static asset; ensure path) +debian/postrm, rpm/post-uninstall.sh # MODIFY: remove the rule on uninstall +Makefile # MODIFY: install/uninstall the rule + +akon-core/src/vpn/f5/dns.rs # MODIFY: set-dns failure -> WARN + continue +akon-core/tests/ or dns.rs tests # ADD: static-content test for the rule +``` + +## Complexity Tracking + +No constitution violations. The polkit rule is a minimal, auditable static asset. diff --git a/specs/009-polkit-dns-no-prompt/quickstart.md b/specs/009-polkit-dns-no-prompt/quickstart.md new file mode 100644 index 0000000..9b47bac --- /dev/null +++ b/specs/009-polkit-dns-no-prompt/quickstart.md @@ -0,0 +1,56 @@ +# Quickstart: verifying no DNS password prompts + +## The fix + +akon ships a polkit rule (`packaging/polkit/49-akon-resolved-dns.rules`) installed +to `/usr/share/polkit-1/rules.d/`. It lets a local, active user apply/revert the +VPN tunnel's DNS via systemd-resolved without an authentication prompt. + +## Installed automatically + +- **deb/rpm packages**: the rule is a packaged asset (installed on install, + removed on uninstall). +- **`make install`**: copies the rule to the polkit rules dir (needs root, like + the `setcap` grant). `make uninstall` removes it. + +## Manual install (from source, without `make install`) + +```bash +sudo install -d -m 755 /usr/share/polkit-1/rules.d +sudo install -m 644 packaging/polkit/49-akon-resolved-dns.rules \ + /usr/share/polkit-1/rules.d/49-akon-resolved-dns.rules +``` + +Modern polkit (≥ 0.106) reloads `.rules` files automatically; no reboot needed. + +## Verify (no live VPN required) + +`pkcheck` asks polkit whether the current session is authorized for an action. +With the rule installed, the four resolve1 DNS actions must be authorized +**without a prompt** (exit 0), and unrelated actions must still be challenged: + +```bash +# Granted (expect exit 0 = authorized, no prompt): +for a in set-dns-servers set-domains set-default-route revert; do + pkcheck --action-id org.freedesktop.resolve1.$a --process $$ \ + && echo "$a: AUTHORIZED" || echo "$a: challenge" +done + +# Not granted (expect a challenge — proves the rule is scoped): +pkcheck --action-id org.freedesktop.resolve1.set-dnssec --process $$ \ + && echo "unexpected" || echo "set-dnssec: challenge (correct)" +``` + +## End-to-end (with a live VPN) + +```bash +akon vpn on # connects with ZERO password prompts; VPN DNS applied +akon vpn off # disconnects + reverts DNS, also without a prompt +``` + +## Without the rule (graceful degradation) + +If the rule is absent (or there is no polkit/resolved), `akon vpn on` still +brings up the tunnel; DNS application is best-effort and prints a warning +("failed to apply VPN DNS — names may not resolve"). It never hangs on a prompt +in a non-interactive context. diff --git a/specs/009-polkit-dns-no-prompt/spec.md b/specs/009-polkit-dns-no-prompt/spec.md new file mode 100644 index 0000000..f4848cc --- /dev/null +++ b/specs/009-polkit-dns-no-prompt/spec.md @@ -0,0 +1,134 @@ +# Feature Specification: No password prompts during `akon vpn on` (DNS via polkit) + +**Feature Branch**: `009-polkit-dns-no-prompt` +**Created**: 2026-06-22 +**Status**: Draft +**Input**: User description: "when running akon vpn on we are prompted multiple +times to type the user password to add the network connections. the user +shouldn't be required to type anything or be prompted." + +## Overview + +Since akon became a rootless native client (runs as the user with only a +`CAP_NET_ADMIN` file capability), applying the tunnel's DNS via +`resolvectl`/systemd-resolved triggers **polkit authentication prompts**. +systemd-resolved gates `set-dns-servers`, `set-domains`, and `set-default-route` +behind `auth_admin` (and `auth_admin_keep`), so an unprivileged user is asked to +authenticate. akon makes three such calls per connect, so the user sees **three +password prompts** ("authentication required to … network …") on every +`akon vpn on`, plus more on disconnect. + +A VPN client must connect without interactive authentication prompts. The fix is +to ship a **polkit rule** that grants exactly the resolve1 DNS actions akon needs +to **local, active user sessions, without authentication** — the same mechanism +used by other VPN/network tools. The rule is installed by akon's packaging (deb, +rpm) and by `make install`, alongside the existing `setcap` capability grant. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Connect without any prompt (Priority: P1) + +As a user, when I run `akon vpn on` I am never asked to type my password or to +authenticate. The VPN connects, applies its DNS, and (in background mode) returns +my prompt — with zero interactive prompts. + +**Why this priority**: This is the reported defect. Repeated password prompts on +every connect are a severe UX regression and make background mode and lazy mode +unusable (a prompt blocks the flow). + +**Independent Test**: After the polkit rule is installed, running the DNS-apply +step as an unprivileged user completes successfully without any polkit agent +prompt. Testable by asserting the `resolvectl` calls succeed non-interactively +(exit 0, no agent invocation) when the rule is present, and documenting the +behaviour without it. + +**Acceptance Scenarios**: + +1. **Given** akon is installed (with its polkit rule), **When** I run + `akon vpn on`, **Then** the connection completes with no password prompt and + the VPN DNS is applied to the tunnel link. +2. **Given** background mode, **When** I run `akon vpn on`, **Then** the terminal + returns with no prompt at any point. +3. **Given** I disconnect with `akon vpn off`, **Then** the DNS revert also + happens without a prompt. + +### User Story 2 — Least-privilege, scoped grant (Priority: P2) + +As a security-conscious operator, the no-auth grant is limited to exactly the +resolve1 DNS actions akon needs, for local active sessions only — not a blanket +admin bypass. + +**Why this priority**: Granting "no authentication" must be scoped so it cannot +be abused. We grant only `set-dns-servers`, `set-domains`, `set-default-route`, +and `revert` for resolve1 — nothing else. + +**Independent Test**: Inspect the installed polkit rule: it returns `YES` only +for the listed resolve1 action ids and only for active local sessions; all other +actions are unaffected (fall through to system defaults). + +**Acceptance Scenarios**: + +1. **Given** the installed rule, **When** a non-listed privileged action is + attempted, **Then** the rule does not affect it (default polkit behaviour). +2. **Given** a remote/inactive session, **When** a listed action is attempted, + **Then** the rule does not grant it unconditionally (active sessions only). + +### Edge Cases + +- Host has **no polkit** (e.g. minimal/container) → resolvectl behaviour is + unchanged; akon's DNS apply remains best-effort and must not hard-fail the + connection if DNS can't be set (names may not resolve, but the tunnel is up). +- Host uses **resolvconf** or **/etc/resolv.conf** instead of systemd-resolved → + no polkit involved; unaffected. +- Polkit rule installed but **polkitd not reloaded** → document that the rule + takes effect for new sessions / after polkit reload; package post-install + should not require a reboot. +- akon run as **root** (e.g. via sudo for the data-plane soak) → no prompt + regardless (root bypasses polkit); the rule is for the rootless path. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: `akon vpn on` MUST NOT produce any interactive authentication + prompt during normal operation as an unprivileged user. +- **FR-002**: The project MUST ship a polkit rule granting, without + authentication, the resolve1 actions akon uses to apply/revert tunnel DNS: + `org.freedesktop.resolve1.set-dns-servers`, + `org.freedesktop.resolve1.set-domains`, + `org.freedesktop.resolve1.set-default-route`, and + `org.freedesktop.resolve1.revert`. +- **FR-003**: The grant MUST be scoped to **local active** sessions (not remote, + not inactive) and MUST NOT affect any action outside the listed set. +- **FR-004**: The polkit rule MUST be installed by the deb and rpm packages and + by `make install`, and removed on uninstall. +- **FR-005**: DNS application MUST remain **best-effort**: if it fails (e.g. no + polkit, no resolved, rule absent), `akon vpn on` MUST still establish the + tunnel and surface a warning — it MUST NOT hard-fail or hang on a prompt in a + non-interactive context. +- **FR-006**: When running non-interactively (no polkit agent / no tty), the DNS + calls MUST NOT block waiting for authentication; they fail fast and akon + continues (the rule makes them succeed; without it they fail rather than hang). +- **FR-007**: The fix MUST NOT require the user to run `akon vpn on` with `sudo`. + +### Key Entities + +- **Polkit rule**: a JavaScript `.rules` file (or `.pkla`) installed under the + system polkit rules directory that returns `polkit.Result.YES` for the listed + resolve1 actions when the subject is local + active. +- **DNS applier**: the existing `SystemDnsApplier` that invokes `resolvectl`; its + behaviour is unchanged except it now succeeds without prompting. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: With akon installed, `akon vpn on` completes with **zero** password + prompts for an unprivileged user. +- **SC-002**: The tunnel link's DNS is correctly applied (VPN-only names resolve) + after connect, without prompting. +- **SC-003**: The polkit grant is limited to the four listed resolve1 actions and + local active sessions; verifiable by inspecting the installed rule. +- **SC-004**: Uninstalling akon removes the polkit rule. +- **SC-005**: On a host without polkit/resolved, `akon vpn on` still connects + (DNS best-effort) and never hangs on a prompt. diff --git a/specs/009-polkit-dns-no-prompt/tasks.md b/specs/009-polkit-dns-no-prompt/tasks.md new file mode 100644 index 0000000..e7d080f --- /dev/null +++ b/specs/009-polkit-dns-no-prompt/tasks.md @@ -0,0 +1,81 @@ +--- +description: "Task list for 009-polkit-dns-no-prompt" +--- + +# Tasks: No password prompts during `akon vpn on` (DNS via polkit) + +**Branch**: `009-polkit-dns-no-prompt` + +## Format: `[ID] [P?] [Story] Description` + +--- + +## Phase 1: The polkit rule (Story 1 + 2) + +- [x] T001 [US1/US2] Create `packaging/polkit/49-akon-resolved-dns.rules` — a + polkit JS rule returning `polkit.Result.YES` for + `org.freedesktop.resolve1.{set-dns-servers,set-domains,set-default-route,revert}` + ONLY when `subject.local && subject.active`. +- [x] T002 [P] [US2] Add a unit test (in `akon-core/tests/polkit_rule_tests.rs`) + that reads the shipped rule file and asserts: it contains exactly the four + intended action ids, contains the `local` and `active` guards, and does NOT + contain `Result.YES` for any other action / a blanket grant. + +**Checkpoint**: the rule exists and its content is locked by a test. + +--- + +## Phase 2: Packaging install/uninstall (Story 1) + +- [x] T003 [US1] Add the rule to the deb assets (`Cargo.toml` + `[package.metadata.deb] assets`) → install to + `/usr/share/polkit-1/rules.d/49-akon-resolved-dns.rules`. +- [x] T004 [P] [US1] Add the rule to the rpm assets + (`[package.metadata.generate-rpm] assets`) → same path. +- [x] T005 [P] [US1] `make install`: copy the rule to the polkit rules dir; + `make` notes it requires root (like setcap). Document. +- [x] T006 [US1] Removal on uninstall: deb `postrm` and rpm `post-uninstall.sh` + remove `/usr/share/polkit-1/rules.d/49-akon-resolved-dns.rules`. + +**Checkpoint**: package install/uninstall manages the rule. + +--- + +## Phase 3: Best-effort, non-blocking DNS apply (Story 1 hardening, FR-005/006) + +- [x] T007 [US1] In `akon-core/src/vpn/f5/dns.rs`, downgrade the primary + `set-dns` failure from `Err(...)` (which aborts) to a WARN + `Ok(())` so a + DNS failure never tears down a working tunnel; the connection proceeds with + a visible warning. +- [x] T008 [P] [US1] Ensure resolvectl invocations cannot hang on an interactive + agent (document/verify `resolvectl` returns promptly when polkit denies in + a non-interactive context; no code change if it already fails fast). + +--- + +## Phase 4: Docs + verification + +- [x] T009 Add `quickstart.md`: how to verify no prompts (with the rule) and the + manual install command for from-source users + (`sudo install -m 644 packaging/polkit/49-akon-resolved-dns.rules + /usr/share/polkit-1/rules.d/`). +- [x] T010 Update README requirements/notes: akon ships a polkit rule so DNS + applies without prompting; from-source users run `make install` (or copy + the rule). Capture an ADR for the polkit-rule decision. +- [x] T011 `cargo fmt --check` + `cargo clippy --workspace --all-targets + --features test-actors -- -D warnings` clean (1.96); full CI-equivalent + `cargo test --workspace --features test-actors` green. +- [x] T012 Manual: on a real desktop, install the rule, run `akon vpn on` → + confirm ZERO password prompts and that VPN-only names resolve. + +--- + +## Dependencies + +T001 → T002 (test reads the rule), T003/T004/T005 (package the rule), T006. +T007 independent (can run in parallel with packaging). T009/T010 after T001. +T011/T012 last. + +## Parallel opportunities + +T002 ∥ T007. T003 ∥ T004 ∥ T005 once T001 exists.