@attraccess/plugin-wago 0.1.0-nightly.929.1 → 0.1.0-nightly.935.1
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/dist/index.js +725 -57
- package/dist/migrations.js +37 -0
- package/dist/signing-public-key.pub +1 -0
- package/frontend/__federation_expose_Plugin-DdJzlkSB.js +1067 -0
- package/frontend/remoteEntry.js +1 -1
- package/frontend/style.css +32 -18
- package/package.json +3 -2
- package/frontend/__federation_expose_Plugin-CXB18jsp.js +0 -1489
package/dist/index.js
CHANGED
|
@@ -35,8 +35,8 @@ __export(plugin_exports, {
|
|
|
35
35
|
module.exports = __toCommonJS(plugin_exports);
|
|
36
36
|
|
|
37
37
|
// backend/wago.controller.ts
|
|
38
|
-
var
|
|
39
|
-
var
|
|
38
|
+
var import_common3 = require("@nestjs/common");
|
|
39
|
+
var import_plugins_backend_sdk8 = require("@attraccess/plugins-backend-sdk");
|
|
40
40
|
|
|
41
41
|
// backend/wago.service.ts
|
|
42
42
|
var import_node_crypto2 = require("node:crypto");
|
|
@@ -277,7 +277,36 @@ var CHANNEL_PROFILES = [
|
|
|
277
277
|
"generic-digital-output",
|
|
278
278
|
"generic-monitored-input"
|
|
279
279
|
];
|
|
280
|
-
var CAPABILITIES = ["output", "input", "measurement", "pulse", "guard"];
|
|
280
|
+
var CAPABILITIES = ["output", "input", "measurement", "pulse", "guard", "feedback"];
|
|
281
|
+
var WAGO_PRESETS = [
|
|
282
|
+
{
|
|
283
|
+
id: "metered-switched-load",
|
|
284
|
+
name: "Metered switched load",
|
|
285
|
+
description: "Switches an output off immediately when disconnected and reports a transformed measurement."
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
id: "pulsed-lock-bank",
|
|
289
|
+
name: "Pulsed lock bank",
|
|
290
|
+
description: "Pulses an output briefly and returns it to off immediately when disconnected."
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
id: "guarded-enable-request",
|
|
294
|
+
name: "Guarded enable request",
|
|
295
|
+
description: "Makes a non-safety enable request only while an operational guard is satisfied."
|
|
296
|
+
},
|
|
297
|
+
{ id: "generic-digital-output", name: "Generic digital output", description: "A conservative output foundation." },
|
|
298
|
+
{ id: "generic-monitored-input", name: "Generic monitored input", description: "A monitored digital input foundation." }
|
|
299
|
+
];
|
|
300
|
+
function applyPreset(snapshot, application) {
|
|
301
|
+
const preset = application && WAGO_PRESETS.find((item) => item.id === application.presetId);
|
|
302
|
+
if (!preset) throw new Error("unknown WAGO preset");
|
|
303
|
+
const channel = presetChannel(application);
|
|
304
|
+
const existingIndex = snapshot.logicalChannels.findIndex((item) => item.id === application.channelId);
|
|
305
|
+
const logicalChannels = [...snapshot.logicalChannels];
|
|
306
|
+
if (existingIndex === -1) logicalChannels.push(channel);
|
|
307
|
+
else logicalChannels[existingIndex] = channel;
|
|
308
|
+
return { ...snapshot, logicalChannels };
|
|
309
|
+
}
|
|
281
310
|
function canonicalSnapshot(snapshot) {
|
|
282
311
|
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot))
|
|
283
312
|
throw new Error("configuration snapshot must be an object");
|
|
@@ -321,6 +350,7 @@ function validateSnapshot(snapshot) {
|
|
|
321
350
|
const channels = collection(value.logicalChannels, "logicalChannels", errors);
|
|
322
351
|
const pointIds = /* @__PURE__ */ new Set();
|
|
323
352
|
const channelIds = /* @__PURE__ */ new Set();
|
|
353
|
+
const channelsById = /* @__PURE__ */ new Map();
|
|
324
354
|
points.forEach((point, index) => {
|
|
325
355
|
const path = `physicalPoints[${index}]`;
|
|
326
356
|
if (!record(point, path, errors)) return;
|
|
@@ -335,8 +365,11 @@ function validateSnapshot(snapshot) {
|
|
|
335
365
|
});
|
|
336
366
|
});
|
|
337
367
|
channels.forEach((channel, index) => {
|
|
338
|
-
if (channel && typeof channel === "object" && !Array.isArray(channel))
|
|
339
|
-
|
|
368
|
+
if (channel && typeof channel === "object" && !Array.isArray(channel)) {
|
|
369
|
+
const item = channel;
|
|
370
|
+
addId(item.id, `logicalChannels[${index}].id`, channelIds, errors);
|
|
371
|
+
if (typeof item.id === "string") channelsById.set(item.id, item);
|
|
372
|
+
}
|
|
340
373
|
});
|
|
341
374
|
channels.forEach((channel, index) => {
|
|
342
375
|
const path = `logicalChannels[${index}]`;
|
|
@@ -353,6 +386,7 @@ function validateSnapshot(snapshot) {
|
|
|
353
386
|
"range",
|
|
354
387
|
"pulse",
|
|
355
388
|
"guard",
|
|
389
|
+
"feedback",
|
|
356
390
|
"measurement"
|
|
357
391
|
],
|
|
358
392
|
errors
|
|
@@ -365,6 +399,7 @@ function validateSnapshot(snapshot) {
|
|
|
365
399
|
validateRange(channel.range, `${path}.range`, capabilities, errors);
|
|
366
400
|
validatePulse(channel.pulse, `${path}.pulse`, capabilities, errors);
|
|
367
401
|
validateGuard(channel.guard, `${path}.guard`, capabilities, channelIds, errors);
|
|
402
|
+
validateFeedback(channel.feedback, `${path}.feedback`, capabilities, channel.id, channelsById, errors);
|
|
368
403
|
validateMeasurement(channel.measurement, `${path}.measurement`, capabilities, errors);
|
|
369
404
|
validateProfile(channel.profile, capabilities, path, errors);
|
|
370
405
|
});
|
|
@@ -411,7 +446,7 @@ function exactKeys(value, path, allowed, errors, optional = []) {
|
|
|
411
446
|
message: "field is not supported by configuration version 1"
|
|
412
447
|
})
|
|
413
448
|
);
|
|
414
|
-
allowed.filter((key) => !["range", "pulse", "guard", "measurement", ...optional].includes(key) && !(key in value)).forEach(
|
|
449
|
+
allowed.filter((key) => !["range", "pulse", "guard", "feedback", "measurement", ...optional].includes(key) && !(key in value)).forEach(
|
|
415
450
|
(key) => errors.push({
|
|
416
451
|
path: path === "$" ? key : `${path}.${key}`,
|
|
417
452
|
code: "required_field",
|
|
@@ -519,6 +554,22 @@ function validateMeasurement(value, path, capabilities, errors) {
|
|
|
519
554
|
if (!capabilities.has("measurement"))
|
|
520
555
|
errors.push({ path, code: "unsupported_field", message: "measurement requires measurement capability" });
|
|
521
556
|
}
|
|
557
|
+
function validateFeedback(value, path, capabilities, currentChannelId, channelsById, errors) {
|
|
558
|
+
if (value === void 0) return;
|
|
559
|
+
if (!record(value, path, errors)) return;
|
|
560
|
+
exactKeys(value, path, ["channelId", "expected", "timeoutMs"], errors);
|
|
561
|
+
const feedbackChannel = typeof value.channelId === "string" ? channelsById.get(value.channelId) : void 0;
|
|
562
|
+
if (!feedbackChannel)
|
|
563
|
+
errors.push(referenceError(`${path}.channelId`, "logical channel", value.channelId));
|
|
564
|
+
else if (value.channelId === currentChannelId || !Array.isArray(feedbackChannel.capabilities) || !feedbackChannel.capabilities.includes("input"))
|
|
565
|
+
errors.push({ path: `${path}.channelId`, code: "invalid_feedback_channel", message: "feedback must reference a distinct input channel" });
|
|
566
|
+
if (!["match", "inverse"].includes(value.expected))
|
|
567
|
+
errors.push({ path: `${path}.expected`, code: "unsupported_value", message: "expected must be match or inverse" });
|
|
568
|
+
if (!Number.isSafeInteger(value.timeoutMs) || value.timeoutMs <= 0)
|
|
569
|
+
errors.push({ path: `${path}.timeoutMs`, code: "invalid_timeout", message: "timeoutMs must be a positive integer" });
|
|
570
|
+
if (!capabilities.has("feedback"))
|
|
571
|
+
errors.push({ path, code: "unsupported_field", message: "feedback requires feedback capability" });
|
|
572
|
+
}
|
|
522
573
|
function validateProfile(value, capabilities, path, errors) {
|
|
523
574
|
const required = {
|
|
524
575
|
"metered-switched-load": ["output", "measurement"],
|
|
@@ -535,6 +586,35 @@ function validateProfile(value, capabilities, path, errors) {
|
|
|
535
586
|
})
|
|
536
587
|
);
|
|
537
588
|
}
|
|
589
|
+
function presetChannel(application) {
|
|
590
|
+
const base = {
|
|
591
|
+
id: application.channelId,
|
|
592
|
+
physicalPointId: application.physicalPointId,
|
|
593
|
+
profile: application.presetId,
|
|
594
|
+
disconnectPolicy: { mode: application.presetId === "generic-monitored-input" ? "hold" : "immediate" }
|
|
595
|
+
};
|
|
596
|
+
switch (application.presetId) {
|
|
597
|
+
case "metered-switched-load":
|
|
598
|
+
return { ...base, capabilities: ["output", "measurement"], measurement: { unit: "watt", scale: 1, offset: 0 } };
|
|
599
|
+
case "pulsed-lock-bank":
|
|
600
|
+
return { ...base, capabilities: ["output", "pulse"], pulse: { durationMs: 500 } };
|
|
601
|
+
case "guarded-enable-request":
|
|
602
|
+
if (!application.guardChannelId) throw new Error("guarded enable requests require a guard channel");
|
|
603
|
+
return {
|
|
604
|
+
...base,
|
|
605
|
+
capabilities: ["output", "guard"],
|
|
606
|
+
guard: { channelId: application.guardChannelId, when: "on" }
|
|
607
|
+
};
|
|
608
|
+
case "generic-monitored-input":
|
|
609
|
+
return { ...base, capabilities: ["input"] };
|
|
610
|
+
case "generic-digital-output":
|
|
611
|
+
return {
|
|
612
|
+
...base,
|
|
613
|
+
capabilities: application.feedbackChannelId ? ["output", "feedback"] : ["output"],
|
|
614
|
+
...application.feedbackChannelId ? { feedback: { channelId: application.feedbackChannelId, expected: "match", timeoutMs: 1e3 } } : {}
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
}
|
|
538
618
|
function referenceError(path, type, id) {
|
|
539
619
|
return { path, code: "missing_reference", message: `${type} ${String(id)} does not exist in this snapshot` };
|
|
540
620
|
}
|
|
@@ -554,6 +634,7 @@ var WagoConfigurationDraft = class {
|
|
|
554
634
|
__publicField(this, "controllerId");
|
|
555
635
|
__publicField(this, "snapshot");
|
|
556
636
|
__publicField(this, "reviewedHash");
|
|
637
|
+
__publicField(this, "presetProvenance");
|
|
557
638
|
__publicField(this, "updatedAt");
|
|
558
639
|
}
|
|
559
640
|
};
|
|
@@ -569,6 +650,9 @@ __decorateClass([
|
|
|
569
650
|
__decorateClass([
|
|
570
651
|
(0, import_plugins_backend_sdk4.Column)({ type: "varchar", name: "reviewed_hash", nullable: true })
|
|
571
652
|
], WagoConfigurationDraft.prototype, "reviewedHash", 2);
|
|
653
|
+
__decorateClass([
|
|
654
|
+
(0, import_plugins_backend_sdk4.Column)({ type: "text", name: "preset_provenance", nullable: true })
|
|
655
|
+
], WagoConfigurationDraft.prototype, "presetProvenance", 2);
|
|
572
656
|
__decorateClass([
|
|
573
657
|
(0, import_plugins_backend_sdk4.Column)({ type: "varchar", name: "updated_at" })
|
|
574
658
|
], WagoConfigurationDraft.prototype, "updatedAt", 2);
|
|
@@ -627,6 +711,12 @@ var PLUGIN_CONTEXT = /* @__PURE__ */ Symbol.for("attraccess.plugin.context");
|
|
|
627
711
|
var STALE_AFTER_MS = 9e4;
|
|
628
712
|
var MAX_PENDING_CONFIGURATION_REPORTS = 100;
|
|
629
713
|
var ENROLLMENT_RETRY_MS = 6e4;
|
|
714
|
+
var MqttSubscriptionError = class extends Error {
|
|
715
|
+
constructor(mqttError) {
|
|
716
|
+
super(String(mqttError));
|
|
717
|
+
__publicField(this, "mqttError", mqttError);
|
|
718
|
+
}
|
|
719
|
+
};
|
|
630
720
|
var WagoService = class {
|
|
631
721
|
constructor(context) {
|
|
632
722
|
__publicField(this, "context", context);
|
|
@@ -645,16 +735,22 @@ var WagoService = class {
|
|
|
645
735
|
__publicField(this, "subscriptionRetryTimer", null);
|
|
646
736
|
__publicField(this, "activeSubscriptionGeneration", 0);
|
|
647
737
|
__publicField(this, "destroyed", false);
|
|
648
|
-
this.controllers = context.getRepository(WagoController);
|
|
649
|
-
this.settings = context.getRepository(WagoSettings);
|
|
650
|
-
this.enrollments = context.getRepository(WagoEnrollment);
|
|
651
|
-
this.drafts = context.getRepository(WagoConfigurationDraft);
|
|
652
|
-
this.revisions = context.getRepository(WagoConfigurationRevision);
|
|
653
738
|
}
|
|
654
739
|
async onModuleInit() {
|
|
740
|
+
this.controllers = this.context.getRepository(WagoController);
|
|
741
|
+
this.settings = this.context.getRepository(WagoSettings);
|
|
742
|
+
this.enrollments = this.context.getRepository(WagoEnrollment);
|
|
743
|
+
this.drafts = this.context.getRepository(WagoConfigurationDraft);
|
|
744
|
+
this.revisions = this.context.getRepository(WagoConfigurationRevision);
|
|
655
745
|
const enrollments = await this.enrollments.createQueryBuilder("enrollment").where("enrollment.consumedAt IS NULL").getMany();
|
|
656
746
|
for (const enrollment of enrollments) this.scheduleEnrollmentExpiry(enrollment);
|
|
657
|
-
|
|
747
|
+
try {
|
|
748
|
+
await this.subscribeConfiguredServers();
|
|
749
|
+
} catch (error) {
|
|
750
|
+
if (!(error instanceof MqttSubscriptionError)) throw error;
|
|
751
|
+
this.context.logger.warn(`Could not establish WAGO MQTT subscriptions during startup: ${String(error.mqttError)}`);
|
|
752
|
+
this.scheduleSubscriptionRetry();
|
|
753
|
+
}
|
|
658
754
|
}
|
|
659
755
|
onModuleDestroy() {
|
|
660
756
|
this.destroyed = true;
|
|
@@ -715,6 +811,12 @@ var WagoService = class {
|
|
|
715
811
|
async setDefaultMqttServer(serverId) {
|
|
716
812
|
return this.setSettings(serverId);
|
|
717
813
|
}
|
|
814
|
+
async enrollmentCredentialSupport(mqttServerId) {
|
|
815
|
+
if (!await this.context.getMqttServerConfig(mqttServerId))
|
|
816
|
+
throw new import_common.NotFoundException(`MQTT server ${mqttServerId} not found`);
|
|
817
|
+
const providers = await this.context.getMqttCredentialProvisioning().availableProviders(mqttServerId);
|
|
818
|
+
return { automatic: providers.length === 1 };
|
|
819
|
+
}
|
|
718
820
|
async getDraft(controllerId) {
|
|
719
821
|
await this.claimedController(controllerId);
|
|
720
822
|
return this.drafts.findOneBy({ controllerId });
|
|
@@ -728,6 +830,35 @@ var WagoService = class {
|
|
|
728
830
|
const errors = validateSnapshot(JSON.parse(draft.snapshot));
|
|
729
831
|
return { valid: errors.length === 0, errors };
|
|
730
832
|
}
|
|
833
|
+
presets() {
|
|
834
|
+
return WAGO_PRESETS;
|
|
835
|
+
}
|
|
836
|
+
async previewPreset(controllerId, application) {
|
|
837
|
+
const draft = await this.draftForPreset(controllerId);
|
|
838
|
+
const snapshot = JSON.parse(draft.snapshot);
|
|
839
|
+
return { draftHash: configurationHash(snapshot), diff: configurationDiff(snapshot, applyPreset(snapshot, application)) };
|
|
840
|
+
}
|
|
841
|
+
async applyPreset(controllerId, application, selectedPaths, previewedDraftHash) {
|
|
842
|
+
return this.withConfigurationLock(controllerId, async () => {
|
|
843
|
+
const draft = await this.draftForPreset(controllerId);
|
|
844
|
+
const snapshot = JSON.parse(draft.snapshot);
|
|
845
|
+
if (previewedDraftHash !== configurationHash(snapshot))
|
|
846
|
+
throw new import_common.ConflictException("selected preset changes no longer match the configuration draft");
|
|
847
|
+
const candidate = applyPreset(snapshot, application);
|
|
848
|
+
const diff = configurationDiff(snapshot, candidate);
|
|
849
|
+
const validPaths = new Set(diff.map((change) => change.path));
|
|
850
|
+
if (!Array.isArray(selectedPaths) || selectedPaths.some((path) => !validPaths.has(path)))
|
|
851
|
+
throw new import_common.ConflictException("selected preset changes no longer match the configuration draft");
|
|
852
|
+
draft.snapshot = canonicalSnapshot(applySelectedChanges(snapshot, diff, selectedPaths));
|
|
853
|
+
draft.reviewedHash = null;
|
|
854
|
+
draft.presetProvenance = JSON.stringify([
|
|
855
|
+
...parsePresetProvenance(draft.presetProvenance).slice(-99),
|
|
856
|
+
{ presetId: application.presetId, appliedAt: (/* @__PURE__ */ new Date()).toISOString(), selectedPaths }
|
|
857
|
+
]);
|
|
858
|
+
draft.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
859
|
+
return this.drafts.save(draft);
|
|
860
|
+
});
|
|
861
|
+
}
|
|
731
862
|
async revisionsFor(controllerId, offset = 0, limit = 20) {
|
|
732
863
|
await this.claimedController(controllerId);
|
|
733
864
|
const pageOffset = Number.isSafeInteger(offset) && offset > 0 ? offset : 0;
|
|
@@ -780,7 +911,7 @@ var WagoService = class {
|
|
|
780
911
|
await this.claimedController(controllerId);
|
|
781
912
|
const serialized = canonicalSnapshot(snapshot);
|
|
782
913
|
const existing = await this.drafts.findOneBy({ controllerId });
|
|
783
|
-
const draft = existing ?? this.drafts.create({ controllerId, snapshot: serialized, reviewedHash: null, updatedAt: "" });
|
|
914
|
+
const draft = existing ?? this.drafts.create({ controllerId, snapshot: serialized, reviewedHash: null, presetProvenance: null, updatedAt: "" });
|
|
784
915
|
draft.snapshot = serialized;
|
|
785
916
|
draft.reviewedHash = null;
|
|
786
917
|
draft.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -796,6 +927,12 @@ var WagoService = class {
|
|
|
796
927
|
const diff = configurationDiff(previous ? JSON.parse(previous.snapshot) : null, JSON.parse(draft.snapshot));
|
|
797
928
|
return { draft, previous, changed: diff.length > 0, diff };
|
|
798
929
|
}
|
|
930
|
+
async draftForPreset(controllerId) {
|
|
931
|
+
await this.claimedController(controllerId);
|
|
932
|
+
const draft = await this.drafts.findOneBy({ controllerId });
|
|
933
|
+
if (!draft) throw new import_common.NotFoundException(`WAGO controller ${controllerId} has no configuration draft`);
|
|
934
|
+
return draft;
|
|
935
|
+
}
|
|
799
936
|
async publishDraftWhileLocked(controllerId) {
|
|
800
937
|
const controller = await this.claimedController(controllerId);
|
|
801
938
|
const draft = await this.drafts.findOneBy({ controllerId });
|
|
@@ -879,6 +1016,11 @@ var WagoService = class {
|
|
|
879
1016
|
) : void 0
|
|
880
1017
|
};
|
|
881
1018
|
}
|
|
1019
|
+
/** Server-side commissioning calls this to revoke a bootstrap identity without exposing it to a browser. */
|
|
1020
|
+
async revokeEnrollmentForHardwareId(hardwareId) {
|
|
1021
|
+
const enrollment = (await this.enrollments.find({ where: { hardwareId } })).find((item) => this.isActiveEnrollment(item));
|
|
1022
|
+
if (enrollment) await this.revokeEnrollment(enrollment);
|
|
1023
|
+
}
|
|
882
1024
|
async claim(id, name, verifier, mqttServerId) {
|
|
883
1025
|
return this.withClaimLock(id, async () => {
|
|
884
1026
|
const prepared = await this.withClaimConfigurationLock(() => this.prepareClaim(id, name, verifier, mqttServerId));
|
|
@@ -1040,7 +1182,7 @@ var WagoService = class {
|
|
|
1040
1182
|
try {
|
|
1041
1183
|
for (const serverId of serverIds) {
|
|
1042
1184
|
replacements.push(
|
|
1043
|
-
await this.
|
|
1185
|
+
await this.subscribeMqtt(serverId, `${DISCOVERY_ROOT}/+`, async (message) => {
|
|
1044
1186
|
if (!this.isActiveSubscriptionGeneration(generation)) return;
|
|
1045
1187
|
await this.onDiscovery(serverId, message.topic, message.payload);
|
|
1046
1188
|
})
|
|
@@ -1056,7 +1198,7 @@ var WagoService = class {
|
|
|
1056
1198
|
claimedControllers.map((controller) => [controller.hardwareId, controller])
|
|
1057
1199
|
);
|
|
1058
1200
|
replacements.push(
|
|
1059
|
-
await this.
|
|
1201
|
+
await this.subscribeMqtt(
|
|
1060
1202
|
serverId,
|
|
1061
1203
|
configurationReportedWildcardTopic(settings.operationalPrefix),
|
|
1062
1204
|
(message) => {
|
|
@@ -1073,7 +1215,7 @@ var WagoService = class {
|
|
|
1073
1215
|
}
|
|
1074
1216
|
for (const controller of claimedControllers) {
|
|
1075
1217
|
replacements.push(
|
|
1076
|
-
await this.
|
|
1218
|
+
await this.subscribeMqtt(
|
|
1077
1219
|
serverId,
|
|
1078
1220
|
heartbeatTopic(settings.operationalPrefix, controller.hardwareId),
|
|
1079
1221
|
async (message) => {
|
|
@@ -1103,6 +1245,13 @@ var WagoService = class {
|
|
|
1103
1245
|
unsubscribe() {
|
|
1104
1246
|
this.subscriptions.splice(0).forEach((subscription) => subscription.unsubscribe());
|
|
1105
1247
|
}
|
|
1248
|
+
async subscribeMqtt(...args) {
|
|
1249
|
+
try {
|
|
1250
|
+
return await this.context.mqtt.subscribe(...args);
|
|
1251
|
+
} catch (error) {
|
|
1252
|
+
throw new MqttSubscriptionError(error);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1106
1255
|
isActiveSubscriptionGeneration(generation) {
|
|
1107
1256
|
return !this.destroyed && generation === this.activeSubscriptionGeneration;
|
|
1108
1257
|
}
|
|
@@ -1414,11 +1563,462 @@ function safeEqual(left, right) {
|
|
|
1414
1563
|
function isValidHardwareId(hardwareId) {
|
|
1415
1564
|
return Boolean(hardwareId) && !/[/+#]/.test(hardwareId);
|
|
1416
1565
|
}
|
|
1566
|
+
function parsePresetProvenance(value) {
|
|
1567
|
+
if (!value) return [];
|
|
1568
|
+
try {
|
|
1569
|
+
const parsed = JSON.parse(value);
|
|
1570
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
1571
|
+
} catch {
|
|
1572
|
+
return [];
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
function applySelectedChanges(snapshot, diff, selectedPaths) {
|
|
1576
|
+
let merged = JSON.parse(JSON.stringify(snapshot));
|
|
1577
|
+
const changes = new Map(diff.map((change) => [change.path, change]));
|
|
1578
|
+
for (const path of selectedPaths) {
|
|
1579
|
+
const change = changes.get(path);
|
|
1580
|
+
if (!change) continue;
|
|
1581
|
+
const segments = [...path.matchAll(/\.([^.[\]]+)|\[(\d+)\]/g)].map((match) => match[1] ?? Number(match[2]));
|
|
1582
|
+
if (!segments.length || segments.some((segment) => typeof segment === "string" && unsafePathSegment(segment))) continue;
|
|
1583
|
+
merged = replacePath(merged, segments, change.current);
|
|
1584
|
+
}
|
|
1585
|
+
return merged;
|
|
1586
|
+
}
|
|
1587
|
+
function replacePath(value, [segment, ...remaining], replacement) {
|
|
1588
|
+
if (segment === void 0) return replacement;
|
|
1589
|
+
if (typeof segment === "number") {
|
|
1590
|
+
const next = Array.isArray(value) ? [...value] : [];
|
|
1591
|
+
if (remaining.length) next[segment] = replacePath(next[segment], remaining, replacement);
|
|
1592
|
+
else if (replacement === void 0) delete next[segment];
|
|
1593
|
+
else next[segment] = replacement;
|
|
1594
|
+
return next;
|
|
1595
|
+
}
|
|
1596
|
+
const entries = Object.entries(value ?? {}).filter(([key]) => key !== segment);
|
|
1597
|
+
if (remaining.length) entries.push([segment, replacePath(value?.[segment], remaining, replacement)]);
|
|
1598
|
+
else if (replacement !== void 0) entries.push([segment, replacement]);
|
|
1599
|
+
return Object.fromEntries(entries);
|
|
1600
|
+
}
|
|
1601
|
+
function unsafePathSegment(segment) {
|
|
1602
|
+
return segment === "__proto__" || segment === "constructor" || segment === "prototype";
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
// backend/wago-commissioning.service.ts
|
|
1606
|
+
var import_common2 = require("@nestjs/common");
|
|
1607
|
+
var import_node_child_process = require("node:child_process");
|
|
1608
|
+
var import_node_crypto3 = require("node:crypto");
|
|
1609
|
+
var import_promises = require("node:fs/promises");
|
|
1610
|
+
var import_node_os = require("node:os");
|
|
1611
|
+
var import_node_path = require("node:path");
|
|
1612
|
+
var import_plugins_backend_sdk7 = require("@attraccess/plugins-backend-sdk");
|
|
1613
|
+
|
|
1614
|
+
// backend/wago-commissioning-session.entity.ts
|
|
1615
|
+
var import_plugins_backend_sdk6 = require("@attraccess/plugins-backend-sdk");
|
|
1616
|
+
var WagoCommissioningSession = class {
|
|
1617
|
+
constructor() {
|
|
1618
|
+
__publicField(this, "id");
|
|
1619
|
+
__publicField(this, "hardwareId");
|
|
1620
|
+
__publicField(this, "mqttServerId");
|
|
1621
|
+
__publicField(this, "targetHost");
|
|
1622
|
+
__publicField(this, "hostKeyFingerprint");
|
|
1623
|
+
__publicField(this, "firmwareBaseline");
|
|
1624
|
+
__publicField(this, "state");
|
|
1625
|
+
__publicField(this, "enrollmentExpiresAt");
|
|
1626
|
+
__publicField(this, "codesysState");
|
|
1627
|
+
__publicField(this, "auditLog");
|
|
1628
|
+
__publicField(this, "failureReason");
|
|
1629
|
+
__publicField(this, "createdAt");
|
|
1630
|
+
__publicField(this, "updatedAt");
|
|
1631
|
+
}
|
|
1632
|
+
};
|
|
1633
|
+
__decorateClass([
|
|
1634
|
+
(0, import_plugins_backend_sdk6.PrimaryGeneratedColumn)({ type: "integer" })
|
|
1635
|
+
], WagoCommissioningSession.prototype, "id", 2);
|
|
1636
|
+
__decorateClass([
|
|
1637
|
+
(0, import_plugins_backend_sdk6.Column)({ type: "varchar", name: "hardware_id" })
|
|
1638
|
+
], WagoCommissioningSession.prototype, "hardwareId", 2);
|
|
1639
|
+
__decorateClass([
|
|
1640
|
+
(0, import_plugins_backend_sdk6.Column)({ type: "integer", name: "mqtt_server_id" })
|
|
1641
|
+
], WagoCommissioningSession.prototype, "mqttServerId", 2);
|
|
1642
|
+
__decorateClass([
|
|
1643
|
+
(0, import_plugins_backend_sdk6.Column)({ type: "varchar", name: "target_host" })
|
|
1644
|
+
], WagoCommissioningSession.prototype, "targetHost", 2);
|
|
1645
|
+
__decorateClass([
|
|
1646
|
+
(0, import_plugins_backend_sdk6.Column)({ type: "varchar", name: "host_key_fingerprint" })
|
|
1647
|
+
], WagoCommissioningSession.prototype, "hostKeyFingerprint", 2);
|
|
1648
|
+
__decorateClass([
|
|
1649
|
+
(0, import_plugins_backend_sdk6.Column)({ type: "varchar", name: "firmware_baseline" })
|
|
1650
|
+
], WagoCommissioningSession.prototype, "firmwareBaseline", 2);
|
|
1651
|
+
__decorateClass([
|
|
1652
|
+
(0, import_plugins_backend_sdk6.Column)({ type: "varchar" })
|
|
1653
|
+
], WagoCommissioningSession.prototype, "state", 2);
|
|
1654
|
+
__decorateClass([
|
|
1655
|
+
(0, import_plugins_backend_sdk6.Column)({ type: "varchar", name: "enrollment_expires_at", nullable: true })
|
|
1656
|
+
], WagoCommissioningSession.prototype, "enrollmentExpiresAt", 2);
|
|
1657
|
+
__decorateClass([
|
|
1658
|
+
(0, import_plugins_backend_sdk6.Column)({ type: "varchar", name: "codesys_state", nullable: true })
|
|
1659
|
+
], WagoCommissioningSession.prototype, "codesysState", 2);
|
|
1660
|
+
__decorateClass([
|
|
1661
|
+
(0, import_plugins_backend_sdk6.Column)({ type: "varchar", name: "audit_log" })
|
|
1662
|
+
], WagoCommissioningSession.prototype, "auditLog", 2);
|
|
1663
|
+
__decorateClass([
|
|
1664
|
+
(0, import_plugins_backend_sdk6.Column)({ type: "varchar", name: "failure_reason", nullable: true })
|
|
1665
|
+
], WagoCommissioningSession.prototype, "failureReason", 2);
|
|
1666
|
+
__decorateClass([
|
|
1667
|
+
(0, import_plugins_backend_sdk6.Column)({ type: "varchar", name: "created_at" })
|
|
1668
|
+
], WagoCommissioningSession.prototype, "createdAt", 2);
|
|
1669
|
+
__decorateClass([
|
|
1670
|
+
(0, import_plugins_backend_sdk6.Column)({ type: "varchar", name: "updated_at" })
|
|
1671
|
+
], WagoCommissioningSession.prototype, "updatedAt", 2);
|
|
1672
|
+
WagoCommissioningSession = __decorateClass([
|
|
1673
|
+
(0, import_plugins_backend_sdk6.Entity)({ name: "plugin_wago_commissioning_sessions" })
|
|
1674
|
+
], WagoCommissioningSession);
|
|
1675
|
+
|
|
1676
|
+
// backend/wago-commissioning.service.ts
|
|
1677
|
+
var SSH_TIMEOUT_MS = 2e4;
|
|
1678
|
+
var configuredFirmwareBaseline = process.env.WAGO_CC100_FIRMWARE_BASELINE?.trim() || "31";
|
|
1679
|
+
var configuredRuntimeImage = process.env.WAGO_CC100_RUNTIME_IMAGE?.trim() ?? "";
|
|
1680
|
+
var configuredRuntimeBundle = process.env.WAGO_CC100_RUNTIME_BUNDLE_PATH?.trim() ?? "";
|
|
1681
|
+
var configuredRuntimeBundleChecksum = process.env.WAGO_CC100_RUNTIME_BUNDLE_SHA256_PATH?.trim() ?? "";
|
|
1682
|
+
var configuredRuntimeBundleSignature = process.env.WAGO_CC100_RUNTIME_BUNDLE_SIGNATURE_PATH?.trim() ?? "";
|
|
1683
|
+
var SIGNING_NAMESPACE = "attraccess-wago-runtime";
|
|
1684
|
+
var SIGNING_IDENTITY = "attraccess-wago-runtime";
|
|
1685
|
+
var WagoCommissioningService = class {
|
|
1686
|
+
constructor(context, wago) {
|
|
1687
|
+
__publicField(this, "context", context);
|
|
1688
|
+
__publicField(this, "wago", wago);
|
|
1689
|
+
__publicField(this, "sessions");
|
|
1690
|
+
this.sessions = context.getRepository(WagoCommissioningSession);
|
|
1691
|
+
}
|
|
1692
|
+
support() {
|
|
1693
|
+
return {
|
|
1694
|
+
firmwareBaseline: configuredFirmwareBaseline || null,
|
|
1695
|
+
ready: Boolean(
|
|
1696
|
+
configuredFirmwareBaseline && isImmutableImage(configuredRuntimeImage) && configuredRuntimeBundle && configuredRuntimeBundleChecksum && configuredRuntimeBundleSignature
|
|
1697
|
+
)
|
|
1698
|
+
};
|
|
1699
|
+
}
|
|
1700
|
+
async create(input) {
|
|
1701
|
+
const hardwareId = input.hardwareId.trim();
|
|
1702
|
+
if (!/^[A-Za-z0-9._-]+$/.test(hardwareId)) throw new import_common2.ConflictException("a valid controller hardware ID is required");
|
|
1703
|
+
if (!isPrivateAddress(input.targetHost)) throw new import_common2.ConflictException("commissioning is limited to a private controller address");
|
|
1704
|
+
if (!configuredFirmwareBaseline)
|
|
1705
|
+
throw new import_common2.ConflictException("no CC100 firmware baseline is configured; commissioning is disabled until an exact supported baseline is set");
|
|
1706
|
+
if (!await this.context.getMqttServerConfig(input.mqttServerId)) throw new import_common2.NotFoundException("MQTT server not found");
|
|
1707
|
+
const hostKeyFingerprint = await scanHostKey(input.targetHost);
|
|
1708
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1709
|
+
return this.sessions.save(
|
|
1710
|
+
this.sessions.create({
|
|
1711
|
+
hardwareId,
|
|
1712
|
+
mqttServerId: input.mqttServerId,
|
|
1713
|
+
targetHost: input.targetHost,
|
|
1714
|
+
hostKeyFingerprint,
|
|
1715
|
+
firmwareBaseline: configuredFirmwareBaseline,
|
|
1716
|
+
state: "awaiting_identity_confirmation",
|
|
1717
|
+
enrollmentExpiresAt: null,
|
|
1718
|
+
codesysState: null,
|
|
1719
|
+
auditLog: JSON.stringify([{ at: now, event: "host_key_scanned" }]),
|
|
1720
|
+
failureReason: null,
|
|
1721
|
+
createdAt: now,
|
|
1722
|
+
updatedAt: now
|
|
1723
|
+
})
|
|
1724
|
+
);
|
|
1725
|
+
}
|
|
1726
|
+
async list() {
|
|
1727
|
+
return this.sessions.find({ order: { updatedAt: "DESC" } });
|
|
1728
|
+
}
|
|
1729
|
+
async deliver(id, input) {
|
|
1730
|
+
const session = await this.sessions.findOneBy({ id });
|
|
1731
|
+
if (!session) throw new import_common2.NotFoundException("commissioning session not found");
|
|
1732
|
+
if (session.state === "completed" || session.state === "revoked") throw new import_common2.ConflictException("commissioning session is no longer active");
|
|
1733
|
+
if (!input.physicalIdentityConfirmed) throw new import_common2.ConflictException("physical controller identity confirmation is required");
|
|
1734
|
+
if (input.hostKeyFingerprint !== session.hostKeyFingerprint) throw new import_common2.ConflictException("controller SSH host key does not match the scanned key");
|
|
1735
|
+
if (!input.temporarySsh.username.trim() || !input.temporarySsh.password)
|
|
1736
|
+
throw new import_common2.ConflictException("temporary SSH credentials are required for this session only");
|
|
1737
|
+
if (!isRuntimeArtifactConfigured())
|
|
1738
|
+
throw new import_common2.ConflictException("no immutable, signed CC100 runtime bundle is configured for commissioning");
|
|
1739
|
+
try {
|
|
1740
|
+
const inspection = await this.inspect(session.targetHost, session.hostKeyFingerprint, input.temporarySsh);
|
|
1741
|
+
session.codesysState = inspection.codesys;
|
|
1742
|
+
if (!isSupportedController(inspection.firmware, session.firmwareBaseline)) {
|
|
1743
|
+
throw new import_common2.ConflictException(`unsupported CC100 model or firmware; expected baseline ${session.firmwareBaseline}`);
|
|
1744
|
+
}
|
|
1745
|
+
if (inspection.codesys === "active" && !input.codesysStopConfirmed) {
|
|
1746
|
+
session.state = "awaiting_codesys_confirmation";
|
|
1747
|
+
session.failureReason = "CODESYS is active; explicit administrator confirmation is required before it is stopped.";
|
|
1748
|
+
return this.save(session, "codesys_confirmation_required");
|
|
1749
|
+
}
|
|
1750
|
+
const bundle = await verifyRuntimeBundle();
|
|
1751
|
+
const enrollment = await this.wago.createEnrollment(session.hardwareId, session.mqttServerId);
|
|
1752
|
+
if (!enrollment.password) throw new import_common2.ConflictException("automatic restricted MQTT credential provisioning is required for secure commissioning");
|
|
1753
|
+
const runtimeEnv = [
|
|
1754
|
+
`WAGO_HARDWARE_ID=${session.hardwareId}`,
|
|
1755
|
+
`WAGO_MQTT_URL=${enrollment.broker.useTls ? "mqtts" : "mqtt"}://${enrollment.broker.host}:${enrollment.broker.port}`,
|
|
1756
|
+
`WAGO_MQTT_USERNAME=${enrollment.username}`,
|
|
1757
|
+
`WAGO_MQTT_PASSWORD=${enrollment.password}`,
|
|
1758
|
+
`WAGO_ENROLLMENT_SECRET=${enrollment.claimSecret}`
|
|
1759
|
+
].join("\n");
|
|
1760
|
+
if (inspection.codesys === "active")
|
|
1761
|
+
await this.sudoRun(session.targetHost, session.hostKeyFingerprint, input.temporarySsh, "/etc/init.d/pp_codesys3 stop");
|
|
1762
|
+
await this.sudoRun(
|
|
1763
|
+
session.targetHost,
|
|
1764
|
+
session.hostKeyFingerprint,
|
|
1765
|
+
input.temporarySsh,
|
|
1766
|
+
"docker info >/dev/null 2>&1 || /etc/init.d/dockerd start; docker info >/dev/null"
|
|
1767
|
+
);
|
|
1768
|
+
await this.writeRuntimeEnvironment(session.targetHost, session.hostKeyFingerprint, input.temporarySsh, runtimeEnv);
|
|
1769
|
+
try {
|
|
1770
|
+
await this.copyTo(session.targetHost, session.hostKeyFingerprint, input.temporarySsh, bundle.path, "/tmp/attraccess-wago-runtime.tar");
|
|
1771
|
+
await this.sudoRun(
|
|
1772
|
+
session.targetHost,
|
|
1773
|
+
session.hostKeyFingerprint,
|
|
1774
|
+
input.temporarySsh,
|
|
1775
|
+
`docker load -i /tmp/attraccess-wago-runtime.tar && rm -f /tmp/attraccess-wago-runtime.tar && docker image inspect ${runtimeImageTag()} >/dev/null && (docker rm -f attraccess-wago >/dev/null 2>&1 || true) && docker run -d --name attraccess-wago --restart unless-stopped --env-file /etc/attraccess-wago/runtime.env -v /var/lib/attraccess-wago:/var/lib/attraccess-wago ${runtimeImageTag()}`
|
|
1776
|
+
);
|
|
1777
|
+
} finally {
|
|
1778
|
+
await (0, import_promises.rm)(bundle.directory, { recursive: true, force: true });
|
|
1779
|
+
}
|
|
1780
|
+
session.state = "awaiting_discovery";
|
|
1781
|
+
session.enrollmentExpiresAt = enrollment.expiresAt;
|
|
1782
|
+
session.failureReason = null;
|
|
1783
|
+
return this.save(session, "bootstrap_delivered");
|
|
1784
|
+
} catch (error) {
|
|
1785
|
+
session.state = "delivery_failed";
|
|
1786
|
+
session.failureReason = error instanceof Error ? redact(error.message) : "Secure delivery failed";
|
|
1787
|
+
return this.save(session, "delivery_failed");
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
async revoke(id) {
|
|
1791
|
+
const session = await this.sessions.findOneBy({ id });
|
|
1792
|
+
if (!session) throw new import_common2.NotFoundException("commissioning session not found");
|
|
1793
|
+
await this.wago.revokeEnrollmentForHardwareId(session.hardwareId);
|
|
1794
|
+
session.state = "revoked";
|
|
1795
|
+
session.failureReason = null;
|
|
1796
|
+
return this.save(session, "revoked");
|
|
1797
|
+
}
|
|
1798
|
+
async inspect(host, fingerprint, credential) {
|
|
1799
|
+
const output = await this.run(host, fingerprint, credential, "cat /etc/os-release; printf '\\nCODESYS='; ps");
|
|
1800
|
+
const [, codesys = "unknown"] = output.match(/CODESYS=([^\r\n]*)/) ?? [];
|
|
1801
|
+
return { firmware: output, codesys: /codesys/i.test(codesys) ? "active" : "inactive" };
|
|
1802
|
+
}
|
|
1803
|
+
async writeRuntimeEnvironment(host, fingerprint, credential, content) {
|
|
1804
|
+
await this.sudoRun(
|
|
1805
|
+
host,
|
|
1806
|
+
fingerprint,
|
|
1807
|
+
credential,
|
|
1808
|
+
"install -d -m 0700 /etc/attraccess-wago && umask 077 && base64 -d > /etc/attraccess-wago/runtime.env && chmod 0600 /etc/attraccess-wago/runtime.env",
|
|
1809
|
+
Buffer.from(content).toString("base64")
|
|
1810
|
+
);
|
|
1811
|
+
}
|
|
1812
|
+
async sudoRun(host, fingerprint, credential, command, input) {
|
|
1813
|
+
return this.run(host, fingerprint, credential, `sudo -S sh -c ${shellQuote(command)}`, `${credential.password}
|
|
1814
|
+
${input ?? ""}`);
|
|
1815
|
+
}
|
|
1816
|
+
async run(host, fingerprint, credential, command, input) {
|
|
1817
|
+
const dir = await (0, import_promises.mkdtemp)((0, import_node_path.join)((0, import_node_os.tmpdir)(), "attraccess-cc100-"));
|
|
1818
|
+
const knownHosts = (0, import_node_path.join)(dir, "known_hosts");
|
|
1819
|
+
const askPass = (0, import_node_path.join)(dir, "askpass");
|
|
1820
|
+
try {
|
|
1821
|
+
await (0, import_promises.writeFile)(knownHosts, await pinnedHostKey(host, fingerprint), { mode: 384 });
|
|
1822
|
+
await (0, import_promises.writeFile)(askPass, `#!/bin/sh
|
|
1823
|
+
printf '%s\\n' "$ATTRACCESS_SSH_PASSWORD"
|
|
1824
|
+
`, { mode: 448 });
|
|
1825
|
+
return await runProcess(
|
|
1826
|
+
"ssh",
|
|
1827
|
+
[
|
|
1828
|
+
"-o",
|
|
1829
|
+
"BatchMode=no",
|
|
1830
|
+
"-o",
|
|
1831
|
+
"NumberOfPasswordPrompts=1",
|
|
1832
|
+
"-o",
|
|
1833
|
+
"HostKeyAlgorithms=ssh-ed25519",
|
|
1834
|
+
"-o",
|
|
1835
|
+
"StrictHostKeyChecking=yes",
|
|
1836
|
+
"-o",
|
|
1837
|
+
`UserKnownHostsFile=${knownHosts}`,
|
|
1838
|
+
"-o",
|
|
1839
|
+
"ConnectTimeout=15",
|
|
1840
|
+
`${credential.username}@${host}`,
|
|
1841
|
+
"sh",
|
|
1842
|
+
"-c",
|
|
1843
|
+
command
|
|
1844
|
+
],
|
|
1845
|
+
input,
|
|
1846
|
+
{ SSH_ASKPASS: askPass, SSH_ASKPASS_REQUIRE: "force", DISPLAY: "attraccess", ATTRACCESS_SSH_PASSWORD: credential.password }
|
|
1847
|
+
);
|
|
1848
|
+
} finally {
|
|
1849
|
+
await (0, import_promises.rm)(dir, { recursive: true, force: true });
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
async copyTo(host, fingerprint, credential, source, destination) {
|
|
1853
|
+
const dir = await (0, import_promises.mkdtemp)((0, import_node_path.join)((0, import_node_os.tmpdir)(), "attraccess-cc100-"));
|
|
1854
|
+
const knownHosts = (0, import_node_path.join)(dir, "known_hosts");
|
|
1855
|
+
const askPass = (0, import_node_path.join)(dir, "askpass");
|
|
1856
|
+
try {
|
|
1857
|
+
await (0, import_promises.writeFile)(knownHosts, await pinnedHostKey(host, fingerprint), { mode: 384 });
|
|
1858
|
+
await (0, import_promises.writeFile)(askPass, `#!/bin/sh
|
|
1859
|
+
printf '%s\\n' "$ATTRACCESS_SSH_PASSWORD"
|
|
1860
|
+
`, { mode: 448 });
|
|
1861
|
+
await runProcess(
|
|
1862
|
+
"scp",
|
|
1863
|
+
[
|
|
1864
|
+
"-o",
|
|
1865
|
+
"BatchMode=no",
|
|
1866
|
+
"-o",
|
|
1867
|
+
"NumberOfPasswordPrompts=1",
|
|
1868
|
+
"-o",
|
|
1869
|
+
"HostKeyAlgorithms=ssh-ed25519",
|
|
1870
|
+
"-o",
|
|
1871
|
+
"StrictHostKeyChecking=yes",
|
|
1872
|
+
"-o",
|
|
1873
|
+
`UserKnownHostsFile=${knownHosts}`,
|
|
1874
|
+
"-o",
|
|
1875
|
+
"ConnectTimeout=15",
|
|
1876
|
+
source,
|
|
1877
|
+
`${credential.username}@${host}:${destination}`
|
|
1878
|
+
],
|
|
1879
|
+
void 0,
|
|
1880
|
+
{ SSH_ASKPASS: askPass, SSH_ASKPASS_REQUIRE: "force", DISPLAY: "attraccess", ATTRACCESS_SSH_PASSWORD: credential.password }
|
|
1881
|
+
);
|
|
1882
|
+
} finally {
|
|
1883
|
+
await (0, import_promises.rm)(dir, { recursive: true, force: true });
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
save(session, event) {
|
|
1887
|
+
const audit = JSON.parse(session.auditLog);
|
|
1888
|
+
audit.push({ at: (/* @__PURE__ */ new Date()).toISOString(), event });
|
|
1889
|
+
session.auditLog = JSON.stringify(audit.slice(-50));
|
|
1890
|
+
session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1891
|
+
return this.sessions.save(session);
|
|
1892
|
+
}
|
|
1893
|
+
};
|
|
1894
|
+
WagoCommissioningService = __decorateClass([
|
|
1895
|
+
(0, import_common2.Injectable)(),
|
|
1896
|
+
__decorateParam(0, (0, import_common2.Inject)(import_plugins_backend_sdk7.PLUGIN_CONTEXT))
|
|
1897
|
+
], WagoCommissioningService);
|
|
1898
|
+
async function scanHostKey(host) {
|
|
1899
|
+
const dir = await (0, import_promises.mkdtemp)((0, import_node_path.join)((0, import_node_os.tmpdir)(), "attraccess-cc100-"));
|
|
1900
|
+
const knownHosts = (0, import_node_path.join)(dir, "known_hosts");
|
|
1901
|
+
try {
|
|
1902
|
+
await (0, import_promises.writeFile)(knownHosts, await scanHostKeys(host), { mode: 384 });
|
|
1903
|
+
return fingerprintFor(knownHosts);
|
|
1904
|
+
} finally {
|
|
1905
|
+
await (0, import_promises.rm)(dir, { recursive: true, force: true });
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
function scanHostKeys(host) {
|
|
1909
|
+
return runProcess("ssh-keyscan", ["-T", "15", "-t", "ed25519", host]);
|
|
1910
|
+
}
|
|
1911
|
+
async function pinnedHostKey(host, expectedFingerprint) {
|
|
1912
|
+
const key = (await scanHostKeys(host)).split("\n").find((line) => line.includes("ssh-ed25519"));
|
|
1913
|
+
if (!key) throw new import_common2.ConflictException("the controller did not provide an Ed25519 SSH host key");
|
|
1914
|
+
const dir = await (0, import_promises.mkdtemp)((0, import_node_path.join)((0, import_node_os.tmpdir)(), "attraccess-cc100-"));
|
|
1915
|
+
const knownHosts = (0, import_node_path.join)(dir, "known_hosts");
|
|
1916
|
+
try {
|
|
1917
|
+
await (0, import_promises.writeFile)(knownHosts, `${key}
|
|
1918
|
+
`, { mode: 384 });
|
|
1919
|
+
if (await fingerprintFor(knownHosts) !== expectedFingerprint) throw new import_common2.ConflictException("controller SSH host key changed after identity confirmation");
|
|
1920
|
+
return `${key}
|
|
1921
|
+
`;
|
|
1922
|
+
} finally {
|
|
1923
|
+
await (0, import_promises.rm)(dir, { recursive: true, force: true });
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
async function fingerprintFor(knownHosts) {
|
|
1927
|
+
const result = (await runProcess("ssh-keygen", ["-lf", knownHosts, "-E", "sha256"])).match(/(SHA256:[A-Za-z0-9+/=]+)/)?.[1];
|
|
1928
|
+
if (!result) throw new import_common2.ConflictException("the controller did not provide a supported SSH host key");
|
|
1929
|
+
return result;
|
|
1930
|
+
}
|
|
1931
|
+
function runProcess(command, args, input, environment) {
|
|
1932
|
+
return new Promise((resolve, reject) => {
|
|
1933
|
+
const child = (0, import_node_child_process.spawn)(command, args, { env: { ...process.env, ...environment }, stdio: ["pipe", "pipe", "pipe"] });
|
|
1934
|
+
let stdout = "";
|
|
1935
|
+
let stderr = "";
|
|
1936
|
+
const timer = setTimeout(() => child.kill(), SSH_TIMEOUT_MS);
|
|
1937
|
+
child.stdout.on("data", (chunk) => stdout += chunk);
|
|
1938
|
+
child.stderr.on("data", (chunk) => stderr += chunk);
|
|
1939
|
+
child.on("error", reject);
|
|
1940
|
+
child.on("close", (code) => {
|
|
1941
|
+
clearTimeout(timer);
|
|
1942
|
+
if (code === 0) resolve(stdout);
|
|
1943
|
+
else reject(new Error(stderr || `${command} exited with status ${code}`));
|
|
1944
|
+
});
|
|
1945
|
+
child.stdin.end(input);
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
function isPrivateAddress(host) {
|
|
1949
|
+
const match = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
1950
|
+
if (!match) return false;
|
|
1951
|
+
const octets = match.slice(1).map(Number);
|
|
1952
|
+
if (octets.some((octet) => octet > 255)) return false;
|
|
1953
|
+
return octets[0] === 10 || octets[0] === 192 && octets[1] === 168 || octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;
|
|
1954
|
+
}
|
|
1955
|
+
function redact(value) {
|
|
1956
|
+
return value.replace(/(?:password|secret|token)=?[^\s]*/gi, "[redacted]");
|
|
1957
|
+
}
|
|
1958
|
+
function isImmutableImage(image) {
|
|
1959
|
+
return /@sha256:[a-f0-9]{64}$/i.test(image);
|
|
1960
|
+
}
|
|
1961
|
+
function isRuntimeArtifactConfigured() {
|
|
1962
|
+
return Boolean(
|
|
1963
|
+
isImmutableImage(configuredRuntimeImage) && configuredRuntimeBundle && configuredRuntimeBundleChecksum && configuredRuntimeBundleSignature
|
|
1964
|
+
);
|
|
1965
|
+
}
|
|
1966
|
+
async function verifyRuntimeBundle() {
|
|
1967
|
+
const directory = await (0, import_promises.mkdtemp)((0, import_node_path.join)((0, import_node_os.tmpdir)(), "attraccess-cc100-runtime-"));
|
|
1968
|
+
const bundlePath = (0, import_node_path.join)(directory, "runtime.tar");
|
|
1969
|
+
const checksumPath = (0, import_node_path.join)(directory, "runtime.tar.sha256");
|
|
1970
|
+
const signaturePath = (0, import_node_path.join)(directory, "runtime.tar.sig");
|
|
1971
|
+
try {
|
|
1972
|
+
await Promise.all([
|
|
1973
|
+
(0, import_promises.copyFile)(configuredRuntimeBundle, bundlePath),
|
|
1974
|
+
(0, import_promises.copyFile)(configuredRuntimeBundleChecksum, checksumPath),
|
|
1975
|
+
(0, import_promises.copyFile)(configuredRuntimeBundleSignature, signaturePath)
|
|
1976
|
+
]);
|
|
1977
|
+
} catch (error) {
|
|
1978
|
+
await (0, import_promises.rm)(directory, { recursive: true, force: true });
|
|
1979
|
+
throw error;
|
|
1980
|
+
}
|
|
1981
|
+
const [bundle, checksum, signature] = await Promise.all([(0, import_promises.readFile)(bundlePath), (0, import_promises.readFile)(checksumPath, "utf8"), (0, import_promises.stat)(signaturePath)]);
|
|
1982
|
+
if (!signature.isFile()) throw new import_common2.ConflictException("CC100 runtime signature is not a file");
|
|
1983
|
+
const digest = (0, import_node_crypto3.createHash)("sha256").update(bundle).digest("hex");
|
|
1984
|
+
if (!new RegExp(`^${digest}\\s+\\*?${escapeRegExp(configuredRuntimeBundle.split("/").pop() ?? "")}\\s*$`, "m").test(checksum)) {
|
|
1985
|
+
throw new import_common2.ConflictException("CC100 runtime bundle checksum does not match");
|
|
1986
|
+
}
|
|
1987
|
+
try {
|
|
1988
|
+
const allowedSigners = (0, import_node_path.join)(directory, "allowed_signers");
|
|
1989
|
+
const publicKey = await (0, import_promises.readFile)((0, import_node_path.join)(__dirname, "signing-public-key.pub"), "utf8");
|
|
1990
|
+
await (0, import_promises.writeFile)(allowedSigners, `${SIGNING_IDENTITY} ${publicKey.trim()}
|
|
1991
|
+
`, { mode: 384 });
|
|
1992
|
+
await runProcess(
|
|
1993
|
+
"ssh-keygen",
|
|
1994
|
+
["-Y", "verify", "-f", allowedSigners, "-I", SIGNING_IDENTITY, "-n", SIGNING_NAMESPACE, "-s", signaturePath],
|
|
1995
|
+
bundle
|
|
1996
|
+
);
|
|
1997
|
+
} finally {
|
|
1998
|
+
await (0, import_promises.rm)((0, import_node_path.join)(directory, "allowed_signers"), { force: true });
|
|
1999
|
+
}
|
|
2000
|
+
return { directory, path: bundlePath };
|
|
2001
|
+
}
|
|
2002
|
+
function escapeRegExp(value) {
|
|
2003
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2004
|
+
}
|
|
2005
|
+
function runtimeImageTag() {
|
|
2006
|
+
return configuredRuntimeImage.split("@", 1)[0];
|
|
2007
|
+
}
|
|
2008
|
+
function isSupportedController(inspection, firmwareBaseline) {
|
|
2009
|
+
if (!inspection.includes('PTXDIST_PLATFORM_NAME="cc100"')) return false;
|
|
2010
|
+
const reportedFirmware = inspection.match(/firmware[^\r\n]*?(\d+(?:\.\d+)+|\d+)/i)?.[1];
|
|
2011
|
+
return !reportedFirmware || reportedFirmware.includes(firmwareBaseline);
|
|
2012
|
+
}
|
|
2013
|
+
function shellQuote(value) {
|
|
2014
|
+
return `'${value.replace(/'/g, `'\\"'\\"'`)}'`;
|
|
2015
|
+
}
|
|
1417
2016
|
|
|
1418
2017
|
// backend/wago.controller.ts
|
|
1419
2018
|
var WagoControllerApi = class {
|
|
1420
|
-
constructor(wago) {
|
|
2019
|
+
constructor(wago, commissioning) {
|
|
1421
2020
|
__publicField(this, "wago", wago);
|
|
2021
|
+
__publicField(this, "commissioning", commissioning);
|
|
1422
2022
|
}
|
|
1423
2023
|
list() {
|
|
1424
2024
|
return this.wago.list();
|
|
@@ -1429,9 +2029,30 @@ var WagoControllerApi = class {
|
|
|
1429
2029
|
setSettings(body) {
|
|
1430
2030
|
return this.wago.setSettings(body?.defaultMqttServerId, body?.operationalPrefix);
|
|
1431
2031
|
}
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
2032
|
+
commissioningSupport() {
|
|
2033
|
+
return this.commissioning.support();
|
|
2034
|
+
}
|
|
2035
|
+
commissioningSessions() {
|
|
2036
|
+
return this.commissioning.list();
|
|
2037
|
+
}
|
|
2038
|
+
createCommissioningSession(body) {
|
|
2039
|
+
if (!body?.mqttServerId) throw new import_common3.BadRequestException("MQTT server is required");
|
|
2040
|
+
return this.commissioning.create({
|
|
2041
|
+
hardwareId: body.hardwareId ?? "",
|
|
2042
|
+
mqttServerId: body.mqttServerId,
|
|
2043
|
+
targetHost: body.targetHost ?? ""
|
|
2044
|
+
});
|
|
2045
|
+
}
|
|
2046
|
+
deliverCommissioningSession(id, body) {
|
|
2047
|
+
return this.commissioning.deliver(id, {
|
|
2048
|
+
hostKeyFingerprint: body?.hostKeyFingerprint ?? "",
|
|
2049
|
+
physicalIdentityConfirmed: body?.physicalIdentityConfirmed === true,
|
|
2050
|
+
codesysStopConfirmed: body?.codesysStopConfirmed === true,
|
|
2051
|
+
temporarySsh: { username: body?.temporarySsh?.username ?? "", password: body?.temporarySsh?.password ?? "" }
|
|
2052
|
+
});
|
|
2053
|
+
}
|
|
2054
|
+
revokeCommissioningSession(id) {
|
|
2055
|
+
return this.commissioning.revoke(id);
|
|
1435
2056
|
}
|
|
1436
2057
|
claim(id, body) {
|
|
1437
2058
|
return this.wago.claim(id, body?.name ?? "", body?.verifier ?? "", body?.mqttServerId);
|
|
@@ -1439,6 +2060,17 @@ var WagoControllerApi = class {
|
|
|
1439
2060
|
draft(id) {
|
|
1440
2061
|
return this.wago.getDraft(id);
|
|
1441
2062
|
}
|
|
2063
|
+
presets() {
|
|
2064
|
+
return this.wago.presets();
|
|
2065
|
+
}
|
|
2066
|
+
previewPreset(id, body) {
|
|
2067
|
+
if (!body?.application) throw new import_common3.BadRequestException("application is required");
|
|
2068
|
+
return this.wago.previewPreset(id, body.application);
|
|
2069
|
+
}
|
|
2070
|
+
applyPreset(id, body) {
|
|
2071
|
+
if (!body?.application) throw new import_common3.BadRequestException("application is required");
|
|
2072
|
+
return this.wago.applyPreset(id, body.application, body.selectedPaths ?? [], body.previewedDraftHash ?? "");
|
|
2073
|
+
}
|
|
1442
2074
|
saveDraft(id, body) {
|
|
1443
2075
|
return this.wago.saveDraft(id, body?.snapshot);
|
|
1444
2076
|
}
|
|
@@ -1462,78 +2094,114 @@ var WagoControllerApi = class {
|
|
|
1462
2094
|
}
|
|
1463
2095
|
};
|
|
1464
2096
|
__decorateClass([
|
|
1465
|
-
(0,
|
|
2097
|
+
(0, import_common3.Get)("controllers")
|
|
1466
2098
|
], WagoControllerApi.prototype, "list", 1);
|
|
1467
2099
|
__decorateClass([
|
|
1468
|
-
(0,
|
|
2100
|
+
(0, import_common3.Get)("settings")
|
|
1469
2101
|
], WagoControllerApi.prototype, "settings", 1);
|
|
1470
2102
|
__decorateClass([
|
|
1471
|
-
(0,
|
|
1472
|
-
__decorateParam(0, (0,
|
|
2103
|
+
(0, import_common3.Post)("settings"),
|
|
2104
|
+
__decorateParam(0, (0, import_common3.Body)())
|
|
1473
2105
|
], WagoControllerApi.prototype, "setSettings", 1);
|
|
1474
2106
|
__decorateClass([
|
|
1475
|
-
(0,
|
|
1476
|
-
|
|
1477
|
-
|
|
2107
|
+
(0, import_common3.Get)("commissioning/support")
|
|
2108
|
+
], WagoControllerApi.prototype, "commissioningSupport", 1);
|
|
2109
|
+
__decorateClass([
|
|
2110
|
+
(0, import_common3.Get)("commissioning/sessions")
|
|
2111
|
+
], WagoControllerApi.prototype, "commissioningSessions", 1);
|
|
2112
|
+
__decorateClass([
|
|
2113
|
+
(0, import_common3.Post)("commissioning/sessions"),
|
|
2114
|
+
__decorateParam(0, (0, import_common3.Body)())
|
|
2115
|
+
], WagoControllerApi.prototype, "createCommissioningSession", 1);
|
|
2116
|
+
__decorateClass([
|
|
2117
|
+
(0, import_common3.Post)("commissioning/sessions/:id/deliver"),
|
|
2118
|
+
__decorateParam(0, (0, import_common3.Param)("id", import_common3.ParseIntPipe)),
|
|
2119
|
+
__decorateParam(1, (0, import_common3.Body)())
|
|
2120
|
+
], WagoControllerApi.prototype, "deliverCommissioningSession", 1);
|
|
1478
2121
|
__decorateClass([
|
|
1479
|
-
(0,
|
|
1480
|
-
__decorateParam(0, (0,
|
|
1481
|
-
|
|
2122
|
+
(0, import_common3.Post)("commissioning/sessions/:id/revoke"),
|
|
2123
|
+
__decorateParam(0, (0, import_common3.Param)("id", import_common3.ParseIntPipe))
|
|
2124
|
+
], WagoControllerApi.prototype, "revokeCommissioningSession", 1);
|
|
2125
|
+
__decorateClass([
|
|
2126
|
+
(0, import_common3.Post)("controllers/:id/claim"),
|
|
2127
|
+
__decorateParam(0, (0, import_common3.Param)("id", import_common3.ParseIntPipe)),
|
|
2128
|
+
__decorateParam(1, (0, import_common3.Body)())
|
|
1482
2129
|
], WagoControllerApi.prototype, "claim", 1);
|
|
1483
2130
|
__decorateClass([
|
|
1484
|
-
(0,
|
|
1485
|
-
__decorateParam(0, (0,
|
|
2131
|
+
(0, import_common3.Get)("controllers/:id/configuration/draft"),
|
|
2132
|
+
__decorateParam(0, (0, import_common3.Param)("id", import_common3.ParseIntPipe))
|
|
1486
2133
|
], WagoControllerApi.prototype, "draft", 1);
|
|
1487
2134
|
__decorateClass([
|
|
1488
|
-
(0,
|
|
1489
|
-
|
|
1490
|
-
|
|
2135
|
+
(0, import_common3.Get)("configuration/presets")
|
|
2136
|
+
], WagoControllerApi.prototype, "presets", 1);
|
|
2137
|
+
__decorateClass([
|
|
2138
|
+
(0, import_common3.Post)("controllers/:id/configuration/presets/preview"),
|
|
2139
|
+
__decorateParam(0, (0, import_common3.Param)("id", import_common3.ParseIntPipe)),
|
|
2140
|
+
__decorateParam(1, (0, import_common3.Body)())
|
|
2141
|
+
], WagoControllerApi.prototype, "previewPreset", 1);
|
|
2142
|
+
__decorateClass([
|
|
2143
|
+
(0, import_common3.Post)("controllers/:id/configuration/presets/apply"),
|
|
2144
|
+
__decorateParam(0, (0, import_common3.Param)("id", import_common3.ParseIntPipe)),
|
|
2145
|
+
__decorateParam(1, (0, import_common3.Body)())
|
|
2146
|
+
], WagoControllerApi.prototype, "applyPreset", 1);
|
|
2147
|
+
__decorateClass([
|
|
2148
|
+
(0, import_common3.Post)("controllers/:id/configuration/draft"),
|
|
2149
|
+
__decorateParam(0, (0, import_common3.Param)("id", import_common3.ParseIntPipe)),
|
|
2150
|
+
__decorateParam(1, (0, import_common3.Body)())
|
|
1491
2151
|
], WagoControllerApi.prototype, "saveDraft", 1);
|
|
1492
2152
|
__decorateClass([
|
|
1493
|
-
(0,
|
|
1494
|
-
__decorateParam(0, (0,
|
|
2153
|
+
(0, import_common3.Post)("controllers/:id/configuration/validate"),
|
|
2154
|
+
__decorateParam(0, (0, import_common3.Param)("id", import_common3.ParseIntPipe))
|
|
1495
2155
|
], WagoControllerApi.prototype, "validateDraft", 1);
|
|
1496
2156
|
__decorateClass([
|
|
1497
|
-
(0,
|
|
1498
|
-
__decorateParam(0, (0,
|
|
2157
|
+
(0, import_common3.Post)("controllers/:id/configuration/review"),
|
|
2158
|
+
__decorateParam(0, (0, import_common3.Param)("id", import_common3.ParseIntPipe))
|
|
1499
2159
|
], WagoControllerApi.prototype, "reviewDraft", 1);
|
|
1500
2160
|
__decorateClass([
|
|
1501
|
-
(0,
|
|
1502
|
-
__decorateParam(0, (0,
|
|
1503
|
-
__decorateParam(1, (0,
|
|
1504
|
-
__decorateParam(2, (0,
|
|
2161
|
+
(0, import_common3.Get)("controllers/:id/configuration/revisions"),
|
|
2162
|
+
__decorateParam(0, (0, import_common3.Param)("id", import_common3.ParseIntPipe)),
|
|
2163
|
+
__decorateParam(1, (0, import_common3.Query)("offset")),
|
|
2164
|
+
__decorateParam(2, (0, import_common3.Query)("limit"))
|
|
1505
2165
|
], WagoControllerApi.prototype, "revisions", 1);
|
|
1506
2166
|
__decorateClass([
|
|
1507
|
-
(0,
|
|
1508
|
-
__decorateParam(0, (0,
|
|
2167
|
+
(0, import_common3.Post)("controllers/:id/configuration/publish"),
|
|
2168
|
+
__decorateParam(0, (0, import_common3.Param)("id", import_common3.ParseIntPipe))
|
|
1509
2169
|
], WagoControllerApi.prototype, "publishDraft", 1);
|
|
1510
2170
|
__decorateClass([
|
|
1511
|
-
(0,
|
|
1512
|
-
__decorateParam(0, (0,
|
|
1513
|
-
__decorateParam(1, (0,
|
|
2171
|
+
(0, import_common3.Post)("controllers/:id/configuration/rollback/:revision"),
|
|
2172
|
+
__decorateParam(0, (0, import_common3.Param)("id", import_common3.ParseIntPipe)),
|
|
2173
|
+
__decorateParam(1, (0, import_common3.Param)("revision", import_common3.ParseIntPipe))
|
|
1514
2174
|
], WagoControllerApi.prototype, "rollback", 1);
|
|
1515
2175
|
__decorateClass([
|
|
1516
|
-
(0,
|
|
1517
|
-
__decorateParam(0, (0,
|
|
1518
|
-
__decorateParam(1, (0,
|
|
2176
|
+
(0, import_common3.Get)("controllers/:id/configuration/revisions/:revision/preview"),
|
|
2177
|
+
__decorateParam(0, (0, import_common3.Param)("id", import_common3.ParseIntPipe)),
|
|
2178
|
+
__decorateParam(1, (0, import_common3.Param)("revision", import_common3.ParseIntPipe))
|
|
1519
2179
|
], WagoControllerApi.prototype, "previewRevision", 1);
|
|
1520
2180
|
WagoControllerApi = __decorateClass([
|
|
1521
|
-
(0,
|
|
1522
|
-
(0,
|
|
1523
|
-
__decorateParam(0, (0,
|
|
2181
|
+
(0, import_plugins_backend_sdk8.Auth)("system.settings.manage"),
|
|
2182
|
+
(0, import_common3.Controller)("wago"),
|
|
2183
|
+
__decorateParam(0, (0, import_common3.Inject)(WagoService)),
|
|
2184
|
+
__decorateParam(1, (0, import_common3.Inject)(WagoCommissioningService))
|
|
1524
2185
|
], WagoControllerApi);
|
|
1525
2186
|
|
|
1526
2187
|
// backend/plugin.ts
|
|
1527
|
-
var
|
|
2188
|
+
var PLUGIN_CONTEXT3 = /* @__PURE__ */ Symbol.for("attraccess.plugin.context");
|
|
1528
2189
|
var WagoPluginModule = class {
|
|
1529
2190
|
};
|
|
1530
2191
|
var plugin = {
|
|
1531
|
-
entities: [
|
|
2192
|
+
entities: [
|
|
2193
|
+
WagoController,
|
|
2194
|
+
WagoSettings,
|
|
2195
|
+
WagoEnrollment,
|
|
2196
|
+
WagoConfigurationDraft,
|
|
2197
|
+
WagoConfigurationRevision,
|
|
2198
|
+
WagoCommissioningSession
|
|
2199
|
+
],
|
|
1532
2200
|
register(context) {
|
|
1533
2201
|
return {
|
|
1534
2202
|
module: WagoPluginModule,
|
|
1535
2203
|
controllers: [WagoControllerApi],
|
|
1536
|
-
providers: [{ provide:
|
|
2204
|
+
providers: [{ provide: PLUGIN_CONTEXT3, useValue: context }, WagoService, WagoCommissioningService]
|
|
1537
2205
|
};
|
|
1538
2206
|
}
|
|
1539
2207
|
};
|