feat(device-edge): open bounded tracker ingress
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-edge-vps.tracker-ingress.v1",
|
||||
"mode": "provider-neutral-allowlisted-adapter-ingress-over-accepted-core-channel",
|
||||
"status": "active-tracker-ingress",
|
||||
"authority": "DCPLATFORM-21/DCPLATFORM-76/ADR-0001",
|
||||
"component": "device-edge-vps",
|
||||
"phase": "tracker-ingress",
|
||||
"runtimeHost": "koffyvngij",
|
||||
"predecessorPatch": "device-edge-vps-tailscale-retirement-20260812-011",
|
||||
"predecessorArtifactSha256": "e7b61ec9c83122fa5631467010eff871b98935746df6a1326b6ff6bb9713d877",
|
||||
"runtimeUser": "nodedc-channel",
|
||||
"runtimeService": "nodedc-device-edge-channel.service",
|
||||
"runtimeComposition": "single-process-core-channel-plus-universal-device-gateway",
|
||||
"publicIngress": "tcp/443-mtls-core-channel+tcp/9921-tracker-telemetry",
|
||||
"trackerIngress": "enabled:allowlisted-adapters-only",
|
||||
"initialAdapterProfile": "arusnavi.b2.internal.v1",
|
||||
"acknowledgementBoundary": "tracker-ack-only-after-core-durable-acceptance",
|
||||
"health": "127.0.0.1:18222",
|
||||
"adapterHealth": "127.0.0.1:18221",
|
||||
"rawDeviceTcp9921": "public-telemetry-ingest",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"tailscale": "absent",
|
||||
"dataBoundary": "no-vps-database-no-business-logic-no-synology-route",
|
||||
"resourceCeilings": {
|
||||
"memory": "192M",
|
||||
"swap": "0",
|
||||
"cpu": "75%",
|
||||
"tasks": 128,
|
||||
"openFiles": 1024,
|
||||
"sessions": 128,
|
||||
"sessionsPerAddress": 16,
|
||||
"connectionsPerMinutePerAddress": 60,
|
||||
"sessionBufferBytes": 65536,
|
||||
"aggregateBufferBytes": 33554432
|
||||
},
|
||||
"preserved": [
|
||||
"management-ssh-key",
|
||||
"accepted-node-runtime",
|
||||
"accepted-core-channel-trust-and-registration",
|
||||
"retired-tailnet-boundary",
|
||||
"gelios-production-path"
|
||||
],
|
||||
"forbidden": [
|
||||
"vps-initiated-synology-connection",
|
||||
"generic-tcp-forwarding",
|
||||
"tailscale-runtime",
|
||||
"docker",
|
||||
"public-health",
|
||||
"vps-database",
|
||||
"vps-business-logic",
|
||||
"unregistered-adapter",
|
||||
"device-command"
|
||||
],
|
||||
"acceptance": [
|
||||
"exact-tailscale-retirement-011-predecessor",
|
||||
"single-non-root-edge-process",
|
||||
"core-channel-remains-accepted",
|
||||
"public-tracker-tcp-9921-listening",
|
||||
"adapter-profile-allowlisted",
|
||||
"bounded-session-and-buffer-limits",
|
||||
"tracker-ack-after-core-acceptance",
|
||||
"tailscale-remains-absent",
|
||||
"no-vps-to-synology-route",
|
||||
"command-transport-disabled",
|
||||
"gelios-untouched"
|
||||
],
|
||||
"rollback": "close-9921-restore-exact-tailscale-retirement-011-source-unit-firewall-and-accepted-core-channel-runtime"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
normalizeTrackerIngressConfiguration,
|
||||
} from "../../../vps/edge-process/device-edge-runtime.mjs";
|
||||
|
||||
test("VPS tracker ingress resolves one allowlisted profile within bounded limits", () => {
|
||||
const config = normalizeTrackerIngressConfiguration({}, "edge:moscow-vps-1");
|
||||
|
||||
assert.equal(config.protocolProfileRef, "arusnavi.b2.internal.v1");
|
||||
assert.equal(config.tcpHost, "0.0.0.0");
|
||||
assert.equal(config.tcpPort, 9921);
|
||||
assert.equal(config.maxConcurrentSessions, 128);
|
||||
assert.equal(config.maxSessionsPerAddress, 16);
|
||||
assert.equal(config.maxConnectionsPerMinutePerAddress, 60);
|
||||
assert.equal(config.maxAggregateBufferedBytes, 32 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test("VPS tracker ingress rejects hidden bind and non-allowlisted adapters", () => {
|
||||
assert.throws(() => normalizeTrackerIngressConfiguration({
|
||||
DEVICE_GATEWAY_TCP_HOST: "127.0.0.1",
|
||||
}, "edge:moscow-vps-1"), /public_host_invalid/);
|
||||
|
||||
assert.throws(() => normalizeTrackerIngressConfiguration({
|
||||
DEVICE_GATEWAY_PROTOCOL_PROFILE_REF: "vendor.unknown.v1",
|
||||
}, "edge:moscow-vps-1"), /profile_not_allowlisted/);
|
||||
|
||||
assert.throws(() => normalizeTrackerIngressConfiguration({
|
||||
DEVICE_GATEWAY_MAX_SESSIONS: "129",
|
||||
}, "edge:moscow-vps-1"), /session_limit_invalid/);
|
||||
});
|
||||
@@ -139,6 +139,9 @@ export function createDeviceGatewayRuntime(options = {}) {
|
||||
},
|
||||
status() {
|
||||
return {
|
||||
adapter: config.adapter?.adapterRef ?? "disabled",
|
||||
protocolProfile: config.profile?.profileRef ?? "disabled",
|
||||
framing: config.profile?.framing?.status ?? "disabled",
|
||||
activeSessions: sessions.size,
|
||||
totalAccepted,
|
||||
totalRejected,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/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 443 ct state new limit rate 120/minute burst 120 packets accept
|
||||
tcp dport 9921 ct state new limit rate 600/minute burst 128 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,243 @@
|
||||
import { createServer } from "node:http";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import {
|
||||
DEVICE_ADAPTER_CATALOG,
|
||||
} from "../../packages/device-adapter-catalog/src/index.mjs";
|
||||
import {
|
||||
createDeviceEdgeChannelServer,
|
||||
} from "../../services/device-edge-channel/src/runtime.mjs";
|
||||
import {
|
||||
readRuntimeConfiguration,
|
||||
} from "../../services/device-edge-channel/src/server.mjs";
|
||||
import {
|
||||
createDeviceGatewayRuntime,
|
||||
} from "../../services/device-gateway/src/runtime.mjs";
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
await main();
|
||||
}
|
||||
|
||||
export async function main(environment = process.env) {
|
||||
const base = await readRuntimeConfiguration(environment);
|
||||
const tracker = normalizeTrackerIngressConfiguration(
|
||||
environment,
|
||||
base.channel.edgeRegistrationId,
|
||||
);
|
||||
const channel = createDeviceEdgeChannelServer(base.channel);
|
||||
const gateway = createDeviceGatewayRuntime({
|
||||
listenEnabled: true,
|
||||
publicIngressEnabled: true,
|
||||
coreChannelAuthenticated: true,
|
||||
adapterRegistry: DEVICE_ADAPTER_CATALOG.registry,
|
||||
protocolProfileRef: tracker.protocolProfileRef,
|
||||
edgeRef: tracker.edgeRef,
|
||||
healthHost: tracker.healthHost,
|
||||
healthPort: tracker.healthPort,
|
||||
tcpHost: tracker.tcpHost,
|
||||
tcpPort: tracker.tcpPort,
|
||||
maxBufferedBytes: tracker.maxBufferedBytes,
|
||||
maxAggregateBufferedBytes: tracker.maxAggregateBufferedBytes,
|
||||
maxConcurrentSessions: tracker.maxConcurrentSessions,
|
||||
maxSessionsPerAddress: tracker.maxSessionsPerAddress,
|
||||
maxConnectionsPerMinutePerAddress:
|
||||
tracker.maxConnectionsPerMinutePerAddress,
|
||||
maxTrackedSourceAddresses: tracker.maxTrackedSourceAddresses,
|
||||
sessionTimeoutMs: tracker.sessionTimeoutMs,
|
||||
onDiscovery: (signal) => channel.submitDiscovery(signal),
|
||||
onMessage: (message) => channel.submitAdapterMessage(message),
|
||||
});
|
||||
const health = createCombinedHealthServer(channel, gateway, base.health);
|
||||
let stopping = false;
|
||||
|
||||
try {
|
||||
await channel.start();
|
||||
await gateway.start();
|
||||
await listen(health, base.health.port, base.health.host);
|
||||
} catch (error) {
|
||||
await Promise.allSettled([
|
||||
gateway.stop(),
|
||||
channel.stop(),
|
||||
closeServer(health),
|
||||
]);
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
event: "device_edge_runtime_started",
|
||||
channel: `${base.channel.host}:${base.channel.port}`,
|
||||
health: `${base.health.host}:${base.health.port}`,
|
||||
trackerIngress: `${tracker.tcpHost}:${tracker.tcpPort}`,
|
||||
adapterProfile: tracker.protocolProfileRef,
|
||||
edgeRegistrationId: base.channel.edgeRegistrationId,
|
||||
channelGeneration: base.channel.channelGeneration,
|
||||
trustGeneration: base.channel.trustGeneration,
|
||||
commandTransport: "disabled",
|
||||
}));
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
async function shutdown() {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
await Promise.allSettled([
|
||||
gateway.stop(),
|
||||
channel.stop(),
|
||||
closeServer(health),
|
||||
]);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeTrackerIngressConfiguration(environment = {}, edgeRef) {
|
||||
return Object.freeze({
|
||||
edgeRef: normalizeRef(edgeRef, "device_edge_runtime_edge_ref_invalid"),
|
||||
protocolProfileRef: normalizeProfileRef(
|
||||
environment.DEVICE_GATEWAY_PROTOCOL_PROFILE_REF
|
||||
?? DEVICE_ADAPTER_CATALOG.defaultProfileRef,
|
||||
),
|
||||
healthHost: normalizeLoopbackHost(
|
||||
environment.DEVICE_GATEWAY_HEALTH_HOST ?? "127.0.0.1",
|
||||
),
|
||||
healthPort: normalizePort(environment.DEVICE_GATEWAY_HEALTH_PORT, 18221),
|
||||
tcpHost: normalizePublicHost(
|
||||
environment.DEVICE_GATEWAY_TCP_HOST ?? "0.0.0.0",
|
||||
),
|
||||
tcpPort: normalizePort(environment.DEVICE_GATEWAY_TCP_PORT, 9921),
|
||||
maxBufferedBytes: normalizeInteger(
|
||||
environment.DEVICE_GATEWAY_MAX_BUFFERED_BYTES,
|
||||
64 * 1024,
|
||||
1024,
|
||||
256 * 1024,
|
||||
"device_edge_runtime_session_buffer_invalid",
|
||||
),
|
||||
maxAggregateBufferedBytes: normalizeInteger(
|
||||
environment.DEVICE_GATEWAY_MAX_AGGREGATE_BUFFERED_BYTES,
|
||||
32 * 1024 * 1024,
|
||||
1024,
|
||||
32 * 1024 * 1024,
|
||||
"device_edge_runtime_aggregate_buffer_invalid",
|
||||
),
|
||||
maxConcurrentSessions: normalizeInteger(
|
||||
environment.DEVICE_GATEWAY_MAX_SESSIONS,
|
||||
128,
|
||||
1,
|
||||
128,
|
||||
"device_edge_runtime_session_limit_invalid",
|
||||
),
|
||||
maxSessionsPerAddress: normalizeInteger(
|
||||
environment.DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS,
|
||||
16,
|
||||
1,
|
||||
16,
|
||||
"device_edge_runtime_address_session_limit_invalid",
|
||||
),
|
||||
maxConnectionsPerMinutePerAddress: normalizeInteger(
|
||||
environment.DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS,
|
||||
60,
|
||||
1,
|
||||
60,
|
||||
"device_edge_runtime_address_rate_limit_invalid",
|
||||
),
|
||||
maxTrackedSourceAddresses: normalizeInteger(
|
||||
environment.DEVICE_GATEWAY_MAX_TRACKED_SOURCE_ADDRESSES,
|
||||
2048,
|
||||
1,
|
||||
2048,
|
||||
"device_edge_runtime_source_tracking_limit_invalid",
|
||||
),
|
||||
sessionTimeoutMs: normalizeInteger(
|
||||
environment.DEVICE_GATEWAY_SESSION_TIMEOUT_MS,
|
||||
10_000,
|
||||
100,
|
||||
60_000,
|
||||
"device_edge_runtime_session_timeout_invalid",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function createCombinedHealthServer(channel, gateway, 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;
|
||||
}
|
||||
response.statusCode = 200;
|
||||
response.end(JSON.stringify({
|
||||
ok: true,
|
||||
service: "nodedc-device-edge-runtime",
|
||||
health: `${healthConfig.host}:${healthConfig.port}`,
|
||||
...channel.status(),
|
||||
trackerIngress: "telemetry-ingest",
|
||||
tracker: gateway.status(),
|
||||
commandTransport: "disabled",
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRef(value, errorCode) {
|
||||
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) {
|
||||
throw new TypeError(errorCode);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeProfileRef(value) {
|
||||
if (typeof value !== "string" || !/^[a-z][a-z0-9._-]{2,127}$/.test(value)) {
|
||||
throw new TypeError("device_edge_runtime_profile_ref_invalid");
|
||||
}
|
||||
DEVICE_ADAPTER_CATALOG.registry.resolveProfile(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeLoopbackHost(value) {
|
||||
if (!["127.0.0.1", "::1"].includes(value)) {
|
||||
throw new TypeError("device_edge_runtime_health_host_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizePublicHost(value) {
|
||||
if (!["0.0.0.0", "::"].includes(value)) {
|
||||
throw new TypeError("device_edge_runtime_public_host_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizePort(value, fallback) {
|
||||
return normalizeInteger(
|
||||
value,
|
||||
fallback,
|
||||
1,
|
||||
65_535,
|
||||
"device_edge_runtime_port_invalid",
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeInteger(value, fallback, minimum, maximum, errorCode) {
|
||||
const normalized = Number(value ?? fallback);
|
||||
if (!Number.isSafeInteger(normalized) || normalized < minimum || normalized > maximum) {
|
||||
throw new TypeError(errorCode);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!server.listening) return Promise.resolve();
|
||||
return new Promise((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
[Unit]
|
||||
Description=NODE.DC provider-neutral Device Edge runtime
|
||||
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 /opt/nodedc-b2-vps/vps/edge-process/device-edge-runtime.mjs
|
||||
Environment=DEVICE_EDGE_CHANNEL_HOST=0.0.0.0
|
||||
Environment=DEVICE_EDGE_CHANNEL_PORT=443
|
||||
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
|
||||
Environment=DEVICE_GATEWAY_PROTOCOL_PROFILE_REF=arusnavi.b2.internal.v1
|
||||
Environment=DEVICE_GATEWAY_HEALTH_HOST=127.0.0.1
|
||||
Environment=DEVICE_GATEWAY_HEALTH_PORT=18221
|
||||
Environment=DEVICE_GATEWAY_TCP_HOST=0.0.0.0
|
||||
Environment=DEVICE_GATEWAY_TCP_PORT=9921
|
||||
Environment=DEVICE_GATEWAY_MAX_BUFFERED_BYTES=65536
|
||||
Environment=DEVICE_GATEWAY_MAX_AGGREGATE_BUFFERED_BYTES=33554432
|
||||
Environment=DEVICE_GATEWAY_MAX_SESSIONS=128
|
||||
Environment=DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS=16
|
||||
Environment=DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS=60
|
||||
Environment=DEVICE_GATEWAY_MAX_TRACKED_SOURCE_ADDRESSES=2048
|
||||
Environment=DEVICE_GATEWAY_SESSION_TIMEOUT_MS=10000
|
||||
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=no
|
||||
SystemCallArchitectures=native
|
||||
RestrictAddressFamilies=AF_INET AF_INET6
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
UMask=0077
|
||||
MemoryMax=192M
|
||||
MemorySwapMax=0
|
||||
CPUQuota=75%
|
||||
TasksMax=128
|
||||
LimitNOFILE=1024
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -36,11 +36,12 @@ if (
|
||||
"relay",
|
||||
"core-channel",
|
||||
"tailscale-retirement",
|
||||
"tracker-ingress",
|
||||
].includes(phase)
|
||||
|| !/^[A-Za-z0-9._-]{1,96}$/.test(patchId || "")
|
||||
) {
|
||||
throw new Error(
|
||||
"usage: build-device-edge-vps-artifact.mjs <foundation|runtime-reconciliation|backhaul|relay|core-channel|tailscale-retirement> <patch-id>",
|
||||
"usage: build-device-edge-vps-artifact.mjs <foundation|runtime-reconciliation|backhaul|relay|core-channel|tailscale-retirement|tracker-ingress> <patch-id>",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,6 +97,19 @@ const entriesByPhase = {
|
||||
"tailscale-retirement": [
|
||||
"deployment/device-edge-vps-tailscale-retirement-v1.json",
|
||||
],
|
||||
"tracker-ingress": [
|
||||
"packages/device-adapter-runtime/package.json",
|
||||
"packages/device-adapter-runtime/src",
|
||||
"packages/device-adapter-catalog/package.json",
|
||||
"packages/device-adapter-catalog/src",
|
||||
"packages/arusnavi-b2-adapter/package.json",
|
||||
"packages/arusnavi-b2-adapter/src",
|
||||
"services/device-gateway/src/runtime.mjs",
|
||||
"vps/edge-process/device-edge-runtime.mjs",
|
||||
"vps/config/nftables-tracker-ingress.conf",
|
||||
"vps/systemd/nodedc-device-edge-runtime.service",
|
||||
"deployment/device-edge-vps-tracker-ingress-v1.json",
|
||||
],
|
||||
};
|
||||
const entries = entriesByPhase[phase];
|
||||
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
|
||||
@@ -157,6 +171,8 @@ try {
|
||||
? "tcp/9921"
|
||||
: ["core-channel", "tailscale-retirement"].includes(phase)
|
||||
? "tcp/443-mtls-only"
|
||||
: phase === "tracker-ingress"
|
||||
? "tcp/443-mtls+tcp/9921-telemetry"
|
||||
: "disabled",
|
||||
commandTransport: "disabled",
|
||||
gelios: "untouched",
|
||||
@@ -314,6 +330,41 @@ async function assertBoundary() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (phase === "tracker-ingress") {
|
||||
for (const required of [
|
||||
'"predecessorPatch": "device-edge-vps-tailscale-retirement-20260812-011"',
|
||||
'"runtimeComposition": "single-process-core-channel-plus-universal-device-gateway"',
|
||||
'"trackerIngress": "enabled:allowlisted-adapters-only"',
|
||||
'"acknowledgementBoundary": "tracker-ack-only-after-core-durable-acceptance"',
|
||||
'"initialAdapterProfile": "arusnavi.b2.internal.v1"',
|
||||
"createDeviceGatewayRuntime",
|
||||
"DEVICE_ADAPTER_CATALOG.registry",
|
||||
"onDiscovery: (signal) => channel.submitDiscovery(signal)",
|
||||
"onMessage: (message) => channel.submitAdapterMessage(message)",
|
||||
"tcp dport 9921",
|
||||
"User=nodedc-channel",
|
||||
"MemoryMax=192M",
|
||||
"MemorySwapMax=0",
|
||||
"CPUQuota=75%",
|
||||
"TasksMax=128",
|
||||
"LimitNOFILE=1024",
|
||||
]) {
|
||||
if (!combined.includes(required)) {
|
||||
throw new Error(`tracker_ingress_boundary_missing:${required}`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"LocalForward",
|
||||
"tailscale-userspace",
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM",
|
||||
'commandTransport": "enabled',
|
||||
"device.dc.ru",
|
||||
]) {
|
||||
if (combined.includes(forbidden)) {
|
||||
throw new Error(`tracker_ingress_boundary_violation:${forbidden}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
|
||||
@@ -106,6 +106,12 @@ CORE_CHANNEL_ACCEPTED_PATCH = "device-edge-vps-core-channel-20260812-010"
|
||||
CORE_CHANNEL_ACCEPTED_SHA256 = (
|
||||
"c8ef3c4bb45850cad32e881eba081bc4c891c2886e5500d02cb94616d82353f3"
|
||||
)
|
||||
TAILSCALE_RETIREMENT_ACCEPTED_PATCH = (
|
||||
"device-edge-vps-tailscale-retirement-20260812-011"
|
||||
)
|
||||
TAILSCALE_RETIREMENT_ACCEPTED_SHA256 = (
|
||||
"e7b61ec9c83122fa5631467010eff871b98935746df6a1326b6ff6bb9713d877"
|
||||
)
|
||||
|
||||
FOUNDATION_ENTRIES = (
|
||||
"vps/config/00-nodedc-b2-vps.conf",
|
||||
@@ -143,6 +149,19 @@ CORE_CHANNEL_ENTRIES = (
|
||||
TAILSCALE_RETIREMENT_ENTRIES = (
|
||||
"deployment/device-edge-vps-tailscale-retirement-v1.json",
|
||||
)
|
||||
TRACKER_INGRESS_ENTRIES = (
|
||||
"packages/device-adapter-runtime/package.json",
|
||||
"packages/device-adapter-runtime/src",
|
||||
"packages/device-adapter-catalog/package.json",
|
||||
"packages/device-adapter-catalog/src",
|
||||
"packages/arusnavi-b2-adapter/package.json",
|
||||
"packages/arusnavi-b2-adapter/src",
|
||||
"services/device-gateway/src/runtime.mjs",
|
||||
"vps/edge-process/device-edge-runtime.mjs",
|
||||
"vps/config/nftables-tracker-ingress.conf",
|
||||
"vps/systemd/nodedc-device-edge-runtime.service",
|
||||
"deployment/device-edge-vps-tracker-ingress-v1.json",
|
||||
)
|
||||
|
||||
PHASE_ENTRIES = {
|
||||
"foundation": FOUNDATION_ENTRIES,
|
||||
@@ -151,6 +170,7 @@ PHASE_ENTRIES = {
|
||||
"relay": RELAY_ENTRIES,
|
||||
"core-channel": CORE_CHANNEL_ENTRIES,
|
||||
"tailscale-retirement": TAILSCALE_RETIREMENT_ENTRIES,
|
||||
"tracker-ingress": TRACKER_INGRESS_ENTRIES,
|
||||
}
|
||||
|
||||
SUPERSEDED_TRANSPORT_PHASES = frozenset({"backhaul", "relay"})
|
||||
@@ -218,6 +238,30 @@ PHASE_FILE_SHA256 = {
|
||||
"deployment/device-edge-vps-tailscale-retirement-v1.json":
|
||||
"bbe11e8cf4103f44ae7888b4d3f3dde7015eeaef8ea5a7c4c00427a7b85f8e33",
|
||||
},
|
||||
"tracker-ingress": {
|
||||
"packages/device-adapter-runtime/package.json":
|
||||
"293ee6010f255c511df40b34cf8019fcd0d1b06babe30761c2a842c7da7af778",
|
||||
"packages/device-adapter-runtime/src/index.mjs":
|
||||
"199fcdd775d1ea15ddc9f85ab24fc2c165bb7757008de26749f1b7e84481dfeb",
|
||||
"packages/device-adapter-catalog/package.json":
|
||||
"60dbfc551ca49719e0851755058c159968411c490621eac0bf96ca76bf8b78d1",
|
||||
"packages/device-adapter-catalog/src/index.mjs":
|
||||
"614749e763c0624bf0de7905343632d3edb4e026316f427a2f52ce12c4225dca",
|
||||
"packages/arusnavi-b2-adapter/package.json":
|
||||
"136fc997f5d6757ba81d370f03037e0a93fd4750dc624def1af593e7ba8b9098",
|
||||
"packages/arusnavi-b2-adapter/src/index.mjs":
|
||||
"c5d95391438dfff1f1c12396b2e919effca9003a4a4a3eb3b828be42790f6173",
|
||||
"services/device-gateway/src/runtime.mjs":
|
||||
"bf0697674ed150d5e043e19e31fa99a242bb5dcc7d184e7adaccd883a22c5e5e",
|
||||
"vps/edge-process/device-edge-runtime.mjs":
|
||||
"7616e894e55b2579de7c2652589baf3cd3804dc0ec8af69661eadac1098979e9",
|
||||
"vps/config/nftables-tracker-ingress.conf":
|
||||
"6a6a76a02e5908118104a9a54a9e41b5a7e04476418efdb1485e39f0ee7c995c",
|
||||
"vps/systemd/nodedc-device-edge-runtime.service":
|
||||
"7e639efb66eee891da85b2976353715d942bd8aa381ddc1383446debf279817b",
|
||||
"deployment/device-edge-vps-tracker-ingress-v1.json":
|
||||
"faecfa0317ad0e3d102c3636c0547b65257b930e9b48f34d8679c00ceb3d323b",
|
||||
},
|
||||
}
|
||||
|
||||
# Exact immutable baselines from terminally accepted predecessor artifacts.
|
||||
@@ -617,6 +661,19 @@ def current_phase_preflight(phase: str):
|
||||
return {
|
||||
"predecessor": "failed-core-channel-001-rollback-runtime-mode-drift",
|
||||
}
|
||||
if phase == "tracker-ingress":
|
||||
retirement_record = applied_phase_record("tailscale-retirement")
|
||||
if (
|
||||
retirement_record.get("patch")
|
||||
!= TAILSCALE_RETIREMENT_ACCEPTED_PATCH
|
||||
or retirement_record.get("sha256")
|
||||
!= TAILSCALE_RETIREMENT_ACCEPTED_SHA256
|
||||
):
|
||||
die("VPS tracker ingress Tailscale retirement predecessor mismatch")
|
||||
validate_tailscale_retirement_runtime()
|
||||
if (LIVE_ROOT / TRACKER_INGRESS_ENTRIES[-1]).exists():
|
||||
die("VPS tracker ingress target path already exists")
|
||||
return {"predecessor": "accepted-tailscale-retirement-011"}
|
||||
validate_foundation_runtime(
|
||||
require_running_tailnet=phase in {"backhaul", "relay"},
|
||||
expected_key_user=BACKHAUL_USER if phase == "relay" else SERVICE_USER,
|
||||
@@ -724,6 +781,8 @@ def backup_targets_for_phase(phase: str):
|
||||
TAILSCALE_BIN.parent,
|
||||
TRUST_ROOT,
|
||||
]
|
||||
if phase == "tracker-ingress":
|
||||
return common + [CHANNEL_UNIT, NFTABLES_CONFIG]
|
||||
return common + [RELAY_UNIT, NFTABLES_CONFIG]
|
||||
|
||||
|
||||
@@ -1194,6 +1253,18 @@ def apply_tailscale_retirement(_payload: Path):
|
||||
validate_tailscale_retirement_runtime()
|
||||
|
||||
|
||||
def apply_tracker_ingress(_payload: Path):
|
||||
install_file(
|
||||
LIVE_ROOT / TRACKER_INGRESS_ENTRIES[9],
|
||||
CHANNEL_UNIT,
|
||||
0o644,
|
||||
)
|
||||
apply_nftables(LIVE_ROOT / TRACKER_INGRESS_ENTRIES[8])
|
||||
systemctl("daemon-reload")
|
||||
systemctl("restart", "nodedc-device-edge-channel.service")
|
||||
validate_tracker_ingress_runtime()
|
||||
|
||||
|
||||
def sshd_effective():
|
||||
return run(["/usr/sbin/sshd", "-T"]).stdout.lower()
|
||||
|
||||
@@ -1462,6 +1533,93 @@ def validate_tailscale_retirement_runtime():
|
||||
return health
|
||||
|
||||
|
||||
def validate_tracker_ingress_runtime():
|
||||
source_file_state("foundation")
|
||||
source_file_state("core-channel")
|
||||
source_file_state("tailscale-retirement")
|
||||
source_file_state("tracker-ingress")
|
||||
retirement_record = applied_phase_record("tailscale-retirement")
|
||||
if (
|
||||
retirement_record.get("patch") != TAILSCALE_RETIREMENT_ACCEPTED_PATCH
|
||||
or retirement_record.get("sha256")
|
||||
!= TAILSCALE_RETIREMENT_ACCEPTED_SHA256
|
||||
):
|
||||
die("tracker ingress retirement identity mismatch")
|
||||
if run([str(NODE_BIN), "--version"]).stdout.strip() != f"v{NODE_VERSION}":
|
||||
die("tracker ingress Node version mismatch")
|
||||
for path in (
|
||||
TAILSCALE_UNIT,
|
||||
TAILSCALE_SOCKET,
|
||||
TAILSCALE_STATE.parent,
|
||||
TAILSCALE_BIN.parent,
|
||||
TRUST_ROOT,
|
||||
):
|
||||
if path.exists() or path.is_symlink():
|
||||
die(f"tracker ingress superseded trust boundary present: {path}")
|
||||
if service_active("nodedc-b2-tailscaled.service"):
|
||||
die("tracker ingress Tailscale service became active")
|
||||
if service_active("nodedc-b2-backhaul.service"):
|
||||
die("tracker ingress frozen backhaul service became active")
|
||||
if service_active("nodedc-b2-relay.service"):
|
||||
die("tracker ingress frozen relay service became active")
|
||||
assert_management_key()
|
||||
assert_channel_trust(require_runtime_owner=True)
|
||||
if not service_active("nodedc-device-edge-channel.service"):
|
||||
die("VPS Device Edge runtime is not active")
|
||||
health = core_channel_health(require_accepted=True)
|
||||
expected = {
|
||||
"ok": True,
|
||||
"service": "nodedc-device-edge-runtime",
|
||||
"channel": "accepted",
|
||||
"trackerIngress": "telemetry-ingest",
|
||||
"commandTransport": "disabled",
|
||||
}
|
||||
for key, value in expected.items():
|
||||
if health.get(key) != value:
|
||||
die(f"VPS tracker ingress health contract mismatch: {key}")
|
||||
tracker = health.get("tracker") or {}
|
||||
for key, value in {
|
||||
"adapter": "arusnavi-b2",
|
||||
"protocolProfile": "arusnavi.b2.internal.v1",
|
||||
"framing": "verified-read-only",
|
||||
"publicIngress": "telemetry-ingest",
|
||||
"commandTransport": "disabled",
|
||||
}.items():
|
||||
if tracker.get(key) != value:
|
||||
die(f"VPS tracker adapter health contract mismatch: {key}")
|
||||
for port in (22, CHANNEL_PUBLIC_PORT, 9921):
|
||||
if not port_is_open(PUBLIC_IPV4, port, timeout=5):
|
||||
die(f"VPS required public listener is unavailable: {port}")
|
||||
if not port_is_open("127.0.0.1", 18221, timeout=5):
|
||||
die("VPS tracker adapter health listener is unavailable")
|
||||
if port_is_open("127.0.0.1", 1055):
|
||||
die("VPS retired Tailscale SOCKS listener returned")
|
||||
nft = run(["/usr/sbin/nft", "list", "table", "inet", "nodedc_b2_vps"]).stdout
|
||||
for required in ("policy drop", "tcp dport 22", "tcp dport 443", "tcp dport 9921"):
|
||||
if required not in nft:
|
||||
die(f"VPS tracker ingress firewall contract mismatch: {required}")
|
||||
unit = run([
|
||||
"/usr/bin/systemctl",
|
||||
"show",
|
||||
"nodedc-device-edge-channel.service",
|
||||
"--property=User,Group,NoNewPrivileges,CapabilityBoundingSet,AmbientCapabilities,MemoryMax,MemorySwapMax,CPUQuotaPerSecUSec,TasksMax,LimitNOFILE",
|
||||
]).stdout
|
||||
for required in (
|
||||
"User=nodedc-channel",
|
||||
"Group=nodedc-channel",
|
||||
"NoNewPrivileges=yes",
|
||||
"CapabilityBoundingSet=cap_net_bind_service",
|
||||
"AmbientCapabilities=cap_net_bind_service",
|
||||
"MemoryMax=201326592",
|
||||
"MemorySwapMax=0",
|
||||
"TasksMax=128",
|
||||
"LimitNOFILE=1024",
|
||||
):
|
||||
if required not in unit:
|
||||
die(f"VPS tracker ingress resource boundary mismatch: {required}")
|
||||
return health
|
||||
|
||||
|
||||
def validate_relay_runtime():
|
||||
validate_backhaul_runtime()
|
||||
source_file_state("relay")
|
||||
@@ -1621,7 +1779,7 @@ def plan_artifact(artifact_argument: str):
|
||||
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")
|
||||
else:
|
||||
elif phase == "tailscale-retirement":
|
||||
print("public_core_channel=preserved:155.212.211.15:443/tcp:tls13-mtls-h2")
|
||||
print(f"health=preserved:127.0.0.1:{CHANNEL_HEALTH_PORT}")
|
||||
print("tailscale=stop+disable+destroy-local-runtime-state")
|
||||
@@ -1631,6 +1789,15 @@ def plan_artifact(artifact_argument: str):
|
||||
print("tracker_tcp_9921=closed")
|
||||
print("services=preserved:nodedc-device-edge-channel")
|
||||
print("external_tailnet_machine_cleanup=required-after-deploy-ok")
|
||||
else:
|
||||
print("public_core_channel=preserved:155.212.211.15:443/tcp:tls13-mtls-h2")
|
||||
print(f"health=127.0.0.1:{CHANNEL_HEALTH_PORT}:combined-edge-runtime")
|
||||
print("public_b2_ingress=155.212.211.15:9921/tcp:telemetry-only")
|
||||
print("tracker_adapter=arusnavi-b2:profile=arusnavi.b2.internal.v1")
|
||||
print("tracker_ack=after-core-durable-acceptance-only")
|
||||
print("runtime_composition=single-non-root-process:core-channel+universal-gateway")
|
||||
print("tailscale=preserved:absent")
|
||||
print("services=recreate:nodedc-device-edge-channel")
|
||||
print("command_transport=disabled")
|
||||
print("gelios=untouched")
|
||||
print("dns=unchanged")
|
||||
@@ -1666,8 +1833,10 @@ def apply_artifact(artifact_argument: str):
|
||||
apply_relay(loaded["payload"])
|
||||
elif loaded["phase"] == "core-channel":
|
||||
apply_core_channel(loaded["payload"])
|
||||
else:
|
||||
elif loaded["phase"] == "tailscale-retirement":
|
||||
apply_tailscale_retirement(loaded["payload"])
|
||||
else:
|
||||
apply_tracker_ingress(loaded["payload"])
|
||||
|
||||
archived = archive_artifact(loaded["artifact"], APPLIED_ROOT)
|
||||
record = {
|
||||
|
||||
@@ -99,6 +99,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
|
||||
"relay",
|
||||
"core-channel",
|
||||
"tailscale-retirement",
|
||||
"tracker-ingress",
|
||||
):
|
||||
with self.subTest(phase=phase), tempfile.TemporaryDirectory(
|
||||
prefix=f"nodedc-vps-{phase}-"
|
||||
@@ -178,6 +179,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
|
||||
"relay",
|
||||
"core-channel",
|
||||
"tailscale-retirement",
|
||||
"tracker-ingress",
|
||||
):
|
||||
result = self.build(
|
||||
inbox,
|
||||
@@ -361,6 +363,45 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
|
||||
self.assertIn("command_transport=disabled", rendered)
|
||||
self.assertIn("gelios=untouched", rendered)
|
||||
|
||||
def test_tracker_ingress_plan_is_single_process_bounded_and_command_free(self):
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-ingress-plan-") as directory:
|
||||
inbox = Path(directory) / "inbox"
|
||||
inbox.mkdir()
|
||||
result = self.build(
|
||||
inbox,
|
||||
"tracker-ingress",
|
||||
"device-edge-vps-tracker-ingress-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-tailscale-retirement-011"},
|
||||
), 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=tracker-ingress", rendered)
|
||||
self.assertIn("predecessor=accepted-tailscale-retirement-011", rendered)
|
||||
self.assertIn(
|
||||
"public_b2_ingress=155.212.211.15:9921/tcp:telemetry-only",
|
||||
rendered,
|
||||
)
|
||||
self.assertIn("tracker_adapter=arusnavi-b2", rendered)
|
||||
self.assertIn("tracker_ack=after-core-durable-acceptance-only", rendered)
|
||||
self.assertIn("runtime_composition=single-non-root-process", rendered)
|
||||
self.assertIn("tailscale=preserved:absent", rendered)
|
||||
self.assertIn("command_transport=disabled", rendered)
|
||||
self.assertIn("gelios=untouched", rendered)
|
||||
|
||||
def test_publish_payload_preserves_unselected_executable_modes(self):
|
||||
with tempfile.TemporaryDirectory(prefix="nodedc-vps-publish-scope-") as directory:
|
||||
root = Path(directory)
|
||||
@@ -435,6 +476,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
|
||||
foundation = (source_root / "vps/config/nftables-foundation.conf").read_text()
|
||||
relay = (source_root / "vps/config/nftables-relay.conf").read_text()
|
||||
channel = (source_root / "vps/config/nftables-core-channel.conf").read_text()
|
||||
tracker = (source_root / "vps/config/nftables-tracker-ingress.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()
|
||||
tailscale_unit = (
|
||||
@@ -447,6 +489,9 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
|
||||
channel_unit = (
|
||||
source_root / "vps/systemd/nodedc-device-edge-channel.service"
|
||||
).read_text()
|
||||
tracker_unit = (
|
||||
source_root / "vps/systemd/nodedc-device-edge-runtime.service"
|
||||
).read_text()
|
||||
|
||||
self.assertIn("policy drop", foundation)
|
||||
self.assertIn("tcp dport 22", foundation)
|
||||
@@ -454,6 +499,8 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
|
||||
self.assertIn("tcp dport 9921", relay)
|
||||
self.assertIn("tcp dport 443", channel)
|
||||
self.assertNotIn("tcp dport 9921", channel)
|
||||
self.assertIn("tcp dport 443", tracker)
|
||||
self.assertIn("tcp dport 9921", tracker)
|
||||
self.assertIn("PasswordAuthentication no", sshd)
|
||||
self.assertIn("AllowTcpForwarding no", sshd)
|
||||
self.assertIn("StrictHostKeyChecking yes", backhaul)
|
||||
@@ -490,6 +537,17 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase):
|
||||
self.assertIn("LimitNOFILE=1024", channel_unit)
|
||||
self.assertNotIn("LocalForward", channel_unit)
|
||||
self.assertNotIn("DEVICE_EDGE_RELAY_UPSTREAM", channel_unit)
|
||||
self.assertIn("User=nodedc-channel", tracker_unit)
|
||||
self.assertIn("vps/edge-process/device-edge-runtime.mjs", tracker_unit)
|
||||
self.assertIn("DEVICE_GATEWAY_TCP_PORT=9921", tracker_unit)
|
||||
self.assertIn("DEVICE_GATEWAY_MAX_SESSIONS=128", tracker_unit)
|
||||
self.assertIn("MemoryMax=192M", tracker_unit)
|
||||
self.assertIn("MemorySwapMax=0", tracker_unit)
|
||||
self.assertIn("CPUQuota=75%", tracker_unit)
|
||||
self.assertIn("TasksMax=128", tracker_unit)
|
||||
self.assertIn("LimitNOFILE=1024", tracker_unit)
|
||||
self.assertNotIn("LocalForward", tracker_unit)
|
||||
self.assertNotIn("DEVICE_EDGE_RELAY_UPSTREAM", tracker_unit)
|
||||
|
||||
def test_runner_has_registered_rollback_and_no_generic_latest(self):
|
||||
source = RUNNER_PATH.read_text(encoding="utf-8")
|
||||
|
||||
Reference in New Issue
Block a user