256 lines
7.5 KiB
TypeScript
256 lines
7.5 KiB
TypeScript
/**
|
|
* The GitHub edge for the Desktop update suite: a local stand-in for the two
|
|
* api.github.com resources the source update check reads, answered from the
|
|
* local bare origin, behind an HTTPS forward proxy the install is pointed at
|
|
* (HTTPS_PROXY + a CA bundle that trusts this run's own root, i.e. a
|
|
* TLS-inspecting corporate proxy).
|
|
*
|
|
* GET /repos/<owner>/<repo>/commits/<branch> (Accept: application/vnd.github.sha)
|
|
* GET /repos/<owner>/<repo>/compare/<base>...<head>
|
|
*
|
|
* Every other CONNECT target is tunnelled through unchanged and recorded, so a
|
|
* failing cell can list every host the product reached.
|
|
*/
|
|
|
|
import { execFileSync, spawnSync } from 'node:child_process'
|
|
import * as fs from 'node:fs'
|
|
import * as http from 'node:http'
|
|
import * as net from 'node:net'
|
|
import * as path from 'node:path'
|
|
import * as tls from 'node:tls'
|
|
|
|
export interface GithubEdge {
|
|
proxyUrl: string
|
|
caBundle: string
|
|
/** host:port of every CONNECT the proxy saw, in order. */
|
|
connects: string[]
|
|
/** method + path of every request the fake api.github.com answered. */
|
|
apiHits: string[]
|
|
env: Record<string, string>
|
|
close: () => Promise<void>
|
|
}
|
|
|
|
function openssl(args: string[], cwd: string): void {
|
|
execFileSync('openssl', args, { cwd, stdio: 'pipe' })
|
|
}
|
|
|
|
/** A throwaway root + an api.github.com leaf signed by it. */
|
|
function makeCerts(dir: string): { key: Buffer; cert: Buffer; caPem: string } {
|
|
fs.mkdirSync(dir, { recursive: true })
|
|
// Python >= 3.13 verifies with X509_STRICT: the root needs keyUsage, the leaf an AKI.
|
|
openssl(
|
|
[
|
|
'req',
|
|
'-x509',
|
|
'-newkey',
|
|
'rsa:2048',
|
|
'-nodes',
|
|
'-keyout',
|
|
'ca.key',
|
|
'-out',
|
|
'ca.pem',
|
|
'-days',
|
|
'2',
|
|
'-subj',
|
|
'/CN=hermes-update-e2e-root',
|
|
'-addext',
|
|
'basicConstraints=critical,CA:TRUE',
|
|
'-addext',
|
|
'keyUsage=critical,keyCertSign,cRLSign',
|
|
'-addext',
|
|
'subjectKeyIdentifier=hash'
|
|
],
|
|
dir
|
|
)
|
|
openssl(
|
|
['req', '-newkey', 'rsa:2048', '-nodes', '-keyout', 'leaf.key', '-out', 'leaf.csr', '-subj', '/CN=api.github.com'],
|
|
dir
|
|
)
|
|
fs.writeFileSync(
|
|
path.join(dir, 'leaf.ext'),
|
|
'basicConstraints=CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n' +
|
|
'subjectAltName=DNS:api.github.com\nsubjectKeyIdentifier=hash\nauthorityKeyIdentifier=keyid,issuer\n'
|
|
)
|
|
openssl(
|
|
[
|
|
'x509',
|
|
'-req',
|
|
'-in',
|
|
'leaf.csr',
|
|
'-CA',
|
|
'ca.pem',
|
|
'-CAkey',
|
|
'ca.key',
|
|
'-CAcreateserial',
|
|
'-out',
|
|
'leaf.pem',
|
|
'-days',
|
|
'2',
|
|
'-extfile',
|
|
'leaf.ext'
|
|
],
|
|
dir
|
|
)
|
|
|
|
return {
|
|
key: fs.readFileSync(path.join(dir, 'leaf.key')),
|
|
cert: fs.readFileSync(path.join(dir, 'leaf.pem')),
|
|
caPem: fs.readFileSync(path.join(dir, 'ca.pem'), 'utf8')
|
|
}
|
|
}
|
|
|
|
function gitIn(repo: string, args: string[]): { ok: boolean; out: string } {
|
|
const cp = spawnSync('git', args, { cwd: repo, encoding: 'utf8', env: { ...process.env, GIT_NO_LAZY_FETCH: '1' } })
|
|
|
|
return { ok: cp.status === 0, out: (cp.stdout ?? '').trim() }
|
|
}
|
|
|
|
/** The GitHub REST shapes the check reads, computed from the bare origin at request time. */
|
|
function apiHandler(origin: string, hits: string[]) {
|
|
return (req: http.IncomingMessage, res: http.ServerResponse) => {
|
|
const url = new URL(req.url ?? '/', 'https://api.github.com')
|
|
hits.push(`${req.method} ${url.pathname}`)
|
|
|
|
const send = (status: number, body: string, type = 'application/json') => {
|
|
res.writeHead(status, { 'content-type': type })
|
|
res.end(body)
|
|
}
|
|
|
|
const commit = /^\/repos\/[^/]+\/[^/]+\/commits\/(.+)$/.exec(url.pathname)
|
|
|
|
if (commit) {
|
|
const tip = gitIn(origin, ['rev-parse', '--verify', `refs/heads/${decodeURIComponent(commit[1])}^{commit}`])
|
|
|
|
if (!tip.ok) {
|
|
return send(404, JSON.stringify({ message: 'No commit found for SHA' }))
|
|
}
|
|
|
|
return /vnd\.github\.sha/.test(String(req.headers.accept))
|
|
? send(200, tip.out, 'application/vnd.github.sha')
|
|
: send(200, JSON.stringify({ sha: tip.out }))
|
|
}
|
|
|
|
const compare = /^\/repos\/[^/]+\/[^/]+\/compare\/([0-9a-f]{40})\.\.\.([0-9a-f]{40})$/.exec(url.pathname)
|
|
|
|
if (compare) {
|
|
const [, base, head] = compare
|
|
const log = gitIn(origin, ['log', '--reverse', '--format=%H%x1f%an%x1f%cI%x1f%s', `${base}..${head}`])
|
|
|
|
if (!log.ok) {
|
|
return send(404, JSON.stringify({ message: 'Not Found' }))
|
|
}
|
|
|
|
const behind = gitIn(origin, ['rev-list', '--count', `${head}..${base}`])
|
|
|
|
const commits = log.out
|
|
? log.out.split('\n').map(line => {
|
|
const [sha, author, date, message] = line.split('\x1f')
|
|
|
|
return { sha, commit: { message, author: { name: author, date }, committer: { name: author, date } } }
|
|
})
|
|
: []
|
|
|
|
return send(
|
|
200,
|
|
JSON.stringify({
|
|
status: commits.length ? 'ahead' : 'identical',
|
|
ahead_by: commits.length,
|
|
behind_by: Number(behind.out || 0),
|
|
total_commits: commits.length,
|
|
commits
|
|
})
|
|
)
|
|
}
|
|
|
|
send(404, JSON.stringify({ message: 'Not Found' }))
|
|
}
|
|
}
|
|
|
|
export async function startGithubEdge(
|
|
workDir: string,
|
|
origin: string,
|
|
baseCaBundle: string | undefined
|
|
): Promise<GithubEdge> {
|
|
const { key, cert, caPem } = makeCerts(path.join(workDir, 'edge-ca'))
|
|
const caBundle = path.join(workDir, 'edge-ca', 'bundle.pem')
|
|
const systemBundle = [baseCaBundle, '/etc/ssl/certs/ca-certificates.crt'].find(p => p && fs.existsSync(p))
|
|
fs.writeFileSync(caBundle, `${systemBundle ? fs.readFileSync(systemBundle, 'utf8') : ''}\n${caPem}`)
|
|
|
|
const connects: string[] = []
|
|
const apiHits: string[] = []
|
|
const api = http.createServer(apiHandler(origin, apiHits))
|
|
const sockets = new Set<net.Socket>()
|
|
|
|
const proxy = http.createServer((_req, res) => {
|
|
res.writeHead(405)
|
|
res.end('CONNECT only')
|
|
})
|
|
|
|
proxy.on('connection', socket => {
|
|
sockets.add(socket)
|
|
socket.on('close', () => sockets.delete(socket))
|
|
})
|
|
|
|
proxy.on('connect', (req: http.IncomingMessage, client: net.Socket, head: Buffer) => {
|
|
const target = req.url ?? ''
|
|
connects.push(target)
|
|
const [host, portText] = target.split(':')
|
|
const port = Number(portText || 443)
|
|
client.on('error', () => undefined)
|
|
|
|
if (host === 'api.github.com') {
|
|
client.write('HTTP/1.1 200 Connection Established\r\n\r\n')
|
|
|
|
if (head.length) {
|
|
client.unshift(head)
|
|
}
|
|
|
|
const secure = new tls.TLSSocket(client, { isServer: true, key, cert })
|
|
secure.on('error', () => undefined)
|
|
api.emit('connection', secure)
|
|
|
|
return
|
|
}
|
|
|
|
const upstream = net.connect(port, host, () => {
|
|
client.write('HTTP/1.1 200 Connection Established\r\n\r\n')
|
|
|
|
if (head.length) {
|
|
upstream.write(head)
|
|
}
|
|
|
|
upstream.pipe(client)
|
|
client.pipe(upstream)
|
|
})
|
|
|
|
sockets.add(upstream)
|
|
upstream.on('close', () => sockets.delete(upstream))
|
|
upstream.on('error', () => client.destroy())
|
|
})
|
|
|
|
await new Promise<void>(resolve => proxy.listen(0, '127.0.0.1', resolve))
|
|
const proxyUrl = `http://127.0.0.1:${(proxy.address() as net.AddressInfo).port}`
|
|
|
|
return {
|
|
proxyUrl,
|
|
caBundle,
|
|
connects,
|
|
apiHits,
|
|
env: {
|
|
HTTPS_PROXY: proxyUrl,
|
|
https_proxy: proxyUrl,
|
|
NO_PROXY: '127.0.0.1,localhost,::1',
|
|
no_proxy: '127.0.0.1,localhost,::1',
|
|
SSL_CERT_FILE: caBundle
|
|
},
|
|
close: async () => {
|
|
for (const socket of sockets) {
|
|
socket.destroy()
|
|
}
|
|
|
|
await new Promise<void>(resolve => proxy.close(() => resolve()))
|
|
api.close()
|
|
}
|
|
}
|
|
}
|