feat(device-edge): add canonical core channel deployment
This commit is contained in:
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": "nodedc.device-edge-vps.core-channel.v1",
|
||||||
|
"mode": "provider-neutral-core-initiated-mtls-http2",
|
||||||
|
"status": "closed-tracker-ingress",
|
||||||
|
"authority": "DCPLATFORM-21/DCPLATFORM-76/ADR-0001",
|
||||||
|
"component": "device-edge-vps",
|
||||||
|
"phase": "core-channel",
|
||||||
|
"runtimeHost": "koffyvngij",
|
||||||
|
"runtimeUser": "nodedc-channel",
|
||||||
|
"runtimeService": "nodedc-device-edge-channel.service",
|
||||||
|
"runtime": "accepted-node-v22.23.2-no-docker",
|
||||||
|
"publicIngress": "tcp/8443-mtls-only",
|
||||||
|
"health": "127.0.0.1:18222",
|
||||||
|
"trackerIngress": "disabled",
|
||||||
|
"rawDeviceTcp9921": "closed",
|
||||||
|
"commandTransport": "disabled",
|
||||||
|
"gelios": "untouched",
|
||||||
|
"privateKeyBoundary": "runner-managed-host-local-only",
|
||||||
|
"peerTrustPrerequisite": "exact-pinned-self-signed-core-certificate-and-fingerprint",
|
||||||
|
"tls": "TLSv1.3+h2+mutual-authentication",
|
||||||
|
"resourceCeilings": {
|
||||||
|
"memory": "128M",
|
||||||
|
"swap": "0",
|
||||||
|
"cpu": "50%",
|
||||||
|
"tasks": 64,
|
||||||
|
"openFiles": 1024
|
||||||
|
},
|
||||||
|
"preserved": [
|
||||||
|
"management-ssh-key",
|
||||||
|
"accepted-node-runtime",
|
||||||
|
"foundation-source",
|
||||||
|
"gelios-production-path"
|
||||||
|
],
|
||||||
|
"forbidden": [
|
||||||
|
"vps-initiated-synology-connection",
|
||||||
|
"generic-tcp-forwarding",
|
||||||
|
"tailscale-ssh-backhaul",
|
||||||
|
"docker",
|
||||||
|
"public-health",
|
||||||
|
"tracker-tcp/9921"
|
||||||
|
],
|
||||||
|
"acceptance": [
|
||||||
|
"exact-non-root-runtime-identity",
|
||||||
|
"tls13-h2-mutual-authentication",
|
||||||
|
"edge-server-and-core-client-self-signed-identities-mutually-pinned",
|
||||||
|
"core-initiated-channel-accepted",
|
||||||
|
"unknown-core-certificate-rejected",
|
||||||
|
"public-8443-only-beside-management-ssh",
|
||||||
|
"tracker-tcp-9921-closed",
|
||||||
|
"loopback-health-contract",
|
||||||
|
"resource-ceilings-present"
|
||||||
|
],
|
||||||
|
"rollback": "close-8443-stop-channel-restore-exact-accepted-foundation-without-backhaul-or-relay"
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { lstat, readFile } from "node:fs/promises";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
import { createDeviceEdgeChannelServer } from "./runtime.mjs";
|
||||||
|
|
||||||
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
|
await main();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const runtime = await readRuntimeConfiguration(process.env);
|
||||||
|
const channel = createDeviceEdgeChannelServer(runtime.channel);
|
||||||
|
const health = createHealthServer(channel, runtime.health);
|
||||||
|
let stopping = false;
|
||||||
|
|
||||||
|
await channel.start();
|
||||||
|
await listen(health, runtime.health.port, runtime.health.host);
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
event: "device_edge_channel_started",
|
||||||
|
host: runtime.channel.host,
|
||||||
|
port: runtime.channel.port,
|
||||||
|
healthHost: runtime.health.host,
|
||||||
|
healthPort: runtime.health.port,
|
||||||
|
edgeRegistrationId: runtime.channel.edgeRegistrationId,
|
||||||
|
channelGeneration: runtime.channel.channelGeneration,
|
||||||
|
trustGeneration: runtime.channel.trustGeneration,
|
||||||
|
trackerIngress: "disabled",
|
||||||
|
commandTransport: "disabled",
|
||||||
|
}));
|
||||||
|
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
|
||||||
|
async function shutdown() {
|
||||||
|
if (stopping) return;
|
||||||
|
stopping = true;
|
||||||
|
await Promise.allSettled([
|
||||||
|
channel.stop(),
|
||||||
|
closeServer(health),
|
||||||
|
]);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readRuntimeConfiguration(environment = {}) {
|
||||||
|
const configPath = requiredPath(
|
||||||
|
environment.DEVICE_EDGE_CHANNEL_CONFIG_FILE,
|
||||||
|
"device_edge_channel_config_file_required",
|
||||||
|
);
|
||||||
|
const keyPath = requiredPath(
|
||||||
|
environment.DEVICE_EDGE_CHANNEL_KEY_FILE,
|
||||||
|
"device_edge_channel_key_file_required",
|
||||||
|
);
|
||||||
|
const certificatePath = requiredPath(
|
||||||
|
environment.DEVICE_EDGE_CHANNEL_CERTIFICATE_FILE,
|
||||||
|
"device_edge_channel_certificate_file_required",
|
||||||
|
);
|
||||||
|
const coreTrustPath = requiredPath(
|
||||||
|
environment.DEVICE_EDGE_CHANNEL_CORE_TRUST_FILE,
|
||||||
|
"device_edge_channel_core_trust_file_required",
|
||||||
|
);
|
||||||
|
const config = normalizeRuntimeDocument(JSON.parse(
|
||||||
|
await readBoundedRegularFile(configPath, 32 * 1024, "utf8"),
|
||||||
|
));
|
||||||
|
const [key, cert, ca] = await Promise.all([
|
||||||
|
readBoundedRegularFile(keyPath, 32 * 1024),
|
||||||
|
readBoundedRegularFile(certificatePath, 32 * 1024),
|
||||||
|
readBoundedRegularFile(coreTrustPath, 64 * 1024),
|
||||||
|
]);
|
||||||
|
return Object.freeze({
|
||||||
|
channel: Object.freeze({
|
||||||
|
edgeRegistrationId: config.edgeRegistrationId,
|
||||||
|
channelGeneration: config.channelGeneration,
|
||||||
|
trustGeneration: config.trustGeneration,
|
||||||
|
host: normalizeHost(environment.DEVICE_EDGE_CHANNEL_HOST ?? "0.0.0.0"),
|
||||||
|
port: normalizePort(environment.DEVICE_EDGE_CHANNEL_PORT, 8443),
|
||||||
|
tls: Object.freeze({
|
||||||
|
key,
|
||||||
|
cert,
|
||||||
|
ca,
|
||||||
|
allowedCoreFingerprints: config.allowedCoreFingerprints,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
health: Object.freeze({
|
||||||
|
host: normalizeHost(
|
||||||
|
environment.DEVICE_EDGE_CHANNEL_HEALTH_HOST ?? "127.0.0.1",
|
||||||
|
),
|
||||||
|
port: normalizePort(environment.DEVICE_EDGE_CHANNEL_HEALTH_PORT, 18222),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRuntimeDocument(value) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new TypeError("device_edge_channel_runtime_config_invalid");
|
||||||
|
}
|
||||||
|
const allowedKeys = new Set([
|
||||||
|
"schemaVersion",
|
||||||
|
"edgeRegistrationId",
|
||||||
|
"channelGeneration",
|
||||||
|
"trustGeneration",
|
||||||
|
"allowedCoreFingerprints",
|
||||||
|
]);
|
||||||
|
if (Object.keys(value).some((key) => !allowedKeys.has(key))) {
|
||||||
|
throw new TypeError("device_edge_channel_runtime_config_key_invalid");
|
||||||
|
}
|
||||||
|
if (value.schemaVersion !== "nodedc.device-edge.channel-runtime.v1") {
|
||||||
|
throw new TypeError("device_edge_channel_runtime_schema_invalid");
|
||||||
|
}
|
||||||
|
const fingerprints = value.allowedCoreFingerprints;
|
||||||
|
if (!Array.isArray(fingerprints) || fingerprints.length < 1 || fingerprints.length > 2) {
|
||||||
|
throw new TypeError("device_edge_channel_runtime_core_identity_invalid");
|
||||||
|
}
|
||||||
|
if (new Set(fingerprints).size !== fingerprints.length) {
|
||||||
|
throw new TypeError("device_edge_channel_runtime_core_identity_invalid");
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: value.schemaVersion,
|
||||||
|
edgeRegistrationId: normalizeRef(value.edgeRegistrationId, "edge_registration"),
|
||||||
|
channelGeneration: normalizeRef(value.channelGeneration, "channel_generation"),
|
||||||
|
trustGeneration: normalizeRef(value.trustGeneration, "trust_generation"),
|
||||||
|
allowedCoreFingerprints: Object.freeze(fingerprints.map((fingerprint) => {
|
||||||
|
if (typeof fingerprint !== "string" || !/^([A-F0-9]{2}:){31}[A-F0-9]{2}$/.test(fingerprint)) {
|
||||||
|
throw new TypeError("device_edge_channel_runtime_core_identity_invalid");
|
||||||
|
}
|
||||||
|
return fingerprint;
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHealthServer(channel, healthConfig) {
|
||||||
|
return createServer((request, response) => {
|
||||||
|
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||||
|
response.setHeader("Cache-Control", "no-store");
|
||||||
|
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
|
if (request.method !== "GET" || request.url !== "/healthz") {
|
||||||
|
response.statusCode = 404;
|
||||||
|
response.end(JSON.stringify({ ok: false, error: "not_found" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const status = channel.status();
|
||||||
|
response.statusCode = 200;
|
||||||
|
response.end(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
service: "nodedc-device-edge-channel",
|
||||||
|
health: `${healthConfig.host}:${healthConfig.port}`,
|
||||||
|
...status,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBoundedRegularFile(path, maximumBytes, encoding = null) {
|
||||||
|
const state = await lstat(path);
|
||||||
|
if (!state.isFile() || state.isSymbolicLink() || state.size < 1 || state.size > maximumBytes) {
|
||||||
|
throw new Error("device_edge_channel_runtime_file_invalid");
|
||||||
|
}
|
||||||
|
return readFile(path, encoding ?? undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredPath(value, code) {
|
||||||
|
if (typeof value !== "string" || value.trim() === "" || !value.startsWith("/")) {
|
||||||
|
throw new Error(code);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRef(value, field) {
|
||||||
|
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) {
|
||||||
|
throw new TypeError(`device_edge_channel_runtime_${field}_invalid`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHost(value) {
|
||||||
|
if (typeof value !== "string" || value.length < 1 || value.length > 253) {
|
||||||
|
throw new TypeError("device_edge_channel_runtime_host_invalid");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePort(value, fallback) {
|
||||||
|
const number = Number(value ?? fallback);
|
||||||
|
if (!Number.isSafeInteger(number) || number < 1 || number > 65_535) {
|
||||||
|
throw new TypeError("device_edge_channel_runtime_port_invalid");
|
||||||
|
}
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listen(server, port, host) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(port, host, () => {
|
||||||
|
server.off("error", reject);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeServer(server) {
|
||||||
|
return new Promise((resolve) => server.close(() => resolve()));
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
import { normalizeRuntimeDocument } from "../src/server.mjs";
|
||||||
|
|
||||||
|
const fingerprint = Array.from({ length: 32 }, () => "AB").join(":");
|
||||||
|
|
||||||
|
test("runtime document accepts one generation-bound Core identity", () => {
|
||||||
|
const result = normalizeRuntimeDocument({
|
||||||
|
schemaVersion: "nodedc.device-edge.channel-runtime.v1",
|
||||||
|
edgeRegistrationId: "edge:moscow-vps-1",
|
||||||
|
channelGeneration: "channel:1",
|
||||||
|
trustGeneration: "trust:1",
|
||||||
|
allowedCoreFingerprints: [fingerprint],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.edgeRegistrationId, "edge:moscow-vps-1");
|
||||||
|
assert.deepEqual(result.allowedCoreFingerprints, [fingerprint]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runtime document rejects hidden authority and missing identity", () => {
|
||||||
|
assert.throws(() => normalizeRuntimeDocument({
|
||||||
|
schemaVersion: "nodedc.device-edge.channel-runtime.v1",
|
||||||
|
edgeRegistrationId: "edge:moscow-vps-1",
|
||||||
|
channelGeneration: "channel:1",
|
||||||
|
trustGeneration: "trust:1",
|
||||||
|
allowedCoreFingerprints: [fingerprint],
|
||||||
|
endpoint: "https://attacker.invalid",
|
||||||
|
}), /runtime_config_key_invalid/);
|
||||||
|
|
||||||
|
assert.throws(() => normalizeRuntimeDocument({
|
||||||
|
schemaVersion: "nodedc.device-edge.channel-runtime.v1",
|
||||||
|
edgeRegistrationId: "edge:moscow-vps-1",
|
||||||
|
channelGeneration: "channel:1",
|
||||||
|
trustGeneration: "trust:1",
|
||||||
|
allowedCoreFingerprints: [],
|
||||||
|
}), /runtime_core_identity_invalid/);
|
||||||
|
|
||||||
|
assert.throws(() => normalizeRuntimeDocument({
|
||||||
|
schemaVersion: "nodedc.device-edge.channel-runtime.v1",
|
||||||
|
edgeRegistrationId: "edge:moscow-vps-1",
|
||||||
|
channelGeneration: "channel:1",
|
||||||
|
trustGeneration: "trust:1",
|
||||||
|
allowedCoreFingerprints: [fingerprint, fingerprint],
|
||||||
|
}), /runtime_core_identity_invalid/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/sbin/nft -f
|
||||||
|
|
||||||
|
flush ruleset
|
||||||
|
|
||||||
|
table inet nodedc_b2_vps {
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority -10; policy drop;
|
||||||
|
|
||||||
|
iifname "lo" accept
|
||||||
|
ct state invalid drop
|
||||||
|
ct state established,related accept
|
||||||
|
ip protocol icmp accept
|
||||||
|
ip6 nexthdr ipv6-icmp accept
|
||||||
|
|
||||||
|
tcp dport 22 ct state new limit rate 30/minute burst 60 packets accept
|
||||||
|
tcp dport 8443 ct state new limit rate 120/minute burst 120 packets accept
|
||||||
|
}
|
||||||
|
|
||||||
|
chain forward {
|
||||||
|
type filter hook forward priority -10; policy drop;
|
||||||
|
}
|
||||||
|
|
||||||
|
chain output {
|
||||||
|
type filter hook output priority -10; policy accept;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=NODE.DC provider-neutral Device Edge Core channel
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=nodedc-channel
|
||||||
|
Group=nodedc-channel
|
||||||
|
ExecStart=/opt/nodedc-b2-vps/runtime/node/bin/node --jitless /opt/nodedc-b2-vps/services/device-edge-channel/src/server.mjs
|
||||||
|
Environment=DEVICE_EDGE_CHANNEL_HOST=0.0.0.0
|
||||||
|
Environment=DEVICE_EDGE_CHANNEL_PORT=8443
|
||||||
|
Environment=DEVICE_EDGE_CHANNEL_HEALTH_HOST=127.0.0.1
|
||||||
|
Environment=DEVICE_EDGE_CHANNEL_HEALTH_PORT=18222
|
||||||
|
Environment=DEVICE_EDGE_CHANNEL_CONFIG_FILE=/var/lib/nodedc-b2-vps/channel-trust/runtime.json
|
||||||
|
Environment=DEVICE_EDGE_CHANNEL_KEY_FILE=/var/lib/nodedc-b2-vps/channel-trust/edge-private-key.pem
|
||||||
|
Environment=DEVICE_EDGE_CHANNEL_CERTIFICATE_FILE=/var/lib/nodedc-b2-vps/channel-trust/edge-certificate.pem
|
||||||
|
Environment=DEVICE_EDGE_CHANNEL_CORE_TRUST_FILE=/var/lib/nodedc-b2-vps/channel-trust/core-certificate.pem
|
||||||
|
Restart=always
|
||||||
|
RestartSec=2
|
||||||
|
TimeoutStartSec=20
|
||||||
|
TimeoutStopSec=15
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
PrivateDevices=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectKernelLogs=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
ProtectClock=yes
|
||||||
|
ProtectHostname=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
RestrictRealtime=yes
|
||||||
|
LockPersonality=yes
|
||||||
|
MemoryDenyWriteExecute=yes
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
RestrictAddressFamilies=AF_INET AF_INET6
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
AmbientCapabilities=
|
||||||
|
UMask=0077
|
||||||
|
MemoryMax=128M
|
||||||
|
MemorySwapMax=0
|
||||||
|
CPUQuota=50%
|
||||||
|
TasksMax=64
|
||||||
|
LimitNOFILE=1024
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -29,11 +29,11 @@ const runtimeCache = resolve(
|
|||||||
const [phase, patchId, ...extra] = process.argv.slice(2);
|
const [phase, patchId, ...extra] = process.argv.slice(2);
|
||||||
if (
|
if (
|
||||||
extra.length
|
extra.length
|
||||||
|| !["foundation", "backhaul", "relay"].includes(phase)
|
|| !["foundation", "backhaul", "relay", "core-channel"].includes(phase)
|
||||||
|| !/^[A-Za-z0-9._-]{1,96}$/.test(patchId || "")
|
|| !/^[A-Za-z0-9._-]{1,96}$/.test(patchId || "")
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"usage: build-device-edge-vps-artifact.mjs <foundation|backhaul|relay> <patch-id>",
|
"usage: build-device-edge-vps-artifact.mjs <foundation|backhaul|relay|core-channel> <patch-id>",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +72,17 @@ const entriesByPhase = {
|
|||||||
"services/device-edge-relay/src",
|
"services/device-edge-relay/src",
|
||||||
"deployment/device-edge-vps-relay-v1.json",
|
"deployment/device-edge-vps-relay-v1.json",
|
||||||
],
|
],
|
||||||
|
"core-channel": [
|
||||||
|
"packages/device-protocol-contract/package.json",
|
||||||
|
"packages/device-protocol-contract/src",
|
||||||
|
"packages/device-edge-channel-contract/package.json",
|
||||||
|
"packages/device-edge-channel-contract/src",
|
||||||
|
"services/device-edge-channel/package.json",
|
||||||
|
"services/device-edge-channel/src",
|
||||||
|
"vps/config/nftables-core-channel.conf",
|
||||||
|
"vps/systemd/nodedc-device-edge-channel.service",
|
||||||
|
"deployment/device-edge-vps-core-channel-v1.json",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
const entries = entriesByPhase[phase];
|
const entries = entriesByPhase[phase];
|
||||||
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
|
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
|
||||||
@@ -129,7 +140,11 @@ try {
|
|||||||
size: bytes.length,
|
size: bytes.length,
|
||||||
component: "device-edge-vps",
|
component: "device-edge-vps",
|
||||||
entries,
|
entries,
|
||||||
publicIngress: phase === "relay" ? "tcp/9921" : "disabled",
|
publicIngress: phase === "relay"
|
||||||
|
? "tcp/9921"
|
||||||
|
: phase === "core-channel"
|
||||||
|
? "tcp/8443-mtls-only"
|
||||||
|
: "disabled",
|
||||||
commandTransport: "disabled",
|
commandTransport: "disabled",
|
||||||
gelios: "untouched",
|
gelios: "untouched",
|
||||||
}, null, 2));
|
}, null, 2));
|
||||||
@@ -221,6 +236,34 @@ async function assertBoundary() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (phase === "core-channel") {
|
||||||
|
for (const required of [
|
||||||
|
"\"runtimeUser\": \"nodedc-channel\"",
|
||||||
|
"\"trackerIngress\": \"disabled\"",
|
||||||
|
"User=nodedc-channel",
|
||||||
|
"node --jitless",
|
||||||
|
"tcp dport 8443",
|
||||||
|
"MemoryMax=128M",
|
||||||
|
"MemorySwapMax=0",
|
||||||
|
"CPUQuota=50%",
|
||||||
|
"TasksMax=64",
|
||||||
|
"LimitNOFILE=1024",
|
||||||
|
]) {
|
||||||
|
if (!combined.includes(required)) {
|
||||||
|
throw new Error(`core_channel_boundary_missing:${required}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const forbidden of [
|
||||||
|
"tcp dport 9921",
|
||||||
|
"LocalForward",
|
||||||
|
"tailscale-userspace",
|
||||||
|
"DEVICE_EDGE_RELAY_UPSTREAM",
|
||||||
|
]) {
|
||||||
|
if (combined.includes(forbidden)) {
|
||||||
|
throw new Error(`core_channel_boundary_violation:${forbidden}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function canonicalTarScript() {
|
function canonicalTarScript() {
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ BACKHAUL_USER = "nodedc-backhaul"
|
|||||||
BACKHAUL_GROUP = "nodedc-backhaul"
|
BACKHAUL_GROUP = "nodedc-backhaul"
|
||||||
RELAY_USER = "nodedc-relay"
|
RELAY_USER = "nodedc-relay"
|
||||||
RELAY_GROUP = "nodedc-relay"
|
RELAY_GROUP = "nodedc-relay"
|
||||||
|
CHANNEL_USER = "nodedc-channel"
|
||||||
|
CHANNEL_GROUP = "nodedc-channel"
|
||||||
TAILSCALE_REQUIRED_TAG = "tag:device-edge-vps"
|
TAILSCALE_REQUIRED_TAG = "tag:device-edge-vps"
|
||||||
MANAGEMENT_KEY_FINGERPRINT = (
|
MANAGEMENT_KEY_FINGERPRINT = (
|
||||||
"SHA256:DYYy1E3DaxIQGC0jnsW6SP7gXdBHUy3A1zn4pvgVUEw"
|
"SHA256:DYYy1E3DaxIQGC0jnsW6SP7gXdBHUy3A1zn4pvgVUEw"
|
||||||
@@ -89,6 +91,14 @@ NFTABLES_CONFIG = Path("/etc/nftables.conf")
|
|||||||
TAILSCALE_UNIT = Path("/etc/systemd/system/nodedc-b2-tailscaled.service")
|
TAILSCALE_UNIT = Path("/etc/systemd/system/nodedc-b2-tailscaled.service")
|
||||||
BACKHAUL_UNIT = Path("/etc/systemd/system/nodedc-b2-backhaul.service")
|
BACKHAUL_UNIT = Path("/etc/systemd/system/nodedc-b2-backhaul.service")
|
||||||
RELAY_UNIT = Path("/etc/systemd/system/nodedc-b2-relay.service")
|
RELAY_UNIT = Path("/etc/systemd/system/nodedc-b2-relay.service")
|
||||||
|
CHANNEL_UNIT = Path("/etc/systemd/system/nodedc-device-edge-channel.service")
|
||||||
|
CHANNEL_TRUST_ROOT = Path("/var/lib/nodedc-b2-vps/channel-trust")
|
||||||
|
CHANNEL_PRIVATE_KEY = CHANNEL_TRUST_ROOT / "edge-private-key.pem"
|
||||||
|
CHANNEL_CERTIFICATE = CHANNEL_TRUST_ROOT / "edge-certificate.pem"
|
||||||
|
CHANNEL_CORE_CERTIFICATE = CHANNEL_TRUST_ROOT / "core-certificate.pem"
|
||||||
|
CHANNEL_RUNTIME_CONFIG = CHANNEL_TRUST_ROOT / "runtime.json"
|
||||||
|
CHANNEL_HEALTH_PORT = 18222
|
||||||
|
CHANNEL_PUBLIC_PORT = 8443
|
||||||
|
|
||||||
FOUNDATION_ENTRIES = (
|
FOUNDATION_ENTRIES = (
|
||||||
"vps/config/00-nodedc-b2-vps.conf",
|
"vps/config/00-nodedc-b2-vps.conf",
|
||||||
@@ -109,11 +119,23 @@ RELAY_ENTRIES = (
|
|||||||
"services/device-edge-relay/src",
|
"services/device-edge-relay/src",
|
||||||
"deployment/device-edge-vps-relay-v1.json",
|
"deployment/device-edge-vps-relay-v1.json",
|
||||||
)
|
)
|
||||||
|
CORE_CHANNEL_ENTRIES = (
|
||||||
|
"packages/device-protocol-contract/package.json",
|
||||||
|
"packages/device-protocol-contract/src",
|
||||||
|
"packages/device-edge-channel-contract/package.json",
|
||||||
|
"packages/device-edge-channel-contract/src",
|
||||||
|
"services/device-edge-channel/package.json",
|
||||||
|
"services/device-edge-channel/src",
|
||||||
|
"vps/config/nftables-core-channel.conf",
|
||||||
|
"vps/systemd/nodedc-device-edge-channel.service",
|
||||||
|
"deployment/device-edge-vps-core-channel-v1.json",
|
||||||
|
)
|
||||||
|
|
||||||
PHASE_ENTRIES = {
|
PHASE_ENTRIES = {
|
||||||
"foundation": FOUNDATION_ENTRIES,
|
"foundation": FOUNDATION_ENTRIES,
|
||||||
"backhaul": BACKHAUL_ENTRIES,
|
"backhaul": BACKHAUL_ENTRIES,
|
||||||
"relay": RELAY_ENTRIES,
|
"relay": RELAY_ENTRIES,
|
||||||
|
"core-channel": CORE_CHANNEL_ENTRIES,
|
||||||
}
|
}
|
||||||
|
|
||||||
SUPERSEDED_TRANSPORT_PHASES = frozenset({"backhaul", "relay"})
|
SUPERSEDED_TRANSPORT_PHASES = frozenset({"backhaul", "relay"})
|
||||||
@@ -121,19 +143,19 @@ SUPERSEDED_TRANSPORT_PHASES = frozenset({"backhaul", "relay"})
|
|||||||
PHASE_FILE_SHA256 = {
|
PHASE_FILE_SHA256 = {
|
||||||
"foundation": {
|
"foundation": {
|
||||||
"vps/config/00-nodedc-b2-vps.conf":
|
"vps/config/00-nodedc-b2-vps.conf":
|
||||||
"cc94d0579f85d0af9746b9ce760bc72980f4a22fb59027e1f5f9c7bf3aaebd64",
|
"2079748f48b2297ecb067e16ae46248e3a981b26331aa0998dc14fdf7454cd4a",
|
||||||
"vps/config/nftables-foundation.conf":
|
"vps/config/nftables-foundation.conf":
|
||||||
"4d44f902d8d98d1aa8506fca9d9582e700f6424def2b1d667ab2cd5a5ee84934",
|
"bce5b8c6e2226d47d8d322f8ee9e7158f2d7a553c5e2c4bc2a722d6067f46e28",
|
||||||
"vps/systemd/nodedc-b2-tailscaled.service":
|
"vps/systemd/nodedc-b2-tailscaled.service":
|
||||||
"de147d29bc1759f56533d31058993df55e1200973f2899903bdbf3d13ff579da",
|
"de147d29bc1759f56533d31058993df55e1200973f2899903bdbf3d13ff579da",
|
||||||
"deployment/device-edge-vps-foundation-v1.json":
|
"deployment/device-edge-vps-foundation-v1.json":
|
||||||
"317c98b42520fff3238275908482de7aa611b4ee41c6b1f8062abd2730ab072a",
|
"5ae007195b17d6cfaeb564abbed6dc9234eb36bf62db23bf2b9ace539c398898",
|
||||||
f"vendor/{NODE_ARCHIVE}": NODE_ARCHIVE_SHA256,
|
f"vendor/{NODE_ARCHIVE}": NODE_ARCHIVE_SHA256,
|
||||||
f"vendor/{TAILSCALE_ARCHIVE}": TAILSCALE_ARCHIVE_SHA256,
|
f"vendor/{TAILSCALE_ARCHIVE}": TAILSCALE_ARCHIVE_SHA256,
|
||||||
},
|
},
|
||||||
"backhaul": {
|
"backhaul": {
|
||||||
"vps/config/backhaul_ssh_config":
|
"vps/config/backhaul_ssh_config":
|
||||||
"ff1a3575b5a55a56b08a8642aa820d1f8e5f07e0e7698fdd0e01a142452674d4",
|
"d0df8b70dda025b7c1c3fecd2dafffe60a5bb753650d3bc37db65db626cfc1af",
|
||||||
"vps/systemd/nodedc-b2-backhaul.service":
|
"vps/systemd/nodedc-b2-backhaul.service":
|
||||||
"64c26cad21cc17675c67ae4a57fc43b129065a8d22fda894648340b310d3aa8c",
|
"64c26cad21cc17675c67ae4a57fc43b129065a8d22fda894648340b310d3aa8c",
|
||||||
"deployment/device-edge-vps-backhaul-v1.json":
|
"deployment/device-edge-vps-backhaul-v1.json":
|
||||||
@@ -141,7 +163,7 @@ PHASE_FILE_SHA256 = {
|
|||||||
},
|
},
|
||||||
"relay": {
|
"relay": {
|
||||||
"vps/config/nftables-relay.conf":
|
"vps/config/nftables-relay.conf":
|
||||||
"d99290bf825a3aff2ad8f9dbc6502ba6c32295f0cabd3f71c3d9b94c06373259",
|
"497485a2fb1b79faa95eb67ca30a89a3b65c3d4d8ddcb14cd1dc9acfeb2bb6f2",
|
||||||
"vps/systemd/nodedc-b2-relay.service":
|
"vps/systemd/nodedc-b2-relay.service":
|
||||||
"12a927a4cb42016229ac438f6e75969e1bf015037f2b5b0a60ab0591bdf424d2",
|
"12a927a4cb42016229ac438f6e75969e1bf015037f2b5b0a60ab0591bdf424d2",
|
||||||
"services/device-edge-relay/src/runtime.mjs":
|
"services/device-edge-relay/src/runtime.mjs":
|
||||||
@@ -151,6 +173,28 @@ PHASE_FILE_SHA256 = {
|
|||||||
"deployment/device-edge-vps-relay-v1.json":
|
"deployment/device-edge-vps-relay-v1.json":
|
||||||
"cb3c2fff4878021783efef4f4a4d1ec31c8e3d8e6325fe33657fec22bec20165",
|
"cb3c2fff4878021783efef4f4a4d1ec31c8e3d8e6325fe33657fec22bec20165",
|
||||||
},
|
},
|
||||||
|
"core-channel": {
|
||||||
|
"packages/device-protocol-contract/package.json":
|
||||||
|
"19d0d07da0341e8c2e8d3485400767566b18f6270245f0e9a9681c95013ba3b7",
|
||||||
|
"packages/device-protocol-contract/src/index.mjs":
|
||||||
|
"21a8b2b85a807899f946387c7976eaffcdc438b7db5e3d41fc3930f4437f0ec7",
|
||||||
|
"packages/device-edge-channel-contract/package.json":
|
||||||
|
"57d5349b5dcef2cacd4f3e4fad010359a65d59f5f903eff07d89f67c497f97c0",
|
||||||
|
"packages/device-edge-channel-contract/src/index.mjs":
|
||||||
|
"09d45e6104779212605fed650544794aafe1c2b1ab66f33f07bef4d92f430c80",
|
||||||
|
"services/device-edge-channel/package.json":
|
||||||
|
"bdf502be43b62bdd6db05b022a532d93ba954277ac5143d6058d2f27f6a2e9d2",
|
||||||
|
"services/device-edge-channel/src/runtime.mjs":
|
||||||
|
"cbb07f7e644a68e9c8c36c1c2ecf0224c06c46c08c49339d62637b10a1495501",
|
||||||
|
"services/device-edge-channel/src/server.mjs":
|
||||||
|
"ea891634a18efb9eb44f17b56c95ba97527215c4d0a6147cc2b7bad1d7356e36",
|
||||||
|
"vps/config/nftables-core-channel.conf":
|
||||||
|
"1a04a5450042e80b8a20da3c6634dd6bc68693f191a463ba9a62984279d81d0c",
|
||||||
|
"vps/systemd/nodedc-device-edge-channel.service":
|
||||||
|
"57c6c0c5eb952e1e196f50c410a8537468af145941b21dd4aad6ff0e8ca56249",
|
||||||
|
"deployment/device-edge-vps-core-channel-v1.json":
|
||||||
|
"938f6f7959b78e6be3a6e91a54dca2922fbd813f1a33dca5cebe0dc256a83a14",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -452,7 +496,7 @@ def current_phase_preflight(phase: str):
|
|||||||
die("VPS foundation live/runtime root already exists")
|
die("VPS foundation live/runtime root already exists")
|
||||||
if any(path.exists() for path in (SSHD_DROPIN, TAILSCALE_UNIT, BACKHAUL_UNIT, RELAY_UNIT)):
|
if any(path.exists() for path in (SSHD_DROPIN, TAILSCALE_UNIT, BACKHAUL_UNIT, RELAY_UNIT)):
|
||||||
die("VPS foundation system path already exists")
|
die("VPS foundation system path already exists")
|
||||||
for port in (1055, 18221, 19921, 9921):
|
for port in (1055, 18221, CHANNEL_HEALTH_PORT, 19921, 9921, CHANNEL_PUBLIC_PORT):
|
||||||
assert_port_closed(port)
|
assert_port_closed(port)
|
||||||
return {"predecessor": "clean-ubuntu-24.04.4"}
|
return {"predecessor": "clean-ubuntu-24.04.4"}
|
||||||
|
|
||||||
@@ -462,6 +506,19 @@ def current_phase_preflight(phase: str):
|
|||||||
require_running_tailnet=phase in {"backhaul", "relay"},
|
require_running_tailnet=phase in {"backhaul", "relay"},
|
||||||
expected_key_user=BACKHAUL_USER if phase == "relay" else SERVICE_USER,
|
expected_key_user=BACKHAUL_USER if phase == "relay" else SERVICE_USER,
|
||||||
)
|
)
|
||||||
|
if phase == "core-channel":
|
||||||
|
if service_active("nodedc-b2-backhaul.service"):
|
||||||
|
die("frozen VPS backhaul service must remain inactive")
|
||||||
|
if service_active("nodedc-b2-relay.service"):
|
||||||
|
die("frozen VPS relay service must remain inactive")
|
||||||
|
if CHANNEL_UNIT.exists() or (LIVE_ROOT / CORE_CHANNEL_ENTRIES[-1]).exists():
|
||||||
|
die("VPS Core channel target path already exists")
|
||||||
|
if user_exists(CHANNEL_USER):
|
||||||
|
die("VPS Core channel runtime user already exists")
|
||||||
|
assert_channel_trust(require_runtime_owner=False)
|
||||||
|
for port in (CHANNEL_HEALTH_PORT, CHANNEL_PUBLIC_PORT, 9921):
|
||||||
|
assert_port_closed(port)
|
||||||
|
return {"predecessor": "accepted-foundation-closed-channel"}
|
||||||
if phase == "backhaul":
|
if phase == "backhaul":
|
||||||
for tool in (Path("/usr/bin/ssh"), Path("/usr/bin/nc")):
|
for tool in (Path("/usr/bin/ssh"), Path("/usr/bin/nc")):
|
||||||
assert_executable_command_path(
|
assert_executable_command_path(
|
||||||
@@ -529,6 +586,8 @@ def backup_targets_for_phase(phase: str):
|
|||||||
return common + [SSHD_DROPIN, NFTABLES_CONFIG, TAILSCALE_UNIT]
|
return common + [SSHD_DROPIN, NFTABLES_CONFIG, TAILSCALE_UNIT]
|
||||||
if phase == "backhaul":
|
if phase == "backhaul":
|
||||||
return common + [BACKHAUL_UNIT, BACKHAUL_KNOWN_HOSTS]
|
return common + [BACKHAUL_UNIT, BACKHAUL_KNOWN_HOSTS]
|
||||||
|
if phase == "core-channel":
|
||||||
|
return common + [CHANNEL_UNIT, NFTABLES_CONFIG, CHANNEL_TRUST_ROOT]
|
||||||
return common + [RELAY_UNIT, NFTABLES_CONFIG]
|
return common + [RELAY_UNIT, NFTABLES_CONFIG]
|
||||||
|
|
||||||
|
|
||||||
@@ -568,7 +627,7 @@ def create_backup(patch_id: str, phase: str):
|
|||||||
"serviceUserExisted": user_exists(),
|
"serviceUserExisted": user_exists(),
|
||||||
"serviceUsersExisted": {
|
"serviceUsersExisted": {
|
||||||
name: user_exists(name)
|
name: user_exists(name)
|
||||||
for name in (SERVICE_USER, BACKHAUL_USER, RELAY_USER)
|
for name in (SERVICE_USER, BACKHAUL_USER, RELAY_USER, CHANNEL_USER)
|
||||||
},
|
},
|
||||||
"services": {
|
"services": {
|
||||||
name: {
|
name: {
|
||||||
@@ -582,6 +641,7 @@ def create_backup(patch_id: str, phase: str):
|
|||||||
"nodedc-b2-tailscaled.service",
|
"nodedc-b2-tailscaled.service",
|
||||||
"nodedc-b2-backhaul.service",
|
"nodedc-b2-backhaul.service",
|
||||||
"nodedc-b2-relay.service",
|
"nodedc-b2-relay.service",
|
||||||
|
"nodedc-device-edge-channel.service",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -740,6 +800,146 @@ def assign_backhaul_trust(account):
|
|||||||
os.chmod(BACKHAUL_KNOWN_HOSTS, 0o444)
|
os.chmod(BACKHAUL_KNOWN_HOSTS, 0o444)
|
||||||
|
|
||||||
|
|
||||||
|
def certificate_fingerprint(path: Path):
|
||||||
|
output = run([
|
||||||
|
"/usr/bin/openssl",
|
||||||
|
"x509",
|
||||||
|
"-in",
|
||||||
|
str(path),
|
||||||
|
"-noout",
|
||||||
|
"-fingerprint",
|
||||||
|
"-sha256",
|
||||||
|
]).stdout.strip()
|
||||||
|
prefix = "sha256 Fingerprint="
|
||||||
|
if not output.lower().startswith(prefix.lower()):
|
||||||
|
die("VPS certificate fingerprint output is invalid")
|
||||||
|
fingerprint = output.split("=", 1)[1].upper()
|
||||||
|
if not re.fullmatch(r"(?:[A-F0-9]{2}:){31}[A-F0-9]{2}", fingerprint):
|
||||||
|
die("VPS certificate fingerprint is invalid")
|
||||||
|
return fingerprint
|
||||||
|
|
||||||
|
|
||||||
|
def assert_channel_trust(*, require_runtime_owner: bool):
|
||||||
|
directory = assert_directory_nonsymlink(
|
||||||
|
CHANNEL_TRUST_ROOT,
|
||||||
|
"Core channel trust root",
|
||||||
|
)
|
||||||
|
expected_uid = 0
|
||||||
|
expected_gid = 0
|
||||||
|
if require_runtime_owner:
|
||||||
|
account = pwd.getpwnam(CHANNEL_USER)
|
||||||
|
expected_uid = account.pw_uid
|
||||||
|
expected_gid = account.pw_gid
|
||||||
|
if (
|
||||||
|
directory.st_uid != expected_uid
|
||||||
|
or directory.st_gid != expected_gid
|
||||||
|
or (directory.st_mode & 0o777) != 0o700
|
||||||
|
):
|
||||||
|
die("Core channel trust root ownership/mode mismatch")
|
||||||
|
|
||||||
|
for path, maximum, mode in (
|
||||||
|
(CHANNEL_PRIVATE_KEY, 32 * 1024, 0o400),
|
||||||
|
(CHANNEL_CERTIFICATE, 32 * 1024, 0o444),
|
||||||
|
(CHANNEL_CORE_CERTIFICATE, 32 * 1024, 0o444),
|
||||||
|
(CHANNEL_RUNTIME_CONFIG, 32 * 1024, 0o444),
|
||||||
|
):
|
||||||
|
state = assert_regular_nonsymlink(path, f"Core channel trust {path.name}")
|
||||||
|
if state.st_size < 1 or state.st_size > maximum:
|
||||||
|
die(f"Core channel trust file size mismatch: {path.name}")
|
||||||
|
if (
|
||||||
|
state.st_uid != expected_uid
|
||||||
|
or state.st_gid != expected_gid
|
||||||
|
or (state.st_mode & 0o777) != mode
|
||||||
|
):
|
||||||
|
die(f"Core channel trust file ownership/mode mismatch: {path.name}")
|
||||||
|
|
||||||
|
private_text = CHANNEL_PRIVATE_KEY.read_text(encoding="ascii")
|
||||||
|
public_text = (
|
||||||
|
CHANNEL_CERTIFICATE.read_text(encoding="ascii")
|
||||||
|
+ CHANNEL_CORE_CERTIFICATE.read_text(encoding="ascii")
|
||||||
|
)
|
||||||
|
if "PRIVATE KEY" not in private_text or "PRIVATE KEY" in public_text:
|
||||||
|
die("Core channel private/public trust boundary mismatch")
|
||||||
|
for path in (
|
||||||
|
CHANNEL_CERTIFICATE,
|
||||||
|
CHANNEL_CORE_CERTIFICATE,
|
||||||
|
):
|
||||||
|
if path.read_text(encoding="ascii").count("-----BEGIN CERTIFICATE-----") != 1:
|
||||||
|
die(f"Core channel certificate cardinality mismatch: {path.name}")
|
||||||
|
|
||||||
|
run(["/usr/bin/openssl", "pkey", "-in", str(CHANNEL_PRIVATE_KEY), "-check", "-noout"])
|
||||||
|
run(["/usr/bin/openssl", "x509", "-in", str(CHANNEL_CERTIFICATE), "-noout"])
|
||||||
|
run(["/usr/bin/openssl", "x509", "-in", str(CHANNEL_CORE_CERTIFICATE), "-noout"])
|
||||||
|
run([
|
||||||
|
"/usr/bin/openssl", "verify", "-purpose", "sslserver",
|
||||||
|
"-CAfile", str(CHANNEL_CERTIFICATE), str(CHANNEL_CERTIFICATE),
|
||||||
|
])
|
||||||
|
run([
|
||||||
|
"/usr/bin/openssl", "verify", "-purpose", "sslclient",
|
||||||
|
"-CAfile", str(CHANNEL_CORE_CERTIFICATE), str(CHANNEL_CORE_CERTIFICATE),
|
||||||
|
])
|
||||||
|
run([
|
||||||
|
"/usr/bin/openssl", "x509", "-in", str(CHANNEL_CERTIFICATE),
|
||||||
|
"-noout", "-checkip", PUBLIC_IPV4,
|
||||||
|
])
|
||||||
|
certificate_key = run([
|
||||||
|
"/usr/bin/openssl", "x509", "-in", str(CHANNEL_CERTIFICATE), "-pubkey", "-noout",
|
||||||
|
]).stdout.strip()
|
||||||
|
private_key = run([
|
||||||
|
"/usr/bin/openssl", "pkey", "-in", str(CHANNEL_PRIVATE_KEY), "-pubout",
|
||||||
|
]).stdout.strip()
|
||||||
|
if certificate_key != private_key:
|
||||||
|
die("Core channel Edge certificate/private key mismatch")
|
||||||
|
|
||||||
|
try:
|
||||||
|
document = json.loads(CHANNEL_RUNTIME_CONFIG.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
die("Core channel runtime configuration is invalid JSON")
|
||||||
|
if set(document) != {
|
||||||
|
"schemaVersion",
|
||||||
|
"edgeRegistrationId",
|
||||||
|
"channelGeneration",
|
||||||
|
"trustGeneration",
|
||||||
|
"allowedCoreFingerprints",
|
||||||
|
}:
|
||||||
|
die("Core channel runtime configuration key set mismatch")
|
||||||
|
if document.get("schemaVersion") != "nodedc.device-edge.channel-runtime.v1":
|
||||||
|
die("Core channel runtime configuration schema mismatch")
|
||||||
|
for key in ("edgeRegistrationId", "channelGeneration", "trustGeneration"):
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", str(document.get(key, ""))):
|
||||||
|
die(f"Core channel runtime configuration ref mismatch: {key}")
|
||||||
|
fingerprints = document.get("allowedCoreFingerprints")
|
||||||
|
if (
|
||||||
|
not isinstance(fingerprints, list)
|
||||||
|
or not 1 <= len(fingerprints) <= 2
|
||||||
|
or len(fingerprints) != len(set(fingerprints))
|
||||||
|
or any(
|
||||||
|
not isinstance(value, str)
|
||||||
|
or not re.fullmatch(r"(?:[A-F0-9]{2}:){31}[A-F0-9]{2}", value)
|
||||||
|
for value in fingerprints
|
||||||
|
)
|
||||||
|
):
|
||||||
|
die("Core channel Core identity allowlist mismatch")
|
||||||
|
if certificate_fingerprint(CHANNEL_CORE_CERTIFICATE) not in fingerprints:
|
||||||
|
die("Core channel Core trust fingerprint mismatch")
|
||||||
|
return document
|
||||||
|
|
||||||
|
|
||||||
|
def assign_channel_trust(account):
|
||||||
|
assert_channel_trust(require_runtime_owner=False)
|
||||||
|
os.chown(CHANNEL_TRUST_ROOT, account.pw_uid, account.pw_gid)
|
||||||
|
os.chmod(CHANNEL_TRUST_ROOT, 0o700)
|
||||||
|
for path, mode in (
|
||||||
|
(CHANNEL_PRIVATE_KEY, 0o400),
|
||||||
|
(CHANNEL_CERTIFICATE, 0o444),
|
||||||
|
(CHANNEL_CORE_CERTIFICATE, 0o444),
|
||||||
|
(CHANNEL_RUNTIME_CONFIG, 0o444),
|
||||||
|
):
|
||||||
|
os.chown(path, account.pw_uid, account.pw_gid)
|
||||||
|
os.chmod(path, mode)
|
||||||
|
return assert_channel_trust(require_runtime_owner=True)
|
||||||
|
|
||||||
|
|
||||||
def apply_nftables(source: Path):
|
def apply_nftables(source: Path):
|
||||||
install_file(source, NFTABLES_CONFIG, 0o644)
|
install_file(source, NFTABLES_CONFIG, 0o644)
|
||||||
run(["/usr/sbin/nft", "-c", "-f", str(NFTABLES_CONFIG)])
|
run(["/usr/sbin/nft", "-c", "-f", str(NFTABLES_CONFIG)])
|
||||||
@@ -813,6 +1013,19 @@ def apply_relay(payload: Path):
|
|||||||
validate_relay_runtime()
|
validate_relay_runtime()
|
||||||
|
|
||||||
|
|
||||||
|
def apply_core_channel(payload: Path):
|
||||||
|
account = ensure_service_user(
|
||||||
|
CHANNEL_USER,
|
||||||
|
"/var/lib/nodedc-b2-vps/channel-runtime",
|
||||||
|
)
|
||||||
|
assign_channel_trust(account)
|
||||||
|
install_file(LIVE_ROOT / CORE_CHANNEL_ENTRIES[7], CHANNEL_UNIT, 0o644)
|
||||||
|
apply_nftables(LIVE_ROOT / CORE_CHANNEL_ENTRIES[6])
|
||||||
|
systemctl("daemon-reload")
|
||||||
|
systemctl("enable", "--now", "nodedc-device-edge-channel.service")
|
||||||
|
validate_core_channel_runtime()
|
||||||
|
|
||||||
|
|
||||||
def sshd_effective():
|
def sshd_effective():
|
||||||
return run(["/usr/sbin/sshd", "-T"]).stdout.lower()
|
return run(["/usr/sbin/sshd", "-T"]).stdout.lower()
|
||||||
|
|
||||||
@@ -919,6 +1132,85 @@ def relay_health():
|
|||||||
die(f"VPS relay health timeout: {last_error}")
|
die(f"VPS relay health timeout: {last_error}")
|
||||||
|
|
||||||
|
|
||||||
|
def core_channel_health(*, require_accepted: bool):
|
||||||
|
last_error = None
|
||||||
|
for _attempt in range(60):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
f"http://127.0.0.1:{CHANNEL_HEALTH_PORT}/healthz",
|
||||||
|
timeout=3,
|
||||||
|
) as response:
|
||||||
|
payload = json.loads(response.read(65537).decode("utf-8"))
|
||||||
|
if (
|
||||||
|
response.status == 200
|
||||||
|
and payload.get("ok") is True
|
||||||
|
and (
|
||||||
|
not require_accepted
|
||||||
|
or payload.get("channel") == "accepted"
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return payload
|
||||||
|
last_error = f"channel={payload.get('channel')}"
|
||||||
|
except Exception as error:
|
||||||
|
last_error = str(error)
|
||||||
|
time.sleep(2)
|
||||||
|
die(f"VPS Core channel health timeout: {last_error}")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_core_channel_runtime():
|
||||||
|
validate_foundation_runtime(
|
||||||
|
require_running_tailnet=False,
|
||||||
|
expected_key_user=SERVICE_USER,
|
||||||
|
)
|
||||||
|
source_file_state("core-channel")
|
||||||
|
assert_channel_trust(require_runtime_owner=True)
|
||||||
|
if service_active("nodedc-b2-backhaul.service"):
|
||||||
|
die("frozen VPS backhaul service became active")
|
||||||
|
if service_active("nodedc-b2-relay.service"):
|
||||||
|
die("frozen VPS relay service became active")
|
||||||
|
if not service_active("nodedc-device-edge-channel.service"):
|
||||||
|
die("VPS Core channel service is not active")
|
||||||
|
health = core_channel_health(require_accepted=True)
|
||||||
|
expected = {
|
||||||
|
"ok": True,
|
||||||
|
"service": "nodedc-device-edge-channel",
|
||||||
|
"channel": "accepted",
|
||||||
|
"trackerIngress": "disabled",
|
||||||
|
"commandTransport": "disabled",
|
||||||
|
}
|
||||||
|
for key, value in expected.items():
|
||||||
|
if health.get(key) != value:
|
||||||
|
die(f"VPS Core channel health contract mismatch: {key}")
|
||||||
|
if not port_is_open(PUBLIC_IPV4, CHANNEL_PUBLIC_PORT, timeout=5):
|
||||||
|
die("VPS public Core channel listener is unavailable")
|
||||||
|
assert_port_closed(9921)
|
||||||
|
nft = run(["/usr/sbin/nft", "list", "table", "inet", "nodedc_b2_vps"]).stdout
|
||||||
|
if (
|
||||||
|
"policy drop" not in nft
|
||||||
|
or "tcp dport 8443" not in nft
|
||||||
|
or "tcp dport 9921" in nft
|
||||||
|
):
|
||||||
|
die("VPS Core channel firewall contract mismatch")
|
||||||
|
unit = run([
|
||||||
|
"/usr/bin/systemctl",
|
||||||
|
"show",
|
||||||
|
"nodedc-device-edge-channel.service",
|
||||||
|
"--property=User,Group,NoNewPrivileges,MemoryMax,MemorySwapMax,CPUQuotaPerSecUSec,TasksMax,LimitNOFILE",
|
||||||
|
]).stdout
|
||||||
|
for required in (
|
||||||
|
"User=nodedc-channel",
|
||||||
|
"Group=nodedc-channel",
|
||||||
|
"NoNewPrivileges=yes",
|
||||||
|
"MemoryMax=134217728",
|
||||||
|
"MemorySwapMax=0",
|
||||||
|
"TasksMax=64",
|
||||||
|
"LimitNOFILE=1024",
|
||||||
|
):
|
||||||
|
if required not in unit:
|
||||||
|
die(f"VPS Core channel resource boundary mismatch: {required}")
|
||||||
|
return health
|
||||||
|
|
||||||
|
|
||||||
def validate_relay_runtime():
|
def validate_relay_runtime():
|
||||||
validate_backhaul_runtime()
|
validate_backhaul_runtime()
|
||||||
source_file_state("relay")
|
source_file_state("relay")
|
||||||
@@ -979,6 +1271,7 @@ def rollback(backup: Path, phase: str):
|
|||||||
for service in (
|
for service in (
|
||||||
"nodedc-b2-relay.service",
|
"nodedc-b2-relay.service",
|
||||||
"nodedc-b2-backhaul.service",
|
"nodedc-b2-backhaul.service",
|
||||||
|
"nodedc-device-edge-channel.service",
|
||||||
"nodedc-b2-tailscaled.service",
|
"nodedc-b2-tailscaled.service",
|
||||||
):
|
):
|
||||||
if phase == "foundation" or service != "nodedc-b2-tailscaled.service":
|
if phase == "foundation" or service != "nodedc-b2-tailscaled.service":
|
||||||
@@ -1004,6 +1297,9 @@ def rollback(backup: Path, phase: str):
|
|||||||
if phase == "relay":
|
if phase == "relay":
|
||||||
if not users_before.get(RELAY_USER, False) and user_exists(RELAY_USER):
|
if not users_before.get(RELAY_USER, False) and user_exists(RELAY_USER):
|
||||||
run(["/usr/sbin/userdel", RELAY_USER], check=False)
|
run(["/usr/sbin/userdel", RELAY_USER], check=False)
|
||||||
|
if phase == "core-channel":
|
||||||
|
if not users_before.get(CHANNEL_USER, False) and user_exists(CHANNEL_USER):
|
||||||
|
run(["/usr/sbin/userdel", CHANNEL_USER], check=False)
|
||||||
if phase == "foundation" and not metadata.get("serviceUserExisted"):
|
if phase == "foundation" and not metadata.get("serviceUserExisted"):
|
||||||
runtime_state_root = Path("/var/lib/nodedc-b2-vps")
|
runtime_state_root = Path("/var/lib/nodedc-b2-vps")
|
||||||
if LIVE_ROOT.exists() and not LIVE_ROOT.is_symlink():
|
if LIVE_ROOT.exists() and not LIVE_ROOT.is_symlink():
|
||||||
@@ -1052,13 +1348,21 @@ def plan_artifact(artifact_argument: str):
|
|||||||
print("public_b2_ingress=disabled")
|
print("public_b2_ingress=disabled")
|
||||||
print("services=nodedc-b2-backhaul")
|
print("services=nodedc-b2-backhaul")
|
||||||
print(f"backhaul_runtime_identity={BACKHAUL_USER}:private-key-owner")
|
print(f"backhaul_runtime_identity={BACKHAUL_USER}:private-key-owner")
|
||||||
else:
|
elif phase == "relay":
|
||||||
print("public_b2_ingress=155.212.211.15:9921/tcp")
|
print("public_b2_ingress=155.212.211.15:9921/tcp")
|
||||||
print("health=127.0.0.1:18221")
|
print("health=127.0.0.1:18221")
|
||||||
print("private_upstream=127.0.0.1:19921")
|
print("private_upstream=127.0.0.1:19921")
|
||||||
print("source_admission=public-ipv4-only")
|
print("source_admission=public-ipv4-only")
|
||||||
print("services=nodedc-b2-relay")
|
print("services=nodedc-b2-relay")
|
||||||
print(f"relay_runtime_identity={RELAY_USER}:no-credentials")
|
print(f"relay_runtime_identity={RELAY_USER}:no-credentials")
|
||||||
|
else:
|
||||||
|
print("public_core_channel=155.212.211.15:8443/tcp:tls13-mtls-h2")
|
||||||
|
print(f"health=127.0.0.1:{CHANNEL_HEALTH_PORT}")
|
||||||
|
print("public_b2_ingress=disabled")
|
||||||
|
print("tracker_tcp_9921=closed")
|
||||||
|
print("services=nodedc-device-edge-channel")
|
||||||
|
print(f"channel_runtime_identity={CHANNEL_USER}:host-local-private-key")
|
||||||
|
print("peer_trust=preprovisioned-pinned-self-signed-core-certificate+fingerprint")
|
||||||
print("command_transport=disabled")
|
print("command_transport=disabled")
|
||||||
print("gelios=untouched")
|
print("gelios=untouched")
|
||||||
print("dns=unchanged")
|
print("dns=unchanged")
|
||||||
@@ -1088,8 +1392,10 @@ def apply_artifact(artifact_argument: str):
|
|||||||
apply_foundation(loaded["payload"])
|
apply_foundation(loaded["payload"])
|
||||||
elif loaded["phase"] == "backhaul":
|
elif loaded["phase"] == "backhaul":
|
||||||
apply_backhaul(loaded["payload"])
|
apply_backhaul(loaded["payload"])
|
||||||
else:
|
elif loaded["phase"] == "relay":
|
||||||
apply_relay(loaded["payload"])
|
apply_relay(loaded["payload"])
|
||||||
|
else:
|
||||||
|
apply_core_channel(loaded["payload"])
|
||||||
|
|
||||||
archived = archive_artifact(loaded["artifact"], APPLIED_ROOT)
|
archived = archive_artifact(loaded["artifact"], APPLIED_ROOT)
|
||||||
record = {
|
record = {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_builders_are_deterministic_narrow_and_secret_free(self):
|
def test_builders_are_deterministic_narrow_and_secret_free(self):
|
||||||
self.require_runtime_cache()
|
self.require_runtime_cache()
|
||||||
for phase in ("foundation", "backhaul", "relay"):
|
for phase in ("foundation", "backhaul", "relay", "core-channel"):
|
||||||
with self.subTest(phase=phase), tempfile.TemporaryDirectory(
|
with self.subTest(phase=phase), tempfile.TemporaryDirectory(
|
||||||
prefix=f"nodedc-vps-{phase}-"
|
prefix=f"nodedc-vps-{phase}-"
|
||||||
) as directory:
|
) as directory:
|
||||||
@@ -164,7 +164,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
|
|||||||
old_inbox = RUNNER.INBOX_ROOT
|
old_inbox = RUNNER.INBOX_ROOT
|
||||||
RUNNER.INBOX_ROOT = inbox
|
RUNNER.INBOX_ROOT = inbox
|
||||||
try:
|
try:
|
||||||
for phase in ("foundation", "backhaul", "relay"):
|
for phase in ("foundation", "backhaul", "relay", "core-channel"):
|
||||||
result = self.build(
|
result = self.build(
|
||||||
inbox,
|
inbox,
|
||||||
phase,
|
phase,
|
||||||
@@ -217,10 +217,55 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
|
|||||||
self.assertIn("b2_routes=unchanged", rendered)
|
self.assertIn("b2_routes=unchanged", rendered)
|
||||||
self.assertIn("command_transport=disabled", rendered)
|
self.assertIn("command_transport=disabled", rendered)
|
||||||
|
|
||||||
|
def test_core_channel_plan_is_exact_and_keeps_tracker_ingress_closed(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="nodedc-vps-channel-plan-") as directory:
|
||||||
|
inbox = Path(directory) / "inbox"
|
||||||
|
inbox.mkdir()
|
||||||
|
result = self.build(
|
||||||
|
inbox,
|
||||||
|
"core-channel",
|
||||||
|
"device-edge-vps-core-channel-plan-001",
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
artifact = Path(json.loads(result.stdout)["artifact"])
|
||||||
|
old_inbox = RUNNER.INBOX_ROOT
|
||||||
|
RUNNER.INBOX_ROOT = inbox
|
||||||
|
try:
|
||||||
|
with patch.object(RUNNER, "assert_root"), patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"preflight",
|
||||||
|
return_value={"predecessor": "accepted-foundation-closed-channel"},
|
||||||
|
), patch("builtins.print") as output:
|
||||||
|
RUNNER.plan_artifact(str(artifact))
|
||||||
|
finally:
|
||||||
|
RUNNER.INBOX_ROOT = old_inbox
|
||||||
|
rendered = "\n".join(
|
||||||
|
" ".join(str(arg) for arg in call.args)
|
||||||
|
for call in output.call_args_list
|
||||||
|
)
|
||||||
|
self.assertIn("phase=core-channel", rendered)
|
||||||
|
self.assertIn(
|
||||||
|
"predecessor=accepted-foundation-closed-channel",
|
||||||
|
rendered,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"public_core_channel=155.212.211.15:8443/tcp:tls13-mtls-h2",
|
||||||
|
rendered,
|
||||||
|
)
|
||||||
|
self.assertIn("tracker_tcp_9921=closed", rendered)
|
||||||
|
self.assertIn("public_b2_ingress=disabled", rendered)
|
||||||
|
self.assertIn(
|
||||||
|
"peer_trust=preprovisioned-pinned-self-signed-core-certificate+fingerprint",
|
||||||
|
rendered,
|
||||||
|
)
|
||||||
|
self.assertIn("command_transport=disabled", rendered)
|
||||||
|
self.assertIn("gelios=untouched", rendered)
|
||||||
|
|
||||||
def test_units_and_firewalls_keep_the_required_boundaries(self):
|
def test_units_and_firewalls_keep_the_required_boundaries(self):
|
||||||
source_root = SCRIPT_DIR.parent.parent / "device-plane"
|
source_root = SCRIPT_DIR.parent.parent / "device-plane"
|
||||||
foundation = (source_root / "vps/config/nftables-foundation.conf").read_text()
|
foundation = (source_root / "vps/config/nftables-foundation.conf").read_text()
|
||||||
relay = (source_root / "vps/config/nftables-relay.conf").read_text()
|
relay = (source_root / "vps/config/nftables-relay.conf").read_text()
|
||||||
|
channel = (source_root / "vps/config/nftables-core-channel.conf").read_text()
|
||||||
sshd = (source_root / "vps/config/00-nodedc-b2-vps.conf").read_text()
|
sshd = (source_root / "vps/config/00-nodedc-b2-vps.conf").read_text()
|
||||||
backhaul = (source_root / "vps/config/backhaul_ssh_config").read_text()
|
backhaul = (source_root / "vps/config/backhaul_ssh_config").read_text()
|
||||||
tailscale_unit = (
|
tailscale_unit = (
|
||||||
@@ -230,11 +275,16 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
|
|||||||
backhaul_unit = (
|
backhaul_unit = (
|
||||||
source_root / "vps/systemd/nodedc-b2-backhaul.service"
|
source_root / "vps/systemd/nodedc-b2-backhaul.service"
|
||||||
).read_text()
|
).read_text()
|
||||||
|
channel_unit = (
|
||||||
|
source_root / "vps/systemd/nodedc-device-edge-channel.service"
|
||||||
|
).read_text()
|
||||||
|
|
||||||
self.assertIn("policy drop", foundation)
|
self.assertIn("policy drop", foundation)
|
||||||
self.assertIn("tcp dport 22", foundation)
|
self.assertIn("tcp dport 22", foundation)
|
||||||
self.assertNotIn("tcp dport 9921", foundation)
|
self.assertNotIn("tcp dport 9921", foundation)
|
||||||
self.assertIn("tcp dport 9921", relay)
|
self.assertIn("tcp dport 9921", relay)
|
||||||
|
self.assertIn("tcp dport 8443", channel)
|
||||||
|
self.assertNotIn("tcp dport 9921", channel)
|
||||||
self.assertIn("PasswordAuthentication no", sshd)
|
self.assertIn("PasswordAuthentication no", sshd)
|
||||||
self.assertIn("AllowTcpForwarding no", sshd)
|
self.assertIn("AllowTcpForwarding no", sshd)
|
||||||
self.assertIn("StrictHostKeyChecking yes", backhaul)
|
self.assertIn("StrictHostKeyChecking yes", backhaul)
|
||||||
@@ -248,6 +298,15 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
|
|||||||
self.assertNotIn("User=nodedc-edge", relay_unit)
|
self.assertNotIn("User=nodedc-edge", relay_unit)
|
||||||
self.assertIn("DEVICE_EDGE_RELAY_SOURCE_POLICY=public-ipv4-only", relay_unit)
|
self.assertIn("DEVICE_EDGE_RELAY_SOURCE_POLICY=public-ipv4-only", relay_unit)
|
||||||
self.assertIn("MemoryMax=192M", relay_unit)
|
self.assertIn("MemoryMax=192M", relay_unit)
|
||||||
|
self.assertIn("User=nodedc-channel", channel_unit)
|
||||||
|
self.assertIn("node --jitless", channel_unit)
|
||||||
|
self.assertIn("MemoryMax=128M", channel_unit)
|
||||||
|
self.assertIn("MemorySwapMax=0", channel_unit)
|
||||||
|
self.assertIn("CPUQuota=50%", channel_unit)
|
||||||
|
self.assertIn("TasksMax=64", channel_unit)
|
||||||
|
self.assertIn("LimitNOFILE=1024", channel_unit)
|
||||||
|
self.assertNotIn("LocalForward", channel_unit)
|
||||||
|
self.assertNotIn("DEVICE_EDGE_RELAY_UPSTREAM", channel_unit)
|
||||||
|
|
||||||
def test_runner_has_registered_rollback_and_no_generic_latest(self):
|
def test_runner_has_registered_rollback_and_no_generic_latest(self):
|
||||||
source = RUNNER_PATH.read_text(encoding="utf-8")
|
source = RUNNER_PATH.read_text(encoding="utf-8")
|
||||||
@@ -344,6 +403,82 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
|
|||||||
"old-source\n",
|
"old-source\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_backup_restore_preserves_core_channel_source_trust_and_firewall(self):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="nodedc-vps-channel-backup-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
live = root / "live"
|
||||||
|
backups = root / "backups"
|
||||||
|
nft = root / "etc/nftables.conf"
|
||||||
|
channel_unit = root / "etc/nodedc-device-edge-channel.service"
|
||||||
|
trust = root / "state/channel-trust"
|
||||||
|
backups.mkdir()
|
||||||
|
nft.parent.mkdir(parents=True)
|
||||||
|
trust.mkdir(parents=True)
|
||||||
|
nft.write_text("foundation-firewall\n", encoding="utf-8")
|
||||||
|
channel_unit.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
channel_unit.write_text("old-channel-unit\n", encoding="utf-8")
|
||||||
|
(trust / "runtime.json").write_text("old-runtime\n", encoding="utf-8")
|
||||||
|
for relative in RUNNER.CORE_CHANNEL_ENTRIES:
|
||||||
|
target = live / relative
|
||||||
|
if relative.endswith("/src"):
|
||||||
|
target.mkdir(parents=True)
|
||||||
|
(target / "server.mjs").write_text("old-channel-source\n", encoding="utf-8")
|
||||||
|
else:
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_text(f"old:{relative}\n", encoding="utf-8")
|
||||||
|
|
||||||
|
old_live = RUNNER.LIVE_ROOT
|
||||||
|
old_backups = RUNNER.BACKUP_ROOT
|
||||||
|
old_nft = RUNNER.NFTABLES_CONFIG
|
||||||
|
old_unit = RUNNER.CHANNEL_UNIT
|
||||||
|
old_trust = RUNNER.CHANNEL_TRUST_ROOT
|
||||||
|
RUNNER.LIVE_ROOT = live
|
||||||
|
RUNNER.BACKUP_ROOT = backups
|
||||||
|
RUNNER.NFTABLES_CONFIG = nft
|
||||||
|
RUNNER.CHANNEL_UNIT = channel_unit
|
||||||
|
RUNNER.CHANNEL_TRUST_ROOT = trust
|
||||||
|
completed = subprocess.CompletedProcess([], 0, "table inet old {}\n", "")
|
||||||
|
try:
|
||||||
|
with patch.object(RUNNER, "run", return_value=completed), patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"service_active",
|
||||||
|
return_value=False,
|
||||||
|
), patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"systemctl",
|
||||||
|
return_value=completed,
|
||||||
|
), patch.object(
|
||||||
|
RUNNER,
|
||||||
|
"user_exists",
|
||||||
|
return_value=False,
|
||||||
|
):
|
||||||
|
_backup_id, backup = RUNNER.create_backup(
|
||||||
|
"channel-unit",
|
||||||
|
"core-channel",
|
||||||
|
)
|
||||||
|
nft.write_text("candidate-firewall\n", encoding="utf-8")
|
||||||
|
channel_unit.write_text("candidate-channel-unit\n", encoding="utf-8")
|
||||||
|
(trust / "runtime.json").write_text("candidate-runtime\n", encoding="utf-8")
|
||||||
|
(live / "services/device-edge-channel/src/server.mjs").write_text(
|
||||||
|
"candidate-channel-source\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
RUNNER.restore_backup(backup, "core-channel")
|
||||||
|
finally:
|
||||||
|
RUNNER.LIVE_ROOT = old_live
|
||||||
|
RUNNER.BACKUP_ROOT = old_backups
|
||||||
|
RUNNER.NFTABLES_CONFIG = old_nft
|
||||||
|
RUNNER.CHANNEL_UNIT = old_unit
|
||||||
|
RUNNER.CHANNEL_TRUST_ROOT = old_trust
|
||||||
|
|
||||||
|
self.assertEqual(nft.read_text(), "foundation-firewall\n")
|
||||||
|
self.assertEqual(channel_unit.read_text(), "old-channel-unit\n")
|
||||||
|
self.assertEqual((trust / "runtime.json").read_text(), "old-runtime\n")
|
||||||
|
self.assertEqual(
|
||||||
|
(live / "services/device-edge-channel/src/server.mjs").read_text(),
|
||||||
|
"old-channel-source\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main(verbosity=2)
|
unittest.main(verbosity=2)
|
||||||
|
|||||||
Reference in New Issue
Block a user