@camstack/server 0.1.6 → 0.1.7
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/package.json +1 -1
- package/src/__tests__/addon-upload.spec.ts +58 -0
- package/src/__tests__/bulk-update-coordinator.spec.ts +286 -0
- package/src/__tests__/cap-ownership-authority.spec.ts +400 -0
- package/src/__tests__/cap-providers-bulk-update.spec.ts +388 -0
- package/src/__tests__/cap-route-adapter.spec.ts +289 -0
- package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +123 -0
- package/src/__tests__/cap-routers/capabilities-node.spec.ts +55 -0
- package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +30 -0
- package/src/__tests__/device-settings-contribution-dispatch.spec.ts +249 -0
- package/src/__tests__/framework-installer-defer-restart.spec.ts +165 -0
- package/src/__tests__/moleculer/uds-readiness.spec.ts +143 -0
- package/src/__tests__/moleculer/uds-topology.spec.ts +390 -0
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +123 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +39 -4
- package/src/__tests__/native-cap-route.spec.ts +404 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +85 -0
- package/src/__tests__/uds-addon-call-wiring.spec.ts +237 -0
- package/src/__tests__/uds-log-ingest.spec.ts +183 -0
- package/src/api/addon-upload.ts +27 -1
- package/src/api/capabilities.router.ts +1 -1
- package/src/api/core/bulk-update-coordinator.ts +302 -0
- package/src/api/core/cap-providers.ts +59 -6
- package/src/api/core/capabilities.router.ts +26 -3
- package/src/api/oauth2/oauth2-routes.ts +5 -1
- package/src/api/trpc/__tests__/client-ip.spec.ts +120 -0
- package/src/api/trpc/cap-route-error-formatter.ts +163 -0
- package/src/api/trpc/client-ip.ts +130 -0
- package/src/api/trpc/generated-cap-mounts.ts +19 -1
- package/src/api/trpc/generated-cap-routers.ts +180 -1
- package/src/api/trpc/trpc.middleware.ts +5 -1
- package/src/api/trpc/trpc.router.ts +45 -0
- package/src/core/addon/addon-call-gateway.ts +157 -0
- package/src/core/addon/addon-package.service.ts +9 -0
- package/src/core/addon/addon-registry.service.ts +364 -105
- package/src/core/addon/addon-settings-provider.ts +40 -116
- package/src/core/capability/capability.service.ts +9 -0
- package/src/core/moleculer/cap-call-fn.spec.ts +166 -0
- package/src/core/moleculer/cap-call-fn.ts +103 -0
- package/src/core/moleculer/cap-route-authority.ts +182 -0
- package/src/core/moleculer/moleculer.service.ts +380 -36
- package/src/main.ts +45 -12
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument -- The server installs @camstack/types@0.1.38 (last published) in server/backend/node_modules, while the workspace has 0.1.39 with the new BulkUpdate* types. ESLint's type-checker resolves against 0.1.38 and treats the new imports as `any`. Runtime is correct because Node module resolution walks up to root node_modules → workspace symlink. This disable mirrors the pattern in cap-providers.ts (same root cause). Will resolve when 0.1.39 is published and the local dist is synced. */
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import {
|
|
4
|
+
EventCategory,
|
|
5
|
+
type BulkUpdateItem,
|
|
6
|
+
type BulkUpdateState,
|
|
7
|
+
type BulkUpdatePhase,
|
|
8
|
+
type BulkUpdateItemStatus,
|
|
9
|
+
} from '@camstack/types'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Narrow event-bus interface required by BulkUpdateCoordinator.
|
|
13
|
+
* Intentionally narrower than IEventBus: the coordinator only emits a single
|
|
14
|
+
* topic, so we avoid importing the full IEventBus (which lives in @camstack/types
|
|
15
|
+
* and would pull in all event-catalog types). The full IEventBus satisfies this
|
|
16
|
+
* interface structurally, so wiring in cap-providers.ts is cast-free.
|
|
17
|
+
*/
|
|
18
|
+
export interface IBulkUpdateEventBus {
|
|
19
|
+
emit(category: EventCategory.AddonsBulkUpdateProgress, payload: BulkUpdateState): void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface IBulkUpdateLogger {
|
|
23
|
+
info(message: string, ...args: unknown[]): void
|
|
24
|
+
warn(message: string, ...args: unknown[]): void
|
|
25
|
+
error(message: string, ...args: unknown[]): void
|
|
26
|
+
debug(message: string, ...args: unknown[]): void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface BulkUpdateCoordinatorDeps {
|
|
30
|
+
readonly eventBus: IBulkUpdateEventBus
|
|
31
|
+
readonly updateAddon: (input: { name: string; version: string }) => Promise<void>
|
|
32
|
+
readonly updateFrameworkPackage: (input: { packageName: string; version: string; deferRestart: boolean }) => Promise<void>
|
|
33
|
+
readonly restartServer: (input: { confirm: true }) => Promise<void>
|
|
34
|
+
readonly logger: IBulkUpdateLogger
|
|
35
|
+
/** Injectable clock for tests. Default: `() => Date.now()`. */
|
|
36
|
+
readonly clock?: () => number
|
|
37
|
+
/** How long to keep completed state before purging. Default: 5 minutes. */
|
|
38
|
+
readonly cleanupAfterMs?: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface StartBulkUpdateInput {
|
|
42
|
+
readonly nodeId: string
|
|
43
|
+
readonly items: readonly { name: string; version: string; isSystem: boolean }[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULT_CLEANUP_AFTER_MS = 5 * 60 * 1_000
|
|
47
|
+
|
|
48
|
+
export class BulkUpdateCoordinator {
|
|
49
|
+
private readonly states = new Map<string, BulkUpdateState>()
|
|
50
|
+
private readonly cancelFlags = new Map<string, { cancelled: boolean }>()
|
|
51
|
+
/**
|
|
52
|
+
* Tracks wall-clock time (ms) when each bulk completed. Used for lazy
|
|
53
|
+
* cleanup in `get()` — avoids scheduling a fake-timer `setTimeout` that
|
|
54
|
+
* would be eagerly fired by `vi.runAllTimersAsync()` in tests.
|
|
55
|
+
*/
|
|
56
|
+
private readonly completedWallMs = new Map<string, number>()
|
|
57
|
+
/** Tracks which nodeIds currently have an active (non-completed) bulk update. */
|
|
58
|
+
private readonly activeNodeIds = new Set<string>()
|
|
59
|
+
|
|
60
|
+
private readonly now: () => number
|
|
61
|
+
private readonly cleanupAfterMs: number
|
|
62
|
+
/** Wall-clock source. Fake timers intercept `Date.now`, so tests can advance via `advanceTimersByTimeAsync`. */
|
|
63
|
+
private readonly wallNow: () => number
|
|
64
|
+
|
|
65
|
+
constructor(private readonly deps: BulkUpdateCoordinatorDeps) {
|
|
66
|
+
this.now = deps.clock ?? (() => Date.now())
|
|
67
|
+
this.cleanupAfterMs = deps.cleanupAfterMs ?? DEFAULT_CLEANUP_AFTER_MS
|
|
68
|
+
this.wallNow = () => Date.now()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Public API ────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
start(input: StartBulkUpdateInput): { id: string } {
|
|
74
|
+
if (this.activeNodeIds.has(input.nodeId)) {
|
|
75
|
+
throw new Error(`Bulk update already in progress for node ${input.nodeId}`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const id = randomUUID()
|
|
79
|
+
|
|
80
|
+
const items: BulkUpdateItem[] = input.items.map(i => ({
|
|
81
|
+
name: i.name,
|
|
82
|
+
isSystem: i.isSystem,
|
|
83
|
+
// fromVersion: the cap interface receives name+version+isSystem only;
|
|
84
|
+
// the caller (cap-providers.ts) may enrich this with the current version
|
|
85
|
+
// if available. Empty string is acceptable per plan spec.
|
|
86
|
+
fromVersion: '',
|
|
87
|
+
toVersion: i.version,
|
|
88
|
+
status: 'queued' as BulkUpdateItemStatus,
|
|
89
|
+
}))
|
|
90
|
+
|
|
91
|
+
const state: BulkUpdateState = {
|
|
92
|
+
id,
|
|
93
|
+
nodeId: input.nodeId,
|
|
94
|
+
startedAtMs: this.now(),
|
|
95
|
+
total: items.length,
|
|
96
|
+
completed: 0,
|
|
97
|
+
failed: 0,
|
|
98
|
+
current: null,
|
|
99
|
+
phase: 'regular',
|
|
100
|
+
cancelled: false,
|
|
101
|
+
items,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this.states.set(id, state)
|
|
105
|
+
this.activeNodeIds.add(input.nodeId)
|
|
106
|
+
|
|
107
|
+
const cancelFlag = { cancelled: false }
|
|
108
|
+
this.cancelFlags.set(id, cancelFlag)
|
|
109
|
+
|
|
110
|
+
// Emit initial state so clients see the bulk as started immediately
|
|
111
|
+
this.emit(state)
|
|
112
|
+
|
|
113
|
+
void this.runLoop(id, cancelFlag).catch(err => {
|
|
114
|
+
this.deps.logger.error('BulkUpdateCoordinator: loop crashed unexpectedly', err)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
return { id }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
get(id: string): BulkUpdateState | null {
|
|
121
|
+
const state = this.states.get(id)
|
|
122
|
+
if (state === undefined) return null
|
|
123
|
+
// Lazy cleanup: purge if the wall-clock elapsed since completion exceeds threshold.
|
|
124
|
+
// This avoids scheduling a long-lived setTimeout that would be eagerly fired
|
|
125
|
+
// by vi.runAllTimersAsync() in tests.
|
|
126
|
+
const completedWall = this.completedWallMs.get(id)
|
|
127
|
+
if (completedWall !== undefined && this.wallNow() - completedWall >= this.cleanupAfterMs) {
|
|
128
|
+
this.purge(id)
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
return state
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
list(nodeId?: string): readonly BulkUpdateState[] {
|
|
135
|
+
const all = [...this.states.keys()]
|
|
136
|
+
.map(id => this.get(id)) // get() applies lazy-cleanup
|
|
137
|
+
.filter((s): s is BulkUpdateState => s !== null)
|
|
138
|
+
return nodeId === undefined ? all : all.filter(s => s.nodeId === nodeId)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
cancel(id: string): { cancelled: boolean } {
|
|
142
|
+
const state = this.states.get(id)
|
|
143
|
+
const flag = this.cancelFlags.get(id)
|
|
144
|
+
if (state === undefined || flag === undefined) return { cancelled: false }
|
|
145
|
+
// Once restarting, the hub restart is committed — cancel has no effect.
|
|
146
|
+
if (state.phase === 'restarting') return { cancelled: false }
|
|
147
|
+
// Already completed.
|
|
148
|
+
if (state.completedAtMs !== undefined) return { cancelled: false }
|
|
149
|
+
|
|
150
|
+
flag.cancelled = true
|
|
151
|
+
this.mutate(id, s => ({ ...s, cancelled: true }))
|
|
152
|
+
return { cancelled: true }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Internal loop ─────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
private async runLoop(id: string, cancelFlag: { cancelled: boolean }): Promise<void> {
|
|
158
|
+
const initial = this.states.get(id)!
|
|
159
|
+
|
|
160
|
+
// ── Phase 1: regular addons ──────────────────────────────────────
|
|
161
|
+
this.transitionPhase(id, 'regular')
|
|
162
|
+
|
|
163
|
+
for (const item of initial.items.filter(i => !i.isSystem)) {
|
|
164
|
+
if (cancelFlag.cancelled) break
|
|
165
|
+
await this.processItem(id, item, false)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Phase 2: system packages (deferRestart: true) ────────────────
|
|
169
|
+
if (!cancelFlag.cancelled && initial.items.some(i => i.isSystem)) {
|
|
170
|
+
this.transitionPhase(id, 'system')
|
|
171
|
+
|
|
172
|
+
for (const item of initial.items.filter(i => i.isSystem)) {
|
|
173
|
+
if (cancelFlag.cancelled) break
|
|
174
|
+
await this.processItem(id, item, true)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Phase 3: single restart ──────────────────────────────────
|
|
178
|
+
const anySystemPendingRestart = this.states.get(id)!.items.some(
|
|
179
|
+
i => i.isSystem && i.status === 'done-pending-restart',
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if (anySystemPendingRestart && !cancelFlag.cancelled) {
|
|
183
|
+
this.transitionPhase(id, 'restarting')
|
|
184
|
+
try {
|
|
185
|
+
await this.deps.restartServer({ confirm: true })
|
|
186
|
+
// NOTE: In production, restartServer kills+respawns the hub process.
|
|
187
|
+
// Code below this point will not execute in that scenario.
|
|
188
|
+
// If the mock/stub returns (e.g. in tests), we fall through to finalizing.
|
|
189
|
+
} catch (err) {
|
|
190
|
+
// Restart failed but the npm installs already completed. Promote all
|
|
191
|
+
// done-pending-restart items to done with a caveat error so the UI
|
|
192
|
+
// can inform the user that a manual restart is needed.
|
|
193
|
+
this.deps.logger.error('BulkUpdateCoordinator: restart failed', err)
|
|
194
|
+
const errMsg = err instanceof Error ? err.message : String(err)
|
|
195
|
+
for (const it of this.states.get(id)!.items) {
|
|
196
|
+
if (it.status === 'done-pending-restart') {
|
|
197
|
+
this.setItemStatus(id, it.name, 'done', {
|
|
198
|
+
error: `Restart failed; manual restart required (${errMsg})`,
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Phase 4: finalize ────────────────────────────────────────────
|
|
207
|
+
// Reached when:
|
|
208
|
+
// a) no system packages at all, OR
|
|
209
|
+
// b) restart failed (process continued), OR
|
|
210
|
+
// c) cancelled before the restart phase.
|
|
211
|
+
this.transitionPhase(id, 'finalizing')
|
|
212
|
+
this.completeBulk(id)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private async processItem(id: string, item: BulkUpdateItem, isSystem: boolean): Promise<void> {
|
|
216
|
+
this.setItemStatus(id, item.name, 'updating', { startedAtMs: this.now() })
|
|
217
|
+
this.mutate(id, s => ({ ...s, current: item.name }))
|
|
218
|
+
this.emit(this.states.get(id)!)
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
if (isSystem) {
|
|
222
|
+
await this.deps.updateFrameworkPackage({
|
|
223
|
+
packageName: item.name,
|
|
224
|
+
version: item.toVersion,
|
|
225
|
+
deferRestart: true,
|
|
226
|
+
})
|
|
227
|
+
this.setItemStatus(id, item.name, 'done-pending-restart', { completedAtMs: this.now() })
|
|
228
|
+
} else {
|
|
229
|
+
await this.deps.updateAddon({ name: item.name, version: item.toVersion })
|
|
230
|
+
this.setItemStatus(id, item.name, 'done', { completedAtMs: this.now() })
|
|
231
|
+
}
|
|
232
|
+
} catch (err) {
|
|
233
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
234
|
+
this.setItemStatus(id, item.name, 'failed', { error: msg, completedAtMs: this.now() })
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
this.mutate(id, s => ({ ...s, current: null }))
|
|
238
|
+
this.emit(this.states.get(id)!)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── State mutation helpers ────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
private setItemStatus(
|
|
244
|
+
id: string,
|
|
245
|
+
name: string,
|
|
246
|
+
status: BulkUpdateItemStatus,
|
|
247
|
+
fields: { error?: string; startedAtMs?: number; completedAtMs?: number } = {},
|
|
248
|
+
): void {
|
|
249
|
+
this.mutate(id, s => {
|
|
250
|
+
const items = s.items.map(it =>
|
|
251
|
+
it.name === name ? { ...it, status, ...fields } : it,
|
|
252
|
+
)
|
|
253
|
+
// completed = all terminal states: done | done-pending-restart | failed
|
|
254
|
+
const completed = items.filter(
|
|
255
|
+
it => it.status === 'done' || it.status === 'done-pending-restart' || it.status === 'failed',
|
|
256
|
+
).length
|
|
257
|
+
const failed = items.filter(it => it.status === 'failed').length
|
|
258
|
+
return { ...s, items, completed, failed }
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private transitionPhase(id: string, phase: BulkUpdatePhase): void {
|
|
263
|
+
this.mutate(id, s => ({ ...s, phase }))
|
|
264
|
+
this.emit(this.states.get(id)!)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private completeBulk(id: string): void {
|
|
268
|
+
this.mutate(id, s => ({ ...s, completedAtMs: this.now(), current: null }))
|
|
269
|
+
this.emit(this.states.get(id)!)
|
|
270
|
+
|
|
271
|
+
// Free the nodeId slot so a new bulk for the same node can be started
|
|
272
|
+
const nodeId = this.states.get(id)!.nodeId
|
|
273
|
+
this.activeNodeIds.delete(nodeId)
|
|
274
|
+
|
|
275
|
+
// Record wall-clock completion time for lazy cleanup in `get()`.
|
|
276
|
+
// We intentionally avoid scheduling a setTimeout here: a long-lived
|
|
277
|
+
// setTimeout (5 min) would be eagerly fired by vi.runAllTimersAsync()
|
|
278
|
+
// in tests, causing `get()` to return null immediately after the run.
|
|
279
|
+
// Instead, `get()` lazily checks whether the cleanup threshold has
|
|
280
|
+
// elapsed using Date.now() — which fake timers DO advance via
|
|
281
|
+
// advanceTimersByTimeAsync(), making the cleanup testable without
|
|
282
|
+
// a long-running timer.
|
|
283
|
+
this.completedWallMs.set(id, this.wallNow())
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private purge(id: string): void {
|
|
287
|
+
this.states.delete(id)
|
|
288
|
+
this.cancelFlags.delete(id)
|
|
289
|
+
this.completedWallMs.delete(id)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Immutably update the state for the given id. No-op if id is unknown. */
|
|
293
|
+
private mutate(id: string, update: (s: BulkUpdateState) => BulkUpdateState): void {
|
|
294
|
+
const current = this.states.get(id)
|
|
295
|
+
if (current === undefined) return
|
|
296
|
+
this.states.set(id, update(current))
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private emit(state: BulkUpdateState): void {
|
|
300
|
+
this.deps.eventBus.emit(EventCategory.AddonsBulkUpdateProgress, state)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -58,6 +58,8 @@ import type { AddonPackageService } from '../../core/addon/addon-package.service
|
|
|
58
58
|
import type { NetworkQualityService } from '../../core/network/network-quality.service'
|
|
59
59
|
import type { ConfigService } from '../../core/config/config.service'
|
|
60
60
|
import { persistCollectionDisabled } from './collection-preference.js'
|
|
61
|
+
import { BulkUpdateCoordinator } from './bulk-update-coordinator.js'
|
|
62
|
+
import { FRAMEWORK_PACKAGE_ALLOWLIST } from '../../core/addon/addon-package.service.js'
|
|
61
63
|
|
|
62
64
|
const execFileAsync = promisify(execFile)
|
|
63
65
|
|
|
@@ -477,9 +479,17 @@ export function buildNodesProvider(
|
|
|
477
479
|
return reg.getGraph({ windowSeconds: input.windowSeconds, nowMs: Date.now() })
|
|
478
480
|
},
|
|
479
481
|
setProcessLogLevel: async (input) => {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
482
|
+
// E2: for hub-local children, try the UDS path first (fire-and-forget);
|
|
483
|
+
// fall back to the Moleculer $node-mgmt.setLogLevel action for remote
|
|
484
|
+
// nodes (agents) that still run a per-node broker. The UDS path is always
|
|
485
|
+
// attempted for hub/<runner> nodeIds even when the Moleculer path would
|
|
486
|
+
// also work — both are safe to run in parallel during Phase E.
|
|
487
|
+
const reachedViaUds = moleculer.setChildLogLevelByNodeId(input.nodeId, input.level)
|
|
488
|
+
if (!reachedViaUds) {
|
|
489
|
+
await broker.call('$node-mgmt.setLogLevel', {
|
|
490
|
+
level: input.level,
|
|
491
|
+
}, { nodeID: input.nodeId, timeout: 5_000 })
|
|
492
|
+
}
|
|
483
493
|
return { success: true }
|
|
484
494
|
},
|
|
485
495
|
executeQuery: async (input) => {
|
|
@@ -806,8 +816,45 @@ export function buildAddonsProvider(
|
|
|
806
816
|
moleculer: MoleculerService,
|
|
807
817
|
configService: ConfigService,
|
|
808
818
|
ctx: TrpcContext,
|
|
819
|
+
eb: EventBusService,
|
|
809
820
|
): IAddonsProvider {
|
|
810
821
|
const broker = moleculer.broker as unknown as BrokerLike
|
|
822
|
+
|
|
823
|
+
// Adapt the hub EventBusService (which takes a full SystemEvent object) to
|
|
824
|
+
// the IBulkUpdateEventBus interface (which takes (category, payload) pairs).
|
|
825
|
+
// Using `import { EventCategory }` from @camstack/types avoids a new import
|
|
826
|
+
// — it is already resolved in the generated-cap-routers layer above. The
|
|
827
|
+
// `eb.emit` overload that takes a TypedSystemEvent is the type-safe path.
|
|
828
|
+
const bulkEventBus = {
|
|
829
|
+
emit: (category: Parameters<typeof eb.emit>[0]['category'], payload: unknown) => {
|
|
830
|
+
eb.emit({
|
|
831
|
+
id: randomUUID(),
|
|
832
|
+
timestamp: new Date(),
|
|
833
|
+
source: { type: 'core', id: 'bulk-update-coordinator' },
|
|
834
|
+
category,
|
|
835
|
+
data: payload as Record<string, unknown>,
|
|
836
|
+
})
|
|
837
|
+
},
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const bulkCoordinator = new BulkUpdateCoordinator({
|
|
841
|
+
eventBus: bulkEventBus,
|
|
842
|
+
updateAddon: async (i) => { await ps.updatePackage(i.name, i.version) },
|
|
843
|
+
updateFrameworkPackage: async (i) => {
|
|
844
|
+
await ps.updateFrameworkPackage({
|
|
845
|
+
packageName: i.packageName,
|
|
846
|
+
version: i.version,
|
|
847
|
+
deferRestart: i.deferRestart,
|
|
848
|
+
})
|
|
849
|
+
},
|
|
850
|
+
restartServer: async () => {
|
|
851
|
+
await ps.restartServer(ctx.user?.username ?? ctx.user?.id)
|
|
852
|
+
},
|
|
853
|
+
logger: ls.createLogger('bulk-update'),
|
|
854
|
+
})
|
|
855
|
+
|
|
856
|
+
const frameworkAllowSet = new Set<string>(FRAMEWORK_PACKAGE_ALLOWLIST)
|
|
857
|
+
|
|
811
858
|
return {
|
|
812
859
|
list: async () => {
|
|
813
860
|
const rollbackable = ps.getRollbackablePackages()
|
|
@@ -843,9 +890,10 @@ export function buildAddonsProvider(
|
|
|
843
890
|
},
|
|
844
891
|
listUpdates: async (input) => {
|
|
845
892
|
const nodeId = input.nodeId
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
893
|
+
const updates = nodeId === undefined || isHubNode(nodeId)
|
|
894
|
+
? await ps.checkUpdates()
|
|
895
|
+
: await ps.checkUpdatesForInstalled(await fetchAgentInstalledPackages(broker, nodeId))
|
|
896
|
+
return updates.map(u => ({ ...u, isSystem: frameworkAllowSet.has(u.name) }))
|
|
849
897
|
},
|
|
850
898
|
updatePackage: async (input) => {
|
|
851
899
|
const nodeId = input.nodeId
|
|
@@ -937,6 +985,7 @@ export function buildAddonsProvider(
|
|
|
937
985
|
: ctx.user?.id !== undefined
|
|
938
986
|
? { requestedBy: ctx.user.id }
|
|
939
987
|
: {}),
|
|
988
|
+
...(input.deferRestart !== undefined ? { deferRestart: input.deferRestart } : {}),
|
|
940
989
|
}),
|
|
941
990
|
getVersions: async (input) => ps.getPackageVersions(input.name),
|
|
942
991
|
restartAddon: async (input) => ar.restartAddon(input.addonId),
|
|
@@ -955,6 +1004,10 @@ export function buildAddonsProvider(
|
|
|
955
1004
|
}
|
|
956
1005
|
return { success: true as const }
|
|
957
1006
|
},
|
|
1007
|
+
startBulkUpdate: async (input) => bulkCoordinator.start(input),
|
|
1008
|
+
getBulkUpdateState: async ({ id }) => bulkCoordinator.get(id),
|
|
1009
|
+
cancelBulkUpdate: async ({ id }) => bulkCoordinator.cancel(id),
|
|
1010
|
+
listActiveBulkUpdates: async ({ nodeId }) => bulkCoordinator.list(nodeId),
|
|
958
1011
|
custom: async (input) => {
|
|
959
1012
|
const registry = ar.getCustomActionRegistry()
|
|
960
1013
|
const entry = registry.resolve(input.addonId, input.action)
|
|
@@ -28,12 +28,35 @@ export function createCapabilitiesRouter(
|
|
|
28
28
|
}),
|
|
29
29
|
|
|
30
30
|
setActiveSingleton: adminProcedure
|
|
31
|
-
.input(z.object({
|
|
31
|
+
.input(z.object({
|
|
32
|
+
capability: z.string(),
|
|
33
|
+
addonId: z.string(),
|
|
34
|
+
nodeId: z.string().optional(),
|
|
35
|
+
}))
|
|
32
36
|
.output(z.void())
|
|
33
37
|
.mutation(async ({ input }) => {
|
|
34
38
|
if (!registry) throw new Error('Capability registry unavailable')
|
|
35
|
-
await registry.setActiveSingleton(input.capability, input.addonId)
|
|
36
|
-
|
|
39
|
+
await registry.setActiveSingleton(input.capability, input.addonId, input.nodeId)
|
|
40
|
+
if (input.nodeId !== undefined) {
|
|
41
|
+
// Per-node override: persist under the node-qualified key so the boot
|
|
42
|
+
// restorer (`setNodeConfigReader`) replays it on restart.
|
|
43
|
+
config.set(`capabilities.singletonNode.${input.capability}.${input.nodeId}`, input.addonId)
|
|
44
|
+
} else {
|
|
45
|
+
// Cluster-global default: persist to the SAME key the boot restorer reads
|
|
46
|
+
// (addon-registry `setConfigReader` → `capabilities.singleton.<cap>`).
|
|
47
|
+
config.set(`capabilities.singleton.${input.capability}`, input.addonId)
|
|
48
|
+
}
|
|
49
|
+
}),
|
|
50
|
+
|
|
51
|
+
clearSingletonNodeOverride: adminProcedure
|
|
52
|
+
.input(z.object({ capability: z.string(), nodeId: z.string() }))
|
|
53
|
+
.output(z.void())
|
|
54
|
+
.mutation(({ input }) => {
|
|
55
|
+
if (!registry) throw new Error('Capability registry unavailable')
|
|
56
|
+
registry.clearSingletonNodeOverride(input.capability, input.nodeId)
|
|
57
|
+
// Persist the cleared state as null so the boot restorer doesn't re-apply
|
|
58
|
+
// a stale override on restart.
|
|
59
|
+
config.set(`capabilities.singletonNode.${input.capability}.${input.nodeId}`, null)
|
|
37
60
|
}),
|
|
38
61
|
|
|
39
62
|
/**
|
|
@@ -196,7 +196,11 @@ export function registerOauth2Routes(fastify: FastifyInstance, deps: Oauth2Deps)
|
|
|
196
196
|
username: tokenInfo.username ?? '',
|
|
197
197
|
scopes: descriptor.requestedScopes,
|
|
198
198
|
redirectUri: v.redirectUri,
|
|
199
|
-
|
|
199
|
+
// Prefer the integration's own public origin (e.g. the operator-selected
|
|
200
|
+
// external-access endpoint surfaced by a forked exporter addon) so the
|
|
201
|
+
// claim the cloud Lambda routes back on is the reachable public URL, not
|
|
202
|
+
// the hub-global fallback (which defaults to localhost in dev).
|
|
203
|
+
hubUrl: descriptor.hubUrl ?? deps.publicHubUrl(),
|
|
200
204
|
})
|
|
201
205
|
|
|
202
206
|
return reply.redirect(`${v.redirectUri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(v.state)}`)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the client-IP extraction + LAN/remote classification
|
|
3
|
+
* used by the `webrtcSession.createSession` relay-only override.
|
|
4
|
+
*
|
|
5
|
+
* The classification is the load-bearing decision: a `true` from
|
|
6
|
+
* `isRemoteClientIp` forces TURN-relay-only ICE for that live-view
|
|
7
|
+
* session. A false positive would needlessly relay a LAN viewer
|
|
8
|
+
* (latency); a false negative would leave a remote viewer on the dead
|
|
9
|
+
* direct path (the bug being fixed). The private-range edges are
|
|
10
|
+
* therefore exercised explicitly.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect } from 'vitest'
|
|
13
|
+
import type { IncomingMessage } from 'node:http'
|
|
14
|
+
import { extractClientIp, isRemoteClientIp } from '../client-ip.js'
|
|
15
|
+
|
|
16
|
+
function reqWith(opts: {
|
|
17
|
+
xff?: string | string[]
|
|
18
|
+
ip?: string
|
|
19
|
+
remoteAddress?: string
|
|
20
|
+
}): IncomingMessage {
|
|
21
|
+
const headers: Record<string, string | string[]> = {}
|
|
22
|
+
if (opts.xff !== undefined) headers['x-forwarded-for'] = opts.xff
|
|
23
|
+
const req = {
|
|
24
|
+
headers,
|
|
25
|
+
socket: { remoteAddress: opts.remoteAddress },
|
|
26
|
+
} as unknown as IncomingMessage
|
|
27
|
+
if (opts.ip !== undefined) {
|
|
28
|
+
Object.defineProperty(req, 'ip', { value: opts.ip, enumerable: true })
|
|
29
|
+
}
|
|
30
|
+
return req
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('extractClientIp', () => {
|
|
34
|
+
it('returns null for an absent request (mesh-originated call)', () => {
|
|
35
|
+
expect(extractClientIp(undefined)).toBeNull()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('prefers the first X-Forwarded-For hop over socket/ip', () => {
|
|
39
|
+
const req = reqWith({ xff: '203.0.113.7, 10.0.0.1', ip: '10.0.0.1', remoteAddress: '10.0.0.1' })
|
|
40
|
+
expect(extractClientIp(req)).toBe('203.0.113.7')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('handles X-Forwarded-For as an array', () => {
|
|
44
|
+
const req = reqWith({ xff: ['198.51.100.4, 10.0.0.1'] })
|
|
45
|
+
expect(extractClientIp(req)).toBe('198.51.100.4')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('falls back to req.ip when no XFF', () => {
|
|
49
|
+
const req = reqWith({ ip: '192.168.1.50', remoteAddress: '192.168.1.50' })
|
|
50
|
+
expect(extractClientIp(req)).toBe('192.168.1.50')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('falls back to socket.remoteAddress when no XFF or ip', () => {
|
|
54
|
+
const req = reqWith({ remoteAddress: '127.0.0.1' })
|
|
55
|
+
expect(extractClientIp(req)).toBe('127.0.0.1')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('strips IPv4-mapped IPv6 prefix', () => {
|
|
59
|
+
const req = reqWith({ remoteAddress: '::ffff:192.168.1.5' })
|
|
60
|
+
expect(extractClientIp(req)).toBe('192.168.1.5')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('strips IPv6 zone id', () => {
|
|
64
|
+
const req = reqWith({ remoteAddress: 'fe80::1%en0' })
|
|
65
|
+
expect(extractClientIp(req)).toBe('fe80::1')
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('isRemoteClientIp', () => {
|
|
70
|
+
it('null → false (treated as LAN — safe default)', () => {
|
|
71
|
+
expect(isRemoteClientIp(null)).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// Private / loopback / link-local / Tailscale ranges → LAN (direct path kept)
|
|
75
|
+
const lan = [
|
|
76
|
+
'10.0.0.1',
|
|
77
|
+
'10.255.255.255',
|
|
78
|
+
'172.16.0.1',
|
|
79
|
+
'172.31.255.255',
|
|
80
|
+
'192.168.1.1',
|
|
81
|
+
'127.0.0.1',
|
|
82
|
+
'169.254.1.1',
|
|
83
|
+
// Tailscale CGNAT overlay (100.64.0.0/10) — direct host↔host over the mesh
|
|
84
|
+
'100.64.0.1', // bottom of 100.64/10
|
|
85
|
+
'100.104.179.3', // the hub's own Tailscale IP (confirmed live)
|
|
86
|
+
'100.127.255.255', // top of 100.64/10
|
|
87
|
+
'::1',
|
|
88
|
+
'fe80::1',
|
|
89
|
+
'fc00::1',
|
|
90
|
+
'fd12:3456::1',
|
|
91
|
+
// Tailscale ULA overlay (fd7a::/16) — subset of fc00::/7
|
|
92
|
+
'fd7a:115c:a1e0::1',
|
|
93
|
+
]
|
|
94
|
+
for (const ip of lan) {
|
|
95
|
+
it(`LAN: ${ip} → not remote`, () => {
|
|
96
|
+
expect(isRemoteClientIp(ip)).toBe(false)
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Public ranges → remote (relay-only forced)
|
|
101
|
+
const remote = [
|
|
102
|
+
'203.0.113.7', // TEST-NET-3 (public)
|
|
103
|
+
'8.8.8.8',
|
|
104
|
+
'172.15.0.1', // just below the 172.16/12 private block
|
|
105
|
+
'172.32.0.1', // just above the 172.16/12 private block
|
|
106
|
+
'11.0.0.1', // just above 10/8
|
|
107
|
+
'100.63.255.255', // just below the 100.64/10 Tailscale block (public)
|
|
108
|
+
'100.128.0.1', // just above the 100.64/10 Tailscale block (public)
|
|
109
|
+
'2001:4860:4860::8888', // public IPv6
|
|
110
|
+
]
|
|
111
|
+
for (const ip of remote) {
|
|
112
|
+
it(`remote: ${ip} → remote`, () => {
|
|
113
|
+
expect(isRemoteClientIp(ip)).toBe(true)
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
it('unparseable literal → false (conservative LAN default)', () => {
|
|
118
|
+
expect(isRemoteClientIp('not-an-ip')).toBe(false)
|
|
119
|
+
})
|
|
120
|
+
})
|