OXYGENHUB

Reach self-signed hubs with the remote's CA (0.7.0) 5888949525ec

"OxygenHub: fetch failed" was plain fetch refusing Earth's self-signed
certificate. Requests to a hub now trust the CA file its checkout's
remote names (remote.NAME.ca, as oxy does), learnt from the Oxygen
extension's remotes, or one from the new oxygenhub.caFiles setting:
fetchTrusting goes through node https with that CA beside the usual
roots and returns an ordinary Response. A test serves HTTPS with a
throwaway self-signed certificate.

closes #1
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 12:42 UTC
Signature
✔ signed by Andy McCall
Hash
5888949525ecd6d26634917e79c97b97ff0b98554e3b0d94387d9fcd3775c7d7
Depends on
092b8bf9f2ac 1cc0ff44e5ca 426ddc5039b5 486c15b00314 b2dd082c57e4 e04950748142 f0b06deac4b9
Depended on by
263de209e5ba
In bookmarks
main
In tags
v0.10.0 v0.10.1 v0.10.2 v0.11.0 v0.11.1 v0.11.2 v0.13.0
Edits
20 atoms, 1 tree operation, 9 files

Browse the tree at this change

added test/tls.test.mjs +49 −0
1 +
// A hub with a self-signed certificate: plain fetch refuses it, and the
2 +
// fetch that trusts the remote's CA file reaches it.
3 +
import { test } from "node:test";
4 +
import assert from "node:assert/strict";
5 +
import { execFileSync } from "node:child_process";
6 +
import { mkdtempSync, readFileSync } from "node:fs";
7 +
import { createServer } from "node:https";
8 +
import { tmpdir } from "node:os";
9 +
import { join } from "node:path";
10 +
import { fetchTrusting, HubClient } from "../out/hub.mjs";
11 +
12 +
test("a self-signed hub is reached with its CA file", async () => {
13 +
  const dir = mkdtempSync(join(tmpdir(), "hubtls-"));
14 +
  const key = join(dir, "key.pem");
15 +
  const cert = join(dir, "cert.pem");
16 +
  execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", key, "-out", cert, "-days", "1", "-subj", "/CN=127.0.0.1", "-addext", "subjectAltName=IP:127.0.0.1"], { stdio: "ignore" });
17 +
  const server = createServer({ key: readFileSync(key), cert: readFileSync(cert) }, (req, res) => {
18 +
    let body = "";
19 +
    req.on("data", (c) => (body += c));
20 +
    req.on("end", () => {
21 +
      if (req.url === "/api/v1/user") {
22 +
        res.writeHead(200, { "content-type": "application/json" });
23 +
        res.end(JSON.stringify({ username: "andy", auth: req.headers.authorization ?? "" }));
24 +
      } else if (req.url === "/echo") {
25 +
        res.writeHead(201, { "content-type": "application/json" });
26 +
        res.end(JSON.stringify({ method: req.method, body }));
27 +
      } else {
28 +
        res.writeHead(204);
29 +
        res.end();
30 +
      }
31 +
    });
32 +
  });
33 +
  await new Promise((r) => server.listen(0, "127.0.0.1", r));
34 +
  const base = `https://127.0.0.1:${server.address().port}`;
35 +
  try {
36 +
    await assert.rejects(fetch(`${base}/api/v1/user`), "plain fetch does not trust it");
37 +
    const trusting = fetchTrusting(cert);
38 +
    const me = await new HubClient(base, "tok", trusting).me();
39 +
    assert.equal(me.username, "andy");
40 +
    assert.equal(me.auth, "Bearer tok", "headers go through");
41 +
    const echo = await trusting(`${base}/echo`, { method: "POST", body: '{"a":1}', headers: { "content-type": "application/json" } });
42 +
    assert.equal(echo.status, 201);
43 +
    assert.deepEqual(await echo.json(), { method: "POST", body: '{"a":1}' });
44 +
    assert.equal((await trusting(`${base}/other`)).status, 204);
45 +
    await assert.rejects(fetchTrusting(join(dir, "missing.pem"))(`${base}/api/v1/user`), /cannot read the hub's CA file/);
46 +
  } finally {
47 +
    server.close();
48 +
  }
49 +
});
modified CHANGELOG.md +4 −0
1 1
# Changelog
2 2
3 +
## 0.7.0 (2026-09-29)
4 +
5 +
- **Self-signed hubs work.** "OxygenHub: fetch failed" came from calling a hub whose certificate is self-signed (Earth's) with plain `fetch`, which does not trust it. The extension now uses the CA file the checkout's remote names (`remote.NAME.ca`, as `oxy` does), or one from the new `oxygenhub.caFiles` setting. It needs Oxygen extension 0.13 for the remote's CA.
6 +
3 7
## 0.6.2 (2026-09-28)
4 8
5 9
- File links keep line ranges (`#L10-L20`) and permalinks to a state (`hash+hash`) readable, for Oxygen's Copy Permalink.
…
53 unchanged lines
modified README.md +1 −0
…
56 unchanged lines
57 57
| Setting | Default | What it does |
58 58
|---|---|---|
59 59
| `oxygenhub.hubs` | `[]` | Base URLs of the hubs you use. Signing in adds to it, so you rarely edit it by hand. |
60 +
| `oxygenhub.caFiles` | `{}` | CA certificate files for hubs with self-signed certificates, by base URL. A checkout whose remote names a CA (`remote.NAME.ca`, which `oxy` uses too) needs no entry: the extension takes it from the remote. |
60 61
| `oxygenhub.proposalBookmarkPrefix` | `"proposal/"` | Prefix of the local bookmark made by **Check Out Proposal** (`proposal/12`). |
61 62
| `oxygenhub.autoRefresh` | `60` | Seconds between refreshes of the views, the checks and the notification count; `0` turns it off. |
62 63
| `oxygenhub.announceNotifications` | `true` | Show a message when new notifications arrive (checked on the `autoRefresh` interval). |
…
15 unchanged lines
modified package.json +9 −1
…
1 unchanged lines
2 2
  "name": "oxygenhub-vscode",
3 3
  "displayName": "OxygenHub",
4 4
  "description": "Proposals, reviews, issues and checks from an OxygenHub, in the editor. Builds on the Oxygen extension.",
5 -
  "version": "0.6.2",
5 +
  "version": "0.7.0",
6 6
  "publisher": "andymccall",
7 7
  "license": "MIT OR Apache-2.0",
8 8
  "icon": "assets/images/oxygenhub.png",
…
37 unchanged lines
46 46
          },
47 47
          "default": [],
48 48
          "description": "Base URLs of hubs you use (learnt from sign in too)."
49 +
        },
50 +
        "oxygenhub.caFiles": {
51 +
          "type": "object",
52 +
          "additionalProperties": {
53 +
            "type": "string"
54 +
          },
55 +
          "default": {},
56 +
          "description": "CA certificate files for hubs with self-signed certificates, by base URL, e.g. {\"https://earth.example:7343\": \"~/.config/oxy/earth-ca.pem\"}. Checkouts whose remote names a CA (remote.NAME.ca) need no entry."
49 57
        },
50 58
        "oxygenhub.proposalBookmarkPrefix": {
51 59
          "type": "string",
…
323 unchanged lines
modified src/auth.ts +19 −3
…
1 unchanged lines
2 2
// base URL and a personal access token, kept in SecretStorage.
3 3
4 4
import * as vscode from "vscode";
5 -
import { HubClient, Session } from "./hub";
5 +
import { fetchTrusting, HubClient, Session } from "./hub";
6 6
7 7
const KEY = "oxygenhub.sessions";
8 8
…
4 unchanged lines
13 13
  /** Fires when the set of hubs changes. */
14 14
  readonly onDidChange = this.onDidChangeEmitter.event;
15 15
  private sessions: Session[] = [];
16 +
  /** CA files for hubs with self-signed certificates, learnt from remotes. */
17 +
  private readonly cas = new Map<string, string>();
16 18
  private readonly disposables: vscode.Disposable[] = [];
17 19
18 20
  constructor(private readonly secrets: vscode.SecretStorage) {
…
25 unchanged lines
44 46
  }
45 47
46 48
  clientFor(base: string): HubClient {
47 -
    return new HubClient(base.replace(/\/$/, ""), this.sessionFor(base)?.token);
49 +
    const b = base.replace(/\/$/, "");
50 +
    return new HubClient(b, this.sessionFor(b)?.token, this.fetchFor(b));
51 +
  }
52 +
53 +
  /** Remember the CA file a remote names for a hub (`remote.NAME.ca`). */
54 +
  setCa(base: string, caFile: string): void {
55 +
    this.cas.set(base.replace(/\/$/, ""), caFile);
48 56
  }
49 57
58 +
  /** How to reach a hub: trusting its CA file when one is known (from a
59 +
   * checkout's remote, or the `oxygenhub.caFiles` setting), else plain fetch. */
60 +
  fetchFor(base: string): typeof fetch {
61 +
    const configured = vscode.workspace.getConfiguration("oxygenhub").get<Record<string, string>>("caFiles") ?? {};
62 +
    const ca = this.cas.get(base) ?? configured[base] ?? configured[`${base}/`];
63 +
    return ca ? fetchTrusting(ca) : fetch;
64 +
  }
65 +
50 66
  private toVs(s: Session): vscode.AuthenticationSession {
51 67
    return { id: s.base, accessToken: s.token, account: { id: `${s.username ?? "?"}@${s.base}`, label: `${s.username ?? "?"} on ${s.base}` }, scopes: [] };
52 68
  }
…
29 unchanged lines
82 98
    if (!token) {
83 99
      return undefined;
84 100
    }
85 -
    const client = new HubClient(base.replace(/\/$/, ""), token);
101 +
    const client = new HubClient(base.replace(/\/$/, ""), token, this.fetchFor(base.replace(/\/$/, "")));
86 102
    let username: string | undefined;
87 103
    try {
88 104
      username = (await client.me()).username;
…
17 unchanged lines
modified src/extension.ts +3 −0
…
203 unchanged lines
204 204
  for (const r of oxygen?.repositories ?? []) {
205 205
    for (const remote of await r.remotes().catch(() => [])) {
206 206
      const m = /^(https?:\/\/[^/]+)\/[^/]+\/[^/]+\/?$/.exec(remote.url);
207 +
      if (m && remote.ca) {
208 +
        auth.setCa(m[1], remote.ca);
209 +
      }
207 210
      if (m && !out.includes(m[1])) {
208 211
        out.push(m[1]);
209 212
      }
…
212 unchanged lines
modified src/hub.ts +50 −0
…
22 unchanged lines
23 23
  return { base: m[1], owner: m[2], name: m[3] };
24 24
}
25 25
26 +
import { readFileSync } from "node:fs";
27 +
import * as http from "node:http";
28 +
import * as https from "node:https";
29 +
import * as tls from "node:tls";
30 +
26 31
export interface Session {
27 32
  base: string;
28 33
  token: string;
…
258 unchanged lines
287 292
/** Whether a tag name reads as a pre-release (`v1.0-rc1`, `2.0.0-beta.2`, `alpha`). */
288 293
export function looksPrerelease(tag: string): boolean {
289 294
  return /(^|[-.+_])(alpha|beta|rc|pre|preview|dev)\d*([-.+_]|$)/i.test(tag) || /-(a|b|rc)\d+$/i.test(tag);
295 +
}
296 +
297 +
/**
298 +
 * A `fetch` that also trusts the CA certificate in `caFile`, for a hub with a
299 +
 * self-signed certificate (Node's `fetch` takes no CA). The file is read on
300 +
 * each request, so a renewed certificate needs no reload.
301 +
 */
302 +
export function fetchTrusting(caFile: string): typeof fetch {
303 +
  return ((input: string | URL | Request, init?: RequestInit) =>
304 +
    new Promise<Response>((resolve, reject) => {
305 +
      const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url);
306 +
      const secure = url.protocol === "https:";
307 +
      let ca: string[] | undefined;
308 +
      if (secure) {
309 +
        try {
310 +
          ca = [...tls.rootCertificates, readFileSync(caFile, "utf8")];
311 +
        } catch (e) {
312 +
          reject(new Error(`cannot read the hub's CA file ${caFile}: ${(e as Error).message}`));
313 +
          return;
314 +
        }
315 +
      }
316 +
      const headers: Record<string, string> = {};
317 +
      new Headers(init?.headers).forEach((v, k) => (headers[k] = v));
318 +
      const req = (secure ? https : http).request(url, { method: init?.method ?? "GET", headers, ca }, (res) => {
319 +
        const chunks: Buffer[] = [];
320 +
        res.on("data", (c: Buffer) => chunks.push(c));
321 +
        res.on("error", reject);
322 +
        res.on("end", () => {
323 +
          const status = res.statusCode ?? 0;
324 +
          const out = new Headers();
325 +
          for (const [k, v] of Object.entries(res.headers)) {
326 +
            if (v !== undefined) {
327 +
              out.set(k, Array.isArray(v) ? v.join(", ") : String(v));
328 +
            }
329 +
          }
330 +
          const empty = status === 204 || status === 304;
331 +
          resolve(new Response(empty ? null : Buffer.concat(chunks), { status, headers: out }));
332 +
        });
333 +
      });
334 +
      req.on("error", (e) => reject(Object.assign(new Error(`fetch failed: ${e.message}`), { cause: e })));
335 +
      if (init?.body !== undefined && init.body !== null) {
336 +
        req.write(typeof init.body === "string" ? init.body : Buffer.from(init.body as ArrayBuffer));
337 +
      }
338 +
      req.end();
339 +
    })) as typeof fetch;
290 340
}
modified src/oxygenapi.ts +2 −1
…
20 unchanged lines
21 21
  readonly status: unknown;
22 22
  readonly bookmarks: { name: string; heads: string[]; current?: boolean }[];
23 23
  readonly currentBookmark: string | undefined;
24 -
  remotes(): Promise<{ name: string; url: string }[]>;
24 +
  /** With the CA file each names for a self-signed hub (Oxygen extension 0.13+). */
25 +
  remotes(): Promise<{ name: string; url: string; ca?: string }[]>;
25 26
  json<T>(args: string[]): Promise<T>;
26 27
  refresh(): Promise<void>;
27 28
}
…
10 unchanged lines
modified src/proposals.ts +4 −1
…
23 unchanged lines
24 24
  const out: HubRepo[] = [];
25 25
  const bases = auth.bases();
26 26
  for (const handle of oxygen.repositories) {
27 -
    let remotes: { name: string; url: string }[] = [];
27 +
    let remotes: { name: string; url: string; ca?: string }[] = [];
28 28
    try {
29 29
      remotes = await handle.remotes();
30 30
    } catch {
…
1 unchanged lines
32 32
    }
33 33
    for (const r of remotes) {
34 34
      const p = parseRepoUrl(r.url);
35 +
      if (p && r.ca) {
36 +
        auth.setCa(p.base, r.ca);
37 +
      }
35 38
      if (p && bases.includes(p.base)) {
36 39
        out.push({ handle, base: p.base, owner: p.owner, name: p.name, remote: r.name });
37 40
        break;
…
162 unchanged lines