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
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
internal/audio WAV validation and in-memory cache
internal/event event bus decoupling the core from the TUI
internal/sipua User-Agent and Server identity headers
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.
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.
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.
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.
- Configuration files are written mode
0600because they contain SIP passwords. - The container image is distroless and runs as uid 65532, with no shell and no package manager.