Streaming
This page is about getting video into ETTIX STREAM and getting it out again. A stream is one channel. It has one source, one HLS timeline and one identifier, and that identifier appears in every URL and every API route that concerns it.
There are two ways in, chosen per stream by streams[].source.type:
| Type | Who connects to whom | Typical use |
|---|---|---|
rtmp_push |
The encoder connects to ETTIX, on the RTMP port. ETTIX authenticates the publisher. | OBS, ffmpeg, Wirecast, vMix, hardware encoders. |
srt_pull |
ETTIX connects out to a remote SRT listener and pulls MPEG-TS from it. The far end authenticates ETTIX. | Contribution feeds from a remote site, and any source you want ETTIX to chase rather than wait for. |
There is one way out: HLS over HTTP, optionally encrypted. The output is the same whichever ingest produced it, so nothing downstream of the server needs to know which one a stream uses.
Every configuration change described here goes into the JSON configuration
file. Validate it with ettix-stream config check before you apply
it, and apply it with SIGHUP rather than a restart: an invalid file
is rejected and the running configuration stays active.
ettix-stream config check --config /etc/ettix-stream/config.json
kill -HUP $(pidof ettix-stream)
Ingest — RTMP push
ETTIX STREAM is an RTMP publish server only. It accepts H.264 video
and AAC audio and does not serve RTMP playback. The listener is
rtmp.listen, default :1935.
The URL
rtmp://host:1935/<app>/<stream-id>?key=<stream key>
Three parts of that URL matter, and they are not interchangeable.
The stream id is the last path element. It is
streams[].id in the configuration, and it is what selects the
stream. It must match ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ — one to
sixty-four characters of letters, digits, underscore and hyphen, starting with a
letter or a digit. The same id names the stream in the playback URL, so an id is
a public identifier and should not be secret.
The app is the path element before it, and it is a second
thing that has to agree rather than a second way to select a stream. ETTIX looks
the stream up by id, then requires source.rtmp.app to equal the app
the publisher connected with; if it does not, the publish is refused exactly as
though the stream did not exist. The default app is live. Use the
app to group channels for your own clarity, or to make a copied encoder profile
fail loudly when it is pointed at the wrong service.
The stream key is a credential and is not part of the path.
It travels as the key query parameter.
Most encoders split the URL into a "Server" field and a "Stream Key" field.
The split is at the app boundary, and the query string goes in the second field.
In OBS: Server rtmp://origin.example/live, Stream Key
channel1?key=SECRET. ETTIX merges query parameters from both halves
before it looks at them, so it does not matter to the server which field carried
them.
A complete RTMP example
Configure the stream
This is a whole, valid configuration file. The stream key here is an example; generate your own, and make it long — the minimum accepted length is 8 characters, which is a floor, not a recommendation.
{
"version": 1,
"server": { "name": "origin-1", "data_dir": "/var/lib/ettix-stream" },
"log": { "level": "info", "format": "text" },
"http": {
"listen": ":8080",
"admin_listen": "127.0.0.1:8081",
"cors_origins": ["*"]
},
"rtmp": { "listen": ":1935" },
"hls": { "cache_max_bytes": "512MB" },
"streams": [
{
"id": "channel1",
"name": "Channel 1",
"source": {
"type": "rtmp_push",
"rtmp": {
"app": "live",
"stream_key": "Xg7pQ2vL9taR4ncE",
"replace_policy": "reject"
}
},
"hls": {
"segment_duration": "4s",
"playlist_segments": 6,
"cache_segments": 12,
"program_date_time": true
}
}
]
}
Publish to it
From an encoder, or from ffmpeg for a test. ffmpeg is not required by ETTIX and is not shipped with it; this is only a convenient way to prove the path works.
ffmpeg -re -stream_loop -1 -i testdata/sample-320x240-h264-aac.flv \
-c copy -f flv "rtmp://127.0.0.1:1935/live/channel1?key=Xg7pQ2vL9taR4ncE"
The equivalent OBS settings are Server rtmp://127.0.0.1/live and
Stream Key channel1?key=Xg7pQ2vL9taR4ncE.
Play it back
The playlist appears a few seconds after the first keyframe arrives, because the first segment has to be cut before there is anything to list.
curl -i http://127.0.0.1:8080/hls/channel1/index.m3u8
ffplay http://127.0.0.1:8080/hls/channel1/index.m3u8
A 503 with PLAYLIST_NOT_READY and
Retry-After: 2 means the stream exists but is not live yet. A
404 with STREAM_NOT_FOUND means the id is wrong or the
stream is disabled.
Publisher authentication
The stream key alone is the whole check for a stream that configures nothing
else, and it is compared in constant time. Anything more is configured in the
stream's publisher block, which holds up to four local requirements
plus an optional external callback:
| Field | What it requires |
|---|---|
publisher.users[] |
A username and password. username is plain text;
password_hash is a hash, never a password. Any one
configured account satisfies the requirement. |
publisher.tokens[] |
A bearer token, presented as ?token=. Each entry has a
name (which appears in logs and is not a secret) and a
hash. Any one token satisfies the requirement. |
publisher.ip |
An address allow-list and deny-list (allow,
deny, as CIDRs or bare addresses). Deny is applied first.
The address is the TCP peer's own, which for RTMP is authoritative:
there is no forwarded-for header for a client to forge. |
publisher.require_stream_key |
The stream key from source.rtmp.stream_key, in addition to
whatever else is set. Defaults to true whenever a stream
key is configured. |
publisher.external |
An HTTPS callback to your own service, asked only after every local requirement above has passed. See the Security page. |
The composition is AND, not OR. A block with both users and
tokens requires a valid password and a valid token, not
either one. If you want alternatives, configure one mechanism and let whatever
issues credentials decide which to hand out.
Passwords and tokens are configured as hashes because a configuration file
gets copied, backed up and read by more people than you expect. Both an Argon2id
hash ($argon2id$v=19$…) and a bcrypt hash ($2b$…) are
accepted; bcrypt exists so an existing user database can be migrated without
resetting every password.
There is no dedicated publisher-credential helper in this release.
ettix-stream admin hash --user NAME produces a hash in the accepted
format, so it can be used to generate one — but copy only the
password_hash value out of what it prints. It emits an object shaped
for http.admin_auth.users, which carries a read_only
field that a publisher entry does not have, and unknown fields are rejected by
the configuration loader.
ettix-stream admin hash --user encoder-a
An RTMP URL can carry four credentials, in the places RTMP leaves for them:
| Credential | Where it is read from |
|---|---|
| Stream key | ?key=, or the RTMP password field —
see below |
| Username | user:…@ in the URL, otherwise
?user= |
| Password | …:password@ in the URL, otherwise
?pass= or ?password= |
| Token | ?token= — RTMP has no header to carry a
bearer token in |
One field cannot be two credentials. OBS, ffmpeg and every
librtmp-based tool put the stream key in the RTMP password field, and ETTIX reads
it there — but only for a stream that configures no publisher accounts. As soon
as publisher.users exists, the password field means the account
password, and the stream key must be given as ?key=. Adding a
publisher account to a stream whose encoder relies on the old behaviour will take
that encoder off the air until its URL is changed.
What a refused publisher sees
Nothing useful, deliberately. A wrong key, a wrong password, a disallowed
address, an unknown stream, a disabled stream and a stream that already has a
publisher all produce one generic NetStream.Publish.BadName, and
every refusal is held for roughly 250 ms plus jitter so that a byte comparison
and a password hash cannot be told apart by timing. The real reason goes to your
log as a stable PUB_* code, and that log line is the only place it
exists. When an encoder cannot connect, read the server's log; there is no more
detail to be had at the encoder.
When a second publisher arrives
A stream has one source and one HLS timeline, so
source.rtmp.max_publishers is 1 and may only be set to 1 or 0
(which selects 1). replace_policy does not raise that limit; it
decides which of two connections holds the single slot.
| Setting | Effect |
|---|---|
"replace_policy": "reject" (default) |
The publisher already on air keeps the stream. The new connection is refused. A flapping backup encoder cannot interrupt a good broadcast. |
"replace_policy": "replace" |
The new publisher takes the stream and the previous connection is closed. Use it when the newest connection is by definition the right one — an encoder that reconnects after a network drop while the server still believes the old connection is alive. |
A takeover is not free. The previous source's partial segment is flushed
immediately and the new source starts on a discontinuity, which every player
handles but few handle invisibly. That is why
source.rtmp.min_replace_interval exists: it is the shortest time
between two replacements, default 2s, maximum 1m.
Inside that window replace behaves as reject. Without
it, two encoders configured with the same credentials and both set to reconnect
would take the stream from each other as fast as TCP allows and insert a
discontinuity every time. The first publisher on an idle stream is never a
replacement and is never delayed, which is the case that actually happens day to
day.
"allow_anonymous": true accepts a publisher with no credential at
all. Anyone who can reach port 1935 can then broadcast on that channel. It exists
for isolated test networks and nothing else. It cannot be combined with a stream
key or with publisher credentials — the configuration is refused rather than
guessed at — and its use is recorded in the log as
INGEST_UNAUTHENTICATED.
Ingest — SRT pull
An srt_pull stream works in the opposite direction to RTMP.
ETTIX dials out as an SRT caller, so the far end must be
configured as an SRT listener waiting on a UDP port. Listener-mode
ingest, where a remote encoder pushes SRT into ETTIX, is not part of this
release; if your source can only push, put an SRT-to-RTMP bridge in front of it,
or configure it to listen instead.
Two consequences follow from ETTIX being the caller, and both surprise people:
- The
publisherblock does not apply and is refused on ansrt_pullstream. There is no publisher connecting to this server, so ingest credentials could never be presented. What authenticates ETTIX to the far end isstream_idandpassphrase, insidesource.srt. - The firewall rule you need is outbound UDP to the source, not inbound. The RTMP port is irrelevant to a stream that has no RTMP source.
Sources and priority
source.srt.sources[] lists between one and eight remote
listeners. Each entry is one place the same programme can be obtained from, and
ETTIX uses exactly one of them at a time.
| Field | Default | Meaning |
|---|---|---|
id | required | Names the source in the API and in logs. Same grammar as a stream id,
unique within the stream. Choose something you would want to read in an
alert: primary, backup-fibre. |
host | required | DNS name or IP address of the remote listener. |
port | required | Its UDP port, 1–65535. |
latency | 120ms |
SRT latency, between 20ms and 8s. This is the
buffer SRT uses to retransmit lost packets, so it must be comfortably
larger than the round-trip time to the source. Too small and loss
becomes visible corruption; too large and it is added to every viewer's
delay. |
stream_id | empty | The SRT streamid string sent to the listener, up to 512
bytes. It is what most SRT servers use to select a channel and to
authorise the caller. ETTIX treats it as a secret: it is never logged
and never returned by the API, because it commonly carries a
token. |
passphrase | empty | Enables SRT's own encryption, 10 to 79 bytes. Secret. Without it the feed crosses the network in the clear. |
pbkeylen | 16 when a passphrase is set |
16, 24 or 32 — AES-128, AES-192 or AES-256. It must be 0, or absent, when there is no passphrase; there is no key length for encryption you did not ask for. |
connect_timeout | 5s |
Bounds one connection attempt, between 1s and
60s. |
priority | list position | Lower is preferred, 1 to 1000, and unique within the stream. This is what makes one source the primary and the rest backups. |
Two sources with the same host, port and
stream_id are refused: they are the same endpoint written twice, and
as a redundancy plan they protect against nothing.
Reconnection of a single source is exponential backoff, configured once for
the whole stream in source.srt.reconnect:
delay (default 1s), multiplied by
multiplier (default 2.0) after each failure, capped at
max_delay (default 30s).
Health, failover and warm standby
The source.srt.failover block decides when ETTIX gives up on the
source it is using and which one it moves to.
| Field | Default | Meaning |
|---|---|---|
mode | return_to_primary |
return_to_primary: once a higher-priority source is
healthy again, and has stayed healthy, take it back.
stay: having failed over, remain on the current source
until it too fails. |
unhealthy_after | 5s |
How long a still-connected source may be unhealthy before ETTIX
abandons it, between 1s and 5m. A source
whose connection is lost outright is not given this grace: reconnecting
already costs at least the reconnect delay, so waiting achieves
nothing. |
stability_period | 30s |
How long a source must be continuously healthy before it is
considered trustworthy again, between 1s and
1h. This is the anti-flap clock, and any bad instant
restarts it from zero. |
warm_standby | false |
Keep every source connected at all times, discarding the packets of the ones that are not active. |
min_bitrate_bps | 0 |
A connected source delivering less than this counts as unhealthy. 0 disables the check. Use it where a source can fail by going quiet rather than by disconnecting. |
max_keyframe_interval | 10s |
A video source that has produced no keyframe for this long counts as
unhealthy, between 1s and 5m. Set it above the
encoder's real keyframe interval with room to spare, or a healthy
source will be declared dead. |
A source counts as healthy only while it is connected and all of the
following hold: bytes are arriving, the MPEG-TS program tables have been seen,
media and its decoder configuration are arriving, timestamps are moving forward,
a keyframe has arrived within max_keyframe_interval (for a source
that carries video), the bitrate is at or above min_bitrate_bps, and
demux errors are not pouring in. When a source is unhealthy the API reports which
of those signals is missing, so failover is something you can explain
afterwards.
The choice of a new source is deliberately conservative. ETTIX prefers the
highest-priority source that has been healthy for the whole
stability_period. If there is no such source it will take any
currently healthy one — something beats nothing — and if there is none at all it
stays on the degraded source it has rather than dropping the stream. Every switch
marks a discontinuity in the HLS output.
Warm standby is the difference between a backup that is
already connected and one that has to be dialled when it is needed. With
warm_standby: false (the default) a cold backup must complete a UDP
handshake, fill its SRT latency buffer and wait for program tables and a keyframe
before it can carry the programme — several seconds of dead air on top of the
failover decision itself. With warm_standby: true every source is
connected and demuxed continuously and its packets are thrown away until it is
needed, so a switch is close to instant. The price is real: each standby source
uses the bandwidth of a full feed and roughly the same memory as the active one,
because it holds its own receive buffer and demuxer state. Turn it on for
sources whose failover must be invisible; leave it off for a distant backup you
are willing to wait a few seconds for.
Even with warm standby off, ETTIX is not entirely idle about backups. In
return_to_primary mode it keeps every source of higher priority than
the active one connected, because it cannot know a primary has recovered without
watching it. And while the active source is degraded it starts bringing up
backups in priority order rather than waiting for the failover decision.
Return to primary is the behaviour most operators want and is
the default: a stream that failed over to a backup at 03:00 should be back on its
primary path when someone looks at it in the morning, without anyone touching it.
The cost is a second discontinuity, at a moment nobody chose, when the primary
comes back. Choose stay for a channel where every glitch has to be
accounted for and you would rather move back deliberately — by editing the
configuration and reloading — than automatically.
A complete SRT example
Two sites carrying the same programme. The primary is a fibre path with an SRT passphrase; the backup is over the public internet, so it is given more latency to absorb worse loss. Failover is warm, because this channel must not go dark, and it returns to the primary once that has been solid for a minute.
{
"version": 1,
"server": { "name": "origin-1", "data_dir": "/var/lib/ettix-stream" },
"http": { "listen": ":8080", "admin_listen": "127.0.0.1:8081" },
"hls": { "cache_max_bytes": "512MB" },
"streams": [
{
"id": "channel2",
"name": "Channel 2",
"source": {
"type": "srt_pull",
"srt": {
"sources": [
{
"id": "primary-fibre",
"host": "198.51.100.10",
"port": 9000,
"priority": 1,
"latency": "120ms",
"stream_id": "live/ch2",
"passphrase": "a-long-shared-passphrase",
"pbkeylen": 16
},
{
"id": "backup-internet",
"host": "203.0.113.7",
"port": 9000,
"priority": 2,
"latency": "800ms",
"stream_id": "live/ch2-backup"
}
],
"reconnect": { "delay": "1s", "max_delay": "30s", "multiplier": 2.0 },
"failover": {
"mode": "return_to_primary",
"unhealthy_after": "5s",
"stability_period": "60s",
"warm_standby": true,
"max_keyframe_interval": "10s"
}
}
},
"hls": { "segment_duration": "4s", "playlist_segments": 6 }
}
]
}
The far end must be listening on those ports with the matching passphrase and stream id. There is nothing to publish and nothing to start: as soon as the configuration is loaded, ETTIX begins dialling, and it keeps dialling on the reconnect backoff for as long as the stream is enabled.
Which source is live, why the last switch happened, and what each source is doing are all on the administration listener:
curl -s http://127.0.0.1:8081/api/v1/streams/channel2/sources
SRT ingest is a licensed capability (srt_ingest). On an
installation whose licence does not cover it, the sources are not dialled at all
and the reason is logged. RTMP ingest, HLS playback and the encryption modes are
gated the same way. See the licence status on the administration interface if a
correctly configured source never connects.
Output — HLS
Playback is served from the HTTP listener, http.listen, default
:8080. There are two routes per stream, and their shape never
changes:
GET /hls/<stream-id>/index.m3u8 the live media playlist
GET /hls/<stream-id>/<sequence>.ts one segment
Segment names are the media sequence number zero-padded to at least ten
digits, for example 1757195400123.ts, and only that exact form is
accepted. The sequence does not start at zero: it starts from a
wall-clock-derived value, so a segment name is never reused after a restart.
That is what makes it safe for a CDN or a browser to cache segments for a long
time.
Adding viewer authorisation later adds query parameters and headers to these URLs but never changes the paths, so a CDN configured for a public stream keeps working when the stream is made private.
Segment duration and the playlist window
| Setting | Default | Meaning |
|---|---|---|
streams[].hls.segment_duration | 4s |
The target segment length, 1s to 30s. |
streams[].hls.playlist_segments | 6 |
How many segments the live playlist lists, 3 to 60. Three is the minimum HLS itself permits. |
streams[].hls.cache_segments | 12 |
How many completed segments stay in memory, at least
playlist_segments and at most 600. |
streams[].hls.program_date_time | true |
Write absolute wall-clock times into the playlist. |
segment_duration is a target rather than a promise, because a
segment has to start with a keyframe for a player to be able to join at it. ETTIX
cuts at the first video keyframe at or after the target, so the real length is
decided by your encoder's keyframe interval. If your encoder emits a keyframe
every 2 seconds and you ask for 4-second segments you will get 4-second segments;
if it emits one every 5 seconds you will get 5-second segments regardless of what
you configured. A segment that reaches three times the target, or 64 MiB, is cut
without a keyframe as a last resort — that is a symptom, and the server logs it,
because a segment with no keyframe at the front is one a player cannot start
at.
These two numbers together set the two things viewers feel. Latency is roughly
the playlist window: a player typically starts about three segments back from the
live edge, so 4-second segments put a viewer around 12 seconds behind reality.
Tolerance of a bad network is the same window from the other side: with
playlist_segments: 6 a player has 24 seconds of listed media to
survive a stall before the segment it wanted has left the playlist. Shortening
segments reduces latency and increases request rate and overhead; lengthening
them does the reverse. Four seconds and six segments is a balance that works for
most live channels, and it is the default for that reason.
cache_segments is separate because a segment can usefully outlive
the playlist that listed it: a viewer who fell behind, or a CDN filling a cache
miss, may ask for a segment after it has scrolled out of the window. The default
of 12 keeps roughly twice the playlist window available.
Program date time
With program_date_time enabled — the default — ETTIX writes an
EXT-X-PROGRAM-DATE-TIME tag giving the segment's wall-clock time in
UTC. The tag appears at the head of the playlist window and again after every
discontinuity, which is all a player needs to map the whole timeline to real
time.
This is what lets a player display an absolute clock rather than a position, what lets a viewer be sent to "20:00:00" rather than "142 seconds in", and what lines live playback up with the DVR timeline of the same stream. It costs one short line per playlist. Turn it off only if you have a downstream system that mishandles the tag.
The segment cache
Completed segments live in memory, not on disk. Two limits apply:
streams[].hls.cache_segments per stream, and
hls.cache_max_bytes as one global budget shared by every stream —
default 512MB, minimum 16MB.
When the global budget is exceeded, the oldest segments outside a
live playlist window are evicted first. Segments inside a live window are never
evicted for the budget, because dropping one would break playback for every
viewer of that stream in order to fix a number the operator can see and change.
Instead the cache reports itself as over budget, in the hls_cache
section of the status API:
curl -s http://127.0.0.1:8081/api/v1/status
If hls_cache.over_budget is true, either raise
hls.cache_max_bytes or lower cache_segments on the
streams that are using it. hls_cache.evictions counts how often the
budget has had to take a segment out of the cache.
Sizing is arithmetic: bitrate multiplied by segment duration multiplied by
cache_segments, summed over every stream. A 5 Mbit/s stream with
4-second segments and 12 cached segments needs about 30 MB.
Caching headers
| Response | Cache-Control | Why |
|---|---|---|
| Playlist | no-cache, max-age=0 |
It changes every segment. A cached playlist is a stalled player. |
| Segment | public, max-age=<cache_segments × segment_duration>, immutable |
A segment's bytes never change and its name is never reused, so shared caches may keep it for as long as the origin does. |
| Content key | no-store |
Authorisation is decided per request; a cached key would outlive the decision that released it. |
Browsers reaching the playback listener from another origin need
http.cors_origins to list their origin, or "*". With no
match, no CORS headers are sent at all and the browser refuses the response.
Output — encryption
HLS delivery encryption is configured per stream in
streams[].hls.encryption. It protects the segments in transit and at
rest in every cache between the server and the player: someone who obtains the
segment files still has nothing to watch without the content key.
| Field | Default | Meaning |
|---|---|---|
mode | none |
none, aes-128 or sample-aes. |
key_rotation_interval | 24h |
Rotate the content key on a clock. 24h is also the hard ceiling; the
minimum is 1m. |
key_rotation_segments | 0 |
Also rotate after this many segments. 0 disables the segment rule; the interval still applies. |
key_uri_prefix | empty | Overrides the origin of the key URI written into playlists. Empty means this server's own key endpoint, which is what almost every deployment wants. |
Choosing a mode
aes-128 encrypts each segment whole. It is the
oldest and most widely implemented form of HLS encryption and it plays
everywhere HLS plays. Choose it unless you have a specific reason not to.
sample-aes encrypts only the media samples and
leaves the transport stream itself readable, as Apple's HLS Sample Encryption
specifies. Support is uneven and the unevenness is not ETTIX's to fix: Apple
platforms support it broadly, browsers largely do not, and FFmpeg-based players
show a decode error on the rare slice whose payload needs emulation prevention —
a defect in their decryptor rather than in this output, which is verified against
an independent implementation on every build. Choose sample-aes only
when you know what will be playing the stream, and choose
aes-128 when a stream must play flawlessly everywhere.
The key URL
With encryption enabled the playlist carries an EXT-X-KEY tag
whose URI attribute points at the content key. The key is not in the
playlist; the URI is a request the player has to make, and that request is
authorised. By default the URI is a path on this server:
GET /key/<stream-id>/<key-id>
GET /hls/<stream-id>/key/<key-id> the same key, under the HLS prefix
The key id is 32 hexadecimal characters. The response is 16 raw bytes with
Cache-Control: no-store. Every case that cannot produce a key —
unknown key id, a key belonging to a different stream, a stream that does not
exist, a stream with no encryption — answers identically, so key ids cannot be
probed from outside.
The content key is served only to a request that passes the same authorisation as the playlist and the segments. That is the whole point of the design: the key, the playlist and the segments are gated with one decision, because gating the key alone would leave the media readable to anyone on an unencrypted stream, and gating the playlist alone would leave segment URLs guessable from one leaked playlist.
"Authorised viewers" means whatever your auth block says, and a
stream with no auth block is public. Encryption on a public stream
still protects the segments in caches and on the wire, but the key endpoint will
hand a key to anyone who can reach the playback listener. Encryption and viewer
authorisation are two separate settings and each is doing only its own job;
configure both. The server logs a warning at startup when key delivery is
running with no authorisation policy behind it.
key_uri_prefix exists for deployments that front key delivery
with a CDN or an edge: set it to an absolute path or an http(s)
origin and that is what appears in the playlist. It changes where players ask for
the key and nothing else. Whatever is at the other end still has to enforce
authorisation, and if it does not, you have moved the key out from behind the
check.
Keys on disk
Content keys are stored wrapped by an installation master key, under
encryption.key_dir (<data_dir>/keys by default),
with the master key itself at encryption.master_key_file
(<key_dir>/master.key). The directory must be on the server's
own storage and is created readable only by the service user. The installer, or
ettix-stream init, generates the master key; the server will not
silently create one, because a fresh master key beside existing wrapped keys
would orphan them.
A stream configured to encrypt is never served in the clear because the key provider failed to start: the server treats that as a startup failure rather than falling back.
Back up the master key, and keep the backup somewhere other than the server it protects. Losing it makes every encrypted recording unrecoverable, and no support path can undo that.
ettix-stream keys export --out /secure/backup/ettix-master.key
Delivery encryption is a licensed capability (hls_encryption for
AES-128, sample_aes for SAMPLE-AES). If the licence does not cover
the configured mode, the stream is served in the clear and
HLS_ENCRYPTION_UNLICENSED is logged. The playlist then carries no
EXT-X-KEY tag, so players are not misled into thinking they are
receiving protected content — but nor is the content protected. Check the log
after enabling encryption for the first time.
Delivery encryption and DVR at-rest encryption are independent of each other. Recording stores the cleartext segment under its own at-rest key, so rotating a delivery key never affects the archive. See the DVR page.