mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-01 07:02:35 +00:00
feat(render): private-service topology, docs, and a CI check on render.yaml (#10)
* feat(render): private-service topology, docs, and a CI check on render.yaml Rewrites the Blueprint around the topology Abhigyan called for: `serve` on a private service, the public web service reverse-proxying /api/* to it, and the generated token gating that edge. `gitnexus-server` becomes a `pserv`, so it has no public URL. `gitnexus-web` gets `GITNEXUS_UPSTREAM_URL` from `fromService … property: hostport`, `GITNEXUS_SERVE_AUTH_TOKEN` with `generateValue: true`, and `GITNEXUS_PROXY_TRUST_XFF: 1` for Render's load balancer. `autoDeploy: true` becomes `autoDeployTrigger: off` so a push cannot restart an indexing server mid-analyze. Four variables come out: - `GITNEXUS_PUBLIC_ORIGIN`: under the proxy the browser is same-origin with the web service and Origin is stripped, so it has no job — and since #2820 `assertServeAuthForPublicOrigin` refuses to start when it is set without serve-native auth. The old topology no longer boots. - `GITNEXUS_BACKEND_URL`: docker-server.mjs falls back to RENDER_EXTERNAL_URL, which is this service's own origin. - `AZURE_DEVOPS_URL` / `AZURE_DEVOPS_PAT`: per Abhigyan, "a stranger can POST /api/analyze an Azure URL, have the server spend the operator's credential, then read the private repo back through the ungated /api/file and /api/grep." The edge token closes the stranger path, but the knob still should not ship in a one-click prompt. Operators who want Azure DevOps set both in the dashboard, on a deploy they have already secured. README drops "with no API keys required" — it described missing authentication as a convenience, and the deploy now generates a token — and replaces it with the token-retrieval flow. It also corrects `plan` versus `sizeGB`: `plan` sets RAM, `sizeGB` only matters when the disk fills. Both the README and a new SECURITY.md section state plainly that the proxy strips Origin, so the CSRF guard does nothing for proxied traffic and the token is the only control — not a second layer behind the guard. SECURITY.md enumerates the routes a token holder reaches with no origin guard, and notes that `POST /api/mcp` has no `authToken` of its own as a pre-existing gap in `serve`. Nothing in CI parsed render.yaml, so the couplings the file documents were unenforced. `gitnexus/test/unit/render-blueprint.test.ts` asserts them: mountPath equals GITNEXUS_HOME, PORT matches Dockerfile.cli's CMD, the server stays a `pserv`, GITNEXUS_PUBLIC_ORIGIN appears in no YAML, the token is generated, no AZURE_DEVOPS_* key, and the schema pragma is intact. Each was confirmed to fail when its invariant is deliberately broken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(readme): state what the Render deploy costs The Deploy to Render section described the topology and the token but not the bill. At the Blueprint's defaults it is ~$35/month: $25 for the server's `standard` instance, $7 for the web service's `starter`, $2.50 for the 10 GB disk. Neither service scales to zero, which matters more than the number — an idle deploy costs the same as a busy one. Also trims the section's em dashes and drops "access token" to "token" where the antecedent is one sentence away. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore: update readme --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
a2a2d51193
commit
356032e2f6
4 changed files with 219 additions and 38 deletions
16
README.md
16
README.md
|
|
@ -86,9 +86,21 @@ Deploy GitNexus in one click:
|
|||
|
||||
[](https://render.com/deploy?repo=https://github.com/abhigyanpatwari/GitNexus)
|
||||
|
||||
Render provisions the API server, a persistent disk for indexes and cloned repos, and the web UI pointed at it, with no API keys required.
|
||||
The Blueprint creates two services. `gitnexus-server` runs `gitnexus serve` as a private service: no public URL, reachable only over Render's private network, with a persistent disk for indexes and cloned repos. `gitnexus-web` is the public one. It serves the UI and reverse-proxies `/api/*` to the server, so the browser talks to a single origin.
|
||||
|
||||
Indexing is memory-bound, so if `gitnexus-server` runs out of memory on a large repo, raise its `plan` and `sizeGB`.
|
||||
At the Blueprint's defaults this runs about **$35/month**: $25 for the server's `standard` instance, $7 for the web service's `starter` instance, and $2.50 for the 10 GB disk. See [Render's pricing](https://render.com/pricing) for other plans.
|
||||
|
||||
The deploy generates an access token, and the UI asks for it on first use:
|
||||
|
||||
1. Open the `gitnexus-web` service in your [Render dashboard](https://dashboard.render.com/).
|
||||
2. Copy `GITNEXUS_SERVE_AUTH_TOKEN` from its **Environment** tab.
|
||||
3. Load the site and paste the token into the prompt (or the settings panel).
|
||||
|
||||
Every `/api/*` request carries that token as a header, and the proxy answers `401` without it. The browser keeps it in `sessionStorage`, so a new tab asks again. To rotate it, edit the environment variable and redeploy.
|
||||
|
||||
The proxy strips `Origin` before forwarding, so the server's CSRF guard does nothing for proxied traffic; it passes `Origin`-less requests through by design. The token is the only control on this deploy, not a second layer behind the guard. Anyone holding it can read every indexed repo. See [SECURITY.md](SECURITY.md#hosted-deploys-on-render).
|
||||
|
||||
Indexing is memory-bound. If `gitnexus-server` runs out of memory on a large repo, raise its `plan`, which sets available RAM: `standard` is 2 GB, `pro` is 4 GB. Raise `sizeGB` only if the disk fills with clones and indexes.
|
||||
|
||||
## Two Ways to Use GitNexus
|
||||
|
||||
|
|
|
|||
13
SECURITY.md
13
SECURITY.md
|
|
@ -51,6 +51,19 @@ If you fork GitNexus or self-host it, we recommend enabling the following in you
|
|||
- **Secret scanning** and **Push protection** — blocks pushes that introduce known secret patterns. Defense-in-depth on top of the in-CI Gitleaks scan documented below.
|
||||
- **Code scanning** — surfaces SARIF results from CodeQL, Trivy, Scorecard, and zizmor in one place.
|
||||
|
||||
### Hosted Deploys on Render
|
||||
|
||||
The `render.yaml` Blueprint (see the README's **Deploy to Render**) puts `gitnexus serve` on a **private service** with no public URL, and a public web service in front of it that reverse-proxies `/api/*`. What that does and does not protect:
|
||||
|
||||
- **The web service is public and its URL is discoverable.** `onrender.com` hostnames appear in certificate transparency logs. Treat the URL as known rather than secret.
|
||||
- **The generated `GITNEXUS_SERVE_AUTH_TOKEN` is the only access control.** The proxy rejects any `/api/*` request without it with a `401` before forwarding. Rotate it by editing the environment variable on the `gitnexus-web` service and redeploying.
|
||||
- **The CSRF guard is inert on this path.** The proxy strips `Origin` before forwarding, so the server's write-origin guard does nothing for proxied traffic — it passes `Origin`-less requests through by design. The token is not a second layer behind the guard.
|
||||
- **Anyone holding the token can read every indexed repo's source.** These routes carry no origin guard, and the first three carry no rate limiter either: `GET /api/repos`, `GET /api/graph`, `POST /api/query`, `GET /api/file`, `GET /api/grep`. Whoever has the token can also index and delete repositories.
|
||||
- **`POST /api/mcp` rides the same path.** `serve` mounts the MCP handler via `mountMCPEndpoints`, and `createStreamableHttpHandler` is called with no `authToken` — a **pre-existing** gap in `serve` itself, not something this deploy introduces. On Render it is closed only by the edge token and the private network. A `serve` bound directly to a public interface has no such cover.
|
||||
- **Rate limits bound cost, not access.** They cap what a token holder can spend; they do not decide who gets in.
|
||||
|
||||
Do not hand the URL out as a public demo. A token holder has read access to everything the deploy has indexed.
|
||||
|
||||
## Automated Scans Running in CI
|
||||
|
||||
This repository runs the following scans automatically. Findings appear under the repository's **Security → Code scanning** tab.
|
||||
|
|
|
|||
144
gitnexus/test/unit/render-blueprint.test.ts
Normal file
144
gitnexus/test/unit/render-blueprint.test.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* Invariants of the repo-root `render.yaml`, which nothing else in CI parses.
|
||||
*
|
||||
* Two kinds: couplings the Blueprint documents but cannot enforce
|
||||
* (`disk.mountPath` = `GITNEXUS_HOME`, `PORT` = the port `Dockerfile.cli` binds),
|
||||
* and the choices that keep the deploy closed (the API server is private, the
|
||||
* public proxy always has a token). Without these, a plausible-looking edit puts
|
||||
* the API back on the internet with every other test green.
|
||||
*
|
||||
* Full schema conformance is `render blueprints validate render.yaml`, which
|
||||
* needs network and an account; checked here is only the pragma pointing at it.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { load } from 'js-yaml';
|
||||
|
||||
const monorepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
||||
const blueprintSource = readFileSync(path.join(monorepoRoot, 'render.yaml'), 'utf8');
|
||||
const dockerfileSource = readFileSync(path.join(monorepoRoot, 'Dockerfile.cli'), 'utf8');
|
||||
|
||||
const SCHEMA_PRAGMA = '# yaml-language-server: $schema=https://render.com/schema/render.yaml.json';
|
||||
|
||||
interface EnvVar {
|
||||
key: string;
|
||||
value?: string | number;
|
||||
generateValue?: boolean;
|
||||
fromService?: { type?: string; name?: string; property?: string };
|
||||
}
|
||||
|
||||
interface Service {
|
||||
type: string;
|
||||
name: string;
|
||||
envVars?: EnvVar[];
|
||||
disk?: { mountPath?: string };
|
||||
}
|
||||
|
||||
interface Blueprint {
|
||||
projects?: { environments?: { services?: Service[] }[] }[];
|
||||
services?: Service[];
|
||||
}
|
||||
|
||||
const blueprint = load(blueprintSource) as Blueprint;
|
||||
|
||||
/** Services sit under projects → environments, or at the top level. */
|
||||
const services: Service[] = [
|
||||
...(blueprint.services ?? []),
|
||||
...(blueprint.projects ?? []).flatMap((project) =>
|
||||
(project.environments ?? []).flatMap((environment) => environment.services ?? []),
|
||||
),
|
||||
];
|
||||
|
||||
/**
|
||||
* Every key and value in the document, comments excluded by construction —
|
||||
* parsing drops them. The Blueprint documents in comments what it deliberately
|
||||
* does NOT set, and those comments are what stop the next editor re-adding it,
|
||||
* so a mention in prose is the documentation and a mention here is the bug.
|
||||
* Covers shapes the interfaces above don't model (envVarGroups, dockerCommand).
|
||||
*/
|
||||
const blueprintData = JSON.stringify(blueprint);
|
||||
|
||||
const envVarKeys = services.flatMap((service) => (service.envVars ?? []).map((entry) => entry.key));
|
||||
|
||||
const SERVER = 'gitnexus-server';
|
||||
const WEB = 'gitnexus-web';
|
||||
|
||||
/** Looked up per test, so a rename fails each assertion with its own message. */
|
||||
function serviceNamed(name: string): Service {
|
||||
const match = services.find((service) => service.name === name);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`render.yaml has no service named "${name}". If the rename is intentional, ` +
|
||||
'update this test — the invariants below are about that service.',
|
||||
);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
function envVar(service: Service, key: string): EnvVar | undefined {
|
||||
return (service.envVars ?? []).find((entry) => entry.key === key);
|
||||
}
|
||||
|
||||
/** Absent as an env var name, and absent anywhere else in the document. */
|
||||
function expectNeverSet(pattern: RegExp): void {
|
||||
expect(envVarKeys.filter((key) => pattern.test(key))).toEqual([]);
|
||||
expect(blueprintData).not.toMatch(pattern);
|
||||
}
|
||||
|
||||
describe('render.yaml ↔ Dockerfile.cli couplings', () => {
|
||||
it('mounts the disk at GITNEXUS_HOME', () => {
|
||||
// One key of a multi-line `ENV`.
|
||||
const home = /^\s*(?:ENV\s+)?GITNEXUS_HOME=(\S+)/m.exec(dockerfileSource)?.[1];
|
||||
expect(home, 'Dockerfile.cli must set GITNEXUS_HOME').toBeTruthy();
|
||||
expect(serviceNamed(SERVER).disk?.mountPath).toBe(home);
|
||||
});
|
||||
|
||||
it('routes to the port the image binds', () => {
|
||||
// ... --port \"${PORT:-4747}\" — the backslashes are literal in the CMD.
|
||||
const dockerfilePort = /--port\s+\\?"?\$\{PORT:-(\d+)\}/.exec(dockerfileSource)?.[1];
|
||||
expect(dockerfilePort, "Dockerfile.cli's CMD must bind a default port").toBeTruthy();
|
||||
const port = envVar(serviceNamed(SERVER), 'PORT')?.value;
|
||||
expect(port, 'render.yaml must set PORT on gitnexus-server').toBeDefined();
|
||||
expect(String(port)).toBe(dockerfilePort);
|
||||
});
|
||||
});
|
||||
|
||||
describe('render.yaml topology', () => {
|
||||
it('keeps the API server off the internet', () => {
|
||||
// `web` would give `serve` a public URL, and `serve` has no auth of its own.
|
||||
expect(serviceNamed(SERVER).type).toBe('pserv');
|
||||
});
|
||||
|
||||
it('never sets GITNEXUS_PUBLIC_ORIGIN', () => {
|
||||
// `serve` refuses to start with it set and no serve-native auth
|
||||
// (assertServeAuthForPublicOrigin), and behind the proxy it has no job.
|
||||
expectNeverSet(/GITNEXUS_PUBLIC_ORIGIN/);
|
||||
});
|
||||
|
||||
it('ships no Azure DevOps credential knob', () => {
|
||||
// A token holder could POST /api/analyze an Azure URL, spend the operator's
|
||||
// PAT, then read the private repo back through /api/file and /api/grep.
|
||||
expectNeverSet(/AZURE_DEVOPS_/);
|
||||
});
|
||||
|
||||
it('points the proxy at the private server over the private network', () => {
|
||||
expect(envVar(serviceNamed(WEB), 'GITNEXUS_UPSTREAM_URL')?.fromService).toMatchObject({
|
||||
type: 'pserv',
|
||||
name: SERVER,
|
||||
property: 'hostport',
|
||||
});
|
||||
});
|
||||
|
||||
it('always generates an edge token for the public proxy', () => {
|
||||
// docker-server.mjs refuses to start with an upstream and no token, so this
|
||||
// line is what makes the one-click deploy authenticated rather than broken.
|
||||
expect(envVar(serviceNamed(WEB), 'GITNEXUS_SERVE_AUTH_TOKEN')?.generateValue).toBe(true);
|
||||
});
|
||||
|
||||
it('declares the schema so editors and `render blueprints validate` find it', () => {
|
||||
expect(blueprintSource.split('\n')[0]).toBe(SCHEMA_PRAGMA);
|
||||
});
|
||||
});
|
||||
84
render.yaml
84
render.yaml
|
|
@ -1,61 +1,62 @@
|
|||
# yaml-language-server: $schema=https://render.com/schema/render.yaml.json
|
||||
#
|
||||
# One-click deploy of GitNexus: an API server and the web UI that calls it.
|
||||
# fromService gives each the other's public URL, so there is nothing to paste.
|
||||
# One-click GitNexus deploy: a private API server, plus the public web UI that
|
||||
# proxies /api/* to it over Render's private network.
|
||||
#
|
||||
# SECURITY: this deploy is UNAUTHENTICATED, and onrender.com hostnames appear in
|
||||
# certificate transparency logs. Anyone who finds the URL can index and delete
|
||||
# repos. Rate limits bound the cost; they are not access control.
|
||||
# SECURITY: the web service's URL is discoverable (onrender.com names appear in
|
||||
# CT logs). The generated GITNEXUS_SERVE_AUTH_TOKEN is the only access control —
|
||||
# the proxy strips Origin, so the server's CSRF guard sees no proxied traffic,
|
||||
# and rate limits bound cost, not access. Anyone holding the token can read
|
||||
# every indexed repo. See SECURITY.md § Hosted Deploys on Render.
|
||||
|
||||
previews:
|
||||
generation: off
|
||||
generation: 'off'
|
||||
|
||||
projects:
|
||||
- name: gitnexus
|
||||
environments:
|
||||
- name: production
|
||||
services:
|
||||
- type: web
|
||||
# Private: no public URL. `serve` has no authentication of its own.
|
||||
- type: pserv
|
||||
name: gitnexus-server
|
||||
runtime: docker
|
||||
# Private networking needs one shared region.
|
||||
region: oregon
|
||||
# Indexing OOMs on the 512MB starter. standard (2GB) covers
|
||||
# small/mid repos; bump to pro (4GB) or larger for a big one.
|
||||
# plan sets RAM: standard 2GB, pro 4GB. Indexing is memory-bound.
|
||||
plan: standard
|
||||
dockerfilePath: ./Dockerfile.cli
|
||||
dockerContext: .
|
||||
# No dockerCommand — the image's CMD already binds the port below,
|
||||
# and Render re-wraps dockerCommand in a shell, so `sh -c` exits 127.
|
||||
healthCheckPath: /api/health
|
||||
autoDeploy: true
|
||||
# No dockerCommand — the image's CMD already binds $PORT, and Render
|
||||
# re-wraps dockerCommand in a shell, so `sh -c` exits 127.
|
||||
# No healthCheckPath either: Render rejects one on a pserv. The
|
||||
# proxy's connect-retry covers the restart window instead.
|
||||
#
|
||||
# Don't restart an indexing server on a push.
|
||||
autoDeployTrigger: 'off'
|
||||
# mountPath must equal GITNEXUS_HOME in Dockerfile.cli.
|
||||
disk:
|
||||
name: gitnexus-data
|
||||
mountPath: /data/gitnexus
|
||||
sizeGB: 10
|
||||
envVars:
|
||||
# Render routes to this port; Dockerfile.cli's CMD binds 4747.
|
||||
# Dockerfile.cli's CMD binds this.
|
||||
- key: PORT
|
||||
value: 4747
|
||||
# Required. The container binds 0.0.0.0, which has no host
|
||||
# identity, so without this the UI's writes fail the same-host
|
||||
# origin check.
|
||||
- key: GITNEXUS_PUBLIC_ORIGIN
|
||||
fromService:
|
||||
type: web
|
||||
name: gitnexus-web
|
||||
envVarKey: RENDER_EXTERNAL_URL
|
||||
# 1, not true: Render's LB is one hop, and true would trust a
|
||||
# spoofed X-Forwarded-For and hand the rate limiter a fake IP.
|
||||
# One hop: gitnexus-web's proxy, which writes X-Forwarded-For
|
||||
# itself. A count rather than the default ranges (loopback,
|
||||
# linklocal, uniquelocal), which assume Render's private network
|
||||
# is RFC1918. `true` is rejected outright.
|
||||
- key: GITNEXUS_TRUST_PROXY
|
||||
value: 1
|
||||
# Optional Azure DevOps integration; leave unset to skip. Any
|
||||
# visitor can use this PAT (see SECURITY above).
|
||||
- key: AZURE_DEVOPS_URL
|
||||
sync: false
|
||||
- key: AZURE_DEVOPS_PAT
|
||||
sync: false
|
||||
# Deliberately unset: GITNEXUS_PUBLIC_ORIGIN, which has no job
|
||||
# behind the proxy and makes `serve` refuse to start
|
||||
# (assertServeAuthForPublicOrigin); AZURE_DEVOPS_URL /
|
||||
# AZURE_DEVOPS_PAT, since /api/analyze would spend the PAT and
|
||||
# /api/file could read the private repo back. Set the Azure pair
|
||||
# in the dashboard if you want it.
|
||||
|
||||
# Public: the UI, plus a token-gated same-origin proxy to the server.
|
||||
- type: web
|
||||
name: gitnexus-web
|
||||
runtime: docker
|
||||
|
|
@ -64,12 +65,23 @@ projects:
|
|||
dockerfilePath: ./Dockerfile.web
|
||||
dockerContext: .
|
||||
healthCheckPath: /
|
||||
autoDeploy: true
|
||||
autoDeployTrigger: 'off'
|
||||
envVars:
|
||||
# Injected into the page as window.__GITNEXUS_CONFIG__.backendUrl,
|
||||
# so a first-time visitor's UI targets this deploy, not localhost.
|
||||
- key: GITNEXUS_BACKEND_URL
|
||||
# host:port, scheme-less; the proxy prefixes http://. Set without
|
||||
# the token below, docker-server.mjs refuses to start.
|
||||
- key: GITNEXUS_UPSTREAM_URL
|
||||
fromService:
|
||||
type: web
|
||||
type: pserv
|
||||
name: gitnexus-server
|
||||
envVarKey: RENDER_EXTERNAL_URL
|
||||
property: hostport
|
||||
# Bearer token for every /api/* request. Copy it from this
|
||||
# service's Environment tab into the UI's settings panel; rotate by
|
||||
# editing it here and redeploying.
|
||||
- key: GITNEXUS_SERVE_AUTH_TOKEN
|
||||
generateValue: true
|
||||
# Render's load balancer is the one hop in front and appends the
|
||||
# real peer. The proxy ignores inbound XFF by default.
|
||||
- key: GITNEXUS_PROXY_TRUST_XFF
|
||||
value: 1
|
||||
# Deliberately unset: GITNEXUS_BACKEND_URL. docker-server.mjs falls
|
||||
# back to RENDER_EXTERNAL_URL, this service's own origin.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue