diff --git a/scripts/generate-caddyfile.sh b/scripts/generate-caddyfile.sh index d97dea8..b062488 100755 --- a/scripts/generate-caddyfile.sh +++ b/scripts/generate-caddyfile.sh @@ -13,6 +13,9 @@ 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:-}" @@ -20,7 +23,7 @@ APPFACTORY_GITEA_DOMAIN="${APPFACTORY_GITEA_DOMAIN:-}" APPFACTORY_REGISTRY_DOMAIN="${APPFACTORY_REGISTRY_DOMAIN:-}" mkdir -p "$(dirname "$CADDY_FILE")" -mkdir -p "$APPFACTORY_DIR/gateway/static" +mkdir -p "$STATIC_DIR" write_site_header() { local site="$1" @@ -36,18 +39,22 @@ EOF_CADDY fi } -write_app_routes() { - local target_file="$1" - +write_apps_json() { if [ ! -f "$CATALOG_FILE" ]; then + cat > "$APPS_JSON_FILE" <<'EOF_JSON' +{ + "apps": [] +} +EOF_JSON return 0 fi - python3 - "$CATALOG_FILE" "$target_file" <<'PY' + python3 - "$CATALOG_FILE" "$APPS_JSON_FILE" <<'PY' +import json import sys catalog_file = sys.argv[1] -caddy_file = sys.argv[2] +apps_json_file = sys.argv[2] apps = [] current = None @@ -67,6 +74,147 @@ with open(catalog_file, "r", encoding="utf-8") as f: 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") @@ -75,6 +223,41 @@ with open(caddy_file, "a", encoding="utf-8") as f: 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 WRITE_METHODS: + matcher = f"blocked_{safe_name}_{method.lower()}" + allowed_ips = sorted(set(allowed_by_method.get(method, []))) + + 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") + if allowed_ips: + f.write(" not remote_ip " + " ".join(allowed_ips) + "\n") + f.write(" }\n") + f.write(f" respond @{matcher} \"Forbidden\" 403\n\n") + + get_allowed_ips = sorted(set(allowed_by_method.get("GET", []))) + if get_allowed_ips: + matcher = f"blocked_{safe_name}_get" + f.write(f" @{matcher} {{\n") + f.write(f" path /apps/{app_id}/*\n") + f.write(" method GET\n") + f.write(" not path " + " ".join(open_paths) + "\n") + f.write(" not remote_ip " + " ".join(get_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") @@ -98,7 +281,15 @@ write_portal_routes() { handle /apps { root * /srv/static - rewrite * /apps.html + 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 } @@ -199,12 +390,15 @@ EOF_CADDY 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 @@ -212,4 +406,4 @@ if docker inspect appfactory-caddy >/dev/null 2>&1; then docker exec appfactory-caddy caddy reload --config /etc/caddy/Caddyfile else echo "WARN: appfactory-caddy container not found, skipping validate/reload" -fi \ No newline at end of file +fi