From 095cd0499e741d6ba907931313c0b61acea97dd0 Mon Sep 17 00:00:00 2001 From: gru <1+gru@gitea.linuxiarz.pl> Date: Thu, 10 Sep 2026 10:27:35 +0200 Subject: [PATCH] Update npm_install.py --- npm_install.py | 3432 ++++++++++++++---------------------------------- 1 file changed, 982 insertions(+), 2450 deletions(-) diff --git a/npm_install.py b/npm_install.py index 2cad11e..99114a3 100644 --- a/npm_install.py +++ b/npm_install.py @@ -1,26 +1,35 @@ #!/usr/bin/env python3 -""" -NPM Auto-Installer -==================================================== +"""Native Nginx Proxy Manager + Angie installer. -For legacy installations (< 2.13.0, use npm_install_multiversion.py - -Usage: - ./npm_install.py # Install latest stable >= 2.13.0 - ./npm_install.py --version 2.13.0 # Install specific version (>= 2.13.0) - ./npm_install.py --branch master # Install from master branch - ./npm_install.py --branch develop # Install from develop branch +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, os, sys, json, shutil, subprocess, tarfile, tempfile, urllib.request, re, time, threading, signal, shutil, filecmp -from pathlib import Path -from glob import glob -from datetime import datetime -from pathlib import Path +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 @@ -56,144 +65,84 @@ SWAP_SIZE_GB = 2.0 class Spinner: + FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") - FRAMES = { - "dots": ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], - "line": ["|", "/", "-", "\\"], - "arrow": ["←", "↖", "↑", "↗", "→", "↘", "↓", "↙"], - "braille": ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"], - "circle": ["◐", "◓", "◑", "◒"], - "bounce": ["⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"], - } - - def __init__(self, text, style="dots"): + def __init__(self, text): self.text = text - self.style = style - self.frames = self.FRAMES.get(style, self.FRAMES["dots"]) self._stop_event = threading.Event() - self._lock = threading.Lock() self._thread = None self._frame_index = 0 - self._is_running = False def _spin(self): - try: - while not self._stop_event.is_set(): - with self._lock: - 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) - except Exception: - pass + 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: - print(f"• {self.text} ...") - return self - - if not sys.stdout.isatty(): - print(f"• {self.text} ...") - return self - - with self._lock: - if not self._is_running: - self._stop_event.clear() - self._frame_index = 0 - self._thread = threading.Thread(target=self._spin, daemon=True) - self._thread.start() - self._is_running = True - return self - - def stop_ok(self, final_text=None): - text = final_text or self.text - self._stop(f"✔ {text}", " " * 20) - - def stop_fail(self, final_text=None): - text = final_text or self.text - self._stop(f"✖ {text}", " " * 20) - - def stop_warning(self, final_text=None): - text = final_text or self.text - self._stop(f"⚠ {text}", " " * 20) - - def _stop(self, message, padding=""): if DEBUG or not sys.stdout.isatty(): - print(message) - self._is_running = False + print(f"• {self.text} ...") return + self._thread = threading.Thread(target=self._spin, daemon=True) + self._thread.start() - with self._lock: - self._stop_event.set() - self._is_running = False - + 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{message}{padding}\n") + sys.stdout.write(f"\r\033[K{'✔' if ok else '✖'} {self.text}\n") sys.stdout.flush() - def update_text(self, new_text): - with self._lock: - self.text = new_text - - def __enter__(self): - self.start() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - if exc_type is not None: - self.stop_fail() - else: - self.stop_ok() - return False - @contextmanager -def step(text, style="dots"): - spinner = Spinner(text, style=style) +def step(text): + spinner = Spinner(text) spinner.start() try: - yield spinner - spinner.stop_ok() - except Exception as e: - spinner.stop_fail() + yield + except BaseException: + spinner.stop(False) raise - - -def signal_handler(signum, frame): - sys.stdout.write("\r\033[K") - sys.stdout.flush() - print("\nAborted by user") - sys.exit(130) - - -signal.signal(signal.SIGINT, signal_handler) - + else: + spinner.stop(True) def _devnull(): return subprocess.DEVNULL if not DEBUG else None -def run(cmd, timeout=600, check=True, env=None): - """Default 10 minues tomeout for yarn/node """ +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, - stdout=None if DEBUG else subprocess.DEVNULL, - stderr=None if DEBUG else subprocess.DEVNULL, + 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): +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, capture_output=True, text=True) + result = subprocess.run( + cmd, + check=check, + cwd=str(cwd) if cwd else None, + capture_output=True, + text=True, + ) return result.stdout @@ -270,312 +219,176 @@ def write_file(path: Path, content: str, mode=0o644): os.chmod(path, mode) -def append_unique(path: Path, lines: str): - path.parent.mkdir(parents=True, exist_ok=True) - existing = path.read_text(encoding="utf-8") if path.exists() else "" - out = existing - for line in lines.splitlines(): - if line.strip() and line not in existing: - out += ("" if out.endswith("\n") else "\n") + line + "\n" - path.write_text(out, encoding="utf-8") -def parse_version(version_str: str) -> tuple: - try: - parts = re.match(r"(\d+)\.(\d+)\.(\d+)", version_str.strip()) - if parts: - return (int(parts.group(1)), int(parts.group(2)), int(parts.group(3))) - return (0, 0, 0) - except: - return (0, 0, 0) +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(): - """ - Interactive mode - asks user for installation preferences when no args provided. - Returns dict with user choices. - """ - # DEFAULT: Tagged release (stable) instead of branch - print("="*70) + print("=" * 70) print("NGINX PROXY MANAGER - INTERACTIVE INSTALLATION") - print("="*70) - - print("1. Select mode") - print(" 1) Fresh Install (default)") - print(" 2) Update existing installation") - mode_choice = input(" choice [1]: ").strip() or "1" - is_update = (mode_choice == "2") - - if is_update: - print(" Update mode selected") + print("=" * 70) + + if (input("1) Fresh install 2) Update existing [1]: ").strip() or "1") == "2": return {"update": True} - - print("2. Installation source") - print(" 1) Tagged release (stable version) - recommended") - print(" 2) Branch (master) - latest development") - source_choice = input(" choice [1]: ").strip() or "1" - - if source_choice == "2": - print("3. Select branch") - print(" 1) master (default)") - print(" 2) dev") - print(" 3) custom branch name") - branch_choice = input(" choice [1]: ").strip() or "1" - - if branch_choice == "1": - branch_name = "master" - elif branch_choice == "2": - branch_name = "dev" - else: - branch_name = input(" Enter custom branch name: ").strip() or "master" - - print(f" Installing from branch: {branch_name}") - return { - "update": False, - "branch": branch_name, - "npm_version": None, - } - else: - # INSTALL TAG - print("3. Select NPM version") - print(" 1) Latest stable release (auto-detect)") - print(" 2) Specific version (e.g., 2.13.2)") - version_choice = input(" choice [1]: ").strip() or "1" - - if version_choice == "1": - npm_version = None - print(" ✓ Will install latest stable release") - else: - npm_version = input(" Enter version (e.g., 2.13.2): ").strip() - if npm_version: - print(f" ✓ Will install NPM v{npm_version}") - else: - npm_version = None - print(" ✓ Will install latest stable release") - - return { - "update": False, - "branch": None, - "npm_version": npm_version, - } + + 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): - """Apply interactive mode choices to argparse args.""" args.update = choices.get("update", False) - args.branch = choices.get("branch", None) - args.npm_version = choices.get("npm_version", None) - - if DEBUG: - print(f"DEBUG: Interactive choices applied:") - print(f" - update: {args.update}") - print(f" - branch: {args.branch}") - print(f" - npm_version: {args.npm_version}") - print(f" - Logic: branch={args.branch is not None}, npm_version={args.npm_version is not None}") - - if args.branch is None and args.npm_version is None: - print(f" → Installing from LATEST RELEASE tag (auto-detect latest)") - elif args.branch is not None and args.npm_version is None: - print(f" → Installing from BRANCH: {args.branch}") - elif args.npm_version is not None: - print(f" → Installing from TAG/VERSION: {args.npm_version}") - + args.branch = choices.get("branch") + args.npm_version = choices.get("npm_version") return args -def check_memory_and_create_swap(): - """Check available memory and create swap if needed - portable version.""" + +def _memory_gb(): + """Return total and available RAM in GiB using /proc when possible.""" try: + values = {} + for line in Path("/proc/meminfo").read_text().splitlines(): + key, value = line.split(":", 1) + values[key] = int(value.split()[0]) + total = values.get("MemTotal", 0) / (1024**2) + available = values.get("MemAvailable", values.get("MemFree", 0)) / (1024**2) + return total, available + except Exception: try: - import psutil + page_size = os.sysconf("SC_PAGE_SIZE") + total = page_size * os.sysconf("SC_PHYS_PAGES") / (1024**3) + available = page_size * os.sysconf("SC_AVPHYS_PAGES") / (1024**3) + return total, available + except Exception: + return 0.0, 0.0 - total_memory_gb = psutil.virtual_memory().total / (1024**3) - available_memory_gb = psutil.virtual_memory().available / (1024**3) - except ImportError: - try: - with open("/proc/meminfo", "r") as f: - meminfo = {} - for line in f: - key, val = line.split(":") - meminfo[key.strip()] = int(val.split()[0]) - total_memory_gb = meminfo.get("MemTotal", 0) / (1024**2) - available_memory_gb = meminfo.get( - "MemAvailable", meminfo.get("MemFree", 0) - ) / (1024**2) - except: - try: - total_memory = os.sysconf("SC_PAGE_SIZE") * os.sysconf( - "SC_PHYS_PAGES" - ) - available_memory = os.sysconf("SC_PAGE_SIZE") * os.sysconf( - "SC_PAGESIZE" - ) - total_memory_gb = total_memory / (1024**3) - available_memory_gb = available_memory / (1024**3) - except: - if DEBUG: - print( - "⚠ Could not detect system memory, assuming 2 GB available" - ) - return {"total_gb": 2.0, "available_gb": 2.0, "needs_swap": False} +def check_memory_and_create_swap(): + """Create temporary swap only when needed and remember what we changed.""" + total_memory_gb, available_memory_gb = _memory_gb() + state = {"created": False, "activated_existing": False} - print(f"\n{'='*70}") - print("MEMORY CHECK") - print(f"{'='*70}") + print(f"\n{'='*70}") + print("MEMORY CHECK") + print(f"{'='*70}") + if total_memory_gb: print(f"Total RAM: {total_memory_gb:.1f} GB") print(f"Available: {available_memory_gb:.1f} GB") - print(f"Threshold: {MIN_MEMORY_GB} GB") - - memory_info = { - "total_gb": total_memory_gb, - "available_gb": available_memory_gb, - "needs_swap": available_memory_gb < MIN_MEMORY_GB, - } - - if memory_info["needs_swap"]: - print( - f"⚠ Low memory detected! ({available_memory_gb:.1f} GB < {MIN_MEMORY_GB} GB)" - ) - - swap_file = Path("/swapfile") - - try: - swapon_output = run_out(["swapon", "--show"], check=False) - if swapon_output and "/swapfile" in swapon_output: - print(f"✓ Swap file (/swapfile) already active") - print(f"{'='*70}\n") - return memory_info - except Exception as e: - if DEBUG: - print(f" Debug: swapon check failed: {e}") - - if swap_file.exists(): - print(f"✓ Swap file already exists at /swapfile") - file_size_bytes = swap_file.stat().st_size - file_size_gb = file_size_bytes / (1024**3) - print(f" File size: {file_size_gb:.1f} GB") - - try: - run(["swapon", str(swap_file)], check=False) - except: - pass - - print(f"{'='*70}\n") - return memory_info - - print(f"Creating {SWAP_SIZE_GB} GB swap file at /swapfile...") - - try: - with step("Creating swap file"): - run( - [ - "dd", - "if=/dev/zero", - f"of={swap_file}", - f"bs=1G", - f"count={int(SWAP_SIZE_GB)}", - ] - ) - run(["chmod", "600", str(swap_file)]) - run(["mkswap", str(swap_file)]) - run(["swapon", str(swap_file)]) - print(f"✓ Swap ({SWAP_SIZE_GB} GB) created and activated") - except Exception as e: - print(f"⚠ Could not create swap: {e}") - print(f" Continuing anyway, installation may be slower...") - else: - print( - f"✓ Memory sufficient ({available_memory_gb:.1f} GB >= {MIN_MEMORY_GB} GB)" - ) - + else: + print("RAM detection: unavailable") + print("Continuing without automatic swap changes.") print(f"{'='*70}\n") - return memory_info + return state + print(f"Threshold: {MIN_MEMORY_GB} GB") - except Exception as e: - print(f"⚠ Error checking memory: {e}") - print(f" Assuming sufficient memory and continuing...") - return {"total_gb": 2.0, "available_gb": 2.0, "needs_swap": False} + if available_memory_gb >= MIN_MEMORY_GB: + print("✓ Memory sufficient") + print(f"{'='*70}\n") + return state + + swap_file = Path("/swapfile") + print(f"⚠ Low available memory ({available_memory_gb:.1f} GB)") + + try: + active_swap = run_out(["swapon", "--show=NAME", "--noheadings"], check=False) + if str(swap_file) in active_swap.split(): + print("✓ /swapfile is already active; leaving it untouched") + print(f"{'='*70}\n") + return state + except Exception: + pass + + if swap_file.exists(): + with step("Activating existing /swapfile"): + run(["swapon", str(swap_file)]) + state["activated_existing"] = True + print("✓ Existing /swapfile activated temporarily") + 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 BaseException: + # The caller cannot receive `state` if this function fails before return, + # so clean up a partially created swap file here. + if state["created"]: + run(["swapoff", str(swap_file)], check=False) + swap_file.unlink(missing_ok=True) + raise + + print("✓ Temporary swap created and activated") + print(f"{'='*70}\n") + return state def cleanup_build_artifacts(): - - with step("Cleaning up old build artifacts"): - tmp_patterns = [ - "/tmp/npm-*", - "/tmp/yarn-*", - "/tmp/node-*", - "/tmp/v8-compile-cache-*", - "/tmp/npm-angie-*", - "/tmp/npm-update-*", - ] - - removed_count = 0 - for pattern in tmp_patterns: - try: - matches = glob(pattern) - for path in matches: - p = Path(path) - if p.exists(): - try: - if p.is_dir(): - shutil.rmtree(p, ignore_errors=True) - else: - p.unlink() - removed_count += 1 - if DEBUG: - print(f" ✓ Removed: {path}") - except Exception as e: - if DEBUG: - print(f" ⚠ Could not remove {path}: {e}") - except Exception as e: - if DEBUG: - print(f" ⚠ Error processing pattern {pattern}: {e}") - - if DEBUG and removed_count > 0: - print(f" Cleaned {removed_count} items from /tmp") - - with step("Cleaning up Yarn cache directories"): - yarn_cache_dirs = [ - Path("/root/.yarn"), - Path.home() / ".yarn", - Path("/root/.cache/yarn"), - Path.home() / ".cache" / "yarn", - ] - - for cache_dir in yarn_cache_dirs: - if cache_dir.exists(): + 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" Cleaning {cache_dir}...") - - for subdir in ["cache", "global", "install-state.gz"]: - target = cache_dir / subdir - if target.exists(): - if target.is_dir(): - shutil.rmtree(target, ignore_errors=True) - else: - target.unlink() - if DEBUG: - print(f" ✓ Removed {target}") - - - except Exception as e: - if DEBUG: - print(f" ⚠ Could not clean {cache_dir}: {e}") + 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) -def cleanup_swap(): - """ - Removes temporary swap if it was created. - """ + 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: - swap_file = Path("/swapfile") - if swap_file.exists(): - with step("Cleaning up swap"): + if state.get("created"): + with step("Removing temporary swap"): run(["swapoff", str(swap_file)], check=False) - swap_file.unlink() - print("✓ Temporary swap removed") - except Exception as e: - print(f"⚠ Could not remove swap: {e}") + 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: @@ -584,12 +397,10 @@ def github_latest_release_tag(repo: str, override: str = None) -> str: 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"Downloading from GitGub: {repo}"): - with urllib.request.urlopen(url) as r: - data = json.load(r) - tag = data["tag_name"] - return tag.lstrip("v") + 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") @@ -701,7 +512,7 @@ def write_resolvers_conf(ipv6_enabled: bool): def validate_nodejs_version(version: str) -> tuple[bool, str, str | None]: - version_map = {"latest": "21", "lts": "18", "current": "21"} + version_map = {"latest": str(MAX_NODEJS_VERSION), "lts": str(MAX_NODEJS_VERSION), "current": str(MAX_NODEJS_VERSION)} resolved = version_map.get(version.lower(), version) @@ -711,14 +522,20 @@ def validate_nodejs_version(version: str) -> tuple[bool, str, str | None]: major_version = int(match.group(1)) - if major_version > MAX_NODEJS_VERSION: - warning = ( - f"⚠ WARNING: Requested Node.js v{major_version} exceeds maximum tested version (v{MAX_NODEJS_VERSION}).\n" - f" NPM may not be compatible with Node.js v{major_version}.\n" - f" Falling back to Node.js v{MAX_NODEJS_VERSION}." + 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 False, str(MAX_NODEJS_VERSION), warning - return True, resolved, None @@ -726,14 +543,14 @@ def validate_supported_os(): distro_id = OSREL.get("ID", "").lower() version_id = OSREL.get("VERSION_ID", "").strip() - SUPPORTED = {"debian": ["12", "13"], "ubuntu": ["20.04", "22.04", "24.04"]} + 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 12 (Bookworm), 13 (Trixie) (recommended)") - print(f" • Ubuntu 20.04 LTS, 22.04 LTS, 24.04 LTS, 26.04 LTS (recommended)") + 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") @@ -804,7 +621,9 @@ def load_installer_config() -> dict: def comment_x_served_by_step(path="/etc/angie/conf.d/include/proxy.conf"): p = Path(path) if not p.exists(): - raise FileNotFoundError(path) + 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 @@ -827,40 +646,22 @@ def comment_x_served_by_step(path="/etc/angie/conf.d/include/proxy.conf"): def set_file_ownership(files: list[str | Path], owner: str, mode: int | None = None): - success = [] - failed = [] - - for file_path in files: - path = Path(file_path) - + ok = True + for raw_path in files: + path = Path(raw_path) if not path.exists(): - failed.append((str(path), "File not found")) + if DEBUG: + print(f" ⊘ Missing: {path}") + ok = False continue - try: run(["chown", owner, str(path)]) - if mode is not None: os.chmod(path, mode) - - success.append(str(path)) - - except Exception as e: - failed.append((str(path), str(e))) - - if success: - print(f"✔ Set ownership '{owner}' for {len(success)} file(s)") - if DEBUG: - for f in success: - print(f" - {f}") - - if failed: - print(f"⚠ Failed to set ownership for {len(failed)} file(s):") - for f, err in failed: - print(f" - {f}: {err}") - - return len(failed) == 0 - + except Exception as exc: + print(f"⚠ Could not set ownership for {path}: {exc}") + ok = False + return ok def check_distro_nodejs_available(): try: @@ -908,117 +709,73 @@ def install_nodejs_from_distro(): 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): - with step("Checking Node.js version requirements\n"): - try: - node_ver = run_out(["node", "--version"], check=False).strip() - match = re.match(r"v?(\d+)", node_ver) - if match: - current_major = int(match.group(1)) + """Ensure a tested Node.js version and npm are available.""" + requested = _requested_node_major(user_requested_version) + current = _installed_node_major() - if user_requested_version: - requested_match = re.match(r"(\d+)", str(user_requested_version)) - if requested_match: - requested_major = int(requested_match.group(1)) - if requested_major < MIN_NODEJS_VERSION: - requested_major = MIN_NODEJS_VERSION - elif requested_major > MAX_NODEJS_VERSION: - requested_major = MAX_NODEJS_VERSION + 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 current_major == requested_major: - 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}") - return True - else: - if current_major >= min_version: - 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}") - return True - except FileNotFoundError: - pass - except Exception: - pass - - if user_requested_version: - requested_match = re.match(r"(\d+)", str(user_requested_version)) - if requested_match: - requested_major = int(requested_match.group(1)) - - if requested_major < MIN_NODEJS_VERSION: - print( - f"⚠ Requested version {requested_major} < minimum {MIN_NODEJS_VERSION}" - ) - print(f" Installing minimum version: v{MIN_NODEJS_VERSION}") - install_node_from_nodesource(str(MIN_NODEJS_VERSION)) - elif requested_major > MAX_NODEJS_VERSION: - print( - f"⚠ Requested version {requested_major} > maximum {MAX_NODEJS_VERSION}" - ) - print(f" Installing maximum version: v{MAX_NODEJS_VERSION}") - install_node_from_nodesource(str(MAX_NODEJS_VERSION)) - else: - install_node_from_nodesource(str(requested_major)) - else: - install_node_from_nodesource(str(MIN_NODEJS_VERSION)) + if requested is not None: + install_node_from_nodesource(str(requested)) else: - has_nodejs, major, version_str = check_distro_nodejs_available() - - if has_nodejs and major and major >= min_version: - print(f"✓ Distribution provides Node.js v{version_str} (>= v{min_version})") - if install_nodejs_from_distro(): - return True - else: - print(f"⚠ Failed to install from distro, falling back to NodeSource") + 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_nodejs: - print(f"⚠ Distribution Node.js v{version_str} < minimum v{min_version}") - else: - print(f"✓ Distribution doesn't provide Node.js package") - print(f" Installing from NodeSource: v{min_version}") + 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 shutil.which("node"): - node_ver = run_out(["node", "--version"], check=False).strip() - if shutil.which("npm"): - npm_ver = run_out(["npm", "--version"], check=False).strip() - return True - - return False - + 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) - with step("Downloading and untaring"): - with urllib.request.urlopen(url) as r, tempfile.NamedTemporaryFile( - delete=False - ) as tf: - shutil.copyfileobj(r, tf) - tf.flush() - tf_path = Path(tf.name) - with tarfile.open(tf_path, "r:gz") as t: - try: - t.extractall(dest_dir, filter="data") - except TypeError: - t.extractall(dest_dir) - except Exception as e: - if "LinkOutsideDestinationError" in str(type(e).__name__): - t.extractall(dest_dir) - else: - raise - top = t.getmembers()[0].name.split("/")[0] - os.unlink(tf_path) - return dest_dir / top + 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) @@ -1026,24 +783,6 @@ OSREL = os_release() # === extra sync === -def sync_backup_nginx_conf(): - - src = Path("/etc/nginx.bak/conf.d") - dst = Path("/etc/angie/conf.d") - if not src.exists(): - return - with step("Sync /etc/nginx.bak/conf.d -> /etc/angie/conf.d"): - for p in src.rglob("*"): - if p.is_dir(): - continue - rel = p.relative_to(src) - target = dst / rel - target.parent.mkdir(parents=True, exist_ok=True) - try: - if not target.exists() or not filecmp.cmp(p, target, shallow=False): - shutil.copy2(p, target) - except Exception as e: - print(f"Warning: sync failed for {p} -> {target}: {e}") @@ -1148,12 +887,8 @@ def patch_npm_certbot_plugins_config() -> str: -def run_logged(cmd, log_path: Path, timeout=1200, check=True, env=None): - """Run command with stdout/stderr saved to a log file. - - Normal installer output stays concise, but failed builds/installations leave - actionable diagnostics instead of hiding stderr in /dev/null. - """ +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: @@ -1164,6 +899,7 @@ def run_logged(cmd, log_path: Path, timeout=1200, check=True, env=None): timeout=timeout, check=False, env=env, + cwd=cwd, stdout=log, stderr=subprocess.STDOUT, text=True, @@ -1171,8 +907,7 @@ def run_logged(cmd, log_path: Path, timeout=1200, check=True, env=None): if check and result.returncode != 0: print(f" ✖ Command failed, log: {log_path}") try: - lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines() - tail = lines[-40:] + tail = log_path.read_text(encoding="utf-8", errors="replace").splitlines()[-40:] if tail: print(" --- log tail ---") for line in tail: @@ -1184,6 +919,7 @@ def run_logged(cmd, log_path: Path, timeout=1200, check=True, env=None): return result + def _python_version(exe: str) -> tuple[int, int] | None: try: out = run_out([exe, "--version"], check=False).strip() @@ -1218,137 +954,54 @@ def _create_certbot_venv_with_python(python_exe: str, python_label: str, venv_di 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)]) - - venv_bin = venv_dir / "bin" - pip_path = venv_bin / "pip" - certbot_path = venv_bin / "certbot" 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, + ) - _install_certbot_stack(pip_path, certbot_path, env_build) - - cb_ver = run_out([str(certbot_path), "--version"], check=False) or "" - pip_ver = run_out([str(pip_path), "--version"], check=False) or "" + certbot_path = venv_dir / "bin" / "certbot" + pip_path = venv_dir / "bin" / "pip" print(f" Python: {python_label}") - print(f" Certbot: {cb_ver.strip()}") - print(f" Pip: {pip_ver.strip().split(' from ')[0]}") + 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_path: Path, certbot_path: Path, env_build: dict): - """Install Certbot and DNS plugins in one venv with matching versions. - - NPM installs DNS plugins on startup when they are missing. On non-docker - installs this can fail with 'acme==undefined'. We prevent that by making - the venv complete before npm.service starts. - """ +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") - run_logged( - [str(pip_path), "install", "-U", "pip", "setuptools", "wheel"], - log_path, - env=env_build, - ) - run_logged( - [ - str(pip_path), - "install", - "-U", - "cryptography", - "cffi", - "certbot", - "tldextract", - ], - log_path, - env=env_build, - ) + + 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) - run_logged( - [ - str(pip_path), - "install", - "-U", - f"acme=={certbot_ver}", - f"certbot-dns-cloudflare=={certbot_ver}", - f"certbot-dns-rfc2136=={certbot_ver}", - ], - log_path, - env=env_build, + pip( + "install", "-U", + f"acme=={certbot_ver}", + f"certbot-dns-cloudflare=={certbot_ver}", + f"certbot-dns-rfc2136=={certbot_ver}", ) missing = [] - for pkg in CERTBOT_REQUIRED_PACKAGES: + for package in CERTBOT_REQUIRED_PACKAGES: result = subprocess.run( - [str(pip_path), "show", pkg], + [*pip_cmd, "show", package], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ) if result.returncode != 0: - missing.append(pkg) + missing.append(package) if missing: raise RuntimeError(f"Certbot venv incomplete, missing: {', '.join(missing)}") _ensure_certbot_symlink(certbot_path) -def _install_certbot_stack_with_python(python_path: Path, certbot_path: Path, env_build: dict): - """Repair/install Certbot stack using venv python -m pip. - - This is safer on upgraded Debian 11 systems where /opt/certbot/bin/pip - may have a stale shebang, but /opt/certbot/bin/python still works. - """ - log_path = Path("/tmp/npm-certbot-venv.log") - py = str(python_path) - run_logged( - [py, "-m", "pip", "install", "-U", "pip", "setuptools", "wheel"], - log_path, - env=env_build, - ) - run_logged( - [ - py, - "-m", - "pip", - "install", - "-U", - "cryptography", - "cffi", - "certbot", - "tldextract", - ], - log_path, - env=env_build, - ) - - certbot_ver = _detect_certbot_version(certbot_path) - run_logged( - [ - py, - "-m", - "pip", - "install", - "-U", - f"acme=={certbot_ver}", - f"certbot-dns-cloudflare=={certbot_ver}", - f"certbot-dns-rfc2136=={certbot_ver}", - ], - log_path, - env=env_build, - ) - - missing = [] - for pkg in CERTBOT_REQUIRED_PACKAGES: - result = subprocess.run( - [py, "-m", "pip", "show", pkg], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - if result.returncode != 0: - missing.append(pkg) - 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]: @@ -1409,12 +1062,6 @@ def _venv_package_installed_with_python(python_path: Path, pkg: str) -> bool: def _try_repair_existing_certbot_venv(venv_dir: Path, reason: str) -> bool: - """Try to repair an existing venv before deleting it. - - Important for Debian 11: a pip wrapper may point to a removed python3.11, - while /opt/certbot/bin/python still works. In that case rebuild through - pyenv is unnecessary and riskier than repairing with python -m pip. - """ python_path = _venv_python_path(venv_dir) certbot_path = venv_dir / "bin" / "certbot" @@ -1428,24 +1075,20 @@ def _try_repair_existing_certbot_venv(venv_dir: Path, reason: str) -> bool: with step(f"Repairing existing certbot venv ({reason})"): env_build = os.environ.copy() env_build["SETUPTOOLS_USE_DISTUTILS"] = "local" - _install_certbot_stack_with_python(python_path, certbot_path, env_build) - - cb_ver = run_out([str(certbot_path), "--version"], check=False).strip() - print(f"✓ Existing certbot venv repaired: {cb_ver}") + _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 e: - print(f"⚠ Could not repair existing certbot venv: {e}") + 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): - """Fresh install: build full venv. Update: keep/repair existing venv when possible. - - Debian/Ubuntu upgrades can leave /opt/certbot/bin/pip with a stale shebang. - Do not delete the venv immediately: first try /opt/certbot/bin/python -m pip, - which is safer on Debian 11 where rebuilding Python 3.11 requires pyenv. - """ certbot_path = venv_dir / "bin" / "certbot" pip_path = venv_dir / "bin" / "pip" python_path = _venv_python_path(venv_dir) @@ -1454,52 +1097,44 @@ def ensure_certbot_venv_ready(venv_dir: Path = Path("/opt/certbot"), force_rebui with step("Removing certbot venv for forced rebuild"): shutil.rmtree(venv_dir, ignore_errors=True) - needs_rebuild = force_rebuild - rebuild_reason = "forced rebuild" if force_rebuild else "" - - if not needs_rebuild: - if not venv_dir.exists(): - needs_rebuild = True - rebuild_reason = f"missing: {venv_dir}" - else: - 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"]) - - packages_ok = python_ok - missing_pkg = None - if packages_ok: - for pkg in CERTBOT_REQUIRED_PACKAGES: - if not _venv_package_installed_with_python(python_path, pkg): - packages_ok = False - missing_pkg = pkg - break - - if python_ok and certbot_ok and packages_ok: - if not pip_ok: - # Wrapper is stale, but the venv itself works. Repair scripts in place. - _try_repair_existing_certbot_venv(venv_dir, pip_reason) - _ensure_certbot_symlink(certbot_path) - cb_ver = run_out([str(certbot_path), "--version"], check=False).strip() - print(f"✓ Existing certbot venv is complete: {cb_ver}") - run(["chown", "-R", "npm:npm", str(venv_dir)], check=False) - return True - - if python_ok: - reason = certbot_reason if not certbot_ok else (f"missing package: {missing_pkg}" if missing_pkg else pip_reason) - if _try_repair_existing_certbot_venv(venv_dir, reason): - run(["chown", "-R", "npm:npm", str(venv_dir)], check=False) - return True - - needs_rebuild = True - rebuild_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 needs_rebuild: - if venv_dir.exists(): - with step(f"Removing broken certbot venv ({rebuild_reason})"): - 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 @@ -1667,38 +1302,28 @@ preferred-chain = ISRG Root X1 def ensure_nginx_symlink(): - + """Keep /etc/nginx as a compatibility symlink to Angie configuration.""" target = Path("/etc/angie") link = Path("/etc/nginx") - try: - if link.is_symlink() and link.resolve() == target: - print("✔ Created symlink /etc/nginx -> /etc/angie") - return - - if link.exists() and not link.is_symlink(): - backup = Path("/etc/nginx.bak") - try: - if backup.exists(): - if backup.is_symlink() or backup.is_file(): - backup.unlink() - link.rename(backup) - print("✔ Backed up /etc/nginx to /etc/nginx.bak") - except Exception as e: - print(f"Warning: could not backup /etc/nginx: {e}") + target.mkdir(parents=True, exist_ok=True) + if link.is_symlink(): try: - if link.exists() or link.is_symlink(): - link.unlink() - except Exception: + 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") - try: - link.symlink_to(target) - print("✔ Created symlink /etc/nginx -> /etc/angie") - except Exception as e: - print(f"Warning: could not create /etc/nginx symlink: {e}") - except Exception as e: - print(f"Warning: symlink check failed: {e}") # ========== Angie / NPM template ========== @@ -1888,22 +1513,6 @@ WantedBy=multi-user.target """ -def lsb_info(): - try: - apt_try_install(["lsb-release"]) - dist = ( - run_out(["bash", "-lc", "lsb_release -si"]).strip().lower().replace(" ", "") - ) - rel = run_out(["bash", "-lc", "lsb_release -sr"]).strip() - code = run_out(["bash", "-lc", "lsb_release -sc"]).strip() - return { - "ID": dist, - "VERSION_ID": rel, - "CODENAME": code, - "PRETTY": f"{dist} {rel} ({code})", - } - except Exception: - return os_release() # ========== Angie ========== @@ -1949,19 +1558,19 @@ def setup_angie(ipv6_enabled: bool): write_file(Path("/etc/apt/sources.list.d/angie.list"), line) run(["apt-get", "update"]) - base = [ + packages = [ "angie", "angie-module-headers-more", "angie-module-brotli", "angie-module-zstd", - "angie-module-echo" + "angie-module-echo", + "angie-module-prometheus", + "angie-console-light", ] - optional = ["angie-module-prometheus", "angie-console-light"] - apt_install(base) - apt_try_install(optional) + apt_install(packages) with step("Configuring modules and main Angie config"): - modules_dir = Path("/etc/nginx/modules") + 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() @@ -1970,7 +1579,7 @@ def setup_angie(ipv6_enabled: bool): exec sudo -n /usr/sbin/angie "$@" """ write_file(Path("/usr/sbin/nginx"), WRAP, 0o755) - Path("/etc/nginx/conf.d/include").mkdir(parents=True, exist_ok=True) + 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) @@ -1983,10 +1592,10 @@ exec sudo -n /usr/sbin/angie "$@" 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 - """Create /etc/angie/metrics.conf (port 82/8282 with console & status).""" with step("Adding Angie metrics & console on :82 / :8282 (https)"): if NPM_ADMIN_ENABLE_SSL: generate_selfsigned_cert() @@ -2209,30 +1818,9 @@ def adjust_nginx_like_paths_in_tree(root: Path): txt = txt.replace("daemon on;", "#daemon on;") cand.write_text(txt, encoding="utf-8") -def _prepare_sass(frontend_dir: Path): - """Prepare SASS/SCSS dependencies for frontend build.""" - sass_dir = frontend_dir / "sass" - node_sass_dir = frontend_dir / "node_modules" / "node-sass" - - # Check if sass directory exists - if not sass_dir.exists(): - if DEBUG: - print(f" No sass directory found at {sass_dir}") - return - - # Try to ensure node-sass is available - try: - if not node_sass_dir.exists(): - if DEBUG: - print(" Installing node-sass...") - os.chdir(frontend_dir) - run(["npm", "install", "node-sass"], check=False) - except Exception as e: - if DEBUG: - print(f" Warning: Could not install node-sass: {e}") def install_node_from_nodesource(version: str): - is_valid, resolved_version, warning = validate_nodejs_version(version) + _, resolved_version, warning = validate_nodejs_version(version) if warning: print(warning) @@ -2321,901 +1909,235 @@ def install_node_from_nodesource(version: str): raise RuntimeError("Node.js installation failed") -def _build_frontend(src_frontend: Path, dest_frontend: Path): - """Build frontend with Yarn 4.x and robust error handling.""" - - def _get_yarn_major_version(cmd: list[str]) -> int | None: - """Get major version of yarn.""" - try: - result = subprocess.run( - cmd + ["--version"], - capture_output=True, - text=True, - timeout=5, - check=False, - ) - - if result.returncode != 0: - return None - - version_str = (result.stdout or "").strip() - if not version_str: - return None - - # Extract major version (e.g., "4.11.0" -> 4, "1.22.22" -> 1) - match = re.match(r'^(\d+)', version_str) - if match: - return int(match.group(1)) - - return None - except Exception: - return None - - def _pick_yarn_cmd() -> list[str] | None: - """Find working yarn command (requires Yarn 4.x or higher).""" - - # Check yarn in PATH - if shutil.which("yarn"): - major = _get_yarn_major_version(["yarn"]) - if major and major >= 4: - return ["yarn"] - elif major: - if DEBUG: - print(f" Found Yarn {major}.x but need 4.x+") - - # Check yarnpkg - if shutil.which("yarnpkg"): - major = _get_yarn_major_version(["yarnpkg"]) - if major and major >= 4: - return ["yarnpkg"] - elif major: - if DEBUG: - print(f" Found yarnpkg {major}.x but need 4.x+") - - # Fallback to npm exec - if shutil.which("npm"): - try: - major = _get_yarn_major_version(["npm", "exec", "--yes", "yarn@stable", "--"]) - if major and major >= 4: - return ["npm", "exec", "--yes", "yarn@stable", "--"] - except Exception: - pass - - # Fallback to npx - if shutil.which("npx"): - try: - major = _get_yarn_major_version(["npx", "-y", "yarn@stable"]) - if major and major >= 4: - return ["npx", "-y", "yarn@stable"] - except Exception: - pass - - return None - - def _cleanup_yarn_artifacts(): - """Remove corrupted yarn artifacts.""" - cleanup_paths = [ - Path("/root/.yarn"), - Path("/root/.yarnrc.yml"), - Path("/root/.yarnrc"), - Path(os.path.expanduser("~/.config/yarn")), - ] - - for path in cleanup_paths: - try: - if path.exists(): - if path.is_dir(): - shutil.rmtree(path, ignore_errors=True) - else: - path.unlink(missing_ok=True) - if DEBUG: - print(f" ✓ Cleaned: {path}") - except Exception as e: - if DEBUG: - print(f" ⚠ Could not remove {path}: {e}") +def _yarn_version() -> str: + if not shutil.which("yarn"): + return "" + return run_out(["yarn", "--version"], check=False).strip() - def _build_locale_files(frontend_dir: Path): - """Build locale JSON files from source translations before frontend build.""" - locale_src_dir = frontend_dir / "src" / "locale" / "src" - locale_lang_dir = frontend_dir / "src" / "locale" / "lang" - lang_list_file = frontend_dir / "src" / "locale" / "src" / "lang-list.json" - - if not locale_src_dir.exists(): - if DEBUG: - print(f" No locale source directory found at {locale_src_dir}") - return - - if not lang_list_file.exists(): - if DEBUG: - print(f" No lang-list.json found at {lang_list_file}") - return - - try: - with open(lang_list_file, 'r', encoding='utf-8') as f: - lang_list = json.load(f) - - locale_lang_dir.mkdir(parents=True, exist_ok=True) - - languages = [] - for key in lang_list.keys(): - if key.startswith("locale-"): - parts = key.split("-") - if len(parts) >= 2: - lang_code = parts[1].lower() - languages.append(lang_code) - - if DEBUG: - print(f" Found {len(languages)} languages: {', '.join(languages)}") - - os.chdir(frontend_dir) - - package_json = frontend_dir / "package.json" - if package_json.exists(): - try: - with open(package_json, 'r', encoding='utf-8') as f: - pkg_data = json.load(f) - scripts = pkg_data.get("scripts", {}) - - if "locale-compile" in scripts: - if DEBUG: - print(" Running yarn locale-compile...") - run(["yarn", "locale-compile"]) - return - except Exception as e: - if DEBUG: - print(f" Could not check package.json scripts: {e}") - - for lang_code in languages: - src_file = locale_src_dir / f"{lang_code}.json" - dest_file = locale_lang_dir / f"{lang_code}.json" - - if src_file.exists(): - try: - with open(src_file, 'r', encoding='utf-8') as f: - translations = json.load(f) - - with open(dest_file, 'w', encoding='utf-8') as f: - json.dump(translations, f, ensure_ascii=False, indent=2) - - if DEBUG: - print(f" ✓ Compiled {lang_code}.json") - except Exception as e: - if DEBUG: - print(f" ⚠ Failed to compile {lang_code}.json: {e}") - else: - if DEBUG: - print(f" ⊘ Source file not found: {src_file}") - - dest_lang_list = locale_lang_dir / "lang-list.json" - try: - shutil.copy2(lang_list_file, dest_lang_list) - if DEBUG: - print(f" ✓ Copied lang-list.json") - except Exception as e: - if DEBUG: - print(f" ⚠ Failed to copy lang-list.json: {e}") - - except Exception as e: - if DEBUG: - print(f" ⚠ Error building locale files: {e}") +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") - - def _ensure_yarn_installed(retry_count=0, max_retries=2): - """Install Yarn 4.x using corepack (preferred) or other methods.""" - - step_msg = "Installing Yarn 4.x" - if retry_count > 0: - step_msg = f"Reinstalling Yarn 4.x (attempt {retry_count + 1}/{max_retries + 1})" - - with step(step_msg): - # Cleanup on retry - if retry_count > 0: - _cleanup_yarn_artifacts() - - # Uninstall old Yarn 1.x - try: - run(["npm", "uninstall", "-g", "yarn"], check=False, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except Exception: - pass - - # Clear cache - try: - run(["npm", "cache", "clean", "--force"], check=False, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except Exception: - pass - - # Ensure npm is available - if not shutil.which("npm"): - try: - apt_try_install(["npm"]) - except Exception: - run(["apt-get", "update"], check=False) - run(["apt-get", "install", "-y", "npm"]) - - # METHOD 1: Corepack (recommended for Yarn 4.x) - if shutil.which("corepack"): - try: - # Enable corepack - result = subprocess.run( - ["corepack", "enable"], - input="", - stdin=subprocess.PIPE, - capture_output=True, - timeout=10, - text=True, - check=False, - ) - - if result.returncode == 0 or "already" in (result.stderr or "").lower(): - # Install Yarn stable (4.x) - result = subprocess.run( - ["corepack", "install", "-g", "yarn@stable"], - capture_output=True, - timeout=60, - text=True, - check=False, - ) - - if result.returncode == 0: - # Activate it - subprocess.run( - ["corepack", "prepare", "yarn@stable", "--activate"], - capture_output=True, - timeout=30, - check=False, - ) - - # Check version - if shutil.which("yarn"): - major = _get_yarn_major_version(["yarn"]) - - if major and major >= 4: - if DEBUG: - print(f" ✓ Yarn {major}.x installed via corepack") - return True - except Exception as e: - if DEBUG: - print(f" ⚠ Corepack method failed: {e}") - - # METHOD 2: yarn set version (for upgrade from 1.x) - if shutil.which("yarn"): - try: - # Use existing yarn 1.x to install 4.x - result = subprocess.run( - ["yarn", "set", "version", "stable"], - capture_output=True, - timeout=60, - text=True, - check=False, - ) - - if result.returncode == 0: - major = _get_yarn_major_version(["yarn"]) - - if major and major >= 4: - if DEBUG: - print(f" ✓ Yarn {major}.x installed via 'yarn set version'") - return True - except Exception as e: - if DEBUG: - print(f" ⚠ 'yarn set version' failed: {e}") - - # METHOD 3: npm install yarn@berry - try: - run(["npm", "install", "-g", "yarn@berry"], check=False) - - if shutil.which("yarn"): - major = _get_yarn_major_version(["yarn"]) - - if major and major >= 4: - if DEBUG: - print(f" ✓ Yarn {major}.x installed via npm") - return True - except Exception as e: - if DEBUG: - print(f" ⚠ npm install yarn@berry failed: {e}") - - # METHOD 4: Force install latest yarn - try: - run(["npm", "install", "-g", "yarn@latest", "--force"], check=False) - - if shutil.which("yarn"): - major = _get_yarn_major_version(["yarn"]) - - if major and major >= 4: - if DEBUG: - print(f" ✓ Yarn {major}.x installed via npm --force") - return True - except Exception as e: - if DEBUG: - print(f" ⚠ npm install --force failed: {e}") - - return False - - # Main logic: Find or install Yarn 4.x - yarn_cmd = _pick_yarn_cmd() - - if not yarn_cmd: - if DEBUG: - print(" No valid Yarn 4.x found, attempting installation...") - - if _ensure_yarn_installed(retry_count=0): - yarn_cmd = _pick_yarn_cmd() - - # Retry with cleanup if still not found - if not yarn_cmd: - if DEBUG: - print(" Yarn installation failed, retrying with full cleanup...") - - if _ensure_yarn_installed(retry_count=1): - yarn_cmd = _pick_yarn_cmd() - - # Final check - if not yarn_cmd: - raise RuntimeError( - "Unable to detect or install a valid Yarn 4.x after multiple attempts.\n" - "Manual recovery steps:\n" - " 1. Remove old Yarn: npm uninstall -g yarn\n" - " 2. Clean artifacts: rm -rf /root/.yarn /root/.yarnrc* ~/.config/yarn\n" - " 3. Clean cache: npm cache clean --force\n" - " 4. Enable corepack: corepack enable\n" - " 5. Install Yarn 4.x: corepack install -g yarn@stable\n" - " 6. Activate: corepack prepare yarn@stable --activate\n" - " 7. Alternative: yarn set version stable (if yarn 1.x exists)" + 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 - if DEBUG: - major = _get_yarn_major_version(yarn_cmd) - print(f" Using Yarn {major}.x: {' '.join(yarn_cmd)}") - - with step("Installing frontend dependencies (yarn)"): + +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: - os.environ["NODE_ENV"] = "development" - os.chdir(src_frontend) - _prepare_sass(src_frontend) + 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) - shutil.rmtree(src_frontend / ".yarn", ignore_errors=True) - shutil.rmtree(src_frontend / "node_modules", ignore_errors=True) - run(yarn_cmd + ["cache", "clean", "--all"], check=False) + dist_dir = src_frontend / "dist" + if not dist_dir.is_dir(): + raise RuntimeError(f"Frontend build output not found: {dist_dir}") - custom_tmp = src_frontend / "tmp-build" - custom_cache = src_frontend / "yarn-cache" - custom_tmp.mkdir(exist_ok=True) - custom_cache.mkdir(exist_ok=True) - - os.environ["TMPDIR"] = str(custom_tmp) - os.environ["YARN_CACHE_FOLDER"] = str(custom_cache) - os.environ["YARN_NETWORK_CONCURRENCY"] = "1" - - try: - cache_dir = (run_out(yarn_cmd + ["cache", "dir"], check=False) or "").strip() - if cache_dir: - Path(cache_dir).mkdir(parents=True, exist_ok=True) - except Exception as e: - if DEBUG: - print(f" ⚠ Cache dir setup failed: {e}") - - install_cmd = yarn_cmd + ["install", "--network-timeout", "100000"] - if DEBUG: - print(f" Running: {' '.join(install_cmd)}") - - try: - run(install_cmd, timeout=1200) - except subprocess.CalledProcessError as e: - print(f" ⚠ Yarn failed (exit {e.returncode}), cleaning node_modules...") - node_modules = src_frontend / "node_modules" - if node_modules.exists(): - shutil.rmtree(node_modules, ignore_errors=True) - run(install_cmd, timeout=1200) - - except Exception as e: - raise RuntimeError(f"Frontend dependency installation failed: {e}") from e + 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) - with step("Building locale translation files"): - try: - _build_locale_files(src_frontend) - except Exception as e: - if DEBUG: - print(f" ⚠ Warning: Locale build failed (non-fatal): {e}") - - # Build frontend - with step("Building frontend (yarn build)"): - try: - env = os.environ.copy() - env["NODE_OPTIONS"] = "--openssl-legacy-provider" - - build_cmd = yarn_cmd + ["build"] - if build_cmd[-1] == "--": - build_cmd = build_cmd[:-1] - - if DEBUG: - print(f" Running: {' '.join(build_cmd)}") - - try: - run(build_cmd, env=env) - except subprocess.CalledProcessError: - print(" ⚠ Build failed with legacy provider, retrying without...") - env.pop("NODE_OPTIONS", None) - run(build_cmd, env=env) - except subprocess.CalledProcessError as e: - raise RuntimeError( - f"Frontend build failed with exit code {e.returncode}.\n" - f"Check build logs above for details." - ) from e - - # Copy artifacts - with step("Copying frontend artifacts"): - try: - dist_dir = src_frontend / "dist" - if not dist_dir.exists(): - raise RuntimeError(f"Build output directory not found: {dist_dir}") - - shutil.copytree(dist_dir, dest_frontend, dirs_exist_ok=True) - - # Copy images if exist - app_images = src_frontend / "app-images" - if app_images.exists(): - shutil.copytree( - app_images, - dest_frontend / "images", - dirs_exist_ok=True, - ) - except Exception as e: - raise RuntimeError(f"Failed to copy frontend artifacts: {e}") from e +def install_backend_dependencies(app_dir: Path = Path("/opt/npm")): + with step("Installing backend dependencies"): + _yarn_install(app_dir, "npm-backend-yarn") -def patch_npm_backend_commands(): - candidates = [ - Path("/opt/npm/lib/utils.js"), - Path("/opt/npm/utils.js"), - Path("/opt/npm/lib/commands.js"), - ] - for p in candidates: - if not p.exists(): - continue - try: - txt = p.read_text(encoding="utf-8") - except Exception: - continue - new = re.sub(r"\blogrotate\b", "/usr/local/bin/logrotate-npm", txt) - new = re.sub(r"(? None: - footer_path = src / "frontend" / "src" / "components" / "SiteFooter.tsx" - - if not footer_path.exists(): - if DEBUG: - print(f" SiteFooter.tsx not found at {footer_path}") + footer = src / "frontend" / "src" / "components" / "SiteFooter.tsx" + if not footer.exists(): return - - try: - content = footer_path.read_text(encoding="utf-8") - - if '' not in content: - if DEBUG: - print(" GitHub fork link not found in SiteFooter.tsx") - return - - if "linuxiarz.pl" in content or "Auto Installer" in content: - if DEBUG: - print(" Installer link already present in SiteFooter.tsx") - return - - installer_item = """
  • - Auto Installer by linuxiarz.pl + + 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 +
  • """ - - pattern = r'(
  • \s*]*>\s*\s*
  • )(\s*)' - - if re.search(pattern, content, re.DOTALL): - new_content = re.sub( - pattern, - rf"\1\n{installer_item}\2", - content, - flags=re.DOTALL - ) - footer_path.write_text(new_content, encoding="utf-8") + 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(" ✓ Injected installer link via regex method") - return - - search_str = '' - idx = content.find(search_str) - - if idx < 0: - if DEBUG: - print(" Could not find injection point for installer link") - return - - close_li_idx = content.find("", idx) - - if close_li_idx < 0: - if DEBUG: - print(" Could not find closing tag for injection") - return - - insert_pos = close_li_idx + 5 - new_content = ( - content[:insert_pos] + - "\n" + installer_item + - content[insert_pos:] - ) - - footer_path.write_text(new_content, encoding="utf-8") + 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(" ✓ Injected installer link via fallback method") - return - - except Exception as e: - if DEBUG: - print(f" ⚠ Warning: Failed to inject footer link: {e}") - return + 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: - if ref.startswith("refs/heads/"): - ref_type = "branch" - branch_name = ref.replace("refs/heads/", "") - timestamp = datetime.now().strftime("%Y%m%d-%H%M") - version = f"{branch_name}-dev-{timestamp}" - git_ref = branch_name - elif ref.startswith("refs/tags/"): - ref_type = "tag" - version = ref.replace("refs/tags/v", "").replace("refs/tags/", "") - tag_name = ref.replace("refs/tags/", "") - git_ref = tag_name - else: - ref_type = "branch" - branch_name = ref - timestamp = datetime.now().strftime("%Y%m%d-%H%M") - version = f"{branch_name}-dev-{timestamp}" - git_ref = branch_name + 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) - url = f"https://codeload.github.com/{GITHUB_REPO_OWNER}/{GITHUB_REPO_NAME}/tar.gz/{git_ref}" + 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) - tmp = Path(tempfile.mkdtemp(prefix="npm-angie-")) - src = download_extract_tar_gz(url, tmp) + with step("Writing database configuration"): + _write_database_config(src) - # Set version numbers in package.json files - with step("Setting version numbers in package.json"): - for pkg in ["backend/package.json", "frontend/package.json"]: - pj = src / pkg - if not pj.exists(): - continue + _build_frontend(src / "frontend", Path("/opt/npm/frontend")) + install_backend_dependencies() - try: - data = json.loads(pj.read_text(encoding="utf-8")) - data["version"] = version - pj.write_text( - json.dumps(data, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) - if DEBUG: - print(f" ✓ Updated {pkg} -> version {version}") - except Exception as e: - if DEBUG: - print(f" ⚠ Warning: Could not update {pkg}: {e}") + with step("Normalizing NPM ownership"): + run(["chown", "-R", "npm:npm", "/opt/npm", "/data"]) - # Fix nginx-like include paths in configuration files - with step("Fixing include paths / nginx.conf"): - adjust_nginx_like_paths_in_tree(src) + 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) - with step("Customizing frontend components"): - inject_footer_link(src) - - # Copy web root and configuration to /etc/angie - with step("Copying web root and configs to /etc/angie"): - Path("/var/www/html").mkdir(parents=True, exist_ok=True) - shutil.copytree( - src / "docker" / "rootfs" / "var" / "www" / "html", - "/var/www/html", - dirs_exist_ok=True, - ) - shutil.copytree( - src / "docker" / "rootfs" / "etc" / "nginx", - "/etc/angie", - dirs_exist_ok=True, - ) - # Remove development config file if present - devconf = Path("/etc/angie/conf.d/dev.conf") - if devconf.exists(): - devconf.unlink() - # Copy logrotate configuration - shutil.copy2( - src / "docker" / "rootfs" / "etc" / "logrotate.d" / "nginx-proxy-manager", - "/etc/logrotate.d/nginx-proxy-manager", - ) - # Create symlink to /etc/nginx if it doesn't exist - if not Path("/etc/nginx").exists(): - os.symlink("/etc/angie", "/etc/nginx") - - # Copy backend and global directories to /opt/npm - with step("Copying backend to /opt/npm"): - shutil.copytree(src / "backend", "/opt/npm", dirs_exist_ok=True) - Path("/opt/npm/frontend/images").mkdir(parents=True, exist_ok=True) - - # Copy /global if it exists (git always has it) - global_src = src / "global" - if global_src.exists(): - shutil.copytree(global_src, "/opt/npm/global", dirs_exist_ok=True) - print(f" ✓ Directory 'global' copied") - - # Create SQLite database configuration if missing - with step("Dynamic database config from backend package.json"): - cfg = Path("/opt/npm/config/production.json") - backend_pkg = src / "backend" / "package.json" - - db_config = { - "database": { - "engine": "knex-native", - "knex": { - "client": "sqlite3", - "connection": {"filename": "/data/database.sqlite"} - } - } - } - - if backend_pkg.exists(): - try: - with open(backend_pkg, "r") as f: - pkg_data = json.load(f) - - deps = pkg_data.get("dependencies", {}) - if "better-sqlite3" in deps: - db_config["database"]["knex"]["client"] = "better-sqlite3" - print(" ✓ Detected better-sqlite3 – using knex-native with better-sqlite3 client") - elif "sqlite3" in deps: - print(" ✓ Detected knex sqlite3 – using knex-native") - else: - print(" ✓ No SQLite deps found – using default knex-native sqlite3") - - except Exception as e: - if DEBUG: - print(f" ⚠ package.json parse failed: {e}") - - cfg.parent.mkdir(parents=True, exist_ok=True) - write_file(cfg, json.dumps(db_config, indent=2)) - print(" ✓ Wrote dynamic /opt/npm/config/production.json") - if DEBUG: - print(f" Detected DB: {db_config['database']['engine']}") - - # Build frontend application - _build_frontend(src / "frontend", Path("/opt/npm/frontend")) - - # Install backend Node.js dependencies via yarn - with step("Installing backend dependencies (yarn)"): - os.chdir("/opt/npm") - run(["yarn", "install"]) - - # Fix ownership of NPM directories - with step("Normalizing directories ownership"): - run(["chown", "-R", "npm:npm", "/opt/npm", "/data"]) - - # Prepare and set permissions for IP ranges configuration - with step("Preparing include/ip_ranges.conf (owned by npm)"): - include_dir = Path("/etc/nginx/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") - try: - run(["chown", "npm:npm", str(include_dir), str(ipranges)]) - except Exception: - pass - os.chmod(ipranges, 0o664) - - # Apply patches to NPM backend - patch_npm_backend_commands() - - return version + return version + finally: + shutil.rmtree(tmp, ignore_errors=True) -def copy_tree_safe(src: Path, dst: Path) -> None: - - dst.mkdir(parents=True, exist_ok=True) - - for item in src.iterdir(): - src_item = src / item.name - dst_item = dst / item.name - - try: - if src_item.is_dir(): - if dst_item.exists(): - shutil.rmtree(dst_item) - shutil.copytree(src_item, dst_item) - else: - shutil.copy2(src_item, dst_item) - except FileNotFoundError: - if DEBUG: - print(f" ⊘ Skipped missing: {src_item.name}") - except Exception as e: - if DEBUG: - print(f" ⚠ Error copying {src_item.name}: {e}") -def deploy_npm_app_from_release(version: str | None) -> str: - """ - Deploy NPM from GitHub release tag. - For versions >= 2.13.0, automatically falls back to git source (missing /global in releases). - Args: - version (str | None): Release tag version (e.g., "2.13.1"). If None, fetches latest. - - Returns: - str: Installed version string - """ - # Get latest version if not specified - if not version: - repo = f"{GITHUB_REPO_OWNER}/{GITHUB_REPO_NAME}" - version = github_latest_release_tag(repo, override=None) - print(f"✓ Latest stable version: {version}") - - version_parsed = parse_version(version) - if version_parsed < (2, 13, 0): - error(f"Version {version} is not supported. Minimum version: 2.13.0") - sys.exit(1) - - # Check if version >= 2.13.0 - if so, use git instead (releases missing /global) - if version_parsed >= (2, 13, 0): - print( - f" Version {version} >= 2.13.0: using git source (release archive incomplete)" - ) - return deploy_npm_app_from_git(f"refs/tags/v{version}") - - # For versions < 2.13.0, download from release archive - url = f"https://codeload.github.com/{GITHUB_REPO_OWNER}/{GITHUB_REPO_NAME}/tar.gz/refs/tags/v{version}" - tmp = Path(tempfile.mkdtemp(prefix="npm-angie-")) - src = download_extract_tar_gz(url, tmp) - - with step(f"Preparing NPM app from release v{version}"): - Path("/opt/npm").mkdir(parents=True, exist_ok=True) - - backend_src = src / "backend" - if backend_src.exists(): - if DEBUG: - print(f" Unpacking backend contents to /opt/npm/") - - try: - for item in backend_src.iterdir(): - src_item = backend_src / item.name - dst_item = Path(f"/opt/npm/{item.name}") - - if src_item.is_dir(): - if dst_item.exists(): - shutil.rmtree(dst_item) - copy_tree_safe(src_item, dst_item) - else: - shutil.copy2(src_item, dst_item) - - if DEBUG: - print(f" ✓ Backend contents unpacked") - except Exception as e: - if DEBUG: - print(f" ⚠ Warning unpacking backend: {e}") - - # 2. Kopiuj frontend/ - frontend_src = src / "frontend" - frontend_dst = Path("/opt/npm/frontend") - if frontend_src.exists(): - if frontend_dst.exists(): - shutil.rmtree(frontend_dst) - try: - copy_tree_safe(frontend_src, frontend_dst) - if DEBUG: - print(f" ✓ Copied frontend") - except Exception as e: - if DEBUG: - print(f" ⚠ Warning copying frontend: {e}") - - # 3. Kopiuj global/ - global_src = src / "global" - global_dst = Path("/opt/npm/global") - if global_src.exists(): - if global_dst.exists(): - shutil.rmtree(global_dst) - try: - copy_tree_safe(global_src, global_dst) - if DEBUG: - print(f" ✓ Copied global") - except Exception as e: - if DEBUG: - print(f" ⚠ Warning copying global: {e}") - else: - # Create empty /global if missing - global_dst.mkdir(parents=True, exist_ok=True) - if DEBUG: - print(f" ⊘ Directory 'global' not in archive (created empty)") - - # Set version numbers in package.json files - with step("Setting version numbers in package.json"): - for pkg_path in ["/opt/npm/package.json", "/opt/npm/frontend/package.json"]: - pj = Path(pkg_path) - if not pj.exists(): - if DEBUG: - print(f" ⚠ {pkg_path} not found, skipping") - continue - - try: - data = json.loads(pj.read_text(encoding="utf-8")) - data["version"] = version - pj.write_text( - json.dumps(data, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) - if DEBUG: - print(f" ✓ Updated {pkg_path} -> version {version}") - except Exception as e: - if DEBUG: - print(f" ⚠ Warning: Could not update {pkg_path}: {e}") - - with step("Fixing include paths / nginx.conf"): - adjust_nginx_like_paths_in_tree(src) - - with step("Customizing frontend components"): - inject_footer_link(src) - - with step("Copying web root and configs to /etc/angie"): - Path("/var/www/html").mkdir(parents=True, exist_ok=True) - - docker_rootfs = src / "docker" / "rootfs" - - if (docker_rootfs / "var" / "www" / "html").exists(): - try: - shutil.copytree( - docker_rootfs / "var" / "www" / "html", - "/var/www/html", - dirs_exist_ok=True, - ) - except Exception as e: - if DEBUG: - print(f" ⚠ Warning copying web root: {e}") - - if (docker_rootfs / "etc" / "nginx").exists(): - try: - shutil.copytree( - docker_rootfs / "etc" / "nginx", "/etc/angie", dirs_exist_ok=True - ) - except Exception as e: - if DEBUG: - print(f" ⚠ Warning copying nginx config: {e}") - - # Build frontend application - _build_frontend(src / "frontend", Path("/opt/npm/frontend")) - - # Install backend dependencies - with step("Installing backend dependencies (yarn)"): - os.chdir("/opt/npm") - run(["yarn", "install"]) - - # Fix ownership - with step("Normalizing directories ownership"): - run(["chown", "-R", "npm:npm", "/opt/npm", "/data"]) - - # Prepare IP ranges configuration - with step("Preparing include/ip_ranges.conf"): - include_dir = Path("/etc/nginx/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") - - # Apply patches - patch_npm_backend_commands() - - return version def strip_ipv6_listens(paths): @@ -3261,66 +2183,32 @@ def install_logrotate_for_data_logs(): def fix_logrotate_permissions_and_wrapper(): - with step("Fixing logrotate state-file permissions and helper"): - system_status = Path("/var/lib/logrotate/status") - if system_status.exists(): - try: - run(["setfacl", "-m", "u:npm:rw", str(system_status)], check=False) - except FileNotFoundError: - try: - run(["chgrp", "npm", str(system_status)], check=False) - os.chmod(system_status, 0o664) - except Exception: - pass - + """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_dir.mkdir(parents=True, exist_ok=True) state_file = state_dir / "logrotate.state" - if not state_file.exists(): - state_file.touch() + 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) - try: - import pwd, grp - - uid = pwd.getpwnam("npm").pw_uid - gid = grp.getgrnam("npm").gr_gid - os.chown(state_dir, uid, gid) - os.chown(state_file, uid, gid) - except Exception: - pass - - helper = Path("/usr/local/bin/logrotate-npm") + # 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 -# Logrotate wrapper for npm user -exec /usr/sbin/logrotate -s {state_file} "$@" +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) - logrotate_dir = Path("/var/lib/logrotate") - if logrotate_dir.exists(): - try: - run(["usermod", "-aG", "adm", "npm"], check=False) - - run(["chgrp", "adm", str(logrotate_dir)], check=False) - os.chmod(logrotate_dir, 0o775) - except Exception as e: - print(f"⚠ Warning: could not fix {logrotate_dir} permissions: {e}") -def write_npm_service_unit(ipv6_enabled: bool, include_certbot_version: bool = True): - """Write npm.service. - - During update this refreshes an older unit so NPM receives - CERTBOT_VERSION and does not expand DNS plugin requirements to - acme==undefined. Fresh installs also use this helper when creating - the service from scratch. - """ - certbot_ver = "" - if include_certbot_version: - certbot_ver = patch_npm_certbot_plugins_config() - +def write_npm_service_unit(ipv6_enabled: bool): + certbot_ver = patch_npm_certbot_plugins_config() unit_lines = [ "[Unit]", "Description=Nginx Proxy Manager (backend)", @@ -3332,7 +2220,7 @@ def write_npm_service_unit(ipv6_enabled: bool, include_certbot_version: bool = T "Group=npm", "WorkingDirectory=/opt/npm", "Environment=NODE_ENV=production", - "Environment=PATH=/opt/certbot/bin:/usr/local/bin:/usr/bin:/bin", + "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}") @@ -3347,84 +2235,49 @@ def write_npm_service_unit(ipv6_enabled: bool, include_certbot_version: bool = T "WantedBy=multi-user.target", "", ] - write_file(Path("/etc/systemd/system/npm.service"), "\n".join(unit_lines), 0o644) - subprocess.run(["systemctl", "daemon-reload"], check=False) - if certbot_ver: - print(f"✔ npm.service updated with CERTBOT_VERSION={certbot_ver}") - else: - print("✔ npm.service updated") return certbot_ver -def refresh_npm_service_for_update(ipv6_enabled: bool): - with step("Updating npm.service for update (CERTBOT_VERSION)"): - return write_npm_service_unit(ipv6_enabled=ipv6_enabled, include_certbot_version=True) -def create_systemd_units(ipv6_enabled: bool): - with step("Creating and starting systemd services (angie, npm)"): - # Some configs may already reference the admin/metrics SSL certificate. - # Ensure it exists before the first Angie config test/restart. - if NPM_ADMIN_ENABLE_SSL: - generate_selfsigned_cert() +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) - write_npm_service_unit(ipv6_enabled=ipv6_enabled, include_certbot_version=True) - write_file(Path("/etc/systemd/system/angie.service"), ANGIE_UNIT, 0o644) - subprocess.run(["systemctl", "daemon-reload"], check=False) - ensure_angie_log_include_files() - if NPM_ADMIN_ENABLE_SSL: - generate_selfsigned_cert() - - # Validate configuration before touching the running service. - run(["/usr/sbin/angie", "-t"], check=True) - - subprocess.run(["systemctl", "restart", "angie.service"], check=False) - subprocess.run(["systemctl", "enable", "angie.service"], check=False) - - subprocess.run(["systemctl", "restart", "npm.service"], check=False) - subprocess.run(["systemctl", "enable", "npm.service"], check=False) - - subprocess.run(["angie", "-s", "reload"], check=False) - - if DEBUG: - print("✓ Systemd units: daemon-reload, restart & enable (angie/npm)") + 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, description=None -): +def update_config_file(filepath, newcontent, owner="npm:npm", mode=0o644): filepath = Path(filepath) - backuppath = None - + backup_path = None if filepath.exists(): timestamp = time.strftime("%Y%m%d-%H%M%S") - backuppath = filepath.parent / f"{filepath.name}.backup-{timestamp}" + 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" Creating backup: {backuppath}") - shutil.copy2(filepath, backuppath) + print(f" Backup: {backup_path}") - filepath.parent.mkdir(parents=True, exist_ok=True) write_file(filepath, newcontent, mode) - - if DEBUG: - print(f" Written to: {filepath}") - if owner: - try: - run(["chown", owner, str(filepath)], check=False) - if DEBUG: - print(f" Owner set to: {owner}") - except Exception as e: - if DEBUG: - print(f" Warning: Could not set owner: {e}") - - return backuppath + run(["chown", owner, str(filepath)], check=False) + return backup_path -def update_npn_assets_config(): +def update_npm_assets_config(): """ Update /etc/nginx/conf.d/include/assets.conf with optimized cache settings. """ @@ -3477,11 +2330,13 @@ ssl_conf_command Ciphersuites TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA25 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. """ - Update NPM listen template with HTTP/3 (QUIC) support for Angie. - """ - content = """# HTTP listening -# HTTP listening + content = """# HTTP listen 80; {% if ipv6 -%} listen [::]:80; @@ -3490,67 +2345,57 @@ listen [::]:80; {% endif %} {% if certificate -%} -# HTTPS/TLS listening -# HTTP/3 (QUIC) -listen 443 quic; -{% if ipv6 -%} -listen [::]:443 quic; -{% endif %} - -# HTTP/2 and HTTP/1.1 fallback - TCP port +# 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 -%} -# Enable HTTP/2 and HTTP/3 together {% if http2_support == 1 or http2_support == true %} http2 on; http3 on; -http3_hq on; {% else -%} http2 off; http3 off; {% endif %} - -# Advertise HTTP/3 availability to clients -add_header Alt-Svc 'h3=":443"; ma=86400' always; {% endif %} -# Angie status for stats status_zone {{ domain_names[0] | replace: "*.", "" | replace: ".", "_" }}; """ - with step("Updating NPM listen template with HTTP/3 support"): return update_config_file( - filepath="/opt/npm/templates/_listen.conf", - newcontent=content, + "/opt/npm/templates/_listen.conf", + content, owner="npm:npm", mode=0o644, ) def update_npm_proxy_host_template(): - """ - Update /opt/npm/templates/proxy_host.conf with upstream keepalive configuration. - """ content = """{% include "_header_comment.conf" %} - {% if enabled %} -#### BCKEND UPSTREAM #### +#### BACKEND UPSTREAM #### {% assign bname = domain_names[0] | replace: "*.", "" | replace: ".", "_" %} upstream backend_{{ bname }} { -zone {{ bname }} 1m; -server {{ forward_host }}:{{ forward_port }}; -keepalive 16; + zone {{ bname }} 1m; + server {{ forward_host }}:{{ forward_port }}; + keepalive 16; } {% include "_hsts_map.conf" %} @@ -3577,11 +2422,9 @@ proxy_http_version 1.1; error_log /data/logs/proxy-host-{{ id }}_error.log warn; {{ advanced_config }} - {{ locations }} {% if use_default_location %} - location / { {% include "_access.conf" %} {% include "_hsts.conf" %} @@ -3594,32 +2437,27 @@ proxy_http_version 1.1; 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 %} +{% if allow_websocket_upgrade == 1 or allow_websocket_upgrade == true %} proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $http_connection; - {% endif %} +{% endif %} } {% endif %} - # Custom include /data/nginx/custom/server_proxy[.]conf; } {% endif %} """ - with step("Updating NPM proxy host template"): return update_config_file( - filepath="/opt/npm/templates/proxy_host.conf", - newcontent=content, + "/opt/npm/templates/proxy_host.conf", + content, owner="npm:npm", mode=0o644, ) def update_npm_location_template(): - """ - Update /opt/npm/templates/_location.conf with status_zone monitoring. - """ content = """ location {{ path }} { {{ advanced_config }} @@ -3631,7 +2469,7 @@ def update_npm_location_template(): 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 }}; + proxy_pass {{ forward_scheme }}://{{ forward_host }}:{{ forward_port }}{{ forward_path }}; {% include "_access.conf" %} {% include "_assets.conf" %} @@ -3639,23 +2477,56 @@ def update_npm_location_template(): {% include "_forced_ssl.conf" %} {% include "_hsts.conf" %} - {% if allow_websocket_upgrade == 1 or allow_websocket_upgrade == true %} +{% 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 %} +{% endif %} } """ - with step("Updating NPM custom location template"): return update_config_file( - filepath="/opt/npm/templates/_location.conf", - newcontent=content, + "/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) @@ -3736,6 +2607,7 @@ server {{ listen {https_port} ssl; listen {https_port} quic reuseport; + # Intentional singleton socket bootstrap for QUIC vhosts. listen 443 ssl; listen 443 quic reuseport; @@ -3809,7 +2681,7 @@ server {{ location / {{ etag off; index index.html; - iif ($request_uri ~ ^/(.*)\\.html$) {{ + if ($request_uri ~ ^/(.*)\\.html$) {{ return 302 /$1; }} try_files $uri $uri.html $uri/ /index.html; @@ -3928,23 +2800,15 @@ def update_motd( else "IPv6: disabled in resolvers and conf." ) - is_branch_version = "-dev-" in npm_version if npm_version else False - - npm_version_parsed = (0, 0, 0) - if npm_version and not is_branch_version: - clean_version = npm_version[1:] if npm_version.startswith("v") else npm_version - npm_version_parsed = parse_version(clean_version) - 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}" - if is_branch_version: - npm_source = f"Source: branch ({npm_version})" - elif installed_from_branch: - npm_source = "Source: master branch (development)" - else: - npm_source = f"Source: release {npm_version}" + npm_source = ( + f"Source: branch ({npm_version})" + if installed_from_branch + else f"Source: release {npm_version}" + ) text = f""" ################################ NPM / ANGIE ################################ @@ -3982,699 +2846,367 @@ Paths: app=/opt/npm data=/data cache=/var/lib/angie/cache certbot=/opt/certbo write_file(motd, content, 0o644) -def print_summary( - info, ipv6_enabled, update_mode, npm_version=None, installed_from_branch=False -): - +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']} ({OSREL['ID']} {OSREL['VERSION_ID']})") - print(f"Mode: {'UPDATE' if update_mode else 'INSTALL'}") - - if NPM_ADMIN_ENABLE_SSL: - print(f"NPM panel address: https://{ip}:{NPM_ADMIN_HTTPS_PORT}") - print(f" (HTTP→HTTPS: http://{ip}:{NPM_ADMIN_HTTP_PORT})") - else: - print(f"NPM panel address: http://{ip}:{NPM_ADMIN_HTTP_PORT}") - - print(f"Angie & Prometheus stats: http://{ip}:82/console | http://{ip}:82/p8s") - print(f"Angie & Prometheus stats (https): https://{ip}:8282/console | http://{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( - "Paths: /opt/npm (app), /data (npm data), /etc/angie (conf), /opt/certbot (cerbot venv)" - ) - print("Services: systemctl status angie.service / npm.service") - - if not update_mode: - npm_version_parsed = parse_version(npm_version) if npm_version else (0, 0, 0) - is_branch_version = "-dev-" in npm_version if npm_version else False - - print("Test config: /usr/sbin/angie -t") - - print(f"\n FIRST LOGIN (branch/tag: {npm_version}):") - print(f" URL: https://{ip}:{NPM_ADMIN_HTTPS_PORT}") - print(f" Set admin user and password during first login") + 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 update_only( - node_pkg: str, - node_version: str | None, - npm_version_override: str | None, - ipv6_enabled: bool, - is_update=False -): - - apt_update_upgrade() - cleanup_build_artifacts() - - # ========== VALIDATE NPM VERSION ========== - if npm_version_override: - version_parsed = parse_version(npm_version_override) - if version_parsed < (2, 13, 0): - print(f"\n ERROR: NPM version {npm_version_override} is not supported.") - print(f" Minimum supported version: 2.13.0") - sys.exit(1) - - - # Ensure npm exists before trying to install yarn - if not shutil.which("npm"): - ensure_minimum_nodejs(user_requested_version=node_pkg) - - repo = f"{GITHUB_REPO_OWNER}/{GITHUB_REPO_NAME}" - version = github_latest_release_tag(repo, npm_version_override) - url = f"https://codeload.github.com/{GITHUB_REPO_OWNER}/{GITHUB_REPO_NAME}/tar.gz/refs/tags/v{version}" - tmp = Path(tempfile.mkdtemp(prefix="npm-update-")) - src = download_extract_tar_gz(url, tmp) - - with step("Setting version in package.json (update)"): - - for pkg_path in [ - "package.json", - "backend/package.json", - "frontend/package.json", - ]: - pj = src / pkg_path - if not pj.exists(): - continue - - try: - data = json.loads(pj.read_text(encoding="utf-8")) - data["version"] = version - pj.write_text( - json.dumps(data, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) - if DEBUG: - print(f" ✓ Updated {pkg_path} -> version {version}") - except Exception as e: - if DEBUG: - print(f" ⚠ Warning: Could not update {pkg_path}: {e}") - - # ========== BACKUP BEFORE UPDATE ========== +def _backup_before_update(target_version: str) -> Path: timestamp = time.strftime("%Y%m%d-%H%M%S") - backup_dir = Path(f"/data/backups/npm-backup-{timestamp}") + backup_root = Path("/data/backups") + backup_dir = backup_root / f"npm-backup-{timestamp}" + backup_root.mkdir(parents=True, exist_ok=True) - with step("Creating full backup before update"): - backup_dir.parent.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) - try: - if Path("/opt/npm").exists(): - shutil.copytree("/opt/npm", backup_dir / "opt_npm", dirs_exist_ok=True) + (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 - if Path("/data/database.sqlite").exists(): - shutil.copy2("/data/database.sqlite", backup_dir / "database.sqlite") - if Path("/data/letsencrypt").exists(): - shutil.copytree( - "/data/letsencrypt", backup_dir / "letsencrypt", dirs_exist_ok=True - ) - if Path("/data/nginx").exists(): - shutil.copytree("/data/nginx", backup_dir / "nginx", dirs_exist_ok=True) + 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 - backup_info = { - "backup_date": timestamp, - "npm_version": "current", - "update_to_version": version, - "backup_path": str(backup_dir), - } - (backup_dir / "backup_info.json").write_text( - json.dumps(backup_info, indent=2) - ) - backups = sorted(backup_dir.parent.glob("npm-backup-*")) - if len(backups) > 3: - for old_backup in backups[:-3]: - shutil.rmtree(old_backup, ignore_errors=True) +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 - except Exception as e: - print(f"⚠ Warning: Backup failed: {e}") - print(" Continue update anyway? [y/N]: ", end="", flush=True) - response = input().strip().lower() - if response not in ["y", "yes"]: - print("Update cancelled.") - sys.exit(1) + 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}") - print(f" Backup location: {backup_dir}") - backups = sorted(backup_dir.parent.glob("npm-backup-*")) - if len(backups) > 3: - print(f" Removed {len(backups) - 3} old backup(s)") - # ========== END BACKUP ========== + for conf in include_dst.glob("*.conf"): + run(["chown", "npm:npm", str(conf)], check=False) + ensure_angie_log_include_files() - # Customize frontend components (inject installer link) - with step("Customizing frontend components"): - inject_footer_link(src) - _build_frontend(src / "frontend", Path("/opt/npm/frontend")) +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) - # ========== SYNC CONFIG FILES FROM SOURCE ========== - with step("Synchronizing updated config files from docker/rootfs"): - docker_rootfs = src / "docker" / "rootfs" - include_src = docker_rootfs / "etc" / "nginx" / "conf.d" / "include" - include_dst = Path("/etc/angie/conf.d/include") - - if include_src.exists(): - include_dst.mkdir(parents=True, exist_ok=True) - - # list file to safe replace/update, remember it! - safe_to_sync = [ - "force-ssl.conf", - "proxy.conf", - "assets.conf", - "exploits.conf", - "hsts.conf", - "log.conf", - "log-proxy.conf", - "log-stream.conf", - ] - - for conf_file in include_src.glob("*.conf"): - if conf_file.name not in safe_to_sync: - if DEBUG: - print(f" ⊘ Skipped: {conf_file.name}") - continue - - src_file = conf_file - dst_file = include_dst / conf_file.name - - try: - if not dst_file.exists(): - shutil.copy2(src_file, dst_file) - print(f" ✓ Created: {conf_file.name}") - elif not filecmp.cmp(src_file, dst_file, shallow=False): - timestamp = time.strftime("%Y%m%d-%H%M%S") - backup_path = dst_file.parent / f"{dst_file.name}.backup-{timestamp}" - shutil.copy2(dst_file, backup_path) - shutil.copy2(src_file, dst_file) - print(f" ✓ Updated: {conf_file.name}") - else: - if DEBUG: - print(f" = Unchanged: {conf_file.name}") - except Exception as e: - print(f" ⚠ Warning: {conf_file.name}: {e}") - - try: - run(["chown", "npm:npm", str(include_dst / "*.conf")], check=False) - except Exception: - pass - ensure_angie_log_include_files() - # ========== UPDATE MAIN ANGIE.CONF ========== - with step("Updating main Angie configuration /etc/angie/angie.conf"): - angieconfpath = Path("/etc/angie/angie.conf") - - if is_update or not angieconfpath.exists(): - timestamp = time.strftime("%Y%m%d-%H%M%S") - backuppath = angieconfpath.parent / f"angie.conf.backup-{timestamp}" - if angieconfpath.exists(): - shutil.copy2(angieconfpath, backuppath) - print(f" ✓ Backed up to {backuppath.name}") - - angieconfpath.parent.mkdir(parents=True, exist_ok=True) - write_file(angieconfpath, ANGIE_CONF_TEMPLATE, 0o644) - print(" ✓ Updated /etc/angie/angie.conf") - - if shutil.which("angie"): - try: - run(["angie", "-t"], check=True) - print(" ✓ Config syntax OK") - except subprocess.CalledProcessError: - if backuppath and backuppath.exists(): - shutil.copy2(backuppath, angieconfpath) - print(f" ✖ Config syntax failed - restored {backuppath.name}") - else: - print(" ✖ Config syntax failed - no backup available") - raise - else: - if DEBUG: - print(" ⚠ /etc/angie/angie.conf unchanged (install mode)") +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/") - with step("Updating backend without overwriting config/"): - backup_cfg = Path("/tmp/npm-config-backup") - if backup_cfg.exists(): - shutil.rmtree(backup_cfg) - if Path("/opt/npm/config").exists(): - shutil.copytree("/opt/npm/config", backup_cfg, dirs_exist_ok=True) + 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) - backend_src = src / "backend" + 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) - if backend_src.exists(): - if DEBUG: - print(f" Unpacking backend contents (version < 2.13.0)") - - for item in Path("/opt/npm").glob("*"): - if item.name in ("frontend", "config"): - continue - if item.is_dir(): - shutil.rmtree(item) - else: - item.unlink() - - for item in backend_src.iterdir(): - src_item = backend_src / item.name - dst_item = Path(f"/opt/npm/{item.name}") - - if src_item.is_dir(): - if dst_item.exists(): - shutil.rmtree(dst_item) - copy_tree_safe(src_item, dst_item) - else: - shutil.copy2(src_item, dst_item) - else: - if DEBUG: - print(f" Copying root contents (version >= 2.13.0)") - - for item in Path("/opt/npm").glob("*"): - if item.name in ("frontend", "config"): - continue - if item.is_dir(): - shutil.rmtree(item) - else: - item.unlink() - - for item in src.iterdir(): - src_item = src / item.name - dst_item = Path(f"/opt/npm/{item.name}") - - if item.name in ("frontend", "config", "docker"): - continue - - if src_item.is_dir(): - if dst_item.exists(): - shutil.rmtree(dst_item) - copy_tree_safe(src_item, dst_item) - else: - shutil.copy2(src_item, dst_item) + 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" - if global_src.exists(): - global_dst = Path("/opt/npm/global") - if global_dst.exists(): - shutil.rmtree(global_dst) - shutil.copytree(global_src, global_dst, dirs_exist_ok=True) - if DEBUG: - print(f" ✓ Directory 'global' copied") + global_dst = app / "global" + if global_dst.exists(): + shutil.rmtree(global_dst) + if global_src.is_dir(): + shutil.copytree(global_src, global_dst) else: - Path("/opt/npm/global").mkdir(parents=True, exist_ok=True) - if DEBUG: - print(f" ⊘ Directory 'global' not in archive (created empty)") + global_dst.mkdir(parents=True, exist_ok=True) - Path("/opt/npm/config").mkdir(parents=True, exist_ok=True) - if backup_cfg.exists(): - # Przywróć wszystko z backup_cfg - for item in backup_cfg.iterdir(): - src_cfg = backup_cfg / item.name - dst_cfg = Path(f"/opt/npm/config/{item.name}") - - if src_cfg.is_dir(): - if dst_cfg.exists(): - shutil.rmtree(dst_cfg) - shutil.copytree(src_cfg, dst_cfg) - else: - shutil.copy2(src_cfg, dst_cfg) - - shutil.rmtree(backup_cfg, ignore_errors=True) - - with step("Installing backend dependencies after update"): - os.chdir("/opt/npm") - run(["yarn", "install"]) - - patch_npm_backend_commands() - ensure_certbot_venv_ready() - patch_npm_certbot_plugins_config() - refresh_npm_service_for_update(ipv6_enabled=ipv6_enabled) - configure_letsencrypt() - create_systemd_units(ipv6_enabled=ipv6_enabled) - - with step("Setting owners"): - run(["chown", "-R", "npm:npm", "/opt/npm"]) - - # Cleanup development configuration - with step("Cleaning up development configuration"): - dev_conf = Path("/etc/nginx/conf.d/dev.conf") - if dev_conf.exists(): - try: - dev_conf.unlink() - print(f" ✓ Removed development config") - except Exception as e: - print(f" ⚠ Warning: Could not remove dev.conf: {e}") - - with step("Restarting services after update"): - run(["systemctl", "restart", "angie.service"], check=False) - run(["systemctl", "restart", "npm.service"], check=False) - - return version + 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 main(): +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/upgrade NPM on Angie (Debian 11 + / Ubuntu 20.04 +).", + description="Install or update Nginx Proxy Manager on Angie.", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) - parser.add_argument( - "--nodejs-pkg", - default="nodejs", - help="APT Node.js package name (e.g. nodejs, nodejs-18).", - ) parser.add_argument( "--node-version", default=None, - help=f"Install Node.js from NodeSource repo (e.g. 'latest', '21', '22'). " - f"Maximum supported: v{MAX_NODEJS_VERSION}. Overrides --nodejs-pkg.", + help=f"Node.js major from NodeSource (20-{MAX_NODEJS_VERSION}); otherwise auto-detect.", ) - parser.add_argument( - "--npm-version", - default=None, - help="Force NPM app version from release tag (e.g. 2.13.2). Default: last tag from git", + 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.", ) - parser.add_argument( - "--motd", - choices=["yes", "no"], - default="yes", - help="Update MOTD after completion.", - ) - parser.add_argument( - "--enable-ipv6", - action="store_true", - help="Do not strip IPv6 from configs/resolvers (keep IPv6).", - ) - parser.add_argument( - "--update", - action="store_true", - help="Update mode: upgrade/downgrade NPM, rebuild frontend/backend without reconfiguring Angie. " - "Use with --npm-version to specify target version (>= 2.13.0).", - ) - parser.add_argument( - "--branch", - type=str, - default=None, - metavar="BRANCH", - help="Install from specific git branch (e.g., master, dev, develop). ", - ) - parser.add_argument( - "--debug", action="store_true", help="Show detailed logs and progress." + 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 - # Check memory and create swap if needed - memory_info = check_memory_and_create_swap() - - # Determine if any main parameters were provided - main_params_provided = any([ - args.npm_version, - args.branch, - args.update, - # args.node_version removed - it's just an environment setting - ]) - - # ========== INTERACTIVE MODE ========== - if not main_params_provided: - print("\nNo installation parameters provided. Starting interactive mode...") - choices = interactive_install_mode() - args = apply_interactive_choices(args, choices) + # 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("INSTALLATION SUMMARY") - print("=" * 70) - if args.update: - print("Mode: UPDATE") - elif args.branch: - print(f"Mode: INSTALL from branch '{args.branch}'") + 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(f"Mode: INSTALL from release tag") - if args.npm_version: - print(f"Version: {args.npm_version}") - else: - print("Version: Latest stable") - print("=" * 70 + "\n") - - confirm = input("Proceed with installation? [Y/n]: ").strip().lower() - if confirm and confirm not in ["y", "yes", ""]: - cleanup_swap() + print("Source: latest stable release") + print("=" * 70) + if input("Proceed? [Y/n]: ").strip().lower() not in ("", "y", "yes"): print("Installation cancelled.") - sys.exit(0) + return 0 - # ========== WRAP INSTALLATION ========== + 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: - # Initialize variables to prevent UnboundLocalError - npm_app_version = None - installed_from_branch = False - - # Display installation banner + swap_state = check_memory_and_create_swap() print("\n================== NPM + ANGIE installer ==================") - print(f"Repository: https://git.linuxiarz.pl/gru/npm-angie-auto-install") - print(f"NPM source repo: {GITHUB_REPO_URL}") - print(f"Script description: Auto-installer with Angie + Node.js auto-setup") - print(f"") - print(f"System Information:") - print(f" OS: {OSREL['PRETTY']}") - print(f" Distribution: {OSREL['ID']} {OSREL['VERSION_ID']}") - print(f" Codename: {OSREL.get('CODENAME', 'N/A')}") - print(f" Python: {sys.version.split()[0]}") - print(f"") - print(f"Installation Mode:") - print(f" Log Level: {'DEBUG (verbose)' if DEBUG else 'SIMPLE'}") - print(f" Min Node.js: v{MIN_NODEJS_VERSION}+") - print(f" Max Node.js: v{MAX_NODEJS_VERSION}") - print(f"") - print(f"Author: @linuxiarz.pl (Mateusz Gruszczyński)") + 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") - # ========== UPDATE MODE ========== if args.update: - installer_config = load_installer_config() - stored_ipv6 = installer_config.get("ipv6_enabled", args.enable_ipv6) - installed_from_branch = installer_config.get("installed_from_branch", False) - previous_branch = installer_config.get("branch", "master") - - if args.branch: - installed_from_branch = True - previous_branch = args.branch - install_logrotate_for_data_logs() fix_logrotate_permissions_and_wrapper() - - if installed_from_branch: - print(f"Old installation: branch '{previous_branch}'") - with step(f"Updating NPM from branch: {previous_branch}"): - npm_app_version = deploy_npm_app_from_git(f"refs/heads/{previous_branch}") - print(f"✓ NPM updated to {npm_app_version} from branch {previous_branch}") - npm_version_parsed = parse_version(npm_app_version) - else: - print(f"✓ Old installation: release tag") - version = update_only( - node_pkg=args.nodejs_pkg, - node_version=args.node_version, - npm_version_override=args.npm_version, - ipv6_enabled=stored_ipv6 if "stored_ipv6" in locals() else args.enable_ipv6, - is_update=args.update - ) - npm_app_version = version - npm_version_parsed = parse_version(npm_app_version) - - comment_x_served_by_step() - set_file_ownership(["/etc/nginx/conf.d/include/ip_ranges.conf"], "npm:npm", 0o664) - - update_ssl_ciphers_config() - update_npn_assets_config() - update_npm_admin_interface() - update_npm_proxy_host_template() - update_npm_location_template() - update_npm_listen_template() - update_npm_stream_template() - - info = gather_versions(npm_app_version) - update_motd( - args.motd == "yes", - info, - ipv6_enabled=args.enable_ipv6, - npm_version=npm_app_version, - installed_from_branch=installed_from_branch, - ) - - save_installer_config({ - "ipv6_enabled": args.enable_ipv6, - "node_version": args.node_version, - "npm_version": npm_app_version, - "installed_from_branch": installed_from_branch, - "branch": args.branch if installed_from_branch else None, - }) - - print_summary( - info, - args.enable_ipv6, - update_mode=True, - npm_version=npm_app_version, - installed_from_branch=installed_from_branch, - ) - - # ========== FRESH INSTALL ========== + npm_app_version = update_npm_app_from_git(ref, args.node_version) else: - validate_supported_os() apt_update_upgrade() - apt_purge([ - "nginx", "openresty", "nodejs", "npm", "yarn", - "certbot", "rustc", "cargo" - ]) + 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", + "ca-certificates", "curl", "gnupg", "apt-transport-https", "openssl", + "apache2-utils", "logrotate", "sudo", "acl", "python3", "sqlite3", + "git", "lsb-release", "build-essential", ]) - setup_angie(ipv6_enabled=args.enable_ipv6) - write_metrics_files() - ensure_minimum_nodejs(user_requested_version=args.node_version) + 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) - # ========== INSTALLATION ========== - if args.branch is not None: - # Install from branch - branch_name = args.branch - with step(f"Installing NPM from branch: {branch_name}"): - npm_app_version = deploy_npm_app_from_git(f"refs/heads/{branch_name}") - - print(f"\n{'='*70}") - print(f"✓ NPM Installation Complete (from Branch)") - print(f"{'='*70}") - print(f"Source: Branch (development)") - print(f"Branch: {branch_name}") - print(f"NPM Version: {npm_app_version}") - print(f"{'='*70}\n") - installed_from_branch = True - - elif args.npm_version is not None: - # Install specific version - version_parsed = parse_version(args.npm_version) - - # Validate minimum version - if version_parsed < (2, 13, 0): - error(f"NPM version {args.npm_version} is not supported.") - print(f" Minimum supported version: 2.13.0") - print(f" For legacy versions, use npm_install_multiversion.py (not recommended)") - sys.exit(1) - - # Install from git tag (all >= 2.13.0 use git) - with step(f"Installing NPM v{args.npm_version} from git tag"): - npm_app_version = deploy_npm_app_from_git(f"refs/tags/v{args.npm_version}") - - print(f"\n{'='*70}") - print(f"✓ NPM Installation Complete (from Release Tag)") - print(f"{'='*70}") - print(f"Source: Release tag (stable)") - print(f"Requested: v{args.npm_version}") - print(f"Installed: {npm_app_version}") - print(f"{'='*70}\n") - - installed_from_branch = False - - else: - # Install latest stable - with step("Detecting latest stable release"): - repo = f"{GITHUB_REPO_OWNER}/{GITHUB_REPO_NAME}" - latest_version = github_latest_release_tag(repo, override=None) - print(f" Latest stable version: {latest_version}") - - version_parsed = parse_version(latest_version) - - # Validate minimum version (should not happen, but safety check) - if version_parsed < (2, 13, 0): - error(f"Latest version {latest_version} is below minimum (2.13.0)") - print(f" This should not happen - please report this issue") - sys.exit(1) - - # Install from git tag - with step(f"Installing NPM v{latest_version} from git tag"): - npm_app_version = deploy_npm_app_from_git(f"refs/tags/v{latest_version}") - - print(f"\n{'='*70}") - print(f"✓ NPM Installation Complete (latest stable)") - print(f"{'='*70}") - print(f"Source: Latest stable release (auto-detected)") - print(f"Installed: {npm_app_version}") - print(f"{'='*70}\n") - - installed_from_branch = False - - # Handle IPv6 stripping - if not args.enable_ipv6: - strip_ipv6_listens([Path("/etc/angie"), Path("/etc/nginx")]) - else: - print("IPv6: leaving entries (skipped IPv6 cleanup).") - - npm_version_parsed = parse_version(npm_app_version) - - # Save installation configuration - save_installer_config({ - "ipv6_enabled": args.enable_ipv6, - "node_version": args.node_version, - "npm_version": npm_app_version, - "installed_from_branch": installed_from_branch, - "branch": args.branch if installed_from_branch else None, - }) - - patch_npm_certbot_plugins_config() - create_systemd_units(ipv6_enabled=args.enable_ipv6) - - ensure_nginx_symlink() install_logrotate_for_data_logs() fix_logrotate_permissions_and_wrapper() - sync_backup_nginx_conf() - comment_x_served_by_step() - set_file_ownership(["/etc/nginx/conf.d/include/ip_ranges.conf"], "npm:npm", 0o664) - update_ssl_ciphers_config() - update_npn_assets_config() - update_npm_admin_interface() - update_npm_proxy_host_template() - update_npm_location_template() - update_npm_listen_template() - update_npm_stream_template() + ensure_nginx_symlink() + apply_custom_configuration() + if args.update: + print("ℹ NPM templates updated; existing host configs use them after the host is saved again.") - # Restart services - with step("Restarting services after installation"): - run(["systemctl", "restart", "angie.service"], check=False) - run(["systemctl", "restart", "npm.service"], check=False) + if not ipv6_enabled: + strip_ipv6_listens([Path("/etc/angie"), Path("/data/nginx")]) - info = gather_versions(npm_app_version) - update_motd( - args.motd == "yes", - info, - ipv6_enabled=args.enable_ipv6, - npm_version=npm_app_version, - installed_from_branch=installed_from_branch, - ) - print_summary( - info, - args.enable_ipv6, - update_mode=False, - npm_version=npm_app_version, - installed_from_branch=installed_from_branch, - ) + # 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: - # Always cleanup swap at the end - cleanup_swap() + cleanup_swap(swap_state) + if __name__ == "__main__": - signal.signal(signal.SIGINT, lambda s, f: sys.exit(130)) - main() - \ No newline at end of file + sys.exit(main())