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
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
import { contextKey, contextSelection, runWithContext, resolveGateway } from './gateway.js'
|
|
5
|
+
import { scopedStateDirectory } from './paths.js'
|
|
6
|
+
import { listGroups, orgRoute, readOrg } from './org.js'
|
|
7
|
+
import { listPolicies } from './egress.js'
|
|
8
|
+
import { findTemplate, listTemplates, validateTemplate, policyRoute } from './policy.js'
|
|
9
|
+
import { composeTemplate } from '../shared/policy-templates.js'
|
|
10
|
+
import { getSetupStore } from './setups.js'
|
|
11
|
+
import { listImageTemplates, imageTemplateRoute } from './image-templates.js'
|
|
12
|
+
import { IMPORT_TYPES, IMPORT_BODY_LIMIT, fingerprint, importFail, portableResource, validateImportBundle, validImportId } from './resource-import-portable.js'
|
|
13
|
+
|
|
14
|
+
export { IMPORT_BODY_LIMIT } from './resource-import-portable.js'
|
|
15
|
+
export const importCapabilities = ({ activity = false } = {}) => ({
|
|
16
|
+
version: 1,
|
|
17
|
+
types: {
|
|
18
|
+
policyTemplates: { supported: true, internal: true, description: 'Materialized base security policies included as group dependencies.' },
|
|
19
|
+
groups: { supported: true, description: 'Group definitions; sandbox memberships are not copied.' },
|
|
20
|
+
network: { supported: true, description: 'Group/setup rules with remapped targets. Global and sandbox-specific rules need manual review.' },
|
|
21
|
+
setups: { supported: true, description: 'Saved MCP definitions and text skill files. Packages must be prepared and credentials reconnected.' },
|
|
22
|
+
templates: { supported: true, description: 'Image references or recipes rebuilt on the destination. Environment values are excluded.' },
|
|
23
|
+
sandboxes: { supported: false, reason: 'Use Copy workspace on a running sandbox. Configuration import does not move workspace files.' },
|
|
24
|
+
secrets: { supported: false, reason: 'Reconnect or add credentials on the destination. Secret values are never exported in configuration bundles.' },
|
|
25
|
+
activity: activity ? { supported: true, description: 'Normalized historical events, up to 500 per reviewed batch, preserving original timestamps and source identity.' } : { supported: false, reason: 'The activity store is unavailable at this location.' },
|
|
26
|
+
},
|
|
27
|
+
limits: { bytes: IMPORT_BODY_LIMIT, resources: 500, activityEvents: 500, activeJobs: 2 }, conflicts: ['create', 'reuse'],
|
|
28
|
+
})
|
|
29
|
+
const running = globalThis[Symbol.for('openrod.resource-import.running.v1')] ??= new Map()
|
|
30
|
+
const queues = globalThis[Symbol.for('openrod.resource-import.queues.v1')] ??= new Map()
|
|
31
|
+
const phases = { policyTemplates: -1, groups: 0, setups: 1, network: 2, templates: 3, activity: 4 }
|
|
32
|
+
const terminal = new Set(['completed', 'partial', 'failed', 'cancelled', 'interrupted'])
|
|
33
|
+
const JOB_ID = /^[a-f0-9]{32}$/
|
|
34
|
+
const now = () => new Date().toISOString()
|
|
35
|
+
|
|
36
|
+
function queue(key, task) {
|
|
37
|
+
const result = (queues.get(key) || Promise.resolve()).then(task, task)
|
|
38
|
+
const settled = result.catch(() => {})
|
|
39
|
+
queues.set(key, settled)
|
|
40
|
+
settled.finally(() => { if (queues.get(key) === settled) queues.delete(key) })
|
|
41
|
+
return result
|
|
42
|
+
}
|
|
43
|
+
async function atomic(file, value) {
|
|
44
|
+
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
45
|
+
const temporary = `${file}.${randomUUID()}.tmp`
|
|
46
|
+
try { await fs.writeFile(temporary, JSON.stringify(value), { flag: 'wx', mode: 0o600 }); await fs.rename(temporary, file) }
|
|
47
|
+
finally { await fs.rm(temporary, { force: true }) }
|
|
48
|
+
}
|
|
49
|
+
function jobView(job) {
|
|
50
|
+
return {
|
|
51
|
+
id: job.id, status: job.status, source: job.source, destination: job.destination, createdAt: job.createdAt, updatedAt: job.updatedAt,
|
|
52
|
+
warnings: job.warnings, cancelRequested: Boolean(job.cancelRequested),
|
|
53
|
+
items: job.items.map(({ data, reuseRevision, attempted, ...item }) => item),
|
|
54
|
+
counts: { total: job.items.length, completed: job.items.filter(item => ['created', 'reused'].includes(item.status)).length, failed: job.items.filter(item => ['failed', 'blocked'].includes(item.status)).length, pending: job.items.filter(item => ['pending', 'running'].includes(item.status)).length },
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function translated(resource, mapping, targetId) {
|
|
58
|
+
const data = structuredClone(resource.data)
|
|
59
|
+
if (resource.type === 'templates') { data.name = targetId; data.recipe.name = targetId; data.recipe.setups = data.recipe.setups.map(id => mapping[`setups:${id}`]); data.recipe.setupRevisions = {} }
|
|
60
|
+
else {
|
|
61
|
+
data.id = targetId
|
|
62
|
+
if (resource.type === 'groups' && data.template) data.template = mapping[`policyTemplates:${data.template}`]
|
|
63
|
+
if (resource.type === 'network') {
|
|
64
|
+
data.appliesTo.groups = data.appliesTo.groups.map(id => mapping[`groups:${id}`])
|
|
65
|
+
data.appliesTo.setups = data.appliesTo.setups.map(id => mapping[`setups:${id}`])
|
|
66
|
+
if (data.setup) data.setup.id = mapping[`setups:${data.setup.id}`]
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return data
|
|
70
|
+
}
|
|
71
|
+
function comparable(type, value) {
|
|
72
|
+
const { data } = portableResource(type, value)
|
|
73
|
+
// Destination preparation is independent from identity mapping; source ids and
|
|
74
|
+
// template revision pins are not content differences.
|
|
75
|
+
if (type === 'templates') data.recipe.setupRevisions = {}
|
|
76
|
+
return fingerprint(data)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function defaultAdapter({ activityStore } = {}) {
|
|
80
|
+
return {
|
|
81
|
+
async list(type) {
|
|
82
|
+
if (type === 'policyTemplates') return listTemplates()
|
|
83
|
+
if (type === 'activity') return activityStore ? activityStore.coverage().sources.filter(source => source.importBatch).map(source => ({ ...source, importReceipt: true })) : []
|
|
84
|
+
if (type === 'groups') return listGroups()
|
|
85
|
+
if (type === 'network') return listPolicies()
|
|
86
|
+
if (type === 'templates') return listImageTemplates()
|
|
87
|
+
const store = getSetupStore()
|
|
88
|
+
return Promise.all((await store.list()).map(setup => store.get(setup.id)))
|
|
89
|
+
},
|
|
90
|
+
async organizationRules(context) {
|
|
91
|
+
const [organization, groups] = await Promise.all([readOrg(), listGroups()])
|
|
92
|
+
const rules = []
|
|
93
|
+
for (const group of groups) for (let offset = 0; offset < organization.blocked.length; offset += 200) rules.push({
|
|
94
|
+
id: 'org-block-' + fingerprint([context, group.id, offset]).slice(0, 28), name: `Source restrictions: ${group.name}`.slice(0, 80), action: 'block',
|
|
95
|
+
destinations: organization.blocked.slice(offset, offset + 200), appliesTo: { everyone: false, groups: [group.id], sandboxes: [], setups: [] }, sourceOrganizationBlock: true,
|
|
96
|
+
})
|
|
97
|
+
return rules
|
|
98
|
+
},
|
|
99
|
+
async export(type, input, context) {
|
|
100
|
+
if (type === 'policyTemplates') {
|
|
101
|
+
const catalog = await listTemplates()
|
|
102
|
+
for (const group of await listGroups()) if (group.template && !catalog.some(policy => policy.id === group.template)) {
|
|
103
|
+
const policy = await findTemplate(group.template)
|
|
104
|
+
if (policy) catalog.push(policy)
|
|
105
|
+
}
|
|
106
|
+
return catalog.map(policy => validateTemplate({ ...composeTemplate(policy, [], catalog), accessTemplates: [] }))
|
|
107
|
+
}
|
|
108
|
+
if (type !== 'activity') return this.list(type)
|
|
109
|
+
if (!activityStore) throw importFail('The activity store is unavailable.', 503)
|
|
110
|
+
const query = input.activity || {}
|
|
111
|
+
const page = activityStore.query({ range: query.from || query.to ? 'custom' : 'all', ...(query.from ? { from: query.from } : {}), ...(query.to ? { to: query.to } : {}), ...(query.snapshot != null ? { snapshot: query.snapshot } : {}), offset: query.offset || 0, limit: 500 })
|
|
112
|
+
if (!page.events.length) return []
|
|
113
|
+
const id = 'history-' + fingerprint([context, page.snapshot, query.offset || 0, query.from, query.to]).slice(0, 24)
|
|
114
|
+
return [{ id, name: `Activity (${page.events.length} events)`, origin: context, events: page.events, exportPage: { snapshot: page.snapshot, offset: query.offset || 0, total: page.total, exported: page.events.length, nextOffset: page.nextOffset } }]
|
|
115
|
+
},
|
|
116
|
+
async prepare(type, data, job, item) {
|
|
117
|
+
if (type !== 'templates' || !data.recipe.setups.length) return data
|
|
118
|
+
const { prepareLaunch, preparationStatus, cancelPreparation } = await import('./setup-preparation.js')
|
|
119
|
+
const store = getSetupStore(), setups = []
|
|
120
|
+
for (const id of data.recipe.setups) {
|
|
121
|
+
const source = await store.get(id)
|
|
122
|
+
let result = await prepareLaunch(store, id, { revision: source.revision })
|
|
123
|
+
const preparationId = result.id, deadline = Date.now() + 25 * 60_000
|
|
124
|
+
while (result.status === 'running' && Date.now() < deadline) {
|
|
125
|
+
if (job.cancelRequested) { await cancelPreparation(preparationId); throw importFail('Setup preparation cancelled.', 409) }
|
|
126
|
+
await new Promise(resolve => setTimeout(resolve, 1500))
|
|
127
|
+
result = await preparationStatus(preparationId)
|
|
128
|
+
}
|
|
129
|
+
if (result.status !== 'complete' || !result.setup) throw importFail('A required MCP package could not be prepared. Open the imported setup and retry preparation.', 409)
|
|
130
|
+
const ready = await store.get(result.setup.id)
|
|
131
|
+
if (ready.items.some(item => !item.disabled && item.issues.length)) throw importFail('A required setup needs credentials or configuration. Resolve its warnings on the destination before importing this template.', 409)
|
|
132
|
+
setups.push(ready.id)
|
|
133
|
+
if (ready.id !== id) item.warnings = [...new Set([...item.warnings, `Prepared setup snapshot ${ready.id} is used by this template; source setup ${id} remains available.`])]
|
|
134
|
+
}
|
|
135
|
+
return { ...data, recipe: { ...data.recipe, setups, setupRevisions: {} } }
|
|
136
|
+
},
|
|
137
|
+
async create(type, data, job, item) {
|
|
138
|
+
if (type === 'policyTemplates') return policyRoute('POST', ['templates'], { ...data, isNew: true })
|
|
139
|
+
if (type === 'activity') {
|
|
140
|
+
if (!activityStore) throw importFail('The activity store is unavailable.', 503)
|
|
141
|
+
const scope = `import:${fingerprint(data.origin)}`
|
|
142
|
+
for (const event of data.events) {
|
|
143
|
+
if (job.cancelRequested) throw importFail('Activity import cancelled. Already imported events are retained and retry will deduplicate them.', 409)
|
|
144
|
+
activityStore.ingest({ ...event, importedFrom: { ...data.origin, eventId: event.id, receivedAt: event.receivedAt || null }, importedAt: now(), importJob: job.id, location: { label: `${data.origin.gateway} / ${data.origin.workspace} (imported)` } }, scope)
|
|
145
|
+
}
|
|
146
|
+
activityStore.source(data.id, { importBatch: true, name: data.name, revision: fingerprint(data), eventCount: data.events.length, origin: data.origin, completedAt: now() })
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
if (type === 'groups') return orgRoute('POST', ['org', 'groups'], { ...data, isNew: true })
|
|
150
|
+
if (type === 'network') return orgRoute('POST', ['egress', 'policies'], { ...data, isNew: true })
|
|
151
|
+
if (type === 'templates') {
|
|
152
|
+
await imageTemplateRoute('POST', ['image-templates'], { recipe: data.recipe })
|
|
153
|
+
const deadline = Date.now() + 25 * 60_000
|
|
154
|
+
while (Date.now() < deadline) {
|
|
155
|
+
const template = (await listImageTemplates()).find(template => template.name === data.name)
|
|
156
|
+
if (template?.status === 'ready') return template
|
|
157
|
+
if (template?.status === 'failed') throw importFail('Template build failed. Open the destination template to inspect its build log.', 502)
|
|
158
|
+
if (job.cancelRequested) {
|
|
159
|
+
await imageTemplateRoute('POST', ['image-templates', data.name, 'cancel'], {})
|
|
160
|
+
throw importFail('Template build cancelled.', 409)
|
|
161
|
+
}
|
|
162
|
+
await new Promise(resolve => setTimeout(resolve, 1500))
|
|
163
|
+
}
|
|
164
|
+
throw importFail('Template build timed out. Inspect the destination template before retrying.', 504)
|
|
165
|
+
}
|
|
166
|
+
const items = data.items
|
|
167
|
+
const setup = { ...data, revision: fingerprintItems(items), createdAt: now(), owner: 'local operator', importSource: { job: job.id, key: item.key }, items }
|
|
168
|
+
const dir = path.join(scopedStateDirectory(), 'setups')
|
|
169
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 })
|
|
170
|
+
const final = path.join(dir, `${data.id}.json`), temporary = `${final}.${randomUUID()}.tmp`
|
|
171
|
+
try {
|
|
172
|
+
await fs.writeFile(temporary, JSON.stringify(setup), { flag: 'wx', mode: 0o600 })
|
|
173
|
+
await fs.link(temporary, final) // Atomic, exclusive publication; a crash cannot leave a partial Setup.
|
|
174
|
+
} finally { await fs.rm(temporary, { force: true }) }
|
|
175
|
+
return setup
|
|
176
|
+
},
|
|
177
|
+
async reset(type, id) {
|
|
178
|
+
if (type !== 'templates') return
|
|
179
|
+
const template = (await listImageTemplates()).find(template => template.name === id)
|
|
180
|
+
if (template?.status === 'failed' && !template.exists) await imageTemplateRoute('POST', ['image-templates', id, 'dismiss'], {})
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Setup storage hashes its exact JSON array rather than a sorted-key canonical form.
|
|
185
|
+
import { hash as fingerprintItemsRaw } from './setup-discovery.js'
|
|
186
|
+
const fingerprintItems = items => fingerprintItemsRaw(JSON.stringify(items))
|
|
187
|
+
|
|
188
|
+
export function createResourceImports({ directory = path.join(scopedStateDirectory(), 'resource-imports'), context = contextSelection(), adapter, activityStore, sourceKind, onEvent = async () => {} } = {}) {
|
|
189
|
+
adapter ??= defaultAdapter({ activityStore })
|
|
190
|
+
const destination = { gateway: context.gateway || '', workspace: context.workspace || '' }
|
|
191
|
+
const file = id => { if (!JOB_ID.test(id || '')) throw importFail('Import not found.', 404); return path.join(directory, `${id}.json`) }
|
|
192
|
+
const key = id => file(id)
|
|
193
|
+
const save = async job => { job.updatedAt = now(); await atomic(file(job.id), job) }
|
|
194
|
+
const read = async id => {
|
|
195
|
+
// A job saves its final state before it stops running, so only a job that
|
|
196
|
+
// was already stopped when the read began can be stranded in 'running'.
|
|
197
|
+
const active = running.has(key(id))
|
|
198
|
+
let job
|
|
199
|
+
try { job = JSON.parse(await fs.readFile(file(id), 'utf8')) } catch (error) { if (error.code === 'ENOENT') throw importFail('Import not found.', 404); throw error }
|
|
200
|
+
if (contextKey(job.destination) !== contextKey(destination)) throw importFail('Import belongs to another destination.', 403)
|
|
201
|
+
if (job.status === 'running' && !active && !running.has(key(id))) {
|
|
202
|
+
job.status = 'interrupted'
|
|
203
|
+
for (const item of job.items) if (item.status === 'running') { item.status = 'failed'; item.error = 'The process stopped during this item. Retry to check the destination and resume.' }
|
|
204
|
+
}
|
|
205
|
+
return job
|
|
206
|
+
}
|
|
207
|
+
const inventory = async type => adapter.list(type)
|
|
208
|
+
const find = async (type, id) => (await inventory(type)).find(value => (value.id ?? value.name) === id)
|
|
209
|
+
const equal = (type, existing, data) => { if (type === 'activity' && existing.importReceipt) return existing.revision === fingerprint(data); try { return comparable(type, existing) === comparable(type, data) } catch { return false } }
|
|
210
|
+
const emit = async (job, action) => { try { await onEvent({ action, job: jobView(job) }) } catch { /* Activity delivery must not undo an import. */ } }
|
|
211
|
+
|
|
212
|
+
async function run(job) {
|
|
213
|
+
try {
|
|
214
|
+
for (const item of job.items) {
|
|
215
|
+
if (['created', 'reused'].includes(item.status)) continue
|
|
216
|
+
if (job.cancelRequested) break
|
|
217
|
+
const failedDependency = item.dependencies.find(dependency => !job.items.some(other => other.key === dependency && ['created', 'reused'].includes(other.status)))
|
|
218
|
+
if (failedDependency) { item.status = 'blocked'; item.error = 'A dependency did not complete. Retry after resolving the failed item.'; await save(job); continue }
|
|
219
|
+
item.status = 'running'; item.error = null; await save(job)
|
|
220
|
+
try {
|
|
221
|
+
let existing = await find(item.type, item.targetId)
|
|
222
|
+
if (item.action === 'reuse') {
|
|
223
|
+
if (!existing || (item.type === 'activity' && existing.importReceipt ? existing.revision : comparable(item.type, existing)) !== item.reuseRevision) throw importFail('The destination resource changed after review. Create a new plan.', 409)
|
|
224
|
+
item.status = 'reused'
|
|
225
|
+
} else if (existing && item.attempted && equal(item.type, existing, item.data) && (!existing.status || existing.status === 'ready')) {
|
|
226
|
+
item.status = 'created'
|
|
227
|
+
item.warnings = [...new Set([...item.warnings, 'Recovered an already-created resource from the interrupted attempt.'])]
|
|
228
|
+
} else {
|
|
229
|
+
if (existing && item.attempted && existing.status === 'failed' && !existing.exists) { await adapter.reset?.(item.type, item.targetId); existing = await find(item.type, item.targetId) }
|
|
230
|
+
if (existing) throw importFail('The destination identity is already in use or a build is still running. Review it before retrying or create a new plan.', 409)
|
|
231
|
+
await adapter.validate?.(item.type, item.data)
|
|
232
|
+
if (adapter.prepare) item.data = await adapter.prepare(item.type, item.data, job, item)
|
|
233
|
+
if (job.cancelRequested) throw importFail('Import cancelled before creating this resource.', 409)
|
|
234
|
+
item.attempted = true; await save(job)
|
|
235
|
+
await adapter.create(item.type, item.data, job, item)
|
|
236
|
+
const created = await find(item.type, item.targetId)
|
|
237
|
+
if (!created || !equal(item.type, created, item.data) || (created.status && created.status !== 'ready')) throw importFail('Destination verification did not complete. Inspect the resource before retrying.', 502)
|
|
238
|
+
item.status = 'created'
|
|
239
|
+
}
|
|
240
|
+
} catch (error) {
|
|
241
|
+
item.status = job.cancelRequested ? 'cancelled' : 'failed'; item.error = error.status ? error.message : 'Import failed. Check destination connectivity and retry.'
|
|
242
|
+
}
|
|
243
|
+
await save(job)
|
|
244
|
+
}
|
|
245
|
+
const completed = job.items.filter(item => ['created', 'reused'].includes(item.status)).length
|
|
246
|
+
job.status = job.cancelRequested ? 'cancelled' : completed === job.items.length ? 'completed' : completed ? 'partial' : 'failed'
|
|
247
|
+
if (job.cancelRequested) for (const item of job.items) if (item.status === 'pending') item.status = 'cancelled'
|
|
248
|
+
await save(job); await emit(job, 'import.' + job.status)
|
|
249
|
+
} catch {
|
|
250
|
+
job.status = 'failed'
|
|
251
|
+
job.warnings = [...new Set([...job.warnings, 'Import stopped unexpectedly. Check destination storage and retry.'])]
|
|
252
|
+
await save(job).catch(() => {})
|
|
253
|
+
} finally { running.delete(key(job.id)) }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const service = {
|
|
257
|
+
capabilities: () => importCapabilities({ activity: Boolean(activityStore) }),
|
|
258
|
+
async export(input = {}) {
|
|
259
|
+
const types = input.types || IMPORT_TYPES.filter(type => !['activity', 'policyTemplates'].includes(type))
|
|
260
|
+
if (!Array.isArray(types) || types.some(type => !IMPORT_TYPES.includes(type))) throw importFail('Choose supported resource types.')
|
|
261
|
+
const resources = [], excluded = []
|
|
262
|
+
const needed = new Set(types)
|
|
263
|
+
if (needed.has('templates')) needed.add('setups')
|
|
264
|
+
if (needed.has('network')) { needed.add('groups'); needed.add('setups') }
|
|
265
|
+
if (needed.has('groups')) needed.add('policyTemplates')
|
|
266
|
+
const pages = {}
|
|
267
|
+
for (const type of IMPORT_TYPES) {
|
|
268
|
+
if (!needed.has(type)) continue
|
|
269
|
+
try {
|
|
270
|
+
for (const raw of await (adapter.export ? adapter.export(type, input, destination) : inventory(type))) {
|
|
271
|
+
try {
|
|
272
|
+
const resource = portableResource(type, raw)
|
|
273
|
+
if (raw.exportPage) { pages[type] = raw.exportPage; if (raw.exportPage.nextOffset != null) resource.warnings.push(`This batch includes ${raw.exportPage.exported} of ${raw.exportPage.total} matching events. Export the next page to copy older history.`) }
|
|
274
|
+
resources.push(resource)
|
|
275
|
+
}
|
|
276
|
+
catch (error) { excluded.push({ type, id: raw.id ?? raw.name, name: raw.name, reason: error.status ? error.message : 'This resource could not be exported.' }) }
|
|
277
|
+
}
|
|
278
|
+
} catch { excluded.push({ type, reason: 'Could not read this resource type. Check source connectivity.' }) }
|
|
279
|
+
}
|
|
280
|
+
if (needed.has('groups') && adapter.organizationRules) {
|
|
281
|
+
for (const rule of await adapter.organizationRules(destination)) {
|
|
282
|
+
const resource = portableResource('network', rule)
|
|
283
|
+
if (!resources.some(other => other.key === resource.key)) {
|
|
284
|
+
resource.warnings.push('Source organization blocks are preserved for this imported group. Destination organization settings stay unchanged.')
|
|
285
|
+
resources.push(resource)
|
|
286
|
+
} else throw importFail('A source rule conflicts with a generated organization restriction. Rename the source rule and export again.', 409)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const selected = new Set(input.selection || resources.filter(resource => types.includes(resource.type)).map(resource => resource.key))
|
|
290
|
+
for (let previous = -1; previous !== selected.size;) {
|
|
291
|
+
previous = selected.size
|
|
292
|
+
for (const resource of resources) {
|
|
293
|
+
if (resource.requiredForGroups?.some(id => selected.has(`groups:${id}`))) selected.add(resource.key)
|
|
294
|
+
if (selected.has(resource.key)) for (const dependency of resource.dependencies) selected.add(dependency)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
let remote = false
|
|
298
|
+
try { remote = resolveGateway(context.gateway).remote } catch { /* Tests can supply an isolated context. */ }
|
|
299
|
+
const bundle = { version: 1, source: { kind: sourceKind || (remote ? 'host' : 'local'), label: `${destination.gateway || 'Local'} / ${destination.workspace || 'default'}`, context: destination }, resources: resources.filter(resource => selected.has(resource.key)), excluded, ...(Object.keys(pages).length ? { pages } : {}) }
|
|
300
|
+
if (Buffer.byteLength(JSON.stringify(bundle)) > IMPORT_BODY_LIMIT) throw importFail('Configuration export exceeds 8 MiB. Select fewer resources.', 413)
|
|
301
|
+
return bundle
|
|
302
|
+
},
|
|
303
|
+
async plan(input) {
|
|
304
|
+
const bundle = validateImportBundle(input?.bundle)
|
|
305
|
+
const selected = new Set(input.selection || bundle.resources.map(resource => resource.key))
|
|
306
|
+
if (!selected.size || selected.size > 500 || [...selected].some(key => !bundle.resources.some(resource => resource.key === key))) throw importFail('Choose available resources to import.')
|
|
307
|
+
for (let previous = -1; previous !== selected.size;) {
|
|
308
|
+
previous = selected.size
|
|
309
|
+
for (const resource of bundle.resources) {
|
|
310
|
+
if (resource.requiredForGroups?.some(id => selected.has(`groups:${id}`))) selected.add(resource.key)
|
|
311
|
+
if (!selected.has(resource.key)) continue
|
|
312
|
+
for (const dependency of resource.dependencies) {
|
|
313
|
+
if (!bundle.resources.some(resource => resource.key === dependency)) throw importFail(`Missing dependency ${dependency}. Export it with the selected resources.`, 409)
|
|
314
|
+
selected.add(dependency)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// Organization blocks come before other rules, so a reused group never
|
|
319
|
+
// runs with an imported allow rule but without its source's blocks.
|
|
320
|
+
const blocks = resource => resource.requiredForGroups?.length ? 0 : 1
|
|
321
|
+
const resources = bundle.resources.filter(resource => selected.has(resource.key)).sort((a, b) => phases[a.type] - phases[b.type] || blocks(a) - blocks(b) || a.key.localeCompare(b.key))
|
|
322
|
+
const id = randomUUID().replaceAll('-', ''), mapping = {}, items = [], inventories = {}
|
|
323
|
+
for (const type of new Set(resources.map(resource => resource.type))) inventories[type] = await inventory(type)
|
|
324
|
+
for (const resource of resources) {
|
|
325
|
+
const choice = input.conflicts?.[resource.key]
|
|
326
|
+
if (choice && !['create', 'reuse'].includes(choice.action)) throw importFail('Unsupported conflict resolution.')
|
|
327
|
+
const reuse = resource.type === 'activity' && inventories.activity.some(value => (value.id ?? value.name) === resource.id) || choice?.action === 'reuse'
|
|
328
|
+
const existingIds = new Set(inventories[resource.type].map(value => value.id ?? value.name))
|
|
329
|
+
let targetId = resource.id
|
|
330
|
+
if (reuse) targetId = choice?.targetId || resource.id
|
|
331
|
+
else if (resource.type === 'activity') targetId = resource.id
|
|
332
|
+
else if (resource.type === 'setups') targetId = fingerprint([id, resource.key]).slice(0, 24)
|
|
333
|
+
else if (resource.type === 'policyTemplates') targetId = 'import-' + fingerprint([id, resource.key]).slice(0, 32)
|
|
334
|
+
else if (existingIds.has(targetId) || items.some(item => item.type === resource.type && item.targetId === targetId)) {
|
|
335
|
+
const max = { groups: 48, network: 40, templates: 19 }[resource.type]
|
|
336
|
+
targetId = resource.id.slice(0, max - 9).replace(/-+$/, '') + '-' + fingerprint([id, resource.key]).slice(0, 8)
|
|
337
|
+
}
|
|
338
|
+
if (!validImportId(resource.type, targetId)) throw importFail('Invalid destination resource identity.')
|
|
339
|
+
mapping[resource.key] = targetId
|
|
340
|
+
const data = translated(resource, mapping, targetId)
|
|
341
|
+
await adapter.validate?.(resource.type, data)
|
|
342
|
+
const warnings = [...resource.warnings]
|
|
343
|
+
if (targetId !== resource.id && resource.type !== 'setups') warnings.push(`Destination identity: ${targetId}. The existing resource will stay unchanged.`)
|
|
344
|
+
let reuseRevision
|
|
345
|
+
if (reuse) {
|
|
346
|
+
const existing = inventories[resource.type].find(value => (value.id ?? value.name) === targetId)
|
|
347
|
+
if (!existing || !equal(resource.type, existing, data)) throw importFail(`The destination ${resource.name} is not identical to the selected configuration. Create a copy instead.`, 409)
|
|
348
|
+
reuseRevision = resource.type === 'activity' && existing.importReceipt ? existing.revision : comparable(resource.type, existing)
|
|
349
|
+
}
|
|
350
|
+
items.push({ key: resource.key, type: resource.type, name: resource.name, sourceId: resource.id, targetId, action: reuse ? 'reuse' : 'create', status: 'pending', dependencies: resource.dependencies, warnings, error: null, data, ...(reuseRevision ? { reuseRevision } : {}) })
|
|
351
|
+
}
|
|
352
|
+
const job = { id, status: 'planned', source: bundle.source, destination, createdAt: now(), updatedAt: now(), items, warnings: ['Copies preserve the source. Credentials and sandbox memberships are not copied. Source organization blocks become rules scoped to imported groups; destination organization settings stay unchanged. Historical activity is included only when selected.'], cancelRequested: false }
|
|
353
|
+
await save(job)
|
|
354
|
+
return jobView(job)
|
|
355
|
+
},
|
|
356
|
+
async list() {
|
|
357
|
+
const names = await fs.readdir(directory).catch(error => { if (error.code === 'ENOENT') return []; throw error })
|
|
358
|
+
const jobs = await Promise.all(names.filter(name => /^[a-f0-9]{32}\.json$/.test(name)).map(async name => jobView(await read(name.slice(0, -5)))))
|
|
359
|
+
return { jobs: jobs.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 100) }
|
|
360
|
+
},
|
|
361
|
+
async get(id) { return jobView(await read(id)) },
|
|
362
|
+
async execute(id, input, retry = false) {
|
|
363
|
+
return queue(directory, async () => {
|
|
364
|
+
if (input?.acknowledged !== true) throw importFail('Review the import plan and acknowledge the changes before starting.')
|
|
365
|
+
if (running.has(key(id))) return jobView(running.get(key(id)))
|
|
366
|
+
if ([...running.keys()].filter(file => path.dirname(file) === directory).length >= 2) throw importFail('Two imports are already running at this destination. Wait or cancel one before starting another.', 429)
|
|
367
|
+
const job = await read(id)
|
|
368
|
+
if (job.status === 'completed') return jobView(job)
|
|
369
|
+
if (retry ? !terminal.has(job.status) : job.status !== 'planned') throw importFail('Use retry to resume this import.', 409)
|
|
370
|
+
if (retry) for (const item of job.items) if (!['created', 'reused'].includes(item.status)) { item.status = 'pending'; item.error = null }
|
|
371
|
+
job.status = 'running'; job.cancelRequested = false
|
|
372
|
+
running.set(key(id), job)
|
|
373
|
+
try { await save(job) } catch (error) { running.delete(key(id)); throw error }
|
|
374
|
+
const response = jobView(job)
|
|
375
|
+
void runWithContext(context, () => run(job))
|
|
376
|
+
await emit(job, retry ? 'import.retry' : 'import.started')
|
|
377
|
+
return response
|
|
378
|
+
})
|
|
379
|
+
},
|
|
380
|
+
async cancel(id) {
|
|
381
|
+
return queue(directory, async () => {
|
|
382
|
+
const active = running.get(key(id)), job = active || await read(id)
|
|
383
|
+
if (terminal.has(job.status)) return jobView(job)
|
|
384
|
+
job.cancelRequested = true
|
|
385
|
+
if (!active) { job.status = 'cancelled'; for (const item of job.items) if (item.status === 'pending') item.status = 'cancelled' }
|
|
386
|
+
await save(job)
|
|
387
|
+
return jobView(job)
|
|
388
|
+
})
|
|
389
|
+
},
|
|
390
|
+
}
|
|
391
|
+
// A retained service is destination-bound even after the console changes its
|
|
392
|
+
// selected gateway; async storage and background jobs use the captured scope.
|
|
393
|
+
for (const method of ['export', 'plan', 'list', 'get', 'execute', 'cancel']) {
|
|
394
|
+
const operation = service[method]
|
|
395
|
+
service[method] = (...args) => runWithContext(context, () => operation(...args))
|
|
396
|
+
}
|
|
397
|
+
return service
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export async function resourceImportRoute(method, parts, input, options) {
|
|
401
|
+
if (parts[0] !== 'resource-imports') return undefined
|
|
402
|
+
const service = createResourceImports(options)
|
|
403
|
+
if (method === 'GET' && parts.length === 1) return service.list()
|
|
404
|
+
if (method === 'GET' && parts[1] === 'capabilities' && parts.length === 2) return service.capabilities()
|
|
405
|
+
if (method === 'GET' && parts.length === 2) return service.get(parts[1])
|
|
406
|
+
if (method === 'POST' && parts.length === 2 && parts[1] === 'export') return service.export(input)
|
|
407
|
+
if (method === 'POST' && parts.length === 2 && parts[1] === 'plan') return service.plan(input)
|
|
408
|
+
if (method === 'POST' && parts.length === 3 && parts[2] === 'execute') return service.execute(parts[1], input)
|
|
409
|
+
if (method === 'POST' && parts.length === 3 && parts[2] === 'retry') return service.execute(parts[1], input, true)
|
|
410
|
+
if (method === 'POST' && parts.length === 3 && parts[2] === 'cancel') return service.cancel(parts[1])
|
|
411
|
+
throw importFail('Unknown import operation.', 404)
|
|
412
|
+
}
|
|
@@ -5,7 +5,7 @@ import { identityContext } from './security.js'
|
|
|
5
5
|
// configured operator, or the OS account running the server, never request data.
|
|
6
6
|
export function sandboxIdentityLabels() {
|
|
7
7
|
const identity = identityContext.getStore()
|
|
8
|
-
if (identity) return { 'openshell.console/created-by': identity.uid, 'openshell.console/owner': identity.uid, 'openrod/org': identity.org }
|
|
8
|
+
if (identity) return { 'openshell.console/created-by': identity.uid, 'openshell.console/owner': identity.uid, ...(identity.org ? { 'openrod/org': identity.org } : {}) }
|
|
9
9
|
const operator = process.env.OPENSHELL_CONSOLE_OPERATOR?.trim() || userInfo().username
|
|
10
10
|
return {
|
|
11
11
|
'openshell.console/created-by': operator,
|
package/server/security.js
CHANGED
|
@@ -2,6 +2,7 @@ import { verifyWorkerRequest } from './worker-auth.js'
|
|
|
2
2
|
import { isIP } from 'node:net'
|
|
3
3
|
import {createHash} from 'node:crypto'
|
|
4
4
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
5
|
+
import { workerProtocol, workerAuthHeader } from './cloud-deployment.js'
|
|
5
6
|
export function requestPath(req) {
|
|
6
7
|
if (typeof req.url !== 'string' || !req.url.startsWith('/') || req.url.startsWith('//') || req.url.includes('\\')) throw fail('Invalid request target', 400)
|
|
7
8
|
try { return new URL(req.url, 'http://local').pathname } catch { throw fail('Invalid request target', 400) }
|
|
@@ -19,30 +20,42 @@ export function cloudConfig(env = process.env) {
|
|
|
19
20
|
const mode = env.OPENROD_MODE ?? 'local'
|
|
20
21
|
if (!['local', 'cloud', 'worker'].includes(mode)) throw Error('OPENROD_MODE must be local, cloud or worker')
|
|
21
22
|
if (mode === 'local') return { mode }
|
|
23
|
+
const protocol = workerProtocol(env.OPENROD_WORKER_PROTOCOL, env)
|
|
22
24
|
if (mode === 'worker') {
|
|
23
25
|
if (!/^[a-f0-9]{64}$/.test(env.OPENROD_WORKER_KEY ?? '') || !env.OPENROD_WORKER_UID || !env.OPENROD_PUBLIC_ORIGIN) throw Error('Worker mode requires its owner, key and public origin')
|
|
24
26
|
const origin = new URL(env.OPENROD_PUBLIC_ORIGIN)
|
|
25
27
|
if (origin.protocol !== 'https:' || origin.origin !== env.OPENROD_PUBLIC_ORIGIN) throw Error('Invalid worker public origin')
|
|
26
|
-
return {mode, key: env.OPENROD_WORKER_KEY, owner: env.OPENROD_WORKER_UID, origin: origin.origin, host: origin.host}
|
|
28
|
+
return {mode, key: env.OPENROD_WORKER_KEY, owner: env.OPENROD_WORKER_UID, origin: origin.origin, host: origin.host, workerProtocol: protocol, authHeader: workerAuthHeader(protocol, env)}
|
|
27
29
|
}
|
|
28
30
|
for (const key of ['OPENROD_ORG_ID', 'OPENROD_PUBLIC_ORIGIN', 'GOOGLE_CLOUD_PROJECT', 'OPENROD_FIREBASE_API_KEY', 'OPENROD_FIREBASE_AUTH_DOMAIN']) if (!env[key]?.trim()) throw Error(`Cloud mode requires ${key}`)
|
|
29
31
|
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(env.OPENROD_ORG_ID)) throw Error('Invalid OPENROD_ORG_ID')
|
|
30
32
|
if (!/^[a-z0-9.-]+$/i.test(env.OPENROD_FIREBASE_AUTH_DOMAIN) || !/^[a-z][a-z0-9-]{4,62}$/.test(env.GOOGLE_CLOUD_PROJECT)) throw Error('Invalid Identity Platform project or auth domain')
|
|
31
33
|
const origin = new URL(env.OPENROD_PUBLIC_ORIGIN)
|
|
32
34
|
if (origin.protocol !== 'https:' || origin.origin !== env.OPENROD_PUBLIC_ORIGIN || origin.username || origin.password) throw Error('OPENROD_PUBLIC_ORIGIN must be an HTTPS origin without a path')
|
|
33
|
-
if (env.FIREBASE_AUTH_EMULATOR_HOST) throw Error('Cloud mode cannot use
|
|
34
|
-
|
|
35
|
+
if (env.FIREBASE_AUTH_EMULATOR_HOST || env.FIRESTORE_EMULATOR_HOST) throw Error('Cloud mode cannot use Firebase emulators')
|
|
36
|
+
const sessionCookie = env.OPENROD_SESSION_COOKIE ?? '__Host-openrod_session'
|
|
37
|
+
if (!/^__Host-[A-Za-z0-9_-]{1,64}$/.test(sessionCookie)) throw Error('OPENROD_SESSION_COOKIE must be a __Host- cookie name')
|
|
38
|
+
const proxyClientIpHeader = env.OPENROD_PROXY_CLIENT_IP_HEADER ?? 'x-openrod-client-ip'
|
|
39
|
+
// Only a dedicated header the loopback proxy overwrites, never X-Forwarded-For.
|
|
40
|
+
if (!/^x-[a-z0-9]+(?:-[a-z0-9]+)*-client-ip$/.test(proxyClientIpHeader)) throw Error('Invalid OPENROD_PROXY_CLIENT_IP_HEADER')
|
|
41
|
+
return { mode, org: env.OPENROD_ORG_ID, origin: origin.origin, host: origin.host, workerProtocol: protocol, sessionCookie, proxyClientIpHeader, firebase: { apiKey: env.OPENROD_FIREBASE_API_KEY, authDomain: env.OPENROD_FIREBASE_AUTH_DOMAIN, projectId: env.GOOGLE_CLOUD_PROJECT } }
|
|
35
42
|
}
|
|
36
|
-
|
|
37
|
-
//
|
|
43
|
+
// Selecting cloud/worker is an explicit deployment choice. Their configuration
|
|
44
|
+
// and runtime dependencies are validated before opening a listening socket.
|
|
38
45
|
export function releaseConfig(env = process.env) {
|
|
39
|
-
if (['cloud', 'worker'].includes(env.OPENROD_MODE)) throw Error(CLOUD_UNRELEASED)
|
|
40
46
|
return cloudConfig(env)
|
|
41
47
|
}
|
|
48
|
+
// OPENROD_TELEMETRY=0 (or false, off, no) and the DO_NOT_TRACK=1 convention
|
|
49
|
+
// turn off both usage sharing and feedback, whatever the browser chose.
|
|
50
|
+
export function telemetryAllowed(env = process.env) {
|
|
51
|
+
const value = name => String(env[name] ?? '').trim().toLowerCase()
|
|
52
|
+
return !['0', 'false', 'off', 'no'].includes(value('OPENROD_TELEMETRY')) && !['1', 'true', 'yes'].includes(value('DO_NOT_TRACK'))
|
|
53
|
+
}
|
|
42
54
|
const COOKIE = '__Host-openrod_session'
|
|
43
|
-
const cookieOf = req => (req.headers.cookie ?? '').split(';').map(s => s.trim()).find(s => s.startsWith(`${
|
|
55
|
+
const cookieOf = (req, name = COOKIE) => (req.headers.cookie ?? '').split(';').map(s => s.trim()).find(s => s.startsWith(`${name}=`))?.slice(name.length + 1)
|
|
44
56
|
export function createSecurity(config, auth, revocations = { has: () => false, add: () => { throw Error("Session revocation store is required") } }) {
|
|
45
57
|
const verified = new WeakMap()
|
|
58
|
+
const cookieName = config.sessionCookie ?? COOKIE
|
|
46
59
|
const attempts = new Map()
|
|
47
60
|
const originFor = req => config.mode !== 'local' ? config.origin : `http://${req.headers.host}`
|
|
48
61
|
const checkBoundary = req => {
|
|
@@ -60,11 +73,11 @@ export function createSecurity(config, auth, revocations = { has: () => false, a
|
|
|
60
73
|
checkBoundary(req)
|
|
61
74
|
if (config.mode === 'local') return null
|
|
62
75
|
if (config.mode === 'worker') {
|
|
63
|
-
const identity = verifyWorkerRequest(config.key, config.owner, req)
|
|
76
|
+
const identity = verifyWorkerRequest(config.key, config.owner, req, Date.now(), config.authHeader ?? workerAuthHeader(config.workerProtocol))
|
|
64
77
|
verified.set(req, identity)
|
|
65
78
|
return identity
|
|
66
79
|
}
|
|
67
|
-
const cookie = cookieOf(req)
|
|
80
|
+
const cookie = cookieOf(req, cookieName)
|
|
68
81
|
if (!cookie || cookie.length > 10000 || revocations.has(cookie)) throw fail('Sign in to OpenRod Cloud', 401)
|
|
69
82
|
let decoded
|
|
70
83
|
try { decoded = await auth.verifySessionCookie(cookie, true) } catch { throw fail('Session expired. Sign in again.', 401) }
|
|
@@ -75,18 +88,20 @@ export function createSecurity(config, auth, revocations = { has: () => false, a
|
|
|
75
88
|
}
|
|
76
89
|
const isAllowed = req => config.mode === 'local' ? isLocalApiRequest(req) : verified.has(req)
|
|
77
90
|
const json = (res, status, value) => { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(value)) }
|
|
78
|
-
const setCookie = (res, value, age) => res.setHeader('Set-Cookie', `${
|
|
91
|
+
const setCookie = (res, value, age) => res.setHeader('Set-Cookie', `${cookieName}=${value}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${age}`)
|
|
79
92
|
const middleware = async (req, res, next) => {
|
|
80
93
|
try {
|
|
81
94
|
const pathname = requestPath(req)
|
|
82
95
|
if (!pathname.startsWith('/api/')) return next()
|
|
83
96
|
checkBoundary(req)
|
|
84
|
-
if (pathname === '/api/auth/config' && req.method === 'GET') return json(res, 200, { mode: config.mode,
|
|
97
|
+
if (pathname === '/api/auth/config' && req.method === 'GET') return json(res, 200, { mode: config.mode,
|
|
98
|
+
telemetryEnabled: config.mode === 'local' && telemetryAllowed(),
|
|
99
|
+
...(config.mode === 'cloud' ? { firebase: config.firebase } : {}) })
|
|
85
100
|
if (config.mode === 'cloud' && pathname === '/api/auth/session' && req.method === 'POST') {
|
|
86
101
|
if (req.headers['x-openshell-console'] !== '1' || req.headers['content-type'] !== 'application/json') throw fail('Request rejected', 403)
|
|
87
102
|
// nginx overwrites this header from the GCP-appended client address.
|
|
88
103
|
// Trust it only from our loopback proxy, never from an external socket.
|
|
89
|
-
const forwarded = req.headers['x-openrod-client-ip']
|
|
104
|
+
const forwarded = req.headers[config.proxyClientIpHeader ?? 'x-openrod-client-ip']
|
|
90
105
|
const loopback = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress)
|
|
91
106
|
const key = loopback && typeof forwarded === 'string' && isIP(forwarded) ? forwarded : req.socket.remoteAddress
|
|
92
107
|
const now = Date.now()
|
|
@@ -112,7 +127,7 @@ export function createSecurity(config, auth, revocations = { has: () => false, a
|
|
|
112
127
|
if (config.mode === 'cloud' && pathname === '/api/auth/logout' && req.method === 'POST') {
|
|
113
128
|
if (req.headers['x-openshell-console'] !== '1') throw fail('Request rejected', 403)
|
|
114
129
|
setCookie(res, '', 0)
|
|
115
|
-
const cookie = cookieOf(req)
|
|
130
|
+
const cookie = cookieOf(req, cookieName)
|
|
116
131
|
if (cookie) revocations.add(cookie, Date.now() + 3600000)
|
|
117
132
|
return json(res, 200, { ok: true })
|
|
118
133
|
}
|
|
@@ -132,9 +147,10 @@ export function createSecurity(config, auth, revocations = { has: () => false, a
|
|
|
132
147
|
check?.unref?.()
|
|
133
148
|
target.once('close', () => { clearTimeout(expiry); clearInterval(check) })
|
|
134
149
|
}
|
|
135
|
-
return { config, authenticate, isAllowed, originFor, middleware, watch, sessionHash: req => createHash('sha256').update(cookieOf(req) ?? '').digest('hex'), isSessionRevoked: hash => revocations.hasDigest?.(hash) ?? false }
|
|
150
|
+
return { config, authenticate, isAllowed, originFor, middleware, watch, sessionHash: req => createHash('sha256').update(cookieOf(req, cookieName) ?? '').digest('hex'), isSessionRevoked: hash => revocations.hasDigest?.(hash) ?? false }
|
|
136
151
|
}
|
|
137
152
|
export function assertCloudOperation(parts, input = {}) {
|
|
138
153
|
if (!identityContext.getStore()) return
|
|
154
|
+
if (parts[0] === 'ingress' && ['expose', 'extend'].includes(parts[1])) throw fail('Public service forwarding is not available in OpenRod Cloud.', 501)
|
|
139
155
|
if (parts[0] === 'local-folder' || parts[0] === 'editors' || (parts[0] === 'sandboxes' && ['editor', 'terminal'].includes(parts[2])) || (parts[0] === 'sandboxes' && parts.length === 1 && input?.folder)) throw fail('Host-local actions are unavailable in OpenRod Cloud. Use the browser terminal or upload files.', 403)
|
|
140
156
|
}
|
|
@@ -285,5 +285,6 @@ export async function discover({ sources, home = os.homedir() }) {
|
|
|
285
285
|
|
|
286
286
|
export function publicItem(item) {
|
|
287
287
|
const { root, config, files, _sourceCredentials, ...rest } = item
|
|
288
|
-
|
|
288
|
+
const contentDigest = hash(JSON.stringify(canonical({kind:item.kind, name:item.name, config, files, disabled:Boolean(item.disabled), credentialRef:item.credentialRef, credentialBindings:item.credentialBindings, requirements:item.requirements, auth:item.auth})))
|
|
289
|
+
return { ...rest, contentDigest, sourceCredentialFields: item.sourceCredentialFields || Object.keys(_sourceCredentials || {}), ...(config ? { configuration: config.url ? { endpoint: config.url } : { command: config.command, args: config.args } } : {}), ...(files ? { files: files.map(({ path, content, encoding, archiveEntries }) => ({ path, bytes: Buffer.byteLength(content, encoding === 'base64' ? 'base64' : 'utf8'), ...(encoding ? { encoding, archiveEntries } : {}) })) } : {}) }
|
|
289
290
|
}
|