mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-06 12:19:50 +00:00
Compare commits
5 Commits
dev
...
feat/remem
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d13e9105c | ||
|
|
289be30e6b | ||
|
|
a74c0c227c | ||
|
|
05bf77da29 | ||
|
|
bb4deb1ae9 |
@@ -864,8 +864,8 @@
|
|||||||
"policyAuthHeaderAuthTitle": "Basic Header Auth",
|
"policyAuthHeaderAuthTitle": "Basic Header Auth",
|
||||||
"policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request",
|
"policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request",
|
||||||
"policyAuthHeaderAuthSummary": "Header configured",
|
"policyAuthHeaderAuthSummary": "Header configured",
|
||||||
"policyAuthHeaderName": "Username",
|
"policyAuthHeaderName": "Header name",
|
||||||
"policyAuthHeaderValue": "Password",
|
"policyAuthHeaderValue": "Expected value",
|
||||||
"policyAuthSetPasscode": "Set Passcode",
|
"policyAuthSetPasscode": "Set Passcode",
|
||||||
"policyAuthSetPincode": "Set PIN Code",
|
"policyAuthSetPincode": "Set PIN Code",
|
||||||
"policyAuthSetEmailWhitelist": "Set Email Whitelist",
|
"policyAuthSetEmailWhitelist": "Set Email Whitelist",
|
||||||
@@ -1503,6 +1503,7 @@
|
|||||||
"otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.",
|
"otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.",
|
||||||
"otpAuthSubmit": "Submit Code",
|
"otpAuthSubmit": "Submit Code",
|
||||||
"idpContinue": "Or continue with",
|
"idpContinue": "Or continue with",
|
||||||
|
"idpLastUsed": "Last used",
|
||||||
"otpAuthBack": "Back to Password",
|
"otpAuthBack": "Back to Password",
|
||||||
"navbar": "Navigation Menu",
|
"navbar": "Navigation Menu",
|
||||||
"navbarDescription": "Main navigation menu for the application",
|
"navbarDescription": "Main navigation menu for the application",
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import logger from "@server/logger";
|
|
||||||
|
|
||||||
const MAX_RETRIES = 5;
|
|
||||||
const BASE_DELAY_MS = 50;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect transient errors that are safe to retry (connection drops, deadlocks,
|
|
||||||
* serialization failures). PostgreSQL deadlocks (40P01) are always safe to
|
|
||||||
* retry: the database guarantees exactly one winner per deadlock pair, so the
|
|
||||||
* loser just needs to try again.
|
|
||||||
*/
|
|
||||||
export function isTransientError(error: any): boolean {
|
|
||||||
if (!error) return false;
|
|
||||||
|
|
||||||
const message = (error.message || "").toLowerCase();
|
|
||||||
const causeMessage = (error.cause?.message || "").toLowerCase();
|
|
||||||
const code = error.code || error.cause?.code || "";
|
|
||||||
|
|
||||||
// Connection timeout / terminated
|
|
||||||
if (
|
|
||||||
message.includes("connection timeout") ||
|
|
||||||
message.includes("connection terminated") ||
|
|
||||||
message.includes("timeout exceeded when trying to connect") ||
|
|
||||||
causeMessage.includes("connection terminated unexpectedly") ||
|
|
||||||
causeMessage.includes("connection timeout")
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostgreSQL deadlock detected - always safe to retry (one winner guaranteed)
|
|
||||||
if (code === "40P01" || message.includes("deadlock")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostgreSQL serialization failure
|
|
||||||
if (code === "40001") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
|
|
||||||
if (
|
|
||||||
code === "ECONNRESET" ||
|
|
||||||
code === "ECONNREFUSED" ||
|
|
||||||
code === "EPIPE" ||
|
|
||||||
code === "ETIMEDOUT"
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple retry wrapper with exponential backoff for transient errors
|
|
||||||
* (deadlocks, connection timeouts, unexpected disconnects).
|
|
||||||
*/
|
|
||||||
export async function withRetry<T>(
|
|
||||||
operation: () => Promise<T>,
|
|
||||||
context: string,
|
|
||||||
maxRetries: number = MAX_RETRIES,
|
|
||||||
baseDelayMs: number = BASE_DELAY_MS
|
|
||||||
): Promise<T> {
|
|
||||||
let attempt = 0;
|
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
return await operation();
|
|
||||||
} catch (error: any) {
|
|
||||||
if (isTransientError(error) && attempt < maxRetries) {
|
|
||||||
attempt++;
|
|
||||||
const baseDelay = Math.pow(2, attempt - 1) * baseDelayMs;
|
|
||||||
const jitter = Math.random() * baseDelay;
|
|
||||||
const delay = baseDelay + jitter;
|
|
||||||
logger.warn(
|
|
||||||
`Transient DB error in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
|
|
||||||
{ code: error?.code ?? error?.cause?.code }
|
|
||||||
);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +1,10 @@
|
|||||||
import { db, exitNodes, Transaction } from "@server/db";
|
import { db, exitNodes } from "@server/db";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { findNextAvailableCidr } from "@server/lib/ip";
|
import { findNextAvailableCidr } from "@server/lib/ip";
|
||||||
import { lockManager } from "#dynamic/lib/lock";
|
|
||||||
|
|
||||||
/**
|
export async function getNextAvailableSubnet(): Promise<string> {
|
||||||
* Reserves the next available exit node subnet.
|
|
||||||
*
|
|
||||||
* Exit node subnets must never overlap with one another - regardless of
|
|
||||||
* which org(s) they belong to - since HA exit nodes can end up routing for
|
|
||||||
* the same org. This acquires a lock that the caller MUST release (via the
|
|
||||||
* returned `release`) only after the chosen address has been durably
|
|
||||||
* persisted (e.g. after the enclosing transaction commits), otherwise
|
|
||||||
* concurrent callers can race and pick the same subnet.
|
|
||||||
*/
|
|
||||||
export async function getNextAvailableSubnet(
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<{ value: string; release: () => Promise<void> }> {
|
|
||||||
const lockKey = "exit-node-subnet-allocation";
|
|
||||||
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
|
|
||||||
if (!acquired) {
|
|
||||||
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
|
||||||
}
|
|
||||||
const release = () => lockManager.releaseLock(lockKey, acquired);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get all existing subnets from routes table
|
// Get all existing subnets from routes table
|
||||||
const existingAddresses = await trx
|
const existingAddresses = await db
|
||||||
.select({
|
.select({
|
||||||
address: exitNodes.address
|
address: exitNodes.address
|
||||||
})
|
})
|
||||||
@@ -47,9 +26,5 @@ export async function getNextAvailableSubnet(
|
|||||||
".1" +
|
".1" +
|
||||||
"/" +
|
"/" +
|
||||||
subnet.split("/")[1];
|
subnet.split("/")[1];
|
||||||
return { value: subnet, release };
|
return subnet;
|
||||||
} catch (e) {
|
|
||||||
await release();
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ import {
|
|||||||
} from "@server/routers/client/targets";
|
} from "@server/routers/client/targets";
|
||||||
import { lockManager } from "#dynamic/lib/lock";
|
import { lockManager } from "#dynamic/lib/lock";
|
||||||
import { rebuildQueue } from "#dynamic/lib/rebuildQueue";
|
import { rebuildQueue } from "#dynamic/lib/rebuildQueue";
|
||||||
import { withRetry, isTransientError } from "@server/lib/dbRetry";
|
|
||||||
import {
|
import {
|
||||||
checkOrgRebuildRateLimit,
|
checkOrgRebuildRateLimit,
|
||||||
decrementOrgRebuildCount,
|
decrementOrgRebuildCount,
|
||||||
@@ -286,20 +285,10 @@ export async function rebuildClientAssociationsFromSiteResource(
|
|||||||
) {
|
) {
|
||||||
await incrementOrgRebuildCount(siteResource.orgId);
|
await incrementOrgRebuildCount(siteResource.orgId);
|
||||||
try {
|
try {
|
||||||
// The whole locked rebuild is idempotent (it diffs full expected vs.
|
return await lockManager.withLock(
|
||||||
// actual state each time), so on a transient DB error it's safe to
|
|
||||||
// retry the entire thing rather than just the failed query.
|
|
||||||
return await withRetry(
|
|
||||||
() =>
|
|
||||||
lockManager.withLock(
|
|
||||||
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
|
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
|
||||||
() =>
|
() => rebuildClientAssociationsFromSiteResourceImpl(siteResource),
|
||||||
rebuildClientAssociationsFromSiteResourceImpl(
|
|
||||||
siteResource
|
|
||||||
),
|
|
||||||
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
||||||
),
|
|
||||||
`rebuildClientAssociationsFromSiteResource:${siteResource.siteResourceId}`
|
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (
|
if (
|
||||||
@@ -315,17 +304,6 @@ export async function rebuildClientAssociationsFromSiteResource(
|
|||||||
});
|
});
|
||||||
return { mergedAllClients: [] };
|
return { mergedAllClients: [] };
|
||||||
}
|
}
|
||||||
if (isTransientError(err)) {
|
|
||||||
logger.warn(
|
|
||||||
`rebuildClientAssociations: transient DB error rebuilding site resource ${siteResource.siteResourceId} persisted after retries, queuing for deferred processing:`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
await rebuildQueue.enqueue({
|
|
||||||
type: "site-resource",
|
|
||||||
id: siteResource.siteResourceId
|
|
||||||
});
|
|
||||||
return { mergedAllClients: [] };
|
|
||||||
}
|
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
await decrementOrgRebuildCount(siteResource.orgId);
|
await decrementOrgRebuildCount(siteResource.orgId);
|
||||||
@@ -485,7 +463,6 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
|
|||||||
await trx
|
await trx
|
||||||
.insert(clientSiteResourcesAssociationsCache)
|
.insert(clientSiteResourcesAssociationsCache)
|
||||||
.values(clientSiteResourcesToInsert)
|
.values(clientSiteResourcesToInsert)
|
||||||
.onConflictDoNothing()
|
|
||||||
.returning();
|
.returning();
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserted clientSiteResource associations`
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserted clientSiteResource associations`
|
||||||
@@ -533,7 +510,6 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
|
|||||||
for (const site of sitesToProcess) {
|
for (const site of sitesToProcess) {
|
||||||
const siteId = site.siteId;
|
const siteId = site.siteId;
|
||||||
|
|
||||||
try {
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}`
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}`
|
||||||
);
|
);
|
||||||
@@ -563,14 +539,11 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
|
|||||||
subnet: clients.subnet
|
subnet: clients.subnet
|
||||||
})
|
})
|
||||||
.from(clients)
|
.from(clients)
|
||||||
.where(
|
.where(inArray(clients.clientId, existingClientSiteIds))
|
||||||
inArray(clients.clientId, existingClientSiteIds)
|
|
||||||
)
|
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const otherResourceClientIds =
|
const otherResourceClientIds =
|
||||||
clientsFromOtherResourcesBySite.get(siteId) ??
|
clientsFromOtherResourcesBySite.get(siteId) ?? new Set<number>();
|
||||||
new Set<number>();
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]`
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]`
|
||||||
@@ -582,17 +555,10 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
|
|||||||
? mergedAllClientIds
|
? mergedAllClientIds
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Note: we deliberately do NOT exclude clients covered by another
|
|
||||||
// site resource here (unlike clientSitesToRemove below). Doing so
|
|
||||||
// previously caused a permanent gap: if resource A saw resource B's
|
|
||||||
// cache row and skipped adding (assuming B would maintain it), and
|
|
||||||
// B's own rebuild made the same assumption about A, the site-level
|
|
||||||
// row could end up never inserted by anyone even though both
|
|
||||||
// resources' client associations were otherwise correct.
|
|
||||||
// onConflictDoNothing makes a redundant insert harmless, so there's
|
|
||||||
// no correctness reason to skip here.
|
|
||||||
const clientSitesToAdd = expectedClientIdsForSite.filter(
|
const clientSitesToAdd = expectedClientIdsForSite.filter(
|
||||||
(clientId) => !existingClientSiteIds.includes(clientId)
|
(clientId) =>
|
||||||
|
!existingClientSiteIds.includes(clientId) &&
|
||||||
|
!otherResourceClientIds.has(clientId) // dont add if already connected via another site resource
|
||||||
);
|
);
|
||||||
|
|
||||||
const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
|
const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
|
||||||
@@ -611,7 +577,6 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
|
|||||||
await trx
|
await trx
|
||||||
.insert(clientSitesAssociationsCache)
|
.insert(clientSitesAssociationsCache)
|
||||||
.values(clientSitesToInsert)
|
.values(clientSitesToInsert)
|
||||||
.onConflictDoNothing()
|
|
||||||
.returning();
|
.returning();
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations`
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations`
|
||||||
@@ -660,21 +625,6 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
|
|||||||
clientSitesToRemove,
|
clientSitesToRemove,
|
||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
} catch (err) {
|
|
||||||
// Don't let a failure on one site abort processing of every
|
|
||||||
// other site queued after it in this run. Since we're not
|
|
||||||
// re-throwing, the outer wrapper's retry/requeue logic never
|
|
||||||
// sees this failure, so explicitly queue this resource for a
|
|
||||||
// follow-up pass to reconcile whatever this site didn't get to.
|
|
||||||
logger.error(
|
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} failed while processing site for siteResourceId=${siteResource.siteResourceId}, continuing with remaining sites and queuing a follow-up pass:`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
await rebuildQueue.enqueue({
|
|
||||||
type: "site-resource",
|
|
||||||
id: siteResource.siteResourceId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle subnet proxy target updates for the resource associations
|
// Handle subnet proxy target updates for the resource associations
|
||||||
@@ -707,7 +657,7 @@ async function handleMessagesForSiteClients(
|
|||||||
trx: Transaction | typeof db = db
|
trx: Transaction | typeof db = db
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!site.exitNodeId) {
|
if (!site.exitNodeId) {
|
||||||
logger.debug(
|
logger.warn(
|
||||||
`Exit node ID not on site ${site.siteId} so there is no reason to update clients because it must be offline`
|
`Exit node ID not on site ${site.siteId} so there is no reason to update clients because it must be offline`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -721,14 +671,14 @@ async function handleMessagesForSiteClients(
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!exitNode) {
|
if (!exitNode) {
|
||||||
logger.debug(
|
logger.warn(
|
||||||
`Exit node not found for site ${site.siteId} so there is no reason to update clients because it must be offline`
|
`Exit node not found for site ${site.siteId} so there is no reason to update clients because it must be offline`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!site.publicKey) {
|
if (!site.publicKey) {
|
||||||
logger.debug(
|
logger.warn(
|
||||||
`Site publicKey not set for site ${site.siteId} so cannot add peers to clients`
|
`Site publicKey not set for site ${site.siteId} so cannot add peers to clients`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -742,7 +692,7 @@ async function handleMessagesForSiteClients(
|
|||||||
.where(eq(newts.siteId, siteId))
|
.where(eq(newts.siteId, siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!newt) {
|
if (!newt) {
|
||||||
logger.debug(
|
logger.warn(
|
||||||
`Newt not found for site ${siteId} so cannot add peers to clients`
|
`Newt not found for site ${siteId} so cannot add peers to clients`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -967,7 +917,7 @@ export async function updateClientSiteDestinations(
|
|||||||
|
|
||||||
for (const site of sitesData) {
|
for (const site of sitesData) {
|
||||||
if (!site.sites.subnet) {
|
if (!site.sites.subnet) {
|
||||||
logger.debug(`Site ${site.sites.siteId} has no subnet, skipping`);
|
logger.warn(`Site ${site.sites.siteId} has no subnet, skipping`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1706,17 +1656,10 @@ export async function rebuildClientAssociationsFromClient(
|
|||||||
await incrementOrgRebuildCount(client.orgId);
|
await incrementOrgRebuildCount(client.orgId);
|
||||||
try {
|
try {
|
||||||
const trx = primaryDb;
|
const trx = primaryDb;
|
||||||
// The whole locked rebuild is idempotent (it diffs full expected vs.
|
return await lockManager.withLock(
|
||||||
// actual state each time), so on a transient DB error it's safe to
|
|
||||||
// retry the entire thing rather than just the failed query.
|
|
||||||
return await withRetry(
|
|
||||||
() =>
|
|
||||||
lockManager.withLock(
|
|
||||||
`rebuild-client-associations:client:${client.clientId}`,
|
`rebuild-client-associations:client:${client.clientId}`,
|
||||||
() => rebuildClientAssociationsFromClientImpl(client, trx),
|
() => rebuildClientAssociationsFromClientImpl(client, trx),
|
||||||
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
||||||
),
|
|
||||||
`rebuildClientAssociationsFromClient:${client.clientId}`
|
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (
|
if (
|
||||||
@@ -1732,17 +1675,6 @@ export async function rebuildClientAssociationsFromClient(
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isTransientError(err)) {
|
|
||||||
logger.warn(
|
|
||||||
`rebuildClientAssociations: transient DB error rebuilding client ${client.clientId} persisted after retries, queuing for deferred processing:`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
await rebuildQueue.enqueue({
|
|
||||||
type: "client",
|
|
||||||
id: client.clientId
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
await decrementOrgRebuildCount(client.orgId);
|
await decrementOrgRebuildCount(client.orgId);
|
||||||
@@ -1894,15 +1826,12 @@ async function rebuildClientAssociationsFromClientImpl(
|
|||||||
|
|
||||||
// Insert new associations
|
// Insert new associations
|
||||||
if (resourcesToAdd.length > 0) {
|
if (resourcesToAdd.length > 0) {
|
||||||
await trx
|
await trx.insert(clientSiteResourcesAssociationsCache).values(
|
||||||
.insert(clientSiteResourcesAssociationsCache)
|
|
||||||
.values(
|
|
||||||
resourcesToAdd.map((siteResourceId) => ({
|
resourcesToAdd.map((siteResourceId) => ({
|
||||||
clientId: client.clientId,
|
clientId: client.clientId,
|
||||||
siteResourceId
|
siteResourceId
|
||||||
}))
|
}))
|
||||||
)
|
);
|
||||||
.onConflictDoNothing();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove old associations
|
// Remove old associations
|
||||||
@@ -1940,15 +1869,12 @@ async function rebuildClientAssociationsFromClientImpl(
|
|||||||
|
|
||||||
// Insert new site associations
|
// Insert new site associations
|
||||||
if (sitesToAdd.length > 0) {
|
if (sitesToAdd.length > 0) {
|
||||||
await trx
|
await trx.insert(clientSitesAssociationsCache).values(
|
||||||
.insert(clientSitesAssociationsCache)
|
|
||||||
.values(
|
|
||||||
sitesToAdd.map((siteId) => ({
|
sitesToAdd.map((siteId) => ({
|
||||||
clientId: client.clientId,
|
clientId: client.clientId,
|
||||||
siteId
|
siteId
|
||||||
}))
|
}))
|
||||||
)
|
);
|
||||||
.onConflictDoNothing();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove old site associations
|
// Remove old site associations
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { isTransientError } from "@server/lib/dbRetry";
|
|
||||||
|
|
||||||
export type RebuildJobType = "site-resource" | "client";
|
export type RebuildJobType = "site-resource" | "client";
|
||||||
|
|
||||||
export interface RebuildJob {
|
export interface RebuildJob {
|
||||||
type: RebuildJobType;
|
type: RebuildJobType;
|
||||||
id: number;
|
id: number;
|
||||||
// Number of times this job has already been re-queued after a transient
|
|
||||||
// failure. Absent/0 means it has not failed yet.
|
|
||||||
attempt?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RebuildJobHandlers {
|
export interface RebuildJobHandlers {
|
||||||
@@ -28,10 +24,6 @@ export interface RebuildQueueManager {
|
|||||||
// retried shortly after against fresh DB state.
|
// retried shortly after against fresh DB state.
|
||||||
const POLL_INTERVAL_MS = 500;
|
const POLL_INTERVAL_MS = 500;
|
||||||
const BATCH_SIZE = 5;
|
const BATCH_SIZE = 5;
|
||||||
// A job that fails with a transient DB error gets re-queued with backoff
|
|
||||||
// instead of being dropped, up to this many times.
|
|
||||||
const MAX_JOB_ATTEMPTS = 5;
|
|
||||||
const JOB_RETRY_BASE_DELAY_MS = 1000;
|
|
||||||
|
|
||||||
function dedupeKey(job: RebuildJob): string {
|
function dedupeKey(job: RebuildJob): string {
|
||||||
return `${job.type}:${job.id}`;
|
return `${job.type}:${job.id}`;
|
||||||
@@ -114,31 +106,12 @@ class InMemoryRebuildQueue implements RebuildQueueManager {
|
|||||||
`Rebuild queue: completed ${job.type}:${job.id}`
|
`Rebuild queue: completed ${job.type}:${job.id}`
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const attempt = (job.attempt ?? 0) + 1;
|
|
||||||
if (isTransientError(err) && attempt <= MAX_JOB_ATTEMPTS) {
|
|
||||||
const delay =
|
|
||||||
JOB_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
|
|
||||||
logger.warn(
|
|
||||||
`Rebuild queue: job ${job.type}:${job.id} hit a transient error (attempt ${attempt}/${MAX_JOB_ATTEMPTS}), re-queuing in ${delay}ms:`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
setTimeout(() => {
|
|
||||||
this.enqueue({ ...job, attempt }).catch(
|
|
||||||
(enqueueErr) =>
|
|
||||||
logger.error(
|
|
||||||
`Rebuild queue: failed to re-queue ${job.type}:${job.id} after transient error:`,
|
|
||||||
enqueueErr
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, delay);
|
|
||||||
} else {
|
|
||||||
logger.error(
|
logger.error(
|
||||||
`Rebuild queue: job ${job.type}:${job.id} threw an error:`,
|
`Rebuild queue: job ${job.type}:${job.id} threw an error:`,
|
||||||
err
|
err
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
this.processing = false;
|
this.processing = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import { redis } from "#private/lib/redis";
|
import { redis } from "#private/lib/redis";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
|
||||||
export const ORG_REBUILD_CONCURRENCY_LIMIT = 10;
|
export const ORG_REBUILD_CONCURRENCY_LIMIT = 5;
|
||||||
|
|
||||||
// Safety-net TTL: slightly longer than the rebuild lock TTL (120 s). If a
|
// Safety-net TTL: slightly longer than the rebuild lock TTL (120 s). If a
|
||||||
// server process dies while holding a rebuild, this ensures the counter key
|
// server process dies while holding a rebuild, this ensures the counter key
|
||||||
|
|||||||
@@ -14,16 +14,12 @@
|
|||||||
import { redis } from "#private/lib/redis";
|
import { redis } from "#private/lib/redis";
|
||||||
import { lockManager } from "#private/lib/lock";
|
import { lockManager } from "#private/lib/lock";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { isTransientError } from "@server/lib/dbRetry";
|
|
||||||
|
|
||||||
export type RebuildJobType = "site-resource" | "client";
|
export type RebuildJobType = "site-resource" | "client";
|
||||||
|
|
||||||
export interface RebuildJob {
|
export interface RebuildJob {
|
||||||
type: RebuildJobType;
|
type: RebuildJobType;
|
||||||
id: number;
|
id: number;
|
||||||
// Number of times this job has already been re-queued after a transient
|
|
||||||
// failure. Absent/0 means it has not failed yet.
|
|
||||||
attempt?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RebuildJobHandlers {
|
export interface RebuildJobHandlers {
|
||||||
@@ -47,11 +43,6 @@ const PROCESSOR_LOCK_TTL_MS = 120000 * BATCH_SIZE + 30000; // ~630 s
|
|||||||
|
|
||||||
const POLL_INTERVAL_MS = 500;
|
const POLL_INTERVAL_MS = 500;
|
||||||
|
|
||||||
// A job that fails with a transient DB error gets re-queued with backoff
|
|
||||||
// instead of being dropped, up to this many times.
|
|
||||||
const MAX_JOB_ATTEMPTS = 5;
|
|
||||||
const JOB_RETRY_BASE_DELAY_MS = 1000;
|
|
||||||
|
|
||||||
class RedisRebuildQueue {
|
class RedisRebuildQueue {
|
||||||
private processingStarted = false;
|
private processingStarted = false;
|
||||||
|
|
||||||
@@ -189,35 +180,12 @@ class RedisRebuildQueue {
|
|||||||
`Rebuild queue: completed ${job.type}:${job.id}`
|
`Rebuild queue: completed ${job.type}:${job.id}`
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const attempt = (job.attempt ?? 0) + 1;
|
|
||||||
if (
|
|
||||||
isTransientError(err) &&
|
|
||||||
attempt <= MAX_JOB_ATTEMPTS
|
|
||||||
) {
|
|
||||||
const delay =
|
|
||||||
JOB_RETRY_BASE_DELAY_MS *
|
|
||||||
Math.pow(2, attempt - 1);
|
|
||||||
logger.warn(
|
|
||||||
`Rebuild queue: job ${job.type}:${job.id} hit a transient error (attempt ${attempt}/${MAX_JOB_ATTEMPTS}), re-queuing in ${delay}ms:`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
setTimeout(() => {
|
|
||||||
this.enqueue({ ...job, attempt }).catch(
|
|
||||||
(enqueueErr) =>
|
|
||||||
logger.error(
|
|
||||||
`Rebuild queue: failed to re-queue ${job.type}:${job.id} after transient error:`,
|
|
||||||
enqueueErr
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, delay);
|
|
||||||
} else {
|
|
||||||
logger.error(
|
logger.error(
|
||||||
`Rebuild queue: job ${job.type}:${job.id} threw an error:`,
|
`Rebuild queue: job ${job.type}:${job.id} threw an error:`,
|
||||||
err
|
err
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
PROCESSOR_LOCK_TTL_MS
|
PROCESSOR_LOCK_TTL_MS
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -894,19 +894,6 @@ class RegionalRedisManager {
|
|||||||
return opts;
|
return opts;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The regional Redis StatefulSet's "redis" service pins to pod redis-0
|
|
||||||
// (primary). The replica (redis-1) is only reachable through the
|
|
||||||
// per-pod headless service: <svc>.<namespace>.svc.cluster.local ->
|
|
||||||
// redis-1.redis-headless.<namespace>.svc.cluster.local. Returns null
|
|
||||||
// if the configured host doesn't match that pattern (e.g. local dev),
|
|
||||||
// in which case callers should fall back to the primary for reads.
|
|
||||||
private getReplicaHost(primaryHost: string): string | null {
|
|
||||||
const match = primaryHost.match(/^redis\.([^.]+)\.svc\.cluster\.local$/);
|
|
||||||
if (!match) return null;
|
|
||||||
const namespace = match[1];
|
|
||||||
return `redis-1.redis-headless.${namespace}.svc.cluster.local`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private initializeClients(): void {
|
private initializeClients(): void {
|
||||||
const cfg = this.getConfig();
|
const cfg = this.getConfig();
|
||||||
const baseOpts = {
|
const baseOpts = {
|
||||||
@@ -920,42 +907,35 @@ class RegionalRedisManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
this.writeClient = new Redis(baseOpts);
|
this.writeClient = new Redis(baseOpts);
|
||||||
|
// redis-1 (replica) handles reads; fall back to primary if not resolvable
|
||||||
|
this.readClient = new Redis({
|
||||||
|
...baseOpts,
|
||||||
|
host: cfg.host!.replace(/^(.*?)(\.\S+)$/, (_, h, rest) => {
|
||||||
|
// Derive replica hostname from the headless service pattern:
|
||||||
|
// redis.redis.svc.cluster.local -> redis-1.redis-headless.redis.svc.cluster.local
|
||||||
|
// If it doesn't look like a k8s service, just use the same host
|
||||||
|
return h + rest;
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
const replicaHost = this.getReplicaHost(cfg.host!);
|
// For simplicity use same host for both; callers can always read from primary
|
||||||
this.readClient = replicaHost
|
// The real replica routing is handled by the StatefulSet headless service
|
||||||
? new Redis({ ...baseOpts, host: replicaHost })
|
this.readClient = this.writeClient;
|
||||||
: this.writeClient;
|
|
||||||
|
|
||||||
this.writeClient.on("ready", () => {
|
this.writeClient.on("ready", () => {
|
||||||
logger.info("Regional Redis write client ready");
|
logger.info("Regional Redis client ready");
|
||||||
this.isHealthy = true;
|
this.isHealthy = true;
|
||||||
});
|
});
|
||||||
this.writeClient.on("error", (err) => {
|
this.writeClient.on("error", (err) => {
|
||||||
logger.error("Regional Redis write client error:", err);
|
logger.error("Regional Redis client error:", err);
|
||||||
this.isHealthy = false;
|
this.isHealthy = false;
|
||||||
});
|
});
|
||||||
this.writeClient.on("reconnecting", () => {
|
this.writeClient.on("reconnecting", () => {
|
||||||
logger.info("Regional Redis write client reconnecting...");
|
logger.info("Regional Redis client reconnecting...");
|
||||||
this.isHealthy = false;
|
this.isHealthy = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.readClient !== this.writeClient) {
|
logger.info("Regional Redis client initialized");
|
||||||
this.readClient.on("ready", () => {
|
|
||||||
logger.info("Regional Redis read client ready");
|
|
||||||
});
|
|
||||||
this.readClient.on("error", (err) => {
|
|
||||||
logger.error("Regional Redis read client error:", err);
|
|
||||||
});
|
|
||||||
this.readClient.on("reconnecting", () => {
|
|
||||||
logger.info("Regional Redis read client reconnecting...");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
replicaHost
|
|
||||||
? `Regional Redis client initialized (reads routed to replica ${replicaHost})`
|
|
||||||
: "Regional Redis client initialized (no replica resolvable, reads routed to primary)"
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Failed to initialize regional Redis client:", error);
|
logger.error("Failed to initialize regional Redis client:", error);
|
||||||
this.isEnabled = false;
|
this.isEnabled = false;
|
||||||
@@ -1061,14 +1041,11 @@ class RegionalRedisManager {
|
|||||||
|
|
||||||
public async disconnect(): Promise<void> {
|
public async disconnect(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (this.readClient && this.readClient !== this.writeClient) {
|
|
||||||
await this.readClient.quit();
|
|
||||||
}
|
|
||||||
this.readClient = null;
|
|
||||||
if (this.writeClient) {
|
if (this.writeClient) {
|
||||||
await this.writeClient.quit();
|
await this.writeClient.quit();
|
||||||
this.writeClient = null;
|
this.writeClient = null;
|
||||||
}
|
}
|
||||||
|
this.readClient = null;
|
||||||
logger.info("Regional Redis client disconnected");
|
logger.info("Regional Redis client disconnected");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error disconnecting regional Redis client:", error);
|
logger.error("Error disconnecting regional Redis client:", error);
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ export async function createExitNode(
|
|||||||
.where(eq(exitNodes.publicKey, publicKey));
|
.where(eq(exitNodes.publicKey, publicKey));
|
||||||
let exitNode: ExitNode;
|
let exitNode: ExitNode;
|
||||||
if (!exitNodeQuery) {
|
if (!exitNodeQuery) {
|
||||||
const { value: address, release } = await getNextAvailableSubnet();
|
const address = await getNextAvailableSubnet();
|
||||||
try {
|
|
||||||
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
|
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
|
||||||
// const listenPort = await getNextAvailablePort();
|
// const listenPort = await getNextAvailablePort();
|
||||||
const listenPort = config.getRawConfig().gerbil.start_port;
|
const listenPort = config.getRawConfig().gerbil.start_port;
|
||||||
@@ -61,9 +60,6 @@ export async function createExitNode(
|
|||||||
logger.info(
|
logger.info(
|
||||||
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
|
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
await release();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// update the reachable at
|
// update the reachable at
|
||||||
[exitNode] = await db
|
[exitNode] = await db
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ export async function createRemoteExitNode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const secretHash = await hashPassword(secret);
|
const secretHash = await hashPassword(secret);
|
||||||
|
// const address = await getNextAvailableSubnet();
|
||||||
|
const address = "100.89.140.1/24"; // FOR NOW LETS HARDCODE THESE ADDRESSES
|
||||||
|
|
||||||
const [existingRemoteExitNode] = await db
|
const [existingRemoteExitNode] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -189,27 +191,13 @@ export async function createRemoteExitNode(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If this remote exit node isn't already backing an exit node in
|
|
||||||
// another org, we're about to create a brand new one. Reserve a
|
|
||||||
// subnet for it up front so the allocation lock is held across the
|
|
||||||
// whole insert - this guarantees exit node subnets never overlap,
|
|
||||||
// even under concurrent creation, which matters for HA setups.
|
|
||||||
let releaseSubnetLock: (() => Promise<void>) | null = null;
|
|
||||||
let newExitNodeAddress: string | null = null;
|
|
||||||
if (!existingExitNode) {
|
|
||||||
const { value, release } = await getNextAvailableSubnet();
|
|
||||||
newExitNodeAddress = value;
|
|
||||||
releaseSubnetLock = release;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
if (!existingExitNode) {
|
if (!existingExitNode) {
|
||||||
const [res] = await trx
|
const [res] = await trx
|
||||||
.insert(exitNodes)
|
.insert(exitNodes)
|
||||||
.values({
|
.values({
|
||||||
name: remoteExitNodeId,
|
name: remoteExitNodeId,
|
||||||
address: newExitNodeAddress!,
|
address,
|
||||||
endpoint: "",
|
endpoint: "",
|
||||||
publicKey: "",
|
publicKey: "",
|
||||||
listenPort: 0,
|
listenPort: 0,
|
||||||
@@ -286,9 +274,6 @@ export async function createRemoteExitNode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
await releaseSubnetLock?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = generateSessionToken();
|
const token = generateSessionToken();
|
||||||
await createRemoteExitNodeSession(token, remoteExitNodeId);
|
await createRemoteExitNodeSession(token, remoteExitNodeId);
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ export async function createExitNode(
|
|||||||
const [exitNodeQuery] = await db.select().from(exitNodes).limit(1);
|
const [exitNodeQuery] = await db.select().from(exitNodes).limit(1);
|
||||||
let exitNode: ExitNode;
|
let exitNode: ExitNode;
|
||||||
if (!exitNodeQuery) {
|
if (!exitNodeQuery) {
|
||||||
const { value: address, release } = await getNextAvailableSubnet();
|
const address = await getNextAvailableSubnet();
|
||||||
try {
|
|
||||||
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
|
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
|
||||||
// const listenPort = await getNextAvailablePort();
|
// const listenPort = await getNextAvailablePort();
|
||||||
const listenPort = config.getRawConfig().gerbil.start_port;
|
const listenPort = config.getRawConfig().gerbil.start_port;
|
||||||
@@ -45,9 +44,6 @@ export async function createExitNode(
|
|||||||
logger.info(
|
logger.info(
|
||||||
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
|
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
await release();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// update the existing exit node
|
// update the existing exit node
|
||||||
[exitNode] = await db
|
[exitNode] = await db
|
||||||
|
|||||||
@@ -52,13 +52,13 @@ export async function buildClientConfigurationForNewtClient(
|
|||||||
clientsRes
|
clientsRes
|
||||||
.filter((client) => {
|
.filter((client) => {
|
||||||
if (!client.clients.pubKey) {
|
if (!client.clients.pubKey) {
|
||||||
logger.debug(
|
logger.warn(
|
||||||
`Client ${client.clients.clientId} has no public key, skipping`
|
`Client ${client.clients.clientId} has no public key, skipping`
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!client.clients.subnet) {
|
if (!client.clients.subnet) {
|
||||||
logger.debug(
|
logger.warn(
|
||||||
`Client ${client.clients.clientId} has no subnet, skipping`
|
`Client ${client.clients.clientId} has no subnet, skipping`
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!newt.siteId) {
|
if (!newt.siteId) {
|
||||||
logger.warn("Newt has no site ID!");
|
logger.warn("Newt has no client ID!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,12 +34,6 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
|
|||||||
.where(eq(sites.siteId, newt.siteId!))
|
.where(eq(sites.siteId, newt.siteId!))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!site) {
|
|
||||||
throw new Error(
|
|
||||||
`Could not find site ${newt.siteId} to update disconnection from disconnect message`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await fireSiteOfflineAlert(
|
await fireSiteOfflineAlert(
|
||||||
site.orgId,
|
site.orgId,
|
||||||
site.siteId,
|
site.siteId,
|
||||||
@@ -49,6 +43,6 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error handling site disconnecting message", error);
|
logger.error("Error handling disconnecting message", { error });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { sites, clients, olms } from "@server/db";
|
|||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fireSiteOnlineAlert } from "@server/lib/alerts";
|
import { fireSiteOnlineAlert } from "@server/lib/alerts";
|
||||||
import { withRetry } from "@server/lib/dbRetry";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ping Accumulator
|
* Ping Accumulator
|
||||||
@@ -23,6 +22,8 @@ import { withRetry } from "@server/lib/dbRetry";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds
|
const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds
|
||||||
|
const MAX_RETRIES = 5;
|
||||||
|
const BASE_DELAY_MS = 50;
|
||||||
|
|
||||||
// ── Site (newt) pings ──────────────────────────────────────────────────
|
// ── Site (newt) pings ──────────────────────────────────────────────────
|
||||||
// Map of siteId -> latest ping timestamp (unix seconds)
|
// Map of siteId -> latest ping timestamp (unix seconds)
|
||||||
@@ -265,7 +266,85 @@ export async function flushPingsToDb(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Retry / Error Helpers ──────────────────────────────────────────────
|
// ── Retry / Error Helpers ──────────────────────────────────────────────
|
||||||
// See @server/lib/dbRetry for the shared withRetry/isTransientError helpers.
|
|
||||||
|
/**
|
||||||
|
* Simple retry wrapper with exponential backoff for transient errors
|
||||||
|
* (deadlocks, connection timeouts, unexpected disconnects).
|
||||||
|
*
|
||||||
|
* PostgreSQL deadlocks (40P01) are always safe to retry: the database
|
||||||
|
* guarantees exactly one winner per deadlock pair, so the loser just needs
|
||||||
|
* to try again. MAX_RETRIES is intentionally higher than typical connection
|
||||||
|
* retry budgets to give deadlock victims enough chances to succeed.
|
||||||
|
*/
|
||||||
|
async function withRetry<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
context: string
|
||||||
|
): Promise<T> {
|
||||||
|
let attempt = 0;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (error: any) {
|
||||||
|
if (isTransientError(error) && attempt < MAX_RETRIES) {
|
||||||
|
attempt++;
|
||||||
|
const baseDelay = Math.pow(2, attempt - 1) * BASE_DELAY_MS;
|
||||||
|
const jitter = Math.random() * baseDelay;
|
||||||
|
const delay = baseDelay + jitter;
|
||||||
|
logger.warn(
|
||||||
|
`Transient DB error in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms`,
|
||||||
|
{ code: error?.code ?? error?.cause?.code }
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect transient errors that are safe to retry.
|
||||||
|
*/
|
||||||
|
function isTransientError(error: any): boolean {
|
||||||
|
if (!error) return false;
|
||||||
|
|
||||||
|
const message = (error.message || "").toLowerCase();
|
||||||
|
const causeMessage = (error.cause?.message || "").toLowerCase();
|
||||||
|
const code = error.code || error.cause?.code || "";
|
||||||
|
|
||||||
|
// Connection timeout / terminated
|
||||||
|
if (
|
||||||
|
message.includes("connection timeout") ||
|
||||||
|
message.includes("connection terminated") ||
|
||||||
|
message.includes("timeout exceeded when trying to connect") ||
|
||||||
|
causeMessage.includes("connection terminated unexpectedly") ||
|
||||||
|
causeMessage.includes("connection timeout")
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostgreSQL deadlock detected - always safe to retry (one winner guaranteed)
|
||||||
|
if (code === "40P01" || message.includes("deadlock")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostgreSQL serialization failure
|
||||||
|
if (code === "40001") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
|
||||||
|
if (
|
||||||
|
code === "ECONNRESET" ||
|
||||||
|
code === "ECONNREFUSED" ||
|
||||||
|
code === "EPIPE" ||
|
||||||
|
code === "ETIMEDOUT"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Lifecycle ──────────────────────────────────────────────────────────
|
// ── Lifecycle ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export async function buildSiteConfigurationForOlmClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!site.subnet) {
|
if (!site.subnet) {
|
||||||
logger.debug(`Site ${site.siteId} has no subnet, skipping`);
|
logger.warn(`Site ${site.siteId} has no subnet, skipping`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ import logger from "@server/logger";
|
|||||||
/**
|
/**
|
||||||
* Handles disconnecting messages from clients to show disconnected in the ui
|
* Handles disconnecting messages from clients to show disconnected in the ui
|
||||||
*/
|
*/
|
||||||
export const handleOlmDisconnectingMessage: MessageHandler = async (
|
export const handleOlmDisconnectingMessage: MessageHandler = async (context) => {
|
||||||
context
|
|
||||||
) => {
|
|
||||||
const { message, client: c, sendToClient } = context;
|
const { message, client: c, sendToClient } = context;
|
||||||
const olm = c as Olm;
|
const olm = c as Olm;
|
||||||
|
|
||||||
@@ -31,6 +29,6 @@ export const handleOlmDisconnectingMessage: MessageHandler = async (
|
|||||||
})
|
})
|
||||||
.where(eq(clients.clientId, olm.clientId));
|
.where(eq(clients.clientId, olm.clientId));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error handling client disconnecting message", error);
|
logger.error("Error handling disconnecting message", { error });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -616,8 +616,8 @@ export async function listResources(
|
|||||||
and(
|
and(
|
||||||
inArray(resources.mode, browserGatewayModes),
|
inArray(resources.mode, browserGatewayModes),
|
||||||
or(
|
or(
|
||||||
effectiveSso,
|
eq(effectiveSso, true),
|
||||||
effectiveWhitelist,
|
eq(effectiveWhitelist, true),
|
||||||
not(isNull(effectiveHeaderAuthId)),
|
not(isNull(effectiveHeaderAuthId)),
|
||||||
not(isNull(effectivePincodeId)),
|
not(isNull(effectivePincodeId)),
|
||||||
not(isNull(effectivePasswordId))
|
not(isNull(effectivePasswordId))
|
||||||
@@ -629,8 +629,8 @@ export async function listResources(
|
|||||||
conditions.push(
|
conditions.push(
|
||||||
and(
|
and(
|
||||||
inArray(resources.mode, browserGatewayModes),
|
inArray(resources.mode, browserGatewayModes),
|
||||||
not(effectiveSso),
|
not(eq(effectiveSso, true)),
|
||||||
not(effectiveWhitelist),
|
not(eq(effectiveWhitelist, true)),
|
||||||
isNull(effectiveHeaderAuthId),
|
isNull(effectiveHeaderAuthId),
|
||||||
isNull(effectivePincodeId),
|
isNull(effectivePincodeId),
|
||||||
isNull(effectivePasswordId)
|
isNull(effectivePasswordId)
|
||||||
|
|||||||
@@ -268,11 +268,7 @@ export async function createSite(
|
|||||||
|
|
||||||
let newSite: Site | undefined;
|
let newSite: Site | undefined;
|
||||||
try {
|
try {
|
||||||
if (type === "wireguard" && subnet && exitNodeId) {
|
if (subnet && exitNodeId) {
|
||||||
// Only wireguard sites actually persist the provided subnet/exitNodeId.
|
|
||||||
// Newt sites have their subnet/exit node chosen (under a lock) when the
|
|
||||||
// newt connects, so validating them here is both unnecessary and racy,
|
|
||||||
// since pickSiteDefaults does not lock the subnet it suggests.
|
|
||||||
//make sure the subnet is in the range of the exit node if provided
|
//make sure the subnet is in the range of the exit node if provided
|
||||||
const [exitNode] = await db
|
const [exitNode] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -290,13 +290,7 @@ export default function BillingPage() {
|
|||||||
setHasSubscription(
|
setHasSubscription(
|
||||||
tierSub.subscription.status === "active"
|
tierSub.subscription.status === "active"
|
||||||
);
|
);
|
||||||
// expiresAt is only meaningful while the trial hasn't
|
setIsTrial(tierSub.subscription.expiresAt != null);
|
||||||
// actually run out yet; a stale row with a past
|
|
||||||
// expiresAt should no longer be treated as a live trial
|
|
||||||
const expiresAt = tierSub.subscription.expiresAt;
|
|
||||||
setIsTrial(
|
|
||||||
expiresAt != null && expiresAt * 1000 > Date.now()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find license subscription
|
// Find license subscription
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useActionState, useState } from "react";
|
import { useActionState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -137,21 +137,11 @@ function ProxyResourceHttpForm({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [, formAction, saveLoading] = useActionState(onSubmit, null);
|
const [, formAction, saveLoading] = useActionState(onSubmit, null);
|
||||||
const [headersValid, setHeadersValid] = useState(true);
|
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
const isValid = await form.trigger();
|
const isValid = await form.trigger();
|
||||||
if (!isValid) return;
|
if (!isValid) return;
|
||||||
|
|
||||||
if (!headersValid) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("settingsErrorUpdate"),
|
|
||||||
description: t("headersValidationError")
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = form.getValues();
|
const data = form.getValues();
|
||||||
|
|
||||||
const res = await api
|
const res = await api
|
||||||
@@ -328,9 +318,6 @@ function ProxyResourceHttpForm({
|
|||||||
onChange={
|
onChange={
|
||||||
field.onChange
|
field.onChange
|
||||||
}
|
}
|
||||||
onValidityChange={
|
|
||||||
setHeadersValid
|
|
||||||
}
|
|
||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -354,7 +341,7 @@ function ProxyResourceHttpForm({
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
loading={saveLoading}
|
loading={saveLoading}
|
||||||
disabled={saveLoading || !headersValid}
|
disabled={saveLoading}
|
||||||
form="http-settings-form"
|
form="http-settings-form"
|
||||||
>
|
>
|
||||||
{t("saveSettings")}
|
{t("saveSettings")}
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ import LoginCardHeader from "@app/components/LoginCardHeader";
|
|||||||
import { priv } from "@app/lib/api";
|
import { priv } from "@app/lib/api";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { LoginFormIDP } from "@app/components/LoginForm";
|
import { LoginFormIDP } from "@app/components/LoginForm";
|
||||||
import { ListIdpsResponse } from "@server/routers/idp";
|
import { ListIdpsResponse, type GetIdpResponse } from "@server/routers/idp";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Log In"
|
title: "Log In"
|
||||||
@@ -29,8 +32,9 @@ export default async function Page(props: {
|
|||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
}) {
|
}) {
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
const getUser = cache(verifySession);
|
const user = await verifySession({ skipCheckVerifyEmail: true });
|
||||||
const user = await getUser({ skipCheckVerifyEmail: true });
|
|
||||||
|
const lastUsedIdpCookie = (await cookies()).get(LAST_USED_IDP_COOKIE_NAME);
|
||||||
|
|
||||||
const isInvite = searchParams?.redirect?.includes("/invite");
|
const isInvite = searchParams?.redirect?.includes("/invite");
|
||||||
const forceLoginParam = searchParams?.forceLogin;
|
const forceLoginParam = searchParams?.forceLogin;
|
||||||
@@ -85,19 +89,47 @@ export default async function Page(props: {
|
|||||||
(build === "enterprise" && env.app.identityProviderMode === "org");
|
(build === "enterprise" && env.app.identityProviderMode === "org");
|
||||||
|
|
||||||
let loginIdps: LoginFormIDP[] = [];
|
let loginIdps: LoginFormIDP[] = [];
|
||||||
|
let lastUsedIdpForSmartLogin: (LoginFormIDP & { orgId?: string }) | null =
|
||||||
|
null;
|
||||||
if (!useSmartLogin) {
|
if (!useSmartLogin) {
|
||||||
// Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
|
// Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
|
||||||
if (build === "oss" || env.app.identityProviderMode !== "org") {
|
if (build === "oss" || env.app.identityProviderMode !== "org") {
|
||||||
const idpsRes = await cache(
|
const idpsRes =
|
||||||
async () =>
|
await priv.get<AxiosResponse<ListIdpsResponse>>("/idp");
|
||||||
await priv.get<AxiosResponse<ListIdpsResponse>>("/idp")
|
|
||||||
)();
|
|
||||||
loginIdps = idpsRes.data.data.idps.map((idp) => ({
|
loginIdps = idpsRes.data.data.idps.map((idp) => ({
|
||||||
idpId: idp.idpId,
|
idpId: idp.idpId,
|
||||||
name: idp.name,
|
name: idp.name,
|
||||||
variant: idp.type
|
variant: idp.type
|
||||||
})) as LoginFormIDP[];
|
})) as LoginFormIDP[];
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (lastUsedIdpCookie) {
|
||||||
|
const lastUsedIdpSchema = z.object({
|
||||||
|
orgId: z.string().optional(),
|
||||||
|
idpId: z.number()
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const persistedData = lastUsedIdpSchema.parse(
|
||||||
|
JSON.parse(lastUsedIdpCookie.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const idpRes = await priv.get<AxiosResponse<GetIdpResponse>>(
|
||||||
|
`/idp/${persistedData.idpId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const idp = idpRes.data.data.idp;
|
||||||
|
|
||||||
|
lastUsedIdpForSmartLogin = {
|
||||||
|
idpId: idp.idpId,
|
||||||
|
name: idp.name,
|
||||||
|
variant: idp.type,
|
||||||
|
orgId: persistedData.orgId,
|
||||||
|
lastUsed: true
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// the idp might not exist or the data is malformatted, skip this
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
@@ -160,6 +192,7 @@ export default async function Page(props: {
|
|||||||
redirect={redirectUrl}
|
redirect={redirectUrl}
|
||||||
forceLogin={forceLogin}
|
forceLogin={forceLogin}
|
||||||
defaultUser={defaultUser}
|
defaultUser={defaultUser}
|
||||||
|
lastUsedIdp={lastUsedIdpForSmartLogin}
|
||||||
orgSignIn={
|
orgSignIn={
|
||||||
!isInvite &&
|
!isInvite &&
|
||||||
(build === "saas" ||
|
(build === "saas" ||
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import UserProvider from "@app/providers/UserProvider";
|
|||||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { cache } from "react";
|
|
||||||
import OrganizationLanding from "@app/components/OrganizationLanding";
|
import OrganizationLanding from "@app/components/OrganizationLanding";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||||
@@ -13,7 +12,6 @@ import { Layout } from "@app/components/Layout";
|
|||||||
import RedirectToOrg from "@app/components/RedirectToOrg";
|
import RedirectToOrg from "@app/components/RedirectToOrg";
|
||||||
import { InitialSetupCompleteResponse } from "@server/routers/auth";
|
import { InitialSetupCompleteResponse } from "@server/routers/auth";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { build } from "@server/build";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -27,8 +25,7 @@ export default async function Page(props: {
|
|||||||
|
|
||||||
const env = pullEnv();
|
const env = pullEnv();
|
||||||
|
|
||||||
const getUser = cache(verifySession);
|
const user = await verifySession({ skipCheckVerifyEmail: true });
|
||||||
const user = await getUser({ skipCheckVerifyEmail: true });
|
|
||||||
|
|
||||||
let complete = false;
|
let complete = false;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,36 +2,24 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
interface HeadersInputProps {
|
interface HeadersInputProps {
|
||||||
value?: { name: string; value: string }[] | null;
|
value?: { name: string; value: string }[] | null;
|
||||||
onChange: (value: { name: string; value: string }[] | null) => void;
|
onChange: (value: { name: string; value: string }[] | null) => void;
|
||||||
onValidityChange?: (isValid: boolean) => void;
|
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors the server side validation in updateResource.ts so that invalid
|
|
||||||
// input is caught (and shown to the user) before it is ever submitted,
|
|
||||||
// instead of being silently dropped in favor of the last known good value.
|
|
||||||
const validHeaderNamePattern = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/;
|
|
||||||
const validHeaderValuePattern = /^[\t\x20-\x7E]*$/;
|
|
||||||
const templatePattern = /\{\{[^}]+\}\}/;
|
|
||||||
|
|
||||||
export function HeadersInput({
|
export function HeadersInput({
|
||||||
value = [],
|
value = [],
|
||||||
onChange,
|
onChange,
|
||||||
onValidityChange,
|
|
||||||
placeholder = `X-Example-Header: example-value
|
placeholder = `X-Example-Header: example-value
|
||||||
X-Another-Header: another-value`,
|
X-Another-Header: another-value`,
|
||||||
rows = 4,
|
rows = 4,
|
||||||
className
|
className
|
||||||
}: HeadersInputProps) {
|
}: HeadersInputProps) {
|
||||||
const t = useTranslations();
|
|
||||||
const [internalValue, setInternalValue] = useState("");
|
const [internalValue, setInternalValue] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const isUserEditingRef = useRef(false);
|
const isUserEditingRef = useRef(false);
|
||||||
|
|
||||||
@@ -46,56 +34,37 @@ X-Another-Header: another-value`,
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse newline-separated text into header objects, validating each line
|
// Convert newline-separated string to header objects array
|
||||||
// against the same rules enforced by the server. Returns either the
|
const convertToHeadersArray = (
|
||||||
// parsed headers or an error message describing the first invalid line.
|
|
||||||
const parseHeaders = (
|
|
||||||
newlineSeparated: string
|
newlineSeparated: string
|
||||||
):
|
): { name: string; value: string }[] | null => {
|
||||||
| { headers: { name: string; value: string }[]; error: null }
|
if (!newlineSeparated || newlineSeparated.trim() === "") return [];
|
||||||
| { headers: null; error: string } => {
|
|
||||||
if (!newlineSeparated || newlineSeparated.trim() === "") {
|
|
||||||
return { headers: [], error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = newlineSeparated
|
return newlineSeparated
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter((line) => line.length > 0);
|
.filter((line) => line.length > 0 && line.includes(":"))
|
||||||
|
.map((line) => {
|
||||||
const headers: { name: string; value: string }[] = [];
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const colonIndex = line.indexOf(":");
|
const colonIndex = line.indexOf(":");
|
||||||
if (colonIndex === -1) {
|
|
||||||
return { headers: null, error: t("headersValidationError") };
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = line.substring(0, colonIndex).trim();
|
const name = line.substring(0, colonIndex).trim();
|
||||||
const value = line.substring(colonIndex + 1).trim();
|
const value = line.substring(colonIndex + 1).trim();
|
||||||
|
|
||||||
if (
|
// Ensure header name conforms to HTTP header requirements
|
||||||
!validHeaderNamePattern.test(name) ||
|
// Header names should be case-insensitive, contain only ASCII letters, digits, and hyphens
|
||||||
!validHeaderValuePattern.test(value) ||
|
const normalizedName = name
|
||||||
templatePattern.test(name) ||
|
.replace(/[^a-zA-Z0-9\-]/g, "")
|
||||||
templatePattern.test(value)
|
.toLowerCase();
|
||||||
) {
|
|
||||||
return { headers: null, error: t("headersValidationError") };
|
|
||||||
}
|
|
||||||
|
|
||||||
headers.push({ name, value });
|
return { name: normalizedName, value };
|
||||||
}
|
})
|
||||||
|
.filter((header) => header.name.length > 0); // Filter out headers with invalid names
|
||||||
return { headers, error: null };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update internal value when external value changes
|
// Update internal value when external value changes
|
||||||
// But only if the user is not currently editing (textarea not focused)
|
// But only if the user is not currently editing (textarea not focused)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isUserEditingRef.current) {
|
if (!isUserEditingRef.current) {
|
||||||
setInternalValue(convertToNewlineSeparated(value ?? []));
|
setInternalValue(convertToNewlineSeparated(value));
|
||||||
setError(null);
|
|
||||||
onValidityChange?.(true);
|
|
||||||
}
|
}
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
@@ -106,20 +75,31 @@ X-Another-Header: another-value`,
|
|||||||
// Mark that user is actively editing
|
// Mark that user is actively editing
|
||||||
isUserEditingRef.current = true;
|
isUserEditingRef.current = true;
|
||||||
|
|
||||||
const result = parseHeaders(newValue);
|
// Only update parent if the input is in a valid state
|
||||||
|
// Valid states: empty/whitespace only, or contains properly formatted headers
|
||||||
|
|
||||||
if (result.error) {
|
if (newValue.trim() === "") {
|
||||||
// Surface the error and do not touch the last known good value.
|
// Empty input is valid - represents no headers
|
||||||
// Silently dropping the update here (without telling the user)
|
onChange([]);
|
||||||
// is what previously let stale data get saved without warning.
|
} else {
|
||||||
setError(result.error);
|
// Check if all non-empty lines are properly formatted (contain ':')
|
||||||
onValidityChange?.(false);
|
const lines = newValue.split("\n");
|
||||||
return;
|
const nonEmptyLines = lines
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0);
|
||||||
|
|
||||||
|
// If there are no non-empty lines, or all non-empty lines contain ':', it's valid
|
||||||
|
const isValid =
|
||||||
|
nonEmptyLines.length === 0 ||
|
||||||
|
nonEmptyLines.every((line) => line.includes(":"));
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
// Safe to convert and update parent
|
||||||
|
const headersArray = convertToHeadersArray(newValue);
|
||||||
|
onChange(headersArray);
|
||||||
|
}
|
||||||
|
// If not valid, don't call onChange - let user continue typing
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(null);
|
|
||||||
onValidityChange?.(true);
|
|
||||||
onChange(result.headers);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFocus = () => {
|
const handleFocus = () => {
|
||||||
@@ -134,7 +114,6 @@ X-Another-Header: another-value`,
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
|
||||||
<Textarea
|
<Textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={internalValue}
|
value={internalValue}
|
||||||
@@ -145,9 +124,5 @@ X-Another-Header: another-value`,
|
|||||||
rows={rows}
|
rows={rows}
|
||||||
className={className}
|
className={className}
|
||||||
/>
|
/>
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-destructive mt-1.5">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,25 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { generateOidcUrlProxy } from "@app/actions/server";
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
||||||
import {
|
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
generateOidcUrlProxy,
|
import { Button } from "@app/components/ui/button";
|
||||||
type GenerateOidcUrlResponse
|
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||||
} from "@app/actions/server";
|
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
|
||||||
|
import { setClientCookie } from "@app/lib/setClientCookie";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
import {
|
||||||
redirect as redirectTo,
|
redirect as redirectTo,
|
||||||
useParams,
|
useRouter,
|
||||||
useSearchParams
|
useSearchParams
|
||||||
} from "next/navigation";
|
} from "next/navigation";
|
||||||
import { useRouter } from "next/navigation";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
|
||||||
|
|
||||||
export type LoginFormIDP = {
|
export type LoginFormIDP = {
|
||||||
idpId: number;
|
idpId: number;
|
||||||
name: string;
|
name: string;
|
||||||
variant?: string;
|
variant?: string;
|
||||||
|
lastUsed?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type IdpLoginButtonsProps = {
|
type IdpLoginButtonsProps = {
|
||||||
@@ -35,7 +34,6 @@ export default function IdpLoginButtons({
|
|||||||
orgId
|
orgId
|
||||||
}: IdpLoginButtonsProps) {
|
}: IdpLoginButtonsProps) {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
@@ -52,10 +50,22 @@ export default function IdpLoginButtons({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const [loading, startTransition] = useTransition();
|
||||||
|
|
||||||
async function loginWithIdp(idpId: number) {
|
async function loginWithIdp(idpId: number) {
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
setClientCookie(
|
||||||
|
LAST_USED_IDP_COOKIE_NAME,
|
||||||
|
JSON.stringify({
|
||||||
|
orgId,
|
||||||
|
idpId
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
sameSite: "Lax"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
let redirectToUrl: string | undefined;
|
let redirectToUrl: string | undefined;
|
||||||
try {
|
try {
|
||||||
console.log("generating", idpId, redirect || "/", orgId);
|
console.log("generating", idpId, redirect || "/", orgId);
|
||||||
@@ -68,7 +78,6 @@ export default function IdpLoginButtons({
|
|||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
setError(response.message);
|
setError(response.message);
|
||||||
setLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +93,6 @@ export default function IdpLoginButtons({
|
|||||||
"An unexpected error occurred. Please try again."
|
"An unexpected error occurred. Please try again."
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (redirectToUrl) {
|
if (redirectToUrl) {
|
||||||
@@ -124,20 +132,38 @@ export default function IdpLoginButtons({
|
|||||||
idp.variant || idp.name.toLowerCase();
|
idp.variant || idp.name.toLowerCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full relative"
|
||||||
|
key={idp.idpId}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
key={idp.idpId}
|
key={idp.idpId}
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full inline-flex items-center space-x-2"
|
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
loginWithIdp(idp.idpId);
|
startTransition(() =>
|
||||||
|
loginWithIdp(idp.idpId)
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
>
|
>
|
||||||
<IdpTypeIcon type={effectiveType} size={16} />
|
<IdpTypeIcon
|
||||||
|
type={effectiveType}
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
<span>{idp.name}</span>
|
<span>{idp.name}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{idp.lastUsed && (
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<span className="absolute top-0 right-0 text-xs bg-primary text-primary-foreground rounded-bl-sm rounded-tr-sm px-2 py-0.5">
|
||||||
|
{t("idpLastUsed")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -30,10 +30,7 @@ import Link from "next/link";
|
|||||||
import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
||||||
import { Separator } from "./ui/separator";
|
import { Separator } from "./ui/separator";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
import { generateOidcUrlProxy, loginProxy } from "@app/actions/server";
|
||||||
generateOidcUrlProxy,
|
|
||||||
loginProxy
|
|
||||||
} from "@app/actions/server";
|
|
||||||
import { redirect as redirectTo } from "next/navigation";
|
import { redirect as redirectTo } from "next/navigation";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
||||||
@@ -41,11 +38,13 @@ import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
|||||||
import { loadReoScript } from "reodotdev";
|
import { loadReoScript } from "reodotdev";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import MfaInputForm from "@app/components/MfaInputForm";
|
import MfaInputForm from "@app/components/MfaInputForm";
|
||||||
|
import { useLocalStorage } from "@app/hooks/useLocalStorage";
|
||||||
|
|
||||||
export type LoginFormIDP = {
|
export type LoginFormIDP = {
|
||||||
idpId: number;
|
idpId: number;
|
||||||
name: string;
|
name: string;
|
||||||
variant?: string;
|
variant?: string;
|
||||||
|
lastUsed?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LoginFormProps = {
|
type LoginFormProps = {
|
||||||
@@ -105,7 +104,6 @@ export default function LoginForm({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
email: z.string().email({ message: t("emailInvalid") }),
|
email: z.string().email({ message: t("emailInvalid") }),
|
||||||
password: z.string().min(8, { message: t("passwordRequirementsChars") })
|
password: z.string().min(8, { message: t("passwordRequirementsChars") })
|
||||||
@@ -130,6 +128,10 @@ export default function LoginForm({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [lastUsedIdpId, setLastUsedIdpId] = useLocalStorage<string | null>(
|
||||||
|
"login:last-used-idp",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
async function onSubmit(values: any) {
|
async function onSubmit(values: any) {
|
||||||
const { email, password } = form.getValues();
|
const { email, password } = form.getValues();
|
||||||
@@ -179,8 +181,7 @@ export default function LoginForm({
|
|||||||
if (data.useSecurityKey) {
|
if (data.useSecurityKey) {
|
||||||
setError(
|
setError(
|
||||||
t("securityKeyRequired", {
|
t("securityKeyRequired", {
|
||||||
defaultValue:
|
defaultValue: "Please use your security key to sign in."
|
||||||
"Please use your security key to sign in."
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -242,6 +243,8 @@ export default function LoginForm({
|
|||||||
|
|
||||||
async function loginWithIdp(idpId: number) {
|
async function loginWithIdp(idpId: number) {
|
||||||
let redirectUrl: string | undefined;
|
let redirectUrl: string | undefined;
|
||||||
|
|
||||||
|
setLastUsedIdpId(idpId.toString());
|
||||||
try {
|
try {
|
||||||
const data = await generateOidcUrlProxy(
|
const data = await generateOidcUrlProxy(
|
||||||
idpId,
|
idpId,
|
||||||
@@ -356,7 +359,6 @@ export default function LoginForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
||||||
{!mfaRequested && (
|
{!mfaRequested && (
|
||||||
<>
|
<>
|
||||||
<SecurityKeyAuthButton
|
<SecurityKeyAuthButton
|
||||||
@@ -385,25 +387,41 @@ export default function LoginForm({
|
|||||||
idp.variant || idp.name.toLowerCase();
|
idp.variant || idp.name.toLowerCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full relative"
|
||||||
|
key={idp.idpId}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
key={idp.idpId}
|
key={idp.idpId}
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full inline-flex items-center space-x-2"
|
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
loginWithIdp(idp.idpId);
|
loginWithIdp(idp.idpId);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IdpTypeIcon type={effectiveType} size={16} />
|
<IdpTypeIcon
|
||||||
|
type={effectiveType}
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
<span>{idp.name}</span>
|
<span>{idp.name}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{lastUsedIdpId ===
|
||||||
|
idp.idpId.toString() && (
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<span className="absolute top-0 right-0 text-xs bg-primary text-primary-foreground rounded-bl-sm rounded-tr-sm px-2 py-0.5">
|
||||||
|
{t("idpLastUsed")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import UserProfileCard from "@app/components/UserProfileCard";
|
|||||||
import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
|
import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
|
||||||
import { Separator } from "@app/components/ui/separator";
|
import { Separator } from "@app/components/ui/separator";
|
||||||
import OrgSignInLink from "@app/components/OrgSignInLink";
|
import OrgSignInLink from "@app/components/OrgSignInLink";
|
||||||
|
import type { LoginFormIDP } from "./LoginForm";
|
||||||
|
import IdpLoginButtons from "./IdpLoginButtons";
|
||||||
|
|
||||||
const identifierSchema = z.object({
|
const identifierSchema = z.object({
|
||||||
identifier: z.string().min(1, "Username or email is required")
|
identifier: z.string().min(1, "Username or email is required")
|
||||||
@@ -53,6 +55,7 @@ type SmartLoginFormProps = {
|
|||||||
forceLogin?: boolean;
|
forceLogin?: boolean;
|
||||||
defaultUser?: string;
|
defaultUser?: string;
|
||||||
orgSignIn?: OrgSignInConfig;
|
orgSignIn?: OrgSignInConfig;
|
||||||
|
lastUsedIdp?: (LoginFormIDP & { orgId?: string }) | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ViewState =
|
type ViewState =
|
||||||
@@ -89,7 +92,8 @@ export default function SmartLoginForm({
|
|||||||
redirect,
|
redirect,
|
||||||
forceLogin,
|
forceLogin,
|
||||||
defaultUser,
|
defaultUser,
|
||||||
orgSignIn
|
orgSignIn,
|
||||||
|
lastUsedIdp
|
||||||
}: SmartLoginFormProps) {
|
}: SmartLoginFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -294,6 +298,15 @@ export default function SmartLoginForm({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{lastUsedIdp && (
|
||||||
|
<IdpLoginButtons
|
||||||
|
idps={[lastUsedIdp]}
|
||||||
|
orgId={lastUsedIdp.orgId}
|
||||||
|
redirect={redirect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<OrgSignInLink
|
<OrgSignInLink
|
||||||
href={orgSignIn.href}
|
href={orgSignIn.href}
|
||||||
linkText={orgSignIn.linkText}
|
linkText={orgSignIn.linkText}
|
||||||
|
|||||||
1
src/lib/consts.ts
Normal file
1
src/lib/consts.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const LAST_USED_IDP_COOKIE_NAME = "p__last_used_idp";
|
||||||
32
src/lib/setClientCookie.ts
Normal file
32
src/lib/setClientCookie.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Set a cookie on the client side in javascript code, not on the server
|
||||||
|
* @param name
|
||||||
|
* @param value
|
||||||
|
* @param days
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
export function setClientCookie(
|
||||||
|
name: string,
|
||||||
|
value: string,
|
||||||
|
options: {
|
||||||
|
days?: number;
|
||||||
|
path?: string;
|
||||||
|
secure?: boolean;
|
||||||
|
sameSite?: "Strict" | "Lax" | "None";
|
||||||
|
} = {}
|
||||||
|
): void {
|
||||||
|
let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
|
||||||
|
|
||||||
|
if (options.days) {
|
||||||
|
const date = new Date();
|
||||||
|
date.setTime(date.getTime() + options.days * 864e5);
|
||||||
|
cookie += `; expires=${date.toUTCString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie += `; path=${options.path ?? "/"}`;
|
||||||
|
|
||||||
|
if (options.secure) cookie += "; Secure";
|
||||||
|
if (options.sameSite) cookie += `; SameSite=${options.sameSite}`;
|
||||||
|
|
||||||
|
document.cookie = cookie;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user