diff --git a/SECURITY_EXECUTION_PLAN.md b/SECURITY_EXECUTION_PLAN.md new file mode 100644 index 0000000..918a6c4 --- /dev/null +++ b/SECURITY_EXECUTION_PLAN.md @@ -0,0 +1,113 @@ +# Security Execution Plan (Dual Caddy: VPS Public + NAS Internal) + +## Confirmed Architecture + +1. `caddy-vps` is public ingress only. +2. `nas-caddy` is internal ingress only (LAN/Tailscale/internal service routing). +3. `server2` is for VLESS+REALITY and Tailscale exit-node use, not app ingress/CI. +4. CI/CD is Gitea push-triggered deploy on NAS. + +## Public Host Responsibility Matrix + +1. `auth.jimmygan.com` -> VPS Caddy -> NAS Authelia (`100.78.131.124:9092`). +2. `nas.jimmygan.com` -> VPS Caddy -> NAS dashboard (`100.78.131.124:4000`). +3. `music.jimmygan.com` -> VPS Caddy -> NAS navidrome (`100.78.131.124:4533`). +4. `photos.jimmygan.com` -> VPS Caddy -> NAS immich (`100.78.131.124:2283`). +5. `photos-app.jimmygan.com` -> VPS Caddy -> NAS immich (`100.78.131.124:2283`). + +All other service domains remain internal-only unless explicitly approved. + +## Security Objectives + +1. Keep your public access goals (`nas/music/photos/photos-app`) working. +2. Enforce centralized auth + policy at public edge (`caddy-vps` + Authelia). +3. Remove secret leakage risk and tighten trust boundaries. +4. Preserve internal NAS workflows and current service availability. + +## Phase 0 - Pre-Change Safety + +1. Snapshot current configs: + - VPS: `/etc/caddy/Caddyfile` + - NAS: `/volume1/docker/caddy/Caddyfile` + - NAS: `/volume1/docker/authelia/config/configuration.yml` +2. Export backups for dashboard/gitea/immich/auth config + DB. +3. Define maintenance window (expect auth/session resets). + +## Phase 1 - Secret Rotation and Hygiene + +1. Rotate exposed secrets immediately: + - Authelia `session.secret`, storage encryption key, reset JWT secret. + - LDAP bind password. + - SMTP app password. + - Gitea DB password and runner registration token. + - Telegram bot token and any API tokens. +2. Move secrets to non-git env/secret files. +3. Keep `.env` and local assistant memory files untracked. +4. Verify no plaintext secrets remain in tracked files. + +## Phase 2 - VPS Caddy as Single Public Auth Edge + +1. Update VPS Caddyfile to include all public hosts: + - `auth`, `nas`, `music`, `photos`, `photos-app`. +2. Ensure `forward_auth` is applied on `nas/music/photos/photos-app`. +3. Keep `auth.jimmygan.com` without `forward_auth` (Authelia portal itself). +4. Keep TLS + security headers on VPS Caddy. +5. Reload Caddy and test all hostnames from public internet. + +## Phase 3 - Authelia Policy Enforcement + +1. Keep Authelia + LDAP as unified identity source. +2. Enforce mandatory 2FA for public apps: + - `nas`, `music`, `photos`, `photos-app`. +3. Use group-based access rules where needed (family/media/admin groups). +4. Keep documented break-glass admin recovery procedure. + +## Phase 4 - NAS Internal Plane Hardening + +1. Keep NAS Caddy for internal-only domains/routes. +2. Remove overlap for public host policies from NAS Caddy (or clearly isolate). +3. Reduce direct host port exposure where possible. +4. Restrict NAS firewall/source ACL so public app ports are intended-path only. + +## Phase 5 - Dashboard Hardening + +1. Tighten trusted proxy logic in dashboard backend: + - Only explicit proxy IPs/subnets. + - No broad trust defaults. +2. Harden `/api/auth/proxy` against header spoofing. +3. Make SSH terminal fail-closed when `known_hosts` is missing. +4. Move dashboard auth from `localStorage` tokens to secure httpOnly cookies. +5. Add CSRF defenses for state-changing endpoints after cookie migration. + +## Phase 6 - Jellyfin/LLDAP Persistence Fix + +1. Persist network attachment in compose (current runtime `docker network connect` is not durable). +2. Avoid exposing LDAP bind ports publicly if not required. +3. Keep Jellyfin auth path consistent with Authelia/LDAP strategy. + +## Phase 7 - Validation Checklist + +1. Public access works for: + - `https://nas.jimmygan.com` + - `https://music.jimmygan.com` + - `https://photos.jimmygan.com` + - `https://photos-app.jimmygan.com` +2. All above require Authelia login and 2FA. +3. `https://auth.jimmygan.com` is reachable and stable. +4. No app access from internet via raw NAS ports. +5. Internal-only routes still work from LAN/Tailscale. +6. Secret scan returns no tracked plaintext credentials. + +## Rollback Plan + +1. Restore previous VPS Caddyfile and reload Caddy. +2. Restore previous NAS Caddyfile/Authelia config. +3. Revert to prior container image tags if app auth breaks. +4. Keep old+new secrets during transition window, then remove old. + +## New Conversation Handoff Prompt + +Use this to resume execution in a new chat: + +"Execute `SECURITY_EXECUTION_PLAN.md` phase-by-phase. Start with Phase 0 and Phase 1 only, show diffs before apply, and validate after each phase. Preserve dual-Caddy architecture: VPS public ingress, NAS internal ingress." + diff --git a/dashboard/backend/config.py b/dashboard/backend/config.py index 1afee27..16140c0 100644 --- a/dashboard/backend/config.py +++ b/dashboard/backend/config.py @@ -61,7 +61,7 @@ def is_lan_ip(ip_str: str) -> bool: except ValueError: return False -TRUSTED_PROXIES = os.environ.get("TRUSTED_PROXIES", "127.0.0.1,::1,172.16.0.0/12,10.0.0.0/8").split(",") +TRUSTED_PROXIES = os.environ.get("TRUSTED_PROXIES", "127.0.0.1,::1,172.16.0.0/12").split(",") def is_trusted_proxy(ip_str: str) -> bool: import ipaddress diff --git a/dashboard/backend/routers/terminal.py b/dashboard/backend/routers/terminal.py index c3ec04b..9148cbe 100644 --- a/dashboard/backend/routers/terminal.py +++ b/dashboard/backend/routers/terminal.py @@ -21,12 +21,14 @@ HOSTS = { "command": "/volume1/@appstore/ContainerManager/usr/bin/docker exec -it claude-dev bash"}, } -# Load known_hosts once at import time -_known_hosts = None +# Load known_hosts once at import time — fail closed if missing +_known_hosts_available = False try: _known_hosts = asyncssh.read_known_hosts(config.SSH_KNOWN_HOSTS) + _known_hosts_available = True except Exception: - logger.warning("SSH known_hosts file not found at %s — host key verification disabled", config.SSH_KNOWN_HOSTS) + _known_hosts = None + logger.error("SSH known_hosts file not found at %s — terminal connections will be refused", config.SSH_KNOWN_HOSTS) async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")): @@ -43,6 +45,10 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")): await websocket.close(code=1008, reason="Unauthorized") return + if not _known_hosts_available: + await websocket.close(code=1011, reason="SSH host verification unavailable") + return + if len(_active_sessions) >= MAX_SESSIONS: await websocket.close(code=1013, reason="Too many sessions") return diff --git a/gitea/docker-compose.yml b/gitea/docker-compose.yml new file mode 100755 index 0000000..87c65da --- /dev/null +++ b/gitea/docker-compose.yml @@ -0,0 +1,77 @@ +version: "3" + +services: + gitea: + image: gitea/gitea:latest + container_name: gitea + environment: + - USER_UID=1000 + - USER_GID=1000 + - GITEA__database__DB_TYPE=postgres + - GITEA__database__HOST=db:5432 + - GITEA__database__NAME=gitea + - GITEA__database__USER=gitea + - GITEA__database__PASSWD=${GITEA_DB_PASSWORD} + - GITEA__server__ROOT_URL=https://git.jimmygan.com:8443/ + - GITEA__server__SSH_DOMAIN=100.78.131.124 + - GITEA__server__SSH_PORT=2222 + - GITEA__actions__ENABLED=true + - GITEA__service__ENABLE_REVERSE_PROXY_AUTHENTICATION=true + - GITEA__service__ENABLE_REVERSE_PROXY_AUTO_REGISTRATION=false + - GITEA__service__ENABLE_REVERSE_PROXY_EMAIL=true + - GITEA__service__ENABLE_REVERSE_PROXY_FULL_NAME=true + - GITEA__reverse_proxy__TRUSTED_PROXIES=127.0.0.1/32,::1/128,172.16.0.0/12 + - GITEA__reverse_proxy__AUTHENTICATION_USER=Remote-User + - GITEA__reverse_proxy__AUTHENTICATION_EMAIL=Remote-Email + - GITEA__reverse_proxy__AUTHENTICATION_FULL_NAME=Remote-Name + restart: always + volumes: + - ./gitea-data:/data + - /etc/TZ:/etc/TZ:ro + - /etc/localtime:/etc/localtime:ro + ports: + - "3300:3000" + - "2222:22" + depends_on: + - db + networks: + - gitea + labels: + - com.centurylinklabs.watchtower.enable=true + + db: + image: postgres:16-alpine + container_name: gitea-db + restart: always + environment: + - POSTGRES_USER=gitea + - POSTGRES_PASSWORD=${GITEA_DB_PASSWORD} + - POSTGRES_DB=gitea + volumes: + - ./postgres-data:/var/lib/postgresql/data + networks: + - gitea + labels: + - com.centurylinklabs.watchtower.enable=true + + runner: + image: gitea/act_runner:latest + container_name: gitea-runner + restart: always + depends_on: + - gitea + volumes: + - ./runner-data:/data + - ./runner-config.yaml:/config.yaml + - /var/run/docker.sock:/var/run/docker.sock + - /var/packages/ContainerManager/target/usr/bin/docker:/usr/bin/docker + environment: + - CONFIG_FILE=/config.yaml + - GITEA_INSTANCE_URL=http://gitea:3000 + - GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_REGISTRATION_TOKEN} + networks: + - gitea + +networks: + gitea: + driver: bridge diff --git a/navidrome/docker-compose.yml b/navidrome/docker-compose.yml index 4b2c83c..dc1967a 100644 --- a/navidrome/docker-compose.yml +++ b/navidrome/docker-compose.yml @@ -9,6 +9,8 @@ services: ND_SCANSCHEDULE: 1h ND_LOGLEVEL: info ND_BASEURL: "" + ND_REVERSEPROXYUSERHEADER: "Remote-User" + ND_REVERSEPROXYWHITELIST: "127.0.0.1/32,::1/128,172.16.0.0/12,192.168.0.0/16,10.0.0.0/8" healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:4533/ping"] interval: 30s diff --git a/tmp_remote/Caddyfile_nas b/tmp_remote/Caddyfile_nas new file mode 100755 index 0000000..c79438d --- /dev/null +++ b/tmp_remote/Caddyfile_nas @@ -0,0 +1,73 @@ +{ + https_port 8443 + http_port 8880 + servers { + protocols h1 h2 + } +} + +# Authelia endpoint (no forward_auth on itself) +auth.jimmygan.com { + tls { + dns cloudflare {env.CLOUDFLARE_API_TOKEN} + } + reverse_proxy localhost:9092 +} + +# Protected services with forward_auth +nas.jimmygan.com { + tls { + dns cloudflare {env.CLOUDFLARE_API_TOKEN} + } + forward_auth localhost:9092 { + uri /api/verify?rd=https://auth.jimmygan.com:8443 + copy_headers Remote-User Remote-Groups Remote-Name Remote-Email + } + reverse_proxy localhost:4000 +} + +music.jimmygan.com { + tls { + dns cloudflare {env.CLOUDFLARE_API_TOKEN} + } + forward_auth localhost:9092 { + uri /api/verify?rd=https://auth.jimmygan.com:8443 + copy_headers Remote-User Remote-Groups Remote-Name Remote-Email + } + reverse_proxy localhost:4533 { + flush_interval -1 + } +} + +n8n.jimmygan.com { + tls { + dns cloudflare {env.CLOUDFLARE_API_TOKEN} + } + forward_auth localhost:9092 { + uri /api/verify?rd=https://auth.jimmygan.com:8443 + copy_headers Remote-User Remote-Groups Remote-Name Remote-Email + } + reverse_proxy localhost:5678 +} + +ldap.jimmygan.com { + tls { + dns cloudflare {env.CLOUDFLARE_API_TOKEN} + } + forward_auth localhost:9092 { + uri /api/verify?rd=https://auth.jimmygan.com:8443 + copy_headers Remote-User Remote-Groups Remote-Name Remote-Email + } + reverse_proxy localhost:17170 +} + +git.jimmygan.com { + tls { + dns cloudflare {env.CLOUDFLARE_API_TOKEN} + } + forward_auth localhost:9092 { + uri /api/verify?rd=https://auth.jimmygan.com:8443 + copy_headers Remote-User Remote-Groups Remote-Name Remote-Email + } + reverse_proxy localhost:3300 +} diff --git a/tmp_remote/Caddyfile_vps b/tmp_remote/Caddyfile_vps new file mode 100644 index 0000000..06c7a43 --- /dev/null +++ b/tmp_remote/Caddyfile_vps @@ -0,0 +1,54 @@ +(security_headers) { + header { + X-Content-Type-Options nosniff + X-Frame-Options SAMEORIGIN + Referrer-Policy strict-origin-when-cross-origin + X-XSS-Protection "1; mode=block" + -Server + } +} + +(authelia) { + forward_auth 100.78.131.124:9092 { + uri /api/verify?rd=https://auth.jimmygan.com + copy_headers Remote-User Remote-Groups Remote-Name Remote-Email + } +} + +# Authelia portal (no forward_auth on itself) +auth.jimmygan.com { + import security_headers + reverse_proxy 100.78.131.124:9092 +} + +# Protected services +nas.jimmygan.com { + import security_headers + import authelia + reverse_proxy 100.78.131.124:4000 +} + +music.jimmygan.com { + import security_headers + import authelia + reverse_proxy 100.78.131.124:4533 { + flush_interval -1 + } +} + +photos.jimmygan.com { + import security_headers + import authelia + reverse_proxy 100.78.131.124:2283 { + header_up X-Forwarded-Proto https + header_up X-Forwarded-Host {host} + } +} + +photos-app.jimmygan.com { + import security_headers + reverse_proxy 100.78.131.124:2283 { + header_up X-Forwarded-Proto https + header_up X-Forwarded-Host {host} + } +} diff --git a/tmp_remote/dnsmasq.conf b/tmp_remote/dnsmasq.conf new file mode 100755 index 0000000..c3772f6 --- /dev/null +++ b/tmp_remote/dnsmasq.conf @@ -0,0 +1,9 @@ +address=/nas.jimmygan.com/100.78.131.124 +address=/music.jimmygan.com/100.78.131.124 +address=/n8n.jimmygan.com/100.78.131.124 +address=/auth.jimmygan.com/100.78.131.124 +address=/ldap.jimmygan.com/100.78.131.124 +address=/git.jimmygan.com/100.78.131.124 +server=223.5.5.5 +server=119.29.29.29 +no-resolv