Skip to content
Merged
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
16 changes: 15 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,21 @@ GITTENSORY_REVIEW_DRAFT=false
# # EXACT public URL operators browse to (scheme + host [+ port]).
# # Deriving it from the request Host header would let an attacker
# # redirect the App-creation callback, so it must be set explicitly.
# # Not needed once the App credentials are configured.
# # Stays load-bearing after setup too: this same origin is embedded
# # as the <img src> for every visual-capture screenshot posted to a
# # PR comment (see PUBLIC_SITE_ORIGIN below), so it must be
# # reachable from GitHub's own servers, not just this host or your
# # tailnet. See PUBLIC_ORIGIN_ACKNOWLEDGED below.
# PUBLIC_SITE_ORIGIN=https://your-fork.example.com # "before" screenshots in the visual-capture PR comment
# # table render THIS origin's production pages (vs. PUBLIC_API_ORIGIN,
# # which serves the screenshot images themselves). Unset ⇒ every
# # "Before (production)" cell is blank, not an error — visual capture
# # degrades to after-only, never fails the review.
# PUBLIC_ORIGIN_ACKNOWLEDGED=false # silences the boot-time warning when PUBLIC_API_ORIGIN/
# # PUBLIC_SITE_ORIGIN look like a private/internal hostname (e.g. a
# # bare Tailscale MagicDNS `*.ts.net` address) once you've confirmed
# # it's genuinely public (Funnel enabled, a reverse proxy in front of
# # it, etc.). Default false (warning shown).
# SELFHOST_SETUP_TOKEN=change-this-long-random-value # REQUIRED to unlock the first-run /setup wizard. Without it
# # /setup returns 400; with it, enter the token in the browser form
# # or send an x-setup-token / Bearer header. Never put this token in
Expand Down
8 changes: 6 additions & 2 deletions .env.selfhost.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,12 @@ GITTENSOR_REGISTRY_URL=https://example.invalid/registry.json
# GITTENSORY_MCP_TOKEN= # shared MCP bearer token
# INTERNAL_JOB_TOKEN= # gates internal-only routes

# REQUIRED once, for the first-run /setup wizard that creates the GitHub App manifest. Not needed
# after the App credentials above are filled in.
# REQUIRED for the first-run /setup wizard that creates the GitHub App manifest, AND required ongoing:
# this exact origin is embedded as the <img src> for every visual-capture screenshot posted to a PR
# comment, so it MUST be reachable from GitHub's own servers, not just from this machine or your
# tailnet — a private/internal hostname here (e.g. a Tailscale MagicDNS `*.ts.net` address with no
# Funnel) makes every screenshot render as a broken image with no other warning besides this instance's
# own boot log (see PUBLIC_ORIGIN_ACKNOWLEDGED below).
PUBLIC_API_ORIGIN=https://reviews.example.com
# SELFHOST_SETUP_TOKEN= # generate a real random value the same way as above before setting this

Expand Down
10 changes: 10 additions & 0 deletions apps/gittensory-ui/src/lib/selfhost-env-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,14 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
name: "PUBLIC_API_ORIGIN",
firstReference: "src/selfhost/preflight.ts",
},
{
name: "PUBLIC_ORIGIN_ACKNOWLEDGED",
firstReference: "src/server.ts",
},
{
name: "PUBLIC_SITE_ORIGIN",
firstReference: "src/server.ts",
},
{
name: "QDRANT_API_KEY",
firstReference: "src/selfhost/qdrant-vectorize.ts",
Expand Down Expand Up @@ -486,6 +494,8 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
"| `PGVECTOR_ENABLED` | `src/server.ts` |",
"| `PORT` | `src/server.ts` |",
"| `PUBLIC_API_ORIGIN` | `src/selfhost/preflight.ts` |",
"| `PUBLIC_ORIGIN_ACKNOWLEDGED` | `src/server.ts` |",
"| `PUBLIC_SITE_ORIGIN` | `src/server.ts` |",
"| `QDRANT_API_KEY` | `src/selfhost/qdrant-vectorize.ts` |",
"| `QDRANT_DIM` | `src/selfhost/qdrant-vectorize.ts` |",
"| `QDRANT_URL` | `src/server.ts` |",
Expand Down
75 changes: 75 additions & 0 deletions src/selfhost/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,78 @@ export function sqliteBackupAdvisory(opts: { usingSqlite: boolean; backupAcknowl
export function backupAcknowledgedGaugeValue(opts: { usingSqlite: boolean; backupAcknowledged: boolean }): 0 | 1 {
return sqliteBackupAdvisory(opts) === null ? 1 : 0;
}

// Hostnames that are NEVER reachable from the public internet, regardless of deployment — loopback, RFC1918
// private ranges, and the mDNS/internal-DNS conventions no public resolver honors. Deliberately excludes
// Tailscale's own *.ts.net MagicDNS suffix from this "never" list: a node with Funnel explicitly enabled DOES
// serve that exact hostname over public HTTPS, so *.ts.net gets its own softer check below rather than being
// treated as definitely non-public.
function isDefinitelyPrivateHostname(hostname: string): boolean {
const host = hostname.toLowerCase();
// URL.hostname keeps the brackets on an IPv6 literal ("[::1]", not "::1") — compare against the bracketed
// form, not the bare address.
if (host === "localhost" || host === "0.0.0.0" || host === "[::1]") return true;
if (host.endsWith(".local") || host.endsWith(".internal")) return true;
const ipv4 = /^(\d{1,3})\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/.exec(host);
if (ipv4) {
const first = Number(ipv4[1]);
const second = Number(ipv4[2]);
if (first === 127 || first === 10) return true;
if (first === 192 && second === 168) return true;
if (first === 172 && second >= 16 && second <= 31) return true;
}
return false;
}

/** True when `origin` parses as a URL whose hostname is a well-known non-public pattern (loopback, RFC1918,
* mDNS/`.internal`), OR a bare Tailscale MagicDNS hostname (`*.ts.net`) — publicly reachable ONLY when the
* operator has explicitly enabled Tailscale Funnel for that node, which this check has no way to observe.
* An unparseable value is this function's job to reject as "not a URL at all", not "looks non-public" — but
* that is a distinct, well-formedness problem this advisory doesn't cover (PUBLIC_API_ORIGIN's own
* first-run-setup preflight check already validates that shape; nothing else currently reads
* PUBLIC_SITE_ORIGIN at boot), so it's silently false here rather than double-reported. */
function looksNonPublic(origin: string): boolean {
try {
const { hostname } = new URL(origin);
return isDefinitelyPrivateHostname(hostname) || hostname.toLowerCase().endsWith(".ts.net");
} catch {
return false;
}
}

/** Boot-time advisory (JSONbored/gittensory PR #4180's live bug): `PUBLIC_API_ORIGIN`/`PUBLIC_SITE_ORIGIN` get
* embedded VERBATIM as `<img src>` in the public "Visual preview" PR comment table (see
* `src/review/visual/capture.ts`) — a value GitHub's own servers, not this instance, must be able to fetch.
* `PUBLIC_API_ORIGIN`'s existing preflight check (see `isBareHttpsOrigin` above) only confirms it's a
* WELL-FORMED bare https origin — a private tailnet hostname like `https://node.example.ts.net` passes that
* check fine while still being completely unfetchable by GitHub, so every screenshot silently renders as a
* broken image with no operator-visible signal until a human notices. This never hard-fails boot (unlike
* `assertSelfHostPreflight`'s checks): a false positive here (e.g. a legitimately Funnel-exposed `*.ts.net`
* node) would only be a degraded review feature, not a security or data-loss risk, so — mirroring
* {@link sqliteBackupAdvisory}'s own acknowledgment escape hatch — it warns rather than blocks, and the
* operator can silence it with `PUBLIC_ORIGIN_ACKNOWLEDGED=true` once they've confirmed the origin really is
* public (Funnel enabled, a reverse proxy in front of it, etc.). Neither var set is left alone: visual
* capture degrades to dash cells in that case (see capture.ts), not a broken image, so there's nothing to warn
* about until an operator sets one of these to something that looks wrong. */
export function publicOriginReachabilityAdvisory(opts: {
publicApiOrigin: string | undefined;
publicSiteOrigin: string | undefined;
acknowledged: boolean;
}): string | null {
if (opts.acknowledged) return null;
const suspect = [opts.publicApiOrigin, opts.publicSiteOrigin]
.filter((value): value is string => Boolean(value?.trim()))
.find((value) => looksNonPublic(value));
if (!suspect) return null;
return `PUBLIC_API_ORIGIN/PUBLIC_SITE_ORIGIN includes "${suspect}", which looks like a private/internal hostname — GitHub's servers cannot fetch it, so visual-capture screenshots embedded in PR comments will render as broken images. Set it to a real publicly-reachable origin (or, if this host has Tailscale Funnel enabled and IS genuinely public, set PUBLIC_ORIGIN_ACKNOWLEDGED=true to silence this warning).`;
}

/** Prometheus gauge value mirroring {@link publicOriginReachabilityAdvisory}: 1 when no suspect origin is
* configured (or the operator acknowledged it), 0 when the advisory would fire. */
export function publicOriginAcknowledgedGaugeValue(opts: {
publicApiOrigin: string | undefined;
publicSiteOrigin: string | undefined;
acknowledged: boolean;
}): 0 | 1 {
return publicOriginReachabilityAdvisory(opts) === null ? 1 : 0;
}
21 changes: 21 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import {
buildHealthBody,
codexAuthReadinessProbe,
githubAppReadinessProbe,
publicOriginAcknowledgedGaugeValue,
publicOriginReachabilityAdvisory,
readiness,
sqliteBackupAdvisory,
type ReadinessProbe,
Expand Down Expand Up @@ -392,6 +394,24 @@ async function main(): Promise<void> {
}),
);

// Public-origin advisory (JSONbored/gittensory#4180): warn LOUDLY at boot if PUBLIC_API_ORIGIN/
// PUBLIC_SITE_ORIGIN look like a private/internal hostname, so an operator doesn't run for weeks with every
// visual-capture screenshot silently rendering as a broken image in public PR comments.
const publicOriginOpts = {
publicApiOrigin: process.env.PUBLIC_API_ORIGIN,
publicSiteOrigin: process.env.PUBLIC_SITE_ORIGIN,
acknowledged: process.env.PUBLIC_ORIGIN_ACKNOWLEDGED === "true",
};
const publicOriginAdvisory = publicOriginReachabilityAdvisory(publicOriginOpts);
if (publicOriginAdvisory)
console.warn(
JSON.stringify({
level: "warn",
event: "selfhost_public_origin_advisory",
message: publicOriginAdvisory,
}),
);

const applied = await runSelfHostMigrations(
backend.db,
process.env.MIGRATIONS_DIR ?? "migrations",
Expand Down Expand Up @@ -701,6 +721,7 @@ async function main(): Promise<void> {
Math.floor((Date.now() - startedAt) / 1000),
);
gauge("gittensory_backup_acknowledged", () => backupAcknowledgedGaugeValue(sqliteBackupOpts));
gauge("gittensory_public_origin_acknowledged", () => publicOriginAcknowledgedGaugeValue(publicOriginOpts));
// Pre-initialize job counters to 0 so they appear in the first Prometheus scrape (lazy counters
// created on first use would otherwise cause "No data" in Grafana until the first job event).
for (const c of [
Expand Down
87 changes: 87 additions & 0 deletions test/unit/selfhost-health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
buildHealthBody,
codexAuthReadinessProbe,
githubAppReadinessProbe,
publicOriginAcknowledgedGaugeValue,
publicOriginReachabilityAdvisory,
readiness,
sqliteBackupAdvisory,
} from "../../src/selfhost/health";
Expand Down Expand Up @@ -219,6 +221,91 @@ describe("backupAcknowledgedGaugeValue (#2089)", () => {
});
});

describe("publicOriginReachabilityAdvisory (#4180)", () => {
it("is silent when acknowledged, regardless of how bad the origin looks", () => {
expect(
publicOriginReachabilityAdvisory({
publicApiOrigin: "https://node.raccoon-bushi.ts.net",
publicSiteOrigin: undefined,
acknowledged: true,
}),
).toBeNull();
});

it("is silent when neither origin is set, or both are ordinary public https origins", () => {
expect(publicOriginReachabilityAdvisory({ publicApiOrigin: undefined, publicSiteOrigin: undefined, acknowledged: false })).toBeNull();
expect(publicOriginReachabilityAdvisory({ publicApiOrigin: " ", publicSiteOrigin: undefined, acknowledged: false })).toBeNull();
expect(
publicOriginReachabilityAdvisory({
publicApiOrigin: "https://reviews.example.com",
publicSiteOrigin: "https://example.com",
acknowledged: false,
}),
).toBeNull();
});

it("is silent on an unparseable value — a well-formedness problem, not this advisory's job", () => {
expect(
publicOriginReachabilityAdvisory({ publicApiOrigin: "not a url", publicSiteOrigin: undefined, acknowledged: false }),
).toBeNull();
});

it("regression: warns on the exact PR #4180 shape — a bare Tailscale MagicDNS origin", () => {
const message = publicOriginReachabilityAdvisory({
publicApiOrigin: "https://edge-us-01.raccoon-bushi.ts.net",
publicSiteOrigin: undefined,
acknowledged: false,
});
expect(message).toMatch(/edge-us-01\.raccoon-bushi\.ts\.net/);
expect(message).toMatch(/PUBLIC_ORIGIN_ACKNOWLEDGED/);
});

it("checks PUBLIC_SITE_ORIGIN too, not just PUBLIC_API_ORIGIN", () => {
expect(
publicOriginReachabilityAdvisory({
publicApiOrigin: "https://reviews.example.com",
publicSiteOrigin: "http://localhost:3000",
acknowledged: false,
}),
).toMatch(/localhost/);
});

it("flags loopback, RFC1918 ranges, mDNS, and .internal — but not a normal public IP or domain", () => {
const bad = [
"http://localhost",
"http://0.0.0.0",
"http://[::1]",
"https://node.local",
"https://svc.internal",
"http://127.0.0.1",
"http://10.0.0.5",
"http://192.168.1.1",
"http://172.20.0.5",
];
for (const publicApiOrigin of bad) {
expect(publicOriginReachabilityAdvisory({ publicApiOrigin, publicSiteOrigin: undefined, acknowledged: false })).not.toBeNull();
}
const fine = ["https://8.8.8.8", "http://172.15.0.5", "http://172.32.0.5", "https://reviews.example.com"];
for (const publicApiOrigin of fine) {
expect(publicOriginReachabilityAdvisory({ publicApiOrigin, publicSiteOrigin: undefined, acknowledged: false })).toBeNull();
}
});
});

describe("publicOriginAcknowledgedGaugeValue (#4180)", () => {
it("mirrors the advisory: 0 only when a suspect origin is unacknowledged", () => {
expect(
publicOriginAcknowledgedGaugeValue({ publicApiOrigin: "https://node.ts.net", publicSiteOrigin: undefined, acknowledged: false }),
).toBe(0);
expect(
publicOriginAcknowledgedGaugeValue({ publicApiOrigin: "https://node.ts.net", publicSiteOrigin: undefined, acknowledged: true }),
).toBe(1);
expect(
publicOriginAcknowledgedGaugeValue({ publicApiOrigin: "https://reviews.example.com", publicSiteOrigin: undefined, acknowledged: false }),
).toBe(1);
});
});

describe("readiness (#982)", () => {
afterEach(() => {
vi.restoreAllMocks();
Expand Down
Loading