Security

This page describes what ETTIX STREAM protects on its own, and what you have to do yourself. The distinction matters: some of what follows is enforced by the server and refuses to start when you get it wrong, and some of it is only advice that nothing will check for you. Each section says which it is.

There are four separate things being protected, and they are protected by different mechanisms. Who may reach the administration interface is settled by the listener address and by administration credentials. Who may publish to a stream is settled by publisher authorisation. Who may watch a stream is settled by viewer authorisation. What an attacker gets from a stolen disk is settled by encryption and the master key. Getting one of them right does nothing for the other three.

The two listeners

ETTIX serves HTTP on two separate addresses, and which address a route is served on is itself a security control.

SettingDefaultWhat it serves
http.listen :8080 Playback: HLS playlists and segments, DVR playlists and segments, MP4 export, content-key delivery, the browser playback SDK. Also /health and /ready.
http.admin_listen 127.0.0.1:8081 The administration API under /api/v1/, the administration interface at /, these documentation pages under /docs/, and the player example page under /player/. Also /health and /ready.

The administration API is never served on the playback listener. That is not a convention you could switch off: the playback listener is the one address you are obliged to expose to the internet, and the API on it can stop a broadcast, rewrite the configuration, delete recorded media and read every operational detail of the server. Two addresses mean you can put the whole privileged surface behind a firewall rule or a VPN without touching the port your viewers use.

/health and /ready are the deliberate exception and stay on both listeners without a credential, because a load balancer's health check cannot hold one and neither answer tells a prober anything the successful connection did not already tell it.

Why the administration listener is on loopback

http.admin_listen defaults to 127.0.0.1:8081, which is reachable only from the machine itself. The usual way to work with it from your laptop is an SSH port forward:

ssh -L 8081:127.0.0.1:8081 you@stream-host
# then open http://127.0.0.1:8081/ in a browser on your laptop

That is the recommended arrangement, and it stays correct however the rest of your configuration changes. A binding to any other address is refused unless you set http.admin_allow_remote. The refusal is enforced twice — once when the configuration is validated, and again at the moment the socket is opened — because an empty or malformed address handed to the operating system binds every interface, and a mistake that is caught only by validation is a mistake that a code path bypassing validation would publish to the internet.

admin_allow_remote is an acknowledgement, not a credential. All it does is record that you meant to expose the administration listener. It grants nothing, hides nothing and checks nothing. A non-loopback admin_listen is refused unless http.admin_auth also configures at least one user or API key, so the two settings are always required together. Setting admin_allow_remote and stopping there produces a configuration that will not load.

config check reports both problems before the server ever runs, and exits non-zero:

ettix-stream config check --config /etc/ettix-stream/config.json

Credentials may be omitted only on a loopback listener. That is why an omitted admin_auth block withholds a remote API rather than publishing an anonymous one: the setting an operator forgot must fail towards being closed.

Creating administration credentials

There is no default administrator and no bundled password. A shipped credential is a credential every installation in the world shares, so ETTIX has none and you create the first one yourself. The configuration stores an Argon2id or bcrypt hash and never a password, and ettix-stream admin hash is what produces one.

Create a login for a person. The command asks for the password twice on the terminal with echo off — it is not a flag, because a password on a command line lands in your shell history, in /proc where every user on the box can read it, and in the audit log. The minimum length is 12 characters.

ettix-stream admin hash --user ops

The prompts go to standard error and the JSON to standard output, so you can redirect the one without capturing the other. Paste the object into http.admin_auth.users.

Create a credential for automation — a monitoring poller, a backup job, a deployment script. There is no password to choose: the secret half is generated from the system random source and printed once.

ettix-stream admin hash --api-key backup-job --read-only

Paste the object into http.admin_auth.api_keys and store the printed Authorization line wherever that job keeps its secrets. ETTIX keeps only the hash and cannot print the secret again; if it is lost, issue a new key and remove the old entry.

Validate and reload. Names must start with a letter or digit and contain only letters, digits, ., _ and -, at most 64 characters, and must be unique within their list — a duplicate is refused rather than resolved silently.

ettix-stream config check --config /etc/ettix-stream/config.json
systemctl reload ettix-stream

The resulting block looks like this. Both lists are optional individually; at least one entry across the two is required as soon as the listener is not loopback.

"http": {
  "listen": ":8080",
  "admin_listen": "127.0.0.1:8081",
  "admin_auth": {
    "users": [
      { "username": "ops", "password_hash": "$argon2id$v=19$...", "read_only": false }
    ],
    "api_keys": [
      { "id": "backup-job", "hash": "$argon2id$v=19$...", "read_only": true }
    ],
    "session_ttl": "12h",
    "session_idle": "1h",
    "max_sessions": 1024
  }
}

The three session settings are shown at their defaults; session_idle may not exceed session_ttl, and max_sessions has no unlimited value, because a login endpoint that anyone can reach must not be able to grow the server's memory without bound.

Sessions, the anti-forgery header, and API keys

The two credential kinds exist because they have different threat models, and the difference decides what each one has to carry.

A person signing in to the administration interface posts to /api/v1/auth/login and receives a session cookie named __Host-ettix_admin, which is HttpOnly, Secure, SameSite=Strict and Path=/. A browser attaches that cookie automatically to requests this server receives, including requests set off by a page on some other site the person happens to be visiting. That is cross-site request forgery, and the defence is a header a foreign page cannot set: every mutating request must also carry X-ETTIX-CSRF with the token that session was issued at login. Without it the answer is 403 CSRF_REQUIRED, which is a distinct code from UNAUTHORIZED precisely because the fix is different — the caller has a valid session and forgot a header.

An API key is presented as Authorization: Bearer <id>.<secret>. No browser attaches that on its own, so it cannot be cross-site forged and it needs no anti-forgery token. The id half is public, appears in logs, and selects which record to verify against, so checking a wrong key costs exactly one hash verification rather than one per configured key.

The __Host- prefix obliges the browser to reject the cookie unless it carries the Secure attribute, so ETTIX always sets it. Browsers treat http://127.0.0.1 and http://localhost as trustworthy, so the loopback default and an SSH forward both work over plain HTTP. What does not work is a remote administration listener on plain HTTP with nothing terminating TLS in front of it: the browser silently discards the cookie, the sign-in appears to succeed, and the next request arrives with no session. The right fix is TLS, not a weaker cookie — the alternative is an administration session travelling in the clear across a network.

Failed logins are counted against the account name and against the client address. Limiting only the account lets one client walk every name; limiting only the address lets a distributed attacker grind one account. After ten consecutive failures on either, further attempts are refused with 429 for fifteen minutes. Every failure is padded to a fixed minimum duration, and an unknown user is verified against a configured hash anyway, so a clock cannot be used to discover which account names exist.

Live session identifiers are stored as SHA-256 digests rather than as themselves, so the session table is of no use to anyone who manages to read it. Configured passwords and API keys are stored only as hashes.

Read-only credentials

Both credential kinds take "read_only": true, and ettix-stream admin hash writes it for you with --read-only. A read-only identity may use GET, HEAD and OPTIONS; anything else is 403 READ_ONLY. Give a read-only credential to anything that only watches — a monitoring poller, a dashboard, a support engineer diagnosing a problem — so that a leaked credential cannot stop a broadcast or delete a recording.

The credential check sits in front of the router rather than on each route, so a route added in a later release cannot forget it. The documentation pages you are reading, and the shell of the administration interface, are deliberately outside the check: an operator has to be able to read how to create a credential before they have one, and neither carries operational data. Everything the interface displays comes from /api/v1, which is behind the check. The player example page is not in that class and is behind the check with everything else.

Publisher authentication

Publisher authorisation decides who may push media into a stream. It is configured per stream, in that stream's publisher block, and it is an AND of everything you configure: a stream key from source.rtmp.stream_key, a username and password, a bearer token, an IP allow-list, and an external HTTP callback. Passwords and tokens are configured as Argon2id or bcrypt hashes, never as plaintext.

Two properties matter more than the field list. First, every refusal looks identical to the publisher: one generic RTMP rejection whatever the cause, held until a fixed minimum time plus jitter has elapsed, so that a fast byte comparison and a slow password hash cannot be told apart by a stopwatch. The real reason is in your log against the request. Second, publisher authorisation fails closed: a policy that cannot be built refuses every publisher rather than admitting them. That is the opposite direction from a viewer callback's fail_open, and deliberately so — an ingest policy that fails open hands the channel itself to the first connection that arrives.

One publisher holds a stream at a time. replace_policy chooses whether a second encoder is rejected or takes over, and min_replace_interval (default 2 seconds) bounds how fast two encoders sharing credentials can take the stream from each other, because every takeover inserts a discontinuity into the output.

RTMP ingest is cleartext and there is no RTMPS listener. Authentication is enforced, but the credential itself — stream key, password, bearer token — is visible to anyone on the network path between the encoder and this server. This is a known limitation of V1, not an oversight to work around in configuration. Either terminate TLS in front of the RTMP listener, or use SRT ingest, which encrypts with a passphrase.

Viewer authorisation

Viewer authorisation decides who may watch. Configure it server-wide in the top-level auth block, or per stream in that stream's auth block. A stream's block replaces the server-wide one entirely — there is no field-level merging, because a half-inherited policy set cannot be reviewed by reading one block. One consequence is worth committing to memory: an empty "auth": {} on a stream means that stream is public, even when the server default is not. That is the intended way to publish one open channel on an otherwise private server, and it is also the easiest way to expose one by accident.

Everything you configure must allow. Composition is AND, so setting both signed_url and jwt means a viewer has to present both. If you want alternatives, configure one policy and let the system that issues credentials decide which to mint.

The table below is meant to help you pick, not to document every field.

BlockWhat it checksChoose it when
signed_url An HMAC-SHA256 signature carried in the URL, with an expiry, an optional start time, a path scope that is required by default, and an optional client address binding. Your own system can mint a URL per viewer at the moment they press play. Nothing is stored server-side and nothing has to be revoked; the URL expires.
token An opaque ETTIX token, recognisable by its ET1 prefix, carried in a query parameter (token by default) or an Authorization: Bearer header. You want one credential a player can carry across the playlist and its segments, and you would rather not re-sign every URL. The bearer form keeps it out of access logs and Referer headers, where players support it.
jwt A JSON Web Token verified against keys you configure. Each key pins exactly one algorithm; the algorithm is never read from the token, and none is never accepted. You already issue JWTs from an identity system and want ETTIX to trust the same tokens.
ip Allow and deny lists of addresses and CIDRs. Deny is applied first. The audience is a fixed set of networks — a corporate site, a partner's egress range.
domain The Origin and Referer headers against a list of permitted embedding hosts. You want to stop casual hotlinking from another web page. Both headers are chosen by the client, so this stops nothing else — pair it with a signed URL or a token, never use it instead of one. Native players send neither header, so serving them requires allow_missing, after which the policy constrains browsers only.
geo The viewer's country, from a MaxMind-format database file on this machine. ETTIX never queries a remote geolocation service. You have a contractual territory restriction. ETTIX ships no database: you supply a GeoLite2, DB-IP or IPinfo country file and give its path in database.
external An HTTPS callback to your own system, asked only after every local policy has already accepted the viewer. The answer is one only you know: is this subscription still paid, has this account reached its device limit, is this event ticketed.
concurrency Simultaneous sessions, per stream and per subject. You want to cap how many people watch at once, or how many devices one credential may use.

Nothing in the shipped binary mints a signed URL or a token: there is no command and no API route that issues one, because the thing that knows whether a viewer should be admitted is your own back end, not this server. ETTIX verifies. Whatever issues credentials has to implement the same signing scheme with the same shared secret; the signed-URL parameter names (sig, exp, nbf, kid, scope, ip, sub) are fixed rather than configurable, so that the contract between the issuer and this server is spelled out once.

Five behaviours of the viewer path are worth knowing before you deploy any of it.

A denial is a generic 403. Every refusal produces the same response whatever objected — an expired token, an address outside the allow-list, a concurrency limit, a callback saying no. A client that can tell those apart can map your policy from outside without ever being let in. The real reason is recorded in your log as a stable code against the request's X-Request-ID, which is what you alert on and what you quote when a viewer complains.

A policy that cannot be built denies everything for that stream. A mistyped geolocation database path or a callback key that fails to decode takes the stream off the air rather than serving it to everyone. An outage is visible and is recoverable in a minute; a silent disclosure is neither.

Every playback route is gated through one check. Playlists, segments, content keys, DVR playlists, DVR segments and MP4 export are all covered together. Gating them separately would protect nothing — an attacker only has to find the route that was forgotten.

Client identity comes from http.trusted_proxies. Every IP-based rule, and every address-bound credential, depends on knowing who the client is. Behind a CDN or a reverse proxy the connecting peer is the proxy, and the viewer's address is in X-Forwarded-For, a header the client can write whatever it likes into. ETTIX therefore ignores that header entirely by default and reads it only when the peer is itself a listed proxy. If you run behind a CDN and do not list it here, your IP, geolocation and concurrency rules will all see the CDN rather than the viewer.

The external callback is the only policy that puts another computer on the playback path. HTTPS is required unless you explicitly permit plain HTTP; requests are signed with HMAC-SHA256 over the method, URL, timestamp, nonce and body; private, loopback, link-local and cloud-metadata addresses are refused at the socket rather than at the URL, so a hostname that resolves inside your network cannot be used to make this server fetch something it should not; redirects are not followed. The viewer's own credential is sent as a SHA-256 digest by default rather than raw, so it cannot be replayed out of a log or a proxy trace. fail_open admits viewers while the callback is unreachable and is off by default — the ingest callback's fail_open is the only other one, and both are an explicit choice you make per stream.

Concurrency limits count the sessions this server sees. Behind a CDN, or across several origin nodes, they undercount; a shared NAT counts once. They are a reasonable abuse control and are not a licence-enforcement mechanism.

TLS

ETTIX serves plain HTTP unless you configure http.tls, which takes either a certificate you supply or one obtained from a certificate authority over ACME. A configuration that asks for TLS and cannot have it is a startup failure, never a quiet fall back to plain HTTP — an operator who configured HTTPS must not end up serving HTTP without being told.

If a load balancer, CDN or reverse proxy already terminates TLS in front of ETTIX, do not configure TLS here at all; configure http.trusted_proxies instead, for the reason given above. The full procedure, including certificate file layout, ACME rate limits and renewal, is in the TLS and certificates guide, docs/operator/tls.md. That guide is part of the project's source tree and is not shipped in the release tarball, so ask whoever supplied your build for a copy if you do not have one; there is no TLS page in this built-in set yet. Two rules from it are worth repeating here in the meantime: the certificate file must be the full chain with the leaf first, because a leaf-only file works in a browser that has cached the intermediate and fails in one that has not; and a private key that is group- or world-readable is refused rather than loaded.

Encryption and the master key

ETTIX encrypts in two independent places. Delivery encryption protects HLS segments on their way to viewers and is set per stream in hls.encryption.mode (aes-128 or sample-aes). At-rest encryption protects recordings and is set per DVR destination with encryption.enabled. The two are separate on purpose: an archive kept for months must not depend on a key that rotates on a viewer-facing schedule, so the recorder always receives cleartext media and applies its own key.

Both rest on one installation master key, 32 bytes generated at installation and stored at <data_dir>/keys/master.key with mode 0600. It is a key-encryption key: it never touches media, and it wraps the per-stream data keys that do. The server refuses to load a master key whose file is group- or world-readable, and refuses to overwrite an existing one, because replacing it would orphan every existing recording.

If you lose the master key, every encrypted recording is permanently unreadable. There is no recovery procedure, no vendor copy and no support ticket that can retrieve it — which is exactly what makes the recordings yours and not anybody else's. Ettix holds no copy.

Take a backup now, before you need one:

ettix-stream keys export --config /etc/ettix-stream/config.json --out ettix-master-key.backup

The command writes a file with mode 0600 and refuses to write to standard output at all, because standard output ends up in shell history, terminal scrollback, CI logs and screenshots. Move the file off this machine as soon as it exists: a backup that lives on the machine it protects is not a backup. Anyone holding that file can read every recording encrypted under the key, so store it where you store your other secrets.

Restoring onto a rebuilt server is the reverse, and must happen before the first start with encryption configured — otherwise ettix-stream init, which the installer runs, will have generated a new key and the import will refuse to overwrite it:

ettix-stream keys import --config /etc/ettix-stream/config.json --in ettix-master-key.backup

The complete rebuild sequence, and what to do when a machine is gone entirely, is in the recovery guide, docs/operator/recovery.md, sections 4 and 5. Like the TLS guide it lives in the project's source tree rather than in the release tarball. The Troubleshooting page here covers the same ground for the failures you are most likely to meet.

Hardening checklist

Work through this once when you commission a server, and again after any change to how it is reached from the network.

  1. Leave http.admin_listen on 127.0.0.1:8081 and reach it over an SSH forward or a VPN. If it genuinely must be remote, set http.admin_allow_remote, configure http.admin_auth, and terminate TLS in front of it — the session cookie cannot survive plain HTTP on a remote address.
  2. Create one administration credential per person or per job with ettix-stream admin hash, never a shared one, and mark everything that only reads as --read-only.
  3. Set http.trusted_proxies if anything sits between viewers and this server, and leave it empty if nothing does. Every IP, geolocation and concurrency rule depends on it being right.
  4. Give every stream that is not meant to be public an auth block, and check the streams you believe are covered by the server-wide default: a per-stream block replaces it entirely, and an empty one makes that stream public.
  5. Give every stream a publisher credential. A stream key generated at random is the minimum; add a password or token where the encoder supports one.
  6. Put TLS in front of RTMP ingest, or use SRT with a passphrase. RTMP credentials travel in the clear.
  7. Leave dvr.export.enabled off unless somebody needs MP4 downloads, and keep max_range and max_concurrent at values your disks can absorb.
  8. Back up the encryption master key off the machine, and confirm you can find it. Do this on the day you enable encryption, not the day you need it.
  9. Check file ownership: the configuration 0640 root:ettix-stream, the data directory 0700 for the service user, the master key 0600. The installer sets all three; a hand-built deployment does not.
  10. Add per-address connection limits in your host firewall. ETTIX bounds total connections with http.max_connections and rtmp.max_connections, but has no per-source rate limiting on playback or publish connections.
  11. Run ettix-stream config check after every edit and read the output. It reports every problem in the document, not only the first.

What ETTIX does not do

These are limits of the finished product rather than work in progress, listed so you plan around them instead of discovering them.

There is no RTMPS listener, so ingest credentials are exposed in transit unless you provide the encryption yourself. There is no per-address rate limiting on playback or publish connections; the administration login is rate-limited, ordinary connections are not, and a host firewall is the right place for that. Geolocation needs a database file that ETTIX does not ship and will not download. And an administration credential is a credential for the whole API: apart from the read-only flag, there is no per-route or per-stream permission model, so anyone who can change anything can change everything.