v1.0.25
This commit is contained in:
1 parent
c210ef0a8e
commit
afee09866d
55 files changed
+7767
-131
No files matched your search
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* API Token Management
|
||||
*
|
||||
* Provides Bearer token authentication for CI/CD pipelines and scripts.
|
||||
* Tokens use `dh_` prefix, Argon2id hashing, and prefix-based lookup.
|
||||
*
|
||||
* Performance: An in-memory cache (SHA-256 key, 60s TTL) avoids running
|
||||
* Argon2id on every request. First request: ~100ms. Subsequent: ~0ms.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { db, eq, and } from '$lib/server/db/drizzle';
|
||||
import { hashPassword, verifyPassword, type AuthenticatedUser } from './auth';
|
||||
import { secureRandomBytes } from './crypto-fallback';
|
||||
import { getUserRoles, userHasAdminRole, type Permissions } from './db';
|
||||
import { isEnterprise } from './license';
|
||||
import { tokenCache, ensureCleanupInterval, invalidateTokenCacheForUser, clearTokenCache } from './token-cache';
|
||||
|
||||
// Re-export cache functions so existing consumers don't need to change imports
|
||||
export { invalidateTokenCacheForUser, clearTokenCache } from './token-cache';
|
||||
|
||||
// Dynamic schema import (same pattern as db.ts)
|
||||
let apiTokensTable: any;
|
||||
|
||||
async function getApiTokensTable() {
|
||||
if (apiTokensTable) return apiTokensTable;
|
||||
const isPostgres = !!(process.env.DATABASE_URL && (
|
||||
process.env.DATABASE_URL.startsWith('postgres://') ||
|
||||
process.env.DATABASE_URL.startsWith('postgresql://')
|
||||
));
|
||||
const schema = isPostgres
|
||||
? await import('./db/schema/pg-schema.js')
|
||||
: await import('./db/schema/index.js');
|
||||
apiTokensTable = schema.apiTokens;
|
||||
return apiTokensTable;
|
||||
}
|
||||
|
||||
// Token format: dh_ + 32 bytes base64url = dh_ + 43 chars
|
||||
const TOKEN_PREFIX = 'dh_';
|
||||
const TOKEN_BYTES = 32;
|
||||
const PREFIX_LENGTH = 8; // chars after dh_ stored for identification
|
||||
const MAX_TOKEN_LENGTH = 200;
|
||||
const CACHE_TTL = 60_000; // 60 seconds
|
||||
|
||||
function cacheKey(rawToken: string): string {
|
||||
return createHash('sha256').update(rawToken).digest('hex');
|
||||
}
|
||||
|
||||
// Pre-computed dummy hash for timing protection on invalid prefixes
|
||||
let dummyHash: string | null = null;
|
||||
|
||||
async function getDummyHash(): Promise<string> {
|
||||
if (!dummyHash) {
|
||||
dummyHash = await hashPassword('dh_dummy_token_for_timing_protection');
|
||||
}
|
||||
return dummyHash;
|
||||
}
|
||||
|
||||
// Initialize dummy hash on import (fire and forget)
|
||||
void getDummyHash();
|
||||
|
||||
/**
|
||||
* Generate a new API token.
|
||||
* Returns the plaintext token (shown once) and the database record.
|
||||
*/
|
||||
export async function generateApiToken(
|
||||
userId: number,
|
||||
name: string,
|
||||
expiresAt?: string | null
|
||||
): Promise<{ token: string; id: number; tokenPrefix: string }> {
|
||||
const table = await getApiTokensTable();
|
||||
|
||||
// Generate random token
|
||||
const randomBytes = secureRandomBytes(TOKEN_BYTES);
|
||||
const rawToken = TOKEN_PREFIX + randomBytes.toString('base64url');
|
||||
const tokenPrefix = rawToken.substring(TOKEN_PREFIX.length, TOKEN_PREFIX.length + PREFIX_LENGTH);
|
||||
|
||||
// Hash for storage
|
||||
const tokenHash = await hashPassword(rawToken);
|
||||
|
||||
const result = await db.insert(table).values({
|
||||
userId,
|
||||
name,
|
||||
tokenHash,
|
||||
tokenPrefix,
|
||||
expiresAt: expiresAt || null
|
||||
}).returning();
|
||||
|
||||
return {
|
||||
token: rawToken,
|
||||
id: result[0].id,
|
||||
tokenPrefix
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a Bearer token and return the associated user.
|
||||
* Uses cache to avoid Argon2id on every request.
|
||||
*/
|
||||
export async function validateApiToken(rawToken: string): Promise<AuthenticatedUser | null> {
|
||||
// Input validation
|
||||
if (!rawToken || rawToken.length > MAX_TOKEN_LENGTH || !rawToken.startsWith(TOKEN_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
ensureCleanupInterval();
|
||||
const key = cacheKey(rawToken);
|
||||
const cached = tokenCache.get(key);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.user;
|
||||
}
|
||||
|
||||
const table = await getApiTokensTable();
|
||||
|
||||
// Extract prefix for lookup
|
||||
const prefix = rawToken.substring(TOKEN_PREFIX.length, TOKEN_PREFIX.length + PREFIX_LENGTH);
|
||||
|
||||
// Find tokens with matching prefix (deleted tokens are gone, no isActive filter needed)
|
||||
const candidates = await db
|
||||
.select()
|
||||
.from(table)
|
||||
.where(eq(table.tokenPrefix, prefix));
|
||||
|
||||
if (candidates.length === 0) {
|
||||
// Timing protection: run Argon2id anyway
|
||||
await verifyPassword(rawToken, await getDummyHash());
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify against each candidate (usually just one)
|
||||
for (const candidate of candidates) {
|
||||
const valid = await verifyPassword(rawToken, candidate.tokenHash);
|
||||
if (!valid) continue;
|
||||
|
||||
// Check expiration AFTER hash verification to avoid timing oracle
|
||||
if (candidate.expiresAt && new Date(candidate.expiresAt) < new Date()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build AuthenticatedUser from the token's user
|
||||
const user = await buildUserFromToken(candidate);
|
||||
if (!user) continue;
|
||||
|
||||
// Update lastUsed (fire and forget — non-critical audit field)
|
||||
void db.update(table)
|
||||
.set({ lastUsed: new Date().toISOString() })
|
||||
.where(eq(table.id, candidate.id))
|
||||
.catch((err) => {
|
||||
if (typeof process !== 'undefined' && process.env.DB_VERBOSE_LOGGING === 'true') {
|
||||
console.debug('[api-tokens] lastUsed update failed:', err?.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Cache the result — cap TTL at token expiry time if sooner
|
||||
let cacheTtl = CACHE_TTL;
|
||||
if (candidate.expiresAt) {
|
||||
const timeUntilExpiry = new Date(candidate.expiresAt).getTime() - Date.now();
|
||||
if (timeUntilExpiry < cacheTtl) {
|
||||
cacheTtl = Math.max(0, timeUntilExpiry);
|
||||
}
|
||||
}
|
||||
tokenCache.set(key, { user, expiresAt: Date.now() + cacheTtl });
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an AuthenticatedUser from a token's database record.
|
||||
*/
|
||||
async function buildUserFromToken(tokenRecord: any): Promise<AuthenticatedUser | null> {
|
||||
// Import getUserWithoutPassword dynamically to avoid circular deps
|
||||
// This avoids keeping passwordHash in memory unnecessarily
|
||||
const { getUserWithoutPassword } = await import('./db');
|
||||
|
||||
const dbUser = await getUserWithoutPassword(tokenRecord.userId);
|
||||
if (!dbUser || !dbUser.isActive) return null;
|
||||
|
||||
const enterprise = await isEnterprise();
|
||||
let isAdmin = false;
|
||||
let permissions: Permissions;
|
||||
|
||||
if (!enterprise) {
|
||||
// Free edition: everyone is effectively admin
|
||||
isAdmin = true;
|
||||
const { getRoleByName } = await import('./db');
|
||||
const adminRole = await getRoleByName('Admin');
|
||||
permissions = adminRole?.permissions ?? {} as Permissions;
|
||||
} else {
|
||||
isAdmin = await userHasAdminRole(dbUser.id);
|
||||
const roles = await getUserRoles(dbUser.id);
|
||||
// Merge permissions from all roles
|
||||
permissions = {} as Permissions;
|
||||
for (const role of roles) {
|
||||
const rolePerms = typeof role.permissions === 'string'
|
||||
? JSON.parse(role.permissions)
|
||||
: role.permissions;
|
||||
for (const [key, actions] of Object.entries(rolePerms)) {
|
||||
if (!permissions[key as keyof Permissions]) {
|
||||
permissions[key as keyof Permissions] = [];
|
||||
}
|
||||
for (const action of actions as string[]) {
|
||||
if (!permissions[key as keyof Permissions].includes(action)) {
|
||||
permissions[key as keyof Permissions].push(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine provider from authProvider field
|
||||
let provider: 'local' | 'ldap' | 'oidc' = 'local';
|
||||
if (dbUser.authProvider?.startsWith('ldap')) provider = 'ldap';
|
||||
else if (dbUser.authProvider?.startsWith('oidc')) provider = 'oidc';
|
||||
|
||||
return {
|
||||
id: dbUser.id,
|
||||
username: dbUser.username,
|
||||
email: dbUser.email ?? undefined,
|
||||
displayName: dbUser.displayName ?? undefined,
|
||||
avatar: dbUser.avatar ?? undefined,
|
||||
isAdmin,
|
||||
provider,
|
||||
permissions
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all tokens for a user (no hashes returned).
|
||||
*/
|
||||
export async function listUserTokens(userId: number) {
|
||||
const table = await getApiTokensTable();
|
||||
return db
|
||||
.select({
|
||||
id: table.id,
|
||||
name: table.name,
|
||||
tokenPrefix: table.tokenPrefix,
|
||||
lastUsed: table.lastUsed,
|
||||
expiresAt: table.expiresAt,
|
||||
createdAt: table.createdAt
|
||||
})
|
||||
.from(table)
|
||||
.where(eq(table.userId, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (delete) a token. Owner or admin can revoke.
|
||||
*/
|
||||
export async function revokeApiToken(tokenId: number, requestingUserId: number, isAdmin: boolean): Promise<boolean> {
|
||||
const table = await getApiTokensTable();
|
||||
|
||||
// Find the token
|
||||
const [token] = await db.select().from(table).where(eq(table.id, tokenId));
|
||||
if (!token) return false;
|
||||
|
||||
// Check ownership or admin
|
||||
if (token.userId !== requestingUserId && !isAdmin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hard-delete
|
||||
await db.delete(table).where(eq(table.id, tokenId));
|
||||
|
||||
// Clear cache — we can't map prefix to SHA-256 cache keys, so clear all
|
||||
clearTokenCache();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { isEnterprise } from './license';
|
||||
import { logAuditEvent, type AuditAction, type AuditEntityType, type AuditLogCreateData } from './db';
|
||||
import { authorize } from './authorize';
|
||||
import { getRequestContext } from './request-context';
|
||||
|
||||
export interface AuditContext {
|
||||
userId?: number | null;
|
||||
@@ -21,7 +22,8 @@ export interface AuditContext {
|
||||
* Extract audit context from a request event
|
||||
*/
|
||||
export async function getAuditContext(event: RequestEvent): Promise<AuditContext> {
|
||||
const auth = await authorize(event.cookies);
|
||||
const ctx = getRequestContext();
|
||||
const user = ctx?.user ?? (await authorize(event.cookies)).user;
|
||||
|
||||
// Get IP address from various headers (proxied requests)
|
||||
const forwardedFor = event.request.headers.get('x-forwarded-for');
|
||||
@@ -40,8 +42,8 @@ export async function getAuditContext(event: RequestEvent): Promise<AuditContext
|
||||
const userAgent = event.request.headers.get('user-agent') || null;
|
||||
|
||||
return {
|
||||
userId: auth.user?.id ?? null,
|
||||
username: auth.user?.username ?? 'anonymous',
|
||||
userId: user?.id ?? null,
|
||||
username: user?.username ?? 'anonymous',
|
||||
ipAddress,
|
||||
userAgent
|
||||
};
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
import { Client as LdapClient } from 'ldapts';
|
||||
import { isEnterprise } from './license';
|
||||
import { secureRandomBytes } from './crypto-fallback';
|
||||
import { invalidateTokenCacheForUser } from './token-cache';
|
||||
|
||||
// Session cookie name
|
||||
const SESSION_COOKIE_NAME = 'dockhand_session';
|
||||
@@ -735,6 +736,9 @@ async function tryLdapAuth(
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cached token permissions after role sync
|
||||
invalidateTokenCacheForUser(user.id);
|
||||
|
||||
if (!user.isActive) {
|
||||
return { success: false, error: 'Account is disabled' };
|
||||
}
|
||||
@@ -1449,6 +1453,9 @@ export async function handleOidcCallback(
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cached token permissions after role sync
|
||||
invalidateTokenCacheForUser(user.id);
|
||||
|
||||
if (!user.isActive) {
|
||||
return { success: false, error: 'Account is disabled' };
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import type { Permissions } from './db';
|
||||
import { getUserAccessibleEnvironments, userCanAccessEnvironment, userHasAdminRole } from './db';
|
||||
import { validateSession, isAuthEnabled, checkPermission, type AuthenticatedUser } from './auth';
|
||||
import { isEnterprise } from './license';
|
||||
import { getRequestContext } from './request-context';
|
||||
|
||||
export interface AuthorizationContext {
|
||||
/** Whether authentication is enabled globally */
|
||||
@@ -113,7 +114,10 @@ export interface AuthorizationContext {
|
||||
export async function authorize(cookies: Cookies): Promise<AuthorizationContext> {
|
||||
const authEnabled = await isAuthEnabled();
|
||||
const enterprise = await isEnterprise();
|
||||
const user = authEnabled ? await validateSession(cookies) : null;
|
||||
|
||||
// Try request context first (set by hook — handles both cookie and Bearer)
|
||||
const reqCtx = getRequestContext();
|
||||
const user = reqCtx?.user ?? (authEnabled ? await validateSession(cookies) : null);
|
||||
|
||||
// Determine admin status:
|
||||
// - Free edition: all authenticated users are effectively admins (full access)
|
||||
|
||||
+44
-1
@@ -1185,6 +1185,37 @@ export async function getUser(id: number): Promise<UserData | null> {
|
||||
return results[0] as UserData || null;
|
||||
}
|
||||
|
||||
export interface SafeUserData {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string | null;
|
||||
displayName: string | null;
|
||||
avatar: string | null;
|
||||
authProvider: string | null;
|
||||
mfaEnabled: boolean;
|
||||
isActive: boolean;
|
||||
lastLogin: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export async function getUserWithoutPassword(id: number): Promise<SafeUserData | null> {
|
||||
const results = await db.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
email: users.email,
|
||||
displayName: users.displayName,
|
||||
avatar: users.avatar,
|
||||
authProvider: users.authProvider,
|
||||
mfaEnabled: users.mfaEnabled,
|
||||
isActive: users.isActive,
|
||||
lastLogin: users.lastLogin,
|
||||
createdAt: users.createdAt,
|
||||
updatedAt: users.updatedAt
|
||||
}).from(users).where(eq(users.id, id));
|
||||
return results[0] as SafeUserData || null;
|
||||
}
|
||||
|
||||
export async function hasAdminUser(): Promise<boolean> {
|
||||
// Check if any user has the Admin role assigned
|
||||
const adminRole = await db.select().from(roles).where(eq(roles.name, 'Admin')).limit(1);
|
||||
@@ -3046,7 +3077,7 @@ export type AuditAction =
|
||||
export type AuditEntityType =
|
||||
| 'container' | 'image' | 'stack' | 'volume' | 'network'
|
||||
| 'user' | 'role' | 'settings' | 'environment' | 'registry' | 'git_repository' | 'git_credential'
|
||||
| 'config_set' | 'notification' | 'oidc_provider' | 'ldap_config' | 'git_stack';
|
||||
| 'config_set' | 'notification' | 'oidc_provider' | 'ldap_config' | 'git_stack' | 'api_token';
|
||||
|
||||
export interface AuditLogData {
|
||||
id: number;
|
||||
@@ -4586,6 +4617,18 @@ export async function setStackEnvVars(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the set of secret key names for a stack.
|
||||
* Used to mask secret values in container inspect responses.
|
||||
*/
|
||||
export async function getSecretKeyNames(
|
||||
stackName: string,
|
||||
environmentId?: number | null
|
||||
): Promise<Set<string>> {
|
||||
const vars = await getStackEnvVars(stackName, environmentId, true);
|
||||
return new Set(vars.filter(v => v.isSecret).map(v => v.key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of environment variables for a stack.
|
||||
* @param stackName - Name of the stack
|
||||
|
||||
@@ -335,7 +335,8 @@ const REQUIRED_TABLES = [
|
||||
'audit_logs',
|
||||
'container_events',
|
||||
'schedule_executions',
|
||||
'user_preferences'
|
||||
'user_preferences',
|
||||
'api_tokens'
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -768,7 +769,7 @@ async function seedDatabase(): Promise<void> {
|
||||
license: ['manage'],
|
||||
audit_logs: ['view'],
|
||||
activity: ['view'],
|
||||
schedules: ['view']
|
||||
schedules: ['view', 'edit', 'run']
|
||||
});
|
||||
|
||||
const operatorPermissions = JSON.stringify({
|
||||
@@ -787,7 +788,7 @@ async function seedDatabase(): Promise<void> {
|
||||
license: [],
|
||||
audit_logs: [],
|
||||
activity: ['view'],
|
||||
schedules: ['view']
|
||||
schedules: ['view', 'edit', 'run']
|
||||
});
|
||||
|
||||
const viewerPermissions = JSON.stringify({
|
||||
@@ -898,6 +899,7 @@ export const userPreferences = schemaProxy.userPreferences;
|
||||
export const scheduleExecutions = schemaProxy.scheduleExecutions;
|
||||
export const stackEnvironmentVariables = schemaProxy.stackEnvironmentVariables;
|
||||
export const pendingContainerUpdates = schemaProxy.pendingContainerUpdates;
|
||||
export const apiTokens = schemaProxy.apiTokens;
|
||||
|
||||
// Re-export types from SQLite schema (they're compatible with PostgreSQL)
|
||||
export type {
|
||||
@@ -956,7 +958,9 @@ export type {
|
||||
StackEnvironmentVariable,
|
||||
NewStackEnvironmentVariable,
|
||||
PendingContainerUpdate,
|
||||
NewPendingContainerUpdate
|
||||
NewPendingContainerUpdate,
|
||||
ApiToken,
|
||||
NewApiToken
|
||||
} from './schema/index.js';
|
||||
|
||||
export { eq, and, or, desc, asc, like, sql, inArray, isNull, isNotNull } from 'drizzle-orm';
|
||||
|
||||
@@ -467,6 +467,25 @@ export const pendingContainerUpdates = sqliteTable('pending_container_updates',
|
||||
envContainerUnique: unique().on(table.environmentId, table.containerId)
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// API TOKENS TABLE
|
||||
// =============================================================================
|
||||
|
||||
export const apiTokens = sqliteTable('api_tokens', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
tokenHash: text('token_hash').notNull().unique(),
|
||||
tokenPrefix: text('token_prefix').notNull(),
|
||||
lastUsed: text('last_used'),
|
||||
expiresAt: text('expires_at'),
|
||||
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
|
||||
}, (table) => ({
|
||||
userIdIdx: index('api_tokens_user_id_idx').on(table.userId),
|
||||
tokenPrefixIdx: index('api_tokens_token_prefix_idx').on(table.tokenPrefix)
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// USER PREFERENCES TABLE (unified key-value store)
|
||||
// =============================================================================
|
||||
@@ -570,3 +589,6 @@ export type NewStackEnvironmentVariable = typeof stackEnvironmentVariables.$infe
|
||||
|
||||
export type PendingContainerUpdate = typeof pendingContainerUpdates.$inferSelect;
|
||||
export type NewPendingContainerUpdate = typeof pendingContainerUpdates.$inferInsert;
|
||||
|
||||
export type ApiToken = typeof apiTokens.$inferSelect;
|
||||
export type NewApiToken = typeof apiTokens.$inferInsert;
|
||||
@@ -470,6 +470,25 @@ export const pendingContainerUpdates = pgTable('pending_container_updates', {
|
||||
envContainerUnique: unique().on(table.environmentId, table.containerId)
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// API TOKENS TABLE
|
||||
// =============================================================================
|
||||
|
||||
export const apiTokens = pgTable('api_tokens', {
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
tokenHash: text('token_hash').notNull().unique(),
|
||||
tokenPrefix: text('token_prefix').notNull(),
|
||||
lastUsed: timestamp('last_used', { mode: 'string' }),
|
||||
expiresAt: timestamp('expires_at', { mode: 'string' }),
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
|
||||
}, (table) => ({
|
||||
userIdIdx: index('api_tokens_user_id_idx').on(table.userId),
|
||||
tokenPrefixIdx: index('api_tokens_token_prefix_idx').on(table.tokenPrefix)
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// USER PREFERENCES TABLE (unified key-value store)
|
||||
// =============================================================================
|
||||
|
||||
+22
-10
@@ -415,7 +415,8 @@ export function httpsAgentRequest(
|
||||
if (!streaming) {
|
||||
const isComposeOperation = path === '/_hawser/compose';
|
||||
const composeTimeoutMs = parseInt(process.env.COMPOSE_TIMEOUT || '900') * 1000;
|
||||
reqOptions.timeout = isComposeOperation ? composeTimeoutMs : 30000;
|
||||
const isPrune = path.endsWith('/prune');
|
||||
reqOptions.timeout = isComposeOperation ? composeTimeoutMs : isPrune ? 300000 : 30000;
|
||||
}
|
||||
|
||||
// Honor AbortSignal from caller (e.g., AbortSignal.timeout(5000) for ping)
|
||||
@@ -884,7 +885,7 @@ export async function dockerFetch(
|
||||
body,
|
||||
headers,
|
||||
streaming || false,
|
||||
(streaming || path === '/_hawser/compose') ? 300000 : 30000, // 5 min for streaming/compose, 30s for normal
|
||||
(streaming || path === '/_hawser/compose' || path.endsWith('/prune')) ? 300000 : 30000, // 5 min for streaming/compose/prune, 30s for normal
|
||||
isBinary,
|
||||
fetchOptions.signal ?? undefined
|
||||
);
|
||||
@@ -960,7 +961,8 @@ export async function dockerFetch(
|
||||
if (!streaming && !finalOptions.signal) {
|
||||
const isComposeOperation = path === '/_hawser/compose';
|
||||
const composeTimeoutMs = parseInt(process.env.COMPOSE_TIMEOUT || '900') * 1000;
|
||||
finalOptions.signal = AbortSignal.timeout(isComposeOperation ? composeTimeoutMs : 30000);
|
||||
const isPrune = path.endsWith('/prune');
|
||||
finalOptions.signal = AbortSignal.timeout(isComposeOperation ? composeTimeoutMs : isPrune ? 300000 : 30000);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1225,9 +1227,9 @@ export interface DeviceRequest {
|
||||
export interface CreateContainerOptions {
|
||||
name: string;
|
||||
image: string;
|
||||
ports?: { [key: string]: { HostIp?: string; HostPort: string } };
|
||||
ports?: { [key: string]: { HostIp?: string; HostPort: string } } | null;
|
||||
volumes?: { [key: string]: {} };
|
||||
volumeBinds?: string[];
|
||||
volumeBinds?: string[] | null;
|
||||
env?: string[];
|
||||
labels?: { [key: string]: string };
|
||||
cmd?: string[];
|
||||
@@ -1247,7 +1249,7 @@ export interface CreateContainerOptions {
|
||||
networkGwPriority?: number;
|
||||
user?: string | null;
|
||||
privileged?: boolean;
|
||||
healthcheck?: HealthcheckConfig;
|
||||
healthcheck?: HealthcheckConfig | null;
|
||||
memory?: number;
|
||||
memoryReservation?: number;
|
||||
memorySwap?: number;
|
||||
@@ -1338,7 +1340,10 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
||||
containerConfig.User = options.user ?? '';
|
||||
}
|
||||
|
||||
if (options.healthcheck) {
|
||||
if (options.healthcheck === null) {
|
||||
// Explicitly disable healthcheck (user cleared it)
|
||||
containerConfig.Healthcheck = { Test: ["NONE"] };
|
||||
} else if (options.healthcheck) {
|
||||
containerConfig.Healthcheck = {};
|
||||
if (options.healthcheck.test && options.healthcheck.test.length > 0) {
|
||||
containerConfig.Healthcheck.Test = options.healthcheck.test;
|
||||
@@ -1357,7 +1362,11 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
||||
}
|
||||
}
|
||||
|
||||
if (options.ports) {
|
||||
if (options.ports === null) {
|
||||
// Explicitly clear ports (user removed all mappings)
|
||||
containerConfig.ExposedPorts = {};
|
||||
containerConfig.HostConfig.PortBindings = {};
|
||||
} else if (options.ports) {
|
||||
containerConfig.ExposedPorts = {};
|
||||
containerConfig.HostConfig.PortBindings = {};
|
||||
|
||||
@@ -1367,7 +1376,10 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
||||
}
|
||||
}
|
||||
|
||||
if (options.volumeBinds && options.volumeBinds.length > 0) {
|
||||
if (options.volumeBinds === null) {
|
||||
// Explicitly clear volume binds (user removed all)
|
||||
containerConfig.HostConfig.Binds = [];
|
||||
} else if (options.volumeBinds && options.volumeBinds.length > 0) {
|
||||
containerConfig.HostConfig.Binds = options.volumeBinds;
|
||||
}
|
||||
|
||||
@@ -2393,7 +2405,7 @@ export async function updateContainer(id: string, options: Partial<CreateContain
|
||||
}
|
||||
|
||||
// 5. Start if needed
|
||||
if (startAfterUpdate || wasRunning) {
|
||||
if (startAfterUpdate) {
|
||||
try {
|
||||
await newContainer.start();
|
||||
} catch (startError) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Request-scoped context using AsyncLocalStorage.
|
||||
*
|
||||
* The hook sets the authenticated user (from cookie or Bearer token)
|
||||
* and wraps resolve() in requestContext.run(). Downstream code like
|
||||
* authorize() reads the pre-resolved user from here instead of
|
||||
* re-validating the session.
|
||||
*/
|
||||
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import type { AuthenticatedUser } from './auth';
|
||||
|
||||
export interface RequestContext {
|
||||
user: AuthenticatedUser | null;
|
||||
authEnabled: boolean;
|
||||
authMethod: 'cookie' | 'bearer' | 'none';
|
||||
}
|
||||
|
||||
export const requestContext = new AsyncLocalStorage<RequestContext>();
|
||||
|
||||
export function getRequestContext(): RequestContext | undefined {
|
||||
return requestContext.getStore();
|
||||
}
|
||||
@@ -752,9 +752,10 @@ async function loginToRegistries(dockerHost?: string, logPrefix = '[Stack]', api
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract registry host from URL
|
||||
const url = new URL(reg.url);
|
||||
const registryHost = url.host;
|
||||
// Extract registry host from URL (parseRegistryUrl handles bare hostnames like 'ghcr.io')
|
||||
const { parseRegistryUrl } = await import('./docker.js');
|
||||
const { host } = parseRegistryUrl(reg.url);
|
||||
const registryHost = host;
|
||||
|
||||
console.log(`${logPrefix} Logging into registry: ${registryHost}`);
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* API Token Cache — LRU with TTL
|
||||
*
|
||||
* In-memory cache for validated API tokens. Without this, every Bearer
|
||||
* request would run Argon2id verification (~64MB RAM, ~100ms CPU per call).
|
||||
*
|
||||
* Cache key is SHA-256(fullToken) — safe to hold in memory (not reversible).
|
||||
* TTL is 60s by default, capped at the token's expiry time if sooner.
|
||||
*
|
||||
* Uses a bounded LRU (max 1024 entries) to cap memory usage. On overflow
|
||||
* the least-recently-used entry is evicted. Expired entries are also pruned
|
||||
* every 5 minutes.
|
||||
*
|
||||
* Separated from api-tokens.ts to avoid circular dependencies
|
||||
* (auth.ts ↔ api-tokens.ts).
|
||||
*/
|
||||
|
||||
export interface TokenCacheEntry {
|
||||
user: { id: number; [key: string]: any };
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const MAX_CACHE_SIZE = 1024;
|
||||
|
||||
/**
|
||||
* Simple LRU cache backed by a Map.
|
||||
* Map iteration order is insertion order, so we delete-and-re-set on every
|
||||
* access to move the entry to the "newest" position. The oldest entry
|
||||
* (Map.keys().next()) is evicted when the cache exceeds MAX_CACHE_SIZE.
|
||||
*/
|
||||
class LruTokenCache {
|
||||
private map = new Map<string, TokenCacheEntry>();
|
||||
|
||||
get(key: string): TokenCacheEntry | undefined {
|
||||
const entry = this.map.get(key);
|
||||
if (!entry) return undefined;
|
||||
// Move to end (most-recently-used)
|
||||
this.map.delete(key);
|
||||
this.map.set(key, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
set(key: string, entry: TokenCacheEntry): void {
|
||||
// If key already exists, delete first so re-insert moves it to end
|
||||
if (this.map.has(key)) {
|
||||
this.map.delete(key);
|
||||
}
|
||||
this.map.set(key, entry);
|
||||
// Evict oldest if over capacity
|
||||
if (this.map.size > MAX_CACHE_SIZE) {
|
||||
const oldest = this.map.keys().next().value!;
|
||||
this.map.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
this.map.delete(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.map.clear();
|
||||
}
|
||||
|
||||
entries(): IterableIterator<[string, TokenCacheEntry]> {
|
||||
return this.map.entries();
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.map.size;
|
||||
}
|
||||
}
|
||||
|
||||
export const tokenCache = new LruTokenCache();
|
||||
|
||||
/**
|
||||
* Invalidate all cached tokens for a specific user.
|
||||
* Call when user permissions change, roles are updated, or user is deleted/deactivated.
|
||||
*/
|
||||
export function invalidateTokenCacheForUser(userId: number): void {
|
||||
for (const [key, entry] of tokenCache.entries()) {
|
||||
if (entry.user.id === userId) {
|
||||
tokenCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the entire token cache.
|
||||
* Called on revocation, role permission edits, and license state changes.
|
||||
*/
|
||||
export function clearTokenCache(): void {
|
||||
tokenCache.clear();
|
||||
}
|
||||
|
||||
// Periodic cleanup every 5 minutes
|
||||
let cleanupInterval: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
export function ensureCleanupInterval(): void {
|
||||
if (cleanupInterval) return;
|
||||
cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of tokenCache.entries()) {
|
||||
if (entry.expiresAt <= now) {
|
||||
tokenCache.delete(key);
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
if (cleanupInterval.unref) cleanupInterval.unref();
|
||||
}
|
||||
Reference in new issue
Block a user