@ceralive/modem-control 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +31 -0
- package/src/backend/at-lease.test.ts +106 -0
- package/src/backend/at-lease.ts +158 -0
- package/src/backend/cell-info.test.ts +154 -0
- package/src/backend/cell-info.ts +160 -0
- package/src/backend/constants.ts +35 -0
- package/src/backend/device-classifier.test.ts +168 -0
- package/src/backend/device-classifier.ts +240 -0
- package/src/backend/enrichment.ts +96 -0
- package/src/backend/features.test.ts +158 -0
- package/src/backend/features.ts +179 -0
- package/src/backend/identity-ladder.test.ts +117 -0
- package/src/backend/identity-ladder.ts +221 -0
- package/src/backend/identity-registry.test.ts +89 -0
- package/src/backend/identity-registry.ts +151 -0
- package/src/backend/index.ts +221 -0
- package/src/backend/lifecycle-interlock.ts +38 -0
- package/src/backend/managed-objects.ts +108 -0
- package/src/backend/mapping.ts +160 -0
- package/src/backend/mm-backend.ts +191 -0
- package/src/backend/mm-mutations.ts +228 -0
- package/src/backend/modem-actor.test.ts +95 -0
- package/src/backend/modem-actor.ts +112 -0
- package/src/backend/nm-auto-apn.ts +161 -0
- package/src/backend/nm-gsm-fields.ts +122 -0
- package/src/backend/nmcli-nm-port.ts +228 -0
- package/src/backend/nmcli-runner.ts +52 -0
- package/src/backend/observer.ts +297 -0
- package/src/backend/power-contract.test.ts +40 -0
- package/src/backend/power-contract.ts +83 -0
- package/src/backend/recovery-attribution.test.ts +102 -0
- package/src/backend/recovery-attribution.ts +86 -0
- package/src/backend/recovery-budget.test.ts +64 -0
- package/src/backend/recovery-budget.ts +84 -0
- package/src/backend/recovery-ladder.test.ts +257 -0
- package/src/backend/recovery-ladder.ts +249 -0
- package/src/backend/router-ethernet.test.ts +71 -0
- package/src/backend/router-ethernet.ts +90 -0
- package/src/backend/row-store.ts +105 -0
- package/src/backend/signal-setup.ts +112 -0
- package/src/backend/sim-unlock.ts +193 -0
- package/src/backend/transition-preconditions.ts +149 -0
- package/src/backend/usage/accounting.test.ts +147 -0
- package/src/backend/usage/accounting.ts +123 -0
- package/src/backend/usage/billing-cycle.test.ts +62 -0
- package/src/backend/usage/billing-cycle.ts +45 -0
- package/src/backend/usage/boot-id.ts +18 -0
- package/src/backend/usage/index.ts +37 -0
- package/src/backend/usage/proc-net-dev.test.ts +56 -0
- package/src/backend/usage/proc-net-dev.ts +77 -0
- package/src/backend/usage/sampler.test.ts +219 -0
- package/src/backend/usage/sampler.ts +228 -0
- package/src/backend/usage/store.test.ts +148 -0
- package/src/backend/usage/store.ts +177 -0
- package/src/backend/usb-enumerator.test.ts +87 -0
- package/src/backend/usb-enumerator.ts +181 -0
- package/src/backend/usb-mode-transition.test.ts +323 -0
- package/src/backend/usb-mode-transition.ts +253 -0
- package/src/domain/brand.ts +29 -0
- package/src/domain/errors.ts +77 -0
- package/src/domain/guards.test.ts +218 -0
- package/src/domain/guards.ts +144 -0
- package/src/domain/identity.test.ts +83 -0
- package/src/domain/identity.ts +165 -0
- package/src/domain/index.ts +12 -0
- package/src/domain/policy.ts +137 -0
- package/src/domain/snapshot.test.ts +266 -0
- package/src/domain/snapshot.ts +120 -0
- package/src/domain/state.ts +189 -0
- package/src/index.test.ts +6 -0
- package/src/index.ts +15 -0
- package/src/ports/README.md +61 -0
- package/src/ports/forbidden-surface.test.ts +80 -0
- package/src/ports/index.ts +16 -0
- package/src/ports/modem-manager.ts +72 -0
- package/src/ports/network-manager.ts +87 -0
- package/src/ports/observation.ts +48 -0
- package/src/ports/ops.ts +60 -0
- package/src/ports/ops.type-test.ts +39 -0
- package/src/ports/receipts.test.ts +153 -0
- package/src/ports/receipts.ts +43 -0
- package/src/ports/reconcile.test.ts +152 -0
- package/src/ports/reconcile.ts +338 -0
- package/src/ports/router.ts +29 -0
- package/src/redact.test.ts +82 -0
- package/src/redact.ts +73 -0
- package/src/transport/README.md +65 -0
- package/src/transport/codec.test.ts +118 -0
- package/src/transport/codec.ts +240 -0
- package/src/transport/conformance-python.test.ts +152 -0
- package/src/transport/conformance-same-lib.test.ts +115 -0
- package/src/transport/dbus-native-lib.d.ts +19 -0
- package/src/transport/dbus-native.ts +85 -0
- package/src/transport/errors.ts +74 -0
- package/src/transport/index.ts +30 -0
- package/src/transport/no-library-leak.test.ts +50 -0
- package/src/transport/reliability.test.ts +173 -0
- package/src/transport/signature.ts +110 -0
- package/src/transport/test-support/fake-service.ts +143 -0
- package/src/transport/test-support/independent-producer.py +110 -0
- package/src/transport/test-support/private-bus.ts +66 -0
- package/src/transport/transport.ts +439 -0
- package/src/transport/types.ts +118 -0
- package/src/usb-mode/catalog-schema.test.ts +181 -0
- package/src/usb-mode/catalog-schema.ts +113 -0
- package/src/usb-mode/catalog.ts +60 -0
- package/src/usb-mode/certified-catalog.json +67 -0
- package/src/usb-mode/index.ts +27 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// The desired-state planner — pure. Given the currently-applied state, a desired
|
|
2
|
+
// policy, and the modem's capabilities, it produces the port-tagged ops needed to
|
|
3
|
+
// converge PLUS an honest receipt per policy dimension. It performs no I/O and no
|
|
4
|
+
// side effects: the same inputs always yield the same ops and receipts, which is
|
|
5
|
+
// what makes re-applying a policy idempotent (see reconcile.test.ts).
|
|
6
|
+
|
|
7
|
+
import type { DesiredCellularPolicy, NmActivation, RadioAccessTechnology } from '../domain';
|
|
8
|
+
import type { ConnectionId, DeviceIfname, GsmProfileInput } from './network-manager';
|
|
9
|
+
import { mmOp, nmOp, type PortTaggedOp } from './ops';
|
|
10
|
+
import { type Receipt, receipt } from './receipts';
|
|
11
|
+
|
|
12
|
+
/** What the modem / stack can actually do — the capability set the planner honors. */
|
|
13
|
+
export interface ModemCapabilities {
|
|
14
|
+
readonly supportedRats: ReadonlySet<RadioAccessTechnology>;
|
|
15
|
+
readonly simSlotCount: number;
|
|
16
|
+
readonly supportsAutoApn: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The currently-applied cellular configuration, as the planner sees it — the "is"
|
|
21
|
+
* state, distinct from the observational snapshot: it tracks what has actually been
|
|
22
|
+
* written (profile, radio preference, primary slot) plus the live NM activation.
|
|
23
|
+
*/
|
|
24
|
+
export interface AppliedCellularState {
|
|
25
|
+
readonly nmActivation: NmActivation;
|
|
26
|
+
readonly hasProfile: boolean;
|
|
27
|
+
readonly connectionId?: ConnectionId;
|
|
28
|
+
readonly deviceIfname?: DeviceIfname;
|
|
29
|
+
readonly appliedApn?: 'auto' | string;
|
|
30
|
+
readonly appliedRoaming?: boolean;
|
|
31
|
+
readonly appliedRadioPreference?: readonly RadioAccessTechnology[];
|
|
32
|
+
readonly activePrimarySlot?: number;
|
|
33
|
+
readonly activationFailureReason?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A reconcile plan: the ops to run and one receipt per policy dimension. */
|
|
37
|
+
export interface Plan {
|
|
38
|
+
readonly ops: readonly PortTaggedOp[];
|
|
39
|
+
readonly receipts: readonly Receipt[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface DimensionResult {
|
|
43
|
+
readonly receipt: Receipt;
|
|
44
|
+
readonly op?: PortTaggedOp;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface ProfilePlan {
|
|
48
|
+
readonly connection: Receipt;
|
|
49
|
+
readonly roaming: Receipt;
|
|
50
|
+
readonly op?: PortTaggedOp;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Reconcile a desired policy against the applied state into ops + receipts. */
|
|
54
|
+
export function planReconcile(
|
|
55
|
+
current: AppliedCellularState,
|
|
56
|
+
desired: DesiredCellularPolicy,
|
|
57
|
+
capabilities: ModemCapabilities,
|
|
58
|
+
): Plan {
|
|
59
|
+
const profile = planNmProfile(current, desired, capabilities);
|
|
60
|
+
const results: readonly DimensionResult[] = [
|
|
61
|
+
planRadio(current, desired, capabilities),
|
|
62
|
+
planSimSlot(current, desired, capabilities),
|
|
63
|
+
planEnabled(current, desired),
|
|
64
|
+
{ receipt: planRecovery(desired) },
|
|
65
|
+
{ receipt: planUsage(desired) },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
const ops: PortTaggedOp[] = [];
|
|
69
|
+
const receipts: Receipt[] = [profile.connection, profile.roaming];
|
|
70
|
+
if (profile.op !== undefined) {
|
|
71
|
+
ops.push(profile.op);
|
|
72
|
+
}
|
|
73
|
+
for (const result of results) {
|
|
74
|
+
receipts.push(result.receipt);
|
|
75
|
+
if (result.op !== undefined) {
|
|
76
|
+
ops.push(result.op);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { ops, receipts };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function profileFromPolicy(desired: DesiredCellularPolicy): GsmProfileInput {
|
|
83
|
+
const auto = desired.connection.apn === 'auto';
|
|
84
|
+
const base: GsmProfileInput = {
|
|
85
|
+
connectionName: 'ceralive-cellular',
|
|
86
|
+
apn: auto ? '' : desired.connection.apn,
|
|
87
|
+
homeOnly: !desired.roaming,
|
|
88
|
+
autoConfig: auto,
|
|
89
|
+
};
|
|
90
|
+
// SENSITIVE creds only when explicitly provided AND not in auto-config mode.
|
|
91
|
+
if (!auto && desired.connection.auth !== undefined) {
|
|
92
|
+
const { username, password } = desired.connection.auth;
|
|
93
|
+
return {
|
|
94
|
+
...base,
|
|
95
|
+
...(username !== undefined ? { username } : {}),
|
|
96
|
+
...(password !== undefined ? { password } : {}),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return base;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// NM owns APN / auth / roaming / autoconnect: one profile write converges all of
|
|
103
|
+
// them, so connection + roaming share a single op and each get their own receipt.
|
|
104
|
+
function planNmProfile(
|
|
105
|
+
current: AppliedCellularState,
|
|
106
|
+
desired: DesiredCellularPolicy,
|
|
107
|
+
capabilities: ModemCapabilities,
|
|
108
|
+
): ProfilePlan {
|
|
109
|
+
const auto = desired.connection.apn === 'auto';
|
|
110
|
+
if (auto && !capabilities.supportsAutoApn) {
|
|
111
|
+
return {
|
|
112
|
+
connection: receipt(
|
|
113
|
+
'connection',
|
|
114
|
+
'unsupported',
|
|
115
|
+
'Auto-APN is not available on this NetworkManager / modem',
|
|
116
|
+
),
|
|
117
|
+
roaming: receipt(
|
|
118
|
+
'roaming',
|
|
119
|
+
'unsupported',
|
|
120
|
+
'roaming cannot be applied without a connection profile',
|
|
121
|
+
),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const needsWrite =
|
|
125
|
+
!current.hasProfile ||
|
|
126
|
+
current.appliedApn !== desired.connection.apn ||
|
|
127
|
+
current.appliedRoaming !== desired.roaming;
|
|
128
|
+
if (!needsWrite) {
|
|
129
|
+
return {
|
|
130
|
+
connection: receipt(
|
|
131
|
+
'connection',
|
|
132
|
+
'applied',
|
|
133
|
+
`connection APN '${desired.connection.apn}' already configured`,
|
|
134
|
+
),
|
|
135
|
+
roaming: receipt(
|
|
136
|
+
'roaming',
|
|
137
|
+
'applied',
|
|
138
|
+
`roaming already ${desired.roaming ? 'enabled' : 'disabled'}`,
|
|
139
|
+
),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const op =
|
|
143
|
+
current.hasProfile && current.connectionId !== undefined
|
|
144
|
+
? nmOp({
|
|
145
|
+
kind: 'updateGsmProfile',
|
|
146
|
+
connectionId: current.connectionId,
|
|
147
|
+
patch: profileFromPolicy(desired),
|
|
148
|
+
})
|
|
149
|
+
: nmOp({ kind: 'createGsmProfile', profile: profileFromPolicy(desired) });
|
|
150
|
+
const verb = current.hasProfile ? 'updating' : 'creating';
|
|
151
|
+
return {
|
|
152
|
+
connection: receipt(
|
|
153
|
+
'connection',
|
|
154
|
+
'pending',
|
|
155
|
+
`${verb} connection profile for APN '${desired.connection.apn}'`,
|
|
156
|
+
),
|
|
157
|
+
roaming: receipt(
|
|
158
|
+
'roaming',
|
|
159
|
+
'pending',
|
|
160
|
+
`roaming ${desired.roaming ? 'enabled' : 'disabled'} via connection profile`,
|
|
161
|
+
),
|
|
162
|
+
op,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function radioMatches(
|
|
167
|
+
applied: readonly RadioAccessTechnology[] | undefined,
|
|
168
|
+
desired: readonly RadioAccessTechnology[],
|
|
169
|
+
): boolean {
|
|
170
|
+
if (applied === undefined || applied.length !== desired.length) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
return applied.every((rat, index) => rat === desired[index]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function planRadio(
|
|
177
|
+
current: AppliedCellularState,
|
|
178
|
+
desired: DesiredCellularPolicy,
|
|
179
|
+
capabilities: ModemCapabilities,
|
|
180
|
+
): DimensionResult {
|
|
181
|
+
const preference = desired.radio.preferenceOrdered;
|
|
182
|
+
const top = preference[0];
|
|
183
|
+
if (top === undefined) {
|
|
184
|
+
return {
|
|
185
|
+
receipt: receipt(
|
|
186
|
+
'radio',
|
|
187
|
+
'failed',
|
|
188
|
+
'radio preference must list at least one access technology',
|
|
189
|
+
),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
if (!capabilities.supportedRats.has(top)) {
|
|
193
|
+
const supported = [...capabilities.supportedRats].join(', ') || 'none';
|
|
194
|
+
return {
|
|
195
|
+
receipt: receipt(
|
|
196
|
+
'radio',
|
|
197
|
+
'unsupported',
|
|
198
|
+
`preferred radio access technology '${top}' is not supported by this modem (supports ${supported})`,
|
|
199
|
+
),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (radioMatches(current.appliedRadioPreference, preference)) {
|
|
203
|
+
return {
|
|
204
|
+
receipt: receipt('radio', 'applied', `radio preference already ${preference.join(' > ')}`),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
receipt: receipt('radio', 'pending', `setting radio preference to ${preference.join(' > ')}`),
|
|
209
|
+
op: mmOp({ kind: 'setRadioModes', preference: desired.radio }),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function planSimSlot(
|
|
214
|
+
current: AppliedCellularState,
|
|
215
|
+
desired: DesiredCellularPolicy,
|
|
216
|
+
capabilities: ModemCapabilities,
|
|
217
|
+
): DimensionResult {
|
|
218
|
+
if (desired.simSlot === undefined) {
|
|
219
|
+
return { receipt: receipt('simSlot', 'applied', 'no primary SIM slot preference set') };
|
|
220
|
+
}
|
|
221
|
+
if (capabilities.simSlotCount <= 1) {
|
|
222
|
+
return {
|
|
223
|
+
receipt: receipt(
|
|
224
|
+
'simSlot',
|
|
225
|
+
'unsupported',
|
|
226
|
+
`primary SIM slot selection requires a multi-slot modem (this modem has ${capabilities.simSlotCount})`,
|
|
227
|
+
),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
if (desired.simSlot < 1 || desired.simSlot > capabilities.simSlotCount) {
|
|
231
|
+
return {
|
|
232
|
+
receipt: receipt(
|
|
233
|
+
'simSlot',
|
|
234
|
+
'failed',
|
|
235
|
+
`SIM slot ${desired.simSlot} is out of range (1..${capabilities.simSlotCount})`,
|
|
236
|
+
),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
if (current.activePrimarySlot === desired.simSlot) {
|
|
240
|
+
return {
|
|
241
|
+
receipt: receipt('simSlot', 'applied', `SIM slot ${desired.simSlot} already primary`),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
receipt: receipt('simSlot', 'pending', `switching primary SIM slot to ${desired.simSlot}`),
|
|
246
|
+
op: mmOp({ kind: 'setPrimarySimSlot', slotIndex: desired.simSlot }),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function planEnabled(
|
|
251
|
+
current: AppliedCellularState,
|
|
252
|
+
desired: DesiredCellularPolicy,
|
|
253
|
+
): DimensionResult {
|
|
254
|
+
const nm = current.nmActivation;
|
|
255
|
+
const addressable = current.connectionId !== undefined && current.deviceIfname !== undefined;
|
|
256
|
+
if (desired.enabled) {
|
|
257
|
+
switch (nm) {
|
|
258
|
+
case 'activated':
|
|
259
|
+
return { receipt: receipt('enabled', 'applied', 'connection is active') };
|
|
260
|
+
case 'activating':
|
|
261
|
+
return { receipt: receipt('enabled', 'pending', 'connection activation in progress') };
|
|
262
|
+
case 'failed':
|
|
263
|
+
return {
|
|
264
|
+
receipt: receipt(
|
|
265
|
+
'enabled',
|
|
266
|
+
'failed',
|
|
267
|
+
`connection activation failed${current.activationFailureReason ? `: ${current.activationFailureReason}` : ''}`,
|
|
268
|
+
),
|
|
269
|
+
};
|
|
270
|
+
case 'unmanaged':
|
|
271
|
+
return {
|
|
272
|
+
receipt: receipt('enabled', 'unsupported', 'device is not managed by NetworkManager'),
|
|
273
|
+
};
|
|
274
|
+
default:
|
|
275
|
+
if (
|
|
276
|
+
!addressable ||
|
|
277
|
+
current.connectionId === undefined ||
|
|
278
|
+
current.deviceIfname === undefined
|
|
279
|
+
) {
|
|
280
|
+
return {
|
|
281
|
+
receipt: receipt(
|
|
282
|
+
'enabled',
|
|
283
|
+
'pending',
|
|
284
|
+
'awaiting connection profile and data interface before activation',
|
|
285
|
+
),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
receipt: receipt('enabled', 'pending', 'activating connection'),
|
|
290
|
+
op: nmOp({
|
|
291
|
+
kind: 'activate',
|
|
292
|
+
connectionId: current.connectionId,
|
|
293
|
+
deviceIfname: current.deviceIfname,
|
|
294
|
+
}),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (
|
|
299
|
+
(nm === 'activated' || nm === 'activating') &&
|
|
300
|
+
current.connectionId !== undefined &&
|
|
301
|
+
current.deviceIfname !== undefined
|
|
302
|
+
) {
|
|
303
|
+
return {
|
|
304
|
+
receipt: receipt('enabled', 'pending', 'deactivating connection'),
|
|
305
|
+
op: nmOp({
|
|
306
|
+
kind: 'deactivate',
|
|
307
|
+
connectionId: current.connectionId,
|
|
308
|
+
deviceIfname: current.deviceIfname,
|
|
309
|
+
}),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
return { receipt: receipt('enabled', 'applied', 'connection is inactive') };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Recovery + usage are LOCAL-CONTROLLER owned (README ownership table): they emit no
|
|
316
|
+
// MM / NM op, only a receipt recording that the local policy was accepted.
|
|
317
|
+
function planRecovery(desired: DesiredCellularPolicy): Receipt {
|
|
318
|
+
return receipt(
|
|
319
|
+
'recovery',
|
|
320
|
+
'applied',
|
|
321
|
+
desired.recovery.enabled ? 'recovery policy recorded (enabled)' : 'recovery disabled (default)',
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function planUsage(desired: DesiredCellularPolicy): Receipt {
|
|
326
|
+
const parts: string[] = [];
|
|
327
|
+
if (desired.usage.cycleDay !== undefined) {
|
|
328
|
+
parts.push(`cycle day ${desired.usage.cycleDay}`);
|
|
329
|
+
}
|
|
330
|
+
if (desired.usage.thresholdBytes !== undefined) {
|
|
331
|
+
parts.push(`threshold ${desired.usage.thresholdBytes} bytes`);
|
|
332
|
+
}
|
|
333
|
+
return receipt(
|
|
334
|
+
'usage',
|
|
335
|
+
'applied',
|
|
336
|
+
parts.length > 0 ? `usage policy recorded (${parts.join(', ')})` : 'no usage policy set',
|
|
337
|
+
);
|
|
338
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The router port — for devices MM cannot control (HiLink, router-ethernet class).
|
|
2
|
+
//
|
|
3
|
+
// This port is DELIBERATELY tiny: presence and advisory health ONLY. Health never
|
|
4
|
+
// drives activation or recovery — a degraded router stays in the routing set (it is
|
|
5
|
+
// never evicted on health alone). A router device is an Ethernet uplink we OBSERVE,
|
|
6
|
+
// not a modem we configure; there is no bearer / APN / radio verb here.
|
|
7
|
+
|
|
8
|
+
import type { EpochMillis } from '../domain';
|
|
9
|
+
import type { DeviceIfname } from './network-manager';
|
|
10
|
+
|
|
11
|
+
/** Whether a router-class device is present on an interface. */
|
|
12
|
+
export type RouterPresence = 'present' | 'absent';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Advisory health of a router-class uplink. Every field is informational — a
|
|
16
|
+
* degraded router is reported, never removed from the routing set on health alone.
|
|
17
|
+
*/
|
|
18
|
+
export interface RouterHealth {
|
|
19
|
+
readonly presence: RouterPresence;
|
|
20
|
+
readonly gatewayReachable: boolean;
|
|
21
|
+
readonly egressHealthy: boolean;
|
|
22
|
+
readonly observedAt: EpochMillis;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The router port — presence and advisory health only. No mutation verbs. */
|
|
26
|
+
export interface RouterPort {
|
|
27
|
+
probePresence(ifname: DeviceIfname): Promise<RouterPresence>;
|
|
28
|
+
checkHealth(ifname: DeviceIfname): Promise<RouterHealth>;
|
|
29
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Redaction classes — ICCID, IMSI, EID, PIN, PUK, and APN/connection passwords are
|
|
2
|
+
// stripped, including deeply nested occurrences and inside arrays; non-secret
|
|
3
|
+
// siblings survive and the input is never mutated.
|
|
4
|
+
|
|
5
|
+
import { expect, test } from 'bun:test';
|
|
6
|
+
import { REDACTED, redact } from './redact';
|
|
7
|
+
|
|
8
|
+
test('redacts every sensitive class at the top level, keeping non-secret siblings', () => {
|
|
9
|
+
const input = {
|
|
10
|
+
iccid: '8988303000000000000',
|
|
11
|
+
imsi: '310150123456789',
|
|
12
|
+
eid: '89049032000000000000000000000000',
|
|
13
|
+
pin: '1234',
|
|
14
|
+
puk: '12345678',
|
|
15
|
+
password: 's3cret',
|
|
16
|
+
apn: 'internet',
|
|
17
|
+
username: 'operator-user',
|
|
18
|
+
};
|
|
19
|
+
const out = redact(input) as Record<string, unknown>;
|
|
20
|
+
|
|
21
|
+
expect(out.iccid).toBe(REDACTED);
|
|
22
|
+
expect(out.imsi).toBe(REDACTED);
|
|
23
|
+
expect(out.eid).toBe(REDACTED);
|
|
24
|
+
expect(out.pin).toBe(REDACTED);
|
|
25
|
+
expect(out.puk).toBe(REDACTED);
|
|
26
|
+
expect(out.password).toBe(REDACTED);
|
|
27
|
+
expect(out.apn).toBe('internet');
|
|
28
|
+
expect(out.username).toBe('operator-user');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('redacts an APN password nested three levels deep', () => {
|
|
32
|
+
const policy = { connection: { auth: { username: 'u', password: 'hunter2' } } };
|
|
33
|
+
const out = redact(policy) as { connection: { auth: { username: string; password: string } } };
|
|
34
|
+
expect(out.connection.auth.password).toBe(REDACTED);
|
|
35
|
+
expect(out.connection.auth.username).toBe('u');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('redacts ICCID inside an array of SIM slots', () => {
|
|
39
|
+
const input = {
|
|
40
|
+
simSlots: [
|
|
41
|
+
{ index: 1, iccid: '8988000000000000001' },
|
|
42
|
+
{ index: 2, iccid: '8988000000000000002' },
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
const out = redact(input) as { simSlots: Array<{ index: number; iccid: string }> };
|
|
46
|
+
expect(out.simSlots[0]?.iccid).toBe(REDACTED);
|
|
47
|
+
expect(out.simSlots[1]?.iccid).toBe(REDACTED);
|
|
48
|
+
expect(out.simSlots[0]?.index).toBe(1);
|
|
49
|
+
expect(out.simSlots[1]?.index).toBe(2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('redacts NM-style gsm.password but keeps the gsm.password-flags flag', () => {
|
|
53
|
+
const input = { 'gsm.password': 'secret', 'gsm.password-flags': '0', 'gsm.apn': 'internet' };
|
|
54
|
+
const out = redact(input) as Record<string, unknown>;
|
|
55
|
+
expect(out['gsm.password']).toBe(REDACTED);
|
|
56
|
+
expect(out['gsm.password-flags']).toBe('0');
|
|
57
|
+
expect(out['gsm.apn']).toBe('internet');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('redacts subscriptionId, newPin, and puk2 variants', () => {
|
|
61
|
+
const input = { subscriptionId: '8988303000000000000', newPin: '4321', puk2: '87654321' };
|
|
62
|
+
const out = redact(input) as Record<string, unknown>;
|
|
63
|
+
expect(out.subscriptionId).toBe(REDACTED);
|
|
64
|
+
expect(out.newPin).toBe(REDACTED);
|
|
65
|
+
expect(out.puk2).toBe(REDACTED);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('does not mutate the input', () => {
|
|
69
|
+
const input = { pin: '1234', nested: { iccid: '5678' } };
|
|
70
|
+
const before = JSON.stringify(input);
|
|
71
|
+
redact(input);
|
|
72
|
+
expect(JSON.stringify(input)).toBe(before);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('passes primitives and empty containers through unchanged', () => {
|
|
76
|
+
expect(redact('plain')).toBe('plain');
|
|
77
|
+
expect(redact(42)).toBe(42);
|
|
78
|
+
expect(redact(null)).toBe(null);
|
|
79
|
+
expect(redact(undefined)).toBe(undefined);
|
|
80
|
+
expect(redact({})).toEqual({});
|
|
81
|
+
expect(redact([])).toEqual([]);
|
|
82
|
+
});
|
package/src/redact.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Redaction — strip sensitive identifiers from any value before it is logged,
|
|
2
|
+
// serialized into a receipt, or written to a bundle.
|
|
3
|
+
//
|
|
4
|
+
// The sensitive CLASSES (draft §Oracle #1, round-5 auth semantics): ICCID, IMSI,
|
|
5
|
+
// EID, SIM PIN, SIM PUK, and APN / connection passwords. Redaction is KEY-BASED and
|
|
6
|
+
// RECURSIVE: it walks nested objects and arrays and replaces the value under any
|
|
7
|
+
// sensitive key with a fixed marker, no matter how deep — e.g. a password at
|
|
8
|
+
// `policy.connection.auth.password`, or an `iccid` inside an array of SIM slots.
|
|
9
|
+
|
|
10
|
+
/** The marker substituted for every redacted value. */
|
|
11
|
+
export const REDACTED = '[redacted]';
|
|
12
|
+
|
|
13
|
+
// Leaf key names carrying a sensitive value, matched case-insensitively. The match
|
|
14
|
+
// is EXACT (or exact on the last dotted segment), so NM-style keys like
|
|
15
|
+
// `gsm.password` are caught while non-secret siblings like `gsm.password-flags`
|
|
16
|
+
// (a "0"/"4" flag, not a secret) are not.
|
|
17
|
+
const SENSITIVE_KEYS: ReadonlySet<string> = new Set<string>([
|
|
18
|
+
'iccid',
|
|
19
|
+
'imsi',
|
|
20
|
+
'eid',
|
|
21
|
+
'pin',
|
|
22
|
+
'pin2',
|
|
23
|
+
'newpin',
|
|
24
|
+
'puk',
|
|
25
|
+
'puk2',
|
|
26
|
+
'password',
|
|
27
|
+
'passwd',
|
|
28
|
+
'subscriptionid',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
function isSensitiveKey(key: string): boolean {
|
|
32
|
+
const lower = key.toLowerCase();
|
|
33
|
+
if (SENSITIVE_KEYS.has(lower)) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
const dot = lower.lastIndexOf('.');
|
|
37
|
+
return dot >= 0 && SENSITIVE_KEYS.has(lower.slice(dot + 1));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
41
|
+
if (typeof value !== 'object' || value === null) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
const proto = Object.getPrototypeOf(value) as unknown;
|
|
45
|
+
return proto === Object.prototype || proto === null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function redactValue(value: unknown, underSensitiveKey: boolean): unknown {
|
|
49
|
+
if (underSensitiveKey) {
|
|
50
|
+
return REDACTED;
|
|
51
|
+
}
|
|
52
|
+
if (Array.isArray(value)) {
|
|
53
|
+
return value.map((item) => redactValue(item, false));
|
|
54
|
+
}
|
|
55
|
+
if (isPlainObject(value)) {
|
|
56
|
+
const out: Record<string, unknown> = {};
|
|
57
|
+
for (const [key, child] of Object.entries(value)) {
|
|
58
|
+
out[key] = redactValue(child, isSensitiveKey(key));
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Return a deep copy of `value` with every sensitive field redacted. Plain objects
|
|
67
|
+
* and arrays are walked recursively; all other values (primitives, and opaque
|
|
68
|
+
* objects like `Date` / `Set` / `Map`) are returned unchanged. The input is never
|
|
69
|
+
* mutated.
|
|
70
|
+
*/
|
|
71
|
+
export function redact(value: unknown): unknown {
|
|
72
|
+
return redactValue(value, false);
|
|
73
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# D-Bus transport seam
|
|
2
|
+
|
|
3
|
+
A minimal internal transport interface over [`@httptoolkit/dbus-native`](https://www.npmjs.com/package/@httptoolkit/dbus-native).
|
|
4
|
+
Everything the rest of `@ceralive/modem-control` needs to talk to ModemManager over
|
|
5
|
+
D-Bus goes through the `DbusTransport` interface exported by [`index.ts`](./index.ts) —
|
|
6
|
+
method calls, signal subscriptions, and reconnect. The A3.x D-Bus backend builds
|
|
7
|
+
directly on this shape.
|
|
8
|
+
|
|
9
|
+
## Why this library
|
|
10
|
+
|
|
11
|
+
`@httptoolkit/dbus-native` is a pure-JavaScript D-Bus client (no libdbus / native
|
|
12
|
+
addon), which is the deciding factor: it imports and runs under **Bun 1.3.14** with
|
|
13
|
+
**EXTERNAL** auth on a session bus — session-verified during A2.4, and pinned exactly at
|
|
14
|
+
`0.1.5`. A native-addon client (anything binding libdbus) is a portability and
|
|
15
|
+
cross-compile liability for the arm64 + amd64 device image; a pure-JS client is not.
|
|
16
|
+
|
|
17
|
+
## The fallback
|
|
18
|
+
|
|
19
|
+
If `@httptoolkit/dbus-native` proves inadequate — an un-fixable marshalling bug, an
|
|
20
|
+
unmaintained upstream, or a Bun incompatibility introduced by a future runtime bump —
|
|
21
|
+
the documented fallback is [`@particle/dbus-next`](https://www.npmjs.com/package/@particle/dbus-next),
|
|
22
|
+
which is also pure-JS and was verified importable under Bun during planning (draft
|
|
23
|
+
ledger §dbus-native). It is **not** implemented here; this note records the escape hatch.
|
|
24
|
+
Because the entire library surface is quarantined behind this seam (see below), swapping
|
|
25
|
+
to it would touch only [`dbus-native.ts`](./dbus-native.ts), [`transport.ts`](./transport.ts),
|
|
26
|
+
and [`codec.ts`](./codec.ts) — never a caller.
|
|
27
|
+
|
|
28
|
+
## The seam contract
|
|
29
|
+
|
|
30
|
+
* **No library types leak.** `index.ts` re-exports only the transport's own types
|
|
31
|
+
(`DbusValue`, `DbusVariant`, `MethodCall`, `SignalEvent`, …). The raw library types
|
|
32
|
+
live in [`dbus-native.ts`](./dbus-native.ts) and go no further. A guard test asserts
|
|
33
|
+
the package entry (`../index.ts`) never re-exports the library.
|
|
34
|
+
* **Lossless 64-bit.** D-Bus `x` (INT64) and `t` (UINT64) are `bigint` end-to-end, never
|
|
35
|
+
a JS `number`. On decode the library runs with `ReturnLongjs: true` and we convert its
|
|
36
|
+
Long.js objects to `bigint` via their exact decimal string; on encode we require a
|
|
37
|
+
`bigint` and hand the library a decimal string (its only lossless 64-bit input).
|
|
38
|
+
Passing a `number` for a 64-bit field throws `BigIntRequiredError`.
|
|
39
|
+
* **`h` is unsupported.** UNIX_FD / file-descriptor passing is rejected up front with a
|
|
40
|
+
typed `UnsupportedSignatureError` — in an outgoing signature, a reply/signal signature,
|
|
41
|
+
or nested in a variant — never silently dropped or coerced.
|
|
42
|
+
* **Variants round-trip.** A decoded variant keeps its inner signature
|
|
43
|
+
(`DbusVariant { signature, value }`), so encode/decode is symmetric.
|
|
44
|
+
|
|
45
|
+
## Conformance
|
|
46
|
+
|
|
47
|
+
Tests run under `dbus-run-session -- bun test control/src/transport` and prove the seam
|
|
48
|
+
two independent ways:
|
|
49
|
+
|
|
50
|
+
1. **Same library** ([`conformance-same-lib.test.ts`](./conformance-same-lib.test.ts)) —
|
|
51
|
+
round-trips representative signatures against a minimal fake service built on the same
|
|
52
|
+
`@httptoolkit/dbus-native`.
|
|
53
|
+
2. **Independent producer** ([`conformance-python.test.ts`](./conformance-python.test.ts)) —
|
|
54
|
+
round-trips against a `python3-dbus` (`dbus-python`) service in a subprocess,
|
|
55
|
+
exercising `a{oa{sa{sv}}}`, `x`/`t` above 2^53, variants, and `PropertiesChanged`
|
|
56
|
+
invalidations. A different implementation on the wire is the real proof our codec is
|
|
57
|
+
correct, not merely self-consistent.
|
|
58
|
+
|
|
59
|
+
Plus [`reliability.test.ts`](./reliability.test.ts): reconnect after a bus restart
|
|
60
|
+
(against a dedicated private `dbus-daemon`), a ≥5000-event signal stream, late replies,
|
|
61
|
+
and a 100-cycle subscribe/unsubscribe listener-leak check.
|
|
62
|
+
|
|
63
|
+
The `test-support/` fakes here are intentionally minimal — just enough to round-trip the
|
|
64
|
+
signatures under test. The MM-faithful fake service (root ObjectManager, `Modem` /
|
|
65
|
+
`Modem3gpp` interfaces, SIM objects, bearer tripwires) is a separate, later task (A2.3).
|