Files
npm-angie-auto-install/npm_install.py
T
2026-09-10 10:50:01 +02:00

3325 lines
112 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Native Nginx Proxy Manager + Angie installer.
Supported NPM releases: >= 2.13.0
Examples:
./npm_install.py
./npm_install.py --npm-version 2.15.1
./npm_install.py --branch master
./npm_install.py --branch develop
./npm_install.py --update
"""
from __future__ import annotations
import argparse
import filecmp
import json
import os
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
import threading
import time
import urllib.request
from contextlib import contextmanager
from datetime import datetime
from glob import glob
from pathlib import Path
DEBUG = False
# GitHub Repository Configuration
GITHUB_REPO_OWNER = "NginxProxyManager"
GITHUB_REPO_NAME = "nginx-proxy-manager"
GITHUB_REPO_URL = f"https://github.com/{GITHUB_REPO_OWNER}/{GITHUB_REPO_NAME}"
# Alternative: Use full URL directly (uncomment to override above)
# GITHUB_REPO_URL = "https://github.com/YourUsername/your-fork-name"
# ========== Configuration ==========
# Minimum required Node.js version for NPM 2.13.0+
MIN_NODEJS_VERSION = 20
# Maximum supported Node.js version
MAX_NODEJS_VERSION = 24
# NPM Admin Interface Configuration
NPM_ADMIN_ENABLE_SSL = True
NPM_ADMIN_HTTP_PORT = 81
NPM_ADMIN_HTTPS_PORT = 8181
NPM_ADMIN_ROOT_PATH = "/opt/npm/frontend"
NPM_ADMIN_CERT_PATH = "/etc/nginx/ssl/npm-admin.crt"
NPM_ADMIN_KEY_PATH = "/etc/nginx/ssl/npm-admin.key"
NPM_ADMIN_CERT_DAYS = 3650
# min. RAM settings
MIN_MEMORY_GB = 3.5
SWAP_SIZE_GB = 2.0
# ========== UI / Spinner ==========
class Spinner:
FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
def __init__(self, text):
self.text = text
self._stop_event = threading.Event()
self._thread = None
self._frame_index = 0
def _spin(self):
while not self._stop_event.is_set():
frame = self.FRAMES[self._frame_index % len(self.FRAMES)]
sys.stdout.write(f"\r\033[K{frame} {self.text}")
sys.stdout.flush()
self._frame_index += 1
time.sleep(0.08)
def start(self):
if DEBUG or not sys.stdout.isatty():
print(f"• {self.text} ...")
return
self._thread = threading.Thread(target=self._spin, daemon=True)
self._thread.start()
def stop(self, ok=True):
if DEBUG or not sys.stdout.isatty():
print(f"{'✔' if ok else '✖'} {self.text}")
return
self._stop_event.set()
if self._thread and self._thread.is_alive():
self._thread.join(timeout=0.5)
sys.stdout.write(f"\r\033[K{'✔' if ok else '✖'} {self.text}\n")
sys.stdout.flush()
@contextmanager
def step(text):
spinner = Spinner(text)
spinner.start()
try:
yield
except BaseException:
spinner.stop(False)
raise
else:
spinner.stop(True)
def _devnull():
return subprocess.DEVNULL if not DEBUG else None
def run(cmd, timeout=600, check=True, env=None, cwd=None, quiet=True):
"""Run a command. Output is hidden unless DEBUG is enabled or quiet=False."""
cmd = [str(x) for x in cmd]
if DEBUG:
print("+", " ".join(cmd))
hide = quiet and not DEBUG
return subprocess.run(
cmd,
timeout=timeout,
check=check,
env=env,
cwd=str(cwd) if cwd else None,
stdout=subprocess.DEVNULL if hide else None,
stderr=subprocess.DEVNULL if hide else None,
)
def run_out(cmd, check=True, cwd=None):
cmd = [str(x) for x in cmd]
if DEBUG:
print("+", " ".join(cmd))
result = subprocess.run(
cmd,
check=check,
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True,
)
return result.stdout
# ========== Utils ==========
def ensure_root():
if os.geteuid() != 0:
print("Run as root.", file=sys.stderr)
sys.exit(1)
def os_release():
data = {}
try:
for line in Path("/etc/os-release").read_text().splitlines():
if "=" in line:
k, v = line.split("=", 1)
data[k] = v.strip().strip('"')
except Exception:
pass
pretty = (
data.get("PRETTY_NAME")
or f"{data.get('ID','linux')} {data.get('VERSION_ID','')}".strip()
)
return {
"ID": data.get("ID", ""),
"VERSION_ID": data.get("VERSION_ID", ""),
"CODENAME": data.get("VERSION_CODENAME", ""),
"PRETTY": pretty,
}
def apt_update_upgrade():
with step("Updating package lists and system"):
run(["apt-get", "update", "-y"])
run(["apt-get", "-y", "upgrade"])
def apt_install(pkgs):
if not pkgs:
return
with step(f"Installing packages: {', '.join(pkgs)}"):
run(["apt-get", "install", "-y"] + pkgs)
def apt_try_install(pkgs):
if not pkgs:
return
avail = []
for p in pkgs:
ok = subprocess.run(
["apt-cache", "show", p], stdout=_devnull(), stderr=_devnull()
)
if ok.returncode == 0:
avail.append(p)
elif DEBUG:
print(f"skip missing pkg: {p}")
if avail:
apt_install(avail)
def apt_purge(pkgs):
if not pkgs:
return
with step(f"Removing conflicting packages: {', '.join(pkgs)}"):
run(["apt-get", "purge", "-y"] + pkgs, check=False)
run(["apt-get", "autoremove", "-y"], check=False)
def write_file(path: Path, content: str, mode=0o644):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
os.chmod(path, mode)
def parse_version(version_str: str) -> tuple[int, int, int]:
match = re.match(r"^v?(\d+)\.(\d+)\.(\d+)", (version_str or "").strip())
return tuple(map(int, match.groups())) if match else (0, 0, 0)
def interactive_install_mode():
print("=" * 70)
print("NGINX PROXY MANAGER - INTERACTIVE INSTALLATION")
print("=" * 70)
if (input("1) Fresh install 2) Update existing [1]: ").strip() or "1") == "2":
return {"update": True}
if (input("Source: 1) stable release 2) branch [1]: ").strip() or "1") == "2":
choice = input("Branch: 1) master 2) develop 3) custom [1]: ").strip() or "1"
branch = {"1": "master", "2": "develop"}.get(choice)
if not branch:
branch = input("Branch name: ").strip() or "master"
return {"update": False, "branch": branch, "npm_version": None}
version = input("NPM version (empty = latest stable): ").strip() or None
return {"update": False, "branch": None, "npm_version": version}
def apply_interactive_choices(args, choices):
args.update = choices.get("update", False)
args.branch = choices.get("branch")
args.npm_version = choices.get("npm_version")
return args
def _memory_gb():
"""Return RAM and swap information in GiB."""
try:
values = {}
for line in Path("/proc/meminfo").read_text().splitlines():
key, value = line.split(":", 1)
values[key] = int(value.split()[0])
return {
"ram_total": values.get("MemTotal", 0) / (1024**2),
"ram_available": values.get("MemAvailable", values.get("MemFree", 0)) / (1024**2),
"swap_total": values.get("SwapTotal", 0) / (1024**2),
"swap_free": values.get("SwapFree", 0) / (1024**2),
}
except Exception:
try:
page_size = os.sysconf("SC_PAGE_SIZE")
return {
"ram_total": page_size * os.sysconf("SC_PHYS_PAGES") / (1024**3),
"ram_available": page_size * os.sysconf("SC_AVPHYS_PAGES") / (1024**3),
"swap_total": 0.0,
"swap_free": 0.0,
}
except Exception:
return {
"ram_total": 0.0,
"ram_available": 0.0,
"swap_total": 0.0,
"swap_free": 0.0,
}
def _container_type() -> str | None:
"""Return container type when running inside one (for example: lxc, docker)."""
if shutil.which("systemd-detect-virt"):
try:
result = subprocess.run(
["systemd-detect-virt", "--container"],
check=False,
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
value = (result.stdout or "").strip().lower()
if value and value != "none":
return value
except Exception:
pass
marker = Path("/run/systemd/container")
try:
if marker.exists():
value = marker.read_text(encoding="utf-8", errors="ignore").strip().lower()
if value:
return value
except Exception:
pass
try:
cgroup = Path("/proc/1/cgroup").read_text(encoding="utf-8", errors="ignore").lower()
for name in ("lxc", "docker", "podman", "containerd"):
if name in cgroup:
return name
except Exception:
pass
return None
def _print_manual_memory_help(container_type: str | None, available_gb: float):
print("\n" + "=" * 70)
print("INSUFFICIENT MEMORY FOR INSTALLATION / UPDATE")
print("=" * 70)
if container_type:
print(f"Container detected: {container_type}")
print("Automatic swapon inside a container is usually not permitted.")
else:
print("Automatic temporary swap could not be enabled.")
print(f"Available RAM + free swap: {available_gb:.1f} GB")
print(f"Required: {MIN_MEMORY_GB:.1f} GB")
print("")
print("Temporarily increase RAM or swap before running the installer again.")
if container_type:
print("For LXC, configure RAM/swap on the host or hypervisor, not inside the container.")
print("After the installation/update completes, you can restore the previous limits.")
print("=" * 70 + "\n")
def check_memory_and_create_swap():
"""Ensure enough RAM+swap for builds; create temporary swap only outside containers."""
memory = _memory_gb()
state = {"created": False, "activated_existing": False, "ready": True}
ram_total = memory["ram_total"]
ram_available = memory["ram_available"]
swap_total = memory["swap_total"]
swap_free = memory["swap_free"]
available_total = ram_available + swap_free
print(f"\n{'='*70}")
print("MEMORY CHECK")
print(f"{'='*70}")
if not ram_total:
print("RAM detection: unavailable")
print("Continuing without automatic swap changes.")
print(f"{'='*70}\n")
return state
print(f"Total RAM: {ram_total:.1f} GB")
print(f"Available RAM: {ram_available:.1f} GB")
print(f"Swap: {swap_free:.1f} GB free / {swap_total:.1f} GB total")
print(f"Usable now: {available_total:.1f} GB (available RAM + free swap)")
print(f"Threshold: {MIN_MEMORY_GB:.1f} GB")
if available_total >= MIN_MEMORY_GB:
print("\u2713 Memory/swap sufficient")
print(f"{'='*70}\n")
return state
print(f"\u26a0 Low working memory ({available_total:.1f} GB < {MIN_MEMORY_GB:.1f} GB)")
container_type = _container_type()
if container_type:
state["ready"] = False
_print_manual_memory_help(container_type, available_total)
return state
swap_file = Path("/swapfile")
try:
active_swap = run_out(["swapon", "--show=NAME", "--noheadings"], check=False)
if str(swap_file) in active_swap.split():
# It was already counted in SwapFree above. If that was still not enough,
# do not alter a pre-existing swap file owned by the administrator.
state["ready"] = False
_print_manual_memory_help(None, available_total)
return state
except Exception:
pass
if swap_file.exists():
try:
with step("Activating existing /swapfile"):
run(["swapon", str(swap_file)])
state["activated_existing"] = True
except subprocess.CalledProcessError:
state["ready"] = False
_print_manual_memory_help(None, available_total)
return state
memory = _memory_gb()
available_after = memory["ram_available"] + memory["swap_free"]
if available_after < MIN_MEMORY_GB:
state["ready"] = False
_print_manual_memory_help(None, available_after)
else:
print(f"\u2713 Existing /swapfile activated; usable memory: {available_after:.1f} GB")
print(f"{'='*70}\n")
return state
try:
with step(f"Creating temporary {SWAP_SIZE_GB:g} GB swap"):
if shutil.which("fallocate"):
run(["fallocate", "-l", f"{SWAP_SIZE_GB:g}G", str(swap_file)])
else:
run([
"dd", "if=/dev/zero", f"of={swap_file}", "bs=1M",
f"count={int(SWAP_SIZE_GB * 1024)}", "status=none",
])
state["created"] = True
run(["chmod", "600", str(swap_file)])
run(["mkswap", str(swap_file)])
run(["swapon", str(swap_file)])
except subprocess.CalledProcessError:
if state["created"]:
run(["swapoff", str(swap_file)], check=False)
swap_file.unlink(missing_ok=True)
state["created"] = False
state["ready"] = False
_print_manual_memory_help(None, available_total)
return state
except BaseException:
if state["created"]:
run(["swapoff", str(swap_file)], check=False)
swap_file.unlink(missing_ok=True)
state["created"] = False
raise
memory = _memory_gb()
available_after = memory["ram_available"] + memory["swap_free"]
if available_after < MIN_MEMORY_GB:
state["ready"] = False
_print_manual_memory_help(None, available_after)
else:
print(f"\u2713 Temporary swap created; usable memory: {available_after:.1f} GB")
print(f"{'='*70}\n")
return state
def cleanup_build_artifacts():
with step("Cleaning old build artifacts"):
patterns = (
"/tmp/npm-*", "/tmp/yarn-*", "/tmp/node-*",
"/tmp/v8-compile-cache-*", "/tmp/npm-angie-*", "/tmp/npm-update-*",
)
for pattern in patterns:
for raw_path in glob(pattern):
path = Path(raw_path)
try:
shutil.rmtree(path, ignore_errors=True) if path.is_dir() else path.unlink(missing_ok=True)
except Exception as exc:
if DEBUG:
print(f" ⚠ Could not remove {path}: {exc}")
for cache_dir in {Path("/root/.yarn"), Path("/root/.cache/yarn"), Path.home() / ".yarn", Path.home() / ".cache/yarn"}:
for name in ("cache", "global", "install-state.gz"):
target = cache_dir / name
if target.is_dir():
shutil.rmtree(target, ignore_errors=True)
else:
target.unlink(missing_ok=True)
for rc_file in (Path("/root/.yarnrc"), Path("/root/.yarnrc.yml")):
rc_file.unlink(missing_ok=True)
def cleanup_swap(state=None):
"""Undo only swap changes made by this installer run."""
state = state or {}
swap_file = Path("/swapfile")
try:
if state.get("created"):
with step("Removing temporary swap"):
run(["swapoff", str(swap_file)], check=False)
swap_file.unlink(missing_ok=True)
elif state.get("activated_existing"):
with step("Restoring existing swap state"):
run(["swapoff", str(swap_file)], check=False)
except Exception as exc:
print(f"⚠ Could not restore swap state: {exc}")
def _github_request(url):
return urllib.request.Request(
url,
headers={"User-Agent": "npm-angie-auto-install/2"},
)
def github_latest_release_tag(repo: str, override: str = None) -> str:
if override:
return override.lstrip("v")
if "/" not in repo:
repo = f"{GITHUB_REPO_OWNER}/{GITHUB_REPO_NAME}"
url = f"https://api.github.com/repos/{repo}/releases/latest"
with step(f"Checking latest NPM release: {repo}"):
with urllib.request.urlopen(_github_request(url), timeout=30) as response:
data = json.load(response)
return data["tag_name"].lstrip("v")
def _sanitize_angie_log_config(text: str) -> str:
"""Make log formats valid in global http/stream context.
`$server` is only defined inside generated proxy_host server blocks. When it
appears in a global log_format, `angie -t` fails with: unknown "server"
variable. Use built-in `$upstream_addr` instead.
"""
return text.replace("$server", "$upstream_addr")
def ensure_angie_log_include_files():
"""Ensure split log include files required by ANGIE_CONF_TEMPLATE exist.
Older installs may only have /etc/angie/conf.d/include/log.conf from the
upstream NPM rootfs. The Angie template used by this installer includes
log-proxy.conf in http{} and log-stream.conf in stream{}, so fresh/update
mode must create them before running `angie -t`.
"""
include_dir = Path("/etc/angie/conf.d/include")
include_dir.mkdir(parents=True, exist_ok=True)
log_conf = include_dir / "log.conf"
log_proxy = include_dir / "log-proxy.conf"
log_stream = include_dir / "log-stream.conf"
default_proxy = """log_format proxy '[$time_local] $upstream_cache_status $upstream_status $status - $request_method $scheme $host "$request_uri" [Client $remote_addr] [Length $body_bytes_sent] [Gzip $gzip_ratio] [Sent-to $upstream_addr] "$http_user_agent" "$http_referer"';
log_format standard '[$time_local] $status - $request_method $scheme $host "$request_uri" [Client $remote_addr] [Length $body_bytes_sent] [Gzip $gzip_ratio] "$http_user_agent" "$http_referer"';
access_log /data/logs/fallback_access.log proxy;
"""
default_stream = """log_format stream '[$time_local] [Client $remote_addr:$remote_port] $protocol $status $bytes_sent $bytes_received $session_time [Sent-to $upstream_addr] [Sent $upstream_bytes_sent] [Received $upstream_bytes_received] [Time $upstream_connect_time] $ssl_protocol $ssl_cipher';
access_log /data/logs/fallback_stream_access.log stream;
"""
try:
if log_conf.exists():
proxy_text = _sanitize_angie_log_config(log_conf.read_text(encoding="utf-8"))
if not proxy_text.strip():
proxy_text = default_proxy
else:
proxy_text = default_proxy
if not log_proxy.exists():
write_file(log_proxy, proxy_text, 0o644)
print(f" ✓ Created missing {log_proxy.name}")
else:
current = log_proxy.read_text(encoding="utf-8")
sanitized = _sanitize_angie_log_config(current)
if sanitized != current:
write_file(log_proxy, sanitized, 0o644)
print(f" ✓ Fixed invalid $server variable in {log_proxy.name}")
if not log_stream.exists():
write_file(log_stream, default_stream, 0o644)
print(f" ✓ Created missing {log_stream.name}")
# Keep legacy log.conf present for compatibility with older/custom configs.
if not log_conf.exists():
write_file(log_conf, proxy_text, 0o644)
print(f" ✓ Created missing {log_conf.name}")
else:
current = log_conf.read_text(encoding="utf-8")
sanitized = _sanitize_angie_log_config(current)
if sanitized != current:
write_file(log_conf, sanitized, 0o644)
print(f" ✓ Fixed invalid $server variable in {log_conf.name}")
run(["chown", "root:root", str(log_proxy), str(log_stream), str(log_conf)], check=False)
except Exception as e:
print(f" ⚠ Warning: could not ensure log include files: {e}")
def write_resolvers_conf(ipv6_enabled: bool):
ns_v4, ns_v6 = [], []
try:
for line in Path("/etc/resolv.conf").read_text().splitlines():
line = line.strip()
if not line.startswith("nameserver"):
continue
ip = line.split()[1].split("%")[0]
(ns_v6 if ":" in ip else ns_v4).append(ip)
except Exception:
pass
ips = ns_v4 + (ns_v6 if ipv6_enabled else [])
cloudflare_ips = ["1.1.1.1"] + (["2606:4700:4700::1111"] if ipv6_enabled else [])
google_ips = ["8.8.8.8"] + (["2001:4860:4860::8888"] if ipv6_enabled else [])
if not ips:
ips = cloudflare_ips + google_ips
ipv6_flag = " ipv6=on" if ipv6_enabled and any(":" in x for x in ips) else ""
if ns_v4 or ns_v6:
status_zone = "status_zone=default_resolver"
elif all(ip in cloudflare_ips for ip in ips):
status_zone = "status_zone=cloudflare_resolver"
elif all(ip in google_ips for ip in ips):
status_zone = "status_zone=google_resolver"
else:
status_zone = "status_zone=mixed_resolver"
content = f"resolver {' '.join(ips)} valid=10s {status_zone}{ipv6_flag};\n"
write_file(Path("/etc/angie/conf.d/include/resolvers.conf"), content, 0o644)
def validate_nodejs_version(version: str) -> tuple[bool, str, str | None]:
version_map = {"latest": str(MAX_NODEJS_VERSION), "lts": str(MAX_NODEJS_VERSION), "current": str(MAX_NODEJS_VERSION)}
resolved = version_map.get(version.lower(), version)
match = re.match(r"(\d+)", resolved)
if not match:
return False, resolved, f"Invalid version format: {version}"
major_version = int(match.group(1))
if major_version < MIN_NODEJS_VERSION:
return (
False,
str(MIN_NODEJS_VERSION),
f"⚠ Requested Node.js v{major_version} is below the supported minimum; "
f"using v{MIN_NODEJS_VERSION}.",
)
if major_version > MAX_NODEJS_VERSION:
return (
False,
str(MAX_NODEJS_VERSION),
f"⚠ Requested Node.js v{major_version} exceeds the tested maximum; "
f"using v{MAX_NODEJS_VERSION}.",
)
return True, resolved, None
def validate_supported_os():
distro_id = OSREL.get("ID", "").lower()
version_id = OSREL.get("VERSION_ID", "").strip()
SUPPORTED = {"debian": ["11", "12", "13"], "ubuntu": ["22.04", "24.04", "26.04"]}
if distro_id not in SUPPORTED:
print(f"\n ⚠ ERROR: Unsupported distribution: {distro_id}")
print(f" Detected: {OSREL.get('PRETTY', 'Unknown')}")
print(f"\n Supported distributions:")
print(f" • Debian 11, 12 (Bookworm), 13 (Trixie)")
print(f" • Ubuntu 22.04 LTS, 24.04 LTS, 26.04 LTS")
print(f" • Debian derivatives: Proxmox, armbian")
print(f"\n Your distribution may work but is not tested.")
print(f" Continue at your own risk or install on a supported system.\n")
sys.exit(1)
supported_versions = SUPPORTED[distro_id]
version_match = False
for supported_ver in supported_versions:
if version_id.startswith(supported_ver):
version_match = True
break
if not version_match:
print(f"\n ⚠ WARNING: Unsupported version of {distro_id}: {version_id}")
print(f" Detected: {OSREL.get('PRETTY', 'Unknown')}")
print(f" Supported versions: {', '.join(supported_versions)}")
print(f"\n This version is not officially tested.")
print(f" Prerequisites:")
print(f" • Angie packages must be available for your distribution")
print(
f" • Check: https://en.angie.software/angie/docs/installation/oss_packages/"
)
print(f" • Your system should be Debian/Ubuntu compatible (apt-based)")
response = input("\n Continue anyway? [y/N]: ").strip().lower()
if response not in ["y", "yes"]:
print("\n Installation cancelled.\n")
sys.exit(1)
print()
else:
print(f"✓ Supported OS detected: {OSREL.get('PRETTY', 'Unknown')}\n")
def save_installer_config(config: dict):
config_path = Path("/data/installer.json")
config_path.parent.mkdir(parents=True, exist_ok=True)
config["last_modified"] = time.strftime("%Y-%m-%d %H:%M:%S")
try:
config_path.write_text(json.dumps(config, indent=2), encoding="utf-8")
if DEBUG:
print(f"✓ Saved installer config to {config_path}")
except Exception as e:
print(f"⚠ Warning: Could not save installer config: {e}")
def load_installer_config() -> dict:
config_path = Path("/data/installer.json")
if not config_path.exists():
if DEBUG:
print(f"No installer config found at {config_path}")
return {}
try:
content = config_path.read_text(encoding="utf-8")
config = json.loads(content)
if DEBUG:
print(f"✓ Loaded installer config from {config_path}")
return config
except Exception as e:
print(f"⚠ Warning: Could not load installer config: {e}")
return {}
def comment_x_served_by_step(path="/etc/angie/conf.d/include/proxy.conf"):
p = Path(path)
if not p.exists():
if DEBUG:
print(f" ⊘ Skip X-Served-By patch; missing {path}")
return 0
src = p.read_text()
pattern = re.compile(
r"^(?P<ws>\s*)(?!#)\s*add_header\s+X-Served-By\s+\$host\s*;\s*$", re.MULTILINE
)
count = len(pattern.findall(src))
if count == 0:
return 0
backup = p.with_suffix(p.suffix + ".bak")
shutil.copy2(p, backup)
out = pattern.sub(
lambda m: f"{m.group('ws')}# add_header X-Served-By $host;", src
)
fd, tmp = tempfile.mkstemp(dir=str(p.parent))
os.close(fd)
Path(tmp).write_text(out)
shutil.copymode(p, tmp)
os.replace(tmp, p)
print(f"✔ Hide X-Served-by header | backup: {backup}")
return count
def set_file_ownership(files: list[str | Path], owner: str, mode: int | None = None):
ok = True
for raw_path in files:
path = Path(raw_path)
if not path.exists():
if DEBUG:
print(f" ⊘ Missing: {path}")
ok = False
continue
try:
run(["chown", owner, str(path)])
if mode is not None:
os.chmod(path, mode)
except Exception as exc:
print(f"⚠ Could not set ownership for {path}: {exc}")
ok = False
return ok
def check_distro_nodejs_available():
try:
result = subprocess.run(
["apt-cache", "show", "nodejs"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if result.returncode == 0:
for line in result.stdout.splitlines():
if line.startswith("Version:"):
version_str = line.split(":", 1)[1].strip()
match = re.match(r"(\d+)", version_str)
if match:
major = int(match.group(1))
if DEBUG:
print(
f"✓ Distro has nodejs v{version_str} (major: {major})"
)
return True, major, version_str
return False, None, None
except Exception as e:
if DEBUG:
print(f"Failed to check distro nodejs: {e}")
return False, None, None
def install_nodejs_from_distro():
with step("Installing Node.js from distribution repositories"):
apt_install(["nodejs"])
if not shutil.which("npm"):
apt_try_install(["npm"])
if shutil.which("node"):
node_ver = run_out(["node", "--version"], check=False).strip()
print(f" Node.js: {node_ver}")
if shutil.which("npm"):
npm_ver = run_out(["npm", "--version"], check=False).strip()
print(f" npm: {npm_ver}")
return True
return False
def _installed_node_major() -> int | None:
if not shutil.which("node"):
return None
match = re.match(r"v?(\d+)", run_out(["node", "--version"], check=False).strip())
return int(match.group(1)) if match else None
def _requested_node_major(value) -> int | None:
if value is None:
return None
_, resolved, warning = validate_nodejs_version(str(value))
if warning:
print(warning)
match = re.match(r"(\d+)", resolved)
return int(match.group(1)) if match else None
def ensure_minimum_nodejs(min_version=MIN_NODEJS_VERSION, user_requested_version=None):
"""Ensure a tested Node.js version and npm are available."""
requested = _requested_node_major(user_requested_version)
current = _installed_node_major()
if current is not None and shutil.which("npm"):
wanted = current == requested if requested is not None else min_version <= current <= MAX_NODEJS_VERSION
if wanted:
print(f"✓ Node.js: {run_out(['node', '--version'], check=False).strip()}")
print(f" npm: {run_out(['npm', '--version'], check=False).strip()}")
return True
if requested is not None:
install_node_from_nodesource(str(requested))
else:
has_distro, major, version = check_distro_nodejs_available()
if has_distro and major is not None and min_version <= major <= MAX_NODEJS_VERSION:
print(f"✓ Distribution provides Node.js {version}")
if not install_nodejs_from_distro():
install_node_from_nodesource(str(min_version))
else:
if has_distro and major is not None:
print(f"⚠ Distribution Node.js {version} outside tested range "
f"{min_version}-{MAX_NODEJS_VERSION}")
install_node_from_nodesource(str(min_version))
if not shutil.which("node") or not shutil.which("npm"):
raise RuntimeError("Node.js/npm installation failed")
return True
def download_extract_tar_gz(url: str, dest_dir: Path) -> Path:
dest_dir.mkdir(parents=True, exist_ok=True)
tmp_path = None
with step("Downloading and extracting NPM source"):
try:
with urllib.request.urlopen(_github_request(url), timeout=60) as response, \
tempfile.NamedTemporaryFile(delete=False) as tmp:
shutil.copyfileobj(response, tmp)
tmp_path = Path(tmp.name)
with tarfile.open(tmp_path, "r:gz") as archive:
top = archive.getmembers()[0].name.split("/", 1)[0]
try:
archive.extractall(dest_dir, filter="data")
except TypeError: # Python < 3.12
archive.extractall(dest_dir)
return dest_dir / top
finally:
if tmp_path:
tmp_path.unlink(missing_ok=True)
# Distro info (used in banners & repo setup)
OSREL = os_release()
# === extra sync ===
CERTBOT_REQUIRED_PACKAGES = [
"certbot",
"acme",
"certbot-dns-cloudflare",
"certbot-dns-rfc2136",
]
def _ensure_certbot_symlink(certbot_path: Path):
Path("/usr/local/bin").mkdir(parents=True, exist_ok=True)
target = Path("/usr/local/bin/certbot")
if target.exists() or target.is_symlink():
try:
target.unlink()
except Exception:
pass
target.symlink_to(certbot_path)
def _detect_certbot_version(certbot_path: Path) -> str:
cb_ver = run_out([str(certbot_path), "--version"], check=False).strip()
m = re.search(r"(\d+\.\d+\.\d+)", cb_ver)
if not m:
raise RuntimeError(f"Cannot detect certbot version from: {cb_ver!r}")
return m.group(1)
def certbot_version_for_npm() -> str:
"""Return the certbot version that NPM must expose as CERTBOT_VERSION.
NPM v2.15.x replaces {{certbot-version}} in dns-plugins.json from the
CERTBOT_VERSION environment variable. If it is missing, Node turns it into
"undefined" and pip receives invalid requirements such as acme==undefined.
"""
candidates = [
Path("/opt/certbot/bin/certbot"),
Path("/usr/local/bin/certbot"),
]
for certbot_path in candidates:
if certbot_path.exists():
try:
return _detect_certbot_version(certbot_path)
except Exception:
pass
if shutil.which("certbot"):
try:
out = run_out(["certbot", "--version"], check=False).strip()
m = re.search(r"(\d+\.\d+\.\d+)", out)
if m:
return m.group(1)
except Exception:
pass
return ""
def patch_npm_certbot_plugins_config() -> str:
"""Patch NPM DNS plugin metadata with a concrete certbot version.
This is a non-docker install. NPM normally expects CERTBOT_VERSION in the
container environment. We set the env in systemd and also replace the JSON
placeholders after every install/update so startup cannot generate
acme==undefined even if the process environment is incomplete.
"""
certbot_ver = certbot_version_for_npm()
if not certbot_ver:
print("⚠ Could not detect Certbot version for NPM DNS plugin metadata")
return ""
os.environ["CERTBOT_VERSION"] = certbot_ver
patched = []
for path in [
Path("/opt/npm/certbot/dns-plugins.json"),
Path("/opt/npm/backend/certbot/dns-plugins.json"),
]:
if not path.exists():
continue
try:
txt = path.read_text(encoding="utf-8")
new = txt.replace("{{certbot-version}}", certbot_ver)
new = new.replace("acme==undefined", f"acme=={certbot_ver}")
if new != txt:
path.write_text(new, encoding="utf-8")
patched.append(str(path))
except Exception as e:
print(f"⚠ Could not patch {path}: {e}")
if patched:
print(f"✔ Patched NPM Certbot plugin metadata: Certbot {certbot_ver}")
if DEBUG:
for path in patched:
print(f" - {path}")
else:
print(f"✔ NPM Certbot plugin metadata ready: Certbot {certbot_ver}")
return certbot_ver
def run_logged(cmd, log_path: Path, timeout=1200, check=True, env=None, cwd=None):
"""Run a command and keep its full output in a diagnostic log."""
log_path = Path(log_path)
log_path.parent.mkdir(parents=True, exist_ok=True)
with log_path.open("a", encoding="utf-8", errors="replace") as log:
log.write("\n\n$ " + " ".join(map(str, cmd)) + "\n")
log.flush()
result = subprocess.run(
cmd,
timeout=timeout,
check=False,
env=env,
cwd=cwd,
stdout=log,
stderr=subprocess.STDOUT,
text=True,
)
if check and result.returncode != 0:
print(f" ✖ Command failed, log: {log_path}")
try:
tail = log_path.read_text(encoding="utf-8", errors="replace").splitlines()[-40:]
if tail:
print(" --- log tail ---")
for line in tail:
print(" " + line[:220])
print(" --- end log tail ---")
except Exception:
pass
raise subprocess.CalledProcessError(result.returncode, cmd)
return result
def _python_version(exe: str) -> tuple[int, int] | None:
try:
out = run_out([exe, "--version"], check=False).strip()
m = re.search(r"Python\s+(\d+)\.(\d+)", out)
if m:
return (int(m.group(1)), int(m.group(2)))
except Exception:
pass
return None
def _find_certbot_system_python() -> tuple[str | None, str | None]:
"""Prefer python3.11 when installed; otherwise use distro python >= 3.11.
Debian 13 ships a newer Python (for example 3.13). That is suitable for the
certbot venv and avoids compiling Python 3.11 through pyenv on fresh/update.
"""
candidates = ["python3.11", "python3", "python3.13", "python3.12"]
seen = set()
for exe in candidates:
if exe in seen or not shutil.which(exe):
continue
seen.add(exe)
ver = _python_version(exe)
if ver and ver[0] == 3 and ver[1] >= 11:
out = run_out([exe, "--version"], check=False).strip()
return exe, out
return None, None
def _create_certbot_venv_with_python(python_exe: str, python_label: str, venv_dir: Path):
with step(f"Using {python_label} for certbot venv"):
venv_dir.mkdir(parents=True, exist_ok=True)
run([python_exe, "-m", "venv", str(venv_dir)])
env_build = os.environ.copy()
env_build["SETUPTOOLS_USE_DISTUTILS"] = "local"
_install_certbot_stack(
[str(venv_dir / "bin" / "pip")],
venv_dir / "bin" / "certbot",
env_build,
)
certbot_path = venv_dir / "bin" / "certbot"
pip_path = venv_dir / "bin" / "pip"
print(f" Python: {python_label}")
print(f" Certbot: {run_out([str(certbot_path), '--version'], check=False).strip()}")
print(f" Pip: {run_out([str(pip_path), '--version'], check=False).strip().split(' from ')[0]}")
def _install_certbot_stack(pip_cmd: list[str], certbot_path: Path, env_build: dict):
"""Install Certbot plus the DNS plugins required by this native NPM setup."""
log_path = Path("/tmp/npm-certbot-venv.log")
def pip(*args):
run_logged([*pip_cmd, *args], log_path, env=env_build)
pip("install", "-U", "pip", "setuptools", "wheel")
pip("install", "-U", "cryptography", "cffi", "certbot", "tldextract")
certbot_ver = _detect_certbot_version(certbot_path)
pip(
"install", "-U",
f"acme=={certbot_ver}",
f"certbot-dns-cloudflare=={certbot_ver}",
f"certbot-dns-rfc2136=={certbot_ver}",
)
missing = []
for package in CERTBOT_REQUIRED_PACKAGES:
result = subprocess.run(
[*pip_cmd, "show", package],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
if result.returncode != 0:
missing.append(package)
if missing:
raise RuntimeError(f"Certbot venv incomplete, missing: {', '.join(missing)}")
_ensure_certbot_symlink(certbot_path)
def _venv_entrypoint_usable(path: Path, args: list[str] | None = None) -> tuple[bool, str]:
"""Return whether a venv script/binary can be executed.
Debian/Ubuntu upgrades can leave /opt/certbot/bin/* wrappers with a stale
shebang, for example /opt/certbot/bin/python3.11 no longer exists. In that
case subprocess may raise FileNotFoundError even though the wrapper file
itself exists. Treat that as a broken venv and rebuild it.
"""
args = args or ["--version"]
if not path.exists():
return False, f"missing: {path}"
try:
first_line = path.read_bytes().splitlines()[0].decode("utf-8", "ignore")
except Exception:
first_line = ""
if first_line.startswith("#!"):
interpreter = first_line[2:].strip().split()[0]
if interpreter and interpreter.startswith("/") and not Path(interpreter).exists():
return False, f"stale interpreter in {path}: {interpreter}"
try:
result = subprocess.run(
[str(path)] + args,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=20,
check=False,
)
if result.returncode == 0:
return True, "ok"
return False, f"{path} exited with code {result.returncode}"
except FileNotFoundError as e:
return False, f"cannot execute {path}: {e}"
except Exception as e:
return False, f"cannot execute {path}: {e}"
def _venv_python_path(venv_dir: Path) -> Path:
return venv_dir / "bin" / "python"
def _venv_package_installed_with_python(python_path: Path, pkg: str) -> bool:
try:
result = subprocess.run(
[str(python_path), "-m", "pip", "show", pkg],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=30,
check=False,
)
return result.returncode == 0
except Exception:
return False
def _try_repair_existing_certbot_venv(venv_dir: Path, reason: str) -> bool:
python_path = _venv_python_path(venv_dir)
certbot_path = venv_dir / "bin" / "certbot"
python_ok, python_reason = _venv_entrypoint_usable(python_path, ["--version"])
if not python_ok:
if DEBUG:
print(f" Existing certbot venv python unusable: {python_reason}")
return False
try:
with step(f"Repairing existing certbot venv ({reason})"):
env_build = os.environ.copy()
env_build["SETUPTOOLS_USE_DISTUTILS"] = "local"
_install_certbot_stack(
[str(python_path), "-m", "pip"],
certbot_path,
env_build,
)
print(f"✓ Existing certbot venv repaired: {_detect_certbot_version(certbot_path)}")
return True
except Exception as exc:
print(f"⚠ Could not repair existing certbot venv: {exc}")
print(" Falling back to full rebuild.")
return False
def ensure_certbot_venv_ready(venv_dir: Path = Path("/opt/certbot"), force_rebuild: bool = False):
certbot_path = venv_dir / "bin" / "certbot"
pip_path = venv_dir / "bin" / "pip"
python_path = _venv_python_path(venv_dir)
if force_rebuild and venv_dir.exists():
with step("Removing certbot venv for forced rebuild"):
shutil.rmtree(venv_dir, ignore_errors=True)
if not venv_dir.exists():
setup_certbot_venv(venv_dir)
run(["chown", "-R", "npm:npm", str(venv_dir)], check=False)
return True
python_ok, python_reason = _venv_entrypoint_usable(python_path, ["--version"])
certbot_ok, certbot_reason = _venv_entrypoint_usable(certbot_path, ["--version"])
pip_ok, pip_reason = _venv_entrypoint_usable(pip_path, ["--version"])
missing_pkg = None
packages_ok = python_ok
if packages_ok:
for package in CERTBOT_REQUIRED_PACKAGES:
if not _venv_package_installed_with_python(python_path, package):
missing_pkg = package
packages_ok = False
break
if python_ok and certbot_ok and packages_ok and pip_ok:
_ensure_certbot_symlink(certbot_path)
print(f"✓ Existing certbot venv is complete: {_detect_certbot_version(certbot_path)}")
run(["chown", "-R", "npm:npm", str(venv_dir)], check=False)
return True
reason = (
python_reason if not python_ok else
certbot_reason if not certbot_ok else
f"missing package: {missing_pkg}" if missing_pkg else
pip_reason
)
if python_ok and _try_repair_existing_certbot_venv(venv_dir, reason):
run(["chown", "-R", "npm:npm", str(venv_dir)], check=False)
return True
with step(f"Removing broken certbot venv ({reason})"):
shutil.rmtree(venv_dir, ignore_errors=True)
setup_certbot_venv(venv_dir)
run(["chown", "-R", "npm:npm", str(venv_dir)], check=False)
return True
def setup_certbot_venv(venv_dir: Path = Path("/opt/certbot")):
info = os_release()
distro_id = (info.get("ID") or "").lower()
version_id = (info.get("VERSION_ID") or "").strip()
# Prefer system Python 3.11 if present. On Debian 13, use distro Python
# (for example Python 3.13) instead of compiling Python 3.11 via pyenv.
if distro_id == "debian" and version_id.startswith("13"):
apt_try_install(["python3", "python3-venv", "python3-pip", "python3-dev"])
else:
apt_try_install(["python3.11-venv", "python3-venv", "python3-pip"])
python_exe, python_label = _find_certbot_system_python()
if python_exe:
_create_certbot_venv_with_python(python_exe, python_label, venv_dir)
return
# Ubuntu fallback: install Python 3.11 from deadsnakes when no suitable
# system Python is available.
if distro_id == "ubuntu":
with step(
f"Ubuntu detected: {info.get('PRETTY','Ubuntu')}. Install Python 3.11 via deadsnakes"
):
try:
run(["apt-get", "update", "-y"], check=False)
apt_try_install(["software-properties-common"])
except Exception:
run(["apt-get", "install", "-y", "software-properties-common"], check=False)
run(["add-apt-repository", "-y", "ppa:deadsnakes/ppa"])
run(["apt-get", "update", "-y"], check=False)
run(["apt-get", "install", "-y", "python3.11", "python3.11-venv"])
_create_certbot_venv_with_python("python3.11", "Python 3.11 (deadsnakes)", venv_dir)
return
# Last resort only: pyenv. Debian 13 should not normally reach this path,
# because it has a suitable distro Python.
PYENV_ROOT = Path("/opt/npm/.pyenv")
PYENV_OWNER = "npm"
PYTHON_VERSION = "3.11.14"
pyenv_log = Path("/tmp/npm-pyenv-python-build.log")
with step("Installing pyenv build dependencies"):
apt_install(
[
"build-essential",
"gcc",
"make",
"pkg-config",
"libssl-dev",
"zlib1g-dev",
"libbz2-dev",
"libreadline-dev",
"libsqlite3-dev",
"tk-dev",
"libncursesw5-dev",
"libgdbm-dev",
"libffi-dev",
"uuid-dev",
"liblzma-dev",
"curl",
"git",
"ca-certificates",
]
)
Path("/opt/npm").mkdir(parents=True, exist_ok=True)
PYENV_ROOT.mkdir(parents=True, exist_ok=True)
run(["chown", "-R", f"{PYENV_OWNER}:{PYENV_OWNER}", "/opt/npm"], check=False)
with step(f"Ensuring pyenv is available at {PYENV_ROOT}"):
pyenv_bin_path = PYENV_ROOT / "bin" / "pyenv"
if not pyenv_bin_path.exists():
run_logged(
[
"sudo",
"-u",
PYENV_OWNER,
"bash",
"-lc",
'if [ ! -x "/opt/npm/.pyenv/bin/pyenv" ]; then git clone --depth=1 https://github.com/pyenv/pyenv.git /opt/npm/.pyenv; fi',
],
pyenv_log,
)
pyenv_bin = PYENV_ROOT / "bin" / "pyenv"
if not pyenv_bin.exists():
raise RuntimeError("No 'pyenv' found even after git clone attempt.")
with step(f"Installing Python {PYTHON_VERSION} via pyenv into {PYENV_ROOT}"):
run(["mkdir", "-p", str(PYENV_ROOT)])
run(["chown", "-R", f"{PYENV_OWNER}:{PYENV_OWNER}", "/opt/npm"], check=False)
install_cmd = (
"export HOME=/opt/npm; "
"export PYENV_ROOT=/opt/npm/.pyenv; "
'export PATH="$PYENV_ROOT/bin:/usr/bin:/bin"; '
'mkdir -p "$PYENV_ROOT"; cd "$HOME"; '
f"pyenv install -v -s {PYTHON_VERSION}"
)
run_logged(
[
"sudo",
"-u",
PYENV_OWNER,
"env",
"-i",
"HOME=/opt/npm",
f"PYENV_ROOT={PYENV_ROOT}",
f"PATH={PYENV_ROOT}/bin:/usr/bin:/bin",
"bash",
"-lc",
install_cmd,
],
pyenv_log,
timeout=3600,
)
profile_snippet = f"""# Auto-generated by npm-angie-auto-install
# pyenv for '{PYENV_OWNER}'
if [ -d "{PYENV_ROOT}" ]; then
export PYENV_ROOT="{PYENV_ROOT}"
case ":$PATH:" in *":{PYENV_ROOT}/bin:"*) ;; *) PATH="{PYENV_ROOT}/bin:$PATH";; esac
case ":$PATH:" in *":/usr/lib/pyenv/bin:"*) ;; *) PATH="/usr/lib/pyenv/bin:$PATH";; esac
export PATH
case "$-" in *i*) _interactive=1 ;; *) _interactive=0 ;; esac
if [ "$_interactive" = 1 ] && {{ [ "${{USER:-}}" = "{PYENV_OWNER}" ] || [ "${{SUDO_USER:-}}" = "{PYENV_OWNER}" ]; }}; then
if command -v pyenv >/dev/null 2>&1; then
eval "$(pyenv init -)"
elif [ -x "{PYENV_ROOT}/bin/pyenv" ]; then
eval "$("{PYENV_ROOT}/bin/pyenv" init -)"
fi
fi
fi
"""
write_file(Path("/etc/profile.d/npm-pyenv.sh"), profile_snippet, 0o644)
python311 = PYENV_ROOT / "versions" / PYTHON_VERSION / "bin" / "python3.11"
if not python311.exists():
python311 = PYENV_ROOT / "versions" / PYTHON_VERSION / "bin" / "python3"
if not python311.exists():
raise RuntimeError(f"No python {PYTHON_VERSION} in {PYENV_ROOT}/versions/.")
_create_certbot_venv_with_python(str(python311), f"Python {PYTHON_VERSION} (pyenv)", venv_dir)
run(["chown", "-R", f"{PYENV_OWNER}:{PYENV_OWNER}", str(PYENV_ROOT)], check=False)
def configure_letsencrypt():
with step("configure letsencrypt"):
run(["chown", "-R", "npm:npm", "/opt/certbot"], check=False)
Path("/etc/letsencrypt").mkdir(parents=True, exist_ok=True)
run(["chown", "-R", "npm:npm", "/etc/letsencrypt"], check=False)
# Do not install distro certbot here; use /opt/certbot venv only.
ini = """text = True
non-interactive = True
webroot-path = /data/letsencrypt-acme-challenge
key-type = ecdsa
elliptic-curve = secp384r1
preferred-chain = ISRG Root X1
"""
write_file(Path("/etc/letsencrypt.ini"), ini, 0o644)
run(["chown", "-R", "npm:npm", "/etc/letsencrypt"], check=False)
def ensure_nginx_symlink():
"""Keep /etc/nginx as a compatibility symlink to Angie configuration."""
target = Path("/etc/angie")
link = Path("/etc/nginx")
target.mkdir(parents=True, exist_ok=True)
if link.is_symlink():
try:
if link.resolve() == target:
return
except OSError:
pass
link.unlink()
elif link.exists():
backup = Path("/etc/nginx.bak")
if backup.exists() or backup.is_symlink():
backup = Path(f"/etc/nginx.bak-{time.strftime('%Y%m%d-%H%M%S')}")
link.rename(backup)
print(f"✔ Backed up /etc/nginx to {backup}")
link.symlink_to(target, target_is_directory=True)
print("✔ Created symlink /etc/nginx -> /etc/angie")
# ========== Angie / NPM template ==========
ANGIE_CONF_TEMPLATE = r"""# run angie in foreground
#daemon off;
load_module /etc/angie/modules/ngx_http_headers_more_filter_module.so;
load_module /etc/angie/modules/ngx_http_brotli_filter_module.so;
load_module /etc/angie/modules/ngx_http_brotli_static_module.so;
load_module /etc/angie/modules/ngx_http_zstd_filter_module.so;
load_module /etc/angie/modules/ngx_http_zstd_static_module.so;
load_module /etc/angie/modules/ngx_http_echo_module.so;
# other modules
include /data/nginx/custom/modules[.]conf;
pid /run/angie/angie.pid;
user root;
worker_processes auto;
pcre_jit on;
error_log /data/logs/fallback_error.log warn;
# Custom
include /data/nginx/custom/root_top[.]conf;
events {
include /data/nginx/custom/events[.]conf;
}
http {
include /etc/angie/mime.types;
default_type application/octet-stream;
sendfile on;
server_tokens off;
tcp_nopush on;
tcp_nodelay on;
client_body_temp_path /tmp/angie/body 1 2;
keepalive_timeout 90s;
proxy_connect_timeout 90s;
proxy_send_timeout 90s;
proxy_read_timeout 90s;
ssl_prefer_server_ciphers on;
#gzip on;
proxy_ignore_client_abort off;
client_max_body_size 2000m;
server_names_hash_bucket_size 1024;
proxy_http_version 1.1;
proxy_set_header X-Forwarded-Scheme $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Accept-Encoding "";
proxy_cache off;
proxy_cache_path /var/lib/angie/cache/public levels=1:2 keys_zone=public-cache:128m max_size=1g inactive=6h use_temp_path=off;
proxy_cache_path /var/lib/angie/cache/private levels=1:2 keys_zone=private-cache:10m max_size=64m inactive=1h use_temp_path=off;
# HTTP/3 global settings
http3_max_concurrent_streams 128;
http3_stream_buffer_size 64k;
# QUIC settings
quic_retry on;
quic_gso on;
quic_active_connection_id_limit 2;
# Enable BPF for connection migration (Linux 5.7+)
# quic_bpf on; # Uncomment if your kernel supports it
include /etc/angie/conf.d/include/log-proxy.conf;
include /etc/angie/conf.d/include/resolvers.conf;
map $host $forward_scheme { default http; }
# Handle upstream X-Forwarded-Proto and X-Forwarded-Scheme header
map $http_x_forwarded_proto $x_forwarded_proto {
"http" "http";
"https" "https";
default $scheme;
}
map $http_x_forwarded_scheme $x_forwarded_scheme {
"http" "http";
"https" "https";
default $scheme;
}
# Real IP Determination (IPv4 only by default)
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
include /etc/angie/conf.d/include/ip_ranges.conf;
real_ip_header X-Real-IP;
real_ip_recursive on;
# custom
map $sent_http_content_type $compressible_type {
default 0;
~*text/plain 1;
~*text/css 1;
~*text/xml 1;
~*text/javascript 1;
~*application/javascript 1;
~*application/x-javascript 1;
~*application/json 1;
~*application/xml 1;
~*application/xml\+rss 1;
~*application/rss\+xml 1;
~*image/svg\+xml 1;
~*font/truetype 1;
~*font/opentype 1;
~*font/woff 1;
~*font/woff2 1;
~*application/font-woff 1;
~*application/font-woff2 1;
}
# Brotli compression
brotli on;
brotli_static on;
brotli_comp_level 6;
brotli_min_length 1000;
brotli_types text/plain text/css text/xml text/javascript application/javascript application/x-javascript application/json application/xml application/xml+rss application/rss+xml image/svg+xml font/truetype font/opentype font/woff font/woff2 application/font-woff application/font-woff2;
# Zstd compression
zstd on;
zstd_comp_level 3;
zstd_min_length 256;
zstd_types text/plain text/css text/xml text/javascript application/javascript application/x-javascript application/json application/xml application/xml+rss application/rss+xml image/svg+xml font/truetype font/opentype font/woff font/woff2 application/font-woff application/font-woff2;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1000;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/x-javascript application/json application/xml application/xml+rss application/rss+xml image/svg+xml font/truetype font/opentype font/woff font/woff2 application/font-woff application/font-woff2;
more_clear_headers "Server";
more_set_headers 'X-by: linuxiarz.pl';
# npm
include /data/nginx/custom/http_top[.]conf;
include /etc/nginx/conf.d/*.conf;
include /data/nginx/default_host/*.conf;
include /data/nginx/proxy_host/*.conf;
include /data/nginx/redirection_host/*.conf;
include /data/nginx/dead_host/*.conf;
include /data/nginx/temp/*.conf;
include /data/nginx/custom/http[.]conf;
# metrics & console
include /etc/angie/metrics.conf;
}
stream {
# npm
include /etc/angie/conf.d/include/log-stream.conf;
include /data/nginx/stream/*.conf;
include /data/nginx/custom/stream[.]conf;
}
# npm
include /data/nginx/custom/root[.]conf;
"""
ANGIE_UNIT = """[Unit]
Description=Angie - high performance web server
Documentation=https://en.angie.software/angie/docs/
After=network-online.target remote-fs.target nss-lookup.target
Wants=network-online.target
[Service]
Type=forking
PIDFile=/run/angie/angie.pid
ExecStartPre=/bin/mkdir -p /run/angie
ExecStartPre=/bin/mkdir -p /tmp/angie/body
ExecStart=/usr/sbin/angie -c /etc/angie/angie.conf
ExecReload=/bin/sh -c "/bin/kill -s HUP $(/bin/cat /run/angie/angie.pid)"
ExecStop=/bin/sh -c "/bin/kill -s TERM $(/bin/cat /run/angie/angie.pid)"
Restart=on-failure
RestartSec=3s
[Install]
WantedBy=multi-user.target
"""
# ========== Angie ==========
def setup_angie(ipv6_enabled: bool):
def _norm(s: str, allow_dot: bool = False) -> str:
pat = r"[^a-z0-9+\-\.]" if allow_dot else r"[^a-z0-9+\-]"
return re.sub(pat, "", s.strip().lower())
with step("Adding Angie repo and installing Angie packages"):
run(
[
"curl",
"-fsSL",
"-o",
"/etc/apt/trusted.gpg.d/angie-signing.gpg",
"https://angie.software/keys/angie-signing.gpg",
]
)
try:
dist = run_out(["lsb_release", "-si"])
rel = run_out(["lsb_release", "-sr"])
code = run_out(["lsb_release", "-sc"])
except Exception:
dist = run_out(["bash", "-c", '. /etc/os-release && printf %s "$ID"'])
rel = run_out(
["bash", "-c", '. /etc/os-release && printf %s "$VERSION_ID"']
)
code = run_out(
["bash", "-c", '. /etc/os-release && printf %s "$VERSION_CODENAME"']
)
dist = _norm(dist)
rel = _norm(rel, allow_dot=True)
code = _norm(code)
os_id = f"{dist}/{rel}" if rel else dist
if code:
line = f"deb https://download.angie.software/angie/{os_id} {code} main\n"
else:
line = f"deb https://download.angie.software/angie/{os_id} main\n"
write_file(Path("/etc/apt/sources.list.d/angie.list"), line)
run(["apt-get", "update"])
packages = [
"angie",
"angie-module-headers-more",
"angie-module-brotli",
"angie-module-zstd",
"angie-module-echo",
"angie-console-light",
]
apt_install(packages)
with step("Configuring modules and main Angie config"):
modules_dir = Path("/etc/angie/modules")
modules_dir.mkdir(parents=True, exist_ok=True)
write_file(Path("/etc/angie/angie.conf"), ANGIE_CONF_TEMPLATE, 0o644)
ensure_angie_log_include_files()
WRAP = """#!/bin/sh
exec sudo -n /usr/sbin/angie "$@"
"""
write_file(Path("/usr/sbin/nginx"), WRAP, 0o755)
Path("/etc/angie/conf.d/include").mkdir(parents=True, exist_ok=True)
with step("Setting resolver(s) and cache directories"):
write_resolvers_conf(ipv6_enabled)
for p in ["/var/lib/angie/cache/public", "/var/lib/angie/cache/private"]:
Path(p).mkdir(parents=True, exist_ok=True)
os.chmod(p, 0o755)
with step("Installing corrected systemd unit for Angie"):
write_file(Path("/etc/systemd/system/angie.service"), ANGIE_UNIT, 0o644)
def write_metrics_files():
"""Create /etc/angie/metrics.conf (port 82/8282 with console & status)."""
cert_path = NPM_ADMIN_CERT_PATH
key_path = NPM_ADMIN_KEY_PATH
with step("Adding Angie metrics & console on :82 / :8282 (https)"):
if NPM_ADMIN_ENABLE_SSL:
generate_selfsigned_cert()
metrics = f"""include /etc/angie/prometheus_all.conf;
server {{
listen 8282 ssl;
http2 on;
access_log off;
ssl_certificate {cert_path};
ssl_certificate_key {key_path};
location / {{
default_type text/html;
echo '<!DOCTYPE html>';
echo '<html><head><title>Status Page</title></head><body>';
echo '<h1>Server Status</h1>';
echo '<ul>';
echo '<li><a href="/console/">Console</a></li>';
echo '<li><a href="/nginx_status">Status</a></li>';
echo '<li><a href="/p8s">Prometheus Metrics</a></li>';
echo '</ul>';
echo '</body></html>';
}}
location /nginx_status {{
stub_status on;
access_log off;
allow all;
}}
auto_redirect on;
location /status/ {{
api /status/;
api_config_files on;
}}
location /console/ {{
alias /usr/share/angie-console-light/html/;
index index.html;
}}
location /console/api/ {{
api /status/;
}}
location =/p8s {{
prometheus all;
}}
}}
server {{
listen 82;
access_log off;
location / {{
default_type text/html;
echo '<!DOCTYPE html>';
echo '<html><head><title>Status Page</title></head><body>';
echo '<h1>Server Status</h1>';
echo '<ul>';
echo '<li><a href="/console/">Console</a></li>';
echo '<li><a href="/nginx_status">Status</a></li>';
echo '<li><a href="/p8s">Prometheus Metrics</a></li>';
echo '</ul>';
echo '</body></html>';
}}
location /nginx_status {{
stub_status on;
access_log off;
allow all;
}}
auto_redirect on;
location /status/ {{
api /status/;
api_config_files on;
}}
location /console/ {{
alias /usr/share/angie-console-light/html/;
index index.html;
}}
location /console/api/ {{
api /status/;
}}
location =/p8s {{
prometheus all;
}}
}}
"""
write_file(Path("/etc/angie/metrics.conf"), metrics, 0o644)
def ensure_angie_runtime_perms():
run_path = Path("/run/angie")
pid_file = run_path / "angie.pid"
run_path.mkdir(parents=True, exist_ok=True)
os.chmod(run_path, 0o2775)
try:
import grp
gid = grp.getgrnam("angie").gr_gid
os.chown(run_path, -1, gid)
except Exception:
pass
if not pid_file.exists():
pid_file.touch()
os.chmod(pid_file, 0o664)
try:
import grp, pwd
gid = grp.getgrnam("angie").gr_gid
uid = pwd.getpwnam("root").pw_uid
os.chown(pid_file, uid, gid)
except Exception:
pass
def ensure_user_and_dirs():
with step("Creating npm user and app/log directories"):
try:
run(["id", "-u", "npm"])
except subprocess.CalledProcessError:
run(
[
"useradd",
"--system",
"--home",
"/opt/npm",
"--create-home",
"--shell",
"/usr/sbin/nologin",
"npm",
]
)
rc = subprocess.run(
["getent", "group", "angie"], stdout=_devnull(), stderr=_devnull()
).returncode
if rc != 0:
run(["groupadd", "angie"])
run(["usermod", "-aG", "angie", "npm"], check=False)
dirs = [
"/data",
"/data/nginx",
"/data/custom_ssl",
"/data/logs",
"/data/access",
"/data/nginx/default_host",
"/data/nginx/default_www",
"/data/nginx/proxy_host",
"/data/nginx/redirection_host",
"/data/nginx/stream",
"/data/nginx/dead_host",
"/data/nginx/temp",
"/data/letsencrypt-acme-challenge",
"/opt/npm",
"/opt/npm/frontend",
"/opt/npm/global",
"/run/nginx",
"/run/angie",
"/tmp/angie/body",
]
for d in dirs:
Path(d).mkdir(parents=True, exist_ok=True)
run(["chgrp", "-h", "angie", "/run/angie"], check=False)
os.chmod("/run/angie", 0o2775)
Path("/var/log/angie").mkdir(parents=True, exist_ok=True)
for f in ["access.log", "error.log"]:
(Path("/var/log/angie") / f).touch(exist_ok=True)
paths = ["/var/log/angie"] + glob("/var/log/angie/*.log")
for pth in paths:
run(["chgrp", "-h", "angie", pth], check=False)
run(["chmod", "775", "/var/log/angie"], check=False)
for pth in glob("/var/log/angie/*.log"):
run(["chmod", "664", pth], check=False)
Path("/var/log/nginx").mkdir(parents=True, exist_ok=True)
Path("/var/log/nginx/error.log").touch(exist_ok=True)
os.chmod("/var/log/nginx/error.log", 0o666)
run(["chown", "-R", "npm:npm", "/opt/npm", "/data"])
ensure_angie_runtime_perms()
def create_sudoers_for_npm():
with step("Configuring sudoers for npm -> angie"):
content = """User_Alias NPMUSERS = npm
NPMUSERS ALL=(root) NOPASSWD: /usr/sbin/angie
"""
path = Path("/etc/sudoers.d/npm")
write_file(path, content, 0o440)
if shutil.which("visudo"):
run(["visudo", "-cf", str(path)], check=False)
def adjust_nginx_like_paths_in_tree(root: Path):
for p in root.rglob("*.conf"):
try:
txt = p.read_text(encoding="utf-8")
except Exception:
continue
txt2 = txt.replace("include conf.d", "include /etc/nginx/conf.d").replace(
"include /etc/angie/conf.d", "include /etc/nginx/conf.d"
)
if txt2 != txt:
p.write_text(txt2, encoding="utf-8")
for cand in root.rglob("nginx.conf"):
try:
txt = cand.read_text(encoding="utf-8")
except Exception:
continue
txt = re.sub(r"^user\s+\S+.*", "user root;", txt, flags=re.M)
txt = re.sub(r"^pid\s+.*", "pid /run/angie/angie.pid;", txt, flags=re.M)
txt = txt.replace("daemon on;", "#daemon on;")
cand.write_text(txt, encoding="utf-8")
def install_node_from_nodesource(version: str):
_, resolved_version, warning = validate_nodejs_version(version)
if warning:
print(warning)
match = re.match(r"(\d+)", resolved_version)
if not match:
raise ValueError(f"Invalid Node.js version: {version}")
major_version = match.group(1)
with step("Removing old Node.js installations"):
run(
["apt-get", "remove", "-y", "nodejs", "npm", "libnode-dev", "libnode72"],
check=False,
)
run(
["apt-get", "purge", "-y", "nodejs", "npm", "libnode-dev", "libnode72"],
check=False,
)
run(["apt-get", "autoremove", "-y"], check=False)
for f in [
"/etc/apt/sources.list.d/nodesource.list",
"/etc/apt/keyrings/nodesource.gpg",
"/usr/share/keyrings/nodesource.gpg",
"/etc/apt/trusted.gpg.d/nodesource.gpg",
]:
if Path(f).exists():
Path(f).unlink()
with step(f"Installing Node.js v{major_version}.x from NodeSource repository"):
setup_url = f"https://deb.nodesource.com/setup_{major_version}.x"
with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False) as tf:
script_path = tf.name
try:
run(["curl", "-fsSL", setup_url, "-o", script_path])
os.chmod(script_path, 0o755)
if DEBUG:
subprocess.run(["bash", script_path], check=True)
else:
run(["bash", script_path])
run(["apt-get", "update", "-y"])
run(["apt-get", "install", "-y", "nodejs"])
finally:
if Path(script_path).exists():
os.unlink(script_path)
if shutil.which("node"):
node_ver = run_out(["node", "--version"], check=False).strip()
installed_major = re.match(r"v?(\d+)", node_ver)
if installed_major and installed_major.group(1) != major_version:
print(f"⚠ WARNING: Requested Node.js v{major_version}.x but got {node_ver}")
print(
f" This likely means NodeSource doesn't support your distribution yet."
)
if shutil.which("npm"):
npm_ver = run_out(["npm", "--version"], check=False).strip()
print(f" Node.js: {node_ver}")
print(f" npm: {npm_ver}")
else:
print(f" Node.js: {node_ver}")
apt_try_install(["npm"])
if shutil.which("npm"):
npm_ver = run_out(["npm", "--version"], check=False).strip()
print(f" npm: {npm_ver}")
if not shutil.which("npm"):
run(["corepack", "enable"], check=False)
if shutil.which("npm"):
npm_ver = run_out(["npm", "--version"], check=False).strip()
print(f"\n✔ npm {npm_ver} installed successfully")
else:
print(f"✖ npm could not be installed - manual intervention required")
else:
print("✖ Node.js installation failed")
raise RuntimeError("Node.js installation failed")
def _yarn_version() -> str:
if not shutil.which("yarn"):
return ""
return run_out(["yarn", "--version"], check=False).strip()
def ensure_yarn_classic():
"""Use the Yarn Classic version used by the current NPM 2.x build."""
expected = "1.22.22"
version = _yarn_version()
if version == expected:
return version
if not shutil.which("npm"):
raise RuntimeError("npm is required to install Yarn Classic")
with step(f"Installing Yarn Classic {expected}"):
run_logged(
["npm", "install", "-g", f"yarn@{expected}", "--force"],
Path("/tmp/npm-yarn-install.log"),
timeout=600,
)
version = _yarn_version()
if version != expected:
raise RuntimeError(f"Expected Yarn {expected}, got: {version or 'not found'}")
return version
def _package_scripts(project_dir: Path) -> dict:
package_json = project_dir / "package.json"
if not package_json.exists():
return {}
try:
return json.loads(package_json.read_text(encoding="utf-8")).get("scripts", {})
except Exception:
return {}
def _yarn_install(project_dir: Path, log_name: str):
ensure_yarn_classic()
cmd = ["yarn", "install", "--network-timeout", "600000"]
if (project_dir / "yarn.lock").exists():
cmd.append("--frozen-lockfile")
run_logged(cmd, Path(f"/tmp/{log_name}.log"), timeout=1200, cwd=project_dir)
def _build_frontend(src_frontend: Path, dest_frontend: Path):
log_path = Path("/tmp/npm-frontend-build.log")
with step("Installing frontend dependencies"):
shutil.rmtree(src_frontend / "node_modules", ignore_errors=True)
_yarn_install(src_frontend, "npm-frontend-yarn")
scripts = _package_scripts(src_frontend)
if "locale-compile" in scripts:
with step("Building locale files"):
run_logged(["yarn", "locale-compile"], log_path, cwd=src_frontend)
with step("Building frontend"):
env = os.environ.copy()
env["NODE_ENV"] = "development"
env["NODE_OPTIONS"] = "--max_old_space_size=2048"
try:
run_logged(["yarn", "build"], log_path, timeout=1200, env=env, cwd=src_frontend)
except subprocess.CalledProcessError:
# Compatibility fallback for older NPM/webpack dependency trees.
env["NODE_OPTIONS"] += " --openssl-legacy-provider"
run_logged(["yarn", "build"], log_path, timeout=1200, env=env, cwd=src_frontend)
dist_dir = src_frontend / "dist"
if not dist_dir.is_dir():
raise RuntimeError(f"Frontend build output not found: {dist_dir}")
with step("Installing frontend artifacts"):
shutil.rmtree(dest_frontend, ignore_errors=True)
shutil.copytree(dist_dir, dest_frontend)
images_dst = dest_frontend / "images"
for image_src in (src_frontend / "app-images", src_frontend / "public" / "images"):
if image_src.is_dir():
shutil.copytree(image_src, images_dst, dirs_exist_ok=True)
def install_backend_dependencies(app_dir: Path = Path("/opt/npm")):
with step("Installing backend dependencies"):
_yarn_install(app_dir, "npm-backend-yarn")
# ========== DEPLOY FUNCTIONS ==========
def inject_footer_link(src: Path) -> None:
footer = src / "frontend" / "src" / "components" / "SiteFooter.tsx"
if not footer.exists():
return
text = footer.read_text(encoding="utf-8")
if "linuxiarz.pl" in text:
return
marker = '<T id="footer.github-fork" />'
marker_pos = text.find(marker)
if marker_pos < 0:
return
close_li = text.find("</li>", marker_pos)
if close_li < 0:
return
item = """
<li className="list-inline-item">
<a href="https://git.linuxiarz.pl/gru/npm-angie-auto-install" target="_blank" className="link-secondary" rel="noopener">
Auto Installer by linuxiarz.pl
</a>
</li>"""
insert_at = close_li + len("</li>")
footer.write_text(text[:insert_at] + item + text[insert_at:], encoding="utf-8")
def _npm_ref_details(ref: str) -> tuple[str, str]:
if ref.startswith("refs/heads/"):
git_ref = ref.removeprefix("refs/heads/")
version = f"{git_ref}-dev-{datetime.now().strftime('%Y%m%d-%H%M')}"
elif ref.startswith("refs/tags/"):
git_ref = ref.removeprefix("refs/tags/")
version = git_ref.removeprefix("v")
else:
git_ref = ref
version = f"{git_ref}-dev-{datetime.now().strftime('%Y%m%d-%H%M')}"
return git_ref, version
def _set_npm_source_version(src: Path, version: str):
for rel in ("package.json", "backend/package.json", "frontend/package.json"):
path = src / rel
if not path.exists():
continue
try:
data = json.loads(path.read_text(encoding="utf-8"))
data["version"] = version
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
except Exception as exc:
if DEBUG:
print(f" ⚠ Could not set version in {rel}: {exc}")
def _download_npm_source(ref: str, prefix: str) -> tuple[Path, Path, str]:
git_ref, version = _npm_ref_details(ref)
tmp = Path(tempfile.mkdtemp(prefix=prefix))
try:
url = f"https://codeload.github.com/{GITHUB_REPO_OWNER}/{GITHUB_REPO_NAME}/tar.gz/{git_ref}"
src = download_extract_tar_gz(url, tmp)
_set_npm_source_version(src, version)
return src, tmp, version
except Exception:
shutil.rmtree(tmp, ignore_errors=True)
raise
def _write_database_config(src: Path):
cfg = Path("/opt/npm/config/production.json")
backend_pkg = src / "backend" / "package.json"
client = "sqlite3"
try:
deps = json.loads(backend_pkg.read_text(encoding="utf-8")).get("dependencies", {})
if "better-sqlite3" in deps:
client = "better-sqlite3"
except Exception as exc:
if DEBUG:
print(f" ⚠ Could not inspect backend dependencies: {exc}")
config = {
"database": {
"engine": "knex-native",
"knex": {
"client": client,
"connection": {"filename": "/data/database.sqlite"},
"useNullAsDefault": True,
},
}
}
cfg.parent.mkdir(parents=True, exist_ok=True)
write_file(cfg, json.dumps(config, indent=2) + "\n")
print(f" ✓ SQLite backend: {client}")
def deploy_npm_app_from_git(ref: str) -> str:
src, tmp, version = _download_npm_source(ref, "npm-angie-")
try:
with step("Preparing NPM source tree"):
adjust_nginx_like_paths_in_tree(src)
inject_footer_link(src)
with step("Copying NPM runtime files"):
rootfs = src / "docker" / "rootfs"
shutil.copytree(rootfs / "var" / "www" / "html", "/var/www/html", dirs_exist_ok=True)
shutil.copytree(rootfs / "etc" / "nginx", "/etc/angie", dirs_exist_ok=True)
(Path("/etc/angie/conf.d/dev.conf")).unlink(missing_ok=True)
shutil.copy2(
rootfs / "etc" / "logrotate.d" / "nginx-proxy-manager",
"/etc/logrotate.d/nginx-proxy-manager",
)
shutil.copytree(src / "backend", "/opt/npm", dirs_exist_ok=True)
if (src / "global").is_dir():
shutil.copytree(src / "global", "/opt/npm/global", dirs_exist_ok=True)
with step("Writing database configuration"):
_write_database_config(src)
_build_frontend(src / "frontend", Path("/opt/npm/frontend"))
install_backend_dependencies()
with step("Normalizing NPM ownership"):
run(["chown", "-R", "npm:npm", "/opt/npm", "/data"])
with step("Preparing ip_ranges.conf"):
include_dir = Path("/etc/angie/conf.d/include")
include_dir.mkdir(parents=True, exist_ok=True)
ipranges = include_dir / "ip_ranges.conf"
if not ipranges.exists():
write_file(ipranges, "# populated by NPM (IPv4 only)\n")
run(["chown", "npm:npm", str(include_dir), str(ipranges)], check=False)
os.chmod(ipranges, 0o664)
return version
finally:
shutil.rmtree(tmp, ignore_errors=True)
def strip_ipv6_listens(paths):
with step("Removing IPv6 listen entries from configs (--enable-ipv6 not set)"):
confs = []
for p in paths:
confs.extend(Path(p).rglob("*.conf"))
for f in confs:
try:
txt = f.read_text(encoding="utf-8")
except Exception:
continue
new = re.sub(r"(?m)^\s*listen\s+\[::\]:\d+[^;]*;\s*$", "", txt)
new = re.sub(r"\n{3,}", "\n\n", new)
if new != txt:
f.write_text(new, encoding="utf-8")
def install_logrotate_for_data_logs():
with step("Installing logrotate policy for /var/log/angie (*.log)"):
conf_path = Path("/etc/logrotate.d/angie")
content = """/var/log/angie/*.log {
daily
rotate 1
compress
missingok
notifempty
copytruncate
create 0640 root root
su root root
postrotate
if [ -f /run/angie/angie.pid ]; then
kill -USR1 $(cat /run/angie/angie.pid)
fi
endscript
}
"""
write_file(conf_path, content, 0o644)
try:
run(["/usr/sbin/logrotate", "-d", str(conf_path)], check=False)
except Exception:
pass
def fix_logrotate_permissions_and_wrapper():
"""Give the NPM user its own logrotate state without patching NPM sources."""
with step("Configuring logrotate wrapper for NPM"):
state_dir = Path("/opt/npm/var")
state_file = state_dir / "logrotate.state"
state_dir.mkdir(parents=True, exist_ok=True)
state_file.touch(exist_ok=True)
run(["chown", "-R", "npm:npm", str(state_dir)], check=False)
os.chmod(state_dir, 0o755)
os.chmod(state_file, 0o664)
# npm.service has /usr/local/bin before /usr/sbin in PATH. NPM therefore
# gets a private state file, while root/manual logrotate keeps normal behavior.
helper = Path("/usr/local/bin/logrotate")
helper_content = f"""#!/bin/sh
if [ \"$(id -u)\" = \"$(id -u npm 2>/dev/null)\" ]; then
exec /usr/sbin/logrotate -s {state_file} \"$@\"
fi
exec /usr/sbin/logrotate \"$@\"
"""
write_file(helper, helper_content, 0o755)
def write_npm_service_unit(ipv6_enabled: bool):
certbot_ver = patch_npm_certbot_plugins_config()
unit_lines = [
"[Unit]",
"Description=Nginx Proxy Manager (backend)",
"After=network.target angie.service",
"Wants=angie.service",
"",
"[Service]",
"User=npm",
"Group=npm",
"WorkingDirectory=/opt/npm",
"Environment=NODE_ENV=production",
"Environment=PATH=/opt/certbot/bin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
]
if certbot_ver:
unit_lines.append(f"Environment=CERTBOT_VERSION={certbot_ver}")
if not ipv6_enabled:
unit_lines.append("Environment=DISABLE_IPV6=true")
unit_lines += [
"ExecStart=/usr/bin/node /opt/npm/index.js",
"Restart=on-failure",
"RestartSec=5",
"",
"[Install]",
"WantedBy=multi-user.target",
"",
]
write_file(Path("/etc/systemd/system/npm.service"), "\n".join(unit_lines), 0o644)
return certbot_ver
def create_systemd_units(ipv6_enabled: bool, restart: bool = True):
"""Write units, validate Angie, then optionally enable/restart services once."""
if NPM_ADMIN_ENABLE_SSL:
generate_selfsigned_cert()
write_npm_service_unit(ipv6_enabled=ipv6_enabled)
write_file(Path("/etc/systemd/system/angie.service"), ANGIE_UNIT, 0o644)
subprocess.run(["systemctl", "daemon-reload"], check=False)
ensure_angie_log_include_files()
run(["/usr/sbin/angie", "-t"], check=True, quiet=False)
if restart:
for service in ("angie.service", "npm.service"):
run(["systemctl", "enable", service], check=False)
run(["systemctl", "restart", service], check=True, quiet=False)
########### REPLACE CONFIGS ############
def update_config_file(filepath, newcontent, owner="npm:npm", mode=0o644):
filepath = Path(filepath)
backup_path = None
if filepath.exists():
timestamp = time.strftime("%Y%m%d-%H%M%S")
backup_path = filepath.parent / f"{filepath.name}.backup-{timestamp}"
if filepath.read_text(encoding="utf-8", errors="replace") == newcontent:
return None
shutil.copy2(filepath, backup_path)
if DEBUG:
print(f" Backup: {backup_path}")
write_file(filepath, newcontent, mode)
if owner:
run(["chown", owner, str(filepath)], check=False)
return backup_path
def update_npm_assets_config():
"""
Update /etc/nginx/conf.d/include/assets.conf with optimized cache settings.
"""
content = """location ~* \\.(css|js|mjs|json|xml|txt|md|html|htm|pdf|doc|docx|xls|xlsx|ppt|pptx|jpg|jpeg|jpe|jfif|pjpeg|pjp|png|gif|webp|avif|apng|svg|svgz|ico|bmp|tif|tiff|jxl|heic|heif|woff|woff2|ttf|otf|eot|mp3|mp4|m4a|m4v|ogg|ogv|oga|opus|wav|webm|flac|aac|mov|avi|wmv|zip|gz|bz2|tar|rar|7z|css\\.map|js\\.map)$ {
proxy_cache public-cache;
proxy_cache_valid 200 30m;
proxy_cache_revalidate on;
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
proxy_cache_background_update on;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
add_header X-Cache-Status $upstream_cache_status always;
proxy_hide_header Age;
proxy_hide_header X-Cache-Hits;
proxy_hide_header X-Cache;
access_log off;
include /etc/angie/conf.d/include/proxy.conf;
status_zone cache_assets;
}
"""
with step("Updating NPM assets cache configuration"):
return update_config_file(
filepath="/etc/nginx/conf.d/include/assets.conf",
newcontent=content,
owner="npm:npm",
mode=0o644,
)
def update_ssl_ciphers_config():
content = """# Modern SSL/TLS Configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers on;
ssl_conf_command Ciphersuites TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384;
"""
with step("Updating NPM SSL/TLS cipher configuration"):
return update_config_file(
filepath="/etc/nginx/conf.d/include/ssl-ciphers.conf",
newcontent=content,
owner="npm:npm",
mode=0o644,
)
def update_npm_listen_template():
"""Install Angie-aware listen template.
Until NPM ships a separate HTTP/3 field, its per-host HTTP/2 switch controls
both HTTP/2 and HTTP/3. QUIC is only attached to vhosts where that switch is on.
The admin server owns the singleton `reuseport` declaration for UDP/443.
"""
content = """# HTTP
listen 80;
{% if ipv6 -%}
listen [::]:80;
{% else -%}
#listen [::]:80;
{% endif %}
{% if certificate -%}
# HTTPS/TCP
listen 443 ssl;
{% if ipv6 -%}
listen [::]:443 ssl;
{% else -%}
#listen [::]:443 ssl;
{% endif %}
{% if http2_support == 1 or http2_support == true %}
# HTTP/3/QUIC; reuseport is initialized once by the admin server.
listen 443 quic;
{% if ipv6 -%}
listen [::]:443 quic;
{% endif %}
{% endif %}
{% endif %}
server_name {{ domain_names | join: " " }};
{% if certificate -%}
{% if http2_support == 1 or http2_support == true %}
http2 on;
http3 on;
{% else -%}
http2 off;
http3 off;
{% endif %}
{% endif %}
status_zone {{ domain_names[0] | replace: "*.", "" | replace: ".", "_" }};
"""
with step("Updating NPM listen template with HTTP/3 support"):
return update_config_file(
"/opt/npm/templates/_listen.conf",
content,
owner="npm:npm",
mode=0o644,
)
def update_npm_proxy_host_template():
content = """{% include "_header_comment.conf" %}
{% if enabled %}
#### BACKEND UPSTREAM ####
{% assign bname = domain_names[0] | replace: "*.", "" | replace: ".", "_" %}
upstream backend_{{ bname }} {
zone {{ bname }} 1m;
server {{ forward_host }}:{{ forward_port }};
keepalive 16;
}
{% include "_hsts_map.conf" %}
server {
set $forward_scheme {{ forward_scheme }};
set $server "{{ forward_host }}";
set $port {{ forward_port }};
{% include "_listen.conf" %}
{% include "_certificates.conf" %}
{% include "_assets.conf" %}
{% include "_exploits.conf" %}
{% include "_hsts.conf" %}
{% include "_forced_ssl.conf" %}
{% if allow_websocket_upgrade == 1 or allow_websocket_upgrade == true %}
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
proxy_http_version 1.1;
{% endif %}
access_log /data/logs/proxy-host-{{ id }}_access.log proxy;
error_log /data/logs/proxy-host-{{ id }}_error.log warn;
{{ advanced_config }}
{{ locations }}
{% if use_default_location %}
location / {
{% include "_access.conf" %}
{% include "_hsts.conf" %}
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Scheme $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_pass {{ forward_scheme }}://backend_{{ bname }}$request_uri;
{% if allow_websocket_upgrade == 1 or allow_websocket_upgrade == true %}
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
{% endif %}
}
{% endif %}
include /data/nginx/custom/server_proxy[.]conf;
}
{% endif %}
"""
with step("Updating NPM proxy host template"):
return update_config_file(
"/opt/npm/templates/proxy_host.conf",
content,
owner="npm:npm",
mode=0o644,
)
def update_npm_location_template():
content = """ location {{ path }} {
{{ advanced_config }}
status_zone location_{{ forward_host }}_{{ forward_port }}_{{ path }};
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Scheme $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass {{ forward_scheme }}://{{ forward_host }}:{{ forward_port }}{{ forward_path }};
{% include "_access.conf" %}
{% include "_assets.conf" %}
{% include "_exploits.conf" %}
{% include "_forced_ssl.conf" %}
{% include "_hsts.conf" %}
{% if allow_websocket_upgrade == 1 or allow_websocket_upgrade == true %}
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
proxy_http_version 1.1;
{% endif %}
}
"""
with step("Updating NPM custom location template"):
return update_config_file(
"/opt/npm/templates/_location.conf",
content,
owner="npm:npm",
mode=0o644,
)
def update_npm_http3_header_template():
"""Add/clear Alt-Svc consistently at server and location scope."""
content = """{% if certificate %}
{% if http2_support == 1 or http2_support == true %}
add_header Alt-Svc 'h3=":443"; ma=86400' always;
{% else %}
# Clear a previously cached HTTP/3 alternative when H3 is disabled for this host.
add_header Alt-Svc 'clear' always;
{% endif %}
{% endif %}
"""
return update_config_file(
"/opt/npm/templates/_http3.conf", content, owner="npm:npm", mode=0o644
)
def inject_http3_include_into_host_templates():
"""Place the H3 header next to HSTS wherever NPM emits response headers."""
template_dir = Path("/opt/npm/templates")
hsts = '{% include "_hsts.conf" %}'
h3 = '{% include "_http3.conf" %}'
for name in ("proxy_host.conf", "_location.conf", "redirection_host.conf", "dead_host.conf"):
path = template_dir / name
if not path.exists():
continue
text = path.read_text(encoding="utf-8")
pair = f"{hsts}\n{h3}"
# Normalize previous runs, then add the helper after every HSTS include.
updated = text.replace(pair, hsts).replace(hsts, pair)
if updated != text:
write_file(path, updated, 0o644)
run(["chown", "npm:npm", str(path)], check=False)
def generate_selfsigned_cert(cert_path=None, key_path=None, days=None):
cert_path = Path(cert_path or NPM_ADMIN_CERT_PATH)
key_path = Path(key_path or NPM_ADMIN_KEY_PATH)
days = days or NPM_ADMIN_CERT_DAYS
cert_path.parent.mkdir(parents=True, exist_ok=True)
if cert_path.exists() and key_path.exists():
if DEBUG:
print(f" Certificate already exists: {cert_path}")
return (str(cert_path), str(key_path))
if DEBUG:
print(f" Generating self-signed certificate...")
run(
[
"openssl",
"req",
"-x509",
"-nodes",
"-days",
str(days),
"-newkey",
"rsa:4096",
"-keyout",
str(key_path),
"-out",
str(cert_path),
"-subj",
"/C=US/ST=State/L=City/O=Organization/CN=nginxproxymanager",
],
check=True,
)
run(["chmod", "644", str(cert_path)], check=False)
run(["chmod", "600", str(key_path)], check=False)
run(["chown", "npm:npm", str(cert_path)], check=False)
run(["chown", "npm:npm", str(key_path)], check=False)
if DEBUG:
print(f" Certificate created: {cert_path}")
print(f" Private key created: {key_path}")
return (str(cert_path), str(key_path))
def update_npm_admin_interface(
enable_ssl=None, http_port=None, https_port=None, root_path=None
):
"""
Update NPM admin interface configuration with SSL support and redirect.
Uses global configuration if parameters not provided.
"""
enable_ssl = NPM_ADMIN_ENABLE_SSL if enable_ssl is None else enable_ssl
http_port = http_port or NPM_ADMIN_HTTP_PORT
https_port = https_port or NPM_ADMIN_HTTPS_PORT
root_path = root_path or NPM_ADMIN_ROOT_PATH
cert_path = NPM_ADMIN_CERT_PATH
key_path = NPM_ADMIN_KEY_PATH
if enable_ssl:
with step("Generating self-signed certificate for admin interface"):
generate_selfsigned_cert()
content = f"""# Admin Interface - HTTP (redirect to HTTPS)
server {{
listen {http_port} default_server;
server_name nginxproxymanager;
add_header Alt-Svc 'h3=":{https_port}"; ma=60' always;
# Redirect all HTTP traffic to HTTPS
return 301 https://$host:{https_port}$request_uri;
}}
# Admin Interface - HTTPS
server {{
listen {https_port} ssl;
listen {https_port} quic reuseport;
# Intentional singleton socket bootstrap for QUIC vhosts.
listen 443 ssl;
listen 443 quic reuseport;
add_header Alt-Svc 'h3=":{https_port}"; ma=60' always;
http3 on;
http2 on;
server_name nginxproxymanager npm-admin;
# SSL Configuration
ssl_certificate {cert_path};
ssl_certificate_key {key_path};
include /etc/angie/conf.d/include/ssl-ciphers.conf;
status_zone npm_admin;
root {root_path};
access_log off;
location /api {{
return 302 /api/;
}}
location /api/ {{
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Scheme $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass http://127.0.0.1:3000/;
proxy_read_timeout 15m;
proxy_send_timeout 15m;
}}
location / {{
etag off;
index index.html;
if ($request_uri ~ ^/(.*)\\.html$) {{
return 302 /$1;
}}
try_files $uri $uri.html $uri/ /index.html;
}}
}}
"""
else:
# Configuration without SSL (original)
content = f"""# Admin Interface
server {{
listen {http_port} default_server;
server_name nginxproxymanager npm-admin;
root {root_path};
access_log /dev/null;
status_zone npm_admin;
location /api {{
return 302 /api/;
}}
location /api/ {{
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Scheme $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass http://127.0.0.1:3000/;
proxy_read_timeout 15m;
proxy_send_timeout 15m;
}}
location / {{
etag off;
index index.html;
if ($request_uri ~ ^/(.*)\\.html$) {{
return 302 /$1;
}}
try_files $uri $uri.html $uri/ /index.html;
}}
}}
"""
with step("Updating NPM admin interface configuration"):
return update_config_file(
filepath="/etc/nginx/conf.d/production.conf",
newcontent=content,
owner="npm:npm",
mode=0o644,
)
def update_npm_stream_template():
"""
Update /opt/npm/templates/stream.conf with status_zone monitoring.
"""
content = """# ------------------------------------------------------------
# {{ incoming_port }} TCP: {{ tcp_forwarding }} UDP: {{ udp_forwarding }}
# ------------------------------------------------------------
{% if enabled %}
{% if tcp_forwarding == 1 or tcp_forwarding == true -%}
server {
listen {{ incoming_port }} {%- if certificate %} ssl reuseport{%- endif %};
{% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} {%- if certificate %} ssl reuseport{%- endif %};
{%- include "_certificates_stream.conf" %}
proxy_pass {{ forwarding_host }}:{{ forwarding_port }};
access_log /data/logs/stream-{{ id }}_access.log stream;
error_log /data/logs/stream-{{ id }}_error.log warn;
status_zone stream_tcp_{{ incoming_port }}_{{ forwarding_port }};
# Custom
include /data/nginx/custom/server_stream[.]conf;
include /data/nginx/custom/server_stream_tcp[.]conf;
}
{% endif %}
{% if udp_forwarding == 1 or udp_forwarding == true -%}
server {
listen {{ incoming_port }} udp reuseport;
{% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} udp reuseport;
proxy_pass {{ forwarding_host }}:{{ forwarding_port }};
access_log /data/logs/stream-{{ id }}_access.log stream;
error_log /data/logs/stream-{{ id }}_error.log warn;
status_zone stream_udp_{{ incoming_port }}_{{ forwarding_port }};
# Custom
include /data/nginx/custom/server_stream[.]conf;
include /data/nginx/custom/server_stream_udp[.]conf;
}
{% endif %}
{% endif %}
"""
with step("Updating NPM stream template"):
return update_config_file(
filepath="/opt/npm/templates/stream.conf",
newcontent=content,
owner="npm:npm",
mode=0o644,
)
def gather_versions(npm_app_version: str):
_ips = run_out(["hostname", "-I"], check=False) or ""
ip = (_ips.split() or [""])[0]
angie_out = (
(run_out(["angie", "-v"], check=False) or "")
+ "\n"
+ (run_out(["angie", "-V"], check=False) or "")
)
m = re.search(r"(?i)\bangie\s*/\s*([0-9]+(?:\.[0-9]+)+)\b", angie_out)
if not m:
dp = (
run_out(["dpkg-query", "-W", "-f=${Version}", "angie"], check=False) or ""
).strip()
m = re.search(r"([0-9]+(?:\.[0-9]+)+)", dp)
angie_v = m.group(1) if m else (angie_out.strip() or "")
node_v = (run_out(["node", "-v"], check=False) or "").strip().lstrip("v")
yarn_v = (run_out(["yarn", "-v"], check=False) or "").strip()
if not yarn_v:
yarn_v = (run_out(["yarnpkg", "-v"], check=False) or "").strip()
return ip, angie_v, node_v, yarn_v, npm_app_version
def update_motd(
enabled: bool,
info,
ipv6_enabled: bool,
npm_version: str = None,
installed_from_branch: bool = False,
):
if not enabled:
return
ip, angie_v, node_v, yarn_v, npm_v = info
ipv6_line = (
"IPv6: enabled (configs untouched)."
if ipv6_enabled
else "IPv6: disabled in resolvers and conf."
)
protocol = "https" if NPM_ADMIN_ENABLE_SSL else "http"
port = NPM_ADMIN_HTTPS_PORT if NPM_ADMIN_ENABLE_SSL else NPM_ADMIN_HTTP_PORT
npm_line = f"Nginx Proxy Manager: {protocol}://{ip}:{port}"
npm_source = (
f"Source: branch ({npm_version})"
if installed_from_branch
else f"Source: release {npm_version}"
)
text = f"""
################################ NPM / ANGIE ################################
OS: {OSREL['PRETTY']} ({OSREL['ID']} {OSREL['VERSION_ID']})
{npm_line}
Angie & Prometheus stats: http://{ip}:82/console | http://{ip}:82/p8s
or https://{ip}:8282/console | https://{ip}:8282/p8s
Angie: {angie_v} (conf: /etc/angie -> /etc/nginx, reload: angie -s reload)
Node.js: v{node_v} Yarn: v{yarn_v}
NPM: {npm_v}
{npm_source}
Paths: app=/opt/npm data=/data cache=/var/lib/angie/cache certbot=/opt/certbot
{ipv6_line}
###########################################################################
"""
motd_d = Path("/etc/motd.d")
if motd_d.exists():
write_file(motd_d / "10-npm-angie", text.strip() + "\n", 0o644)
else:
motd = Path("/etc/motd")
existing = motd.read_text(encoding="utf-8") if motd.exists() else ""
pattern = re.compile(
r"################################ NPM / ANGIE ################################.*?###########################################################################\n",
re.S,
)
if pattern.search(existing):
content = pattern.sub(text.strip() + "\n", existing)
else:
content = (
(existing.rstrip() + "\n\n" + text.strip() + "\n")
if existing
else (text.strip() + "\n")
)
write_file(motd, content, 0o644)
def print_summary(info, ipv6_enabled: bool, update_mode: bool, npm_version: str):
ip, angie_v, node_v, yarn_v, npm_v = info
mode = "UPDATE" if update_mode else "INSTALL"
panel_scheme = "https" if NPM_ADMIN_ENABLE_SSL else "http"
panel_port = NPM_ADMIN_HTTPS_PORT if NPM_ADMIN_ENABLE_SSL else NPM_ADMIN_HTTP_PORT
print("\n====================== SUMMARY ======================")
print(f"OS: {OSREL['PRETTY']}")
print(f"Mode: {mode}")
print(f"NPM panel: {panel_scheme}://{ip}:{panel_port}")
print(f"Angie stats: http://{ip}:82/console | https://{ip}:8282/console")
print(f"Prometheus: http://{ip}:82/p8s | https://{ip}:8282/p8s")
print(f"Angie: v{angie_v}")
print(f"Node.js: v{node_v}")
print(f"Yarn: v{yarn_v}")
print(f"NPM: {npm_v}")
print(f"IPv6: {'ENABLED' if ipv6_enabled else 'DISABLED'}")
print("Config test: /usr/sbin/angie -t")
print(f"Installed source: {npm_version}")
print("==========================================================\n")
# ========== UPDATE-ONLY ==========
def _backup_before_update(target_version: str) -> Path:
timestamp = time.strftime("%Y%m%d-%H%M%S")
backup_root = Path("/data/backups")
backup_dir = backup_root / f"npm-backup-{timestamp}"
backup_root.mkdir(parents=True, exist_ok=True)
try:
if Path("/opt/npm").exists():
shutil.copytree("/opt/npm", backup_dir / "opt_npm", dirs_exist_ok=True)
for source, name in (
(Path("/data/database.sqlite"), "database.sqlite"),
(Path("/data/letsencrypt"), "letsencrypt"),
(Path("/data/nginx"), "nginx"),
):
if source.is_dir():
shutil.copytree(source, backup_dir / name, dirs_exist_ok=True)
elif source.exists():
shutil.copy2(source, backup_dir / name)
(backup_dir / "backup_info.json").write_text(
json.dumps(
{
"backup_date": timestamp,
"update_to_version": target_version,
"backup_path": str(backup_dir),
},
indent=2,
) + "\n",
encoding="utf-8",
)
except Exception as exc:
print(f"⚠ Backup failed: {exc}")
if input("Continue update anyway? [y/N]: ").strip().lower() not in ("y", "yes"):
raise RuntimeError("Update cancelled because backup failed") from exc
backups = sorted(backup_root.glob("npm-backup-*"))
for old_backup in backups[:-3]:
shutil.rmtree(old_backup, ignore_errors=True)
print(f" Backup: {backup_dir}")
return backup_dir
def _sync_npm_include_files(src: Path):
include_src = src / "docker" / "rootfs" / "etc" / "nginx" / "conf.d" / "include"
include_dst = Path("/etc/angie/conf.d/include")
if not include_src.is_dir():
return
safe_names = {
"force-ssl.conf", "proxy.conf", "assets.conf", "exploits.conf",
"hsts.conf", "log.conf", "log-proxy.conf", "log-stream.conf",
}
include_dst.mkdir(parents=True, exist_ok=True)
timestamp = time.strftime("%Y%m%d-%H%M%S")
for source in include_src.glob("*.conf"):
if source.name not in safe_names:
continue
target = include_dst / source.name
if target.exists() and filecmp.cmp(source, target, shallow=False):
continue
if target.exists():
shutil.copy2(target, target.with_name(f"{target.name}.backup-{timestamp}"))
shutil.copy2(source, target)
print(f" ✓ Synced: {source.name}")
for conf in include_dst.glob("*.conf"):
run(["chown", "npm:npm", str(conf)], check=False)
ensure_angie_log_include_files()
def _update_angie_main_config():
path = Path("/etc/angie/angie.conf")
if path.exists() and path.read_text(encoding="utf-8") == ANGIE_CONF_TEMPLATE:
return
if path.exists():
backup = path.with_name(f"angie.conf.backup-{time.strftime('%Y%m%d-%H%M%S')}")
shutil.copy2(path, backup)
print(f" ✓ Backed up {path.name} to {backup.name}")
write_file(path, ANGIE_CONF_TEMPLATE, 0o644)
def _replace_backend_preserving_config(src: Path):
backend_src = src / "backend"
if not backend_src.is_dir():
raise RuntimeError("NPM source does not contain backend/")
app = Path("/opt/npm")
config_backup = Path(tempfile.mkdtemp(prefix="npm-config-")) / "config"
try:
if (app / "config").is_dir():
shutil.copytree(app / "config", config_backup)
for item in app.iterdir():
if item.name in {"frontend", "config"}:
continue
if item.is_dir() and not item.is_symlink():
shutil.rmtree(item)
else:
item.unlink(missing_ok=True)
for item in backend_src.iterdir():
target = app / item.name
if item.is_dir():
shutil.copytree(item, target, dirs_exist_ok=True)
else:
shutil.copy2(item, target)
global_src = src / "global"
global_dst = app / "global"
if global_dst.exists():
shutil.rmtree(global_dst)
if global_src.is_dir():
shutil.copytree(global_src, global_dst)
else:
global_dst.mkdir(parents=True, exist_ok=True)
if config_backup.is_dir():
shutil.copytree(config_backup, app / "config", dirs_exist_ok=True)
else:
(app / "config").mkdir(parents=True, exist_ok=True)
finally:
shutil.rmtree(config_backup.parent, ignore_errors=True)
def update_npm_app_from_git(ref: str, node_version: str | None) -> str:
cleanup_build_artifacts()
src, tmp, version = _download_npm_source(ref, "npm-update-")
try:
with step("Creating backup before update"):
_backup_before_update(version)
apt_update_upgrade()
ensure_minimum_nodejs(user_requested_version=node_version)
with step("Preparing NPM source tree"):
adjust_nginx_like_paths_in_tree(src)
inject_footer_link(src)
_build_frontend(src / "frontend", Path("/opt/npm/frontend"))
with step("Synchronizing NPM include files"):
_sync_npm_include_files(src)
with step("Updating Angie main configuration"):
_update_angie_main_config()
with step("Updating backend (preserving config)"):
_replace_backend_preserving_config(src)
install_backend_dependencies()
ensure_certbot_venv_ready()
configure_letsencrypt()
with step("Normalizing ownership"):
run(["chown", "-R", "npm:npm", "/opt/npm"])
Path("/etc/angie/conf.d/dev.conf").unlink(missing_ok=True)
return version
finally:
shutil.rmtree(tmp, ignore_errors=True)
def apply_custom_configuration():
"""Apply installer-owned NPM templates after upstream files are in place."""
comment_x_served_by_step()
set_file_ownership(["/etc/angie/conf.d/include/ip_ranges.conf"], "npm:npm", 0o664)
update_ssl_ciphers_config()
update_npm_assets_config()
update_npm_admin_interface()
update_npm_proxy_host_template()
update_npm_location_template()
update_npm_listen_template()
update_npm_stream_template()
update_npm_http3_header_template()
inject_http3_include_into_host_templates()
def validate_npm_release(version: str):
if parse_version(version) < (2, 13, 0):
raise ValueError(f"NPM {version} is unsupported; minimum version is 2.13.0")
def resolve_npm_ref(args, stored_config: dict) -> tuple[str, bool, str | None]:
"""Return (git ref, installed_from_branch, branch_name)."""
if args.branch:
return f"refs/heads/{args.branch}", True, args.branch
if args.npm_version:
validate_npm_release(args.npm_version)
return f"refs/tags/v{args.npm_version}", False, None
if args.update and stored_config.get("installed_from_branch"):
branch = stored_config.get("branch") or "master"
return f"refs/heads/{branch}", True, branch
version = github_latest_release_tag(f"{GITHUB_REPO_OWNER}/{GITHUB_REPO_NAME}")
validate_npm_release(version)
print(f"✓ Latest stable NPM: {version}")
return f"refs/tags/v{version}", False, None
def main() -> int:
global DEBUG
ensure_root()
parser = argparse.ArgumentParser(
description="Install or update Nginx Proxy Manager on Angie.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"--node-version",
default=None,
help=f"Node.js major from NodeSource (20-{MAX_NODEJS_VERSION}); otherwise auto-detect.",
)
parser.add_argument("--nodejs-pkg", default=None, help=argparse.SUPPRESS)
source = parser.add_mutually_exclusive_group()
source.add_argument(
"--npm-version", "--version", dest="npm_version", default=None,
help="NPM release version (>= 2.13.0); default: latest stable.",
)
source.add_argument(
"--branch", default=None, metavar="BRANCH",
help="Install/update from a Git branch instead of a release tag.",
)
parser.add_argument("--update", action="store_true", help="Update the existing installation.")
parser.add_argument("--motd", choices=["yes", "no"], default="yes")
ipv6 = parser.add_mutually_exclusive_group()
ipv6.add_argument("--enable-ipv6", dest="ipv6_enabled", action="store_true")
ipv6.add_argument("--disable-ipv6", dest="ipv6_enabled", action="store_false")
parser.set_defaults(ipv6_enabled=None)
parser.add_argument("--debug", action="store_true", help="Show detailed command output.")
args = parser.parse_args()
DEBUG = args.debug
# Backward-compatible interpretation of the old, previously broken option.
if args.nodejs_pkg and not args.node_version:
match = re.search(r"(\d+)", args.nodejs_pkg)
if match:
args.node_version = match.group(1)
if not any((args.npm_version, args.branch, args.update)):
print("\nNo installation mode selected. Starting interactive mode...")
args = apply_interactive_choices(args, interactive_install_mode())
print("\n" + "=" * 70)
print("UPDATE" if args.update else "INSTALL")
if args.branch:
print(f"Source: branch {args.branch}")
elif args.npm_version:
print(f"Source: release {args.npm_version}")
else:
print("Source: latest stable release")
print("=" * 70)
if input("Proceed? [Y/n]: ").strip().lower() not in ("", "y", "yes"):
print("Installation cancelled.")
return 0
stored_config = load_installer_config() if args.update else {}
stored_ipv6 = bool(stored_config.get("ipv6_enabled", False))
ipv6_enabled = stored_ipv6 if args.ipv6_enabled is None and args.update else bool(args.ipv6_enabled)
ref, installed_from_branch, branch_name = resolve_npm_ref(args, stored_config)
validate_supported_os()
swap_state = {}
try:
swap_state = check_memory_and_create_swap()
if not swap_state.get("ready", True):
return 1
print("\n================== NPM + ANGIE installer ==================")
print(f"Repository: {GITHUB_REPO_URL}")
print(f"OS: {OSREL['PRETTY']}")
print(f"Mode: {'UPDATE' if args.update else 'INSTALL'}")
print(f"Source: {ref}")
print("===========================================================\n")
if args.update:
install_logrotate_for_data_logs()
fix_logrotate_permissions_and_wrapper()
npm_app_version = update_npm_app_from_git(ref, args.node_version)
else:
apt_update_upgrade()
apt_purge(["nginx", "openresty", "nodejs", "npm", "yarn", "certbot"])
apt_install([
"ca-certificates", "curl", "gnupg", "apt-transport-https", "openssl",
"apache2-utils", "logrotate", "sudo", "acl", "python3", "sqlite3",
"git", "lsb-release", "build-essential",
])
setup_angie(ipv6_enabled=ipv6_enabled)
ensure_nginx_symlink()
ensure_user_and_dirs()
create_sudoers_for_npm()
write_metrics_files()
ensure_minimum_nodejs(user_requested_version=args.node_version)
ensure_certbot_venv_ready()
configure_letsencrypt()
npm_app_version = deploy_npm_app_from_git(ref)
install_logrotate_for_data_logs()
fix_logrotate_permissions_and_wrapper()
ensure_nginx_symlink()
apply_custom_configuration()
if args.update:
print(" NPM templates updated; existing host configs use them after the host is saved again.")
if not ipv6_enabled:
strip_ipv6_listens([Path("/etc/angie"), Path("/data/nginx")])
# Validate the final generated configuration, then restart exactly once.
create_systemd_units(ipv6_enabled=ipv6_enabled, restart=True)
save_installer_config({
"ipv6_enabled": ipv6_enabled,
"node_version": args.node_version,
"npm_version": npm_app_version,
"installed_from_branch": installed_from_branch,
"branch": branch_name,
})
info = gather_versions(npm_app_version)
update_motd(
args.motd == "yes", info, ipv6_enabled=ipv6_enabled,
npm_version=npm_app_version, installed_from_branch=installed_from_branch,
)
print_summary(info, ipv6_enabled, args.update, npm_app_version)
return 0
except KeyboardInterrupt:
print("\nAborted by user")
return 130
finally:
cleanup_swap(swap_state)
if __name__ == "__main__":
sys.exit(main())