grithdocs

TLS and reverse proxies

Native TLS for the daemon, and what it takes to reach the dashboard from another machine.

The daemon listens on 127.0.0.1:3141 over plain HTTP, and it answers loopback clients only. That is not just a bind address: a request arriving from any other IP is rejected with 403 LOCALHOST_ONLY no matter what server.host is set to. Changing the bind address alone does not give you remote access.

Native TLS

grith terminates TLS itself. Point [server.tls] at a certificate and key and restart the daemon.

~/.config/grith/config.toml

toml
[server]
host = "127.0.0.1"
port = 3141

[server.tls]
cert_path = "/etc/grith/tls/fullchain.pem"
key_path = "/etc/grith/tls/privkey.pem"
terminal
$ grith daemon restart

Both keys or neither - the section is parsed as a unit, so setting one without the other fails to load. It is not reachable through grith config set; edit the file. The daemon says on startup whether it came up with TLS, and a bind to a non-loopback address without TLS logs a security warning telling you traffic is going out in plain text.

Reaching the dashboard from another machine

Because the daemon refuses non-loopback clients, remote access means running a reverse proxy on the same host and letting it be the loopback client.

Caddyfile
grith.internal.example {
  reverse_proxy 127.0.0.1:3141
}

Caddy obtains and renews the certificate and upgrades WebSockets without extra configuration. For nginx the WebSocket path needs its own block:

/etc/nginx/conf.d/grith.conf
server {
  listen 443 ssl;
  http2 on;
  server_name grith.internal.example;
  ssl_certificate     /etc/letsencrypt/live/grith.internal.example/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/grith.internal.example/privkey.pem;
  location / {
      proxy_pass http://127.0.0.1:3141;
      proxy_set_header Host $host;
  }
  location /ws/live {
      proxy_pass http://127.0.0.1:3141;
      proxy_http_version 1.1;
      proxy_set_header Host $host;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection "upgrade";
      proxy_read_timeout 1d;
  }
}

Keep Host intact. The WebSocket upgrade is authorised by comparing the request's Origin against its Host, so a proxy that rewrites one and not the other breaks the live stream.

Authorising the remote browser

Sensitive reads and browser writes both need the dashboard token, carried in an x-grith-csrf header; the live stream carries it as a ?token= query parameter instead, because a browser cannot set headers on a WebSocket handshake. A browser gets the token by pairing, and the pairing code is single use.

terminal
$ grith daemon pair

That mints a code and builds a #pair=<code> URL against the loopback address. Take the fragment and open it against your proxied hostname instead - https://grith.internal.example/#pair=<code>. The fragment never reaches the server; the page reads it, exchanges it for the real token, stores it, and strips the URL. A screenshot of the URL afterwards is inert.

Authentication is the proxy's job

grith has no user accounts, no configurable API key, and no roles. The dashboard token is a per-browser bearer, not an identity. If more than one person can reach the proxied hostname, put real authentication in front of it - OIDC, mTLS, or an IP allowlist - and treat anyone who gets past it as holding full control of that machine's daemon.

⚠️Loopback is the security boundary

Everything above moves the boundary from "processes on this machine" to "whatever your proxy lets through". The daemon can approve digest items, change config and shut itself down over the same API the dashboard uses.

See also

Last updated: 2026-08-24Edit this page on GitHub →