feat(device-edge): add isolated B2 ingress domain
This commit is contained in:
@@ -6,5 +6,7 @@ docs
|
||||
node_modules
|
||||
**/test
|
||||
**/*.log
|
||||
**/*.prev-*
|
||||
**/*.next-*
|
||||
runtime
|
||||
secrets
|
||||
|
||||
@@ -28,6 +28,18 @@ The planned runtime services are:
|
||||
- `device-gateway`: raw TCP sessions, bounded codecs and presence;
|
||||
- `device-postgres`: private persistent state.
|
||||
|
||||
`services/device-edge-relay` is a separate, disabled-by-default runtime for the
|
||||
dedicated Debian ingress host. It is an L4 byte relay: it neither decodes
|
||||
ARUSNAVI B2, acknowledges a device nor holds a Core token, database credential
|
||||
or command capability. The implemented backhaul forwards only through a
|
||||
restricted outbound SSH path to the Synology Gateway loopback listener. The
|
||||
reviewed ingress target gives only this container a fixed IPvlan L2 address on
|
||||
the Mini's single Ethernet link, so the host Amnezia full tunnel stays intact
|
||||
and Docker publishes no host port. The fixed relay address is
|
||||
`192.168.71.253`, explicitly outside the documented Deco DHCP pool
|
||||
`192.168.68.50`–`192.168.71.250`. See `docs/IMPLEMENTATION_BASELINE.md` for
|
||||
the remaining activation gates.
|
||||
|
||||
The Foundry `Device Manager` is a canonical page template using a server-owned
|
||||
`device-plane-control` binding. It is not a service in this directory.
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-edge.backhaul.v1",
|
||||
"mode": "private-tailnet-ssh-local-forward",
|
||||
"runtimeHost": "ndcmini12",
|
||||
"selectedServices": [
|
||||
"device-edge-backhaul"
|
||||
],
|
||||
"preservedServices": [
|
||||
"device-edge-relay",
|
||||
"tailnet"
|
||||
],
|
||||
"tailnetSocksTarget": "nodedc-device-edge-tailnet-1:1055",
|
||||
"sshTarget": "100.109.216.21:2222",
|
||||
"sshUser": "device-backhaul",
|
||||
"localForward": "0.0.0.0:19921",
|
||||
"permittedRemoteTarget": "127.0.0.1:9921",
|
||||
"hostPortPublication": "disabled",
|
||||
"deviceIngress": "disabled",
|
||||
"protocolInspection": "disabled",
|
||||
"commandTransport": "disabled",
|
||||
"privateKey": "runtime-only-read-only",
|
||||
"knownHosts": "runner-prepared-exact-ed25519",
|
||||
"routerNatFirewall": "unchanged",
|
||||
"gelios": "untouched"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-edge.ingress-ipvlan.v1",
|
||||
"mode": "single-nic-ipvlan-b2-relay-only",
|
||||
"runtimeHost": "ndcmini12",
|
||||
"component": "device-edge",
|
||||
"selectedServices": [
|
||||
"device-edge-relay"
|
||||
],
|
||||
"preservedServices": [
|
||||
"device-edge-backhaul",
|
||||
"tailnet"
|
||||
],
|
||||
"composeProject": "nodedc-device-edge",
|
||||
"composeFiles": [
|
||||
"docker-compose.device-edge.yml",
|
||||
"docker-compose.device-edge.ingress.yml"
|
||||
],
|
||||
"parentInterface": "enp1s0f0",
|
||||
"lanSubnet": "192.168.68.0/22",
|
||||
"lanGateway": "192.168.68.1",
|
||||
"ingressIpv4": "192.168.71.253",
|
||||
"ingressIpv4Approval": "approved-outside-dhcp-pool",
|
||||
"ingressNetwork": "nodedc-device-edge-ingress",
|
||||
"deviceTcpListen": "192.168.71.253:9921",
|
||||
"hostPortPublication": "disabled",
|
||||
"healthPublication": "disabled",
|
||||
"privateUpstream": "device-edge-backhaul:19921",
|
||||
"protocolInspection": "gateway-owned",
|
||||
"identityTrust": "claimed-not-ownership-proof",
|
||||
"discoveryLifecycle": "quarantine",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"amneziaHostFullTunnel": "preserved",
|
||||
"routerNatFirewall": "separate-manual-gate",
|
||||
"rollback": "restore-predecessor-relay-remove-unused-ingress-network"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.b2-discovery-loopback-recovery.v1",
|
||||
"mode": "failed-b2-loopback-build-reconciliation",
|
||||
"failedPatchId": "device-plane-b2-discovery-loopback-20260801-003",
|
||||
"failedArtifactSha256": "7273c5bf67fe6bc1f1da66ad726009240d39ee3aee58201b96c23d6f707a3d84",
|
||||
"failedBackupId": "device-plane-device-plane-b2-discovery-loopback-20260801-003-20260802-154311",
|
||||
"sourceAction": "publish-reconciliation-marker-only",
|
||||
"runtimeAction": "read-only-acceptance",
|
||||
"preservedServices": [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres"
|
||||
],
|
||||
"expectedLoopbackPorts": [
|
||||
"127.0.0.1:18120:18120",
|
||||
"127.0.0.1:18121:18121"
|
||||
],
|
||||
"closedPort": "127.0.0.1:9921/tcp",
|
||||
"databaseVolume": "nodedc-device-plane-postgres-data",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"rollback": "marker-only-runtime-unchanged"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"schemaVersion": "nodedc.device-plane.backhaul-target-tailnet-serve.v1",
|
||||
"mode": "failed-backhaul-target-to-loopback-tailnet-serve",
|
||||
"failedPatchId": "device-plane-backhaul-target-20260803-001",
|
||||
"failedArtifactSha256": "ed0bda4110a756c32be68990e2e0f647409d5a77eec7e26c18502bafbdc1bb76",
|
||||
"failedBackupId": "device-plane-device-plane-backhaul-target-20260803-001-20260804-035519",
|
||||
"predecessorPatchId": "device-plane-b2-discovery-loopback-20260803-006",
|
||||
"predecessorArtifactSha256": "25f9e9e55e283e9b7bb5e128ff14a244f848b1c063acca9724a23206131c9adf",
|
||||
"sourceAction": "publish-loopback-backhaul-target-source",
|
||||
"runtimeAction": "build-create-target-and-register-private-tailnet-serve",
|
||||
"composeOverlay": "docker-compose.device-plane.backhaul-target.yml",
|
||||
"selectedServices": [
|
||||
"device-backhaul-target"
|
||||
],
|
||||
"preservedServices": [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres"
|
||||
],
|
||||
"loopbackListenAddress": "127.0.0.1",
|
||||
"listenPort": 2222,
|
||||
"tailnetAddress": "100.109.216.21",
|
||||
"tailnetExposure": "tailscale-serve-private",
|
||||
"tailscaleServeTarget": "tcp://127.0.0.1:2222",
|
||||
"permittedTarget": "127.0.0.1:9921",
|
||||
"networkMode": "host",
|
||||
"dockerPortPublication": "disabled",
|
||||
"routerNatFirewall": "unchanged",
|
||||
"edgePublicIngress": "disabled",
|
||||
"funnel": "disabled",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"databaseVolume": "nodedc-device-plane-postgres-data",
|
||||
"runtimeTrust": "runner-managed",
|
||||
"rollback": "remove-tailnet-serve-target-and-restore-source"
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
services:
|
||||
device-edge-backhaul:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: services/device-edge-backhaul/Dockerfile
|
||||
image: nodedc/device-edge-backhaul:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=8m,mode=1777
|
||||
volumes:
|
||||
- ../secrets/backhaul/id_ed25519:/run/keys/edge-to-synology:ro
|
||||
- ../trust/synology-backhaul-known_hosts:/run/trust/known_hosts:ro
|
||||
command:
|
||||
- -N
|
||||
- -T
|
||||
- -p
|
||||
- "2222"
|
||||
- -i
|
||||
- /run/keys/edge-to-synology
|
||||
- -L
|
||||
- 0.0.0.0:19921:127.0.0.1:9921
|
||||
- -o
|
||||
- BatchMode=yes
|
||||
- -o
|
||||
- PasswordAuthentication=no
|
||||
- -o
|
||||
- KbdInteractiveAuthentication=no
|
||||
- -o
|
||||
- PubkeyAuthentication=yes
|
||||
- -o
|
||||
- IdentitiesOnly=yes
|
||||
- -o
|
||||
- StrictHostKeyChecking=yes
|
||||
- -o
|
||||
- UserKnownHostsFile=/run/trust/known_hosts
|
||||
- -o
|
||||
- UpdateHostKeys=no
|
||||
- -o
|
||||
- ExitOnForwardFailure=yes
|
||||
- -o
|
||||
- ServerAliveInterval=15
|
||||
- -o
|
||||
- ServerAliveCountMax=3
|
||||
- -o
|
||||
- TCPKeepAlive=yes
|
||||
- -o
|
||||
- LogLevel=VERBOSE
|
||||
- -o
|
||||
- ProxyCommand=nc -X 5 -x nodedc-device-edge-tailnet-1:1055 %h %p
|
||||
- device-backhaul@100.109.216.21
|
||||
networks:
|
||||
- device-edge-private
|
||||
- device-edge-tailnet
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
pids_limit: 32
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- nc
|
||||
- -z
|
||||
- -w
|
||||
- "3"
|
||||
- 127.0.0.1
|
||||
- "19921"
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
device-edge-private:
|
||||
name: nodedc-device-edge-private
|
||||
external: true
|
||||
device-edge-tailnet:
|
||||
name: nodedc-device-edge-tailnet
|
||||
external: true
|
||||
@@ -0,0 +1,32 @@
|
||||
services:
|
||||
device-edge-relay:
|
||||
environment:
|
||||
DEVICE_EDGE_RELAY_INGRESS_ENABLED: "true"
|
||||
DEVICE_EDGE_RELAY_TCP_HOST: 0.0.0.0
|
||||
DEVICE_EDGE_RELAY_TCP_PORT: "9921"
|
||||
DEVICE_EDGE_RELAY_UPSTREAM_HOST: device-edge-backhaul
|
||||
DEVICE_EDGE_RELAY_UPSTREAM_PORT: "19921"
|
||||
DEVICE_EDGE_RELAY_MAX_SESSIONS: "100"
|
||||
DEVICE_EDGE_RELAY_MAX_SESSIONS_PER_ADDRESS: "10"
|
||||
DEVICE_EDGE_RELAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS: "30"
|
||||
DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS: "10000"
|
||||
networks:
|
||||
device-edge-private:
|
||||
gw_priority: 0
|
||||
device-edge-ingress:
|
||||
ipv4_address: 192.168.71.253
|
||||
gw_priority: 100
|
||||
|
||||
networks:
|
||||
device-edge-private:
|
||||
name: nodedc-device-edge-private
|
||||
device-edge-ingress:
|
||||
name: nodedc-device-edge-ingress
|
||||
driver: ipvlan
|
||||
driver_opts:
|
||||
parent: enp1s0f0
|
||||
ipvlan_mode: l2
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 192.168.68.0/22
|
||||
gateway: 192.168.68.1
|
||||
@@ -0,0 +1,42 @@
|
||||
services:
|
||||
device-edge-relay:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: services/device-edge-relay/Dockerfile
|
||||
image: nodedc/device-edge-relay:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
DEVICE_EDGE_RELAY_HEALTH_HOST: 127.0.0.1
|
||||
DEVICE_EDGE_RELAY_HEALTH_PORT: "18221"
|
||||
DEVICE_EDGE_RELAY_INGRESS_ENABLED: "false"
|
||||
DEVICE_EDGE_RELAY_TCP_PORT: "9921"
|
||||
DEVICE_EDGE_RELAY_MAX_SESSIONS: "100"
|
||||
DEVICE_EDGE_RELAY_MAX_SESSIONS_PER_ADDRESS: "10"
|
||||
DEVICE_EDGE_RELAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS: "30"
|
||||
DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS: "10000"
|
||||
networks:
|
||||
device-edge-private: {}
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18221/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
device-edge-private:
|
||||
name: nodedc-device-edge-private
|
||||
internal: true
|
||||
@@ -0,0 +1,39 @@
|
||||
services:
|
||||
device-backhaul-target:
|
||||
image: nodedc/device-backhaul-target:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /run:size=8m,mode=0755
|
||||
- /tmp:size=8m,mode=1777
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/backhaul-target/ssh_host_ed25519_key
|
||||
target: /run/nodedc-secrets/ssh_host_ed25519_key
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/backhaul-target/authorized_keys
|
||||
target: /run/nodedc-secrets/authorized_keys
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- CHOWN
|
||||
- DAC_OVERRIDE
|
||||
- SETGID
|
||||
- SETUID
|
||||
- SYS_CHROOT
|
||||
healthcheck:
|
||||
test: ["CMD", "nc", "-z", "-w", "3", "127.0.0.1", "2222"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
@@ -27,6 +27,26 @@ Device Control Core <-> Device PostgreSQL
|
||||
Device Gateway <-> physical devices
|
||||
```
|
||||
|
||||
The isolated ingress placement replaces the direct physical-device arrow when
|
||||
the raw route must not terminate on the multi-service Synology:
|
||||
|
||||
```text
|
||||
ARUSNAVI B2 device
|
||||
|
|
||||
| raw TCP 9921 (future, separately approved)
|
||||
v
|
||||
Device Edge Relay on dedicated mini
|
||||
|
|
||||
| outbound restricted SSH local-forward; opaque byte stream only
|
||||
v
|
||||
Synology loopback 127.0.0.1:9921 -> Device Gateway -> Device Control Core
|
||||
```
|
||||
|
||||
The Edge Relay owns neither protocol acknowledgement nor device identity. It
|
||||
does not receive the Gateway/Core token, PostgreSQL credentials, Foundry
|
||||
bindings or any command capability. The Synology Gateway remains the sole B2
|
||||
codec and acknowledgement owner.
|
||||
|
||||
Engine L2 may consume safe decoded observations and build workflows/Data
|
||||
Products. It does not own TCP sessions, secrets or the command transport.
|
||||
|
||||
@@ -50,7 +70,9 @@ platform/device-plane/
|
||||
packages/arusnavi-b2-adapter/
|
||||
services/device-control-core/
|
||||
services/device-gateway/
|
||||
services/device-edge-relay/
|
||||
docker-compose.device-plane.yml
|
||||
docker-compose.device-edge.yml
|
||||
```
|
||||
|
||||
Planned Synology runtime:
|
||||
@@ -99,6 +121,41 @@ as `443 -> 9921`.
|
||||
The artifact never changes DSM firewall, DSM Router Configuration, DNS or a
|
||||
physical router.
|
||||
|
||||
### Dedicated mini Device Edge
|
||||
|
||||
The Debian mini is the isolated raw-TCP edge. Its accepted predecessor keeps the
|
||||
relay disabled and publishes health only on `127.0.0.1:18221`. The reviewed
|
||||
target removes even that host publication: health remains container-internal,
|
||||
the relay stays on the `internal: true` private bridge for backhaul, and a
|
||||
second IPvlan L2 attachment gives only the relay a LAN-routable address for
|
||||
`9921/TCP`. The relay has bounded global/per-address sessions and connection
|
||||
rate, emits no bytes of its own and does not inspect device payloads.
|
||||
|
||||
IPvlan deliberately reuses the Mini's one physical parent `enp1s0f0`; a second
|
||||
Ethernet adapter is not required. The host keeps `192.168.68.54/22` and the
|
||||
Amnezia `0.0.0.0/1` plus `128.0.0.0/1` routes. The relay has its own fixed LAN
|
||||
IPv4 and default route through `192.168.68.1`, while its private connected route
|
||||
continues to reach `device-edge-backhaul:19921`. No Docker host `ports:` entry,
|
||||
host-network mode, privileged container or VPN teardown is allowed.
|
||||
|
||||
Enabling ingress is a separate reviewed operation and requires all of the
|
||||
following evidence:
|
||||
|
||||
1. A distinct, no-shell Synology SSH account and key whose sole permitted open
|
||||
target is `127.0.0.1:9921`; host-key pinning and a persistent, monitored
|
||||
tunnel are required.
|
||||
2. A private backhaul sidecar/network; the raw listener may forward only to
|
||||
that tunnel. The Core token and all Core/Database secrets remain on
|
||||
Synology.
|
||||
3. Router evidence proving the fixed relay IPv4 is outside DHCP, followed by a
|
||||
manual router/NAT rule mapping only TCP `9921` to that IPv4. The artifact
|
||||
cannot choose an address and never changes router, firewall or DHCP state.
|
||||
4. The host full-tunnel VPN remains active. Before production activation, the
|
||||
exact single-NIC IPvlan design must pass duplicate-address detection,
|
||||
gateway reachability, external return-path and private-backhaul checks.
|
||||
5. One pre-authorized B2 pilot route, quarantine-only Gateway/Core ingest and
|
||||
disabled command transport.
|
||||
|
||||
## Identity and onboarding
|
||||
|
||||
An IMEI is a claimed protocol identifier, not proof of tenant ownership.
|
||||
@@ -185,20 +242,21 @@ raw TCP remain forbidden until separate reviewed acceptance slices.
|
||||
absence preflight: an existing database container or volume fails closed,
|
||||
and rollback never removes the volume.
|
||||
|
||||
## Next source slice
|
||||
## Next activation slice
|
||||
|
||||
1. Add the versioned B2 discovery-ingress descriptor and runner acceptance
|
||||
contract without weakening foundation/recovery acceptance.
|
||||
2. Build a deterministic data-only app-overlay and promote the runner extension
|
||||
through the standalone root administrative gate.
|
||||
3. Plan/apply the exact discovery-ingress artifact and independently prove:
|
||||
Core/Gateway/PostgreSQL health, PostgreSQL generation preservation,
|
||||
`127.0.0.1:9921` loopback-only publication, verified framing,
|
||||
quarantine-only Core ingest and disabled command transport.
|
||||
4. Do not publish raw device TCP from the multi-service Synology. Design and
|
||||
independently review an isolated edge/DMZ/VPS ingress with no lateral NAS
|
||||
access, explicit firewall/NAT evidence, acceptance and rollback.
|
||||
5. Only after that separate edge gate, add the NDC route to one approved B2
|
||||
free server slot while preserving Gelios, then prove
|
||||
HEADER/discovery/PACKAGE acknowledgement. Claim and tenant assignment
|
||||
remain a later explicit platform-admin operation.
|
||||
1. The Deco DHCP range has been recorded as `192.168.68.50` through
|
||||
`192.168.71.250`; the fixed Relay IPv4 is `192.168.71.253`, outside that
|
||||
pool and independently DAD-tested. It is pinned in Compose, descriptor,
|
||||
builder and the separate Edge runner.
|
||||
2. Build the deterministic `component=device-edge` artifact, promote the
|
||||
root-owned Edge runner and review its `plan`. The Synology runner and inbox
|
||||
are not used for this host.
|
||||
3. Apply only `device-edge-relay`; prove exact IPvlan runtime, no host ports,
|
||||
internal health, private backhaul reachability, unchanged backhaul/tailnet
|
||||
identities and preserved Amnezia routes. Automatic rollback restores the
|
||||
predecessor relay and removes the unused ingress network.
|
||||
4. Independently add the single router/NAT rule for TCP `9921` only and verify
|
||||
that Synology still exposes no public device port.
|
||||
5. Add the NDC route to one approved B2 free server slot while preserving
|
||||
Gelios, then prove HEADER/discovery/PACKAGE acknowledgement. Claim and tenant
|
||||
assignment remain a later explicit platform-admin operation.
|
||||
|
||||
Generated
+11
@@ -23,6 +23,10 @@
|
||||
"resolved": "services/device-control-core",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@nodedc/device-edge-relay": {
|
||||
"resolved": "services/device-edge-relay",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@nodedc/device-gateway": {
|
||||
"resolved": "services/device-gateway",
|
||||
"link": true
|
||||
@@ -201,6 +205,13 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"services/device-edge-relay": {
|
||||
"name": "@nodedc/device-edge-relay",
|
||||
"version": "0.1.0",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"services/device-gateway": {
|
||||
"name": "@nodedc/device-gateway",
|
||||
"version": "0.1.0",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM alpine:3.22
|
||||
|
||||
RUN apk add --no-cache openssh-server \
|
||||
&& addgroup -g 11021 device-backhaul \
|
||||
&& adduser -D -H -u 11021 -G device-backhaul -s /sbin/nologin device-backhaul \
|
||||
&& passwd -u device-backhaul
|
||||
|
||||
COPY services/device-backhaul-target/sshd_config /etc/ssh/sshd_config
|
||||
|
||||
EXPOSE 2222/tcp
|
||||
|
||||
CMD ["sh", "-c", "mkdir -p /run/sshd && exec /usr/sbin/sshd -D -e -f /etc/ssh/sshd_config"]
|
||||
@@ -0,0 +1,35 @@
|
||||
Port 2222
|
||||
ListenAddress 127.0.0.1
|
||||
AddressFamily inet
|
||||
Protocol 2
|
||||
|
||||
HostKey /run/nodedc-secrets/ssh_host_ed25519_key
|
||||
AuthorizedKeysFile /run/nodedc-secrets/authorized_keys
|
||||
StrictModes no
|
||||
|
||||
PermitRootLogin no
|
||||
PasswordAuthentication no
|
||||
KbdInteractiveAuthentication no
|
||||
PubkeyAuthentication yes
|
||||
AuthenticationMethods publickey
|
||||
AllowUsers device-backhaul
|
||||
|
||||
AllowTcpForwarding local
|
||||
PermitOpen 127.0.0.1:9921
|
||||
GatewayPorts no
|
||||
PermitTunnel no
|
||||
AllowAgentForwarding no
|
||||
X11Forwarding no
|
||||
PermitTTY no
|
||||
PermitUserEnvironment no
|
||||
ForceCommand /bin/false
|
||||
|
||||
ClientAliveInterval 30
|
||||
ClientAliveCountMax 3
|
||||
LoginGraceTime 20
|
||||
MaxAuthTries 3
|
||||
MaxSessions 4
|
||||
MaxStartups 4:30:8
|
||||
UseDNS no
|
||||
PrintMotd no
|
||||
LogLevel VERBOSE
|
||||
@@ -3,9 +3,11 @@ FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages ./packages
|
||||
COPY packages/device-protocol-contract ./packages/device-protocol-contract
|
||||
COPY packages/arusnavi-b2-adapter ./packages/arusnavi-b2-adapter
|
||||
COPY services/device-control-core ./services/device-control-core
|
||||
COPY services/device-gateway/package.json ./services/device-gateway/package.json
|
||||
COPY services/device-edge-relay/package.json ./services/device-edge-relay/package.json
|
||||
|
||||
RUN npm ci --omit=dev --ignore-scripts
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM alpine:3.22
|
||||
|
||||
RUN apk add --no-cache openssh-client-default netcat-openbsd \
|
||||
&& addgroup -g 1000 edge-backhaul \
|
||||
&& adduser -D -H -u 1000 -G edge-backhaul -s /sbin/nologin edge-backhaul
|
||||
|
||||
USER 1000:1000
|
||||
|
||||
ENTRYPOINT ["/usr/bin/ssh"]
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const devicePlane = resolve(here, "../../..");
|
||||
|
||||
test("edge backhaul is key-only, pinned and never host-published", async () => {
|
||||
const compose = await readFile(
|
||||
resolve(devicePlane, "docker-compose.device-edge.backhaul.yml"),
|
||||
"utf8",
|
||||
);
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(devicePlane, "deployment/device-edge-backhaul-v1.json"),
|
||||
"utf8",
|
||||
));
|
||||
|
||||
assert.match(compose, /StrictHostKeyChecking=yes/);
|
||||
assert.match(compose, /UserKnownHostsFile=\/run\/trust\/known_hosts/);
|
||||
assert.match(compose, /PasswordAuthentication=no/);
|
||||
assert.match(compose, /KbdInteractiveAuthentication=no/);
|
||||
assert.match(compose, /ExitOnForwardFailure=yes/);
|
||||
assert.match(compose, /ProxyCommand=nc -X 5 -x nodedc-device-edge-tailnet-1:1055/);
|
||||
assert.match(compose, /\.\.\/secrets\/backhaul\/id_ed25519/);
|
||||
assert.doesNotMatch(compose, /\.\.\/keys\/edge-to-synology/);
|
||||
assert.match(compose, /0\.0\.0\.0:19921:127\.0\.0\.1:9921/);
|
||||
assert.doesNotMatch(compose, /^\s+ports:/m);
|
||||
assert.doesNotMatch(compose, /0\.0\.0\.0:9921/);
|
||||
assert.equal(descriptor.hostPortPublication, "disabled");
|
||||
assert.equal(descriptor.deviceIngress, "disabled");
|
||||
assert.equal(descriptor.commandTransport, "disabled");
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY services/device-edge-relay/src ./src
|
||||
|
||||
USER node
|
||||
|
||||
CMD ["node", "src/server.mjs"]
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@nodedc/device-edge-relay",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/server.mjs",
|
||||
"test": "node --test test/*.test.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { createServer as createHttpServer } from "node:http";
|
||||
import { connect, createServer as createTcpServer } from "node:net";
|
||||
|
||||
export function createDeviceEdgeRelayRuntime(options = {}) {
|
||||
const config = normalizeConfig(options);
|
||||
const sessions = new Map();
|
||||
const sessionsByAddress = new Map();
|
||||
const connectionWindows = new Map();
|
||||
let totalAccepted = 0;
|
||||
let totalRejected = 0;
|
||||
let totalForwarded = 0;
|
||||
|
||||
const tcpServer = createTcpServer({ allowHalfOpen: true }, (socket) => {
|
||||
const remoteAddress = normalizeRemoteAddress(socket.remoteAddress);
|
||||
if (
|
||||
sessions.size >= config.maxConcurrentSessions
|
||||
|| currentAddressSessions(remoteAddress) >= config.maxSessionsPerAddress
|
||||
|| !consumeConnectionPermit(remoteAddress)
|
||||
) {
|
||||
totalRejected += 1;
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const session = {
|
||||
remoteAddress,
|
||||
socket,
|
||||
upstream: null,
|
||||
closed: false,
|
||||
forwarded: false,
|
||||
};
|
||||
sessions.set(socket, session);
|
||||
incrementAddressSessions(remoteAddress);
|
||||
totalAccepted += 1;
|
||||
|
||||
socket.setNoDelay(true);
|
||||
socket.setTimeout(config.sessionTimeoutMs);
|
||||
socket.pause();
|
||||
socket.on("timeout", () => rejectSession(session));
|
||||
socket.on("close", () => closeSession(session));
|
||||
socket.on("error", () => rejectSession(session));
|
||||
|
||||
const upstream = connect({
|
||||
host: config.upstreamHost,
|
||||
port: config.upstreamPort,
|
||||
});
|
||||
session.upstream = upstream;
|
||||
upstream.setNoDelay(true);
|
||||
upstream.setTimeout(config.sessionTimeoutMs);
|
||||
upstream.on("connect", () => {
|
||||
if (session.closed) {
|
||||
upstream.destroy();
|
||||
return;
|
||||
}
|
||||
session.forwarded = true;
|
||||
totalForwarded += 1;
|
||||
socket.pipe(upstream);
|
||||
upstream.pipe(socket);
|
||||
socket.resume();
|
||||
});
|
||||
upstream.on("timeout", () => rejectSession(session));
|
||||
upstream.on("error", () => rejectSession(session));
|
||||
upstream.on("close", () => closeSession(session));
|
||||
});
|
||||
|
||||
const healthServer = createHttpServer((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('{"ok":false,"error":"device_edge_relay_route_not_found"}\n');
|
||||
return;
|
||||
}
|
||||
response.statusCode = 200;
|
||||
response.end(`${JSON.stringify({
|
||||
ok: true,
|
||||
service: "nodedc-device-edge-relay",
|
||||
ingress: config.ingressEnabled ? "relay-only" : "disabled",
|
||||
protocolInspection: "disabled",
|
||||
commandTransport: "disabled",
|
||||
sessions: {
|
||||
active: sessions.size,
|
||||
accepted: totalAccepted,
|
||||
rejected: totalRejected,
|
||||
forwarded: totalForwarded,
|
||||
},
|
||||
})}\n`);
|
||||
});
|
||||
|
||||
return {
|
||||
async start() {
|
||||
await listen(healthServer, config.healthPort, config.healthHost);
|
||||
if (config.ingressEnabled) {
|
||||
await listen(tcpServer, config.tcpPort, config.tcpHost);
|
||||
}
|
||||
return {
|
||||
healthAddress: healthServer.address(),
|
||||
tcpAddress: config.ingressEnabled ? tcpServer.address() : null,
|
||||
};
|
||||
},
|
||||
async stop() {
|
||||
for (const session of sessions.values()) rejectSession(session);
|
||||
await Promise.all([
|
||||
closeServer(healthServer),
|
||||
config.ingressEnabled ? closeServer(tcpServer) : Promise.resolve(),
|
||||
]);
|
||||
},
|
||||
status() {
|
||||
return {
|
||||
activeSessions: sessions.size,
|
||||
totalAccepted,
|
||||
totalRejected,
|
||||
totalForwarded,
|
||||
ingress: config.ingressEnabled ? "relay-only" : "disabled",
|
||||
protocolInspection: "disabled",
|
||||
commandTransport: "disabled",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function currentAddressSessions(remoteAddress) {
|
||||
return sessionsByAddress.get(remoteAddress) || 0;
|
||||
}
|
||||
|
||||
function incrementAddressSessions(remoteAddress) {
|
||||
sessionsByAddress.set(
|
||||
remoteAddress,
|
||||
currentAddressSessions(remoteAddress) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
function decrementAddressSessions(remoteAddress) {
|
||||
const current = currentAddressSessions(remoteAddress);
|
||||
if (current <= 1) {
|
||||
sessionsByAddress.delete(remoteAddress);
|
||||
} else {
|
||||
sessionsByAddress.set(remoteAddress, current - 1);
|
||||
}
|
||||
}
|
||||
|
||||
function consumeConnectionPermit(remoteAddress) {
|
||||
const nowMs = config.now().getTime();
|
||||
const current = connectionWindows.get(remoteAddress);
|
||||
if (!current || nowMs - current.startedAt >= 60_000) {
|
||||
connectionWindows.set(remoteAddress, { startedAt: nowMs, count: 1 });
|
||||
return true;
|
||||
}
|
||||
if (current.count >= config.maxConnectionsPerMinutePerAddress) return false;
|
||||
current.count += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
function rejectSession(session) {
|
||||
if (!session.closed) totalRejected += 1;
|
||||
session.socket.destroy();
|
||||
session.upstream?.destroy();
|
||||
closeSession(session);
|
||||
}
|
||||
|
||||
function closeSession(session) {
|
||||
if (session.closed) return;
|
||||
session.closed = true;
|
||||
sessions.delete(session.socket);
|
||||
decrementAddressSessions(session.remoteAddress);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConfig(input) {
|
||||
const ingressEnabled = input.ingressEnabled === true;
|
||||
return {
|
||||
ingressEnabled,
|
||||
healthHost: normalizeHost(input.healthHost, "127.0.0.1"),
|
||||
healthPort: parseInteger(
|
||||
input.healthPort,
|
||||
18221,
|
||||
0,
|
||||
65535,
|
||||
"device_edge_relay_health_port_invalid",
|
||||
),
|
||||
tcpHost: normalizeTcpHost(input.tcpHost, ingressEnabled),
|
||||
tcpPort: parseInteger(
|
||||
input.tcpPort,
|
||||
9921,
|
||||
0,
|
||||
65535,
|
||||
"device_edge_relay_tcp_port_invalid",
|
||||
),
|
||||
upstreamHost: ingressEnabled
|
||||
? normalizeUpstreamHost(input.upstreamHost)
|
||||
: "disabled",
|
||||
upstreamPort: ingressEnabled
|
||||
? parseInteger(
|
||||
input.upstreamPort,
|
||||
undefined,
|
||||
1,
|
||||
65535,
|
||||
"device_edge_relay_upstream_port_invalid",
|
||||
)
|
||||
: 0,
|
||||
maxConcurrentSessions: parseInteger(
|
||||
input.maxConcurrentSessions,
|
||||
100,
|
||||
1,
|
||||
10000,
|
||||
"device_edge_relay_session_limit_invalid",
|
||||
),
|
||||
maxSessionsPerAddress: parseInteger(
|
||||
input.maxSessionsPerAddress,
|
||||
10,
|
||||
1,
|
||||
1000,
|
||||
"device_edge_relay_address_session_limit_invalid",
|
||||
),
|
||||
maxConnectionsPerMinutePerAddress: parseInteger(
|
||||
input.maxConnectionsPerMinutePerAddress,
|
||||
30,
|
||||
1,
|
||||
10000,
|
||||
"device_edge_relay_connection_rate_invalid",
|
||||
),
|
||||
sessionTimeoutMs: parseInteger(
|
||||
input.sessionTimeoutMs,
|
||||
10000,
|
||||
100,
|
||||
60000,
|
||||
"device_edge_relay_session_timeout_invalid",
|
||||
),
|
||||
now: typeof input.now === "function" ? input.now : () => new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHost(value, fallback) {
|
||||
const normalized = String(value || fallback).trim();
|
||||
if (!["127.0.0.1", "::1", "0.0.0.0", "::"].includes(normalized)) {
|
||||
throw new TypeError("device_edge_relay_health_host_invalid");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeTcpHost(value, ingressEnabled) {
|
||||
const fallback = ingressEnabled ? "0.0.0.0" : "127.0.0.1";
|
||||
const normalized = String(value || fallback).trim();
|
||||
const allowed = ingressEnabled ? ["0.0.0.0", "::"] : ["127.0.0.1", "::1"];
|
||||
if (!allowed.includes(normalized)) {
|
||||
throw new TypeError(
|
||||
ingressEnabled
|
||||
? "device_edge_relay_public_ingress_host_invalid"
|
||||
: "device_edge_relay_baseline_loopback_only",
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeUpstreamHost(value) {
|
||||
const normalized = String(value || "").trim();
|
||||
if (
|
||||
normalized.length === 0
|
||||
|| normalized.length > 253
|
||||
|| /[/:\\s]/.test(normalized)
|
||||
) {
|
||||
throw new TypeError("device_edge_relay_upstream_host_invalid");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeRemoteAddress(value) {
|
||||
const normalized = String(value || "unknown").trim();
|
||||
return normalized.slice(0, 64) || "unknown";
|
||||
}
|
||||
|
||||
function parseInteger(value, fallback, minimum, maximum, errorCode) {
|
||||
const parsed = Number(value ?? fallback);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
||||
throw new TypeError(errorCode);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
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, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createDeviceEdgeRelayRuntime } from "./runtime.mjs";
|
||||
|
||||
const runtime = createDeviceEdgeRelayRuntime({
|
||||
ingressEnabled: parseBoolean(
|
||||
process.env.DEVICE_EDGE_RELAY_INGRESS_ENABLED,
|
||||
false,
|
||||
),
|
||||
healthHost: process.env.DEVICE_EDGE_RELAY_HEALTH_HOST || "127.0.0.1",
|
||||
healthPort: parsePort(process.env.DEVICE_EDGE_RELAY_HEALTH_PORT, 18221),
|
||||
tcpHost: process.env.DEVICE_EDGE_RELAY_TCP_HOST,
|
||||
tcpPort: parsePort(process.env.DEVICE_EDGE_RELAY_TCP_PORT, 9921),
|
||||
upstreamHost: process.env.DEVICE_EDGE_RELAY_UPSTREAM_HOST,
|
||||
upstreamPort: parsePort(
|
||||
process.env.DEVICE_EDGE_RELAY_UPSTREAM_PORT,
|
||||
undefined,
|
||||
),
|
||||
maxConcurrentSessions: parsePositiveInt(
|
||||
process.env.DEVICE_EDGE_RELAY_MAX_SESSIONS,
|
||||
100,
|
||||
),
|
||||
maxSessionsPerAddress: parsePositiveInt(
|
||||
process.env.DEVICE_EDGE_RELAY_MAX_SESSIONS_PER_ADDRESS,
|
||||
10,
|
||||
),
|
||||
maxConnectionsPerMinutePerAddress: parsePositiveInt(
|
||||
process.env.DEVICE_EDGE_RELAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS,
|
||||
30,
|
||||
),
|
||||
sessionTimeoutMs: parsePositiveInt(
|
||||
process.env.DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS,
|
||||
10000,
|
||||
),
|
||||
});
|
||||
|
||||
const addresses = await runtime.start();
|
||||
console.log(JSON.stringify({
|
||||
event: "device_edge_relay_started",
|
||||
health: addresses.healthAddress,
|
||||
tcp: addresses.tcpAddress,
|
||||
ingress: runtime.status().ingress,
|
||||
protocolInspection: "disabled",
|
||||
commandTransport: "disabled",
|
||||
}));
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
async function shutdown() {
|
||||
await runtime.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function parsePort(value, fallback) {
|
||||
if (value === undefined && fallback === undefined) return undefined;
|
||||
const parsed = Number(value ?? fallback);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) {
|
||||
throw new Error("device_edge_relay_port_invalid");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parsePositiveInt(value, fallback) {
|
||||
const parsed = Number(value || fallback);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
||||
throw new Error("device_edge_relay_positive_integer_invalid");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseBoolean(value, fallback) {
|
||||
if (value === undefined || value === null || value === "") return fallback;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
||||
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
||||
throw new Error("device_edge_relay_boolean_invalid");
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const devicePlaneRoot = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../../..",
|
||||
);
|
||||
|
||||
test("single-NIC ingress source has no host publication and a fixed ipvlan", async () => {
|
||||
const baseline = await readFile(
|
||||
resolve(devicePlaneRoot, "docker-compose.device-edge.yml"),
|
||||
"utf8",
|
||||
);
|
||||
const ingress = await readFile(
|
||||
resolve(devicePlaneRoot, "docker-compose.device-edge.ingress.yml"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(
|
||||
baseline,
|
||||
/DEVICE_EDGE_RELAY_HEALTH_HOST: 127\.0\.0\.1/,
|
||||
);
|
||||
assert.doesNotMatch(baseline, /^\s+ports:/m);
|
||||
assert.doesNotMatch(baseline, /device-edge-control/);
|
||||
|
||||
for (const required of [
|
||||
'DEVICE_EDGE_RELAY_INGRESS_ENABLED: "true"',
|
||||
"DEVICE_EDGE_RELAY_UPSTREAM_HOST: device-edge-backhaul",
|
||||
'DEVICE_EDGE_RELAY_UPSTREAM_PORT: "19921"',
|
||||
"name: nodedc-device-edge-ingress",
|
||||
"driver: ipvlan",
|
||||
"parent: enp1s0f0",
|
||||
"ipvlan_mode: l2",
|
||||
"ipv4_address: 192.168.71.253",
|
||||
"subnet: 192.168.68.0/22",
|
||||
"gateway: 192.168.68.1",
|
||||
"gw_priority: 100",
|
||||
]) {
|
||||
assert.ok(ingress.includes(required), `missing ingress boundary: ${required}`);
|
||||
}
|
||||
|
||||
for (const forbidden of [
|
||||
"ports:",
|
||||
"network_mode: host",
|
||||
"privileged: true",
|
||||
"DEVICE_EDGE_RELAY_COMMAND",
|
||||
"0.0.0.0:9921:9921",
|
||||
]) {
|
||||
assert.ok(
|
||||
!ingress.includes(forbidden),
|
||||
`forbidden ingress boundary: ${forbidden}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("ingress descriptor keeps address approval and router exposure gated", async () => {
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(
|
||||
devicePlaneRoot,
|
||||
"deployment/device-edge-ingress-ipvlan-v1.json",
|
||||
),
|
||||
"utf8",
|
||||
));
|
||||
|
||||
assert.equal(descriptor.component, "device-edge");
|
||||
assert.deepEqual(descriptor.selectedServices, ["device-edge-relay"]);
|
||||
assert.deepEqual(
|
||||
descriptor.preservedServices,
|
||||
["device-edge-backhaul", "tailnet"],
|
||||
);
|
||||
assert.equal(descriptor.ingressIpv4Approval, "approved-outside-dhcp-pool");
|
||||
assert.equal(descriptor.hostPortPublication, "disabled");
|
||||
assert.equal(descriptor.healthPublication, "disabled");
|
||||
assert.equal(descriptor.privateUpstream, "device-edge-backhaul:19921");
|
||||
assert.equal(descriptor.protocolInspection, "gateway-owned");
|
||||
assert.equal(descriptor.discoveryLifecycle, "quarantine");
|
||||
assert.equal(descriptor.commandTransport, "disabled");
|
||||
assert.equal(descriptor.gelios, "untouched");
|
||||
assert.equal(descriptor.amneziaHostFullTunnel, "preserved");
|
||||
assert.equal(descriptor.routerNatFirewall, "separate-manual-gate");
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer, connect } from "node:net";
|
||||
import test from "node:test";
|
||||
|
||||
import { createDeviceEdgeRelayRuntime } from "../src/runtime.mjs";
|
||||
|
||||
test("baseline starts only loopback health and no device TCP listener", async () => {
|
||||
const runtime = createDeviceEdgeRelayRuntime({ healthPort: 0 });
|
||||
const addresses = await runtime.start();
|
||||
try {
|
||||
assert.equal(addresses.tcpAddress, null);
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${addresses.healthAddress.port}/healthz`,
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const body = await response.json();
|
||||
assert.equal(body.ingress, "disabled");
|
||||
assert.equal(body.protocolInspection, "disabled");
|
||||
assert.equal(body.commandTransport, "disabled");
|
||||
} finally {
|
||||
await runtime.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("relay is transparent and never emits its own protocol bytes", async () => {
|
||||
const upstream = await startEchoServer();
|
||||
const runtime = createDeviceEdgeRelayRuntime({
|
||||
healthPort: 0,
|
||||
ingressEnabled: true,
|
||||
tcpHost: "0.0.0.0",
|
||||
tcpPort: 0,
|
||||
upstreamHost: "127.0.0.1",
|
||||
upstreamPort: upstream.port,
|
||||
});
|
||||
const addresses = await runtime.start();
|
||||
try {
|
||||
const response = await sendAndCollect(
|
||||
addresses.tcpAddress.port,
|
||||
Buffer.from("ff230102030405060708", "hex"),
|
||||
);
|
||||
assert.equal(response.toString("hex"), "ff230102030405060708");
|
||||
assert.equal(runtime.status().totalForwarded, 1);
|
||||
assert.equal(runtime.status().commandTransport, "disabled");
|
||||
} finally {
|
||||
await runtime.stop();
|
||||
await closeServer(upstream.server);
|
||||
}
|
||||
});
|
||||
|
||||
test("enabled relay requires a concrete private upstream", () => {
|
||||
assert.throws(
|
||||
() => createDeviceEdgeRelayRuntime({
|
||||
ingressEnabled: true,
|
||||
tcpHost: "0.0.0.0",
|
||||
upstreamPort: 19921,
|
||||
}),
|
||||
/device_edge_relay_upstream_host_invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test("baseline rejects a non-loopback device binding", () => {
|
||||
assert.throws(
|
||||
() => createDeviceEdgeRelayRuntime({ tcpHost: "0.0.0.0" }),
|
||||
/device_edge_relay_baseline_loopback_only/,
|
||||
);
|
||||
});
|
||||
|
||||
function startEchoServer() {
|
||||
const server = createServer((socket) => socket.pipe(socket));
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve({ server, port: server.address().port });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sendAndCollect(port, payload) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
const socket = connect({ host: "127.0.0.1", port }, () => {
|
||||
socket.end(payload);
|
||||
});
|
||||
socket.on("data", (chunk) => chunks.push(chunk));
|
||||
socket.on("close", () => resolve(Buffer.concat(chunks)));
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
@@ -3,9 +3,11 @@ FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages ./packages
|
||||
COPY packages/device-protocol-contract ./packages/device-protocol-contract
|
||||
COPY packages/arusnavi-b2-adapter ./packages/arusnavi-b2-adapter
|
||||
COPY services/device-gateway ./services/device-gateway
|
||||
COPY services/device-control-core/package.json ./services/device-control-core/package.json
|
||||
COPY services/device-edge-relay/package.json ./services/device-edge-relay/package.json
|
||||
|
||||
RUN npm ci --omit=dev --ignore-scripts
|
||||
|
||||
|
||||
Reference in New Issue
Block a user