Skip to main content

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

KeyTypeDefaultPurpose
sipobjectsee belowSIP listeners and identity.
mediaobjectsee belowCodecs and RTP.
mohobjectGlobal music-on-hold classes.
defaultTrunkstringTrunk used when no route matches. The number is sent unchanged.
timezonestringLocalIANA timezone for time conditions, unless a tenant or condition overrides it.
allowInterTenantboolfalseCeiling for inter-tenant calling.
dataDirpathThe writable tree. See The data directory.
cdrobjectoffCall logs.
recordingobjectoffCall recording.
storageobjectObject storage for recordings.
voicemailobjectoffVoicemail.
smtpobjectThe relay voicemail notifications go through.
ttsobjectPrompt generation.
apiobjectoffManagement API.
securityobjectoffThe security guard.
webhooksobjectDelivery defaults for Webhook endpoints.
livekitobjectThe 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
KeyTypeDefaultNotes
timeoutdurationBounds one delivery attempt, for endpoints that set none.
maxAttemptAgedurationGive up on a delivery that has failed this long.
concurrencyintIn-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
KeyTypeRequiredNotes
urlstringyesThe LiveKit deployment.
apiKeystringyes
apiSecretstringnoInline. Setting this and apiSecretFile is refused.
apiSecretFilepathnoRead at startup.
sipHoststringyesWhere the bridge reaches this PBX.
sipPortintnoMust be in range.
sipTransportstringnoudp or tcp. tls is refused by name.
transportIdstringnoPins the SIP leg to a listener from sip.transports.
authobjectnousername/password the bridge authenticates with.
matchIpslistnoExtra source addresses for the bridge.
roomPrefixstringnoPrefix for generated room names.
outboundTrunkIdstringnoLiveKit 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
KeyTypeDefaultNotes
bindHoststring0.0.0.0Address to listen on.
bindPortint5060Port to listen on.
transportslist[udp]See below.
externalHoststringAddress advertised in SIP when the PBX is behind NAT.
userAgentstringlyno-pbxProduct token in User-Agent and Server headers. Setting it empty removes both.
contactUserstringuserAgentUser part of the Contact header the PBX advertises. Also the fallback From display name.
tlsobjectCertificates 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}
KeyTypeDefaultNotes
transportstringudp, tcp, tls or wss. Required.
idstringthe protocol nameNames the listener so a trunk can pin to it with transportId. A second listener of the same protocol becomes tcp-2.
bindHoststringsip.bindHost
bindPortintsip.bindPort, or 5061 for tls and 443 for wss
srtpstringoffoff, 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 work

Validation 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
KeyTypeDefaultNotes
codecslist[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.
rtpPortMinintlibrary defaultMust be set together with rtpPortMax.
rtpPortMaxintlibrary defaultMust be greater than rtpPortMin.
externalIpstringAddress advertised in SDP when the PBX is behind NAT.
savpProfilestringalways or mirror. Validated and never applied.

moh

moh:
default: ../sounds/moh/default.wav
classes:
jazz: ../sounds/moh/jazz.wav
KeyTypeNotes
defaultpathThe class named default.
classesmapClass 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}
KeyTypeDefaultNotes
hoststringRequired. Hostname or IP.
tenantstringPins every inbound call on this trunk to one tenant. Must exist.
portint5060
transportstringudpudp or tcp.
transportIdstringPins outgoing calls to one named listener from sip.transports. Must exist.
auth.usernamestring
auth.passwordstring
registration.enabledboolfalseRequires auth.
registration.expiryduration5mThe re-registration period, not a retry delay.
registration.contactUserstringUser part of the Contact URI.
inbound.matchIpslistExtra source addresses, added to what DNS discovers.
inbound.autoIpsbooltrueResolve host (A/AAAA + SIP SRV) to discover source addresses, refreshed every 5 minutes.
callerId.numberstringFallback when the tenant has none.
callerId.namestring

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"}
KeyTypeRequiredNotes
priorityintnoAscending; ties break on metadata.name. A catch-all belongs last.
didstringyesThe number, or * as a global catch-all.
tenantstringyesThe tenant that handles the call.
destination.typestringnoUse extension or ring_group. See below.
destination.targetstringnoExtension number or ring group name.

Without a destination, only the tenant is reassigned and that tenant's own inbound routes decide the rest.

Only two destination types are delivered

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
KeyTypeRequiredNotes
priorityintnoAscending; first match wins, so this is load-bearing.
patternstringyesGo regular expression. Must compile. Not anchored implicitly.
stripintnoDigits removed from the front, applied before prepend.
prependstringnoText put in front of what remains.
trunkstringyesMust 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
KeyTypeDefaultNotes
enabledboolfalseNeeds dataDir. No per-tenant override exists.
retentionduration0Zero keeps records forever. Applied by a 24-hour janitor.

See Call logs.

recording

recording:
enabled: true
mode: all
storage: local
keepLocal: true
maxPending: 500
KeyTypeDefaultNotes
enabledboolfalseNeeds dataDir.
modestringnonenone, inbound, outbound, internal or all.
storagestringlocallocal or s3. s3 requires a storage.s3 block.
keepLocalbooltrueKeep the local copy after a confirmed upload.
maxPendingint0 (no cap)Stop recording past this many queued uploads. Only meaningful with storage: s3.
retentionduration0Validated and never applied — no janitor exists.
beepboolfalseValidated 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"
KeyTypeDefaultNotes
s3.bucketstringRequired when the block is present.
s3.regionstringRequired unless endpoint is set.
s3.endpointstringFor a store that is not AWS: MinIO, R2 (region auto), Wasabi.
s3.pathStyleboolfalsetrue for MinIO and most self-hosted stores.
s3.accessKeyIdstringEmpty falls through to the environment, credentials file or instance role.
s3.secretAccessKeystringMust be set together with accessKeyId, or neither.
s3.sessionTokenstring
s3.keyPrefixstring{tenant}/recordings/{yyyy}/{mm}/{dd}/{callid}.wavTokens {tenant} {callid} {ext} {kind} {yyyy} {mm} {dd} {HH} {MM}, case-sensitive. An unknown token is a validation error.
s3.ssestringEmpty, AES256, or aws:kms with kmsKeyId.
s3.kmsKeyIdstringRequired for sse: aws:kms.
s3.storageClassstringOmitted from the request when empty.
s3.timeoutduration
s3.maxRetryAgedurationValidated 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
KeyTypeDefaultNotes
enabledboolfalseNeeds dataDir.
maxMessageduration3mOverridable per mailbox.
minMessageduration0Anything shorter is discarded, not stored. Must not exceed maxMessage.
silenceTimeoutduration0 (off)Stops the recording after continuous silence.
retentionduration0Zero keeps messages forever. Applied by a 24-hour janitor.
promptsDirpathThe spoken fragments the retrieval menu assembles.
mwibooltrueValidated 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
KeyTypeDefaultNotes
hoststringSetting it makes from required.
portint587 / 465 / 25 by security
securitystringstarttlsnone, starttls or tls. starttls refuses to continue when the server does not offer it.
usernamestringAuthentication is attempted only when set: PLAIN, falling back to LOGIN.
passwordstring
fromstringAn RFC 5322 address, so Lyno PBX <pbx@example.com> is valid.
timeoutduration30sBounds the whole conversation.
maxAttachduration0 (no limit)A longer message is notified without the recording.
subjectstringbuilt-intext/template.
bodystringbuilt-intext/template.
helostring
insecureSkipVerifyboolfalseAccepts 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
KeyTypeDefaultNotes
providerstringOnly elevenlabs is accepted, and nothing dispatches on it.
apiKeystringThe worst of the three sources — configs/ is usually in version control.
apiKeyFilepathThe file's contents.
voicestringDefault voice, overridden by the manifest and -voice.
modelstringeleven_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"
KeyTypeDefaultNotes
enabledboolfalseA disabled block is not validated at all.
bindHoststring127.0.0.1Anything not loopback requires api.tls.
bindPortintnoneZero binds an ephemeral port the OS picks. Always set one.
tlsobjectSame shape as sip.tls. This one is actually used.
rateLimit.requestsPerMinutefloat300
rateLimit.burstfloat60Ignored unless requestsPerMinute is set too.
cors.allowedOriginslistExact strings, no wildcard. Empty disables CORS entirely.
recordings.presignboolfalseTurning it on makes every recording download fail.
recordings.presignTtlduration15mInert, for the same reason.
tokenslistAt least one is required when enabled.

api.tokens[]

KeyTypeRequiredNotes
namestringyesUnique. This is what appears in the log.
tokenstringone ofPlaintext. Refused below 32 characters.
hashstringone ofsha256: plus 64 hex characters. Preferred.
scopeslistyesstatus:read, cdr:read, recordings:read, voicemail:read, voicemail:delete, security:read, operate.
tenantslistnoEmpty means every tenant. Each must exist.
expiresstringnoYYYY-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"]
KeyTypeDefaultEnforcedNotes
enabledboolfalseyesEverything below already has a default.
packetRatefloat50yesSustained packets per second, per source.
packetBurstfloat200yesRaised to packetRate if lower.
authFailuresint5yesWithin authWindow, then a ban.
authWindowduration10myes
banDurationduration10myesDoubles per repeat offence. Must not exceed banMax.
banMaxduration24hyes
maxTrackedint20000yesThe tracker fails open when full.
maxDatagramint0 (off)yesUDP only. Not validated.
trustLoopbackbooltrueyes
allowlistyesPrefixes or bare addresses. Also exempts from banning and throttling.
denylistyesDeny wins over allow.
acl.register.{allow,deny}listnoValidated, never read.
acl.invite.{allow,deny}listnoValidated, never read.
requireSrtpboolfalsenoValidated, never read.
limits.maxConcurrentCallsintyesReserved once the tenant is known.
limits.maxConcurrentPerAccountintyesFor an endpoint, only after it authenticated.
limits.maxOutboundPerMinutefloatyes
limits.blockedPrefixeslistyesMatched as dialled and after strip/prepend.
limits.outboundMinutesPerHourintpartlyCounted in memory; restarts at zero.
limits.outboundMinutesPerMonthintpartlyCounted 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; rtpPortMaxrtpPortMin; savpProfile that is not always or mirror.
  • sip.transports: unsupported transport; an srtp mode that is not off, sdes or dtls; a tls/wss listener or srtp: dtls with no certificate in sip.tls; two listeners binding the same address; a duplicate id.
  • sip.tls: a certificate missing certFile or keyFile; a minVersion that is not 1.2 or 1.3.
  • trunks: missing name or host; duplicate name; unknown tenant; registration.enabled without auth; unknown transportId.
  • outbound_routes: pattern that does not compile; unknown trunk.
  • dataDir: absent while call logs, recordings or voicemail are enabled. The directory itself is never stat'ed — -validate has to pass in CI and against a read-only mount.
  • recording: an invalid mode or storage; a negative retention or maxPending; storage: s3 with no storage.s3 block.
  • storage.s3: missing bucket; neither region nor endpoint; an unknown keyPrefix token, a leading / or a ..; an invalid sse, or aws:kms without kmsKeyId; one of accessKeyId/secretAccessKey without the other.
  • voicemail: negative durations; minMessage longer than maxMessage; a promptsDir that is not a readable directory.
  • smtp: an invalid security; a from that is not an address; a host with no from; a subject or body that is not a valid template.
  • tts: an unknown provider.
  • api (only when enabled): no tokens; a non-loopback bindHost without api.tls; api.tls with no certificate pair; a bindPort out of range; a token with no name, a duplicate name, both or neither of token/hash, a malformed hash, a plaintext token under 32 characters, a shared secret, no scopes, an unknown scope, an unknown tenant, or an unparsable expires.
  • security: negative rates, authFailures or maxTracked; banDuration longer than banMax; a malformed prefix in allow, deny or any acl list; negative limits; an empty blockedPrefixes entry.
  • Across tenants: a domain claimed twice; a DID claimed twice.
  • Per tenant: see the tenant reference.