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.
ETTIX serves HTTP on two separate addresses, and which address a route is served on is itself a security control.
| Setting | Default | What 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.
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.
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.
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.
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 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 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.
| Block | What it checks | Choose 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.
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.
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.
Work through this once when you commission a server, and again after any change to how it is reached from the network.
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.ettix-stream admin hash, never a shared one, and mark
everything that only reads as --read-only.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.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.dvr.export.enabled off unless somebody needs MP4
downloads, and keep max_range and max_concurrent
at values your disks can absorb.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.http.max_connections and
rtmp.max_connections, but has no per-source rate limiting on
playback or publish connections.ettix-stream config check after every edit and read the
output. It reports every problem in the document, not only the first.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.