Merge branch 'private-site-ha' into private-http-ha

This commit is contained in:
Owen
2026-04-13 10:25:49 -07:00
39 changed files with 1412 additions and 825 deletions

View File

@@ -20,6 +20,7 @@ import {
db,
domains,
newts,
siteNetworks,
SiteResource,
siteResources
} from "@server/db";
@@ -91,16 +92,17 @@ async function pushCertUpdateToAffectedNewts(
for (const resource of affectedResources) {
try {
// Get the newt for this site
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, resource.siteId))
.limit(1);
// Get all sites for this resource via siteNetworks
const resourceSiteRows = resource.networkId
? await db
.select({ siteId: siteNetworks.siteId })
.from(siteNetworks)
.where(eq(siteNetworks.networkId, resource.networkId))
: [];
if (!newt) {
if (resourceSiteRows.length === 0) {
logger.debug(
`acmeCertSync: no newt found for site ${resource.siteId}, skipping resource ${resource.siteResourceId}`
`acmeCertSync: no sites for resource ${resource.siteResourceId}, skipping`
);
continue;
}
@@ -139,7 +141,7 @@ async function pushCertUpdateToAffectedNewts(
await cache.del(`cert:${resource.fullDomain}`);
}
// Generate the new target (will read the freshly updated cert from DB)
// Generate target once — same cert applies to all sites for this resource
const newTarget = await generateSubnetProxyTargetV2(
resource,
resourceClients
@@ -161,15 +163,31 @@ async function pushCertUpdateToAffectedNewts(
tlsKey: oldKeyPem ?? undefined
};
await updateTargets(
newt.newtId,
{ oldTargets: [oldTarget], newTargets: [newTarget] },
newt.version
);
// Push update to each site's newt
for (const { siteId } of resourceSiteRows) {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, siteId))
.limit(1);
logger.info(
`acmeCertSync: pushed cert update to newt for site ${resource.siteId}, resource ${resource.siteResourceId}`
);
if (!newt) {
logger.debug(
`acmeCertSync: no newt found for site ${siteId}, skipping resource ${resource.siteResourceId}`
);
continue;
}
await updateTargets(
newt.newtId,
{ oldTargets: [oldTarget], newTargets: [newTarget] },
newt.version
);
logger.info(
`acmeCertSync: pushed cert update to newt for site ${siteId}, resource ${resource.siteResourceId}`
);
}
} catch (err) {
logger.error(
`acmeCertSync: error pushing cert update for resource ${resource?.siteResourceId}: ${err}`

View File

@@ -33,7 +33,7 @@ import {
} from "drizzle-orm";
import logger from "@server/logger";
import config from "@server/lib/config";
import { orgs, resources, sites, siteResources, Target, targets } from "@server/db";
import { orgs, resources, sites, siteNetworks, siteResources, Target, targets } from "@server/db";
import {
sanitize,
encodePath,
@@ -275,7 +275,8 @@ export async function getTraefikConfig(
mode: siteResources.mode
})
.from(siteResources)
.innerJoin(sites, eq(sites.siteId, siteResources.siteId))
.innerJoin(siteNetworks, eq(siteResources.networkId, siteNetworks.networkId))
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.where(
and(
eq(siteResources.enabled, true),

View File

@@ -22,11 +22,15 @@ import { OpenAPITags, registry } from "@server/openApi";
import { db, domainNamespaces, resources } from "@server/db";
import { inArray } from "drizzle-orm";
import { CheckDomainAvailabilityResponse } from "@server/routers/domain/types";
import { build } from "@server/build";
import { isSubscribed } from "#private/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
const paramsSchema = z.strictObject({});
const querySchema = z.strictObject({
subdomain: z.string()
subdomain: z.string(),
// orgId: build === "saas" ? z.string() : z.string().optional() // Required for saas, optional otherwise
});
registry.registerPath({
@@ -58,6 +62,23 @@ export async function checkDomainNamespaceAvailability(
}
const { subdomain } = parsedQuery.data;
// if (
// build == "saas" &&
// !isSubscribed(orgId!, tierMatrix.domainNamespaces)
// ) {
// // return not available
// return response<CheckDomainAvailabilityResponse>(res, {
// data: {
// available: false,
// options: []
// },
// success: true,
// error: false,
// message: "Your current subscription does not support custom domain namespaces. Please upgrade to access this feature.",
// status: HttpCode.OK
// });
// }
const namespaces = await db.select().from(domainNamespaces);
let possibleDomains = namespaces.map((ns) => {
const desired = `${subdomain}.${ns.domainNamespaceId}`;

View File

@@ -22,6 +22,9 @@ import { eq, sql } from "drizzle-orm";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { isSubscribed } from "#private/lib/isSubscribed";
import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
const paramsSchema = z.strictObject({});
@@ -37,7 +40,8 @@ const querySchema = z.strictObject({
.optional()
.default("0")
.transform(Number)
.pipe(z.int().nonnegative())
.pipe(z.int().nonnegative()),
// orgId: build === "saas" ? z.string() : z.string().optional() // Required for saas, optional otherwise
});
async function query(limit: number, offset: number) {
@@ -99,6 +103,26 @@ export async function listDomainNamespaces(
);
}
// if (
// build == "saas" &&
// !isSubscribed(orgId!, tierMatrix.domainNamespaces)
// ) {
// return response<ListDomainNamespacesResponse>(res, {
// data: {
// domainNamespaces: [],
// pagination: {
// total: 0,
// limit,
// offset
// }
// },
// success: true,
// error: false,
// message: "No namespaces found. Your current subscription does not support custom domain namespaces. Please upgrade to access this feature.",
// status: HttpCode.OK
// });
// }
const domainNamespacesList = await query(limit, offset);
const [{ count }] = await db

View File

@@ -21,7 +21,7 @@ import {
roles,
roundTripMessageTracker,
siteResources,
sites,
siteNetworks,
userOrgs
} from "@server/db";
import { logAccessAudit } from "#private/lib/logAccessAudit";
@@ -63,10 +63,12 @@ const bodySchema = z
export type SignSshKeyResponse = {
certificate: string;
messageIds: number[];
messageId: number;
sshUsername: string;
sshHost: string;
resourceId: number;
siteIds: number[];
siteId: number;
keyId: string;
validPrincipals: string[];
@@ -260,10 +262,7 @@ export async function signSshKey(
.update(userOrgs)
.set({ pamUsername: usernameToUse })
.where(
and(
eq(userOrgs.orgId, orgId),
eq(userOrgs.userId, userId)
)
and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId))
);
} else {
usernameToUse = userOrg.pamUsername;
@@ -395,21 +394,12 @@ export async function signSshKey(
homedir = roleRows[0].sshCreateHomeDir ?? null;
}
// get the site
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, resource.siteId))
.limit(1);
const sites = await db
.select({ siteId: siteNetworks.siteId })
.from(siteNetworks)
.where(eq(siteNetworks.networkId, resource.networkId!));
if (!newt) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Site associated with resource not found"
)
);
}
const siteIds = sites.map((site) => site.siteId);
// Sign the public key
const now = BigInt(Math.floor(Date.now() / 1000));
@@ -423,43 +413,64 @@ export async function signSshKey(
validBefore: now + validFor
});
const [message] = await db
.insert(roundTripMessageTracker)
.values({
wsClientId: newt.newtId,
messageType: `newt/pam/connection`,
sentAt: Math.floor(Date.now() / 1000)
})
.returning();
const messageIds: number[] = [];
for (const siteId of siteIds) {
// get the site
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, siteId))
.limit(1);
if (!message) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create message tracker entry"
)
);
}
await sendToClient(newt.newtId, {
type: `newt/pam/connection`,
data: {
messageId: message.messageId,
orgId: orgId,
agentPort: resource.authDaemonPort ?? 22123,
externalAuthDaemon: resource.authDaemonMode === "remote",
agentHost: resource.destination,
caCert: caKeys.publicKeyOpenSSH,
username: usernameToUse,
niceId: resource.niceId,
metadata: {
sudoMode: sudoMode,
sudoCommands: parsedSudoCommands,
homedir: homedir,
groups: parsedGroups
}
if (!newt) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Site associated with resource not found"
)
);
}
});
const [message] = await db
.insert(roundTripMessageTracker)
.values({
wsClientId: newt.newtId,
messageType: `newt/pam/connection`,
sentAt: Math.floor(Date.now() / 1000)
})
.returning();
if (!message) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create message tracker entry"
)
);
}
messageIds.push(message.messageId);
await sendToClient(newt.newtId, {
type: `newt/pam/connection`,
data: {
messageId: message.messageId,
orgId: orgId,
agentPort: resource.authDaemonPort ?? 22123,
externalAuthDaemon: resource.authDaemonMode === "remote",
agentHost: resource.destination,
caCert: caKeys.publicKeyOpenSSH,
username: usernameToUse,
niceId: resource.niceId,
metadata: {
sudoMode: sudoMode,
sudoCommands: parsedSudoCommands,
homedir: homedir,
groups: parsedGroups
}
}
});
}
const expiresIn = Number(validFor); // seconds
@@ -480,7 +491,7 @@ export async function signSshKey(
metadata: JSON.stringify({
resourceId: resource.siteResourceId,
resource: resource.name,
siteId: resource.siteId,
siteIds: siteIds
})
});
@@ -494,7 +505,7 @@ export async function signSshKey(
: undefined,
metadata: {
resourceName: resource.name,
siteId: resource.siteId,
siteId: siteIds[0],
sshUsername: usernameToUse,
sshHost: sshHost
},
@@ -505,11 +516,13 @@ export async function signSshKey(
return response<SignSshKeyResponse>(res, {
data: {
certificate: cert.certificate,
messageId: message.messageId,
messageIds: messageIds,
messageId: messageIds[0], // just pick the first one for backward compatibility
sshUsername: usernameToUse,
sshHost: sshHost,
resourceId: resource.siteResourceId,
siteId: resource.siteId,
siteIds: siteIds,
siteId: siteIds[0], // just pick the first one for backward compatibility
keyId: cert.keyId,
validPrincipals: cert.validPrincipals,
validAfter: cert.validAfter.toISOString(),