Security and Access - Kheopsian/Hydranos GitHub Wiki

🇬🇧 English · 🇫🇷 Français

Security and Access

Applies to Hydranos 4.4.

This page is for anyone who runs Hydranos somewhere other people can reach: on a LAN, behind a reverse proxy, or with other machines enrolled as nodes. It explains how Hydranos checks every request and which addresses answer without a key. It also covers how to recover a lost password or rotate the API key, and how to expose the web UI without handing it to strangers.

One admin account, one API key

Hydranos has exactly two credentials, both stored in the config file (/config/default.toml unless you pass --config; on Windows, default.toml beside hydranos.exe, or, if there is none there, a \config\default.toml left by 4.3, silently: its notice is printed before any console is attached, see Install on Windows):

Credential Where What it is
Admin account [auth] username, [auth] password_hash One user for the web UI. The password is stored as a bcrypt hash (cost 12, the library default, whether it comes from the first-run screen, Change password or hydranos reset-password). The password is at least 8 characters everywhere. There are no other users and no roles.
API key [daemon] api_key The one secret for the web UI, the REST API, the qBittorrent shim and the MCP server.

The API key is generated at boot when api_key is empty or still holds the published placeholder change-me-in-production. The new key is 48 hexadecimal characters (24 random bytes) and is written back into the TOML in place, so your comments survive. If the file cannot be read or written, nothing is replaced, and the log says "the API stays closed". That is only true for an empty key: it stays empty and every authenticated request is refused, since an empty key authorises nobody. A placeholder key stays change-me-in-production and keeps working, so anyone who knows the published value gets in. In both cases, fix the file's permissions and restart.

The web UI shows the key in one place: Config › Advanced settings (default.toml) › General › api_key, with ↻ (generate) and ⧉ (copy) buttons. The same page lists the [auth] rows under the Other sub-tab, password_hash included. GET /api/settings returns the whole TOML to anyone holding the key, the API key and password_hash included. The proxy passwords ([proxy] socks5_pass, and socks5_outbound_pass in [race], [hoard] or an engine's session) and the gluetun API key read ******** there, and in the Network tab's routes (GET /api/network/mode, GET /api/network/egress). A save that posts the mask back keeps the stored value.

Known bug in 4.4: not every secret is masked — an announce_proxy URL that carries user:pass ([race], [hoard] or an engine's session) is returned in clear by GET /api/settings — treat the API key as giving away that credential as well.

Managed WireGuard files are the exception: each provider .conf is written to <data dir>/wireguard/ (folder mode 0700) with mode 0600 from the first byte, and the API never returns its keys. The listing gives only the file name, address, endpoint and the peer's public key (Managed WireGuard). On Windows the modes do not apply, and managed WireGuard does not run there anyway.

How requests are authenticated

Caller What it sends How it gets it
Web UI X-Api-Key header (and ?apikey= on live streams and form posts) POST /api/login with the admin username and password returns the key in its JSON body; it sets no cookie. The browser keeps the key in localStorage under hydra_api_key.
Scripts, REST clients X-Api-Key: YOUR_API_KEY header, or ?apikey=YOUR_API_KEY Copied from the config file or the Config tab.
Sonarr, Radarr, autobrr, cross-seed (qBittorrent shim) SID session cookie POST /api/v2/auth/login, the only route that sets this cookie, with either the admin username and password, or the API key typed as the password (any username).
MCP clients Authorization: Bearer YOUR_API_KEY (the header, query and cookie forms work too) Copied from the config.
A node being enrolled one-time enrolment token POST /api/nodes/register only; see Nodes and credentials below.

Rules that apply to all of them:

  • The key is compared in constant time. The header wins over the query parameter.
  • A SID cookie unlocks the whole native API, not just /api/v2. Sessions live in memory only. One expires after 1 hour without a request (each request refreshes it), at most 256 exist (the oldest is dropped), and a restart ends them all.
  • /api/login and /api/v2/auth/login have no rate limit and no lockout. A wrong username and a wrong password get the same answer.

These paths answer without any credential:

Path What it reveals or does
GET /health status, version, uptime, and per engine: id, torrent count, whether it listens, whether it is held by start_paused or blocked by the kill switch (with the reason)
GET /metrics every series of Prometheus Metrics: engine ids, torrent counts, rates, memory, the version (hydra_build_info), and the host name of every tracker you announce to (hydra_tracker_announces_total{engine,tracker,result}, hydra_tracker_errors_last_hour{engine,tracker,class})
GET /api/startup torrent totals, per engine while they load
GET /api/setup, POST /api/setup first-run status and account creation (guarded, see next section)
POST /api/login, POST /api/v2/auth/login, POST /api/v2/auth/logout sign-in and shim sign-out
GET /, GET /static/…, GET /changelog.md the web UI's files and the changelog
GET /install.sh the node enrolment script (no secret inside)
POST /api/nodes/register needs a valid enrolment token instead of the key
POST /api/auth/api-key/confirm needs the pending key of a rotation instead of the key (see Rotating the API key)

Every other route goes through the gate below, including POST /mcp. GET /mcp and DELETE /mcp skip the gate but return nothing: they always answer 405 with Allow: POST.

flowchart TD
    R["Incoming request"] --> P{"Public path?<br/>/health, /metrics, /, /static/*, /changelog.md,<br/>/install.sh, /api/setup, /api/login, /api/startup,<br/>/api/nodes/register, /api/auth/api-key/confirm,<br/>/api/v2/auth/login, /api/v2/auth/logout"}
    P -->|yes| H["Handler runs<br/>setup, the two logins, register and confirm<br/>apply their own checks"]
    P -->|no| E{"api_key empty in config?"}
    E -->|yes| X["Refused"]
    E -->|no| K{"X-Api-Key header or apikey query<br/>equals the key?"}
    K -->|yes| OK["Allowed"]
    K -->|no| C{"SID cookie of a live session?"}
    C -->|yes| OK
    C -->|no| M{"Path is /mcp and<br/>Authorization Bearer equals the key?"}
    M -->|yes| OK
    M -->|no| X
    X --> W{"Which path?"}
    W -->|"/api/v2/..."| F["403, body Forbidden."]
    W -->|"/mcp"| B["401 with WWW-Authenticate Bearer"]
    W -->|"/api/selection/*, /api/network/egress"| V["401, unauthorized"]
    W -->|other| U["401, Invalid or missing API key"]
Loading

The shim answers 403 rather than 401 on purpose; the code comment gives the reason: Sonarr and Radarr probe /api/v2/app/webapiVersion before logging in and expect qBittorrent's 403 (third-party behaviour, not checked here). The web UI reopens its login dialog on any 401.

There is no CORS support: Hydranos sends no Access-Control-* headers, so a web page on another origin cannot read its API responses.

The first-run setup window

Until [auth] password_hash holds a hash, the web UI shows Welcome to Hydranos and Create account. POST /api/setup creates the account (password at least 8 characters, empty username becomes admin) and returns the API key. It answers 409 once an account exists. While the engines are still loading, the startup router only routes GET /api/setup, so a POST is refused: create the account once the startup screen is gone.

The setup route only answers a local caller, judged this way:

  1. The address of the TCP peer decides first. If it is not loopback or a private network, the request is refused, whatever headers it carries.
  2. If that peer is private and the request carries X-Forwarded-For (a reverse proxy on your LAN), the first address in that header must be private too.
  3. A private peer without X-Forwarded-For counts as local.

"Private" means 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 and 169.254.0.0/16 in IPv4; ::1, fc00::/7 and fe80::/10 in IPv6. A refused caller gets 403 first-run setup only answers a local caller; run `hydranos reset-password <password>` on the host.

Two consequences follow from the code (not tested):

  • A client on a CGNAT or Tailscale address (100.64.0.0/10) is not private and is refused. Use hydranos reset-password on the host instead (Changing or recovering the password).
  • Anything that hands Hydranos a connection from a private address without adding X-Forwarded-For makes every caller local: a reverse proxy that does not set the header, a NAT or port-forward that rewrites the source. Create the account before the port is reachable from outside, and set X-Forwarded-For in your proxy (the nginx example below does).

If you must set up a remote machine, keep the UI on loopback and tunnel to it:

[daemon]
api_host = "127.0.0.1"
ssh -L 8199:127.0.0.1:8199 [email protected]
# then open http://localhost:8199 on your own computer

In Docker, leave api_host alone (loopback inside the container is unreachable from the host) and publish the port on the host's loopback instead: "127.0.0.1:8199:8199/tcp".

When the setup answers 403, do it from the LAN on port 8199, or follow its message: hydranos reset-password <password> on the host writes the account's password into the file (recipe below). It does not touch [auth] username, which the shipped template sets to admin.

Changing or recovering the password

Signed in: Config › Account › Change password ("Sets [auth] password_hash (bcrypt). You stay logged in."), type the new password (placeholder New password (min 8)), click Change. POST /api/auth/password refuses fewer than 8 characters (400 password must be at least 8 characters; the page says Password too short (min 8). before sending). Otherwise it writes the bcrypt hash into [auth] password_hash in default.toml and reloads the config: the old password is refused from the next login, no restart needed. The API key does not change, so this browser, the *arr apps and every other client stay signed in.

Locked out: hydranos reset-password. On the host, with the binary of the install:

# Docker (config at /config/default.toml in the container; add the path if you changed HYDRANOS_CONFIG_DIR)
docker exec hydranos hydranos reset-password 'your-new-password'
docker restart hydranos

# Linux, packaged layout
sudo /opt/hydranos/hydranos reset-password 'your-new-password' /etc/hydranos/default.toml
sudo systemctl restart hydranos

The command needs 8 characters at least (hydranos: the password must be at least 8 characters, exit 2), writes the hash into [auth] password_hash, checks that the file still parses (otherwise … would not parse after the edit; nothing written), prints hydranos: password set in <path>. Restart Hydranos for it to apply. and exits. Without a path it edits /config/default.toml (Windows: default.toml beside hydranos.exe, or the \config\default.toml a 4.3 install left when there is none there). The running daemon keeps the old hash until it restarts. username and api_key do not change. The password is a command-line argument, so it stays in your shell history and shows in ps while the command runs. More detail: Operating Hydranos.

On Windows, hydranos.exe is a windowed program and, from the code, the output of reset-password and hash-password does not reach the console (not tested on Windows). reset-password still writes the file, so prefer it there: quit Hydranos from the tray, run hydranos.exe reset-password "your-new-password" in the folder of hydranos.exe, start Hydranos again and sign in.

Procedure A: write a new hash. Any bcrypt hash with the $2b$, $2y$, $2a$ or $2x$ prefix is accepted. hydranos hash-password '<password>' prints one (8 characters minimum), and so do common tools:

# Hydranos itself
hydranos hash-password 'your-new-password'

# Apache's htpasswd (package apache2-utils / httpd-tools); prints a $2y$ hash
htpasswd -nbBC 12 "" 'your-new-password' | tr -d ':\n'; echo

# or Python with the bcrypt package (pip install bcrypt); prints a $2b$ hash
python3 -c 'import bcrypt,sys; print(bcrypt.hashpw(sys.argv[1].encode(), bcrypt.gensalt(12)).decode())' 'your-new-password'

On Windows, htpasswd and tr are not there and hash-password prints nothing you can see: use reset-password above, Procedure B below (only Notepad is needed), or install Python from python.org and run this in PowerShell or a command prompt (it prints a $2b$ hash):

py -m pip install bcrypt
py -c "import bcrypt,sys; print(bcrypt.hashpw(sys.argv[1].encode(), bcrypt.gensalt(12)).decode())" "your-new-password"

Then either:

  • Still signed in: open Config › Advanced settings (default.toml) › Other, paste the hash into password_hash, click Save settings. The next login uses it at once. The banner asks for a full restart, but the password does not need one.
  • Locked out: stop Hydranos, set password_hash = "$2b$12$…" under [auth] in default.toml, start it again. Hand edits are read only at startup.

[auth] username can be changed the same two ways. The login compares it exactly (case-sensitive).

Procedure B: start over. Stop Hydranos, set password_hash = "" in default.toml (any text editor, Notepad included), start it. The first-run screen comes back and you create the account again. The API key does not change, so automation keeps working. While the hash is empty, the setup window above is open again, with the same exposure risk. Never blank password_hash from the Config tab on a reachable instance: the change applies immediately.

Whoever holds the API key keeps full access through all of this, including shim logins with the key as the password.

Rotating the API key

  1. Choose the new key:
    • in the UI: Config › Advanced settings (default.toml) › General › api_key, click ↻, then Save settings;
    • or in the file: put your own value (for example from openssl rand -hex 24), or set api_key = "" to get a generated one, then restart.
  2. A key saved from the UI applies at once to every new request; no restart is needed for the key. The browser that saved it updates its own copy. Other browsers get the login dialog; signing in again gives them the new key.
  3. Update everything that holds the old key:
    • *arr and autobrr, if you typed the key as the shim password (those using the admin password are unaffected);
    • MCP clients and scripts;
    • every controller that declared this instance as a node: on the controller's Nodes tab, Edit the node and give it the new key (PATCH /api/nodes/:name with api_key; the key is probed before it is saved).

A controller can also rotate a node's key for you: Rotate key on its Nodes tab (POST /api/nodes/:name/rotate-key). It runs the node's own two-step rotation, which you can call yourself on any instance:

  1. POST /api/auth/api-key/rotate, with the current key, answers {"pending_key", "expires_in", "confirm"}. Nothing is written; the current key keeps working. The pending key is valid for 10 minutes (600 s) and is forgotten on a restart.
  2. POST /api/auth/api-key/confirm with the pending key in X-Api-Key writes it to [daemon] api_key. From the next request the old key is refused, and the log says API key rotated: the previous key is no longer accepted.

The controller stores the new key as soon as the node confirmed, and its answer shows the key once, for the node's other clients (*arr, browsers).

Shim sessions (SID cookies) already issued stay valid after a rotation until they idle out or Hydranos restarts. Restart if you rotated because a key leaked. Do not blank api_key from the Config tab: an empty key refuses everyone, including the UI, until the next restart generates a new one.

A restart through the UI or POST /api/settings/restart is the same clean stop as a SIGTERM, then exit code 75 (Operating Hydranos).

Signing out

The Sign out button, the door-and-arrow icon at the right of the header, signs this browser out: it ends this browser's shim session, if it has one (POST /api/v2/auth/logout), deletes the key from its storage (hydra_api_key in localStorage) and reloads the page, which asks for the password again.

Signing out removes the key from that browser only; the key itself stays valid, since it is the instance's single key, shared with the *arr apps. To cut off every browser at once, rotate the API key. Shim clients can end their own session with POST /api/v2/auth/logout.

Exposing Hydranos

Port 8199 serves plain HTTP: the web UI, the REST API, the qBittorrent shim and /mcp on one listener (ports table: Installation and First Run). Hydranos has no TLS of its own.

Recommended, in order:

  1. Keep 8199 off the internet. Use it from your LAN, a VPN between your machines, or an SSH tunnel. Bind api_host = "127.0.0.1" when only local clients need it.
  2. Create the admin account first (see The first-run setup window).
  3. If you need remote access, put a TLS reverse proxy in front, at the root path of its own hostname (https://hydranos.example.org/). The UI loads /static/… and calls /api/… with absolute paths, so a sub-path such as /hydranos/ does not work.
  4. Mind what stays public: /metrics, /health, /api/startup and /install.sh need no key. /metrics names every tracker you announce to (host names in the tracker label) and /health gives your engines, their torrent counts and their kill-switch state: anyone who reaches them learns which trackers you use. Block them at the proxy if you do not want them reachable, and let only your Prometheus host scrape /metrics.
  5. Mind the logs: the UI sends the key as ?apikey= on some requests, so proxy access logs can contain it.

A minimal nginx example:

server {
    listen 443 ssl;
    server_name hydranos.example.org;
    ssl_certificate     /etc/ssl/hydranos.example.org.crt;
    ssl_certificate_key /etc/ssl/hydranos.example.org.key;

    location / {
        proxy_pass http://192.0.2.10:8199;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;   # overwrite, never append
        proxy_http_version 1.1;
        proxy_buffering off;          # live progress streams
        proxy_read_timeout 1h;
        client_max_body_size 4g;      # Transmission import uploads its folder
        proxy_cookie_flags SID secure;
    }
    location = /metrics { deny all; }
}

The SID cookie is set with Path=/; HttpOnly; SameSite=Lax and without Secure, deliberately: *arr clients talk to the shim over plain HTTP on the LAN, and a Secure cookie would never come back to them. Behind TLS, proxy_cookie_flags adds the flag for clients that come through the proxy. Point your *arr apps at port 8199 on the LAN rather than through the proxy (settings: qBittorrent Shim and Automation).

If the store cannot be opened, Hydranos starts in rescue mode on the listener it already opened, so on your api_host and api_port. It then serves /health, /, GET /api/setup, GET/POST /api/store/repair and POST /api/settings/restart, all without a key (Operating Hydranos). Anyone who reaches the port can run the store conversion and restart Hydranos, so the same rule applies: keep 8199 reachable only from trusted hosts.

The MCP endpoint uses the same key; its two destructive tools are hidden unless [mcp] allow_destructive = true (MCP Server).

Nodes and credentials

  • A controller stores each node's URL and API key in plain text in its store (hydra.db, table nodes). Treat backups of hydra.db as secrets.
  • Enrolment hands out a one-time token valid for 30 minutes. The enrolment command uses plain http://, so the token and, afterwards, the node's key travel unencrypted. Enrol and run nodes over a private network or a VPN. For HTTPS, put the node behind a reverse proxy and declare it with its https:// URL.
  • The node sends its own key to the controller; it never receives the controller's key.
  • The Open button on the Nodes tab signs you into a node by passing the node's key in the URL fragment (#key=…). The page stores the key, then removes it from the address bar.

Enrolment and the cross-node action matrix: Nodes and Multiple Machines.

Reporting a vulnerability

Use a private security advisory on github.com/Kheopsian/Hydranos (Security tab), not a public issue. Only the latest release receives fixes.

See also

Installation and First Run · qBittorrent Shim and Automation · MCP Server · Nodes and Multiple Machines · Configuration: Daemon, Auth and Global Sections · Monitoring and Logs · Prometheus Metrics · Managed WireGuard · Troubleshooting · Known Limitations in 4.4

⚠️ **GitHub.com Fallback** ⚠️