feat(scripts): npm_api.py — CLI do zarzadzania npm PIHA+VPS przez REST API
token/list-hosts/list-certs/set-cert/create-host, dry-run domyslny dla zmian (--apply wymagane), stdlib urllib (zero-dep). Adresy npm@VPS przez Tailscale (100.95.58.48:81), NIE public IP. + docs/backlog.md: npm@VPS admin panel :81 publicznie osiagalny — brak override ograniczajacego bind do mesh/localhost. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
5db6ffa1f3
commit
5daae77e2f
|
|
@ -49,6 +49,23 @@ historia incydentów, out-of-band watchdog.
|
|||
|
||||
## Aktywne
|
||||
|
||||
### 🔴 npm@VPS panel admina :81 publicznie osiągalny z internetu
|
||||
|
||||
**Data**: 2026-07-10
|
||||
**Źródło**: sesja `scripts/npm/npm_api.py` (skrypt do zarządzania NPM przez REST API)
|
||||
**Problem**: `services/npm/docker-compose.yml` mapuje `81:81` bez ograniczenia do
|
||||
interfejsu — Docker bindem domyślnym wystawia to na `0.0.0.0`, czyli panel admina
|
||||
NPM@VPS jest osiągalny z publicznego IP (`135.181.153.108:81`), nie tylko przez
|
||||
Tailscale mesh (`100.95.58.48:81`). Panel admina (login+hasło, bez 2FA wymuszonego)
|
||||
nie powinien być publiczny. Brak `hosts/vps/runtime/npm/docker-compose.override.yml`
|
||||
ograniczającego bind.
|
||||
**Fix**: dodać override z bindem `127.0.0.1:81:81` (dostęp tylko przez Tailscale/SSH
|
||||
tunnel) albo `<tailscale_ip>:81:81`, zachowując `80`/`443` publiczne (to jest ich rola).
|
||||
Zweryfikować po zmianie, że `npm_api.py --npm vps` nadal łączy się przez
|
||||
`100.95.58.48:81`.
|
||||
|
||||
---
|
||||
|
||||
### Ghosty B WRÓCIŁY — hash-prefixed control-plane na VPS nieposprzątane
|
||||
|
||||
**Data**: 2026-07-06
|
||||
|
|
|
|||
104
scripts/npm/README.md
Normal file
104
scripts/npm/README.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# npm_api.py — sterowanie Nginx Proxy Manager przez REST API
|
||||
|
||||
CLI do zarzadzania dwoma instancjami NPM (PIHA, VPS) bez klikania w web UI.
|
||||
Uzywa tego samego REST API co panel admina (`/api/tokens`, `/api/nginx/*`).
|
||||
|
||||
## Dlaczego stdlib (`urllib`), nie `requests`
|
||||
|
||||
Skrypt uzywa wylacznie biblioteki standardowej Pythona (3.11+) — zero zaleznosci
|
||||
do zainstalowania. To narzedzie CLI uruchamiane doraznie z roznych miejsc (SATURN,
|
||||
laptop operatora), nie serwis z wlasnym obrazem Docker — `pip install` przed kazdym
|
||||
uzyciem byloby tarciem bez korzysci. Zapotrzebowanie na API jest proste (JWT bearer,
|
||||
JSON), wiec `urllib.request` w zupelnosci wystarcza.
|
||||
|
||||
## Konfiguracja
|
||||
|
||||
```bash
|
||||
cp scripts/npm/env.example scripts/npm/.env
|
||||
# wypelnij NPM_PIHA_USER/PASS i NPM_VPS_USER/PASS realnymi danymi
|
||||
```
|
||||
|
||||
`.env` jest gitignored (wzorzec `*.env` w `.gitignore`) — nigdy nie trafia do repo.
|
||||
|
||||
Adresy domyslne (nadpisywalne w `.env` przez `NPM_PIHA_URL`/`NPM_VPS_URL`):
|
||||
|
||||
| Instancja | URL | Uwaga |
|
||||
|---|---|---|
|
||||
| `piha` | `http://192.168.31.5:81` | LAN |
|
||||
| `vps` | `http://100.95.58.48:81` | **Tailscale mesh**, NIE publiczny IP `135.181.153.108:81` |
|
||||
|
||||
## Uzycie
|
||||
|
||||
```bash
|
||||
python3 scripts/npm/npm_api.py --npm piha|vps <komenda> [opcje]
|
||||
```
|
||||
|
||||
### Komendy
|
||||
|
||||
- `token` — loguje sie i pokazuje status + czas wygasniecia tokenu (test poswiadczen).
|
||||
- `list-hosts` — tabela proxy hostow (id, domeny, forward, cert, ssl_forced, enabled).
|
||||
- `list-certs` — tabela certyfikatow (id, provider, nice_name, domeny, expires_on).
|
||||
- `set-cert HOST_ID CERT_ID [--apply]` — podpina certyfikat pod host. **Dry-run domyslnie**,
|
||||
realna zmiana tylko z `--apply`. NPM przeladowuje nginx sam po PUT.
|
||||
- `create-host --domain ... --forward-host ... --forward-port ... [opcje] [--apply]` —
|
||||
tworzy nowy proxy host. **Dry-run domyslnie**, realna zmiana tylko z `--apply`.
|
||||
|
||||
`create-host` opcje: `--domain` (powtarzalne), `--forward-host`, `--forward-port`,
|
||||
`--forward-scheme http|https` (domyslnie `http`), `--cert-id` (domyslnie `0` = brak),
|
||||
`--ssl-forced`/`--no-ssl-forced` (domyslnie: `true` jesli podano `--cert-id`),
|
||||
`--http2-support`/`--no-http2-support`, `--block-exploits`/`--no-block-exploits`
|
||||
(domyslnie `true`), `--websocket`/`--no-websocket` (domyslnie `false`),
|
||||
`--access-list-id`, `--advanced-config`.
|
||||
|
||||
## Przyklady
|
||||
|
||||
### Faza 2 okit.pl — przepiecie certow proxy hostow na wildcard
|
||||
|
||||
Zamiast bulk-SQL + recznego `sed` na `.conf` (patrz
|
||||
`docs/sessions/2026-07-07-okit-wildcard.md`), API robi reload samo:
|
||||
|
||||
```bash
|
||||
# 1. znajdz ID wildcard certu
|
||||
python3 scripts/npm/npm_api.py --npm piha list-certs
|
||||
|
||||
# 2. znajdz ID hosta ktory ma dostac nowy cert
|
||||
python3 scripts/npm/npm_api.py --npm piha list-hosts
|
||||
|
||||
# 3. podejrzyj zmiane (dry-run)
|
||||
python3 scripts/npm/npm_api.py --npm piha set-cert 11 5
|
||||
|
||||
# 4. wykonaj
|
||||
python3 scripts/npm/npm_api.py --npm piha set-cert 11 5 --apply
|
||||
```
|
||||
|
||||
### Deploy Gokapi — vhost share.okit.pl na npm@VPS
|
||||
|
||||
```bash
|
||||
# dry-run
|
||||
python3 scripts/npm/npm_api.py --npm vps create-host \
|
||||
--domain share.okit.pl \
|
||||
--forward-host 127.0.0.1 --forward-port 8080 \
|
||||
--cert-id 5 --ssl-forced --http2-support --block-exploits
|
||||
|
||||
# apply
|
||||
python3 scripts/npm/npm_api.py --npm vps create-host \
|
||||
--domain share.okit.pl \
|
||||
--forward-host 127.0.0.1 --forward-port 8080 \
|
||||
--cert-id 5 --ssl-forced --http2-support --block-exploits \
|
||||
--apply
|
||||
```
|
||||
|
||||
## Obsluga bledow
|
||||
|
||||
Bledy autentykacji, brakujace hosty/certy i bledy API zwracane sa z czytelnym
|
||||
komunikatem na stderr (kod wyjscia 1) — np.:
|
||||
|
||||
```
|
||||
Error: PUT /nginx/proxy-hosts/11 -> HTTP 404: Proxy host not found
|
||||
```
|
||||
|
||||
## Referencje API
|
||||
|
||||
Oficjalny swagger jest niekompletny; zweryfikowano bezposrednio ze zrodla
|
||||
(`GET /api/schema` na dzialacej instancji NPM 2.14, OpenAPI 3.1) oraz
|
||||
`github.com/DenAV/nginx-proxy-manager-ansible`.
|
||||
15
scripts/npm/env.example
Normal file
15
scripts/npm/env.example
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# scripts/npm/.env — sekrety NPM. Skopiuj do .env (gitignored), NIGDY nie commituj .env.
|
||||
|
||||
# PIHA — LAN ingress, panel admina: http://192.168.31.5:81
|
||||
NPM_PIHA_USER=CHANGEME
|
||||
NPM_PIHA_PASS=CHANGEME
|
||||
|
||||
# VPS — public ingress. Panel admina osiagalny WYLACZNIE przez Tailscale mesh
|
||||
# (http://100.95.58.48:81). NIGDY nie uzywac publicznego IP (135.181.153.108:81) —
|
||||
# patrz docs/backlog.md, wpis o publicznej ekspozycji panelu admina.
|
||||
NPM_VPS_USER=CHANGEME
|
||||
NPM_VPS_PASS=CHANGEME
|
||||
|
||||
# Opcjonalnie: nadpisanie domyslnych adresow URL powyzej.
|
||||
# NPM_PIHA_URL=http://192.168.31.5:81
|
||||
# NPM_VPS_URL=http://100.95.58.48:81
|
||||
315
scripts/npm/npm_api.py
Normal file
315
scripts/npm/npm_api.py
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
#!/usr/bin/env python3
|
||||
# Manage Nginx Proxy Manager (PIHA + VPS) via its REST API instead of the web UI.
|
||||
# Zero-dep (stdlib only) — see README.md for rationale.
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
DEFAULT_ENV_FILE = SCRIPT_DIR / ".env"
|
||||
|
||||
INSTANCES = {
|
||||
"piha": {
|
||||
"url_env": "NPM_PIHA_URL",
|
||||
"url_default": "http://192.168.31.5:81",
|
||||
"user_env": "NPM_PIHA_USER",
|
||||
"pass_env": "NPM_PIHA_PASS",
|
||||
},
|
||||
"vps": {
|
||||
"url_env": "NPM_VPS_URL",
|
||||
# Tailscale mesh address of the admin panel — the public IP
|
||||
# (135.181.153.108:81) must never be used here, see docs/backlog.md.
|
||||
"url_default": "http://100.95.58.48:81",
|
||||
"user_env": "NPM_VPS_USER",
|
||||
"pass_env": "NPM_VPS_PASS",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class NpmApiError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def load_env_file(path):
|
||||
values = {}
|
||||
if not path.exists():
|
||||
return values
|
||||
for line in path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
values[key.strip()] = value.strip().strip('"').strip("'")
|
||||
return values
|
||||
|
||||
|
||||
class NpmClient:
|
||||
def __init__(self, base_url, identity, secret):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.identity = identity
|
||||
self.secret = secret
|
||||
self.token = None
|
||||
self.token_expires = None
|
||||
|
||||
def authenticate(self):
|
||||
result = self._request(
|
||||
"POST", "/tokens", {"identity": self.identity, "secret": self.secret}, auth=False
|
||||
)
|
||||
if "token" not in result:
|
||||
raise NpmApiError("login requires 2FA — not supported by this tool")
|
||||
self.token = result["token"]
|
||||
self.token_expires = result.get("expires")
|
||||
return self.token
|
||||
|
||||
def get(self, path, params=None):
|
||||
if params:
|
||||
path = f"{path}?{urllib.parse.urlencode(params)}"
|
||||
return self._request("GET", path)
|
||||
|
||||
def post(self, path, body):
|
||||
return self._request("POST", path, body)
|
||||
|
||||
def put(self, path, body):
|
||||
return self._request("PUT", path, body)
|
||||
|
||||
def list_proxy_hosts(self):
|
||||
return self.get("/nginx/proxy-hosts", {"expand": "certificate"})
|
||||
|
||||
def get_proxy_host(self, host_id):
|
||||
return self.get(f"/nginx/proxy-hosts/{host_id}")
|
||||
|
||||
def list_certificates(self):
|
||||
return self.get("/nginx/certificates")
|
||||
|
||||
def get_certificate(self, cert_id):
|
||||
return self.get(f"/nginx/certificates/{cert_id}")
|
||||
|
||||
def set_certificate(self, host_id, cert_id):
|
||||
return self.put(f"/nginx/proxy-hosts/{host_id}", {"certificate_id": cert_id})
|
||||
|
||||
def create_proxy_host(self, body):
|
||||
return self.post("/nginx/proxy-hosts", body)
|
||||
|
||||
def _request(self, method, path, body=None, auth=True):
|
||||
url = f"{self.base_url}/api{path}"
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("User-Agent", "homelab-codex-npm-api/1.0")
|
||||
if auth:
|
||||
if self.token is None:
|
||||
self.authenticate()
|
||||
req.add_header("Authorization", f"Bearer {self.token}")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw) if raw else None
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
message = None
|
||||
try:
|
||||
message = json.loads(raw).get("error", {}).get("message")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
raise NpmApiError(
|
||||
f"{method} {path} -> HTTP {e.code}: {message or raw.decode(errors='replace')}"
|
||||
) from e
|
||||
except urllib.error.URLError as e:
|
||||
raise NpmApiError(f"{method} {path} -> connection failed: {e.reason}") from e
|
||||
|
||||
|
||||
def print_table(headers, rows):
|
||||
if not rows:
|
||||
print("(no results)")
|
||||
return
|
||||
str_rows = [[str(cell) for cell in row] for row in rows]
|
||||
widths = [len(h) for h in headers]
|
||||
for row in str_rows:
|
||||
for i, cell in enumerate(row):
|
||||
widths[i] = max(widths[i], len(cell))
|
||||
|
||||
def fmt(row):
|
||||
return " ".join(cell.ljust(widths[i]) for i, cell in enumerate(row))
|
||||
|
||||
print(fmt(headers))
|
||||
print(fmt(["-" * w for w in widths]))
|
||||
for row in str_rows:
|
||||
print(fmt(row))
|
||||
|
||||
|
||||
def cert_label(cert):
|
||||
if not cert:
|
||||
return "-"
|
||||
name = cert.get("nice_name") or ",".join(cert.get("domain_names", []))
|
||||
return f"{cert['id']} ({name})"
|
||||
|
||||
|
||||
def cmd_token(client, args):
|
||||
client.authenticate()
|
||||
print(f"OK — authenticated as {client.identity} @ {client.base_url}")
|
||||
print(f"Token expires: {client.token_expires}")
|
||||
|
||||
|
||||
def cmd_list_hosts(client, args):
|
||||
hosts = client.list_proxy_hosts()
|
||||
rows = []
|
||||
for h in hosts:
|
||||
label = cert_label(h.get("certificate")) if h.get("certificate") else (
|
||||
str(h["certificate_id"]) if h.get("certificate_id") else "-"
|
||||
)
|
||||
rows.append(
|
||||
[
|
||||
h["id"],
|
||||
",".join(h["domain_names"]),
|
||||
f"{h['forward_scheme']}://{h['forward_host']}:{h['forward_port']}",
|
||||
label,
|
||||
"yes" if h["ssl_forced"] else "no",
|
||||
"yes" if h["enabled"] else "no",
|
||||
]
|
||||
)
|
||||
print_table(["ID", "DOMAINS", "FORWARD", "CERT", "SSL_FORCED", "ENABLED"], rows)
|
||||
|
||||
|
||||
def cmd_list_certs(client, args):
|
||||
certs = client.list_certificates()
|
||||
rows = [
|
||||
[
|
||||
c["id"],
|
||||
c.get("provider", "-"),
|
||||
c.get("nice_name", "-"),
|
||||
",".join(c.get("domain_names", [])),
|
||||
c.get("expires_on", "-"),
|
||||
]
|
||||
for c in certs
|
||||
]
|
||||
print_table(["ID", "PROVIDER", "NICE_NAME", "DOMAINS", "EXPIRES_ON"], rows)
|
||||
|
||||
|
||||
def cmd_set_cert(client, args):
|
||||
host = client.get_proxy_host(args.host_id)
|
||||
cert = client.get_certificate(args.cert_id)
|
||||
print(f"Host #{host['id']}: {', '.join(host['domain_names'])}")
|
||||
print(f" current certificate_id: {host.get('certificate_id') or 0}")
|
||||
print(f" new certificate_id: {cert_label(cert)}")
|
||||
if not args.apply:
|
||||
print("\nDRY RUN — no changes made. Re-run with --apply to perform this change.")
|
||||
return
|
||||
updated = client.set_certificate(args.host_id, args.cert_id)
|
||||
print(
|
||||
f"\nOK — host #{updated['id']} now uses certificate_id "
|
||||
f"{updated['certificate_id']} (NPM reloaded nginx automatically)."
|
||||
)
|
||||
|
||||
|
||||
def build_create_body(args):
|
||||
cert_id = args.cert_id or 0
|
||||
ssl_forced = args.ssl_forced if args.ssl_forced is not None else cert_id != 0
|
||||
body = {
|
||||
"domain_names": args.domains,
|
||||
"forward_scheme": args.forward_scheme,
|
||||
"forward_host": args.forward_host,
|
||||
"forward_port": args.forward_port,
|
||||
"certificate_id": cert_id,
|
||||
"ssl_forced": ssl_forced,
|
||||
"http2_support": args.http2_support if args.http2_support is not None else ssl_forced,
|
||||
"block_exploits": args.block_exploits if args.block_exploits is not None else True,
|
||||
"allow_websocket_upgrade": args.allow_websocket_upgrade
|
||||
if args.allow_websocket_upgrade is not None
|
||||
else False,
|
||||
"caching_enabled": False,
|
||||
"enabled": True,
|
||||
}
|
||||
if args.advanced_config:
|
||||
body["advanced_config"] = args.advanced_config
|
||||
if args.access_list_id:
|
||||
body["access_list_id"] = args.access_list_id
|
||||
return body
|
||||
|
||||
|
||||
def cmd_create_host(client, args):
|
||||
body = build_create_body(args)
|
||||
print("Planned proxy host:")
|
||||
print(json.dumps(body, indent=2))
|
||||
if not args.apply:
|
||||
print("\nDRY RUN — no changes made. Re-run with --apply to perform this change.")
|
||||
return
|
||||
created = client.create_proxy_host(body)
|
||||
print(f"\nOK — created proxy host #{created['id']}: {', '.join(created['domain_names'])}")
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="npm_api.py", description="Manage Nginx Proxy Manager (PIHA/VPS) via its REST API"
|
||||
)
|
||||
parser.add_argument("--npm", choices=sorted(INSTANCES), required=True, help="which NPM instance to target")
|
||||
parser.add_argument(
|
||||
"--env-file", type=Path, default=DEFAULT_ENV_FILE, help=f"path to credentials file (default: {DEFAULT_ENV_FILE})"
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_token = sub.add_parser("token", help="authenticate and print token info")
|
||||
p_token.set_defaults(func=cmd_token)
|
||||
|
||||
p_hosts = sub.add_parser("list-hosts", help="list proxy hosts")
|
||||
p_hosts.set_defaults(func=cmd_list_hosts)
|
||||
|
||||
p_certs = sub.add_parser("list-certs", help="list certificates")
|
||||
p_certs.set_defaults(func=cmd_list_certs)
|
||||
|
||||
p_set_cert = sub.add_parser("set-cert", help="attach a certificate to a proxy host")
|
||||
p_set_cert.add_argument("host_id", type=int)
|
||||
p_set_cert.add_argument("cert_id", type=int)
|
||||
p_set_cert.add_argument("--apply", action="store_true", help="perform the change (default: dry-run)")
|
||||
p_set_cert.set_defaults(func=cmd_set_cert)
|
||||
|
||||
p_create = sub.add_parser("create-host", help="create a new proxy host")
|
||||
p_create.add_argument("--domain", action="append", required=True, dest="domains", help="domain name (repeatable)")
|
||||
p_create.add_argument("--forward-host", required=True)
|
||||
p_create.add_argument("--forward-port", type=int, required=True)
|
||||
p_create.add_argument("--forward-scheme", choices=["http", "https"], default="http")
|
||||
p_create.add_argument("--cert-id", type=int, default=0, help="certificate ID (0 = none, default)")
|
||||
p_create.add_argument("--ssl-forced", action=argparse.BooleanOptionalAction, default=None)
|
||||
p_create.add_argument("--http2-support", action=argparse.BooleanOptionalAction, default=None)
|
||||
p_create.add_argument("--block-exploits", action=argparse.BooleanOptionalAction, default=None)
|
||||
p_create.add_argument("--websocket", action=argparse.BooleanOptionalAction, default=None, dest="allow_websocket_upgrade")
|
||||
p_create.add_argument("--access-list-id", type=int, default=0)
|
||||
p_create.add_argument("--advanced-config", default="")
|
||||
p_create.add_argument("--apply", action="store_true", help="perform the change (default: dry-run)")
|
||||
p_create.set_defaults(func=cmd_create_host)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
env = {**load_env_file(args.env_file), **os.environ}
|
||||
instance = INSTANCES[args.npm]
|
||||
base_url = env.get(instance["url_env"], instance["url_default"])
|
||||
|
||||
try:
|
||||
identity = env[instance["user_env"]]
|
||||
secret = env[instance["pass_env"]]
|
||||
except KeyError as e:
|
||||
print(
|
||||
f"Error: missing {e.args[0]} — set it in {args.env_file} (see env.example)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
client = NpmClient(base_url, identity, secret)
|
||||
try:
|
||||
args.func(client, args)
|
||||
except NpmApiError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Reference in a new issue