Firewall Service — SIP brute-force protection
The connect_freeswitch module ships with an out-of-process firewall
service that pairs with FreeSWITCH and blocks SIP brute-force attempts
at the kernel level. It is disabled by default and turned on with a
single toggle once you've added the supporting container.
What it does
Every SIP REGISTER / INVITE that FreeSWITCH sees produces an event on
the ESL bus. The firewall service listens, classifies the event, and
moves the source IP between six ipset tables that an iptables chain
in front of the SIP ports consults at line rate:
| ipset | TTL | What happens to traffic from listed IPs |
|---|---|---|
connect_fw_whitelist |
permanent | always ACCEPT |
connect_fw_blacklist |
permanent | always DROP (admin-managed) |
connect_fw_authenticated |
7 d (sliding) | ACCEPT — IP has registered successfully |
connect_fw_banned |
24 h | DROP — auto-ban after a failed authentication |
connect_fw_expire_short |
30 s | ACCEPT — challenge response window after a SIP 401 |
connect_fw_expire_long |
24 h | DROP — default-deny after a challenge is sent but not answered |
Both address families are covered: each table above is the IPv4
(family inet) set, and a 6-suffixed twin (connect_fw_whitelist6,
connect_fw_banned6, …) holds the IPv6 entries, consulted by an
identical connect_fw_voip chain installed via ip6tables. Whitelist
and blacklist entries in Odoo accept both families and are routed to
the right set automatically; on hosts without IPv6 (ipv6.disable=1)
the service logs an error for the v6 family and keeps protecting IPv4.
In addition, an iptables -m string filter at the bottom of the chain
DROPs known SIP-scanner User-Agents (friendly-scanner, sipvicious,
sipcli, VaxSIPUserAgent, sundayddr, sipsak, sip-scan) before
they even reach FreeSWITCH.
How a session flows
- Phone sends REGISTER. FreeSWITCH issues a 401 challenge → IP lands in
expire_short(30 s pass) andexpire_long(24 h default-deny). - Phone re-sends REGISTER with credentials.
- Correct →
sofia::registerevent → IP moves toauthenticated(7 days, sliding). All further packets ACCEPT. - Wrong →
sofia::register_attemptwithauth-result=FORBIDDENand/orsofia::register_failure→ IP moves tobanned(24 h). Subsequent packets DROP. - An INVITE without an established session emits
sofia::wrong_call_state→ treated as toll-fraud, IP banned.
A whitelisted IP / CIDR short-circuits the whole pipeline.
Components
┌──────────────┐ POST /firewall/sync (Bearer) ┌──────────────────┐
│ Odoo │ ─────────────────────────────────────▶│ firewall service │
│ (connect_fw) │ ◀── HTTP /freeswitch/firewall/api/* ──│ (oduist/ │
│ │ (Bearer; fetch/report) │ freeswitch- │
│ - whitelist │ │ firewall) │
│ - blacklist │ ESL events │ │
│ - events log │ ◀──────────── │ - ipset / iptables
│ - settings │ │ - HTTP / SSE
│ - agent st. │ │ dashboard
└──────────────┘ └─────────┬────────┘
│ ESL :8021
▼
┌──────────────┐
│ FreeSWITCH │
└──────────────┘
- Odoo owns the configuration, the static whitelist/blacklist, the
event audit log and the agent status singleton. It exposes a small
HTTP control plane under
/freeswitch/firewall/api/*that the service polls (config,whitelist,blacklist) and posts to (heartbeat,event,applied). - firewall service is a small async Python container; it talks to
FreeSWITCH via
mod_event_socket(ESL), to Odoo over plain HTTP authenticated with a shared Bearer token, and manipulatesipset/iptableson the host kernel. It also serves a Lit-based dashboard at/firewall/.
Both directions use the same shared secret
(connect.settings.firewall_service_token in Odoo, AGENT_TOKEN env
var on the service). There is no dedicated Odoo user for the service.
The data plane (ipset + iptables) lives in the host kernel and is
not affected by restarts of either container.
Installing the firewall service
The service container must:
- run with
network_mode: host(it needs to talk to ESL on127.0.0.1:8021and haveiptablessee the real outside traffic); - hold the
NET_ADMINcapability soipset/iptablessyscalls succeed against the host kernel.
Required environment variables
| Variable | Purpose |
|---|---|
ODOO_URL |
base URL of the Odoo instance (e.g. https://pbx.example.com). The service appends /freeswitch/firewall/api/* paths to it. |
AGENT_TOKEN |
shared Bearer token. Must match Firewall Service Token in Odoo settings. Used in both directions (this service → Odoo and Odoo → /firewall/sync on this service). The service refuses to start without it. |
FS_ESL_HOST |
usually 127.0.0.1 |
FS_ESL_PORT |
usually 8021 |
FS_ESL_PASSWORD |
password of FreeSWITCH mod_event_socket. The shipped FS image bakes in ConnectNGESLPassword; set FS_ESL_PASSWORD on both containers if you want a different value. |
HTTP_BIND_HOST, HTTP_BIND_PORT |
where the service listens. The production compose binds 127.0.0.1:8081 and exposes /firewall through Traefik. |
DASHBOARD_USER, DASHBOARD_PASSWORD |
basic-auth credentials for the dashboard / JSON API |
Optionally:
| Variable | Effect |
|---|---|
LOG_LEVEL |
INFO (default) or DEBUG |
CONFIG_CACHE_PATH |
local JSON cache (default /var/lib/connect-firewall/config.json) |
Docker Compose example
firewall:
image: oduist/freeswitch-firewall:2.1.1
network_mode: host
cap_add: [NET_ADMIN]
environment:
ODOO_URL: https://pbx.example.com
AGENT_TOKEN: <copy from Firewall Service Token in Odoo settings>
FS_ESL_HOST: 127.0.0.1
FS_ESL_PASSWORD: ConnectNGESLPassword
HTTP_BIND_HOST: 127.0.0.1
DASHBOARD_USER: admin
DASHBOARD_PASSWORD: <pick a strong password>
volumes:
- firewall-cache:/var/lib/connect-firewall
A ready preset for oduflow lives at
connect_freeswitch/deploy/firewall/oduflow-preset.yaml.
Setting up in Odoo
- Install or upgrade
connect_freeswitch—post_init_hook(or the per-version migration on upgrade) runs the missing-only deployment bootstrap, generates both service tokens, and creates the agent singleton. - Open Connect → FreeSWITCH → Configuration → Settings, page Firewall, as an admin:
- Toggle Firewall Enabled on.
- Set Firewall Service URL to where Traefik (or whichever reverse-proxy you use) reaches the service container.
- Firewall Service Token: leave the auto-generated value unchanged
during normal installation. Oduflow reads the protected
firewall_service_tokenwith a sudo Odoo shell and passes it directly as the service'sAGENT_TOKEN. For a manual deployment, retrieve the same setting through an administrative Odoo shell. Entering a value in this masked field is an explicit rotation and requires updating and restarting the service in the same maintenance window. - Adjust port lists and timeouts if you need to deviate from the defaults.
- Connect → FreeSWITCH → Firewall → Whitelist: add your trunk providers,
office NAT exits, anything you don't want auto-banned. Save —
connect.firewall.agent._trigger_sync()will POST to/firewall/syncimmediately. - Connect → FreeSWITCH → Firewall → Agent Status: should show the agent as online within one heartbeat interval (default 60 s).
Daily operations
- Active auto-bans — they live only in the kernel. View them on
the service's dashboard:
https://<your-host>/firewall/(basic-auth withDASHBOARD_USER/DASHBOARD_PASSWORD). Each row has an Unban button. - From Odoo — open
Connect → FreeSWITCH → Firewall → Events. Every row with Event Type = Automatic Ban gets a small unlock-icon button. Clicking it calls back into the service to remove the IP fromconnect_fw_bannedand writes amanual_unban_appliedaudit record. The button hides itself as soon as the IP is no longer banned. - Permanent block —
Connect → FreeSWITCH → Firewall → Blacklist, add an IP or CIDR. Useful for blocking entire VPS-provider subnets.
Settings reference
| Setting | Default | Effect |
|---|---|---|
| Firewall Enabled | False | Master switch. When off, the service still runs but reports firewall_enabled=False. |
| Firewall Service URL | See deployment | Odoo posts /firewall/sync here. In production set this to https://<freeswitch-host>; Traefik routes the /firewall prefix to the loopback-only service on 127.0.0.1:8081. |
| Firewall Service Token | generated | Shared Bearer secret. ≥24 chars, [A-Za-z0-9_-] only. The service uses the same value for both directions; restart the container after changing it. |
| Heartbeat Interval | 60 s | How often the service pings Odoo. |
| Event Retention | 30 days | How long the audit log is kept; the daily cron prunes older. |
| Firewall TCP/UDP Ports | 5060,5061,5080,5081 |
Where the chain hooks in. Must match the ports sofia actually listens on. |
| Auto-ban TTL | 86400 s (24 h) | How long an auto-ban stays in connect_fw_banned. |
| Trust TTL | 604800 s (7 d) | How long a successful registration buys trust. |
| Challenge Window | 30 s | expire_short lifetime. |
| Default-Deny TTL | 86400 s (24 h) | expire_long lifetime if the challenge is never answered. |
Troubleshooting
# 1a. Process-level liveness (no auth, always 200 while the service is up).
# Wire this to Kubernetes / Traefik / Docker liveness probes — it must
# not flap when Odoo is down, otherwise the orchestrator restarts the
# container in a loop while the upstream recovers.
curl -sk https://<host>/healthz
# 1b. Dependency-aware readiness check (no auth, the URL to point external
# monitoring at — Uptime Kuma, Prometheus blackbox exporter, etc.).
# Returns 200 + {"status":"ok","odoo":true,"esl":true} when both Odoo
# and FreeSWITCH ESL are reachable; 503 + {"status":"error","odoo":...,
# "esl":...} otherwise.
curl -sk -i https://<host>/firewall/healthz
# 1c. Rich JSON for the dashboard (auth required — Bearer token or basic).
curl -sk -u admin:<pw> https://<host>/firewall/api/heartbeat | jq
# 2. Is ESL really connected?
docker logs <firewall-container> | grep -E "ESL connected|Reconciler|AUTO-BAN"
# 3. Look at the actual kernel state (the "6"-suffixed sets and
# ip6tables are the IPv6 side).
ipset list connect_fw_banned
ipset list connect_fw_banned6
ipset list connect_fw_authenticated
iptables -L connect_fw_voip -v -n
ip6tables -L connect_fw_voip -v -n
# 4. Verify the chain is hooked into INPUT for both families.
iptables -L INPUT -v -n | grep connect_fw_voip
ip6tables -L INPUT -v -n | grep connect_fw_voip
If the agent stays offline in Odoo:
- check that
AGENT_TOKENin the service env matches Firewall Service Token in Odoo settings — the easiest mistake is rotating the token in Odoo but not redeploying the container; - check that
/freeswitch/firewall/api/heartbeaton Odoo is reachable from inside the firewall container (curl with the Bearer header should return{"ok":true}); - check that
/firewall/syncis reachable from inside the Odoo container — Odoo sends notifications to this URL, not the service to Odoo.
If you see events arrive in Odoo but no auto-bans land in ipset:
- the container is running without
NET_ADMIN—ipset addreturnsOperation not permitted, visible in the service logs; - the kernel does not have the
ip_set/xt_setmodules available — typical on hardened/minimal hosts.modprobe ip_seton the host fixes it. The IPv6 side additionally needsip6_tables/ip6table_filter; on hosts booted withipv6.disable=1the service logs an error for the v6 family and continues IPv4-only — that is the expected degradation, not a fault.
Architecture-level reference
See specs/decisions/014-freeswitch-firewall-service.md for the
design decisions behind this stack (six-table ipset model, direct
HTTP control plane, hardcoded UA blacklist, single-instance) and
specs/decisions/037-firewall-ipv6-support.md for how IPv6 was
layered in (parallel inet6 sets + ip6tables chain).