mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-15 21:17:20 +00:00
Merge branch 'dev' into feat/resource-policies
This commit is contained in:
@@ -12,7 +12,8 @@
|
||||
*/
|
||||
|
||||
import { build } from "@server/build";
|
||||
import { db, customers, subscriptions } from "@server/db";
|
||||
import { db, customers, subscriptions, orgs } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { Tier } from "@server/types/Tiers";
|
||||
import { eq, and, ne } from "drizzle-orm";
|
||||
|
||||
@@ -27,37 +28,61 @@ export async function getOrgTierData(
|
||||
}
|
||||
|
||||
try {
|
||||
const [org] = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!org) {
|
||||
return { tier, active };
|
||||
}
|
||||
|
||||
let orgIdToUse = org.orgId;
|
||||
if (!org.isBillingOrg) {
|
||||
if (!org.billingOrgId) {
|
||||
logger.warn(
|
||||
`Org ${orgId} is not a billing org and does not have a billingOrgId`
|
||||
);
|
||||
return { tier, active };
|
||||
}
|
||||
orgIdToUse = org.billingOrgId;
|
||||
}
|
||||
|
||||
// Get customer for org
|
||||
const [customer] = await db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(eq(customers.orgId, orgId))
|
||||
.where(eq(customers.orgId, orgIdToUse))
|
||||
.limit(1);
|
||||
|
||||
if (customer) {
|
||||
// Query for active subscriptions that are not license type
|
||||
const [subscription] = await db
|
||||
.select()
|
||||
.from(subscriptions)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptions.customerId, customer.customerId),
|
||||
eq(subscriptions.status, "active"),
|
||||
ne(subscriptions.type, "license")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!customer) {
|
||||
return { tier, active };
|
||||
}
|
||||
|
||||
if (subscription) {
|
||||
// Validate that subscription.type is one of the expected tier values
|
||||
if (
|
||||
subscription.type === "tier1" ||
|
||||
subscription.type === "tier2" ||
|
||||
subscription.type === "tier3"
|
||||
) {
|
||||
tier = subscription.type;
|
||||
active = true;
|
||||
}
|
||||
// Query for active subscriptions that are not license type
|
||||
const [subscription] = await db
|
||||
.select()
|
||||
.from(subscriptions)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptions.customerId, customer.customerId),
|
||||
eq(subscriptions.status, "active"),
|
||||
ne(subscriptions.type, "license")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (subscription) {
|
||||
// Validate that subscription.type is one of the expected tier values
|
||||
if (
|
||||
subscription.type === "tier1" ||
|
||||
subscription.type === "tier2" ||
|
||||
subscription.type === "tier3" ||
|
||||
subscription.type === "enterprise"
|
||||
) {
|
||||
tier = subscription.type;
|
||||
active = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
266
server/private/lib/cache.ts
Normal file
266
server/private/lib/cache.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import NodeCache from "node-cache";
|
||||
import logger from "@server/logger";
|
||||
import { redisManager } from "@server/private/lib/redis";
|
||||
|
||||
// Create local cache with maxKeys limit to prevent memory leaks
|
||||
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
||||
export const localCache = new NodeCache({
|
||||
stdTTL: 3600,
|
||||
checkperiod: 120,
|
||||
maxKeys: 10000
|
||||
});
|
||||
|
||||
// Log cache statistics periodically for monitoring
|
||||
setInterval(() => {
|
||||
const stats = localCache.getStats();
|
||||
logger.debug(
|
||||
`Local cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%`
|
||||
);
|
||||
}, 300000); // Every 5 minutes
|
||||
|
||||
/**
|
||||
* Adaptive cache that uses Redis when available in multi-node environments,
|
||||
* otherwise falls back to local memory cache for single-node deployments.
|
||||
*/
|
||||
class AdaptiveCache {
|
||||
private useRedis(): boolean {
|
||||
return redisManager.isRedisEnabled() && redisManager.getHealthStatus().isHealthy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in the cache
|
||||
* @param key - Cache key
|
||||
* @param value - Value to cache (will be JSON stringified for Redis)
|
||||
* @param ttl - Time to live in seconds (0 = no expiration)
|
||||
* @returns boolean indicating success
|
||||
*/
|
||||
async set(key: string, value: any, ttl?: number): Promise<boolean> {
|
||||
const effectiveTtl = ttl === 0 ? undefined : ttl;
|
||||
|
||||
if (this.useRedis()) {
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
const success = await redisManager.set(key, serialized, effectiveTtl);
|
||||
|
||||
if (success) {
|
||||
logger.debug(`Set key in Redis: ${key}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Redis failed, fall through to local cache
|
||||
logger.debug(`Redis set failed for key ${key}, falling back to local cache`);
|
||||
} catch (error) {
|
||||
logger.error(`Redis set error for key ${key}:`, error);
|
||||
// Fall through to local cache
|
||||
}
|
||||
}
|
||||
|
||||
// Use local cache as fallback or primary
|
||||
const success = localCache.set(key, value, effectiveTtl || 0);
|
||||
if (success) {
|
||||
logger.debug(`Set key in local cache: ${key}`);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from the cache
|
||||
* @param key - Cache key
|
||||
* @returns The cached value or undefined if not found
|
||||
*/
|
||||
async get<T = any>(key: string): Promise<T | undefined> {
|
||||
if (this.useRedis()) {
|
||||
try {
|
||||
const value = await redisManager.get(key);
|
||||
|
||||
if (value !== null) {
|
||||
logger.debug(`Cache hit in Redis: ${key}`);
|
||||
return JSON.parse(value) as T;
|
||||
}
|
||||
|
||||
logger.debug(`Cache miss in Redis: ${key}`);
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
logger.error(`Redis get error for key ${key}:`, error);
|
||||
// Fall through to local cache
|
||||
}
|
||||
}
|
||||
|
||||
// Use local cache as fallback or primary
|
||||
const value = localCache.get<T>(key);
|
||||
if (value !== undefined) {
|
||||
logger.debug(`Cache hit in local cache: ${key}`);
|
||||
} else {
|
||||
logger.debug(`Cache miss in local cache: ${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a value from the cache
|
||||
* @param key - Cache key or array of keys
|
||||
* @returns Number of deleted entries
|
||||
*/
|
||||
async del(key: string | string[]): Promise<number> {
|
||||
const keys = Array.isArray(key) ? key : [key];
|
||||
let deletedCount = 0;
|
||||
|
||||
if (this.useRedis()) {
|
||||
try {
|
||||
for (const k of keys) {
|
||||
const success = await redisManager.del(k);
|
||||
if (success) {
|
||||
deletedCount++;
|
||||
logger.debug(`Deleted key from Redis: ${k}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedCount === keys.length) {
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
// Some Redis deletes failed, fall through to local cache
|
||||
logger.debug(`Some Redis deletes failed, falling back to local cache`);
|
||||
} catch (error) {
|
||||
logger.error(`Redis del error for keys ${keys.join(", ")}:`, error);
|
||||
// Fall through to local cache
|
||||
deletedCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Use local cache as fallback or primary
|
||||
for (const k of keys) {
|
||||
const success = localCache.del(k);
|
||||
if (success > 0) {
|
||||
deletedCount++;
|
||||
logger.debug(`Deleted key from local cache: ${k}`);
|
||||
}
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key exists in the cache
|
||||
* @param key - Cache key
|
||||
* @returns boolean indicating if key exists
|
||||
*/
|
||||
async has(key: string): Promise<boolean> {
|
||||
if (this.useRedis()) {
|
||||
try {
|
||||
const value = await redisManager.get(key);
|
||||
return value !== null;
|
||||
} catch (error) {
|
||||
logger.error(`Redis has error for key ${key}:`, error);
|
||||
// Fall through to local cache
|
||||
}
|
||||
}
|
||||
|
||||
// Use local cache as fallback or primary
|
||||
return localCache.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple values from the cache
|
||||
* @param keys - Array of cache keys
|
||||
* @returns Array of values (undefined for missing keys)
|
||||
*/
|
||||
async mget<T = any>(keys: string[]): Promise<(T | undefined)[]> {
|
||||
if (this.useRedis()) {
|
||||
try {
|
||||
const results: (T | undefined)[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const value = await redisManager.get(key);
|
||||
if (value !== null) {
|
||||
results.push(JSON.parse(value) as T);
|
||||
} else {
|
||||
results.push(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error(`Redis mget error:`, error);
|
||||
// Fall through to local cache
|
||||
}
|
||||
}
|
||||
|
||||
// Use local cache as fallback or primary
|
||||
return keys.map((key) => localCache.get<T>(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all keys from the cache
|
||||
*/
|
||||
async flushAll(): Promise<void> {
|
||||
if (this.useRedis()) {
|
||||
logger.warn("Adaptive cache flushAll called - Redis flush not implemented, only local cache will be flushed");
|
||||
}
|
||||
|
||||
localCache.flushAll();
|
||||
logger.debug("Flushed local cache");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
* Note: Only returns local cache stats, Redis stats are not included
|
||||
*/
|
||||
getStats() {
|
||||
return localCache.getStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current cache backend being used
|
||||
* @returns "redis" if Redis is available and healthy, "local" otherwise
|
||||
*/
|
||||
getCurrentBackend(): "redis" | "local" {
|
||||
return this.useRedis() ? "redis" : "local";
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a key from the cache and delete it
|
||||
* @param key - Cache key
|
||||
* @returns The value or undefined if not found
|
||||
*/
|
||||
async take<T = any>(key: string): Promise<T | undefined> {
|
||||
const value = await this.get<T>(key);
|
||||
if (value !== undefined) {
|
||||
await this.del(key);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get TTL (time to live) for a key
|
||||
* @param key - Cache key
|
||||
* @returns TTL in seconds, 0 if no expiration, -1 if key doesn't exist
|
||||
*/
|
||||
getTtl(key: string): number {
|
||||
// Note: This only works for local cache, Redis TTL is not supported
|
||||
if (this.useRedis()) {
|
||||
logger.warn(`getTtl called for key ${key} but Redis TTL lookup is not implemented`);
|
||||
}
|
||||
|
||||
const ttl = localCache.getTtl(key);
|
||||
if (ttl === undefined) {
|
||||
return -1;
|
||||
}
|
||||
return Math.max(0, Math.floor((ttl - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all keys from the cache
|
||||
* Note: Only returns local cache keys, Redis keys are not included
|
||||
*/
|
||||
keys(): string[] {
|
||||
if (this.useRedis()) {
|
||||
logger.warn("keys() called but Redis keys are not included, only local cache keys returned");
|
||||
}
|
||||
return localCache.keys();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const cache = new AdaptiveCache();
|
||||
export default cache;
|
||||
@@ -15,9 +15,8 @@ import config from "./config";
|
||||
import { certificates, db } from "@server/db";
|
||||
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
|
||||
import { decryptData } from "@server/lib/encryption";
|
||||
import * as fs from "fs";
|
||||
import logger from "@server/logger";
|
||||
import cache from "@server/lib/cache";
|
||||
import cache from "#private/lib/cache";
|
||||
|
||||
let encryptionKeyHex = "";
|
||||
let encryptionKey: Buffer;
|
||||
@@ -55,7 +54,7 @@ export async function getValidCertificatesForDomains(
|
||||
if (useCache) {
|
||||
for (const domain of domains) {
|
||||
const cacheKey = `cert:${domain}`;
|
||||
const cachedCert = cache.get<CertificateResult>(cacheKey);
|
||||
const cachedCert = await cache.get<CertificateResult>(cacheKey);
|
||||
if (cachedCert) {
|
||||
finalResults.push(cachedCert); // Valid cache hit
|
||||
} else {
|
||||
@@ -169,7 +168,7 @@ export async function getValidCertificatesForDomains(
|
||||
// Add to cache for future requests, using the *requested domain* as the key
|
||||
if (useCache) {
|
||||
const cacheKey = `cert:${domain}`;
|
||||
cache.set(cacheKey, resultCert, 180);
|
||||
await cache.set(cacheKey, resultCert, 180);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -11,17 +11,17 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { accessAuditLog, db, orgs } from "@server/db";
|
||||
import { accessAuditLog, logsDb, db, orgs } from "@server/db";
|
||||
import { getCountryCodeForIp } from "@server/lib/geoip";
|
||||
import logger from "@server/logger";
|
||||
import { and, eq, lt } from "drizzle-orm";
|
||||
import cache from "@server/lib/cache";
|
||||
import cache from "#private/lib/cache";
|
||||
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
||||
import { stripPortFromHost } from "@server/lib/ip";
|
||||
|
||||
async function getAccessDays(orgId: string): Promise<number> {
|
||||
// check cache first
|
||||
const cached = cache.get<number>(`org_${orgId}_accessDays`);
|
||||
const cached = await cache.get<number>(`org_${orgId}_accessDays`);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
@@ -39,7 +39,7 @@ async function getAccessDays(orgId: string): Promise<number> {
|
||||
}
|
||||
|
||||
// store the result in cache
|
||||
cache.set(
|
||||
await cache.set(
|
||||
`org_${orgId}_accessDays`,
|
||||
org.settingsLogRetentionDaysAction,
|
||||
300
|
||||
@@ -52,7 +52,7 @@ export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
|
||||
const cutoffTimestamp = calculateCutoffTimestamp(retentionDays);
|
||||
|
||||
try {
|
||||
await db
|
||||
await logsDb
|
||||
.delete(accessAuditLog)
|
||||
.where(
|
||||
and(
|
||||
@@ -124,7 +124,7 @@ export async function logAccessAudit(data: {
|
||||
? await getCountryCodeFromIp(data.requestIp)
|
||||
: undefined;
|
||||
|
||||
await db.insert(accessAuditLog).values({
|
||||
await logsDb.insert(accessAuditLog).values({
|
||||
timestamp: timestamp,
|
||||
orgId: data.orgId,
|
||||
actorType,
|
||||
@@ -146,14 +146,14 @@ export async function logAccessAudit(data: {
|
||||
async function getCountryCodeFromIp(ip: string): Promise<string | undefined> {
|
||||
const geoIpCacheKey = `geoip_access:${ip}`;
|
||||
|
||||
let cachedCountryCode: string | undefined = cache.get(geoIpCacheKey);
|
||||
let cachedCountryCode: string | undefined = await cache.get(geoIpCacheKey);
|
||||
|
||||
if (!cachedCountryCode) {
|
||||
cachedCountryCode = await getCountryCodeForIp(ip); // do it locally
|
||||
// Only cache successful lookups to avoid filling cache with undefined values
|
||||
if (cachedCountryCode) {
|
||||
// Cache for longer since IP geolocation doesn't change frequently
|
||||
cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes
|
||||
await cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
gerbil: z
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,18 +12,18 @@
|
||||
*/
|
||||
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
import { actionAuditLog, db, orgs } from "@server/db";
|
||||
import { actionAuditLog, logsDb, db, orgs } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { and, eq, lt } from "drizzle-orm";
|
||||
import cache from "@server/lib/cache";
|
||||
import cache from "#private/lib/cache";
|
||||
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
||||
|
||||
async function getActionDays(orgId: string): Promise<number> {
|
||||
// check cache first
|
||||
const cached = cache.get<number>(`org_${orgId}_actionDays`);
|
||||
const cached = await cache.get<number>(`org_${orgId}_actionDays`);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
@@ -41,7 +41,7 @@ async function getActionDays(orgId: string): Promise<number> {
|
||||
}
|
||||
|
||||
// store the result in cache
|
||||
cache.set(
|
||||
await cache.set(
|
||||
`org_${orgId}_actionDays`,
|
||||
org.settingsLogRetentionDaysAction,
|
||||
300
|
||||
@@ -54,7 +54,7 @@ export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
|
||||
const cutoffTimestamp = calculateCutoffTimestamp(retentionDays);
|
||||
|
||||
try {
|
||||
await db
|
||||
await logsDb
|
||||
.delete(actionAuditLog)
|
||||
.where(
|
||||
and(
|
||||
@@ -123,7 +123,7 @@ export function logActionAudit(action: ActionsEnum) {
|
||||
metadata = JSON.stringify(req.params);
|
||||
}
|
||||
|
||||
await db.insert(actionAuditLog).values({
|
||||
await logsDb.insert(actionAuditLog).values({
|
||||
timestamp,
|
||||
orgId,
|
||||
actorType,
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { accessAuditLog, db, resources } from "@server/db";
|
||||
import { accessAuditLog, logsDb, resources, db, primaryDb } from "@server/db";
|
||||
import { registry } from "@server/openApi";
|
||||
import { NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { eq, gt, lt, and, count, desc } from "drizzle-orm";
|
||||
import { eq, gt, lt, and, count, desc, inArray } from "drizzle-orm";
|
||||
import { OpenAPITags } from "@server/openApi";
|
||||
import { z } from "zod";
|
||||
import createHttpError from "http-errors";
|
||||
@@ -115,15 +115,13 @@ function getWhere(data: Q) {
|
||||
}
|
||||
|
||||
export function queryAccess(data: Q) {
|
||||
return db
|
||||
return logsDb
|
||||
.select({
|
||||
orgId: accessAuditLog.orgId,
|
||||
action: accessAuditLog.action,
|
||||
actorType: accessAuditLog.actorType,
|
||||
actorId: accessAuditLog.actorId,
|
||||
resourceId: accessAuditLog.resourceId,
|
||||
resourceName: resources.name,
|
||||
resourceNiceId: resources.niceId,
|
||||
ip: accessAuditLog.ip,
|
||||
location: accessAuditLog.location,
|
||||
userAgent: accessAuditLog.userAgent,
|
||||
@@ -133,16 +131,46 @@ export function queryAccess(data: Q) {
|
||||
actor: accessAuditLog.actor
|
||||
})
|
||||
.from(accessAuditLog)
|
||||
.leftJoin(
|
||||
resources,
|
||||
eq(accessAuditLog.resourceId, resources.resourceId)
|
||||
)
|
||||
.where(getWhere(data))
|
||||
.orderBy(desc(accessAuditLog.timestamp), desc(accessAuditLog.id));
|
||||
}
|
||||
|
||||
async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryAccess>>) {
|
||||
// If logs database is the same as main database, we can do a join
|
||||
// Otherwise, we need to fetch resource details separately
|
||||
const resourceIds = logs
|
||||
.map(log => log.resourceId)
|
||||
.filter((id): id is number => id !== null && id !== undefined);
|
||||
|
||||
if (resourceIds.length === 0) {
|
||||
return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null }));
|
||||
}
|
||||
|
||||
// Fetch resource details from main database
|
||||
const resourceDetails = await primaryDb
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
name: resources.name,
|
||||
niceId: resources.niceId
|
||||
})
|
||||
.from(resources)
|
||||
.where(inArray(resources.resourceId, resourceIds));
|
||||
|
||||
// Create a map for quick lookup
|
||||
const resourceMap = new Map(
|
||||
resourceDetails.map(r => [r.resourceId, { name: r.name, niceId: r.niceId }])
|
||||
);
|
||||
|
||||
// Enrich logs with resource details
|
||||
return logs.map(log => ({
|
||||
...log,
|
||||
resourceName: log.resourceId ? resourceMap.get(log.resourceId)?.name ?? null : null,
|
||||
resourceNiceId: log.resourceId ? resourceMap.get(log.resourceId)?.niceId ?? null : null
|
||||
}));
|
||||
}
|
||||
|
||||
export function countAccessQuery(data: Q) {
|
||||
const countQuery = db
|
||||
const countQuery = logsDb
|
||||
.select({ count: count() })
|
||||
.from(accessAuditLog)
|
||||
.where(getWhere(data));
|
||||
@@ -161,7 +189,7 @@ async function queryUniqueFilterAttributes(
|
||||
);
|
||||
|
||||
// Get unique actors
|
||||
const uniqueActors = await db
|
||||
const uniqueActors = await logsDb
|
||||
.selectDistinct({
|
||||
actor: accessAuditLog.actor
|
||||
})
|
||||
@@ -169,7 +197,7 @@ async function queryUniqueFilterAttributes(
|
||||
.where(baseConditions);
|
||||
|
||||
// Get unique locations
|
||||
const uniqueLocations = await db
|
||||
const uniqueLocations = await logsDb
|
||||
.selectDistinct({
|
||||
locations: accessAuditLog.location
|
||||
})
|
||||
@@ -177,25 +205,40 @@ async function queryUniqueFilterAttributes(
|
||||
.where(baseConditions);
|
||||
|
||||
// Get unique resources with names
|
||||
const uniqueResources = await db
|
||||
const uniqueResources = await logsDb
|
||||
.selectDistinct({
|
||||
id: accessAuditLog.resourceId,
|
||||
name: resources.name
|
||||
id: accessAuditLog.resourceId
|
||||
})
|
||||
.from(accessAuditLog)
|
||||
.leftJoin(
|
||||
resources,
|
||||
eq(accessAuditLog.resourceId, resources.resourceId)
|
||||
)
|
||||
.where(baseConditions);
|
||||
|
||||
// Fetch resource names from main database for the unique resource IDs
|
||||
const resourceIds = uniqueResources
|
||||
.map(row => row.id)
|
||||
.filter((id): id is number => id !== null);
|
||||
|
||||
let resourcesWithNames: Array<{ id: number; name: string | null }> = [];
|
||||
|
||||
if (resourceIds.length > 0) {
|
||||
const resourceDetails = await primaryDb
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
name: resources.name
|
||||
})
|
||||
.from(resources)
|
||||
.where(inArray(resources.resourceId, resourceIds));
|
||||
|
||||
resourcesWithNames = resourceDetails.map(r => ({
|
||||
id: r.resourceId,
|
||||
name: r.name
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
actors: uniqueActors
|
||||
.map((row) => row.actor)
|
||||
.filter((actor): actor is string => actor !== null),
|
||||
resources: uniqueResources.filter(
|
||||
(row): row is { id: number; name: string | null } => row.id !== null
|
||||
),
|
||||
resources: resourcesWithNames,
|
||||
locations: uniqueLocations
|
||||
.map((row) => row.locations)
|
||||
.filter((location): location is string => location !== null)
|
||||
@@ -243,7 +286,10 @@ export async function queryAccessAuditLogs(
|
||||
|
||||
const baseQuery = queryAccess(data);
|
||||
|
||||
const log = await baseQuery.limit(data.limit).offset(data.offset);
|
||||
const logsRaw = await baseQuery.limit(data.limit).offset(data.offset);
|
||||
|
||||
// Enrich with resource details (handles cross-database scenario)
|
||||
const log = await enrichWithResourceDetails(logsRaw);
|
||||
|
||||
const totalCountResult = await countAccessQuery(data);
|
||||
const totalCount = totalCountResult[0].count;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { actionAuditLog, db } from "@server/db";
|
||||
import { actionAuditLog, logsDb } from "@server/db";
|
||||
import { registry } from "@server/openApi";
|
||||
import { NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
@@ -97,7 +97,7 @@ function getWhere(data: Q) {
|
||||
}
|
||||
|
||||
export function queryAction(data: Q) {
|
||||
return db
|
||||
return logsDb
|
||||
.select({
|
||||
orgId: actionAuditLog.orgId,
|
||||
action: actionAuditLog.action,
|
||||
@@ -113,7 +113,7 @@ export function queryAction(data: Q) {
|
||||
}
|
||||
|
||||
export function countActionQuery(data: Q) {
|
||||
const countQuery = db
|
||||
const countQuery = logsDb
|
||||
.select({ count: count() })
|
||||
.from(actionAuditLog)
|
||||
.where(getWhere(data));
|
||||
@@ -132,14 +132,14 @@ async function queryUniqueFilterAttributes(
|
||||
);
|
||||
|
||||
// Get unique actors
|
||||
const uniqueActors = await db
|
||||
const uniqueActors = await logsDb
|
||||
.selectDistinct({
|
||||
actor: actionAuditLog.actor
|
||||
})
|
||||
.from(actionAuditLog)
|
||||
.where(baseConditions);
|
||||
|
||||
const uniqueActions = await db
|
||||
const uniqueActions = await logsDb
|
||||
.selectDistinct({
|
||||
action: actionAuditLog.action
|
||||
})
|
||||
|
||||
@@ -15,7 +15,19 @@ import { SubscriptionType } from "./hooks/getSubType";
|
||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { Tier } from "@server/types/Tiers";
|
||||
import logger from "@server/logger";
|
||||
import { db, idp, idpOrg, loginPage, loginPageBranding, loginPageBrandingOrg, loginPageOrg, orgs, resources, roles } from "@server/db";
|
||||
import {
|
||||
db,
|
||||
idp,
|
||||
idpOrg,
|
||||
loginPage,
|
||||
loginPageBranding,
|
||||
loginPageBrandingOrg,
|
||||
loginPageOrg,
|
||||
orgs,
|
||||
resources,
|
||||
roles,
|
||||
siteResources
|
||||
} from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
@@ -59,10 +71,7 @@ async function capRetentionDays(
|
||||
}
|
||||
|
||||
// Get current org settings
|
||||
const [org] = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId));
|
||||
|
||||
if (!org) {
|
||||
logger.warn(`Org ${orgId} not found when capping retention days`);
|
||||
@@ -110,18 +119,13 @@ async function capRetentionDays(
|
||||
|
||||
// Apply updates if needed
|
||||
if (needsUpdate) {
|
||||
await db
|
||||
.update(orgs)
|
||||
.set(updates)
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
await db.update(orgs).set(updates).where(eq(orgs.orgId, orgId));
|
||||
|
||||
logger.info(
|
||||
`Successfully capped retention days for org ${orgId} to max ${maxRetentionDays} days`
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`No retention day capping needed for org ${orgId}`
|
||||
);
|
||||
logger.debug(`No retention day capping needed for org ${orgId}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +138,35 @@ export async function handleTierChange(
|
||||
`Handling tier change for org ${orgId}: ${previousTier || "none"} -> ${newTier || "free"}`
|
||||
);
|
||||
|
||||
// Get all orgs that have this orgId as their billingOrgId
|
||||
const associatedOrgs = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.billingOrgId, orgId));
|
||||
|
||||
logger.info(
|
||||
`Found ${associatedOrgs.length} org(s) associated with billing org ${orgId}`
|
||||
);
|
||||
|
||||
// Loop over all associated orgs and apply tier changes
|
||||
for (const org of associatedOrgs) {
|
||||
await handleTierChangeForOrg(org.orgId, newTier, previousTier);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Completed tier change handling for all orgs associated with billing org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
async function handleTierChangeForOrg(
|
||||
orgId: string,
|
||||
newTier: SubscriptionType | null,
|
||||
previousTier?: SubscriptionType | null
|
||||
): Promise<void> {
|
||||
logger.info(
|
||||
`Handling tier change for org ${orgId}: ${previousTier || "none"} -> ${newTier || "free"}`
|
||||
);
|
||||
|
||||
// License subscriptions are handled separately and don't use the tier matrix
|
||||
if (newTier === "license") {
|
||||
logger.debug(
|
||||
@@ -254,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`
|
||||
@@ -283,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()
|
||||
@@ -314,9 +357,7 @@ async function disableLoginPageDomain(orgId: string): Promise<void> {
|
||||
);
|
||||
|
||||
if (existingLoginPage) {
|
||||
await db
|
||||
.delete(loginPageOrg)
|
||||
.where(eq(loginPageOrg.orgId, orgId));
|
||||
await db.delete(loginPageOrg).where(eq(loginPageOrg.orgId, orgId));
|
||||
|
||||
await db
|
||||
.delete(loginPage)
|
||||
|
||||
@@ -112,11 +112,13 @@ export async function getOrgSubscriptionsData(
|
||||
throw new Error(`Not found`);
|
||||
}
|
||||
|
||||
const billingOrgId = org[0].billingOrgId || org[0].orgId;
|
||||
|
||||
// Get customer for org
|
||||
const customer = await db
|
||||
.select()
|
||||
.from(customers)
|
||||
.where(eq(customers.orgId, orgId))
|
||||
.where(eq(customers.orgId, billingOrgId))
|
||||
.limit(1);
|
||||
|
||||
const subscriptionsWithItems: Array<{
|
||||
|
||||
@@ -85,10 +85,14 @@ export async function getOrgUsage(
|
||||
orgId,
|
||||
FeatureId.REMOTE_EXIT_NODES
|
||||
);
|
||||
const egressData = await usageService.getUsage(
|
||||
const organizations = await usageService.getUsage(
|
||||
orgId,
|
||||
FeatureId.EGRESS_DATA_MB
|
||||
FeatureId.ORGINIZATIONS
|
||||
);
|
||||
// const egressData = await usageService.getUsage(
|
||||
// orgId,
|
||||
// FeatureId.EGRESS_DATA_MB
|
||||
// );
|
||||
|
||||
if (sites) {
|
||||
usageData.push(sites);
|
||||
@@ -96,15 +100,18 @@ export async function getOrgUsage(
|
||||
if (users) {
|
||||
usageData.push(users);
|
||||
}
|
||||
if (egressData) {
|
||||
usageData.push(egressData);
|
||||
}
|
||||
// if (egressData) {
|
||||
// usageData.push(egressData);
|
||||
// }
|
||||
if (domains) {
|
||||
usageData.push(domains);
|
||||
}
|
||||
if (remoteExitNodes) {
|
||||
usageData.push(remoteExitNodes);
|
||||
}
|
||||
if (organizations) {
|
||||
usageData.push(organizations);
|
||||
}
|
||||
|
||||
const orgLimits = await db
|
||||
.select()
|
||||
|
||||
@@ -25,6 +25,7 @@ import * as logs from "#private/routers/auditLogs";
|
||||
import * as misc from "#private/routers/misc";
|
||||
import * as reKey from "#private/routers/re-key";
|
||||
import * as approval from "#private/routers/approvals";
|
||||
import * as ssh from "#private/routers/ssh";
|
||||
import * as resource from "#private/routers/resource";
|
||||
import * as policy from "#private/routers/policy";
|
||||
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
verifyUserIsServerAdmin,
|
||||
verifySiteAccess,
|
||||
verifyClientAccess,
|
||||
verifyLimits,
|
||||
verifyLimits
|
||||
} from "@server/middlewares";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
@@ -503,9 +505,9 @@ authenticated.get(
|
||||
|
||||
authenticated.post(
|
||||
"/re-key/:clientId/regenerate-client-secret",
|
||||
verifyClientAccess, // this is first to set the org id
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.rotateCredentials),
|
||||
verifyClientAccess, // this is first to set the org id
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
||||
reKey.reGenerateClientSecret
|
||||
@@ -513,9 +515,9 @@ authenticated.post(
|
||||
|
||||
authenticated.post(
|
||||
"/re-key/:siteId/regenerate-site-secret",
|
||||
verifySiteAccess, // this is first to set the org id
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.rotateCredentials),
|
||||
verifySiteAccess, // this is first to set the org id
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
||||
reKey.reGenerateSiteSecret
|
||||
@@ -530,3 +532,14 @@ authenticated.put(
|
||||
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
||||
reKey.reGenerateExitNodeSecret
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/ssh/sign-key",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.sshPam),
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.signSshKey),
|
||||
logActionAudit(ActionsEnum.signSshKey),
|
||||
ssh.signSshKey
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { eq, InferInsertModel } from "drizzle-orm";
|
||||
import { build } from "@server/build";
|
||||
import { validateLocalPath } from "@app/lib/validateLocalPath";
|
||||
import config from "#private/lib/config";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
@@ -37,14 +38,36 @@ const bodySchema = z.strictObject({
|
||||
.union([
|
||||
z.literal(""),
|
||||
z
|
||||
.url("Must be a valid URL")
|
||||
.superRefine(async (url, ctx) => {
|
||||
.string()
|
||||
.superRefine(async (urlOrPath, ctx) => {
|
||||
const parseResult = z.url().safeParse(urlOrPath);
|
||||
if (!parseResult.success) {
|
||||
if (build !== "enterprise") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Must be a valid URL"
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
try {
|
||||
validateLocalPath(urlOrPath);
|
||||
} catch (error) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
|
||||
});
|
||||
} finally {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
const response = await fetch(urlOrPath, {
|
||||
method: "HEAD"
|
||||
}).catch(() => {
|
||||
// If HEAD fails (CORS or method not allowed), try GET
|
||||
return fetch(url, { method: "GET" });
|
||||
return fetch(urlOrPath, { method: "GET" });
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types";
|
||||
import { isSubscribed } from "#private/lib/isSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import { build } from "@server/build";
|
||||
|
||||
const paramsSchema = z.strictObject({ orgId: z.string().nonempty() });
|
||||
|
||||
@@ -122,12 +123,14 @@ export async function createOrgOidcIdp(
|
||||
|
||||
let { autoProvision } = parsedBody.data;
|
||||
|
||||
const subscribed = await isSubscribed(
|
||||
orgId,
|
||||
tierMatrix.deviceApprovals
|
||||
);
|
||||
if (!subscribed) {
|
||||
autoProvision = false;
|
||||
if (build == "saas") { // this is not paywalled with a ee license because this whole endpoint is restricted
|
||||
const subscribed = await isSubscribed(
|
||||
orgId,
|
||||
tierMatrix.deviceApprovals
|
||||
);
|
||||
if (!subscribed) {
|
||||
autoProvision = false;
|
||||
}
|
||||
}
|
||||
|
||||
const key = config.getRawConfig().server.secret!;
|
||||
|
||||
@@ -27,6 +27,7 @@ import config from "@server/lib/config";
|
||||
import { isSubscribed } from "#private/lib/isSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import { build } from "@server/build";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
@@ -127,12 +128,15 @@ export async function updateOrgOidcIdp(
|
||||
|
||||
let { autoProvision } = parsedBody.data;
|
||||
|
||||
const subscribed = await isSubscribed(
|
||||
orgId,
|
||||
tierMatrix.deviceApprovals
|
||||
);
|
||||
if (!subscribed) {
|
||||
autoProvision = false;
|
||||
if (build == "saas") {
|
||||
// this is not paywalled with a ee license because this whole endpoint is restricted
|
||||
const subscribed = await isSubscribed(
|
||||
orgId,
|
||||
tierMatrix.deviceApprovals
|
||||
);
|
||||
if (!subscribed) {
|
||||
autoProvision = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if IDP exists and is of type OIDC
|
||||
|
||||
@@ -12,7 +12,14 @@
|
||||
*/
|
||||
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { db, exitNodes, exitNodeOrgs, ExitNode, ExitNodeOrg } from "@server/db";
|
||||
import {
|
||||
db,
|
||||
exitNodes,
|
||||
exitNodeOrgs,
|
||||
ExitNode,
|
||||
ExitNodeOrg,
|
||||
orgs
|
||||
} from "@server/db";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { z } from "zod";
|
||||
import { remoteExitNodes } from "@server/db";
|
||||
@@ -25,7 +32,7 @@ import { createRemoteExitNodeSession } from "#private/auth/sessions/remoteExitNo
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { hashPassword, verifyPassword } from "@server/auth/password";
|
||||
import logger from "@server/logger";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, inArray, ne } from "drizzle-orm";
|
||||
import { getNextAvailableSubnet } from "@server/lib/exitNodes";
|
||||
import { usageService } from "@server/lib/billing/usageService";
|
||||
import { FeatureId } from "@server/lib/billing";
|
||||
@@ -169,7 +176,17 @@ export async function createRemoteExitNode(
|
||||
);
|
||||
}
|
||||
|
||||
let numExitNodeOrgs: ExitNodeOrg[] | undefined;
|
||||
const [org] = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!org) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Organization not found")
|
||||
);
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
if (!existingExitNode) {
|
||||
@@ -217,19 +234,43 @@ export async function createRemoteExitNode(
|
||||
});
|
||||
}
|
||||
|
||||
numExitNodeOrgs = await trx
|
||||
.select()
|
||||
.from(exitNodeOrgs)
|
||||
.where(eq(exitNodeOrgs.orgId, orgId));
|
||||
});
|
||||
// calculate if the node is in any other of the orgs before we count it as an add to the billing org
|
||||
if (org.billingOrgId) {
|
||||
const otherBillingOrgs = await trx
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(
|
||||
and(
|
||||
eq(orgs.billingOrgId, org.billingOrgId),
|
||||
ne(orgs.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (numExitNodeOrgs) {
|
||||
await usageService.updateCount(
|
||||
orgId,
|
||||
FeatureId.REMOTE_EXIT_NODES,
|
||||
numExitNodeOrgs.length
|
||||
);
|
||||
}
|
||||
const billingOrgIds = otherBillingOrgs.map((o) => o.orgId);
|
||||
|
||||
const orgsInBillingDomainThatTheNodeIsStillIn = await trx
|
||||
.select()
|
||||
.from(exitNodeOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
exitNodeOrgs.exitNodeId,
|
||||
existingExitNode.exitNodeId
|
||||
),
|
||||
inArray(exitNodeOrgs.orgId, billingOrgIds)
|
||||
)
|
||||
);
|
||||
|
||||
if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) {
|
||||
await usageService.add(
|
||||
orgId,
|
||||
FeatureId.REMOTE_EXIT_NODES,
|
||||
1,
|
||||
trx
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const token = generateSessionToken();
|
||||
await createRemoteExitNodeSession(token, remoteExitNodeId);
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, ExitNodeOrg, exitNodeOrgs, exitNodes } from "@server/db";
|
||||
import { db, ExitNodeOrg, exitNodeOrgs, exitNodes, orgs } from "@server/db";
|
||||
import { remoteExitNodes } from "@server/db";
|
||||
import { and, count, eq } from "drizzle-orm";
|
||||
import { and, count, eq, inArray } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
@@ -50,7 +50,8 @@ export async function deleteRemoteExitNode(
|
||||
const [remoteExitNode] = await db
|
||||
.select()
|
||||
.from(remoteExitNodes)
|
||||
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId));
|
||||
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId))
|
||||
.limit(1);
|
||||
|
||||
if (!remoteExitNode) {
|
||||
return next(
|
||||
@@ -70,7 +71,17 @@ export async function deleteRemoteExitNode(
|
||||
);
|
||||
}
|
||||
|
||||
let numExitNodeOrgs: ExitNodeOrg[] | undefined;
|
||||
const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId));
|
||||
|
||||
if (!org) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Org with ID ${orgId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.delete(exitNodeOrgs)
|
||||
@@ -81,38 +92,39 @@ export async function deleteRemoteExitNode(
|
||||
)
|
||||
);
|
||||
|
||||
const [remainingExitNodeOrgs] = await trx
|
||||
.select({ count: count() })
|
||||
.from(exitNodeOrgs)
|
||||
.where(eq(exitNodeOrgs.exitNodeId, remoteExitNode.exitNodeId!));
|
||||
// calculate if the user is in any other of the orgs before we count it as an remove to the billing org
|
||||
if (org.billingOrgId) {
|
||||
const otherBillingOrgs = await trx
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.billingOrgId, org.billingOrgId));
|
||||
|
||||
if (remainingExitNodeOrgs.count === 0) {
|
||||
await trx
|
||||
.delete(remoteExitNodes)
|
||||
const billingOrgIds = otherBillingOrgs.map((o) => o.orgId);
|
||||
|
||||
const orgsInBillingDomainThatTheNodeIsStillIn = await trx
|
||||
.select()
|
||||
.from(exitNodeOrgs)
|
||||
.where(
|
||||
eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)
|
||||
and(
|
||||
eq(
|
||||
exitNodeOrgs.exitNodeId,
|
||||
remoteExitNode.exitNodeId!
|
||||
),
|
||||
inArray(exitNodeOrgs.orgId, billingOrgIds)
|
||||
)
|
||||
);
|
||||
await trx
|
||||
.delete(exitNodes)
|
||||
.where(
|
||||
eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId!)
|
||||
|
||||
if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) {
|
||||
await usageService.add(
|
||||
orgId,
|
||||
FeatureId.REMOTE_EXIT_NODES,
|
||||
-1,
|
||||
trx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
numExitNodeOrgs = await trx
|
||||
.select()
|
||||
.from(exitNodeOrgs)
|
||||
.where(eq(exitNodeOrgs.orgId, orgId));
|
||||
});
|
||||
|
||||
if (numExitNodeOrgs) {
|
||||
await usageService.updateCount(
|
||||
orgId,
|
||||
FeatureId.REMOTE_EXIT_NODES,
|
||||
numExitNodeOrgs.length
|
||||
);
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
|
||||
14
server/private/routers/ssh/index.ts
Normal file
14
server/private/routers/ssh/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
export * from "./signSshKey";
|
||||
476
server/private/routers/ssh/signSshKey.ts
Normal file
476
server/private/routers/ssh/signSshKey.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
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";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { eq, or, and } from "drizzle-orm";
|
||||
import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource";
|
||||
import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA";
|
||||
import config from "@server/lib/config";
|
||||
import { sendToClient } from "#private/routers/ws";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
});
|
||||
|
||||
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
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
const fields = [data.resourceId, data.resource];
|
||||
const definedFields = fields.filter((field) => field !== undefined);
|
||||
return definedFields.length === 1;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Exactly one of resourceId, niceId, or alias must be provided"
|
||||
}
|
||||
);
|
||||
|
||||
export type SignSshKeyResponse = {
|
||||
certificate: string;
|
||||
messageId: number;
|
||||
sshUsername: string;
|
||||
sshHost: string;
|
||||
resourceId: number;
|
||||
keyId: string;
|
||||
validPrincipals: string[];
|
||||
validAfter: string;
|
||||
validBefore: string;
|
||||
expiresIn: number;
|
||||
};
|
||||
|
||||
// registry.registerPath({
|
||||
// method: "post",
|
||||
// path: "/org/{orgId}/ssh/sign-key",
|
||||
// description: "Sign an SSH public key for access to a resource.",
|
||||
// tags: [OpenAPITags.Org, OpenAPITags.Ssh],
|
||||
// request: {
|
||||
// params: paramsSchema,
|
||||
// body: {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: bodySchema
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// responses: {}
|
||||
// });
|
||||
|
||||
export async function signSshKey(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
const {
|
||||
publicKey,
|
||||
resourceId,
|
||||
resource: resourceQueryString
|
||||
} = parsedBody.data;
|
||||
const userId = req.user?.userId;
|
||||
const roleId = req.userOrgRoleId!;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
const [userOrg] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!userOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not belong to the specified organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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]
|
||||
.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,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
|
||||
if (!caKeys) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"SSH CA not configured for this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the resource exists and belongs to the org
|
||||
// Build the where clause dynamically based on which field is provided
|
||||
let whereClause;
|
||||
if (resourceId !== undefined) {
|
||||
whereClause = eq(siteResources.siteResourceId, resourceId);
|
||||
} else if (resourceQueryString !== undefined) {
|
||||
whereClause = or(
|
||||
eq(siteResources.niceId, resourceQueryString),
|
||||
eq(siteResources.alias, resourceQueryString)
|
||||
);
|
||||
} else {
|
||||
// This should never happen due to the schema validation, but TypeScript doesn't know that
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"One of resourceId, niceId, or alias must be provided"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const resources = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(and(whereClause, eq(siteResources.orgId, orgId)));
|
||||
|
||||
if (!resources || resources.length === 0) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, `Resource not found`)
|
||||
);
|
||||
}
|
||||
|
||||
if (resources.length > 1) {
|
||||
// error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`Multiple resources found matching the criteria`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const resource = resources[0];
|
||||
|
||||
if (resource.orgId !== orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Resource does not belong to the specified organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (resource.mode == "cidr") {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"SSHing is not supported for CIDR resources"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the user has access to the resource
|
||||
const hasAccess = await canUserAccessSiteResource({
|
||||
userId: userId,
|
||||
resourceId: resource.siteResourceId,
|
||||
roleId: roleId
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have access to this resource"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, resource.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!newt) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Site associated with resource not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Sign the public key
|
||||
const now = BigInt(Math.floor(Date.now() / 1000));
|
||||
// only valid for 5 minutes
|
||||
const validFor = 300n;
|
||||
|
||||
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 [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"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
let sshHost;
|
||||
if (resource.alias && resource.alias != "") {
|
||||
sshHost = resource.alias;
|
||||
} else {
|
||||
sshHost = resource.destination;
|
||||
}
|
||||
|
||||
return response<SignSshKeyResponse>(res, {
|
||||
data: {
|
||||
certificate: cert.certificate,
|
||||
messageId: message.messageId,
|
||||
sshUsername: usernameToUse,
|
||||
sshHost: sshHost,
|
||||
resourceId: resource.siteResourceId,
|
||||
keyId: cert.keyId,
|
||||
validPrincipals: cert.validPrincipals,
|
||||
validAfter: cert.validAfter.toISOString(),
|
||||
validBefore: cert.validBefore.toISOString(),
|
||||
expiresIn
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "SSH key signed successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error signing SSH key:", error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"An error occurred while signing the SSH key"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user