405 lines
9.3 KiB
Bash
Executable File
405 lines
9.3 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)
|
|
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
|