Skip to content

Opt-in, API-key-authenticated LAN access to the compatibility endpoints - #38

Open
CryptoJones wants to merge 4 commits into
NVIDIA:mainfrom
CryptoJones:feat/opt-in-authenticated-lan-ingress
Open

CryptoJones wants to merge 4 commits into
NVIDIA:mainfrom
CryptoJones:feat/opt-in-authenticated-lan-ingress

Conversation

@CryptoJones

@CryptoJones CryptoJones commented Sep 6, 2026

Copy link
Copy Markdown

Description

Closes #28.

PAIR's Ollama- and OpenAI-compatible proxies refuse every plaintext request that does not arrive from loopback (403 loopback-only). That is the right default, but it leaves no supported way for an ordinary OpenAI-compatible client on a trusted LAN — an automation host, a container, a Kubernetes workload, a Windows workstation's SDK — to use a node without running PAIR itself. #28 asks for an explicit, documented opt-in; the relay described in its comments (#31) shows people are already working around the gate with something that does no authentication at all.

This adds an opt-in, API-key-gated path for non-loopback plaintext callers, shared by both proxies:

  • Default unchanged. With no key configured, a LAN caller still gets exactly the same 403 loopback-only. TestHandlePlainRejectsNonLoopback is untouched in both proxies.
  • Opt-in by configuring a key: a key file (NVPAIR_PROXY_API_KEYS_FILE, default proxy-api-keys in the PAIR data directory) and/or NVPAIR_PROXY_API_KEYS inline. One key per line, # comments; a key must be ≥ 32 characters from the RFC 6750 b64token alphabet (letters, digits, - . _ ~ + / =), generated randomly.
  • With a key configured, a non-loopback caller presenting it as Authorization: Bearer <key> (or X-Api-Key: <key>, the Anthropic SDK convention — relevant to [Feature]: Expose the Anthropic Messages API (POST /v1/messages) on the PAIR proxy #16/Add Anthropic Messages API Routing #27; either header may carry it) is routed through the same local router a loopback client uses. Missing key → 401 unauthorized with WWW-Authenticate: Bearer realm="nvpair-proxy"; a presented but unknown key adds error="invalid_token" (RFC 6750 §3.1).
  • Optional source allowlist NVPAIR_PROXY_ALLOWED_CIDRS; a caller outside it gets 403 source-not-allowed before its key is even examined.
  • Loopback callers are never asked for a key — the desktop app, TUI, and local tools are unaffected. A loopback client's own Authorization header is forwarded untouched, as today.
  • The key is stripped from an admitted request before it is forwarded, so PAIR's credential never reaches an engine or a peer.
  • Fails closed everywhere. A key file that other users can read or that another user owns (Unix checks, made on the opened handle), contains a malformed entry, is a directory, or cannot be read contributes no keys and the LAN stays closed; the reason is logged once per distinct problem. A malformed inline key or CIDR disables the gate for the process — a mistyped allowlist must not silently leave the allowlist off.
  • Hot reload. The key file is re-read per non-loopback request and swapped in when its content hash changes (a size/mtime stamp cannot see a same-length rewrite within the filesystem's timestamp granularity), so keys can be added, rotated, or revoked without a restart — matching how mesh.Refresh() already handles cluster membership. The file is a few short lines; the read is cheap.
  • Secret hygiene. Keys are held in memory only as SHA-256 digests; comparison is crypto/subtle.ConstantTimeCompare over every configured digest with no early exit; a rejection logs the caller's address and the first 8 hex digits of the presented key's digest, never the key. Forwarding headers (X-Forwarded-For etc.) are never consulted. Enabling the gate logs a WARN at startup and on every key-set change.
  • OPTIONS preflight is still answered 204 ahead of the gate (browsers send no Authorization on a preflight); the existing test for that ordering is unchanged.

This does not reopen the amplification concern that keeps the mTLS ingress terminal: an authenticated LAN caller takes the same one-hop path a loopback client takes today, and a peer hop still lands on the mTLS ingress, which never routes onward.

Scope

In: nvpair-shared/ingressauth (new package, the single implementation both proxies use, following the nvpair-shared/cors precedent); the gate call in handlePlain of both proxies; tests at both layers; documentation.

Out (deliberately): rate limiting / lockout (a ≥ 32-character random key makes online guessing infeasible; can be added later without changing the contract); TLS on the plaintext personality (terminate TLS in front if the network is not trusted — documented); Settings-tab / JSON-RPC wiring for the key (the key file is the operator surface for this PR — no JSON-RPC method or payload changes, so no broker relay, desktop bridge, or service-contracts changes); per-key scopes.

Validation

Go 1.26 on macOS 15 (arm64) and Ubuntu (x86_64). Desktop tree untouched, so the npm gates were not run.

Gate macOS Linux
gofmt -l / go vet ./... on shared, ollama-proxy, lmstudio-proxy clean clean
go test ./... services/shared (incl. new ingressauth, 24 tests, also under -race) ok ok
go test ./... services/lmstudio-proxy ok ok
go test ./... services/ollama-proxy 1 pre-existing failure¹ ok
gate tests in both proxies under -race ok
go test -fuzz FuzzValidateKey / FuzzParseKeys / FuzzCredentialsFrom, 10 s each no findings
services/build.sh (13 binaries; ollama-proxy --version → 0.27.0, lmstudio-proxy --version → 0.17.0) ok ok
make test-services (every Go module) all ok except services/tests²
cd services/tests && go test ./... (cross-process, real binaries) ok (355 s) 6 failures²
node scripts/spdx-headers.mjs 875 checked, 0 missing 875 checked, 0 missing

¹ TestAliasSelfTargetMatchesBoundLoopbackAddressNotPortAlone binds 127.0.0.2, which macOS does not alias by default; it fails identically on pristine main (13b6811) and passes on Linux.
² TestWorkloadManager* / TestWorkload* in services/tests fail identically on pristine main on that host (server cert not pinned against 127.0.0.1:14320); the host runs a live PAIR service tree (cluster manager on :14321). They pass on the macOS host and do not exercise this change.

Live LAN check, ollama-proxy built from this branch on a spare port (11499) on one node, probed with curl from another node on the LAN, key file 0600 holding one openssl rand -hex 32 key:

Probe Result
startup log WARN authenticated LAN ingress ENABLED keys=1 …
loopback, no key routed (the standalone proxy's own 503 model inventory unavailable, since it knows no nodes; the gate is what is under test)
LAN, no key 401, Www-Authenticate: Bearer realm="nvpair-proxy", Access-Control-Allow-Origin: *, body {"code":"unauthorized",…}; log key_fp=none
LAN, wrong key 401; log key_fp=108f5470
LAN, right key as Authorization: Bearer routed; log key_fp=7cf2fd42 = sha256(key)[:8]
LAN, right key as X-Api-Key routed
chmod 644 key file next LAN request → 403 loopback-only; log ERROR … permissions 0644 allow other users to read it; chmod 600, then INFO … disabled
chmod 600 again next LAN request routed, no restart; log WARN … ENABLED
NVPAIR_PROXY_ALLOWED_CIDRS=10.99.0.0/24, LAN caller outside it, right key 403 source-not-allowed; log key_fp=none (key never examined)
key text anywhere in the proxy log 0 occurrences

Independent review. The diff was reviewed by a panel of twelve independently trained models (Anthropic, OpenAI, Google, DeepSeek, Mistral, Z.AI, MiniMax, NVIDIA Nemotron, poolside), one at a time, each blind to the others. No lane found a bypass. Adopted into the final commit: the key file is opened and checked through the opened handle and re-read by content hash (closes a stat/open TOCTOU and a same-size, same-mtime rewrite that a stat stamp cannot see); a single Authorize call reports enablement and judges the request from one view (no window between "is the gate on" and "is this key good"); the documentation no longer suggests a same-host TLS-terminating proxy, which would present every external client as a loopback caller; RFC 6750 error="invalid_token" on a rejected key; either presented header may carry the key; an sshd-style owner check on the key file; the key alphabet restricted to b64token characters and bounded at 512 bytes (a presented credential longer than that is not hashed); the CIDR allowlist applied before the preflight; a 1 s re-check floor so a node that never opted in does not open() the key file per LAN request; key rotation logged even when the key count is unchanged; successful LAN authentications logged at Info; fuzz targets and a concurrent-rotation test; and SECURITY.md statements about cluster-wide reach of a key, multi-user hosts, same-host reverse proxies, same-user processes, and Windows ACLs.

Risk

  • Security: widens the proxy's exposure only when an operator configures a key; nothing changes otherwise. The plaintext personality remains plaintext when enabled — documented prominently in SECURITY.md, the getting-started guide, and the startup WARN. A holder of a key can do everything a local application can, including routing to peers; documented.
  • Compatibility: additive. No JSON-RPC, payload, port, or default behavior changes. The desktop/ tree is untouched.
  • Pre-existing, out of scope, named so it is not mistaken for new: the loopback exemption is address-based, so a page that DNS-rebinds to 127.0.0.1 in a LAN user's browser reaches the proxy as a loopback caller today, before and after this change. fix: resolve 14 review issues across the service control plane #14's deny-by-default Origin allowlist is the fix path for that; this PR does not touch it.
  • Conflict note for maintainers: fix: resolve 14 review issues across the service control plane #14 also edits both ingress.go files (CORS deny-by-default, writeIngressError signature). This change is a self-contained block inside handlePlain and uses the same NVPAIR_PROXY_* env-var convention, so either order rebases cleanly.

Versions

ollama-proxy 0.26.2 → 0.27.0 and lmstudio-proxy 0.16.2 → 0.17.0 (MINOR: new, additive, HTTP-visible behavior). Per services/VERSIONING.md, a MINOR component bump implies product/installer 0.91.7 → 0.92.0; I applied that rule literally — please adjust if the product series is managed differently.

AI disclosure

This change was written with AI assistance and reviewed by a panel of AI models, under the direction of the human author, who tested it on his own hardware and takes responsibility for it.

  • Authored: Claude (Anthropic, Fable 5.1) running in Claude Code, operated by Aaron K. Clark (CryptoJones), who set the design decisions (opt-in via env/key file, loopback exempt, CIDR allowlist, full-router routing) and ran the live LAN checks on his cluster.
  • Quality review: the diff and design were put to twelve independently trained models, one at a time, each blind to the others' answers; every finding was verified against the code before being acted on, and the adopted ones are listed under Independent review above.
Reviewer Model
Anthropic Claude Opus (opus), Claude Sonnet (sonnet), Claude Haiku (haiku), Claude Fable 5.1 (fable) — via the claude CLI; the Opus lane also ran probes against the working tree
OpenAI openai/gpt-oss-120b
Google gemini-3.6-flash-medium (via agy)
DeepSeek deepseek/deepseek-v4-flash
Mistral mistralai/mistral-large-2512
Z.AI z-ai/glm-5.3-flash
MiniMax minimax/minimax-m2.7
NVIDIA nvidia/nemotron-3-super-120b-a12b
poolside poolside/laguna-s-2.1

No model found a bypass. Transcripts of every lane's answer are retained by the author and can be shared with maintainers on request.

Checklist

  • I have read the Contributing Guidelines.
  • Every commit is signed off (git commit -s), certifying the Developer Certificate of Origin.
  • New or existing tests cover the change.
  • Relevant documentation is updated.
  • I checked the diff, changed filenames, and commit messages for credentials, private data, internal URLs, internal issue identifiers, and generated artifacts.
  • I recorded the validation commands and results above.
  • I bumped any affected component in services/versions.json, and described user-visible changes above so they reach the release notes.

Proudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/

🤖 Generated with Claude Code

https://claude.ai/code/session_01BwxtwuoRxP75PdR6NAmMS3

CryptoJones and others added 4 commits September 5, 2026 19:47
…k plaintext callers

Both inference proxies refuse plaintext requests that do not arrive from
loopback. This package is the credential gate they will share so a LAN
caller can be admitted only when the operator opts in by configuring API
keys (NVPAIR_PROXY_API_KEYS_FILE, default <appdir>/proxy-api-keys, or
NVPAIR_PROXY_API_KEYS) and the caller presents one as Authorization:
Bearer or X-Api-Key, optionally restricted by NVPAIR_PROXY_ALLOWED_CIDRS.

Keys are held only as SHA-256 digests and compared in constant time
across every configured digest with no early exit. Every failure fails
closed: a key file readable by other users, a malformed entry, an
unreadable file, or a malformed CIDR contributes no keys. The key file
is re-read when its size, modification time, or mode changes, so keys
can be rotated or revoked without a restart. A rejected key is logged
only as an eight-hex-digit digest fingerprint.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxtwuoRxP75PdR6NAmMS3
Signed-off-by: Aaron K. Clark (CryptoJones) <cryptojones@owasp.org>
…io proxies when keys are configured

With no key configured nothing changes: a non-loopback plaintext request
is still refused with 403 loopback-only. Once the operator configures a
key, handlePlain consults nvpair-shared/ingressauth for a non-loopback
caller: a caller outside NVPAIR_PROXY_ALLOWED_CIDRS gets 403
source-not-allowed, a missing or unknown key gets 401 unauthorized with
a Bearer challenge, and a caller presenting a configured key has the
credential stripped and is routed through the same local router a
loopback client uses. Loopback callers are never asked for a key, and
an OPTIONS preflight is still answered ahead of the gate.

Bump ollama-proxy to 0.27.0 and lmstudio-proxy to 0.17.0 (additive
HTTP-visible behavior), and product/installer to 0.92.0 per VERSIONING.

Closes NVIDIA#28.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxtwuoRxP75PdR6NAmMS3
Signed-off-by: Aaron K. Clark (CryptoJones) <cryptojones@owasp.org>
Loopback-only stays the documented default. SECURITY.md, the
architecture trust tables, the getting-started guide (a new "Reaching
PAIR from Another Machine" section with key generation, file location
per OS, permissions, and client configuration), troubleshooting (the
401 unauthorized and 403 source-not-allowed cases), the overview, both
proxy READMEs (environment variables and response codes), and the
OpenAPI description (securitySchemes) now describe the opt-in gate, what
it checks, what it does not add, and the exposure an operator accepts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxtwuoRxP75PdR6NAmMS3
Signed-off-by: Aaron K. Clark (CryptoJones) <cryptojones@owasp.org>
Read the key file through the opened handle and re-read it by content hash
rather than a stat stamp: a stat-then-open check validated a file it did
not necessarily read, and a stamp of size and modification time cannot see
a same-length rewrite within the filesystem's timestamp granularity, which
is exactly the rotation a compromised key needs. Re-check at most once per
second so a node that never opted in does not pay an open() per LAN
request. Refuse a key file another user owns, the way sshd treats
authorized_keys, and fail closed when ownership cannot be determined.

Judge each request from one Authorize call that also reports whether the
gate is enabled, so enablement and the key set cannot change between the
two questions. Apply the CIDR allowlist before answering a preflight, so a
source the operator excluded gets nothing. Accept the key from either the
Authorization or X-Api-Key header when both are present, since an SDK may
send a placeholder Bearer beside the real key. Carry error="invalid_token"
on a rejected key per RFC 6750, restrict keys to the b64token alphabet so
they survive any conformant intermediary, and log a key rotation even when
the key count is unchanged, as SECURITY.md promises.

Documentation: a TLS-terminating or reverse proxy on the same host presents
every client as loopback and must do its own authentication; a key reaches
inference routed to peers and Ollama's model-management routes; inline
environment keys remain in the process environment in clear; Windows
relies on the data directory's ACL; any same-user process can enable the
gate by creating the file. Add fuzz targets for the parsers and header
extraction, a concurrent-rotation test, and the isLoopbackRemote table
test to lmstudio-proxy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxtwuoRxP75PdR6NAmMS3
Signed-off-by: Aaron K. Clark (CryptoJones) <cryptojones@owasp.org>
@DustinTrap

Copy link
Copy Markdown

Tried it on the OpenShift setup from #31/#32, as asked on #28. It does what the description says, and it is the better answer for a Kubernetes workload than the relay. Two findings for Kubernetes users, one of which is a fail-closed path that will catch people.

Environment. ollama-proxy 0.27.0 and lmstudio-proxy 0.17.0 built from a4ca630 (linux/amd64, Go 1.26, CGO_ENABLED=0), every other service the 0.1.1 release, running as a hostNetwork pod on single-node OpenShift 4.22 (OVN-Kubernetes) under an arbitrary UID, paired with a 0.1.1 Linux node holding the GPU. Callers: a pod on the cluster network, and a macOS host on the LAN. Key: 64 hex characters from openssl rand, held in a Kubernetes Secret. NVPAIR_PROXY_ALLOWED_CIDRS=10.128.0.0/14 (the cluster's pod network). The env vars reach the proxies unchanged: nvpair-tui, the broker, and the proxy launches all inherit the environment.

Inline key (NVPAIR_PROXY_API_KEYS from the Secret via valueFrom), callers hitting the node's own port 1234:

Caller Request Result
pod, no key GET /v1/models 401, Www-Authenticate: Bearer realm="nvpair-proxy", {"code":"unauthorized",…}
pod, right key, Authorization: Bearer GET /v1/models 200
pod, right key, X-Api-Key GET /v1/models 200
pod, wrong key GET /v1/models 401, Www-Authenticate: … error="invalid_token"
pod, right key GET :11434/api/tags (Ollama proxy) 200
pod, right key POST /v1/chat/completions, 27B model 200, routed to the GPU node over cluster mTLS, "pong", usage reported
LAN host, right key GET /v1/models 403 {"code":"source-not-allowed","error":"the caller's address is outside NVPAIR_PROXY_ALLOWED_CIDRS"}; log code=source-not-allowed, key never examined
LAN host, no key GET /v1/models 403 source-not-allowed
loopback (the relay from #31, no key) GET /v1/models 200, unchanged

Finding 1: the key-file path fails closed on Kubernetes, by design, and the reason is invisible to most operators. A Secret mounted as a file under a pod fsGroup (which every OpenShift SCC sets, and many vanilla clusters do) arrives as -r--r----- root:<fsGroup>, 0440. ownedByProcessUser accepts uid 0, but the perm check rejects the group bit, so the gate stays closed: with only NVPAIR_PROXY_API_KEYS_FILE set and the mount at defaultMode: 0400, a LAN caller with the right key still got 403 loopback-only. Two possible answers, either is fine: accept group-readable when the file's gid is the process's own primary or supplementary gid (that is exactly the fsGroup contract, and the group has nobody else in it), or say in the docs that on Kubernetes the key goes in through NVPAIR_PROXY_API_KEYS from a Secret via valueFrom, and the file path is for hosts. I would lean to the docs line plus the gid check.

Finding 2, a good one: a bad key file does not poison inline keys. With the 0440 file still configured and the inline var added, the gate enabled and every row above held. So "contributes no keys" is per source, not per process; worth one sentence in the README since the description's "the LAN stays closed" reads as global.

Source address on OVN-Kubernetes, for the ALLOWED_CIDRS advice: a pod-network caller reaching a host-network pod on the same node is seen with its pod IP (10.128.0.x), not the node's management address, so the pod CIDR is the right allowlist entry.

Not captured: the startup WARN … ENABLED and the once-per-problem key-file error. The headless TUI's Logs tab had scrolled past them by the time I looked, and the proxies do not write a file; the behavior above is the evidence. If you want the lines, a --log-level debug run is easy to repeat.

For #31: once this merges, the manifests there can point the Service at port 1234 with the key from a Secret and drop the relay entirely, which also removes the trust-boundary paragraph I had to correct today.

@Noah-Tervalon-Nvidia

Copy link
Copy Markdown
Collaborator

This is a great idea, thank you for bringing it up. We're having some discussions internally about the security implications and how we want to navigate that and will get back to you on this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Allow authenticated opt-in LAN access to local compatibility endpoints

4 participants