#!/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