Architecture
Lyno is a single Go binary. The packages layer strictly: configuration is a schema with no behaviour, the decision packages are pure, and only the lowest layer touches SIP and media.
cmd/lyno flags, logging, signals, TUI wiring, prompt generation
internal/config YAML schema (global + per tenant), loading, validation
internal/tenant tenant registry: lookup by domain, per-tenant routing
internal/pbx composition root and lifecycle
internal/registrar server-side REGISTER: digest auth + contact store
internal/trunk trunk registration and inbound identification
internal/routing DID→tenant assignment, inbound map, outbound patterns
internal/dialplan step registry, compiled plans, time conditions
internal/call call classification, forking, bridging, playback, transfer
internal/queue call queues: waiting callers, agents, four strategies
internal/voicemail mailboxes, messages and the retrieval state machine
internal/feature runtime do-not-disturb and forwarding state
internal/recording stereo capture, metadata sidecar, hand-off to storage
internal/cdr the call log: one record per call plus a per-day index
internal/storage local disk and S3 back ends, and the upload queue
internal/data the writable tree, confined to one root per tenant
internal/mail SMTP delivery of voicemail notifications
internal/sbc source-address guard, rate limits and fraud ceilings
internal/api HTTP management interface: routes, token auth, scopes
internal/webhook outbound event delivery: signing, the durable queue
internal/mobile device registry, call grants, the app's extra call leg
internal/livekit control-plane client: join tokens, rooms, SIP participants
internal/tlsx certificate loading with reload
internal/tts ElevenLabs client used to generate prompt audio
internal/audio WAV validation, in-memory cache, trimming and levelling
internal/event event bus decoupling the core from the TUI
internal/sipua User-Agent and Server identity headers
internal/siplog sipgo's logger, and the per-message read trace
internal/tui Bubble Tea dashboard and configuration editor
The layering is config → tenant/routing/trunk → call/dialplan →
pbx. The call package takes a DialplanRunner interface rather than
importing dialplan, purely to break an import cycle.
The packages that own written state — data, cdr, recording, voicemail,
queue, storage, mail, sbc — know nothing about the configuration schema.
The translation happens where they are wired up, in pbx.
Two planes
Configuration is read once and never written back by the running PBX. Everything the PBX produces goes to the data directory instead. That split is why the shipped container mounts its configuration read-only and needs a second, writable volume.
internal/data is what enforces it: every root is opened with openat and every
path resolved inside it, so nothing written can escape — including a hostile SIP
Call-ID, which is always a field in a file and never a path element.
Everything is compiled at startup
Configuration is read once. Validation runs across the whole tree and collects every problem rather than stopping at the first, then dialplans are compiled: every step is built and every time condition parsed before the first call arrives. Sound files are validated and cached in memory at the same point, so a bad WAV fails at startup instead of mid-call.
The consequence is that there is no hot reload. Restart to apply a change.
There is one deliberate exception: SIGHUP reloads the
management API's tokens and nothing else, because the
alternative — restarting to rotate a token — means nobody ever rotates one.
The event bus
The core never talks to the terminal. State changes are published on an event
bus that the dashboard consumes, which is why -headless is a complete swap of
the presentation layer rather than a flag threaded through the call path.
The bus reports state changes only; the dashboard turns them into history and counters. That is deliberate — without it, a failed call vanished the moment it ended, which made the dashboard useless for finding out what just happened.
Upstream libraries
Lyno pins sipgo and diago, both pre-1.0. A few of their behaviours are
surprising enough to be worth knowing when reading logs or traces:
- sipgo never sends
User-AgentorServerheaders.internal/sipuaadds them everywhere Lyno builds a message itself. Messages diago builds internally — 100/180/200 on an inbound INVITE, in-dialog BYE/ACK/CANCEL — still carry none, because there is no hook for them. - diago has no registrar. It only does client-side REGISTER, which is why
internal/registrarexists at all. RegisterOptions.RetryIntervalis the re-REGISTER period, not a retry delay. Lyno's own failure backoff lives in the trunk register loop instead.- An unreachable registration host can panic inside the library. Trunk registration is wrapped so one bad trunk cannot take the process down, and the host is resolved first so the common case (a typo in a hostname) reports a readable cause.
- The bridge does not transcode. Both legs must agree on a codec, so the same list is offered on both.
- sipgo's logger is a package-level variable, so it has to be set once at
startup rather than in
pbx.New— two PBXs in one process (which the tests do) would otherwise write it while the first one is reading it. Without that wiring everything sipgo logs goes to stderr at info level, which in dashboard mode means straight over the dashboard. - A transport read filter sees bytes, not messages. On a stream transport it
is handed whatever the socket read returned, which is why
the SIP trace marks a chunk with no blank
line yet as
partialinstead of reporting a truncated message. sipgo also answers keep-alives itself and passes the bare CRLF through the filter, so the trace skips empty payloads.
Security posture
- Trunk source addresses are the authorisation for inbound calls. Nothing else gates them, so the firewall in front of the PBX matters.
- Endpoint calls always authenticate, in the realm of the tenant domain.
- The guard rate-limits and bans source addresses before their packets are parsed. The fraud ceilings are configured and not enforced, and neither is SIP TLS — read that page before relying on either.
- Configuration files are written mode
0600because they contain SIP passwords. Data files are0600and data directories0700, because a recording is a conversation. - The management API is off by default, binds loopback, and refuses a wider bind address without TLS.
- The container image is distroless and runs as uid 65532, with no shell and no package manager.