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
9 changes: 9 additions & 0 deletions src/api/routes/status_metrics/metrics_routes.zig
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const proxy_control_plane = @import("../../../network/proxy/control_plane.zig");
const steering_runtime = @import("../../../network/proxy/steering_runtime.zig");
const upstream_pool = @import("../../../network/proxy/upstream_pool.zig");
const cert_issuer = @import("../../../cluster/cert_issuer.zig");
const mtls_metrics = @import("../../../tls/mtls_metrics.zig");

const Response = common.Response;
const MetricsResponseContext = struct {
Expand Down Expand Up @@ -791,6 +792,14 @@ fn writeServiceObservabilityPrometheus(
try writer.writeAll("# HELP yoq_service_mtls_rotation_failures_total Issuance/rotation failures since the issuer last succeeded for the service\n");
try writer.writeAll("# TYPE yoq_service_mtls_rotation_failures_total counter\n");

try writer.writeAll("# HELP yoq_service_mtls_handshakes_total Service-to-service mTLS handshakes by side and outcome\n");
try writer.writeAll("# TYPE yoq_service_mtls_handshakes_total counter\n");
const hs = mtls_metrics.snapshot();
try writer.print("yoq_service_mtls_handshakes_total{{side=\"client\",outcome=\"ok\"}} {d}\n", .{hs[0]});
try writer.print("yoq_service_mtls_handshakes_total{{side=\"client\",outcome=\"failed\"}} {d}\n", .{hs[1]});
try writer.print("yoq_service_mtls_handshakes_total{{side=\"server\",outcome=\"ok\"}} {d}\n", .{hs[2]});
try writer.print("yoq_service_mtls_handshakes_total{{side=\"server\",outcome=\"failed\"}} {d}\n", .{hs[3]});

// snapshot the issuer's per-service failure counters once for this scrape;
// lookups inside the loop are linear but the list is tiny in practice.
var mtls_failures = cert_issuer.snapshotFailures(std.heap.page_allocator) catch std.ArrayList(cert_issuer.FailureSnapshot).empty;
Expand Down
2 changes: 2 additions & 0 deletions src/test_root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ comptime {
_ = @import("tls/handshake/message_parse.zig");
_ = @import("tls/client_session.zig");
_ = @import("tls/client_dial.zig");
_ = @import("tls/mtls_metrics.zig");
_ = @import("tls/cli/service_cert_command.zig");
_ = @import("tls/proxy.zig");
_ = @import("tls/proxy/session_runtime.zig");
_ = @import("storage/s3.zig");
Expand Down
89 changes: 89 additions & 0 deletions src/tls/cli/service_cert_command.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// service_cert_command — `yoq cert service <name>` inspector.
//
// shows the raft-replicated mTLS leaf cert for a service: when it was
// issued, when it expires, and its SAN identity. read-only — useful for
// debugging issuance / rotation issues without needing direct sqlite
// access.

const std = @import("std");
const cli = @import("../../lib/cli.zig");
const json_out = @import("../../lib/json_output.zig");
const store = @import("../../state/store.zig");
const x509_verify = @import("../x509_verify.zig");
const pem_mod = @import("../pem.zig");
const common = @import("common.zig");

const write = cli.write;
const writeErr = cli.writeErr;

pub const Error = common.TlsCommandsError;

pub fn run(args: *std.process.Args.Iterator, alloc: std.mem.Allocator) Error!void {
const service_name = args.next() orelse {
writeErr("usage: yoq cert service <name>\n", .{});
return Error.InvalidArgument;
};

const rec_opt = store.getMtlsCert(alloc, service_name) catch |err| {
writeErr("failed to read certificate store: {}\n", .{err});
return Error.ReadFailed;
};
const rec = rec_opt orelse {
writeErr("no mtls cert issued for service '{s}' (issuer may not have run yet)\n", .{service_name});
return Error.CertificateNotFound;
};
defer rec.deinit(alloc);

// parse the leaf to surface the SAN URI alongside the row's stored
// timestamps. parse errors are non-fatal — we still show what we
// have from the row.
var san_buf: [8][]const u8 = undefined;
var san: ?[]const u8 = null;
var subject_cn: ?[]const u8 = null;
var parsed_der: ?[]u8 = null;
defer if (parsed_der) |d| alloc.free(d);

if (pem_mod.parseCertDer(alloc, rec.cert_pem)) |der| {
parsed_der = der;
if (x509_verify.parseDer(der, &san_buf)) |parsed| {
subject_cn = parsed.subject_cn;
if (parsed.san_uris.len > 0) san = parsed.san_uris[0];
} else |_| {}
} else |_| {}

if (cli.output_mode == .json) {
writeJson(rec, subject_cn, san);
} else {
writeHuman(service_name, rec, subject_cn, san);
}
}

fn writeHuman(service_name: []const u8, rec: store.MtlsCertRecord, subject_cn: ?[]const u8, san: ?[]const u8) void {
write("service: {s}\n", .{service_name});
write("source: mtls (raft-replicated)\n", .{});
if (subject_cn) |cn| write("subject: {s}\n", .{cn});
if (san) |uri| write("identity: {s}\n", .{uri});
write("issued: {d} (unix)\n", .{rec.created_at});
write("expires: {d} (unix)\n", .{rec.not_after});

const now = std.Io.Clock.real.now(std.Options.debug_io).toSeconds();
const remaining = rec.not_after - now;
if (remaining <= 0) {
write("status: expired\n", .{});
} else {
const hours = @divFloor(remaining, 3600);
write("status: valid for ~{d}h\n", .{hours});
}
}

fn writeJson(rec: store.MtlsCertRecord, subject_cn: ?[]const u8, san: ?[]const u8) void {
var w = json_out.JsonWriter{};
w.beginObject();
w.stringField("source", "mtls");
if (subject_cn) |cn| w.stringField("subject", cn);
if (san) |uri| w.stringField("identity", uri);
w.intField("created_at", rec.created_at);
w.intField("not_after", rec.not_after);
w.endObject();
w.flush();
}
13 changes: 13 additions & 0 deletions src/tls/client_session.zig
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const message_parse = @import("handshake/message_parse.zig");
const x509_verify = @import("x509_verify.zig");
const pem_mod = @import("pem.zig");
const common = @import("handshake/common.zig");
const mtls_metrics = @import("mtls_metrics.zig");

const X25519 = common.X25519;
const Sha384 = common.Sha384;
Expand Down Expand Up @@ -149,6 +150,18 @@ pub fn doHandshake(
alloc: std.mem.Allocator,
fd: posix.fd_t,
opts: HandshakeOpts,
) ClientError!ClientSession {
errdefer mtls_metrics.record(.client, .failed);
const sess = try doHandshakeInner(io, alloc, fd, opts);
mtls_metrics.record(.client, .ok);
return sess;
}

fn doHandshakeInner(
io: std.Io,
alloc: std.mem.Allocator,
fd: posix.fd_t,
opts: HandshakeOpts,
) ClientError!ClientSession {
var transcript = Sha384.init(.{});

Expand Down
5 changes: 5 additions & 0 deletions src/tls/commands.zig
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const acme_command = @import("cli/acme_command.zig");
const install_command = @import("cli/install_command.zig");
const list_command = @import("cli/list_command.zig");
const remove_command = @import("cli/remove_command.zig");
const service_cert_command = @import("cli/service_cert_command.zig");

const writeErr = cli.writeErr;

Expand Down Expand Up @@ -35,6 +36,7 @@ pub fn cert(args: *std.process.Args.Iterator, ctx: AppContext) !void {
\\ renew via ACME
\\ list list certificates
\\ rm <domain> remove a certificate
\\ service <name> show the mtls leaf cert for a service
\\
, .{});
return TlsCommandsError.InvalidArgument;
Expand All @@ -58,6 +60,9 @@ pub fn cert(args: *std.process.Args.Iterator, ctx: AppContext) !void {
if (std.mem.eql(u8, cmd, "rm")) {
return remove_command.run(args, ctx.alloc);
}
if (std.mem.eql(u8, cmd, "service")) {
return service_cert_command.run(args, ctx.alloc);
}

writeErr("unknown cert command: {s}\n", .{cmd});
return TlsCommandsError.InvalidArgument;
Expand Down
53 changes: 53 additions & 0 deletions src/tls/mtls_metrics.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// mtls_metrics — global counters for service-to-service mtls handshakes.
//
// counts are split by side (client / server) and outcome (ok / failed) so
// /metrics dashboards can show both throughput and error rate. there is
// no per-service breakdown here — the per-service cert state lives in
// the cert_issuer snapshot (already surfaced as yoq_service_mtls_*).
// these counters cover what happens in the data plane after issuance.

const std = @import("std");

pub const Side = enum { client, server };
pub const Outcome = enum { ok, failed };

var lock: std.Io.Mutex = .init;
var counters: [4]u64 = .{ 0, 0, 0, 0 };

fn slot(side: Side, outcome: Outcome) usize {
return @as(usize, @intFromEnum(side)) * 2 + @as(usize, @intFromEnum(outcome));
}

pub fn record(side: Side, outcome: Outcome) void {
lock.lockUncancelable(std.Options.debug_io);
defer lock.unlock(std.Options.debug_io);
counters[slot(side, outcome)] += 1;
}

pub fn snapshot() [4]u64 {
lock.lockUncancelable(std.Options.debug_io);
defer lock.unlock(std.Options.debug_io);
return counters;
}

pub fn resetForTest() void {
lock.lockUncancelable(std.Options.debug_io);
defer lock.unlock(std.Options.debug_io);
counters = .{ 0, 0, 0, 0 };
}

test "record increments per side+outcome bucket independently" {
resetForTest();
defer resetForTest();

record(.client, .ok);
record(.client, .ok);
record(.client, .failed);
record(.server, .ok);

const snap = snapshot();
try std.testing.expectEqual(@as(u64, 2), snap[slot(.client, .ok)]);
try std.testing.expectEqual(@as(u64, 1), snap[slot(.client, .failed)]);
try std.testing.expectEqual(@as(u64, 1), snap[slot(.server, .ok)]);
try std.testing.expectEqual(@as(u64, 0), snap[slot(.server, .failed)]);
}
5 changes: 5 additions & 0 deletions src/tls/proxy/session_runtime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const pem = @import("../pem.zig");
const record = @import("../record.zig");
const socket_support = @import("socket_support.zig");
const x509_verify = @import("../x509_verify.zig");
const mtls_metrics = @import("../mtls_metrics.zig");

const X25519 = std.crypto.dh.X25519;
const Sha384 = std.crypto.hash.sha2.Sha384;
Expand Down Expand Up @@ -66,6 +67,9 @@ pub fn acceptServerHandshake(
mtls_opts: ?MtlsOpts,
handshake_complete: *bool,
) !ServerSession {
// metrics are tracked only for mtls handshakes — the regular TLS
// termination path has its own throughput surface elsewhere.
errdefer if (mtls_opts != null) mtls_metrics.record(.server, .failed);
if (client_hello.len < 9) return error.InvalidClientHello;
const rec_len = (@as(usize, client_hello[3]) << 8) | @as(usize, client_hello[4]);
if (client_hello.len < 5 + rec_len) return error.InvalidClientHello;
Expand Down Expand Up @@ -183,6 +187,7 @@ pub fn acceptServerHandshake(
const app_keys = handshake.deriveApplicationSecrets(master, app_transcript_hash);

handshake_complete.* = true;
if (mtls_opts != null) mtls_metrics.record(.server, .ok);

return .{
.selected_alpn = selected_alpn,
Expand Down
Loading