feat(npm): create-cert — zamawianie certow Let's Encrypt przez API
Brakowalo certu dla narty27.kapala.org (host 14 na npm@VPS) — 443 zwracalo
tlsv1 unrecognized name. Proby POST /api/nginx/certificates z meta
{letsencrypt_email, letsencrypt_agree} konczyly sie 400 "data/meta must NOT
have additional properties".
Zweryfikowane w GET /api/schema oraz w /app/internal/certificate.js kontenera
(NPM 2.14.0): meta ma additionalProperties:false i nie zna pol email/agree.
Certbot dostaje --agree-tos na sztywno, a -m <email> z konta uzytkownika NPM.
Poprawny payload to {provider, domain_names, meta:{dns_challenge:false}}.
- create-cert --domain (powtarzalne), HTTP-01, dry-run domyslnie + --apply
- POST /nginx/certificates jest synchroniczne (reload + certbot + reload),
wiec ma wlasny timeout 120 s zamiast globalnych 15 s; po nim polling
GET /nginx/certificates/<id> az do expires_on, z czytelnym bledem po czasie
- gdy POST padnie na timeoucie klienta, cert jest odszukiwany po domenach
(certbot moze wciaz pracowac po stronie serwera)
- set-cert: przyjmuje takze --host-id/--cert-id obok pozycyjnych i umie
ustawic --ssl-forced przy tej samej zmianie
- README: schemat payloadu, skad bierze sie email, przyklad dla narty27
Cert #38 wystawiony (expires 2026-11-01), podpiety pod host 14 z ssl_forced;
https://narty27.kapala.org/viz.html -> 200, http -> 301.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
756e8454a5
commit
3e42218868
|
|
@ -40,9 +40,36 @@ python3 scripts/npm/npm_api.py --npm piha|vps <komenda> [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 <email>` 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/<id>` 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 <ID> --ssl-forced --apply
|
||||
```
|
||||
|
||||
## Obsluga bledow
|
||||
|
||||
Bledy autentykacji, brakujace hosty/certy i bledy API zwracane sa z czytelnym
|
||||
|
|
|
|||
|
|
@ -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 <email of the owning NPM user>.
|
||||
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 <inst> set-cert --host-id <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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue