# V-Connect PBX — Firewall, Fail2Ban & Geo-Firewall Analysis

> **Scope.** This document describes the complete network-security stack of the
> V-Connect / ShiftBridge PBX: the nftables packet filter, the fail2ban
> intrusion-prevention layer, the country-based geo-firewall, and the GUI/DB
> layer that drives them. It covers the on-disk layout, the live runtime
> layout, the systemd persistence model, and how the whole thing self-heals on
> every `php artisan pbx:deploy` and on reboot.

---

## 1. Architecture overview

The PBX is a **hosted/cloud PBX**: SIP phones register from random public IPs
all over the world, so the base firewall policy is deliberately **ACCEPT** for
the SIP/RTP/Web service ports. Security is layered on top rather than relying on
a default-deny posture that would break roaming handsets.

There are **three cooperating layers**, ordered by the nftables hook priority at
which each attaches to the kernel `input` hook (lower number = runs first):

| Order | Layer | nftables object | Hook priority | Purpose |
|------|-------|-----------------|---------------|---------|
| 1 | **fail2ban bans** | `table inet f2b-table` / `f2b-chain` | `filter - 1` (−1) | Drops IPs that tripped a jail. Created/owned by fail2ban. |
| 2 | **Geo-firewall** | `table inet filter` / `chain PBX-GEO` | `-1` | Country allow/block (whitelist or blacklist). Owned by `GeoFirewallService`. |
| 3 | **Base service filter** | `table inet filter` / `chain input` | `filter` (0) | Opens the PBX service ports; policy `accept`. Owned by `pbx:deploy`. |

> Both the fail2ban chain and the PBX-GEO chain sit at priority −1, ahead of the
> main service chain at priority 0. A packet from a banned or geo-blocked source
> is dropped before it ever reaches the port-accept rules.

In addition there is a **legacy ipset layer** (`pbx_whitelist` / `pbx_blacklist`
hooked into the old `iptables` `INPUT` chain) used by the GUI Access-Control page
for manual allow/deny entries. This is the older enforcement path and coexists
with nftables.

### Component map

| Concern | Code | Live artefact |
|--------|------|---------------|
| Base ruleset + systemd unit | `PbxDeploy::setupFirewall()` | `/etc/nftables.conf`, `pbx-firewall.service` |
| Geo drop-in generation/apply | `app/Services/GeoFirewallService.php` | `/etc/nftables.d/pbx-geo.nft` |
| Offline IP→country lookup | `app/Services/GeoIpService.php` | `/usr/share/xt_geoip/*` + `geo_ip_cache` table |
| Manual allow/deny (ipset) | `app/Services/Firewall.php` | `pbx_whitelist` / `pbx_blacklist` ipsets |
| GUI controller | `app/Http/Controllers/FirewallController.php` | views under `resources/views/firewall/` |
| fail2ban jails | `scripts/fail2ban/jail.local`, `scripts/fail2ban/jail.d/pbx-weblogin.conf` | `/etc/fail2ban/jail.local`, `/etc/fail2ban/jail.d/` |
| fail2ban filters | `scripts/fail2ban/filter.d/*.conf` | `/etc/fail2ban/filter.d/` |
| Web-login protection install | `PbxDeploy::setupWebLoginProtection()` | `pbx-weblogin` jail + filter |
| Auth-failure logging | `App\Providers\AppServiceProvider::registerAuthFailureLogging` | `storage/logs/auth-failures.log` |

---

## 2. nftables — the base packet filter

### 2.1 Canonical config (`/etc/nftables.conf`)

Written verbatim by `PbxDeploy::setupFirewall()` on every deploy. It is a single
source of truth that is reloaded at boot and re-applied on each deploy:

```nft
#!/usr/sbin/nft -f
# VConnect PBX - canonical nftables ruleset
# Hosted PBX: phones connect from random public IPs
# Policy is ACCEPT - fail2ban handles bad actors at higher priority

flush ruleset

table inet filter {
        chain input {
                type filter hook input priority filter; policy accept;

                ct state established,related accept
                iif "lo" accept
                ct state invalid drop

                meta l4proto icmp accept
                meta l4proto ipv6-icmp accept

                tcp dport 2205 accept                 # SSH (non-standard port)
                tcp dport { 80, 443 } accept          # HTTP / HTTPS (GUI, ACME)

                udp dport { 5060, 5061 } accept       # SIP signalling
                tcp dport { 5060, 5061 } accept       # SIP over TCP/TLS

                tcp dport { 5063, 5161, 8089 } accept # alt-SIP / WSS (WebRTC)

                udp dport 10000-40000 accept          # RTP media

                tcp dport 5038 ip saddr 127.0.0.1 accept   # AMI (localhost only)
                tcp dport 3306 ip saddr 127.0.0.1 accept   # MariaDB (localhost only)
                tcp dport 8088 ip saddr 127.0.0.1 accept   # Asterisk HTTP (localhost only)
        }

        chain forward { type filter hook forward priority filter; policy accept; }
        chain output  { type filter hook output  priority filter; policy accept; }
}
```

Key design points:

- **`inet` family** — one table handles both IPv4 and IPv6.
- **Policy `accept`** on every chain. The firewall does not block by default; it
  relies on fail2ban + geo to drop bad sources at a higher (earlier) priority.
- **Stateful fast-path** — `ct state established,related accept` short-circuits
  reply traffic; `ct state invalid drop` discards malformed flows.
- **Loopback trusted** — `iif "lo" accept`.
- **Management ports are localhost-only** — AMI (5038), MariaDB (3306) and the
  Asterisk built-in HTTP (8088) accept only from `127.0.0.1`. They are never
  exposed to the network.
- **RTP** uses the wide `udp 10000-40000` media range.
- The file is written `0755` and is itself a runnable `nft -f` script
  (`#!/usr/sbin/nft -f` shebang).

### 2.2 Drop-in include model

`GeoFirewallService` persists country filtering as a **drop-in** rather than
editing the canonical file. The contract (constants in `GeoFirewallService`):

```php
const GEO_DIR      = '/etc/nftables.d';
const GEO_FILE     = '/etc/nftables.d/pbx-geo.nft';
const INCLUDE_LINE = 'include "/etc/nftables.d/*.nft"';
```

`setupFirewall()` and the GUI both call `GeoFirewallService::withInclude($config)`
before writing `/etc/nftables.conf`, which appends the `include` line if it is
not already present. Because the include is part of the canonical config, the
geo chain is **rebuilt every time the base ruleset loads** and can no longer be
silently wiped by a `flush ruleset`.

> **Live state note (dev box, at time of writing).** No geo countries are
> currently configured, so `/etc/nftables.d/pbx-geo.nft` does not exist and the
> canonical config carries no `include` line. The include + drop-in are
> (re)generated automatically the moment a geo rule is saved/applied or the next
> deploy runs with geo rows present. This is expected: the geo layer is dormant
> until used.

### 2.3 Live runtime layout

`nft list ruleset` on the running box shows the layers stacked as designed:

```
table inet filter {            # base service filter (priority 0)
    chain input  { ... }
    chain forward { ... }
    chain output { ... }
}
table inet f2b-table {         # fail2ban (priority filter - 1 = -1)
    set addr-set-asterisk-tcp { type ipv4_addr; elements = { ... } }
    set addr-set-asterisk-udp { ... }
    chain f2b-chain { type filter hook input priority filter - 1; ... }
}
```

When geo is active a third object appears — the `pbxgeo4`/`pbxgeo6` sets and the
`PBX-GEO` chain — also at priority −1, inside `table inet filter`.

---

## 3. Geo-firewall — country-based allow/block

### 3.1 Why a custom implementation

nftables has **no native GeoIP match** (that was an `iptables` + `xt_geoip`
feature). The PBX therefore builds native nftables **interval sets** from the
offline `xt_geoip` database and matches source IPs against them.

### 3.2 `GeoIpService` — offline IP → country

`app/Services/GeoIpService.php` reads the binary `xt_geoip` country files:

- `/usr/share/xt_geoip/<CC>.iv4` — sorted `[start,end]` IPv4 range pairs.
- `/usr/share/xt_geoip/<CC>.iv6` — the IPv6 equivalent.

Lookups (`lookup()`, `lookupMany()`) binary-search the sorted pairs and **cache**
results in the `geo_ip_cache` table so repeated GUI page loads stay fast.
`rangesForCountries()` returns the range list as nftables interval-set element
strings (e.g. `1.2.3.0-1.2.5.255`). `countryName()` resolves a 2-letter code to
a display name via PHP `intl`, falling back to a built-in `NAMES` constant.

`GeoIpService` is also used by the fail2ban GUI page to label each banned IP with
its country flag.

### 3.3 `GeoFirewallService` — generate & apply the drop-in

Reads the `geo_firewalls` table (rows of `country_code` + `action` =
`allow`/`block`) and decides the **mode**:

- **whitelist** — any `allow` rows exist ⇒ allow ONLY the listed countries, drop
  everything else.
- **blacklist** — only `block` rows exist ⇒ drop the listed countries, allow the
  rest.
- **none/inactive** — no rows ⇒ geo filtering disabled.

The generated `/etc/nftables.d/pbx-geo.nft` re-opens `table inet filter` to add
the country sets and the `PBX-GEO` chain:

```nft
table inet filter {
  set pbxgeo4 { type ipv4_addr; flags interval; elements = { ... } }
  set pbxgeo6 { type ipv6_addr; flags interval; elements = { ... } }

  chain PBX-GEO {
    type filter hook input priority -1; policy accept;

    # --- always-return safety exemptions (never lock yourself out) ---
    ip saddr { 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } return
    ct state established,related return        # replies to our own outbound
    tcp dport 80 return                        # keep Let's Encrypt/ACME reachable
    <explicit IP allow-list from firewall_access_controls action=allow> return

    # --- enforcement ---
    # whitelist mode:
    ip  saddr @pbxgeo4 return
    ip6 saddr @pbxgeo6 return
    drop
    # blacklist mode (instead of the above):
    ip  saddr @pbxgeo4 drop
    ip6 saddr @pbxgeo6 drop
    return
  }
}
```

Safety-by-design: before any country test, the chain **returns** (i.e. defers to
the normal filter) for private/loopback ranges, established connections, port 80
(so ACME HTTP-01 cert renewal never breaks), and any IP explicitly allow-listed
in `firewall_access_controls`. This makes it very hard to lock yourself out even
with an aggressive whitelist.

### 3.4 Service methods

| Method | Purpose |
|--------|---------|
| `regenerate()` | Rebuild `/etc/nftables.d/pbx-geo.nft` from the DB. Returns `{mode, v4, v6, written}`. |
| `withInclude($config)` | Idempotently append the `include "/etc/nftables.d/*.nft"` line to a canonical-config string. |
| `applyLive()` | Regenerate + validate with `nft -c` (dry-run) **before** applying, then load. Returns `{ok, mode, v4, v6, error, debug}`. |
| `chainLoaded()` | True if `PBX-GEO` chain is currently in the live ruleset. |
| `ensureDir()` | Create `/etc/nftables.d` if missing. |

`applyLive()` validating with `nft -c` first means a malformed geo set can never
take down the whole firewall — a bad ruleset is rejected before it is loaded.

---

## 4. Manual access control — ipset layer (`app/Services/Firewall.php`)

The GUI **Access Control** page stores manual allow/deny entries in
`firewall_access_controls` and enforces them immediately at the OS level via
ipset + the legacy `iptables` INPUT chain:

- `ensureSets()` — `ipset create pbx_whitelist hash:net -exist` and the matching
  `pbx_blacklist`.
- `ensureHooks()` — idempotently inserts, at the top of `iptables INPUT`:
  1. `-m set --match-set pbx_whitelist src -j ACCEPT`
  2. `-m set --match-set pbx_blacklist src -j DROP`
- `addAllow($ip)` — normalises to CIDR (`/32` or `/128`), adds to the whitelist
  set, removes from blacklist, **adds the IP to fail2ban `ignoreip`**, inserts an
  `accept` rule into `f2b-table/f2b-chain` so an allow-listed IP bypasses any
  active ban, and unbans it from all jails.
- `addDeny($ip)` — adds to the blacklist set, removes from whitelist.
- `removeAllow()` / `removeDeny()` — reverse the above, including pulling the
  IP back out of `ignoreip` and removing the per-IP accept rule from `f2b-chain`.
- Helpers: `inWhitelist()`, `inBlacklist()`, `listSet()`, `fail2banStatus()`,
  `recentScanners()` (greps `/var/log/asterisk/messages` for "No matching
  endpoint" notices to surface scanner IPs), `status()`.

`normalizeCidr()` adds `/32` to bare IPv4 and `/128` to bare IPv6 so set
membership tests are consistent.

> Note: this ipset path predates the nftables migration and runs through the
> legacy iptables compatibility layer. The geo chain references the same
> `firewall_access_controls` allow rows for its exemptions, so a single allow
> entry is honoured by both layers.

---

## 5. fail2ban — intrusion prevention

### 5.1 Global defaults (`jail.local [DEFAULT]`)

```ini
bantime  = 3600          # 1 hour
findtime = 600           # 10-minute detection window
maxretry = 5
banaction          = nftables-multiport
banaction_allports = nftables-allports
ignoreip = 127.0.0.1/8 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
```

Bans are enforced through the **nftables** ban actions, which is why fail2ban
owns `table inet f2b-table` / `chain f2b-chain` at priority −1. The GUI's
"allow" action edits the `ignoreip` line (via `Firewall::addToFail2BanIgnore()`),
which is why deploy never overwrites `jail.local` blindly.

### 5.2 Jails

| Jail | Port(s) | Log watched | maxretry | findtime | bantime | Notes |
|------|---------|-------------|----------|----------|---------|-------|
| `sshd` | 2205 | `/var/log/auth.log` | 3 | 600 | 86400 (1d) | SSH brute-force. |
| `asterisk-pjsip` | 5060,5061 udp+tcp | `/var/log/asterisk/messages` | 5 | 600 | 3600 | SIP registration brute-force. |
| `asterisk-ami` | 5038 | `/var/log/asterisk/messages` | 3 | 300 | 86400 (1d) | Manager-interface brute-force. |
| `asterisk-security` | 5060,5061 udp+tcp | `/var/log/asterisk/security` | 3 | 600 | 7200 (2h) | All Asterisk SecurityEvents. |
| `asterisk-scanner` | 5060,5061 udp+tcp | `/var/log/asterisk/messages` | 2 | 3600 | 86400 (1d) | Aggressive ban for known SIP scanners. |
| `pbx-weblogin` | 80,443 | `storage/logs/auth-failures.log` | 5 | (default) | 3600 | GUI / WebRTC login brute-force. Installed from `jail.d/`. |
| `recidive` | — | `/var/log/fail2ban.log` | 3 | 86400 | **604800 (1 week)** | Repeat offenders; uses `nftables-allports`. |

> **Removed jail — `[pbx-web]`.** An older access-log-based web jail was deleted
> because Laravel returns HTTP `302` on **both** successful and failed logins, so
> the web-server status code can't distinguish them — it banned legitimate users.
> It was replaced by `pbx-weblogin`, which watches a dedicated app-level
> auth-failure log containing real failures only.

### 5.3 Filters (`scripts/fail2ban/filter.d/`)

**`asterisk-security.conf`** — matches the structured Asterisk security log:

```
SecurityEvent="FailedACL" | "InvalidAccountID" | "ChallengeResponseFailed"
            | "InvalidPassword" | "UnexpectedAddress" | "RequestBadFormat"
... RemoteAddress="IPV[46]/(udp|tcp)/<HOST>/<port>"
```

**`asterisk-scanner.conf`** — catches automated SIP toolkits:

```
No matching endpoint found for '...' from '<HOST>'
Request from '"friendly-scanner"...' failed for '<HOST>'
Request from '"sipvicious"...'      failed for '<HOST>'
Request from '"sundayddr"...'       failed for '<HOST>'
Request from '"sip-scan"...'        failed for '<HOST>'
res_pjsip Request '...' from '<HOST>' could not be authenticated
```

**`pbx-weblogin.conf`** — matches the Laravel auth-failure log:

```
failregex = Failed web login from <HOST>
            Login lockout from <HOST>
datepattern = ^\[%%Y-%%m-%%d %%H:%%M:%%S\]
```

Those log lines are written by
`AppServiceProvider::registerAuthFailureLogging()` to
`storage/logs/auth-failures.log`, e.g.
`[2026-06-17 08:00:00] local.WARNING: Failed web login from 1.2.3.4 user=admin@x`.

Additional filters in the repo: `asterisk-pjsip.conf`, `asterisk-ami.conf`, and
the legacy `pbx-web.conf`.

### 5.4 Web-login protection install (`PbxDeploy::setupWebLoginProtection()`)

On each deploy:

1. Copies `scripts/fail2ban/filter.d/pbx-weblogin.conf` →
   `/etc/fail2ban/filter.d/` and `scripts/fail2ban/jail.d/pbx-weblogin.conf` →
   `/etc/fail2ban/jail.d/`.
2. Ensures `storage/logs/auth-failures.log` exists and is owned by `www-data`
   `0644`, so the jail starts cleanly before the first failure is logged.
3. `fail2ban-client reload` (non-disruptive — existing bans/jails preserved).

This method **only touches its own files** — it never rewrites `jail.local`,
which the GUI edits for `ignoreip`.

---

## 6. GUI & database layer (`FirewallController`)

The Firewall section of the admin GUI is DB-driven; the database is the source
of truth and the controller writes the live OS rules.

### 6.1 Database tables

| Table | Used by | Columns of interest |
|-------|---------|---------------------|
| `firewall_services` | Services tab | `name, protocol, port, ip_range, enabled` |
| `firewall_access_controls` | Access Control tab + geo exemptions | `name, ip_address, action(allow/deny), description` |
| `geo_firewalls` | Geo tab | `country_code, country_name, action(allow/block)` |
| `geo_ip_cache` | `GeoIpService` | cached IP→country results |

### 6.2 Controller actions

- **Services** (`services`, `storeService`, `destroyService`, `servicesDefaults`,
  `syncFromIptables`): CRUD on `firewall_services`; `applyServicesToNft()`
  regenerates the **entire** `/etc/nftables.conf` from enabled rows, re-includes
  the geo drop-in via `withInclude()`, loads it, then restarts `fail2ban` and
  `pbx-firewall` so their chains reattach. `servicesDefaults()` seeds the
  standard PBX port set; `syncFromIptables()` imports existing live rules into
  the DB.
- **Access Control** (`access`, `storeAccess`, `destroyAccess`, `unbanIp`):
  CRUD on `firewall_access_controls`, enforced immediately through the
  `Firewall` ipset helpers; shows live fail2ban bans and live ipset membership.
- **Geo** (`geo`, `storeGeo`, `destroyGeo`, `bulkSaveGeo`, `applyGeo`): CRUD on
  `geo_firewalls`; `applyGeo()` delegates to `GeoFirewallService::applyLive()`
  (validate-then-load). `bulkSaveGeo()` truncates + re-inserts the full country
  selection and applies in one step ("Save & Apply").
- **Fail2ban** (`fail2ban`, `fail2banUnban`): parses `fail2ban-client status`
  per jail, reads accurate ban/expiry times from
  `fail2ban-client get <jail> banip --with-time` (authoritative, not log
  scraping), tails `/var/log/fail2ban.log` for the recent-events table (only the
  last ~1000 lines — never the whole file, to avoid OOM on large logs), and
  resolves each banned IP's country via `GeoIpService`.
- **Raw Rules** (`rules`): read-only `nft list ruleset` snapshot.

`runFail2ban()` and the nft/ipset helpers all shell out with `sudo`, so the
web user (`www-data`) needs the corresponding sudoers entries.

---

## 7. Persistence & self-healing

### 7.1 systemd units

`setupFirewall()` installs **`pbx-firewall.service`**:

```ini
[Unit]
Description=V-Connect PBX Firewall - apply canonical nftables ruleset
Wants=network-pre.target
Before=network-pre.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/sbin/nft -f /etc/nftables.conf

[Install]
WantedBy=multi-user.target
```

It applies the ruleset **early in boot, before the network comes up** — the
documented systemd pattern for a boot firewall. (A previous version ordered
itself `After=network-pre.target` AND `Before=network.target`, which formed an
ordering cycle systemd had to break on every boot.) fail2ban starts later
(post-network) and layers its `f2b-table` on top automatically.

Both `pbx-firewall.service` and the distro `nftables.service` are enabled.

### 7.2 What `pbx:deploy` does every run (`setupFirewall()`)

1. Build the canonical nftables config string.
2. `GeoFirewallService::regenerate()` → rewrite the geo drop-in from the DB.
3. `withInclude()` → ensure the canonical config includes the drop-in.
4. Write `/etc/nftables.conf` (`0755`) and load it (`nft -f`), aborting on
   non-zero return so a broken ruleset is not left half-applied.
5. (Re)write + enable `pbx-firewall.service` and `nftables.service`.
6. `systemctl restart fail2ban` so its ban table reattaches on top of the clean
   ruleset.
7. Run `/usr/local/bin/f2b-whitelist-sync.sh` if present (re-applies the
   whitelist after the fail2ban restart).
8. `setupWebLoginProtection()` reinstalls the web-login jail/filter and reloads
   fail2ban.

Because all of this is idempotent and DB-driven, **every deploy and every reboot
reconstructs the full firewall + geo + fail2ban stack from source of truth**.
Manual `nft`/ipset tweaks that were not persisted to the DB or the canonical
config are intentionally not preserved — the system always converges back to the
declared state.

### 7.3 Failure-safety summary

- Geo rulesets are validated with `nft -c` before loading (`applyLive()`).
- The base policy is `accept`, so a deploy hiccup can't black-hole all traffic.
- The geo chain always-returns for private ranges, established flows, port 80,
  and explicit allow-listed IPs — protecting against self-lockout.
- The boot firewall applies before the network is up, closing the "open until
  the app starts" window.

---

## 8. Port reference

| Port | Proto | Exposure | Service |
|------|-------|----------|---------|
| 2205 | tcp | public | SSH |
| 80 | tcp | public | HTTP (GUI redirect, ACME HTTP-01) |
| 443 | tcp | public | HTTPS (admin GUI, WebRTC signalling page) |
| 5060 | udp+tcp | public | SIP |
| 5061 | udp+tcp | public | SIP-TLS |
| 5063 | tcp | public | alternate SIP |
| 5161 | tcp | public | alternate SIP/TLS |
| 8089 | tcp | public | WSS (WebRTC websocket) |
| 10000–40000 | udp | public | RTP media |
| 5038 | tcp | localhost | Asterisk AMI |
| 3306 | tcp | localhost | MariaDB |
| 8088 | tcp | localhost | Asterisk built-in HTTP |

---

## 9. Operational quick-reference

```bash
# Live ruleset
nft list ruleset

# fail2ban overview + a single jail
fail2ban-client status
fail2ban-client status asterisk-pjsip

# Accurate ban times / expiry
fail2ban-client get asterisk-pjsip banip --with-time

# Unban an IP
fail2ban-client set <jail> unbanip <ip>

# Reapply the whole firewall (also rebuilds geo + restarts fail2ban)
php artisan pbx:deploy        # runs setupFirewall() + setupWebLoginProtection()

# Validate a candidate ruleset without loading it
nft -c -f /etc/nftables.conf

# ipset manual lists (legacy access-control layer)
ipset list pbx_whitelist
ipset list pbx_blacklist
```

**Log locations**

| Log | Consumed by |
|-----|-------------|
| `/var/log/auth.log` | `sshd` jail |
| `/var/log/asterisk/messages` | `asterisk-pjsip`, `asterisk-ami`, `asterisk-scanner` |
| `/var/log/asterisk/security` | `asterisk-security` |
| `/var/log/fail2ban.log` | `recidive` jail + GUI recent-events table |
| `storage/logs/auth-failures.log` | `pbx-weblogin` jail |
