@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
class ConfigurationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def normalize_public_url(value: str) -> tuple[str, str]:
|
||||
raw = value.strip().rstrip("/")
|
||||
parsed = urlparse(raw)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||
raise ConfigurationError("Gebruik een volledige http(s)-URL, bijvoorbeeld https://jobs.example.be")
|
||||
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise ConfigurationError("De publieke URL mag geen credentials, query of fragment bevatten.")
|
||||
if parsed.path not in {"", "/"}:
|
||||
raise ConfigurationError("De applicatie moet op de root van het domein gepubliceerd worden.")
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise ConfigurationError("De publieke URL bevat een ongeldige poort.") from exc
|
||||
host = parsed.hostname.casefold()
|
||||
display_host = f"[{host}]" if ":" in host else host
|
||||
default_port = 443 if parsed.scheme == "https" else 80
|
||||
port_suffix = f":{port}" if port and port != default_port else ""
|
||||
return f"{parsed.scheme}://{display_host}{port_suffix}", host
|
||||
|
||||
|
||||
def parse_env(text: str) -> tuple[list[str], dict[str, str]]:
|
||||
lines = text.splitlines()
|
||||
values: dict[str, str] = {}
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||
continue
|
||||
key, value = stripped.split("=", 1)
|
||||
values[key.strip()] = value.strip()
|
||||
return lines, values
|
||||
|
||||
|
||||
def merge_csv(existing: str, additions: list[str]) -> str:
|
||||
result: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in [*existing.split(","), *additions]:
|
||||
value = item.strip()
|
||||
if value and value.casefold() not in seen:
|
||||
seen.add(value.casefold())
|
||||
result.append(value)
|
||||
return ",".join(result)
|
||||
|
||||
|
||||
def render_env(lines: list[str], updates: dict[str, str]) -> str:
|
||||
rendered: list[str] = []
|
||||
written: set[str] = set()
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped and not stripped.startswith("#") and "=" in stripped:
|
||||
key = stripped.split("=", 1)[0].strip()
|
||||
if key in updates:
|
||||
rendered.append(f"{key}={updates[key]}")
|
||||
written.add(key)
|
||||
continue
|
||||
rendered.append(line)
|
||||
missing = [key for key in updates if key not in written]
|
||||
if missing:
|
||||
if rendered and rendered[-1].strip():
|
||||
rendered.append("")
|
||||
rendered.append("# Publieke URL en reverse-proxyhardening")
|
||||
rendered.extend(f"{key}={updates[key]}" for key in missing)
|
||||
return "\n".join(rendered).rstrip() + "\n"
|
||||
|
||||
|
||||
def build_updates(
|
||||
current: dict[str, str],
|
||||
public_url: str,
|
||||
*,
|
||||
hsts_seconds: int,
|
||||
cache_url: str | None,
|
||||
trusted_proxy_cidrs: list[str],
|
||||
) -> dict[str, str]:
|
||||
origin, host = normalize_public_url(public_url)
|
||||
if not origin.startswith("https://"):
|
||||
raise ConfigurationError("Een publieke productie-URL moet HTTPS gebruiken.")
|
||||
updates = {
|
||||
"PUBLIC_BASE_URL": origin,
|
||||
"DJANGO_DEBUG": "0",
|
||||
"DJANGO_ALLOWED_HOSTS": merge_csv(
|
||||
current.get("DJANGO_ALLOWED_HOSTS", ""), [host, "127.0.0.1", "localhost"]
|
||||
),
|
||||
"DJANGO_CSRF_TRUSTED_ORIGINS": merge_csv(
|
||||
current.get("DJANGO_CSRF_TRUSTED_ORIGINS", ""), [origin]
|
||||
),
|
||||
"SESSION_COOKIE_SECURE": "1",
|
||||
"CSRF_COOKIE_SECURE": "1",
|
||||
"SECURE_SSL_REDIRECT": "1",
|
||||
"SECURE_HSTS_SECONDS": str(hsts_seconds),
|
||||
"TRUST_PROXY_HEADERS": "1",
|
||||
"DEMO_READ_ONLY": "1",
|
||||
"SEARCH_ENGINE_INDEXING_ENABLED": "0",
|
||||
"HEALTHCHECK_REQUIRE_CACHE": "1",
|
||||
}
|
||||
if cache_url:
|
||||
updates["CACHE_URL"] = cache_url
|
||||
if trusted_proxy_cidrs:
|
||||
updates["TRUSTED_PROXY_CIDRS"] = merge_csv(
|
||||
current.get("TRUSTED_PROXY_CIDRS", ""), trusted_proxy_cidrs
|
||||
)
|
||||
return updates
|
||||
|
||||
|
||||
def configure_env(
|
||||
env_file: Path,
|
||||
public_url: str,
|
||||
*,
|
||||
hsts_seconds: int = 300,
|
||||
cache_url: str | None = None,
|
||||
trusted_proxy_cidrs: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
if hsts_seconds < 0:
|
||||
raise ConfigurationError("HSTS-seconden mogen niet negatief zijn.")
|
||||
original = env_file.read_text(encoding="utf-8") if env_file.exists() else ""
|
||||
lines, current = parse_env(original)
|
||||
updates = build_updates(
|
||||
current,
|
||||
public_url,
|
||||
hsts_seconds=hsts_seconds,
|
||||
cache_url=cache_url,
|
||||
trusted_proxy_cidrs=trusted_proxy_cidrs or [],
|
||||
)
|
||||
output = render_env(lines, updates)
|
||||
env_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temporary_name = tempfile.mkstemp(prefix=f".{env_file.name}.", dir=env_file.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write(output)
|
||||
os.chmod(temporary_name, 0o600)
|
||||
os.replace(temporary_name, env_file)
|
||||
finally:
|
||||
if os.path.exists(temporary_name):
|
||||
os.unlink(temporary_name)
|
||||
return list(updates)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Configureer VacatureRadar veilig voor een publieke reverse-proxy-URL."
|
||||
)
|
||||
parser.add_argument("public_url", help="Bijvoorbeeld https://vacatureradar.example.be")
|
||||
parser.add_argument("--env-file", type=Path, default=Path(".env"))
|
||||
parser.add_argument(
|
||||
"--hsts-seconds",
|
||||
type=int,
|
||||
default=300,
|
||||
help="Start conservatief; verhoog na succesvolle HTTPS-validatie.",
|
||||
)
|
||||
parser.add_argument("--cache-url", help="Bij Unraid AIO: redis://127.0.0.1:6379/1")
|
||||
parser.add_argument(
|
||||
"--trusted-proxy-cidr",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Optioneel vertrouwd proxy-IP/CIDR voor correcte client-IP-rate-limiting.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
changed = configure_env(
|
||||
args.env_file,
|
||||
args.public_url,
|
||||
hsts_seconds=args.hsts_seconds,
|
||||
cache_url=args.cache_url,
|
||||
trusted_proxy_cidrs=args.trusted_proxy_cidr,
|
||||
)
|
||||
except (OSError, ConfigurationError) as exc:
|
||||
parser.error(str(exc))
|
||||
print(f"Bijgewerkt: {args.env_file}")
|
||||
print("Veilig ingestelde sleutels: " + ", ".join(changed))
|
||||
print("Hermaak de container; een gewone restart laadt gewijzigde environment niet opnieuw.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+82
-46
@@ -36,9 +36,10 @@ generate_secret() {
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_env() {
|
||||
write_env() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
local force="${3:-0}"
|
||||
local existing_line
|
||||
local existing_value
|
||||
|
||||
@@ -49,7 +50,7 @@ ensure_env() {
|
||||
fi
|
||||
|
||||
existing_value="${existing_line#*=}"
|
||||
if [[ -n "$existing_value" && \
|
||||
if [[ "$force" != "1" && -n "$existing_value" && \
|
||||
"$existing_value" != CHANGE_ME* && \
|
||||
"$existing_value" != dev-only-change-me && \
|
||||
"$existing_value" != codex-local-only ]]; then
|
||||
@@ -66,12 +67,17 @@ ensure_env() {
|
||||
fi
|
||||
done < "$env_file"
|
||||
mv "$temp_env" "$env_file"
|
||||
chmod 600 "$env_file"
|
||||
}
|
||||
|
||||
app_host_was_explicit=0
|
||||
[[ -n "${APP_HOST:-}" ]] && app_host_was_explicit=1
|
||||
app_host="${APP_HOST:-${HOSTNAME:-localhost}}"
|
||||
app_scheme="${APP_SCHEME:-http}"
|
||||
app_public_port="${APP_PUBLIC_PORT:-}"
|
||||
app_hostnames="${APP_HOSTS:-$app_host}"
|
||||
app_origin="${APP_ORIGINS:-}"
|
||||
app_base_url=""
|
||||
|
||||
# De lokale healthcheck gebruikt 127.0.0.1. Houd die host en localhost altijd
|
||||
# toegestaan, ook wanneer APP_HOSTS expliciet door de runner is ingesteld.
|
||||
@@ -81,29 +87,43 @@ for required_host in 127.0.0.1 localhost; do
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "$app_origin" ]]; then
|
||||
if [[ -z "$app_public_port" ]]; then
|
||||
if [[ "$app_scheme" == "https" ]]; then
|
||||
if [[ "$app_port" == "443" ]]; then
|
||||
app_origin="https://$app_host"
|
||||
else
|
||||
app_origin="https://$app_host:$app_port"
|
||||
fi
|
||||
app_public_port="443"
|
||||
else
|
||||
app_origin="http://$app_host:$app_port"
|
||||
app_public_port="$app_port"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ( "$app_scheme" == "https" && "$app_public_port" == "443" ) || \
|
||||
( "$app_scheme" == "http" && "$app_public_port" == "80" ) ]]; then
|
||||
app_base_url="${app_scheme}://${app_host}"
|
||||
else
|
||||
app_base_url="${app_scheme}://${app_host}:${app_public_port}"
|
||||
fi
|
||||
|
||||
if [[ -z "$app_origin" ]]; then
|
||||
app_origin="$app_base_url"
|
||||
fi
|
||||
|
||||
secret_key="$(generate_secret)"
|
||||
postgres_password="${POSTGRES_PASSWORD:-$(generate_secret)}"
|
||||
admin_password="${VACATURERADAR_ADMIN_PASSWORD:-$(generate_secret | cut -c 1-24)}"
|
||||
db_url="postgresql://vacatureradar:${postgres_password}@postgres:5432/vacatureradar"
|
||||
|
||||
debug="${DJANGO_DEBUG:-1}"
|
||||
debug="${DJANGO_DEBUG:-}"
|
||||
if [[ -z "$debug" ]]; then
|
||||
if [[ "$app_host_was_explicit" == "1" && "$app_scheme" == "https" ]]; then
|
||||
debug="0"
|
||||
else
|
||||
debug="1"
|
||||
fi
|
||||
fi
|
||||
if [[ "$debug" == "0" ]]; then
|
||||
session_secure="${SESSION_COOKIE_SECURE:-1}"
|
||||
csrf_secure="${CSRF_COOKIE_SECURE:-1}"
|
||||
ssl_redirect="${SECURE_SSL_REDIRECT:-1}"
|
||||
hsts_seconds="${SECURE_HSTS_SECONDS:-31536000}"
|
||||
hsts_seconds="${SECURE_HSTS_SECONDS:-300}"
|
||||
else
|
||||
session_secure="${SESSION_COOKIE_SECURE:-0}"
|
||||
csrf_secure="${CSRF_COOKIE_SECURE:-0}"
|
||||
@@ -111,37 +131,51 @@ else
|
||||
hsts_seconds="${SECURE_HSTS_SECONDS:-0}"
|
||||
fi
|
||||
|
||||
ensure_env "DJANGO_SECRET_KEY" "$secret_key"
|
||||
ensure_env "DJANGO_DEBUG" "$debug"
|
||||
ensure_env "DJANGO_ALLOWED_HOSTS" "$app_hostnames"
|
||||
ensure_env "DJANGO_CSRF_TRUSTED_ORIGINS" "$app_origin"
|
||||
ensure_env "DJANGO_TIME_ZONE" "Europe/Brussels"
|
||||
ensure_env "POSTGRES_DB" "vacatureradar"
|
||||
ensure_env "POSTGRES_USER" "vacatureradar"
|
||||
ensure_env "POSTGRES_PASSWORD" "$postgres_password"
|
||||
ensure_env "POSTGRES_HOST" "postgres"
|
||||
ensure_env "POSTGRES_PORT" "5432"
|
||||
ensure_env "DATABASE_URL" "$db_url"
|
||||
ensure_env "REDIS_URL" "redis://redis:6379/0"
|
||||
ensure_env "CELERY_TASK_ALWAYS_EAGER" "0"
|
||||
ensure_env "VACATURERADAR_ADMIN_USERNAME" "admin"
|
||||
ensure_env "VACATURERADAR_ADMIN_PASSWORD" "$admin_password"
|
||||
ensure_env "VACATURERADAR_AUTO_BOOTSTRAP" "1"
|
||||
ensure_env "SESSION_COOKIE_SECURE" "$session_secure"
|
||||
ensure_env "CSRF_COOKIE_SECURE" "$csrf_secure"
|
||||
ensure_env "SECURE_SSL_REDIRECT" "$ssl_redirect"
|
||||
ensure_env "SECURE_HSTS_SECONDS" "$hsts_seconds"
|
||||
ensure_env "EMAIL_BACKEND" "django.core.mail.backends.console.EmailBackend"
|
||||
ensure_env "DEFAULT_FROM_EMAIL" "VacatureRadar <vacatureradar@localhost>"
|
||||
ensure_env "SOURCE_POLICY_MODE" "strict"
|
||||
ensure_env "AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS" "8"
|
||||
ensure_env "AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS" "300"
|
||||
ensure_env "AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS" "300"
|
||||
ensure_env "MANUAL_IMPORT_RATE_LIMIT_MAX_ATTEMPTS" "12"
|
||||
ensure_env "MANUAL_IMPORT_RATE_LIMIT_WINDOW_SECONDS" "120"
|
||||
ensure_env "MANUAL_IMPORT_RATE_LIMIT_BLOCK_SECONDS" "300"
|
||||
write_env "DJANGO_SECRET_KEY" "$secret_key"
|
||||
write_env "DJANGO_DEBUG" "$debug" "$app_host_was_explicit"
|
||||
write_env "DJANGO_ALLOWED_HOSTS" "$app_hostnames" "$app_host_was_explicit"
|
||||
write_env "DJANGO_CSRF_TRUSTED_ORIGINS" "$app_origin" "$app_host_was_explicit"
|
||||
if [[ "$app_host_was_explicit" == "1" ]]; then
|
||||
write_env "PUBLIC_BASE_URL" "$app_base_url" 1
|
||||
if [[ "$app_scheme" == "https" ]]; then
|
||||
write_env "TRUST_PROXY_HEADERS" "1" 1
|
||||
else
|
||||
write_env "TRUST_PROXY_HEADERS" "0" 1
|
||||
fi
|
||||
write_env "USE_X_FORWARDED_HOST" "0" 1
|
||||
fi
|
||||
write_env "DJANGO_TIME_ZONE" "Europe/Brussels"
|
||||
write_env "POSTGRES_DB" "vacatureradar"
|
||||
write_env "POSTGRES_USER" "vacatureradar"
|
||||
write_env "POSTGRES_PASSWORD" "$postgres_password"
|
||||
write_env "POSTGRES_HOST" "postgres"
|
||||
write_env "POSTGRES_PORT" "5432"
|
||||
write_env "DATABASE_URL" "$db_url"
|
||||
write_env "REDIS_URL" "redis://redis:6379/0"
|
||||
write_env "CACHE_URL" "redis://redis:6379/1"
|
||||
write_env "HEALTHCHECK_REQUIRE_CACHE" "1"
|
||||
write_env "CELERY_TASK_ALWAYS_EAGER" "0"
|
||||
write_env "VACATURERADAR_ADMIN_USERNAME" "admin"
|
||||
write_env "VACATURERADAR_ADMIN_PASSWORD" "$admin_password"
|
||||
write_env "VACATURERADAR_AUTO_BOOTSTRAP" "1"
|
||||
write_env "SESSION_COOKIE_SECURE" "$session_secure"
|
||||
write_env "CSRF_COOKIE_SECURE" "$csrf_secure"
|
||||
write_env "SECURE_SSL_REDIRECT" "$ssl_redirect"
|
||||
write_env "SECURE_HSTS_SECONDS" "$hsts_seconds"
|
||||
write_env "DEMO_READ_ONLY" "1"
|
||||
write_env "SEARCH_ENGINE_INDEXING_ENABLED" "0"
|
||||
write_env "EMAIL_BACKEND" "django.core.mail.backends.console.EmailBackend"
|
||||
write_env "DEFAULT_FROM_EMAIL" "VacatureRadar <vacatureradar@localhost>"
|
||||
write_env "SOURCE_POLICY_MODE" "strict"
|
||||
write_env "AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS" "8"
|
||||
write_env "AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS" "300"
|
||||
write_env "AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS" "300"
|
||||
write_env "MANUAL_IMPORT_RATE_LIMIT_MAX_ATTEMPTS" "12"
|
||||
write_env "MANUAL_IMPORT_RATE_LIMIT_WINDOW_SECONDS" "120"
|
||||
write_env "MANUAL_IMPORT_RATE_LIMIT_BLOCK_SECONDS" "300"
|
||||
|
||||
docker compose -f "$compose_file" up -d --build
|
||||
export VACATURERADAR_ENV_FILE="$env_file"
|
||||
docker compose --env-file "$env_file" -f "$compose_file" up -d --build
|
||||
|
||||
if [[ "${SKIP_HEALTHCHECK:-0}" != "1" ]]; then
|
||||
for i in {1..30}; do
|
||||
@@ -157,14 +191,16 @@ if [[ "${SKIP_HEALTHCHECK:-0}" != "1" ]]; then
|
||||
done
|
||||
fi
|
||||
|
||||
cat <<"EOF"
|
||||
cat <<EOF
|
||||
Deploy klaargezet.
|
||||
|
||||
Applicatie:
|
||||
- open http://127.0.0.1:1226/
|
||||
- admin: admin / waarde uit $env_file
|
||||
- lokaal: http://127.0.0.1:${app_port}/
|
||||
- publiek: ${app_base_url}
|
||||
- admin: admin / waarde uit ${env_file}
|
||||
|
||||
Volgende stappen:
|
||||
- Zet APP_HOST en APP_SCHEME=HTTPS voor publieke domeinstandaard.
|
||||
- Zet APP_ORIGINS en DJANGO_DEBUG=0 voor echte productie.
|
||||
- Gebruik APP_HOST=<domein> APP_SCHEME=https DJANGO_DEBUG=0 voor publieke productie.
|
||||
- APP_PORT is de interne hostpoort; APP_PUBLIC_PORT is optioneel en standaard 443 bij HTTPS.
|
||||
- Hermaak de container na iedere environmentwijziging.
|
||||
EOF
|
||||
|
||||
@@ -4,6 +4,8 @@ set -eu
|
||||
mkdir -p /app/media /app/logs /app/local
|
||||
mkdir -p /tmp/celery
|
||||
|
||||
python manage.py check --deploy
|
||||
|
||||
if [ "${VACATURERADAR_RUN_MIGRATIONS:-1}" = "1" ]; then
|
||||
python manage.py migrate --noinput
|
||||
|
||||
|
||||
@@ -53,4 +53,8 @@ if [[ -n "${APP_PORT:-}" ]]; then
|
||||
export APP_PORT
|
||||
fi
|
||||
|
||||
if [[ -n "${APP_PUBLIC_PORT:-}" ]]; then
|
||||
export APP_PUBLIC_PORT
|
||||
fi
|
||||
|
||||
bash scripts/deploy_docker.sh "$COMPOSE_FILE" "$ENV_FILE"
|
||||
|
||||
@@ -37,6 +37,7 @@ fi
|
||||
runuser -u postgres -- "$pg_bin/pg_ctl" -D "$pg_data" -m fast -w stop
|
||||
|
||||
runuser -u postgres -- "$pg_bin/pg_ctl" -D "$pg_data" -o "-c listen_addresses=127.0.0.1" -w start
|
||||
runuser -u app -- python manage.py check --deploy
|
||||
runuser -u app -- python manage.py migrate --noinput
|
||||
if [[ "${VACATURERADAR_AUTO_BOOTSTRAP:-0}" == "1" ]]; then
|
||||
runuser -u app -- python manage.py bootstrap_instance || true
|
||||
|
||||
@@ -13,16 +13,21 @@ MARKDOWN_LINK = re.compile(r"(?<!!)\[[^\]]+\]\(([^)]+)\)")
|
||||
REQUIRED_ENV_KEYS = {
|
||||
"DJANGO_SECRET_KEY",
|
||||
"DJANGO_DEBUG",
|
||||
"PUBLIC_BASE_URL",
|
||||
"DJANGO_ALLOWED_HOSTS",
|
||||
"DATABASE_URL",
|
||||
"POSTGRES_PASSWORD",
|
||||
"REDIS_URL",
|
||||
"CACHE_URL",
|
||||
"HEALTHCHECK_REQUIRE_CACHE",
|
||||
"SOURCE_POLICY_MODE",
|
||||
"MAILBOX_CREDENTIAL_KEYS",
|
||||
"IMAP_CONNECT_TIMEOUT_SECONDS",
|
||||
"IMAP_MAX_MESSAGES_PER_POLL",
|
||||
"IMAP_MAX_MESSAGE_BYTES",
|
||||
"OLLAMA_ENABLED",
|
||||
"DEMO_READ_ONLY",
|
||||
"TRUST_PROXY_HEADERS",
|
||||
}
|
||||
REQUIRED_DOCS = {
|
||||
"README.md",
|
||||
@@ -34,6 +39,8 @@ REQUIRED_DOCS = {
|
||||
"docs/quality/THREAT_MODEL.md",
|
||||
"docs/quality/TRACEABILITY_MATRIX.md",
|
||||
"docs/operations/UNRAID_DEPLOYMENT.md",
|
||||
"docs/operations/NGINX_PROXY_MANAGER.md",
|
||||
"deployment/production.env.example",
|
||||
"docs/api/openapi.yaml",
|
||||
".agents/skills/vacatureradar-maintainer/SKILL.md",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user