openrod 0.1.2 → 0.1.3
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 +1 -2
- package/THIRD_PARTY_NOTICES.md +84 -1
- package/dist/assets/index-DBqg9X9x.js +553 -0
- package/dist/assets/index-HZ0q_FBv.css +1 -0
- package/dist/assets/index.esm-BqjC2fhp.js +376 -0
- package/dist/assets/index.esm-Cj6nXjOx.js +16 -0
- package/dist/assets/index.esm-Z1y-oluO.js +1666 -0
- package/dist/assets/terminal-view-D-SQcdM5.js +40 -0
- package/dist/index.html +3 -3
- package/package.json +8 -1
- package/server/announcements.js +22 -0
- package/server/api.js +54 -11
- package/server/capabilities.js +13 -0
- package/server/cli.js +18 -2
- package/server/cloud-deployment.js +29 -0
- package/server/cloud-proxy.js +19 -9
- package/server/cloud-runtime.js +72 -0
- package/server/cloud-ssh-proxy.cjs +22 -4
- package/server/cloud-transfer.js +1 -1
- package/server/compute.js +18 -7
- package/server/image-templates.js +17 -0
- package/server/ingress.js +3 -0
- package/server/local-cloud-native.js +7 -1
- package/server/local-cloud.js +3 -3
- package/server/machines.js +33 -18
- package/server/policy-template-store.js +1 -0
- package/server/remote-gateway.js +26 -6
- package/server/remote-http.js +1 -1
- package/server/resource-import-portable.js +155 -0
- package/server/resource-imports.js +412 -0
- package/server/sandbox-identity.js +1 -1
- package/server/security.js +30 -14
- package/server/setup-discovery.js +2 -1
- package/server/setup-packages.js +138 -59
- package/server/setup-preparation.js +4 -3
- package/server/start.js +29 -7
- package/server/worker-auth.js +2 -2
- package/shared/agent-access.js +26 -4
- package/shared/analytics-release.json +5 -0
- package/src/lib/activity-inventory.js +1 -1
- package/src/lib/analytics.js +334 -0
- package/src/lib/api.js +28 -11
- package/src/lib/cloud-sign-in-error.js +13 -0
- package/src/lib/cloud-transfer.js +1 -1
- package/src/lib/combined-activity.js +95 -0
- package/src/lib/connection-host.js +6 -0
- package/src/lib/egress-inventory.js +63 -0
- package/src/lib/local-cloud.js +16 -3
- package/src/lib/location-choice.js +7 -0
- package/src/lib/location-data-reader.js +25 -0
- package/src/lib/locations.js +18 -8
- package/src/lib/placement.js +1 -1
- package/src/lib/resource-imports.js +63 -0
- package/src/lib/resource-sources.js +105 -0
- package/src/lib/sandbox-creations.js +14 -9
- package/src/lib/sandboxes.js +5 -1
- package/src/lib/working-location.js +10 -0
- package/dist/assets/index-89TNohi_.css +0 -1
- package/dist/assets/index-DoKK2G9s.js +0 -545
- package/dist/assets/terminal-view-DzqQnmh2.js +0 -40
package/dist/index.html
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<meta name="theme-color" content="#ffffff" />
|
|
8
|
-
<meta name="description" content="
|
|
8
|
+
<meta name="description" content="OpenRod — sandboxes, templates, and activity." />
|
|
9
9
|
<title>OpenRod</title>
|
|
10
|
-
<script type="module" crossorigin src="/assets/index-
|
|
11
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
10
|
+
<script type="module" crossorigin src="/assets/index-DBqg9X9x.js"></script>
|
|
11
|
+
<link rel="stylesheet" crossorigin href="/assets/index-HZ0q_FBv.css">
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
14
14
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openrod",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "A local console for OpenShell gateways, sandboxes, and activity.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://github.com/OpenRod/OpenRod#readme",
|
|
@@ -71,9 +71,11 @@
|
|
|
71
71
|
"@vitejs/plugin-react": "^4.3.4",
|
|
72
72
|
"@xterm/addon-fit": "^0.11.0",
|
|
73
73
|
"@xterm/xterm": "^6.0.0",
|
|
74
|
+
"canvas-confetti": "^1.9.4",
|
|
74
75
|
"class-variance-authority": "^0.7.1",
|
|
75
76
|
"clsx": "^2.1.1",
|
|
76
77
|
"cn": "^0.2.5",
|
|
78
|
+
"firebase": "12.19.0",
|
|
77
79
|
"lucide-react": "^0.577.0",
|
|
78
80
|
"motion": "^13.2.0",
|
|
79
81
|
"next-themes": "^0.4.6",
|
|
@@ -88,5 +90,10 @@
|
|
|
88
90
|
},
|
|
89
91
|
"engines": {
|
|
90
92
|
"node": ">=22.13.0"
|
|
93
|
+
},
|
|
94
|
+
"overrides": {
|
|
95
|
+
"@firebase/firestore": {
|
|
96
|
+
"@grpc/grpc-js": "1.14.5"
|
|
97
|
+
}
|
|
91
98
|
}
|
|
92
99
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { stateDirectory } from './paths.js'
|
|
5
|
+
|
|
6
|
+
// Lives outside the npm package and browser origin, so npx upgrades, ports,
|
|
7
|
+
// and browser changes do not replay the announcement for this OS user. While
|
|
8
|
+
// cloud is unavailable it stays unclaimed for the release that offers it.
|
|
9
|
+
export async function claimCloudAnnouncement({ directory = stateDirectory(), user = 'local', available = true } = {}) {
|
|
10
|
+
if (!available) return { show: false }
|
|
11
|
+
const owner = createHash('sha256').update(user).digest('hex')
|
|
12
|
+
const folder = path.join(directory, 'announcements', owner)
|
|
13
|
+
await fs.mkdir(folder, { recursive: true, mode: 0o700 })
|
|
14
|
+
try {
|
|
15
|
+
const file = await fs.open(path.join(folder, 'cloud-v1.seen'), 'wx', 0o600)
|
|
16
|
+
await file.close()
|
|
17
|
+
return { show: true }
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error.code === 'EEXIST') return { show: false }
|
|
20
|
+
throw error
|
|
21
|
+
}
|
|
22
|
+
}
|
package/server/api.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import {createLocalCloudNative} from './local-cloud-native.js'
|
|
2
|
+
import { claimCloudAnnouncement } from './announcements.js'
|
|
2
3
|
import {cloudSshRoute,cloudSshUpgrade} from './cloud-ssh.js'
|
|
3
4
|
import {createLocalCloud} from './local-cloud.js'
|
|
4
5
|
import { cloudOrigin, CLOUD_SOON } from './cloud-origin.js'
|
|
5
6
|
import { setupTargetsFor, validateSetupTargets } from '../shared/setup-targets.js'
|
|
6
7
|
import { localTransfer, importTransfer, exportTransfer } from './cloud-transfer.js'
|
|
8
|
+
import { resourceImportRoute } from './resource-imports.js'
|
|
9
|
+
import { consoleCapabilities } from './capabilities.js'
|
|
7
10
|
import path from 'node:path'
|
|
8
11
|
import { Readable } from 'node:stream'
|
|
9
12
|
import { scopedStateDirectory, stateDirectory } from './paths.js'
|
|
@@ -39,7 +42,7 @@ import { filesRoute, planSeed, receiveUpload, serveDownload, startSeed } from '.
|
|
|
39
42
|
// header alone is not proof of a local caller when Vite is bound to a LAN
|
|
40
43
|
// address, so check the socket, the Host and the browser's own origin claims.
|
|
41
44
|
export { isLocalApiRequest } from './security.js'
|
|
42
|
-
import { createSecurity, releaseConfig, assertCloudOperation, requestPath } from './security.js'
|
|
45
|
+
import { createSecurity, releaseConfig, assertCloudOperation, requestPath, identityContext } from './security.js'
|
|
43
46
|
import { createLaunchToken, createTokenGate, tokenUrl } from './launch-token.js'
|
|
44
47
|
|
|
45
48
|
// A mutation must also carry a JSON body and a custom header, which a
|
|
@@ -54,14 +57,20 @@ function isMutation(req, security) {
|
|
|
54
57
|
const NAME = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/
|
|
55
58
|
const IMAGE = /^[\w./:@-]{1,256}$/
|
|
56
59
|
|
|
57
|
-
async function
|
|
58
|
-
|
|
60
|
+
export async function readRequestBody(req, limit = 65536) {
|
|
61
|
+
const chunks = []
|
|
62
|
+
let bytes = 0
|
|
59
63
|
for await (const chunk of req) {
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
65
|
+
bytes += buffer.length
|
|
66
|
+
if (bytes > limit) throw Object.assign(new Error('Request too large'), { status: 413 })
|
|
67
|
+
chunks.push(buffer)
|
|
62
68
|
}
|
|
63
|
-
|
|
69
|
+
if (!bytes) return {}
|
|
70
|
+
try { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))) }
|
|
71
|
+
catch { throw Object.assign(new Error('Invalid JSON request'), { status: 400 }) }
|
|
64
72
|
}
|
|
73
|
+
const body = readRequestBody
|
|
65
74
|
|
|
66
75
|
function send(res, status, payload) {
|
|
67
76
|
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
|
|
@@ -146,7 +155,8 @@ export async function createSandbox(input, { sessionOverride = false } = {}) {
|
|
|
146
155
|
// A built template's agents get their sign-in and model destinations from
|
|
147
156
|
// the reviewed table in shared/agent-access.js, never from the recipe.
|
|
148
157
|
let agentRules = []
|
|
149
|
-
if (saved?.managed)
|
|
158
|
+
if (input.connectors?.length && !saved?.managed) throw fail('Connectors need a template built in OpenRod.')
|
|
159
|
+
if (saved?.managed) { try { agentRules = agentAccessRules(saved.recipe, { connectors: input.connectors ?? [] }) } catch (error) { throw fail(error.message) } }
|
|
150
160
|
if (saved) {
|
|
151
161
|
const start = sessionOverride && input.session !== undefined ? String(input.session) : saved.recipe.command.trim()
|
|
152
162
|
let selectedSession
|
|
@@ -354,7 +364,7 @@ export function createOpenShellApi({ httpServer, logger = console, security = cr
|
|
|
354
364
|
onReuse: () => logger.warn('The link OpenRod opened in your browser was used a second time. If that wasn’t you, another program may have opened the console first: stop OpenRod with Ctrl+C and start it again.'),
|
|
355
365
|
}) : null
|
|
356
366
|
const runtimes = new Map(), streams = new Set(), sockets = new Set(), pending = new Set(), responses = new Set()
|
|
357
|
-
const localCloud = security.config.mode === 'local' ? createLocalCloud({ native: createLocalCloudNative() }) : null
|
|
367
|
+
const localCloud = security.config.mode === 'local' ? createLocalCloud({ native: createLocalCloudNative({ token }) }) : null
|
|
358
368
|
const cloudOff = security.config.mode === 'local' && !cloudOrigin()
|
|
359
369
|
const initialContext = contextSelection()
|
|
360
370
|
const initialKey = contextKey(initialContext)
|
|
@@ -512,6 +522,12 @@ export function createOpenShellApi({ httpServer, logger = console, security = cr
|
|
|
512
522
|
if (explicitLocation && security.config.mode === 'local') owner = await inventory.resolve(requestedContext)
|
|
513
523
|
else if (requestedContext != null && requestedContext !== contextKey() && !(req.method === 'GET' && parts[0] === 'connections')) return send(res, 409, { error: 'Console context changed. Reload before continuing.' })
|
|
514
524
|
return await runWithContext(owner, async () => {
|
|
525
|
+
if (req.method === 'GET' && parts.length === 1 && parts[0] === 'capabilities') return send(res, 200, consoleCapabilities(security.config.mode))
|
|
526
|
+
if (parts.length === 2 && parts[0] === 'announcements' && parts[1] === 'cloud') {
|
|
527
|
+
if (!isMutation(req, security)) return send(res, 403, { error: 'Request rejected' })
|
|
528
|
+
await body(req)
|
|
529
|
+
return send(res, 200, await claimCloudAnnouncement({ user: identityContext.getStore()?.uid ?? 'local', available: !cloudOff }))
|
|
530
|
+
}
|
|
515
531
|
// Connection discovery is available before any gateway is selected.
|
|
516
532
|
// Job reads remain available after that job changes the context.
|
|
517
533
|
if (parts[0] === 'connections') {
|
|
@@ -529,11 +545,18 @@ export function createOpenShellApi({ httpServer, logger = console, security = cr
|
|
|
529
545
|
const input = await body(req)
|
|
530
546
|
if (parts.length === 2 && parts[1] === 'connect') return send(res, 200, remoteConnections.begin(input))
|
|
531
547
|
if (parts.length === 3 && parts[1] === 'hosts' && parts[2] === 'scan') return send(res, 200, await sshHosts.scan(input))
|
|
532
|
-
if (parts.length === 2 && parts[1] === 'hosts')
|
|
548
|
+
if (parts.length === 2 && parts[1] === 'hosts') {
|
|
549
|
+
const saved = await sshHosts.add(input.token)
|
|
550
|
+
await remoteConnections.unforgetHost(saved.alias)
|
|
551
|
+
return send(res, 201, saved)
|
|
552
|
+
}
|
|
553
|
+
if (parts.length === 3 && parts[1] === 'hosts' && parts[2] === 'restore') return send(res, 200, await remoteConnections.restoreHost(input.host))
|
|
533
554
|
if (parts.length === 3 && parts[1] === 'hosts' && parts[2] === 'remove') {
|
|
534
555
|
const current = (await remoteConnections.overview()).active
|
|
535
556
|
if (current?.host === input.alias && ['connected', 'connecting'].includes(current.status)) throw fail('Disconnect from this machine before removing it.', 409)
|
|
536
|
-
|
|
557
|
+
const removed = await sshHosts.remove(input.alias)
|
|
558
|
+
await remoteConnections.unforgetHost(removed.alias)
|
|
559
|
+
return send(res, 200, removed)
|
|
537
560
|
}
|
|
538
561
|
if (parts.length === 2 && parts[1] === 'disconnect') return send(res, 200, await remoteConnections.disconnect())
|
|
539
562
|
if (parts.length === 2 && parts[1] === 'forget') {
|
|
@@ -596,7 +619,23 @@ export function createOpenShellApi({ httpServer, logger = console, security = cr
|
|
|
596
619
|
if (remote?.gateway === contextSelection().gateway) await inventory.resolve(contextKey())
|
|
597
620
|
}
|
|
598
621
|
const { store, delivery, hub } = runtimeFor(contextSelection())
|
|
622
|
+
const importOptions = {
|
|
623
|
+
sourceKind: security.config.mode === 'local' ? undefined : 'cloud',
|
|
624
|
+
activityStore: store,
|
|
625
|
+
onEvent: ({ action, job }) => store.ingest({
|
|
626
|
+
id: randomUUID(), sandbox: '', at: new Date().toISOString(), kind: 'event', category: 'EVENT',
|
|
627
|
+
source: 'openrod-import', action, severity: job.status === 'failed' || job.status === 'partial' ? 'WARN' : 'INFO',
|
|
628
|
+
outcome: job.status === 'completed' ? 'success' : job.status === 'failed' ? 'failure' : undefined,
|
|
629
|
+
actor: identityContext.getStore()?.email ?? 'Local operator', correlationId: job.id,
|
|
630
|
+
message: `${action}: ${job.counts.completed}/${job.counts.total} resources completed`,
|
|
631
|
+
original: { jobId: job.id, source: job.source, destination: job.destination, counts: job.counts },
|
|
632
|
+
}, contextKey()),
|
|
633
|
+
}
|
|
599
634
|
if (req.method === 'GET') {
|
|
635
|
+
if (parts[0] === 'resource-imports') {
|
|
636
|
+
const result = await resourceImportRoute('GET', parts, undefined, importOptions)
|
|
637
|
+
return send(res, result === undefined ? 404 : 200, result ?? { error: 'Not found' })
|
|
638
|
+
}
|
|
600
639
|
if (parts[0] === 'stream') {
|
|
601
640
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' })
|
|
602
641
|
res.write(': connected\n\n')
|
|
@@ -651,8 +690,12 @@ export function createOpenShellApi({ httpServer, logger = console, security = cr
|
|
|
651
690
|
}
|
|
652
691
|
if (!isMutation(req, security)) return send(res, 403, { error: 'Request rejected' })
|
|
653
692
|
if (parts[0] === 'cloud-import' && ['worker', 'local'].includes(security.config.mode)) return send(res, 200, await importTransfer(req, { createSandbox: input => createSandbox(input, { sessionOverride: true }) }))
|
|
654
|
-
const input = await body(req, ['image-templates', 'activity'].includes(parts[0]) ? 512 * 1024 : parts[0] === 'setups' ? 128 * 1024 : 65536)
|
|
693
|
+
const input = await body(req, parts[0] === 'resource-imports' ? 8 * 1024 * 1024 : ['image-templates', 'activity'].includes(parts[0]) ? 512 * 1024 : parts[0] === 'setups' ? 128 * 1024 : 65536)
|
|
655
694
|
assertCloudOperation(parts, input)
|
|
695
|
+
if (parts[0] === 'resource-imports') {
|
|
696
|
+
const result = await resourceImportRoute('POST', parts, input, importOptions)
|
|
697
|
+
return send(res, result === undefined ? 404 : 200, result ?? { error: 'Not found' })
|
|
698
|
+
}
|
|
656
699
|
if (parts[0] === 'cloud-transfer' && security.config.mode === 'local') return send(res, 200, await localTransfer(input))
|
|
657
700
|
if (parts[0] === 'activity' && parts.length === 2) {
|
|
658
701
|
if (parts[1] === 'delete-preview') return send(res, 200, store.previewDeletion(input))
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { CLOUD_TRANSFER_LIMIT, CLOUD_TRANSFER_BODY_LIMIT } from './cloud-transfer.js'
|
|
2
|
+
|
|
3
|
+
// Explicit support, rather than environment names, drives available UI actions.
|
|
4
|
+
export function consoleCapabilities(mode = 'local') {
|
|
5
|
+
const cloud = mode !== 'local'
|
|
6
|
+
return {
|
|
7
|
+
version: 1,
|
|
8
|
+
mode,
|
|
9
|
+
resources: Object.fromEntries(['sandboxes', 'templates', 'setups', 'network', 'groups', 'secrets', 'activity'].map(type => [type, { read: true, manage: true }])),
|
|
10
|
+
features: { terminal: true, files: true, templateBuilds: true, localFolders: !cloud, publicIngress: !cloud, resourceImports: true },
|
|
11
|
+
workspaceTransfer: { supported: true, maxBytes: CLOUD_TRANSFER_LIMIT, maxFiles: 2000, maxRequestBytes: CLOUD_TRANSFER_BODY_LIMIT, preservesSource: true, credentials: false, runningProcesses: false },
|
|
12
|
+
}
|
|
13
|
+
}
|
package/server/cli.js
CHANGED
|
@@ -121,7 +121,21 @@ function openBrowser(url, logger) {
|
|
|
121
121
|
child.unref()
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
// The API authenticates these upgrades. The CLI must not destroy a cloud
|
|
125
|
+
// relay socket after that asynchronous handler has accepted responsibility.
|
|
126
|
+
export function rejectUnsupportedUpgrade(req, socket) {
|
|
127
|
+
const pathname = (req.url ?? '').split('?', 1)[0]
|
|
128
|
+
if (!['/api/os/terminal', '/api/os/ssh', '/api/remote/os/terminal', '/api/remote/os/ssh'].includes(pathname)) socket.destroy()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The openrod command is the loopback console guarded by its launch token.
|
|
132
|
+
// Cloud and worker deployments start server/start.js instead.
|
|
133
|
+
function assertLocalMode() {
|
|
134
|
+
if ((process.env.OPENROD_MODE ?? 'local') !== 'local') throw new Error('openrod runs the local console. Unset OPENROD_MODE, or start cloud and worker deployments with server/start.js.')
|
|
135
|
+
}
|
|
136
|
+
|
|
124
137
|
export async function startConsole(options = parseOptions([]), logger = console) {
|
|
138
|
+
assertLocalMode()
|
|
125
139
|
const { host, port } = parseOptions(['--host', options.host ?? '127.0.0.1', '--port', String(options.port ?? 4600)])
|
|
126
140
|
let directory
|
|
127
141
|
try {
|
|
@@ -129,6 +143,7 @@ export async function startConsole(options = parseOptions([]), logger = console)
|
|
|
129
143
|
await fs.access(path.join(directory, 'index.html'))
|
|
130
144
|
} catch { throw new Error('Built frontend is missing. Run npm run build from the source checkout before starting or packaging the console.') }
|
|
131
145
|
const { createOpenShellApi, isLocalApiRequest } = await import('./api.js')
|
|
146
|
+
const { createSecurity } = await import('./security.js')
|
|
132
147
|
const { createLaunchToken, tokenUrl } = await import('./launch-token.js')
|
|
133
148
|
const token = createLaunchToken()
|
|
134
149
|
const serveStatic = staticMiddleware(directory)
|
|
@@ -153,9 +168,9 @@ export async function startConsole(options = parseOptions([]), logger = console)
|
|
|
153
168
|
server.once('error', error)
|
|
154
169
|
server.listen(port, host === 'localhost' ? '127.0.0.1' : host, () => { server.off('error', error); resolve() })
|
|
155
170
|
})
|
|
156
|
-
try { api = createOpenShellApi({ httpServer: server, logger, token }) }
|
|
171
|
+
try { api = createOpenShellApi({ httpServer: server, logger, token, security: createSecurity({ mode: 'local' }) }) }
|
|
157
172
|
catch (error) { server.close(); throw error }
|
|
158
|
-
server.on('upgrade',
|
|
173
|
+
server.on('upgrade', rejectUnsupportedUpgrade)
|
|
159
174
|
let closing
|
|
160
175
|
const close = () => {
|
|
161
176
|
if (closing) return closing
|
|
@@ -184,6 +199,7 @@ async function main() {
|
|
|
184
199
|
if (options.help) { process.stdout.write(HELP); return }
|
|
185
200
|
if (options.version) { console.log(JSON.parse(await fs.readFile(new URL('../package.json', import.meta.url), 'utf8')).version); return }
|
|
186
201
|
if (!nodeSupported()) throw new Error(`OpenRod needs Node.js 22.13 or newer, and this is ${process.versions.node}. Install a current release from https://nodejs.org, then run it again.`)
|
|
202
|
+
assertLocalMode()
|
|
187
203
|
quietSqliteWarning()
|
|
188
204
|
// Before the console starts, so the browser opens on a usable local gateway.
|
|
189
205
|
try {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
// Infrastructure identities can outlive product naming. Configure an existing
|
|
4
|
+
// fleet explicitly rather than renaming records or allocating replacement VMs.
|
|
5
|
+
export function workerPrefix(value = 'openrod-user') {
|
|
6
|
+
if (!/^[a-z][a-z0-9-]{0,30}$/.test(value)) throw Error('Invalid OPENROD_WORKER_PREFIX')
|
|
7
|
+
return value
|
|
8
|
+
}
|
|
9
|
+
export function workerOwnerHash(uid) {
|
|
10
|
+
if (typeof uid !== 'string' || !uid || uid.length > 128) throw Object.assign(Error('Invalid account'), { status: 400 })
|
|
11
|
+
return createHash('sha256').update(uid).digest('hex').slice(0, 24)
|
|
12
|
+
}
|
|
13
|
+
// A fleet provisioned before OpenRod signs requests with its own header names.
|
|
14
|
+
// Its operator supplies their prefix; the source names only OpenRod's headers.
|
|
15
|
+
export function legacyHeaderPrefix(value) {
|
|
16
|
+
if (!/^x-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value ?? '') || value === 'x-openrod') throw Error('OPENROD_WORKER_PROTOCOL=legacy requires OPENROD_LEGACY_HEADER_PREFIX, the earlier header prefix such as x-acme')
|
|
17
|
+
return value
|
|
18
|
+
}
|
|
19
|
+
export function workerProtocol(value = 'current', env = process.env) {
|
|
20
|
+
if (!['current', 'legacy'].includes(value)) throw Error('OPENROD_WORKER_PROTOCOL must be current or legacy')
|
|
21
|
+
if (value === 'legacy') legacyHeaderPrefix(env.OPENROD_LEGACY_HEADER_PREFIX)
|
|
22
|
+
return value
|
|
23
|
+
}
|
|
24
|
+
export const workerAuthHeader = (protocol, env = process.env) => protocol === 'legacy' ? `${legacyHeaderPrefix(env.OPENROD_LEGACY_HEADER_PREFIX)}-worker-auth` : 'x-openrod-worker-auth'
|
|
25
|
+
export const workerNameHeader = (protocol, env = process.env) => protocol === 'legacy' ? `${legacyHeaderPrefix(env.OPENROD_LEGACY_HEADER_PREFIX)}-worker` : 'x-openrod-worker'
|
|
26
|
+
export function ownerLabel(value = 'openrod_owner') {
|
|
27
|
+
if (!/^[a-z][a-z0-9_]{0,62}$/.test(value)) throw Error('Invalid OPENROD_WORKER_OWNER_LABEL')
|
|
28
|
+
return value
|
|
29
|
+
}
|
package/server/cloud-proxy.js
CHANGED
|
@@ -4,20 +4,22 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
|
4
4
|
import { identityContext, requestPath } from './security.js'
|
|
5
5
|
import {readJson,safeWorkspaceTarget} from './remote-http.js'
|
|
6
6
|
import { signWorkerRequest } from './worker-auth.js'
|
|
7
|
+
import { workerPrefix, workerAuthHeader, workerNameHeader } from './cloud-deployment.js'
|
|
7
8
|
const fail=(message,status=503)=>Object.assign(Error(message),{status})
|
|
8
9
|
const json=(res,status,value)=>{res.writeHead(status,{'Content-Type':'application/json','Cache-Control':'no-store'});res.end(JSON.stringify(value))}
|
|
9
10
|
const hash=value=>createHash('sha256').update(value).digest('hex')
|
|
10
|
-
export function createHandoffs(db,now=()=>Date.now()) {
|
|
11
|
+
export function createHandoffs(db,now=()=>Date.now(),prefix='openrod-user') {
|
|
12
|
+
const ticketPattern=new RegExp(`^${workerPrefix(prefix)}-[a-f0-9]{24}\\.[a-f0-9]{64}$`)
|
|
11
13
|
return {
|
|
12
14
|
async issue(identity,name){const token=`${name}.${randomBytes(32).toString('hex')}`;await db.collection('handoffs').doc(name).set({uid:identity.uid,hash:hash(token),expires:Math.min(identity.expires,now()+300000),used:false});return token},
|
|
13
15
|
async consume(token){
|
|
14
|
-
if(typeof token!=='string'
|
|
16
|
+
if(typeof token!=='string'||!ticketPattern.test(token))throw fail('Invalid cloud transfer ticket',401)
|
|
15
17
|
return db.runTransaction(async tx=>{const ref=db.collection('handoffs').doc(token.split('.')[0]),s=await tx.get(ref),r=s.exists?s.data():null;if(!r||r.used||r.expires<=now()||r.hash!==hash(token))throw fail('Cloud transfer ticket expired or already used',401);tx.update(ref,{used:true});return r.uid})
|
|
16
18
|
},
|
|
17
19
|
}
|
|
18
20
|
}
|
|
19
21
|
export function proxyWorker(req,res,record,identity,config,{target=req.url,maxBytes=36*1024*1024}={}) {
|
|
20
|
-
const headers={host:config.host,origin:config.origin,'x-openshell-console':'1',
|
|
22
|
+
const headers={host:config.host,origin:config.origin,'x-openshell-console':'1',[workerAuthHeader(record.workerProtocol??config.workerProtocol)]:signWorkerRequest(record.key,identity,{method:req.method,url:target})}
|
|
21
23
|
for(const key of ['content-type','content-length','accept','range','x-openshell-context','x-openshell-location'])if(req.headers[key])headers[key]=req.headers[key]
|
|
22
24
|
return new Promise(resolve=>{
|
|
23
25
|
const upstream=http.request({hostname:record.address,port:record.port??4600,path:target,method:req.method,headers},response=>{
|
|
@@ -32,7 +34,7 @@ export function proxyWorker(req,res,record,identity,config,{target=req.url,maxBy
|
|
|
32
34
|
})
|
|
33
35
|
}
|
|
34
36
|
export function proxyWorkerSocket(req,socket,head,record,identity,config,{target=req.url}={}) {
|
|
35
|
-
const upstream=http.request({hostname:record.address,port:record.port??4600,path:target,method:'GET',headers:{host:config.host,origin:config.origin,upgrade:'websocket',connection:'Upgrade','sec-websocket-key':req.headers['sec-websocket-key'],'sec-websocket-version':req.headers['sec-websocket-version'],
|
|
37
|
+
const upstream=http.request({hostname:record.address,port:record.port??4600,path:target,method:'GET',headers:{host:config.host,origin:config.origin,upgrade:'websocket',connection:'Upgrade','sec-websocket-key':req.headers['sec-websocket-key'],'sec-websocket-version':req.headers['sec-websocket-version'],[workerAuthHeader(record.workerProtocol??config.workerProtocol)]:signWorkerRequest(record.key,identity,{method:'GET',url:target})}})
|
|
36
38
|
upstream.once('upgrade',(response,remote,remoteHead)=>{
|
|
37
39
|
socket.write(`HTTP/1.1 101 Switching Protocols\r\n${Object.entries(response.headers).map(([k,v])=>`${k}: ${v}`).join('\r\n')}\r\n\r\n`)
|
|
38
40
|
if(remoteHead.length)socket.write(remoteHead);if(head.length)remote.write(head)
|
|
@@ -47,7 +49,7 @@ export function cloudRouter(security,machines,handoffs,auth,{artifact=process.en
|
|
|
47
49
|
const pathname=requestPath(req)
|
|
48
50
|
if(pathname==='/internal/worker-artifact') {
|
|
49
51
|
if(req.method!=='GET')throw fail('Not found',404)
|
|
50
|
-
const record=await machines.store.byName(req.headers['x-openrod-worker'])
|
|
52
|
+
const record=await machines.store.byName(req.headers['x-openrod-worker']??(security.config.workerProtocol==='legacy'?req.headers[workerNameHeader('legacy')]:undefined))
|
|
51
53
|
const supplied=req.headers.authorization?.replace(/^Bearer /,'')??''
|
|
52
54
|
if(!record||supplied.length!==record.key.length||!timingSafeEqual(Buffer.from(supplied),Buffer.from(record.key))||!artifact)throw fail('Not found',404)
|
|
53
55
|
res.writeHead(200,{'Content-Type':'application/gzip','Cache-Control':'no-store'});const file=fs.createReadStream(artifact);file.on('error',()=>res.destroy());res.once('close',()=>file.destroy());file.pipe(res);return
|
|
@@ -69,11 +71,15 @@ export function cloudRouter(security,machines,handoffs,auth,{artifact=process.en
|
|
|
69
71
|
return await proxyWorker(req,res,record,identity,security.config,{target:'/api/os/inventory'})
|
|
70
72
|
}
|
|
71
73
|
if(pathname==='/api/cloud/local-connect/machine'&&req.method==='GET')return json(res,200,await machines.status(identity))
|
|
74
|
+
if(pathname==='/api/cloud/local-connect/machine'&&req.method==='POST'){
|
|
75
|
+
if(req.headers['x-openshell-console']!=='1')throw fail('Request rejected',403)
|
|
76
|
+
await readJson(req);return json(res,200,await machines.prepare(identity))
|
|
77
|
+
}
|
|
72
78
|
if(pathname.startsWith('/api/cloud/local-connect/os/')&&['GET','POST'].includes(req.method)){
|
|
73
79
|
const target=safeWorkspaceTarget(req.url,'/api/cloud/local-connect/os')
|
|
74
80
|
if(req.method!=='GET'&&req.headers['x-openshell-console']!=='1')throw fail('Request rejected',403)
|
|
75
81
|
watchConnection(token,res,identity)
|
|
76
|
-
return await proxyWorker(req,res,await machines.target(identity),identity,security.config,{target})
|
|
82
|
+
return await proxyWorker(req,res,await machines.target(identity,{provision:req.method!=='GET'}),identity,security.config,{target})
|
|
77
83
|
}
|
|
78
84
|
throw fail('Not found',404)
|
|
79
85
|
}
|
|
@@ -93,11 +99,15 @@ export function cloudRouter(security,machines,handoffs,auth,{artifact=process.en
|
|
|
93
99
|
const identity=identityContext.getStore(),pathname=requestPath(req)
|
|
94
100
|
if(connections&&pathname==='/api/cloud/local-connect/authorize'&&req.method==='POST'){const body=await readJson(req);return json(res,200,{code:await connections.authorize(identity,body,security.sessionHash(req))})}
|
|
95
101
|
if(pathname==='/api/cloud/machine'&&req.method==='GET')return json(res,200,await machines.status(identity))
|
|
102
|
+
if(pathname==='/api/cloud/machine'&&req.method==='POST'){
|
|
103
|
+
if(req.headers['x-openshell-console']!=='1')throw fail('Request rejected',403)
|
|
104
|
+
await readJson(req);return json(res,200,await machines.prepare(identity))
|
|
105
|
+
}
|
|
96
106
|
if(pathname==='/api/cloud/handoff'&&req.method==='POST'){
|
|
97
107
|
if(req.headers['x-openshell-console']!=='1')throw fail('Request rejected',403)
|
|
98
108
|
const record=await machines.target(identity);return json(res,200,{ticket:await handoffs.issue(identity,record.name)})
|
|
99
109
|
}
|
|
100
|
-
if(pathname.startsWith('/api/os/'))return await proxyWorker(req,res,await machines.target(identity),identity,security.config)
|
|
110
|
+
if(pathname.startsWith('/api/os/'))return await proxyWorker(req,res,await machines.target(identity,{provision:req.method!=='GET'}),identity,security.config)
|
|
101
111
|
next()
|
|
102
112
|
}catch(error){json(res,error.status??503,{error:error.status?error.message:'Cloud service unavailable. Try again shortly.'})}
|
|
103
113
|
}
|
|
@@ -113,10 +123,10 @@ export function cloudRouter(security,machines,handoffs,auth,{artifact=process.en
|
|
|
113
123
|
const target=safeWorkspaceTarget(req.url,'/api/cloud/local-connect/os')
|
|
114
124
|
if(!['/api/os/terminal','/api/os/ssh'].includes(target.split('?')[0]))throw fail('Not found',404)
|
|
115
125
|
const token=req.headers.authorization?.replace(/^Bearer /,''),identity=await connections.authenticate(token)
|
|
116
|
-
watchConnection(token,socket,identity);proxyWorkerSocket(req,socket,head,await machines.target(identity),identity,security.config,{target});return
|
|
126
|
+
watchConnection(token,socket,identity);proxyWorkerSocket(req,socket,head,await machines.target(identity,{provision:false}),identity,security.config,{target});return
|
|
117
127
|
}
|
|
118
128
|
if(pathname!=='/api/os/terminal')return
|
|
119
|
-
const identity=await security.authenticate(req);security.watch(req,socket,identity);proxyWorkerSocket(req,socket,head,await machines.target(identity),identity,security.config)
|
|
129
|
+
const identity=await security.authenticate(req);security.watch(req,socket,identity);proxyWorkerSocket(req,socket,head,await machines.target(identity,{provision:false}),identity,security.config)
|
|
120
130
|
}catch{socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')}
|
|
121
131
|
}}
|
|
122
132
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
4
|
+
import { createRequire } from 'node:module'
|
|
5
|
+
import { createCompute, workerDeploymentConfig } from './compute.js'
|
|
6
|
+
import { createMachineStore, createMachineManager, workerReady } from './machines.js'
|
|
7
|
+
import { createCloudConnections } from './cloud-connections.js'
|
|
8
|
+
import { createHandoffs } from './cloud-proxy.js'
|
|
9
|
+
import { workerPrefix, ownerLabel } from './cloud-deployment.js'
|
|
10
|
+
|
|
11
|
+
// Keep the Admin SDK outside the local npm package and worker installation.
|
|
12
|
+
// Only the explicitly selected cloud control plane ever loads it.
|
|
13
|
+
async function loadCloudAdmin() {
|
|
14
|
+
const require = createRequire(new URL('../../deploy/gcp/runtime/package.json', import.meta.url))
|
|
15
|
+
try {
|
|
16
|
+
return { ...require('firebase-admin/app'), ...require('firebase-admin/auth'), ...require('firebase-admin/firestore') }
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (error.code === 'MODULE_NOT_FOUND') throw Error('Cloud runtime dependencies are missing. Run npm ci --prefix deploy/gcp/runtime from the repository root.')
|
|
19
|
+
throw error
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function cloudRuntimeConfig(config, env = process.env) {
|
|
24
|
+
if (config.mode !== 'cloud') throw Error('Cloud runtime requires cloud mode')
|
|
25
|
+
const maxMachines = Number(env.OPENROD_MAX_MACHINES ?? 10)
|
|
26
|
+
if (!Number.isInteger(maxMachines) || maxMachines < 1 || maxMachines > 100) throw Error('OPENROD_MAX_MACHINES must be between 1 and 100')
|
|
27
|
+
const databaseId = env.OPENROD_FIRESTORE_DATABASE ?? 'openrod-cloud'
|
|
28
|
+
if (!/^[a-z][a-z0-9-]{2,61}[a-z0-9]$/.test(databaseId)) throw Error('Invalid OPENROD_FIRESTORE_DATABASE')
|
|
29
|
+
const artifact = env.OPENROD_WORKER_ARTIFACT
|
|
30
|
+
if (!artifact || !path.isAbsolute(artifact)) throw Error('OPENROD_WORKER_ARTIFACT must identify the reviewed worker artifact by absolute path')
|
|
31
|
+
const prefix = workerPrefix(env.OPENROD_WORKER_PREFIX), label = ownerLabel(env.OPENROD_WORKER_OWNER_LABEL)
|
|
32
|
+
if (env.OPENROD_PROVISIONING_ENABLED !== undefined && !['true', 'false'].includes(env.OPENROD_PROVISIONING_ENABLED)) throw Error('OPENROD_PROVISIONING_ENABLED must be true or false')
|
|
33
|
+
return { maxMachines, databaseId, artifact, prefix, ownerLabel: label, allowProvisioning: env.OPENROD_PROVISIONING_ENABLED !== 'false', deployment: workerDeploymentConfig(config, env) }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function verifyWorkerArtifact(filename, expectedHash) {
|
|
37
|
+
if (!fs.statSync(filename).isFile()) throw Error('Worker artifact must be a regular file')
|
|
38
|
+
const digest = createHash('sha256')
|
|
39
|
+
for await (const chunk of fs.createReadStream(filename)) digest.update(chunk)
|
|
40
|
+
if (digest.digest('hex') !== expectedHash) throw Error('Worker artifact checksum does not match OPENROD_WORKER_ARTIFACT_SHA256')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function createCloudRuntime(config, { env = process.env, revocations, loadAdmin = loadCloudAdmin, computeFactory = createCompute } = {}) {
|
|
44
|
+
const settings = cloudRuntimeConfig(config, env)
|
|
45
|
+
await verifyWorkerArtifact(settings.artifact, settings.deployment.artifactHash)
|
|
46
|
+
const admin = await loadAdmin()
|
|
47
|
+
const credential = admin.applicationDefault()
|
|
48
|
+
const app = admin.initializeApp({ projectId: config.firebase.projectId, credential }, `openrod-${randomUUID()}`)
|
|
49
|
+
let db, closed = false
|
|
50
|
+
const close = async () => {
|
|
51
|
+
if (closed) return
|
|
52
|
+
closed = true
|
|
53
|
+
try { await db?.terminate() } finally { await admin.deleteApp(app) }
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const auth = admin.getAuth(app)
|
|
57
|
+
db = admin.getFirestore(app, settings.databaseId)
|
|
58
|
+
// Fail startup before advertising readiness if ADC or registry IAM is broken.
|
|
59
|
+
// This is read-only and never reserves a fleet slot or creates a VM.
|
|
60
|
+
await credential.getAccessToken()
|
|
61
|
+
await db.collection('control').doc('fleet').get()
|
|
62
|
+
const compute = await computeFactory(config, credential, { env })
|
|
63
|
+
const store = createMachineStore(db, { maxMachines: settings.maxMachines, prefix: settings.prefix })
|
|
64
|
+
const machines = createMachineManager(store, compute, { ownerLabel: settings.ownerLabel, allowProvisioning: settings.allowProvisioning, ready: (record, address) => workerReady(record, address, { host: config.host, protocol: record.workerProtocol ?? config.workerProtocol }) })
|
|
65
|
+
const handoffs = createHandoffs(db, Date.now, settings.prefix)
|
|
66
|
+
const connections = createCloudConnections(db, auth, { org: config.org, isSessionRevoked: hash => revocations?.hasDigest(hash) ?? false })
|
|
67
|
+
return { auth, machines, handoffs, connections, artifact: settings.artifact, close, async ready() { await db.collection('control').doc('fleet').get(); return true } }
|
|
68
|
+
} catch (error) {
|
|
69
|
+
await close().catch(() => {})
|
|
70
|
+
throw error
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
'use strict'
|
|
3
3
|
// OpenSSH invokes this helper locally. Grants remain inside OpenRod's local server;
|
|
4
4
|
// this process obtains only a single-use, account-scoped SSH stream ticket.
|
|
5
|
+
const fs = require('node:fs/promises')
|
|
5
6
|
const { WebSocket, createWebSocketStream } = require('ws')
|
|
6
7
|
|
|
7
8
|
function validate(options) {
|
|
@@ -18,24 +19,41 @@ function parseProxyArgs(args) {
|
|
|
18
19
|
const options = {}
|
|
19
20
|
for (let i = 0; i < args.length; i += 2) {
|
|
20
21
|
const flag = args[i]
|
|
21
|
-
if (!['--origin', '--sandbox', '--owner', '--context'].includes(flag) || !args[i + 1] || Object.hasOwn(options, flag.slice(2))) throw Error('Invalid OpenRod SSH proxy arguments')
|
|
22
|
+
if (!['--origin', '--sandbox', '--owner', '--context', '--auth-file'].includes(flag) || !args[i + 1] || Object.hasOwn(options, flag.slice(2))) throw Error('Invalid OpenRod SSH proxy arguments')
|
|
22
23
|
options[flag.slice(2)] = args[i + 1]
|
|
23
24
|
}
|
|
24
25
|
return validate(options)
|
|
25
26
|
}
|
|
26
27
|
async function runProxy(options, { input = process.stdin, output = process.stdout, fetchRequest = fetch, signal } = {}) {
|
|
27
28
|
validate(options)
|
|
29
|
+
let cookie
|
|
30
|
+
if (options['auth-file']) {
|
|
31
|
+
try {
|
|
32
|
+
const file = await fs.open(options['auth-file'], require('node:fs').constants.O_RDONLY | require('node:fs').constants.O_NOFOLLOW)
|
|
33
|
+
try {
|
|
34
|
+
const stat = await file.stat()
|
|
35
|
+
if (!stat.isFile() || (stat.mode & 0o077) || (process.getuid && stat.uid !== process.getuid()) || stat.size > 4096) throw Error('Invalid credential file')
|
|
36
|
+
const auth = JSON.parse(await file.readFile('utf8'))
|
|
37
|
+
if (auth.origin !== options.origin || typeof auth.cookie !== 'string' || !/^openrod_token_[0-9]+=[A-Za-z0-9_-]{32,256}$/.test(auth.cookie)) throw Error('Invalid credential')
|
|
38
|
+
cookie = auth.cookie
|
|
39
|
+
} finally { await file.close() }
|
|
40
|
+
} catch { throw Error('Local OpenRod SSH credentials are unavailable. Open the sandbox from OpenRod again.') }
|
|
41
|
+
}
|
|
28
42
|
const response = await fetchRequest(`${options.origin}/api/remote/os/sandboxes/${options.sandbox}/ssh-ticket`, {
|
|
29
|
-
method: 'POST', headers: { 'x-openrod-local-owner': options.owner, Origin: options.origin, 'content-type': 'application/json', 'x-openshell-console': '1' },
|
|
43
|
+
method: 'POST', headers: { ...(cookie ? { Cookie: cookie } : {}), 'x-openrod-local-owner': options.owner, Origin: options.origin, 'content-type': 'application/json', 'x-openshell-console': '1' },
|
|
30
44
|
body: JSON.stringify({ owner: options.owner, ...(options.context ? { context: options.context } : {}) }), redirect: 'error', signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000),
|
|
31
45
|
})
|
|
32
|
-
if (!response.ok)
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
const error = await response.json().catch(() => ({}))
|
|
48
|
+
if (error.code === 'CONSOLE_TOKEN_REQUIRED') throw Error('Local OpenRod console authorization expired. Open the sandbox from OpenRod again.')
|
|
49
|
+
throw Error(`Local OpenRod SSH authorization failed (${response.status}). Reconnect to OpenRod Cloud.`)
|
|
50
|
+
}
|
|
33
51
|
const body = await response.json()
|
|
34
52
|
if (typeof body.ticket !== 'string' || !/^[A-Za-z0-9-]{1,100}$/.test(body.ticket)) throw Error('Invalid SSH ticket from local OpenRod')
|
|
35
53
|
const url = new URL('/api/remote/os/ssh', options.origin); url.protocol = 'ws:'; url.searchParams.set('ticket', body.ticket); url.searchParams.set('owner', options.owner)
|
|
36
54
|
if (options.context) url.searchParams.set('context', options.context)
|
|
37
55
|
return new Promise((resolve, reject) => {
|
|
38
|
-
const ws = new WebSocket(url, { origin: options.origin, handshakeTimeout: 30000, maxPayload: 1024 * 1024 })
|
|
56
|
+
const ws = new WebSocket(url, { origin: options.origin, headers: cookie ? { Cookie: cookie } : {}, handshakeTimeout: 30000, maxPayload: 1024 * 1024 })
|
|
39
57
|
let stream, settled = false
|
|
40
58
|
const abort = () => { ws.terminate(); finish(Error('OpenRod SSH connection canceled')) }
|
|
41
59
|
const finish = error => {
|
package/server/cloud-transfer.js
CHANGED
|
@@ -135,7 +135,7 @@ export async function exportTransfer(input, options = {}) {
|
|
|
135
135
|
export async function localTransfer(input, options = {}) {
|
|
136
136
|
const origin = options.origin ?? cloudOrigin()
|
|
137
137
|
if (!origin) throw cloudUnavailable()
|
|
138
|
-
if (typeof input?.ticket !== 'string' || !/^
|
|
138
|
+
if (typeof input?.ticket !== 'string' || !/^[a-z][a-z0-9-]{0,62}\.[a-f0-9]{64}$/.test(input.ticket)) throw fail('Invalid cloud transfer request.')
|
|
139
139
|
const { bundle, warning } = await exportTransfer(input, options)
|
|
140
140
|
if (input.destinationGroups !== undefined) {
|
|
141
141
|
try { bundle.destinationGroups = groupIds(input.destinationGroups) } catch (error) { throw fail(error.message) }
|
package/server/compute.js
CHANGED
|
@@ -1,21 +1,32 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const
|
|
3
|
+
import { legacyHeaderPrefix, ownerLabel, workerOwnerHash } from './cloud-deployment.js'
|
|
4
|
+
export function workerDeploymentConfig(config, env=process.env) {
|
|
5
|
+
const project=config.firebase.projectId,zone=env.OPENROD_WORKER_ZONE??'us-east1-b',subnet=env.OPENROD_WORKER_SUBNET
|
|
6
|
+
const artifactOrigin=env.OPENROD_WORKER_ARTIFACT_ORIGIN,artifactHash=env.OPENROD_WORKER_ARTIFACT_SHA256
|
|
6
7
|
if(!/^[a-z0-9-]+$/.test(zone)||!subnet?.startsWith(`projects/${project}/regions/`)||!/^http:\/\/10\.80\.0\.\d{1,3}:8080$/.test(artifactOrigin??'')||!/^[a-f0-9]{64}$/.test(artifactHash??''))throw Error('Worker deployment configuration is incomplete')
|
|
8
|
+
const url=new URL(artifactOrigin)
|
|
9
|
+
if(Number(url.hostname.split('.').at(-1))>254||Number(url.hostname.split('.').at(-1))<1)throw Error('Invalid worker artifact address')
|
|
10
|
+
const region=zone.replace(/-[a-z]$/,'')
|
|
11
|
+
if(!new RegExp(`^projects/${project}/regions/${region}/subnetworks/[a-z][a-z0-9-]{0,62}$`).test(subnet))throw Error('Worker subnet must belong to the configured project and zone region')
|
|
12
|
+
const networkTag=env.OPENROD_WORKER_NETWORK_TAG??'openrod-user-worker',label=ownerLabel(env.OPENROD_WORKER_OWNER_LABEL)
|
|
13
|
+
if(!/^[a-z][a-z0-9-]{0,62}$/.test(networkTag))throw Error('Invalid OPENROD_WORKER_NETWORK_TAG')
|
|
14
|
+
return {project,zone,subnet,artifactOrigin,artifactHash,networkTag,ownerLabel:label}
|
|
15
|
+
}
|
|
16
|
+
export async function createCompute(config, credential, {env=process.env, requestFetch=fetch}={}) {
|
|
17
|
+
const {project,zone,subnet,artifactOrigin,artifactHash,networkTag,ownerLabel}=workerDeploymentConfig(config,env)
|
|
7
18
|
const source=fs.readFileSync(path.resolve(import.meta.dirname,'../../deploy/gcp/worker-startup.sh'),'utf8')
|
|
8
19
|
const base=`https://compute.googleapis.com/compute/v1/projects/${project}/zones/${zone}`
|
|
9
20
|
async function request(method,url,body) {
|
|
10
21
|
const token=await credential.getAccessToken()
|
|
11
|
-
const response=await
|
|
12
|
-
if(response.status===404)return null
|
|
22
|
+
const response=await requestFetch(url,{method,headers:{Authorization:`Bearer ${token.access_token}`,'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(30000)})
|
|
23
|
+
if(response.status===404&&method==='GET')return null
|
|
13
24
|
const value=await response.json()
|
|
14
25
|
if(!response.ok || value.error){const e=Error(value.error?.message??value.error?.errors?.[0]?.message??'Compute request failed');e.code=response.status;throw e}
|
|
15
26
|
return value
|
|
16
27
|
}
|
|
17
28
|
return {get:name=>request('GET',`${base}/instances/${name}`),async create(record){
|
|
18
|
-
const settings=Buffer.from(JSON.stringify({key:record.key,uid:record.uid,origin:config.origin,artifactOrigin,artifactHash,name:record.name})).toString('base64')
|
|
29
|
+
const settings=Buffer.from(JSON.stringify({key:record.key,uid:record.uid,origin:config.origin,artifactOrigin,artifactHash,name:record.name,workerProtocol:config.workerProtocol??'current',...(config.workerProtocol==='legacy'?{legacyHeaderPrefix:legacyHeaderPrefix(env.OPENROD_LEGACY_HEADER_PREFIX)}:{})})).toString('base64')
|
|
19
30
|
const script=Buffer.from(source.replace('__OPENROD_WORKER_SETTINGS__',settings)).toString('base64')
|
|
20
31
|
const startup=`#!/usr/bin/env bash
|
|
21
32
|
set -euo pipefail
|
|
@@ -41,7 +52,7 @@ UNIT
|
|
|
41
52
|
systemctl daemon-reload
|
|
42
53
|
systemctl enable --now openrod-worker-bootstrap
|
|
43
54
|
`
|
|
44
|
-
const body={name:record.name,machineType:`zones/${zone}/machineTypes/e2-standard-2`,labels:{application:'openrod',managed_by:'openrod-console',
|
|
55
|
+
const body={name:record.name,machineType:`zones/${zone}/machineTypes/e2-standard-2`,labels:{application:'openrod',managed_by:'openrod-console',[ownerLabel]:workerOwnerHash(record.uid)},tags:{items:[networkTag]},serviceAccounts:[],canIpForward:false,
|
|
45
56
|
disks:[{boot:true,autoDelete:true,initializeParams:{sourceImage:'projects/ubuntu-os-cloud/global/images/family/ubuntu-2404-lts-amd64',diskSizeGb:'30',diskType:`zones/${zone}/diskTypes/pd-balanced`}},{boot:false,autoDelete:false,deviceName:'openrod-state',initializeParams:{diskName:`${record.name}-state`,diskSizeGb:'100',diskType:`zones/${zone}/diskTypes/pd-balanced`}}],
|
|
46
57
|
networkInterfaces:[{subnetwork:subnet,stackType:'IPV4_ONLY'}],metadata:{items:[{key:'enable-oslogin',value:'TRUE'},{key:'block-project-ssh-keys',value:'TRUE'},{key:'disable-legacy-endpoints',value:'TRUE'},{key:'startup-script',value:startup}]},shieldedInstanceConfig:{enableSecureBoot:true,enableVtpm:true,enableIntegrityMonitoring:true}}
|
|
47
58
|
try{return await request('POST',`${base}/instances?requestId=${record.requestId}`,body)}catch(error){if(error.code!==409)throw error;return request('GET',`${base}/instances/${record.name}`)}
|
|
@@ -91,6 +91,23 @@ export async function localEngine({ execute = run } = {}) {
|
|
|
91
91
|
async function deploymentEngine(target) {
|
|
92
92
|
return target.remote ? remoteImageEngine(target, { execute: run }) : localEngine()
|
|
93
93
|
}
|
|
94
|
+
// Cache fast path only when the gateway's actual Docker engine is established.
|
|
95
|
+
// Inspect the pinned image; never run a container or pull an image for this probe.
|
|
96
|
+
export async function preparationImageRuntime(target, reference, { execute = run, guard = gatewayDocker } = {}) {
|
|
97
|
+
if (!target) return null
|
|
98
|
+
let engine
|
|
99
|
+
if (target.remote) engine = await remoteImageEngine(target, { execute })
|
|
100
|
+
else {
|
|
101
|
+
const status = await guard?.status({ fresh: true })
|
|
102
|
+
if (status?.state !== 'ok' || status.gateway !== target.name || !status.driver?.engineId || status.driver.engineId !== status.build?.engineId) return null
|
|
103
|
+
engine = await localEngine({ execute })
|
|
104
|
+
if (engine.engineId !== status.driver.engineId) return null
|
|
105
|
+
}
|
|
106
|
+
const info = await inspect(reference, engine, { execute })
|
|
107
|
+
const node = info.Config?.Env?.find(value => value.startsWith('NODE_VERSION='))?.slice('NODE_VERSION='.length)
|
|
108
|
+
if (!/^22\.\d+\.\d+$/.test(node ?? '') || !['amd64', 'arm64'].includes(info.Architecture)) return null
|
|
109
|
+
return { node, arch: info.Architecture === 'amd64' ? 'x64' : 'arm64', platform: 'linux', libc: 'glibc' }
|
|
110
|
+
}
|
|
94
111
|
async function inspect(reference, engine, { execute = run, job, architecture = engine.architecture } = {}) {
|
|
95
112
|
const image = JSON.parse(await execute(['image', 'inspect', reference], { engine, job }))[0]
|
|
96
113
|
if (image.Os !== 'linux') throw fail('Choose a Linux container image.')
|