Skip to content

Commit 0a828da

Browse files
authored
fix: configure OpenShell gateway bind address and port (#3425)
## Summary Closes #2779. This adds bounded OpenShell gateway network configuration for onboarding: - keeps the default gateway behavior on `127.0.0.1:8080` - allows `NEMOCLAW_GATEWAY_BIND_ADDRESS=127.0.0.1` or `0.0.0.0` - keeps local registration, readiness probes, SSH gateway host, and sandbox callback endpoints on concrete connect hosts instead of advertising `0.0.0.0` - rejects `NEMOCLAW_GATEWAY_PORT` values that overlap the dashboard allocation range, configured NemoClaw service ports, or the default inference/proxy ports - documents the bind-address security tradeoff ## Validation - `npm run build:cli` - `npx vitest run src/lib/core/ports.test.ts src/lib/core/gateway-address.test.ts src/lib/onboard/preflight.test.ts src/lib/onboard/gateway-tcp-readiness.test.ts test/gateway-start-wait.test.ts test/gateway-http-reuse-wait.test.ts test/check-env-var-docs.test.ts test/onboard.test.ts` - `npm run checks` - `git diff --check` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Configure gateway bind address (127.0.0.1 or 0.0.0.0); onboarding, TCP/HTTP readiness probes, and advertised endpoints respect bind/connect host; wildcard bind emits a warning. * **Behavior Changes** * Stricter gateway port validation (disallowed ranges/ports and no overlaps with dashboard/vLLM/Ollama/proxy); improved probing, retries and error messages. * **Documentation** * Expanded CLI reference, env-var docs, security guidance, troubleshooting, and console examples for bind/port settings. * **Tests** * Expanded coverage for bind parsing, endpoint mapping, port validation, onboarding readiness, Docker-driver env generation, and env-file handling. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/NVIDIA/NemoClaw/pull/3425) <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
1 parent 86fe71b commit 0a828da

20 files changed

Lines changed: 784 additions & 145 deletions

docs/reference/commands.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,8 @@ It verifies that Docker is reachable, warns on untested runtimes such as Podman,
190190
The preflight also enforces the OpenShell version range declared in the blueprint (`min_openshell_version` and `max_openshell_version`).
191191
If the installed OpenShell version falls outside this range, onboarding exits with an actionable error and a link to compatible releases.
192192

193-
When an existing gateway is detected for reuse, NemoClaw probes the host gateway HTTP endpoint (`http://127.0.0.1:${NEMOCLAW_GATEWAY_PORT}/`) before declaring it reusable, so a gateway whose container is running but whose upstream is still warming up (e.g. immediately after a Docker daemon restart) is rebuilt instead of trusted.
193+
When NemoClaw finds an existing gateway to reuse, it probes the host gateway HTTP endpoint before declaring the gateway reusable.
194+
If the container is running but the upstream is still warming up (for example, immediately after a Docker daemon restart), NemoClaw rebuilds the gateway instead of trusting stale metadata.
194195
Tune the wait via `NEMOCLAW_REUSE_HEALTH_POLL_COUNT` (default `6`) and `NEMOCLAW_REUSE_HEALTH_POLL_INTERVAL` (default `5` seconds).
195196
The poll count is clamped to a minimum of `1` so the probe always runs at least once, and the interval is clamped to a minimum of `0` (no sleep between attempts).
196197

@@ -1070,21 +1071,30 @@ All ports must be non-privileged integers between 1024 and 65535.
10701071

10711072
| Variable | Default | Service |
10721073
|----------|---------|---------|
1073-
| `NEMOCLAW_GATEWAY_PORT` | 8080 | OpenShell gateway |
1074+
| `NEMOCLAW_GATEWAY_PORT` | 8080 | OpenShell gateway port |
1075+
| `NEMOCLAW_GATEWAY_BIND_ADDRESS` | 127.0.0.1 | OpenShell gateway bind address (`127.0.0.1` or `0.0.0.0`) |
10741076
| `NEMOCLAW_DASHBOARD_PORT` | 18789 (auto-derived from `CHAT_UI_URL` port if set) | Dashboard UI |
10751077
| `NEMOCLAW_VLLM_PORT` | 8000 | vLLM / NIM inference |
10761078
| `NEMOCLAW_OLLAMA_PORT` | 11434 | Ollama inference |
10771079
| `NEMOCLAW_OLLAMA_PROXY_PORT` | 11435 | Ollama auth proxy |
10781080

10791081
If a port value is not a valid integer or falls outside the allowed range, the CLI exits with an error.
1082+
`NEMOCLAW_GATEWAY_PORT` also cannot overlap the configured dashboard, vLLM, Ollama, or Ollama proxy ports, and cannot use the dashboard auto-allocation range `18789` through `18799` or the default inference/proxy ports `8000`, `11434`, and `11435`.
10801083
On non-WSL hosts, `NEMOCLAW_OLLAMA_PORT` and `NEMOCLAW_OLLAMA_PROXY_PORT` must be different.
10811084
If you run Ollama on port 11435, set `NEMOCLAW_OLLAMA_PROXY_PORT` to another free port before onboarding.
10821085

1086+
`NEMOCLAW_GATEWAY_BIND_ADDRESS` accepts only `127.0.0.1` and `0.0.0.0`.
1087+
Binding the OpenShell gateway to `0.0.0.0` may make it reachable from other hosts on the network.
1088+
10831089
```console
10841090
$ export NEMOCLAW_DASHBOARD_PORT=19000
10851091
$ nemoclaw onboard
10861092
```
10871093

1094+
```console
1095+
$ NEMOCLAW_GATEWAY_BIND_ADDRESS=0.0.0.0 NEMOCLAW_GATEWAY_PORT=8990 nemoclaw onboard
1096+
```
1097+
10881098
These overrides apply to onboarding, status checks, health probes, and the uninstaller.
10891099
Defaults are unchanged when no variable is set.
10901100
If `NEMOCLAW_DASHBOARD_PORT` or the port from `CHAT_UI_URL` is already occupied by another sandbox, onboarding scans `18789` through `18799` and uses the next free dashboard port.

docs/reference/troubleshooting.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,23 @@ Or set the port directly:
206206
$ NEMOCLAW_DASHBOARD_PORT=19000 nemoclaw onboard
207207
```
208208

209+
For an OpenShell gateway port conflict, set `NEMOCLAW_GATEWAY_PORT` to a free
210+
non-privileged port that does not overlap NemoClaw's dashboard, vLLM, Ollama,
211+
or Ollama proxy ports:
212+
213+
```console
214+
$ NEMOCLAW_GATEWAY_PORT=8990 nemoclaw onboard
215+
```
216+
217+
Remote/headless hosts can bind the OpenShell gateway to all IPv4 interfaces:
218+
219+
```console
220+
$ NEMOCLAW_GATEWAY_BIND_ADDRESS=0.0.0.0 NEMOCLAW_GATEWAY_PORT=8990 nemoclaw onboard
221+
```
222+
223+
Use `NEMOCLAW_GATEWAY_BIND_ADDRESS=0.0.0.0` only when other hosts on the
224+
network should be able to reach the gateway.
225+
209226
See [Environment Variables](commands.md#environment-variables) for the full list of port overrides.
210227

211228
### Running multiple sandboxes simultaneously

docs/security/best-practices.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,17 @@ Device authentication requires each connecting device to go through a pairing fl
423423
| Risk if relaxed | Disabling device auth allows any device on the network to connect to the gateway without proving identity. This is dangerous when combined with LAN-bind changes or cloudflared tunnels in remote deployments, resulting in an unauthenticated, publicly reachable dashboard. |
424424
| Recommendation | Keep device auth enabled (the default). Only disable it for headless or development environments where no untrusted devices can reach the gateway. |
425425

426+
### Gateway Bind Address
427+
428+
NemoClaw binds the OpenShell gateway to loopback by default.
429+
430+
| Aspect | Detail |
431+
|---|---|
432+
| Default | `NEMOCLAW_GATEWAY_BIND_ADDRESS=127.0.0.1`. |
433+
| What you can change | Set `NEMOCLAW_GATEWAY_BIND_ADDRESS=0.0.0.0` before onboarding to listen on all IPv4 interfaces. |
434+
| Risk if relaxed | Other hosts on the network may be able to reach the OpenShell gateway. |
435+
| Recommendation | Keep the loopback default unless the gateway must be reachable from another host. |
436+
426437
### Insecure Auth Derivation
427438

428439
The `allowInsecureAuth` setting controls whether the gateway permits non-HTTPS authentication.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { afterEach, describe, expect, it } from "vitest";
5+
6+
import {
7+
getGatewayConnectHost,
8+
getGatewayHttpEndpoint,
9+
getGatewayHttpsEndpoint,
10+
parseGatewayBindAddress,
11+
} from "../../../dist/lib/core/gateway-address";
12+
13+
const ENV_KEY = "TEST_GATEWAY_BIND_ADDRESS";
14+
15+
afterEach(() => {
16+
delete process.env[ENV_KEY];
17+
});
18+
19+
describe("parseGatewayBindAddress", () => {
20+
it("defaults to loopback", () => {
21+
expect(parseGatewayBindAddress(ENV_KEY)).toBe("127.0.0.1");
22+
});
23+
24+
it("accepts loopback", () => {
25+
process.env[ENV_KEY] = "127.0.0.1";
26+
expect(parseGatewayBindAddress(ENV_KEY)).toBe("127.0.0.1");
27+
});
28+
29+
it("accepts all IPv4 interfaces", () => {
30+
process.env[ENV_KEY] = "0.0.0.0";
31+
expect(parseGatewayBindAddress(ENV_KEY)).toBe("0.0.0.0");
32+
});
33+
34+
it("rejects comma-separated addresses", () => {
35+
process.env[ENV_KEY] = "0.0.0.0,127.0.0.1";
36+
expect(() => parseGatewayBindAddress(ENV_KEY)).toThrow("must be either");
37+
});
38+
39+
it.each(["localhost", "10.0.0.5", "::", "::1"])("rejects %s", (value) => {
40+
process.env[ENV_KEY] = value;
41+
expect(() => parseGatewayBindAddress(ENV_KEY)).toThrow("must be either");
42+
});
43+
});
44+
45+
describe("gateway endpoint helpers", () => {
46+
it("keeps loopback endpoints unchanged", () => {
47+
expect(getGatewayConnectHost("127.0.0.1")).toBe("127.0.0.1");
48+
expect(getGatewayHttpEndpoint(8080, "127.0.0.1")).toBe("http://127.0.0.1:8080");
49+
expect(getGatewayHttpsEndpoint(8080, "127.0.0.1")).toBe("https://127.0.0.1:8080");
50+
});
51+
52+
it("does not advertise wildcard bind addresses as client endpoints", () => {
53+
expect(getGatewayConnectHost("0.0.0.0")).toBe("127.0.0.1");
54+
expect(getGatewayHttpEndpoint(8990, "0.0.0.0")).toBe("http://127.0.0.1:8990");
55+
expect(getGatewayHttpsEndpoint(8990, "0.0.0.0")).toBe("https://127.0.0.1:8990");
56+
});
57+
});

src/lib/core/gateway-address.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { GATEWAY_PORT } from "./ports";
5+
6+
export const DEFAULT_GATEWAY_BIND_ADDRESS = "127.0.0.1";
7+
export const WILDCARD_GATEWAY_BIND_ADDRESS = "0.0.0.0";
8+
9+
export type GatewayBindAddress =
10+
| typeof DEFAULT_GATEWAY_BIND_ADDRESS
11+
| typeof WILDCARD_GATEWAY_BIND_ADDRESS;
12+
13+
export function parseGatewayBindAddress(
14+
envVar = "NEMOCLAW_GATEWAY_BIND_ADDRESS",
15+
fallback: GatewayBindAddress = DEFAULT_GATEWAY_BIND_ADDRESS,
16+
): GatewayBindAddress {
17+
const raw = process.env[envVar];
18+
if (raw === undefined || raw === "") return fallback;
19+
const trimmed = String(raw).trim();
20+
if (trimmed === DEFAULT_GATEWAY_BIND_ADDRESS) return DEFAULT_GATEWAY_BIND_ADDRESS;
21+
if (trimmed === WILDCARD_GATEWAY_BIND_ADDRESS) return WILDCARD_GATEWAY_BIND_ADDRESS;
22+
throw new Error(
23+
`Invalid gateway bind address: ${envVar}="${raw}" — must be either ${DEFAULT_GATEWAY_BIND_ADDRESS} or ${WILDCARD_GATEWAY_BIND_ADDRESS}`,
24+
);
25+
}
26+
27+
export const GATEWAY_BIND_ADDRESS = parseGatewayBindAddress();
28+
29+
export function getGatewayConnectHost(
30+
bindAddress: GatewayBindAddress = GATEWAY_BIND_ADDRESS,
31+
): string {
32+
return bindAddress === WILDCARD_GATEWAY_BIND_ADDRESS
33+
? DEFAULT_GATEWAY_BIND_ADDRESS
34+
: bindAddress;
35+
}
36+
37+
export function getGatewayHttpEndpoint(
38+
port: number = GATEWAY_PORT,
39+
bindAddress: GatewayBindAddress = GATEWAY_BIND_ADDRESS,
40+
): string {
41+
return `http://${getGatewayConnectHost(bindAddress)}:${port}`;
42+
}
43+
44+
export function getGatewayHttpsEndpoint(
45+
port: number = GATEWAY_PORT,
46+
bindAddress: GatewayBindAddress = GATEWAY_BIND_ADDRESS,
47+
): string {
48+
return `https://${getGatewayConnectHost(bindAddress)}:${port}`;
49+
}

src/lib/core/ports.test.ts

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

44
import { describe, it, expect, beforeEach, afterEach } from "vitest";
55
// Import from compiled dist/ so coverage is attributed correctly.
6-
import { parsePort } from "../../../dist/lib/core/ports";
6+
import { parseGatewayPort, parsePort } from "../../../dist/lib/core/ports";
7+
8+
const GATEWAY_VALIDATION_OPTIONS = {
9+
dashboardPort: 18789,
10+
dashboardRangeStart: 18789,
11+
dashboardRangeEnd: 18799,
12+
vllmPort: 8000,
13+
ollamaPort: 11434,
14+
ollamaProxyPort: 11435,
15+
};
716

817
describe("parsePort", () => {
918
const ENV_KEY = "TEST_PORT";
@@ -70,3 +79,71 @@ describe("parsePort", () => {
7079
expect(() => parsePort(ENV_KEY, 8080)).toThrow("Invalid port");
7180
});
7281
});
82+
83+
describe("parseGatewayPort", () => {
84+
const ENV_KEY = "TEST_GATEWAY_PORT";
85+
86+
beforeEach(() => {
87+
delete process.env[ENV_KEY];
88+
});
89+
90+
afterEach(() => {
91+
delete process.env[ENV_KEY];
92+
});
93+
94+
it("allows the default gateway port when no override is set", () => {
95+
expect(parseGatewayPort(ENV_KEY, 8080, GATEWAY_VALIDATION_OPTIONS)).toBe(8080);
96+
});
97+
98+
it("rejects the default gateway port when another service is configured there", () => {
99+
expect(() =>
100+
parseGatewayPort(ENV_KEY, 8080, {
101+
...GATEWAY_VALIDATION_OPTIONS,
102+
vllmPort: 8080,
103+
}),
104+
).toThrow("NEMOCLAW_VLLM_PORT");
105+
});
106+
107+
it("accepts a non-conflicting gateway port override", () => {
108+
process.env[ENV_KEY] = "8990";
109+
expect(parseGatewayPort(ENV_KEY, 8080, GATEWAY_VALIDATION_OPTIONS)).toBe(8990);
110+
});
111+
112+
it("rejects the dashboard auto-allocation range", () => {
113+
process.env[ENV_KEY] = "18790";
114+
expect(() => parseGatewayPort(ENV_KEY, 8080, GATEWAY_VALIDATION_OPTIONS)).toThrow(
115+
"18789-18799",
116+
);
117+
});
118+
119+
it("rejects overlap with the configured dashboard port", () => {
120+
process.env[ENV_KEY] = "19000";
121+
expect(() =>
122+
parseGatewayPort(ENV_KEY, 8080, {
123+
...GATEWAY_VALIDATION_OPTIONS,
124+
dashboardPort: 19000,
125+
}),
126+
).toThrow("NEMOCLAW_DASHBOARD_PORT");
127+
});
128+
129+
it("rejects overlap with a configured non-default service port", () => {
130+
process.env[ENV_KEY] = "19001";
131+
expect(() =>
132+
parseGatewayPort(ENV_KEY, 8080, {
133+
...GATEWAY_VALIDATION_OPTIONS,
134+
vllmPort: 19001,
135+
}),
136+
).toThrow("NEMOCLAW_VLLM_PORT");
137+
});
138+
139+
it.each([
140+
["8000", "vLLM / NIM inference"],
141+
["11434", "Ollama inference"],
142+
["11435", "Ollama auth proxy"],
143+
])("rejects overlap with default port %s", (port, label) => {
144+
process.env[ENV_KEY] = port;
145+
expect(() => parseGatewayPort(ENV_KEY, 8080, GATEWAY_VALIDATION_OPTIONS)).toThrow(
146+
label,
147+
);
148+
});
149+
});

src/lib/core/ports.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,15 @@ export function parsePort(envVar: string, fallback: number): number {
2828
return parsed;
2929
}
3030

31-
/** OpenShell gateway port (default 8080, override via NEMOCLAW_GATEWAY_PORT). */
32-
export const GATEWAY_PORT = parsePort("NEMOCLAW_GATEWAY_PORT", 8080);
31+
export interface GatewayPortValidationOptions {
32+
dashboardPort: number;
33+
dashboardRangeStart: number;
34+
dashboardRangeEnd: number;
35+
vllmPort: number;
36+
ollamaPort: number;
37+
ollamaProxyPort: number;
38+
}
39+
3340
/**
3441
* The default port the OpenClaw dashboard listens on inside the sandbox.
3542
* The sandbox image is built with CHAT_UI_URL=http://127.0.0.1:SANDBOX_DASHBOARD_PORT
@@ -50,3 +57,60 @@ export const VLLM_PORT = parsePort("NEMOCLAW_VLLM_PORT", 8000);
5057
export const OLLAMA_PORT = parsePort("NEMOCLAW_OLLAMA_PORT", 11434);
5158
/** Ollama auth proxy port (default 11435, override via NEMOCLAW_OLLAMA_PROXY_PORT). */
5259
export const OLLAMA_PROXY_PORT = parsePort("NEMOCLAW_OLLAMA_PROXY_PORT", 11435);
60+
61+
export function validateGatewayPort(
62+
envVar: string,
63+
port: number,
64+
options: GatewayPortValidationOptions,
65+
): void {
66+
if (port >= options.dashboardRangeStart && port <= options.dashboardRangeEnd) {
67+
throw new Error(
68+
`Invalid port: ${envVar}="${port}" — must not overlap the ${options.dashboardRangeStart}-${options.dashboardRangeEnd} dashboard port range`,
69+
);
70+
}
71+
72+
const reservedDefaults = [
73+
{ label: "vLLM / NIM inference", port: 8000 },
74+
{ label: "Ollama inference", port: 11434 },
75+
{ label: "Ollama auth proxy", port: 11435 },
76+
];
77+
const reservedDefault = reservedDefaults.find((entry) => entry.port === port);
78+
if (reservedDefault) {
79+
throw new Error(
80+
`Invalid port: ${envVar}="${port}" — must not overlap the ${reservedDefault.label} default port (${reservedDefault.port})`,
81+
);
82+
}
83+
84+
const conflicts = [
85+
{ envVar: "NEMOCLAW_DASHBOARD_PORT", port: options.dashboardPort },
86+
{ envVar: "NEMOCLAW_VLLM_PORT", port: options.vllmPort },
87+
{ envVar: "NEMOCLAW_OLLAMA_PORT", port: options.ollamaPort },
88+
{ envVar: "NEMOCLAW_OLLAMA_PROXY_PORT", port: options.ollamaProxyPort },
89+
];
90+
const conflict = conflicts.find((entry) => entry.port === port);
91+
if (conflict) {
92+
throw new Error(
93+
`Invalid port: ${envVar}="${port}" — conflicts with ${conflict.envVar} (${conflict.port})`,
94+
);
95+
}
96+
}
97+
98+
export function parseGatewayPort(
99+
envVar: string,
100+
fallback: number,
101+
options: GatewayPortValidationOptions,
102+
): number {
103+
const port = parsePort(envVar, fallback);
104+
validateGatewayPort(envVar, port, options);
105+
return port;
106+
}
107+
108+
/** OpenShell gateway port (default 8080, override via NEMOCLAW_GATEWAY_PORT). */
109+
export const GATEWAY_PORT = parseGatewayPort("NEMOCLAW_GATEWAY_PORT", 8080, {
110+
dashboardPort: DASHBOARD_PORT,
111+
dashboardRangeStart: DASHBOARD_PORT_RANGE_START,
112+
dashboardRangeEnd: DASHBOARD_PORT_RANGE_END,
113+
vllmPort: VLLM_PORT,
114+
ollamaPort: OLLAMA_PORT,
115+
ollamaProxyPort: OLLAMA_PROXY_PORT,
116+
});

0 commit comments

Comments
 (0)