milestone 13 discrepancies: redact credentials from urls in logs, metrics and cli output
This commit is contained in:
@@ -9,6 +9,10 @@
|
||||
//! refresh downloads and compiles before the response is written: 202 is
|
||||
//! "accepted and done as far as this connection is concerned", and the status
|
||||
//! table in the body is what tells the operator which sources actually landed.
|
||||
//!
|
||||
//! `DELETE /api/blocklists/{id}` removes the row, reloads, and then sweeps the
|
||||
//! compiled files that row named, so `<data_dir>/blocklists/` follows the table
|
||||
//! the operator is looking at rather than the scheduler's next pass.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
@@ -130,7 +134,27 @@ pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure {
|
||||
state.config_lock.unlock(io);
|
||||
|
||||
written catch |err| return mutations.dbFailure(err, url_conflict);
|
||||
return mutations.reload(state, io);
|
||||
const failure = mutations.reload(state, io);
|
||||
pruneFiles(state, io);
|
||||
return failure;
|
||||
}
|
||||
|
||||
/// Removes the compiled files the deleted row leaves behind.
|
||||
///
|
||||
/// This is the moment an orphan is made during normal operation, and the only
|
||||
/// other sweep is the scheduler's — up to `blocklist_update.interval_hours`
|
||||
/// away. Without this call a deleted list keeps its megabytes on disk for a day.
|
||||
///
|
||||
/// After the reload and never part of the response: the row is gone and the
|
||||
/// snapshot has stopped enforcing the list, so bytes still on disk are not a
|
||||
/// failed delete. `Manager.pruneOrphans` takes the manager's writer lock, which
|
||||
/// the reload above has already taken and released — nothing here holds it, and
|
||||
/// `state.config_lock` was released before either.
|
||||
fn pruneFiles(state: *server.WebState, io: std.Io) void {
|
||||
const manager = state.manager orelse return;
|
||||
manager.pruneOrphans(io) catch |err| {
|
||||
log.warn("pruning the deleted blocklist's files failed: {s}", .{@errorName(err)});
|
||||
};
|
||||
}
|
||||
|
||||
/// Refreshes every enabled source, then applies the result (ruling 12).
|
||||
|
||||
@@ -184,8 +184,12 @@ fn rebuildFailed(what: []const u8, cause: []const u8) Failure {
|
||||
const skeleton_group = "default";
|
||||
const skeleton_upstream: model.UpstreamServer = .{ .url = "https://dns.example/dns-query" };
|
||||
|
||||
/// Runs the shipped validator over `cfg` and returns the first problem's text,
|
||||
/// Runs the shipped validator over `cfg` and returns the first failure's text,
|
||||
/// or null when the candidate is valid. The text is arena-allocated.
|
||||
///
|
||||
/// Failures only: a warning describes a configuration that is legal, and a row
|
||||
/// this API is about to store cannot be rejected for one. The blocklist source
|
||||
/// a POST creates is in no group yet — a warning by design, and never a 400.
|
||||
pub fn firstProblem(arena: Allocator, cfg: model.Config) error{OutOfMemory}!?[]const u8 {
|
||||
var diags: validate.Diagnostics = .init(arena);
|
||||
defer diags.deinit();
|
||||
@@ -194,8 +198,7 @@ pub fn firstProblem(arena: Allocator, cfg: model.Config) error{OutOfMemory}!?[]c
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
else => {},
|
||||
};
|
||||
if (diags.problems.items.len == 0) return null;
|
||||
const problem = diags.problems.items[0];
|
||||
const problem = diags.firstFailure() orelse return null;
|
||||
return try std.fmt.allocPrint(arena, "{s}: {s}", .{ problem.path, problem.message });
|
||||
}
|
||||
|
||||
|
||||
+386
-23
@@ -2,7 +2,9 @@
|
||||
//!
|
||||
//! Two halves, so that neither needs the other to be testable: `collect` walks
|
||||
//! the live collaborators and copies every number into a `Sample`, and `render`
|
||||
//! turns a `Sample` into text. Nothing is computed during rendering.
|
||||
//! turns a `Sample` into text. Nothing is computed during rendering, with one
|
||||
//! exception: an upstream url is redacted where its label is written rather
|
||||
//! than where it is copied. `writeUrlLabel` carries the reasoning.
|
||||
//!
|
||||
//! Three rules the collection half obeys:
|
||||
//!
|
||||
@@ -32,6 +34,7 @@ const logging = @import("../platform/logging.zig");
|
||||
const pool_mod = @import("../upstream/pool.zig");
|
||||
const rate_limiter = @import("../server/rate_limiter.zig");
|
||||
const retention_mod = @import("../storage/retention.zig");
|
||||
const safe_url = @import("../safe_url.zig");
|
||||
const server = @import("server.zig");
|
||||
|
||||
/// The exposition format version, as the 0.0.4 specification writes it.
|
||||
@@ -96,6 +99,9 @@ pub const DohListenerSample = struct {
|
||||
|
||||
/// One upstream, with every string owned by the caller's arena.
|
||||
pub const UpstreamSample = struct {
|
||||
/// The configured url, whole. It reaches the exposition only through
|
||||
/// `writeUrlLabel`, which redacts it; a reader of this field is reading a
|
||||
/// credential.
|
||||
url: []const u8,
|
||||
enabled: bool,
|
||||
available: bool,
|
||||
@@ -377,16 +383,19 @@ fn endpointValue(
|
||||
|
||||
fn renderUpstreams(w: *std.Io.Writer, list: []const UpstreamSample) std.Io.Writer.Error!void {
|
||||
try labeledHead(w, "nxdns_upstream_up", "1 while an upstream is enabled and healthy.", "gauge");
|
||||
for (list) |entry| try labeledValue(w, "nxdns_upstream_up", entry.url, @intFromBool(entry.available));
|
||||
for (list, 0..) |entry, i| {
|
||||
try labeledValue(w, "nxdns_upstream_up", i, entry.url, @intFromBool(entry.available));
|
||||
}
|
||||
|
||||
try labeledHead(w, "nxdns_upstream_enabled", "1 while an upstream is enabled by configuration.", "gauge");
|
||||
for (list) |entry| try labeledValue(w, "nxdns_upstream_enabled", entry.url, @intFromBool(entry.enabled));
|
||||
for (list, 0..) |entry, i| {
|
||||
try labeledValue(w, "nxdns_upstream_enabled", i, entry.url, @intFromBool(entry.enabled));
|
||||
}
|
||||
|
||||
try labeledHead(w, "nxdns_upstream_success_rate", "Share of recent exchanges that succeeded.", "gauge");
|
||||
for (list) |entry| {
|
||||
try w.writeAll("nxdns_upstream_success_rate{url=\"");
|
||||
try writeLabelValue(w, entry.url);
|
||||
try w.print("\"}} {d:.4}\n", .{entry.success_rate});
|
||||
for (list, 0..) |entry, i| {
|
||||
try writeUpstreamLabels(w, "nxdns_upstream_success_rate", i, entry.url);
|
||||
try w.print(" {d:.4}\n", .{entry.success_rate});
|
||||
}
|
||||
|
||||
try labeledHead(
|
||||
@@ -395,15 +404,19 @@ fn renderUpstreams(w: *std.Io.Writer, list: []const UpstreamSample) std.Io.Write
|
||||
"Failures since an upstream last answered.",
|
||||
"gauge",
|
||||
);
|
||||
for (list) |entry| {
|
||||
try labeledValue(w, "nxdns_upstream_consecutive_failures", entry.url, entry.consecutive_failures);
|
||||
for (list, 0..) |entry, i| {
|
||||
try labeledValue(w, "nxdns_upstream_consecutive_failures", i, entry.url, entry.consecutive_failures);
|
||||
}
|
||||
|
||||
try labeledHead(w, "nxdns_upstream_successes_total", "Exchanges an upstream answered.", "counter");
|
||||
for (list) |entry| try labeledValue(w, "nxdns_upstream_successes_total", entry.url, entry.total_successes);
|
||||
for (list, 0..) |entry, i| {
|
||||
try labeledValue(w, "nxdns_upstream_successes_total", i, entry.url, entry.total_successes);
|
||||
}
|
||||
|
||||
try labeledHead(w, "nxdns_upstream_failures_total", "Exchanges an upstream failed.", "counter");
|
||||
for (list) |entry| try labeledValue(w, "nxdns_upstream_failures_total", entry.url, entry.total_failures);
|
||||
for (list, 0..) |entry, i| {
|
||||
try labeledValue(w, "nxdns_upstream_failures_total", i, entry.url, entry.total_failures);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every field of a plain counter struct, under one prefix.
|
||||
@@ -438,12 +451,93 @@ fn labeledHead(
|
||||
fn labeledValue(
|
||||
w: *std.Io.Writer,
|
||||
name: []const u8,
|
||||
index: usize,
|
||||
url: []const u8,
|
||||
value: u64,
|
||||
) std.Io.Writer.Error!void {
|
||||
try w.print("{s}{{url=\"", .{name});
|
||||
try writeLabelValue(w, url);
|
||||
try w.print("\"}} {d}\n", .{value});
|
||||
try writeUpstreamLabels(w, name, index, url);
|
||||
try w.print(" {d}\n", .{value});
|
||||
}
|
||||
|
||||
/// The label set every upstream family shares, up to and including the closing
|
||||
/// brace. One definition, because six families have to agree on it exactly:
|
||||
/// Prometheus identifies a series by its name and its whole label set, so a
|
||||
/// family that labelled its samples differently would be a different series.
|
||||
///
|
||||
/// `index` is the upstream's position in the pool, in the priority order `Pool`
|
||||
/// sorts on. It is here because the url alone stopped identifying a series once
|
||||
/// it was redacted: two upstreams on one host — the shape a NextDNS account with
|
||||
/// two profiles takes — both print `https://dns.nextdns.io`, and two samples of
|
||||
/// one name with one label set is a duplicate series a scrape must not contain.
|
||||
/// The position is read from the rendered slice rather than carried in
|
||||
/// `UpstreamSample`, so no caller can build two samples that claim one index.
|
||||
///
|
||||
/// What the index is not: a durable key, and the difference is an operator's to
|
||||
/// know. `Pool.Snapshot` carries no row id — threading one out of the repository
|
||||
/// through the pool to reach here is a larger change than the defect warrants —
|
||||
/// so the position is all there is. Removing `upstreams[0]` renumbers every
|
||||
/// upstream after it, and one upstream's history then continues under the label
|
||||
/// its neighbour used to carry.
|
||||
///
|
||||
/// What bounds that: the index is only load-bearing when two upstreams share an
|
||||
/// origin, which is the case it was added for. Where origins differ, `url`
|
||||
/// carries the identity on its own and a reorder moves nothing that a query
|
||||
/// grouping on `url` can see. So group on `url`, and read `index` as the
|
||||
/// disambiguator between upstreams that group would otherwise merge.
|
||||
fn writeUpstreamLabels(
|
||||
w: *std.Io.Writer,
|
||||
name: []const u8,
|
||||
index: usize,
|
||||
url: []const u8,
|
||||
) std.Io.Writer.Error!void {
|
||||
try w.print("{s}{{index=\"{d}\",url=\"", .{ name, index });
|
||||
try writeUrlLabel(w, url);
|
||||
try w.writeAll("\"}");
|
||||
}
|
||||
|
||||
/// The one place an upstream url becomes exposition text.
|
||||
///
|
||||
/// `/metrics` is `.auth = .open` in `web/routes.zig` and `web.bind` defaults to
|
||||
/// `0.0.0.0`, so a url in a label is readable by anything on the LAN without a
|
||||
/// session, and a Prometheus that scrapes it keeps that string for as long as it
|
||||
/// keeps the series. A NextDNS DoH upstream is `https://dns.nextdns.io/abcd12`,
|
||||
/// where the path segment is the whole account identifier, so the label prints
|
||||
/// what `safe_url.redact` leaves: the scheme, the host and the port.
|
||||
///
|
||||
/// The redaction sits here rather than in `collect` because this is where the
|
||||
/// open endpoint writes the value. A `Sample` built anywhere else renders
|
||||
/// through this function too, so the guarantee cannot be one caller away.
|
||||
/// `UpstreamSample.url` stays whole for the same reason it is safe to: nothing
|
||||
/// but this function reads it, and the session-authenticated
|
||||
/// `GET /api/upstream/health` reports the same pool with the same urls whole.
|
||||
///
|
||||
/// **`redact` output is not safe to interpolate into a label value, and this
|
||||
/// function is the reason it never has to be.** Do not delete the second layer
|
||||
/// on the grounds that the first one escapes.
|
||||
///
|
||||
/// What `redact` gives: it escapes every control character and bounds its
|
||||
/// output, so it emits neither a raw newline nor a lone backslash. What it does
|
||||
/// not give: it leaves `"` alone. A `"` is legal in the text `redact` keeps, and
|
||||
/// it is the one character that ends a label value — so a host holding one
|
||||
/// closes the label and lets the rest of the string write label pairs of its
|
||||
/// own. That is a defect of this call site, not of `redact`: a `"` needs no
|
||||
/// escape in a log line, which is what `redact` was written for.
|
||||
///
|
||||
/// So `writeLabelValue` runs over the redacted text rather than instead of it,
|
||||
/// and the order is the whole point. It also doubles a backslash `redact` wrote,
|
||||
/// which is what keeps `\n` in a host from reading as a newline to a parser: the
|
||||
/// four bytes `\x1b` arrive at a scrape as `\\x1b`.
|
||||
///
|
||||
/// A label value's escapes are not a shell's. `quoteText`, which the log lines
|
||||
/// use, answers a different question — its delimiter is `'` and it writes its
|
||||
/// own quotes — and it is not the tool here.
|
||||
fn writeUrlLabel(w: *std.Io.Writer, url: []const u8) std.Io.Writer.Error!void {
|
||||
// `SafeUrl.format` prints at most `max_len` characters, plus the `...` that
|
||||
// marks a truncation. The buffer is that bound, so the write cannot fail.
|
||||
var buf: [safe_url.max_len + 3]u8 = undefined;
|
||||
var redacted: std.Io.Writer = .fixed(&buf);
|
||||
try redacted.print("{f}", .{safe_url.redact(url)});
|
||||
try writeLabelValue(w, redacted.buffered());
|
||||
}
|
||||
|
||||
/// The three characters the exposition format reserves inside a label value.
|
||||
@@ -552,22 +646,24 @@ test "a full sample renders the whole exposition, byte for byte" {
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_blocklist_generation 4\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_disk_free_bytes 1000\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_disk_sample_failures_total 1\n"));
|
||||
// The label set carries the redaction, so the path of the configured url is
|
||||
// already gone from the golden text.
|
||||
try testing.expect(std.mem.containsAtLeast(
|
||||
u8,
|
||||
text,
|
||||
1,
|
||||
"nxdns_upstream_up{url=\"https://dns.example/dns-query\"} 1\n",
|
||||
"nxdns_upstream_up{index=\"0\",url=\"https://dns.example\"} 1\n",
|
||||
));
|
||||
try testing.expect(std.mem.containsAtLeast(
|
||||
u8,
|
||||
text,
|
||||
1,
|
||||
"nxdns_upstream_success_rate{url=\"https://dns.example/dns-query\"} 0.9000\n",
|
||||
"nxdns_upstream_success_rate{index=\"0\",url=\"https://dns.example\"} 0.9000\n",
|
||||
));
|
||||
try testing.expect(std.mem.endsWith(
|
||||
u8,
|
||||
text,
|
||||
"nxdns_upstream_failures_total{url=\"https://dns.example/dns-query\"} 1\n",
|
||||
"nxdns_upstream_failures_total{index=\"0\",url=\"https://dns.example\"} 1\n",
|
||||
));
|
||||
}
|
||||
|
||||
@@ -671,15 +767,281 @@ test "cert reload counters render per endpoint, only for the wired stores" {
|
||||
try testing.expect(std.mem.containsAtLeast(u8, both, 1, "nxdns_cert_reload_failures_total{endpoint=\"dot\"} 0\n"));
|
||||
}
|
||||
|
||||
/// Reads a sample line's label set the way a scrape does and returns the number
|
||||
/// of label pairs, or null if the line is not one a parser accepts.
|
||||
///
|
||||
/// A checker rather than a golden string, because the input that exercises it is
|
||||
/// a url no parser accepts, and `safe_url.redact` is entitled to change what it
|
||||
/// prints for one of those. What may not change is the property: an
|
||||
/// operator-supplied byte must not close a label value early, end the line, or
|
||||
/// leave an escape sequence behind that means something else to a parser. Only
|
||||
/// the format's three escapes are accepted for that reason — a `\x1b` `redact`
|
||||
/// wrote reaches here as `\\x1b`, whose backslash is escaped and whose `x1b` is
|
||||
/// three ordinary characters.
|
||||
fn labelPairs(line: []const u8) ?usize {
|
||||
var i = (std.mem.indexOfScalar(u8, line, '{') orelse return null) + 1;
|
||||
var pairs: usize = 0;
|
||||
while (true) {
|
||||
const eq = std.mem.indexOfScalarPos(u8, line, i, '=') orelse return null;
|
||||
if (eq == i) return null;
|
||||
for (line[i..eq]) |c| if (!std.ascii.isAlphanumeric(c) and c != '_') return null;
|
||||
if (eq + 1 >= line.len or line[eq + 1] != '"') return null;
|
||||
|
||||
i = eq + 2;
|
||||
while (true) {
|
||||
if (i >= line.len) return null;
|
||||
if (line[i] == '"') break;
|
||||
if (line[i] != '\\') {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (i + 1 >= line.len) return null;
|
||||
switch (line[i + 1]) {
|
||||
'\\', '"', 'n' => i += 2,
|
||||
else => return null,
|
||||
}
|
||||
}
|
||||
|
||||
pairs += 1;
|
||||
i += 1;
|
||||
if (i >= line.len) return null;
|
||||
if (line[i] == '}') return pairs;
|
||||
if (line[i] != ',') return null;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// `name{labels}` — what Prometheus identifies a series by. Null for a sample
|
||||
/// that carries no label set.
|
||||
fn seriesKey(line: []const u8) ?[]const u8 {
|
||||
const close = std.mem.lastIndexOfScalar(u8, line, '}') orelse return null;
|
||||
return line[0 .. close + 1];
|
||||
}
|
||||
|
||||
test "a label value escapes the characters the format reserves" {
|
||||
// Every shape an operator-supplied url can take that reaches the label with
|
||||
// a character the format reserves. The assertion is the property, not the
|
||||
// text: these are urls no parser accepts, and `safe_url.redact` may change
|
||||
// what it prints for one of them without changing what this test protects.
|
||||
const hostile = [_][]const u8{
|
||||
"https://a\"b/dns-query",
|
||||
"https://a\nb/dns-query",
|
||||
"https://a\\b/dns-query",
|
||||
"https://a\x1bb/dns-query",
|
||||
"https://user:pa55@h\"ost/dns-query",
|
||||
"https://\"}{=,\"/dns-query",
|
||||
// The shape `safe_url.redact` is being hardened against in this same
|
||||
// wave: a `?` before the last `@`. What it prints is that fix's to
|
||||
// decide; that the label holds it safely is this one's.
|
||||
"https://lists.example?token=prefix@hunter2",
|
||||
};
|
||||
|
||||
for (hostile) |url| {
|
||||
const upstream_list = [_]UpstreamSample{.{
|
||||
.url = url,
|
||||
.enabled = true,
|
||||
.available = false,
|
||||
.consecutive_failures = 2,
|
||||
.total_successes = 0,
|
||||
.total_failures = 2,
|
||||
.success_rate = 0,
|
||||
}};
|
||||
const text = try renderToString(testing.allocator, .{ .upstreams = &upstream_list });
|
||||
defer testing.allocator.free(text);
|
||||
|
||||
// The url wrote no line of its own, and lost none: every line is a
|
||||
// comment or a sample, and the six families contribute six samples.
|
||||
var samples: usize = 0;
|
||||
var lines = std.mem.splitScalar(u8, text, '\n');
|
||||
while (lines.next()) |line| {
|
||||
if (line.len == 0 or std.mem.startsWith(u8, line, "# ")) continue;
|
||||
try testing.expect(std.mem.startsWith(u8, line, "nxdns_"));
|
||||
if (!std.mem.startsWith(u8, line, "nxdns_upstream_")) continue;
|
||||
samples += 1;
|
||||
// Both labels are there, and both values close where they opened.
|
||||
try testing.expectEqual(@as(?usize, 2), labelPairs(line));
|
||||
}
|
||||
try testing.expectEqual(@as(usize, 6), samples);
|
||||
}
|
||||
}
|
||||
|
||||
test "two upstreams on one host stay two series" {
|
||||
// Redaction costs the url the job of telling two upstreams apart: a NextDNS
|
||||
// account with two profiles is two urls on one host, and both print
|
||||
// `https://dns.nextdns.io`. Two samples of one name with one label set is a
|
||||
// duplicate series, which is a broken scrape rather than a hidden one.
|
||||
const upstream_list = [_]UpstreamSample{
|
||||
.{
|
||||
.url = "https://dns.nextdns.io/abcd12",
|
||||
.enabled = true,
|
||||
.available = true,
|
||||
.consecutive_failures = 0,
|
||||
.total_successes = 5,
|
||||
.total_failures = 0,
|
||||
.success_rate = 1,
|
||||
},
|
||||
.{
|
||||
.url = "https://dns.nextdns.io/efgh34",
|
||||
.enabled = true,
|
||||
.available = false,
|
||||
.consecutive_failures = 3,
|
||||
.total_successes = 9,
|
||||
.total_failures = 3,
|
||||
.success_rate = 0.75,
|
||||
},
|
||||
};
|
||||
const text = try renderToString(testing.allocator, .{ .upstreams = &upstream_list });
|
||||
defer testing.allocator.free(text);
|
||||
|
||||
try testing.expect(std.mem.containsAtLeast(
|
||||
u8,
|
||||
text,
|
||||
1,
|
||||
"nxdns_upstream_up{index=\"0\",url=\"https://dns.nextdns.io\"} 1\n",
|
||||
));
|
||||
try testing.expect(std.mem.containsAtLeast(
|
||||
u8,
|
||||
text,
|
||||
1,
|
||||
"nxdns_upstream_up{index=\"1\",url=\"https://dns.nextdns.io\"} 0\n",
|
||||
));
|
||||
try testing.expect(std.mem.containsAtLeast(
|
||||
u8,
|
||||
text,
|
||||
1,
|
||||
"nxdns_upstream_successes_total{index=\"1\",url=\"https://dns.nextdns.io\"} 9\n",
|
||||
));
|
||||
|
||||
// Distinguishable, not merely present. `nxdns_upstream_up` is the family
|
||||
// this matters most in: one of these two upstreams is down and the other is
|
||||
// up, and a reader has to be able to see which. Under one shared label set
|
||||
// the two samples say 1 and 0 of the same series, so a scrape either reports
|
||||
// whichever it read last or rejects the pair — and the down upstream is
|
||||
// invisible either way, on the endpoint an operator watches to find out.
|
||||
var up_keys: [2][]const u8 = undefined;
|
||||
var up_values: [2][]const u8 = undefined;
|
||||
var found: usize = 0;
|
||||
var up_lines = std.mem.splitScalar(u8, text, '\n');
|
||||
while (up_lines.next()) |line| {
|
||||
if (!std.mem.startsWith(u8, line, "nxdns_upstream_up{")) continue;
|
||||
try testing.expect(found < up_keys.len);
|
||||
const key = seriesKey(line).?;
|
||||
up_keys[found] = key;
|
||||
up_values[found] = line[key.len + 1 ..];
|
||||
found += 1;
|
||||
}
|
||||
try testing.expectEqual(@as(usize, 2), found);
|
||||
try testing.expect(!std.mem.eql(u8, up_keys[0], up_keys[1]));
|
||||
try testing.expectEqualStrings("1", up_values[0]);
|
||||
try testing.expectEqualStrings("0", up_values[1]);
|
||||
|
||||
// No two samples in the scrape share a series key, whatever the urls were.
|
||||
var keys: [32][]const u8 = undefined;
|
||||
var count: usize = 0;
|
||||
var lines = std.mem.splitScalar(u8, text, '\n');
|
||||
while (lines.next()) |line| {
|
||||
if (line.len == 0 or std.mem.startsWith(u8, line, "# ")) continue;
|
||||
const key = seriesKey(line) orelse continue;
|
||||
for (keys[0..count]) |seen| try testing.expect(!std.mem.eql(u8, seen, key));
|
||||
keys[count] = key;
|
||||
count += 1;
|
||||
}
|
||||
try testing.expectEqual(@as(usize, 12), count);
|
||||
}
|
||||
|
||||
test "an upstream url is redacted before it reaches an open endpoint's label" {
|
||||
// `/metrics` is `.auth = .open`, so every label here is readable without a
|
||||
// session by anything that can reach the bind address. A NextDNS DoH
|
||||
// upstream carries the whole account identifier in its path, and a scraper
|
||||
// keeps a label for as long as it keeps the series.
|
||||
const upstream_list = [_]UpstreamSample{
|
||||
.{
|
||||
.url = "https://dns.nextdns.io/abcd12",
|
||||
.enabled = true,
|
||||
.available = true,
|
||||
.consecutive_failures = 0,
|
||||
.total_successes = 3,
|
||||
.total_failures = 0,
|
||||
.success_rate = 1,
|
||||
},
|
||||
.{
|
||||
.url = "https://user:hunter2@dns.example:8443/dns-query?apikey=s3cr3t#frag",
|
||||
.enabled = false,
|
||||
.available = false,
|
||||
.consecutive_failures = 4,
|
||||
.total_successes = 0,
|
||||
.total_failures = 4,
|
||||
.success_rate = 0,
|
||||
},
|
||||
};
|
||||
const text = try renderToString(testing.allocator, .{ .upstreams = &upstream_list });
|
||||
defer testing.allocator.free(text);
|
||||
|
||||
// The four components a credential can live in, none of them exposed.
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "abcd12"));
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "hunter2"));
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "s3cr3t"));
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "frag"));
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "dns-query"));
|
||||
|
||||
// Every family carries the label, so none of the six may keep the whole url.
|
||||
try testing.expect(std.mem.containsAtLeast(
|
||||
u8,
|
||||
text,
|
||||
1,
|
||||
"nxdns_upstream_up{index=\"0\",url=\"https://dns.nextdns.io\"} 1\n",
|
||||
));
|
||||
try testing.expect(std.mem.containsAtLeast(
|
||||
u8,
|
||||
text,
|
||||
1,
|
||||
"nxdns_upstream_enabled{index=\"0\",url=\"https://dns.nextdns.io\"} 1\n",
|
||||
));
|
||||
try testing.expect(std.mem.containsAtLeast(
|
||||
u8,
|
||||
text,
|
||||
1,
|
||||
"nxdns_upstream_success_rate{index=\"0\",url=\"https://dns.nextdns.io\"} 1.0000\n",
|
||||
));
|
||||
try testing.expect(std.mem.containsAtLeast(
|
||||
u8,
|
||||
text,
|
||||
1,
|
||||
"nxdns_upstream_successes_total{index=\"0\",url=\"https://dns.nextdns.io\"} 3\n",
|
||||
));
|
||||
try testing.expect(std.mem.containsAtLeast(
|
||||
u8,
|
||||
text,
|
||||
1,
|
||||
"nxdns_upstream_failures_total{index=\"0\",url=\"https://dns.nextdns.io\"} 0\n",
|
||||
));
|
||||
|
||||
// The scheme, the host and the port stay: an operator reading a scrape has
|
||||
// to know which upstream a series is about.
|
||||
try testing.expect(std.mem.containsAtLeast(
|
||||
u8,
|
||||
text,
|
||||
1,
|
||||
"nxdns_upstream_enabled{index=\"1\",url=\"https://dns.example:8443\"} 0\n",
|
||||
));
|
||||
try testing.expect(std.mem.containsAtLeast(
|
||||
u8,
|
||||
text,
|
||||
1,
|
||||
"nxdns_upstream_consecutive_failures{index=\"1\",url=\"https://dns.example:8443\"} 4\n",
|
||||
));
|
||||
}
|
||||
|
||||
test "a url longer than the redaction bound cannot run past it" {
|
||||
const long_host = "h" ** (4 * safe_url.max_len);
|
||||
const upstream_list = [_]UpstreamSample{.{
|
||||
.url = "https://dns.example/a\"b\\c",
|
||||
.url = "https://" ++ long_host ++ "/dns-query",
|
||||
.enabled = true,
|
||||
.available = false,
|
||||
.consecutive_failures = 2,
|
||||
.available = true,
|
||||
.consecutive_failures = 0,
|
||||
.total_successes = 0,
|
||||
.total_failures = 2,
|
||||
.success_rate = 0,
|
||||
.total_failures = 0,
|
||||
.success_rate = 1,
|
||||
}};
|
||||
const text = try renderToString(testing.allocator, .{ .upstreams = &upstream_list });
|
||||
defer testing.allocator.free(text);
|
||||
@@ -688,7 +1050,8 @@ test "a label value escapes the characters the format reserves" {
|
||||
u8,
|
||||
text,
|
||||
1,
|
||||
"nxdns_upstream_up{url=\"https://dns.example/a\\\"b\\\\c\"} 0\n",
|
||||
"nxdns_upstream_up{index=\"0\",url=\"" ++
|
||||
("https://" ++ long_host)[0..safe_url.max_len] ++ "...\"} 1\n",
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -1129,6 +1129,93 @@ test "W10 a rule mutation reloads the snapshot and the change is live" {
|
||||
try bounded(env.io(), default_budget, mutationReloads, .{ env.io(), env });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deleting a source takes its compiled files with it (m13 ruling F-f)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The id in a `201 Created` body from `/api/blocklists`.
|
||||
fn createdId(body: []const u8) !i64 {
|
||||
const marker = "\"id\":";
|
||||
const at = std.mem.indexOf(u8, body, marker) orelse return error.TestNoId;
|
||||
const rest = body[at + marker.len ..];
|
||||
const end = std.mem.indexOfNone(u8, rest, "0123456789") orelse rest.len;
|
||||
return std.fmt.parseInt(i64, rest[0..end], 10);
|
||||
}
|
||||
|
||||
fn writeCompiled(io: std.Io, dir: std.Io.Dir, id: i64, body: []const u8) !void {
|
||||
var buf: [64]u8 = undefined;
|
||||
try dir.writeFile(io, .{
|
||||
.sub_path = try std.fmt.bufPrint(&buf, "{d}.list", .{id}),
|
||||
.data = body,
|
||||
});
|
||||
try dir.writeFile(io, .{
|
||||
.sub_path = try std.fmt.bufPrint(&buf, "{d}.wild", .{id}),
|
||||
.data = "",
|
||||
});
|
||||
}
|
||||
|
||||
fn accessCompiled(io: std.Io, dir: std.Io.Dir, id: i64) !void {
|
||||
var buf: [64]u8 = undefined;
|
||||
return dir.access(io, try std.fmt.bufPrint(&buf, "{d}.list", .{id}), .{});
|
||||
}
|
||||
|
||||
fn deleteSweepsCompiledFiles(io: std.Io, env: *Env) anyerror!void {
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
var body_buf: [4096]u8 = undefined;
|
||||
try conn.request("POST", "/api/blocklists", null, "{\"url\":\"https://doomed.test/a.txt\",\"name\":\"doomed\"}");
|
||||
var response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 201), response.status);
|
||||
const doomed = try createdId(response.body);
|
||||
|
||||
try conn.request("POST", "/api/blocklists", null, "{\"url\":\"https://kept.test/b.txt\",\"name\":\"kept\"}");
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 201), response.status);
|
||||
const kept = try createdId(response.body);
|
||||
|
||||
// The files a refresh would have produced for each row. Neither row carries
|
||||
// a checksum, so the reload the delete runs treats both as never fetched
|
||||
// and reads neither — this case is about the directory, not the snapshot.
|
||||
_ = try env.tmp.dir.createDirPathStatus(io, "blocklists", .fromMode(0o700));
|
||||
var dir = try env.tmp.dir.openDir(io, "blocklists", .{ .iterate = true });
|
||||
defer dir.close(io);
|
||||
try writeCompiled(io, dir, doomed, "doomed.example\n");
|
||||
try writeCompiled(io, dir, kept, "kept.example\n");
|
||||
|
||||
var target_buf: [64]u8 = undefined;
|
||||
const target = try std.fmt.bufPrint(&target_buf, "/api/blocklists/{d}", .{doomed});
|
||||
try conn.request("DELETE", target, null, null);
|
||||
response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 204), response.status);
|
||||
|
||||
// The row is gone, so its files are orphans; without a sweep on this path
|
||||
// they would sit here until a restart or the scheduler's next pass.
|
||||
var name_buf: [64]u8 = undefined;
|
||||
try testing.expectError(error.FileNotFound, dir.access(
|
||||
io,
|
||||
try std.fmt.bufPrint(&name_buf, "{d}.list", .{doomed}),
|
||||
.{},
|
||||
));
|
||||
try testing.expectError(error.FileNotFound, dir.access(
|
||||
io,
|
||||
try std.fmt.bufPrint(&name_buf, "{d}.wild", .{doomed}),
|
||||
.{},
|
||||
));
|
||||
try accessCompiled(io, dir, kept);
|
||||
}
|
||||
|
||||
test "W10 deleting a blocklist deletes its compiled files and spares the others" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var env = try Env.create(gpa, .{});
|
||||
defer env.destroy();
|
||||
|
||||
try bounded(env.io(), default_budget, deleteSweepsCompiledFiles, .{ env.io(), env });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pause via the API changes a real handler decision (ruling 15)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user