From a5a052a3f18a4c9acff6b60a9490d14269609c0f Mon Sep 17 00:00:00 2001 From: Manesh Date: Mon, 13 Apr 2026 16:42:01 +0000 Subject: [PATCH] first commit --- .env | 6 + README.md | 2 + ReverseProxy/.env | 19 + .../fastapi_nginx_gen.cpython-310.pyc | Bin 0 -> 9121 bytes ReverseProxy/fastapi_nginx_gen copy.py | 435 ++++++++ ReverseProxy/fastapi_nginx_gen.py | 332 +++++++ ReverseProxy/requirements.txt | 3 + package-lock.json | 926 ++++++++++++++++++ package.json | 19 + routes/nginx.js | 292 ++++++ server.js | 41 + 11 files changed, 2075 insertions(+) create mode 100755 .env create mode 100644 README.md create mode 100755 ReverseProxy/.env create mode 100644 ReverseProxy/__pycache__/fastapi_nginx_gen.cpython-310.pyc create mode 100755 ReverseProxy/fastapi_nginx_gen copy.py create mode 100755 ReverseProxy/fastapi_nginx_gen.py create mode 100755 ReverseProxy/requirements.txt create mode 100755 package-lock.json create mode 100755 package.json create mode 100755 routes/nginx.js create mode 100755 server.js diff --git a/.env b/.env new file mode 100755 index 0000000..6409666 --- /dev/null +++ b/.env @@ -0,0 +1,6 @@ +STATIC_IP=147.93.40.215 +PARENT_PATH=/home/user/conf/web +PUBLIC_WEB_ROOT=/home/user/web +ERROR_LOG_ROOT=/var/log/apache2/domains +NGINX_RELOAD_CMD="systemctl reload nginx" +PORT=3013 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..09bf33e --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# Hestia-Nginx-Reverse-Proxy +# Hestia-Nginx-Reverse-Proxy diff --git a/ReverseProxy/.env b/ReverseProxy/.env new file mode 100755 index 0000000..9568ff3 --- /dev/null +++ b/ReverseProxy/.env @@ -0,0 +1,19 @@ +# Networking / defaults +STATIC_IP=147.93.40.215 +PARENT_PATH=/home/user/conf/web +PUBLIC_WEB_ROOT=/home/user/web +ERROR_LOG_ROOT=/var/log/apache2/domains + +# Binaries +SUDO_BIN=/usr/bin/sudo +NGINX_BIN=/usr/sbin/nginx +SYSTEMCTL_BIN=/bin/systemctl + +# API +API_KEY=change_me_to_a_long_random_value +DISABLE_AUTH=false + +# App bind +BIND_HOST=0.0.0.0 +BIND_PORT=9999 + diff --git a/ReverseProxy/__pycache__/fastapi_nginx_gen.cpython-310.pyc b/ReverseProxy/__pycache__/fastapi_nginx_gen.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..109e2738743a18d4b4c54c7151bad4c6c40a0699 GIT binary patch literal 9121 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 + +Run as root (recommended) or configure sudoers for nginx/systemctl. +""" + +import os +import re +import time +import pathlib +import subprocess +from typing import Optional, Dict, Any + +from fastapi import FastAPI, Body, HTTPException, Header, Depends +from fastapi.responses import JSONResponse +from pydantic import BaseModel, field_validator + +# ---------------------------- Config ---------------------------- + +STATIC_IP = os.getenv("STATIC_IP", "147.93.40.215") +PARENT_PATH = os.getenv("PARENT_PATH", "/home/user/conf/web") # // +PUBLIC_WEB_ROOT = os.getenv("PUBLIC_WEB_ROOT", "/home/user/web") # for /error alias +ERROR_LOG_ROOT = os.getenv("ERROR_LOG_ROOT", "/var/log/apache2/domains") + +# Binaries (absolute paths are safer under systemd) +SUDO_BIN = os.getenv("SUDO_BIN", "/usr/bin/sudo") +NGINX_BIN = os.getenv("NGINX_BIN", "/usr/sbin/nginx") +SYSTEMCTL_BIN = os.getenv("SYSTEMCTL_BIN", "/bin/systemctl") + +# Simple header auth +API_KEY = os.getenv("API_KEY", "") +DISABLE_AUTH = os.getenv("DISABLE_AUTH", "false").lower() in ("1", "true", "yes") + +# Safety: bound interface/port for uvicorn (when you use the run command below) +BIND_HOST = os.getenv("BIND_HOST", "127.0.0.1") # expose via reverse proxy if you like +BIND_PORT = int(os.getenv("BIND_PORT", "5055")) + +# ------------------------- App bootstrap ------------------------ + +app = FastAPI(title="Nginx Reverse Proxy Generator (FastAPI)") + + +# ------------------------- Validators --------------------------- + +_ipv4_re = re.compile(r"^(\d{1,3}\.){3}\d{1,3}$") +_domain_re = re.compile(r"^(?!-)(?:[a-zA-Z0-9-]{1,63}\.)+[a-zA-Z]{2,63}$") + + +def is_valid_ipv4(ip: str) -> bool: + if not _ipv4_re.match(ip): + return False + parts = ip.split(".") + try: + return all(0 <= int(p) <= 255 and str(int(p)) == p for p in parts) + except Exception: + return False + + +def is_valid_domain(d: str) -> bool: + return bool(_domain_re.match(d)) + + +def is_valid_port(p: int) -> bool: + return isinstance(p, int) and 1 <= p <= 65535 + + +def safe_domain(d: str) -> str: + """Reject path traversal and uppercases; ensure it's a normal FQDN.""" + if not is_valid_domain(d): + raise ValueError("Invalid domain") + return d.lower() + + +# --------------------------- Models ----------------------------- + +class GenRequest(BaseModel): + domain: str + port: int + ip: Optional[str] = None + dryRun: Optional[bool] = False + skipReload: Optional[bool] = False + + @field_validator("domain") + @classmethod + def _dom(cls, v: str) -> str: + if not is_valid_domain(v): + raise ValueError("Invalid domain") + return v + + @field_validator("port") + @classmethod + def _prt(cls, v: int) -> int: + if not is_valid_port(v): + raise ValueError("Invalid port") + return v + + @field_validator("ip") + @classmethod + def _ip(cls, v: Optional[str]) -> Optional[str]: + if v is None or str(v).strip() == "": + return None + if not is_valid_ipv4(v): + raise ValueError("Invalid ip") + return v + + +# ------------------------- Auth dependency ---------------------- + +def require_api_key(x_api_key: Optional[str] = Header(default=None)) -> None: + return + if DISABLE_AUTH: + return + if not API_KEY: + # If you forgot to set API_KEY but disabled auth is False + raise HTTPException(status_code=500, detail="Server misconfigured: API_KEY not set") + if x_api_key != API_KEY: + print(x_api_key,"!=",API_KEY) + raise HTTPException(status_code=401, detail="Invalid or missing API key") + + +# ----------------------- Templating funcs ----------------------- + +# def build_ssl(ip: str, domain: str, port: int) -> str: +# base = f"{PARENT_PATH}/{domain}" +# public = f"{PUBLIC_WEB_ROOT}/{domain}" +# return f"""server {{ +# listen {ip}:443 ssl; +# server_name {domain}; +# error_log {ERROR_LOG_ROOT}/{domain}.error.log error; + +# ssl_certificate {base}/ssl/{domain}.pem; +# ssl_certificate_key {base}/ssl/{domain}.key; + +# location / {{ +# proxy_pass http://127.0.0.1:{port}; +# proxy_http_version 1.1; +# proxy_set_header Upgrade $http_upgrade; +# proxy_set_header Connection 'upgrade'; +# proxy_set_header Host $host; +# proxy_cache_bypass $http_upgrade; + +# proxy_set_header X-Real-IP $remote_addr; +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# proxy_set_header X-Forwarded-Proto $scheme; +# }} + +# location ~ /\\.(?!well-known\\/|file) {{ +# deny all; +# return 404; +# }} + +# location /error/ {{ +# alias {public}/document_errors/; +# }} + +# disable_symlinks if_not_owner from={public}/public_html; + +# include {base}/nginx.ssl.conf_*; +# }} +# """.rstrip() + "\n" + + +# def build_nonssl(ip: str, domain: str, port: int) -> str: +# base = f"{PARENT_PATH}/{domain}" +# public = f"{PUBLIC_WEB_ROOT}/{domain}" +# return f"""server {{ +# listen {ip}:80; +# server_name {domain}; +# error_log {ERROR_LOG_ROOT}/{domain}.error.log error; + +# include {base}/nginx.forcessl.conf*; + +# location / {{ +# proxy_pass http://127.0.0.1:{port}; +# proxy_http_version 1.1; +# proxy_set_header Upgrade $http_upgrade; +# proxy_set_header Connection 'upgrade'; +# proxy_set_header Host $host; +# proxy_cache_bypass $http_upgrade; + +# proxy_set_header X-Real-IP $remote_addr; +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# proxy_set_header X-Forwarded-Proto $scheme; +# }} + +# location ~ /\\.(?!well-known\\/|file) {{ +# deny all; +# return 404; +# }} + +# location /error/ {{ +# alias {public}/document_errors/; +# }} + +# disable_symlinks if_not_owner from={public}/public_html; + +# include {base}/nginx.conf_*; +# }} +# """.rstrip() + "\n" + + +def build_ssl(ip: str, domain: str, port: int) -> str: + base = f"{PARENT_PATH}/{domain}" + public = f"{PUBLIC_WEB_ROOT}/{domain}" + return f"""server {{ + listen {ip}:443 ssl; + server_name {domain}; + + error_log {ERROR_LOG_ROOT}/{domain}.error.log error; + + ssl_certificate {base}/ssl/{domain}.pem; + ssl_certificate_key {base}/ssl/{domain}.key; + + ssl_stapling on; + ssl_stapling_verify on; + + # TLS 1.3 0-RTT protection + if ($anti_replay = 307) {{ return 307 https://$host$request_uri; }} + if ($anti_replay = 425) {{ return 425; }} + + include {base}/nginx.hsts.conf*; + + ################################################################# + # FORCE BROWSER TO UPGRADE HTTP REQUESTS TO HTTPS + ################################################################# + add_header Content-Security-Policy "upgrade-insecure-requests"; + + ################################################################# + # SECURITY + ################################################################# + location ~ /\\.(?!well-known\\/|file) {{ + deny all; + return 404; + }} + + ################################################################# + # MAIN REVERSE PROXY + ################################################################# + location / {{ + proxy_pass http://127.0.0.1:{port}; + + proxy_http_version 1.1; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_set_header Host $host; + + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_set_header X-NginX-Proxy true; + + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port 443; + + # convert upstream http redirects into https + proxy_redirect http:// https://; + + proxy_cache_bypass $http_upgrade; + + proxy_request_buffering off; + + proxy_connect_timeout 36000s; + proxy_read_timeout 36000s; + proxy_send_timeout 36000s; + send_timeout 36000s; + + client_max_body_size 10240m; + }} + + ################################################################# + + location /error/ {{ + alias {public}/document_errors/; + }} + + disable_symlinks if_not_owner from={public}/public_html; + + proxy_hide_header Upgrade; + + include {base}/nginx.ssl.conf_*; +}} +""".rstrip() + "\n" + + +def build_nonssl(ip: str, domain: str, port: int) -> str: + return f"""server {{ + listen {ip}:80; + server_name {domain}; + + return 301 https://$host$request_uri; +}} +""".rstrip() + "\n" + +# ----------------------- Shell helpers -------------------------- + +def run(cmd: list[str], timeout: int = 30) -> dict: + """Run a command and return {ok, rc, stdout, stderr} without raising.""" + try: + p = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout, + check=False, + text=True, + ) + return {"ok": p.returncode == 0, "rc": p.returncode, "stdout": p.stdout, "stderr": p.stderr} + except subprocess.TimeoutExpired as te: + return {"ok": False, "rc": -1, "stdout": te.stdout or "", "stderr": f"Timeout: {te}"} + except Exception as e: + return {"ok": False, "rc": -2, "stdout": "", "stderr": str(e)} + + +# --------------------------- Routes ----------------------------- + +@app.get("/healthz") +def healthz(): + return {"ok": True, "ip": STATIC_IP, "parent": PARENT_PATH} + + +@app.post("/api/nginx/app", dependencies=[Depends(require_api_key)]) +def create_or_update_vhost(payload: GenRequest = Body(...)) -> JSONResponse: + # Resolve values + ip = payload.ip or STATIC_IP + domain = safe_domain(payload.domain) + port = int(payload.port) + + # Render templates + ssl_txt = build_ssl(ip, domain, port) + nonssl_txt = build_nonssl(ip, domain, port) + + # Target paths + domain_dir = pathlib.Path(PARENT_PATH) / domain + ssl_file = domain_dir / "nginx.ssl.conf" + nonssl_file = domain_dir / "nginx.conf" + + if payload.dryRun: + return JSONResponse( + { + "ok": True, + "dryRun": True, + "files": { + str(nonssl_file): nonssl_txt, + str(ssl_file): ssl_txt, + }, + } + ) + + # Ensure directory exists + try: + domain_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to create {domain_dir}: {e}") + + # Check write access + if not os.access(domain_dir, os.W_OK | os.X_OK): + raise HTTPException( + status_code=403, + detail=f"No write access to {domain_dir}. Run as root or fix permissions.", + ) + + # Backup old files, if present + def backup_if_exists(p: pathlib.Path) -> Optional[pathlib.Path]: + try: + if p.is_file(): + bkp = p.with_suffix(p.suffix + f".bak-{int(time.time())}") + bkp.write_text(p.read_text(encoding="utf-8"), encoding="utf-8") + return bkp + except Exception: + pass + return None + + nonssl_bkp = backup_if_exists(nonssl_file) + ssl_bkp = backup_if_exists(ssl_file) + + # Write new files + try: + nonssl_file.write_text(nonssl_txt, encoding="utf-8") + ssl_file.write_text(ssl_txt, encoding="utf-8") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed writing files: {e}") + + # Validate Nginx config (nginx -t) + test = run([SUDO_BIN, NGINX_BIN, "-t"]) + if not test["ok"]: + # Restore backups + try: + if nonssl_bkp and nonssl_bkp.exists(): + nonssl_file.write_text(nonssl_bkp.read_text(encoding="utf-8"), encoding="utf-8") + if ssl_bkp and ssl_bkp.exists(): + ssl_file.write_text(ssl_bkp.read_text(encoding="utf-8"), encoding="utf-8") + except Exception: + pass + raise HTTPException( + status_code=500, + detail=f"nginx -t failed: rc={test['rc']} stderr={test['stderr'] or test['stdout']}", + ) + + # Reload Nginx (unless skipped) + reload_out: Dict[str, Any] = {"skipped": True} + if not payload.skipReload: + reload_out = run([SUDO_BIN, SYSTEMCTL_BIN, "reload", "nginx"]) + if not reload_out["ok"]: + # Config is valid, but reload failed — return error for visibility + raise HTTPException( + status_code=500, + detail=f"Failed to reload nginx: rc={reload_out['rc']} stderr={reload_out['stderr'] or reload_out['stdout']}", + ) + + return JSONResponse( + { + "ok": True, + "written": [str(nonssl_file), str(ssl_file)], + "test": test, + "reload": reload_out, + "render": {"nonssl": nonssl_txt, "ssl": ssl_txt}, + } + ) diff --git a/ReverseProxy/fastapi_nginx_gen.py b/ReverseProxy/fastapi_nginx_gen.py new file mode 100755 index 0000000..d700848 --- /dev/null +++ b/ReverseProxy/fastapi_nginx_gen.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +""" +FastAPI-based Nginx reverse-proxy generator. + +POST /api/nginx/app +Body: +{ + "domain": "ebayapp.data4autos.com", + "port": 3011, + "ip": "147.93.40.215", + "dryRun": false, + "skipReload": false +} + +Auth (required unless DISABLE_AUTH=true): + send header: X-API-Key: +""" + +import os +import re +import time +import pathlib +import subprocess +from typing import Optional, Dict, Any + +from fastapi import FastAPI, Body, HTTPException, Header, Depends +from fastapi.responses import JSONResponse +from pydantic import BaseModel, field_validator + +# ---------------------------- Config ---------------------------- + +STATIC_IP = os.getenv("STATIC_IP", "147.93.40.215") +PARENT_PATH = os.getenv("PARENT_PATH", "/home/user/conf/web") +PUBLIC_WEB_ROOT = os.getenv("PUBLIC_WEB_ROOT", "/home/user/web") +ERROR_LOG_ROOT = os.getenv("ERROR_LOG_ROOT", "/var/log/apache2/domains") + +SUDO_BIN = os.getenv("SUDO_BIN", "/usr/bin/sudo") +NGINX_BIN = os.getenv("NGINX_BIN", "/usr/sbin/nginx") +SYSTEMCTL_BIN = os.getenv("SYSTEMCTL_BIN", "/bin/systemctl") + +API_KEY = os.getenv("API_KEY", "") +DISABLE_AUTH = os.getenv("DISABLE_AUTH", "false").lower() in ("1", "true", "yes") + +BIND_HOST = os.getenv("BIND_HOST", "127.0.0.1") +BIND_PORT = int(os.getenv("BIND_PORT", "5055")) + +# ------------------------- App bootstrap ------------------------ + +app = FastAPI(title="Nginx Reverse Proxy Generator (FastAPI)") + +# ------------------------- Validators --------------------------- + +_ipv4_re = re.compile(r"^(\d{1,3}\.){3}\d{1,3}$") +_domain_re = re.compile(r"^(?!-)(?:[a-zA-Z0-9-]{1,63}\.)+[a-zA-Z]{2,63}$") + + +def is_valid_ipv4(ip: str) -> bool: + if not _ipv4_re.match(ip): + return False + parts = ip.split(".") + try: + return all(0 <= int(p) <= 255 and str(int(p)) == p for p in parts) + except Exception: + return False + + +def is_valid_domain(d: str) -> bool: + return bool(_domain_re.match(d)) + + +def is_valid_port(p: int) -> bool: + return isinstance(p, int) and 1 <= p <= 65535 + + +def safe_domain(d: str) -> str: + if not is_valid_domain(d): + raise ValueError("Invalid domain") + return d.lower() + +# --------------------------- Models ----------------------------- + +class GenRequest(BaseModel): + domain: str + port: int + ip: Optional[str] = None + dryRun: Optional[bool] = False + skipReload: Optional[bool] = False + + @field_validator("domain") + @classmethod + def _dom(cls, v: str) -> str: + if not is_valid_domain(v): + raise ValueError("Invalid domain") + return v + + @field_validator("port") + @classmethod + def _prt(cls, v: int) -> int: + if not is_valid_port(v): + raise ValueError("Invalid port") + return v + + @field_validator("ip") + @classmethod + def _ip(cls, v: Optional[str]) -> Optional[str]: + if v is None or str(v).strip() == "": + return None + if not is_valid_ipv4(v): + raise ValueError("Invalid ip") + return v + +# ------------------------- Auth dependency ---------------------- + +def require_api_key(x_api_key: Optional[str] = Header(default=None)) -> None: + return + if DISABLE_AUTH: + return + if not API_KEY: + raise HTTPException(status_code=500, detail="Server misconfigured: API_KEY not set") + if x_api_key != API_KEY: + print(x_api_key, "!=", API_KEY) + raise HTTPException(status_code=401, detail="Invalid or missing API key") + +# ----------------------- Templating funcs ----------------------- + +def build_ssl(ip: str, domain: str, port: int) -> str: + base = f"{PARENT_PATH}/{domain}" + public = f"{PUBLIC_WEB_ROOT}/{domain}" + return f"""server {{ + listen {ip}:443 ssl; + server_name {domain}; + + error_log {ERROR_LOG_ROOT}/{domain}.error.log error; + + ssl_certificate {base}/ssl/{domain}.pem; + ssl_certificate_key {base}/ssl/{domain}.key; + + ssl_stapling on; + ssl_stapling_verify on; + + # TLS 1.3 0-RTT protection + if ($anti_replay = 307) {{ return 307 https://$host$request_uri; }} + if ($anti_replay = 425) {{ return 425; }} + + include {base}/nginx.hsts.conf*; + + ################################################################# + # FORCE BROWSER TO UPGRADE HTTP REQUESTS TO HTTPS + ################################################################# + add_header Content-Security-Policy "upgrade-insecure-requests"; + + ################################################################# + # SECURITY + ################################################################# + location ~ /\\.(?!well-known\\/|file) {{ + deny all; + return 404; + }} + + ################################################################# + # MAIN REVERSE PROXY + ################################################################# + location / {{ + proxy_pass http://127.0.0.1:{port}; + + proxy_http_version 1.1; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_set_header Host $host; + + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_set_header X-NginX-Proxy true; + + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port 443; + + # convert upstream http redirects into https + proxy_redirect http:// https://; + + proxy_cache_bypass $http_upgrade; + + proxy_request_buffering off; + + proxy_connect_timeout 36000s; + proxy_read_timeout 36000s; + proxy_send_timeout 36000s; + send_timeout 36000s; + + client_max_body_size 10240m; + }} + + ################################################################# + + location /error/ {{ + alias {public}/document_errors/; + }} + + disable_symlinks if_not_owner from={public}/public_html; + + proxy_hide_header Upgrade; + + include {base}/nginx.ssl.conf_*; +}} +""".rstrip() + "\n" + + +def build_nonssl(ip: str, domain: str, port: int) -> str: + return f"""server {{ + listen {ip}:80; + server_name {domain}; + + return 301 https://$host$request_uri; +}} +""".rstrip() + "\n" + +# ----------------------- Shell helpers -------------------------- + +def run(cmd: list[str], timeout: int = 30) -> dict: + try: + p = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout, + check=False, + text=True, + ) + return {"ok": p.returncode == 0, "rc": p.returncode, "stdout": p.stdout, "stderr": p.stderr} + except subprocess.TimeoutExpired as te: + return {"ok": False, "rc": -1, "stdout": te.stdout or "", "stderr": f"Timeout: {te}"} + except Exception as e: + return {"ok": False, "rc": -2, "stdout": "", "stderr": str(e)} + +# --------------------------- Routes ----------------------------- + +@app.get("/healthz") +def healthz(): + return {"ok": True, "ip": STATIC_IP, "parent": PARENT_PATH} + + +@app.post("/api/nginx/app", dependencies=[Depends(require_api_key)]) +def create_or_update_vhost(payload: GenRequest = Body(...)) -> JSONResponse: + ip = payload.ip or STATIC_IP + domain = safe_domain(payload.domain) + port = int(payload.port) + + ssl_txt = build_ssl(ip, domain, port) + nonssl_txt = build_nonssl(ip, domain, port) + + domain_dir = pathlib.Path(PARENT_PATH) / domain + ssl_file = domain_dir / "nginx.ssl.conf" + nonssl_file = domain_dir / "nginx.conf" + + if payload.dryRun: + return JSONResponse( + { + "ok": True, + "dryRun": True, + "files": { + str(nonssl_file): nonssl_txt, + str(ssl_file): ssl_txt, + }, + } + ) + + try: + domain_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to create {domain_dir}: {e}") + + if not os.access(domain_dir, os.W_OK | os.X_OK): + raise HTTPException( + status_code=403, + detail=f"No write access to {domain_dir}. Run as root or fix permissions.", + ) + + def backup_if_exists(p: pathlib.Path) -> Optional[pathlib.Path]: + try: + if p.is_file(): + bkp = p.with_suffix(p.suffix + f".bak-{int(time.time())}") + bkp.write_text(p.read_text(encoding="utf-8"), encoding="utf-8") + return bkp + except Exception: + pass + return None + + nonssl_bkp = backup_if_exists(nonssl_file) + ssl_bkp = backup_if_exists(ssl_file) + + try: + nonssl_file.write_text(nonssl_txt, encoding="utf-8") + ssl_file.write_text(ssl_txt, encoding="utf-8") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed writing files: {e}") + + test = run([SUDO_BIN, NGINX_BIN, "-t"]) + if not test["ok"]: + try: + if nonssl_bkp and nonssl_bkp.exists(): + nonssl_file.write_text(nonssl_bkp.read_text(encoding="utf-8"), encoding="utf-8") + if ssl_bkp and ssl_bkp.exists(): + ssl_file.write_text(ssl_bkp.read_text(encoding="utf-8"), encoding="utf-8") + except Exception: + pass + raise HTTPException( + status_code=500, + detail=f"nginx -t failed: rc={test['rc']} stderr={test['stderr'] or test['stdout']}", + ) + + reload_out: Dict[str, Any] = {"skipped": True} + if not payload.skipReload: + reload_out = run([SUDO_BIN, SYSTEMCTL_BIN, "reload", "nginx"]) + if not reload_out["ok"]: + raise HTTPException( + status_code=500, + detail=f"Failed to reload nginx: rc={reload_out['rc']} stderr={reload_out['stderr'] or reload_out['stdout']}", + ) + + return JSONResponse( + { + "ok": True, + "written": [str(nonssl_file), str(ssl_file)], + "test": test, + "reload": reload_out, + "render": {"nonssl": nonssl_txt, "ssl": ssl_txt}, + } + ) \ No newline at end of file diff --git a/ReverseProxy/requirements.txt b/ReverseProxy/requirements.txt new file mode 100755 index 0000000..da958cb --- /dev/null +++ b/ReverseProxy/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +pydantic==2.9.2 diff --git a/package-lock.json b/package-lock.json new file mode 100755 index 0000000..10557c1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,926 @@ +{ + "name": "management", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "management", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "dotenv": "^17.2.2", + "express": "^5.1.0", + "morgan": "^1.10.1" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dotenv": { + "version": "17.2.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz", + "integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/morgan/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.7.0", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100755 index 0000000..745d5ea --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "management", + "version": "1.0.0", + "main": "server.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "node server.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "module", + "dependencies": { + "dotenv": "^17.2.2", + "express": "^5.1.0", + "morgan": "^1.10.1" + }, + "description": "" +} diff --git a/routes/nginx.js b/routes/nginx.js new file mode 100755 index 0000000..5d56a8b --- /dev/null +++ b/routes/nginx.js @@ -0,0 +1,292 @@ +// routes/nginx.js +import { Router } from "express"; +import { promises as fs } from "fs"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { + STATIC_IP, + PARENT_PATH, + PUBLIC_WEB_ROOT, + ERROR_LOG_ROOT, +} from "../server.js"; + +const router = Router(); + +// ---- absolute binaries (systemd PATH is tiny) ---- +const SUDO_BIN = process.env.SUDO_BIN || "/usr/bin/sudo"; +const NGINX_BIN = process.env.NGINX_BIN || "/usr/sbin/nginx"; +const SYSTEMCTL_BIN = process.env.SYSTEMCTL_BIN || "/bin/systemctl"; + +// ---------- helpers ---------- +const isValidIPv4 = (ip) => + /^(\d{1,3}\.){3}\d{1,3}$/.test(ip) && + ip.split(".").every((n) => { + const v = Number(n); + return v >= 0 && v <= 255 && String(v) === n; + }); + +const isValidDomain = (d) => + /^(?!-)(?:[a-zA-Z0-9-]{1,63}\.)+[a-zA-Z]{2,63}$/.test(d); + +const isValidPort = (p) => + Number.isInteger(Number(p)) && Number(p) >= 1 && Number(p) <= 65535; + +// very small sanitizer to avoid path traversal via domain +function safeDomain(d) { + if (!isValidDomain(d)) throw new Error("Invalid domain"); + return d.toLowerCase(); +} + +function buildSSL({ ip, domain, port }) { + const basePath = path.posix.join(PARENT_PATH, domain); + const publicPath = path.posix.join(PUBLIC_WEB_ROOT, domain); + + return `server { + listen ${ip}:443 ssl; + server_name ${domain}; + error_log ${path.posix.join(ERROR_LOG_ROOT, `${domain}.error.log`)} error; + + ssl_certificate ${path.posix.join(basePath, "ssl", `${domain}.pem`)}; + ssl_certificate_key ${path.posix.join(basePath, "ssl", `${domain}.key`)}; + + location / { + proxy_pass http://127.0.0.1:${port}; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location ~ /\\.(?!well-known\\/|file) { + deny all; + return 404; + } + + location /error/ { + alias ${path.posix.join(publicPath, "document_errors")}/; + } + + disable_symlinks if_not_owner from=${path.posix.join(publicPath, "public_html")}; + + include ${path.posix.join(basePath, "nginx.ssl.conf_*")}; +}`; +} + +function buildNonSSL({ ip, domain, port }) { + const basePath = path.posix.join(PARENT_PATH, domain); + const publicPath = path.posix.join(PUBLIC_WEB_ROOT, domain); + + return `server { + listen ${ip}:80; + server_name ${domain}; + error_log ${path.posix.join(ERROR_LOG_ROOT, `${domain}.error.log`)} error; + + include ${path.posix.join(basePath, "nginx.forcessl.conf*")}; + + location / { + proxy_pass http://127.0.0.1:${port}; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location ~ /\\.(?!well-known\\/|file) { + deny all; + return 404; + } + + location /error/ { + alias ${path.posix.join(publicPath, "document_errors")}/; + } + + disable_symlinks if_not_owner from=${path.posix.join(publicPath, "public_html")}; + + include ${path.posix.join(basePath, "nginx.conf_*")}; +}`; +} + +function sh(cmd, args = [], opts = {}) { + return new Promise((resolve, reject) => { + execFile(cmd, args, { timeout: 30_000, ...opts }, (err, stdout, stderr) => { + if (err) return reject({ err, stdout, stderr }); + resolve({ stdout, stderr }); + }); + }); +} + +async function exists(p) { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} + +// ---------- routes ---------- + +/** + * POST /api/nginx/app + * body: { domain: string, port: number|string, ip?: string, dryRun?: boolean, skipReload?: boolean } + */ +router.post("/nginx/app", async (req, res) => { + try { + const { domain, port, ip: ipIn, dryRun = false, skipReload = false } = req.body || {}; + + if (!domain || !port) { + return res.status(400).json({ ok: false, error: "domain and port are required" }); + } + + const ip = ipIn && String(ipIn).trim() ? String(ipIn).trim() : STATIC_IP; + + if (!isValidDomain(domain)) { + return res.status(400).json({ ok: false, error: "Invalid domain" }); + } + if (!isValidPort(port)) { + return res.status(400).json({ ok: false, error: "Invalid port" }); + } + if (!isValidIPv4(ip)) { + return res.status(400).json({ ok: false, error: "Invalid ip" }); + } + + const dom = safeDomain(domain); + const context = { ip, domain: dom, port: Number(port) }; + + const sslConf = buildSSL(context); + const nonsslConf = buildNonSSL(context); + + const domainDir = path.join(PARENT_PATH, dom); + const sslFile = path.join(domainDir, "nginx.ssl.conf"); + const nonsslFile = path.join(domainDir, "nginx.conf"); + + if (dryRun) { + return res.json({ + ok: true, + dryRun: true, + files: { + [nonsslFile]: nonsslConf, + [sslFile]: sslConf, + }, + }); + } + + // ensure directory exists + await fs.mkdir(domainDir, { recursive: true }); + + // preflight: can we write to the dir? + try { + await fs.access(domainDir, fs.constants.W_OK | fs.constants.X_OK); + } catch { + return res.status(403).json({ + ok: false, + error: `No write access to ${domainDir} for this process. Fix permissions or run with sudo.`, + }); + } + + // backup previous if exist (returns backup path or null) + async function backupIfExists(fpath) { + try { + const stat = await fs.stat(fpath); + if (stat.isFile()) { + const bkp = `${fpath}.bak-${Date.now()}`; + await fs.copyFile(fpath, bkp); + return bkp; + } + } catch {} + return null; + } + + const nonsslBkp = await backupIfExists(nonsslFile); + const sslBkp = await backupIfExists(sslFile); + + // write new files + await fs.writeFile(nonsslFile, nonsslConf, "utf8"); + await fs.writeFile(sslFile, sslConf, "utf8"); + + // nginx -t (as root, via sudo, with absolute paths) + let testOut; + try { + //testOut = await sh(SUDO_BIN, [NGINX_BIN, "-t"]); + } catch (e) { + // restore backups if test fails + try { + if (nonsslBkp && (await exists(nonsslBkp))) await fs.copyFile(nonsslBkp, nonsslFile); + if (sslBkp && (await exists(sslBkp))) await fs.copyFile(sslBkp, sslFile); + } catch {} + return res.status(500).json({ + ok: false, + error: "nginx -t failed", + details: e.stderr || e.stdout || String(e.err), + }); + } + + // // reload (always via sudo/systemctl) + // let reloadOut = { stdout: "", stderr: "" }; + // if (!skipReload) { + // try { + // reloadOut = await sh(SUDO_BIN, [SYSTEMCTL_BIN, "reload", "nginx"]); + // } catch (e) { + // return res.status(500).json({ + // ok: false, + // error: "Failed to reload nginx", + // details: e.stderr || e.stdout || String(e.err), + // test: testOut, + // }); + // } + // } + + + // reload (always via sudo) +let reloadOut = { stdout: "", stderr: "" }; + +if (!skipReload) { + try { + // 1) Test nginx configuration + const testOut = await sh("sudo", ["nginx", "-t"]); + if (testOut.stderr?.length) { + // nginx -t writes to stderr even on success, so check exit code if sh() gives it + console.log("nginx -t:", testOut.stderr); + } + + // 2) Reload nginx if the test passed + reloadOut = await sh("sudo", ["systemctl", "reload", "nginx"]); + + return res.json({ + ok: true, + test: testOut, + reload: reloadOut, + }); + } catch (e) { + return res.status(500).json({ + ok: false, + error: "Failed to validate or reload nginx", + details: e.stderr || e.stdout || String(e.err), + }); + } +} + + + return res.json({ + ok: true, + written: [nonsslFile, sslFile], + tested: testOut, + reloaded: skipReload ? "skipped" : reloadOut, + render: { nonssl: nonsslConf, ssl: sslConf }, + }); + } catch (err) { + return res.status(500).json({ ok: false, error: String(err && err.message ? err.message : err) }); + } +}); + +export default router; diff --git a/server.js b/server.js new file mode 100755 index 0000000..2d09420 --- /dev/null +++ b/server.js @@ -0,0 +1,41 @@ +// server.js +// Express app bootstrap for Nginx vhost generator/updater + + +// server.js +import express from "express"; +import morgan from "morgan"; +import nginxRouter from "./routes/nginx.js"; +import dotenv from "dotenv"; + +dotenv.config(); // loads .env if present + +// ---- Static defaults (env first, fallback second) ---- +export const STATIC_IP = process.env.STATIC_IP || "147.93.40.215"; +export const PARENT_PATH = process.env.PARENT_PATH || "/home/user/conf/web"; // // +export const PUBLIC_WEB_ROOT = process.env.PUBLIC_WEB_ROOT || "/home/user/web"; // for /error alias +export const ERROR_LOG_ROOT = process.env.ERROR_LOG_ROOT || "/var/log/apache2/domains"; + +// How to restart Nginx (can be "reload" or full "restart") +export const NGINX_RELOAD_CMD = process.env.NGINX_RELOAD_CMD || "systemctl reload nginx"; + +// ----------------------------------------------------- + +const app = express(); +app.use(express.json({ limit: "256kb" })); +app.use(morgan("tiny")); + +// health +app.get("/healthz", (_req, res) => res.json({ ok: true })); + +// api +app.use("/api", nginxRouter); + +// 404 +app.use((_req, res) => res.status(404).json({ ok: false, error: "Not found" })); + +const PORT = process.env.PORT || 5055; +app.listen(PORT, () => { + console.log(`Nginx generator API listening on port ${PORT}`); + console.log(`Defaults -> IP: ${STATIC_IP}, PARENT_PATH: ${PARENT_PATH}`); +});