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 ServerSection config
  • 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":

  1. On startup, check SQLite cert store for valid certs matching configured domains
  2. 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)
  3. Store cert + key in encrypted SQLite (reuse existing secrets infrastructure)
  4. Background renewal — check daily, renew at 30 days before expiry
  5. Hot reload — swap cert in rustls ServerConfig without restart via Arc<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 serve with tls = "auto" obtains and serves a valid Let's Encrypt cert
  • bext serve with tls = "self-signed" works with zero config
  • Certs auto-renew without restart
  • SNI routes correct cert per app domain
  • bext cert list/info/renew CLI commands work
  • OCSP stapling active by default
  • HTTP→HTTPS redirect on port 80
  • All tests passing