diff --git a/README.md b/README.md
index 8426918..c3bfe33 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,69 @@ The WAF module includes:
- NetSapiens exclusion rules for admin UI, ns-api, SiPbx, NqsProxy, and iNSight health checks
- CRS tuning for allowed HTTP methods and content types used by NetSapiens
+### Path Restrictions (.htaccess)
+
+Restrict access to sensitive NetSapiens paths (admin UI, API, NDP, recording) using `.htaccess` IP allowlists:
+
+```bash
+# Show current restriction status
+nssec waf restrict show
+
+# Create .htaccess restrictions (interactive — shows existing IPs, asks to keep or overwrite)
+sudo nssec waf restrict init
+
+# Specify IPs directly
+sudo nssec waf restrict init --ip 1.1.1.1 --ip 1.2.3.0/22
+
+# Add/remove individual IPs
+sudo nssec waf restrict add 1.1.1.1
+sudo nssec waf restrict remove 1.1.1.1
+
+# Re-deploy after a NetSapiens package upgrade overwrites .htaccess files
+sudo nssec waf restrict reapply
+```
+
+The `init` command will:
+- Detect which paths apply to the current server type (Core, NDP, Recording, Combo)
+- Show any existing IPs from current `.htaccess` files and ask whether to keep or overwrite them
+- Always include `127.0.0.1` automatically
+- Save the IP list to `/etc/nssec/restrict-ips.json` so it survives NS package upgrades
+
+**Protected paths:**
+
+| Target | Path | Server Types |
+|--------|------|:------------:|
+| SiPbx Admin UI | `/usr/local/NetSapiens/SiPbx/html/SiPbx/` | Core, Combo |
+| ns-api | `/usr/local/NetSapiens/SiPbx/html/ns-api/` | Core, Combo |
+| NDP Endpoints | `/usr/local/NetSapiens/ndp/` | NDP, Combo |
+| LiCf Recording | `/usr/local/NetSapiens/LiCf/html/LiCf/` | Recording, Combo |
+
+### mod_evasive (HTTP Flood Protection)
+
+mod_evasive is managed independently from the WAF and provides application-layer DDoS protection. It has **no detection-only mode** — when enabled it will block IPs that exceed request thresholds (HTTP 403).
+
+```bash
+# Check mod_evasive status
+nssec waf evasive status
+
+# Enable with standard profile (high thresholds — safe default)
+sudo nssec waf evasive enable
+
+# Enable with strict profile (tuned for NetSapiens traffic)
+sudo nssec waf evasive enable --profile strict
+
+# Disable
+sudo nssec waf evasive disable
+```
+
+**Profiles:**
+| Profile | DOSPageCount | DOSSiteCount | DOSBlockingPeriod | Use Case |
+|---------|:---:|:---:|:---:|------|
+| `standard` | 100 req/page/s | 500 req/IP/s | 10s | Safe default — only catches extreme floods |
+| `strict` | 15 req/page/s | 60 req/IP/s | 60s | Tuned for NetSapiens traffic patterns |
+
+Start with `standard` and review the Apache API Usage dashboard and mod_evasive block logs before switching to `strict`. Block events are logged to `/var/log/apache2/mod_evasive.log` for Loki/Grafana ingestion.
+
## Server Types
| Component | Core | NDP | Recording | QoS |
@@ -121,6 +184,7 @@ Pre-built dashboards are available for import into your Grafana/iNSight instance
- `api.json` — API v1/v2 request rate monitoring (Prometheus)
- `apacheApiUsage.json` — Apache access log analysis by IP and path (Loki)
- `modsecurityWaf.json` — ModSecurity WAF event analysis: severity, attacking IPs, triggered rules, targeted URIs (Loki)
+- `modEvasive.json` — mod_evasive HTTP flood protection: blocked IPs, block rate, repeat offenders (Loki)
## Related Projects
@@ -142,6 +206,7 @@ These community projects provide additional NetSapiens security capabilities:
- [x] ModSecurity installation and configuration with OWASP CRS
- [x] NetSapiens-specific WAF exclusion rules
- [x] ModSecurity WAF monitoring dashboard
+- [x] .htaccess IP restrictions for sensitive paths
- [ ] MySQL password rotation across all NS services
- [ ] Fail2ban SIP plugin for NetSapiens
diff --git a/docs/waf-setup-guide.md b/docs/waf-setup-guide.md
index ebaf020..14bb424 100644
--- a/docs/waf-setup-guide.md
+++ b/docs/waf-setup-guide.md
@@ -35,6 +35,8 @@ Optionally install mod_evasive for HTTP flood protection alongside ModSecurity:
sudo apt-get install -y libapache2-mod-evasive
```
+> **Note:** mod_evasive has **no detection-only mode**. When enabled it will block IPs that exceed request thresholds (HTTP 403). See [mod_evasive Configuration](#mod_evasive-configuration) for details on threshold tuning.
+
## Step 2: Enable the Apache Module
```bash
@@ -441,6 +443,163 @@ SecRuleRemoveById RULE_ID
- [Christian Folini's CRS Tuning Tutorials](https://www.netnea.com/cms/apache-tutorials/)
- [CRS Paranoia Levels Explained](https://coreruleset.org/docs/concepts/paranoia_levels/)
+## mod_evasive Configuration
+
+mod_evasive provides application-layer HTTP flood and DDoS protection. Unlike ModSecurity, it has **no detection-only mode** — when enabled it will return HTTP 403 to IPs that exceed thresholds.
+
+### Understanding the thresholds
+
+| Directive | Description |
+|-----------|-------------|
+| `DOSPageCount` | Max requests to the same page per IP per interval |
+| `DOSSiteCount` | Max total requests from one IP per interval |
+| `DOSPageInterval` / `DOSSiteInterval` | Sliding window in seconds |
+| `DOSBlockingPeriod` | How long (seconds) an IP is blocked |
+| `DOSWhitelist` | IPs excluded from blocking (RFC 1918 ranges by default) |
+
+### Threshold profiles
+
+If using `nssec`, two profiles are available:
+
+| Profile | DOSPageCount | DOSSiteCount | DOSBlockingPeriod | Use Case |
+|---------|:---:|:---:|:---:|------|
+| `standard` (default) | 100 | 500 | 10s | Safe default — only catches extreme floods |
+| `strict` | 15 | 60 | 60s | Tuned for NetSapiens traffic (~270 req/s sustained) |
+
+```bash
+# Enable with standard profile (recommended starting point)
+sudo nssec waf evasive enable
+
+# Switch to strict after reviewing traffic
+sudo nssec waf evasive enable --profile strict
+```
+
+### Manual configuration
+
+If configuring manually, edit `/etc/apache2/mods-available/evasive.conf`:
+
+```apache
+
+ DOSHashTableSize 3097
+ DOSPageCount 100
+ DOSSiteCount 500
+ DOSPageInterval 1
+ DOSSiteInterval 1
+ DOSBlockingPeriod 10
+ DOSLogDir /var/log/apache2/mod_evasive
+
+ # Structured logging for Loki/Grafana
+ DOSSystemCommand "/bin/sh -c 'echo $(date -Is) action=blocked src_ip=%s >> /var/log/apache2/mod_evasive.log'"
+
+ # Whitelist internal traffic
+ DOSWhitelist 127.0.0.1
+ DOSWhitelist 10.*.*.*
+ DOSWhitelist 192.168.*.*
+
+```
+
+Create the log directory:
+
+```bash
+sudo mkdir -p /var/log/apache2/mod_evasive
+```
+
+### Tuning recommendations
+
+1. **Start with the standard profile** (or high thresholds manually) to avoid blocking legitimate traffic
+2. **Review traffic patterns** using the Apache API Usage dashboard (`insight/apacheApiUsage.json`) or access logs
+3. **Monitor block events** in `/var/log/apache2/mod_evasive.log` and the mod_evasive dashboard (`insight/modEvasive.json`)
+4. **Lower thresholds gradually** once you understand your traffic baseline
+5. **Always whitelist** internal service IPs and monitoring endpoints
+
+### Enabling and disabling
+
+mod_evasive is managed independently from ModSecurity. Enabling/disabling the WAF (`nssec waf enable`/`nssec waf disable`) does **not** affect mod_evasive.
+
+```bash
+# Check status
+nssec waf evasive status
+
+# Enable/disable independently
+sudo nssec waf evasive enable
+sudo nssec waf evasive disable
+```
+
+## Path Restrictions (.htaccess)
+
+NetSapiens recommends restricting access to sensitive directories using `.htaccess` IP allowlists. This limits who can reach the admin login page, API, and provisioning endpoints.
+
+### Which paths to protect
+
+| Target | .htaccess Path | Server Types |
+|--------|---------------|:------------:|
+| SiPbx Admin UI | `/usr/local/NetSapiens/SiPbx/html/SiPbx/.htaccess` | Core, Combo |
+| ns-api | `/usr/local/NetSapiens/SiPbx/html/ns-api/.htaccess` | Core, Combo |
+| NDP Endpoints | `/usr/local/NetSapiens/ndp/.htaccess` | NDP, Combo |
+| LiCf Recording | `/usr/local/NetSapiens/LiCf/html/LiCf/.htaccess` | Recording, Combo |
+
+### .htaccess format
+
+Each `.htaccess` file should follow this format:
+
+```apache
+
+ Order allow,deny
+ Allow from 127.0.0.1
+ Allow from X.X.X.X
+ Allow from 1.1.1.1
+ Allow from 2.2.2.2
+
+```
+
+**IPs to include:**
+- `127.0.0.1` — required for internal NS service communication
+- NetSapiens support IPs — so support can access your admin UI for support
+- Your admin office IP(s) — for your own management access
+
+### Using nssec
+
+```bash
+# Show current restriction status across all applicable paths
+nssec waf restrict show
+
+# Create/update .htaccess restrictions interactively
+# Shows existing IPs and asks whether to keep or overwrite them
+sudo nssec waf restrict init
+
+# Or specify IPs directly on the command line
+sudo nssec waf restrict init --ip 1.1.1.1 --ip 1.2.3.0/22
+
+# Add a single IP to all managed .htaccess files
+sudo nssec waf restrict add 1.1.1.1
+
+# Remove an IP (cannot remove 127.0.0.1)
+sudo nssec waf restrict remove 2.2.2.2
+
+# Re-deploy after a NetSapiens package upgrade overwrites .htaccess files
+sudo nssec waf restrict reapply
+```
+
+### Surviving NS package upgrades
+
+NetSapiens package upgrades can overwrite `.htaccess` files. The `nssec waf restrict init` command saves the IP list to `/etc/nssec/restrict-ips.json`. After an upgrade, run:
+
+```bash
+sudo nssec waf restrict reapply
+```
+
+This re-creates all `.htaccess` files from the cached IP list.
+
+### Manual configuration
+
+If configuring manually, create the `.htaccess` file in each applicable directory with the format shown above. Ensure `127.0.0.1` is always included.
+
+After creating or modifying `.htaccess` files, test and reload Apache:
+
+```bash
+sudo apache2ctl configtest && sudo systemctl reload apache2
+```
+
## File Summary
| File | Purpose |
@@ -450,7 +609,10 @@ SecRuleRemoveById RULE_ID
| `/etc/modsecurity/crs/crs-setup.conf` | CRS settings (paranoia level, thresholds) |
| `/etc/modsecurity/crs/rules/*.conf` | CRS rule files (do not edit) |
| `/etc/apache2/mods-available/security2.conf` | Apache Include directives |
+| `/etc/apache2/mods-available/evasive.conf` | mod_evasive threshold configuration |
| `/var/log/apache2/modsec_audit.log` | Audit log for triggered rules |
+| `/var/log/apache2/mod_evasive.log` | mod_evasive block event log |
+| `/var/log/apache2/mod_evasive/` | Per-IP block files (native mod_evasive) |
## Complementary Security Tools
diff --git a/insight/modEvasive.json b/insight/modEvasive.json
new file mode 100644
index 0000000..ff64426
--- /dev/null
+++ b/insight/modEvasive.json
@@ -0,0 +1,1051 @@
+{
+ "annotations": {
+ "list": [
+ {
+ "builtIn": 1,
+ "datasource": {
+ "type": "datasource",
+ "uid": "grafana"
+ },
+ "enable": true,
+ "hide": true,
+ "iconColor": "rgba(0, 211, 255, 1)",
+ "name": "Annotations & Alerts",
+ "target": {
+ "limit": 100,
+ "matchAny": false,
+ "tags": [],
+ "type": "dashboard"
+ },
+ "type": "dashboard"
+ }
+ ]
+ },
+ "description": "Dashboard for monitoring mod_evasive HTTP flood protection events, blocked IPs, and block frequency",
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 1,
+ "id": null,
+ "links": [],
+ "liveNow": false,
+ "panels": [
+ {
+ "collapsed": false,
+ "gridPos": {
+ "h": 1,
+ "w": 24,
+ "x": 0,
+ "y": 0
+ },
+ "id": 100,
+ "panels": [],
+ "title": "Overview Statistics",
+ "type": "row"
+ },
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "${loki_datasource}"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "thresholds"
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "yellow",
+ "value": 50
+ },
+ {
+ "color": "red",
+ "value": 200
+ }
+ ]
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 4,
+ "w": 4,
+ "x": 0,
+ "y": 1
+ },
+ "id": 101,
+ "options": {
+ "colorMode": "value",
+ "graphMode": "area",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": [
+ "sum"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto"
+ },
+ "pluginVersion": "9.1.6",
+ "targets": [
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "${loki_datasource}"
+ },
+ "editorMode": "code",
+ "expr": "sum(count_over_time({instance=~\"$node\", job=\"mod_evasive\"} |= \"action=blocked\" |~ `(?i)$ip_filter` [$__range]))",
+ "queryType": "range",
+ "refId": "A"
+ }
+ ],
+ "title": "Total Blocks",
+ "type": "stat"
+ },
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "${loki_datasource}"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "thresholds"
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "yellow",
+ "value": 5
+ },
+ {
+ "color": "red",
+ "value": 20
+ }
+ ]
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 4,
+ "w": 4,
+ "x": 4,
+ "y": 1
+ },
+ "id": 102,
+ "options": {
+ "colorMode": "value",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": [
+ "sum"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto"
+ },
+ "pluginVersion": "9.1.6",
+ "targets": [
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "${loki_datasource}"
+ },
+ "editorMode": "code",
+ "expr": "count(sum by (src_ip) (count_over_time({instance=~\"$node\", job=\"mod_evasive\"} |= \"action=blocked\" |~ `(?i)$ip_filter` | regexp `src_ip=(?P[\\d\\.]+)` [$__range])))",
+ "queryType": "instant",
+ "refId": "A"
+ }
+ ],
+ "title": "Unique Blocked IPs",
+ "type": "stat"
+ },
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "${loki_datasource}"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisCenteredZero": false,
+ "axisColorMode": "text",
+ "axisLabel": "blocks",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "bars",
+ "fillOpacity": 50,
+ "gradientMode": "scheme",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "never",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "none"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "yellow",
+ "value": 10
+ },
+ {
+ "color": "red",
+ "value": 50
+ }
+ ]
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 16,
+ "x": 8,
+ "y": 1
+ },
+ "id": 103,
+ "options": {
+ "legend": {
+ "calcs": [
+ "sum",
+ "max",
+ "mean"
+ ],
+ "displayMode": "table",
+ "placement": "bottom",
+ "showLegend": true
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "pluginVersion": "9.1.6",
+ "targets": [
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "${loki_datasource}"
+ },
+ "editorMode": "code",
+ "expr": "sum(count_over_time({instance=~\"$node\", job=\"mod_evasive\"} |= \"action=blocked\" |~ `(?i)$ip_filter` [$__interval]))",
+ "legendFormat": "Blocks",
+ "queryType": "range",
+ "refId": "A"
+ }
+ ],
+ "title": "Block Rate Over Time",
+ "type": "timeseries"
+ },
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "${loki_datasource}"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "thresholds"
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "blue",
+ "value": null
+ },
+ {
+ "color": "yellow",
+ "value": 60
+ },
+ {
+ "color": "red",
+ "value": 300
+ }
+ ]
+ },
+ "unit": "s"
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 4,
+ "w": 4,
+ "x": 0,
+ "y": 5
+ },
+ "id": 104,
+ "options": {
+ "colorMode": "value",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto"
+ },
+ "pluginVersion": "9.1.6",
+ "targets": [
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "${loki_datasource}"
+ },
+ "editorMode": "code",
+ "expr": "60",
+ "queryType": "instant",
+ "refId": "A"
+ }
+ ],
+ "title": "Block Duration",
+ "description": "DOSBlockingPeriod configured in evasive.conf",
+ "type": "stat"
+ },
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "${loki_datasource}"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "thresholds"
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "orange",
+ "value": 3
+ },
+ {
+ "color": "red",
+ "value": 10
+ }
+ ]
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 4,
+ "w": 4,
+ "x": 4,
+ "y": 5
+ },
+ "id": 105,
+ "options": {
+ "colorMode": "value",
+ "graphMode": "area",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": [
+ "sum"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto"
+ },
+ "pluginVersion": "9.1.6",
+ "targets": [
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "${loki_datasource}"
+ },
+ "editorMode": "code",
+ "expr": "sum(count_over_time({instance=~\"$node\", job=\"mod_evasive\"} |= \"action=blocked\" |~ `(?i)$ip_filter` [5m]))",
+ "queryType": "range",
+ "refId": "A"
+ }
+ ],
+ "title": "Blocks (Last 5m)",
+ "type": "stat"
+ },
+ {
+ "collapsed": false,
+ "gridPos": {
+ "h": 1,
+ "w": 24,
+ "x": 0,
+ "y": 9
+ },
+ "id": 200,
+ "panels": [],
+ "title": "Top Blocked IPs",
+ "type": "row"
+ },
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "${loki_datasource}"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "thresholds"
+ },
+ "custom": {
+ "align": "left",
+ "displayMode": "auto",
+ "inspect": false
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "yellow",
+ "value": 10
+ },
+ {
+ "color": "red",
+ "value": 50
+ }
+ ]
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "IP Address"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 200
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Block Count"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 150
+ },
+ {
+ "id": "custom.displayMode",
+ "value": "gradient-gauge"
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 10,
+ "w": 10,
+ "x": 0,
+ "y": 10
+ },
+ "id": 201,
+ "options": {
+ "footer": {
+ "fields": "",
+ "reducer": [
+ "sum"
+ ],
+ "show": false
+ },
+ "showHeader": true,
+ "sortBy": [
+ {
+ "desc": true,
+ "displayName": "Block Count"
+ }
+ ]
+ },
+ "pluginVersion": "9.1.6",
+ "targets": [
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "${loki_datasource}"
+ },
+ "editorMode": "code",
+ "expr": "topk(20, sum by (src_ip) (count_over_time({instance=~\"$node\", job=\"mod_evasive\"} |= \"action=blocked\" |~ `(?i)$ip_filter` | regexp `src_ip=(?P[\\d\\.]+)` [$__range])))",
+ "legendFormat": "{{src_ip}}",
+ "queryType": "instant",
+ "refId": "A"
+ }
+ ],
+ "title": "Top 20 Blocked IPs",
+ "transformations": [
+ {
+ "id": "organize",
+ "options": {
+ "excludeByName": {
+ "Time": true
+ },
+ "indexByName": {
+ "src_ip": 0,
+ "Value": 1
+ },
+ "renameByName": {
+ "Value": "Block Count",
+ "src_ip": "IP Address"
+ }
+ }
+ }
+ ],
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "${loki_datasource}"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisCenteredZero": false,
+ "axisColorMode": "text",
+ "axisLabel": "",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "line",
+ "fillOpacity": 0,
+ "gradientMode": "none",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "never",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "none"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ }
+ ]
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 10,
+ "w": 14,
+ "x": 10,
+ "y": 10
+ },
+ "id": 202,
+ "options": {
+ "legend": {
+ "calcs": [
+ "lastNotNull",
+ "max",
+ "mean"
+ ],
+ "displayMode": "table",
+ "placement": "right",
+ "showLegend": true
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "pluginVersion": "9.1.6",
+ "targets": [
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "${loki_datasource}"
+ },
+ "editorMode": "code",
+ "expr": "topk(10, sum by (src_ip) (count_over_time({instance=~\"$node\", job=\"mod_evasive\"} |= \"action=blocked\" |~ `(?i)$ip_filter` | regexp `src_ip=(?P[\\d\\.]+)` [1m])))",
+ "legendFormat": "{{src_ip}}",
+ "queryType": "range",
+ "refId": "A"
+ }
+ ],
+ "title": "Top 10 Blocked IPs Over Time",
+ "type": "timeseries"
+ },
+ {
+ "collapsed": false,
+ "gridPos": {
+ "h": 1,
+ "w": 24,
+ "x": 0,
+ "y": 20
+ },
+ "id": 300,
+ "panels": [],
+ "title": "Block Frequency Analysis",
+ "type": "row"
+ },
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "${loki_datasource}"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "thresholds"
+ },
+ "custom": {
+ "align": "left",
+ "displayMode": "auto",
+ "inspect": false
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "orange",
+ "value": 3
+ },
+ {
+ "color": "red",
+ "value": 10
+ }
+ ]
+ }
+ },
+ "overrides": [
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "IP Address"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 200
+ }
+ ]
+ },
+ {
+ "matcher": {
+ "id": "byName",
+ "options": "Times Blocked"
+ },
+ "properties": [
+ {
+ "id": "custom.width",
+ "value": 150
+ },
+ {
+ "id": "custom.displayMode",
+ "value": "color-background"
+ }
+ ]
+ }
+ ]
+ },
+ "gridPos": {
+ "h": 10,
+ "w": 10,
+ "x": 0,
+ "y": 21
+ },
+ "id": 301,
+ "options": {
+ "footer": {
+ "fields": "",
+ "reducer": [
+ "sum"
+ ],
+ "show": false
+ },
+ "showHeader": true,
+ "sortBy": [
+ {
+ "desc": true,
+ "displayName": "Times Blocked"
+ }
+ ]
+ },
+ "pluginVersion": "9.1.6",
+ "targets": [
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "${loki_datasource}"
+ },
+ "editorMode": "code",
+ "expr": "topk(20, sum by (src_ip) (count_over_time({instance=~\"$node\", job=\"mod_evasive\"} |= \"action=blocked\" |~ `(?i)$ip_filter` | regexp `src_ip=(?P[\\d\\.]+)` [$__range])) > 2)",
+ "legendFormat": "{{src_ip}}",
+ "queryType": "instant",
+ "refId": "A"
+ }
+ ],
+ "title": "Repeat Offenders (blocked 3+ times)",
+ "transformations": [
+ {
+ "id": "organize",
+ "options": {
+ "excludeByName": {
+ "Time": true
+ },
+ "indexByName": {
+ "src_ip": 0,
+ "Value": 1
+ },
+ "renameByName": {
+ "Value": "Times Blocked",
+ "src_ip": "IP Address"
+ }
+ }
+ }
+ ],
+ "type": "table"
+ },
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "${loki_datasource}"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "color": {
+ "mode": "palette-classic"
+ },
+ "custom": {
+ "axisCenteredZero": false,
+ "axisColorMode": "text",
+ "axisLabel": "blocks / 5m",
+ "axisPlacement": "auto",
+ "barAlignment": 0,
+ "drawStyle": "bars",
+ "fillOpacity": 80,
+ "gradientMode": "scheme",
+ "hideFrom": {
+ "legend": false,
+ "tooltip": false,
+ "viz": false
+ },
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "scaleDistribution": {
+ "type": "linear"
+ },
+ "showPoints": "never",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "normal"
+ },
+ "thresholdsStyle": {
+ "mode": "off"
+ }
+ },
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ }
+ ]
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 10,
+ "w": 14,
+ "x": 10,
+ "y": 21
+ },
+ "id": 302,
+ "options": {
+ "legend": {
+ "calcs": [
+ "sum",
+ "max"
+ ],
+ "displayMode": "table",
+ "placement": "right",
+ "showLegend": true
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "pluginVersion": "9.1.6",
+ "targets": [
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "${loki_datasource}"
+ },
+ "editorMode": "code",
+ "expr": "sum by (src_ip) (count_over_time({instance=~\"$node\", job=\"mod_evasive\"} |= \"action=blocked\" |~ `(?i)$ip_filter` | regexp `src_ip=(?P[\\d\\.]+)` [5m]))",
+ "legendFormat": "{{src_ip}}",
+ "queryType": "range",
+ "refId": "A"
+ }
+ ],
+ "title": "Blocks per 5m by IP (Stacked)",
+ "type": "timeseries"
+ },
+ {
+ "collapsed": false,
+ "gridPos": {
+ "h": 1,
+ "w": 24,
+ "x": 0,
+ "y": 31
+ },
+ "id": 400,
+ "panels": [],
+ "title": "Raw mod_evasive Logs",
+ "type": "row"
+ },
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "${loki_datasource}"
+ },
+ "gridPos": {
+ "h": 15,
+ "w": 24,
+ "x": 0,
+ "y": 32
+ },
+ "id": 401,
+ "options": {
+ "dedupStrategy": "none",
+ "enableLogDetails": true,
+ "prettifyLogMessage": false,
+ "showCommonLabels": false,
+ "showLabels": false,
+ "showTime": true,
+ "sortOrder": "Descending",
+ "wrapLogMessage": true
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "loki",
+ "uid": "${loki_datasource}"
+ },
+ "editorMode": "code",
+ "expr": "{instance=~\"$node\", job=\"mod_evasive\"} |= \"action=blocked\" |~ `(?i)$ip_filter`",
+ "queryType": "range",
+ "refId": "A"
+ }
+ ],
+ "title": "mod_evasive Block Logs",
+ "type": "logs"
+ }
+ ],
+ "refresh": "30s",
+ "schemaVersion": 37,
+ "style": "dark",
+ "tags": [
+ "mod_evasive",
+ "ddos",
+ "security"
+ ],
+ "templating": {
+ "list": [
+ {
+ "current": {
+ "selected": false,
+ "text": "",
+ "value": ""
+ },
+ "hide": 2,
+ "includeAll": false,
+ "label": "",
+ "multi": false,
+ "name": "prometheus_datasource",
+ "options": [],
+ "query": "prometheus",
+ "queryValue": "",
+ "refresh": 1,
+ "regex": "",
+ "skipUrlSync": false,
+ "type": "datasource"
+ },
+ {
+ "current": {
+ "selected": false,
+ "text": "",
+ "value": ""
+ },
+ "hide": 2,
+ "includeAll": false,
+ "label": "",
+ "multi": false,
+ "name": "loki_datasource",
+ "options": [],
+ "query": "loki",
+ "queryValue": "",
+ "refresh": 1,
+ "regex": "",
+ "skipUrlSync": false,
+ "type": "datasource"
+ },
+ {
+ "current": {
+ "selected": false,
+ "text": "All",
+ "value": "$__all"
+ },
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${prometheus_datasource}"
+ },
+ "definition": "label_values(insight_log_rate,instance)",
+ "hide": 0,
+ "includeAll": true,
+ "label": "Host:",
+ "multi": true,
+ "name": "node",
+ "options": [],
+ "query": {
+ "query": "label_values(insight_log_rate,instance)",
+ "refId": "StandardVariableQuery"
+ },
+ "refresh": 2,
+ "regex": "",
+ "skipUrlSync": false,
+ "sort": 1,
+ "type": "query"
+ },
+ {
+ "current": {
+ "selected": false,
+ "text": "",
+ "value": ""
+ },
+ "hide": 0,
+ "label": "IP Filter (regex):",
+ "name": "ip_filter",
+ "options": [
+ {
+ "selected": true,
+ "text": "",
+ "value": ""
+ }
+ ],
+ "query": "",
+ "skipUrlSync": false,
+ "type": "textbox"
+ }
+ ]
+ },
+ "time": {
+ "from": "now-1h",
+ "to": "now"
+ },
+ "timepicker": {
+ "refresh_intervals": [
+ "10s",
+ "30s",
+ "1m",
+ "5m",
+ "15m",
+ "30m",
+ "1h"
+ ]
+ },
+ "timezone": "",
+ "title": "mod_evasive - HTTP Flood Protection",
+ "uid": "mod-evasive-blocks",
+ "version": 1,
+ "weekStart": ""
+}
diff --git a/src/nssec/cli/mtls_commands.py b/src/nssec/cli/mtls_commands.py
index 4e5c5e3..701058e 100644
--- a/src/nssec/cli/mtls_commands.py
+++ b/src/nssec/cli/mtls_commands.py
@@ -1,14 +1,35 @@
"""mTLS management CLI commands for nssec."""
+from __future__ import annotations
+
import click
from nssec.cli import console
-@click.group()
-def mtls():
- """mTLS device provisioning management commands."""
- pass
+@click.group(invoke_without_command=True)
+@click.pass_context
+def mtls(ctx):
+ """Manage mTLS device provisioning for NetSapiens NDP servers.
+
+ Requires mTLSProtect to be installed. See:
+ https://github.com/OITApps/mTLSProtect
+ """
+ if ctx.invoked_subcommand is None:
+ console.print("[bold]mTLS Management[/bold]\n")
+ console.print(" Manages IP allowlists in the mTLSProtect configuration")
+ console.print(" so monitoring and trusted IPs are not blocked by")
+ console.print(" client certificate requirements.\n")
+ console.print("[bold]Allowlist Commands:[/bold]")
+ console.print(" [cyan]nssec mtls allowlist show[/cyan] Show all whitelisted IPs")
+ console.print(" [cyan]nssec mtls allowlist add[/cyan] Add an IP to the allowlist")
+ console.print(" [cyan]nssec mtls allowlist remove[/cyan] Remove an IP from the allowlist")
+ console.print()
+ console.print("[bold]NodePing Commands:[/bold]")
+ console.print(" [cyan]nssec mtls nodeping show[/cyan] Show current NodePing IPs")
+ console.print(" [cyan]nssec mtls nodeping fetch[/cyan] Fetch IPs from NodePing (dry run)")
+ console.print(" [cyan]nssec mtls nodeping update[/cyan] Fetch and apply NodePing IPs")
+ console.print(" [cyan]nssec mtls nodeping remove[/cyan] Remove NodePing IPs section")
@mtls.group("nodeping", invoke_without_command=True)
@@ -60,14 +81,12 @@ def nodeping_fetch():
console.print(" [cyan]sudo nssec mtls nodeping update[/cyan]")
-def _require_root(command_name: str) -> None:
+def _require_root(command_path: str) -> None:
"""Exit with error if not running as root."""
from nssec.core.ssh import is_root
if not is_root():
- console.print(
- f"[red]Error: Must run as root (sudo nssec mtls nodeping {command_name})[/red]"
- )
+ console.print(f"[red]Error: Must run as root (sudo nssec mtls {command_path})[/red]")
raise SystemExit(1)
@@ -104,7 +123,7 @@ def nodeping_update(yes, dry_run):
"""Fetch NodePing IPs and update ndp_mtls.conf."""
from nssec.modules.mtls import update_nodeping_ips
- _require_root("update")
+ _require_root("nodeping update")
console.print("[bold]Updating NodePing IPs...[/bold]")
result = update_nodeping_ips(dry_run=dry_run)
@@ -132,7 +151,7 @@ def nodeping_remove(yes):
"""Remove NodePing IPs section from ndp_mtls.conf."""
from nssec.modules.mtls import remove_nodeping_ips
- _require_root("remove")
+ _require_root("nodeping remove")
console.print(
"[bold yellow]Warning:[/bold yellow] This will remove all NodePing IPs from mTLS config."
@@ -173,3 +192,106 @@ def _display_ip_list(ips: list[str], title: str) -> None:
console.print(f"\n[cyan]IPv6[/cyan] ({len(ipv6)}):")
for ip in sorted(ipv6):
console.print(f" {ip}")
+
+
+# --- Allowlist commands ---
+
+
+@mtls.group("allowlist", invoke_without_command=True)
+@click.pass_context
+def mtls_allowlist(ctx):
+ """Manage IP allowlist in mTLS config."""
+ if ctx.invoked_subcommand is None:
+ ctx.invoke(allowlist_show)
+
+
+@mtls_allowlist.command("show")
+def allowlist_show():
+ """Show all whitelisted IPs in ndp_mtls.conf."""
+ from nssec.modules.mtls import get_allowlist_ips
+ from nssec.modules.mtls.config import NDP_MTLS_CONF
+ from nssec.modules.mtls.utils import file_exists
+
+ if not file_exists(NDP_MTLS_CONF):
+ console.print(f"[yellow]mTLS config not found:[/yellow] {NDP_MTLS_CONF}")
+ console.print("[dim]Is mTLSProtect installed?[/dim]")
+ return
+
+ entries = get_allowlist_ips()
+ if not entries:
+ console.print("[dim]No IPs currently in allowlist.[/dim]")
+ console.print("\nTo add an IP, run:")
+ console.print(" [cyan]sudo nssec mtls allowlist add [/cyan]")
+ return
+
+ manual = [e["ip"] for e in entries if not e["managed"]]
+ managed = [e["ip"] for e in entries if e["managed"]]
+
+ if manual:
+ _display_ip_list(manual, "Manual Allowlist IPs")
+
+ if managed:
+ if manual:
+ console.print()
+ _display_ip_list(managed, "NodePing IPs (auto-managed)")
+ console.print("\n[dim]NodePing IPs are managed via 'nssec mtls nodeping update'[/dim]")
+
+ if not manual and not managed:
+ console.print("[dim]No IPs currently in allowlist.[/dim]")
+
+
+@mtls_allowlist.command("add")
+@click.argument("ip")
+@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts")
+def allowlist_add(ip, yes):
+ """Add an IP to the mTLS allowlist.
+
+ The IP will be added to the block in ndp_mtls.conf,
+ allowing it to bypass client certificate requirements.
+ """
+ from nssec.core.validators import validate_ip_address
+ from nssec.modules.mtls import add_allowlist_ip
+
+ _require_root("allowlist add")
+
+ try:
+ validate_ip_address(ip)
+ except ValueError:
+ console.print(f"[red]Error:[/red] '{ip}' is not a valid IP address")
+ raise SystemExit(1)
+
+ console.print(f"Adding [bold]{ip}[/bold] to mTLS allowlist...")
+
+ result = add_allowlist_ip(ip)
+ if not result.success:
+ console.print(f"[red]Error:[/red] {result.error}")
+ raise SystemExit(1)
+
+ console.print(f" [green]Done:[/green] {result.message}")
+ _validate_and_reload(yes)
+
+
+@mtls_allowlist.command("remove")
+@click.argument("ip")
+@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts")
+def allowlist_remove(ip, yes):
+ """Remove an IP from the mTLS allowlist.
+
+ Only manually-added IPs can be removed. NodePing IPs are managed
+ separately via 'nssec mtls nodeping'.
+ """
+ from nssec.modules.mtls import remove_allowlist_ip
+
+ _require_root("allowlist remove")
+
+ if not yes and not click.confirm(f"Remove {ip} from mTLS allowlist?"):
+ console.print("[yellow]Aborted.[/yellow]")
+ return
+
+ result = remove_allowlist_ip(ip)
+ if not result.success:
+ console.print(f"[red]Error:[/red] {result.error}")
+ raise SystemExit(1)
+
+ console.print(f" [green]Done:[/green] {result.message}")
+ _validate_and_reload(yes)
diff --git a/src/nssec/cli/waf_commands.py b/src/nssec/cli/waf_commands.py
index fd3d4e7..e9d87f7 100644
--- a/src/nssec/cli/waf_commands.py
+++ b/src/nssec/cli/waf_commands.py
@@ -49,8 +49,7 @@ def _display_install_plan(pf, mode, skip_evasive):
table.add_row("security2.conf", sec2_state, sec2_action)
if not skip_evasive:
- evasive_action = "install + enable" if mode == "On" else "install config (disabled in DetectionOnly)"
- table.add_row("mod_evasive", "", evasive_action)
+ table.add_row("mod_evasive", "", "install + enable")
console.print(table)
@@ -225,9 +224,6 @@ def waf_enable(yes):
"[bold yellow]Warning:[/bold yellow] Switching to blocking mode "
"will actively reject requests that match ModSecurity rules."
)
- console.print(
- "This will also [bold]enable mod_evasive[/bold] (HTTP flood protection)."
- )
console.print(
"Ensure you have reviewed "
"[cyan]/var/log/apache2/modsec_audit.log[/cyan] for false positives."
@@ -241,6 +237,11 @@ def waf_enable(yes):
result = installer.set_mode("On")
if result.success:
console.print(f"[green]{result.message}[/green]")
+ console.print()
+ console.print(
+ "[bold]Tip:[/bold] To also enable HTTP flood protection, run:"
+ )
+ console.print(" [cyan]sudo nssec waf evasive enable[/cyan]")
else:
console.print(f"[red]Error: {result.error}[/red]")
raise SystemExit(1)
@@ -264,9 +265,6 @@ def waf_disable(yes):
"Switching to [cyan]DetectionOnly[/cyan] mode. "
"ModSecurity will log violations but not block requests."
)
- console.print(
- "This will also [bold]disable mod_evasive[/bold] (HTTP flood protection)."
- )
console.print()
if not yes and not click.confirm("Switch SecRuleEngine to DetectionOnly?"):
@@ -493,3 +491,534 @@ def waf_allowlist_delete(ip, yes):
console.print(f" [green]Done:[/green] {val.message}")
_prompt_and_reload_apache(installer, yes)
+
+
+# ─── EVASIVE SUBCOMMANDS ───
+
+
+@waf.group("evasive", invoke_without_command=True)
+@click.pass_context
+def waf_evasive(ctx):
+ """Manage mod_evasive (HTTP flood protection)."""
+ if ctx.invoked_subcommand is None:
+ ctx.invoke(waf_evasive_status)
+
+
+@waf_evasive.command("enable")
+@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
+@click.option(
+ "--profile",
+ type=click.Choice(["standard", "strict"]),
+ default="standard",
+ help="Threshold profile: standard (high thresholds, safe default) or strict (tuned for NS traffic)",
+)
+def waf_evasive_enable(yes, profile):
+ """Enable mod_evasive HTTP flood protection.
+
+ mod_evasive has NO detection-only mode — it WILL block IPs that exceed
+ the configured thresholds (HTTP 403).
+
+ \b
+ Profiles:
+ standard High thresholds (100 req/page, 500 req/site). Safe default
+ that only catches extreme floods. Start here.
+ strict Tight thresholds (15 req/page, 60 req/site) tuned for
+ NetSapiens traffic. Use after reviewing traffic patterns.
+
+ Review your traffic with the Apache API Usage dashboard or access logs
+ before switching to the strict profile.
+ """
+ from nssec.modules.waf import ModSecurityInstaller
+ from nssec.modules.waf.config import EVASIVE_PACKAGE, EVASIVE_PROFILES
+ from nssec.modules.waf.utils import package_installed
+
+ installer = ModSecurityInstaller()
+ pf = installer.preflight()
+
+ if not pf.is_root:
+ console.print("[red]Error: Must run as root (sudo nssec waf evasive enable)[/red]")
+ raise SystemExit(1)
+
+ if not package_installed(EVASIVE_PACKAGE):
+ console.print(
+ "[red]Error: mod_evasive is not installed. "
+ "Run 'nssec waf init' first.[/red]"
+ )
+ raise SystemExit(1)
+
+ thresholds = EVASIVE_PROFILES[profile]
+ console.print(
+ f"[bold yellow]Warning:[/bold yellow] mod_evasive has no detection-only mode. "
+ "When enabled it [bold]will block[/bold] IPs that exceed thresholds (HTTP 403)."
+ )
+ console.print(f" Profile: [cyan]{profile}[/cyan]")
+ console.print(f" DOSPageCount: {thresholds['page_count']} req/page/s")
+ console.print(f" DOSSiteCount: {thresholds['site_count']} req/IP/s")
+ console.print(f" DOSBlockingPeriod: {thresholds['blocking_period']}s")
+ console.print()
+ console.print(
+ "Review traffic patterns before enabling. Use the Apache API Usage "
+ "dashboard or [cyan]tail -f /var/log/apache2/access.log[/cyan]."
+ )
+ console.print()
+
+ if not yes and not click.confirm("Enable mod_evasive?"):
+ console.print("[yellow]Aborted.[/yellow]")
+ return
+
+ config_result = installer.setup_evasive_config(profile=profile)
+ if not config_result.success and not config_result.skipped:
+ console.print(f"[red]Error: {config_result.error}[/red]")
+ raise SystemExit(1)
+ console.print(f" [green]Done:[/green] {config_result.message}")
+
+ result = installer.set_evasive_state(enable=True)
+ if result.skipped:
+ console.print(f" [green]{result.message}[/green]")
+ elif not result.success:
+ console.print(f"[red]Error: {result.error}[/red]")
+ raise SystemExit(1)
+ else:
+ console.print(f" [green]Done:[/green] {result.message}")
+
+ _prompt_and_reload_apache(installer, yes)
+
+
+@waf_evasive.command("disable")
+@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
+def waf_evasive_disable(yes):
+ """Disable mod_evasive HTTP flood protection."""
+ from nssec.modules.waf import ModSecurityInstaller
+ from nssec.core.ssh import is_root
+
+ if not is_root():
+ console.print("[red]Error: Must run as root (sudo nssec waf evasive disable)[/red]")
+ raise SystemExit(1)
+
+ console.print(
+ "[bold yellow]Warning:[/bold yellow] Disabling mod_evasive removes "
+ "HTTP flood protection. The server will be vulnerable to "
+ "application-layer DDoS attacks."
+ )
+ console.print()
+
+ if not yes and not click.confirm("Disable mod_evasive?"):
+ console.print("[yellow]Aborted.[/yellow]")
+ return
+
+ installer = ModSecurityInstaller()
+ result = installer.set_evasive_state(enable=False)
+ if result.skipped:
+ console.print(f"[green]{result.message}[/green]")
+ return
+
+ if not result.success:
+ console.print(f"[red]Error: {result.error}[/red]")
+ raise SystemExit(1)
+
+ console.print(f"[green]{result.message}[/green]")
+ _prompt_and_reload_apache(installer, yes)
+
+
+@waf_evasive.command("status")
+def waf_evasive_status():
+ """Show mod_evasive status."""
+ from nssec.modules.waf.config import EVASIVE_CONF, EVASIVE_LOAD, EVASIVE_PACKAGE
+ from nssec.modules.waf.utils import file_exists, package_installed, read_file
+
+ installed = package_installed(EVASIVE_PACKAGE)
+ enabled = file_exists(EVASIVE_LOAD)
+ configured = file_exists(EVASIVE_CONF)
+
+ console.print("[bold]mod_evasive Status[/bold]\n")
+
+ if not installed:
+ console.print(" Installed: [red]no[/red]")
+ console.print("\n Run [cyan]nssec waf init[/cyan] to install.")
+ return
+
+ console.print(" Installed: [green]yes[/green]")
+ console.print(f" Configured: {_yn(configured)}")
+
+ if enabled:
+ console.print(" Module: [green]enabled[/green]")
+ else:
+ console.print(" Module: [yellow]disabled[/yellow]")
+
+ # Show active profile from config comment
+ if configured:
+ content = read_file(EVASIVE_CONF) or ""
+ profile = "unknown"
+ for line in content.splitlines():
+ if line.startswith("# Profile:"):
+ profile = line.split(":", 1)[1].strip()
+ break
+ console.print(f" Profile: [cyan]{profile}[/cyan]")
+
+ if not enabled:
+ console.print(
+ "\n Enable with: [cyan]sudo nssec waf evasive enable[/cyan]"
+ )
+
+
+# ─── RESTRICT SUBCOMMANDS ───
+
+
+def _validate_and_prompt_reload_for_restrict(yes):
+ """Run apache2ctl configtest then prompt for Apache reload."""
+ from nssec.modules.waf.utils import run_cmd
+
+ stdout, stderr, rc = run_cmd(["apache2ctl", "configtest"])
+ if rc != 0:
+ console.print(f" [red]Error:[/red] Apache config test failed: {stderr or stdout}")
+ raise SystemExit(1)
+ console.print(" [green]Done:[/green] Apache config test passed")
+
+ console.print()
+ if yes or click.confirm("Reload Apache to apply changes?"):
+ _, stderr, rc = run_cmd(["systemctl", "reload", "apache2"])
+ if rc != 0:
+ console.print(f" [red]Error:[/red] Apache reload failed: {stderr}")
+ raise SystemExit(1)
+ console.print(" [green]Done:[/green] Apache reloaded")
+ else:
+ console.print("[yellow]Skipped Apache reload. Run manually:[/yellow]")
+ console.print(" [cyan]sudo systemctl reload apache2[/cyan]")
+
+
+@waf.group("restrict", invoke_without_command=True)
+@click.pass_context
+def waf_restrict(ctx):
+ """Manage .htaccess IP restrictions for sensitive NetSapiens paths."""
+ if ctx.invoked_subcommand is None:
+ ctx.invoke(waf_restrict_show)
+
+
+@waf_restrict.command("show")
+def waf_restrict_show():
+ """Show .htaccess restriction status for each protected path."""
+ from nssec.core.server_types import detect_server_type
+ from nssec.modules.waf.restrict import get_restrict_status, load_cached_ips
+
+ server_type = detect_server_type().value
+ statuses = get_restrict_status(server_type)
+
+ if not statuses:
+ console.print("[dim]No applicable restriction targets for this server type.[/dim]")
+ return
+
+ table = Table(title="Path Restrictions", show_header=True)
+ table.add_column("Target", style="cyan")
+ table.add_column("Path")
+ table.add_column("Status")
+ table.add_column("IPs", justify="right")
+
+ first_ips = None
+ first_ips_managed = False
+ for s in statuses:
+ if not s["exists"]:
+ status = "[red]missing[/red]"
+ ip_count = "-"
+ elif s["managed"]:
+ status = "[green]managed[/green]"
+ ip_count = str(len(s["ips"]))
+ if first_ips is None:
+ first_ips = s["ips"]
+ first_ips_managed = True
+ else:
+ status = "[yellow]unmanaged[/yellow]"
+ ip_count = str(len(s["ips"]))
+ if first_ips is None:
+ first_ips = s["ips"]
+
+ table.add_row(s["name"], s["path"], status, ip_count)
+
+ console.print(table)
+
+ if first_ips:
+ label = "Allowed IPs" if first_ips_managed else "Existing IPs (unmanaged)"
+ console.print(f"\n[bold]{label}[/bold] ({len(first_ips)}):")
+ for ip in first_ips:
+ console.print(f" {ip}")
+
+ # Show cache status
+ cached = load_cached_ips()
+ if cached:
+ console.print(f"\n[bold]IP cache:[/bold] [green]{len(cached)} IP(s) saved[/green]")
+ console.print(" Run [cyan]nssec waf restrict reapply[/cyan] after NS upgrades to restore")
+ elif first_ips_managed:
+ console.print("\n[bold]IP cache:[/bold] [yellow]not saved[/yellow]")
+ console.print(" Run [cyan]nssec waf restrict init[/cyan] to save IPs for reapply after upgrades")
+
+
+@waf_restrict.command("init")
+@click.option("--ip", "ips", multiple=True, help="IP address or CIDR to allow (repeatable)")
+@click.option("--dry-run", is_flag=True, help="Show what would be done without making changes")
+@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts")
+def waf_restrict_init(ips, dry_run, yes):
+ """Create .htaccess IP restrictions for sensitive NetSapiens paths.
+
+ Restricts access to admin UI, API, and other sensitive directories
+ using Apache 2.4 mod_authz_core (Require ip) directives.
+
+ \b
+ 127.0.0.1 is always included automatically. You should also include:
+ - NetSapiens TAC support IPs
+ - Your admin office IP(s)
+
+ Use --ip to specify IPs, or omit to be prompted interactively.
+ If existing .htaccess files are found, you will be asked whether to
+ keep or overwrite the existing IPs.
+ """
+ import ipaddress
+
+ from nssec.core.ssh import is_root
+ from nssec.core.server_types import detect_server_type
+ from nssec.modules.waf.restrict import init_restrictions, collect_existing_ips
+
+ if not is_root():
+ console.print("[red]Error: Must run as root (sudo nssec waf restrict init)[/red]")
+ raise SystemExit(1)
+
+ server_type = detect_server_type().value
+
+ # Check for existing IPs on disk / in cache
+ existing_ips = collect_existing_ips(server_type)
+ merge_existing = True
+
+ if existing_ips and not dry_run:
+ console.print(f"[bold]Existing IPs found[/bold] ({len(existing_ips)}):")
+ for eip in existing_ips:
+ console.print(f" {eip}")
+ console.print()
+ if not yes:
+ keep = click.confirm(
+ "Keep these existing IPs? (No = overwrite with only the IPs you provide)",
+ default=True,
+ )
+ merge_existing = keep
+ # When --yes is passed, default to keeping existing IPs
+
+ # Collect IPs
+ ip_list = list(ips)
+ if not ip_list:
+ console.print(
+ "[bold]Enter IP addresses to allow access[/bold] "
+ "(one per line, or space/comma separated)."
+ )
+ console.print(
+ " Include NetSapiens TAC IPs and your admin office IPs."
+ )
+ console.print(" 127.0.0.1 is always included automatically.")
+ console.print(" Press Enter on a blank line when done.")
+ console.print()
+ lines: list[str] = []
+ while True:
+ line = click.prompt("IP", default="", show_default=False)
+ if not line.strip():
+ break
+ lines.append(line)
+ # Split on commas and whitespace across all lines
+ raw = " ".join(lines)
+ ip_list = [s.strip() for s in raw.replace(",", " ").split() if s.strip()]
+
+ # Validate each IP
+ for ip_str in ip_list:
+ try:
+ if "/" in ip_str:
+ ipaddress.ip_network(ip_str, strict=False)
+ else:
+ ipaddress.ip_address(ip_str)
+ except ValueError:
+ console.print(f"[red]Error: Invalid IP address or CIDR: {ip_str}[/red]")
+ raise SystemExit(1)
+
+ console.print(f"\n[bold]Server type:[/bold] {server_type}")
+ console.print(f"[bold]IPs to allow:[/bold] 127.0.0.1 {' '.join(ip_list)}")
+ if existing_ips and merge_existing:
+ console.print(f"[bold]Keeping:[/bold] {len(existing_ips)} existing IP(s)")
+ elif existing_ips and not merge_existing:
+ console.print("[yellow]Overwriting existing IPs[/yellow]")
+ console.print()
+
+ if dry_run:
+ results = init_restrictions(server_type, ip_list, dry_run=True,
+ merge_existing=merge_existing)
+ for name, result in results:
+ label = f"[cyan]{name}:[/cyan] " if name else ""
+ console.print(f" {label}{result.message}")
+ console.print("\n[yellow]Dry run — no changes made.[/yellow]")
+ return
+
+ if not yes and not click.confirm("Create .htaccess restrictions?"):
+ console.print("[yellow]Aborted.[/yellow]")
+ return
+
+ results = init_restrictions(server_type, ip_list,
+ merge_existing=merge_existing)
+ any_error = False
+ for name, result in results:
+ label = f"[cyan]{name}:[/cyan] " if name else ""
+ if result.skipped:
+ console.print(f" [dim]Skipped:[/dim] {label}{result.message}")
+ elif result.success:
+ console.print(f" [green]Done:[/green] {label}{result.message}")
+ else:
+ console.print(f" [red]Error:[/red] {label}{result.error}")
+ any_error = True
+
+ if any_error:
+ raise SystemExit(1)
+
+ _validate_and_prompt_reload_for_restrict(yes)
+
+
+@waf_restrict.command("add")
+@click.argument("ip")
+@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts")
+def waf_restrict_add(ip, yes):
+ """Add an IP address to all managed .htaccess restriction files.
+
+ IP can be a single address (192.168.1.1) or CIDR notation (10.0.0.0/8).
+ """
+ import ipaddress
+
+ from nssec.core.ssh import is_root
+ from nssec.core.server_types import detect_server_type
+ from nssec.modules.waf.restrict import add_restricted_ip
+
+ if not is_root():
+ console.print("[red]Error: Must run as root (sudo nssec waf restrict add)[/red]")
+ raise SystemExit(1)
+
+ # Validate IP
+ try:
+ if "/" in ip:
+ ipaddress.ip_network(ip, strict=False)
+ else:
+ ipaddress.ip_address(ip)
+ except ValueError:
+ console.print(f"[red]Error: Invalid IP address or CIDR: {ip}[/red]")
+ raise SystemExit(1)
+
+ server_type = detect_server_type().value
+ console.print(f"Adding [cyan]{ip}[/cyan] to managed .htaccess files...")
+
+ results = add_restricted_ip(server_type, ip)
+ any_changed = False
+ for name, result in results:
+ label = f"[cyan]{name}:[/cyan] " if name else ""
+ if result.skipped:
+ console.print(f" [dim]Skipped:[/dim] {label}{result.message}")
+ elif result.success:
+ console.print(f" [green]Done:[/green] {label}{result.message}")
+ any_changed = True
+ else:
+ console.print(f" [red]Error:[/red] {label}{result.error}")
+ raise SystemExit(1)
+
+ if any_changed:
+ _validate_and_prompt_reload_for_restrict(yes)
+
+
+@waf_restrict.command("remove")
+@click.argument("ip")
+@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts")
+def waf_restrict_remove(ip, yes):
+ """Remove an IP address from all managed .htaccess restriction files.
+
+ Cannot remove 127.0.0.1 (localhost is always required).
+ """
+ from nssec.core.ssh import is_root
+ from nssec.core.server_types import detect_server_type
+ from nssec.modules.waf.restrict import remove_restricted_ip
+
+ if not is_root():
+ console.print("[red]Error: Must run as root (sudo nssec waf restrict remove)[/red]")
+ raise SystemExit(1)
+
+ server_type = detect_server_type().value
+ console.print(f"Removing [cyan]{ip}[/cyan] from managed .htaccess files...")
+
+ results = remove_restricted_ip(server_type, ip)
+ any_changed = False
+ any_error = False
+ for name, result in results:
+ label = f"[cyan]{name}:[/cyan] " if name else ""
+ if not result.success and result.error:
+ console.print(f" [red]Error:[/red] {label}{result.error}")
+ any_error = True
+ elif result.skipped:
+ console.print(f" [dim]Skipped:[/dim] {label}{result.message}")
+ elif result.success:
+ console.print(f" [green]Done:[/green] {label}{result.message}")
+ any_changed = True
+
+ if any_error:
+ raise SystemExit(1)
+
+ if any_changed:
+ _validate_and_prompt_reload_for_restrict(yes)
+
+
+@waf_restrict.command("reapply")
+@click.option("--dry-run", is_flag=True, help="Show what would be done without making changes")
+@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts")
+def waf_restrict_reapply(dry_run, yes):
+ """Re-deploy .htaccess restrictions from cached IPs.
+
+ Use after a NetSapiens package upgrade overwrites .htaccess files.
+ Reads the saved IP list from /etc/nssec/restrict-ips.json and
+ re-creates all managed .htaccess files.
+ """
+ from nssec.core.ssh import is_root
+ from nssec.core.server_types import detect_server_type
+ from nssec.modules.waf.restrict import reapply_restrictions, load_cached_ips
+
+ if not is_root():
+ console.print("[red]Error: Must run as root (sudo nssec waf restrict reapply)[/red]")
+ raise SystemExit(1)
+
+ cached_ips = load_cached_ips()
+ if cached_ips:
+ console.print(f"[bold]Cached IPs[/bold] ({len(cached_ips)}):")
+ for ip in cached_ips:
+ console.print(f" {ip}")
+ console.print()
+
+ server_type = detect_server_type().value
+
+ if dry_run:
+ results = reapply_restrictions(server_type, dry_run=True)
+ for name, result in results:
+ label = f"[cyan]{name}:[/cyan] " if name else ""
+ if result.skipped:
+ console.print(f" [dim]Skipped:[/dim] {label}{result.message}")
+ else:
+ console.print(f" {label}{result.message}")
+ console.print("\n[yellow]Dry run — no changes made.[/yellow]")
+ return
+
+ if not yes and not click.confirm("Re-deploy .htaccess restrictions from cache?"):
+ console.print("[yellow]Aborted.[/yellow]")
+ return
+
+ results = reapply_restrictions(server_type)
+ any_error = False
+ any_changed = False
+ for name, result in results:
+ label = f"[cyan]{name}:[/cyan] " if name else ""
+ if result.skipped:
+ console.print(f" [dim]Skipped:[/dim] {label}{result.message}")
+ elif result.success:
+ console.print(f" [green]Done:[/green] {label}{result.message}")
+ any_changed = True
+ else:
+ console.print(f" [red]Error:[/red] {label}{result.error}")
+ any_error = True
+
+ if any_error:
+ raise SystemExit(1)
+
+ if any_changed:
+ _validate_and_prompt_reload_for_restrict(yes)
diff --git a/src/nssec/core/checks.py b/src/nssec/core/checks.py
index 469a9c1..140b326 100644
--- a/src/nssec/core/checks.py
+++ b/src/nssec/core/checks.py
@@ -612,13 +612,15 @@ class ProtectedRoutesCheck(BaseCheck):
name = "Protected Routes Configuration"
description = "Verify sensitive admin routes have IP restrictions"
severity = Severity.HIGH
- applies_to = ["core", "combo"]
+ applies_to = ["core", "ndp", "recording", "combo"]
reference = f"{NS_DOCS} - search 'Securing Your NetSapiens System'"
def run(self) -> CheckResult:
protected_paths = [
("/usr/local/NetSapiens/SiPbx/html/SiPbx", "Admin UI"),
("/usr/local/NetSapiens/SiPbx/html/ns-api", "API"),
+ ("/usr/local/NetSapiens/ndp", "NDP"),
+ ("/usr/local/NetSapiens/LiCf/html/LiCf", "LiCf Recording"),
]
unprotected = []
@@ -648,7 +650,7 @@ def run(self) -> CheckResult:
return self._fail(
f"Unprotected routes: {', '.join(unprotected)}",
details="Admin routes should restrict access by IP",
- remediation="Add IP restrictions to .htaccess or configure ModSecurity",
+ remediation="Run 'nssec waf restrict init' to create .htaccess IP restrictions",
)
return self._pass(f"Protected routes: {', '.join(protected)}")
@@ -1246,15 +1248,16 @@ def run(self) -> CheckResult:
)
htaccess = Path(htaccess_path)
- if file_contains(htaccess, "Allow from", ignore_comments=True):
+ has_legacy = file_contains(htaccess, "Allow from", ignore_comments=True)
+ has_modern = file_contains(htaccess, "Require ip", ignore_comments=True)
+ if has_legacy or has_modern:
return self._pass("Admin UI has IP restrictions configured")
return self._fail(
"Admin UI does not have IP restrictions",
remediation=(
- "Configure IP allowlist in .htaccess or use "
- f"ModSecurity. See {NS_DOCS} - search "
- "'Securing Your NetSapiens System'"
+ "Run 'nssec waf restrict init' to create IP restrictions, "
+ f"or see {NS_DOCS} - search 'Securing Your NetSapiens System'"
),
)
diff --git a/src/nssec/modules/mtls/__init__.py b/src/nssec/modules/mtls/__init__.py
index 0d12ff6..19d9c68 100644
--- a/src/nssec/modules/mtls/__init__.py
+++ b/src/nssec/modules/mtls/__init__.py
@@ -10,13 +10,16 @@
from nssec.modules.mtls.config import BACKUP_SUFFIX, NDP_MTLS_CONF
from nssec.modules.mtls.utils import (
+ add_ip_to_requireany,
backup_file,
build_managed_section,
fetch_nodeping_ips,
file_exists,
find_requireany_block,
+ get_all_requireany_ips,
get_managed_section,
read_file,
+ remove_ip_from_requireany,
run_cmd,
write_file,
)
@@ -130,6 +133,63 @@ def remove_nodeping_ips() -> StepResult:
return StepResult(message="Removed NodePing IPs section from ndp_mtls.conf")
+def get_allowlist_ips() -> list[dict]:
+ """Get all whitelisted IPs from ndp_mtls.conf.
+
+ Returns list of dicts with 'ip' and 'managed' (bool) keys.
+ """
+ content = read_file(NDP_MTLS_CONF)
+ if not content:
+ return []
+ return get_all_requireany_ips(content)
+
+
+def add_allowlist_ip(ip: str) -> StepResult:
+ """Add an IP to the mTLS allowlist in ndp_mtls.conf."""
+ if not file_exists(NDP_MTLS_CONF):
+ return StepResult(
+ success=False,
+ error=f"{NDP_MTLS_CONF} not found. Is mTLSProtect installed?",
+ )
+
+ content = read_file(NDP_MTLS_CONF)
+ if not content:
+ return StepResult(success=False, error=f"Failed to read {NDP_MTLS_CONF}")
+
+ new_content, error = add_ip_to_requireany(content, ip)
+ if error:
+ return StepResult(success=False, error=error)
+
+ backup_file(NDP_MTLS_CONF)
+ if not write_file(NDP_MTLS_CONF, new_content):
+ return StepResult(success=False, error=f"Failed to write {NDP_MTLS_CONF}")
+
+ return StepResult(message=f"Added {ip} to mTLS allowlist")
+
+
+def remove_allowlist_ip(ip: str) -> StepResult:
+ """Remove an IP from the mTLS allowlist in ndp_mtls.conf."""
+ if not file_exists(NDP_MTLS_CONF):
+ return StepResult(
+ success=False,
+ error=f"{NDP_MTLS_CONF} not found. Is mTLSProtect installed?",
+ )
+
+ content = read_file(NDP_MTLS_CONF)
+ if not content:
+ return StepResult(success=False, error=f"Failed to read {NDP_MTLS_CONF}")
+
+ new_content, error = remove_ip_from_requireany(content, ip)
+ if error:
+ return StepResult(success=False, error=error)
+
+ backup_file(NDP_MTLS_CONF)
+ if not write_file(NDP_MTLS_CONF, new_content):
+ return StepResult(success=False, error=f"Failed to write {NDP_MTLS_CONF}")
+
+ return StepResult(message=f"Removed {ip} from mTLS allowlist")
+
+
def validate_apache_config() -> StepResult:
"""Run apache2ctl configtest to validate configuration."""
stdout, stderr, rc = run_cmd(["apache2ctl", "configtest"])
diff --git a/src/nssec/modules/mtls/utils.py b/src/nssec/modules/mtls/utils.py
index 45a64c3..f753172 100644
--- a/src/nssec/modules/mtls/utils.py
+++ b/src/nssec/modules/mtls/utils.py
@@ -163,3 +163,138 @@ def find_requireany_block(content: str) -> int:
if abs_pos < len(content) and content[abs_pos] == "\n":
abs_pos += 1
return abs_pos
+
+
+def get_requireany_bounds(content: str) -> tuple[int, int]:
+ """Find the start and end positions of the block in .
+
+ Returns (start_after_tag, end_before_closing_tag) or (-1, -1) if not found.
+ """
+ loc_start = content.find("", loc_start)
+ if loc_end == -1:
+ return -1, -1
+
+ loc_content = content[loc_start:loc_end]
+ req_any_pos = loc_content.find("")
+ if req_any_pos == -1:
+ return -1, -1
+
+ abs_start = loc_start + req_any_pos + len("")
+ if abs_start < len(content) and content[abs_start] == "\n":
+ abs_start += 1
+
+ req_end_pos = loc_content.find("", req_any_pos)
+ if req_end_pos == -1:
+ return -1, -1
+
+ abs_end = loc_start + req_end_pos
+ return abs_start, abs_end
+
+
+def get_all_requireany_ips(content: str) -> list[dict]:
+ """Parse all Require ip lines from the block.
+
+ Returns a list of dicts with keys:
+ - ip: the IP address string
+ - managed: True if inside the NodePing managed section
+ """
+ block_start, block_end = get_requireany_bounds(content)
+ if block_start == -1:
+ return []
+
+ block = content[block_start:block_end]
+ managed_start = block.find(NODEPING_BEGIN_MARKER)
+ managed_end = block.find(NODEPING_END_MARKER)
+ has_managed = managed_start != -1 and managed_end != -1
+
+ results = []
+ offset = 0
+ for line in block.splitlines():
+ line_start = offset
+ offset += len(line) + 1 # +1 for newline
+ stripped = line.strip()
+ if not stripped.startswith("Require ip "):
+ continue
+ ip = stripped.replace("Require ip ", "").strip()
+ managed = has_managed and managed_start <= line_start < managed_end
+ results.append({"ip": ip, "managed": managed})
+
+ return results
+
+
+def add_ip_to_requireany(content: str, ip: str) -> tuple[str, str]:
+ """Add a Require ip line to the block (outside managed section).
+
+ Returns (new_content, error). Error is empty on success.
+ """
+ block_start, block_end = get_requireany_bounds(content)
+ if block_start == -1:
+ return "", "Could not find block in ndp_mtls.conf"
+
+ # Check if IP already exists
+ block = content[block_start:block_end]
+ for line in block.splitlines():
+ stripped = line.strip()
+ if stripped == f"Require ip {ip}":
+ return "", f"IP {ip} is already in the allowlist"
+
+ # Insert before the closing tag, outside any managed section
+ # Find the last non-managed Require ip line, or insert at the top
+ new_line = f" Require ip {ip}\n"
+
+ # Insert at the beginning of the block (before any managed section)
+ managed_begin = block.find(NODEPING_BEGIN_MARKER)
+ if managed_begin != -1:
+ insert_pos = block_start + managed_begin
+ # Back up to find a good insertion point (before the blank line before managed section)
+ while insert_pos > block_start and content[insert_pos - 1] in ("\n", " "):
+ insert_pos -= 1
+ if insert_pos > block_start:
+ insert_pos += 1 # Keep one newline
+ new_content = content[:insert_pos] + new_line + content[insert_pos:]
+ else:
+ # No managed section — insert before
+ new_content = content[:block_end] + new_line + content[block_end:]
+
+ return new_content, ""
+
+
+def remove_ip_from_requireany(content: str, ip: str) -> tuple[str, str]:
+ """Remove a Require ip line from the block.
+
+ Only removes IPs outside the NodePing managed section.
+ Returns (new_content, error). Error is empty on success.
+ """
+ block_start, block_end = get_requireany_bounds(content)
+ if block_start == -1:
+ return "", "Could not find block in ndp_mtls.conf"
+
+ block = content[block_start:block_end]
+ managed_start = block.find(NODEPING_BEGIN_MARKER)
+ managed_end = block.find(NODEPING_END_MARKER)
+ has_managed = managed_start != -1 and managed_end != -1
+
+ # Find the exact line to remove
+ offset = 0
+ for line in block.splitlines(keepends=True):
+ line_start = offset
+ offset += len(line)
+ stripped = line.strip()
+ if stripped == f"Require ip {ip}":
+ in_managed = has_managed and managed_start <= line_start < managed_end
+ if in_managed:
+ return "", (
+ f"IP {ip} is managed by NodePing auto-updates. "
+ "Use 'nssec mtls nodeping remove' to remove all NodePing IPs."
+ )
+ # Remove this line
+ abs_start = block_start + line_start
+ abs_end = block_start + line_start + len(line)
+ new_content = content[:abs_start] + content[abs_end:]
+ return new_content, ""
+
+ return "", f"IP {ip} not found in allowlist"
diff --git a/src/nssec/modules/waf/__init__.py b/src/nssec/modules/waf/__init__.py
index dad8a63..352bb52 100644
--- a/src/nssec/modules/waf/__init__.py
+++ b/src/nssec/modules/waf/__init__.py
@@ -22,8 +22,11 @@
EVASIVE_CONF,
EVASIVE_CONF_TEMPLATE,
EVASIVE_LOAD,
+ EVASIVE_DEFAULT_PROFILE,
EVASIVE_LOG_DIR,
+ EVASIVE_LOG_FILE,
EVASIVE_PACKAGE,
+ EVASIVE_PROFILES,
MODSEC_AUDIT_LOG,
MODSEC_CONF,
MODSEC_CONF_RECOMMENDED,
@@ -200,12 +203,7 @@ def install_packages(self) -> StepResult:
return StepResult(message=f"Installed: {', '.join(packages)}")
def enable_modules(self) -> StepResult:
- """Enable Apache security2 module and conditionally enable evasive.
-
- mod_evasive is only enabled when the WAF mode is 'On' (blocking).
- In DetectionOnly mode, evasive is left disabled to avoid blocking
- traffic while the WAF is still being tuned.
- """
+ """Enable Apache security2 module and conditionally enable evasive."""
if file_exists(SECURITY2_LOAD):
return StepResult(skipped=True, message="security2 module already enabled")
if self.dry_run:
@@ -217,8 +215,7 @@ def enable_modules(self) -> StepResult:
success=False,
error=f"a2enmod security2 failed: {stderr}",
)
- # Only enable evasive in blocking mode; DetectionOnly = passive
- if self.install_evasive and self.mode == "On":
+ if self.install_evasive:
run_cmd(["a2enmod", "evasive"])
return StepResult(message="Enabled security2 module")
@@ -248,26 +245,36 @@ def setup_config(self) -> StepResult:
msg = f"Configured ModSecurity (SecRuleEngine {self.mode})"
return StepResult(message=msg)
- def setup_evasive_config(self) -> StepResult:
- """Write the mod_evasive configuration with tuned thresholds.
+ def setup_evasive_config(self, profile: str = EVASIVE_DEFAULT_PROFILE) -> StepResult:
+ """Write the mod_evasive configuration with the given threshold profile.
- Deploys a baseline evasive.conf with thresholds tuned for
- NetSapiens traffic patterns and whitelists for localhost/internal IPs.
+ Profiles:
+ - "standard" (default): high thresholds, only catches extreme floods.
+ - "strict": tighter thresholds tuned for NetSapiens traffic patterns.
"""
if not self.install_evasive:
return StepResult(skipped=True, message="Evasive installation skipped")
+ if profile not in EVASIVE_PROFILES:
+ return StepResult(success=False, error=f"Unknown evasive profile: {profile}")
if self.dry_run:
- return StepResult(message=f"Would write {EVASIVE_CONF}")
+ return StepResult(message=f"Would write {EVASIVE_CONF} (profile: {profile})")
if file_exists(EVASIVE_CONF):
backup_file(EVASIVE_CONF)
- content = render(EVASIVE_CONF_TEMPLATE, log_dir=EVASIVE_LOG_DIR)
+ thresholds = EVASIVE_PROFILES[profile]
+ content = render(
+ EVASIVE_CONF_TEMPLATE,
+ profile=profile,
+ log_dir=EVASIVE_LOG_DIR,
+ log_file=EVASIVE_LOG_FILE,
+ **thresholds,
+ )
if not write_file(EVASIVE_CONF, content):
return StepResult(success=False, error=f"Failed to write {EVASIVE_CONF}")
Path(EVASIVE_LOG_DIR).mkdir(parents=True, exist_ok=True)
- return StepResult(message=f"Configured mod_evasive ({EVASIVE_CONF})")
+ return StepResult(message=f"Configured mod_evasive ({EVASIVE_CONF}, profile: {profile})")
def set_evasive_state(self, enable: bool) -> StepResult:
"""Enable or disable the mod_evasive Apache module.
@@ -505,13 +512,12 @@ def run(self, admin_ips: list[str] | None = None) -> InstallResult:
if pf.apache_installed and not pf.apache_running:
result.warnings.append("Apache2 is installed but not running")
- evasive_enable = self.mode == "On"
steps = [
("Install packages", self.install_packages),
("Enable Apache modules", self.enable_modules),
("Configure ModSecurity", self.setup_config),
("Configure mod_evasive", self.setup_evasive_config),
- ("Set mod_evasive state", lambda: self.set_evasive_state(evasive_enable)),
+ ("Enable mod_evasive", lambda: self.set_evasive_state(True)),
("Install OWASP CRS v4", self.install_crs_v4),
("Install NS exclusions", lambda: self.install_exclusions(admin_ips)),
("Update security2.conf", self.write_security2_conf),
@@ -536,11 +542,10 @@ def run(self, admin_ips: list[str] | None = None) -> InstallResult:
# ------------------------------------------------------------------
def set_mode(self, mode: str) -> StepResult:
- """Change SecRuleEngine mode and toggle mod_evasive accordingly.
+ """Change SecRuleEngine mode.
- When switching to 'On' (blocking), mod_evasive is enabled.
- When switching to 'DetectionOnly', mod_evasive is disabled so it
- does not block traffic while the WAF is still being tuned.
+ Only changes the ModSecurity engine mode. mod_evasive is managed
+ independently via 'nssec waf evasive enable/disable'.
"""
content = read_file(MODSEC_CONF)
if not content:
@@ -565,10 +570,6 @@ def set_mode(self, mode: str) -> StepResult:
if not write_file(MODSEC_CONF, "\n".join(new_lines) + "\n"):
return StepResult(success=False, error=f"Failed to write {MODSEC_CONF}")
- # Toggle mod_evasive: enabled in blocking mode, disabled in detect mode
- evasive_enable = mode == "On"
- evasive_result = self.set_evasive_state(evasive_enable)
-
stdout, stderr, rc = run_cmd(["apache2ctl", "configtest"])
if rc != 0:
self._rollback()
@@ -579,14 +580,5 @@ def set_mode(self, mode: str) -> StepResult:
if rc != 0:
return StepResult(success=False, error=f"Apache reload failed: {stderr}")
- evasive_state = "enabled" if evasive_enable else "disabled"
- evasive_note = ""
- if evasive_result.skipped:
- evasive_note = f" (mod_evasive: {evasive_result.message})"
- elif evasive_result.success:
- evasive_note = f" (mod_evasive {evasive_state})"
- else:
- evasive_note = f" (warning: mod_evasive toggle failed: {evasive_result.error})"
-
- msg = f"SecRuleEngine set to {mode} and Apache reloaded{evasive_note}"
+ msg = f"SecRuleEngine set to {mode} and Apache reloaded"
return StepResult(message=msg)
diff --git a/src/nssec/modules/waf/config.py b/src/nssec/modules/waf/config.py
index 25a2c30..4db0dbe 100644
--- a/src/nssec/modules/waf/config.py
+++ b/src/nssec/modules/waf/config.py
@@ -7,6 +7,30 @@
EVASIVE_CONF = "/etc/apache2/mods-available/evasive.conf"
EVASIVE_LOAD = "/etc/apache2/mods-enabled/evasive.load"
EVASIVE_LOG_DIR = "/var/log/apache2/mod_evasive"
+EVASIVE_LOG_FILE = "/var/log/apache2/mod_evasive.log"
+
+# mod_evasive threshold profiles
+# "standard" — high thresholds, safe default that only catches extreme floods.
+# "strict" — tighter thresholds tuned for NetSapiens traffic patterns.
+EVASIVE_PROFILES = {
+ "standard": {
+ "hash_table_size": 3097,
+ "page_count": 100,
+ "site_count": 500,
+ "page_interval": 1,
+ "site_interval": 1,
+ "blocking_period": 10,
+ },
+ "strict": {
+ "hash_table_size": 3097,
+ "page_count": 15,
+ "site_count": 60,
+ "page_interval": 1,
+ "site_interval": 1,
+ "blocking_period": 60,
+ },
+}
+EVASIVE_DEFAULT_PROFILE = "standard"
# CRS version pinning (used when apt ships v3.x)
PINNED_CRS_VERSION = "4.8.0"
@@ -302,38 +326,101 @@
setvar:'tx.allowed_request_content_type=|application/x-www-form-urlencoded| |multipart/form-data| |multipart/related| |multipart/mixed| |text/xml| |application/xml| |application/soap+xml| |application/json| |application/cloudevents+json| |application/cloudevents-batch+json|'"
"""
+# ---------------------------------------------------------------------------
+# .htaccess IP Restriction Constants
+# ---------------------------------------------------------------------------
+
+RESTRICT_MANAGED_MARKER = "# Managed by nssec"
+
+RESTRICT_TARGETS = [
+ {
+ "name": "SiPbx Admin UI",
+ "htaccess_path": "/usr/local/NetSapiens/SiPbx/html/SiPbx/.htaccess",
+ "directory": "/usr/local/NetSapiens/SiPbx/html/SiPbx",
+ "file_target": "adminlogin.php",
+ "server_types": ["core", "combo"],
+ },
+ {
+ "name": "ns-api",
+ "htaccess_path": "/usr/local/NetSapiens/SiPbx/html/ns-api/.htaccess",
+ "directory": "/usr/local/NetSapiens/SiPbx/html/ns-api",
+ "file_target": "adminlogin.php",
+ "server_types": ["core", "combo"],
+ },
+ {
+ "name": "NDP Endpoints",
+ "htaccess_path": "/usr/local/NetSapiens/ndp/.htaccess",
+ "directory": "/usr/local/NetSapiens/ndp",
+ "file_target": "adminlogin.php",
+ "server_types": ["ndp", "combo"],
+ },
+ {
+ "name": "LiCf Recording",
+ "htaccess_path": "/usr/local/NetSapiens/LiCf/html/LiCf/.htaccess",
+ "directory": "/usr/local/NetSapiens/LiCf/html/LiCf",
+ "file_target": "adminlogin.php",
+ "server_types": ["recording", "combo"],
+ },
+]
+
+HTACCESS_DIR_TEMPLATE = """\
+{{ managed_marker }}
+# Generated by nssec waf restrict
+Order allow,deny
+{%- for ip in ips %}
+Allow from {{ ip }}
+{%- endfor %}
+"""
+
+HTACCESS_FILE_TEMPLATE = """\
+{{ managed_marker }}
+# Generated by nssec waf restrict
+
+ Order allow,deny
+{%- for ip in ips %}
+ Allow from {{ ip }}
+{%- endfor %}
+
+"""
+
+# Cache file so restrictions survive NS package upgrades that overwrite .htaccess
+RESTRICT_CACHE_PATH = "/etc/nssec/restrict-ips.json"
+
EVASIVE_CONF_TEMPLATE = """\
# mod_evasive Configuration
# Managed by nssec
# Generated: {{ timestamp }}
+# Profile: {{ profile }}
#
# HTTP flood / DDoS protection for Apache.
-# Thresholds tuned for NetSapiens traffic patterns (~270 req/s sustained,
-# peak ~318 req/s across hundreds of IPs).
+# WARNING: mod_evasive has no detection-only mode. When enabled it WILL
+# return HTTP 403 to clients that exceed the thresholds below.
+# Review your traffic with 'nssec waf status' and the Apache API Usage
+# dashboard before switching to the strict profile.
# Hash table size — prime number with headroom above expected unique IPs
- DOSHashTableSize 3097
+ DOSHashTableSize {{ hash_table_size }}
# Max requests to the same page per interval before blocking
- # Tightened from default (2) — scanner patterns warrant 15/s per-page
- DOSPageCount 15
+ DOSPageCount {{ page_count }}
# Max total requests from one IP per interval before blocking
- # Peak burst is ~318 req/s total across ~372 IPs; 60/s per-IP is generous
- DOSSiteCount 60
+ DOSSiteCount {{ site_count }}
# Sliding window intervals (seconds)
- DOSPageInterval 1
- DOSSiteInterval 1
+ DOSPageInterval {{ page_interval }}
+ DOSSiteInterval {{ site_interval }}
# How long (seconds) an IP is blocked once a threshold is hit
- # 60s block breaks automated scanner loops without permanent impact
- DOSBlockingPeriod 60
+ DOSBlockingPeriod {{ blocking_period }}
- # Log blocked IPs here
+ # Log blocked IPs here (one file per IP)
DOSLogDir {{ log_dir }}
+ # Log block events to a structured log file for Loki/Grafana ingestion
+ DOSSystemCommand "/bin/sh -c 'echo $(date -Is) action=blocked src_ip=%s >> {{ log_file }}'"
+
# Whitelist RFC 1918 private ranges and loopback to avoid false positives
# on internal NS service traffic and cluster communication
DOSWhitelist 127.0.0.1
diff --git a/src/nssec/modules/waf/restrict.py b/src/nssec/modules/waf/restrict.py
new file mode 100644
index 0000000..9ef89ed
--- /dev/null
+++ b/src/nssec/modules/waf/restrict.py
@@ -0,0 +1,485 @@
+""".htaccess IP restriction management for NetSapiens paths."""
+
+from __future__ import annotations
+
+import json
+import re
+
+from nssec.core.ssh import is_directory
+from nssec.modules.waf.config import (
+ HTACCESS_DIR_TEMPLATE,
+ HTACCESS_FILE_TEMPLATE,
+ RESTRICT_CACHE_PATH,
+ RESTRICT_MANAGED_MARKER,
+ RESTRICT_TARGETS,
+)
+from nssec.modules.waf.types import StepResult
+from nssec.modules.waf.utils import (
+ backup_file,
+ file_exists,
+ read_file,
+ render,
+ write_file,
+)
+
+
+def load_cached_ips() -> list[str]:
+ """Load saved IP list from the restrict cache file.
+
+ Returns:
+ List of cached IPs, or empty list if cache doesn't exist.
+ """
+ content = read_file(RESTRICT_CACHE_PATH)
+ if not content:
+ return []
+ try:
+ data = json.loads(content)
+ return data.get("ips", [])
+ except (json.JSONDecodeError, AttributeError):
+ return []
+
+
+def save_cached_ips(ips: list[str]) -> bool:
+ """Save IP list to the restrict cache file.
+
+ Args:
+ ips: List of IP addresses/CIDRs to save.
+
+ Returns:
+ True on success, False on write failure.
+ """
+ data = {"ips": ips}
+ return write_file(RESTRICT_CACHE_PATH, json.dumps(data, indent=2) + "\n")
+
+
+def get_applicable_targets(server_type: str) -> list[dict]:
+ """Filter RESTRICT_TARGETS by server type and directory existence.
+
+ Args:
+ server_type: Server type string (e.g. "core", "ndp", "combo").
+
+ Returns:
+ List of target dicts whose server_types include server_type
+ and whose directory exists on the filesystem.
+ """
+ targets = []
+ for target in RESTRICT_TARGETS:
+ if server_type not in target["server_types"]:
+ continue
+ if not is_directory(target["directory"]):
+ continue
+ targets.append(target)
+ return targets
+
+
+def parse_htaccess_ips(path: str) -> list[str]:
+ """Extract IP addresses from an .htaccess file.
+
+ Supports both Apache 2.4 syntax (Require ip) and legacy 2.2 syntax
+ (Allow from) so IPs can be preserved when upgrading from hand-crafted
+ files.
+
+ Args:
+ path: Path to the .htaccess file.
+
+ Returns:
+ List of IP addresses/CIDRs found.
+ """
+ content = read_file(path)
+ if not content:
+ return []
+ ips: list[str] = []
+ # Apache 2.4: Require ip
+ ips.extend(re.findall(r"Require\s+ip\s+(\S+)", content))
+ # Legacy Apache 2.2: Allow from
+ for match in re.findall(r"Allow\s+from\s+(.+)", content):
+ for token in match.split():
+ stripped = token.strip().rstrip(",")
+ if stripped and stripped.lower() != "all":
+ ips.append(stripped)
+ # Deduplicate while preserving order
+ seen: set[str] = set()
+ unique: list[str] = []
+ for ip in ips:
+ if ip not in seen:
+ seen.add(ip)
+ unique.append(ip)
+ return unique
+
+
+def is_nssec_managed(path: str) -> bool:
+ """Check if an .htaccess file was created by nssec.
+
+ Args:
+ path: Path to the .htaccess file.
+
+ Returns:
+ True if the file contains the managed marker.
+ """
+ content = read_file(path)
+ if not content:
+ return False
+ return RESTRICT_MANAGED_MARKER in content
+
+
+def get_restrict_status(server_type: str) -> list[dict]:
+ """Return status of all applicable restriction targets.
+
+ Args:
+ server_type: Server type string.
+
+ Returns:
+ List of dicts with keys: name, path, exists, managed, ips.
+ """
+ statuses = []
+ for target in RESTRICT_TARGETS:
+ if server_type not in target["server_types"]:
+ continue
+
+ path = target["htaccess_path"]
+ dir_exists = is_directory(target["directory"])
+
+ if not dir_exists:
+ continue
+
+ exists = file_exists(path)
+ managed = is_nssec_managed(path) if exists else False
+ ips = parse_htaccess_ips(path) if exists else []
+
+ statuses.append({
+ "name": target["name"],
+ "path": path,
+ "exists": exists,
+ "managed": managed,
+ "ips": ips,
+ })
+ return statuses
+
+
+def _render_htaccess(target: dict, ips: list[str]) -> str:
+ """Render the appropriate .htaccess template for a target."""
+ if target["file_target"]:
+ content = render(
+ HTACCESS_FILE_TEMPLATE,
+ managed_marker=RESTRICT_MANAGED_MARKER,
+ file_target=target["file_target"],
+ ips=ips,
+ )
+ else:
+ content = render(
+ HTACCESS_DIR_TEMPLATE,
+ managed_marker=RESTRICT_MANAGED_MARKER,
+ ips=ips,
+ )
+ # Jinja2 strips the trailing newline; ensure file ends with one
+ if not content.endswith("\n"):
+ content += "\n"
+ return content
+
+
+def collect_existing_ips(server_type: str) -> list[str]:
+ """Gather all unique IPs from existing .htaccess files for applicable targets.
+
+ Reads both Apache 2.4 (Require ip) and legacy 2.2 (Allow from) syntax.
+ Also includes any IPs from the restrict cache.
+
+ Args:
+ server_type: Server type string.
+
+ Returns:
+ Deduplicated list of IPs found, excluding 127.0.0.1.
+ """
+ seen: set[str] = set()
+ result: list[str] = []
+
+ for target in RESTRICT_TARGETS:
+ if server_type not in target["server_types"]:
+ continue
+ if not is_directory(target["directory"]):
+ continue
+ if not file_exists(target["htaccess_path"]):
+ continue
+ for ip in parse_htaccess_ips(target["htaccess_path"]):
+ if ip != "127.0.0.1" and ip not in seen:
+ seen.add(ip)
+ result.append(ip)
+
+ for ip in load_cached_ips():
+ if ip != "127.0.0.1" and ip not in seen:
+ seen.add(ip)
+ result.append(ip)
+
+ return result
+
+
+def init_restrictions(
+ server_type: str,
+ ips: list[str],
+ dry_run: bool = False,
+ merge_existing: bool = True,
+) -> list[tuple[str, StepResult]]:
+ """Create .htaccess files with provided IPs for all applicable targets.
+
+ 127.0.0.1 is always included automatically.
+
+ Args:
+ server_type: Server type string.
+ ips: List of IP addresses/CIDRs to allow.
+ dry_run: Show what would be done without making changes.
+ merge_existing: If True, merge IPs from cache and existing .htaccess
+ files on disk. If False, only use the provided *ips* list
+ (plus 127.0.0.1).
+
+ Returns:
+ List of (target_name, StepResult) tuples.
+ """
+ all_ips = ["127.0.0.1"] + [ip for ip in ips if ip != "127.0.0.1"]
+
+ # Merge IPs from all existing .htaccess files and cache so every target
+ # gets the full set (not just the IPs from its own file).
+ if merge_existing:
+ for existing_ip in collect_existing_ips(server_type):
+ if existing_ip not in all_ips:
+ all_ips.append(existing_ip)
+
+ targets = get_applicable_targets(server_type)
+ results: list[tuple[str, StepResult]] = []
+
+ if not targets:
+ results.append(("", StepResult(
+ skipped=True,
+ message="No applicable targets found for this server type",
+ )))
+ return results
+
+ for target in targets:
+ path = target["htaccess_path"]
+ name = target["name"]
+
+ if dry_run:
+ results.append((name, StepResult(
+ message=f"Would create {path} with {len(all_ips)} IP(s)",
+ )))
+ continue
+
+ if file_exists(path):
+ backup_file(path)
+
+ content = _render_htaccess(target, all_ips)
+ if not write_file(path, content):
+ results.append((name, StepResult(
+ success=False,
+ error=f"Failed to write {path}",
+ )))
+ continue
+
+ results.append((name, StepResult(
+ message=f"Created {path} with {len(all_ips)} IP(s)",
+ )))
+
+ # Save the full IP set to cache for reapply after upgrades
+ if not dry_run:
+ save_cached_ips(all_ips)
+
+ return results
+
+
+def add_restricted_ip(
+ server_type: str,
+ ip: str,
+) -> list[tuple[str, StepResult]]:
+ """Add an IP to all managed .htaccess files.
+
+ Args:
+ server_type: Server type string.
+ ip: IP address or CIDR to add.
+
+ Returns:
+ List of (target_name, StepResult) tuples.
+ """
+ targets = get_applicable_targets(server_type)
+ results: list[tuple[str, StepResult]] = []
+
+ for target in targets:
+ path = target["htaccess_path"]
+ name = target["name"]
+
+ if not file_exists(path):
+ results.append((name, StepResult(
+ skipped=True,
+ message=f"No .htaccess at {path} (run init first)",
+ )))
+ continue
+
+ if not is_nssec_managed(path):
+ results.append((name, StepResult(
+ skipped=True,
+ message=f"Skipping unmanaged {path}",
+ )))
+ continue
+
+ current_ips = parse_htaccess_ips(path)
+ if ip in current_ips:
+ results.append((name, StepResult(
+ skipped=True,
+ message=f"{ip} already in {path}",
+ )))
+ continue
+
+ new_ips = current_ips + [ip]
+ backup_file(path)
+ content = _render_htaccess(target, new_ips)
+ if not write_file(path, content):
+ results.append((name, StepResult(
+ success=False,
+ error=f"Failed to write {path}",
+ )))
+ continue
+
+ results.append((name, StepResult(
+ message=f"Added {ip} to {path}",
+ )))
+
+ # Update cache with new IP
+ cached = load_cached_ips()
+ if ip not in cached:
+ cached.append(ip)
+ save_cached_ips(cached)
+
+ return results
+
+
+def remove_restricted_ip(
+ server_type: str,
+ ip: str,
+) -> list[tuple[str, StepResult]]:
+ """Remove an IP from all managed .htaccess files.
+
+ Refuses to remove 127.0.0.1.
+
+ Args:
+ server_type: Server type string.
+ ip: IP address or CIDR to remove.
+
+ Returns:
+ List of (target_name, StepResult) tuples.
+ """
+ if ip == "127.0.0.1":
+ return [("", StepResult(
+ success=False,
+ error="Cannot remove 127.0.0.1 (localhost must always be allowed)",
+ ))]
+
+ targets = get_applicable_targets(server_type)
+ results: list[tuple[str, StepResult]] = []
+
+ for target in targets:
+ path = target["htaccess_path"]
+ name = target["name"]
+
+ if not file_exists(path):
+ results.append((name, StepResult(
+ skipped=True,
+ message=f"No .htaccess at {path}",
+ )))
+ continue
+
+ if not is_nssec_managed(path):
+ results.append((name, StepResult(
+ skipped=True,
+ message=f"Skipping unmanaged {path}",
+ )))
+ continue
+
+ current_ips = parse_htaccess_ips(path)
+ if ip not in current_ips:
+ results.append((name, StepResult(
+ skipped=True,
+ message=f"{ip} not found in {path}",
+ )))
+ continue
+
+ new_ips = [existing for existing in current_ips if existing != ip]
+ backup_file(path)
+ content = _render_htaccess(target, new_ips)
+ if not write_file(path, content):
+ results.append((name, StepResult(
+ success=False,
+ error=f"Failed to write {path}",
+ )))
+ continue
+
+ results.append((name, StepResult(
+ message=f"Removed {ip} from {path}",
+ )))
+
+ # Update cache — remove this IP
+ cached = load_cached_ips()
+ if ip in cached:
+ cached = [c for c in cached if c != ip]
+ save_cached_ips(cached)
+
+ return results
+
+
+def reapply_restrictions(
+ server_type: str,
+ dry_run: bool = False,
+) -> list[tuple[str, StepResult]]:
+ """Re-deploy .htaccess files from the cached IP list.
+
+ Use after a NetSapiens package upgrade overwrites .htaccess files.
+
+ Args:
+ server_type: Server type string.
+ dry_run: Show what would be done without making changes.
+
+ Returns:
+ List of (target_name, StepResult) tuples.
+ """
+ cached_ips = load_cached_ips()
+ if not cached_ips:
+ return [("", StepResult(
+ skipped=True,
+ message=f"No cached IPs found in {RESTRICT_CACHE_PATH} (run init first)",
+ ))]
+
+ # Ensure 127.0.0.1 is first
+ ips = ["127.0.0.1"] + [ip for ip in cached_ips if ip != "127.0.0.1"]
+
+ targets = get_applicable_targets(server_type)
+ results: list[tuple[str, StepResult]] = []
+
+ if not targets:
+ results.append(("", StepResult(
+ skipped=True,
+ message="No applicable targets found for this server type",
+ )))
+ return results
+
+ for target in targets:
+ path = target["htaccess_path"]
+ name = target["name"]
+
+ if dry_run:
+ results.append((name, StepResult(
+ message=f"Would write {path} with {len(ips)} cached IP(s)",
+ )))
+ continue
+
+ if file_exists(path):
+ backup_file(path)
+
+ content = _render_htaccess(target, ips)
+ if not write_file(path, content):
+ results.append((name, StepResult(
+ success=False,
+ error=f"Failed to write {path}",
+ )))
+ continue
+
+ results.append((name, StepResult(
+ message=f"Restored {path} with {len(ips)} cached IP(s)",
+ )))
+
+ return results
diff --git a/tests/unit/test_mtls_commands.py b/tests/unit/test_mtls_commands.py
new file mode 100644
index 0000000..de0ef36
--- /dev/null
+++ b/tests/unit/test_mtls_commands.py
@@ -0,0 +1,226 @@
+"""Tests for mTLS CLI commands."""
+
+from unittest.mock import patch
+
+import pytest
+from click.testing import CliRunner
+
+from nssec.cli.mtls_commands import mtls
+from nssec.modules.mtls import StepResult
+
+
+@pytest.fixture
+def runner():
+ """Click CLI test runner."""
+ return CliRunner()
+
+
+@pytest.fixture
+def mock_mtls_file_ops():
+ """Mock file operations for mTLS module."""
+ with (
+ patch("nssec.modules.mtls.file_exists") as exists,
+ patch("nssec.modules.mtls.read_file") as read,
+ patch("nssec.modules.mtls.write_file") as write,
+ patch("nssec.modules.mtls.backup_file") as backup,
+ ):
+ exists.return_value = True
+ read.return_value = ""
+ write.return_value = True
+ backup.return_value = "/tmp/backup"
+ yield {
+ "exists": exists,
+ "read": read,
+ "write": write,
+ "backup": backup,
+ }
+
+
+SAMPLE_CONF_WITH_IPS = """\
+
+ SSLVerifyClient require
+
+ Require ip 10.0.0.1
+ Require ip 192.168.1.0/24
+
+ # BEGIN nssec-managed NodePing IPs (do not edit)
+ Require ip 1.2.3.4
+ Require ip 5.6.7.8
+ # END nssec-managed NodePing IPs
+
+
+"""
+
+
+class TestMtlsHelp:
+ """Tests for mtls group help output."""
+
+ def test_shows_commands_when_no_subcommand(self, runner):
+ result = runner.invoke(mtls, [])
+ assert result.exit_code == 0
+ assert "Allowlist Commands" in result.output
+ assert "NodePing Commands" in result.output
+ assert "allowlist show" in result.output
+ assert "nodeping show" in result.output
+
+
+class TestAllowlistShow:
+ """Tests for mtls allowlist show command."""
+
+ def test_shows_all_ips(self, runner):
+ with (
+ patch("nssec.modules.mtls.utils.file_exists", return_value=True),
+ patch(
+ "nssec.modules.mtls.get_allowlist_ips",
+ return_value=[
+ {"ip": "10.0.0.1", "managed": False},
+ {"ip": "1.2.3.4", "managed": True},
+ ],
+ ),
+ ):
+ result = runner.invoke(mtls, ["allowlist", "show"])
+
+ assert result.exit_code == 0
+ assert "10.0.0.1" in result.output
+ assert "1.2.3.4" in result.output
+ assert "Manual Allowlist" in result.output
+ assert "NodePing" in result.output
+
+ def test_shows_config_not_found(self, runner):
+ with patch("nssec.modules.mtls.utils.file_exists", return_value=False):
+ result = runner.invoke(mtls, ["allowlist", "show"])
+
+ assert result.exit_code == 0
+ assert "not found" in result.output
+
+ def test_shows_empty_allowlist(self, runner):
+ with (
+ patch("nssec.modules.mtls.utils.file_exists", return_value=True),
+ patch("nssec.modules.mtls.get_allowlist_ips", return_value=[]),
+ ):
+ result = runner.invoke(mtls, ["allowlist", "show"])
+
+ assert result.exit_code == 0
+ assert "No IPs" in result.output
+
+ def test_default_subcommand_shows_allowlist(self, runner):
+ """Running 'mtls allowlist' without subcommand should show IPs."""
+ with (
+ patch("nssec.modules.mtls.utils.file_exists", return_value=True),
+ patch("nssec.modules.mtls.get_allowlist_ips", return_value=[]),
+ ):
+ result = runner.invoke(mtls, ["allowlist"])
+
+ assert result.exit_code == 0
+ assert "No IPs" in result.output
+
+
+class TestAllowlistAdd:
+ """Tests for mtls allowlist add command."""
+
+ def test_adds_ip(self, runner):
+ with (
+ patch("nssec.core.ssh.is_root", return_value=True),
+ patch(
+ "nssec.modules.mtls.add_allowlist_ip",
+ return_value=StepResult(message="Added 203.0.113.1 to mTLS allowlist"),
+ ),
+ patch(
+ "nssec.modules.mtls.validate_apache_config",
+ return_value=StepResult(message="Apache config test passed"),
+ ),
+ patch(
+ "nssec.modules.mtls.reload_apache",
+ return_value=StepResult(message="Apache reloaded"),
+ ),
+ ):
+ result = runner.invoke(mtls, ["allowlist", "add", "203.0.113.1", "-y"])
+
+ assert result.exit_code == 0
+ assert "Added" in result.output
+
+ def test_requires_root(self, runner):
+ with patch("nssec.core.ssh.is_root", return_value=False):
+ result = runner.invoke(mtls, ["allowlist", "add", "203.0.113.1", "-y"])
+
+ assert result.exit_code == 1
+ assert "root" in result.output.lower()
+
+ def test_rejects_invalid_ip(self, runner):
+ with patch("nssec.core.ssh.is_root", return_value=True):
+ result = runner.invoke(mtls, ["allowlist", "add", "not-an-ip", "-y"])
+
+ assert result.exit_code == 1
+ assert "not a valid IP" in result.output
+
+ def test_shows_error_for_duplicate(self, runner):
+ with (
+ patch("nssec.core.ssh.is_root", return_value=True),
+ patch(
+ "nssec.modules.mtls.add_allowlist_ip",
+ return_value=StepResult(
+ success=False, error="IP 10.0.0.1 is already in the allowlist"
+ ),
+ ),
+ ):
+ result = runner.invoke(mtls, ["allowlist", "add", "10.0.0.1", "-y"])
+
+ assert result.exit_code == 1
+ assert "already" in result.output
+
+
+class TestAllowlistRemove:
+ """Tests for mtls allowlist remove command."""
+
+ def test_removes_ip(self, runner):
+ with (
+ patch("nssec.core.ssh.is_root", return_value=True),
+ patch(
+ "nssec.modules.mtls.remove_allowlist_ip",
+ return_value=StepResult(message="Removed 10.0.0.1 from mTLS allowlist"),
+ ),
+ patch(
+ "nssec.modules.mtls.validate_apache_config",
+ return_value=StepResult(message="Apache config test passed"),
+ ),
+ patch(
+ "nssec.modules.mtls.reload_apache",
+ return_value=StepResult(message="Apache reloaded"),
+ ),
+ ):
+ result = runner.invoke(mtls, ["allowlist", "remove", "10.0.0.1", "-y"])
+
+ assert result.exit_code == 0
+ assert "Removed" in result.output
+
+ def test_requires_root(self, runner):
+ with patch("nssec.core.ssh.is_root", return_value=False):
+ result = runner.invoke(mtls, ["allowlist", "remove", "10.0.0.1", "-y"])
+
+ assert result.exit_code == 1
+ assert "root" in result.output.lower()
+
+ def test_prompts_without_yes_flag(self, runner):
+ with (
+ patch("nssec.core.ssh.is_root", return_value=True),
+ ):
+ result = runner.invoke(mtls, ["allowlist", "remove", "10.0.0.1"], input="n\n")
+
+ assert result.exit_code == 0
+ assert "Aborted" in result.output
+
+ def test_blocks_removal_of_managed_ip(self, runner):
+ with (
+ patch("nssec.core.ssh.is_root", return_value=True),
+ patch(
+ "nssec.modules.mtls.remove_allowlist_ip",
+ return_value=StepResult(
+ success=False,
+ error="IP 1.2.3.4 is managed by NodePing auto-updates.",
+ ),
+ ),
+ ):
+ result = runner.invoke(mtls, ["allowlist", "remove", "1.2.3.4", "-y"])
+
+ assert result.exit_code == 1
+ assert "managed by NodePing" in result.output
diff --git a/tests/unit/test_mtls_module.py b/tests/unit/test_mtls_module.py
index b5aa73d..6960bab 100644
--- a/tests/unit/test_mtls_module.py
+++ b/tests/unit/test_mtls_module.py
@@ -2,10 +2,14 @@
from nssec.modules.mtls.config import NODEPING_BEGIN_MARKER, NODEPING_END_MARKER
from nssec.modules.mtls.utils import (
+ add_ip_to_requireany,
build_managed_section,
find_requireany_block,
+ get_all_requireany_ips,
get_managed_section,
+ get_requireany_bounds,
parse_ip_list,
+ remove_ip_from_requireany,
)
@@ -163,3 +167,130 @@ def test_returns_negative_when_no_requireany(self):
"""
pos = find_requireany_block(content)
assert pos == -1
+
+
+SAMPLE_CONF = f"""
+
+ SSLVerifyClient require
+
+ Require ip 10.0.0.1
+ Require ip 192.168.1.0/24
+ {NODEPING_BEGIN_MARKER}
+ Require ip 1.2.3.4
+ Require ip 5.6.7.8
+ {NODEPING_END_MARKER}
+
+
+"""
+
+SAMPLE_CONF_NO_MANAGED = """
+
+ SSLVerifyClient require
+
+ Require ip 10.0.0.1
+ Require ip 192.168.1.0/24
+
+
+"""
+
+
+class TestGetRequireanyBounds:
+ """Tests for get_requireany_bounds function."""
+
+ def test_finds_block_bounds(self):
+ start, end = get_requireany_bounds(SAMPLE_CONF)
+ assert start > 0
+ assert end > start
+ block = SAMPLE_CONF[start:end]
+ assert "Require ip 10.0.0.1" in block
+ assert "" not in block
+
+ def test_returns_negative_when_no_location(self):
+ content = "\n Require ip 1.2.3.4\n"
+ start, end = get_requireany_bounds(content)
+ assert start == -1
+ assert end == -1
+
+ def test_returns_negative_when_no_requireany(self):
+ content = "\n Require ip 1.2.3.4\n"
+ start, end = get_requireany_bounds(content)
+ assert start == -1
+ assert end == -1
+
+
+class TestGetAllRequireanyIps:
+ """Tests for get_all_requireany_ips function."""
+
+ def test_returns_all_ips_with_managed_flag(self):
+ results = get_all_requireany_ips(SAMPLE_CONF)
+ ips = [r["ip"] for r in results]
+ assert "10.0.0.1" in ips
+ assert "192.168.1.0/24" in ips
+ assert "1.2.3.4" in ips
+ assert "5.6.7.8" in ips
+
+ def test_marks_managed_ips_correctly(self):
+ results = get_all_requireany_ips(SAMPLE_CONF)
+ by_ip = {r["ip"]: r["managed"] for r in results}
+ assert by_ip["10.0.0.1"] is False
+ assert by_ip["192.168.1.0/24"] is False
+ assert by_ip["1.2.3.4"] is True
+ assert by_ip["5.6.7.8"] is True
+
+ def test_no_managed_section(self):
+ results = get_all_requireany_ips(SAMPLE_CONF_NO_MANAGED)
+ assert len(results) == 2
+ assert all(not r["managed"] for r in results)
+
+ def test_empty_content(self):
+ results = get_all_requireany_ips("")
+ assert results == []
+
+
+class TestAddIpToRequireany:
+ """Tests for add_ip_to_requireany function."""
+
+ def test_adds_ip_to_block(self):
+ new_content, error = add_ip_to_requireany(SAMPLE_CONF_NO_MANAGED, "203.0.113.1")
+ assert error == ""
+ assert "Require ip 203.0.113.1" in new_content
+
+ def test_rejects_duplicate_ip(self):
+ _, error = add_ip_to_requireany(SAMPLE_CONF_NO_MANAGED, "10.0.0.1")
+ assert "already in the allowlist" in error
+
+ def test_adds_outside_managed_section(self):
+ new_content, error = add_ip_to_requireany(SAMPLE_CONF, "203.0.113.1")
+ assert error == ""
+ assert "Require ip 203.0.113.1" in new_content
+ # The new IP should be before the managed section
+ new_ip_pos = new_content.find("Require ip 203.0.113.1")
+ managed_pos = new_content.find(NODEPING_BEGIN_MARKER)
+ assert new_ip_pos < managed_pos
+
+ def test_returns_error_when_no_block(self):
+ _, error = add_ip_to_requireany("no block here", "1.2.3.4")
+ assert "Could not find" in error
+
+
+class TestRemoveIpFromRequireany:
+ """Tests for remove_ip_from_requireany function."""
+
+ def test_removes_manual_ip(self):
+ new_content, error = remove_ip_from_requireany(SAMPLE_CONF, "10.0.0.1")
+ assert error == ""
+ assert "Require ip 10.0.0.1" not in new_content
+ # Other IPs should remain
+ assert "Require ip 192.168.1.0/24" in new_content
+
+ def test_blocks_removal_of_managed_ip(self):
+ _, error = remove_ip_from_requireany(SAMPLE_CONF, "1.2.3.4")
+ assert "managed by NodePing" in error
+
+ def test_returns_error_for_missing_ip(self):
+ _, error = remove_ip_from_requireany(SAMPLE_CONF, "99.99.99.99")
+ assert "not found" in error
+
+ def test_returns_error_when_no_block(self):
+ _, error = remove_ip_from_requireany("no block here", "1.2.3.4")
+ assert "Could not find" in error
diff --git a/tests/unit/test_waf_commands.py b/tests/unit/test_waf_commands.py
index 2f8e70f..2041709 100644
--- a/tests/unit/test_waf_commands.py
+++ b/tests/unit/test_waf_commands.py
@@ -217,3 +217,110 @@ def test_default_subcommand_shows_list(self, runner):
assert result.exit_code == 0
assert "192.168.1.100" in result.output
+
+
+class TestWafEvasiveEnable:
+ """Tests for waf evasive enable command."""
+
+ def test_enables_evasive(self, runner, mock_installer):
+ """Should enable mod_evasive."""
+ mock_installer.set_evasive_state.return_value = MagicMock(
+ success=True, skipped=False, message="Enabled mod_evasive"
+ )
+
+ with patch("nssec.modules.waf.utils.package_installed", return_value=True):
+ result = runner.invoke(waf, ["evasive", "enable", "-y"])
+
+ assert result.exit_code == 0
+ mock_installer.set_evasive_state.assert_called_once_with(enable=True)
+
+ def test_skips_if_already_enabled(self, runner, mock_installer):
+ """Should report already enabled."""
+ mock_installer.set_evasive_state.return_value = MagicMock(
+ success=True, skipped=True, message="mod_evasive already enabled"
+ )
+
+ with patch("nssec.modules.waf.utils.package_installed", return_value=True):
+ result = runner.invoke(waf, ["evasive", "enable", "-y"])
+
+ assert result.exit_code == 0
+ assert "already enabled" in result.output
+
+ def test_requires_root(self, runner, mock_installer):
+ """Should fail if not root."""
+ mock_installer.preflight.return_value.is_root = False
+
+ result = runner.invoke(waf, ["evasive", "enable", "-y"])
+
+ assert result.exit_code == 1
+ assert "root" in result.output.lower()
+
+ def test_requires_package_installed(self, runner, mock_installer):
+ """Should fail if mod_evasive package not installed."""
+ with patch("nssec.modules.waf.utils.package_installed", return_value=False):
+ result = runner.invoke(waf, ["evasive", "enable", "-y"])
+
+ assert result.exit_code == 1
+ assert "not installed" in result.output.lower()
+
+
+class TestWafEvasiveDisable:
+ """Tests for waf evasive disable command."""
+
+ def test_disables_evasive(self, runner, mock_installer):
+ """Should disable mod_evasive."""
+ mock_installer.set_evasive_state.return_value = MagicMock(
+ success=True, skipped=False, message="Disabled mod_evasive"
+ )
+
+ with patch("nssec.core.ssh.is_root", return_value=True):
+ result = runner.invoke(waf, ["evasive", "disable", "-y"])
+
+ assert result.exit_code == 0
+ mock_installer.set_evasive_state.assert_called_once_with(enable=False)
+
+ def test_prompts_without_yes_flag(self, runner, mock_installer):
+ """Should prompt for confirmation without -y flag."""
+ with patch("nssec.core.ssh.is_root", return_value=True):
+ result = runner.invoke(waf, ["evasive", "disable"], input="n\n")
+
+ assert "Aborted" in result.output
+ mock_installer.set_evasive_state.assert_not_called()
+
+ def test_requires_root(self, runner):
+ """Should fail if not root."""
+ with patch("nssec.core.ssh.is_root", return_value=False):
+ result = runner.invoke(waf, ["evasive", "disable", "-y"])
+
+ assert result.exit_code == 1
+ assert "root" in result.output.lower()
+
+
+class TestWafEvasiveStatus:
+ """Tests for waf evasive status command."""
+
+ def test_shows_enabled_status(self, runner):
+ """Should show enabled status when evasive is active."""
+ with patch("nssec.modules.waf.utils.package_installed", return_value=True), \
+ patch("nssec.modules.waf.utils.file_exists", return_value=True):
+ result = runner.invoke(waf, ["evasive", "status"])
+
+ assert result.exit_code == 0
+ assert "enabled" in result.output
+
+ def test_shows_not_installed(self, runner):
+ """Should indicate when not installed."""
+ with patch("nssec.modules.waf.utils.package_installed", return_value=False):
+ result = runner.invoke(waf, ["evasive", "status"])
+
+ assert result.exit_code == 0
+ assert "no" in result.output.lower()
+
+ def test_default_subcommand_shows_status(self, runner):
+ """Running 'waf evasive' without subcommand should show status."""
+ with patch("nssec.modules.waf.utils.package_installed", return_value=True), \
+ patch("nssec.modules.waf.utils.file_exists", return_value=True):
+ result = runner.invoke(waf, ["evasive"])
+
+ assert result.exit_code == 0
+ assert "mod_evasive Status" in result.output
diff --git a/tests/unit/test_waf_module.py b/tests/unit/test_waf_module.py
index b67d400..8fd8095 100644
--- a/tests/unit/test_waf_module.py
+++ b/tests/unit/test_waf_module.py
@@ -164,14 +164,23 @@ def test_returns_error_on_write_failure(self, mock_file_ops):
class TestEvasiveConfTemplate:
"""Tests for the evasive.conf Jinja2 template."""
+ def _render_template(self, profile="standard", **overrides):
+ """Helper to render evasive template with profile defaults."""
+ from nssec.modules.waf.config import EVASIVE_CONF_TEMPLATE, EVASIVE_PROFILES
+
+ params = {
+ "timestamp": "test",
+ "profile": profile,
+ "log_dir": "/var/log/apache2/mod_evasive",
+ "log_file": "/var/log/apache2/mod_evasive.log",
+ **EVASIVE_PROFILES[profile],
+ **overrides,
+ }
+ return Template(EVASIVE_CONF_TEMPLATE).render(**params)
+
def test_template_renders_with_required_directives(self):
"""Should render a valid evasive.conf with all key directives."""
- from nssec.modules.waf.config import EVASIVE_CONF_TEMPLATE
-
- rendered = Template(EVASIVE_CONF_TEMPLATE).render(
- timestamp="2026-01-01 00:00 UTC",
- log_dir="/var/log/apache2/mod_evasive",
- )
+ rendered = self._render_template()
assert "DOSHashTableSize" in rendered
assert "DOSPageCount" in rendered
assert "DOSSiteCount" in rendered
@@ -182,31 +191,39 @@ def test_template_renders_with_required_directives(self):
def test_template_whitelists_rfc1918(self):
"""Should whitelist all RFC 1918 private ranges."""
- from nssec.modules.waf.config import EVASIVE_CONF_TEMPLATE
-
- rendered = Template(EVASIVE_CONF_TEMPLATE).render(
- timestamp="test", log_dir="/tmp",
- )
+ rendered = self._render_template()
assert "10.*.*.*" in rendered
assert "172.16.*.*" in rendered
assert "172.31.*.*" in rendered
assert "192.168.*.*" in rendered
- def test_template_has_tuned_thresholds(self):
- """Thresholds should be tuned for NetSapiens traffic patterns."""
- from nssec.modules.waf.config import EVASIVE_CONF_TEMPLATE
+ def test_standard_profile_has_high_thresholds(self):
+ """Standard profile should have high thresholds for safety."""
+ rendered = self._render_template("standard")
+ assert "DOSPageCount 100" in rendered
+ assert "DOSSiteCount 500" in rendered
+ assert "DOSBlockingPeriod 10" in rendered
- rendered = Template(EVASIVE_CONF_TEMPLATE).render(
- timestamp="test",
- log_dir="/tmp",
- )
- # DOSPageCount should be > 2 (default is too aggressive)
+ def test_strict_profile_has_tuned_thresholds(self):
+ """Strict profile should have tighter thresholds for NS traffic."""
+ rendered = self._render_template("strict")
assert "DOSPageCount 15" in rendered
- # DOSSiteCount tuned for ~318 peak req/s across ~372 IPs
assert "DOSSiteCount 60" in rendered
- # Extended blocking period for active scanners
assert "DOSBlockingPeriod 60" in rendered
+ def test_template_renders_dos_system_command(self):
+ """Should render DOSSystemCommand for structured logging."""
+ rendered = self._render_template()
+ assert "DOSSystemCommand" in rendered
+ assert "/var/log/apache2/mod_evasive.log" in rendered
+ assert "action=blocked" in rendered
+ assert "src_ip=%s" in rendered
+
+ def test_template_includes_profile_name(self):
+ """Should include the profile name in the config comment."""
+ rendered = self._render_template("strict")
+ assert "Profile: strict" in rendered
+
class TestSetupEvasiveConfig:
"""Tests for ModSecurityInstaller.setup_evasive_config."""
@@ -266,6 +283,18 @@ def test_returns_error_on_write_failure(self, mock_file_ops):
assert not result.success
assert "Failed to write" in result.error
+ def test_passes_log_file_to_render(self, mock_file_ops):
+ """Should pass log_file parameter to render."""
+ from nssec.modules.waf import ModSecurityInstaller
+ from nssec.modules.waf.config import EVASIVE_LOG_FILE
+
+ with patch("nssec.modules.waf.Path"):
+ installer = ModSecurityInstaller(mode="On")
+ installer.setup_evasive_config()
+
+ render_call = mock_file_ops["render"].call_args
+ assert EVASIVE_LOG_FILE in str(render_call)
+
class TestSetEvasiveState:
"""Tests for ModSecurityInstaller.set_evasive_state."""
@@ -362,37 +391,37 @@ def test_returns_error_on_command_failure(self, mock_file_ops):
class TestSetModeEvasiveIntegration:
- """Tests for set_mode toggling mod_evasive alongside ModSecurity."""
+ """Tests for set_mode NOT toggling mod_evasive (decoupled)."""
- def test_enable_mode_enables_evasive(self, mock_file_ops):
- """Switching to On mode should enable mod_evasive."""
+ def test_enable_mode_does_not_toggle_evasive(self, mock_file_ops):
+ """Switching to On mode should NOT enable mod_evasive."""
from nssec.modules.waf import ModSecurityInstaller
mock_file_ops["read"].return_value = "SecRuleEngine DetectionOnly\n"
with patch("nssec.modules.waf.package_installed", return_value=True), \
patch("nssec.modules.waf.run_cmd", return_value=("", "", 0)) as mock_run:
- mock_file_ops["exists"].return_value = False # evasive not enabled
+ mock_file_ops["exists"].return_value = False
installer = ModSecurityInstaller()
result = installer.set_mode("On")
assert result.success
- # Should have called a2enmod evasive
+ # Should NOT have called a2enmod/a2dismod evasive
run_calls = [str(c) for c in mock_run.call_args_list]
- assert any("a2enmod" in c and "evasive" in c for c in run_calls)
+ assert not any("evasive" in c for c in run_calls)
- def test_detect_mode_disables_evasive(self, mock_file_ops):
- """Switching to DetectionOnly should disable mod_evasive."""
+ def test_detect_mode_does_not_toggle_evasive(self, mock_file_ops):
+ """Switching to DetectionOnly should NOT disable mod_evasive."""
from nssec.modules.waf import ModSecurityInstaller
mock_file_ops["read"].return_value = "SecRuleEngine On\n"
with patch("nssec.modules.waf.package_installed", return_value=True), \
patch("nssec.modules.waf.run_cmd", return_value=("", "", 0)) as mock_run:
- mock_file_ops["exists"].return_value = True # evasive currently enabled
+ mock_file_ops["exists"].return_value = True
installer = ModSecurityInstaller()
result = installer.set_mode("DetectionOnly")
assert result.success
- # Should have called a2dismod evasive
run_calls = [str(c) for c in mock_run.call_args_list]
- assert any("a2dismod" in c and "evasive" in c for c in run_calls)
- assert "mod_evasive disabled" in result.message
+ assert not any("evasive" in c for c in run_calls)
+ # Message should NOT mention mod_evasive
+ assert "mod_evasive" not in result.message
diff --git a/tests/unit/test_waf_restrict.py b/tests/unit/test_waf_restrict.py
new file mode 100644
index 0000000..37bfbb7
--- /dev/null
+++ b/tests/unit/test_waf_restrict.py
@@ -0,0 +1,745 @@
+"""Tests for WAF restrict module functions."""
+
+import json
+import pytest
+from unittest.mock import patch, MagicMock
+from jinja2 import Template
+
+
+class TestLoadCachedIps:
+ """Tests for load_cached_ips function."""
+
+ def test_returns_ips_from_cache(self):
+ """Should return cached IPs from JSON file."""
+ from nssec.modules.waf.restrict import load_cached_ips
+
+ cache = json.dumps({"ips": ["127.0.0.1", "10.0.0.1"]})
+ with patch("nssec.modules.waf.restrict.read_file", return_value=cache):
+ result = load_cached_ips()
+
+ assert result == ["127.0.0.1", "10.0.0.1"]
+
+ def test_returns_empty_when_no_cache(self):
+ """Should return empty list when cache file doesn't exist."""
+ from nssec.modules.waf.restrict import load_cached_ips
+
+ with patch("nssec.modules.waf.restrict.read_file", return_value=None):
+ result = load_cached_ips()
+
+ assert result == []
+
+ def test_returns_empty_on_invalid_json(self):
+ """Should return empty list when cache contains invalid JSON."""
+ from nssec.modules.waf.restrict import load_cached_ips
+
+ with patch("nssec.modules.waf.restrict.read_file", return_value="not json"):
+ result = load_cached_ips()
+
+ assert result == []
+
+
+class TestSaveCachedIps:
+ """Tests for save_cached_ips function."""
+
+ def test_saves_ips_to_json(self):
+ """Should write IPs as JSON to cache file."""
+ from nssec.modules.waf.restrict import save_cached_ips
+ from nssec.modules.waf.config import RESTRICT_CACHE_PATH
+
+ with patch("nssec.modules.waf.restrict.write_file", return_value=True) as mock_write:
+ result = save_cached_ips(["127.0.0.1", "10.0.0.1"])
+
+ assert result is True
+ mock_write.assert_called_once()
+ written_content = mock_write.call_args[0][1]
+ data = json.loads(written_content)
+ assert data["ips"] == ["127.0.0.1", "10.0.0.1"]
+
+
+class TestGetApplicableTargets:
+ """Tests for get_applicable_targets function."""
+
+ def test_core_server_gets_sipbx_and_nsapi(self):
+ """Core server should get SiPbx Admin UI and ns-api targets."""
+ from nssec.modules.waf.restrict import get_applicable_targets
+
+ with patch("nssec.modules.waf.restrict.is_directory", return_value=True):
+ targets = get_applicable_targets("core")
+
+ names = [t["name"] for t in targets]
+ assert "SiPbx Admin UI" in names
+ assert "ns-api" in names
+ assert "NDP Endpoints" not in names
+ assert "LiCf Recording" not in names
+
+ def test_ndp_server_gets_ndp_target(self):
+ """NDP server should get NDP Endpoints target."""
+ from nssec.modules.waf.restrict import get_applicable_targets
+
+ with patch("nssec.modules.waf.restrict.is_directory", return_value=True):
+ targets = get_applicable_targets("ndp")
+
+ names = [t["name"] for t in targets]
+ assert "NDP Endpoints" in names
+ assert "SiPbx Admin UI" not in names
+
+ def test_recording_server_gets_licf_target(self):
+ """Recording server should get LiCf Recording target."""
+ from nssec.modules.waf.restrict import get_applicable_targets
+
+ with patch("nssec.modules.waf.restrict.is_directory", return_value=True):
+ targets = get_applicable_targets("recording")
+
+ names = [t["name"] for t in targets]
+ assert "LiCf Recording" in names
+ assert "SiPbx Admin UI" not in names
+
+ def test_combo_server_gets_all_targets(self):
+ """Combo server should get all targets."""
+ from nssec.modules.waf.restrict import get_applicable_targets
+
+ with patch("nssec.modules.waf.restrict.is_directory", return_value=True):
+ targets = get_applicable_targets("combo")
+
+ names = [t["name"] for t in targets]
+ assert "SiPbx Admin UI" in names
+ assert "ns-api" in names
+ assert "NDP Endpoints" in names
+ assert "LiCf Recording" in names
+
+ def test_filters_by_directory_existence(self):
+ """Should exclude targets whose directory doesn't exist."""
+ from nssec.modules.waf.restrict import get_applicable_targets
+
+ def mock_is_dir(path):
+ return "/SiPbx" in path
+
+ with patch("nssec.modules.waf.restrict.is_directory", side_effect=mock_is_dir):
+ targets = get_applicable_targets("combo")
+
+ names = [t["name"] for t in targets]
+ assert "SiPbx Admin UI" in names
+ assert "NDP Endpoints" not in names
+
+ def test_unknown_server_type_returns_empty(self):
+ """Unknown server type should return no targets."""
+ from nssec.modules.waf.restrict import get_applicable_targets
+
+ with patch("nssec.modules.waf.restrict.is_directory", return_value=True):
+ targets = get_applicable_targets("unknown")
+
+ assert targets == []
+
+
+class TestParseHtaccessIps:
+ """Tests for parse_htaccess_ips function."""
+
+ def test_parses_require_ip_lines(self):
+ """Should parse Require ip entries from .htaccess."""
+ from nssec.modules.waf.restrict import parse_htaccess_ips
+
+ content = """\
+
+ Require all denied
+ Require ip 127.0.0.1
+ Require ip 192.168.1.100
+ Require ip 10.0.0.0/8
+
+"""
+ with patch("nssec.modules.waf.restrict.read_file", return_value=content):
+ ips = parse_htaccess_ips("/some/path")
+
+ assert ips == ["127.0.0.1", "192.168.1.100", "10.0.0.0/8"]
+
+ def test_returns_empty_for_missing_file(self):
+ """Should return empty list when file doesn't exist."""
+ from nssec.modules.waf.restrict import parse_htaccess_ips
+
+ with patch("nssec.modules.waf.restrict.read_file", return_value=None):
+ ips = parse_htaccess_ips("/nonexistent")
+
+ assert ips == []
+
+ def test_returns_empty_for_no_require_ip(self):
+ """Should return empty list when no IP entries present."""
+ from nssec.modules.waf.restrict import parse_htaccess_ips
+
+ content = "# Just a comment\nAllow from all\n"
+ with patch("nssec.modules.waf.restrict.read_file", return_value=content):
+ ips = parse_htaccess_ips("/some/path")
+
+ assert ips == []
+
+ def test_parses_legacy_allow_from_lines(self):
+ """Should parse legacy Apache 2.2 Allow from entries."""
+ from nssec.modules.waf.restrict import parse_htaccess_ips
+
+ content = """\
+Order deny,allow
+Deny from all
+Allow from 192.168.1.100
+Allow from 10.0.0.0/8
+Allow from 172.16.0.1
+"""
+ with patch("nssec.modules.waf.restrict.read_file", return_value=content):
+ ips = parse_htaccess_ips("/some/path")
+
+ assert ips == ["192.168.1.100", "10.0.0.0/8", "172.16.0.1"]
+
+ def test_parses_allow_from_with_multiple_ips_on_one_line(self):
+ """Should parse multiple IPs on a single Allow from line."""
+ from nssec.modules.waf.restrict import parse_htaccess_ips
+
+ content = "Allow from 192.168.1.100 10.0.0.1, 172.16.0.1\n"
+ with patch("nssec.modules.waf.restrict.read_file", return_value=content):
+ ips = parse_htaccess_ips("/some/path")
+
+ assert "192.168.1.100" in ips
+ assert "10.0.0.1" in ips
+ assert "172.16.0.1" in ips
+
+ def test_deduplicates_ips_across_syntaxes(self):
+ """Should deduplicate IPs found in both Require ip and Allow from."""
+ from nssec.modules.waf.restrict import parse_htaccess_ips
+
+ content = """\
+Require ip 127.0.0.1
+Require ip 192.168.1.100
+Allow from 192.168.1.100
+Allow from 10.0.0.1
+"""
+ with patch("nssec.modules.waf.restrict.read_file", return_value=content):
+ ips = parse_htaccess_ips("/some/path")
+
+ assert ips == ["127.0.0.1", "192.168.1.100", "10.0.0.1"]
+
+
+class TestIsNssecManaged:
+ """Tests for is_nssec_managed function."""
+
+ def test_returns_true_for_managed_file(self):
+ """Should return True when marker is present."""
+ from nssec.modules.waf.restrict import is_nssec_managed
+ from nssec.modules.waf.config import RESTRICT_MANAGED_MARKER
+
+ content = f"{RESTRICT_MANAGED_MARKER}\n\n\n"
+ with patch("nssec.modules.waf.restrict.read_file", return_value=content):
+ assert is_nssec_managed("/some/path") is True
+
+ def test_returns_false_for_unmanaged_file(self):
+ """Should return False when marker is absent."""
+ from nssec.modules.waf.restrict import is_nssec_managed
+
+ content = "Allow from 192.168.1.0/24\n"
+ with patch("nssec.modules.waf.restrict.read_file", return_value=content):
+ assert is_nssec_managed("/some/path") is False
+
+ def test_returns_false_for_missing_file(self):
+ """Should return False when file doesn't exist."""
+ from nssec.modules.waf.restrict import is_nssec_managed
+
+ with patch("nssec.modules.waf.restrict.read_file", return_value=None):
+ assert is_nssec_managed("/nonexistent") is False
+
+
+class TestInitRestrictions:
+ """Tests for init_restrictions function."""
+
+ @patch("nssec.modules.waf.restrict.collect_existing_ips", return_value=[])
+ @patch("nssec.modules.waf.restrict.save_cached_ips")
+ @patch("nssec.modules.waf.restrict.write_file", return_value=True)
+ @patch("nssec.modules.waf.restrict.backup_file")
+ @patch("nssec.modules.waf.restrict.file_exists", return_value=False)
+ @patch("nssec.modules.waf.restrict.is_directory", return_value=True)
+ @patch("nssec.modules.waf.restrict.render", return_value="rendered")
+ def test_creates_htaccess_files(self, mock_render, mock_isdir, mock_exists, mock_backup, mock_write, mock_save, mock_collect):
+ """Should create .htaccess files for applicable targets."""
+ from nssec.modules.waf.restrict import init_restrictions
+
+ results = init_restrictions("core", ["192.168.1.100"])
+
+ assert len(results) == 2 # SiPbx + ns-api
+ for name, result in results:
+ assert result.success
+ assert "Created" in result.message
+
+ @patch("nssec.modules.waf.restrict.collect_existing_ips", return_value=[])
+ @patch("nssec.modules.waf.restrict.save_cached_ips")
+ @patch("nssec.modules.waf.restrict.write_file", return_value=True)
+ @patch("nssec.modules.waf.restrict.backup_file")
+ @patch("nssec.modules.waf.restrict.file_exists", return_value=True)
+ @patch("nssec.modules.waf.restrict.is_directory", return_value=True)
+ @patch("nssec.modules.waf.restrict.render", return_value="rendered")
+ def test_overwrites_unmanaged_files(self, mock_render, mock_isdir, mock_exists, mock_backup, mock_write, mock_save, mock_collect):
+ """Should overwrite existing unmanaged .htaccess files."""
+ from nssec.modules.waf.restrict import init_restrictions
+
+ results = init_restrictions("core", ["192.168.1.100"])
+
+ for name, result in results:
+ assert result.success
+ assert "Created" in result.message
+
+ @patch("nssec.modules.waf.restrict.collect_existing_ips", return_value=[])
+ @patch("nssec.modules.waf.restrict.file_exists", return_value=False)
+ @patch("nssec.modules.waf.restrict.is_directory", return_value=True)
+ def test_dry_run_does_not_write(self, mock_isdir, mock_exists, mock_collect):
+ """Should not write files in dry run mode."""
+ from nssec.modules.waf.restrict import init_restrictions
+
+ with patch("nssec.modules.waf.restrict.write_file") as mock_write:
+ results = init_restrictions("core", ["192.168.1.100"], dry_run=True)
+
+ mock_write.assert_not_called()
+ for name, result in results:
+ assert result.success
+ assert "Would create" in result.message
+
+ def test_no_targets_returns_skip(self):
+ """Should return skip result when no targets apply."""
+ from nssec.modules.waf.restrict import init_restrictions
+
+ with patch("nssec.modules.waf.restrict.collect_existing_ips", return_value=[]), \
+ patch("nssec.modules.waf.restrict.is_directory", return_value=False):
+ results = init_restrictions("core", ["192.168.1.100"])
+
+ assert len(results) == 1
+ assert results[0][1].skipped
+
+ @patch("nssec.modules.waf.restrict.collect_existing_ips", return_value=[])
+ @patch("nssec.modules.waf.restrict.save_cached_ips")
+ @patch("nssec.modules.waf.restrict.write_file", return_value=True)
+ @patch("nssec.modules.waf.restrict.backup_file")
+ @patch("nssec.modules.waf.restrict.file_exists", return_value=False)
+ @patch("nssec.modules.waf.restrict.is_directory", return_value=True)
+ @patch("nssec.modules.waf.restrict.render", return_value="rendered")
+ def test_always_includes_localhost(self, mock_render, mock_isdir, mock_exists, mock_backup, mock_write, mock_save, mock_collect):
+ """Should always include 127.0.0.1 in rendered IPs."""
+ from nssec.modules.waf.restrict import init_restrictions
+
+ init_restrictions("core", ["192.168.1.100"])
+
+ # Check that render was called with 127.0.0.1 in the ips list
+ for call_args in mock_render.call_args_list:
+ ips = call_args.kwargs.get("ips", call_args[1].get("ips", []))
+ assert "127.0.0.1" in ips
+
+ @patch("nssec.modules.waf.restrict.collect_existing_ips", return_value=["10.0.0.5"])
+ @patch("nssec.modules.waf.restrict.save_cached_ips")
+ @patch("nssec.modules.waf.restrict.write_file", return_value=True)
+ @patch("nssec.modules.waf.restrict.backup_file")
+ @patch("nssec.modules.waf.restrict.file_exists", return_value=True)
+ @patch("nssec.modules.waf.restrict.is_directory", return_value=True)
+ @patch("nssec.modules.waf.restrict.render", return_value="rendered")
+ def test_merges_existing_ips_from_all_targets(self, mock_render, mock_isdir, mock_exists, mock_backup, mock_write, mock_save, mock_collect):
+ """Should merge existing IPs from all targets into every file."""
+ from nssec.modules.waf.restrict import init_restrictions
+
+ results = init_restrictions("core", ["192.168.1.100"])
+
+ for name, result in results:
+ assert result.success
+ assert "Created" in result.message
+
+ # Every target gets the full merged set
+ for call_args in mock_render.call_args_list:
+ ips = call_args.kwargs.get("ips", call_args[1].get("ips", []))
+ assert "127.0.0.1" in ips
+ assert "192.168.1.100" in ips
+ assert "10.0.0.5" in ips
+
+ @patch("nssec.modules.waf.restrict.collect_existing_ips", return_value=["10.0.0.5"])
+ @patch("nssec.modules.waf.restrict.save_cached_ips")
+ @patch("nssec.modules.waf.restrict.write_file", return_value=True)
+ @patch("nssec.modules.waf.restrict.backup_file")
+ @patch("nssec.modules.waf.restrict.file_exists", return_value=False)
+ @patch("nssec.modules.waf.restrict.is_directory", return_value=True)
+ @patch("nssec.modules.waf.restrict.render", return_value="rendered")
+ def test_merges_ips_from_cache(self, mock_render, mock_isdir, mock_exists, mock_backup, mock_write, mock_save, mock_collect):
+ """Should merge IPs from cache file into new .htaccess files."""
+ from nssec.modules.waf.restrict import init_restrictions
+
+ results = init_restrictions("core", ["192.168.1.100"])
+
+ for name, result in results:
+ assert result.success
+
+ # Check that render was called with collected IP merged in
+ for call_args in mock_render.call_args_list:
+ ips = call_args.kwargs.get("ips", call_args[1].get("ips", []))
+ assert "10.0.0.5" in ips
+ assert "192.168.1.100" in ips
+
+ @patch("nssec.modules.waf.restrict.collect_existing_ips", return_value=[])
+ @patch("nssec.modules.waf.restrict.write_file", return_value=True)
+ @patch("nssec.modules.waf.restrict.backup_file")
+ @patch("nssec.modules.waf.restrict.file_exists", return_value=False)
+ @patch("nssec.modules.waf.restrict.is_directory", return_value=True)
+ @patch("nssec.modules.waf.restrict.render", return_value="rendered")
+ def test_saves_ips_to_cache_after_init(self, mock_render, mock_isdir, mock_exists, mock_backup, mock_write, mock_collect):
+ """Should save IPs to cache file after successful init."""
+ from nssec.modules.waf.restrict import init_restrictions
+
+ with patch("nssec.modules.waf.restrict.save_cached_ips") as mock_save:
+ init_restrictions("core", ["192.168.1.100"])
+
+ mock_save.assert_called_once()
+ saved_ips = mock_save.call_args[0][0]
+ assert "127.0.0.1" in saved_ips
+ assert "192.168.1.100" in saved_ips
+
+
+class TestCollectExistingIps:
+ """Tests for collect_existing_ips function."""
+
+ @patch("nssec.modules.waf.restrict.load_cached_ips", return_value=[])
+ @patch("nssec.modules.waf.restrict.is_directory", return_value=True)
+ def test_collects_ips_from_existing_htaccess(self, mock_isdir, mock_cache):
+ """Should collect IPs from existing .htaccess files."""
+ from nssec.modules.waf.restrict import collect_existing_ips
+
+ content = "Require ip 127.0.0.1\nRequire ip 10.0.0.5\n"
+ with patch("nssec.modules.waf.restrict.file_exists", return_value=True), \
+ patch("nssec.modules.waf.restrict.read_file", return_value=content):
+ ips = collect_existing_ips("core")
+
+ assert "10.0.0.5" in ips
+ assert "127.0.0.1" not in ips # localhost excluded
+
+ @patch("nssec.modules.waf.restrict.is_directory", return_value=True)
+ def test_collects_ips_from_cache(self, mock_isdir):
+ """Should include IPs from the cache file."""
+ from nssec.modules.waf.restrict import collect_existing_ips
+
+ with patch("nssec.modules.waf.restrict.file_exists", return_value=False), \
+ patch("nssec.modules.waf.restrict.load_cached_ips", return_value=["127.0.0.1", "172.16.0.1"]):
+ ips = collect_existing_ips("core")
+
+ assert "172.16.0.1" in ips
+ assert "127.0.0.1" not in ips
+
+ @patch("nssec.modules.waf.restrict.load_cached_ips", return_value=[])
+ @patch("nssec.modules.waf.restrict.file_exists", return_value=False)
+ @patch("nssec.modules.waf.restrict.is_directory", return_value=True)
+ def test_returns_empty_when_nothing_exists(self, mock_isdir, mock_exists, mock_cache):
+ """Should return empty list when no files or cache exist."""
+ from nssec.modules.waf.restrict import collect_existing_ips
+
+ ips = collect_existing_ips("core")
+ assert ips == []
+
+ @patch("nssec.modules.waf.restrict.load_cached_ips", return_value=[])
+ @patch("nssec.modules.waf.restrict.is_directory", return_value=True)
+ def test_collects_legacy_allow_from_ips(self, mock_isdir, mock_cache):
+ """Should collect IPs from legacy Allow from syntax."""
+ from nssec.modules.waf.restrict import collect_existing_ips
+
+ content = "Order deny,allow\nDeny from all\nAllow from 10.0.0.5\n"
+ with patch("nssec.modules.waf.restrict.file_exists", return_value=True), \
+ patch("nssec.modules.waf.restrict.read_file", return_value=content):
+ ips = collect_existing_ips("core")
+
+ assert "10.0.0.5" in ips
+
+
+class TestInitRestrictionsNoMerge:
+ """Tests for init_restrictions with merge_existing=False."""
+
+ @patch("nssec.modules.waf.restrict.save_cached_ips")
+ @patch("nssec.modules.waf.restrict.write_file", return_value=True)
+ @patch("nssec.modules.waf.restrict.backup_file")
+ @patch("nssec.modules.waf.restrict.file_exists", return_value=True)
+ @patch("nssec.modules.waf.restrict.is_directory", return_value=True)
+ @patch("nssec.modules.waf.restrict.render", return_value="rendered")
+ def test_does_not_merge_existing_when_disabled(self, mock_render, mock_isdir, mock_exists, mock_backup, mock_write, mock_save):
+ """Should not merge existing IPs when merge_existing=False."""
+ from nssec.modules.waf.restrict import init_restrictions
+
+ results = init_restrictions("core", ["192.168.1.100"],
+ merge_existing=False)
+
+ for name, result in results:
+ assert result.success
+
+ # Render should only include provided IPs + localhost
+ for call_args in mock_render.call_args_list:
+ ips = call_args.kwargs.get("ips", call_args[1].get("ips", []))
+ assert "127.0.0.1" in ips
+ assert "192.168.1.100" in ips
+ assert len(ips) == 2
+
+ @patch("nssec.modules.waf.restrict.save_cached_ips")
+ @patch("nssec.modules.waf.restrict.write_file", return_value=True)
+ @patch("nssec.modules.waf.restrict.backup_file")
+ @patch("nssec.modules.waf.restrict.file_exists", return_value=False)
+ @patch("nssec.modules.waf.restrict.is_directory", return_value=True)
+ @patch("nssec.modules.waf.restrict.render", return_value="rendered")
+ def test_does_not_merge_cache_when_disabled(self, mock_render, mock_isdir, mock_exists, mock_backup, mock_write, mock_save):
+ """Should not merge cache IPs when merge_existing=False."""
+ from nssec.modules.waf.restrict import init_restrictions
+
+ results = init_restrictions("core", ["192.168.1.100"],
+ merge_existing=False)
+
+ # Render should only include provided IPs + localhost
+ for call_args in mock_render.call_args_list:
+ ips = call_args.kwargs.get("ips", call_args[1].get("ips", []))
+ assert len(ips) == 2
+ assert "192.168.1.100" in ips
+
+
+class TestAddRestrictedIp:
+ """Tests for add_restricted_ip function."""
+
+ @patch("nssec.modules.waf.restrict.save_cached_ips")
+ @patch("nssec.modules.waf.restrict.load_cached_ips", return_value=["127.0.0.1"])
+ @patch("nssec.modules.waf.restrict.write_file", return_value=True)
+ @patch("nssec.modules.waf.restrict.backup_file")
+ @patch("nssec.modules.waf.restrict.render", return_value="rendered")
+ @patch("nssec.modules.waf.restrict.is_nssec_managed", return_value=True)
+ @patch("nssec.modules.waf.restrict.parse_htaccess_ips", return_value=["127.0.0.1"])
+ @patch("nssec.modules.waf.restrict.file_exists", return_value=True)
+ @patch("nssec.modules.waf.restrict.is_directory", return_value=True)
+ def test_adds_ip_to_managed_files(self, mock_isdir, mock_exists, mock_parse, mock_managed, mock_render, mock_backup, mock_write, mock_load, mock_save):
+ """Should add IP to all managed .htaccess files and update cache."""
+ from nssec.modules.waf.restrict import add_restricted_ip
+
+ results = add_restricted_ip("core", "192.168.1.100")
+
+ for name, result in results:
+ assert result.success
+ assert "Added" in result.message
+
+ # Should update cache with new IP
+ mock_save.assert_called_once()
+ saved_ips = mock_save.call_args[0][0]
+ assert "192.168.1.100" in saved_ips
+
+ @patch("nssec.modules.waf.restrict.is_nssec_managed", return_value=True)
+ @patch("nssec.modules.waf.restrict.parse_htaccess_ips", return_value=["127.0.0.1", "192.168.1.100"])
+ @patch("nssec.modules.waf.restrict.file_exists", return_value=True)
+ @patch("nssec.modules.waf.restrict.is_directory", return_value=True)
+ def test_skips_duplicate_ip(self, mock_isdir, mock_exists, mock_parse, mock_managed):
+ """Should skip if IP already present in file."""
+ from nssec.modules.waf.restrict import add_restricted_ip
+
+ results = add_restricted_ip("core", "192.168.1.100")
+
+ for name, result in results:
+ assert result.skipped
+ assert "already in" in result.message
+
+ @patch("nssec.modules.waf.restrict.is_nssec_managed", return_value=False)
+ @patch("nssec.modules.waf.restrict.file_exists", return_value=True)
+ @patch("nssec.modules.waf.restrict.is_directory", return_value=True)
+ def test_skips_unmanaged_files(self, mock_isdir, mock_exists, mock_managed):
+ """Should skip unmanaged .htaccess files."""
+ from nssec.modules.waf.restrict import add_restricted_ip
+
+ results = add_restricted_ip("core", "192.168.1.100")
+
+ for name, result in results:
+ assert result.skipped
+ assert "unmanaged" in result.message.lower()
+
+ @patch("nssec.modules.waf.restrict.file_exists", return_value=False)
+ @patch("nssec.modules.waf.restrict.is_directory", return_value=True)
+ def test_skips_missing_files(self, mock_isdir, mock_exists):
+ """Should skip when .htaccess doesn't exist."""
+ from nssec.modules.waf.restrict import add_restricted_ip
+
+ results = add_restricted_ip("core", "192.168.1.100")
+
+ for name, result in results:
+ assert result.skipped
+ assert "init first" in result.message
+
+
+class TestRemoveRestrictedIp:
+ """Tests for remove_restricted_ip function."""
+
+ def test_refuses_to_remove_localhost(self):
+ """Should refuse to remove 127.0.0.1."""
+ from nssec.modules.waf.restrict import remove_restricted_ip
+
+ results = remove_restricted_ip("core", "127.0.0.1")
+
+ assert len(results) == 1
+ assert not results[0][1].success
+ assert "Cannot remove 127.0.0.1" in results[0][1].error
+
+ @patch("nssec.modules.waf.restrict.save_cached_ips")
+ @patch("nssec.modules.waf.restrict.load_cached_ips", return_value=["127.0.0.1", "192.168.1.100"])
+ @patch("nssec.modules.waf.restrict.write_file", return_value=True)
+ @patch("nssec.modules.waf.restrict.backup_file")
+ @patch("nssec.modules.waf.restrict.render", return_value="rendered")
+ @patch("nssec.modules.waf.restrict.is_nssec_managed", return_value=True)
+ @patch("nssec.modules.waf.restrict.parse_htaccess_ips", return_value=["127.0.0.1", "192.168.1.100"])
+ @patch("nssec.modules.waf.restrict.file_exists", return_value=True)
+ @patch("nssec.modules.waf.restrict.is_directory", return_value=True)
+ def test_removes_user_added_ip(self, mock_isdir, mock_exists, mock_parse, mock_managed, mock_render, mock_backup, mock_write, mock_load, mock_save):
+ """Should remove a user-added IP from managed files and update cache."""
+ from nssec.modules.waf.restrict import remove_restricted_ip
+
+ results = remove_restricted_ip("core", "192.168.1.100")
+
+ for name, result in results:
+ assert result.success
+ assert "Removed" in result.message
+
+ # Should update cache without the removed IP
+ mock_save.assert_called_once()
+ saved_ips = mock_save.call_args[0][0]
+ assert "192.168.1.100" not in saved_ips
+ assert "127.0.0.1" in saved_ips
+
+ @patch("nssec.modules.waf.restrict.is_nssec_managed", return_value=True)
+ @patch("nssec.modules.waf.restrict.parse_htaccess_ips", return_value=["127.0.0.1"])
+ @patch("nssec.modules.waf.restrict.file_exists", return_value=True)
+ @patch("nssec.modules.waf.restrict.is_directory", return_value=True)
+ def test_skips_ip_not_in_file(self, mock_isdir, mock_exists, mock_parse, mock_managed):
+ """Should skip if IP not found in file."""
+ from nssec.modules.waf.restrict import remove_restricted_ip
+
+ results = remove_restricted_ip("core", "10.0.0.1")
+
+ for name, result in results:
+ assert result.skipped
+ assert "not found" in result.message
+
+ @patch("nssec.modules.waf.restrict.is_nssec_managed", return_value=False)
+ @patch("nssec.modules.waf.restrict.file_exists", return_value=True)
+ @patch("nssec.modules.waf.restrict.is_directory", return_value=True)
+ def test_skips_unmanaged_files(self, mock_isdir, mock_exists, mock_managed):
+ """Should skip unmanaged .htaccess files."""
+ from nssec.modules.waf.restrict import remove_restricted_ip
+
+ results = remove_restricted_ip("core", "192.168.1.100")
+
+ for name, result in results:
+ assert result.skipped
+ assert "unmanaged" in result.message.lower()
+
+
+class TestHtaccessTemplates:
+ """Tests for .htaccess Jinja2 templates."""
+
+ def test_dir_template_renders_allow_from(self):
+ """Directory template should use Order/Allow from syntax."""
+ from nssec.modules.waf.config import HTACCESS_DIR_TEMPLATE, RESTRICT_MANAGED_MARKER
+
+ rendered = Template(HTACCESS_DIR_TEMPLATE).render(
+ managed_marker=RESTRICT_MANAGED_MARKER,
+ ips=["127.0.0.1", "192.168.1.100"],
+ )
+
+ assert RESTRICT_MANAGED_MARKER in rendered
+ assert "Order allow,deny" in rendered
+ assert "Allow from 127.0.0.1" in rendered
+ assert "Allow from 192.168.1.100" in rendered
+ # No blank lines between entries
+ assert "127.0.0.1\nAllow from 192.168.1.100" in rendered
+
+ def test_file_template_wraps_in_files_directive(self):
+ """File template should wrap restrictions in Files directive."""
+ from nssec.modules.waf.config import HTACCESS_FILE_TEMPLATE, RESTRICT_MANAGED_MARKER
+
+ rendered = Template(HTACCESS_FILE_TEMPLATE).render(
+ managed_marker=RESTRICT_MANAGED_MARKER,
+ file_target="adminlogin.php",
+ ips=["127.0.0.1", "10.0.0.0/8"],
+ )
+
+ assert RESTRICT_MANAGED_MARKER in rendered
+ assert '' in rendered
+ assert "Order allow,deny" in rendered
+ assert "Allow from 127.0.0.1" in rendered
+ assert "Allow from 10.0.0.0/8" in rendered
+ assert "" in rendered
+ # No blank lines between entries
+ assert "127.0.0.1\n Allow from 10.0.0.0/8" in rendered
+
+ def test_templates_match_netsapiens_doc_format(self):
+ """Templates should match the NetSapiens documentation format."""
+ from nssec.modules.waf.config import HTACCESS_FILE_TEMPLATE, RESTRICT_MANAGED_MARKER
+
+ rendered = Template(HTACCESS_FILE_TEMPLATE).render(
+ managed_marker=RESTRICT_MANAGED_MARKER,
+ file_target="adminlogin.php",
+ ips=["127.0.0.1"],
+ )
+
+ # Should use same syntax as the NS doc
+ assert "Order allow,deny" in rendered
+ assert "Allow from" in rendered
+ # Should NOT use Apache 2.4 Require syntax
+ assert "Require" not in rendered
+
+
+class TestReapplyRestrictions:
+ """Tests for reapply_restrictions function."""
+
+ def test_returns_skip_when_no_cache(self):
+ """Should skip when no cached IPs exist."""
+ from nssec.modules.waf.restrict import reapply_restrictions
+
+ with patch("nssec.modules.waf.restrict.read_file", return_value=None):
+ results = reapply_restrictions("core")
+
+ assert len(results) == 1
+ assert results[0][1].skipped
+ assert "No cached IPs" in results[0][1].message
+
+ @patch("nssec.modules.waf.restrict.write_file", return_value=True)
+ @patch("nssec.modules.waf.restrict.backup_file")
+ @patch("nssec.modules.waf.restrict.file_exists", return_value=False)
+ @patch("nssec.modules.waf.restrict.is_directory", return_value=True)
+ @patch("nssec.modules.waf.restrict.render", return_value="rendered")
+ def test_restores_from_cache(self, mock_render, mock_isdir, mock_exists, mock_backup, mock_write):
+ """Should re-create .htaccess files from cached IPs."""
+ from nssec.modules.waf.restrict import reapply_restrictions
+
+ cached = json.dumps({"ips": ["127.0.0.1", "192.168.1.100"]})
+ with patch("nssec.modules.waf.restrict.read_file", return_value=cached):
+ results = reapply_restrictions("core")
+
+ for name, result in results:
+ assert result.success
+ assert "Restored" in result.message
+
+ # Verify IPs passed to render include cached IPs
+ for call_args in mock_render.call_args_list:
+ ips = call_args.kwargs.get("ips", call_args[1].get("ips", []))
+ assert "127.0.0.1" in ips
+ assert "192.168.1.100" in ips
+
+ @patch("nssec.modules.waf.restrict.is_directory", return_value=True)
+ def test_dry_run_does_not_write(self, mock_isdir):
+ """Should not write files in dry run mode."""
+ from nssec.modules.waf.restrict import reapply_restrictions
+
+ cached = json.dumps({"ips": ["127.0.0.1", "10.0.0.1"]})
+ with patch("nssec.modules.waf.restrict.read_file", return_value=cached), \
+ patch("nssec.modules.waf.restrict.file_exists", return_value=False), \
+ patch("nssec.modules.waf.restrict.write_file") as mock_write:
+ results = reapply_restrictions("core", dry_run=True)
+
+ mock_write.assert_not_called()
+ for name, result in results:
+ assert result.success
+ assert "Would write" in result.message
+
+ @patch("nssec.modules.waf.restrict.write_file", return_value=True)
+ @patch("nssec.modules.waf.restrict.backup_file")
+ @patch("nssec.modules.waf.restrict.file_exists", return_value=True)
+ @patch("nssec.modules.waf.restrict.is_directory", return_value=True)
+ @patch("nssec.modules.waf.restrict.render", return_value="rendered")
+ def test_backs_up_existing_before_overwrite(self, mock_render, mock_isdir, mock_exists, mock_backup, mock_write):
+ """Should backup existing files before restoring from cache."""
+ from nssec.modules.waf.restrict import reapply_restrictions
+
+ cached = json.dumps({"ips": ["127.0.0.1"]})
+ with patch("nssec.modules.waf.restrict.read_file", return_value=cached):
+ reapply_restrictions("core")
+
+ mock_backup.assert_called()
diff --git a/tests/unit/test_waf_restrict_commands.py b/tests/unit/test_waf_restrict_commands.py
new file mode 100644
index 0000000..facde43
--- /dev/null
+++ b/tests/unit/test_waf_restrict_commands.py
@@ -0,0 +1,398 @@
+"""Tests for WAF restrict CLI commands."""
+
+import pytest
+from unittest.mock import patch, MagicMock
+from click.testing import CliRunner
+
+from nssec.cli.waf_commands import waf
+
+
+@pytest.fixture
+def runner():
+ """Click CLI test runner."""
+ return CliRunner()
+
+
+class TestWafRestrictShow:
+ """Tests for waf restrict show command."""
+
+ def test_shows_status_table(self, runner):
+ """Should display restriction status for applicable paths."""
+ statuses = [
+ {
+ "name": "SiPbx Admin UI",
+ "path": "/usr/local/NetSapiens/SiPbx/html/SiPbx/.htaccess",
+ "exists": True,
+ "managed": True,
+ "ips": ["127.0.0.1", "192.168.1.100"],
+ },
+ {
+ "name": "ns-api",
+ "path": "/usr/local/NetSapiens/SiPbx/html/ns-api/.htaccess",
+ "exists": False,
+ "managed": False,
+ "ips": [],
+ },
+ ]
+ with patch("nssec.core.server_types.detect_server_type") as mock_detect, \
+ patch("nssec.modules.waf.restrict.get_restrict_status", return_value=statuses):
+ mock_detect.return_value = MagicMock(value="core")
+ result = runner.invoke(waf, ["restrict", "show"])
+
+ assert result.exit_code == 0
+ assert "SiPbx Admin UI" in result.output
+ assert "ns-api" in result.output
+
+ def test_shows_empty_message(self, runner):
+ """Should show message when no targets apply."""
+ with patch("nssec.core.server_types.detect_server_type") as mock_detect, \
+ patch("nssec.modules.waf.restrict.get_restrict_status", return_value=[]):
+ mock_detect.return_value = MagicMock(value="unknown")
+ result = runner.invoke(waf, ["restrict", "show"])
+
+ assert result.exit_code == 0
+ assert "No applicable" in result.output
+
+ def test_default_subcommand_shows_status(self, runner):
+ """Running 'waf restrict' without subcommand should show status."""
+ statuses = [
+ {
+ "name": "SiPbx Admin UI",
+ "path": "/usr/local/NetSapiens/SiPbx/html/SiPbx/.htaccess",
+ "exists": True,
+ "managed": True,
+ "ips": ["127.0.0.1"],
+ },
+ ]
+ with patch("nssec.core.server_types.detect_server_type") as mock_detect, \
+ patch("nssec.modules.waf.restrict.get_restrict_status", return_value=statuses):
+ mock_detect.return_value = MagicMock(value="core")
+ result = runner.invoke(waf, ["restrict"])
+
+ assert result.exit_code == 0
+ assert "SiPbx Admin UI" in result.output
+
+ def test_lists_ips_from_first_managed_file(self, runner):
+ """Should list IPs from the first managed file."""
+ statuses = [
+ {
+ "name": "SiPbx Admin UI",
+ "path": "/usr/local/NetSapiens/SiPbx/html/SiPbx/.htaccess",
+ "exists": True,
+ "managed": True,
+ "ips": ["127.0.0.1", "10.0.0.1"],
+ },
+ ]
+ with patch("nssec.core.server_types.detect_server_type") as mock_detect, \
+ patch("nssec.modules.waf.restrict.get_restrict_status", return_value=statuses):
+ mock_detect.return_value = MagicMock(value="core")
+ result = runner.invoke(waf, ["restrict", "show"])
+
+ assert result.exit_code == 0
+ assert "127.0.0.1" in result.output
+ assert "10.0.0.1" in result.output
+
+
+class TestWafRestrictInit:
+ """Tests for waf restrict init command."""
+
+ def test_requires_root(self, runner):
+ """Should fail if not root."""
+ with patch("nssec.core.ssh.is_root", return_value=False):
+ result = runner.invoke(waf, ["restrict", "init", "--ip", "10.0.0.1", "-y"])
+
+ assert result.exit_code == 1
+ assert "root" in result.output.lower()
+
+ def test_creates_htaccess_files(self, runner):
+ """Should create .htaccess files with provided IPs."""
+ from nssec.modules.waf.types import StepResult
+
+ mock_results = [
+ ("SiPbx Admin UI", StepResult(message="Created file")),
+ ("ns-api", StepResult(message="Created file")),
+ ]
+
+ with patch("nssec.core.ssh.is_root", return_value=True), \
+ patch("nssec.core.server_types.detect_server_type") as mock_detect, \
+ patch("nssec.modules.waf.restrict.collect_existing_ips", return_value=[]), \
+ patch("nssec.modules.waf.restrict.init_restrictions", return_value=mock_results) as mock_init, \
+ patch("nssec.modules.waf.utils.run_cmd", return_value=("", "", 0)):
+ mock_detect.return_value = MagicMock(value="core")
+ result = runner.invoke(waf, ["restrict", "init", "--ip", "192.168.1.100", "-y"])
+
+ assert result.exit_code == 0
+ mock_init.assert_called_once()
+ call_args = mock_init.call_args
+ assert "192.168.1.100" in call_args[0][1] # ips list
+
+ def test_validates_ip_address(self, runner):
+ """Should reject invalid IP addresses."""
+ with patch("nssec.core.ssh.is_root", return_value=True), \
+ patch("nssec.core.server_types.detect_server_type") as mock_detect, \
+ patch("nssec.modules.waf.restrict.collect_existing_ips", return_value=[]):
+ mock_detect.return_value = MagicMock(value="core")
+ result = runner.invoke(waf, ["restrict", "init", "--ip", "not-an-ip", "-y"])
+
+ assert result.exit_code == 1
+ assert "Invalid" in result.output
+
+ def test_dry_run(self, runner):
+ """Should show what would be done in dry run."""
+ from nssec.modules.waf.types import StepResult
+
+ mock_results = [
+ ("SiPbx Admin UI", StepResult(message="Would create file")),
+ ]
+
+ with patch("nssec.core.ssh.is_root", return_value=True), \
+ patch("nssec.core.server_types.detect_server_type") as mock_detect, \
+ patch("nssec.modules.waf.restrict.collect_existing_ips", return_value=[]), \
+ patch("nssec.modules.waf.restrict.init_restrictions", return_value=mock_results):
+ mock_detect.return_value = MagicMock(value="core")
+ result = runner.invoke(waf, ["restrict", "init", "--ip", "10.0.0.1", "--dry-run"])
+
+ assert result.exit_code == 0
+ assert "Dry run" in result.output
+
+ def test_accepts_cidr_notation(self, runner):
+ """Should accept CIDR notation IPs."""
+ from nssec.modules.waf.types import StepResult
+
+ mock_results = [
+ ("SiPbx Admin UI", StepResult(message="Created file")),
+ ]
+
+ with patch("nssec.core.ssh.is_root", return_value=True), \
+ patch("nssec.core.server_types.detect_server_type") as mock_detect, \
+ patch("nssec.modules.waf.restrict.collect_existing_ips", return_value=[]), \
+ patch("nssec.modules.waf.restrict.init_restrictions", return_value=mock_results), \
+ patch("nssec.modules.waf.utils.run_cmd", return_value=("", "", 0)):
+ mock_detect.return_value = MagicMock(value="core")
+ result = runner.invoke(waf, ["restrict", "init", "--ip", "10.0.0.0/8", "-y"])
+
+ assert result.exit_code == 0
+
+ def test_shows_existing_ips_and_keeps_by_default(self, runner):
+ """Should show existing IPs and keep them when user confirms."""
+ from nssec.modules.waf.types import StepResult
+
+ mock_results = [
+ ("SiPbx Admin UI", StepResult(message="Created file")),
+ ]
+
+ with patch("nssec.core.ssh.is_root", return_value=True), \
+ patch("nssec.core.server_types.detect_server_type") as mock_detect, \
+ patch("nssec.modules.waf.restrict.collect_existing_ips", return_value=["10.0.0.5", "172.16.0.1"]), \
+ patch("nssec.modules.waf.restrict.init_restrictions", return_value=mock_results) as mock_init, \
+ patch("nssec.modules.waf.utils.run_cmd", return_value=("", "", 0)):
+ mock_detect.return_value = MagicMock(value="core")
+ # Confirm keep=Yes, create=Yes, reload=Yes
+ result = runner.invoke(waf, ["restrict", "init", "--ip", "192.168.1.100"],
+ input="y\ny\ny\n")
+
+ assert result.exit_code == 0
+ assert "10.0.0.5" in result.output
+ assert "172.16.0.1" in result.output
+ # merge_existing should be True (keeping existing)
+ call_kwargs = mock_init.call_args[1]
+ assert call_kwargs.get("merge_existing") is True
+
+ def test_shows_existing_ips_and_overwrites_on_no(self, runner):
+ """Should overwrite existing IPs when user says no to keeping."""
+ from nssec.modules.waf.types import StepResult
+
+ mock_results = [
+ ("SiPbx Admin UI", StepResult(message="Created file")),
+ ]
+
+ with patch("nssec.core.ssh.is_root", return_value=True), \
+ patch("nssec.core.server_types.detect_server_type") as mock_detect, \
+ patch("nssec.modules.waf.restrict.collect_existing_ips", return_value=["10.0.0.5"]), \
+ patch("nssec.modules.waf.restrict.init_restrictions", return_value=mock_results) as mock_init, \
+ patch("nssec.modules.waf.utils.run_cmd", return_value=("", "", 0)):
+ mock_detect.return_value = MagicMock(value="core")
+ # Confirm keep=No, create=Yes, reload=Yes
+ result = runner.invoke(waf, ["restrict", "init", "--ip", "192.168.1.100"],
+ input="n\ny\ny\n")
+
+ assert result.exit_code == 0
+ assert "Overwriting" in result.output
+ # merge_existing should be False
+ call_kwargs = mock_init.call_args[1]
+ assert call_kwargs.get("merge_existing") is False
+
+ def test_yes_flag_keeps_existing_ips_by_default(self, runner):
+ """With --yes, should keep existing IPs without prompting."""
+ from nssec.modules.waf.types import StepResult
+
+ mock_results = [
+ ("SiPbx Admin UI", StepResult(message="Created file")),
+ ]
+
+ with patch("nssec.core.ssh.is_root", return_value=True), \
+ patch("nssec.core.server_types.detect_server_type") as mock_detect, \
+ patch("nssec.modules.waf.restrict.collect_existing_ips", return_value=["10.0.0.5"]), \
+ patch("nssec.modules.waf.restrict.init_restrictions", return_value=mock_results) as mock_init, \
+ patch("nssec.modules.waf.utils.run_cmd", return_value=("", "", 0)):
+ mock_detect.return_value = MagicMock(value="core")
+ result = runner.invoke(waf, ["restrict", "init", "--ip", "192.168.1.100", "-y"])
+
+ assert result.exit_code == 0
+ assert "Keeping" in result.output
+ call_kwargs = mock_init.call_args[1]
+ assert call_kwargs.get("merge_existing") is True
+
+
+class TestWafRestrictAdd:
+ """Tests for waf restrict add command."""
+
+ def test_requires_root(self, runner):
+ """Should fail if not root."""
+ with patch("nssec.core.ssh.is_root", return_value=False):
+ result = runner.invoke(waf, ["restrict", "add", "192.168.1.100", "-y"])
+
+ assert result.exit_code == 1
+ assert "root" in result.output.lower()
+
+ def test_adds_ip_to_managed_files(self, runner):
+ """Should add IP to all managed .htaccess files."""
+ from nssec.modules.waf.types import StepResult
+
+ mock_results = [
+ ("SiPbx Admin UI", StepResult(message="Added 192.168.1.100")),
+ ]
+
+ with patch("nssec.core.ssh.is_root", return_value=True), \
+ patch("nssec.core.server_types.detect_server_type") as mock_detect, \
+ patch("nssec.modules.waf.restrict.add_restricted_ip", return_value=mock_results) as mock_add, \
+ patch("nssec.modules.waf.utils.run_cmd", return_value=("", "", 0)):
+ mock_detect.return_value = MagicMock(value="core")
+ result = runner.invoke(waf, ["restrict", "add", "192.168.1.100", "-y"])
+
+ assert result.exit_code == 0
+ mock_add.assert_called_once_with("core", "192.168.1.100")
+
+ def test_validates_ip_address(self, runner):
+ """Should reject invalid IP addresses."""
+ with patch("nssec.core.ssh.is_root", return_value=True), \
+ patch("nssec.core.server_types.detect_server_type") as mock_detect:
+ mock_detect.return_value = MagicMock(value="core")
+ result = runner.invoke(waf, ["restrict", "add", "not-valid", "-y"])
+
+ assert result.exit_code == 1
+ assert "Invalid" in result.output
+
+
+class TestWafRestrictRemove:
+ """Tests for waf restrict remove command."""
+
+ def test_requires_root(self, runner):
+ """Should fail if not root."""
+ with patch("nssec.core.ssh.is_root", return_value=False):
+ result = runner.invoke(waf, ["restrict", "remove", "192.168.1.100", "-y"])
+
+ assert result.exit_code == 1
+ assert "root" in result.output.lower()
+
+ def test_removes_ip_from_managed_files(self, runner):
+ """Should remove IP from managed .htaccess files."""
+ from nssec.modules.waf.types import StepResult
+
+ mock_results = [
+ ("SiPbx Admin UI", StepResult(message="Removed 192.168.1.100")),
+ ]
+
+ with patch("nssec.core.ssh.is_root", return_value=True), \
+ patch("nssec.core.server_types.detect_server_type") as mock_detect, \
+ patch("nssec.modules.waf.restrict.remove_restricted_ip", return_value=mock_results) as mock_remove, \
+ patch("nssec.modules.waf.utils.run_cmd", return_value=("", "", 0)):
+ mock_detect.return_value = MagicMock(value="core")
+ result = runner.invoke(waf, ["restrict", "remove", "192.168.1.100", "-y"])
+
+ assert result.exit_code == 0
+ mock_remove.assert_called_once_with("core", "192.168.1.100")
+
+ def test_blocks_localhost_removal(self, runner):
+ """Should block removal of 127.0.0.1."""
+ from nssec.modules.waf.types import StepResult
+
+ mock_results = [
+ ("", StepResult(success=False, error="Cannot remove 127.0.0.1 (localhost must always be allowed)")),
+ ]
+
+ with patch("nssec.core.ssh.is_root", return_value=True), \
+ patch("nssec.core.server_types.detect_server_type") as mock_detect, \
+ patch("nssec.modules.waf.restrict.remove_restricted_ip", return_value=mock_results):
+ mock_detect.return_value = MagicMock(value="core")
+ result = runner.invoke(waf, ["restrict", "remove", "127.0.0.1", "-y"])
+
+ assert result.exit_code == 1
+ assert "Cannot remove" in result.output
+
+
+class TestWafRestrictReapply:
+ """Tests for waf restrict reapply command."""
+
+ def test_requires_root(self, runner):
+ """Should fail if not root."""
+ with patch("nssec.core.ssh.is_root", return_value=False):
+ result = runner.invoke(waf, ["restrict", "reapply", "-y"])
+
+ assert result.exit_code == 1
+ assert "root" in result.output.lower()
+
+ def test_restores_from_cache(self, runner):
+ """Should restore .htaccess files from cached IPs."""
+ from nssec.modules.waf.types import StepResult
+
+ mock_results = [
+ ("SiPbx Admin UI", StepResult(message="Restored file")),
+ ]
+
+ with patch("nssec.core.ssh.is_root", return_value=True), \
+ patch("nssec.core.server_types.detect_server_type") as mock_detect, \
+ patch("nssec.modules.waf.restrict.load_cached_ips", return_value=["127.0.0.1", "10.0.0.1"]), \
+ patch("nssec.modules.waf.restrict.reapply_restrictions", return_value=mock_results) as mock_reapply, \
+ patch("nssec.modules.waf.utils.run_cmd", return_value=("", "", 0)):
+ mock_detect.return_value = MagicMock(value="core")
+ result = runner.invoke(waf, ["restrict", "reapply", "-y"])
+
+ assert result.exit_code == 0
+ mock_reapply.assert_called_once()
+
+ def test_shows_cached_ips(self, runner):
+ """Should display cached IPs before reapplying."""
+ from nssec.modules.waf.types import StepResult
+
+ mock_results = [
+ ("SiPbx Admin UI", StepResult(message="Restored file")),
+ ]
+
+ with patch("nssec.core.ssh.is_root", return_value=True), \
+ patch("nssec.core.server_types.detect_server_type") as mock_detect, \
+ patch("nssec.modules.waf.restrict.load_cached_ips", return_value=["127.0.0.1", "10.0.0.1"]), \
+ patch("nssec.modules.waf.restrict.reapply_restrictions", return_value=mock_results), \
+ patch("nssec.modules.waf.utils.run_cmd", return_value=("", "", 0)):
+ mock_detect.return_value = MagicMock(value="core")
+ result = runner.invoke(waf, ["restrict", "reapply", "-y"])
+
+ assert "10.0.0.1" in result.output
+
+ def test_dry_run(self, runner):
+ """Should show what would be done in dry run."""
+ from nssec.modules.waf.types import StepResult
+
+ mock_results = [
+ ("SiPbx Admin UI", StepResult(message="Would write file")),
+ ]
+
+ with patch("nssec.core.ssh.is_root", return_value=True), \
+ patch("nssec.core.server_types.detect_server_type") as mock_detect, \
+ patch("nssec.modules.waf.restrict.load_cached_ips", return_value=["127.0.0.1"]), \
+ patch("nssec.modules.waf.restrict.reapply_restrictions", return_value=mock_results):
+ mock_detect.return_value = MagicMock(value="core")
+ result = runner.invoke(waf, ["restrict", "reapply", "--dry-run"])
+
+ assert result.exit_code == 0
+ assert "Dry run" in result.output