Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,8 @@ Each entry assumes the ones before it.
8. **[Developer guide](docs/developing.mdx)** — read this before contributing:
where the code lives, how a change travels through the layers, and the
conventions the project enforces.
9. **[Engine bind validation](docs/engine-bind-validation.mdx)** — why local
engine starts require a loopback bind address.

Component references, for when you already know what you are looking for:

Expand Down
64 changes: 64 additions & 0 deletions docs/engine-bind-validation.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{/*
SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
SPDX-License-Identifier: Apache-2.0
*/}

# Engine bind validation

`services/nvpair-engine-manager` starts and installs inference engines on
behalf of the broker. Engine APIs (Ollama, LM Studio) are unauthenticated, so
the address they bind to is the entire access control. This document covers
the fix that keeps local engine starts on loopback.

## The problem

`engine:start` (and install-then-start) accepted a `bind` parameter that was
validated only as a well-formed IP address. Any valid IP passed — including
`0.0.0.0`:

```json
{"method": "engine:start", "params": {"engine": "ollama", "bind": "0.0.0.0"}}
```

That silently exposed the engine's unauthenticated API to the whole LAN. The
remote-start path already hard-binds `127.0.0.1`
(`controllifecycle.go`), so local starts were the inconsistent, weaker case:
the same engine, the same API, bound to every interface on the machine.

## The fix

`runOp` now requires a loopback address for `bind`:

- `""` (unset) keeps the previous default behavior — the engine binds as the
executor configures it.
- A non-loopback address (`0.0.0.0`, `192.168.1.5`, `::`, …) is rejected with
JSON-RPC `-32602` and the message `bind must be a loopback address`.
Nothing is started.
- An unparseable value is still rejected as before with `bind must be a valid
IP address`.
- Loopback addresses (`127.0.0.1`, `::1`) pass validation and reach the
executor unchanged.

```mermaid
flowchart TD
A[engine:start / install+start] --> B{bind set?}
B -->|no| C[start with default binding]
B -->|yes| D{valid IP?}
D -->|no| E[-32602: bind must be<br/>a valid IP address]
D -->|yes| F{IsLoopback?}
F -->|yes| G[start bound to loopback]
F -->|no| H[-32602: bind must be<br/>a loopback address]
```

If a deployment genuinely needs an engine reachable beyond loopback, that is
a product decision for the engine's own configuration — not something a
broker caller should be able to opt into per start.

## Validation

- `bind_validation_test.go` drives `runOp` directly: `0.0.0.0`,
`192.168.1.5`, and `::` are rejected; `not-an-ip` is rejected as invalid;
`127.0.0.1` and `::1` pass validation. Run with
`go test -race ./...` from `services/nvpair-engine-manager`.
- Component version bumped per `services/VERSIONING.md`:
`nvpair-engine-manager` 0.17.4 → 0.17.5.
70 changes: 70 additions & 0 deletions services/nvpair-engine-manager/bind_validation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package main

import (
"bytes"
"context"
"encoding/json"
"io"
"strings"
"testing"
)

// codecCapture is an io.ReadWriter that discards reads and accumulates writes
// so a test can inspect the JSON-RPC frames a Manager emits.
type codecCapture struct{ buf bytes.Buffer }

func (c *codecCapture) Read(p []byte) (int, error) { return 0, io.EOF }
func (c *codecCapture) Write(p []byte) (int, error) { return c.buf.Write(p) }

// runOpError drives Manager.runOp with the given method/params and returns the
// JSON-RPC error frame it emitted, or "" if it emitted none. reachedExec is
// true when validation passed and runOp proceeded to the (nil, in tests)
// executor — the test only cares that no -32602 bind rejection was emitted.
func runOpError(t *testing.T, method, params string) (out string, reachedExec bool) {
t.Helper()
cap := &codecCapture{}
m := NewManager(NewCodec(cap), nil, nil)
msg := &Message{Method: method, Params: json.RawMessage(params)}
id := json.RawMessage(`"test-1"`)
msg.ID = &id
defer func() {
// The executor is nil in this fixture: validation-passing calls panic
// when they reach it, which is exactly the signal we want.
if recover() != nil {
reachedExec = true
}
}()
m.runOp(context.Background(), msg)
return cap.buf.String(), false
}

// TestRunOpRejectsNonLoopbackBind: engine:start (and install+start) used to
// accept any valid IP as a bind override, so a broker caller could put the
// unauthenticated engine API on 0.0.0.0. Remote starts hard-bind 127.0.0.1
// (controllifecycle.go); local starts now require loopback too.
func TestRunOpRejectsNonLoopbackBind(t *testing.T) {
for _, bind := range []string{"0.0.0.0", "192.168.1.5", "::"} {
out, reachedExec := runOpError(t, "engine:start", `{"engine":"ollama","bind":"`+bind+`"}`)
if reachedExec || !strings.Contains(out, "bind must be a loopback address") {
t.Errorf("bind %q: response %q (reachedExec=%v), want loopback rejection", bind, out, reachedExec)
}
}

// Invalid IPs are still rejected as invalid.
out, reachedExec := runOpError(t, "engine:start", `{"engine":"ollama","bind":"not-an-ip"}`)
if reachedExec || !strings.Contains(out, "bind must be a valid IP address") {
t.Errorf("invalid bind: response %q (reachedExec=%v), want invalid-address rejection", out, reachedExec)
}

// Loopback binds pass validation and reach the executor (which is nil in
// this fixture — the panic-recovery reports reachedExec).
for _, bind := range []string{"127.0.0.1", "::1"} {
out, reachedExec := runOpError(t, "engine:start", `{"engine":"ollama","bind":"`+bind+`"}`)
if !reachedExec || strings.Contains(out, "bind must be") {
t.Errorf("loopback bind %q: response %q (reachedExec=%v), want validation to pass", bind, out, reachedExec)
}
}
}
17 changes: 14 additions & 3 deletions services/nvpair-engine-manager/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,9 +289,20 @@ func (m *Manager) runOp(ctx context.Context, msg *Message) {
m.codec.RespondError(msg.ID, -32602, "port must be between 0 and 65535")
return
}
if p.Bind != "" && net.ParseIP(p.Bind) == nil {
m.codec.RespondError(msg.ID, -32602, "bind must be a valid IP address")
return
if p.Bind != "" {
ip := net.ParseIP(p.Bind)
if ip == nil {
m.codec.RespondError(msg.ID, -32602, "bind must be a valid IP address")
return
}
// Local starts never expose the engine beyond loopback: engine APIs
// are unauthenticated, and remote starts hard-bind 127.0.0.1 (see
// controllifecycle.go). A non-loopback override would silently put
// them on the LAN.
if !ip.IsLoopback() {
m.codec.RespondError(msg.ID, -32602, "bind must be a loopback address")
return
}
}
start := func() error {
return m.exec.StartWith(ctx, p.Engine, startOpts{Port: p.Port, Bind: p.Bind})
Expand Down
2 changes: 1 addition & 1 deletion services/versions.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"nvpair-errors": "0.7.4",
"nvpair-node-settings": "1.0.4",
"nvpair-ui-broker": "0.40.2",
"nvpair-engine-manager": "0.17.4",
"nvpair-engine-manager": "0.17.5",
"nvpair-cluster-manager": "1.1.4",
"nvpair-job-scheduler": "0.4.1",
"nvpair-tui": "0.7.2"
Expand Down