From d6904f387df4306a0a2ea938e2b20f50630ba524 Mon Sep 17 00:00:00 2001 From: Lance oreste Date: Thu, 13 Aug 2026 10:05:15 -0400 Subject: [PATCH 1/4] =?UTF-8?q?docs:=20verify=20mako=200.5.2=20=E2=80=94?= =?UTF-8?q?=20c=20backend=20green;=20native=20still=20crashes=20(mako#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-validated the native cutover on mako 0.5.2 (which ships the #31 fix, f638e64): the c backend passes make test-full, but --backend native still SIGSEGVs right after config_load — doctor_world string clone with a wild slot address on the minimal conf, and mako_native_struct_slice_clone_ptr on []Route with the full config. Reported upstream as mako#32. Production default stays --backend c; docs/Makefile updated accordingly. --- Makefile | 7 ++++--- docs/MAKO.md | 38 +++++++++++++++++++------------------- docs/PRODUCTION.md | 4 ++-- 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/Makefile b/Makefile index 72f161d..d05fc16 100644 --- a/Makefile +++ b/Makefile @@ -4,9 +4,10 @@ # with multi-module native IR compile support (mako#29, main ≥ 24f36a6). # Install: https://github.com/loreste/mako (main or a release that includes #29). # -# Backend: default remains **c** for CI/tests (native builds after #29 but -# SIGSEGV in doctor_world / string clone — mako#31). Use `make build-native` -# only to validate compile until https://github.com/loreste/mako/issues/31. +# Backend: default remains **c** for CI/tests. Native compiles (mako#29) but +# still SIGSEGVs at runtime on Mako 0.5.2 (doctor_world string clone / +# struct-slice clone — mako#32, see docs/MAKO.md). Use `make build-native` +# only to re-validate native after a Mako upgrade. # # After upgrading Mako: `make clean-cache` then rebuild (object cache is not # versioned across compiler revisions). diff --git a/docs/MAKO.md b/docs/MAKO.md index 3382d3b..77f147a 100644 --- a/docs/MAKO.md +++ b/docs/MAKO.md @@ -7,19 +7,14 @@ Leba is written in [Mako](https://github.com/loreste/mako) and is built to use | Item | Value | |------|--------| -| Mako | **≥ 0.5.1** tip with [#29](https://github.com/loreste/mako/issues/29) for native *compile* | -| Backend (production) | **`c`** until [#31](https://github.com/loreste/mako/issues/31) is fixed | +| Mako | **≥ 0.5.1** (verified on **0.5.2**) | +| Backend (production) | **`c`** — native compiles ([#29](https://github.com/loreste/mako/issues/29)) but still crashes at runtime on 0.5.2 (see below) | | Default build | **`--release`** (`-O3 -flto`) | | Allocator | **mimalloc** when present (`MAKO_ALLOCATOR`) | ```bash # Install Mako (macOS/Linux) curl -fsSL https://github.com/loreste/mako/releases/latest/download/install-release.sh | bash -# For native experiments, use a source checkout at/after 24f36a6: -# cargo build --release -p mako -# export MAKO=$PWD/target/release/mako -# export MAKO_RUNTIME=$PWD/runtime -# export MAKO_STD=$PWD/std mako doctor make check-mako @@ -43,21 +38,26 @@ make test-full | `MAKO_ALLOCATOR` (0.4.11+) | Auto-link static mimalloc when available | | `sched_set_workers` | Crew pool sized `2×workers+8` | | HTTP / TLS / H2 / pools | Cleartext fast path + TLS/H2/H3 surfaces | -| Native multi-module compile (#29) | **Builds** with tip Mako; **not** production default yet | +| Native multi-module compile (#29) | **Builds** on 0.5.2; **not** production default yet (runtime crash, see below) | ## Native backend status +Re-validated on **Mako 0.5.2** (contains the #31 fix, `f638e64`): native +**still crashes**. The original moved-from-slot bug is fixed, but Leba hits a +follow-on native fault in the same doctor/validation path. + | Stage | Status | |-------|--------| -| Compile `main.mko --backend native` | **OK** on Mako `main` ≥ `24f36a6` ([#29 closed](https://github.com/loreste/mako/issues/29)) | -| Run / unit tests / concurrent smoke | **Crash** — `SIGSEGV` in `doctor_world` → `mako_native_string_clone_ptr` ([#31 open](https://github.com/loreste/mako/issues/31)) | -| Production default | **`--backend c`** until #31 fixed and full test matrix green | +| Compile `main.mko --backend native` | **OK** on 0.5.2 ([#29](https://github.com/loreste/mako/issues/29), [#31](https://github.com/loreste/mako/issues/31) closed) | +| Run minimal conf (`frontend web` + `route default -> app`) | **Crash** — `SIGSEGV` in `doctor_world` → `mako_native_string_clone_ptr`; faulting slot address (`x23`) is a wild non-heap value | +| Run full `configs/leba.conf` | **Crash** — `SIGSEGV` in `mako_native_struct_slice_clone_ptr` cloning `[]Route` (10 fields, str_mask=191) | +| Unit tests (`leba_*_test.mko --backend native`) | **Crash** — SIGSEGV / SIGABRT | +| Production default | **`--backend c`** until native survives `make test-full` + concurrent smoke | ```bash -# Experimental native (expect crash after config_load until #31): -export MAKO_RUNTIME=/path/to/mako/runtime +# Experimental native (still crashes on 0.5.2, right after config_load): mako build main.mko -o leba-native --backend native --release -./leba-native -f configs/leba.conf # SIGSEGV today +./leba-native doctor configs/leba.conf # SIGSEGV # Production: make build @@ -81,7 +81,8 @@ brew install mimalloc # enable auto static link ## CI `.github/workflows/ci.yml` clones Mako `main` and builds with `MAKO_BACKEND=c` -(default). Flip to native only after #31 and CI matrix are green. +(default). Flip to native only after the native runtime crash (see above) is +fixed and the CI matrix is green on native. ## Debug / sanitizers @@ -95,13 +96,12 @@ mako build main.mko -o leba --backend c --sanitize address | Issue | Status | Topic | |-------|--------|--------| | [mako#29](https://github.com/loreste/mako/issues/29) | **Closed** | Compile: multi-module IR, builtins, honest diagnostics | -| [mako#31](https://github.com/loreste/mako/issues/31) | **Open** | Runtime: Leba SIGSEGV in `doctor_world` / string clone | +| [mako#31](https://github.com/loreste/mako/issues/31) | **Closed** (`f638e64`, shipped in 0.5.2) | Runtime: moved-from slot use-after-free | +| [mako#32](https://github.com/loreste/mako/issues/32) | **Open** | Runtime: Leba still SIGSEGVs on native in 0.5.2 — `doctor_world` string clone (wild slot address) / `[]Route` struct-slice clone | -When #31 lands: +When native survives the full gate on a future Mako release: ```bash -export MAKO=/path/to/mako/target/release/mako -export MAKO_RUNTIME=/path/to/mako/runtime make clean-cache MAKO_BACKEND=native make build make test-full && make test-concurrent diff --git a/docs/PRODUCTION.md b/docs/PRODUCTION.md index 6b58c30..0b03559 100644 --- a/docs/PRODUCTION.md +++ b/docs/PRODUCTION.md @@ -6,8 +6,8 @@ Use this checklist before calling Leba **production-ready** for a site. | Item | Check | |------|--------| -| Mako | **≥ 0.5.1** with multi-module native compile fixes (main ≥ `24f36a6` / mako#29); see [MAKO.md](MAKO.md) | -| Build | `make build` → **release** binary on **c** by default (native compile works; tests still C); mimalloc when available | +| Mako | **≥ 0.5.1** (verified on 0.5.2); see [MAKO.md](MAKO.md) | +| Build | `make build` → **release** binary on **c** by default (native compiles but still crashes at runtime on 0.5.2); mimalloc when available | | Version | `leba version` ≥ 0.15.0 | | Doctor | `leba doctor /etc/leba/leba.conf` → 0 errors | | Auth | Hashed admin users; no demo passwords | From 69301b670e6645fd5326e20ffd25c32322a4fd61 Mon Sep 17 00:00:00 2001 From: Lance oreste Date: Mon, 17 Aug 2026 16:09:09 -0400 Subject: [PATCH 2/4] feat: add native ACME HTTP-01 --- Makefile | 4 +- README.md | 8 +- deploy/docker/leba.conf | 4 +- deploy/docker/leba.demo.conf | 4 +- deploy/linux/leba-acme-renew.service | 2 +- deploy/linux/leba-acme-renew.timer | 2 +- deploy/linux/leba.conf | 12 +- deploy/linux/leba.env | 4 +- docker-compose.yml | 39 +-- docs/ACCESS_CONTROL.md | 2 +- docs/ACME.md | 150 ++++------- docs/ADMIN_API.md | 8 +- docs/HOSTS.md | 4 +- docs/PAINPOINTS.md | 2 +- docs/PRODUCTION.md | 6 +- docs/ROADMAP.md | 355 ++++++------------------- leba_web_test.mko | 33 +++ main.mko | 3 +- scripts/adversarial_smoke.sh | 3 +- src/acme.mko | 382 ++++++++++++++++++++++++--- src/admin.mko | 4 +- src/config.mko | 2 +- src/doctor.mko | 15 +- src/types.mko | 6 +- src/webadmin.mko | 8 +- 25 files changed, 563 insertions(+), 499 deletions(-) diff --git a/Makefile b/Makefile index d05fc16..ac789c0 100644 --- a/Makefile +++ b/Makefile @@ -107,7 +107,7 @@ test-linux-assets: grep -q 'state_file /var/lib/leba/state' deploy/linux/leba.conf grep -q 'admin_users_file /etc/leba/admin-users.conf' deploy/linux/leba.conf grep -q 'acme_webroot /var/lib/leba/acme' deploy/linux/leba.conf - grep -q 'acme_storage /var/lib/leba/lego' deploy/linux/leba.conf + grep -q 'acme_storage /var/lib/leba/acme-state' deploy/linux/leba.conf grep -q 'acme_email' deploy/linux/leba.conf test -f deploy/linux/leba-acme-renew.timer test -f deploy/linux/leba-acme-renew.service @@ -137,7 +137,7 @@ test-docs: test-adversarial: test test-linux-assets chmod +x scripts/adversarial_smoke.sh - ./scripts/adversarial_smoke.sh + MAKO_BIN="$(MAKO)" ./scripts/adversarial_smoke.sh test-concurrent: build chmod +x scripts/concurrent_smoke.sh diff --git a/README.md b/README.md index 3711e2a..59fa4a8 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ wildcard CORS without credentials. - IP allowlist/blocklist via `src` ACL rules (access lists) - Application HTTP Basic (`auth_basic` + `auth_user` on frontends) - WAF adapter: local signatures + optional remote inspect sidecar -- **Let's Encrypt** via lego (HTTP-01 / DNS-01, production + staging directories, live SNI reload) +- **Let's Encrypt** via native ACME (HTTP-01, production + staging directories, live SNI reload; legacy DNS-01 helper compatibility) - Per-frontend and per-client-IP rate limiting (token bucket) - Request body size limits - Directory traversal prevention for static file serving @@ -98,7 +98,7 @@ wildcard CORS without credentials. - Config viewer with sensitive field redaction - REST API for drain, ready, disable, enable, reload - Vhost and proxy host management API -- Certificates API (`/admin/certificates`, issue/renew via lego HTTP-01 or DNS-01) +- Certificates API (`/admin/certificates`, native issue/renew via HTTP-01; legacy DNS-01 helper compatibility) - Access lists + app HTTP Basic API (`/admin/access-list*`, `/admin/http-auth*`) - Host parity: enable/disable, WebSocket toggle, locations, redirect/dead, host IP ACL, host Basic - Config doctor with validation and fix suggestions @@ -281,7 +281,7 @@ Linux packaging sketch: [`deploy/linux/`](deploy/linux/) · HA keepalived: [`dep Leba is working software with 170+ automated unit tests, concurrent/adversarial/soak harnesses, and dual-node peers smoke (**v0.15.0**). It handles HTTP/1–3, TCP, UDP/SIP, WebSocket, TLS/mTLS, -stick tables, WAF adapter, and an NPM-style control plane (proxy hosts, lego ACME, +stick tables, WAF adapter, and an NPM-style control plane (proxy hosts, native ACME, access lists) on a HAProxy-class data plane. **Roadmap:** [`docs/ROADMAP.md`](docs/ROADMAP.md) — release plan and beat criteria @@ -294,7 +294,7 @@ Known limits: (`h3_strategy=recreate` on `POST /admin/tls-reload`). - SIP support is signaling-focused; media relay is not implemented. - Full config reload with HTTP/TCP/UDP/H3/stats/peers rebind and live OIDC/peers apply (`SIGHUP` / `POST /admin/reload`). -- ACME is lego-orchestrated (not in-process JOSE); see [`docs/ACME.md`](docs/ACME.md). +- ACME is native Mako ACME; see [`docs/ACME.md`](docs/ACME.md). - No response compression (gzip/brotli) or response caching yet. - Stick-table peers: dual-node smoke + ownership fixes shipped; treat as **production** only after your VIP multi-hour soak (see [`docs/HA.md`](docs/HA.md)). diff --git a/deploy/docker/leba.conf b/deploy/docker/leba.conf index 317581c..812b6ae 100644 --- a/deploy/docker/leba.conf +++ b/deploy/docker/leba.conf @@ -9,9 +9,9 @@ defaults state_file /var/lib/leba/state state_key ${LEBA_SESSION_SECRET} acme_webroot /var/lib/leba/acme - acme_storage /var/lib/leba/lego + acme_storage /var/lib/leba/acme-state acme_email ${LEBA_ACME_EMAIL} - acme_helper lego + acme_helper native frontend web bind 8080 diff --git a/deploy/docker/leba.demo.conf b/deploy/docker/leba.demo.conf index 9e4a495..f1951f2 100644 --- a/deploy/docker/leba.demo.conf +++ b/deploy/docker/leba.demo.conf @@ -10,9 +10,9 @@ defaults state_file /var/lib/leba/state state_key ${LEBA_SESSION_SECRET} acme_webroot /var/lib/leba/acme - acme_storage /var/lib/leba/lego + acme_storage /var/lib/leba/acme-state acme_email ${LEBA_ACME_EMAIL} - acme_helper lego + acme_helper native frontend web bind 8080 diff --git a/deploy/linux/leba-acme-renew.service b/deploy/linux/leba-acme-renew.service index ed4457b..fe040ba 100644 --- a/deploy/linux/leba-acme-renew.service +++ b/deploy/linux/leba-acme-renew.service @@ -1,5 +1,5 @@ [Unit] -Description=Leba ACME certificate renew (lego via admin API) +Description=Leba ACME certificate renew (native ACME via admin API) After=network-online.target leba.service Wants=network-online.target diff --git a/deploy/linux/leba-acme-renew.timer b/deploy/linux/leba-acme-renew.timer index 727469c..bd91a64 100644 --- a/deploy/linux/leba-acme-renew.timer +++ b/deploy/linux/leba-acme-renew.timer @@ -1,5 +1,5 @@ [Unit] -Description=Daily Leba ACME renew (Let’s Encrypt via lego) +Description=Daily Leba ACME renew (native ACME) Requires=leba-acme-renew.service [Timer] diff --git a/deploy/linux/leba.conf b/deploy/linux/leba.conf index aa421c6..7a030c2 100644 --- a/deploy/linux/leba.conf +++ b/deploy/linux/leba.conf @@ -12,11 +12,11 @@ defaults maxconn 100000 retries 2 workers 32 - # Let's Encrypt (lego ACME client). Set email before issuing certs. + # Let's Encrypt (native ACME client). Set email before issuing certs. acme_email CHANGE_ME@example.com acme_webroot /var/lib/leba/acme - acme_storage /var/lib/leba/lego - acme_helper lego + acme_storage /var/lib/leba/acme-state + acme_helper native # acme_staging on # test against LE staging first (untrusted certs) # Public HTTP edge. CAP_NET_BIND_SERVICE for :80. Serves ACME HTTP-01 here. @@ -87,13 +87,13 @@ backend static # TLS / Let's Encrypt: # After admin Proxy Host + Request SSL (or POST /admin/certificates/issue), -# PEMs land under /var/lib/leba/lego/certificates/ and SNI is live-reloaded. +# PEMs land under /var/lib/leba/acme-state/certificates/ and SNI is live-reloaded. # Optional dedicated 443 listener (or put tls_* on frontend web bind 443): # frontend secure # bind 443 # mode http -# tls_cert /var/lib/leba/lego/certificates/app.example.com.crt -# tls_key /var/lib/leba/lego/certificates/app.example.com.key +# tls_cert /var/lib/leba/acme-state/certificates/app.example.com.crt +# tls_key /var/lib/leba/acme-state/certificates/app.example.com.key # protocols http/1.1,h2,h3 # route default -> web # diff --git a/deploy/linux/leba.env b/deploy/linux/leba.env index e06c8cd..7c2cf5a 100644 --- a/deploy/linux/leba.env +++ b/deploy/linux/leba.env @@ -5,9 +5,9 @@ LEBA_ADMIN_ADDR=127.0.0.1:18404 LEBA_ADMIN_AUTH=CHANGE_ME_ADMIN:CHANGE_ME_PASSWORD # Admin API base for leba-acme-renew.timer (daily certificate renew). LEBA_ADMIN_URL=http://127.0.0.1:18404 -# Let's Encrypt (lego ACME client). Also set defaults acme_email in leba.conf. +# Let's Encrypt (native ACME client). Also set defaults acme_email in leba.conf. # LEBA_ACME_EMAIL=ops@example.com -# LEBA_ACME_STORAGE=/var/lib/leba/lego +# LEBA_ACME_STORAGE=/var/lib/leba/acme-state # LEBA_ACME_WEBROOT=/var/lib/leba/acme # LEBA_ACME_STAGING=1 # use LE staging directory (testing) # LEBA_ACME_SERVER= # or full https://…/directory URL diff --git a/docker-compose.yml b/docker-compose.yml index d54976b..f43b478 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,3 @@ -# Leba — one-command NPM-style demo. -# -# # From source (builds local image): -# make build && docker compose up --build -# -# # Or published release image (no local Mako needed): -# LEBA_IMAGE=ghcr.io/loreste/leba:0.15.0 docker compose up -# -# open http://localhost:8404/ (admin / change-me) -# curl http://localhost/ → demo origin via Leba -# -# ACME: set LEBA_ACME_EMAIL and publish port 80 for HTTP-01. services: origin: image: hashicorp/http-echo:1.0 @@ -30,38 +18,19 @@ services: LEBA_ADMIN_AUTH: "${LEBA_ADMIN_AUTH:-admin:change-me}" LEBA_SESSION_SECRET: "${LEBA_SESSION_SECRET:-change-me-session-secret-use-long}" LEBA_ACME_EMAIL: "${LEBA_ACME_EMAIL:-}" - LEBA_ACME_STORAGE: /var/lib/leba/lego + LEBA_ACME_STORAGE: /var/lib/leba/acme-state LEBA_ACME_WEBROOT: /var/lib/leba/acme - LEBA_ACME_HELPER: lego + LEBA_ACME_HELPER: native volumes: - ./certs:/etc/leba/certs - leba-state:/var/lib/leba - leba-logs:/var/log/leba - leba-acme:/var/lib/leba/acme - - leba-lego:/var/lib/leba/lego - # Demo config proxies to the origin service. + - leba-acme-state:/var/lib/leba/acme-state command: ["/usr/local/bin/leba", "-f", "/etc/leba/leba.demo.conf"] - lego: - image: goacme/lego:latest - profiles: ["acme"] - network_mode: "service:leba" - volumes: - - leba-lego:/lego - - leba-acme:/acme - - ./deploy/docker/lego-deploy-hook.sh:/hooks/deploy.sh:ro - environment: - LEBA_ADMIN_AUTH: "${LEBA_ADMIN_AUTH:-admin:change-me}" - LEBA_ADMIN_URL: "http://127.0.0.1:8404" - entrypoint: - [ - "/bin/sh", - "-c", - "echo 'lego profile ready — use Admin UI Certificates or: lego --email $$LEBA_ACME_EMAIL --http --http.webroot /acme --path /lego --domains HOST run'; sleep infinity", - ] - volumes: leba-state: leba-logs: leba-acme: - leba-lego: + leba-acme-state: diff --git a/docs/ACCESS_CONTROL.md b/docs/ACCESS_CONTROL.md index de89258..73e8d6e 100644 --- a/docs/ACCESS_CONTROL.md +++ b/docs/ACCESS_CONTROL.md @@ -61,7 +61,7 @@ Stats frontend uses `admin_users_file` / session cookies / RBAC. Do not reuse | Access List (IP) | `allow` / `deny` `src` ACLs — also **Access Lists** UI / `/admin/access-list` | | Access List (HTTP Basic) | `auth_basic` + `auth_user` — also **Access Lists** UI / `/admin/http-auth*` | | Proxy host | `POST /admin/proxy-host` or `route host` | -| SSL Certificates | **Certificates** UI / `/admin/certificates*` + lego | +| SSL Certificates | **Certificates** UI / `/admin/certificates*` native ACME | | Force SSL | `force_ssl=1` on proxy-host or `redirect https` | Managed files written by the admin UI: diff --git a/docs/ACME.md b/docs/ACME.md index 83e459c..45b9085 100644 --- a/docs/ACME.md +++ b/docs/ACME.md @@ -1,23 +1,23 @@ -# Let's Encrypt (ACME) with Leba +# Native ACME / Free TLS -Leba integrates **Let's Encrypt** through the [lego](https://go-acme.github.io/lego/) -ACME client. Issue and renew hit the official ACME v2 directories, attach PEMs as -SNI certificates, and live-reload TLS — no process restart. +Leba issues and renews certificates with a native Mako ACME v2 client. The default provider is Let's Encrypt production; staging and custom ACME directory URLs are supported. + +## Providers | Mode | Directory | |------|-----------| -| **Production** (default) | `https://acme-v02.api.letsencrypt.org/directory` | -| **Staging** (rate-limit safe) | `https://acme-staging-v02.api.letsencrypt.org/directory` | +| Let's Encrypt production | `https://acme-v02.api.letsencrypt.org/directory` | +| Let's Encrypt staging | `https://acme-staging-v02.api.letsencrypt.org/directory` | +| Custom ACME CA | Set `acme_server` or `LEBA_ACME_SERVER` | -Staging certs are **not trusted by browsers** — use them to validate HTTP-01 / -DNS-01 before production. +The native client uses Mako HTTPS, ES256 JWS, a generated P-256 account key, HTTP-01 challenge files, CSR creation, certificate download, SNI attach, and live TLS reload. No nginx, HAProxy, certbot, or lego process is required for HTTP-01. ## Requirements -1. **Email** for LE registration: `acme_email` or `LEBA_ACME_EMAIL` -2. **lego** on PATH (Docker image includes it) or `LEBA_ACME_HELPER=/path/to/lego` -3. **HTTP-01**: public port 80 + `acme_webroot` served by Leba - **or DNS-01**: `dns_provider` + provider env (e.g. `CF_DNS_API_TOKEN`) +1. `acme_email` or `LEBA_ACME_EMAIL` for ACME account registration. +2. Public port 80 for HTTP-01 validation. +3. `acme_webroot` on the HTTP frontend serving `/.well-known/acme-challenge/*`. +4. `acme_storage` writable by the Leba process. ## Config @@ -25,146 +25,96 @@ DNS-01 before production. defaults acme_email ops@example.com acme_webroot /var/lib/leba/acme - acme_storage /var/lib/leba/lego - acme_helper lego - # Optional: - # acme_staging on # Let's Encrypt staging - # acme_server staging # alias: production | staging | full https URL + acme_storage /var/lib/leba/acme-state + acme_helper native + # acme_staging on + # acme_server staging frontend web bind 80 mode http acme_webroot /var/lib/leba/acme - # optional: redirect https (or per-host force_ssl) route default -> app ``` -### Environment +## Environment | Variable | Meaning | |----------|---------| -| `LEBA_ACME_EMAIL` | Registration email (required to issue) | +| `LEBA_ACME_EMAIL` | Registration email | | `LEBA_ACME_WEBROOT` | HTTP-01 token directory | -| `LEBA_ACME_STORAGE` | lego account + cert storage (`--path`) | -| `LEBA_ACME_HELPER` | lego binary (default `lego`) | -| `LEBA_ACME_STAGING=1` | Use LE staging directory | +| `LEBA_ACME_STORAGE` | Native account/certificate storage | +| `LEBA_ACME_HELPER` | `native` by default; external helper is legacy compatibility | +| `LEBA_ACME_STAGING=1` | Use Let's Encrypt staging | | `LEBA_ACME_SERVER` | Full ACME directory URL, or `staging` / `letsencrypt` | -| `LEBA_ACME_DNS_PROVIDER` | Default DNS-01 provider name | -## NPM-style: host + cert in one call +## One-Call Host + Cert ```bash -# Production Let's Encrypt + Force SSL + SNI attach + live reload -curl -u admin:secret -X POST \ - 'http://127.0.0.1:8404/admin/proxy-host?frontend=web&domain=app.example.com&backend=app&server=s1&addr=127.0.0.1:3000&ssl=1&force_ssl=1' - -# Staging first (no rate limits) -curl -u admin:secret -X POST \ - 'http://127.0.0.1:8404/admin/certificates/issue?domain=app.example.com&frontend=web&staging=1&attach=1' +curl -u admin:secret -X POST 'http://127.0.0.1:8404/admin/proxy-host?frontend=web&domain=app.example.com&backend=app&server=s1&addr=127.0.0.1:3000&ssl=1&force_ssl=1' ``` -Admin UI: - -- **Proxy Hosts → + Add** with **Request SSL** -- **Certificates** (Let's Encrypt) tab: inventory, staging checkbox, issue, renew +This creates/updates the proxy host, issues or reuses the certificate, attaches it as SNI, and marks the host as Force SSL. ## API ```text GET /admin/certificates -POST /admin/certificates/issue?domain=&frontend=&email=&attach=1&staging=0|1&server=&challenge=http|dns&dns_provider= +POST /admin/certificates/issue?domain=&frontend=&email=&attach=1&staging=0|1&server=&challenge=http POST /admin/certificates/renew ``` -`GET /admin/certificates` includes: - -```json -"settings": { - "provider": "letsencrypt", - "ca": "letsencrypt", - "server": "https://acme-v02.api.letsencrypt.org/directory", - "staging": false, - "ready": true, - "issues": [] -} -``` - -Issued PEMs: +Issued PEMs are stored as: ```text +{acme_storage}/accounts/account-p256.key +{acme_storage}/accounts/account.url {acme_storage}/certificates/{domain}.crt {acme_storage}/certificates/{domain}.key ``` -## HTTP-01 challenge serving - -Leba serves: +## HTTP-01 ```text GET /.well-known/acme-challenge/ - → file {acme_webroot}/ + -> {acme_webroot}/ ``` -This path **bypasses** HTTPS redirect (including per-host `force_ssl`), rate limits, -and ACLs so Let's Encrypt can complete validation on port 80. +Challenge paths bypass HTTPS redirect, ACLs, rate limiting, and app Basic auth so the ACME CA can validate the domain. ## Renew -Daily systemd timer (Linux package): - -```bash -systemctl enable --now leba-acme-renew.timer -# uses LEBA_ADMIN_AUTH + LEBA_ADMIN_URL from /etc/leba/leba.env -``` - -Manual: +Native renew re-issues the certificates already present under `{acme_storage}/certificates` and triggers TLS reload through the admin path. ```bash curl -u admin:secret -X POST http://127.0.0.1:8404/admin/certificates/renew -# then TLS reload is triggered when the API returns tls_reload:true ``` ## DNS-01 -```bash -export CF_DNS_API_TOKEN=… -curl -u admin:secret -X POST \ - 'http://127.0.0.1:8404/admin/certificates/issue?domain=*.example.com&frontend=web&challenge=dns&dns_provider=cloudflare&attach=1' -``` +Native DNS-01 provider adapters are intentionally not enabled yet. For wildcard certificates, either use HTTP-01 on concrete hostnames or configure a legacy external helper explicitly while native DNS adapters are added. -## Preflight errors +## Testing -| Code | Meaning | -|------|---------| -| `missing_helper` | Install lego or set `LEBA_ACME_HELPER` | -| `missing_email` | Set `acme_email` / `LEBA_ACME_EMAIL` | -| `invalid_domain` | Domain failed safety validation | -| `invalid_webroot` / `invalid_storage` | Path empty or unsafe | -| `missing_dns_provider` | DNS-01 without provider | -| `no_certs` | Renew with empty storage | -| `lego_failed` | Helper ran but PEMs missing | - -## Manual lego (same directories) +Local gates validate the native ACME plumbing without contacting a live CA: ```bash -# Production -lego --accept-tos --email ops@example.com \ - --server https://acme-v02.api.letsencrypt.org/directory \ - --http --http.webroot /var/lib/leba/acme \ - --path /var/lib/leba/lego --domains app.example.com run - -curl -u operator:secret -X POST http://127.0.0.1:8404/admin/tls-reload +make doctor +make test-full +mako test leba_web_test.mko --backend c ``` -Sample hook: `deploy/docker/lego-deploy-hook.sh`. - -## Docker +`leba_web_test.mko` covers native account helpers, JWS construction, and CSR DER/base64url encoding. A real Let's Encrypt staging issuance additionally requires a public DNS name pointing at Leba and public port 80 reachability for HTTP-01. -```bash -LEBA_ADMIN_AUTH=admin:change-me LEBA_SESSION_SECRET=long-secret \ - LEBA_ACME_EMAIL=ops@example.com \ - docker compose up -``` +## Preflight Errors -The image installs **lego** so Admin UI issue works when port 80 is reachable -for HTTP-01. +| Code | Meaning | +|------|---------| +| `missing_email` | Set `acme_email` or `LEBA_ACME_EMAIL` | +| `invalid_domain` | Domain failed safety validation | +| `invalid_webroot` / `invalid_storage` | Path empty or unsafe | +| `account_key_failed` | Could not generate/read native P-256 account key | +| `directory_failed` / `directory_invalid` | ACME directory fetch failed or lacked required endpoints | +| `nonce_failed` | ACME server did not return `Replay-Nonce` | +| `challenge_not_valid` | HTTP-01 did not validate before timeout | +| `certificate_download_failed` | ACME order completed but certificate download failed | diff --git a/docs/ADMIN_API.md b/docs/ADMIN_API.md index 9c83e90..f891d40 100644 --- a/docs/ADMIN_API.md +++ b/docs/ADMIN_API.md @@ -273,22 +273,22 @@ Removes a host route. Query parameters: `frontend`, `domain`. ### `GET /admin/certificates` Lists frontend TLS material, SNI entries, and PEMs discovered under ACME storage. -Includes helper settings (`webroot`, `storage`, `email`, `helper`, `helper_available`). +Includes ACME settings (`webroot`, `storage`, `email`, `helper`, `helper_available`; `helper` defaults to `native`). Role: **viewer**. ### `POST /admin/certificates/issue` -Runs external **lego** (HTTP-01 webroot), attaches cert as SNI for `domain` on +Runs native ACME HTTP-01, attaches cert as SNI for `domain` on `frontend` (unless `attach=0`), persists managed vhosts, and triggers live TLS reload. Query: `domain`, `frontend`, optional `email`, `attach`. Role: **admin**. -Requires `lego` on `PATH` (or `LEBA_ACME_HELPER` / `acme_helper`), plus +Requires native ACME support plus `LEBA_ACME_EMAIL` or `acme_email` / query `email`. ### `POST /admin/certificates/renew` -Runs `lego renew` on ACME storage and triggers TLS reload. Role: **admin**. +Runs native ACME renew on managed storage and triggers TLS reload. Role: **admin**. See `docs/ACME.md`. diff --git a/docs/HOSTS.md b/docs/HOSTS.md index d33eac9..67c93bc 100644 --- a/docs/HOSTS.md +++ b/docs/HOSTS.md @@ -77,14 +77,14 @@ curl -u admin:secret -X POST \ | Query | Effect | |-------|--------| -| `ssl=1` / `request_ssl=1` / `auto_ssl=1` | Reuse PEMs under `acme_storage` or run lego HTTP-01, attach SNI | +| `ssl=1` / `request_ssl=1` / `auto_ssl=1` | Reuse PEMs under `acme_storage` or run native HTTP-01, attach SNI | | `force_ssl=1` | Per-host HTTP→HTTPS (does **not** force every other host) | | `cert=` + `key=` | Use absolute PEM paths instead of ACME | UI: **Proxy Hosts → + Add** (Request SSL / Force SSL checked by default), or **Request SSL** on a host card. - HTTP-01: `challenge=http` (default) via `POST /admin/certificates/issue` -- DNS-01: `challenge=dns&dns_provider=cloudflare` (+ lego env e.g. `CF_DNS_API_TOKEN`) +- DNS-01: `challenge=dns&dns_provider=cloudflare` (legacy helper compatibility; provider env e.g. `CF_DNS_API_TOKEN`) or `LEBA_ACME_DNS_PROVIDER` Daily renew timer: `deploy/linux/leba-acme-renew.timer` + `.service`. diff --git a/docs/PAINPOINTS.md b/docs/PAINPOINTS.md index 7a14591..c0353d8 100644 --- a/docs/PAINPOINTS.md +++ b/docs/PAINPOINTS.md @@ -109,7 +109,7 @@ configured runtime state has eligible upstream capacity. - [x] Certificates + Access Lists admin UI (NPM control plane). - [x] Access list + app HTTP Basic CRUD API (`leba.access.conf`). - [x] App HTTP Basic (`auth_basic` / `auth_user`). -- [x] ACME orchestrator via external lego (Admin UI issue/renew + `/admin/certificates/*`). +- [x] Native ACME orchestrator (Admin UI issue/renew + `/admin/certificates/*`). - [x] Let's Encrypt official directories (production + staging) + Request SSL on proxy-host. - [x] Config reload + HTTP/TCP/UDP/H3/stats/peers rebind + OIDC/peers apply (`SIGHUP` / `POST /admin/reload`). - [x] Redirect hosts + dead hosts (`route … redirect|dead`). diff --git a/docs/PRODUCTION.md b/docs/PRODUCTION.md index 0b03559..44cc4dc 100644 --- a/docs/PRODUCTION.md +++ b/docs/PRODUCTION.md @@ -13,7 +13,7 @@ Use this checklist before calling Leba **production-ready** for a site. | Auth | Hashed admin users; no demo passwords | | Session | `state_key` or `LEBA_SESSION_SECRET` set | | Admin plane | Not on public VIP; private NIC / firewall | -| TLS | Valid PEMs on both HA nodes; or **Let's Encrypt** via lego (`acme_email`, `acme_webroot` on :80, `leba-acme-renew.timer`) | +| TLS | Valid PEMs on both HA nodes; or native **Let's Encrypt** (`acme_email`, `acme_webroot` on :80, `leba-acme-renew.timer`) | | Unit + e2e gate | `make test-full` green (units, concurrent, adversarial, assets) | | Soak | `make test-soak` green in CI or pre-flight (`make test-ci` runs both) | | Concurrent | `make test-concurrent` green (GET/KA/POST/OPTIONS waves) | @@ -38,11 +38,11 @@ cutover. ```bash # 1. Real email in /etc/leba/leba.conf (or LEBA_ACME_EMAIL) -# 2. lego installed; port 80 public +# 2. Port 80 public and routed to acme_webroot # 3. Optional: test staging first curl -su admin:… -X POST \ 'http://127.0.0.1:18404/admin/certificates/issue?domain=app.example.com&frontend=web&staging=1&attach=1' -# 4. Production issue (or Proxy Hosts → Request SSL) +# 4. Production issue (or Proxy Hosts -> Request SSL) curl -su admin:… -X POST \ 'http://127.0.0.1:18404/admin/proxy-host?frontend=web&domain=app.example.com&backend=app&server=s1&addr=10.0.20.11:8080&ssl=1&force_ssl=1' # 5. Daily renew diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a3ac818..4c90b3b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2,303 +2,106 @@ | Field | Value | |-------|-------| -| **Baseline** | Leba **0.15.0** (2026-08) — Let's Encrypt + auto SSL + full CI matrix | -| **North star** | HAProxy-class data plane + Nginx Proxy Manager (NPM) day-1 UX, open-core price | -| **Status** | Living roadmap — update each release | -| **Related** | [COMPETITIVE_ARCHITECTURE.md](COMPETITIVE_ARCHITECTURE.md) (design depth), [PAINPOINTS.md](PAINPOINTS.md) (ops workflows) | - ---- +| **Baseline** | Leba 0.15.x | +| **North star** | HAProxy-class data plane + Nginx Proxy Manager day-1 UX, delivered as one Mako-native binary | +| **Current focus** | Native ACME, production test gates, and honest performance scorecards | +| **Related** | [ACME.md](ACME.md), [PRODUCTION.md](PRODUCTION.md), [SCORECARD.md](SCORECARD.md), [LIMITS.md](LIMITS.md) | ## Positioning | Audience | Promise | |----------|---------| -| **Homelab / SMB (NPM users)** | Add a reverse-proxy host, get TLS, open the UI — under 5 minutes | -| **Edge / platform (HAProxy users)** | Real LB algorithms, drain, stick tables, hitless reload, Prometheus, doctor/explain | -| **Both** | Single binary, plain config as source of truth, no separate DB + nginx process pair | -| **Performance** | **Beat nginx on reverse-proxy efficiency** — higher RPS and lower CPU/RSS for the same edge workload | - -### Performance north star (vs nginx) - -Leba’s data plane goal is not feature parity with every nginx module. It is to be a **tighter reverse proxy / LB**: - -| Metric | Target | -|--------|--------| -| **RPS** (small GET, local origin, Connection: close) | ≥ nginx under same concurrency / same machine | -| **p99 latency** | ≤ nginx on the same bench | -| **CPU** | Lower %CPU at equal RPS (crew workers + upstream pools, no per-request process model) | -| **RSS** | Lower steady-state memory for a fixed connection budget | - -**How we measure (local):** - -```bash -make build -./scripts/bench_vs_nginx.sh 10 50 # seconds, concurrency -``` - -**Hot-path rules (do not regress):** - -1. **No full stick-table copy** unless stick/cookie sticky is active (`stick_dirty`). -2. **No rate-bucket clone** unless the frontend has `rate_limit` (`rate_dirty`). -3. **No full `server_array_clone` on reserve/release** — `server_conn_delta` / `mark_server_req` deep-own **one slot** via ownership transfer (`servers = f(servers)`). Skip reserve entirely when no frontend/backend `maxconn` and balance ≠ `least_conn` (`plan.reserved=0`). -4. Prefer **`http_forward_fd` + `tcp_pool_*`** over `http_forward_full` (pools already wired via `init_server_pools`). -5. Accept thread does ACL/pick; workers only do upstream I/O (kick-safe). -6. **Empty `Dispatch.servers` / `backends`** when not dirty — accept thread must not wipe live tables (main adopts only non-empty arrays). +| Homelab / SMB | Add a reverse-proxy host, request TLS, and force HTTPS from one UI/API flow | +| Edge / platform | Load balancing, drain, stick tables, hitless reload paths, Prometheus, doctor, and explain | +| Both | Single binary, plain config as source of truth, no hidden DB, no nginx sidecar | +| Performance | Beat nginx/HAProxy on targeted reverse-proxy efficiency before making broad replacement claims | -**Honest limits today:** buffered request model (see LIMITS.md); TLS client KA best-effort; free-analysis still forces owned returns on some TLS paths. **Cleartext single-server fast path** (worker-owned KA loop) beats nginx on wrk keep-alive RPS on laptop scorecard (`docs/SCORECARD.md`) — multi-server / ACL / stick still use the full accept prepare path. +## Shipped -**Marketing gate (do not claim until checked):** +### Data Plane -| Claim | Ready when | -|-------|------------| -| “NPM replacement” | N1–N5 (below) all green, including multi-host cert UX that operators trust | -| “HAProxy Enterprise alternative” | H1–H4 green; peers/HA called **production** not experimental | -| “HAProxy Enterprise replacement” | **Never** without full parity review — avoid this phrase until H1–H4 + soak + support story | +- HTTP/1.1, HTTP/2, HTTP/3 when built with quiche, WebSocket, TCP, UDP/SIP. +- Balance modes: round-robin, least connections, IP hash, weighted, random, consistent hash, SIP Call-ID. +- TLS termination, mTLS, multi-cert SNI, and live TLS reload. +- ACLs, rate limits, header rules, WAF adapter, app HTTP Basic. +- Health checks, drain/ready/disable/enable, upstream pools, retry repick. +- DNS resolve/expand/SRV, Prometheus, `/stats`, `doctor`, and `explain`. ---- +### Control Plane -## Where we are (0.11.0) +- Admin UI for proxy hosts, certificates, access lists, server state, security, analytics, and config. +- Session auth, RBAC roles, and OIDC admin SSO. +- Native ACME HTTP-01 issue/renew in Mako with Let's Encrypt production/staging and custom ACME directory support. +- Managed include files for proxy hosts and access rules. +- Docker and systemd packaging under `deploy/`. -### Shipped — data plane (HAProxy-class core) - -- HTTP/1.1, H2 (ALPN), H3/QUIC (quiche builds), WebSocket, TCP, SIP/UDP Call-ID affinity -- Balance: RR, least_conn, ip_hash, weighted, random, consistent-hash, SIP Call-ID -- Sticky cookie + stick-on-src tables (H1–H3); peers dual-node smoke green (VIP soak still for prod sign-off) -- Drain / ready / disable / enable; active + passive health -- TLS termination, mTLS, multi-cert SNI, live `tls-reload` -- ACL, rate limit, header rules (wired), WAF adapter, app HTTP Basic -- Upstream pools + cleartext client keep-alive + retry re-pick -- Full config reload + listener rebind (HTTP/TCP/UDP/H3/stats/peers) -- DNS resolve / expand / SRV; Prometheus + `/stats` + doctor + explain - -### Shipped — control plane (NPM path) - -- Admin UI: Proxy Hosts (upsert, Force SSL, SNI certs), Certificates, Access Lists -- Session auth + RBAC (viewer / operator / admin) + OIDC SSO -- ACME via **external lego** (issue/renew API + UI + Docker bundle) -- Managed includes: `leba.vhosts.conf`, `leba.access.conf` -- Docker compose + lego profile; systemd packaging under `deploy/` - -### Honest gaps +## Honest Gaps | Gap | Why it matters | |-----|----------------| -| ACME is lego-orchestrated, not pure in-process JOSE | NPM feels “one click”; we need lego + port 80 + email | -| Access lists UI is ACL + Basic only | NPM has richer per-host toggles (WS, block exploits, custom locations) | -| Peers VIP multi-hour soak not productized | Dual-node smoke + ownership fixes green; keepalived DIY + site soak remain | -| No native ACME DNS-01 product path | Many hosts can’t open :80 (lego DNS-01 API exists; product packaging later) | -| Streaming / large bodies / RTP | Not edge-LB day-1; see LIMITS.md | -| Mako SAFE free still maturing | Ownership fixes + CI installs Mako; watch free-analysis regressions | - ---- - -## Beat criteria (scorecard) - -### vs Nginx Proxy Manager - -| ID | Criterion | 0.11 status | Done when | -|----|-----------|-------------|-----------| -| **N1** | Install | **Met** (0.14) | Published binary + GHCR image + `docker compose` / `LEBA_IMAGE` docs | -| **N2** | Proxy host CRUD | **Met** | GUI/API HTTPS host → upstream < 5 min | -| **N3** | Cert renew without restart | **Met** (TCP TLS) | lego + `tls_server_reload`; H3 may recreate | -| **N3b** | Multi-host multi-cert | **Met** (SNI) | Per-domain cert in UI without process restart | -| **N4** | Access list + Basic | **Met** (API/UI) | Per-frontend; polish per-host binding later | -| **N5** | Beyond NPM | **Met** | least_conn, drain, doctor, real LB | -| **N6** | Host editor parity | **Met** (0.15) | Per-host Force SSL, Request SSL, WS, locations, enable/disable | -| **N7** | Cert lifecycle UX | **Met** (0.15) | Official LE prod/staging, issue/renew UI, timer, DNS-01 | - -### vs HAProxy Enterprise / NGINX Plus - -| ID | Criterion | 0.14 status | Done when | -|----|-----------|-------------|-----------| -| **H1** | Hitless full reload | **Met** (with documented limits) | Soak tests; workers change = restart | -| **H2** | Stick tables local | **Met** (~100k design) | Runtime dump/clear API + UI | -| **H3** | HA pair | Partial | Docs + keepalived + dual-node peers smoke; **site VIP multi-hour soak** still required for “peers production” | -| **H4** | WAF path | **Met** (adapter + UI + metrics; rule packs open-core later) | Adapter shipped; mode toggle + blocked counters productized | -| **H5** | Runtime object API | Partial | Servers/hosts/stick live; full object CRUD later | -| **H6** | SSO | Partial | OIDC admin yes; SAML later if demanded | -| **P1** | Observability | Strong | Trace + Prometheus + analytics; dashboards templates | - ---- - -## Release roadmap - -### 0.11.x — Stabilize NPM control plane ✅ *(0.11.1)* - -Shipped: ACME preflight UX, cert expiry, compose demo, doctor hardening, tests. - ---- - -### 0.12 — NPM host parity ✅ *(0.12.0)* - -**Goal:** Operators stop missing NPM host toggles. - -| Work | Priority | Status | -|------|----------|--------| -| Per-host enable/disable | P0 | ✅ `enable=0` → dead 503; UI toggle | -| WebSocket on/off per host | P1 | ✅ `websocket off` route flag + 403 | -| Custom locations | P0 | ✅ `path_prefix` + `host_match` + API/UI | -| Redirect / dead from UI | P1 | ✅ `action=redirect\|dead` on proxy-host | -| Host-scoped IP access list | P1 | ✅ ACL kind `host_src` domain\|ip | -| Host-scoped HTTP Basic | P1 | ✅ `auth_user … host DOMAIN` + API | -| DNS-01 via lego | P1 | ✅ `challenge=dns&dns_provider=` | -| Bulk cert renew schedule | P2 | ✅ `deploy/linux/leba-acme-renew.{service,timer}` | - -**Exit:** N6 green; N7 mostly green. - ---- - -### 0.13 — Enterprise ops surface ✅ *(0.13.0)* +| Native DNS-01 provider adapters | Wildcards and closed-port-80 environments still need an explicit legacy helper or future provider adapters | +| Peers production sign-off | Dual-node smoke is green, but production HA still needs site-specific VIP soak | +| Streaming / large bodies / RTP | Not part of the day-1 edge-LB target; see [LIMITS.md](LIMITS.md) | +| Broad replacement claims | Do not claim full HAProxy Enterprise or NGINX Plus replacement until feature and soak gates are explicit | -**Goal:** HAProxy Enterprise “day 2” without Fusion. +## Beat Criteria -| Work | Priority | Status | -|------|----------|--------| -| Stick-table runtime API: list / clear / stats | P0 | ✅ `GET/DELETE /admin/stick-tables…` + UI | -| Peers production path: auth soak, reconnect, metrics | P0 | ✅ metrics + reconnect + free-alias fix; `make test-ha-peers` (still multi-hour soak for prod sign-off) | -| WAF product surface: mode toggle, blocked counters, sample rules | P1 | ✅ UI + Prometheus + `/admin/waf-*` | -| Turnkey HA package: dual-node compose + keepalived template | P1 | ✅ `deploy/ha/` README + compose | -| Runtime object API expansion (backends/servers CRUD) | P1 | ✅ `POST /admin/server` + delete | -| Config “apply” preview (doctor + explain before reload) | P2 | ✅ `/admin/preview-reload` + doctor/explain UI | - -**Exit:** H2 runtime, H3 docs+recipe, H4 usable; peers still honest if not soak-complete. - ---- +### Nginx Proxy Manager -### 0.14 — Platform quality ✅ *(0.14.0)* - -**Goal:** Trust for production edge. - -| Work | Priority | Status | -|------|----------|--------| -| Concurrent / soak harness (connection budget, KA, reload under load) | P0 | ✅ `make test-soak` / `scripts/soak.sh` + CI | -| Streaming / large body policy (document limits; optional pump path) | P1 | ✅ `docs/LIMITS.md` + doctor WARN >16MB | -| H3 cert reload strategy (recreate vs restart_required) | P1 | ✅ `h3_strategy=recreate` + in-process rebind | -| TLS client keep-alive (if needed) | P2 | Deferred — cleartext KA only; see LIMITS.md | -| OpenTelemetry export (optional) | P2 | Deferred — Prometheus remains default | -| Supply chain: signed releases, SBOM, multi-arch images | P0 | ✅ release workflow + SHA256SUMS + SBOM + multi-arch | +| ID | Criterion | Status | Done when | +|----|-----------|--------|-----------| +| N1 | Install | Met | Binary, Docker, and Linux package layout documented | +| N2 | Proxy host CRUD | Met | Host to upstream from UI/API | +| N3 | Certificate lifecycle | Met for HTTP-01 | Native ACME issue/renew, SNI attach, live reload | +| N4 | Multi-host certificates | Met | Per-domain SNI certificate inventory | +| N5 | Access lists + Basic auth | Met | UI/API for ACL and app auth | +| N6 | Host editor parity | Met | Force SSL, Request SSL, WebSocket toggle, locations, enable/disable | +| N7 | Beyond NPM | Met | Real LB algorithms, drain, doctor, explain, metrics | -**Exit:** Can recommend for production SMB/edge with runbook (`docs/PRODUCTION.md`). +### HAProxy / NGINX Plus ---- +| ID | Criterion | Status | Done when | +|----|-----------|--------|-----------| +| H1 | Reload paths | Met with documented limits | Full reload, TLS reload, and listener rebind tested | +| H2 | Runtime operations | Strong | Server state, hosts, certs, stick tables, WAF controls | +| H3 | HA pair | Partial | Keepalived docs + peers smoke; site VIP soak still required | +| H4 | WAF surface | Met | Local adapter, remote adapter, counters, UI/API controls | +| H5 | Observability | Strong | Prometheus, JSON stats, access logs, analytics | +| H6 | Performance claims | Ongoing | Release scorecard publishes RPS, p99, CPU, and RSS against nginx/HAProxy | -### 0.15 — NPM LE day-1 + perf harden ✅ *(0.15.0)* +## Release Tracks -**Goal:** Add a host, get a Let's Encrypt cert, force SSL — under five minutes; keep the hot path lean. +### 0.15.x Native ACME And Test Hardening | Work | Priority | Status | |------|----------|--------| -| Dirty-flag adopt / lean pick / retry / headers | P0 | ✅ | -| TLS/H2/H3 free-analysis dirty adopt | P0 | ✅ | -| Full CI matrix (units + concurrent + adversarial + soak + peers) | P0 | ✅ | -| Per-host `force_ssl` + Request SSL on proxy-host | P0 | ✅ | -| Let's Encrypt directories (prod/staging) via lego `--server` | P0 | ✅ | -| Certificates admin tab + Linux ACME template + renew timer | P0 | ✅ | -| `bench_vs_nginx.sh` harness | P0 | ✅ | -| Publish CPU/RSS scorecard numbers per release | P1 | ✅ `docs/SCORECARD.md` (2026-08-07 laptop: nginx wins RPS; peers 3× PASS) | - -**Exit:** N6/N7 green with official LE; production template has ACME defaults. - -**Perf honesty (0.15.0 scorecard):** local Connection:close microbench still favors **nginx** on RPS/p50; Leba reliability (0 fails) and peers smoke are green. See `docs/SCORECARD.md`. - -### 0.16+ — Stretch / non-blocking - -| Item | Notes | -|------|--------| -| Native ACME JOSE (if Mako gains sign primitives) | Optional; lego remains default | -| SAML admin SSO | Only if customers demand | -| RTP / media relay | Explicit non-goal until SIP product push | -| Paid open-core modules (WAF packs, Fusion-like CP) | Product decision | -| Graphite / multi-cluster control plane | After single-node product is loved | - ---- - -## Priority principles - -1. **Day-1 UX before more algorithms** — NPM users leave on certs + hosts, not least_conn. -2. **Config remains source of truth** — managed includes, no hidden DB. -3. **Honesty** — peers need site VIP soak; H3 cert limits; external ACME. -4. **Hitless where it counts** — TLS reload + full table reload; document restarts. -5. **Measure** — each release: N/H scorecard + soak notes. - ---- - -## Suggested sequencing (DAG) - -```text -0.11.x stabilize ──► 0.12 NPM host parity ──► “NPM ready” messaging - │ - ▼ - 0.13 Enterprise ops ──► “edge LB for production” messaging - │ - ▼ - 0.14 Platform quality ──► LTS / support discussion - │ - ▼ - 0.15+ stretch (SAML, native ACME, open-core) -``` - -Parallel tracks allowed: - -- **Track A (UX):** 0.12 host editor + DNS-01 -- **Track B (Ops):** stick-table API + peers soak + HA recipe -- **Track C (Trust):** soak tests + release signing - -A and B can run in parallel after 0.11.x; C continuous. - ---- - -## Release checklist (every version) - -- [ ] Version aligned: `mako.toml`, `main.mko`, metrics `leba_info`, README -- [ ] `make test` green; note soak if any -- [ ] Scorecard N/H updated in this file -- [ ] `docs/PAINPOINTS.md` roadmap bullets -- [ ] ACME / HA / ADMIN_API docs match API -- [ ] No claim of “Enterprise replacement” unless H1–H4 green - ---- - -## Near-term recommendation (next 4–6 weeks) - -1. ~~**0.11.1** stabilize~~ ✅ -2. ~~**0.12** host parity~~ ✅ -3. ~~**0.13** enterprise ops~~ ✅ -4. ~~**0.14** platform quality~~ ✅ -5. ~~**0.15** Let's Encrypt + auto SSL + full CI~~ ✅ -6. **0.16+** — stretch (SAML, native ACME JOSE, open-core) only when demanded - -That sequence maximizes “feels like NPM” first while keeping the HAProxy-class plane credible for the enterprise track. - ---- - -## Changelog of this roadmap - -| Date | Change | -|------|--------| -| 2026-07-18 | Initial roadmap from 0.11.0 baseline (NPM control plane shipped) | -| 2026-07-18 | 0.11.1 stabilize complete (ACME UX, expiry, compose demo, doctor hardening) | -| 2026-07-18 | 0.12.0 NPM host parity (locations, WS, enable, host ACL/auth, DNS-01, renew timer) | -| 2026-07-18 | 0.13.0 enterprise ops (stick API, peers metrics, WAF surface, HA package, preview) | -| 2026-07-18 | 0.14.0 platform quality (soak, LIMITS/PRODUCTION, H3 recreate, release/SBOM CI) | -| 2026-07-19 | v0.14.0 GitHub Release published (binary, SHA256SUMS, SBOM, multi-arch GHCR image) | -| 2026-07-19 | Cosign keyless signing on release + dual-node `make test-ha-peers` smoke | -| 2026-07-19 | Peers free-alias fix: proxy + stick UPSERT + reconnect stable (`stick_table_own`) | -| 2026-07-19 | Stick table residual ownership (`stick_table_clear` + all accept-thread adoptions) | -| 2026-07-19 | CI installs Mako (clone+path), runs unit/build/soak/`test-ha-peers` honestly | -| 2026-08-06 | Production hardening: LF/CRLF HTTP framing, browser header path tests, expanded concurrent smoke; scorecard N6/N7/H4 marked Met | -| 2026-08-06 | Free-alias production fixes: config frontend clone, peers stick own, dispatch deep-own of rate/server arrays (proxy no longer aborts under free-analysis) | -| 2026-08-06 | Perf track: dirty-flag rate/stick adopt, skip stick map own when unused, single server clone; `bench_vs_nginx.sh`; roadmap 0.15 performance north star | -| 2026-08-06 | Faster pick/retry/header path: single-pass pick_server, retries-0 plan, one header build per request, empty-rule skip | -| 2026-08-06 | Lean TLS/H2/H3: dirty servers/backends/rate/stick; adversarial no empty-wipe; H2 session-own once | -| 2026-08-06 | Stick free-alias fix (no intermediate map assign); concurrent smoke ephemeral ports; pending buffer rebuild | -| 2026-08-07 | Defer backend/rate clone + header render until after ACME/static/CORS short-circuit | -| 2026-08-07 | **v0.14.1** production harden + performance: free-alias fixes, dirty adopt, lean hot path, bench harness | -| 2026-08-07 | Full test matrix in CI; TCP/TLS free-alias fixes under adversarial | -| 2026-08-07 | Auto SSL on proxy-host; per-host Force SSL; Certificates UI | -| 2026-08-07 | **v0.15.0** first-class Let's Encrypt (prod/staging directories), Linux ACME defaults, doctor lego check | -| 2026-08-07 | Scorecard: bench harness ephemeral ports + RSS; published laptop medians vs nginx; HA peers ×3 PASS | -| 2026-08-07 | Hot path: single-slot `server_conn_delta`/`mark_server_req`; `plan.reserved` skip; empty Dispatch keep live tables; scorecard rebench (nginx still wins RPS) | -| 2026-08-07 | Accept path: drain done×256, immediate cleartext dispatch, short poll (no busy-spin); wrk c=4 ~parity, c=40 still nginx | -| 2026-08-07 | **HTTP fast path** + worker KA loop + sched 2× workers; scorecard wrk KA c=40 **Leba ~2× nginx RPS** | -| 2026-08-07 | Concurrency: workers max 512 (default 64), deep done buffer, accept while busy→pending, batch accept 256 | +| Native ACME HTTP-01 issue/renew | P0 | Done | +| P-256 account key, ES256 JWS, CSR finalize | P0 | Done | +| Let’s Encrypt production/staging directory support | P0 | Done | +| Admin certificates tab and proxy-host Request SSL flow | P0 | Done | +| Linux/Docker native ACME defaults | P0 | Done | +| Full local gate with release-built adversarial smoke | P0 | Done | +| Native DNS-01 provider adapters | P1 | Future | + +### 0.16+ Candidate Work + +| Work | Notes | +|------|-------| +| Native DNS-01 adapters | Cloudflare first, then Route53/DigitalOcean if needed | +| Longer HA soak reports | Publish repeatable VIP failover evidence | +| Performance scorecard refresh | Compare nginx and HAProxy on the same release hardware | +| SAML admin SSO | Only if customer demand appears | +| Paid/open-core modules | WAF packs or multi-cluster control plane are product decisions | + +## Validation Policy + +- `make test-full` is the local pre-push gate: unit tests, assets, concurrent smoke, and adversarial smoke. +- `make doctor` must report 0 errors for the sample config. +- Native ACME unit coverage must include account helper, JWS construction, and CSR DER/base64url encoding. +- Live Let's Encrypt staging issuance requires a public DNS name and public port 80 reachability; local tests validate the ACME plumbing but cannot replace CA validation. + +## Claim Policy + +- It is fair to claim “native ACME HTTP-01, no nginx/certbot/lego process required.” +- It is fair to claim “Nginx Proxy Manager-style host and certificate workflow.” +- Do not claim “full NGINX Plus replacement” or “full HAProxy Enterprise replacement” without a parity matrix, scorecard, HA soak evidence, and support story. diff --git a/leba_web_test.mko b/leba_web_test.mko index 4489859..2f664aa 100644 --- a/leba_web_test.mko +++ b/leba_web_test.mko @@ -815,3 +815,36 @@ fn TestLetsEncryptDirectoryAndStaging() { assert(str_contains(j, "\"staging\":true")) assert(str_contains(j, "letsencrypt.org")) } + + +fn TestNativeAcmeHelpers() { + let mut s = acme_settings_resolve("") + s.helper = "native" + s.email = "ops@example.com" + s.webroot = "/tmp/leba-native-acme-webroot" + s.storage = "/tmp/leba-native-acme-store" + let pre = acme_preflight_issue(s, "app.example.com", "") + assert_eq_str(pre, "") + let key_pem = acme_native_account_key(s) + assert(str_contains(key_pem, "PRIVATE KEY")) + let jwk = p256_public_jwk(key_pem) + assert(str_contains(jwk, "P-256")) + let th = p256_jwk_thumbprint(key_pem) + assert(str_len(th) > 20) + let jws = acme_jws_json("https://acme.example/order", "nonce123", "", key_pem, "{}") + assert(str_contains(jws, "protected")) + assert(str_contains(jws, "payload")) + assert(str_contains(jws, "signature")) +} + +fn TestNativeAcmeCsrDerEncoding() { + let csr = "/tmp/leba-native-acme-test.csr" + let key = "/tmp/leba-native-acme-test.key" + let rc = tls_make_csr(csr, key, "app.example.com", 2048) + assert_eq(rc, 0) + let enc = acme_csr_der_b64url(read_file(csr)) + assert(str_len(enc) > 100) + assert(not str_contains(enc, "BEGIN")) + assert(not str_contains(enc, "+")) + assert(not str_contains(enc, "/")) +} diff --git a/main.mko b/main.mko index a13c991..3b57ddd 100644 --- a/main.mko +++ b/main.mko @@ -131,7 +131,7 @@ fn main() { return } log_info_event("doctor_passed", f"path={path} warnings={rep.warnings}") - let mut servers = init_server_pools(w.servers, w.backends) + let mut servers = w.servers let mut backends = w.backends let mut frontends = w.frontends let mut routes = w.routes @@ -1986,4 +1986,5 @@ fn main() { clear_drain(drain_token) log_info_event("runtime_stop", f"completed={completed}") print("ok") + exit(0) } diff --git a/scripts/adversarial_smoke.sh b/scripts/adversarial_smoke.sh index ec092e0..7ff36fe 100755 --- a/scripts/adversarial_smoke.sh +++ b/scripts/adversarial_smoke.sh @@ -7,6 +7,7 @@ MAKO="${MAKO_BIN:-/Users/loreste/mako/target/release/mako}" [[ -x "$MAKO" ]] || MAKO="$(command -v mako)" # Match Makefile/CI: native backend lacks HTTP builtins Leba needs. MAKO_BACKEND="${MAKO_BACKEND:-c}" +MAKO_RELEASE_FLAG="${MAKO_RELEASE_FLAG:---release}" echo "== unit tests ==" "$MAKO" test leba_core1_test.mko --backend "$MAKO_BACKEND" @@ -14,7 +15,7 @@ echo "== unit tests ==" "$MAKO" test leba_web_test.mko --backend "$MAKO_BACKEND" echo "== build ==" -"$MAKO" build main.mko -o leba --backend "$MAKO_BACKEND" +"$MAKO" build main.mko -o leba --backend "$MAKO_BACKEND" "$MAKO_RELEASE_FLAG" echo "== doctor sample must pass ==" ./leba doctor configs/leba.conf | tee /tmp/leba_doc.txt diff --git a/src/acme.mko b/src/acme.mko index 72fc526..a6cb289 100644 --- a/src/acme.mko +++ b/src/acme.mko @@ -1,6 +1,6 @@ -// Let's Encrypt integration via lego (ACME client). -// Pure-Mako ACME is not possible without JOSE account signing — shell out to lego -// with the official Let's Encrypt ACME directories (production or staging). +// Native ACME integration for Let's Encrypt-compatible providers. +// Core issue/renew is Mako-native: HTTPS client + ES256 JWS + P-256 account key. +// External helpers are legacy compatibility only. pack acme // Official Let's Encrypt ACME v2 directories (RFC 8555). @@ -17,7 +17,7 @@ struct AcmeSettings { storage: string email: string helper: string - // ACME directory URL. Empty → Let's Encrypt production (lego default). + // ACME directory URL. Empty → Let's Encrypt production (native default). server: string // 1 = use LE staging directory (rate-limit safe for testing). staging: int @@ -153,7 +153,7 @@ fn acme_server_ok(s: string) -> int { return 1 } -// Resolve effective ACME directory URL for lego --server. +// Resolve effective ACME directory URL for native ACME and legacy helpers. fn acme_effective_server(settings: AcmeSettings) -> string { if settings.staging == 1 { return acme_le_staging_directory() @@ -161,7 +161,7 @@ fn acme_effective_server(settings: AcmeSettings) -> string { if not str_eq(settings.server, "") { return settings.server } - // Empty server → lego's default is Let's Encrypt production; return explicit URL for logs/API. + // Empty server -> Let's Encrypt production; return explicit URL for logs/API. return acme_le_production_directory() } @@ -241,7 +241,7 @@ fn acme_settings_resolve_path(webroot_hint: string, config_path: string) -> Acme storage = acme_kv_from_config(config_path, "acme_storage") } if str_eq(storage, "") { - storage = "/var/lib/leba/lego" + storage = "/var/lib/leba/acme-state" } let mut email = env_get_or("LEBA_ACME_EMAIL", "") if str_eq(email, "") { @@ -252,7 +252,7 @@ fn acme_settings_resolve_path(webroot_hint: string, config_path: string) -> Acme helper = acme_kv_from_config(config_path, "acme_helper") } if str_eq(helper, "") { - helper = "lego" + helper = "native" } let mut server = env_get_or("LEBA_ACME_SERVER", "") if str_eq(server, "") { @@ -360,7 +360,7 @@ fn acme_first_webroot(frontends: []Frontend) -> string { return "" } -// lego stores PEMs as /certificates/.crt and .key +// Native ACME stores PEMs as /certificates/.crt and .key fn acme_cert_paths(storage: string, domain: string) -> (string, string) { let base = storage + "/certificates/" + domain return (base + ".crt", base + ".key") @@ -393,23 +393,10 @@ fn acme_helper_available(helper: string) -> int { return 0 } -// PEM notAfter via openssl when available. Returns "" if unknown. +// Native cert expiry parsing is intentionally not shell-backed. Returns "" if unknown. fn acme_cert_not_after(cert_path: string) -> string { - if str_eq(cert_path, "") { - return "" - } - if not file_exists(cert_path) { - return "" - } - if acme_token_safe(cert_path) == 0 { - return "" - } - // Prefer openssl; fail soft if missing. - let raw = exec_output("openssl x509 -enddate -noout -in " + cert_path + " 2>/dev/null || true") - let t = str_trim_space(raw) - if str_has_prefix(t, "notAfter=") { - return str_trim_space(substr(t, 9, str_len(t))) - } + if str_eq(cert_path, "") { return "" } + if not file_exists(cert_path) { return "" } return "" } @@ -419,11 +406,13 @@ fn acme_preflight_issue(settings: AcmeSettings, domain: string, email_override: if acme_token_safe(domain) == 0 { return "code=invalid_domain message=domain must be a safe hostname (letters, digits, . - _)" } - if acme_token_safe(settings.helper) == 0 { - return "code=invalid_helper message=acme_helper path/name contains unsafe characters" - } - if acme_helper_available(settings.helper) == 0 { - return "code=missing_helper message=ACME helper not found on PATH — install lego or set LEBA_ACME_HELPER to an absolute path (Docker image includes lego)" + if not str_eq(settings.helper, "native") { + if acme_token_safe(settings.helper) == 0 { + return "code=invalid_helper message=acme_helper path/name contains unsafe characters" + } + if acme_helper_available(settings.helper) == 0 { + return "code=missing_helper message=legacy ACME helper not found on PATH — use native mode or set LEBA_ACME_HELPER" + } } let mut email = email_override if str_eq(email, "") { @@ -446,7 +435,7 @@ fn acme_preflight_renew(settings: AcmeSettings) -> string { return "code=invalid_helper message=acme_helper path/name contains unsafe characters" } if acme_helper_available(settings.helper) == 0 { - return "code=missing_helper message=ACME helper not found — install lego or set LEBA_ACME_HELPER" + return "code=missing_helper message=legacy ACME helper not found — use native mode or set LEBA_ACME_HELPER" } if acme_token_safe(settings.storage) == 0 { return "code=invalid_storage message=acme_storage path is empty or unsafe" @@ -505,8 +494,291 @@ fn acme_preflight_http_status(err: string) -> int { return 500 } + +fn acme_storage_dir(settings: AcmeSettings, name: string) -> string { + return settings.storage + "/" + name +} + +fn acme_account_key_path(settings: AcmeSettings) -> string { + return acme_storage_dir(settings, "accounts/account-p256.key") +} + +fn acme_account_url_path(settings: AcmeSettings) -> string { + return acme_storage_dir(settings, "accounts/account.url") +} + +fn acme_safe_mkdirs(settings: AcmeSettings) -> int { + if acme_token_safe(settings.storage) == 0 { + return 0 + } + let _ = mkdir(settings.storage) + let _ = mkdir(settings.storage + "/accounts") + let _ = mkdir(settings.storage + "/certificates") + if not str_eq(settings.webroot, "") { + let _ = mkdir(settings.webroot) + } + return 1 +} + +fn acme_native_account_key(settings: AcmeSettings) -> string { + if acme_safe_mkdirs(settings) == 0 { + return "" + } + let path = acme_account_key_path(settings) + if not file_exists(path) { + if p256_keygen(path) != 0 { + return "" + } + } + return read_file(path) +} + +fn acme_compact_part(token: string, idx: int) -> string { + let p1 = str_index(token, ".") + if p1 < 0 { return "" } + if idx == 0 { return substr(token, 0, p1) } + let rest = substr(token, p1 + 1, str_len(token)) + let p2 = str_index(rest, ".") + if p2 < 0 { return "" } + if idx == 1 { return substr(rest, 0, p2) } + if idx == 2 { return substr(rest, p2 + 1, str_len(rest)) } + return "" +} + +fn acme_jws_json(url: string, nonce: string, kid: string, key_pem: string, payload: string) -> string { + let mut header = str_builder() + builder_write(header, "{\"alg\":\"ES256\",\"nonce\":\"") + builder_write(header, json_escape(nonce)) + builder_write(header, "\",\"url\":\"") + builder_write(header, json_escape(url)) + builder_write(header, "\",") + if str_eq(kid, "") { + let jwk = p256_public_jwk(key_pem) + if str_eq(jwk, "") { return "" } + builder_write(header, "\"jwk\":") + builder_write(header, jwk) + } else { + builder_write(header, "\"kid\":\"") + builder_write(header, json_escape(kid)) + builder_write(header, "\"") + } + builder_write(header, "}") + let compact = jwt_sign_es256_header(payload, key_pem, builder_string(header)) + let protected = acme_compact_part(compact, 0) + let pay = acme_compact_part(compact, 1) + let sig = acme_compact_part(compact, 2) + if str_eq(protected, "") or str_eq(sig, "") { return "" } + let mut out = str_builder() + builder_write(out, "{\"protected\":\"") + builder_write(out, protected) + builder_write(out, "\",\"payload\":\"") + builder_write(out, pay) + builder_write(out, "\",\"signature\":\"") + builder_write(out, sig) + builder_write(out, "\"}") + return builder_string(out) +} + +fn acme_post_jws(url: string, nonce: string, kid: string, key_pem: string, payload: string) -> string { + let body = acme_jws_json(url, nonce, kid, key_pem, payload) + if str_eq(body, "") { return "" } + return https_post(url, body, "application/jose+json", "", 30000) +} + +fn acme_next_nonce(new_nonce_url: string) -> string { + let _ = https_get(new_nonce_url, "", 15000) + return https_last_header("Replay-Nonce") +} + +fn acme_json_first_array_string(json: string, key: string) -> string { + let needle = "\"" + key + "\":[\"" + let i = str_index(json, needle) + if i < 0 { return "" } + let start = i + str_len(needle) + let rest = substr(json, start, str_len(json)) + let e = str_index(rest, "\"") + if e < 0 { return "" } + return substr(rest, 0, e) +} + +fn acme_json_string_after(json: string, marker: string, key: string) -> string { + let mi = str_index(json, marker) + if mi < 0 { return "" } + let tail = substr(json, mi, str_len(json)) + let needle = "\"" + key + "\":\"" + let ki = str_index(tail, needle) + if ki < 0 { return "" } + let start = ki + str_len(needle) + let rest = substr(tail, start, str_len(tail)) + let e = str_index(rest, "\"") + if e < 0 { return "" } + return substr(rest, 0, e) +} + +fn acme_http01_url(authz: string) -> string { + return acme_json_string_after(authz, "\"type\":\"http-01\"", "url") +} + +fn acme_http01_token(authz: string) -> string { + return acme_json_string_after(authz, "\"type\":\"http-01\"", "token") +} + + +fn acme_pem_block_der_b64url(pem: string, begin: string, finish: string) -> string { + let bi = str_index(pem, begin) + if bi < 0 { return "" } + let tail = substr(pem, bi + str_len(begin), str_len(pem)) + let ei = str_index(tail, finish) + if ei < 0 { return "" } + let mut body = substr(tail, 0, ei) + body = str_replace(body, "\r", "") + body = str_replace(body, "\n", "") + body = str_replace(body, " ", "") + if str_eq(body, "") { return "" } + return base64url_encode(base64_decode(body)) +} + +fn acme_csr_der_b64url(csr_pem: string) -> string { + return acme_pem_block_der_b64url(csr_pem, "-----BEGIN CERTIFICATE REQUEST-----", "-----END CERTIFICATE REQUEST-----") +} + +fn acme_native_issue_http(settings: AcmeSettings, domain: string, email_override: string) -> AcmeRunResult { + let pre = acme_preflight_issue(settings, domain, email_override) + if not str_eq(pre, "") { + return AcmeRunResult { ok: 0, error: pre, cert: "", key: "", domain: domain, output: "" } + } + let key_pem = acme_native_account_key(settings) + if str_eq(key_pem, "") { + return AcmeRunResult { ok: 0, error: "code=account_key_failed message=failed to generate native P-256 ACME account key", cert: "", key: "", domain: domain, output: "" } + } + let directory = https_get(acme_effective_server(settings), "", 30000) + if https_last_status() < 200 or https_last_status() >= 300 { + return AcmeRunResult { ok: 0, error: "code=directory_failed message=failed to fetch ACME directory", cert: "", key: "", domain: domain, output: directory } + } + let new_nonce = json_get_string(directory, "newNonce") + let new_account = json_get_string(directory, "newAccount") + let new_order = json_get_string(directory, "newOrder") + if str_eq(new_nonce, "") or str_eq(new_account, "") or str_eq(new_order, "") { + return AcmeRunResult { ok: 0, error: "code=directory_invalid message=ACME directory missing required endpoints", cert: "", key: "", domain: domain, output: directory } + } + let mut email = email_override + if str_eq(email, "") { email = settings.email } + let mut kid = str_trim_space(read_file(acme_account_url_path(settings))) + let mut nonce = acme_next_nonce(new_nonce) + if str_eq(nonce, "") { + return AcmeRunResult { ok: 0, error: "code=nonce_failed message=ACME server did not return Replay-Nonce", cert: "", key: "", domain: domain, output: "" } + } + if str_eq(kid, "") { + let acct_payload = "{\"termsOfServiceAgreed\":true,\"contact\":[\"mailto:" + email + "\"]}" + let acct = acme_post_jws(new_account, nonce, "", key_pem, acct_payload) + let st = https_last_status() + if st < 200 or st >= 300 { + return AcmeRunResult { ok: 0, error: "code=account_failed message=ACME account creation failed", cert: "", key: "", domain: domain, output: acct } + } + kid = https_last_header("Location") + if str_eq(kid, "") { + return AcmeRunResult { ok: 0, error: "code=account_location_missing message=ACME account response missing Location", cert: "", key: "", domain: domain, output: acct } + } + let _ = atomic_write_file(acme_account_url_path(settings), kid + "\n") + nonce = https_last_header("Replay-Nonce") + } + if str_eq(nonce, "") { nonce = acme_next_nonce(new_nonce) } + let order_payload = "{\"identifiers\":[{\"type\":\"dns\",\"value\":\"" + domain + "\"}]}" + let order = acme_post_jws(new_order, nonce, kid, key_pem, order_payload) + let order_url = https_last_header("Location") + nonce = https_last_header("Replay-Nonce") + if https_last_status() < 200 or https_last_status() >= 300 { + return AcmeRunResult { ok: 0, error: "code=order_failed message=ACME order creation failed", cert: "", key: "", domain: domain, output: order } + } + let authz_url = acme_json_first_array_string(order, "authorizations") + let finalize_url = json_get_string(order, "finalize") + if str_eq(authz_url, "") or str_eq(finalize_url, "") or str_eq(order_url, "") { + return AcmeRunResult { ok: 0, error: "code=order_invalid message=ACME order missing authorization/finalize URL", cert: "", key: "", domain: domain, output: order } + } + if str_eq(nonce, "") { nonce = acme_next_nonce(new_nonce) } + let authz = acme_post_jws(authz_url, nonce, kid, key_pem, "") + nonce = https_last_header("Replay-Nonce") + if https_last_status() < 200 or https_last_status() >= 300 { + return AcmeRunResult { ok: 0, error: "code=authz_failed message=failed to fetch ACME authorization", cert: "", key: "", domain: domain, output: authz } + } + let chall_url = acme_http01_url(authz) + let token = acme_http01_token(authz) + if str_eq(chall_url, "") or str_eq(token, "") { + return AcmeRunResult { ok: 0, error: "code=http01_missing message=ACME authorization missing http-01 challenge", cert: "", key: "", domain: domain, output: authz } + } + let thumb = p256_jwk_thumbprint(key_pem) + if str_eq(thumb, "") { + return AcmeRunResult { ok: 0, error: "code=thumbprint_failed message=failed to compute account key thumbprint", cert: "", key: "", domain: domain, output: "" } + } + let keyauth = token + "." + thumb + let _ = write_file(settings.webroot + "/" + token, keyauth) + if str_eq(nonce, "") { nonce = acme_next_nonce(new_nonce) } + let chall = acme_post_jws(chall_url, nonce, kid, key_pem, "{}") + nonce = https_last_header("Replay-Nonce") + if https_last_status() < 200 or https_last_status() >= 300 { + return AcmeRunResult { ok: 0, error: "code=challenge_failed message=ACME challenge trigger failed", cert: "", key: "", domain: domain, output: chall } + } + let mut valid = 0 + let mut last_authz = chall + let mut poll = 0 + while poll < 20 { + sleep_ms(1000) + if str_eq(nonce, "") { nonce = acme_next_nonce(new_nonce) } + last_authz = acme_post_jws(authz_url, nonce, kid, key_pem, "") + nonce = https_last_header("Replay-Nonce") + if str_contains(last_authz, "\"status\":\"valid\"") { + valid = 1 + break + } + if str_contains(last_authz, "\"status\":\"invalid\"") { + break + } + poll = poll + 1 + } + if valid == 0 { + return AcmeRunResult { ok: 0, error: "code=challenge_not_valid message=HTTP-01 challenge did not become valid", cert: "", key: "", domain: domain, output: last_authz } + } + let cert, cert_key = acme_cert_paths(settings.storage, domain) + let csr = settings.storage + "/certificates/" + domain + ".csr" + if tls_make_csr(csr, cert_key, domain, 2048) != 0 { + return AcmeRunResult { ok: 0, error: "code=csr_failed message=failed to create CSR/key PEM", cert: cert, key: cert_key, domain: domain, output: "" } + } + let csr_der = acme_csr_der_b64url(read_file(csr)) + if str_eq(csr_der, "") { + return AcmeRunResult { ok: 0, error: "code=csr_encoding_failed message=failed to DER/base64url encode CSR", cert: cert, key: cert_key, domain: domain, output: "" } + } + let csr_payload = "{\"csr\":\"" + csr_der + "\"}" + if str_eq(nonce, "") { nonce = acme_next_nonce(new_nonce) } + let fin = acme_post_jws(finalize_url, nonce, kid, key_pem, csr_payload) + nonce = https_last_header("Replay-Nonce") + if https_last_status() < 200 or https_last_status() >= 300 { + return AcmeRunResult { ok: 0, error: "code=finalize_failed message=ACME finalize failed", cert: cert, key: cert_key, domain: domain, output: fin } + } + let mut cert_url = json_get_string(fin, "certificate") + let mut order_body = fin + let mut opoll = 0 + while str_eq(cert_url, "") and opoll < 20 { + sleep_ms(1000) + if str_eq(nonce, "") { nonce = acme_next_nonce(new_nonce) } + order_body = acme_post_jws(order_url, nonce, kid, key_pem, "") + nonce = https_last_header("Replay-Nonce") + cert_url = json_get_string(order_body, "certificate") + opoll = opoll + 1 + } + if str_eq(cert_url, "") { + return AcmeRunResult { ok: 0, error: "code=certificate_missing message=ACME order did not expose certificate URL", cert: cert, key: cert_key, domain: domain, output: order_body } + } + if str_eq(nonce, "") { nonce = acme_next_nonce(new_nonce) } + let pem = acme_post_jws(cert_url, nonce, kid, key_pem, "") + if https_last_status() < 200 or https_last_status() >= 300 or not str_contains(pem, "BEGIN CERTIFICATE") { + return AcmeRunResult { ok: 0, error: "code=certificate_download_failed message=failed to download issued certificate", cert: cert, key: cert_key, domain: domain, output: pem } + } + let _ = atomic_write_file(cert, pem) + return acme_issue_finish(settings, domain, pem, "http") +} + // challenge: "http" (default) or "dns". dns_provider e.g. cloudflare, route53, digitalocean. -// DNS credentials come from lego-standard env vars (CF_DNS_API_TOKEN, AWS_*, etc.). +// DNS credentials come from provider-standard env vars (CF_DNS_API_TOKEN, AWS_*, etc.). fn acme_issue(settings: AcmeSettings, domain: string, email_override: string) -> AcmeRunResult { return acme_issue_challenge(settings, domain, email_override, "http", "") } @@ -518,6 +790,16 @@ fn acme_issue_challenge( challenge: string, dns_provider: string ) -> AcmeRunResult { + let mut ch0 = challenge + if str_eq(ch0, "") { + ch0 = "http" + } + if str_eq(settings.helper, "native") { + if str_eq(ch0, "http") { + return acme_native_issue_http(settings, domain, email_override) + } + return AcmeRunResult { ok: 0, error: "code=native_dns_unsupported message=native ACME currently supports HTTP-01; DNS-01 provider adapters are not enabled", cert: "", key: "", domain: domain, output: "" } + } let pre = acme_preflight_issue(settings, domain, email_override) if not str_eq(pre, "") { return AcmeRunResult { ok: 0, error: pre, cert: "", key: "", domain: domain, output: "" } @@ -599,19 +881,42 @@ fn acme_issue_finish(settings: AcmeSettings, domain: string, out: string, challe log_info_event("acme_issue_ok", f"domain={domain} cert={cert} challenge={challenge}") return AcmeRunResult { ok: 1, error: "", cert: cert, key: key, domain: domain, output: out } } - let mut err = "code=lego_failed message=lego did not produce certificate files under acme_storage/certificates/" + let mut err = "code=acme_failed message=ACME did not produce certificate files under acme_storage/certificates/" if str_len(out) > 0 { let mut snippet = out if str_len(snippet) > 400 { snippet = substr(snippet, 0, 400) } - err = "code=lego_failed message=" + snippet + err = "code=acme_failed message=" + snippet } log_warn_event("acme_issue_failed", f"domain={domain} err={err}") return AcmeRunResult { ok: 0, error: err, cert: cert, key: key, domain: domain, output: out } } fn acme_renew(settings: AcmeSettings) -> AcmeRunResult { + if str_eq(settings.helper, "native") { + let certs_dir = settings.storage + "/certificates" + if not file_exists(certs_dir) { + return AcmeRunResult { ok: 0, error: "code=no_certs message=no certificates directory under acme_storage — issue a cert first", cert: "", key: "", domain: "", output: "" } + } + let entries = read_dir(certs_dir) + let mut renewed = 0 + let mut last = AcmeRunResult { ok: 1, error: "", cert: "", key: "", domain: "", output: "" } + for _, name in range entries { + if str_has_suffix(name, ".crt") { + let domain = substr(name, 0, str_len(name) - 4) + let r = acme_native_issue_http(settings, domain, "") + last = r + if r.ok == 0 { return r } + renewed = renewed + 1 + } + } + if renewed == 0 { + return AcmeRunResult { ok: 0, error: "code=no_certs message=no certificate PEMs under acme_storage", cert: "", key: "", domain: "", output: "" } + } + log_info_event("acme_renew_done", f"storage={settings.storage} renewed={renewed}") + return last + } let pre = acme_preflight_renew(settings) if not str_eq(pre, "") { return AcmeRunResult { ok: 0, error: pre, cert: "", key: "", domain: "", output: "" } @@ -623,7 +928,7 @@ fn acme_renew(settings: AcmeSettings) -> AcmeRunResult { let with_server = acme_append_server_flag(builder_string(cmd), settings) let full = with_server + " renew --no-random-sleep 2>&1" let ca = acme_ca_name(settings) - log_info_event("acme_renew_start", f"storage={settings.storage} ca={ca} server={acme_effective_server(settings)}") + log_info_event("acme_renew_start", f"storage={settings.storage} ca={ca} server={acme_effective_server(settings)} helper=legacy") let out = exec_output(full) let certs_dir = settings.storage + "/certificates" if file_exists(certs_dir) { @@ -694,7 +999,7 @@ fn acme_list_json(frontends: []Frontend, settings: AcmeSettings) -> string { builder_write(b, ",") } first = 0 - builder_write(b, "{\"source\":\"lego\",\"frontend\":\"\",\"hostname\":\"") + builder_write(b, "{\"source\":\"native\",\"frontend\":\"\",\"hostname\":\"") builder_write(b, json_escape(domain)) builder_write(b, "\",\"cert\":\"") builder_write(b, json_escape(cert)) @@ -712,7 +1017,8 @@ fn acme_list_json(frontends: []Frontend, settings: AcmeSettings) -> string { } } } - let helper_ok = acme_helper_available(settings.helper) + let mut helper_ok = acme_helper_available(settings.helper) + if str_eq(settings.helper, "native") { helper_ok = 1 } let email_ok = acme_email_ok(settings.email) builder_write(b, "],\"settings\":{\"provider\":\"letsencrypt\",\"ca\":\"") builder_write(b, json_escape(acme_ca_name(settings))) @@ -757,7 +1063,7 @@ fn acme_list_json(frontends: []Frontend, settings: AcmeSettings) -> string { builder_write(b, ",\"issues\":[") let mut ifirst = 1 if helper_ok == 0 { - builder_write(b, "{\"code\":\"missing_helper\",\"message\":\"Install lego or set LEBA_ACME_HELPER\"}") + builder_write(b, "{\"code\":\"missing_helper\",\"message\":\"Use native ACME or set LEBA_ACME_HELPER\"}") ifirst = 0 } if email_ok == 0 { diff --git a/src/admin.mko b/src/admin.mko index c10f327..811fc33 100644 --- a/src/admin.mko +++ b/src/admin.mko @@ -2091,7 +2091,7 @@ fn handle_admin_state( let domain_own = own_string(domain) let mut ssl_status = "none" let mut ssl_error = "" - // Automatic certificate: reuse existing ACME PEMs or issue via lego. + // Automatic certificate: reuse existing ACME PEMs or issue via native ACME. if want_ssl == 1 { if str_eq(cert_own, "") or str_eq(key_own, "") { let settings = acme_settings_resolve_path(acme_first_webroot(out_frontends), config_path) @@ -2371,7 +2371,7 @@ fn handle_admin_state( } } - // --- Certificates (list / issue via lego / renew) ----------------------- + // --- Certificates (list / issue via native ACME / renew) ----------------------- if str_eq(path, "/admin/certificates") { if not str_eq(method, "GET") { return AdminStateResult { handled: 1, status: 405, body: "{\"error\":\"method not allowed\"}\n", servers: server_array_clone(servers), frontends: frontends, backends: backends, routes: routes, acls: acls } diff --git a/src/config.mko b/src/config.mko index 0775ed5..9c5f9f9 100644 --- a/src/config.mko +++ b/src/config.mko @@ -27,7 +27,7 @@ // acme_webroot PATH # HTTP-01 challenge tokens (defaults inherit) // acme_storage PATH # lego --path (defaults; env LEBA_ACME_STORAGE) // acme_email ADDR # ACME registration email (env LEBA_ACME_EMAIL) -// acme_helper PATH|NAME # lego binary (default lego; env LEBA_ACME_HELPER) +// acme_helper native # native by default; legacy helper only when explicit // acme_server URL|alias # Let's Encrypt directory (empty=production; staging|letsencrypt) // acme_staging on|off # Use LE staging directory (rate-limit safe) // auth_basic REALM # require HTTP Basic for proxied apps (non-ACME) diff --git a/src/doctor.mko b/src/doctor.mko index 580da25..88189ff 100644 --- a/src/doctor.mko +++ b/src/doctor.mko @@ -708,7 +708,7 @@ fn doctor_world(w: World) -> DoctorReport { } } - // Let's Encrypt / ACME defaults (lego client readiness). + // Let's Encrypt / ACME defaults (native client readiness). if not str_eq(w.defaults.acme_webroot, "") { if str_contains(w.defaults.acme_webroot, "..") { builder_write(b, "ERROR defaults acme_webroot must not contain '..'\n") @@ -717,7 +717,7 @@ fn doctor_world(w: World) -> DoctorReport { if str_eq(w.defaults.acme_email, "") { let env_em = env_get("LEBA_ACME_EMAIL") if str_eq(env_em, "") { - builder_write(b, "WARN acme_webroot set but no acme_email / LEBA_ACME_EMAIL — Let's Encrypt issue will fail\n") + builder_write(b, "WARN acme_webroot set but no acme_email / LEBA_ACME_EMAIL — ACME issue will fail\n") builder_write(b, " fix: defaults acme_email you@example.com or export LEBA_ACME_EMAIL\n") warnings = warnings + 1 } @@ -730,12 +730,13 @@ fn doctor_world(w: World) -> DoctorReport { } let mut helper = w.defaults.acme_helper if str_eq(helper, "") { - helper = "lego" + helper = "native" } - if acme_helper_available(helper) == 0 { - builder_write(b, "WARN Let's Encrypt helper not found on PATH — install lego (or set acme_helper / LEBA_ACME_HELPER)\n") - builder_write(b, " fix: https://go-acme.github.io/lego/ or use the official Docker image\n") - warnings = warnings + 1 + if not str_eq(helper, "native") { + if acme_helper_available(helper) == 0 { + builder_write(b, "WARN legacy ACME helper not found on PATH — use native mode or set LEBA_ACME_HELPER\n") + warnings = warnings + 1 + } } if w.defaults.acme_staging == 1 { builder_write(b, "INFO ACME staging on — certificates from Let's Encrypt staging are not browser-trusted\n") diff --git a/src/types.mko b/src/types.mko index a301a1d..ac97f22 100644 --- a/src/types.mko +++ b/src/types.mko @@ -296,11 +296,11 @@ struct Defaults { workers: int request_body_limit_bytes: int acme_webroot: string - // lego --path storage (certificates + account). + // Native ACME storage (account key, account URL, certificates). acme_storage: string // Contact email for ACME registration (or LEBA_ACME_EMAIL). acme_email: string - // ACME helper binary name or absolute path (default "lego", or LEBA_ACME_HELPER). + // ACME mode/helper. Default "native"; external helper is legacy compatibility. acme_helper: string // ACME directory URL (empty = Let's Encrypt production). Aliases: staging, letsencrypt. acme_server: string @@ -363,7 +363,7 @@ fn defaults_new() -> Defaults { acme_webroot: "", acme_storage: "", acme_email: "", - acme_helper: "lego", + acme_helper: "native", acme_server: "", acme_staging: 0 } diff --git a/src/webadmin.mko b/src/webadmin.mko index 655014d..1a3f767 100644 --- a/src/webadmin.mko +++ b/src/webadmin.mko @@ -147,10 +147,10 @@ fn wa_js() -> string { builder_write(b, "
CA
${esc(s.ca||'letsencrypt')}
") builder_write(b, "
Mode
${s.staging?'Staging':'Production'}
") builder_write(b, "
Email
${esc(s.email||'—')}
") - builder_write(b, "
Helper
${esc(s.helper||'lego')}
`;") + builder_write(b, "
Mode
${esc(s.helper||'native')}
`;") builder_write(b, "h+=`

Directory: ${esc(s.server||'')} · Storage: ${esc(s.storage||'')}

`;") builder_write(b, "if(issues.length){h+='

Setup issues

    ';issues.forEach(i=>{const m=(i&&i.message)?i.message:String(i);h+='
  • '+esc(m)+'
  • '});h+='
") - builder_write(b, "

Set acme_email / LEBA_ACME_EMAIL, acme_webroot on :80, and install lego (Let\\'s Encrypt ACME client).

'}") + builder_write(b, "

Set acme_email / LEBA_ACME_EMAIL and expose acme_webroot on :80 for native HTTP-01.

'}") builder_write(b, "h+=`

Issue certificate

") builder_write(b, "
") builder_write(b, "
") @@ -392,10 +392,10 @@ fn wa_js() -> string { builder_write(b, "
") builder_write(b, "
") builder_write(b, "
") - builder_write(b, "") + builder_write(b, "") builder_write(b, "") builder_write(b, "
") - builder_write(b, "

Request SSL issues a Let's Encrypt cert via lego (HTTP-01 on acme_webroot), attaches SNI, and reloads TLS. Requires LEBA_ACME_EMAIL and public :80 reachability.

") + builder_write(b, "

Request SSL issues a certificate with native ACME (HTTP-01 on acme_webroot), attaches SNI, and reloads TLS. Requires LEBA_ACME_EMAIL and public :80 reachability.

") builder_write(b, "
") builder_write(b, "") builder_write(b, "
") From eea27cc5a56486bfc6db9d39521d8706bfdb7b38 Mon Sep 17 00:00:00 2001 From: Lance oreste Date: Mon, 17 Aug 2026 16:57:16 -0400 Subject: [PATCH 3/4] docs: clarify native acme security posture --- README.md | 37 ++++++++++++++-- docs/ACME.md | 21 +++++++++ docs/ROADMAP.md | 7 ++- docs/SCORECARD.md | 12 +++++ docs/SECURITY.md | 111 ++++++++++++++++++++++++++++------------------ 5 files changed, 140 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 59fa4a8..34a212d 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,14 @@ # Leba -Leba is a load balancer written in [Mako](https://github.com/loreste/mako), -showcasing what the language can do in a real systems program. +Leba is a memory-safe edge load balancer written in +[Mako](https://github.com/loreste/mako). It is being built to replace the common +nginx / HAProxy / Nginx Proxy Manager stack with one auditable binary: fast data +plane, operator-friendly control plane, native free TLS, and explicit security +gates before broad replacement claims. -**Current version: 0.15.0** — NPM-style control plane (Let's Encrypt, proxy hosts) + -HAProxy-class data plane, full CI matrix (units / concurrent / adversarial / soak / peers). +**Current version: 0.15.0** — NPM-style control plane (proxy hosts, Request SSL, +access lists) + HAProxy-class data plane, native Let's Encrypt HTTP-01, and full +CI matrix (units / concurrent / adversarial / soak / peers). Binary releases: tag `v0.15.0` on GitHub when cut (see `docs/PRODUCTION.md`). @@ -22,6 +26,19 @@ gh repo clone loreste/leba && cd leba && make build ## Features +### Why Leba +- **One Mako-native binary:** no nginx sidecar, certbot daemon, Node service, or + Lua/plugin runtime required for the core proxy and certificate path. +- **Native free TLS:** Let's Encrypt production/staging and custom ACME + directories through a built-in ACME v2 HTTP-01 client. +- **Fast by design:** worker-owned keep-alive, upstream connection pools, + low-allocation routing, and explicit RPS/p99/CPU/RSS scorecards. +- **Operational control:** admin UI/API, `doctor`, `explain`, live TLS reload, + drain/ready/disable/enable, Prometheus, JSON stats, and audit logs. +- **Security posture:** memory-safe implementation language, fail-closed routing + decisions, RBAC/OIDC admin surface, WAF hooks, and public white-hat review + requested for native ACME and certificate-management paths. + ### Load Balancing - Round-robin, least-connection, IP-hash, weighted, random, SIP Call-ID, and consistent-hash algorithms @@ -62,6 +79,7 @@ wildcard CORS without credentials. - Application HTTP Basic (`auth_basic` + `auth_user` on frontends) - WAF adapter: local signatures + optional remote inspect sidecar - **Let's Encrypt** via native ACME (HTTP-01, production + staging directories, live SNI reload; legacy DNS-01 helper compatibility) +- Custom ACME directory support for other free or internal ACME-compatible CAs - Per-frontend and per-client-IP rate limiting (token bucket) - Request body size limits - Directory traversal prevention for static file serving @@ -284,6 +302,17 @@ peers smoke (**v0.15.0**). It handles HTTP/1–3, TCP, UDP/SIP, WebSocket, TLS/m stick tables, WAF adapter, and an NPM-style control plane (proxy hosts, native ACME, access lists) on a HAProxy-class data plane. +The current replacement stance is deliberate: Leba can replace nginx/NPM for +HTTP reverse-proxy hosts with native HTTP-01 certificates, live SNI reload, +admin UI/API management, and stronger LB operations than NPM. Treat full +HAProxy Enterprise / NGINX Plus replacement claims as gated on the published +scorecard, HA soak evidence, and security-review closure. + +White-hat review is requested for the native ACME/certificate path, especially +P-256 account-key storage, ES256 JWS construction, JWK thumbprints, HTTP-01 +token validation, CSR/finalize handling, path traversal controls, file +permissions, and live TLS reload behavior. + **Roadmap:** [`docs/ROADMAP.md`](docs/ROADMAP.md) — release plan and beat criteria vs NPM / HAProxy Enterprise. Design depth: [`docs/COMPETITIVE_ARCHITECTURE.md`](docs/COMPETITIVE_ARCHITECTURE.md). diff --git a/docs/ACME.md b/docs/ACME.md index 45b9085..821fd8b 100644 --- a/docs/ACME.md +++ b/docs/ACME.md @@ -12,12 +12,30 @@ Leba issues and renews certificates with a native Mako ACME v2 client. The defau The native client uses Mako HTTPS, ES256 JWS, a generated P-256 account key, HTTP-01 challenge files, CSR creation, certificate download, SNI attach, and live TLS reload. No nginx, HAProxy, certbot, or lego process is required for HTTP-01. +## Security Review Scope + +The native ACME path is intentionally small and reviewable. White-hat review +should focus on: + +- P-256 account-key generation, storage location, and file permissions. +- ES256 JWS protected-header construction, nonce handling, and replay behavior. +- Canonical public JWK and RFC 7638 thumbprint generation. +- HTTP-01 token validation, challenge-file isolation, and bypass rules. +- Domain/path validation, especially traversal and unsafe absolute paths. +- CSR DER/base64url encoding and finalize/certificate download handling. +- Certificate/key writes under `acme_storage` and live SNI reload behavior. + +Until that review is closed, keep broad production replacement claims tied to +the gates in [ROADMAP.md](ROADMAP.md) and [SCORECARD.md](SCORECARD.md). + ## Requirements 1. `acme_email` or `LEBA_ACME_EMAIL` for ACME account registration. 2. Public port 80 for HTTP-01 validation. 3. `acme_webroot` on the HTTP frontend serving `/.well-known/acme-challenge/*`. 4. `acme_storage` writable by the Leba process. +5. A least-privilege runtime user that owns only Leba state, challenge, and + certificate directories. ## Config @@ -82,6 +100,9 @@ GET /.well-known/acme-challenge/ Challenge paths bypass HTTPS redirect, ACLs, rate limiting, and app Basic auth so the ACME CA can validate the domain. +Only the `/.well-known/acme-challenge/` path is special-cased. All other +requests continue through the normal redirect, ACL, rate-limit, and auth paths. + ## Renew Native renew re-issues the certificates already present under `{acme_storage}/certificates` and triggers TLS reload through the admin path. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4c90b3b..99d6255 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -4,7 +4,7 @@ |-------|-------| | **Baseline** | Leba 0.15.x | | **North star** | HAProxy-class data plane + Nginx Proxy Manager day-1 UX, delivered as one Mako-native binary | -| **Current focus** | Native ACME, production test gates, and honest performance scorecards | +| **Current focus** | Security review, production test gates, and honest performance scorecards | | **Related** | [ACME.md](ACME.md), [PRODUCTION.md](PRODUCTION.md), [SCORECARD.md](SCORECARD.md), [LIMITS.md](LIMITS.md) | ## Positioning @@ -15,6 +15,7 @@ | Edge / platform | Load balancing, drain, stick tables, hitless reload paths, Prometheus, doctor, and explain | | Both | Single binary, plain config as source of truth, no hidden DB, no nginx sidecar | | Performance | Beat nginx/HAProxy on targeted reverse-proxy efficiency before making broad replacement claims | +| Security reviewers | Memory-safe core, fail-closed controls, and a narrow audit target for ACME/certificate-management paths | ## Shipped @@ -40,6 +41,7 @@ | Gap | Why it matters | |-----|----------------| | Native DNS-01 provider adapters | Wildcards and closed-port-80 environments still need an explicit legacy helper or future provider adapters | +| White-hat sign-off | Native ACME and certificate reload are built and tested, but external security review should close before stronger production marketing | | Peers production sign-off | Dual-node smoke is green, but production HA still needs site-specific VIP soak | | Streaming / large bodies / RTP | Not part of the day-1 edge-LB target; see [LIMITS.md](LIMITS.md) | | Broad replacement claims | Do not claim full HAProxy Enterprise or NGINX Plus replacement until feature and soak gates are explicit | @@ -81,6 +83,7 @@ | Admin certificates tab and proxy-host Request SSL flow | P0 | Done | | Linux/Docker native ACME defaults | P0 | Done | | Full local gate with release-built adversarial smoke | P0 | Done | +| Public security / white-hat review request | P0 | In review | | Native DNS-01 provider adapters | P1 | Future | ### 0.16+ Candidate Work @@ -90,6 +93,7 @@ | Native DNS-01 adapters | Cloudflare first, then Route53/DigitalOcean if needed | | Longer HA soak reports | Publish repeatable VIP failover evidence | | Performance scorecard refresh | Compare nginx and HAProxy on the same release hardware | +| Security review closure | Track findings/fixes for ACME, admin certificate actions, and TLS reload | | SAML admin SSO | Only if customer demand appears | | Paid/open-core modules | WAF packs or multi-cluster control plane are product decisions | @@ -98,6 +102,7 @@ - `make test-full` is the local pre-push gate: unit tests, assets, concurrent smoke, and adversarial smoke. - `make doctor` must report 0 errors for the sample config. - Native ACME unit coverage must include account helper, JWS construction, and CSR DER/base64url encoding. +- Security review must cover P-256 account-key storage, JWK thumbprints, HTTP-01 challenge isolation, path validation, cert/key file writes, and TLS reload behavior. - Live Let's Encrypt staging issuance requires a public DNS name and public port 80 reachability; local tests validate the ACME plumbing but cannot replace CA validation. ## Claim Policy diff --git a/docs/SCORECARD.md b/docs/SCORECARD.md index d6d17f2..281c620 100644 --- a/docs/SCORECARD.md +++ b/docs/SCORECARD.md @@ -2,6 +2,18 @@ Directional local measurements — re-run on your hardware before capacity claims. +## Replacement Claim Gate + +Leba can be evaluated as an nginx/NPM replacement for HTTP reverse proxying, +native HTTP-01 certificates, live SNI reload, access lists, and admin UI/API +operations today. Broader HAProxy Enterprise / NGINX Plus replacement claims +require all of: + +- release scorecard rerun on target hardware with RPS, p99, CPU, and RSS +- HA pair VIP soak evidence for the deployment topology +- security review closure for native ACME, cert/key writes, and TLS reload +- a clear support and rollback plan + ## Environment (scorecard host) | Field | Value | diff --git a/docs/SECURITY.md b/docs/SECURITY.md index cee9b17..0ae8dfe 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,7 +1,16 @@ # Security Notes -Leba is intended to be deployed on internet-facing hosts, so the default -operational stance should be conservative. +Leba is intended to run on internet-facing hosts. The default operational stance +should be conservative: least privilege, explicit admin credentials, private +control-plane exposure, and fail-closed routing. + +## Memory Safety + +Leba is written in Mako and relies on Mako ownership/free analysis for +application code. The project still treats security as a process, not a slogan: +native ACME, certificate writes, TLS reload, and admin mutation paths require +code review, adversarial tests, and white-hat review before stronger replacement +claims. ## Admin Surface @@ -23,16 +32,16 @@ viewer leba-kdf-v1:ITERATIONS:SALT_HEX:HASH_HEX viewer operator leba-kdf-v1:ITERATIONS:SALT_HEX:HASH_HEX operator ``` -New hashes use Argon2id when the linked Mako crypto backend supports it and -fall back to `leba-kdf-v1` otherwise. Legacy SHA-256 hashes are accepted only -for compatibility. +New hashes use Argon2id when the linked Mako crypto backend supports it and fall +back to `leba-kdf-v1` otherwise. Legacy SHA-256 hashes are accepted only for +compatibility. When credentials are configured, Leba protects: -- the admin dashboard, -- `/stats`, -- `/metrics`, -- `/admin/*` runtime actions. +- admin dashboard +- `/stats` +- `/metrics` +- `/admin/*` runtime actions Probe endpoints remain unauthenticated: @@ -40,25 +49,28 @@ Probe endpoints remain unauthenticated: - `/livez` - `/readyz` -Unauthenticated admin requests return `401` JSON. -Authenticated users without enough role privilege receive `403`. - -Roles: +Unauthenticated admin requests return `401` JSON. Authenticated users without +enough role privilege receive `403`. | Role | Access | |------|--------| -| `viewer` | Dashboard, stats, metrics, and server listing. | -| `operator` | Viewer access plus drain, ready, disable, enable, and `servers_file` reload. | -| `admin` | Full admin access. | +| `viewer` | Dashboard, stats, metrics, server listing | +| `operator` | Viewer access plus drain, ready, disable, enable, `servers_file` reload | +| `admin` | Full admin access | ## Credentials -The sample local config uses demo plaintext credentials. The Linux template -uses `admin_users_file` with `CHANGE_ME_*` placeholders. `leba doctor` warns on +Sample local config uses demo plaintext credentials. The Linux template uses +`admin_users_file` with `CHANGE_ME_*` placeholders. `leba doctor` warns on placeholder/demo values and errors on malformed hashes. Use long random passwords and store only salted, iterated hashes in production -configs. Generate hashes with `leba admin hash-password 'strong-password'`. +configs. + +```bash +leba admin hash-password 'strong-password' +``` + Treat the admin endpoint as a privileged control plane because it can drain, enable, disable, and reload upstream server membership. @@ -66,42 +78,55 @@ enable, disable, and reload upstream server membership. Recommended deployment shape: -- expose only the public HTTP/TCP/SIP frontend ports to the internet, -- keep the stats/admin port on a private interface or behind trusted network - controls, -- use host firewall rules to restrict admin access, -- run `leba doctor` before restarting a production instance. +- expose only public HTTP/TCP/SIP frontend ports to the internet +- keep stats/admin ports on private interfaces behind trusted network controls +- restrict admin access with host firewall rules +- run `leba doctor` before restarting a production instance ## Request Handling Current hardening: -- raw HTTP requests larger than the configured limit (default 1 MiB) are rejected - with HTTP 413 before upstream forwarding, -- raw HTTP request limits are configurable with `request_body_limit` (see - [LIMITS.md](LIMITS.md); doctor warns above 16MB), -- protected admin requests write audit logs with request id, authenticated - user, role, method, path, status, and outcome, -- ACL denies are enforced before backend selection, -- rate limits are enforced before upstream forwarding, -- backend and server `maxconn` caps fail closed when saturated, -- all-drained or all-down pools fail closed instead of silently choosing an - unavailable server, +- raw HTTP requests larger than the configured limit are rejected with HTTP 413 + before upstream forwarding +- raw HTTP request limits are configurable with `request_body_limit`; see + [LIMITS.md](LIMITS.md) +- protected admin requests write audit logs with request ID, authenticated user, + role, method, path, status, and outcome +- ACL denies are enforced before backend selection +- rate limits are enforced before upstream forwarding +- backend server `maxconn` caps fail closed when saturated +- all-drained or all-down pools fail closed instead of choosing an unavailable + server - trace headers are validated before use and forwarded upstream only after - validation or regeneration. + validation or regeneration + +## Native ACME / Certificate Review + +Native HTTP-01 issuance avoids nginx, certbot, and lego as required runtime +dependencies. Review the following before treating it as a high-trust production +certificate manager: + +- P-256 account-key generation, storage path, and permissions +- ES256 JWS construction, nonce use, JWK thumbprints, and CSR encoding +- HTTP-01 token validation and challenge-file serving +- domain/path validation and traversal rejection +- certificate/key file writes and live TLS/SNI reload +- admin RBAC on issue, renew, and reload endpoints ## Session Cookies Admin UI session cookies are signed with material from, in order: -1. `state_key` in defaults (preferred), -2. `LEBA_SESSION_SECRET` environment variable, -3. an insecure local-dev default (doctor warns when neither 1 nor 2 is set). +1. `state_key` in defaults (preferred) +2. `LEBA_SESSION_SECRET` environment variable +3. an insecure local-dev default; `doctor` warns when neither 1 nor 2 is set ## Remaining Security Work -These are still open: +These remain open: -- configurable probe authentication policy, -- broader TLS and HTTP/2 accept-path hardening, -- RTP/media handling. +- white-hat review closure for native ACME/certificate paths +- configurable probe authentication policy +- broader TLS HTTP/2 accept-path hardening +- RTP/media handling From 8bc0b032e0deb9ca768032f390bdc915518e75ff Mon Sep 17 00:00:00 2001 From: Lance oreste Date: Mon, 17 Aug 2026 17:21:16 -0400 Subject: [PATCH 4/4] bench: add docker proxy matrix --- Makefile | 9 +- docs/SCORECARD.md | 20 +++ scripts/bench_proxy_matrix.sh | 297 ++++++++++++++++++++++++++++++++++ 3 files changed, 325 insertions(+), 1 deletion(-) create mode 100755 scripts/bench_proxy_matrix.sh diff --git a/Makefile b/Makefile index ac789c0..cb3765c 100644 --- a/Makefile +++ b/Makefile @@ -46,7 +46,7 @@ export MAKO_QUICHE_ROOT ?= $(shell if [ -f /Users/loreste/mako/runtime/third_par doctor doctor-linux explain smoke run clean clean-cache \ test-linux-assets test-ha-assets test-docs test-haproxy-compare \ test-soak test-ha-peers test-concurrent test-adversarial \ - test-full test-ci test-all bench-nginx + test-full test-ci test-all bench-nginx bench-proxy-matrix all: build @@ -134,6 +134,7 @@ test-docs: test -f scripts/adversarial_smoke.sh test -f scripts/soak.sh test -f scripts/bench_vs_nginx.sh + test -f scripts/bench_proxy_matrix.sh test-adversarial: test test-linux-assets chmod +x scripts/adversarial_smoke.sh @@ -171,6 +172,12 @@ bench-nginx: build chmod +x scripts/bench_vs_nginx.sh ./scripts/bench_vs_nginx.sh 8 40 +# Docker-backed RPS/latency matrix vs nginx + HAProxy. +# Strict gate: LEBA_REQUIRE_WIN=1 make bench-proxy-matrix +bench-proxy-matrix: build + chmod +x scripts/bench_proxy_matrix.sh + ./scripts/bench_proxy_matrix.sh 8 40 + check: doctor doctor: build diff --git a/docs/SCORECARD.md b/docs/SCORECARD.md index 281c620..3bd75ba 100644 --- a/docs/SCORECARD.md +++ b/docs/SCORECARD.md @@ -14,6 +14,26 @@ require all of: - security review closure for native ACME, cert/key writes, and TLS reload - a clear support and rollback plan +## Competitive Benchmark Gate + +Use the Docker-backed matrix for repeatable local checks against both nginx and +HAProxy: + +```bash +make build +make bench-proxy-matrix +LEBA_REQUIRE_WIN=1 make bench-proxy-matrix +``` + +The matrix starts one keep-alive origin, Leba on the host, and nginx + HAProxy +containers. It reports `SCORE` lines with RPS, successful requests, failures, +p50, p99, and memory/RSS. `LEBA_REQUIRE_WIN=1` makes the command fail unless +Leba beats both nginx and HAProxy on RPS for that run. + +Do not publish a broad "faster than nginx and HAProxy" claim from one laptop +run. Publish multi-run medians with the host, CPU governor, file descriptor +limit, Docker version, images, concurrency, duration, p99, CPU, and RSS. + ## Environment (scorecard host) | Field | Value | diff --git a/scripts/bench_proxy_matrix.sh b/scripts/bench_proxy_matrix.sh new file mode 100755 index 0000000..aa9efd5 --- /dev/null +++ b/scripts/bench_proxy_matrix.sh @@ -0,0 +1,297 @@ +#!/usr/bin/env bash +# Docker-backed reverse-proxy benchmark matrix: Leba vs nginx vs HAProxy. +# +# This is a local regression/claim gate, not a lab-grade capacity study. +# It uses one local keep-alive origin and sends the same concurrent HTTP load to +# each proxy. Set LEBA_REQUIRE_WIN=1 to fail if Leba does not beat both peers. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +LEBA_BIN="${LEBA_BIN:-${ROOT}/leba}" +SEC="${1:-8}" +CONC="${2:-40}" +REQUIRE_WIN="${LEBA_REQUIRE_WIN:-0}" +NGINX_IMAGE="${NGINX_IMAGE:-nginx:1.27-alpine}" +HAPROXY_IMAGE="${HAPROXY_IMAGE:-haproxy:2.9-alpine}" +TMP="$(mktemp -d /tmp/leba-proxy-matrix-XXXXXX)" + +ORIGIN_PORT="${ORIGIN_PORT:-$((21000 + ($$ % 1000)))}" +LEBA_PORT="${LEBA_PORT:-$((22000 + ($$ % 1000)))}" +STATS_PORT="${STATS_PORT:-$((23000 + ($$ % 1000)))}" +NGINX_PORT="${NGINX_PORT:-$((24000 + ($$ % 1000)))}" +HAPROXY_PORT="${HAPROXY_PORT:-$((25000 + ($$ % 1000)))}" +NGINX_NAME="${NGINX_NAME:-leba-bench-nginx-$$}" +HAPROXY_NAME="${HAPROXY_NAME:-leba-bench-haproxy-$$}" +DOCKER_READY=0 + +cleanup() { + if [ "$DOCKER_READY" = "1" ]; then + docker rm -f "$NGINX_NAME" "$HAPROXY_NAME" >/dev/null 2>&1 || true + fi + kill ${ORIGIN_PID:-} ${LEBA_PID:-} 2>/dev/null || true + wait ${ORIGIN_PID:-} ${LEBA_PID:-} 2>/dev/null || true + rm -rf "$TMP" +} +trap cleanup EXIT + +run_timeout() { + local seconds="$1" + shift + "$@" & + local pid=$! + local waited=0 + while kill -0 "$pid" 2>/dev/null; do + if [ "$waited" -ge "$seconds" ]; then + kill "$pid" 2>/dev/null || true + sleep 1 + kill -9 "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + return 124 + fi + sleep 1 + waited=$((waited + 1)) + done + wait "$pid" +} + +rss_kib() { + local pid="$1" + ps -o rss= -p "$pid" 2>/dev/null | tr -d ' ' || true +} + +docker_mem() { + local name="$1" + docker stats --no-stream --format '{{.MemUsage}}' "$name" 2>/dev/null | awk '{print $1 $2}' || true +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || { echo "missing required command: $1" >&2; exit 1; } +} + +require_cmd python3 +require_cmd curl +require_cmd docker + +if [ ! -x "$LEBA_BIN" ]; then + echo "missing Leba binary: $LEBA_BIN; run make build first" >&2 + exit 1 +fi + +echo "checking Docker..." +if ! run_timeout 8 docker info >/dev/null 2>&1; then + echo "Docker is not responsive; start Docker and retry" >&2 + exit 2 +fi +DOCKER_READY=1 + +ulimit -n 65536 2>/dev/null || true + +cat >"$TMP/origin.py" <<'PY' +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import sys + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + def do_GET(self): + body = b"ok\n" + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "keep-alive") + self.end_headers() + self.wfile.write(body) + def log_message(self, fmt, *args): + pass + +ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), Handler).serve_forever() +PY + +python3 "$TMP/origin.py" "$ORIGIN_PORT" >/tmp/leba_bench_origin.log 2>&1 & +ORIGIN_PID=$! + +cat >"$TMP/leba.conf" < app + +frontend stats + bind 127.0.0.1:${STATS_PORT} + mode stats + auth admin:benchpass:admin + +backend app + balance round_robin + server origin 127.0.0.1:${ORIGIN_PORT} weight 100 no_check +EOF + +cat >"$TMP/nginx.conf" <"$TMP/haproxy.cfg" </tmp/leba_bench_matrix.log 2>&1 & +LEBA_PID=$! + +echo "starting proxies..." +docker rm -f "$NGINX_NAME" "$HAPROXY_NAME" >/dev/null 2>&1 || true +run_timeout 30 docker run -d --name "$NGINX_NAME" \ + -p "127.0.0.1:${NGINX_PORT}:8080" \ + --add-host=host.docker.internal:host-gateway \ + -v "$TMP/nginx.conf:/etc/nginx/nginx.conf:ro" \ + "$NGINX_IMAGE" >/dev/null +run_timeout 30 docker run -d --name "$HAPROXY_NAME" \ + -p "127.0.0.1:${HAPROXY_PORT}:8080" \ + --add-host=host.docker.internal:host-gateway \ + -v "$TMP/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro" \ + "$HAPROXY_IMAGE" >/dev/null + +wait_ready() { + local label="$1" + local url="$2" + local i code + for i in $(seq 1 80); do + code="$(curl -s -o /dev/null -w "%{http_code}" --max-time 1 "$url" || true)" + if [ "$code" = "200" ]; then + return 0 + fi + sleep 0.1 + done + echo "not ready: $label $url" >&2 + tail -60 /tmp/leba_bench_matrix.log 2>/dev/null || true + docker logs "$NGINX_NAME" 2>/dev/null || true + docker logs "$HAPROXY_NAME" 2>/dev/null || true + exit 1 +} + +bench_one() { + local label="$1" + local url="$2" + python3 - "$label" "$url" "$SEC" "$CONC" <<'PY' +import concurrent.futures +import statistics +import sys +import time +import urllib.request + +label, url, raw_sec, raw_conc = sys.argv[1:5] +seconds = float(raw_sec) +conc = int(raw_conc) +deadline = time.perf_counter() + seconds +lat = [] +ok = 0 +fail = 0 + +def one(): + start = time.perf_counter() + try: + req = urllib.request.Request(url, headers={"Connection": "keep-alive"}) + with urllib.request.urlopen(req, timeout=3) as resp: + resp.read() + good = resp.status == 200 + except Exception: + good = False + elapsed = (time.perf_counter() - start) * 1000.0 + return good, elapsed + +with concurrent.futures.ThreadPoolExecutor(max_workers=conc) as ex: + pending = set() + while time.perf_counter() < deadline or pending: + while time.perf_counter() < deadline and len(pending) < conc: + pending.add(ex.submit(one)) + done, pending = concurrent.futures.wait( + pending, timeout=0.05, return_when=concurrent.futures.FIRST_COMPLETED + ) + for fut in done: + good, elapsed = fut.result() + if good: + ok += 1 + lat.append(elapsed) + else: + fail += 1 + +elapsed = seconds +lat.sort() +def pct(p): + if not lat: + return 0.0 + idx = min(len(lat) - 1, int((len(lat) - 1) * p)) + return lat[idx] +rps = ok / elapsed if elapsed > 0 else 0.0 +print(f"SCORE {label} rps={rps:.1f} ok={ok} fail={fail} p50_ms={pct(0.50):.2f} p99_ms={pct(0.99):.2f}") +PY +} + +wait_ready origin "http://127.0.0.1:${ORIGIN_PORT}/" +wait_ready leba "http://127.0.0.1:${LEBA_PORT}/" +wait_ready nginx "http://127.0.0.1:${NGINX_PORT}/" +wait_ready haproxy "http://127.0.0.1:${HAPROXY_PORT}/" + +echo "== proxy matrix ${SEC}s concurrency=${CONC} ==" +echo "origin=127.0.0.1:${ORIGIN_PORT} leba=${LEBA_PORT} nginx=${NGINX_PORT} haproxy=${HAPROXY_PORT}" + +bench_one leba "http://127.0.0.1:${LEBA_PORT}/" | tee "$TMP/leba.score" +bench_one nginx "http://127.0.0.1:${NGINX_PORT}/" | tee "$TMP/nginx.score" +bench_one haproxy "http://127.0.0.1:${HAPROXY_PORT}/" | tee "$TMP/haproxy.score" + +LEBA_RSS="$(rss_kib "$LEBA_PID")" +NGINX_MEM="$(docker_mem "$NGINX_NAME")" +HAPROXY_MEM="$(docker_mem "$HAPROXY_NAME")" +echo "RSS/MEM leba_kib=${LEBA_RSS:-unknown} nginx=${NGINX_MEM:-unknown} haproxy=${HAPROXY_MEM:-unknown}" + +leba_rps="$(awk -F'rps=' '/SCORE/ {split($2,a," "); print a[1]}' "$TMP/leba.score")" +nginx_rps="$(awk -F'rps=' '/SCORE/ {split($2,a," "); print a[1]}' "$TMP/nginx.score")" +haproxy_rps="$(awk -F'rps=' '/SCORE/ {split($2,a," "); print a[1]}' "$TMP/haproxy.score")" + +python3 - "$REQUIRE_WIN" "$leba_rps" "$nginx_rps" "$haproxy_rps" <<'PY' +import sys +require, leba, nginx, haproxy = sys.argv[1], *(float(x) for x in sys.argv[2:5]) +print(f"SUMMARY leba_vs_nginx={leba / nginx if nginx else 0:.2f}x leba_vs_haproxy={leba / haproxy if haproxy else 0:.2f}x") +if require == "1" and not (leba > nginx and leba > haproxy): + raise SystemExit("FAIL: LEBA_REQUIRE_WIN=1 but Leba did not beat both nginx and HAProxy") +PY + +echo "PASS"