Phase 1: Automatic TLS / HTTPS
Goal
bext serve provisions and renews TLS certificates automatically. Setting a domain name is all that's needed — no certbot, no manual cert files, no reverse proxy.
Current State
HttpServer::bind()on plain TCP (no TLS)- No certificate paths in
ServerSectionconfig - Relies on nginx or Cloudflare for TLS termination
- SSRF prevention exists (private IP blocking) but no TLS-level security
Design
Three TLS Modes
[server]
listen = "0.0.0.0:443"
# Mode 1: Automatic (Let's Encrypt / ZeroSSL)
tls = "auto"
# Mode 2: Self-signed (dev mode, auto-generated on startup)
tls = "self-signed"
# Mode 3: Manual (bring your own certs)
[server.tls]
cert = "/path/to/fullchain.pem"
key = "/path/to/privkey.pem"
# Mode 4: Off (plain HTTP, current default)
tls = "off"
Auto Mode (ACME)
When tls = "auto":
- On startup, check SQLite cert store for valid certs matching configured domains
- If missing or expiring (<30 days), initiate ACME challenge:
- HTTP-01 challenge (serve
/.well-known/acme-challenge/on port 80) - TLS-ALPN-01 challenge (serve on port 443, no port 80 needed)
- HTTP-01 challenge (serve
- Store cert + key in encrypted SQLite (reuse existing secrets infrastructure)
- Background renewal — check daily, renew at 30 days before expiry
- Hot reload — swap cert in rustls
ServerConfigwithout restart viaArc<ArcSwap>
SNI for Multi-App
Each app in platform.toml can have different domains:
[apps.marketing]
domains = ["example.com", "www.example.com"]
[apps.api]
domains = ["api.example.com"]
The TLS layer uses SNI (Server Name Indication) to select the correct cert per connection. Certs are provisioned per unique domain, shared across apps if domains overlap.
Self-Signed Mode
For tls = "self-signed":
- Generate EC P-256 self-signed cert on startup via
rcgen - Valid for 365 days, regenerated if expired
- Prints fingerprint to console so developer can trust it
- No ACME, no network calls
Implementation
New Crate: bext-tls
bext-tls/
src/
lib.rs # Public API
acme.rs # ACME client (HTTP-01 + TLS-ALPN-01)
store.rs # SQLite cert store (encrypted)
resolver.rs # SNI-based cert resolver
self_signed.rs # Self-signed cert generator
renewal.rs # Background renewal task
config.rs # TLS config parsing
Key Dependencies
| Crate | Purpose |
|---|---|
rustls |
TLS implementation (pure Rust, no OpenSSL) |
rustls-acme |
ACME protocol client with rustls integration |
rcgen |
Self-signed certificate generation |
tokio-rustls |
Async TLS acceptor for actix-web |
webpki |
Certificate validation |
Server Binding Changes (bext-server)
// Current
HttpServer::new(app).bind(addr)?
// New
match tls_config.mode {
TlsMode::Off => HttpServer::new(app).bind(addr)?,
TlsMode::Auto | TlsMode::Manual => {
let rustls_config = build_rustls_config(&tls_config, &cert_store).await?;
HttpServer::new(app).bind_rustls_0_23(addr, rustls_config)?
}
TlsMode::SelfSigned => {
let rustls_config = build_self_signed_config()?;
HttpServer::new(app).bind_rustls_0_23(addr, rustls_config)?
}
}
HTTP → HTTPS Redirect
When TLS is enabled, also bind port 80 and redirect all traffic to HTTPS:
// Redirect server on :80
HttpServer::new(|| {
App::new()
.route("/.well-known/acme-challenge/{token}", web::get().to(acme_challenge))
.default_service(web::to(redirect_to_https))
})
.bind("0.0.0.0:80")?
OCSP Stapling
Fetch OCSP response from CA and staple it to TLS handshake. Refreshed every 12 hours. Reduces client-side OCSP lookup latency.
Cert Store Schema
Reuse existing SQLite infrastructure (same DB as app registry, secrets):
CREATE TABLE IF NOT EXISTS tls_certificates (
id TEXT PRIMARY KEY,
domain TEXT NOT NULL,
cert_pem BLOB NOT NULL, -- encrypted
key_pem BLOB NOT NULL, -- encrypted
ca_pem BLOB, -- encrypted, intermediate chain
not_before TEXT NOT NULL,
not_after TEXT NOT NULL,
acme_account_id TEXT,
created_at TEXT NOT NULL,
renewed_at TEXT
);
CREATE INDEX idx_tls_domain ON tls_certificates(domain);
Config Reference
[server]
listen = "0.0.0.0:443"
tls = "auto" # auto | self-signed | off
[server.tls]
# Manual mode only:
cert = "/path/to/fullchain.pem"
key = "/path/to/privkey.pem"
# Auto mode tuning:
acme_email = "admin@example.com" # Let's Encrypt account email
acme_ca = "letsencrypt" # letsencrypt | zerossl | custom URL
challenge = "tls-alpn-01" # http-01 | tls-alpn-01
renew_before_days = 30 # Renew when < N days remaining
ocsp_stapling = true # Enable OCSP stapling
# Protocol constraints:
min_version = "1.2" # Minimum TLS version (1.2 or 1.3)
CLI Integration
bext cert list # List all managed certificates
bext cert info example.com # Show cert details (expiry, issuer, SANs)
bext cert renew example.com # Force renewal
bext cert import --cert x.pem --key x.key # Import manual cert
Testing Plan
| Test | Type | What it validates |
|---|---|---|
| Self-signed cert generation | Unit | rcgen produces valid cert, rustls accepts it |
| Cert store CRUD | Unit | Encrypt/decrypt round-trip, domain lookup |
| SNI resolver | Unit | Correct cert returned per hostname |
| ACME HTTP-01 challenge | Integration | Challenge token served, cert obtained (staging CA) |
| HTTP→HTTPS redirect | Integration | Port 80 redirects to 443 |
| Cert renewal | Unit | Renewal triggered at threshold, hot-swapped |
| Multi-domain platform | Integration | Different certs for different apps |
| OCSP stapling | Unit | OCSP response attached to handshake |
Risks & Mitigations
| Risk | Mitigation |
|---|---|
| Port 80/443 requires root on Linux | Document setcap or systemd socket activation |
| ACME rate limits | Use staging CA for dev/test, cache certs aggressively |
| DNS not pointing to server yet | Graceful fallback: log warning, serve self-signed until DNS resolves |
| Certificate key security | Encrypted at rest in SQLite, key derived from BEXT_SECRET env var |
Done Criteria
-
bext servewithtls = "auto"obtains and serves a valid Let's Encrypt cert -
bext servewithtls = "self-signed"works with zero config - Certs auto-renew without restart
- SNI routes correct cert per app domain
-
bext cert list/info/renewCLI commands work - OCSP stapling active by default
- HTTP→HTTPS redirect on port 80
- All tests passing