Support not push ssh method

This commit is contained in:
Owen
2026-05-22 11:19:35 -07:00
parent f1e4bf8d36
commit 715b957660
3 changed files with 327 additions and 228 deletions

View File

@@ -352,8 +352,11 @@ export const siteResources = pgTable("siteResources", {
udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"), udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"),
disableIcmp: boolean("disableIcmp").notNull().default(false), disableIcmp: boolean("disableIcmp").notNull().default(false),
authDaemonPort: integer("authDaemonPort").default(22123), authDaemonPort: integer("authDaemonPort").default(22123),
pamMode: varchar("pamMode", { length: 32 })
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: varchar("authDaemonMode", { length: 32 }) authDaemonMode: varchar("authDaemonMode", { length: 32 })
.$type<"site" | "remote">() .$type<"site" | "remote" | "native">()
.default("site"), .default("site"),
domainId: varchar("domainId").references(() => domains.domainId, { domainId: varchar("domainId").references(() => domains.domainId, {
onDelete: "set null" onDelete: "set null"

View File

@@ -387,8 +387,11 @@ export const siteResources = sqliteTable("siteResources", {
.notNull() .notNull()
.default(false), .default(false),
authDaemonPort: integer("authDaemonPort").default(22123), authDaemonPort: integer("authDaemonPort").default(22123),
pamMode: text("pamMode")
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: text("authDaemonMode") authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote">() .$type<"site" | "remote" | "native">()
.default("site"), .default("site"),
domainId: text("domainId").references(() => domains.domainId, { domainId: text("domainId").references(() => domains.domainId, {
onDelete: "set null" onDelete: "set null"

View File

@@ -23,7 +23,8 @@ import {
roundTripMessageTracker, roundTripMessageTracker,
siteResources, siteResources,
siteNetworks, siteNetworks,
userOrgs userOrgs,
sites
} from "@server/db"; } from "@server/db";
import { logAccessAudit } from "#private/lib/logAccessAudit"; import { logAccessAudit } from "#private/lib/logAccessAudit";
import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
@@ -48,7 +49,8 @@ const bodySchema = z
.strictObject({ .strictObject({
publicKey: z.string().nonempty(), publicKey: z.string().nonempty(),
resourceId: z.number().int().positive().optional(), 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( .refine(
(data) => { (data) => {
@@ -63,19 +65,19 @@ const bodySchema = z
); );
export type SignSshKeyResponse = { export type SignSshKeyResponse = {
certificate: string; certificate?: string;
messageIds: number[]; messageIds: number[];
messageId: number; messageId?: number;
sshUsername: string; sshUsername: string;
sshHost: string; sshHost: string;
resourceId: number; resourceId: number;
siteIds: number[]; siteIds: number[];
siteId: number; siteId: number;
keyId: string; keyId?: string;
validPrincipals: string[]; validPrincipals?: string[];
validAfter: string; validAfter?: string;
validBefore: string; validBefore?: string;
expiresIn: number; expiresIn?: number;
}; };
// registry.registerPath({ // registry.registerPath({
@@ -126,7 +128,8 @@ export async function signSshKey(
const { const {
publicKey, publicKey,
resourceId, resourceId,
resource: resourceQueryString resource: resourceQueryString,
username
} = parsedBody.data; } = parsedBody.data;
const userId = req.user?.userId; const userId = req.user?.userId;
const roleIds = req.userOrgRoleIds ?? []; 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 // Get and decrypt the org's CA keys
const caKeys = await getOrgCAKeys( const caKeys = await getOrgCAKeys(
orgId, orgId,
@@ -361,90 +269,303 @@ export async function signSshKey(
); );
} }
const roleRows = await db const sitesFromNetworks = 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<string>();
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
.select({ siteId: siteNetworks.siteId }) .select({ siteId: siteNetworks.siteId })
.from(siteNetworks) .from(siteNetworks)
.where(eq(siteNetworks.networkId, resource.networkId!)); .where(eq(siteNetworks.networkId, resource.networkId!));
const siteIds = sites.map((site) => site.siteId); const siteIds = sitesFromNetworks.map((site) => site.siteId);
// Sign the public key let expiresIn: number | undefined;
const now = BigInt(Math.floor(Date.now() / 1000)); let messageIds: number[] = [];
// only valid for 5 minutes let cert:
const validFor = 300n; | {
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, { // prefix with p-
keyId: `${usernameToUse}@${resource.niceId}`, usernameToUse = `p-${usernameToUse}`;
validPrincipals: [usernameToUse, resource.niceId],
validAfter: now - 60n, // Start 1 min ago for clock skew // check if we have a existing user in this org with the same
validBefore: now + validFor 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<string>();
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 // get the site
const [newt] = await db const [site] = await db
.select() .select()
.from(newts) .from(sites)
.where(eq(newts.siteId, siteId)) .where(eq(sites.siteId, siteIds[0]))
.limit(1); .limit(1);
if (!newt) { if (!site) {
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,
@@ -453,54 +574,26 @@ export async function signSshKey(
); );
} }
const [message] = await db if (!site.address) {
.insert(roundTripMessageTracker)
.values({
wsClientId: newt.newtId,
messageType: `newt/pam/connection`,
sentAt: Math.floor(Date.now() / 1000)
})
.returning();
if (!message) {
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, 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); // its the address but split off the cidr if there is one
sshHost = site.address.split("/")[0];
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 if (!sshHost) {
return next(
let sshHost; createHttpError(
if (resource.alias && resource.alias != "") { HttpCode.INTERNAL_SERVER_ERROR,
sshHost = resource.alias; "Unable to determine SSH host for the resource"
} else { )
sshHost = resource.destination; );
} }
await logsDb.insert(actionAuditLog).values({ await logsDb.insert(actionAuditLog).values({
@@ -527,7 +620,7 @@ export async function signSshKey(
: undefined, : undefined,
metadata: { metadata: {
resourceName: resource.name, resourceName: resource.name,
siteId: siteIds[0], siteIds: siteIds,
sshUsername: usernameToUse, sshUsername: usernameToUse,
sshHost: sshHost sshHost: sshHost
}, },
@@ -537,18 +630,18 @@ export async function signSshKey(
return response<SignSshKeyResponse>(res, { return response<SignSshKeyResponse>(res, {
data: { data: {
certificate: cert.certificate, certificate: cert?.certificate,
messageIds: messageIds, 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, sshUsername: usernameToUse,
sshHost: sshHost, sshHost: sshHost, // just pick the first one for backward compatibility with older olms
resourceId: resource.siteResourceId, resourceId: resource.siteResourceId,
siteIds: siteIds, siteIds: siteIds,
siteId: siteIds[0], // just pick the first one for backward compatibility siteId: siteIds[0], // just pick the first one for backward compatibility with older olms
keyId: cert.keyId, keyId: cert?.keyId,
validPrincipals: cert.validPrincipals, validPrincipals: cert?.validPrincipals,
validAfter: cert.validAfter.toISOString(), validAfter: cert?.validAfter.toISOString(),
validBefore: cert.validBefore.toISOString(), validBefore: cert?.validBefore.toISOString(),
expiresIn expiresIn
}, },
success: true, success: true,