Skip to content

Distribution Domain

Manages the CDN infrastructure layer — Castis Streamer nodes, cproxy cache nodes, SLB (Castis ELB) nodes, and the colorbars UDP broadcaster. Provides registration, health checking, proxy passthrough, UDP stream probing, colorbars stream lifecycle management, runtime cproxy origin switching, and SSH-push installation of streamer and cproxy binaries onto blank nodes.

Handles:

  • Streamer node registration and health (ping, status)
  • Streamer proxy passthrough — forward any request to a streamer's API
  • UDP multicast probe — detect whether a stream is live on a given group:port
  • Streamer installation — SSH-push provisioning onto a blank Linux node
  • Cache (cproxy) node registration and health (ping, status)
  • Cache origin switching — update URLs on existing key (SwitchOrigin)
  • Cache origins replace — add/remove/reorder keys (PutOrigins)
  • Cache proxy passthrough
  • cproxy installation — SSH-push provisioning, same path as streamer via BinaryProfile
  • SLB (Castis ELB) node registration and health (ping, status)
  • SLB proxy passthrough
  • Colorbars broadcaster — manage ffmpeg UDP streams via hosted Fiber controller
  • Streamer node deletion (guarded — pending origin safety check)
  • GSLB node management (planned)
  • ELB installation via SSH push (planned — natural next BinaryProfile, see pkg/installer)

Domain Structure

domain/distribution/
├── config.go              package distribution  — Config struct + ConfigFromEnv()
│                                                   reads: COLORBARS_HOST, COLORBARS_PORT,
│                                                   INSTALLER_ASSETS_DIR
├── models.go              package distribution  — Streamer, Cache, SLB structs + status consts
│                                                   Streamer AND Cache both carry InstallStatus,
│                                                   InstallStage, InstallError, InstallStartedAt
│                                                   (shared InstallStatus type, no duplicate enum)
├── helper.go               package distribution  — ApplyNodeFilters()
├── validation.go          package distribution  — ParseID()
│
├── handlers/
│   ├── handler.go         package handlers      — Handler struct + NewHandler(db, cfg)
│   │                                              colorbarNode() helper
│   ├── handler_streamer.go package handlers     — GetStreamers, GetStreamerByID,
│   │                                              CreateStreamer, UpdateStreamer,
│   │                                              DeleteStreamer, PingStreamer,
│   │                                              ProxyStreamerRequest, ProbeUDP
│   ├── handler_install.go package handlers      — InstallStreamer, GetInstallStatus,
│   │                                              UninstallStreamer, InstallCache,
│   │                                              GetCacheInstallStatus, UninstallCache
│   ├── handler_cache.go   package handlers      — GetCaches, GetCacheByID,
│   │                                              CreateCache, UpdateCache, DeleteCache,
│   │                                              PingCache, SwitchOrigin, PutOrigins,
│   │                                              ProxyCacheRequest
│   ├── handler_slb.go     package handlers      — GetSLBs, GetSLBByID,
│   │                                              CreateSLB, UpdateSLB, DeleteSLB,
│   │                                              PingSLB, ProxySLBRequest
│   ├── handler_cache_origins.go package handlers — ListOriginKeys, CreateOriginKey,
│   │                                              UpdateOriginKey, DeleteOriginKey,
│   │                                              AddStreamerToKey, RemoveStreamerFromKey,
│   │                                              SyncOriginKeys, GetStreamerChannels,
│   │                                              GetStreamerCaches
│   └── handler_colorbars.go package handlers   — GetColorbarStatus, CreateColorbarStream,
│                                                  UpdateColorbarStream, StartColorbarStream,
│                                                  StopColorbarStream, DeleteColorbarStream,
│                                                  ProxyColorbarRequest
│
└── routes/
    ├── wire.go            package routes        — Setup() — only entry point main.go calls
    └── routes.go          package routes        — registerRoutes() — internal

pkg/
├── StreamerClient/        — Castis Streamer HTTP client (see below)
├── CProxyClient/          — cproxy HTTP client (models.go + client.go)
│     ProxyRequest, PutOriginURLs, PutOrigins, PurgeOrigin, PurgeContent, GetTraffic
├── ElbClient/             — Castis ELB HTTP client (used by handler_slb.go)
├── ColorbarClient/        — colorbars Fiber API client
└── installer/             — SSH-push binary provisioning, profile-driven (see pkg/installer/index.md)
      types.go      — InstallRequest + BinaryProfile + StreamerProfile/CproxyProfile
      debug.go, ssh.go, template.go, install.go, uninstall.go

Data Model

erDiagram
    Streamer {
        int id
        string name
        string host
        string publicHost "browser-accessible hostname, distinct from host"
        int httpPort
        int apiPort
        bool hasGpu
        string role "ingest | cproxy — see Streamer Role below"
        string status
        datetime lastPingedAt
        string installStatus
        string installStage
        string installError
        datetime installStartedAt
    }

    Cache {
        int id
        string name
        string host
        string publicHost
        int httpPort
        int apiPort
        string status
        datetime lastPingedAt
        string installStatus
        string installStage
        string installError
        datetime installStartedAt
    }

    SLB {
        int id
        string name
        string host
        string publicHost
        int proxyPort
        int apiPort
        string status
        datetime lastPingedAt
    }

    Channel {
        int id
        string name
    }

    ChannelOrigin {
        int channelId
        int streamerId
        string ingestUrl
        string streamId
        string playbackUrl
    }
    Streamer ||--o{ ChannelOrigin : "hosts"
    Channel  ||--o{ ChannelOrigin : "has"


    CacheOriginKey {
        int id
        int cacheId
        string originKey
        string urlPattern
        string urlRewriteMatch
        string urlRewriteReplace
        string ttl
        int priority
    }

    CacheOriginStreamer {
        int id
        int originKeyId
        int streamerId
        int priority
    }
    Cache    ||--o{ CacheOriginKey      : "has keys"
    CacheOriginKey ||--o{ CacheOriginStreamer : "has streamers"
    Streamer ||--o{ CacheOriginStreamer  : "assigned to"

Streamer Role

Streamer.Role is "ingest" (default) or "cproxy":

  • ingest — a full origin-streamer, capable of UDP/multicast contribution ingest.
  • cproxy — cache/shield only, no live contribution ingest capability.

This is enforced, not just descriptive: playback's CreateChannel handler rejects a UDP-mode ChannelOrigin whose target streamer has Role: "cproxy" ("streamer %q has role %q, udp ingest requires role %q"), per-origin, silently recorded in that origin's failures[] entry rather than as an upfront validation error. Registering a streamer node with the wrong role is a common cause of "channel created but this origin failed" when the streamer/host itself is reachable and healthy.

Colorbars streams are not persisted — they live in the colorbars container's memory and reset on restart.

Install credentials (sshHost, sshUser, sshPassword/sshPrivateKey) are never persisted — they exist only in the request body for the duration of a single install/uninstall call, for both Streamer and Cache installs alike.


Architecture

CoreAPI (Fiber)
  │
  ├── /distribution/streamers   → Castis Streamer nodes (DB-backed)
  │     └── /probe              → UDP multicast detection
  │     └── /install            → SSH-push provisioning (StreamerProfile)
  │     └── /proxy/*            → passthrough to Streamer API
  │
  ├── /distribution/caches      → cproxy nodes (DB-backed)
  │     └── /origins            → full origins array replace (PUT)
  │     └── /origins/:key/switch → hot-switch URLs on existing key (POST)
  │     └── /install            → SSH-push provisioning (CproxyProfile)
  │     └── /proxy/*            → passthrough to cproxy API
  │
  ├── /distribution/slbs        → Castis ELB nodes (DB-backed)
  │     └── /proxy/*            → passthrough to ELB API
  │
  └── /distribution/colorbars   → colorbars container (stateless proxy)
        └── /streams            → ffmpeg UDP stream lifecycle
        └── /proxy/*            → passthrough to colorbars Fiber API

playtelly_internal (Docker bridge)
  ├── CoreAPI          → streamer_bkk:18081, streamer_ntb:18081
  ├── CoreAPI          → cproxy_bkk_1:8081
  ├── CoreAPI          → elb_bkk:8100
  ├── CoreAPI          → colorbars:9999
  └── CoreAPI          → (SSH) any registered node's :22 — installer only,
                          same path regardless of which BinaryProfile is used

playtelly_cdn_net (Docker bridge — 172.28.0.0/16)
  ├── colorbars        → sends UDP multicast (e.g. udp://239.0.0.8:4444)
  ├── streamer_bkk     → joins multicast group, ingests stream
  └── streamer_ntb     → joins multicast group, ingests stream

StreamerClient Package

pkg/StreamerClient — the HTTP client distribution handlers use to talk to a Castis Streamer node's API (http://{host}:{apiPort}/api/...). Purely transport — no SSH, no installation logic. Distinct from pkg/installer, which provisions the binary onto a node before this client ever has anything to talk to.

Files: client.go, models.go

Types (models.go):

type StreamerNode struct {
    Host     string
    APIPort  int
    HTTPPort int
}

type StreamerResponse struct {
    StatusCode int
    Body       json.RawMessage
}

Functions (client.go):

Function Description
BuildStreamID(name string) string Slugifies a channel name into a Castis stream ID — lowercases, replaces spaces with underscores, appends .stream. e.g. "BBC World""bbc_world.stream"
CreateStream(streamer, streamID, config) (*StreamerResponse, error) POST /api/streams/{streamID} with a raw JSON config body
DeleteStream(streamer, streamID) (*StreamerResponse, error) DELETE /api/streams/{streamID}
GetStreams(streamer) (*StreamerResponse, error) GET /api/streams — list all streams on the node
GetTraffic(streamer) (*StreamerResponse, error) GET /api/traffic
PlaybackURL(streamer, streamID) string Builds the public playback URL, e.g. http://host:httpPort/{streamID}/manifest.mpd
BuildPayload(ingestURL, ingestConfig, transcodeConfig) (json.RawMessage, error) Merges an ingest URL and transcoder config into a single stream-creation payload — unmarshals ingestConfig, overwrites url, attaches transcoders from transcodeConfig, re-marshals
ProxyRequest(streamer, method, path string, body []byte) (*StreamerResponse, error) Forwards any HTTP request verbatim to the node. Used as the implementation behind ALL /streamers/:id/proxy/*, and internally by ProbeUDP for status polling

Internal helpers: doGet(url), readResponse(resp) — the latter logs the raw response body, and wraps it as json.RawMessage if valid JSON, or as a JSON-encoded string if the streamer returned something non-JSON (e.g. an HTML error page) so the response shape stays consistent regardless of what the upstream node actually returns.

Client config: package-level http.Client with a 10s timeout — no per-call timeout override currently exposed.

Logging: every CreateStream and ProxyRequest call logs the outgoing method/URL and the response status/body via the standard log package — useful for tracing what CoreAPI actually sent to a streamer node, but verbose; no log-level gating currently exists in this package.


Installation — Streamer and cproxy

SSH-push provisioning of a Castis binary onto a blank Linux node — no agent, credentials supplied fresh per request, never persisted. A single profile-driven Install()/Uninstall() in pkg/installer handles both Streamer and cproxy, parameterized by a BinaryProfile (StreamerProfile / CproxyProfile). See pkg/installer for the full mechanics (stage breakdown per profile, the BinaryProfile shape, credential handling, asset layout, known limitations).

Routes — streamer:

POST   /distribution/streamers/:id/install           start install (async, 202)
GET    /distribution/streamers/:id/install/status     poll current stage/status
DELETE /distribution/streamers/:id/install             uninstall (?purge=true|false)

Routes — cproxy:

POST   /distribution/caches/:id/install               start install (async, 202)
GET    /distribution/caches/:id/install/status         poll current stage/status

Cache uninstall is implemented but not routed

UninstallCache in handler_install.go is fully implemented — identical shape to UninstallStreamer, nothing broken about it — but DELETE /distribution/caches/:id/install is commented out in routes.go with no explanatory comment. Previous versions of this doc claimed this route "mirrors the streamer routes exactly" and was live; it currently is not. Needs a decision: re-enable the route, or confirm it's intentionally disabled and note why.

curl -X POST http://localhost:3000/api/v1/distribution/streamers/7/install \
  -H "Content-Type: application/json" \
  -d '{"sshHost":"node_x","sshPort":22,"sshUser":"deploy","sshPassword":"deploy123","httpPort":18080,"apiPort":18081}'

curl -X POST http://localhost:3000/api/v1/distribution/caches/2/install \
  -H "Content-Type: application/json" \
  -d '{"sshHost":"node_x","sshPort":22,"sshUser":"deploy","sshPassword":"deploy123","httpPort":8080,"apiPort":8081}'

installStatus lifecycle (identical for both row types): not_installedpendinginstallinginstalled | failed. Reinstall reuses the same endpoint — the flow is idempotent (dependency install no-ops on existing packages; the starting stage stops any already-running process before relaunching).

Stage sequence differs slightly per profileStreamerProfile includes uploading_media and configuring_libs (it has bundled sample media and three lib subdirectories needing ldconfig); CproxyProfile skips both, since its tarball has no lib/ directory and no sample asset is involved. Both are profile-driven gates in the shared Install(), not separate code paths.


cproxy Origin Management

cproxy origins define how incoming paths are routed to streamer nodes. Two endpoints handle different levels of control:

Cache Origin Key Management

Origin keys are named routing slots in cproxy — e.g. food, ads, live-ch1. Each key has its own urlPattern, optional URL rewriter, TTL, and an ordered list of streamers in its origin-urls.

DB is the source of truth. cproxy runtime config is derived from DB and pushed via sync.

Flow: 1. Create key → POST /caches/:id/keys 2. Assign streamers → POST /caches/:id/keys/:keyId/streamers 3. Sync to cproxy → POST /caches/:id/keys/sync

Topology queries: - GET /streamers/:id/channels — channels hosted on this streamer - GET /streamers/:id/caches — cproxy keys this streamer feeds into

SwitchOrigin — update URLs on an existing key

POST /caches/:id/origins/:originKey/switch
Body: { "urls": ["http://streamer_bkk:18080"] }

GET current config → find key → replace originUrls → PUT full array back. Preserves all other fields. Fails if the key doesn't exist. Purges stale cache automatically after switch.

PutOrigins — full replace (add/remove/reorder keys)

PUT /caches/:id/origins
Body: [ ...full origins array... ]

Passes the array straight to PUT /api/config/origins on cproxy. Caller owns the full desired state — GET current origins first if you need to preserve existing keys.

# get current origins to use as base
curl -s http://localhost:3000/api/v1/distribution/caches/1/proxy/api/config | jq .origins

# push modified array (e.g. add 'jap' key)
curl -X PUT http://localhost:3000/api/v1/distribution/caches/1/origins \
  -H "Content-Type: application/json" \
  -d '[...]'

cproxy bind mount workaround

PUT /api/config/origins backs up cproxy.yml before writing. Docker bind mounts block the rename (device or resource busy) causing the entire operation to fail.

Fix: mount config to a temp path, copy at startup:

volumes:
  - ./cdn/cproxy-bkk-1/cproxy.yml:/tmp/cproxy-init.yml
command: bash -c "cp /tmp/cproxy-init.yml /castis/bin/cproxy/cproxy.yml && ./cproxy"

Version requirement: PUT /api/config/origins requires cproxy v1.1.5.rc1+.


SLB — Server Load Balancer

CoreAPI uses the term SLB (Server Load Balancer) as a vendor-agnostic abstraction. The underlying technology is the Castis ELB (Edge Load Balancer)pkg/ElbClient wraps its API.

The ELB sits between clients and cproxy nodes, distributing traffic using round-robin or consistent hashing. It monitors backend node health via WebSocket traffic streams.

client → elb_bkk:8090 → cproxy_bkk_1:8080 → streamer_bkk:18080

Ports: - :8090 — proxy port (client-facing) - :8100 — API port (management, used by pkg/ElbClient)

Health check: GET /api/version via pkg/ElbClient.GetVersion() — ELB has no /api/ping.

Seeded node: elb-bkkelb_bkk:8100 registered on startup via pkg/seed/slb.go.

Note: ELB installation via SSH push is not yet built, but would follow the exact same pattern — a third BinaryProfile (ElbProfile) plugged into the existing Install()/Uninstall(), no new orchestration code needed.


Colorbars — UDP Multicast Broadcaster

The colorbars container is a Go Fiber application that manages ffmpeg subprocesses. Each subprocess broadcasts SMPTE colorbars + 1kHz sine tone as MPEG-TS over UDP multicast.

Why multicast: A single ffmpeg send reaches all streamer nodes simultaneously — no per-streamer connection needed. Each streamer joins the multicast group address and receives the same stream.

Port constraint: Each stream must use a unique port. Two streams on the same port — even different multicast IPs — will interfere because the Castis streamer binds on port only when joining a group.

Stream lifecycle:

Create (POST /colorbars/streams)
  → idle in memory, no ffmpeg process

Start (POST /colorbars/streams/:id/start)
  → spawns ffmpeg subprocess
  → ffmpeg sends to udp://239.x.x.x:PORT?pkt_size=1316&localaddr=0.0.0.0&ttl=5

Stop (POST /colorbars/streams/:id/stop)
  → kills ffmpeg process, stream stays in memory

Delete (DELETE /colorbars/streams/:id)
  → stops ffmpeg if running, removes from memory

Patch (PATCH /colorbars/streams/:id)
  → updates config
  → if running: kills and restarts ffmpeg with new config (hot-restart)

Direct access (dev): The colorbars UI is served at http://localhost:9999 — lets you create, start, stop, and delete streams without going through CoreAPI.


UDP Probe

The probe endpoint lets CoreAPI verify whether a UDP multicast stream is actually flowing on a given group:port, by asking a streamer node to attempt ingestion.

sequenceDiagram
    participant Client
    participant CoreAPI
    participant Streamer

    Client->>CoreAPI: POST /distribution/streamers/:id/probe
    Note over Client,CoreAPI: body: { "udp": "239.0.0.8", "port": 4444 }

    CoreAPI->>Streamer: POST /api/streams/probe_239_0_0_8_4444.stream
    Note over CoreAPI,Streamer: {"url": "udp://239.0.0.8:4444"}

    loop every 1s for up to 6s
        CoreAPI->>Streamer: GET /api/streams/probe_239_0_0_8_4444.stream
        Streamer-->>CoreAPI: { "status": "Running" | "Waiting" }
    end

    CoreAPI->>Streamer: DELETE /api/streams/probe_239_0_0_8_4444.stream
    CoreAPI-->>Client: { "detected": true | false }

Running = packets are flowing. Waiting = socket open but no data arrived within 6s.


Environment Variables

Variable Default Description
COLORBARS_HOST colorbars Docker service name or IP of the colorbars container
COLORBARS_PORT 9999 Fiber API port on the colorbars container
INSTALLER_ASSETS_DIR /castis/installer-assets Directory containing streamer.tar.gz, streamer.yml.tmpl, kitchen.mp4, cproxy.tar.gz, cproxy.yml.tmpl — bind-mounted, sibling to the CoreAPI repo on the host

Seeding

All distribution nodes are seeded on startup. Existing records (matched by name) are skipped — safe to run on every boot.

pkg/seed/streamer.go  — Streamer nodes (local compose + staging)
pkg/seed/cache.go     — Cache nodes (cproxy)
pkg/seed/slb.go       — SLB nodes (Castis ELB)

Local compose nodes:

// streamers
{ Name: "streamer-bkk", Host: "streamer_bkk", HTTPPort: 18080, APIPort: 18081 }
{ Name: "streamer-ntb", Host: "streamer_ntb", HTTPPort: 18080, APIPort: 18081 }

// caches
{ Name: "cproxy-bkk-1", Host: "cproxy_bkk_1", HTTPPort: 8080, APIPort: 8081 }

// slbs
{ Name: "elb-bkk", Host: "elb_bkk", ProxyPort: 8090, APIPort: 8100 }

Installation-test nodes (node_x, container name streamer_x) are not seeded — deliberately blank Rocky9 containers with nothing registered until install is run against them. A single fixture node now commonly hosts both a streamer and a cproxy install simultaneously, on different port pairs, registered manually for testing.


Changelog

See changelog.md


API Routes