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": ""}
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
sudo-request "cmd" "reason"POSTs to the local executor on127.0.0.1(authenticated with a local token).- The executor relays the request to the hub over Tailscale.
- The hub stores it pending and pushes a notification to your phone.
- You open the approve page over the tailnet and approve with your passkey.
- The hub signs
{approval_id, host, command, nonce, issued_at, expiry}with its Ed25519 key. - 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.
- 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.
- Tailscale on the hub, each executor machine, and your phone.
- Python 3.11+ (the executor uses
tomllib). The hub needs the packages inrequirements.txt; the executor needs onlycryptography.
Push notifications are built in (Web Push straight from the hub to your phone) — no external notification service required.
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.pyThe 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 8080Prefer 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.
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:
- Register Passkey — Face ID / Touch ID for approvals.
- 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.
On the target machine, with this repo checked out:
cp executor-params.env.example executor-params.env # then edit
sudo bash deploy-executor.shdeploy-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.
sudo-request "<command>" "<reason>" [wait_seconds] # wait defaults to 300Hub — .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.
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.