Skip to content

Authentication

RunWisp has two ways in:

  • The CLI and TUI on the same machine use the control socket. No password.
  • The Web UI and anything else over HTTP need the password.

runwisp, runwisp tui, runwisp status, runwisp run, and the other commands talk to the daemon over the control socket. Only the user who started the daemon can open it, so there’s nothing to log in to. Anyone who can read the data directory controls the daemon.

With --url (or RUNWISP_URL), runwisp run and runwisp tui connect over HTTP instead and log in like any network client.

If the CLI prints daemon is not reachable at <path> while a daemon is up, the two are using different data directories. Check --data and which user you’re running as.

The Web UI asks for the password once, then keeps you logged in with a session cookie for 24 hours. The password itself never crosses the network: login is a challenge and response. To log in from a script, use runwisp run --url or the script in Remote triggers.

The session cookie and run output are only as private as the connection. When you bind beyond loopback, turn on TLS or put the daemon behind a reverse proxy that does.

The TUI’s Open in browser logs the browser in with a single-use ticket that expires after 60 seconds. When runwisp tui --url points at a plain http:// address that isn’t loopback, the TUI warns you first, because the ticket would cross the network unencrypted.

Terminal window
RUNWISP_PASSWORD='long-random-string' runwisp daemon
  • Set RUNWISP_PASSWORD. The daemon reads it from the environment and keeps it in memory. Browser sessions survive a restart as long as the value doesn’t change. Keep the value in whatever secret store you already use.
  • Leave it unset. The daemon makes up a new random password on every boot, so each restart logs everyone out. Fine for a daemon you start by hand; annoying for one that runs for months.

runwisp service install generates a stable password for you, so an installed daemon doesn’t have this problem.

To rotate the password, change RUNWISP_PASSWORD (or unset it) and restart. Every network session ends.

RunWisp never writes the password or the session signing key to disk.

Terminal window
runwisp password | wl-copy # Linux, Wayland
runwisp password | pbcopy # macOS

In the TUI, open Home, focus the Password row, and press Enter to copy it.

runwisp password works only over the control socket, and only for a generated password. It exits 1 when RUNWISP_PASSWORD is set (RunWisp never hands out a password you chose) and 5 when authentication is off. Piping it into a clipboard tool keeps it out of your scrollback.

Terminal window
RUNWISP_AUTH=off runwisp daemon

The API and Web UI answer every request without a password. Anyone who can reach the port can read every log and run any task as the user that started the daemon. Use it for local development or on an isolated network, never on a port the internet can reach. If you need remote access without a RunWisp login, put a proxy that does its own authentication in front.

  • RUNWISP_AUTH accepts off or on (any case). Any other value stops the daemon at startup.
  • It can’t be combined with RUNWISP_PASSWORD; the daemon refuses to start with both.
  • The daemon prints a warning at startup, the Web UI shows an Auth disabled badge, and the TUI shows the password as disabled.
  • The control socket works the same as before.

The official Docker image won’t start until you choose RUNWISP_PASSWORD or RUNWISP_AUTH=off. For a passwordless local container, publish the port on loopback only:

Terminal window
docker run --rm -e RUNWISP_AUTH=off -p 127.0.0.1:9477:9477 \
-v ./runwisp.toml:/etc/runwisp/runwisp.toml:ro runwisp/runwisp:latest

A CI job or webhook can control one task or service without the password. Give it a hook_tokens entry, then send the token as a bearer header:

Terminal window
curl -fsS -X POST -H "Authorization: Bearer $CI_DEPLOY_TOKEN" \
"https://runwisp.example.com:9477/api/hooks/tasks/deploy/run?wait=true"

Each hook is a session route under /api/hooks/, with the same query, body, and response:

Hook Same as
POST /api/hooks/tasks/{name}/run POST /api/tasks/{name}/run
POST /api/hooks/tasks/{name}/start POST /api/tasks/{name}/start
POST /api/hooks/tasks/{name}/stop POST /api/tasks/{name}/stop
POST /api/hooks/tasks/{name}/restart POST /api/tasks/{name}/restart

Add ?wait=true to hold the request until the work is done:

  • run, start, restart on a task: returns the finished run with its exitCode and endReason. start on a task that is already running waits for that run.
  • stop: returns once every run has ended, or 409 if some are still ending when waitTimeout (seconds, default 120) runs out.
  • start, restart on a service: 400, since a service instance doesn’t end on its own.

Runs started this way show as triggered by hook.

If a webhook sender can only set a URL, pass the token as a query parameter instead. The header wins when both are sent.

https://runwisp.example.com:9477/api/hooks/tasks/deploy/run?token=<token>

A missing or wrong token gets 401, and so does an unknown task, so hooks don’t reveal which tasks exist. A valid token whose allow list leaves out the action gets 403. Tokens are checked even with RUNWISP_AUTH=off, and manual_trigger doesn’t apply. A rotated token takes effect on runwisp reload.

Login requests are limited per client IP: 5 requests in any 5-minute window, counting the challenge, the login, and launch-ticket redemptions together. Over the limit, the daemon answers 429 Too Many Requests until the window moves on. A restart resets the count. The control socket isn’t limited.

Scripts should log in once and reuse the session token; runwisp run --url does this for you.

Hooks are limited by failures only: after 20 rejected tokens from one client IP within a minute, that IP gets 429 (even with a valid token) until the window moves on. Successful calls never count.

Live log streams are capped at 128 open at once, and 16 per client IP. Past the cap, new streams get 503 Service Unavailable. Close some log views and try again.

Use a reverse proxy (nginx, Caddy, Traefik, Cloudflare Tunnel) when you need a publicly trusted certificate. Let the proxy handle TLS, keep RunWisp on plain HTTP behind it, and tell RunWisp which addresses the proxy connects from:

[daemon]
trusted_proxies = ["127.0.0.1/32", "10.0.0.0/8"]

From those addresses only, RunWisp trusts:

  • X-Forwarded-For, so logs and the rate limit see the real client IP.
  • X-Forwarded-Proto: https, so the session cookie is marked Secure.

Keep the list tight: an address in it can claim to be any client IP, and so use up another client’s login limit. The RUNWISP_TRUSTED_PROXIES environment variable (comma-separated) overrides the key. See trusted_proxies.

For a LAN or a tailnet, you don’t need a proxy: tls = "auto" serves HTTPS with a self-signed certificate.

These answer without a password:

  • GET /health: always 200 OK. Use it as a liveness probe.
  • GET /metrics: when metrics are on and share the main listener.
  • /api/auth/*: login status, challenge, login, and launch-ticket redemption.
  • POST /api/hooks/*: needs one of the unit’s hook tokens instead, in the header or the URL.
  • GET /api/daemon/identity: tells a starting runwisp which daemon holds the port. Loopback and socket callers only; 403 for everyone else.
  • The Web UI’s static files.

Everything else under /api/ needs a session or the control socket.

The login response is PBKDF2-HMAC-SHA256 (600,000 rounds) of the password, salted with a single-use challenge, so a captured login is slow to brute-force. It doesn’t protect the session token; TLS does.