Troubleshooting

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.

Three things worth knowing before you start

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.

Symptom index

What you are seeingGo to
The unit will not start, or restarts every two secondsThe service will not start
Every playlist and segment answers 503, but the administration interface worksEverything answers 503
no trusted licence authority keys are availableNo trusted authority keys
OBS or ffmpeg cannot publish; the stream stays idleA publisher is refused
An SRT source never connects, or connects and produces nothingAn SRT source will not connect
The stream is live but viewers stutterLive but stuttering
Nothing is being recorded, or a destination stoppedDVR is not recording
A sign-in form that never works, or no interface at allCannot sign in, cannot reach it
Saving a change in the interface failsA change will not save
HTTPS fails, or a browser refuses the certificateCertificates
The server, or a build of it, is inexplicably slowInexplicably slow
You need to send something to supportCollecting evidence for support

The service will not start

What you see

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.

What it means

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.

What to run

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:

ExitMeaning
0Valid. It prints a one-line summary: /etc/ettix-stream/config.json: OK (1 streams, http :8080, rtmp :1935)
1The 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"
2You 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
3The 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

How to fix it

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:

MessageWhat 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 itThe 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 loopbackBind 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 pathThe 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.


Everything answers 503, and the administration interface still works

What you see

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.

What it means

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.

What to run

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:

stateWhat it meansWhat to do
onlineA lease is valid and renewal is working. Normal.Nothing. If you are seeing 503s in this state the cause is not the licence
offlineThe 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 issuedFix 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
expiredNothing valid remains — the lease's own expiry or the 72-hour offline ceiling passed, whichever came firstPOST /api/v1/license/renew, or activate with a fresh token
revokedEttix produced a signed revocation for this installation's licenceContact Ettix with the installation_id. The server will not resume on its own
unlicensedNo lease has ever been accepted. A fresh installation is in this stateActivate. The installation_id in the same response is what you register at Ettix.com
invalidA 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 differentUsually 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

How to fix it

Try a renewal first

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 .

If renewal cannot work, activate

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 .

Check the clock while you are here

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.


"no trusted licence authority keys are available"

What you see

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

What it means

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.

How to fix it

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.


A publisher is refused

What you see

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.

What it means

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.

What to run

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:

codeWhat it means
PUB_STREAM_KEY_MISSINGNo stream key was presented at all. The publisher connected to the right place with no credential
PUB_STREAM_KEY_INVALIDA key was presented and it is not the configured one
PUB_STREAM_UNKNOWNThere is no stream with that name. Check the stream name in the encoder against the id in the configuration
PUB_APP_MISMATCHRight stream, wrong RTMP application — the live in rtmp://host/live/name must match source.rtmp.app
PUB_STREAM_NOT_RTMPThe stream is configured as srt_pull. It does not accept pushed publishers at all
PUB_STREAM_DISABLED"enabled": false on that stream
PUB_STREAM_BUSYA publisher is already connected and replace_policy is reject
PUB_REPLACE_TOO_SOONA replacement was rate-limited by min_replace_interval. Reported separately in your log, but indistinguishable from busy to the publisher
PUB_UNLICENSEDNo valid licence covers RTMP ingest. See the licence section
PUB_AUTH_UNAVAILABLEThe 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

How to fix it

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:

WhereExample
The key query parameter — the documented placertmp://host/live/channel1?key=<stream key>
The RTMP password field, which is where OBS and librtmp-based tools put a stream keyStream 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.


An SRT source will not connect

What you see

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.

What it means

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.

What to run

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 beginsWhat 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 peerThe 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 hostDNS. 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.

How to fix it

Confirm the direction and the transport

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.

Match the crypto settings exactly

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

Check the stream id

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.

Only then adjust the timing

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 is live but viewers stutter

What you see

The stream reports live, the playlist updates, and playback still breaks up, freezes or drops out.

What it means

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.

What to run

curl -s http://127.0.0.1:8081/api/v1/streams/channel1 | jq '.sources[] | {id, bitrate_bps, srt, demux}'
What is risingWhat it points atWhat to do
srt.packets_dropped, alongside srt.packets_retransmitted and a non-zero srt.loss_rate_percentThe 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 budgetRaise 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.discontinuitiesThe source is producing a broken transport stream. The continuity counters in the MPEG-TS packets do not follow on. This is upstream of ETTIXNothing 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 stutterDelivery, not ingest. The media reached ETTIX cleanly and got to the viewer badlyLook 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 sendLoss on the way in, almost always. It is a useful cross-check on the two rows aboveCompare 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.


DVR is not recording

What you see

Nothing is appearing on disk, or a destination that was working has stopped.

What to run

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"

What it means

Each destination reports a state, and every transition is logged. Read the state and the reason together:

stateWhat it means
idleConfigured, not yet started. Recording has not been asked for — see the recording mode below
activeAccepting writes. This is the healthy state
degradedWrites are failing transiently, or the write queue is overflowing. The destination drops segments rather than stalling the live media path or growing without limit
fullOut of space, and retention cannot free any more
failedUnusable: 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:

reasonCause
no space left on deviceThe volume is full. Retention could not free enough, or no retention rule is configured
destination root is not writableA permission error. Almost always ReadWritePaths — see below
destination root is read-onlyThe filesystem itself is mounted read-only, or ProtectSystem=strict is making it so
destination root is missingThe path does not exist, or a volume did not mount
unsupported on-disk formatThe directory holds a DVR format this build cannot read, usually after a downgrade
write errorSomething else the storage returned. The underlying error is in the log line and in last_error

How to fix it

If nothing was ever recorded, check that recording was asked for

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.

A permission error right after adding a destination is nearly always ReadWritePaths

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.

For a full destination, look at retention before adding disk

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.


The administration interface shows a sign-in form that never works, or I cannot reach it at all

Three distinct problems present almost identically. Work through them in this order.

1. Nothing responds on the administration port

The administration listener defaults to 127.0.0.1:8081loopback 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.

2. The sign-in form appears, and every attempt fails

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

3. Sign-in appears to succeed, then bounces straight back to the form

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.

Locked out

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.


A change in the administration interface will not save

What you see

You edit something in the interface, press save, and it reports an error. The change is not applied and is not written to disk.

What it means

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 messageCauseFix
CONFIG_INVALID, with the individual problems listed in details.errorsThe document did not pass validation. Same rules and same messages as config checkCorrect 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 systemThe service cannot write /etc/ettix-streamSee 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 changeReload the page and make the change again
CONFIG_READ_ONLYThis server was started with no configuration file, so there is nothing to write back toNothing 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_onlyUse a credential without "read_only": true
CSRF_REQUIREDA 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 keyUse Authorization: Bearer <id>.<secret> for automation

How to fix a permission error

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.

Saved, but nothing happened

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).


Certificates

What you see

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.

What it means

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.

What to run

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 seeWhat it means
certs: private key file permissions are too openThe 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 certificateThe two files are not a pair. Usually a half-finished renewal, or the wrong file copied
certs: certificate chain is not ordered leaf firstcertificate_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 yetExactly that. Check the machine's clock as well as the certificate
certs: not a certificate and key this build can readWrong PEM block type, truncated, encrypted, or larger than the accepted bound. Encrypted private keys are not supported
connection refused during ACME validationPort 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: NXDOMAINThe 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 limitProduction was used before staging. Waiting is the only cure; there is no override
certs: the daily cap on issuance attempts has been reachedETTIX'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 itYou are still pointed at the ACME staging directory, or you switched to production without deleting the staging state

How to fix it

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.


The server, or a build of it, is inexplicably slow

What you see

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.

What it means

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.

What to run

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}'
FieldWhat it tells you
last_write_latency_ms, max_write_latency_msWhat 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_capacityA 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_errorsNon-zero means it has already overflowed or failed
busy_percentThe device's own utilisation, when the kernel reports it
cpu.iowait_percentTime spent waiting on storage rather than computing. High iowait with low CPU is the classic disk-bound signature
cpu.steal_percentTime 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_percentA process pinned on one core looks idle in the average
cpu.limit.used_percentPresent when a cgroup CPU quota is in force. Approaching 100 means the process is being throttled by the container, not by the machine

How to fix it

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:

syncWhat it doesCost
segmentAn fdatasync on every segmentOne 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 defaultThe sensible default. A sudden power loss can cost the last interval's writes
noneNo explicit flushing; the kernel decidesFastest, 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.


Collecting evidence for support

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.


What is not on this page

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.