Changelog
2026-08-03
Documentation audit — corrections, no code changes
Cross-checked index.md against the actual handler code and routes.go.
Three corrections made, no runtime behavior changed:
Streamer.Role("ingest"|"cproxy") was missing from the ER diagram and had no narrative section at all, despite being actively enforced (CreateChannelin theplaybackdomain rejects UDP-mode origins oncproxy-role streamers). Added a "Streamer Role" section and the field to the ER diagram.handler_cache_origins.go(~400 lines, 9 handlers — origin-key CRUD- streamer topology queries) was missing from the "Domain Structure" file tree, even though its functionality was already documented narratively elsewhere on the page. Added to the tree.
DELETE /distribution/caches/:id/installwas documented as live ("mirrors the streamer routes exactly") but is commented out inroutes.go.UninstallCacheitself is fully implemented — this is either a forgotten doc update after disabling the route, or the route was disabled by accident. Flagged as a decision item rather than silently re-enabled or silently left wrong.
Also added Cache.PublicHost/SLB.PublicHost to the ER diagram (present
on the model, previously undocumented on those two entities — Streamer.PublicHost
was already documented via playout/distribution.md's field list).
2026-07-01
Bug fixes — streamer installer post-generalization
Several bugs surfaced after the BinaryProfile refactor, all fixed in this session.
Missing OS dependencies in StreamerProfile
StreamerProfile.OSDependencies only contained "epel-release" — the full
package list (libunwind, libicu, zeromq, harfbuzz, fribidi, fontconfig,
freetype, etc.) was left behind as a comment stub when the old hardcoded
streamer-specific Install() was replaced by the generic profile-driven version.
The installer would complete without error (dnf installed only epel-release and
returned 0) but the streamer binary would fail to start with
error while loading shared libraries: libunwind.so.8.
Fix: restore the full package list to StreamerProfile.OSDependencies.
epel-release must be installed before the rest
Some packages (zeromq in particular) are only available via EPEL repos. Installing
them in a single dnf install call alongside epel-release fails because dnf
resolves the full package list before installing anything — EPEL repos aren't
registered yet when it tries to resolve zeromq.
Fix: split installing_dependencies into two sequential dnf install calls — first
epel-release alone, then the remaining packages.
Double-nesting of lib/ subdirectories during extraction
The generic moveLibsCmd loop in install.go did:
mkdir -p /castis/bin/streamer/lib/common && mv "$SRC"/lib/common /castis/bin/streamer/lib/common
mv with a pre-existing destination directory moves the source into it rather
than renaming it, producing lib/common/common/ instead of lib/common/. The .so
files were one level deeper than ldconfig expected, so ldconfig -p | grep libsrt
returned nothing even though the files were on disk.
Fix: only pre-create the parent lib/ directory, not the subdirs — let mv rename
them cleanly:
mkdir -p /castis/bin/streamer/lib && mv "$SRC"/lib/common /castis/bin/streamer/lib/common
rpm -q pre-check added to skip dnf on already-provisioned nodes
Reinstall was always re-running the full dnf install even when all packages were
already present, causing 30–90s delays waiting on mirror metadata. Added a fast
rpm -q check before running dnf — if all packages are already installed, the
installing_dependencies stage completes instantly with no network traffic.
dnf mirror timeout flags added
Added --setopt=timeout=10 --setopt=minrate=50000 to both dnf calls so slow/bad
mirrors are abandoned after 10s (down from 30s default) rather than blocking the
install for minutes per mirror timeout.
s_id vs sid column name — duplicate column from two migration paths
The channels table ended up with both s_id and sid columns after adding
gorm:"column:sid" to domain/playback/models.go. Root cause: pkg/db/migrate.go
contained a duplicate Channel struct (without the column tag) that ran first via
RunMigrations(), creating s_id. Then RunMigrationMain() ran with the fixed
model, adding sid. Two migration paths, two struct definitions, two columns, inserts
failing with null value in column "s_id".
Fix: remove Channel, ChannelOrigin, and Streamer from RunMigrations() in
pkg/db/migrate.go and delete their local struct definitions — these are now
exclusively managed by RunMigrationMain() via RegisterModels() using the real
domain model imports. No more duplication, no more drift risk.
ch.sid column reference in raw SQL
GetStreamerChannels used ch.sid in a raw SQL query but the DB column was s_id
(GORM's auto-naming for all-caps SID field). Fixed as part of the column rename
above — now ch.sid matches the actual column name after the migration cleanup.
GORM all-caps field naming behavior documented
GORM's NamingStrategy splits consecutive uppercase letters: SID → s_id,
CID → c_id. Only trailing ID is special-cased (e.g. UserID → user_id).
Standalone acronym fields need explicit gorm:"column:..." tags to control the
output — SID int \gorm:"column:sid"`renders assid, nots_id`.
2026-06-30 (2)
Installer generalized to support cproxy — BinaryProfile introduced
pkg/installer's Install()/Uninstall() are no longer streamer-specific.
A new BinaryProfile struct parameterizes everything binary-specific (vendor
tarball layout, OS dependencies, lib paths, start command), so the same
SSH/SFTP orchestration now drives installation of both Castis Streamer and
cproxy. StreamerProfile and CproxyProfile are the two profiles defined
today; ELB/GSLB are the natural next additions following the same pattern.
New routes (cache/cproxy side, mirroring the existing streamer routes):
- POST /caches/:id/install — start install (async, 202)
- GET /caches/:id/install/status — poll current stage/status
- DELETE /caches/:id/install?purge=true|false — uninstall
New Cache fields: installStatus, installStage, installError,
installStartedAt — same shape as Streamer's, reusing the existing
package-level InstallStatus type/consts (no new enum).
BinaryProfile shape:
type BinaryProfile struct {
Name string
VendorDirName string
BinaryInTar string
InstallDir string
LibSubdirs []string // nil → configuring_libs stage is skipped entirely
OSDependencies []string
ConfigFilename string
TarballFilename string
TemplateFilename string
StartEnv map[string]string // nil → no env vars on start command
}
Install() conditionally skips two stages based on the profile: uploading_media
(streamer-only — cproxy has no bundled sample asset) and configuring_libs
(skipped whenever len(profile.LibSubdirs) == 0 — cproxy's tarball has no
lib/ directory at all, confirmed via tar -tzvf, so there's nothing to
ldconfig).
Uninstall() signature changed — now takes a profile BinaryProfile as a
third argument so it knows the correct binary path/install directory to stop
and (optionally) purge. Both UninstallStreamer and the new UninstallCache
call it with their respective profile.
Dependency install fix: the generic installing_dependencies stage was
initially missing --allowerasing on the dnf install command (present in
the original streamer-specific version, dropped during generalization).
Without it, installing cproxy's dependency list failed against a pre-existing
curl-minimal package already present on the test node (problem with
installed package curl...) — dnf refuses to swap a conflicting package
without explicit permission. Fixed by adding --allowerasing back to the
shared depsCmd construction, which is harmless for streamer's dependency
list too (only matters when an actual conflict exists).
Test fixture renamed: the blank test node's compose service key changed
from streamer_x to node_x to reflect that it now hosts both a streamer
and a cproxy install simultaneously on different ports — container_name
remains streamer_x for now (cosmetic mismatch, not yet cleaned up).
See pkg/installer for full documentation,
including the BinaryProfile reference and updated stage lists per profile.
2026-06-30
Streamer SSH-push installer added
New pkg/installer package and handler_install.go in the distribution domain.
Provisions a Castis Streamer binary onto a blank Linux node over SSH — no agent,
no pre-existing software required on the target beyond SSH access.
New routes:
- POST /streamers/:id/install — start install (async, returns 202 immediately)
- GET /streamers/:id/install/status — poll current stage/status
- DELETE /streamers/:id/install?purge=true|false — uninstall, optionally wiping data
New Streamer fields: installStatus, installStage, installError, installStartedAt.
No new table — columns added to the existing streamers table via AutoMigrate.
SSH credentials are never persisted. Every install/uninstall call requires
sshHost/sshUser/sshPassword or sshPrivateKey fresh in the request body —
read once for the duration of the call, then discarded.
Install stages: connecting → installing_dependencies → uploading_tarball →
uploading_config → uploading_media → extracting → configuring_libs → starting → installed
Asset layout: INSTALLER_ASSETS_DIR (default /castis/installer-assets,
bind-mounted, sibling to the CoreAPI repo) holds streamer.tar.gz, streamer.yml.tmpl,
kitchen.mp4.
Known issue resolved during build-out: the starting stage originally hung
indefinitely — Session.CombinedOutput() in golang.org/x/crypto/ssh blocks until
all inherited file descriptors close, which a backgrounded nohup ... & disown
process doesn't fully release even with stdin redirected to /dev/null. Fixed by
switching to Session.Start() + a non-blocking Wait() reaper (runCommandNoWait
in pkg/installer/ssh.go), combined with setsid on the remote command for full
session detachment.
Tarball layout note: the vendor tarball extracts to
streamer-latest.el9.x86_64.dir/{bin,lib,doc}/..., with the binary itself named
streamer-latest.el9.x86_64 rather than streamer — the extraction stage now
mirrors the original Dockerfile's mv sequence move-for-move instead of assuming
a generic --strip-components depth.
See pkg/installer for full documentation.
2026-06-26
Cache origin key management added
Two new tables: cache_origin_keys, cache_origin_streamers.
Replaces the manual PUT /caches/:id/origins raw-JSON workflow with a DB-backed
CRUD API. DB is now source of truth for cproxy origins — cproxy config is derived
from DB and pushed on demand.
New routes:
- GET/POST /caches/:id/keys — list/create origin keys
- PUT/DELETE /caches/:id/keys/:keyId — update/delete a key
- POST /caches/:id/keys/:keyId/streamers — assign streamer to a key's origin-urls
- DELETE /caches/:id/keys/:keyId/streamers/:streamerId — remove streamer from key
- POST /caches/:id/keys/sync — push full DB state to cproxy via PUT /api/config/origins
New topology routes:
- GET /streamers/:id/channels — channels hosted on this streamer (queries channel_origins)
- GET /streamers/:id/caches — cproxy origin keys this streamer is assigned to
cproxy boot config: starts with placeholder origin ^/__placeholder__/.*$ that
matches no real traffic. First sync replaces it entirely.
Port note: buildOriginsPayload uses streamer.Host (Docker internal DNS) with
internal port 18080. streamer.HTTPPort is the host-mapped port for browser playback
and should NOT be used for cproxy→streamer internal URLs.
2026-06-24
cproxy origin management upgraded to v1.1.5 API
PutOriginURLs in pkg/CProxyClient rewritten to use PUT /api/config/origins
(cproxy v1.1.5+) instead of PATCH /api/config.
Old flow (v1.1.1):
PATCH /api/config → { origins: [{ key, originUrls }] }
New flow (v1.1.5+):
GET /api/config → extract origins array
modify target key's originUrls
PUT /api/config/origins → full array replace
ttlRules, healthCheck, retryCount, failoverResponses)
that were silently dropped by the old PATCH approach.
Persists to disk automatically — survives container restarts.
Version requirement: cproxy v1.1.5.rc1 or later.
Earlier versions return 404 for PUT /api/config/origins.
2026-06-24
PutOrigins handler added
PUT /caches/:id/origins added to handler_cache.go.
Accepts the full desired origins array and pushes it straight to cproxy via
PUT /api/config/origins. No GET/merge step — caller owns full state.
Use this to add new origin keys, remove keys, or reorder. SwitchOrigin only
modifies URLs on existing keys — it cannot add new keys.
# add a new 'jap' origin key alongside existing food + ads
curl -X PUT http://localhost:3000/api/v1/distribution/caches/1/origins \
-H "Content-Type: application/json" \
-d '[
{"key":"food","urlPattern":"^/food/.*$","urlRewriters":[{"matchPattern":"^/food/(.*)$","replace":"/$1"}],"originUrls":[{"url":"http://streamer_bkk:18080"},{"url":"http://streamer_ntb:18080"}],"memCacheConfigId":"mem-cache","ttl":"1s","ttlRules":[{"extensions":[".ts",".m4s",".mp4",".m4a",".aac"],"ttl":"30s"},{"extensions":[".m3u8",".mpd"],"ttl":"500ms"}],"healthCheck":{"period":"5s","timeout":"1s"},"failoverResponses":["404","5xx"],"retryCount":1,"balancingPolicy":"first-active"},
{"key":"ads","urlPattern":"^/ads/.*$","urlRewriters":[{"matchPattern":"^/ads/(.*)$","replace":"/$1"}],"originUrls":[{"url":"http://streamer_bkk:18080"}],"memCacheConfigId":"mem-cache","ttl":"1s","ttlRules":[{"extensions":[".ts",".m4s",".mp4",".m4a",".aac"],"ttl":"30s"},{"extensions":[".m3u8",".mpd"],"ttl":"500ms"}],"healthCheck":{"period":"5s","timeout":"1s"},"failoverResponses":["404","5xx"],"retryCount":1,"balancingPolicy":"first-active"},
{"key":"jap","urlPattern":"^/jap/.*$","urlRewriters":[{"matchPattern":"^/jap/(.*)$","replace":"/$1"}],"originUrls":[{"url":"http://streamer_bkk:18080"}],"memCacheConfigId":"mem-cache","ttl":"1s","ttlRules":[{"extensions":[".ts",".m4s",".mp4",".m4a",".aac"],"ttl":"30s"},{"extensions":[".m3u8",".mpd"],"ttl":"500ms"}],"healthCheck":{"period":"5s","timeout":"1s"},"failoverResponses":["404","5xx"],"retryCount":1,"balancingPolicy":"first-active"}
]'
PutOrigins function added to pkg/CProxyClient — passes raw JSON array straight
to cproxy with no modification.
Route added: PUT /distribution/caches/:id/origins
2026-06-24
cproxy bind mount workaround
PUT /api/config/origins writes back to cproxy.yml on disk by first renaming
the existing file to a timestamped backup, then writing the new file. Docker bind
mounts block the rename operation (device or resource busy), causing the entire
PUT to roll back with no change applied.
Fix: mount config to a temp path and copy at container startup:
cproxy_bkk_1:
volumes:
- ./cdn/cproxy-bkk-1/cproxy.yml:/tmp/cproxy-init.yml # not directly over target
- ./cdn/cproxy-bkk-1/logs:/data/log/cproxy
- ./cdn/cproxy-bkk-1/filecache:/data/cproxy/filecache
command: bash -c "cp /tmp/cproxy-init.yml /castis/bin/cproxy/cproxy.yml && ./cproxy"
cproxy starts with the host-managed config and can freely rename/backup the file
at runtime. Config changes via PUT /api/config/origins now persist correctly.
2026-06-21
SLB (Castis ELB) node management added
New handler_slb.go in distribution domain. CoreAPI uses the term SLB (Server Load Balancer)
as a vendor-agnostic abstraction — the underlying technology is the Castis ELB.
pkg/ElbClient wraps the ELB API.
Routes added under /api/v1/distribution/slbs/:
| Route | Description |
|---|---|
GET /slbs |
List SLB nodes |
POST /slbs |
Register node |
GET /slbs/:id |
Get by ID |
PUT /slbs/:id |
Update |
DELETE /slbs/:id |
Remove |
POST /slbs/:id/ping |
Health check via GET /api/version |
ALL /slbs/:id/proxy/* |
Passthrough to ELB API |
pkg/ElbClient/ added — ProxyRequest, GetVersion, GetTraffic.
Model: SLB struct in domain/distribution/models.go, table slbs. Fields: name, host,
proxyPort (:8090), apiPort (:8100), status, lastPingedAt.
Seed: pkg/seed/slb.go — seeds elb-bkk → elb_bkk:8100 on startup.
ELB startup fix: image binary lives at /usr/local/castis/GSLB/elb-1.0.3.rc1.x86_64,
not /castis/bin/elb/. Config mount and command updated in cdn.yml:
volumes:
- ./cdn/elb-bkk/elb.yml:/usr/local/castis/GSLB/elb.yml:ro
command: bash -c "cd /usr/local/castis/GSLB && chmod +x elb-1.0.3.rc1.x86_64 && ./elb-1.0.3.rc1.x86_64"
2026-06-21
Colorbars broadcaster added
New handler_colorbars.go in distribution domain. The colorbars container is a Go Fiber
app that manages ffmpeg subprocesses — each one broadcasts SMPTE colorbars + 1kHz sine
tone as MPEG-TS over UDP multicast to playtelly_cdn_net.
Routes added under /api/v1/distribution/colorbars/:
| Route | Description |
|---|---|
GET /status |
List all streams and running state |
POST /streams |
Create stream (port-unique enforced) |
PATCH /streams/:id |
Update config — hot-restarts ffmpeg if running |
POST /streams/:id/start |
Spawn ffmpeg subprocess |
POST /streams/:id/stop |
Kill ffmpeg subprocess |
DELETE /streams/:id |
Stop + remove from memory |
ALL /proxy/* |
Passthrough to colorbars Fiber API on :9999 |
pkg/ColorbarClient/ added — mirrors StreamerClient and CProxyClient pattern.
Port uniqueness constraint discovered: Castis Streamer binds on port only when joining
a multicast group. Two streams on the same port — even different IPs — cause the streamer
to receive mixed data. createStream now returns 409 if the port is already in use.
compose/infrastructure/cdn.yml: colorbars now on both internal + cdn_net
compose/infrastructure/networks-volumes.yml: cdn_net bridge added (172.28.0.0/16)
2026-06-21
UDP multicast probe added
ProbeUDP handler added to handler_streamer.go.
Creates a temporary .stream file on the target streamer node, polls
GET /api/streams/:probeId every second for up to 6 seconds, then cleans up.
Returns { "detected": true } if status reaches Running, false on timeout.
curl -X POST http://localhost:3000/api/v1/distribution/streamers/5/probe \
-H "Content-Type: application/json" \
-d '{"udp":"239.0.0.8","port":4444}'
Route added: POST /distribution/streamers/:id/probe
2026-06-21
Route prefix changed to /distribution
All distribution routes moved from /api/v1/streamers and /api/v1/caches
to /api/v1/distribution/streamers and /api/v1/distribution/caches.
wire.go — Setup() now mounts under r.Group("/distribution") instead of directly on v1.
Old routes (deprecated):
GET /api/v1/streamers
GET /api/v1/caches
New routes:
GET /api/v1/distribution/streamers
GET /api/v1/distribution/caches
2026-06-21
Cache (cproxy) handlers added
handler_cache.go added alongside handler_streamer.go. Covers the cproxy edge
cache layer in the CDN stack.
| Route | Description |
|---|---|
GET /caches |
List cproxy nodes |
POST /caches |
Register node |
PUT /caches/:id |
Update |
DELETE /caches/:id |
Remove |
POST /caches/:id/ping |
Health check via /api/version (cproxy has no /api/ping) |
POST /caches/:id/origins/:key/switch |
Patch origin URLs + purge stale cache |
ALL /caches/:id/proxy/* |
Passthrough to cproxy API |
pkg/CProxyClient/ added — PatchOriginURLs, PurgeOrigin, PurgeContent, GetTraffic.
2026-06-20
DeleteStreamer added (guarded)
DeleteStreamer handler added. Route commented out in routes.go pending
an origin safety check — deleting a streamer node while channels still reference
it leaves orphaned ChannelOrigin records. Will be enabled once the check is in place.
2026-06-20
config.go added
Environment variables centralized into domain/distribution/config.go.
ConfigFromEnv() reads COLORBARS_HOST (default: colorbars) and
COLORBARS_PORT (default: 9999). Handler no longer calls os.Getenv directly.
2026-06-19
Initial distribution domain
Extracted from legacy domain/service/ flat package.
Nested package structure established:
domain/distribution/
├── config.go
├── models.go — Streamer, Cache structs, status consts
├── helper.go — ApplyNodeFilters()
├── validation.go — ParseID()
├── handlers/
│ ├── handler.go
│ └── handler_streamer.go
└── routes/
├── wire.go
└── routes.go
pkg/StreamerClient/ added — CreateStream, DeleteStream, GetStreams,
GetTraffic, PlaybackURL, BuildPayload, ProxyRequest.
Streamer proxy passthrough: ALL /streamers/:id/proxy/* forwards verbatim to
the streamer's API port. Used for traffic inspection and direct stream management
without adding wrapper endpoints for every Castis API operation.
Seeding: pkg/seed/streamer.go seeds local compose nodes (streamer-bkk,
streamer-ntb) and staging nodes on startup. Existing records skipped by name.