Skip to main content

Documents and paths

Configuration is a set of documents

Every document says what it is, so where it lives carries no meaning. The whole system fits in one file, and it could equally be a file per tenant or a tree something generates.

configVersion: lyno/v1 # checked per document, not per file
kind: Account # what this document is
metadata:
name: alice # its identity
tenant: acme # its namespace, for the kinds that have one
spec:
password: s3cret
extension: "100"

Documents in one file are separated by ---:

configs/lyno.yaml
configVersion: lyno/v1
kind: System
spec:
sip: { bindHost: 0.0.0.0, bindPort: 5060, transports: [udp] }
timezone: Europe/Amsterdam
---
configVersion: lyno/v1
kind: Tenant
metadata:
name: acme
spec:
domains: [acme.pbx.example.com]
---
configVersion: lyno/v1
kind: Account
metadata:
name: alice
tenant: acme
spec:
password: s3cret
extension: "100"

Point -config at that file, or at a directory, which is walked recursively for *.yaml and *.yml. A directory holding no such file is an error rather than an empty configuration.

This replaced a directory layout

A tenant used to be a directory holding a tenant.yaml, and a dialplan a file beneath it. That meant the same text meant different things depending on where it was filed, and it forced a layout on anyone generating configuration. If you are looking at older examples with tenants/acme/extensions/200.yaml in them, they describe a format this build no longer reads.

The kinds

KindScopeWhat it is
SystemglobalThe one singleton: listeners, media, data, security.
TrunkglobalA carrier. A tenant: in its spec reserves it for one.
InboundOverrideglobalRedirects a number regardless of who claims it.
TenantglobalA customer's identity: domains, DIDs, caller ID.
AccounttenantA SIP endpoint and its extension number.
ExtensiontenantA dialplan: ordered steps.
RingGrouptenantExtensions that ring together.
QueuetenantCallers wait here until an agent is free.
IVRMenutenantA keypad menu, shared by that tenant's dialplans.
MailboxtenantVoicemail, keyed by the extension it belongs to.
InboundRoutetenantWhere one of this tenant's DIDs is delivered.
OutboundRouteeitherWith a tenant, tried first; without one, the fallback.

An unknown kind is refused at startup with the accepted list, so -validate is the authority on what your build takes.

Identity and namespace

metadata.name is the resource's identity: a trunk's name, an account's SIP username, a mailbox's extension, a dialplan's number. System is the exception — it is a singleton and needs no name.

A tenant-scoped kind also needs metadata.tenant. A queue called support in one tenant has nothing to do with one of the same name in another, exactly as extension 100 does not.

OutboundRoute is the single kind that may omit it. Without a tenant it is the global fallback, tried when a tenant's own routes match nothing — so leaving the field off is a meaningful choice rather than an omission.

Trunk keeps its tenant as a spec field instead, because a global trunk reserved for a tenant is not a trunk that lives inside one.

Order lives in the data

InboundRoute, OutboundRoute and InboundOverride carry a priority, ascending, with ties broken on metadata.name:

kind: OutboundRoute
metadata: { name: emergency }
spec:
priority: 10 # lower is tried first
pattern: ^112$
trunk: carrier

Outbound routes are first-match-wins, so precedence has to live somewhere. If reading order decided it, precedence would depend on document order within a file and file order within a directory — the exact dependency on layout this format removes.

Saving renumbers priorities as 10, 20, 30…. The order survives a round trip; your own integers do not.

The path rule

Every relative path resolves against one root: the directory -config names, or that path itself when it is a directory. Never against the document that declares the path.

So with -config configs/lyno.yaml, a shared sounds/ folder at the repository root is ../sounds/… from every document, wherever it is filed:

kind: System
spec:
moh:
default: ../sounds/moh/default.wav # configs/ → sounds/
---
kind: Extension
metadata: { name: "200", tenant: acme }
spec:
steps:
- type: play
file: ../sounds/welcome.wav # the same prefix, from any document

Resolving against the declaring document instead would give this syntax the old semantics, where the same text meant different things depending on where it was filed.

Absolute paths are left untouched and are the safest option when the layout is not under your control.

Absolute in memory, relative on disk

Loading makes every path absolute; saving converts it back to a path relative to the configuration root. This matters if you generate configuration with your own tooling: keep paths relative, exactly as lyno -configure writes them. A relative -config once produced cwd-relative paths that were written back verbatim and re-prefixed on the next load, doubling the directory each time — which is why a test now saves and loads twice.

dataDir is the exception

dataDir — global and per tenant — is the one path written back exactly as you typed it. Making /var/lib/lyno relative to the configuration file would turn it into ../../../var/lib/lyno, which breaks the moment that file moves.

A relative dataDir still resolves against the configuration root when it is read. It is simply not rewritten on save.

Strict decoding

A misspelt key is a startup error, not a setting that quietly does nothing. That holds for the document bodies too: a spec is re-encoded and run back through the strict decoder, because YAML's own node decoding does not inherit that setting and would silently accept a typo.

Keys are camelCase throughout: bindHost, callerId, ringTimeout, dataDir.

Documents are rewritten, not edited

lyno -configure regenerates the YAML from the parsed configuration.

  • Comments are not preserved, and priorities are renumbered.
  • Each document is written back to the file it was read from, so a layout you chose survives a save. A file that ends up holding nothing is removed.
  • Files are written atomically with mode 0600, because they contain SIP passwords.

If you keep configuration in version control and maintain comments by hand, edit by hand too and use the editor only for exploration.

Validation

./bin/lyno -config configs/lyno.yaml -validate

Validation is a whole-tree check that collects every problem instead of stopping at the first. It covers, among others:

  • an unknown kind, a missing configVersion, a duplicate resource, a tenant-scoped document with no metadata.tenant;
  • unsupported codecs or transports;
  • an RTP port range where only one bound is set, or where max ≤ min;
  • trunks without a name or host, duplicate trunk names, a trunk naming an unknown tenant, and registration.enabled without auth;
  • outbound routes with a pattern that is not a valid regular expression, or naming an unknown trunk;
  • domains claimed by more than one tenant, and DIDs claimed by more than one tenant;
  • dialplan steps referencing an unknown ring group, extension, queue, menu, mailbox or file, and goto/ivr jumps that form a loop;
  • a mailbox whose name is not an account extension of its tenant;
  • time conditions with an invalid day, hour range, date or timezone;
  • dataDir missing while call logs, recordings or voicemail are enabled;
  • API tokens that are guessable, duplicated, scopeless or expired-by-format, and a management interface bound off-host without TLS.

The full list is in the global and tenant references.

Saving from the editor runs the same validation first, so the editor cannot write a file the PBX would refuse to load.

Valid is not the same as effective

Validation checks that a document is well-formed and internally consistent. It does not tell you whether the PBX acts on a setting, and several settings it accepts are not read at run time.