Skip to main content

Management API

An HTTP interface for reading call logs, recordings, voicemail, status and what the security guard is doing, plus four operator actions. It is off by default and there is no command-line flag for it: the only switch is api.enabled in lyno.yaml.

configVersion: lyno/v1
kind: System
spec:
api:
enabled: true
bindHost: 127.0.0.1
bindPort: 8080
rateLimit:
requestsPerMinute: 120
burst: 20
tokens:
- name: monitoring
hash: "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"
scopes: [status:read, cdr:read]
tenants: [acme]
expires: "2027-01-01"

It binds loopback, and that is enforced

The SIP side has to listen on 0.0.0.0 because phones must reach it. A management interface has no such excuse, so bindHost defaults to 127.0.0.1 and validation refuses any wider address without api.tls:

api: bindHost "0.0.0.0" is reachable off this host, so api.tls is required

Enabling the API with no tokens is refused for the same reason — that would leave it open.

bindPort has no default

api.enabled: true with no bindPort passes validation and binds an ephemeral port the operating system picks. The chosen port appears only in the startup log line. Always set one.

A disabled api: block is not validated at all, so a broken token list surfaces the day you flip enabled: true, not before.

Tokens and scopes

Authenticate with a bearer token in the Authorization header. Nothing else is accepted — a token in the query string is never parsed, because that is where tokens end up in access logs and browser history.

curl -H "Authorization: Bearer $LYNO_TOKEN" http://127.0.0.1:8080/v1/status
KeyNotes
nameRequired and unique. This is what appears in the log, never the secret.
tokenA plaintext secret. Refused below 32 characters as guessable.
hashsha256: followed by 64 hex characters. Preferred.
scopesAt least one, from the table below.
tenantsRestricts the token. Empty means every tenant.
expiresYYYY-MM-DD or a full RFC 3339 timestamp.

Exactly one of token and hash must be set, and two tokens may not share a secret.

Prefer hash. Configuration holds SIP passwords in the clear because digest authentication needs them; an API token has no such constraint. The digest is plain SHA-256 rather than bcrypt or argon2 because a token is required to be high-entropy random, so a slow key derivation buys nothing and turns every request into a way to burn CPU.

# produce a token and its hash
python3 -c "import secrets,hashlib; t=secrets.token_urlsafe(32); print(t); print('sha256:'+hashlib.sha256(t.encode()).hexdigest())"
ScopeGrants
status:read/v1/status, /v1/tenants, /v1/trunks, /v1/registrations, /v1/calls
cdr:read/v1/cdr, /v1/cdr/{tenant}/{id}
recordings:read/v1/recordings…, including /audio
voicemail:read/v1/voicemail…, including /audio
voicemail:deleteDELETE /v1/voicemail/{tenant}/{extension}/{id}
security:read/v1/security/bans, /v1/security/counters
mobile:readGET /v1/mobile/devices
mobile:manageDELETE /v1/mobile/devices/{tenant}/{deviceId}
operateHang up a call, drop a registration, refresh a trunk, lift a ban

Everything that changes the running system sits behind operate, and no shipped example grants it.

/v1/mobile/* is a second surface

Everything else under /v1/mobile runs on a device token issued to a phone, never on an operator token — the two routes above are the exception and say so with their scope. See Mobile apps.

Tenant scoping uses two different status codes on purpose

A token restricted with tenants: is filtered twice — once on the query, and again on every row a store hands back, so a bug in a store still cannot leak. The status code depends on what you asked for:

  • ?tenant= you may not see → 403. Answering with an empty list would make a busy system look idle.
  • A record addressed by id that belongs to another tenant → 404, never 403, so ids cannot be probed across tenants.
  • /v1/security/* with a tenant-scoped token → 403. Guard data is process-global; there is no per-tenant view of it.
  • /v1/status with a tenant-scoped token returns counts narrowed to that tenant rather than process-wide totals.

A trunk with no tenant is shared and visible to every token.

Routes

Every route is under /v1.

Health

MethodPathScope
GET/v1/healthnone

The one unauthenticated route. It is what a container probe calls and reports liveness and nothing else.

Status and inventory — status:read

MethodPathReturns
GET/v1/statusProcess status. Narrowed for a tenant-scoped token.
GET/v1/tenants{"tenants": [...]}
GET/v1/tenants/{tenant}One tenant.
GET/v1/trunks{"trunks": [...]}
GET/v1/trunks/{name}One trunk, with its discovered addresses.
GET/v1/registrations{"registrations": [...]}
GET/v1/calls{"calls": [...]} — calls in progress.

Call log — cdr:read

MethodPathReturns
GET/v1/cdr{"records": [...], "next_cursor": "…"}
GET/v1/cdr/{tenant}/{id}One full record.

Recordings — recordings:read

MethodPathReturns
GET/v1/recordings{"recordings": [...], "next_cursor": "…"}
GET/v1/recordings/{tenant}/{id}The sidecar metadata.
GET/v1/recordings/{tenant}/{id}/audioThe WAV.

Voicemail

MethodPathScopeReturns
GET/v1/voicemailvoicemail:read{"messages": [...]}
GET/v1/voicemail/{tenant}/{extension}/{id}voicemail:readOne message.
GET/v1/voicemail/{tenant}/{extension}/{id}/audiovoicemail:readThe WAV.
DELETE/v1/voicemail/{tenant}/{extension}/{id}voicemail:delete204, no body.

Security — security:read

MethodPathReturns
GET/v1/security/bans{"bans": [...]}
GET/v1/security/countersPacket counters.

Operator actions — operate

MethodPathReturns
DELETE/v1/security/bans/{ip}204
POST/v1/calls/{id}/hangup202 — the BYE is sent in the background
DELETE/v1/registrations/{tenant}/{extension}204
POST/v1/trunks/{name}/refresh202

POST routes take no request body; they are addressed entirely by path. Every operator action writes an audit log line naming the token that made it.

There are no queue routes and no feature-code routes. Queue state and do-not-disturb / forwarding are not exposed over HTTP.

Filtering and pagination

Only /v1/cdr and /v1/recordings paginate. Everything else returns a full list.

RouteAccepted query parameters
/v1/cdrtenant, from, to, direction, number, answered, trunk, extension, cursor, limit
/v1/recordingstenant, from, to, extension, number, cursor, limit
/v1/voicemailtenant, extension
/v1/registrationstenant, extension, online
/v1/trunks, /v1/callstenant
everything elsenone

An unknown query parameter is a 400, naming the set that is accepted. A typo like ?tenat=acme is an error rather than a silent listing of everything.

from and to are RFC 3339. direction is inbound, outbound or internal. limit defaults to 100 and is capped at 1000.

Pagination is cursor-based, never offset: pass the next_cursor you were given back as ?cursor=. The cursor is opaque — do not construct one.

Every list body is an object with a named field rather than a bare array, and an empty result renders as [], never null.

Errors

{"error": {"code": "not_found", "message": "no such route", "request_id": "3f9ac1d2"}}

Codes are unauthorized, forbidden, not_found, invalid_request, rate_limited and internal. A 500 reports internal error and logs the real cause, so no filesystem path ever reaches a client.

Every response carries X-Request-Id. An inbound X-Request-Id is accepted and echoed when it is 8–64 characters of [A-Za-z0-9_-], otherwise one is minted.

The catch-all 404 is deliberately unauthenticated, so a 401-versus-404 difference cannot be used to enumerate routes. A wrong method gives 405 with an Allow header. HEAD works on every GET.

Rate limits

BucketKeyed onDefaultConfigurable
Per tokenthe token's name300/min, burst 60api.rateLimit
Failed authenticationsource IP10/min, burst 5no

Exceeding either gives 429 with Retry-After.

burst alone is silently ignored

Both defaults are replaced as a pair. Setting burst without requestsPerMinute leaves the built-in 300/60 in place. A negative requestsPerMinute is not caught by validation and disables the limiter entirely.

The source address used for rate limiting and logging is the socket peer. X-Forwarded-For is deliberately ignored, so putting a reverse proxy in front of this API makes every client look like the proxy.

CORS

Empty by default, and there is no wildcard — the audience is an operator or a monitoring agent, not a browser. Enabling it by default would let any page the operator happens to visit probe a loopback interface with the browser's blessing.

api:
cors:
allowedOrigins: ["https://ops.example.com"]

Origins are matched as exact strings. Access-Control-Allow-Credentials is never set. With the list empty the CORS middleware is not installed at all, so a preflight OPTIONS falls through to the 405 handler.

Rotating a token without a restart

Sending SIGHUP reloads the configuration and swaps the API's credential set. This is the only hot-reloadable configuration in the product — everything else still needs a restart.

systemctl reload lyno # or: kill -HUP $(pidof lyno)

The new set is validated before it is swapped in, so a file with a broken token leaves the running set alone rather than locking you out of the interface you would use to fix it. It exists because the alternative — restart to rotate a token — means nobody ever rotates one.

Routes that answer 404 by design

A deployment without a data directory still serves status, security and the operator actions. The routes behind a component that is not running answer 404 with a message saying so, rather than needing a different binary:

RoutesPresent when
/v1/cdr…at least one tenant has call logging on
/v1/recordings…recording is on somewhere
/v1/voicemail…at least one tenant has mailboxes

What does not work

api.recordings.presign breaks recording downloads

presign: true makes /v1/recordings/{tenant}/{id}/audio answer 500 on every request. No storage backend implements presigning — neither local disk nor S3 — so the call fails and there is deliberately no fallback to streaming the bytes, because silently streaming would hide that the redirect you asked for is impossible.

Nothing in validation stops you turning it on, and the configuration editor offers it as an ordinary checkbox. Leave presign off. presignTtl is therefore inert as well.

The reasoning behind the setting still stands for when a backend gains the ability — a presigned URL is a second credential that cannot be revoked and that lands in browser history and proxy logs, while a bearer token can be revoked.

Reference

The full key-by-key schema is in the lyno.yaml reference.