Skip to content

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.

from siphon_sdk.gateways import GatewayListResponse, GatewayRow

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 and From are fixed when it starts, but its destinations carry over with their health, failure count and Retry-After cooldown. 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.groups entry with probe.enabled: false. Its membership for from_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_code refuses the row, like a zero probe interval, and counts in the reconcile's rejected.
  • 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": false}
{"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 username of its own answers a 401/407 with that registration's credential, so rotating a trunk password is one edit rather than two that drift apart.
  • require_registration: true gates selection. While that registration is not registered, the destination is skipped by gateway.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 a 403.

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.groups and ones a script created with gateway.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/refresh applies 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 when security.firewall is 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.