1.0.34
This commit is contained in:
56 files changed
+9101
-117
No files matched your search
+31
-4
@@ -137,6 +137,14 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
|
||||
}
|
||||
}
|
||||
|
||||
let dummyAuthHashCache: Promise<string> | null = null;
|
||||
export function getDummyAuthHash(): Promise<string> {
|
||||
if (!dummyAuthHashCache) {
|
||||
dummyAuthHashCache = hashPassword(`dummy-${Math.random()}-${Date.now()}`);
|
||||
}
|
||||
return dummyAuthHashCache;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Session Management
|
||||
// ============================================
|
||||
@@ -241,11 +249,22 @@ function getSessionIdFromCookies(cookies: Cookies): string | null {
|
||||
export async function validateSession(cookies: Cookies): Promise<AuthenticatedUser | null> {
|
||||
const sessionId = getSessionIdFromCookies(cookies);
|
||||
if (!sessionId) return null;
|
||||
return validateSessionById(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a session by raw session ID (without the SvelteKit Cookies object).
|
||||
*
|
||||
* Used by WebSocket upgrade handlers in server.js / vite.config.ts that only
|
||||
* have a raw Cookie header string. Mirrors validateSession() semantics:
|
||||
* returns the AuthenticatedUser on success, null on missing/expired/disabled.
|
||||
*/
|
||||
export async function validateSessionById(sessionId: string): Promise<AuthenticatedUser | null> {
|
||||
if (!sessionId) return null;
|
||||
|
||||
const session = await dbGetSession(sessionId);
|
||||
if (!session) return null;
|
||||
|
||||
// Check if session is expired
|
||||
const expiresAt = new Date(session.expiresAt);
|
||||
if (expiresAt < new Date()) {
|
||||
await dbDeleteSession(sessionId);
|
||||
@@ -258,6 +277,13 @@ export async function validateSession(cookies: Cookies): Promise<AuthenticatedUs
|
||||
return await buildAuthenticatedUser(user, session.provider as 'local' | 'ldap' | 'oidc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie name used for browser session auth. Exported so raw header parsers
|
||||
* (WebSocket upgrade handlers) can look it up without re-encoding the
|
||||
* constant.
|
||||
*/
|
||||
export const SESSION_COOKIE = SESSION_COOKIE_NAME;
|
||||
|
||||
/**
|
||||
* Destroy a session (logout)
|
||||
*/
|
||||
@@ -461,13 +487,14 @@ export async function authenticateLocal(
|
||||
const user = await getUserByUsername(username);
|
||||
|
||||
if (!user) {
|
||||
// Use constant time to prevent timing attacks
|
||||
await hashPassword('dummy');
|
||||
await verifyPassword(password, await getDummyAuthHash());
|
||||
return { success: false, error: 'Invalid username or password' };
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
return { success: false, error: 'Account is disabled' };
|
||||
await verifyPassword(password, await getDummyAuthHash());
|
||||
console.warn(`[Auth] Login attempt for disabled account: user=${username}`);
|
||||
return { success: false, error: 'Invalid username or password' };
|
||||
}
|
||||
|
||||
const validPassword = await verifyPassword(password, user.passwordHash);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Resolve the client IP for rate limiting, logging, and audit.
|
||||
*
|
||||
* Defaults to the socket-level IP via getClientAddress(). X-Forwarded-For
|
||||
* is consulted only when TRUST_FORWARDED_HEADERS=true is set explicitly —
|
||||
* intended for deployments behind a reverse proxy (Traefik, nginx, Caddy)
|
||||
* that controls XFF. In that mode the right-most XFF entry (closest to the
|
||||
* trusted proxy) is returned; earlier entries in the chain are ignored.
|
||||
*/
|
||||
|
||||
type IpEventLike = {
|
||||
request: Request;
|
||||
getClientAddress?: () => string;
|
||||
};
|
||||
|
||||
function normalize(ip: string | null | undefined): string {
|
||||
if (!ip) return 'unknown';
|
||||
if (ip === '::1' || ip === '::ffff:127.0.0.1') return '127.0.0.1';
|
||||
if (ip.startsWith('::ffff:')) return ip.substring(7);
|
||||
return ip;
|
||||
}
|
||||
|
||||
export function getClientIp(event: IpEventLike): string {
|
||||
if (process.env.TRUST_FORWARDED_HEADERS === 'true') {
|
||||
const xff = event.request.headers.get('x-forwarded-for');
|
||||
if (xff) {
|
||||
const parts = xff.split(',').map((p) => p.trim()).filter(Boolean);
|
||||
if (parts.length > 0) return normalize(parts[parts.length - 1]);
|
||||
}
|
||||
const realIp = event.request.headers.get('x-real-ip');
|
||||
if (realIp) return normalize(realIp.trim());
|
||||
}
|
||||
|
||||
try {
|
||||
const addr = event.getClientAddress?.();
|
||||
if (addr) return normalize(addr);
|
||||
} catch {
|
||||
// getClientAddress may throw if unavailable (test contexts, raw upgrades)
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
@@ -4334,6 +4334,17 @@ export async function setExternalStackPaths(paths: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently add a directory to the external stack paths allowlist.
|
||||
* Returns true if the path was newly added (false if already present).
|
||||
*/
|
||||
export async function addExternalStackPath(dir: string): Promise<boolean> {
|
||||
const current = await getExternalStackPaths();
|
||||
if (current.includes(dir)) return false;
|
||||
await setExternalStackPaths([...current, dir]);
|
||||
return true;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PRIMARY STACK LOCATION
|
||||
// =============================================================================
|
||||
|
||||
@@ -769,7 +769,8 @@ async function seedDatabase(): Promise<void> {
|
||||
license: ['manage'],
|
||||
audit_logs: ['view'],
|
||||
activity: ['view'],
|
||||
schedules: ['view', 'edit', 'run']
|
||||
schedules: ['view', 'edit', 'run'],
|
||||
templates: ['view', 'deploy', 'manage']
|
||||
});
|
||||
|
||||
const operatorPermissions = JSON.stringify({
|
||||
@@ -788,7 +789,8 @@ async function seedDatabase(): Promise<void> {
|
||||
license: [],
|
||||
audit_logs: [],
|
||||
activity: ['view'],
|
||||
schedules: ['view', 'edit', 'run']
|
||||
schedules: ['view', 'edit', 'run'],
|
||||
templates: ['view', 'deploy']
|
||||
});
|
||||
|
||||
const viewerPermissions = JSON.stringify({
|
||||
@@ -807,9 +809,31 @@ async function seedDatabase(): Promise<void> {
|
||||
license: [],
|
||||
audit_logs: [],
|
||||
activity: ['view'],
|
||||
schedules: ['view']
|
||||
schedules: ['view'],
|
||||
templates: ['view']
|
||||
});
|
||||
|
||||
// Seed template sources if table is empty
|
||||
const existingTemplateSources = await db.select().from(schema.templateSources);
|
||||
if (existingTemplateSources.length === 0) {
|
||||
// Inline defaults to avoid circular dependency (library.ts imports db/drizzle)
|
||||
const defaultSources = [
|
||||
{ sourceId: 'portainer-lissy93', name: 'Portainer templates (Lissy93)', url: 'https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json', enabled: true, builtin: true, sortOrder: 0 },
|
||||
{ sourceId: 'ntv-one', name: 'NTV-One (consolidated)', url: 'https://raw.githubusercontent.com/ntv-one/portainer/main/template.json', enabled: false, builtin: true, sortOrder: 1 },
|
||||
{ sourceId: 'mlva', name: 'MLVA (TheLustriVA)', url: 'https://raw.githubusercontent.com/TheLustriVA/portainer-templates-Nov-2022-collection/main/templates_2_2_rc_2_2.json', enabled: false, builtin: true, sortOrder: 2 },
|
||||
{ sourceId: 'selfhostedpro', name: 'SelfHostedPro', url: 'https://raw.githubusercontent.com/SelfhostedPro/selfhosted_templates/master/Template/portainer-v2.json', enabled: false, builtin: true, sortOrder: 3 },
|
||||
{ sourceId: 'portainer-qballjos', name: 'Qballjos (homelab)', url: 'https://raw.githubusercontent.com/Qballjos/portainer_templates/master/Template/template.json', enabled: false, builtin: true, sortOrder: 4 },
|
||||
{ sourceId: 'lsio-technorabilia', name: 'LinuxServer.io (Technorabilia)', url: 'https://raw.githubusercontent.com/technorabilia/portainer-templates/main/lsio/templates/templates.json', enabled: true, builtin: true, sortOrder: 5 },
|
||||
{ sourceId: 'mikestraney', name: 'MikeStraney', url: 'https://raw.githubusercontent.com/mikestraney/portainer-templates/master/templates.json', enabled: false, builtin: true, sortOrder: 6 },
|
||||
{ sourceId: 'pi-hosted-amd64', name: 'Pi-Hosted (amd64)', url: 'https://raw.githubusercontent.com/pi-hosted/pi-hosted/master/template/portainer-v2-amd64.json', enabled: false, builtin: true, sortOrder: 7 },
|
||||
{ sourceId: 'pi-hosted-arm64', name: 'Pi-Hosted (arm64)', url: 'https://raw.githubusercontent.com/pi-hosted/pi-hosted/master/template/portainer-v2-arm64.json', enabled: false, builtin: true, sortOrder: 8 },
|
||||
];
|
||||
for (const source of defaultSources) {
|
||||
await db.insert(schema.templateSources).values(source);
|
||||
}
|
||||
logStep('Created default template sources');
|
||||
}
|
||||
|
||||
const existingRoles = await db.select().from(schema.roles);
|
||||
if (existingRoles.length === 0) {
|
||||
await db.insert(schema.roles).values([
|
||||
|
||||
@@ -505,6 +505,19 @@ export const userPreferences = sqliteTable('user_preferences', {
|
||||
unique().on(table.userId, table.environmentId, table.key)
|
||||
]);
|
||||
|
||||
// Template sources
|
||||
export const templateSources = sqliteTable('template_sources', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
sourceId: text('source_id').notNull().unique(), // stable identifier (e.g., 'portainer-lissy93')
|
||||
name: text('name').notNull(),
|
||||
url: text('url').notNull(),
|
||||
enabled: integer('enabled', { mode: 'boolean' }).default(true),
|
||||
builtin: integer('builtin', { mode: 'boolean' }).default(false),
|
||||
sortOrder: integer('sort_order').default(0),
|
||||
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// TYPE EXPORTS
|
||||
// =============================================================================
|
||||
|
||||
@@ -507,3 +507,16 @@ export const userPreferences = pgTable('user_preferences', {
|
||||
}, (table) => [
|
||||
unique().on(table.userId, table.environmentId, table.key)
|
||||
]);
|
||||
|
||||
// Template sources
|
||||
export const templateSources = pgTable('template_sources', {
|
||||
id: serial('id').primaryKey(),
|
||||
sourceId: text('source_id').notNull().unique(), // stable identifier (e.g., 'portainer-lissy93')
|
||||
name: text('name').notNull(),
|
||||
url: text('url').notNull(),
|
||||
enabled: boolean('enabled').default(true),
|
||||
builtin: boolean('builtin').default(false),
|
||||
sortOrder: integer('sort_order').default(0),
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
|
||||
});
|
||||
@@ -5086,18 +5086,20 @@ export async function listContainerDirectory(
|
||||
// Sanitize path to prevent command injection
|
||||
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
||||
|
||||
// Commands to try in order of preference
|
||||
// Commands to try in order of preference (includes /usr/sbin/ls for Wolfi/busybox images)
|
||||
const commands = useSimpleLs
|
||||
? [
|
||||
['ls', '-la', safePath],
|
||||
['/bin/ls', '-la', safePath],
|
||||
['/usr/bin/ls', '-la', safePath],
|
||||
['/usr/sbin/ls', '-la', safePath],
|
||||
]
|
||||
: [
|
||||
['ls', '-la', '--time-style=long-iso', safePath],
|
||||
['ls', '-la', safePath],
|
||||
['/bin/ls', '-la', safePath],
|
||||
['/usr/bin/ls', '-la', safePath],
|
||||
['/usr/sbin/ls', '-la', safePath],
|
||||
];
|
||||
|
||||
let lastError: Error | null = null;
|
||||
@@ -5180,7 +5182,7 @@ export async function statContainerPath(
|
||||
containerId: string,
|
||||
path: string,
|
||||
envId?: number | null
|
||||
): Promise<{ name: string; size: number; mode: number; mtime: string; linkTarget?: string }> {
|
||||
): Promise<{ name: string; size: number; mode: number; mtime: string; linkTarget?: string; isDir: boolean }> {
|
||||
// Sanitize path
|
||||
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
||||
|
||||
@@ -5202,7 +5204,10 @@ export async function statContainerPath(
|
||||
}
|
||||
|
||||
const statJson = Buffer.from(statHeader, 'base64').toString('utf-8');
|
||||
return JSON.parse(statJson);
|
||||
const stat = JSON.parse(statJson);
|
||||
// Go's os.FileMode encodes the file type in the high bits. ModeDir = 1<<31.
|
||||
// Docker emits mode in that format, so the directory bit lives at 0x80000000.
|
||||
return { ...stat, isDir: (stat.mode & 0x80000000) !== 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5755,6 +5760,22 @@ export async function getVolumeArchive(
|
||||
// Note: Container is kept alive for reuse. Cache TTL will handle cleanup.
|
||||
}
|
||||
|
||||
/**
|
||||
* Stat a path inside a volume via the helper container.
|
||||
* Returns the same shape as statContainerPath (#1180 raw download needs isDir).
|
||||
*/
|
||||
export async function statVolumePath(
|
||||
volumeName: string,
|
||||
path: string,
|
||||
envId?: number | null,
|
||||
readOnly: boolean = true
|
||||
): Promise<{ name: string; size: number; mode: number; mtime: string; linkTarget?: string; isDir: boolean }> {
|
||||
const containerId = await getOrCreateVolumeHelperContainer(volumeName, envId, readOnly);
|
||||
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
||||
const fullPath = `${VOLUME_MOUNT_PATH}${safePath.startsWith('/') ? safePath : '/' + safePath}`;
|
||||
return statContainerPath(containerId, fullPath, envId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file content from volume
|
||||
* Uses cached helper containers for better performance.
|
||||
|
||||
+31
-11
@@ -265,6 +265,16 @@ export async function handleEdgeMetrics(
|
||||
// Register global handler for metrics
|
||||
globalThis.__hawserHandleMetrics = handleEdgeMetrics;
|
||||
|
||||
let dummyHawserHash: string | null = null;
|
||||
async function getDummyHawserHash(): Promise<string> {
|
||||
if (!dummyHawserHash) {
|
||||
dummyHawserHash = await hashPassword('hawser_init_seed');
|
||||
}
|
||||
return dummyHawserHash;
|
||||
}
|
||||
// Warm the lazy init so first-call latency is consistent.
|
||||
getDummyHawserHash().catch(() => {});
|
||||
|
||||
/**
|
||||
* Validate a Hawser token
|
||||
*/
|
||||
@@ -279,22 +289,32 @@ export async function validateHawserToken(
|
||||
.from(hawserTokens)
|
||||
.where(and(eq(hawserTokens.tokenPrefix, prefix), eq(hawserTokens.isActive, true)));
|
||||
|
||||
if (candidates.length === 0) {
|
||||
await verifyPassword(token, await getDummyHawserHash());
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
for (const t of candidates) {
|
||||
try {
|
||||
const isValid = await verifyPassword(token, t.token);
|
||||
if (isValid) {
|
||||
// Update last used timestamp
|
||||
await db
|
||||
.update(hawserTokens)
|
||||
.set({ lastUsed: new Date().toISOString() })
|
||||
.where(eq(hawserTokens.id, t.id));
|
||||
if (!isValid) continue;
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
environmentId: t.environmentId ?? undefined,
|
||||
tokenId: t.id
|
||||
};
|
||||
// Expiry check intentionally runs after the hash verify.
|
||||
if (t.expiresAt && new Date(t.expiresAt) < new Date()) {
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
// Update last used timestamp
|
||||
await db
|
||||
.update(hawserTokens)
|
||||
.set({ lastUsed: new Date().toISOString() })
|
||||
.where(eq(hawserTokens.id, t.id));
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
environmentId: t.environmentId ?? undefined,
|
||||
tokenId: t.id
|
||||
};
|
||||
} catch {
|
||||
// Invalid hash format, skip
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ let cachedMounts: Array<{ source: string; destination: string }> | null = null;
|
||||
// Used by scanner to replicate how Dockhand connects to Docker
|
||||
let cachedOwnDockerHost: string | null = null;
|
||||
let cachedOwnNetworkMode: string | null = null;
|
||||
let cachedOwnAllNetworks: string[] | null = null;
|
||||
let cachedOwnExtraHosts: string[] | null = null;
|
||||
|
||||
/**
|
||||
@@ -166,7 +167,10 @@ export async function detectHostDataDir(): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// Cache Dockhand's network (prefer non-default for service discovery)
|
||||
// Cache Dockhand's networks. Picks one as the primary networkMode
|
||||
// (custom net first, falling back to bridge) and keeps the full list
|
||||
// so callers can warn when a setup is fragile — e.g. socket-proxy
|
||||
// living on a network other than the one the scanner joins (#1011).
|
||||
const networks = containerInfo.NetworkSettings?.Networks;
|
||||
if (networks) {
|
||||
const custom = Object.keys(networks).filter(
|
||||
@@ -174,8 +178,9 @@ export async function detectHostDataDir(): Promise<string | null> {
|
||||
);
|
||||
cachedOwnNetworkMode = custom.length > 0 ? custom[0]
|
||||
: networks.bridge ? 'bridge' : null;
|
||||
cachedOwnAllNetworks = Object.keys(networks);
|
||||
if (cachedOwnNetworkMode) {
|
||||
console.log(`[HostPath] Detected own network: ${cachedOwnNetworkMode}`);
|
||||
console.log(`[HostPath] Detected own network: ${cachedOwnNetworkMode} (all: ${cachedOwnAllNetworks.join(', ')})`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +250,15 @@ export function getOwnNetworkMode(): string | null {
|
||||
return cachedOwnNetworkMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* All Docker networks Dockhand itself is attached to. The scanner uses
|
||||
* this to detect split-network setups and warn that socket-proxy may not
|
||||
* be reachable from the network it actually joins (#1011).
|
||||
*/
|
||||
export function getOwnAllNetworks(): string[] {
|
||||
return cachedOwnAllNetworks ? [...cachedOwnAllNetworks] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ExtraHosts entries configured on Dockhand itself.
|
||||
* Used to mirror host aliases into sibling sidecar containers.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Detect the on-host Docker socket path for a remote daemon (#1076).
|
||||
*
|
||||
* Used by the vulnerability scanner when it needs to bind-mount the daemon
|
||||
* socket into a helper container running on that daemon. Docker daemons use
|
||||
* /var/run/docker.sock; Podman uses /run/podman/podman.sock (rootful) or
|
||||
* /run/user/UID/podman/podman.sock (rootless). Hardcoding /var/run/docker.sock
|
||||
* breaks Podman with a mkdir-permission-denied error.
|
||||
*
|
||||
* Detection runs against the remote daemon over the same connection
|
||||
* Dockhand already uses (socket / direct TCP / Hawser), so no agent change
|
||||
* is required.
|
||||
*
|
||||
* Result is cached per envId for 5 minutes — daemon identity doesn't change
|
||||
* during a process lifetime in practice, but the short TTL lets us recover
|
||||
* if the user reconfigures an env to point at a different daemon.
|
||||
*/
|
||||
import { dockerFetch } from './docker';
|
||||
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const cache = new Map<number, { path: string; expires: number }>();
|
||||
|
||||
const DEFAULT_DOCKER_SOCKET = '/var/run/docker.sock';
|
||||
const PODMAN_ROOTFUL_SOCKET = '/run/podman/podman.sock';
|
||||
|
||||
export function clearRemoteSocketCache(envId?: number): void {
|
||||
if (envId === undefined) cache.clear();
|
||||
else cache.delete(envId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute path to the daemon's API socket on its own host.
|
||||
*
|
||||
* Best-effort: any failure falls back to /var/run/docker.sock, which matches
|
||||
* the historic behaviour and is correct for stock Docker.
|
||||
*/
|
||||
export async function detectRemoteSocketPath(envId: number | undefined): Promise<string> {
|
||||
if (envId === undefined) return DEFAULT_DOCKER_SOCKET;
|
||||
|
||||
const cached = cache.get(envId);
|
||||
if (cached && cached.expires > Date.now()) return cached.path;
|
||||
|
||||
let path = DEFAULT_DOCKER_SOCKET;
|
||||
try {
|
||||
const isPodman = await daemonIsPodman(envId);
|
||||
if (isPodman) {
|
||||
path = (await detectPodmanSocketPath(envId)) ?? PODMAN_ROOTFUL_SOCKET;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[Scanner] detectRemoteSocketPath(env=${envId}) failed, defaulting to ${DEFAULT_DOCKER_SOCKET}:`,
|
||||
(err as Error)?.message ?? err
|
||||
);
|
||||
}
|
||||
|
||||
cache.set(envId, { path, expires: Date.now() + CACHE_TTL_MS });
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the remote daemon identifies itself as Podman.
|
||||
* Used by both the scanner socket-path detection and the env list pill.
|
||||
* Any transport / parse failure returns false — callers treat "unknown"
|
||||
* as "assume Docker" so a transient network hiccup never breaks the UI.
|
||||
*/
|
||||
export async function daemonIsPodman(envId: number): Promise<boolean> {
|
||||
try {
|
||||
// Docker-compat /version returns Components[].Name. Podman labels
|
||||
// itself "Podman Engine"; Docker uses "Engine".
|
||||
const res = await dockerFetch('/version', {}, envId);
|
||||
if (!res.ok) return false;
|
||||
const data = (await res.json()) as { Components?: Array<{ Name?: string }> };
|
||||
const components = data.Components ?? [];
|
||||
return components.some((c) => typeof c?.Name === 'string' && c.Name.includes('Podman'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface PodmanLibpodInfo {
|
||||
host?: {
|
||||
security?: { rootless?: boolean };
|
||||
idMappings?: { uidmap?: Array<{ host_id?: number }> };
|
||||
};
|
||||
}
|
||||
|
||||
async function detectPodmanSocketPath(envId: number): Promise<string | null> {
|
||||
// Podman's native /libpod/info exposes rootless flag + uid mapping.
|
||||
// Versioned path: /v4.0.0/libpod/info works across all Podman 4.x/5.x.
|
||||
const res = await dockerFetch('/v4.0.0/libpod/info', {}, envId);
|
||||
if (!res.ok) return null;
|
||||
const info = (await res.json()) as PodmanLibpodInfo;
|
||||
|
||||
const isRootless = info.host?.security?.rootless === true;
|
||||
if (!isRootless) return PODMAN_ROOTFUL_SOCKET;
|
||||
|
||||
// The first uidmap entry's host_id is the user the daemon runs as.
|
||||
// Example uidmap: [{ container_id: 0, host_id: 1000, size: 1 }, ...]
|
||||
const uid = info.host?.idMappings?.uidmap?.[0]?.host_id;
|
||||
if (typeof uid !== 'number' || !Number.isInteger(uid) || uid < 0) {
|
||||
// No usable uid — leave it to the caller's default
|
||||
return null;
|
||||
}
|
||||
return `/run/user/${uid}/podman/podman.sock`;
|
||||
}
|
||||
@@ -16,13 +16,15 @@ import {
|
||||
} from './docker';
|
||||
import { getEnvironment, getEnvSetting, getSetting } from './db';
|
||||
import { sendEventNotification } from './notifications';
|
||||
import { detectRemoteSocketPath } from './scanner-socket-detect';
|
||||
import {
|
||||
getHostDockerSocket,
|
||||
getHostDataDir,
|
||||
extractUidFromSocketPath,
|
||||
getOwnDockerHost,
|
||||
getOwnExtraHosts,
|
||||
getOwnNetworkMode
|
||||
getOwnNetworkMode,
|
||||
getOwnAllNetworks
|
||||
} from './host-path';
|
||||
import { resolve } from 'node:path';
|
||||
import { mkdir, chown, rm } from 'node:fs/promises';
|
||||
@@ -632,7 +634,7 @@ async function runScannerContainerCore(
|
||||
let rootlessUid: string | undefined;
|
||||
let scannerNetworkMode: string | undefined;
|
||||
let scannerDockerHost: string | undefined;
|
||||
const scannerExtraHosts = !isHawser ? getOwnExtraHosts() ?? undefined : undefined;
|
||||
let scannerExtraHosts = !isHawser ? getOwnExtraHosts() ?? undefined : undefined;
|
||||
|
||||
// Check if Dockhand itself uses TCP to reach Docker (e.g., socket proxy).
|
||||
// Detected at startup from Dockhand's own container inspect data.
|
||||
@@ -641,9 +643,19 @@ async function runScannerContainerCore(
|
||||
const ownDockerHost = getOwnDockerHost();
|
||||
|
||||
if (!isHawser && ownDockerHost?.startsWith('tcp://')) {
|
||||
// TCP mode: scanner uses the same DOCKER_HOST + network as Dockhand
|
||||
// TCP mode: scanner uses the same DOCKER_HOST + network as Dockhand.
|
||||
scannerDockerHost = ownDockerHost;
|
||||
scannerNetworkMode = getOwnNetworkMode() ?? undefined;
|
||||
const allNets = getOwnAllNetworks();
|
||||
if (allNets.length > 1) {
|
||||
// Multiple custom networks — if socket-proxy lives on a network
|
||||
// other than the one we picked, DNS will fail. Make the choice
|
||||
// visible so users with split-network setups can colocate
|
||||
// socket-proxy with Dockhand on the primary network (#1011).
|
||||
console.warn(
|
||||
`[Scanner] Dockhand is on multiple networks (${allNets.join(', ')}); scanner will only join "${scannerNetworkMode}". If DOCKER_HOST=${scannerDockerHost} fails to resolve, put socket-proxy on this network.`
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`[Scanner] TCP mode (from container inspect) - DOCKER_HOST=${scannerDockerHost}, network=${scannerNetworkMode ?? 'default'}`
|
||||
);
|
||||
@@ -651,9 +663,35 @@ async function runScannerContainerCore(
|
||||
console.log(`[Scanner] Reusing ExtraHosts from Dockhand: ${scannerExtraHosts.join(', ')}`);
|
||||
}
|
||||
} else if (isHawser) {
|
||||
// Hawser: scanner runs on remote host, uses remote host's standard Docker socket
|
||||
hostSocketPath = '/var/run/docker.sock';
|
||||
console.log(`[Scanner] Remote scan via Hawser (${connectionType}) - using standard socket path`);
|
||||
// Hawser: scanner runs on remote host. Detect the actual socket path
|
||||
// because rootless Podman uses /run/user/UID/podman/podman.sock, not
|
||||
// /var/run/docker.sock (#1076). Falls back to the standard path on
|
||||
// detection failure — no regression for stock Docker hosts.
|
||||
hostSocketPath = await detectRemoteSocketPath(envId);
|
||||
console.log(`[Scanner] Remote scan via Hawser (${connectionType}) - detected socket path: ${hostSocketPath}`);
|
||||
} else if (connectionType === 'direct' && env?.host) {
|
||||
// Direct TCP to a remote daemon (e.g. Docker over TCP, Podman over TCP).
|
||||
// The scanner container is created on the REMOTE daemon, not on
|
||||
// Dockhand's host. Binding "/var/run/docker.sock" from Dockhand's
|
||||
// host into a remote-daemon container is nonsense — on remote Docker
|
||||
// it silently creates an empty dir (scans fall back to registry); on
|
||||
// rootless Podman it errors with "mkdir /var/run/docker.sock:
|
||||
// permission denied" (#1076, #1011). Instead, tell the scanner to
|
||||
// talk to the daemon over the same TCP endpoint Dockhand uses.
|
||||
// `host.containers.internal` resolves to the daemon's host from
|
||||
// inside the scanner container on both Docker (with `host-gateway`)
|
||||
// and Podman (built-in).
|
||||
scannerDockerHost = `tcp://host.containers.internal:${env.port}`;
|
||||
// Add the host-gateway mapping so Docker honours the hostname.
|
||||
// Podman recognises host.containers.internal natively; the extra
|
||||
// mapping is harmless there.
|
||||
scannerExtraHosts = [
|
||||
...(scannerExtraHosts ?? []),
|
||||
'host.containers.internal:host-gateway'
|
||||
];
|
||||
console.log(
|
||||
`[Scanner] Direct TCP env (${env.protocol ?? 'http'}://${env.host}:${env.port}) - DOCKER_HOST=${scannerDockerHost} (#1076, #1011)`
|
||||
);
|
||||
} else {
|
||||
// Local socket — detect host socket path (handles rootless Docker)
|
||||
hostSocketPath = getHostDockerSocket();
|
||||
|
||||
+157
-3
@@ -5,8 +5,8 @@
|
||||
* All lifecycle operations use docker compose commands.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve, dirname, basename } from 'node:path';
|
||||
import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync, realpathSync } from 'node:fs';
|
||||
import { join, resolve, dirname, basename, normalize as pathNormalize, sep as pathSep } from 'node:path';
|
||||
import { spawn as nodeSpawn } from 'node:child_process';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import {
|
||||
@@ -34,7 +34,9 @@ import {
|
||||
removePendingContainerUpdate,
|
||||
deleteAutoUpdateSchedule,
|
||||
getAutoUpdateSetting,
|
||||
getStackSourceByComposePath
|
||||
getStackSourceByComposePath,
|
||||
getExternalStackPaths,
|
||||
addExternalStackPath
|
||||
} from './db';
|
||||
import { unregisterSchedule } from './scheduler';
|
||||
import { deleteGitStackFiles, parseEnvFileContent } from './git';
|
||||
@@ -348,6 +350,125 @@ export async function getStackDir(stackName: string, envId?: number | null): Pro
|
||||
return join(stacksDir, stackName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filenames a stack is allowed to write. Compose files in the conventional
|
||||
* names + .env. Anything else is rejected even when the directory is in
|
||||
* the allowlist, so this code path can only ever produce stack-shaped files.
|
||||
*/
|
||||
const ALLOWED_STACK_FILENAMES = new Set([
|
||||
'docker-compose.yml',
|
||||
'docker-compose.yaml',
|
||||
'compose.yml',
|
||||
'compose.yaml',
|
||||
'.env'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Resolve a path against the parent's realpath when the parent exists, so
|
||||
* symlinks resolve to their canonical location. We can't realpath the leaf
|
||||
* because the file may not exist yet (new stack).
|
||||
*/
|
||||
function resolveStackPath(input: string): string {
|
||||
const abs = resolve(input);
|
||||
const parent = dirname(abs);
|
||||
try {
|
||||
if (existsSync(parent)) {
|
||||
return join(realpathSync(parent), basename(abs));
|
||||
}
|
||||
} catch {
|
||||
// realpath may fail on permission errors; fall through to the plain resolve.
|
||||
}
|
||||
return abs;
|
||||
}
|
||||
|
||||
function isInside(child: string, parent: string): boolean {
|
||||
const c = pathNormalize(child);
|
||||
const p = pathNormalize(parent);
|
||||
if (c === p) return true;
|
||||
return c.startsWith(p.endsWith(pathSep) ? p : p + pathSep);
|
||||
}
|
||||
|
||||
export interface StackPathValidation {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
resolved?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a custom compose or env file path is writable by this code
|
||||
* path. A path is accepted when it lives inside getStacksDir() or any
|
||||
* directory in the external_stack_paths allowlist (admin-controlled, set
|
||||
* in Settings → General), its filename is one of the conventional
|
||||
* compose/env names, and it does not contain .. segments. The parent is
|
||||
* resolved via realpath so a symlinked component inside an allowlisted dir
|
||||
* cannot point elsewhere.
|
||||
*
|
||||
* Callers that need to grandfather an existing admin-configured path should
|
||||
* call validateStackPathWithGrandfather() instead.
|
||||
*/
|
||||
export async function validateStackPath(input: string): Promise<StackPathValidation> {
|
||||
if (!input || typeof input !== 'string') {
|
||||
return { ok: false, error: 'Path is required' };
|
||||
}
|
||||
|
||||
const resolvedPath = resolveStackPath(input);
|
||||
|
||||
// Normalized form must not contain a .. segment.
|
||||
const segments = pathNormalize(resolvedPath).split(pathSep);
|
||||
if (segments.includes('..')) {
|
||||
return { ok: false, error: 'Path traversal not allowed' };
|
||||
}
|
||||
|
||||
const filename = basename(resolvedPath);
|
||||
if (!ALLOWED_STACK_FILENAMES.has(filename)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `File "${filename}" is not an allowed stack filename (expected one of: ${[...ALLOWED_STACK_FILENAMES].join(', ')})`
|
||||
};
|
||||
}
|
||||
|
||||
const stacksDir = getStacksDir();
|
||||
if (isInside(resolvedPath, stacksDir)) {
|
||||
return { ok: true, resolved: resolvedPath };
|
||||
}
|
||||
|
||||
const allowlist = await getExternalStackPaths();
|
||||
for (const dir of allowlist) {
|
||||
if (!dir) continue;
|
||||
const dirResolved = resolve(dir);
|
||||
if (isInside(resolvedPath, dirResolved)) {
|
||||
return { ok: true, resolved: resolvedPath };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: `Path "${resolvedPath}" is not inside an allowed stack directory. Add its parent directory in Settings → General → External stack paths.`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as validateStackPath, but when the path comes from an existing
|
||||
* stack source row (a stored custom path the admin set previously) and
|
||||
* fails the allowlist check, add its parent dir to external_stack_paths
|
||||
* and re-validate. Lets old custom-path configurations keep working
|
||||
* without operator intervention.
|
||||
*/
|
||||
export async function validateStackPathWithGrandfather(
|
||||
input: string,
|
||||
isPreExisting: boolean
|
||||
): Promise<StackPathValidation> {
|
||||
const first = await validateStackPath(input);
|
||||
if (first.ok || !isPreExisting) return first;
|
||||
|
||||
const parent = dirname(resolveStackPath(input));
|
||||
const added = await addExternalStackPath(parent);
|
||||
if (added) {
|
||||
console.log(`[Stack] Grandfathered pre-existing custom path: ${parent}`);
|
||||
}
|
||||
return validateStackPath(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find stack directory, checking paths in order:
|
||||
* 1. Database: Custom composePath in stackSources table (adopted/imported stacks)
|
||||
@@ -554,6 +675,31 @@ export async function saveStackComposeFile(
|
||||
const source = await getStackSource(name, envId);
|
||||
const composePath = options?.composePath || source?.composePath;
|
||||
|
||||
// Path-allowlist validation. Pre-existing stored paths grandfather into
|
||||
// the allowlist; new caller-supplied paths must already be inside it.
|
||||
// See validateStackPath() docs.
|
||||
if (composePath) {
|
||||
const fromDb = !options?.composePath && !!source?.composePath;
|
||||
const v = await validateStackPathWithGrandfather(composePath, fromDb);
|
||||
if (!v.ok) return { success: false, error: v.error };
|
||||
}
|
||||
if (options?.envPath) {
|
||||
const v = await validateStackPath(options.envPath);
|
||||
if (!v.ok) return { success: false, error: v.error };
|
||||
} else if (source?.envPath && !options?.envPath) {
|
||||
// Grandfather a DB-stored env path that may have been configured before validation.
|
||||
const v = await validateStackPathWithGrandfather(source.envPath, true);
|
||||
if (!v.ok) return { success: false, error: v.error };
|
||||
}
|
||||
if (options?.oldComposePath) {
|
||||
const v = await validateStackPathWithGrandfather(options.oldComposePath, true);
|
||||
if (!v.ok) return { success: false, error: v.error };
|
||||
}
|
||||
if (options?.oldEnvPath) {
|
||||
const v = await validateStackPathWithGrandfather(options.oldEnvPath, true);
|
||||
if (!v.ok) return { success: false, error: v.error };
|
||||
}
|
||||
|
||||
// Handle compose file move/rename when path changes
|
||||
if (options?.oldComposePath && options?.composePath &&
|
||||
options.oldComposePath !== options.composePath &&
|
||||
@@ -2700,6 +2846,10 @@ export async function writeStackEnvFile(
|
||||
envId?: number | null,
|
||||
customEnvPath?: string
|
||||
): Promise<void> {
|
||||
if (customEnvPath) {
|
||||
const v = await validateStackPath(customEnvPath);
|
||||
if (!v.ok) throw new Error(v.error || 'Invalid env path');
|
||||
}
|
||||
let envFilePath: string;
|
||||
if (customEnvPath) {
|
||||
envFilePath = customEnvPath;
|
||||
@@ -2745,6 +2895,10 @@ export async function writeRawStackEnvFile(
|
||||
envId?: number | null,
|
||||
customEnvPath?: string
|
||||
): Promise<void> {
|
||||
if (customEnvPath) {
|
||||
const v = await validateStackPath(customEnvPath);
|
||||
if (!v.ok) throw new Error(v.error || 'Invalid env path');
|
||||
}
|
||||
let envFilePath: string;
|
||||
if (customEnvPath) {
|
||||
envFilePath = customEnvPath;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Single-file tar extraction for raw downloads (#1180).
|
||||
*
|
||||
* Docker's /archive endpoint always wraps file contents in a USTAR tar.
|
||||
* When the user picks the "no archive" download format and the path is a
|
||||
* regular file, we strip the wrapper and emit the bytes verbatim.
|
||||
*
|
||||
* Only handles the first regular-file entry — the caller has already
|
||||
* guaranteed (via stat) that the requested path is a single file, so the
|
||||
* tar contains exactly one entry.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extract the bytes of the first regular file entry in a USTAR tar.
|
||||
* Returns the file content as a Uint8Array.
|
||||
*
|
||||
* Throws when no regular file entry is found (e.g. the tar contained only
|
||||
* a directory header) — that's an unexpected state, since the caller is
|
||||
* supposed to have already verified the path points to a file.
|
||||
*/
|
||||
export function extractFirstFileFromTar(tarData: Uint8Array): Uint8Array {
|
||||
let offset = 0;
|
||||
while (offset + 512 <= tarData.length) {
|
||||
const header = tarData.subarray(offset, offset + 512);
|
||||
|
||||
// Two consecutive zero blocks mark end-of-archive.
|
||||
if (isZeroBlock(header)) break;
|
||||
|
||||
const name = readString(header, 0, 100);
|
||||
const sizeOctal = readString(header, 124, 12).trim();
|
||||
const size = sizeOctal ? parseInt(sizeOctal, 8) : 0;
|
||||
const typeFlag = header[156];
|
||||
|
||||
// Regular file: typeflag '0' (0x30) or NUL (0x00, legacy)
|
||||
const isRegularFile = typeFlag === 0x30 || typeFlag === 0x00;
|
||||
|
||||
if (isRegularFile && name && size >= 0) {
|
||||
const start = offset + 512;
|
||||
const end = start + size;
|
||||
if (end > tarData.length) {
|
||||
throw new Error('Truncated tar archive');
|
||||
}
|
||||
return tarData.subarray(start, end);
|
||||
}
|
||||
|
||||
// Skip header + content (padded to 512-byte boundary)
|
||||
offset += 512 + Math.ceil(size / 512) * 512;
|
||||
}
|
||||
throw new Error('No regular file entry found in tar archive');
|
||||
}
|
||||
|
||||
function isZeroBlock(block: Uint8Array): boolean {
|
||||
for (let i = 0; i < block.length; i++) {
|
||||
if (block[i] !== 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readString(buf: Uint8Array, offset: number, length: number): string {
|
||||
let end = offset;
|
||||
const limit = offset + length;
|
||||
while (end < limit && buf[end] !== 0) end++;
|
||||
return new TextDecoder('utf-8').decode(buf.subarray(offset, end));
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { db } from './db/drizzle';
|
||||
import { asc } from 'drizzle-orm';
|
||||
|
||||
// Dynamic schema import (same pattern as db.ts)
|
||||
const isPostgres = !!process.env.DATABASE_URL;
|
||||
const schema = isPostgres
|
||||
? await import('./db/schema/pg-schema.js')
|
||||
: await import('./db/schema/index.js');
|
||||
|
||||
const { templateSources } = schema;
|
||||
|
||||
export interface TemplateSource {
|
||||
id: number;
|
||||
sourceId: string;
|
||||
name: string;
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
builtin: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_TEMPLATE_SOURCES: Omit<TemplateSource, 'id'>[] = [
|
||||
// Large collections
|
||||
{ sourceId: 'portainer-lissy93', name: 'Portainer templates (Lissy93)', url: 'https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json', enabled: true, builtin: true, sortOrder: 0 },
|
||||
{ sourceId: 'ntv-one', name: 'NTV-One (consolidated)', url: 'https://raw.githubusercontent.com/ntv-one/portainer/main/template.json', enabled: false, builtin: true, sortOrder: 1 },
|
||||
{ sourceId: 'mlva', name: 'MLVA (TheLustriVA)', url: 'https://raw.githubusercontent.com/TheLustriVA/portainer-templates-Nov-2022-collection/main/templates_2_2_rc_2_2.json', enabled: false, builtin: true, sortOrder: 2 },
|
||||
{ sourceId: 'selfhostedpro', name: 'SelfHostedPro', url: 'https://raw.githubusercontent.com/SelfhostedPro/selfhosted_templates/master/Template/portainer-v2.json', enabled: false, builtin: true, sortOrder: 3 },
|
||||
// Homelab / self-hosted
|
||||
{ sourceId: 'portainer-qballjos', name: 'Qballjos (homelab)', url: 'https://raw.githubusercontent.com/Qballjos/portainer_templates/master/Template/template.json', enabled: false, builtin: true, sortOrder: 4 },
|
||||
{ sourceId: 'lsio-technorabilia', name: 'LinuxServer.io (Technorabilia)', url: 'https://raw.githubusercontent.com/technorabilia/portainer-templates/main/lsio/templates/templates.json', enabled: true, builtin: true, sortOrder: 5 },
|
||||
{ sourceId: 'mikestraney', name: 'MikeStraney', url: 'https://raw.githubusercontent.com/mikestraney/portainer-templates/master/templates.json', enabled: false, builtin: true, sortOrder: 6 },
|
||||
// ARM / Raspberry Pi
|
||||
{ sourceId: 'pi-hosted-amd64', name: 'Pi-Hosted (amd64)', url: 'https://raw.githubusercontent.com/pi-hosted/pi-hosted/master/template/portainer-v2-amd64.json', enabled: false, builtin: true, sortOrder: 7 },
|
||||
{ sourceId: 'pi-hosted-arm64', name: 'Pi-Hosted (arm64)', url: 'https://raw.githubusercontent.com/pi-hosted/pi-hosted/master/template/portainer-v2-arm64.json', enabled: false, builtin: true, sortOrder: 8 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Seed default sources into the library_sources table if empty.
|
||||
*/
|
||||
export async function seedTemplateSources(): Promise<void> {
|
||||
const existing = await db.select().from(templateSources);
|
||||
if (existing.length > 0) return;
|
||||
|
||||
for (const source of DEFAULT_TEMPLATE_SOURCES) {
|
||||
await db.insert(templateSources).values({
|
||||
sourceId: source.sourceId,
|
||||
name: source.name,
|
||||
url: source.url,
|
||||
enabled: source.enabled,
|
||||
builtin: source.builtin,
|
||||
sortOrder: source.sortOrder,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTemplateSources(): Promise<TemplateSource[]> {
|
||||
const rows = await db.select().from(templateSources).orderBy(asc(templateSources.sortOrder));
|
||||
return rows.map(r => ({
|
||||
id: r.id,
|
||||
sourceId: r.sourceId,
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
enabled: r.enabled ?? true,
|
||||
builtin: r.builtin ?? false,
|
||||
sortOrder: r.sortOrder ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function updateTemplateSource(id: number, updates: { enabled?: boolean; name?: string; url?: string }): Promise<void> {
|
||||
const { eq } = await import('drizzle-orm');
|
||||
await db.update(templateSources)
|
||||
.set({ ...updates, updatedAt: new Date().toISOString() })
|
||||
.where(eq(templateSources.id, id));
|
||||
}
|
||||
|
||||
export async function addTemplateSource(source: { name: string; url: string }): Promise<TemplateSource> {
|
||||
const sourceId = `custom-${Date.now()}`;
|
||||
const maxOrder = await db.select().from(templateSources).orderBy(asc(templateSources.sortOrder));
|
||||
const nextOrder = maxOrder.length > 0 ? (maxOrder[maxOrder.length - 1].sortOrder ?? 0) + 1 : 0;
|
||||
|
||||
const result = await db.insert(templateSources).values({
|
||||
sourceId,
|
||||
name: source.name,
|
||||
url: source.url,
|
||||
enabled: true,
|
||||
builtin: false,
|
||||
sortOrder: nextOrder,
|
||||
}).returning();
|
||||
|
||||
const r = result[0];
|
||||
return {
|
||||
id: r.id,
|
||||
sourceId: r.sourceId,
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
enabled: r.enabled ?? true,
|
||||
builtin: r.builtin ?? false,
|
||||
sortOrder: r.sortOrder ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteTemplateSource(id: number): Promise<void> {
|
||||
const { eq } = await import('drizzle-orm');
|
||||
await db.delete(templateSources).where(eq(templateSources.id, id));
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { validateSessionById, isAuthEnabled, SESSION_COOKIE } from './auth';
|
||||
import { validateApiToken } from './api-tokens';
|
||||
import { isEnterprise } from './license';
|
||||
import { userHasAdminRole, userCanAccessEnvironment } from './db';
|
||||
|
||||
export interface WsUpgradeAuth {
|
||||
userId: number;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
authDisabled: boolean;
|
||||
}
|
||||
|
||||
function parseCookieHeader(header: string | undefined): Record<string, string> {
|
||||
if (!header) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const part of header.split(';')) {
|
||||
const eq = part.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const k = part.slice(0, eq).trim();
|
||||
let v = part.slice(eq + 1).trim();
|
||||
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
||||
if (k) out[k] = decodeURIComponent(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type LowercasedHeaders = Record<string, string | string[] | undefined>;
|
||||
|
||||
function pickHeader(headers: LowercasedHeaders, name: string): string | undefined {
|
||||
const v = headers[name];
|
||||
if (Array.isArray(v)) return v[0];
|
||||
return v;
|
||||
}
|
||||
|
||||
export async function authenticateWsUpgrade(
|
||||
headers: LowercasedHeaders
|
||||
): Promise<WsUpgradeAuth | null> {
|
||||
const authEnabled = await isAuthEnabled();
|
||||
if (!authEnabled) {
|
||||
return { userId: -1, username: '__bootstrap__', isAdmin: true, authDisabled: true };
|
||||
}
|
||||
|
||||
const cookieHeader = pickHeader(headers, 'cookie');
|
||||
const cookies = parseCookieHeader(cookieHeader);
|
||||
const sessionId = cookies[SESSION_COOKIE];
|
||||
|
||||
if (sessionId) {
|
||||
const user = await validateSessionById(sessionId);
|
||||
if (user) {
|
||||
const enterprise = await isEnterprise();
|
||||
const isAdmin = enterprise ? await userHasAdminRole(user.id) : true;
|
||||
return { userId: user.id, username: user.username, isAdmin, authDisabled: false };
|
||||
}
|
||||
}
|
||||
|
||||
const authHeader = pickHeader(headers, 'authorization');
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.slice(7).trim();
|
||||
const user = await validateApiToken(token);
|
||||
if (user) {
|
||||
const enterprise = await isEnterprise();
|
||||
const isAdmin = enterprise ? await userHasAdminRole(user.id) : true;
|
||||
return { userId: user.id, username: user.username, isAdmin, authDisabled: false };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function canAccessEnvForUser(
|
||||
auth: WsUpgradeAuth,
|
||||
environmentId: number | undefined | null
|
||||
): Promise<boolean> {
|
||||
if (auth.authDisabled) return true;
|
||||
if (auth.isAdmin) return true;
|
||||
const enterprise = await isEnterprise();
|
||||
if (!enterprise) return true;
|
||||
if (environmentId == null) {
|
||||
return false;
|
||||
}
|
||||
return userCanAccessEnvironment(auth.userId, environmentId);
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __authenticateWsUpgrade:
|
||||
| ((headers: LowercasedHeaders) => Promise<WsUpgradeAuth | null>)
|
||||
| undefined;
|
||||
// eslint-disable-next-line no-var
|
||||
var __canAccessEnvForUser:
|
||||
| ((auth: WsUpgradeAuth, envId: number | undefined | null) => Promise<boolean>)
|
||||
| undefined;
|
||||
}
|
||||
|
||||
globalThis.__authenticateWsUpgrade = authenticateWsUpgrade;
|
||||
globalThis.__canAccessEnvForUser = canAccessEnvForUser;
|
||||
Reference in new issue
Block a user