diff --git a/scripts/npm/README.md b/scripts/npm/README.md index 01c8a2a..06f270d 100644 --- a/scripts/npm/README.md +++ b/scripts/npm/README.md @@ -40,9 +40,36 @@ python3 scripts/npm/npm_api.py --npm piha|vps [opcje] - `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. + Rownowaznie: `set-cert --host-id 14 --cert-id 38`. Opcjonalne + `--ssl-forced`/`--no-ssl-forced` ustawia przy okazji wymuszanie HTTPS. +- `create-cert --domain ... [--timeout N] [--apply]` — zamawia certyfikat Let's Encrypt + (challenge HTTP-01). **Dry-run domyslnie**, realne zamowienie tylko z `--apply`. - `create-host --domain ... --forward-host ... --forward-port ... [opcje] [--apply]` — tworzy nowy proxy host. **Dry-run domyslnie**, realna zmiana tylko z `--apply`. +### create-cert — schemat payloadu (NPM 2.14.0) + +Wbrew starszym poradnikom `meta` **nie przyjmuje** `letsencrypt_email` ani +`letsencrypt_agree` — schemat ma `additionalProperties: false`, wiec takie pola daja +`400 data/meta must NOT have additional properties`. Zweryfikowane w `GET /api/schema` +oraz w zrodle kontenera (`/app/internal/certificate.js`). Faktyczny payload to: + +```json +{"provider": "letsencrypt", "domain_names": ["example.com"], "meta": {"dns_challenge": false}} +``` + +Certbot dostaje `--agree-tos` na sztywno, a `-m ` bierze **z konta uzytkownika NPM** +(`GET /api/users` -> `email`). Jesli konto nie ma emaila, NPM zwraca +`A valid email address must be set on your user account to use Let's Encrypt`. + +`POST /nginx/certificates` jest **synchroniczne** — w jednym requescie leci reload nginx, +certbot i drugi reload — dlatego ta komenda ma timeout `--timeout` (domyslnie 120 s) +zamiast globalnych 15 s. Po odpowiedzi skrypt i tak dopytuje +`GET /nginx/certificates/` az `expires_on` bedzie ustawione; jesli POST padnie na +timeoucie klienta, cert jest odszukiwany po domenach (certbot moze wciaz dzialac po +stronie serwera). Przy porazce challenge'u NPM kasuje wiersz certu i zwraca blad z +wyjsciem certbota — wtedy szukaj szczegolow w `docker logs npm` na docelowym node. + `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`), @@ -88,6 +115,19 @@ python3 scripts/npm/npm_api.py --npm vps create-host \ --apply ``` +### Cert dla narty27.kapala.org na npm@VPS + +```bash +# 1. dry-run +python3 scripts/npm/npm_api.py --npm vps create-cert --domain narty27.kapala.org + +# 2. zamow cert (HTTP-01; domena musi wskazywac A-rekordem na publiczny IP VPS) +python3 scripts/npm/npm_api.py --npm vps create-cert --domain narty27.kapala.org --apply + +# 3. podepnij pod host 14 + wymus HTTPS +python3 scripts/npm/npm_api.py --npm vps set-cert --host-id 14 --cert-id --ssl-forced --apply +``` + ## Obsluga bledow Bledy autentykacji, brakujace hosty/certy i bledy API zwracane sa z czytelnym diff --git a/scripts/npm/npm_api.py b/scripts/npm/npm_api.py index 2f8f6fd..0660a17 100644 --- a/scripts/npm/npm_api.py +++ b/scripts/npm/npm_api.py @@ -6,6 +6,7 @@ import argparse import json import os import sys +import time import urllib.error import urllib.parse import urllib.request @@ -14,6 +15,11 @@ from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent DEFAULT_ENV_FILE = SCRIPT_DIR / ".env" +DEFAULT_TIMEOUT = 15 +# NPM issues the Let's Encrypt cert synchronously inside POST /nginx/certificates +# (nginx reload -> certbot -> nginx reload), so that one call needs a long leash. +CERT_ISSUE_TIMEOUT = 120 + INSTANCES = { "piha": { "url_env": "NPM_PIHA_URL", @@ -36,6 +42,10 @@ class NpmApiError(Exception): pass +class NpmTimeoutError(NpmApiError): + """The request did not complete in time — the server may still be working on it.""" + + def load_env_file(path): values = {} if not path.exists(): @@ -72,8 +82,8 @@ class NpmClient: path = f"{path}?{urllib.parse.urlencode(params)}" return self._request("GET", path) - def post(self, path, body): - return self._request("POST", path, body) + def post(self, path, body, timeout=DEFAULT_TIMEOUT): + return self._request("POST", path, body, timeout=timeout) def put(self, path, body): return self._request("PUT", path, body) @@ -90,13 +100,22 @@ class NpmClient: 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 set_certificate(self, host_id, cert_id, ssl_forced=None): + body = {"certificate_id": cert_id} + if ssl_forced is not None: + body["ssl_forced"] = ssl_forced + return self.put(f"/nginx/proxy-hosts/{host_id}", body) def create_proxy_host(self, body): return self.post("/nginx/proxy-hosts", body) - def _request(self, method, path, body=None, auth=True): + def create_certificate(self, body, timeout=CERT_ISSUE_TIMEOUT): + return self.post("/nginx/certificates", body, timeout=timeout) + + def test_http_challenge(self, domains): + return self.post("/nginx/certificates/test-http", {"domains": domains}, timeout=60) + + def _request(self, method, path, body=None, auth=True, timeout=DEFAULT_TIMEOUT): 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) @@ -107,7 +126,7 @@ class NpmClient: self.authenticate() req.add_header("Authorization", f"Bearer {self.token}") try: - with urllib.request.urlopen(req, timeout=15) as resp: + with urllib.request.urlopen(req, timeout=timeout) as resp: raw = resp.read() return json.loads(raw) if raw else None except urllib.error.HTTPError as e: @@ -121,7 +140,11 @@ class NpmClient: f"{method} {path} -> HTTP {e.code}: {message or raw.decode(errors='replace')}" ) from e except urllib.error.URLError as e: + if isinstance(e.reason, TimeoutError): + raise NpmTimeoutError(f"{method} {path} -> timed out after {timeout}s") from e raise NpmApiError(f"{method} {path} -> connection failed: {e.reason}") from e + except TimeoutError as e: + raise NpmTimeoutError(f"{method} {path} -> timed out after {timeout}s") from e def print_table(headers, rows): @@ -191,22 +214,111 @@ def cmd_list_certs(client, args): print_table(["ID", "PROVIDER", "NICE_NAME", "DOMAINS", "EXPIRES_ON"], rows) +def resolve_id(positional, flag, name): + """set-cert accepts both `set-cert 14 42` and `set-cert --host-id 14 --cert-id 42`.""" + if positional is not None and flag is not None and positional != flag: + raise NpmApiError(f"conflicting values for {name}: {positional} (positional) vs {flag} (--{name})") + value = flag if flag is not None else positional + if value is None: + raise NpmApiError(f"missing {name} — pass it positionally or as --{name}") + return value + + def cmd_set_cert(client, args): - host = client.get_proxy_host(args.host_id) - cert = client.get_certificate(args.cert_id) + host_id = resolve_id(args.host_id_pos, args.host_id, "host-id") + cert_id = resolve_id(args.cert_id_pos, args.cert_id, "cert-id") + host = client.get_proxy_host(host_id) + cert = client.get_certificate(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 args.ssl_forced is not None: + print(f" ssl_forced: {host['ssl_forced']} -> {args.ssl_forced}") 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) + updated = client.set_certificate(host_id, cert_id, args.ssl_forced) print( f"\nOK — host #{updated['id']} now uses certificate_id " - f"{updated['certificate_id']} (NPM reloaded nginx automatically)." + f"{updated['certificate_id']} (ssl_forced={updated['ssl_forced']}); " + "NPM reloaded nginx automatically." ) +def build_cert_body(domains): + # Schema (GET /api/schema, POST /nginx/certificates on NPM 2.14.0): only + # provider / nice_name / domain_names / meta are accepted, and `meta` itself is + # additionalProperties:false. There is NO letsencrypt_email / letsencrypt_agree — + # certbot is always run with --agree-tos and -m . + return { + "provider": "letsencrypt", + "domain_names": domains, + "meta": {"dns_challenge": False}, + } + + +def find_cert_by_domains(client, domains, exclude_ids): + """Locate a cert NPM created for `domains` when the POST response was lost to a timeout.""" + wanted = sorted(domains) + matches = [ + c + for c in client.list_certificates() + if c["id"] not in exclude_ids and sorted(c.get("domain_names", [])) == wanted + ] + return max(matches, key=lambda c: c["id"]) if matches else None + + +def poll_until_issued(client, cert_id, deadline, interval=3): + """Wait for expires_on to appear on a cert row. Returns the cert, or None on timeout.""" + while True: + cert = client.get_certificate(cert_id) + if cert.get("expires_on"): + return cert + if time.monotonic() >= deadline: + return None + time.sleep(min(interval, max(0, deadline - time.monotonic()))) + + +def cmd_create_cert(client, args): + body = build_cert_body(args.domains) + print("Planned certificate request (HTTP-01, no DNS challenge):") + print(json.dumps(body, indent=2)) + if not args.apply: + print("\nDRY RUN — no changes made. Re-run with --apply to request the certificate.") + return + + existing_ids = {c["id"] for c in client.list_certificates()} + deadline = time.monotonic() + args.timeout + print(f"\nRequesting certificate from Let's Encrypt (timeout {args.timeout}s) ...") + + cert_id = None + try: + created = client.create_certificate(body, timeout=args.timeout) + cert_id = created["id"] + print(f"NPM accepted the request — certificate #{cert_id}") + except NpmTimeoutError: + # certbot keeps running server-side; find the row NPM inserted before the POST returned. + print("POST timed out client-side — certbot may still be running, looking for the cert row ...") + found = find_cert_by_domains(client, args.domains, existing_ids) + if found is None: + raise NpmApiError( + f"certificate request timed out after {args.timeout}s and no certificate for " + f"{', '.join(args.domains)} was created — check `docker logs npm` on the target node" + ) from None + cert_id = found["id"] + print(f"Found pending certificate #{cert_id}") + + cert = poll_until_issued(client, cert_id, deadline) + if cert is None: + raise NpmApiError( + f"certificate #{cert_id} was created but expires_on is still empty after {args.timeout}s " + "— the Let's Encrypt challenge has not completed; check `docker logs npm` on the target node" + ) + print(f"\nOK — certificate #{cert['id']} issued for {', '.join(cert['domain_names'])}") + print(f" expires_on: {cert['expires_on']}") + print(f"\nNext: npm_api.py --npm set-cert --host-id --cert-id {cert['id']} --ssl-forced --apply") + + 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 @@ -263,11 +375,27 @@ def parse_args(): 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("host_id_pos", type=int, nargs="?", metavar="host_id") + p_set_cert.add_argument("cert_id_pos", type=int, nargs="?", metavar="cert_id") + p_set_cert.add_argument("--host-id", type=int, default=None, help="proxy host ID (alternative to positional)") + p_set_cert.add_argument("--cert-id", type=int, default=None, help="certificate ID (alternative to positional)") + p_set_cert.add_argument( + "--ssl-forced", action=argparse.BooleanOptionalAction, default=None, help="also redirect HTTP to HTTPS" + ) 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_cert = sub.add_parser("create-cert", help="request a Let's Encrypt certificate (HTTP-01)") + p_create_cert.add_argument("--domain", action="append", required=True, dest="domains", help="domain name (repeatable)") + p_create_cert.add_argument( + "--timeout", + type=int, + default=CERT_ISSUE_TIMEOUT, + help=f"seconds to wait for issuance (default: {CERT_ISSUE_TIMEOUT})", + ) + p_create_cert.add_argument("--apply", action="store_true", help="perform the change (default: dry-run)") + p_create_cert.set_defaults(func=cmd_create_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)