GitNexus/Dockerfile.cli
Shifra Williams 68f0e24d98
fix(render): restore the /api/* reverse proxy in docker-server.mjs (#7)
* fix(render): implement the /api/* proxy the Blueprint assumed

The Blueprint wired GITNEXUS_UPSTREAM_URL into gitnexus-web and its
comment claimed docker-server.mjs proxied /api/* to the private server.
No such proxy existed -- docker-server.mjs was a pure static file server
and nothing in the repo read GITNEXUS_UPSTREAM_URL. So the deployed UI
had no reachable backend: GITNEXUS_BACKEND_URL was unset, no
window.__GITNEXUS_CONFIG__ was injected, and ui-constants.ts fell back to
http://localhost:4747 in the visitor's own browser.

Restore the implementation from fork history (ecdcb0ef):

- docker-server.mjs: real reverse proxy for /api/*, accepting the
  scheme-less host:port that `fromService: hostport` yields, with
  hop-by-hop header stripping, bounded body buffering, retry across an
  upstream restart, and an idle timeout. Falls back to
  RENDER_EXTERNAL_URL for backendUrl so the browser's API calls are
  same-origin and this proxy forwards them.
- Dockerfile.cli: honor an injected $PORT (default 4747) instead of
  hardcoding the port, so Render's port detection and `hostport` agree.

Unset GITNEXUS_UPSTREAM_URL disables the proxy entirely, so
docker-compose behavior is unchanged. render.yaml is deliberately left
alone here -- the Blueprint topology is a later change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(render): name the real env var in URL warnings

backendUrl now falls back to RENDER_EXTERNAL_URL, so a malformed
RENDER_EXTERNAL_URL was reported as a bad GITNEXUS_BACKEND_URL. Name
whichever variable actually supplied the value, and let validHttpUrl quote
the operator's raw input rather than the derived string -- so a bad
GITNEXUS_UPSTREAM_URL is echoed back as set, not with http:// prepended.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(deploy): rewrite X-Forwarded-For and strip response hop-by-hop headers

The API trusts private-network hops and keys its per-IP rate limiters off
req.ip, so forwarding the client's X-Forwarded-For chain verbatim let any
caller rotate a fake address per request and slip the limits on the
expensive analyze/embed routes. Collapse the chain to the entry Render's
load balancer appends -- the rightmost, never the leftmost, since the
leftmost is client-supplied and spoofable -- or the socket peer when there
is no chain. The chain is replaced, not appended, which is what keeps
GITNEXUS_TRUST_PROXY=1 correct despite the extra hop.

Also drop hop-by-hop headers from the upstream response, not just the
request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(deploy): extract proxy header helpers

Pull the X-Forwarded-For chain collapse and the hop-by-hop strip out of
proxyToUpstream into named helpers, matching the file's existing
small-helper style (validHttpUrl, readBodyCapped, failGateway). Sharing one
stripHopByHopHeaders call site in each direction makes the
request/response symmetry visible. No behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: tighten comments across the proxy changes

Compress the prose in docker-server.mjs, Dockerfile.cli, and the proxy
tests to the non-obvious "why" only -- including stating plainly that
terminating Origin leaves the API's CSRF guard with no traffic to protect.
No behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(deploy): keep the restored diff strictly proxy-scoped

The restored file carried one line unrelated to the proxy -- an `.ico` entry
in `contentTypes`. Drop it so that with GITNEXUS_UPSTREAM_URL unset the
static-serving behavior is byte-identical to before, and every hunk in this
PR is proxy work. Serving favicon.ico as image/x-icon is worth doing, just
not here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(deploy): extract the log-sanitizing helper both env readers duplicated

`validHttpUrl` and `numberFromEnv` each inlined the same control-character
scrub and 200-char truncation before echoing an operator value into a warning.
Name it once so the log-forging guard is a single, obvious thing rather than a
pattern a future env reader has to remember to copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(deploy): harden the restored proxy against config and framing edge cases

Four findings from a clean-code pass over the restore, each a case where the
code was right for the deployment we expect and wrong for one we might get:

- numberFromEnv now rejects out-of-range values. Every consumer reads <= 0 as
  "disabled", so GITNEXUS_PROXY_TIMEOUT_MS=-1 removed the idle timeout outright
  and hung proxied requests. Warn and keep the documented default instead of
  obeying a typo. Subsumes the Math.max(1, ...) on retry attempts.
- stripHopByHopHeaders drops headers that Connection *names* as single-hop, not
  just the well-known eight (RFC 7230 6.1). The fixed list alone leaked them.
- Retry eligibility follows body framing rather than method (RFC 7230 3.3.3):
  a bodyless DELETE buffers nothing, so it is replayable, and keying off the
  method sent it down the stream-once path for no reason.
- proxyToUpstream asserts the resolved target's origin. The /api/ route guard
  already makes req.url host-relative, but that leaves an SSRF boundary resting
  on a two-step argument; this proxy has exactly one legitimate destination.

Deferred, not fixed: clientAddressFor trusts X-Forwarded-For unconditionally,
which only holds behind an appending load balancer. That's a trust-boundary
decision and lands with the edge token instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(deploy): fold ten copies of the proxy fixture into one, tighten comments

The proxy tests each stood up the same four things — a dist/ to serve, a fake
upstream, a docker-server pointed at it, and teardown that leaks neither a
process nor a temp dir — and differed only in how the upstream misbehaves. Two
named helpers plus eight inline copies, ~200 lines of it.

withProxy() takes that difference as options: a handler (or null for a port
nothing listens on), a late bind for the restart window, a scheme-less upstream
URL, and env overrides. Every test now reads as its own setup plus assertions,
and what the upstream saw arrives in one ctx object instead of a per-test
closure variable.

Same 39 tests, same assertions. Verified by mutating the source three ways —
rightmost XFF to leftmost, dropping the Connection-list strip, keying hasBody
off the method — and confirming exactly the four expected tests fail.

Also trimmed the wordier comments on both files down to the load-bearing part.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 10:20:31 -07:00

131 lines
7.2 KiB
Text

ARG BUILDPLATFORM
ARG TARGETPLATFORM
# Pinned npm version used to replace the bundled npm in the upstream Node
# image. Bumping requires a coordinated update in Dockerfile.web and
# gitnexus/Dockerfile.test so all images bootstrap the same npm.
ARG NPM_VERSION=11.14.1
# -- Builder -----------------------------------------------------------
# Native modules (tree-sitter-*, onnxruntime-node, node-gyp builds for
# tree-sitter-proto / tree-sitter-swift) require python3 + a C/C++ toolchain.
# node:22-bookworm-slim
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS builder
ARG NPM_VERSION
WORKDIR /app
RUN npx --yes npm@${NPM_VERSION} install -g npm@${NPM_VERSION}
# Toolchain for node-gyp / native builds.
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ git && rm -rf /var/lib/apt/lists/*
# Build gitnexus-shared first - gitnexus depends on it as a workspace.
COPY gitnexus-shared/package.json gitnexus-shared/package-lock.json ./gitnexus-shared/
RUN npm ci --prefix gitnexus-shared
COPY gitnexus-shared ./gitnexus-shared
RUN rm -f gitnexus-shared/tsconfig.tsbuildinfo
RUN npm run build --prefix gitnexus-shared
# Copy the full gitnexus package before installing - `npm ci` triggers
# `postinstall` (patches tree-sitter-swift, builds the vendored
# tree-sitter-proto) and `prepare` (compiles TypeScript via scripts/build.js),
# both of which need the source tree.
COPY gitnexus ./gitnexus
RUN npm ci --prefix gitnexus
# Drop dev dependencies for a smaller runtime layer.
RUN npm prune --omit=dev --prefix gitnexus
# `npm prune` removes anything not in package.json's dependency tree — which
# includes the VENDORED tree-sitter grammars (materialized into node_modules/ by
# postinstall, but not declared as deps) and their freshly-built native bindings.
# The `serve` image analyzes/parses uploaded repos at runtime, so those grammars
# must survive into the runtime layer. Re-run the grammar postinstall here in the
# builder (which still has python3/make/g++ and the hoisted node-addon-api /
# node-gyp-build) to re-materialize + rebuild them after the prune. This is
# load-bearing for tree-sitter-c (a core, REQUIRED grammar now vendored, #2116):
# as a former `dependency` it used to survive prune; vendored, it would not.
RUN npm run postinstall --prefix gitnexus
# -- Runtime -----------------------------------------------------------
# node:22-bookworm-slim
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime
# curl for the healthcheck; git for cloning; ca-certificates for TLS verification.
RUN apt-get update && apt-get install -y --no-install-recommends curl git ca-certificates && rm -rf /var/lib/apt/lists/* \
&& rm -rf /usr/local/lib/node_modules/npm \
&& rm -rf /usr/local/lib/node_modules/corepack \
&& rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack
WORKDIR /app
# Pre-create the data directory and hand it to the unprivileged `node` user
# so the bind-mounted volume is writable without root.
RUN mkdir -p /data/gitnexus && chown -R node:node /data
COPY --from=builder --chown=node:node /app/gitnexus/dist ./gitnexus/dist
COPY --from=builder --chown=node:node /app/gitnexus/node_modules ./gitnexus/node_modules
COPY --from=builder --chown=node:node /app/gitnexus/package.json ./gitnexus/package.json
COPY --from=builder --chown=node:node /app/gitnexus/scripts/install-duckdb-extension.mjs ./gitnexus/scripts/install-duckdb-extension.mjs
COPY --from=builder --chown=node:node /app/gitnexus/vendor ./gitnexus/vendor
# Expose the `gitnexus` binary on PATH so the documented Docker workflow
# (`docker compose exec gitnexus-server gitnexus index /workspace/<repo>`)
# works without users having to invoke `node /app/gitnexus/dist/cli/index.js`.
# `npm prune --omit=dev` in the builder stage strips `node_modules/.bin/`
# entries, so the `gitnexus` bin declared in package.json (`dist/cli/index.js`,
# which already carries `#!/usr/bin/env node` and 755 perms) is otherwise
# unreachable from $PATH.
RUN ln -s /app/gitnexus/dist/cli/index.js /usr/local/bin/gitnexus
# Bake the LadybugDB FTS extension into the image so BM25 keyword search works
# at runtime. The server runs the default `load-only` extension policy (the read
# pool pins `{ policy: 'load-only' }`), so a runtime `LOAD EXTENSION fts` never
# INSTALLs — the extension must already exist in the runtime user's HOME
# extension dir, or every keyword search silently degrades (no FTS indexes are
# written and ranking falls back to vector-only with only a `warning` field).
# Run the installer as the `node` user with the SAME HOME the server runs under,
# so `INSTALL fts` materializes the extension under `$HOME/.lbdb/extension` where
# the runtime `LOAD` resolves it offline. `ENV HOME` is pinned because Docker
# does not derive HOME from `USER`, so without it build-install and runtime-load
# would resolve different paths. Requires network egress for the one-time
# INSTALL; the build fails loudly if it cannot fetch the extension. The DB-size
# default comes from GITNEXUS_LBUG_MAX_DB_SIZE (single source of truth, matches
# the runtime) — it only sizes the throwaway scratch DB used to run INSTALL.
# The second `--verify-only` step re-LOADs the extension in a FRESH process
# under the same HOME, so a HOME/extension-dir mismatch fails the build here
# rather than silently degrading keyword search to vector-only at runtime.
ENV HOME=/home/node \
GITNEXUS_LBUG_MAX_DB_SIZE=17179869184
RUN su node -s /bin/sh -c "HOME=/home/node node /app/gitnexus/scripts/install-duckdb-extension.mjs fts" \
&& su node -s /bin/sh -c "HOME=/home/node node /app/gitnexus/scripts/install-duckdb-extension.mjs fts --verify-only"
# Published runtime assets (in package.json `files`). Placed AFTER the DuckDB
# FTS-extension RUN above so editing hook/skill content does not invalidate that
# network-fetching cache layer; they have no input dependency on it.
# `hooks/`: dist/cli/resolve-invocation.js does
# `require('../../hooks/claude/resolve-analyze-cmd.cjs')` at module load — the
# single source of truth for the npm-11 npx-crash invocation decision (#1939).
# Without it, `gitnexus analyze` inside the image crashes with MODULE_NOT_FOUND
# before it does any work (#2130). `skills/`: the CLI reads the bundled SKILL.md
# templates from `<pkg>/skills/` for `gitnexus analyze --skills` and `gitnexus
# setup`/`uninstall`; absent, those degrade silently (placeholder content / zero
# skills installed). (The web UI bundle `web/`, also in `files`, is deliberately
# NOT shipped: this builder never builds gitnexus-web, so the image is API-only;
# the UI is the separate Dockerfile.web image / hosted app.)
COPY --from=builder --chown=node:node /app/gitnexus/hooks ./gitnexus/hooks
COPY --from=builder --chown=node:node /app/gitnexus/skills ./gitnexus/skills
USER node
# The web UI defaults to http://localhost:4747 - keep that contract.
ENV GITNEXUS_HOME=/data/gitnexus \
NODE_ENV=production \
PORT=4747
EXPOSE 4747
# Bind 0.0.0.0 for the host's mapped port, honoring an injected $PORT (Render
# sets one). `sh -c` expands it; `exec` keeps the server PID 1 so SIGTERM still
# reaches it. Platforms can rely on this instead of a dockerCommand override.
CMD ["sh", "-c", "exec gitnexus serve --host 0.0.0.0 --port \"${PORT:-4747}\""]