Skip to content

feat: Publish a proxy-only image and support a remote upstream - #12

Merged
harshaneel merged 18 commits into
mainfrom
hg/proxy-only-image
Aug 5, 2026
Merged

feat: Publish a proxy-only image and support a remote upstream#12
harshaneel merged 18 commits into
mainfrom
hg/proxy-only-image

Conversation

@harshaneel

Copy link
Copy Markdown
Owner

Summary

  • New :proxy image variant, 41 MB. Alpine plus pdftoppm plus the Go binary, with no inference engine and no model. For anyone who already runs an OpenAI-compatible server and wants only the translation layer instead of pulling 3.16 GB and running a second model they will never call. Added as a middle Dockerfile stage so the llama.cpp stage stays last and a bare docker build . keeps producing the full image.
  • Configuration by environment. LK_UPSTREAM and LK_UPSTREAM_AUTH_HEADER, with flag beating environment beating default, so the existing entrypoint keeps working unchanged. LK_UPSTREAM_AUTH_HEADER takes a complete header line (Authorization: Bearer abc123) rather than a bare token, which covers Bearer, llama.cpp's --api-key, and custom schemes without the proxy needing to know which is in use.
  • Upstream credential injected once, in the transport. All six upstream call sites already flow through one http.Client, so a RoundTripper wrapper covers every route including streaming and cannot be forgotten when a seventh path is added. Credentials that clients send to localaik are still stripped and never forwarded; the two properties are asserted together across 11 routes.
  • Published from CI as proxy and vX.Y.Z-proxy, never latest. Anyone pulling latest today gets a self-contained container, and silently turning that into one requiring an external server would break them. The existing gemma3-4b and gemma3-12b entries are unchanged apart from an added empty target, which docker/build-push-action omits when falsy.

Security fix found during review

Three independent pre-PR reviews ran over this branch. Two of them separately reproduced a credential-exfiltration path in the first version of the transport, which is fixed in d28e31d:

RoundTrip set the header on every request it saw. An http.Client re-enters its RoundTripper for each redirect hop, and the standard library strips Authorization only from the request it built itself, so setting the header per-request re-added it after that strip. A 302 from the upstream handed the credential to whatever host the Location named, over cleartext by default.

The fix pins the header to the configured upstream hostname, and the credentialed client no longer follows redirects at all, since a followed redirect would also send prompt content to the target. Redirect handling stays at the standard library default when no credential is configured, so the model-bundled images are unaffected. A follow-up review confirmed the fix holds under adversarial probing (redirect chains, case and trailing-dot hostname variants, port and scheme changes, IPv6 literals, userinfo spoofing) and that each of the three new tests fails when the specific change it pins is reverted.

One residual note is recorded in a comment at the CheckRedirect site: the pin is on hostname only, so relaxing redirect handling later would reopen cross-port and scheme-downgrade leakage.

Reviewer-flagged follow-ups, deliberately not in this PR

  • The proxy image runs as root with no USER directive.
  • entrypoint.sh has a pre-existing bug, present on main and untouched here: a hardcoded 120s model-load timeout whose cleanup() then blocks forever in wait, because the kill does not terminate llama-server. Under host load the container wedges with llama.cpp healthy on 8080 and nothing listening on 8090.
  • integration/proxy_image_test.go builds an image inside the test and hardcodes its container name and host port, so two concurrent local runs collide.

Test Plan

  • make lint clean.
  • go test ./cmd/... ./internal/... all pass.
  • go test -tags=docker_integration ./integration -run ProxyImage passes: the built proxy image round-trips an OpenAI, a Gemini and an Anthropic request, and renders a PDF to PNG using alpine's poppler-utils.
  • The default image still builds and serves {"status":"ok"}, confirming the added stage did not displace the llama.cpp stage.
  • git diff main -- Dockerfile is the inserted stage only.
  • The credential never appears in container output: docker run -e LK_UPSTREAM_AUTH_HEADER=... 2>&1 | grep -c <sentinel> returns 0, for both the sentinel and Bearer.
  • Redirect behaviour: a redirect target on a different host receives no credential, the credentialed client surfaces the 3xx rather than following it, and a client with no credential still follows redirects.
  • Header validation is exercised at the predicate rather than only through a capturing transport, since a capturing transport bypasses Go's wire-level checks. Values containing further colons (Bearer a:b) are accepted; names and values that net/http would reject at the wire are rejected at startup instead.

To try it:

docker build --target proxy -t localaik:proxy .
docker run -d -p 127.0.0.1:8090:8090 \
  -e LK_UPSTREAM=http://your-server:8080/v1 \
  -e LK_UPSTREAM_AUTH_HEADER="Authorization: Bearer your-key" \
  localaik:proxy
curl -s http://127.0.0.1:8090/health

/health returns 503 until the configured upstream answers, 200 once it does.

⚠️ :proxy has a different risk profile from the bundled tags: it is a network hop into infrastructure you care about, and localaik authenticates none of its own callers by design. Anyone who can reach the port can drive your model server. Bind to localhost and do not publish it on a shared network.

🤖 Generated with Claude Code

harshaneel and others added 17 commits August 4, 2026 11:56
Design for a `:proxy` tag containing the translating proxy and poppler but no
inference stack, for users who already run llama.cpp or a shared internal model
server. The Go binary already accepts any OpenAI-compatible upstream, so the
work is packaging plus the upstream credential the proxy currently cannot send.

Also anchors the specs/ gitignore rule to the repo root. It had no leading
slash, so it matched at any depth and would have silently swallowed this file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eight tasks, TDD throughout. Alpine base, poppler-utils, tini and busybox wget
were verified against a real build: pdftoppm 25.12.0 is present and the base
plus packages measures 35 MB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Injected at the transport layer so every upstream request carries it without
each call site opting in.
Locks both halves at once: the proxy credential is added, the caller's is not
forwarded.
Adds subtests for /health, /v1/completions, /v1/models/{id}, /v1beta/models,
and /v1beta/models/{id} routes. Fixes streaming test to assert status code and
response body content.
Follows the pattern PORT already set, so the container needs no shell wrapper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
alpine plus poppler-utils and the binary, no inference stack. The llama.cpp
stage stays last so the default build is unchanged.
Builds the proxy Dockerfile target, runs it against a stub upstream
reached via host.docker.internal, and drives OpenAI, Gemini, and
Anthropic-shaped requests through it end to end. Also fixes a leaked
listener fd, a stale-container name collision, and an unsynchronized
cross-goroutine read found during review of the brief's literal code.
The stub returns one OpenAI-shaped payload for every route, and the
per-subtest assertion only checked for a non-empty JSON object. That
would pass even if routing sent a Gemini or Anthropic request to the
OpenAI passthrough handler. Assert the expected top-level key per
protocol (choices/candidates/content) and the assistant role on the
Anthropic response, so a misrouted handler fails the test.
Adds a build target to the matrix. Empty target keeps the existing entries on
the default final stage.
The transport set LK_UPSTREAM_AUTH_HEADER on every request it saw. An
http.Client re-enters its RoundTripper for each redirect hop, and the
stdlib strips Authorization only on the request it built itself, so
setting the header per-request re-added it after that strip. A 302 from
the upstream handed the credential to whatever host the Location named.

The transport now pins on the configured upstream hostname, and the
credentialed client no longer follows redirects at all, since a followed
redirect would also send prompt content to the target. Redirect handling
is left at the stdlib default when no credential is configured, so the
model-bundled images are unaffected.

Also extracts one header predicate, now rejecting names and values that
net/http would reject at the wire on every request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main.go had its own copy of the validation rules, so the startup warning
could drift from what the transport actually does and claim no credential
would be sent while one was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The spec required the PDF path be exercised inside the proxy image rather
than only against the Debian-based one, since alpine's poppler is a
different build. The README quickstart also published port 8090 on every
interface, which is what its own security warning tells operators not to
do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The make target tags $(IMAGE)-proxy, not gokhalh/localaik:proxy, so the
annotation implied a tag the command does not produce. The CheckRedirect
comment now names the invariant a reviewer found: the transport pins
hostname only, so relaxing redirect handling would reopen cross-port and
scheme-downgrade leakage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Design docs and plans are local working notes, the same treatment /specs/
already gets. Untracks the two that landed, which stay on disk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@harshaneel
harshaneel merged commit fff1fdb into main Aug 5, 2026
5 of 6 checks passed
@harshaneel harshaneel mentioned this pull request Aug 5, 2026
7 tasks
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.

1 participant