# M26 — Admin Security Tab (CrowdSec + ModSecurity + UFW)

> Construction plan. Every step is cold-start executable. Read the step brief, run the tasks, verify, commit on a feature branch, open a PR to `main`.

## Objective

Ship an admin-only `Security` page at `/jabali-admin/security` with three tabs exposing:

1. **CrowdSec** — decisions (bans), bouncers, scenarios, metrics
2. **ModSecurity** — global enable + paranoia level, per-domain enable, recent audit log
3. **UFW** — firewall rules list, add/remove, default policies, enable/disable

User-side security tab is out of scope — deferred to a later milestone.

## Constraints (load-bearing; do not violate)

- **ADR-0050 (M25 unix sockets) load-bearing** — any new service that supports unix sockets MUST bind on a unix socket. CrowdSec LAPI supports them, so use them. ModSecurity is an nginx module (no socket question). UFW is kernel/iptables.
- **ADR-0002 (DB as truth) for config only** — per-domain `modsec_enabled` is config, lives in DB. CrowdSec decisions and UFW rules are service runtime state; DO NOT mirror them in jabali DB. Panel queries them live via `cscli` / `ufw status`.
- **ADR-0009 (file-per-vhost)** — per-domain ModSec include directive goes in the vhost template, regenerated by the existing domain reconciler pass.
- **Golden rule: no direct shell from panel-api** — every privileged op (CrowdSec CLI, UFW CLI, config reload) flows through the panel-agent NDJSON RPC.
- **Stalwart owns 127.0.0.1:8080** (ADR-0050). CrowdSec LAPI MUST NOT use that port — use unix socket `/run/crowdsec/api.sock` (mode 0660, group `jabali`).
- **Fresh install must pass** — M26 additions wire into install.sh; install.sh must remain idempotent and green end-to-end on a clean VM before merge.

## Out of scope

- User-shell security page (deferred)
- CrowdSec Console integration (hosted threat-intel sharing) — operator enrols manually via `cscli console enroll` if they want
- ModSecurity custom rule editor — first cut uses only OWASP CRS paranoia slider
- IPv6 UFW rules — UFW handles v4 + v6 by default, UI still shows v4 only in first cut
- Per-domain CrowdSec bouncer exclusions — global bouncers only
- WAF log search / grep — first cut shows last 50 lines of `/var/log/modsec_audit.log` via agent tail

---

## ADRs to write during Step 1

| ADR  | Title                                               | Decision                                                                 |
| ---- | --------------------------------------------------- | ------------------------------------------------------------------------ |
| 0053 | CrowdSec over fail2ban for behaviour-based blocking | Community threat intel + bouncer model + richer scenarios                |
| 0054 | UFW over raw iptables/nftables                      | Simpler, idempotent, systemd-managed, already packaged on Debian 12/13   |
| 0055 | ModSecurity-nginx + OWASP CRS, per-domain toggle    | Industry standard; per-vhost flag in DB, global paranoia in server_settings |

---

## Step 1 — Install foundation + ADRs

### Context brief
Fresh Debian/Ubuntu host must boot with CrowdSec, ModSecurity, and UFW installed and in a safe default state. CrowdSec LAPI binds on a unix socket (per M25). ModSecurity is wired into nginx globally but OFF by default. UFW has a minimal allow-list with the panel port, SSH, and public mail/web ports, then default-deny.

Key constraints:
- Stalwart owns `127.0.0.1:8080`; CrowdSec LAPI MUST NOT conflict. Use unix socket.
- Panel-api runs as `jabali` group; agent runs as root. Both need to talk to CrowdSec LAPI via `cscli`. The socket lives at `/run/crowdsec/api.sock`, mode 0660, group `jabali`. `cscli` reads `/etc/crowdsec/local_api_credentials.yaml` which points at the socket.
- UFW default-deny must NOT block the panel port `8443`, nginx `80/443`, SSH `22`, Stalwart public ports (`25, 465, 587, 993, 995, 4190`). Install.sh must open all of these BEFORE `ufw enable`, in the same run, or operators lose SSH.
- ModSecurity install drops `modsecurity-nginx` connector + OWASP CRS v4 package (`modsecurity-crs` on Debian). Global include stays commented until M26 Step 4 lands the DB toggle.

### Tasks
1. Write `docs/adr/0053-crowdsec-over-fail2ban.md`, `0054-ufw-over-iptables.md`, `0055-modsecurity-per-domain.md`. Each ADR: Context → Decision → Alternatives Considered → Consequences.
2. In `install.sh`, add three idempotent functions (alphabetical within the install flow, each guarded by `command -v` or `dpkg -l`):
   - `install_crowdsec()` — detect Debian release (`lsb_release -rs`); on 12 (bookworm) use `crowdsec-firewall-bouncer-iptables`, on 13+ (trixie) prefer `crowdsec-firewall-bouncer-nftables` with fallback to `crowdsec-firewall-bouncer-iptables` if the nftables package is missing (`apt-cache show <pkg> >/dev/null`). Then `apt install crowdsec <chosen-bouncer>`. Do NOT clobber `/etc/crowdsec/config.yaml` if it already exists — use `yq` (add `yq` to installer deps) to patch only `api.server.listen_socket: /run/crowdsec/api.sock` and blank out any `listen_uri`. Create systemd drop-in `/etc/systemd/system/crowdsec.service.d/10-jabali-socket.conf` with `RuntimeDirectory=crowdsec` + `RuntimeDirectoryMode=0750` + `Group=jabali`, restart service, verify `cscli lapi status` returns OK.
   - `install_modsecurity()` — `apt install libnginx-mod-http-modsecurity modsecurity-crs`, create `/etc/nginx/modsec/main.conf` (includes `/etc/modsecurity/modsecurity.conf` + `/usr/share/modsecurity-crs/*.conf`), set `SecRuleEngine Off` as default (global toggle flips to `On` in Step 4). Do NOT add the `modsecurity on;` directive to any vhost yet.
   - `install_ufw()` — `apt install ufw`, `ufw default deny incoming`, `ufw default allow outgoing`, `ufw allow 22/tcp`, `ufw allow 80/tcp`, `ufw allow 443/tcp`, `ufw allow 8443/tcp`, `ufw allow 25/tcp`, `ufw allow 465/tcp`, `ufw allow 587/tcp`, `ufw allow 993/tcp`, `ufw allow 995/tcp`, `ufw allow 4190/tcp`. Idempotent enable guard: `if ! systemctl is-active --quiet ufw; then ufw --force enable; fi`. Never run bare `ufw --force enable` in an already-active installer run — it reloads iptables mid-flight.
3. Wire the three functions into the correct phase of `install.sh` (after MariaDB/PowerDNS, before Stalwart — Stalwart install must not race with `ufw enable` reloading iptables).
4. Add `crowdsec`, `crowdsec-firewall-bouncer-iptables`, `libnginx-mod-http-modsecurity`, `modsecurity-crs`, `ufw` to the install deps list (enforced by `feedback_deps_in_installer` memory rule).

### Verify
```bash
# on a fresh VM (LXC container or VPS):
bash install.sh
systemctl is-active crowdsec      # → active
cscli lapi status                 # → "You can successfully interact with Local API"
ls -la /run/crowdsec/api.sock     # → srw-rw---- root jabali
nginx -T 2>&1 | grep modsec       # → Step 1 leaves this empty (no vhost has modsec yet)
ufw status verbose                # → "Status: active", default deny incoming, 11 allow rules
ss -tlnp | grep ':8443\|:80\|:443'  # panel + nginx still reachable
ssh into host again from a 2nd terminal  # UFW didn't lock you out

# UFW + CrowdSec bouncer chain layout spike (MANDATORY before sign-off):
# Confirm bouncer chain insertion order vs UFW chains so rule numbering
# stays predictable after both services land.
iptables -L -v --line-numbers | tee /tmp/m26-iptables-layout.txt
nft list ruleset | tee /tmp/m26-nftables-layout.txt
# Expected: UFW's numbered INPUT rules unchanged; CrowdSec bouncer owns
# its own CROWDSEC_CHAIN referenced by a jump from INPUT position 1. If
# bouncer inserts rules INTO ufw-user-input instead of its own chain,
# STOP — that breaks UFW's numbered-delete semantics in Step 2.
```

### Exit criteria
- Three new ADRs on branch, accepted status.
- `install.sh` idempotent: second run is a no-op for all three installs.
- Fresh VM smoke passes end-to-end.

### Branch
`feat/m26-security-foundation`

---

## Step 2 — Agent commands

### Context brief
panel-agent gains nine new NDJSON handlers under three namespaces. Each wraps the corresponding CLI and returns structured JSON — no raw stdout leakage to panel-api.

CLI cheat-sheet (for reference; agent wraps these):
- `cscli decisions list -o json`, `cscli decisions add --ip <x> --duration <d> --reason <r>`, `cscli decisions delete --ip <x>`
- `cscli bouncers list -o json`
- `cscli metrics -o json`
- `cscli hub list -o json`
- `ufw status numbered` (parse), `ufw allow <rule>`, `ufw delete <num>`, `ufw default <policy> <chain>`, `ufw disable`, `ufw enable`
- ModSec global: edit `/etc/modsecurity/modsecurity.conf` `SecRuleEngine` directive, `nginx -t && systemctl reload nginx`
- ModSec audit log tail: `tail -n 50 /var/log/modsec_audit.log`

Commands:

| Command                    | Args                                    | Returns                              |
| -------------------------- | --------------------------------------- | ------------------------------------ |
| `security.crowdsec.status` | —                                       | `{running, lapi_reachable, version}` |
| `security.crowdsec.decisions.list` | `{scope?, limit?}`              | `{decisions:[{id,ip,duration,reason,scenario,until}]}` |
| `security.crowdsec.decisions.add`  | `{ip, duration, reason}`        | `{id}`                               |
| `security.crowdsec.decisions.delete` | `{id}` or `{ip}`              | `{deleted:true}`                     |
| `security.crowdsec.bouncers.list`  | —                               | `{bouncers:[{name,type,revoked,last_pull}]}` |
| `security.crowdsec.metrics`        | —                               | `{parsed, unparsed, buckets, decisions_active, alerts_total}` |
| `security.crowdsec.hub.list`       | `{type?}`                       | `{items:[{name,type,installed,enabled}]}` |
| `security.modsec.global.get`       | —                               | `{engine_mode, paranoia}`            |
| `security.modsec.global.set`       | `{engine_mode, paranoia}`       | `{applied:true, nginx_reloaded:true}` |
| `security.modsec.audit.tail`       | `{lines?}`                      | `{entries:[{ts, client, uri, rule_ids, severity}]}` |
| `security.ufw.status`              | —                               | `{active, default_in, default_out, rules:[{num,action,from,to,proto,port}]}` |
| `security.ufw.rule.add`            | `{action, port, proto?, from?}` | `{added:true, rule_num}`             |
| `security.ufw.rule.delete`         | `{num}`                         | `{deleted:true}`                     |
| `security.ufw.default.set`         | `{chain, policy}`               | `{applied:true}`                     |
| `security.ufw.enable`              | —                               | `{active:true}`                      |
| `security.ufw.disable`             | —                               | `{active:false}`                     |

### Tasks
1. Create files under `panel-agent/internal/commands/`: `security_crowdsec_*.go`, `security_modsec_*.go`, `security_ufw_*.go`. Each wraps its CLI with safe arg escaping.
2. Register in `panel-agent/internal/commands/default.go` (wherever the existing registry lives — check existing files like `ip_bind.go` for the pattern).
3. Add table-driven tests per file, using `exec.LookPath` fakes or subprocess command stubs (see `cron_apply_test.go` for the project's testing pattern with `"command"` args).
4. `security.ufw.enable` / `security.ufw.disable` MUST require `params.confirm = "YES"` and return `invalid_argument` otherwise — misclick on the UI must not drop the firewall.
5. **Strict input validation in every handler before shelling out**:
   - `security.ufw.rule.add`: `action ∈ {allow, deny, reject}`, `proto ∈ {tcp, udp}`, `port ∈ [1, 65535]` (or `port: "<N>:<M>"` range with both ends in range), `from` optional and must parse as IP or CIDR via `net.ParseCIDR`/`net.ParseIP`. Reject anything else with `invalid_argument`.
   - `security.ufw.rule.delete`: `num ∈ [1, 1000]`, integer only.
   - `security.ufw.default.set`: `chain ∈ {incoming, outgoing, routed}`, `policy ∈ {allow, deny, reject}`.
   - `security.crowdsec.decisions.add`: validate `ip` via `net.ParseIP`/`net.ParseCIDR`, `duration` against Go's `time.ParseDuration`, `reason` length 3..200.
   - `security.modsec.global.set`: `engine_mode ∈ {On, Off, DetectionOnly}`, `paranoia ∈ [1, 4]`.
6. **Audit log parser** for `security.modsec.audit.tail`: `/var/log/modsec_audit.log` in ModSecurity v3 defaults to JSON (one event per line). Agent reads last N lines (`tail -n <N>`), JSON-parses each, extracts `{transaction.time_stamp, transaction.client_ip, transaction.request.uri, transaction.messages[*].details.ruleId, transaction.messages[*].details.severity}`. If parsing fails for a line, include a raw-text fallback entry with `{raw: <line>, parse_error: true}`. Never return unparsed binary.

### Verify
```bash
cd /home/shuki/projects/jabali2
go test ./panel-agent/internal/commands/... -run Security -count=1
# smoke via socket on VM:
echo '{"id":"1","command":"security.ufw.status","params":{}}' | socat -t5 - UNIX-CONNECT:/run/jabali/agent.sock
echo '{"id":"2","command":"security.crowdsec.status","params":{}}' | socat -t5 - UNIX-CONNECT:/run/jabali/agent.sock
echo '{"id":"3","command":"security.modsec.global.get","params":{}}' | socat -t5 - UNIX-CONNECT:/run/jabali/agent.sock
```

### Exit criteria
- 16 new commands registered, all return OK on the VM smoke.
- Unit tests green.
- UFW enable/disable refuse without `confirm=YES`.

### Branch
`feat/m26-agent-security`

---

## Step 3 — DB migration (ModSecurity config only)

### Context brief
CrowdSec and UFW state stays in their services. Only ModSecurity needs DB rows because `modsec_enabled` is per-domain config flowing through the nginx vhost template (ADR-0009).

Two locations:
1. `domains` table — add `modsec_enabled BOOL NOT NULL DEFAULT 0` column
2. `server_settings` — add keys `modsec_global_enabled` (default `"0"`) and `modsec_paranoia_level` (default `"1"`)

### Tasks
1. Create migration files `panel-api/internal/db/migrations/000058_add_modsec_enabled_to_domains.up.sql` + `.down.sql`:
   - UP: `ALTER TABLE domains ADD COLUMN modsec_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER email_enabled;`
   - DOWN: `ALTER TABLE domains DROP COLUMN modsec_enabled;`
   - **Schema only — no data seed** (per `feedback_migration_data_seed_ordering`). ServerSetting defaults are seeded by repository code, not migrations.
2. Add `ModSecEnabled bool` field to `models.Domain` with the right GORM tag.
3. Add `ServerSetting.EnsureDefault("modsec_global_enabled", "0")` + `EnsureDefault("modsec_paranoia_level", "1")` wired into `serve.go` first-boot init (check how `managed_ips` seeds its default row for the pattern).
4. Verify migration runs forward and back on a dev DB: `migrate up; migrate down 1; migrate up`.

### Verify
```bash
cd /home/shuki/projects/jabali2 && go test ./panel-api/internal/repository/... -count=1
# then on VM:
mariadb jabali_panel -e "DESCRIBE domains" | grep modsec_enabled   # → column exists
mariadb jabali_panel -e "SELECT key_name, value FROM server_settings WHERE key_name LIKE 'modsec%'"   # 2 rows
```

### Exit criteria
- Migration 000058 green on fresh install AND on an upgrade install (existing .14 VM).
- Model + repository methods compile and unit tests pass.

### Branch
`feat/m26-db-modsec-flag`

---

## Step 4 — panel-api routes

### Context brief
Three handler files, all behind `middleware.RequireAdmin()`:

- `panel-api/internal/api/security_crowdsec.go`
- `panel-api/internal/api/security_modsec.go`
- `panel-api/internal/api/security_ufw.go`

Route map:
```
GET    /api/v1/admin/security/crowdsec/status
GET    /api/v1/admin/security/crowdsec/decisions?scope=&limit=
POST   /api/v1/admin/security/crowdsec/decisions        {ip, duration, reason}
DELETE /api/v1/admin/security/crowdsec/decisions/:id
GET    /api/v1/admin/security/crowdsec/bouncers
GET    /api/v1/admin/security/crowdsec/metrics
GET    /api/v1/admin/security/crowdsec/hub?type=

GET    /api/v1/admin/security/modsec/status             ({global, paranoia, audit_tail})
PUT    /api/v1/admin/security/modsec/global             {engine_mode, paranoia}
GET    /api/v1/admin/security/modsec/domains            ({data:[{id,name,modsec_enabled}], total})
PATCH  /api/v1/admin/security/modsec/domains/:id        {modsec_enabled}
GET    /api/v1/admin/security/modsec/audit?lines=

GET    /api/v1/admin/security/ufw/status
POST   /api/v1/admin/security/ufw/rules                 {action, port, proto?, from?}
DELETE /api/v1/admin/security/ufw/rules/:num
PUT    /api/v1/admin/security/ufw/default               {chain, policy}
POST   /api/v1/admin/security/ufw/enable                {confirm:"YES"}
POST   /api/v1/admin/security/ufw/disable               {confirm:"YES"}
```

Wire contract for `modsec/domains` — must use the same envelope as `/admin/ips` (`{data, total, page, page_size}`) per `feedback_verify_wire_contract`.

`PATCH /admin/security/modsec/domains/:id` triggers the existing domain reconciler pass to regenerate the vhost (see how `PATCH /admin/domains/:id` already does it).

`PUT /admin/security/modsec/global` calls `security.modsec.global.set` which reloads nginx. If `engine_mode: "On"` while NO rules exist, agent should return 200 (mode is global; per-domain vhosts still opt in via `modsec_enabled`).

### Tasks
1. Create the three handler files with tests (`_test.go` alongside each).
2. Register routes in `app.go` (or wherever `RegisterIPRoutes` is called — follow the same pattern).
3. On `PATCH /admin/security/modsec/domains/:id`, push the domain through the existing reconciler reconcile-single-domain path (don't duplicate logic).
4. Add destructive-action guard on `POST /ufw/enable|disable` — require JSON body `{"confirm":"YES"}`, else 400.
5. **`GET /admin/security/crowdsec/decisions` normalizes `?scope`** — accepted values: `ip, range, country, as`. Unknown value → 400 `invalid_scope`. Empty/omitted → no filter (all scopes). Handler echoes the normalized value into the agent `params` (keep wire contract mapping explicit — see `feedback_verify_wire_contract`).

### Verify
```bash
cd /home/shuki/projects/jabali2 && go test ./panel-api/internal/api/... -run Security -count=1
# smoke via curl (grab an admin session from Kratos first):
curl -sk -H "Cookie: ory_kratos_session=..." https://jabali-panel.local:8443/api/v1/admin/security/ufw/status | jq
```

### Exit criteria
- All 18 endpoints return 200/2xx on the VM with real services.
- 401/403 on missing/non-admin session.
- Unit tests green.

### Branch
`feat/m26-api-security`

---

## Step 5 — Reconciler: per-domain ModSec include

### Context brief
nginx vhost generator (`panel-api/internal/nginxgen/` or similar — find via `grep -rn "server_name" panel-api/internal/`) emits one `.conf` per domain. When `domains.modsec_enabled = 1` AND `server_settings.modsec_global_enabled = 1`, the vhost must include:

```nginx
modsecurity on;
modsecurity_rules_file /etc/nginx/modsec/main.conf;
```

When either flag is 0, the directives are omitted (or emitted as `modsecurity off;` so a partial-cache-fallback still works).

File layout (locked in):
- `/etc/nginx/modsec/main.conf` — ONE entry point owned by Step 1. Contains `Include /etc/modsecurity/modsecurity.conf`, CRS bootstrap, and `Include /usr/share/modsecurity-crs/rules/*.conf`. **Never rewritten at runtime**; vhosts reference this path.
- `/etc/modsecurity/modsecurity.conf` — Debian package default. Step 4's `security.modsec.global.set` agent handler rewrites ONLY the two directives `SecRuleEngine <mode>` and the `SecAction "id:900000,phase:1,...,setvar:tx.blocking_paranoia_level=<N>"` line (OWASP CRS reads paranoia from this tx var). Atomic write to `modsecurity.conf.new`, `mv`, `nginx -t`, reload on pass / rollback on fail.
- Per-domain `<domain>.conf` in `/etc/nginx/sites-available/` — rendered by this step's generator; includes the two directives above when flags allow.

Thread the two flags (domain.modsec_enabled + server_settings.modsec_global_enabled) through as template inputs. Paranoia level is a runtime setting on `modsecurity.conf`, not a per-vhost concern — Step 4 handles rewrite + reload.

### Tasks
1. Extend the vhost template with a conditional ModSec block gated on the two flags.
2. Thread `global_modsec_enabled` through the reconciler so individual domain reconciles see it.
3. Add golden-file unit tests: `domain(modsec_enabled=0) → no modsec lines`; `domain(modsec_enabled=1) & global=1 → modsec on lines`; `domain(modsec_enabled=1) & global=0 → no modsec lines` (global master switch wins).

### Verify
```bash
cd /home/shuki/projects/jabali2 && go test ./panel-api/internal/nginxgen/... -count=1
# on VM, enable global + one domain:
curl -X PUT -d '{"engine_mode":"On","paranoia":1}' .../admin/security/modsec/global
curl -X PATCH -d '{"modsec_enabled":true}' .../admin/security/modsec/domains/<id>
grep -l "modsecurity on" /etc/nginx/sites-available/   # → the toggled domain's file
nginx -t                                               # → ok
```

### Exit criteria
- Toggle round-trip via API reflects in vhost file within one reconcile pass (< 60s).
- `nginx -t` passes for any combination of toggles.

### Branch
`feat/m26-nginx-modsec-include`

---

## Step 6 — UI shell (admin/security route + tabs)

### Context brief
Mirror the existing admin-shell pattern (`panel-ui/src/shells/admin/ips/AdminIPList.tsx`). Create:
```
panel-ui/src/shells/admin/security/
  AdminSecurityPage.tsx         ← Page with <Tabs> — CrowdSec / ModSecurity / UFW
  AdminSecurityCrowdsec.tsx     ← placeholder in this step, filled in Step 7
  AdminSecurityModsec.tsx       ← placeholder, filled in Step 8
  AdminSecurityUfw.tsx          ← placeholder, filled in Step 8
```

Add a nav item to the admin sider (Lucide icon `Shield`) with href `/jabali-admin/security`. Route table lives somewhere like `panel-ui/src/routes/adminRoutes.tsx` — grep for an existing admin route to find it.

### Tasks
1. Create the four files. Tab switching via `antd` `Tabs`. URL param `?tab=crowdsec|modsec|ufw` drives active tab (useTabURL hook if one exists; else plain `useSearchParams`).
2. Wire into admin sider + route table.
3. Responsive: on mobile, Tabs orientation remains horizontal; scroll within each tab's content (per ADR-0046).
4. **Admin guard at the route**: the `/jabali-admin/*` shell already enforces `user.is_admin === true` (reuse the existing `RequireAdminShell` / `AdminLayout` wrapper — grep for how `AdminIPList.tsx` is registered). Do NOT register the route outside the admin shell. Verify by logging in as a non-admin and confirming the route 404s (SPA-side) rather than rendering a broken page.

### Verify
- `npm run build` green.
- Manual: navigate to `/jabali-admin/security`, see three tabs, placeholders render, URL updates on switch.

### Exit criteria
- Nav + routing works.
- Placeholders render a one-line "Coming in step 7/8" message each.

### Branch
`feat/m26-ui-security-shell`

---

## Step 7 — UI CrowdSec tab

### Context brief
Four cards, top to bottom:
1. **Metrics** — small card with parsed/unparsed/alerts counters. Poll every 30s.
2. **Active decisions** (table) — columns: IP, scenario, reason, until, action (delete). Filter by scope. Row "Add decision" opens Modal with IP + duration + reason.
3. **Bouncers** (table) — columns: name, type, last_pull, revoked. Read-only.
4. **Hub scenarios** (table) — columns: name, type, installed, enabled. Read-only.

TanStack Query hooks reuse `useTableURL` + `useQueries` helpers already in `panel-ui/src/hooks/`.

Add-decision form validation: IP (ipv4 or CIDR), duration (e.g. `4h`, `1d`, `30m`), reason (min 3 chars).

### Tasks
1. Build the four cards using existing `SearchableTable` / `Card` patterns from `AdminIPList.tsx`.
2. Add-decision Modal with form + POST hook + query invalidation.
3. Delete button on each decision row → `Popconfirm` ("Remove the ban on <ip>? Traffic will resume immediately.") then DELETE + invalidation. Never delete on single click.
4. Empty-state messages when no decisions / no bouncers / no hub items.

### Verify
- Seed a decision via `cscli decisions add --ip 1.2.3.4 --duration 1h --reason test`, see it in the UI within one refetch.
- Delete from UI, confirm `cscli decisions list` no longer shows it.
- Playwright: smoke journey lives in Step 9.

### Exit criteria
- All four cards populated with live data on VM.
- Add + delete round-trip works.

### Branch
`feat/m26-ui-crowdsec`

---

## Step 8 — UI ModSec + UFW tabs

### Context brief
**ModSec tab:**
- Card 1: Global — Switch (Off / DetectionOnly / On), Slider paranoia 1-4.
- Card 2: Per-domain table — columns: domain, modsec_enabled (Switch). Reuse `useTableURL` paginated.
- Card 3: Audit log tail — last 50 lines, monospace, auto-refresh every 30s.

**UFW tab:**
- Card 1: Status — Tag active/inactive, default in/out as Tag.
- Card 2: Rules table — num, action, port, proto, from, delete.
- Card 3: Add rule form (action, port, proto, from).
- Footer: Enable / Disable Firewall buttons. Each opens a `Modal.confirm` with "Type YES to confirm" text input gate; only then POSTs `{confirm:"YES"}`. Disable is RED.

### Tasks
1. ModSec tab as described; on Switch toggle, PATCH `/modsec/domains/:id` then invalidate list.
2. UFW tab: rules table + add-rule form; `Popconfirm` on every rule delete (never single-click); destructive Modal on enable/disable with YES-typing gate; show Tag red "Firewall DISABLED" banner at top of the Security page if UFW is off.
3. When UFW is off, hide the rule list (misleading) and show an info banner "Rules ignored — firewall disabled. Enable firewall to apply rules." above the rules card.

### Verify
- Toggle ModSec globally then on one domain; curl the domain and see a WAF block on a benign XSS probe (`?q=<script>alert(1)</script>`) — should get 403.
- Add a UFW deny rule to port 9999, `ufw status` shows it, delete from UI, `ufw status` no longer shows it.
- Disable UFW from UI (with YES confirm), red banner appears, rules card hidden.

### Exit criteria
- Both tabs fully functional on VM.
- Destructive actions blocked without confirm text.

### Branch
`feat/m26-ui-modsec-ufw`

---

## Step 9 — E2E + runbook + polish

### Context brief
Gitea CI runs vitest + Playwright. Add E2E journeys and a runbook.

### Tasks
1. Playwright E2E (`panel-ui/e2e/admin-security.spec.ts`):
   - Navigate to `/jabali-admin/security`, assert three tabs visible.
   - CrowdSec: add decision for 10.0.0.99, see row, delete, gone.
   - ModSec: toggle global on, toggle one domain, curl the domain from the test (via `page.evaluate` fetch) with an XSS probe, expect 403.
   - UFW: add rule, see row, delete, gone. Disable firewall test SKIPPED (would lock out the runner).
2. Runbook at `plans/m26-security-tab-runbook.md`:
   - Install smoke commands
   - How to enrol in CrowdSec Console (optional)
   - How to change ModSec paranoia (UI vs CLI)
   - UFW factory reset: `ufw --force reset && ufw default deny incoming && <re-open ports>`
   - Troubleshooting: CrowdSec LAPI unreachable, ModSec blocking legitimate traffic, UFW locked-out recovery (LXC console path)
3. Update memory: `project_plan_m26_security_tab.md` → `project_m26_security_tab_shipped.md` after final merge.

### Verify
- CI run green on the final PR.
- Fresh VM install + full journey smoke via Playwright headed run.

### Exit criteria
- E2E green in CI.
- Runbook published.
- Memory updated.
- M26 folded into the "What's shipped" section of `docs/BLUEPRINT.md`.

### Branch
`feat/m26-e2e-runbook`

---

## Dependency DAG

```
Step 1 (foundation)
  ├── Step 2 (agent commands) ── Step 4 (api) ── Step 6 (ui shell)
  │                                                 ├── Step 7 (crowdsec tab)
  │                                                 └── Step 8 (modsec + ufw tabs)
  ├── Step 3 (db migration) ── Step 4 (api) ── Step 5 (reconciler)
  └──────────────────────────────────────────────────── Step 9 (E2E + runbook)

Parallel windows:
- Step 2 and Step 3 run parallel after Step 1
- Step 7 and Step 8 run parallel after Step 6
```

## Risk register

| Risk | Severity | Mitigation |
|------|----------|------------|
| UFW enable locks operator out of SSH | CRITICAL | Step 1 opens 22/tcp BEFORE `ufw enable`; destructive UI actions require `confirm=YES`; runbook documents LXC console recovery |
| CrowdSec LAPI socket collides with Stalwart :8080 | HIGH | Use unix socket per ADR-0050; NEVER bind TCP for LAPI |
| ModSec OWASP CRS blocks legitimate panel/webmail traffic | HIGH | Default engine_mode `Off`; operator opts in; paranoia defaults to 1 (least aggressive); audit tail visible in UI for tuning |
| Installing CrowdSec firewall-bouncer while UFW is active causes rule conflict | MEDIUM | Step 1 orders: UFW base rules → UFW enable → CrowdSec install (bouncer adds its own chain separate from UFW's numbered rules) |
| ModSec paranoia change rewrites main.conf mid-request | LOW | Agent writes to `main.conf.new`, `mv` atomic, `nginx -t` then reload; failure rolls back |
| Running two bouncers (iptables + nginx) double-bans requests | LOW | Start with iptables-bouncer only; nginx-bouncer deferred |

## Reference index

- M25 Unix sockets ADR — `docs/adr/0050-m25-unix-sockets.md`
- Nginx file-per-vhost — `docs/adr/0009-nginx-file-per-vhost.md`
- DB-as-truth — `docs/adr/0002-database-source-of-truth.md`
- CrowdSec docs — `https://docs.crowdsec.net/docs/next/`
- ModSecurity-nginx — `https://github.com/owasp-modsecurity/ModSecurity-nginx`
- UFW — `man ufw` + Debian wiki

## Cold-start cheat sheet

```bash
# Where do I start? → Step 1. Read the Context brief, run Tasks, verify.
cd /home/shuki/projects/jabali2
git checkout main && git pull
git checkout -b feat/m26-security-foundation
# ... implement Step 1 ...
git commit -m "feat(m26): crowdsec + modsecurity + ufw install foundation"
# open PR to main, merge when green, move on to Step 2.

# Anything unclear in a step? Check the ADRs listed in its Context brief
# and the Reference index at the bottom of this file.

# Before declaring any step done:
go test ./... -count=1
cd panel-ui && npm run build && npx tsc --noEmit
```
