Gateway provisioning contract (v1)¶
The JSON a gateway.backend: http source answers with, so a controller that
owns its carriers as data can hand siphon the list instead of having them
written into siphon.yaml by hand.
The models are typed in siphon_sdk.gateways
and mirrored by the Rust structs in src/gateway/source.rs. A runnable
reference server — serving this and the
registrant contract off one table — is
examples/provisioning_api_server.py.
Configuration¶
gateway:
backend: http # static (default) | database | http
http:
url: "http://127.0.0.1:8080/gateways"
refresh_secs: 30
timeout_ms: 2000
auth_header: "Bearer ${PROVISIONING_TOKEN}"
The SQL form reads the same fields as columns:
gateway:
backend: database
database:
url: "postgresql://siphon@db.internal/siphon"
query: 'SELECT "group", uri, address, transport, weight, priority,
username, password, registers, enabled, probe FROM gateways
WHERE node = $1'
refresh_secs: 30
group (or group_name, since group is a reserved word) and uri are
required; every other column is optional. probe and require_registration
are boolean columns, the probe_* counts integers. When the statement references $1,
siphon binds server.instance_id to it.
Response (source → siphon)¶
GET {url} returns the complete desired state, not a delta. One row is one
destination; rows are gathered into groups by group.
{
"version": "1",
"gateways": [
{
"group": "carriers",
"uri": "sip:gw1.carrier.example:5060",
"weight": 3,
"priority": 1,
"attrs": {"region": "eu-west"},
"registers": "sip:trunk1@carrier.example",
"require_registration": true,
"enabled": true
}
]
}
| Field | Type | Default | Meaning |
|---|---|---|---|
group |
string | — | The name gateway.select() takes |
uri |
string | — | SIP URI to route to |
address |
string | from the URI host | Socket address; a hostname is re-resolved each probe cycle |
transport |
string | URI param, then udp |
udp, tcp or tls, case-insensitive; anything else rejects the row, in the URI ;transport= parameter too |
weight |
int | 1 |
Weighted round-robin weight |
priority |
int | 1 |
Lower is tried first; a higher tier is a failover pool |
algorithm |
string | "weighted" |
Group-wide: weighted, round_robin, hash |
attrs |
object | {} |
Matched by gateway.select(attrs=…) |
source_networks |
array | [] |
Group-wide source CIDRs for from_gateway() |
probe |
bool | true |
Group-wide: health-probe with OPTIONS (probe policy) |
probe_interval_secs |
int | 30 |
Group-wide: seconds between probes; 0 is refused |
probe_failure_threshold |
int | 3 |
Group-wide: failed probes before a destination is marked down |
probe_from_user |
string | "siphon" |
Group-wide: user part of the probe's From |
probe_from_domain |
string | local address | Group-wide: host part of the probe's From |
inbound_max_concurrent_calls |
int | unlimited | Group-wide: calls up at once from this group's sources (inbound limit) |
inbound_max_calls_per_second |
int | unlimited | Group-wide: new calls per second from this group's sources |
inbound_reject_code |
int | 503 |
Group-wide: status a call past either ceiling gets; outside 400-699 is refused |
inbound_retry_after_secs |
int | 1 |
Group-wide: Retry-After on that answer; 0 omits it |
username |
string | — | Digest username this destination challenges with |
password |
string | — | Plaintext password. Supply this or ha1 |
ha1 |
string | — | H(username:realm:password) hex |
ha1_algorithm |
string | "md5" |
Hash ha1 was computed with |
registers |
string | — | AoR of the outbound registration this destination belongs to |
require_registration |
bool | false |
Withhold from selection while registers is down |
verify_server |
bool | tls.verify_server |
Verify this destination's TLS server certificate (chain against tls.server_ca, the uri host as a SIP domain, RFC 5922 §7) |
enabled |
bool | true |
false drops the destination without removing the row |
Probe policy¶
A source group is probed with OPTIONS every 30 s unless its rows say
otherwise, which is also what a gateway.groups entry does. The probe_*
fields are the row form of that entry's probe: block, and are group-wide:
each is taken from the first row of the group that carries it, so they can sit
on one row or on all of them.
Turn probing off for a carrier that does not answer OPTIONS. Probed anyway,
it fails its probe, its destinations are marked down and selection skips them:
the carrier is taken out of service by being provisioned. Moving a
hand-written group with probe.enabled: false to a source means carrying that
false across as "probe": false.
Set probe_from_user / probe_from_domain for a carrier that rejects an
OPTIONS whose From it does not recognise. A probe rejected that way looks
exactly like a carrier that is down.
- A change keeps what the prober learned. Changing any
probe_*field replaces the group, because a prober's period andFromare fixed when it starts, but its destinations carry over with their health, failure count andRetry-Aftercooldown. A poll that changes none of them leaves the group and its prober alone. - Switching probing off puts the prober's verdicts back in service. A
destination the prober had marked down is marked up again, because with no
prober nothing ever would. This is the migration case above: the carrier went
down for not answering
OPTIONS, not for being down. - An unprobed group has no prober to re-resolve it. A hostname
destination's send address is resolved once, when the destination is built,
exactly as for a
gateway.groupsentry withprobe.enabled: false. Its membership forfrom_gateway()is re-resolved on the kernel allow set's floor tick when that set is on.
Inbound limit¶
The inbound_* fields are the row form of a gateway.groups entry's
inbound_limit: block. They cap the calls arriving from the group, in
B2BUA mode: from the addresses its destinations resolve to plus
source_networks, which is the membership from_gateway() answers from.
Like the probe_* fields they are group-wide, each taken from the first row of
the group that carries it.
{"group": "carrier-a", "uri": "sip:gw1.carrier.example:5060",
"inbound_max_concurrent_calls": 300, "inbound_max_calls_per_second": 30,
"inbound_reject_code": 486}
A group whose rows set neither ceiling has no limit. A call past one is
answered before @b2bua.on_invite runs, so no handler sees it; see
Limiting inbound calls
for what is refused, what is exempt and what records it.
- A change keeps the count. Changing an
inbound_*field replaces the group, but the calls already up from it stay counted against the new limit. Lowering a ceiling below what is up ends no call; it refuses new ones until enough have ended. - Removing the fields removes the limit, and removing the group forgets its count. Calls still up at that moment finish normally.
- A bad
inbound_reject_coderefuses the row, like a zero probe interval, and counts in the reconcile'srejected. - The count is per siphon instance. N nodes reading the same source each allow the full figure.
With security.trust_gateways on, a provisioned carrier is exempt from the
rate limiter, so this is the only throttle on it.
Exempting provisioned carriers from abuse controls¶
A provisioned carrier is admitted by from_gateway() and the kernel allow set,
but it is not in security.trusted_cidrs, so rate_limit, scanner_block,
failed_auth_ban, the connection ceilings and APIBAN treat it like any other
source. Set security.trust_gateways: true to trust every source a gateway
group admits (destination addresses plus source_networks, whichever created
the group). The reconcile that provisions a carrier makes it trusted and lifts
any auto-ban or APIBAN entry on its address, kernel set included; the reconcile
that removes it puts it back under every policy, counted from zero. See
Trusting gateways.
{"group": "carriers", "uri": "sip:gw1.carrier.example:5060",
"probe_interval_secs": 15, "probe_from_domain": "sbc.example.com"}
Linking a destination to its registration¶
registers names an AoR from the registrant source. It
does two things:
- The credential is defined once. A destination with no
usernameof its own answers a401/407with that registration's credential, so rotating a trunk password is one edit rather than two that drift apart. require_registration: truegates selection. While that registration is not registered, the destination is skipped bygateway.select(). This is opt-in because silently withholding a destination is worse than trying it: a gateway that authenticates per call does not need its registration up. Turn it on for a carrier that only accepts calls from a registered peer, where dialling it unregistered just earns a403.
A require_registration with no registers to gate on is refused as a row.
Typed contract models¶
siphon_sdk.gateways is the single typed source for the shapes above —
zero-dependency dataclasses. Build your endpoint against them:
from fastapi import FastAPI
from siphon_sdk.gateways import GatewayListResponse, GatewayRow
app = FastAPI()
@app.get("/gateways")
def gateways() -> dict:
return GatewayListResponse(gateways=[
GatewayRow(group="carriers", uri="sip:gw1.carrier.example:5060",
weight=3, registers="sip:trunk1@carrier.example"),
]).to_dict()
GatewayListResponse¶
| Field | Type | Default | Meaning |
|---|---|---|---|
gateways |
list[GatewayRow] |
[] |
The complete desired state |
version |
str |
"1" |
Contract version; a mismatch is logged, not fatal |
GatewayRow¶
group and uri are required positionally; everything else is
keyword-with-default and maps one-to-one onto the JSON fields above.
Behavior notes¶
- Health survives a refresh. A destination whose definition has not changed is carried over as the same object, keeping what the health prober has learned about it. Rebuilding the group every poll would mark every dead carrier healthy again on a 30-second cycle and route calls straight back into it.
- An unchanged group is not touched at all. Replacing a group restarts its health prober, so a poll that finds nothing different leaves it alone.
- A changed definition is a new destination. Change the URI, address, transport, weight, priority, attributes or credentials and it is rebuilt, starting healthy — correct, because it is a different peer or a different way of reaching one.
- An unreadable source changes nothing. A failed read keeps the current groups, so a database being briefly down does not leave the node with nowhere to route. An endpoint that cannot answer should fail the request rather than return an empty list.
- The source owns only what it created. Groups from
gateway.groupsand ones a script created withgateway.add_group()are never replaced or removed by a reconcile, and a group name one of them already holds is skipped with a warning. - A malformed row is skipped, not fatal. One bad gateway must not take the rest of the estate with it; it is counted and logged with its group and URI.
- Push, when polling is too slow.
POST /admin/gateways/refreshapplies a change the moment the controller saves a row. - If your carriers authenticate by source address, turn on the kernel allow
set. Provisioning a carrier here makes it dialable; it does not make the
kernel accept its answers. With
security.firewall.gateway_set(the default whensecurity.firewallis set at all) siphon publishes every address these groups resolve to into an nftables set you reference from your own ruleset, on the same reconcile that made the carrier dialable. See kernel-firewall.md. Without it, a newly provisioned carrier works outbound and is silently dead inbound.