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 ---:
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.
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
| Kind | Scope | What it is |
|---|---|---|
System | global | The one singleton: listeners, media, data, security. |
Trunk | global | A carrier. A tenant: in its spec reserves it for one. |
InboundOverride | global | Redirects a number regardless of who claims it. |
Tenant | global | A customer's identity: domains, DIDs, caller ID. |
Account | tenant | A SIP endpoint and its extension number. |
Extension | tenant | A dialplan: ordered steps. |
RingGroup | tenant | Extensions that ring together. |
Queue | tenant | Callers wait here until an agent is free. |
IVRMenu | tenant | A keypad menu, shared by that tenant's dialplans. |
Mailbox | tenant | Voicemail, keyed by the extension it belongs to. |
InboundRoute | tenant | Where one of this tenant's DIDs is delivered. |
OutboundRoute | either | With 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.
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 missingconfigVersion, a duplicate resource, a tenant-scoped document with nometadata.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.enabledwithoutauth; - 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/ivrjumps 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;
dataDirmissing 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.
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.