590 lines
27 KiB
Python
Executable File
590 lines
27 KiB
Python
Executable File
#!/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
|
|
|
|
|
|
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)
|
|
|
|
|
|
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()
|