Files

414 lines
9.7 KiB
Bash
Executable File

#!/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" <<EOF_CADDY
$site {
EOF_CADDY
else
cat >> "$CADDY_FILE" <<EOF_CADDY
http://$site {
EOF_CADDY
fi
}
write_apps_json() {
if [ ! -f "$CATALOG_FILE" ]; then
cat > "$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)
f.write(f" route /apps/{app_id}/* {{\n")
# Public endpoints are always open and are handled before IP rules.
open_paths = [f"/apps/{app_id}{suffix}" for suffix in OPEN_PATH_SUFFIXES]
f.write(f" @open_{safe_name} {{\n")
f.write(" path " + " ".join(open_paths) + "\n")
f.write(" }\n")
f.write(f" handle @open_{safe_name} {{\n")
f.write(f" uri strip_prefix /apps/{app_id}\n")
f.write(f" reverse_proxy {app_id}:{port}\n")
f.write(" }\n\n")
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" method {method}\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" uri strip_prefix /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