///|
/// TLS certificate material for HTTPS serving (← uvicorn's `ssl_certfile` /
/// `ssl_keyfile`). The backend the `moonbitlang/async` TLS layer uses is
/// platform-specific, so both forms are carried:
///
/// * `certificate_file` + `private_key_file` — PEM files, used by the OpenSSL
/// backend (Linux / macOS, the CI platforms);
/// * `pfx_file` — a PKCS#12 bundle, used by the SChannel backend (Windows).
///
/// `serve_tls` picks the right pair at compile time. Supply whichever your
/// deployment targets; a self-signed `certs/` pair for tests is generated with
/// `scripts/gen_test_cert.sh`.
pub(all) struct TlsCert {
certificate_file : String
private_key_file : String
pfx_file : String
}
///|
/// Wrap an accepted TCP connection in a TLS server session on the OpenSSL
/// backend, reading the certificate/key from PEM files.
///
/// The async library marks its TLS-server constructor `#internal` ("for
/// internal testing only") — it is the only server-side TLS entry point it
/// exposes, and its own test suite drives HTTPS through exactly this call. The
/// package opts in via `warnings = "-alert_internal"` in `moon.pkg`; when the
/// library graduates a public TLS-server API mooncat will move to it. This is
/// the sole such opt-in and is the documented TLS boundary (README §TLS).
#cfg(not(platform="windows"))
async fn tls_accept(conn : @socket.Tcp, cert : TlsCert) -> @tls.Tls {
@tls.Tls::server_from_pair(
conn,
conn,
private_key_file=cert.private_key_file,
private_key_type=PEM,
certificate_file=cert.certificate_file,
certificate_type=PEM,
)
}
///|
/// Wrap an accepted TCP connection in a TLS server session on the SChannel
/// backend (Windows), reading the certificate + key from a PKCS#12 bundle.
/// See `tls_accept` (OpenSSL) for the `#internal` opt-in rationale.
#cfg(platform="windows")
async fn tls_accept(conn : @socket.Tcp, cert : TlsCert) -> @tls.Tls {
@tls.Tls::server_from_pair(conn, conn, pfx_file=cert.pfx_file)
}
///|
/// Handle one accepted TCP connection as an HTTPS connection: perform the TLS
/// handshake, then loop the self-built HTTP/1.1 codec (`parse_http1_request` +
/// `dispatch_http1`) over the encrypted stream, honouring keep-alive, until the
/// peer closes or asks to close. The TLS session is always torn down on the way
/// out. A handshake or I/O failure raises and is contained by the accept loop's
/// `allow_failure`, exactly as a bad plaintext connection would be.
async fn handle_https_conn(
app : @moonasgi.AsgiApp,
conn : @socket.Tcp,
cert : TlsCert,
) -> Unit {
let tls = tls_accept(conn, cert)
defer tls.close()
for ;; {
guard parse_http1_request(tls) is Some(req) else { break }
let keep = dispatch_http1(app, req, tls, tls, scheme="https")
if !keep {
break
}
}
}
///|
/// Serve a moonasgi ASGI application over native HTTP/1.1 **over TLS** (HTTPS,
/// ← uvicorn's `--ssl-certfile`/`--ssl-keyfile`). Convenience wrapper over
/// `serve_tls_config` that builds the `Config` from `host`/`port`/`backlog` and
/// the certificate paths.
///
/// Runs the full ASGI lifespan protocol around the accept loop (startup before
/// the listener binds, shutdown on exit — even under cancellation), then, for
/// every accepted connection, completes a TLS handshake and drives the app
/// through the encrypted HTTP/1.1 codec.
///
/// WebSocket-over-TLS (`wss://`) is not bridged here: the async websocket
/// upgrade requires an `@http.ServerConnection`, which is welded to
/// `@socket.Tcp` and cannot wrap a `@tls.Tls` stream. Plaintext `serve` retains
/// full WebSocket support; this is a transport-capability boundary, not a
/// behavioural choice (README §TLS).
pub async fn serve_tls(
app : @moonasgi.AsgiApp,
certificate_file~ : String,
private_key_file~ : String,
pfx_file~ : String,
host? : String = "127.0.0.1",
port? : Int = 8443,
backlog? : Int = 2048,
) -> Unit {
serve_tls_config(app, Config::new(host~, port~, backlog~), {
certificate_file,
private_key_file,
pfx_file,
})
}
///|
/// Serve a moonasgi ASGI application over HTTPS under an explicit `Config` and
/// `TlsCert`. Mirrors `serve_config`: lifespan startup is driven before the
/// listener binds and shutdown on the way out (guarded by `protect_from_cancel`
/// so it still runs when the serving task is cancelled), and each accepted
/// connection is handled by `handle_https_conn`.
pub async fn serve_tls_config(
app : @moonasgi.AsgiApp,
config : Config,
cert : TlsCert,
) -> Unit {
@async.with_task_group(g => {
let lifespan = Lifespan::new(app)
let task = lifespan.spawn(g)
lifespan.startup(task)
let server = @socket.TcpServer(
@socket.Addr::parse(config.bind()),
dual_stack=config.dual_stack,
reuse_addr=config.reuse_addr,
)
try
server.run_forever(
(conn, _addr) => handle_https_conn(app, conn, cert),
allow_failure=config.allow_failure,
max_connections?=config.max_connections,
)
catch {
err => {
@async.protect_from_cancel(() => {
lifespan.shutdown(task) catch {
_ => ()
}
})
server.close()
raise err
}
} noraise {
_ => {
@async.protect_from_cancel(() => {
lifespan.shutdown(task) catch {
_ => ()
}
})
server.close()
}
}
})
}
///|
/// Self-signed localhost material for the integration tests below, generated by
/// `scripts/gen_test_cert.sh`. PEM for OpenSSL platforms, PKCS#12 for Windows.
fn test_cert() -> TlsCert {
{
certificate_file: "certs/cert.pem",
private_key_file: "certs/key.pem",
pfx_file: "certs/dev.pfx",
}
}
///|
/// Real HTTPS round-trip: a genuine `@http` TLS client performs `GET /` over an
/// encrypted connection to a `serve_tls`-served app and observes `200` + the
/// exact body. Proves the TLS handshake, the self-built HTTP/1.1 codec over the
/// `@tls.Tls` stream, and the moonasgi SEAM all cooperate end to end.
async test "https: real TLS client GET returns 200 with body" {
@async.with_task_group(g => {
let app : @moonasgi.AsgiApp = (_scope, _receive, send) => {
send(
@moonasgi.Event::HttpResponseStart(
status=200,
headers=[("content-type", "text/plain")],
trailers=false,
),
)
send(
@moonasgi.Event::HttpResponseBody(
body=b"secure hello over tls",
more_body=false,
),
)
}
let task = g.spawn(() => {
serve_tls_config(app, Config::new(port=18443), test_cert())
})
@async.sleep(400)
let client = @http.Client::Client(
"https://127.0.0.1:18443",
trust=NoVerification,
)
let resp = client.get("/")
assert_eq(resp.code, 200)
assert_eq(client.read_all().text(), "secure hello over tls")
client.close()
task.cancel()
})
}