@truenas/api-client 1.0.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/README.md +38 -0
- package/dist/index.cjs +1561 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2627 -0
- package/dist/index.d.ts +2627 -0
- package/dist/index.js +1538 -0
- package/dist/index.js.map +1 -0
- package/package.json +75 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1538 @@
|
|
|
1
|
+
import { switchMap, map, toArray, concat, from, defer, throwError, firstValueFrom, BehaviorSubject, Subject, distinctUntilChanged, of, filter, take, catchError as catchError$1, retry, startWith, shareReplay as shareReplay$1, takeUntil, combineLatest, interval, Observable, timeout, race, tap, share, takeWhile, merge, finalize } from 'rxjs';
|
|
2
|
+
import { webSocket } from 'rxjs/webSocket';
|
|
3
|
+
import { map as map$1, catchError, shareReplay } from 'rxjs/operators';
|
|
4
|
+
|
|
5
|
+
// src/factory.ts
|
|
6
|
+
|
|
7
|
+
// src/enums/truenas-endpoint.enum.ts
|
|
8
|
+
var TrueNasEndpoint = /* @__PURE__ */ ((TrueNasEndpoint2) => {
|
|
9
|
+
TrueNasEndpoint2["UpdateStatus"] = "update.status";
|
|
10
|
+
TrueNasEndpoint2["AppQuery"] = "app.query";
|
|
11
|
+
TrueNasEndpoint2["AppStart"] = "app.start";
|
|
12
|
+
TrueNasEndpoint2["AppStop"] = "app.stop";
|
|
13
|
+
TrueNasEndpoint2["AppRedeploy"] = "app.redeploy";
|
|
14
|
+
TrueNasEndpoint2["AppUpgrade"] = "app.upgrade";
|
|
15
|
+
TrueNasEndpoint2["CoreSubscribe"] = "core.subscribe";
|
|
16
|
+
TrueNasEndpoint2["ReplicationQuery"] = "replication.query";
|
|
17
|
+
TrueNasEndpoint2["ReplicationRun"] = "replication.run";
|
|
18
|
+
TrueNasEndpoint2["InterfaceQuery"] = "interface.query";
|
|
19
|
+
TrueNasEndpoint2["CloudBackupQuery"] = "cloud_backup.query";
|
|
20
|
+
TrueNasEndpoint2["CloudSyncQuery"] = "cloudsync.query";
|
|
21
|
+
TrueNasEndpoint2["VirtualInstanceQuery"] = "virt.instance.query";
|
|
22
|
+
TrueNasEndpoint2["VirtualInstanceStop"] = "virt.instance.stop";
|
|
23
|
+
TrueNasEndpoint2["VirtualInstanceStart"] = "virt.instance.start";
|
|
24
|
+
TrueNasEndpoint2["VirtualInstanceRestart"] = "virt.instance.restart";
|
|
25
|
+
TrueNasEndpoint2["ContainerQuery"] = "container.query";
|
|
26
|
+
TrueNasEndpoint2["ContainerStart"] = "container.start";
|
|
27
|
+
TrueNasEndpoint2["ContainerStop"] = "container.stop";
|
|
28
|
+
TrueNasEndpoint2["VmQuery"] = "vm.query";
|
|
29
|
+
TrueNasEndpoint2["VmStart"] = "vm.start";
|
|
30
|
+
TrueNasEndpoint2["VmStop"] = "vm.stop";
|
|
31
|
+
TrueNasEndpoint2["VmRestart"] = "vm.restart";
|
|
32
|
+
TrueNasEndpoint2["CoreDownload"] = "core.download";
|
|
33
|
+
TrueNasEndpoint2["CoreGetJobs"] = "core.get_jobs";
|
|
34
|
+
TrueNasEndpoint2["SystemInfo"] = "system.info";
|
|
35
|
+
TrueNasEndpoint2["SystemProductType"] = "system.product_type";
|
|
36
|
+
TrueNasEndpoint2["ExtendedSystemInfo"] = "webui.main.dashboard.sys_info";
|
|
37
|
+
TrueNasEndpoint2["DiskQuery"] = "disk.query";
|
|
38
|
+
TrueNasEndpoint2["DeviceGetInfo"] = "device.get_info";
|
|
39
|
+
TrueNasEndpoint2["CorePing"] = "core.ping";
|
|
40
|
+
TrueNasEndpoint2["GenerateToken"] = "auth.generate_token";
|
|
41
|
+
TrueNasEndpoint2["AlertList"] = "alert.list";
|
|
42
|
+
TrueNasEndpoint2["AuthLogin"] = "auth.login_ex";
|
|
43
|
+
TrueNasEndpoint2["AuthLogout"] = "auth.logout";
|
|
44
|
+
TrueNasEndpoint2["ApiKeyQuery"] = "api_key.query";
|
|
45
|
+
TrueNasEndpoint2["ApiKeyCreate"] = "api_key.create";
|
|
46
|
+
TrueNasEndpoint2["ApiKeyDelete"] = "api_key.delete";
|
|
47
|
+
TrueNasEndpoint2["FailOverStatus"] = "failover.status";
|
|
48
|
+
TrueNasEndpoint2["FailOverNode"] = "failover.node";
|
|
49
|
+
TrueNasEndpoint2["FailOverDisabledReasons"] = "failover.disabled.reasons";
|
|
50
|
+
TrueNasEndpoint2["PoolQuery"] = "pool.query";
|
|
51
|
+
TrueNasEndpoint2["DatasetQuery"] = "pool.dataset.query";
|
|
52
|
+
TrueNasEndpoint2["DatasetCreate"] = "pool.dataset.create";
|
|
53
|
+
TrueNasEndpoint2["DatasetDelete"] = "pool.dataset.delete";
|
|
54
|
+
TrueNasEndpoint2["SnapshotTaskCreate"] = "pool.snapshottask.create";
|
|
55
|
+
TrueNasEndpoint2["SnapshotTaskQuery"] = "pool.snapshottask.query";
|
|
56
|
+
TrueNasEndpoint2["SnapshotTaskDelete"] = "pool.snapshottask.delete";
|
|
57
|
+
TrueNasEndpoint2["SnapshotCreate"] = "pool.snapshot.create";
|
|
58
|
+
TrueNasEndpoint2["SnapshotDelete"] = "pool.snapshot.delete";
|
|
59
|
+
TrueNasEndpoint2["ReplicationCountEligibleSnapshots"] = "replication.count_eligible_manual_snapshots";
|
|
60
|
+
TrueNasEndpoint2["BootGetState"] = "boot.get_state";
|
|
61
|
+
TrueNasEndpoint2["TnConnectUpdate"] = "tn_connect.update";
|
|
62
|
+
TrueNasEndpoint2["HardwareVirtualizationVariant"] = "hardware.virtualization.variant";
|
|
63
|
+
TrueNasEndpoint2["KeychainCredentialGenerateSshKeyPair"] = "keychaincredential.generate_ssh_key_pair";
|
|
64
|
+
TrueNasEndpoint2["KeychainCredentialCreate"] = "keychaincredential.create";
|
|
65
|
+
TrueNasEndpoint2["KeychainCredentialRemoteSshHostKeyScan"] = "keychaincredential.remote_ssh_host_key_scan";
|
|
66
|
+
TrueNasEndpoint2["KeychainCredentialDelete"] = "keychaincredential.delete";
|
|
67
|
+
TrueNasEndpoint2["ReplicationCreate"] = "replication.create";
|
|
68
|
+
TrueNasEndpoint2["UserQuery"] = "user.query";
|
|
69
|
+
TrueNasEndpoint2["UserUpdate"] = "user.update";
|
|
70
|
+
TrueNasEndpoint2["NetworkConfiguration"] = "network.configuration.config";
|
|
71
|
+
TrueNasEndpoint2["ServiceQuery"] = "service.query";
|
|
72
|
+
TrueNasEndpoint2["ServiceUpdate"] = "service.update";
|
|
73
|
+
TrueNasEndpoint2["ServiceControl"] = "service.control";
|
|
74
|
+
return TrueNasEndpoint2;
|
|
75
|
+
})(TrueNasEndpoint || {});
|
|
76
|
+
|
|
77
|
+
// src/types/api-error.type.ts
|
|
78
|
+
function isJsonRpcError(error) {
|
|
79
|
+
return typeof error === "object" && error !== null && "message" in error && typeof error.message === "string";
|
|
80
|
+
}
|
|
81
|
+
function isTrueNasError(error) {
|
|
82
|
+
return typeof error === "object" && error !== null && "reason" in error && typeof error.reason === "string";
|
|
83
|
+
}
|
|
84
|
+
function getApiErrorMessage(error, fallback = "API call failed") {
|
|
85
|
+
if (isJsonRpcError(error) && error.data && isTrueNasError(error.data)) {
|
|
86
|
+
return error.data.reason;
|
|
87
|
+
}
|
|
88
|
+
if (isTrueNasError(error)) {
|
|
89
|
+
return error.reason;
|
|
90
|
+
}
|
|
91
|
+
if (isJsonRpcError(error)) {
|
|
92
|
+
return error.message;
|
|
93
|
+
}
|
|
94
|
+
return fallback;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/types/job.type.ts
|
|
98
|
+
var JobState = /* @__PURE__ */ ((JobState2) => {
|
|
99
|
+
JobState2["Pending"] = "PENDING";
|
|
100
|
+
JobState2["Running"] = "RUNNING";
|
|
101
|
+
JobState2["Hold"] = "HOLD";
|
|
102
|
+
JobState2["Error"] = "ERROR";
|
|
103
|
+
JobState2["Failed"] = "FAILED";
|
|
104
|
+
JobState2["Aborted"] = "ABORTED";
|
|
105
|
+
JobState2["Success"] = "SUCCESS";
|
|
106
|
+
JobState2["Finished"] = "FINISHED";
|
|
107
|
+
JobState2["Locked"] = "LOCKED";
|
|
108
|
+
JobState2["Waiting"] = "WAITING";
|
|
109
|
+
return JobState2;
|
|
110
|
+
})(JobState || {});
|
|
111
|
+
var withId = (id) => filter((msg) => msg.id === id);
|
|
112
|
+
function randomUUID() {
|
|
113
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
114
|
+
return crypto.randomUUID();
|
|
115
|
+
}
|
|
116
|
+
if (typeof crypto === "undefined" || typeof crypto.getRandomValues !== "function") {
|
|
117
|
+
throw new Error("Web Crypto API unavailable: cannot generate a UUID");
|
|
118
|
+
}
|
|
119
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
120
|
+
const r = crypto.getRandomValues(new Uint8Array(1))[0] % 16 | 0;
|
|
121
|
+
const v = c === "x" ? r : r & 3 | 8;
|
|
122
|
+
return v.toString(16);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// src/utils/jsonrpc.utils.ts
|
|
127
|
+
function createJsonRpcMessage(method, params) {
|
|
128
|
+
return {
|
|
129
|
+
jsonrpc: "2.0",
|
|
130
|
+
id: randomUUID(),
|
|
131
|
+
method,
|
|
132
|
+
params: params ?? []
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// src/api/truenas-api.ts
|
|
137
|
+
var TrueNasApi = class {
|
|
138
|
+
constructor(authenticated, connection) {
|
|
139
|
+
this.authenticated = authenticated;
|
|
140
|
+
this.connection = connection;
|
|
141
|
+
/**
|
|
142
|
+
* Stream of job events from websocket.
|
|
143
|
+
* JSON-RPC 2.0 events have structure: { method: 'collection_update', params: { collection, fields, ... } }
|
|
144
|
+
*/
|
|
145
|
+
this.jobEvents = this.connection.messages().pipe(
|
|
146
|
+
filter(
|
|
147
|
+
(res) => res.method === "collection_update" && res.params?.collection === "core.get_jobs"
|
|
148
|
+
),
|
|
149
|
+
map((event) => event.params.fields),
|
|
150
|
+
filter((job) => !!job?.id),
|
|
151
|
+
share()
|
|
152
|
+
);
|
|
153
|
+
this.initializeJobEventsSubscription();
|
|
154
|
+
}
|
|
155
|
+
call(method, params) {
|
|
156
|
+
const message = createJsonRpcMessage(method, params);
|
|
157
|
+
this.connection.ws.next(message);
|
|
158
|
+
const messageId = message.id ?? "";
|
|
159
|
+
return this.connection.messages().pipe(
|
|
160
|
+
withId(messageId),
|
|
161
|
+
map((msg) => {
|
|
162
|
+
if (msg.error) {
|
|
163
|
+
const errorMessage = getApiErrorMessage(msg.error, "API call failed");
|
|
164
|
+
throw new Error(errorMessage);
|
|
165
|
+
}
|
|
166
|
+
return msg.result;
|
|
167
|
+
}),
|
|
168
|
+
take(1)
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Makes an API call and returns the job ID from the websocket event.
|
|
173
|
+
* Used for v26 where API calls return null but job events contain the job ID.
|
|
174
|
+
*
|
|
175
|
+
* The job ID is extracted from the first job event where message_ids contains
|
|
176
|
+
* the original request ID.
|
|
177
|
+
*
|
|
178
|
+
* @param method The API method to call
|
|
179
|
+
* @param params The parameters for the API call
|
|
180
|
+
* @returns Observable that emits the job ID when received from websocket events
|
|
181
|
+
*/
|
|
182
|
+
callAndGetJobId(method, params) {
|
|
183
|
+
const message = createJsonRpcMessage(method, params);
|
|
184
|
+
this.connection.ws.next(message);
|
|
185
|
+
const requestId = message.id ?? "";
|
|
186
|
+
return this.jobEvents.pipe(
|
|
187
|
+
filter((job) => job.message_ids?.includes(requestId) ?? false),
|
|
188
|
+
map((job) => job.id),
|
|
189
|
+
take(1)
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
events(eventName) {
|
|
193
|
+
this.authenticated.pipe(filter(Boolean), take(1)).subscribe(() => {
|
|
194
|
+
const message = createJsonRpcMessage("core.subscribe", [eventName]);
|
|
195
|
+
this.connection.ws.next(message);
|
|
196
|
+
});
|
|
197
|
+
return this.connection.messages().pipe(
|
|
198
|
+
filter((res) => {
|
|
199
|
+
const params = res.params;
|
|
200
|
+
return res.method === "collection_update" && params?.collection === eventName && ["added", "changed", "removed"].includes(params?.msg || "") && params.fields !== void 0;
|
|
201
|
+
})
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Convenience wrapper for auth.generate_token API call.
|
|
206
|
+
*/
|
|
207
|
+
generateToken(ttl = 600, matchOrigin = false, singleUse = true) {
|
|
208
|
+
return this.call("auth.generate_token" /* GenerateToken */, [
|
|
209
|
+
ttl,
|
|
210
|
+
{},
|
|
211
|
+
matchOrigin,
|
|
212
|
+
singleUse
|
|
213
|
+
]);
|
|
214
|
+
}
|
|
215
|
+
trackJob(jobId) {
|
|
216
|
+
const completedStates = [
|
|
217
|
+
"SUCCESS" /* Success */,
|
|
218
|
+
"FAILED" /* Failed */,
|
|
219
|
+
"ABORTED" /* Aborted */,
|
|
220
|
+
"ERROR" /* Error */,
|
|
221
|
+
"FINISHED" /* Finished */
|
|
222
|
+
];
|
|
223
|
+
const currentJobState$ = this.call("core.get_jobs", [
|
|
224
|
+
[["id", "=", jobId]]
|
|
225
|
+
]).pipe(
|
|
226
|
+
map((jobs) => jobs[0]),
|
|
227
|
+
filter((job) => job !== void 0)
|
|
228
|
+
);
|
|
229
|
+
const jobUpdates$ = this.jobEvents.pipe(
|
|
230
|
+
filter((job) => job.id === jobId),
|
|
231
|
+
takeWhile((job) => !completedStates.includes(job.state), true)
|
|
232
|
+
// Include the final state
|
|
233
|
+
);
|
|
234
|
+
return currentJobState$.pipe(
|
|
235
|
+
switchMap((currentJob) => {
|
|
236
|
+
if (completedStates.includes(currentJob.state)) {
|
|
237
|
+
return of(currentJob);
|
|
238
|
+
}
|
|
239
|
+
return merge(of(currentJob), jobUpdates$);
|
|
240
|
+
})
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
initializeJobEventsSubscription() {
|
|
244
|
+
this.authenticated.pipe(filter(Boolean), take(1)).subscribe(() => {
|
|
245
|
+
const message = createJsonRpcMessage("core.subscribe", ["core.get_jobs"]);
|
|
246
|
+
this.connection.ws.next(message);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// src/enums/truenas-auth-mechanism.enum.ts
|
|
252
|
+
var TrueNasAuthMechanism = /* @__PURE__ */ ((TrueNasAuthMechanism2) => {
|
|
253
|
+
TrueNasAuthMechanism2["ApiKey"] = "API_KEY_PLAIN";
|
|
254
|
+
TrueNasAuthMechanism2["Password"] = "PASSWORD_PLAIN";
|
|
255
|
+
TrueNasAuthMechanism2["Otp"] = "OTP_TOKEN";
|
|
256
|
+
TrueNasAuthMechanism2["Token"] = "TOKEN_PLAIN";
|
|
257
|
+
return TrueNasAuthMechanism2;
|
|
258
|
+
})(TrueNasAuthMechanism || {});
|
|
259
|
+
|
|
260
|
+
// src/errors/auth.errors.ts
|
|
261
|
+
var AuthErrorCode = /* @__PURE__ */ ((AuthErrorCode2) => {
|
|
262
|
+
AuthErrorCode2["PasswordAuthFailed"] = "PASSWORD_AUTH_FAILED";
|
|
263
|
+
AuthErrorCode2["OtpAuthFailed"] = "OTP_AUTH_FAILED";
|
|
264
|
+
AuthErrorCode2["ApiKeyAuthFailed"] = "API_KEY_AUTH_FAILED";
|
|
265
|
+
AuthErrorCode2["FullAdminRequired"] = "FULL_ADMIN_REQUIRED";
|
|
266
|
+
return AuthErrorCode2;
|
|
267
|
+
})(AuthErrorCode || {});
|
|
268
|
+
var AuthError = class _AuthError extends Error {
|
|
269
|
+
constructor(code, message) {
|
|
270
|
+
super(message);
|
|
271
|
+
this.code = code;
|
|
272
|
+
this.name = "AuthError";
|
|
273
|
+
Object.setPrototypeOf(this, _AuthError.prototype);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// src/auth/truenas-authenticator.ts
|
|
278
|
+
var throwOnAuthenticationFailure = (code, message) => switchMap((response) => {
|
|
279
|
+
if (response.response_type === "AUTH_ERR" /* AuthErr */) {
|
|
280
|
+
return throwError(() => new AuthError(code, message));
|
|
281
|
+
}
|
|
282
|
+
return of(response);
|
|
283
|
+
});
|
|
284
|
+
var TrueNasAuthenticator = class _TrueNasAuthenticator {
|
|
285
|
+
constructor(connection) {
|
|
286
|
+
this.connection = connection;
|
|
287
|
+
// in seconds (5 minutes)
|
|
288
|
+
/**
|
|
289
|
+
* whether or not the system is currently authenticated and accessible.
|
|
290
|
+
* this is a `BehaviorSubject` instead of a signal, since we use its `getValue` method
|
|
291
|
+
* throughout a lot of the codebase.
|
|
292
|
+
*/
|
|
293
|
+
this.authenticated$ = new BehaviorSubject(false);
|
|
294
|
+
/**
|
|
295
|
+
* `true` when the system is currently being authenticated to, and `false` otherwise.
|
|
296
|
+
* does not indicate whatsoever if authentication is successful or not, just whether the process
|
|
297
|
+
* is ongoing.
|
|
298
|
+
*/
|
|
299
|
+
this.authenticating$ = new BehaviorSubject(false);
|
|
300
|
+
this.credentials = { username: "", password: "", key: "" };
|
|
301
|
+
this.sessionLifetime = _TrueNasAuthenticator.DefaultSessionLifetime;
|
|
302
|
+
this.connection.opened.pipe(
|
|
303
|
+
filter(
|
|
304
|
+
(isOpen) => !!(isOpen && this.credentials?.username && (this.credentials.password || this.credentials.key))
|
|
305
|
+
),
|
|
306
|
+
switchMap(() => {
|
|
307
|
+
if (this.credentials.password) {
|
|
308
|
+
return this.loginWithUserPass(
|
|
309
|
+
this.credentials.username,
|
|
310
|
+
this.credentials.password
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
return this.loginWithApiKey({
|
|
314
|
+
username: this.credentials.username,
|
|
315
|
+
key: this.credentials.key
|
|
316
|
+
});
|
|
317
|
+
})
|
|
318
|
+
).subscribe();
|
|
319
|
+
connection.closed.subscribe(() => {
|
|
320
|
+
this.sessionLifetime = _TrueNasAuthenticator.DefaultSessionLifetime;
|
|
321
|
+
this.authenticated$.next(false);
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
static {
|
|
325
|
+
this.DefaultSessionLifetime = 300;
|
|
326
|
+
}
|
|
327
|
+
loginWithUserPass(username, password) {
|
|
328
|
+
const message = createJsonRpcMessage("auth.login_ex", [
|
|
329
|
+
{
|
|
330
|
+
mechanism: "PASSWORD_PLAIN" /* Password */,
|
|
331
|
+
username,
|
|
332
|
+
password
|
|
333
|
+
}
|
|
334
|
+
]);
|
|
335
|
+
this.authenticating$.next(true);
|
|
336
|
+
this.connection.send(message);
|
|
337
|
+
const messageId = message.id ?? "";
|
|
338
|
+
return this.connection.messages().pipe(
|
|
339
|
+
withId(messageId),
|
|
340
|
+
map((msg) => {
|
|
341
|
+
if (msg.error) {
|
|
342
|
+
const errorMessage = getApiErrorMessage(
|
|
343
|
+
msg.error,
|
|
344
|
+
"Authentication failed"
|
|
345
|
+
);
|
|
346
|
+
throw new Error(errorMessage);
|
|
347
|
+
}
|
|
348
|
+
return msg.result;
|
|
349
|
+
}),
|
|
350
|
+
throwOnAuthenticationFailure(
|
|
351
|
+
"PASSWORD_AUTH_FAILED" /* PasswordAuthFailed */,
|
|
352
|
+
"TrueNAS authentication failed. Please verify your TrueNAS user credentials and try again."
|
|
353
|
+
),
|
|
354
|
+
tap((res) => {
|
|
355
|
+
if (res.response_type === "SUCCESS" /* Success */) {
|
|
356
|
+
if (res.user_info?.privilege.roles.$set.includes("FULL_ADMIN" /* FullAdmin */)) {
|
|
357
|
+
this.credentials.username = username;
|
|
358
|
+
this.credentials.password = password;
|
|
359
|
+
this.sessionLifetime = res.user_info?.attributes?.preferences?.lifetime ?? _TrueNasAuthenticator.DefaultSessionLifetime;
|
|
360
|
+
this.authenticated$.next(true);
|
|
361
|
+
} else {
|
|
362
|
+
this.logout();
|
|
363
|
+
this.authenticated$.next(false);
|
|
364
|
+
throw new AuthError(
|
|
365
|
+
"FULL_ADMIN_REQUIRED" /* FullAdminRequired */,
|
|
366
|
+
"User account must have full admin privileges"
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}),
|
|
371
|
+
finalize(() => {
|
|
372
|
+
this.authenticating$.next(false);
|
|
373
|
+
}),
|
|
374
|
+
take(1)
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
loginWithOtp(code) {
|
|
378
|
+
const message = createJsonRpcMessage("auth.login_ex", [
|
|
379
|
+
{
|
|
380
|
+
mechanism: "OTP_TOKEN" /* Otp */,
|
|
381
|
+
otp_token: code
|
|
382
|
+
}
|
|
383
|
+
]);
|
|
384
|
+
this.authenticating$.next(true);
|
|
385
|
+
this.connection.send(message);
|
|
386
|
+
const messageId = message.id ?? "";
|
|
387
|
+
return this.connection.messages().pipe(
|
|
388
|
+
withId(messageId),
|
|
389
|
+
map((msg) => {
|
|
390
|
+
if (msg.error) {
|
|
391
|
+
const errorMessage = getApiErrorMessage(
|
|
392
|
+
msg.error,
|
|
393
|
+
"Authentication failed"
|
|
394
|
+
);
|
|
395
|
+
throw new Error(errorMessage);
|
|
396
|
+
}
|
|
397
|
+
return msg.result;
|
|
398
|
+
}),
|
|
399
|
+
tap((res) => {
|
|
400
|
+
if (res?.response_type === "SUCCESS" /* Success */) {
|
|
401
|
+
this.sessionLifetime = res.user_info?.attributes?.preferences?.lifetime ?? _TrueNasAuthenticator.DefaultSessionLifetime;
|
|
402
|
+
this.authenticated$.next(true);
|
|
403
|
+
}
|
|
404
|
+
}),
|
|
405
|
+
throwOnAuthenticationFailure(
|
|
406
|
+
"OTP_AUTH_FAILED" /* OtpAuthFailed */,
|
|
407
|
+
"TrueNAS authentication failed. Please verify your one-time passcode and try again."
|
|
408
|
+
),
|
|
409
|
+
finalize(() => {
|
|
410
|
+
this.authenticating$.next(false);
|
|
411
|
+
}),
|
|
412
|
+
take(1)
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
loginWithApiKey(credentials) {
|
|
416
|
+
const { username, key } = credentials;
|
|
417
|
+
const message = createJsonRpcMessage("auth.login_ex", [
|
|
418
|
+
{
|
|
419
|
+
mechanism: "API_KEY_PLAIN" /* ApiKey */,
|
|
420
|
+
username,
|
|
421
|
+
api_key: key
|
|
422
|
+
}
|
|
423
|
+
]);
|
|
424
|
+
this.authenticating$.next(true);
|
|
425
|
+
this.connection.send(message);
|
|
426
|
+
const messageId = message.id ?? "";
|
|
427
|
+
return this.connection.messages().pipe(
|
|
428
|
+
withId(messageId),
|
|
429
|
+
map((msg) => {
|
|
430
|
+
if (msg.error) {
|
|
431
|
+
const errorMessage = getApiErrorMessage(
|
|
432
|
+
msg.error,
|
|
433
|
+
"Authentication failed"
|
|
434
|
+
);
|
|
435
|
+
throw new Error(errorMessage);
|
|
436
|
+
}
|
|
437
|
+
return msg.result;
|
|
438
|
+
}),
|
|
439
|
+
throwOnAuthenticationFailure(
|
|
440
|
+
"API_KEY_AUTH_FAILED" /* ApiKeyAuthFailed */,
|
|
441
|
+
"TrueNAS authentication failed. Has the TrueNAS Connect API key been removed from your TrueNAS server?"
|
|
442
|
+
),
|
|
443
|
+
tap((res) => {
|
|
444
|
+
this.credentials.username = username;
|
|
445
|
+
this.credentials.key = key;
|
|
446
|
+
this.sessionLifetime = res.user_info?.attributes?.preferences?.lifetime ?? _TrueNasAuthenticator.DefaultSessionLifetime;
|
|
447
|
+
this.authenticated$.next(true);
|
|
448
|
+
}),
|
|
449
|
+
finalize(() => {
|
|
450
|
+
this.authenticating$.next(false);
|
|
451
|
+
}),
|
|
452
|
+
take(1)
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
newApiKey(username) {
|
|
456
|
+
const message = createJsonRpcMessage("api_key.create", [
|
|
457
|
+
{ name: `tnc-${randomUUID()}`, username }
|
|
458
|
+
]);
|
|
459
|
+
this.connection.send(message);
|
|
460
|
+
const messageId = message.id ?? "";
|
|
461
|
+
return this.connection.messages().pipe(
|
|
462
|
+
withId(messageId),
|
|
463
|
+
map((msg) => {
|
|
464
|
+
if (msg.error) {
|
|
465
|
+
const errorMessage = getApiErrorMessage(
|
|
466
|
+
msg.error,
|
|
467
|
+
"Failed to create API key"
|
|
468
|
+
);
|
|
469
|
+
throw new Error(errorMessage);
|
|
470
|
+
}
|
|
471
|
+
return msg.result;
|
|
472
|
+
}),
|
|
473
|
+
take(1)
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
logout() {
|
|
477
|
+
const message = createJsonRpcMessage("auth.logout");
|
|
478
|
+
this.connection.send(message);
|
|
479
|
+
const messageId = message.id ?? "";
|
|
480
|
+
return this.connection.messages().pipe(
|
|
481
|
+
withId(messageId),
|
|
482
|
+
map((msg) => {
|
|
483
|
+
if (msg.error) {
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
return msg.result;
|
|
487
|
+
}),
|
|
488
|
+
tap((success) => {
|
|
489
|
+
this.sessionLifetime = _TrueNasAuthenticator.DefaultSessionLifetime;
|
|
490
|
+
this.authenticated$.next(!success);
|
|
491
|
+
}),
|
|
492
|
+
take(1)
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
// src/logger.ts
|
|
498
|
+
var consoleLogger = {
|
|
499
|
+
trace: (message, ...args) => console.trace(message, ...args),
|
|
500
|
+
debug: (message, ...args) => console.debug(message, ...args),
|
|
501
|
+
info: (message, ...args) => console.info(message, ...args),
|
|
502
|
+
warn: (message, ...args) => console.warn(message, ...args),
|
|
503
|
+
error: (message, ...args) => console.error(message, ...args)
|
|
504
|
+
};
|
|
505
|
+
var noop = () => void 0;
|
|
506
|
+
var noopLogger = {
|
|
507
|
+
trace: noop,
|
|
508
|
+
debug: noop,
|
|
509
|
+
info: noop,
|
|
510
|
+
warn: noop,
|
|
511
|
+
error: noop
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
// src/utils/truenas-connection.utils.ts
|
|
515
|
+
function isHttpStatusError(reason) {
|
|
516
|
+
return ["404", "502", "503"].some((code) => reason.includes(code));
|
|
517
|
+
}
|
|
518
|
+
function getHttpError(reason) {
|
|
519
|
+
if (reason.includes("404")) {
|
|
520
|
+
return "API endpoint not found - System may not support versioned API";
|
|
521
|
+
}
|
|
522
|
+
if (reason.includes("502")) {
|
|
523
|
+
return "Bad Gateway - System may be starting up";
|
|
524
|
+
}
|
|
525
|
+
if (reason.includes("503")) {
|
|
526
|
+
return "Service Unavailable - System may be overloaded";
|
|
527
|
+
}
|
|
528
|
+
return "";
|
|
529
|
+
}
|
|
530
|
+
function getWebSocketError(code) {
|
|
531
|
+
switch (code) {
|
|
532
|
+
case 1e3:
|
|
533
|
+
return "Connection closed normally";
|
|
534
|
+
case 1001:
|
|
535
|
+
return "Server is shutting down or restarting";
|
|
536
|
+
case 1002:
|
|
537
|
+
return "Protocol error - Invalid WebSocket communication";
|
|
538
|
+
case 1003:
|
|
539
|
+
return "Unsupported data format received";
|
|
540
|
+
case 1006:
|
|
541
|
+
return "Connection lost unexpectedly - Check network connectivity";
|
|
542
|
+
case 1007:
|
|
543
|
+
return "Invalid data received from server";
|
|
544
|
+
case 1008:
|
|
545
|
+
return "Connection terminated due to policy violation";
|
|
546
|
+
case 1009:
|
|
547
|
+
return "Message size exceeded server limits";
|
|
548
|
+
case 1011:
|
|
549
|
+
return "Server encountered an internal error";
|
|
550
|
+
case 1012:
|
|
551
|
+
return "Service is restarting";
|
|
552
|
+
case 1013:
|
|
553
|
+
return "Server temporarily overloaded - Try again later";
|
|
554
|
+
case 1015:
|
|
555
|
+
return "TLS/Certificate error - Certificate may be expired";
|
|
556
|
+
default:
|
|
557
|
+
return "Network connection failed - Check IP address and connectivity";
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
var TrueNasSocket = class {
|
|
561
|
+
constructor(config) {
|
|
562
|
+
this.config = config;
|
|
563
|
+
this.socket = webSocket(this.config);
|
|
564
|
+
}
|
|
565
|
+
messages() {
|
|
566
|
+
return this.socket;
|
|
567
|
+
}
|
|
568
|
+
next(msg) {
|
|
569
|
+
this.socket.next(msg);
|
|
570
|
+
}
|
|
571
|
+
complete() {
|
|
572
|
+
this.socket.complete();
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
// src/connection/truenas-connection.ts
|
|
577
|
+
var tenSeconds = 10 * 1e3;
|
|
578
|
+
var twentySeconds = 20 * 1e3;
|
|
579
|
+
var TrueNasConnection = class {
|
|
580
|
+
constructor(initialEnabled, hostnames, systemUuid, websocketPath, systemName, retryDelay = tenSeconds, maxRetry = 3, logger = noopLogger) {
|
|
581
|
+
this.hostnames = hostnames;
|
|
582
|
+
this.systemUuid = systemUuid;
|
|
583
|
+
this.websocketPath = websocketPath;
|
|
584
|
+
this.systemName = systemName;
|
|
585
|
+
this.retryDelay = retryDelay;
|
|
586
|
+
this.maxRetry = maxRetry;
|
|
587
|
+
this.logger = logger;
|
|
588
|
+
// compatibility properties
|
|
589
|
+
this.opened = new BehaviorSubject(false);
|
|
590
|
+
this.closed = new Subject();
|
|
591
|
+
this.hostname = new BehaviorSubject("");
|
|
592
|
+
this.connectionAttempts = new BehaviorSubject(0);
|
|
593
|
+
this.lastErrorMessage = new BehaviorSubject(null);
|
|
594
|
+
/**
|
|
595
|
+
* emits when the connection is manually closed.
|
|
596
|
+
*/
|
|
597
|
+
this.closeConnection = new Subject();
|
|
598
|
+
/**
|
|
599
|
+
* the connection only establishes a socket while this gate is `true`. consumers
|
|
600
|
+
* flip it via `setEnabled()` (the app maps `SystemState.Active -> true`).
|
|
601
|
+
*/
|
|
602
|
+
this.enabled$ = new BehaviorSubject(false);
|
|
603
|
+
/**
|
|
604
|
+
* observable which emits the current gate value and only emits again when it changes.
|
|
605
|
+
*/
|
|
606
|
+
this.enabledChange$ = this.enabled$.pipe(distinctUntilChanged());
|
|
607
|
+
/**
|
|
608
|
+
* observable that handles the entire connection lifecycle.
|
|
609
|
+
*/
|
|
610
|
+
this.connection$ = this.enabledChange$.pipe(
|
|
611
|
+
switchMap((enabled) => {
|
|
612
|
+
if (enabled) {
|
|
613
|
+
return this.connect().pipe(
|
|
614
|
+
map((conn) => makeActiveConnection(conn.ws, conn.hostname))
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
return of(closedConnection);
|
|
618
|
+
}),
|
|
619
|
+
switchMap((connection) => {
|
|
620
|
+
if (connection.state !== "active") {
|
|
621
|
+
return of(connection);
|
|
622
|
+
}
|
|
623
|
+
const initMessage = createJsonRpcMessage("core.set_options", [
|
|
624
|
+
{ legacy_jobs: false }
|
|
625
|
+
]);
|
|
626
|
+
connection.ws.next(initMessage);
|
|
627
|
+
return connection.ws.messages().pipe(
|
|
628
|
+
filter((msg) => msg.id === initMessage.id),
|
|
629
|
+
take(1),
|
|
630
|
+
map(() => connection)
|
|
631
|
+
);
|
|
632
|
+
}),
|
|
633
|
+
// in the event of an error, do two things:
|
|
634
|
+
// 1. emit an errored connection downstream to inform consumers that
|
|
635
|
+
// the connection is dead.
|
|
636
|
+
// 2. re-throw an error so the downstream `retry` will re-subscribe.
|
|
637
|
+
catchError$1((err) => {
|
|
638
|
+
this.logger.error("All connections failed - retrying", { message: err?.message, hostname: err?.hostname });
|
|
639
|
+
return concat(
|
|
640
|
+
of(makeConnectionError(err.message, err.hostname)),
|
|
641
|
+
// since this error will immediately get caught by `retry` there's no reason to build a real error.
|
|
642
|
+
throwError(() => null)
|
|
643
|
+
);
|
|
644
|
+
}),
|
|
645
|
+
retry(),
|
|
646
|
+
// start with a closed connection.
|
|
647
|
+
startWith(closedConnection),
|
|
648
|
+
// prevent multiple subscriptions from re-evaluating the entire pipeline.
|
|
649
|
+
shareReplay$1({ bufferSize: 1, refCount: false }),
|
|
650
|
+
takeUntil(this.closeConnection)
|
|
651
|
+
);
|
|
652
|
+
/**
|
|
653
|
+
* observable which is either an instance of the current `TrueNasSocket` used for the connection
|
|
654
|
+
* or `null` if the connection is closed or errored for any reason.
|
|
655
|
+
*/
|
|
656
|
+
this.ws$ = this.connection$.pipe(
|
|
657
|
+
map((conn) => conn.state === "active" ? conn.ws : null)
|
|
658
|
+
);
|
|
659
|
+
/**
|
|
660
|
+
* current hostname we used to build the URL for this connection.
|
|
661
|
+
*/
|
|
662
|
+
this.hostname$ = this.connection$.pipe(
|
|
663
|
+
map((conn) => conn.state === "active" ? conn.hostname : null)
|
|
664
|
+
);
|
|
665
|
+
/**
|
|
666
|
+
* emits error messages from the connection pipeline. this is used to derive the
|
|
667
|
+
* corresponding compatibility property.
|
|
668
|
+
*/
|
|
669
|
+
this.lastErrorMessage$ = this.connection$.pipe(
|
|
670
|
+
map((conn) => conn.state === "error" ? conn.message : null)
|
|
671
|
+
);
|
|
672
|
+
/**
|
|
673
|
+
* emits when the connection state *changes*, so it'll never emit `true` twice
|
|
674
|
+
* in a row nor `false` twice in a row.
|
|
675
|
+
*/
|
|
676
|
+
this.opened$ = this.connection$.pipe(
|
|
677
|
+
map((conn) => conn.state),
|
|
678
|
+
distinctUntilChanged(),
|
|
679
|
+
map((state) => state === "active")
|
|
680
|
+
);
|
|
681
|
+
/**
|
|
682
|
+
* emits when the connection state goes from open to closed.
|
|
683
|
+
*/
|
|
684
|
+
this.closed$ = this.connection$.pipe(
|
|
685
|
+
map((conn) => conn.state),
|
|
686
|
+
distinctUntilChanged(),
|
|
687
|
+
filter((state) => state === "closed" || state === "error"),
|
|
688
|
+
map(() => {
|
|
689
|
+
})
|
|
690
|
+
);
|
|
691
|
+
/**
|
|
692
|
+
* whether to display a connection error to the user — the **live** signal.
|
|
693
|
+
*
|
|
694
|
+
* `true` only while `connection$` is currently a `ConnectionError`, meaning the entire race +
|
|
695
|
+
* retry cycle has been exhausted for all hostnames right now; it flips back to `false` once a
|
|
696
|
+
* connection is re-established. individual socket losses during a race are expected and not surfaced.
|
|
697
|
+
*
|
|
698
|
+
* NOTE: this is NOT the same as the `hasExhaustedRetries()` method, which is a **cumulative**
|
|
699
|
+
* snapshot (see there). The two can disagree — prefer this observable for "is the connection
|
|
700
|
+
* errored right now?".
|
|
701
|
+
*/
|
|
702
|
+
this.hasConnectionError$ = this.connection$.pipe(
|
|
703
|
+
map((conn) => conn.state === "error"),
|
|
704
|
+
takeUntil(this.closeConnection)
|
|
705
|
+
);
|
|
706
|
+
/**
|
|
707
|
+
* observable which always emits messages from the current socket.
|
|
708
|
+
*/
|
|
709
|
+
this.messages$ = this.ws$.pipe(
|
|
710
|
+
filter((ws) => ws !== null),
|
|
711
|
+
switchMap((ws) => ws.messages())
|
|
712
|
+
);
|
|
713
|
+
combineLatest([
|
|
714
|
+
interval(twentySeconds),
|
|
715
|
+
this.ws$
|
|
716
|
+
]).pipe(
|
|
717
|
+
takeUntil(this.closeConnection)
|
|
718
|
+
).subscribe(([, ws]) => {
|
|
719
|
+
if (ws) {
|
|
720
|
+
const pingMessage = createJsonRpcMessage("core.ping");
|
|
721
|
+
ws.next(pingMessage);
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
this.opened$.subscribe((val) => this.opened.next(val));
|
|
725
|
+
this.closed$.subscribe(() => this.closed.next());
|
|
726
|
+
this.hostname$.pipe(filter(Boolean)).subscribe((name) => this.hostname.next(name));
|
|
727
|
+
this.ws$.pipe(filter(Boolean)).subscribe((ws) => this.ws = ws);
|
|
728
|
+
this.lastErrorMessage$.subscribe((msg) => this.lastErrorMessage.next(msg));
|
|
729
|
+
this.enabled$.next(initialEnabled);
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* whether the connection has exhausted its retries — the **cumulative** snapshot, read
|
|
733
|
+
* synchronously. (Formerly `hasConnectionError()`; renamed to disambiguate it from the
|
|
734
|
+
* live `hasConnectionError$` observable, with which it can disagree.)
|
|
735
|
+
*
|
|
736
|
+
* `true` when the lifetime `connectionAttempts` count exceeds `hostnames.length * maxRetry`,
|
|
737
|
+
* OR when an error message is currently set. Ported from the source (tncui) behavior.
|
|
738
|
+
*
|
|
739
|
+
* CAVEAT (pre-existing tncui behavior, preserved here): `connectionAttempts` only ever grows —
|
|
740
|
+
* it is never reset on a successful (re)connection — so over a long-lived connection with
|
|
741
|
+
* reconnect churn this can latch to `true` even while the connection is currently healthy. For
|
|
742
|
+
* "is it errored right now?" use the live `hasConnectionError$` observable instead.
|
|
743
|
+
*/
|
|
744
|
+
hasExhaustedRetries() {
|
|
745
|
+
const attemptsExhausted = this.connectionAttempts.value > this.hostnames.length * this.maxRetry;
|
|
746
|
+
const hasErrorMessage = this.lastErrorMessage.value !== null;
|
|
747
|
+
return attemptsExhausted || hasErrorMessage;
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* enables or disables the connection gate. the app calls this when its `SystemState`
|
|
751
|
+
* changes (mapping `SystemState.Active -> true`, everything else -> `false`).
|
|
752
|
+
*/
|
|
753
|
+
setEnabled(enabled) {
|
|
754
|
+
this.enabled$.next(enabled);
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* compatibility method which just returns the public `messages$` observable.
|
|
758
|
+
* this is used in a few places in the codebase that relied upon the old implementation.
|
|
759
|
+
*/
|
|
760
|
+
messages() {
|
|
761
|
+
return this.messages$;
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* sends a message over the current websocket OR queues it to send once
|
|
765
|
+
* the next websocket is opened. messages sent via `send` will not be lost until after
|
|
766
|
+
* the next `closed$` emission.
|
|
767
|
+
*/
|
|
768
|
+
send(message) {
|
|
769
|
+
return this.ws$.pipe(
|
|
770
|
+
filter((ws) => ws !== null),
|
|
771
|
+
take(1)
|
|
772
|
+
).subscribe((ws) => ws.next(message));
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* manually closes this connection and prevents it from retrying/opening any more.
|
|
776
|
+
*/
|
|
777
|
+
close() {
|
|
778
|
+
this.closeConnection.next();
|
|
779
|
+
this.closeConnection.complete();
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* helper method which creates an `Observable` which emits an `ActiveConnection`
|
|
783
|
+
* after it establishes a connection to the given hostname. the observable will emit an
|
|
784
|
+
* error if the connection is never established and will not complete until unsubscribed from or closed.
|
|
785
|
+
*/
|
|
786
|
+
createSocket(hostname) {
|
|
787
|
+
const url = `wss://${hostname}${this.websocketPath}`;
|
|
788
|
+
return new Observable((subscriber) => {
|
|
789
|
+
const ws = new TrueNasSocket({
|
|
790
|
+
url,
|
|
791
|
+
openObserver: {
|
|
792
|
+
next: () => {
|
|
793
|
+
subscriber.next({ ws, hostname });
|
|
794
|
+
}
|
|
795
|
+
},
|
|
796
|
+
closeObserver: {
|
|
797
|
+
next: (event) => {
|
|
798
|
+
const reason = event.reason || "";
|
|
799
|
+
let errorMessage;
|
|
800
|
+
if (isHttpStatusError(reason)) {
|
|
801
|
+
errorMessage = getHttpError(reason);
|
|
802
|
+
} else {
|
|
803
|
+
errorMessage = getWebSocketError(event.code);
|
|
804
|
+
}
|
|
805
|
+
this.connectionAttempts.next(this.connectionAttempts.value + 1);
|
|
806
|
+
subscriber.error(makeConnectionError(errorMessage, hostname));
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
});
|
|
810
|
+
const startupSub = ws.socket.subscribe({
|
|
811
|
+
error: () => {
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
return () => {
|
|
815
|
+
startupSub.unsubscribe();
|
|
816
|
+
ws.complete();
|
|
817
|
+
};
|
|
818
|
+
}).pipe(
|
|
819
|
+
// retry logic:
|
|
820
|
+
// * if a connection is not established in 10 seconds, consider that an error
|
|
821
|
+
timeout({ first: tenSeconds }),
|
|
822
|
+
// * if an error happens, wait `retryDelay` before trying again.
|
|
823
|
+
// after `maxRetry` retries, give up.
|
|
824
|
+
retry({ count: this.maxRetry, delay: this.retryDelay })
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* helper function which actually performs the parallel connection `race`.
|
|
829
|
+
*/
|
|
830
|
+
connect() {
|
|
831
|
+
return race(
|
|
832
|
+
this.hostnames.map(this.createSocket.bind(this))
|
|
833
|
+
).pipe(
|
|
834
|
+
tap((conn) => this.logger.debug(`TrueNas socket opened to ${conn.hostname}.`)),
|
|
835
|
+
takeUntil(this.closeConnection)
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
var makeActiveConnection = (ws, hostname) => ({
|
|
840
|
+
ws,
|
|
841
|
+
hostname,
|
|
842
|
+
state: "active"
|
|
843
|
+
});
|
|
844
|
+
var makeConnectionError = (message, hostname) => ({
|
|
845
|
+
name: "ConnectionError",
|
|
846
|
+
message,
|
|
847
|
+
hostname,
|
|
848
|
+
state: "error"
|
|
849
|
+
});
|
|
850
|
+
var closedConnection = { state: "closed" };
|
|
851
|
+
|
|
852
|
+
// src/config/api-version.config.ts
|
|
853
|
+
var apiVersionConfig = {
|
|
854
|
+
/**
|
|
855
|
+
* Minimum supported API version.
|
|
856
|
+
* Systems with older API versions will be rejected.
|
|
857
|
+
*/
|
|
858
|
+
MIN_SUPPORTED_VERSION: "v25.10.0",
|
|
859
|
+
/**
|
|
860
|
+
* Maximum supported API version.
|
|
861
|
+
* Systems with newer API versions will be rejected.
|
|
862
|
+
*/
|
|
863
|
+
MAX_SUPPORTED_VERSION: "v26.0.0",
|
|
864
|
+
/**
|
|
865
|
+
* Fallback version to use when version discovery fails due to CORS/network errors.
|
|
866
|
+
* When /api/versions returns HTTP status 0 (CORS block, network down, etc.),
|
|
867
|
+
* the system will attempt to connect using this version as a best-effort fallback.
|
|
868
|
+
*
|
|
869
|
+
* WARNING: Status 0 errors have multiple causes:
|
|
870
|
+
* - CORS policy blocking the request
|
|
871
|
+
* - Network disconnected
|
|
872
|
+
* - DNS lookup failure
|
|
873
|
+
* - Server unreachable
|
|
874
|
+
*
|
|
875
|
+
* Using this fallback means systems with genuine network issues will still
|
|
876
|
+
* attempt connection (and fail during WebSocket handshake instead of immediately).
|
|
877
|
+
*/
|
|
878
|
+
FALLBACK_VERSION: "v25.10.0"
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
// src/types/api-version.type.ts
|
|
882
|
+
var VersionCompatibility = /* @__PURE__ */ ((VersionCompatibility2) => {
|
|
883
|
+
VersionCompatibility2["Compatible"] = "compatible";
|
|
884
|
+
VersionCompatibility2["TooOld"] = "too-old";
|
|
885
|
+
VersionCompatibility2["TooNew"] = "too-new";
|
|
886
|
+
VersionCompatibility2["Invalid"] = "invalid";
|
|
887
|
+
return VersionCompatibility2;
|
|
888
|
+
})(VersionCompatibility || {});
|
|
889
|
+
|
|
890
|
+
// src/utils/api-version.utils.ts
|
|
891
|
+
var legacyCutoffYear = 25;
|
|
892
|
+
function parseApiVersion(versionString) {
|
|
893
|
+
const regex = /^v(\d{2})\.(\d+)\.(\d+)$/;
|
|
894
|
+
const match = versionString.match(regex);
|
|
895
|
+
if (!match) {
|
|
896
|
+
return null;
|
|
897
|
+
}
|
|
898
|
+
const [, yearStr, minorStr, patchStr] = match;
|
|
899
|
+
const year = parseInt(yearStr, 10);
|
|
900
|
+
const minor = parseInt(minorStr, 10);
|
|
901
|
+
const patch = parseInt(patchStr, 10);
|
|
902
|
+
if (year <= legacyCutoffYear) {
|
|
903
|
+
if (minor < 1 || minor > 12) {
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
} else if (year >= 26) {
|
|
907
|
+
if (minor < 0 || minor > 99) {
|
|
908
|
+
return null;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return {
|
|
912
|
+
version: versionString,
|
|
913
|
+
year,
|
|
914
|
+
minor,
|
|
915
|
+
patch,
|
|
916
|
+
websocketPath: `/api/${versionString}`
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
function compareVersions(a, b) {
|
|
920
|
+
if (a.year !== b.year) {
|
|
921
|
+
return a.year - b.year;
|
|
922
|
+
}
|
|
923
|
+
if (a.minor !== b.minor) {
|
|
924
|
+
return a.minor - b.minor;
|
|
925
|
+
}
|
|
926
|
+
return a.patch - b.patch;
|
|
927
|
+
}
|
|
928
|
+
function checkVersionCompatibility(version) {
|
|
929
|
+
const minVersion = parseApiVersion(apiVersionConfig.MIN_SUPPORTED_VERSION);
|
|
930
|
+
const maxVersion = parseApiVersion(apiVersionConfig.MAX_SUPPORTED_VERSION);
|
|
931
|
+
if (!minVersion || !maxVersion) {
|
|
932
|
+
return "invalid" /* Invalid */;
|
|
933
|
+
}
|
|
934
|
+
if (compareVersions(version, minVersion) < 0) {
|
|
935
|
+
return "too-old" /* TooOld */;
|
|
936
|
+
}
|
|
937
|
+
if (compareVersions(version, maxVersion) > 0) {
|
|
938
|
+
return "too-new" /* TooNew */;
|
|
939
|
+
}
|
|
940
|
+
return "compatible" /* Compatible */;
|
|
941
|
+
}
|
|
942
|
+
function isVersionSupported(version) {
|
|
943
|
+
return checkVersionCompatibility(version) === "compatible" /* Compatible */;
|
|
944
|
+
}
|
|
945
|
+
function filterCompatibleVersions(versions) {
|
|
946
|
+
return versions.filter(isVersionSupported);
|
|
947
|
+
}
|
|
948
|
+
function getWebSocketPath(version) {
|
|
949
|
+
return version.websocketPath;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// src/client/truenas-api-client.ts
|
|
953
|
+
var TrueNasApiClient = class {
|
|
954
|
+
constructor(uuid, hostnames, version, enabled, systemName, logger = noopLogger) {
|
|
955
|
+
this.uuid = uuid;
|
|
956
|
+
this.hostnames = hostnames;
|
|
957
|
+
this.version = version;
|
|
958
|
+
this.enabled = enabled;
|
|
959
|
+
this.systemName = systemName;
|
|
960
|
+
this.logger = logger;
|
|
961
|
+
this.connection = this.createConnection();
|
|
962
|
+
this.authenticator = this.createAuthenticator();
|
|
963
|
+
this.api = this.createApi();
|
|
964
|
+
this.ops = this.createOperations();
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Get current connection status.
|
|
968
|
+
* @returns true if WebSocket is connected
|
|
969
|
+
*/
|
|
970
|
+
get connected() {
|
|
971
|
+
return this.connection.opened.getValue();
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Get current authentication status.
|
|
975
|
+
* @returns true if authenticated with the system
|
|
976
|
+
*/
|
|
977
|
+
get authenticated() {
|
|
978
|
+
return this.authenticator.authenticated$.getValue();
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Close the WebSocket connection.
|
|
982
|
+
* Connection will automatically retry if retry is enabled.
|
|
983
|
+
*/
|
|
984
|
+
close() {
|
|
985
|
+
this.connection.close();
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* Factory method to create the WebSocket connection.
|
|
989
|
+
* Override in subclasses to provide version-specific connection handling.
|
|
990
|
+
*/
|
|
991
|
+
createConnection() {
|
|
992
|
+
const websocketPath = getWebSocketPath(this.version);
|
|
993
|
+
return new TrueNasConnection(
|
|
994
|
+
this.enabled,
|
|
995
|
+
this.hostnames,
|
|
996
|
+
this.uuid,
|
|
997
|
+
websocketPath,
|
|
998
|
+
this.systemName,
|
|
999
|
+
void 0,
|
|
1000
|
+
// retryDelay (use default)
|
|
1001
|
+
void 0,
|
|
1002
|
+
// maxRetry (use default)
|
|
1003
|
+
this.logger
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* Factory method to create the API handler.
|
|
1008
|
+
* Override in subclasses to provide version-specific API implementations.
|
|
1009
|
+
*/
|
|
1010
|
+
createApi() {
|
|
1011
|
+
return new TrueNasApi(this.authenticator.authenticated$, this.connection);
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Factory method to create the authenticator.
|
|
1015
|
+
* Override in subclasses to provide version-specific authentication.
|
|
1016
|
+
*/
|
|
1017
|
+
createAuthenticator() {
|
|
1018
|
+
return new TrueNasAuthenticator(this.connection);
|
|
1019
|
+
}
|
|
1020
|
+
};
|
|
1021
|
+
|
|
1022
|
+
// src/client/truenas-api-client-v25-10.ts
|
|
1023
|
+
var TrueNasApiClientV2510 = class extends TrueNasApiClient {
|
|
1024
|
+
/**
|
|
1025
|
+
* Create v25.10-specific operation mappings
|
|
1026
|
+
*
|
|
1027
|
+
* virt.instance.* APIs return boolean (true) but emit job events via websocket.
|
|
1028
|
+
* We use callAndGetJobId to capture the job ID, then track the job.
|
|
1029
|
+
* All operations emit Job updates until the operation completes.
|
|
1030
|
+
*/
|
|
1031
|
+
createOperations() {
|
|
1032
|
+
return {
|
|
1033
|
+
containerQuery: () => this.api.call("virt.instance.query" /* VirtualInstanceQuery */, [
|
|
1034
|
+
[["type", "=", "CONTAINER" /* Container */]]
|
|
1035
|
+
]).pipe(map((instances) => instances.map(this.toContainer))),
|
|
1036
|
+
containerStart: (id) => this.api.callAndGetJobId("virt.instance.start" /* VirtualInstanceStart */, [id]).pipe(switchMap((jobId) => this.api.trackJob(jobId))),
|
|
1037
|
+
containerStop: (id, options) => this.api.callAndGetJobId("virt.instance.stop" /* VirtualInstanceStop */, [id, options]).pipe(switchMap((jobId) => this.api.trackJob(jobId))),
|
|
1038
|
+
containerRestart: (id, options) => this.api.callAndGetJobId("virt.instance.restart" /* VirtualInstanceRestart */, [
|
|
1039
|
+
id,
|
|
1040
|
+
options
|
|
1041
|
+
]).pipe(switchMap((jobId) => this.api.trackJob(jobId)))
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Transform VirtualInstanceQuery to unified Container type
|
|
1046
|
+
*/
|
|
1047
|
+
toContainer(instance) {
|
|
1048
|
+
return {
|
|
1049
|
+
id: instance.id,
|
|
1050
|
+
name: instance.name,
|
|
1051
|
+
status: instance.status,
|
|
1052
|
+
autostart: instance.autostart,
|
|
1053
|
+
cpu: instance.cpu,
|
|
1054
|
+
memory: instance.memory,
|
|
1055
|
+
image: instance.image
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1059
|
+
var TrueNasApiClientV26 = class _TrueNasApiClientV26 extends TrueNasApiClient {
|
|
1060
|
+
/**
|
|
1061
|
+
* Map v26 status state string to AppState enum
|
|
1062
|
+
*/
|
|
1063
|
+
static mapStatus(state) {
|
|
1064
|
+
switch (state.toUpperCase()) {
|
|
1065
|
+
case "RUNNING":
|
|
1066
|
+
return "RUNNING" /* Running */;
|
|
1067
|
+
case "STOPPED":
|
|
1068
|
+
return "STOPPED" /* Stopped */;
|
|
1069
|
+
case "STOPPING":
|
|
1070
|
+
return "STOPPING" /* Stopping */;
|
|
1071
|
+
default:
|
|
1072
|
+
return "STOPPED" /* Stopped */;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Create v26-specific operation mappings
|
|
1077
|
+
*
|
|
1078
|
+
* Operations return Observable<Job | null>:
|
|
1079
|
+
* - Async operations emit Job updates until complete
|
|
1080
|
+
* - Sync operations emit null once
|
|
1081
|
+
*/
|
|
1082
|
+
createOperations() {
|
|
1083
|
+
return {
|
|
1084
|
+
containerQuery: () => this.api.call("container.query" /* ContainerQuery */, [[]]).pipe(map((containers) => containers.map(this.toContainer))),
|
|
1085
|
+
// container.start is synchronous in v26.0.0 - emit null
|
|
1086
|
+
containerStart: (id) => this.api.call("container.start" /* ContainerStart */, [parseInt(id, 10)]).pipe(map(() => null)),
|
|
1087
|
+
// container.stop emits job updates
|
|
1088
|
+
containerStop: (id, options) => this.api.callAndGetJobId("container.stop" /* ContainerStop */, [
|
|
1089
|
+
parseInt(id, 10),
|
|
1090
|
+
{
|
|
1091
|
+
force: options.force,
|
|
1092
|
+
force_after_timeout: options.force
|
|
1093
|
+
}
|
|
1094
|
+
]).pipe(switchMap((jobId) => this.api.trackJob(jobId))),
|
|
1095
|
+
// v26.0.0 doesn't have container.restart - chain stop + start
|
|
1096
|
+
// Emits Job updates during stop, then null when start completes
|
|
1097
|
+
containerRestart: (id, options) => {
|
|
1098
|
+
const numericId = parseInt(id, 10);
|
|
1099
|
+
return this.api.callAndGetJobId("container.stop" /* ContainerStop */, [
|
|
1100
|
+
numericId,
|
|
1101
|
+
{
|
|
1102
|
+
force: options.force,
|
|
1103
|
+
force_after_timeout: options.force
|
|
1104
|
+
}
|
|
1105
|
+
]).pipe(
|
|
1106
|
+
// Track stop job until it completes
|
|
1107
|
+
switchMap((stopJobId) => this.api.trackJob(stopJobId)),
|
|
1108
|
+
// Collect all job updates to ensure stop fully completes
|
|
1109
|
+
toArray(),
|
|
1110
|
+
// Re-emit job updates, then call start after stop is done
|
|
1111
|
+
switchMap(
|
|
1112
|
+
(jobUpdates) => concat(
|
|
1113
|
+
from(jobUpdates),
|
|
1114
|
+
this.api.call("container.start" /* ContainerStart */, [numericId]).pipe(map(() => null))
|
|
1115
|
+
)
|
|
1116
|
+
)
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* Transform v26 ContainerQueryV26 to unified Container type
|
|
1123
|
+
*/
|
|
1124
|
+
toContainer(container) {
|
|
1125
|
+
return {
|
|
1126
|
+
id: container.id.toString(),
|
|
1127
|
+
name: container.name,
|
|
1128
|
+
status: _TrueNasApiClientV26.mapStatus(container.status.state),
|
|
1129
|
+
autostart: container.autostart,
|
|
1130
|
+
description: container.description
|
|
1131
|
+
// cpu and memory are not available in v26 container.query
|
|
1132
|
+
// image is not available in v26 container.query
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
};
|
|
1136
|
+
|
|
1137
|
+
// src/errors/version-discovery.errors.ts
|
|
1138
|
+
var VersionDiscoveryError = class extends Error {
|
|
1139
|
+
constructor(message, hostname) {
|
|
1140
|
+
super(message);
|
|
1141
|
+
this.hostname = hostname;
|
|
1142
|
+
this.name = this.constructor.name;
|
|
1143
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
1144
|
+
}
|
|
1145
|
+
};
|
|
1146
|
+
var VersionTooOldError = class extends VersionDiscoveryError {
|
|
1147
|
+
constructor(hostname, availableVersions, minRequired = apiVersionConfig.MIN_SUPPORTED_VERSION) {
|
|
1148
|
+
super(
|
|
1149
|
+
`System API version is too old. Minimum supported: ${minRequired}. Available versions: ${availableVersions.join(
|
|
1150
|
+
", "
|
|
1151
|
+
)}`,
|
|
1152
|
+
hostname
|
|
1153
|
+
);
|
|
1154
|
+
this.availableVersions = availableVersions;
|
|
1155
|
+
this.minRequired = minRequired;
|
|
1156
|
+
}
|
|
1157
|
+
};
|
|
1158
|
+
var VersionTooNewError = class extends VersionDiscoveryError {
|
|
1159
|
+
constructor(hostname, availableVersions, maxSupported = apiVersionConfig.MAX_SUPPORTED_VERSION) {
|
|
1160
|
+
super(
|
|
1161
|
+
`System API version is too new. Maximum supported: ${maxSupported}. Available versions: ${availableVersions.join(
|
|
1162
|
+
", "
|
|
1163
|
+
)}`,
|
|
1164
|
+
hostname
|
|
1165
|
+
);
|
|
1166
|
+
this.availableVersions = availableVersions;
|
|
1167
|
+
this.maxSupported = maxSupported;
|
|
1168
|
+
}
|
|
1169
|
+
};
|
|
1170
|
+
var VersionEndpointNotFoundError = class extends VersionDiscoveryError {
|
|
1171
|
+
constructor(hostname, statusCode = 404) {
|
|
1172
|
+
super(
|
|
1173
|
+
`Version discovery endpoint not found: ${hostname} (HTTP ${String(
|
|
1174
|
+
statusCode
|
|
1175
|
+
)})`,
|
|
1176
|
+
hostname
|
|
1177
|
+
);
|
|
1178
|
+
this.statusCode = statusCode;
|
|
1179
|
+
}
|
|
1180
|
+
};
|
|
1181
|
+
var VersionDiscoveryTimeoutError = class extends VersionDiscoveryError {
|
|
1182
|
+
constructor(hostname, timeoutMs = 5e3) {
|
|
1183
|
+
super(
|
|
1184
|
+
`Cannot connect to system (timeout after ${String(
|
|
1185
|
+
timeoutMs
|
|
1186
|
+
)}ms): ${hostname}`,
|
|
1187
|
+
hostname
|
|
1188
|
+
);
|
|
1189
|
+
this.timeoutMs = timeoutMs;
|
|
1190
|
+
}
|
|
1191
|
+
};
|
|
1192
|
+
var VersionDiscoveryNetworkError = class extends VersionDiscoveryError {
|
|
1193
|
+
constructor(hostname, originalError) {
|
|
1194
|
+
super(
|
|
1195
|
+
`Network error connecting to ${hostname}: ${originalError.message}`,
|
|
1196
|
+
hostname
|
|
1197
|
+
);
|
|
1198
|
+
this.originalError = originalError;
|
|
1199
|
+
}
|
|
1200
|
+
};
|
|
1201
|
+
var NoCompatibleVersionsError = class extends VersionDiscoveryError {
|
|
1202
|
+
constructor(hostname, availableVersions, supportedRange = {
|
|
1203
|
+
min: apiVersionConfig.MIN_SUPPORTED_VERSION,
|
|
1204
|
+
max: apiVersionConfig.MAX_SUPPORTED_VERSION
|
|
1205
|
+
}) {
|
|
1206
|
+
super(
|
|
1207
|
+
`No compatible API versions available. Supported: ${supportedRange.min} to ${supportedRange.max}. Available: ${availableVersions.join(", ")}`,
|
|
1208
|
+
hostname
|
|
1209
|
+
);
|
|
1210
|
+
this.availableVersions = availableVersions;
|
|
1211
|
+
this.supportedRange = supportedRange;
|
|
1212
|
+
}
|
|
1213
|
+
};
|
|
1214
|
+
var InvalidVersionResponseError = class extends VersionDiscoveryError {
|
|
1215
|
+
constructor(hostname, reason) {
|
|
1216
|
+
super(`Invalid API response format from ${hostname}: ${reason}`, hostname);
|
|
1217
|
+
this.reason = reason;
|
|
1218
|
+
}
|
|
1219
|
+
};
|
|
1220
|
+
var discoveryTimeoutMs = 5e3;
|
|
1221
|
+
function hasErrorName(error, expected) {
|
|
1222
|
+
return typeof error === "object" && error !== null && "name" in error && error.name === expected;
|
|
1223
|
+
}
|
|
1224
|
+
var VersionDiscovery = class {
|
|
1225
|
+
constructor(logger = noopLogger) {
|
|
1226
|
+
this.logger = logger;
|
|
1227
|
+
this.versionCache = /* @__PURE__ */ new Map();
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Discovers the API version for a given hostname.
|
|
1231
|
+
*
|
|
1232
|
+
* Makes a GET request to `https://{hostname}/api/versions` and returns the latest
|
|
1233
|
+
* compatible version. Results are cached per hostname; the cache entry is removed
|
|
1234
|
+
* on failure so the next call retries.
|
|
1235
|
+
*
|
|
1236
|
+
* @param hostname - The TrueNAS system hostname (e.g., "truenas.local")
|
|
1237
|
+
* @returns Observable that emits the selected ApiVersion
|
|
1238
|
+
* @throws VersionDiscoveryError subclasses for specific failure scenarios
|
|
1239
|
+
*/
|
|
1240
|
+
discoverVersion(hostname) {
|
|
1241
|
+
const cached = this.versionCache.get(hostname);
|
|
1242
|
+
if (cached) {
|
|
1243
|
+
this.logger.info("Version discovery cache hit", { hostname });
|
|
1244
|
+
return cached;
|
|
1245
|
+
}
|
|
1246
|
+
const url = `https://${hostname}/api/versions`;
|
|
1247
|
+
this.logger.info("Starting version discovery", { hostname, url });
|
|
1248
|
+
const discovery$ = defer(() => from(this.fetchVersions(hostname))).pipe(
|
|
1249
|
+
map$1((versionStrings) => this.selectVersion(hostname, versionStrings)),
|
|
1250
|
+
catchError((error) => {
|
|
1251
|
+
this.versionCache.delete(hostname);
|
|
1252
|
+
this.logger.error("Version discovery failed", { hostname, error });
|
|
1253
|
+
return throwError(() => this.classify(error, hostname));
|
|
1254
|
+
}),
|
|
1255
|
+
shareReplay(1)
|
|
1256
|
+
);
|
|
1257
|
+
this.versionCache.set(hostname, discovery$);
|
|
1258
|
+
return discovery$;
|
|
1259
|
+
}
|
|
1260
|
+
/**
|
|
1261
|
+
* Clears the version cache for a specific hostname or all hostnames.
|
|
1262
|
+
*
|
|
1263
|
+
* @param hostname - Optional hostname to clear. If omitted, clears all cached versions.
|
|
1264
|
+
*/
|
|
1265
|
+
clearCache(hostname) {
|
|
1266
|
+
if (hostname) {
|
|
1267
|
+
this.versionCache.delete(hostname);
|
|
1268
|
+
} else {
|
|
1269
|
+
this.versionCache.clear();
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Performs the `fetch` with a 5-second `AbortController` timeout and inspects the
|
|
1274
|
+
* *resolved* Response (fetch does not reject on non-2xx). Throws
|
|
1275
|
+
* `VersionEndpointNotFoundError` on 404 and `InvalidVersionResponseError` on any
|
|
1276
|
+
* other non-2xx or a body that is not an array of strings; lets `TypeError`
|
|
1277
|
+
* (network) and `AbortError` (timeout) bubble to `classify`.
|
|
1278
|
+
*
|
|
1279
|
+
* The element-type check matters: a reachable server returning an array of
|
|
1280
|
+
* non-strings (e.g. `[1, 2, 3]`) would otherwise reach `parseApiVersion`, whose
|
|
1281
|
+
* `.match()` throws a `TypeError` on a non-string — which `classify` would then
|
|
1282
|
+
* misfile as a network error. Validating here keeps it an `InvalidVersionResponseError`.
|
|
1283
|
+
*/
|
|
1284
|
+
async fetchVersions(hostname) {
|
|
1285
|
+
const url = `https://${hostname}/api/versions`;
|
|
1286
|
+
const controller = new AbortController();
|
|
1287
|
+
const timer = setTimeout(() => controller.abort(), discoveryTimeoutMs);
|
|
1288
|
+
try {
|
|
1289
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
1290
|
+
if (response.status === 404) {
|
|
1291
|
+
throw new VersionEndpointNotFoundError(hostname);
|
|
1292
|
+
}
|
|
1293
|
+
if (!response.ok) {
|
|
1294
|
+
throw new InvalidVersionResponseError(
|
|
1295
|
+
hostname,
|
|
1296
|
+
`HTTP ${String(response.status)}`
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
const body = await response.json();
|
|
1300
|
+
if (!Array.isArray(body) || !body.every((v) => typeof v === "string")) {
|
|
1301
|
+
throw new InvalidVersionResponseError(
|
|
1302
|
+
hostname,
|
|
1303
|
+
"Response was not an array of version strings"
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
return body;
|
|
1307
|
+
} finally {
|
|
1308
|
+
clearTimeout(timer);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
/**
|
|
1312
|
+
* Parses, filters, and selects the latest compatible version from the raw response
|
|
1313
|
+
* array. Throws the appropriate typed error when no valid/compatible version exists.
|
|
1314
|
+
*/
|
|
1315
|
+
selectVersion(hostname, versionStrings) {
|
|
1316
|
+
this.logger.info("Version discovery response received", {
|
|
1317
|
+
hostname,
|
|
1318
|
+
versionCount: versionStrings.length,
|
|
1319
|
+
versions: versionStrings
|
|
1320
|
+
});
|
|
1321
|
+
const parsedVersions = versionStrings.map(parseApiVersion).filter((v) => v !== null);
|
|
1322
|
+
if (parsedVersions.length === 0) {
|
|
1323
|
+
this.logger.error("No valid versions in response", {
|
|
1324
|
+
hostname,
|
|
1325
|
+
versionStrings
|
|
1326
|
+
});
|
|
1327
|
+
throw new InvalidVersionResponseError(
|
|
1328
|
+
hostname,
|
|
1329
|
+
"No valid API versions found in response"
|
|
1330
|
+
);
|
|
1331
|
+
}
|
|
1332
|
+
const compatibleVersions = filterCompatibleVersions(parsedVersions);
|
|
1333
|
+
this.logger.info("Version compatibility check", {
|
|
1334
|
+
hostname,
|
|
1335
|
+
total: parsedVersions.length,
|
|
1336
|
+
compatible: compatibleVersions.length,
|
|
1337
|
+
supportedRange: {
|
|
1338
|
+
min: apiVersionConfig.MIN_SUPPORTED_VERSION,
|
|
1339
|
+
max: apiVersionConfig.MAX_SUPPORTED_VERSION
|
|
1340
|
+
}
|
|
1341
|
+
});
|
|
1342
|
+
if (compatibleVersions.length === 0) {
|
|
1343
|
+
const allTooOld = parsedVersions.every(
|
|
1344
|
+
(v) => checkVersionCompatibility(v) === "too-old" /* TooOld */
|
|
1345
|
+
);
|
|
1346
|
+
const allTooNew = parsedVersions.every(
|
|
1347
|
+
(v) => checkVersionCompatibility(v) === "too-new" /* TooNew */
|
|
1348
|
+
);
|
|
1349
|
+
const availableVersions = parsedVersions.map((v) => v.version);
|
|
1350
|
+
if (allTooOld) {
|
|
1351
|
+
this.logger.warn("All available versions too old", {
|
|
1352
|
+
hostname,
|
|
1353
|
+
availableVersions
|
|
1354
|
+
});
|
|
1355
|
+
throw new VersionTooOldError(hostname, availableVersions);
|
|
1356
|
+
} else if (allTooNew) {
|
|
1357
|
+
this.logger.warn("All available versions too new", {
|
|
1358
|
+
hostname,
|
|
1359
|
+
availableVersions
|
|
1360
|
+
});
|
|
1361
|
+
throw new VersionTooNewError(hostname, availableVersions);
|
|
1362
|
+
} else {
|
|
1363
|
+
this.logger.warn("No compatible versions found", {
|
|
1364
|
+
hostname,
|
|
1365
|
+
availableVersions
|
|
1366
|
+
});
|
|
1367
|
+
throw new NoCompatibleVersionsError(hostname, availableVersions);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
const sortedVersions = compatibleVersions.sort(
|
|
1371
|
+
(a, b) => compareVersions(b, a)
|
|
1372
|
+
);
|
|
1373
|
+
const selectedVersion = sortedVersions[0];
|
|
1374
|
+
this.logger.info("Version selected", {
|
|
1375
|
+
hostname,
|
|
1376
|
+
selected: selectedVersion.version,
|
|
1377
|
+
websocketPath: selectedVersion.websocketPath
|
|
1378
|
+
});
|
|
1379
|
+
return selectedVersion;
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* Maps a raw failure to a typed {@link VersionDiscoveryError}. Errors thrown by
|
|
1383
|
+
* `fetchVersions`/`selectVersion` are already typed and pass through unchanged; a
|
|
1384
|
+
* `fetch` `TypeError` becomes {@link VersionDiscoveryNetworkError} (the CORS/network
|
|
1385
|
+
* sentinel), an `AbortError` becomes {@link VersionDiscoveryTimeoutError}, and any
|
|
1386
|
+
* other failure (e.g. an unparseable body's `SyntaxError`) becomes
|
|
1387
|
+
* {@link InvalidVersionResponseError}.
|
|
1388
|
+
*/
|
|
1389
|
+
classify(error, hostname) {
|
|
1390
|
+
if (error instanceof VersionDiscoveryError) {
|
|
1391
|
+
return error;
|
|
1392
|
+
}
|
|
1393
|
+
if (hasErrorName(error, "AbortError")) {
|
|
1394
|
+
this.logger.warn("Version discovery timeout", { hostname });
|
|
1395
|
+
return new VersionDiscoveryTimeoutError(hostname);
|
|
1396
|
+
}
|
|
1397
|
+
if (error instanceof TypeError) {
|
|
1398
|
+
this.logger.warn("Version discovery network error", {
|
|
1399
|
+
hostname,
|
|
1400
|
+
message: error.message
|
|
1401
|
+
});
|
|
1402
|
+
return new VersionDiscoveryNetworkError(hostname, error);
|
|
1403
|
+
}
|
|
1404
|
+
const reason = error instanceof Error ? error.message : "Unknown error";
|
|
1405
|
+
this.logger.warn("Version discovery failed with unknown error", {
|
|
1406
|
+
hostname,
|
|
1407
|
+
reason
|
|
1408
|
+
});
|
|
1409
|
+
return new InvalidVersionResponseError(
|
|
1410
|
+
hostname,
|
|
1411
|
+
`Versioned API not available. This system may be running an older TrueNAS version (requires v25.10.0+). ${reason}`
|
|
1412
|
+
);
|
|
1413
|
+
}
|
|
1414
|
+
};
|
|
1415
|
+
|
|
1416
|
+
// src/factory.ts
|
|
1417
|
+
async function createTrueNasClient(opts) {
|
|
1418
|
+
const { uuid, hostnames, systemName } = opts;
|
|
1419
|
+
const logger = opts.logger ?? noopLogger;
|
|
1420
|
+
if (!hostnames || hostnames.length === 0) {
|
|
1421
|
+
throw new Error(
|
|
1422
|
+
`Cannot create client for system ${uuid}: hostnames array is empty`
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
const primaryHostname = hostnames[0];
|
|
1426
|
+
const versionDiscovery = new VersionDiscovery(logger);
|
|
1427
|
+
logger.info("Creating versioned API client", {
|
|
1428
|
+
uuid: uuid.slice(0, 8),
|
|
1429
|
+
hostname: primaryHostname,
|
|
1430
|
+
systemName
|
|
1431
|
+
});
|
|
1432
|
+
let version;
|
|
1433
|
+
try {
|
|
1434
|
+
version = await firstValueFrom(
|
|
1435
|
+
versionDiscovery.discoverVersion(primaryHostname)
|
|
1436
|
+
);
|
|
1437
|
+
logger.info("API version discovered, instantiating client", {
|
|
1438
|
+
uuid: uuid.slice(0, 8),
|
|
1439
|
+
version: version.version,
|
|
1440
|
+
websocketPath: version.websocketPath
|
|
1441
|
+
});
|
|
1442
|
+
} catch (error) {
|
|
1443
|
+
const errorMessage = errorMessageOrDefault(error, "Unknown error");
|
|
1444
|
+
if (!(error instanceof VersionDiscoveryNetworkError)) {
|
|
1445
|
+
logger.error("Version discovery failed", {
|
|
1446
|
+
uuid: uuid.slice(0, 8),
|
|
1447
|
+
hostname: primaryHostname,
|
|
1448
|
+
error: errorMessage,
|
|
1449
|
+
errorType: error instanceof Error ? error.constructor.name : typeof error
|
|
1450
|
+
});
|
|
1451
|
+
throw error;
|
|
1452
|
+
}
|
|
1453
|
+
const fallbackVersionString = apiVersionConfig.FALLBACK_VERSION;
|
|
1454
|
+
const fallbackVersion = parseApiVersion(fallbackVersionString);
|
|
1455
|
+
if (!fallbackVersion) {
|
|
1456
|
+
logger.error("Invalid fallback version configuration", {
|
|
1457
|
+
uuid: uuid.slice(0, 8),
|
|
1458
|
+
hostname: primaryHostname,
|
|
1459
|
+
fallbackVersion: fallbackVersionString
|
|
1460
|
+
});
|
|
1461
|
+
throw error;
|
|
1462
|
+
}
|
|
1463
|
+
logger.warn(
|
|
1464
|
+
"Version discovery failed with a network error (possible CORS or network issue), falling back to assumed version",
|
|
1465
|
+
{
|
|
1466
|
+
uuid: uuid.slice(0, 8),
|
|
1467
|
+
hostname: primaryHostname,
|
|
1468
|
+
fallbackVersion: fallbackVersionString,
|
|
1469
|
+
originalError: errorMessage,
|
|
1470
|
+
warning: "A network error has multiple causes (CORS, network down, DNS failure). The connection may still fail during the WebSocket handshake."
|
|
1471
|
+
}
|
|
1472
|
+
);
|
|
1473
|
+
version = fallbackVersion;
|
|
1474
|
+
}
|
|
1475
|
+
return instantiateClientForVersion(version, opts, logger);
|
|
1476
|
+
}
|
|
1477
|
+
function instantiateClientForVersion(version, opts, logger) {
|
|
1478
|
+
const { uuid, hostnames, enabled, systemName } = opts;
|
|
1479
|
+
let versionKey;
|
|
1480
|
+
if (version.year <= legacyCutoffYear) {
|
|
1481
|
+
const monthPadded = version.minor.toString().padStart(2, "0");
|
|
1482
|
+
versionKey = `${version.year.toString()}.${monthPadded}`;
|
|
1483
|
+
} else {
|
|
1484
|
+
versionKey = version.year.toString();
|
|
1485
|
+
}
|
|
1486
|
+
switch (versionKey) {
|
|
1487
|
+
case "25.10":
|
|
1488
|
+
logger.info("Instantiating TrueNasApiClientV2510", {
|
|
1489
|
+
uuid: uuid.slice(0, 8),
|
|
1490
|
+
version: version.version,
|
|
1491
|
+
versionKey
|
|
1492
|
+
});
|
|
1493
|
+
return new TrueNasApiClientV2510(
|
|
1494
|
+
uuid,
|
|
1495
|
+
hostnames,
|
|
1496
|
+
version,
|
|
1497
|
+
enabled,
|
|
1498
|
+
systemName,
|
|
1499
|
+
logger
|
|
1500
|
+
);
|
|
1501
|
+
case "26":
|
|
1502
|
+
logger.info("Instantiating TrueNasApiClientV26", {
|
|
1503
|
+
uuid: uuid.slice(0, 8),
|
|
1504
|
+
version: version.version,
|
|
1505
|
+
versionKey
|
|
1506
|
+
});
|
|
1507
|
+
return new TrueNasApiClientV26(
|
|
1508
|
+
uuid,
|
|
1509
|
+
hostnames,
|
|
1510
|
+
version,
|
|
1511
|
+
enabled,
|
|
1512
|
+
systemName,
|
|
1513
|
+
logger
|
|
1514
|
+
);
|
|
1515
|
+
default:
|
|
1516
|
+
logger.error("No client implementation for version", {
|
|
1517
|
+
uuid: uuid.slice(0, 8),
|
|
1518
|
+
version: version.version,
|
|
1519
|
+
versionKey
|
|
1520
|
+
});
|
|
1521
|
+
throw new Error(
|
|
1522
|
+
`No client implementation for API version ${version.version}. Supported versions: v25.10.x, v26.x.y. Version key: ${versionKey}`
|
|
1523
|
+
);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
function errorMessageOrDefault(error, fallback) {
|
|
1527
|
+
if (error instanceof Error) {
|
|
1528
|
+
return error.message;
|
|
1529
|
+
}
|
|
1530
|
+
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") {
|
|
1531
|
+
return error.message;
|
|
1532
|
+
}
|
|
1533
|
+
return fallback;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
export { AuthError, AuthErrorCode, InvalidVersionResponseError, JobState, NoCompatibleVersionsError, TrueNasApiClient, TrueNasApiClientV2510, TrueNasApiClientV26, TrueNasAuthMechanism, TrueNasEndpoint, VersionCompatibility, VersionDiscovery, VersionDiscoveryError, VersionDiscoveryNetworkError, VersionDiscoveryTimeoutError, VersionEndpointNotFoundError, VersionTooNewError, VersionTooOldError, consoleLogger, createTrueNasClient, getApiErrorMessage, noopLogger };
|
|
1537
|
+
//# sourceMappingURL=index.js.map
|
|
1538
|
+
//# sourceMappingURL=index.js.map
|