v0.27.3
This commit is contained in:
1 parent
e84df445c6
commit
e87a382a7e
109 files changed
+1092
-2925
No files matched your search
Executable
+75
@@ -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()
|
||||
Executable
+124
@@ -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)
|
||||
|
||||
|
||||
Executable
+403
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user