TLS & HTTPS

TLS & HTTPS

TLS lives in its own bundle, tls.moon, rather than the standard library, so programs that do not speak TLS carry none of it. The bundle wraps OpenSSL and embeds its own static copy of it, so a program using TLS needs no linker flags and no OpenSSL installed on the machine it runs on.

That holds both for compiled programs (sun -c program.sun) and for running one straight from source: the JIT loads the same embedded archives the linker uses. See carrying the C library along for how the archives travel.

using std;
using tls;
 
/** Fetches a page and reports either error category. */
function main() i32 {
    var alloc = make_heap_allocator();
    var client = HttpsClient(alloc);
    var host = String(alloc, "example.com");
 
    return match client.get(host, "/") {
        ClientResult.Ok(response) => {
            println(response.status);
            println(response.body);
            0;
        },
        (error: const ref IError) => {
            println(error.message());
            1;
        }
    };
}
 
manifest {
    libraries: ["stdlib.moon", "tls.moon"]
}
sun client.sun              # run it
sun --compile -o client client.sun

TLS operations return ClientResult<T> with Ok(T), System(Error), or Tls(TlsError). Use specific variants, an interface arm as above, or _ to handle remaining variants. Prefix try extracts success and propagates failures into a compatible caller result. The stream examples below assume such a caller. The standalone HTTP response parser returns SystemResult<void>.

Certificate verification

Verification is on and cannot be switched off. A connection only succeeds if the peer's certificate chain validates against the trusted CA store and the certificate matches the hostname passed to connect. Anything else returns ClientResult.Tls, with the reason in the error message:

certificate verification failed: self-signed certificate
certificate verification failed: hostname mismatch

Trust comes from the system CA store (/etc/ssl). Two environment variables override it, which is how you test against a private CA:

VariableMeaning
SSL_CERT_FILEA PEM file of trusted certificates
SSL_CERT_DIRA directory of trusted certificates

The client also sends SNI, so it works with virtual hosts.

HttpsClient

get and post open a connection, exchange one request, and close it. Both take the host as a String and the path as a literal; both have an overload taking a port, which defaults to 443.

public function get(host: ref String, path: static_ptr<u8>) ClientResult<HttpClientResponse>
public function get(host: ref String, port: i32, path: static_ptr<u8>) ClientResult<HttpClientResponse>
public function post(host: ref String, path: static_ptr<u8>, content_type: static_ptr<u8>,
                     body: const ref String) ClientResult<HttpClientResponse>

The response carries the status, the raw header block, and the decoded body:

public class HttpClientResponse {
    public var status: i32;
    public var headers: String;
    public var body: String;
}

Bodies framed with Content-Length and with chunked transfer encoding are both decoded. parse_response is public, so code driving a TlsStream directly can reuse the same framing.

This first version does not follow redirects, reuse connections, or decode compressed bodies. A 301 or 302 comes back as-is for the caller to act on.

TlsStream

For protocols other than HTTP, TlsStream is the connection itself — the same shape as TcpStream, with the handshake done in connect.

var stream = TlsStream();
try stream.connect(host, 443);
try stream.send_str("PING\r\n");
 
var buf = ContiguousBuffer<u8>(alloc, 4096);
var n = try stream.recv(buf);   // 0 once the peer closes
stream.close();

close shuts the session down and releases everything; dropping the value does the same.

Server TLS

TlsServerContext loads a PEM certificate chain and matching, unencrypted PEM private key. It requires TLS 1.2 or newer. A failed replacement load leaves the previous configuration usable, and sessions already accepted retain their original configuration.

Accept TCP connections with TcpListener, then transfer each socket to the context. accept makes the socket nonblocking and returns a TlsServerStream; it does not run the handshake or wait for network traffic.

var context = TlsServerContext();
var cert = String("cert.pem");
var key = String("key.pem");
try context.load(cert, key);
 
var listener = TcpListener();
try listener.bind_port(8443);
try listener.listen(128);
var stream = try context.accept(try listener.accept());

Setup uses ClientResult<T>, the bundle's existing transport/TLS error result. The snippet assumes a caller returning a compatible result. For an event-driven accept loop, also set the TcpListener nonblocking and handle its would-block errors with is_would_block.

Drive stream.handshake() until it returns ServerResult.Ok(0), then use read(buffer) and write(string). These operations return ServerResult<i64>:

ResultMeaning
Ok(n)Handshake complete, or application bytes read/written
WantReadWait for readability on get_fd(), then retry the operation
WantWriteWait for writability on get_fd(), then retry the operation
ClosedThe peer sent a TLS close notification, or shutdown finished
Tls(error)TLS/transport, state, or argument error, with an explanation

Either I/O operation can require either readiness direction. After a write returns a readiness result, retry with the same contents and length; the string's storage may move. Successful writes can be partial: advance by the returned count before starting the next write. pending() reports decrypted bytes already buffered inside OpenSSL; consume those without waiting for another socket event. Empty reads and writes return Ok(0) after the handshake.

shutdown() performs a nonblocking close-notification exchange; continue on readiness results until it returns Closed. close() and destruction release the session and socket immediately, without network I/O. Abrupt TCP EOF is an error, not a clean TLS close. Server socket writes suppress SIGPIPE without changing process-wide signal handlers.

Application protocol negotiation

The optional third argument to load is a static, length-prefixed ALPN list, in server preference order:

try context.load(cert, key, "\x02h2\x08http/1.1");

Each length byte must be nonzero and followed by exactly that many protocol bytes. Names containing NUL are unsupported. Static storage keeps the callback valid even after the context is moved or closed, without copying the list per connection. stream.alpn() returns an owned string with the selected name, or an empty string when the client offers no matching protocol. Advertise only protocols your application implements. TLS does not implement HTTP/2 itself.

The server API handles TLS transport; HttpServer does not yet use it directly.

Hostnames

Name resolution is in the standard library, not the TLS bundle, so plain TCP can use it too:

using std;
 
var stream = TcpStream();
try stream.connect_host(host, 8080);          // resolve, then connect
 
var ip: i32 = try resolve_host_ipv4(host);    // first A record, network order

Both return SystemResult.Error if the name does not resolve or has no IPv4 address. IPv6 is not supported yet.

Building the bundle

tls.moon embeds libssl.a and libcrypto.a, which the repository does not carry. Build them once with the script for your platform, then build as usual:

./scripts/fetch-openssl.sh          # Linux
./scripts/build-openssl-macos.sh    # macOS (Apple Silicon)
./build.sh

Debian packages also include AArch64 Linux TLS and standard-library bundles. The compiler selects them automatically with --target aarch64-linux-gnu or --target aarch64-linux-musl. Linking an executable still requires a cross-linker and target system libraries.

To build these bundles locally, fetch AArch64 archives with ./scripts/fetch-openssl.sh --arch aarch64, configure CMake with -DSUN_CROSS_TLS_AARCH64=ON, and build the tls_cross_moon target. The workspace root sun-config.json compiles the standard library first, then TLS, in one compiler invocation. The archives are kept separately from the native archives.

On Linux the archives come from Alpine's openssl-libs-static package: Alpine is musl-based, matching the toolchain Sun links static binaries with, and builds OpenSSL without the optional compression and jitter-entropy backends, so the archives depend on nothing outside themselves. On macOS there is no such package, so the script compiles a pinned OpenSSL release with the same self-containment rule. Without the archives the build skips tls.moon and says so.

On macOS the archives look for trusted certificates in /etc/ssl, where stock macOS keeps its CA bundle (/etc/ssl/cert.pem), so verification works with no setup; SSL_CERT_FILE still overrides the store on both platforms.

The manifest names the archives per operating system, and the bundle carries the set matching the compilation target:

manifest {
    source_files: ["openssl.sun", "tls_stream.sun", "tls_server.sun", "tls_socket.sun", "https_client.sun"]
    libraries: ["stdlib.moon"]
    target: {
        linux: {
            archives: ["$OPENSSL_LIBS_LINUX/libssl.a", "$OPENSSL_LIBS_LINUX/libcrypto.a"]
        }
        macos: {
            archives: ["$OPENSSL_LIBS_MACOS/libssl.a", "$OPENSSL_LIBS_MACOS/libcrypto.a"]
        }
    }
}

archives: works for any bundle wrapping a C library. Importers link against what the bundle carries automatically — no -l flags, and nothing to install alongside the finished program. A bundle built on tls.moon carries the archives forward with the tls code it links in, so a program importing only that bundle still links. The OpenSSL symbols inside are renamed under a hash of the two archives together, so a program may import two bundles built on different OpenSSL builds, each bound to its own, while bundles sharing one build share one copy; see C FFI.

Tests

The bundle's own tests are Sun code, tls/https_client_tests.sun and tls/tls_server_tests.sun, listed under test_files: in tls/tls.sun (see Testing) and compiled only into the test binary, build/tls_tests_test. Response parsing runs with no network; the handshake tests mint a throwaway certificate with the openssl command-line tool, run openssl s_server on it, and check that the client accepts the certificate once SSL_CERT_FILE trusts it and rejects it otherwise. They run sequentially because that variable is process-wide, and skip themselves when the openssl tool is not on the path.

Server tests use local nonblocking OpenSSL peers to check handshake progress, ALPN preference and fallback, partial writes and backpressure, credential load failures, context lifetime, and clean versus abrupt closure. They need the openssl command-line tool only to create temporary credentials.