The security guard
Two things always happen to a phone system on the internet: password guessing and toll fraud. The guard handles the first by dropping packets from a source that misbehaves; the fraud limits handle the second by refusing a call that would leave over a trunk.
configVersion: lyno/v1
kind: System
spec:
security:
enabled: true
packetRate: 50
packetBurst: 200
authFailures: 5
authWindow: 10m
banDuration: 10m
banMax: 24h
maxTracked: 20000
maxDatagram: 9000
trustLoopback: true
allow: ["203.0.113.0/24"]
deny: ["198.51.100.9"]
Everything except maxDatagram, allow and deny is shown at its default, so
enabled: true on its own is a reasonable configuration. A zero or missing
value falls back to the default rather than switching the check off — with the
exception of maxDatagram, which is passed through as written and is 0, that
is off, unless you set it.
How it works
The guard sits in the transport read filter, so it drops packets before they are parsed. A flood therefore costs almost nothing: no SIP message is built, no handler runs, no response is sent.
A packet is examined in this order:
- Oversized? A UDP datagram larger than
maxDatagramis dropped. - Trusted? Loopback (when
trustLoopback), any address discovered for a trunk, and anything matchingallowis admitted without accounting. - Denied? A source matching
denyis refused. Deny wins over allow. - Banned? Refused, and counted.
- Too fast? Over
packetRate/packetBurst, refused.
A phone legitimately bursts — REGISTER, INVITE, ACK, BYE inside a second — so the burst is deliberately generous relative to the rate.
allow is a trust statement, not just a filterAn address matching allow is exempt from banning and rate limiting as well as
from deny. Use it for a carrier or a monitoring host, not as a general
allowlist.
There is no SIP response
A refused packet gets no answer at all — not a 403, not a 503. On UDP the datagram is discarded; on TCP, TLS or WebSocket the connection is closed. Nothing reaches a handler, which is the point.
Bans
authFailures failed authentications within authWindow earn a ban of
banDuration. A repeat offender's ban doubles each time, up to banMax.
An address that behaves for ten ban durations has its strike count reset.
Digest authentication is always two round trips, so the first unauthenticated REGISTER of every phone is a challenge, not a failure — it is not counted. What is counted:
| Event | Counted |
|---|---|
| REGISTER for an unknown user (answered 404) | yes |
| REGISTER with a rejected digest response | yes |
| A 401 challenge | no |
| INVITE from an unknown SIP domain (403) | yes |
| INVITE from an unknown account (403) | yes |
INVITE with a rejected Authorization header | yes |
| INVITE with no credentials at all | no — that is a challenge |
| A successful authentication | clears the window |
maxTracked caps the address table so the tracker cannot itself become the way
to exhaust memory. When it is full the guard fails open rather than dropping
traffic it cannot account for.
Bans lift on expiry, when an address becomes trusted, or when an operator lifts one.
A carrier is never banned
Every address discovered for a trunk is trusted automatically and needs no entry
in allow. A carrier legitimately bursts, and a banned carrier is a
self-inflicted outage. That includes trunks pinned by matchIps with
autoIps: false.
Watching it
The dashboard's Security tab lists what the guard is holding at arm's length:
SOURCE STATE REASON STRIKES FAILURES EXPIRES DROPPED TENANT
198.51.100.9 banned auth failures 2 7 18m 412 acme
Press x to lift a ban. It is the one destructive dashboard action aimed at
letting a real customer back in — banning is the feature most likely to shut
somebody out at three in the morning, and "restart the PBX" is a worse answer
than a keypress.
The tab shows The security guard is not enabled when security.enabled is off,
which is a different statement from Nothing is being blocked.
The same data is on the management API at /v1/security/bans and
/v1/security/counters, and lifting a ban is
DELETE /v1/security/bans/{ip}.
In the log:
source bannedat warn, with the address, reason, strike count and expiry;ban expired/ban liftedat info;dropped packets from banned sourcesat warn, at most once per interval, with a total and the worst three offenders;dropped packets over the per-source rate limitat warn, the same way, namingsecurity.packetRateandsecurity.allowin the message.
A dropped packet is never logged individually: logging the packets themselves is what turns a flood into an outage of the log. Three offenders is enough to recognise a carrier or a single scanner, and a line that names them all is a line nobody reads.
A throttled source is the one to look at. It is usually a peer that is allowed to be here and is losing packets anyway — which is how a working trunk becomes a trunk that no longer delivers calls, with nothing in the log to say why. Folding it into the ban total would make exactly that case invisible.
If a carrier shows up there, raise packetRate or put it in allow.
Nothing here goes over the event bus. The bus drops events when a subscriber falls behind, which is right for a dashboard and wrong for the numbers that describe a flood, so the dashboard polls instead.
Unhandled SIP methods
SUBSCRIBE, MESSAGE, PUBLISH, UPDATE and PRACK are answered
405 Method Not Allowed with an Allow header. Scanners hammer them, and
answering explicitly is both more correct than silence and the only way they
show up in the packet counters. They do not count as authentication failures.
The fraud limits
The guard above acts on a source address, before anything knows which tenant a packet belongs to. The fraud limits sit at the other end: at the point where a call would actually leave over a trunk, when the tenant and the destination are both known.
security:
limits:
maxConcurrentCalls: 60
maxConcurrentPerAccount: 3
maxOutboundPerMinute: 10
blockedPrefixes: ["+882", "+1900"]
outboundMinutesPerHour: 240
outboundMinutesPerMonth: 20000
A tenant may tighten any of these for itself under security.limits in its
own Tenant document.
Blocked prefixes
blockedPrefixes is checked at the single point every route to a trunk passes
through: an external dial target, a follow-me stage pointing at a mobile, an
external member of a ring group, a plain outside number. Checking one level up
would miss exactly the paths toll fraud uses — a compromised account dialling an
IVR whose follow-me forwards to a premium-rate number.
The number is matched both as dialled and after a route's strip and
prepend, because a route can turn a harmless-looking string into an
expensive one.
Concurrency
A concurrency slot is taken once, as soon as the tenant is known — and for a call from an endpoint, only after its credential was accepted. Counting unauthenticated attempts would let anyone who can reach the port hold a tenant at its ceiling with INVITEs that never become calls.
The minute caps restart at zero
outboundMinutesPerHour and outboundMinutesPerMonth do not survive a restartBoth caps are counted in memory. The function that would seed them from the call log at startup exists but has no callers, so a restart returns the counters to zero — a monthly ceiling is only ever measured from the last restart.
The concurrency caps and blockedPrefixes are unaffected: neither depends on
history.
What does not work
security.acl is not enforced
security:
acl:
register: {allow: ["203.0.113.0/24"]} # not enforced
invite: {deny: ["198.51.100.0/24"]} # not enforced
The per-method allow and deny lists are validated and editable, and the guard
never reads them. Use the top-level allow: and deny:, which are enforced —
at the cost of not being able to distinguish REGISTER from INVITE.
security.requireSrtp is not enforced
Nothing inspects an SDP offer. A caller offering plain RTP is never refused,
whatever this is set to. The same applies to a tenant's own requireSrtp.
TLS and SRTP listeners do not work
sip:
transports:
- udp
- {transport: tls, bindPort: 5061, srtp: sdes} # will not start
tls:
certFile: /etc/lyno/tls/cert.pem
keyFile: /etc/lyno/tls/key.pem
tls or wss listener cannot bindValidation demands a certificate for a tls or wss listener, and then the
certificate is never handed to the SIP stack. The listener is created with no
TLS configuration and fails to bind. wss passes validation while plain ws is
rejected, and neither can work.
srtp: on a transport and media.savpProfile are likewise accepted and never
applied — they appear in the startup summary and in the -validate output and
change nothing else. Every call is plain RTP.
Run SIP over UDP or TCP, and put the transport security in front of the PBX.
The certificate machinery does work for the management API, which is
what api.tls uses.
Per-tenant security
A tenant may set acl, limits and requireSrtp under security: in its
own Tenant document. Only limits is enforced; acl and
requireSrtp are in the list above.
Banning and rate limiting are deliberately not available per tenant, because they act on a source address before anything has decided which tenant it belongs to.
Reference
Key-by-key schema: lyno.yaml → security.