Skip to content

Latest commit

 

History

History
557 lines (437 loc) · 24.9 KB

File metadata and controls

557 lines (437 loc) · 24.9 KB

TLS and certificates

polyemesis can terminate TLS itself. tls.mode chooses how, and config.example.yaml ships auto, which decides at startup.

If you read one section of this page, make it Binding, and the SSH tunnel — plain HTTP on every interface is the single biggest practical exposure this product has.


Modes

mode certificate from browser warning needs
auto resolves to one of the four below depends nothing
acme Let's Encrypt, issued on demand none public DNS name, acmeEmail, inbound port 80
selfsigned a CA generated on this box yes, until you trust that CA nothing
manual certFile / keyFile you supply none, if their issuer is trusted those two files
off nothing — plain HTTP n/a something else terminating TLS

Whenever polyemesis is terminating TLS, the listener pins TLS 1.2 as the floor and prefers X25519, then P-256 and P-384. Go's server default already floors at 1.2; pinning it means a future toolchain default cannot quietly change what this server accepts.

How auto decides

At startup, in this order:

  1. trustProxyHeaders: trueoff. You have told polyemesis a reverse proxy sits in front of it, so the proxy owns TLS.
  2. hostname is a public FQDN and acmeEmail is set → acme.
  3. anything else → selfsigned.

A public FQDN contains a dot, is not an IP literal, and does not end in .local, .internal, .lan, .home, .arpa or localhost — a name Let's Encrypt could plausibly validate. stream.example.com qualifies; polyemesis.lan, nas.local and 192.168.1.10 do not.

Rule 2 needs both a public name and a contact address, so a box with real DNS but no acmeEmail falls to self-signed rather than repeatedly failing issuance.

If hostname is empty and the resolved mode is selfsigned, the system hostname is used, so the certificate always has a name in it.

Upgrading from tls.enabled

tls.enabled is still parsed, and is consulted only when tls.mode is absent:

existing config behaves as
enabled: true with certFile/keyFile mode: manual — your certificate keeps being served
enabled: false, or no tls: block at all mode: off — plain HTTP, exactly as before

An explicit tls.mode always wins, so you can migrate without deleting the old key, and an upgrade never swaps a real certificate for a self-signed one or silently stops serving HTTPS.

Note that this also means an existing install does not get auto for free — it keeps doing what it did yesterday until you write mode: auto yourself.

Worked configurations

1. Public server with a DNS name — the recommended deployment

addr: ":443"
tls:
  mode: "auto"                    # resolves to acme
  hostname: "stream.example.com"
  acmeEmail: "ops@example.com"
  hsts: true                      # safe here: publicly trusted certificate

Point an A/AAAA record at the box and open 80 and 443. The certificate is issued lazily, on the first HTTPS handshake for that name, and renewed automatically.

Tradeoff: you depend on Let's Encrypt being reachable and on your DNS being correct, and issuance is pinned to that one hostname — a request arriving with any other SNI is refused rather than triggering a new order, which is what stops a public port being used to burn your rate limit.

2. Homelab box with no public DNS

tls:
  mode: "auto"                    # resolves to selfsigned
  hostname: "polyemesis.lan"

polyemesis mints a local CA and a leaf for that name, plus localhost, 127.0.0.1 and ::1 so the first login over an SSH tunnel or by loopback does not warn either.

Tradeoff: every browser warns until you install the CA, and mobile clients are genuinely annoying to convince. In exchange the traffic is encrypted, which is the part that matters on a shared LAN.

If you reach the box by LAN address rather than by name, put the address in hostname — an IP literal is accepted and becomes a SAN. A certificate naming only polyemesis.lan will still warn when you browse to https://192.168.1.10, because the name you typed is not in it. Changing hostname reissues the leaf on the next start; the CA, and everything that already trusts it, is untouched.

3. Behind nginx / Caddy / Traefik

addr: "127.0.0.1:8080"
trustProxyHeaders: true
tls:
  mode: "auto"                    # resolves to off

The proxy owns TLS, HSTS and the redirect. See Behind a reverse proxy.

4. Your own certificate

Corporate CA, wildcard, cert-manager, an existing certbot:

tls:
  mode: "manual"
  hostname: "stream.example.com"  # used for the HTTP→HTTPS redirect target
  certFile: "/etc/ssl/polyemesis/fullchain.pem"
  keyFile:  "/etc/ssl/polyemesis/privkey.pem"
  hsts: true                      # only if the issuer is publicly trusted

certFile should be the full chain, leaf first. Both files are read at startup and never rewritten.

Tradeoff: renewal is yours. polyemesis loads the pair once, so a certbot renewal needs a systemctl restart polyemesis (or a --deploy-hook) before the new certificate is served. Missing or mismatched files are a hard startup error, naming both paths.

5. Plain HTTP on purpose

addr: "127.0.0.1:8080"
tls:
  mode: "off"

Fine on loopback. On any other address it is the worst thing in this document — see Binding, and the SSH tunnel.

Trusting the self-signed CA

The generated material lives in <dataDir>/tls/ (directory 0700, private keys 0600, never logged and never returned by any API):

<dataDir>/tls/ca.crt        the local CA — this is the file you install
<dataDir>/tls/ca.key        its private key. Never leaves the box.
<dataDir>/tls/server.crt    the leaf, followed by the CA, as a chain
<dataDir>/tls/server.key    the leaf's private key

The CA is valid for ten years; the leaf for one, and it is regenerated automatically within 30 days of expiry or if you change tls.hostname. That split is on purpose: installing a CA into a browser, a phone and a keychain is the most tedious step of a homelab setup, and making you redo it annually would be a reason to give up on HTTPS entirely.

Copy the CA to the machine you browse from and check the fingerprint against the ca sha-256 line polyemesis prints at startup before you trust it:

scp user@host:/var/lib/polyemesis/tls/ca.crt ./polyemesis-ca.crt
openssl x509 -in polyemesis-ca.crt -noout -fingerprint -sha256

The server also offers it at GET /api/v1/tls/ca, which needs no session. On a fresh box the browser will not let you reach the login form until the CA is installed, so gating the download behind a sign-in would deadlock the only way out of that. It is the public half of the CA, which every client already receives during the handshake; the private key has no route. The Settings page links to it, or:

curl -k https://polyemesis.lan:8443/api/v1/tls/ca -o polyemesis-ca.crt

Check the fingerprint either way — -k means you have not yet verified who answered.

macOS (Keychain Access will also do this by drag-and-drop):

sudo security add-trusted-cert -d -r trustRoot \
  -k /Library/Keychains/System.keychain polyemesis-ca.crt

Linux, Debian/Ubuntu:

sudo cp polyemesis-ca.crt /usr/local/share/ca-certificates/polyemesis.crt
sudo update-ca-certificates

Fedora/RHEL: drop it in /etc/pki/ca-trust/source/anchors/ and run sudo update-ca-trust.

Firefox — and Chrome on Linux — do not use the system store. Firefox: Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import, and tick "identify websites". Chrome on Linux reads NSS: certutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n polyemesis -i polyemesis-ca.crt.

Windows, PowerShell as Administrator:

Import-Certificate -FilePath .\polyemesis-ca.crt `
  -CertStoreLocation Cert:\LocalMachine\Root

iOS/Android both need two steps — install the profile and then explicitly enable it as a trusted root (iOS: Settings → General → About → Certificate Trust Settings). If that is more than you want to do, use mode acme, or reach the UI over the SSH tunnel.

Switching to Let's Encrypt

The awkward part of self-signed mode is not the browser warning. It is not knowing that you are one config change from not having it — a box with a real domain already pointed at it looks exactly like a box without one.

Settings → Security answers that, in three parts and in this order:

  1. What you are on now. Five situations, one sentence each. A working ACME certificate and a reverse proxy that owns TLS are both nothing to do here, and the panel says so rather than nudging a correct install.
  2. What a trusted certificate would need. A hostname field, prefilled from tls.hostname or, failing that, from the name in your address bar; and a contact address, which is only ever written into the snippet below it.
  3. What happens if you try. A Check this host button, which is the part most guides leave out.

That third step is worth the button because trying is expensive. A restart into acme on a box that cannot answer a challenge leaves you with no certificate at all — the self-signed one you had is not a fallback — and Let's Encrypt allows five failed validations per hostname per hour, so the second attempt can be an hour away. The checks are GET /api/v1/tls/acme-preflight:

check what a pass means
the name it is the shape a public CA can issue for: dotted, not an IP literal, not .local/.internal/.lan/.home/.arpa/localhost. Whether you control it is between you and your registrar
DNS the name resolves, and to an address this machine holds
port 80 this process is holding :80 and answering /.well-known/acme-challenge/ on it
contact tls.acmeEmail is set. The address itself is never sent back to the browser
last attempt acme mode only — a certificate is in place, or the sentence Let's Encrypt sent the last time it refused

Each check is pass, fail or unknown, and unknown is not a soft failure. It is the answer where this process cannot see far enough, and only fail clears ready. Two of them are honestly unknowable from inside the box and say so instead of guessing:

  • Whether the public internet reaches port 80. Nothing on this side of the NAT can establish that, and polyemesis does not ask a third party. It tells you to run curl -sS http://<name>/.well-known/acme-challenge/probe from another network.
  • A name that resolves somewhere else. Behind NAT, a floating IP or a load balancer, the public record correctly points at something that is not an interface on this box. That is indistinguishable from a record left pointing at an old host, so the check reports where the name went and declines to call it either way.

Nothing here writes anything. config.yaml is root:polyemesis mode 0640 and this service cannot write it — and giving a service the power to rewrite its own transport security is a privilege decision, not a small convenience. It also cuts the wrong way for the person who needs this most: an operator on tls.mode: off is reading this page over plain HTTP, and a form that accepts a contact address and reconfigures the server is exactly the wrong thing to offer over that connection. Guidance is safe there; a write path is not. So the panel prints the YAML and the systemctl restart line, and a person with a shell does the rest.

The snippet it prints leaves hsts commented out, unlike worked configuration 1 above. That example describes a deployment where issuance already works; the snippet is handed to someone whose first ACME restart has not happened yet, and HSTS has no server-side undo. Turn it on afterwards.

ACME needs port 80

Let's Encrypt validates over HTTP-01, which means it must reach http://<hostname>/.well-known/acme-challenge/… on port 80 from the public internet. Open it on the firewall and in any NAT/port-forward. A CNAME to a CDN, a captive portal, or an ISP that blocks 80 will all break issuance.

polyemesis also advertises the TLS-ALPN-01 protocol on its HTTPS listener, so on a box where 443 is reachable but 80 is not, issuance can still succeed that way. Treat that as a fallback, not a plan.

If port 80 cannot be bound — already taken, or the process is unprivileged — polyemesis logs a warning and carries on serving. It does not refuse to start. That is deliberate: a server that dies over a certificate problem leaves you with no UI in which to fix the setting that killed it. The log carries:

cannot bind :80 for the acme http-01 challenge; certificate issuance will keep
failing until port 80 reaches this host (free the port, grant
CAP_NET_BIND_SERVICE, or forward it). Serving https meanwhile

and the startup banner says the certificate has not been issued yet.

Under systemd, the usual fix is AmbientCapabilities=CAP_NET_BIND_SERVICE — see the commented block in deploy/polyemesis.service.

Back up <dataDir>/tls/acme/. It holds your ACME account key and every issued certificate; a redeploy that loses it re-orders from scratch, and Let's Encrypt's duplicate-certificate limit is not generous.

In Docker, port 80 is commented out in docker-compose.yml and needs uncommenting for exactly this. It ships off because publishing :80 unconditionally breaks docker compose up on any host already running a web server.

HSTS is opt-in, and here is why

tls.hsts defaults to false. Turn it on only when you have a certificate a browser will validate without help.

Strict-Transport-Security tells a browser "never speak plain HTTP to this hostname again, and never let the user click through a certificate warning for it". The browser remembers that for the whole max-age, on the client, and there is no way for the server to take it back — clearing it means clearing site data in every browser on every device that saw the header.

Now picture that on a homelab box with a self-signed certificate. The browser has been told to refuse plain HTTP to polyemesis.lan, and HSTS also removes the "Advanced → Proceed anyway" escape hatch for the untrusted certificate. Both doors are shut, from one header, and rebuilding the server does not reopen them.

So:

  • Never sent unless the connection really is HTTPS. The check is Go's r.TLS, not a forwarded header — a header can be forged, and behind a trusted proxy the policy for the connection the browser actually made belongs to whoever terminated it.
  • Never sent in selfsigned mode, even with hsts: true. polyemesis logs a warning at startup explaining that it is being suppressed, rather than quietly obeying you into a lockout.
  • Never sent when the resolved mode is off — same warning.
  • When it is sent it is max-age=86400, one day. No includeSubDomains, no preload. Both widen the blast radius past this one host, and preload in particular is close to irreversible. A day is long enough to be a real downgrade defence and short enough that a mistake ages out.

If you want a long max-age and preloading, set it on the reverse proxy, where you already own the whole origin and can undo it.

Binding, and the SSH tunnel

The default addr is ":8080"every interface. Plain HTTP on every interface is the single biggest practical exposure this product has: the login form and the session cookie cross the network in clear text, and anyone on the path can read or replay them. polyemesis prints a loud warning at startup when it detects exactly that combination (binds publicly, not terminating TLS, no trusted proxy).

Fix it by enabling TLS — or, if you just want it private with no certificates at all, bind to loopback and tunnel:

addr: "127.0.0.1:8080"
tls:
  mode: "off"
ssh -N -L 8080:127.0.0.1:8080 user@host

Then open http://localhost:8080. SSH carries the encryption and the authentication, nothing is exposed on the box, and there is no certificate to install anywhere. For a single admin this is the lowest-effort secure setup there is.

Two caveats, both real:

  • The tunnel only covers the web UI. The ingest listener binds 0.0.0.0 on its own port regardless of addr, because your encoder has to reach it. Guard that with the SRT passphrase and a firewall rule — see Transport security beyond the UI.
  • The HLS preview and the WebSocket both travel inside the tunnel and work normally; nothing in the UI needs a second port.

Behind a reverse proxy

The other common deployment terminates TLS at nginx (or Caddy, or Traefik) and lets polyemesis listen on plain HTTP behind it. A complete example is in deploy/nginx.conf.example.

With trustProxyHeaders: true, mode: auto resolves to off — polyemesis does not try to obtain or serve a certificate, does not bind port 80, and sends no HSTS. The proxy owns all three. That is the intended interaction, not a limitation: two things fighting over port 80 for ACME is a much worse day than one.

Four things matter:

  1. Set trustProxyHeaders: true. polyemesis then honours X-Forwarded-Proto and X-Forwarded-Host when marking session cookies Secure and when building OAuth redirect URIs. Leave it false when there is no proxy — otherwise a client can forge those headers.
  2. Bind polyemesis to loopback (addr: "127.0.0.1:8080"). With a proxy in front there is no reason for the plaintext port to be reachable from anywhere else, and trustProxyHeaders suppresses the exposure warning that would otherwise have told you about it.
  3. Proxy the WebSocket. Live status, meters and logs all arrive over /api/v1/ws: proxy_http_version 1.1, Upgrade/Connection "upgrade", and a long proxy_read_timeout.
  4. Do not proxy the ingest. SRT is UDP and RTMP is not HTTP; neither travels through an HTTP reverse proxy. Open those ports directly on the firewall.

Also turn buffering off (proxy_buffering off) or the HLS preview will lag, and set client_max_body_size 0 so multi-gigabyte recording downloads work.

If you want HSTS in this deployment, set it on the proxy. polyemesis will not send it with mode: off however tls.hsts is set, and says so at startup.

The plain-HTTP companion on :80

Whenever polyemesis is terminating TLS itself, it also tries to bind :80 for a small helper that does two jobs: answers ACME HTTP-01 challenges (acme mode only) and redirects everything else to HTTPS.

Which status code it sends depends on whether it knows its own name:

tls.hostname GET/HEAD other methods
set 301 308
empty 302 307

The method split keeps an API client's verb and body across the hop; a 301 is allowed to be rewritten to GET, a 308 is not.

The permanent/temporary split is the more important half. With no hostname configured, the only available redirect target is the client's own Host header — so caching that permanently would let one request poison the redirect for everyone after it. The temporary form is uncacheable and varies on Host, and the header is checked for authority shape before it reaches Location (an authority that is not letters, digits, dots, hyphens or an IPv6 bracket-and-colon form is a 400, not a redirect). Setting hostname removes the guesswork and earns the permanent codes.

Cache-Control: no-store and Vary: Host are not tied to that table, and this page said they were until the walkthrough in Settings → Security was written against it. They are sent in the empty-hostname case for the reason above, AND in either case whenever the redirect could be carrying a credential — any request with a query string, or any path under the public playout origin or /watch. A Location reproduces the request URI verbatim, a watch token travels in that query string, and a 301 is permanently cacheable by definition: without this, an intermediary would hold a URL containing a live credential for as long as it liked. So tls.hostname set plus a query string is a 301 with no-store, which the old table ruled out. The test is deliberately over-broad — any query string, not one spelled token — because the cost of over-matching is one uncached redirect and the cost of under-matching already shipped once.

It is skipped when addr is already port 80, and a failure to bind is a warning rather than a fatal error — you keep your HTTPS listener and your UI either way.

Security headers

Every response carries these, with one deliberate exception noted below:

header value
Content-Security-Policy default-src 'self' plus the relaxations below
X-Frame-Options DENY
X-Content-Type-Options nosniff
Referrer-Policy no-referrer
Permissions-Policy camera=(), microphone=(), geolocation=()
Strict-Transport-Security only under the conditions above

The CSP relaxations exist for specific features and each one is load-bearing: media-src 'self' blob: and worker-src 'self' blob: because hls.js hands <video> a blob URL and compiles its demuxer worker from generated source; connect-src 'self' ws: wss: for the telemetry WebSocket, with ws: because a LAN box may legitimately be on plain HTTP; img-src 'self' data: for inline icons; style-src 'self' 'unsafe-inline' because the bundle injects <style> at runtime.

Notably absent is 'unsafe-inline' for scripts — the UI is a Vite bundle of hashed module files with no inline <script>, and that is the one relaxation that would turn an injected string into executable code.

The /watch exception

The public player is the one page that does not get the table above verbatim. When you turn on allowCrossOrigin for playout, /watch drops X-Frame-Options and its CSP relaxes frame-ancestors to * — a page whose whole purpose is to sit in an iframe on someone else's site cannot also refuse to be framed.

Two things keep that narrow. It applies to /watch alone, never to the admin console. And it is inert until you set allowCrossOrigin, which is the same switch that already publishes the media cross-origin — so the frame policy is not loosened beyond what you had already chosen to make public. The rest of the policy is derived from the admin one rather than written out separately, so a directive added there cannot go missing here.

Transport security beyond the UI

TLS on the web UI is not the whole story:

  • SRT ingest has its own encryption. Set a passphrase under Settings → Ingest (SRT requires 10–79 characters, which polyemesis enforces in the form) and SRT encrypts the stream with AES. The dashboard renders the exact srt://…?passphrase=… URL to paste into OBS. Without one, your stream — including anything on screen — crosses the network unencrypted.
  • RTMP ingest has no equivalent. RTMP is authenticated — the stream key in the URL is matched in constant time against every source's key, so an unrecognised publisher is refused rather than guessed at — but that is a string comparison, not encryption. The stream and the key itself both cross the network in the clear. It is the fallback for encoders that cannot do SRT; prefer SRT where you have the choice.
  • Destinations can be rtmps://. RTMP destination URLs accept both rtmp:// and rtmps:// and the URL is handed to FFmpeg verbatim, so where a platform publishes an RTMPS ingest address, paste that one. SRT destinations are passed through unchanged too — append your own ?passphrase=… if the receiving end expects one.

See also