From 4f4c5122318be6ab62ca3dc4060202fe6749946d Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Thu, 20 Aug 2026 11:57:54 -0500 Subject: [PATCH] feat: derive the server DID from the public URL; one clock Without --server-did, a PDS configured with only --public-url advertised did:web:localhost and rejected service-auth whose aud was its real DID. Derive did:web: (port as %3A) like the reference PDS (PDS_SERVICE_DID ?? did:web:${hostname}) and tranquil; explicit wins. Collapse fourteen clock_gettime helpers into core/clock.zig on Io.Timestamp. On 32-bit targets timespec.sec is 32-bit, so each copy was Y2038-bound; the i96 nanosecond timestamp is not. Document 32-bit builds and link the Pi 1 recipe. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 9 ++++++++ README.md | 1 + docs/deployment.md | 18 +++++++++++++++ src/atproto/oauth.zig | 7 ++---- src/atproto/server.zig | 7 ++---- src/atproto/space.zig | 7 ++---- src/atproto/sync.zig | 3 ++- src/auth/tokens.zig | 18 +++++---------- src/core/clock.zig | 36 +++++++++++++++++++++++++++++ src/core/config.zig | 35 ++++++++++++++++++++++++++++ src/http/api.zig | 7 ++---- src/internal/client_attestation.zig | 7 ++---- src/internal/dpop.zig | 7 ++---- src/internal/passkeys.zig | 7 ++---- src/internal/permissioned_data.zig | 7 ++---- src/main.zig | 7 +++++- src/root.zig | 2 ++ src/storage/store.zig | 29 ++++------------------- 18 files changed, 136 insertions(+), 78 deletions(-) create mode 100644 src/core/clock.zig diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cacc10..eafdcc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ Reconstructed from git history for everything up to `v0.1.1`; kept by hand from ## unreleased +- **feat**: when `--server-did` / `ZDS_SERVER_DID` is not set but `--public-url` is, the server + DID is derived as `did:web:` (port encoded `%3A`), matching the reference PDS + (`PDS_SERVICE_DID ?? did:web:${hostname}`) and tranquil. Before, an operator who set only the + public URL advertised `did:web:localhost` in `describeServer` and `/.well-known/did.json` and + rejected service-auth tokens whose `aud` was their real DID. Explicit configuration still wins. +- **refactor**: one clock. Fourteen hand-rolled `clock_gettime` helpers across auth, oauth, + dpop, passkeys, permissioned data, sync, the HTTP layer and the store collapse into + `core/clock.zig` on `Io.Timestamp` (an `i96` of nanoseconds). On 32-bit targets + `std.posix.timespec.sec` is 32-bit, so every one of the old copies was Y2038-bound. - **feat**: zds builds and runs on 32-bit targets without native 64-bit atomics (ARMv6, e.g. a Raspberry Pi 1 — Chad Miller runs one at `pi.chadtmiller.com`). Two things were in the way: `sync.nowMillis()` multiplied a 32-bit `timespec.sec` and trapped at startup, and four diff --git a/README.md b/README.md index 8c42a90..ff72c80 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ dependency internals in this repository. See ## references - [Tranquil PDS](https://tangled.org/tranquil.farm/tranquil-pds) +- [zds-pi-setup](https://tangled.org/chadtmiller.com/zds-pi-setup) — zds on a Raspberry Pi 1 - [Pegasus](https://tangled.org/futur.blue/pegasus) - [Bluesky PDS](https://github.com/bluesky-social/pds) - [haileyok/cocoon](https://github.com/haileyok/cocoon) diff --git a/docs/deployment.md b/docs/deployment.md index f6295bb..9f3ff4d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -116,3 +116,21 @@ the migration and volume snapshot together before rolling either side back. For ordinary account operations after a healthy deployment, use the [operator guide](operations.md), [invite-code guide](invite-codes.md), and [account takedown runbook](account-takedown-runbook.md). + +## 32-bit and small boards + +zds builds and runs on 32-bit ARM without native 64-bit atomics (ARMv6, e.g. a Raspberry +Pi 1 with 512 MB). Cross-compile; do not build on the board — zig 0.16's build runner does +not compile for 32-bit hosts, independent of RAM: + +```sh +zig build -Dtarget=arm-linux-musleabihf -Dcpu=arm1176jzf_s -Doptimize=ReleaseSafe +``` + +CI builds this target on every push. A complete Pi 1 recipe — Cloudflare Tunnel behind +CGNAT, systemd units, Wi-Fi watchdog — is Chad Miller's +[zds-pi-setup](https://tangled.org/chadtmiller.com/zds-pi-setup). + +Set `ZDS_SERVER_DID` explicitly or leave it unset and let zds derive `did:web:` from +`ZDS_PUBLIC_URL`; the one thing not to do is set only a non-localhost public URL on a build +older than `0.3.0`, which advertised `did:web:localhost`. diff --git a/src/atproto/oauth.zig b/src/atproto/oauth.zig index 14fd884..7f61f3c 100644 --- a/src/atproto/oauth.zig +++ b/src/atproto/oauth.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const clock = @import("../core/clock.zig"); const auth = @import("../auth/tokens.zig"); const config = @import("../core/config.zig"); const dpop = @import("../internal/dpop.zig"); @@ -1245,11 +1246,7 @@ fn acceptsJson(request: *const http_api.Request) bool { } fn now() i64 { - var ts: std.posix.timespec = undefined; - return switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) { - .SUCCESS => ts.sec, - else => 0, - }; + return clock.nowSeconds(); } fn oauthError(request: *http_api.Request, status: http.Status, err: []const u8, description: []const u8) !void { diff --git a/src/atproto/server.zig b/src/atproto/server.zig index 3ed3196..d58fdec 100644 --- a/src/atproto/server.zig +++ b/src/atproto/server.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const clock = @import("../core/clock.zig"); const auth = @import("../auth/tokens.zig"); const config = @import("../core/config.zig"); const log = @import("../core/log.zig"); @@ -1189,11 +1190,7 @@ fn requestedServiceAuthExpiration(request: *http_api.Request, has_lxm: bool) !?i } fn unixNow() i64 { - var ts: std.posix.timespec = undefined; - return switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) { - .SUCCESS => ts.sec, - else => 0, - }; + return clock.nowSeconds(); } fn serviceAuthProtectedMethod(method: []const u8) bool { diff --git a/src/atproto/space.zig b/src/atproto/space.zig index e480636..59f8f4c 100644 --- a/src/atproto/space.zig +++ b/src/atproto/space.zig @@ -6,6 +6,7 @@ //! experimental. const std = @import("std"); +const clock = @import("../core/clock.zig"); const auth = @import("../auth/tokens.zig"); const config = @import("../core/config.zig"); const http_api = @import("../http/api.zig"); @@ -1304,11 +1305,7 @@ fn fireNotifySpaceDeleted(allocator: std.mem.Allocator, account: auth.Account, s } fn unixNow() i64 { - var ts: std.posix.timespec = undefined; - return switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) { - .SUCCESS => ts.sec, - else => 0, - }; + return clock.nowSeconds(); } fn datetimeFromUnix(allocator: std.mem.Allocator, seconds: i64) ![]const u8 { diff --git a/src/atproto/sync.zig b/src/atproto/sync.zig index 8a85a8d..3f5d0fb 100644 --- a/src/atproto/sync.zig +++ b/src/atproto/sync.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const clock = @import("../core/clock.zig"); const config = @import("../core/config.zig"); const log = @import("../core/log.zig"); const http_api = @import("../http/api.zig"); @@ -416,7 +417,7 @@ pub fn notifyCrawlers(force: bool) void { } fn nowSeconds() u32 { - return @truncate(@as(u64, @intCast(@divTrunc(store.nowMs(), 1000)))); + return @truncate(@as(u64, @intCast(clock.nowSeconds()))); } fn notifyCrawlersThread() void { diff --git a/src/auth/tokens.zig b/src/auth/tokens.zig index 1d7b971..0aa0391 100644 --- a/src/auth/tokens.zig +++ b/src/auth/tokens.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const clock = @import("../core/clock.zig"); const config = @import("../core/config.zig"); const zat = @import("zat"); @@ -109,15 +110,12 @@ fn createSessionTokenWithScopeAndCnf( ) !SessionToken { const header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"; const jti = @atomicRmw(usize, &jwt_counter, .Add, 1, .monotonic); - var ts: std.posix.timespec = undefined; - const timestamp = switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) { - .SUCCESS => ts, - else => std.posix.timespec{ .sec = 0, .nsec = 0 }, - }; - const iat = timestamp.sec; + const now_ns = clock.nowNanos(); + const iat: i64 = @intCast(@divTrunc(now_ns, std.time.ns_per_s)); + const nsec: i64 = @intCast(@mod(now_ns, std.time.ns_per_s)); const lifetime: i64 = if (std.mem.eql(u8, kind, "refresh")) 14 * 24 * 60 * 60 else 2 * 60 * 60; const exp = iat + lifetime; - const jti_text = try std.fmt.allocPrint(allocator, "zds-{d}-{d}-{d}", .{ timestamp.sec, timestamp.nsec, jti }); + const jti_text = try std.fmt.allocPrint(allocator, "zds-{d}-{d}-{d}", .{ iat, nsec, jti }); errdefer allocator.free(jti_text); const payload = if (dpop_jkt) |jkt| try std.fmt.allocPrint( @@ -204,11 +202,7 @@ pub fn createServiceJwtWithKeypair( } fn unixNow() i64 { - var ts: std.posix.timespec = undefined; - return switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) { - .SUCCESS => ts.sec, - else => 0, - }; + return clock.nowSeconds(); } pub fn serviceKeypair() !zat.Keypair { diff --git a/src/core/clock.zig b/src/core/clock.zig new file mode 100644 index 0000000..616229c --- /dev/null +++ b/src/core/clock.zig @@ -0,0 +1,36 @@ +const std = @import("std"); +const Io = std.Io; + +var configured_io: ?Io = null; + +pub fn init(io: Io) void { + configured_io = io; +} + +fn currentIo() Io { + return configured_io orelse std.Options.debug_io; +} + +pub fn nowNanos() i96 { + return Io.Timestamp.now(currentIo(), .real).nanoseconds; +} + +/// nanoseconds on the suspend-excluding monotonic clock; for durations only +pub fn monotonicNs() u64 { + return @intCast(@max(0, Io.Timestamp.now(currentIo(), .awake).nanoseconds)); +} + +pub fn nowMs() i64 { + return @intCast(@divTrunc(nowNanos(), std.time.ns_per_ms)); +} + +pub fn nowSeconds() i64 { + return @intCast(@divTrunc(nowNanos(), std.time.ns_per_s)); +} + +test "clock runs without init and is monotone-ish across calls" { + const a = nowSeconds(); + const b = nowMs(); + try std.testing.expect(a > 1_700_000_000); + try std.testing.expect(@divTrunc(b, 1000) >= a); +} diff --git a/src/core/config.zig b/src/core/config.zig index e60b144..6434f26 100644 --- a/src/core/config.zig +++ b/src/core/config.zig @@ -151,6 +151,31 @@ pub fn setServerDid(value: []const u8) void { server_did_value = value; } +var derived_server_did_buf: [512]u8 = undefined; + +/// `did:web:` for a public URL, the DID a PDS is expected to answer to +/// when none is configured explicitly. A port is encoded as `%3A` per did:web. +pub fn didWebForUrl(buf: []u8, url: []const u8) error{InvalidPublicUrl}![]const u8 { + const scheme_end = std.mem.indexOf(u8, url, "://") orelse return error.InvalidPublicUrl; + var authority = url[scheme_end + 3 ..]; + if (std.mem.indexOfScalar(u8, authority, '/')) |slash| authority = authority[0..slash]; + if (authority.len == 0 or std.mem.indexOfScalar(u8, authority, '@') != null) return error.InvalidPublicUrl; + var w = std.Io.Writer.fixed(buf); + w.writeAll("did:web:") catch return error.InvalidPublicUrl; + for (authority) |c| { + if (c == ':') { + w.writeAll("%3A") catch return error.InvalidPublicUrl; + } else { + w.writeByte(c) catch return error.InvalidPublicUrl; + } + } + return w.buffered(); +} + +pub fn deriveServerDidFromPublicUrl() error{InvalidPublicUrl}!void { + server_did_value = try didWebForUrl(&derived_server_did_buf, public_url_value); +} + pub fn setPlcDirectory(value: []const u8) void { plc_directory_value = trimTrailingSlash(value); } @@ -275,3 +300,13 @@ test "trims configured proxy service URL slash" { setProxyServiceUrl("https://api.example.com/"); try std.testing.expectEqualStrings("https://api.example.com", proxyServiceUrl()); } + +test "did:web derived from a public URL" { + var buf: [128]u8 = undefined; + try std.testing.expectEqualStrings("did:web:pi.chadtmiller.com", try didWebForUrl(&buf, "https://pi.chadtmiller.com")); + try std.testing.expectEqualStrings("did:web:pds.zat.dev", try didWebForUrl(&buf, "https://pds.zat.dev/")); + try std.testing.expectEqualStrings("did:web:localhost%3A2583", try didWebForUrl(&buf, "http://localhost:2583/xrpc")); + try std.testing.expectError(error.InvalidPublicUrl, didWebForUrl(&buf, "pds.zat.dev")); + try std.testing.expectError(error.InvalidPublicUrl, didWebForUrl(&buf, "https://")); + try std.testing.expectError(error.InvalidPublicUrl, didWebForUrl(&buf, "https://user@host")); +} diff --git a/src/http/api.zig b/src/http/api.zig index 7d7b00c..76d7a17 100644 --- a/src/http/api.zig +++ b/src/http/api.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const clock = @import("../core/clock.zig"); const auth = @import("../auth/tokens.zig"); const dpop = @import("../internal/dpop.zig"); const log = @import("../core/log.zig"); @@ -183,11 +184,7 @@ pub fn optionalBearerAccount(request: *const Request, allocator: std.mem.Allocat } fn now() i64 { - var ts: std.posix.timespec = undefined; - return switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) { - .SUCCESS => ts.sec, - else => 0, - }; + return clock.nowSeconds(); } pub fn parseJsonBody(request: *Request, allocator: std.mem.Allocator, body: []const u8) !std.json.Parsed(std.json.Value) { diff --git a/src/internal/client_attestation.zig b/src/internal/client_attestation.zig index 3a25c71..25b1315 100644 --- a/src/internal/client_attestation.zig +++ b/src/internal/client_attestation.zig @@ -1,6 +1,7 @@ //! Verification for permissioned-space client attestations. const std = @import("std"); +const clock = @import("../core/clock.zig"); const zat = @import("zat"); const max_json_bytes = 256 * 1024; @@ -115,11 +116,7 @@ fn fresh(current: i64, issued_at: i64, expires_at: i64) bool { } fn now() i64 { - var ts: std.posix.timespec = undefined; - return switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) { - .SUCCESS => ts.sec, - else => 0, - }; + return clock.nowSeconds(); } test "client attestation freshness is short lived" { diff --git a/src/internal/dpop.zig b/src/internal/dpop.zig index f172456..7f9efd5 100644 --- a/src/internal/dpop.zig +++ b/src/internal/dpop.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const clock = @import("../core/clock.zig"); const config = @import("../core/config.zig"); const http_api = @import("../http/api.zig"); const store = @import("../storage/store.zig"); @@ -277,11 +278,7 @@ fn methodName(method: anytype) []const u8 { } fn now() i64 { - var ts: std.posix.timespec = undefined; - return switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) { - .SUCCESS => ts.sec, - else => 0, - }; + return clock.nowSeconds(); } test "validates DPoP proof and rejects replay" { diff --git a/src/internal/passkeys.zig b/src/internal/passkeys.zig index 6ff9337..c446f0f 100644 --- a/src/internal/passkeys.zig +++ b/src/internal/passkeys.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const clock = @import("../core/clock.zig"); const auth = @import("../auth/tokens.zig"); const config = @import("../core/config.zig"); const http_api = @import("../http/api.zig"); @@ -443,11 +444,7 @@ fn percentEncode(allocator: std.mem.Allocator, input: []const u8) ![]const u8 { } fn now() i64 { - var ts: std.posix.timespec = undefined; - return switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) { - .SUCCESS => ts.sec, - else => 0, - }; + return clock.nowSeconds(); } fn jsonError(request: *http_api.Request, status: http.Status, message: []const u8) !void { diff --git a/src/internal/permissioned_data.zig b/src/internal/permissioned_data.zig index 223b8d0..e6535f2 100644 --- a/src/internal/permissioned_data.zig +++ b/src/internal/permissioned_data.zig @@ -5,6 +5,7 @@ //! auth, repo, or sync modules. const std = @import("std"); +const clock = @import("../core/clock.zig"); const zat = @import("zat"); const Blake3 = std.crypto.hash.Blake3; @@ -484,11 +485,7 @@ fn randomTokenId(allocator: std.mem.Allocator, io: std.Io) ![]const u8 { } fn unixNow() i64 { - var ts: std.posix.timespec = undefined; - return switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) { - .SUCCESS => ts.sec, - else => 0, - }; + return clock.nowSeconds(); } fn commitMac(ikm: *const [32]u8, context: []const u8, hash: *const [32]u8) [32]u8 { diff --git a/src/main.zig b/src/main.zig index 1206302..1c6a71b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -24,7 +24,11 @@ pub fn main(init: std.process.Init) !void { }; if (options.public_url) |value| zds.core.config.setPublicUrl(value); - if (options.server_did) |value| zds.core.config.setServerDid(value); + if (options.server_did) |value| { + zds.core.config.setServerDid(value); + } else if (options.public_url != null) { + try zds.core.config.deriveServerDidFromPublicUrl(); + } if (options.plc_directory) |value| zds.core.config.setPlcDirectory(value); zds.core.config.setPlcRotationKey(options.plc_rotation_key); zds.core.config.setRecoveryDidKey(options.recovery_did_key); @@ -58,6 +62,7 @@ pub fn main(init: std.process.Init) !void { zds.core.config.setLogLevel(parsed); } + zds.core.clock.init(io); zds.storage.blobstore.init(io, zds.core.config.blobstorePath()); zds.storage.eventlog.init(io); try zds.storage.store.init(io, options.db_path); diff --git a/src/root.zig b/src/root.zig index d4f8f4d..374e829 100644 --- a/src/root.zig +++ b/src/root.zig @@ -15,6 +15,7 @@ pub const auth = struct { pub const core = struct { pub const atid = @import("core/atid.zig"); + pub const clock = @import("core/clock.zig"); pub const config = @import("core/config.zig"); pub const log = @import("core/log.zig"); pub const mail = @import("core/mail.zig"); @@ -66,6 +67,7 @@ test { atproto.sync, auth.tokens, core.atid, + core.clock, core.config, core.log, core.mail, diff --git a/src/storage/store.zig b/src/storage/store.zig index 34dfaac..0a4ba80 100644 --- a/src/storage/store.zig +++ b/src/storage/store.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const clock = @import("../core/clock.zig"); const atid = @import("../core/atid.zig"); const log = @import("../core/log.zig"); const auth = @import("../auth/tokens.zig"); @@ -587,8 +588,7 @@ pub fn close() void { } pub fn nowMs() i64 { - const now = std.Io.Timestamp.now(store_io, .real); - return @intCast(@divTrunc(now.nanoseconds, std.time.ns_per_ms)); + return clock.nowMs(); } /// low 32 bits of nowMs(); differences via -% stay exact for spans under ~49 days @@ -2293,14 +2293,7 @@ fn addProfile(profile: ?*WriteProfile, field: ProfileField, ns: u64) void { } fn monotonicNs() u64 { - var ts: std.posix.timespec = undefined; - const timestamp = switch (std.posix.errno(std.posix.system.clock_gettime(.MONOTONIC, &ts))) { - .SUCCESS => ts, - else => std.posix.timespec{ .sec = 0, .nsec = 0 }, - }; - const seconds: u64 = if (timestamp.sec < 0) 0 else @intCast(timestamp.sec); - const nanos: u64 = if (timestamp.nsec < 0) 0 else @intCast(timestamp.nsec); - return seconds * std.time.ns_per_s + nanos; + return clock.monotonicNs(); } fn elapsedNs(start: u64) u64 { @@ -2319,14 +2312,7 @@ pub fn revForSeq(allocator: std.mem.Allocator, seq: u64, current_rev: ?[]const u } fn nowMicros() u64 { - var ts: std.posix.timespec = undefined; - const timestamp = switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) { - .SUCCESS => ts, - else => std.posix.timespec{ .sec = 0, .nsec = 0 }, - }; - const seconds: u64 = if (timestamp.sec < 0) 0 else @intCast(timestamp.sec); - const nanos: u64 = if (timestamp.nsec < 0) 0 else @intCast(timestamp.nsec); - return seconds * std.time.us_per_s + nanos / std.time.ns_per_us; + return @intCast(@max(0, @divTrunc(clock.nowNanos(), std.time.ns_per_us))); } pub fn get(did: []const u8, collection: []const u8, rkey: []const u8) ?Record { @@ -6282,12 +6268,7 @@ fn firehoseOp( } fn nowIso(allocator: std.mem.Allocator) ![]const u8 { - var ts: std.posix.timespec = undefined; - const timestamp = switch (std.posix.errno(std.posix.system.clock_gettime(.REALTIME, &ts))) { - .SUCCESS => ts, - else => std.posix.timespec{ .sec = 0, .nsec = 0 }, - }; - const seconds_i64 = timestamp.sec; + const seconds_i64 = clock.nowSeconds(); const seconds: u64 = if (seconds_i64 < 0) 0 else @intCast(seconds_i64); const epoch_seconds = std.time.epoch.EpochSeconds{ .secs = seconds }; const year_day = epoch_seconds.getEpochDay().calculateYearDay(); -- 2.51.2