#!/usr/bin/env python3 """Native Nginx Proxy Manager + Angie installer. Supported NPM releases: >= 2.13.0 Examples: ./npm_install.py ./npm_install.py --npm-version 2.15.1 ./npm_install.py --branch master ./npm_install.py --branch develop ./npm_install.py --update """ from __future__ import annotations import argparse import filecmp import json import os import re import shutil import subprocess import sys import tarfile import tempfile import threading import time import urllib.request from contextlib import contextmanager from datetime import datetime from glob import glob from pathlib import Path DEBUG = False # GitHub Repository Configuration GITHUB_REPO_OWNER = "NginxProxyManager" GITHUB_REPO_NAME = "nginx-proxy-manager" GITHUB_REPO_URL = f"https://github.com/{GITHUB_REPO_OWNER}/{GITHUB_REPO_NAME}" # Alternative: Use full URL directly (uncomment to override above) # GITHUB_REPO_URL = "https://github.com/YourUsername/your-fork-name" # ========== Configuration ========== # Minimum required Node.js version for NPM 2.13.0+ MIN_NODEJS_VERSION = 20 # Maximum supported Node.js version MAX_NODEJS_VERSION = 24 # NPM Admin Interface Configuration NPM_ADMIN_ENABLE_SSL = True NPM_ADMIN_HTTP_PORT = 81 NPM_ADMIN_HTTPS_PORT = 8181 NPM_ADMIN_ROOT_PATH = "/opt/npm/frontend" NPM_ADMIN_CERT_PATH = "/etc/nginx/ssl/npm-admin.crt" NPM_ADMIN_KEY_PATH = "/etc/nginx/ssl/npm-admin.key" NPM_ADMIN_CERT_DAYS = 3650 # min. RAM settings MIN_MEMORY_GB = 3.5 SWAP_SIZE_GB = 2.0 # ========== UI / Spinner ========== class Spinner: FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") def __init__(self, text): self.text = text self._stop_event = threading.Event() self._thread = None self._frame_index = 0 def _spin(self): while not self._stop_event.is_set(): frame = self.FRAMES[self._frame_index % len(self.FRAMES)] sys.stdout.write(f"\r\033[K{frame} {self.text}") sys.stdout.flush() self._frame_index += 1 time.sleep(0.08) def start(self): if DEBUG or not sys.stdout.isatty(): print(f"• {self.text} ...") return self._thread = threading.Thread(target=self._spin, daemon=True) self._thread.start() def stop(self, ok=True): if DEBUG or not sys.stdout.isatty(): print(f"{'✔' if ok else '✖'} {self.text}") return self._stop_event.set() if self._thread and self._thread.is_alive(): self._thread.join(timeout=0.5) sys.stdout.write(f"\r\033[K{'✔' if ok else '✖'} {self.text}\n") sys.stdout.flush() @contextmanager def step(text): spinner = Spinner(text) spinner.start() try: yield except BaseException: spinner.stop(False) raise else: spinner.stop(True) def _devnull(): return subprocess.DEVNULL if not DEBUG else None def run(cmd, timeout=600, check=True, env=None, cwd=None, quiet=True): """Run a command. Output is hidden unless DEBUG is enabled or quiet=False.""" cmd = [str(x) for x in cmd] if DEBUG: print("+", " ".join(cmd)) hide = quiet and not DEBUG return subprocess.run( cmd, timeout=timeout, check=check, env=env, cwd=str(cwd) if cwd else None, stdout=subprocess.DEVNULL if hide else None, stderr=subprocess.DEVNULL if hide else None, ) def run_out(cmd, check=True, cwd=None): cmd = [str(x) for x in cmd] if DEBUG: print("+", " ".join(cmd)) result = subprocess.run( cmd, check=check, cwd=str(cwd) if cwd else None, capture_output=True, text=True, ) return result.stdout # ========== Utils ========== def ensure_root(): if os.geteuid() != 0: print("Run as root.", file=sys.stderr) sys.exit(1) def os_release(): data = {} try: for line in Path("/etc/os-release").read_text().splitlines(): if "=" in line: k, v = line.split("=", 1) data[k] = v.strip().strip('"') except Exception: pass pretty = ( data.get("PRETTY_NAME") or f"{data.get('ID','linux')} {data.get('VERSION_ID','')}".strip() ) return { "ID": data.get("ID", ""), "VERSION_ID": data.get("VERSION_ID", ""), "CODENAME": data.get("VERSION_CODENAME", ""), "PRETTY": pretty, } def apt_update_upgrade(): with step("Updating package lists and system"): run(["apt-get", "update", "-y"]) run(["apt-get", "-y", "upgrade"]) def apt_install(pkgs): if not pkgs: return with step(f"Installing packages: {', '.join(pkgs)}"): run(["apt-get", "install", "-y"] + pkgs) def apt_try_install(pkgs): if not pkgs: return avail = [] for p in pkgs: ok = subprocess.run( ["apt-cache", "show", p], stdout=_devnull(), stderr=_devnull() ) if ok.returncode == 0: avail.append(p) elif DEBUG: print(f"skip missing pkg: {p}") if avail: apt_install(avail) def apt_purge(pkgs): if not pkgs: return with step(f"Removing conflicting packages: {', '.join(pkgs)}"): run(["apt-get", "purge", "-y"] + pkgs, check=False) run(["apt-get", "autoremove", "-y"], check=False) def write_file(path: Path, content: str, mode=0o644): path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") os.chmod(path, mode) def parse_version(version_str: str) -> tuple[int, int, int]: match = re.match(r"^v?(\d+)\.(\d+)\.(\d+)", (version_str or "").strip()) return tuple(map(int, match.groups())) if match else (0, 0, 0) def interactive_install_mode(): print("=" * 70) print("NGINX PROXY MANAGER - INTERACTIVE INSTALLATION") print("=" * 70) if (input("1) Fresh install 2) Update existing [1]: ").strip() or "1") == "2": return {"update": True} if (input("Source: 1) stable release 2) branch [1]: ").strip() or "1") == "2": choice = input("Branch: 1) master 2) develop 3) custom [1]: ").strip() or "1" branch = {"1": "master", "2": "develop"}.get(choice) if not branch: branch = input("Branch name: ").strip() or "master" return {"update": False, "branch": branch, "npm_version": None} version = input("NPM version (empty = latest stable): ").strip() or None return {"update": False, "branch": None, "npm_version": version} def apply_interactive_choices(args, choices): args.update = choices.get("update", False) args.branch = choices.get("branch") args.npm_version = choices.get("npm_version") return args def _memory_gb(): """Return RAM and swap information in GiB.""" try: values = {} for line in Path("/proc/meminfo").read_text().splitlines(): key, value = line.split(":", 1) values[key] = int(value.split()[0]) return { "ram_total": values.get("MemTotal", 0) / (1024**2), "ram_available": values.get("MemAvailable", values.get("MemFree", 0)) / (1024**2), "swap_total": values.get("SwapTotal", 0) / (1024**2), "swap_free": values.get("SwapFree", 0) / (1024**2), } except Exception: try: page_size = os.sysconf("SC_PAGE_SIZE") return { "ram_total": page_size * os.sysconf("SC_PHYS_PAGES") / (1024**3), "ram_available": page_size * os.sysconf("SC_AVPHYS_PAGES") / (1024**3), "swap_total": 0.0, "swap_free": 0.0, } except Exception: return { "ram_total": 0.0, "ram_available": 0.0, "swap_total": 0.0, "swap_free": 0.0, } def _container_type() -> str | None: """Return container type when running inside one (for example: lxc, docker).""" if shutil.which("systemd-detect-virt"): try: result = subprocess.run( ["systemd-detect-virt", "--container"], check=False, capture_output=True, text=True, timeout=5, ) if result.returncode == 0: value = (result.stdout or "").strip().lower() if value and value != "none": return value except Exception: pass marker = Path("/run/systemd/container") try: if marker.exists(): value = marker.read_text(encoding="utf-8", errors="ignore").strip().lower() if value: return value except Exception: pass try: cgroup = Path("/proc/1/cgroup").read_text(encoding="utf-8", errors="ignore").lower() for name in ("lxc", "docker", "podman", "containerd"): if name in cgroup: return name except Exception: pass return None def _print_manual_memory_help(container_type: str | None, available_gb: float): print("\n" + "=" * 70) print("INSUFFICIENT MEMORY FOR INSTALLATION / UPDATE") print("=" * 70) if container_type: print(f"Container detected: {container_type}") print("Automatic swapon inside a container is usually not permitted.") else: print("Automatic temporary swap could not be enabled.") print(f"Available RAM + free swap: {available_gb:.1f} GB") print(f"Required: {MIN_MEMORY_GB:.1f} GB") print("") print("Temporarily increase RAM or swap before running the installer again.") if container_type: print("For LXC, configure RAM/swap on the host or hypervisor, not inside the container.") print("After the installation/update completes, you can restore the previous limits.") print("=" * 70 + "\n") def check_memory_and_create_swap(): """Ensure enough RAM+swap for builds; create temporary swap only outside containers.""" memory = _memory_gb() state = {"created": False, "activated_existing": False, "ready": True} ram_total = memory["ram_total"] ram_available = memory["ram_available"] swap_total = memory["swap_total"] swap_free = memory["swap_free"] available_total = ram_available + swap_free print(f"\n{'='*70}") print("MEMORY CHECK") print(f"{'='*70}") if not ram_total: print("RAM detection: unavailable") print("Continuing without automatic swap changes.") print(f"{'='*70}\n") return state print(f"Total RAM: {ram_total:.1f} GB") print(f"Available RAM: {ram_available:.1f} GB") print(f"Swap: {swap_free:.1f} GB free / {swap_total:.1f} GB total") print(f"Usable now: {available_total:.1f} GB (available RAM + free swap)") print(f"Threshold: {MIN_MEMORY_GB:.1f} GB") if available_total >= MIN_MEMORY_GB: print("\u2713 Memory/swap sufficient") print(f"{'='*70}\n") return state print(f"\u26a0 Low working memory ({available_total:.1f} GB < {MIN_MEMORY_GB:.1f} GB)") container_type = _container_type() if container_type: state["ready"] = False _print_manual_memory_help(container_type, available_total) return state swap_file = Path("/swapfile") try: active_swap = run_out(["swapon", "--show=NAME", "--noheadings"], check=False) if str(swap_file) in active_swap.split(): # It was already counted in SwapFree above. If that was still not enough, # do not alter a pre-existing swap file owned by the administrator. state["ready"] = False _print_manual_memory_help(None, available_total) return state except Exception: pass if swap_file.exists(): try: with step("Activating existing /swapfile"): run(["swapon", str(swap_file)]) state["activated_existing"] = True except subprocess.CalledProcessError: state["ready"] = False _print_manual_memory_help(None, available_total) return state memory = _memory_gb() available_after = memory["ram_available"] + memory["swap_free"] if available_after < MIN_MEMORY_GB: state["ready"] = False _print_manual_memory_help(None, available_after) else: print(f"\u2713 Existing /swapfile activated; usable memory: {available_after:.1f} GB") print(f"{'='*70}\n") return state try: with step(f"Creating temporary {SWAP_SIZE_GB:g} GB swap"): if shutil.which("fallocate"): run(["fallocate", "-l", f"{SWAP_SIZE_GB:g}G", str(swap_file)]) else: run([ "dd", "if=/dev/zero", f"of={swap_file}", "bs=1M", f"count={int(SWAP_SIZE_GB * 1024)}", "status=none", ]) state["created"] = True run(["chmod", "600", str(swap_file)]) run(["mkswap", str(swap_file)]) run(["swapon", str(swap_file)]) except subprocess.CalledProcessError: if state["created"]: run(["swapoff", str(swap_file)], check=False) swap_file.unlink(missing_ok=True) state["created"] = False state["ready"] = False _print_manual_memory_help(None, available_total) return state except BaseException: if state["created"]: run(["swapoff", str(swap_file)], check=False) swap_file.unlink(missing_ok=True) state["created"] = False raise memory = _memory_gb() available_after = memory["ram_available"] + memory["swap_free"] if available_after < MIN_MEMORY_GB: state["ready"] = False _print_manual_memory_help(None, available_after) else: print(f"\u2713 Temporary swap created; usable memory: {available_after:.1f} GB") print(f"{'='*70}\n") return state def cleanup_build_artifacts(): with step("Cleaning old build artifacts"): patterns = ( "/tmp/npm-*", "/tmp/yarn-*", "/tmp/node-*", "/tmp/v8-compile-cache-*", "/tmp/npm-angie-*", "/tmp/npm-update-*", ) for pattern in patterns: for raw_path in glob(pattern): path = Path(raw_path) try: shutil.rmtree(path, ignore_errors=True) if path.is_dir() else path.unlink(missing_ok=True) except Exception as exc: if DEBUG: print(f" ⚠ Could not remove {path}: {exc}") for cache_dir in {Path("/root/.yarn"), Path("/root/.cache/yarn"), Path.home() / ".yarn", Path.home() / ".cache/yarn"}: for name in ("cache", "global", "install-state.gz"): target = cache_dir / name if target.is_dir(): shutil.rmtree(target, ignore_errors=True) else: target.unlink(missing_ok=True) for rc_file in (Path("/root/.yarnrc"), Path("/root/.yarnrc.yml")): rc_file.unlink(missing_ok=True) def cleanup_swap(state=None): """Undo only swap changes made by this installer run.""" state = state or {} swap_file = Path("/swapfile") try: if state.get("created"): with step("Removing temporary swap"): run(["swapoff", str(swap_file)], check=False) swap_file.unlink(missing_ok=True) elif state.get("activated_existing"): with step("Restoring existing swap state"): run(["swapoff", str(swap_file)], check=False) except Exception as exc: print(f"⚠ Could not restore swap state: {exc}") def _github_request(url): return urllib.request.Request( url, headers={"User-Agent": "npm-angie-auto-install/2"}, ) def github_latest_release_tag(repo: str, override: str = None) -> str: if override: return override.lstrip("v") if "/" not in repo: repo = f"{GITHUB_REPO_OWNER}/{GITHUB_REPO_NAME}" url = f"https://api.github.com/repos/{repo}/releases/latest" with step(f"Checking latest NPM release: {repo}"): with urllib.request.urlopen(_github_request(url), timeout=30) as response: data = json.load(response) return data["tag_name"].lstrip("v") def _sanitize_angie_log_config(text: str) -> str: """Make log formats valid in global http/stream context. `$server` is only defined inside generated proxy_host server blocks. When it appears in a global log_format, `angie -t` fails with: unknown "server" variable. Use built-in `$upstream_addr` instead. """ return text.replace("$server", "$upstream_addr") def ensure_angie_log_include_files(): """Ensure split log include files required by ANGIE_CONF_TEMPLATE exist. Older installs may only have /etc/angie/conf.d/include/log.conf from the upstream NPM rootfs. The Angie template used by this installer includes log-proxy.conf in http{} and log-stream.conf in stream{}, so fresh/update mode must create them before running `angie -t`. """ include_dir = Path("/etc/angie/conf.d/include") include_dir.mkdir(parents=True, exist_ok=True) log_conf = include_dir / "log.conf" log_proxy = include_dir / "log-proxy.conf" log_stream = include_dir / "log-stream.conf" default_proxy = """log_format proxy '[$time_local] $upstream_cache_status $upstream_status $status - $request_method $scheme $host "$request_uri" [Client $remote_addr] [Length $body_bytes_sent] [Gzip $gzip_ratio] [Sent-to $upstream_addr] "$http_user_agent" "$http_referer"'; log_format standard '[$time_local] $status - $request_method $scheme $host "$request_uri" [Client $remote_addr] [Length $body_bytes_sent] [Gzip $gzip_ratio] "$http_user_agent" "$http_referer"'; access_log /data/logs/fallback_access.log proxy; """ default_stream = """log_format stream '[$time_local] [Client $remote_addr:$remote_port] $protocol $status $bytes_sent $bytes_received $session_time [Sent-to $upstream_addr] [Sent $upstream_bytes_sent] [Received $upstream_bytes_received] [Time $upstream_connect_time] $ssl_protocol $ssl_cipher'; access_log /data/logs/fallback_stream_access.log stream; """ try: if log_conf.exists(): proxy_text = _sanitize_angie_log_config(log_conf.read_text(encoding="utf-8")) if not proxy_text.strip(): proxy_text = default_proxy else: proxy_text = default_proxy if not log_proxy.exists(): write_file(log_proxy, proxy_text, 0o644) print(f" ✓ Created missing {log_proxy.name}") else: current = log_proxy.read_text(encoding="utf-8") sanitized = _sanitize_angie_log_config(current) if sanitized != current: write_file(log_proxy, sanitized, 0o644) print(f" ✓ Fixed invalid $server variable in {log_proxy.name}") if not log_stream.exists(): write_file(log_stream, default_stream, 0o644) print(f" ✓ Created missing {log_stream.name}") # Keep legacy log.conf present for compatibility with older/custom configs. if not log_conf.exists(): write_file(log_conf, proxy_text, 0o644) print(f" ✓ Created missing {log_conf.name}") else: current = log_conf.read_text(encoding="utf-8") sanitized = _sanitize_angie_log_config(current) if sanitized != current: write_file(log_conf, sanitized, 0o644) print(f" ✓ Fixed invalid $server variable in {log_conf.name}") run(["chown", "root:root", str(log_proxy), str(log_stream), str(log_conf)], check=False) except Exception as e: print(f" ⚠ Warning: could not ensure log include files: {e}") def write_resolvers_conf(ipv6_enabled: bool): ns_v4, ns_v6 = [], [] try: for line in Path("/etc/resolv.conf").read_text().splitlines(): line = line.strip() if not line.startswith("nameserver"): continue ip = line.split()[1].split("%")[0] (ns_v6 if ":" in ip else ns_v4).append(ip) except Exception: pass ips = ns_v4 + (ns_v6 if ipv6_enabled else []) cloudflare_ips = ["1.1.1.1"] + (["2606:4700:4700::1111"] if ipv6_enabled else []) google_ips = ["8.8.8.8"] + (["2001:4860:4860::8888"] if ipv6_enabled else []) if not ips: ips = cloudflare_ips + google_ips ipv6_flag = " ipv6=on" if ipv6_enabled and any(":" in x for x in ips) else "" if ns_v4 or ns_v6: status_zone = "status_zone=default_resolver" elif all(ip in cloudflare_ips for ip in ips): status_zone = "status_zone=cloudflare_resolver" elif all(ip in google_ips for ip in ips): status_zone = "status_zone=google_resolver" else: status_zone = "status_zone=mixed_resolver" content = f"resolver {' '.join(ips)} valid=10s {status_zone}{ipv6_flag};\n" write_file(Path("/etc/angie/conf.d/include/resolvers.conf"), content, 0o644) def validate_nodejs_version(version: str) -> tuple[bool, str, str | None]: version_map = {"latest": str(MAX_NODEJS_VERSION), "lts": str(MAX_NODEJS_VERSION), "current": str(MAX_NODEJS_VERSION)} resolved = version_map.get(version.lower(), version) match = re.match(r"(\d+)", resolved) if not match: return False, resolved, f"Invalid version format: {version}" major_version = int(match.group(1)) if major_version < MIN_NODEJS_VERSION: return ( False, str(MIN_NODEJS_VERSION), f"⚠ Requested Node.js v{major_version} is below the supported minimum; " f"using v{MIN_NODEJS_VERSION}.", ) if major_version > MAX_NODEJS_VERSION: return ( False, str(MAX_NODEJS_VERSION), f"⚠ Requested Node.js v{major_version} exceeds the tested maximum; " f"using v{MAX_NODEJS_VERSION}.", ) return True, resolved, None def validate_supported_os(): distro_id = OSREL.get("ID", "").lower() version_id = OSREL.get("VERSION_ID", "").strip() SUPPORTED = {"debian": ["11", "12", "13"], "ubuntu": ["22.04", "24.04", "26.04"]} if distro_id not in SUPPORTED: print(f"\n ⚠ ERROR: Unsupported distribution: {distro_id}") print(f" Detected: {OSREL.get('PRETTY', 'Unknown')}") print(f"\n Supported distributions:") print(f" • Debian 11, 12 (Bookworm), 13 (Trixie)") print(f" • Ubuntu 22.04 LTS, 24.04 LTS, 26.04 LTS") print(f" • Debian derivatives: Proxmox, armbian") print(f"\n Your distribution may work but is not tested.") print(f" Continue at your own risk or install on a supported system.\n") sys.exit(1) supported_versions = SUPPORTED[distro_id] version_match = False for supported_ver in supported_versions: if version_id.startswith(supported_ver): version_match = True break if not version_match: print(f"\n ⚠ WARNING: Unsupported version of {distro_id}: {version_id}") print(f" Detected: {OSREL.get('PRETTY', 'Unknown')}") print(f" Supported versions: {', '.join(supported_versions)}") print(f"\n This version is not officially tested.") print(f" Prerequisites:") print(f" • Angie packages must be available for your distribution") print( f" • Check: https://en.angie.software/angie/docs/installation/oss_packages/" ) print(f" • Your system should be Debian/Ubuntu compatible (apt-based)") response = input("\n Continue anyway? [y/N]: ").strip().lower() if response not in ["y", "yes"]: print("\n Installation cancelled.\n") sys.exit(1) print() else: print(f"✓ Supported OS detected: {OSREL.get('PRETTY', 'Unknown')}\n") def save_installer_config(config: dict): config_path = Path("/data/installer.json") config_path.parent.mkdir(parents=True, exist_ok=True) config["last_modified"] = time.strftime("%Y-%m-%d %H:%M:%S") try: config_path.write_text(json.dumps(config, indent=2), encoding="utf-8") if DEBUG: print(f"✓ Saved installer config to {config_path}") except Exception as e: print(f"⚠ Warning: Could not save installer config: {e}") def load_installer_config() -> dict: config_path = Path("/data/installer.json") if not config_path.exists(): if DEBUG: print(f"No installer config found at {config_path}") return {} try: content = config_path.read_text(encoding="utf-8") config = json.loads(content) if DEBUG: print(f"✓ Loaded installer config from {config_path}") return config except Exception as e: print(f"⚠ Warning: Could not load installer config: {e}") return {} def comment_x_served_by_step(path="/etc/angie/conf.d/include/proxy.conf"): p = Path(path) if not p.exists(): if DEBUG: print(f" ⊘ Skip X-Served-By patch; missing {path}") return 0 src = p.read_text() pattern = re.compile( r"^(?P\s*)(?!#)\s*add_header\s+X-Served-By\s+\$host\s*;\s*$", re.MULTILINE ) count = len(pattern.findall(src)) if count == 0: return 0 backup = p.with_suffix(p.suffix + ".bak") shutil.copy2(p, backup) out = pattern.sub( lambda m: f"{m.group('ws')}# add_header X-Served-By $host;", src ) fd, tmp = tempfile.mkstemp(dir=str(p.parent)) os.close(fd) Path(tmp).write_text(out) shutil.copymode(p, tmp) os.replace(tmp, p) print(f"✔ Hide X-Served-by header | backup: {backup}") return count def set_file_ownership(files: list[str | Path], owner: str, mode: int | None = None): ok = True for raw_path in files: path = Path(raw_path) if not path.exists(): if DEBUG: print(f" ⊘ Missing: {path}") ok = False continue try: run(["chown", owner, str(path)]) if mode is not None: os.chmod(path, mode) except Exception as exc: print(f"⚠ Could not set ownership for {path}: {exc}") ok = False return ok def check_distro_nodejs_available(): try: result = subprocess.run( ["apt-cache", "show", "nodejs"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) if result.returncode == 0: for line in result.stdout.splitlines(): if line.startswith("Version:"): version_str = line.split(":", 1)[1].strip() match = re.match(r"(\d+)", version_str) if match: major = int(match.group(1)) if DEBUG: print( f"✓ Distro has nodejs v{version_str} (major: {major})" ) return True, major, version_str return False, None, None except Exception as e: if DEBUG: print(f"Failed to check distro nodejs: {e}") return False, None, None def install_nodejs_from_distro(): with step("Installing Node.js from distribution repositories"): apt_install(["nodejs"]) if not shutil.which("npm"): apt_try_install(["npm"]) if shutil.which("node"): node_ver = run_out(["node", "--version"], check=False).strip() print(f" Node.js: {node_ver}") if shutil.which("npm"): npm_ver = run_out(["npm", "--version"], check=False).strip() print(f" npm: {npm_ver}") return True return False def _installed_node_major() -> int | None: if not shutil.which("node"): return None match = re.match(r"v?(\d+)", run_out(["node", "--version"], check=False).strip()) return int(match.group(1)) if match else None def _requested_node_major(value) -> int | None: if value is None: return None _, resolved, warning = validate_nodejs_version(str(value)) if warning: print(warning) match = re.match(r"(\d+)", resolved) return int(match.group(1)) if match else None def ensure_minimum_nodejs(min_version=MIN_NODEJS_VERSION, user_requested_version=None): """Ensure a tested Node.js version and npm are available.""" requested = _requested_node_major(user_requested_version) current = _installed_node_major() if current is not None and shutil.which("npm"): wanted = current == requested if requested is not None else min_version <= current <= MAX_NODEJS_VERSION if wanted: print(f"✓ Node.js: {run_out(['node', '--version'], check=False).strip()}") print(f" npm: {run_out(['npm', '--version'], check=False).strip()}") return True if requested is not None: install_node_from_nodesource(str(requested)) else: has_distro, major, version = check_distro_nodejs_available() if has_distro and major is not None and min_version <= major <= MAX_NODEJS_VERSION: print(f"✓ Distribution provides Node.js {version}") if not install_nodejs_from_distro(): install_node_from_nodesource(str(min_version)) else: if has_distro and major is not None: print(f"⚠ Distribution Node.js {version} outside tested range " f"{min_version}-{MAX_NODEJS_VERSION}") install_node_from_nodesource(str(min_version)) if not shutil.which("node") or not shutil.which("npm"): raise RuntimeError("Node.js/npm installation failed") return True def download_extract_tar_gz(url: str, dest_dir: Path) -> Path: dest_dir.mkdir(parents=True, exist_ok=True) tmp_path = None with step("Downloading and extracting NPM source"): try: with urllib.request.urlopen(_github_request(url), timeout=60) as response, \ tempfile.NamedTemporaryFile(delete=False) as tmp: shutil.copyfileobj(response, tmp) tmp_path = Path(tmp.name) with tarfile.open(tmp_path, "r:gz") as archive: top = archive.getmembers()[0].name.split("/", 1)[0] try: archive.extractall(dest_dir, filter="data") except TypeError: # Python < 3.12 archive.extractall(dest_dir) return dest_dir / top finally: if tmp_path: tmp_path.unlink(missing_ok=True) # Distro info (used in banners & repo setup) OSREL = os_release() # === extra sync === CERTBOT_REQUIRED_PACKAGES = [ "certbot", "acme", "certbot-dns-cloudflare", "certbot-dns-rfc2136", ] def _ensure_certbot_symlink(certbot_path: Path): Path("/usr/local/bin").mkdir(parents=True, exist_ok=True) target = Path("/usr/local/bin/certbot") if target.exists() or target.is_symlink(): try: target.unlink() except Exception: pass target.symlink_to(certbot_path) def _detect_certbot_version(certbot_path: Path) -> str: cb_ver = run_out([str(certbot_path), "--version"], check=False).strip() m = re.search(r"(\d+\.\d+\.\d+)", cb_ver) if not m: raise RuntimeError(f"Cannot detect certbot version from: {cb_ver!r}") return m.group(1) def certbot_version_for_npm() -> str: """Return the certbot version that NPM must expose as CERTBOT_VERSION. NPM v2.15.x replaces {{certbot-version}} in dns-plugins.json from the CERTBOT_VERSION environment variable. If it is missing, Node turns it into "undefined" and pip receives invalid requirements such as acme==undefined. """ candidates = [ Path("/opt/certbot/bin/certbot"), Path("/usr/local/bin/certbot"), ] for certbot_path in candidates: if certbot_path.exists(): try: return _detect_certbot_version(certbot_path) except Exception: pass if shutil.which("certbot"): try: out = run_out(["certbot", "--version"], check=False).strip() m = re.search(r"(\d+\.\d+\.\d+)", out) if m: return m.group(1) except Exception: pass return "" def patch_npm_certbot_plugins_config() -> str: """Patch NPM DNS plugin metadata with a concrete certbot version. This is a non-docker install. NPM normally expects CERTBOT_VERSION in the container environment. We set the env in systemd and also replace the JSON placeholders after every install/update so startup cannot generate acme==undefined even if the process environment is incomplete. """ certbot_ver = certbot_version_for_npm() if not certbot_ver: print("⚠ Could not detect Certbot version for NPM DNS plugin metadata") return "" os.environ["CERTBOT_VERSION"] = certbot_ver patched = [] for path in [ Path("/opt/npm/certbot/dns-plugins.json"), Path("/opt/npm/backend/certbot/dns-plugins.json"), ]: if not path.exists(): continue try: txt = path.read_text(encoding="utf-8") new = txt.replace("{{certbot-version}}", certbot_ver) new = new.replace("acme==undefined", f"acme=={certbot_ver}") if new != txt: path.write_text(new, encoding="utf-8") patched.append(str(path)) except Exception as e: print(f"⚠ Could not patch {path}: {e}") if patched: print(f"✔ Patched NPM Certbot plugin metadata: Certbot {certbot_ver}") if DEBUG: for path in patched: print(f" - {path}") else: print(f"✔ NPM Certbot plugin metadata ready: Certbot {certbot_ver}") return certbot_ver def run_logged(cmd, log_path: Path, timeout=1200, check=True, env=None, cwd=None): """Run a command and keep its full output in a diagnostic log.""" log_path = Path(log_path) log_path.parent.mkdir(parents=True, exist_ok=True) with log_path.open("a", encoding="utf-8", errors="replace") as log: log.write("\n\n$ " + " ".join(map(str, cmd)) + "\n") log.flush() result = subprocess.run( cmd, timeout=timeout, check=False, env=env, cwd=cwd, stdout=log, stderr=subprocess.STDOUT, text=True, ) if check and result.returncode != 0: print(f" ✖ Command failed, log: {log_path}") try: tail = log_path.read_text(encoding="utf-8", errors="replace").splitlines()[-40:] if tail: print(" --- log tail ---") for line in tail: print(" " + line[:220]) print(" --- end log tail ---") except Exception: pass raise subprocess.CalledProcessError(result.returncode, cmd) return result def _python_version(exe: str) -> tuple[int, int] | None: try: out = run_out([exe, "--version"], check=False).strip() m = re.search(r"Python\s+(\d+)\.(\d+)", out) if m: return (int(m.group(1)), int(m.group(2))) except Exception: pass return None def _find_certbot_system_python() -> tuple[str | None, str | None]: """Prefer python3.11 when installed; otherwise use distro python >= 3.11. Debian 13 ships a newer Python (for example 3.13). That is suitable for the certbot venv and avoids compiling Python 3.11 through pyenv on fresh/update. """ candidates = ["python3.11", "python3", "python3.13", "python3.12"] seen = set() for exe in candidates: if exe in seen or not shutil.which(exe): continue seen.add(exe) ver = _python_version(exe) if ver and ver[0] == 3 and ver[1] >= 11: out = run_out([exe, "--version"], check=False).strip() return exe, out return None, None def _create_certbot_venv_with_python(python_exe: str, python_label: str, venv_dir: Path): with step(f"Using {python_label} for certbot venv"): venv_dir.mkdir(parents=True, exist_ok=True) run([python_exe, "-m", "venv", str(venv_dir)]) env_build = os.environ.copy() env_build["SETUPTOOLS_USE_DISTUTILS"] = "local" _install_certbot_stack( [str(venv_dir / "bin" / "pip")], venv_dir / "bin" / "certbot", env_build, ) certbot_path = venv_dir / "bin" / "certbot" pip_path = venv_dir / "bin" / "pip" print(f" Python: {python_label}") print(f" Certbot: {run_out([str(certbot_path), '--version'], check=False).strip()}") print(f" Pip: {run_out([str(pip_path), '--version'], check=False).strip().split(' from ')[0]}") def _install_certbot_stack(pip_cmd: list[str], certbot_path: Path, env_build: dict): """Install Certbot plus the DNS plugins required by this native NPM setup.""" log_path = Path("/tmp/npm-certbot-venv.log") def pip(*args): run_logged([*pip_cmd, *args], log_path, env=env_build) pip("install", "-U", "pip", "setuptools", "wheel") pip("install", "-U", "cryptography", "cffi", "certbot", "tldextract") certbot_ver = _detect_certbot_version(certbot_path) pip( "install", "-U", f"acme=={certbot_ver}", f"certbot-dns-cloudflare=={certbot_ver}", f"certbot-dns-rfc2136=={certbot_ver}", ) missing = [] for package in CERTBOT_REQUIRED_PACKAGES: result = subprocess.run( [*pip_cmd, "show", package], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ) if result.returncode != 0: missing.append(package) if missing: raise RuntimeError(f"Certbot venv incomplete, missing: {', '.join(missing)}") _ensure_certbot_symlink(certbot_path) def _venv_entrypoint_usable(path: Path, args: list[str] | None = None) -> tuple[bool, str]: """Return whether a venv script/binary can be executed. Debian/Ubuntu upgrades can leave /opt/certbot/bin/* wrappers with a stale shebang, for example /opt/certbot/bin/python3.11 no longer exists. In that case subprocess may raise FileNotFoundError even though the wrapper file itself exists. Treat that as a broken venv and rebuild it. """ args = args or ["--version"] if not path.exists(): return False, f"missing: {path}" try: first_line = path.read_bytes().splitlines()[0].decode("utf-8", "ignore") except Exception: first_line = "" if first_line.startswith("#!"): interpreter = first_line[2:].strip().split()[0] if interpreter and interpreter.startswith("/") and not Path(interpreter).exists(): return False, f"stale interpreter in {path}: {interpreter}" try: result = subprocess.run( [str(path)] + args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=20, check=False, ) if result.returncode == 0: return True, "ok" return False, f"{path} exited with code {result.returncode}" except FileNotFoundError as e: return False, f"cannot execute {path}: {e}" except Exception as e: return False, f"cannot execute {path}: {e}" def _venv_python_path(venv_dir: Path) -> Path: return venv_dir / "bin" / "python" def _venv_package_installed_with_python(python_path: Path, pkg: str) -> bool: try: result = subprocess.run( [str(python_path), "-m", "pip", "show", pkg], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30, check=False, ) return result.returncode == 0 except Exception: return False def _try_repair_existing_certbot_venv(venv_dir: Path, reason: str) -> bool: python_path = _venv_python_path(venv_dir) certbot_path = venv_dir / "bin" / "certbot" python_ok, python_reason = _venv_entrypoint_usable(python_path, ["--version"]) if not python_ok: if DEBUG: print(f" Existing certbot venv python unusable: {python_reason}") return False try: with step(f"Repairing existing certbot venv ({reason})"): env_build = os.environ.copy() env_build["SETUPTOOLS_USE_DISTUTILS"] = "local" _install_certbot_stack( [str(python_path), "-m", "pip"], certbot_path, env_build, ) print(f"✓ Existing certbot venv repaired: {_detect_certbot_version(certbot_path)}") return True except Exception as exc: print(f"⚠ Could not repair existing certbot venv: {exc}") print(" Falling back to full rebuild.") return False def ensure_certbot_venv_ready(venv_dir: Path = Path("/opt/certbot"), force_rebuild: bool = False): certbot_path = venv_dir / "bin" / "certbot" pip_path = venv_dir / "bin" / "pip" python_path = _venv_python_path(venv_dir) if force_rebuild and venv_dir.exists(): with step("Removing certbot venv for forced rebuild"): shutil.rmtree(venv_dir, ignore_errors=True) if not venv_dir.exists(): setup_certbot_venv(venv_dir) run(["chown", "-R", "npm:npm", str(venv_dir)], check=False) return True python_ok, python_reason = _venv_entrypoint_usable(python_path, ["--version"]) certbot_ok, certbot_reason = _venv_entrypoint_usable(certbot_path, ["--version"]) pip_ok, pip_reason = _venv_entrypoint_usable(pip_path, ["--version"]) missing_pkg = None packages_ok = python_ok if packages_ok: for package in CERTBOT_REQUIRED_PACKAGES: if not _venv_package_installed_with_python(python_path, package): missing_pkg = package packages_ok = False break if python_ok and certbot_ok and packages_ok and pip_ok: _ensure_certbot_symlink(certbot_path) print(f"✓ Existing certbot venv is complete: {_detect_certbot_version(certbot_path)}") run(["chown", "-R", "npm:npm", str(venv_dir)], check=False) return True reason = ( python_reason if not python_ok else certbot_reason if not certbot_ok else f"missing package: {missing_pkg}" if missing_pkg else pip_reason ) if python_ok and _try_repair_existing_certbot_venv(venv_dir, reason): run(["chown", "-R", "npm:npm", str(venv_dir)], check=False) return True with step(f"Removing broken certbot venv ({reason})"): shutil.rmtree(venv_dir, ignore_errors=True) setup_certbot_venv(venv_dir) run(["chown", "-R", "npm:npm", str(venv_dir)], check=False) return True def setup_certbot_venv(venv_dir: Path = Path("/opt/certbot")): info = os_release() distro_id = (info.get("ID") or "").lower() version_id = (info.get("VERSION_ID") or "").strip() # Prefer system Python 3.11 if present. On Debian 13, use distro Python # (for example Python 3.13) instead of compiling Python 3.11 via pyenv. if distro_id == "debian" and version_id.startswith("13"): apt_try_install(["python3", "python3-venv", "python3-pip", "python3-dev"]) else: apt_try_install(["python3.11-venv", "python3-venv", "python3-pip"]) python_exe, python_label = _find_certbot_system_python() if python_exe: _create_certbot_venv_with_python(python_exe, python_label, venv_dir) return # Ubuntu fallback: install Python 3.11 from deadsnakes when no suitable # system Python is available. if distro_id == "ubuntu": with step( f"Ubuntu detected: {info.get('PRETTY','Ubuntu')}. Install Python 3.11 via deadsnakes" ): try: run(["apt-get", "update", "-y"], check=False) apt_try_install(["software-properties-common"]) except Exception: run(["apt-get", "install", "-y", "software-properties-common"], check=False) run(["add-apt-repository", "-y", "ppa:deadsnakes/ppa"]) run(["apt-get", "update", "-y"], check=False) run(["apt-get", "install", "-y", "python3.11", "python3.11-venv"]) _create_certbot_venv_with_python("python3.11", "Python 3.11 (deadsnakes)", venv_dir) return # Last resort only: pyenv. Debian 13 should not normally reach this path, # because it has a suitable distro Python. PYENV_ROOT = Path("/opt/npm/.pyenv") PYENV_OWNER = "npm" PYTHON_VERSION = "3.11.14" pyenv_log = Path("/tmp/npm-pyenv-python-build.log") with step("Installing pyenv build dependencies"): apt_install( [ "build-essential", "gcc", "make", "pkg-config", "libssl-dev", "zlib1g-dev", "libbz2-dev", "libreadline-dev", "libsqlite3-dev", "tk-dev", "libncursesw5-dev", "libgdbm-dev", "libffi-dev", "uuid-dev", "liblzma-dev", "curl", "git", "ca-certificates", ] ) Path("/opt/npm").mkdir(parents=True, exist_ok=True) PYENV_ROOT.mkdir(parents=True, exist_ok=True) run(["chown", "-R", f"{PYENV_OWNER}:{PYENV_OWNER}", "/opt/npm"], check=False) with step(f"Ensuring pyenv is available at {PYENV_ROOT}"): pyenv_bin_path = PYENV_ROOT / "bin" / "pyenv" if not pyenv_bin_path.exists(): run_logged( [ "sudo", "-u", PYENV_OWNER, "bash", "-lc", 'if [ ! -x "/opt/npm/.pyenv/bin/pyenv" ]; then git clone --depth=1 https://github.com/pyenv/pyenv.git /opt/npm/.pyenv; fi', ], pyenv_log, ) pyenv_bin = PYENV_ROOT / "bin" / "pyenv" if not pyenv_bin.exists(): raise RuntimeError("No 'pyenv' found even after git clone attempt.") with step(f"Installing Python {PYTHON_VERSION} via pyenv into {PYENV_ROOT}"): run(["mkdir", "-p", str(PYENV_ROOT)]) run(["chown", "-R", f"{PYENV_OWNER}:{PYENV_OWNER}", "/opt/npm"], check=False) install_cmd = ( "export HOME=/opt/npm; " "export PYENV_ROOT=/opt/npm/.pyenv; " 'export PATH="$PYENV_ROOT/bin:/usr/bin:/bin"; ' 'mkdir -p "$PYENV_ROOT"; cd "$HOME"; ' f"pyenv install -v -s {PYTHON_VERSION}" ) run_logged( [ "sudo", "-u", PYENV_OWNER, "env", "-i", "HOME=/opt/npm", f"PYENV_ROOT={PYENV_ROOT}", f"PATH={PYENV_ROOT}/bin:/usr/bin:/bin", "bash", "-lc", install_cmd, ], pyenv_log, timeout=3600, ) profile_snippet = f"""# Auto-generated by npm-angie-auto-install # pyenv for '{PYENV_OWNER}' if [ -d "{PYENV_ROOT}" ]; then export PYENV_ROOT="{PYENV_ROOT}" case ":$PATH:" in *":{PYENV_ROOT}/bin:"*) ;; *) PATH="{PYENV_ROOT}/bin:$PATH";; esac case ":$PATH:" in *":/usr/lib/pyenv/bin:"*) ;; *) PATH="/usr/lib/pyenv/bin:$PATH";; esac export PATH case "$-" in *i*) _interactive=1 ;; *) _interactive=0 ;; esac if [ "$_interactive" = 1 ] && {{ [ "${{USER:-}}" = "{PYENV_OWNER}" ] || [ "${{SUDO_USER:-}}" = "{PYENV_OWNER}" ]; }}; then if command -v pyenv >/dev/null 2>&1; then eval "$(pyenv init -)" elif [ -x "{PYENV_ROOT}/bin/pyenv" ]; then eval "$("{PYENV_ROOT}/bin/pyenv" init -)" fi fi fi """ write_file(Path("/etc/profile.d/npm-pyenv.sh"), profile_snippet, 0o644) python311 = PYENV_ROOT / "versions" / PYTHON_VERSION / "bin" / "python3.11" if not python311.exists(): python311 = PYENV_ROOT / "versions" / PYTHON_VERSION / "bin" / "python3" if not python311.exists(): raise RuntimeError(f"No python {PYTHON_VERSION} in {PYENV_ROOT}/versions/.") _create_certbot_venv_with_python(str(python311), f"Python {PYTHON_VERSION} (pyenv)", venv_dir) run(["chown", "-R", f"{PYENV_OWNER}:{PYENV_OWNER}", str(PYENV_ROOT)], check=False) def configure_letsencrypt(): with step("configure letsencrypt"): run(["chown", "-R", "npm:npm", "/opt/certbot"], check=False) Path("/etc/letsencrypt").mkdir(parents=True, exist_ok=True) run(["chown", "-R", "npm:npm", "/etc/letsencrypt"], check=False) # Do not install distro certbot here; use /opt/certbot venv only. ini = """text = True non-interactive = True webroot-path = /data/letsencrypt-acme-challenge key-type = ecdsa elliptic-curve = secp384r1 preferred-chain = ISRG Root X1 """ write_file(Path("/etc/letsencrypt.ini"), ini, 0o644) run(["chown", "-R", "npm:npm", "/etc/letsencrypt"], check=False) def ensure_nginx_symlink(): """Keep /etc/nginx as a compatibility symlink to Angie configuration.""" target = Path("/etc/angie") link = Path("/etc/nginx") target.mkdir(parents=True, exist_ok=True) if link.is_symlink(): try: if link.resolve() == target: return except OSError: pass link.unlink() elif link.exists(): backup = Path("/etc/nginx.bak") if backup.exists() or backup.is_symlink(): backup = Path(f"/etc/nginx.bak-{time.strftime('%Y%m%d-%H%M%S')}") link.rename(backup) print(f"✔ Backed up /etc/nginx to {backup}") link.symlink_to(target, target_is_directory=True) print("✔ Created symlink /etc/nginx -> /etc/angie") # ========== Angie / NPM template ========== ANGIE_CONF_TEMPLATE = r"""# run angie in foreground #daemon off; load_module /etc/angie/modules/ngx_http_headers_more_filter_module.so; load_module /etc/angie/modules/ngx_http_brotli_filter_module.so; load_module /etc/angie/modules/ngx_http_brotli_static_module.so; load_module /etc/angie/modules/ngx_http_zstd_filter_module.so; load_module /etc/angie/modules/ngx_http_zstd_static_module.so; load_module /etc/angie/modules/ngx_http_echo_module.so; # other modules include /data/nginx/custom/modules[.]conf; pid /run/angie/angie.pid; user root; worker_processes auto; pcre_jit on; error_log /data/logs/fallback_error.log warn; # Custom include /data/nginx/custom/root_top[.]conf; events { include /data/nginx/custom/events[.]conf; } http { include /etc/angie/mime.types; default_type application/octet-stream; sendfile on; server_tokens off; tcp_nopush on; tcp_nodelay on; client_body_temp_path /tmp/angie/body 1 2; keepalive_timeout 90s; proxy_connect_timeout 90s; proxy_send_timeout 90s; proxy_read_timeout 90s; ssl_prefer_server_ciphers on; #gzip on; proxy_ignore_client_abort off; client_max_body_size 2000m; server_names_hash_bucket_size 1024; proxy_http_version 1.1; proxy_set_header X-Forwarded-Scheme $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Accept-Encoding ""; proxy_cache off; proxy_cache_path /var/lib/angie/cache/public levels=1:2 keys_zone=public-cache:128m max_size=1g inactive=6h use_temp_path=off; proxy_cache_path /var/lib/angie/cache/private levels=1:2 keys_zone=private-cache:10m max_size=64m inactive=1h use_temp_path=off; # HTTP/3 global settings http3_max_concurrent_streams 128; http3_stream_buffer_size 64k; # QUIC settings quic_retry on; quic_gso on; quic_active_connection_id_limit 2; # Enable BPF for connection migration (Linux 5.7+) # quic_bpf on; # Uncomment if your kernel supports it include /etc/angie/conf.d/include/log-proxy.conf; include /etc/angie/conf.d/include/resolvers.conf; map $host $forward_scheme { default http; } # Handle upstream X-Forwarded-Proto and X-Forwarded-Scheme header map $http_x_forwarded_proto $x_forwarded_proto { "http" "http"; "https" "https"; default $scheme; } map $http_x_forwarded_scheme $x_forwarded_scheme { "http" "http"; "https" "https"; default $scheme; } # Real IP Determination (IPv4 only by default) set_real_ip_from 10.0.0.0/8; set_real_ip_from 172.16.0.0/12; set_real_ip_from 192.168.0.0/16; include /etc/angie/conf.d/include/ip_ranges.conf; real_ip_header X-Real-IP; real_ip_recursive on; # custom map $sent_http_content_type $compressible_type { default 0; ~*text/plain 1; ~*text/css 1; ~*text/xml 1; ~*text/javascript 1; ~*application/javascript 1; ~*application/x-javascript 1; ~*application/json 1; ~*application/xml 1; ~*application/xml\+rss 1; ~*application/rss\+xml 1; ~*image/svg\+xml 1; ~*font/truetype 1; ~*font/opentype 1; ~*font/woff 1; ~*font/woff2 1; ~*application/font-woff 1; ~*application/font-woff2 1; } # Brotli compression brotli on; brotli_static on; brotli_comp_level 6; brotli_min_length 1000; brotli_types text/plain text/css text/xml text/javascript application/javascript application/x-javascript application/json application/xml application/xml+rss application/rss+xml image/svg+xml font/truetype font/opentype font/woff font/woff2 application/font-woff application/font-woff2; # Zstd compression zstd on; zstd_comp_level 3; zstd_min_length 256; zstd_types text/plain text/css text/xml text/javascript application/javascript application/x-javascript application/json application/xml application/xml+rss application/rss+xml image/svg+xml font/truetype font/opentype font/woff font/woff2 application/font-woff application/font-woff2; # Gzip compression gzip on; gzip_vary on; gzip_proxied any; gzip_comp_level 6; gzip_min_length 1000; gzip_types text/plain text/css text/xml text/javascript application/javascript application/x-javascript application/json application/xml application/xml+rss application/rss+xml image/svg+xml font/truetype font/opentype font/woff font/woff2 application/font-woff application/font-woff2; more_clear_headers "Server"; more_set_headers 'X-by: linuxiarz.pl'; # npm include /data/nginx/custom/http_top[.]conf; include /etc/nginx/conf.d/*.conf; include /data/nginx/default_host/*.conf; include /data/nginx/proxy_host/*.conf; include /data/nginx/redirection_host/*.conf; include /data/nginx/dead_host/*.conf; include /data/nginx/temp/*.conf; include /data/nginx/custom/http[.]conf; # metrics & console include /etc/angie/metrics.conf; } stream { # npm include /etc/angie/conf.d/include/log-stream.conf; include /data/nginx/stream/*.conf; include /data/nginx/custom/stream[.]conf; } # npm include /data/nginx/custom/root[.]conf; """ ANGIE_UNIT = """[Unit] Description=Angie - high performance web server Documentation=https://en.angie.software/angie/docs/ After=network-online.target remote-fs.target nss-lookup.target Wants=network-online.target [Service] Type=forking PIDFile=/run/angie/angie.pid ExecStartPre=/bin/mkdir -p /run/angie ExecStartPre=/bin/mkdir -p /tmp/angie/body ExecStart=/usr/sbin/angie -c /etc/angie/angie.conf ExecReload=/bin/sh -c "/bin/kill -s HUP $(/bin/cat /run/angie/angie.pid)" ExecStop=/bin/sh -c "/bin/kill -s TERM $(/bin/cat /run/angie/angie.pid)" Restart=on-failure RestartSec=3s [Install] WantedBy=multi-user.target """ # ========== Angie ========== def setup_angie(ipv6_enabled: bool): def _norm(s: str, allow_dot: bool = False) -> str: pat = r"[^a-z0-9+\-\.]" if allow_dot else r"[^a-z0-9+\-]" return re.sub(pat, "", s.strip().lower()) with step("Adding Angie repo and installing Angie packages"): run( [ "curl", "-fsSL", "-o", "/etc/apt/trusted.gpg.d/angie-signing.gpg", "https://angie.software/keys/angie-signing.gpg", ] ) try: dist = run_out(["lsb_release", "-si"]) rel = run_out(["lsb_release", "-sr"]) code = run_out(["lsb_release", "-sc"]) except Exception: dist = run_out(["bash", "-c", '. /etc/os-release && printf %s "$ID"']) rel = run_out( ["bash", "-c", '. /etc/os-release && printf %s "$VERSION_ID"'] ) code = run_out( ["bash", "-c", '. /etc/os-release && printf %s "$VERSION_CODENAME"'] ) dist = _norm(dist) rel = _norm(rel, allow_dot=True) code = _norm(code) os_id = f"{dist}/{rel}" if rel else dist if code: line = f"deb https://download.angie.software/angie/{os_id} {code} main\n" else: line = f"deb https://download.angie.software/angie/{os_id} main\n" write_file(Path("/etc/apt/sources.list.d/angie.list"), line) run(["apt-get", "update"]) packages = [ "angie", "angie-module-headers-more", "angie-module-brotli", "angie-module-zstd", "angie-module-echo", "angie-console-light", ] apt_install(packages) with step("Configuring modules and main Angie config"): modules_dir = Path("/etc/angie/modules") modules_dir.mkdir(parents=True, exist_ok=True) write_file(Path("/etc/angie/angie.conf"), ANGIE_CONF_TEMPLATE, 0o644) ensure_angie_log_include_files() WRAP = """#!/bin/sh exec sudo -n /usr/sbin/angie "$@" """ write_file(Path("/usr/sbin/nginx"), WRAP, 0o755) Path("/etc/angie/conf.d/include").mkdir(parents=True, exist_ok=True) with step("Setting resolver(s) and cache directories"): write_resolvers_conf(ipv6_enabled) for p in ["/var/lib/angie/cache/public", "/var/lib/angie/cache/private"]: Path(p).mkdir(parents=True, exist_ok=True) os.chmod(p, 0o755) with step("Installing corrected systemd unit for Angie"): write_file(Path("/etc/systemd/system/angie.service"), ANGIE_UNIT, 0o644) def write_metrics_files(): """Create /etc/angie/metrics.conf (port 82/8282 with console & status).""" cert_path = NPM_ADMIN_CERT_PATH key_path = NPM_ADMIN_KEY_PATH with step("Adding Angie metrics & console on :82 / :8282 (https)"): if NPM_ADMIN_ENABLE_SSL: generate_selfsigned_cert() metrics = f"""include /etc/angie/prometheus_all.conf; server {{ listen 8282 ssl; http2 on; access_log off; ssl_certificate {cert_path}; ssl_certificate_key {key_path}; location / {{ default_type text/html; echo ''; echo 'Status Page'; echo '

Server Status

'; echo ''; echo ''; }} location /nginx_status {{ stub_status on; access_log off; allow all; }} auto_redirect on; location /status/ {{ api /status/; api_config_files on; }} location /console/ {{ alias /usr/share/angie-console-light/html/; index index.html; }} location /console/api/ {{ api /status/; }} location =/p8s {{ prometheus all; }} }} server {{ listen 82; access_log off; location / {{ default_type text/html; echo ''; echo 'Status Page'; echo '

Server Status

'; echo ''; echo ''; }} location /nginx_status {{ stub_status on; access_log off; allow all; }} auto_redirect on; location /status/ {{ api /status/; api_config_files on; }} location /console/ {{ alias /usr/share/angie-console-light/html/; index index.html; }} location /console/api/ {{ api /status/; }} location =/p8s {{ prometheus all; }} }} """ write_file(Path("/etc/angie/metrics.conf"), metrics, 0o644) def ensure_angie_runtime_perms(): run_path = Path("/run/angie") pid_file = run_path / "angie.pid" run_path.mkdir(parents=True, exist_ok=True) os.chmod(run_path, 0o2775) try: import grp gid = grp.getgrnam("angie").gr_gid os.chown(run_path, -1, gid) except Exception: pass if not pid_file.exists(): pid_file.touch() os.chmod(pid_file, 0o664) try: import grp, pwd gid = grp.getgrnam("angie").gr_gid uid = pwd.getpwnam("root").pw_uid os.chown(pid_file, uid, gid) except Exception: pass def ensure_user_and_dirs(): with step("Creating npm user and app/log directories"): try: run(["id", "-u", "npm"]) except subprocess.CalledProcessError: run( [ "useradd", "--system", "--home", "/opt/npm", "--create-home", "--shell", "/usr/sbin/nologin", "npm", ] ) rc = subprocess.run( ["getent", "group", "angie"], stdout=_devnull(), stderr=_devnull() ).returncode if rc != 0: run(["groupadd", "angie"]) run(["usermod", "-aG", "angie", "npm"], check=False) dirs = [ "/data", "/data/nginx", "/data/custom_ssl", "/data/logs", "/data/access", "/data/nginx/default_host", "/data/nginx/default_www", "/data/nginx/proxy_host", "/data/nginx/redirection_host", "/data/nginx/stream", "/data/nginx/dead_host", "/data/nginx/temp", "/data/letsencrypt-acme-challenge", "/opt/npm", "/opt/npm/frontend", "/opt/npm/global", "/run/nginx", "/run/angie", "/tmp/angie/body", ] for d in dirs: Path(d).mkdir(parents=True, exist_ok=True) run(["chgrp", "-h", "angie", "/run/angie"], check=False) os.chmod("/run/angie", 0o2775) Path("/var/log/angie").mkdir(parents=True, exist_ok=True) for f in ["access.log", "error.log"]: (Path("/var/log/angie") / f).touch(exist_ok=True) paths = ["/var/log/angie"] + glob("/var/log/angie/*.log") for pth in paths: run(["chgrp", "-h", "angie", pth], check=False) run(["chmod", "775", "/var/log/angie"], check=False) for pth in glob("/var/log/angie/*.log"): run(["chmod", "664", pth], check=False) Path("/var/log/nginx").mkdir(parents=True, exist_ok=True) Path("/var/log/nginx/error.log").touch(exist_ok=True) os.chmod("/var/log/nginx/error.log", 0o666) run(["chown", "-R", "npm:npm", "/opt/npm", "/data"]) ensure_angie_runtime_perms() def create_sudoers_for_npm(): with step("Configuring sudoers for npm -> angie"): content = """User_Alias NPMUSERS = npm NPMUSERS ALL=(root) NOPASSWD: /usr/sbin/angie """ path = Path("/etc/sudoers.d/npm") write_file(path, content, 0o440) if shutil.which("visudo"): run(["visudo", "-cf", str(path)], check=False) def adjust_nginx_like_paths_in_tree(root: Path): for p in root.rglob("*.conf"): try: txt = p.read_text(encoding="utf-8") except Exception: continue txt2 = txt.replace("include conf.d", "include /etc/nginx/conf.d").replace( "include /etc/angie/conf.d", "include /etc/nginx/conf.d" ) if txt2 != txt: p.write_text(txt2, encoding="utf-8") for cand in root.rglob("nginx.conf"): try: txt = cand.read_text(encoding="utf-8") except Exception: continue txt = re.sub(r"^user\s+\S+.*", "user root;", txt, flags=re.M) txt = re.sub(r"^pid\s+.*", "pid /run/angie/angie.pid;", txt, flags=re.M) txt = txt.replace("daemon on;", "#daemon on;") cand.write_text(txt, encoding="utf-8") def install_node_from_nodesource(version: str): _, resolved_version, warning = validate_nodejs_version(version) if warning: print(warning) match = re.match(r"(\d+)", resolved_version) if not match: raise ValueError(f"Invalid Node.js version: {version}") major_version = match.group(1) with step("Removing old Node.js installations"): run( ["apt-get", "remove", "-y", "nodejs", "npm", "libnode-dev", "libnode72"], check=False, ) run( ["apt-get", "purge", "-y", "nodejs", "npm", "libnode-dev", "libnode72"], check=False, ) run(["apt-get", "autoremove", "-y"], check=False) for f in [ "/etc/apt/sources.list.d/nodesource.list", "/etc/apt/keyrings/nodesource.gpg", "/usr/share/keyrings/nodesource.gpg", "/etc/apt/trusted.gpg.d/nodesource.gpg", ]: if Path(f).exists(): Path(f).unlink() with step(f"Installing Node.js v{major_version}.x from NodeSource repository"): setup_url = f"https://deb.nodesource.com/setup_{major_version}.x" with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False) as tf: script_path = tf.name try: run(["curl", "-fsSL", setup_url, "-o", script_path]) os.chmod(script_path, 0o755) if DEBUG: subprocess.run(["bash", script_path], check=True) else: run(["bash", script_path]) run(["apt-get", "update", "-y"]) run(["apt-get", "install", "-y", "nodejs"]) finally: if Path(script_path).exists(): os.unlink(script_path) if shutil.which("node"): node_ver = run_out(["node", "--version"], check=False).strip() installed_major = re.match(r"v?(\d+)", node_ver) if installed_major and installed_major.group(1) != major_version: print(f"⚠ WARNING: Requested Node.js v{major_version}.x but got {node_ver}") print( f" This likely means NodeSource doesn't support your distribution yet." ) if shutil.which("npm"): npm_ver = run_out(["npm", "--version"], check=False).strip() print(f" Node.js: {node_ver}") print(f" npm: {npm_ver}") else: print(f" Node.js: {node_ver}") apt_try_install(["npm"]) if shutil.which("npm"): npm_ver = run_out(["npm", "--version"], check=False).strip() print(f" npm: {npm_ver}") if not shutil.which("npm"): run(["corepack", "enable"], check=False) if shutil.which("npm"): npm_ver = run_out(["npm", "--version"], check=False).strip() print(f"\n✔ npm {npm_ver} installed successfully") else: print(f"✖ npm could not be installed - manual intervention required") else: print("✖ Node.js installation failed") raise RuntimeError("Node.js installation failed") def _yarn_version() -> str: if not shutil.which("yarn"): return "" return run_out(["yarn", "--version"], check=False).strip() def ensure_yarn_classic(): """Use the Yarn Classic version used by the current NPM 2.x build.""" expected = "1.22.22" version = _yarn_version() if version == expected: return version if not shutil.which("npm"): raise RuntimeError("npm is required to install Yarn Classic") with step(f"Installing Yarn Classic {expected}"): run_logged( ["npm", "install", "-g", f"yarn@{expected}", "--force"], Path("/tmp/npm-yarn-install.log"), timeout=600, ) version = _yarn_version() if version != expected: raise RuntimeError(f"Expected Yarn {expected}, got: {version or 'not found'}") return version def _package_scripts(project_dir: Path) -> dict: package_json = project_dir / "package.json" if not package_json.exists(): return {} try: return json.loads(package_json.read_text(encoding="utf-8")).get("scripts", {}) except Exception: return {} def _yarn_install(project_dir: Path, log_name: str): ensure_yarn_classic() cmd = ["yarn", "install", "--network-timeout", "600000"] if (project_dir / "yarn.lock").exists(): cmd.append("--frozen-lockfile") run_logged(cmd, Path(f"/tmp/{log_name}.log"), timeout=1200, cwd=project_dir) def _build_frontend(src_frontend: Path, dest_frontend: Path): log_path = Path("/tmp/npm-frontend-build.log") with step("Installing frontend dependencies"): shutil.rmtree(src_frontend / "node_modules", ignore_errors=True) _yarn_install(src_frontend, "npm-frontend-yarn") scripts = _package_scripts(src_frontend) if "locale-compile" in scripts: with step("Building locale files"): run_logged(["yarn", "locale-compile"], log_path, cwd=src_frontend) with step("Building frontend"): env = os.environ.copy() env["NODE_ENV"] = "development" env["NODE_OPTIONS"] = "--max_old_space_size=2048" try: run_logged(["yarn", "build"], log_path, timeout=1200, env=env, cwd=src_frontend) except subprocess.CalledProcessError: # Compatibility fallback for older NPM/webpack dependency trees. env["NODE_OPTIONS"] += " --openssl-legacy-provider" run_logged(["yarn", "build"], log_path, timeout=1200, env=env, cwd=src_frontend) dist_dir = src_frontend / "dist" if not dist_dir.is_dir(): raise RuntimeError(f"Frontend build output not found: {dist_dir}") with step("Installing frontend artifacts"): shutil.rmtree(dest_frontend, ignore_errors=True) shutil.copytree(dist_dir, dest_frontend) images_dst = dest_frontend / "images" for image_src in (src_frontend / "app-images", src_frontend / "public" / "images"): if image_src.is_dir(): shutil.copytree(image_src, images_dst, dirs_exist_ok=True) def install_backend_dependencies(app_dir: Path = Path("/opt/npm")): with step("Installing backend dependencies"): _yarn_install(app_dir, "npm-backend-yarn") # ========== DEPLOY FUNCTIONS ========== def inject_footer_link(src: Path) -> None: footer = src / "frontend" / "src" / "components" / "SiteFooter.tsx" if not footer.exists(): return text = footer.read_text(encoding="utf-8") if "linuxiarz.pl" in text: return marker = '' marker_pos = text.find(marker) if marker_pos < 0: return close_li = text.find("", marker_pos) if close_li < 0: return item = """
  • Auto Installer by linuxiarz.pl
  • """ insert_at = close_li + len("") footer.write_text(text[:insert_at] + item + text[insert_at:], encoding="utf-8") def _npm_ref_details(ref: str) -> tuple[str, str]: if ref.startswith("refs/heads/"): git_ref = ref.removeprefix("refs/heads/") version = f"{git_ref}-dev-{datetime.now().strftime('%Y%m%d-%H%M')}" elif ref.startswith("refs/tags/"): git_ref = ref.removeprefix("refs/tags/") version = git_ref.removeprefix("v") else: git_ref = ref version = f"{git_ref}-dev-{datetime.now().strftime('%Y%m%d-%H%M')}" return git_ref, version def _set_npm_source_version(src: Path, version: str): for rel in ("package.json", "backend/package.json", "frontend/package.json"): path = src / rel if not path.exists(): continue try: data = json.loads(path.read_text(encoding="utf-8")) data["version"] = version path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") except Exception as exc: if DEBUG: print(f" ⚠ Could not set version in {rel}: {exc}") def _download_npm_source(ref: str, prefix: str) -> tuple[Path, Path, str]: git_ref, version = _npm_ref_details(ref) tmp = Path(tempfile.mkdtemp(prefix=prefix)) try: url = f"https://codeload.github.com/{GITHUB_REPO_OWNER}/{GITHUB_REPO_NAME}/tar.gz/{git_ref}" src = download_extract_tar_gz(url, tmp) _set_npm_source_version(src, version) return src, tmp, version except Exception: shutil.rmtree(tmp, ignore_errors=True) raise def _write_database_config(src: Path): cfg = Path("/opt/npm/config/production.json") backend_pkg = src / "backend" / "package.json" client = "sqlite3" try: deps = json.loads(backend_pkg.read_text(encoding="utf-8")).get("dependencies", {}) if "better-sqlite3" in deps: client = "better-sqlite3" except Exception as exc: if DEBUG: print(f" ⚠ Could not inspect backend dependencies: {exc}") config = { "database": { "engine": "knex-native", "knex": { "client": client, "connection": {"filename": "/data/database.sqlite"}, "useNullAsDefault": True, }, } } cfg.parent.mkdir(parents=True, exist_ok=True) write_file(cfg, json.dumps(config, indent=2) + "\n") print(f" ✓ SQLite backend: {client}") def deploy_npm_app_from_git(ref: str) -> str: src, tmp, version = _download_npm_source(ref, "npm-angie-") try: with step("Preparing NPM source tree"): adjust_nginx_like_paths_in_tree(src) inject_footer_link(src) with step("Copying NPM runtime files"): rootfs = src / "docker" / "rootfs" shutil.copytree(rootfs / "var" / "www" / "html", "/var/www/html", dirs_exist_ok=True) shutil.copytree(rootfs / "etc" / "nginx", "/etc/angie", dirs_exist_ok=True) (Path("/etc/angie/conf.d/dev.conf")).unlink(missing_ok=True) shutil.copy2( rootfs / "etc" / "logrotate.d" / "nginx-proxy-manager", "/etc/logrotate.d/nginx-proxy-manager", ) shutil.copytree(src / "backend", "/opt/npm", dirs_exist_ok=True) if (src / "global").is_dir(): shutil.copytree(src / "global", "/opt/npm/global", dirs_exist_ok=True) with step("Writing database configuration"): _write_database_config(src) _build_frontend(src / "frontend", Path("/opt/npm/frontend")) install_backend_dependencies() with step("Normalizing NPM ownership"): run(["chown", "-R", "npm:npm", "/opt/npm", "/data"]) with step("Preparing ip_ranges.conf"): include_dir = Path("/etc/angie/conf.d/include") include_dir.mkdir(parents=True, exist_ok=True) ipranges = include_dir / "ip_ranges.conf" if not ipranges.exists(): write_file(ipranges, "# populated by NPM (IPv4 only)\n") run(["chown", "npm:npm", str(include_dir), str(ipranges)], check=False) os.chmod(ipranges, 0o664) return version finally: shutil.rmtree(tmp, ignore_errors=True) def strip_ipv6_listens(paths): with step("Removing IPv6 listen entries from configs (--enable-ipv6 not set)"): confs = [] for p in paths: confs.extend(Path(p).rglob("*.conf")) for f in confs: try: txt = f.read_text(encoding="utf-8") except Exception: continue new = re.sub(r"(?m)^\s*listen\s+\[::\]:\d+[^;]*;\s*$", "", txt) new = re.sub(r"\n{3,}", "\n\n", new) if new != txt: f.write_text(new, encoding="utf-8") def install_logrotate_for_data_logs(): with step("Installing logrotate policy for /var/log/angie (*.log)"): conf_path = Path("/etc/logrotate.d/angie") content = """/var/log/angie/*.log { daily rotate 1 compress missingok notifempty copytruncate create 0640 root root su root root postrotate if [ -f /run/angie/angie.pid ]; then kill -USR1 $(cat /run/angie/angie.pid) fi endscript } """ write_file(conf_path, content, 0o644) try: run(["/usr/sbin/logrotate", "-d", str(conf_path)], check=False) except Exception: pass def fix_logrotate_permissions_and_wrapper(): """Give the NPM user its own logrotate state without patching NPM sources.""" with step("Configuring logrotate wrapper for NPM"): state_dir = Path("/opt/npm/var") state_file = state_dir / "logrotate.state" state_dir.mkdir(parents=True, exist_ok=True) state_file.touch(exist_ok=True) run(["chown", "-R", "npm:npm", str(state_dir)], check=False) os.chmod(state_dir, 0o755) os.chmod(state_file, 0o664) # npm.service has /usr/local/bin before /usr/sbin in PATH. NPM therefore # gets a private state file, while root/manual logrotate keeps normal behavior. helper = Path("/usr/local/bin/logrotate") helper_content = f"""#!/bin/sh if [ \"$(id -u)\" = \"$(id -u npm 2>/dev/null)\" ]; then exec /usr/sbin/logrotate -s {state_file} \"$@\" fi exec /usr/sbin/logrotate \"$@\" """ write_file(helper, helper_content, 0o755) def write_npm_service_unit(ipv6_enabled: bool): certbot_ver = patch_npm_certbot_plugins_config() unit_lines = [ "[Unit]", "Description=Nginx Proxy Manager (backend)", "After=network.target angie.service", "Wants=angie.service", "", "[Service]", "User=npm", "Group=npm", "WorkingDirectory=/opt/npm", "Environment=NODE_ENV=production", "Environment=PATH=/opt/certbot/bin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", ] if certbot_ver: unit_lines.append(f"Environment=CERTBOT_VERSION={certbot_ver}") if not ipv6_enabled: unit_lines.append("Environment=DISABLE_IPV6=true") unit_lines += [ "ExecStart=/usr/bin/node /opt/npm/index.js", "Restart=on-failure", "RestartSec=5", "", "[Install]", "WantedBy=multi-user.target", "", ] write_file(Path("/etc/systemd/system/npm.service"), "\n".join(unit_lines), 0o644) return certbot_ver def create_systemd_units(ipv6_enabled: bool, restart: bool = True): """Write units, validate Angie, then optionally enable/restart services once.""" if NPM_ADMIN_ENABLE_SSL: generate_selfsigned_cert() write_npm_service_unit(ipv6_enabled=ipv6_enabled) write_file(Path("/etc/systemd/system/angie.service"), ANGIE_UNIT, 0o644) subprocess.run(["systemctl", "daemon-reload"], check=False) ensure_angie_log_include_files() run(["/usr/sbin/angie", "-t"], check=True, quiet=False) if restart: for service in ("angie.service", "npm.service"): run(["systemctl", "enable", service], check=False) run(["systemctl", "restart", service], check=True, quiet=False) ########### REPLACE CONFIGS ############ def update_config_file(filepath, newcontent, owner="npm:npm", mode=0o644): filepath = Path(filepath) backup_path = None if filepath.exists(): timestamp = time.strftime("%Y%m%d-%H%M%S") backup_path = filepath.parent / f"{filepath.name}.backup-{timestamp}" if filepath.read_text(encoding="utf-8", errors="replace") == newcontent: return None shutil.copy2(filepath, backup_path) if DEBUG: print(f" Backup: {backup_path}") write_file(filepath, newcontent, mode) if owner: run(["chown", owner, str(filepath)], check=False) return backup_path def update_npm_assets_config(): """ Update /etc/nginx/conf.d/include/assets.conf with optimized cache settings. """ content = """location ~* \\.(css|js|mjs|json|xml|txt|md|html|htm|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|jpe|jfif|pjpeg|pjp|png|gif|webp|avif|apng|svg|svgz|ico|bmp|tif|tiff|jxl|heic|heif|woff|woff2|ttf|otf|eot|mp3|mp4|m4a|m4v|ogg|ogv|oga|opus|wav|webm|flac|aac|mov|avi|wmv|zip|gz|bz2|tar|rar|7z|css\\.map|js\\.map)$ { proxy_cache public-cache; proxy_cache_valid 200 30m; proxy_cache_revalidate on; proxy_cache_lock on; proxy_cache_lock_timeout 5s; proxy_cache_background_update on; proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; proxy_connect_timeout 5s; proxy_read_timeout 15s; add_header X-Cache-Status $upstream_cache_status always; proxy_hide_header Age; proxy_hide_header X-Cache-Hits; proxy_hide_header X-Cache; access_log off; include /etc/angie/conf.d/include/proxy.conf; status_zone cache_assets; } """ with step("Updating NPM assets cache configuration"): return update_config_file( filepath="/etc/nginx/conf.d/include/assets.conf", newcontent=content, owner="npm:npm", mode=0o644, ) def update_ssl_ciphers_config(): content = """# Modern SSL/TLS Configuration ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384'; ssl_prefer_server_ciphers on; ssl_conf_command Ciphersuites TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384; """ with step("Updating NPM SSL/TLS cipher configuration"): return update_config_file( filepath="/etc/nginx/conf.d/include/ssl-ciphers.conf", newcontent=content, owner="npm:npm", mode=0o644, ) def update_npm_listen_template(): """Install Angie-aware listen template. Until NPM ships a separate HTTP/3 field, its per-host HTTP/2 switch controls both HTTP/2 and HTTP/3. QUIC is only attached to vhosts where that switch is on. The admin server owns the singleton `reuseport` declaration for UDP/443. """ content = """# HTTP listen 80; {% if ipv6 -%} listen [::]:80; {% else -%} #listen [::]:80; {% endif %} {% if certificate -%} # HTTPS/TCP listen 443 ssl; {% if ipv6 -%} listen [::]:443 ssl; {% else -%} #listen [::]:443 ssl; {% endif %} {% if http2_support == 1 or http2_support == true %} # HTTP/3/QUIC; reuseport is initialized once by the admin server. listen 443 quic; {% if ipv6 -%} listen [::]:443 quic; {% endif %} {% endif %} {% endif %} server_name {{ domain_names | join: " " }}; {% if certificate -%} {% if http2_support == 1 or http2_support == true %} http2 on; http3 on; {% else -%} http2 off; http3 off; {% endif %} {% endif %} status_zone {{ domain_names[0] | replace: "*.", "" | replace: ".", "_" }}; """ with step("Updating NPM listen template with HTTP/3 support"): return update_config_file( "/opt/npm/templates/_listen.conf", content, owner="npm:npm", mode=0o644, ) def update_npm_proxy_host_template(): content = """{% include "_header_comment.conf" %} {% if enabled %} #### BACKEND UPSTREAM #### {% assign bname = domain_names[0] | replace: "*.", "" | replace: ".", "_" %} upstream backend_{{ bname }} { zone {{ bname }} 1m; server {{ forward_host }}:{{ forward_port }}; keepalive 16; } {% include "_hsts_map.conf" %} server { set $forward_scheme {{ forward_scheme }}; set $server "{{ forward_host }}"; set $port {{ forward_port }}; {% include "_listen.conf" %} {% include "_certificates.conf" %} {% include "_assets.conf" %} {% include "_exploits.conf" %} {% include "_hsts.conf" %} {% include "_forced_ssl.conf" %} {% if allow_websocket_upgrade == 1 or allow_websocket_upgrade == true %} proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $http_connection; proxy_http_version 1.1; {% endif %} access_log /data/logs/proxy-host-{{ id }}_access.log proxy; error_log /data/logs/proxy-host-{{ id }}_error.log warn; {{ advanced_config }} {{ locations }} {% if use_default_location %} location / { {% include "_access.conf" %} {% include "_hsts.conf" %} proxy_set_header Host $host; proxy_set_header X-Forwarded-Scheme $scheme; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Real-IP $remote_addr; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_pass {{ forward_scheme }}://backend_{{ bname }}$request_uri; {% if allow_websocket_upgrade == 1 or allow_websocket_upgrade == true %} proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $http_connection; {% endif %} } {% endif %} include /data/nginx/custom/server_proxy[.]conf; } {% endif %} """ with step("Updating NPM proxy host template"): return update_config_file( "/opt/npm/templates/proxy_host.conf", content, owner="npm:npm", mode=0o644, ) def update_npm_location_template(): content = """ location {{ path }} { {{ advanced_config }} status_zone location_{{ forward_host }}_{{ forward_port }}_{{ path }}; proxy_set_header Host $host; proxy_set_header X-Forwarded-Scheme $scheme; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Real-IP $remote_addr; proxy_pass {{ forward_scheme }}://{{ forward_host }}:{{ forward_port }}{{ forward_path }}; {% include "_access.conf" %} {% include "_assets.conf" %} {% include "_exploits.conf" %} {% include "_forced_ssl.conf" %} {% include "_hsts.conf" %} {% if allow_websocket_upgrade == 1 or allow_websocket_upgrade == true %} proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $http_connection; proxy_http_version 1.1; {% endif %} } """ with step("Updating NPM custom location template"): return update_config_file( "/opt/npm/templates/_location.conf", content, owner="npm:npm", mode=0o644, ) def update_npm_http3_header_template(): """Add/clear Alt-Svc consistently at server and location scope.""" content = """{% if certificate %} {% if http2_support == 1 or http2_support == true %} add_header Alt-Svc 'h3=":443"; ma=86400' always; {% else %} # Clear a previously cached HTTP/3 alternative when H3 is disabled for this host. add_header Alt-Svc 'clear' always; {% endif %} {% endif %} """ return update_config_file( "/opt/npm/templates/_http3.conf", content, owner="npm:npm", mode=0o644 ) def inject_http3_include_into_host_templates(): """Place the H3 header next to HSTS wherever NPM emits response headers.""" template_dir = Path("/opt/npm/templates") hsts = '{% include "_hsts.conf" %}' h3 = '{% include "_http3.conf" %}' for name in ("proxy_host.conf", "_location.conf", "redirection_host.conf", "dead_host.conf"): path = template_dir / name if not path.exists(): continue text = path.read_text(encoding="utf-8") pair = f"{hsts}\n{h3}" # Normalize previous runs, then add the helper after every HSTS include. updated = text.replace(pair, hsts).replace(hsts, pair) if updated != text: write_file(path, updated, 0o644) run(["chown", "npm:npm", str(path)], check=False) def generate_selfsigned_cert(cert_path=None, key_path=None, days=None): cert_path = Path(cert_path or NPM_ADMIN_CERT_PATH) key_path = Path(key_path or NPM_ADMIN_KEY_PATH) days = days or NPM_ADMIN_CERT_DAYS cert_path.parent.mkdir(parents=True, exist_ok=True) if cert_path.exists() and key_path.exists(): if DEBUG: print(f" Certificate already exists: {cert_path}") return (str(cert_path), str(key_path)) if DEBUG: print(f" Generating self-signed certificate...") run( [ "openssl", "req", "-x509", "-nodes", "-days", str(days), "-newkey", "rsa:4096", "-keyout", str(key_path), "-out", str(cert_path), "-subj", "/C=US/ST=State/L=City/O=Organization/CN=nginxproxymanager", ], check=True, ) run(["chmod", "644", str(cert_path)], check=False) run(["chmod", "600", str(key_path)], check=False) run(["chown", "npm:npm", str(cert_path)], check=False) run(["chown", "npm:npm", str(key_path)], check=False) if DEBUG: print(f" Certificate created: {cert_path}") print(f" Private key created: {key_path}") return (str(cert_path), str(key_path)) def update_npm_admin_interface( enable_ssl=None, http_port=None, https_port=None, root_path=None ): """ Update NPM admin interface configuration with SSL support and redirect. Uses global configuration if parameters not provided. """ enable_ssl = NPM_ADMIN_ENABLE_SSL if enable_ssl is None else enable_ssl http_port = http_port or NPM_ADMIN_HTTP_PORT https_port = https_port or NPM_ADMIN_HTTPS_PORT root_path = root_path or NPM_ADMIN_ROOT_PATH cert_path = NPM_ADMIN_CERT_PATH key_path = NPM_ADMIN_KEY_PATH if enable_ssl: with step("Generating self-signed certificate for admin interface"): generate_selfsigned_cert() content = f"""# Admin Interface - HTTP (redirect to HTTPS) server {{ listen {http_port} default_server; server_name nginxproxymanager; add_header Alt-Svc 'h3=":{https_port}"; ma=60' always; # Redirect all HTTP traffic to HTTPS return 301 https://$host:{https_port}$request_uri; }} # Admin Interface - HTTPS server {{ listen {https_port} ssl; listen {https_port} quic reuseport; # Intentional singleton socket bootstrap for QUIC vhosts. listen 443 ssl; listen 443 quic reuseport; add_header Alt-Svc 'h3=":{https_port}"; ma=60' always; http3 on; http2 on; server_name nginxproxymanager npm-admin; # SSL Configuration ssl_certificate {cert_path}; ssl_certificate_key {key_path}; include /etc/angie/conf.d/include/ssl-ciphers.conf; status_zone npm_admin; root {root_path}; access_log off; location /api {{ return 302 /api/; }} location /api/ {{ proxy_set_header Host $host; proxy_set_header X-Forwarded-Scheme $scheme; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Real-IP $remote_addr; proxy_pass http://127.0.0.1:3000/; proxy_read_timeout 15m; proxy_send_timeout 15m; }} location / {{ etag off; index index.html; if ($request_uri ~ ^/(.*)\\.html$) {{ return 302 /$1; }} try_files $uri $uri.html $uri/ /index.html; }} }} """ else: # Configuration without SSL (original) content = f"""# Admin Interface server {{ listen {http_port} default_server; server_name nginxproxymanager npm-admin; root {root_path}; access_log /dev/null; status_zone npm_admin; location /api {{ return 302 /api/; }} location /api/ {{ proxy_set_header Host $host; proxy_set_header X-Forwarded-Scheme $scheme; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Real-IP $remote_addr; proxy_pass http://127.0.0.1:3000/; proxy_read_timeout 15m; proxy_send_timeout 15m; }} location / {{ etag off; index index.html; if ($request_uri ~ ^/(.*)\\.html$) {{ return 302 /$1; }} try_files $uri $uri.html $uri/ /index.html; }} }} """ with step("Updating NPM admin interface configuration"): return update_config_file( filepath="/etc/nginx/conf.d/production.conf", newcontent=content, owner="npm:npm", mode=0o644, ) def update_npm_stream_template(): """ Update /opt/npm/templates/stream.conf with status_zone monitoring. """ content = """# ------------------------------------------------------------ # {{ incoming_port }} TCP: {{ tcp_forwarding }} UDP: {{ udp_forwarding }} # ------------------------------------------------------------ {% if enabled %} {% if tcp_forwarding == 1 or tcp_forwarding == true -%} server { listen {{ incoming_port }} {%- if certificate %} ssl reuseport{%- endif %}; {% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} {%- if certificate %} ssl reuseport{%- endif %}; {%- include "_certificates_stream.conf" %} proxy_pass {{ forwarding_host }}:{{ forwarding_port }}; access_log /data/logs/stream-{{ id }}_access.log stream; error_log /data/logs/stream-{{ id }}_error.log warn; status_zone stream_tcp_{{ incoming_port }}_{{ forwarding_port }}; # Custom include /data/nginx/custom/server_stream[.]conf; include /data/nginx/custom/server_stream_tcp[.]conf; } {% endif %} {% if udp_forwarding == 1 or udp_forwarding == true -%} server { listen {{ incoming_port }} udp reuseport; {% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} udp reuseport; proxy_pass {{ forwarding_host }}:{{ forwarding_port }}; access_log /data/logs/stream-{{ id }}_access.log stream; error_log /data/logs/stream-{{ id }}_error.log warn; status_zone stream_udp_{{ incoming_port }}_{{ forwarding_port }}; # Custom include /data/nginx/custom/server_stream[.]conf; include /data/nginx/custom/server_stream_udp[.]conf; } {% endif %} {% endif %} """ with step("Updating NPM stream template"): return update_config_file( filepath="/opt/npm/templates/stream.conf", newcontent=content, owner="npm:npm", mode=0o644, ) def gather_versions(npm_app_version: str): _ips = run_out(["hostname", "-I"], check=False) or "" ip = (_ips.split() or [""])[0] angie_out = ( (run_out(["angie", "-v"], check=False) or "") + "\n" + (run_out(["angie", "-V"], check=False) or "") ) m = re.search(r"(?i)\bangie\s*/\s*([0-9]+(?:\.[0-9]+)+)\b", angie_out) if not m: dp = ( run_out(["dpkg-query", "-W", "-f=${Version}", "angie"], check=False) or "" ).strip() m = re.search(r"([0-9]+(?:\.[0-9]+)+)", dp) angie_v = m.group(1) if m else (angie_out.strip() or "") node_v = (run_out(["node", "-v"], check=False) or "").strip().lstrip("v") yarn_v = (run_out(["yarn", "-v"], check=False) or "").strip() if not yarn_v: yarn_v = (run_out(["yarnpkg", "-v"], check=False) or "").strip() return ip, angie_v, node_v, yarn_v, npm_app_version def update_motd( enabled: bool, info, ipv6_enabled: bool, npm_version: str = None, installed_from_branch: bool = False, ): if not enabled: return ip, angie_v, node_v, yarn_v, npm_v = info ipv6_line = ( "IPv6: enabled (configs untouched)." if ipv6_enabled else "IPv6: disabled in resolvers and conf." ) protocol = "https" if NPM_ADMIN_ENABLE_SSL else "http" port = NPM_ADMIN_HTTPS_PORT if NPM_ADMIN_ENABLE_SSL else NPM_ADMIN_HTTP_PORT npm_line = f"Nginx Proxy Manager: {protocol}://{ip}:{port}" npm_source = ( f"Source: branch ({npm_version})" if installed_from_branch else f"Source: release {npm_version}" ) text = f""" ################################ NPM / ANGIE ################################ OS: {OSREL['PRETTY']} ({OSREL['ID']} {OSREL['VERSION_ID']}) {npm_line} Angie & Prometheus stats: http://{ip}:82/console | http://{ip}:82/p8s or https://{ip}:8282/console | https://{ip}:8282/p8s Angie: {angie_v} (conf: /etc/angie -> /etc/nginx, reload: angie -s reload) Node.js: v{node_v} Yarn: v{yarn_v} NPM: {npm_v} {npm_source} Paths: app=/opt/npm data=/data cache=/var/lib/angie/cache certbot=/opt/certbot {ipv6_line} ########################################################################### """ motd_d = Path("/etc/motd.d") if motd_d.exists(): write_file(motd_d / "10-npm-angie", text.strip() + "\n", 0o644) else: motd = Path("/etc/motd") existing = motd.read_text(encoding="utf-8") if motd.exists() else "" pattern = re.compile( r"################################ NPM / ANGIE ################################.*?###########################################################################\n", re.S, ) if pattern.search(existing): content = pattern.sub(text.strip() + "\n", existing) else: content = ( (existing.rstrip() + "\n\n" + text.strip() + "\n") if existing else (text.strip() + "\n") ) write_file(motd, content, 0o644) def print_summary(info, ipv6_enabled: bool, update_mode: bool, npm_version: str): ip, angie_v, node_v, yarn_v, npm_v = info mode = "UPDATE" if update_mode else "INSTALL" panel_scheme = "https" if NPM_ADMIN_ENABLE_SSL else "http" panel_port = NPM_ADMIN_HTTPS_PORT if NPM_ADMIN_ENABLE_SSL else NPM_ADMIN_HTTP_PORT print("\n====================== SUMMARY ======================") print(f"OS: {OSREL['PRETTY']}") print(f"Mode: {mode}") print(f"NPM panel: {panel_scheme}://{ip}:{panel_port}") print(f"Angie stats: http://{ip}:82/console | https://{ip}:8282/console") print(f"Prometheus: http://{ip}:82/p8s | https://{ip}:8282/p8s") print(f"Angie: v{angie_v}") print(f"Node.js: v{node_v}") print(f"Yarn: v{yarn_v}") print(f"NPM: {npm_v}") print(f"IPv6: {'ENABLED' if ipv6_enabled else 'DISABLED'}") print("Config test: /usr/sbin/angie -t") print(f"Installed source: {npm_version}") print("==========================================================\n") # ========== UPDATE-ONLY ========== def _backup_before_update(target_version: str) -> Path: timestamp = time.strftime("%Y%m%d-%H%M%S") backup_root = Path("/data/backups") backup_dir = backup_root / f"npm-backup-{timestamp}" backup_root.mkdir(parents=True, exist_ok=True) try: if Path("/opt/npm").exists(): shutil.copytree("/opt/npm", backup_dir / "opt_npm", dirs_exist_ok=True) for source, name in ( (Path("/data/database.sqlite"), "database.sqlite"), (Path("/data/letsencrypt"), "letsencrypt"), (Path("/data/nginx"), "nginx"), ): if source.is_dir(): shutil.copytree(source, backup_dir / name, dirs_exist_ok=True) elif source.exists(): shutil.copy2(source, backup_dir / name) (backup_dir / "backup_info.json").write_text( json.dumps( { "backup_date": timestamp, "update_to_version": target_version, "backup_path": str(backup_dir), }, indent=2, ) + "\n", encoding="utf-8", ) except Exception as exc: print(f"⚠ Backup failed: {exc}") if input("Continue update anyway? [y/N]: ").strip().lower() not in ("y", "yes"): raise RuntimeError("Update cancelled because backup failed") from exc backups = sorted(backup_root.glob("npm-backup-*")) for old_backup in backups[:-3]: shutil.rmtree(old_backup, ignore_errors=True) print(f" Backup: {backup_dir}") return backup_dir def _sync_npm_include_files(src: Path): include_src = src / "docker" / "rootfs" / "etc" / "nginx" / "conf.d" / "include" include_dst = Path("/etc/angie/conf.d/include") if not include_src.is_dir(): return safe_names = { "force-ssl.conf", "proxy.conf", "assets.conf", "exploits.conf", "hsts.conf", "log.conf", "log-proxy.conf", "log-stream.conf", } include_dst.mkdir(parents=True, exist_ok=True) timestamp = time.strftime("%Y%m%d-%H%M%S") for source in include_src.glob("*.conf"): if source.name not in safe_names: continue target = include_dst / source.name if target.exists() and filecmp.cmp(source, target, shallow=False): continue if target.exists(): shutil.copy2(target, target.with_name(f"{target.name}.backup-{timestamp}")) shutil.copy2(source, target) print(f" ✓ Synced: {source.name}") for conf in include_dst.glob("*.conf"): run(["chown", "npm:npm", str(conf)], check=False) ensure_angie_log_include_files() def _update_angie_main_config(): path = Path("/etc/angie/angie.conf") if path.exists() and path.read_text(encoding="utf-8") == ANGIE_CONF_TEMPLATE: return if path.exists(): backup = path.with_name(f"angie.conf.backup-{time.strftime('%Y%m%d-%H%M%S')}") shutil.copy2(path, backup) print(f" ✓ Backed up {path.name} to {backup.name}") write_file(path, ANGIE_CONF_TEMPLATE, 0o644) def _replace_backend_preserving_config(src: Path): backend_src = src / "backend" if not backend_src.is_dir(): raise RuntimeError("NPM source does not contain backend/") app = Path("/opt/npm") config_backup = Path(tempfile.mkdtemp(prefix="npm-config-")) / "config" try: if (app / "config").is_dir(): shutil.copytree(app / "config", config_backup) for item in app.iterdir(): if item.name in {"frontend", "config"}: continue if item.is_dir() and not item.is_symlink(): shutil.rmtree(item) else: item.unlink(missing_ok=True) for item in backend_src.iterdir(): target = app / item.name if item.is_dir(): shutil.copytree(item, target, dirs_exist_ok=True) else: shutil.copy2(item, target) global_src = src / "global" global_dst = app / "global" if global_dst.exists(): shutil.rmtree(global_dst) if global_src.is_dir(): shutil.copytree(global_src, global_dst) else: global_dst.mkdir(parents=True, exist_ok=True) if config_backup.is_dir(): shutil.copytree(config_backup, app / "config", dirs_exist_ok=True) else: (app / "config").mkdir(parents=True, exist_ok=True) finally: shutil.rmtree(config_backup.parent, ignore_errors=True) def update_npm_app_from_git(ref: str, node_version: str | None) -> str: cleanup_build_artifacts() src, tmp, version = _download_npm_source(ref, "npm-update-") try: with step("Creating backup before update"): _backup_before_update(version) apt_update_upgrade() ensure_minimum_nodejs(user_requested_version=node_version) with step("Preparing NPM source tree"): adjust_nginx_like_paths_in_tree(src) inject_footer_link(src) _build_frontend(src / "frontend", Path("/opt/npm/frontend")) with step("Synchronizing NPM include files"): _sync_npm_include_files(src) with step("Updating Angie main configuration"): _update_angie_main_config() with step("Updating backend (preserving config)"): _replace_backend_preserving_config(src) install_backend_dependencies() ensure_certbot_venv_ready() configure_letsencrypt() with step("Normalizing ownership"): run(["chown", "-R", "npm:npm", "/opt/npm"]) Path("/etc/angie/conf.d/dev.conf").unlink(missing_ok=True) return version finally: shutil.rmtree(tmp, ignore_errors=True) def apply_custom_configuration(): """Apply installer-owned NPM templates after upstream files are in place.""" comment_x_served_by_step() set_file_ownership(["/etc/angie/conf.d/include/ip_ranges.conf"], "npm:npm", 0o664) update_ssl_ciphers_config() update_npm_assets_config() update_npm_admin_interface() update_npm_proxy_host_template() update_npm_location_template() update_npm_listen_template() update_npm_stream_template() update_npm_http3_header_template() inject_http3_include_into_host_templates() def validate_npm_release(version: str): if parse_version(version) < (2, 13, 0): raise ValueError(f"NPM {version} is unsupported; minimum version is 2.13.0") def resolve_npm_ref(args, stored_config: dict) -> tuple[str, bool, str | None]: """Return (git ref, installed_from_branch, branch_name).""" if args.branch: return f"refs/heads/{args.branch}", True, args.branch if args.npm_version: validate_npm_release(args.npm_version) return f"refs/tags/v{args.npm_version}", False, None if args.update and stored_config.get("installed_from_branch"): branch = stored_config.get("branch") or "master" return f"refs/heads/{branch}", True, branch version = github_latest_release_tag(f"{GITHUB_REPO_OWNER}/{GITHUB_REPO_NAME}") validate_npm_release(version) print(f"✓ Latest stable NPM: {version}") return f"refs/tags/v{version}", False, None def main() -> int: global DEBUG ensure_root() parser = argparse.ArgumentParser( description="Install or update Nginx Proxy Manager on Angie.", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) parser.add_argument( "--node-version", default=None, help=f"Node.js major from NodeSource (20-{MAX_NODEJS_VERSION}); otherwise auto-detect.", ) parser.add_argument("--nodejs-pkg", default=None, help=argparse.SUPPRESS) source = parser.add_mutually_exclusive_group() source.add_argument( "--npm-version", "--version", dest="npm_version", default=None, help="NPM release version (>= 2.13.0); default: latest stable.", ) source.add_argument( "--branch", default=None, metavar="BRANCH", help="Install/update from a Git branch instead of a release tag.", ) parser.add_argument("--update", action="store_true", help="Update the existing installation.") parser.add_argument("--motd", choices=["yes", "no"], default="yes") ipv6 = parser.add_mutually_exclusive_group() ipv6.add_argument("--enable-ipv6", dest="ipv6_enabled", action="store_true") ipv6.add_argument("--disable-ipv6", dest="ipv6_enabled", action="store_false") parser.set_defaults(ipv6_enabled=None) parser.add_argument("--debug", action="store_true", help="Show detailed command output.") args = parser.parse_args() DEBUG = args.debug # Backward-compatible interpretation of the old, previously broken option. if args.nodejs_pkg and not args.node_version: match = re.search(r"(\d+)", args.nodejs_pkg) if match: args.node_version = match.group(1) if not any((args.npm_version, args.branch, args.update)): print("\nNo installation mode selected. Starting interactive mode...") args = apply_interactive_choices(args, interactive_install_mode()) print("\n" + "=" * 70) print("UPDATE" if args.update else "INSTALL") if args.branch: print(f"Source: branch {args.branch}") elif args.npm_version: print(f"Source: release {args.npm_version}") else: print("Source: latest stable release") print("=" * 70) if input("Proceed? [Y/n]: ").strip().lower() not in ("", "y", "yes"): print("Installation cancelled.") return 0 stored_config = load_installer_config() if args.update else {} stored_ipv6 = bool(stored_config.get("ipv6_enabled", False)) ipv6_enabled = stored_ipv6 if args.ipv6_enabled is None and args.update else bool(args.ipv6_enabled) ref, installed_from_branch, branch_name = resolve_npm_ref(args, stored_config) validate_supported_os() swap_state = {} try: swap_state = check_memory_and_create_swap() if not swap_state.get("ready", True): return 1 print("\n================== NPM + ANGIE installer ==================") print(f"Repository: {GITHUB_REPO_URL}") print(f"OS: {OSREL['PRETTY']}") print(f"Mode: {'UPDATE' if args.update else 'INSTALL'}") print(f"Source: {ref}") print("===========================================================\n") if args.update: install_logrotate_for_data_logs() fix_logrotate_permissions_and_wrapper() npm_app_version = update_npm_app_from_git(ref, args.node_version) else: apt_update_upgrade() apt_purge(["nginx", "openresty", "nodejs", "npm", "yarn", "certbot"]) apt_install([ "ca-certificates", "curl", "gnupg", "apt-transport-https", "openssl", "apache2-utils", "logrotate", "sudo", "acl", "python3", "sqlite3", "git", "lsb-release", "build-essential", ]) setup_angie(ipv6_enabled=ipv6_enabled) ensure_nginx_symlink() ensure_user_and_dirs() create_sudoers_for_npm() write_metrics_files() ensure_minimum_nodejs(user_requested_version=args.node_version) ensure_certbot_venv_ready() configure_letsencrypt() npm_app_version = deploy_npm_app_from_git(ref) install_logrotate_for_data_logs() fix_logrotate_permissions_and_wrapper() ensure_nginx_symlink() apply_custom_configuration() if args.update: print("ℹ NPM templates updated; existing host configs use them after the host is saved again.") if not ipv6_enabled: strip_ipv6_listens([Path("/etc/angie"), Path("/data/nginx")]) # Validate the final generated configuration, then restart exactly once. create_systemd_units(ipv6_enabled=ipv6_enabled, restart=True) save_installer_config({ "ipv6_enabled": ipv6_enabled, "node_version": args.node_version, "npm_version": npm_app_version, "installed_from_branch": installed_from_branch, "branch": branch_name, }) info = gather_versions(npm_app_version) update_motd( args.motd == "yes", info, ipv6_enabled=ipv6_enabled, npm_version=npm_app_version, installed_from_branch=installed_from_branch, ) print_summary(info, ipv6_enabled, args.update, npm_app_version) return 0 except KeyboardInterrupt: print("\nAborted by user") return 130 finally: cleanup_swap(swap_state) if __name__ == "__main__": sys.exit(main())