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