diff --git a/src/manifest/orchestrator/startup_runtime.zig b/src/manifest/orchestrator/startup_runtime.zig index e7b321b1..67f319c3 100644 --- a/src/manifest/orchestrator/startup_runtime.zig +++ b/src/manifest/orchestrator/startup_runtime.zig @@ -210,7 +210,7 @@ pub fn refreshServiceRuntimeBindings( const reg = backend_registry orelse return; const target = tlsBackendTargetForService(alloc, svc, tls.domain, record.ip_address) orelse return; defer alloc.free(target.ip); - reg.register(tls.domain, target.ip, target.port) catch { + reg.register(tls.domain, target.ip, target.port, tls.peer) catch { log.warn("failed to refresh backend for {s}", .{tls.domain}); return; }; @@ -317,7 +317,7 @@ fn registerTlsBackends( const target = tlsBackendTargetForService(alloc, svc, tls.domain, record.ip_address) orelse continue; defer alloc.free(target.ip); - reg.register(tls.domain, target.ip, target.port) catch { + reg.register(tls.domain, target.ip, target.port, tls.peer) catch { log.warn("failed to register backend for {s}", .{tls.domain}); continue; }; diff --git a/src/tls/backend.zig b/src/tls/backend.zig index 3ae67186..a5eeacc3 100644 --- a/src/tls/backend.zig +++ b/src/tls/backend.zig @@ -8,10 +8,15 @@ // and the orchestrator lifecycle run on different threads. const std = @import("std"); +const spec = @import("../manifest/spec.zig"); pub const Backend = struct { ip: []const u8, port: u16, + /// service-to-service mTLS posture for inbound traffic to this + /// backend's service. `.off` keeps the legacy (TLS-terminate only) + /// behavior; `.warn` and `.require` flip the listener to mTLS. + peer_mode: spec.TlsConfig.PeerMode = .off, }; pub const BackendRegistry = struct { @@ -37,7 +42,13 @@ pub const BackendRegistry = struct { } /// register a backend for a domain. overwrites any existing mapping. - pub fn register(self: *BackendRegistry, domain: []const u8, ip: []const u8, port: u16) !void { + pub fn register( + self: *BackendRegistry, + domain: []const u8, + ip: []const u8, + port: u16, + peer_mode: spec.TlsConfig.PeerMode, + ) !void { self.mutex.lockUncancelable(std.Options.debug_io); defer self.mutex.unlock(std.Options.debug_io); @@ -45,7 +56,7 @@ pub const BackendRegistry = struct { if (self.backends.getEntry(domain)) |entry| { self.allocator.free(entry.value_ptr.ip); const new_ip = try self.allocator.dupe(u8, ip); - entry.value_ptr.* = .{ .ip = new_ip, .port = port }; + entry.value_ptr.* = .{ .ip = new_ip, .port = port, .peer_mode = peer_mode }; return; } @@ -54,7 +65,7 @@ pub const BackendRegistry = struct { const owned_ip = try self.allocator.dupe(u8, ip); errdefer self.allocator.free(owned_ip); - try self.backends.put(self.allocator, owned_domain, .{ .ip = owned_ip, .port = port }); + try self.backends.put(self.allocator, owned_domain, .{ .ip = owned_ip, .port = port, .peer_mode = peer_mode }); } /// remove a backend for a domain. @@ -86,6 +97,7 @@ pub const BackendRegistry = struct { return .{ .ip = try alloc.dupe(u8, backend.ip), .port = backend.port, + .peer_mode = backend.peer_mode, }; } }; @@ -97,7 +109,7 @@ test "register and lookup" { var reg = BackendRegistry.init(alloc); defer reg.deinit(); - try reg.register("example.com", "10.42.0.5", 8080); + try reg.register("example.com", "10.42.0.5", 8080, .off); const backend = reg.lookup("example.com"); try std.testing.expect(backend != null); @@ -118,8 +130,8 @@ test "register overwrites existing" { var reg = BackendRegistry.init(alloc); defer reg.deinit(); - try reg.register("example.com", "10.42.0.5", 8080); - try reg.register("example.com", "10.42.0.10", 9090); + try reg.register("example.com", "10.42.0.5", 8080, .off); + try reg.register("example.com", "10.42.0.10", 9090, .off); const backend = reg.lookup("example.com"); try std.testing.expect(backend != null); @@ -132,12 +144,40 @@ test "unregister removes backend" { var reg = BackendRegistry.init(alloc); defer reg.deinit(); - try reg.register("example.com", "10.42.0.5", 8080); + try reg.register("example.com", "10.42.0.5", 8080, .off); reg.unregister("example.com"); try std.testing.expect(reg.lookup("example.com") == null); } +test "register and lookup carry peer_mode through" { + const alloc = std.testing.allocator; + var reg = BackendRegistry.init(alloc); + defer reg.deinit(); + + try reg.register("api.example", "10.0.0.1", 8443, .require); + + const got = reg.lookup("api.example").?; + try std.testing.expectEqual(spec.TlsConfig.PeerMode.require, got.peer_mode); + + const owned = (try reg.lookupOwned(alloc, "api.example")).?; + defer alloc.free(owned.ip); + try std.testing.expectEqual(spec.TlsConfig.PeerMode.require, owned.peer_mode); +} + +test "register without peer_mode defaults to off via overwrite path" { + const alloc = std.testing.allocator; + var reg = BackendRegistry.init(alloc); + defer reg.deinit(); + + try reg.register("api.example", "10.0.0.1", 8443, .require); + try reg.register("api.example", "10.0.0.2", 9090, .off); + + const got = reg.lookup("api.example").?; + try std.testing.expectEqual(spec.TlsConfig.PeerMode.off, got.peer_mode); + try std.testing.expectEqualStrings("10.0.0.2", got.ip); +} + test "unregister nonexistent is safe" { const alloc = std.testing.allocator; var reg = BackendRegistry.init(alloc); @@ -151,8 +191,8 @@ test "multiple domains" { var reg = BackendRegistry.init(alloc); defer reg.deinit(); - try reg.register("a.com", "10.42.0.1", 80); - try reg.register("b.com", "10.42.0.2", 443); + try reg.register("a.com", "10.42.0.1", 80, .off); + try reg.register("b.com", "10.42.0.2", 443, .off); const a = reg.lookup("a.com"); const b = reg.lookup("b.com"); @@ -167,7 +207,7 @@ test "lookupOwned returns stable backend copy" { var reg = BackendRegistry.init(alloc); defer reg.deinit(); - try reg.register("example.com", "10.42.0.5", 8080); + try reg.register("example.com", "10.42.0.5", 8080, .off); const owned = (try reg.lookupOwned(alloc, "example.com")).?; defer alloc.free(owned.ip); diff --git a/src/tls/proxy.zig b/src/tls/proxy.zig index caad4b8b..273f4e08 100644 --- a/src/tls/proxy.zig +++ b/src/tls/proxy.zig @@ -27,6 +27,7 @@ const backend_mod = @import("backend.zig"); const acme_mod = @import("acme.zig"); const managed_runtime = @import("acme/managed_runtime.zig"); const runtime_wait = @import("../lib/runtime_wait.zig"); +const store_mod = @import("../state/store.zig"); const max_connections: u32 = 256; var active_connections: std.atomic.Value(u32) = std.atomic.Value(u32).init(0); @@ -444,6 +445,27 @@ pub const TlsProxy = struct { }; defer self.allocator.free(backend.ip); + // build MtlsOpts when the service has tls.peer set. the trust + // root is the cluster CA loaded from raft state. missing CA on + // a service that asked for mtls is a misconfiguration; fall + // back to plain TLS and log loudly. + var mtls_ca_rec: ?store_mod.ClusterCaRecord = null; + defer if (mtls_ca_rec) |rec| rec.deinit(self.allocator); + + const mtls_opts: ?session_runtime.MtlsOpts = blk: { + if (backend.peer_mode == .off) break :blk null; + const rec = (store_mod.getClusterCa(self.allocator) catch null) orelse { + log.warn("tls.peer set for {s} but cluster CA not yet seeded; downgrading to plain TLS", .{server_name}); + break :blk null; + }; + mtls_ca_rec = rec; + break :blk session_runtime.MtlsOpts{ + .require_client_cert = backend.peer_mode == .require, + .trust_ca_pem = rec.cert_pem, + .now_unix = std.Io.Clock.real.now(std.Options.debug_io).toSeconds(), + }; + }; + // perform TLS handshake and proxy traffic session_runtime.handleTlsSession( self.threaded_io.io(), @@ -453,6 +475,7 @@ pub const TlsProxy = struct { cert_result.key_pem, backend, &handshake_complete, + mtls_opts, ) catch |err| { log.warn("TLS session error for {s}: {}", .{ server_name, err }); }; diff --git a/src/tls/proxy/session_runtime.zig b/src/tls/proxy/session_runtime.zig index 14609466..4bf6a2af 100644 --- a/src/tls/proxy/session_runtime.zig +++ b/src/tls/proxy/session_runtime.zig @@ -203,6 +203,7 @@ pub fn handleTlsSession( key_pem: []u8, backend_info: backend_mod.Backend, handshake_complete: *bool, + mtls_opts: ?MtlsOpts, ) !void { var session = try acceptServerHandshake( io, @@ -211,11 +212,15 @@ pub fn handleTlsSession( client_hello, cert_pem, key_pem, - null, + mtls_opts, handshake_complete, ); defer session.deinit(std.heap.page_allocator); + if (session.peer_identity) |peer| { + log.info("tls: mtls peer accepted: {s}", .{peer}); + } + const selected_alpn = session.selected_alpn; const app_keys = session.app_keys;