@attraccess/plugin-wago 0.1.0-nightly.932.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 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 import_common2 = require("@nestjs/common");
39
- var import_plugins_backend_sdk6 = require("@attraccess/plugins-backend-sdk");
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");
@@ -711,6 +711,12 @@ var PLUGIN_CONTEXT = /* @__PURE__ */ Symbol.for("attraccess.plugin.context");
711
711
  var STALE_AFTER_MS = 9e4;
712
712
  var MAX_PENDING_CONFIGURATION_REPORTS = 100;
713
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
+ };
714
720
  var WagoService = class {
715
721
  constructor(context) {
716
722
  __publicField(this, "context", context);
@@ -738,7 +744,13 @@ var WagoService = class {
738
744
  this.revisions = this.context.getRepository(WagoConfigurationRevision);
739
745
  const enrollments = await this.enrollments.createQueryBuilder("enrollment").where("enrollment.consumedAt IS NULL").getMany();
740
746
  for (const enrollment of enrollments) this.scheduleEnrollmentExpiry(enrollment);
741
- await this.subscribeConfiguredServers();
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
+ }
742
754
  }
743
755
  onModuleDestroy() {
744
756
  this.destroyed = true;
@@ -803,7 +815,7 @@ var WagoService = class {
803
815
  if (!await this.context.getMqttServerConfig(mqttServerId))
804
816
  throw new import_common.NotFoundException(`MQTT server ${mqttServerId} not found`);
805
817
  const providers = await this.context.getMqttCredentialProvisioning().availableProviders(mqttServerId);
806
- return { automatic: providers.length > 0 };
818
+ return { automatic: providers.length === 1 };
807
819
  }
808
820
  async getDraft(controllerId) {
809
821
  await this.claimedController(controllerId);
@@ -1004,6 +1016,11 @@ var WagoService = class {
1004
1016
  ) : void 0
1005
1017
  };
1006
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
+ }
1007
1024
  async claim(id, name, verifier, mqttServerId) {
1008
1025
  return this.withClaimLock(id, async () => {
1009
1026
  const prepared = await this.withClaimConfigurationLock(() => this.prepareClaim(id, name, verifier, mqttServerId));
@@ -1165,7 +1182,7 @@ var WagoService = class {
1165
1182
  try {
1166
1183
  for (const serverId of serverIds) {
1167
1184
  replacements.push(
1168
- await this.context.mqtt.subscribe(serverId, `${DISCOVERY_ROOT}/+`, async (message) => {
1185
+ await this.subscribeMqtt(serverId, `${DISCOVERY_ROOT}/+`, async (message) => {
1169
1186
  if (!this.isActiveSubscriptionGeneration(generation)) return;
1170
1187
  await this.onDiscovery(serverId, message.topic, message.payload);
1171
1188
  })
@@ -1181,7 +1198,7 @@ var WagoService = class {
1181
1198
  claimedControllers.map((controller) => [controller.hardwareId, controller])
1182
1199
  );
1183
1200
  replacements.push(
1184
- await this.context.mqtt.subscribe(
1201
+ await this.subscribeMqtt(
1185
1202
  serverId,
1186
1203
  configurationReportedWildcardTopic(settings.operationalPrefix),
1187
1204
  (message) => {
@@ -1198,7 +1215,7 @@ var WagoService = class {
1198
1215
  }
1199
1216
  for (const controller of claimedControllers) {
1200
1217
  replacements.push(
1201
- await this.context.mqtt.subscribe(
1218
+ await this.subscribeMqtt(
1202
1219
  serverId,
1203
1220
  heartbeatTopic(settings.operationalPrefix, controller.hardwareId),
1204
1221
  async (message) => {
@@ -1228,6 +1245,13 @@ var WagoService = class {
1228
1245
  unsubscribe() {
1229
1246
  this.subscriptions.splice(0).forEach((subscription) => subscription.unsubscribe());
1230
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
+ }
1231
1255
  isActiveSubscriptionGeneration(generation) {
1232
1256
  return !this.destroyed && generation === this.activeSubscriptionGeneration;
1233
1257
  }
@@ -1549,31 +1573,452 @@ function parsePresetProvenance(value) {
1549
1573
  }
1550
1574
  }
1551
1575
  function applySelectedChanges(snapshot, diff, selectedPaths) {
1552
- const merged = JSON.parse(JSON.stringify(snapshot));
1576
+ let merged = JSON.parse(JSON.stringify(snapshot));
1553
1577
  const changes = new Map(diff.map((change) => [change.path, change]));
1554
1578
  for (const path of selectedPaths) {
1555
1579
  const change = changes.get(path);
1556
1580
  if (!change) continue;
1557
1581
  const segments = [...path.matchAll(/\.([^.[\]]+)|\[(\d+)\]/g)].map((match) => match[1] ?? Number(match[2]));
1558
- if (!segments.length) continue;
1559
- let target = merged;
1560
- for (const segment of segments.slice(0, -1)) {
1561
- const next = target[segment];
1562
- if (next === void 0) target[segment] = typeof segment === "number" ? [] : {};
1563
- target = target[segment];
1564
- }
1565
- const last = segments[segments.length - 1];
1566
- if (last === void 0) continue;
1567
- if (change.current === void 0) delete target[last];
1568
- else target[last] = change.current;
1582
+ if (!segments.length || segments.some((segment) => typeof segment === "string" && unsafePathSegment(segment))) continue;
1583
+ merged = replacePath(merged, segments, change.current);
1569
1584
  }
1570
1585
  return merged;
1571
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
+ }
1572
2016
 
1573
2017
  // backend/wago.controller.ts
1574
2018
  var WagoControllerApi = class {
1575
- constructor(wago) {
2019
+ constructor(wago, commissioning) {
1576
2020
  __publicField(this, "wago", wago);
2021
+ __publicField(this, "commissioning", commissioning);
1577
2022
  }
1578
2023
  list() {
1579
2024
  return this.wago.list();
@@ -1584,12 +2029,30 @@ var WagoControllerApi = class {
1584
2029
  setSettings(body) {
1585
2030
  return this.wago.setSettings(body?.defaultMqttServerId, body?.operationalPrefix);
1586
2031
  }
1587
- enrollment(body) {
1588
- const manualCredentials = body?.manualUsername || body?.manualPassword ? { username: body.manualUsername ?? "", password: body.manualPassword ?? "" } : void 0;
1589
- return this.wago.createEnrollment(body?.hardwareId ?? "", body?.mqttServerId, manualCredentials);
2032
+ commissioningSupport() {
2033
+ return this.commissioning.support();
1590
2034
  }
1591
- credentialSupport(mqttServerId) {
1592
- return this.wago.enrollmentCredentialSupport(mqttServerId);
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);
1593
2056
  }
1594
2057
  claim(id, body) {
1595
2058
  return this.wago.claim(id, body?.name ?? "", body?.verifier ?? "", body?.mqttServerId);
@@ -1601,11 +2064,11 @@ var WagoControllerApi = class {
1601
2064
  return this.wago.presets();
1602
2065
  }
1603
2066
  previewPreset(id, body) {
1604
- if (!body?.application) throw new import_common2.BadRequestException("application is required");
2067
+ if (!body?.application) throw new import_common3.BadRequestException("application is required");
1605
2068
  return this.wago.previewPreset(id, body.application);
1606
2069
  }
1607
2070
  applyPreset(id, body) {
1608
- if (!body?.application) throw new import_common2.BadRequestException("application is required");
2071
+ if (!body?.application) throw new import_common3.BadRequestException("application is required");
1609
2072
  return this.wago.applyPreset(id, body.application, body.selectedPaths ?? [], body.previewedDraftHash ?? "");
1610
2073
  }
1611
2074
  saveDraft(id, body) {
@@ -1631,95 +2094,114 @@ var WagoControllerApi = class {
1631
2094
  }
1632
2095
  };
1633
2096
  __decorateClass([
1634
- (0, import_common2.Get)("controllers")
2097
+ (0, import_common3.Get)("controllers")
1635
2098
  ], WagoControllerApi.prototype, "list", 1);
1636
2099
  __decorateClass([
1637
- (0, import_common2.Get)("settings")
2100
+ (0, import_common3.Get)("settings")
1638
2101
  ], WagoControllerApi.prototype, "settings", 1);
1639
2102
  __decorateClass([
1640
- (0, import_common2.Post)("settings"),
1641
- __decorateParam(0, (0, import_common2.Body)())
2103
+ (0, import_common3.Post)("settings"),
2104
+ __decorateParam(0, (0, import_common3.Body)())
1642
2105
  ], WagoControllerApi.prototype, "setSettings", 1);
1643
2106
  __decorateClass([
1644
- (0, import_common2.Post)("enrollments"),
1645
- __decorateParam(0, (0, import_common2.Body)())
1646
- ], WagoControllerApi.prototype, "enrollment", 1);
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);
1647
2121
  __decorateClass([
1648
- (0, import_common2.Get)("enrollments/credential-support/:mqttServerId"),
1649
- __decorateParam(0, (0, import_common2.Param)("mqttServerId", import_common2.ParseIntPipe))
1650
- ], WagoControllerApi.prototype, "credentialSupport", 1);
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);
1651
2125
  __decorateClass([
1652
- (0, import_common2.Post)("controllers/:id/claim"),
1653
- __decorateParam(0, (0, import_common2.Param)("id", import_common2.ParseIntPipe)),
1654
- __decorateParam(1, (0, import_common2.Body)())
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)())
1655
2129
  ], WagoControllerApi.prototype, "claim", 1);
1656
2130
  __decorateClass([
1657
- (0, import_common2.Get)("controllers/:id/configuration/draft"),
1658
- __decorateParam(0, (0, import_common2.Param)("id", import_common2.ParseIntPipe))
2131
+ (0, import_common3.Get)("controllers/:id/configuration/draft"),
2132
+ __decorateParam(0, (0, import_common3.Param)("id", import_common3.ParseIntPipe))
1659
2133
  ], WagoControllerApi.prototype, "draft", 1);
1660
2134
  __decorateClass([
1661
- (0, import_common2.Get)("configuration/presets")
2135
+ (0, import_common3.Get)("configuration/presets")
1662
2136
  ], WagoControllerApi.prototype, "presets", 1);
1663
2137
  __decorateClass([
1664
- (0, import_common2.Post)("controllers/:id/configuration/presets/preview"),
1665
- __decorateParam(0, (0, import_common2.Param)("id", import_common2.ParseIntPipe)),
1666
- __decorateParam(1, (0, import_common2.Body)())
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)())
1667
2141
  ], WagoControllerApi.prototype, "previewPreset", 1);
1668
2142
  __decorateClass([
1669
- (0, import_common2.Post)("controllers/:id/configuration/presets/apply"),
1670
- __decorateParam(0, (0, import_common2.Param)("id", import_common2.ParseIntPipe)),
1671
- __decorateParam(1, (0, import_common2.Body)())
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)())
1672
2146
  ], WagoControllerApi.prototype, "applyPreset", 1);
1673
2147
  __decorateClass([
1674
- (0, import_common2.Post)("controllers/:id/configuration/draft"),
1675
- __decorateParam(0, (0, import_common2.Param)("id", import_common2.ParseIntPipe)),
1676
- __decorateParam(1, (0, import_common2.Body)())
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)())
1677
2151
  ], WagoControllerApi.prototype, "saveDraft", 1);
1678
2152
  __decorateClass([
1679
- (0, import_common2.Post)("controllers/:id/configuration/validate"),
1680
- __decorateParam(0, (0, import_common2.Param)("id", import_common2.ParseIntPipe))
2153
+ (0, import_common3.Post)("controllers/:id/configuration/validate"),
2154
+ __decorateParam(0, (0, import_common3.Param)("id", import_common3.ParseIntPipe))
1681
2155
  ], WagoControllerApi.prototype, "validateDraft", 1);
1682
2156
  __decorateClass([
1683
- (0, import_common2.Post)("controllers/:id/configuration/review"),
1684
- __decorateParam(0, (0, import_common2.Param)("id", import_common2.ParseIntPipe))
2157
+ (0, import_common3.Post)("controllers/:id/configuration/review"),
2158
+ __decorateParam(0, (0, import_common3.Param)("id", import_common3.ParseIntPipe))
1685
2159
  ], WagoControllerApi.prototype, "reviewDraft", 1);
1686
2160
  __decorateClass([
1687
- (0, import_common2.Get)("controllers/:id/configuration/revisions"),
1688
- __decorateParam(0, (0, import_common2.Param)("id", import_common2.ParseIntPipe)),
1689
- __decorateParam(1, (0, import_common2.Query)("offset")),
1690
- __decorateParam(2, (0, import_common2.Query)("limit"))
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"))
1691
2165
  ], WagoControllerApi.prototype, "revisions", 1);
1692
2166
  __decorateClass([
1693
- (0, import_common2.Post)("controllers/:id/configuration/publish"),
1694
- __decorateParam(0, (0, import_common2.Param)("id", import_common2.ParseIntPipe))
2167
+ (0, import_common3.Post)("controllers/:id/configuration/publish"),
2168
+ __decorateParam(0, (0, import_common3.Param)("id", import_common3.ParseIntPipe))
1695
2169
  ], WagoControllerApi.prototype, "publishDraft", 1);
1696
2170
  __decorateClass([
1697
- (0, import_common2.Post)("controllers/:id/configuration/rollback/:revision"),
1698
- __decorateParam(0, (0, import_common2.Param)("id", import_common2.ParseIntPipe)),
1699
- __decorateParam(1, (0, import_common2.Param)("revision", import_common2.ParseIntPipe))
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))
1700
2174
  ], WagoControllerApi.prototype, "rollback", 1);
1701
2175
  __decorateClass([
1702
- (0, import_common2.Get)("controllers/:id/configuration/revisions/:revision/preview"),
1703
- __decorateParam(0, (0, import_common2.Param)("id", import_common2.ParseIntPipe)),
1704
- __decorateParam(1, (0, import_common2.Param)("revision", import_common2.ParseIntPipe))
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))
1705
2179
  ], WagoControllerApi.prototype, "previewRevision", 1);
1706
2180
  WagoControllerApi = __decorateClass([
1707
- (0, import_plugins_backend_sdk6.Auth)("resources.update"),
1708
- (0, import_common2.Controller)("wago"),
1709
- __decorateParam(0, (0, import_common2.Inject)(WagoService))
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))
1710
2185
  ], WagoControllerApi);
1711
2186
 
1712
2187
  // backend/plugin.ts
1713
- var PLUGIN_CONTEXT2 = /* @__PURE__ */ Symbol.for("attraccess.plugin.context");
2188
+ var PLUGIN_CONTEXT3 = /* @__PURE__ */ Symbol.for("attraccess.plugin.context");
1714
2189
  var WagoPluginModule = class {
1715
2190
  };
1716
2191
  var plugin = {
1717
- entities: [WagoController, WagoSettings, WagoEnrollment, WagoConfigurationDraft, WagoConfigurationRevision],
2192
+ entities: [
2193
+ WagoController,
2194
+ WagoSettings,
2195
+ WagoEnrollment,
2196
+ WagoConfigurationDraft,
2197
+ WagoConfigurationRevision,
2198
+ WagoCommissioningSession
2199
+ ],
1718
2200
  register(context) {
1719
2201
  return {
1720
2202
  module: WagoPluginModule,
1721
2203
  controllers: [WagoControllerApi],
1722
- providers: [{ provide: PLUGIN_CONTEXT2, useValue: context }, WagoService]
2204
+ providers: [{ provide: PLUGIN_CONTEXT3, useValue: context }, WagoService, WagoCommissioningService]
1723
2205
  };
1724
2206
  }
1725
2207
  };