Skip to main content

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

ThingWhy
Go, or DockerTo build or run the binary.
Two softphonesZoiper, Linphone, Groundwire… the second one can be the app on your phone.
A terminalThe 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.

configs/lyno.yaml
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.

Allergic to writing YAML?

./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:

FieldAlice's phoneBob's phone
Usernamealicebob
Password(from alice's Account)(from bob's Account)
Domain / SIP serveracme.pbx.example.comacme.pbx.example.com
Outbound proxyyour PBX's IP, e.g. 192.168.1.10:5060same

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.

Where next