From 715b957660716f5d3af1a096ecee71060539b61e Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 22 May 2026 11:19:35 -0700 Subject: [PATCH] Support not push ssh method --- server/db/pg/schema/schema.ts | 5 +- server/db/sqlite/schema/schema.ts | 5 +- server/private/routers/ssh/signSshKey.ts | 545 +++++++++++++---------- 3 files changed, 327 insertions(+), 228 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 5f52b7e39..fd291c512 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -352,8 +352,11 @@ export const siteResources = pgTable("siteResources", { udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"), disableIcmp: boolean("disableIcmp").notNull().default(false), authDaemonPort: integer("authDaemonPort").default(22123), + pamMode: varchar("pamMode", { length: 32 }) + .$type<"passthrough" | "push">() + .default("passthrough"), authDaemonMode: varchar("authDaemonMode", { length: 32 }) - .$type<"site" | "remote">() + .$type<"site" | "remote" | "native">() .default("site"), domainId: varchar("domainId").references(() => domains.domainId, { onDelete: "set null" diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 307806b3e..113f4c443 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -387,8 +387,11 @@ export const siteResources = sqliteTable("siteResources", { .notNull() .default(false), authDaemonPort: integer("authDaemonPort").default(22123), + pamMode: text("pamMode") + .$type<"passthrough" | "push">() + .default("passthrough"), authDaemonMode: text("authDaemonMode") - .$type<"site" | "remote">() + .$type<"site" | "remote" | "native">() .default("site"), domainId: text("domainId").references(() => domains.domainId, { onDelete: "set null" diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index 1cbb31657..61232b51f 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -23,7 +23,8 @@ import { roundTripMessageTracker, siteResources, siteNetworks, - userOrgs + userOrgs, + sites } from "@server/db"; import { logAccessAudit } from "#private/lib/logAccessAudit"; import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed"; @@ -48,7 +49,8 @@ const bodySchema = z .strictObject({ publicKey: z.string().nonempty(), resourceId: z.number().int().positive().optional(), - resource: z.string().nonempty().optional() // this is either the nice id or the alias + resource: z.string().nonempty().optional(), // this is either the nice id or the alias + username: z.string().nonempty().optional() }) .refine( (data) => { @@ -63,19 +65,19 @@ const bodySchema = z ); export type SignSshKeyResponse = { - certificate: string; + certificate?: string; messageIds: number[]; - messageId: number; + messageId?: number; sshUsername: string; sshHost: string; resourceId: number; siteIds: number[]; siteId: number; - keyId: string; - validPrincipals: string[]; - validAfter: string; - validBefore: string; - expiresIn: number; + keyId?: string; + validPrincipals?: string[]; + validAfter?: string; + validBefore?: string; + expiresIn?: number; }; // registry.registerPath({ @@ -126,7 +128,8 @@ export async function signSshKey( const { publicKey, resourceId, - resource: resourceQueryString + resource: resourceQueryString, + username } = parsedBody.data; const userId = req.user?.userId; const roleIds = req.userOrgRoleIds ?? []; @@ -174,101 +177,6 @@ export async function signSshKey( ); } - let usernameToUse; - if (!userOrg.pamUsername) { - if (req.user?.email) { - // Extract username from email (first part before @) - usernameToUse = req.user?.email - .split("@")[0] - .replace(/[^a-zA-Z0-9_-]/g, ""); - if (!usernameToUse) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Unable to extract username from email" - ) - ); - } - } else if (req.user?.username) { - usernameToUse = req.user.username; - // We need to clean out any spaces or special characters from the username to ensure it's valid for SSH certificates - usernameToUse = usernameToUse.replace(/[^a-zA-Z0-9_-]/g, "-"); - if (!usernameToUse) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Username is not valid for SSH certificate" - ) - ); - } - } else { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "User does not have a valid email or username for SSH certificate" - ) - ); - } - - // prefix with p- - usernameToUse = `p-${usernameToUse}`; - - // check if we have a existing user in this org with the same - const [existingUserWithSameName] = await db - .select() - .from(userOrgs) - .where( - and( - eq(userOrgs.orgId, orgId), - eq(userOrgs.pamUsername, usernameToUse) - ) - ) - .limit(1); - - if (existingUserWithSameName) { - let foundUniqueUsername = false; - for (let attempt = 0; attempt < 20; attempt++) { - const randomNum = Math.floor(Math.random() * 101); // 0 to 100 - const candidateUsername = `${usernameToUse}${randomNum}`; - - const [existingUser] = await db - .select() - .from(userOrgs) - .where( - and( - eq(userOrgs.orgId, orgId), - eq(userOrgs.pamUsername, candidateUsername) - ) - ) - .limit(1); - - if (!existingUser) { - usernameToUse = candidateUsername; - foundUniqueUsername = true; - break; - } - } - - if (!foundUniqueUsername) { - return next( - createHttpError( - HttpCode.CONFLICT, - "Unable to generate a unique username for SSH certificate" - ) - ); - } - } - - await db - .update(userOrgs) - .set({ pamUsername: usernameToUse }) - .where( - and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId)) - ); - } else { - usernameToUse = userOrg.pamUsername; - } - // Get and decrypt the org's CA keys const caKeys = await getOrgCAKeys( orgId, @@ -361,90 +269,303 @@ export async function signSshKey( ); } - const roleRows = await db - .select({ - sshSudoCommands: roles.sshSudoCommands, - sshUnixGroups: roles.sshUnixGroups, - sshCreateHomeDir: roles.sshCreateHomeDir, - sshSudoMode: roles.sshSudoMode - }) - .from(roles) - .innerJoin( - roleSiteResources, - eq(roleSiteResources.roleId, roles.roleId) - ) - .where( - and( - inArray(roles.roleId, roleIds), - eq( - roleSiteResources.siteResourceId, - resource.siteResourceId - ) - ) - ); - - const parsedSudoCommands: string[] = []; - const parsedGroupsSet = new Set(); - let homedir: boolean | null = null; - const sudoModeOrder = { none: 0, commands: 1, full: 2 }; - let sudoMode: "none" | "commands" | "full" = "none"; - for (const roleRow of roleRows) { - try { - const cmds = JSON.parse(roleRow?.sshSudoCommands ?? "[]"); - if (Array.isArray(cmds)) parsedSudoCommands.push(...cmds); - } catch { - // skip - } - try { - const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]"); - if (Array.isArray(grps)) - grps.forEach((g: string) => parsedGroupsSet.add(g)); - } catch { - // skip - } - if (roleRow?.sshCreateHomeDir === true) homedir = true; - const m = roleRow?.sshSudoMode ?? "none"; - if ( - sudoModeOrder[m as keyof typeof sudoModeOrder] > - sudoModeOrder[sudoMode] - ) { - sudoMode = m as "none" | "commands" | "full"; - } - } - const parsedGroups = Array.from(parsedGroupsSet); - if (homedir === null && roleRows.length > 0) { - homedir = roleRows[0].sshCreateHomeDir ?? null; - } - - const sites = await db + const sitesFromNetworks = await db .select({ siteId: siteNetworks.siteId }) .from(siteNetworks) .where(eq(siteNetworks.networkId, resource.networkId!)); - const siteIds = sites.map((site) => site.siteId); + const siteIds = sitesFromNetworks.map((site) => site.siteId); - // Sign the public key - const now = BigInt(Math.floor(Date.now() / 1000)); - // only valid for 5 minutes - const validFor = 300n; + let expiresIn: number | undefined; + let messageIds: number[] = []; + let cert: + | { + certificate: string; + keyId: string; + validPrincipals: string[]; + validAfter: Date; + validBefore: Date; + } + | undefined; + // if the pam mode is push then we generate the user's pam username and use that or pull it from the userOrgs table + // if the mode is passthrough then just use what was provided because the user will log in themselves + let usernameToUse; + if (resource.pamMode === "push") { + if (!userOrg.pamUsername) { + if (req.user?.email) { + // Extract username from email (first part before @) + usernameToUse = req.user?.email + .split("@")[0] + .replace(/[^a-zA-Z0-9_-]/g, ""); + if (!usernameToUse) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Unable to extract username from email" + ) + ); + } + } else if (req.user?.username) { + usernameToUse = req.user.username; + // We need to clean out any spaces or special characters from the username to ensure it's valid for SSH certificates + usernameToUse = usernameToUse.replace( + /[^a-zA-Z0-9_-]/g, + "-" + ); + if (!usernameToUse) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Username is not valid for SSH certificate" + ) + ); + } + } else { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User does not have a valid email or username for SSH certificate" + ) + ); + } - const cert = signPublicKey(caKeys.privateKeyPem, publicKey, { - keyId: `${usernameToUse}@${resource.niceId}`, - validPrincipals: [usernameToUse, resource.niceId], - validAfter: now - 60n, // Start 1 min ago for clock skew - validBefore: now + validFor - }); + // prefix with p- + usernameToUse = `p-${usernameToUse}`; + + // check if we have a existing user in this org with the same + const [existingUserWithSameName] = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.orgId, orgId), + eq(userOrgs.pamUsername, usernameToUse) + ) + ) + .limit(1); + + if (existingUserWithSameName) { + let foundUniqueUsername = false; + for (let attempt = 0; attempt < 20; attempt++) { + const randomNum = Math.floor(Math.random() * 101); // 0 to 100 + const candidateUsername = `${usernameToUse}${randomNum}`; + + const [existingUser] = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.orgId, orgId), + eq(userOrgs.pamUsername, candidateUsername) + ) + ) + .limit(1); + + if (!existingUser) { + usernameToUse = candidateUsername; + foundUniqueUsername = true; + break; + } + } + + if (!foundUniqueUsername) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Unable to generate a unique username for SSH certificate" + ) + ); + } + } + + await db + .update(userOrgs) + .set({ pamUsername: usernameToUse }) + .where( + and( + eq(userOrgs.orgId, orgId), + eq(userOrgs.userId, userId) + ) + ); + } else { + usernameToUse = userOrg.pamUsername; + } + + const roleRows = await db + .select({ + sshSudoCommands: roles.sshSudoCommands, + sshUnixGroups: roles.sshUnixGroups, + sshCreateHomeDir: roles.sshCreateHomeDir, + sshSudoMode: roles.sshSudoMode + }) + .from(roles) + .innerJoin( + roleSiteResources, + eq(roleSiteResources.roleId, roles.roleId) + ) + .where( + and( + inArray(roles.roleId, roleIds), + eq( + roleSiteResources.siteResourceId, + resource.siteResourceId + ) + ) + ); + + const parsedSudoCommands: string[] = []; + const parsedGroupsSet = new Set(); + let homedir: boolean | null = null; + const sudoModeOrder = { none: 0, commands: 1, full: 2 }; + let sudoMode: "none" | "commands" | "full" = "none"; + for (const roleRow of roleRows) { + try { + const cmds = JSON.parse(roleRow?.sshSudoCommands ?? "[]"); + if (Array.isArray(cmds)) parsedSudoCommands.push(...cmds); + } catch { + // skip + } + try { + const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]"); + if (Array.isArray(grps)) + grps.forEach((g: string) => parsedGroupsSet.add(g)); + } catch { + // skip + } + if (roleRow?.sshCreateHomeDir === true) homedir = true; + const m = roleRow?.sshSudoMode ?? "none"; + if ( + sudoModeOrder[m as keyof typeof sudoModeOrder] > + sudoModeOrder[sudoMode] + ) { + sudoMode = m as "none" | "commands" | "full"; + } + } + const parsedGroups = Array.from(parsedGroupsSet); + if (homedir === null && roleRows.length > 0) { + homedir = roleRows[0].sshCreateHomeDir ?? null; + } + + // Sign the public key + const now = BigInt(Math.floor(Date.now() / 1000)); + // only valid for 5 minutes + const validFor = 300n; + expiresIn = Number(validFor); // seconds + + const cert = signPublicKey(caKeys.privateKeyPem, publicKey, { + keyId: `${usernameToUse}@${resource.niceId}`, + validPrincipals: [usernameToUse, resource.niceId], + validAfter: now - 60n, // Start 1 min ago for clock skew + validBefore: now + validFor + }); + + 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 (!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, + authDaemonMode: resource.authDaemonMode, // site, remote, native where native is the pty mode + externalAuthDaemon: + resource.authDaemonMode === "remote", // keep this for backward compatibility but new newts are using the authDaemonMode field + agentHost: resource.destination, + caCert: caKeys.publicKeyOpenSSH, + username: usernameToUse, + niceId: resource.niceId, + metadata: { + sudoMode: sudoMode, + sudoCommands: parsedSudoCommands, + homedir: homedir, + groups: parsedGroups + } + } + }); + } + } else if (resource.pamMode === "passthrough") { + usernameToUse = username; + if (!usernameToUse) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Username must be provided when PAM mode is passthrough" + ) + ); + } + } else { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Invalid PAM mode configured for resource" + ) + ); + } + + let sshHost: string | undefined; + if ( + resource.authDaemonMode === "site" || + resource.authDaemonMode === "remote" + ) { + if (resource.alias && resource.alias != "") { + sshHost = resource.alias; + } else { + sshHost = resource.destination; + } + } else if (resource.authDaemonMode === "native") { + if (siteIds.length > 1) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Multiple sites associated with resource, unable to determine SSH host when in native mode" + ) + ); + } - const messageIds: number[] = []; - for (const siteId of siteIds) { // get the site - const [newt] = await db + const [site] = await db .select() - .from(newts) - .where(eq(newts.siteId, siteId)) + .from(sites) + .where(eq(sites.siteId, siteIds[0])) .limit(1); - if (!newt) { + if (!site) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, @@ -453,54 +574,26 @@ export async function signSshKey( ); } - const [message] = await db - .insert(roundTripMessageTracker) - .values({ - wsClientId: newt.newtId, - messageType: `newt/pam/connection`, - sentAt: Math.floor(Date.now() / 1000) - }) - .returning(); - - if (!message) { + if (!site.address) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - "Failed to create message tracker entry" + "Site address not configured, unable to determine SSH host when in native mode" ) ); } - 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 - } - } - }); + // its the address but split off the cidr if there is one + sshHost = site.address.split("/")[0]; } - const expiresIn = Number(validFor); // seconds - - let sshHost; - if (resource.alias && resource.alias != "") { - sshHost = resource.alias; - } else { - sshHost = resource.destination; + if (!sshHost) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Unable to determine SSH host for the resource" + ) + ); } await logsDb.insert(actionAuditLog).values({ @@ -527,7 +620,7 @@ export async function signSshKey( : undefined, metadata: { resourceName: resource.name, - siteId: siteIds[0], + siteIds: siteIds, sshUsername: usernameToUse, sshHost: sshHost }, @@ -537,18 +630,18 @@ export async function signSshKey( return response(res, { data: { - certificate: cert.certificate, + certificate: cert?.certificate, messageIds: messageIds, - messageId: messageIds[0], // just pick the first one for backward compatibility + messageId: messageIds[0], // just pick the first one for backward compatibility with older olms sshUsername: usernameToUse, - sshHost: sshHost, + sshHost: sshHost, // just pick the first one for backward compatibility with older olms resourceId: resource.siteResourceId, siteIds: siteIds, - siteId: siteIds[0], // just pick the first one for backward compatibility - keyId: cert.keyId, - validPrincipals: cert.validPrincipals, - validAfter: cert.validAfter.toISOString(), - validBefore: cert.validBefore.toISOString(), + siteId: siteIds[0], // just pick the first one for backward compatibility with older olms + keyId: cert?.keyId, + validPrincipals: cert?.validPrincipals, + validAfter: cert?.validAfter.toISOString(), + validBefore: cert?.validBefore.toISOString(), expiresIn }, success: true,