Implement exit node check-in tracking and adjust logging for connection errors

This commit is contained in:
Owen
2026-09-10 10:35:42 -04:00
parent 3e3c5cf1c3
commit 217a59ad10
6 changed files with 69 additions and 20 deletions
+14
View File
@@ -0,0 +1,14 @@
// Tracks, per process lifetime, whether a given exit node has ever checked in
// (called /gerbil/get-config) since this Pangolin instance started. This lets
// callers distinguish "gerbil hasn't come up yet" (expected briefly after a
// restart, since gerbil depends on pangolin's container starting first) from
// "gerbil was reachable and now isn't" (a real problem worth an error log).
const checkedInExitNodeIds = new Set<number>();
export function markExitNodeCheckedIn(exitNodeId: number): void {
checkedInExitNodeIds.add(exitNodeId);
}
export function hasExitNodeCheckedIn(exitNodeId: number): boolean {
return checkedInExitNodeIds.has(exitNodeId);
}
+13 -6
View File
@@ -1,6 +1,7 @@
import axios from "axios"; import axios from "axios";
import logger from "@server/logger"; import logger from "@server/logger";
import { ExitNode } from "@server/db"; import { ExitNode } from "@server/db";
import { hasExitNodeCheckedIn } from "./exitNodeCheckIn";
interface ExitNodeRequest { interface ExitNodeRequest {
remoteType?: string; remoteType?: string;
@@ -72,13 +73,19 @@ export async function sendToExitNode(
return response.data; return response.data;
} catch (error) { } catch (error) {
if (axios.isAxiosError(error)) { const message = axios.isAxiosError(error)
logger.error( ? `Error making ${method} request (can Pangolin see Gerbil HTTP API?) for exit node at ${exitNode.reachableAt} (status: ${error.response?.status}): ${error.message}`
`Error making ${method} request (can Pangolin see Gerbil HTTP API?) for exit node at ${exitNode.reachableAt} (status: ${error.response?.status}): ${error.message}` : `Error making ${method} request for exit node at ${exitNode.reachableAt}: ${error}`;
);
// The exit node (gerbil) may still be starting up and not yet
// reachable. Until it has checked in at least once, log this at a
// lower level since it's expected; once it has checked in, a
// connection failure is a real problem.
if (hasExitNodeCheckedIn(exitNode.exitNodeId)) {
logger.error(message);
} else { } else {
logger.error( logger.warn(
`Error making ${method} request for exit node at ${exitNode.reachableAt}: ${error}` `${message} (exit node has not checked in yet since startup, this is expected briefly)`
); );
} }
throw error; throw error;
+1
View File
@@ -1,5 +1,6 @@
export * from "./exitNodes"; export * from "./exitNodes";
export * from "./exitNodeComms"; export * from "./exitNodeComms";
export * from "./exitNodeCheckIn";
export * from "./subnet"; export * from "./subnet";
export * from "./getCurrentExitNodeId"; export * from "./getCurrentExitNodeId";
export * from "./calculateExitNodeWeight"; export * from "./calculateExitNodeWeight";
+25 -8
View File
@@ -6,7 +6,10 @@ import * as yaml from "js-yaml";
import axios from "axios"; import axios from "axios";
import { db, exitNodes } from "@server/db"; import { db, exitNodes } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { getCurrentExitNodeId } from "@server/lib/exitNodes"; import {
getCurrentExitNodeId,
hasExitNodeCheckedIn
} from "@server/lib/exitNodes";
import { getTraefikConfig } from "#dynamic/lib/traefik"; import { getTraefikConfig } from "#dynamic/lib/traefik";
import { getValidCertificatesForDomains } from "@server/lib/certificates"; import { getValidCertificatesForDomains } from "@server/lib/certificates";
import { sendToExitNode } from "#dynamic/lib/exitNodes"; import { sendToExitNode } from "#dynamic/lib/exitNodes";
@@ -466,32 +469,46 @@ export class TraefikConfigManager {
await this.writeTraefikDynamicConfig(traefikConfig); await this.writeTraefikDynamicConfig(traefikConfig);
// Send domains to SNI proxy // Send domains to SNI proxy
let exitNodeForSni: (typeof exitNodes.$inferSelect) | undefined;
try { try {
let exitNode;
if (config.getRawConfig().gerbil.exit_node_name) { if (config.getRawConfig().gerbil.exit_node_name) {
const exitNodeName = const exitNodeName =
config.getRawConfig().gerbil.exit_node_name!; config.getRawConfig().gerbil.exit_node_name!;
[exitNode] = await db [exitNodeForSni] = await db
.select() .select()
.from(exitNodes) .from(exitNodes)
.where(eq(exitNodes.name, exitNodeName)) .where(eq(exitNodes.name, exitNodeName))
.limit(1); .limit(1);
} else { } else {
[exitNode] = await db.select().from(exitNodes).limit(1); [exitNodeForSni] = await db
.select()
.from(exitNodes)
.limit(1);
} }
if (exitNode) { if (exitNodeForSni) {
await sendToExitNode(exitNode, { await sendToExitNode(exitNodeForSni, {
localPath: "/update-local-snis", localPath: "/update-local-snis",
method: "POST", method: "POST",
data: { fullDomains: Array.from(domains) } data: { fullDomains: Array.from(domains) }
}); });
} else { } else {
logger.error( logger.warn(
"No exit node found. Has gerbil registered yet?" "No exit node found. Has gerbil registered yet?"
); );
} }
} catch (err) { } catch (err) {
logger.error("Failed to post domains to SNI proxy:", err); // sendToExitNode already logs the underlying connection
// error at the appropriate level (warn before the exit node
// has checked in since startup, error after), so avoid
// double-logging it as an error here.
if (
exitNodeForSni &&
!hasExitNodeCheckedIn(exitNodeForSni.exitNodeId)
) {
logger.warn("Failed to post domains to SNI proxy:", err);
} else {
logger.error("Failed to post domains to SNI proxy:", err);
}
} }
// Update active domains tracking // Update active domains tracking
+13 -6
View File
@@ -18,6 +18,7 @@ import { eq } from "drizzle-orm";
import { sendToClient } from "#private/routers/ws"; import { sendToClient } from "#private/routers/ws";
import privateConfig from "#private/lib/config"; import privateConfig from "#private/lib/config";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { hasExitNodeCheckedIn } from "@server/lib/exitNodes";
interface ExitNodeRequest { interface ExitNodeRequest {
remoteType?: string; remoteType?: string;
@@ -138,13 +139,19 @@ export async function sendToExitNode(
return response.data; return response.data;
} catch (error) { } catch (error) {
if (axios.isAxiosError(error)) { const message = axios.isAxiosError(error)
logger.error( ? `Error making ${method} request (can Pangolin see Gerbil HTTP API?) for exit node at ${hostname} (status: ${error.response?.status}): ${error.message}`
`Error making ${method} request (can Pangolin see Gerbil HTTP API?) for exit node at ${hostname} (status: ${error.response?.status}): ${error.message}` : `Error making ${method} request for exit node at ${hostname}: ${error}`;
);
// The exit node (gerbil) may still be starting up and not yet
// reachable. Until it has checked in at least once, log this at a
// lower level since it's expected; once it has checked in, a
// connection failure is a real problem.
if (hasExitNodeCheckedIn(exitNode.exitNodeId)) {
logger.error(message);
} else { } else {
logger.error( logger.warn(
`Error making ${method} request for exit node at ${hostname}: ${error}` `${message} (exit node has not checked in yet since startup, this is expected briefly)`
); );
} }
} }
+3
View File
@@ -10,6 +10,7 @@ import config from "@server/lib/config";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { getAllowedIps } from "../target/helpers"; import { getAllowedIps } from "../target/helpers";
import { createExitNode } from "#dynamic/routers/gerbil/createExitNode"; import { createExitNode } from "#dynamic/routers/gerbil/createExitNode";
import { markExitNodeCheckedIn } from "@server/lib/exitNodes";
// Define Zod schema for request validation // Define Zod schema for request validation
const getConfigSchema = z.object({ const getConfigSchema = z.object({
@@ -65,6 +66,8 @@ export async function getConfig(
); );
} }
markExitNodeCheckedIn(exitNode.exitNodeId);
const configResponse = await generateGerbilConfig(exitNode); const configResponse = await generateGerbilConfig(exitNode);
logger.debug("Sending config: ", configResponse); logger.debug("Sending config: ", configResponse);