sigproxy-rs is a Layer-7 SIP-aware stateless proxy / load balancer written in
Rust. The current mainline focuses on SIP-aware routing, affinity, registration
handling, upstream health checks, and correct request/response forwarding for
UDP and TCP SIP.
The proxy uses rsipstack for SIP parsing, serialization, typed headers, URI
handling, and TCP SIP framing where those APIs fit the stateless proxy path.
Implemented mainline behavior:
- UDP and TCP SIP listeners.
- Listener-to-upstream-group mapping.
- Static route overrides by listener, domain, and URI prefix.
- SIP-aware affinity / session persistence by
dialog-id,call-id, orrequest-uri. - Transparent forwarding for SIP requests including REGISTER and OPTIONS.
- Upstream health checks using SIP
OPTIONSor TCP connect probes. - UDP SIP port compatibility for softphone STUN Binding Requests and CRLF keepalives.
- Stateless forwarding with proxy
Viainsertion and responseViaremoval. - UDP response routing through a stable listener socket.
- TCP downstream framing and upstream connection reuse.
- INVITE provisional/final response forwarding.
- Lightweight INVITE transaction routing so
CANCELandACKcan hit the same upstream as the original INVITE. Max-Forwardsproxy behavior.- Optional Prometheus text metrics endpoint.
clapCLI for running and validating configuration.
Out of current mainline scope:
- Full PBX behavior.
- B2BUA behavior.
- RTP / media proxy.
- Full SIP transaction state machine.
- Full SIP dialog ownership.
- Active-active multi-writer clustering.
- Built-in cloud EIP / VIP provider integrations.
Active-standby HA and snapshot replication are available as optional deployment building blocks. Floating endpoint movement is delegated to the configured HA addon hook.
Generate an example configuration:
cargo run --bin sigproxy -- config init --output config.tomlValidate configuration:
cargo run --bin sigproxy -- config check --config config.tomlRun the proxy:
cargo run --bin sigproxy -- run --config config.tomlFor a release build:
cargo build --release
./target/release/sigproxy run --config config.tomlListeners are configured only under [[proxy.listeners]]. Each listener points
to an upstream group.
[node]
id = 1
[sip]
public_addr = "95.40.96.117"
internal_addr = "172.30.0.101"
public_stun_server = ""
internal_probe_addr = "8.8.8.8:53"
max_message_bytes = 65535
[proxy]
record_route = true
# "path" keeps REGISTER Contact unchanged and adds Path for standard proxy/PLB.
# Use "contact-rewrite" only for PBX/registrar compatibility deployments.
register_routing = "path"
[proxy.socket]
# Keep reuse_port disabled for simple deployments. Enable it for high PPS UDP
# listeners before raising workers_per_listener; workers_per_listener = 0 means
# auto CPU count and requires reuse_port = true.
reuse_port = false
workers_per_listener = 1
recv_buffer_bytes = 4194304
send_buffer_bytes = 4194304
tcp_nodelay = true
[proxy.metrics]
enabled = false
bind_addr = "127.0.0.1:9100"
[proxy.affinity]
enabled = true
key = "dialog-id"
ttl_seconds = 3600
[[proxy.upstream_groups]]
name = "pbx-a"
mode = "round-robin"
servers = ["127.0.0.1:5080", "127.0.0.1:5081"]
[proxy.upstream_groups.health_check]
enabled = true
interval_ms = 5000
timeout_ms = 1000
success_threshold = 2
failure_threshold = 3
[proxy.upstream_groups.health_check.probe]
mode = "options"
transport = "udp"
uri = "sip:healthcheck@pbx-a"
success_codes = [200, 202, 405, 481]
[[proxy.listeners]]
bind = "0.0.0.0:5060"
transport = "udp"
upstream_group = "pbx-a"
[[proxy.listeners]]
bind = "0.0.0.0:5060"
transport = "tcp"
upstream_group = "pbx-a"
[persistence]
enabled = true
path = "/var/lib/sigproxy-rs/ha/state.db"
required = false
event_retention_seconds = 3600
cleanup_interval_ms = 60000Use public_addr for the address public SIP clients can reach, and
internal_addr for the address upstream PBX servers can reach. Via and
Path use the address for the next hop. Dialog-forming requests crossing
between public and internal sides get two Record-Route headers so each side
keeps a reachable route.
public_addr and internal_addr may be just a host/IP; when the port is
omitted, sigproxy uses the port from the matching SIP listener.
When [persistence] is enabled, sigproxy stores REGISTER location,
affinity bindings, and the HA event log in local SQLite WAL mode. In a
two-node active/standby pair, standby pulls /ha/events incrementally and
falls back to /ha/snapshot when its checkpoint is behind the retained event
log. The SQLite file is node-local and must not be shared between pods.
If internal_addr is empty or omitted, sigproxy probes internal_probe_addr
with a UDP socket and advertises that local address. If public_addr is empty
or omitted, set public_stun_server so sigproxy can discover the public IP with
STUN; the advertised port still comes from the SIP listener.
external_addr is still accepted as a legacy single advertised address and is
used as a fallback when public_addr or internal_addr is not set. When no
usable advertised address is set, or it is loopback/unspecified such as
127.0.0.1:5060 or 0.0.0.0:5060, the proxy derives an upstream-facing local
address automatically.
Use examples/single-node.toml for a fuller local example.
Configuration files support environment placeholders before TOML parsing:
${VAR}, $VAR, and ${VAR:-default}. Keep quotes around values that expand
to TOML strings, for example public_addr = "${SIP_PUBLIC_ADDR}".
The repository keeps only the main deployment shapes:
examples/single-node.toml: local single-node stateless SIP-aware proxy.examples/active-standby-node1.toml: two-node active-standby, node 1 starts active.examples/active-standby-node2.toml: two-node active-standby, node 2 starts standby.
For a commented template that lists every supported configuration field, see
docs/config-template.toml.
The active-standby examples use the noop HA addon, so they do not move SIP
traffic by themselves. Production active-standby deployments still need a
VIP/EIP/LB hook or equivalent traffic steering so only the active node receives
client SIP traffic. Command hooks are treated as required fencing hooks for
active-standby role changes: promotion succeeds only after
on_become_leader succeeds, and hook timeout terminates the hook process.
HA is configured from a single [ha] enabled = true block; enabling HA starts
both the role heartbeat loop and standby state replication. Single-node
deployments can omit [ha] entirely.
Health checks are configured per upstream group. The default active mode is
options, which sends SIP OPTIONS and treats configured SIP response codes as
successful. tcp-connect is also available for lightweight TCP port checks.
[proxy.upstream_groups.health_check]
enabled = true
interval_ms = 5000
timeout_ms = 1000
success_threshold = 2
failure_threshold = 3
[proxy.upstream_groups.health_check.probe]
mode = "options"
transport = "udp"
uri = "sip:healthcheck@pbx-a"
success_codes = [200, 202, 405, 481]OPTIONS is useful because it checks the SIP application path, but it still
does not prove that all real call flows are healthy. When health checks are
enabled, the proxy also applies passive health feedback from real forwarding:
upstream send/connect failures and 5xx responses count as failures; non-5xx
upstream responses count as successes.
The first active probe runs immediately when the proxy starts. Servers in the
same group are probed concurrently, and each OPTIONS Via uses the probe
socket's actual local address with rport so standards-compliant upstreams can
return the response to the correct socket.
For TCP-only upstream groups, a lightweight check can be expressed as:
[proxy.upstream_groups.health_check]
enabled = true
interval_ms = 5000
timeout_ms = 1000
success_threshold = 2
failure_threshold = 3
[proxy.upstream_groups.health_check.probe]
mode = "tcp-connect"The default path is listener-based:
[[proxy.listeners]]
bind = "0.0.0.0:5060"
transport = "udp"
upstream_group = "pbx-a"Optional route entries can override the listener default:
[[proxy.routes]]
name = "tenant-a-on-5060"
listener = "udp/0.0.0.0:5060"
domain = "tenant-a.example.com"
prefix = "sip:1"
upstream_group = "pbx-a"If an upstream load balancer forwards to internal SIP nodes that send requests back from different source IPs, mark those sources without adding them as load-balancing targets:
[proxy]
upstream_source_cidrs = ["10.42.0.0/16"]These CIDRs are treated as upstream-side sources for request routing and are trusted for packet-level security, but they are not selected as upstream servers.
Route selection order is:
- Match listener key.
- Match request URI domain when configured.
- Match request URI prefix when configured.
- Fall back to the listener's
upstream_group.
Affinity is configured under [proxy.affinity]:
[proxy.affinity]
enabled = true
key = "dialog-id"
ttl_seconds = 3600Supported keys:
dialog-id: direction-independent dialog key when both tags exist, withCall-IDfallback.call-id: all messages with the sameCall-IDprefer the same upstream.request-uri: messages with the same request URI prefer the same upstream.
The proxy also records a lightweight INVITE transaction route:
INVITErecordsclient Via branch + Call-ID + CSeq number -> upstream.CANCELandACKtry that mapping before normal affinity.
This improves SIP-aware session persistence without turning the proxy into a stateful SIP transaction proxy.
Security is configured globally under [proxy.security] and can be overridden
per listener with [proxy.listeners.security]. Dynamic ban is enabled by
default; set preset = "off" to explicitly disable all listener security.
[proxy.security]
preset = "public"
# Upstream PBX/backend IPs are trusted automatically from proxy.upstream_groups.
# Add trusted_cidrs only for extra non-upstream packet-level trusted sources.
# trusted_cidrs = ["10.0.1.20/32"]
[proxy.security.ip_rate_limit]
packets_per_second = 50
burst = 100
parse_errors_per_minute = 20
block_seconds = 300
[proxy.security.sip_rate_limit]
register_per_minute_per_aor = 20
invite_per_minute_per_aor = 60
block_seconds = 300
[proxy.security.sip_policy]
require_registered_invite_source = true
registered_invite_source_match = "ip"
[proxy.security.geo]
enabled = true
cache_dir = "/var/lib/sigproxy-rs/geo"
startup_refresh = "disabled"
request_retries = 3
allow_partial = true
[proxy.security.geo.deny]
countries = ["RU", "IR", "KP"]
[proxy.security.threat_intel]
enabled = true
cache_dir = "/var/lib/sigproxy-rs/threat"
startup_refresh = "disabled"
request_retries = 3
allow_partial = true
[proxy.security.dynamic_ban]
enabled = true
ban_seconds = 3600
invalid_packets_per_minute = 30
parse_errors_per_minute = 20
sip_rate_violations_per_minute = 10
[proxy.security.flood]
enabled = true
udp_packets_per_second = 200
udp_burst = 400
tcp_packets_per_second = 200
tcp_burst = 400
tcp_syn_packets_per_second = 100
tcp_syn_burst = 200
tcp_ack_packets_per_second = 200
tcp_ack_burst = 400
icmp_packets_per_second = 50
icmp_burst = 100
block_seconds = 300Presets are off, trusted, public, and strict. SIP listeners apply
CIDR allow/deny checks, listener-scoped geo country checks, threat-intel CIDR
checks, raw packet flood limits, packet and parse-error rate limits,
fail2ban-style dynamic bans, invalid start-line prefiltering, and
REGISTER/INVITE SIP identity limits. ACK and CANCEL are not
SIP-rate-limited so transaction cleanup is not disrupted. Geo data is loaded
from a binary geo.sgeo cache in cache_dir; threat intel is loaded from
threat.sthr. Both use an embedded empty snapshot as the startup fallback. By
default, runtime refresh is disabled so container deployments use the caches
built into the image.
Upstream PBX/backend IPs from proxy.upstream_groups are automatically added
to the packet-level trusted set. trusted_cidrs is only for extra exact
non-upstream trusted source IPs. Trusted peers skip packet-level
geo/threat/flood checks, but non-upstream clients still pass through SIP
identity policy, SIP method rate limits, and SIP dynamic bans. Avoid broad VPC
or phone-subnet ranges here.
[proxy.security.flood] is a raw packet flood guard applied before SIP method
rate limits. It is listener-scoped and source-IP based. XDP offloads UDP flood,
TCP flood, TCP SYN flood, TCP ACK flood, and ICMP/ICMPv6 flood token buckets
when [proxy.security.xdp] is enabled. ICMP is enforced as an interface-level
pseudo listener because ICMP has no SIP listener port.
When require_registered_invite_source = true, initial INVITE requests from
client-side peers must use a From AoR that has an active REGISTER binding, and
the source must match the registered source IP by default. Use
registered_invite_source_match = "ip-port" only for environments where the
client NAT source port is stable. The default "ip" mode is a source-IP-level
guard, not end-to-end identity authentication: multiple devices behind the same
public NAT IP can satisfy this check for each other. "ip-port" is stricter but
can reject legitimate clients when NAT source ports drift.
Build or preseed the binary geo cache with:
sigproxy geo-cache build --countries all --output /var/lib/sigproxy-rs/geo/geo.sgeo --retries 3 --allow-partialBuild or preseed the binary threat-intel cache with the built-in IPSum and Spamhaus DROP sources:
sigproxy threat-cache build --output /var/lib/sigproxy-rs/threat/threat.sthr --retries 3 --allow-partialThe Dockerfile downloads and builds the full geo cache and default threat cache
at image build time by default (GEO_COUNTRIES=all, GEO_RETRIES=3,
GEO_ALLOW_PARTIAL=true, THREAT_CACHE=true, THREAT_RETRIES=3,
THREAT_ALLOW_PARTIAL=true). Cache prefetch is fail-open by default
(GEO_FAIL_OPEN=true, THREAT_FAIL_OPEN=true): temporary upstream failures log
a build warning and continue without blocking the image. Set either build arg to
false when the image must contain a fresh cache or fail the build. Runtime
startup does not download geo or threat data unless
startup_refresh = "background" or "blocking" is explicitly configured. When
runtime refresh is enabled, request_retries and allow_partial apply to the
refresh path as well.
all expands to country and territory zones only; ipdeny aggregate zones such
as AP and EU are intentionally excluded because they overlap concrete
country ranges.
docker build -t sigproxy-rs .Set startup_refresh = "background" only when the running process should
refresh the geo cache after startup.
To skip geo download during image build, pass an empty build arg:
docker build --build-arg GEO_COUNTRIES= -t sigproxy-rs .When [proxy.security.xdp] is enabled, the Docker image also builds and ships
/usr/local/share/sigproxy/sigproxy_xdp.o. XDP offloads listener-scoped dynamic
IP blocklists, CIDR allow/deny rules, threat-intel CIDRs, geo country
allow/deny rules generated from geo.sgeo, and per-source-IP packet token
buckets. SIP semantic checks
such as REGISTER/INVITE AoR limits and registered-INVITE-source policy remain
in user space. XDP drop/pass counters are exposed as
proxy_xdp_packets_total{action="..."}. The userspace control plane loads,
attaches, and updates XDP maps directly through aya; runtime bpftool is not
required. When detach_stale = true, sigproxy uses the ip command from
iproute2 to remove only stale sigproxy_xdp programs before attaching; the
official image includes it. Running with XDP requires Linux privileges such as
CAP_NET_ADMIN and CAP_BPF plus access to bpffs at /sys/fs/bpf; otherwise
fail_open = true falls back to user-space security, while fail_open = false
rejects startup.
On the host, make sure bpffs is mounted before starting the container:
mountpoint -q /sys/fs/bpf || sudo mount -t bpf bpf /sys/fs/bpf
docker run --cap-add NET_ADMIN --cap-add BPF \
--mount type=bind,source=/sys/fs/bpf,target=/sys/fs/bpf \
sigproxy-rsFor Kubernetes, use hostNetwork: true, privileged security context, and a
hostPath mount for /sys/fs/bpf; see docs/kubernetes-xdp.md.
REGISTER and downstream OPTIONS requests are forwarded to the selected
upstream like other SIP requests. The proxy does not act as a registrar or
local OPTIONS responder on the normal signaling path.
For forwarded requests, the proxy:
- Adds a proxy
Via. - Records proxy branch routing metadata.
- Parses upstream responses.
- Verifies the top
Viabranch belongs to this proxy. - Removes the proxy
Via. - Sends the response back to the original client side.
INVITE responses may include multiple provisional responses before a final response. UDP uses a stable socket for UDP upstream responses; TCP upstream responses are dispatched by proxy branch through the reused upstream connection, including UDP client to TCP upstream forwarding.
Enable metrics:
[proxy.metrics]
enabled = true
bind_addr = "127.0.0.1:9100"Then scrape:
curl http://127.0.0.1:9100/metricsCurrent counters cover SIP requests, local responses, upstream responses, forwarded requests, forwarding errors, affinity lookups, and security drops. Runtime gauges cover UDP branch routes, TCP upstream connections, TCP branch routes, INVITE transaction routes, affinity bindings, location bindings, active security blocks, token bucket entries, and per-upstream health state with consecutive success/failure counters.
When [persistence] is enabled, metrics also expose
proxy_persistence_latest_event_seq, proxy_persistence_last_applied_seq,
proxy_persistence_event_rows, proxy_persistence_event_lag,
proxy_persistence_event_appends_total, and
proxy_persistence_sqlite_write_failures_total. Active-standby replication
pulls are tracked separately with proxy_ha_event_pulls_total,
proxy_ha_snapshot_pulls_total, and proxy_ha_snapshot_fallbacks_total.
See docs/benchmark.md.
Example local run:
python3 tools/sip_bench.py mock-upstream --bind 127.0.0.1:5080
cargo run --bin sigproxy -- run --config examples/single-node.toml
python3 tools/sip_bench.py udp --scenario invite --target 127.0.0.1:5060 --requests 10000 --concurrency 64Run checks:
cargo fmt --check
cargo check
cargo testThe project currently has focused unit tests for configuration validation, SIP message wrapping, registry parsing, affinity, routing, proxy forwarding, TCP framing, metrics, and selected active-standby HA boundary modules.
The current proxy is intentionally lightweight at the SIP transaction layer. It keeps the smallest routing state needed for SIP-aware affinity, correct response return paths, UDP client retransmission de-duplication, and final response replay. It does not own fork aggregation or dialog state as a B2BUA would.
This keeps the proxy closer to a SIP-aware load balancer while still handling important Layer-7 correctness that a pure Layer-4 load balancer cannot provide.