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 defaultapi.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
| Key | Notes |
|---|---|
name | Required and unique. This is what appears in the log, never the secret. |
token | A plaintext secret. Refused below 32 characters as guessable. |
hash | sha256: followed by 64 hex characters. Preferred. |
scopes | At least one, from the table below. |
tenants | Restricts the token. Empty means every tenant. |
expires | YYYY-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())"
| Scope | Grants |
|---|---|
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:delete | DELETE /v1/voicemail/{tenant}/{extension}/{id} |
security:read | /v1/security/bans, /v1/security/counters |
mobile:read | GET /v1/mobile/devices |
mobile:manage | DELETE /v1/mobile/devices/{tenant}/{deviceId} |
operate | Hang 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 surfaceEverything 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/statuswith 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
| Method | Path | Scope |
|---|---|---|
GET | /v1/health | none |
The one unauthenticated route. It is what a container probe calls and reports liveness and nothing else.
Status and inventory — status:read
| Method | Path | Returns |
|---|---|---|
GET | /v1/status | Process 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
| Method | Path | Returns |
|---|---|---|
GET | /v1/cdr | {"records": [...], "next_cursor": "…"} |
GET | /v1/cdr/{tenant}/{id} | One full record. |
Recordings — recordings:read
| Method | Path | Returns |
|---|---|---|
GET | /v1/recordings | {"recordings": [...], "next_cursor": "…"} |
GET | /v1/recordings/{tenant}/{id} | The sidecar metadata. |
GET | /v1/recordings/{tenant}/{id}/audio | The WAV. |
Voicemail
| Method | Path | Scope | Returns |
|---|---|---|---|
GET | /v1/voicemail | voicemail:read | {"messages": [...]} |
GET | /v1/voicemail/{tenant}/{extension}/{id} | voicemail:read | One message. |
GET | /v1/voicemail/{tenant}/{extension}/{id}/audio | voicemail:read | The WAV. |
DELETE | /v1/voicemail/{tenant}/{extension}/{id} | voicemail:delete | 204, no body. |
Security — security:read
| Method | Path | Returns |
|---|---|---|
GET | /v1/security/bans | {"bans": [...]} |
GET | /v1/security/counters | Packet counters. |
Operator actions — operate
| Method | Path | Returns |
|---|---|---|
DELETE | /v1/security/bans/{ip} | 204 |
POST | /v1/calls/{id}/hangup | 202 — the BYE is sent in the background |
DELETE | /v1/registrations/{tenant}/{extension} | 204 |
POST | /v1/trunks/{name}/refresh | 202 |
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.
| Route | Accepted query parameters |
|---|---|
/v1/cdr | tenant, from, to, direction, number, answered, trunk, extension, cursor, limit |
/v1/recordings | tenant, from, to, extension, number, cursor, limit |
/v1/voicemail | tenant, extension |
/v1/registrations | tenant, extension, online |
/v1/trunks, /v1/calls | tenant |
| everything else | none |
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
| Bucket | Keyed on | Default | Configurable |
|---|---|---|---|
| Per token | the token's name | 300/min, burst 60 | api.rateLimit |
| Failed authentication | source IP | 10/min, burst 5 | no |
Exceeding either gives 429 with Retry-After.
burst alone is silently ignoredBoth 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:
| Routes | Present 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 downloadspresign: 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.