diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..069ed5f --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# +# Deployment support for the intrusion-detection IDS. +# +# This directory is intentionally small and uses no external framework: +# deploy.sh conflict-free `git pull` update wrapper (D004) +# ids.service systemd unit template (D005) +# restart-service.sh supervisor-agnostic restart shim (D005) +# check_runtime_git.sh proves a scan never dirties tracked runtime files (D001) +# +# See .github/instructions/DEPLOYMENT.md for the full plan. + +set -euo pipefail diff --git a/deploy/check_runtime_git.sh b/deploy/check_runtime_git.sh new file mode 100644 index 0000000..e1c951e --- /dev/null +++ b/deploy/check_runtime_git.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# +# D001 — runtime write-path guard. +# +# Proves the deployment invariant: after running a scan, `git status` shows +# NO tracked file modified by the scan — every runtime write lands in a +# gitignored location (data/, reports/, *.db). This is what makes `git pull` +# conflict-free on a server where scans keep running. +# +# If this fails, a scan wrote into a git-tracked path and `git pull` on the +# server can conflict. Investigate the printed files before deploying. +# +# Usage: +# deploy/check_runtime_git.sh [--live] +# --live additionally run a real scan first (requires log sources) and +# then assert the tree is still clean. Without --live it only +# verifies that whatever runtime files already exist are untracked. +set -euo pipefail +cd "$(dirname "$0")/.." + +if [[ "${1:-}" == "--live" ]]; then + echo "== Running a real scan to exercise every write path ==" + python app.py scan --quiet --dry-run || true +fi + +echo "== Checking that runtime data is gitignored / untracked ==" +# Paths a scan writes to. Any of these being tracked OR showing as (non-ignored) +# untracked means the invariant is broken and a git pull could conflict. +RUNTIME_PATHS=( + "app/data" + "app/reports" + "app/data/ids.db" + "app/data/ids.db-wal" + "app/data/ids.db-shm" +) + +fail=0 +# 1) No runtime path may be TRACKED, except the single allowed read-only +# PaloAlto seed. `git ls-files` reflects the index (including staged +# changes), so unborn commits don't cause false failures. +allowed="app/data/paloalto/scanner-ips.txt" +tracked=$(git ls-files app/data app/reports) +problem=$(printf '%s\n' "$tracked" | grep -v '^'"/\$allowed"'$' || true) +if [[ -n "$problem" ]]; then + echo "FAIL: unexpected tracked files under runtime dirs:" >&2 + echo "$problem" >&2 + fail=1 +fi + +# 2) Nothing may show as untracked-but-not-ignored under the runtime dirs, i.e. +# a scan wrote a file git would happily add (and later conflict on). +for p in "${RUNTIME_PATHS[@]}"; do + dirty=$(git status --porcelain --untracked-files=all -- "$p" 2>/dev/null || true) + if [[ -n "$dirty" ]]; then + echo "FAIL: runtime path has changes git cares about:" >&2 + echo "$dirty" >&2 + fail=1 + fi +done + +if [[ "$fail" -eq 0 ]]; then + echo "OK: no tracked runtime data; git pull will not conflict with scan writes." +else + echo "BROKEN: a scan wrote into git-tracked paths — fix before deploying." >&2 + exit 1 +fi diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100644 index 0000000..437c2bf --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# +# D004 — conflict-free deployment update wrapper. +# +# Updates a deployed instance with `git pull` WITHOUT ever hitting a merge +# conflict from files a scan wrote since the last pull. Guarantees: +# +# 1. Refuses to update over uncommitted tracked changes (clean-tree guard). +# 2. Fast-forwards only (--ff-only): never creates merge commits and never +# silently conflicts. +# 3. Runtime data (data/, reports/, *.db) is gitignored, so a running scan +# leaves no dirty tracked paths and never blocks the pull. +# 4. Reinstalls dependencies only if requirements.txt changed. +# 5. Applies DB migrations idempotently. +# 6. Restarts the app under its supervisor. +# +# Usage: +# sudo deploy/deploy.sh # update + restart (default env: production) +# ENV=production deploy/deploy.sh # use deploy/ids@production.service +# deploy/deploy.sh --no-restart # update deps+migrate but don't restart +set -euo pipefail +cd "$(dirname "$0")/.." + +ENV="${ENV:-production}" +RESTART="${1:-restart}" +ROOT="$(pwd)" +PY="${PY:-python}" + +echo "== Intrusion Detection deploy (env=$ENV) ==" + +# 1) Clean-tree guard. Runtime data is gitignored, so this only fails if a +# human edited tracked source without committing. +if ! git diff --quiet HEAD || ! git diff --cached --quiet; then + echo "REFUSING: working tree is not clean." >&2 + echo "Commit or stash tracked changes, then re-run." >&2 + echo " git status --short" >&2 + exit 1 +fi + +# 2) Pre-flight: prove runtime isolation holds (fail fast before touching repo). +if "$ROOT/deploy/check_runtime_git.sh"; then + echo "== runtime isolation ok ==" +else + echo "ABORT: runtime isolation check failed." >&2 + exit 1 +fi + +# 3) Fast-forward only. +echo "== fetching upstream ==" +git fetch origin +echo "== fast-forward pull ==" +git pull --ff-only + +# 4) Dependencies (only if the requirement set changed since the last pull). +if git diff --quiet HEAD@{1} HEAD -- requirements.txt; then + echo "== requirements.txt unchanged; skipping pip ==" +else + echo "== requirements.txt changed; installing deps ==" + command -v "$PY" >/dev/null && "$PY" -m pip install -r requirements.txt \ + || pip install -r requirements.txt +fi + +# 5) Apply DB migrations (idempotent). +echo "== applying DB migrations ==" +"$PY" -m app.db --migrate + +# 6) Restart under the supervisor. +if [[ "$RESTART" == "--no-restart" ]]; then + echo "== --no-restart: update complete; restart manually ==" +else + echo "== restarting service ==" + "$ROOT/deploy/restart-service.sh" "$ENV" +fi + +echo "== deploy complete ==" diff --git a/deploy/ids.service b/deploy/ids.service new file mode 100644 index 0000000..245cb47 --- /dev/null +++ b/deploy/ids.service @@ -0,0 +1,41 @@ +# systemd unit template (D005) — intrusion-detection web UI. +# +# Install: +# - Replace /path/to/intrusion-detection and the User/Group. +# - Copy to /etc/systemd/system/ids.service +# - sudo systemctl daemon-reload && sudo systemctl enable --now ids +# +# The app binds to 127.0.0.1:; it must NOT be exposed to the LAN. +# +# Restart policy: automatically restart on crash. `deploy/deploy.sh` restarts +# this unit after a successful `git pull`. Scans run in a background thread, so +# one (serialized) scan at a time; deploy waits for the worker to be idle +# (see restart-service.sh) before restarting. + +[Unit] +Description=Intrusion Detection web UI + IDS scanner +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +# Run as an unprivileged user; the app only needs read access to the log dirs +# it consumes (COPYPARTY_LOG_PATH / NPM_LOG_PATH) and write access to its own +# data/ + reports/ + ids.db. +User=ids +Group=ids +WorkingDirectory=/path/to/intrusion-detection +EnvironmentFile=-/etc/ids.env +ExecStart=/path/to/intrusion-detection/.venv/bin/python /path/to/intrusion-detection/app.py +Restart=on-failure +RestartSec=5 + +# Hardening +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=full +ProtectHome=no +ReadWritePaths=/path/to/intrusion-detection/data /path/to/intrusion-detection/reports /var/lib/ids + +[Install] +WantedBy=multi-user.target diff --git a/deploy/restart-service.sh b/deploy/restart-service.sh new file mode 100644 index 0000000..11310e5 --- /dev/null +++ b/deploy/restart-service.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# +# D005 — supervisor-agnostic restart shim. +# +# Restarts the intrusion-detection service after a deploy, preferring systemd +# (Linux), with a fallback message for other environments (Windows dev). +# +# It first waits for any in-flight background scan to finish so a checkout +# change never interleaves with a live scan (belt-and-suspenders; the runtime +# isolation guarantee already means scans only write gitignored files). +# +# Usage: +# deploy/restart-service.sh [production] +# ENV=production deploy/restart-service.sh +set -euo pipefail +cd "$(dirname "$0")/.." + +ENV="${1:-${ENV:-production}}" +UNIT="ids@${ENV}.service" + +# Wait for an in-flight scan to finish (~0..120s). We probe the web UI status +# endpoint if it is reachable; a failure to reach it just means no web UI is up. +web_host="${WEB_HOST:-127.0.0.1}" +web_port="${WEB_PORT:-54666}" +status_url="http://${web_host}:${web_port}/api/scan/status" +for _ in $(seq 1 60); do + running=$(curl -fsS --max-time 2 "$status_url" 2>/dev/null \ + | python3 -c 'import sys,json;print(json.load(sys.stdin).get("running",False))' 2>/dev/null \ + || echo false) + [[ "$running" == "false" ]] && break + echo " waiting for running scan to finish..." + sleep 2 +done + +if command -v systemctl >/dev/null 2>&1; then + # Installed unit is .service.template; the enabled name includes env. + if systemctl list-unit-files "${UNIT}" >/dev/null 2>&1; then + echo "== systemctl restart ${UNIT} ==" + systemctl restart "${UNIT}" + systemctl --no-pager status "${UNIT}" --lines=0 || true + elif systemctl list-unit-files "ids.service" >/dev/null 2>&1; then + echo "== systemctl restart ids.service ==" + systemctl restart ids.service + systemctl --no-pager status ids.service --lines=0 || true + else + echo "No ids systemd unit found; restart manually." >&2 + exit 2 + fi +else + echo "No systemd available (Windows dev?). Restart the app manually." >&2 + echo " python app.py # in a terminal, or via your dev runner" >&2 + exit 2 +fi diff --git a/tests/test_deployment.py b/tests/test_deployment.py new file mode 100644 index 0000000..fa35409 --- /dev/null +++ b/tests/test_deployment.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Deployment invariant tests (D001). + +Proves that running a scan never creates or modifies git-tracked runtime data, +which is what makes ``git pull`` conflict-free on a deployed server. + +Run with: + python -m unittest discover -s tests -p 'test_*.py' +or (if pytest is installed): + python -m pytest tests/ +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import unittest + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +# The paloalto scanner-ips.txt seed is deliberately tracked (read-only); every +# other file under the runtime dirs must remain untracked. +ALLOWED_TRACKED_RUNTIME = {os.path.join("app", "data", "paloalto", + "scanner-ips.txt")} + + +def _git(*args: str) -> str: + return subprocess.run(["git", *args], cwd=ROOT, check=True, + capture_output=True, text=True).stdout + + +class RuntimeGitTest(unittest.TestCase): + def test_no_runtime_data_tracked(self): + """No runtime file (other than the read-only paloalto seed) is tracked.""" + tracked = _git("ls-files", "app/data", "app/reports").splitlines() + unexpected = [f for f in tracked + if f not in ALLOWED_TRACKED_RUNTIME] + self.assertEqual( + unexpected, [], + "git-tracked runtime files would make `git pull` conflict; " + "add them to .gitignore: %s" % unexpected) + + def test_runtime_write_paths_ignored(self): + """Every runtime write path is ignored by git (won't dirty the tree).""" + stats = subprocess.run( + ["git", "check-ignore", "-v", + "app/data/scan_state.json", "app/data/scan_jobs.json", + "app/data/whois_cache.json", "app/data/ids.db", + "app/data/ids.db-wal", "app/reports/app/data/blocklist_ipv4.txt", + ], cwd=ROOT, capture_output=True, text=True).stdout + self.assertIn("scan_state.json", stats) + self.assertIn("ids.db", stats) + self.assertIn("blocklist_ipv4.txt", stats) + + +if __name__ == "__main__": + unittest.main()