ocremote 1.3.0
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/README.md +277 -0
- package/install.sh +261 -0
- package/lib/crypto.mjs +146 -0
- package/lib/identity.mjs +165 -0
- package/lib/relay.mjs +495 -0
- package/lib/rpc.mjs +114 -0
- package/oc-remote.mjs +2390 -0
- package/package.json +34 -0
- package/vendor/qrcode.js +2297 -0
package/oc-remote.mjs
ADDED
|
@@ -0,0 +1,2390 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import http from 'node:http'
|
|
3
|
+
import https from 'node:https'
|
|
4
|
+
import net from 'node:net'
|
|
5
|
+
import os from 'node:os'
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import zlib from 'node:zlib'
|
|
9
|
+
import crypto from 'node:crypto'
|
|
10
|
+
import { spawn, execFile } from 'node:child_process'
|
|
11
|
+
import { createRequire } from 'node:module'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
import { Identity } from './lib/identity.mjs'
|
|
14
|
+
import { RelayClient } from './lib/relay.mjs'
|
|
15
|
+
import { RpcAdapter } from './lib/rpc.mjs'
|
|
16
|
+
import { b64 } from './lib/crypto.mjs'
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url)
|
|
19
|
+
const NAME = 'oc-remote'
|
|
20
|
+
const VERSION = '1.3.0'
|
|
21
|
+
const LAUNCH_LABEL = 'com.raul.ocremote'
|
|
22
|
+
const LOG_PATH = path.join(os.homedir(), 'Library', 'Logs', 'oc-remote.log')
|
|
23
|
+
const STARTED_AT = Date.now()
|
|
24
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
25
|
+
const LOOPBACK = '127.0.0.1'
|
|
26
|
+
|
|
27
|
+
let qrcode = null
|
|
28
|
+
try {
|
|
29
|
+
qrcode = require(path.join(HERE, 'vendor', 'qrcode.js'))
|
|
30
|
+
} catch {
|
|
31
|
+
qrcode = null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function augmentPath() {
|
|
35
|
+
const extras = [
|
|
36
|
+
'/opt/homebrew/bin',
|
|
37
|
+
'/usr/local/bin',
|
|
38
|
+
'/usr/bin',
|
|
39
|
+
path.join(os.homedir(), '.local', 'bin'),
|
|
40
|
+
path.join(os.homedir(), '.opencode', 'bin'),
|
|
41
|
+
]
|
|
42
|
+
const current = (process.env.PATH || '').split(':').filter(Boolean)
|
|
43
|
+
const missing = extras.filter((dir) => dir && !current.includes(dir) && fs.existsSync(dir))
|
|
44
|
+
if (missing.length) process.env.PATH = [...current, ...missing].join(':')
|
|
45
|
+
}
|
|
46
|
+
augmentPath()
|
|
47
|
+
|
|
48
|
+
const HELP = `oc-remote v${VERSION} - remote companion for opencode
|
|
49
|
+
|
|
50
|
+
Usage:
|
|
51
|
+
oc-remote [options] Start the companion (and the daemon entry point)
|
|
52
|
+
npx ocremote --pair First run: check opencode, show the pairing QR
|
|
53
|
+
npx ocremote --daemon Install as a background service (LaunchAgent)
|
|
54
|
+
oc-remote doctor Diagnose every link in the connection chain
|
|
55
|
+
oc-remote status Show config, daemon state and live endpoint
|
|
56
|
+
oc-remote restart Restart the installed LaunchAgent
|
|
57
|
+
oc-remote logs Show the daemon log path and last lines
|
|
58
|
+
oc-remote funnel on|off|status Enable/disable the stable Tailscale Funnel URL
|
|
59
|
+
oc-remote pair Print a fresh pairing QR for OpenCode Remote v2
|
|
60
|
+
oc-remote relay-status Relay identity, connection and paired devices
|
|
61
|
+
oc-remote unpair <pairID> Revoke a paired device (relay + local)
|
|
62
|
+
oc-remote version Print the version
|
|
63
|
+
|
|
64
|
+
Options:
|
|
65
|
+
--dir <path> Project directory used by opencode (default: cwd)
|
|
66
|
+
--port <n> Companion HTTP/SSE proxy port (default: 4190)
|
|
67
|
+
--opencode-port <n> Loopback port for the internal opencode serve (default: 4191)
|
|
68
|
+
--utility-port <n> Loopback-only port for pairing/utility pages (default: port+1)
|
|
69
|
+
--relay <url> Relay v2 URL (wss://relay.example) for anywhere access
|
|
70
|
+
--device-name <name> Name shown on the phone (default: hostname)
|
|
71
|
+
--tunnel <mode> Remote access: auto | funnel | cloudflare | tailscale | none
|
|
72
|
+
auto reuse an active funnel, else cloudflared, else Tailscale, else LAN
|
|
73
|
+
funnel Tailscale Funnel: stable public HTTPS URL (one-time approval)
|
|
74
|
+
cloudflare quick tunnel, downloads cloudflared if needed (zero setup)
|
|
75
|
+
tailscale advertise the Tailscale IP (stable, needs Tailscale app)
|
|
76
|
+
none LAN only (default when nothing else is available)
|
|
77
|
+
--host <ip> Bind address for the companion (default: 0.0.0.0)
|
|
78
|
+
--token <str> Set the pairing token (persisted in config.json)
|
|
79
|
+
--new-token Rotate the pairing token (only way to rotate)
|
|
80
|
+
--ntfy <topic|url> Enable ntfy push notifications and endpoint beacons
|
|
81
|
+
--public-url <url> Public base URL to advertise (skips --tunnel)
|
|
82
|
+
--no-auth Disable token auth (DANGEROUS, LAN becomes open)
|
|
83
|
+
--opencode <path> opencode binary (default: ~/.opencode/bin/opencode)
|
|
84
|
+
--no-mdns Do not publish _ocremote._tcp via Bonjour
|
|
85
|
+
--print-qr Print pairing QR at startup (default)
|
|
86
|
+
--no-print-qr Do not print the pairing QR
|
|
87
|
+
--help Show this help
|
|
88
|
+
`
|
|
89
|
+
|
|
90
|
+
const SUBCOMMANDS = new Set(['doctor', 'status', 'restart', 'logs', 'version', 'funnel', 'pair', 'relay-status', 'unpair'])
|
|
91
|
+
|
|
92
|
+
const VALUE_FLAGS = new Set([
|
|
93
|
+
'dir',
|
|
94
|
+
'port',
|
|
95
|
+
'opencode-port',
|
|
96
|
+
'utility-port',
|
|
97
|
+
'host',
|
|
98
|
+
'token',
|
|
99
|
+
'ntfy',
|
|
100
|
+
'public-url',
|
|
101
|
+
'opencode',
|
|
102
|
+
'tunnel',
|
|
103
|
+
'relay',
|
|
104
|
+
'device-name',
|
|
105
|
+
])
|
|
106
|
+
const BOOL_FLAGS = new Set(['new-token', 'no-auth', 'no-mdns', 'print-qr', 'no-print-qr', 'help', 'pair', 'daemon'])
|
|
107
|
+
const TUNNEL_MODES = new Set(['auto', 'funnel', 'cloudflare', 'tailscale', 'none'])
|
|
108
|
+
const ALLOWED_METHODS = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'])
|
|
109
|
+
|
|
110
|
+
let shuttingDown = false
|
|
111
|
+
let server = null
|
|
112
|
+
let child = null
|
|
113
|
+
let mdnsChild = null
|
|
114
|
+
let restartTimer = null
|
|
115
|
+
let restartAttempts = 0
|
|
116
|
+
let activeClients = 0
|
|
117
|
+
let allUrls = []
|
|
118
|
+
let primaryUrl = ''
|
|
119
|
+
let remoteToken = ''
|
|
120
|
+
let internalPassword = ''
|
|
121
|
+
let internalAuth = ''
|
|
122
|
+
let tunnelChild = null
|
|
123
|
+
let tunnelTimer = null
|
|
124
|
+
let tunnelUrl = ''
|
|
125
|
+
let tunnelProvider = null
|
|
126
|
+
let tunnelAttempts = 0
|
|
127
|
+
let quickTunnelUrl = ''
|
|
128
|
+
let funnelMonitorTimer = null
|
|
129
|
+
let transportTimer = null
|
|
130
|
+
let cachedTailscaleIp = null
|
|
131
|
+
let endpointTimer = null
|
|
132
|
+
let lastBeaconKey = ''
|
|
133
|
+
let watchdogTimer = null
|
|
134
|
+
let watchdogFailures = 0
|
|
135
|
+
const sockets = new Set()
|
|
136
|
+
|
|
137
|
+
const AUTH_FAIL_LIMIT = 20
|
|
138
|
+
const AUTH_FAIL_WINDOW = 60_000
|
|
139
|
+
const AUTH_BLOCK_MS = 60_000
|
|
140
|
+
const authFailures = new Map()
|
|
141
|
+
|
|
142
|
+
function log(level, msg) {
|
|
143
|
+
process.stderr.write(`[oc-remote] ${new Date().toISOString()} ${level} ${msg}\n`)
|
|
144
|
+
}
|
|
145
|
+
const info = (m) => log('INFO', m)
|
|
146
|
+
const warn = (m) => log('WARN', m)
|
|
147
|
+
const error = (m) => log('ERROR', m)
|
|
148
|
+
|
|
149
|
+
function randomHex(bytes) {
|
|
150
|
+
return crypto.randomBytes(bytes).toString('hex')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function safeEqual(a, b) {
|
|
154
|
+
const ab = Buffer.from(String(a))
|
|
155
|
+
const bb = Buffer.from(String(b))
|
|
156
|
+
if (ab.length !== bb.length) {
|
|
157
|
+
crypto.timingSafeEqual(bb, bb)
|
|
158
|
+
return false
|
|
159
|
+
}
|
|
160
|
+
return crypto.timingSafeEqual(ab, bb)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function authBlocked(ip) {
|
|
164
|
+
const now = Date.now()
|
|
165
|
+
const record = authFailures.get(ip)
|
|
166
|
+
if (!record) return false
|
|
167
|
+
if (record.blockedUntil && record.blockedUntil > now) return true
|
|
168
|
+
if (record.blockedUntil) {
|
|
169
|
+
authFailures.delete(ip)
|
|
170
|
+
return false
|
|
171
|
+
}
|
|
172
|
+
if (now - record.windowStart > AUTH_FAIL_WINDOW) {
|
|
173
|
+
authFailures.delete(ip)
|
|
174
|
+
return false
|
|
175
|
+
}
|
|
176
|
+
if (record.count >= AUTH_FAIL_LIMIT) {
|
|
177
|
+
record.blockedUntil = now + AUTH_BLOCK_MS
|
|
178
|
+
return true
|
|
179
|
+
}
|
|
180
|
+
return false
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function noteAuthFailure(ip) {
|
|
184
|
+
const now = Date.now()
|
|
185
|
+
const record = authFailures.get(ip)
|
|
186
|
+
if (!record || now - record.windowStart > AUTH_FAIL_WINDOW) {
|
|
187
|
+
authFailures.set(ip, { count: 1, windowStart: now, blockedUntil: 0 })
|
|
188
|
+
} else {
|
|
189
|
+
record.count++
|
|
190
|
+
}
|
|
191
|
+
if (authFailures.get(ip).count >= AUTH_FAIL_LIMIT) {
|
|
192
|
+
authFailures.get(ip).blockedUntil = now + AUTH_BLOCK_MS
|
|
193
|
+
warn(`auth: too many failed attempts from ${ip}; blocking for ${AUTH_BLOCK_MS / 1000}s`)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function clearAuthFailures(ip) {
|
|
198
|
+
authFailures.delete(ip)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function sleep(ms) {
|
|
202
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function ntfyTarget(raw) {
|
|
206
|
+
const text = String(raw || '').trim()
|
|
207
|
+
if (!text) return null
|
|
208
|
+
if (/^https?:\/\//i.test(text)) {
|
|
209
|
+
try {
|
|
210
|
+
const url = new URL(text)
|
|
211
|
+
const segments = url.pathname.split('/').filter(Boolean)
|
|
212
|
+
const topic = segments.pop() || ''
|
|
213
|
+
const base = `${url.origin}${segments.length ? `/${segments.join('/')}` : ''}`
|
|
214
|
+
return { base: base.replace(/\/+$/, ''), topic, query: url.search || '' }
|
|
215
|
+
} catch {
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { base: 'https://ntfy.sh', topic: text, query: '' }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function publishNtfy(payload) {
|
|
223
|
+
const target = opts.ntfyTarget || (opts.ntfyTarget = ntfyTarget(opts.ntfy))
|
|
224
|
+
if (!target || !target.topic) {
|
|
225
|
+
warn('ntfy: no topic configured; skipping publish')
|
|
226
|
+
return Promise.resolve()
|
|
227
|
+
}
|
|
228
|
+
const url = `${target.base}/${target.query}`
|
|
229
|
+
return fetch(url, {
|
|
230
|
+
method: 'POST',
|
|
231
|
+
headers: { 'content-type': 'application/json' },
|
|
232
|
+
body: JSON.stringify({ topic: target.topic, ...payload }),
|
|
233
|
+
signal: AbortSignal.timeout(5000),
|
|
234
|
+
})
|
|
235
|
+
.then((res) => {
|
|
236
|
+
if (!res.ok) warn(`ntfy: publish failed with HTTP ${res.status}`)
|
|
237
|
+
})
|
|
238
|
+
.catch((err) => warn(`ntfy: publish failed: ${err.message}`))
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function parseArgs(argv) {
|
|
242
|
+
const flags = {}
|
|
243
|
+
for (let i = 0; i < argv.length; i++) {
|
|
244
|
+
const arg = argv[i]
|
|
245
|
+
if (!arg.startsWith('--')) {
|
|
246
|
+
return { error: `unexpected argument: ${arg}` }
|
|
247
|
+
}
|
|
248
|
+
const eq = arg.indexOf('=')
|
|
249
|
+
const key = eq >= 0 ? arg.slice(2, eq) : arg.slice(2)
|
|
250
|
+
if (BOOL_FLAGS.has(key)) {
|
|
251
|
+
flags[key] = true
|
|
252
|
+
continue
|
|
253
|
+
}
|
|
254
|
+
if (!VALUE_FLAGS.has(key)) {
|
|
255
|
+
return { error: `unknown flag: --${key}` }
|
|
256
|
+
}
|
|
257
|
+
const value = eq >= 0 ? arg.slice(eq + 1) : argv[++i]
|
|
258
|
+
if (value === undefined) {
|
|
259
|
+
return { error: `missing value for --${key}` }
|
|
260
|
+
}
|
|
261
|
+
flags[key] = value
|
|
262
|
+
}
|
|
263
|
+
if (flags['no-print-qr']) flags['print-qr'] = false
|
|
264
|
+
return { flags }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function configPaths() {
|
|
268
|
+
const dir = process.env.OCREMOTE_CONFIG_DIR
|
|
269
|
+
? path.resolve(process.env.OCREMOTE_CONFIG_DIR)
|
|
270
|
+
: path.join(os.homedir(), '.config', 'oc-remote')
|
|
271
|
+
return { dir, file: path.join(dir, 'config.json') }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function loadConfig() {
|
|
275
|
+
const { file } = configPaths()
|
|
276
|
+
try {
|
|
277
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
278
|
+
return parsed && typeof parsed === 'object' ? parsed : {}
|
|
279
|
+
} catch {
|
|
280
|
+
return {}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function saveConfig(cfg) {
|
|
285
|
+
const { dir, file } = configPaths()
|
|
286
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
287
|
+
try {
|
|
288
|
+
fs.chmodSync(dir, 0o700)
|
|
289
|
+
} catch {}
|
|
290
|
+
const tmp = `${file}.tmp`
|
|
291
|
+
fs.writeFileSync(tmp, `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 })
|
|
292
|
+
fs.renameSync(tmp, file)
|
|
293
|
+
try {
|
|
294
|
+
fs.chmodSync(file, 0o600)
|
|
295
|
+
} catch {}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function resolveOpencodeBin(flagValue) {
|
|
299
|
+
if (flagValue) return flagValue
|
|
300
|
+
if (process.env.OPENCODE_BIN) return process.env.OPENCODE_BIN
|
|
301
|
+
const homeBin = path.join(os.homedir(), '.opencode', 'bin', 'opencode')
|
|
302
|
+
if (fs.existsSync(homeBin)) return homeBin
|
|
303
|
+
return 'opencode'
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function locateBin(bin) {
|
|
307
|
+
if (bin.includes('/')) {
|
|
308
|
+
return fs.existsSync(bin) ? bin : null
|
|
309
|
+
}
|
|
310
|
+
for (const dir of (process.env.PATH || '').split(':')) {
|
|
311
|
+
if (!dir) continue
|
|
312
|
+
const candidate = path.join(dir, bin)
|
|
313
|
+
try {
|
|
314
|
+
fs.accessSync(candidate, fs.constants.X_OK)
|
|
315
|
+
return candidate
|
|
316
|
+
} catch {}
|
|
317
|
+
}
|
|
318
|
+
return null
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function lanIPv4() {
|
|
322
|
+
const out = []
|
|
323
|
+
for (const [name, addrs] of Object.entries(os.networkInterfaces())) {
|
|
324
|
+
for (const addr of addrs || []) {
|
|
325
|
+
if (addr.family !== 'IPv4' || addr.internal) continue
|
|
326
|
+
if (addr.address.startsWith('169.254.')) continue
|
|
327
|
+
out.push({ name, address: addr.address })
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return out
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function tailscaleBin() {
|
|
334
|
+
return process.env.OCREMOTE_TAILSCALE || 'tailscale'
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function execTailscale(args, timeout = 10_000) {
|
|
338
|
+
return new Promise((resolve) => {
|
|
339
|
+
execFile(tailscaleBin(), args, { timeout }, (err, stdout, stderr) => {
|
|
340
|
+
resolve({
|
|
341
|
+
ok: !err,
|
|
342
|
+
code: err && typeof err.code === 'number' ? err.code : err ? 1 : 0,
|
|
343
|
+
stdout: String(stdout || ''),
|
|
344
|
+
stderr: String(stderr || ''),
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function tailscaleIp() {
|
|
351
|
+
return new Promise((resolve) => {
|
|
352
|
+
execFile(tailscaleBin(), ['ip', '-4'], { timeout: 1500 }, (err, stdout) => {
|
|
353
|
+
if (err) return resolve(null)
|
|
354
|
+
const ip = String(stdout)
|
|
355
|
+
.split('\n')
|
|
356
|
+
.map((line) => line.trim())
|
|
357
|
+
.filter(Boolean)[0]
|
|
358
|
+
resolve(ip && net.isIPv4(ip) ? ip : null)
|
|
359
|
+
})
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function tailscaleStatusJson() {
|
|
364
|
+
const result = await execTailscale(['status', '--json'], 5000)
|
|
365
|
+
if (!result.ok) return null
|
|
366
|
+
try {
|
|
367
|
+
return JSON.parse(result.stdout)
|
|
368
|
+
} catch {
|
|
369
|
+
return null
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function tailscaleRunning() {
|
|
374
|
+
const status = await tailscaleStatusJson()
|
|
375
|
+
if (!status) return false
|
|
376
|
+
return status.BackendState === 'Running'
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function tailscaleDnsName() {
|
|
380
|
+
const status = await tailscaleStatusJson()
|
|
381
|
+
const name = status && status.Self && status.Self.DNSName
|
|
382
|
+
if (typeof name !== 'string' || !name.length) return null
|
|
383
|
+
return name.replace(/\.$/, '')
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function funnelStatusJson() {
|
|
387
|
+
const result = await execTailscale(['funnel', 'status', '--json'], 5000)
|
|
388
|
+
if (!result.ok) return null
|
|
389
|
+
const text = result.stdout.trim()
|
|
390
|
+
if (!text || text === '{}') return {}
|
|
391
|
+
try {
|
|
392
|
+
return JSON.parse(text)
|
|
393
|
+
} catch {
|
|
394
|
+
return null
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function funnelProxyTarget(dnsName) {
|
|
399
|
+
const status = await funnelStatusJson()
|
|
400
|
+
if (!status || !status.Web || !dnsName) return null
|
|
401
|
+
const entry = status.Web[`${dnsName}:443`]
|
|
402
|
+
if (!entry || !entry.Handlers) return null
|
|
403
|
+
const root = entry.Handlers['/']
|
|
404
|
+
if (!root || typeof root.Proxy !== 'string') return null
|
|
405
|
+
return root.Proxy
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function funnelProxyMatches(proxy) {
|
|
409
|
+
if (!proxy) return false
|
|
410
|
+
return new RegExp(`(?:127\\.0\\.0\\.1|localhost):${opts.port}(?:/|$)`).test(proxy)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function funnelUrlIfActive() {
|
|
414
|
+
if (!(await tailscaleRunning())) return null
|
|
415
|
+
const dnsName = await tailscaleDnsName()
|
|
416
|
+
if (!dnsName) return null
|
|
417
|
+
const proxy = await funnelProxyTarget(dnsName)
|
|
418
|
+
if (!funnelProxyMatches(proxy)) return null
|
|
419
|
+
return `https://${dnsName}`
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function enableFunnel() {
|
|
423
|
+
if (!(await tailscaleRunning())) {
|
|
424
|
+
info('tailscale is not running; trying `tailscale up`...')
|
|
425
|
+
await execTailscale(['up'], 15_000)
|
|
426
|
+
if (!(await tailscaleRunning())) {
|
|
427
|
+
error('tailscale is not connected. Open the Tailscale app and sign in, then retry')
|
|
428
|
+
return null
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const dnsName = await tailscaleDnsName()
|
|
432
|
+
if (!dnsName) {
|
|
433
|
+
error('could not read the Tailscale DNS name (is MagicDNS enabled?)')
|
|
434
|
+
return null
|
|
435
|
+
}
|
|
436
|
+
const active = await funnelUrlIfActive()
|
|
437
|
+
if (active) return active
|
|
438
|
+
|
|
439
|
+
info(`enabling Tailscale Funnel on https://${dnsName} -> http://${LOOPBACK}:${opts.port}`)
|
|
440
|
+
const result = await execTailscale(
|
|
441
|
+
['funnel', '--bg', '--https=443', `http://${LOOPBACK}:${opts.port}`],
|
|
442
|
+
20_000
|
|
443
|
+
)
|
|
444
|
+
const output = `${result.stdout}\n${result.stderr}`
|
|
445
|
+
const approval = /https:\/\/login\.tailscale\.com\/[^\s]+/.exec(output)
|
|
446
|
+
if (approval) {
|
|
447
|
+
warn('Funnel needs a one-time approval. Open this URL, approve Funnel, then restart oc-remote:')
|
|
448
|
+
warn(` ${approval[0]}`)
|
|
449
|
+
return null
|
|
450
|
+
}
|
|
451
|
+
if (!result.ok) {
|
|
452
|
+
error(`could not enable Tailscale Funnel: ${(result.stderr || result.stdout).trim() || 'unknown error'}`)
|
|
453
|
+
return null
|
|
454
|
+
}
|
|
455
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
456
|
+
const url = await funnelUrlIfActive()
|
|
457
|
+
if (url) return url
|
|
458
|
+
await sleep(1000)
|
|
459
|
+
}
|
|
460
|
+
const url = await tailscaleDnsName()
|
|
461
|
+
return url ? `https://${url}` : null
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function disableFunnel() {
|
|
465
|
+
const dnsName = await tailscaleDnsName()
|
|
466
|
+
const proxy = await funnelProxyTarget(dnsName)
|
|
467
|
+
if (!funnelProxyMatches(proxy)) return
|
|
468
|
+
info('disabling Tailscale Funnel (it pointed at this companion)')
|
|
469
|
+
await execTailscale(['funnel', '--https=443', 'off'], 10_000)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function startFunnelMonitor() {
|
|
473
|
+
clearInterval(funnelMonitorTimer)
|
|
474
|
+
funnelMonitorTimer = setInterval(async () => {
|
|
475
|
+
if (shuttingDown || opts.tunnel !== 'funnel') return
|
|
476
|
+
if (await funnelUrlIfActive()) return
|
|
477
|
+
warn('Tailscale Funnel is no longer serving this companion; re-applying')
|
|
478
|
+
const url = await enableFunnel()
|
|
479
|
+
if (url && url !== tunnelUrl) {
|
|
480
|
+
tunnelUrl = url
|
|
481
|
+
rebuildUrls()
|
|
482
|
+
info(`funnel restored: ${tunnelUrl}`)
|
|
483
|
+
printPairing()
|
|
484
|
+
}
|
|
485
|
+
}, 30_000)
|
|
486
|
+
funnelMonitorTimer.unref?.()
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function isLoopback(req) {
|
|
490
|
+
const addr = req.socket.remoteAddress || ''
|
|
491
|
+
return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1'
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function extractToken(req, url) {
|
|
495
|
+
const header = req.headers.authorization
|
|
496
|
+
if (header) {
|
|
497
|
+
const bearer = /^Bearer\s+(.+)$/i.exec(header)
|
|
498
|
+
if (bearer) return bearer[1].trim()
|
|
499
|
+
const basic = /^Basic\s+(.+)$/i.exec(header)
|
|
500
|
+
if (basic) {
|
|
501
|
+
try {
|
|
502
|
+
const decoded = Buffer.from(basic[1], 'base64').toString('utf8')
|
|
503
|
+
const idx = decoded.indexOf(':')
|
|
504
|
+
if (idx >= 0) return decoded.slice(idx + 1)
|
|
505
|
+
} catch {}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
const authToken = url.searchParams.get('auth_token')
|
|
509
|
+
if (authToken) {
|
|
510
|
+
try {
|
|
511
|
+
const decoded = Buffer.from(authToken, 'base64').toString('utf8')
|
|
512
|
+
const idx = decoded.indexOf(':')
|
|
513
|
+
if (idx >= 0) return decoded.slice(idx + 1)
|
|
514
|
+
} catch {}
|
|
515
|
+
}
|
|
516
|
+
const plain = url.searchParams.get('token')
|
|
517
|
+
if (plain) return plain
|
|
518
|
+
return null
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function authorized(req, url) {
|
|
522
|
+
if (opts.noAuth) return true
|
|
523
|
+
return safeEqual(extractToken(req, url) || '', remoteToken)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function sendJson(req, res, status, payload, extraHeaders) {
|
|
527
|
+
const body = JSON.stringify(payload)
|
|
528
|
+
const headers = {
|
|
529
|
+
'content-type': 'application/json; charset=utf-8',
|
|
530
|
+
'content-length': Buffer.byteLength(body),
|
|
531
|
+
'cache-control': 'no-store',
|
|
532
|
+
...(extraHeaders || {}),
|
|
533
|
+
}
|
|
534
|
+
res.writeHead(status, headers)
|
|
535
|
+
if (req.method === 'HEAD') return res.end()
|
|
536
|
+
res.end(body)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function filterRequestHeaders(headers) {
|
|
540
|
+
const drop = new Set([
|
|
541
|
+
'host',
|
|
542
|
+
'authorization',
|
|
543
|
+
'connection',
|
|
544
|
+
'proxy-connection',
|
|
545
|
+
'keep-alive',
|
|
546
|
+
'upgrade',
|
|
547
|
+
'transfer-encoding',
|
|
548
|
+
'te',
|
|
549
|
+
'trailer',
|
|
550
|
+
'proxy-authorization',
|
|
551
|
+
'expect',
|
|
552
|
+
])
|
|
553
|
+
const out = {}
|
|
554
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
555
|
+
if (!drop.has(key.toLowerCase())) out[key] = value
|
|
556
|
+
}
|
|
557
|
+
return out
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function filterResponseHeaders(headers) {
|
|
561
|
+
const drop = new Set([
|
|
562
|
+
'connection',
|
|
563
|
+
'proxy-connection',
|
|
564
|
+
'keep-alive',
|
|
565
|
+
'upgrade',
|
|
566
|
+
'transfer-encoding',
|
|
567
|
+
'trailer',
|
|
568
|
+
'proxy-authenticate',
|
|
569
|
+
'proxy-authorization',
|
|
570
|
+
])
|
|
571
|
+
const out = {}
|
|
572
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
573
|
+
if (!drop.has(key.toLowerCase())) out[key] = value
|
|
574
|
+
}
|
|
575
|
+
return out
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function handleProxy(req, res, url) {
|
|
579
|
+
if (!ALLOWED_METHODS.has(req.method)) {
|
|
580
|
+
sendJson(req, res, 405, { error: 'method_not_allowed', method: req.method })
|
|
581
|
+
return
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const params = new URLSearchParams(url.searchParams)
|
|
585
|
+
params.delete('token')
|
|
586
|
+
params.delete('auth_token')
|
|
587
|
+
const qs = params.toString()
|
|
588
|
+
const targetPath = url.pathname + (qs ? `?${qs}` : '')
|
|
589
|
+
|
|
590
|
+
const headers = filterRequestHeaders(req.headers)
|
|
591
|
+
headers.authorization = internalAuth
|
|
592
|
+
|
|
593
|
+
const isEvent =
|
|
594
|
+
url.pathname === '/event' || url.pathname === '/global/event' || url.pathname === '/api/event'
|
|
595
|
+
|
|
596
|
+
activeClients++
|
|
597
|
+
let counted = false
|
|
598
|
+
const release = () => {
|
|
599
|
+
if (counted) return
|
|
600
|
+
counted = true
|
|
601
|
+
activeClients = Math.max(0, activeClients - 1)
|
|
602
|
+
}
|
|
603
|
+
res.on('close', release)
|
|
604
|
+
|
|
605
|
+
const upstream = http.request(
|
|
606
|
+
{
|
|
607
|
+
host: LOOPBACK,
|
|
608
|
+
port: opts.opencodePort,
|
|
609
|
+
method: req.method,
|
|
610
|
+
path: targetPath,
|
|
611
|
+
headers,
|
|
612
|
+
},
|
|
613
|
+
(upstreamRes) => {
|
|
614
|
+
const outHeaders = filterResponseHeaders(upstreamRes.headers)
|
|
615
|
+
if (isEvent) outHeaders['x-accel-buffering'] = 'no'
|
|
616
|
+
const status = upstreamRes.statusCode || 502
|
|
617
|
+
if (upstreamRes.statusMessage) {
|
|
618
|
+
res.writeHead(status, upstreamRes.statusMessage, outHeaders)
|
|
619
|
+
} else {
|
|
620
|
+
res.writeHead(status, outHeaders)
|
|
621
|
+
}
|
|
622
|
+
res.flushHeaders()
|
|
623
|
+
upstreamRes.on('data', (chunk) => {
|
|
624
|
+
if (!res.write(chunk)) {
|
|
625
|
+
upstreamRes.pause()
|
|
626
|
+
res.once('drain', () => upstreamRes.resume())
|
|
627
|
+
}
|
|
628
|
+
})
|
|
629
|
+
upstreamRes.on('end', () => res.end())
|
|
630
|
+
upstreamRes.on('error', () => res.destroy())
|
|
631
|
+
}
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
upstream.setNoDelay(true)
|
|
635
|
+
upstream.setTimeout(0)
|
|
636
|
+
upstream.on('error', (err) => {
|
|
637
|
+
release()
|
|
638
|
+
if (res.headersSent) {
|
|
639
|
+
res.destroy()
|
|
640
|
+
return
|
|
641
|
+
}
|
|
642
|
+
sendJson(req, res, 502, {
|
|
643
|
+
error: 'bad_gateway',
|
|
644
|
+
message:
|
|
645
|
+
err.code === 'ECONNREFUSED'
|
|
646
|
+
? 'opencode upstream is not running'
|
|
647
|
+
: err.code === 'ECONNRESET'
|
|
648
|
+
? 'opencode upstream closed the connection'
|
|
649
|
+
: err.message || String(err),
|
|
650
|
+
})
|
|
651
|
+
})
|
|
652
|
+
req.on('error', () => upstream.destroy())
|
|
653
|
+
res.on('close', () => {
|
|
654
|
+
if (!res.writableEnded) upstream.destroy()
|
|
655
|
+
})
|
|
656
|
+
req.pipe(upstream)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function statusPayload() {
|
|
660
|
+
return {
|
|
661
|
+
name: NAME,
|
|
662
|
+
version: VERSION,
|
|
663
|
+
directory: opts.dir,
|
|
664
|
+
uptime: Math.round((Date.now() - STARTED_AT) / 1000),
|
|
665
|
+
port: opts.port,
|
|
666
|
+
opencodePort: opts.opencodePort,
|
|
667
|
+
clients: activeClients,
|
|
668
|
+
tunnel: remoteUrl(),
|
|
669
|
+
tunnelProvider,
|
|
670
|
+
auth: !opts.noAuth,
|
|
671
|
+
utilityPort: opts.utilityPort,
|
|
672
|
+
relay: opts.relay || null,
|
|
673
|
+
deviceID: identity ? identity.deviceID() : null,
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function remoteUrl() {
|
|
678
|
+
return opts.publicUrl || tunnelUrl || null
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const CRC_TABLE = (() => {
|
|
682
|
+
const table = new Int32Array(256)
|
|
683
|
+
for (let n = 0; n < 256; n++) {
|
|
684
|
+
let c = n
|
|
685
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
|
686
|
+
table[n] = c
|
|
687
|
+
}
|
|
688
|
+
return table
|
|
689
|
+
})()
|
|
690
|
+
|
|
691
|
+
function crc32(buf) {
|
|
692
|
+
let c = 0xffffffff
|
|
693
|
+
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8)
|
|
694
|
+
return (c ^ 0xffffffff) >>> 0
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function pngChunk(type, data) {
|
|
698
|
+
const len = Buffer.alloc(4)
|
|
699
|
+
len.writeUInt32BE(data.length, 0)
|
|
700
|
+
const typeBuf = Buffer.from(type, 'latin1')
|
|
701
|
+
const crc = Buffer.alloc(4)
|
|
702
|
+
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0)
|
|
703
|
+
return Buffer.concat([len, typeBuf, data, crc])
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function qrPng(qr, scale = 8, margin = 4) {
|
|
707
|
+
const count = qr.getModuleCount()
|
|
708
|
+
const size = (count + margin * 2) * scale
|
|
709
|
+
const raw = Buffer.alloc(size * (size + 1), 0xff)
|
|
710
|
+
for (let y = 0; y < size; y++) raw[y * (size + 1)] = 0
|
|
711
|
+
for (let my = 0; my < count; my++) {
|
|
712
|
+
for (let mx = 0; mx < count; mx++) {
|
|
713
|
+
if (!qr.isDark(my, mx)) continue
|
|
714
|
+
for (let py = 0; py < scale; py++) {
|
|
715
|
+
const row = (margin + my) * scale + py
|
|
716
|
+
const start = row * (size + 1) + 1 + (margin + mx) * scale
|
|
717
|
+
raw.fill(0x00, start, start + scale)
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
const ihdr = Buffer.alloc(13)
|
|
722
|
+
ihdr.writeUInt32BE(size, 0)
|
|
723
|
+
ihdr.writeUInt32BE(size, 4)
|
|
724
|
+
ihdr[8] = 8
|
|
725
|
+
ihdr[9] = 0
|
|
726
|
+
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
|
727
|
+
return Buffer.concat([
|
|
728
|
+
signature,
|
|
729
|
+
pngChunk('IHDR', ihdr),
|
|
730
|
+
pngChunk('IDAT', zlib.deflateSync(raw)),
|
|
731
|
+
pngChunk('IEND', Buffer.alloc(0)),
|
|
732
|
+
])
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function buildQr(text) {
|
|
736
|
+
const qr = qrcode(0, 'L')
|
|
737
|
+
qr.addData(text)
|
|
738
|
+
qr.make()
|
|
739
|
+
return qr
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function pairLink() {
|
|
743
|
+
const host = os.hostname()
|
|
744
|
+
const params = [
|
|
745
|
+
'v=1',
|
|
746
|
+
`name=${encodeURIComponent(host)}`,
|
|
747
|
+
`url=${encodeURIComponent(primaryUrl)}`,
|
|
748
|
+
`token=${encodeURIComponent(remoteToken)}`,
|
|
749
|
+
]
|
|
750
|
+
if (opts.ntfy) params.push(`ntfy=${encodeURIComponent(String(opts.ntfy))}`)
|
|
751
|
+
if (allUrls.length > 1) {
|
|
752
|
+
params.push(`urls=${encodeURIComponent(allUrls.map((entry) => entry.url).join(','))}`)
|
|
753
|
+
}
|
|
754
|
+
return `ocremote://pair?${params.join('&')}`
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function escapeHtml(value) {
|
|
758
|
+
return String(value)
|
|
759
|
+
.replace(/&/g, '&')
|
|
760
|
+
.replace(/</g, '<')
|
|
761
|
+
.replace(/>/g, '>')
|
|
762
|
+
.replace(/"/g, '"')
|
|
763
|
+
.replace(/'/g, ''')
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
let identity = null
|
|
767
|
+
let relayClient = null
|
|
768
|
+
let adapter = null
|
|
769
|
+
let utilityServer = null
|
|
770
|
+
let lastInvite = null
|
|
771
|
+
|
|
772
|
+
async function createInvitePayload() {
|
|
773
|
+
if (!relayClient) {
|
|
774
|
+
return { relay: false, url: pairLink(), token: remoteToken }
|
|
775
|
+
}
|
|
776
|
+
const invite = await relayClient.createInvite()
|
|
777
|
+
const url = relayClient.inviteURL(invite)
|
|
778
|
+
lastInvite = { url, expiresAt: invite.expiresAt }
|
|
779
|
+
return {
|
|
780
|
+
relay: true,
|
|
781
|
+
url,
|
|
782
|
+
expiresAt: invite.expiresAt,
|
|
783
|
+
deviceID: relayClient.identity.deviceID(),
|
|
784
|
+
name: relayClient.identity.name(),
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function currentInviteLink() {
|
|
789
|
+
if (lastInvite && lastInvite.expiresAt > Date.now()) return lastInvite.url
|
|
790
|
+
return null
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function pairPage() {
|
|
794
|
+
const v2Link = currentInviteLink()
|
|
795
|
+
const link = v2Link || pairLink()
|
|
796
|
+
let qrBlock = '<p class="warn">qrcode.js vendor missing: re-download companion/vendor/qrcode.js</p>'
|
|
797
|
+
if (qrcode) {
|
|
798
|
+
const png = qrPng(buildQr(link))
|
|
799
|
+
qrBlock = `<img class="qr" alt="Pairing QR" src="data:image/png;base64,${png.toString('base64')}">`
|
|
800
|
+
}
|
|
801
|
+
const v2Block = v2Link
|
|
802
|
+
? `<div class="card"><div class="row"><span class="k">relay</span><code class="v" id="rl">${escapeHtml(opts.relay)}</code></div>
|
|
803
|
+
<div class="row"><span class="k">device</span><code class="v" id="dv">${escapeHtml(relayClient ? relayClient.identity.deviceID() : '')}</code><button onclick="cp('dv')">copy</button></div>
|
|
804
|
+
<div class="row"><span class="k">deep link</span><code class="v" id="dl2">${escapeHtml(v2Link)}</code><button onclick="cp('dl2')">copy</button></div>
|
|
805
|
+
<p>Scan with the app. The invite is single-use and expires in 5 minutes; reload this page for a new one.</p></div>`
|
|
806
|
+
: ''
|
|
807
|
+
const rows = allUrls
|
|
808
|
+
.map(
|
|
809
|
+
(entry) =>
|
|
810
|
+
`<div class="row"><span class="k">${escapeHtml(entry.label)}</span><code class="v" id="u${escapeHtml(entry.label)}">${escapeHtml(entry.url)}</code><button onclick="cp('u${escapeHtml(entry.label)}')">copy</button></div>`
|
|
811
|
+
)
|
|
812
|
+
.join('\n')
|
|
813
|
+
return `<!doctype html>
|
|
814
|
+
<html lang="en">
|
|
815
|
+
<head>
|
|
816
|
+
<meta charset="utf-8">
|
|
817
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
818
|
+
<meta name="robots" content="noindex">
|
|
819
|
+
<title>oc-remote pairing</title>
|
|
820
|
+
<style>
|
|
821
|
+
:root { color-scheme: dark; }
|
|
822
|
+
body { margin: 0; padding: 32px 20px; background: #0a0a0a; color: #e5e5e5;
|
|
823
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
|
|
824
|
+
.wrap { max-width: 560px; margin: 0 auto; }
|
|
825
|
+
h1 { font-size: 16px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: #a3a3a3; }
|
|
826
|
+
.qr { width: 288px; height: 288px; display: block; margin: 24px auto; background: #fff; border-radius: 8px; padding: 12px; image-rendering: pixelated; }
|
|
827
|
+
.card { border: 1px solid #262626; border-radius: 8px; padding: 16px; margin: 16px 0; background: #111; }
|
|
828
|
+
.row { display: flex; align-items: center; gap: 12px; padding: 6px 0; }
|
|
829
|
+
.k { color: #737373; min-width: 76px; font-size: 12px; }
|
|
830
|
+
.v { flex: 1; overflow-wrap: anywhere; color: #e5e5e5; }
|
|
831
|
+
button { background: #1f1f1f; color: #d4d4d4; border: 1px solid #333; border-radius: 6px;
|
|
832
|
+
padding: 4px 10px; font: inherit; font-size: 12px; cursor: pointer; }
|
|
833
|
+
button:hover { background: #262626; color: #fff; }
|
|
834
|
+
.warn { color: #fbbf24; }
|
|
835
|
+
p { color: #a3a3a3; line-height: 1.5; }
|
|
836
|
+
code { font-family: inherit; font-size: 13px; }
|
|
837
|
+
</style>
|
|
838
|
+
</head>
|
|
839
|
+
<body>
|
|
840
|
+
<div class="wrap">
|
|
841
|
+
<h1>oc-remote · pairing</h1>
|
|
842
|
+
${qrBlock}
|
|
843
|
+
<p>Scan with the OpenCode Remote iOS app, or copy the values below.</p>
|
|
844
|
+
${v2Block}
|
|
845
|
+
<div class="card">
|
|
846
|
+
<div class="row"><span class="k">token</span><code class="v" id="tk">${escapeHtml(remoteToken)}</code><button onclick="cp('tk')">copy</button></div>
|
|
847
|
+
${rows}
|
|
848
|
+
</div>
|
|
849
|
+
<div class="card"><div class="row"><span class="k">deep link</span><code class="v" id="dl">${escapeHtml(pairLink())}</code><button onclick="cp('dl')">copy</button></div></div>
|
|
850
|
+
<p>Served on a loopback-only port that is never exposed through tunnels or the relay.</p>
|
|
851
|
+
</div>
|
|
852
|
+
<script>
|
|
853
|
+
function cp(id) {
|
|
854
|
+
var el = document.getElementById(id);
|
|
855
|
+
var text = el.textContent;
|
|
856
|
+
var done = function () { var b = el.parentNode.querySelector('button'); if (b) { b.textContent = 'copied'; setTimeout(function(){ b.textContent = 'copy'; }, 1200); } };
|
|
857
|
+
if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(done, fallback); }
|
|
858
|
+
else { fallback(); }
|
|
859
|
+
function fallback() {
|
|
860
|
+
var ta = document.createElement('textarea');
|
|
861
|
+
ta.value = text; document.body.appendChild(ta); ta.select();
|
|
862
|
+
try { document.execCommand('copy'); } catch (e) {}
|
|
863
|
+
document.body.removeChild(ta); done();
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
</script>
|
|
867
|
+
</body>
|
|
868
|
+
</html>`
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function handleRequest(req, res) {
|
|
872
|
+
let url
|
|
873
|
+
try {
|
|
874
|
+
url = new URL(req.url, `http://${LOOPBACK}`)
|
|
875
|
+
} catch {
|
|
876
|
+
sendJson(req, res, 400, { error: 'bad_request' })
|
|
877
|
+
return
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const pathname = url.pathname
|
|
881
|
+
|
|
882
|
+
if (pathname === '/_ocremote/health') {
|
|
883
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
884
|
+
sendJson(req, res, 405, { error: 'method_not_allowed' })
|
|
885
|
+
return
|
|
886
|
+
}
|
|
887
|
+
sendJson(req, res, 200, { ok: true })
|
|
888
|
+
return
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
{
|
|
892
|
+
const clientIp = req.socket.remoteAddress || 'unknown'
|
|
893
|
+
if (authorized(req, url)) {
|
|
894
|
+
clearAuthFailures(clientIp)
|
|
895
|
+
} else if (authBlocked(clientIp)) {
|
|
896
|
+
sendJson(
|
|
897
|
+
req,
|
|
898
|
+
res,
|
|
899
|
+
429,
|
|
900
|
+
{ error: 'too_many_requests', message: 'too many failed auth attempts; retry in a minute' },
|
|
901
|
+
{ 'retry-after': '60' }
|
|
902
|
+
)
|
|
903
|
+
return
|
|
904
|
+
} else {
|
|
905
|
+
noteAuthFailure(clientIp)
|
|
906
|
+
sendJson(req, res, 401, { error: 'unauthorized', message: 'missing or invalid token' })
|
|
907
|
+
return
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
if (pathname === '/_ocremote/status') {
|
|
912
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
913
|
+
sendJson(req, res, 405, { error: 'method_not_allowed' })
|
|
914
|
+
return
|
|
915
|
+
}
|
|
916
|
+
sendJson(req, res, 200, statusPayload())
|
|
917
|
+
return
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
if (pathname === '/_ocremote/pair' || pathname === '/_ocremote/invite') {
|
|
921
|
+
sendJson(req, res, 404, { error: 'not_found', message: 'pairing endpoints live on the loopback-only utility port' })
|
|
922
|
+
return
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
handleProxy(req, res, url)
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function handleUtilityRequest(req, res) {
|
|
929
|
+
let url
|
|
930
|
+
try {
|
|
931
|
+
url = new URL(req.url, 'http://127.0.0.1')
|
|
932
|
+
} catch {
|
|
933
|
+
sendJson(req, res, 400, { error: 'bad_request' })
|
|
934
|
+
return
|
|
935
|
+
}
|
|
936
|
+
if (url.pathname === '/_ocremote/health') {
|
|
937
|
+
sendJson(req, res, 200, { ok: true })
|
|
938
|
+
return
|
|
939
|
+
}
|
|
940
|
+
if (url.pathname === '/_ocremote/invite') {
|
|
941
|
+
if (req.method !== 'GET' && req.method !== 'POST') {
|
|
942
|
+
sendJson(req, res, 405, { error: 'method_not_allowed' })
|
|
943
|
+
return
|
|
944
|
+
}
|
|
945
|
+
createInvitePayload()
|
|
946
|
+
.then((payload) => sendJson(req, res, 200, payload))
|
|
947
|
+
.catch((err) => sendJson(req, res, 500, { error: 'invite_failed', message: err.message }))
|
|
948
|
+
return
|
|
949
|
+
}
|
|
950
|
+
if (url.pathname === '/_ocremote/pair') {
|
|
951
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
952
|
+
sendJson(req, res, 405, { error: 'method_not_allowed' })
|
|
953
|
+
return
|
|
954
|
+
}
|
|
955
|
+
const ready = relayClient && !currentInviteLink() ? createInvitePayload() : Promise.resolve(null)
|
|
956
|
+
ready
|
|
957
|
+
.then(() => {
|
|
958
|
+
const html = pairPage()
|
|
959
|
+
res.writeHead(200, {
|
|
960
|
+
'content-type': 'text/html; charset=utf-8',
|
|
961
|
+
'content-length': Buffer.byteLength(html),
|
|
962
|
+
'cache-control': 'no-store',
|
|
963
|
+
})
|
|
964
|
+
if (req.method === 'HEAD') return res.end()
|
|
965
|
+
res.end(html)
|
|
966
|
+
})
|
|
967
|
+
.catch((err) => sendJson(req, res, 500, { error: 'page_failed', message: err.message }))
|
|
968
|
+
return
|
|
969
|
+
}
|
|
970
|
+
sendJson(req, res, 404, { error: 'not_found' })
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
async function createUtilityServer() {
|
|
974
|
+
const preferred = opts.utilityPort ?? opts.port + 1
|
|
975
|
+
for (let candidate = preferred; candidate < preferred + 20; candidate++) {
|
|
976
|
+
if (candidate === opts.port || candidate === opts.opencodePort) continue
|
|
977
|
+
if (candidate > 65535) break
|
|
978
|
+
if (!(await portFree(candidate))) continue
|
|
979
|
+
const srv = http.createServer(handleUtilityRequest)
|
|
980
|
+
const ok = await new Promise((resolve) => {
|
|
981
|
+
srv.once('error', (err) => {
|
|
982
|
+
warn(`utility port ${candidate} failed: ${err.message}`)
|
|
983
|
+
resolve(false)
|
|
984
|
+
})
|
|
985
|
+
srv.listen(candidate, LOOPBACK, () => {
|
|
986
|
+
opts.utilityPort = candidate
|
|
987
|
+
resolve(true)
|
|
988
|
+
})
|
|
989
|
+
})
|
|
990
|
+
if (!ok) continue
|
|
991
|
+
info(`pairing page (loopback only): http://${LOOPBACK}:${opts.utilityPort}/_ocremote/pair`)
|
|
992
|
+
return srv
|
|
993
|
+
}
|
|
994
|
+
error('could not start the loopback utility listener; pairing pages are unavailable')
|
|
995
|
+
return null
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function createProxyServer() {
|
|
999
|
+
const srv = http.createServer(handleRequest)
|
|
1000
|
+
srv.requestTimeout = 0
|
|
1001
|
+
srv.headersTimeout = 60000
|
|
1002
|
+
srv.keepAliveTimeout = 65000
|
|
1003
|
+
srv.timeout = 0
|
|
1004
|
+
srv.on('connection', (socket) => {
|
|
1005
|
+
sockets.add(socket)
|
|
1006
|
+
socket.on('close', () => sockets.delete(socket))
|
|
1007
|
+
})
|
|
1008
|
+
srv.on('clientError', (err, socket) => {
|
|
1009
|
+
try {
|
|
1010
|
+
socket.end('HTTP/1.1 400 Bad Request\r\nconnection: close\r\ncontent-length: 0\r\n\r\n')
|
|
1011
|
+
} catch {}
|
|
1012
|
+
})
|
|
1013
|
+
srv.on('upgrade', (req, socket) => {
|
|
1014
|
+
rejectUpgrade(socket)
|
|
1015
|
+
})
|
|
1016
|
+
srv.on('connect', (req, socket) => {
|
|
1017
|
+
rejectUpgrade(socket)
|
|
1018
|
+
})
|
|
1019
|
+
srv.on('error', (err) => {
|
|
1020
|
+
error(`server error: ${err.message}`)
|
|
1021
|
+
})
|
|
1022
|
+
return srv
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function rejectUpgrade(socket) {
|
|
1026
|
+
const body = JSON.stringify({
|
|
1027
|
+
error: 'not_implemented',
|
|
1028
|
+
message: 'WebSocket/Upgrade is not supported by oc-remote (PTY is out of scope)',
|
|
1029
|
+
})
|
|
1030
|
+
try {
|
|
1031
|
+
socket.write(
|
|
1032
|
+
`HTTP/1.1 501 Not Implemented\r\ncontent-type: application/json; charset=utf-8\r\nconnection: close\r\ncontent-length: ${Buffer.byteLength(body)}\r\n\r\n${body}`
|
|
1033
|
+
)
|
|
1034
|
+
} catch {}
|
|
1035
|
+
socket.destroy()
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function pipePrefixed(stream, prefix) {
|
|
1039
|
+
let buffer = ''
|
|
1040
|
+
stream.setEncoding('utf8')
|
|
1041
|
+
stream.on('data', (chunk) => {
|
|
1042
|
+
buffer += chunk
|
|
1043
|
+
let idx
|
|
1044
|
+
while ((idx = buffer.indexOf('\n')) >= 0) {
|
|
1045
|
+
process.stderr.write(prefix + buffer.slice(0, idx) + '\n')
|
|
1046
|
+
buffer = buffer.slice(idx + 1)
|
|
1047
|
+
}
|
|
1048
|
+
})
|
|
1049
|
+
stream.on('end', () => {
|
|
1050
|
+
if (buffer.length) process.stderr.write(prefix + buffer + '\n')
|
|
1051
|
+
})
|
|
1052
|
+
stream.on('error', () => {})
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function launchOpencode() {
|
|
1056
|
+
const args = [
|
|
1057
|
+
'serve',
|
|
1058
|
+
'--hostname',
|
|
1059
|
+
LOOPBACK,
|
|
1060
|
+
'--port',
|
|
1061
|
+
String(opts.opencodePort),
|
|
1062
|
+
'--print-logs',
|
|
1063
|
+
'--log-level',
|
|
1064
|
+
'INFO',
|
|
1065
|
+
]
|
|
1066
|
+
info(`starting opencode: ${opts.opencodeBin} ${args.join(' ')} (cwd: ${opts.dir})`)
|
|
1067
|
+
child = spawn(opts.opencodeBin, args, {
|
|
1068
|
+
cwd: opts.dir,
|
|
1069
|
+
env: {
|
|
1070
|
+
...process.env,
|
|
1071
|
+
OPENCODE_SERVER_USERNAME: 'opencode',
|
|
1072
|
+
OPENCODE_SERVER_PASSWORD: internalPassword,
|
|
1073
|
+
},
|
|
1074
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1075
|
+
})
|
|
1076
|
+
child.on('error', (err) => {
|
|
1077
|
+
error(`failed to spawn opencode: ${err.message}`)
|
|
1078
|
+
})
|
|
1079
|
+
child.on('exit', (code, signal) => {
|
|
1080
|
+
child = null
|
|
1081
|
+
if (shuttingDown) return
|
|
1082
|
+
scheduleRestart(signal ? `signal ${signal}` : `code ${code}`)
|
|
1083
|
+
})
|
|
1084
|
+
if (child.stdout) pipePrefixed(child.stdout, '[opencode] ')
|
|
1085
|
+
if (child.stderr) pipePrefixed(child.stderr, '[opencode] ')
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function scheduleRestart(reason) {
|
|
1089
|
+
restartAttempts++
|
|
1090
|
+
const delay = Math.min(15000, 1000 * 2 ** Math.min(restartAttempts - 1, 4))
|
|
1091
|
+
warn(`opencode exited (${reason}); restarting in ${delay}ms (attempt ${restartAttempts})`)
|
|
1092
|
+
restartTimer = setTimeout(async () => {
|
|
1093
|
+
if (shuttingDown) return
|
|
1094
|
+
launchOpencode()
|
|
1095
|
+
const health = await waitHealthy(30000)
|
|
1096
|
+
if (health) {
|
|
1097
|
+
restartAttempts = 0
|
|
1098
|
+
info(`opencode restarted (v${health.version})`)
|
|
1099
|
+
}
|
|
1100
|
+
}, delay)
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function waitHealthy(timeoutMs) {
|
|
1104
|
+
const deadline = Date.now() + timeoutMs
|
|
1105
|
+
const attempt = () =>
|
|
1106
|
+
new Promise((resolve) => {
|
|
1107
|
+
const req = http.request(
|
|
1108
|
+
{
|
|
1109
|
+
host: LOOPBACK,
|
|
1110
|
+
port: opts.opencodePort,
|
|
1111
|
+
path: '/global/health',
|
|
1112
|
+
method: 'GET',
|
|
1113
|
+
headers: { authorization: internalAuth },
|
|
1114
|
+
timeout: 2000,
|
|
1115
|
+
},
|
|
1116
|
+
(res) => {
|
|
1117
|
+
let data = ''
|
|
1118
|
+
res.setEncoding('utf8')
|
|
1119
|
+
res.on('data', (chunk) => {
|
|
1120
|
+
data += chunk
|
|
1121
|
+
})
|
|
1122
|
+
res.on('end', () => {
|
|
1123
|
+
try {
|
|
1124
|
+
const parsed = JSON.parse(data)
|
|
1125
|
+
resolve(parsed && parsed.healthy ? parsed : null)
|
|
1126
|
+
} catch {
|
|
1127
|
+
resolve(null)
|
|
1128
|
+
}
|
|
1129
|
+
})
|
|
1130
|
+
}
|
|
1131
|
+
)
|
|
1132
|
+
req.on('error', () => resolve(null))
|
|
1133
|
+
req.on('timeout', () => {
|
|
1134
|
+
req.destroy()
|
|
1135
|
+
resolve(null)
|
|
1136
|
+
})
|
|
1137
|
+
req.end()
|
|
1138
|
+
})
|
|
1139
|
+
|
|
1140
|
+
return (async () => {
|
|
1141
|
+
while (Date.now() < deadline) {
|
|
1142
|
+
if (shuttingDown) return null
|
|
1143
|
+
const health = await attempt()
|
|
1144
|
+
if (health) return health
|
|
1145
|
+
await sleep(400)
|
|
1146
|
+
}
|
|
1147
|
+
return null
|
|
1148
|
+
})()
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function portFree(port) {
|
|
1152
|
+
return new Promise((resolve) => {
|
|
1153
|
+
const probe = net.createServer()
|
|
1154
|
+
probe.once('error', () => resolve(false))
|
|
1155
|
+
probe.once('listening', () => probe.close(() => resolve(true)))
|
|
1156
|
+
probe.listen(port, LOOPBACK)
|
|
1157
|
+
})
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function configBinDir() {
|
|
1161
|
+
return path.join(configPaths().dir, 'bin')
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function cloudflaredLocalBin() {
|
|
1165
|
+
return path.join(configBinDir(), process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared')
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function findCloudflared() {
|
|
1169
|
+
if (process.env.OCREMOTE_CLOUDFLARED) {
|
|
1170
|
+
const override = process.env.OCREMOTE_CLOUDFLARED
|
|
1171
|
+
return fs.existsSync(override) ? override : null
|
|
1172
|
+
}
|
|
1173
|
+
const local = cloudflaredLocalBin()
|
|
1174
|
+
if (fs.existsSync(local)) return local
|
|
1175
|
+
return locateBin('cloudflared')
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function downloadFile(url, dest, redirects = 0) {
|
|
1179
|
+
return new Promise((resolve, reject) => {
|
|
1180
|
+
if (redirects > 5) return reject(new Error('too many redirects'))
|
|
1181
|
+
const req = https.get(url, { headers: { 'user-agent': `${NAME}/${VERSION}` } }, (res) => {
|
|
1182
|
+
const status = res.statusCode || 0
|
|
1183
|
+
if (status >= 300 && status < 400 && res.headers.location) {
|
|
1184
|
+
res.resume()
|
|
1185
|
+
resolve(downloadFile(new URL(res.headers.location, url).toString(), dest, redirects + 1))
|
|
1186
|
+
return
|
|
1187
|
+
}
|
|
1188
|
+
if (status !== 200) {
|
|
1189
|
+
res.resume()
|
|
1190
|
+
reject(new Error(`download failed with HTTP ${status}`))
|
|
1191
|
+
return
|
|
1192
|
+
}
|
|
1193
|
+
const file = fs.createWriteStream(dest, { mode: 0o755 })
|
|
1194
|
+
res.pipe(file)
|
|
1195
|
+
file.on('finish', () => file.close(() => resolve()))
|
|
1196
|
+
file.on('error', reject)
|
|
1197
|
+
res.on('error', reject)
|
|
1198
|
+
})
|
|
1199
|
+
req.on('error', reject)
|
|
1200
|
+
req.setTimeout(120000, () => req.destroy(new Error('download timed out')))
|
|
1201
|
+
})
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
async function ensureCloudflared() {
|
|
1205
|
+
const existing = findCloudflared()
|
|
1206
|
+
if (existing) return existing
|
|
1207
|
+
const arch = process.arch === 'arm64' ? 'arm64' : 'amd64'
|
|
1208
|
+
if (process.platform !== 'darwin' && process.platform !== 'linux') {
|
|
1209
|
+
throw new Error(`automatic cloudflared install is not supported on ${process.platform}; install it manually`)
|
|
1210
|
+
}
|
|
1211
|
+
const asset = process.platform === 'darwin' ? `cloudflared-darwin-${arch}.tgz` : `cloudflared-linux-${arch}`
|
|
1212
|
+
const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`
|
|
1213
|
+
info(`downloading cloudflared (${asset}) ...`)
|
|
1214
|
+
fs.mkdirSync(configBinDir(), { recursive: true, mode: 0o700 })
|
|
1215
|
+
const tmp = path.join(os.tmpdir(), `oc-remote-cloudflared-${Date.now()}`)
|
|
1216
|
+
await downloadFile(url, tmp)
|
|
1217
|
+
if (asset.endsWith('.tgz')) {
|
|
1218
|
+
await new Promise((resolve, reject) => {
|
|
1219
|
+
execFile('tar', ['-xzf', tmp, '-C', configBinDir()], (err) => (err ? reject(err) : resolve()))
|
|
1220
|
+
})
|
|
1221
|
+
} else {
|
|
1222
|
+
fs.copyFileSync(tmp, cloudflaredLocalBin())
|
|
1223
|
+
}
|
|
1224
|
+
fs.chmodSync(cloudflaredLocalBin(), 0o755)
|
|
1225
|
+
try {
|
|
1226
|
+
fs.unlinkSync(tmp)
|
|
1227
|
+
} catch {}
|
|
1228
|
+
const bin = findCloudflared()
|
|
1229
|
+
if (!bin) throw new Error('cloudflared install failed')
|
|
1230
|
+
return bin
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function readLines(stream, onLine) {
|
|
1234
|
+
let buffer = ''
|
|
1235
|
+
stream.setEncoding('utf8')
|
|
1236
|
+
stream.on('data', (chunk) => {
|
|
1237
|
+
buffer += chunk
|
|
1238
|
+
let idx
|
|
1239
|
+
while ((idx = buffer.indexOf('\n')) >= 0) {
|
|
1240
|
+
onLine(buffer.slice(0, idx))
|
|
1241
|
+
buffer = buffer.slice(idx + 1)
|
|
1242
|
+
}
|
|
1243
|
+
})
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
async function startTunnel() {
|
|
1247
|
+
if (opts.publicUrl) {
|
|
1248
|
+
info('--public-url set; skipping tunnel startup')
|
|
1249
|
+
return
|
|
1250
|
+
}
|
|
1251
|
+
if (opts.tunnel === 'none') return
|
|
1252
|
+
|
|
1253
|
+
if (opts.tunnel === 'funnel') {
|
|
1254
|
+
const url = await enableFunnel()
|
|
1255
|
+
if (!url) {
|
|
1256
|
+
warn('funnel unavailable; staying on LAN/Tailscale candidates')
|
|
1257
|
+
return
|
|
1258
|
+
}
|
|
1259
|
+
tunnelProvider = 'funnel'
|
|
1260
|
+
tunnelUrl = url
|
|
1261
|
+
cachedTailscaleIp = await tailscaleIp()
|
|
1262
|
+
rebuildUrls()
|
|
1263
|
+
info(`stable remote URL via Tailscale Funnel: ${tunnelUrl}`)
|
|
1264
|
+
printPairing()
|
|
1265
|
+
startFunnelMonitor()
|
|
1266
|
+
return
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
if (opts.tunnel === 'tailscale') {
|
|
1270
|
+
const ip = await tailscaleIp()
|
|
1271
|
+
if (!ip) {
|
|
1272
|
+
warn('--tunnel tailscale: tailscale is not installed or not connected; falling back to LAN')
|
|
1273
|
+
return
|
|
1274
|
+
}
|
|
1275
|
+
tunnelProvider = 'tailscale'
|
|
1276
|
+
tunnelUrl = `http://${ip}:${opts.port}`
|
|
1277
|
+
rebuildUrls()
|
|
1278
|
+
info(`remote access via Tailscale: ${tunnelUrl}`)
|
|
1279
|
+
printPairing()
|
|
1280
|
+
return
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
if (opts.tunnel === 'auto') {
|
|
1284
|
+
const activeFunnel = await funnelUrlIfActive()
|
|
1285
|
+
if (activeFunnel) {
|
|
1286
|
+
tunnelProvider = 'funnel'
|
|
1287
|
+
tunnelUrl = activeFunnel
|
|
1288
|
+
rebuildUrls()
|
|
1289
|
+
info(`stable remote URL via existing Tailscale Funnel: ${tunnelUrl}`)
|
|
1290
|
+
printPairing()
|
|
1291
|
+
return
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
const installed = findCloudflared()
|
|
1296
|
+
if (!installed && opts.tunnel === 'auto') {
|
|
1297
|
+
if (cachedTailscaleIp) {
|
|
1298
|
+
tunnelProvider = 'tailscale'
|
|
1299
|
+
tunnelUrl = `http://${cachedTailscaleIp}:${opts.port}`
|
|
1300
|
+
rebuildUrls()
|
|
1301
|
+
info(`remote access via Tailscale: ${tunnelUrl}`)
|
|
1302
|
+
printPairing()
|
|
1303
|
+
return
|
|
1304
|
+
}
|
|
1305
|
+
info('remote access: LAN only - run `oc-remote --tunnel funnel` (stable URL) or --tunnel cloudflare')
|
|
1306
|
+
return
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
let bin = installed
|
|
1310
|
+
if (!bin) {
|
|
1311
|
+
try {
|
|
1312
|
+
bin = await ensureCloudflared()
|
|
1313
|
+
info(`cloudflared ready: ${bin}`)
|
|
1314
|
+
} catch (err) {
|
|
1315
|
+
error(`could not install cloudflared: ${err.message}`)
|
|
1316
|
+
return
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
tunnelProvider = 'cloudflare'
|
|
1321
|
+
spawnCloudflared(bin)
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function spawnCloudflared(bin) {
|
|
1325
|
+
if (shuttingDown) return
|
|
1326
|
+
info('starting cloudflare quick tunnel...')
|
|
1327
|
+
tunnelChild = spawn(bin, ['tunnel', '--url', `http://${LOOPBACK}:${opts.port}`, '--no-autoupdate'], {
|
|
1328
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1329
|
+
})
|
|
1330
|
+
const onLine = (line) => {
|
|
1331
|
+
const match = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i.exec(line)
|
|
1332
|
+
if (!match) return
|
|
1333
|
+
const url = match[0]
|
|
1334
|
+
quickTunnelUrl = url
|
|
1335
|
+
if (url === tunnelUrl) return
|
|
1336
|
+
if (tunnelProvider === 'funnel') {
|
|
1337
|
+
rebuildUrls()
|
|
1338
|
+
return
|
|
1339
|
+
}
|
|
1340
|
+
tunnelUrl = url
|
|
1341
|
+
tunnelAttempts = 0
|
|
1342
|
+
rebuildUrls()
|
|
1343
|
+
info(`cloudflare quick tunnel ready: ${tunnelUrl}`)
|
|
1344
|
+
printPairing()
|
|
1345
|
+
}
|
|
1346
|
+
if (tunnelChild.stdout) readLines(tunnelChild.stdout, onLine)
|
|
1347
|
+
if (tunnelChild.stderr) readLines(tunnelChild.stderr, onLine)
|
|
1348
|
+
tunnelChild.on('error', (err) => error(`cloudflared failed to start: ${err.message}`))
|
|
1349
|
+
tunnelChild.on('exit', (code, signal) => {
|
|
1350
|
+
tunnelChild = null
|
|
1351
|
+
if (shuttingDown || opts.tunnel === 'none') return
|
|
1352
|
+
tunnelUrl = tunnelProvider === 'funnel' ? tunnelUrl : ''
|
|
1353
|
+
quickTunnelUrl = ''
|
|
1354
|
+
rebuildUrls()
|
|
1355
|
+
tunnelAttempts++
|
|
1356
|
+
const delay = Math.min(30000, 2000 * 2 ** Math.min(tunnelAttempts - 1, 4))
|
|
1357
|
+
warn(`cloudflared exited (${signal || code}); restarting in ${delay}ms`)
|
|
1358
|
+
tunnelTimer = setTimeout(() => spawnCloudflared(bin), delay)
|
|
1359
|
+
})
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
function stopTunnel() {
|
|
1363
|
+
clearTimeout(tunnelTimer)
|
|
1364
|
+
tunnelTimer = null
|
|
1365
|
+
if (tunnelChild) {
|
|
1366
|
+
try {
|
|
1367
|
+
tunnelChild.kill('SIGTERM')
|
|
1368
|
+
} catch {}
|
|
1369
|
+
tunnelChild = null
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
async function computeUrls() {
|
|
1374
|
+
cachedTailscaleIp = await tailscaleIp()
|
|
1375
|
+
rebuildUrls()
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function rebuildUrls() {
|
|
1379
|
+
const urls = []
|
|
1380
|
+
if (opts.publicUrl) urls.push({ url: opts.publicUrl, label: 'public' })
|
|
1381
|
+
if (tunnelUrl && !opts.publicUrl) {
|
|
1382
|
+
urls.push({ url: tunnelUrl, label: tunnelProvider === 'tailscale' ? 'tailscale' : 'tunnel' })
|
|
1383
|
+
}
|
|
1384
|
+
if (quickTunnelUrl && quickTunnelUrl !== tunnelUrl) {
|
|
1385
|
+
urls.push({ url: quickTunnelUrl, label: 'tunnel' })
|
|
1386
|
+
}
|
|
1387
|
+
if (cachedTailscaleIp && !urls.some((entry) => entry.url.includes(cachedTailscaleIp))) {
|
|
1388
|
+
urls.push({ url: `http://${cachedTailscaleIp}:${opts.port}`, label: 'tailscale' })
|
|
1389
|
+
}
|
|
1390
|
+
const seen = new Set()
|
|
1391
|
+
for (const entry of lanIPv4()) {
|
|
1392
|
+
if (seen.has(entry.address)) continue
|
|
1393
|
+
seen.add(entry.address)
|
|
1394
|
+
if (urls.some((existing) => existing.url.includes(entry.address))) continue
|
|
1395
|
+
urls.push({ url: `http://${entry.address}:${opts.port}`, label: entry.name })
|
|
1396
|
+
}
|
|
1397
|
+
urls.push({ url: `http://${LOOPBACK}:${opts.port}`, label: 'loopback' })
|
|
1398
|
+
allUrls = urls
|
|
1399
|
+
const next = urls[0].url.replace(/\/+$/, '')
|
|
1400
|
+
const changed = next !== primaryUrl
|
|
1401
|
+
primaryUrl = next
|
|
1402
|
+
if (changed) {
|
|
1403
|
+
writeEndpointFile()
|
|
1404
|
+
publishBeacon()
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
function endpointId() {
|
|
1409
|
+
return crypto.createHash('sha256').update(`oc-remote:${remoteToken}`).digest('hex').slice(0, 12)
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
function endpointPayload() {
|
|
1413
|
+
return {
|
|
1414
|
+
type: 'endpoint',
|
|
1415
|
+
id: endpointId(),
|
|
1416
|
+
name: os.hostname(),
|
|
1417
|
+
version: VERSION,
|
|
1418
|
+
url: primaryUrl,
|
|
1419
|
+
urls: allUrls.map((entry) => ({ url: entry.url, label: entry.label })),
|
|
1420
|
+
provider: opts.publicUrl ? 'public' : tunnelProvider || 'lan',
|
|
1421
|
+
auth: !opts.noAuth,
|
|
1422
|
+
port: opts.port,
|
|
1423
|
+
ts: Date.now(),
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
function writeEndpointFile() {
|
|
1428
|
+
try {
|
|
1429
|
+
const { dir } = configPaths()
|
|
1430
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
1431
|
+
const file = path.join(dir, 'endpoint.json')
|
|
1432
|
+
const tmp = `${file}.tmp`
|
|
1433
|
+
fs.writeFileSync(tmp, `${JSON.stringify(endpointPayload(), null, 2)}\n`, { mode: 0o600 })
|
|
1434
|
+
fs.renameSync(tmp, file)
|
|
1435
|
+
} catch (err) {
|
|
1436
|
+
warn(`could not write endpoint.json: ${err.message}`)
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
function publishBeacon() {
|
|
1441
|
+
if (!opts.ntfyUrl || shuttingDown) return
|
|
1442
|
+
const payload = endpointPayload()
|
|
1443
|
+
const key = payload.url
|
|
1444
|
+
if (key === lastBeaconKey) return
|
|
1445
|
+
lastBeaconKey = key
|
|
1446
|
+
info(`beacon: publishing ${payload.url} [${payload.provider}] to ntfy`)
|
|
1447
|
+
publishNtfy({
|
|
1448
|
+
title: 'oc-remote endpoint',
|
|
1449
|
+
message: JSON.stringify(payload),
|
|
1450
|
+
tags: ['ocremote-endpoint'],
|
|
1451
|
+
priority: 1,
|
|
1452
|
+
})
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function startEndpointBeacon() {
|
|
1456
|
+
writeEndpointFile()
|
|
1457
|
+
publishBeacon()
|
|
1458
|
+
clearInterval(endpointTimer)
|
|
1459
|
+
endpointTimer = setInterval(() => {
|
|
1460
|
+
if (shuttingDown) return
|
|
1461
|
+
computeUrls()
|
|
1462
|
+
writeEndpointFile()
|
|
1463
|
+
lastBeaconKey = ''
|
|
1464
|
+
publishBeacon()
|
|
1465
|
+
}, 5 * 60_000)
|
|
1466
|
+
endpointTimer.unref?.()
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
function startTransportWatcher() {
|
|
1470
|
+
clearInterval(transportTimer)
|
|
1471
|
+
transportTimer = setInterval(async () => {
|
|
1472
|
+
if (shuttingDown || opts.publicUrl || opts.tunnel !== 'auto') return
|
|
1473
|
+
const active = await funnelUrlIfActive()
|
|
1474
|
+
if (active && active !== tunnelUrl) {
|
|
1475
|
+
tunnelProvider = 'funnel'
|
|
1476
|
+
tunnelUrl = active
|
|
1477
|
+
rebuildUrls()
|
|
1478
|
+
info(`stable remote URL via existing Tailscale Funnel: ${tunnelUrl}`)
|
|
1479
|
+
printPairing()
|
|
1480
|
+
return
|
|
1481
|
+
}
|
|
1482
|
+
if (!active && tunnelProvider === 'funnel') {
|
|
1483
|
+
tunnelProvider = quickTunnelUrl ? 'cloudflare' : null
|
|
1484
|
+
tunnelUrl = quickTunnelUrl
|
|
1485
|
+
rebuildUrls()
|
|
1486
|
+
warn(
|
|
1487
|
+
tunnelUrl
|
|
1488
|
+
? `Tailscale Funnel went away; falling back to ${tunnelUrl}`
|
|
1489
|
+
: 'Tailscale Funnel went away; falling back to LAN/Tailscale candidates'
|
|
1490
|
+
)
|
|
1491
|
+
printPairing()
|
|
1492
|
+
}
|
|
1493
|
+
}, 60_000)
|
|
1494
|
+
transportTimer.unref?.()
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
function startWatchdog() {
|
|
1498
|
+
clearInterval(watchdogTimer)
|
|
1499
|
+
watchdogTimer = setInterval(() => {
|
|
1500
|
+
if (shuttingDown || !child || child.exitCode !== null) return
|
|
1501
|
+
const req = http.request(
|
|
1502
|
+
{
|
|
1503
|
+
host: LOOPBACK,
|
|
1504
|
+
port: opts.opencodePort,
|
|
1505
|
+
path: '/global/health',
|
|
1506
|
+
method: 'GET',
|
|
1507
|
+
headers: { authorization: internalAuth },
|
|
1508
|
+
timeout: 3000,
|
|
1509
|
+
},
|
|
1510
|
+
(res) => {
|
|
1511
|
+
res.resume()
|
|
1512
|
+
if (res.statusCode && res.statusCode < 500) {
|
|
1513
|
+
watchdogFailures = 0
|
|
1514
|
+
return
|
|
1515
|
+
}
|
|
1516
|
+
noteWatchdogFailure()
|
|
1517
|
+
}
|
|
1518
|
+
)
|
|
1519
|
+
req.on('timeout', () => req.destroy(new Error('timeout')))
|
|
1520
|
+
req.on('error', () => noteWatchdogFailure())
|
|
1521
|
+
req.end()
|
|
1522
|
+
}, 30_000)
|
|
1523
|
+
watchdogTimer.unref?.()
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
function noteWatchdogFailure() {
|
|
1527
|
+
watchdogFailures++
|
|
1528
|
+
warn(`watchdog: opencode health check failed (${watchdogFailures}/3)`)
|
|
1529
|
+
if (watchdogFailures < 3) return
|
|
1530
|
+
watchdogFailures = 0
|
|
1531
|
+
if (!child || child.exitCode !== null) return
|
|
1532
|
+
error('watchdog: opencode is unresponsive; restarting it')
|
|
1533
|
+
try {
|
|
1534
|
+
child.kill('SIGKILL')
|
|
1535
|
+
} catch {}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
function printPairing() {
|
|
1539
|
+
for (const entry of allUrls) info(`url: ${entry.url} [${entry.label}]`)
|
|
1540
|
+
info(`pairing token: ${remoteToken}`)
|
|
1541
|
+
const publicEntry = allUrls.find((entry) => entry.label === 'public' || entry.label === 'tunnel')
|
|
1542
|
+
if (publicEntry) {
|
|
1543
|
+
const stable = tunnelProvider === 'funnel' || publicEntry.label === 'public'
|
|
1544
|
+
info(
|
|
1545
|
+
`remote access: ${publicEntry.url} (${stable ? 'stable, reachable from anywhere' : 'reachable from anywhere; the URL changes when the tunnel restarts'})`
|
|
1546
|
+
)
|
|
1547
|
+
} else if (tunnelProvider === 'tailscale') {
|
|
1548
|
+
info('remote access: Tailscale IP above (needs the Tailscale app on your phone)')
|
|
1549
|
+
} else {
|
|
1550
|
+
info('remote access: LAN only - run `oc-remote --tunnel funnel` (stable URL) or --tunnel cloudflare')
|
|
1551
|
+
}
|
|
1552
|
+
info(`pairing deep link: ${pairLink()}`)
|
|
1553
|
+
if (!opts.printQr) return
|
|
1554
|
+
if (!qrcode) {
|
|
1555
|
+
warn('QR unavailable: companion/vendor/qrcode.js not found')
|
|
1556
|
+
return
|
|
1557
|
+
}
|
|
1558
|
+
const ascii = buildQr(pairLink()).createASCII(1, 4)
|
|
1559
|
+
process.stderr.write(`${ascii}\n`)
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
function printInviteQr(link) {
|
|
1563
|
+
if (!opts.printQr || !qrcode) return
|
|
1564
|
+
process.stderr.write(`${buildQr(link).createASCII(1, 4)}\n`)
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
function handleRelayEvent(event) {
|
|
1568
|
+
if (event.type === 'relay_connected') return
|
|
1569
|
+
if (event.type === 'relay_disconnected') return
|
|
1570
|
+
if (event.type === 'paired') {
|
|
1571
|
+
info(`relay: paired ${event.clientID}${event.name ? ` (${event.name})` : ''}`)
|
|
1572
|
+
return
|
|
1573
|
+
}
|
|
1574
|
+
if (event.type === 'session_open') {
|
|
1575
|
+
info(`relay: client session ${event.session} (${event.clientID})`)
|
|
1576
|
+
return
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
async function startRelay() {
|
|
1581
|
+
if (!opts.relay) return
|
|
1582
|
+
identity = new Identity(configPaths().dir).ensure(opts.deviceName || os.hostname().replace(/\.local$/i, ''))
|
|
1583
|
+
adapter = new RpcAdapter({
|
|
1584
|
+
baseUrl: `http://${LOOPBACK}:${opts.opencodePort}`,
|
|
1585
|
+
authHeader: internalAuth,
|
|
1586
|
+
logger: warn,
|
|
1587
|
+
})
|
|
1588
|
+
relayClient = new RelayClient({
|
|
1589
|
+
relayUrl: opts.relay,
|
|
1590
|
+
identity,
|
|
1591
|
+
adapter,
|
|
1592
|
+
logger: info,
|
|
1593
|
+
invites: new Map(),
|
|
1594
|
+
onEvent: handleRelayEvent,
|
|
1595
|
+
})
|
|
1596
|
+
try {
|
|
1597
|
+
await relayClient.registerDevice(VERSION)
|
|
1598
|
+
info(`relay: registered device ${identity.deviceID()} (${identity.name()})`)
|
|
1599
|
+
} catch (err) {
|
|
1600
|
+
error(`relay registration failed: ${err.message}`)
|
|
1601
|
+
}
|
|
1602
|
+
relayClient.start()
|
|
1603
|
+
const payload = await createInvitePayload()
|
|
1604
|
+
info(`relay: ${opts.relay}`)
|
|
1605
|
+
info(`pairing v2 link: ${payload.url}`)
|
|
1606
|
+
printInviteQr(payload.url)
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
function printBanner(health) {
|
|
1610
|
+
const hostname = os.hostname()
|
|
1611
|
+
info(`${NAME} v${VERSION} on ${hostname} (${os.platform()} ${os.release()})`)
|
|
1612
|
+
info(`project directory: ${opts.dir}`)
|
|
1613
|
+
info(`opencode: ${opts.opencodeBin} v${health.version} -> http://${LOOPBACK}:${opts.opencodePort} (loopback only)`)
|
|
1614
|
+
info(`proxy listening: ${opts.host}:${opts.port}`)
|
|
1615
|
+
if (opts.noAuth) warn('AUTH DISABLED: anyone who can reach this port controls opencode')
|
|
1616
|
+
for (const line of opts.noAuth
|
|
1617
|
+
? ['pair page: disabled (--no-auth)']
|
|
1618
|
+
: [`pair page (loopback): http://${LOOPBACK}:${opts.port}/_ocremote/pair`])
|
|
1619
|
+
info(line)
|
|
1620
|
+
if (opts.relay) info(`relay: ${opts.relay} (device ${opts.deviceName || os.hostname()})`)
|
|
1621
|
+
info('self-check: oc-remote doctor')
|
|
1622
|
+
printPairing()
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
function startMdns() {
|
|
1626
|
+
if (!opts.mdns) return
|
|
1627
|
+
const dnsSd = '/usr/bin/dns-sd'
|
|
1628
|
+
if (!fs.existsSync(dnsSd)) {
|
|
1629
|
+
warn('mDNS: /usr/bin/dns-sd not found; skipping Bonjour registration')
|
|
1630
|
+
return
|
|
1631
|
+
}
|
|
1632
|
+
const hostLabel = os.hostname().replace(/\.local\.?$/i, '')
|
|
1633
|
+
const serviceName = `${NAME} (${hostLabel})`
|
|
1634
|
+
mdnsChild = spawn(dnsSd, ['-R', serviceName, '_ocremote._tcp', 'local', String(opts.port), 'v=1'], {
|
|
1635
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1636
|
+
})
|
|
1637
|
+
mdnsChild.on('error', (err) => warn(`mDNS: ${err.message}`))
|
|
1638
|
+
mdnsChild.on('exit', (code, signal) => {
|
|
1639
|
+
mdnsChild = null
|
|
1640
|
+
if (!shuttingDown && code !== 0) warn(`mDNS registration exited (${signal || code})`)
|
|
1641
|
+
})
|
|
1642
|
+
if (mdnsChild.stdout) pipePrefixed(mdnsChild.stdout, '[dns-sd] ')
|
|
1643
|
+
if (mdnsChild.stderr) pipePrefixed(mdnsChild.stderr, '[dns-sd] ')
|
|
1644
|
+
info(`mDNS: publishing "_ocremote._tcp.local" as "${serviceName}" on port ${opts.port}`)
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
function startNtfy() {
|
|
1648
|
+
if (!opts.ntfy) return
|
|
1649
|
+
const busy = new Set()
|
|
1650
|
+
const publish = (payload) => publishNtfy(payload)
|
|
1651
|
+
|
|
1652
|
+
const onEvent = (event) => {
|
|
1653
|
+
const props = event.properties || {}
|
|
1654
|
+
if (event.type === 'permission.asked') {
|
|
1655
|
+
const patterns = Array.isArray(props.patterns) ? props.patterns.join(', ') : ''
|
|
1656
|
+
const message = `${props.permission || 'permission'} ${patterns}`.trim()
|
|
1657
|
+
info(`ntfy: permission.asked -> push notification`)
|
|
1658
|
+
publish({
|
|
1659
|
+
title: 'opencode: permission required',
|
|
1660
|
+
message: message || 'permission required',
|
|
1661
|
+
tags: ['warning'],
|
|
1662
|
+
priority: 4,
|
|
1663
|
+
...(primaryUrl ? { click: primaryUrl } : {}),
|
|
1664
|
+
})
|
|
1665
|
+
return
|
|
1666
|
+
}
|
|
1667
|
+
if (event.type === 'session.status') {
|
|
1668
|
+
const status = props.status && props.status.type
|
|
1669
|
+
if (status === 'busy') busy.add(props.sessionID)
|
|
1670
|
+
else busy.delete(props.sessionID)
|
|
1671
|
+
return
|
|
1672
|
+
}
|
|
1673
|
+
if (event.type === 'session.idle') {
|
|
1674
|
+
if (busy.delete(props.sessionID)) {
|
|
1675
|
+
info(`ntfy: session.idle after busy -> push notification`)
|
|
1676
|
+
publish({
|
|
1677
|
+
title: 'opencode: task finished',
|
|
1678
|
+
message: `session ${props.sessionID} is idle`,
|
|
1679
|
+
tags: ['white_check_mark'],
|
|
1680
|
+
priority: 3,
|
|
1681
|
+
...(primaryUrl ? { click: primaryUrl } : {}),
|
|
1682
|
+
})
|
|
1683
|
+
}
|
|
1684
|
+
return
|
|
1685
|
+
}
|
|
1686
|
+
if (event.type === 'session.deleted') busy.delete(props.sessionID)
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
const connect = () => {
|
|
1690
|
+
if (shuttingDown) return
|
|
1691
|
+
const req = http.request(
|
|
1692
|
+
{
|
|
1693
|
+
host: LOOPBACK,
|
|
1694
|
+
port: opts.opencodePort,
|
|
1695
|
+
path: '/event',
|
|
1696
|
+
method: 'GET',
|
|
1697
|
+
headers: { authorization: internalAuth, accept: 'text/event-stream' },
|
|
1698
|
+
},
|
|
1699
|
+
(res) => {
|
|
1700
|
+
if (res.statusCode !== 200) {
|
|
1701
|
+
warn(`ntfy: event stream returned HTTP ${res.statusCode}; retrying in 5s`)
|
|
1702
|
+
res.resume()
|
|
1703
|
+
setTimeout(connect, 5000)
|
|
1704
|
+
return
|
|
1705
|
+
}
|
|
1706
|
+
info(`ntfy: subscribed to opencode events (topic ${opts.ntfyUrl})`)
|
|
1707
|
+
let buffer = ''
|
|
1708
|
+
res.setEncoding('utf8')
|
|
1709
|
+
res.on('data', (chunk) => {
|
|
1710
|
+
buffer += chunk
|
|
1711
|
+
let idx
|
|
1712
|
+
while ((idx = buffer.indexOf('\n\n')) >= 0) {
|
|
1713
|
+
const block = buffer.slice(0, idx)
|
|
1714
|
+
buffer = buffer.slice(idx + 2)
|
|
1715
|
+
for (const line of block.split('\n')) {
|
|
1716
|
+
if (!line.startsWith('data:')) continue
|
|
1717
|
+
try {
|
|
1718
|
+
onEvent(JSON.parse(line.slice(5).trim()))
|
|
1719
|
+
} catch {}
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
})
|
|
1723
|
+
res.on('end', () => {
|
|
1724
|
+
if (!shuttingDown) setTimeout(connect, 5000)
|
|
1725
|
+
})
|
|
1726
|
+
res.on('error', () => {})
|
|
1727
|
+
}
|
|
1728
|
+
)
|
|
1729
|
+
req.on('error', (err) => {
|
|
1730
|
+
if (shuttingDown) return
|
|
1731
|
+
warn(`ntfy: event stream error: ${err.message}; retrying in 5s`)
|
|
1732
|
+
setTimeout(connect, 5000)
|
|
1733
|
+
})
|
|
1734
|
+
req.end()
|
|
1735
|
+
}
|
|
1736
|
+
connect()
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
function shutdown(signal) {
|
|
1740
|
+
if (shuttingDown) return
|
|
1741
|
+
shuttingDown = true
|
|
1742
|
+
info(`received ${signal}; shutting down`)
|
|
1743
|
+
clearTimeout(restartTimer)
|
|
1744
|
+
clearInterval(endpointTimer)
|
|
1745
|
+
clearInterval(watchdogTimer)
|
|
1746
|
+
clearInterval(funnelMonitorTimer)
|
|
1747
|
+
clearInterval(transportTimer)
|
|
1748
|
+
stopTunnel()
|
|
1749
|
+
if (relayClient) {
|
|
1750
|
+
try {
|
|
1751
|
+
relayClient.stop()
|
|
1752
|
+
} catch {}
|
|
1753
|
+
relayClient = null
|
|
1754
|
+
}
|
|
1755
|
+
if (utilityServer) {
|
|
1756
|
+
try {
|
|
1757
|
+
utilityServer.close()
|
|
1758
|
+
} catch {}
|
|
1759
|
+
utilityServer = null
|
|
1760
|
+
}
|
|
1761
|
+
if (mdnsChild) {
|
|
1762
|
+
try {
|
|
1763
|
+
mdnsChild.kill('SIGTERM')
|
|
1764
|
+
} catch {}
|
|
1765
|
+
mdnsChild = null
|
|
1766
|
+
}
|
|
1767
|
+
for (const socket of sockets) {
|
|
1768
|
+
try {
|
|
1769
|
+
socket.destroy()
|
|
1770
|
+
} catch {}
|
|
1771
|
+
}
|
|
1772
|
+
const finish = () => process.exit(0)
|
|
1773
|
+
if (server) {
|
|
1774
|
+
server.close(finish)
|
|
1775
|
+
} else {
|
|
1776
|
+
setTimeout(finish, 0)
|
|
1777
|
+
}
|
|
1778
|
+
if (child && child.exitCode === null) {
|
|
1779
|
+
try {
|
|
1780
|
+
child.kill('SIGTERM')
|
|
1781
|
+
} catch {}
|
|
1782
|
+
const killer = setTimeout(() => {
|
|
1783
|
+
try {
|
|
1784
|
+
child?.kill('SIGKILL')
|
|
1785
|
+
} catch {}
|
|
1786
|
+
finish()
|
|
1787
|
+
}, 3000)
|
|
1788
|
+
killer.unref()
|
|
1789
|
+
}
|
|
1790
|
+
setTimeout(finish, 4000).unref()
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
let opts = {
|
|
1794
|
+
dir: process.cwd(),
|
|
1795
|
+
port: 4190,
|
|
1796
|
+
opencodePort: 4191,
|
|
1797
|
+
utilityPort: null,
|
|
1798
|
+
host: '0.0.0.0',
|
|
1799
|
+
publicUrl: null,
|
|
1800
|
+
tunnel: 'auto',
|
|
1801
|
+
ntfy: null,
|
|
1802
|
+
ntfyUrl: null,
|
|
1803
|
+
noAuth: false,
|
|
1804
|
+
mdns: true,
|
|
1805
|
+
printQr: true,
|
|
1806
|
+
opencodeBin: null,
|
|
1807
|
+
relay: null,
|
|
1808
|
+
deviceName: null,
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
function abort(msg) {
|
|
1812
|
+
process.stderr.write(`${HELP}\n`)
|
|
1813
|
+
error(msg)
|
|
1814
|
+
process.exit(2)
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
function readEndpointFile() {
|
|
1818
|
+
try {
|
|
1819
|
+
return JSON.parse(fs.readFileSync(path.join(configPaths().dir, 'endpoint.json'), 'utf8'))
|
|
1820
|
+
} catch {
|
|
1821
|
+
return null
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
function maskToken(token) {
|
|
1826
|
+
if (!token) return '(none)'
|
|
1827
|
+
const text = String(token)
|
|
1828
|
+
return text.length <= 8 ? '****' : `${text.slice(0, 4)}...${text.slice(-4)}`
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
function execCommand(bin, args, timeout = 5000) {
|
|
1832
|
+
return new Promise((resolve) => {
|
|
1833
|
+
execFile(bin, args, { timeout }, (err, stdout, stderr) => {
|
|
1834
|
+
resolve({ ok: !err, stdout: String(stdout || ''), stderr: String(stderr || '') })
|
|
1835
|
+
})
|
|
1836
|
+
})
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
async function probeUrl(url, headers = {}, timeout = 5000) {
|
|
1840
|
+
try {
|
|
1841
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(timeout) })
|
|
1842
|
+
const text = await res.text()
|
|
1843
|
+
return { ok: res.ok, status: res.status, text }
|
|
1844
|
+
} catch (err) {
|
|
1845
|
+
return { ok: false, status: 0, text: err.message }
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
function postLocal(port, path, timeout = 2500) {
|
|
1850
|
+
return new Promise((resolve) => {
|
|
1851
|
+
const req = http.request(
|
|
1852
|
+
{ host: LOOPBACK, port, path, method: 'POST', timeout },
|
|
1853
|
+
(res) => {
|
|
1854
|
+
let text = ''
|
|
1855
|
+
res.setEncoding('utf8')
|
|
1856
|
+
res.on('data', (chunk) => {
|
|
1857
|
+
text += chunk
|
|
1858
|
+
})
|
|
1859
|
+
res.on('end', () => resolve({ status: res.statusCode, text }))
|
|
1860
|
+
}
|
|
1861
|
+
)
|
|
1862
|
+
req.on('timeout', () => req.destroy(new Error('timeout')))
|
|
1863
|
+
req.on('error', () => resolve(null))
|
|
1864
|
+
req.end()
|
|
1865
|
+
})
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
function probeLocal(path, headers = {}, timeout = 3000) {
|
|
1869
|
+
return new Promise((resolve) => {
|
|
1870
|
+
const req = http.request(
|
|
1871
|
+
{ host: LOOPBACK, port: opts.port, path, method: 'GET', headers, timeout },
|
|
1872
|
+
(res) => {
|
|
1873
|
+
let text = ''
|
|
1874
|
+
res.setEncoding('utf8')
|
|
1875
|
+
res.on('data', (chunk) => {
|
|
1876
|
+
text += chunk
|
|
1877
|
+
})
|
|
1878
|
+
res.on('end', () => resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, text }))
|
|
1879
|
+
}
|
|
1880
|
+
)
|
|
1881
|
+
req.on('timeout', () => req.destroy(new Error('timeout')))
|
|
1882
|
+
req.on('error', (err) => resolve({ ok: false, status: 0, text: err.message }))
|
|
1883
|
+
req.end()
|
|
1884
|
+
})
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
async function launchAgentState() {
|
|
1888
|
+
const result = await execCommand('launchctl', ['print', `gui/${process.getuid()}/${LAUNCH_LABEL}`], 5000)
|
|
1889
|
+
if (!result.ok) return 'not loaded'
|
|
1890
|
+
const match = /state = ([a-z]+)/.exec(result.stdout)
|
|
1891
|
+
return match ? match[1] : 'loaded'
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
async function runSubcommand(name, argv) {
|
|
1895
|
+
const parsed = parseArgs(argv)
|
|
1896
|
+
const flags = parsed.error ? {} : parsed.flags
|
|
1897
|
+
const cfg = loadConfig()
|
|
1898
|
+
const port = flags.port !== undefined ? Number(flags.port) : Number(cfg.port ?? 4190)
|
|
1899
|
+
const token = flags.token !== undefined ? String(flags.token) : cfg.token
|
|
1900
|
+
opts.port = port
|
|
1901
|
+
const out = (line) => process.stdout.write(`${line}\n`)
|
|
1902
|
+
const authHeader = token ? { authorization: `Bearer ${token}` } : {}
|
|
1903
|
+
|
|
1904
|
+
if (name === 'funnel') {
|
|
1905
|
+
const action = (argv[0] || 'status').toLowerCase()
|
|
1906
|
+
opts.port = port
|
|
1907
|
+
if (action === 'off') {
|
|
1908
|
+
await disableFunnel()
|
|
1909
|
+
out('tailscale funnel disabled')
|
|
1910
|
+
return 0
|
|
1911
|
+
}
|
|
1912
|
+
if (action === 'status') {
|
|
1913
|
+
const active = await funnelUrlIfActive()
|
|
1914
|
+
out(active ? `funnel active: ${active}` : 'funnel not active')
|
|
1915
|
+
return active ? 0 : 1
|
|
1916
|
+
}
|
|
1917
|
+
if (action !== 'on') {
|
|
1918
|
+
out('usage: oc-remote funnel on|off|status')
|
|
1919
|
+
return 2
|
|
1920
|
+
}
|
|
1921
|
+
const enabled = await enableFunnel()
|
|
1922
|
+
if (enabled) {
|
|
1923
|
+
out(`funnel active: ${enabled}`)
|
|
1924
|
+
out('the companion will adopt it automatically (auto mode watches for it)')
|
|
1925
|
+
return 0
|
|
1926
|
+
}
|
|
1927
|
+
out('funnel not enabled yet: approve the link printed above, then run `oc-remote funnel on` again')
|
|
1928
|
+
return 1
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
if (name === 'version') {
|
|
1932
|
+
out(VERSION)
|
|
1933
|
+
return 0
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
if (name === 'pair') {
|
|
1937
|
+
const mainPort = Number(cfg.port ?? 4190)
|
|
1938
|
+
const candidates = []
|
|
1939
|
+
if (flags['utility-port'] !== undefined) candidates.push(Number(flags['utility-port']))
|
|
1940
|
+
if (cfg.utilityPort) candidates.push(Number(cfg.utilityPort))
|
|
1941
|
+
for (let offset = 1; offset <= 4; offset++) candidates.push(mainPort + offset)
|
|
1942
|
+
const ports = [...new Set(candidates)].filter((port) => port > 0 && port <= 65535)
|
|
1943
|
+
let payload = null
|
|
1944
|
+
for (const port of ports) {
|
|
1945
|
+
const res = await postLocal(port, '/_ocremote/invite')
|
|
1946
|
+
if (!res || res.status !== 200) continue
|
|
1947
|
+
try {
|
|
1948
|
+
payload = JSON.parse(res.text)
|
|
1949
|
+
break
|
|
1950
|
+
} catch {}
|
|
1951
|
+
}
|
|
1952
|
+
if (!payload) {
|
|
1953
|
+
error('could not get an invite from the running companion')
|
|
1954
|
+
out('hint: start it with `oc-remote --dir <project> --relay <wss://...>` or `bash companion/install.sh ...`')
|
|
1955
|
+
return 1
|
|
1956
|
+
}
|
|
1957
|
+
out(payload.url)
|
|
1958
|
+
if (payload.expiresAt) out(`expires: ${new Date(payload.expiresAt).toISOString()} (single use)`)
|
|
1959
|
+
if (!payload.relay) out('note: no relay configured; this is the legacy LAN/tunnel pairing link')
|
|
1960
|
+
if (qrcode) process.stdout.write(`${buildQr(payload.url).createASCII(1, 4)}\n`)
|
|
1961
|
+
return 0
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
if (name === 'relay-status') {
|
|
1965
|
+
const relay = opts.relay ?? cfg.relay
|
|
1966
|
+
if (!relay) {
|
|
1967
|
+
out('relay: not configured (add --relay wss://relay.example)')
|
|
1968
|
+
return 1
|
|
1969
|
+
}
|
|
1970
|
+
const identityStore = new Identity(configPaths().dir).load()
|
|
1971
|
+
if (!identityStore) {
|
|
1972
|
+
out('identity: missing (start the companion once to create it)')
|
|
1973
|
+
return 1
|
|
1974
|
+
}
|
|
1975
|
+
out(`relay: ${relay}`)
|
|
1976
|
+
out(`device: ${identityStore.deviceID()} (${identityStore.name()})`)
|
|
1977
|
+
const pairs = identityStore.pairs()
|
|
1978
|
+
out(`paired devices: ${pairs.length}`)
|
|
1979
|
+
for (const item of pairs) out(` - ${item.pairID} ${item.clientID} ${item.name || ''}`)
|
|
1980
|
+
const httpBase = relay.replace(/^ws/, 'http').replace(/\/+$/, '')
|
|
1981
|
+
const probe = await probeUrl(`${httpBase}/v1/devices/${identityStore.deviceID()}`, {}, 5000)
|
|
1982
|
+
if (probe.ok) {
|
|
1983
|
+
try {
|
|
1984
|
+
const device = JSON.parse(probe.text)
|
|
1985
|
+
const seen = device.lastSeen ? new Date(device.lastSeen * 1000).toISOString() : 'never'
|
|
1986
|
+
out(`companion online: ${device.online} lastSeen: ${seen}`)
|
|
1987
|
+
} catch {}
|
|
1988
|
+
} else {
|
|
1989
|
+
out(`relay unreachable (HTTP ${probe.status})`)
|
|
1990
|
+
}
|
|
1991
|
+
return 0
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
if (name === 'unpair') {
|
|
1995
|
+
const pairID = argv.find((value) => !value.startsWith('--'))
|
|
1996
|
+
if (!pairID) {
|
|
1997
|
+
out('usage: oc-remote unpair <pairID>')
|
|
1998
|
+
return 2
|
|
1999
|
+
}
|
|
2000
|
+
const identityStore = new Identity(configPaths().dir).load()
|
|
2001
|
+
const pair = identityStore ? identityStore.pair(pairID) : null
|
|
2002
|
+
if (!pair) {
|
|
2003
|
+
error(`unknown pair: ${pairID}`)
|
|
2004
|
+
return 1
|
|
2005
|
+
}
|
|
2006
|
+
const relay = opts.relay ?? cfg.relay
|
|
2007
|
+
if (relay) {
|
|
2008
|
+
try {
|
|
2009
|
+
const client = new RelayClient({ relayUrl: relay, identity: identityStore, adapter: null, logger: () => {} })
|
|
2010
|
+
await client.revokePair(pairID)
|
|
2011
|
+
out(`relay: revoked ${pairID}`)
|
|
2012
|
+
} catch (err) {
|
|
2013
|
+
error(`relay revoke failed: ${err.message}`)
|
|
2014
|
+
return 1
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
identityStore.removePair(pairID)
|
|
2018
|
+
out(`local pair removed: ${pairID}`)
|
|
2019
|
+
return 0
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
if (name === 'logs') {
|
|
2023
|
+
out(LOG_PATH)
|
|
2024
|
+
if (fs.existsSync(LOG_PATH)) {
|
|
2025
|
+
const lines = fs.readFileSync(LOG_PATH, 'utf8').trimEnd().split('\n')
|
|
2026
|
+
out(lines.slice(-40).join('\n'))
|
|
2027
|
+
} else {
|
|
2028
|
+
out('(no log file yet)')
|
|
2029
|
+
}
|
|
2030
|
+
return 0
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
if (name === 'restart') {
|
|
2034
|
+
const result = await execCommand('launchctl', ['kickstart', '-k', `gui/${process.getuid()}/${LAUNCH_LABEL}`], 8000)
|
|
2035
|
+
if (!result.ok) {
|
|
2036
|
+
error(`could not restart ${LAUNCH_LABEL}: ${result.stderr.trim() || 'agent not loaded'}`)
|
|
2037
|
+
out('hint: bash companion/install.sh --dir <project> --tunnel <mode>')
|
|
2038
|
+
return 1
|
|
2039
|
+
}
|
|
2040
|
+
out(`restarted ${LAUNCH_LABEL}`)
|
|
2041
|
+
return 0
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
if (name === 'status') {
|
|
2045
|
+
const endpoint = readEndpointFile()
|
|
2046
|
+
const state = await launchAgentState()
|
|
2047
|
+
const health = await probeLocal('/_ocremote/health')
|
|
2048
|
+
const authenticated = token
|
|
2049
|
+
? await probeLocal('/_ocremote/status', authHeader)
|
|
2050
|
+
: { ok: false, status: 0, text: 'no token' }
|
|
2051
|
+
out(`oc-remote v${VERSION}`)
|
|
2052
|
+
out(`config: ${configPaths().file}`)
|
|
2053
|
+
out(`token: ${maskToken(token)}`)
|
|
2054
|
+
out(`launch agent: ${state}`)
|
|
2055
|
+
out(`companion: ${health.ok ? `reachable on 127.0.0.1:${port}` : `NOT reachable on 127.0.0.1:${port}`}`)
|
|
2056
|
+
out(`auth: ${authenticated.ok ? 'token accepted' : `token rejected (HTTP ${authenticated.status})`}`)
|
|
2057
|
+
if (endpoint) {
|
|
2058
|
+
const age = Math.round((Date.now() - Number(endpoint.ts || 0)) / 1000)
|
|
2059
|
+
out(`endpoint: ${endpoint.url} [${endpoint.provider}] published ${age}s ago`)
|
|
2060
|
+
for (const entry of endpoint.urls || []) out(` - ${entry.url} [${entry.label}]`)
|
|
2061
|
+
out(`beacon id: ${endpoint.id}`)
|
|
2062
|
+
} else {
|
|
2063
|
+
out('endpoint: (no endpoint.json yet)')
|
|
2064
|
+
}
|
|
2065
|
+
return health.ok ? 0 : 1
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
if (name === 'doctor') {
|
|
2069
|
+
out(`oc-remote doctor v${VERSION}`)
|
|
2070
|
+
let critical = 0
|
|
2071
|
+
const check = (ok, label, fix) => {
|
|
2072
|
+
out(`${ok ? ' ok ' : 'FAIL'} ${label}`)
|
|
2073
|
+
if (!ok && fix) out(` fix: ${fix}`)
|
|
2074
|
+
if (!ok) critical++
|
|
2075
|
+
}
|
|
2076
|
+
const note = (label, hint) => {
|
|
2077
|
+
out(` - ${label}`)
|
|
2078
|
+
if (hint) out(` hint: ${hint}`)
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
const bin = locateBin(resolveOpencodeBin(flags.opencode))
|
|
2082
|
+
if (bin) {
|
|
2083
|
+
const version = await execCommand(bin, ['--version'], 8000)
|
|
2084
|
+
check(true, `opencode binary: ${bin}${version.ok ? ` (${version.stdout.trim()})` : ''}`)
|
|
2085
|
+
} else {
|
|
2086
|
+
check(false, 'opencode binary not found', 'install opencode or pass --opencode <path>')
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
check(Boolean(token), `pairing token stored in ${configPaths().file}`, 'run `oc-remote` once to generate one')
|
|
2090
|
+
|
|
2091
|
+
const health = await probeLocal('/_ocremote/health')
|
|
2092
|
+
check(
|
|
2093
|
+
health.ok,
|
|
2094
|
+
`companion reachable on 127.0.0.1:${port}`,
|
|
2095
|
+
'start it (`oc-remote --dir <project>`) or `bash companion/install.sh --dir <project>`'
|
|
2096
|
+
)
|
|
2097
|
+
|
|
2098
|
+
if (health.ok) {
|
|
2099
|
+
const status = await probeLocal('/_ocremote/status', authHeader)
|
|
2100
|
+
check(status.ok, `token accepted by the companion (HTTP ${status.status})`, 'rotate the token with `oc-remote --new-token`')
|
|
2101
|
+
const upstream = await probeLocal('/global/health', authHeader, 5000)
|
|
2102
|
+
check(upstream.ok, `opencode upstream healthy (HTTP ${upstream.status})`, 'restart with `oc-remote restart`')
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
const state = await launchAgentState()
|
|
2106
|
+
if (state === 'running' || state === 'loaded') {
|
|
2107
|
+
check(true, `LaunchAgent ${LAUNCH_LABEL}: ${state}`)
|
|
2108
|
+
} else {
|
|
2109
|
+
note(
|
|
2110
|
+
`LaunchAgent ${LAUNCH_LABEL}: ${state}`,
|
|
2111
|
+
'install it for always-on access: `bash companion/install.sh --dir <project> --tunnel auto`'
|
|
2112
|
+
)
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
const lan = lanIPv4()
|
|
2116
|
+
if (lan.length) {
|
|
2117
|
+
check(true, `LAN address${lan.length === 1 ? '' : 'es'}: ${lan.map((e) => e.address).join(', ')}`)
|
|
2118
|
+
} else {
|
|
2119
|
+
note('no LAN address (Wi-Fi or Ethernet off?)', 'the phone can still connect through the tunnel')
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
const tsInstalled = (await execCommand(tailscaleBin(), ['version'], 4000)).ok
|
|
2123
|
+
if (tsInstalled) {
|
|
2124
|
+
const running = await tailscaleRunning()
|
|
2125
|
+
const dnsName = running ? await tailscaleDnsName() : null
|
|
2126
|
+
if (running) {
|
|
2127
|
+
check(true, `tailscale: running (${dnsName || 'no DNS name'})`)
|
|
2128
|
+
if (dnsName) {
|
|
2129
|
+
const proxy = await funnelProxyTarget(dnsName)
|
|
2130
|
+
const active = funnelProxyMatches(proxy)
|
|
2131
|
+
if (active) {
|
|
2132
|
+
check(true, `tailscale funnel -> ${proxy}`)
|
|
2133
|
+
} else {
|
|
2134
|
+
note(
|
|
2135
|
+
'tailscale funnel: not serving this companion',
|
|
2136
|
+
'stable URL with a one-time approval: `oc-remote --tunnel funnel`'
|
|
2137
|
+
)
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
} else {
|
|
2141
|
+
note('tailscale: stopped', 'open the Tailscale app for the stable Funnel URL / tailnet access')
|
|
2142
|
+
}
|
|
2143
|
+
} else {
|
|
2144
|
+
note('tailscale CLI not found', 'optional: stable Funnel URL / tailnet access')
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
const endpoint = readEndpointFile()
|
|
2148
|
+
if (endpoint && endpoint.url) {
|
|
2149
|
+
const endpointProbe = await probeUrl(`${endpoint.url}/_ocremote/health`, {}, 8000)
|
|
2150
|
+
check(
|
|
2151
|
+
endpointProbe.ok,
|
|
2152
|
+
`endpoint reachable: ${endpoint.url} (HTTP ${endpointProbe.status})`,
|
|
2153
|
+
'check the tunnel: `oc-remote logs` or restart with `oc-remote restart`'
|
|
2154
|
+
)
|
|
2155
|
+
} else {
|
|
2156
|
+
note('no endpoint published yet (LAN/tailscale only)', 'run the companion once to publish endpoint.json')
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
if (opts.ntfy || cfg.ntfy) {
|
|
2160
|
+
const raw = String(cfg.ntfy || '')
|
|
2161
|
+
let base = 'https://ntfy.sh'
|
|
2162
|
+
if (/^https?:\/\//i.test(raw)) {
|
|
2163
|
+
try {
|
|
2164
|
+
base = new URL(raw).origin
|
|
2165
|
+
} catch {
|
|
2166
|
+
base = 'https://ntfy.sh'
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
const ntfy = await probeUrl(`${base}/v1/health`, {}, 5000)
|
|
2170
|
+
check(ntfy.ok, `ntfy reachable (${base})`, 'check your network or use a self-hosted ntfy URL')
|
|
2171
|
+
} else {
|
|
2172
|
+
note('ntfy not configured', 'optional: push notifications + endpoint beacons')
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
out(critical === 0 ? 'all checks passed' : `${critical} check${critical === 1 ? '' : 's'} failed`)
|
|
2176
|
+
return critical === 0 ? 0 : 1
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
return 0
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
async function main() {
|
|
2183
|
+
const argv = process.argv.slice(2)
|
|
2184
|
+
if (argv[0] && SUBCOMMANDS.has(argv[0])) {
|
|
2185
|
+
const code = await runSubcommand(argv[0], argv.slice(1))
|
|
2186
|
+
return code
|
|
2187
|
+
}
|
|
2188
|
+
const parsed = parseArgs(argv)
|
|
2189
|
+
if (parsed.error) abort(parsed.error)
|
|
2190
|
+
const flags = parsed.flags
|
|
2191
|
+
if (flags.help) {
|
|
2192
|
+
process.stdout.write(HELP)
|
|
2193
|
+
return 0
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
const cfg = loadConfig()
|
|
2197
|
+
|
|
2198
|
+
opts.port = flags.port !== undefined ? Number(flags.port) : Number(cfg.port ?? 4190)
|
|
2199
|
+
opts.opencodePort =
|
|
2200
|
+
flags['opencode-port'] !== undefined ? Number(flags['opencode-port']) : Number(cfg.opencodePort ?? 4191)
|
|
2201
|
+
opts.host = flags.host !== undefined ? flags.host : (cfg.host ?? '0.0.0.0')
|
|
2202
|
+
opts.publicUrl = flags['public-url'] !== undefined ? flags['public-url'] : (cfg.publicUrl ?? null)
|
|
2203
|
+
opts.tunnel = flags.tunnel !== undefined ? String(flags.tunnel) : (cfg.tunnel ?? 'auto')
|
|
2204
|
+
opts.ntfy = flags.ntfy !== undefined ? flags.ntfy : (cfg.ntfy ?? null)
|
|
2205
|
+
opts.relay = flags.relay !== undefined ? String(flags.relay) : (cfg.relay ?? null)
|
|
2206
|
+
opts.deviceName = flags['device-name'] !== undefined ? String(flags['device-name']) : (cfg.deviceName ?? null)
|
|
2207
|
+
opts.utilityPort =
|
|
2208
|
+
flags['utility-port'] !== undefined
|
|
2209
|
+
? Number(flags['utility-port'])
|
|
2210
|
+
: Number(cfg.utilityPort ?? 0) || null
|
|
2211
|
+
opts.noAuth = Boolean(flags['no-auth'])
|
|
2212
|
+
opts.mdns = !flags['no-mdns']
|
|
2213
|
+
opts.printQr = flags['print-qr'] !== false
|
|
2214
|
+
|
|
2215
|
+
if (!TUNNEL_MODES.has(opts.tunnel)) {
|
|
2216
|
+
abort(`invalid --tunnel: ${opts.tunnel} (expected ${[...TUNNEL_MODES].join(' | ')})`)
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
if (!Number.isInteger(opts.port) || opts.port < 1 || opts.port > 65535) abort(`invalid --port: ${flags.port ?? cfg.port}`)
|
|
2220
|
+
if (!Number.isInteger(opts.opencodePort) || opts.opencodePort < 1 || opts.opencodePort > 65535)
|
|
2221
|
+
abort(`invalid --opencode-port: ${flags['opencode-port'] ?? cfg.opencodePort}`)
|
|
2222
|
+
if (opts.port === opts.opencodePort) abort('--port and --opencode-port must differ')
|
|
2223
|
+
if (opts.utilityPort !== null && (!Number.isInteger(opts.utilityPort) || opts.utilityPort < 1 || opts.utilityPort > 65535))
|
|
2224
|
+
abort(`invalid --utility-port: ${flags['utility-port'] ?? cfg.utilityPort}`)
|
|
2225
|
+
if (opts.utilityPort !== null && (opts.utilityPort === opts.port || opts.utilityPort === opts.opencodePort))
|
|
2226
|
+
abort('--utility-port must differ from --port and --opencode-port')
|
|
2227
|
+
if (opts.relay && !/^wss?:\/\//i.test(opts.relay)) abort(`invalid --relay: ${opts.relay} (expected wss://...)`)
|
|
2228
|
+
|
|
2229
|
+
const dir = path.resolve(flags.dir !== undefined ? flags.dir : process.cwd())
|
|
2230
|
+
let stat
|
|
2231
|
+
try {
|
|
2232
|
+
stat = fs.statSync(dir)
|
|
2233
|
+
} catch {
|
|
2234
|
+
abort(`directory does not exist: ${dir}`)
|
|
2235
|
+
}
|
|
2236
|
+
if (!stat.isDirectory()) abort(`not a directory: ${dir}`)
|
|
2237
|
+
opts.dir = dir
|
|
2238
|
+
|
|
2239
|
+
if (flags.daemon) {
|
|
2240
|
+
const installer = path.join(HERE, 'install.sh')
|
|
2241
|
+
if (!fs.existsSync(installer)) abort(`install.sh not found next to oc-remote.mjs`)
|
|
2242
|
+
const args = [installer, '--dir', dir, '--port', String(opts.port)]
|
|
2243
|
+
if (opts.tunnel) args.push('--tunnel', opts.tunnel)
|
|
2244
|
+
if (opts.relay) args.push('--relay', opts.relay)
|
|
2245
|
+
if (opts.ntfy) args.push('--ntfy', String(opts.ntfy))
|
|
2246
|
+
if (opts.deviceName) args.push('--device-name', opts.deviceName)
|
|
2247
|
+
const child = spawn('bash', args, { stdio: 'inherit' })
|
|
2248
|
+
return await new Promise((resolve) => child.on('exit', (code) => resolve(code ?? 0)))
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
opts.opencodeBin = locateBin(resolveOpencodeBin(flags.opencode))
|
|
2252
|
+
if (!opts.opencodeBin) {
|
|
2253
|
+
if (flags.pair) {
|
|
2254
|
+
const say = (line) => process.stdout.write(`${line}\n`)
|
|
2255
|
+
error('opencode is not installed on this computer.')
|
|
2256
|
+
say(' 1. Install it: curl -fsSL https://opencode.ai/install | bash')
|
|
2257
|
+
say(' 2. Sign in: opencode auth login')
|
|
2258
|
+
say(' 3. Run again: npx ocremote --pair')
|
|
2259
|
+
return 1
|
|
2260
|
+
}
|
|
2261
|
+
abort(`opencode binary not found: ${flags.opencode || 'opencode'}`)
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
if (flags.pair) {
|
|
2265
|
+
info('first run: starting opencode and preparing the pairing QR')
|
|
2266
|
+
info('keep this terminal open while you pair; for an always-on service use --daemon')
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
if (opts.ntfy) {
|
|
2270
|
+
const topic = String(opts.ntfy)
|
|
2271
|
+
opts.ntfyUrl = /^https?:\/\//i.test(topic)
|
|
2272
|
+
? topic
|
|
2273
|
+
: `https://ntfy.sh/${encodeURIComponent(topic)}`
|
|
2274
|
+
opts.ntfyTarget = ntfyTarget(opts.ntfy)
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
const storedToken = typeof cfg.token === 'string' && cfg.token.length ? cfg.token : null
|
|
2278
|
+
let token
|
|
2279
|
+
let tokenChanged = false
|
|
2280
|
+
if (flags['new-token']) {
|
|
2281
|
+
token = randomHex(16)
|
|
2282
|
+
tokenChanged = true
|
|
2283
|
+
info('rotating pairing token (--new-token)')
|
|
2284
|
+
} else if (flags.token !== undefined) {
|
|
2285
|
+
token = String(flags.token)
|
|
2286
|
+
tokenChanged = token !== storedToken
|
|
2287
|
+
} else if (storedToken) {
|
|
2288
|
+
token = storedToken
|
|
2289
|
+
} else {
|
|
2290
|
+
token = randomHex(16)
|
|
2291
|
+
tokenChanged = true
|
|
2292
|
+
info('generated a new pairing token')
|
|
2293
|
+
}
|
|
2294
|
+
remoteToken = token
|
|
2295
|
+
|
|
2296
|
+
const nextConfig = {
|
|
2297
|
+
version: 1,
|
|
2298
|
+
token,
|
|
2299
|
+
port: opts.port,
|
|
2300
|
+
opencodePort: opts.opencodePort,
|
|
2301
|
+
host: opts.host,
|
|
2302
|
+
publicUrl: opts.publicUrl,
|
|
2303
|
+
tunnel: opts.tunnel,
|
|
2304
|
+
ntfy: opts.ntfy,
|
|
2305
|
+
relay: opts.relay,
|
|
2306
|
+
deviceName: opts.deviceName,
|
|
2307
|
+
utilityPort: flags['utility-port'] !== undefined ? opts.utilityPort : (cfg.utilityPort ?? null),
|
|
2308
|
+
updatedAt: new Date().toISOString(),
|
|
2309
|
+
}
|
|
2310
|
+
const configChanged =
|
|
2311
|
+
tokenChanged ||
|
|
2312
|
+
cfg.port !== nextConfig.port ||
|
|
2313
|
+
cfg.opencodePort !== nextConfig.opencodePort ||
|
|
2314
|
+
cfg.host !== nextConfig.host ||
|
|
2315
|
+
cfg.publicUrl !== nextConfig.publicUrl ||
|
|
2316
|
+
cfg.tunnel !== nextConfig.tunnel ||
|
|
2317
|
+
cfg.ntfy !== nextConfig.ntfy ||
|
|
2318
|
+
cfg.relay !== nextConfig.relay ||
|
|
2319
|
+
cfg.deviceName !== nextConfig.deviceName ||
|
|
2320
|
+
cfg.utilityPort !== nextConfig.utilityPort
|
|
2321
|
+
if (configChanged) {
|
|
2322
|
+
try {
|
|
2323
|
+
saveConfig(nextConfig)
|
|
2324
|
+
info(`config saved: ${configPaths().file} (0600)`)
|
|
2325
|
+
} catch (err) {
|
|
2326
|
+
warn(`could not save config: ${err.message}`)
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
internalPassword = randomHex(16)
|
|
2331
|
+
internalAuth = `Basic ${Buffer.from(`opencode:${internalPassword}`, 'utf8').toString('base64')}`
|
|
2332
|
+
|
|
2333
|
+
if (!(await portFree(opts.opencodePort))) {
|
|
2334
|
+
error(`port ${opts.opencodePort} is already in use; pick another with --opencode-port`)
|
|
2335
|
+
return 1
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
await computeUrls()
|
|
2339
|
+
|
|
2340
|
+
launchOpencode()
|
|
2341
|
+
const health = await waitHealthy(30000)
|
|
2342
|
+
if (!health) {
|
|
2343
|
+
error('opencode did not become healthy within 30s; aborting')
|
|
2344
|
+
if (child && child.exitCode === null) child.kill('SIGKILL')
|
|
2345
|
+
return 1
|
|
2346
|
+
}
|
|
2347
|
+
info(`opencode is healthy (v${health.version})`)
|
|
2348
|
+
|
|
2349
|
+
server = createProxyServer()
|
|
2350
|
+
await new Promise((resolve, reject) => {
|
|
2351
|
+
server.once('error', reject)
|
|
2352
|
+
server.listen(opts.port, opts.host, () => {
|
|
2353
|
+
server.off('error', reject)
|
|
2354
|
+
resolve()
|
|
2355
|
+
})
|
|
2356
|
+
}).catch((err) => {
|
|
2357
|
+
error(
|
|
2358
|
+
err.code === 'EADDRINUSE'
|
|
2359
|
+
? `port ${opts.port} is already in use; pick another with --port`
|
|
2360
|
+
: `could not listen on ${opts.host}:${opts.port}: ${err.message}`
|
|
2361
|
+
)
|
|
2362
|
+
if (child && child.exitCode === null) child.kill('SIGKILL')
|
|
2363
|
+
return 1
|
|
2364
|
+
})
|
|
2365
|
+
|
|
2366
|
+
printBanner(health)
|
|
2367
|
+
utilityServer = await createUtilityServer()
|
|
2368
|
+
startMdns()
|
|
2369
|
+
startNtfy()
|
|
2370
|
+
startEndpointBeacon()
|
|
2371
|
+
startTransportWatcher()
|
|
2372
|
+
startWatchdog()
|
|
2373
|
+
await startTunnel()
|
|
2374
|
+
await startRelay()
|
|
2375
|
+
return 0
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
process.on('SIGINT', () => shutdown('SIGINT'))
|
|
2379
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
2380
|
+
process.on('uncaughtException', (err) => error(`uncaught exception: ${err.stack || err.message}`))
|
|
2381
|
+
process.on('unhandledRejection', (err) => error(`unhandled rejection: ${err?.stack || err}`))
|
|
2382
|
+
|
|
2383
|
+
main()
|
|
2384
|
+
.then((code) => {
|
|
2385
|
+
if (code !== 0) process.exit(code)
|
|
2386
|
+
})
|
|
2387
|
+
.catch((err) => {
|
|
2388
|
+
error(`fatal: ${err.stack || err.message}`)
|
|
2389
|
+
process.exit(1)
|
|
2390
|
+
})
|