Quick start
Ten minutes, two softphones, one folder of YAML. No trunk, no carrier contract — the outside world can wait until the last step.
By the end you will have:
- a PBX running on your machine, with a live dashboard;
- two phones registered as extensions 100 and 101;
- calls between them, running through a real dialplan;
- a follow-me extension that hunts for someone to answer;
- (optional) a carrier trunk for real inbound and outbound calls.
You'll need
| Thing | Why |
|---|---|
| Go, or Docker | To build or run the binary. |
| Two softphones | Zoiper, Linphone, Groundwire… the second one can be the app on your phone. |
| A terminal | The dashboard and the editor both live there. |
Build it
git clone https://github.com/telqo/lyno.git
cd lyno
make build # produces ./bin/lyno
Prefer not to build? Use the container image instead — see Docker, then come back here for the configuration.
Describe your phone system
The smallest useful system is one tenant with two phones. It is a set of
documents, and they all fit in one file — kind: says what each one is, so the
layout is up to you rather than to Lyno.
configVersion: lyno/v1
kind: System
spec:
sip:
bindHost: 0.0.0.0
bindPort: 5060
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 # the SIP username
tenant: acme
spec:
password: "pick-something-good"
extension: "100"
displayName: Alice
---
configVersion: lyno/v1
kind: Account
metadata:
name: bob
tenant: acme
spec:
password: "and-something-else"
extension: "101"
displayName: Bob
The domain doesn't need to exist in DNS — phones will use the PBX address as their outbound proxy, and the domain is just how Lyno knows which tenant a phone belongs to.
Two accounts are enough to call each other: dialling an extension of the same
tenant rings it directly. Add an Extension document when you want more than
ringing — a greeting, a fallback, voicemail. For now, one that rings Alice's phone
for 30 seconds and then gives up:
---
configVersion: lyno/v1
kind: Extension
metadata:
name: "100" # the number this plan answers on
tenant: acme
spec:
description: Alice
steps:
- id: ring
type: dial
timeout: 30s
target:
kind: endpoint
endpoint: "100"
A plan named after an account extension is found before the account, so this
now decides what dialling 100 does. Copy it for 101 if you want the same.
./bin/lyno -config configs/lyno.yaml -configure opens a terminal editor that
builds all of this for you — pointing it at a path that doesn't exist starts from
defaults. It validates before it saves, so it can't write a broken config.
Turn it on
./bin/lyno -config configs/lyno.yaml -validate
Validation reads every document and reports every problem in one pass, not just the first — so one run tells you everything that's wrong. When it prints a summary instead of errors:
./bin/lyno -config configs/lyno.yaml
The dashboard appears. The Endpoints tab shows alice and bob, both
offline — an account that never registered still shows up, which is how you
tell "the phone is off" from "the account doesn't exist". Let's fix the
offline part.
Point two phones at it
In each softphone, create an account:
| Field | Alice's phone | Bob's phone |
|---|---|---|
| Username | alice | bob |
| Password | (from alice's Account) | (from bob's Account) |
| Domain / SIP server | acme.pbx.example.com | acme.pbx.example.com |
| Outbound proxy | your PBX's IP, e.g. 192.168.1.10:5060 | same |
The domain is the tenant's, the proxy is the PBX. That split matters: the domain selects the tenant (and the auth realm), the proxy is just where the packets go. Get them backwards and the log will tell you — a registration whose domain matches no tenant is refused with a line naming the domain the phone offered.
Watch the Endpoints tab as each phone registers and goes green.
Make the first call
Dial 101 from Alice's phone. Bob's phone rings; answer it, talk to
yourself, feel a little silly, hang up.
What just happened, in order: Alice's phone sent an INVITE → the source
wasn't a trunk, so Lyno demanded digest auth in acme's realm → 101 matched
an extension of acme → the plan for 101 ran its dial step → Bob answered and
the two legs were bridged. The call is now in the dashboard's history.
Now switch Bob's phone off and call 101 again. It rings for 30 seconds and
then the call ends — the plan ran out of steps. Which brings us to the fun
part.
Give the plan some personality
Dialplans read top to bottom, and a dial nobody answers falls through to the next step. That one rule is most of the power. Make a shared entry point that hunts:
configVersion: lyno/v1
kind: Extension
metadata:
name: "200"
tenant: acme
spec:
description: Support
steps:
- id: try-bob
type: dial
target:
kind: followme
stages:
- targets: ["101"] # Bob first
timeout: 10s
- targets: ["100", "101"] # then both
timeout: 20s
- id: done
type: hangup
Restart the PBX (config is read once, at startup) and dial 200: Bob's phone
rings for ten seconds, then both ring together, and the first to answer wins.
From here the same plan can grow office hours (when:), a spoken greeting
(play — needs an 8 kHz WAV), music on hold and
external numbers. It can also grow a keypad menu, a
queue and voicemail, each of
which falls through to the next step in exactly the same way a dial does. The
dialplan pages walk through all of it.
Voicemail and recording write files, so they also want a
dataDir — the one thing in the
configuration that is not read-only.
Connect the outside world (optional)
Everything so far worked without a carrier. When you have one, add three documents — a trunk, the routes that reach it, and the route inbound calls take — and name the trunk as the default:
---
configVersion: lyno/v1
kind: Trunk
metadata:
name: carrier
spec:
host: sip.provider.example
auth:
username: "12345678"
password: "from-your-provider"
registration:
enabled: true
---
configVersion: lyno/v1
kind: OutboundRoute
metadata:
name: emergency # no metadata.tenant: these are the global fallback
spec:
priority: 10 # lower is tried first
pattern: '^112$'
trunk: carrier
---
configVersion: lyno/v1
kind: OutboundRoute
metadata:
name: national
spec:
priority: 20
pattern: '^0[1-9]\d{8}$'
strip: 1
prepend: "+31"
trunk: carrier
Name it as the default on the System document:
kind: System
spec:
sip: { bindHost: 0.0.0.0, bindPort: 5060 }
timezone: Europe/Amsterdam
defaultTrunk: carrier
…and the tenant claims its number, while an InboundRoute decides where calls to
it land:
kind: Tenant
metadata:
name: acme
spec:
domains: [acme.pbx.example.com]
dids:
numbers: ["+31201234567"]
callerId:
number: "+31201234567"
name: Acme
---
configVersion: lyno/v1
kind: InboundRoute
metadata:
name: main-line
tenant: acme
spec:
priority: 10
did: "+31201234567"
destination: {type: extension, target: "200"}
Restart, and the Trunks tab shows the carrier registering and — under
INBOUND FROM — the addresses Lyno discovered for it via DNS. Calls to your
number now land on extension 200 and hunt for Bob; dialling 0201234567 from
Alice's phone goes out as +31201234567 with acme's caller ID.
If inbound calls are refused while outbound works, the answer is almost always on that Trunks tab — see how inbound calls are recognised.