gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit94716fd294716fd2tracker mission 028 (code order) 2/5 util + topics: lib/util.hl holds envOr, storageDir, postersDir, profilesDir, newId, hexDigits, todayStr, dateOr, textOr, hasId, listOr, firstOf, sortDesc once (were copied into up to 5 files); tmdbsync.hl split into tmdb.hl (TMDB/TVmaze requests), sync.hl (one title's sync), sync-helpers.hl, backfill.hl; details.hl split into details.hl, credits.hl, credits-helpers.hl (isIncomplete to shows.hl); search-helpers.hl (words, query, ranking, slugs); collections.hl (the TMDB collection seed, out of franchises.hl); deltasync.hl renamed dailysync.hl; no behaviour change: gates 342/0, 32/0, 52/0, real-copy pages identicalmre94716fd2/plugins/http/tls_common.zig

25.4 KB

  1. // Shared TLS layer — OpenSSL via dlopen, compiled into each HTTP plugin at build time.
  2. // Not a standalone plugin .so, but a Zig module imported by hl:http1 and hl:http2
  3. // (the same shape as http_common.zig and ws_common.zig).
  4. //
  5. // Mission 121 EXTRACTED this file. Both http1 and http2 carried their own copy of
  6. // the same dlopen dance — http1's since the plugin was written, http2's since
  7. // mission 061 added ALPN on top of it. D8's rule applies here exactly as it does to
  8. // WS framing: the transport-agnostic half is shared, the per-protocol half stays in
  9. // the plugin. What is per-protocol about TLS is TWO things and nothing else:
  10. // • the ALPN list a server offers ("h2" for http2, "http/1.1" for http1)
  11. // • WHERE the handshake happens (http2 does it on the accept thread before the
  12. // socket goes back to non-blocking; http1 does it in the I/O worker)
  13. // Both are parameters here, so there is one implementation of the dlopen, the
  14. // SSL_CTX, the cert/key load, the ALPN callback and the read/write/shutdown paths.
  15. //
  16. // No compile-time dependency on OpenSSL: the library is resolved at runtime and a
  17. // server whose host has no libssl falls back to plaintext (the caller decides).
  18. //
  19. // NOT hl:http3. Its `Quic` struct dlopens the same library but through a DIFFERENT
  20. // OpenSSL surface — `OSSL_QUIC_server_method`, `SSL_new_listener`/`SSL_listen`,
  21. // `SSL_read_ex`/`SSL_write_ex`, event-driven, no `SSL_accept` and no socket fd —
  22. // so it is not a copy of this and folding it in would be a rewrite, not an
  23. // extraction. It stays where it is.
  24. const std = @import("std");
  25. const c_dlfcn = @cImport({
  26. @cInclude("dlfcn.h");
  27. });
  28. const linux = std.os.linux;
  29. // per-request allocations across threads: the plugins' allocator (plugin_api.zig)
  30. const allocator = @import("plugin_api").allocator;
  31. // Direct syscall for stderr — std.debug.print pulls in std.Progress, whose global
  32. // state is ABI-incompatible when a .so is loaded into a differently-built binary.
  33. fn logMsg(msg: []const u8) void {
  34. _ = linux.write(2, msg.ptr, msg.len);
  35. }
  36. fn logFmt(comptime fmt: []const u8, args: anytype) void {
  37. var buf: [512]u8 = undefined;
  38. const s = std.fmt.bufPrint(&buf, fmt, args) catch return;
  39. logMsg(s);
  40. }
  41. pub const SSL_CTX = opaque {};
  42. pub const SSL = opaque {};
  43. pub const SSL_METHOD = opaque {};
  44. // OpenSSL function pointer types
  45. const SSL_library_init_fn = *const fn () callconv(.c) c_int;
  46. const SSL_load_error_strings_fn = *const fn () callconv(.c) void;
  47. const TLS_server_method_fn = *const fn () callconv(.c) ?*const SSL_METHOD;
  48. const SSL_CTX_new_fn = *const fn (?*const SSL_METHOD) callconv(.c) ?*SSL_CTX;
  49. const SSL_CTX_free_fn = *const fn (?*SSL_CTX) callconv(.c) void;
  50. const SSL_CTX_use_certificate_chain_file_fn = *const fn (?*SSL_CTX, [*:0]const u8) callconv(.c) c_int;
  51. const SSL_CTX_use_PrivateKey_file_fn = *const fn (?*SSL_CTX, [*:0]const u8, c_int) callconv(.c) c_int;
  52. const SSL_CTX_set_alpn_select_cb_fn = *const fn (?*SSL_CTX, ?AlpnSelectCallback, ?*anyopaque) callconv(.c) void;
  53. const SSL_new_fn = *const fn (?*SSL_CTX) callconv(.c) ?*SSL;
  54. const SSL_free_fn = *const fn (?*SSL) callconv(.c) void;
  55. const SSL_set_fd_fn = *const fn (?*SSL, c_int) callconv(.c) c_int;
  56. const SSL_accept_fn = *const fn (?*SSL) callconv(.c) c_int;
  57. const SSL_read_fn = *const fn (?*SSL, [*]u8, c_int) callconv(.c) c_int;
  58. const SSL_write_fn = *const fn (?*SSL, [*]const u8, c_int) callconv(.c) c_int;
  59. const SSL_shutdown_fn = *const fn (?*SSL) callconv(.c) c_int;
  60. const SSL_get_error_fn = *const fn (?*SSL, c_int) callconv(.c) c_int;
  61. const OPENSSL_init_ssl_fn = *const fn (u64, ?*anyopaque) callconv(.c) c_int;
  62. // ── The CLIENT half's symbols (hl:fetch, hl:smtp) ────────────────────────────
  63. // RESTORED 2026-08-29 (mission 257): `initClient`/`connect` and everything they
  64. // resolve were lost in the 2026-08-20 recovery replay, which nothing noticed
  65. // because neither plugin that calls them was in `native/build.zig`.
  66. const TLS_client_method_fn = *const fn () callconv(.c) ?*const SSL_METHOD;
  67. const SSL_CTX_set_default_verify_paths_fn = *const fn (?*SSL_CTX) callconv(.c) c_int;
  68. const SSL_CTX_load_verify_locations_fn = *const fn (?*SSL_CTX, ?[*:0]const u8, ?[*:0]const u8) callconv(.c) c_int;
  69. const SSL_CTX_set_verify_fn = *const fn (?*SSL_CTX, c_int, ?*anyopaque) callconv(.c) void;
  70. const SSL_connect_fn = *const fn (?*SSL) callconv(.c) c_int;
  71. const SSL_get_verify_result_fn = *const fn (?*SSL) callconv(.c) c_long;
  72. /// SNI goes through the generic control entry point: there is no exported
  73. /// `SSL_set_tlsext_host_name` — it is a macro over `SSL_ctrl` in the headers.
  74. const SSL_ctrl_fn = *const fn (?*SSL, c_int, c_long, ?*anyopaque) callconv(.c) c_long;
  75. const SSL_set1_host_fn = *const fn (?*SSL, [*:0]const u8) callconv(.c) c_int;
  76. pub const SSL_VERIFY_NONE: c_int = 0;
  77. pub const SSL_VERIFY_PEER: c_int = 1;
  78. pub const X509_V_OK: c_long = 0;
  79. const SSL_CTRL_SET_TLSEXT_HOSTNAME: c_int = 55;
  80. const TLSEXT_NAMETYPE_host_name: c_long = 0;
  81. pub const SSL_FILETYPE_PEM: c_int = 1;
  82. pub const SSL_ERROR_WANT_READ: c_int = 2;
  83. pub const SSL_ERROR_WANT_WRITE: c_int = 3;
  84. pub const SSL_ERROR_ZERO_RETURN: c_int = 6;
  85. pub const SSL_TLSEXT_ERR_OK: c_int = 0;
  86. pub const SSL_TLSEXT_ERR_NOACK: c_int = 2;
  87. const AlpnSelectCallback = *const fn (?*SSL, [*c][*c]const u8, [*c]u8, [*c]const u8, c_uint, ?*anyopaque) callconv(.c) c_int;
  88. /// The server's ALPN offer in wire format (each protocol length-prefixed), heap
  89. /// allocated because OpenSSL keeps the callback's user_data pointer for the life of
  90. /// the SSL_CTX and a TlsContext is returned BY VALUE (a pointer to it would dangle).
  91. const AlpnOffer = struct {
  92. wire: []u8,
  93. };
  94. /// RFC 7301 selection with SERVER preference: the first protocol this server offers
  95. /// that the client also listed wins. No overlap → NOACK, which leaves the connection
  96. /// without a negotiated protocol rather than failing the handshake (what http2 did
  97. /// before the extraction, and what http1 wants anyway — a browser that omits ALPN
  98. /// still speaks HTTP/1.1 over the socket).
  99. fn alpnSelect(ssl: ?*SSL, out: [*c][*c]const u8, outlen: [*c]u8, in: [*c]const u8, inlen: c_uint, user_data: ?*anyopaque) callconv(.c) c_int {
  100. _ = ssl;
  101. const offer: *const AlpnOffer = @ptrCast(@alignCast(user_data orelse return SSL_TLSEXT_ERR_NOACK));
  102. var si: usize = 0;
  103. while (si < offer.wire.len) {
  104. const slen = offer.wire[si];
  105. si += 1;
  106. if (si + slen > offer.wire.len) break;
  107. const ours = offer.wire[si .. si + slen];
  108. var ci: usize = 0;
  109. while (ci < inlen) {
  110. const clen = in[ci];
  111. ci += 1;
  112. if (ci + clen > inlen) break;
  113. if (clen == slen and std.mem.eql(u8, in[ci .. ci + clen], ours)) {
  114. out.* = in + ci;
  115. outlen.* = clen;
  116. return SSL_TLSEXT_ERR_OK;
  117. }
  118. ci += clen;
  119. }
  120. si += slen;
  121. }
  122. return SSL_TLSEXT_ERR_NOACK;
  123. }
  124. /// The per-protocol half, as data.
  125. pub const Options = struct {
  126. /// Protocols this server offers over ALPN, in preference order. Empty = no ALPN
  127. /// callback is registered at all (byte-identical to a server that never had one).
  128. alpn: []const []const u8 = &.{},
  129. /// Prefix for this plugin's log lines ("http1" / "http2" / "fetch" / "smtp").
  130. tag: []const u8 = "tls",
  131. // ── client-side only ──
  132. /// An EXTRA trust anchor on top of the system store, for a self-signed test
  133. /// fixture. null = the system store alone. Never a replacement: a caFile that
  134. /// fails to load fails the whole context rather than silently trusting less.
  135. ca_file: ?[]const u8 = null,
  136. /// Verify the peer's chain AND its hostname. hl:fetch never turns this off —
  137. /// it does not pass the field at all. hl:smtp exposes it, because a mail host
  138. /// with a self-signed certificate and no way to name its CA file is a real
  139. /// configuration, and a deliberate one.
  140. verify: bool = true,
  141. };
  142. pub const TlsContext = struct {
  143. ssl_ctx: ?*SSL_CTX = null,
  144. lib_ssl: ?*anyopaque = null,
  145. lib_crypto: ?*anyopaque = null,
  146. alpn: ?*AlpnOffer = null,
  147. fn_ssl_ctx_new: ?SSL_CTX_new_fn = null,
  148. fn_ssl_ctx_free: ?SSL_CTX_free_fn = null,
  149. fn_ssl_ctx_use_cert: ?SSL_CTX_use_certificate_chain_file_fn = null,
  150. fn_ssl_ctx_use_key: ?SSL_CTX_use_PrivateKey_file_fn = null,
  151. fn_ssl_ctx_set_alpn: ?SSL_CTX_set_alpn_select_cb_fn = null,
  152. fn_ssl_new: ?SSL_new_fn = null,
  153. fn_ssl_free: ?SSL_free_fn = null,
  154. fn_ssl_set_fd: ?SSL_set_fd_fn = null,
  155. fn_ssl_accept: ?SSL_accept_fn = null,
  156. fn_ssl_read: ?SSL_read_fn = null,
  157. fn_ssl_write: ?SSL_write_fn = null,
  158. fn_ssl_shutdown: ?SSL_shutdown_fn = null,
  159. fn_ssl_get_error: ?SSL_get_error_fn = null,
  160. // client half
  161. fn_ssl_connect: ?SSL_connect_fn = null,
  162. fn_ssl_ctrl: ?SSL_ctrl_fn = null,
  163. fn_ssl_set1_host: ?SSL_set1_host_fn = null,
  164. fn_ssl_get_verify_result: ?SSL_get_verify_result_fn = null,
  165. /// What this context was built for. `connect` refuses on a server context and
  166. /// `wrapConnection` on a client one, rather than calling a null function
  167. /// pointer somewhere deep inside OpenSSL.
  168. is_client: bool = false,
  169. /// Whether `connect` enforces the chain and the hostname (client only).
  170. verify: bool = true,
  171. fn loadSym(lib: ?*anyopaque, comptime T: type, name: [*:0]const u8) ?T {
  172. const sym = c_dlfcn.dlsym(lib, name) orelse return null;
  173. return @ptrCast(sym);
  174. }
  175. /// dlopen libssl+libcrypto, run OpenSSL's own initialiser, and resolve every
  176. /// symbol BOTH halves use. false = there is no usable OpenSSL on this host
  177. /// (already logged, and anything opened is already closed again).
  178. fn openOpenSsl(ctx: *TlsContext, tag: []const u8) bool {
  179. const ssl_paths = [_][*:0]const u8{ "libssl.so.3", "libssl.so.1.1", "libssl.so" };
  180. const crypto_paths = [_][*:0]const u8{ "libcrypto.so.3", "libcrypto.so.1.1", "libcrypto.so" };
  181. for (ssl_paths) |path| {
  182. ctx.lib_ssl = c_dlfcn.dlopen(path, c_dlfcn.RTLD_NOW | c_dlfcn.RTLD_LOCAL);
  183. if (ctx.lib_ssl != null) break;
  184. }
  185. if (ctx.lib_ssl == null) {
  186. logFmt("{s}: TLS: failed to load libssl.so\n", .{tag});
  187. return false;
  188. }
  189. for (crypto_paths) |path| {
  190. ctx.lib_crypto = c_dlfcn.dlopen(path, c_dlfcn.RTLD_NOW | c_dlfcn.RTLD_LOCAL);
  191. if (ctx.lib_crypto != null) break;
  192. }
  193. if (ctx.lib_crypto == null) {
  194. logFmt("{s}: TLS: failed to load libcrypto.so\n", .{tag});
  195. _ = c_dlfcn.dlclose(ctx.lib_ssl);
  196. ctx.lib_ssl = null;
  197. return false;
  198. }
  199. // OPENSSL_init_ssl first (OpenSSL 1.1+), SSL_library_init as the fallback.
  200. if (loadSym(ctx.lib_ssl, OPENSSL_init_ssl_fn, "OPENSSL_init_ssl")) |init_fn| {
  201. _ = init_fn(0, null);
  202. } else if (loadSym(ctx.lib_ssl, SSL_library_init_fn, "SSL_library_init")) |lib_init| {
  203. _ = lib_init();
  204. if (loadSym(ctx.lib_ssl, SSL_load_error_strings_fn, "SSL_load_error_strings")) |load_err| {
  205. load_err();
  206. }
  207. }
  208. ctx.fn_ssl_ctx_new = loadSym(ctx.lib_ssl, SSL_CTX_new_fn, "SSL_CTX_new");
  209. ctx.fn_ssl_ctx_free = loadSym(ctx.lib_ssl, SSL_CTX_free_fn, "SSL_CTX_free");
  210. ctx.fn_ssl_new = loadSym(ctx.lib_ssl, SSL_new_fn, "SSL_new");
  211. ctx.fn_ssl_free = loadSym(ctx.lib_ssl, SSL_free_fn, "SSL_free");
  212. ctx.fn_ssl_set_fd = loadSym(ctx.lib_ssl, SSL_set_fd_fn, "SSL_set_fd");
  213. ctx.fn_ssl_read = loadSym(ctx.lib_ssl, SSL_read_fn, "SSL_read");
  214. ctx.fn_ssl_write = loadSym(ctx.lib_ssl, SSL_write_fn, "SSL_write");
  215. ctx.fn_ssl_shutdown = loadSym(ctx.lib_ssl, SSL_shutdown_fn, "SSL_shutdown");
  216. ctx.fn_ssl_get_error = loadSym(ctx.lib_ssl, SSL_get_error_fn, "SSL_get_error");
  217. return true;
  218. }
  219. /// Resolve OpenSSL, build an SSL_CTX, load cert+key, register the ALPN offer.
  220. /// null = no TLS on this host or a bad cert/key; the caller falls back to plaintext.
  221. pub fn init(cert_path: []const u8, key_path: []const u8, opts: Options) ?TlsContext {
  222. var ctx = TlsContext{};
  223. if (!openOpenSsl(&ctx, opts.tag)) return null;
  224. const method_fn = loadSym(ctx.lib_ssl, TLS_server_method_fn, "TLS_server_method") orelse {
  225. logFmt("{s}: TLS: TLS_server_method not found\n", .{opts.tag});
  226. ctx.deinit();
  227. return null;
  228. };
  229. ctx.fn_ssl_ctx_use_cert = loadSym(ctx.lib_ssl, SSL_CTX_use_certificate_chain_file_fn, "SSL_CTX_use_certificate_chain_file");
  230. ctx.fn_ssl_ctx_use_key = loadSym(ctx.lib_ssl, SSL_CTX_use_PrivateKey_file_fn, "SSL_CTX_use_PrivateKey_file");
  231. ctx.fn_ssl_ctx_set_alpn = loadSym(ctx.lib_ssl, SSL_CTX_set_alpn_select_cb_fn, "SSL_CTX_set_alpn_select_cb");
  232. ctx.fn_ssl_accept = loadSym(ctx.lib_ssl, SSL_accept_fn, "SSL_accept");
  233. if (ctx.fn_ssl_ctx_new == null or ctx.fn_ssl_new == null or
  234. ctx.fn_ssl_set_fd == null or ctx.fn_ssl_accept == null or
  235. ctx.fn_ssl_read == null or ctx.fn_ssl_write == null)
  236. {
  237. logFmt("{s}: TLS: missing required SSL symbols\n", .{opts.tag});
  238. ctx.deinit();
  239. return null;
  240. }
  241. const method = method_fn();
  242. ctx.ssl_ctx = ctx.fn_ssl_ctx_new.?(method);
  243. if (ctx.ssl_ctx == null) {
  244. logFmt("{s}: TLS: SSL_CTX_new failed\n", .{opts.tag});
  245. ctx.deinit();
  246. return null;
  247. }
  248. if (opts.alpn.len > 0) {
  249. if (ctx.fn_ssl_ctx_set_alpn) |set_alpn| {
  250. var total: usize = 0;
  251. for (opts.alpn) |p| total += 1 + p.len;
  252. const wire = allocator.alloc(u8, total) catch {
  253. ctx.deinit();
  254. return null;
  255. };
  256. var w: usize = 0;
  257. for (opts.alpn) |p| {
  258. wire[w] = @intCast(p.len);
  259. w += 1;
  260. @memcpy(wire[w .. w + p.len], p);
  261. w += p.len;
  262. }
  263. const offer = allocator.create(AlpnOffer) catch {
  264. allocator.free(wire);
  265. ctx.deinit();
  266. return null;
  267. };
  268. offer.* = .{ .wire = wire };
  269. ctx.alpn = offer;
  270. set_alpn(ctx.ssl_ctx, &alpnSelect, offer);
  271. }
  272. }
  273. const cert_z = allocator.dupeZ(u8, cert_path) catch {
  274. ctx.deinit();
  275. return null;
  276. };
  277. defer allocator.free(cert_z);
  278. const key_z = allocator.dupeZ(u8, key_path) catch {
  279. ctx.deinit();
  280. return null;
  281. };
  282. defer allocator.free(key_z);
  283. if (ctx.fn_ssl_ctx_use_cert) |use_cert| {
  284. if (use_cert(ctx.ssl_ctx, cert_z.ptr) != 1) {
  285. logFmt("{s}: TLS: failed to load certificate: {s}\n", .{ opts.tag, cert_path });
  286. ctx.deinit();
  287. return null;
  288. }
  289. }
  290. if (ctx.fn_ssl_ctx_use_key) |use_key| {
  291. if (use_key(ctx.ssl_ctx, key_z.ptr, SSL_FILETYPE_PEM) != 1) {
  292. logFmt("{s}: TLS: failed to load private key: {s}\n", .{ opts.tag, key_path });
  293. ctx.deinit();
  294. return null;
  295. }
  296. }
  297. logFmt("{s}: TLS initialized\n", .{opts.tag});
  298. return ctx;
  299. }
  300. /// THE CLIENT HALF (mission 135, restored in 257). An SSL_CTX that VERIFIES:
  301. /// the system trust store is loaded, `opts.ca_file` adds an extra anchor for a
  302. /// self-signed fixture, and `connect` checks the hostname as well as the chain.
  303. /// null = no usable OpenSSL, or a caFile that would not load — never a context
  304. /// that trusts less than asked, because "https that silently stopped verifying"
  305. /// is the one failure a caller cannot see.
  306. ///
  307. /// An SSL_CTX is thread-safe and expensive (it reads the trust store), so the
  308. /// callers keep one per distinct caFile and share it across worker threads.
  309. pub fn initClient(opts: Options) ?TlsContext {
  310. var ctx = TlsContext{ .is_client = true, .verify = opts.verify };
  311. if (!openOpenSsl(&ctx, opts.tag)) return null;
  312. const method_fn = loadSym(ctx.lib_ssl, TLS_client_method_fn, "TLS_client_method") orelse {
  313. logFmt("{s}: TLS: TLS_client_method not found\n", .{opts.tag});
  314. ctx.deinit();
  315. return null;
  316. };
  317. ctx.fn_ssl_connect = loadSym(ctx.lib_ssl, SSL_connect_fn, "SSL_connect");
  318. ctx.fn_ssl_ctrl = loadSym(ctx.lib_ssl, SSL_ctrl_fn, "SSL_ctrl");
  319. ctx.fn_ssl_set1_host = loadSym(ctx.lib_ssl, SSL_set1_host_fn, "SSL_set1_host");
  320. ctx.fn_ssl_get_verify_result = loadSym(ctx.lib_ssl, SSL_get_verify_result_fn, "SSL_get_verify_result");
  321. if (ctx.fn_ssl_ctx_new == null or ctx.fn_ssl_new == null or
  322. ctx.fn_ssl_set_fd == null or ctx.fn_ssl_connect == null or
  323. ctx.fn_ssl_read == null or ctx.fn_ssl_write == null)
  324. {
  325. logFmt("{s}: TLS: missing required client SSL symbols\n", .{opts.tag});
  326. ctx.deinit();
  327. return null;
  328. }
  329. // Hostname checking is not optional when verification is on. Without
  330. // SSL_set1_host (OpenSSL < 1.1.0) a valid certificate for ANY host would
  331. // pass, which is not verification — so refuse rather than pretend.
  332. if (opts.verify and ctx.fn_ssl_set1_host == null) {
  333. logFmt("{s}: TLS: SSL_set1_host not found — this OpenSSL cannot check hostnames\n", .{opts.tag});
  334. ctx.deinit();
  335. return null;
  336. }
  337. ctx.ssl_ctx = ctx.fn_ssl_ctx_new.?(method_fn());
  338. if (ctx.ssl_ctx == null) {
  339. logFmt("{s}: TLS: SSL_CTX_new failed\n", .{opts.tag});
  340. ctx.deinit();
  341. return null;
  342. }
  343. if (loadSym(ctx.lib_ssl, SSL_CTX_set_default_verify_paths_fn, "SSL_CTX_set_default_verify_paths")) |defaults| {
  344. if (defaults(ctx.ssl_ctx) != 1 and opts.verify and opts.ca_file == null) {
  345. logFmt("{s}: TLS: the system trust store could not be loaded\n", .{opts.tag});
  346. ctx.deinit();
  347. return null;
  348. }
  349. }
  350. if (opts.ca_file) |ca| {
  351. const load_verify = loadSym(ctx.lib_ssl, SSL_CTX_load_verify_locations_fn, "SSL_CTX_load_verify_locations") orelse {
  352. logFmt("{s}: TLS: SSL_CTX_load_verify_locations not found\n", .{opts.tag});
  353. ctx.deinit();
  354. return null;
  355. };
  356. const ca_z = allocator.dupeZ(u8, ca) catch {
  357. ctx.deinit();
  358. return null;
  359. };
  360. defer allocator.free(ca_z);
  361. if (load_verify(ctx.ssl_ctx, ca_z.ptr, null) != 1) {
  362. logFmt("{s}: TLS: caFile could not be loaded: {s}\n", .{ opts.tag, ca });
  363. ctx.deinit();
  364. return null;
  365. }
  366. }
  367. if (loadSym(ctx.lib_ssl, SSL_CTX_set_verify_fn, "SSL_CTX_set_verify")) |set_verify| {
  368. set_verify(ctx.ssl_ctx, if (opts.verify) SSL_VERIFY_PEER else SSL_VERIFY_NONE, null);
  369. } else if (opts.verify) {
  370. logFmt("{s}: TLS: SSL_CTX_set_verify not found\n", .{opts.tag});
  371. ctx.deinit();
  372. return null;
  373. }
  374. logFmt("{s}: TLS client initialized (verify={s})\n", .{ opts.tag, if (opts.verify) "on" else "off" });
  375. return ctx;
  376. }
  377. /// Connect `fd` — already a live TCP socket, and for STARTTLS one that has
  378. /// already spoken plaintext — as a TLS CLIENT to `host`. SNI and the hostname
  379. /// to check are both `host`, which is why the caller passes the name and not
  380. /// just the socket. null = the handshake or the verification failed (logged).
  381. pub fn connect(self: *const TlsContext, fd: i32, host: []const u8, tag: []const u8) ?*SSL {
  382. if (!self.is_client) {
  383. logFmt("{s}: TLS: connect() on a server context\n", .{tag});
  384. return null;
  385. }
  386. const ssl = self.fn_ssl_new.?(self.ssl_ctx) orelse return null;
  387. _ = self.fn_ssl_set_fd.?(ssl, fd);
  388. // A name is only a name — an IP literal is not a valid SNI value, and
  389. // OpenSSL rejects it, so the dupeZ is worth doing once either way.
  390. const host_z = allocator.dupeZ(u8, host) catch {
  391. self.fn_ssl_free.?(ssl);
  392. return null;
  393. };
  394. defer allocator.free(host_z);
  395. if (self.fn_ssl_ctrl) |ctrl| {
  396. _ = ctrl(ssl, SSL_CTRL_SET_TLSEXT_HOSTNAME, TLSEXT_NAMETYPE_host_name, @constCast(@ptrCast(host_z.ptr)));
  397. }
  398. if (self.verify) {
  399. if (self.fn_ssl_set1_host.?(ssl, host_z.ptr) != 1) {
  400. logFmt("{s}: TLS: could not set the hostname to verify ({s})\n", .{ tag, host });
  401. self.fn_ssl_free.?(ssl);
  402. return null;
  403. }
  404. }
  405. while (true) {
  406. const ret = self.fn_ssl_connect.?(ssl);
  407. if (ret == 1) break;
  408. const err = self.getError(ssl, ret);
  409. if (err == SSL_ERROR_WANT_READ or err == SSL_ERROR_WANT_WRITE) continue;
  410. logFmt("{s}: TLS client handshake failed ret={d} err={d} host={s}\n", .{ tag, ret, err, host });
  411. self.fn_ssl_free.?(ssl);
  412. return null;
  413. }
  414. // SSL_connect succeeding is NOT the verdict: with SSL_VERIFY_PEER the
  415. // handshake already fails on a bad chain, but reading the result is the
  416. // documented way to be sure, and it is the only signal when verify is off
  417. // for a host that nevertheless presented something valid.
  418. if (self.verify) {
  419. if (self.fn_ssl_get_verify_result) |verify_result| {
  420. const rc = verify_result(ssl);
  421. if (rc != X509_V_OK) {
  422. logFmt("{s}: TLS: certificate verification failed (code {d}) for {s}\n", .{ tag, rc, host });
  423. self.shutdownAndFree(ssl);
  424. return null;
  425. }
  426. }
  427. }
  428. return ssl;
  429. }
  430. /// A new SSL object bound to `fd`. No handshake — the caller decides where that
  431. /// happens (this is the per-protocol half).
  432. pub fn newSSL(self: *const TlsContext, fd: i32) ?*SSL {
  433. const ssl = self.fn_ssl_new.?(self.ssl_ctx);
  434. if (ssl == null) return null;
  435. _ = self.fn_ssl_set_fd.?(ssl, fd);
  436. return ssl;
  437. }
  438. pub fn getError(self: *const TlsContext, ssl: *SSL, ret: isize) c_int {
  439. const f = self.fn_ssl_get_error orelse return 0;
  440. return f(ssl, @intCast(ret));
  441. }
  442. /// Drive SSL_accept to completion. On a BLOCKING socket this returns on the
  443. /// first call; on a non-blocking one it spins on WANT_READ/WANT_WRITE, which is
  444. /// what http2's accept path relies on.
  445. pub fn handshake(self: *const TlsContext, ssl: *SSL, tag: []const u8) bool {
  446. while (true) {
  447. const ret = self.fn_ssl_accept.?(ssl);
  448. if (ret == 1) return true;
  449. const err = self.getError(ssl, ret);
  450. if (err == SSL_ERROR_WANT_READ or err == SSL_ERROR_WANT_WRITE) continue;
  451. logFmt("{s}: TLS handshake failed ret={d} err={d}\n", .{ tag, ret, err });
  452. return false;
  453. }
  454. }
  455. /// newSSL + handshake, freeing the SSL object if the handshake fails.
  456. pub fn wrapConnection(self: *const TlsContext, fd: i32, tag: []const u8) ?*SSL {
  457. const ssl = self.newSSL(fd) orelse return null;
  458. if (!self.handshake(ssl, tag)) {
  459. self.fn_ssl_free.?(ssl);
  460. return null;
  461. }
  462. return ssl;
  463. }
  464. /// Raw SSL_read. <= 0 means "ask getError" — WANT_READ/WANT_WRITE is "nothing
  465. /// more right now", anything else is a dead connection.
  466. pub fn read(self: *const TlsContext, ssl: *SSL, buf: []u8) isize {
  467. return self.fn_ssl_read.?(ssl, buf.ptr, @intCast(@min(buf.len, std.math.maxInt(c_int))));
  468. }
  469. /// Raw SSL_write (one record's worth at most).
  470. pub fn write(self: *const TlsContext, ssl: *SSL, data: []const u8) isize {
  471. return self.fn_ssl_write.?(ssl, data.ptr, @intCast(@min(data.len, std.math.maxInt(c_int))));
  472. }
  473. /// Write everything, giving up on the first non-retryable error. Returns the
  474. /// number of bytes actually written.
  475. pub fn writeAll(self: *const TlsContext, ssl: *SSL, data: []const u8) usize {
  476. var written: usize = 0;
  477. while (written < data.len) {
  478. const ret = self.write(ssl, data[written..]);
  479. if (ret <= 0) return written;
  480. written += @intCast(ret);
  481. }
  482. return written;
  483. }
  484. pub fn shutdownAndFree(self: *const TlsContext, ssl: *SSL) void {
  485. if (self.fn_ssl_shutdown) |sd| _ = sd(ssl);
  486. self.fn_ssl_free.?(ssl);
  487. }
  488. /// Free WITHOUT the close_notify — for a connection whose handshake never
  489. /// completed (there is no session to shut down) or whose socket is already
  490. /// gone (writing a TLS record into a recycled fd number is worse than
  491. /// skipping the notify: mission 128 measured it corrupting a live peer).
  492. pub fn freeSSL(self: *const TlsContext, ssl: *SSL) void {
  493. self.fn_ssl_free.?(ssl);
  494. }
  495. pub fn deinit(self: *TlsContext) void {
  496. if (self.ssl_ctx != null) {
  497. if (self.fn_ssl_ctx_free) |free_fn| {
  498. free_fn(self.ssl_ctx);
  499. }
  500. self.ssl_ctx = null;
  501. }
  502. if (self.alpn) |offer| {
  503. allocator.free(offer.wire);
  504. allocator.destroy(offer);
  505. self.alpn = null;
  506. }
  507. if (self.lib_ssl != null) {
  508. _ = c_dlfcn.dlclose(self.lib_ssl);
  509. self.lib_ssl = null;
  510. }
  511. if (self.lib_crypto != null) {
  512. _ = c_dlfcn.dlclose(self.lib_crypto);
  513. self.lib_crypto = null;
  514. }
  515. }
  516. };

Branches

Latest commits

  • 94716fd2tracker mission 028 (code order) 2/5 util + topics: lib/util.hl holds envOr, storageDir, postersDir, profilesDir, newId, hexDigits, todayStr, dateOr, textOr, hasId, listOr, firstOf, sortDesc once (were copied into up to 5 files); tmdbsync.hl split into tmdb.hl (TMDB/TVmaze requests), sync.hl (one title's sync), sync-helpers.hl, backfill.hl; details.hl split into details.hl, credits.hl, credits-helpers.hl (isIncomplete to shows.hl); search-helpers.hl (words, query, ranking, slugs); collections.hl (the TMDB collection seed, out of franchises.hl); deltasync.hl renamed dailysync.hl; no behaviour change: gates 342/0, 32/0, 52/0, real-copy pages identicalmre
  • 186079b0tracker mission 028 (code order) 1/5 move: every root .hl except project.hl into lib/ (styles.hl into components/), import paths only; gates 342/0, 32/0, 52/0; real-copy pages identicalmre
  • 4f47f181tracker: report 027mre
  • dc1d4be4tracker mission 027: Hybriel master 06617221 vendored (plugin allocator fixes 3a781359 + 413f60e4); real copy RSS through first-start jobs + 400 loads flat ~2.55 GB (190aa11d 2.3 -> 5.6 GB), page times <= 1.1x; gates 342/0, 32/0, 52/0mre
  • 84e1b3e1tracker: reports 025 + 026mre
  • dc40d859tracker#31 (mission 026): duplicate titles merged — the 68 type+tmdbId pairs held by 157 records were the old tracker's (all migrated); merge.hl repair job (own clock, before the TMDB jobs) keeps one keeper per title (follows/watches > old short id > oldest), moves follows, watches, seasons, cast, credits, timelines, tombstones the rest (mergedInto, never deleted), slugs + short ids 301 to the keeper; stray seasons merged into their listed twin (Reacher S3 watches) or linked when watched; search import re-checks before its put; deploy.sh waits up to 90 s for 200; real copy 68 -> 0 dup ids, az5b2 follows/watches equal; gates 342/0, 32/0, 52/0mre
  • 2667da05tracker#30 (mission 025): /my/ pages from slim cached title cards, episode rows and watch sets (after the jobs /my/series 1.8 s -> 0.06 s, /my/unwatched 4.1 -> 0.18 s); timeline page shows its name once; franchise widget under the poster/title; movies with TV leftovers (First Contact) go through the details repair; tools/count-tmdb-ids.hl; gates 327/0, 52/0, 32/0mre
  • 3909810dantcolony#40: mission references in README/STATUS/docs point to the moved missionsmre
  • e63b1d28antcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 1cda451dtracker: Hybriel master 190aa11d (#127 both shapes, GC correctness fc838894) — conductor adopts despite /my/series 2.4x after jobs (memory 8.0 → 1.7 GB boot); see reports/071mre
  • c1fa2f2etracker (mission 071): Hybriel master 190aa11d measured on the real copy vs the live binary 8590df63 — NOT adopted (after the first-start jobs /my/series 2.4x slower, /series 1.6x, RSS swings 7.4-12.2 GB; fresh it is flat at 1.7-2.2 GB and /my/unwatched faster), vendor stays 8efba065, candidate kept in .scratch/w071/vendor-190aa11d; tests/kinds.mjs: collection seed off (its TMDB request broke check 1 in 1 of 4 runs); tests/realdata-071.sh + realdata-071-bench.mjs + tools/realdata-071-table.py; README + STATUS (numbers, how to repeat); gates 325/0, 32/0, 50/0mre
  • 8081350atracker docs (mission 070): README (summary, Config HL_GC_BYTES — kept at Hybriel's default, the 256 MiB setting is taken out of docker-compose.yml again: the jobs grew to 12+ GB with it too, see STATUS), Test (three gates), Deploy (first start ~50 min: kinds then seed, restart once after collections done, memory numbers), Vendored Hybriel 8efba065 + #48 audit, Files; STATUS mission 070 entry (merges, migrated counts, lambda audit, gates, RSS old vs new, how to repeat, open points); docs/kinds.md + docs/franchises.md job order; tests/realdata-070-*.sh, tools/count-migrated.hl, tools/ref-params.py, tools/lambda-audit.pymre
  • 1ad19c8ctracker: re-vendor Hybriel master 8efba065 (#126 GC by bytes, #48 lambda parameters copy) (mission 070): bin/hybriel sha256 50361e95…, plugins core crypto data fetch fs http http1 mpackdb proc smtp time web; lambda audit: 13 lambdas change a passed record/list (11 through a local alias), no caller relies on it — unchanged; 319 read-only lambda parameters get & (no copy per call: /my/schedule 2.35 → 0.46 s, /my/unwatched 13.7 → 5.7 s on the real copy); /my/unwatched one merge sort instead of n² inserts; docker-compose HL_GC_BYTES=268435456; deploy.sh runs kinds.mjs + franchises.mjs too (default ports 8700–8710); tests/realdata-070.mjs; gates browser 325/0, kinds 32/0, franchises 50/0mre
  • 46b21389tracker (mission 070, conductor): the sync never destroys migrated data — the one-time summary step MOVES a copied summary to migratedSummary (marker summaries-moved.txt) instead of clearing it; a migrated record's first title/genres/homepage/tagline TMDB replaces → migratedTitle/migratedGenres/migratedHomepage/migratedTagline, a migrated season's/episode's title/summary → migratedTitle/migratedSummary (set once); tests/peek-shows.hl prints them; gate 325/0mre
  • 749019b0Merge t19 (tracker#19 franchises + timelines) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/browser.mjs/faketmdb.mjs, both sides kept; franchise/timeline pages get #20's typed heading (Franchise | …, Timeline | …), their title links via titlePath (/movies|/series|/shows); the collection seed waits for repair, kinds and credits too; franchises.mjs URLs + 2 new checks; gates browser 323/0, kinds 32/0, franchises 50/0mre
  • daf49feaMerge t20 (tracker#20 typed headings + #21 series/shows split) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/search.hl/components/search.hl/browser.mjs/faketmdb.mjs, both sides kept; clock order backfill → repair → kinds → credits; withDetailsFields stores tmdbType + kind; gates: browser.mjs URLs → /series|/movies, typed h1 selectors; kinds.mjs repair/credits off, fixture name = seed name; browser 323/0, kinds 32/0mre
  • 20e09d89Merge t18 (tracker#18 delta sync) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/browser.mjs, both sides kept; pageShowOf summary = summaryOfmre
  • f83571c3tracker#18 (mission 067): daily sync by change lists — TMDB /tv|movie/changes (since the stored day, paged) + TVmaze /updates/shows → only our changed titles (followed: full step, unfollowed: light step — changed seasons, no TVmaze), full walk on first run / gap > 14 days / failed list; show record refreshed (title, tmdbSummary, tagline, status, genres …; renamed titles re-indexed); summary = the creator's own text (page: summary > tmdbSummary > tvmazeSummary), one-time clear of copied summaries (9,647 on the live copy); gate 261, tests/realdata-018*.mjs, README + STATUSmre
  • 1ed5457etracker#20 + #21 (mission 068): typed headings "<Type> | <name>" in type colours; TV titles split into Series (/series) and Shows (/shows) by TMDB type + Reality/Talk/News genres — kind stored by sync/import/adult backfill + new kind backfill (resumes), /movies/<slug>, /shows/<slug> of a series/movie → 301, /my/series + /my/shows, home 5 tiles + 3 rows, search/filmography labels; gates kinds 32 + browser 266, tests/realdata-068.mjs, tools/count-kinds.hl, docs/kinds.md, README + STATUSmre
  • f2b00674Merge t26 (tracker#26 + #28) into main (mission 062): short ids for every new person (castPersonId, guest route), guest stars stored on the title and created as people only when opened (/person/tmdb/<id>?show=<id> → 302), lean watch/follow clicks (showRow a small object, cast/crew from the slug, watches cached per user, face rows only after a season toggle); gate 311, tests/realdata-062.mjs, README + STATUSmre