diff --git a/src/manifest/loader.zig b/src/manifest/loader.zig index cd21100d..67a281fd 100644 --- a/src/manifest/loader.zig +++ b/src/manifest/loader.zig @@ -1376,6 +1376,67 @@ test "tls config — missing domain returns error" { )); } +test "tls peer — defaults to off when unspecified" { + const alloc = std.testing.allocator; + + var manifest = try loadFromString(alloc, + \\[service.web] + \\image = "nginx:latest" + \\ + \\[service.web.tls] + \\domain = "test.org" + ); + defer manifest.deinit(); + + const tls = manifest.services[0].tls orelse return error.TestExpectedNonNull; + try std.testing.expectEqual(spec.TlsConfig.PeerMode.off, tls.peer); +} + +test "tls peer — parses require / warn / off" { + const alloc = std.testing.allocator; + + inline for ([_]struct { raw: []const u8, want: spec.TlsConfig.PeerMode }{ + .{ .raw = "require", .want = .require }, + .{ .raw = "warn", .want = .warn }, + .{ .raw = "off", .want = .off }, + }) |case| { + const src = "[service.web]\nimage = \"nginx:latest\"\n[service.web.tls]\nperker_unused = 0\npeer = \"" ++ case.raw ++ "\"\n"; + var manifest = try loadFromString(alloc, src); + defer manifest.deinit(); + const tls = manifest.services[0].tls orelse return error.TestExpectedNonNull; + try std.testing.expectEqual(case.want, tls.peer); + } +} + +test "tls peer — mtls-only service needs no domain" { + const alloc = std.testing.allocator; + + var manifest = try loadFromString(alloc, + \\[service.api] + \\image = "demo:latest" + \\ + \\[service.api.tls] + \\peer = "require" + ); + defer manifest.deinit(); + + const tls = manifest.services[0].tls orelse return error.TestExpectedNonNull; + try std.testing.expectEqualStrings("", tls.domain); + try std.testing.expectEqual(spec.TlsConfig.PeerMode.require, tls.peer); +} + +test "tls peer — invalid value returns error" { + const alloc = std.testing.allocator; + + try std.testing.expectError(LoadError.InvalidTlsConfig, loadFromString(alloc, + \\[service.web] + \\image = "nginx:latest" + \\ + \\[service.web.tls] + \\peer = "yes please" + )); +} + test "tls config — dns-01 provider settings" { const alloc = std.testing.allocator; diff --git a/src/manifest/loader/fields.zig b/src/manifest/loader/fields.zig index 28763f98..10562f2d 100644 --- a/src/manifest/loader/fields.zig +++ b/src/manifest/loader/fields.zig @@ -254,21 +254,30 @@ pub fn parseTlsConfig( ) common.LoadError!?spec.TlsConfig { const tls_table = table orelse return null; - const domain = tls_table.getString("domain") orelse { - log.err("manifest: service '{s}' tls is missing required field 'domain'", .{service_name}); + const peer_raw = tls_table.getString("peer") orelse "off"; + const peer = spec.TlsConfig.PeerMode.parse(peer_raw) orelse { + log.err("manifest: service '{s}' tls.peer must be 'off', 'warn', or 'require'", .{service_name}); return common.LoadError.InvalidTlsConfig; }; - if (domain.len == 0) { - log.err("manifest: service '{s}' tls domain cannot be empty", .{service_name}); + + const acme_table = tls_table.getTable("acme"); + const domain_raw = tls_table.getString("domain") orelse ""; + + // domain is only required when ACME provisioning is configured. an + // mTLS-only service has no DNS-bound cert to mint, so `peer = "..."` + // alone is valid without a domain. + if (acme_table != null and domain_raw.len == 0) { + log.err("manifest: service '{s}' tls.domain is required when tls.acme is set", .{service_name}); return common.LoadError.InvalidTlsConfig; } - const acme = try parseAcmeConfig(alloc, service_name, tls_table.getTable("acme")); + const acme = try parseAcmeConfig(alloc, service_name, acme_table); errdefer if (acme) |cfg| cfg.deinit(alloc); return .{ - .domain = alloc.dupe(u8, domain) catch return common.LoadError.OutOfMemory, + .domain = alloc.dupe(u8, domain_raw) catch return common.LoadError.OutOfMemory, .acme = acme, + .peer = peer, }; } diff --git a/src/manifest/spec/shared_types.zig b/src/manifest/spec/shared_types.zig index 04e960fb..636f6abf 100644 --- a/src/manifest/spec/shared_types.zig +++ b/src/manifest/spec/shared_types.zig @@ -209,8 +209,39 @@ pub const TlsConfig = struct { } }; - domain: []const u8, + /// service-to-service mTLS posture. `off` (default) means no peer + /// authentication on this service; `warn` requests + verifies a peer + /// cert but still accepts the connection if it's missing or fails; + /// `require` rejects any peer that can't present a valid leaf signed + /// by the cluster CA. + pub const PeerMode = enum { + off, + warn, + require, + + pub fn label(self: PeerMode) []const u8 { + return switch (self) { + .off => "off", + .warn => "warn", + .require => "require", + }; + } + + pub fn parse(value: []const u8) ?PeerMode { + if (std.mem.eql(u8, value, "off")) return .off; + if (std.mem.eql(u8, value, "warn")) return .warn; + if (std.mem.eql(u8, value, "require")) return .require; + return null; + } + }; + + /// public-facing DNS name; required when `acme` is set (so an ACME + /// cert can be provisioned). may be empty when only `peer` is in + /// use, since service-to-service mTLS uses SPIFFE-style identities, + /// not DNS names. + domain: []const u8 = "", acme: ?AcmeConfig = null, + peer: PeerMode = .off, pub fn deinit(self: TlsConfig, alloc: std.mem.Allocator) void { alloc.free(self.domain);