System and global documents
System is the one singleton: what every tenant shares. Trunk,
InboundOverride and a tenant-less OutboundRoute are the other kinds that
carry no metadata.tenant.
Where these documents live carries no meaning — see Documents and paths. Every relative path resolves against the configuration root, and keys are camelCase throughout.
configVersion: lyno/v1
kind: System
spec:
sip: {...}
media: {...}
moh: {...}
defaultTrunk: carrier
timezone: Europe/Amsterdam
allowInterTenant: false
dataDir: /var/lib/lyno
cdr: {...}
recording: {...}
storage: {...}
voicemail: {...}
smtp: {...}
tts: {...}
api: {...}
security: {...}
webhooks: {...}
System takes no metadata — it is a singleton, so it needs no name. A second
System document is a duplicate and is refused.
Top level
| Key | Type | Default | Purpose |
|---|---|---|---|
sip | object | see below | SIP listeners and identity. |
media | object | see below | Codecs and RTP. |
moh | object | — | Global music-on-hold classes. |
defaultTrunk | string | — | Trunk used when no route matches. The number is sent unchanged. |
timezone | string | Local | IANA timezone for time conditions, unless a tenant or condition overrides it. |
allowInterTenant | bool | false | Ceiling for inter-tenant calling. |
dataDir | path | — | The writable tree. See The data directory. |
cdr | object | off | Call logs. |
recording | object | off | Call recording. |
storage | object | — | Object storage for recordings. |
voicemail | object | off | Voicemail. |
smtp | object | — | The relay voicemail notifications go through. |
tts | object | — | Prompt generation. |
api | object | off | Management API. |
security | object | off | The security guard. |
webhooks | object | — | Delivery defaults for Webhook endpoints. |
livekit | object | — | The LiveKit deployment mobile apps join. A tenant override replaces it. |
Carrier trunks, DID redirects and fallback outbound routes are no longer keys here: each is its own document, covered below.
webhooks
Global delivery defaults. There is no per-tenant override — how hard the PBX tries to deliver is a property of the process, not of the tenant being notified.
configVersion: lyno/v1
kind: System
spec:
webhooks:
timeout: 10s
maxAttemptAge: 72h
concurrency: 8
| Key | Type | Default | Notes |
|---|---|---|---|
timeout | duration | — | Bounds one delivery attempt, for endpoints that set none. |
maxAttemptAge | duration | — | Give up on a delivery that has failed this long. |
concurrency | int | — | In-flight deliveries across all tenants. Above 64 is refused: a webhook flood must not starve the process carrying calls. |
The endpoints themselves are Webhook documents,
namespaced by tenant.
livekit
The deployment mobile apps join. Present on System and overridable by a
tenant; both are pointers, so an override replaces the whole block rather
than merging field by field — "said nothing" has to stay distinguishable from
"switched off". No block means the PBX talks to no LiveKit deployment.
configVersion: lyno/v1
kind: System
spec:
livekit:
url: https://lyno.livekit.cloud
apiKey: APIxxxxxxxx
apiSecretFile: /etc/lyno/secrets/livekit
sipHost: sip.lyno.example.com
sipPort: 5060
sipTransport: udp
roomPrefix: lyno-
outboundTrunkId: ST_xxxxxxxx
| Key | Type | Required | Notes |
|---|---|---|---|
url | string | yes | The LiveKit deployment. |
apiKey | string | yes | |
apiSecret | string | no | Inline. Setting this and apiSecretFile is refused. |
apiSecretFile | path | no | Read at startup. |
sipHost | string | yes | Where the bridge reaches this PBX. |
sipPort | int | no | Must be in range. |
sipTransport | string | no | udp or tcp. tls is refused by name. |
transportId | string | no | Pins the SIP leg to a listener from sip.transports. |
auth | object | no | username/password the bridge authenticates with. |
matchIps | list | no | Extra source addresses for the bridge. |
roomPrefix | string | no | Prefix for generated room names. |
outboundTrunkId | string | no | LiveKit outbound trunk used to call back into the PBX. |
LYNO_LIVEKIT_API_SECRET wins over apiSecretFile, which wins over
apiSecret. All three empty is valid — the environment may supply it at
runtime.
See Mobile apps for how the pieces fit together.
sip
sip:
bindHost: 0.0.0.0
bindPort: 5060
transports: [udp]
externalHost: 203.0.113.10
userAgent: lyno-pbx
contactUser: lyno-pbx
| Key | Type | Default | Notes |
|---|---|---|---|
bindHost | string | 0.0.0.0 | Address to listen on. |
bindPort | int | 5060 | Port to listen on. |
transports | list | [udp] | See below. |
externalHost | string | — | Address advertised in SIP when the PBX is behind NAT. |
userAgent | string | lyno-pbx | Product token in User-Agent and Server headers. Setting it empty removes both. |
contactUser | string | userAgent | User part of the Contact header the PBX advertises. Also the fallback From display name. |
tls | object | — | Certificates for a tls or wss listener. Not applied — see below. |
userAgent and contactUser are separate settings because the SIP library
builds the Contact from its own name field. Feeding them separately is the
only way to keep a product name out of the Contact while still controlling the
headers.
transports[]
A listener is either a bare protocol name or a mapping:
sip:
transports:
- udp
- {id: phones, transport: tcp, bindPort: 5060}
| Key | Type | Default | Notes |
|---|---|---|---|
transport | string | — | udp, tcp, tls or wss. Required. |
id | string | the protocol name | Names the listener so a trunk can pin to it with transportId. A second listener of the same protocol becomes tcp-2. |
bindHost | string | sip.bindHost | |
bindPort | int | sip.bindPort, or 5061 for tls and 443 for wss | |
srtp | string | off | off, sdes or dtls. Not applied — see below. |
A listener written as a bare string is written back as a bare string, so the editor round-trips a file nobody changed.
tls, wss and srtp do not workValidation demands a certificate in sip.tls for a tls or wss listener, and
the certificate is then never handed to the SIP stack: the listener is built with
no TLS configuration and fails to bind. srtp: on a transport and
media.savpProfile are accepted, reported in the -validate summary, and never
applied — every call is plain RTP.
Use udp or tcp. Details on the security page.
sip.tls
Validated, and only actually used by api.tls. Listed for completeness:
certFile, keyFile, certificates[] (certFile + keyFile pairs
selected by SNI), clientCaFile (mutual TLS, all or nothing), minVersion
(1.2 or 1.3, default 1.2) and reloadEvery (default one minute).
media
media:
codecs: [alaw, ulaw]
rtpPortMin: 10000
rtpPortMax: 20000
externalIp: 203.0.113.10
| Key | Type | Default | Notes |
|---|---|---|---|
codecs | list | [alaw, ulaw] | Only alaw and ulaw are supported. Offered identically on both legs — there is no transcoding. RFC 2833 telephone-event is always added, since every keypad feature depends on it. |
rtpPortMin | int | library default | Must be set together with rtpPortMax. |
rtpPortMax | int | library default | Must be greater than rtpPortMin. |
externalIp | string | — | Address advertised in SDP when the PBX is behind NAT. |
savpProfile | string | — | always or mirror. Validated and never applied. |
moh
moh:
default: ../sounds/moh/default.wav
classes:
jazz: ../sounds/moh/jazz.wav
| Key | Type | Notes |
|---|---|---|
default | path | The class named default. |
classes | map | Class name → WAV path. Tenants may add to and override this map. |
All files must be 8 kHz mono 16-bit PCM WAV.
Trunk
A carrier. metadata.name is the trunk name that routes and defaultTrunk
refer to.
configVersion: lyno/v1
kind: Trunk
metadata:
name: carrier
spec:
tenant: acme
host: sip.provider.example
port: 5060
transport: udp
auth: {username: "...", password: "..."}
registration: {enabled: true, expiry: 5m, contactUser: "..."}
inbound: {matchIps: ["198.51.100.7"], autoIps: true}
callerId: {number: "+31201234567", name: Acme}
| Key | Type | Default | Notes |
|---|---|---|---|
host | string | — | Required. Hostname or IP. |
tenant | string | — | Pins every inbound call on this trunk to one tenant. Must exist. |
port | int | 5060 | |
transport | string | udp | udp or tcp. |
transportId | string | — | Pins outgoing calls to one named listener from sip.transports. Must exist. |
auth.username | string | — | |
auth.password | string | — | |
registration.enabled | bool | false | Requires auth. |
registration.expiry | duration | 5m | The re-registration period, not a retry delay. |
registration.contactUser | string | — | User part of the Contact URI. |
inbound.matchIps | list | — | Extra source addresses, added to what DNS discovers. |
inbound.autoIps | bool | true | Resolve host (A/AAAA + SIP SRV) to discover source addresses, refreshed every 5 minutes. |
callerId.number | string | — | Fallback when the tenant has none. |
callerId.name | string | — |
tenant is a spec field rather than metadata.tenant, because a global trunk
reserved for a tenant is not a trunk that lives inside one.
See Trunks for the behaviour behind these keys.
InboundOverride
Redirects a number regardless of which tenant claims it.
configVersion: lyno/v1
kind: InboundOverride
metadata:
name: acme-main
spec:
priority: 10
did: "+31201234567"
tenant: globex
destination: {type: extension, target: "900"}
| Key | Type | Required | Notes |
|---|---|---|---|
priority | int | no | Ascending; ties break on metadata.name. A catch-all belongs last. |
did | string | yes | The number, or * as a global catch-all. |
tenant | string | yes | The tenant that handles the call. |
destination.type | string | no | Use extension or ring_group. See below. |
destination.target | string | no | Extension number or ring group name. |
Without a destination, only the tenant is reassigned and that tenant's own
inbound routes decide the rest.
Validation accepts all seven types — extension, ring_group, queue, ivr,
voicemail, external and hangup — and checks that the target exists. At run
time only extension and ring_group are delivered; the other five log
invalid destination type and answer the caller 404.
Point the DID at an extension whose plan starts with the step you wanted. See
Inbound routing.
OutboundRoute (fallback)
An OutboundRoute without metadata.tenant is the global fallback, tried
when a tenant's own routes match nothing. Leaving the field off is therefore a
meaningful choice rather than an omission — it is the one kind that may.
configVersion: lyno/v1
kind: OutboundRoute
metadata:
name: national
spec:
priority: 20
pattern: '^0[1-9]\d{8}$'
strip: 1
prepend: "+31"
trunk: carrier
| Key | Type | Required | Notes |
|---|---|---|---|
priority | int | no | Ascending; first match wins, so this is load-bearing. |
pattern | string | yes | Go regular expression. Must compile. Not anchored implicitly. |
strip | int | no | Digits removed from the front, applied before prepend. |
prepend | string | no | Text put in front of what remains. |
trunk | string | yes | Must name an existing Trunk. |
The per-tenant form is documented with the tenant kinds.
See Outbound routing.
dataDir
dataDir: /var/lib/lyno
The writable tree. No default; required as soon as cdr, recording or
voicemail is enabled anywhere, or any tenant declares mailboxes. It is the one
path written back exactly as typed. See
The data directory.
cdr
cdr:
enabled: true
retention: 8760h
| Key | Type | Default | Notes |
|---|---|---|---|
enabled | bool | false | Needs dataDir. No per-tenant override exists. |
retention | duration | 0 | Zero keeps records forever. Applied by a 24-hour janitor. |
See Call logs.
recording
recording:
enabled: true
mode: all
storage: local
keepLocal: true
maxPending: 500
| Key | Type | Default | Notes |
|---|---|---|---|
enabled | bool | false | Needs dataDir. |
mode | string | none | none, inbound, outbound, internal or all. |
storage | string | local | local or s3. s3 requires a storage.s3 block. |
keepLocal | bool | true | Keep the local copy after a confirmed upload. |
maxPending | int | 0 (no cap) | Stop recording past this many queued uploads. Only meaningful with storage: s3. |
retention | duration | 0 | Validated and never applied — no janitor exists. |
beep | bool | false | Validated and never applied — no tone is played. |
Overridable per tenant, but only enabled and mode are honoured there. See
Call recording.
storage
storage:
s3:
bucket: lyno-recordings
region: eu-central-1
pathStyle: false
sse: AES256
keyPrefix: "{tenant}/recordings/{yyyy}/{mm}/{dd}/{callid}.wav"
| Key | Type | Default | Notes |
|---|---|---|---|
s3.bucket | string | — | Required when the block is present. |
s3.region | string | — | Required unless endpoint is set. |
s3.endpoint | string | — | For a store that is not AWS: MinIO, R2 (region auto), Wasabi. |
s3.pathStyle | bool | false | true for MinIO and most self-hosted stores. |
s3.accessKeyId | string | — | Empty falls through to the environment, credentials file or instance role. |
s3.secretAccessKey | string | — | Must be set together with accessKeyId, or neither. |
s3.sessionToken | string | — | |
s3.keyPrefix | string | {tenant}/recordings/{yyyy}/{mm}/{dd}/{callid}.wav | Tokens {tenant} {callid} {ext} {kind} {yyyy} {mm} {dd} {HH} {MM}, case-sensitive. An unknown token is a validation error. |
s3.sse | string | — | Empty, AES256, or aws:kms with kmsKeyId. |
s3.kmsKeyId | string | — | Required for sse: aws:kms. |
s3.storageClass | string | — | Omitted from the request when empty. |
s3.timeout | duration | — | |
s3.maxRetryAge | duration | — | Validated and never applied — the queue uses a fixed seven days. |
voicemail
voicemail:
enabled: true
maxMessage: 3m
minMessage: 2s
silenceTimeout: 8s
retention: 2160h
promptsDir: ../sounds/prompts/en
| Key | Type | Default | Notes |
|---|---|---|---|
enabled | bool | false | Needs dataDir. |
maxMessage | duration | 3m | Overridable per mailbox. |
minMessage | duration | 0 | Anything shorter is discarded, not stored. Must not exceed maxMessage. |
silenceTimeout | duration | 0 (off) | Stops the recording after continuous silence. |
retention | duration | 0 | Zero keeps messages forever. Applied by a 24-hour janitor. |
promptsDir | path | — | The spoken fragments the retrieval menu assembles. |
mwi | bool | true | Validated and never applied — there is no NOTIFY sender. |
See Voicemail.
smtp
smtp:
host: smtp.example.com
port: 587
security: starttls
username: pbx@example.com
password: "change-me"
from: Lyno PBX <pbx@example.com>
maxAttach: 5m
| Key | Type | Default | Notes |
|---|---|---|---|
host | string | — | Setting it makes from required. |
port | int | 587 / 465 / 25 by security | |
security | string | starttls | none, starttls or tls. starttls refuses to continue when the server does not offer it. |
username | string | — | Authentication is attempted only when set: PLAIN, falling back to LOGIN. |
password | string | — | |
from | string | — | An RFC 5322 address, so Lyno PBX <pbx@example.com> is valid. |
timeout | duration | 30s | Bounds the whole conversation. |
maxAttach | duration | 0 (no limit) | A longer message is notified without the recording. |
subject | string | built-in | text/template. |
body | string | built-in | text/template. |
helo | string | — | |
insecureSkipVerify | bool | false | Accepts an unverifiable certificate. A footgun outside an internal relay. |
Overridable per tenant, field by field. Template variables are listed under Voicemail.
tts
tts:
provider: elevenlabs
apiKeyFile: /etc/lyno/elevenlabs.key
voice: mwkFMsRX1kc5niMwNsbT
model: eleven_multilingual_v2
| Key | Type | Default | Notes |
|---|---|---|---|
provider | string | — | Only elevenlabs is accepted, and nothing dispatches on it. |
apiKey | string | — | The worst of the three sources — configs/ is usually in version control. |
apiKeyFile | path | — | The file's contents. |
voice | string | — | Default voice, overridden by the manifest and -voice. |
model | string | eleven_multilingual_v2 |
LYNO_ELEVENLABS_API_KEY wins over apiKeyFile, which wins over apiKey.
Only ever used by -tts; the running PBX never calls the API. See
Prompt audio.
api
api:
enabled: true
bindHost: 127.0.0.1
bindPort: 8080
rateLimit: {requestsPerMinute: 120, burst: 20}
cors: {allowedOrigins: []}
recordings: {presign: false}
tokens:
- name: monitoring
hash: "sha256:…"
scopes: [status:read, cdr:read]
tenants: [acme]
expires: "2027-01-01"
| Key | Type | Default | Notes |
|---|---|---|---|
enabled | bool | false | A disabled block is not validated at all. |
bindHost | string | 127.0.0.1 | Anything not loopback requires api.tls. |
bindPort | int | none | Zero binds an ephemeral port the OS picks. Always set one. |
tls | object | — | Same shape as sip.tls. This one is actually used. |
rateLimit.requestsPerMinute | float | 300 | |
rateLimit.burst | float | 60 | Ignored unless requestsPerMinute is set too. |
cors.allowedOrigins | list | — | Exact strings, no wildcard. Empty disables CORS entirely. |
recordings.presign | bool | false | Turning it on makes every recording download fail. |
recordings.presignTtl | duration | 15m | Inert, for the same reason. |
tokens | list | — | At least one is required when enabled. |
api.tokens[]
| Key | Type | Required | Notes |
|---|---|---|---|
name | string | yes | Unique. This is what appears in the log. |
token | string | one of | Plaintext. Refused below 32 characters. |
hash | string | one of | sha256: plus 64 hex characters. Preferred. |
scopes | list | yes | status:read, cdr:read, recordings:read, voicemail:read, voicemail:delete, security:read, operate. |
tenants | list | no | Empty means every tenant. Each must exist. |
expires | string | no | YYYY-MM-DD or RFC 3339. |
Exactly one of token and hash; two tokens may not share a secret. See
Management API.
security
security:
enabled: true
packetRate: 50
packetBurst: 200
authFailures: 5
authWindow: 10m
banDuration: 10m
banMax: 24h
maxTracked: 20000
maxDatagram: 9000
trustLoopback: true
allow: ["203.0.113.0/24"]
deny: ["198.51.100.9"]
| Key | Type | Default | Enforced | Notes |
|---|---|---|---|---|
enabled | bool | false | yes | Everything below already has a default. |
packetRate | float | 50 | yes | Sustained packets per second, per source. |
packetBurst | float | 200 | yes | Raised to packetRate if lower. |
authFailures | int | 5 | yes | Within authWindow, then a ban. |
authWindow | duration | 10m | yes | |
banDuration | duration | 10m | yes | Doubles per repeat offence. Must not exceed banMax. |
banMax | duration | 24h | yes | |
maxTracked | int | 20000 | yes | The tracker fails open when full. |
maxDatagram | int | 0 (off) | yes | UDP only. Not validated. |
trustLoopback | bool | true | yes | |
allow | list | — | yes | Prefixes or bare addresses. Also exempts from banning and throttling. |
deny | list | — | yes | Deny wins over allow. |
acl.register.{allow,deny} | list | — | no | Validated, never read. |
acl.invite.{allow,deny} | list | — | no | Validated, never read. |
requireSrtp | bool | false | no | Validated, never read. |
limits.maxConcurrentCalls | int | — | yes | Reserved once the tenant is known. |
limits.maxConcurrentPerAccount | int | — | yes | For an endpoint, only after it authenticated. |
limits.maxOutboundPerMinute | float | — | yes | |
limits.blockedPrefixes | list | — | yes | Matched as dialled and after strip/prepend. |
limits.outboundMinutesPerHour | int | — | partly | Counted in memory; restarts at zero. |
limits.outboundMinutesPerMonth | int | — | partly | Counted in memory; restarts at zero. |
See The security guard, which explains what the enforced half actually does.
Durations
Duration values are Go duration strings: 20s, 1m30s, 5m, 1h. A bare
number is invalid. There is no day unit, so ninety days is 2160h.
Validation summary
The whole tree is checked at load, and every problem is reported:
media: unsupported codec; only one RTP bound set;rtpPortMax≤rtpPortMin;savpProfilethat is notalwaysormirror.sip.transports: unsupported transport; ansrtpmode that is notoff,sdesordtls; atls/wsslistener orsrtp: dtlswith no certificate insip.tls; two listeners binding the same address; a duplicateid.sip.tls: a certificate missingcertFileorkeyFile; aminVersionthat is not1.2or1.3.trunks: missingnameorhost; duplicate name; unknowntenant;registration.enabledwithoutauth; unknowntransportId.outbound_routes: pattern that does not compile; unknowntrunk.dataDir: absent while call logs, recordings or voicemail are enabled. The directory itself is never stat'ed —-validatehas to pass in CI and against a read-only mount.recording: an invalidmodeorstorage; a negativeretentionormaxPending;storage: s3with nostorage.s3block.storage.s3: missingbucket; neitherregionnorendpoint; an unknownkeyPrefixtoken, a leading/or a..; an invalidsse, oraws:kmswithoutkmsKeyId; one ofaccessKeyId/secretAccessKeywithout the other.voicemail: negative durations;minMessagelonger thanmaxMessage; apromptsDirthat is not a readable directory.smtp: an invalidsecurity; afromthat is not an address; ahostwith nofrom; asubjectorbodythat is not a valid template.tts: an unknownprovider.api(only when enabled): no tokens; a non-loopbackbindHostwithoutapi.tls;api.tlswith no certificate pair; abindPortout of range; a token with no name, a duplicate name, both or neither oftoken/hash, a malformedhash, a plaintext token under 32 characters, a shared secret, no scopes, an unknown scope, an unknown tenant, or an unparsableexpires.security: negative rates,authFailuresormaxTracked;banDurationlonger thanbanMax; a malformed prefix inallow,denyor anyacllist; negative limits; an emptyblockedPrefixesentry.- Across tenants: a domain claimed twice; a DID claimed twice.
- Per tenant: see the tenant reference.