diff --git a/src/api/routes/status_metrics/metrics_routes.zig b/src/api/routes/status_metrics/metrics_routes.zig index b4fadc19..0256262c 100644 --- a/src/api/routes/status_metrics/metrics_routes.zig +++ b/src/api/routes/status_metrics/metrics_routes.zig @@ -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 { @@ -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; diff --git a/src/test_root.zig b/src/test_root.zig index 5fc31fa4..b3754e1e 100644 --- a/src/test_root.zig +++ b/src/test_root.zig @@ -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"); diff --git a/src/tls/cli/service_cert_command.zig b/src/tls/cli/service_cert_command.zig new file mode 100644 index 00000000..75656944 --- /dev/null +++ b/src/tls/cli/service_cert_command.zig @@ -0,0 +1,89 @@ +// service_cert_command — `yoq cert service ` 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 \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(); +} diff --git a/src/tls/client_session.zig b/src/tls/client_session.zig index e4acc62b..13f707f1 100644 --- a/src/tls/client_session.zig +++ b/src/tls/client_session.zig @@ -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; @@ -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(.{}); diff --git a/src/tls/commands.zig b/src/tls/commands.zig index 56000bc4..af2b1f78 100644 --- a/src/tls/commands.zig +++ b/src/tls/commands.zig @@ -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; @@ -35,6 +36,7 @@ pub fn cert(args: *std.process.Args.Iterator, ctx: AppContext) !void { \\ renew via ACME \\ list list certificates \\ rm remove a certificate + \\ service show the mtls leaf cert for a service \\ , .{}); return TlsCommandsError.InvalidArgument; @@ -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; diff --git a/src/tls/mtls_metrics.zig b/src/tls/mtls_metrics.zig new file mode 100644 index 00000000..91cf712b --- /dev/null +++ b/src/tls/mtls_metrics.zig @@ -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)]); +} diff --git a/src/tls/proxy/session_runtime.zig b/src/tls/proxy/session_runtime.zig index 4bf6a2af..244365f8 100644 --- a/src/tls/proxy/session_runtime.zig +++ b/src/tls/proxy/session_runtime.zig @@ -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; @@ -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; @@ -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,