3213 lines
108 KiB
Python
3213 lines
108 KiB
Python
#!/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 total and available RAM in GiB using /proc when possible."""
|
||
try:
|
||
values = {}
|
||
for line in Path("/proc/meminfo").read_text().splitlines():
|
||
key, value = line.split(":", 1)
|
||
values[key] = int(value.split()[0])
|
||
total = values.get("MemTotal", 0) / (1024**2)
|
||
available = values.get("MemAvailable", values.get("MemFree", 0)) / (1024**2)
|
||
return total, available
|
||
except Exception:
|
||
try:
|
||
page_size = os.sysconf("SC_PAGE_SIZE")
|
||
total = page_size * os.sysconf("SC_PHYS_PAGES") / (1024**3)
|
||
available = page_size * os.sysconf("SC_AVPHYS_PAGES") / (1024**3)
|
||
return total, available
|
||
except Exception:
|
||
return 0.0, 0.0
|
||
|
||
|
||
def check_memory_and_create_swap():
|
||
"""Create temporary swap only when needed and remember what we changed."""
|
||
total_memory_gb, available_memory_gb = _memory_gb()
|
||
state = {"created": False, "activated_existing": False}
|
||
|
||
print(f"\n{'='*70}")
|
||
print("MEMORY CHECK")
|
||
print(f"{'='*70}")
|
||
if total_memory_gb:
|
||
print(f"Total RAM: {total_memory_gb:.1f} GB")
|
||
print(f"Available: {available_memory_gb:.1f} GB")
|
||
else:
|
||
print("RAM detection: unavailable")
|
||
print("Continuing without automatic swap changes.")
|
||
print(f"{'='*70}\n")
|
||
return state
|
||
print(f"Threshold: {MIN_MEMORY_GB} GB")
|
||
|
||
if available_memory_gb >= MIN_MEMORY_GB:
|
||
print("✓ Memory sufficient")
|
||
print(f"{'='*70}\n")
|
||
return state
|
||
|
||
swap_file = Path("/swapfile")
|
||
print(f"⚠ Low available memory ({available_memory_gb:.1f} GB)")
|
||
|
||
try:
|
||
active_swap = run_out(["swapon", "--show=NAME", "--noheadings"], check=False)
|
||
if str(swap_file) in active_swap.split():
|
||
print("✓ /swapfile is already active; leaving it untouched")
|
||
print(f"{'='*70}\n")
|
||
return state
|
||
except Exception:
|
||
pass
|
||
|
||
if swap_file.exists():
|
||
with step("Activating existing /swapfile"):
|
||
run(["swapon", str(swap_file)])
|
||
state["activated_existing"] = True
|
||
print("✓ Existing /swapfile activated temporarily")
|
||
print(f"{'='*70}\n")
|
||
return state
|
||
|
||
try:
|
||
with step(f"Creating temporary {SWAP_SIZE_GB:g} GB swap"):
|
||
if shutil.which("fallocate"):
|
||
run(["fallocate", "-l", f"{SWAP_SIZE_GB:g}G", str(swap_file)])
|
||
else:
|
||
run([
|
||
"dd", "if=/dev/zero", f"of={swap_file}", "bs=1M",
|
||
f"count={int(SWAP_SIZE_GB * 1024)}", "status=none",
|
||
])
|
||
state["created"] = True
|
||
run(["chmod", "600", str(swap_file)])
|
||
run(["mkswap", str(swap_file)])
|
||
run(["swapon", str(swap_file)])
|
||
except BaseException:
|
||
# The caller cannot receive `state` if this function fails before return,
|
||
# so clean up a partially created swap file here.
|
||
if state["created"]:
|
||
run(["swapoff", str(swap_file)], check=False)
|
||
swap_file.unlink(missing_ok=True)
|
||
raise
|
||
|
||
print("✓ Temporary swap created and activated")
|
||
print(f"{'='*70}\n")
|
||
return state
|
||
|
||
def cleanup_build_artifacts():
|
||
with step("Cleaning 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-module-prometheus",
|
||
"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()
|
||
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())
|