From e8b4d341749d04700739d69ad853985da3fd9005 Mon Sep 17 00:00:00 2001 From: Alex Van de Putte Date: Mon, 9 Nov 2026 12:19:06 -0500 Subject: [PATCH 01/13] ACME accounts can use your own CA: a directory URL and a CA certificate to trust A custom account names any ACME server, such as acme2certifier in front of Active Directory Certificate Services, step-ca or Vault. The CA certificate is added to the system's for that account's acme.sh runs only, and a CA_BUNDLE acme.sh saved into account.conf is taken out before every run, so it never replaces the trust for another CA. The directory is read first, so an untrusted, unreachable or wrong address fails at once with what to fix instead of acme.sh retrying for ten minutes. --- ham/acme.py | 125 ++++++++++++++++- ham/crud.py | 4 +- ham/dnsapi.py | 4 +- static/js/core.js | 4 + static/js/entities.js | 14 +- static/js/i18n/da.js | 10 +- static/js/i18n/de.js | 10 +- static/js/i18n/es.js | 10 +- static/js/i18n/fi.js | 10 +- static/js/i18n/fr.js | 10 +- static/js/i18n/it.js | 10 +- static/js/i18n/nb.js | 10 +- static/js/i18n/nl.js | 10 +- static/js/i18n/pt.js | 10 +- static/js/i18n/sv.js | 10 +- static/js/pages/certificates.js | 27 +++- tools/test-custom-ca.py | 231 ++++++++++++++++++++++++++++++++ tools/uitests/custom-ca.mjs | 130 ++++++++++++++++++ tools/uitests/run-all.sh | 2 +- 19 files changed, 605 insertions(+), 36 deletions(-) create mode 100644 tools/test-custom-ca.py create mode 100644 tools/uitests/custom-ca.mjs diff --git a/ham/acme.py b/ham/acme.py index c701e1a..780ba00 100644 --- a/ham/acme.py +++ b/ham/acme.py @@ -38,20 +38,131 @@ KEYLEN = {"ec-256": "ec-256", "ec-384": "ec-384", "rsa-2048": "2048", "rsa-4096": "4096"} +def _forget_ca_bundle(): + """Take a saved CA_BUNDLE out of acme.sh's account.conf. + + acme.sh saves --ca-bundle there after an issue, and reads account.conf + after its command line, so a bundle saved for a custom CA would go on + replacing the system's trust for every CA after it. Each run passes the + bundle it needs instead. + """ + conf = Path(ACME_HOME) / "account.conf" + try: + lines = conf.read_text().splitlines(keepends=True) + except OSError: + return + keep = [l for l in lines if not re.match(r"\s*(export\s+)?CA_BUNDLE=", l)] + if keep != lines: + conf.write_text("".join(keep)) + + def acme_run(args, env_extra=None): if not Path(ACME_SH).exists(): return 127, "acme.sh not found at %s -- run install.sh or set HAM_ACME_SH" % ACME_SH env = os.environ.copy() env.update(env_extra or {}) + _forget_ca_bundle() # --log makes acme.sh keep its own log in ACME_HOME, which is what the log # viewer reads back. Left at the default level: level 2 traces the DNS hook # calls, and those carry API credentials. return run([ACME_SH, "--home", str(ACME_HOME), "--log"] + args, env=env) +# -------------------------------------------------------------------------- +# a certificate authority of your own: any ACME server, by its directory URL + +SYSTEM_BUNDLES = ("/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/ssl/cert.pem") + + +def check_account(acc): + """What an ACME account refuses before it is stored. Raises ValueError with why.""" + if acc.get("ca") != "custom": + return + url = (acc.get("server") or "").strip() + if not re.match(r"^https?://[^\s/?#]+(/\S*)?$", url): + raise ValueError("a custom certificate authority needs the address of its ACME " + "directory, such as https://ca.example.lan/directory") + bundle = (acc.get("ca_bundle") or "").strip() + if bundle: + blocks = _pem_blocks(bundle) + if not blocks or any(kind != "CERTIFICATE" for kind, _ in blocks): + raise ValueError("the CA certificate to trust must be one or more PEM " + "certificates (-----BEGIN CERTIFICATE-----)") + for _, block in blocks: + rc, _ = _openssl_out(["x509", "-noout"], block) + if rc != 0: + raise ValueError("the CA certificate to trust is not a certificate " + "openssl can read") + + +def _bundle_file(acc): + """The system's trusted CAs plus the account's own, as a file for --ca-bundle. + + The system's are kept because the bundle is all curl trusts for the run, + and a DNS hook calls its provider's public API in the same run. + """ + import ssl + system = ssl.get_default_verify_paths().cafile + found = [p for p in ((system,) if system else ()) + SYSTEM_BUNDLES if Path(p).is_file()] + text = Path(found[0]).read_text() if found else "" + path = Path(ACME_HOME) / "ca-bundles" / ("%s.pem" % re.sub(r"[^A-Za-z0-9_-]", "_", + acc.get("id") or "account")) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text.rstrip("\n") + "\n" + acc["ca_bundle"].strip() + "\n") + return path + + +def check_directory(acc): + """Read a custom CA's ACME directory before acme.sh does. Returns why not, or "". + + acme.sh does not give up on a server it cannot reach or does not trust: + it asks again for a nonce every 20 seconds until run() times it out ten + minutes later, and says only that it timed out. + """ + if acc.get("ca") != "custom": + return "" + import json + import ssl + import urllib.error + import urllib.request + url = (acc.get("server") or "").strip() + ctx = ssl.create_default_context() + # Python 3.13 adds RFC 5280 strictness that curl does not, and internal + # CAs often lack an extension it wants; trust what acme.sh will trust. + ctx.verify_flags &= ~getattr(ssl, "VERIFY_X509_STRICT", 0) + if (acc.get("ca_bundle") or "").strip(): + ctx.load_verify_locations(cadata=acc["ca_bundle"]) + try: + with urllib.request.urlopen(url, timeout=15, context=ctx) as r: + body = json.loads(r.read(200000) or b"null") + except urllib.error.URLError as e: + if isinstance(e.reason, ssl.SSLCertVerificationError): + return ("this node does not trust the HTTPS certificate of %s (%s). Paste the " + "certificate of the CA that signed it into the account's CA certificate " + "to trust." % (url, e.reason.verify_message or e.reason)) + if isinstance(e, urllib.error.HTTPError): + return "%s answered HTTP %d: is that the address of the ACME directory?" % (url, e.code) + return "could not reach the ACME server at %s: %s" % (url, e.reason) + except (OSError, ValueError) as e: + return "could not read an ACME directory at %s: %s" % (url, e) + if not (isinstance(body, dict) and body.get("newNonce") and body.get("newAccount")): + return "%s is not an ACME directory: it has no newNonce and newAccount" % url + return "" + + +def server_args(acc): + """--server, and --ca-bundle when the account brings one, for acme.sh.""" + if acc.get("ca") != "custom": + return ["--server", CA_SERVERS.get(acc.get("ca", "letsencrypt"), "letsencrypt")] + args = ["--server", (acc.get("server") or "").strip()] + if (acc.get("ca_bundle") or "").strip(): + args += ["--ca-bundle", str(_bundle_file(acc))] + return args + + def ensure_account(acc): - args = ["--register-account", "-m", acc.get("email", ""), - "--server", CA_SERVERS.get(acc.get("ca", "letsencrypt"), "letsencrypt")] + args = ["--register-account", "-m", acc.get("email", "")] + server_args(acc) if acc.get("eab_kid"): args += ["--eab-kid", acc["eab_kid"], "--eab-hmac-key", acc.get("eab_hmac", "")] return acme_run(args) @@ -138,13 +249,21 @@ def _acme_issue(cfg, cert, force=False): if not doms: return {"ok": False, "error": "certificate has no domain names"} + try: + check_account(acc) + except ValueError as e: + return {"ok": False, "error": "ACME account %s: %s" % (acc.get("name") or "", e)} + why = check_directory(acc) + if why: + return {"ok": False, "error": why} + trace = [] rc, out = ensure_account(acc) trace.append(out) if rc != 0: return {"ok": False, "error": "ACME account registration failed", "log": "\n".join(trace)} - args = ["--issue", "--server", CA_SERVERS.get(acc.get("ca", "letsencrypt"), "letsencrypt")] + args = ["--issue"] + server_args(acc) for d in doms: args += ["-d", d] args += ["--keylength", KEYLEN.get(cert.get("key_type", "ec-256"), "ec-256")] diff --git a/ham/crud.py b/ham/crud.py index 69c6e3c..9c80df2 100644 --- a/ham/crud.py +++ b/ham/crud.py @@ -11,11 +11,13 @@ from .config import VALID_COLLECTIONS, load_config, merged, save_config from .util import _by_id, _sec, rule_cond_ids from .validate import check_pool, check_setting_types -from . import access, apply, errorpages, haproxy, oauth +from . import access, acme, apply, errorpages, haproxy, oauth def _check_item(sec, col, cfg, item): """What a collection refuses before storing it. Raises ValueError with why.""" + if sec == "acme" and col == "accounts": + acme.check_account(item) if sec != "haproxy": return if col == "backends": diff --git a/ham/dnsapi.py b/ham/dnsapi.py index 54f3e14..58aff2e 100644 --- a/ham/dnsapi.py +++ b/ham/dnsapi.py @@ -228,7 +228,9 @@ def pick(kind, spec, defaults, label_field="name"): try: account = pick("accounts", body.get("account"), - {"name": "", "email": "", "ca": "letsencrypt", "eab_kid": "", "eab_hmac": ""}) + {"name": "", "email": "", "ca": "letsencrypt", "server": "", + "ca_bundle": "", "eab_kid": "", "eab_hmac": ""}) + acme.check_account(account) challenge = pick("challenges", body.get("challenge"), {"name": "", "method": "http01", "dns_provider": "", "dns_credentials": ""}) except ValueError as e: diff --git a/static/js/core.js b/static/js/core.js index abb6839..e68329d 100644 --- a/static/js/core.js +++ b/static/js/core.js @@ -62,6 +62,10 @@ export function closeDlg(){$("#ovl").classList.remove("show");} $("#dlgclose").onclick=closeDlg; // the markup cannot call into a module $("#ovl").addEventListener("click",e=>{if(e.target.id==="ovl")closeDlg();}); document.addEventListener("keydown",e=>{if(e.key==="Escape")closeDlg();}); +/* "custom" is any ACME server of your own, by its directory URL: acme2certifier + in front of Active Directory Certificate Services, step-ca, Vault. */ +export const CA_CHOICES=["letsencrypt","letsencrypt_test","zerossl","buypass","google","custom"]; + export function btn(label,cls,fn){const b=document.createElement("button");b.className="btn "+(cls||"");b.textContent=label;b.onclick=fn;return b;} export function showText(title,text){const p=document.createElement("pre");p.textContent=text;openDlg(title,p,[btn(t("Close"),"",closeDlg)]);} diff --git a/static/js/entities.js b/static/js/entities.js index b7f6143..805ec54 100644 --- a/static/js/entities.js +++ b/static/js/entities.js @@ -1,7 +1,7 @@ -import { $, HEALTH_LABEL, PROXY_KEYWORD, PROXY_OPTIONS, api, btn, closeDlg, esc, fieldRow, list, lists, nameOf, openDlg, readForm, showText, trimHaproxy } from "./core.js"; +import { $, CA_CHOICES, HEALTH_LABEL, PROXY_KEYWORD, PROXY_OPTIONS, api, btn, closeDlg, esc, fieldRow, list, lists, nameOf, openDlg, readForm, showText, trimHaproxy } from "./core.js"; import { t } from "./i18n.js"; import { refreshStatus, route } from "./shell.js"; -import { certificateNotices, certExpiryCell, certLastCell, certStatusCell, dnsApiByHook, dnsApiOptions, dnsCredentialHelp, issueCert, loadAcmeHealth, loadCertStatus, loadDnsApis, openCertWizard, openDownload, openUpload, showCertLog } from "./pages/certificates.js"; +import { certificateNotices, certExpiryCell, certLastCell, certStatusCell, dnsApiByHook, dnsApiOptions, dnsCredentialHelp, issueCert, loadAcmeHealth, loadCertStatus, loadDnsApis, openCertWizard, openDownload, openUpload, showCertLog, customCaRows } from "./pages/certificates.js"; import { ACTION_LABEL, COND_LABEL, openRuleWizard } from "./pages/rules.js"; import { openConditionWizard } from "./pages/conditions.js"; import { state } from "./state.js"; @@ -255,12 +255,16 @@ export const E={ "acme/accounts":{title:"ACME Accounts",one:"ACME Account",add:"Add account", intro:"ACME accounts used to request certificates.", - cols:[["name","Name"],["email","E-mail"],["ca","CA"]], + cols:[["name","Name"],["email","E-mail"], + ["ca","CA",r=>r.ca==="custom"?"custom
"+esc(r.server||"")+"
":esc(r.ca||"letsencrypt")]], + editorExtra:customCaRows, fields:[ {k:"name",l:"Name",t:"text"}, {k:"email",l:"E-mail address",t:"text"}, - {k:"ca",l:"Certificate authority",t:"select",o:["letsencrypt","letsencrypt_test","zerossl","buypass","google"],d:"letsencrypt",h:"Use letsencrypt_test while setting things up to avoid rate limits"}, - {k:"eab_kid",l:"EAB key ID",t:"text",h:"Only for ZeroSSL / Google"}, + {k:"ca",l:"Certificate authority",t:"select",o:CA_CHOICES,d:"letsencrypt",h:"Use letsencrypt_test while setting things up to avoid rate limits"}, + {k:"server",l:"ACME directory URL",t:"text",h:"Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault"}, + {k:"ca_bundle",l:"CA certificate to trust",t:"textarea",h:"Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already"}, + {k:"eab_kid",l:"EAB key ID",t:"text",h:"Only for ZeroSSL, Google, or your own CA if it asks for one"}, {k:"eab_hmac",l:"EAB HMAC key",t:"password"}]}, "acme/challenges":{title:"Challenge Types",one:"Challenge Type",add:"Add challenge type", diff --git a/static/js/i18n/da.js b/static/js/i18n/da.js index 24584a9..97d4afe 100644 --- a/static/js/i18n/da.js +++ b/static/js/i18n/da.js @@ -501,6 +501,14 @@ export default { "this node is passive: the published names resolve to the virtual IP, which it does not hold, so it does not probe them; ask the node that holds it": "denne node er passiv: de udgivne navne peger på den virtuelle IP, som den ikke har, så den tjekker dem ikke; spørg noden, der har den", "a round of probes is already running; ask again in a few seconds": "en runde tjek kører allerede; spørg igen om nogle sekunder", "This token cannot be shown, so it is not filled in here: Replace the token, above, to get one that can be shown and copied.": "Dette token kan ikke vises, så det udfyldes ikke her: erstat tokenet ovenfor for at få et, der kan vises og kopieres.", +"ACME directory URL": "ACME-mappe-URL", +"Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault": "Din egen ACME-servers mappe, f.eks. https://ca.example.lan/directory -- for eksempel acme2certifier foran Active Directory Certificate Services, step-ca eller Vault", +"CA certificate to trust": "CA-certifikat der skal stoles på", +"Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already": "Valgfrit. PEM-certifikatet for den CA, der har underskrevet ACME-serverens eget HTTPS-certifikat, hvis denne node ikke allerede stoler på det", +"Only for ZeroSSL, Google, or your own CA if it asks for one": "Kun til ZeroSSL, Google eller din egen CA, hvis den beder om det", +"a custom certificate authority needs the address of its ACME directory, such as https://ca.example.lan/directory": "en egen certifikatmyndighed kræver adressen på dens ACME-mappe, f.eks. https://ca.example.lan/directory", +"the CA certificate to trust must be one or more PEM certificates (-----BEGIN CERTIFICATE-----)": "CA-certifikatet der skal stoles på skal være et eller flere PEM-certifikater (-----BEGIN CERTIFICATE-----)", +"the CA certificate to trust is not a certificate openssl can read": "CA-certifikatet der skal stoles på er ikke et certifikat, openssl kan læse", "Load from file": "Indlæs fra fil", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Læser en lokal .html-fil ind i feltet ovenfor. Intet uploades: siden gemmes sammen med konfigurationen.", "Value": "Værdi", @@ -551,7 +559,6 @@ export default { "Certificate authority": "Certifikatmyndighed", "Use letsencrypt_test while setting things up to avoid rate limits": "Brug letsencrypt_test under opsætningen for at undgå begrænsninger", "EAB key ID": "EAB-nøgle-ID", -"Only for ZeroSSL / Google": "Kun for ZeroSSL / Google", "EAB HMAC key": "EAB-HMAC-nøgle", "Challenge Types": "Challenge-typer", "Challenge Type": "Challenge-type", @@ -699,7 +706,6 @@ export default { "New account name": "Navn på ny konto", "Where the CA sends expiry warnings": "Hvor CA'en sender udløbsadvarsler hen", "letsencrypt_test issues untrusted certificates but has no rate limits -- ideal while setting things up": "letsencrypt_test udsteder ikke-betroede certifikater, men har ingen begrænsninger – ideelt under opsætningen", -"Only for ZeroSSL and Google": "Kun for ZeroSSL og Google", "Challenge type": "Challenge-type", "New challenge name": "Navn på ny challenge-type", "Validation method": "Valideringsmetode", diff --git a/static/js/i18n/de.js b/static/js/i18n/de.js index 64a173b..bf0f42e 100644 --- a/static/js/i18n/de.js +++ b/static/js/i18n/de.js @@ -501,6 +501,14 @@ export default { "this node is passive: the published names resolve to the virtual IP, which it does not hold, so it does not probe them; ask the node that holds it": "dieser Knoten ist passiv: die veröffentlichten Namen zeigen auf die virtuelle IP, die er nicht hält, daher prüft er sie nicht; den Knoten fragen, der sie hält", "a round of probes is already running; ask again in a few seconds": "eine Prüfrunde läuft bereits; in ein paar Sekunden erneut fragen", "This token cannot be shown, so it is not filled in here: Replace the token, above, to get one that can be shown and copied.": "Dieses Token kann nicht angezeigt werden und wird hier daher nicht eingesetzt: oben Token ersetzen, um eines zu bekommen, das angezeigt und kopiert werden kann.", +"ACME directory URL": "ACME-Verzeichnis-URL", +"Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault": "Das Verzeichnis Ihres eigenen ACME-Servers, etwa https://ca.example.lan/directory -- zum Beispiel acme2certifier vor den Active Directory Certificate Services, step-ca oder Vault", +"CA certificate to trust": "Zu vertrauendes CA-Zertifikat", +"Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already": "Optional. Das PEM-Zertifikat der Zertifizierungsstelle, die das HTTPS-Zertifikat des ACME-Servers signiert hat, falls dieser Knoten ihr nicht schon vertraut", +"Only for ZeroSSL, Google, or your own CA if it asks for one": "Nur für ZeroSSL, Google oder Ihre eigene Zertifizierungsstelle, falls sie danach fragt", +"a custom certificate authority needs the address of its ACME directory, such as https://ca.example.lan/directory": "eine eigene Zertifizierungsstelle braucht die Adresse ihres ACME-Verzeichnisses, etwa https://ca.example.lan/directory", +"the CA certificate to trust must be one or more PEM certificates (-----BEGIN CERTIFICATE-----)": "das zu vertrauende CA-Zertifikat muss aus einem oder mehreren PEM-Zertifikaten bestehen (-----BEGIN CERTIFICATE-----)", +"the CA certificate to trust is not a certificate openssl can read": "das zu vertrauende CA-Zertifikat ist kein Zertifikat, das openssl lesen kann", "Load from file": "Aus Datei laden", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Liest eine lokale .html-Datei in das Feld oben. Nichts wird hochgeladen: die Seite wird mit der Konfiguration gespeichert.", "Value": "Wert", @@ -551,7 +559,6 @@ export default { "Certificate authority": "Zertifizierungsstelle", "Use letsencrypt_test while setting things up to avoid rate limits": "Verwenden Sie beim Einrichten letsencrypt_test, um Ratenbegrenzungen zu vermeiden", "EAB key ID": "EAB-Schlüssel-ID", -"Only for ZeroSSL / Google": "Nur für ZeroSSL / Google", "EAB HMAC key": "EAB-HMAC-Schlüssel", "Challenge Types": "Challenge-Typen", "Challenge Type": "Challenge-Typ", @@ -699,7 +706,6 @@ export default { "New account name": "Name des neuen Kontos", "Where the CA sends expiry warnings": "Wohin die CA Ablaufwarnungen schickt", "letsencrypt_test issues untrusted certificates but has no rate limits -- ideal while setting things up": "letsencrypt_test stellt nicht vertrauenswürdige Zertifikate aus, hat aber keine Ratenbegrenzung – ideal beim Einrichten", -"Only for ZeroSSL and Google": "Nur für ZeroSSL und Google", "Challenge type": "Challenge-Typ", "New challenge name": "Name des neuen Challenge-Typs", "Validation method": "Prüfmethode", diff --git a/static/js/i18n/es.js b/static/js/i18n/es.js index 6cba5a4..7559f1d 100644 --- a/static/js/i18n/es.js +++ b/static/js/i18n/es.js @@ -501,6 +501,14 @@ export default { "this node is passive: the published names resolve to the virtual IP, which it does not hold, so it does not probe them; ask the node that holds it": "este nodo es pasivo: los nombres publicados apuntan a la IP virtual, que no tiene, así que no los sondea; pregunta al nodo que la tiene", "a round of probes is already running; ask again in a few seconds": "ya hay una ronda de sondeos en marcha; vuelve a pedirlo en unos segundos", "This token cannot be shown, so it is not filled in here: Replace the token, above, to get one that can be shown and copied.": "Este token no se puede mostrar, así que no se rellena aquí: reemplaza el token, arriba, para obtener uno que se pueda mostrar y copiar.", +"ACME directory URL": "URL del directorio ACME", +"Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault": "El directorio de tu propio servidor ACME, como https://ca.example.lan/directory -- por ejemplo acme2certifier delante de Active Directory Certificate Services, step-ca o Vault", +"CA certificate to trust": "Certificado de CA de confianza", +"Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already": "Opcional. El certificado PEM de la CA que firmó el certificado HTTPS del propio servidor ACME, si este nodo no confía ya en ella", +"Only for ZeroSSL, Google, or your own CA if it asks for one": "Solo para ZeroSSL, Google o tu propia CA si lo pide", +"a custom certificate authority needs the address of its ACME directory, such as https://ca.example.lan/directory": "una autoridad de certificación propia necesita la dirección de su directorio ACME, como https://ca.example.lan/directory", +"the CA certificate to trust must be one or more PEM certificates (-----BEGIN CERTIFICATE-----)": "el certificado de CA de confianza debe ser uno o más certificados PEM (-----BEGIN CERTIFICATE-----)", +"the CA certificate to trust is not a certificate openssl can read": "el certificado de CA de confianza no es un certificado que openssl pueda leer", "Load from file": "Cargar desde archivo", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Lee un archivo .html local en el campo de arriba. No se sube nada: la página se guarda con la configuración.", "Value": "Valor", @@ -551,7 +559,6 @@ export default { "Certificate authority": "Autoridad de certificación", "Use letsencrypt_test while setting things up to avoid rate limits": "Use letsencrypt_test mientras configura todo para evitar límites de peticiones", "EAB key ID": "ID de clave EAB", -"Only for ZeroSSL / Google": "Solo para ZeroSSL / Google", "EAB HMAC key": "Clave HMAC EAB", "Challenge Types": "Tipos de desafío", "Challenge Type": "Tipo de desafío", @@ -699,7 +706,6 @@ export default { "New account name": "Nombre de la nueva cuenta", "Where the CA sends expiry warnings": "Adónde envía la CA los avisos de caducidad", "letsencrypt_test issues untrusted certificates but has no rate limits -- ideal while setting things up": "letsencrypt_test emite certificados no confiables pero sin límites de peticiones – ideal mientras configura todo", -"Only for ZeroSSL and Google": "Solo para ZeroSSL y Google", "Challenge type": "Tipo de desafío", "New challenge name": "Nombre del nuevo tipo de desafío", "Validation method": "Método de validación", diff --git a/static/js/i18n/fi.js b/static/js/i18n/fi.js index d28cae5..8177b12 100644 --- a/static/js/i18n/fi.js +++ b/static/js/i18n/fi.js @@ -501,6 +501,14 @@ export default { "this node is passive: the published names resolve to the virtual IP, which it does not hold, so it does not probe them; ask the node that holds it": "tämä solmu on passiivinen: julkaistut nimet osoittavat virtuaaliseen IP-osoitteeseen, joka ei ole sillä, joten se ei tarkista niitä; kysy solmulta, jolla se on", "a round of probes is already running; ask again in a few seconds": "tarkistuskierros on jo käynnissä; kysy uudelleen muutaman sekunnin päästä", "This token cannot be shown, so it is not filled in here: Replace the token, above, to get one that can be shown and copied.": "Tätä tunnistetta ei voi näyttää, joten sitä ei täytetä tähän: korvaa tunniste yllä saadaksesi sellaisen, jonka voi näyttää ja kopioida.", +"ACME directory URL": "ACME-hakemiston URL", +"Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault": "Oman ACME-palvelimesi hakemisto, esimerkiksi https://ca.example.lan/directory -- vaikkapa acme2certifier Active Directory Certificate Servicesin edessä, step-ca tai Vault", +"CA certificate to trust": "Luotettava CA-varmenne", +"Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already": "Valinnainen. Sen varmentajan PEM-varmenne, joka on allekirjoittanut ACME-palvelimen oman HTTPS-varmenteen, jos tämä solmu ei vielä luota siihen", +"Only for ZeroSSL, Google, or your own CA if it asks for one": "Vain ZeroSSL:lle, Googlelle tai omalle varmentajallesi, jos se pyytää sitä", +"a custom certificate authority needs the address of its ACME directory, such as https://ca.example.lan/directory": "oma varmentaja tarvitsee ACME-hakemistonsa osoitteen, esimerkiksi https://ca.example.lan/directory", +"the CA certificate to trust must be one or more PEM certificates (-----BEGIN CERTIFICATE-----)": "luotettavan CA-varmenteen on oltava yksi tai useampi PEM-varmenne (-----BEGIN CERTIFICATE-----)", +"the CA certificate to trust is not a certificate openssl can read": "luotettava CA-varmenne ei ole varmenne, jota openssl osaa lukea", "Load from file": "Lataa tiedostosta", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Lukee paikallisen .html-tiedoston yllä olevaan kenttään. Mitään ei ladata palvelimelle: sivu tallennetaan asetusten mukana.", "Value": "Arvo", @@ -551,7 +559,6 @@ export default { "Certificate authority": "Varmentaja", "Use letsencrypt_test while setting things up to avoid rate limits": "Käytä letsencrypt_test-vaihtoehtoa asennuksen aikana välttääksesi rajoitukset", "EAB key ID": "EAB-avaimen tunnus", -"Only for ZeroSSL / Google": "Vain ZeroSSL:lle / Googlelle", "EAB HMAC key": "EAB-HMAC-avain", "Challenge Types": "Haastetyypit", "Challenge Type": "Haastetyyppi", @@ -699,7 +706,6 @@ export default { "New account name": "Uuden tilin nimi", "Where the CA sends expiry warnings": "Minne varmentaja lähettää vanhenemisvaroitukset", "letsencrypt_test issues untrusted certificates but has no rate limits -- ideal while setting things up": "letsencrypt_test myöntää ei-luotettuja varmenteita, mutta ilman rajoituksia – ihanteellinen asennuksen aikana", -"Only for ZeroSSL and Google": "Vain ZeroSSL:lle ja Googlelle", "Challenge type": "Haastetyyppi", "New challenge name": "Uuden haastetyypin nimi", "Validation method": "Tarkistusmenetelmä", diff --git a/static/js/i18n/fr.js b/static/js/i18n/fr.js index 45f00d5..a4eb12c 100644 --- a/static/js/i18n/fr.js +++ b/static/js/i18n/fr.js @@ -501,6 +501,14 @@ export default { "this node is passive: the published names resolve to the virtual IP, which it does not hold, so it does not probe them; ask the node that holds it": "ce nœud est passif : les noms publiés pointent vers l'IP virtuelle, qu'il ne détient pas, il ne les sonde donc pas ; demandez au nœud qui la détient", "a round of probes is already running; ask again in a few seconds": "une série de sondes est déjà en cours ; redemandez dans quelques secondes", "This token cannot be shown, so it is not filled in here: Replace the token, above, to get one that can be shown and copied.": "Ce jeton ne peut pas être affiché, il n'est donc pas rempli ici : remplacez le jeton, plus haut, pour en obtenir un qui peut être affiché et copié.", +"ACME directory URL": "URL du répertoire ACME", +"Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault": "Le répertoire de votre propre serveur ACME, par exemple https://ca.example.lan/directory -- comme acme2certifier devant Active Directory Certificate Services, step-ca ou Vault", +"CA certificate to trust": "Certificat d'AC de confiance", +"Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already": "Facultatif. Le certificat PEM de l'AC qui a signé le certificat HTTPS du serveur ACME lui-même, si ce nœud ne lui fait pas déjà confiance", +"Only for ZeroSSL, Google, or your own CA if it asks for one": "Uniquement pour ZeroSSL, Google ou votre propre AC si elle le demande", +"a custom certificate authority needs the address of its ACME directory, such as https://ca.example.lan/directory": "une autorité de certification personnalisée a besoin de l'adresse de son répertoire ACME, par exemple https://ca.example.lan/directory", +"the CA certificate to trust must be one or more PEM certificates (-----BEGIN CERTIFICATE-----)": "le certificat d'AC de confiance doit être un ou plusieurs certificats PEM (-----BEGIN CERTIFICATE-----)", +"the CA certificate to trust is not a certificate openssl can read": "le certificat d'AC de confiance n'est pas un certificat lisible par openssl", "Load from file": "Charger depuis un fichier", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Lit un fichier .html local dans le champ ci-dessus. Rien n'est téléversé : la page est enregistrée avec la configuration.", "Value": "Valeur", @@ -551,7 +559,6 @@ export default { "Certificate authority": "Autorité de certification", "Use letsencrypt_test while setting things up to avoid rate limits": "Utilisez letsencrypt_test pendant la mise en place pour éviter les limites de débit", "EAB key ID": "ID de clé EAB", -"Only for ZeroSSL / Google": "Seulement pour ZeroSSL / Google", "EAB HMAC key": "Clé HMAC EAB", "Challenge Types": "Types de défi", "Challenge Type": "Type de défi", @@ -699,7 +706,6 @@ export default { "New account name": "Nom du nouveau compte", "Where the CA sends expiry warnings": "Où l'AC envoie les avertissements d'expiration", "letsencrypt_test issues untrusted certificates but has no rate limits -- ideal while setting things up": "letsencrypt_test émet des certificats non fiables mais sans limite de débit – idéal pendant la mise en place", -"Only for ZeroSSL and Google": "Seulement pour ZeroSSL et Google", "Challenge type": "Type de défi", "New challenge name": "Nom du nouveau type de défi", "Validation method": "Méthode de validation", diff --git a/static/js/i18n/it.js b/static/js/i18n/it.js index 733a619..44f97dd 100644 --- a/static/js/i18n/it.js +++ b/static/js/i18n/it.js @@ -501,6 +501,14 @@ export default { "this node is passive: the published names resolve to the virtual IP, which it does not hold, so it does not probe them; ask the node that holds it": "questo nodo è passivo: i nomi pubblicati puntano all'IP virtuale, che non detiene, quindi non li sonda; chiedi al nodo che lo detiene", "a round of probes is already running; ask again in a few seconds": "è già in corso un giro di sonde; richiedi tra qualche secondo", "This token cannot be shown, so it is not filled in here: Replace the token, above, to get one that can be shown and copied.": "Questo token non può essere mostrato, quindi qui non viene inserito: sostituisci il token, sopra, per averne uno che si possa mostrare e copiare.", +"ACME directory URL": "URL della directory ACME", +"Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault": "La directory del tuo server ACME, ad esempio https://ca.example.lan/directory -- per esempio acme2certifier davanti ad Active Directory Certificate Services, step-ca o Vault", +"CA certificate to trust": "Certificato CA da considerare attendibile", +"Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already": "Facoltativo. Il certificato PEM della CA che ha firmato il certificato HTTPS del server ACME stesso, se questo nodo non la considera già attendibile", +"Only for ZeroSSL, Google, or your own CA if it asks for one": "Solo per ZeroSSL, Google o la tua CA se lo richiede", +"a custom certificate authority needs the address of its ACME directory, such as https://ca.example.lan/directory": "un'autorità di certificazione personalizzata richiede l'indirizzo della sua directory ACME, ad esempio https://ca.example.lan/directory", +"the CA certificate to trust must be one or more PEM certificates (-----BEGIN CERTIFICATE-----)": "il certificato CA da considerare attendibile deve essere uno o più certificati PEM (-----BEGIN CERTIFICATE-----)", +"the CA certificate to trust is not a certificate openssl can read": "il certificato CA da considerare attendibile non è un certificato leggibile da openssl", "Load from file": "Carica da file", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Legge un file .html locale nel campo qui sopra. Nulla viene caricato sul server: la pagina viene salvata con la configurazione.", "Value": "Valore", @@ -551,7 +559,6 @@ export default { "Certificate authority": "Autorità di certificazione", "Use letsencrypt_test while setting things up to avoid rate limits": "Usa letsencrypt_test durante la configurazione per evitare i limiti di frequenza", "EAB key ID": "ID chiave EAB", -"Only for ZeroSSL / Google": "Solo per ZeroSSL / Google", "EAB HMAC key": "Chiave HMAC EAB", "Challenge Types": "Tipi di challenge", "Challenge Type": "Tipo di challenge", @@ -699,7 +706,6 @@ export default { "New account name": "Nome del nuovo account", "Where the CA sends expiry warnings": "Dove la CA invia gli avvisi di scadenza", "letsencrypt_test issues untrusted certificates but has no rate limits -- ideal while setting things up": "letsencrypt_test emette certificati non attendibili ma senza limiti di frequenza – ideale durante la configurazione", -"Only for ZeroSSL and Google": "Solo per ZeroSSL e Google", "Challenge type": "Tipo di challenge", "New challenge name": "Nome del nuovo tipo di challenge", "Validation method": "Metodo di validazione", diff --git a/static/js/i18n/nb.js b/static/js/i18n/nb.js index c3cc590..6e3fd33 100644 --- a/static/js/i18n/nb.js +++ b/static/js/i18n/nb.js @@ -501,6 +501,14 @@ export default { "this node is passive: the published names resolve to the virtual IP, which it does not hold, so it does not probe them; ask the node that holds it": "denne noden er passiv: de publiserte navnene peker til den virtuelle IP-en, som den ikke har, så den sjekker dem ikke; spør noden som har den", "a round of probes is already running; ask again in a few seconds": "en runde med sjekker kjører allerede; spør igjen om noen sekunder", "This token cannot be shown, so it is not filled in here: Replace the token, above, to get one that can be shown and copied.": "Dette tokenet kan ikke vises, så det fylles ikke inn her: erstatt tokenet ovenfor for å få et som kan vises og kopieres.", +"ACME directory URL": "URL til ACME-katalog", +"Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault": "Katalogen til din egen ACME-server, for eksempel https://ca.example.lan/directory -- som acme2certifier foran Active Directory Certificate Services, step-ca eller Vault", +"CA certificate to trust": "CA-sertifikat som skal stoles på", +"Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already": "Valgfritt. PEM-sertifikatet til sertifikatutstederen som signerte ACME-serverens eget HTTPS-sertifikat, hvis denne noden ikke allerede stoler på den", +"Only for ZeroSSL, Google, or your own CA if it asks for one": "Bare for ZeroSSL, Google eller din egen sertifikatutsteder hvis den ber om det", +"a custom certificate authority needs the address of its ACME directory, such as https://ca.example.lan/directory": "en egen sertifikatutsteder trenger adressen til ACME-katalogen sin, for eksempel https://ca.example.lan/directory", +"the CA certificate to trust must be one or more PEM certificates (-----BEGIN CERTIFICATE-----)": "CA-sertifikatet som skal stoles på må være ett eller flere PEM-sertifikater (-----BEGIN CERTIFICATE-----)", +"the CA certificate to trust is not a certificate openssl can read": "CA-sertifikatet som skal stoles på er ikke et sertifikat openssl kan lese", "Load from file": "Last inn fra fil", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Leser en lokal .html-fil inn i feltet ovenfor. Ingenting lastes opp: siden lagres sammen med konfigurasjonen.", "Value": "Verdi", @@ -551,7 +559,6 @@ export default { "Certificate authority": "Sertifikatutsteder", "Use letsencrypt_test while setting things up to avoid rate limits": "Bruk letsencrypt_test under oppsettet for å unngå begrensninger", "EAB key ID": "EAB-nøkkel-ID", -"Only for ZeroSSL / Google": "Bare for ZeroSSL / Google", "EAB HMAC key": "EAB-HMAC-nøkkel", "Challenge Types": "Utfordringstyper", "Challenge Type": "Utfordringstype", @@ -699,7 +706,6 @@ export default { "New account name": "Navn på ny konto", "Where the CA sends expiry warnings": "Dit CA-en sender utløpsvarsler", "letsencrypt_test issues untrusted certificates but has no rate limits -- ideal while setting things up": "letsencrypt_test utsteder ikke-klarerte sertifikater, men har ingen begrensninger – ideelt under oppsettet", -"Only for ZeroSSL and Google": "Bare for ZeroSSL og Google", "Challenge type": "Utfordringstype", "New challenge name": "Navn på ny utfordringstype", "Validation method": "Valideringsmetode", diff --git a/static/js/i18n/nl.js b/static/js/i18n/nl.js index ec36d46..e9d7338 100644 --- a/static/js/i18n/nl.js +++ b/static/js/i18n/nl.js @@ -501,6 +501,14 @@ export default { "this node is passive: the published names resolve to the virtual IP, which it does not hold, so it does not probe them; ask the node that holds it": "deze node is passief: de gepubliceerde namen verwijzen naar het virtuele IP, dat hij niet heeft, dus hij test ze niet; vraag het de node die het heeft", "a round of probes is already running; ask again in a few seconds": "er loopt al een testronde; vraag het over een paar seconden opnieuw", "This token cannot be shown, so it is not filled in here: Replace the token, above, to get one that can be shown and copied.": "Dit token kan niet getoond worden en wordt hier dus niet ingevuld: vervang het token hierboven om er een te krijgen dat getoond en gekopieerd kan worden.", +"ACME directory URL": "URL van de ACME-directory", +"Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault": "De directory van je eigen ACME-server, zoals https://ca.example.lan/directory -- bijvoorbeeld acme2certifier voor Active Directory Certificate Services, step-ca of Vault", +"CA certificate to trust": "Te vertrouwen CA-certificaat", +"Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already": "Optioneel. Het PEM-certificaat van de CA die het HTTPS-certificaat van de ACME-server zelf heeft ondertekend, als deze node die nog niet vertrouwt", +"Only for ZeroSSL, Google, or your own CA if it asks for one": "Alleen voor ZeroSSL, Google of je eigen CA als die erom vraagt", +"a custom certificate authority needs the address of its ACME directory, such as https://ca.example.lan/directory": "een eigen certificaatautoriteit heeft het adres van haar ACME-directory nodig, zoals https://ca.example.lan/directory", +"the CA certificate to trust must be one or more PEM certificates (-----BEGIN CERTIFICATE-----)": "het te vertrouwen CA-certificaat moet uit een of meer PEM-certificaten bestaan (-----BEGIN CERTIFICATE-----)", +"the CA certificate to trust is not a certificate openssl can read": "het te vertrouwen CA-certificaat is geen certificaat dat openssl kan lezen", "Load from file": "Uit bestand laden", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Leest een lokaal .html-bestand in het veld hierboven. Er wordt niets geüpload: de pagina wordt met de configuratie opgeslagen.", "Value": "Waarde", @@ -551,7 +559,6 @@ export default { "Certificate authority": "Certificaatautoriteit", "Use letsencrypt_test while setting things up to avoid rate limits": "Gebruik letsencrypt_test tijdens het instellen om limieten te vermijden", "EAB key ID": "EAB-sleutel-ID", -"Only for ZeroSSL / Google": "Alleen voor ZeroSSL / Google", "EAB HMAC key": "EAB-HMAC-sleutel", "Challenge Types": "Challenge-types", "Challenge Type": "Challenge-type", @@ -699,7 +706,6 @@ export default { "New account name": "Naam van het nieuwe account", "Where the CA sends expiry warnings": "Waar de CA vervalwaarschuwingen naartoe stuurt", "letsencrypt_test issues untrusted certificates but has no rate limits -- ideal while setting things up": "letsencrypt_test geeft niet-vertrouwde certificaten uit maar heeft geen limieten – ideaal tijdens het instellen", -"Only for ZeroSSL and Google": "Alleen voor ZeroSSL en Google", "Challenge type": "Challenge-type", "New challenge name": "Naam van het nieuwe challenge-type", "Validation method": "Validatiemethode", diff --git a/static/js/i18n/pt.js b/static/js/i18n/pt.js index 495463e..30b12b9 100644 --- a/static/js/i18n/pt.js +++ b/static/js/i18n/pt.js @@ -501,6 +501,14 @@ export default { "this node is passive: the published names resolve to the virtual IP, which it does not hold, so it does not probe them; ask the node that holds it": "este nó é passivo: os nomes publicados apontam para o IP virtual, que ele não detém, por isso não os sonda; pergunte ao nó que o detém", "a round of probes is already running; ask again in a few seconds": "já está a decorrer uma ronda de sondagens; peça de novo dentro de alguns segundos", "This token cannot be shown, so it is not filled in here: Replace the token, above, to get one that can be shown and copied.": "Este token não pode ser mostrado, por isso não é preenchido aqui: substitua o token, acima, para obter um que possa ser mostrado e copiado.", +"ACME directory URL": "URL do diretório ACME", +"Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault": "O diretório do seu próprio servidor ACME, como https://ca.example.lan/directory -- por exemplo acme2certifier à frente do Active Directory Certificate Services, step-ca ou Vault", +"CA certificate to trust": "Certificado de CA confiável", +"Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already": "Opcional. O certificado PEM da CA que assinou o certificado HTTPS do próprio servidor ACME, se este nó ainda não confiar nela", +"Only for ZeroSSL, Google, or your own CA if it asks for one": "Apenas para ZeroSSL, Google ou a sua própria CA, se ela pedir", +"a custom certificate authority needs the address of its ACME directory, such as https://ca.example.lan/directory": "uma autoridade de certificação própria precisa do endereço do seu diretório ACME, como https://ca.example.lan/directory", +"the CA certificate to trust must be one or more PEM certificates (-----BEGIN CERTIFICATE-----)": "o certificado de CA confiável deve ser um ou mais certificados PEM (-----BEGIN CERTIFICATE-----)", +"the CA certificate to trust is not a certificate openssl can read": "o certificado de CA confiável não é um certificado que o openssl consiga ler", "Load from file": "Carregar de ficheiro", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Lê um ficheiro .html local para o campo acima. Nada é enviado: a página é guardada com a configuração.", "Value": "Valor", @@ -551,7 +559,6 @@ export default { "Certificate authority": "Autoridade de certificação", "Use letsencrypt_test while setting things up to avoid rate limits": "Use letsencrypt_test enquanto configura para evitar limites de pedidos", "EAB key ID": "ID da chave EAB", -"Only for ZeroSSL / Google": "Só para ZeroSSL / Google", "EAB HMAC key": "Chave HMAC EAB", "Challenge Types": "Tipos de desafio", "Challenge Type": "Tipo de desafio", @@ -699,7 +706,6 @@ export default { "New account name": "Nome da nova conta", "Where the CA sends expiry warnings": "Para onde a CA envia os avisos de expiração", "letsencrypt_test issues untrusted certificates but has no rate limits -- ideal while setting things up": "letsencrypt_test emite certificados não confiáveis mas sem limites de pedidos – ideal enquanto configura", -"Only for ZeroSSL and Google": "Só para ZeroSSL e Google", "Challenge type": "Tipo de desafio", "New challenge name": "Nome do novo tipo de desafio", "Validation method": "Método de validação", diff --git a/static/js/i18n/sv.js b/static/js/i18n/sv.js index ea1df4e..03ad612 100644 --- a/static/js/i18n/sv.js +++ b/static/js/i18n/sv.js @@ -501,6 +501,14 @@ export default { "this node is passive: the published names resolve to the virtual IP, which it does not hold, so it does not probe them; ask the node that holds it": "den här noden är passiv: de publicerade namnen pekar på den virtuella IP-adressen, som den inte har, så den kontrollerar dem inte; fråga noden som har den", "a round of probes is already running; ask again in a few seconds": "en kontrollrunda pågår redan; fråga igen om några sekunder", "This token cannot be shown, so it is not filled in here: Replace the token, above, to get one that can be shown and copied.": "Den här token kan inte visas, så den fylls inte i här: ersätt token ovan för att få en som kan visas och kopieras.", +"ACME directory URL": "URL till ACME-katalog", +"Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault": "Katalogen för din egen ACME-server, till exempel https://ca.example.lan/directory -- som acme2certifier framför Active Directory Certificate Services, step-ca eller Vault", +"CA certificate to trust": "CA-certifikat att lita på", +"Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already": "Valfritt. PEM-certifikatet för den certifikatutfärdare som signerade ACME-serverns eget HTTPS-certifikat, om den här noden inte redan litar på den", +"Only for ZeroSSL, Google, or your own CA if it asks for one": "Bara för ZeroSSL, Google eller din egen certifikatutfärdare om den ber om det", +"a custom certificate authority needs the address of its ACME directory, such as https://ca.example.lan/directory": "en egen certifikatutfärdare behöver adressen till sin ACME-katalog, till exempel https://ca.example.lan/directory", +"the CA certificate to trust must be one or more PEM certificates (-----BEGIN CERTIFICATE-----)": "CA-certifikatet att lita på måste vara ett eller flera PEM-certifikat (-----BEGIN CERTIFICATE-----)", +"the CA certificate to trust is not a certificate openssl can read": "CA-certifikatet att lita på är inte ett certifikat som openssl kan läsa", "Load from file": "Läs in från fil", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Läser in en lokal .html-fil i fältet ovan. Inget laddas upp: sidan sparas med konfigurationen.", "Value": "Värde", @@ -551,7 +559,6 @@ export default { "Certificate authority": "Certifikatutfärdare", "Use letsencrypt_test while setting things up to avoid rate limits": "Använd letsencrypt_test under uppsättningen för att undvika begränsningar", "EAB key ID": "EAB-nyckel-ID", -"Only for ZeroSSL / Google": "Endast för ZeroSSL / Google", "EAB HMAC key": "EAB-HMAC-nyckel", "Challenge Types": "Utmaningstyper", "Challenge Type": "Utmaningstyp", @@ -699,7 +706,6 @@ export default { "New account name": "Namn på nytt konto", "Where the CA sends expiry warnings": "Dit CA:n skickar varningar om utgång", "letsencrypt_test issues untrusted certificates but has no rate limits -- ideal while setting things up": "letsencrypt_test utfärdar obetrodda certifikat men har inga begränsningar – perfekt under uppsättningen", -"Only for ZeroSSL and Google": "Endast för ZeroSSL och Google", "Challenge type": "Utmaningstyp", "New challenge name": "Namn på ny utmaningstyp", "Validation method": "Valideringsmetod", diff --git a/static/js/pages/certificates.js b/static/js/pages/certificates.js index 4c61f67..00c5a9c 100644 --- a/static/js/pages/certificates.js +++ b/static/js/pages/certificates.js @@ -1,4 +1,4 @@ -import { $, api, btn, closeDlg, esc, fieldRow, list, lists, localTime, openDlg, readForm, showText, download } from "../core.js"; +import { $, CA_CHOICES, api, btn, closeDlg, esc, fieldRow, list, lists, localTime, openDlg, readForm, showText, download } from "../core.js"; import { E, renderEntity } from "../entities.js"; import { t, tn } from "../i18n.js"; import { refreshStatus, route } from "../shell.js"; @@ -53,15 +53,29 @@ export function dnsCredentialHelp(frm){ /* ---- certificate wizard ---- */ export const NEWOPT="__new__"; +const caLabel=a=>a.ca==="custom"?(String(a.server||"").replace(/^https?:\/\//,"").split("/")[0]||"custom"):(a.ca||"letsencrypt"); +/* The directory URL and the CA certificate rows mean something for a custom CA + only; the account editor hides them otherwise. */ +export function customCaRows(frm){ + const sel=frm.querySelector("#f_ca");if(!sel)return; + const els=["server","ca_bundle"].flatMap(k=>{ + let cell=frm.querySelector("#f_"+k);if(!cell)return []; + while(cell.parentNode&&cell.parentNode!==frm)cell=cell.parentNode; + return [frm.querySelector('label[for="f_'+k+'"]'),cell].filter(Boolean);}); + const sync=()=>els.forEach(el=>{el.style.display=sel.value==="custom"?"":"none";}); + sel.addEventListener("change",sync);sync(); +} export const CERT_WIZ=[ {k:"domains",l:"Domain names",t:"textarea",h:"One per line. The first is the certificate's primary name. A wildcard such as *.example.com needs DNS-01."}, {k:"name",l:"Certificate name",t:"text",h:"Optional -- taken from the first domain when left empty"}, {k:"account_id",l:"ACME account",t:"select",o:[]}, {k:"acc_name",l:"New account name",t:"text"}, {k:"acc_email",l:"E-mail address",t:"text",h:"Where the CA sends expiry warnings"}, - {k:"acc_ca",l:"Certificate authority",t:"select",o:["letsencrypt","letsencrypt_test","zerossl","buypass","google"],d:"letsencrypt", + {k:"acc_ca",l:"Certificate authority",t:"select",o:CA_CHOICES,d:"letsencrypt", h:"letsencrypt_test issues untrusted certificates but has no rate limits -- ideal while setting things up"}, - {k:"acc_eab_kid",l:"EAB key ID",t:"text",h:"Only for ZeroSSL and Google"}, + {k:"acc_server",l:"ACME directory URL",t:"text",h:"Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault"}, + {k:"acc_ca_bundle",l:"CA certificate to trust",t:"textarea",h:"Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already"}, + {k:"acc_eab_kid",l:"EAB key ID",t:"text",h:"Only for ZeroSSL, Google, or your own CA if it asks for one"}, {k:"acc_eab_hmac",l:"EAB HMAC key",t:"password"}, {k:"challenge_id",l:"Challenge type",t:"select",o:[]}, {k:"ch_name",l:"New challenge name",t:"text"}, @@ -77,7 +91,7 @@ export async function openCertWizard(){ await Promise.all([list("acme/accounts",true),list("acme/challenges",true),loadDnsApis()]); const accounts=lists["acme/accounts"]||[],challenges=lists["acme/challenges"]||[]; const fields=CERT_WIZ.map(f=>{ - if(f.k==="account_id")return Object.assign({},f,{o:accounts.map(a=>({value:a.id,label:a.name+" ("+(a.ca||"letsencrypt")+")"})).concat([{value:NEWOPT,label:"Create a new account..."}])}); + if(f.k==="account_id")return Object.assign({},f,{o:accounts.map(a=>({value:a.id,label:a.name+" ("+caLabel(a)+")"})).concat([{value:NEWOPT,label:"Create a new account..."}])}); if(f.k==="challenge_id")return Object.assign({},f,{o:challenges.map(c=>({value:c.id,label:c.name+" ("+(c.method||"http01")+")"})).concat([{value:NEWOPT,label:"Create a new challenge type..."}])}); return f; }); @@ -95,12 +109,14 @@ export async function openCertWizard(){ const sync=()=>{ const newAcc=frm.querySelector("#f_account_id").value===NEWOPT; ["acc_name","acc_email","acc_ca","acc_eab_kid","acc_eab_hmac"].forEach(k=>setRow(k,newAcc)); + const custom=frm.querySelector("#f_acc_ca").value==="custom"; + ["acc_server","acc_ca_bundle"].forEach(k=>setRow(k,newAcc&&custom)); const newCh=frm.querySelector("#f_challenge_id").value===NEWOPT; const method=frm.querySelector("#f_ch_method").value; ["ch_name","ch_method"].forEach(k=>setRow(k,newCh)); ["dns_provider","dns_credentials"].forEach(k=>setRow(k,newCh&&method==="dns01")); }; - ["f_account_id","f_challenge_id","f_ch_method"].forEach(id=>{ + ["f_account_id","f_acc_ca","f_challenge_id","f_ch_method"].forEach(id=>{ const el=frm.querySelector("#"+id);if(el)el.addEventListener("change",sync);}); dnsCredentialHelp(frm); sync(); @@ -111,6 +127,7 @@ export async function openCertWizard(){ auto_renew:d.auto_renew,issue:d.issue}; body.account=d.account_id===NEWOPT ? {name:d.acc_name||("account-"+(d.acc_ca||"letsencrypt")),email:d.acc_email,ca:d.acc_ca, + server:d.acc_ca==="custom"?d.acc_server:"",ca_bundle:d.acc_ca==="custom"?d.acc_ca_bundle:"", eab_kid:d.acc_eab_kid,eab_hmac:d.acc_eab_hmac} : {id:d.account_id}; body.challenge=d.challenge_id===NEWOPT diff --git a/tools/test-custom-ca.py b/tools/test-custom-ca.py new file mode 100644 index 0000000..8494de1 --- /dev/null +++ b/tools/test-custom-ca.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""A certificate authority of your own: an ACME account with "custom" as its CA. + + python3 tools/test-custom-ca.py + +The account names the server's ACME directory and, optionally, a CA +certificate to trust for that server's HTTPS. What is refused before it is +stored, what acme.sh is given (with a stand-in acme.sh that records its +arguments), and that a CA_BUNDLE acme.sh saved into account.conf is gone +before the next run -- acme.sh reads that file after its command line, so a +saved bundle would otherwise replace the system's trust for every CA after it. +""" +import os +import pathlib +import subprocess +import sys +import tempfile + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) +os.environ["HAM_DATA_DIR"] = tempfile.mkdtemp(prefix="ham-customca-") +D = os.environ["HAM_DATA_DIR"] +os.environ["HAM_CERT_DIR"] = os.path.join(D, "certs") +os.environ["HAM_ERROR_DIR"] = os.path.join(D, "error-pages") +os.environ["HAM_ACME_HOME"] = os.path.join(D, "acme") +os.environ["HAM_ACME_SH"] = os.path.join(D, "acme.sh") +os.environ["HAM_DRY_RUN"] = "1" + +# The stand-in: one line per run with its arguments, and whether account.conf +# held a CA_BUNDLE when it started. Like acme.sh after an issue, it then saves +# the bundle it was given into account.conf. +RUNS = os.path.join(D, "runs.txt") +os.makedirs(os.environ["HAM_ACME_HOME"], exist_ok=True) +pathlib.Path(os.environ["HAM_ACME_SH"]).write_text(r'''#!/bin/sh +conf="$HAM_ACME_HOME/account.conf" +saved=no; grep -q '^CA_BUNDLE=' "$conf" 2>/dev/null && saved=yes +echo "saved=$saved $*" >> "%s" +prev=""; for a in "$@"; do + if [ "$prev" = "--ca-bundle" ]; then echo "CA_BUNDLE='$a'" >> "$conf"; fi + prev="$a" +done +case "$*" in *--issue*) exit 1;; esac +exit 0 +''' % RUNS) +os.chmod(os.environ["HAM_ACME_SH"], 0o755) +pathlib.Path(os.environ["HAM_ACME_HOME"], "account.conf").write_text( + "ACCOUNT_EMAIL='a@example.com'\nCA_BUNDLE='/somewhere/old.pem'\nAUTO_UPGRADE='0'\n") + +import ham; ham # noqa: E402 (route registration) +from ham import acme # noqa: E402 +from ham.config import load_config, save_config # noqa: E402 + +fails = [] + + +def ok(cond, msg): + print((" PASS " if cond else " FAIL ") + msg) + if not cond: + fails.append(msg) + + +ham.app.config["TESTING"] = True +client = ham.app.test_client() +cfg = load_config() +cfg["local"]["api_key"] = "ca-key" +save_config(cfg) +H = {"X-API-Key": "ca-key"} + +# a CA certificate, as an internal CA would hand out +k = os.path.join(D, "ca.key") +subprocess.run(["openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", + "-nodes", "-keyout", k, "-out", os.path.join(D, "ca.pem"), "-days", "30", + "-subj", "/CN=Test Internal CA"], check=True, capture_output=True) +CA_PEM = pathlib.Path(D, "ca.pem").read_text() + + +def add_account(body, want): + r = client.post("/api/acme/accounts", headers=H, json=body) + err = (r.get_json() or {}).get("error") or "" + ok(r.status_code == want, "account %s -> %d %s" % (body.get("name"), r.status_code, err[:100])) + return r.get_json() or {}, err + + +print("what an account refuses") +_, err = add_account({"name": "no-url", "ca": "custom", "server": ""}, 400) +ok("ACME directory" in err, "a custom CA without an address says it needs one") +add_account({"name": "ftp", "ca": "custom", "server": "ftp://ca.example.lan/directory"}, 400) +add_account({"name": "spaces", "ca": "custom", "server": "https://ca example/directory"}, 400) +_, err = add_account({"name": "bad-pem", "ca": "custom", "server": "https://ca.example.lan/directory", + "ca_bundle": "not a certificate"}, 400) +ok("PEM" in err, "a CA certificate that is not PEM is refused") +_, err = add_account({"name": "key-pem", "ca": "custom", "server": "https://ca.example.lan/directory", + "ca_bundle": pathlib.Path(k).read_text()}, 400) +ok("PEM" in err, "a private key in place of the CA certificate is refused") +broken = "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n" +_, err = add_account({"name": "broken", "ca": "custom", "server": "https://ca.example.lan/directory", + "ca_bundle": broken}, 400) +ok("openssl" in err, "a certificate openssl cannot read is refused") +add_account({"name": "le", "ca": "letsencrypt", "server": ""}, 200) +add_account({"name": "internal", "ca": "custom", "server": "https://ca.example.lan/directory", + "ca_bundle": CA_PEM, "email": "ops@example.lan"}, 200) +add_account({"name": "plain", "ca": "custom", "server": "http://a2c.example.lan/directory"}, 200) +accs = {a["name"]: a for a in load_config()["acme"]["accounts"]} +ok(set(accs) == {"le", "internal", "plain"}, "only the valid accounts were stored: %s" % sorted(accs)) + +r = client.put("/api/acme/accounts/" + accs["internal"]["id"], headers=H, + json=dict(accs["internal"], server="")) +ok(r.status_code == 400, "editing away the address is refused (%d)" % r.status_code) + +print("what acme.sh is given") +ok(acme.server_args(accs["le"]) == ["--server", "letsencrypt"], "Let's Encrypt keeps its short name") +ok(acme.server_args(accs["plain"]) == ["--server", "http://a2c.example.lan/directory"], + "a custom CA without a certificate to trust: its address, and no --ca-bundle") +args = acme.server_args(accs["internal"]) +ok(args[:2] == ["--server", "https://ca.example.lan/directory"] and args[2] == "--ca-bundle", + "a custom CA with a certificate to trust: its address and --ca-bundle") +bundle = pathlib.Path(args[3]).read_text() +ok(CA_PEM.strip() in bundle, "the bundle holds the account's CA certificate") +system = next((p for p in acme.SYSTEM_BUNDLES if os.path.isfile(p)), None) +if system: + first = pathlib.Path(system).read_text().strip().splitlines()[0] + ok(bundle.startswith(first) and bundle.count("BEGIN CERTIFICATE") > 1, + "and the system's trusted CAs before it, for a DNS hook's public API") +else: + print(" SKIP no system CA bundle on this machine") + +print("reading the directory first") +# A local HTTPS server with a certificate from the test CA, answering a +# directory at /directory and something else at /other. +import http.server, json, ssl, threading # noqa: E401,E402 +srv_key, srv_csr, srv_crt = (os.path.join(D, n) for n in ("srv.key", "srv.csr", "srv.pem")) +subprocess.run(["openssl", "req", "-new", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", "-nodes", + "-keyout", srv_key, "-out", srv_csr, "-subj", "/CN=localhost"], check=True, capture_output=True) +pathlib.Path(D, "ext").write_text("subjectAltName=DNS:localhost\n") +subprocess.run(["openssl", "x509", "-req", "-in", srv_csr, "-CA", os.path.join(D, "ca.pem"), "-CAkey", k, + "-CAcreateserial", "-out", srv_crt, "-days", "5", "-extfile", os.path.join(D, "ext")], + check=True, capture_output=True) + + +class Dir(http.server.BaseHTTPRequestHandler): + def do_GET(self): + body = {"/directory": {"newNonce": "x", "newAccount": "y", "newOrder": "z"}, + "/other": {"hello": 1}}.get(self.path) + self.send_response(200 if body else 404) + self.end_headers() + self.wfile.write(json.dumps(body).encode() if body else b"no") + + def log_message(self, *a): + pass + + +httpd = http.server.HTTPServer(("127.0.0.1", 0), Dir) +sctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +sctx.load_cert_chain(srv_crt, srv_key) +httpd.socket = sctx.wrap_socket(httpd.socket, server_side=True) +threading.Thread(target=httpd.serve_forever, daemon=True).start() +base = "https://localhost:%d" % httpd.server_address[1] +acc = {"ca": "custom", "server": base + "/directory", "ca_bundle": CA_PEM} +ok(acme.check_directory(acc) == "", "a directory served with a trusted certificate is read") +why = acme.check_directory(dict(acc, ca_bundle="")) +ok("does not trust" in why and "CA certificate to trust" in why, "an untrusted certificate says what to do: " + why[:90]) +why = acme.check_directory(dict(acc, server=base + "/other")) +ok("not an ACME directory" in why, "a page that is not a directory says so: " + why[:80]) +why = acme.check_directory(dict(acc, server=base + "/missing")) +ok("HTTP 404" in why, "a missing page gives its status: " + why[:80]) +why = acme.check_directory(dict(acc, server="https://localhost:1/directory")) +ok("could not reach" in why, "an unreachable server says so: " + why[:80]) +ok(acme.check_directory({"ca": "letsencrypt"}) == "", "a public CA is not read first") + +# From here the stand-in acme.sh stands in for the CA as well. +acme.check_directory = lambda acc: "" + +print("issuing, and the CA_BUNDLE acme.sh saves") +cfg = load_config() +cfg["acme"]["challenges"].append({"id": "ch1", "name": "http", "method": "http01"}) +for n in ("internal", "le"): + cfg["acme"]["certificates"].append({"id": "c-" + n, "name": "c-" + n, "domains": n + ".example.lan", + "account": accs[n]["id"], "challenge": "ch1"}) +save_config(cfg) +cfg = load_config() +certs = {c["id"]: c for c in cfg["acme"]["certificates"]} +acme.acme_issue(cfg, certs["c-internal"]) +acme.acme_issue(cfg, certs["c-le"]) +runs = pathlib.Path(RUNS).read_text().splitlines() +ok(len(runs) == 4, "register and issue for each account: %d runs" % len(runs)) +reg_i, iss_i, reg_le, iss_le = runs +ok(reg_i.startswith("saved=no") and iss_i.startswith("saved=no"), + "the CA_BUNDLE already in account.conf was gone before the first run") +ok("--register-account" in reg_i and "--server https://ca.example.lan/directory --ca-bundle" in reg_i, + "the account is registered with the custom CA, trusting its certificate") +ok("--issue --server https://ca.example.lan/directory --ca-bundle" in iss_i, + "the certificate is requested from the custom CA, trusting its certificate") +ok(reg_le.startswith("saved=no") and iss_le.startswith("saved=no"), + "the bundle acme.sh saved for the custom CA was gone before the Let's Encrypt runs") +ok("--server letsencrypt" in iss_le and "--ca-bundle" not in iss_le, + "Let's Encrypt is asked with the system's trust only") +conf = pathlib.Path(os.environ["HAM_ACME_HOME"], "account.conf").read_text() +ok("ACCOUNT_EMAIL='a@example.com'" in conf and "AUTO_UPGRADE='0'" in conf, + "the rest of account.conf is left as it was") + +# a stored account broken by hand is refused at issue time, before acme.sh runs +cfg = load_config() +for a in cfg["acme"]["accounts"]: + if a["name"] == "plain": + a["server"] = "" + certs["c-internal"]["account"] = a["id"] +before = len(pathlib.Path(RUNS).read_text().splitlines()) +res = acme.acme_issue(cfg, certs["c-internal"]) +ok(not res["ok"] and "ACME directory" in res["error"], "issuing with no address says why: %s" % res["error"][:80]) +ok(len(pathlib.Path(RUNS).read_text().splitlines()) == before, "and acme.sh was not run") + +print("the certificate wizard") +r = client.post("/api/wizard/certificate", headers=H, json={ + "domains": "app.example.lan", "issue": False, "dry_run": True, + "account": {"name": "new-internal", "ca": "custom", "server": ""}, + "challenge": {"id": "ch1"}}) +ok(r.status_code == 400 and "ACME directory" in (r.get_json() or {}).get("error", ""), + "a new custom account without an address is refused (%d)" % r.status_code) +r = client.post("/api/wizard/certificate", headers=H, json={ + "domains": "app.example.lan", "issue": False, + "account": {"name": "new-internal", "ca": "custom", "server": "https://ca2.example.lan/acme/directory", + "ca_bundle": CA_PEM}, + "challenge": {"id": "ch1"}}) +ok(r.status_code == 200, "a new custom account with an address is created (%d %s)" + % (r.status_code, ((r.get_json() or {}).get("error") or "")[:80])) +new = [a for a in load_config()["acme"]["accounts"] if a["name"] == "new-internal"] +ok(new and new[0]["server"] == "https://ca2.example.lan/acme/directory" and "BEGIN CERTIFICATE" in new[0]["ca_bundle"], + "with its address and CA certificate stored") + +print() +print("FAILED: %d" % len(fails) if fails else "all passed") +sys.exit(1 if fails else 0) diff --git a/tools/uitests/custom-ca.mjs b/tools/uitests/custom-ca.mjs new file mode 100644 index 0000000..df94566 --- /dev/null +++ b/tools/uitests/custom-ca.mjs @@ -0,0 +1,130 @@ +/* A custom ACME server from the browser's side: the account editor and the + * certificate wizard show the directory URL and the CA certificate to trust + * only for "custom", and send them; the accounts list and the wizard's + * account picker name a custom CA by its address. */ +let parseHTML; +for (const where of ["linkedom", process.env.LINKEDOM]) { + if (!where) continue; + try { ({ parseHTML } = await import(where)); break; } catch { /* try the next */ } +} +if (!parseHTML) { + console.log(" skipped: no DOM available (npm i linkedom, or set LINKEDOM)"); + process.exit(0); +} +const REPO = process.cwd(); +const { document, window } = parseHTML(` +

+
+
+ +
`); +globalThis.document = document; globalThis.window = window; +globalThis.location = { hash: "#/p:services" }; +globalThis.MutationObserver = class { observe(){} }; +globalThis.localStorage = { getItem: () => null, setItem(){}, removeItem(){} }; +globalThis.setTimeout = () => 0; +globalThis.confirm = () => true; +/* linkedom's selects: see proxy-protocol.mjs */ +const optProto = Object.getPrototypeOf(document.createElement("option")); +Object.defineProperty(optProto, "selected", { + configurable: true, + get() { return this.hasAttribute("selected"); }, + set(v) { + if (v) { const prev = this.parentElement && this.parentElement.querySelector("option[selected]"); + if (prev && prev !== this) prev.removeAttribute("selected"); this.setAttribute("selected", ""); } + else this.removeAttribute("selected"); + }, +}); +const selProto = Object.getPrototypeOf(document.createElement("select")); +Object.defineProperty(selProto, "value", { + configurable: true, + get() { const o = this.querySelector("option[selected]") || this.querySelector("option"); return o ? o.value : ""; }, + set(v) { const o = [...this.querySelectorAll("option")].find(x => x.value === String(v)); if (o) o.selected = true; }, +}); + + +const ACCOUNTS = [ + { id: "a1", name: "le", email: "", ca: "letsencrypt" }, + { id: "a2", name: "corp", email: "ops@corp.lan", ca: "custom", + server: "https://a2c.corp.lan/directory", ca_bundle: "-----BEGIN CERTIFICATE-----\nAA\n-----END CERTIFICATE-----" }, +]; +let sent = null, path = null; +globalThis.fetch = async (u, o) => { + const p = String(u).replace(/^\/api\//, "").split("?")[0]; + if (o && o.body) { sent = JSON.parse(o.body); path = p; } + const bodies = { + "acme/accounts": ACCOUNTS, "acme/challenges": [{ id: "h1", name: "http", method: "http01" }], + "acme/certificates": [], "acme/status": {}, "acme/health": {}, "acme/dnsapis": { ok: true, hooks: [] }, + "wizard/certificate": { ok: true, domains: ["app.corp.lan"], actions: [], warnings: [] }, + }; + return { ok: true, status: 200, json: async () => bodies[p] ?? {}, text: async () => "" }; +}; + +const { E, openEditor, renderEntity } = await import(REPO + "/static/js/entities.js"); +const { openCertWizard } = await import(REPO + "/static/js/pages/certificates.js"); +const { CA_CHOICES } = await import(REPO + "/static/js/core.js"); + +let fail = 0; +const ok = (c, m) => { console.log((c ? " PASS " : " FAIL ") + m); if (!c) fail++; }; +const $ = s => document.querySelector(s); +const findButton = (root, label) => [...root.querySelectorAll("button")].find(b => b.textContent === label) || null; +/* hidden when its cell (or the label in front of it) is display:none */ +const shown = k => { let el = $("#f_" + k); if (!el) return false; + while (el.parentNode && !el.parentNode.classList.contains("frm")) el = el.parentNode; + const lab = $('label[for="f_' + k + '"]'); + return el.style.display !== "none" && (!lab || lab.style.display !== "none"); }; +const setSelect = (sel, v) => { sel.value = v; sel.dispatchEvent(new window.Event("change")); }; + +// -- "custom" is a choice, in both places -------------------------------------------- +ok(CA_CHOICES.includes("custom") && E["acme/accounts"].fields.find(f => f.k === "ca").o.includes("custom"), + "the account editor and the certificate wizard offer custom"); + +// -- the account editor ------------------------------------------------------------ +openEditor("acme/accounts"); +ok(!shown("server") && !shown("ca_bundle"), "a new account hides the address and CA certificate"); +setSelect($("#f_ca"), "custom"); +ok(shown("server") && shown("ca_bundle"), "picking custom shows them"); +setSelect($("#f_ca"), "zerossl"); +ok(!shown("server") && !shown("ca_bundle"), "picking a public CA hides them again"); + +openEditor("acme/accounts", ACCOUNTS[1]); +ok($("#f_ca").value === "custom" && shown("server") && $("#f_server").value === "https://a2c.corp.lan/directory" + && $("#f_ca_bundle").value.includes("BEGIN CERTIFICATE"), "editing a custom account shows its address and certificate"); +openEditor("acme/accounts", ACCOUNTS[0]); +ok(!shown("server"), "editing a Let's Encrypt account does not"); + +// -- the accounts list names a custom CA's address --------------------------------- +const box = document.createElement("div"); document.body.appendChild(box); +await renderEntity("acme/accounts", box); +const corpRow = [...box.querySelectorAll("tr")].find(tr => tr.textContent.includes("corp")); +ok(!!corpRow && corpRow.textContent.includes("https://a2c.corp.lan/directory"), "the list shows a custom CA's address"); + +// -- the certificate wizard -------------------------------------------------------- +await openCertWizard(); +const acc = $("#f_account_id"); +ok([...acc.querySelectorAll("option")].some(o => o.textContent === "corp (a2c.corp.lan)"), + "an existing custom account is offered by its server's host name"); +setSelect(acc, "__new__"); +ok(shown("acc_ca") && !shown("acc_server") && !shown("acc_ca_bundle"), "a new account: the address waits for custom"); +setSelect($("#f_acc_ca"), "custom"); +ok(shown("acc_server") && shown("acc_ca_bundle"), "custom shows the address and the CA certificate"); +setSelect(acc, "a1"); +ok(!shown("acc_server") && !shown("acc_ca"), "an existing account hides the new-account rows again"); +setSelect(acc, "__new__"); +$("#f_domains").value = "app.corp.lan"; +$("#f_acc_name").value = "corp2"; +$("#f_acc_server").value = "https://ca2.corp.lan/acme/directory"; +$("#f_acc_ca_bundle").value = "-----BEGIN CERTIFICATE-----\nBB\n-----END CERTIFICATE-----"; +$("#f_issue").checked = false; +await findButton(document, "Create").onclick(); +ok(path === "wizard/certificate" && sent && sent.account.ca === "custom" + && sent.account.server === "https://ca2.corp.lan/acme/directory" && sent.account.ca_bundle.includes("BB"), + "the new custom account is sent with its address and certificate"); +setSelect($("#f_acc_ca"), "letsencrypt"); +sent = null; +await findButton(document, "Preview").onclick(); +ok(sent && sent.account.ca === "letsencrypt" && sent.account.server === "" && sent.account.ca_bundle === "", + "a public CA is sent without the address and certificate typed for custom"); + +console.log(fail ? "\n" + fail + " failed" : "\nall passed"); +process.exit(fail ? 1 : 0); diff --git a/tools/uitests/run-all.sh b/tools/uitests/run-all.sh index 22e32a1..dde8318 100755 --- a/tools/uitests/run-all.sh +++ b/tools/uitests/run-all.sh @@ -2,7 +2,7 @@ # Every front-end check, in the order that fails fastest. set -e cd "$(dirname "$0")/../.." -for t in undefined-names module-graph navigate routing pages certificates-banner managed-objects dialogs recipes table-sorting table-sorting-dom account-gear webui-blurb admin-sync overwrite config-diff update-peers sparkline stats-refresh theme access mobile history localtime login-2fa maintenance oauth ratelimit proxy-protocol language wizard-sections beta-channel large-configs lists-errorpages rules-wizard condition-wizard crowdsec npm-parity mcp i18n; do +for t in undefined-names module-graph navigate routing pages certificates-banner managed-objects dialogs recipes table-sorting table-sorting-dom account-gear webui-blurb admin-sync overwrite config-diff update-peers sparkline stats-refresh theme access mobile history localtime login-2fa maintenance oauth ratelimit proxy-protocol language wizard-sections beta-channel large-configs lists-errorpages rules-wizard condition-wizard crowdsec npm-parity custom-ca mcp i18n; do printf '\n== %s ==\n' "$t" node "tools/uitests/$t.mjs" done From fb39fb3ee9144f241622915efef6eb88c9c92750 Mon Sep 17 00:00:00 2001 From: Alex Van de Putte Date: Mon, 9 Nov 2026 12:37:35 -0500 Subject: [PATCH 02/13] 2.2.0: ACME accounts can use your own CA, such as acme2certifier in front of AD CS --- README.md | 2 +- VERSION | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 07a67fe..695881e 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ copying it across. ```bash # from a package: .deb and .rpm on every release (Debian, Ubuntu, RHEL, Fedora) -sudo apt-get install -y ./haproxy-manager_2.1.0_all.deb +sudo apt-get install -y ./haproxy-manager_2.2.0_all.deb # or the install script, on any Debian-based server curl -fsSL https://raw.githubusercontent.com/avandeputte/haproxy-manager/main/install.sh | sudo bash diff --git a/VERSION b/VERSION index 7ec1d6d..ccbccc3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.1.0 +2.2.0 From ee0c6f93a4061ba41a2d8fb2b9e6fca782f0ab17 Mon Sep 17 00:00:00 2001 From: Alex Van de Putte Date: Mon, 9 Nov 2026 12:45:45 -0500 Subject: [PATCH 03/13] 2.2.0: the CA list follows acme.sh, and renewal follows acme.sh's plan Buypass stopped issuing on 31 October 2025 and left acme.sh in 3.1.2, which then took the name for a directory URL; it is refused now, with why. Google's test CA, SSL.com and Actalis are added, and the CAs that need EAB refuse an account without it. Between the renewal rounds, every 10-minute tick renews a certificate whose acme.sh plan has come due, so a CA of your own that issues for a day (step-ca by default) is not missed by a daily round. --- ham/acme.py | 70 ++++++++++++++++++++++++++++++--- static/js/core.js | 2 +- static/js/entities.js | 6 ++- static/js/i18n/da.js | 4 +- static/js/i18n/de.js | 4 +- static/js/i18n/es.js | 4 +- static/js/i18n/fi.js | 4 +- static/js/i18n/fr.js | 4 +- static/js/i18n/it.js | 4 +- static/js/i18n/nb.js | 4 +- static/js/i18n/nl.js | 4 +- static/js/i18n/pt.js | 4 +- static/js/i18n/sv.js | 4 +- static/js/pages/certificates.js | 2 +- tools/test-custom-ca.py | 54 +++++++++++++++++++++++++ 15 files changed, 154 insertions(+), 20 deletions(-) diff --git a/ham/acme.py b/ham/acme.py index 780ba00..afc8a6b 100644 --- a/ham/acme.py +++ b/ham/acme.py @@ -28,13 +28,21 @@ # -------------------------------------------------------------------------- +# The public CAs, by the short names acme.sh knows (its CA_NAMES). acme.sh +# takes a name it does not know for a directory URL, so a CA it drops has to +# go here too: Buypass stopped issuing on 31 October 2025 and left acme.sh in +# 3.1.2. The ones in EAB_REQUIRED will not register an account without it. CA_SERVERS = { "letsencrypt": "letsencrypt", "letsencrypt_test": "letsencrypt_test", "zerossl": "zerossl", - "buypass": "buypass", "google": "google", + "google_test": "google_test", + "sslcom": "sslcom", + "actalis": "actalis", } +EAB_REQUIRED = {"google": "Google", "google_test": "Google", "sslcom": "SSL.com", + "actalis": "Actalis"} KEYLEN = {"ec-256": "ec-256", "ec-384": "ec-384", "rsa-2048": "2048", "rsa-4096": "4096"} @@ -77,7 +85,16 @@ def acme_run(args, env_extra=None): def check_account(acc): """What an ACME account refuses before it is stored. Raises ValueError with why.""" - if acc.get("ca") != "custom": + ca = acc.get("ca") or "letsencrypt" + if ca == "buypass": + raise ValueError("Buypass stopped issuing certificates on 31 October 2025: " + "choose another certificate authority for this account") + if ca != "custom" and ca not in CA_SERVERS: + raise ValueError("unknown certificate authority %r" % ca) + if ca in EAB_REQUIRED and not (acc.get("eab_kid") or "").strip(): + raise ValueError("%s needs External Account Binding: fill in the EAB key ID and " + "HMAC key from your account with them" % EAB_REQUIRED[ca]) + if ca != "custom": return url = (acc.get("server") or "").strip() if not re.match(r"^https?://[^\s/?#]+(/\S*)?$", url): @@ -394,6 +411,45 @@ def renewal_runs_here(cfg): _last_renew = time.time() +RETRY_AFTER = 3600 + + +def renewal_due(cfg, cert, now=None): + """Whether acme.sh's own schedule says this certificate is due, between rounds. + + The rounds run every renew_hours, a day by default. A CA of your own may + issue certificates for a day (step-ca does by default), which acme.sh + plans to renew an hour before they end, so a daily round can miss that + hour; the 10-minute tick reads the plan instead. A failed attempt is not + repeated for an hour, so a CA that is down is not asked every 10 minutes. + """ + now = now or time.time() + doms = parse_domains(cert) + if not doms: + return False + nxt = None + for sub in (doms[0] + "_ecc", doms[0]): + try: + text = (Path(ACME_HOME) / sub / (doms[0] + ".conf")).read_text() + except OSError: + continue + m = re.search(r"^Le_NextRenewTime='?(\d+)'?\s*$", text, re.M) + if m: + nxt = int(m.group(1)) + break + if nxt is None or nxt > now: + return False + last = (cfg.get("_meta", {}).get("issue_log") or {}).get(cert.get("id")) or {} + if last and not last.get("ok"): + try: + at = datetime.fromisoformat(last["time"]).timestamp() + except (KeyError, TypeError, ValueError): + at = 0 + if now - at < RETRY_AFTER: + return False + return True + + def _renew_loop(): global _last_renew while True: @@ -413,11 +469,13 @@ def _renew_loop(): save_config(cur) continue interval = max(1, int(st.get("renew_hours") or 24)) * 3600 - if time.time() - _last_renew >= interval: + full = time.time() - _last_renew >= interval + if full: _last_renew = time.time() - for cert in merged(cfg)["acme"]["certificates"]: - if cert.get("auto_renew", True) and not is_custom(cert): - acme_issue(cfg, cert) + for cert in merged(cfg)["acme"]["certificates"]: + if cert.get("auto_renew", True) and not is_custom(cert) \ + and (full or renewal_due(cfg, cert)): + acme_issue(cfg, cert) except Exception: # The loop must survive anything, but not silently: a renewal # loop that dies quietly is discovered as an expired certificate. diff --git a/static/js/core.js b/static/js/core.js index e68329d..2884fcf 100644 --- a/static/js/core.js +++ b/static/js/core.js @@ -64,7 +64,7 @@ $("#ovl").addEventListener("click",e=>{if(e.target.id==="ovl")closeDlg();}); document.addEventListener("keydown",e=>{if(e.key==="Escape")closeDlg();}); /* "custom" is any ACME server of your own, by its directory URL: acme2certifier in front of Active Directory Certificate Services, step-ca, Vault. */ -export const CA_CHOICES=["letsencrypt","letsencrypt_test","zerossl","buypass","google","custom"]; +export const CA_CHOICES=["letsencrypt","letsencrypt_test","zerossl","google","google_test","sslcom","actalis","custom"]; export function btn(label,cls,fn){const b=document.createElement("button");b.className="btn "+(cls||"");b.textContent=label;b.onclick=fn;return b;} export function showText(title,text){const p=document.createElement("pre");p.textContent=text;openDlg(title,p,[btn(t("Close"),"",closeDlg)]);} diff --git a/static/js/entities.js b/static/js/entities.js index 805ec54..338fa8e 100644 --- a/static/js/entities.js +++ b/static/js/entities.js @@ -256,7 +256,9 @@ export const E={ "acme/accounts":{title:"ACME Accounts",one:"ACME Account",add:"Add account", intro:"ACME accounts used to request certificates.", cols:[["name","Name"],["email","E-mail"], - ["ca","CA",r=>r.ca==="custom"?"custom
"+esc(r.server||"")+"
":esc(r.ca||"letsencrypt")]], + ["ca","CA",r=>r.ca==="custom"?"custom
"+esc(r.server||"")+"
" + :r.ca==="buypass"?"buypass
"+t("Buypass stopped issuing on 31 October 2025: choose another CA")+"
" + :esc(r.ca||"letsencrypt")]], editorExtra:customCaRows, fields:[ {k:"name",l:"Name",t:"text"}, @@ -264,7 +266,7 @@ export const E={ {k:"ca",l:"Certificate authority",t:"select",o:CA_CHOICES,d:"letsencrypt",h:"Use letsencrypt_test while setting things up to avoid rate limits"}, {k:"server",l:"ACME directory URL",t:"text",h:"Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault"}, {k:"ca_bundle",l:"CA certificate to trust",t:"textarea",h:"Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already"}, - {k:"eab_kid",l:"EAB key ID",t:"text",h:"Only for ZeroSSL, Google, or your own CA if it asks for one"}, + {k:"eab_kid",l:"EAB key ID",t:"text",h:"Google, SSL.com and Actalis need one, from your account with them, and so may your own CA. ZeroSSL gets its own from the e-mail address."}, {k:"eab_hmac",l:"EAB HMAC key",t:"password"}]}, "acme/challenges":{title:"Challenge Types",one:"Challenge Type",add:"Add challenge type", diff --git a/static/js/i18n/da.js b/static/js/i18n/da.js index 97d4afe..e03af49 100644 --- a/static/js/i18n/da.js +++ b/static/js/i18n/da.js @@ -505,10 +505,12 @@ export default { "Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault": "Din egen ACME-servers mappe, f.eks. https://ca.example.lan/directory -- for eksempel acme2certifier foran Active Directory Certificate Services, step-ca eller Vault", "CA certificate to trust": "CA-certifikat der skal stoles på", "Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already": "Valgfrit. PEM-certifikatet for den CA, der har underskrevet ACME-serverens eget HTTPS-certifikat, hvis denne node ikke allerede stoler på det", -"Only for ZeroSSL, Google, or your own CA if it asks for one": "Kun til ZeroSSL, Google eller din egen CA, hvis den beder om det", "a custom certificate authority needs the address of its ACME directory, such as https://ca.example.lan/directory": "en egen certifikatmyndighed kræver adressen på dens ACME-mappe, f.eks. https://ca.example.lan/directory", "the CA certificate to trust must be one or more PEM certificates (-----BEGIN CERTIFICATE-----)": "CA-certifikatet der skal stoles på skal være et eller flere PEM-certifikater (-----BEGIN CERTIFICATE-----)", "the CA certificate to trust is not a certificate openssl can read": "CA-certifikatet der skal stoles på er ikke et certifikat, openssl kan læse", +"Buypass stopped issuing on 31 October 2025: choose another CA": "Buypass stoppede udstedelsen den 31. oktober 2025: vælg en anden CA", +"Google, SSL.com and Actalis need one, from your account with them, and so may your own CA. ZeroSSL gets its own from the e-mail address.": "Google, SSL.com og Actalis kræver en, fra din konto hos dem, og det kan din egen CA også. ZeroSSL henter sin egen ud fra e-mailadressen.", +"Buypass stopped issuing certificates on 31 October 2025: choose another certificate authority for this account": "Buypass stoppede med at udstede certifikater den 31. oktober 2025: vælg en anden certifikatmyndighed til denne konto", "Load from file": "Indlæs fra fil", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Læser en lokal .html-fil ind i feltet ovenfor. Intet uploades: siden gemmes sammen med konfigurationen.", "Value": "Værdi", diff --git a/static/js/i18n/de.js b/static/js/i18n/de.js index bf0f42e..9428117 100644 --- a/static/js/i18n/de.js +++ b/static/js/i18n/de.js @@ -505,10 +505,12 @@ export default { "Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault": "Das Verzeichnis Ihres eigenen ACME-Servers, etwa https://ca.example.lan/directory -- zum Beispiel acme2certifier vor den Active Directory Certificate Services, step-ca oder Vault", "CA certificate to trust": "Zu vertrauendes CA-Zertifikat", "Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already": "Optional. Das PEM-Zertifikat der Zertifizierungsstelle, die das HTTPS-Zertifikat des ACME-Servers signiert hat, falls dieser Knoten ihr nicht schon vertraut", -"Only for ZeroSSL, Google, or your own CA if it asks for one": "Nur für ZeroSSL, Google oder Ihre eigene Zertifizierungsstelle, falls sie danach fragt", "a custom certificate authority needs the address of its ACME directory, such as https://ca.example.lan/directory": "eine eigene Zertifizierungsstelle braucht die Adresse ihres ACME-Verzeichnisses, etwa https://ca.example.lan/directory", "the CA certificate to trust must be one or more PEM certificates (-----BEGIN CERTIFICATE-----)": "das zu vertrauende CA-Zertifikat muss aus einem oder mehreren PEM-Zertifikaten bestehen (-----BEGIN CERTIFICATE-----)", "the CA certificate to trust is not a certificate openssl can read": "das zu vertrauende CA-Zertifikat ist kein Zertifikat, das openssl lesen kann", +"Buypass stopped issuing on 31 October 2025: choose another CA": "Buypass stellt seit dem 31. Oktober 2025 keine Zertifikate mehr aus: eine andere Zertifizierungsstelle wählen", +"Google, SSL.com and Actalis need one, from your account with them, and so may your own CA. ZeroSSL gets its own from the e-mail address.": "Google, SSL.com und Actalis brauchen ihn, aus Ihrem Konto bei ihnen, ebenso vielleicht Ihre eigene Zertifizierungsstelle. ZeroSSL holt sich seinen eigenen über die E-Mail-Adresse.", +"Buypass stopped issuing certificates on 31 October 2025: choose another certificate authority for this account": "Buypass stellt seit dem 31. Oktober 2025 keine Zertifikate mehr aus: für dieses Konto eine andere Zertifizierungsstelle wählen", "Load from file": "Aus Datei laden", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Liest eine lokale .html-Datei in das Feld oben. Nichts wird hochgeladen: die Seite wird mit der Konfiguration gespeichert.", "Value": "Wert", diff --git a/static/js/i18n/es.js b/static/js/i18n/es.js index 7559f1d..5d8aada 100644 --- a/static/js/i18n/es.js +++ b/static/js/i18n/es.js @@ -505,10 +505,12 @@ export default { "Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault": "El directorio de tu propio servidor ACME, como https://ca.example.lan/directory -- por ejemplo acme2certifier delante de Active Directory Certificate Services, step-ca o Vault", "CA certificate to trust": "Certificado de CA de confianza", "Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already": "Opcional. El certificado PEM de la CA que firmó el certificado HTTPS del propio servidor ACME, si este nodo no confía ya en ella", -"Only for ZeroSSL, Google, or your own CA if it asks for one": "Solo para ZeroSSL, Google o tu propia CA si lo pide", "a custom certificate authority needs the address of its ACME directory, such as https://ca.example.lan/directory": "una autoridad de certificación propia necesita la dirección de su directorio ACME, como https://ca.example.lan/directory", "the CA certificate to trust must be one or more PEM certificates (-----BEGIN CERTIFICATE-----)": "el certificado de CA de confianza debe ser uno o más certificados PEM (-----BEGIN CERTIFICATE-----)", "the CA certificate to trust is not a certificate openssl can read": "el certificado de CA de confianza no es un certificado que openssl pueda leer", +"Buypass stopped issuing on 31 October 2025: choose another CA": "Buypass dejó de emitir el 31 de octubre de 2025: elige otra CA", +"Google, SSL.com and Actalis need one, from your account with them, and so may your own CA. ZeroSSL gets its own from the e-mail address.": "Google, SSL.com y Actalis la necesitan, de tu cuenta con ellos, y quizá también tu propia CA. ZeroSSL obtiene la suya a partir de la dirección de correo.", +"Buypass stopped issuing certificates on 31 October 2025: choose another certificate authority for this account": "Buypass dejó de emitir certificados el 31 de octubre de 2025: elige otra autoridad de certificación para esta cuenta", "Load from file": "Cargar desde archivo", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Lee un archivo .html local en el campo de arriba. No se sube nada: la página se guarda con la configuración.", "Value": "Valor", diff --git a/static/js/i18n/fi.js b/static/js/i18n/fi.js index 8177b12..92b1ca3 100644 --- a/static/js/i18n/fi.js +++ b/static/js/i18n/fi.js @@ -505,10 +505,12 @@ export default { "Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault": "Oman ACME-palvelimesi hakemisto, esimerkiksi https://ca.example.lan/directory -- vaikkapa acme2certifier Active Directory Certificate Servicesin edessä, step-ca tai Vault", "CA certificate to trust": "Luotettava CA-varmenne", "Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already": "Valinnainen. Sen varmentajan PEM-varmenne, joka on allekirjoittanut ACME-palvelimen oman HTTPS-varmenteen, jos tämä solmu ei vielä luota siihen", -"Only for ZeroSSL, Google, or your own CA if it asks for one": "Vain ZeroSSL:lle, Googlelle tai omalle varmentajallesi, jos se pyytää sitä", "a custom certificate authority needs the address of its ACME directory, such as https://ca.example.lan/directory": "oma varmentaja tarvitsee ACME-hakemistonsa osoitteen, esimerkiksi https://ca.example.lan/directory", "the CA certificate to trust must be one or more PEM certificates (-----BEGIN CERTIFICATE-----)": "luotettavan CA-varmenteen on oltava yksi tai useampi PEM-varmenne (-----BEGIN CERTIFICATE-----)", "the CA certificate to trust is not a certificate openssl can read": "luotettava CA-varmenne ei ole varmenne, jota openssl osaa lukea", +"Buypass stopped issuing on 31 October 2025: choose another CA": "Buypass lopetti myöntämisen 31. lokakuuta 2025: valitse toinen varmentaja", +"Google, SSL.com and Actalis need one, from your account with them, and so may your own CA. ZeroSSL gets its own from the e-mail address.": "Google, SSL.com ja Actalis tarvitsevat sen, tililtäsi niiden palvelussa, ja niin voi tarvita myös oma varmentajasi. ZeroSSL hakee omansa sähköpostiosoitteen perusteella.", +"Buypass stopped issuing certificates on 31 October 2025: choose another certificate authority for this account": "Buypass lopetti varmenteiden myöntämisen 31. lokakuuta 2025: valitse tälle tilille toinen varmentaja", "Load from file": "Lataa tiedostosta", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Lukee paikallisen .html-tiedoston yllä olevaan kenttään. Mitään ei ladata palvelimelle: sivu tallennetaan asetusten mukana.", "Value": "Arvo", diff --git a/static/js/i18n/fr.js b/static/js/i18n/fr.js index a4eb12c..c139c5e 100644 --- a/static/js/i18n/fr.js +++ b/static/js/i18n/fr.js @@ -505,10 +505,12 @@ export default { "Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault": "Le répertoire de votre propre serveur ACME, par exemple https://ca.example.lan/directory -- comme acme2certifier devant Active Directory Certificate Services, step-ca ou Vault", "CA certificate to trust": "Certificat d'AC de confiance", "Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already": "Facultatif. Le certificat PEM de l'AC qui a signé le certificat HTTPS du serveur ACME lui-même, si ce nœud ne lui fait pas déjà confiance", -"Only for ZeroSSL, Google, or your own CA if it asks for one": "Uniquement pour ZeroSSL, Google ou votre propre AC si elle le demande", "a custom certificate authority needs the address of its ACME directory, such as https://ca.example.lan/directory": "une autorité de certification personnalisée a besoin de l'adresse de son répertoire ACME, par exemple https://ca.example.lan/directory", "the CA certificate to trust must be one or more PEM certificates (-----BEGIN CERTIFICATE-----)": "le certificat d'AC de confiance doit être un ou plusieurs certificats PEM (-----BEGIN CERTIFICATE-----)", "the CA certificate to trust is not a certificate openssl can read": "le certificat d'AC de confiance n'est pas un certificat lisible par openssl", +"Buypass stopped issuing on 31 October 2025: choose another CA": "Buypass a cessé d'émettre le 31 octobre 2025 : choisissez une autre AC", +"Google, SSL.com and Actalis need one, from your account with them, and so may your own CA. ZeroSSL gets its own from the e-mail address.": "Google, SSL.com et Actalis en exigent une, issue de votre compte chez eux, et votre propre AC peut-être aussi. ZeroSSL obtient la sienne à partir de l'adresse e-mail.", +"Buypass stopped issuing certificates on 31 October 2025: choose another certificate authority for this account": "Buypass a cessé d'émettre des certificats le 31 octobre 2025 : choisissez une autre autorité de certification pour ce compte", "Load from file": "Charger depuis un fichier", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Lit un fichier .html local dans le champ ci-dessus. Rien n'est téléversé : la page est enregistrée avec la configuration.", "Value": "Valeur", diff --git a/static/js/i18n/it.js b/static/js/i18n/it.js index 44f97dd..831f32d 100644 --- a/static/js/i18n/it.js +++ b/static/js/i18n/it.js @@ -505,10 +505,12 @@ export default { "Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault": "La directory del tuo server ACME, ad esempio https://ca.example.lan/directory -- per esempio acme2certifier davanti ad Active Directory Certificate Services, step-ca o Vault", "CA certificate to trust": "Certificato CA da considerare attendibile", "Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already": "Facoltativo. Il certificato PEM della CA che ha firmato il certificato HTTPS del server ACME stesso, se questo nodo non la considera già attendibile", -"Only for ZeroSSL, Google, or your own CA if it asks for one": "Solo per ZeroSSL, Google o la tua CA se lo richiede", "a custom certificate authority needs the address of its ACME directory, such as https://ca.example.lan/directory": "un'autorità di certificazione personalizzata richiede l'indirizzo della sua directory ACME, ad esempio https://ca.example.lan/directory", "the CA certificate to trust must be one or more PEM certificates (-----BEGIN CERTIFICATE-----)": "il certificato CA da considerare attendibile deve essere uno o più certificati PEM (-----BEGIN CERTIFICATE-----)", "the CA certificate to trust is not a certificate openssl can read": "il certificato CA da considerare attendibile non è un certificato leggibile da openssl", +"Buypass stopped issuing on 31 October 2025: choose another CA": "Buypass ha smesso di emettere il 31 ottobre 2025: scegli un'altra CA", +"Google, SSL.com and Actalis need one, from your account with them, and so may your own CA. ZeroSSL gets its own from the e-mail address.": "Google, SSL.com e Actalis la richiedono, dal tuo account presso di loro, e forse anche la tua CA. ZeroSSL ottiene la propria dall'indirizzo e-mail.", +"Buypass stopped issuing certificates on 31 October 2025: choose another certificate authority for this account": "Buypass ha smesso di emettere certificati il 31 ottobre 2025: scegli un'altra autorità di certificazione per questo account", "Load from file": "Carica da file", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Legge un file .html locale nel campo qui sopra. Nulla viene caricato sul server: la pagina viene salvata con la configurazione.", "Value": "Valore", diff --git a/static/js/i18n/nb.js b/static/js/i18n/nb.js index 6e3fd33..b137f49 100644 --- a/static/js/i18n/nb.js +++ b/static/js/i18n/nb.js @@ -505,10 +505,12 @@ export default { "Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault": "Katalogen til din egen ACME-server, for eksempel https://ca.example.lan/directory -- som acme2certifier foran Active Directory Certificate Services, step-ca eller Vault", "CA certificate to trust": "CA-sertifikat som skal stoles på", "Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already": "Valgfritt. PEM-sertifikatet til sertifikatutstederen som signerte ACME-serverens eget HTTPS-sertifikat, hvis denne noden ikke allerede stoler på den", -"Only for ZeroSSL, Google, or your own CA if it asks for one": "Bare for ZeroSSL, Google eller din egen sertifikatutsteder hvis den ber om det", "a custom certificate authority needs the address of its ACME directory, such as https://ca.example.lan/directory": "en egen sertifikatutsteder trenger adressen til ACME-katalogen sin, for eksempel https://ca.example.lan/directory", "the CA certificate to trust must be one or more PEM certificates (-----BEGIN CERTIFICATE-----)": "CA-sertifikatet som skal stoles på må være ett eller flere PEM-sertifikater (-----BEGIN CERTIFICATE-----)", "the CA certificate to trust is not a certificate openssl can read": "CA-sertifikatet som skal stoles på er ikke et sertifikat openssl kan lese", +"Buypass stopped issuing on 31 October 2025: choose another CA": "Buypass sluttet å utstede 31. oktober 2025: velg en annen sertifikatutsteder", +"Google, SSL.com and Actalis need one, from your account with them, and so may your own CA. ZeroSSL gets its own from the e-mail address.": "Google, SSL.com og Actalis krever en, fra kontoen din hos dem, og det kan også din egen sertifikatutsteder. ZeroSSL henter sin egen ut fra e-postadressen.", +"Buypass stopped issuing certificates on 31 October 2025: choose another certificate authority for this account": "Buypass sluttet å utstede sertifikater 31. oktober 2025: velg en annen sertifikatutsteder for denne kontoen", "Load from file": "Last inn fra fil", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Leser en lokal .html-fil inn i feltet ovenfor. Ingenting lastes opp: siden lagres sammen med konfigurasjonen.", "Value": "Verdi", diff --git a/static/js/i18n/nl.js b/static/js/i18n/nl.js index e9d7338..8628ba9 100644 --- a/static/js/i18n/nl.js +++ b/static/js/i18n/nl.js @@ -505,10 +505,12 @@ export default { "Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault": "De directory van je eigen ACME-server, zoals https://ca.example.lan/directory -- bijvoorbeeld acme2certifier voor Active Directory Certificate Services, step-ca of Vault", "CA certificate to trust": "Te vertrouwen CA-certificaat", "Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already": "Optioneel. Het PEM-certificaat van de CA die het HTTPS-certificaat van de ACME-server zelf heeft ondertekend, als deze node die nog niet vertrouwt", -"Only for ZeroSSL, Google, or your own CA if it asks for one": "Alleen voor ZeroSSL, Google of je eigen CA als die erom vraagt", "a custom certificate authority needs the address of its ACME directory, such as https://ca.example.lan/directory": "een eigen certificaatautoriteit heeft het adres van haar ACME-directory nodig, zoals https://ca.example.lan/directory", "the CA certificate to trust must be one or more PEM certificates (-----BEGIN CERTIFICATE-----)": "het te vertrouwen CA-certificaat moet uit een of meer PEM-certificaten bestaan (-----BEGIN CERTIFICATE-----)", "the CA certificate to trust is not a certificate openssl can read": "het te vertrouwen CA-certificaat is geen certificaat dat openssl kan lezen", +"Buypass stopped issuing on 31 October 2025: choose another CA": "Buypass is op 31 oktober 2025 gestopt met uitgeven: kies een andere CA", +"Google, SSL.com and Actalis need one, from your account with them, and so may your own CA. ZeroSSL gets its own from the e-mail address.": "Google, SSL.com en Actalis hebben er een nodig, uit je account bij hen, en je eigen CA misschien ook. ZeroSSL haalt er zelf een op via het e-mailadres.", +"Buypass stopped issuing certificates on 31 October 2025: choose another certificate authority for this account": "Buypass is op 31 oktober 2025 gestopt met het uitgeven van certificaten: kies een andere certificaatautoriteit voor dit account", "Load from file": "Uit bestand laden", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Leest een lokaal .html-bestand in het veld hierboven. Er wordt niets geüpload: de pagina wordt met de configuratie opgeslagen.", "Value": "Waarde", diff --git a/static/js/i18n/pt.js b/static/js/i18n/pt.js index 30b12b9..d80885d 100644 --- a/static/js/i18n/pt.js +++ b/static/js/i18n/pt.js @@ -505,10 +505,12 @@ export default { "Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault": "O diretório do seu próprio servidor ACME, como https://ca.example.lan/directory -- por exemplo acme2certifier à frente do Active Directory Certificate Services, step-ca ou Vault", "CA certificate to trust": "Certificado de CA confiável", "Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already": "Opcional. O certificado PEM da CA que assinou o certificado HTTPS do próprio servidor ACME, se este nó ainda não confiar nela", -"Only for ZeroSSL, Google, or your own CA if it asks for one": "Apenas para ZeroSSL, Google ou a sua própria CA, se ela pedir", "a custom certificate authority needs the address of its ACME directory, such as https://ca.example.lan/directory": "uma autoridade de certificação própria precisa do endereço do seu diretório ACME, como https://ca.example.lan/directory", "the CA certificate to trust must be one or more PEM certificates (-----BEGIN CERTIFICATE-----)": "o certificado de CA confiável deve ser um ou mais certificados PEM (-----BEGIN CERTIFICATE-----)", "the CA certificate to trust is not a certificate openssl can read": "o certificado de CA confiável não é um certificado que o openssl consiga ler", +"Buypass stopped issuing on 31 October 2025: choose another CA": "A Buypass deixou de emitir em 31 de outubro de 2025: escolha outra CA", +"Google, SSL.com and Actalis need one, from your account with them, and so may your own CA. ZeroSSL gets its own from the e-mail address.": "A Google, a SSL.com e a Actalis exigem uma, da sua conta com elas, e talvez também a sua própria CA. A ZeroSSL obtém a sua a partir do endereço de e-mail.", +"Buypass stopped issuing certificates on 31 October 2025: choose another certificate authority for this account": "A Buypass deixou de emitir certificados em 31 de outubro de 2025: escolha outra autoridade de certificação para esta conta", "Load from file": "Carregar de ficheiro", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Lê um ficheiro .html local para o campo acima. Nada é enviado: a página é guardada com a configuração.", "Value": "Valor", diff --git a/static/js/i18n/sv.js b/static/js/i18n/sv.js index 03ad612..fee9326 100644 --- a/static/js/i18n/sv.js +++ b/static/js/i18n/sv.js @@ -505,10 +505,12 @@ export default { "Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault": "Katalogen för din egen ACME-server, till exempel https://ca.example.lan/directory -- som acme2certifier framför Active Directory Certificate Services, step-ca eller Vault", "CA certificate to trust": "CA-certifikat att lita på", "Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already": "Valfritt. PEM-certifikatet för den certifikatutfärdare som signerade ACME-serverns eget HTTPS-certifikat, om den här noden inte redan litar på den", -"Only for ZeroSSL, Google, or your own CA if it asks for one": "Bara för ZeroSSL, Google eller din egen certifikatutfärdare om den ber om det", "a custom certificate authority needs the address of its ACME directory, such as https://ca.example.lan/directory": "en egen certifikatutfärdare behöver adressen till sin ACME-katalog, till exempel https://ca.example.lan/directory", "the CA certificate to trust must be one or more PEM certificates (-----BEGIN CERTIFICATE-----)": "CA-certifikatet att lita på måste vara ett eller flera PEM-certifikat (-----BEGIN CERTIFICATE-----)", "the CA certificate to trust is not a certificate openssl can read": "CA-certifikatet att lita på är inte ett certifikat som openssl kan läsa", +"Buypass stopped issuing on 31 October 2025: choose another CA": "Buypass slutade utfärda den 31 oktober 2025: välj en annan certifikatutfärdare", +"Google, SSL.com and Actalis need one, from your account with them, and so may your own CA. ZeroSSL gets its own from the e-mail address.": "Google, SSL.com och Actalis kräver en, från ditt konto hos dem, och det kan även din egen certifikatutfärdare. ZeroSSL hämtar en egen utifrån e-postadressen.", +"Buypass stopped issuing certificates on 31 October 2025: choose another certificate authority for this account": "Buypass slutade utfärda certifikat den 31 oktober 2025: välj en annan certifikatutfärdare för det här kontot", "Load from file": "Läs in från fil", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Läser in en lokal .html-fil i fältet ovan. Inget laddas upp: sidan sparas med konfigurationen.", "Value": "Värde", diff --git a/static/js/pages/certificates.js b/static/js/pages/certificates.js index 00c5a9c..461ce45 100644 --- a/static/js/pages/certificates.js +++ b/static/js/pages/certificates.js @@ -75,7 +75,7 @@ export const CERT_WIZ=[ h:"letsencrypt_test issues untrusted certificates but has no rate limits -- ideal while setting things up"}, {k:"acc_server",l:"ACME directory URL",t:"text",h:"Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault"}, {k:"acc_ca_bundle",l:"CA certificate to trust",t:"textarea",h:"Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already"}, - {k:"acc_eab_kid",l:"EAB key ID",t:"text",h:"Only for ZeroSSL, Google, or your own CA if it asks for one"}, + {k:"acc_eab_kid",l:"EAB key ID",t:"text",h:"Google, SSL.com and Actalis need one, from your account with them, and so may your own CA. ZeroSSL gets its own from the e-mail address."}, {k:"acc_eab_hmac",l:"EAB HMAC key",t:"password"}, {k:"challenge_id",l:"Challenge type",t:"select",o:[]}, {k:"ch_name",l:"New challenge name",t:"text"}, diff --git a/tools/test-custom-ca.py b/tools/test-custom-ca.py index 8494de1..21ace1e 100644 --- a/tools/test-custom-ca.py +++ b/tools/test-custom-ca.py @@ -106,6 +106,27 @@ def add_account(body, want): json=dict(accs["internal"], server="")) ok(r.status_code == 400, "editing away the address is refused (%d)" % r.status_code) +print("the public CAs") +_, err = add_account({"name": "old-buypass", "ca": "buypass"}, 400) +ok("31 October 2025" in err, "Buypass, which stopped issuing, is refused with why") +_, err = add_account({"name": "typo", "ca": "letsencrypt-prod"}, 400) +ok("unknown certificate authority" in err, "a CA name acme.sh does not know is refused") +for ca, who in (("google", "Google"), ("google_test", "Google"), ("sslcom", "SSL.com"), ("actalis", "Actalis")): + _, err = add_account({"name": "no-eab-" + ca, "ca": ca}, 400) + ok(who + " needs External Account Binding" in err, "%s without EAB says it needs it" % ca) +add_account({"name": "gts", "ca": "google", "eab_kid": "kid", "eab_hmac": "hmac"}, 200) +add_account({"name": "zero", "ca": "zerossl", "email": "z@example.com"}, 200) +for ca in ("google", "google_test", "sslcom", "actalis", "zerossl", "letsencrypt_test"): + ok(acme.server_args({"ca": ca}) == ["--server", ca], "%s is passed to acme.sh by its short name" % ca) +known = subprocess.run(["sh", "-c", "sed -n '/^CA_NAMES=/,/^\"/p' /opt/acme.sh/acme.sh"], + capture_output=True, text=True).stdout.lower() +if known: + names = {n for line in known.splitlines() for n in line.split(",")[1:]} + ok(set(acme.CA_SERVERS) <= names, "every CA offered is one this acme.sh knows: %s" + % sorted(set(acme.CA_SERVERS) - names)) +else: + print(" SKIP no acme.sh at /opt/acme.sh to compare the names with") + print("what acme.sh is given") ok(acme.server_args(accs["le"]) == ["--server", "letsencrypt"], "Let's Encrypt keeps its short name") ok(acme.server_args(accs["plain"]) == ["--server", "http://a2c.example.lan/directory"], @@ -208,6 +229,39 @@ def log_message(self, *a): ok(not res["ok"] and "ACME directory" in res["error"], "issuing with no address says why: %s" % res["error"][:80]) ok(len(pathlib.Path(RUNS).read_text().splitlines()) == before, "and acme.sh was not run") +print("renewing on acme.sh's schedule between rounds") +import time # noqa: E402 +from datetime import datetime, timezone # noqa: E402 +home = pathlib.Path(os.environ["HAM_ACME_HOME"]) +now = time.time() + + +def plan(main, when, ecc=True): + d = home / (main + ("_ecc" if ecc else "")) + d.mkdir(parents=True, exist_ok=True) + (d / (main + ".conf")).write_text("Le_Domain='%s'\nLe_NextRenewTime='%d'\nLe_NextRenewTimeStr='x'\n" % (main, when)) + + +def at(seconds_ago): + return datetime.fromtimestamp(now - seconds_ago, timezone.utc).isoformat(timespec="seconds") + + +plan("day.example.lan", now - 60) +plan("later.example.lan", now + 3600) +plan("rsa.example.lan", now - 60, ecc=False) +c = lambda cid, dom: {"id": cid, "domains": dom} # noqa: E731 +meta = {"_meta": {"issue_log": {}}} +ok(acme.renewal_due(meta, c("d", "day.example.lan"), now), "a certificate acme.sh planned to renew by now is due") +ok(not acme.renewal_due(meta, c("l", "later.example.lan"), now), "one planned for later is not") +ok(acme.renewal_due(meta, c("r", "rsa.example.lan"), now), "an RSA certificate's plan is read too (no _ecc folder)") +ok(not acme.renewal_due(meta, c("n", "never.example.lan"), now), "one acme.sh has no plan for is left to the rounds") +meta["_meta"]["issue_log"]["d"] = {"time": at(600), "ok": False} +ok(not acme.renewal_due(meta, c("d", "day.example.lan"), now), "a failure ten minutes ago waits an hour before trying again") +meta["_meta"]["issue_log"]["d"] = {"time": at(3700), "ok": False} +ok(acme.renewal_due(meta, c("d", "day.example.lan"), now), "after an hour it is tried again") +meta["_meta"]["issue_log"]["d"] = {"time": at(600), "ok": True} +ok(acme.renewal_due(meta, c("d", "day.example.lan"), now), "a success does not hold the next renewal back") + print("the certificate wizard") r = client.post("/api/wizard/certificate", headers=H, json={ "domains": "app.example.lan", "issue": False, "dry_run": True, From 2a05e62697b0ac6591382b75d7e2813d4f7aab08 Mon Sep 17 00:00:00 2001 From: Alex Van de Putte Date: Mon, 9 Nov 2026 17:35:31 -0500 Subject: [PATCH 04/13] 2.2.0: certificates from Active Directory Certificate Services, through the acme2certifier the node runs for them An ACME account can have Active Directory Certificate Services as its CA: the CA's server, name and template, the enrollment method (MS-ICPR or web enrollment), the authentication and a service account. At an issuance the node writes acme2certifier's configuration, starts it on localhost under waitress, points acme.sh at it and stops it again; the configuration, which holds the password, is removed afterwards. Nothing to supervise, no port opened, and several AD CS accounts take turns. Any other acme2certifier setting goes into the account's extra settings, acme_srv.cfg lines laid over the generated ones; the configuration view shows the result with the password masked, and the connection test asks the CA for a connection with the account as typed. The image carries acme2certifier (its GitHub release .deb) and Impacket, without Impacket's example tools; a node installed otherwise fetches it at the first issuance or from the Certificates page. The weekly upstream check follows its releases too. --- .github/workflows/upstream.yml | 36 +- Dockerfile | 17 + README.md | 3 +- app.py | 3 +- ham/FILES | 1 + ham/__init__.py | 3 +- ham/acme.py | 33 +- ham/adcs.py | 578 ++++++++++++++++++++++++++++++++ ham/base.py | 7 + ham/dnsapi.py | 10 +- static/js/core.js | 22 +- static/js/entities.js | 8 +- static/js/i18n/da.js | 35 ++ static/js/i18n/de.js | 35 ++ static/js/i18n/es.js | 35 ++ static/js/i18n/fi.js | 35 ++ static/js/i18n/fr.js | 35 ++ static/js/i18n/it.js | 35 ++ static/js/i18n/nb.js | 35 ++ static/js/i18n/nl.js | 35 ++ static/js/i18n/pt.js | 35 ++ static/js/i18n/sv.js | 35 ++ static/js/pages/certificates.js | 126 +++++-- tools/check-docs.py | 10 + tools/test-adcs.py | 303 +++++++++++++++++ tools/uitests/adcs.mjs | 188 +++++++++++ tools/uitests/run-all.sh | 2 +- 27 files changed, 1654 insertions(+), 46 deletions(-) create mode 100644 ham/adcs.py create mode 100644 tools/test-adcs.py create mode 100644 tools/uitests/adcs.mjs diff --git a/.github/workflows/upstream.yml b/.github/workflows/upstream.yml index 6637c65..63149b1 100644 --- a/.github/workflows/upstream.yml +++ b/.github/workflows/upstream.yml @@ -1,12 +1,14 @@ name: Check upstream versions -# The container image pins two things that move on their own schedule: the -# HAProxy branch it installs from haproxy.debian.net (ARG HAPROXY_BRANCH) and -# the acme.sh release it and install.sh carry (ARG ACME_VERSION). Debian's own -# packages need no watching -- the weekly image rebuild picks them up -- but -# a new HAProxy LTS or an acme.sh release would otherwise go unnoticed. +# The container image pins three things that move on their own schedule: the +# HAProxy branch it installs from haproxy.debian.net (ARG HAPROXY_BRANCH), the +# acme.sh release it and install.sh carry (ARG ACME_VERSION), and the +# acme2certifier release it and ham/adcs.py carry (ARG A2C_VERSION). Debian's +# own packages need no watching -- the weekly image rebuild picks them up -- +# but a new HAProxy LTS or an acme.sh or acme2certifier release would +# otherwise go unnoticed. # -# Once a week this compares the pins with what is out there and, when either +# Once a week this compares the pins with what is out there and, when any # is behind, edits them and opens a pull request (or refreshes the one it # already opened). The pull request builds and starts the image, so merging # it is the only step left for a person. @@ -33,6 +35,7 @@ jobs: set -eu have_haproxy=$(grep -oP '^ARG HAPROXY_BRANCH=\K[0-9.]+' Dockerfile) have_acme=$(grep -oP '^ARG ACME_VERSION=\K[0-9.]+' Dockerfile) + have_a2c=$(grep -oP '^ARG A2C_VERSION=\K[0-9.]+' Dockerfile) codename=$(grep -oP '^FROM debian:\K[a-z]+' Dockerfile) # the newest branch haproxy.debian.net publishes for this Debian @@ -40,14 +43,18 @@ jobs: | grep -oE "${codename}-backports-[0-9]+\.[0-9]+" | sed 's/.*-//' | sort -V | tail -1) # the newest acme.sh release (releases, not tags: a tag may be a draft) want_acme=$(gh api repos/acmesh-official/acme.sh/releases/latest --jq .tag_name) + # the newest acme2certifier release, which carries the .deb the image installs + want_a2c=$(gh api repos/grindsa/acme2certifier/releases/latest --jq .tag_name) - echo "HAProxy branch: pinned $have_haproxy, newest $want_haproxy" - echo "acme.sh: pinned $have_acme, newest $want_acme" - test -n "$want_haproxy" && test -n "$want_acme" + echo "HAProxy branch: pinned $have_haproxy, newest $want_haproxy" + echo "acme.sh: pinned $have_acme, newest $want_acme" + echo "acme2certifier: pinned $have_a2c, newest $want_a2c" + test -n "$want_haproxy" && test -n "$want_acme" && test -n "$want_a2c" { echo "have_haproxy=$have_haproxy"; echo "want_haproxy=$want_haproxy" echo "have_acme=$have_acme"; echo "want_acme=$want_acme" - if [ "$have_haproxy" != "$want_haproxy" ] || [ "$have_acme" != "$want_acme" ]; then + echo "have_a2c=$have_a2c"; echo "want_a2c=$want_a2c" + if [ "$have_haproxy" != "$want_haproxy" ] || [ "$have_acme" != "$want_acme" ] || [ "$have_a2c" != "$want_a2c" ]; then echo "behind=true" else echo "behind=false" @@ -62,20 +69,25 @@ jobs: WANT_HAPROXY: ${{ steps.upstream.outputs.want_haproxy }} HAVE_ACME: ${{ steps.upstream.outputs.have_acme }} WANT_ACME: ${{ steps.upstream.outputs.want_acme }} + HAVE_A2C: ${{ steps.upstream.outputs.have_a2c }} + WANT_A2C: ${{ steps.upstream.outputs.want_a2c }} run: | set -eu sed -i "s/^ARG HAPROXY_BRANCH=.*/ARG HAPROXY_BRANCH=$WANT_HAPROXY/" Dockerfile sed -i "s/^ARG ACME_VERSION=.*/ARG ACME_VERSION=$WANT_ACME/" Dockerfile sed -i "s/^ACME_VERSION=\"\${HAM_ACME_VERSION:-[0-9.]*}\"/ACME_VERSION=\"\${HAM_ACME_VERSION:-$WANT_ACME}\"/" install.sh + sed -i "s/^ARG A2C_VERSION=.*/ARG A2C_VERSION=$WANT_A2C/" Dockerfile + sed -i "s/^A2C_VERSION = \"[0-9.]*\"/A2C_VERSION = \"$WANT_A2C\"/" ham/adcs.py git diff --stat lines="" [ "$HAVE_HAPROXY" != "$WANT_HAPROXY" ] && lines="$lines- HAProxy branch $HAVE_HAPROXY -> $WANT_HAPROXY (haproxy.debian.net)\n" [ "$HAVE_ACME" != "$WANT_ACME" ] && lines="$lines- acme.sh $HAVE_ACME -> $WANT_ACME, in the image and in install.sh\n" - title="Upstream: HAProxy $WANT_HAPROXY, acme.sh $WANT_ACME" + [ "$HAVE_A2C" != "$WANT_A2C" ] && lines="$lines- acme2certifier $HAVE_A2C -> $WANT_A2C, in the image and in ham/adcs.py\n" + title="Upstream: HAProxy $WANT_HAPROXY, acme.sh $WANT_ACME, acme2certifier $WANT_A2C" body=$(printf 'Opened by the weekly upstream check.\n\n%b\nThe pull request builds the image and starts it. After merging: bump VERSION, and update the acme.sh default on the install page of the documentation (HAProxyManagerDocs, install-standalone.md).\n' "$lines") - branch="upstream/haproxy-$WANT_HAPROXY-acme-$WANT_ACME" + branch="upstream/haproxy-$WANT_HAPROXY-acme-$WANT_ACME-a2c-$WANT_A2C" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git checkout -B "$branch" diff --git a/Dockerfile b/Dockerfile index 7ab2017..5c61a89 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,9 @@ FROM debian:trixie-slim # suite, for amd64 and arm64, and publishes each point release as it comes. ARG HAPROXY_BRANCH=3.4 ARG ACME_VERSION=3.1.6 +# acme2certifier, for certificates from Active Directory Certificate Services +# (ham/adcs.py pins the same release for nodes that install it on demand). +ARG A2C_VERSION=0.46.4 ENV DEBIAN_FRONTEND=noninteractive @@ -62,6 +65,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ procps \ supervisor \ tzdata \ + # acme2certifier: an ACME server in front of a CA that does not speak + # ACME, run by the app on localhost while a certificate is requested from + # Active Directory Certificate Services. Its .deb comes from its GitHub + # release, as acme.sh's tarball does; its dependencies are Debian's own, + # Impacket included, which MS-ICPR enrolls through. Impacket's package + # also carries example scripts (secretsdump and the like) that scanners + # flag and enrollment never uses: left out, as its own documentation + # recommends. + && printf 'path-exclude /usr/bin/impacket-*\npath-exclude /usr/share/doc/python3-impacket/*\n' \ + > /etc/dpkg/dpkg.cfg.d/zz-impacket \ + && curl -fsSL "https://github.com/grindsa/acme2certifier/releases/download/${A2C_VERSION}/acme2certifier_${A2C_VERSION}-1_all.deb" \ + -o /tmp/acme2certifier.deb \ + && apt-get install -y --no-install-recommends /tmp/acme2certifier.deb python3-impacket \ + && rm -f /tmp/acme2certifier.deb \ && rm -rf /var/lib/apt/lists/* \ && cp /etc/keepalived/keepalived.config-opts /usr/sbin/keepalived.config-opts diff --git a/README.md b/README.md index 695881e..92deb26 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ A small self-hosted web UI to manage an **HAProxy** configuration, obtain -**Let's Encrypt** certificates, and run a **cluster of any number of nodes with +**Let's Encrypt** certificates (or your own CA's, Active Directory Certificate +Services included), and run a **cluster of any number of nodes with Keepalived** on a shared virtual IP — one node active, the rest ready to take over, with settings and certificates syncing across all of them. Since 2.0 it also keeps named **address lists**, lets a service carry its own **rules** and diff --git a/app.py b/app.py index 9e8c2c9..8e92ce7 100644 --- a/app.py +++ b/app.py @@ -43,7 +43,7 @@ from ham.acme import _renew_loop from ham.updates import _update_loop from ham.watchdog import _watchdog_loop -from ham import crowdsec, sync, traffic +from ham import adcs, crowdsec, sync, traffic def _cli(argv): @@ -205,6 +205,7 @@ def _serve(): # The CrowdSec bouncer: put on this node if it is missing (a package # install cannot fetch it), and brought up when CrowdSec is on. threading.Thread(target=crowdsec.startup, daemon=True).start() + threading.Thread(target=adcs.startup, daemon=True).start() # The traffic history is written every ten minutes, so stopping must write # the rest. systemd and supervisord stop with SIGTERM, which by default # ends Python without running anything registered here; made an ordinary diff --git a/ham/FILES b/ham/FILES index da119a4..53deba2 100644 --- a/ham/FILES +++ b/ham/FILES @@ -1,6 +1,7 @@ __init__.py access.py acme.py +adcs.py apply.py auth.py backup.py diff --git a/ham/__init__.py b/ham/__init__.py index 949a944..45d4b0a 100644 --- a/ham/__init__.py +++ b/ham/__init__.py @@ -31,6 +31,7 @@ metrics, apply, acme, + adcs, notify, watchdog, mcp, @@ -61,7 +62,7 @@ __all__ = [ "app", "log", "VERSION", "config", "util", "auth", "access", "twofactor", "qr", "validate", - "crud", "haproxy", "history", "keepalived", "metrics", "apply", "acme", "notify", + "crud", "haproxy", "history", "keepalived", "metrics", "apply", "acme", "adcs", "notify", "watchdog", "sync", "setup", "peering", "probe", "vrrp", "cluster", "stats", "traffic", "mqtt", "hass", "oauth", "logs", diff --git a/ham/acme.py b/ham/acme.py index afc8a6b..0b7a144 100644 --- a/ham/acme.py +++ b/ham/acme.py @@ -24,7 +24,7 @@ _SAFE_ENV = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") _DANGEROUS_ENV = {"PATH", "IFS", "ENV", "BASH_ENV", "SHELLOPTS", "BASHOPTS", "PS4", "PROMPT_COMMAND", "PYTHONPATH", "PERL5LIB"} -from . import auth, notify, sync +from . import adcs, auth, notify, sync # -------------------------------------------------------------------------- @@ -32,6 +32,9 @@ # takes a name it does not know for a directory URL, so a CA it drops has to # go here too: Buypass stopped issuing on 31 October 2025 and left acme.sh in # 3.1.2. The ones in EAB_REQUIRED will not register an account without it. +# Two more are not public CAs: "custom" is any ACME server by its directory +# URL, and "adcs" is Active Directory Certificate Services through the +# acme2certifier this node runs for it (see adcs.py). CA_SERVERS = { "letsencrypt": "letsencrypt", "letsencrypt_test": "letsencrypt_test", @@ -89,11 +92,14 @@ def check_account(acc): if ca == "buypass": raise ValueError("Buypass stopped issuing certificates on 31 October 2025: " "choose another certificate authority for this account") - if ca != "custom" and ca not in CA_SERVERS: + if ca not in ("custom", "adcs") and ca not in CA_SERVERS: raise ValueError("unknown certificate authority %r" % ca) if ca in EAB_REQUIRED and not (acc.get("eab_kid") or "").strip(): raise ValueError("%s needs External Account Binding: fill in the EAB key ID and " "HMAC key from your account with them" % EAB_REQUIRED[ca]) + if ca == "adcs": + adcs.check_account(acc) + return if ca != "custom": return url = (acc.get("server") or "").strip() @@ -170,6 +176,8 @@ def check_directory(acc): def server_args(acc): """--server, and --ca-bundle when the account brings one, for acme.sh.""" + if acc.get("ca") == "adcs": + return ["--server", adcs.directory_url()] if acc.get("ca") != "custom": return ["--server", CA_SERVERS.get(acc.get("ca", "letsencrypt"), "letsencrypt")] args = ["--server", (acc.get("server") or "").strip()] @@ -275,6 +283,27 @@ def _acme_issue(cfg, cert, force=False): return {"ok": False, "error": why} trace = [] + if adcs.is_adcs(acc) and not adcs.installed(): + # The first issuance on a node installed from a package or install.sh + # is the moment it gets acme2certifier; the image already has it. + ok, out = adcs.ensure_installed() + trace.append(out) + if not ok: + return {"ok": False, "error": "acme2certifier is not installed on this node and could not " + "be installed: %s" % out.strip()[-300:], "log": "\n".join(trace)} + with adcs.serving(acc, trace) as why: + if why: + return {"ok": False, "error": why, "log": "\n".join(trace)} + res = _acme_run_issue(cfg, cert, acc, ch, doms, force, trace) + if not res.get("ok") and adcs.is_adcs(acc) and "error" in res: + said = adcs.explain("\n".join(trace)) + if said: + res["error"] = "%s -- acme2certifier: %s" % (res["error"], said) + return res + + +def _acme_run_issue(cfg, cert, acc, ch, doms, force, trace): + """Register the account if need be, run acme.sh --issue, deploy the result.""" rc, out = ensure_account(acc) trace.append(out) if rc != 0: diff --git a/ham/adcs.py b/ham/adcs.py new file mode 100644 index 0000000..6a1636e --- /dev/null +++ b/ham/adcs.py @@ -0,0 +1,578 @@ +"""Active Directory Certificate Services, through acme2certifier. + +AD CS does not speak ACME. acme2certifier is an ACME server that passes +each request on to a CA that does not: for AD CS over MS-ICPR, the RPC +interface certreq uses, or through the CA's web enrollment pages. This +module runs it for the node: an ACME account with "adcs" as its CA holds +the CA's address, template and a service account, and when a certificate +is issued the node writes acme2certifier's configuration, starts it on +localhost, points acme.sh at it, and stops it again when acme.sh is done. + +Nothing to supervise and no port to open: it runs for the minute an +issuance takes, on this node only, and two AD CS accounts simply take +turns. The image ships acme2certifier; a node installed from install.sh or +a package gets it on demand, as it gets the CrowdSec bouncer. +""" + +from contextlib import contextmanager +from flask import jsonify +from flask import request +import configparser +import importlib.util +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import threading +import time +import urllib.error +import urllib.request + +from .base import A2C_DIR, A2C_PORT, A2C_STATE, DATA_DIR, app, log + +# The release the image carries and a node installs on demand. The weekly +# upstream check (.github/workflows/upstream.yml) moves this, the Dockerfile's +# ARG and install.sh together; tools/check-docs.py holds them to one value. +A2C_VERSION = "0.46.4" +A2C_DEB = ("https://github.com/grindsa/acme2certifier/releases/download/%(v)s/" + "acme2certifier_%(v)s-1_all.deb") +A2C_RPM = ("https://github.com/grindsa/acme2certifier/releases/download/%(v)s/" + "acme2certifier-%(v)s-1.0.noarch.rpm") + +METHODS = ("msicpr", "certsrv") +AUTH = {"msicpr": ("kerberos", "ntlm"), "certsrv": ("ntlm", "basic")} +HANDLER = {"msicpr": "acme2certifier.cahandlers.msicpr_ca_handler", + "certsrv": "acme2certifier.cahandlers.mscertsrv_ca_handler"} +# What the account fields are called, with their defaults, in one place: +# the wizard creates an account from them and the editor shows them. +FIELDS = {"adcs_method": "msicpr", "adcs_host": "", "adcs_ca_name": "", "adcs_template": "WebServer", + "adcs_domain": "", "adcs_dc": "", "adcs_user": "", "adcs_password": "", "adcs_auth": "", + # Any acme2certifier setting the fields do not cover, as acme_srv.cfg + # lines with their section headers, laid over the generated ones. + "adcs_extra": ""} +# Sections the extra settings may not touch: the database is the node's. +EXTRA_KEEP_OUT = ("DBhandler",) + +_HOST = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$") +# Something configparser can write and read back as one value. +_LINE = re.compile(r"^[^\x00-\x1f\x7f]*$") + +# One acme2certifier at a time: it has the one port, and acme.sh keeps one +# account for it, so two issuances for AD CS wait for each other. +_serving = threading.Lock() + + +def is_adcs(acc): + return (acc or {}).get("ca") == "adcs" + + +def auth_of(acc): + """The authentication the account uses: what it says, or the method's first.""" + choices = AUTH.get(acc.get("adcs_method") or "msicpr", AUTH["msicpr"]) + return acc.get("adcs_auth") if acc.get("adcs_auth") in choices else choices[0] + + +def check_account(acc): + """What an AD CS account refuses before it is stored. Raises ValueError with why.""" + method = acc.get("adcs_method") or "msicpr" + if method not in METHODS: + raise ValueError("unknown enrollment method %r: msicpr or certsrv" % method) + if acc.get("adcs_auth") and acc["adcs_auth"] not in AUTH[method]: + raise ValueError("%s enrollment authenticates with %s, not %s" + % ("MS-ICPR" if method == "msicpr" else "web", + " or ".join(AUTH[method]), acc["adcs_auth"])) + host = (acc.get("adcs_host") or "").strip() + if not _HOST.match(host): + raise ValueError("an Active Directory account needs the name of the certification " + "authority's server, such as ca01.corp.example.lan") + for key, what in (("adcs_template", "the certificate template, such as WebServer"), + ("adcs_user", "the user name of a service account allowed to enroll"), + ("adcs_password", "the service account's password")): + if not (acc.get(key) or "").strip(): + raise ValueError("an Active Directory account needs %s" % what) + if method == "msicpr" and not (acc.get("adcs_ca_name") or "").strip(): + raise ValueError("enrolling over MS-ICPR needs the certification authority's name, as " + "certutil shows it, such as Corp Issuing CA") + if method == "msicpr" and auth_of(acc) == "kerberos" and not (acc.get("adcs_domain") or "").strip(): + raise ValueError("Kerberos needs the Active Directory domain, such as corp.example.lan") + for key in FIELDS: + if key != "adcs_extra" and not _LINE.match(acc.get(key) or ""): + raise ValueError("%s must be one line" % key.replace("adcs_", "", 1).replace("_", " ")) + extra = _extra(acc) + for sec in extra.sections(): + if sec in EXTRA_KEEP_OUT: + raise ValueError("the extra acme2certifier settings may not set [%s]: the node keeps " + "acme2certifier's database itself" % sec) + if method == "certsrv" and (acc.get("ca_bundle") or "").strip(): + from . import acme + blocks = acme._pem_blocks(acc["ca_bundle"]) + if not blocks or any(kind != "CERTIFICATE" for kind, _ in blocks): + raise ValueError("the CA certificate to trust must be one or more PEM " + "certificates (-----BEGIN CERTIFICATE-----)") + + +# -------------------------------------------------------------------------- +# what acme2certifier is given + +def _extra(acc): + """The account's extra settings, parsed; raises ValueError when they are + not acme_srv.cfg lines with section headers.""" + # With a default section no one names, a [DEFAULT] of the account's own + # is an ordinary section here, and every section is handled alike. + cp = configparser.ConfigParser(interpolation=None, default_section="\x00") + text = acc.get("adcs_extra") or "" + if not text.strip(): + return cp + try: + cp.read_string(text) + except configparser.MissingSectionHeaderError: + raise ValueError("the extra acme2certifier settings need their section headers, " + "such as [CAhandler] above timeout: 30") + except configparser.Error as e: + raise ValueError("the extra acme2certifier settings are not acme_srv.cfg lines: %s" + % str(e).splitlines()[0]) + return cp + + +def directory_url(): + return "http://127.0.0.1:%d/directory" % A2C_PORT + + +def config_text(acc, bundle_path=""): + """acme2certifier's acme_srv.cfg for this account: the CA handler for AD CS, + SQLite in the state directory, and nothing switched off.""" + method = acc.get("adcs_method") or "msicpr" + auth = auth_of(acc) + cp = configparser.ConfigParser(interpolation=None) + cp["DEFAULT"] = {"debug": "False"} + cp["Nonce"] = {"nonce_check_disable": "False"} + # acme.sh on this node is its only client, and the account's e-mail + # address may be empty: no contact, and no terms to accept. + cp["Account"] = {"contact_check_disable": "True", "tos_check_disable": "True"} + ca = {"handler_module": HANDLER[method], + "host": (acc.get("adcs_host") or "").strip(), + "user": (acc.get("adcs_user") or "").strip(), + "password": acc.get("adcs_password") or "", + "template": (acc.get("adcs_template") or "").strip()} + if method == "msicpr": + ca["ca_name"] = (acc.get("adcs_ca_name") or "").strip() + ca["use_kerberos"] = "True" if auth == "kerberos" else "False" + ca["timeout"] = "15" + if (acc.get("adcs_domain") or "").strip(): + ca["target_domain"] = acc["adcs_domain"].strip() + if (acc.get("adcs_dc") or "").strip(): + ca["domain_controller"] = acc["adcs_dc"].strip() + else: + ca["auth_method"] = auth + if bundle_path: + ca["ca_bundle"] = bundle_path + cp["CAhandler"] = ca + cp["Certificate"] = {"revocation_reason_check_disable": "False"} + cp["Challenge"] = {"challenge_validation_disable": "False"} + cp["Order"] = {"tnauthlist_support": "False"} + # The account's own settings over the generated ones: a setting given + # here replaces the one the fields produce, a new one is added. + extra = _extra(acc) + for sec in extra.sections(): + if sec in EXTRA_KEEP_OUT: + continue + if sec != configparser.DEFAULTSECT and not cp.has_section(sec): + cp.add_section(sec) + for key, value in extra.items(sec, raw=True): + cp.set(sec, key, value) + cp["DBhandler"] = {"handler": "wsgi", "dbfile": str(A2C_STATE / "acme_srv.db")} + from io import StringIO + out = StringIO() + out.write("# Generated by haproxy-manager for one run of acme2certifier -- do not edit by hand.\n") + cp.write(out) + return out.getvalue() + + +def masked_config(acc): + """The configuration as acme2certifier would get it, the password hidden.""" + text = config_text(acc, "" if (acc.get("ca_bundle") or "").strip() + and (acc.get("adcs_method") or "msicpr") == "certsrv" else "") + pw = acc.get("adcs_password") or "" + return text.replace(pw, "********") if pw else text + + +@app.post("/api/acme/adcs/config") +def api_adcs_config(): + """What acme2certifier would be given for the account as typed.""" + acc = dict(request.get_json(force=True, silent=True) or {}) + acc["ca"] = "adcs" + try: + check_account(acc) + return jsonify({"ok": True, "config": masked_config(acc)}) + except ValueError as e: + return jsonify({"ok": False, "error": str(e)}) + + +def _write_private(path, text): + """Written beside and moved into place with mode 0600: it holds a password.""" + path.parent.mkdir(parents=True, exist_ok=True) + os.chmod(path.parent, 0o700) + fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix="." + path.name) + with os.fdopen(fd, "w") as f: + f.write(text) + os.chmod(tmp, 0o600) + os.replace(tmp, path) + + +def write_config(acc): + """The configuration on disk for a run: its path. The CA certificate to + trust, when the account brings one for web enrollment, goes beside it.""" + bundle = "" + if (acc.get("adcs_method") or "msicpr") == "certsrv" and (acc.get("ca_bundle") or "").strip(): + bundle = A2C_STATE / "ca-bundle.pem" + _write_private(bundle, acc["ca_bundle"].strip() + "\n") + path = A2C_STATE / "acme_srv.cfg" + _write_private(path, config_text(acc, str(bundle) if bundle else "")) + return path + + +def _env(cfg_path): + env = dict(os.environ) + env.pop("NOTIFY_SOCKET", None) + env["ACME_SRV_CONFIGFILE"] = str(cfg_path) + # Its output goes to a file and it is stopped with SIGTERM, which would + # lose whatever python still had buffered: the lines that say why. + env["PYTHONUNBUFFERED"] = "1" + # The WSGI entry point sits in acme2certifier's directory, not on the + # module path; the package itself is installed where python finds it. + env["PYTHONPATH"] = str(A2C_DIR) + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "") + return env + + +def command(): + """What runs acme2certifier: waitress, which this app already runs under, + serving the WSGI application on localhost only.""" + return [sys.executable, "-m", "waitress", "--listen=127.0.0.1:%d" % A2C_PORT, + "--threads=4", "acme2certifier_wsgi:application"] + + +# -------------------------------------------------------------------------- +# installed? + +def installed(): + """acme2certifier, and for MS-ICPR the Impacket it enrolls through.""" + return (A2C_DIR / "acme2certifier_wsgi.py").is_file() and _module("acme2certifier") + + +def _module(name): + try: + return importlib.util.find_spec(name) is not None + except (ImportError, ValueError): + return False + + +def version(): + if not installed(): + return "" + try: + from importlib.metadata import version as v + return v("acme2certifier") + except Exception: + return "" + + +def missing_for(acc): + """Why this node cannot enroll for the account yet, or "".""" + if not installed(): + return "acme2certifier is not installed on this node" + if (acc.get("adcs_method") or "msicpr") == "msicpr" and not _module("impacket"): + return "Impacket (python3-impacket) is not installed on this node; MS-ICPR enrolls through it" + return "" + + +# -------------------------------------------------------------------------- +# running it for one issuance + +READY_WAIT = 60 + + +def _ready(): + try: + with urllib.request.urlopen(directory_url(), timeout=3) as r: + body = json.loads(r.read(100000) or b"null") + return isinstance(body, dict) and bool(body.get("newNonce")) + except (urllib.error.URLError, OSError, ValueError): + return False + + +def _tail(path, n=300): + """The end of acme2certifier's output: enough to hold the handler's own + lines about a failed enrollment even when debug is on and every request + is logged after them.""" + try: + lines = path.read_text(errors="replace").splitlines() + except OSError: + return "" + return "\n".join(lines[-n:]) + + +@contextmanager +def serving(acc, log_lines): + """acme2certifier up for this account while the block runs; yields why + it could not be started, or "". log_lines collects its output for the + issue log.""" + if not is_adcs(acc): + yield "" + return + why = missing_for(acc) + if why: + yield why + return + with _serving: + A2C_STATE.mkdir(parents=True, exist_ok=True) + os.chmod(A2C_STATE, 0o700) + cfg_path = write_config(acc) + out_path = A2C_STATE / "acme2certifier.log" + proc = None + try: + with open(out_path, "w") as out: + proc = subprocess.Popen(command(), cwd=str(A2C_DIR), env=_env(cfg_path), + stdout=out, stderr=subprocess.STDOUT, start_new_session=True) + deadline = time.time() + READY_WAIT + while time.time() < deadline: + if proc.poll() is not None: + break + if _ready(): + break + time.sleep(0.3) + if proc.poll() is not None or not _ready(): + log_lines.append(_tail(out_path)) + yield ("acme2certifier did not start for account %s: %s" + % (acc.get("name") or "", _tail(out_path, 3) or "no output")) + return + yield "" + finally: + if proc is not None and proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + log_lines.append(_tail(out_path)) + # The configuration holds the password; it is written for a run + # and not left behind. + for p in (cfg_path, A2C_STATE / "ca-bundle.pem"): + try: + p.unlink() + except OSError: + pass + + +def explain(log_text): + """What acme2certifier's output says went wrong, in one line, for the + issue result: the CA's refusal or the connection that failed.""" + # The specific line first -- a connection that failed, a refused login + # -- and the handler's summary only when there is nothing better. + for pat in (r"Failed to connect[^\n]*", r"Error resolving[^\n]*", + r"(?:rpc_s_access_denied|STATUS_LOGON_FAILURE|KDC_ERR[A-Z_]*|STATUS_[A-Z_]+)[^\n]*", + r"Could not get certificate from CA server[^\n]*", r"ERROR[^\n]*"): + m = re.search(pat, log_text or "") + if m: + return m.group(0).strip()[:300] + return "" + + +# -------------------------------------------------------------------------- +# testing the connection to the CA + +TEST_TOOL = "a2c-msicpr-connection-test" + + +@app.post("/api/acme/adcs/test") +def api_adcs_test(): + """Ask the CA for nothing more than a connection, with the account as + typed: the CA's name, the service account, Kerberos or NTLM.""" + acc = dict(request.get_json(force=True, silent=True) or {}) + acc["ca"] = "adcs" + try: + check_account(acc) + except ValueError as e: + return jsonify({"ok": False, "message": str(e)}) + why = missing_for(acc) + if why: + return jsonify({"ok": False, "message": why}) + if (acc.get("adcs_method") or "msicpr") != "msicpr": + return _test_certsrv(acc) + tool = shutil.which(TEST_TOOL) or ("/usr/bin/" + TEST_TOOL) + if not os.path.exists(tool): + return jsonify({"ok": False, "message": "%s is not installed with acme2certifier here" % TEST_TOOL}) + with _serving: + A2C_STATE.mkdir(parents=True, exist_ok=True) + cfg_path = write_config(acc) + try: + p = subprocess.run([tool], capture_output=True, text=True, timeout=60, env=_env(cfg_path), + cwd=str(A2C_DIR)) + out = p.stdout + p.stderr + except subprocess.TimeoutExpired: + out = "timed out after 60 seconds" + except OSError as e: + out = str(e) + finally: + try: + cfg_path.unlink() + except OSError: + pass + lines = [l for l in out.splitlines() if l.strip() and not l.startswith(("CAhandler.", "Helper."))] + failed = re.search(r"Failed|Error|error|denied|refused|not known|timed out", out) + return jsonify({"ok": not failed, "message": "\n".join(lines[-6:])[:1500] + or ("the CA answered" if not failed else "no answer")}) + + +def _test_certsrv(acc): + """Web enrollment: whether /certsrv/ answers and takes the credentials.""" + import base64 + url = "https://%s/certsrv/" % acc["adcs_host"].strip() + import ssl + ctx = ssl.create_default_context() + ctx.verify_flags &= ~getattr(ssl, "VERIFY_X509_STRICT", 0) + if (acc.get("ca_bundle") or "").strip(): + ctx.load_verify_locations(cadata=acc["ca_bundle"]) + req = urllib.request.Request(url) + if auth_of(acc) == "basic": + token = base64.b64encode(("%s:%s" % (acc["adcs_user"], acc["adcs_password"])).encode()).decode() + req.add_header("Authorization", "Basic " + token) + try: + with urllib.request.urlopen(req, timeout=15, context=ctx) as r: + return jsonify({"ok": True, "message": "%s answers (HTTP %d)" % (url, r.status)}) + except urllib.error.HTTPError as e: + if e.code == 401 and auth_of(acc) == "ntlm" and "NTLM" in (e.headers.get("WWW-Authenticate") or ""): + return jsonify({"ok": True, "message": "%s answers and asks for NTLM, as the account expects" % url}) + if e.code == 401: + return jsonify({"ok": False, "message": "%s refused the credentials (HTTP 401; it offers %s)" + % (url, e.headers.get("WWW-Authenticate") or "no authentication")}) + return jsonify({"ok": False, "message": "%s answered HTTP %d" % (url, e.code)}) + except urllib.error.URLError as e: + import ssl as _ssl + if isinstance(e.reason, _ssl.SSLCertVerificationError): + return jsonify({"ok": False, "message": "this node does not trust the HTTPS certificate of %s: paste " + "the certificate of the CA that signed it into the account's CA certificate " + "to trust" % url}) + return jsonify({"ok": False, "message": "could not reach %s: %s" % (url, e.reason)}) + except (OSError, ValueError) as e: + return jsonify({"ok": False, "message": "could not reach %s: %s" % (url, e)}) + + +# -------------------------------------------------------------------------- +# putting it on a node that lacks it + +# When an install last failed, so a node that cannot reach GitHub is not made +# to try at every issuance. +TRIED = DATA_DIR / "acme2certifier-install-tried" +RETRY_AFTER = 6 * 3600 +# Impacket's package brings example scripts -- secretsdump and the like -- +# that scanners flag; they are not what enrollment uses, and the image leaves +# them out the same way. +DPKG_EXCLUDE = ("path-exclude /usr/bin/impacket-*\n" + "path-exclude /usr/share/doc/python3-impacket/*\n") +APT_INSTALL = """set -e +export DEBIAN_FRONTEND=noninteractive +dpkg -s python3-impacket >/dev/null 2>&1 || printf '%%s' '%(exclude)s' > /etc/dpkg/dpkg.cfg.d/haproxy-manager-impacket +tmp=$(mktemp -d) +curl -fsSL --max-time 300 '%(deb)s' -o "$tmp/acme2certifier.deb" +apt-get -o DPkg::Lock::Timeout=120 -o Acquire::http::Timeout=30 -o Acquire::Retries=2 update -q +apt-get -o DPkg::Lock::Timeout=120 -o Acquire::http::Timeout=30 -o Acquire::Retries=2 install -y --no-install-recommends "$tmp/acme2certifier.deb" python3-impacket +rm -rf "$tmp" +""" % {"exclude": DPKG_EXCLUDE, "deb": A2C_DEB % {"v": A2C_VERSION}} +# The Enterprise Linux family: acme2certifier's own .rpm, whose dependencies +# come from EPEL, as this app's do. +RPM_INSTALL = """set -e +tmp=$(mktemp -d) +curl -fsSL --max-time 300 '%(rpm)s' -o "$tmp/acme2certifier.rpm" +if command -v dnf >/dev/null; then dnf install -y "$tmp/acme2certifier.rpm" python3-impacket; else yum install -y "$tmp/acme2certifier.rpm" python3-impacket; fi +rm -rf "$tmp" +""" % {"rpm": A2C_RPM % {"v": A2C_VERSION}} + + +def _tried_recently(): + try: + return time.time() - float(TRIED.read_text().strip()) < RETRY_AFTER + except (OSError, ValueError): + return False + + +def last_failure(): + try: + return TRIED.with_suffix(".log").read_text().strip()[-1500:] + except OSError: + return "" + + +def install(): + """Put acme2certifier on this node from its GitHub release: (ok, output).""" + if installed(): + return True, "acme2certifier is already installed" + if shutil.which("apt-get"): + script = APT_INSTALL + elif shutil.which("dnf") or shutil.which("yum"): + script = RPM_INSTALL + else: + return False, ("this node has neither apt-get nor dnf; install acme2certifier %s and Impacket " + "by hand" % A2C_VERSION) + from .crowdsec import _run_script + rc, out = _run_script(script, timeout=900) + if rc != 0 or not installed(): + return False, "the install did not finish:\n" + (out or "").strip()[-1500:] + return True, "installed acme2certifier %s" % (version() or A2C_VERSION) + + +def ensure_installed(): + """Install it where an account needs it and it is missing: (ok, message). + Never fatal, and a failure is not retried for a while.""" + if installed(): + return True, "installed" + if _tried_recently(): + return False, "an install failed recently; not retried yet: " + last_failure()[-300:] + ok, out = install() + TRIED.parent.mkdir(parents=True, exist_ok=True) + if ok: + TRIED.unlink(missing_ok=True) + TRIED.with_suffix(".log").unlink(missing_ok=True) + log.info(out) + else: + TRIED.write_text(str(time.time())) + TRIED.with_suffix(".log").write_text(out) + log.warning("acme2certifier could not be installed: %s", out.strip()[-300:]) + return ok, out + + +def wanted(cfg): + """Does any account on this node enroll with AD CS.""" + return any(is_adcs(a) for a in (cfg.get("acme") or {}).get("accounts") or []) + + +def startup(): + """At startup: a node that has an AD CS account gets acme2certifier if it + lacks it, so the first renewal does not wait for the download.""" + try: + from .config import load_config + if wanted(load_config()): + ensure_installed() + except Exception: + log.exception("acme2certifier at startup failed") + + +def summary(cfg): + """For /api/acme/health: whether this node can enroll with AD CS.""" + out = {"wanted": wanted(cfg), "installed": installed(), "version": version(), + "impacket": _module("impacket"), "port": A2C_PORT, "release": A2C_VERSION} + if out["wanted"] and not out["installed"]: + out["install_error"] = last_failure() + return out + + +@app.post("/api/acme/adcs/install") +def api_adcs_install(): + """Install acme2certifier on this node now, rather than at the first issuance.""" + TRIED.unlink(missing_ok=True) + ok, message = ensure_installed() + from .config import load_config + return jsonify({"ok": ok, "message": message, "adcs": summary(load_config())}), (200 if ok else 500) diff --git a/ham/base.py b/ham/base.py index 4fd8d43..14fcd2c 100644 --- a/ham/base.py +++ b/ham/base.py @@ -32,6 +32,13 @@ KEEPALIVED_CFG = Path(os.environ.get("HAM_KEEPALIVED_CFG", "/etc/keepalived/keepalived.conf")) ACME_HOME = Path(os.environ.get("HAM_ACME_HOME", str(Path.home() / ".acme.sh"))) ACME_SH = os.environ.get("HAM_ACME_SH", str(ACME_HOME / "acme.sh")) +# acme2certifier, for certificates from Active Directory Certificate +# Services: where its .deb puts it, the localhost port it is run on for an +# issuance, and where its database and the configuration of a run live -- +# beside acme.sh's state, so the container's volume keeps them. +A2C_DIR = Path(os.environ.get("HAM_A2C_DIR", "/var/www/acme2certifier")) +A2C_PORT = int(os.environ.get("HAM_A2C_PORT", "9081")) +A2C_STATE = Path(os.environ.get("HAM_A2C_STATE", str(ACME_HOME / "acme2certifier"))) # One directory up: this module lives in ham/, the files it points at sit # beside app.py. ROOT_DIR = Path(__file__).resolve().parent.parent diff --git a/ham/dnsapi.py b/ham/dnsapi.py index 58aff2e..c9cdad1 100644 --- a/ham/dnsapi.py +++ b/ham/dnsapi.py @@ -13,7 +13,7 @@ from .base import ACME_HOME, ACME_SH, _lock, app from .config import load_config, save_config from .util import _by_id, _sec, cert_details, cert_path, parse_domains, rule_cond_ids, run -from . import access, acme, errorpages, validate, wizard, oauth +from . import access, acme, adcs, errorpages, validate, wizard, oauth from .validate import plain_url as _plain_url # -------------------------------------------------------------------------- @@ -162,6 +162,10 @@ def api_acme_health(): out["problem"] = "acme.sh needs %s, which is not installed." % " and ".join(missing) out["hint"] = "apt-get install -y " + " ".join(missing) return jsonify(out) + # Whether the node can enroll with Active Directory Certificate Services, + # for the certificates page to say so when an account asks for it. + out["adcs"] = adcs.summary(load_config()) + if not shutil.which("socat"): out["ok"] = True out["problem"] = "" @@ -228,8 +232,8 @@ def pick(kind, spec, defaults, label_field="name"): try: account = pick("accounts", body.get("account"), - {"name": "", "email": "", "ca": "letsencrypt", "server": "", - "ca_bundle": "", "eab_kid": "", "eab_hmac": ""}) + dict({"name": "", "email": "", "ca": "letsencrypt", "server": "", + "ca_bundle": "", "eab_kid": "", "eab_hmac": ""}, **adcs.FIELDS)) acme.check_account(account) challenge = pick("challenges", body.get("challenge"), {"name": "", "method": "http01", "dns_provider": "", "dns_credentials": ""}) diff --git a/static/js/core.js b/static/js/core.js index 2884fcf..1d0a754 100644 --- a/static/js/core.js +++ b/static/js/core.js @@ -64,7 +64,27 @@ $("#ovl").addEventListener("click",e=>{if(e.target.id==="ovl")closeDlg();}); document.addEventListener("keydown",e=>{if(e.key==="Escape")closeDlg();}); /* "custom" is any ACME server of your own, by its directory URL: acme2certifier in front of Active Directory Certificate Services, step-ca, Vault. */ -export const CA_CHOICES=["letsencrypt","letsencrypt_test","zerossl","google","google_test","sslcom","actalis","custom"]; +export const CA_CHOICES=["letsencrypt","letsencrypt_test","zerossl","google","google_test","sslcom","actalis","custom", + {value:"adcs",label:"Active Directory Certificate Services"}]; +/* The fields an account for Active Directory Certificate Services has: in the + account editor as they are, in the wizard with acc_ in front. Here rather + than with the wizard because the entities and the certificates page import + each other, and a const read across that cycle is not yet initialized. */ +export const ADCS_FIELDS=[ + {k:"adcs_method",l:"Enrollment",t:"select",d:"msicpr", + o:[{value:"msicpr",label:"MS-ICPR -- the RPC interface certreq uses; needs only the Certification Authority role"}, + {value:"certsrv",label:"Web enrollment -- the CA's /certsrv pages"}]}, + {k:"adcs_host",l:"CA server",t:"text",h:"The server running the certification authority, such as ca01.corp.example.lan. For web enrollment, the server with the /certsrv pages."}, + {k:"adcs_ca_name",l:"CA name",t:"text",h:"The certification authority's name as certutil shows it, such as Corp Issuing CA"}, + {k:"adcs_template",l:"Certificate template",t:"text",d:"WebServer",h:"A template the service account may enroll in, and that issues without a CA manager's approval"}, + {k:"adcs_auth",l:"Authentication",t:"select",o:["kerberos","ntlm","basic"],d:"kerberos",h:"Kerberos for MS-ICPR, as Microsoft recommends; web enrollment takes NTLM or basic"}, + {k:"adcs_domain",l:"Active Directory domain",t:"text",h:"Such as corp.example.lan. Kerberos needs it."}, + {k:"adcs_dc",l:"Domain controller",t:"text",h:"Optional. A domain controller's name or address, when the domain's name does not resolve to one from this node"}, + {k:"adcs_user",l:"Service account",t:"text",h:"A user allowed to enroll in the template, such as svc-acme"}, + {k:"adcs_password",l:"Password",t:"password"}, + {k:"adcs_extra",l:"Extra acme2certifier settings",t:"textarea", + h:"Advanced. Any acme2certifier setting the fields above do not cover, as lines of its acme_srv.cfg with their section headers -- for example [CAhandler] timeout: 30, or [Challenge] dns_server_list: [\"10.0.0.53\"]. These are laid over the generated configuration, so a setting here replaces the one the fields produce. Show the configuration to see the result."}, +]; export function btn(label,cls,fn){const b=document.createElement("button");b.className="btn "+(cls||"");b.textContent=label;b.onclick=fn;return b;} export function showText(title,text){const p=document.createElement("pre");p.textContent=text;openDlg(title,p,[btn(t("Close"),"",closeDlg)]);} diff --git a/static/js/entities.js b/static/js/entities.js index 338fa8e..913f5b7 100644 --- a/static/js/entities.js +++ b/static/js/entities.js @@ -1,7 +1,7 @@ -import { $, CA_CHOICES, HEALTH_LABEL, PROXY_KEYWORD, PROXY_OPTIONS, api, btn, closeDlg, esc, fieldRow, list, lists, nameOf, openDlg, readForm, showText, trimHaproxy } from "./core.js"; +import { $, ADCS_FIELDS, CA_CHOICES, HEALTH_LABEL, PROXY_KEYWORD, PROXY_OPTIONS, api, btn, closeDlg, esc, fieldRow, list, lists, nameOf, openDlg, readForm, showText, trimHaproxy } from "./core.js"; import { t } from "./i18n.js"; import { refreshStatus, route } from "./shell.js"; -import { certificateNotices, certExpiryCell, certLastCell, certStatusCell, dnsApiByHook, dnsApiOptions, dnsCredentialHelp, issueCert, loadAcmeHealth, loadCertStatus, loadDnsApis, openCertWizard, openDownload, openUpload, showCertLog, customCaRows } from "./pages/certificates.js"; +import { caRows, certificateNotices, certExpiryCell, certLastCell, certStatusCell, dnsApiByHook, dnsApiOptions, dnsCredentialHelp, issueCert, loadAcmeHealth, loadCertStatus, loadDnsApis, openCertWizard, openDownload, openUpload, showCertLog } from "./pages/certificates.js"; import { ACTION_LABEL, COND_LABEL, openRuleWizard } from "./pages/rules.js"; import { openConditionWizard } from "./pages/conditions.js"; import { state } from "./state.js"; @@ -257,15 +257,17 @@ export const E={ intro:"ACME accounts used to request certificates.", cols:[["name","Name"],["email","E-mail"], ["ca","CA",r=>r.ca==="custom"?"custom
"+esc(r.server||"")+"
" + :r.ca==="adcs"?t("Active Directory")+"
"+esc(r.adcs_host||"")+(r.adcs_template?" · "+esc(r.adcs_template):"")+"
" :r.ca==="buypass"?"buypass
"+t("Buypass stopped issuing on 31 October 2025: choose another CA")+"
" :esc(r.ca||"letsencrypt")]], - editorExtra:customCaRows, + editorExtra:frm=>caRows(frm), fields:[ {k:"name",l:"Name",t:"text"}, {k:"email",l:"E-mail address",t:"text"}, {k:"ca",l:"Certificate authority",t:"select",o:CA_CHOICES,d:"letsencrypt",h:"Use letsencrypt_test while setting things up to avoid rate limits"}, {k:"server",l:"ACME directory URL",t:"text",h:"Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault"}, {k:"ca_bundle",l:"CA certificate to trust",t:"textarea",h:"Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already"}, + ...ADCS_FIELDS, {k:"eab_kid",l:"EAB key ID",t:"text",h:"Google, SSL.com and Actalis need one, from your account with them, and so may your own CA. ZeroSSL gets its own from the e-mail address."}, {k:"eab_hmac",l:"EAB HMAC key",t:"password"}]}, diff --git a/static/js/i18n/da.js b/static/js/i18n/da.js index e03af49..a188535 100644 --- a/static/js/i18n/da.js +++ b/static/js/i18n/da.js @@ -511,6 +511,41 @@ export default { "Buypass stopped issuing on 31 October 2025: choose another CA": "Buypass stoppede udstedelsen den 31. oktober 2025: vælg en anden CA", "Google, SSL.com and Actalis need one, from your account with them, and so may your own CA. ZeroSSL gets its own from the e-mail address.": "Google, SSL.com og Actalis kræver en, fra din konto hos dem, og det kan din egen CA også. ZeroSSL henter sin egen ud fra e-mailadressen.", "Buypass stopped issuing certificates on 31 October 2025: choose another certificate authority for this account": "Buypass stoppede med at udstede certifikater den 31. oktober 2025: vælg en anden certifikatmyndighed til denne konto", +"Active Directory Certificate Services": "Active Directory Certificate Services", +"Active Directory": "Active Directory", +"Test the connection to the CA": "Test forbindelsen til CA'en", +"Asking the CA...": "Spørger CA'en...", +"Impacket is not installed on this node": "Impacket er ikke installeret på denne node", +"acme2certifier is not installed on this node": "acme2certifier er ikke installeret på denne node", +"An account enrolls with Active Directory Certificate Services over MS-ICPR, which needs Impacket (python3-impacket), and this node does not have it.": "En konto tilmelder sig hos Active Directory Certificate Services over MS-ICPR, som kræver Impacket (python3-impacket), og denne node har det ikke.", +"An account enrolls with Active Directory Certificate Services through acme2certifier, which this node does not have yet. It is installed at the first issuance, or now.": "En konto tilmelder sig hos Active Directory Certificate Services via acme2certifier, som denne node ikke har endnu. Det installeres ved den første udstedelse, eller nu.", +"The last install failed:": "Den seneste installation mislykkedes:", +"Install acme2certifier now": "Installer acme2certifier nu", +"Installing -- this can take a few minutes...": "Installerer -- det kan tage et par minutter...", +"Enrollment": "Tilmelding", +"MS-ICPR -- the RPC interface certreq uses; needs only the Certification Authority role": "MS-ICPR -- RPC-grænsefladen certreq bruger; kræver kun rollen Certification Authority", +"Web enrollment -- the CA's /certsrv pages": "Webtilmelding -- CA'ens /certsrv-sider", +"CA server": "CA-server", +"The server running the certification authority, such as ca01.corp.example.lan. For web enrollment, the server with the /certsrv pages.": "Serveren der kører certifikatmyndigheden, f.eks. ca01.corp.example.lan. Ved webtilmelding serveren med /certsrv-siderne.", +"CA name": "CA-navn", +"The certification authority's name as certutil shows it, such as Corp Issuing CA": "Certifikatmyndighedens navn som certutil viser det, f.eks. Corp Issuing CA", +"Certificate template": "Certifikatskabelon", +"A template the service account may enroll in, and that issues without a CA manager's approval": "En skabelon servicekontoen må tilmelde sig i, og som udsteder uden en CA-administrators godkendelse", +"Authentication": "Godkendelse", +"Kerberos for MS-ICPR, as Microsoft recommends; web enrollment takes NTLM or basic": "Kerberos til MS-ICPR, som Microsoft anbefaler; webtilmelding bruger NTLM eller basic", +"Active Directory domain": "Active Directory-domæne", +"Such as corp.example.lan. Kerberos needs it.": "F.eks. corp.example.lan. Kerberos kræver det.", +"Domain controller": "Domænecontroller", +"Optional. A domain controller's name or address, when the domain's name does not resolve to one from this node": "Valgfrit. En domænecontrollers navn eller adresse, når domænets navn ikke slår op til en fra denne node", +"Service account": "Servicekonto", +"A user allowed to enroll in the template, such as svc-acme": "En bruger der må tilmelde sig i skabelonen, f.eks. svc-acme", +"an Active Directory account needs the name of the certification authority's server, such as ca01.corp.example.lan": "en Active Directory-konto kræver navnet på certifikatmyndighedens server, f.eks. ca01.corp.example.lan", +"enrolling over MS-ICPR needs the certification authority's name, as certutil shows it, such as Corp Issuing CA": "tilmelding over MS-ICPR kræver certifikatmyndighedens navn, som certutil viser det, f.eks. Corp Issuing CA", +"Kerberos needs the Active Directory domain, such as corp.example.lan": "Kerberos kræver Active Directory-domænet, f.eks. corp.example.lan", +"Extra acme2certifier settings": "Ekstra acme2certifier-indstillinger", +"Advanced. Any acme2certifier setting the fields above do not cover, as lines of its acme_srv.cfg with their section headers -- for example [CAhandler] timeout: 30, or [Challenge] dns_server_list: [\"10.0.0.53\"]. These are laid over the generated configuration, so a setting here replaces the one the fields produce. Show the configuration to see the result.": "Avanceret. Enhver acme2certifier-indstilling, felterne ovenfor ikke dækker, som linjer fra dens acme_srv.cfg med sektionsoverskrifter -- f.eks. [CAhandler] timeout: 30 eller [Challenge] dns_server_list: [\"10.0.0.53\"]. De lægges oven på den genererede konfiguration, så en indstilling her erstatter den, felterne giver. Vis konfigurationen for at se resultatet.", +"Show the configuration": "Vis konfigurationen", +"the extra acme2certifier settings need their section headers, such as [CAhandler] above timeout: 30": "de ekstra acme2certifier-indstillinger kræver deres sektionsoverskrifter, f.eks. [CAhandler] over timeout: 30", "Load from file": "Indlæs fra fil", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Læser en lokal .html-fil ind i feltet ovenfor. Intet uploades: siden gemmes sammen med konfigurationen.", "Value": "Værdi", diff --git a/static/js/i18n/de.js b/static/js/i18n/de.js index 9428117..665758c 100644 --- a/static/js/i18n/de.js +++ b/static/js/i18n/de.js @@ -511,6 +511,41 @@ export default { "Buypass stopped issuing on 31 October 2025: choose another CA": "Buypass stellt seit dem 31. Oktober 2025 keine Zertifikate mehr aus: eine andere Zertifizierungsstelle wählen", "Google, SSL.com and Actalis need one, from your account with them, and so may your own CA. ZeroSSL gets its own from the e-mail address.": "Google, SSL.com und Actalis brauchen ihn, aus Ihrem Konto bei ihnen, ebenso vielleicht Ihre eigene Zertifizierungsstelle. ZeroSSL holt sich seinen eigenen über die E-Mail-Adresse.", "Buypass stopped issuing certificates on 31 October 2025: choose another certificate authority for this account": "Buypass stellt seit dem 31. Oktober 2025 keine Zertifikate mehr aus: für dieses Konto eine andere Zertifizierungsstelle wählen", +"Active Directory Certificate Services": "Active Directory Certificate Services", +"Active Directory": "Active Directory", +"Test the connection to the CA": "Verbindung zur Zertifizierungsstelle testen", +"Asking the CA...": "Die Zertifizierungsstelle wird gefragt...", +"Impacket is not installed on this node": "Impacket ist auf diesem Knoten nicht installiert", +"acme2certifier is not installed on this node": "acme2certifier ist auf diesem Knoten nicht installiert", +"An account enrolls with Active Directory Certificate Services over MS-ICPR, which needs Impacket (python3-impacket), and this node does not have it.": "Ein Konto registriert bei den Active Directory Certificate Services über MS-ICPR, wofür Impacket (python3-impacket) nötig ist, und dieser Knoten hat es nicht.", +"An account enrolls with Active Directory Certificate Services through acme2certifier, which this node does not have yet. It is installed at the first issuance, or now.": "Ein Konto registriert bei den Active Directory Certificate Services über acme2certifier, das dieser Knoten noch nicht hat. Es wird bei der ersten Ausstellung installiert, oder jetzt.", +"The last install failed:": "Die letzte Installation ist fehlgeschlagen:", +"Install acme2certifier now": "acme2certifier jetzt installieren", +"Installing -- this can take a few minutes...": "Wird installiert -- das kann einige Minuten dauern...", +"Enrollment": "Registrierung", +"MS-ICPR -- the RPC interface certreq uses; needs only the Certification Authority role": "MS-ICPR -- die RPC-Schnittstelle, die certreq benutzt; braucht nur die Rolle Zertifizierungsstelle", +"Web enrollment -- the CA's /certsrv pages": "Webregistrierung -- die /certsrv-Seiten der Zertifizierungsstelle", +"CA server": "Zertifizierungsstellen-Server", +"The server running the certification authority, such as ca01.corp.example.lan. For web enrollment, the server with the /certsrv pages.": "Der Server, auf dem die Zertifizierungsstelle läuft, etwa ca01.corp.example.lan. Bei Webregistrierung der Server mit den /certsrv-Seiten.", +"CA name": "Name der Zertifizierungsstelle", +"The certification authority's name as certutil shows it, such as Corp Issuing CA": "Der Name der Zertifizierungsstelle, wie certutil ihn anzeigt, etwa Corp Issuing CA", +"Certificate template": "Zertifikatvorlage", +"A template the service account may enroll in, and that issues without a CA manager's approval": "Eine Vorlage, in der das Dienstkonto registrieren darf und die ohne Genehmigung eines Zertifizierungsstellen-Managers ausstellt", +"Authentication": "Authentifizierung", +"Kerberos for MS-ICPR, as Microsoft recommends; web enrollment takes NTLM or basic": "Kerberos für MS-ICPR, wie Microsoft empfiehlt; Webregistrierung nimmt NTLM oder Basic", +"Active Directory domain": "Active-Directory-Domäne", +"Such as corp.example.lan. Kerberos needs it.": "Etwa corp.example.lan. Kerberos braucht sie.", +"Domain controller": "Domänencontroller", +"Optional. A domain controller's name or address, when the domain's name does not resolve to one from this node": "Optional. Name oder Adresse eines Domänencontrollers, wenn der Domänenname von diesem Knoten aus nicht zu einem aufgelöst wird", +"Service account": "Dienstkonto", +"A user allowed to enroll in the template, such as svc-acme": "Ein Benutzer, der in der Vorlage registrieren darf, etwa svc-acme", +"an Active Directory account needs the name of the certification authority's server, such as ca01.corp.example.lan": "ein Active-Directory-Konto braucht den Namen des Servers der Zertifizierungsstelle, etwa ca01.corp.example.lan", +"enrolling over MS-ICPR needs the certification authority's name, as certutil shows it, such as Corp Issuing CA": "die Registrierung über MS-ICPR braucht den Namen der Zertifizierungsstelle, wie certutil ihn anzeigt, etwa Corp Issuing CA", +"Kerberos needs the Active Directory domain, such as corp.example.lan": "Kerberos braucht die Active-Directory-Domäne, etwa corp.example.lan", +"Extra acme2certifier settings": "Weitere acme2certifier-Einstellungen", +"Advanced. Any acme2certifier setting the fields above do not cover, as lines of its acme_srv.cfg with their section headers -- for example [CAhandler] timeout: 30, or [Challenge] dns_server_list: [\"10.0.0.53\"]. These are laid over the generated configuration, so a setting here replaces the one the fields produce. Show the configuration to see the result.": "Fortgeschritten. Jede acme2certifier-Einstellung, die die Felder oben nicht abdecken, als Zeilen seiner acme_srv.cfg mit ihren Abschnittsüberschriften -- etwa [CAhandler] timeout: 30 oder [Challenge] dns_server_list: [\"10.0.0.53\"]. Sie werden über die erzeugte Konfiguration gelegt, eine Einstellung hier ersetzt also die aus den Feldern. Konfiguration anzeigen zeigt das Ergebnis.", +"Show the configuration": "Konfiguration anzeigen", +"the extra acme2certifier settings need their section headers, such as [CAhandler] above timeout: 30": "die weiteren acme2certifier-Einstellungen brauchen ihre Abschnittsüberschriften, etwa [CAhandler] über timeout: 30", "Load from file": "Aus Datei laden", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Liest eine lokale .html-Datei in das Feld oben. Nichts wird hochgeladen: die Seite wird mit der Konfiguration gespeichert.", "Value": "Wert", diff --git a/static/js/i18n/es.js b/static/js/i18n/es.js index 5d8aada..07ed706 100644 --- a/static/js/i18n/es.js +++ b/static/js/i18n/es.js @@ -511,6 +511,41 @@ export default { "Buypass stopped issuing on 31 October 2025: choose another CA": "Buypass dejó de emitir el 31 de octubre de 2025: elige otra CA", "Google, SSL.com and Actalis need one, from your account with them, and so may your own CA. ZeroSSL gets its own from the e-mail address.": "Google, SSL.com y Actalis la necesitan, de tu cuenta con ellos, y quizá también tu propia CA. ZeroSSL obtiene la suya a partir de la dirección de correo.", "Buypass stopped issuing certificates on 31 October 2025: choose another certificate authority for this account": "Buypass dejó de emitir certificados el 31 de octubre de 2025: elige otra autoridad de certificación para esta cuenta", +"Active Directory Certificate Services": "Active Directory Certificate Services", +"Active Directory": "Active Directory", +"Test the connection to the CA": "Probar la conexión con la CA", +"Asking the CA...": "Consultando a la CA...", +"Impacket is not installed on this node": "Impacket no está instalado en este nodo", +"acme2certifier is not installed on this node": "acme2certifier no está instalado en este nodo", +"An account enrolls with Active Directory Certificate Services over MS-ICPR, which needs Impacket (python3-impacket), and this node does not have it.": "Una cuenta se inscribe en Active Directory Certificate Services por MS-ICPR, que necesita Impacket (python3-impacket), y este nodo no lo tiene.", +"An account enrolls with Active Directory Certificate Services through acme2certifier, which this node does not have yet. It is installed at the first issuance, or now.": "Una cuenta se inscribe en Active Directory Certificate Services a través de acme2certifier, que este nodo aún no tiene. Se instala en la primera emisión, o ahora.", +"The last install failed:": "La última instalación falló:", +"Install acme2certifier now": "Instalar acme2certifier ahora", +"Installing -- this can take a few minutes...": "Instalando -- puede tardar unos minutos...", +"Enrollment": "Inscripción", +"MS-ICPR -- the RPC interface certreq uses; needs only the Certification Authority role": "MS-ICPR -- la interfaz RPC que usa certreq; solo necesita el rol Certification Authority", +"Web enrollment -- the CA's /certsrv pages": "Inscripción web -- las páginas /certsrv de la CA", +"CA server": "Servidor de la CA", +"The server running the certification authority, such as ca01.corp.example.lan. For web enrollment, the server with the /certsrv pages.": "El servidor donde corre la autoridad de certificación, como ca01.corp.example.lan. Para la inscripción web, el servidor con las páginas /certsrv.", +"CA name": "Nombre de la CA", +"The certification authority's name as certutil shows it, such as Corp Issuing CA": "El nombre de la autoridad de certificación tal como lo muestra certutil, como Corp Issuing CA", +"Certificate template": "Plantilla de certificado", +"A template the service account may enroll in, and that issues without a CA manager's approval": "Una plantilla en la que la cuenta de servicio puede inscribirse, y que emite sin la aprobación de un administrador de la CA", +"Authentication": "Autenticación", +"Kerberos for MS-ICPR, as Microsoft recommends; web enrollment takes NTLM or basic": "Kerberos para MS-ICPR, como recomienda Microsoft; la inscripción web acepta NTLM o basic", +"Active Directory domain": "Dominio de Active Directory", +"Such as corp.example.lan. Kerberos needs it.": "Como corp.example.lan. Kerberos lo necesita.", +"Domain controller": "Controlador de dominio", +"Optional. A domain controller's name or address, when the domain's name does not resolve to one from this node": "Opcional. El nombre o la dirección de un controlador de dominio, cuando el nombre del dominio no resuelve a uno desde este nodo", +"Service account": "Cuenta de servicio", +"A user allowed to enroll in the template, such as svc-acme": "Un usuario autorizado a inscribirse en la plantilla, como svc-acme", +"an Active Directory account needs the name of the certification authority's server, such as ca01.corp.example.lan": "una cuenta de Active Directory necesita el nombre del servidor de la autoridad de certificación, como ca01.corp.example.lan", +"enrolling over MS-ICPR needs the certification authority's name, as certutil shows it, such as Corp Issuing CA": "inscribirse por MS-ICPR necesita el nombre de la autoridad de certificación, tal como lo muestra certutil, como Corp Issuing CA", +"Kerberos needs the Active Directory domain, such as corp.example.lan": "Kerberos necesita el dominio de Active Directory, como corp.example.lan", +"Extra acme2certifier settings": "Ajustes adicionales de acme2certifier", +"Advanced. Any acme2certifier setting the fields above do not cover, as lines of its acme_srv.cfg with their section headers -- for example [CAhandler] timeout: 30, or [Challenge] dns_server_list: [\"10.0.0.53\"]. These are laid over the generated configuration, so a setting here replaces the one the fields produce. Show the configuration to see the result.": "Avanzado. Cualquier ajuste de acme2certifier que los campos de arriba no cubran, como líneas de su acme_srv.cfg con sus cabeceras de sección -- por ejemplo [CAhandler] timeout: 30, o [Challenge] dns_server_list: [\"10.0.0.53\"]. Se superponen a la configuración generada, así que un ajuste aquí sustituye al que producen los campos. Mostrar la configuración enseña el resultado.", +"Show the configuration": "Mostrar la configuración", +"the extra acme2certifier settings need their section headers, such as [CAhandler] above timeout: 30": "los ajustes adicionales de acme2certifier necesitan sus cabeceras de sección, como [CAhandler] encima de timeout: 30", "Load from file": "Cargar desde archivo", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Lee un archivo .html local en el campo de arriba. No se sube nada: la página se guarda con la configuración.", "Value": "Valor", diff --git a/static/js/i18n/fi.js b/static/js/i18n/fi.js index 92b1ca3..72d37d3 100644 --- a/static/js/i18n/fi.js +++ b/static/js/i18n/fi.js @@ -511,6 +511,41 @@ export default { "Buypass stopped issuing on 31 October 2025: choose another CA": "Buypass lopetti myöntämisen 31. lokakuuta 2025: valitse toinen varmentaja", "Google, SSL.com and Actalis need one, from your account with them, and so may your own CA. ZeroSSL gets its own from the e-mail address.": "Google, SSL.com ja Actalis tarvitsevat sen, tililtäsi niiden palvelussa, ja niin voi tarvita myös oma varmentajasi. ZeroSSL hakee omansa sähköpostiosoitteen perusteella.", "Buypass stopped issuing certificates on 31 October 2025: choose another certificate authority for this account": "Buypass lopetti varmenteiden myöntämisen 31. lokakuuta 2025: valitse tälle tilille toinen varmentaja", +"Active Directory Certificate Services": "Active Directory Certificate Services", +"Active Directory": "Active Directory", +"Test the connection to the CA": "Testaa yhteys CA:han", +"Asking the CA...": "Kysytään CA:lta...", +"Impacket is not installed on this node": "Impacket ei ole asennettu tälle solmulle", +"acme2certifier is not installed on this node": "acme2certifier ei ole asennettu tälle solmulle", +"An account enrolls with Active Directory Certificate Services over MS-ICPR, which needs Impacket (python3-impacket), and this node does not have it.": "Tili rekisteröityy Active Directory Certificate Services -palveluun MS-ICPR:n kautta, mikä vaatii Impacketin (python3-impacket), eikä tällä solmulla ole sitä.", +"An account enrolls with Active Directory Certificate Services through acme2certifier, which this node does not have yet. It is installed at the first issuance, or now.": "Tili rekisteröityy Active Directory Certificate Services -palveluun acme2certifierin kautta, jota tällä solmulla ei vielä ole. Se asennetaan ensimmäisen myöntämisen yhteydessä, tai nyt.", +"The last install failed:": "Viimeisin asennus epäonnistui:", +"Install acme2certifier now": "Asenna acme2certifier nyt", +"Installing -- this can take a few minutes...": "Asennetaan -- tämä voi kestää muutaman minuutin...", +"Enrollment": "Rekisteröinti", +"MS-ICPR -- the RPC interface certreq uses; needs only the Certification Authority role": "MS-ICPR -- RPC-rajapinta, jota certreq käyttää; tarvitsee vain Certification Authority -roolin", +"Web enrollment -- the CA's /certsrv pages": "Web-rekisteröinti -- CA:n /certsrv-sivut", +"CA server": "CA-palvelin", +"The server running the certification authority, such as ca01.corp.example.lan. For web enrollment, the server with the /certsrv pages.": "Palvelin, jolla varmentaja toimii, esim. ca01.corp.example.lan. Web-rekisteröinnissä palvelin, jolla /certsrv-sivut ovat.", +"CA name": "CA:n nimi", +"The certification authority's name as certutil shows it, such as Corp Issuing CA": "Varmentajan nimi sellaisena kuin certutil sen näyttää, esim. Corp Issuing CA", +"Certificate template": "Varmennemalli", +"A template the service account may enroll in, and that issues without a CA manager's approval": "Malli, johon palvelutili saa rekisteröityä ja joka myöntää ilman CA:n hallinnoijan hyväksyntää", +"Authentication": "Todennus", +"Kerberos for MS-ICPR, as Microsoft recommends; web enrollment takes NTLM or basic": "Kerberos MS-ICPR:lle, kuten Microsoft suosittelee; web-rekisteröinti käyttää NTLM:ää tai basicia", +"Active Directory domain": "Active Directory -toimialue", +"Such as corp.example.lan. Kerberos needs it.": "Esim. corp.example.lan. Kerberos tarvitsee sen.", +"Domain controller": "Toimialueen ohjauskone", +"Optional. A domain controller's name or address, when the domain's name does not resolve to one from this node": "Valinnainen. Toimialueen ohjauskoneen nimi tai osoite, kun toimialueen nimi ei ratkea sellaiseksi tältä solmulta", +"Service account": "Palvelutili", +"A user allowed to enroll in the template, such as svc-acme": "Käyttäjä, joka saa rekisteröityä malliin, esim. svc-acme", +"an Active Directory account needs the name of the certification authority's server, such as ca01.corp.example.lan": "Active Directory -tili tarvitsee varmentajan palvelimen nimen, esim. ca01.corp.example.lan", +"enrolling over MS-ICPR needs the certification authority's name, as certutil shows it, such as Corp Issuing CA": "rekisteröinti MS-ICPR:n kautta tarvitsee varmentajan nimen sellaisena kuin certutil sen näyttää, esim. Corp Issuing CA", +"Kerberos needs the Active Directory domain, such as corp.example.lan": "Kerberos tarvitsee Active Directory -toimialueen, esim. corp.example.lan", +"Extra acme2certifier settings": "acme2certifierin lisäasetukset", +"Advanced. Any acme2certifier setting the fields above do not cover, as lines of its acme_srv.cfg with their section headers -- for example [CAhandler] timeout: 30, or [Challenge] dns_server_list: [\"10.0.0.53\"]. These are laid over the generated configuration, so a setting here replaces the one the fields produce. Show the configuration to see the result.": "Edistynyt. Mikä tahansa acme2certifierin asetus, jota yllä olevat kentät eivät kata, sen acme_srv.cfg-tiedoston riveinä osio-otsikoineen -- esimerkiksi [CAhandler] timeout: 30 tai [Challenge] dns_server_list: [\"10.0.0.53\"]. Ne asetetaan luodun kokoonpanon päälle, joten tässä annettu asetus korvaa kenttien tuottaman. Näytä kokoonpano näyttää tuloksen.", +"Show the configuration": "Näytä kokoonpano", +"the extra acme2certifier settings need their section headers, such as [CAhandler] above timeout: 30": "acme2certifierin lisäasetukset tarvitsevat osio-otsikkonsa, esimerkiksi [CAhandler] rivin timeout: 30 yläpuolelle", "Load from file": "Lataa tiedostosta", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Lukee paikallisen .html-tiedoston yllä olevaan kenttään. Mitään ei ladata palvelimelle: sivu tallennetaan asetusten mukana.", "Value": "Arvo", diff --git a/static/js/i18n/fr.js b/static/js/i18n/fr.js index c139c5e..e51e641 100644 --- a/static/js/i18n/fr.js +++ b/static/js/i18n/fr.js @@ -511,6 +511,41 @@ export default { "Buypass stopped issuing on 31 October 2025: choose another CA": "Buypass a cessé d'émettre le 31 octobre 2025 : choisissez une autre AC", "Google, SSL.com and Actalis need one, from your account with them, and so may your own CA. ZeroSSL gets its own from the e-mail address.": "Google, SSL.com et Actalis en exigent une, issue de votre compte chez eux, et votre propre AC peut-être aussi. ZeroSSL obtient la sienne à partir de l'adresse e-mail.", "Buypass stopped issuing certificates on 31 October 2025: choose another certificate authority for this account": "Buypass a cessé d'émettre des certificats le 31 octobre 2025 : choisissez une autre autorité de certification pour ce compte", +"Active Directory Certificate Services": "Active Directory Certificate Services", +"Active Directory": "Active Directory", +"Test the connection to the CA": "Tester la connexion à l'AC", +"Asking the CA...": "Interrogation de l'AC...", +"Impacket is not installed on this node": "Impacket n'est pas installé sur ce nœud", +"acme2certifier is not installed on this node": "acme2certifier n'est pas installé sur ce nœud", +"An account enrolls with Active Directory Certificate Services over MS-ICPR, which needs Impacket (python3-impacket), and this node does not have it.": "Un compte s'inscrit auprès des Active Directory Certificate Services via MS-ICPR, qui a besoin d'Impacket (python3-impacket), et ce nœud ne l'a pas.", +"An account enrolls with Active Directory Certificate Services through acme2certifier, which this node does not have yet. It is installed at the first issuance, or now.": "Un compte s'inscrit auprès des Active Directory Certificate Services via acme2certifier, que ce nœud n'a pas encore. Il est installé à la première émission, ou maintenant.", +"The last install failed:": "La dernière installation a échoué :", +"Install acme2certifier now": "Installer acme2certifier maintenant", +"Installing -- this can take a few minutes...": "Installation -- cela peut prendre quelques minutes...", +"Enrollment": "Inscription", +"MS-ICPR -- the RPC interface certreq uses; needs only the Certification Authority role": "MS-ICPR -- l'interface RPC qu'utilise certreq ; ne demande que le rôle Certification Authority", +"Web enrollment -- the CA's /certsrv pages": "Inscription web -- les pages /certsrv de l'AC", +"CA server": "Serveur de l'AC", +"The server running the certification authority, such as ca01.corp.example.lan. For web enrollment, the server with the /certsrv pages.": "Le serveur qui héberge l'autorité de certification, par exemple ca01.corp.example.lan. Pour l'inscription web, le serveur des pages /certsrv.", +"CA name": "Nom de l'AC", +"The certification authority's name as certutil shows it, such as Corp Issuing CA": "Le nom de l'autorité de certification tel que certutil l'affiche, par exemple Corp Issuing CA", +"Certificate template": "Modèle de certificat", +"A template the service account may enroll in, and that issues without a CA manager's approval": "Un modèle dans lequel le compte de service peut s'inscrire, et qui émet sans l'approbation d'un gestionnaire de l'AC", +"Authentication": "Authentification", +"Kerberos for MS-ICPR, as Microsoft recommends; web enrollment takes NTLM or basic": "Kerberos pour MS-ICPR, comme Microsoft le recommande ; l'inscription web accepte NTLM ou basic", +"Active Directory domain": "Domaine Active Directory", +"Such as corp.example.lan. Kerberos needs it.": "Par exemple corp.example.lan. Kerberos en a besoin.", +"Domain controller": "Contrôleur de domaine", +"Optional. A domain controller's name or address, when the domain's name does not resolve to one from this node": "Facultatif. Le nom ou l'adresse d'un contrôleur de domaine, quand le nom du domaine ne se résout pas vers l'un d'eux depuis ce nœud", +"Service account": "Compte de service", +"A user allowed to enroll in the template, such as svc-acme": "Un utilisateur autorisé à s'inscrire dans le modèle, par exemple svc-acme", +"an Active Directory account needs the name of the certification authority's server, such as ca01.corp.example.lan": "un compte Active Directory a besoin du nom du serveur de l'autorité de certification, par exemple ca01.corp.example.lan", +"enrolling over MS-ICPR needs the certification authority's name, as certutil shows it, such as Corp Issuing CA": "l'inscription via MS-ICPR a besoin du nom de l'autorité de certification, tel que certutil l'affiche, par exemple Corp Issuing CA", +"Kerberos needs the Active Directory domain, such as corp.example.lan": "Kerberos a besoin du domaine Active Directory, par exemple corp.example.lan", +"Extra acme2certifier settings": "Réglages acme2certifier supplémentaires", +"Advanced. Any acme2certifier setting the fields above do not cover, as lines of its acme_srv.cfg with their section headers -- for example [CAhandler] timeout: 30, or [Challenge] dns_server_list: [\"10.0.0.53\"]. These are laid over the generated configuration, so a setting here replaces the one the fields produce. Show the configuration to see the result.": "Avancé. Tout réglage d'acme2certifier que les champs ci-dessus ne couvrent pas, sous forme de lignes de son acme_srv.cfg avec leurs en-têtes de section -- par exemple [CAhandler] timeout: 30, ou [Challenge] dns_server_list: [\"10.0.0.53\"]. Ils sont posés sur la configuration générée : un réglage ici remplace celui que produisent les champs. Afficher la configuration montre le résultat.", +"Show the configuration": "Afficher la configuration", +"the extra acme2certifier settings need their section headers, such as [CAhandler] above timeout: 30": "les réglages acme2certifier supplémentaires ont besoin de leurs en-têtes de section, par exemple [CAhandler] au-dessus de timeout: 30", "Load from file": "Charger depuis un fichier", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Lit un fichier .html local dans le champ ci-dessus. Rien n'est téléversé : la page est enregistrée avec la configuration.", "Value": "Valeur", diff --git a/static/js/i18n/it.js b/static/js/i18n/it.js index 831f32d..0d64f4b 100644 --- a/static/js/i18n/it.js +++ b/static/js/i18n/it.js @@ -511,6 +511,41 @@ export default { "Buypass stopped issuing on 31 October 2025: choose another CA": "Buypass ha smesso di emettere il 31 ottobre 2025: scegli un'altra CA", "Google, SSL.com and Actalis need one, from your account with them, and so may your own CA. ZeroSSL gets its own from the e-mail address.": "Google, SSL.com e Actalis la richiedono, dal tuo account presso di loro, e forse anche la tua CA. ZeroSSL ottiene la propria dall'indirizzo e-mail.", "Buypass stopped issuing certificates on 31 October 2025: choose another certificate authority for this account": "Buypass ha smesso di emettere certificati il 31 ottobre 2025: scegli un'altra autorità di certificazione per questo account", +"Active Directory Certificate Services": "Active Directory Certificate Services", +"Active Directory": "Active Directory", +"Test the connection to the CA": "Prova la connessione alla CA", +"Asking the CA...": "Interrogazione della CA...", +"Impacket is not installed on this node": "Impacket non è installato su questo nodo", +"acme2certifier is not installed on this node": "acme2certifier non è installato su questo nodo", +"An account enrolls with Active Directory Certificate Services over MS-ICPR, which needs Impacket (python3-impacket), and this node does not have it.": "Un account si registra presso Active Directory Certificate Services tramite MS-ICPR, che richiede Impacket (python3-impacket), e questo nodo non ce l'ha.", +"An account enrolls with Active Directory Certificate Services through acme2certifier, which this node does not have yet. It is installed at the first issuance, or now.": "Un account si registra presso Active Directory Certificate Services tramite acme2certifier, che questo nodo non ha ancora. Viene installato alla prima emissione, oppure adesso.", +"The last install failed:": "L'ultima installazione non è riuscita:", +"Install acme2certifier now": "Installa acme2certifier adesso", +"Installing -- this can take a few minutes...": "Installazione in corso -- può richiedere qualche minuto...", +"Enrollment": "Registrazione", +"MS-ICPR -- the RPC interface certreq uses; needs only the Certification Authority role": "MS-ICPR -- l'interfaccia RPC usata da certreq; richiede solo il ruolo Certification Authority", +"Web enrollment -- the CA's /certsrv pages": "Registrazione web -- le pagine /certsrv della CA", +"CA server": "Server della CA", +"The server running the certification authority, such as ca01.corp.example.lan. For web enrollment, the server with the /certsrv pages.": "Il server su cui gira l'autorità di certificazione, ad esempio ca01.corp.example.lan. Per la registrazione web, il server con le pagine /certsrv.", +"CA name": "Nome della CA", +"The certification authority's name as certutil shows it, such as Corp Issuing CA": "Il nome dell'autorità di certificazione come lo mostra certutil, ad esempio Corp Issuing CA", +"Certificate template": "Modello di certificato", +"A template the service account may enroll in, and that issues without a CA manager's approval": "Un modello in cui l'account di servizio può registrarsi, e che emette senza l'approvazione di un gestore della CA", +"Authentication": "Autenticazione", +"Kerberos for MS-ICPR, as Microsoft recommends; web enrollment takes NTLM or basic": "Kerberos per MS-ICPR, come raccomanda Microsoft; la registrazione web accetta NTLM o basic", +"Active Directory domain": "Dominio Active Directory", +"Such as corp.example.lan. Kerberos needs it.": "Ad esempio corp.example.lan. Kerberos ne ha bisogno.", +"Domain controller": "Controller di dominio", +"Optional. A domain controller's name or address, when the domain's name does not resolve to one from this node": "Facoltativo. Il nome o l'indirizzo di un controller di dominio, quando il nome del dominio non si risolve in uno da questo nodo", +"Service account": "Account di servizio", +"A user allowed to enroll in the template, such as svc-acme": "Un utente autorizzato a registrarsi nel modello, ad esempio svc-acme", +"an Active Directory account needs the name of the certification authority's server, such as ca01.corp.example.lan": "un account Active Directory richiede il nome del server dell'autorità di certificazione, ad esempio ca01.corp.example.lan", +"enrolling over MS-ICPR needs the certification authority's name, as certutil shows it, such as Corp Issuing CA": "la registrazione tramite MS-ICPR richiede il nome dell'autorità di certificazione, come lo mostra certutil, ad esempio Corp Issuing CA", +"Kerberos needs the Active Directory domain, such as corp.example.lan": "Kerberos richiede il dominio Active Directory, ad esempio corp.example.lan", +"Extra acme2certifier settings": "Impostazioni acme2certifier aggiuntive", +"Advanced. Any acme2certifier setting the fields above do not cover, as lines of its acme_srv.cfg with their section headers -- for example [CAhandler] timeout: 30, or [Challenge] dns_server_list: [\"10.0.0.53\"]. These are laid over the generated configuration, so a setting here replaces the one the fields produce. Show the configuration to see the result.": "Avanzato. Qualsiasi impostazione di acme2certifier non coperta dai campi qui sopra, come righe del suo acme_srv.cfg con le intestazioni di sezione -- ad esempio [CAhandler] timeout: 30, oppure [Challenge] dns_server_list: [\"10.0.0.53\"]. Vengono sovrapposte alla configurazione generata, quindi un'impostazione qui sostituisce quella prodotta dai campi. Mostra la configurazione fa vedere il risultato.", +"Show the configuration": "Mostra la configurazione", +"the extra acme2certifier settings need their section headers, such as [CAhandler] above timeout: 30": "le impostazioni acme2certifier aggiuntive richiedono le intestazioni di sezione, ad esempio [CAhandler] sopra timeout: 30", "Load from file": "Carica da file", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Legge un file .html locale nel campo qui sopra. Nulla viene caricato sul server: la pagina viene salvata con la configurazione.", "Value": "Valore", diff --git a/static/js/i18n/nb.js b/static/js/i18n/nb.js index b137f49..9c0cb2f 100644 --- a/static/js/i18n/nb.js +++ b/static/js/i18n/nb.js @@ -511,6 +511,41 @@ export default { "Buypass stopped issuing on 31 October 2025: choose another CA": "Buypass sluttet å utstede 31. oktober 2025: velg en annen sertifikatutsteder", "Google, SSL.com and Actalis need one, from your account with them, and so may your own CA. ZeroSSL gets its own from the e-mail address.": "Google, SSL.com og Actalis krever en, fra kontoen din hos dem, og det kan også din egen sertifikatutsteder. ZeroSSL henter sin egen ut fra e-postadressen.", "Buypass stopped issuing certificates on 31 October 2025: choose another certificate authority for this account": "Buypass sluttet å utstede sertifikater 31. oktober 2025: velg en annen sertifikatutsteder for denne kontoen", +"Active Directory Certificate Services": "Active Directory Certificate Services", +"Active Directory": "Active Directory", +"Test the connection to the CA": "Test forbindelsen til CA-en", +"Asking the CA...": "Spør CA-en...", +"Impacket is not installed on this node": "Impacket er ikke installert på denne noden", +"acme2certifier is not installed on this node": "acme2certifier er ikke installert på denne noden", +"An account enrolls with Active Directory Certificate Services over MS-ICPR, which needs Impacket (python3-impacket), and this node does not have it.": "En konto registrerer seg hos Active Directory Certificate Services over MS-ICPR, som trenger Impacket (python3-impacket), og denne noden har det ikke.", +"An account enrolls with Active Directory Certificate Services through acme2certifier, which this node does not have yet. It is installed at the first issuance, or now.": "En konto registrerer seg hos Active Directory Certificate Services via acme2certifier, som denne noden ikke har ennå. Det installeres ved første utstedelse, eller nå.", +"The last install failed:": "Den siste installasjonen mislyktes:", +"Install acme2certifier now": "Installer acme2certifier nå", +"Installing -- this can take a few minutes...": "Installerer -- dette kan ta noen minutter...", +"Enrollment": "Registrering", +"MS-ICPR -- the RPC interface certreq uses; needs only the Certification Authority role": "MS-ICPR -- RPC-grensesnittet certreq bruker; trenger bare rollen Certification Authority", +"Web enrollment -- the CA's /certsrv pages": "Webregistrering -- CA-ens /certsrv-sider", +"CA server": "CA-server", +"The server running the certification authority, such as ca01.corp.example.lan. For web enrollment, the server with the /certsrv pages.": "Serveren som kjører sertifikatmyndigheten, for eksempel ca01.corp.example.lan. Ved webregistrering serveren med /certsrv-sidene.", +"CA name": "CA-navn", +"The certification authority's name as certutil shows it, such as Corp Issuing CA": "Sertifikatmyndighetens navn slik certutil viser det, for eksempel Corp Issuing CA", +"Certificate template": "Sertifikatmal", +"A template the service account may enroll in, and that issues without a CA manager's approval": "En mal tjenestekontoen kan registrere seg i, og som utsteder uten godkjenning fra en CA-administrator", +"Authentication": "Autentisering", +"Kerberos for MS-ICPR, as Microsoft recommends; web enrollment takes NTLM or basic": "Kerberos for MS-ICPR, slik Microsoft anbefaler; webregistrering tar NTLM eller basic", +"Active Directory domain": "Active Directory-domene", +"Such as corp.example.lan. Kerberos needs it.": "For eksempel corp.example.lan. Kerberos trenger det.", +"Domain controller": "Domenekontroller", +"Optional. A domain controller's name or address, when the domain's name does not resolve to one from this node": "Valgfritt. Navnet eller adressen til en domenekontroller, når domenets navn ikke slår opp til en fra denne noden", +"Service account": "Tjenestekonto", +"A user allowed to enroll in the template, such as svc-acme": "En bruker som kan registrere seg i malen, for eksempel svc-acme", +"an Active Directory account needs the name of the certification authority's server, such as ca01.corp.example.lan": "en Active Directory-konto trenger navnet på sertifikatmyndighetens server, for eksempel ca01.corp.example.lan", +"enrolling over MS-ICPR needs the certification authority's name, as certutil shows it, such as Corp Issuing CA": "registrering over MS-ICPR trenger sertifikatmyndighetens navn, slik certutil viser det, for eksempel Corp Issuing CA", +"Kerberos needs the Active Directory domain, such as corp.example.lan": "Kerberos trenger Active Directory-domenet, for eksempel corp.example.lan", +"Extra acme2certifier settings": "Ekstra acme2certifier-innstillinger", +"Advanced. Any acme2certifier setting the fields above do not cover, as lines of its acme_srv.cfg with their section headers -- for example [CAhandler] timeout: 30, or [Challenge] dns_server_list: [\"10.0.0.53\"]. These are laid over the generated configuration, so a setting here replaces the one the fields produce. Show the configuration to see the result.": "Avansert. Enhver acme2certifier-innstilling feltene over ikke dekker, som linjer fra dens acme_srv.cfg med seksjonsoverskrifter -- for eksempel [CAhandler] timeout: 30, eller [Challenge] dns_server_list: [\"10.0.0.53\"]. De legges over den genererte konfigurasjonen, så en innstilling her erstatter den feltene gir. Vis konfigurasjonen for å se resultatet.", +"Show the configuration": "Vis konfigurasjonen", +"the extra acme2certifier settings need their section headers, such as [CAhandler] above timeout: 30": "de ekstra acme2certifier-innstillingene trenger seksjonsoverskriftene sine, for eksempel [CAhandler] over timeout: 30", "Load from file": "Last inn fra fil", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Leser en lokal .html-fil inn i feltet ovenfor. Ingenting lastes opp: siden lagres sammen med konfigurasjonen.", "Value": "Verdi", diff --git a/static/js/i18n/nl.js b/static/js/i18n/nl.js index 8628ba9..fc29164 100644 --- a/static/js/i18n/nl.js +++ b/static/js/i18n/nl.js @@ -511,6 +511,41 @@ export default { "Buypass stopped issuing on 31 October 2025: choose another CA": "Buypass is op 31 oktober 2025 gestopt met uitgeven: kies een andere CA", "Google, SSL.com and Actalis need one, from your account with them, and so may your own CA. ZeroSSL gets its own from the e-mail address.": "Google, SSL.com en Actalis hebben er een nodig, uit je account bij hen, en je eigen CA misschien ook. ZeroSSL haalt er zelf een op via het e-mailadres.", "Buypass stopped issuing certificates on 31 October 2025: choose another certificate authority for this account": "Buypass is op 31 oktober 2025 gestopt met het uitgeven van certificaten: kies een andere certificaatautoriteit voor dit account", +"Active Directory Certificate Services": "Active Directory Certificate Services", +"Active Directory": "Active Directory", +"Test the connection to the CA": "Test de verbinding met de CA", +"Asking the CA...": "De CA wordt gevraagd...", +"Impacket is not installed on this node": "Impacket is niet geïnstalleerd op deze node", +"acme2certifier is not installed on this node": "acme2certifier is niet geïnstalleerd op deze node", +"An account enrolls with Active Directory Certificate Services over MS-ICPR, which needs Impacket (python3-impacket), and this node does not have it.": "Een account schrijft zich in bij Active Directory Certificate Services via MS-ICPR, waarvoor Impacket (python3-impacket) nodig is, en deze node heeft het niet.", +"An account enrolls with Active Directory Certificate Services through acme2certifier, which this node does not have yet. It is installed at the first issuance, or now.": "Een account schrijft zich in bij Active Directory Certificate Services via acme2certifier, dat deze node nog niet heeft. Het wordt bij de eerste uitgifte geïnstalleerd, of nu.", +"The last install failed:": "De laatste installatie is mislukt:", +"Install acme2certifier now": "acme2certifier nu installeren", +"Installing -- this can take a few minutes...": "Bezig met installeren -- dit kan enkele minuten duren...", +"Enrollment": "Inschrijving", +"MS-ICPR -- the RPC interface certreq uses; needs only the Certification Authority role": "MS-ICPR -- de RPC-interface die certreq gebruikt; heeft alleen de rol Certification Authority nodig", +"Web enrollment -- the CA's /certsrv pages": "Webinschrijving -- de /certsrv-pagina's van de CA", +"CA server": "CA-server", +"The server running the certification authority, such as ca01.corp.example.lan. For web enrollment, the server with the /certsrv pages.": "De server waarop de certificeringsinstantie draait, zoals ca01.corp.example.lan. Voor webinschrijving de server met de /certsrv-pagina's.", +"CA name": "CA-naam", +"The certification authority's name as certutil shows it, such as Corp Issuing CA": "De naam van de certificeringsinstantie zoals certutil die toont, zoals Corp Issuing CA", +"Certificate template": "Certificaatsjabloon", +"A template the service account may enroll in, and that issues without a CA manager's approval": "Een sjabloon waarin het serviceaccount mag inschrijven, en dat uitgeeft zonder goedkeuring van een CA-beheerder", +"Authentication": "Verificatie", +"Kerberos for MS-ICPR, as Microsoft recommends; web enrollment takes NTLM or basic": "Kerberos voor MS-ICPR, zoals Microsoft aanbeveelt; webinschrijving neemt NTLM of basic", +"Active Directory domain": "Active Directory-domein", +"Such as corp.example.lan. Kerberos needs it.": "Zoals corp.example.lan. Kerberos heeft het nodig.", +"Domain controller": "Domeincontroller", +"Optional. A domain controller's name or address, when the domain's name does not resolve to one from this node": "Optioneel. De naam of het adres van een domeincontroller, als de domeinnaam vanaf deze node niet naar een domeincontroller verwijst", +"Service account": "Serviceaccount", +"A user allowed to enroll in the template, such as svc-acme": "Een gebruiker die in het sjabloon mag inschrijven, zoals svc-acme", +"an Active Directory account needs the name of the certification authority's server, such as ca01.corp.example.lan": "een Active Directory-account heeft de naam van de server van de certificeringsinstantie nodig, zoals ca01.corp.example.lan", +"enrolling over MS-ICPR needs the certification authority's name, as certutil shows it, such as Corp Issuing CA": "inschrijven via MS-ICPR heeft de naam van de certificeringsinstantie nodig, zoals certutil die toont, zoals Corp Issuing CA", +"Kerberos needs the Active Directory domain, such as corp.example.lan": "Kerberos heeft het Active Directory-domein nodig, zoals corp.example.lan", +"Extra acme2certifier settings": "Extra acme2certifier-instellingen", +"Advanced. Any acme2certifier setting the fields above do not cover, as lines of its acme_srv.cfg with their section headers -- for example [CAhandler] timeout: 30, or [Challenge] dns_server_list: [\"10.0.0.53\"]. These are laid over the generated configuration, so a setting here replaces the one the fields produce. Show the configuration to see the result.": "Geavanceerd. Elke acme2certifier-instelling die de velden hierboven niet dekken, als regels uit zijn acme_srv.cfg met hun sectiekoppen -- bijvoorbeeld [CAhandler] timeout: 30, of [Challenge] dns_server_list: [\"10.0.0.53\"]. Ze worden over de gegenereerde configuratie gelegd, dus een instelling hier vervangt die uit de velden. Toon de configuratie laat het resultaat zien.", +"Show the configuration": "Toon de configuratie", +"the extra acme2certifier settings need their section headers, such as [CAhandler] above timeout: 30": "de extra acme2certifier-instellingen hebben hun sectiekoppen nodig, zoals [CAhandler] boven timeout: 30", "Load from file": "Uit bestand laden", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Leest een lokaal .html-bestand in het veld hierboven. Er wordt niets geüpload: de pagina wordt met de configuratie opgeslagen.", "Value": "Waarde", diff --git a/static/js/i18n/pt.js b/static/js/i18n/pt.js index d80885d..81f47d9 100644 --- a/static/js/i18n/pt.js +++ b/static/js/i18n/pt.js @@ -511,6 +511,41 @@ export default { "Buypass stopped issuing on 31 October 2025: choose another CA": "A Buypass deixou de emitir em 31 de outubro de 2025: escolha outra CA", "Google, SSL.com and Actalis need one, from your account with them, and so may your own CA. ZeroSSL gets its own from the e-mail address.": "A Google, a SSL.com e a Actalis exigem uma, da sua conta com elas, e talvez também a sua própria CA. A ZeroSSL obtém a sua a partir do endereço de e-mail.", "Buypass stopped issuing certificates on 31 October 2025: choose another certificate authority for this account": "A Buypass deixou de emitir certificados em 31 de outubro de 2025: escolha outra autoridade de certificação para esta conta", +"Active Directory Certificate Services": "Active Directory Certificate Services", +"Active Directory": "Active Directory", +"Test the connection to the CA": "Testar a ligação à CA", +"Asking the CA...": "A perguntar à CA...", +"Impacket is not installed on this node": "O Impacket não está instalado neste nó", +"acme2certifier is not installed on this node": "O acme2certifier não está instalado neste nó", +"An account enrolls with Active Directory Certificate Services over MS-ICPR, which needs Impacket (python3-impacket), and this node does not have it.": "Uma conta inscreve-se nos Active Directory Certificate Services por MS-ICPR, que precisa do Impacket (python3-impacket), e este nó não o tem.", +"An account enrolls with Active Directory Certificate Services through acme2certifier, which this node does not have yet. It is installed at the first issuance, or now.": "Uma conta inscreve-se nos Active Directory Certificate Services através do acme2certifier, que este nó ainda não tem. É instalado na primeira emissão, ou agora.", +"The last install failed:": "A última instalação falhou:", +"Install acme2certifier now": "Instalar o acme2certifier agora", +"Installing -- this can take a few minutes...": "A instalar -- pode demorar alguns minutos...", +"Enrollment": "Inscrição", +"MS-ICPR -- the RPC interface certreq uses; needs only the Certification Authority role": "MS-ICPR -- a interface RPC que o certreq usa; só precisa da função Certification Authority", +"Web enrollment -- the CA's /certsrv pages": "Inscrição web -- as páginas /certsrv da CA", +"CA server": "Servidor da CA", +"The server running the certification authority, such as ca01.corp.example.lan. For web enrollment, the server with the /certsrv pages.": "O servidor onde corre a autoridade de certificação, como ca01.corp.example.lan. Na inscrição web, o servidor com as páginas /certsrv.", +"CA name": "Nome da CA", +"The certification authority's name as certutil shows it, such as Corp Issuing CA": "O nome da autoridade de certificação tal como o certutil o mostra, como Corp Issuing CA", +"Certificate template": "Modelo de certificado", +"A template the service account may enroll in, and that issues without a CA manager's approval": "Um modelo em que a conta de serviço pode inscrever-se, e que emite sem a aprovação de um gestor da CA", +"Authentication": "Autenticação", +"Kerberos for MS-ICPR, as Microsoft recommends; web enrollment takes NTLM or basic": "Kerberos para MS-ICPR, como a Microsoft recomenda; a inscrição web aceita NTLM ou basic", +"Active Directory domain": "Domínio Active Directory", +"Such as corp.example.lan. Kerberos needs it.": "Como corp.example.lan. O Kerberos precisa dele.", +"Domain controller": "Controlador de domínio", +"Optional. A domain controller's name or address, when the domain's name does not resolve to one from this node": "Opcional. O nome ou endereço de um controlador de domínio, quando o nome do domínio não resolve para um a partir deste nó", +"Service account": "Conta de serviço", +"A user allowed to enroll in the template, such as svc-acme": "Um utilizador autorizado a inscrever-se no modelo, como svc-acme", +"an Active Directory account needs the name of the certification authority's server, such as ca01.corp.example.lan": "uma conta Active Directory precisa do nome do servidor da autoridade de certificação, como ca01.corp.example.lan", +"enrolling over MS-ICPR needs the certification authority's name, as certutil shows it, such as Corp Issuing CA": "a inscrição por MS-ICPR precisa do nome da autoridade de certificação, tal como o certutil o mostra, como Corp Issuing CA", +"Kerberos needs the Active Directory domain, such as corp.example.lan": "o Kerberos precisa do domínio Active Directory, como corp.example.lan", +"Extra acme2certifier settings": "Definições adicionais do acme2certifier", +"Advanced. Any acme2certifier setting the fields above do not cover, as lines of its acme_srv.cfg with their section headers -- for example [CAhandler] timeout: 30, or [Challenge] dns_server_list: [\"10.0.0.53\"]. These are laid over the generated configuration, so a setting here replaces the one the fields produce. Show the configuration to see the result.": "Avançado. Qualquer definição do acme2certifier que os campos acima não cubram, como linhas do seu acme_srv.cfg com os cabeçalhos de secção -- por exemplo [CAhandler] timeout: 30, ou [Challenge] dns_server_list: [\"10.0.0.53\"]. São sobrepostas à configuração gerada, pelo que uma definição aqui substitui a que os campos produzem. Mostrar a configuração mostra o resultado.", +"Show the configuration": "Mostrar a configuração", +"the extra acme2certifier settings need their section headers, such as [CAhandler] above timeout: 30": "as definições adicionais do acme2certifier precisam dos cabeçalhos de secção, como [CAhandler] acima de timeout: 30", "Load from file": "Carregar de ficheiro", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Lê um ficheiro .html local para o campo acima. Nada é enviado: a página é guardada com a configuração.", "Value": "Valor", diff --git a/static/js/i18n/sv.js b/static/js/i18n/sv.js index fee9326..987d3cb 100644 --- a/static/js/i18n/sv.js +++ b/static/js/i18n/sv.js @@ -511,6 +511,41 @@ export default { "Buypass stopped issuing on 31 October 2025: choose another CA": "Buypass slutade utfärda den 31 oktober 2025: välj en annan certifikatutfärdare", "Google, SSL.com and Actalis need one, from your account with them, and so may your own CA. ZeroSSL gets its own from the e-mail address.": "Google, SSL.com och Actalis kräver en, från ditt konto hos dem, och det kan även din egen certifikatutfärdare. ZeroSSL hämtar en egen utifrån e-postadressen.", "Buypass stopped issuing certificates on 31 October 2025: choose another certificate authority for this account": "Buypass slutade utfärda certifikat den 31 oktober 2025: välj en annan certifikatutfärdare för det här kontot", +"Active Directory Certificate Services": "Active Directory Certificate Services", +"Active Directory": "Active Directory", +"Test the connection to the CA": "Testa anslutningen till CA:n", +"Asking the CA...": "Frågar CA:n...", +"Impacket is not installed on this node": "Impacket är inte installerat på den här noden", +"acme2certifier is not installed on this node": "acme2certifier är inte installerat på den här noden", +"An account enrolls with Active Directory Certificate Services over MS-ICPR, which needs Impacket (python3-impacket), and this node does not have it.": "Ett konto registrerar sig hos Active Directory Certificate Services över MS-ICPR, som kräver Impacket (python3-impacket), och den här noden har det inte.", +"An account enrolls with Active Directory Certificate Services through acme2certifier, which this node does not have yet. It is installed at the first issuance, or now.": "Ett konto registrerar sig hos Active Directory Certificate Services via acme2certifier, som den här noden inte har ännu. Det installeras vid första utfärdandet, eller nu.", +"The last install failed:": "Den senaste installationen misslyckades:", +"Install acme2certifier now": "Installera acme2certifier nu", +"Installing -- this can take a few minutes...": "Installerar -- det kan ta några minuter...", +"Enrollment": "Registrering", +"MS-ICPR -- the RPC interface certreq uses; needs only the Certification Authority role": "MS-ICPR -- RPC-gränssnittet som certreq använder; kräver bara rollen Certification Authority", +"Web enrollment -- the CA's /certsrv pages": "Webbregistrering -- CA:ns /certsrv-sidor", +"CA server": "CA-server", +"The server running the certification authority, such as ca01.corp.example.lan. For web enrollment, the server with the /certsrv pages.": "Servern som kör certifikatutfärdaren, till exempel ca01.corp.example.lan. Vid webbregistrering servern med /certsrv-sidorna.", +"CA name": "CA-namn", +"The certification authority's name as certutil shows it, such as Corp Issuing CA": "Certifikatutfärdarens namn så som certutil visar det, till exempel Corp Issuing CA", +"Certificate template": "Certifikatmall", +"A template the service account may enroll in, and that issues without a CA manager's approval": "En mall som tjänstekontot får registrera sig i, och som utfärdar utan godkännande från en CA-administratör", +"Authentication": "Autentisering", +"Kerberos for MS-ICPR, as Microsoft recommends; web enrollment takes NTLM or basic": "Kerberos för MS-ICPR, som Microsoft rekommenderar; webbregistrering tar NTLM eller basic", +"Active Directory domain": "Active Directory-domän", +"Such as corp.example.lan. Kerberos needs it.": "Till exempel corp.example.lan. Kerberos kräver den.", +"Domain controller": "Domänkontrollant", +"Optional. A domain controller's name or address, when the domain's name does not resolve to one from this node": "Valfritt. Namnet eller adressen till en domänkontrollant, när domänens namn inte slås upp till en från den här noden", +"Service account": "Tjänstekonto", +"A user allowed to enroll in the template, such as svc-acme": "En användare som får registrera sig i mallen, till exempel svc-acme", +"an Active Directory account needs the name of the certification authority's server, such as ca01.corp.example.lan": "ett Active Directory-konto kräver namnet på certifikatutfärdarens server, till exempel ca01.corp.example.lan", +"enrolling over MS-ICPR needs the certification authority's name, as certutil shows it, such as Corp Issuing CA": "registrering över MS-ICPR kräver certifikatutfärdarens namn, så som certutil visar det, till exempel Corp Issuing CA", +"Kerberos needs the Active Directory domain, such as corp.example.lan": "Kerberos kräver Active Directory-domänen, till exempel corp.example.lan", +"Extra acme2certifier settings": "Extra acme2certifier-inställningar", +"Advanced. Any acme2certifier setting the fields above do not cover, as lines of its acme_srv.cfg with their section headers -- for example [CAhandler] timeout: 30, or [Challenge] dns_server_list: [\"10.0.0.53\"]. These are laid over the generated configuration, so a setting here replaces the one the fields produce. Show the configuration to see the result.": "Avancerat. Vilken acme2certifier-inställning som helst som fälten ovan inte täcker, som rader ur dess acme_srv.cfg med sektionsrubriker -- till exempel [CAhandler] timeout: 30, eller [Challenge] dns_server_list: [\"10.0.0.53\"]. De läggs över den genererade konfigurationen, så en inställning här ersätter den fälten ger. Visa konfigurationen för att se resultatet.", +"Show the configuration": "Visa konfigurationen", +"the extra acme2certifier settings need their section headers, such as [CAhandler] above timeout: 30": "de extra acme2certifier-inställningarna behöver sina sektionsrubriker, till exempel [CAhandler] ovanför timeout: 30", "Load from file": "Läs in från fil", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Läser in en lokal .html-fil i fältet ovan. Inget laddas upp: sidan sparas med konfigurationen.", "Value": "Värde", diff --git a/static/js/pages/certificates.js b/static/js/pages/certificates.js index 461ce45..338f652 100644 --- a/static/js/pages/certificates.js +++ b/static/js/pages/certificates.js @@ -1,4 +1,4 @@ -import { $, CA_CHOICES, api, btn, closeDlg, esc, fieldRow, list, lists, localTime, openDlg, readForm, showText, download } from "../core.js"; +import { $, ADCS_FIELDS, CA_CHOICES, api, btn, closeDlg, esc, fieldRow, list, lists, localTime, openDlg, readForm, showText, download } from "../core.js"; import { E, renderEntity } from "../entities.js"; import { t, tn } from "../i18n.js"; import { refreshStatus, route } from "../shell.js"; @@ -53,17 +53,74 @@ export function dnsCredentialHelp(frm){ /* ---- certificate wizard ---- */ export const NEWOPT="__new__"; -const caLabel=a=>a.ca==="custom"?(String(a.server||"").replace(/^https?:\/\//,"").split("/")[0]||"custom"):(a.ca||"letsencrypt"); -/* The directory URL and the CA certificate rows mean something for a custom CA - only; the account editor hides them otherwise. */ -export function customCaRows(frm){ - const sel=frm.querySelector("#f_ca");if(!sel)return; - const els=["server","ca_bundle"].flatMap(k=>{ - let cell=frm.querySelector("#f_"+k);if(!cell)return []; - while(cell.parentNode&&cell.parentNode!==frm)cell=cell.parentNode; - return [frm.querySelector('label[for="f_'+k+'"]'),cell].filter(Boolean);}); - const sync=()=>els.forEach(el=>{el.style.display=sel.value==="custom"?"":"none";}); - sel.addEventListener("change",sync);sync(); +/* What an account's CA is called in a picker: a public CA by its short name, + your own by the server that runs it. */ +const caLabel=a=>a.ca==="custom"?(String(a.server||"").replace(/^https?:\/\//,"").split("/")[0]||"custom") + :a.ca==="adcs"?(a.adcs_host||"adcs"):(a.ca||"letsencrypt"); +/* Which rows an account's CA calls for beyond the common ones: a custom CA its + address and certificate; AD CS its server and credentials, with the CA name + and the domain for MS-ICPR and the CA certificate to trust for web enrollment + over HTTPS. EAB is a public CA's thing, or a custom one's. */ +export const CA_EXTRA=["server","ca_bundle",...ADCS_FIELDS.map(f=>f.k),"eab_kid","eab_hmac"]; +export function caFieldsShown(ca,method){ + if(ca==="custom")return ["server","ca_bundle","eab_kid","eab_hmac"]; + if(ca!=="adcs")return ["eab_kid","eab_hmac"]; + const keys=ADCS_FIELDS.map(f=>f.k).filter(k=>method!=="certsrv"||!["adcs_ca_name","adcs_domain","adcs_dc"].includes(k)); + return method==="certsrv"?keys.concat(["ca_bundle"]):keys; +} +/* Show the rows the chosen CA calls for, keep the authentication to what the + enrollment method takes, and offer to try the connection to the CA. `p` is + the field prefix ("" in the editor, "acc_" in the wizard); `when` says + whether the account rows are on at all. Returns the function that syncs. */ +export function caRows(frm,p,when){ + p=p||"";when=when||(()=>true); + const sel=frm.querySelector("#f_"+p+"ca");if(!sel)return ()=>{}; + const rowOf=k=>{let cell=frm.querySelector("#f_"+p+k);if(!cell)return []; + while(cell.parentNode&&cell.parentNode!==frm&&!(cell.parentNode.classList&&cell.parentNode.classList.contains("frm")))cell=cell.parentNode; + return [frm.querySelector('label[for="f_'+p+k+'"]'),cell].filter(Boolean);}; + const method=frm.querySelector("#f_"+p+"adcs_method"),auth=frm.querySelector("#f_"+p+"adcs_auth"); + adcsTestRow(frm,p); + const sync=()=>{ + const on=when()?caFieldsShown(sel.value,method?method.value:"msicpr"):[]; + CA_EXTRA.forEach(k=>rowOf(k).forEach(el=>{el.style.display=on.includes(k)?"":"none";})); + if(auth&&method){ + const takes=method.value==="certsrv"?["ntlm","basic"]:["kerberos","ntlm"]; + [...auth.querySelectorAll("option")].forEach(o=>{o.disabled=!takes.includes(o.value);}); + if(!takes.includes(auth.value))auth.value=takes[0]; + } + }; + sel.addEventListener("change",sync);if(method)method.addEventListener("change",sync);sync(); + return sync; +} +/* Under the extra settings: "Test the connection", which asks the CA for + nothing but a connection with the account as typed, so a wrong name or + password shows before the first certificate is requested; and "Show the + configuration", the acme_srv.cfg acme2certifier would be given, password + masked. In the last AD CS cell, so they show and hide with the rows. */ +function adcsTestRow(frm,p){ + const last=frm.querySelector("#f_"+p+"adcs_extra");if(!last)return; + const box=document.createElement("div");box.style.marginTop="8px"; + const out=document.createElement("div");out.className="hint";out.style.cssText="margin-top:6px;white-space:pre-wrap"; + const typed=()=>{ + const body={}; + ADCS_FIELDS.forEach(f=>{const el=frm.querySelector("#f_"+p+f.k);if(el)body[f.k]=el.value;}); + const cb=frm.querySelector("#f_"+p+"ca_bundle");if(cb)body.ca_bundle=cb.value; + return body; + }; + const test=btn(t("Test the connection to the CA"),"sm",async()=>{ + test.disabled=true;out.textContent=t("Asking the CA..."); + try{const r=await api("acme/adcs/test","POST",typed()); + out.innerHTML=''+(r.ok?t("reachable"):t("failed"))+" "+esc(r.message||"");} + catch(e){out.textContent=e.message;} + test.disabled=false; + }); + const show=btn(t("Show the configuration"),"sm",async()=>{ + try{const r=await api("acme/adcs/config","POST",typed()); + if(r.ok)showText("acme_srv.cfg",r.config);else out.textContent=r.error||"";} + catch(e){out.textContent=e.message;} + }); + show.style.marginLeft="6px"; + box.appendChild(test);box.appendChild(show);box.appendChild(out);last.parentNode.appendChild(box); } export const CERT_WIZ=[ {k:"domains",l:"Domain names",t:"textarea",h:"One per line. The first is the certificate's primary name. A wildcard such as *.example.com needs DNS-01."}, @@ -75,6 +132,7 @@ export const CERT_WIZ=[ h:"letsencrypt_test issues untrusted certificates but has no rate limits -- ideal while setting things up"}, {k:"acc_server",l:"ACME directory URL",t:"text",h:"Your own ACME server's directory, such as https://ca.example.lan/directory -- for example acme2certifier in front of Active Directory Certificate Services, step-ca or Vault"}, {k:"acc_ca_bundle",l:"CA certificate to trust",t:"textarea",h:"Optional. The PEM certificate of the CA that signed the ACME server's own HTTPS certificate, if this node does not trust it already"}, + ...ADCS_FIELDS.map(f=>Object.assign({},f,{k:"acc_"+f.k})), {k:"acc_eab_kid",l:"EAB key ID",t:"text",h:"Google, SSL.com and Actalis need one, from your account with them, and so may your own CA. ZeroSSL gets its own from the e-mail address."}, {k:"acc_eab_hmac",l:"EAB HMAC key",t:"password"}, {k:"challenge_id",l:"Challenge type",t:"select",o:[]}, @@ -106,17 +164,17 @@ export async function openCertWizard(){ const err=document.createElement("div");err.className="err"; const setRow=(k,on)=>{(rows[k]||[]).forEach(el=>{el.style.display=on?"":"none";});}; + const caSync=caRows(frm,"acc_",()=>frm.querySelector("#f_account_id").value===NEWOPT); const sync=()=>{ const newAcc=frm.querySelector("#f_account_id").value===NEWOPT; - ["acc_name","acc_email","acc_ca","acc_eab_kid","acc_eab_hmac"].forEach(k=>setRow(k,newAcc)); - const custom=frm.querySelector("#f_acc_ca").value==="custom"; - ["acc_server","acc_ca_bundle"].forEach(k=>setRow(k,newAcc&&custom)); + ["acc_name","acc_email","acc_ca"].forEach(k=>setRow(k,newAcc)); + caSync(); const newCh=frm.querySelector("#f_challenge_id").value===NEWOPT; const method=frm.querySelector("#f_ch_method").value; ["ch_name","ch_method"].forEach(k=>setRow(k,newCh)); ["dns_provider","dns_credentials"].forEach(k=>setRow(k,newCh&&method==="dns01")); }; - ["f_account_id","f_acc_ca","f_challenge_id","f_ch_method"].forEach(id=>{ + ["f_account_id","f_challenge_id","f_ch_method"].forEach(id=>{ const el=frm.querySelector("#"+id);if(el)el.addEventListener("change",sync);}); dnsCredentialHelp(frm); sync(); @@ -126,9 +184,11 @@ export async function openCertWizard(){ const body={domains:d.domains,name:d.name,key_type:d.key_type, auto_renew:d.auto_renew,issue:d.issue}; body.account=d.account_id===NEWOPT - ? {name:d.acc_name||("account-"+(d.acc_ca||"letsencrypt")),email:d.acc_email,ca:d.acc_ca, - server:d.acc_ca==="custom"?d.acc_server:"",ca_bundle:d.acc_ca==="custom"?d.acc_ca_bundle:"", - eab_kid:d.acc_eab_kid,eab_hmac:d.acc_eab_hmac} + ? Object.assign({name:d.acc_name||("account-"+(d.acc_ca||"letsencrypt")),email:d.acc_email,ca:d.acc_ca, + server:d.acc_ca==="custom"?d.acc_server:"", + ca_bundle:d.acc_ca==="custom"||d.acc_ca==="adcs"?d.acc_ca_bundle:"", + eab_kid:d.acc_ca==="adcs"?"":d.acc_eab_kid,eab_hmac:d.acc_ca==="adcs"?"":d.acc_eab_hmac}, + d.acc_ca==="adcs"?Object.fromEntries(ADCS_FIELDS.map(f=>[f.k,d["acc_"+f.k]])):{}) : {id:d.account_id}; body.challenge=d.challenge_id===NEWOPT ? {name:d.ch_name||(d.ch_method==="dns01"?("dns-"+(d.dns_provider||"01")):"http-01"), @@ -207,9 +267,33 @@ export function acmeSetupNotice(){ return card; } -/* Both notices the certificates page can show, in the order they matter. */ +/* The notices the certificates page can show, in the order they matter. */ export function certificateNotices(){ - return [acmeSetupNotice(), acmeNotice()]; + return [acmeSetupNotice(), acmeNotice(), adcsNotice()]; +} + +/* Active Directory Certificate Services needs acme2certifier on the node that + issues. The image has it; a node installed from a package or install.sh + fetches it at the first issuance -- or now, from here. */ +export function adcsNotice(){ + const a=acmeHealth&&acmeHealth.adcs; + if(!a||!a.wanted||(a.installed&&a.impacket))return null; + const card=document.createElement("div");card.className="card";card.style.borderColor="#e3cfa8"; + card.innerHTML='

'+(a.installed?t("Impacket is not installed on this node") + :t("acme2certifier is not installed on this node"))+'

'+ + '

'+(a.installed + ?t("An account enrolls with Active Directory Certificate Services over MS-ICPR, which needs Impacket (python3-impacket), and this node does not have it.") + :t("An account enrolls with Active Directory Certificate Services through acme2certifier, which this node does not have yet. It is installed at the first issuance, or now."))+"

"+ + (a.install_error?'

'+t("The last install failed:")+' '+ + esc(a.install_error.split("\n").slice(-3).join(" "))+"

":"")+"
"; + if(!a.installed){ + const foot=document.createElement("div");foot.className="bd";foot.style.cssText="border-top:1px solid var(--hair)"; + const b=btn(t("Install acme2certifier now"),"pri",async()=>{ + b.disabled=true;b.textContent=t("Installing -- this can take a few minutes..."); + try{await api("acme/adcs/install","POST",{});await route();}catch(e){b.textContent=e.message;}}); + foot.appendChild(b);card.appendChild(foot); + } + return card; } export function acmeNotice(){ diff --git a/tools/check-docs.py b/tools/check-docs.py index dd22739..baabb65 100644 --- a/tools/check-docs.py +++ b/tools/check-docs.py @@ -144,6 +144,16 @@ def check(ok, what, detail=""): check(row.group(1) == script_acme.group(1), "the documented acme.sh default is wrong", "docs say %s, install.sh uses %s" % (row.group(1), script_acme.group(1))) +# -- the acme2certifier release -------------------------------------------- +# The image carries it (ARG in the Dockerfile); a node installed from a +# package or install.sh fetches the release ham/adcs.py names. One release. +image_a2c = re.search(r"^ARG A2C_VERSION=([0-9.]+)$", dockerfile, re.M) +code_a2c = re.search(r'^A2C_VERSION = "([0-9.]+)"$', (ROOT / "ham" / "adcs.py").read_text(), re.M) +check(image_a2c is not None and code_a2c is not None, "the acme2certifier pin could not be read") +if image_a2c and code_a2c: + check(image_a2c.group(1) == code_a2c.group(1), "the image and ham/adcs.py pin different acme2certifier releases", + "Dockerfile %s, adcs.py %s" % (image_a2c.group(1), code_a2c.group(1))) + # -- recipes --------------------------------------------------------------- # Every recipe should be listed in the documentation, and every one should come # with example servers -- the shape of the answer is half of what they are for. diff --git a/tools/test-adcs.py b/tools/test-adcs.py new file mode 100644 index 0000000..5025e44 --- /dev/null +++ b/tools/test-adcs.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +"""Certificates from Active Directory Certificate Services, through the +acme2certifier the node runs for an issuance. + + python3 tools/test-adcs.py + +An ACME account with "adcs" as its CA names the CA's server, template and a +service account. What such an account refuses before it is stored, what +acme2certifier is given (its acme_srv.cfg), and what happens at an issuance: +acme2certifier is started on localhost, acme.sh is pointed at it, and it is +stopped and its configuration -- it holds the password -- removed afterwards. +A stand-in acme2certifier answers the directory; a stand-in acme.sh records +what it was given and what it found running. +""" +import configparser +import json +import os +import pathlib +import socket +import subprocess +import sys +import tempfile +import time +import urllib.request + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) +os.environ["HAM_DATA_DIR"] = tempfile.mkdtemp(prefix="ham-adcs-") +D = os.environ["HAM_DATA_DIR"] +os.environ["HAM_CERT_DIR"] = os.path.join(D, "certs") +os.environ["HAM_ERROR_DIR"] = os.path.join(D, "error-pages") +os.environ["HAM_ACME_HOME"] = os.path.join(D, "acme") +os.environ["HAM_ACME_SH"] = os.path.join(D, "acme.sh") +os.environ["HAM_A2C_DIR"] = os.path.join(D, "a2c-dir") +os.environ["HAM_A2C_STATE"] = os.path.join(D, "a2c-state") +os.environ["HAM_DRY_RUN"] = "1" +with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + os.environ["HAM_A2C_PORT"] = str(s.getsockname()[1]) +PORT = int(os.environ["HAM_A2C_PORT"]) + +# The stand-in acme2certifier: the WSGI application acme2certifier's entry +# point exports, served by the same waitress the real one is, answering a +# directory. It checks that it was given a configuration and where. +A2C_DIR = pathlib.Path(os.environ["HAM_A2C_DIR"]) +A2C_DIR.mkdir() +(A2C_DIR / "acme2certifier_wsgi.py").write_text(r''' +import json, os, pathlib +seen = {"cfg": os.environ.get("ACME_SRV_CONFIGFILE", ""), "cwd": os.getcwd(), + "cfg_text": pathlib.Path(os.environ.get("ACME_SRV_CONFIGFILE", "/nonexistent")).read_text() + if os.path.exists(os.environ.get("ACME_SRV_CONFIGFILE", "/nonexistent")) else ""} +def application(environ, start_response): + if environ.get("PATH_INFO") == "/directory": + body = {"newNonce": "n", "newAccount": "a", "newOrder": "o", "seen": seen} + start_response("200 OK", [("Content-Type", "application/json")]) + return [json.dumps(body).encode()] + start_response("404 Not Found", []) + return [b""] +''') + +# The stand-in acme.sh: records its arguments, whether acme2certifier's +# directory answered while it ran, and the configuration file's mode. +RUNS = os.path.join(D, "runs.txt") +os.makedirs(os.environ["HAM_ACME_HOME"], exist_ok=True) +pathlib.Path(os.environ["HAM_ACME_SH"]).write_text(r'''#!/bin/sh +up=no; python3 -c "import urllib.request,sys; urllib.request.urlopen('http://127.0.0.1:%d/directory', timeout=3)" 2>/dev/null && up=yes +mode=none; [ -f "$HAM_A2C_STATE/acme_srv.cfg" ] && mode=$(stat -c %%a "$HAM_A2C_STATE/acme_srv.cfg") +echo "up=$up cfg=$mode $*" >> "%s" +case "$*" in *--issue*) exit 1;; esac +exit 0 +''' % (PORT, RUNS)) +os.chmod(os.environ["HAM_ACME_SH"], 0o755) + +import ham; ham # noqa: E402 (route registration) +from ham import acme, adcs, mcp # noqa: E402 +from ham.config import load_config, save_config # noqa: E402 + +fails = [] + + +def ok(cond, msg): + print((" PASS " if cond else " FAIL ") + msg) + if not cond: + fails.append(msg) + + +ham.app.config["TESTING"] = True +client = ham.app.test_client() +cfg = load_config() +cfg["local"]["api_key"] = "ad-key" +save_config(cfg) +H = {"X-API-Key": "ad-key"} + +GOOD = {"name": "corp", "ca": "adcs", "adcs_method": "msicpr", "adcs_host": "ca01.corp.example.lan", + "adcs_ca_name": "Corp Issuing CA", "adcs_template": "WebServer", "adcs_auth": "kerberos", + "adcs_domain": "corp.example.lan", "adcs_dc": "", "adcs_user": "svc-acme", + "adcs_password": 'p%ss"w#d=x'} + + +def add_account(body, want): + r = client.post("/api/acme/accounts", headers=H, json=body) + err = (r.get_json() or {}).get("error") or "" + ok(r.status_code == want, "account %s -> %d %s" % (body.get("name"), r.status_code, err[:110])) + return r.get_json() or {}, err + + +print("what an account refuses") +_, err = add_account(dict(GOOD, name="no-host", adcs_host=""), 400) +ok("certification authority's server" in err, "without the CA server it says it needs one") +add_account(dict(GOOD, name="bad-host", adcs_host="ca01.corp example"), 400) +add_account(dict(GOOD, name="url-host", adcs_host="https://ca01.corp.example.lan"), 400) +_, err = add_account(dict(GOOD, name="no-template", adcs_template=""), 400) +ok("template" in err, "without a template it says so") +_, err = add_account(dict(GOOD, name="no-user", adcs_user=""), 400) +ok("service account" in err, "without a user it says so") +_, err = add_account(dict(GOOD, name="no-pw", adcs_password=""), 400) +ok("password" in err, "without a password it says so") +_, err = add_account(dict(GOOD, name="no-ca-name", adcs_ca_name=""), 400) +ok("certification authority's name" in err and "certutil" in err, "MS-ICPR without the CA name says so") +_, err = add_account(dict(GOOD, name="no-domain", adcs_domain=""), 400) +ok("Kerberos needs the Active Directory domain" in err, "Kerberos without the domain says so") +_, err = add_account(dict(GOOD, name="basic-rpc", adcs_auth="basic"), 400) +ok("MS-ICPR" in err and "kerberos or ntlm" in err, "MS-ICPR with basic authentication is refused") +_, err = add_account(dict(GOOD, name="web-krb", adcs_method="certsrv", adcs_auth="kerberos"), 400) +ok("ntlm or basic" in err, "web enrollment with Kerberos is refused") +_, err = add_account(dict(GOOD, name="multiline", adcs_password="a\nb"), 400) +ok("one line" in err, "a password over two lines is refused: " + err[:60]) +_, err = add_account(dict(GOOD, name="bad-pem", adcs_method="certsrv", adcs_auth="ntlm", ca_bundle="nope"), 400) +ok("PEM" in err, "web enrollment with a CA certificate that is not PEM is refused") +add_account(dict(GOOD, name="odd-method", adcs_method="ldap"), 400) +add_account(GOOD, 200) +add_account(dict(GOOD, name="ntlm-no-domain", adcs_auth="ntlm", adcs_domain=""), 200) +add_account(dict(GOOD, name="web", adcs_method="certsrv", adcs_auth="ntlm", adcs_ca_name="", adcs_domain=""), 200) +add_account({"name": "le", "ca": "letsencrypt"}, 200) +accs = {a["name"]: a for a in load_config()["acme"]["accounts"]} +ok(set(accs) == {"corp", "ntlm-no-domain", "web", "le"}, "only the valid accounts were stored: %s" % sorted(accs)) +ok(mcp.redact(accs["corp"])["adcs_password"] == "[redacted]", "the password is a secret to MCP") + +print("what acme2certifier is given") +text = adcs.config_text(accs["corp"]) +cp = configparser.ConfigParser(interpolation=None) +cp.read_string(text) +ca = cp["CAhandler"] +ok(ca["handler_module"].endswith("msicpr_ca_handler"), "MS-ICPR: acme2certifier's msicpr handler") +ok(ca["host"] == "ca01.corp.example.lan" and ca["ca_name"] == "Corp Issuing CA" and ca["template"] == "WebServer", + "with the CA server, its name and the template") +ok(ca["user"] == "svc-acme" and ca["password"] == 'p%ss"w#d=x', "the service account, the password as typed") +ok(ca["use_kerberos"] == "True" and ca["target_domain"] == "corp.example.lan" and "domain_controller" not in ca, + "Kerberos with the domain, and no domain controller when none was given") +ok(cp["DBhandler"]["dbfile"].startswith(os.environ["HAM_A2C_STATE"]), "its database lives in the state directory") +ok(cp["Challenge"]["challenge_validation_disable"] == "False" and cp["Nonce"]["nonce_check_disable"] == "False", + "nothing is switched off") +ok(cp["Account"]["contact_check_disable"] == "True" and cp["Account"]["tos_check_disable"] == "True", + "except the contact and the terms, which acme.sh here has neither of") +cp = configparser.ConfigParser(interpolation=None) +cp.read_string(adcs.config_text(dict(accs["corp"], adcs_auth="ntlm", adcs_dc="10.0.0.5"))) +ok(cp["CAhandler"]["use_kerberos"] == "False" and cp["CAhandler"]["domain_controller"] == "10.0.0.5", + "NTLM, with the domain controller given") +cp = configparser.ConfigParser(interpolation=None) +cp.read_string(adcs.config_text(accs["web"], "/state/ca-bundle.pem")) +ca = cp["CAhandler"] +ok(ca["handler_module"].endswith("mscertsrv_ca_handler") and ca["auth_method"] == "ntlm" + and ca["ca_bundle"] == "/state/ca-bundle.pem" and "ca_name" not in ca and "use_kerberos" not in ca, + "web enrollment: the mscertsrv handler, NTLM, the CA certificate to trust") +extra = ('[CAhandler]\ntimeout: 30\nkrb5_keytab: /etc/ham/svc.keytab\n[Challenge]\ndns_server_list: ["10.0.0.53"]\n' + '[Order]\nallowed_domainlist: ["*.corp.example.lan"]\n[DEFAULT]\ndebug: True\n') +cp = configparser.ConfigParser(interpolation=None) +cp.read_string(adcs.config_text(dict(accs["corp"], adcs_extra=extra))) +ok(cp["CAhandler"]["timeout"] == "30" and cp["CAhandler"]["host"] == "ca01.corp.example.lan", + "an extra setting replaces the generated one, the rest stays") +ok(cp["CAhandler"]["krb5_keytab"] == "/etc/ham/svc.keytab" and cp["Challenge"]["dns_server_list"] == '["10.0.0.53"]' + and cp["Order"]["allowed_domainlist"] == '["*.corp.example.lan"]', "new settings are added, in their sections") +ok(cp["DEFAULT"]["debug"] == "True", "a [DEFAULT] of the account's own is taken too") +ok(cp["DBhandler"]["dbfile"].startswith(os.environ["HAM_A2C_STATE"]) and cp["Challenge"]["challenge_validation_disable"] == "False", + "the database and the untouched settings are as before") +_, err = add_account(dict(GOOD, name="extra-db", adcs_extra="[DBhandler]\ndbfile: /tmp/x.db\n"), 400) +ok("[DBhandler]" in err, "extra settings may not move the database") +_, err = add_account(dict(GOOD, name="extra-nosec", adcs_extra="timeout: 30\n"), 400) +ok("section headers" in err, "extra settings without a section header say so") +_, err = add_account(dict(GOOD, name="extra-broken", adcs_extra="[CAhandler]\nthis is not a setting\n"), 400) +ok("acme_srv.cfg lines" in err, "extra settings that do not parse say so: " + err[:70]) +r = client.post("/api/acme/adcs/config", headers=H, json=dict(GOOD, adcs_extra="[CAhandler]\ntimeout: 30\n")).get_json() +ok(r["ok"] and "timeout = 30" in r["config"] and "msicpr_ca_handler" in r["config"], + "the configuration is shown with the extra settings laid over") +ok(GOOD["adcs_password"] not in r["config"] and "password = ********" in r["config"], "with the password masked") +r = client.post("/api/acme/adcs/config", headers=H, json=dict(GOOD, adcs_host="")).get_json() +ok(not r["ok"] and "certification authority's server" in r["error"], "or the reason an incomplete account is refused") +ok(acme.server_args(accs["corp"]) == ["--server", "http://127.0.0.1:%d/directory" % PORT], + "acme.sh is pointed at acme2certifier on localhost") +ok(acme.check_directory(accs["corp"]) == "", "the directory is not read ahead: nothing is running yet") + +print("issuing") +# The stand-in stands in: acme2certifier counts as installed, Impacket too. +adcs.installed = lambda: True +adcs.missing_for = lambda acc: "" +cfg = load_config() +cfg["acme"]["challenges"].append({"id": "ch1", "name": "http", "method": "http01"}) +cfg["acme"]["certificates"].append({"id": "c-corp", "name": "c-corp", "domains": "web.corp.example.lan", + "account": accs["corp"]["id"], "challenge": "ch1"}) +cfg["acme"]["certificates"].append({"id": "c-le", "name": "c-le", "domains": "le.example.lan", + "account": accs["le"]["id"], "challenge": "ch1"}) +save_config(cfg) +cfg = load_config() +certs = {c["id"]: c for c in cfg["acme"]["certificates"]} +t0 = time.time() +res = acme.acme_issue(cfg, certs["c-corp"]) +took = time.time() - t0 +runs = pathlib.Path(RUNS).read_text().splitlines() +ok(len(runs) == 2, "register and issue: %d runs, in %.1fs" % (len(runs), took)) +reg, iss = (runs + ["", ""])[:2] +ok(reg.startswith("up=yes") and iss.startswith("up=yes"), "acme2certifier answered its directory while acme.sh ran") +ok(" cfg=600 " in reg and " cfg=600 " in iss, "its configuration was on disk, mode 0600: %s" % reg.split()[1]) +ok("--register-account" in reg and "--server http://127.0.0.1:%d/directory" % PORT in reg, + "the account is registered with it") +ok("--issue --server http://127.0.0.1:%d/directory" % PORT in iss and "--ca-bundle" not in iss, + "the certificate is requested from it, over plain HTTP on localhost") +state = pathlib.Path(os.environ["HAM_A2C_STATE"]) +ok(not (state / "acme_srv.cfg").exists(), "the configuration, which holds the password, is gone afterwards") +try: + urllib.request.urlopen("http://127.0.0.1:%d/directory" % PORT, timeout=2) + still = True +except Exception: + still = False +ok(not still, "acme2certifier is stopped afterwards") +log_text = res.get("log") or "" +ok("Serving on http://127.0.0.1:%d" % PORT in log_text or "waitress" in log_text.lower() or "seen" in log_text + or len(log_text) > 0, "its output is kept with the issue log") +ok(not res["ok"] and "issuance failed" in res["error"], "the stand-in's refusal is reported: %s" % res["error"][:80]) +# What the stand-in saw: the configuration named by ACME_SRV_CONFIGFILE, in +# the directory acme2certifier's entry point lives in. +seen = None +# (the stand-in's view travelled in the directory the stand-in acme.sh fetched; read it again live) +with adcs.serving(accs["corp"], []) as why: + ok(why == "", "started again for a look: %s" % why) + body = json.loads(urllib.request.urlopen("http://127.0.0.1:%d/directory" % PORT, timeout=3).read()) + seen = body["seen"] +ok(seen and seen["cfg"] == str(state / "acme_srv.cfg") and seen["cwd"] == str(A2C_DIR), + "ACME_SRV_CONFIGFILE names the configuration, and it runs from acme2certifier's directory") +ok(seen and "handler_module = acme2certifier.cahandlers.msicpr_ca_handler" in seen["cfg_text"], + "and the configuration it read is the account's") + +before = len(pathlib.Path(RUNS).read_text().splitlines()) +acme.acme_issue(cfg, certs["c-le"]) +runs = pathlib.Path(RUNS).read_text().splitlines() +ok(len(runs) == before + 2 and runs[-1].startswith("up=no") and "--server letsencrypt" in runs[-1], + "a Let's Encrypt certificate runs without acme2certifier") + +print("when acme2certifier will not start") +good_command = adcs.command +adcs.command = lambda: [sys.executable, "-c", "import sys; print('boom: no such handler'); sys.exit(3)"] +adcs.READY_WAIT = 5 +before = len(pathlib.Path(RUNS).read_text().splitlines()) +res = acme.acme_issue(cfg, certs["c-corp"]) +ok(not res["ok"] and "did not start" in res["error"] and "boom" in res["error"], + "a start that fails says so, with its last words: %s" % res["error"][:100]) +ok(len(pathlib.Path(RUNS).read_text().splitlines()) == before, "and acme.sh was not run") +adcs.command = good_command + +print("when it is not installed") +adcs.installed = lambda: False +adcs.ensure_installed = lambda: (False, "the install did not finish: no apt here") +before = len(pathlib.Path(RUNS).read_text().splitlines()) +res = acme.acme_issue(cfg, certs["c-corp"]) +ok(not res["ok"] and "could not be installed" in res["error"] and "no apt here" in res["error"], + "a node without it that cannot get it says so: %s" % res["error"][:100]) +ok(len(pathlib.Path(RUNS).read_text().splitlines()) == before, "and acme.sh was not run") +r = client.get("/api/acme/health", headers=H).get_json() +ok(r.get("adcs", {}).get("wanted") is True and r["adcs"]["installed"] is False and r["adcs"]["port"] == PORT, + "the health check says AD CS is wanted and acme2certifier missing") +adcs.installed = lambda: True + +print("what acme2certifier's output explains") +ok(adcs.explain("x\nCAhandler.enroll() Could not get certificate from CA server\ny").startswith("Could not get certificate"), + "the CA's refusal") +ok("Failed to connect to endpoint mapper" in adcs.explain("Trying\nFailed to connect to endpoint mapper: [Errno -2] Name or service not known\n"), + "a connection that failed") +ok(adcs.explain("Failed to connect to endpoint mapper: refused\nCould not get certificate from CA server\n") + .startswith("Failed to connect"), "the specific line before the handler's summary") +ok(adcs.explain("all fine") == "", "nothing when nothing went wrong") + +print("testing the connection") +r = client.post("/api/acme/adcs/test", headers=H, json=dict(GOOD, adcs_host="")).get_json() +ok(r["ok"] is False and "certification authority's server" in r["message"], "an incomplete account is refused with why") +r = client.post("/api/acme/adcs/test", headers=H, + json=dict(GOOD, adcs_method="certsrv", adcs_auth="basic", adcs_host="localhost")).get_json() +ok(r["ok"] is False and "could not reach" in r["message"], "web enrollment nobody answers for: %s" % r["message"][:70]) + +print("the certificate wizard") +r = client.post("/api/wizard/certificate", headers=H, json={ + "domains": "app.corp.example.lan", "issue": False, "dry_run": True, + "account": dict(GOOD, name="wiz", adcs_host=""), "challenge": {"id": "ch1"}}) +ok(r.status_code == 400 and "certification authority's server" in (r.get_json() or {}).get("error", ""), + "a new AD CS account without the CA server is refused (%d)" % r.status_code) +r = client.post("/api/wizard/certificate", headers=H, json={ + "domains": "app.corp.example.lan", "issue": False, + "account": dict(GOOD, name="wiz"), "challenge": {"id": "ch1"}}) +ok(r.status_code == 200, "a complete one is created (%d %s)" % (r.status_code, ((r.get_json() or {}).get("error") or "")[:80])) +new = [a for a in load_config()["acme"]["accounts"] if a["name"] == "wiz"] +ok(new and new[0]["adcs_host"] == "ca01.corp.example.lan" and new[0]["adcs_password"] == GOOD["adcs_password"] + and new[0]["adcs_template"] == "WebServer", "with the CA server, template and credentials stored") + +print() +print("FAILED: %d" % len(fails) if fails else "all passed") +sys.exit(1 if fails else 0) diff --git a/tools/uitests/adcs.mjs b/tools/uitests/adcs.mjs new file mode 100644 index 0000000..307543e --- /dev/null +++ b/tools/uitests/adcs.mjs @@ -0,0 +1,188 @@ +/* Active Directory Certificate Services from the browser's side: the account + * editor and the certificate wizard show the CA server, template and + * credentials for "adcs" only, with the CA name and domain for MS-ICPR and + * the CA certificate for web enrollment; the accounts list and the wizard's + * picker name the CA by its server; the connection test sends what is typed; + * and the certificates page says when acme2certifier is missing. */ +let parseHTML; +for (const where of ["linkedom", process.env.LINKEDOM]) { + if (!where) continue; + try { ({ parseHTML } = await import(where)); break; } catch { /* try the next */ } +} +if (!parseHTML) { + console.log(" skipped: no DOM available (npm i linkedom, or set LINKEDOM)"); + process.exit(0); +} +const REPO = process.cwd(); +const { document, window } = parseHTML(` +

+
+
+ +
`); +globalThis.document = document; globalThis.window = window; +globalThis.location = { hash: "#/p:services" }; +globalThis.MutationObserver = class { observe(){} }; +globalThis.localStorage = { getItem: () => null, setItem(){}, removeItem(){} }; +globalThis.setTimeout = () => 0; +globalThis.confirm = () => true; +/* linkedom's selects: see proxy-protocol.mjs */ +const optProto = Object.getPrototypeOf(document.createElement("option")); +Object.defineProperty(optProto, "selected", { + configurable: true, + get() { return this.hasAttribute("selected"); }, + set(v) { + if (v) { const prev = this.parentElement && this.parentElement.querySelector("option[selected]"); + if (prev && prev !== this) prev.removeAttribute("selected"); this.setAttribute("selected", ""); } + else this.removeAttribute("selected"); + }, +}); +const selProto = Object.getPrototypeOf(document.createElement("select")); +Object.defineProperty(selProto, "value", { + configurable: true, + get() { const o = this.querySelector("option[selected]") || this.querySelector("option"); return o ? o.value : ""; }, + set(v) { const o = [...this.querySelectorAll("option")].find(x => x.value === String(v)); if (o) o.selected = true; }, +}); + +const ACCOUNTS = [ + { id: "a1", name: "le", email: "", ca: "letsencrypt" }, + { id: "a2", name: "corp", email: "", ca: "adcs", adcs_method: "msicpr", adcs_host: "ca01.corp.lan", + adcs_ca_name: "Corp Issuing CA", adcs_template: "WebServer", adcs_auth: "kerberos", adcs_domain: "corp.lan", + adcs_dc: "", adcs_user: "svc-acme", adcs_password: "pw" }, +]; +let sent = null, path = null; +let health = { ok: true, adcs: { wanted: true, installed: false, impacket: false, install_error: "curl: (6) no route" } }; +globalThis.fetch = async (u, o) => { + const p = String(u).replace(/^\/api\//, "").split("?")[0]; + if (o && o.body) { sent = JSON.parse(o.body); path = p; } + const bodies = { + "acme/accounts": ACCOUNTS, "acme/challenges": [{ id: "h1", name: "http", method: "http01" }], + "acme/certificates": [], "acme/status": {}, "acme/health": health, "acme/dnsapis": { ok: true, hooks: [] }, + "wizard/certificate": { ok: true, domains: ["app.corp.lan"], actions: [], warnings: [] }, + "acme/adcs/test": { ok: false, message: "Failed to connect to endpoint mapper" }, + "acme/adcs/config": { ok: true, config: "[CAhandler]\nhandler_module = x\npassword = ********\ntimeout = 30\n" }, + }; + return { ok: true, status: 200, json: async () => bodies[p] ?? {}, text: async () => "" }; +}; + +const { E, openEditor, renderEntity } = await import(REPO + "/static/js/entities.js"); +const { openCertWizard, certificateNotices, loadAcmeHealth } = await import(REPO + "/static/js/pages/certificates.js"); +const { CA_CHOICES } = await import(REPO + "/static/js/core.js"); + +let fail = 0; +const ok = (c, m) => { console.log((c ? " PASS " : " FAIL ") + m); if (!c) fail++; }; +const $ = s => document.querySelector(s); +const findButton = (root, label) => [...root.querySelectorAll("button")].find(b => b.textContent === label) || null; +const shown = k => { let el = $("#f_" + k); if (!el) return false; + while (el.parentNode && !el.parentNode.classList.contains("frm")) el = el.parentNode; + const lab = $('label[for="f_' + k + '"]'); + return el.style.display !== "none" && (!lab || lab.style.display !== "none"); }; +const setSelect = (sel, v) => { sel.value = v; sel.dispatchEvent(new window.Event("change")); }; +const ADCS = ["adcs_method", "adcs_host", "adcs_ca_name", "adcs_template", "adcs_auth", "adcs_domain", "adcs_dc", "adcs_user", "adcs_password", "adcs_extra"]; + +// -- "adcs" is a choice, named in words ---------------------------------------------- +const choice = CA_CHOICES.find(o => o && o.value === "adcs"); +ok(!!choice && /Active Directory/.test(choice.label), "the CA pickers offer Active Directory Certificate Services"); + +// -- the account editor ------------------------------------------------------------ +openEditor("acme/accounts"); +ok(ADCS.every(k => !shown(k)) && shown("eab_kid"), "a new account hides the AD CS rows and shows EAB"); +setSelect($("#f_ca"), "adcs"); +ok(ADCS.every(k => shown(k)), "picking Active Directory shows them all for MS-ICPR"); +ok(!shown("server") && !shown("ca_bundle") && !shown("eab_kid") && !shown("eab_hmac"), + "and hides the directory URL, the CA certificate and EAB"); +ok($("#f_adcs_template").value === "WebServer" && $("#f_adcs_auth").value === "kerberos", + "WebServer and Kerberos are the defaults"); +ok(!!findButton($("#dlgbody"), "Test the connection to the CA"), "the connection test is offered"); +setSelect($("#f_adcs_method"), "certsrv"); +ok(!shown("adcs_ca_name") && !shown("adcs_domain") && !shown("adcs_dc") && shown("ca_bundle") && shown("adcs_host"), + "web enrollment drops the CA name and domain and asks for the CA certificate to trust"); +ok($("#f_adcs_auth").value === "ntlm" && $('#f_adcs_auth option[value="kerberos"]').disabled + && !$('#f_adcs_auth option[value="basic"]').disabled, "and takes NTLM or basic, not Kerberos"); +setSelect($("#f_adcs_method"), "msicpr"); +ok($("#f_adcs_auth").value === "ntlm" && !$('#f_adcs_auth option[value="kerberos"]').disabled + && $('#f_adcs_auth option[value="basic"]').disabled, "back to MS-ICPR: NTLM stays, basic is out"); +setSelect($("#f_ca"), "letsencrypt"); +ok(ADCS.every(k => !shown(k)) && shown("eab_kid"), "a public CA hides them again"); + +openEditor("acme/accounts", ACCOUNTS[1]); +ok($("#f_ca").value === "adcs" && shown("adcs_host") && $("#f_adcs_host").value === "ca01.corp.lan" + && $("#f_adcs_ca_name").value === "Corp Issuing CA" && $("#f_adcs_user").value === "svc-acme", + "editing an AD CS account shows its server, CA name and service account"); +$("#f_adcs_password").value = "typed"; +await findButton($("#dlgbody"), "Test the connection to the CA").onclick(); +ok(path === "acme/adcs/test" && sent && sent.adcs_host === "ca01.corp.lan" && sent.adcs_password === "typed" + && sent.adcs_method === "msicpr" && sent.adcs_ca_name === "Corp Issuing CA", + "the connection test sends the account as typed"); +ok($("#dlgbody").textContent.includes("Failed to connect to endpoint mapper"), "and shows what came back"); +$("#f_adcs_extra").value = "[CAhandler]\ntimeout: 30"; +await findButton($("#dlgbody"), "Show the configuration").onclick(); +ok(path === "acme/adcs/config" && sent && sent.adcs_extra === "[CAhandler]\ntimeout: 30" && sent.adcs_password === "typed", + "the configuration view sends the account as typed, extra settings included"); +ok($("#dlgbody").textContent.includes("timeout = 30") && $("#dlgbody").textContent.includes("********"), + "and shows the configuration it got back"); + +// -- the accounts list names the CA by its server --------------------------------- +const box = document.createElement("div"); document.body.appendChild(box); +await renderEntity("acme/accounts", box); +const corpRow = [...box.querySelectorAll("tr")].find(tr => tr.textContent.includes("corp")); +ok(!!corpRow && corpRow.textContent.includes("Active Directory") && corpRow.textContent.includes("ca01.corp.lan") + && corpRow.textContent.includes("WebServer"), "the list says Active Directory, the CA server and the template"); + +// -- the certificate wizard -------------------------------------------------------- +await openCertWizard(); +const acc = $("#f_account_id"); +ok([...acc.querySelectorAll("option")].some(o => o.textContent === "corp (ca01.corp.lan)"), + "an existing AD CS account is offered by its CA server"); +setSelect(acc, "__new__"); +ok(shown("acc_ca") && ADCS.every(k => !shown("acc_" + k)) && shown("acc_eab_kid"), + "a new account: the AD CS rows wait for Active Directory"); +setSelect($("#f_acc_ca"), "adcs"); +ok(ADCS.every(k => shown("acc_" + k)) && !shown("acc_server") && !shown("acc_eab_kid"), + "Active Directory shows them and hides the directory URL and EAB"); +setSelect(acc, "a1"); +ok(ADCS.every(k => !shown("acc_" + k)) && !shown("acc_ca"), "an existing account hides the new-account rows again"); +setSelect(acc, "__new__"); +$("#f_domains").value = "app.corp.lan"; +$("#f_acc_name").value = "corp2"; +$("#f_acc_adcs_host").value = "ca02.corp.lan"; +$("#f_acc_adcs_ca_name").value = "Corp CA 2"; +$("#f_acc_adcs_domain").value = "corp.lan"; +$("#f_acc_adcs_user").value = "svc"; +$("#f_acc_adcs_password").value = "secret"; +$("#f_acc_eab_kid").value = "leftover"; +$("#f_issue").checked = false; +await findButton(document, "Create").onclick(); +ok(path === "wizard/certificate" && sent && sent.account.ca === "adcs" && sent.account.adcs_host === "ca02.corp.lan" + && sent.account.adcs_ca_name === "Corp CA 2" && sent.account.adcs_template === "WebServer" + && sent.account.adcs_auth === "kerberos" && sent.account.adcs_user === "svc" && sent.account.adcs_password === "secret", + "the new AD CS account is sent with its server, CA name, template and credentials"); +ok(sent.account.eab_kid === "" && sent.account.server === "" && sent.account.adcs_extra === "", "and without EAB or a directory URL, the extra settings empty"); +setSelect($("#f_acc_ca"), "letsencrypt"); +sent = null; +await findButton(document, "Preview").onclick(); +ok(sent && sent.account.ca === "letsencrypt" && !("adcs_host" in sent.account) && sent.account.eab_kid === "leftover", + "a public CA is sent without the AD CS fields, EAB as typed"); + +// -- the certificates page says when acme2certifier is missing ---------------------- +await loadAcmeHealth(); +let cards = certificateNotices().filter(Boolean); +let card = cards.find(c => c.textContent.includes("acme2certifier is not installed")); +ok(!!card && card.textContent.includes("no route") && !!findButton(card, "Install acme2certifier now"), + "a node that wants AD CS and lacks acme2certifier is told, with the last failure and an install button"); +await findButton(card, "Install acme2certifier now").onclick(); +ok(path === "acme/adcs/install", "the button asks the node to install it"); +health = { ok: true, adcs: { wanted: true, installed: true, impacket: false } }; +await loadAcmeHealth(); +card = certificateNotices().filter(Boolean).find(c => c.textContent.includes("Impacket is not installed")); +ok(!!card && !findButton(card, "Install acme2certifier now"), "with acme2certifier but no Impacket: a note, no button"); +health = { ok: true, adcs: { wanted: true, installed: true, impacket: true } }; +await loadAcmeHealth(); +ok(!certificateNotices().filter(Boolean).some(c => c.textContent.includes("acme2certifier")), "all there: nothing said"); +health = { ok: true, adcs: { wanted: false, installed: false, impacket: false } }; +await loadAcmeHealth(); +ok(!certificateNotices().filter(Boolean).some(c => c.textContent.includes("acme2certifier")), + "no AD CS account: nothing said either"); + +console.log(fail ? "\n" + fail + " failed" : "\nall passed"); +process.exit(fail ? 1 : 0); diff --git a/tools/uitests/run-all.sh b/tools/uitests/run-all.sh index dde8318..694bdee 100755 --- a/tools/uitests/run-all.sh +++ b/tools/uitests/run-all.sh @@ -2,7 +2,7 @@ # Every front-end check, in the order that fails fastest. set -e cd "$(dirname "$0")/../.." -for t in undefined-names module-graph navigate routing pages certificates-banner managed-objects dialogs recipes table-sorting table-sorting-dom account-gear webui-blurb admin-sync overwrite config-diff update-peers sparkline stats-refresh theme access mobile history localtime login-2fa maintenance oauth ratelimit proxy-protocol language wizard-sections beta-channel large-configs lists-errorpages rules-wizard condition-wizard crowdsec npm-parity custom-ca mcp i18n; do +for t in undefined-names module-graph navigate routing pages certificates-banner managed-objects dialogs recipes table-sorting table-sorting-dom account-gear webui-blurb admin-sync overwrite config-diff update-peers sparkline stats-refresh theme access mobile history localtime login-2fa maintenance oauth ratelimit proxy-protocol language wizard-sections beta-channel large-configs lists-errorpages rules-wizard condition-wizard crowdsec npm-parity custom-ca adcs mcp i18n; do printf '\n== %s ==\n' "$t" node "tools/uitests/$t.mjs" done From 8e7bdcba077ea740218bc7f6fc11a1dcb0d1133e Mon Sep 17 00:00:00 2001 From: Alex Van de Putte Date: Mon, 9 Nov 2026 22:17:21 -0500 Subject: [PATCH 05/13] 2.2.0: review fixes for AD CS and renewal -- no code-loading settings, the .rpm layout, the log, the plan by key type Extra acme2certifier settings: a *_module must be one of acme2certifier's own modules; handler_file, hooks_file, eab_handler_file, log_file and a krb5_kinit_path outside the system's are refused. acme2certifier takes a file path there, which made the box a way to run a file as root. The extra settings are redacted for MCP. The on-demand install on .rpm systems fetches both of acme2certifier's packages and finds them under /opt/acme2certifier (tried on Rocky Linux 9); the connection test runs acme2certifier's module rather than a script on the path, does not wait behind an issuance, and reads every WWW-Authenticate header. acme2certifier's output reaches the stored log. The configuration view masks by setting. renewal_due reads acme.sh's plan from the folder of the certificate's current key type. run_script moves to util for both installers. --- ham/acme.py | 42 +++++++++------ ham/adcs.py | 113 +++++++++++++++++++++++++++++++--------- ham/base.py | 5 +- ham/crowdsec.py | 24 +-------- ham/mcp.py | 2 +- ham/util.py | 20 +++++++ static/js/i18n/da.js | 2 + static/js/i18n/de.js | 2 + static/js/i18n/es.js | 2 + static/js/i18n/fi.js | 2 + static/js/i18n/fr.js | 2 + static/js/i18n/it.js | 2 + static/js/i18n/nb.js | 2 + static/js/i18n/nl.js | 2 + static/js/i18n/pt.js | 2 + static/js/i18n/sv.js | 2 + tools/test-adcs.py | 45 +++++++++++++++- tools/test-custom-ca.py | 9 +++- 18 files changed, 210 insertions(+), 70 deletions(-) diff --git a/ham/acme.py b/ham/acme.py index 30e1f10..18a4d4f 100644 --- a/ham/acme.py +++ b/ham/acme.py @@ -301,14 +301,22 @@ def _acme_issue(cfg, cert, force=False): if not ok: return {"ok": False, "error": "acme2certifier is not installed on this node and could not " "be installed: %s" % out.strip()[-300:], "log": "\n".join(trace)} - with adcs.serving(acc, trace) as why: + a2c_out = [] + with adcs.serving(acc, a2c_out) as why: if why: - return {"ok": False, "error": why, "log": "\n".join(trace)} + return {"ok": False, "error": why, "log": "\n".join(x for x in trace + a2c_out if x)} res = _acme_run_issue(cfg, cert, acc, ch, doms, force, trace) - if not res.get("ok") and adcs.is_adcs(acc) and "error" in res: - said = adcs.explain("\n".join(trace)) - if said: - res["error"] = "%s -- acme2certifier: %s" % (res["error"], said) + # acme2certifier's output is complete only now that it has stopped. Its + # last lines go under acme.sh's in the log; the line that says what the + # CA made of the request goes into the error. + said = "\n".join(a2c_out) + if said.strip(): + res["log"] = "%s\n\n--- acme2certifier ---\n%s" % (res.get("log") or "", + "\n".join(said.splitlines()[-40:])) + if not res.get("ok") and "error" in res: + why = adcs.explain(said) + if why: + res["error"] = "%s -- acme2certifier: %s" % (res["error"], why) return res @@ -476,17 +484,17 @@ def renewal_due(cfg, cert, now=None): doms = parse_domains(cert) if not doms: return False - nxt = None - for sub in (doms[0] + "_ecc", doms[0]): - try: - text = (Path(ACME_HOME) / sub / (doms[0] + ".conf")).read_text() - except OSError: - continue - m = re.search(r"^Le_NextRenewTime='?(\d+)'?\s*$", text, re.M) - if m: - nxt = int(m.group(1)) - break - if nxt is None or nxt > now: + # acme.sh keeps an EC certificate in _ecc and an RSA one in . + # Only the folder of the key type this certificate has now: one issued as + # EC and switched to RSA leaves the _ecc folder behind, with a plan that + # is forever in the past. + folder = doms[0] + ("_ecc" if cert.get("key_type", "ec-256").startswith("ec") else "") + try: + text = (Path(ACME_HOME) / folder / (doms[0] + ".conf")).read_text() + except OSError: + return False + m = re.search(r"^Le_NextRenewTime='?(\d+)'?\s*$", text, re.M) + if not m or int(m.group(1)) > now: return False last = (cfg.get("_meta", {}).get("issue_log") or {}).get(cert.get("id")) or {} if last and not last.get("ok"): diff --git a/ham/adcs.py b/ham/adcs.py index 6a1636e..3897a62 100644 --- a/ham/adcs.py +++ b/ham/adcs.py @@ -31,7 +31,8 @@ import urllib.error import urllib.request -from .base import A2C_DIR, A2C_PORT, A2C_STATE, DATA_DIR, app, log +from .base import A2C_DIRS, A2C_PORT, A2C_STATE, DATA_DIR, app, log +from .util import run_script # The release the image carries and a node installs on demand. The weekly # upstream check (.github/workflows/upstream.yml) moves this, the Dockerfile's @@ -39,8 +40,12 @@ A2C_VERSION = "0.46.4" A2C_DEB = ("https://github.com/grindsa/acme2certifier/releases/download/%(v)s/" "acme2certifier_%(v)s-1_all.deb") +# Its .rpm is two packages: the program, under /opt/acme2certifier, and the +# one that pulls in its Python dependencies. A2C_RPM = ("https://github.com/grindsa/acme2certifier/releases/download/%(v)s/" "acme2certifier-%(v)s-1.0.noarch.rpm") +A2C_RPM_PY = ("https://github.com/grindsa/acme2certifier/releases/download/%(v)s/" + "acme2certifier-python3-%(v)s-1.0.noarch.rpm") METHODS = ("msicpr", "certsrv") AUTH = {"msicpr": ("kerberos", "ntlm"), "certsrv": ("ntlm", "basic")} @@ -55,6 +60,16 @@ "adcs_extra": ""} # Sections the extra settings may not touch: the database is the node's. EXTRA_KEEP_OUT = ("DBhandler",) +# Settings that name code for acme2certifier to load, and it takes a file's +# path as readily as a module's name -- which would make this box a way to +# run any file on the node as root, for whoever can edit an account (an MCP +# client allowed to make changes among them). A module has to be one of +# acme2certifier's own; the settings that only ever take a path are refused, +# and so is the one that picks where its log is written. +_BUILTIN_MODULE = re.compile(r"^acme2certifier\.[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)*$") +EXTRA_REFUSED = ("handler_file", "hooks_file", "eab_handler_file", "log_file") +# kinit is run by path; only where a system's own lives. +_KINIT = re.compile(r"^/(usr/(local/)?)?s?bin/kinit$") _HOST = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$") # Something configparser can write and read back as one value. @@ -106,6 +121,15 @@ def check_account(acc): if sec in EXTRA_KEEP_OUT: raise ValueError("the extra acme2certifier settings may not set [%s]: the node keeps " "acme2certifier's database itself" % sec) + for key, value in extra.items(sec, raw=True): + if key in EXTRA_REFUSED: + raise ValueError("the extra acme2certifier settings may not set %s" % key) + if key.endswith("_module") and not _BUILTIN_MODULE.match((value or "").strip()): + raise ValueError("the extra acme2certifier settings may name one of acme2certifier's " + "own modules for %s, such as acme2certifier.cahandlers.msicpr_ca_handler, " + "not a file or another package" % key) + if key == "krb5_kinit_path" and not _KINIT.match((value or "").strip()): + raise ValueError("krb5_kinit_path must be the system's kinit, such as /usr/bin/kinit") if method == "certsrv" and (acc.get("ca_bundle") or "").strip(): from . import acme blocks = acme._pem_blocks(acc["ca_bundle"]) @@ -141,9 +165,13 @@ def directory_url(): return "http://127.0.0.1:%d/directory" % A2C_PORT -def config_text(acc, bundle_path=""): +_MASKED = re.compile(r"passw|secret|hmac|token", re.I) + + +def config_text(acc, bundle_path="", mask=False): """acme2certifier's acme_srv.cfg for this account: the CA handler for AD CS, - SQLite in the state directory, and nothing switched off.""" + SQLite in the state directory, and nothing switched off. With mask, for + showing: every setting that holds a credential reads ********.""" method = acc.get("adcs_method") or "msicpr" auth = auth_of(acc) cp = configparser.ConfigParser(interpolation=None) @@ -184,6 +212,11 @@ def config_text(acc, bundle_path=""): for key, value in extra.items(sec, raw=True): cp.set(sec, key, value) cp["DBhandler"] = {"handler": "wsgi", "dbfile": str(A2C_STATE / "acme_srv.db")} + if mask: + for sec in [configparser.DEFAULTSECT] + cp.sections(): + for key in list(cp[sec] if sec != configparser.DEFAULTSECT else cp.defaults()): + if _MASKED.search(key) and (sec == configparser.DEFAULTSECT or cp.has_option(sec, key)): + cp.set(sec, key, "********") from io import StringIO out = StringIO() out.write("# Generated by haproxy-manager for one run of acme2certifier -- do not edit by hand.\n") @@ -193,10 +226,8 @@ def config_text(acc, bundle_path=""): def masked_config(acc): """The configuration as acme2certifier would get it, the password hidden.""" - text = config_text(acc, "" if (acc.get("ca_bundle") or "").strip() - and (acc.get("adcs_method") or "msicpr") == "certsrv" else "") - pw = acc.get("adcs_password") or "" - return text.replace(pw, "********") if pw else text + return config_text(acc, "" if (acc.get("ca_bundle") or "").strip() + and (acc.get("adcs_method") or "msicpr") == "certsrv" else "", mask=True) @app.post("/api/acme/adcs/config") @@ -242,8 +273,8 @@ def _env(cfg_path): # lose whatever python still had buffered: the lines that say why. env["PYTHONUNBUFFERED"] = "1" # The WSGI entry point sits in acme2certifier's directory, not on the - # module path; the package itself is installed where python finds it. - env["PYTHONPATH"] = str(A2C_DIR) + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "") + # module path; so does the package itself where the .rpm installed it. + env["PYTHONPATH"] = str(a2c_dir()) + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "") return env @@ -257,9 +288,19 @@ def command(): # -------------------------------------------------------------------------- # installed? +def a2c_dir(): + """Where acme2certifier is: its .deb puts it in /var/www/acme2certifier, + its .rpm in /opt/acme2certifier. The first that holds its WSGI entry + point; the first of all when none does yet.""" + return next((d for d in A2C_DIRS if (d / "acme2certifier_wsgi.py").is_file()), A2C_DIRS[0]) + + def installed(): - """acme2certifier, and for MS-ICPR the Impacket it enrolls through.""" - return (A2C_DIR / "acme2certifier_wsgi.py").is_file() and _module("acme2certifier") + """acme2certifier's entry point, and its package: on python's path (the + .deb) or beside the entry point (the .rpm).""" + d = a2c_dir() + return (d / "acme2certifier_wsgi.py").is_file() and ( + _module("acme2certifier") or (d / "acme2certifier" / "__init__.py").is_file()) def _module(name): @@ -276,6 +317,12 @@ def version(): from importlib.metadata import version as v return v("acme2certifier") except Exception: + pass + try: # the .rpm carries no package metadata, only the source + text = (a2c_dir() / "acme2certifier" / "acme_srv" / "version.py").read_text() + m = re.search(r"__version__\s*=\s*[\"']([^\"']+)", text) + return m.group(1) if m else "" + except OSError: return "" @@ -334,7 +381,7 @@ def serving(acc, log_lines): proc = None try: with open(out_path, "w") as out: - proc = subprocess.Popen(command(), cwd=str(A2C_DIR), env=_env(cfg_path), + proc = subprocess.Popen(command(), cwd=str(a2c_dir()), env=_env(cfg_path), stdout=out, stderr=subprocess.STDOUT, start_new_session=True) deadline = time.time() + READY_WAIT while time.time() < deadline: @@ -384,7 +431,7 @@ def explain(log_text): # -------------------------------------------------------------------------- # testing the connection to the CA -TEST_TOOL = "a2c-msicpr-connection-test" +TEST_MODULE = "acme2certifier.tools.a2c_msicpr_connection_test" @app.post("/api/acme/adcs/test") @@ -402,15 +449,20 @@ def api_adcs_test(): return jsonify({"ok": False, "message": why}) if (acc.get("adcs_method") or "msicpr") != "msicpr": return _test_certsrv(acc) - tool = shutil.which(TEST_TOOL) or ("/usr/bin/" + TEST_TOOL) - if not os.path.exists(tool): - return jsonify({"ok": False, "message": "%s is not installed with acme2certifier here" % TEST_TOOL}) - with _serving: + # acme2certifier's own connection test, run as the module it is so that + # it is found wherever the package was installed. + cmd = [sys.executable, "-m", TEST_MODULE] + # An issuance holds acme2certifier's configuration for as long as acme.sh + # runs, which can be minutes: say so rather than hold this request for them. + if not _serving.acquire(timeout=3): + return jsonify({"ok": False, "message": "a certificate is being requested through acme2certifier " + "right now; test again when it is done"}) + try: A2C_STATE.mkdir(parents=True, exist_ok=True) cfg_path = write_config(acc) try: - p = subprocess.run([tool], capture_output=True, text=True, timeout=60, env=_env(cfg_path), - cwd=str(A2C_DIR)) + p = subprocess.run(cmd, capture_output=True, text=True, timeout=60, env=_env(cfg_path), + cwd=str(a2c_dir())) out = p.stdout + p.stderr except subprocess.TimeoutExpired: out = "timed out after 60 seconds" @@ -421,12 +473,20 @@ def api_adcs_test(): cfg_path.unlink() except OSError: pass + finally: + _serving.release() lines = [l for l in out.splitlines() if l.strip() and not l.startswith(("CAhandler.", "Helper."))] failed = re.search(r"Failed|Error|error|denied|refused|not known|timed out", out) return jsonify({"ok": not failed, "message": "\n".join(lines[-6:])[:1500] or ("the CA answered" if not failed else "no answer")}) +def offers_ntlm(headers): + """Does a 401 offer NTLM: in any of its WWW-Authenticate headers, by name + or as Negotiate.""" + return any(re.match(r"\s*(NTLM|Negotiate)\b", h, re.I) for h in headers.get_all("WWW-Authenticate") or []) + + def _test_certsrv(acc): """Web enrollment: whether /certsrv/ answers and takes the credentials.""" import base64 @@ -444,11 +504,13 @@ def _test_certsrv(acc): with urllib.request.urlopen(req, timeout=15, context=ctx) as r: return jsonify({"ok": True, "message": "%s answers (HTTP %d)" % (url, r.status)}) except urllib.error.HTTPError as e: - if e.code == 401 and auth_of(acc) == "ntlm" and "NTLM" in (e.headers.get("WWW-Authenticate") or ""): + # IIS sends one header per scheme, Negotiate before NTLM. + offers = ", ".join(e.headers.get_all("WWW-Authenticate") or []) + if e.code == 401 and auth_of(acc) == "ntlm" and offers_ntlm(e.headers): return jsonify({"ok": True, "message": "%s answers and asks for NTLM, as the account expects" % url}) if e.code == 401: return jsonify({"ok": False, "message": "%s refused the credentials (HTTP 401; it offers %s)" - % (url, e.headers.get("WWW-Authenticate") or "no authentication")}) + % (url, offers or "no authentication")}) return jsonify({"ok": False, "message": "%s answered HTTP %d" % (url, e.code)}) except urllib.error.URLError as e: import ssl as _ssl @@ -487,9 +549,11 @@ def _test_certsrv(acc): RPM_INSTALL = """set -e tmp=$(mktemp -d) curl -fsSL --max-time 300 '%(rpm)s' -o "$tmp/acme2certifier.rpm" -if command -v dnf >/dev/null; then dnf install -y "$tmp/acme2certifier.rpm" python3-impacket; else yum install -y "$tmp/acme2certifier.rpm" python3-impacket; fi +curl -fsSL --max-time 300 '%(py)s' -o "$tmp/acme2certifier-python3.rpm" +if command -v dnf >/dev/null; then pm=dnf; else pm=yum; fi +$pm install -y "$tmp/acme2certifier.rpm" "$tmp/acme2certifier-python3.rpm" python3-impacket rm -rf "$tmp" -""" % {"rpm": A2C_RPM % {"v": A2C_VERSION}} +""" % {"rpm": A2C_RPM % {"v": A2C_VERSION}, "py": A2C_RPM_PY % {"v": A2C_VERSION}} def _tried_recently(): @@ -517,8 +581,7 @@ def install(): else: return False, ("this node has neither apt-get nor dnf; install acme2certifier %s and Impacket " "by hand" % A2C_VERSION) - from .crowdsec import _run_script - rc, out = _run_script(script, timeout=900) + rc, out = run_script(script, timeout=900) if rc != 0 or not installed(): return False, "the install did not finish:\n" + (out or "").strip()[-1500:] return True, "installed acme2certifier %s" % (version() or A2C_VERSION) diff --git a/ham/base.py b/ham/base.py index 14fcd2c..df41a06 100644 --- a/ham/base.py +++ b/ham/base.py @@ -33,10 +33,11 @@ ACME_HOME = Path(os.environ.get("HAM_ACME_HOME", str(Path.home() / ".acme.sh"))) ACME_SH = os.environ.get("HAM_ACME_SH", str(ACME_HOME / "acme.sh")) # acme2certifier, for certificates from Active Directory Certificate -# Services: where its .deb puts it, the localhost port it is run on for an +# Services: where its .deb or its .rpm puts it, the localhost port it is run on for an # issuance, and where its database and the configuration of a run live -- # beside acme.sh's state, so the container's volume keeps them. -A2C_DIR = Path(os.environ.get("HAM_A2C_DIR", "/var/www/acme2certifier")) +A2C_DIRS = ([Path(os.environ["HAM_A2C_DIR"])] if os.environ.get("HAM_A2C_DIR") + else [Path("/var/www/acme2certifier"), Path("/opt/acme2certifier")]) A2C_PORT = int(os.environ.get("HAM_A2C_PORT", "9081")) A2C_STATE = Path(os.environ.get("HAM_A2C_STATE", str(ACME_HOME / "acme2certifier"))) # One directory up: this module lives in ham/, the files it points at sit diff --git a/ham/crowdsec.py b/ham/crowdsec.py index ce5adbb..94b1180 100644 --- a/ham/crowdsec.py +++ b/ham/crowdsec.py @@ -21,7 +21,6 @@ import re import secrets import shutil -import signal import subprocess import tempfile import time @@ -29,7 +28,7 @@ from .base import (CROWDSEC_BIN, CROWDSEC_BOUNCER_CFG, CROWDSEC_HTML, CROWDSEC_SPOE, DATA_DIR, _lock, _requests, app, log) from .config import DEFAULT_CONFIG, load_config, save_config -from .util import run +from .util import run, run_script from . import access, errorpages UNIT = "crowdsec-spoa-bouncer" @@ -506,25 +505,6 @@ def api_crowdsec_test(): """ % {"key": REPO_KEY_URL, "fpr": REPO_KEY_FPR} -def _run_script(script, timeout): - """Run a shell script in its own process group, and on timeout kill the - group -- the shell alone dying would leave apt or dnf running, mid-transaction.""" - env = dict(os.environ) - env.pop("NOTIFY_SOCKET", None) - p = subprocess.Popen(["sh", "-c", script], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True, env=env, start_new_session=True) - try: - out, _ = p.communicate(timeout=timeout) - return p.returncode, out or "" - except subprocess.TimeoutExpired: - try: - os.killpg(p.pid, signal.SIGTERM) - except OSError: - pass - out, _ = p.communicate() - return 124, (out or "") + "\n[stopped after %ds]" % timeout - - def install(): """Put the bouncer on this node from CrowdSec's repository: (ok, output).""" if installed(): @@ -536,7 +516,7 @@ def install(): else: return False, ("this node has neither apt-get nor dnf; install crowdsec-haproxy-spoa-bouncer " "from CrowdSec's repository by hand") - rc, out = _run_script(script, timeout=900) + rc, out = run_script(script, timeout=900) if rc != 0 or not installed(): return False, "the install did not finish:\n" + (out or "").strip()[-1500:] return True, "installed; it starts when CrowdSec is on and Apply runs" diff --git a/ham/mcp.py b/ham/mcp.py index 13b8a89..6f0dfe0 100644 --- a/ham/mcp.py +++ b/ham/mcp.py @@ -50,7 +50,7 @@ REDACTED = "[redacted]" # Values an assistant has no use for and should never be sent: whatever it is # shown leaves this machine for the model's provider. -SECRET_KEY_RE = re.compile(r"(passw|(^|_)pass$|secret|token|credential|private|salt|hash$|hmac|(^|_)key$|totp)", re.I) +SECRET_KEY_RE = re.compile(r"(passw|(^|_)pass$|secret|token|credential|private|salt|hash$|hmac|(^|_)key$|(^|_)extra$|totp)", re.I) # Secrets that sit inside text rather than under a key: the rendered # haproxy.cfg carries the key that signs single sign-on cookies and the # password hashes of the users HAProxy checks, and a preview or a dry run diff --git a/ham/util.py b/ham/util.py index f2330c1..94e44ef 100644 --- a/ham/util.py +++ b/ham/util.py @@ -4,6 +4,7 @@ from datetime import timezone import os import re +import signal import subprocess from .base import CERT_DIR, DRY_RUN @@ -29,6 +30,25 @@ def run(cmd, env=None, timeout=600): return 124, "timed out: " + " ".join(cmd) +def run_script(script, timeout): + """Run a shell script in its own process group, and on timeout kill the + group -- the shell alone dying would leave apt or dnf running, mid-transaction.""" + env = dict(os.environ) + env.pop("NOTIFY_SOCKET", None) + p = subprocess.Popen(["sh", "-c", script], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, env=env, start_new_session=True) + try: + out, _ = p.communicate(timeout=timeout) + return p.returncode, out or "" + except subprocess.TimeoutExpired: + try: + os.killpg(p.pid, signal.SIGTERM) + except OSError: + pass + out, _ = p.communicate() + return 124, (out or "") + "\n[stopped after %ds]" % timeout + + def _sec(name): """Sanitize a user-supplied name for use in generated configs/filenames.""" return re.sub(r"[^A-Za-z0-9_.-]", "_", (name or "").strip()) or "unnamed" diff --git a/static/js/i18n/da.js b/static/js/i18n/da.js index 42035c3..958f797 100644 --- a/static/js/i18n/da.js +++ b/static/js/i18n/da.js @@ -546,6 +546,8 @@ export default { "Advanced. Any acme2certifier setting the fields above do not cover, as lines of its acme_srv.cfg with their section headers -- for example [CAhandler] timeout: 30, or [Challenge] dns_server_list: [\"10.0.0.53\"]. These are laid over the generated configuration, so a setting here replaces the one the fields produce. Show the configuration to see the result.": "Avanceret. Enhver acme2certifier-indstilling, felterne ovenfor ikke dækker, som linjer fra dens acme_srv.cfg med sektionsoverskrifter -- f.eks. [CAhandler] timeout: 30 eller [Challenge] dns_server_list: [\"10.0.0.53\"]. De lægges oven på den genererede konfiguration, så en indstilling her erstatter den, felterne giver. Vis konfigurationen for at se resultatet.", "Show the configuration": "Vis konfigurationen", "the extra acme2certifier settings need their section headers, such as [CAhandler] above timeout: 30": "de ekstra acme2certifier-indstillinger kræver deres sektionsoverskrifter, f.eks. [CAhandler] over timeout: 30", +"krb5_kinit_path must be the system's kinit, such as /usr/bin/kinit": "krb5_kinit_path skal være systemets kinit, f.eks. /usr/bin/kinit", +"a certificate is being requested through acme2certifier right now; test again when it is done": "der anmodes om et certifikat via acme2certifier lige nu; test igen, når det er færdigt", "Load from file": "Indlæs fra fil", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Læser en lokal .html-fil ind i feltet ovenfor. Intet uploades: siden gemmes sammen med konfigurationen.", "Value": "Værdi", diff --git a/static/js/i18n/de.js b/static/js/i18n/de.js index 0a4f619..caeadba 100644 --- a/static/js/i18n/de.js +++ b/static/js/i18n/de.js @@ -546,6 +546,8 @@ export default { "Advanced. Any acme2certifier setting the fields above do not cover, as lines of its acme_srv.cfg with their section headers -- for example [CAhandler] timeout: 30, or [Challenge] dns_server_list: [\"10.0.0.53\"]. These are laid over the generated configuration, so a setting here replaces the one the fields produce. Show the configuration to see the result.": "Fortgeschritten. Jede acme2certifier-Einstellung, die die Felder oben nicht abdecken, als Zeilen seiner acme_srv.cfg mit ihren Abschnittsüberschriften -- etwa [CAhandler] timeout: 30 oder [Challenge] dns_server_list: [\"10.0.0.53\"]. Sie werden über die erzeugte Konfiguration gelegt, eine Einstellung hier ersetzt also die aus den Feldern. Konfiguration anzeigen zeigt das Ergebnis.", "Show the configuration": "Konfiguration anzeigen", "the extra acme2certifier settings need their section headers, such as [CAhandler] above timeout: 30": "die weiteren acme2certifier-Einstellungen brauchen ihre Abschnittsüberschriften, etwa [CAhandler] über timeout: 30", +"krb5_kinit_path must be the system's kinit, such as /usr/bin/kinit": "krb5_kinit_path muss das kinit des Systems sein, etwa /usr/bin/kinit", +"a certificate is being requested through acme2certifier right now; test again when it is done": "gerade wird ein Zertifikat über acme2certifier angefordert; testen Sie erneut, wenn das fertig ist", "Load from file": "Aus Datei laden", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Liest eine lokale .html-Datei in das Feld oben. Nichts wird hochgeladen: die Seite wird mit der Konfiguration gespeichert.", "Value": "Wert", diff --git a/static/js/i18n/es.js b/static/js/i18n/es.js index 560d14f..8fe184c 100644 --- a/static/js/i18n/es.js +++ b/static/js/i18n/es.js @@ -546,6 +546,8 @@ export default { "Advanced. Any acme2certifier setting the fields above do not cover, as lines of its acme_srv.cfg with their section headers -- for example [CAhandler] timeout: 30, or [Challenge] dns_server_list: [\"10.0.0.53\"]. These are laid over the generated configuration, so a setting here replaces the one the fields produce. Show the configuration to see the result.": "Avanzado. Cualquier ajuste de acme2certifier que los campos de arriba no cubran, como líneas de su acme_srv.cfg con sus cabeceras de sección -- por ejemplo [CAhandler] timeout: 30, o [Challenge] dns_server_list: [\"10.0.0.53\"]. Se superponen a la configuración generada, así que un ajuste aquí sustituye al que producen los campos. Mostrar la configuración enseña el resultado.", "Show the configuration": "Mostrar la configuración", "the extra acme2certifier settings need their section headers, such as [CAhandler] above timeout: 30": "los ajustes adicionales de acme2certifier necesitan sus cabeceras de sección, como [CAhandler] encima de timeout: 30", +"krb5_kinit_path must be the system's kinit, such as /usr/bin/kinit": "krb5_kinit_path debe ser el kinit del sistema, como /usr/bin/kinit", +"a certificate is being requested through acme2certifier right now; test again when it is done": "ahora mismo se está solicitando un certificado a través de acme2certifier; pruebe de nuevo cuando termine", "Load from file": "Cargar desde archivo", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Lee un archivo .html local en el campo de arriba. No se sube nada: la página se guarda con la configuración.", "Value": "Valor", diff --git a/static/js/i18n/fi.js b/static/js/i18n/fi.js index 799cda9..2f57e87 100644 --- a/static/js/i18n/fi.js +++ b/static/js/i18n/fi.js @@ -546,6 +546,8 @@ export default { "Advanced. Any acme2certifier setting the fields above do not cover, as lines of its acme_srv.cfg with their section headers -- for example [CAhandler] timeout: 30, or [Challenge] dns_server_list: [\"10.0.0.53\"]. These are laid over the generated configuration, so a setting here replaces the one the fields produce. Show the configuration to see the result.": "Edistynyt. Mikä tahansa acme2certifierin asetus, jota yllä olevat kentät eivät kata, sen acme_srv.cfg-tiedoston riveinä osio-otsikoineen -- esimerkiksi [CAhandler] timeout: 30 tai [Challenge] dns_server_list: [\"10.0.0.53\"]. Ne asetetaan luodun kokoonpanon päälle, joten tässä annettu asetus korvaa kenttien tuottaman. Näytä kokoonpano näyttää tuloksen.", "Show the configuration": "Näytä kokoonpano", "the extra acme2certifier settings need their section headers, such as [CAhandler] above timeout: 30": "acme2certifierin lisäasetukset tarvitsevat osio-otsikkonsa, esimerkiksi [CAhandler] rivin timeout: 30 yläpuolelle", +"krb5_kinit_path must be the system's kinit, such as /usr/bin/kinit": "krb5_kinit_path-asetuksen on oltava järjestelmän kinit, esim. /usr/bin/kinit", +"a certificate is being requested through acme2certifier right now; test again when it is done": "varmennetta pyydetään juuri nyt acme2certifierin kautta; testaa uudelleen, kun se on valmis", "Load from file": "Lataa tiedostosta", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Lukee paikallisen .html-tiedoston yllä olevaan kenttään. Mitään ei ladata palvelimelle: sivu tallennetaan asetusten mukana.", "Value": "Arvo", diff --git a/static/js/i18n/fr.js b/static/js/i18n/fr.js index d3351ac..d073acd 100644 --- a/static/js/i18n/fr.js +++ b/static/js/i18n/fr.js @@ -546,6 +546,8 @@ export default { "Advanced. Any acme2certifier setting the fields above do not cover, as lines of its acme_srv.cfg with their section headers -- for example [CAhandler] timeout: 30, or [Challenge] dns_server_list: [\"10.0.0.53\"]. These are laid over the generated configuration, so a setting here replaces the one the fields produce. Show the configuration to see the result.": "Avancé. Tout réglage d'acme2certifier que les champs ci-dessus ne couvrent pas, sous forme de lignes de son acme_srv.cfg avec leurs en-têtes de section -- par exemple [CAhandler] timeout: 30, ou [Challenge] dns_server_list: [\"10.0.0.53\"]. Ils sont posés sur la configuration générée : un réglage ici remplace celui que produisent les champs. Afficher la configuration montre le résultat.", "Show the configuration": "Afficher la configuration", "the extra acme2certifier settings need their section headers, such as [CAhandler] above timeout: 30": "les réglages acme2certifier supplémentaires ont besoin de leurs en-têtes de section, par exemple [CAhandler] au-dessus de timeout: 30", +"krb5_kinit_path must be the system's kinit, such as /usr/bin/kinit": "krb5_kinit_path doit être le kinit du système, par exemple /usr/bin/kinit", +"a certificate is being requested through acme2certifier right now; test again when it is done": "un certificat est en cours de demande via acme2certifier ; refaites le test quand ce sera terminé", "Load from file": "Charger depuis un fichier", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Lit un fichier .html local dans le champ ci-dessus. Rien n'est téléversé : la page est enregistrée avec la configuration.", "Value": "Valeur", diff --git a/static/js/i18n/it.js b/static/js/i18n/it.js index 10435b5..8bc9888 100644 --- a/static/js/i18n/it.js +++ b/static/js/i18n/it.js @@ -546,6 +546,8 @@ export default { "Advanced. Any acme2certifier setting the fields above do not cover, as lines of its acme_srv.cfg with their section headers -- for example [CAhandler] timeout: 30, or [Challenge] dns_server_list: [\"10.0.0.53\"]. These are laid over the generated configuration, so a setting here replaces the one the fields produce. Show the configuration to see the result.": "Avanzato. Qualsiasi impostazione di acme2certifier non coperta dai campi qui sopra, come righe del suo acme_srv.cfg con le intestazioni di sezione -- ad esempio [CAhandler] timeout: 30, oppure [Challenge] dns_server_list: [\"10.0.0.53\"]. Vengono sovrapposte alla configurazione generata, quindi un'impostazione qui sostituisce quella prodotta dai campi. Mostra la configurazione fa vedere il risultato.", "Show the configuration": "Mostra la configurazione", "the extra acme2certifier settings need their section headers, such as [CAhandler] above timeout: 30": "le impostazioni acme2certifier aggiuntive richiedono le intestazioni di sezione, ad esempio [CAhandler] sopra timeout: 30", +"krb5_kinit_path must be the system's kinit, such as /usr/bin/kinit": "krb5_kinit_path deve essere il kinit di sistema, ad esempio /usr/bin/kinit", +"a certificate is being requested through acme2certifier right now; test again when it is done": "in questo momento è in corso la richiesta di un certificato tramite acme2certifier; riprova il test al termine", "Load from file": "Carica da file", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Legge un file .html locale nel campo qui sopra. Nulla viene caricato sul server: la pagina viene salvata con la configurazione.", "Value": "Valore", diff --git a/static/js/i18n/nb.js b/static/js/i18n/nb.js index 629a4b4..fa86174 100644 --- a/static/js/i18n/nb.js +++ b/static/js/i18n/nb.js @@ -546,6 +546,8 @@ export default { "Advanced. Any acme2certifier setting the fields above do not cover, as lines of its acme_srv.cfg with their section headers -- for example [CAhandler] timeout: 30, or [Challenge] dns_server_list: [\"10.0.0.53\"]. These are laid over the generated configuration, so a setting here replaces the one the fields produce. Show the configuration to see the result.": "Avansert. Enhver acme2certifier-innstilling feltene over ikke dekker, som linjer fra dens acme_srv.cfg med seksjonsoverskrifter -- for eksempel [CAhandler] timeout: 30, eller [Challenge] dns_server_list: [\"10.0.0.53\"]. De legges over den genererte konfigurasjonen, så en innstilling her erstatter den feltene gir. Vis konfigurasjonen for å se resultatet.", "Show the configuration": "Vis konfigurasjonen", "the extra acme2certifier settings need their section headers, such as [CAhandler] above timeout: 30": "de ekstra acme2certifier-innstillingene trenger seksjonsoverskriftene sine, for eksempel [CAhandler] over timeout: 30", +"krb5_kinit_path must be the system's kinit, such as /usr/bin/kinit": "krb5_kinit_path må være systemets kinit, for eksempel /usr/bin/kinit", +"a certificate is being requested through acme2certifier right now; test again when it is done": "et sertifikat blir forespurt via acme2certifier akkurat nå; test igjen når det er ferdig", "Load from file": "Last inn fra fil", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Leser en lokal .html-fil inn i feltet ovenfor. Ingenting lastes opp: siden lagres sammen med konfigurasjonen.", "Value": "Verdi", diff --git a/static/js/i18n/nl.js b/static/js/i18n/nl.js index fd2d427..a522ddc 100644 --- a/static/js/i18n/nl.js +++ b/static/js/i18n/nl.js @@ -546,6 +546,8 @@ export default { "Advanced. Any acme2certifier setting the fields above do not cover, as lines of its acme_srv.cfg with their section headers -- for example [CAhandler] timeout: 30, or [Challenge] dns_server_list: [\"10.0.0.53\"]. These are laid over the generated configuration, so a setting here replaces the one the fields produce. Show the configuration to see the result.": "Geavanceerd. Elke acme2certifier-instelling die de velden hierboven niet dekken, als regels uit zijn acme_srv.cfg met hun sectiekoppen -- bijvoorbeeld [CAhandler] timeout: 30, of [Challenge] dns_server_list: [\"10.0.0.53\"]. Ze worden over de gegenereerde configuratie gelegd, dus een instelling hier vervangt die uit de velden. Toon de configuratie laat het resultaat zien.", "Show the configuration": "Toon de configuratie", "the extra acme2certifier settings need their section headers, such as [CAhandler] above timeout: 30": "de extra acme2certifier-instellingen hebben hun sectiekoppen nodig, zoals [CAhandler] boven timeout: 30", +"krb5_kinit_path must be the system's kinit, such as /usr/bin/kinit": "krb5_kinit_path moet de kinit van het systeem zijn, zoals /usr/bin/kinit", +"a certificate is being requested through acme2certifier right now; test again when it is done": "er wordt op dit moment een certificaat aangevraagd via acme2certifier; test opnieuw als dat klaar is", "Load from file": "Uit bestand laden", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Leest een lokaal .html-bestand in het veld hierboven. Er wordt niets geüpload: de pagina wordt met de configuratie opgeslagen.", "Value": "Waarde", diff --git a/static/js/i18n/pt.js b/static/js/i18n/pt.js index 234b998..6b35935 100644 --- a/static/js/i18n/pt.js +++ b/static/js/i18n/pt.js @@ -546,6 +546,8 @@ export default { "Advanced. Any acme2certifier setting the fields above do not cover, as lines of its acme_srv.cfg with their section headers -- for example [CAhandler] timeout: 30, or [Challenge] dns_server_list: [\"10.0.0.53\"]. These are laid over the generated configuration, so a setting here replaces the one the fields produce. Show the configuration to see the result.": "Avançado. Qualquer definição do acme2certifier que os campos acima não cubram, como linhas do seu acme_srv.cfg com os cabeçalhos de secção -- por exemplo [CAhandler] timeout: 30, ou [Challenge] dns_server_list: [\"10.0.0.53\"]. São sobrepostas à configuração gerada, pelo que uma definição aqui substitui a que os campos produzem. Mostrar a configuração mostra o resultado.", "Show the configuration": "Mostrar a configuração", "the extra acme2certifier settings need their section headers, such as [CAhandler] above timeout: 30": "as definições adicionais do acme2certifier precisam dos cabeçalhos de secção, como [CAhandler] acima de timeout: 30", +"krb5_kinit_path must be the system's kinit, such as /usr/bin/kinit": "krb5_kinit_path tem de ser o kinit do sistema, como /usr/bin/kinit", +"a certificate is being requested through acme2certifier right now; test again when it is done": "está a ser pedido um certificado através do acme2certifier neste momento; teste de novo quando terminar", "Load from file": "Carregar de ficheiro", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Lê um ficheiro .html local para o campo acima. Nada é enviado: a página é guardada com a configuração.", "Value": "Valor", diff --git a/static/js/i18n/sv.js b/static/js/i18n/sv.js index 1408f51..014fd66 100644 --- a/static/js/i18n/sv.js +++ b/static/js/i18n/sv.js @@ -546,6 +546,8 @@ export default { "Advanced. Any acme2certifier setting the fields above do not cover, as lines of its acme_srv.cfg with their section headers -- for example [CAhandler] timeout: 30, or [Challenge] dns_server_list: [\"10.0.0.53\"]. These are laid over the generated configuration, so a setting here replaces the one the fields produce. Show the configuration to see the result.": "Avancerat. Vilken acme2certifier-inställning som helst som fälten ovan inte täcker, som rader ur dess acme_srv.cfg med sektionsrubriker -- till exempel [CAhandler] timeout: 30, eller [Challenge] dns_server_list: [\"10.0.0.53\"]. De läggs över den genererade konfigurationen, så en inställning här ersätter den fälten ger. Visa konfigurationen för att se resultatet.", "Show the configuration": "Visa konfigurationen", "the extra acme2certifier settings need their section headers, such as [CAhandler] above timeout: 30": "de extra acme2certifier-inställningarna behöver sina sektionsrubriker, till exempel [CAhandler] ovanför timeout: 30", +"krb5_kinit_path must be the system's kinit, such as /usr/bin/kinit": "krb5_kinit_path måste vara systemets kinit, till exempel /usr/bin/kinit", +"a certificate is being requested through acme2certifier right now; test again when it is done": "ett certifikat begärs via acme2certifier just nu; testa igen när det är klart", "Load from file": "Läs in från fil", "Reads a local .html file into the field above. Nothing is uploaded: the page is saved with the configuration.": "Läser in en lokal .html-fil i fältet ovan. Inget laddas upp: sidan sparas med konfigurationen.", "Value": "Värde", diff --git a/tools/test-adcs.py b/tools/test-adcs.py index 5025e44..6ece211 100644 --- a/tools/test-adcs.py +++ b/tools/test-adcs.py @@ -45,6 +45,7 @@ A2C_DIR.mkdir() (A2C_DIR / "acme2certifier_wsgi.py").write_text(r''' import json, os, pathlib +print("stand-in acme2certifier loaded", flush=True) seen = {"cfg": os.environ.get("ACME_SRV_CONFIGFILE", ""), "cwd": os.getcwd(), "cfg_text": pathlib.Path(os.environ.get("ACME_SRV_CONFIGFILE", "/nonexistent")).read_text() if os.path.exists(os.environ.get("ACME_SRV_CONFIGFILE", "/nonexistent")) else ""} @@ -133,6 +134,8 @@ def add_account(body, want): add_account({"name": "le", "ca": "letsencrypt"}, 200) accs = {a["name"]: a for a in load_config()["acme"]["accounts"]} ok(set(accs) == {"corp", "ntlm-no-domain", "web", "le"}, "only the valid accounts were stored: %s" % sorted(accs)) +ok(mcp.redact(dict(accs["corp"], adcs_extra="[CAhandler]\npassword: x"))["adcs_extra"] == "[redacted]", + "and so are the extra settings, which can hold one") ok(mcp.redact(accs["corp"])["adcs_password"] == "[redacted]", "the password is a secret to MCP") print("what acme2certifier is given") @@ -174,6 +177,17 @@ def add_account(body, want): "the database and the untouched settings are as before") _, err = add_account(dict(GOOD, name="extra-db", adcs_extra="[DBhandler]\ndbfile: /tmp/x.db\n"), 400) ok("[DBhandler]" in err, "extra settings may not move the database") +for bad, what in (("[CAhandler]\nhandler_module: /tmp/evil.py\n", "a handler by file path"), + ("[CAhandler]\nhandler_module: os\n", "a module that is not acme2certifier's"), + ("[Hooks]\nhooks_file: /tmp/evil.py\n", "a hooks file"), + ("[EABhandler]\neab_handler_module: ../x.py\n", "an EAB handler by path"), + ("[CAhandler:two]\nhandler_file: /tmp/evil.py\n", "a handler file in a second handler"), + ("[Helper]\nlog_file: /etc/cron.d/x\n", "a log file of its choosing"), + ("[CAhandler]\nkrb5_kinit_path: /tmp/kinit\n", "a kinit outside the system's")): + _, err = add_account(dict(GOOD, name="extra-code", adcs_extra=bad), 400) + ok("extra acme2certifier settings" in err or "kinit" in err, "extra settings may not name %s: %s" % (what, err[:60])) +add_account(dict(GOOD, name="extra-builtin", adcs_extra="[EABhandler]\neab_handler_module: acme2certifier.eabhandlers.kid_profile_handler\n" + "[CAhandler]\nkrb5_kinit_path: /usr/bin/kinit\n"), 200) _, err = add_account(dict(GOOD, name="extra-nosec", adcs_extra="timeout: 30\n"), 400) ok("section headers" in err, "extra settings without a section header say so") _, err = add_account(dict(GOOD, name="extra-broken", adcs_extra="[CAhandler]\nthis is not a setting\n"), 400) @@ -182,12 +196,33 @@ def add_account(body, want): ok(r["ok"] and "timeout = 30" in r["config"] and "msicpr_ca_handler" in r["config"], "the configuration is shown with the extra settings laid over") ok(GOOD["adcs_password"] not in r["config"] and "password = ********" in r["config"], "with the password masked") +r = client.post("/api/acme/adcs/config", headers=H, + json=dict(GOOD, adcs_password="e", adcs_extra="[EABhandler]\nhmac_key: other-sekrit\n")).get_json() +ok(r["ok"] and "other-sekrit" not in r["config"] and "hmac_key = ********" in r["config"] + and "handler_module = acme2certifier" in r["config"], + "masked by setting, not by text: a one-letter password leaves the rest readable, a credential in the extras is hidden") r = client.post("/api/acme/adcs/config", headers=H, json=dict(GOOD, adcs_host="")).get_json() ok(not r["ok"] and "certification authority's server" in r["error"], "or the reason an incomplete account is refused") ok(acme.server_args(accs["corp"]) == ["--server", "http://127.0.0.1:%d/directory" % PORT], "acme.sh is pointed at acme2certifier on localhost") ok(acme.check_directory(accs["corp"]) == "", "the directory is not read ahead: nothing is running yet") +print("where acme2certifier is installed") +ok(not adcs.installed() or adcs._module("acme2certifier"), "an entry point without its package is not an install") +(A2C_DIR / "acme2certifier" / "acme_srv").mkdir(parents=True) +(A2C_DIR / "acme2certifier" / "__init__.py").write_text("") +(A2C_DIR / "acme2certifier" / "acme_srv" / "version.py").write_text('__version__ = "9.9.9"\n') +ok(adcs.installed() and adcs.a2c_dir() == A2C_DIR, "the package beside the entry point counts, as the .rpm lays it out") +ok(adcs.version() in ("9.9.9", adcs.A2C_VERSION), "its version is read from the package when there is no metadata: %s" % adcs.version()) +import email.message # noqa: E402 +hdrs = email.message.Message() +hdrs["WWW-Authenticate"] = "Negotiate" +hdrs["WWW-Authenticate"] = "NTLM" +ok(adcs.offers_ntlm(hdrs), "a 401 that offers Negotiate before NTLM, as IIS does, offers NTLM") +basic = email.message.Message() +basic["WWW-Authenticate"] = 'Basic realm="x"' +ok(not adcs.offers_ntlm(basic), "one that offers only basic does not") + print("issuing") # The stand-in stands in: acme2certifier counts as installed, Impacket too. adcs.installed = lambda: True @@ -222,8 +257,8 @@ def add_account(body, want): still = False ok(not still, "acme2certifier is stopped afterwards") log_text = res.get("log") or "" -ok("Serving on http://127.0.0.1:%d" % PORT in log_text or "waitress" in log_text.lower() or "seen" in log_text - or len(log_text) > 0, "its output is kept with the issue log") +ok("--- acme2certifier ---" in log_text and "stand-in acme2certifier loaded" in log_text.split("--- acme2certifier ---")[1], + "its output is kept with the issue log, under acme.sh's") ok(not res["ok"] and "issuance failed" in res["error"], "the stand-in's refusal is reported: %s" % res["error"][:80]) # What the stand-in saw: the configuration named by ACME_SRV_CONFIGFILE, in # the directory acme2certifier's entry point lives in. @@ -280,6 +315,12 @@ def add_account(body, want): print("testing the connection") r = client.post("/api/acme/adcs/test", headers=H, json=dict(GOOD, adcs_host="")).get_json() ok(r["ok"] is False and "certification authority's server" in r["message"], "an incomplete account is refused with why") +adcs._serving.acquire() +t0 = time.time() +r = client.post("/api/acme/adcs/test", headers=H, json=GOOD).get_json() +adcs._serving.release() +ok(r["ok"] is False and "being requested" in r["message"] and time.time() - t0 < 10, + "while an issuance has acme2certifier, the test says so instead of waiting for it") r = client.post("/api/acme/adcs/test", headers=H, json=dict(GOOD, adcs_method="certsrv", adcs_auth="basic", adcs_host="localhost")).get_json() ok(r["ok"] is False and "could not reach" in r["message"], "web enrollment nobody answers for: %s" % r["message"][:70]) diff --git a/tools/test-custom-ca.py b/tools/test-custom-ca.py index 21ace1e..43742a3 100644 --- a/tools/test-custom-ca.py +++ b/tools/test-custom-ca.py @@ -253,7 +253,14 @@ def at(seconds_ago): meta = {"_meta": {"issue_log": {}}} ok(acme.renewal_due(meta, c("d", "day.example.lan"), now), "a certificate acme.sh planned to renew by now is due") ok(not acme.renewal_due(meta, c("l", "later.example.lan"), now), "one planned for later is not") -ok(acme.renewal_due(meta, c("r", "rsa.example.lan"), now), "an RSA certificate's plan is read too (no _ecc folder)") +ok(acme.renewal_due(meta, dict(c("r", "rsa.example.lan"), key_type="rsa-2048"), now), + "an RSA certificate's plan is read from its own folder (no _ecc)") +plan("switched.example.lan", now - 86400) # issued as EC once: the folder stays +plan("switched.example.lan", now + 3600, ecc=False) # RSA now, and not due +ok(not acme.renewal_due(meta, dict(c("s", "switched.example.lan"), key_type="rsa-2048"), now), + "a certificate switched from EC to RSA is judged by its RSA plan, not the _ecc folder left behind") +ok(acme.renewal_due(meta, dict(c("s", "switched.example.lan"), key_type="ec-256"), now), + "and by the EC one while it is an EC certificate") ok(not acme.renewal_due(meta, c("n", "never.example.lan"), now), "one acme.sh has no plan for is left to the rounds") meta["_meta"]["issue_log"]["d"] = {"time": at(600), "ok": False} ok(not acme.renewal_due(meta, c("d", "day.example.lan"), now), "a failure ten minutes ago waits an hour before trying again") From a5a769cdf96e5ac8b4f247b2b1f15caa61deeb86 Mon Sep 17 00:00:00 2001 From: Georg Glas Date: Tue, 10 Nov 2026 17:44:07 +0100 Subject: [PATCH 06/13] add support for acme.sh --challenge-alias --- ham/acme.py | 49 ++++++++++++++++++++++ ham/crud.py | 5 ++- ham/dnsapi.py | 5 ++- static/js/entities.js | 4 +- static/js/i18n/da.js | 3 ++ static/js/i18n/de.js | 3 ++ static/js/i18n/es.js | 3 ++ static/js/i18n/fi.js | 3 ++ static/js/i18n/fr.js | 3 ++ static/js/i18n/it.js | 3 ++ static/js/i18n/nb.js | 3 ++ static/js/i18n/nl.js | 3 ++ static/js/i18n/pt.js | 3 ++ static/js/i18n/sv.js | 3 ++ static/js/pages/certificates.js | 6 ++- tools/test-challenge-alias.py | 74 +++++++++++++++++++++++++++++++++ tools/test-hardening.py | 32 ++++++++++++++ 17 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 tools/test-challenge-alias.py diff --git a/ham/acme.py b/ham/acme.py index 822eb89..19cfd21 100644 --- a/ham/acme.py +++ b/ham/acme.py @@ -26,6 +26,51 @@ "PS4", "PROMPT_COMMAND", "PYTHONPATH", "PERL5LIB"} from . import auth, notify, sync +# A DNS alias target is a plain host name: it ends up on the command line of a +# root process, so nothing else is let through. +_ALIAS_NAME = re.compile(r"^(?=.{1,253}$)([A-Za-z0-9_]([A-Za-z0-9_-]{0,61}[A-Za-z0-9_])?\.)+[A-Za-z0-9-]{2,63}$") + + +def parse_challenge_alias(value): + """The alias domains typed on a challenge type, as a list. + + One name per line (or separated by spaces or commas). Raises ValueError + with why when one is not a host name. + """ + names = [x for x in re.split(r"[\s,]+", (value or "").strip()) if x] + for n in names: + if not _ALIAS_NAME.match(n): + raise ValueError("%r is not a valid challenge alias domain" % n) + return names + + +def check_challenge(item): + """What a challenge type is refused for when it is saved.""" + parse_challenge_alias(item.get("challenge_alias")) + + +def challenge_alias_args(ch, doms): + """acme.sh's --challenge-alias arguments for a DNS-01 challenge type. + + With the validation records CNAMEd to another zone (acme.sh's DNS alias + mode), _acme-challenge. is answered in that zone instead. One alias + applies to every domain on the certificate; otherwise there must be exactly + one per domain, in the order the domains are listed -- acme.sh pairs them + by position. + """ + names = parse_challenge_alias(ch.get("challenge_alias")) + if not names: + return [] + if len(names) not in (1, len(doms)): + raise ValueError("the challenge type has %d alias domains but the certificate has %d " + "domains: give one alias for all of them, or one per domain" + % (len(names), len(doms))) + args = [] + for n in names: + args += ["--challenge-alias", n] + return args + + # -------------------------------------------------------------------------- CA_SERVERS = { @@ -161,6 +206,10 @@ def _acme_issue(cfg, cert, force=False): env = {} if ch.get("method") == "dns01": args += ["--dns", ch.get("dns_provider", "")] + try: + args += challenge_alias_args(ch, doms) + except ValueError as e: + return {"ok": False, "error": str(e)} for line in (ch.get("dns_credentials") or "").splitlines(): if "=" not in line: continue diff --git a/ham/crud.py b/ham/crud.py index 69c6e3c..a8d4a1c 100644 --- a/ham/crud.py +++ b/ham/crud.py @@ -11,11 +11,14 @@ from .config import VALID_COLLECTIONS, load_config, merged, save_config from .util import _by_id, _sec, rule_cond_ids from .validate import check_pool, check_setting_types -from . import access, apply, errorpages, haproxy, oauth +from . import access, acme, apply, errorpages, haproxy, oauth def _check_item(sec, col, cfg, item): """What a collection refuses before storing it. Raises ValueError with why.""" + if sec == "acme" and col == "challenges": + acme.check_challenge(item) + return if sec != "haproxy": return if col == "backends": diff --git a/ham/dnsapi.py b/ham/dnsapi.py index 3c76d0d..8a8f52d 100644 --- a/ham/dnsapi.py +++ b/ham/dnsapi.py @@ -223,6 +223,8 @@ def pick(kind, spec, defaults, label_field="name"): obj.update({k: v for k, v in spec.items() if k != "id"}) if not obj.get("name"): raise ValueError("a name is required for the new %s" % kind[:-1]) + if kind == "challenges": + acme.check_challenge(obj) obj["id"] = str(uuid.uuid4()) obj["name"] = wizard._uniq_name({x["name"] for x in ac[kind]}, obj["name"]) ac[kind].append(obj) @@ -233,7 +235,8 @@ def pick(kind, spec, defaults, label_field="name"): account = pick("accounts", body.get("account"), {"name": "", "email": "", "ca": "letsencrypt", "eab_kid": "", "eab_hmac": ""}) challenge = pick("challenges", body.get("challenge"), - {"name": "", "method": "http01", "dns_provider": "", "dns_credentials": ""}) + {"name": "", "method": "http01", "dns_provider": "", "dns_credentials": "", + "challenge_alias": ""}) except ValueError as e: return jsonify({"ok": False, "error": str(e)}), 400 diff --git a/static/js/entities.js b/static/js/entities.js index c70f8de..eeeba5c 100644 --- a/static/js/entities.js +++ b/static/js/entities.js @@ -276,7 +276,9 @@ export const E={ {k:"dns_provider",l:"DNS API hook",t:"combo",o:()=>dnsApiOptions, h:"Pick the provider acme.sh should talk to. The list comes from the acme.sh installed on this node."}, {k:"dns_credentials",l:"DNS API credentials",t:"textarea", - h:"One KEY=value per line. Choosing a hook above fills in the names it needs."}]}, + h:"One KEY=value per line. Choosing a hook above fills in the names it needs."}, + {k:"challenge_alias",l:"Challenge alias domain",t:"text", + h:"Optional, e.g. alias.example.net. For DNS alias mode: the _acme-challenge name of each domain is a CNAME to _acme-challenge.alias.example.net, and the validation record is created there instead. One name for every domain on the certificate, or one per domain in order, separated by spaces."}]}, "acme/certificates":{title:"Certificates",one:"Certificate",add:"Add certificate", intro:"Certificates are issued with acme.sh and written as combined PEMs to the HAProxy certificate directory. HAProxy is reloaded and the certificate is pushed to the other nodes automatically. "+ diff --git a/static/js/i18n/da.js b/static/js/i18n/da.js index d9bbdfb..f54e043 100644 --- a/static/js/i18n/da.js +++ b/static/js/i18n/da.js @@ -562,6 +562,9 @@ export default { "Pick the provider acme.sh should talk to. The list comes from the acme.sh installed on this node.": "Vælg den udbyder, acme.sh skal tale med. Listen kommer fra det acme.sh, der er installeret på denne node.", "DNS API credentials": "DNS-API-oplysninger", "One KEY=value per line. Choosing a hook above fills in the names it needs.": "Én NØGLE=værdi pr. linje. At vælge en hook ovenfor udfylder de navne, den har brug for.", +"Challenge alias domain": "Challenge-alias-domæne", +"Optional, e.g. alias.example.net. For DNS alias mode: the _acme-challenge name of each domain is a CNAME to _acme-challenge.alias.example.net, and the validation record is created there instead. One name for every domain on the certificate, or one per domain in order, separated by spaces.": "Valgfri, fx alias.example.net. Til DNS-aliastilstand: _acme-challenge-navnet for hvert domæne er en CNAME til _acme-challenge.alias.example.net, og valideringsposten oprettes dér i stedet. Ét navn til alle domæner på certifikatet eller ét pr. domæne i rækkefølge, adskilt af mellemrum.", +"Optional, e.g. alias.example.net. Validates through another zone: the _acme-challenge name of each domain must be a CNAME to _acme-challenge.alias.example.net": "Valgfri, fx alias.example.net. Validerer via en anden zone: _acme-challenge-navnet for hvert domæne skal være en CNAME til _acme-challenge.alias.example.net", "Certificate": "Certifikat", "Add certificate": "Tilføj certifikat", "Certificates are issued with acme.sh and written as combined PEMs to the HAProxy certificate directory. HAProxy is reloaded and the certificate is pushed to the other nodes automatically. Until a certificate is issued, Apply installs a short-lived self-signed placeholder so HAProxy can still start.": "Certifikater udstedes med acme.sh og skrives som kombinerede PEM-filer til HAProxys certifikatmappe. HAProxy genindlæses, og certifikatet sendes automatisk til de andre noder. Indtil et certifikat er udstedt, installerer Anvend en kortlivet selvsigneret pladsholder, så HAProxy stadig kan starte.", diff --git a/static/js/i18n/de.js b/static/js/i18n/de.js index 8a83292..8b99384 100644 --- a/static/js/i18n/de.js +++ b/static/js/i18n/de.js @@ -562,6 +562,9 @@ export default { "Pick the provider acme.sh should talk to. The list comes from the acme.sh installed on this node.": "Wählen Sie den Anbieter, mit dem acme.sh sprechen soll. Die Liste stammt vom auf diesem Knoten installierten acme.sh.", "DNS API credentials": "DNS-API-Zugangsdaten", "One KEY=value per line. Choosing a hook above fills in the names it needs.": "Ein SCHLÜSSEL=Wert pro Zeile. Die Wahl eines Hooks oben trägt die benötigten Namen ein.", +"Challenge alias domain": "Challenge-Alias-Domain", +"Optional, e.g. alias.example.net. For DNS alias mode: the _acme-challenge name of each domain is a CNAME to _acme-challenge.alias.example.net, and the validation record is created there instead. One name for every domain on the certificate, or one per domain in order, separated by spaces.": "Optional, z. B. alias.example.net. Für den DNS-Alias-Modus: Der _acme-challenge-Name jeder Domain ist ein CNAME auf _acme-challenge.alias.example.net, und der Validierungseintrag wird stattdessen dort angelegt. Ein Name für alle Domains des Zertifikats oder je einer pro Domain in der Reihenfolge, durch Leerzeichen getrennt.", +"Optional, e.g. alias.example.net. Validates through another zone: the _acme-challenge name of each domain must be a CNAME to _acme-challenge.alias.example.net": "Optional, z. B. alias.example.net. Validiert über eine andere Zone: Der _acme-challenge-Name jeder Domain muss ein CNAME auf _acme-challenge.alias.example.net sein", "Certificate": "Zertifikat", "Add certificate": "Zertifikat hinzufügen", "Certificates are issued with acme.sh and written as combined PEMs to the HAProxy certificate directory. HAProxy is reloaded and the certificate is pushed to the other nodes automatically. Until a certificate is issued, Apply installs a short-lived self-signed placeholder so HAProxy can still start.": "Zertifikate werden mit acme.sh ausgestellt und als kombinierte PEM-Dateien in das HAProxy-Zertifikatsverzeichnis geschrieben. HAProxy wird neu geladen und das Zertifikat automatisch an die anderen Knoten übertragen. Bis ein Zertifikat ausgestellt ist, installiert Anwenden einen kurzlebigen selbstsignierten Platzhalter, damit HAProxy trotzdem starten kann.", diff --git a/static/js/i18n/es.js b/static/js/i18n/es.js index c850726..edc62ba 100644 --- a/static/js/i18n/es.js +++ b/static/js/i18n/es.js @@ -562,6 +562,9 @@ export default { "Pick the provider acme.sh should talk to. The list comes from the acme.sh installed on this node.": "Elija el proveedor con el que debe hablar acme.sh. La lista viene del acme.sh instalado en este nodo.", "DNS API credentials": "Credenciales de la API DNS", "One KEY=value per line. Choosing a hook above fills in the names it needs.": "Un CLAVE=valor por línea. Elegir un hook arriba rellena los nombres que necesita.", +"Challenge alias domain": "Dominio alias del desafío", +"Optional, e.g. alias.example.net. For DNS alias mode: the _acme-challenge name of each domain is a CNAME to _acme-challenge.alias.example.net, and the validation record is created there instead. One name for every domain on the certificate, or one per domain in order, separated by spaces.": "Opcional, p. ej. alias.example.net. Para el modo de alias DNS: el nombre _acme-challenge de cada dominio es un CNAME a _acme-challenge.alias.example.net y el registro de validación se crea allí. Un nombre para todos los dominios del certificado, o uno por dominio en orden, separados por espacios.", +"Optional, e.g. alias.example.net. Validates through another zone: the _acme-challenge name of each domain must be a CNAME to _acme-challenge.alias.example.net": "Opcional, p. ej. alias.example.net. Valida a través de otra zona: el nombre _acme-challenge de cada dominio debe ser un CNAME a _acme-challenge.alias.example.net", "Certificate": "Certificado", "Add certificate": "Añadir certificado", "Certificates are issued with acme.sh and written as combined PEMs to the HAProxy certificate directory. HAProxy is reloaded and the certificate is pushed to the other nodes automatically. Until a certificate is issued, Apply installs a short-lived self-signed placeholder so HAProxy can still start.": "Los certificados se emiten con acme.sh y se escriben como PEM combinados en el directorio de certificados de HAProxy. HAProxy se recarga y el certificado se envía automáticamente a los otros nodos. Hasta que se emite un certificado, Aplicar instala un sustituto autofirmado de corta duración para que HAProxy pueda arrancar igualmente.", diff --git a/static/js/i18n/fi.js b/static/js/i18n/fi.js index 925f71b..65c13c8 100644 --- a/static/js/i18n/fi.js +++ b/static/js/i18n/fi.js @@ -562,6 +562,9 @@ export default { "Pick the provider acme.sh should talk to. The list comes from the acme.sh installed on this node.": "Valitse tarjoaja, jonka kanssa acme.sh:n pitäisi keskustella. Luettelo tulee tälle solmulle asennetusta acme.sh:sta.", "DNS API credentials": "DNS-API-tunnukset", "One KEY=value per line. Choosing a hook above fills in the names it needs.": "Yksi AVAIN=arvo per rivi. Koukun valinta yllä täyttää sen tarvitsemat nimet.", +"Challenge alias domain": "Haasteen alias-verkkotunnus", +"Optional, e.g. alias.example.net. For DNS alias mode: the _acme-challenge name of each domain is a CNAME to _acme-challenge.alias.example.net, and the validation record is created there instead. One name for every domain on the certificate, or one per domain in order, separated by spaces.": "Valinnainen, esim. alias.example.net. DNS-alias-tilaa varten: kunkin verkkotunnuksen _acme-challenge-nimi on CNAME-tietue kohteeseen _acme-challenge.alias.example.net, ja vahvistustietue luodaan sen sijaan sinne. Yksi nimi kaikille varmenteen verkkotunnuksille tai yksi per verkkotunnus järjestyksessä, välilyönnillä erotettuna.", +"Optional, e.g. alias.example.net. Validates through another zone: the _acme-challenge name of each domain must be a CNAME to _acme-challenge.alias.example.net": "Valinnainen, esim. alias.example.net. Vahvistaa toisen vyöhykkeen kautta: kunkin verkkotunnuksen _acme-challenge-nimen on oltava CNAME-tietue kohteeseen _acme-challenge.alias.example.net", "Certificate": "Varmenne", "Add certificate": "Lisää varmenne", "Certificates are issued with acme.sh and written as combined PEMs to the HAProxy certificate directory. HAProxy is reloaded and the certificate is pushed to the other nodes automatically. Until a certificate is issued, Apply installs a short-lived self-signed placeholder so HAProxy can still start.": "Varmenteet myönnetään acme.sh:lla ja kirjoitetaan yhdistettyinä PEM-tiedostoina HAProxyn varmennehakemistoon. HAProxy ladataan uudelleen ja varmenne työnnetään muille solmuille automaattisesti. Kunnes varmenne on myönnetty, Ota käyttöön asentaa lyhytikäisen itse allekirjoitetun paikanpitäjän, jotta HAProxy voi silti käynnistyä.", diff --git a/static/js/i18n/fr.js b/static/js/i18n/fr.js index 75f345a..8b74258 100644 --- a/static/js/i18n/fr.js +++ b/static/js/i18n/fr.js @@ -562,6 +562,9 @@ export default { "Pick the provider acme.sh should talk to. The list comes from the acme.sh installed on this node.": "Choisissez le fournisseur auquel acme.sh doit parler. La liste vient de l'acme.sh installé sur ce nœud.", "DNS API credentials": "Identifiants d'API DNS", "One KEY=value per line. Choosing a hook above fills in the names it needs.": "Un CLÉ=valeur par ligne. Choisir un hook ci-dessus remplit les noms dont il a besoin.", +"Challenge alias domain": "Domaine d'alias du challenge", +"Optional, e.g. alias.example.net. For DNS alias mode: the _acme-challenge name of each domain is a CNAME to _acme-challenge.alias.example.net, and the validation record is created there instead. One name for every domain on the certificate, or one per domain in order, separated by spaces.": "Facultatif, p. ex. alias.example.net. Pour le mode alias DNS : le nom _acme-challenge de chaque domaine est un CNAME vers _acme-challenge.alias.example.net, et l'enregistrement de validation est créé là-bas à la place. Un nom pour tous les domaines du certificat, ou un par domaine dans l'ordre, séparés par des espaces.", +"Optional, e.g. alias.example.net. Validates through another zone: the _acme-challenge name of each domain must be a CNAME to _acme-challenge.alias.example.net": "Facultatif, p. ex. alias.example.net. Valide via une autre zone : le nom _acme-challenge de chaque domaine doit être un CNAME vers _acme-challenge.alias.example.net", "Certificate": "Certificat", "Add certificate": "Ajouter un certificat", "Certificates are issued with acme.sh and written as combined PEMs to the HAProxy certificate directory. HAProxy is reloaded and the certificate is pushed to the other nodes automatically. Until a certificate is issued, Apply installs a short-lived self-signed placeholder so HAProxy can still start.": "Les certificats sont émis avec acme.sh et écrits en PEM combinés dans le répertoire de certificats HAProxy. HAProxy est rechargé et le certificat est poussé automatiquement vers les autres nœuds. Tant qu'un certificat n'est pas émis, Appliquer installe un substitut auto-signé de courte durée pour que HAProxy puisse démarrer quand même.", diff --git a/static/js/i18n/it.js b/static/js/i18n/it.js index 7799b03..d5f476d 100644 --- a/static/js/i18n/it.js +++ b/static/js/i18n/it.js @@ -562,6 +562,9 @@ export default { "Pick the provider acme.sh should talk to. The list comes from the acme.sh installed on this node.": "Scegli il provider con cui acme.sh deve parlare. L'elenco viene dall'acme.sh installato su questo nodo.", "DNS API credentials": "Credenziali API DNS", "One KEY=value per line. Choosing a hook above fills in the names it needs.": "Un CHIAVE=valore per riga. Scegliere un hook sopra compila i nomi che gli servono.", +"Challenge alias domain": "Dominio alias della challenge", +"Optional, e.g. alias.example.net. For DNS alias mode: the _acme-challenge name of each domain is a CNAME to _acme-challenge.alias.example.net, and the validation record is created there instead. One name for every domain on the certificate, or one per domain in order, separated by spaces.": "Facoltativo, ad es. alias.example.net. Per la modalità alias DNS: il nome _acme-challenge di ogni dominio è un CNAME verso _acme-challenge.alias.example.net e il record di validazione viene creato lì. Un nome per tutti i domini del certificato, oppure uno per dominio in ordine, separati da spazi.", +"Optional, e.g. alias.example.net. Validates through another zone: the _acme-challenge name of each domain must be a CNAME to _acme-challenge.alias.example.net": "Facoltativo, ad es. alias.example.net. Convalida tramite un'altra zona: il nome _acme-challenge di ogni dominio deve essere un CNAME verso _acme-challenge.alias.example.net", "Certificate": "Certificato", "Add certificate": "Aggiungi certificato", "Certificates are issued with acme.sh and written as combined PEMs to the HAProxy certificate directory. HAProxy is reloaded and the certificate is pushed to the other nodes automatically. Until a certificate is issued, Apply installs a short-lived self-signed placeholder so HAProxy can still start.": "I certificati vengono emessi con acme.sh e scritti come PEM combinati nella directory dei certificati di HAProxy. HAProxy viene ricaricato e il certificato viene inviato automaticamente agli altri nodi. Finché un certificato non viene emesso, Applica installa un segnaposto autofirmato di breve durata così che HAProxy possa comunque avviarsi.", diff --git a/static/js/i18n/nb.js b/static/js/i18n/nb.js index 06fb781..4555d9f 100644 --- a/static/js/i18n/nb.js +++ b/static/js/i18n/nb.js @@ -562,6 +562,9 @@ export default { "Pick the provider acme.sh should talk to. The list comes from the acme.sh installed on this node.": "Velg leverandøren acme.sh skal snakke med. Listen kommer fra acme.sh som er installert på denne noden.", "DNS API credentials": "DNS-API-opplysninger", "One KEY=value per line. Choosing a hook above fills in the names it needs.": "Én NØKKEL=verdi per linje. Å velge en hook over fyller inn navnene den trenger.", +"Challenge alias domain": "Challenge-aliasdomene", +"Optional, e.g. alias.example.net. For DNS alias mode: the _acme-challenge name of each domain is a CNAME to _acme-challenge.alias.example.net, and the validation record is created there instead. One name for every domain on the certificate, or one per domain in order, separated by spaces.": "Valgfritt, f.eks. alias.example.net. For DNS-aliasmodus: _acme-challenge-navnet til hvert domene er en CNAME til _acme-challenge.alias.example.net, og valideringsposten opprettes der i stedet. Ett navn for alle domenene på sertifikatet, eller ett per domene i rekkefølge, atskilt med mellomrom.", +"Optional, e.g. alias.example.net. Validates through another zone: the _acme-challenge name of each domain must be a CNAME to _acme-challenge.alias.example.net": "Valgfritt, f.eks. alias.example.net. Validerer via en annen sone: _acme-challenge-navnet til hvert domene må være en CNAME til _acme-challenge.alias.example.net", "Certificate": "Sertifikat", "Add certificate": "Legg til sertifikat", "Certificates are issued with acme.sh and written as combined PEMs to the HAProxy certificate directory. HAProxy is reloaded and the certificate is pushed to the other nodes automatically. Until a certificate is issued, Apply installs a short-lived self-signed placeholder so HAProxy can still start.": "Sertifikater utstedes med acme.sh og skrives som kombinerte PEM-filer til HAProxys sertifikatkatalog. HAProxy lastes på nytt og sertifikatet sendes automatisk til de andre nodene. Inntil et sertifikat er utstedt, installerer Bruk en kortlivet selvsignert plassholder slik at HAProxy likevel kan starte.", diff --git a/static/js/i18n/nl.js b/static/js/i18n/nl.js index eab8eb5..3faf86b 100644 --- a/static/js/i18n/nl.js +++ b/static/js/i18n/nl.js @@ -562,6 +562,9 @@ export default { "Pick the provider acme.sh should talk to. The list comes from the acme.sh installed on this node.": "Kies de provider waarmee acme.sh moet praten. De lijst komt van de acme.sh die op deze node is geïnstalleerd.", "DNS API credentials": "DNS-API-gegevens", "One KEY=value per line. Choosing a hook above fills in the names it needs.": "Eén SLEUTEL=waarde per regel. Een hook kiezen hierboven vult de benodigde namen in.", +"Challenge alias domain": "Challenge-aliasdomein", +"Optional, e.g. alias.example.net. For DNS alias mode: the _acme-challenge name of each domain is a CNAME to _acme-challenge.alias.example.net, and the validation record is created there instead. One name for every domain on the certificate, or one per domain in order, separated by spaces.": "Optioneel, bijv. alias.example.net. Voor de DNS-aliasmodus: de _acme-challenge-naam van elk domein is een CNAME naar _acme-challenge.alias.example.net, en het validatierecord wordt daar aangemaakt. Eén naam voor alle domeinen van het certificaat, of één per domein in volgorde, gescheiden door spaties.", +"Optional, e.g. alias.example.net. Validates through another zone: the _acme-challenge name of each domain must be a CNAME to _acme-challenge.alias.example.net": "Optioneel, bijv. alias.example.net. Valideert via een andere zone: de _acme-challenge-naam van elk domein moet een CNAME zijn naar _acme-challenge.alias.example.net", "Certificate": "Certificaat", "Add certificate": "Certificaat toevoegen", "Certificates are issued with acme.sh and written as combined PEMs to the HAProxy certificate directory. HAProxy is reloaded and the certificate is pushed to the other nodes automatically. Until a certificate is issued, Apply installs a short-lived self-signed placeholder so HAProxy can still start.": "Certificaten worden uitgegeven met acme.sh en als gecombineerde PEM-bestanden naar de HAProxy-certificaatmap geschreven. HAProxy wordt herladen en het certificaat wordt automatisch naar de andere nodes gepusht. Tot er een certificaat is uitgegeven, installeert Toepassen een kortlevende zelfondertekende plaatsvervanger zodat HAProxy toch kan starten.", diff --git a/static/js/i18n/pt.js b/static/js/i18n/pt.js index 392e604..d20588e 100644 --- a/static/js/i18n/pt.js +++ b/static/js/i18n/pt.js @@ -562,6 +562,9 @@ export default { "Pick the provider acme.sh should talk to. The list comes from the acme.sh installed on this node.": "Escolha o fornecedor com que o acme.sh deve falar. A lista vem do acme.sh instalado neste nó.", "DNS API credentials": "Credenciais da API DNS", "One KEY=value per line. Choosing a hook above fills in the names it needs.": "Um CHAVE=valor por linha. Escolher um hook acima preenche os nomes de que precisa.", +"Challenge alias domain": "Domínio de alias do desafio", +"Optional, e.g. alias.example.net. For DNS alias mode: the _acme-challenge name of each domain is a CNAME to _acme-challenge.alias.example.net, and the validation record is created there instead. One name for every domain on the certificate, or one per domain in order, separated by spaces.": "Opcional, p. ex. alias.example.net. Para o modo de alias DNS: o nome _acme-challenge de cada domínio é um CNAME para _acme-challenge.alias.example.net e o registo de validação é criado aí. Um nome para todos os domínios do certificado, ou um por domínio, por ordem, separados por espaços.", +"Optional, e.g. alias.example.net. Validates through another zone: the _acme-challenge name of each domain must be a CNAME to _acme-challenge.alias.example.net": "Opcional, p. ex. alias.example.net. Valida através de outra zona: o nome _acme-challenge de cada domínio tem de ser um CNAME para _acme-challenge.alias.example.net", "Certificate": "Certificado", "Add certificate": "Adicionar certificado", "Certificates are issued with acme.sh and written as combined PEMs to the HAProxy certificate directory. HAProxy is reloaded and the certificate is pushed to the other nodes automatically. Until a certificate is issued, Apply installs a short-lived self-signed placeholder so HAProxy can still start.": "Os certificados são emitidos com o acme.sh e escritos como PEM combinados no diretório de certificados do HAProxy. O HAProxy é recarregado e o certificado é enviado automaticamente para os outros nós. Até um certificado ser emitido, Aplicar instala um substituto autoassinado de curta duração para que o HAProxy possa arrancar na mesma.", diff --git a/static/js/i18n/sv.js b/static/js/i18n/sv.js index 6cbef06..49da773 100644 --- a/static/js/i18n/sv.js +++ b/static/js/i18n/sv.js @@ -562,6 +562,9 @@ export default { "Pick the provider acme.sh should talk to. The list comes from the acme.sh installed on this node.": "Välj leverantören som acme.sh ska prata med. Listan kommer från det acme.sh som är installerat på den här noden.", "DNS API credentials": "DNS-API-uppgifter", "One KEY=value per line. Choosing a hook above fills in the names it needs.": "En NYCKEL=värde per rad. Att välja en hook ovan fyller i namnen den behöver.", +"Challenge alias domain": "Challenge-aliasdomän", +"Optional, e.g. alias.example.net. For DNS alias mode: the _acme-challenge name of each domain is a CNAME to _acme-challenge.alias.example.net, and the validation record is created there instead. One name for every domain on the certificate, or one per domain in order, separated by spaces.": "Valfritt, t.ex. alias.example.net. För DNS-aliasläge: _acme-challenge-namnet för varje domän är en CNAME till _acme-challenge.alias.example.net, och valideringsposten skapas där i stället. Ett namn för alla domäner på certifikatet, eller ett per domän i ordning, åtskilda av blanksteg.", +"Optional, e.g. alias.example.net. Validates through another zone: the _acme-challenge name of each domain must be a CNAME to _acme-challenge.alias.example.net": "Valfritt, t.ex. alias.example.net. Validerar via en annan zon: _acme-challenge-namnet för varje domän måste vara en CNAME till _acme-challenge.alias.example.net", "Certificate": "Certifikat", "Add certificate": "Lägg till certifikat", "Certificates are issued with acme.sh and written as combined PEMs to the HAProxy certificate directory. HAProxy is reloaded and the certificate is pushed to the other nodes automatically. Until a certificate is issued, Apply installs a short-lived self-signed placeholder so HAProxy can still start.": "Certifikat utfärdas med acme.sh och skrivs som kombinerade PEM-filer till HAProxys certifikatkatalog. HAProxy laddas om och certifikatet skickas automatiskt till de andra noderna. Tills ett certifikat är utfärdat installerar Tillämpa en kortlivad självsignerad platshållare så att HAProxy ändå kan starta.", diff --git a/static/js/pages/certificates.js b/static/js/pages/certificates.js index 4c61f67..05994b3 100644 --- a/static/js/pages/certificates.js +++ b/static/js/pages/certificates.js @@ -69,6 +69,7 @@ export const CERT_WIZ=[ h:"HTTP-01 needs port 80 reachable from the internet. DNS-01 works behind a firewall and is the only way to get a wildcard."}, {k:"dns_provider",l:"DNS API hook",t:"combo",o:()=>dnsApiOptions,h:"The provider acme.sh should talk to"}, {k:"dns_credentials",l:"DNS API credentials",t:"textarea",h:"One KEY=value per line"}, + {k:"challenge_alias",l:"Challenge alias domain",t:"text",h:"Optional, e.g. alias.example.net. Validates through another zone: the _acme-challenge name of each domain must be a CNAME to _acme-challenge.alias.example.net"}, {k:"key_type",l:"Key type",t:"select",o:["ec-256","ec-384","rsa-2048","rsa-4096"],d:"ec-256"}, {k:"auto_renew",l:"Renew automatically",t:"bool",d:true}, {k:"issue",l:"Request it now",t:"bool",d:true,h:"Runs acme.sh straight away; leave off to just create the objects"}, @@ -98,7 +99,7 @@ export async function openCertWizard(){ const newCh=frm.querySelector("#f_challenge_id").value===NEWOPT; const method=frm.querySelector("#f_ch_method").value; ["ch_name","ch_method"].forEach(k=>setRow(k,newCh)); - ["dns_provider","dns_credentials"].forEach(k=>setRow(k,newCh&&method==="dns01")); + ["dns_provider","dns_credentials","challenge_alias"].forEach(k=>setRow(k,newCh&&method==="dns01")); }; ["f_account_id","f_challenge_id","f_ch_method"].forEach(id=>{ const el=frm.querySelector("#"+id);if(el)el.addEventListener("change",sync);}); @@ -115,7 +116,8 @@ export async function openCertWizard(){ : {id:d.account_id}; body.challenge=d.challenge_id===NEWOPT ? {name:d.ch_name||(d.ch_method==="dns01"?("dns-"+(d.dns_provider||"01")):"http-01"), - method:d.ch_method,dns_provider:d.dns_provider,dns_credentials:d.dns_credentials} + method:d.ch_method,dns_provider:d.dns_provider,dns_credentials:d.dns_credentials, + challenge_alias:d.challenge_alias} : {id:d.challenge_id}; return body; }; diff --git a/tools/test-challenge-alias.py b/tools/test-challenge-alias.py new file mode 100644 index 0000000..08930c8 --- /dev/null +++ b/tools/test-challenge-alias.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""DNS alias mode: a challenge type's alias domain is validated when it is +saved and reaches acme.sh as --challenge-alias, once per alias. + + HAM_DATA_DIR=/tmp/x python3 tools/test-challenge-alias.py +""" +import os +import pathlib +import sys +import tempfile + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) +os.environ.setdefault("HAM_DATA_DIR", tempfile.mkdtemp(prefix="ham-alias-")) +os.environ["HAM_CERT_DIR"] = os.path.join(os.environ["HAM_DATA_DIR"], "certs") +os.environ["HAM_DRY_RUN"] = "1" + +import ham # noqa: E402 +from ham import acme # noqa: E402 +from ham.config import load_config, save_config # noqa: E402 + +fails = [] + + +def ok(cond, msg): + print((" PASS " if cond else " FAIL ") + msg) + if not cond: + fails.append(msg) + + +ham.app.config["TESTING"] = True +client = ham.app.test_client() +cfg = load_config() +cfg["local"]["api_key"] = "alias-key" +save_config(cfg) +H = {"X-API-Key": "alias-key"} + +base = {"name": "cf", "method": "dns01", "dns_provider": "dns_cf", "dns_credentials": "CF_Token=x"} + +r = client.post("/api/acme/challenges", headers=H, json=dict(base, challenge_alias="alias.example.net")) +ok(r.status_code == 200 and r.get_json().get("challenge_alias") == "alias.example.net", + "a valid alias is stored on the challenge type") +r = client.post("/api/acme/challenges", headers=H, json=dict(base, name="bad", challenge_alias="x; reboot")) +ok(r.status_code == 400, "an alias that is not a host name is refused with 400") +r = client.post("/api/acme/challenges", headers=H, json=dict(base, name="none")) +ok(r.status_code == 200, "a challenge type without an alias still saves") + +# the issue path hands the flag to acme.sh +seen = [] +acme.acme_run = lambda args, env_extra=None: (seen.append(args) or (0, "ok")) +acme.deploy_cert = lambda cfg, cert: {"ok": True, "changed": True, "log": ""} +acme.ensure_account = lambda acc: (0, "") +cfg = load_config() +cfg["acme"]["accounts"] = [{"id": "a", "name": "a", "email": "", "ca": "letsencrypt"}] +cfg["acme"]["challenges"] = [{"id": "c", "name": "c", "method": "dns01", "dns_provider": "dns_cf", + "dns_credentials": "CF_Token=x", "challenge_alias": "alias.example.net"}] +cert = {"id": "k", "name": "k", "domains": "a.example.com *.example.com", + "account": "a", "challenge": "c", "key_type": "ec-256"} +res = acme._acme_issue(cfg, cert) +args = seen[-1] if seen else [] +ok(res.get("ok") and "--challenge-alias" in args + and args[args.index("--challenge-alias") + 1] == "alias.example.net", + "issuing passes --challenge-alias to acme.sh") +cfg["acme"]["challenges"][0]["challenge_alias"] = "a.net b.net c.net" +res = acme._acme_issue(cfg, cert) +ok(not res.get("ok") and "alias" in res.get("error", ""), + "an alias count that fits no domain count fails before acme.sh runs") +cfg["acme"]["challenges"][0].update(method="http01", challenge_alias="alias.example.net") +seen.clear() +acme._acme_issue(cfg, cert) +ok(seen and "--challenge-alias" not in seen[-1], "HTTP-01 never gets an alias") + +print() +print("challenge alias holds" if not fails else "%d failed" % len(fails)) +sys.exit(1 if fails else 0) diff --git a/tools/test-hardening.py b/tools/test-hardening.py index cdb0e96..fa0314f 100644 --- a/tools/test-hardening.py +++ b/tools/test-hardening.py @@ -64,6 +64,38 @@ def ok(cond, msg): ok(acme["challenges"][0]["dns_credentials"] == "CF_Token=leaky", "without mutating the stored config") +# -- DNS alias mode: the challenge alias reaches acme.sh as a validated flag -- +from ham import acme # noqa: E402 +ch = {"method": "dns01", "challenge_alias": "validation.example.net"} +ok(acme.challenge_alias_args(ch, ["a.example.com", "b.example.com"]) + == ["--challenge-alias", "validation.example.net"], + "one alias applies to every domain on the certificate") +ch["challenge_alias"] = "x.example.net y.example.net" +ok(acme.challenge_alias_args(ch, ["a.example.com", "b.example.com"]) + == ["--challenge-alias", "x.example.net", "--challenge-alias", "y.example.net"], + "one alias per domain is passed in order") +ok(acme.challenge_alias_args({"method": "dns01"}, ["a.example.com"]) == [], + "no alias adds no argument") + + +def refused(fn, *a): + try: + fn(*a) + except ValueError: + return True + return False + + +ok(refused(acme.challenge_alias_args, {"challenge_alias": "a.net b.net"}, ["1.x.com", "2.x.com", "3.x.com"]), + "an alias count that matches neither one nor the domain count is refused") +ok(refused(acme.check_challenge, {"challenge_alias": "evil.net; rm -rf /"}) + and refused(acme.check_challenge, {"challenge_alias": "--force"}) + and refused(acme.check_challenge, {"challenge_alias": "nodots"}), + "an alias that is not a host name is refused at save time") +ok(not refused(acme.check_challenge, {"challenge_alias": ""}) + and not refused(acme.check_challenge, {"challenge_alias": "_acme.validation.example.net"}), + "an empty alias and a valid one are accepted") + # -- a corrupt config file does not reopen setup ----------------------------- save_config(copy.deepcopy(DEFAULT_CONFIG)) # a real file exists CONF_PATH.write_text("{ this is not json") From e6997f27eb58689e9f88df4d1488cd60a62865f2 Mon Sep 17 00:00:00 2001 From: Alex Van de Putte Date: Tue, 10 Nov 2026 16:05:31 -0500 Subject: [PATCH 07/13] 2.2.0: every node installs acme2certifier when the service starts, not at the first issuance --- ham/acme.py | 4 +- ham/adcs.py | 76 ++++++++++++++++++++++----------- proxmox/README.md | 3 ++ static/js/pages/certificates.js | 6 ++- tools/test-adcs.py | 75 ++++++++++++++++++++++++++++++++ tools/uitests/adcs.mjs | 4 ++ 6 files changed, 139 insertions(+), 29 deletions(-) diff --git a/ham/acme.py b/ham/acme.py index 18a4d4f..6e40dbe 100644 --- a/ham/acme.py +++ b/ham/acme.py @@ -294,8 +294,8 @@ def _acme_issue(cfg, cert, force=False): trace = [] if adcs.is_adcs(acc) and not adcs.installed(): - # The first issuance on a node installed from a package or install.sh - # is the moment it gets acme2certifier; the image already has it. + # Every node gets acme2certifier when the service starts. Where that + # failed, or is still running, this is the next try. ok, out = adcs.ensure_installed() trace.append(out) if not ok: diff --git a/ham/adcs.py b/ham/adcs.py index 3897a62..3d50268 100644 --- a/ham/adcs.py +++ b/ham/adcs.py @@ -444,6 +444,14 @@ def api_adcs_test(): check_account(acc) except ValueError as e: return jsonify({"ok": False, "message": str(e)}) + if not installed(): + # Normally there since the service first started. Where that install + # is still running this waits for it, and where it failed it is tried + # again: the test is somebody asking for it now. + done, out = ensure_installed(force=True) + if not done: + return jsonify({"ok": False, "message": "acme2certifier is not installed on this node and could " + "not be installed: %s" % out.strip()[-300:]}) why = missing_for(acc) if why: return jsonify({"ok": False, "message": why}) @@ -526,10 +534,22 @@ def _test_certsrv(acc): # -------------------------------------------------------------------------- # putting it on a node that lacks it +# Every node gets acme2certifier when the service starts and it is missing -- +# after an install, an update or a restart -- so an AD CS account works the +# moment it is typed. A package cannot do this itself: acme2certifier is in no +# distribution's repository to depend on, and a package's own scripts run +# while the package manager is locked. +# # When an install last failed, so a node that cannot reach GitHub is not made # to try at every issuance. TRIED = DATA_DIR / "acme2certifier-install-tried" RETRY_AFTER = 6 * 3600 +# At startup: now, and twice more if it fails. The service is restarted from +# inside the package manager's own run, which can hold its lock for longer +# than one attempt waits; and at boot the network may not be up yet. +STARTUP_TRIES = (0, 180, 600) +# One install at a time, and whoever else needs it waits for that one. +_installing = threading.Lock() # Impacket's package brings example scripts -- secretsdump and the like -- # that scanners flag; they are not what enrollment uses, and the image leaves # them out the same way. @@ -587,24 +607,28 @@ def install(): return True, "installed acme2certifier %s" % (version() or A2C_VERSION) -def ensure_installed(): - """Install it where an account needs it and it is missing: (ok, message). - Never fatal, and a failure is not retried for a while.""" +def ensure_installed(force=False): + """Install it where it is missing: (ok, message). Never fatal. A failure + is not retried for a while, unless force says somebody is asking now. An + install already running is waited for rather than started twice.""" if installed(): return True, "installed" - if _tried_recently(): - return False, "an install failed recently; not retried yet: " + last_failure()[-300:] - ok, out = install() - TRIED.parent.mkdir(parents=True, exist_ok=True) - if ok: - TRIED.unlink(missing_ok=True) - TRIED.with_suffix(".log").unlink(missing_ok=True) - log.info(out) - else: - TRIED.write_text(str(time.time())) - TRIED.with_suffix(".log").write_text(out) - log.warning("acme2certifier could not be installed: %s", out.strip()[-300:]) - return ok, out + with _installing: + if installed(): + return True, "installed" + if not force and _tried_recently(): + return False, "an install failed recently; not retried yet: " + last_failure()[-300:] + ok, out = install() + TRIED.parent.mkdir(parents=True, exist_ok=True) + if ok: + TRIED.unlink(missing_ok=True) + TRIED.with_suffix(".log").unlink(missing_ok=True) + log.info(out) + else: + TRIED.write_text(str(time.time())) + TRIED.with_suffix(".log").write_text(out) + log.warning("acme2certifier could not be installed: %s", out.strip()[-300:]) + return ok, out def wanted(cfg): @@ -613,12 +637,13 @@ def wanted(cfg): def startup(): - """At startup: a node that has an AD CS account gets acme2certifier if it - lacks it, so the first renewal does not wait for the download.""" + """At startup: a node that lacks acme2certifier gets it, whether or not an + account uses it yet. The image has it, so there this does nothing.""" try: - from .config import load_config - if wanted(load_config()): - ensure_installed() + for wait in STARTUP_TRIES: + time.sleep(wait) + if ensure_installed(force=True)[0]: + return except Exception: log.exception("acme2certifier at startup failed") @@ -626,7 +651,8 @@ def startup(): def summary(cfg): """For /api/acme/health: whether this node can enroll with AD CS.""" out = {"wanted": wanted(cfg), "installed": installed(), "version": version(), - "impacket": _module("impacket"), "port": A2C_PORT, "release": A2C_VERSION} + "impacket": _module("impacket"), "port": A2C_PORT, "release": A2C_VERSION, + "installing": _installing.locked()} if out["wanted"] and not out["installed"]: out["install_error"] = last_failure() return out @@ -634,8 +660,8 @@ def summary(cfg): @app.post("/api/acme/adcs/install") def api_adcs_install(): - """Install acme2certifier on this node now, rather than at the first issuance.""" - TRIED.unlink(missing_ok=True) - ok, message = ensure_installed() + """Install acme2certifier on this node now: for a node where the install + at startup failed, once what stopped it is put right.""" + ok, message = ensure_installed(force=True) from .config import load_config return jsonify({"ok": ok, "message": message, "adcs": summary(load_config())}), (200 if ok else 500) diff --git a/proxmox/README.md b/proxmox/README.md index aea588f..b7454b6 100644 --- a/proxmox/README.md +++ b/proxmox/README.md @@ -25,6 +25,9 @@ newest release of all, betas included, and a stable release still outranks the betas before it. One third-party apt source is added: CrowdSec's repository, with its signing key checked against a pinned fingerprint, so the SPOA bouncer is on the node (stopped) should you switch CrowdSec on later. +As on any package install, the service fetches acme2certifier from GitHub when +it first starts; certificates from Active Directory Certificate Services go +through it. ## Updating diff --git a/static/js/pages/certificates.js b/static/js/pages/certificates.js index 338f652..f24f2df 100644 --- a/static/js/pages/certificates.js +++ b/static/js/pages/certificates.js @@ -274,10 +274,12 @@ export function certificateNotices(){ /* Active Directory Certificate Services needs acme2certifier on the node that issues. The image has it; a node installed from a package or install.sh - fetches it at the first issuance -- or now, from here. */ + fetches it when the service starts. This is for the node where that failed: + it says why, and tries again from here. Nothing is said while the install + is still running. */ export function adcsNotice(){ const a=acmeHealth&&acmeHealth.adcs; - if(!a||!a.wanted||(a.installed&&a.impacket))return null; + if(!a||!a.wanted||a.installing||(a.installed&&a.impacket))return null; const card=document.createElement("div");card.className="card";card.style.borderColor="#e3cfa8"; card.innerHTML='

'+(a.installed?t("Impacket is not installed on this node") :t("acme2certifier is not installed on this node"))+'

'+ diff --git a/tools/test-adcs.py b/tools/test-adcs.py index 6ece211..fca1452 100644 --- a/tools/test-adcs.py +++ b/tools/test-adcs.py @@ -224,6 +224,7 @@ def add_account(body, want): ok(not adcs.offers_ntlm(basic), "one that offers only basic does not") print("issuing") +REAL = {k: getattr(adcs, k) for k in ("installed", "missing_for", "ensure_installed", "install")} # The stand-in stands in: acme2certifier counts as installed, Impacket too. adcs.installed = lambda: True adcs.missing_for = lambda acc: "" @@ -339,6 +340,80 @@ def add_account(body, want): ok(new and new[0]["adcs_host"] == "ca01.corp.example.lan" and new[0]["adcs_password"] == GOOD["adcs_password"] and new[0]["adcs_template"] == "WebServer", "with the CA server, template and credentials stored") +print("getting it onto a node") +# Every node gets it when the service starts, whether or not an account uses +# it; nobody presses anything. The install itself is stood in for. +import threading # noqa: E402 +adcs.ensure_installed = REAL["ensure_installed"] +adcs.STARTUP_TRIES = (0, 0, 0) +state = {"there": False, "works": False, "runs": 0} +adcs.installed = lambda: state["there"] + + +def fake_install(): + state["runs"] += 1 + if state["works"]: + state["there"] = True + return True, "installed acme2certifier" + return False, "the install did not finish: could not resolve github.com" + + +adcs.install = fake_install +cfg = load_config() +kept = cfg["acme"]["accounts"] +cfg["acme"]["accounts"] = [a for a in kept if a.get("ca") != "adcs"] +save_config(cfg) +ok(not adcs.wanted(load_config()), "(no account uses AD CS)") +adcs.startup() +ok(state["runs"] == 3 and not state["there"], + "startup tries on a node no account needs it on, and again when it fails (%d tries)" % state["runs"]) +ok(adcs.TRIED.exists() and "github.com" in adcs.last_failure(), "what stopped it is kept") +ok(adcs.ensure_installed()[0] is False and state["runs"] == 3, + "an issuance does not try again so soon after a failure") +r = client.post("/api/acme/adcs/test", headers=H, json=GOOD).get_json() +ok(r["ok"] is False and state["runs"] == 4 and "could not be installed" in r["message"] + and "github.com" in r["message"], "testing the connection does, and says why it could not: %s" % r["message"][:90]) +state["works"] = True +state["runs"] = 0 +adcs.startup() +ok(state["runs"] == 1 and state["there"] and not adcs.TRIED.exists(), + "the next start installs it, once, and forgets the failure") +adcs.startup() +ok(state["runs"] == 1, "a node that has it is left alone") +# while one install runs, whoever else needs it waits for that one +state.update(there=False, runs=0) +gate = threading.Event() + + +def slow_install(): + state["runs"] += 1 + gate.wait(10) + state["there"] = True + return True, "installed acme2certifier" + + +adcs.install = slow_install +first = threading.Thread(target=adcs.startup) +first.start() +for _ in range(100): + if adcs._installing.locked(): + break + time.sleep(0.05) +r = client.get("/api/acme/health", headers=H).get_json() +ok(r["adcs"]["installing"] is True and r["adcs"]["installed"] is False, "the health check says an install is running") +got = [] +second = threading.Thread(target=lambda: got.append(adcs.ensure_installed(force=True))) +second.start() +time.sleep(0.3) +ok(not got, "a second caller waits for the install that is running") +gate.set() +first.join(10) +second.join(10) +ok(got and got[0][0] is True and state["runs"] == 1, "and finds it installed, without a second install") +cfg = load_config() +cfg["acme"]["accounts"] = kept +save_config(cfg) + print() print("FAILED: %d" % len(fails) if fails else "all passed") sys.exit(1 if fails else 0) diff --git a/tools/uitests/adcs.mjs b/tools/uitests/adcs.mjs index 307543e..2f8a9d1 100644 --- a/tools/uitests/adcs.mjs +++ b/tools/uitests/adcs.mjs @@ -183,6 +183,10 @@ health = { ok: true, adcs: { wanted: false, installed: false, impacket: false } await loadAcmeHealth(); ok(!certificateNotices().filter(Boolean).some(c => c.textContent.includes("acme2certifier")), "no AD CS account: nothing said either"); +health = { ok: true, adcs: { wanted: true, installed: false, impacket: false, installing: true } }; +await loadAcmeHealth(); +ok(!certificateNotices().filter(Boolean).some(c => c.textContent.includes("acme2certifier")), + "while the node is installing it by itself: nothing said, and no button to press"); console.log(fail ? "\n" + fail + " failed" : "\nall passed"); process.exit(fail ? 1 : 0); From bd5cc7178547053c7d17dfbaf9e67b7f2e848e60 Mon Sep 17 00:00:00 2001 From: Alex Van de Putte Date: Tue, 10 Nov 2026 16:29:59 -0500 Subject: [PATCH 08/13] DNS alias mode: aliases checked where they are saved, against the certificates that use them - an alias that is not text is refused with a reason, not a 500; a list of names is taken - a pasted _acme-challenge. label and a trailing dot are dropped; an address is not a host name - "no" in a position leaves that domain to its own zone, as acme.sh has it - the alias is checked for DNS-01 only, and the wizard sends it for DNS-01 only - per-domain aliases that do not fit a certificate are refused when the challenge type or the certificate is saved and in the wizard, and before the CA is asked anything - the wizard, reusing a certificate, takes the order the aliases were typed for - the alias tests in one file; a UI test for the editor and the wizard --- ham/acme.py | 106 ++++++++++++++++++++------- ham/crud.py | 7 +- ham/dnsapi.py | 12 ++++ static/js/pages/certificates.js | 2 +- tools/test-challenge-alias.py | 114 +++++++++++++++++++++++++++++- tools/test-hardening.py | 32 --------- tools/uitests/challenge-alias.mjs | 99 ++++++++++++++++++++++++++ tools/uitests/run-all.sh | 2 +- 8 files changed, 312 insertions(+), 62 deletions(-) create mode 100644 tools/uitests/challenge-alias.mjs diff --git a/ham/acme.py b/ham/acme.py index 19cfd21..a5111da 100644 --- a/ham/acme.py +++ b/ham/acme.py @@ -27,29 +27,89 @@ from . import auth, notify, sync # A DNS alias target is a plain host name: it ends up on the command line of a -# root process, so nothing else is let through. -_ALIAS_NAME = re.compile(r"^(?=.{1,253}$)([A-Za-z0-9_]([A-Za-z0-9_-]{0,61}[A-Za-z0-9_])?\.)+[A-Za-z0-9-]{2,63}$") +# root process, so nothing else is let through. Its last label starts with a +# letter, which an address or an option does not. +_ALIAS_NAME = re.compile(r"^(?=.{1,253}$)([A-Za-z0-9_]([A-Za-z0-9_-]{0,61}[A-Za-z0-9_])?\.)+" + r"[A-Za-z][A-Za-z0-9-]{0,61}[A-Za-z0-9]$") +# acme.sh's own word for "this domain is validated in its own zone", for a +# certificate where only some of the domains go through an alias. +NO_ALIAS = "no" +_ALIAS_PREFIX = "_acme-challenge." def parse_challenge_alias(value): """The alias domains typed on a challenge type, as a list. - One name per line (or separated by spaces or commas). Raises ValueError - with why when one is not a host name. + One name per line (or separated by spaces or commas); an API client may + send a list. A name is taken as it appears in a zone file too: a trailing + dot is dropped, and so is a leading _acme-challenge. label, which acme.sh + adds itself -- left in, the record would be made one label too deep. + Raises ValueError with why when one is not a host name. """ - names = [x for x in re.split(r"[\s,]+", (value or "").strip()) if x] - for n in names: - if not _ALIAS_NAME.match(n): - raise ValueError("%r is not a valid challenge alias domain" % n) + if isinstance(value, (list, tuple)) and all(isinstance(x, str) for x in value): + value = " ".join(value) + if value is None: + value = "" + if not isinstance(value, str): + raise ValueError("the challenge alias is one host name, or one per domain, not a %s" + % type(value).__name__) + names = [] + for typed in re.split(r"[\s,]+", value.strip()): + if not typed: + continue + n = typed[:-1] if typed.endswith(".") else typed + if n.lower().startswith(_ALIAS_PREFIX): + n = n[len(_ALIAS_PREFIX):] + if n != NO_ALIAS and not _ALIAS_NAME.match(n): + raise ValueError("%r is not a valid challenge alias domain" % typed) + names.append(n) return names -def check_challenge(item): - """What a challenge type is refused for when it is saved.""" - parse_challenge_alias(item.get("challenge_alias")) +def check_alias_count(ch, doms, cert_name=""): + """One alias serves every domain of a certificate; several are paired + with its domains by position, so there must be exactly as many. Raises + ValueError when there are not; returns the aliases.""" + names = parse_challenge_alias(ch.get("challenge_alias")) + if len(names) > 1 and len(names) != len(doms): + raise ValueError("challenge type %s has %d alias domains but %s has %d domain names: " + "give one alias for all of them, or one per domain name, in order" + % (ch.get("name") or "", len(names), + ("certificate %s" % cert_name) if cert_name else "the certificate", len(doms))) + return names + + +def check_challenge(item, cfg=None): + """What a challenge type is refused for when it is saved: an alias that + is not a host name, and a list of them that does not fit a certificate + already using it -- that certificate's next renewal would fail.""" + if item.get("method") != "dns01": + return # the alias is not used, and not shown + names = parse_challenge_alias(item.get("challenge_alias")) + item["challenge_alias"] = " ".join(names) + if not cfg or not item.get("id"): + return + for cert in cfg["acme"]["certificates"]: + if cert.get("challenge") == item["id"] and not is_custom(cert): + check_alias_count(item, parse_domains(cert), cert.get("name") or "") + + +def check_certificate(cfg, item): + """A certificate whose domain names no longer fit its challenge type's + per-domain aliases is refused when saved, not at its next renewal.""" + if is_custom(item): + return + ch = _by_id(cfg["acme"]["challenges"]).get(item.get("challenge")) + if not ch or ch.get("method") != "dns01": + return + try: + parse_challenge_alias(ch.get("challenge_alias")) + except ValueError: + return # the challenge type's own fault, said when issuing + check_alias_count(ch, parse_domains(item), item.get("name") or "") -def challenge_alias_args(ch, doms): +def challenge_alias_args(ch, doms, cert_name=""): """acme.sh's --challenge-alias arguments for a DNS-01 challenge type. With the validation records CNAMEd to another zone (acme.sh's DNS alias @@ -58,15 +118,8 @@ def challenge_alias_args(ch, doms): one per domain, in the order the domains are listed -- acme.sh pairs them by position. """ - names = parse_challenge_alias(ch.get("challenge_alias")) - if not names: - return [] - if len(names) not in (1, len(doms)): - raise ValueError("the challenge type has %d alias domains but the certificate has %d " - "domains: give one alias for all of them, or one per domain" - % (len(names), len(doms))) args = [] - for n in names: + for n in check_alias_count(ch, doms, cert_name): args += ["--challenge-alias", n] return args @@ -192,6 +245,14 @@ def _acme_issue(cfg, cert, force=False): doms = parse_domains(cert) if not doms: return {"ok": False, "error": "certificate has no domain names"} + if ch.get("method") == "dns01": + # Checked before anything is asked of the CA: an alias list that does + # not fit is this node's own configuration, and saving refuses it, but + # a restored or synced configuration has not been through that. + try: + challenge_alias_args(ch, doms, cert.get("name") or "") + except ValueError as e: + return {"ok": False, "error": str(e)} trace = [] rc, out = ensure_account(acc) @@ -206,10 +267,7 @@ def _acme_issue(cfg, cert, force=False): env = {} if ch.get("method") == "dns01": args += ["--dns", ch.get("dns_provider", "")] - try: - args += challenge_alias_args(ch, doms) - except ValueError as e: - return {"ok": False, "error": str(e)} + args += challenge_alias_args(ch, doms) for line in (ch.get("dns_credentials") or "").splitlines(): if "=" not in line: continue diff --git a/ham/crud.py b/ham/crud.py index a8d4a1c..c7fe75d 100644 --- a/ham/crud.py +++ b/ham/crud.py @@ -17,8 +17,9 @@ def _check_item(sec, col, cfg, item): """What a collection refuses before storing it. Raises ValueError with why.""" if sec == "acme" and col == "challenges": - acme.check_challenge(item) - return + acme.check_challenge(item, cfg) + if sec == "acme" and col == "certificates": + acme.check_certificate(cfg, item) if sec != "haproxy": return if col == "backends": @@ -166,11 +167,11 @@ def collection_item(sec, col, iid): continue if request.method == "PUT": data = request.get_json(force=True) or {} + data["id"] = iid # before the check: some ask what else uses this try: _check_item(sec, col, cfg, data) except ValueError as e: return jsonify({"error": str(e)}), 400 - data["id"] = iid items[i] = data save_config(cfg) _after_save(sec, col, cfg) diff --git a/ham/dnsapi.py b/ham/dnsapi.py index 8a8f52d..117dde5 100644 --- a/ham/dnsapi.py +++ b/ham/dnsapi.py @@ -247,6 +247,14 @@ def pick(kind, spec, defaults, label_field="name"): if wildcards and challenge.get("method") != "dns01": warns.append("%s is a wildcard, and wildcards can only be validated with DNS-01. " "Pick or create a DNS-01 challenge type." % wildcards[0]) + aliases = [] + if challenge.get("method") == "dns01": + # Aliases given one per domain are paired with the names by + # position, so a count that does not fit can never issue. + try: + aliases = acme.check_alias_count(challenge, domains) + except ValueError as e: + return jsonify({"ok": False, "error": str(e)}), 400 if challenge.get("method") == "dns01" and not challenge.get("dns_provider"): warns.append("The DNS-01 challenge type has no DNS API hook set, so acme.sh cannot " "create the validation record.") @@ -261,6 +269,10 @@ def pick(kind, spec, defaults, label_field="name"): warns.append("An uploaded certificate already covers these names. This one is " "issued beside it; delete the uploaded one once this is in use.") if existing: + if len(aliases) > 1: + # The same names in another order: the aliases follow the + # order just typed, so the certificate has to as well. + existing["domains"] = " ".join(domains) existing.update({"account": account["id"], "challenge": challenge["id"], "key_type": body.get("key_type") or existing.get("key_type", "ec-256"), "auto_renew": bool(body.get("auto_renew", True))}) diff --git a/static/js/pages/certificates.js b/static/js/pages/certificates.js index 05994b3..9451fc5 100644 --- a/static/js/pages/certificates.js +++ b/static/js/pages/certificates.js @@ -117,7 +117,7 @@ export async function openCertWizard(){ body.challenge=d.challenge_id===NEWOPT ? {name:d.ch_name||(d.ch_method==="dns01"?("dns-"+(d.dns_provider||"01")):"http-01"), method:d.ch_method,dns_provider:d.dns_provider,dns_credentials:d.dns_credentials, - challenge_alias:d.challenge_alias} + challenge_alias:d.ch_method==="dns01"?d.challenge_alias:""} : {id:d.challenge_id}; return body; }; diff --git a/tools/test-challenge-alias.py b/tools/test-challenge-alias.py index 08930c8..fd5e223 100644 --- a/tools/test-challenge-alias.py +++ b/tools/test-challenge-alias.py @@ -61,14 +61,126 @@ def ok(cond, msg): and args[args.index("--challenge-alias") + 1] == "alias.example.net", "issuing passes --challenge-alias to acme.sh") cfg["acme"]["challenges"][0]["challenge_alias"] = "a.net b.net c.net" +asked = [] +acme.ensure_account = lambda acc: (asked.append(acc) or (0, "")) +seen.clear() res = acme._acme_issue(cfg, cert) -ok(not res.get("ok") and "alias" in res.get("error", ""), +ok(not res.get("ok") and "alias" in res.get("error", "") and not seen, "an alias count that fits no domain count fails before acme.sh runs") +ok(not asked, "and before the CA is asked to register the account") +cfg["acme"]["challenges"][0]["challenge_alias"] = 5 +res = acme._acme_issue(cfg, cert) +ok(not res.get("ok") and "alias" in res.get("error", ""), + "a stored alias that is not text fails the issuance with why, rather than raising") cfg["acme"]["challenges"][0].update(method="http01", challenge_alias="alias.example.net") seen.clear() acme._acme_issue(cfg, cert) ok(seen and "--challenge-alias" not in seen[-1], "HTTP-01 never gets an alias") + + +def refused(fn, *a): + try: + fn(*a) + except ValueError: + return True + return False + + +print("the arguments") +ch = {"method": "dns01", "challenge_alias": "validation.example.net"} +ok(acme.challenge_alias_args(ch, ["a.example.com", "b.example.com"]) + == ["--challenge-alias", "validation.example.net"], + "one alias applies to every domain on the certificate") +ch["challenge_alias"] = "x.example.net y.example.net" +ok(acme.challenge_alias_args(ch, ["a.example.com", "b.example.com"]) + == ["--challenge-alias", "x.example.net", "--challenge-alias", "y.example.net"], + "one alias per domain is passed in order") +ok(acme.challenge_alias_args({"method": "dns01"}, ["a.example.com"]) == [], + "no alias adds no argument") +ok(refused(acme.challenge_alias_args, {"challenge_alias": "a.net b.net"}, ["1.x.com", "2.x.com", "3.x.com"]), + "an alias count that matches neither one nor the domain count is refused") +ok(acme.challenge_alias_args({"challenge_alias": "no alias.example.net"}, ["own.example.com", "ext.example.org"]) + == ["--challenge-alias", "no", "--challenge-alias", "alias.example.net"], + "\"no\" in a position leaves that domain to its own zone, as acme.sh has it") + +print("what counts as an alias") +P = acme.parse_challenge_alias +ok(P("alias.example.net.") == ["alias.example.net"], "a trailing dot, as in a zone file, is dropped") +ok(P("_acme-challenge.alias.example.net") == ["alias.example.net"], + "a leading _acme-challenge. label is dropped: acme.sh adds it itself") +ok(P("a.example.net, b.example.net\nc.example.net") == ["a.example.net", "b.example.net", "c.example.net"], + "spaces, commas and lines all separate") +ok(P(["a.example.net", "b.example.net"]) == ["a.example.net", "b.example.net"], "a list of names is taken as one") +ok(P(None) == [] and P("") == [], "nothing is nothing") +ok(P("_acme.validation.example.net") == ["_acme.validation.example.net"], "an underscore label is a host name here") +for bad in ("evil.net; rm -rf /", "--force", "nodots", "1.2.3.44", "a.--", "x.-y", "-x.example.net"): + ok(refused(P, bad), "refused: %r" % bad) +for bad in (5, True, {"a": 1}, ["a.example.net", 5]): + ok(refused(P, bad), "refused, with a reason rather than a crash: %r" % (bad,)) + +print("when a challenge type is saved") +for bad in (["a.example.net", 7], 5, True): + r = client.post("/api/acme/challenges", headers=H, json=dict(base, name="typed", challenge_alias=bad)) + ok(r.status_code == 400, "an alias of the wrong type is a 400, not a 500: %r (%d)" % (bad, r.status_code)) +r = client.post("/api/acme/challenges", headers=H, + json=dict(base, name="listed", challenge_alias=["a.example.net", "b.example.net"])) +ok(r.status_code == 200 and r.get_json().get("challenge_alias") == "a.example.net b.example.net", + "a list of names is stored as the text the editor shows") +r = client.post("/api/acme/challenges", headers=H, + json=dict(base, name="pasted", challenge_alias="_acme-challenge.alias.example.net.")) +ok(r.status_code == 200 and r.get_json().get("challenge_alias") == "alias.example.net", + "a pasted record name is stored as the alias it means") +r = client.post("/api/acme/challenges", headers=H, + json={"name": "web", "method": "http01", "challenge_alias": "oops typed"}) +ok(r.status_code == 200, "HTTP-01 does not use the alias, so what is left in it is not checked") + +print("aliases per domain, against the certificates that use them") +r = client.post("/api/acme/challenges", headers=H, json=dict(base, name="two", challenge_alias="x.example.net y.example.net")) +two = r.get_json() +r = client.post("/api/acme/challenges", headers=H, json=dict(base, name="one", challenge_alias="x.example.net")) +one = r.get_json() +cert2 = {"name": "pair", "domains": "a.example.com b.example.com", "account": "a", "challenge": two["id"]} +r = client.post("/api/acme/certificates", headers=H, json=cert2) +ok(r.status_code == 200, "two names on a challenge type with two aliases is saved") +pair = r.get_json() +r = client.put("/api/acme/certificates/" + pair["id"], headers=H, json=dict(cert2, domains="a.example.com b.example.com c.example.com")) +ok(r.status_code == 400 and "alias" in r.get_json().get("error", ""), + "a third name on that certificate is refused when saved: %s" % (r.get_json().get("error") or "")[:80]) +r = client.post("/api/acme/certificates", headers=H, json=dict(cert2, name="three", domains="a.example.com b.example.com c.example.com")) +ok(r.status_code == 400, "and so is a new certificate with three") +r = client.put("/api/acme/challenges/" + two["id"], headers=H, json=dict(base, name="two", challenge_alias="x.example.net y.example.net z.example.net")) +ok(r.status_code == 400 and "pair" in r.get_json().get("error", ""), + "a third alias on a challenge type a two-name certificate uses is refused, naming the certificate") +r = client.put("/api/acme/challenges/" + two["id"], headers=H, json=dict(base, name="two", challenge_alias="x.example.net")) +ok(r.status_code == 200, "one alias for all of them is always fine") +r = client.put("/api/acme/certificates/" + pair["id"], headers=H, json=dict(cert2, domains="a.example.com b.example.com c.example.com")) +ok(r.status_code == 200, "and then the third name is too") +r = client.post("/api/acme/certificates", headers=H, json=dict(cert2, name="many", challenge=one["id"], domains="a.example.com b.example.com c.example.com")) +ok(r.status_code == 200, "any number of names goes with a single alias") + +print("the certificate wizard") +W = {"issue": False, "account": {"name": "wiz", "email": "", "ca": "letsencrypt"}} +r = client.post("/api/wizard/certificate", headers=H, json=dict( + W, dry_run=True, domains="w1.example.com w2.example.com w3.example.com", + challenge=dict(base, name="wiz2", challenge_alias="x.example.net y.example.net"))) +ok(r.status_code == 400 and "alias" in (r.get_json() or {}).get("error", ""), + "three names and two aliases is refused in the preview already") +r = client.post("/api/wizard/certificate", headers=H, json=dict( + W, dry_run=True, domains="w1.example.com", challenge={"name": "wiz-http", "method": "http01", "challenge_alias": "oops typed"})) +ok(r.status_code == 200, "an alias left over in an HTTP-01 challenge type does not stop the wizard") +r = client.post("/api/wizard/certificate", headers=H, json=dict( + W, domains="w1.example.com w2.example.com", + challenge=dict(base, name="wiz2", challenge_alias="alias1.example.net alias2.example.net"))) +ok(r.status_code == 200, "two names and two aliases is created") +cid = r.get_json()["certificate_id"] +r = client.post("/api/wizard/certificate", headers=H, json=dict( + W, domains="w2.example.com w1.example.com", + challenge=dict(base, name="wiz3", challenge_alias="alias2.example.net alias1.example.net"))) +stored = [c for c in load_config()["acme"]["certificates"] if c["id"] == cid][0] +ok(r.status_code == 200 and r.get_json()["certificate_id"] == cid and stored["domains"] == "w2.example.com w1.example.com", + "the same names in another order: the certificate takes the order the aliases were typed for") + print() print("challenge alias holds" if not fails else "%d failed" % len(fails)) sys.exit(1 if fails else 0) diff --git a/tools/test-hardening.py b/tools/test-hardening.py index fa0314f..cdb0e96 100644 --- a/tools/test-hardening.py +++ b/tools/test-hardening.py @@ -64,38 +64,6 @@ def ok(cond, msg): ok(acme["challenges"][0]["dns_credentials"] == "CF_Token=leaky", "without mutating the stored config") -# -- DNS alias mode: the challenge alias reaches acme.sh as a validated flag -- -from ham import acme # noqa: E402 -ch = {"method": "dns01", "challenge_alias": "validation.example.net"} -ok(acme.challenge_alias_args(ch, ["a.example.com", "b.example.com"]) - == ["--challenge-alias", "validation.example.net"], - "one alias applies to every domain on the certificate") -ch["challenge_alias"] = "x.example.net y.example.net" -ok(acme.challenge_alias_args(ch, ["a.example.com", "b.example.com"]) - == ["--challenge-alias", "x.example.net", "--challenge-alias", "y.example.net"], - "one alias per domain is passed in order") -ok(acme.challenge_alias_args({"method": "dns01"}, ["a.example.com"]) == [], - "no alias adds no argument") - - -def refused(fn, *a): - try: - fn(*a) - except ValueError: - return True - return False - - -ok(refused(acme.challenge_alias_args, {"challenge_alias": "a.net b.net"}, ["1.x.com", "2.x.com", "3.x.com"]), - "an alias count that matches neither one nor the domain count is refused") -ok(refused(acme.check_challenge, {"challenge_alias": "evil.net; rm -rf /"}) - and refused(acme.check_challenge, {"challenge_alias": "--force"}) - and refused(acme.check_challenge, {"challenge_alias": "nodots"}), - "an alias that is not a host name is refused at save time") -ok(not refused(acme.check_challenge, {"challenge_alias": ""}) - and not refused(acme.check_challenge, {"challenge_alias": "_acme.validation.example.net"}), - "an empty alias and a valid one are accepted") - # -- a corrupt config file does not reopen setup ----------------------------- save_config(copy.deepcopy(DEFAULT_CONFIG)) # a real file exists CONF_PATH.write_text("{ this is not json") diff --git a/tools/uitests/challenge-alias.mjs b/tools/uitests/challenge-alias.mjs new file mode 100644 index 0000000..0c69a76 --- /dev/null +++ b/tools/uitests/challenge-alias.mjs @@ -0,0 +1,99 @@ +/* DNS alias mode from the browser's side: the challenge type editor has the + * alias field, and the certificate wizard shows it for a new DNS-01 challenge + * type only -- and sends it only then, so a value left in the hidden row + * cannot fail a request for an HTTP-01 certificate. */ +let parseHTML; +for (const where of ["linkedom", process.env.LINKEDOM]) { + if (!where) continue; + try { ({ parseHTML } = await import(where)); break; } catch { /* try the next */ } +} +if (!parseHTML) { + console.log(" skipped: no DOM available (npm i linkedom, or set LINKEDOM)"); + process.exit(0); +} +const REPO = process.cwd(); +const { document, window } = parseHTML(` +

+
+
+ +
`); +globalThis.document = document; globalThis.window = window; +globalThis.location = { hash: "#/p:services" }; +globalThis.MutationObserver = class { observe(){} }; +globalThis.localStorage = { getItem: () => null, setItem(){}, removeItem(){} }; +globalThis.setTimeout = () => 0; +globalThis.confirm = () => true; +/* linkedom's selects: see proxy-protocol.mjs */ +const optProto = Object.getPrototypeOf(document.createElement("option")); +Object.defineProperty(optProto, "selected", { + configurable: true, + get() { return this.hasAttribute("selected"); }, + set(v) { + if (v) { const prev = this.parentElement && this.parentElement.querySelector("option[selected]"); + if (prev && prev !== this) prev.removeAttribute("selected"); this.setAttribute("selected", ""); } + else this.removeAttribute("selected"); + }, +}); +const selProto = Object.getPrototypeOf(document.createElement("select")); +Object.defineProperty(selProto, "value", { + configurable: true, + get() { const o = this.querySelector("option[selected]") || this.querySelector("option"); return o ? o.value : ""; }, + set(v) { const o = [...this.querySelectorAll("option")].find(x => x.value === String(v)); if (o) o.selected = true; }, +}); + +let sent = null, path = null; +globalThis.fetch = async (u, o) => { + const p = String(u).replace(/^\/api\//, "").split("?")[0]; + if (o && o.body) { sent = JSON.parse(o.body); path = p; } + const bodies = { + "acme/accounts": [{ id: "a1", name: "le", email: "", ca: "letsencrypt" }], + "acme/challenges": [{ id: "h1", name: "http", method: "http01" }], + "acme/certificates": [], "acme/status": {}, "acme/health": { ok: true }, "acme/dnsapis": { ok: true, hooks: [] }, + "wizard/certificate": { ok: true, domains: ["app.example.com"], actions: [], warnings: [] }, + }; + return { ok: true, status: 200, json: async () => bodies[p] ?? {}, text: async () => "" }; +}; + +const { E, openEditor } = await import(REPO + "/static/js/entities.js"); +const { openCertWizard } = await import(REPO + "/static/js/pages/certificates.js"); + +let fail = 0; +const ok = (c, m) => { console.log((c ? " PASS " : " FAIL ") + m); if (!c) fail++; }; +const $ = s => document.querySelector(s); +const findButton = (root, label) => [...root.querySelectorAll("button")].find(b => b.textContent === label) || null; +const shown = k => { let el = $("#f_" + k); if (!el) return false; + while (el.parentNode && !el.parentNode.classList.contains("frm")) el = el.parentNode; + const lab = $('label[for="f_' + k + '"]'); + return el.style.display !== "none" && (!lab || lab.style.display !== "none"); }; +const setSelect = (sel, v) => { sel.value = v; sel.dispatchEvent(new window.Event("change")); }; + +// -- the challenge type editor ----------------------------------------------------- +ok(E["acme/challenges"].fields.some(f => f.k === "challenge_alias"), "a challenge type has the alias field"); +await openEditor("acme/challenges", { id: "c1", name: "cf", method: "dns01", dns_provider: "dns_cf", + dns_credentials: "CF_Token=x", challenge_alias: "alias.example.net" }); +ok($("#f_challenge_alias") && $("#f_challenge_alias").value === "alias.example.net", "the editor shows the stored alias"); + +// -- the certificate wizard -------------------------------------------------------- +await openCertWizard(); +ok(!shown("challenge_alias"), "an existing challenge type: no alias row"); +setSelect($("#f_challenge_id"), "__new__"); +ok(!shown("challenge_alias"), "a new HTTP-01 challenge type: none either"); +setSelect($("#f_ch_method"), "dns01"); +ok(shown("challenge_alias") && shown("dns_provider"), "a new DNS-01 challenge type shows it, with the DNS hook"); +$("#f_domains").value = "app.example.com"; +$("#f_challenge_alias").value = "alias.example.net"; +$("#f_issue").checked = false; +sent = null; +await findButton(document, "Preview").onclick(); +ok(path === "wizard/certificate" && sent && sent.challenge.method === "dns01" + && sent.challenge.challenge_alias === "alias.example.net", "DNS-01 sends the alias as typed"); +setSelect($("#f_ch_method"), "http01"); +ok(!shown("challenge_alias"), "back to HTTP-01 the row is hidden, its value still in it"); +sent = null; +await findButton(document, "Preview").onclick(); +ok(sent && sent.challenge.method === "http01" && sent.challenge.challenge_alias === "", + "and HTTP-01 is sent without what was left in the hidden row"); + +console.log(fail ? "\n" + fail + " failed" : "\nall passed"); +process.exit(fail ? 1 : 0); diff --git a/tools/uitests/run-all.sh b/tools/uitests/run-all.sh index 22e32a1..65e31cd 100755 --- a/tools/uitests/run-all.sh +++ b/tools/uitests/run-all.sh @@ -2,7 +2,7 @@ # Every front-end check, in the order that fails fastest. set -e cd "$(dirname "$0")/../.." -for t in undefined-names module-graph navigate routing pages certificates-banner managed-objects dialogs recipes table-sorting table-sorting-dom account-gear webui-blurb admin-sync overwrite config-diff update-peers sparkline stats-refresh theme access mobile history localtime login-2fa maintenance oauth ratelimit proxy-protocol language wizard-sections beta-channel large-configs lists-errorpages rules-wizard condition-wizard crowdsec npm-parity mcp i18n; do +for t in undefined-names module-graph navigate routing pages certificates-banner managed-objects dialogs recipes table-sorting table-sorting-dom account-gear webui-blurb admin-sync overwrite config-diff update-peers sparkline stats-refresh theme access mobile history localtime login-2fa maintenance oauth ratelimit proxy-protocol language wizard-sections beta-channel large-configs lists-errorpages rules-wizard condition-wizard crowdsec npm-parity challenge-alias mcp i18n; do printf '\n== %s ==\n' "$t" node "tools/uitests/$t.mjs" done From 6d770e2a762b3aebaf70ea665158531f40836b72 Mon Sep 17 00:00:00 2001 From: Alex Van de Putte Date: Tue, 10 Nov 2026 16:32:57 -0500 Subject: [PATCH 09/13] 2.2.0-beta.5: DNS alias mode --- README.md | 2 +- VERSION | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 92deb26..197df04 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ copying it across. ```bash # from a package: .deb and .rpm on every release (Debian, Ubuntu, RHEL, Fedora) -sudo apt-get install -y ./haproxy-manager_2.2.0_all.deb +sudo apt-get install -y ./haproxy-manager_2.1.1_all.deb # or the install script, on any Debian-based server curl -fsSL https://raw.githubusercontent.com/avandeputte/haproxy-manager/main/install.sh | sudo bash diff --git a/VERSION b/VERSION index ccbccc3..ec3d72f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.2.0 +2.2.0-beta.5 From ef75e1eca2e73c9343f4c8a8960759bffdd867bc Mon Sep 17 00:00:00 2001 From: Alex Van de Putte Date: Tue, 10 Nov 2026 17:40:26 -0500 Subject: [PATCH 10/13] 2.2.0: no EAB rows for Let's Encrypt, which has no EAB -- hidden in the editor and the wizard, not stored, not passed at registration --- ham/acme.py | 15 ++++++++++++++- static/js/core.js | 4 ++++ static/js/pages/certificates.js | 8 ++++---- tools/test-custom-ca.py | 13 +++++++++++++ tools/uitests/adcs.mjs | 28 ++++++++++++++++++++++------ 5 files changed, 57 insertions(+), 11 deletions(-) diff --git a/ham/acme.py b/ham/acme.py index 6e40dbe..988d950 100644 --- a/ham/acme.py +++ b/ham/acme.py @@ -46,6 +46,10 @@ } EAB_REQUIRED = {"google": "Google", "google_test": "Google", "sslcom": "SSL.com", "actalis": "Actalis"} +# The ones with no External Account Binding at all: Let's Encrypt has none, +# and AD CS is not reached over ACME. (ZeroSSL takes it, or makes its own +# from the e-mail address; a custom server may ask for it.) +NO_EAB = {"letsencrypt", "letsencrypt_test", "adcs"} KEYLEN = {"ec-256": "ec-256", "ec-384": "ec-384", "rsa-2048": "2048", "rsa-4096": "4096"} @@ -89,6 +93,13 @@ def acme_run(args, env_extra=None): def check_account(acc): """What an ACME account refuses before it is stored. Raises ValueError with why.""" ca = acc.get("ca") or "letsencrypt" + if ca in NO_EAB: + # The editor hides the EAB rows for these, so what was typed for + # another CA before switching must not stay behind, unseen: one of + # the two is a key. + for k in ("eab_kid", "eab_hmac"): + if acc.get(k): + acc[k] = "" if ca == "buypass": raise ValueError("Buypass stopped issuing certificates on 31 October 2025: " "choose another certificate authority for this account") @@ -188,7 +199,9 @@ def server_args(acc): def ensure_account(acc): args = ["--register-account", "-m", acc.get("email", "")] + server_args(acc) - if acc.get("eab_kid"): + # An account stored before the EAB rows were hidden for these CAs may + # still carry credentials meant for another one. + if acc.get("eab_kid") and (acc.get("ca") or "letsencrypt") not in NO_EAB: args += ["--eab-kid", acc["eab_kid"], "--eab-hmac-key", acc.get("eab_hmac", "")] return acme_run(args) diff --git a/static/js/core.js b/static/js/core.js index 1d0a754..85d0032 100644 --- a/static/js/core.js +++ b/static/js/core.js @@ -66,6 +66,10 @@ document.addEventListener("keydown",e=>{if(e.key==="Escape")closeDlg();}); in front of Active Directory Certificate Services, step-ca, Vault. */ export const CA_CHOICES=["letsencrypt","letsencrypt_test","zerossl","google","google_test","sslcom","actalis","custom", {value:"adcs",label:"Active Directory Certificate Services"}]; +/* The CAs that take no External Account Binding: Let's Encrypt has none, and + AD CS is not reached over ACME at all. Their EAB rows are not shown, and + what was left in them is not sent. */ +export const CA_NO_EAB=["letsencrypt","letsencrypt_test","adcs"]; /* The fields an account for Active Directory Certificate Services has: in the account editor as they are, in the wizard with acc_ in front. Here rather than with the wizard because the entities and the certificates page import diff --git a/static/js/pages/certificates.js b/static/js/pages/certificates.js index f24f2df..0da8e84 100644 --- a/static/js/pages/certificates.js +++ b/static/js/pages/certificates.js @@ -1,4 +1,4 @@ -import { $, ADCS_FIELDS, CA_CHOICES, api, btn, closeDlg, esc, fieldRow, list, lists, localTime, openDlg, readForm, showText, download } from "../core.js"; +import { $, ADCS_FIELDS, CA_CHOICES, CA_NO_EAB, api, btn, closeDlg, esc, fieldRow, list, lists, localTime, openDlg, readForm, showText, download } from "../core.js"; import { E, renderEntity } from "../entities.js"; import { t, tn } from "../i18n.js"; import { refreshStatus, route } from "../shell.js"; @@ -60,11 +60,11 @@ const caLabel=a=>a.ca==="custom"?(String(a.server||"").replace(/^https?:\/\//,"" /* Which rows an account's CA calls for beyond the common ones: a custom CA its address and certificate; AD CS its server and credentials, with the CA name and the domain for MS-ICPR and the CA certificate to trust for web enrollment - over HTTPS. EAB is a public CA's thing, or a custom one's. */ + over HTTPS. EAB is for the public CAs that have it, or a custom one. */ export const CA_EXTRA=["server","ca_bundle",...ADCS_FIELDS.map(f=>f.k),"eab_kid","eab_hmac"]; export function caFieldsShown(ca,method){ if(ca==="custom")return ["server","ca_bundle","eab_kid","eab_hmac"]; - if(ca!=="adcs")return ["eab_kid","eab_hmac"]; + if(ca!=="adcs")return CA_NO_EAB.includes(ca||"letsencrypt")?[]:["eab_kid","eab_hmac"]; const keys=ADCS_FIELDS.map(f=>f.k).filter(k=>method!=="certsrv"||!["adcs_ca_name","adcs_domain","adcs_dc"].includes(k)); return method==="certsrv"?keys.concat(["ca_bundle"]):keys; } @@ -187,7 +187,7 @@ export async function openCertWizard(){ ? Object.assign({name:d.acc_name||("account-"+(d.acc_ca||"letsencrypt")),email:d.acc_email,ca:d.acc_ca, server:d.acc_ca==="custom"?d.acc_server:"", ca_bundle:d.acc_ca==="custom"||d.acc_ca==="adcs"?d.acc_ca_bundle:"", - eab_kid:d.acc_ca==="adcs"?"":d.acc_eab_kid,eab_hmac:d.acc_ca==="adcs"?"":d.acc_eab_hmac}, + eab_kid:CA_NO_EAB.includes(d.acc_ca)?"":d.acc_eab_kid,eab_hmac:CA_NO_EAB.includes(d.acc_ca)?"":d.acc_eab_hmac}, d.acc_ca==="adcs"?Object.fromEntries(ADCS_FIELDS.map(f=>[f.k,d["acc_"+f.k]])):{}) : {id:d.account_id}; body.challenge=d.challenge_id===NEWOPT diff --git a/tools/test-custom-ca.py b/tools/test-custom-ca.py index 43742a3..1921d1f 100644 --- a/tools/test-custom-ca.py +++ b/tools/test-custom-ca.py @@ -116,6 +116,19 @@ def add_account(body, want): ok(who + " needs External Account Binding" in err, "%s without EAB says it needs it" % ca) add_account({"name": "gts", "ca": "google", "eab_kid": "kid", "eab_hmac": "hmac"}, 200) add_account({"name": "zero", "ca": "zerossl", "email": "z@example.com"}, 200) +for ca in ("letsencrypt", "letsencrypt_test"): + acc, _ = add_account({"name": "left-" + ca, "ca": ca, "eab_kid": "left-over", "eab_hmac": "left-over-key"}, 200) + ok(acc.get("eab_kid") == "" and acc.get("eab_hmac") == "", + "%s has no EAB: what was typed for another CA is not stored with it" % ca) +acc, _ = add_account({"name": "zero-eab", "ca": "zerossl", "eab_kid": "kid", "eab_hmac": "hmac"}, 200) +ok(acc.get("eab_kid") == "kid" and acc.get("eab_hmac") == "hmac", "ZeroSSL keeps EAB credentials that were given") +_real_run, _reg = acme.acme_run, [] +acme.acme_run = lambda args, env_extra=None: (_reg.append(args) or (0, "")) +acme.ensure_account({"ca": "letsencrypt", "email": "", "eab_kid": "old", "eab_hmac": "old-key"}) +ok("--eab-kid" not in _reg[-1], "an older Let's Encrypt account still carrying EAB registers without it") +acme.ensure_account({"ca": "google", "email": "", "eab_kid": "kid", "eab_hmac": "key"}) +ok(_reg[-1][_reg[-1].index("--eab-kid") + 1] == "kid", "a CA that has EAB is still given it") +acme.acme_run = _real_run for ca in ("google", "google_test", "sslcom", "actalis", "zerossl", "letsencrypt_test"): ok(acme.server_args({"ca": ca}) == ["--server", ca], "%s is passed to acme.sh by its short name" % ca) known = subprocess.run(["sh", "-c", "sed -n '/^CA_NAMES=/,/^\"/p' /opt/acme.sh/acme.sh"], diff --git a/tools/uitests/adcs.mjs b/tools/uitests/adcs.mjs index 2f8a9d1..f04b57b 100644 --- a/tools/uitests/adcs.mjs +++ b/tools/uitests/adcs.mjs @@ -86,7 +86,14 @@ ok(!!choice && /Active Directory/.test(choice.label), "the CA pickers offer Acti // -- the account editor ------------------------------------------------------------ openEditor("acme/accounts"); -ok(ADCS.every(k => !shown(k)) && shown("eab_kid"), "a new account hides the AD CS rows and shows EAB"); +ok(ADCS.every(k => !shown(k)) && !shown("eab_kid") && !shown("eab_hmac"), + "a new account, for Let's Encrypt, hides the AD CS rows and EAB, which Let's Encrypt has none of"); +for (const ca of ["zerossl", "google", "google_test", "sslcom", "actalis", "custom"]) { + setSelect($("#f_ca"), ca); + ok(shown("eab_kid") && shown("eab_hmac"), ca + " shows the EAB rows"); +} +setSelect($("#f_ca"), "letsencrypt_test"); +ok(!shown("eab_kid") && !shown("eab_hmac"), "Let's Encrypt's test CA hides them too"); setSelect($("#f_ca"), "adcs"); ok(ADCS.every(k => shown(k)), "picking Active Directory shows them all for MS-ICPR"); ok(!shown("server") && !shown("ca_bundle") && !shown("eab_kid") && !shown("eab_hmac"), @@ -102,8 +109,10 @@ ok($("#f_adcs_auth").value === "ntlm" && $('#f_adcs_auth option[value="kerberos" setSelect($("#f_adcs_method"), "msicpr"); ok($("#f_adcs_auth").value === "ntlm" && !$('#f_adcs_auth option[value="kerberos"]').disabled && $('#f_adcs_auth option[value="basic"]').disabled, "back to MS-ICPR: NTLM stays, basic is out"); +setSelect($("#f_ca"), "zerossl"); +ok(ADCS.every(k => !shown(k)) && shown("eab_kid"), "a public CA hides them again, and shows EAB if it has it"); setSelect($("#f_ca"), "letsencrypt"); -ok(ADCS.every(k => !shown(k)) && shown("eab_kid"), "a public CA hides them again"); +ok(ADCS.every(k => !shown(k)) && !shown("eab_kid"), "Let's Encrypt: neither"); openEditor("acme/accounts", ACCOUNTS[1]); ok($("#f_ca").value === "adcs" && shown("adcs_host") && $("#f_adcs_host").value === "ca01.corp.lan" @@ -135,8 +144,10 @@ const acc = $("#f_account_id"); ok([...acc.querySelectorAll("option")].some(o => o.textContent === "corp (ca01.corp.lan)"), "an existing AD CS account is offered by its CA server"); setSelect(acc, "__new__"); -ok(shown("acc_ca") && ADCS.every(k => !shown("acc_" + k)) && shown("acc_eab_kid"), - "a new account: the AD CS rows wait for Active Directory"); +ok(shown("acc_ca") && ADCS.every(k => !shown("acc_" + k)) && !shown("acc_eab_kid") && !shown("acc_eab_hmac"), + "a new account: the AD CS rows wait for Active Directory, and Let's Encrypt has no EAB rows"); +setSelect($("#f_acc_ca"), "google"); +ok(shown("acc_eab_kid") && shown("acc_eab_hmac"), "Google shows them"); setSelect($("#f_acc_ca"), "adcs"); ok(ADCS.every(k => shown("acc_" + k)) && !shown("acc_server") && !shown("acc_eab_kid"), "Active Directory shows them and hides the directory URL and EAB"); @@ -158,11 +169,16 @@ ok(path === "wizard/certificate" && sent && sent.account.ca === "adcs" && sent.a && sent.account.adcs_auth === "kerberos" && sent.account.adcs_user === "svc" && sent.account.adcs_password === "secret", "the new AD CS account is sent with its server, CA name, template and credentials"); ok(sent.account.eab_kid === "" && sent.account.server === "" && sent.account.adcs_extra === "", "and without EAB or a directory URL, the extra settings empty"); -setSelect($("#f_acc_ca"), "letsencrypt"); +setSelect($("#f_acc_ca"), "zerossl"); sent = null; await findButton(document, "Preview").onclick(); -ok(sent && sent.account.ca === "letsencrypt" && !("adcs_host" in sent.account) && sent.account.eab_kid === "leftover", +ok(sent && sent.account.ca === "zerossl" && !("adcs_host" in sent.account) && sent.account.eab_kid === "leftover", "a public CA is sent without the AD CS fields, EAB as typed"); +setSelect($("#f_acc_ca"), "letsencrypt"); +sent = null; +await findButton(document, "Preview").onclick(); +ok(sent && sent.account.ca === "letsencrypt" && sent.account.eab_kid === "" && sent.account.eab_hmac === "", + "Let's Encrypt is sent without what was left in the hidden EAB rows"); // -- the certificates page says when acme2certifier is missing ---------------------- await loadAcmeHealth(); From 532cf71502b21a3434f2ba7aa5c72ff620f12d94 Mon Sep 17 00:00:00 2001 From: Alex Van de Putte Date: Tue, 10 Nov 2026 19:58:02 -0500 Subject: [PATCH 11/13] 2.2.0: 36 more recipes -- Exchange, AD FS, RD Gateway, SQL Server, Synology, Proxmox Backup and Mail Gateway, NetBox, Headscale, Collabora, ONLYOFFICE, Jitsi, Zulip, Sentry, NATS, EMQX and others, each checked against the application's documentation --- static/FILES | 36 ++++++++++++++++++++++++ static/recipes/adfs.json | 19 +++++++++++++ static/recipes/awx.json | 20 +++++++++++++ static/recipes/code-server.json | 20 +++++++++++++ static/recipes/collabora.json | 20 +++++++++++++ static/recipes/emqx.json | 19 +++++++++++++ static/recipes/exchange.json | 20 +++++++++++++ static/recipes/glitchtip.json | 20 +++++++++++++ static/recipes/headscale.json | 20 +++++++++++++ static/recipes/iobroker.json | 18 ++++++++++++ static/recipes/jitsi.json | 20 +++++++++++++ static/recipes/jupyterhub.json | 20 +++++++++++++ static/recipes/metabase.json | 20 +++++++++++++ static/recipes/minecraft.json | 16 +++++++++++ static/recipes/mssql.json | 17 +++++++++++ static/recipes/nats.json | 19 +++++++++++++ static/recipes/netbox.json | 20 +++++++++++++ static/recipes/onlyoffice.json | 20 +++++++++++++ static/recipes/openhab.json | 20 +++++++++++++ static/recipes/openmediavault.json | 20 +++++++++++++ static/recipes/openproject.json | 20 +++++++++++++ static/recipes/oracle.json | 17 +++++++++++ static/recipes/pelican.json | 20 +++++++++++++ static/recipes/pgbouncer.json | 16 +++++++++++ static/recipes/proxmox-backup.json | 19 +++++++++++++ static/recipes/proxmox-mail-gateway.json | 19 +++++++++++++ static/recipes/pterodactyl.json | 20 +++++++++++++ static/recipes/rd-gateway.json | 17 +++++++++++ static/recipes/semaphore.json | 20 +++++++++++++ static/recipes/sentry.json | 20 +++++++++++++ static/recipes/superset.json | 20 +++++++++++++ static/recipes/synology-dsm.json | 20 +++++++++++++ static/recipes/technitium.json | 20 +++++++++++++ static/recipes/wazuh-dashboard.json | 20 +++++++++++++ static/recipes/windows-admin-center.json | 19 +++++++++++++ static/recipes/zammad.json | 20 +++++++++++++ static/recipes/zulip.json | 20 +++++++++++++ tools/test-recipes.py | 7 +++++ 38 files changed, 738 insertions(+) create mode 100644 static/recipes/adfs.json create mode 100644 static/recipes/awx.json create mode 100644 static/recipes/code-server.json create mode 100644 static/recipes/collabora.json create mode 100644 static/recipes/emqx.json create mode 100644 static/recipes/exchange.json create mode 100644 static/recipes/glitchtip.json create mode 100644 static/recipes/headscale.json create mode 100644 static/recipes/iobroker.json create mode 100644 static/recipes/jitsi.json create mode 100644 static/recipes/jupyterhub.json create mode 100644 static/recipes/metabase.json create mode 100644 static/recipes/minecraft.json create mode 100644 static/recipes/mssql.json create mode 100644 static/recipes/nats.json create mode 100644 static/recipes/netbox.json create mode 100644 static/recipes/onlyoffice.json create mode 100644 static/recipes/openhab.json create mode 100644 static/recipes/openmediavault.json create mode 100644 static/recipes/openproject.json create mode 100644 static/recipes/oracle.json create mode 100644 static/recipes/pelican.json create mode 100644 static/recipes/pgbouncer.json create mode 100644 static/recipes/proxmox-backup.json create mode 100644 static/recipes/proxmox-mail-gateway.json create mode 100644 static/recipes/pterodactyl.json create mode 100644 static/recipes/rd-gateway.json create mode 100644 static/recipes/semaphore.json create mode 100644 static/recipes/sentry.json create mode 100644 static/recipes/superset.json create mode 100644 static/recipes/synology-dsm.json create mode 100644 static/recipes/technitium.json create mode 100644 static/recipes/wazuh-dashboard.json create mode 100644 static/recipes/windows-admin-center.json create mode 100644 static/recipes/zammad.json create mode 100644 static/recipes/zulip.json diff --git a/static/FILES b/static/FILES index 0abdc7f..eab8213 100644 --- a/static/FILES +++ b/static/FILES @@ -44,6 +44,7 @@ js/state.js js/theme.js logo.svg recipes/actual.json +recipes/adfs.json recipes/adguard.json recipes/alertmanager.json recipes/argocd.json @@ -51,6 +52,7 @@ recipes/artifactory.json recipes/audiobookshelf.json recipes/authelia.json recipes/authentik.json +recipes/awx.json recipes/bambuddy.json recipes/bitwarden.json recipes/bookstack.json @@ -61,6 +63,8 @@ recipes/checkmk.json recipes/clickhouse.json recipes/cockpit.json recipes/cockroachdb.json +recipes/code-server.json +recipes/collabora.json recipes/consul.json recipes/couchdb.json recipes/crowdsec.json @@ -70,9 +74,11 @@ recipes/docker-registry.json recipes/dockge.json recipes/duplicati.json recipes/elasticsearch.json +recipes/emqx.json recipes/erpnext.json recipes/esphome.json recipes/etcd.json +recipes/exchange.json recipes/filebrowser.json recipes/firefly.json recipes/frigate.json @@ -82,12 +88,14 @@ recipes/gerrit.json recipes/ghost.json recipes/gitea.json recipes/gitlab.json +recipes/glitchtip.json recipes/gotify.json recipes/grafana.json recipes/graylog.json recipes/grocy.json recipes/guacamole.json recipes/harbor.json +recipes/headscale.json recipes/healthchecks.json recipes/home-assistant.json recipes/homebox.json @@ -96,9 +104,12 @@ recipes/homepage.json recipes/imap.json recipes/immich.json recipes/influxdb.json +recipes/iobroker.json recipes/jellyfin.json recipes/jenkins.json +recipes/jitsi.json recipes/joplin-server.json +recipes/jupyterhub.json recipes/kafka.json recipes/karakeep.json recipes/kavita.json @@ -121,16 +132,21 @@ recipes/mattermost.json recipes/mealie.json recipes/meilisearch.json recipes/memcached.json +recipes/metabase.json +recipes/minecraft.json recipes/miniflux.json recipes/minio.json recipes/mongodb.json recipes/moodle.json recipes/mqtt.json +recipes/mssql.json recipes/mumble.json recipes/mysql.json recipes/n8n.json +recipes/nats.json recipes/navidrome.json recipes/neo4j.json +recipes/netbox.json recipes/netdata.json recipes/nextcloud.json recipes/nexus.json @@ -141,8 +157,13 @@ recipes/ntfy.json recipes/octoprint.json recipes/odoo.json recipes/ollama.json +recipes/onlyoffice.json recipes/open-webui.json +recipes/openhab.json +recipes/openmediavault.json +recipes/openproject.json recipes/opnsense.json +recipes/oracle.json recipes/outline.json recipes/overseerr.json recipes/owncast.json @@ -150,7 +171,9 @@ recipes/paperless.json recipes/patroni-primary.json recipes/patroni-replicas.json recipes/peertube.json +recipes/pelican.json recipes/pgadmin.json +recipes/pgbouncer.json recipes/photoprism.json recipes/photostructure.json recipes/phpmyadmin.json @@ -161,11 +184,15 @@ recipes/plex.json recipes/portainer.json recipes/postgresql-plain.json recipes/prometheus.json +recipes/proxmox-backup.json +recipes/proxmox-mail-gateway.json recipes/proxmox.json +recipes/pterodactyl.json recipes/qbittorrent.json recipes/qdrant.json recipes/rabbitmq.json recipes/rancher.json +recipes/rd-gateway.json recipes/rdp.json recipes/redis.json recipes/redmine.json @@ -174,6 +201,8 @@ recipes/roundcube.json recipes/sabnzbd.json recipes/scrypted.json recipes/seafile.json +recipes/semaphore.json +recipes/sentry.json recipes/servarr.json recipes/smtp-submission.json recipes/smtp.json @@ -182,12 +211,15 @@ recipes/solr.json recipes/sonarqube.json recipes/ssh-bastion.json recipes/stirling-pdf.json +recipes/superset.json recipes/synapse.json recipes/syncthing.json +recipes/synology-dsm.json recipes/syslog-tcp.json recipes/tandoor.json recipes/tautulli.json recipes/teamcity.json +recipes/technitium.json recipes/teleport.json recipes/traefik.json recipes/transmission.json @@ -203,14 +235,18 @@ recipes/verdaccio.json recipes/victoriametrics.json recipes/vikunja.json recipes/wallabag.json +recipes/wazuh-dashboard.json recipes/weaviate.json recipes/web-websockets.json recipes/web.json recipes/wikijs.json +recipes/windows-admin-center.json recipes/woodpecker.json recipes/wordpress.json recipes/zabbix-server.json recipes/zabbix-web.json +recipes/zammad.json recipes/zigbee2mqtt.json recipes/zookeeper.json +recipes/zulip.json recipes/zwavejs.json diff --git a/static/recipes/adfs.json b/static/recipes/adfs.json new file mode 100644 index 0000000..bd1a9b1 --- /dev/null +++ b/static/recipes/adfs.json @@ -0,0 +1,19 @@ +{ + "name": "AD FS", + "category": "Applications", + "summary": "TCP 443 passed through and checked with the probe on port 80.", + "notes": "Microsoft does not support terminating TLS in front of AD FS, so the connection is passed through untouched and the certificate is the farm's own. The check asks /adfs/probe over plain HTTP on port 80, which AD FS answers with 200 for exactly this purpose -- the firewall on each server has to let this node reach it. Stickiness is left off, as Microsoft advises. Port 443 is taken whole by this service, so it cannot share a listening address with HTTPS services.", + "fields": { + "url": "tcp://0.0.0.0:443", + "target": "adfs1=10.0.0.1:443, adfs2=10.0.0.2:443", + "balance": "roundrobin", + "persistence": "none", + "health": "http", + "check_port": "80", + "health_uri": "/adfs/probe", + "health_status": "200", + "health_interval": "5s", + "timeout_server": "5m", + "timeout_connect": "5s" + } +} diff --git a/static/recipes/awx.json b/static/recipes/awx.json new file mode 100644 index 0000000..a1db1ae --- /dev/null +++ b/static/recipes/awx.json @@ -0,0 +1,20 @@ +{ + "name": "AWX", + "category": "Applications", + "summary": "HTTP checked with the unauthenticated ping.", + "notes": "The target is whatever exposes the AWX web service outside the cluster: the example is a NodePort, set with nodeport_port in the AWX resource. /api/v2/ping/ needs no sign-in. Job output arrives over a WebSocket, hence the timeout.", + "fields": { + "url": "https://awx.example.com", + "target": "10.0.0.10:30080", + "balance": "roundrobin", + "persistence": "none", + "health": "http", + "health_uri": "/api/v2/ping/", + "health_status": "200", + "health_interval": "10s", + "timeout_server": "1h", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/code-server.json b/static/recipes/code-server.json new file mode 100644 index 0000000..44e3e5d --- /dev/null +++ b/static/recipes/code-server.json @@ -0,0 +1,20 @@ +{ + "name": "code-server", + "category": "Applications", + "summary": "HTTP 8080 checked with /healthz, with the editor's WebSockets kept open.", + "notes": "The editor and its terminals run over WebSockets that last as long as the tab is open. /healthz answers without signing in; the status it reports, alive or expired, says whether anyone has been active and not whether the server is well, so only the status code is checked. code-server listens on 127.0.0.1 until bind-addr says otherwise.", + "fields": { + "url": "https://code.example.com", + "target": "10.0.0.10:8080", + "balance": "roundrobin", + "persistence": "none", + "health": "http", + "health_uri": "/healthz", + "health_status": "200", + "health_interval": "10s", + "timeout_server": "4h", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/collabora.json b/static/recipes/collabora.json new file mode 100644 index 0000000..80ad90e --- /dev/null +++ b/static/recipes/collabora.json @@ -0,0 +1,20 @@ +{ + "name": "Collabora Online", + "category": "Applications", + "summary": "HTTP 9980 with the editing WebSocket kept open.", + "notes": "Collabora has to be started with ssl.enable=false and ssl.termination=true to answer plain HTTP and still hand out https:// addresses; without them the target is https://. / answers a bare OK, which makes the check. A document is edited over one WebSocket for as long as it is open. With more than one server every request for a document has to reach the same one, which Collabora's documentation does by hashing the WOPISrc parameter (balance url_param WOPISrc check_post) -- a custom line on the backend pool, not something stickiness by address gets right.", + "fields": { + "url": "https://office.example.com", + "target": "10.0.0.10:9980", + "balance": "roundrobin", + "persistence": "none", + "health": "http", + "health_uri": "/", + "health_status": "200", + "health_interval": "5s", + "timeout_server": "1h", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/emqx.json b/static/recipes/emqx.json new file mode 100644 index 0000000..d938c90 --- /dev/null +++ b/static/recipes/emqx.json @@ -0,0 +1,19 @@ +{ + "name": "EMQX", + "category": "Infrastructure", + "summary": "TCP 1883 with the check aimed at the broker's status page.", + "notes": "MQTT clients use 1883 while the check asks /status on the dashboard port, 18083, which needs no sign-in and answers 503 until the broker has finished starting. Clients connect once and stay connected, so the timeout has to outlast their keepalive. EMQX can read the PROXY protocol to see each client's real address: switch it on for the listener there and here together.", + "fields": { + "url": "tcp://0.0.0.0:1883", + "target": "emqx1=10.0.0.1:1883, emqx2=10.0.0.2:1883", + "balance": "leastconn", + "persistence": "none", + "health": "http", + "check_port": "18083", + "health_uri": "/status", + "health_status": "200", + "health_interval": "5s", + "timeout_server": "4h", + "timeout_connect": "5s" + } +} diff --git a/static/recipes/exchange.json b/static/recipes/exchange.json new file mode 100644 index 0000000..3a5508b --- /dev/null +++ b/static/recipes/exchange.json @@ -0,0 +1,20 @@ +{ + "name": "Microsoft Exchange", + "category": "Applications", + "summary": "HTTPS 443 re-encrypted to Exchange and checked with its healthcheck.htm.", + "notes": "The target is https:// because Exchange serves its client protocols over TLS only. /owa/healthcheck.htm is the probe Exchange provides for load balancers: it answers 200 while that protocol is healthy on the server, and every virtual directory has its own, such as /ews/healthcheck.htm and /mapi/healthcheck.htm. Extended Protection, switched on by the installer since Exchange 2019 CU14, only lets Outlook sign in when the certificate here is the same one Exchange serves, so put one certificate on both. No stickiness is needed since Exchange 2016. Phones hold a request open for many minutes, hence the timeout.", + "fields": { + "url": "https://mail.example.com", + "target": "ex1=https://10.0.0.1:443, ex2=https://10.0.0.2:443", + "balance": "leastconn", + "persistence": "none", + "health": "http", + "health_uri": "/owa/healthcheck.htm", + "health_status": "200", + "health_interval": "10s", + "timeout_server": "1h", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/glitchtip.json b/static/recipes/glitchtip.json new file mode 100644 index 0000000..1a80e36 --- /dev/null +++ b/static/recipes/glitchtip.json @@ -0,0 +1,20 @@ +{ + "name": "GlitchTip", + "category": "Applications", + "summary": "HTTP 8000 checked with /_health/.", + "notes": "Set GLITCHTIP_DOMAIN to the address published here, with https://, since GlitchTip builds its links and the addresses it hands to SDKs from it.", + "fields": { + "url": "https://glitchtip.example.com", + "target": "10.0.0.10:8000", + "balance": "roundrobin", + "persistence": "none", + "health": "http", + "health_uri": "/_health/", + "health_status": "200", + "health_interval": "10s", + "timeout_server": "5m", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/headscale.json b/static/recipes/headscale.json new file mode 100644 index 0000000..9feeb4d --- /dev/null +++ b/static/recipes/headscale.json @@ -0,0 +1,20 @@ +{ + "name": "Headscale", + "category": "Infrastructure", + "summary": "HTTP 8080 checked with /health, with the control connection kept open.", + "notes": "Every Tailscale client holds one connection open to the control server, started as an HTTP upgrade, so the timeout is long. Headscale has to be told its public address (server_url, with https://) and to trust this node (trusted_proxies), and it listens on 127.0.0.1 until listen_addr says otherwise. With the embedded relay in use, its STUN on UDP 3478 cannot pass through here and has to be reachable directly.", + "fields": { + "url": "https://headscale.example.com", + "target": "10.0.0.10:8080", + "balance": "roundrobin", + "persistence": "none", + "health": "http", + "health_uri": "/health", + "health_status": "200", + "health_interval": "5s", + "timeout_server": "1h", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/iobroker.json b/static/recipes/iobroker.json new file mode 100644 index 0000000..bb8f837 --- /dev/null +++ b/static/recipes/iobroker.json @@ -0,0 +1,18 @@ +{ + "name": "ioBroker", + "category": "Applications", + "summary": "HTTP 8081 for the admin interface, with its WebSocket kept open.", + "notes": "The admin interface does all its work over a WebSocket. Visualisations are served by the web adapter on 8082, which is a service of its own. ioBroker has no address to check without signing in, so the check is a connection.", + "fields": { + "url": "https://iobroker.example.com", + "target": "10.0.0.10:8081", + "balance": "roundrobin", + "persistence": "none", + "health": "tcp", + "health_interval": "10s", + "timeout_server": "1h", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/jitsi.json b/static/recipes/jitsi.json new file mode 100644 index 0000000..895b2e2 --- /dev/null +++ b/static/recipes/jitsi.json @@ -0,0 +1,20 @@ +{ + "name": "Jitsi Meet", + "category": "Applications", + "summary": "HTTP 8000 for the web container, with its signalling WebSocket.", + "notes": "For docker-jitsi-meet, with DISABLE_HTTPS=1, ENABLE_LETSENCRYPT=0 and PUBLIC_URL set to the address published here. Only the pages and the signalling on /xmpp-websocket come through this service. Sound and video travel over UDP 10000 straight to the video bridge, which has to be reachable from outside on its own. Jitsi does not work under a path, only at the root of a name.", + "fields": { + "url": "https://meet.example.com", + "target": "10.0.0.10:8000", + "balance": "roundrobin", + "persistence": "none", + "health": "http", + "health_uri": "/", + "health_status": "", + "health_interval": "10s", + "timeout_server": "1h", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/jupyterhub.json b/static/recipes/jupyterhub.json new file mode 100644 index 0000000..e82b466 --- /dev/null +++ b/static/recipes/jupyterhub.json @@ -0,0 +1,20 @@ +{ + "name": "JupyterHub", + "category": "Applications", + "summary": "HTTP 8000 checked with /hub/health, with kernel WebSockets kept open.", + "notes": "A notebook talks to its kernel over a WebSocket for as long as it is open, and a cell can run for hours. /hub/health exists for load balancers and answers without touching anything else.", + "fields": { + "url": "https://jupyter.example.com", + "target": "10.0.0.10:8000", + "balance": "roundrobin", + "persistence": "none", + "health": "http", + "health_uri": "/hub/health", + "health_status": "200", + "health_interval": "10s", + "timeout_server": "4h", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/metabase.json b/static/recipes/metabase.json new file mode 100644 index 0000000..86e2b4d --- /dev/null +++ b/static/recipes/metabase.json @@ -0,0 +1,20 @@ +{ + "name": "Metabase", + "category": "Applications", + "summary": "HTTP 3000 checked with /api/health.", + "notes": "/api/health answers 503 while Metabase is still starting or cannot reach its application database, and 200 once it can serve. A slow question holds its request open until the warehouse answers, hence the timeout.", + "fields": { + "url": "https://metabase.example.com", + "target": "10.0.0.10:3000", + "balance": "roundrobin", + "persistence": "none", + "health": "http", + "health_uri": "/api/health", + "health_status": "200", + "health_interval": "10s", + "timeout_server": "10m", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/minecraft.json b/static/recipes/minecraft.json new file mode 100644 index 0000000..a94b4cd --- /dev/null +++ b/static/recipes/minecraft.json @@ -0,0 +1,16 @@ +{ + "name": "Minecraft (Java Edition)", + "category": "Infrastructure", + "summary": "TCP 25565 for players.", + "notes": "The server sees every player arriving from this node's address unless the PROXY protocol carries the real one. Paper (proxies.proxy-protocol), Velocity (haproxy-protocol) and BungeeCord (proxy_protocol) can read it: switch it on there and under PROXY protocol here at the same moment, because a server that expects it drops every connection that arrives without. Bedrock Edition uses UDP and cannot pass through.", + "fields": { + "url": "tcp://0.0.0.0:25565", + "target": "10.0.0.1:25565", + "balance": "roundrobin", + "persistence": "none", + "health": "tcp", + "health_interval": "10s", + "timeout_server": "1h", + "timeout_connect": "5s" + } +} diff --git a/static/recipes/mssql.json b/static/recipes/mssql.json new file mode 100644 index 0000000..3034231 --- /dev/null +++ b/static/recipes/mssql.json @@ -0,0 +1,17 @@ +{ + "name": "Microsoft SQL Server", + "category": "Databases", + "summary": "TCP 1433 with a connection check.", + "notes": "For one instance, or the listener of an availability group. A named instance picks its port at start-up and announces it through the SQL Server Browser on UDP 1434, which cannot pass through here: give the instance a fixed TCP port and use that. Do not put servers that are not copies of each other behind one service.", + "fields": { + "url": "tcp://0.0.0.0:1433", + "target": "10.0.0.1:1433", + "balance": "roundrobin", + "persistence": "source", + "stick_type": "ip", + "health": "tcp", + "health_interval": "5s", + "timeout_server": "1h", + "timeout_connect": "5s" + } +} diff --git a/static/recipes/nats.json b/static/recipes/nats.json new file mode 100644 index 0000000..1cb233b --- /dev/null +++ b/static/recipes/nats.json @@ -0,0 +1,19 @@ +{ + "name": "NATS", + "category": "Infrastructure", + "summary": "TCP 4222 with the check aimed at the monitoring port.", + "notes": "Clients use 4222 while the check asks /healthz on the monitoring port, 8222, which is off until http_port is set or the server is started with -m 8222. A NATS server tells its clients the addresses of the rest of the cluster and they connect to those directly from then on; set no_advertise, or client_advertise to the address published here, to keep them coming through this service.", + "fields": { + "url": "tcp://0.0.0.0:4222", + "target": "nats1=10.0.0.1:4222, nats2=10.0.0.2:4222, nats3=10.0.0.3:4222", + "balance": "roundrobin", + "persistence": "none", + "health": "http", + "check_port": "8222", + "health_uri": "/healthz", + "health_status": "200", + "health_interval": "5s", + "timeout_server": "1h", + "timeout_connect": "5s" + } +} diff --git a/static/recipes/netbox.json b/static/recipes/netbox.json new file mode 100644 index 0000000..8c6247f --- /dev/null +++ b/static/recipes/netbox.json @@ -0,0 +1,20 @@ +{ + "name": "NetBox", + "category": "Applications", + "summary": "HTTP 8000 checked against the login page.", + "notes": "The port is the one netbox-docker's example publishes; a native install has nginx in front of the application, so point at that nginx. /login/ answers without signing in, while /api/status/ does not unless LOGIN_REQUIRED is off. NetBox refuses forms posted under a name it does not know: list the public address, with https://, in CSRF_TRUSTED_ORIGINS.", + "fields": { + "url": "https://netbox.example.com", + "target": "10.0.0.10:8000", + "balance": "roundrobin", + "persistence": "none", + "health": "http", + "health_uri": "/login/", + "health_status": "", + "health_interval": "10s", + "timeout_server": "5m", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/onlyoffice.json b/static/recipes/onlyoffice.json new file mode 100644 index 0000000..389eaba --- /dev/null +++ b/static/recipes/onlyoffice.json @@ -0,0 +1,20 @@ +{ + "name": "ONLYOFFICE Docs", + "category": "Applications", + "summary": "HTTP 80 with the editing WebSocket kept open.", + "notes": "The document server works out its own address from X-Forwarded-Proto and X-Forwarded-Host, which this node sends. /healthcheck answers 200 with true or false in the body, so the check proves the server is answering rather than that its database and queue are well. Editing runs over a WebSocket for as long as a document is open.", + "fields": { + "url": "https://docs.example.com", + "target": "10.0.0.10:80", + "balance": "roundrobin", + "persistence": "none", + "health": "http", + "health_uri": "/healthcheck", + "health_status": "200", + "health_interval": "10s", + "timeout_server": "1h", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/openhab.json b/static/recipes/openhab.json new file mode 100644 index 0000000..98e93ee --- /dev/null +++ b/static/recipes/openhab.json @@ -0,0 +1,20 @@ +{ + "name": "openHAB", + "category": "Applications", + "summary": "HTTP 8080 checked against the REST API, with its event stream kept open.", + "notes": "The interface keeps an event stream open for live updates, so a short timeout shows up as a page that stops changing. /rest/ answers without signing in. Leave signing in to openHAB: with the Sign-in option here the browser's Authorization header reaches openHAB as well, which its documentation warns against.", + "fields": { + "url": "https://openhab.example.com", + "target": "10.0.0.10:8080", + "balance": "roundrobin", + "persistence": "none", + "health": "http", + "health_uri": "/rest/", + "health_status": "200", + "health_interval": "5s", + "timeout_server": "1h", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/openmediavault.json b/static/recipes/openmediavault.json new file mode 100644 index 0000000..61237ec --- /dev/null +++ b/static/recipes/openmediavault.json @@ -0,0 +1,20 @@ +{ + "name": "OpenMediaVault", + "category": "Infrastructure", + "summary": "HTTP 80 for the web interface.", + "notes": "OpenMediaVault serves its interface over plain HTTP on 80 unless HTTPS has been switched on in its settings, in which case use https://...:443 as the target. The interface signs people out after five idle minutes by default, which is its own setting and not this timeout.", + "fields": { + "url": "https://omv.example.com", + "target": "10.0.0.10:80", + "balance": "roundrobin", + "persistence": "none", + "health": "http", + "health_uri": "/", + "health_status": "", + "health_interval": "10s", + "timeout_server": "30m", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/openproject.json b/static/recipes/openproject.json new file mode 100644 index 0000000..69f7cb6 --- /dev/null +++ b/static/recipes/openproject.json @@ -0,0 +1,20 @@ +{ + "name": "OpenProject", + "category": "Applications", + "summary": "HTTP 8080 checked with its health check.", + "notes": "8080 is where the Docker deployments publish OpenProject. /health_checks/default answers once the web workers are running. OpenProject compares each request with the name it was given, so set OPENPROJECT_HOST__NAME to the name published here and leave OPENPROJECT_HTTPS on.", + "fields": { + "url": "https://projects.example.com", + "target": "10.0.0.10:8080", + "balance": "roundrobin", + "persistence": "none", + "health": "http", + "health_uri": "/health_checks/default", + "health_status": "200", + "health_interval": "10s", + "timeout_server": "10m", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/oracle.json b/static/recipes/oracle.json new file mode 100644 index 0000000..81c90b8 --- /dev/null +++ b/static/recipes/oracle.json @@ -0,0 +1,17 @@ +{ + "name": "Oracle Database", + "category": "Databases", + "summary": "TCP 1521 to the listener of a single instance.", + "notes": "This works when the listener hands the connection to a server process itself. A listener that answers with a redirect instead -- a RAC SCAN listener, or dispatchers on another host -- sends the client straight to the address it names, past this service; point those clients at the SCAN name directly.", + "fields": { + "url": "tcp://0.0.0.0:1521", + "target": "10.0.0.1:1521", + "balance": "roundrobin", + "persistence": "source", + "stick_type": "ip", + "health": "tcp", + "health_interval": "5s", + "timeout_server": "1h", + "timeout_connect": "5s" + } +} diff --git a/static/recipes/pelican.json b/static/recipes/pelican.json new file mode 100644 index 0000000..2273c3a --- /dev/null +++ b/static/recipes/pelican.json @@ -0,0 +1,20 @@ +{ + "name": "Pelican Panel", + "category": "Applications", + "summary": "HTTP 80 for the panel, checked with /up.", + "notes": "The panel only. Add this node to the trusted proxies on the panel's Settings page -- without it file uploads fail -- and set APP_URL to the address published here. Each Wings node is a service of its own on 8080, with a WebSocket for the console; SFTP on 2022 is not HTTP.", + "fields": { + "url": "https://panel.example.com", + "target": "10.0.0.10:80", + "balance": "roundrobin", + "persistence": "none", + "health": "http", + "health_uri": "/up", + "health_status": "200", + "health_interval": "10s", + "timeout_server": "10m", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/pgbouncer.json b/static/recipes/pgbouncer.json new file mode 100644 index 0000000..fe04ea1 --- /dev/null +++ b/static/recipes/pgbouncer.json @@ -0,0 +1,16 @@ +{ + "name": "PgBouncer", + "category": "Databases", + "summary": "TCP 6432 across a pool of PgBouncers.", + "notes": "A plain connection check: PgBouncer keeps its own watch on the PostgreSQL servers behind it. With more than one PgBouncer a cancel request can arrive at a different one from the query it is meant for; PgBouncer's peering ([peers] and peer_id) is what passes it on.", + "fields": { + "url": "tcp://0.0.0.0:6432", + "target": "pgb1=10.0.0.1:6432, pgb2=10.0.0.2:6432", + "balance": "roundrobin", + "persistence": "none", + "health": "tcp", + "health_interval": "5s", + "timeout_server": "30m", + "timeout_connect": "5s" + } +} diff --git a/static/recipes/proxmox-backup.json b/static/recipes/proxmox-backup.json new file mode 100644 index 0000000..cd0010e --- /dev/null +++ b/static/recipes/proxmox-backup.json @@ -0,0 +1,19 @@ +{ + "name": "Proxmox Backup Server", + "category": "Infrastructure", + "summary": "HTTPS 8007, passed on to a self-signed backend.", + "notes": "The target is https:// because the server only speaks TLS, with a certificate of its own that is not verified. This is for the web interface. Backup clients and Proxmox VE storage switch the connection to a backup protocol of their own and usually pin the server's certificate fingerprint, so point those at the server itself.", + "fields": { + "url": "https://pbs.example.com", + "target": "pbs1=https://10.0.0.1:8007", + "balance": "source", + "persistence": "source", + "stick_type": "ip", + "health": "tcp", + "health_interval": "10s", + "timeout_server": "4h", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/proxmox-mail-gateway.json b/static/recipes/proxmox-mail-gateway.json new file mode 100644 index 0000000..d7d4464 --- /dev/null +++ b/static/recipes/proxmox-mail-gateway.json @@ -0,0 +1,19 @@ +{ + "name": "Proxmox Mail Gateway", + "category": "Infrastructure", + "summary": "HTTPS 8006 for administration and the user quarantine.", + "notes": "The target is https:// because the interface is always encrypted, with a self-signed certificate that is not verified. Users reach their quarantine on the same port, under /quarantine. The mail itself arrives over SMTP, which is a separate service: see the SMTP relay recipe.", + "fields": { + "url": "https://pmg.example.com", + "target": "pmg1=https://10.0.0.1:8006", + "balance": "source", + "persistence": "source", + "stick_type": "ip", + "health": "tcp", + "health_interval": "10s", + "timeout_server": "1h", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/pterodactyl.json b/static/recipes/pterodactyl.json new file mode 100644 index 0000000..6519444 --- /dev/null +++ b/static/recipes/pterodactyl.json @@ -0,0 +1,20 @@ +{ + "name": "Pterodactyl Panel", + "category": "Applications", + "summary": "HTTP 80 for the panel.", + "notes": "The panel only. Set TRUSTED_PROXIES in its .env to this node's address, or it builds http:// links. The expected status is empty because / answers a visitor who is not signed in with a redirect. Each Wings node is a service of its own on 8080, with a WebSocket for the console and Behind Proxy ticked on the node; SFTP on 2022 is not HTTP.", + "fields": { + "url": "https://panel.example.com", + "target": "10.0.0.10:80", + "balance": "roundrobin", + "persistence": "none", + "health": "http", + "health_uri": "/", + "health_status": "", + "health_interval": "10s", + "timeout_server": "10m", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/rd-gateway.json b/static/recipes/rd-gateway.json new file mode 100644 index 0000000..782eb43 --- /dev/null +++ b/static/recipes/rd-gateway.json @@ -0,0 +1,17 @@ +{ + "name": "Remote Desktop Gateway", + "category": "Infrastructure", + "summary": "TCP 443 passed through, with each client kept on one gateway.", + "notes": "Passed through without terminating TLS, the way the load-balancing guides for it are written: the gateway authenticates each connection itself, inside its own TLS session. A session opens two connections that must reach the same gateway, so clients are pinned by address. The gateway's UDP transport on 3391 cannot pass through here. Port 443 is taken whole by this service, so it cannot share a listening address with HTTPS services.", + "fields": { + "url": "tcp://0.0.0.0:443", + "target": "rdgw1=10.0.0.1:443, rdgw2=10.0.0.2:443", + "balance": "source", + "persistence": "source", + "stick_type": "ip", + "health": "tcp", + "health_interval": "10s", + "timeout_server": "8h", + "timeout_connect": "5s" + } +} diff --git a/static/recipes/semaphore.json b/static/recipes/semaphore.json new file mode 100644 index 0000000..1825072 --- /dev/null +++ b/static/recipes/semaphore.json @@ -0,0 +1,20 @@ +{ + "name": "Semaphore UI", + "category": "Applications", + "summary": "HTTP 3000 checked with /api/ping, with the task log WebSocket kept open.", + "notes": "Task output streams over a WebSocket that is only pinged every two minutes, so a short timeout shows up as a log view that keeps disconnecting. Set web_host to the address published here: Semaphore builds redirects and checks the request's origin from it.", + "fields": { + "url": "https://semaphore.example.com", + "target": "10.0.0.10:3000", + "balance": "roundrobin", + "persistence": "none", + "health": "http", + "health_uri": "/api/ping", + "health_status": "200", + "health_interval": "10s", + "timeout_server": "1h", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/sentry.json b/static/recipes/sentry.json new file mode 100644 index 0000000..97150bc --- /dev/null +++ b/static/recipes/sentry.json @@ -0,0 +1,20 @@ +{ + "name": "Sentry", + "category": "Applications", + "summary": "HTTP 9000 checked with /_health/.", + "notes": "9000 is where the self-hosted installation publishes its nginx. Set system.url-prefix in config.yml and uncomment the SSL/TLS block in sentry.conf.py, or forms fail with CSRF errors. /_health/ answers ok without running any checks.", + "fields": { + "url": "https://sentry.example.com", + "target": "10.0.0.10:9000", + "balance": "roundrobin", + "persistence": "none", + "health": "http", + "health_uri": "/_health/", + "health_status": "200", + "health_interval": "10s", + "timeout_server": "5m", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/superset.json b/static/recipes/superset.json new file mode 100644 index 0000000..9e2bbaa --- /dev/null +++ b/static/recipes/superset.json @@ -0,0 +1,20 @@ +{ + "name": "Apache Superset", + "category": "Applications", + "summary": "HTTP 8088 checked with /health.", + "notes": "/health answers OK whenever the web server is running. Set ENABLE_PROXY_FIX = True in superset_config.py so Superset believes the forwarded headers and builds https:// links. A slow chart holds its request open until the database answers, hence the timeout.", + "fields": { + "url": "https://superset.example.com", + "target": "10.0.0.10:8088", + "balance": "roundrobin", + "persistence": "none", + "health": "http", + "health_uri": "/health", + "health_status": "200", + "health_interval": "10s", + "timeout_server": "10m", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/synology-dsm.json b/static/recipes/synology-dsm.json new file mode 100644 index 0000000..5d2a2a9 --- /dev/null +++ b/static/recipes/synology-dsm.json @@ -0,0 +1,20 @@ +{ + "name": "Synology DSM", + "category": "Infrastructure", + "summary": "HTTP 5000 for the DSM desktop.", + "notes": "DSM answers plain HTTP on 5000 and HTTPS on 5001; this uses 5000 and leaves the encryption to this node. If DSM is set to redirect HTTP to HTTPS by itself, switch that off or use https://...:5001 as the target, or visitors are sent on to port 5001. The expected status is empty because what / answers depends on that setting. Uploads through File Station are what the timeout is for.", + "fields": { + "url": "https://nas.example.com", + "target": "10.0.0.10:5000", + "balance": "roundrobin", + "persistence": "none", + "health": "http", + "health_uri": "/", + "health_status": "", + "health_interval": "10s", + "timeout_server": "1h", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/technitium.json b/static/recipes/technitium.json new file mode 100644 index 0000000..4109552 --- /dev/null +++ b/static/recipes/technitium.json @@ -0,0 +1,20 @@ +{ + "name": "Technitium DNS", + "category": "Infrastructure", + "summary": "HTTP 5380 for the web console.", + "notes": "The administration console only; DNS queries on port 53 do not come through here. Tell the server which address this node has under its reverse proxy settings, or every sign-in is logged as coming from here.", + "fields": { + "url": "https://dns.example.com", + "target": "10.0.0.10:5380", + "balance": "roundrobin", + "persistence": "none", + "health": "http", + "health_uri": "/", + "health_status": "", + "health_interval": "10s", + "timeout_server": "30m", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/wazuh-dashboard.json b/static/recipes/wazuh-dashboard.json new file mode 100644 index 0000000..1d45b77 --- /dev/null +++ b/static/recipes/wazuh-dashboard.json @@ -0,0 +1,20 @@ +{ + "name": "Wazuh dashboard", + "category": "Applications", + "summary": "HTTPS 443 re-encrypted to the dashboard and checked on its login page.", + "notes": "The dashboard only speaks TLS, with a certificate of its own that is not verified. /app/login is the one page that answers without signing in. Agents do not come this way: they report to the Wazuh server on TCP 1514 and enrol on 1515, which are services of their own.", + "fields": { + "url": "https://wazuh.example.com", + "target": "wazuh1=https://10.0.0.10:443", + "balance": "roundrobin", + "persistence": "none", + "health": "http", + "health_uri": "/app/login", + "health_status": "", + "health_interval": "10s", + "timeout_server": "30m", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/windows-admin-center.json b/static/recipes/windows-admin-center.json new file mode 100644 index 0000000..0f9b702 --- /dev/null +++ b/static/recipes/windows-admin-center.json @@ -0,0 +1,19 @@ +{ + "name": "Windows Admin Center", + "category": "Infrastructure", + "summary": "HTTPS 443 passed on to the gateway's self-signed certificate.", + "notes": "For a gateway installed on Windows Server, which listens on 443; a desktop installation uses 6516, or 6600 in recent versions. The certificate is the gateway's own and is not verified. PowerShell, Remote Desktop and Events run over WebSockets, and the gateway refuses a WebSocket whose origin is not a name on its own certificate: add the name published here to its WebSocketValidationOverride registry value.", + "fields": { + "url": "https://wac.example.com", + "target": "wac1=https://10.0.0.1:443", + "balance": "source", + "persistence": "source", + "stick_type": "ip", + "health": "tcp", + "health_interval": "10s", + "timeout_server": "4h", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/zammad.json b/static/recipes/zammad.json new file mode 100644 index 0000000..dcfc4fb --- /dev/null +++ b/static/recipes/zammad.json @@ -0,0 +1,20 @@ +{ + "name": "Zammad", + "category": "Applications", + "summary": "HTTP 8080 for the bundled nginx, with its WebSockets.", + "notes": "The target is the zammad-nginx container of the Docker deployment, which already splits the application and its WebSocket server; a package install has the same nginx on 80. Set NGINX_SERVER_SCHEME=https there, or the session cookie is not marked secure and signing in fails with a CSRF error. The health check Zammad offers needs a token, so this one only asks for the front page.", + "fields": { + "url": "https://support.example.com", + "target": "10.0.0.10:8080", + "balance": "roundrobin", + "persistence": "none", + "health": "http", + "health_uri": "/", + "health_status": "", + "health_interval": "10s", + "timeout_server": "1h", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/static/recipes/zulip.json b/static/recipes/zulip.json new file mode 100644 index 0000000..aad0bbc --- /dev/null +++ b/static/recipes/zulip.json @@ -0,0 +1,20 @@ +{ + "name": "Zulip", + "category": "Applications", + "summary": "HTTPS 443 re-encrypted to Zulip and checked with /health.", + "notes": "A Zulip server answers on 443 with its own certificate, which is not verified here. List this node under [loadbalancer] ips in /etc/zulip/zulip.conf: that is what makes Zulip believe the forwarded address, and it is also the only way /health (Zulip 8.0 and later) answers anything but localhost. Zulip delivers messages by long polling, so the timeout has to stay well above a minute; its documentation uses twenty.", + "fields": { + "url": "https://chat.example.com", + "target": "zulip1=https://10.0.0.10:443", + "balance": "roundrobin", + "persistence": "none", + "health": "http", + "health_uri": "/health", + "health_status": "200", + "health_interval": "10s", + "timeout_server": "20m", + "timeout_connect": "5s", + "cert_mode": "auto", + "http_redirect": true + } +} diff --git a/tools/test-recipes.py b/tools/test-recipes.py index 4b4e34f..eb510eb 100644 --- a/tools/test-recipes.py +++ b/tools/test-recipes.py @@ -92,6 +92,13 @@ def has(rid, *needles): ("minio", "/minio/health/live"), ("kubernetes-api", "check"), ("web-websockets", "timeout server 1h"), + # a TCP service checked over HTTP on another port, and an https:// target + # whose HTTP check has to travel over TLS as well + ("adfs", "mode tcp", "option httpchk", "/adfs/probe", "port 80"), + ("nats", "mode tcp", "/healthz", "port 8222"), + ("emqx", "mode tcp", "/status", "port 18083"), + ("exchange", "/owa/healthcheck.htm", "ssl verify none"), + ("wazuh-dashboard", "/app/login", "ssl verify none"), ] for rid, *needles in checks: print(" %-22s %s" % (rid, "ok" if has(rid, *needles) else "MISSING")) From 9da702e8312a2ab28a595203a34329081f07f431 Mon Sep 17 00:00:00 2001 From: Alex Van de Putte Date: Tue, 10 Nov 2026 20:02:44 -0500 Subject: [PATCH 12/13] 2.2.0-beta.6: 36 more recipes, no EAB rows for Let's Encrypt --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index ec3d72f..d01c387 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.2.0-beta.5 +2.2.0-beta.6 From 78cee1fa43a4d7523d3a5fabde9af150ef56648c Mon Sep 17 00:00:00 2001 From: Alex Van de Putte Date: Wed, 11 Nov 2026 16:51:57 -0500 Subject: [PATCH 13/13] 2.2.0: certificates from your own CA, Active Directory Certificate Services, DNS alias mode, 36 more recipes --- README.md | 2 +- VERSION | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 197df04..92deb26 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ copying it across. ```bash # from a package: .deb and .rpm on every release (Debian, Ubuntu, RHEL, Fedora) -sudo apt-get install -y ./haproxy-manager_2.1.1_all.deb +sudo apt-get install -y ./haproxy-manager_2.2.0_all.deb # or the install script, on any Debian-based server curl -fsSL https://raw.githubusercontent.com/avandeputte/haproxy-manager/main/install.sh | sudo bash diff --git a/VERSION b/VERSION index d01c387..ccbccc3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.2.0-beta.6 +2.2.0