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,334 @@
|
|
|
1
|
+
// Explicit, anonymous PostHog capture. No SDK, autocapture, queue, retry, or logs.
|
|
2
|
+
// This module is also imported by Node-side libraries: never touch the browser
|
|
3
|
+
// until the authorized local UI configures it.
|
|
4
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i
|
|
5
|
+
export const USAGE_KEY = 'openrod.usage.v1'
|
|
6
|
+
const ID_KEY = 'openrod.usage-id.v1'
|
|
7
|
+
const SESSION_KEY = 'openrod.usage-session.v1'
|
|
8
|
+
const TERMINAL_KEY = 'openrod.terminal-flows.v1'
|
|
9
|
+
const choices = (...values) => value => values.includes(value) ? value : undefined
|
|
10
|
+
const uuid = value => typeof value === 'string' && UUID.test(value) ? value : undefined
|
|
11
|
+
const number = value => Number.isFinite(value) && value >= 0 ? Math.min(Math.round(value), 86400000) : undefined
|
|
12
|
+
const boolean = value => typeof value === 'boolean' ? value : undefined
|
|
13
|
+
const view = choices('sandboxes', 'activity', 'groups', 'egress', 'ingress', 'secrets', 'templates', 'setups', 'connections', 'terminal')
|
|
14
|
+
const flow = choices('gateway_connection', 'sandbox_creation', 'session_launch')
|
|
15
|
+
const location = choices('local', 'ssh', 'cloud', 'unknown')
|
|
16
|
+
const errorCategory = choices('docker_missing', 'docker_unavailable', 'openshell_missing', 'gateway_unavailable', 'ssh_auth', 'host_key', 'runtime_missing', 'image_build', 'credentials_missing', 'setup_import', 'policy', 'file_seed', 'timeout', 'connection_lost', 'unknown')
|
|
17
|
+
const flowFields = { flow, flow_id: uuid, attempt: number, location_type: location, launch_target: choices('browser', 'terminal', 'vscode', 'cursor') }
|
|
18
|
+
const text = value => typeof value === 'string' ? value.trim().slice(0, 2000) : undefined
|
|
19
|
+
const agentIds = value => Array.isArray(value) ? [...new Set(value.filter(id => ['claude', 'codex', 'cursor', 'pi', 'antigravity', 'opencode', 'aider', 'copilot', 'kiro', 'droid'].includes(id)))].slice(0, 12) : undefined
|
|
20
|
+
const configFields = { creation_mode: choices('quick', 'template'), file_source: choices('empty', 'folder', 'repo'), agent_ids: agentIds, setup_count: number, provider_count: number, group_count: number }
|
|
21
|
+
const EVENTS = {
|
|
22
|
+
console_opened: { gateway_state: choices('available', 'setup_required', 'unavailable'), first_observed_visit: boolean, connection_method: choices('automatic', 'manual', 'existing', 'unknown') },
|
|
23
|
+
view_opened: { previous_view: view },
|
|
24
|
+
flow_started: { ...flowFields, ...configFields },
|
|
25
|
+
flow_step_changed: { ...flowFields, step: choices('location', 'configuration', 'validation', 'host_selection', 'connecting', 'needs_docker', 'needs_runtime', 'preparing_tools', 'building_image', 'creating', 'requesting'), state: choices('entered', 'blocked'), error_category: errorCategory },
|
|
26
|
+
flow_finished: { ...flowFields, ...configFields, outcome: choices('connected', 'created', 'live', 'handoff_requested', 'failed', 'cancelled'), duration_ms: number, start_observed: boolean, error_category: errorCategory },
|
|
27
|
+
sandbox_ready: { flow_id: uuid, duration_ms: number, location_type: location },
|
|
28
|
+
feature_used: { feature: choices('template', 'setup', 'network', 'group', 'activity', 'files', 'sandbox'), action: choices('built', 'imported', 'activated', 'rule_saved', 'membership_saved', 'filter_applied', 'exported', 'uploaded', 'started', 'stopped', 'deleted'), count: number, location_type: location },
|
|
29
|
+
feedback_submitted: { prompt: choices('general'), category: choices('general'), text, feedback_only: boolean },
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function sanitizeEvent(event, properties = {}) {
|
|
33
|
+
if (!Object.hasOwn(EVENTS, event)) return null
|
|
34
|
+
const safe = {}
|
|
35
|
+
for (const [key, validate] of Object.entries({ view, ...EVENTS[event] })) {
|
|
36
|
+
const value = validate(properties[key])
|
|
37
|
+
if (value !== undefined) safe[key] = value
|
|
38
|
+
}
|
|
39
|
+
return safe
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Only the category leaves the browser. Messages can contain private hosts or
|
|
43
|
+
// paths, so they are matched here against OpenRod's own wording and dropped.
|
|
44
|
+
const ERROR_PATTERNS = [
|
|
45
|
+
[/host key/i, 'host_key'],
|
|
46
|
+
[/permission denied|publickey|authentication failed/i, 'ssh_auth'],
|
|
47
|
+
[/docker (engine )?is missing|docker isn.t installed|install docker/i, 'docker_missing'],
|
|
48
|
+
[/docker isn.t running|docker daemon|reach docker|docker.*not available/i, 'docker_unavailable'],
|
|
49
|
+
[/openshell (cli )?is not installed|openshell isn.t installed/i, 'openshell_missing'],
|
|
50
|
+
[/runtime (image|package)|sandbox runtime/i, 'runtime_missing'],
|
|
51
|
+
[/timed out/i, 'timeout'],
|
|
52
|
+
[/gateway/i, 'gateway_unavailable'],
|
|
53
|
+
]
|
|
54
|
+
export function classifyAnalyticsError(error) {
|
|
55
|
+
if (error?.name === 'TimeoutError' || error?.status === 504) return 'timeout'
|
|
56
|
+
if (error?.code === 'GATEWAY_DOCKER_MISMATCH') return 'docker_unavailable'
|
|
57
|
+
const message = typeof error?.message === 'string' ? error.message : ''
|
|
58
|
+
for (const [pattern, category] of ERROR_PATTERNS) if (pattern.test(message)) return category
|
|
59
|
+
if (error?.name === 'TypeError') return 'connection_lost'
|
|
60
|
+
if (error?.status === 502 || error?.status === 503) return 'gateway_unavailable'
|
|
61
|
+
return 'unknown'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createAnalytics({ getStorage = () => window.localStorage, getLocation = () => window.location.href,
|
|
65
|
+
randomUUID = () => crypto.randomUUID(), now = () => Date.now(), fetch: send = (...args) => fetch(...args),
|
|
66
|
+
setTimer = setTimeout, clearTimer = clearTimeout,
|
|
67
|
+
replaceLocation = url => window.history.replaceState(window.history.state, '', url),
|
|
68
|
+
config = typeof __OPENROD_ANALYTICS__ === 'undefined' ? {} : __OPENROD_ANALYTICS__,
|
|
69
|
+
} = {}) {
|
|
70
|
+
let authorized = false, runtimeEnabled = false, storageFailed = false, currentView, lastView, opened = false, firstVisit
|
|
71
|
+
const destination = config.host === 'https://eu.i.posthog.com' ? 'EU' : 'US'
|
|
72
|
+
let state = Object.freeze({ available: false, sharing: null, destination })
|
|
73
|
+
const listeners = new Set(), pending = new Map(), finished = new Set(), steps = new Map(), templates = new Map()
|
|
74
|
+
let recent = [], generation = 0
|
|
75
|
+
const storage = () => getStorage()
|
|
76
|
+
const safe = (operation, fallback) => { try { return operation() } catch { return fallback } }
|
|
77
|
+
function halt() {
|
|
78
|
+
generation++
|
|
79
|
+
for (const [controller, timer] of pending) { controller.abort(); clearTimer(timer) }
|
|
80
|
+
pending.clear(); finished.clear(); steps.clear(); templates.clear(); recent = []
|
|
81
|
+
opened = false; lastView = undefined; firstVisit = undefined
|
|
82
|
+
}
|
|
83
|
+
function cleanLocation() {
|
|
84
|
+
const url = new URL(getLocation())
|
|
85
|
+
return !url.searchParams.has('token') && !url.searchParams.has('handoff') && !/^#local-(connect|return)=/.test(url.hash)
|
|
86
|
+
&& !(config.environment === 'development' && url.searchParams.has('fleet'))
|
|
87
|
+
}
|
|
88
|
+
function available() {
|
|
89
|
+
return safe(() => authorized && runtimeEnabled && !storageFailed && config.enabled === true && /^phc_[a-zA-Z0-9]+$/.test(config.projectToken ?? '')
|
|
90
|
+
&& ['https://us.i.posthog.com', 'https://eu.i.posthog.com'].includes(config.host) && cleanLocation(), false)
|
|
91
|
+
}
|
|
92
|
+
function refresh() {
|
|
93
|
+
safe(() => {
|
|
94
|
+
let sharing = null
|
|
95
|
+
try {
|
|
96
|
+
const value = storage().getItem(USAGE_KEY); sharing = value === 'yes' ? true : value === 'no' ? false : null
|
|
97
|
+
}
|
|
98
|
+
catch { storageFailed = true; halt() }
|
|
99
|
+
if (sharing !== true && state.sharing === true) halt()
|
|
100
|
+
const next = { available: available(), sharing, destination }
|
|
101
|
+
if (Object.keys(next).some(key => next[key] !== state[key])) {
|
|
102
|
+
state = Object.freeze(next)
|
|
103
|
+
for (const notify of listeners) safe(notify)
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
function configure({ enabled = false, mode = 'local' } = {}) {
|
|
108
|
+
safe(() => { authorized = mode === 'local'; runtimeEnabled = enabled; if (!authorized || !enabled) halt(); refresh() })
|
|
109
|
+
}
|
|
110
|
+
function setSharing(value) {
|
|
111
|
+
safe(() => {
|
|
112
|
+
try {
|
|
113
|
+
storage().setItem(USAGE_KEY, value ? 'yes' : 'no')
|
|
114
|
+
if (!value) {
|
|
115
|
+
for (const key of [ID_KEY, SESSION_KEY, TERMINAL_KEY]) storage().removeItem(key)
|
|
116
|
+
halt()
|
|
117
|
+
}
|
|
118
|
+
} catch { storageFailed = true; halt() }
|
|
119
|
+
refresh()
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
function identity() {
|
|
123
|
+
const store = storage()
|
|
124
|
+
let id = uuid(store.getItem(ID_KEY)), session
|
|
125
|
+
const first = !id
|
|
126
|
+
firstVisit ??= first
|
|
127
|
+
if (!id) { id = randomUUID(); store.setItem(ID_KEY, id) }
|
|
128
|
+
try { session = JSON.parse(store.getItem(SESSION_KEY)) } catch { /* invalid old state */ }
|
|
129
|
+
if (!uuid(session?.id) || !Number.isFinite(session?.at) || now() - session.at > 1800000) session = { id: randomUUID() }
|
|
130
|
+
store.setItem(SESSION_KEY, JSON.stringify({ id: session.id, at: now() }))
|
|
131
|
+
return { id, session: session.id, first: firstVisit }
|
|
132
|
+
}
|
|
133
|
+
function capture(event, properties = {}, { explicitFeedback = false } = {}) {
|
|
134
|
+
return safe(() => {
|
|
135
|
+
refresh()
|
|
136
|
+
if (!state.available || (state.sharing !== true && !(explicitFeedback && event === 'feedback_submitted'))) return false
|
|
137
|
+
const sanitized = sanitizeEvent(event, properties)
|
|
138
|
+
if (!sanitized) return false
|
|
139
|
+
// Recheck the stored preference before every request, including in other tabs.
|
|
140
|
+
const regular = state.sharing === true
|
|
141
|
+
let who
|
|
142
|
+
try { who = regular ? identity() : { id: randomUUID() } } catch { storageFailed = true; halt(); refresh(); return false }
|
|
143
|
+
recent = recent.filter(at => now() - at < 60000)
|
|
144
|
+
if (pending.size >= 6 || recent.length >= 120) return false
|
|
145
|
+
const controller = new AbortController()
|
|
146
|
+
const timer = setTimer(() => { controller.abort(); pending.delete(controller) }, 3000)
|
|
147
|
+
pending.set(controller, timer); recent.push(now())
|
|
148
|
+
const payload = {
|
|
149
|
+
api_key: config.projectToken, event, distinct_id: who.id, timestamp: new Date(now()).toISOString(),
|
|
150
|
+
properties: { schema_version: 1, app_version: config.version, environment: config.environment,
|
|
151
|
+
// Feedback sent with sharing off carries only what the user typed.
|
|
152
|
+
...(regular && who.session ? { $session_id: who.session } : {}),
|
|
153
|
+
...(regular && currentView ? { view: currentView } : {}),
|
|
154
|
+
// Opted-in usage gets PostHog's approximate location (country, city);
|
|
155
|
+
// the project discards the IP itself. Feedback-only events get neither.
|
|
156
|
+
...sanitized, $process_person_profile: false, ...(regular ? {} : { $geoip_disable: true }),
|
|
157
|
+
...(event === 'console_opened' ? { first_observed_visit: who.first } : {}),
|
|
158
|
+
...(explicitFeedback ? { feedback_only: !regular } : {}),
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
const done = () => { clearTimer(timer); pending.delete(controller) }
|
|
162
|
+
if (event === 'console_opened') firstVisit = false
|
|
163
|
+
try {
|
|
164
|
+
Promise.resolve(send(`${config.host}/i/v0/e/`, { method: 'POST', headers: { 'content-type': 'text/plain' },
|
|
165
|
+
body: JSON.stringify(payload), signal: controller.signal, credentials: 'omit', referrerPolicy: 'no-referrer',
|
|
166
|
+
// Survives the reload that follows a local gateway connection.
|
|
167
|
+
cache: 'no-store', redirect: 'error', keepalive: true,
|
|
168
|
+
})).then(done, done)
|
|
169
|
+
} catch { done() }
|
|
170
|
+
return true // Attempted, never a delivery acknowledgement.
|
|
171
|
+
}, false)
|
|
172
|
+
}
|
|
173
|
+
function observeConsole(connection) {
|
|
174
|
+
safe(() => {
|
|
175
|
+
if (opened || !['live', 'setup-required', 'gateway-down'].includes(connection)) return
|
|
176
|
+
if (capture('console_opened', { gateway_state: connection === 'live' ? 'available' : connection === 'setup-required' ? 'setup_required' : 'unavailable', connection_method: 'existing' })) opened = true
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
function observeView(next) {
|
|
180
|
+
safe(() => { currentView = view(next); if (currentView && currentView !== lastView && capture('view_opened', { previous_view: lastView })) lastView = currentView })
|
|
181
|
+
}
|
|
182
|
+
function startFlow(kind, properties = {}, previous = null) {
|
|
183
|
+
return safe(() => {
|
|
184
|
+
refresh()
|
|
185
|
+
if (!state.available || state.sharing !== true || !flow(kind)) return null
|
|
186
|
+
const installationId = identity().id
|
|
187
|
+
if (previous?.generation !== generation || previous?.installationId !== installationId || previous?.flow !== kind) previous = null
|
|
188
|
+
// Only a failed attempt, or the exploration before the first submit,
|
|
189
|
+
// continues. Anything still running is superseded by this new flow.
|
|
190
|
+
if (previous && previous.outcome !== 'failed' && previous.started !== null) {
|
|
191
|
+
if (!previous.outcome) finishFlow(previous, 'cancelled')
|
|
192
|
+
previous = null
|
|
193
|
+
}
|
|
194
|
+
const item = { generation, installationId, id: previous?.id ?? randomUUID(), flow: kind, attempt: (previous?.attempt ?? 0) + 1, started: now(), properties: sanitizeEvent('flow_started', properties) }
|
|
195
|
+
capture('flow_started', { ...item.properties, flow: kind, flow_id: item.id, attempt: item.attempt })
|
|
196
|
+
step(item, kind === 'session_launch' ? 'requesting' : kind === 'gateway_connection' ? 'connecting' : 'preparing_tools')
|
|
197
|
+
return item
|
|
198
|
+
}, null)
|
|
199
|
+
}
|
|
200
|
+
function exploreFlow(kind, properties = {}) {
|
|
201
|
+
return safe(() => {
|
|
202
|
+
refresh(); if (!state.available || state.sharing !== true || !flow(kind)) return null
|
|
203
|
+
return { generation, installationId: identity().id, id: randomUUID(), flow: kind, attempt: 0, started: null, properties: sanitizeEvent('flow_started', properties) }
|
|
204
|
+
}, null)
|
|
205
|
+
}
|
|
206
|
+
// Also bind to the stored identity: a suspended tab can miss the brief
|
|
207
|
+
// opt-out state if sharing is turned off and back on before it resumes.
|
|
208
|
+
const activeFlow = item => item && item.generation === generation && state.available && state.sharing === true
|
|
209
|
+
&& uuid(item.installationId) && item.installationId === storage().getItem(ID_KEY)
|
|
210
|
+
const fields = item => ({ ...item.properties, flow: item.flow, flow_id: item.id, attempt: item.attempt })
|
|
211
|
+
function step(item, next, phaseState = 'entered', category) {
|
|
212
|
+
safe(() => {
|
|
213
|
+
refresh(); if (!activeFlow(item)) return
|
|
214
|
+
const key = `${item.id}:${item.attempt}`, value = `${next}:${phaseState}:${category}`
|
|
215
|
+
if (steps.get(key) === value) return
|
|
216
|
+
if (steps.size >= 100) steps.delete(steps.keys().next().value)
|
|
217
|
+
steps.set(key, value)
|
|
218
|
+
capture('flow_step_changed', { ...fields(item), step: next, state: phaseState, error_category: category })
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
function finishFlow(item, outcome, category) {
|
|
222
|
+
safe(() => {
|
|
223
|
+
refresh(); if (!activeFlow(item)) return
|
|
224
|
+
const key = `${item.id}:${item.attempt}`
|
|
225
|
+
if (finished.has(key)) return
|
|
226
|
+
if (finished.size >= 200) finished.delete(finished.values().next().value)
|
|
227
|
+
finished.add(key); steps.delete(key)
|
|
228
|
+
item.outcome = outcome
|
|
229
|
+
capture('flow_finished', { ...fields(item), outcome, start_observed: item.started !== null, duration_ms: item.started === null ? undefined : now() - item.started, error_category: category })
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
function ready(item) {
|
|
233
|
+
safe(() => {
|
|
234
|
+
refresh(); if (!activeFlow(item)) return
|
|
235
|
+
const key = `${item.id}:ready`
|
|
236
|
+
if (finished.has(key)) return
|
|
237
|
+
if (finished.size >= 200) finished.delete(finished.values().next().value)
|
|
238
|
+
finished.add(key)
|
|
239
|
+
capture('sandbox_ready', { flow_id: item.id, duration_ms: now() - item.started, location_type: item.properties.location_type })
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
function submitFeedback(properties) {
|
|
243
|
+
return capture('feedback_submitted', properties, { explicitFeedback: true })
|
|
244
|
+
}
|
|
245
|
+
// Only safe correlation metadata is stored, never a name, command, or host.
|
|
246
|
+
function terminalClick(event, properties = {}) {
|
|
247
|
+
safe(() => {
|
|
248
|
+
const item = startFlow('session_launch', { ...properties, launch_target: 'browser' })
|
|
249
|
+
refresh(); if (!activeFlow(item)) return
|
|
250
|
+
const url = new URL(event.currentTarget.href)
|
|
251
|
+
const [path, query = ''] = url.hash.split('?')
|
|
252
|
+
const params = new URLSearchParams(query); params.set('analyticsFlow', item.id)
|
|
253
|
+
url.hash = `${path}?${params}`
|
|
254
|
+
try {
|
|
255
|
+
const saved = readTerminalFlows()
|
|
256
|
+
saved.push(item); storage().setItem(TERMINAL_KEY, JSON.stringify(saved.slice(-20)))
|
|
257
|
+
event.currentTarget.href = url.href
|
|
258
|
+
} catch { /* Losing correlation cannot affect the link. */ }
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
// A restored flow is consumed, so a reload or a copied link starts a new one.
|
|
262
|
+
function forgetTerminalFlow(saved, id) {
|
|
263
|
+
try { storage().setItem(TERMINAL_KEY, JSON.stringify(saved.filter(item => item.id !== id))) } catch { /* stale state */ }
|
|
264
|
+
safe(() => {
|
|
265
|
+
const url = new URL(getLocation())
|
|
266
|
+
const [path, query = ''] = url.hash.split('?')
|
|
267
|
+
const params = new URLSearchParams(query); params.delete('analyticsFlow')
|
|
268
|
+
url.hash = params.toString() ? `${path}?${params}` : path
|
|
269
|
+
replaceLocation(url.href)
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
function readTerminalFlows() {
|
|
273
|
+
let values = []
|
|
274
|
+
try { values = JSON.parse(storage().getItem(TERMINAL_KEY) || '[]') } catch { /* stale state */ }
|
|
275
|
+
return Array.isArray(values) ? values.filter(item => uuid(item.id) && item.flow === 'session_launch' && Number.isFinite(item.started) && now() - item.started < 1800000).slice(-20) : []
|
|
276
|
+
}
|
|
277
|
+
function terminalFlow(properties, previous) {
|
|
278
|
+
return safe(() => {
|
|
279
|
+
refresh(); if (!state.available || state.sharing !== true) return null
|
|
280
|
+
if (previous) return startFlow('session_launch', properties, previous)
|
|
281
|
+
const id = new URLSearchParams(new URL(getLocation()).hash.split('?')[1]).get('analyticsFlow')
|
|
282
|
+
const installationId = identity().id
|
|
283
|
+
const saved = readTerminalFlows(), restored = saved.find(item => item.id === id && item.installationId === installationId)
|
|
284
|
+
if (id) forgetTerminalFlow(saved, id)
|
|
285
|
+
if (!restored) return startFlow('session_launch', { ...properties, launch_target: 'browser' })
|
|
286
|
+
return { generation, installationId, id: restored.id, flow: 'session_launch', attempt: number(restored.attempt) ?? 1, started: restored.started, properties: sanitizeEvent('flow_started', restored.properties) }
|
|
287
|
+
}, null)
|
|
288
|
+
}
|
|
289
|
+
function templateStarted(record, owner) {
|
|
290
|
+
safe(() => {
|
|
291
|
+
refresh(); if (!state.available || state.sharing !== true) return
|
|
292
|
+
if (record.status === 'ready') { capture('feature_used', { feature: 'template', action: 'built', location_type: analyticsLocation(owner) }); return }
|
|
293
|
+
if (record.status !== 'building') return
|
|
294
|
+
if (templates.size >= 100) templates.delete(templates.keys().next().value)
|
|
295
|
+
templates.set(JSON.stringify([owner?.context, record.name]), { installationId: identity().id, startedAt: record.startedAt, at: now(), location_type: analyticsLocation(owner) })
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
function observeTemplates(records) {
|
|
299
|
+
safe(() => {
|
|
300
|
+
for (const record of records) {
|
|
301
|
+
const key = JSON.stringify([record.location?.context, record.name]), tracked = templates.get(key)
|
|
302
|
+
if (!tracked) continue
|
|
303
|
+
if (tracked.installationId !== storage().getItem(ID_KEY) || now() - tracked.at > 1800000) { templates.delete(key); continue }
|
|
304
|
+
// A finished template record has no build start time; a different
|
|
305
|
+
// start time means a newer build replaced the tracked one.
|
|
306
|
+
if ((record.startedAt && record.startedAt !== tracked.startedAt) || record.location?.connected === false) continue
|
|
307
|
+
if (record.status === 'ready') { templates.delete(key); capture('feature_used', { feature: 'template', action: 'built', location_type: tracked.location_type }) }
|
|
308
|
+
else if (record.status === 'failed') templates.delete(key)
|
|
309
|
+
}
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
return { configure, setSharing, refresh, capture, observeConsole, observeView, startFlow, exploreFlow, step, finishFlow, ready, templateStarted, observeTemplates,
|
|
313
|
+
submitFeedback, terminalClick, terminalFlow,
|
|
314
|
+
subscribe: listener => { listeners.add(listener); return () => listeners.delete(listener) }, getSnapshot: () => state,
|
|
315
|
+
stop: () => { authorized = false; halt(); refresh() },
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export const analytics = createAnalytics()
|
|
320
|
+
export const analyticsLocation = value => value?.target === 'cloud' ? 'cloud' : value?.remote ? 'ssh' : 'local'
|
|
321
|
+
|
|
322
|
+
// Capture successful product mutations at the shared API boundary. Only fixed
|
|
323
|
+
// method/path patterns select events; no path, response, or request body is sent.
|
|
324
|
+
export function analyticsFeatureRequest(suffix, method) {
|
|
325
|
+
if (method === 'GET' || method === undefined) return
|
|
326
|
+
const rules = [
|
|
327
|
+
[/^\/setups\/save$/, 'setup', 'imported'], [/^\/setups\/[^/]+\/enable$/, 'setup', 'activated'],
|
|
328
|
+
[/^\/egress\/policies$/, 'network', 'rule_saved'], [/^\/org\/members$/, 'group', 'membership_saved'],
|
|
329
|
+
[/^\/files\/[^/]+\/uploads\/[^/]+\/commit$/, 'files', 'uploaded'],
|
|
330
|
+
[/^\/sandboxes\/[^/]+\/start$/, 'sandbox', 'started'], [/^\/sandboxes\/[^/]+\/stop$/, 'sandbox', 'stopped'], [/^\/sandboxes\/[^/]+\/delete$/, 'sandbox', 'deleted'],
|
|
331
|
+
]
|
|
332
|
+
const rule = rules.find(([pattern]) => pattern.test(suffix))
|
|
333
|
+
if (rule) analytics.capture('feature_used', { feature: rule[1], action: rule[2] })
|
|
334
|
+
}
|
package/src/lib/api.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// holds the gateway certificate, so nothing here carries a credential.
|
|
3
3
|
|
|
4
4
|
import { computeApiPath, currentComputeTarget, currentCloudOwner } from './compute-target.js'
|
|
5
|
+
import { analyticsFeatureRequest } from './analytics.js'
|
|
5
6
|
|
|
6
7
|
// The local server answers 401 with this code until the browser opens the
|
|
7
8
|
// tokened link that `openrod` prints; the app then shows how to get back in.
|
|
@@ -15,7 +16,10 @@ export function createApi(target, signal, locationContext = null, boundOwner = c
|
|
|
15
16
|
const requestSignal = extra => signal && extra ? AbortSignal.any([signal, extra]) : signal ?? extra
|
|
16
17
|
async function loadContext() {
|
|
17
18
|
signal?.throwIfAborted()
|
|
18
|
-
const response = await fetch(path('/context'), { signal, headers:
|
|
19
|
+
const response = await fetch(path('/context'), { signal, headers: {
|
|
20
|
+
...(selected() === 'cloud' && owner ? { 'x-openrod-local-owner': owner } : {}),
|
|
21
|
+
...(locationContext ? { 'x-openshell-context': locationContext, 'x-openshell-location': '1' } : {}),
|
|
22
|
+
} })
|
|
19
23
|
const context = await response.json()
|
|
20
24
|
signal?.throwIfAborted()
|
|
21
25
|
if (context.code === LINK_REQUIRED) window.dispatchEvent(new Event(LINK_REQUIRED))
|
|
@@ -52,7 +56,8 @@ export function createApi(target, signal, locationContext = null, boundOwner = c
|
|
|
52
56
|
currentSignal?.throwIfAborted()
|
|
53
57
|
if (payload.code === LINK_REQUIRED) window.dispatchEvent(new Event(LINK_REQUIRED))
|
|
54
58
|
else if (response.status === 401 || payload.code === 'CLOUD_OWNER_CHANGED') window.dispatchEvent(new Event('openrod-session-expired'))
|
|
55
|
-
if (!response.ok) throw Object.assign(new Error(payload.error ?? `Request failed (${response.status})`), { code: payload.code, sandboxes: payload.sandboxes, fix: payload.fix })
|
|
59
|
+
if (!response.ok) throw Object.assign(new Error(payload.error ?? `Request failed (${response.status})`), { status: response.status, code: payload.code, sandboxes: payload.sandboxes, fix: payload.fix })
|
|
60
|
+
analyticsFeatureRequest(suffix, method)
|
|
56
61
|
return payload
|
|
57
62
|
}
|
|
58
63
|
return {
|
|
@@ -65,17 +70,29 @@ export function createApi(target, signal, locationContext = null, boundOwner = c
|
|
|
65
70
|
inventory: extraSignal => request('/inventory', { signal: extraSignal, scoped: false }),
|
|
66
71
|
context: loadContext,
|
|
67
72
|
contextKey,
|
|
73
|
+
capabilities: () => request('/capabilities', { scoped: false }),
|
|
74
|
+
importCapabilities: () => request('/resource-imports/capabilities'),
|
|
75
|
+
exportResources: body => request('/resource-imports/export', { method: 'POST', body }),
|
|
76
|
+
planImport: body => request('/resource-imports/plan', { method: 'POST', body }),
|
|
77
|
+
importJobs: () => request('/resource-imports'),
|
|
78
|
+
importJob: id => request(`/resource-imports/${encodeURIComponent(id)}`),
|
|
79
|
+
executeImport: id => request(`/resource-imports/${encodeURIComponent(id)}/execute`, { method: 'POST', body: { acknowledged: true } }),
|
|
80
|
+
retryImport: id => request(`/resource-imports/${encodeURIComponent(id)}/retry`, { method: 'POST', body: { acknowledged: true } }),
|
|
81
|
+
cancelImport: id => request(`/resource-imports/${encodeURIComponent(id)}/cancel`, { method: 'POST', body: {} }),
|
|
68
82
|
connections: extraSignal => request('/connections', { signal: extraSignal, scoped: false }),
|
|
69
83
|
connectionJob: (id, extraSignal) => request(`/connections/jobs/${encodeURIComponent(id)}`, { signal: extraSignal, scoped: false }),
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
84
|
+
// These manage console connections, so an offline working location must
|
|
85
|
+
// never gate reconnecting, forgetting, or selecting a different gateway.
|
|
86
|
+
connect: body => request('/connections/connect', { method: 'POST', body, scoped: false }),
|
|
87
|
+
installConnectionDocker: id => request(`/connections/jobs/${encodeURIComponent(id)}/docker`, { method: 'POST', body: { approve: true }, scoped: false }),
|
|
88
|
+
installConnectionRuntime: id => request(`/connections/jobs/${encodeURIComponent(id)}/install`, { method: 'POST', body: { method: 'download' }, scoped: false }),
|
|
89
|
+
uploadConnectionPackage: (id, file) => request(`/connections/jobs/${encodeURIComponent(id)}/package`, { method: 'POST', body: file, raw: true, scoped: false }),
|
|
90
|
+
scanSshHost: body => request('/connections/hosts/scan', { method: 'POST', body, scoped: false }),
|
|
91
|
+
addSshHost: token => request('/connections/hosts', { method: 'POST', body: { token }, scoped: false }),
|
|
92
|
+
removeSshHost: alias => request('/connections/hosts/remove', { method: 'POST', body: { alias }, scoped: false }),
|
|
93
|
+
restoreSshHost: host => request('/connections/hosts/restore', { method: 'POST', body: { host }, scoped: false }),
|
|
94
|
+
forgetRemote: () => request('/connections/forget', { method: 'POST', body: {}, scoped: false }),
|
|
95
|
+
disconnectRemote: () => request('/connections/disconnect', { method: 'POST', body: {}, scoped: false }),
|
|
79
96
|
syncLocalCatalog: () => request('/local-catalog', { method: 'POST', body: {} }),
|
|
80
97
|
gatewayDocker: (fresh = false) => request(`/gateway-docker${fresh ? '?fresh=1' : ''}`, { scoped: false }),
|
|
81
98
|
connectGatewayDocker: (seen = [], confirm = true) => request('/gateway-docker/connect', { method: 'POST', body: { confirm, seen }, scoped: false }),
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const messages = {
|
|
2
|
+
'auth/network-request-failed': 'Couldn’t reach Google. Check your connection and try again.',
|
|
3
|
+
'auth/popup-closed-by-user': 'Sign-in cancelled.',
|
|
4
|
+
'auth/cancelled-popup-request': 'Sign-in cancelled.',
|
|
5
|
+
'auth/popup-blocked': 'Allow popups, then try again.',
|
|
6
|
+
'auth/unauthorized-domain': 'Sign-in isn’t configured for this address. Contact your administrator.',
|
|
7
|
+
'auth/user-disabled': 'This account is disabled. Contact your administrator.',
|
|
8
|
+
'auth/too-many-requests': 'Too many attempts. Try again shortly.',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function cloudSignInError(reason) {
|
|
12
|
+
return messages[reason?.code] || 'Couldn’t sign in. Try again.'
|
|
13
|
+
}
|
|
@@ -10,7 +10,7 @@ export function cloudHandoffUrl(origin, nonce, cloud = CLOUD_ORIGIN) {
|
|
|
10
10
|
export function isCloudReadyMessage(event, popup, nonce, cloud = CLOUD_ORIGIN) {
|
|
11
11
|
return Boolean(cloud && popup && event.source === popup && event.origin === cloud
|
|
12
12
|
&& event.data?.type === 'openrod-cloud-ready' && event.data.nonce === nonce
|
|
13
|
-
&& typeof event.data.ticket === 'string' && /^
|
|
13
|
+
&& typeof event.data.ticket === 'string' && /^[a-z][a-z0-9-]{0,62}\.[a-f0-9]{64}$/.test(event.data.ticket))
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export const LOCAL_ORIGIN = 'http://127.0.0.1:4600'
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { activityRow, filterActivity, SEVERITY_RANK } from './activity-inventory.js'
|
|
2
|
+
import { locationLabel, resourceKey } from './locations.js'
|
|
3
|
+
|
|
4
|
+
// Each source pages in activity-store's ORDER BY, so the merge must use that exact
|
|
5
|
+
// order or page boundaries skip and repeat events. Keep these keys in step with it.
|
|
6
|
+
const SORTABLE = new Set(['time', 'sandbox', 'scope', 'sandboxId', 'agent', 'category', 'severity', 'logLevel', 'direction', 'verdict', 'outcome', 'process', 'action', 'destination', 'policy', 'session', 'correlation', 'why'])
|
|
7
|
+
// SQLite's BINARY collation compares UTF-8 bytes (code point order), not UTF-16 units.
|
|
8
|
+
const binary = (a, b) => { let i = 0; while (i < a.length && i < b.length && a[i] === b[i]) i++; return i < a.length && i < b.length ? a.codePointAt(i) - b.codePointAt(i) : a.length - b.length }
|
|
9
|
+
// json_extract yields NULL, a number (booleans as 0/1) or text, ordered in that sequence.
|
|
10
|
+
const sqlValue = value => typeof value === 'boolean' ? Number(value) : typeof value === 'number' ? (Number.isFinite(value) ? value : null) : value == null ? null : typeof value === 'object' ? JSON.stringify(value) : String(value)
|
|
11
|
+
const typeRank = value => value === null ? 0 : typeof value === 'number' ? 1 : 2
|
|
12
|
+
const compareSql = (a, b) => typeRank(a) - typeRank(b) || (typeof a === 'number' ? a - b : typeof a === 'string' ? binary(a, b) : 0)
|
|
13
|
+
const severityRank = value => { const name = String(value ?? '').replace(/[a-z]+/g, letters => letters.toUpperCase()); return Object.hasOwn(SEVERITY_RANK, name) ? SEVERITY_RANK[name] : -1 }
|
|
14
|
+
export function serverSort(rows, sort) {
|
|
15
|
+
const key = SORTABLE.has(sort?.key) ? sort.key : 'time', direction = sort?.direction === 'asc' ? 1 : -1
|
|
16
|
+
const value = row => key === 'time' ? (Number.isFinite(row.timestamp) ? row.timestamp : 0) : key === 'severity' ? severityRank(row.values.severity) : sqlValue(row.values[key])
|
|
17
|
+
return rows.map(row => [value(row), row]).sort(([av, a], [bv, b]) => direction * compareSql(av, bv) || binary(String(a.event.originalId ?? a.event.id ?? ''), String(b.event.originalId ?? b.event.id ?? '')) || binary(a.key, b.key)).map(([, row]) => row)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Page each source at its own snapshot, then merge. Names and event ids are
|
|
21
|
+
// only unique within a source; mutation plans retain that source throughout.
|
|
22
|
+
export function combinedActivityApi(base, getLocations, apiFor) {
|
|
23
|
+
const cache = new Map(), plans = new Map()
|
|
24
|
+
let sequence = 0
|
|
25
|
+
return {
|
|
26
|
+
...base,
|
|
27
|
+
async activity(options = {}) {
|
|
28
|
+
const { offset = 0, limit = 50, snapshot = {}, exportAll, ...query } = options
|
|
29
|
+
const now = query.now ?? Date.now()
|
|
30
|
+
const {now: queryNow, ...filters} = query
|
|
31
|
+
const key = JSON.stringify(filters)
|
|
32
|
+
const locations = getLocations(), unavailable = []
|
|
33
|
+
const entries = await Promise.all(locations.map(async (location, index) => {
|
|
34
|
+
const identity = location.id ?? location.context
|
|
35
|
+
const cacheKey = `${identity}:${key}`
|
|
36
|
+
// An export must be complete, so it never substitutes a cached page.
|
|
37
|
+
const prior = exportAll ? null : cache.get(cacheKey)
|
|
38
|
+
if (!location.connected) { unavailable[index] = {label: locationLabel(location), offline: true}; return prior ? {...prior, total:prior.events.length, events: prior.events.map(event => ({...event, location}))} : null }
|
|
39
|
+
try {
|
|
40
|
+
const events = []; let result, sourceOffset = 0, sourceSnapshot = snapshot?.[identity]
|
|
41
|
+
const needed = exportAll ? Infinity : offset + limit
|
|
42
|
+
do {
|
|
43
|
+
result = await apiFor(location).activity({...query, now, offset: sourceOffset, limit: Math.min(1000, needed - sourceOffset), ...(sourceSnapshot != null ? {snapshot: sourceSnapshot} : {})})
|
|
44
|
+
sourceSnapshot = result.snapshot
|
|
45
|
+
events.push(...result.events.map(event => ({...event, originalId: event.id, id: resourceKey({id: event.id, location}), location})))
|
|
46
|
+
sourceOffset += result.events.length
|
|
47
|
+
} while (sourceOffset < needed && sourceOffset < result.total && result.events.length)
|
|
48
|
+
const entry = {...result, events, identity, snapshot: sourceSnapshot}
|
|
49
|
+
if (!exportAll) cache.set(cacheKey, entry)
|
|
50
|
+
return entry
|
|
51
|
+
} catch (error) {
|
|
52
|
+
unavailable[index] = {label: locationLabel(location), error: error.message}
|
|
53
|
+
if (prior) return {...prior, total:prior.events.length, events: prior.events.map(event => ({...event, location: {...location, connected: false}}))}
|
|
54
|
+
return {error}
|
|
55
|
+
}
|
|
56
|
+
}))
|
|
57
|
+
const available = entries.filter(entry => entry && !entry.error)
|
|
58
|
+
if (!available.length && entries.some(entry => entry?.error)) throw entries.find(entry => entry?.error).error
|
|
59
|
+
// filterActivity only filters here; serverSort sets the order.
|
|
60
|
+
const merged = serverSort(filterActivity(available.flatMap(entry => entry.events).map(activityRow), {...query, now, sort: {key: 'time'}}), query.sort).map(row => row.event)
|
|
61
|
+
const total = available.reduce((sum, entry) => sum + entry.total, 0)
|
|
62
|
+
return {events: exportAll ? merged : merged.slice(offset, offset + limit), total, now,
|
|
63
|
+
snapshot: Object.fromEntries(available.map(entry => [entry.identity, entry.snapshot])),
|
|
64
|
+
sandboxes: [...new Set(available.flatMap(entry => entry.sandboxes ?? []))], agents: [...new Set(available.flatMap(entry => entry.agents ?? []))],
|
|
65
|
+
nextOffset: offset + limit < total ? offset + limit : null, unavailable: unavailable.filter(Boolean)}
|
|
66
|
+
},
|
|
67
|
+
async previewActivityDeletion({mode, ids = [], query}) {
|
|
68
|
+
const locations = getLocations()
|
|
69
|
+
const owners = locations.filter(location => mode !== 'selected' || ids.some(id => { try {return JSON.parse(id)[0][0] === (location.target ?? 'local') && JSON.parse(id)[0][1] === location.context} catch {return false} }))
|
|
70
|
+
if (mode === 'selected' && owners.some(location => !location.connected)) throw new Error('Reconnect the source of the selected logs before deleting them.')
|
|
71
|
+
const entries = []
|
|
72
|
+
for (const location of owners.filter(location => location.connected)) {
|
|
73
|
+
const originalIds = ids.filter(id => {try {const [owner] = JSON.parse(id);return owner[0] === (location.target ?? 'local') && owner[1] === location.context} catch {return false}}).map(id => JSON.parse(id)[1])
|
|
74
|
+
const plan = await apiFor(location).previewActivityDeletion({mode, ...(mode === 'selected' ? {ids: originalIds} : {}), query})
|
|
75
|
+
entries.push({location, plan})
|
|
76
|
+
}
|
|
77
|
+
const token = `combined:${++sequence}`
|
|
78
|
+
plans.set(token, {entries, deleted: 0})
|
|
79
|
+
return {token, mode, count: entries.reduce((sum, entry) => sum + entry.plan.count, 0), expiresAt: entries.length ? Math.min(...entries.map(entry => entry.plan.expiresAt)) : Date.now(),
|
|
80
|
+
sources: entries.map(entry => ({label: locationLabel(entry.location), count: entry.plan.count})), skipped: owners.filter(location => !location.connected).map(locationLabel)}
|
|
81
|
+
},
|
|
82
|
+
async deleteActivity(token) {
|
|
83
|
+
const plan = plans.get(token)
|
|
84
|
+
if (!plan) throw new Error('Review the log deletion again.')
|
|
85
|
+
// Remove successful entries so retrying a partial failure never repeats it.
|
|
86
|
+
while (plan.entries.length) {
|
|
87
|
+
const entry = plan.entries[0]
|
|
88
|
+
const result = await apiFor(entry.location).deleteActivity(entry.plan.token)
|
|
89
|
+
plan.deleted += result.deleted; plan.entries.shift()
|
|
90
|
+
}
|
|
91
|
+
plans.delete(token)
|
|
92
|
+
return {deleted: plan.deleted}
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Opening a specific connection must never redirect the user to another host.
|
|
2
|
+
export function connectionHost(connections, initialHost, chosenHost) {
|
|
3
|
+
const pending = connections.job && ['working', 'needs-install', 'needs-docker'].includes(connections.job.status) ? connections.job : null
|
|
4
|
+
const host = chosenHost ?? initialHost ?? pending?.host ?? connections.active?.host ?? connections.hosts[0]?.name ?? ''
|
|
5
|
+
return { host, job: pending?.host === host ? pending : null }
|
|
6
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { sourceOf,hostOf,isIp,portOf } from './policy-sources.js'
|
|
2
|
+
import { blockPatterns,hostMatches } from './egress.js'
|
|
3
|
+
import { resourceKey } from './locations.js'
|
|
4
|
+
|
|
5
|
+
export function egressRows(sandboxes, events, org) {
|
|
6
|
+
const dest = new Map()
|
|
7
|
+
const allowedBy = new Map()
|
|
8
|
+
const perSandbox = new Map()
|
|
9
|
+
for (const s of sandboxes) {
|
|
10
|
+
const hosts = new Set()
|
|
11
|
+
const counts = {}
|
|
12
|
+
for (const r of s.rules) {
|
|
13
|
+
const src = sourceOf(r.key)
|
|
14
|
+
counts[src] = (counts[src] ?? 0) + 1
|
|
15
|
+
for (const e of r.endpoints) {
|
|
16
|
+
if (e.blocked) continue
|
|
17
|
+
hosts.add(e.host)
|
|
18
|
+
const d = dest.get(e.host) ?? { host: e.host, sources: new Set(), access: new Set(), ports: new Set(), sandboxes: new Set(), grants: [], hits: 0, hitsBySandbox: new Map() }
|
|
19
|
+
d.sources.add(src); d.access.add(e.access); e.ports.forEach((p) => d.ports.add(p)); d.sandboxes.add(s.name)
|
|
20
|
+
let grant = d.grants.find((g) => g.sandbox === s.name && g.key === r.key)
|
|
21
|
+
if (!grant) {
|
|
22
|
+
grant = { sandbox: s.name, key: r.key, source: src, access: [], ports: [] }
|
|
23
|
+
d.grants.push(grant)
|
|
24
|
+
}
|
|
25
|
+
grant.access.push(e.access)
|
|
26
|
+
grant.ports.push(...e.ports)
|
|
27
|
+
dest.set(e.host, d)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
allowedBy.set(s.name, hosts)
|
|
31
|
+
perSandbox.set(s.name, { counts, blocked: 0 })
|
|
32
|
+
}
|
|
33
|
+
const block = new Map()
|
|
34
|
+
for (const e of events) {
|
|
35
|
+
if (e.kind !== "audit" || !e.verdict) continue
|
|
36
|
+
const host = hostOf(e.destination)
|
|
37
|
+
if (!host || isIp(host)) continue
|
|
38
|
+
if (e.verdict === "allowed") { const d = dest.get(host); if (d) { d.hits += 1; d.hitsBySandbox.set(e.sandbox, (d.hitsBySandbox.get(e.sandbox) ?? 0) + 1) }; continue }
|
|
39
|
+
if (e.verdict !== "denied" || allowedBy.get(e.sandbox)?.has(host)) continue
|
|
40
|
+
const b = block.get(host) ?? { host, port: portOf(e.destination), sandboxes: new Set(), attempts: 0, programs: new Set(), lastAt: null }
|
|
41
|
+
b.attempts += 1; b.sandboxes.add(e.sandbox)
|
|
42
|
+
if (e.binary) b.programs.add(e.binary)
|
|
43
|
+
if (e.at && (!b.lastAt || e.at > b.lastAt)) b.lastAt = e.at
|
|
44
|
+
block.set(host, b)
|
|
45
|
+
const p = perSandbox.get(e.sandbox); if (p) p.blocked += 1
|
|
46
|
+
}
|
|
47
|
+
// Hosts a block already decides are not waiting for anyone.
|
|
48
|
+
const decided = blockPatterns([...(org?.org?.blocked ?? []), ...(org?.policies ?? []).filter((p) => p.action === "block").flatMap((p) => p.destinations)])
|
|
49
|
+
return {
|
|
50
|
+
destinations: [...dest.values()].sort((a, b) => b.sandboxes.size - a.sandboxes.size || b.hits - a.hits || a.host.localeCompare(b.host)),
|
|
51
|
+
blocked: [...block.values()].filter((b) => !decided.some((pattern) => hostMatches(pattern, b.host))).sort((a, b) => b.sandboxes.size - a.sandboxes.size || b.attempts - a.attempts),
|
|
52
|
+
perSandbox,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function combinedEgressInventory(sources) {
|
|
57
|
+
const inventories=sources.map(source=>({location:source.location,...egressRows(source.data?.fleetPolicy?.sandboxes ?? [],source.data?.activity?.events ?? [],source.data?.org)}))
|
|
58
|
+
return {
|
|
59
|
+
destinations:inventories.flatMap(source=>source.destinations.map(row=>({...row,location:source.location}))),
|
|
60
|
+
blocked:inventories.flatMap(source=>source.blocked.map(row=>({...row,location:source.location}))),
|
|
61
|
+
perSandbox:new Map(inventories.flatMap(source=>[...source.perSandbox].map(([name,row])=>[resourceKey({name,location:source.location}),row]))),
|
|
62
|
+
}
|
|
63
|
+
}
|