Auth
There are really two doors into RunWisp. Network clients — the Web UI and remote REST callers — go through a single password and session. Local clients that sit on the same box as the daemon — the TUI and the CLI — go in through a Unix socket. No secrets are stored on disk. The rule of thumb is: if you can log in over the network, or if you can already read the data directory, you control the daemon. RunWisp is built for one operator on one machine, and the auth model leans into that.
Local clients (CLI, TUI)
Section titled “Local clients (CLI, TUI)”The CLI and TUI talk to the daemon over a Unix socket at
<datadir>/runwisp.sock. The socket sits at 0600 inside a 0700
data directory, so only the user who started the daemon can reach
it — same guarantee the SQLite file already relies on. As an extra
belt to that suspenders, the daemon double-checks the peer UID at
accept time with SO_PEERCRED on Linux and LOCAL_PEERCRED on
macOS, and slams the door on anyone whose UID doesn’t match.
That’s why the CLI and TUI don’t need a password or a JWT — being
on the local socket as the right user is the credential. Commands
like runwisp, runwisp tui, runwisp list, and runwisp exec
all connect through the socket automatically.
Point runwisp tui or runwisp exec at a --url (or set
RUNWISP_URL) and they switch to the network path below instead —
the daemon then authenticates them like any other network client.
If the socket isn’t there, or the CLI can’t reach it, you’ll see
daemon not running at <datadir> and that’s it — no quiet fallback
to some other transport. When a daemon is running but the CLI still
can’t get through, it’s almost always a data-dir ownership or path
mismatch, not a network problem.
Network clients (Web UI, remote REST)
Section titled “Network clients (Web UI, remote REST)”The Web UI shows a single password field. Your password never goes across the network in the clear. Login is a challenge-response handshake: the browser asks the daemon for a one-time challenge, runs it together with your password through PBKDF2 (600,000 rounds of HMAC-SHA-256), and sends back the derived response the daemon can verify. The password itself never hits the wire, and the slow KDF makes a captured transcript expensive to brute-force offline. That’s defense in depth behind the encrypted channel, not a substitute for it — and whenever the daemon binds beyond loopback it serves HTTPS by default (see TLS), so the handshake rides an encrypted connection too.
Once you’re in, the daemon hands you a session inside a secure
cookie. Set a new RUNWISP_PASSWORD and restart, and every
existing session is gone.
When you use the TUI’s “Open in browser” action, the TUI mints a
single-use launch ticket (60-second TTL) and the browser redeems it
into a session cookie — so you land in the dashboard already logged
in. Locally, the TUI mints over the Unix socket and the browser
redeems on 127.0.0.1; the password never leaves the host. Over a
runwisp tui --url … connection the ticket is minted from your
already-authenticated session and redeemed against the remote daemon
— minting always requires a valid session, and the ticket stays
single-use and short-lived. If that connection is plain http:// to
a non-loopback host, the TUI warns you first, because the ticket
would cross the network unencrypted; https:// (a
TLS proxy) closes that gap.
The password
Section titled “The password”RunWisp doesn’t write the password — or the JWT signing key — to disk, ever. You’ve got two ways to set it:
- Set
RUNWISP_PASSWORDand the daemon picks it up from the environment, in memory only. This is what you want for Docker secrets, systemd’sLoadCredential=, or any sealed-secrets setup. As long as the value doesn’t change between restarts, your existing browser sessions survive a restart. - Leave it unset and the daemon generates a fresh random password every boot, again in memory only. Every restart rotates the password, which rotates the JWT key, which logs every browser out.
The JWT signing key isn’t stored anywhere — it’s derived from your password using HKDF-SHA-256, salted with a per-install fingerprint (machine ID, cwd, executable path, hostname). The fingerprint is generated once on first boot and persisted to the database, so the key survives restarts because the same fingerprint plus the same password yields the same key — not because anything sensitive was written to disk. Same password on the same install gives you the same key, so sessions persist across restarts. A different password — or the same password on a different machine — gives you a different key, so sessions can’t leak between installs.
To rotate, change RUNWISP_PASSWORD (or unset it to get a fresh
random one) and restart. Everyone logged in over the network is
out.
Retrieving the ephemeral password
Section titled “Retrieving the ephemeral password”When the daemon makes up its own password (because you didn’t set
RUNWISP_PASSWORD), the value never shows up in a log line, a
startup banner, or anywhere on screen. There are two ways to pull it
back out — and no Web UI tab here on purpose, since you need the
password to get into the Web UI in the first place.
The Home view has a Password row showing ••…. Focus
it and hit Enter — a copy modal pops up with the
real value already on your clipboard.
From any shell that can reach the daemon’s data directory,
runwisp password prints it straight to stdout. It’s a
single line, so piping it into a clipboard tool is one step:
runwisp password | wl-copy # Waylandrunwisp password | pbcopy # macOSrunwisp password | xclip -selection clipboardThe endpoint behind runwisp password only listens on the local
Unix socket. Try it over the network — even with a valid session
cookie — and you’ll get 403 Forbidden. Try it when the daemon
was started with RUNWISP_PASSWORD already set, and you’ll get
404 Not Found. The daemon never hands out an
operator-supplied password; if you set one, you’re the one
responsible for stashing it — Docker secret, systemd
LoadCredential=, vault, password manager, whatever you already
use.
One thing worth flagging: runwisp password writes to stdout,
which means it lands in your shell scrollback. If that matters,
pipe it straight into a clipboard tool.
Running without a password
Section titled “Running without a password”Sometimes a login wall is just in the way — a local dev container, a demo on your own laptop, a throwaway instance on an isolated network. For those cases you can switch auth off entirely:
RUNWISP_NO_AUTH=1 runwisp daemonSet RUNWISP_NO_AUTH=1 (or true) and every /api/ route answers
without a password, the Web UI skips the login screen, and a
persistent Auth disabled badge sits in the Web UI header so the
instance can never be mistaken for a secured one. The TUI’s Home page
shows Password disabled (RUNWISP_NO_AUTH) for the same reason, and
the daemon prints an unmissable warning banner at startup.
Be clear-eyed about what you’re switching off: anyone who can reach the port has full control of the daemon — browsing run history and logs, and triggering tasks that execute shell commands as the user who started it. Only use this for local development or on a trusted, isolated network. Never expose the port to the public internet, and if you need remote access without a login, put an authenticating reverse proxy in front instead.
A few sharp edges, sanded:
RUNWISP_NO_AUTHandRUNWISP_PASSWORDare mutually exclusive — the daemon refuses to start with both set, because a password that is never checked is worse than no password.- Any value other than
1/trueis a startup error, not a guess. runwisp passwordexits with code 5 and an explanation — there is no password to print.- The local Unix socket and the optional cloud connection are unaffected; this switch only opens the local HTTP API and Web UI.
Docker for local dev
Section titled “Docker for local dev”This is the headline use case. Inside a container the daemon has to
bind 0.0.0.0 for port mapping to work, so pair the two:
docker run --rm \ -e RUNWISP_NO_AUTH=1 \ -p 127.0.0.1:9477:9477 \ -v ./runwisp.toml:/etc/runwisp/runwisp.toml:ro \ your-runwisp-image \ runwisp --config /etc/runwisp/runwisp.toml --host 0.0.0.0 daemonOr in a compose.yaml:
services: runwisp: image: your-runwisp-image command: runwisp --config /etc/runwisp/runwisp.toml --host 0.0.0.0 daemon environment: - RUNWISP_NO_AUTH=1 ports: - "127.0.0.1:9477:9477" volumes: - ./runwisp.toml:/etc/runwisp/runwisp.toml:roNote the 127.0.0.1: prefix on the port mapping — it keeps the
passwordless daemon reachable from your machine only, not from
everything on your LAN. Drop it only when the network between you and
the container is genuinely trusted.
Rate limiting
Section titled “Rate limiting”Failed network logins are rate-limited per source IP. Cross the
limit and you’ll get 429 Too Many Requests until the window slides
past. Restarting the daemon resets the counter. The local-socket
path is exempt.
Live log streams are capped too — a few dozen open at once, with a
smaller per-IP cap — so one runaway script can’t exhaust the daemon
on its own. Hit the cap and you’ll get 503 Service Unavailable;
close some open log views and try again.
Reverse proxies
Section titled “Reverse proxies”RunWisp binds to 127.0.0.1:9477 by default and serves plain HTTP
there. Bind beyond loopback and it self-signs and serves HTTPS on its
own — enough for a LAN or a tailnet, no proxy required. Where a
reverse proxy still earns its keep is a publicly-trusted
certificate: put it on the public internet behind nginx, Caddy,
Traefik, or a Cloudflare Tunnel, let the proxy own TLS, set
tls = "off" so
RunWisp serves plain HTTP on its private side, and tell the daemon
which IP ranges that proxy will be calling from:
export RUNWISP_TRUST_PROXY='10.0.0.0/8,2001:db8::/32'runwisp daemon --host 127.0.0.1 --port 9477RUNWISP_TRUST_PROXY is a comma-separated list of CIDR ranges
whose X-Forwarded-Proto and X-Forwarded-For headers the daemon
will trust. When a request comes in from one of those ranges with
X-Forwarded-Proto: https, the daemon treats the connection as
secure and marks the session cookie accordingly. Rate limiting is
applied after the real-IP middleware, so proxied requests count against
the originating IP, not the proxy’s address — limit your trust proxy
ranges carefully or an attacker could consume the rate budget from
behind a trusted proxy.
The daemon refuses 0.0.0.0/0 and ::/0. Trusting the whole
internet would let any random client lie about where it’s coming
from.
Public endpoints
Section titled “Public endpoints”A short list of routes that intentionally skip the password/JWT check:
/health— liveness probe. Always200 OK./metrics— Prometheus / OpenMetrics scrape endpoint. See Metrics for what’s exposed and the trade-off that comes with it.
Everything else under /api/ (other than the auth handshake
itself) needs either a valid session cookie or a request coming in
over the local Unix socket.