This page is organised by what you see, because that is what you have when something breaks. Find the symptom, read what it means, run the commands, apply the fix. Each section is self-contained; you should not have to understand how ETTIX is built to use it.
ETTIX serves two separate listeners and it matters which one you are talking
to. The playback listener (http.listen, :8080
by default) carries HLS playlists, segments, content keys, DVR playback and the
two probes /health and /ready. The
administration listener (http.admin_listen,
127.0.0.1:8081 by default) carries every /api/v1/
route and the administration interface. The administration API is never served
on the playback listener, so a request to :8080/api/v1/status is a
404 rather than a permissions problem.
Almost every diagnosis on this page begins with one of three commands: the
service status, the journal, or config check. Run all three before
forming a theory.
systemctl status ettix-stream
journalctl -u ettix-stream -n 200 --no-pager
ettix-stream config check --config /etc/ettix-stream/config.json
The examples below use curl against
http://127.0.0.1:8081 with no credential, which works when
http.admin_auth is empty and the listener is on loopback. If you
have configured a credential, add the header — an API key is presented as its
id and secret joined by a dot:
curl -H "Authorization: Bearer <id>.<secret>" http://127.0.0.1:8081/api/v1/status
ettix-stream status and ettix-stream streams list
send no credential of their own. On a server that configures
http.admin_auth they will report an HTTP 401; use
curl with the header, or the administration interface, instead.
| What you are seeing | Go to |
|---|---|
| The unit will not start, or restarts every two seconds | The service will not start |
| Every playlist and segment answers 503, but the administration interface works | Everything answers 503 |
no trusted licence authority keys are available | No trusted authority keys |
| OBS or ffmpeg cannot publish; the stream stays idle | A publisher is refused |
| An SRT source never connects, or connects and produces nothing | An SRT source will not connect |
| The stream is live but viewers stutter | Live but stuttering |
| Nothing is being recorded, or a destination stopped | DVR is not recording |
| A sign-in form that never works, or no interface at all | Cannot sign in, cannot reach it |
| Saving a change in the interface fails | A change will not save |
| HTTPS fails, or a browser refuses the certificate | Certificates |
| The server, or a build of it, is inexplicably slow | Inexplicably slow |
| You need to send something to support | Collecting evidence for support |
systemctl status ettix-stream shows the unit as
failed, or as activating (auto-restart) in a loop
every couple of seconds. The shipped unit sets Restart=always with
RestartSec=2, so a configuration the server refuses looks like a
restart loop rather than a single clean failure.
Nearly always the configuration file. ETTIX validates the whole document before it binds a single port, and unknown fields are an error rather than something quietly ignored — a setting you misspelled must not look as though it took effect. Because validation happens first, a failure here means nothing was started and nothing on the machine was changed.
systemctl status ettix-stream
journalctl -u ettix-stream -n 200 --no-pager
ettix-stream config check --config /etc/ettix-stream/config.json
config check is the first thing to run, always. Its exit code
tells you which kind of problem you have:
| Exit | Meaning |
|---|---|
0 | Valid. It prints a one-line summary: /etc/ettix-stream/config.json: OK (1 streams, http :8080, rtmp :1935) |
1 | The file could not be read or parsed at all — a syntax error, a missing file, an unknown field. The message is the JSON problem, for example config: parse: json: unknown field "nosuch" |
2 | You used the command wrongly: no command at all, an unknown one, or --help. A bad flag or a missing subcommand exits 1, like any other error |
3 | The file parsed but is not valid. Every problem is printed, each naming the field |
Exit 3 is the interesting one. It lists all the problems rather than stopping at the first, so one editing pass can fix them all:
$ ettix-stream config check --config /etc/ettix-stream/config.json
error: /etc/ettix-stream/config.json: config: 2 problems:
- http.admin_listen: "0.0.0.0:8081" is not loopback; bind it to 127.0.0.1, or set
http.admin_allow_remote and configure http.admin_auth once you have put a firewall,
VPN or authenticating proxy in front of it too
- streams[0] (channel1).source.rtmp.stream_key: at least 8 characters
$ echo $?
3
Edit the field the message names and run config check again
until it exits 0, then systemctl restart ettix-stream. The
messages that come up most often:
| Message | What to do |
|---|---|
…stream_key: still the example placeholder, which is published in the documentation and in every release; anyone who has read it could publish to this stream. Replace it | The file still carries change-this-stream-key. That string is printed in this documentation and shipped in every release tarball, so it is a credential only in shape. Replace it with a real one. The installer generates one for a fresh installation precisely so this does not happen |
http.admin_listen: … is not loopback | Bind it back to 127.0.0.1, or, if it genuinely has to be remote, set http.admin_allow_remote and configure http.admin_auth, and put a firewall or VPN in front of it. See below |
tls is configured but the certificate manager could not start: … | ACME cannot reach the network, or a custom certificate path is wrong or unreadable. TLS never silently falls back to plain HTTP: an operator who configured HTTPS must not end up serving HTTP. See Certificates |
encryption is configured but the key provider could not start: … | The master key is missing or unreadable. Do not resolve this by generating a new key. Everything already recorded under the old one becomes unreadable, permanently, and Ettix holds no copy. Restore the key backup with ettix-stream keys import instead |
permission denied writing a DVR path | The destination is not listed in the unit's ReadWritePaths. See DVR is not recording |
If it started failing straight after an upgrade, the new
release tightened a validation rule. Run the new binary's
config check against the existing file — that is exactly what
happened, and it is what the rule is protecting you from at the next reboot.
install.sh now does this check before it replaces anything, so an
upgrade that would break the machine refuses and changes nothing. If you are
replacing the binary by hand, do the check yourself first.
The installer does not keep a copy of the binary it replaces. If you want to
be able to roll back, copy /usr/local/bin/ettix-stream somewhere
before you upgrade.
Every playlist, segment, content key and DVR request answers HTTP 503 with a
Retry-After: 60 header and a body naming a licence code:
{
"error": {
"code": "LICENSE_EXPIRED",
"message": "this server is not licensed to serve this request",
"request_id": "…"
}
}
Publishers are refused. Recording has stopped. /ready answers
503, so a load balancer drains this server. But /health still
answers 200 and the administration interface is perfectly responsive.
This is the licence, and the asymmetry is deliberate. The streaming engine
stops; the administration interface stays up so that the licence can be
installed without physical access to the machine. /health is
deliberately unaffected — the process is alive and an operator must be able to
reach it. That is also why you should health-check on /health but
route on /ready: routing on /health sends
viewers to a server that answers 503 to every playlist.
curl -s http://127.0.0.1:8081/api/v1/license | jq .
The state field says which situation you are in. There are six,
and the cure is different for each:
state | What it means | What to do |
|---|---|---|
online | A lease is valid and renewal is working. Normal. | Nothing. If you are seeing 503s in this state the cause is not the licence |
offline | The lease is still valid but renewal is failing. Streaming continues on a clock: offline_seconds_remaining counts down, up to 72 hours from when the lease was issued | Fix the network path to the licence authority. Nothing is lost, and no action is needed on the server once it can reach the authority again |
expired | Nothing valid remains — the lease's own expiry or the 72-hour offline ceiling passed, whichever came first | POST /api/v1/license/renew, or activate with a fresh token |
revoked | Ettix produced a signed revocation for this installation's licence | Contact Ettix with the installation_id. The server will not resume on its own |
unlicensed | No lease has ever been accepted. A fresh installation is in this state | Activate. The installation_id in the same response is what you register at Ettix.com |
invalid | A lease is stored but does not verify — tampered with, issued to another installation, or signed by a key this build does not know. It is separate from expired because the cure is different | Usually a restored backup carrying another machine's lease: activate this installation again. If the message says the lease is signed by an unknown key, the cure is an upgrade to a build that knows it |
It costs nothing and it is the whole fix whenever the authority was temporarily unreachable. The response carries the resulting status alongside any error, so you learn where the installation now stands rather than only that something failed.
curl -sX POST http://127.0.0.1:8081/api/v1/license/renew | jq .
Two forms of the same operation, and which you use depends only on whether this server can reach Ettix.com. An activation token is what you paste from the portal; a lease is the offline path for a server with no outbound access. Supply exactly one.
curl -sX POST http://127.0.0.1:8081/api/v1/license/activate \
-H 'Content-Type: application/json' \
-d '{"token": "<activation token>"}' | jq .
curl -sX POST http://127.0.0.1:8081/api/v1/license/activate \
-H 'Content-Type: application/json' \
-d '{"lease": "<lease document>"}' | jq .
If clock_rollback is true, the system clock moved backwards by
more than the tolerance and licence timing cannot be trusted. Nothing stops and
nothing is deleted because of it — licensing falls back to the highest time it
has seen — but it is reported so that you fix NTP.
Recorded media is never deleted by any licence state. Not by
expiry, not by revocation, not by a restart in any of them. When the licence
lapses, in-flight work gets a grace period so that a recorder finishes the
segment it is writing and closes its journal cleanly: expiry must never be the
thing that truncates a recording. What does delete recorded media is a
retention rule you configured (max_age, max_bytes,
min_free_percent, min_free_bytes) or an explicit
DELETE on the DVR range endpoint. Neither has anything to do with
the licence.
The matching log lines, if you would rather grep than poll, are
LICENSE_OFFLINE, LICENSE_EXPIRED,
LICENSE_REVOKED, LICENSE_RESTRICTED,
LICENSE_GRACE_ENDED and CLOCK_ROLLBACK.
Licensing reports unlicensed and every activation attempt fails.
The message is:
license: no trusted licence authority keys are available: this build has no
compiled-in licence authority key and none is configured, so no lease can be
verified; install a release build, or set license.trusted_keys for a
self-hosted authority
This is a build problem, not a configuration one. Nothing
you can put in config.json will change it, and no amount of
re-activating will help. A lease is an Ed25519-signed document; verifying it
requires the licence authority's public key, which is compiled into a release
build. A build made without one installs, starts, ingests, records and serves
perfectly well — and then refuses every activation, because it has nothing to
verify a lease against.
The message is written this way on purpose. The alternative failure — a later, vaguer complaint about an unknown key id — has a completely different cure, and telling the two apart afterwards is much harder than saying so now.
Install an official release build. The release script refuses to package a
binary with no authority key unless it is explicitly told to, so a build in this
state came from a local go build or from a deliberately
key-less release.
The one legitimate case for seeing this on a build you meant to make is
running your own licence authority, which is configured by adding to
license.trusted_keys. Configuration can only add keys to
the compiled-in set; it can never remove one, and a configured key whose id
collides with a compiled-in id is refused rather than preferred. Otherwise
editing a JSON file could substitute a different key for a built-in id, and the
built-in key would be decoration.
The encoder connects and is then dropped. OBS reports a failure to publish;
ffmpeg typically prints Operation not permitted. On the wire the
connection is closed with NetStream.Publish.BadName and the
description Publish rejected. The stream stays in state
idle.
The client is told nothing useful on purpose. Every refusal — wrong key, unknown stream, wrong RTMP application, stream disabled, licence lapsed — looks identical to the publisher, and all of them are padded to the same fixed delay so they cannot be told apart by timing either. A publisher that could distinguish "wrong key" from "no such stream" could enumerate your streams.
The real reason is in your log and nowhere else.
journalctl -u ettix-stream --since -10m | grep "publish rejected"
Each rejection logs one line carrying a stable code. These codes
are part of the operational contract — alerts get written against them — and
they do not change between a stream that uses a plain stream key and one that
grows a full publisher block later:
code | What it means |
|---|---|
PUB_STREAM_KEY_MISSING | No stream key was presented at all. The publisher connected to the right place with no credential |
PUB_STREAM_KEY_INVALID | A key was presented and it is not the configured one |
PUB_STREAM_UNKNOWN | There is no stream with that name. Check the stream name in the encoder against the id in the configuration |
PUB_APP_MISMATCH | Right stream, wrong RTMP application — the live in rtmp://host/live/name must match source.rtmp.app |
PUB_STREAM_NOT_RTMP | The stream is configured as srt_pull. It does not accept pushed publishers at all |
PUB_STREAM_DISABLED | "enabled": false on that stream |
PUB_STREAM_BUSY | A publisher is already connected and replace_policy is reject |
PUB_REPLACE_TOO_SOON | A replacement was rate-limited by min_replace_interval. Reported separately in your log, but indistinguishable from busy to the publisher |
PUB_UNLICENSED | No valid licence covers RTMP ingest. See the licence section |
PUB_AUTH_UNAVAILABLE | The authorization policy could not decide — a broken publisher block, or an ingest callback that did not answer. An ingest decision that cannot be made is not a decision to admit |
For a wrong or missing key, the key is in the configuration
file, in the stream's source.rtmp.stream_key:
grep -n stream_key /etc/ettix-stream/config.json
The installer generates a real key when it writes a starter configuration, so
on a fresh installation this is a value you have never seen rather than a
default you can guess. If the file still says
change-this-stream-key, the server will refuse to start; replace
it. A stream key must be at least 8 characters.
The publisher may present it in either of two places, and ETTIX accepts both because encoders differ:
| Where | Example |
|---|---|
The key query parameter — the documented place | rtmp://host/live/channel1?key=<stream key> |
| The RTMP password field, which is where OBS and librtmp-based tools put a stream key | Stream key box in OBS, or ?pass= |
The password field is read as the stream key only when the stream
configures no publisher accounts. Once a stream has a publisher
block with users, the password field means a password again, and the stream key
must arrive as ?key=. This is one deliberate decision made in one
place rather than an ambiguity left for each encoder to resolve differently.
allow_anonymous turns the credential check off
for a stream entirely: any publisher that can reach the RTMP listener is
accepted. It exists for a listener that is already protected by something else —
a private network, a firewall rule — and it is refused in combination with
configured publisher credentials, so that a configuration cannot half-say two
things at once. When it is set, ETTIX logs
INGEST_UNAUTHENTICATED at startup so the state is never silent.
Turn it on only if you can say what is protecting the listener instead.
Every stream must resolve to one of the three: a stream_key,
allow_anonymous, or configured publisher credentials. A stream with
none of them fails validation with
stream_key is required unless allow_anonymous is true or the stream
configures publisher credentials.
The stream never leaves idle. connect_failures
climbs on the source. The journal carries source connect failed
lines, rate-limited so a source retrying every second does not flood the log.
First, the direction. ETTIX dials out for an
srt_pull source: it is the caller and the far end must be a
listener. If the far end is also configured to call out, nothing will ever
connect no matter what else is right.
Second, SRT is UDP. A firewall rule, a security group or a NAT mapping written for TCP on that port does nothing at all. This is the single most common cause and it is invisible from the ETTIX side, because an unreachable listener and a refused handshake look the same from here.
The connection error is kept per source, with the passphrase and stream id stripped out of it, and it is easiest to read from the API:
curl -s http://127.0.0.1:8081/api/v1/streams/channel1/sources | jq '.'
journalctl -u ettix-stream --since -10m | grep "source connect failed"
Look at last_error on each source. There are two messages that
matter and they mean quite different things:
last_error begins | What it means |
|---|---|
srt: connect timeout (listener unreachable, or passphrase/stream id rejected by peer) | The handshake did not complete within connect_timeout (5 s by default). Nothing is listening, a firewall is dropping the UDP, the host or port is wrong — or the peer is a libsrt listener that rejected the credentials by not answering at all. The message says so because those cases are genuinely indistinguishable from this side. Check reachability and the UDP rule first |
srt: connection rejected by peer | The listener answered and refused. This is a credential or identity problem: wrong passphrase, wrong pbkeylen, or wrong stream_id. The network is fine |
srt: cannot resolve host | DNS. The host field does not resolve from this machine |
A third case connects and then produces nothing useful: the source shows as
connected, but srt.packets_undecrypted climbs. That is a passphrase
that matches on one side only.
The far end must be listening. The port must be open for UDP, on every
firewall and NAT between the two machines. Test with a UDP-aware tool, not with
telnet or a TCP port scan, both of which will report the port
closed even when SRT is working.
Both ends must agree. pbkeylen must be 16, 24 or 32 — for
AES-128, AES-192 and AES-256 respectively — and it must be the same number the
sender uses. Leaving it out selects 16 when a passphrase is set. Setting it
without a passphrase is refused by validation, because it would not mean
anything. The passphrase itself must be between 10 and 79 bytes, which is the
SRT protocol's own limit rather than a choice made here.
ettix-stream config check --config /etc/ettix-stream/config.json
Many SRT listeners route by stream_id and refuse a caller that
does not present the expected one. It is at most 512 bytes and must contain no
control characters. It is treated as a secret in logs — it never appears in an
error message — so you must compare it against the far end by reading the
configuration, not by reading the log.
latency defaults to 120 ms and must be between 20 ms and 8 s.
connect_timeout defaults to 5 s and must be between 1 s and 60 s.
Raising the connect timeout does not fix a firewall; it only makes you wait
longer to learn the same thing. Raise latency for a lossy link, not
for a link that never connects.
If the log says SRT_UNLICENSED, the sources are stopping
because no valid licence covers SRT ingest, not because of anything about the
connection. See the licence section.
The stream reports live, the playlist updates, and playback
still breaks up, freezes or drops out.
There are three separate places this can come from and they have three different cures. Two counters tell them apart, and reading them the wrong way round wastes a lot of time.
curl -s http://127.0.0.1:8081/api/v1/streams/channel1 | jq '.sources[] | {id, bitrate_bps, srt, demux}'
| What is rising | What it points at | What to do |
|---|---|---|
srt.packets_dropped, alongside srt.packets_retransmitted and a non-zero srt.loss_rate_percent | The link is losing packets and the retransmissions are arriving too late to be useful. SRT drops a packet it can no longer deliver inside the latency budget | Raise latency on that source. The budget has to cover several round trips, so a link with a real wide-area round-trip time needs substantially more than a link on a local network |
demux.cc_errors or demux.discontinuities | The source is producing a broken transport stream. The continuity counters in the MPEG-TS packets do not follow on. This is upstream of ETTIX | Nothing here will fix it. Look at the encoder, or at whatever the encoder is going through. If srt.packets_lost is zero while these climb, the stream arrived intact and was already broken |
| Neither, but viewers still stutter | Delivery, not ingest. The media reached ETTIX cleanly and got to the viewer badly | Look at http.write_timeout, at the CDN or proxy in front, and at /api/v1/system for network saturation. Also check hls.dropped_packets and hls.dropped_before_keyframe on the stream |
bitrate_bps well below what the encoder is configured to send | Loss on the way in, almost always. It is a useful cross-check on the two rows above | Compare against the encoder's own reported output before assuming anything |
srt.packets_lost rising on its own is not necessarily a problem —
loss that SRT recovered in time is what SRT is for. It is
packets_dropped that means a viewer saw a gap.
Nothing is appearing on disk, or a destination that was working has stopped.
curl -s http://127.0.0.1:8081/api/v1/disks | jq '.disks[].destinations'
curl -s http://127.0.0.1:8081/api/v1/streams/channel1/dvr | jq .
journalctl -u ettix-stream | grep "dvr destination"
Each destination reports a state, and every transition is
logged. Read the state and the reason together:
state | What it means |
|---|---|
idle | Configured, not yet started. Recording has not been asked for — see the recording mode below |
active | Accepting writes. This is the healthy state |
degraded | Writes are failing transiently, or the write queue is overflowing. The destination drops segments rather than stalling the live media path or growing without limit |
full | Out of space, and retention cannot free any more |
failed | Unusable: the root is missing, not writable, read-only, or holds an on-disk format this build does not support |
The reason field carries the specific cause, and these strings
are exact:
reason | Cause |
|---|---|
no space left on device | The volume is full. Retention could not free enough, or no retention rule is configured |
destination root is not writable | A permission error. Almost always ReadWritePaths — see below |
destination root is read-only | The filesystem itself is mounted read-only, or ProtectSystem=strict is making it so |
destination root is missing | The path does not exist, or a volume did not mount |
unsupported on-disk format | The directory holds a DVR format this build cannot read, usually after a downgrade |
write error | Something else the storage returned. The underlying error is in the log line and in last_error |
A destination in idle is configured and waiting. The stream's
dvr.mode decides when recording happens: always (the
default), schedule (only inside the configured windows),
api (only when told), or off. In api mode
nothing records until you ask:
curl -sX POST http://127.0.0.1:8081/api/v1/streams/channel1/dvr/recording \
-H 'Content-Type: application/json' -d '{"action": "start"}' | jq .
Also confirm dvr.enabled is true for that stream, and that the
stream itself is live — there is nothing to record from a stream that has no
publisher.
The shipped systemd unit sets ProtectSystem=strict, which makes
the entire filesystem read-only to the service except for the paths the unit
lists. A DVR root that is not listed fails at the very first write, with a
permission error that looks nothing like a configuration mistake.
systemctl cat ettix-stream | grep ReadWritePaths
systemctl edit --full ettix-stream # add the new root to ReadWritePaths=
systemctl daemon-reload
systemctl restart ettix-stream
install.sh appends the roots that were configured at install
time. A destination you add afterwards is yours to add to the unit. The binary
will tell you which roots the configuration names, so you do not have to parse
JSON in a shell:
ettix-stream config check --config /etc/ettix-stream/config.json --dvr-roots
The directory must also be owned by, or writable by, the
ettix-stream service account.
Retention is per destination and combinable: max_age,
max_bytes, min_free_percent and
min_free_bytes. It runs incrementally on
retention_interval (60 s by default) and never blocks recording. A
destination that reaches full with no retention rule configured
will stay full until you configure one or free space by hand.
Point a destination at a dedicated filesystem where you can. Free-space retention measures the filesystem, so a root sharing a volume with something else is measuring the wrong thing.
A failed destination does not affect the live stream, and it does not affect
any other destination. Recording resumes on its own as soon as the medium
accepts writes again — a destination that was degraded, full or failed recovers
by itself, and logs dvr destination healthy when it does. You do
not need to restart the service to bring one back.
If the index rather than the media is damaged — a destination reported as degraded after an unclean shutdown, say — the index is rebuilt from the media at startup, so most damage needs no action at all. When it does:
ettix-stream dvr verify --path /mnt/dvr1 --stream channel1 # fast
ettix-stream dvr verify --path /mnt/dvr1 --stream channel1 --deep # reads every byte
ettix-stream dvr repair --path /mnt/dvr1 --stream channel1 # rebuild from media
repair reads the media and writes a new index. It does not
modify or delete a segment. verify --deep compares each segment
against the SHA-256 recorded when it was written, which detects
corruption, not tampering — the digest lives in the journal on the same
volume as the media, so anyone who could alter one could alter the other. The
tool says so in its own output.
Three distinct problems present almost identically. Work through them in this order.
The administration listener defaults to 127.0.0.1:8081 —
loopback only. Not "loopback unless you set something", and not
an empty default: an omitted admin_listen binds loopback rather
than every interface, and the server refuses at the point of binding as well as
during validation, so no code path can produce a remotely reachable
administration API by omission.
This is because the API is privileged. It can stop a broadcast, read every stream key, and delete recorded media. Reachable and anonymous is a combination the server refuses rather than warns about.
So from another machine, there is nothing to reach, and that is correct. Test from the server itself first:
curl -s http://127.0.0.1:8081/api/v1/status | head
If that works, the service is fine and you need a tunnel. An SSH local forward is the recommended way in, because it needs no configuration change on the server and no port opened anywhere:
ssh -N -L 8081:127.0.0.1:8081 you@stream.example.com
Then open http://127.0.0.1:8081/ on your own machine. Browsers
treat 127.0.0.1 as a trustworthy origin, so this works with no
certificate and no exceptions to click through.
If you genuinely need the listener bound to a real address — for a
management VLAN, say — it takes two settings, and validation will not accept one
without the other: http.admin_allow_remote to acknowledge the
exposure, and at least one credential in http.admin_auth. Put a
firewall, VPN or authenticating proxy in front of it as well. The server logs
ADMIN_API_REMOTE whenever it is in this state.
You reach the interface, it shows a sign-in form, and signing in reports "Sign-in failed" no matter what you type — including the credentials you are certain of.
This means no credential is configured. The sign-in routes
exist only when http.admin_auth configures at least one user or API
key; with none configured they are not registered at all, the interface's first
request for a session gets a 404, and it falls back to showing the form. The
form is real; it is talking to endpoints that are not there.
Note the corollary: on a loopback listener with no credential configured, the
API itself is open. So while you cannot sign in through the browser, you can use
curl from the machine and everything will work. That is the
supported single-operator setup, and it is why validation permits an empty
admin_auth only on loopback.
To create a credential:
ettix-stream admin hash --user ops
It prompts twice on the terminal with echo off and prints one line to
standard output — the JSON object to paste into
http.admin_auth.users. The password is never a flag, because a
password on a command line ends up in the shell history, in
/proc/<pid>/cmdline for every user on the machine, and in the
audit log. The minimum length is 12 characters.
$ ettix-stream admin hash --user ops
Add to http.admin_auth.users:
{"username": "ops", "password_hash": "$argon2id$v=19$m=19456,t=2,p=1$…", "read_only": false}
For automation, generate an API key instead. The secret half is generated rather than chosen, and it is printed once — ETTIX stores only the hash and cannot print it again:
ettix-stream admin hash --api-key monitoring --read-only
Paste the object into the configuration, then:
ettix-stream config check --config /etc/ettix-stream/config.json
systemctl restart ettix-stream
You enter the right password, the page moves, and you are immediately back at the sign-in form. Repeatedly.
This is the session cookie being refused by your browser. The cookie is named
with the __Host- prefix, which is a browser-enforced promise that
the cookie is Secure, has Path=/ and has no
Domain — together, that stops a subdomain from setting a cookie
this server would then trust. The Secure attribute is therefore
always present and is not conditional on anything.
Browsers accept a Secure cookie from
http://127.0.0.1 and http://localhost, so the loopback
default works with no TLS. The one case a browser refuses is a
remote administration listener reached over plain HTTP with no
TLS-terminating proxy in front of it. Refusing is the right behaviour there: the
alternative is a session cookie travelling in the clear across a network.
Fix it by reaching the interface over loopback (the SSH forward above) or by
putting TLS in front of the administration listener. Note that
curl does not enforce cookie prefixes, so an API-key credential
will keep working from the command line while a browser cannot sign in at all —
which is exactly how this becomes confusing.
After 10 consecutive failures the account is refused outright for 15
minutes, and the client address is counted separately with the same limit. The
response is HTTP 429 and the interface says "Too many failed attempts. Try again
later."; the log carries ADMIN_LOGIN_LOCKED_OUT. Both counters are
in memory, so restarting the service clears them — at the cost of ending every
active session.
You edit something in the interface, press save, and it reports an error. The change is not applied and is not written to disk.
A configuration change is validated in full, written to the file in full, and then applied — in that order, so a bad document changes nothing and a crash between the write and the reload leaves the file and the next start agreeing with each other. The error tells you which of those steps failed.
| Error code, or message | Cause | Fix |
|---|---|---|
CONFIG_INVALID, with the individual problems listed in details.errors | The document did not pass validation. Same rules and same messages as config check | Correct the fields it names |
CONFIG_INVALID with a message beginning server: writing configuration: or server: replacing configuration:, ending in permission denied or read-only file system | The service cannot write /etc/ettix-stream | See below — this is the common one |
CONFIG_CONFLICT, "the configuration changed since it was read" | Somebody, or something, changed the configuration between the interface reading it and you saving. The write was refused rather than silently overwriting their change | Reload the page and make the change again |
CONFIG_READ_ONLY | This server was started with no configuration file, so there is nothing to write back to | Nothing to fix on a normal installation; this is a test or embedded configuration |
READ_ONLY, "this credential may read but not change anything" | You are signed in with a credential marked read_only | Use a credential without "read_only": true |
CSRF_REQUIRED | A mutating request arrived without the X-ETTIX-CSRF header. In a browser this should not happen; from a script it means you are using session cookies where you should be using an API key | Use Authorization: Bearer <id>.<secret> for automation |
The administration API writes the configuration file back — saving from the interface, enabling or disabling a stream, editing a destination. That write is an atomic replace: a temporary file is created in the same directory, flushed, and renamed over the original. A crash at any point leaves either the old file or the new one, never a truncated one. But it means the service needs write permission on the directory, not merely on the file.
Two things must both be true:
# 1. The directory is listed in the unit
systemctl cat ettix-stream | grep ReadWritePaths
# Expect /etc/ettix-stream among the paths
# 2. The directory is group-writable by the service account
ls -ld /etc/ettix-stream
# Expect: drwxrws--- root ettix-stream (mode 2770)
ls -l /etc/ettix-stream/config.json
# Expect: -rw-r----- root ettix-stream (mode 0640)
Both are set correctly by install.sh. The setgid bit keeps the
group on files the service creates; the file stays 0640, so nothing
outside the service account can read the stream keys. If you have tightened the
directory to 0750 by hand, every save will fail with "permission
denied" — which reads like a bug in the API rather than a permission that was
never granted.
The trade-off is real and is worth stating: a compromised streaming process
can rewrite its own configuration. It is accepted because the alternative is
shipping a configuration API that cannot save. root still owns the
directory.
If the response says "restart_required": true, the change was
written and accepted but part of it cannot take effect in a running process.
Listener addresses, the RTMP block, the HLS cache size, the data directory, and
the licence and telemetry blocks are all read once when the process starts:
systemctl restart ettix-stream
This is reported rather than hidden for a specific reason. An operator who
corrects a licence authority URL, sends a reload, is told it succeeded, and then
watches nothing change has no way to tell a configuration mistake from a broken
server. Stream additions, removals and changes do apply on a reload
(systemctl reload ettix-stream, which sends SIGHUP).
HTTPS does not work, or a browser refuses the certificate, or the service
will not start with tls is configured but the certificate manager could
not start.
There is no silent fallback. A configuration that asks for TLS and cannot have it is a startup failure, not a server that quietly serves plain HTTP, and no substitute or self-signed certificate is ever presented — a certificate a client will not trust is worse than a refusal it can read.
The certificate status carries the last error, already safe to display:
curl -s http://127.0.0.1:8081/api/v1/certificates | jq .
And to see what a client actually receives — ask openssl, not a
browser, because a browser may be showing you a cached intermediate:
echo | openssl s_client -connect stream.example.com:443 -servername stream.example.com 2>/dev/null \
| openssl x509 -noout -issuer -subject -dates -ext subjectAltName
| What you see | What it means |
|---|---|
certs: private key file permissions are too open | The key file is readable or writable by someone other than its owner. Install it 0600 ettix-stream:ettix-stream |
certs: private key does not match the certificate | The two files are not a pair. Usually a half-finished renewal, or the wrong file copied |
certs: certificate chain is not ordered leaf first | certificate_file must be the full chain with the leaf first. A leaf-only or misordered file works in a browser that has cached the intermediate and fails in one that has not, which is the worst kind of failure to debug |
certs: certificate has expired / is not valid yet | Exactly that. Check the machine's clock as well as the certificate |
certs: not a certificate and key this build can read | Wrong PEM block type, truncated, encrypted, or larger than the accepted bound. Encrypted private keys are not supported |
connection refused during ACME validation | Port 80 is not reachable from the internet, or something else is bound to it. The CA connects to port 80 even when playback is on 443 |
DNS problem: NXDOMAIN | The name does not resolve publicly yet. The CA resolves it itself; /etc/hosts does not count |
certs: the CA refused the order because of a rate limit | Production was used before staging. Waiting is the only cure; there is no override |
certs: the daily cap on issuance attempts has been reached | ETTIX's own cap of five issuance attempts per day. It exists so that a bug here cannot turn into a loop against somebody else's service, and nothing lifts it |
| A certificate is present and browsers still refuse it | You are still pointed at the ACME staging directory, or you switched to production without deleting the staging state |
For a certificate you manage yourself, correct the file and let the reload
timer pick it up — reload_interval re-reads both files on a timer
so an external renewer can replace them without restarting ETTIX. A file that
has changed but does not parse is an error that keeps the working certificate in
place, so a half-written file during a renewal is a transient log line rather
than a broken server.
For ACME, the "renew now" button in the administration interface, or the endpoint behind it, clears the backoff so that a corrected configuration takes effect at once. It deliberately does not lift the daily attempt cap:
curl -sX POST http://127.0.0.1:8081/api/v1/certificates/renew | jq .
If you switched from staging to production and browsers still see the untrusted certificate, the server is serving the one it already has. The staging state has to go:
systemctl stop ettix-stream
rm -rf /var/lib/ettix-stream/acme
systemctl start ettix-stream
Do ACME staging first, every time. Let's Encrypt's production limits are
weekly and unforgiving — five failed validations per account, per hostname, per
hour, and fifty certificates per registered domain per week. A mistyped hostname
or a firewall rule you have not tested can lock the real domain out for days,
and no amount of correcting the configuration afterwards shortens that wait.
This is also why directory_url has no default at all: a staging
default would serve certificates no browser trusts and look like a bug in
ETTIX, and a production default would let a dry run burn a real rate limit.
Everything works and everything is slow: segments take a long time to write, the DVR falls behind, a test run that used to take minutes takes an hour, or the machine feels loaded while showing very little CPU.
Measure fsync before believing anything else.
Durable writes dominate this kind of workload, and the cost of one flush varies
by two orders of magnitude between volumes on the same machine. During this
project's own validation, fsync on one volume measured 83 ms median
on an idle machine — an ageing consumer SSD, nearly full — while a RAID array on
the same host answered in 0.93 ms. Ninety times. Every theory about contention,
about the code, and about load was wrong until that was measured, and load only
doubled the bad figure: the disk was pathological at rest.
ETTIX does not ship a benchmark, so use whatever the machine has. This gives
a rough per-flush cost on a specific volume, which is what matters — run it on
each volume separately, including the DVR roots and whatever holds
TMPDIR:
# 1000 flushed 4 KiB writes. Divide the elapsed time by 1000.
dd if=/dev/zero of=/var/lib/ettix-stream/fsynctest bs=4k count=1000 oflag=dsync
rm -f /var/lib/ettix-stream/fsynctest
If fio is installed it gives a proper distribution rather than a
mean, which is more useful because the tail is what hurts. It is not part of any
ETTIX dependency and may well not be there.
Then read what ETTIX itself measures. The disk figures are per DVR destination and are the closest thing to a direct answer:
curl -s http://127.0.0.1:8081/api/v1/disks | jq '.disks[] | {device, busy_percent, destinations}'
curl -s http://127.0.0.1:8081/api/v1/system | jq '{cpu: .cpu, memory: .memory}'
| Field | What it tells you |
|---|---|
last_write_latency_ms, max_write_latency_ms | What a segment write is actually costing on that destination. Compare against your segment duration: if a write takes a meaningful fraction of a segment, the destination is close to the edge |
queue_depth against queue_capacity | A queue that is filling means the destination cannot keep up. When it overflows the destination goes degraded and drops segments rather than stalling the live path |
segments_dropped, write_errors | Non-zero means it has already overflowed or failed |
busy_percent | The device's own utilisation, when the kernel reports it |
cpu.iowait_percent | Time spent waiting on storage rather than computing. High iowait with low CPU is the classic disk-bound signature |
cpu.steal_percent | Time the hypervisor took away. On a virtual machine this is the first thing to look at when the machine feels slow but idle |
cpu.per_core_percent | A process pinned on one core looks idle in the average |
cpu.limit.used_percent | Present when a cgroup CPU quota is in force. Approaching 100 means the process is being throttled by the container, not by the machine |
Move the work to a faster volume. That is usually the whole answer, and it is cheaper than any amount of tuning. For a DVR destination, give it a dedicated filesystem on storage that can take the write rate.
If you cannot, the durability setting per destination is the lever, and it is a genuine trade rather than a free win:
sync | What it does | Cost |
|---|---|---|
segment | An fdatasync on every segment | One flush per segment per destination. On a slow volume this is the setting that hurts |
interval (default) | A background syncer every sync_interval, 1 s by default | The sensible default. A sudden power loss can cost the last interval's writes |
none | No explicit flushing; the kernel decides | Fastest, and the least durable. Reasonable only where the storage layer already guarantees durability |
If you are building and testing ETTIX rather than running it, the same rule
applies to the test suite: put TMPDIR and any test database on the
fastest volume the machine has before concluding anything about the code. On
this project's validation host, moving them accounted for every timeout that had
previously been blamed on contention.
Run these and send the output. Between them they identify the build, prove whether the configuration is valid, and describe the state of the licence, the streams and the recent past.
ettix-stream version --json
ettix-stream config check --config /etc/ettix-stream/config.json
curl -s http://127.0.0.1:8081/api/v1/status | jq .
curl -s http://127.0.0.1:8081/api/v1/streams | jq .
curl -s http://127.0.0.1:8081/api/v1/license | jq .
curl -s http://127.0.0.1:8081/api/v1/disks | jq .
curl -s http://127.0.0.1:8081/api/v1/certificates | jq .
curl -s http://127.0.0.1:8081/api/v1/system | jq .
curl -s 'http://127.0.0.1:8081/api/v1/events?limit=500' | jq .
journalctl -u ettix-stream --since -1h --no-pager
Add the Authorization: Bearer <id>.<secret> header to
each curl if the administration API has a credential configured.
The installation_id from /api/v1/license is the
identifier support will ask for first. It is a public identifier and is safe to
quote; so is the public_key in the same response, which is
registered with Ettix.com by design. The private half is not in that response,
is not reachable from it, and a test asserts that its bytes never appear in the
marshalled form.
Everything in the list above is redacted. The log formatter and the event ring share the same redaction function rather than each having a copy, so a secret cannot reach one sink by escaping the other, and release validation confirmed on a live system that the stream key, the SRT passphrase and the administration password appeared zero times in the journal.
The configuration file is not redacted, and must not be sent anywhere.
/etc/ettix-stream/config.json holds stream keys, SRT
passphrases and administration password hashes in the clear, because that is
what the server has to read in order to work. It is deliberately excluded from
this list. Do not attach it to a support ticket, paste it into a chat, or put it
in a bug report.
If support asks about a specific setting, quote that setting with its value
removed, or use GET /api/v1/config — that response renders every
secret redacted and carries "redacted": true to say so. Note that
this also means the API response cannot be posted back as a configuration
without restoring the secrets first: it is a view, not a copy.
If the problem involved a specific request, include its
request_id. Every error body carries one and it appears in the
access log, which makes finding the corresponding server-side line exact rather
than a search by timestamp.
Two procedures are deliberately kept separate because they are destructive if run in the wrong situation, and both need their own reading before you touch anything: recovering a lost encryption master key, and rebuilding an installation after the machine is gone. There is no recovery for a master key with no backup — encrypted recordings are unreadable, permanently, and Ettix holds no copy, which is precisely what makes the recordings yours. If you have not taken a backup of it, take one now:
ettix-stream keys export --config /etc/ettix-stream/config.json --out ettix-master-key.backup
Move it off this machine. A backup that lives on the machine it protects is not a backup.