Skip to content

douglasjordan2/sudo-approve

Repository files navigation

sudo-approve

Approve sudo from your phone with Face ID, across all your machines.

sudo-approve lets a command request root on one of your machines, sends a push notification to your phone, and runs the command only after you approve it with a passkey (WebAuthn / Face ID / Touch ID). No long-lived sudo session, no password typed into a script, and a full audit trail of every elevated command.

⚠️ This software runs arbitrary commands as root in response to a network request. It is only safe under the deployment model described here (tailnet-only hub, loopback-only root executor, passkey-gated, signed directives). Read SECURITY.md before deploying. Do not expose any component to the public internet.

$ sudo-request "apt update && apt upgrade -y" "monthly patching"
Submitting to sudo-executor; waiting for passkey approval...
   📱  push → phone → Face ID
{"id": "…", "status": "completed", "exit_code": 0, "stdout": "…", "stderr": ""}

How it works

Two responsibilities are deliberately split so the root component has zero inbound network surface and the network-facing component has no root:

   phone (passkey, Tailscale on)
        │  push notification
        │  approve over tailnet HTTPS (tailscale serve, NOT Funnel)
        ▼
   ┌───────────────────────────────────────────────┐
   │  HUB  (app.py) — runs NON-ROOT, executes nothing│
   │  • WebAuthn: one passkey, one origin            │
   │  • sends push, shows approve UI + audit         │
   │  • Ed25519-signs the approved directive         │
   └───────────────┬─────────────────────────────────┘
        hub-signed directive {approval_id, host, command, nonce, expiry}
        polled by each executor over Tailscale (per-host bearer token)
        ┌──────────┴───────────┬──────────────────────┐
        ▼                      ▼                       ▼
   executor@box1          executor@box2           executor@…
   • runs as ROOT         • runs as ROOT          • verifies hub Ed25519 sig
   • listens 127.0.0.1    • listens 127.0.0.1     • host + exact-command match
   • NO inbound from net  • NO inbound from net    • single-use nonce + expiry
  1. sudo-request "cmd" "reason" POSTs to the local executor on 127.0.0.1 (authenticated with a local token).
  2. The executor relays the request to the hub over Tailscale.
  3. The hub stores it pending and pushes a notification to your phone.
  4. You open the approve page over the tailnet and approve with your passkey.
  5. The hub signs {approval_id, host, command, nonce, issued_at, expiry} with its Ed25519 key.
  6. The executor receives the signed directive and runs the command as root only if the signature is valid and host == itself and command == exactly what it submitted and the nonce is unused and it hasn't expired. Then it reports the result back.

See DESIGN.md for the full rationale.

Why it's structured this way

  • One passkey, one origin, forever. Passkeys are bound to an origin; adding a machine never means enrolling a new passkey — new machines only register an executor key.
  • No public root executor. Only the hub's approve UI is reachable, and only over your tailnet. Executors accept connections on loopback only and reach out to the hub.
  • Signed, command-bound, single-use approvals. An executor runs only a command whose exact text + target host + nonce was Ed25519-signed by the hub after a passkey approval. Forgery, replay, and substitution are all rejected.
  • The network-facing component is non-root. Worst case if the hub host is compromised is denial of service — it cannot execute anything.

Requirements

  • Tailscale on the hub, each executor machine, and your phone.
  • Python 3.11+ (the executor uses tomllib). The hub needs the packages in requirements.txt; the executor needs only cryptography.

Push notifications are built in (Web Push straight from the hub to your phone) — no external notification service required.

Setup

1. Hub (one machine, non-root)

git clone https://github.com/douglasjordan2/sudo-approve.git
cd sudo-approve
python3 -m venv venv && ./venv/bin/pip install -r requirements.txt

./venv/bin/python scripts/setup-hub.py

The wizard detects your tailnet hostname, writes .env with fresh secrets, generates the Ed25519 signing keypair and the VAPID web-push keypair, prints a ready-to-paste config block for your executor machines, and shows a QR code for the phone setup page. Then:

# run it (see sudo-approve.service.example for a systemd user unit)
./venv/bin/python app.py        # binds 127.0.0.1:8080

# expose to the tailnet over HTTPS — tailnet only, NOT Funnel:
sudo tailscale serve --bg 8080

Prefer to do it by hand? Every step the wizard automates is manual-friendly: cp .env.example .env and edit (see "Configuration" below), then python3 scripts/gen-hub-key.py.

2. Phone

Scan the wizard's QR code (or open https://<hub>.<tailnet>.ts.net/register on the phone) and follow the two steps on the page:

  1. Register Passkey — Face ID / Touch ID for approvals.
  2. Enable Notifications — subscribes this device to the hub's built-in Web Push, then send yourself a test notification.

iPhone/iPad: iOS only delivers web push to installed web apps. Tap Share → Add to Home Screen first, then open Sudo Approve from the home screen and enable notifications there. Your phone needs Tailscale connected to receive the approve page (the pushes themselves arrive via Apple/Google push relays regardless).

Already have your own notification pipeline? Set NOTIFY_URL and the hub will also POST each notification's JSON ({title, body, url, tag, ...}) to it — note that ntfy/Pushover expect different field names, so you'll need a small adapter in between.

3. Executor (each machine that needs root, runs as root)

On the target machine, with this repo checked out:

cp executor-params.env.example executor-params.env   # then edit
sudo bash deploy-executor.sh

deploy-executor.sh installs executor.py to /opt/sudo-executor, writes /etc/sudo-executor/config.toml (mode 600), installs and starts the sudo-executor.service systemd unit (root, loopback-only), and drops the sudo-request client + local token into the calling user's home.

4. Use it

sudo-request "<command>" "<reason>" [wait_seconds]   # wait defaults to 300

Configuration

Hub — .env (see .env.example for the annotated list):

Variable Purpose
WEBAUTHN_RP_ID / WEBAUTHN_ORIGIN Passkey binding; the hub's tailnet hostname / origin
BASE_URL Absolute base for the approve deep-link in pushes
SUDO_API_KEY Required. Bearer auth for executor↔hub; the hub refuses to start without it
SUDO_APPROVE_SECRET Required. HMAC secret for short-lived approve/deny tokens
HUB_SIGNING_KEY_PATH Path to the Ed25519 private key (default keys/hub-ed25519)
VAPID_PRIVATE_KEY_PATH VAPID key for built-in web push (default keys/vapid-private.pem; push disabled if absent)
VAPID_SUBJECT Contact claim sent to push services (mailto: or https:)
NOTIFY_URL Optional: also forward notifications to an external gateway

The executor↔hub bearer token (EXEC_TOKEN) must equal the hub's SUDO_API_KEY — the wizard prints the executor block with it already filled in.

Executor — executor-params.env (consumed by deploy-executor.sh): HOST, REAL_USER, HUB_URL, EXEC_TOKEN, HUB_PUBKEY, LOCAL_TOKEN. See executor-params.env.example.

Security

This is a tool for running commands as root. Please read SECURITY.md for the threat model, the guarantees it does and does not provide, and hardening requirements (most importantly: keep everything on the tailnet, set SUDO_API_KEY, and never expose the executor beyond loopback).

To report a vulnerability, see the disclosure section of SECURITY.md.

License

MIT

About

Approve sudo commands for your terminal agent from your phone with Face ID, triggered by a push notification.

Resources

License

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors