Skip to content
Merged
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
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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/"

Expand All @@ -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"
Expand Down
14 changes: 14 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions akon-core/src/vpn/f5/dns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
72 changes: 72 additions & 0 deletions akon-core/tests/polkit_rule_tests.rs
Original file line number Diff line number Diff line change
@@ -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"
);
}
3 changes: 3 additions & 0 deletions debian/postrm
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
65 changes: 65 additions & 0 deletions docs/adr/0003-polkit-rule-for-rootless-dns.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions packaging/polkit/49-akon-resolved-dns.rules
Original file line number Diff line number Diff line change
@@ -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;
}
});
3 changes: 3 additions & 0 deletions rpm/post-uninstall.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
106 changes: 106 additions & 0 deletions specs/009-polkit-dns-no-prompt/plan.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading