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.
Local clients (CLI, TUI)
Section titled “Local clients (CLI, TUI)”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.
Network clients (Web UI, remote REST)
Section titled “Network clients (Web UI, remote REST)”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.
The password
Section titled “The password”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.
Getting the generated password
Section titled “Getting the generated password”runwisp password | wl-copy # Linux, Waylandrunwisp password | pbcopy # macOSIn 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.
The system service’s socket belongs to root, so ask it with
sudo runwisp password.
Read-only access
Section titled “Read-only access”since: 1.7
RUNWISP_PASSWORD='long-random-string' \RUNWISP_VIEWER_PASSWORD='another-string' runwisp daemonA second password lets someone look at the dashboard without being able to
change anything. It’s off unless you set it: with no RUNWISP_VIEWER_PASSWORD
there is no second secret and nobody can sign in read-only.
Someone who signs in with it can see tasks, runs, logs and notifications. The
daemon rejects everything that changes state with a 403: triggering, starting,
stopping or restarting a task, pausing, deleting runs, reloading the config and
marking notifications read. The Web UI hides those controls and shows a
Read-only badge in the top bar. A remote runwisp tui --url hides them too
and marks the sidebar read-only. A remote runwisp run --url signed in this
way fails with a message saying so.
- The two passwords must differ. The daemon refuses to start if they match.
- It can’t be combined with
RUNWISP_AUTH=off. - Changing it ends read-only sessions only; full sessions carry on.
- It can’t be used to get the full password: a read-only session can’t mint a launch ticket (see Network clients).
- The control socket is unaffected. Local clients always have full access.
To get a read-only password without setting one in the environment, ask the running daemon for it:
runwisp password --viewerThe first call makes up a random password and keeps it in memory until the
daemon restarts; later calls print the same one. Like runwisp password, it
exits 1 when RUNWISP_VIEWER_PASSWORD is set.
Running without a password
Section titled “Running without a password”RUNWISP_AUTH=off runwisp daemonThe 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_AUTHacceptsofforon(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:
docker run --rm -e RUNWISP_AUTH=off -p 127.0.0.1:9477:9477 \ -v ./runwisp:/etc/runwisp runwisp/runwisp:latestA 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:
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,restarton a task: returns the finished run with itsexitCodeandendReason.starton a task that is already running waits for that run.stop: returns once every run has ended, or409if some are still ending whenwaitTimeout(seconds, default120) runs out.start,restarton a service:400, since a service instance doesn’t end on its own.
A hook token can’t read run logs. Since 1.8, run takes tail=N (up to
1000) and adds the last N lines of the run’s log to the response as a log
array of {n, stream, text}, so a CI job can show why a run failed:
curl -fsS -X POST -H "Authorization: Bearer $CI_DEPLOY_TOKEN" \ "https://runwisp.example.com:9477/api/hooks/tasks/deploy/run?wait=true&tail=50" \ | jq -r '.log[].text'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.
Rate limiting
Section titled “Rate limiting”Only wrong passwords count, per client IP. The first 5 cost nothing. After
that, each one doubles the wait before the next try: 2 seconds, then 4, 8, and
so on up to 90 seconds. Trying sooner gets 429 Too Many Requests with a
Retry-After header, even with the right password. A successful login clears
the count, and so do 15 minutes without a wrong password. A restart also
clears it.
Launch tickets and the control socket aren’t limited. Challenges are capped at 60 per minute per IP.
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.
Reverse proxies
Section titled “Reverse proxies”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 login throttle see the real client IP.X-Forwarded-Proto: https, so the session cookie is markedSecure.
Without it, every client looks like the proxy, so one client’s wrong passwords slow down logins for everyone. When proxy headers arrive from an address that isn’t in the list, RunWisp logs a warning and adds one to the notification bell.
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.
Serve RunWisp at the root of its own host name, such as
runwisp.example.com. A path prefix like example.com/runwisp/ doesn’t work,
because the Web UI loads its files from /.
A minimal nginx site:
server { listen 443 ssl; server_name runwisp.example.com; # ssl_certificate and ssl_certificate_key go here
location / { proxy_pass http://127.0.0.1:9477; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }}The same with Caddy, which gets the certificate for you:
runwisp.example.com { reverse_proxy 127.0.0.1:9477}Live logs and Web UI updates use long-lived streams. RunWisp tells nginx not to
buffer them and sends a keepalive every 30 seconds, so the default
proxy_read_timeout of 60 seconds is enough. If your proxy times out idle
connections sooner, raise that limit.
For a LAN or a tailnet, you don’t need a proxy:
tls = "auto" serves HTTPS with
a self-signed certificate.
Public endpoints
Section titled “Public endpoints”These answer without a password:
GET /health: always200 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 startingrunwispwhich daemon holds the port. Loopback and socket callers only;403for 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.