Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/manifest/loader.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
21 changes: 15 additions & 6 deletions src/manifest/loader/fields.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down
33 changes: 32 additions & 1 deletion src/manifest/spec/shared_types.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading