Skip to content

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 (CreateChannel in the playback domain rejects UDP-mode origins on cproxy-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/install was documented as live ("mirrors the streamer routes exactly") but is commented out in routes.go. UninstallCache itself 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: SIDs_id, CIDc_id. Only trailing ID is special-cased (e.g. UserIDuser_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 }] }
Could only modify URLs on existing keys. New keys required SSH + yml edit + restart.

New flow (v1.1.5+):

GET /api/config → extract origins array
modify target key's originUrls
PUT /api/config/origins → full array replace
Preserves all fields (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-bkkelb_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.goSetup() 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.