Files
npm-angie-auto-install/npm_install.py
Mateusz Gruszczyński 23af0faadb push
2025-11-13 08:26:35 +01:00

3903 lines
133 KiB
Python

#!/usr/bin/env python3
"""
NPM Auto-Installer
====================================================
For legacy installations (< 2.13.0, use npm_install_multiversion.py
Usage:
./npm_install.py # Install latest stable >= 2.13.0
./npm_install.py --version 2.13.0 # Install specific version (>= 2.13.0)
./npm_install.py --branch master # Install from master branch
./npm_install.py --branch develop # Install from develop branch
"""
from __future__ import annotations
import argparse, os, sys, json, shutil, subprocess, tarfile, tempfile, urllib.request, re, time, threading, signal, shutil, filecmp
from pathlib import Path
from glob import glob
from datetime import datetime
from pathlib import Path
from contextlib import contextmanager
DEBUG = False
# ========== 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 = {
"dots": ["", "", "", "", "", "", "", "", "", ""],
"line": ["|", "/", "-", "\\"],
"arrow": ["", "", "", "", "", "", "", ""],
"braille": ["", "", "", "", "", "", "", ""],
"circle": ["", "", "", ""],
"bounce": ["", "", "", "", "", "", "", ""],
}
def __init__(self, text, style="dots"):
self.text = text
self.style = style
self.frames = self.FRAMES.get(style, self.FRAMES["dots"])
self._stop_event = threading.Event()
self._lock = threading.Lock()
self._thread = None
self._frame_index = 0
self._is_running = False
def _spin(self):
try:
while not self._stop_event.is_set():
with self._lock:
frame = self.frames[self._frame_index % len(self.frames)]
sys.stdout.write(f"\r\033[K{frame} {self.text}")
sys.stdout.flush()
self._frame_index += 1
time.sleep(0.08)
except Exception:
pass
def start(self):
if DEBUG:
print(f"{self.text} ...")
return self
if not sys.stdout.isatty():
print(f"{self.text} ...")
return self
with self._lock:
if not self._is_running:
self._stop_event.clear()
self._frame_index = 0
self._thread = threading.Thread(target=self._spin, daemon=True)
self._thread.start()
self._is_running = True
return self
def stop_ok(self, final_text=None):
text = final_text or self.text
self._stop(f"{text}", " " * 20)
def stop_fail(self, final_text=None):
text = final_text or self.text
self._stop(f"{text}", " " * 20)
def stop_warning(self, final_text=None):
text = final_text or self.text
self._stop(f"{text}", " " * 20)
def _stop(self, message, padding=""):
if DEBUG or not sys.stdout.isatty():
print(message)
self._is_running = False
return
with self._lock:
self._stop_event.set()
self._is_running = False
if self._thread and self._thread.is_alive():
self._thread.join(timeout=0.5)
sys.stdout.write(f"\r\033[K{message}{padding}\n")
sys.stdout.flush()
def update_text(self, new_text):
with self._lock:
self.text = new_text
def __enter__(self):
self.start()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is not None:
self.stop_fail()
else:
self.stop_ok()
return False
@contextmanager
def step(text, style="dots"):
spinner = Spinner(text, style=style)
spinner.start()
try:
yield spinner
spinner.stop_ok()
except Exception as e:
spinner.stop_fail()
raise
def signal_handler(signum, frame):
sys.stdout.write("\r\033[K")
sys.stdout.flush()
print("\nAborted by user")
sys.exit(130)
signal.signal(signal.SIGINT, signal_handler)
def _devnull():
return subprocess.DEVNULL if not DEBUG else None
def run(cmd, check=True, env=None):
if DEBUG:
print("+", " ".join(cmd))
return subprocess.run(
cmd,
check=check,
env=env,
stdout=None if DEBUG else subprocess.DEVNULL,
stderr=None if DEBUG else subprocess.DEVNULL,
)
def run_out(cmd, check=True):
if DEBUG:
print("+", " ".join(cmd))
result = subprocess.run(cmd, check=check, 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 append_unique(path: Path, lines: str):
path.parent.mkdir(parents=True, exist_ok=True)
existing = path.read_text(encoding="utf-8") if path.exists() else ""
out = existing
for line in lines.splitlines():
if line.strip() and line not in existing:
out += ("" if out.endswith("\n") else "\n") + line + "\n"
path.write_text(out, encoding="utf-8")
def parse_version(version_str: str) -> tuple:
try:
parts = re.match(r"(\d+)\.(\d+)\.(\d+)", version_str.strip())
if parts:
return (int(parts.group(1)), int(parts.group(2)), int(parts.group(3)))
return (0, 0, 0)
except:
return (0, 0, 0)
def interactive_install_mode():
"""
Interactive mode - asks user for installation preferences when no args provided.
Returns dict with user choices.
"""
# DEFAULT: Tagged release (stable) instead of branch
print("="*70)
print("NGINX PROXY MANAGER - INTERACTIVE INSTALLATION")
print("="*70)
print("1. Select mode")
print(" 1) Fresh Install (default)")
print(" 2) Update existing installation")
mode_choice = input(" choice [1]: ").strip() or "1"
is_update = (mode_choice == "2")
if is_update:
print(" Update mode selected")
return {"update": True}
print("2. Installation source")
print(" 1) Tagged release (stable version) - recommended")
print(" 2) Branch (master) - latest development")
source_choice = input(" choice [1]: ").strip() or "1"
if source_choice == "2":
print("3. Select branch")
print(" 1) master (default)")
print(" 2) dev")
print(" 3) custom branch name")
branch_choice = input(" choice [1]: ").strip() or "1"
if branch_choice == "1":
branch_name = "master"
elif branch_choice == "2":
branch_name = "dev"
else:
branch_name = input(" Enter custom branch name: ").strip() or "master"
print(f" Installing from branch: {branch_name}")
return {
"update": False,
"branch": branch_name,
"npm_version": None,
}
else:
# INSTALL TAG
print("3. Select NPM version")
print(" 1) Latest stable release (auto-detect)")
print(" 2) Specific version (e.g., 2.13.2)")
version_choice = input(" choice [1]: ").strip() or "1"
if version_choice == "1":
npm_version = None
print(" ✓ Will install latest stable release")
else:
npm_version = input(" Enter version (e.g., 2.13.2): ").strip()
if npm_version:
print(f" ✓ Will install NPM v{npm_version}")
else:
npm_version = None
print(" ✓ Will install latest stable release")
return {
"update": False,
"branch": None,
"npm_version": npm_version,
}
def apply_interactive_choices(args, choices):
"""Apply interactive mode choices to argparse args."""
args.update = choices.get("update", False)
args.branch = choices.get("branch", None)
args.npm_version = choices.get("npm_version", None)
if DEBUG:
print(f"DEBUG: Interactive choices applied:")
print(f" - update: {args.update}")
print(f" - branch: {args.branch}")
print(f" - npm_version: {args.npm_version}")
print(f" - Logic: branch={args.branch is not None}, npm_version={args.npm_version is not None}")
if args.branch is None and args.npm_version is None:
print(f" → Installing from LATEST RELEASE tag (auto-detect latest)")
elif args.branch is not None and args.npm_version is None:
print(f" → Installing from BRANCH: {args.branch}")
elif args.npm_version is not None:
print(f" → Installing from TAG/VERSION: {args.npm_version}")
return args
def check_memory_and_create_swap():
"""Check available memory and create swap if needed - portable version."""
try:
try:
import psutil
total_memory_gb = psutil.virtual_memory().total / (1024**3)
available_memory_gb = psutil.virtual_memory().available / (1024**3)
except ImportError:
try:
with open("/proc/meminfo", "r") as f:
meminfo = {}
for line in f:
key, val = line.split(":")
meminfo[key.strip()] = int(val.split()[0])
total_memory_gb = meminfo.get("MemTotal", 0) / (1024**2)
available_memory_gb = meminfo.get(
"MemAvailable", meminfo.get("MemFree", 0)
) / (1024**2)
except:
try:
total_memory = os.sysconf("SC_PAGE_SIZE") * os.sysconf(
"SC_PHYS_PAGES"
)
available_memory = os.sysconf("SC_PAGE_SIZE") * os.sysconf(
"SC_PAGESIZE"
)
total_memory_gb = total_memory / (1024**3)
available_memory_gb = available_memory / (1024**3)
except:
if DEBUG:
print(
"⚠ Could not detect system memory, assuming 2 GB available"
)
return {"total_gb": 2.0, "available_gb": 2.0, "needs_swap": False}
print(f"\n{'='*70}")
print("MEMORY CHECK")
print(f"{'='*70}")
print(f"Total RAM: {total_memory_gb:.1f} GB")
print(f"Available: {available_memory_gb:.1f} GB")
print(f"Threshold: {MIN_MEMORY_GB} GB")
memory_info = {
"total_gb": total_memory_gb,
"available_gb": available_memory_gb,
"needs_swap": available_memory_gb < MIN_MEMORY_GB,
}
if memory_info["needs_swap"]:
print(
f"⚠ Low memory detected! ({available_memory_gb:.1f} GB < {MIN_MEMORY_GB} GB)"
)
swap_file = Path("/swapfile")
try:
swapon_output = run_out(["swapon", "--show"], check=False)
if swapon_output and "/swapfile" in swapon_output:
print(f"✓ Swap file (/swapfile) already active")
print(f"{'='*70}\n")
return memory_info
except Exception as e:
if DEBUG:
print(f" Debug: swapon check failed: {e}")
if swap_file.exists():
print(f"✓ Swap file already exists at /swapfile")
file_size_bytes = swap_file.stat().st_size
file_size_gb = file_size_bytes / (1024**3)
print(f" File size: {file_size_gb:.1f} GB")
try:
run(["swapon", str(swap_file)], check=False)
except:
pass
print(f"{'='*70}\n")
return memory_info
print(f"Creating {SWAP_SIZE_GB} GB swap file at /swapfile...")
try:
with step("Creating swap file"):
run(
[
"dd",
"if=/dev/zero",
f"of={swap_file}",
f"bs=1G",
f"count={int(SWAP_SIZE_GB)}",
]
)
run(["chmod", "600", str(swap_file)])
run(["mkswap", str(swap_file)])
run(["swapon", str(swap_file)])
print(f"✓ Swap ({SWAP_SIZE_GB} GB) created and activated")
except Exception as e:
print(f"⚠ Could not create swap: {e}")
print(f" Continuing anyway, installation may be slower...")
else:
print(
f"✓ Memory sufficient ({available_memory_gb:.1f} GB >= {MIN_MEMORY_GB} GB)"
)
print(f"{'='*70}\n")
return memory_info
except Exception as e:
print(f"⚠ Error checking memory: {e}")
print(f" Assuming sufficient memory and continuing...")
return {"total_gb": 2.0, "available_gb": 2.0, "needs_swap": False}
def cleanup_swap():
"""
Removes temporary swap if it was created.
"""
try:
swap_file = Path("/swapfile")
if swap_file.exists():
with step("Cleaning up swap"):
run(["swapoff", str(swap_file)], check=False)
swap_file.unlink()
print("✓ Temporary swap removed")
except Exception as e:
print(f"⚠ Could not remove swap: {e}")
def github_latest_release_tag(repo: str, override: str | None) -> str:
if override:
return override.lstrip("v")
url = f"https://api.github.com/repos/{repo}/releases/latest"
with step(f"Downloading from GitGub: {repo}"):
with urllib.request.urlopen(url) as r:
data = json.load(r)
tag = data["tag_name"]
return tag.lstrip("v")
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": "21", "lts": "18", "current": "21"}
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 > MAX_NODEJS_VERSION:
warning = (
f"⚠ WARNING: Requested Node.js v{major_version} exceeds maximum tested version (v{MAX_NODEJS_VERSION}).\n"
f" NPM may not be compatible with Node.js v{major_version}.\n"
f" Falling back to Node.js v{MAX_NODEJS_VERSION}."
)
return False, str(MAX_NODEJS_VERSION), warning
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": ["20.04", "22.04", "24.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 (Bullseye), 12 (Bookworm), 13 (Trixie)")
print(f" • Ubuntu 20.04 LTS, 22.04 LTS, 24.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():
raise FileNotFoundError(path)
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):
success = []
failed = []
for file_path in files:
path = Path(file_path)
if not path.exists():
failed.append((str(path), "File not found"))
continue
try:
run(["chown", owner, str(path)])
if mode is not None:
os.chmod(path, mode)
success.append(str(path))
except Exception as e:
failed.append((str(path), str(e)))
if success:
print(f"✔ Set ownership '{owner}' for {len(success)} file(s)")
if DEBUG:
for f in success:
print(f" - {f}")
if failed:
print(f"⚠ Failed to set ownership for {len(failed)} file(s):")
for f, err in failed:
print(f" - {f}: {err}")
return len(failed) == 0
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 ensure_minimum_nodejs(min_version=MIN_NODEJS_VERSION, user_requested_version=None):
with step("Checking Node.js version requirements\n"):
try:
node_ver = run_out(["node", "--version"], check=False).strip()
match = re.match(r"v?(\d+)", node_ver)
if match:
current_major = int(match.group(1))
if user_requested_version:
requested_match = re.match(r"(\d+)", str(user_requested_version))
if requested_match:
requested_major = int(requested_match.group(1))
if requested_major < MIN_NODEJS_VERSION:
requested_major = MIN_NODEJS_VERSION
elif requested_major > MAX_NODEJS_VERSION:
requested_major = MAX_NODEJS_VERSION
if current_major == requested_major:
if shutil.which("npm"):
npm_ver = run_out(
["npm", "--version"], check=False
).strip()
print(f" Node.js: {node_ver}")
print(f" npm: {npm_ver}")
else:
print(f" Node.js: {node_ver}")
return True
else:
if current_major >= min_version:
if shutil.which("npm"):
npm_ver = run_out(["npm", "--version"], check=False).strip()
print(f" Node.js: {node_ver}")
print(f" npm: {npm_ver}")
else:
print(f" Node.js: {node_ver}")
return True
except FileNotFoundError:
pass
except Exception:
pass
if user_requested_version:
requested_match = re.match(r"(\d+)", str(user_requested_version))
if requested_match:
requested_major = int(requested_match.group(1))
if requested_major < MIN_NODEJS_VERSION:
print(
f"⚠ Requested version {requested_major} < minimum {MIN_NODEJS_VERSION}"
)
print(f" Installing minimum version: v{MIN_NODEJS_VERSION}")
install_node_from_nodesource(str(MIN_NODEJS_VERSION))
elif requested_major > MAX_NODEJS_VERSION:
print(
f"⚠ Requested version {requested_major} > maximum {MAX_NODEJS_VERSION}"
)
print(f" Installing maximum version: v{MAX_NODEJS_VERSION}")
install_node_from_nodesource(str(MAX_NODEJS_VERSION))
else:
install_node_from_nodesource(str(requested_major))
else:
install_node_from_nodesource(str(MIN_NODEJS_VERSION))
else:
has_nodejs, major, version_str = check_distro_nodejs_available()
if has_nodejs and major and major >= min_version:
print(f"✓ Distribution provides Node.js v{version_str} (>= v{min_version})")
if install_nodejs_from_distro():
return True
else:
print(f"⚠ Failed to install from distro, falling back to NodeSource")
install_node_from_nodesource(str(min_version))
else:
if has_nodejs:
print(f"⚠ Distribution Node.js v{version_str} < minimum v{min_version}")
else:
print(f"✓ Distribution doesn't provide Node.js package")
print(f" Installing from NodeSource: v{min_version}")
install_node_from_nodesource(str(min_version))
if shutil.which("node"):
node_ver = run_out(["node", "--version"], check=False).strip()
if shutil.which("npm"):
npm_ver = run_out(["npm", "--version"], check=False).strip()
return True
return False
def download_extract_tar_gz(url: str, dest_dir: Path) -> Path:
dest_dir.mkdir(parents=True, exist_ok=True)
with step("Downloading and untaring"):
with urllib.request.urlopen(url) as r, tempfile.NamedTemporaryFile(
delete=False
) as tf:
shutil.copyfileobj(r, tf)
tf.flush()
tf_path = Path(tf.name)
with tarfile.open(tf_path, "r:gz") as t:
try:
t.extractall(dest_dir, filter="data")
except TypeError:
t.extractall(dest_dir)
except Exception as e:
if "LinkOutsideDestinationError" in str(type(e).__name__):
t.extractall(dest_dir)
else:
raise
top = t.getmembers()[0].name.split("/")[0]
os.unlink(tf_path)
return dest_dir / top
# Distro info (used in banners & repo setup)
OSREL = os_release()
# === extra sync ===
def sync_backup_nginx_conf():
src = Path("/etc/nginx.bak/conf.d")
dst = Path("/etc/angie/conf.d")
if not src.exists():
return
with step("Sync /etc/nginx.bak/conf.d -> /etc/angie/conf.d"):
for p in src.rglob("*"):
if p.is_dir():
continue
rel = p.relative_to(src)
target = dst / rel
target.parent.mkdir(parents=True, exist_ok=True)
try:
if not target.exists() or not filecmp.cmp(p, target, shallow=False):
shutil.copy2(p, target)
except Exception as e:
print(f"Warning: sync failed for {p} -> {target}: {e}")
def setup_certbot_venv(venv_dir: Path = Path("/opt/certbot")):
info = os_release()
distro_id = (info.get("ID") or "").lower()
# ============================================================
# STEP 1: Check if Python 3.11 is already available
# ============================================================
python311_available = False
if shutil.which("python3.11"):
try:
ver_output = run_out(["python3.11", "--version"], check=False).strip()
match = re.search(r"Python (\d+)\.(\d+)", ver_output)
if match:
major, minor = int(match.group(1)), int(match.group(2))
if major == 3 and minor == 11:
python311_available = True
if DEBUG:
print(f"✔ Found system Python 3.11: {ver_output}")
except Exception:
pass
# ============================================================
# STEP 2: Use system Python 3.11 if available
# ============================================================
if python311_available:
with step(f"Using system Python 3.11 for certbot venv"):
# Ensure python3.11-venv is installed
apt_try_install(["python3.11-venv", "python3-pip"])
venv_dir.mkdir(parents=True, exist_ok=True)
run(["python3.11", "-m", "venv", str(venv_dir)])
venv_bin = venv_dir / "bin"
pip_path = venv_bin / "pip"
certbot_path = venv_bin / "certbot"
env_build = os.environ.copy()
env_build["SETUPTOOLS_USE_DISTUTILS"] = "local"
run(
[str(pip_path), "install", "-U", "pip", "setuptools", "wheel"],
env=env_build,
)
run(
[
str(pip_path),
"install",
"-U",
"cryptography",
"cffi",
"certbot",
"tldextract",
],
env=env_build,
)
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)
cb_ver = run_out([str(certbot_path), "--version"], check=False) or ""
pip_ver = run_out([str(pip_path), "--version"], check=False) or ""
print(f" Python: {ver_output}")
print(f" Certbot: {cb_ver.strip()}")
print(f" Pip: {pip_ver.strip().split(' from ')[0]}")
return
# ============================================================
# STEP 3: Ubuntu - install Python 3.11 from deadsnakes PPA
# ============================================================
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"])
with step(f"Create venv at {venv_dir} using python3.11"):
venv_dir.mkdir(parents=True, exist_ok=True)
run(["python3.11", "-m", "venv", str(venv_dir)])
venv_bin = venv_dir / "bin"
pip_path = venv_bin / "pip"
certbot_path = venv_bin / "certbot"
env_build = os.environ.copy()
env_build["SETUPTOOLS_USE_DISTUTILS"] = "local"
run(
[str(pip_path), "install", "-U", "pip", "setuptools", "wheel"],
env=env_build,
)
run(
[
str(pip_path),
"install",
"-U",
"cryptography",
"cffi",
"certbot",
"tldextract",
],
env=env_build,
)
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)
cb_ver = run_out([str(certbot_path), "--version"], check=False) or ""
pip_ver = run_out([str(pip_path), "--version"], check=False) or ""
print(f" Python: Python 3.11 (deadsnakes)")
print(f" Certbot: {cb_ver.strip()}")
print(f" Pip: {pip_ver.strip().split(' from ')[0]}")
return
# ============================================================
# STEP 4: Debian - install Python 3.11 via pyenv
# ============================================================
PYENV_ROOT = Path("/opt/npm/.pyenv")
PYENV_OWNER = "npm"
PYTHON_VERSION = "3.11.14"
# Build dependencies dla pyenv
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",
# "ca-certificates",
# "curl",
# "git",
]
)
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(
[
"sudo",
"-u",
PYENV_OWNER,
"bash",
"-lc",
'if [ ! -x "/opt/npm/.pyenv/bin/pyenv" ]; then '
" command -v git >/dev/null 2>&1 || sudo apt-get install -y git; "
" git clone --depth=1 https://github.com/pyenv/pyenv.git /opt/npm/.pyenv; "
"fi",
]
)
PYENV_BIN_CANDIDATES = [
str(PYENV_ROOT / "bin" / "pyenv"),
"pyenv",
"/usr/bin/pyenv",
"/usr/lib/pyenv/bin/pyenv",
]
pyenv_bin = next(
(c for c in PYENV_BIN_CANDIDATES if shutil.which(c) or Path(c).exists()), None
)
if not pyenv_bin:
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)
run(
[
"sudo",
"-u",
PYENV_OWNER,
"bash",
"-lc",
'if [ ! -x "/opt/npm/.pyenv/bin/pyenv" ]; then '
" command -v git >/dev/null 2>&1 || sudo apt-get install -y git; "
" git clone --depth=1 https://github.com/pyenv/pyenv.git /opt/npm/.pyenv; "
"fi",
]
)
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 -s {PYTHON_VERSION}"
)
run(
[
"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,
]
)
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/.")
venv_bin = venv_dir / "bin"
pip_path = venv_bin / "pip"
certbot_path = venv_bin / "certbot"
with step(f"Preparing Certbot venv at {venv_dir} (Python {PYTHON_VERSION})"):
venv_dir.mkdir(parents=True, exist_ok=True)
if not venv_dir.exists() or not pip_path.exists():
run([str(python311), "-m", "venv", str(venv_dir)])
env_build = os.environ.copy()
env_build["SETUPTOOLS_USE_DISTUTILS"] = "local"
run(
[str(pip_path), "install", "-U", "pip", "setuptools", "wheel"],
env=env_build,
)
run(
[
str(pip_path),
"install",
"-U",
"cryptography",
"cffi",
"certbot",
"tldextract",
],
env=env_build,
)
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)
cb_ver = run_out([str(certbot_path), "--version"], check=False) or ""
pip_ver = run_out([str(pip_path), "--version"], check=False) or ""
print(f" Python: {PYTHON_VERSION} (pyenv)")
print(f" Certbot: {cb_ver.strip()}")
print(f" Pip: {pip_ver.strip().split(' from ')[0]}")
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)
run(
["apt-get", "install", "-y", "--no-install-recommends", "certbot"],
check=False,
)
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():
target = Path("/etc/angie")
link = Path("/etc/nginx")
try:
if link.is_symlink() and link.resolve() == target:
print("✔ Created symlink /etc/nginx -> /etc/angie")
return
if link.exists() and not link.is_symlink():
backup = Path("/etc/nginx.bak")
try:
if backup.exists():
if backup.is_symlink() or backup.is_file():
backup.unlink()
link.rename(backup)
print("✔ Backed up /etc/nginx to /etc/nginx.bak")
except Exception as e:
print(f"Warning: could not backup /etc/nginx: {e}")
try:
if link.exists() or link.is_symlink():
link.unlink()
except Exception:
pass
try:
link.symlink_to(target)
print("✔ Created symlink /etc/nginx -> /etc/angie")
except Exception as e:
print(f"Warning: could not create /etc/nginx symlink: {e}")
except Exception as e:
print(f"Warning: symlink check failed: {e}")
# ========== Angie / NPM template ==========
ANGIE_CONF_TEMPLATE = """# 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;
# 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; # Performance boost dla Linux z UDP_SEGMENT
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.conf;
include /etc/angie/conf.d/include/resolvers.conf;
map $host $forward_scheme { default http; }
# 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 /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
"""
def lsb_info():
try:
apt_try_install(["lsb-release"])
dist = (
run_out(["bash", "-lc", "lsb_release -si"]).strip().lower().replace(" ", "")
)
rel = run_out(["bash", "-lc", "lsb_release -sr"]).strip()
code = run_out(["bash", "-lc", "lsb_release -sc"]).strip()
return {
"ID": dist,
"VERSION_ID": rel,
"CODENAME": code,
"PRETTY": f"{dist} {rel} ({code})",
}
except Exception:
return os_release()
# ========== Angie ==========
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"])
base = [
"angie",
"angie-module-headers-more",
"angie-module-brotli",
"angie-module-zstd",
]
optional = ["angie-module-prometheus", "angie-console-light"]
apt_install(base)
apt_try_install(optional)
with step("Configuring modules and main Angie config"):
modules_dir = Path("/etc/nginx/modules")
modules_dir.mkdir(parents=True, exist_ok=True)
write_file(Path("/etc/angie/angie.conf"), ANGIE_CONF_TEMPLATE, 0o644)
WRAP = """#!/bin/sh
exec sudo -n /usr/sbin/angie "$@"
"""
write_file(Path("/usr/sbin/nginx"), WRAP, 0o755)
Path("/etc/nginx/conf.d/include").mkdir(parents=True, exist_ok=True)
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 with console & status)."""
with step("Adding Angie metrics & console on :82"):
metrics = """include /etc/angie/prometheus_all.conf;
server {
listen 82;
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 _prepare_sass(frontend_dir: Path):
"""Prepare SASS/SCSS dependencies for frontend build."""
sass_dir = frontend_dir / "sass"
node_sass_dir = frontend_dir / "node_modules" / "node-sass"
# Check if sass directory exists
if not sass_dir.exists():
if DEBUG:
print(f" No sass directory found at {sass_dir}")
return
# Try to ensure node-sass is available
try:
if not node_sass_dir.exists():
if DEBUG:
print(" Installing node-sass...")
os.chdir(frontend_dir)
run(["npm", "install", "node-sass"], check=False)
except Exception as e:
if DEBUG:
print(f" Warning: Could not install node-sass: {e}")
def install_node_from_nodesource(version: str):
is_valid, resolved_version, warning = validate_nodejs_version(version)
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 _build_frontend(src_frontend: Path, dest_frontend: Path):
"""Build frontend with Yarn 4.x and robust error handling."""
def _get_yarn_major_version(cmd: list[str]) -> int | None:
"""Get major version of yarn."""
try:
result = subprocess.run(
cmd + ["--version"],
capture_output=True,
text=True,
timeout=5,
check=False,
)
if result.returncode != 0:
return None
version_str = (result.stdout or "").strip()
if not version_str:
return None
# Extract major version (e.g., "4.11.0" -> 4, "1.22.22" -> 1)
match = re.match(r'^(\d+)', version_str)
if match:
return int(match.group(1))
return None
except Exception:
return None
def _pick_yarn_cmd() -> list[str] | None:
"""Find working yarn command (requires Yarn 4.x or higher)."""
# Check yarn in PATH
if shutil.which("yarn"):
major = _get_yarn_major_version(["yarn"])
if major and major >= 4:
return ["yarn"]
elif major:
if DEBUG:
print(f" Found Yarn {major}.x but need 4.x+")
# Check yarnpkg
if shutil.which("yarnpkg"):
major = _get_yarn_major_version(["yarnpkg"])
if major and major >= 4:
return ["yarnpkg"]
elif major:
if DEBUG:
print(f" Found yarnpkg {major}.x but need 4.x+")
# Fallback to npm exec
if shutil.which("npm"):
try:
major = _get_yarn_major_version(["npm", "exec", "--yes", "yarn@stable", "--"])
if major and major >= 4:
return ["npm", "exec", "--yes", "yarn@stable", "--"]
except Exception:
pass
# Fallback to npx
if shutil.which("npx"):
try:
major = _get_yarn_major_version(["npx", "-y", "yarn@stable"])
if major and major >= 4:
return ["npx", "-y", "yarn@stable"]
except Exception:
pass
return None
def _cleanup_yarn_artifacts():
"""Remove corrupted yarn artifacts."""
cleanup_paths = [
Path("/root/.yarn"),
Path("/root/.yarnrc.yml"),
Path("/root/.yarnrc"),
Path(os.path.expanduser("~/.config/yarn")),
]
for path in cleanup_paths:
try:
if path.exists():
if path.is_dir():
shutil.rmtree(path, ignore_errors=True)
else:
path.unlink(missing_ok=True)
if DEBUG:
print(f" ✓ Cleaned: {path}")
except Exception as e:
if DEBUG:
print(f" ⚠ Could not remove {path}: {e}")
def _ensure_yarn_installed(retry_count=0, max_retries=2):
"""Install Yarn 4.x using corepack (preferred) or other methods."""
step_msg = "Installing Yarn 4.x"
if retry_count > 0:
step_msg = f"Reinstalling Yarn 4.x (attempt {retry_count + 1}/{max_retries + 1})"
with step(step_msg):
# Cleanup on retry
if retry_count > 0:
_cleanup_yarn_artifacts()
# Uninstall old Yarn 1.x
try:
run(["npm", "uninstall", "-g", "yarn"], check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception:
pass
# Clear cache
try:
run(["npm", "cache", "clean", "--force"], check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception:
pass
# Ensure npm is available
if not shutil.which("npm"):
try:
apt_try_install(["npm"])
except Exception:
run(["apt-get", "update"], check=False)
run(["apt-get", "install", "-y", "npm"])
# METHOD 1: Corepack (recommended for Yarn 4.x)
if shutil.which("corepack"):
try:
# Enable corepack
result = subprocess.run(
["corepack", "enable"],
input="",
stdin=subprocess.PIPE,
capture_output=True,
timeout=10,
text=True,
check=False,
)
if result.returncode == 0 or "already" in (result.stderr or "").lower():
# Install Yarn stable (4.x)
result = subprocess.run(
["corepack", "install", "-g", "yarn@stable"],
capture_output=True,
timeout=60,
text=True,
check=False,
)
if result.returncode == 0:
# Activate it
subprocess.run(
["corepack", "prepare", "yarn@stable", "--activate"],
capture_output=True,
timeout=30,
check=False,
)
# Check version
if shutil.which("yarn"):
major = _get_yarn_major_version(["yarn"])
if major and major >= 4:
if DEBUG:
print(f" ✓ Yarn {major}.x installed via corepack")
return True
except Exception as e:
if DEBUG:
print(f" ⚠ Corepack method failed: {e}")
# METHOD 2: yarn set version (for upgrade from 1.x)
if shutil.which("yarn"):
try:
# Use existing yarn 1.x to install 4.x
result = subprocess.run(
["yarn", "set", "version", "stable"],
capture_output=True,
timeout=60,
text=True,
check=False,
)
if result.returncode == 0:
major = _get_yarn_major_version(["yarn"])
if major and major >= 4:
if DEBUG:
print(f" ✓ Yarn {major}.x installed via 'yarn set version'")
return True
except Exception as e:
if DEBUG:
print(f"'yarn set version' failed: {e}")
# METHOD 3: npm install yarn@berry
try:
run(["npm", "install", "-g", "yarn@berry"], check=False)
if shutil.which("yarn"):
major = _get_yarn_major_version(["yarn"])
if major and major >= 4:
if DEBUG:
print(f" ✓ Yarn {major}.x installed via npm")
return True
except Exception as e:
if DEBUG:
print(f" ⚠ npm install yarn@berry failed: {e}")
# METHOD 4: Force install latest yarn
try:
run(["npm", "install", "-g", "yarn@latest", "--force"], check=False)
if shutil.which("yarn"):
major = _get_yarn_major_version(["yarn"])
if major and major >= 4:
if DEBUG:
print(f" ✓ Yarn {major}.x installed via npm --force")
return True
except Exception as e:
if DEBUG:
print(f" ⚠ npm install --force failed: {e}")
return False
# Main logic: Find or install Yarn 4.x
yarn_cmd = _pick_yarn_cmd()
if not yarn_cmd:
if DEBUG:
print(" No valid Yarn 4.x found, attempting installation...")
if _ensure_yarn_installed(retry_count=0):
yarn_cmd = _pick_yarn_cmd()
# Retry with cleanup if still not found
if not yarn_cmd:
if DEBUG:
print(" Yarn installation failed, retrying with full cleanup...")
if _ensure_yarn_installed(retry_count=1):
yarn_cmd = _pick_yarn_cmd()
# Final check
if not yarn_cmd:
raise RuntimeError(
"Unable to detect or install a valid Yarn 4.x after multiple attempts.\n"
"Manual recovery steps:\n"
" 1. Remove old Yarn: npm uninstall -g yarn\n"
" 2. Clean artifacts: rm -rf /root/.yarn /root/.yarnrc* ~/.config/yarn\n"
" 3. Clean cache: npm cache clean --force\n"
" 4. Enable corepack: corepack enable\n"
" 5. Install Yarn 4.x: corepack install -g yarn@stable\n"
" 6. Activate: corepack prepare yarn@stable --activate\n"
" 7. Alternative: yarn set version stable (if yarn 1.x exists)"
)
if DEBUG:
major = _get_yarn_major_version(yarn_cmd)
print(f" Using Yarn {major}.x: {' '.join(yarn_cmd)}")
# Install frontend dependencies
with step("Installing frontend dependencies (yarn)"):
try:
os.environ["NODE_ENV"] = "development"
os.chdir(src_frontend)
# Call existing _prepare_sass function (defined earlier in file)
_prepare_sass(src_frontend)
# Setup cache directory
try:
cache_dir = (
run_out(yarn_cmd + ["cache", "dir"], check=False) or ""
).strip()
if cache_dir:
Path(cache_dir).mkdir(parents=True, exist_ok=True)
except Exception as e:
if DEBUG:
print(f" ⚠ Cache dir setup failed: {e}")
# Clean cache
try:
run(yarn_cmd + ["cache", "clean"], check=False)
except Exception:
pass
# Prepare install command
install_cmd = yarn_cmd + ["install"]
if install_cmd[-1] == "--":
install_cmd = install_cmd[:-1]
if DEBUG:
print(f" Running: {' '.join(install_cmd)}")
# Try install
try:
run(install_cmd)
except subprocess.CalledProcessError as e:
print(f" ⚠ Yarn install failed (exit {e.returncode}), retrying with compatibility flags...")
retry_cmd = install_cmd + [
"--network-timeout", "100000",
"--ignore-engines",
]
try:
run(retry_cmd)
except subprocess.CalledProcessError:
# Last resort: remove node_modules and try again
print(" ⚠ Retry failed, cleaning node_modules...")
node_modules = src_frontend / "node_modules"
if node_modules.exists():
shutil.rmtree(node_modules, ignore_errors=True)
run(retry_cmd)
except Exception as e:
raise RuntimeError(f"Frontend dependency installation failed: {e}") from e
# Build frontend
with step("Building frontend (yarn build)"):
try:
env = os.environ.copy()
env["NODE_OPTIONS"] = "--openssl-legacy-provider"
build_cmd = yarn_cmd + ["build"]
if build_cmd[-1] == "--":
build_cmd = build_cmd[:-1]
if DEBUG:
print(f" Running: {' '.join(build_cmd)}")
try:
run(build_cmd, env=env)
except subprocess.CalledProcessError:
print(" ⚠ Build failed with legacy provider, retrying without...")
env.pop("NODE_OPTIONS", None)
run(build_cmd, env=env)
except subprocess.CalledProcessError as e:
raise RuntimeError(
f"Frontend build failed with exit code {e.returncode}.\n"
f"Check build logs above for details."
) from e
# Copy artifacts
with step("Copying frontend artifacts"):
try:
dist_dir = src_frontend / "dist"
if not dist_dir.exists():
raise RuntimeError(f"Build output directory not found: {dist_dir}")
shutil.copytree(dist_dir, dest_frontend, dirs_exist_ok=True)
# Copy images if exist
app_images = src_frontend / "app-images"
if app_images.exists():
shutil.copytree(
app_images,
dest_frontend / "images",
dirs_exist_ok=True,
)
except Exception as e:
raise RuntimeError(f"Failed to copy frontend artifacts: {e}") from e
def patch_npm_backend_commands():
candidates = [
Path("/opt/npm/lib/utils.js"),
Path("/opt/npm/utils.js"),
Path("/opt/npm/lib/commands.js"),
]
for p in candidates:
if not p.exists():
continue
try:
txt = p.read_text(encoding="utf-8")
except Exception:
continue
new = re.sub(r"\blogrotate\b", "/usr/local/bin/logrotate-npm", txt)
new = re.sub(r"(?<!/usr/sbin/)\bnginx\b", "/usr/sbin/nginx", new)
if new != txt:
p.write_text(new, encoding="utf-8")
# ========== DEPLOY FUNCTIONS ==========
def inject_footer_link(src: Path) -> None:
footer_path = src / "frontend" / "src" / "components" / "SiteFooter.tsx"
if not footer_path.exists():
if DEBUG:
print(f" SiteFooter.tsx not found at {footer_path}")
return
try:
content = footer_path.read_text(encoding="utf-8")
if '<T id="footer.github-fork" />' not in content:
if DEBUG:
print(" GitHub fork link not found in SiteFooter.tsx")
return
if "linuxiarz.pl" in content or "Auto Installer" in content:
if DEBUG:
print(" Installer link already present in SiteFooter.tsx")
return
installer_item = """<li className="list-inline-item">
<a href="https://gitea.linuxiarz.pl/gru/npm-angie-auto-install"
target="_blank"
className="link-secondary"
rel="noopener">Deployed by Auto Installer | linuxiarz.pl</a>
</li>"""
pattern = r'(<li className="list-inline-item">\s*<a[^>]*>\s*<T id="footer\.github-fork"[^<]*</a>\s*</li>)(\s*</ul>)'
if re.search(pattern, content, re.DOTALL):
new_content = re.sub(
pattern,
rf"\1\n{installer_item}\2",
content,
flags=re.DOTALL
)
footer_path.write_text(new_content, encoding="utf-8")
if DEBUG:
print(" ✓ Injected installer link via regex method")
return
search_str = '<T id="footer.github-fork" />'
idx = content.find(search_str)
if idx < 0:
if DEBUG:
print(" Could not find injection point for installer link")
return
close_li_idx = content.find("</li>", idx)
if close_li_idx < 0:
if DEBUG:
print(" Could not find closing </li> tag for injection")
return
insert_pos = close_li_idx + 5
new_content = (
content[:insert_pos] +
"\n" + installer_item +
content[insert_pos:]
)
footer_path.write_text(new_content, encoding="utf-8")
if DEBUG:
print(" ✓ Injected installer link via fallback method")
return
except Exception as e:
if DEBUG:
print(f" ⚠ Warning: Failed to inject footer link: {e}")
return
def deploy_npm_app_from_git(ref: str) -> str:
if ref.startswith("refs/heads/"):
ref_type = "branch"
branch_name = ref.replace("refs/heads/", "")
timestamp = datetime.now().strftime("%Y%m%d-%H%M")
version = f"{branch_name}-dev-{timestamp}"
git_ref = branch_name
elif ref.startswith("refs/tags/"):
ref_type = "tag"
version = ref.replace("refs/tags/v", "").replace("refs/tags/", "")
tag_name = ref.replace("refs/tags/", "")
git_ref = tag_name
else:
ref_type = "branch"
branch_name = ref
timestamp = datetime.now().strftime("%Y%m%d-%H%M")
version = f"{branch_name}-dev-{timestamp}"
git_ref = branch_name
url = f"https://codeload.github.com/NginxProxyManager/nginx-proxy-manager/tar.gz/{git_ref}"
tmp = Path(tempfile.mkdtemp(prefix="npm-angie-"))
src = download_extract_tar_gz(url, tmp)
# Set version numbers in package.json files
with step("Setting version numbers in package.json"):
for pkg in ["backend/package.json", "frontend/package.json"]:
pj = src / pkg
if not pj.exists():
continue
try:
data = json.loads(pj.read_text(encoding="utf-8"))
data["version"] = version
pj.write_text(
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
if DEBUG:
print(f" ✓ Updated {pkg} -> version {version}")
except Exception as e:
if DEBUG:
print(f" ⚠ Warning: Could not update {pkg}: {e}")
# Fix nginx-like include paths in configuration files
with step("Fixing include paths / nginx.conf"):
adjust_nginx_like_paths_in_tree(src)
with step("Customizing frontend components"):
inject_footer_link(src)
# Copy web root and configuration to /etc/angie
with step("Copying web root and configs to /etc/angie"):
Path("/var/www/html").mkdir(parents=True, exist_ok=True)
shutil.copytree(
src / "docker" / "rootfs" / "var" / "www" / "html",
"/var/www/html",
dirs_exist_ok=True,
)
shutil.copytree(
src / "docker" / "rootfs" / "etc" / "nginx",
"/etc/angie",
dirs_exist_ok=True,
)
# Remove development config file if present
devconf = Path("/etc/angie/conf.d/dev.conf")
if devconf.exists():
devconf.unlink()
# Copy logrotate configuration
shutil.copy2(
src / "docker" / "rootfs" / "etc" / "logrotate.d" / "nginx-proxy-manager",
"/etc/logrotate.d/nginx-proxy-manager",
)
# Create symlink to /etc/nginx if it doesn't exist
if not Path("/etc/nginx").exists():
os.symlink("/etc/angie", "/etc/nginx")
# Copy backend and global directories to /opt/npm
with step("Copying backend to /opt/npm"):
shutil.copytree(src / "backend", "/opt/npm", dirs_exist_ok=True)
Path("/opt/npm/frontend/images").mkdir(parents=True, exist_ok=True)
# Copy /global if it exists (git always has it)
global_src = src / "global"
if global_src.exists():
shutil.copytree(global_src, "/opt/npm/global", dirs_exist_ok=True)
print(f" ✓ Directory 'global' copied")
# Create SQLite database configuration if missing
with step("Creating SQLite config if missing"):
cfg = Path("/opt/npm/config/production.json")
if not cfg.exists():
write_file(
cfg,
json.dumps(
{
"database": {
"engine": "knex-native",
"knex": {
"client": "sqlite3",
"connection": {"filename": "/data/database.sqlite"},
},
}
},
indent=2,
),
)
# Build frontend application
_build_frontend(src / "frontend", Path("/opt/npm/frontend"))
# Install backend Node.js dependencies via yarn
with step("Installing backend dependencies (yarn)"):
os.chdir("/opt/npm")
run(["yarn", "install"])
# Fix ownership of NPM directories
with step("Normalizing directories ownership"):
run(["chown", "-R", "npm:npm", "/opt/npm", "/data"])
# Prepare and set permissions for IP ranges configuration
with step("Preparing include/ip_ranges.conf (owned by npm)"):
include_dir = Path("/etc/nginx/conf.d/include")
include_dir.mkdir(parents=True, exist_ok=True)
ipranges = include_dir / "ip_ranges.conf"
if not ipranges.exists():
write_file(ipranges, "# populated by NPM (IPv4 only)\n")
try:
run(["chown", "npm:npm", str(include_dir), str(ipranges)])
except Exception:
pass
os.chmod(ipranges, 0o664)
# Apply patches to NPM backend
patch_npm_backend_commands()
return version
def copy_tree_safe(src: Path, dst: Path) -> None:
dst.mkdir(parents=True, exist_ok=True)
for item in src.iterdir():
src_item = src / item.name
dst_item = dst / item.name
try:
if src_item.is_dir():
if dst_item.exists():
shutil.rmtree(dst_item)
shutil.copytree(src_item, dst_item)
else:
shutil.copy2(src_item, dst_item)
except FileNotFoundError:
if DEBUG:
print(f" ⊘ Skipped missing: {src_item.name}")
except Exception as e:
if DEBUG:
print(f" ⚠ Error copying {src_item.name}: {e}")
def deploy_npm_app_from_release(version: str | None) -> str:
"""
Deploy NPM from GitHub release tag.
For versions >= 2.13.0, automatically falls back to git source (missing /global in releases).
Args:
version (str | None): Release tag version (e.g., "2.13.1"). If None, fetches latest.
Returns:
str: Installed version string
"""
# Get latest version if not specified
if not version:
version = github_latest_release_tag(
"NginxProxyManager/nginx-proxy-manager", override=None
)
print(f"✓ Latest stable version: {version}")
if version_parsed < (2, 13, 0):
error(f"Version {version} is not supported. Minimum version: 2.13.0")
sys.exit(1)
# Check if version >= 2.13.0 - if so, use git instead (releases missing /global)
version_parsed = parse_version(version)
if version_parsed >= (2, 13, 0):
print(
f" Version {version} >= 2.13.0: using git source (release archive incomplete)"
)
return deploy_npm_app_from_git(f"refs/tags/v{version}")
# For versions < 2.13.0, download from release archive
url = f"https://codeload.github.com/NginxProxyManager/nginx-proxy-manager/tar.gz/refs/tags/v{version}"
tmp = Path(tempfile.mkdtemp(prefix="npm-angie-"))
src = download_extract_tar_gz(url, tmp)
with step(f"Preparing NPM app from release v{version}"):
Path("/opt/npm").mkdir(parents=True, exist_ok=True)
backend_src = src / "backend"
if backend_src.exists():
if DEBUG:
print(f" Unpacking backend contents to /opt/npm/")
try:
for item in backend_src.iterdir():
src_item = backend_src / item.name
dst_item = Path(f"/opt/npm/{item.name}")
if src_item.is_dir():
if dst_item.exists():
shutil.rmtree(dst_item)
copy_tree_safe(src_item, dst_item)
else:
shutil.copy2(src_item, dst_item)
if DEBUG:
print(f" ✓ Backend contents unpacked")
except Exception as e:
if DEBUG:
print(f" ⚠ Warning unpacking backend: {e}")
# 2. Kopiuj frontend/
frontend_src = src / "frontend"
frontend_dst = Path("/opt/npm/frontend")
if frontend_src.exists():
if frontend_dst.exists():
shutil.rmtree(frontend_dst)
try:
copy_tree_safe(frontend_src, frontend_dst)
if DEBUG:
print(f" ✓ Copied frontend")
except Exception as e:
if DEBUG:
print(f" ⚠ Warning copying frontend: {e}")
# 3. Kopiuj global/
global_src = src / "global"
global_dst = Path("/opt/npm/global")
if global_src.exists():
if global_dst.exists():
shutil.rmtree(global_dst)
try:
copy_tree_safe(global_src, global_dst)
if DEBUG:
print(f" ✓ Copied global")
except Exception as e:
if DEBUG:
print(f" ⚠ Warning copying global: {e}")
else:
# Create empty /global if missing
global_dst.mkdir(parents=True, exist_ok=True)
if DEBUG:
print(f" ⊘ Directory 'global' not in archive (created empty)")
# Set version numbers in package.json files
with step("Setting version numbers in package.json"):
for pkg_path in ["/opt/npm/package.json", "/opt/npm/frontend/package.json"]:
pj = Path(pkg_path)
if not pj.exists():
if DEBUG:
print(f"{pkg_path} not found, skipping")
continue
try:
data = json.loads(pj.read_text(encoding="utf-8"))
data["version"] = version
pj.write_text(
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
if DEBUG:
print(f" ✓ Updated {pkg_path} -> version {version}")
except Exception as e:
if DEBUG:
print(f" ⚠ Warning: Could not update {pkg_path}: {e}")
with step("Fixing include paths / nginx.conf"):
adjust_nginx_like_paths_in_tree(src)
with step("Customizing frontend components"):
inject_footer_link(src)
with step("Copying web root and configs to /etc/angie"):
Path("/var/www/html").mkdir(parents=True, exist_ok=True)
docker_rootfs = src / "docker" / "rootfs"
if (docker_rootfs / "var" / "www" / "html").exists():
try:
shutil.copytree(
docker_rootfs / "var" / "www" / "html",
"/var/www/html",
dirs_exist_ok=True,
)
except Exception as e:
if DEBUG:
print(f" ⚠ Warning copying web root: {e}")
if (docker_rootfs / "etc" / "nginx").exists():
try:
shutil.copytree(
docker_rootfs / "etc" / "nginx", "/etc/angie", dirs_exist_ok=True
)
except Exception as e:
if DEBUG:
print(f" ⚠ Warning copying nginx config: {e}")
# Build frontend application
_build_frontend(src / "frontend", Path("/opt/npm/frontend"))
# Install backend dependencies
with step("Installing backend dependencies (yarn)"):
os.chdir("/opt/npm")
run(["yarn", "install"])
# Fix ownership
with step("Normalizing directories ownership"):
run(["chown", "-R", "npm:npm", "/opt/npm", "/data"])
# Prepare IP ranges configuration
with step("Preparing include/ip_ranges.conf"):
include_dir = Path("/etc/nginx/conf.d/include")
include_dir.mkdir(parents=True, exist_ok=True)
ipranges = include_dir / "ip_ranges.conf"
if not ipranges.exists():
write_file(ipranges, "# populated by NPM (IPv4 only)\n")
# Apply patches
patch_npm_backend_commands()
return version
def strip_ipv6_listens(paths):
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():
with step("Fixing logrotate state-file permissions and helper"):
system_status = Path("/var/lib/logrotate/status")
if system_status.exists():
try:
run(["setfacl", "-m", "u:npm:rw", str(system_status)], check=False)
except FileNotFoundError:
try:
run(["chgrp", "npm", str(system_status)], check=False)
os.chmod(system_status, 0o664)
except Exception:
pass
state_dir = Path("/opt/npm/var")
state_dir.mkdir(parents=True, exist_ok=True)
state_file = state_dir / "logrotate.state"
if not state_file.exists():
state_file.touch()
os.chmod(state_file, 0o664)
try:
import pwd, grp
uid = pwd.getpwnam("npm").pw_uid
gid = grp.getgrnam("npm").gr_gid
os.chown(state_dir, uid, gid)
os.chown(state_file, uid, gid)
except Exception:
pass
helper = Path("/usr/local/bin/logrotate-npm")
helper_content = f"""#!/bin/sh
# Logrotate wrapper for npm user
exec /usr/sbin/logrotate -s {state_file} "$@"
"""
write_file(helper, helper_content, 0o755)
logrotate_dir = Path("/var/lib/logrotate")
if logrotate_dir.exists():
try:
run(["usermod", "-aG", "adm", "npm"], check=False)
run(["chgrp", "adm", str(logrotate_dir)], check=False)
os.chmod(logrotate_dir, 0o775)
except Exception as e:
print(f"⚠ Warning: could not fix {logrotate_dir} permissions: {e}")
def create_systemd_units(ipv6_enabled: bool):
with step("Creating and starting systemd services (angie, npm)"):
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",
]
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
)
write_file(Path("/etc/systemd/system/angie.service"), ANGIE_UNIT, 0o644)
run(["systemctl", "daemon-reload"])
run(["systemctl", "enable", "--now", "angie.service"])
run(["/usr/sbin/nginx", "-t"], check=False)
run(["systemctl", "enable", "--now", "npm.service"])
run(["angie", "-s", "reload"], check=False)
########### REPLACE CONFIGS ############
def update_config_file(
filepath, newcontent, owner="npm:npm", mode=0o644, description=None
):
filepath = Path(filepath)
backuppath = None
if filepath.exists():
timestamp = time.strftime("%Y%m%d-%H%M%S")
backuppath = filepath.parent / f"{filepath.name}.backup-{timestamp}"
if DEBUG:
print(f" Creating backup: {backuppath}")
shutil.copy2(filepath, backuppath)
filepath.parent.mkdir(parents=True, exist_ok=True)
write_file(filepath, newcontent, mode)
if DEBUG:
print(f" Written to: {filepath}")
if owner:
try:
run("chown", owner, str(filepath), check=False)
if DEBUG:
print(f" Owner set to: {owner}")
except Exception as e:
if DEBUG:
print(f" Warning: Could not set owner: {e}")
return backuppath
def update_npn_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/nginx/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():
"""
Update NPM listen template with HTTP/3 (QUIC) support for Angie.
"""
content = """# HTTP listening
# HTTP listening
listen 80;
{% if ipv6 -%}
listen [::]:80;
{% else -%}
#listen [::]:80;
{% endif %}
{% if certificate -%}
# HTTPS/TLS listening
# HTTP/3 (QUIC)
listen 443 quic;
{% if ipv6 -%}
listen [::]:443 quic;
{% endif %}
# HTTP/2 and HTTP/1.1 fallback - TCP port
listen 443 ssl;
{% if ipv6 -%}
listen [::]:443 ssl;
{% else -%}
#listen [::]:443 ssl;
{% endif %}
{% endif %}
server_name {{ domain_names | join: " " }};
{% if certificate -%}
# Enable HTTP/2 and HTTP/3 together
{% if http2_support == 1 or http2_support == true %}
http2 on;
http3 on;
http3_hq on;
{% else -%}
http2 off;
http3 off;
{% endif %}
# Advertise HTTP/3 availability to clients
add_header Alt-Svc 'h3=":443"; ma=86400' always;
{% endif %}
# Angie status for stats
status_zone {{ domain_names[0] | replace: "*.", "" | replace: ".", "_" }};
"""
with step("Updating NPM listen template with HTTP/3 support"):
return update_config_file(
filepath="/opt/npm/templates/_listen.conf",
newcontent=content,
owner="npm:npm",
mode=0o644,
)
def update_npm_proxy_host_template():
"""
Update /opt/npm/templates/proxy_host.conf with upstream keepalive configuration.
"""
content = """{% include "_header_comment.conf" %}
{% if enabled %}
#### BCKEND UPSTREAM ####
{% 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 %}
# Custom
include /data/nginx/custom/server_proxy[.]conf;
}
{% endif %}
"""
with step("Updating NPM proxy host template"):
return update_config_file(
filepath="/opt/npm/templates/proxy_host.conf",
newcontent=content,
owner="npm:npm",
mode=0o644,
)
def update_npm_location_template():
"""
Update /opt/npm/templates/_location.conf with status_zone monitoring.
"""
content = """ location {{ path }} {
{{ advanced_config }}
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(
filepath="/opt/npm/templates/_location.conf",
newcontent=content,
owner="npm:npm",
mode=0o644,
)
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;
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/nginx/conf.d/include/ssl-ciphers.conf;
status_zone npm_admin;
root {root_path};
access_log /dev/null;
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;
iif ($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 {%- endif %};
{% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} {%- if certificate %} ssl {%- endif %};
{%- include "_certificates_stream.conf" %}
proxy_pass {{ forwarding_host }}:{{ forwarding_port }};
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;
{% unless ipv6 -%} # {%- endunless -%} listen [::]:{{ incoming_port }} udp;
proxy_pass {{ forwarding_host }}:{{ forwarding_port }};
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."
)
is_branch_version = "-dev-" in npm_version if npm_version else False
npm_version_parsed = (0, 0, 0)
if npm_version and not is_branch_version:
clean_version = npm_version[1:] if npm_version.startswith("v") else npm_version
npm_version_parsed = parse_version(clean_version)
protocol = "https" if NPM_ADMIN_ENABLE_SSL else "http"
port = NPM_ADMIN_HTTPS_PORT if NPM_ADMIN_ENABLE_SSL else NPM_ADMIN_HTTP_PORT
npm_line = f"Nginx Proxy Manager: {protocol}://{ip}:{port}"
if is_branch_version:
npm_source = f"Source: branch ({npm_version})"
elif installed_from_branch:
npm_source = "Source: master branch (development)"
else:
npm_source = f"Source: release {npm_version}"
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
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
{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, update_mode, npm_version=None, installed_from_branch=False
):
ip, angie_v, node_v, yarn_v, npm_v = info
print("\n====================== SUMMARY ======================")
print(f"OS: {OSREL['PRETTY']} ({OSREL['ID']} {OSREL['VERSION_ID']})")
print(f"Mode: {'UPDATE' if update_mode else 'INSTALL'}")
if NPM_ADMIN_ENABLE_SSL:
print(f"NPM panel address: https://{ip}:{NPM_ADMIN_HTTPS_PORT}")
print(f" (HTTP→HTTPS: http://{ip}:{NPM_ADMIN_HTTP_PORT})")
else:
print(f"NPM panel address: http://{ip}:{NPM_ADMIN_HTTP_PORT}")
print(f"Angie & Prometheus stats: http://{ip}:82/console | http://{ip}:82/p8s")
print(f"Angie: v{angie_v}")
print(f"Node.js: v{node_v}")
print(f"Yarn: v{yarn_v}")
print(f"NPM: {npm_v}")
print(f"IPv6: {'ENABLED' if ipv6_enabled else 'DISABLED'}")
print(
"Paths: /opt/npm (app), /data (data), /etc/angie (conf), /var/log/angie (logs)"
)
print("Services: systemctl status angie.service / npm.service")
if not update_mode:
npm_version_parsed = parse_version(npm_version) if npm_version else (0, 0, 0)
is_branch_version = "-dev-" in npm_version if npm_version else False
print("Test config: /usr/sbin/angie -t")
print(f"\n FIRST LOGIN (branch/tag: {npm_version}):")
print(f" URL: https://{ip}:{NPM_ADMIN_HTTPS_PORT}")
print(f" Set admin user and password during first login")
print("==========================================================\n")
# ========== UPDATE-ONLY ==========
def update_only(
node_pkg: str,
node_version: str | None,
npm_version_override: str | None,
ipv6_enabled: bool,
):
apt_update_upgrade()
# Ensure npm exists before trying to install yarn
if not shutil.which("npm"):
ensure_minimum_nodejs(user_requested_version=node_pkg)
version = github_latest_release_tag(
"NginxProxyManager/nginx-proxy-manager", npm_version_override
)
url = f"https://codeload.github.com/NginxProxyManager/nginx-proxy-manager/tar.gz/refs/tags/v{version}"
tmp = Path(tempfile.mkdtemp(prefix="npm-update-"))
src = download_extract_tar_gz(url, tmp)
with step("Setting version in package.json (update)"):
for pkg_path in [
"package.json",
"backend/package.json",
"frontend/package.json",
]:
pj = src / pkg_path
if not pj.exists():
continue
try:
data = json.loads(pj.read_text(encoding="utf-8"))
data["version"] = version
pj.write_text(
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
if DEBUG:
print(f" ✓ Updated {pkg_path} -> version {version}")
except Exception as e:
if DEBUG:
print(f" ⚠ Warning: Could not update {pkg_path}: {e}")
# ========== BACKUP BEFORE UPDATE ==========
timestamp = time.strftime("%Y%m%d-%H%M%S")
backup_dir = Path(f"/data/backups/npm-backup-{timestamp}")
with step("Creating full backup before update"):
backup_dir.parent.mkdir(parents=True, exist_ok=True)
try:
if Path("/opt/npm").exists():
shutil.copytree("/opt/npm", backup_dir / "opt_npm", dirs_exist_ok=True)
if Path("/data/database.sqlite").exists():
shutil.copy2("/data/database.sqlite", backup_dir / "database.sqlite")
if Path("/data/letsencrypt").exists():
shutil.copytree(
"/data/letsencrypt", backup_dir / "letsencrypt", dirs_exist_ok=True
)
if Path("/data/nginx").exists():
shutil.copytree("/data/nginx", backup_dir / "nginx", dirs_exist_ok=True)
backup_info = {
"backup_date": timestamp,
"npm_version": "current",
"update_to_version": version,
"backup_path": str(backup_dir),
}
(backup_dir / "backup_info.json").write_text(
json.dumps(backup_info, indent=2)
)
backups = sorted(backup_dir.parent.glob("npm-backup-*"))
if len(backups) > 3:
for old_backup in backups[:-3]:
shutil.rmtree(old_backup, ignore_errors=True)
except Exception as e:
print(f"⚠ Warning: Backup failed: {e}")
print(" Continue update anyway? [y/N]: ", end="", flush=True)
response = input().strip().lower()
if response not in ["y", "yes"]:
print("Update cancelled.")
sys.exit(1)
print(f" Backup location: {backup_dir}")
backups = sorted(backup_dir.parent.glob("npm-backup-*"))
if len(backups) > 3:
print(f" Removed {len(backups) - 3} old backup(s)")
# ========== END BACKUP ==========
# Customize frontend components (inject installer link)
with step("Customizing frontend components"):
inject_footer_link(src)
_build_frontend(src / "frontend", Path("/opt/npm/frontend"))
with step("Updating backend without overwriting config/"):
backup_cfg = Path("/tmp/npm-config-backup")
if backup_cfg.exists():
shutil.rmtree(backup_cfg)
if Path("/opt/npm/config").exists():
shutil.copytree("/opt/npm/config", backup_cfg, dirs_exist_ok=True)
backend_src = src / "backend"
if backend_src.exists():
if DEBUG:
print(f" Unpacking backend contents (version < 2.13.0)")
for item in Path("/opt/npm").glob("*"):
if item.name in ("frontend", "config"):
continue
if item.is_dir():
shutil.rmtree(item)
else:
item.unlink()
for item in backend_src.iterdir():
src_item = backend_src / item.name
dst_item = Path(f"/opt/npm/{item.name}")
if src_item.is_dir():
if dst_item.exists():
shutil.rmtree(dst_item)
copy_tree_safe(src_item, dst_item)
else:
shutil.copy2(src_item, dst_item)
else:
if DEBUG:
print(f" Copying root contents (version >= 2.13.0)")
for item in Path("/opt/npm").glob("*"):
if item.name in ("frontend", "config"):
continue
if item.is_dir():
shutil.rmtree(item)
else:
item.unlink()
for item in src.iterdir():
src_item = src / item.name
dst_item = Path(f"/opt/npm/{item.name}")
if item.name in ("frontend", "config", "docker"):
continue
if src_item.is_dir():
if dst_item.exists():
shutil.rmtree(dst_item)
copy_tree_safe(src_item, dst_item)
else:
shutil.copy2(src_item, dst_item)
global_src = src / "global"
if global_src.exists():
global_dst = Path("/opt/npm/global")
if global_dst.exists():
shutil.rmtree(global_dst)
shutil.copytree(global_src, global_dst, dirs_exist_ok=True)
if DEBUG:
print(f" ✓ Directory 'global' copied")
else:
Path("/opt/npm/global").mkdir(parents=True, exist_ok=True)
if DEBUG:
print(f" ⊘ Directory 'global' not in archive (created empty)")
Path("/opt/npm/config").mkdir(parents=True, exist_ok=True)
if backup_cfg.exists():
# Przywróć wszystko z backup_cfg
for item in backup_cfg.iterdir():
src_cfg = backup_cfg / item.name
dst_cfg = Path(f"/opt/npm/config/{item.name}")
if src_cfg.is_dir():
if dst_cfg.exists():
shutil.rmtree(dst_cfg)
shutil.copytree(src_cfg, dst_cfg)
else:
shutil.copy2(src_cfg, dst_cfg)
shutil.rmtree(backup_cfg, ignore_errors=True)
with step("Installing backend dependencies after update"):
os.chdir("/opt/npm")
run(["yarn", "install"])
patch_npm_backend_commands()
create_systemd_units(ipv6_enabled=ipv6_enabled)
with step("Setting owners"):
run(["chown", "-R", "npm:npm", "/opt/npm"])
# Cleanup development configuration
with step("Cleaning up development configuration"):
dev_conf = Path("/etc/nginx/conf.d/dev.conf")
if dev_conf.exists():
try:
dev_conf.unlink()
print(f" ✓ Removed development config")
except Exception as e:
print(f" ⚠ Warning: Could not remove dev.conf: {e}")
certbot_venv = Path("/opt/certbot")
if certbot_venv.exists:
print(f"♻ Removing stale certbot venv for rebuild...")
shutil.rmtree(certbot_venv, ignore_errors=True)
setup_certbot_venv()
configure_letsencrypt()
with step("Restarting services after update"):
run(["systemctl", "restart", "angie.service"], check=False)
run(["systemctl", "restart", "npm.service"], check=False)
return version
def main():
global DEBUG
ensure_root()
parser = argparse.ArgumentParser(
description="Install/upgrade NPM on Angie (Debian 11 + / Ubuntu 20.04 +).",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"--nodejs-pkg",
default="nodejs",
help="APT Node.js package name (e.g. nodejs, nodejs-18).",
)
parser.add_argument(
"--node-version",
default=None,
help=f"Install Node.js from NodeSource repo (e.g. 'latest', '21', '22'). "
f"Maximum supported: v{MAX_NODEJS_VERSION}. Overrides --nodejs-pkg.",
)
parser.add_argument(
"--npm-version",
default=None,
help="Force NPM app version from release tag (e.g. 2.13.2). Default: last tag from git",
)
parser.add_argument(
"--motd",
choices=["yes", "no"],
default="yes",
help="Update MOTD after completion.",
)
parser.add_argument(
"--enable-ipv6",
action="store_true",
help="Do not strip IPv6 from configs/resolvers (keep IPv6).",
)
parser.add_argument(
"--update",
action="store_true",
help="Update mode: upgrade packages + rebuild frontend/backend without reconfiguring Angie.",
)
parser.add_argument(
"--branch",
type=str,
default=None,
metavar="BRANCH",
help="Install from specific git branch (e.g., master, dev, develop). ",
)
parser.add_argument(
"--debug", action="store_true", help="Show detailed logs and progress."
)
args = parser.parse_args()
DEBUG = args.debug
# Check memory and create swap if needed
memory_info = check_memory_and_create_swap()
# Determine if any main parameters were provided
main_params_provided = any([
args.npm_version,
args.branch,
args.update,
# args.node_version removed - it's just an environment setting
])
# ========== INTERACTIVE MODE ==========
if not main_params_provided:
print("\nNo installation parameters provided. Starting interactive mode...")
choices = interactive_install_mode()
args = apply_interactive_choices(args, choices)
print("\n" + "=" * 70)
print("INSTALLATION SUMMARY")
print("=" * 70)
if args.update:
print("Mode: UPDATE")
elif args.branch:
print(f"Mode: INSTALL from branch '{args.branch}'")
else:
print(f"Mode: INSTALL from release tag")
if args.npm_version:
print(f"Version: {args.npm_version}")
else:
print("Version: Latest stable")
print("=" * 70 + "\n")
confirm = input("Proceed with installation? [Y/n]: ").strip().lower()
if confirm and confirm not in ["y", "yes", ""]:
cleanup_swap()
print("Installation cancelled.")
sys.exit(0)
# ========== WRAP INSTALLATION ==========
try:
# Initialize variables to prevent UnboundLocalError
npm_app_version = None
installed_from_branch = False
# Display installation banner
print("\n================== NPM + ANGIE installer ==================")
print(f"Repository: https://gitea.linuxiarz.pl/gru/npm-angie-auto-install")
print(f"Script description: Auto-installer with Angie + Node.js auto-setup")
print(f"")
print(f"System Information:")
print(f" OS: {OSREL['PRETTY']}")
print(f" Distribution: {OSREL['ID']} {OSREL['VERSION_ID']}")
print(f" Codename: {OSREL.get('CODENAME', 'N/A')}")
print(f" Python: {sys.version.split()[0]}")
print(f"")
print(f"Installation Mode:")
print(f" Log Level: {'DEBUG (verbose)' if DEBUG else 'SIMPLE'}")
print(f" Min Node.js: v{MIN_NODEJS_VERSION}+")
print(f" Max Node.js: v{MAX_NODEJS_VERSION}")
print(f"")
print(f"Author: @linuxiarz.pl (Mateusz Gruszczyński)")
print("===========================================================\n")
# ========== UPDATE MODE ==========
if args.update:
installer_config = load_installer_config()
stored_ipv6 = installer_config.get("ipv6_enabled", args.enable_ipv6)
installed_from_branch = installer_config.get("installed_from_branch", False)
previous_branch = installer_config.get("branch", "master")
install_logrotate_for_data_logs()
fix_logrotate_permissions_and_wrapper()
if installed_from_branch:
print(f"Old installation: branch '{previous_branch}'")
with step(f"Updating NPM from branch: {previous_branch}"):
npm_app_version = deploy_npm_app_from_git(f"refs/heads/{previous_branch}")
print(f"✓ NPM updated to {npm_app_version} from branch {previous_branch}")
npm_version_parsed = parse_version(npm_app_version)
else:
print(f"✓ Old installation: release tag")
version = update_only(
node_pkg=args.nodejs_pkg,
node_version=args.node_version,
npm_version_override=args.npm_version,
ipv6_enabled=stored_ipv6 if "stored_ipv6" in locals() else args.enable_ipv6,
)
npm_app_version = version
npm_version_parsed = parse_version(npm_app_version)
comment_x_served_by_step()
set_file_ownership(["/etc/nginx/conf.d/include/ip_ranges.conf"], "npm:npm", 0o664)
update_ssl_ciphers_config()
update_npn_assets_config()
update_npm_admin_interface()
update_npm_proxy_host_template()
update_npm_location_template()
update_npm_listen_template()
update_npm_stream_template()
info = gather_versions(npm_app_version)
update_motd(
args.motd == "yes",
info,
ipv6_enabled=args.enable_ipv6,
npm_version=npm_app_version,
installed_from_branch=installed_from_branch,
)
save_installer_config({
"ipv6_enabled": args.enable_ipv6,
"node_version": args.node_version,
"npm_version": npm_app_version,
"installed_from_branch": installed_from_branch,
"branch": args.branch if installed_from_branch else None,
})
print_summary(
info,
args.enable_ipv6,
update_mode=True,
npm_version=npm_app_version,
installed_from_branch=installed_from_branch,
)
# ========== FRESH INSTALL ==========
else:
validate_supported_os()
apt_update_upgrade()
apt_purge([
"nginx", "openresty", "nodejs", "npm", "yarn",
"certbot", "rustc", "cargo"
])
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=args.enable_ipv6)
write_metrics_files()
ensure_minimum_nodejs(user_requested_version=args.node_version)
ensure_user_and_dirs()
create_sudoers_for_npm()
setup_certbot_venv()
configure_letsencrypt()
# ========== INSTALLATION ==========
if args.branch is not None:
# Install from branch
branch_name = args.branch
with step(f"Installing NPM from branch: {branch_name}"):
npm_app_version = deploy_npm_app_from_git(f"refs/heads/{branch_name}")
print(f"\n{'='*70}")
print(f"✓ NPM Installation Complete (from Branch)")
print(f"{'='*70}")
print(f"Source: Branch (development)")
print(f"Branch: {branch_name}")
print(f"NPM Version: {npm_app_version}")
print(f"{'='*70}\n")
installed_from_branch = True
elif args.npm_version is not None:
# Install specific version
version_parsed = parse_version(args.npm_version)
# Validate minimum version
if version_parsed < (2, 13, 0):
error(f"NPM version {args.npm_version} is not supported.")
print(f" Minimum supported version: 2.13.0")
print(f" For legacy versions, use npm_install_multiversion.py (not recommended)")
sys.exit(1)
# Install from git tag (all >= 2.13.0 use git)
with step(f"Installing NPM v{args.npm_version} from git tag"):
npm_app_version = deploy_npm_app_from_git(f"refs/tags/v{args.npm_version}")
print(f"\n{'='*70}")
print(f"✓ NPM Installation Complete (from Release Tag)")
print(f"{'='*70}")
print(f"Source: Release tag (stable)")
print(f"Requested: v{args.npm_version}")
print(f"Installed: {npm_app_version}")
print(f"{'='*70}\n")
installed_from_branch = False
else:
# Install latest stable
with step("Detecting latest stable release"):
latest_version = github_latest_release_tag(
"NginxProxyManager/nginx-proxy-manager",
override=None
)
print(f" Latest stable version: {latest_version}")
version_parsed = parse_version(latest_version)
# Validate minimum version (should not happen, but safety check)
if version_parsed < (2, 13, 0):
error(f"Latest version {latest_version} is below minimum (2.13.0)")
print(f" This should not happen - please report this issue")
sys.exit(1)
# Install from git tag
with step(f"Installing NPM v{latest_version} from git tag"):
npm_app_version = deploy_npm_app_from_git(f"refs/tags/v{latest_version}")
print(f"\n{'='*70}")
print(f"✓ NPM Installation Complete (latest stable)")
print(f"{'='*70}")
print(f"Source: Latest stable release (auto-detected)")
print(f"Installed: {npm_app_version}")
print(f"{'='*70}\n")
installed_from_branch = False
# Handle IPv6 stripping
if not args.enable_ipv6:
strip_ipv6_listens([Path("/etc/angie"), Path("/etc/nginx")])
else:
print("IPv6: leaving entries (skipped IPv6 cleanup).")
npm_version_parsed = parse_version(npm_app_version)
# Save installation configuration
save_installer_config({
"ipv6_enabled": args.enable_ipv6,
"node_version": args.node_version,
"npm_version": npm_app_version,
"installed_from_branch": installed_from_branch,
"branch": args.branch if installed_from_branch else None,
})
create_systemd_units(ipv6_enabled=args.enable_ipv6)
ensure_nginx_symlink()
install_logrotate_for_data_logs()
fix_logrotate_permissions_and_wrapper()
sync_backup_nginx_conf()
comment_x_served_by_step()
set_file_ownership(["/etc/nginx/conf.d/include/ip_ranges.conf"], "npm:npm", 0o664)
update_ssl_ciphers_config()
update_npn_assets_config()
update_npm_admin_interface()
update_npm_proxy_host_template()
update_npm_location_template()
update_npm_listen_template()
update_npm_stream_template()
# Restart services
with step("Restarting services after installation"):
run(["systemctl", "restart", "angie.service"], check=False)
run(["systemctl", "restart", "npm.service"], check=False)
info = gather_versions(npm_app_version)
update_motd(
args.motd == "yes",
info,
ipv6_enabled=args.enable_ipv6,
npm_version=npm_app_version,
installed_from_branch=installed_from_branch,
)
print_summary(
info,
args.enable_ipv6,
update_mode=False,
npm_version=npm_app_version,
installed_from_branch=installed_from_branch,
)
finally:
# Always cleanup swap at the end
cleanup_swap()
if __name__ == "__main__":
signal.signal(signal.SIGINT, lambda s, f: sys.exit(130))
main()