Design: v0.6 in-process TLS (`https://`) and WSS (`wss://`)
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.upgradework 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)→SSLListenerlistener.accept()/accept_with_timeout→&SSLConnSSLConnhasread,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:
| Method | TCP | SSL | buffered |
|---|---|---|---|
read | tcp + rbuf | ssl + rbuf | rbuf only |
write / write_all | tcp | ssl | error |
close | tcp | ssl | mark closed |
set_read_timeout | tcp | ssl | no-op |
set_write_timeout | tcp | no-op (mbedtls has no write deadline API) | no-op |
peer_ip | tcp | ssl.peer_addr() / stored ip | error |
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, spawnhandle_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_fileandkey_filerequired; empty or unusable → error before listenlistenbehavior unchanged externally (internal Conn wrap is transparent)- Cipher/version policy = mbedtls / V stdlib defaults; no custom crypto surface
- In-memory PEM (
cert/keyas 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
| Path | Role |
|---|---|
engine/conn.v | Dual transport |
engine/engine.v | Conn-based HTTP loop; cleartext uses wrap at accept |
engine/tls.v | TlsOptions, TLS accept loop |
engine/tls_test.v | HTTPS + failure modes + WSS if co-located |
viltrum.v | Export TlsOptions, listen_tls |
examples/https_hello/ | Minimal HTTPS |
examples/wss_echo/ | HTTPS + app.ws |
scripts/dev-cert.sh | Self-signed dev cert (openssl); document only |
docs/tls.md | User-facing TLS docs |
docs/deploy.md, docs/ws.md, README.md, ROADMAP.md | Status updates |
docs/design/v0.6-tls-wss.md | This design (implementation contract) |
Tests (minimum)
- Self-signed HTTPS GET → 200 (client with validation disabled)
- Missing/bad key path →
listen_tlsreturns error - Plain TCP/HTTP client to TLS port → clean fail (no panic)
- WSS: text echo over TLS upgrade
- 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
- Refactor cleartext to Conn-first —
read_message/handle_conn/write_*onConn; keep all existing tests green Conn.wrap_ssl+ TLS dispatch — dual transport without listen yet; unit tests with buffered where possiblelisten_and_serve_tls_full+TlsOptions— accept loop + smoke testapp.listen_tls— facade- Examples +
scripts/dev-cert.sh - WSS verification —
examples/wss_echo+ test - Docs + ROADMAP checkboxes
Do not ship OpenSSL dual-backend or ACME in this PR.
7. Risks
| Risk | Mitigation |
|---|---|
| Conn refactor breaks cleartext | Land step 1 with full existing suite green before TLS |
| No SSL write timeout | Document; rely on read timeout + OS TCP |
| Handshake flood | Log + continue; honor max_conns |
| Version already at 0.6.0 via prior release | Feature lands as docs/feat on current version line; changelog describes real TLS when tagged |
| Double-close on SSL after upgrade | Same pattern as TCP: hijack flag; ws.Socket holds &Conn |
8. Exit criteria (close #1 / roadmap 0.6)
-
v run examples/https_helloworks withscripts/dev-cert.shoutput -
v run examples/wss_echo(or dedicated wss example) over TLS - TLS + WSS tests green in CI
-
docs/tls.mdpublished; README: optional TLS, proxy still fine - ROADMAP v0.6a/v0.6b checkboxes updated
9. Decisions log
| Decision | Choice |
|---|---|
| Approach | A — Conn is the single I/O surface |
| Backend | net.mbedtls only |
| API | Parallel listen_tls + TlsOptions (not flag on listen) |
| WSS | Same PR as HTTPS when path is thin |
| mTLS / ACME / hot reload | Out of scope |
| Spec location | docs/design/ (not superpowers local state) |