Merge branch 'main' into logs-database

This commit is contained in:
Owen
2026-02-23 16:39:39 -08:00
84 changed files with 4253 additions and 3672 deletions

View File

@@ -14,6 +14,9 @@
import { config } from "@server/lib/config";
import logger from "@server/logger";
import { redis } from "#private/lib/redis";
import { v4 as uuidv4 } from "uuid";
const instanceId = uuidv4();
export class LockManager {
/**
@@ -33,7 +36,7 @@ export class LockManager {
}
const lockValue = `${
config.getRawConfig().gerbil.exit_node_name
instanceId
}:${Date.now()}`;
const redisKey = `lock:${lockKey}`;
@@ -52,7 +55,7 @@ export class LockManager {
if (result === "OK") {
logger.debug(
`Lock acquired: ${lockKey} by ${
config.getRawConfig().gerbil.exit_node_name
instanceId
}`
);
return true;
@@ -63,14 +66,14 @@ export class LockManager {
if (
existingValue &&
existingValue.startsWith(
`${config.getRawConfig().gerbil.exit_node_name}:`
`${instanceId}:`
)
) {
// Extend the lock TTL since it's the same worker
await redis.pexpire(redisKey, ttlMs);
logger.debug(
`Lock extended: ${lockKey} by ${
config.getRawConfig().gerbil.exit_node_name
instanceId
}`
);
return true;
@@ -116,7 +119,7 @@ export class LockManager {
local key = KEYS[1]
local worker_prefix = ARGV[1]
local current_value = redis.call('GET', key)
if current_value and string.find(current_value, worker_prefix, 1, true) == 1 then
return redis.call('DEL', key)
else
@@ -129,19 +132,19 @@ export class LockManager {
luaScript,
1,
redisKey,
`${config.getRawConfig().gerbil.exit_node_name}:`
`${instanceId}:`
)) as number;
if (result === 1) {
logger.debug(
`Lock released: ${lockKey} by ${
config.getRawConfig().gerbil.exit_node_name
instanceId
}`
);
} else {
logger.warn(
`Lock not released - not owned by worker: ${lockKey} by ${
config.getRawConfig().gerbil.exit_node_name
instanceId
}`
);
}
@@ -198,7 +201,7 @@ export class LockManager {
const ownedByMe =
exists &&
value!.startsWith(
`${config.getRawConfig().gerbil.exit_node_name}:`
`${instanceId}:`
);
const owner = exists ? value!.split(":")[0] : undefined;
@@ -233,7 +236,7 @@ export class LockManager {
local worker_prefix = ARGV[1]
local ttl = tonumber(ARGV[2])
local current_value = redis.call('GET', key)
if current_value and string.find(current_value, worker_prefix, 1, true) == 1 then
return redis.call('PEXPIRE', key, ttl)
else
@@ -246,14 +249,14 @@ export class LockManager {
luaScript,
1,
redisKey,
`${config.getRawConfig().gerbil.exit_node_name}:`,
`${instanceId}:`,
ttlMs.toString()
)) as number;
if (result === 1) {
logger.debug(
`Lock extended: ${lockKey} by ${
config.getRawConfig().gerbil.exit_node_name
instanceId
} for ${ttlMs}ms`
);
return true;
@@ -356,7 +359,7 @@ export class LockManager {
(value) =>
value &&
value.startsWith(
`${config.getRawConfig().gerbil.exit_node_name}:`
`${instanceId}:`
)
).length;
}

View File

@@ -72,15 +72,15 @@ export const privateConfigSchema = z.object({
db: z.int().nonnegative().optional().default(0)
})
)
.optional(),
tls: z
.object({
rejectUnauthorized: z
.boolean()
.optional()
.default(true)
})
.optional()
// tls: z
// .object({
// reject_unauthorized: z
// .boolean()
// .optional()
// .default(true)
// })
// .optional()
})
.optional(),
postgres_logs: z

View File

@@ -108,11 +108,15 @@ class RedisManager {
port: redisConfig.port!,
password: redisConfig.password,
db: redisConfig.db
// tls: {
// rejectUnauthorized:
// redisConfig.tls?.reject_unauthorized || false
// }
};
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
if (redisConfig.tls) {
opts.tls = {
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
};
}
return opts;
}
@@ -130,11 +134,15 @@ class RedisManager {
port: replica.port!,
password: replica.password,
db: replica.db || redisConfig.db
// tls: {
// rejectUnauthorized:
// replica.tls?.reject_unauthorized || false
// }
};
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
if (redisConfig.tls) {
opts.tls = {
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
};
}
return opts;
}

View File

@@ -61,7 +61,10 @@ function encodeUInt64(value: bigint): Buffer {
* Decode a string from SSH wire format at the given offset
* Returns the string buffer and the new offset
*/
function decodeString(data: Buffer, offset: number): { value: Buffer; newOffset: number } {
function decodeString(
data: Buffer,
offset: number
): { value: Buffer; newOffset: number } {
const len = data.readUInt32BE(offset);
const value = data.subarray(offset + 4, offset + 4 + len);
return { value, newOffset: offset + 4 + len };
@@ -91,7 +94,9 @@ function parseOpenSSHPublicKey(pubKeyLine: string): {
// Verify the key type in the blob matches
const { value: blobKeyType } = decodeString(keyData, 0);
if (blobKeyType.toString("utf8") !== keyType) {
throw new Error(`Key type mismatch: ${blobKeyType.toString("utf8")} vs ${keyType}`);
throw new Error(
`Key type mismatch: ${blobKeyType.toString("utf8")} vs ${keyType}`
);
}
return { keyType, keyData, comment };
@@ -238,7 +243,7 @@ export interface SignedCertificate {
* @param comment - Optional comment for the CA public key
* @returns CA key pair and configuration info
*/
export function generateCA(comment: string = "ssh-ca"): CAKeyPair {
export function generateCA(comment: string = "pangolin-ssh-ca"): CAKeyPair {
// Generate Ed25519 key pair
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519", {
publicKeyEncoding: { type: "spki", format: "pem" },
@@ -269,7 +274,7 @@ export function generateCA(comment: string = "ssh-ca"): CAKeyPair {
/**
* Get and decrypt the SSH CA keys for an organization.
*
*
* @param orgId - Organization ID
* @param decryptionKey - Key to decrypt the CA private key (typically server.secret from config)
* @returns CA key pair or null if not found
@@ -307,7 +312,10 @@ export async function getOrgCAKeys(
key: privateKeyPem,
format: "pem"
});
const publicKeyPem = pubKeyObj.export({ type: "spki", format: "pem" }) as string;
const publicKeyPem = pubKeyObj.export({
type: "spki",
format: "pem"
}) as string;
return {
privateKeyPem,
@@ -365,8 +373,8 @@ export function signPublicKey(
const serial = options.serial ?? BigInt(Date.now());
const certType = options.certType ?? 1; // 1 = user cert
const now = BigInt(Math.floor(Date.now() / 1000));
const validAfter = options.validAfter ?? (now - 60n); // 1 minute ago
const validBefore = options.validBefore ?? (now + 86400n * 365n); // 1 year from now
const validAfter = options.validAfter ?? now - 60n; // 1 minute ago
const validBefore = options.validBefore ?? now + 86400n * 365n; // 1 year from now
// Default extensions for user certificates
const defaultExtensions = [
@@ -422,10 +430,7 @@ export function signPublicKey(
]);
// Build complete certificate
const certificate = Buffer.concat([
certBody,
encodeString(signatureBlob)
]);
const certificate = Buffer.concat([certBody, encodeString(signatureBlob)]);
// Format as OpenSSH certificate line
const certLine = `${certTypeString} ${certificate.toString("base64")} ${options.keyId}`;

View File

@@ -25,7 +25,8 @@ import {
loginPageOrg,
orgs,
resources,
roles
roles,
siteResources
} from "@server/db";
import { eq } from "drizzle-orm";
@@ -286,6 +287,10 @@ async function disableFeature(
await disableAutoProvisioning(orgId);
break;
case TierFeature.SshPam:
await disableSshPam(orgId);
break;
default:
logger.warn(
`Unknown feature ${feature} for org ${orgId}, skipping`
@@ -315,6 +320,12 @@ async function disableDeviceApprovals(orgId: string): Promise<void> {
logger.info(`Disabled device approvals on all roles for org ${orgId}`);
}
async function disableSshPam(orgId: string): Promise<void> {
logger.info(
`Disabled SSH PAM options on all roles and site resources for org ${orgId}`
);
}
async function disableLoginPageBranding(orgId: string): Promise<void> {
const [existingBranding] = await db
.select()

View File

@@ -514,7 +514,7 @@ authenticated.post(
verifyValidSubscription(tierMatrix.sshPam),
verifyOrgAccess,
verifyLimits,
// verifyUserHasAction(ActionsEnum.signSshKey),
verifyUserHasAction(ActionsEnum.signSshKey),
logActionAudit(ActionsEnum.signSshKey),
ssh.signSshKey
);

View File

@@ -13,7 +13,17 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, newts, orgs, roundTripMessageTracker, siteResources, sites, userOrgs } from "@server/db";
import {
db,
newts,
roles,
roundTripMessageTracker,
siteResources,
sites,
userOrgs
} from "@server/db";
import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -135,11 +145,26 @@ export async function signSshKey(
);
}
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.sshPam
);
if (!isLicensed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"SSH key signing requires a paid plan"
)
);
}
let usernameToUse;
if (!userOrg.pamUsername) {
if (req.user?.email) {
// Extract username from email (first part before @)
usernameToUse = req.user?.email.split("@")[0];
usernameToUse = req.user?.email
.split("@")[0]
.replace(/[^a-zA-Z0-9_-]/g, "");
if (!usernameToUse) {
return next(
createHttpError(
@@ -301,6 +326,29 @@ export async function signSshKey(
);
}
const [roleRow] = await db
.select()
.from(roles)
.where(eq(roles.roleId, roleId))
.limit(1);
let parsedSudoCommands: string[] = [];
let parsedGroups: string[] = [];
try {
parsedSudoCommands = JSON.parse(roleRow?.sshSudoCommands ?? "[]");
if (!Array.isArray(parsedSudoCommands)) parsedSudoCommands = [];
} catch {
parsedSudoCommands = [];
}
try {
parsedGroups = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
if (!Array.isArray(parsedGroups)) parsedGroups = [];
} catch {
parsedGroups = [];
}
const homedir = roleRow?.sshCreateHomeDir ?? null;
const sudoMode = roleRow?.sshSudoMode ?? "none";
// get the site
const [newt] = await db
.select()
@@ -334,7 +382,7 @@ export async function signSshKey(
.values({
wsClientId: newt.newtId,
messageType: `newt/pam/connection`,
sentAt: Math.floor(Date.now() / 1000),
sentAt: Math.floor(Date.now() / 1000)
})
.returning();
@@ -352,14 +400,17 @@ export async function signSshKey(
data: {
messageId: message.messageId,
orgId: orgId,
agentPort: 22123,
agentPort: resource.authDaemonPort ?? 22123,
externalAuthDaemon: resource.authDaemonMode === "remote",
agentHost: resource.destination,
caCert: caKeys.publicKeyOpenSSH,
username: usernameToUse,
niceId: resource.niceId,
metadata: {
sudo: true, // we are hardcoding these for now but should make configurable from the role or something
homedir: true
sudoMode: sudoMode,
sudoCommands: parsedSudoCommands,
homedir: homedir,
groups: parsedGroups
}
}
});