Skip to main content

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:

  1. Oversized? A UDP datagram larger than maxDatagram is dropped.
  2. Trusted? Loopback (when trustLoopback), any address discovered for a trunk, and anything matching allow is admitted without accounting.
  3. Denied? A source matching deny is refused. Deny wins over allow.
  4. Banned? Refused, and counted.
  5. 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 filter

An 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:

EventCounted
REGISTER for an unknown user (answered 404)yes
REGISTER with a rejected digest responseyes
A 401 challengeno
INVITE from an unknown SIP domain (403)yes
INVITE from an unknown account (403)yes
INVITE with a rejected Authorization headeryes
INVITE with no credentials at allno — that is a challenge
A successful authenticationclears 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 banned at warn, with the address, reason, strike count and expiry;
  • ban expired / ban lifted at info;
  • dropped packets from banned sources at warn, at most once per interval, with a total and the worst three offenders;
  • dropped packets over the per-source rate limit at warn, the same way, naming security.packetRate and security.allow in 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.

Throttles are reported separately from bans, on purpose

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.

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

Both 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
A tls or wss listener cannot bind

Validation 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.yamlsecurity.