Handle newt online offline with websocket

This commit is contained in:
Owen
2026-03-14 11:59:20 -07:00
parent 75ab074805
commit 1a43f1ef4b
6 changed files with 159 additions and 99 deletions

View File

@@ -89,6 +89,7 @@ export const sites = pgTable("sites", {
lastBandwidthUpdate: varchar("lastBandwidthUpdate"), lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
type: varchar("type").notNull(), // "newt" or "wireguard" type: varchar("type").notNull(), // "newt" or "wireguard"
online: boolean("online").notNull().default(false), online: boolean("online").notNull().default(false),
lastPing: integer("lastPing"),
address: varchar("address"), address: varchar("address"),
endpoint: varchar("endpoint"), endpoint: varchar("endpoint"),
publicKey: varchar("publicKey"), publicKey: varchar("publicKey"),

View File

@@ -90,6 +90,7 @@ export const sites = sqliteTable("sites", {
lastBandwidthUpdate: text("lastBandwidthUpdate"), lastBandwidthUpdate: text("lastBandwidthUpdate"),
type: text("type").notNull(), // "newt" or "wireguard" type: text("type").notNull(), // "newt" or "wireguard"
online: integer("online", { mode: "boolean" }).notNull().default(false), online: integer("online", { mode: "boolean" }).notNull().default(false),
lastPing: integer("lastPing"),
// exit node stuff that is how to connect to the site when it has a wg server // exit node stuff that is how to connect to the site when it has a wg server
address: text("address"), // this is the address of the wireguard interface in newt address: text("address"), // this is the address of the wireguard interface in newt

View File

@@ -25,7 +25,8 @@ import {
OlmSession, OlmSession,
RemoteExitNode, RemoteExitNode,
RemoteExitNodeSession, RemoteExitNodeSession,
remoteExitNodes remoteExitNodes,
sites
} from "@server/db"; } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "@server/db"; import { db } from "@server/db";
@@ -846,6 +847,31 @@ const setupConnection = async (
); );
}); });
// Handle WebSocket protocol-level pings from older newt clients that do
// not send application-level "newt/ping" messages. Update the site's
// online state and lastPing timestamp so the offline checker treats them
// the same as modern newt clients.
if (clientType === "newt") {
const newtClient = client as Newt;
ws.on("ping", async () => {
if (!newtClient.siteId) return;
try {
await db
.update(sites)
.set({
online: true,
lastPing: Math.floor(Date.now() / 1000)
})
.where(eq(sites.siteId, newtClient.siteId));
} catch (error) {
logger.error(
"Error updating newt site online state on WS ping",
{ error }
);
}
});
}
ws.on("error", (error: Error) => { ws.on("error", (error: Error) => {
logger.error( logger.error(
`WebSocket error for ${clientType.toUpperCase()} ID ${clientId}:`, `WebSocket error for ${clientType.toUpperCase()} ID ${clientId}:`,

View File

@@ -1,105 +1,107 @@
import { db, sites } from "@server/db"; import { db, newts, sites } from "@server/db";
import { disconnectClient, getClientConfigVersion } from "#dynamic/routers/ws"; import { hasActiveConnections, getClientConfigVersion } from "#dynamic/routers/ws";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { clients, Newt } from "@server/db"; import { Newt } from "@server/db";
import { eq, lt, isNull, and, or } from "drizzle-orm"; import { eq, lt, isNull, and, or } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { validateSessionToken } from "@server/auth/sessions/app";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { sendTerminateClient } from "../client/terminate";
import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { sendNewtSyncMessage } from "./sync"; import { sendNewtSyncMessage } from "./sync";
// Track if the offline checker interval is running // Track if the offline checker interval is running
// let offlineCheckerInterval: NodeJS.Timeout | null = null; let offlineCheckerInterval: NodeJS.Timeout | null = null;
// const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds
// const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
/** /**
* Starts the background interval that checks for clients that haven't pinged recently * Starts the background interval that checks for newt sites that haven't
* and marks them as offline * pinged recently and marks them as offline. For backward compatibility,
* a site is only marked offline when there is no active WebSocket connection
* either — so older newt versions that don't send pings but remain connected
* continue to be treated as online.
*/ */
// export const startNewtOfflineChecker = (): void => { export const startNewtOfflineChecker = (): void => {
// if (offlineCheckerInterval) { if (offlineCheckerInterval) {
// return; // Already running return; // Already running
// } }
// offlineCheckerInterval = setInterval(async () => { offlineCheckerInterval = setInterval(async () => {
// try { try {
// const twoMinutesAgo = Math.floor( const twoMinutesAgo = Math.floor(
// (Date.now() - OFFLINE_THRESHOLD_MS) / 1000 (Date.now() - OFFLINE_THRESHOLD_MS) / 1000
// ); );
// // TODO: WE NEED TO MAKE SURE THIS WORKS WITH DISTRIBUTED NODES ALL DOING THE SAME THING // Find all online newt-type sites that haven't pinged recently
// (or have never pinged at all). Join newts to obtain the newtId
// needed for the WebSocket connection check.
const staleSites = await db
.select({
siteId: sites.siteId,
newtId: newts.newtId,
lastPing: sites.lastPing
})
.from(sites)
.innerJoin(newts, eq(newts.siteId, sites.siteId))
.where(
and(
eq(sites.online, true),
eq(sites.type, "newt"),
or(
lt(sites.lastPing, twoMinutesAgo),
isNull(sites.lastPing)
)
)
);
// // Find clients that haven't pinged in the last 2 minutes and mark them as offline for (const staleSite of staleSites) {
// const offlineClients = await db // Backward-compatibility check: if the newt still has an
// .update(clients) // active WebSocket connection (older clients that don't send
// .set({ online: false }) // pings), keep the site online.
// .where( const isConnected = await hasActiveConnections(staleSite.newtId);
// and( if (isConnected) {
// eq(clients.online, true), logger.debug(
// or( `Newt ${staleSite.newtId} has not pinged recently but is still connected via WebSocket — keeping site ${staleSite.siteId} online`
// lt(clients.lastPing, twoMinutesAgo), );
// isNull(clients.lastPing) continue;
// ) }
// )
// )
// .returning();
// for (const offlineClient of offlineClients) { logger.info(
// logger.info( `Marking site ${staleSite.siteId} offline: newt ${staleSite.newtId} has no recent ping and no active WebSocket connection`
// `Kicking offline newt client ${offlineClient.clientId} due to inactivity` );
// );
// if (!offlineClient.newtId) { await db
// logger.warn( .update(sites)
// `Offline client ${offlineClient.clientId} has no newtId, cannot disconnect` .set({ online: false })
// ); .where(eq(sites.siteId, staleSite.siteId));
// continue; }
// } } catch (error) {
logger.error("Error in newt offline checker interval", { error });
}
}, OFFLINE_CHECK_INTERVAL);
// // Send a disconnect message to the client if connected logger.debug("Started newt offline checker interval");
// try { };
// await sendTerminateClient(
// offlineClient.clientId,
// offlineClient.newtId
// ); // terminate first
// // wait a moment to ensure the message is sent
// await new Promise((resolve) => setTimeout(resolve, 1000));
// await disconnectClient(offlineClient.newtId);
// } catch (error) {
// logger.error(
// `Error sending disconnect to offline newt ${offlineClient.clientId}`,
// { error }
// );
// }
// }
// } catch (error) {
// logger.error("Error in offline checker interval", { error });
// }
// }, OFFLINE_CHECK_INTERVAL);
// logger.debug("Started offline checker interval");
// };
/** /**
* Stops the background interval that checks for offline clients * Stops the background interval that checks for offline newt sites.
*/ */
// export const stopNewtOfflineChecker = (): void => { export const stopNewtOfflineChecker = (): void => {
// if (offlineCheckerInterval) { if (offlineCheckerInterval) {
// clearInterval(offlineCheckerInterval); clearInterval(offlineCheckerInterval);
// offlineCheckerInterval = null; offlineCheckerInterval = null;
// logger.info("Stopped offline checker interval"); logger.info("Stopped newt offline checker interval");
// } }
// }; };
/** /**
* Handles ping messages from clients and responds with pong * Handles ping messages from newt clients.
*
* On each ping:
* - Marks the associated site as online.
* - Records the current timestamp as the newt's last-ping time.
* - Triggers a config sync if the newt is running an outdated config version.
* - Responds with a pong message.
*/ */
export const handleNewtPingMessage: MessageHandler = async (context) => { export const handleNewtPingMessage: MessageHandler = async (context) => {
const { message, client: c, sendToClient } = context; const { message, client: c } = context;
const newt = c as Newt; const newt = c as Newt;
if (!newt) { if (!newt) {
@@ -112,15 +114,31 @@ export const handleNewtPingMessage: MessageHandler = async (context) => {
return; return;
} }
// get the version try {
// Mark the site as online and record the ping timestamp.
await db
.update(sites)
.set({
online: true,
lastPing: Math.floor(Date.now() / 1000)
})
.where(eq(sites.siteId, newt.siteId));
} catch (error) {
logger.error("Error updating online state on newt ping", { error });
}
// Check config version and sync if stale.
const configVersion = await getClientConfigVersion(newt.newtId); const configVersion = await getClientConfigVersion(newt.newtId);
if (message.configVersion && configVersion != null && configVersion != message.configVersion) { if (
message.configVersion != null &&
configVersion != null &&
configVersion !== message.configVersion
) {
logger.warn( logger.warn(
`Newt ping with outdated config version: ${message.configVersion} (current: ${configVersion})` `Newt ping with outdated config version: ${message.configVersion} (current: ${configVersion})`
); );
// get the site
const [site] = await db const [site] = await db
.select() .select()
.from(sites) .from(sites)
@@ -137,19 +155,6 @@ export const handleNewtPingMessage: MessageHandler = async (context) => {
await sendNewtSyncMessage(newt, site); await sendNewtSyncMessage(newt, site);
} }
// try {
// // Update the client's last ping timestamp
// await db
// .update(clients)
// .set({
// lastPing: Math.floor(Date.now() / 1000),
// online: true
// })
// .where(eq(clients.clientId, newt.clientId));
// } catch (error) {
// logger.error("Error handling ping message", { error });
// }
return { return {
message: { message: {
type: "pong", type: "pong",

View File

@@ -6,7 +6,8 @@ import {
handleDockerContainersMessage, handleDockerContainersMessage,
handleNewtPingRequestMessage, handleNewtPingRequestMessage,
handleApplyBlueprintMessage, handleApplyBlueprintMessage,
handleNewtPingMessage handleNewtPingMessage,
startNewtOfflineChecker
} from "../newt"; } from "../newt";
import { import {
handleOlmRegisterMessage, handleOlmRegisterMessage,
@@ -43,3 +44,4 @@ export const messageHandlers: Record<string, MessageHandler> = {
}; };
startOlmOfflineChecker(); // this is to handle the offline check for olms startOlmOfflineChecker(); // this is to handle the offline check for olms
startNewtOfflineChecker(); // this is to handle the offline check for newts

View File

@@ -3,7 +3,7 @@ import zlib from "zlib";
import { Server as HttpServer } from "http"; import { Server as HttpServer } from "http";
import { WebSocket, WebSocketServer } from "ws"; import { WebSocket, WebSocketServer } from "ws";
import { Socket } from "net"; import { Socket } from "net";
import { Newt, newts, NewtSession, olms, Olm, OlmSession } from "@server/db"; import { Newt, newts, NewtSession, olms, Olm, OlmSession, sites } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "@server/db"; import { db } from "@server/db";
import { validateNewtSessionToken } from "@server/auth/sessions/newt"; import { validateNewtSessionToken } from "@server/auth/sessions/newt";
@@ -380,6 +380,31 @@ const setupConnection = async (
); );
}); });
// Handle WebSocket protocol-level pings from older newt clients that do
// not send application-level "newt/ping" messages. Update the site's
// online state and lastPing timestamp so the offline checker treats them
// the same as modern newt clients.
if (clientType === "newt") {
const newtClient = client as Newt;
ws.on("ping", async () => {
if (!newtClient.siteId) return;
try {
await db
.update(sites)
.set({
online: true,
lastPing: Math.floor(Date.now() / 1000)
})
.where(eq(sites.siteId, newtClient.siteId));
} catch (error) {
logger.error(
"Error updating newt site online state on WS ping",
{ error }
);
}
});
}
ws.on("error", (error: Error) => { ws.on("error", (error: Error) => {
logger.error( logger.error(
`WebSocket error for ${clientType.toUpperCase()} ID ${clientId}:`, `WebSocket error for ${clientType.toUpperCase()} ID ${clientId}:`,