From 29fedde85d46ed4d8815a901140e73abe15fbf2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 23 Oct 2025 22:13:45 +0200 Subject: [PATCH] first commit --- README.md | 114 +++++++ npm_install.py | 897 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1011 insertions(+) create mode 100644 README.md create mode 100644 npm_install.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..291c49b --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# NPM + Angie Auto Installer + +Minimal, repeatable setup for **Nginx Proxy Manager (NPM)** on **Angie** (Debian 13). +The installer configures Angie, deploys NPM (frontend + backend), fixes common pitfalls (PID tests, logrotate) +--- + +## Repository +Base URL: **https://gitea.linuxiarz.pl/gru/npm-angie-auto-install** + +> On Gitea, the raw file URL usually looks like: +> `https://gitea.linuxiarz.pl/gru/npm-angie-auto-install/raw/branch/main/install.py` + +--- + +## Requirements +- Debian 13 (root privileges). +- `curl`, `python3`, `systemd`. +- Network access to fetch packages and Node/Yarn artifacts. +- ~2 GB RAM (recommended) or add 2 GB swap for safer frontend builds. + +--- + +## Quick Start (one-liner) +```bash +curl -fsSL https://gitea.linuxiarz.pl/gru/npm-angie-auto-install/raw/branch/master/npm_install.py -o install.py && sudo python3 install.py --dark-mode +``` + +> If you keep a patched version, replace `install.py` with your file name (e.g. `install_fixed.py`). + +--- + +## Manual Download & Run +```bash +# 1) Download +curl -L https://gitea.linuxiarz.pl/gru/npm-angie-auto-install/raw/branch/master/npm_install.py -o install.py + +# 2) Verify (optional) +python3 -m pyflakes install.py || true + +# 3) Run +sudo python3 install.py --dark-mode +``` + +--- + +## What the Installer Does +- Creates **/etc/nginx -> /etc/angie** symlink (idempotent). +- Installs **Angie** and config templates; provides an **nginx wrapper** that makes config tests safe: + - `nginx -t` automatically uses `-g "pid /tmp/angie-test.pid; error_log off;"` and strips other `-g` flags. +- Prepares **/run/angie/angie.pid** with sane permissions. +- Configures **logrotate** for `/data/logs/*.log` (daily, rotate 7, compress) and provides a **logrotate-npm** helper that uses a writable state file at `/opt/npm/var/logrotate.state`. +- Deploys NPM (frontend + backend) and for building the legacy frontend. + + +--- + +## Options +- IPv6 keep/strip (resolver & conf). +- Theme.Park stylesheet injection (optional). +- Update-only mode (rebuild without reconfiguring Angie). + +Check `--help` in the script for all flags. + +--- + +## Post-Install +```bash +# Status +systemctl status angie.service --no-pager +systemctl status npm.service --no-pager + +# NPM UI +# Default: http(s):// (see summary printed by installer) +``` + +--- + +## Troubleshooting +**1) Frontend build killed (SIGKILL) / high RAM usage** +- The script forces **Yarn Classic (1.22.x)** and reduces concurrency. +- If memory is tight, add swap: + ```bash + sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile + sudo mkswap /swapfile && sudo swapon /swapfile + ``` + +**2) Permission denied: `/var/lib/logrotate/status`** +- Use `/usr/local/bin/logrotate-npm` (created by the installer), which keeps its own writable state file. + +**3) Config test fails on PID** +- The **nginx wrapper** ensures `nginx -t` writes test PID to `/tmp`, not to `/run/angie/angie.pid`. + +**4) `/etc/nginx` not pointing to Angie** +- The installer ensures `/etc/nginx -> /etc/angie`. To fix manually: + ```bash + sudo mv /etc/nginx /etc/nginx.bak # if it is a dir/file + sudo ln -sfn /etc/angie /etc/nginx + ``` + +--- + +## Uninstall (manual) +```bash +sudo systemctl disable --now npm.service angie.service || true +sudo rm -f /usr/sbin/nginx /usr/local/bin/logrotate-npm || true +# restore backups if you created any; remove /opt/npm, configs, etc. with care +``` + +--- + +## License +MIT (unless your repo specifies otherwise). + +## Author linuxiarz.pl, Mateusz Gruszczyński \ No newline at end of file diff --git a/npm_install.py b/npm_install.py new file mode 100644 index 0000000..251ef33 --- /dev/null +++ b/npm_install.py @@ -0,0 +1,897 @@ +#!/usr/bin/env python3 +# install_npm_angie.py +import argparse, os, sys, json, shutil, subprocess, tarfile, tempfile, urllib.request, re, time, threading, signal +from pathlib import Path + +DEBUG = False + +# ========== UI / Spinner ========== + +class Spinner: + FRAMES = ["⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏"] + def __init__(self, text): + self.text = text + self._stop = threading.Event() + self._th = threading.Thread(target=self._spin, daemon=True) + + def _spin(self): + i = 0 + while not self._stop.is_set(): + frame = self.FRAMES[i % len(self.FRAMES)] + print(f"\r{frame} {self.text} ", end="", flush=True) + time.sleep(0.12) + i += 1 + + def start(self): + if not DEBUG: + self._th.start() + else: + print(f"• {self.text} ...") + + def stop_ok(self): + if not DEBUG: + self._stop.set() + self._th.join(timeout=0.2) + print(f"\r✔ {self.text}{' ' * 20}") + else: + print(f"✔ {self.text}") + + def stop_fail(self): + if not DEBUG: + self._stop.set() + self._th.join(timeout=0.2) + print(f"\r✖ {self.text}{' ' * 20}") + else: + print(f"✖ {self.text}") + +def step(text): + class _Ctx: + def __enter__(self_inner): + self_inner.spinner = Spinner(text) + self_inner.spinner.start() + return self_inner + def __exit__(self_inner, exc_type, exc, tb): + if exc is None: + self_inner.spinner.stop_ok() + else: + self_inner.spinner.stop_fail() + return _Ctx() + +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 apt_update_upgrade(): + with step("Updating package lists and system"): + run(["apt-get", "update", "-y"] if DEBUG else ["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_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 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 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) + top = t.getmembers()[0].name.split("/")[0] + os.unlink(tf_path) + return dest_dir / top + +def ensure_nginx_symlink(): + from pathlib import Path + target = Path("/etc/angie") + link = Path("/etc/nginx") + try: + if link.is_symlink() and link.resolve() == target: + print("Symlink /etc/nginx -> /etc/angie already in place") + 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 nginx in foreground +#daemon off; +pid /run/angie/angie.pid; +user root; + +worker_processes auto; +pcre_jit on; + +error_log /data/logs/fallback_error.log warn; + +include /etc/angie/modules/*.conf; + +# 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:30m max_size=192m; + proxy_cache_path /var/lib/angie/cache/private levels=1:2 keys_zone=private-cache:5m max_size=1024m; + + 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 + brotli off; + brotli_comp_level 6; + brotli_static on; + brotli_types *; + zstd on; + zstd_min_length 256; + zstd_comp_level 3; + more_clear_headers "Server"; + + # 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; +} + +stream { + # npm + include /data/nginx/stream/*.conf; + include /data/nginx/custom/stream[.]conf; +} +# npm +include /data/nginx/custom/root[.]conf; +""" + +ANGIE_MODULES_SNIPPET = """load_module /usr/lib/angie/modules/ngx_http_headers_more_filter_module.so; +load_module /usr/lib/angie/modules/ngx_http_brotli_filter_module.so; +load_module /usr/lib/angie/modules/ngx_http_brotli_static_module.so; +load_module /usr/lib/angie/modules/ngx_http_zstd_filter_module.so; +load_module /usr/lib/angie/modules/ngx_http_zstd_static_module.so; +""" + +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 +ExecStart=/usr/sbin/angie -c /etc/angie/angie.conf +ExecReload=/bin/sh -c "/bin/kill -s HUP $(/bin/cat /run/angie/angie.pid)" +ExecStop=/bin/sh -c "/bin/kill -s TERM $(/bin/cat /run/angie/angie.pid)" +Restart=on-failure +RestartSec=3s + +[Install] +WantedBy=multi-user.target +""" + +# ========== Angie ========== +def setup_angie(): + with step("Adding Angie repo and installing Angie packages"): + run(["apt-get", "install", "-y", "ca-certificates", "curl", "gnupg"]) + run(["curl", "-fsSL", "-o", "/etc/apt/trusted.gpg.d/angie-signing.gpg", + "https://angie.software/keys/angie-signing.gpg"]) + os_id = run_out(["bash","-lc",". /etc/os-release && echo \"$ID/$VERSION_ID $VERSION_CODENAME\""]).strip() + write_file(Path("/etc/apt/sources.list.d/angie.list"), + f"deb https://download.angie.software/angie/{os_id} main\n") + run(["apt-get", "update"]) + run(["apt-get", "install", "-y", "angie", "angie-module-headers-more", "angie-module-brotli", "angie-module-zstd"]) + + with step("Configuring modules and main Angie config"): + modules_dir = Path("/etc/nginx/modules") + modules_dir.mkdir(parents=True, exist_ok=True) + write_file(modules_dir / "angie-npm-modules.conf", ANGIE_MODULES_SNIPPET, 0o644) + write_file(Path("/etc/angie/angie.conf"), ANGIE_CONF_TEMPLATE, 0o644) + + if not Path("/usr/sbin/nginx").exists(): + write_file(Path("/usr/sbin/nginx"), "#!/bin/sh\nexec /usr/sbin/angie \"$@\"\n", 0o755) + + WRAP = """#!/bin/sh +# nginx compat -> angie; safe config test (-t) +# - remove all user-provided -g +# - force pid to /tmp during tests to avoid /run/angie perms +if printf ' %s ' "$@" | grep -q ' -t '; then + set -- "$@" + NEW_ARGS="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "-g" ]; then + shift 2 + continue + fi + NEW_ARGS="$NEW_ARGS \"${1}\"" + shift + done + # shellcheck disable=SC2086 + eval set -- $NEW_ARGS + exec /usr/sbin/angie -g "pid /tmp/angie-test.pid; error_log off;" "$@" +else + exec /usr/sbin/angie "$@" +fi +""" + write_file(Path("/usr/sbin/nginx"), WRAP, 0o755) + + if not Path("/etc/nginx").exists(): + os.symlink("/etc/angie", "/etc/nginx") + Path("/etc/nginx/conf.d/include").mkdir(parents=True, exist_ok=True) + + with step("Setting resolver (IPv4 only) and cache directories"): + nameservers = [] + for line in Path("/etc/resolv.conf").read_text().splitlines(): + if line.strip().startswith("nameserver"): + ns = line.split()[1].split("%")[0] + if ":" in ns: + continue + nameservers.append(ns) + write_file(Path("/etc/nginx/conf.d/include/resolvers.conf"), + f"resolver {' '.join(nameservers)} valid=10s;\n", 0o644) + 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 install_certbot_with_dns_plugins(): + with step("Installing certbot + DNS plugins"): + base = ["certbot"] + out = run_out(["bash","-lc","apt-cache search '^python3-certbot-dns-' | awk '{print $1}'"], check=False) or "" + dns_pkgs = [p for p in out.splitlines() if p.strip()] + run(["apt-get", "install", "-y"] + base + dns_pkgs) + +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/nologsn", "npm"]) + run(["bash","-lc","getent group angie >/dev/null 2>&1 || groupadd angie"]) + run(["bash","-lc","usermod -aG angie npm || true"]) + + 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/nginx/body" + ] + for d in dirs: + Path(d).mkdir(parents=True, exist_ok=True) + run(["bash","-lc","chgrp -h angie /run/angie 2>/dev/null || true"]) + 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) + run(["bash","-lc","chgrp -h angie /var/log/angie /var/log/angie/*.log 2>/dev/null || true"]) + run(["bash","-lc","chmod 775 /var/log/angie && chmod 664 /var/log/angie/*.log"]) + 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 adjust_nginx_like_paths_in_tree(root: Path): + for p in root.rglob("*.conf"): + try: + txt = p.read_text(encoding="utf-8") + except Exception: + continue + txt2 = txt.replace("include conf.d", "include /etc/nginx/conf.d") \ + .replace("include /etc/angie/conf.d", "include /etc/nginx/conf.d") + if txt2 != txt: + p.write_text(txt2, encoding="utf-8") + for cand in root.rglob("nginx.conf"): + try: + txt = cand.read_text(encoding="utf-8") + except Exception: + continue + txt = re.sub(r"^user\s+\S+.*", "user root;", txt, flags=re.M) + txt = re.sub(r"^pid\s+.*", "pid /run/angie/angie.pid;", txt, flags=re.M) + txt = txt.replace("daemon on;", "#daemon on;") + cand.write_text(txt, encoding="utf-8") + +def install_node_and_yarn(node_pkg: str): + apt_install([node_pkg, "yarnpkg"]) + if not Path("/usr/bin/yarn").exists() and Path("/usr/bin/yarnpkg").exists(): + os.symlink("/usr/bin/yarnpkg","/usr/bin/yarn") + +def _build_frontend(src_frontend: Path, dest_frontend: Path): + with step("Installing frontend dependencies (yarn)"): + os.environ["NODE_ENV"] = "development" + os.chdir(src_frontend) + run(["yarn", "cache", "clean", "--all"]) + run(["yarn", "install"]) + with step("Building frontend (yarn build)"): + env = os.environ.copy() + env["NODE_OPTIONS"] = "--openssl-legacy-provider" + run(["yarn", "build"], env=env) + with step("Copying frontend artifacts"): + shutil.copytree(src_frontend / "dist", dest_frontend, dirs_exist_ok=True) + if (src_frontend / "app-images").exists(): + shutil.copytree(src_frontend / "app-images", dest_frontend / "images", dirs_exist_ok=True) + +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'(?&1 | awk 'NR==1{print $2}'"], check=False).strip() + node_v = run_out(["bash","-lc","node -v | sed 's/^v//'"], check=False).strip() + yarn_v = run_out(["bash","-lc","yarn -v || yarnpkg -v"], check=False).strip() + return ip, angie_v, node_v, yarn_v, npm_app_version + +def update_motd(enabled: bool, info, ipv6_enabled: bool): + 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." + creds = "Default login: admin@example.com / changeme" + text = f""" +################################ NPM / ANGIE ################################ +Nginx Proxy Manager: http://{ip}:81 +Angie: v{angie_v} (conf: /etc/angie -> /etc/nginx, reload: angie -s reload) +Node.js: v{node_v} Yarn: v{yarn_v} +NPM app: v{npm_v} +Paths: app=/opt/npm data=/data cache=/var/lib/angie/cache +{ipv6_line} +{creds} +########################################################################### +""" + 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, dark_enabled, update_mode): + ip, angie_v, node_v, yarn_v, npm_v = info + print("\n====================== SUMMARY ======================") + print(f"Mode: {'UPDATE' if update_mode else 'INSTALL'}") + print(f"NPM panel address: http://{ip}:81") + print(f"Angie: v{angie_v} (unit: angie.service, PID: /run/angie/angie.pid)") + print(f"Node.js: v{node_v}") + print(f"Yarn: v{yarn_v}") + print(f"NPM (aplikacja): v{npm_v}") + print(f"IPv6: {'WŁĄCZONE (nie usuwano wpisów)' if ipv6_enabled else 'WYŁĄCZONE w konfigach'}") + print(f"Dark mode (TP): {'TAK' if dark_enabled else 'NIE'}") + print("Paths: /opt/npm (app), /data (data), /etc/angie (conf), /var/log/angie (logs)") + print("Services: systemctl status angie.service / npm.service") + print("Default login: Email: admin@example.com Password: changeme") + print("Test config: /usr/sbin/nginx -t (test PID: /tmp/angie-test.pid)") + print("==========================================================\n") + +# ========== UPDATE-ONLY ========== + +def update_only(node_pkg: str, npm_version_override: str | None, apply_dark: bool, dark_env: dict): + apt_update_upgrade() + install_node_and_yarn(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 in ["backend/package.json", "frontend/package.json"]: + pj = src / pkg + txt = pj.read_text(encoding="utf-8") + txt = re.sub(r'"version":\s*"0\.0\.0"', f'"version": "{version}"', txt) + pj.write_text(txt, encoding="utf-8") + + _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) + for item in Path("/opt/npm").glob("*"): + if item.name in ("frontend","config"): + continue + if item.is_dir(): + shutil.rmtree(item) + else: + item.unlink() + shutil.copytree(src / "backend", "/opt/npm", dirs_exist_ok=True) + Path("/opt/npm/config").mkdir(parents=True, exist_ok=True) + if backup_cfg.exists(): + shutil.copytree(backup_cfg, "/opt/npm/config", dirs_exist_ok=True) + shutil.rmtree(backup_cfg, ignore_errors=True) + + with step("Installing backend dependencies after update"): + os.chdir("/opt/npm") + run(["yarn", "install"]) + + with step("Setting owners"): + run(["chown","-R","npm:npm","/opt/npm"]) + + if apply_dark: + apply_dark_mode(**dark_env) + + with step("Restarting services after update"): + run(["systemctl","restart","angie.service"], check=False) + run(["systemctl","restart","npm.service"], check=False) + + return version + +# ========== DARK MODE ========== + +def apply_dark_mode(APP_FILEPATH="/opt/npm/frontend", + TP_DOMAIN=None, TP_COMMUNITY_THEME=None, TP_SCHEME=None, TP_THEME=None): + print('--------------------------------------') + print('| Nginx Proxy Manager theme.park Mod |') + print('--------------------------------------') + + if not Path(APP_FILEPATH).exists(): + if Path("/app/frontend").exists(): + APP_FILEPATH = "/app/frontend" + elif Path("/opt/nginx-proxy-manager/frontend").exists(): + APP_FILEPATH = "/opt/nginx-proxy-manager/frontend" + + if not TP_DOMAIN or TP_DOMAIN.strip() == "": + print("No domain set, defaulting to theme-park.dev") + TP_DOMAIN = "theme-park.dev" + if not TP_SCHEME or TP_SCHEME.strip() == "": + print("No scheme set, defaulting to https") + TP_SCHEME = "https" + THEME_TYPE = "community-theme-options" if (str(TP_COMMUNITY_THEME).lower() == "true") else "theme-options" + if not TP_THEME or TP_THEME.strip() == "": + print("No theme set, defaulting to organizr") + TP_THEME = "organizr" + + if "github.io" in TP_DOMAIN: + TP_DOMAIN = f"{TP_DOMAIN}/theme.park" + + print("Variables set:\n" + f"'APP_FILEPATH'={APP_FILEPATH}\n" + f"'TP_DOMAIN'={TP_DOMAIN}\n" + f"'TP_COMMUNITY_THEME'={TP_COMMUNITY_THEME}\n" + f"'TP_SCHEME'={TP_SCHEME}\n" + f"'TP_THEME'={TP_THEME}\n") + + base_href = f"{TP_SCHEME}://{TP_DOMAIN}/css/base/nginx-proxy-manager/nginx-proxy-manager-base.css" + theme_href = f"{TP_SCHEME}://{TP_DOMAIN}/css/{THEME_TYPE}/{TP_THEME}.css" + + with step("Wstrzykuję arkusze stylów Theme.Park do HTML"): + htmls = list(Path(APP_FILEPATH).rglob("*.html")) + for path in htmls: + html = path.read_text(encoding="utf-8") + if base_href not in html: + html = re.sub(r"", f" ", html, flags=re.I) + html = re.sub(r"", f" ", html, flags=re.I) + path.write_text(html, encoding="utf-8") + if DEBUG: + print(f"Patched: {path}") + +# ========== MAIN ========== +def main(): + global DEBUG + ensure_root() + parser = argparse.ArgumentParser( + description="Install/upgrade NPM on Angie (Debian 13) with step animation.", + 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("--npm-version", default=None, help="Force NPM app version (e.g. 2.12.6). Default: latest release.") + 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("--dark-mode", action="store_true", + help="Inject Theme.Park CSS into frontend (see TP_* vars).") + parser.add_argument("--tp-domain", default=os.environ.get("TP_DOMAIN", ""), + help="Theme.Park domain (e.g. theme-park.dev or *.github.io).") + parser.add_argument("--tp-community-theme", default=os.environ.get("TP_COMMUNITY_THEME", "false"), + help="true = community-theme-options; false = theme-options.") + parser.add_argument("--tp-scheme", default=os.environ.get("TP_SCHEME", "https"), + help="URL scheme (http/https).") + parser.add_argument("--tp-theme", default=os.environ.get("TP_THEME", "organizr"), + help="Theme.Park theme name (e.g. organizr, catppuccin).") + parser.add_argument("--debug", action="store_true", + help="Show detailed logs and progress.") + + args = parser.parse_args() + DEBUG = args.debug + + print("\n================== NPM + ANGIE installer ( https://gitea.linuxiarz.pl/gru/npm-angie-auto-install ) ==================") + print("Log mode:", "DEBUG" if DEBUG else "SIMPLE") + print("\n@linuxiarz.pl\n") + + if args.update: + install_logrotate_for_data_logs() + fix_logrotate_permissions_and_wrapper() + version = update_only( + node_pkg=args.nodejs_pkg, + npm_version_override=args.npm_version, + apply_dark=args.dark_mode, + dark_env=dict( + APP_FILEPATH="/opt/npm/frontend", + TP_DOMAIN=args.tp_domain, + TP_COMMUNITY_THEME=args.tp_community_theme, + TP_SCHEME=args.tp_scheme, + TP_THEME=args.tp_theme, + ) + ) + info = gather_versions(version) + update_motd(args.motd == "yes", info, ipv6_enabled=args.enable_ipv6) + print_summary(info, args.enable_ipv6, args.dark_mode, update_mode=True) + return + + apt_update_upgrade() + apt_purge(["nginx","openresty","nodejs","npm","yarn","certbot","rustc","cargo"]) + apt_install(["ca-certificates","curl","gnupg","openssl","apache2-utils","logrotate", + "python3","python3-venv","sqlite3","build-essential"]) + + setup_angie() + install_certbot_with_dns_plugins() + install_node_and_yarn(args.nodejs_pkg) + ensure_user_and_dirs() + npm_app_version = deploy_npm_app(args.npm_version) + + if not args.enable_ipv6: + strip_ipv6_listens([Path("/etc/angie"), Path("/etc/nginx")]) + else: + print("IPv6: leaving entries (skipped IPv6 cleanup).") + + if args.dark_mode: + apply_dark_mode(APP_FILEPATH="/opt/npm/frontend", + TP_DOMAIN=args.tp_domain, + TP_COMMUNITY_THEME=args.tp_community_theme, + TP_SCHEME=args.tp_scheme, + TP_THEME=args.tp_theme) + + create_systemd_units() + + ensure_nginx_symlink() + install_logrotate_for_data_logs() + fix_logrotate_permissions_and_wrapper() + 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) + print_summary(info, args.enable_ipv6, args.dark_mode, update_mode=False) + +if __name__ == "__main__": + signal.signal(signal.SIGINT, lambda s, f: sys.exit(130)) + main()