This commit is contained in:
Mateusz Gruszczyński committed 2026-10-07 22:22:54 +02:00
1 parent e84df445c6
commit e87a382a7e
109 files changed
+1092 -2925

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Migrate Nginx Proxy Manager proxy hosts from SQLite into RustProxy."""
from __future__ import annotations
from migrate_npm_core import * # stable migration/model API
from migrate_npm_api import * # stable administration API helpers
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--database", type=Path, default=Path("/data/database.sqlite"), help="NPM SQLite database (default: /data/database.sqlite)")
parser.add_argument("--rustproxy", required=True, help="RustProxy admin origin, e.g. https://rustproxy.example:9090")
parser.add_argument("--username", default=os.environ.get("RUSTPROXY_USERNAME"), help="RustProxy administrator username (or RUSTPROXY_USERNAME)")
parser.add_argument("--password-env", default="RUSTPROXY_PASSWORD", help="environment variable containing the RustProxy password")
parser.add_argument("--ca-file", type=Path, help="CA file for RustProxy administration HTTPS")
parser.add_argument("--insecure-admin-tls", action="store_true", help="disable certificate verification for the RustProxy admin HTTPS connection")
parser.add_argument("--http-listener", action="append", help="RustProxy HTTP listener ID; repeat or comma-separate. Default: first enabled HTTP listener")
parser.add_argument("--https-listener", action="append", help="RustProxy TLS listener ID; repeat or comma-separate. Default: first enabled TLS listener")
parser.add_argument("--certificate-id", help="named RustProxy certificate for imported TLS hosts; omit to use the application default certificate")
parser.add_argument("--force-https", action="store_true", help="enable RustProxy Force SSL for every imported host and attach it to HTTP+TLS listeners")
parser.add_argument("--npm-compatible-backend-tls", action="store_true", help="legacy no-op: backend tls_verify already defaults to false in RustProxy 0.22.1")
parser.add_argument("--plan-out", type=Path, default=Path("npm-migration-plan.json"), help="write the generated plan/config JSON here")
parser.add_argument("--apply", action="store_true", help="perform the revisioned PUT /api/config; without this flag the run is read-only")
args = parser.parse_args()
if args.ca_file and args.insecure_admin_tls:
parser.error("choose either --ca-file or --insecure-admin-tls")
username = args.username or input("RustProxy username: ").strip()
if not username:
parser.error("RustProxy username is required")
password = os.environ.get(args.password_env)
if password is None:
password = getpass.getpass("RustProxy password: ")
try:
npm_hosts = load_npm_hosts(args.database)
client = RustProxyClient(args.rustproxy, ca_file=args.ca_file, insecure_tls=args.insecure_admin_tls)
client.login(username, password)
envelope = client.config()
merged, decisions = build_migration(
npm_hosts,
envelope["config"],
http_listener_ids=split_ids(args.http_listener),
https_listener_ids=split_ids(args.https_listener),
certificate_id=args.certificate_id,
force_https=args.force_https,
npm_compatible_backend_tls=args.npm_compatible_backend_tls,
)
config_bytes = len(json.dumps(merged, separators=(",", ":"), ensure_ascii=False).encode("utf-8"))
if config_bytes > MAX_CONFIG_BYTES:
raise MigrationError(f"merged RustProxy config is {config_bytes} bytes; API limit is {MAX_CONFIG_BYTES} bytes")
plan = {
"source": {"type": "nginx-proxy-manager-sqlite", "database": str(args.database)},
"rustproxy": {"origin": args.rustproxy, "expected_revision": envelope["revision"], "config_bytes": config_bytes},
"apply_requested": bool(args.apply),
"decisions": [asdict(item) for item in decisions],
"config": merged,
}
write_private_json(args.plan_out, plan)
print_report(decisions)
print(f"\nPlan written: {args.plan_out}")
if args.apply:
imported = sum(1 for item in decisions if item.action == "import")
if imported == 0:
raise MigrationError("nothing to import; configuration was not written")
applied = client.apply(envelope["revision"], merged)
print(f"Applied RustProxy configuration revision {applied.get('revision', '?')} ({imported} NPM proxy host records imported).")
else:
print("Dry run only. Review the plan and rerun with --apply to write RustProxy configuration.")
except (MigrationError, OSError, sqlite3.Error) as error:
parser.exit(1, f"Migration failed: {error}\n")
if __name__ == "__main__":
main()
+124
View File
@@ -0,0 +1,124 @@
"""RustProxy administration API helpers for NPM migration."""
from __future__ import annotations
from migrate_npm_core import *
class RustProxyClient:
def __init__(self, base_url: str, *, ca_file: Path | None = None, insecure_tls: bool = False):
parsed = urllib.parse.urlsplit(base_url.rstrip("/"))
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.path not in {"", "/"}:
raise MigrationError("--rustproxy must be an http(s) origin, e.g. https://rustproxy.example:9090")
if parsed.query or parsed.fragment or parsed.username or parsed.password:
raise MigrationError("--rustproxy must not contain credentials, query or fragment")
self.base_url = urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
jar = http.cookiejar.CookieJar()
handlers: list[Any] = [urllib.request.HTTPCookieProcessor(jar)]
if parsed.scheme == "https":
if insecure_tls:
context = ssl._create_unverified_context() # Explicit operator opt-in for local/self-signed admin TLS.
else:
context = ssl.create_default_context(cafile=str(ca_file) if ca_file else None)
handlers.append(urllib.request.HTTPSHandler(context=context))
self.opener = urllib.request.build_opener(*handlers)
self.csrf = ""
def request(self, method: str, path: str, body: dict[str, Any] | None = None, *, authenticated_write: bool = False) -> Any:
data = None if body is None else json.dumps(body, separators=(",", ":")).encode()
headers = {"Accept": "application/json", "X-RustProxy-Request": "1"}
if data is not None:
headers["Content-Type"] = "application/json"
if method != "GET":
headers["Origin"] = self.base_url
if authenticated_write:
headers["X-CSRF-Token"] = self.csrf
request = urllib.request.Request(self.base_url + path, data=data, headers=headers, method=method)
try:
with self.opener.open(request, timeout=10) as response:
payload = response.read(1024 * 1024)
except urllib.error.HTTPError as error:
payload = error.read(65536)
try:
detail = json.loads(payload).get("error", payload.decode("utf-8", "replace"))
except Exception:
detail = payload.decode("utf-8", "replace")
raise MigrationError(f"RustProxy API {method} {path} failed with HTTP {error.code}: {detail}") from error
except (OSError, urllib.error.URLError) as error:
raise MigrationError(f"RustProxy API connection failed: {error}") from error
try:
return json.loads(payload)
except json.JSONDecodeError as error:
raise MigrationError(f"RustProxy API returned invalid JSON for {path}") from error
def login(self, username: str, password: str) -> None:
info = self.request("POST", "/api/auth/login", {"username": username, "password": password})
token = info.get("csrf_token") if isinstance(info, dict) else None
if not isinstance(token, str) or not token:
session = self.request("GET", "/api/auth/session")
token = session.get("csrf_token") if isinstance(session, dict) else None
if not isinstance(token, str) or not token:
raise MigrationError("RustProxy login succeeded but no CSRF token was returned")
self.csrf = token
def config(self) -> dict[str, Any]:
value = self.request("GET", "/api/config")
if not isinstance(value, dict) or not isinstance(value.get("revision"), int) or not isinstance(value.get("config"), dict):
raise MigrationError("unexpected RustProxy /api/config response")
return value
def apply(self, revision: int, config: dict[str, Any]) -> dict[str, Any]:
value = self.request("PUT", "/api/config", {"expected_revision": revision, "config": config}, authenticated_write=True)
if not isinstance(value, dict):
raise MigrationError("unexpected RustProxy PUT /api/config response")
return value
def write_private_json(path: Path, value: dict[str, Any]) -> None:
path = path.expanduser()
path.parent.mkdir(parents=True, exist_ok=True)
if path.is_symlink():
raise MigrationError(f"refusing to write migration plan through symlink: {path}")
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
fd = os.open(path, flags, 0o600)
try:
os.fchmod(fd, 0o600)
payload = (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
with os.fdopen(fd, "wb", closefd=False) as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
finally:
os.close(fd)
def split_ids(values: list[str] | None) -> list[str] | None:
if values is None:
return None
out: list[str] = []
for value in values:
for item in value.split(","):
item = item.strip()
if item and item not in out:
out.append(item)
return out
def print_report(decisions: list[Decision]) -> None:
imported = sum(1 for d in decisions if d.action == "import")
skipped = sum(1 for d in decisions if d.action == "skip")
print(f"NPM proxy hosts: {len(decisions)}; import: {imported}; skip: {skipped}")
for decision in decisions:
print(f"\n[{decision.action.upper()}] NPM #{decision.npm_id}")
if decision.hosts:
print(" hosts: " + ", ".join(decision.hosts))
if decision.backend:
print(" backend: " + decision.backend)
if decision.rustproxy_ids:
print(" ids: " + ", ".join(decision.rustproxy_ids))
if decision.reason:
print(" reason: " + decision.reason)
for warning in decision.warnings or []:
print(" warning: " + warning)
+403
View File
@@ -0,0 +1,403 @@
#!/usr/bin/env python3
"""Migrate Nginx Proxy Manager proxy hosts from SQLite into RustProxy.
The migrator intentionally focuses on proxy-host identity and upstream routing. It
never imports certificate/key material from NPM. NPM hosts that used TLS are attached
to RustProxy's TLS listener and use the RustProxy default certificate unless a named
certificate is supplied explicitly.
Run without --apply first. The script logs in to the RustProxy administration API,
reads the current revision, builds a merged configuration and prints a review report.
Only --apply performs the revisioned PUT /api/config.
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass, asdict
import getpass
import http.cookiejar
import ipaddress
import json
import os
from pathlib import Path
import re
import sqlite3
import ssl
import sys
from typing import Any
import urllib.error
import urllib.parse
import urllib.request
MAX_CONFIG_BYTES = 262_144
class MigrationError(RuntimeError):
pass
@dataclass(frozen=True)
class NpmProxyHost:
id: int
domain_names: list[str]
forward_scheme: str
forward_host: str
forward_port: int
allow_websocket_upgrade: bool
http2_support: bool
enabled: bool
certificate_id: int
ssl_forced: bool
access_list_id: int
advanced_config: str
locations: list[Any]
caching_enabled: bool
block_exploits: bool
meta: dict[str, Any]
@dataclass
class Decision:
npm_id: int
action: str
hosts: list[str]
backend: str | None = None
rustproxy_ids: list[str] | None = None
warnings: list[str] | None = None
reason: str | None = None
def json_value(raw: Any, fallback: Any) -> Any:
if raw is None or raw == "":
return fallback
if isinstance(raw, (list, dict)):
return raw
try:
return json.loads(raw)
except (TypeError, json.JSONDecodeError):
return fallback
def bool_value(raw: Any) -> bool:
if isinstance(raw, str):
return raw.strip().lower() in {"1", "true", "yes", "on"}
return bool(raw)
def normalize_host_pattern(value: str) -> str:
value = value.strip().lower().rstrip(".")
wildcard = value.startswith("*.")
base = value[2:] if wildcard else value
if not base or len(base) > 253 or "*" in base or ":" in base or "/" in base:
raise MigrationError(f"invalid hostname {value!r}")
labels = base.split(".")
if wildcard and len(labels) < 2:
raise MigrationError(f"invalid wildcard hostname {value!r}")
for label in labels:
if not label or len(label) > 63 or label.startswith("-") or label.endswith("-"):
raise MigrationError(f"invalid hostname {value!r}")
if not re.fullmatch(r"[a-z0-9-]+", label):
raise MigrationError(f"hostname must be ASCII/punycode: {value!r}")
return ("*." if wildcard else "") + base
def sql_columns(connection: sqlite3.Connection, table: str) -> set[str]:
return {str(row[1]) for row in connection.execute(f"PRAGMA table_info({table})")}
def load_npm_hosts(database: Path) -> list[NpmProxyHost]:
if database.is_symlink() or not database.is_file():
raise MigrationError(f"NPM SQLite database not found or unsafe: {database}")
uri = f"file:{urllib.parse.quote(str(database.resolve()))}?mode=ro"
connection = sqlite3.connect(uri, uri=True)
connection.row_factory = sqlite3.Row
try:
tables = {row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")}
if "proxy_host" not in tables:
raise MigrationError("NPM database does not contain the proxy_host table")
columns = sql_columns(connection, "proxy_host")
required = {"id", "domain_names", "forward_scheme", "forward_host", "forward_port"}
missing = sorted(required - columns)
if missing:
raise MigrationError("NPM proxy_host table is missing required columns: " + ", ".join(missing))
wanted = [
"id", "domain_names", "forward_scheme", "forward_host", "forward_port",
"allow_websocket_upgrade", "http2_support", "enabled", "certificate_id",
"ssl_forced", "access_list_id", "advanced_config", "locations",
"caching_enabled", "block_exploits", "meta", "is_deleted",
]
select = ", ".join(name for name in wanted if name in columns)
where = " WHERE is_deleted = 0" if "is_deleted" in columns else ""
rows = connection.execute(f"SELECT {select} FROM proxy_host{where} ORDER BY id").fetchall()
finally:
connection.close()
result: list[NpmProxyHost] = []
for row in rows:
data = dict(row)
domains_raw = json_value(data.get("domain_names"), [])
if not isinstance(domains_raw, list):
domains_raw = []
domains: list[str] = []
for item in domains_raw:
if isinstance(item, str) and item.strip():
domains.append(item.strip())
locations = json_value(data.get("locations"), [])
if not isinstance(locations, list):
locations = []
meta = json_value(data.get("meta"), {})
if not isinstance(meta, dict):
meta = {}
try:
port = int(data.get("forward_port"))
except (TypeError, ValueError):
port = 0
result.append(NpmProxyHost(
id=int(data["id"]),
domain_names=domains,
forward_scheme=str(data.get("forward_scheme") or "").strip().lower(),
forward_host=str(data.get("forward_host") or "").strip(),
forward_port=port,
allow_websocket_upgrade=bool_value(data.get("allow_websocket_upgrade", False)),
http2_support=bool_value(data.get("http2_support", True)),
enabled=bool_value(data.get("enabled", True)),
certificate_id=int(data.get("certificate_id") or 0),
ssl_forced=bool_value(data.get("ssl_forced", False)),
access_list_id=int(data.get("access_list_id") or 0),
advanced_config=str(data.get("advanced_config") or ""),
locations=locations,
caching_enabled=bool_value(data.get("caching_enabled", False)),
block_exploits=bool_value(data.get("block_exploits", False)),
meta=meta,
))
return result
def backend_url(item: NpmProxyHost) -> str:
if item.forward_scheme not in {"http", "https"}:
raise MigrationError(f"unsupported upstream scheme {item.forward_scheme!r}")
if not 1 <= item.forward_port <= 65535:
raise MigrationError(f"invalid upstream port {item.forward_port}")
host = item.forward_host.strip()
if not host or any(ch.isspace() for ch in host) or "/" in host or "@" in host:
raise MigrationError(f"invalid upstream host {host!r}")
if host.startswith("[") and host.endswith("]"):
rendered = host
else:
try:
ip = ipaddress.ip_address(host)
rendered = f"[{ip}]" if ip.version == 6 else str(ip)
except ValueError:
if ":" in host:
raise MigrationError(f"invalid upstream host {host!r}")
rendered = host
return f"{item.forward_scheme}://{rendered}:{item.forward_port}"
def slug(value: str) -> str:
value = value.lower().replace("*.", "wildcard-")
value = re.sub(r"[^a-z0-9_-]+", "-", value).strip("-_")
return value[:40] or "host"
def unique_id(base: str, used: set[str]) -> str:
base = re.sub(r"[^A-Za-z0-9_-]+", "-", base)[:64].strip("-_") or "npm"
candidate = base
index = 2
while candidate in used:
suffix = f"-{index}"
candidate = base[:64 - len(suffix)] + suffix
index += 1
used.add(candidate)
return candidate
def listener_inventory(config: dict[str, Any]) -> tuple[list[str], list[str]]:
http_ids: list[str] = []
https_ids: list[str] = []
for entry in ((config.get("listeners") or {}).get("endpoints") or []):
if not isinstance(entry, dict) or not entry.get("enabled", True):
continue
listener_id = str(entry.get("id") or "").strip()
binding = entry.get("binding") or {}
if not listener_id or not isinstance(binding, dict):
continue
(https_ids if binding.get("tls") else http_ids).append(listener_id)
return http_ids, https_ids
def selected_tls_ports(config: dict[str, Any], listener_ids: list[str]) -> set[int]:
selected = set(listener_ids)
ports: set[int] = set()
for entry in ((config.get("listeners") or {}).get("endpoints") or []):
if not isinstance(entry, dict) or str(entry.get("id") or "") not in selected:
continue
binding = entry.get("binding") or {}
if not isinstance(binding, dict) or not binding.get("tls"):
continue
for address in [binding.get("address"), *(entry.get("additional_addresses") or [])]:
if not isinstance(address, str) or ":" not in address:
raise MigrationError(f"invalid RustProxy listener address for Force SSL: {address!r}")
try:
port = int(address.rsplit(":", 1)[1])
except (TypeError, ValueError) as error:
raise MigrationError(f"invalid RustProxy listener address for Force SSL: {address!r}") from error
if not 1 <= port <= 65535:
raise MigrationError(f"invalid RustProxy listener port for Force SSL: {address!r}")
ports.add(port)
return ports
def existing_host_owners(config: dict[str, Any]) -> dict[str, str]:
result: dict[str, str] = {}
for vhost in config.get("vhosts") or []:
if not isinstance(vhost, dict):
continue
vid = str(vhost.get("id") or "?")
frontend = vhost.get("frontend") or {}
for raw in frontend.get("hosts") or []:
if isinstance(raw, str):
try:
result[normalize_host_pattern(raw)] = vid
except MigrationError:
pass
return result
def warnings_for(item: NpmProxyHost, tls_verify: bool) -> list[str]:
warnings: list[str] = []
if item.access_list_id:
warnings.append(f"NPM access_list_id={item.access_list_id} was not imported; attach an ACL Profile manually")
if item.advanced_config.strip():
warnings.append("NPM advanced_config was not imported")
if item.locations:
warnings.append(f"{len(item.locations)} NPM custom location(s) were not imported")
if item.caching_enabled:
warnings.append("NPM caching_enabled was not mapped; configure RustProxy cache policy separately")
if item.block_exploits:
warnings.append("NPM block_exploits was not mapped")
if item.ssl_forced:
warnings.append("NPM ssl_forced is mapped to RustProxy Force SSL (HTTP -> HTTPS 308 redirect)")
if item.forward_scheme == "https" and tls_verify:
warnings.append("HTTPS upstream verification was explicitly enabled; provide a matching DNS name/private CA when needed")
if item.meta.get("nginx_err"):
warnings.append("NPM meta.nginx_err is set; review the source host before cutover")
return warnings
def build_migration(
npm_hosts: list[NpmProxyHost],
config: dict[str, Any],
*,
http_listener_ids: list[str] | None = None,
https_listener_ids: list[str] | None = None,
certificate_id: str | None = None,
force_https: bool = False,
npm_compatible_backend_tls: bool = False,
) -> tuple[dict[str, Any], list[Decision]]:
new_config = json.loads(json.dumps(config))
new_config.setdefault("vhosts", [])
discovered_http, discovered_https = listener_inventory(new_config)
http_ids = http_listener_ids if http_listener_ids is not None else discovered_http[:1]
https_ids = https_listener_ids if https_listener_ids is not None else discovered_https[:1]
unknown_http = [value for value in http_ids if value not in discovered_http]
unknown_https = [value for value in https_ids if value not in discovered_https]
if unknown_http:
raise MigrationError("HTTP listener ID is missing, disabled or TLS-enabled: " + ", ".join(unknown_http))
if unknown_https:
raise MigrationError("HTTPS listener ID is missing, disabled or not TLS-enabled: " + ", ".join(unknown_https))
if not http_ids and not https_ids:
raise MigrationError("RustProxy config has no enabled HTTP/TLS listener to attach imported vhosts")
https_ports = selected_tls_ports(new_config, https_ids)
if certificate_id:
known_certificates = {str(item.get("id")) for item in ((new_config.get("tls") or {}).get("certificates") or []) if isinstance(item, dict)}
if certificate_id not in known_certificates:
raise MigrationError(f"RustProxy certificate ID not found: {certificate_id}")
owners = existing_host_owners(new_config)
used_ids = {str(v.get("id")) for v in new_config.get("vhosts") or [] if isinstance(v, dict)}
decisions: list[Decision] = []
for item in npm_hosts:
warnings = warnings_for(item, False)
if not item.domain_names:
decisions.append(Decision(item.id, "skip", [], warnings=warnings, reason="no domain_names"))
continue
try:
normalized = []
seen = set()
for raw in item.domain_names:
host = normalize_host_pattern(raw)
if host not in seen:
normalized.append(host)
seen.add(host)
origin = backend_url(item)
except MigrationError as error:
decisions.append(Decision(item.id, "skip", item.domain_names, warnings=warnings, reason=str(error)))
continue
conflicts = [(host, owners[host]) for host in normalized if host in owners]
if conflicts:
reason = "hostname already owned in RustProxy: " + ", ".join(f"{host} ({owner})" for host, owner in conflicts)
decisions.append(Decision(item.id, "skip", normalized, backend=origin, warnings=warnings, reason=reason))
continue
force_ssl = item.ssl_forced or force_https
source_tls = item.certificate_id > 0 or force_ssl
if force_ssl and not https_ids:
decisions.append(Decision(
item.id, "skip", normalized, backend=origin, warnings=warnings,
reason="NPM Force SSL requires an enabled RustProxy TLS listener",
))
continue
if force_ssl and len(https_ports) != 1:
decisions.append(Decision(
item.id, "skip", normalized, backend=origin, warnings=warnings,
reason="RustProxy Force SSL requires the selected TLS listeners to use one HTTPS port",
))
continue
if source_tls:
listeners = list(dict.fromkeys([*http_ids, *https_ids]))
else:
listeners = list(http_ids or https_ids)
if source_tls and not https_ids:
warnings.append("source host used TLS but RustProxy has no enabled TLS listener; imported on HTTP only")
listeners = list(http_ids)
if force_ssl and not http_ids:
warnings.append("Force SSL is preserved, but no HTTP listener is selected so there is currently no cleartext request to redirect")
if not listeners:
decisions.append(Decision(item.id, "skip", normalized, backend=origin, warnings=warnings, reason="no compatible RustProxy listener"))
continue
created_ids: list[str] = []
base = f"npm-{item.id}-{slug(normalized[0])}"
vid = unique_id(base, used_ids)
vhost: dict[str, Any] = {
"id": vid,
"enabled": item.enabled,
"force_ssl": force_ssl,
"forward_client_ip": True,
"websockets": item.allow_websocket_upgrade,
"http2_enabled": item.http2_support,
"http3_enabled": False,
"frontend": {"hosts": normalized, "listener_ids": listeners},
"backends": [{
"id": "origin",
"url": origin,
"allow_private": True,
"preserve_host": True,
"tls_verify": False,
}],
"load_balancing": {"health_check": {"mode": "off"}},
"routes": [{"id": "root", "path_prefix": "/"}],
}
if source_tls and certificate_id:
vhost["certificate_id"] = certificate_id
new_config["vhosts"].append(vhost)
created_ids.append(vid)
for host in normalized:
owners[host] = vid
decisions.append(Decision(item.id, "import", normalized, backend=origin, rustproxy_ids=created_ids, warnings=warnings))
return new_config, decisions