Skip to content

Commit 01e0b92

Browse files
authored
fix(onboard): separate portable host gateway subnet (#9641)
1 parent ea912e7 commit 01e0b92

16 files changed

Lines changed: 324 additions & 43 deletions

.github/workflows/podman-cpu-proof.yaml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ on:
2828
- "src/lib/onboard/runtime-provider/container-state-mutation.ts"
2929
- "src/lib/onboard/runtime-provider/docker-state-mutation.ts"
3030
- "src/lib/onboard/experimental/portable-host-preparation*.ts"
31+
- "src/lib/onboard/experimental/portable-profile.ts"
3132
- "src/lib/onboard/runtime-provider/podman*.ts"
3233
- "scripts/install-openshell.sh"
3334
- "scripts/checks/run-portable-cpu-delegation-proof.mts"
@@ -334,6 +335,21 @@ jobs:
334335
printf 'XDG_RUNTIME_DIR=%s\n' "$runtime_dir"
335336
} >>"$GITHUB_ENV"
336337
338+
- name: Configure exact Portable host gateway alias
339+
shell: bash
340+
run: |
341+
set -euo pipefail
342+
portable_host_gateway_ip="$(
343+
node --input-type=module --eval '
344+
const { PORTABLE_HOST_GATEWAY_IP } =
345+
await import("./dist/lib/onboard/docker-driver-platform.js");
346+
process.stdout.write(PORTABLE_HOST_GATEWAY_IP);
347+
'
348+
)"
349+
sudo ip address replace "$portable_host_gateway_ip/32" dev lo
350+
ip -o -4 address show dev lo | awk '{print $4}' | grep -Fx "$portable_host_gateway_ip/32"
351+
printf 'E2E_PORTABLE_HOST_GATEWAY_IP=%s\n' "$portable_host_gateway_ip" >>"$GITHUB_ENV"
352+
337353
- name: Start the exact connected gateway required by portable retirement
338354
shell: bash
339355
run: |
@@ -458,6 +474,17 @@ jobs:
458474
esac
459475
done < <(podman --url "$endpoint" secret ls --format '{{.Name}}' 2>/dev/null || true)
460476
podman --url "$endpoint" network rm openshell-docker 2>/dev/null || true
477+
portable_host_gateway_ip=""
478+
if portable_host_gateway_ip="$(
479+
node --input-type=module --eval '
480+
const { PORTABLE_HOST_GATEWAY_IP } =
481+
await import("./dist/lib/onboard/docker-driver-platform.js");
482+
process.stdout.write(PORTABLE_HOST_GATEWAY_IP);
483+
'
484+
)" && [ -n "$portable_host_gateway_ip" ] \
485+
&& [ "${E2E_PORTABLE_HOST_GATEWAY_IP:-}" = "$portable_host_gateway_ip" ]; then
486+
sudo ip address delete "$portable_host_gateway_ip/32" dev lo 2>/dev/null || true
487+
fi
461488
service_pid="${E2E_PODMAN_SERVICE_PID:-}"
462489
if [[ "$service_pid" =~ ^[1-9][0-9]*$ ]]; then
463490
kill "$service_pid" 2>/dev/null || true

docs/inference/set-up-openai-compatible-endpoint.mdx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,11 @@ curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_AGENT=langchain-deepage
132132

133133
</AgentOnly>
134134

135-
The portable profile creates the `openshell-docker` network on `169.254.1.0/24` and assigns `169.254.1.2/32` to the host loopback interface.
136-
On that network, `169.254.1.2` is the host-gateway address for `host.openshell.internal` and carries authenticated mTLS plus sandbox-JWT callbacks to OpenShell on port `8080`.
135+
The portable profile creates the `openshell-docker` network on `169.254.1.0/24` and assigns `169.254.2.2/32` to the host loopback interface.
136+
The address `169.254.2.2` is outside the Portable sandbox subnet and is the host-gateway address for `host.openshell.internal`.
137+
It carries authenticated mTLS plus sandbox-JWT callbacks to OpenShell on port `8080`.
137138
The managed local registry uses the distinct address `169.254.1.3` on port `5000`.
138-
Keeping these addresses distinct prevents registry traffic from intercepting gateway callbacks.
139+
Keeping the host-gateway address outside the sandbox subnet prevents a Portable workload from receiving that address.
139140

140141
The portable profile handles the descriptor as follows:
141142

src/lib/onboard/docker-driver-gateway-env.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
startPackageManagedDockerDriverGatewayWithEnvOverride,
1616
writeDockerGatewayDebEnvOverride,
1717
} from "./docker-driver-gateway-env";
18+
import { PORTABLE_HOST_GATEWAY_IP } from "./experimental/portable-profile";
1819

1920
function homeEnv(home: string, xdgConfigHome = ""): NodeJS.ProcessEnv {
2021
return { HOME: home, XDG_CONFIG_HOME: xdgConfigHome } as NodeJS.ProcessEnv;
@@ -156,14 +157,14 @@ describe("buildDockerDriverGatewayEnv", () => {
156157
OPENSHELL_DRIVERS: "podman",
157158
CONTAINERS_CONF: "/tmp/nemoclaw-portable/containers.conf",
158159
OPENSHELL_BIND_ADDRESS: "0.0.0.0",
159-
OPENSHELL_GRPC_ENDPOINT: "https://169.254.1.2:8080",
160+
OPENSHELL_GRPC_ENDPOINT: `https://${PORTABLE_HOST_GATEWAY_IP}:8080`,
160161
NETAVARK_FW: "iptables",
161162
OPENSHELL_PODMAN_SOCKET: "/run/user/1001/podman/podman.sock",
162163
});
163164
const toml = fs.readFileSync(env.OPENSHELL_GATEWAY_CONFIG, "utf-8");
164165
expect(toml).toContain('compute_drivers = ["podman"]');
165166
expect(toml).toContain("[openshell.drivers.podman]");
166-
expect(toml).toContain('host_gateway_ip = "169.254.1.2"');
167+
expect(toml).toContain(`host_gateway_ip = "${PORTABLE_HOST_GATEWAY_IP}"`);
167168
expect(toml).toContain('socket_path = "/run/user/1001/podman/podman.sock"');
168169
expect(toml).not.toContain("supervisor_bin");
169170
} finally {

src/lib/onboard/docker-driver-gateway-launch.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
resolveDriftGatewayBin,
2121
shouldUseContainerizedGateway,
2222
} from "./docker-driver-gateway-launch";
23+
import { PORTABLE_HOST_GATEWAY_IP } from "./experimental/portable-profile";
2324
import { gatewayProcessCmdlineMatches } from "./gateway-process-identity";
2425

2526
function withTempBinaries<T>(
@@ -158,7 +159,7 @@ describe("docker-driver-gateway-launch", () => {
158159
it("writes the exact rootless socket only for the Podman driver", () => {
159160
const toml = buildDockerDriverGatewayConfigToml({
160161
OPENSHELL_DRIVERS: "podman",
161-
OPENSHELL_GRPC_ENDPOINT: "https://169.254.1.2:8080",
162+
OPENSHELL_GRPC_ENDPOINT: `https://${PORTABLE_HOST_GATEWAY_IP}:8080`,
162163
OPENSHELL_DOCKER_NETWORK_NAME: "openshell-docker",
163164
OPENSHELL_DOCKER_SUPERVISOR_IMAGE: "supervisor:test",
164165
OPENSHELL_PODMAN_SOCKET: "/run/user/1001/podman/podman.sock",

src/lib/onboard/docker-driver-gateway-local-tls.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
ensureDockerDriverGatewayLocalTlsBundle,
1313
getDockerDriverGatewayLocalTlsBundle,
1414
} from "./docker-driver-gateway-local-tls";
15+
import { PORTABLE_HOST_GATEWAY_IP } from "./experimental/portable-profile";
1516

1617
const TEST_CERT_VALID_AT = new Date("2026-06-27T00:00:00.000Z");
1718
const TEST_CERT_SKEW_BOUNDARY_NOT_YET_VALID_AT = new Date("2026-06-26T20:38:47.000Z");
@@ -239,7 +240,7 @@ describe("docker-driver-gateway-local-tls", () => {
239240
}) as never,
240241
}),
241242
).toThrow("did not create a complete");
242-
expect(calls[0]).toEqual(expect.arrayContaining(["--server-san", "169.254.1.2"]));
243+
expect(calls[0]).toEqual(expect.arrayContaining(["--server-san", PORTABLE_HOST_GATEWAY_IP]));
243244
} finally {
244245
fs.rmSync(stateDir, { recursive: true, force: true });
245246
}

src/lib/onboard/experimental/hermes-portable-ollama-inference.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
} from "../../../../test/helpers/hermes-portable-ollama-test-harness";
3737
import { hermesPortableOllamaAuthorityInternals } from "./hermes-portable-ollama-authority";
3838
import { createHermesPortableOllamaInferenceResolver } from "./hermes-portable-ollama-inference";
39+
import { PORTABLE_HOST_GATEWAY_IP } from "./portable-profile";
3940

4041
const PODMAN_PATH = "/usr/bin/podman";
4142
const PODMAN_BYTES = Buffer.from("portable-podman-5.7.0", "utf8");
@@ -378,7 +379,7 @@ describe("Hermes Portable Ollama inference activation", () => {
378379
networkName: "openshell-docker",
379380
networkId: NETWORK_ID,
380381
networkGatewayIp: "169.254.1.1",
381-
networkListenerIp: "169.254.1.2",
382+
networkListenerIp: PORTABLE_HOST_GATEWAY_IP,
382383
gpuDevices: [GPU_DEVICE],
383384
},
384385
});
@@ -739,7 +740,7 @@ describe("Hermes Portable Ollama inference activation", () => {
739740
);
740741
fixture.gatewayProvider.setLookupFailure(false);
741742
const mutation = await selection.prepareGatewayMutation(gatewayMutationInput);
742-
expect(() => createExactGatewayProvider(mutation, "http://169.254.1.2:11434/v1")).toThrow(
743+
expect(() => createExactGatewayProvider(mutation, "http://192.0.2.2:11434/v1")).toThrow(
743744
"provider mutation authority changed",
744745
);
745746
createExactGatewayProvider(mutation);

src/lib/onboard/experimental/portable-host-preparation.test.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import { spawnSync } from "node:child_process";
55
import fs from "node:fs";
6+
import { BlockList } from "node:net";
67
import os from "node:os";
78
import path from "node:path";
89

@@ -137,6 +138,20 @@ describe("preparePortableExperimentalHost", () => {
137138
for (const tempDir of tempDirs) fs.rmSync(tempDir, { recursive: true, force: true });
138139
});
139140

141+
it("keeps the host gateway outside every Portable sandbox subnet (#9587)", () => {
142+
const [networkAddress, prefixText] = PORTABLE_DOCKER_NETWORK_SUBNET.split("/");
143+
const portableSandboxAddresses = new BlockList();
144+
portableSandboxAddresses.addSubnet(networkAddress!, Number(prefixText), "ipv4");
145+
146+
expect(PORTABLE_DOCKER_NETWORK_SUBNET).toBe("169.254.1.0/24");
147+
expect(PORTABLE_REGISTRY_IP).toBe("169.254.1.3");
148+
expect(PORTABLE_HOST_GATEWAY_IP).toBe("169.254.2.2");
149+
expect(portableSandboxAddresses.check("169.254.1.2", "ipv4")).toBe(true);
150+
expect(portableSandboxAddresses.check(PORTABLE_REGISTRY_IP, "ipv4")).toBe(true);
151+
expect(portableSandboxAddresses.check(PORTABLE_HOST_GATEWAY_IP, "ipv4")).toBe(false);
152+
expect(PORTABLE_HOST_GATEWAY_IP).not.toBe(PORTABLE_REGISTRY_IP);
153+
});
154+
140155
it("does nothing unless the portable profile is explicit", () => {
141156
const systemctl = vi.fn();
142157
const docker = vi.fn();
@@ -419,7 +434,7 @@ describe("preparePortableExperimentalHost", () => {
419434
PORTABLE_REGISTRY_IP,
420435
]),
421436
]);
422-
expect(ip).toHaveBeenCalledTimes(2);
437+
expect(ip).toHaveBeenCalledTimes(4);
423438
expect(sudo).not.toHaveBeenCalled();
424439
});
425440

@@ -473,6 +488,61 @@ describe("preparePortableExperimentalHost", () => {
473488
expect(commands).toEqual(["create network", "add host gateway"]);
474489
});
475490

491+
it.each([
492+
["missing network", false, "1: lo inet 169.254.1.2/32 scope global lo\n"],
493+
[
494+
"existing network and replacement gateway",
495+
true,
496+
`1: lo inet 169.254.1.2/32 scope global lo\n1: lo inet ${PORTABLE_HOST_GATEWAY_IP}/32 scope global lo\n`,
497+
],
498+
])(
499+
"rejects the retired gateway alias before inspecting the %s (#9587)",
500+
(_case, simulateExistingPortableNetwork, addresses) => {
501+
const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-portable-"));
502+
tempDirs.push(home);
503+
const docker = vi.fn<(args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult>(() =>
504+
result(),
505+
);
506+
const ip = vi.fn(() => result(0, addresses));
507+
const sudo = vi.fn(() => result());
508+
509+
expect(() =>
510+
preparePortableExperimentalHost(
511+
{ NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" },
512+
portablePreparationDeps(home, docker, { ip, sudo }),
513+
undefined,
514+
{ simulateExistingPortableNetwork },
515+
),
516+
).toThrow(
517+
/sudo ip address delete 169\.254\.1\.2\/32 dev lo.*nemoclaw onboard --experimental-profile portable/u,
518+
);
519+
expect(docker.mock.calls.map(([args]) => args)).toEqual([["--version"]]);
520+
expect(ip).toHaveBeenCalledTimes(1);
521+
expect(sudo).not.toHaveBeenCalled();
522+
},
523+
);
524+
525+
it("does not remove a conflicting retired gateway assignment (#9587)", () => {
526+
const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-portable-"));
527+
tempDirs.push(home);
528+
const docker = vi.fn<(args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult>(() =>
529+
result(),
530+
);
531+
const sudo = vi.fn(() => result());
532+
533+
expect(() =>
534+
preparePortableExperimentalHost(
535+
{ NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" },
536+
portablePreparationDeps(home, docker, {
537+
ip: () => result(0, "2: eth0 inet 169.254.1.2/24 scope global eth0\n"),
538+
sudo,
539+
}),
540+
),
541+
).toThrow(/retired portable host gateway address 169\.254\.1\.2 has a conflicting/u);
542+
expect(docker.mock.calls.map(([args]) => args)).toEqual([["--version"]]);
543+
expect(sudo).not.toHaveBeenCalled();
544+
});
545+
476546
it("configures and verifies the portable gateway loopback alias before registry mutation (#9461)", () => {
477547
const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-portable-"));
478548
tempDirs.push(home);
@@ -484,6 +554,7 @@ describe("preparePortableExperimentalHost", () => {
484554
const ip = vi
485555
.fn<(args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult>()
486556
.mockReturnValueOnce(result())
557+
.mockReturnValueOnce(result())
487558
.mockReturnValueOnce(
488559
result(0, `1: lo inet ${PORTABLE_HOST_GATEWAY_IP}/32 scope global lo\n`),
489560
);
@@ -499,6 +570,7 @@ describe("preparePortableExperimentalHost", () => {
499570
expect(ip.mock.calls.map(([args]) => args)).toEqual([
500571
["-o", "-4", "address", "show"],
501572
["-o", "-4", "address", "show"],
573+
["-o", "-4", "address", "show"],
502574
]);
503575
expect(sudo).toHaveBeenCalledWith(
504576
["--", "ip", "address", "replace", `${PORTABLE_HOST_GATEWAY_IP}/32`, "dev", "lo"],

src/lib/onboard/experimental/portable-host-preparation.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ import {
4141

4242
const REGISTRY_CONTAINER = "nemoclaw-portable-registry";
4343
const REGISTRY_LABEL = "com.nvidia.nemoclaw.portable=1";
44+
// Portable onboarding assigned this address to loopback before #9587 moved the
45+
// host gateway outside the sandbox subnet. Keep the retired value here so an
46+
// upgraded host cannot silently retain a route that captures sandbox traffic.
47+
const RETIRED_PORTABLE_HOST_GATEWAY_IP = "169.254.1.2";
4448
const REGISTRY_IMAGE =
4549
"docker.io/library/registry:2@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373";
4650
const HOST_COMMAND_TIMEOUT_MS = 30_000;
@@ -128,8 +132,11 @@ function requireDockerCompatibleCli(
128132
);
129133
}
130134

131-
function portableHostGatewayAliasState(output: string): "absent" | "configured" {
132-
const escapedGatewayIp = PORTABLE_HOST_GATEWAY_IP.replaceAll(".", "\\.");
135+
function portableHostGatewayAliasState(
136+
output: string,
137+
gatewayIp = PORTABLE_HOST_GATEWAY_IP,
138+
): "absent" | "configured" {
139+
const escapedGatewayIp = gatewayIp.replaceAll(".", "\\.");
133140
const addressPattern = new RegExp(`\\binet\\s+${escapedGatewayIp}/(\\d+)\\b`, "u");
134141
const assignments = output
135142
.split("\n")
@@ -143,7 +150,33 @@ function portableHostGatewayAliasState(output: string): "absent" | "configured"
143150
return "configured";
144151
}
145152
throw new Error(
146-
`Refusing to configure ${PORTABLE_HOST_GATEWAY_IP}/32 because the address already has a conflicting host assignment.`,
153+
`Refusing to configure ${gatewayIp}/32 because the address already has a conflicting host assignment.`,
154+
);
155+
}
156+
157+
function rejectRetiredPortableHostGatewayAlias(
158+
env: NodeJS.ProcessEnv,
159+
ip: NonNullable<PortableHostPreparationDeps["ip"]>,
160+
): void {
161+
const result = ip(["-o", "-4", "address", "show"], env);
162+
requireCommand(result, "Inspecting the retired portable host gateway address");
163+
let state: "absent" | "configured";
164+
try {
165+
state = portableHostGatewayAliasState(
166+
String(result.stdout ?? ""),
167+
RETIRED_PORTABLE_HOST_GATEWAY_IP,
168+
);
169+
} catch {
170+
throw new Error(
171+
`The retired portable host gateway address ${RETIRED_PORTABLE_HOST_GATEWAY_IP} has a conflicting host assignment. ` +
172+
"Resolve that assignment, then rerun `nemoclaw onboard --experimental-profile portable`.",
173+
);
174+
}
175+
if (state === "absent") return;
176+
throw new Error(
177+
`The retired portable host gateway address ${RETIRED_PORTABLE_HOST_GATEWAY_IP}/32 is still assigned to loopback. ` +
178+
`Remove it with \`sudo ip address delete ${RETIRED_PORTABLE_HOST_GATEWAY_IP}/32 dev lo\`, then rerun ` +
179+
"`nemoclaw onboard --experimental-profile portable`.",
147180
);
148181
}
149182

@@ -643,6 +676,7 @@ export function preparePortableExperimentalHost(
643676
env: childEnv,
644677
timeout: HOST_COMMAND_TIMEOUT_MS,
645678
}));
679+
rejectRetiredPortableHostGatewayAlias(podmanEnv, ip);
646680
ensurePortableSandboxNetwork(podmanEnv, docker, dockerNetworkName);
647681
ensurePortableHostGatewayAlias(podmanEnv, ip, sudo);
648682
ensureRegistryContainer(podmanEnv, docker, dockerNetworkName);

src/lib/onboard/experimental/portable-profile.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { DEFAULT_DOCKER_DRIVER_NETWORK_NAME } from "./docker-network-authority";
55

66
export const EXPERIMENTAL_PROFILE_ENV = "NEMOCLAW_EXPERIMENTAL_PROFILE";
77
export const PORTABLE_EXPERIMENTAL_PROFILE = "portable";
8-
export const PORTABLE_HOST_GATEWAY_IP = "169.254.1.2";
8+
export const PORTABLE_HOST_GATEWAY_IP = "169.254.2.2";
99
export const PORTABLE_REGISTRY_IP = "169.254.1.3";
1010
export const PORTABLE_DOCKER_NETWORK_NAME = DEFAULT_DOCKER_DRIVER_NETWORK_NAME;
1111
export const PORTABLE_DOCKER_NETWORK_SUBNET = "169.254.1.0/24";

0 commit comments

Comments
 (0)