#!/usr/bin/env bash set -euo pipefail CONFIG_FILE="/opt/appfactory/config/appfactory.env" if [ -f "$CONFIG_FILE" ]; then set -a # shellcheck disable=SC1090 source "$CONFIG_FILE" set +a fi APPFACTORY_DIR="${APPFACTORY_DIR:-/opt/appfactory}" CATALOG_FILE="$APPFACTORY_DIR/apps/catalog.yml" CADDY_FILE="$APPFACTORY_DIR/gateway/Caddyfile" DB_FILE="$APPFACTORY_DIR/data/appfactory/appfactory.db" STATIC_DIR="$APPFACTORY_DIR/gateway/static" APPS_JSON_FILE="$STATIC_DIR/apps.json" APPFACTORY_ENABLE_HTTPS="${APPFACTORY_ENABLE_HTTPS:-false}" APPFACTORY_PORTAL_DOMAIN="${APPFACTORY_PORTAL_DOMAIN:-}" APPFACTORY_GITEA_DOMAIN="${APPFACTORY_GITEA_DOMAIN:-}" APPFACTORY_REGISTRY_DOMAIN="${APPFACTORY_REGISTRY_DOMAIN:-}" mkdir -p "$(dirname "$CADDY_FILE")" mkdir -p "$STATIC_DIR" write_site_header() { local site="$1" if [ "$APPFACTORY_ENABLE_HTTPS" = "true" ]; then cat >> "$CADDY_FILE" <> "$CADDY_FILE" < "$APPS_JSON_FILE" <<'EOF_JSON' { "apps": [] } EOF_JSON return 0 fi python3 - "$CATALOG_FILE" "$APPS_JSON_FILE" <<'PY' import json import sys catalog_file = sys.argv[1] apps_json_file = sys.argv[2] apps = [] current = None with open(catalog_file, "r", encoding="utf-8") as f: for raw in f: stripped = raw.strip() if stripped.startswith("- id:"): if current: apps.append(current) current = {"id": stripped.split(":", 1)[1].strip().strip('"')} elif current and ":" in stripped: key, value = stripped.split(":", 1) current[key.strip()] = value.strip().strip('"') if current: apps.append(current) result = {"apps": []} for app in apps: app_id = app.get("id") if not app_id: continue name = app.get("name") or app_id base_path = app.get("base_path") or f"/apps/{app_id}" health = app.get("health") or f"{base_path}/health" docs = app.get("docs") or f"{base_path}/docs" result["apps"].append({ "id": app_id, "name": name, "base_url": base_path, "health_url": health, "docs_url": docs, }) with open(apps_json_file, "w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False, indent=2) f.write("\n") PY } write_app_routes() { local target_file="$1" if [ ! -f "$CATALOG_FILE" ]; then return 0 fi python3 - "$CATALOG_FILE" "$target_file" "$DB_FILE" <<'PY' import re import sqlite3 import sys from collections import defaultdict catalog_file = sys.argv[1] caddy_file = sys.argv[2] db_file = sys.argv[3] READ_METHODS = ["GET"] WRITE_METHODS = ["POST", "PUT", "PATCH", "DELETE"] ALL_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] OPEN_PATH_SUFFIXES = [ "/health", "/docs", "/docs/*", "/openapi.json", "/swagger.json", "/swagger/*", ] apps = [] current = None with open(catalog_file, "r", encoding="utf-8") as f: for raw in f: stripped = raw.strip() if stripped.startswith("- id:"): if current: apps.append(current) current = {"id": stripped.split(":", 1)[1].strip().strip('"')} elif current and ":" in stripped: key, value = stripped.split(":", 1) current[key.strip()] = value.strip().strip('"') if current: apps.append(current) def safe_matcher_name(value: str) -> str: value = re.sub(r"[^a-zA-Z0-9_]+", "_", value) value = value.strip("_") if not value: value = "app" if value[0].isdigit(): value = "app_" + value return value def parse_methods(value: str) -> set[str]: value = (value or "").strip().upper() result: set[str] = set() if not value: return result for part in value.split(","): part = part.strip().upper() if not part: continue if part == "READ": result.update(READ_METHODS) elif part == "WRITE": result.update(WRITE_METHODS) elif part == "ALL": result.update(ALL_METHODS) else: result.add(part) return {m for m in result if m in ALL_METHODS} rules_by_app: dict[str, list[dict]] = defaultdict(list) try: con = sqlite3.connect(db_file) con.row_factory = sqlite3.Row rows = con.execute( """ SELECT app_id, ip_cidr, methods FROM app_ip_access_rules WHERE is_enabled = 1 ORDER BY app_id, id """ ).fetchall() for row in rows: app_id = (row["app_id"] or "").strip() ip_cidr = (row["ip_cidr"] or "").strip() methods = parse_methods(row["methods"] or "") if not app_id or not ip_cidr or not methods: continue rules_by_app[app_id].append({ "ip_cidr": ip_cidr, "methods": methods, }) con.close() except Exception as exc: print(f"WARN: Unable to read app_ip_access_rules from {db_file}: {exc}", file=sys.stderr) with open(caddy_file, "a", encoding="utf-8") as f: for app in apps: app_id = app.get("id") port = app.get("container_port") or app.get("port") or "8000" if not app_id: continue rules = rules_by_app.get(app_id, []) safe_name = safe_matcher_name(app_id) open_paths = [f"/apps/{app_id}{suffix}" for suffix in OPEN_PATH_SUFFIXES] if rules: allowed_by_method = {method: [] for method in ALL_METHODS} for rule in rules: for method in rule["methods"]: allowed_by_method.setdefault(method, []).append(rule["ip_cidr"]) for method in ALL_METHODS: allowed_ips = sorted(set(allowed_by_method.get(method, []))) # Core rule: # If a method has no rules, it stays open. # If a method has at least one rule, it becomes IP-whitelisted. if not allowed_ips: continue matcher = f"blocked_{safe_name}_{method.lower()}" f.write(f" @{matcher} {{\n") f.write(f" path /apps/{app_id}/*\n") f.write(f" method {method}\n") f.write(" not path " + " ".join(open_paths) + "\n") f.write(" not remote_ip " + " ".join(allowed_ips) + "\n") f.write(" }\n") f.write(f" respond @{matcher} \"Forbidden\" 403\n\n") f.write(f" handle_path /apps/{app_id}/* {{\n") f.write(f" reverse_proxy {app_id}:{port}\n") f.write(" }\n\n") PY } write_portal_routes() { local target_file="$1" cat >> "$target_file" <<'EOF_ROUTES' handle /health { respond "AppFactory OK" 200 } redir / /portal/ 302 redir /portal /portal/ 302 handle_path /portal/* { reverse_proxy appfactory-portal:9100 } handle /apps { root * /srv/static rewrite * /apps.json header Content-Type application/json file_server } handle /apps/ { root * /srv/static rewrite * /apps.json header Content-Type application/json file_server } EOF_ROUTES write_app_routes "$target_file" cat >> "$target_file" <<'EOF_ROUTES' handle_path /webhook/* { reverse_proxy appfactory-webhook:9000 } handle_path /registry/* { reverse_proxy appfactory-registry:5000 } handle { redir /portal/ 302 } EOF_ROUTES } write_http_only_caddyfile() { cat > "$CADDY_FILE" <<'EOF_CADDY' :80 { EOF_CADDY write_portal_routes "$CADDY_FILE" cat >> "$CADDY_FILE" <<'EOF_CADDY' } EOF_CADDY } write_internal_caddy_block() { cat >> "$CADDY_FILE" <<'EOF_CADDY' http://appfactory-caddy { EOF_CADDY write_portal_routes "$CADDY_FILE" cat >> "$CADDY_FILE" <<'EOF_CADDY' } EOF_CADDY } write_domain_caddyfile() { : > "$CADDY_FILE" if [ -n "$APPFACTORY_PORTAL_DOMAIN" ]; then write_site_header "$APPFACTORY_PORTAL_DOMAIN" write_portal_routes "$CADDY_FILE" cat >> "$CADDY_FILE" <<'EOF_CADDY' } EOF_CADDY fi write_internal_caddy_block if [ -n "$APPFACTORY_GITEA_DOMAIN" ]; then write_site_header "$APPFACTORY_GITEA_DOMAIN" cat >> "$CADDY_FILE" <<'EOF_CADDY' handle /health { respond "Gitea gateway OK" 200 } handle { reverse_proxy appfactory-gitea:3000 } } EOF_CADDY fi if [ -n "$APPFACTORY_REGISTRY_DOMAIN" ]; then write_site_header "$APPFACTORY_REGISTRY_DOMAIN" cat >> "$CADDY_FILE" <<'EOF_CADDY' handle /health { respond "Registry gateway OK" 200 } handle /v2/* { reverse_proxy appfactory-registry:5000 } handle { reverse_proxy appfactory-registry:5000 } } EOF_CADDY fi if [ ! -s "$CADDY_FILE" ]; then write_http_only_caddyfile fi } write_apps_json if [ -n "$APPFACTORY_PORTAL_DOMAIN" ] || [ -n "$APPFACTORY_GITEA_DOMAIN" ] || [ -n "$APPFACTORY_REGISTRY_DOMAIN" ]; then write_domain_caddyfile else write_http_only_caddyfile fi echo "Generated: $APPS_JSON_FILE" echo "Generated: $CADDY_FILE" if docker inspect appfactory-caddy >/dev/null 2>&1; then docker exec appfactory-caddy caddy validate --config /etc/caddy/Caddyfile docker exec appfactory-caddy caddy reload --config /etc/caddy/Caddyfile else echo "WARN: appfactory-caddy container not found, skipping validate/reload" fi