@kinocode/dsh-mobile-bridge 0.0.0-stage → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +47 -2
- package/cordis.patch.yml +16 -0
- package/lib/auth.mjs +180 -0
- package/lib/client.js +219 -0
- package/lib/control.mjs +226 -0
- package/lib/desktop-api.mjs +102 -0
- package/lib/gateway-client.mjs +236 -0
- package/lib/gateway.mjs +319 -0
- package/lib/index.mjs +246 -0
- package/lib/preview.mjs +149 -0
- package/lib/push.mjs +173 -0
- package/lib/relay-tunnel.mjs +256 -0
- package/lib/sessions.mjs +131 -0
- package/lib/state.mjs +187 -0
- package/lib/tls-store.mjs +107 -0
- package/lib/upstream.mjs +61 -0
- package/lib/vendor/qrcode.mjs +2291 -0
- package/lib/web-sessions.mjs +113 -0
- package/lib/x509.mjs +228 -0
- package/package.json +63 -4
package/lib/sessions.mjs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Small in-memory view of live sessions built from `session/event`:
|
|
2
|
+
// titles, recent tool-call arguments (approval requests carry only callId), last assistant text.
|
|
3
|
+
|
|
4
|
+
const MAX_SESSIONS = 100
|
|
5
|
+
const MAX_CALLS_PER_SESSION = 64
|
|
6
|
+
const MAX_ARGUMENT_CHARS = 8000
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {object} SessionInfo
|
|
10
|
+
* @property {string} title
|
|
11
|
+
* @property {Map<string, {name: string, arguments: string}>} calls
|
|
12
|
+
* @property {string} lastAssistantText
|
|
13
|
+
* @property {number} touchedAt
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export class SessionTracker {
|
|
17
|
+
/**
|
|
18
|
+
* @param {{onTurnEnd?: (sessionId: string, reason: string) => void, now?: () => number}} hooks
|
|
19
|
+
*/
|
|
20
|
+
constructor(hooks = {}) {
|
|
21
|
+
this.hooks = hooks
|
|
22
|
+
this.now = hooks.now ?? Date.now
|
|
23
|
+
/** @type {Map<string, SessionInfo>} */
|
|
24
|
+
this.sessions = new Map()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @param {string} sessionId */
|
|
28
|
+
info(sessionId) {
|
|
29
|
+
let info = this.sessions.get(sessionId)
|
|
30
|
+
if (info === undefined) {
|
|
31
|
+
info = { title: '', calls: new Map(), lastAssistantText: '', touchedAt: this.now() }
|
|
32
|
+
this.sessions.set(sessionId, info)
|
|
33
|
+
if (this.sessions.size > MAX_SESSIONS) {
|
|
34
|
+
const oldest = [...this.sessions.entries()].sort((a, b) => a[1].touchedAt - b[1].touchedAt)[0]
|
|
35
|
+
if (oldest !== undefined) this.sessions.delete(oldest[0])
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
info.touchedAt = this.now()
|
|
39
|
+
return info
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @param {string} sessionId */
|
|
43
|
+
title(sessionId) { return this.sessions.get(sessionId)?.title ?? '' }
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Fill titles from Session Controller summaries (`projections.values.title.title`), so sessions
|
|
47
|
+
* titled before this plugin started still get a name. Live `session/title` events stay authoritative.
|
|
48
|
+
* @param {Array<{sessionId: string, projections?: {values?: Record<string, any>}}>} summaries
|
|
49
|
+
*/
|
|
50
|
+
learnTitles(summaries) {
|
|
51
|
+
for (const summary of summaries) {
|
|
52
|
+
const title = summaryTitle(summary)
|
|
53
|
+
if (title === '' || typeof summary.sessionId !== 'string') continue
|
|
54
|
+
const known = this.sessions.get(summary.sessionId)
|
|
55
|
+
if (known !== undefined && known.title !== '') continue
|
|
56
|
+
this.info(summary.sessionId).title = title
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {string} sessionId
|
|
62
|
+
* @param {string|undefined} callId
|
|
63
|
+
*/
|
|
64
|
+
call(sessionId, callId) {
|
|
65
|
+
if (callId === undefined) return undefined
|
|
66
|
+
return this.sessions.get(sessionId)?.calls.get(callId)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @param {string} sessionId */
|
|
70
|
+
lastAssistantText(sessionId) { return this.sessions.get(sessionId)?.lastAssistantText ?? '' }
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Consume one `session/event`.
|
|
74
|
+
* @param {{id: string}} session
|
|
75
|
+
* @param {{type: string, data?: any}} event
|
|
76
|
+
*/
|
|
77
|
+
observe(session, event) {
|
|
78
|
+
const sessionId = session?.id
|
|
79
|
+
if (typeof sessionId !== 'string' || event === null || typeof event !== 'object') return
|
|
80
|
+
const data = event.data ?? {}
|
|
81
|
+
switch (event.type) {
|
|
82
|
+
case 'session/title':
|
|
83
|
+
if (typeof data.title === 'string') this.info(sessionId).title = data.title.slice(0, 200)
|
|
84
|
+
return
|
|
85
|
+
case 'tool/call': {
|
|
86
|
+
if (typeof data.callId !== 'string') return
|
|
87
|
+
const calls = this.info(sessionId).calls
|
|
88
|
+
calls.set(data.callId, {
|
|
89
|
+
name: typeof data.name === 'string' ? data.name : '',
|
|
90
|
+
arguments: typeof data.arguments === 'string' ? data.arguments.slice(0, MAX_ARGUMENT_CHARS) : '',
|
|
91
|
+
})
|
|
92
|
+
while (calls.size > MAX_CALLS_PER_SESSION) calls.delete(calls.keys().next().value)
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
case 'assistant/message': {
|
|
96
|
+
const text = textOf(data.message)
|
|
97
|
+
if (text !== '') this.info(sessionId).lastAssistantText = text.slice(-2000)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
case 'turn/end': {
|
|
101
|
+
const reason = typeof data.reason?.kind === 'string' ? data.reason.kind : 'completed'
|
|
102
|
+
if (reason === 'forked') return
|
|
103
|
+
this.hooks.onTurnEnd?.(sessionId, reason)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
default:
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Title carried by a Session Controller summary, or ''.
|
|
113
|
+
* @param {{projections?: {values?: Record<string, any>}}} summary
|
|
114
|
+
*/
|
|
115
|
+
export function summaryTitle(summary) {
|
|
116
|
+
const value = summary?.projections?.values?.title
|
|
117
|
+
const title = typeof value === 'string' ? value : value?.title
|
|
118
|
+
return typeof title === 'string' ? title.slice(0, 200) : ''
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Join the text parts of an assistant message. */
|
|
122
|
+
function textOf(message) {
|
|
123
|
+
const content = message?.content
|
|
124
|
+
if (typeof content === 'string') return content.trim()
|
|
125
|
+
if (!Array.isArray(content)) return ''
|
|
126
|
+
return content
|
|
127
|
+
.filter(part => part !== null && typeof part === 'object' && part.type === 'text' && typeof part.text === 'string')
|
|
128
|
+
.map(part => part.text)
|
|
129
|
+
.join('\n')
|
|
130
|
+
.trim()
|
|
131
|
+
}
|
package/lib/state.mjs
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Durable plugin state: host identity and registered devices (one JSON file, mode 0600).
|
|
2
|
+
|
|
3
|
+
import { randomBytes, randomUUID } from 'node:crypto'
|
|
4
|
+
import { mkdir, readFile, rename, writeFile, chmod } from 'node:fs/promises'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
export const MAX_DEVICES = 16
|
|
8
|
+
export const MAX_WATCHES_PER_DEVICE = 50
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {object} DeviceRecord
|
|
12
|
+
* @property {string} publicKey - SPKI DER base64url
|
|
13
|
+
* @property {string} label
|
|
14
|
+
* @property {string|null} fcmToken
|
|
15
|
+
* @property {'redacted'|'full'} previewMode
|
|
16
|
+
* @property {boolean} pushEnabled
|
|
17
|
+
* @property {boolean} watchAll
|
|
18
|
+
* @property {Record<string, number>} watches - sessionId -> expiry (epoch ms)
|
|
19
|
+
* @property {number} createdAt
|
|
20
|
+
* @property {number} lastSeenAt
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Owns the state file; writes are serialized and atomic (tmp + rename). */
|
|
24
|
+
export class StateStore {
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} dir
|
|
27
|
+
* @param {{now?: () => number, log?: (...args: unknown[]) => void}} [options]
|
|
28
|
+
*/
|
|
29
|
+
constructor(dir, options = {}) {
|
|
30
|
+
this.dir = dir
|
|
31
|
+
this.file = join(dir, 'state.json')
|
|
32
|
+
this.now = options.now ?? Date.now
|
|
33
|
+
this.log = options.log ?? (() => {})
|
|
34
|
+
/** @type {{version: 1, hostId: string, hostSecret: string, devices: Record<string, DeviceRecord>}} */
|
|
35
|
+
this.data = { version: 1, hostId: '', hostSecret: '', devices: {} }
|
|
36
|
+
this.writing = Promise.resolve()
|
|
37
|
+
this.dirty = false
|
|
38
|
+
this.timer = undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async load() {
|
|
42
|
+
await mkdir(this.dir, { recursive: true, mode: 0o700 })
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(await readFile(this.file, 'utf8'))
|
|
45
|
+
if (parsed?.version !== 1 || typeof parsed.hostId !== 'string' || typeof parsed.hostSecret !== 'string') {
|
|
46
|
+
throw new Error('unsupported state file')
|
|
47
|
+
}
|
|
48
|
+
this.data = { version: 1, hostId: parsed.hostId, hostSecret: parsed.hostSecret, devices: sanitizeDevices(parsed.devices) }
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error?.code !== 'ENOENT') {
|
|
51
|
+
// A corrupt file must not silently re-key the host: keep a copy and start fresh loudly.
|
|
52
|
+
this.log('state file unreadable; starting a new host identity:', error?.message ?? String(error))
|
|
53
|
+
try {
|
|
54
|
+
await rename(this.file, `${this.file}.corrupt-${this.now()}`)
|
|
55
|
+
} catch { /* best effort: the original may already be gone */ }
|
|
56
|
+
}
|
|
57
|
+
this.data = { version: 1, hostId: randomUUID(), hostSecret: randomBytes(32).toString('base64url'), devices: {} }
|
|
58
|
+
await this.flush()
|
|
59
|
+
}
|
|
60
|
+
return this
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get hostId() { return this.data.hostId }
|
|
64
|
+
get hostSecret() { return this.data.hostSecret }
|
|
65
|
+
|
|
66
|
+
/** @returns {Array<[string, DeviceRecord]>} */
|
|
67
|
+
devices() { return Object.entries(this.data.devices) }
|
|
68
|
+
|
|
69
|
+
/** @param {string} id */
|
|
70
|
+
device(id) { return Object.hasOwn(this.data.devices, id) ? this.data.devices[id] : undefined }
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Add a device; an older registration with the same device key (re-pairing) is replaced.
|
|
74
|
+
* @param {Omit<DeviceRecord, 'watches'|'createdAt'|'lastSeenAt'|'pushEnabled'|'watchAll'>} input
|
|
75
|
+
* @returns {string} new device id
|
|
76
|
+
*/
|
|
77
|
+
addDevice(input) {
|
|
78
|
+
for (const [id, record] of this.devices()) if (record.publicKey === input.publicKey) delete this.data.devices[id]
|
|
79
|
+
const entries = this.devices().sort((a, b) => a[1].lastSeenAt - b[1].lastSeenAt)
|
|
80
|
+
while (entries.length >= MAX_DEVICES) delete this.data.devices[entries.shift()[0]]
|
|
81
|
+
const id = randomBytes(16).toString('hex')
|
|
82
|
+
const now = this.now()
|
|
83
|
+
this.data.devices[id] = { ...input, pushEnabled: true, watchAll: false, watches: {}, createdAt: now, lastSeenAt: now }
|
|
84
|
+
this.save(true)
|
|
85
|
+
return id
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @param {string} id */
|
|
89
|
+
removeDevice(id) {
|
|
90
|
+
if (!Object.hasOwn(this.data.devices, id)) return false
|
|
91
|
+
delete this.data.devices[id]
|
|
92
|
+
this.save(true)
|
|
93
|
+
return true
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {string} id
|
|
98
|
+
* @param {(record: DeviceRecord) => void} mutate
|
|
99
|
+
* @param {boolean} [urgent]
|
|
100
|
+
*/
|
|
101
|
+
updateDevice(id, mutate, urgent = false) {
|
|
102
|
+
const record = this.device(id)
|
|
103
|
+
if (record === undefined) return false
|
|
104
|
+
mutate(record)
|
|
105
|
+
this.save(urgent)
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Devices that should hear about a session right now.
|
|
111
|
+
* @param {string} sessionId
|
|
112
|
+
* @returns {Array<[string, DeviceRecord]>}
|
|
113
|
+
*/
|
|
114
|
+
watchers(sessionId) {
|
|
115
|
+
const now = this.now()
|
|
116
|
+
return this.devices().filter(([, record]) => record.pushEnabled && record.fcmToken
|
|
117
|
+
&& (record.watchAll || (record.watches[sessionId] ?? 0) > now))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Schedule a write; urgent writes go out immediately, others are coalesced for 2 s. */
|
|
121
|
+
save(urgent = false) {
|
|
122
|
+
this.dirty = true
|
|
123
|
+
if (urgent) {
|
|
124
|
+
clearTimeout(this.timer)
|
|
125
|
+
this.timer = undefined
|
|
126
|
+
void this.flush()
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
if (this.timer === undefined) {
|
|
130
|
+
this.timer = setTimeout(() => {
|
|
131
|
+
this.timer = undefined
|
|
132
|
+
void this.flush()
|
|
133
|
+
}, 2000)
|
|
134
|
+
this.timer.unref?.()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
flush() {
|
|
139
|
+
this.writing = this.writing.then(async () => {
|
|
140
|
+
this.dirty = false
|
|
141
|
+
const tmp = `${this.file}.tmp-${process.pid}`
|
|
142
|
+
await writeFile(tmp, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600 })
|
|
143
|
+
await chmod(tmp, 0o600)
|
|
144
|
+
await rename(tmp, this.file)
|
|
145
|
+
}).catch((error) => {
|
|
146
|
+
this.log('state write failed:', error?.message ?? String(error))
|
|
147
|
+
})
|
|
148
|
+
return this.writing
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async close() {
|
|
152
|
+
clearTimeout(this.timer)
|
|
153
|
+
this.timer = undefined
|
|
154
|
+
if (this.dirty) await this.flush()
|
|
155
|
+
await this.writing
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Keep only well-formed device records from disk. */
|
|
160
|
+
function sanitizeDevices(value) {
|
|
161
|
+
/** @type {Record<string, DeviceRecord>} */
|
|
162
|
+
const out = {}
|
|
163
|
+
if (value === null || typeof value !== 'object') return out
|
|
164
|
+
for (const [id, record] of Object.entries(value)) {
|
|
165
|
+
if (!/^[a-f0-9]{32}$/.test(id) || record === null || typeof record !== 'object') continue
|
|
166
|
+
// v1 records were bound to a dsh-mobile device (mobileDeviceId); they cannot be trusted any more.
|
|
167
|
+
if (typeof record.publicKey !== 'string' || record.mobileDeviceId !== undefined) continue
|
|
168
|
+
const watches = {}
|
|
169
|
+
if (record.watches !== null && typeof record.watches === 'object') {
|
|
170
|
+
for (const [sessionId, expiry] of Object.entries(record.watches)) {
|
|
171
|
+
if (typeof expiry === 'number' && sessionId.length <= 200) watches[sessionId] = expiry
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
out[id] = {
|
|
175
|
+
publicKey: record.publicKey,
|
|
176
|
+
label: typeof record.label === 'string' ? record.label.slice(0, 80) : '',
|
|
177
|
+
fcmToken: typeof record.fcmToken === 'string' ? record.fcmToken : null,
|
|
178
|
+
previewMode: record.previewMode === 'full' ? 'full' : 'redacted',
|
|
179
|
+
pushEnabled: record.pushEnabled !== false,
|
|
180
|
+
watchAll: record.watchAll === true,
|
|
181
|
+
watches,
|
|
182
|
+
createdAt: typeof record.createdAt === 'number' ? record.createdAt : 0,
|
|
183
|
+
lastSeenAt: typeof record.lastSeenAt === 'number' ? record.lastSeenAt : 0,
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return out
|
|
187
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Gateway TLS material: one private CA (kept for years, pinned by phones) and a server
|
|
2
|
+
// certificate reissued whenever the set of listening addresses changes.
|
|
3
|
+
|
|
4
|
+
import { createPrivateKey, X509Certificate } from 'node:crypto'
|
|
5
|
+
import { hostname, networkInterfaces } from 'node:os'
|
|
6
|
+
import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { createCa, fingerprintOf, issueServerCertificate } from './x509.mjs'
|
|
9
|
+
|
|
10
|
+
const RENEW_BEFORE_MS = 30 * 24 * 60 * 60 * 1000
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Private IPv4 addresses a phone can reach: RFC 1918 LAN ranges and 100.64/10 (Tailscale CGNAT).
|
|
14
|
+
* LAN addresses come first so QR origins try the local network before Tailscale.
|
|
15
|
+
* @param {ReturnType<typeof networkInterfaces>} [table]
|
|
16
|
+
* @returns {string[]}
|
|
17
|
+
*/
|
|
18
|
+
export function reachableAddresses(table = networkInterfaces()) {
|
|
19
|
+
const lan = []
|
|
20
|
+
const tailnet = []
|
|
21
|
+
for (const entries of Object.values(table)) {
|
|
22
|
+
for (const entry of entries ?? []) {
|
|
23
|
+
if (entry.family !== 'IPv4' && entry.family !== 4) continue
|
|
24
|
+
if (entry.internal) continue
|
|
25
|
+
const [a, b] = entry.address.split('.').map(Number)
|
|
26
|
+
if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)) lan.push(entry.address)
|
|
27
|
+
else if (a === 100 && b >= 64 && b <= 127) tailnet.push(entry.address)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return [...new Set([...lan, ...tailnet])]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** DNS names to put in the server certificate (helps `.local` and Tailscale MagicDNS users). */
|
|
34
|
+
export function localDnsNames(name = hostname()) {
|
|
35
|
+
const short = name.replace(/\.(local|lan|home)$/i, '')
|
|
36
|
+
return [...new Set([short, `${short}.local`].filter(n => /^[A-Za-z0-9-]{1,63}(\.local)?$/.test(n)))]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class TlsStore {
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} dir - stateDir/tls
|
|
42
|
+
* @param {{log?: (...a: unknown[]) => void, now?: () => number}} [options]
|
|
43
|
+
*/
|
|
44
|
+
constructor(dir, options = {}) {
|
|
45
|
+
this.dir = dir
|
|
46
|
+
this.log = options.log ?? (() => {})
|
|
47
|
+
this.now = options.now ?? Date.now
|
|
48
|
+
this.ca = undefined
|
|
49
|
+
this.server = undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Load or create the CA. */
|
|
53
|
+
async loadCa() {
|
|
54
|
+
await mkdir(this.dir, { recursive: true, mode: 0o700 })
|
|
55
|
+
try {
|
|
56
|
+
const certPem = await readFile(join(this.dir, 'ca.pem'), 'utf8')
|
|
57
|
+
const keyPem = await readFile(join(this.dir, 'ca-key.pem'), 'utf8')
|
|
58
|
+
const cert = new X509Certificate(certPem)
|
|
59
|
+
if (!cert.ca || new Date(cert.validTo).getTime() <= this.now()) throw new Error('CA expired or invalid')
|
|
60
|
+
this.ca = { certPem, key: createPrivateKey(keyPem), der: cert.raw, fingerprint: fingerprintOf(cert.raw) }
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error?.code !== 'ENOENT') this.log('gateway CA unusable; creating a new one (phones must pair again):', error?.message ?? String(error))
|
|
63
|
+
const ca = createCa({ now: new Date(this.now()) })
|
|
64
|
+
await this.write('ca-key.pem', ca.keyPem, 0o600)
|
|
65
|
+
await this.write('ca.pem', ca.certPem, 0o644)
|
|
66
|
+
this.ca = { certPem: ca.certPem, key: createPrivateKey(ca.keyPem), der: ca.certDer, fingerprint: ca.fingerprint }
|
|
67
|
+
}
|
|
68
|
+
return this.ca
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Server certificate covering exactly these addresses (reused while it still matches).
|
|
73
|
+
* @param {string[]} ips
|
|
74
|
+
* @returns {Promise<{certPem: string, keyPem: string}>}
|
|
75
|
+
*/
|
|
76
|
+
async serverFor(ips) {
|
|
77
|
+
if (this.ca === undefined) await this.loadCa()
|
|
78
|
+
const key = [...ips].sort().join(',')
|
|
79
|
+
if (this.server?.key === key && this.server.notAfter - this.now() > RENEW_BEFORE_MS) return this.server
|
|
80
|
+
try {
|
|
81
|
+
const certPem = await readFile(join(this.dir, 'server.pem'), 'utf8')
|
|
82
|
+
const keyPem = await readFile(join(this.dir, 'server-key.pem'), 'utf8')
|
|
83
|
+
const cert = new X509Certificate(certPem)
|
|
84
|
+
const notAfter = new Date(cert.validTo).getTime()
|
|
85
|
+
const covers = ips.every(ip => cert.checkIP(ip) !== undefined) && cert.verify(new X509Certificate(this.ca.certPem).publicKey)
|
|
86
|
+
const sameSet = (cert.subjectAltName ?? '').split(', ').filter(s => s.startsWith('IP Address:')).length === ips.length
|
|
87
|
+
if (covers && sameSet && notAfter - this.now() > RENEW_BEFORE_MS) {
|
|
88
|
+
this.server = { key, certPem, keyPem, notAfter }
|
|
89
|
+
return this.server
|
|
90
|
+
}
|
|
91
|
+
} catch { /* missing or stale: issue below */ }
|
|
92
|
+
const issued = issueServerCertificate({ caCertPem: this.ca.certPem, caKey: this.ca.key, ips, dnsNames: localDnsNames(), now: new Date(this.now()) })
|
|
93
|
+
await this.write('server-key.pem', issued.keyPem, 0o600)
|
|
94
|
+
await this.write('server.pem', issued.certPem, 0o644)
|
|
95
|
+
this.server = { key, ...issued, notAfter: new Date(new X509Certificate(issued.certPem).validTo).getTime() }
|
|
96
|
+
this.log('gateway certificate issued', JSON.stringify({ ips }))
|
|
97
|
+
return this.server
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async write(name, content, mode) {
|
|
101
|
+
const path = join(this.dir, name)
|
|
102
|
+
const tmp = `${path}.tmp-${process.pid}`
|
|
103
|
+
await writeFile(tmp, content, { mode })
|
|
104
|
+
await chmod(tmp, mode)
|
|
105
|
+
await rename(tmp, path)
|
|
106
|
+
}
|
|
107
|
+
}
|
package/lib/upstream.mjs
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Authenticated access to the local DSH Web server for the gateway.
|
|
2
|
+
// DSH's browser login is `/?token=<launch token>` → signed host-bound cookie. The gateway does
|
|
3
|
+
// that once (server side) and attaches the cookie to proxied requests; phones never see it.
|
|
4
|
+
|
|
5
|
+
import { request } from 'node:http'
|
|
6
|
+
|
|
7
|
+
export class Upstream {
|
|
8
|
+
/**
|
|
9
|
+
* @param {object} deps
|
|
10
|
+
* @param {() => number|undefined} deps.port - DSH webServer port (127.0.0.1)
|
|
11
|
+
* @param {(baseUrl: string) => string} deps.authenticatedUrl - ctx.connection.authenticatedUrl
|
|
12
|
+
* @param {(...a: unknown[]) => void} deps.log
|
|
13
|
+
*/
|
|
14
|
+
constructor(deps) {
|
|
15
|
+
this.deps = deps
|
|
16
|
+
/** @type {{port: number, cookie: string} | undefined} */
|
|
17
|
+
this.cached = undefined
|
|
18
|
+
this.pending = undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
invalidate() { this.cached = undefined }
|
|
22
|
+
|
|
23
|
+
/** @returns {Promise<{port: number, cookie: string} | undefined>} */
|
|
24
|
+
async target() {
|
|
25
|
+
const port = this.deps.port()
|
|
26
|
+
if (port === undefined || port === 0) return undefined
|
|
27
|
+
if (this.cached?.port === port) return this.cached
|
|
28
|
+
this.pending ??= this.login(port).finally(() => { this.pending = undefined })
|
|
29
|
+
return this.pending
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @param {number} port */
|
|
33
|
+
async login(port) {
|
|
34
|
+
let url
|
|
35
|
+
try {
|
|
36
|
+
url = new URL(this.deps.authenticatedUrl(`http://127.0.0.1:${port}/`))
|
|
37
|
+
} catch (error) {
|
|
38
|
+
this.deps.log('gateway: cannot build DSH login URL:', error?.message ?? String(error))
|
|
39
|
+
return undefined
|
|
40
|
+
}
|
|
41
|
+
const cookie = await new Promise((resolve) => {
|
|
42
|
+
const req = request({ host: '127.0.0.1', port, method: 'GET', path: `${url.pathname}${url.search}`, headers: { host: `127.0.0.1:${port}`, accept: 'text/html' } }, (res) => {
|
|
43
|
+
res.resume()
|
|
44
|
+
const setCookie = res.headers['set-cookie'] ?? []
|
|
45
|
+
resolve(setCookie.map(line => line.split(';')[0].trim()).filter(Boolean).join('; '))
|
|
46
|
+
})
|
|
47
|
+
req.on('error', (error) => {
|
|
48
|
+
this.deps.log('gateway: DSH login failed:', error?.code ?? error?.message)
|
|
49
|
+
resolve('')
|
|
50
|
+
})
|
|
51
|
+
req.setTimeout(10_000, () => req.destroy(new Error('timeout')))
|
|
52
|
+
req.end()
|
|
53
|
+
})
|
|
54
|
+
if (cookie === '') {
|
|
55
|
+
this.deps.log('gateway: DSH login returned no cookie')
|
|
56
|
+
return undefined
|
|
57
|
+
}
|
|
58
|
+
this.cached = { port, cookie }
|
|
59
|
+
return this.cached
|
|
60
|
+
}
|
|
61
|
+
}
|