Design: v0.6 in-process TLS (`https://`) and WSS (`wss://`)

← Viltrum

Status: implemented (PR #15)
Tracks: #1 · ROADMAP.md v0.6
Branch: feat/v06-tls-wss
Date: 2026-07-24 (impl 2026-07-28)

This document is the implementation contract for the first TLS/WSS ship. Use-case tracker #1 closes when this design ships.


1. Goal

In-process TLS for single-binary demos and simple deploys. WSS is TLS listener + existing v0.5 WebSocket code on the same Conn story. Reverse-proxy TLS stays first-class forever.

Success

  • app.listen_tls(addr, TlsOptions{...})! serves HTTPS/1.1
  • Existing app.ws / app.upgrade work over that stream without a second WS stack
  • Examples: examples/https_hello, examples/wss_echo
  • Tests: HTTPS smoke, bad cert fail, plain client to TLS port fails cleanly, WSS echo
  • Docs: docs/tls.md + updates to deploy/ws/README/ROADMAP

Non-goals (v0.6)

  • ACME / Let's Encrypt inside Viltrum
  • mTLS / client cert auth
  • Hot-reload certs
  • HTTP/2, ALPN multiplex beyond default stdlib behavior
  • Competing as an edge TLS terminator
  • OpenSSL server path (-d use_openssl)

2. Spike result (stdlib)

Go. V 0.5.x ships a working server TLS path via net.mbedtls:

  • mbedtls.new_ssl_listener(saddr, SSLConnectConfig) → SSLListener
  • listener.accept() / accept_with_timeout → &SSLConn
  • SSLConn has read, write, close, set_read_timeout, peer_addr

V's own net.http.Server TLS uses the same mbedtls listener. OpenSSL server listen is explicitly not supported there; we match that: mbedtls only for v0.6.


3. Architecture (approach A)

After accept, all server I/O goes through engine.Conn. Cleartext and TLS share one HTTP/upgrade loop.

listen(addr)
  TcpListener.accept → Conn.wrap(tcp)     → handle_conn

listen_tls(addr, tls)
  SSLListener.accept → Conn.wrap_ssl(ssl) → handle_conn
                                              │
                          HTTP keep-alive  or  upgrade → app.ws / app.upgrade

WSS = listen_tls + existing app.ws. No second frame codec.

Conn transport

enum ConnKind {
	tcp
	ssl
	buffered // tests / pushback-only
}

pub struct Conn {
mut:
	kind   ConnKind
	tcp    net.TcpConn
	ssl    &mbedtls.SSLConn = unsafe { nil }
	rbuf   []u8
	closed bool
}

Public methods dispatch on kind:

MethodTCPSSLbuffered
readtcp + rbufssl + rbufrbuf only
write / write_alltcpsslerror
closetcpsslmark closed
set_read_timeouttcpsslno-op
set_write_timeouttcpno-op (mbedtls has no write deadline API)no-op
peer_iptcpssl.peer_addr() / stored iperror

handle_conn and read_message take mut Conn (today they take net.TcpConn). Cleartext path constructs Conn.wrap immediately after accept so one code path remains.

TLS listen glue

New file engine/tls.v (or equivalent):

  • Validate TlsOptions (non-empty cert/key paths, files readable where practical)
  • mbedtls.new_ssl_listener(addr, SSLConnectConfig{ cert, cert_key, validate: false, read_timeout: ... })
  • Accept loop mirrors cleartext: signals, max_conns, spawn handle_conn
  • Log line: listening on https://${addr}

Handshake failures: log and continue (do not tear down the listener).


4. Public API

// engine / re-exported from viltrum
pub struct TlsOptions {
pub:
	// PEM certificate file path (required).
	cert_file string
	// PEM private key file path (required).
	key_file string
}

// App facade
pub fn (mut app App) listen_tls(addr string, tls TlsOptions) !

// Engine (tests + advanced)
pub fn listen_and_serve_tls_full(
	addr string,
	handler Handler,
	upgrades []UpgradeRoute,
	opts ServerOptions,
	tls TlsOptions,
) !

Rules

  • cert_file and key_file required; empty or unusable → error before listen
  • listen behavior unchanged externally (internal Conn wrap is transparent)
  • Cipher/version policy = mbedtls / V stdlib defaults; no custom crypto surface
  • In-memory PEM (cert / key as strings) is optional stretch; ship file paths first. Add only if tests need temp PEM without disk complexity (tests may write temp files)

Happy path

mut app := new()
app.get('/', fn (req Request) Response {
	return text(200, 'ok\n')
})
app.ws('/ws', fn (mut s WsSocket) {
	for {
		msg := s.read_message() or { break }
		if msg.is_text() {
			s.write_text(msg.text()) or { break }
		}
	}
	s.close_quiet()
})
app.listen_tls('127.0.0.1:8443', TlsOptions{
	cert_file: 'certs/dev.crt'
	key_file:  'certs/dev.key'
})!

5. Deliverables

PathRole
engine/conn.vDual transport
engine/engine.vConn-based HTTP loop; cleartext uses wrap at accept
engine/tls.vTlsOptions, TLS accept loop
engine/tls_test.vHTTPS + failure modes + WSS if co-located
viltrum.vExport TlsOptions, listen_tls
examples/https_hello/Minimal HTTPS
examples/wss_echo/HTTPS + app.ws
scripts/dev-cert.shSelf-signed dev cert (openssl); document only
docs/tls.mdUser-facing TLS docs
docs/deploy.md, docs/ws.md, README.md, ROADMAP.mdStatus updates
docs/design/v0.6-tls-wss.mdThis design (implementation contract)

Tests (minimum)

  1. Self-signed HTTPS GET → 200 (client with validation disabled)
  2. Missing/bad key path → listen_tls returns error
  3. Plain TCP/HTTP client to TLS port → clean fail (no panic)
  4. WSS: text echo over TLS upgrade
  5. Existing suites green: v test http/ router/ engine/ ws/

CI

V bundles mbedtls; no extra system package required for default builds. Dev cert script optional for humans; tests generate temp PEMs or use scripted openssl in the test setup when available.


6. Implementation order

  1. Refactor cleartext to Conn-first — read_message / handle_conn / write_* on Conn; keep all existing tests green
  2. Conn.wrap_ssl + TLS dispatch — dual transport without listen yet; unit tests with buffered where possible
  3. listen_and_serve_tls_full + TlsOptions — accept loop + smoke test
  4. app.listen_tls — facade
  5. Examples + scripts/dev-cert.sh
  6. WSS verification — examples/wss_echo + test
  7. Docs + ROADMAP checkboxes

Do not ship OpenSSL dual-backend or ACME in this PR.


7. Risks

RiskMitigation
Conn refactor breaks cleartextLand step 1 with full existing suite green before TLS
No SSL write timeoutDocument; rely on read timeout + OS TCP
Handshake floodLog + continue; honor max_conns
Version already at 0.6.0 via prior releaseFeature lands as docs/feat on current version line; changelog describes real TLS when tagged
Double-close on SSL after upgradeSame pattern as TCP: hijack flag; ws.Socket holds &Conn

8. Exit criteria (close #1 / roadmap 0.6)

  • v run examples/https_hello works with scripts/dev-cert.sh output
  • v run examples/wss_echo (or dedicated wss example) over TLS
  • TLS + WSS tests green in CI
  • docs/tls.md published; README: optional TLS, proxy still fine
  • ROADMAP v0.6a/v0.6b checkboxes updated

9. Decisions log

DecisionChoice
ApproachA — Conn is the single I/O surface
Backendnet.mbedtls only
APIParallel listen_tls + TlsOptions (not flag on listen)
WSSSame PR as HTTPS when path is thin
mTLS / ACME / hot reloadOut of scope
Spec locationdocs/design/ (not superpowers local state)