feat: security hardening and HTTPS/SSO infrastructure configs
Deploy Dashboard / deploy (push) Successful in 1m28s
Deploy Dashboard / deploy (push) Successful in 1m28s
- Narrow trusted proxies, remove overly broad 10.0.0.0/8 - Fail closed on missing SSH known_hosts in terminal proxy - Add Navidrome reverse proxy headers for Authelia SSO - Add Gitea docker-compose definition - Add Caddy configs for NAS and VPS edge proxies - Add dnsmasq split-horizon DNS config - Add security execution plan document
This commit is contained in:
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+77
@@ -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
|
||||
@@ -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
|
||||
|
||||
Executable
+73
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
Executable
+9
@@ -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
|
||||
Reference in New Issue
Block a user