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 |