This commit is contained in:
jarek
2026-06-17 08:21:30 +02:00
parent aa45be6844
commit efb634701c
56 changed files with 9101 additions and 117 deletions
+1 -1
View File
@@ -1 +1 @@
v1.0.33
v1.0.34
+12
View File
@@ -0,0 +1,12 @@
CREATE TABLE "template_sources" (
"id" serial PRIMARY KEY NOT NULL,
"source_id" text NOT NULL,
"name" text NOT NULL,
"url" text NOT NULL,
"enabled" boolean DEFAULT true,
"builtin" boolean DEFAULT false,
"sort_order" integer DEFAULT 0,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "template_sources_source_id_unique" UNIQUE("source_id")
);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -57,6 +57,13 @@
"when": 1781158711008,
"tag": "0007_add_synced_files",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1781620381909,
"tag": "0008_add_template_sources",
"breakpoints": true
}
]
}
+13
View File
@@ -0,0 +1,13 @@
CREATE TABLE `template_sources` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`source_id` text NOT NULL,
`name` text NOT NULL,
`url` text NOT NULL,
`enabled` integer DEFAULT true,
`builtin` integer DEFAULT false,
`sort_order` integer DEFAULT 0,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`updated_at` text DEFAULT CURRENT_TIMESTAMP
);
--> statement-breakpoint
CREATE UNIQUE INDEX `template_sources_source_id_unique` ON `template_sources` (`source_id`);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -57,6 +57,13 @@
"when": 1781158702731,
"tag": "0007_add_synced_files",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1781620376161,
"tag": "0008_add_template_sources",
"breakpoints": true
}
]
}
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "dockhand",
"private": true,
"version": "1.0.33",
"version": "1.0.34",
"type": "module",
"scripts": {
"dev": "npx vite dev",
@@ -82,14 +82,14 @@
"fast-xml-parser": "5.7.3",
"js-yaml": "4.1.1",
"ldapts": "8.1.3",
"nodemailer": "8.0.5",
"nodemailer": "8.0.9",
"otpauth": "9.4.1",
"postgres": "3.4.8",
"qrcode": "1.5.4",
"rollup": "4.60.0",
"svelte-sonner": "1.0.7",
"undici": "7.24.5",
"ws": "8.20.1"
"ws": "8.21.0"
},
"devDependencies": {
"@internationalized/date": "^3.10.1",
@@ -117,7 +117,7 @@
"d3-shape": "^3.2.0",
"drizzle-kit": "0.31.8",
"layerchart": "^1.0.13",
"lucide-svelte": "^0.562.0",
"lucide-svelte": "0.562.0",
"mode-watcher": "^1.1.0",
"postcss": "^8.5.6",
"svelte": "5.55.7",
+40 -1
View File
@@ -168,7 +168,7 @@ globalThis.__terminalHandleExecMessage = (msg) => {
};
// Handle WebSocket upgrade
server.on('upgrade', (req, socket, head) => {
server.on('upgrade', async (req, socket, head) => {
const url = new URL(req.url || '/', `http://${req.headers.host}`);
// Only handle our specific WebSocket paths
@@ -180,7 +180,30 @@ server.on('upgrade', (req, socket, head) => {
return;
}
let wsAuth = null;
if (isTerminal) {
try {
if (typeof globalThis.__authenticateWsUpgrade !== 'function') {
socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
wsAuth = await globalThis.__authenticateWsUpgrade(req.headers);
if (!wsAuth) {
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
} catch (err) {
console.error('[WS] auth error during upgrade:', err);
socket.write('HTTP/1.1 500 Internal Server Error\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
}
wss.handleUpgrade(req, socket, head, (ws) => {
if (wsAuth) ws.__auth = wsAuth;
wss.emit('connection', ws, req);
});
});
@@ -223,6 +246,22 @@ async function handleTerminalConnection(ws, url, connId) {
return;
}
if (ws.__auth && typeof globalThis.__canAccessEnvForUser === 'function') {
try {
const ok = await globalThis.__canAccessEnvForUser(ws.__auth, envId);
if (!ok) {
console.warn(`[WS] env access denied: user=${ws.__auth.username} envId=${envId}`);
ws.send(JSON.stringify({ type: 'error', message: 'Access denied for this environment' }));
ws.close(1008, 'env access denied');
return;
}
} catch (err) {
console.error('[WS] env access check failed:', err);
ws.close(1011, 'internal error');
return;
}
}
try {
// Resolve Docker target via SvelteKit app's database
let target;
+5 -10
View File
@@ -18,6 +18,11 @@ import { join } from 'path';
import type { HandleServerError, Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { startRssTracker, stopRssTracker, rssBeforeOp, rssAfterOp } from '$lib/server/rss-tracker';
import { getClientIp } from '$lib/server/client-ip';
// Side-effect import: installs globalThis.__authenticateWsUpgrade and
// globalThis.__canAccessEnvForUser used by the raw WS upgrade handlers in
// server.js / vite.config.ts to authenticate /api/containers/*/exec.
import '$lib/server/ws-auth';
// Content types worth compressing
const COMPRESSIBLE_TYPES = [
@@ -218,16 +223,6 @@ setInterval(() => {
}
}, BEARER_COOLDOWN_MS).unref?.();
function getClientIp(event: { request: Request; getClientAddress?: () => string }): string {
// Prefer socket-level IP (SvelteKit resolves proxy headers via adapter config)
// This prevents X-Forwarded-For spoofing to bypass rate limiting
try {
const addr = event.getClientAddress?.();
if (addr) return addr;
} catch { /* getClientAddress may throw if unavailable */ }
return 'unknown';
}
function recordBearerFailure(ip: string): void {
const now = Date.now();
const entry = bearerFailCounts.get(ip);
+3 -1
View File
@@ -22,7 +22,8 @@
User,
ClipboardList,
Activity,
Timer
Timer,
LibraryBig
} from 'lucide-svelte';
import { licenseStore } from '$lib/stores/license';
import { authStore, hasAnyAccess } from '$lib/stores/auth';
@@ -101,6 +102,7 @@
{ href: '/images', Icon: Images, label: 'Images', permission: 'images' },
{ href: '/volumes', Icon: HardDrive, label: 'Volumes', permission: 'volumes' },
{ href: '/networks', Icon: Network, label: 'Networks', permission: 'networks' },
{ href: '/templates', Icon: LibraryBig, label: 'Templates', permission: 'templates' },
{ href: '/registry', Icon: Download, label: 'Registry', permission: 'registries' },
{ href: '/activity', Icon: Activity, label: 'Activity', permission: 'activity' },
{ href: '/schedules', Icon: Timer, label: 'Schedules', permission: 'schedules' },
+27 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Cpu, MemoryStick, Box, Globe, ChevronDown, Check, HardDrive, Clock, Wifi, WifiOff, Route, UndoDot, Icon, AlertCircle, Loader2, Search, X } from 'lucide-svelte';
import { Cpu, MemoryStick, Box, Globe, ChevronDown, Check, HardDrive, Clock, Wifi, WifiOff, Route, UndoDot, Icon, AlertCircle, Loader2, Search, Server, X } from 'lucide-svelte';
import { whale } from '@lucide/lab';
import { Button } from '$lib/components/ui/button';
import { currentEnvironment, environments, type Environment } from '$lib/stores/environment';
@@ -95,6 +95,22 @@
}
}
// Display string for the env hostname / IP in the header (#962).
// Show both when available; drop only the field that is unknown/empty.
// Hide the whole block when neither is meaningful (e.g. hawser-edge
// reports 'unknown' for both).
const hostLabel = $derived.by(() => {
if (!hostInfo) return '';
const isMeaningful = (v: string | undefined) => {
const t = (v || '').trim();
return t && t.toLowerCase() !== 'unknown';
};
const h = isMeaningful(hostInfo.hostname) ? hostInfo.hostname.trim() : '';
const ip = isMeaningful(hostInfo.ipAddress) ? hostInfo.ipAddress.trim() : '';
if (h && ip && h !== ip) return `${h} (${ip})`;
return h || ip;
});
// Reactive environment list from store
let envList = $derived($environments);
const showSearch = $derived(envList.length > 8);
@@ -449,6 +465,16 @@
{#if hostInfo}
<span class="text-border">|</span>
<!-- Hostname / IP (#962) — first info segment after the env dropdown.
Hidden on narrow viewports to keep the strip readable. -->
{#if hostLabel}
<div class="hidden xl:flex items-center gap-1" title="Daemon hostname / IP">
<Server class="{iconSizeClass()}" />
<span>{hostLabel}</span>
</div>
<span class="hidden xl:inline text-border">|</span>
{/if}
<!-- Platform/OS -->
<span class="hidden md:inline">{hostInfo.platform} {hostInfo.arch}</span>
+19
View File
@@ -1,4 +1,23 @@
[
{
"version": "1.0.34",
"date": "2026-06-17",
"changes": [
{ "type": "feature", "text": "raw file download — no tar wrapping (#1180)" },
{ "type": "fix", "text": "update modal stuck after closing mid-pull (#1094)" },
{ "type": "fix", "text": "vulnerability scans on Podman hosts (direct TCP and Hawser) (#1076)" },
{ "type": "fix", "text": "crash-looping containers now appear in the logs page list (#227)" },
{ "type": "feature", "text": "filter containers by \"Update available\" (#1063)" },
{ "type": "feature", "text": "show hostname / IP of the selected environment in the top header (#962)" },
{ "type": "feature", "text": "internal auth and validation hardening and dependency bumps" },
{ "type": "feature", "text": "Traefik and Pangolin integration — surface proxy URLs on container and stack panels (#2)" },
{ "type": "feature", "text": "release-notes link next to images with updates available (#538)" },
{ "type": "feature", "text": "lifecycle action buttons in the container details modal (#461)" },
{ "type": "feature", "text": "template library — browse and deploy compose templates from configurable sources (#48)" },
{ "type": "fix", "text": "file browser fails on containers with ls in /usr/sbin (#1185)" }
],
"imageTag": "fnsys/dockhand:v1.0.34"
},
{
"version": "1.0.33",
"date": "2026-06-15",
+31 -4
View File
@@ -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);
+41
View File
@@ -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';
}
+11
View File
@@ -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
// =============================================================================
+27 -3
View File
@@ -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([
+13
View File
@@ -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
// =============================================================================
+13
View File
@@ -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()
});
+24 -3
View File
@@ -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
View File
@@ -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
}
+16 -2
View File
@@ -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.
+105
View File
@@ -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`;
}
+44 -6
View File
@@ -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
View File
@@ -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;
+64
View File
@@ -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));
}
+105
View File
@@ -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));
}
+96
View File
@@ -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;
+1
View File
@@ -18,6 +18,7 @@ export interface Permissions {
audit_logs: string[];
activity: string[];
schedules: string[];
templates: string[];
}
export interface AuthUser {
+25 -3
View File
@@ -3,7 +3,7 @@ import { browser } from '$app/environment';
export type TimeFormat = '12h' | '24h';
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
export type DownloadFormat = 'tar' | 'tar.gz';
export type DownloadFormat = 'tar' | 'tar.gz' | 'raw';
export type EventCollectionMode = 'stream' | 'poll';
export type LabelFilterMode = 'any' | 'all';
@@ -39,6 +39,8 @@ export interface AppSettings {
defaultTrivyImage: string;
defaultComposeTemplate: string;
labelFilterMode: LabelFilterMode;
honorProxyLabels: boolean;
showImageChangelogLinks: boolean;
}
const DEFAULT_SETTINGS: AppSettings = {
@@ -72,6 +74,8 @@ const DEFAULT_SETTINGS: AppSettings = {
defaultGrypeImage: 'anchore/grype:v0.110.0',
defaultTrivyImage: 'aquasec/trivy:0.69.3',
labelFilterMode: 'any',
honorProxyLabels: true,
showImageChangelogLinks: true,
defaultComposeTemplate: `version: "3.8"
services:
@@ -151,7 +155,9 @@ function createSettingsStore() {
defaultGrypeImage: settings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
defaultTrivyImage: settings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage,
defaultComposeTemplate: settings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: settings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode
labelFilterMode: settings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
honorProxyLabels: settings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
showImageChangelogLinks: settings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks
});
}
} catch {
@@ -202,7 +208,9 @@ function createSettingsStore() {
defaultGrypeImage: updatedSettings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
defaultTrivyImage: updatedSettings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage,
defaultComposeTemplate: updatedSettings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: updatedSettings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode
labelFilterMode: updatedSettings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
honorProxyLabels: updatedSettings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
showImageChangelogLinks: updatedSettings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks
});
}
} catch (error) {
@@ -446,6 +454,20 @@ function createSettingsStore() {
return newSettings;
});
},
setHonorProxyLabels: (value: boolean) => {
update((current) => {
const newSettings = { ...current, honorProxyLabels: value };
saveSettings({ honorProxyLabels: value });
return newSettings;
});
},
setShowImageChangelogLinks: (value: boolean) => {
update((current) => {
const newSettings = { ...current, showImageChangelogLinks: value };
saveSettings({ showImageChangelogLinks: value });
return newSettings;
});
},
// Manual refresh from database
refresh: () => {
initialized = false;
+63
View File
@@ -0,0 +1,63 @@
/**
* Resolve a changelog / release-notes URL for a container image (#538).
*
* Three tiers in priority order:
* 1. `dockhand.changelog.url` label explicit override set by the image
* author or at runtime via `--label dockhand.changelog.url=…`. Wins over
* everything. Pattern matches the existing dockhand.* label convention.
* 2. `org.opencontainers.image.source` label the OCI standard. When it
* points at github.com the canonical changelog page is `<source>/releases`.
* 3. GHCR images `ghcr.io/<owner>/<repo>` is always the same as
* `github.com/<owner>/<repo>`, so the release page is deterministic.
*
* No tier 3-style fuzzy match against Docker Hub. Wrong-repo matches are a
* worse UX than no link, and there is no good answer for unlabelled
* upstream images like `nginx:latest` (nginx's changelog isn't on GitHub).
*
* Pure function: deterministic, no I/O, safe to call from a render loop.
*/
const GITHUB_HOST = 'github.com';
const GHCR_PREFIX = 'ghcr.io/';
function stripTrailingSlash(s: string): string {
return s.endsWith('/') ? s.slice(0, -1) : s;
}
function stripImageTag(image: string): string {
// "image@sha256:…" — split on @ first so we don't lose the digest fragment.
const atIdx = image.indexOf('@');
const withoutDigest = atIdx >= 0 ? image.slice(0, atIdx) : image;
const colonIdx = withoutDigest.lastIndexOf(':');
// A colon before a slash is a port in a registry hostname, not a tag.
if (colonIdx > withoutDigest.lastIndexOf('/')) {
return withoutDigest.slice(0, colonIdx);
}
return withoutDigest;
}
export function resolveChangelogUrl(
imageName: string | null | undefined,
labels?: Record<string, string> | null
): string | null {
if (!imageName) return null;
const override = labels?.['dockhand.changelog.url'];
if (override && override.trim()) return override.trim();
const source = labels?.['org.opencontainers.image.source'];
if (source && source.includes(GITHUB_HOST)) {
return stripTrailingSlash(source) + '/releases';
}
if (imageName.startsWith(GHCR_PREFIX)) {
const repo = stripImageTag(imageName.slice(GHCR_PREFIX.length));
// Sanity guard: GHCR images are always owner/repo. Single-segment values
// like `ghcr.io/something` are malformed; skip rather than emit a bad URL.
if (repo.split('/').length >= 2) {
return `https://github.com/${repo}/releases`;
}
}
return null;
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Pangolin label public URL extraction (#2 follow-up).
*
* Pangolin Blueprints (https://docs.pangolin.net/manage/blueprints) annotates
* a container with one or more proxy resources. The relevant labels for URL
* extraction are:
*
* pangolin.proxy-resources.<name>.name human-friendly label
* pangolin.proxy-resources.<name>.full-domain public hostname (mandatory)
* pangolin.proxy-resources.<name>.protocol http | https (defaults to https)
*
* The `targets[N].port` family is intentionally ignored Pangolin terminates
* the public connection at full-domain; the internal target port is not part
* of the URL a user sees.
*
* Returns one URL per resource that declares a full-domain. Multiple
* resources on the same container yield multiple URLs. Identical URLs across
* different resources are deduped.
*
* dockhand.url labels override this Pangolin extraction is a fallback,
* never a winner over an explicit user-provided URL.
*/
export interface PangolinUrl {
url: string;
/** The Pangolin resource key (the `<name>` in the label key). */
resource: string;
/** Optional human-friendly name from the `.name` label, if set. */
displayName?: string;
}
const RESOURCE_KEY_RE =
/^pangolin\.proxy-resources\.([^.]+)\.(full-domain|protocol|name)$/;
export function extractPangolinUrls(
labels: Record<string, string> | undefined | null
): PangolinUrl[] {
if (!labels) return [];
// Group label values by resource key.
const byResource = new Map<
string,
{ fullDomain?: string; protocol?: string; name?: string }
>();
for (const [key, value] of Object.entries(labels)) {
const m = key.match(RESOURCE_KEY_RE);
if (!m) continue;
const [, resource, field] = m;
let entry = byResource.get(resource);
if (!entry) {
entry = {};
byResource.set(resource, entry);
}
const v = (value ?? '').trim();
if (!v) continue;
if (field === 'full-domain') entry.fullDomain = v;
else if (field === 'protocol') entry.protocol = v.toLowerCase();
else if (field === 'name') entry.name = v;
}
const out: PangolinUrl[] = [];
const seen = new Set<string>();
for (const [resource, entry] of byResource) {
if (!entry.fullDomain) continue;
const proto =
entry.protocol === 'http' || entry.protocol === 'https'
? entry.protocol
: 'https';
const url = `${proto}://${entry.fullDomain}`;
if (seen.has(url)) continue;
seen.add(url);
out.push({
url,
resource,
displayName: entry.name
});
}
return out;
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Traefik label public URL extraction (#2).
*
* Parses standard Traefik v2/v3 router labels on a container:
* traefik.http.routers.<name>.rule e.g. Host(`app.example.com`)
* traefik.http.routers.<name>.entrypoints e.g. websecure
* traefik.http.routers.<name>.tls e.g. true
*
* Returns one URL per router whose rule contains at least one Host() match.
* Multiple Host() entries on the same router yield multiple URLs. Path
* prefixes (e.g. `Host(`x`) && PathPrefix(`/api`)`) are appended so the link
* lands on the actual served path.
*
* Scheme inference (in order):
* 1. explicit `tls=true` https
* 2. entrypoints contains "websecure" https
* 3. entrypoints contains "web" (and no secure variant) http
* 4. default https (the overwhelmingly common production setup)
*
* dockhand.url labels override this Traefik extraction is a fallback,
* never a winner over an explicit user-provided URL.
*/
export interface TraefikUrl {
url: string;
router: string;
}
const ROUTER_RULE_RE = /^traefik\.http\.routers\.([^.]+)\.rule$/;
// Host(`a.b.c`) or Host("a.b.c") or Host('a.b.c') — Traefik accepts all three quotings.
const HOST_RE = /Host\(\s*[`"']([^`"']+)[`"']\s*\)/g;
// PathPrefix(`/api`) — same quoting variations.
const PATH_PREFIX_RE = /PathPrefix\(\s*[`"']([^`"']+)[`"']\s*\)/;
export function extractTraefikUrls(
labels: Record<string, string> | undefined | null
): TraefikUrl[] {
if (!labels) return [];
const out: TraefikUrl[] = [];
const seen = new Set<string>();
for (const [key, rule] of Object.entries(labels)) {
const m = key.match(ROUTER_RULE_RE);
if (!m) continue;
const router = m[1];
const hosts: string[] = [];
let hostMatch: RegExpExecArray | null;
HOST_RE.lastIndex = 0;
while ((hostMatch = HOST_RE.exec(rule)) !== null) {
hosts.push(hostMatch[1]);
}
if (hosts.length === 0) continue;
const pathMatch = rule.match(PATH_PREFIX_RE);
const path = pathMatch ? pathMatch[1] : '';
const scheme = inferScheme(labels, router);
for (const host of hosts) {
const url = `${scheme}://${host}${path}`;
if (seen.has(url)) continue;
seen.add(url);
out.push({ url, router });
}
}
return out;
}
function inferScheme(labels: Record<string, string>, router: string): 'http' | 'https' {
const tls = labels[`traefik.http.routers.${router}.tls`];
if (tls && /^true$/i.test(tls.trim())) return 'https';
const entrypoints = labels[`traefik.http.routers.${router}.entrypoints`] || '';
const eps = entrypoints
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
if (eps.includes('websecure') || eps.includes('https')) return 'https';
if (eps.length > 0 && (eps.includes('web') || eps.includes('http'))) return 'http';
return 'https';
}
+5 -5
View File
@@ -13,10 +13,11 @@ import {
} from '$lib/server/auth';
import { getUser, getUserByUsername } from '$lib/server/db';
import { auditAuth } from '$lib/server/audit';
import { getClientIp } from '$lib/server/client-ip';
// POST /api/auth/login - Authenticate user
export const POST: RequestHandler = async (event) => {
const { request, cookies, getClientAddress } = event;
const { request, cookies } = event;
// Check if auth is enabled
if (!(await isAuthEnabled())) {
return json({ error: 'Authentication is not enabled' }, { status: 400 });
@@ -29,10 +30,9 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'Username and password are required' }, { status: 400 });
}
// Rate limiting by IP and username
const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|| request.headers.get('x-real-ip')
|| getClientAddress();
// Rate-limit key derived from socket IP + username. See client-ip.ts
// for how XFF is handled (opt-in via TRUST_FORWARDED_HEADERS).
const clientIp = getClientIp(event);
const rateLimitKey = `${clientIp}:${username}`;
const { limited, retryAfter } = isRateLimited(rateLimitKey);
+2 -3
View File
@@ -3,6 +3,7 @@ import type { RequestHandler } from '@sveltejs/kit';
import { destroySession } from '$lib/server/auth';
import { authorize } from '$lib/server/authorize';
import { auditAuth } from '$lib/server/audit';
import { getClientIp } from '$lib/server/client-ip';
// POST /api/auth/logout - End session
export const POST: RequestHandler = async (event) => {
@@ -11,9 +12,7 @@ export const POST: RequestHandler = async (event) => {
// Get current user before destroying session for audit log
const auth = await authorize(cookies);
const username = auth.user?.username || 'unknown';
const clientIp = event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|| event.request.headers.get('x-real-ip')
|| event.getClientAddress();
const clientIp = getClientIp(event);
await destroySession(cookies);
console.log(`[Auth] Logout: user=${username} ip=${clientIp}`);
+3 -4
View File
@@ -2,6 +2,7 @@ import { json, redirect } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { handleOidcCallback, createUserSession, isAuthEnabled } from '$lib/server/auth';
import { auditAuth } from '$lib/server/audit';
import { getClientIp } from '$lib/server/client-ip';
// GET /api/auth/oidc/callback - Handle OIDC callback from IdP
export const GET: RequestHandler = async (event) => {
@@ -17,10 +18,8 @@ export const GET: RequestHandler = async (event) => {
const error = url.searchParams.get('error');
const errorDescription = url.searchParams.get('error_description');
// Extract client IP for logging
const clientIp = event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|| event.request.headers.get('x-real-ip')
|| event.getClientAddress();
// Extract client IP for logging.
const clientIp = getClientIp(event);
// Handle error from IdP
if (error) {
@@ -2,6 +2,7 @@ import { gzipSync } from 'node:zlib';
import { getContainerArchive, statContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import { extractFirstFileFromTar } from '$lib/server/tar-extract';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
@@ -33,11 +34,14 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
// Get format from query parameter (defaults to tar)
const format = url.searchParams.get('format') || 'tar';
// Get stat info to determine filename
// Get stat info to determine filename and whether the path is a directory.
// Directories with format=raw fall back to tar (raw only makes sense for files).
let filename: string;
let isDir = false;
try {
const stat = await statContainerPath(params.id, path, envIdNum);
filename = stat.name || path.split('/').pop() || 'download';
isDir = stat.isDir === true;
} catch {
filename = path.split('/').pop() || 'download';
}
@@ -54,7 +58,13 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
let contentType = 'application/x-tar';
let extension = '.tar';
if (format === 'tar.gz') {
if (format === 'raw' && !isDir) {
// Strip the tar wrapper and emit raw file bytes (#1180).
const tarData = new Uint8Array(await response.arrayBuffer());
body = extractFirstFileFromTar(tarData);
contentType = 'application/octet-stream';
extension = '';
} else if (format === 'tar.gz') {
// Compress with gzip
const tarData = new Uint8Array(await response.arrayBuffer());
body = gzipSync(tarData);
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
import { getEnvironment, updateEnvironment } from '$lib/server/db';
import { getDockerInfo, getHawserInfo } from '$lib/server/docker';
import { edgeConnections, isEdgeConnected } from '$lib/server/hawser';
import { daemonIsPodman } from '$lib/server/scanner-socket-detect';
export const POST: RequestHandler = async ({ params }) => {
try {
@@ -35,14 +36,18 @@ export const POST: RequestHandler = async ({ params }) => {
// Agent is connected - try to get Docker info with shorter timeout
console.log(`[Test] Edge environment ${id} (${env.name}) - agent connected, testing Docker...`);
try {
const info = await getDockerInfo(env.id) as any;
const [info, isPodman] = await Promise.all([
getDockerInfo(env.id) as Promise<any>,
daemonIsPodman(env.id)
]);
return json({
success: true,
info: {
serverVersion: info.ServerVersion,
containers: info.Containers,
images: info.Images,
name: info.Name
name: info.Name,
engine: isPodman ? 'podman' : 'docker'
},
isEdgeMode: true,
hawser: edgeConn ? {
@@ -70,17 +75,20 @@ export const POST: RequestHandler = async ({ params }) => {
}
}
// For Hawser Standard mode, fetch Docker info and Hawser info in parallel
// (parallel calls are more efficient and avoid sequential connection issues)
// Fetch Docker info, podman detection, and (for hawser-standard) hawser
// info in parallel — faster, and avoids serializing remote calls.
let info: any;
let hawserInfo = null;
let isPodman = false;
if (env.connectionType === 'hawser-standard') {
const [dockerResult, hawserResult] = await Promise.all([
const [dockerResult, hawserResult, detected] = await Promise.all([
getDockerInfo(env.id),
getHawserInfo(id)
getHawserInfo(id),
daemonIsPodman(env.id)
]);
info = dockerResult;
hawserInfo = hawserResult;
isPodman = detected;
if (hawserInfo?.hawserVersion) {
await updateEnvironment(id, {
hawserVersion: hawserInfo.hawserVersion,
@@ -90,7 +98,12 @@ export const POST: RequestHandler = async ({ params }) => {
});
}
} else {
info = await getDockerInfo(env.id);
const [dockerResult, detected] = await Promise.all([
getDockerInfo(env.id),
daemonIsPodman(env.id)
]);
info = dockerResult;
isPodman = detected;
}
return json({
@@ -99,7 +112,8 @@ export const POST: RequestHandler = async ({ params }) => {
serverVersion: info.ServerVersion,
containers: info.Containers,
images: info.Images,
name: info.Name
name: info.Name,
engine: isPodman ? 'podman' : 'docker'
},
hawser: hawserInfo
});
+28 -1
View File
@@ -90,6 +90,13 @@ export interface GeneralSettings {
defaultComposeTemplate: string;
// Label filter mode
labelFilterMode: 'any' | 'all';
// Whether to surface URLs inferred from reverse-proxy labels — currently
// Traefik (traefik.http.routers.*) and Pangolin (pangolin.proxy-resources.*).
// When false both parsers are bypassed and no proxy-derived pills are rendered.
honorProxyLabels: boolean;
// Whether to surface a "view changelog" link next to the update badge.
// Resolved client-side from OCI labels / GHCR image names; no server hit.
showImageChangelogLinks: boolean;
// Whether spinning icons (animate-spin etc.) are animated (#1169)
animateIcons: boolean;
}
@@ -124,6 +131,8 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
defaultGrypeImage: DEFAULT_GRYPE_IMAGE,
defaultTrivyImage: DEFAULT_TRIVY_IMAGE,
labelFilterMode: 'any' as const,
honorProxyLabels: true,
showImageChangelogLinks: true,
animateIcons: true,
defaultComposeTemplate: `version: "3.8"
@@ -203,6 +212,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
defaultTrivyImage,
defaultComposeTemplate,
labelFilterMode,
honorProxyLabels,
showImageChangelogLinks,
animateIcons
] = await Promise.all([
getSetting('confirm_destructive'),
@@ -243,6 +254,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
getSetting('default_trivy_image'),
getSetting('default_compose_template'),
getSetting('label_filter_mode'),
getSetting('honor_proxy_labels'),
getSetting('show_image_changelog_links'),
getSetting('animate_icons')
]);
@@ -287,6 +300,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE,
defaultComposeTemplate: defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
honorProxyLabels: honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
showImageChangelogLinks: showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
animateIcons: animateIcons ?? DEFAULT_SETTINGS.animateIcons
};
@@ -305,7 +320,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const body = await request.json();
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, animateIcons } = body;
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, honorProxyLabels, showImageChangelogLinks, animateIcons } = body;
if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive);
@@ -445,6 +460,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (labelFilterMode !== undefined && (labelFilterMode === 'any' || labelFilterMode === 'all')) {
await setSetting('label_filter_mode', labelFilterMode);
}
if (honorProxyLabels !== undefined && typeof honorProxyLabels === 'boolean') {
await setSetting('honor_proxy_labels', honorProxyLabels);
}
if (showImageChangelogLinks !== undefined && typeof showImageChangelogLinks === 'boolean') {
await setSetting('show_image_changelog_links', showImageChangelogLinks);
}
if (animateIcons !== undefined && typeof animateIcons === 'boolean') {
await setSetting('animate_icons', animateIcons);
}
@@ -489,6 +510,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
defaultTrivyImageVal,
defaultComposeTemplateVal,
labelFilterModeVal,
honorProxyLabelsVal,
showImageChangelogLinksVal,
animateIconsVal
] = await Promise.all([
getSetting('confirm_destructive'),
@@ -529,6 +552,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getSetting('default_trivy_image'),
getSetting('default_compose_template'),
getSetting('label_filter_mode'),
getSetting('honor_proxy_labels'),
getSetting('show_image_changelog_links'),
getSetting('animate_icons')
]);
@@ -573,6 +598,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE,
defaultComposeTemplate: defaultComposeTemplateVal ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: labelFilterModeVal ?? DEFAULT_SETTINGS.labelFilterMode,
honorProxyLabels: honorProxyLabelsVal ?? DEFAULT_SETTINGS.honorProxyLabels,
showImageChangelogLinks: showImageChangelogLinksVal ?? DEFAULT_SETTINGS.showImageChangelogLinks,
animateIcons: animateIconsVal ?? DEFAULT_SETTINGS.animateIcons
};
+151
View File
@@ -0,0 +1,151 @@
import type { RequestHandler } from '@sveltejs/kit';
import { json } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { getTemplateSources, type TemplateSource } from '$lib/server/templates';
export interface TemplateItem {
id: string;
type: 'container' | 'stack';
title: string;
description: string;
logo: string;
categories: string[];
source: string;
image?: string;
ports?: string[];
volumes?: { bind: string; container: string }[];
env?: { name: string; label: string; default?: string }[];
restartPolicy?: string;
repository?: { url: string; stackfile: string };
stars?: number;
pulls?: number;
network?: string;
}
// Server-side cache: url → { data, fetchedAt }
const cache = new Map<string, { data: TemplateItem[]; fetchedAt: number }>();
const CACHE_TTL = 60 * 60 * 1000; // 1 hour
function hashId(source: string, title: string): string {
let hash = 0;
const str = `${source}:${title}`;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash |= 0;
}
return Math.abs(hash).toString(36);
}
function normalizePortainerTemplate(entry: any, sourceName: string): TemplateItem | null {
if (!entry.title && !entry.name) return null;
// Skip Swarm templates (type 2)
if (entry.type === 2) return null;
const title = entry.title || entry.name || '';
const template: TemplateItem = {
id: hashId(sourceName, title),
type: entry.type === 3 ? 'stack' : 'container',
title,
description: entry.description || '',
logo: entry.logo || '',
categories: Array.isArray(entry.categories) ? entry.categories : [],
source: sourceName,
};
if (entry.type === 3 && entry.repository) {
template.repository = {
url: entry.repository.url,
stackfile: entry.repository.stackfile
};
} else {
template.image = entry.image || '';
template.ports = Array.isArray(entry.ports) ? entry.ports : [];
template.volumes = Array.isArray(entry.volumes) ? entry.volumes : [];
template.env = Array.isArray(entry.env) ? entry.env.map((e: any) => ({
name: e.name || '',
label: e.label || e.name || '',
default: e.default ?? e.set ?? ''
})) : [];
template.restartPolicy = entry.restart_policy || 'unless-stopped';
if (entry.network) template.network = entry.network;
}
return template;
}
function normalizeLinuxServerTemplate(entry: any): TemplateItem | null {
if (!entry.name || entry.deprecated) return null;
return {
id: hashId('LinuxServer.io', entry.name),
type: 'container',
title: entry.name,
description: entry.description || '',
logo: entry.project_logo || '',
categories: entry.category ? entry.category.split(',').map((c: string) => c.trim()).filter(Boolean) : [],
source: 'LinuxServer.io',
image: `lscr.io/linuxserver/${entry.name}:latest`,
env: [
{ name: 'PUID', label: 'User ID', default: '1000' },
{ name: 'PGID', label: 'Group ID', default: '1000' },
{ name: 'TZ', label: 'Timezone', default: 'Etc/UTC' },
],
restartPolicy: 'unless-stopped',
stars: entry.stars,
pulls: entry.monthly_pulls,
};
}
async function fetchSource(source: TemplateSource): Promise<TemplateItem[]> {
const cached = cache.get(source.url);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL) {
return cached.data;
}
try {
const response = await fetch(source.url, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000),
});
if (!response.ok) {
console.error(`[Templates] Failed to fetch ${source.name}: ${response.status}`);
return cached?.data || [];
}
const raw = await response.json();
let templates: TemplateItem[];
if (source.id === 'linuxserver' || source.url.includes('fleet.linuxserver.io')) {
// LinuxServer.io fleet API returns an array of images
const images = Array.isArray(raw) ? raw : (raw.images || []);
templates = images.map(normalizeLinuxServerTemplate).filter(Boolean) as TemplateItem[];
} else {
// Portainer-format templates
const entries = Array.isArray(raw) ? raw : (raw.templates || []);
templates = entries.map((e: any) => normalizePortainerTemplate(e, source.name)).filter(Boolean) as TemplateItem[];
}
cache.set(source.url, { data: templates, fetchedAt: Date.now() });
return templates;
} catch (error) {
console.error(`[Templates] Error fetching ${source.name}:`, error instanceof Error ? error.message : error);
return cached?.data || [];
}
}
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('templates', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const sources = await getTemplateSources();
const enabledSources = sources.filter(s => s.enabled);
const results = await Promise.all(enabledSources.map(fetchSource));
const templates = results.flat();
return json(templates);
};
+108
View File
@@ -0,0 +1,108 @@
import type { RequestHandler } from '@sveltejs/kit';
import { json } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import type { TemplateItem } from '../+server';
function generateContainerCompose(template: TemplateItem): string {
const name = template.title.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
const lines: string[] = ['services:', ` ${name}:`];
if (template.image) {
lines.push(` image: ${template.image}`);
}
if (template.restartPolicy) {
lines.push(` restart: ${template.restartPolicy}`);
}
if (template.network) {
lines.push(` network_mode: ${template.network}`);
}
if (template.ports && template.ports.length > 0 && !template.network) {
lines.push(' ports:');
for (const port of template.ports) {
lines.push(` - "${port}"`);
}
}
if (template.volumes && template.volumes.length > 0) {
lines.push(' volumes:');
for (const vol of template.volumes) {
lines.push(` - ${vol.bind}:${vol.container}`);
}
}
if (template.env && template.env.length > 0) {
lines.push(' environment:');
for (const env of template.env) {
const value = env.default || '';
lines.push(` - ${env.name}=${value}`);
}
}
return lines.join('\n') + '\n';
}
async function fetchStackCompose(repository: { url: string; stackfile: string }): Promise<string> {
// Convert GitHub URL to raw content URL
let rawUrl = '';
const githubMatch = repository.url.match(/github\.com\/([^/]+\/[^/]+)/);
if (githubMatch) {
const repo = githubMatch[1].replace(/\.git$/, '');
// Try main branch first, then master
for (const branch of ['main', 'master']) {
const url = `https://raw.githubusercontent.com/${repo}/${branch}/${repository.stackfile}`;
try {
const response = await fetch(url, { signal: AbortSignal.timeout(10000) });
if (response.ok) {
return await response.text();
}
} catch {
continue;
}
}
}
// Try the URL directly as fallback
if (!rawUrl) {
rawUrl = repository.url.endsWith('/')
? `${repository.url}${repository.stackfile}`
: `${repository.url}/${repository.stackfile}`;
}
const response = await fetch(rawUrl, { signal: AbortSignal.timeout(10000) });
if (!response.ok) {
throw new Error(`Failed to fetch compose file: ${response.status}`);
}
return await response.text();
}
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('templates', 'deploy')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const { template } = await request.json() as { template: TemplateItem };
if (!template) {
return json({ error: 'Template is required' }, { status: 400 });
}
let compose: string;
if (template.type === 'stack' && template.repository) {
compose = await fetchStackCompose(template.repository);
} else {
compose = generateContainerCompose(template);
}
return json({ compose });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to generate compose';
return json({ error: message }, { status: 500 });
}
};
@@ -0,0 +1,63 @@
import type { RequestHandler } from '@sveltejs/kit';
import { json } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { getTemplateSources, updateTemplateSource, addTemplateSource, deleteTemplateSource } from '$lib/server/templates';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('templates', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const sources = await getTemplateSources();
return json(sources);
};
// Toggle enabled or update a source
export const PUT: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('templates', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const { id, enabled, name, url } = await request.json();
if (!id) return json({ error: 'Missing id' }, { status: 400 });
const updates: any = {};
if (enabled !== undefined) updates.enabled = enabled;
if (name !== undefined) updates.name = name;
if (url !== undefined) updates.url = url;
await updateTemplateSource(id, updates);
return json({ ok: true });
};
// Add a custom source
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('templates', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const { name, url } = await request.json();
if (!name?.trim() || !url?.trim()) {
return json({ error: 'Name and URL are required' }, { status: 400 });
}
const source = await addTemplateSource({ name: name.trim(), url: url.trim() });
return json(source);
};
// Delete a custom source
export const DELETE: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('templates', 'manage')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const id = url.searchParams.get('id');
if (!id) return json({ error: 'Missing id' }, { status: 400 });
await deleteTemplateSource(parseInt(id));
return json({ ok: true });
};
@@ -1,9 +1,10 @@
import { gzipSync } from 'node:zlib';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getVolumeArchive, releaseVolumeHelperContainer } from '$lib/server/docker';
import { getVolumeArchive, releaseVolumeHelperContainer, statVolumePath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import { extractFirstFileFromTar } from '$lib/server/tar-extract';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.name, 'volume');
@@ -22,6 +23,18 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
}
try {
// For format=raw, check if the path is a single file. Directories fall back to tar.
let isDir = false;
if (format === 'raw' && path !== '/') {
try {
const stat = await statVolumePath(params.name, path, envIdNum);
isDir = stat.isDir === true;
} catch {
// Stat failure: let the archive call below produce the real error
}
} else if (format === 'raw') {
isDir = true; // root path '/' is always a directory
}
const { response } = await getVolumeArchive(params.name, path, envIdNum);
@@ -35,7 +48,17 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
// Prepare response based on format
let body: ReadableStream<Uint8Array> | Uint8Array = response.body!;
if (format === 'tar.gz') {
if (format === 'raw' && !isDir) {
// Strip the tar wrapper and emit raw file bytes (#1180).
const tarData = new Uint8Array(await response.arrayBuffer());
body = extractFirstFileFromTar(tarData);
// Use the file's basename, not the volume-derived path-joined name.
filename = path.split('/').filter(Boolean).pop() || filename;
contentType = 'application/octet-stream';
extension = '';
releaseVolumeHelperContainer(params.name, envIdNum).catch(() => {});
} else if (format === 'tar.gz') {
// Compress with gzip — fully consumes the archive stream
const tarData = new Uint8Array(await response.arrayBuffer());
body = gzipSync(tarData);
+104 -8
View File
@@ -29,6 +29,7 @@
Plus,
FileText,
Pencil,
NotepadText,
RefreshCw,
CircleArrowUp,
X,
@@ -88,6 +89,9 @@
import { ipToNumber } from '$lib/utils/ip';
import { formatHostPortUrl } from '$lib/utils/url';
import { parseCustomUrl } from '$lib/utils/custom-url';
import { extractTraefikUrls } from '$lib/utils/traefik-urls';
import { extractPangolinUrls } from '$lib/utils/pangolin-urls';
import { resolveChangelogUrl } from '$lib/utils/changelog-url';
import { detectShells, getBestShell, hasAvailableShell, USER_OPTIONS, getSavedUser, saveUserForContainer, getCustomUsers, removeCustomUser, type ShellDetectionResult } from '$lib/utils/shell-detection';
import { DataGrid } from '$lib/components/data-grid';
import type { ColumnConfig } from '$lib/types';
@@ -119,8 +123,10 @@
let sortField = $state<SortField>('name');
let sortDirection = $state<SortDirection>('asc');
// Status filter state
// Status filter state — also carries the synthetic 'update-available'
// pseudo-status (#1063), which ANDs with actual Docker states.
const STATUS_FILTER_STORAGE_KEY = 'dockhand-containers-status-filter';
const UPDATE_AVAILABLE_FILTER_VALUE = 'update-available';
let statusFilter = $state<string[]>([]);
// Status types with icons for filter and table
@@ -305,6 +311,35 @@
// Set of container IDs with updates available (for O(1) lookup)
const containersWithUpdatesSet = $derived(new Set(batchUpdateContainerIds));
// Filter dropdown entries: real statuses plus the synthetic
// "update-available" entry, only offered once we know about a pending
// update — picking it on an empty set would just empty the list (#1063).
const filterOptions = $derived(
containersWithUpdatesSet.size > 0
? [
...statusTypes,
{
value: UPDATE_AVAILABLE_FILTER_VALUE,
label: 'Update available',
icon: CircleArrowUp,
color: 'text-amber-500'
}
]
: statusTypes
);
// Drop the 'update-available' filter when no pending updates remain —
// otherwise the user has no way to deselect it (dropdown hides the
// entry) and the list stays empty (#1063).
$effect(() => {
if (
statusFilter.includes(UPDATE_AVAILABLE_FILTER_VALUE) &&
containersWithUpdatesSet.size === 0
) {
statusFilter = statusFilter.filter((v) => v !== UPDATE_AVAILABLE_FILTER_VALUE);
}
});
// Count of updatable containers (excluding system containers like Dockhand/Hawser)
const updatableContainersCount = $derived(
batchUpdateContainerIds.filter(id => {
@@ -746,9 +781,16 @@
result = result.filter(c => c.state.toLowerCase() !== 'exited');
}
// Filter by status if any are selected
if (statusFilter.length > 0) {
result = result.filter(c => statusFilter.includes(c.state.toLowerCase()));
// Filter by status. The synthetic 'update-available' value (#1063)
// is split off so it ANDs with real-state selections instead of
// being treated like another Docker state.
const stateValues = statusFilter.filter((v) => v !== UPDATE_AVAILABLE_FILTER_VALUE);
const updatesOnly = statusFilter.includes(UPDATE_AVAILABLE_FILTER_VALUE);
if (stateValues.length > 0) {
result = result.filter((c) => stateValues.includes(c.state.toLowerCase()));
}
if (updatesOnly) {
result = result.filter((c) => containersWithUpdatesSet.has(c.id));
}
// Filter by search query
@@ -1397,12 +1439,14 @@
class="pl-8 h-8 w-48 text-sm"
/>
</div>
<!-- Status filter (multi-select) -->
<!-- Status filter (multi-select). The synthetic 'update-available'
entry appears once at least one container has a pending update,
and ANDs with selected real states (#1063). -->
<MultiSelectFilter
bind:value={statusFilter}
options={statusTypes}
options={filterOptions}
placeholder="All statuses"
pluralLabel="statuses"
pluralLabel="filters"
width="w-44"
defaultIcon={Box}
/>
@@ -1790,6 +1834,21 @@
<span title="Update available">
<CircleArrowUp class="w-3 h-3 text-amber-500 {$appSettings.highlightUpdates ? 'glow-amber' : ''} shrink-0" />
</span>
{#if $appSettings.showImageChangelogLinks}
{@const changelogUrl = resolveChangelogUrl(container.image, container.labels)}
{#if changelogUrl}
<a
href={changelogUrl}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
title="View changelog"
class="shrink-0 text-amber-500 hover:text-amber-400 transition-colors"
>
<NotepadText class="w-3 h-3" />
</a>
{/if}
{/if}
{/if}
<span class="text-xs text-muted-foreground truncate" title={container.image}>{container.image}</span>
</div>
@@ -1897,7 +1956,9 @@
{:else if column.id === 'ports'}
{@const exposedPorts = $appSettings.showExposedPorts ? formatExposedPorts(container.ports) : []}
{@const parsedUrl = parseCustomUrl(container.labels?.['dockhand.url'])}
{#if ports.length > 0 || exposedPorts.length > 0 || parsedUrl}
{@const traefikUrls = (parsedUrl || !$appSettings.honorProxyLabels) ? [] : extractTraefikUrls(container.labels)}
{@const pangolinUrls = (parsedUrl || !$appSettings.honorProxyLabels) ? [] : extractPangolinUrls(container.labels)}
{#if ports.length > 0 || exposedPorts.length > 0 || parsedUrl || traefikUrls.length > 0 || pangolinUrls.length > 0}
{@const compactPorts = $appSettings.compactPorts}
{@const displayPorts = compactPorts && ports.length > 1 ? [ports[0]] : ports}
{@const remainingCount = ports.length - 1}
@@ -1916,6 +1977,36 @@
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
</a>
{/if}
<!-- Traefik fallback URLs (#2). dockhand.url suppresses these. -->
{#each traefikUrls as t}
<a
href={t.url}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
class="inline-flex items-center gap-0.5 text-xs bg-primary/10 hover:bg-primary/20 text-primary px-1 py-0.5 rounded transition-colors shrink-0"
title="Traefik router {t.router}{t.url}"
>
<Globe class="w-2.5 h-2.5" />
<span class="max-w-[120px] truncate">{t.url.replace(/^https?:\/\//, '')}</span>
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
</a>
{/each}
<!-- Pangolin fallback URLs (#2 follow-up). dockhand.url suppresses these. -->
{#each pangolinUrls as p}
<a
href={p.url}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
class="inline-flex items-center gap-0.5 text-xs bg-primary/10 hover:bg-primary/20 text-primary px-1 py-0.5 rounded transition-colors shrink-0"
title="Pangolin resource {p.resource}{p.url}"
>
<Globe class="w-2.5 h-2.5" />
<span class="max-w-[120px] truncate">{p.displayName ?? p.url.replace(/^https?:\/\//, '')}</span>
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
</a>
{/each}
{#each displayPorts as port}
{@const portParsed = parseCustomUrl(container.labels?.[`dockhand.port.${port.publicPort}.url`])}
{@const portUrl = portParsed?.url || null}
@@ -2381,6 +2472,11 @@
// Refresh the container list
fetchContainers();
}}
onStart={$canAccess('containers', 'start') ? (id) => startContainer(id) : undefined}
onStop={$canAccess('containers', 'stop') ? (id) => stopContainer(id) : undefined}
onRestart={$canAccess('containers', 'restart') ? (id) => restartContainer(id) : undefined}
onRemove={$canAccess('containers', 'remove') ? (id) => removeContainer(id) : undefined}
onEdit={$canAccess('containers', 'edit') ? (id) => editContainer(id) : undefined}
/>
<FileBrowserModal
+16 -4
View File
@@ -76,6 +76,10 @@
}
let status = $state<'idle' | 'updating' | 'complete' | 'error'>('idle');
// Incremented on each close. The startUpdate() poll loop captures the
// run id at start and stops mutating state once it no longer matches —
// prevents a stale background poll from clobbering the next run (#1094).
let runId = 0;
let progress = $state<ContainerProgress[]>([]);
let progressListEl = $state<HTMLDivElement | null>(null);
let scrollTick = $state(0);
@@ -139,6 +143,7 @@
async function startUpdate() {
if (containerIds.length === 0) return;
const myRunId = ++runId;
status = 'updating';
progress = [];
currentIndex = 0;
@@ -164,6 +169,8 @@
const blockedIds: string[] = [];
await watchJob(jobId, (line) => {
// If the user closed the modal (or started another run), drop the line.
if (myRunId !== runId) return;
try {
const data = line.data as any;
scrollTick++;
@@ -282,6 +289,7 @@
});
} catch (error: any) {
console.error('Failed to update containers:', error);
if (myRunId !== runId) return;
status = 'error';
errorMessage = error.message || 'Failed to update';
}
@@ -290,6 +298,9 @@
function handleClose() {
open = false;
onClose();
// Invalidate any in-flight poll so its onLine callbacks stop mutating
// state for this modal instance (#1094).
runId++;
// Reset state
status = 'idle';
progress = [];
@@ -300,10 +311,11 @@
}
function handleOpenChange(isOpen: boolean) {
if (!isOpen && status === 'updating') {
// Don't allow closing while updating
return;
}
// The X button (DialogPrimitive.Close) bypasses controlled close, so
// returning early here without resetting state strands `status` at
// 'updating' and the next open of this modal never re-triggers
// startUpdate() (#1094). Always reset; the in-flight poll loop
// finishes against the server but the UI is freed.
if (!isOpen) {
handleClose();
}
@@ -5,7 +5,8 @@
import * as Tabs from '$lib/components/ui/tabs';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, XCircle, Activity, Wifi, Pencil, RefreshCw, X, FolderOpen, Moon, Tags, ExternalLink, Gpu, Globe, Link, Unlink } from 'lucide-svelte';
import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, XCircle, Activity, Wifi, Pencil, RefreshCw, X, FolderOpen, Moon, Tags, ExternalLink, Gpu, Globe, Link, Unlink, Play, Square as SquareIcon, RotateCw, Trash2 } from 'lucide-svelte';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import * as Select from '$lib/components/ui/select';
import { toast } from 'svelte-sonner';
import * as Tooltip from '$lib/components/ui/tooltip';
@@ -26,9 +27,73 @@
containerId: string;
containerName?: string;
onRename?: (newName: string) => void;
// Lifecycle handlers from the parent (#461). Non-destructive actions
// return a promise so the modal can refresh inspect data afterwards;
// onRemove and onEdit close the modal themselves.
onStart?: (id: string) => Promise<void> | void;
onStop?: (id: string) => Promise<void> | void;
onRestart?: (id: string) => Promise<void> | void;
onRemove?: (id: string) => Promise<void> | void;
onEdit?: (id: string) => void;
}
let { open = $bindable(), containerId, containerName, onRename }: Props = $props();
let { open = $bindable(), containerId, containerName, onRename, onStart, onStop, onRestart, onRemove, onEdit }: Props = $props();
// Confirmation-popover open state for the destructive actions in the header.
let confirmStopOpen = $state(false);
let confirmRestartOpen = $state(false);
let confirmRemoveOpen = $state(false);
// Per-action in-flight flags so the corresponding icon can spin/pulse.
let starting = $state(false);
let stopping = $state(false);
let restarting = $state(false);
async function doStart() {
if (!onStart) return;
starting = true;
try {
await onStart(containerId);
await fetchContainerInspect();
} finally {
starting = false;
}
}
async function doStop() {
if (!onStop) return;
stopping = true;
try {
await onStop(containerId);
await fetchContainerInspect();
} finally {
stopping = false;
}
}
async function doRestart() {
if (!onRestart) return;
restarting = true;
try {
await onRestart(containerId);
await fetchContainerInspect();
} finally {
restarting = false;
}
}
async function doRemove() {
if (!onRemove) return;
try {
await onRemove(containerId);
} finally {
// Always close: the container is either gone or the parent toasted an error.
open = false;
}
}
function doEdit() {
if (!onEdit) return;
// Close the inspect modal first so EditContainerModal isn't stacked on top.
open = false;
onEdit(containerId);
}
// Rename state
let isEditing = $state(false);
@@ -652,16 +717,91 @@
</span>
{/if}
{#if containerData && !loading}
<Button
variant="outline"
size="sm"
onclick={() => showRawJson = true}
title="View raw inspect data"
class="ml-auto mr-6"
>
<Code class="w-4 h-4 mr-1.5" />
Inspect
</Button>
<div class="ml-auto mr-6 flex items-center gap-1">
<!-- Lifecycle actions (#461). Mirrors the per-row action set on the containers page;
non-destructive actions refresh the inspect data in place, Delete closes the modal. -->
{#if containerData.State?.Running}
{#if onStop}
<ConfirmPopover
open={confirmStopOpen}
action="Stop"
itemType="container"
itemName={displayName || containerId.slice(0, 12)}
title="Stop"
onConfirm={doStop}
onOpenChange={(o) => confirmStopOpen = o}
>
{#snippet children({ open })}
<SquareIcon class="w-4 h-4 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'} {stopping ? 'animate-pulse text-destructive' : ''}" />
{/snippet}
</ConfirmPopover>
{/if}
{#if onRestart}
<ConfirmPopover
open={confirmRestartOpen}
action="Restart"
itemType="container"
itemName={displayName || containerId.slice(0, 12)}
title="Restart"
variant="secondary"
onConfirm={doRestart}
onOpenChange={(o) => confirmRestartOpen = o}
>
{#snippet children({ open })}
<RotateCw class="w-4 h-4 {open ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'} {restarting ? 'animate-spin text-foreground' : ''}" />
{/snippet}
</ConfirmPopover>
{/if}
{:else}
{#if onStart}
<button
type="button"
onclick={doStart}
title="Start"
disabled={starting}
class="p-1 rounded hover:bg-muted transition-colors cursor-pointer disabled:opacity-50"
>
<Play class="w-4 h-4 {starting ? 'animate-pulse text-emerald-500' : 'text-muted-foreground hover:text-emerald-500'}" />
</button>
{/if}
{/if}
{#if onEdit}
<button
type="button"
onclick={doEdit}
title="Edit"
class="p-1 rounded hover:bg-muted transition-colors cursor-pointer"
>
<Pencil class="w-4 h-4 text-muted-foreground hover:text-foreground" />
</button>
{/if}
{#if onRemove}
<ConfirmPopover
open={confirmRemoveOpen}
action="Delete"
itemType="container"
itemName={displayName || containerId.slice(0, 12)}
title="Delete"
variant="destructive"
onConfirm={doRemove}
onOpenChange={(o) => confirmRemoveOpen = o}
>
{#snippet children({ open })}
<Trash2 class="w-4 h-4 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
{/snippet}
</ConfirmPopover>
{/if}
<Button
variant="outline"
size="sm"
onclick={() => showRawJson = true}
title="View raw inspect data"
class="ml-1"
>
<Code class="w-4 h-4 mr-1.5" />
Inspect
</Button>
</div>
{/if}
</Dialog.Title>
</Dialog.Header>
@@ -605,7 +605,12 @@
<Dialog.Root bind:open onOpenChange={(isOpen) => isOpen && focusFirstInput()}>
<Dialog.Content class="max-w-4xl w-full h-[85vh] p-0 flex flex-col overflow-hidden !zoom-in-0 !zoom-out-0" showCloseButton={false}>
<Dialog.Header class="px-5 py-4 border-b bg-muted/30 shrink-0 sticky top-0 z-10">
<Dialog.Title class="text-base font-semibold">Create new container</Dialog.Title>
<Dialog.Title class="text-base font-semibold">
Create new container
{#if $currentEnvironment}
<span class="font-medium">on <span class="text-amber-600 dark:text-amber-400">{$currentEnvironment.name}</span></span>
{/if}
</Dialog.Title>
<button
type="button"
onclick={handleClose}
+5 -2
View File
@@ -593,9 +593,12 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
try {
const response = await fetch(appendEnvParam('/api/containers', envId));
const allContainers = await response.json();
// Show running and exited containers (logs are available for both)
// Docker keeps the log file across state transitions, so every
// state has logs to serve. Excluding `restarting` made crash-
// looping containers unselectable mid-loop (#227); paused/
// created/dead are listed for the same reason.
const loggableContainers = allContainers.filter((c: ContainerInfo) =>
c.state === 'running' || c.state === 'exited'
['running', 'exited', 'restarting', 'paused', 'created', 'dead'].includes(c.state)
);
// Before updating containers, capture current running set for grouped mode change detection
@@ -79,6 +79,7 @@
containers: number;
images: number;
name: string;
engine?: 'docker' | 'podman';
};
}
@@ -576,7 +577,12 @@
<!-- Docker Version Column -->
<Table.Cell>
{#if testResult?.info?.serverVersion}
<span class="text-sm text-muted-foreground">{testResult.info.serverVersion}</span>
<div class="flex items-center gap-1.5">
<span class="text-sm text-muted-foreground">{testResult.info.serverVersion}</span>
{#if testResult.info.engine === 'podman'}
<Badge variant="secondary" class="text-[10px] px-1.5 py-0 leading-tight">Podman</Badge>
{/if}
</div>
{:else}
<span class="text-muted-foreground text-sm"></span>
{/if}
+82 -8
View File
@@ -23,6 +23,8 @@
let highlightUpdates = $derived($appSettings.highlightUpdates);
let compactPorts = $derived($appSettings.compactPorts);
let showExposedPorts = $derived($appSettings.showExposedPorts);
let honorProxyLabels = $derived($appSettings.honorProxyLabels);
let showImageChangelogLinks = $derived($appSettings.showImageChangelogLinks);
let timeFormat = $derived($appSettings.timeFormat);
let dateFormat = $derived($appSettings.dateFormat);
let downloadFormat = $derived($appSettings.downloadFormat);
@@ -116,6 +118,18 @@ services:
{ value: 'YYYY-MM-DD', label: 'YYYY-MM-DD', example: '2024-12-31' }
];
const downloadFormatOptions: { value: DownloadFormat; label: string; description: string }[] = [
{ value: 'tar', label: 'tar', description: 'Uncompressed archive' },
{ value: 'tar.gz', label: 'tar.gz', description: 'Gzip-compressed archive' },
{ value: 'raw', label: 'No archive', description: 'Single file, raw bytes' }
];
const downloadFormatLabel: Record<DownloadFormat, string> = {
tar: 'tar',
'tar.gz': 'tar.gz',
raw: 'No archive'
};
function handleScheduleRetentionChange(e: Event) {
const value = Math.max(1, Math.min(365, parseInt((e.target as HTMLInputElement).value) || 30));
appSettings.setScheduleRetentionDays(value);
@@ -288,6 +302,28 @@ services:
</div>
<p class="text-xs text-muted-foreground">Highlight container rows in amber when updates are available</p>
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Show changelog links</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="top" class="max-w-xs">
<p>Surface a release-notes link next to the image name on rows with updates available. The link is resolved from the image's <code>org.opencontainers.image.source</code> label, from the <code>ghcr.io</code> registry path, or from an explicit <code>dockhand.changelog.url</code> label override.</p>
</Tooltip.Content>
</Tooltip.Root>
<TogglePill
checked={showImageChangelogLinks}
onchange={(checked) => {
appSettings.setShowImageChangelogLinks(checked);
toast.success(checked ? 'Changelog links shown' : 'Changelog links hidden');
}}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Show a release-notes icon next to images with updates available</p>
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Compact port display</Label>
@@ -324,6 +360,28 @@ services:
</div>
<p class="text-xs text-muted-foreground">Display internal container ports in the container list grid</p>
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Honor Traefik/Pangolin labels</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="top" class="max-w-xs">
<p>Parse <code>traefik.http.routers.&lt;name&gt;.rule</code> and <code>pangolin.proxy-resources.&lt;name&gt;.full-domain</code> labels and surface the resulting URLs as clickable pills next to ports. When off, only explicit <code>dockhand.url</code> labels are shown.</p>
</Tooltip.Content>
</Tooltip.Root>
<TogglePill
checked={honorProxyLabels}
onchange={(checked) => {
appSettings.setHonorProxyLabels(checked);
toast.success(checked ? 'Proxy labels honored' : 'Proxy labels ignored');
}}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Show URLs inferred from Traefik and Pangolin labels alongside dockhand.url</p>
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Time format</Label>
@@ -470,18 +528,34 @@ services:
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Download format</Label>
<ToggleSwitch
<Select.Root
type="single"
value={downloadFormat}
leftValue="tar"
rightValue="tar.gz"
onchange={(newFormat) => {
appSettings.setDownloadFormat(newFormat as DownloadFormat);
toast.success(`Download format set to ${newFormat}`);
onValueChange={(value) => {
if (value) {
appSettings.setDownloadFormat(value as DownloadFormat);
toast.success(`Download format set to ${downloadFormatLabel[value as DownloadFormat]}`);
}
}}
disabled={!$canAccess('settings', 'edit')}
/>
>
<Select.Trigger class="w-[180px]">
<FileText class="w-4 h-4 mr-2" />
<span>{downloadFormatLabel[downloadFormat]}</span>
</Select.Trigger>
<Select.Content>
{#each downloadFormatOptions as option}
<Select.Item value={option.value}>
<div class="flex items-center justify-between w-full gap-4">
<span>{option.label}</span>
<span class="text-xs text-muted-foreground">{option.description}</span>
</div>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<p class="text-xs text-muted-foreground">Archive format when downloading files from containers</p>
<p class="text-xs text-muted-foreground">Format when downloading files from containers or volumes. "No archive" emits raw bytes for single files; directories still download as tar.</p>
</div>
</div>
<div class="space-y-4">
+35
View File
@@ -18,6 +18,9 @@
import { Play, Square, Trash2, Plus, ArrowBigDown, Search, Pencil, ExternalLink, GitBranch, RefreshCw, Loader2, FileCode, FileText, FileOutput, Box, RotateCcw, ScrollText, Terminal, Eye, Network, HardDrive, Heart, HeartPulse, HeartOff, ChevronsUpDown, ChevronsDownUp, Rocket, AlertTriangle, X, Layers, Pause, CircleDashed, Skull, FolderOpen, Variable, Clock, RotateCw, Import, Ship, Cable, LayoutPanelLeft, Rows3, GripVertical, Globe } from 'lucide-svelte';
import { formatPorts } from '$lib/utils/port-format';
import { parseCustomUrl } from '$lib/utils/custom-url';
import { extractTraefikUrls } from '$lib/utils/traefik-urls';
import { extractPangolinUrls } from '$lib/utils/pangolin-urls';
import { appSettings } from '$lib/stores/settings';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
import type { ComposeStackInfo, ContainerStats } from '$lib/types';
@@ -2059,6 +2062,38 @@
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
</a>
{/if}
{:else}
<!-- Traefik fallback URLs (#2). dockhand.url suppresses these, as does the
"Honor Traefik/Pangolin labels" setting being off. -->
{#each ($appSettings.honorProxyLabels ? extractTraefikUrls(container.labels) : []) as t}
<a
href={t.url}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
title="Traefik router {t.router}{t.url}"
>
<Globe class="w-2.5 h-2.5" />
<span class="max-w-[120px] truncate">{t.url.replace(/^https?:\/\//, '')}</span>
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
</a>
{/each}
<!-- Pangolin fallback URLs (#2 follow-up). Same suppression rules. -->
{#each ($appSettings.honorProxyLabels ? extractPangolinUrls(container.labels) : []) as p}
<a
href={p.url}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
title="Pangolin resource {p.resource}{p.url}"
>
<Globe class="w-2.5 h-2.5" />
<span class="max-w-[120px] truncate">{p.displayName ?? p.url.replace(/^https?:\/\//, '')}</span>
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
</a>
{/each}
{/if}
<!-- Clickable ports with range collapsing -->
{#if container.ports.length > 0}
+11 -3
View File
@@ -35,11 +35,13 @@
open: boolean;
mode: 'create' | 'edit';
stackName?: string; // Required for edit mode, optional for create
initialCompose?: string; // Pre-fill compose content (for library deploy)
initialStackName?: string; // Pre-fill stack name (for library deploy)
onClose: () => void;
onSuccess: () => void; // Called after create or save
}
let { open = $bindable(), mode: propMode, stackName: propStackName = '', onClose, onSuccess }: Props = $props();
let { open = $bindable(), mode: propMode, stackName: propStackName = '', initialCompose, initialStackName, onClose, onSuccess }: Props = $props();
// Local effective state - can transition from create → edit after failed deploy
let mode = $state(propMode);
@@ -1222,8 +1224,11 @@
validateEnvVars();
});
} else if (mode === 'create') {
// Set default compose content for create mode
composeContent = defaultCompose;
// Set default compose content for create mode (library templates override default)
composeContent = initialCompose || defaultCompose;
if (initialStackName) {
newStackName = initialStackName;
}
isDirty = false; // Reset dirty flag for new modal
loading = false;
// Auto-validate default compose
@@ -1351,6 +1356,9 @@
{:else}
{stackName}
{/if}
{#if $currentEnvironment}
<span class="font-medium">on <span class="text-amber-600 dark:text-amber-400">{$currentEnvironment.name}</span></span>
{/if}
</Dialog.Title>
<Dialog.Description class="text-xs text-zinc-500 dark:text-zinc-400">
{#if mode === 'create'}
+301
View File
@@ -0,0 +1,301 @@
<svelte:head>
<title>Templates - Dockhand</title>
</svelte:head>
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Input } from '$lib/components/ui/input';
import * as Select from '$lib/components/ui/select';
import { Skeleton } from '$lib/components/ui/skeleton';
import { EmptyState } from '$lib/components/ui/empty-state';
import PageHeader from '$lib/components/PageHeader.svelte';
import {
LibraryBig,
Search,
RefreshCw,
Loader2,
Package,
Settings2
} from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import type { TemplateItem } from '../api/templates/+server';
import TemplateCard from './TemplateCard.svelte';
import TemplateSourcesTab from './TemplateSourcesTab.svelte';
import StackModal from '../stacks/StackModal.svelte';
// State
let templates = $state<TemplateItem[]>([]);
let loading = $state(true);
let searchQuery = $state('');
let debouncedQuery = $state('');
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let selectedCategories = $state<string[]>([]);
let selectedSources = $state<string[]>([]);
let activeTab = $state<'browse' | 'sources'>('browse');
let loadingTemplateId = $state<string | null>(null);
// Debounce search input
$effect(() => {
const q = searchQuery;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => { debouncedQuery = q; }, 200);
});
onDestroy(() => { if (debounceTimer) clearTimeout(debounceTimer); });
// StackModal state
let showStackModal = $state(false);
let stackModalCompose = $state('');
let stackModalName = $state('');
// Client-side cache
let cacheTimestamp = 0;
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
// Derived
let allCategories = $derived(
[...new Set(templates.flatMap(t => t.categories))].sort()
);
let allSources = $derived(
[...new Set(templates.map(t => t.source))]
);
let filteredTemplates = $derived(templates.filter(t => {
const q = debouncedQuery.toLowerCase();
const matchesSearch = !q ||
t.title.toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q);
const matchesCategory = selectedCategories.length === 0 ||
t.categories.some(c => selectedCategories.includes(c));
const matchesSource = selectedSources.length === 0 ||
selectedSources.includes(t.source);
return matchesSearch && matchesCategory && matchesSource;
}));
async function fetchTemplates(force = false) {
if (!force && templates.length > 0 && Date.now() - cacheTimestamp < CACHE_TTL) {
return;
}
loading = true;
try {
const response = await fetch('/api/templates');
if (!response.ok) throw new Error('Failed to fetch');
templates = await response.json();
cacheTimestamp = Date.now();
} catch {
toast.error('Failed to load library templates');
} finally {
loading = false;
}
}
async function handleCardClick(template: TemplateItem) {
loadingTemplateId = template.id;
try {
const response = await fetch('/api/templates/compose', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to generate compose');
}
const { compose } = await response.json();
stackModalName = template.title.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
stackModalCompose = compose;
showStackModal = true;
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to load template');
} finally {
loadingTemplateId = null;
}
}
function handleSourcesChanged() {
fetchTemplates(true);
}
onMount(() => {
fetchTemplates();
});
</script>
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<!-- Header -->
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<PageHeader icon={LibraryBig} title="Templates" count={loading ? undefined : filteredTemplates.length} showConnection={false}>
<button
class="p-1 rounded hover:bg-muted transition-colors"
onclick={() => fetchTemplates(true)}
disabled={loading}
title="Refresh templates"
>
{#if loading}
<Loader2 class="w-3.5 h-3.5 animate-spin text-emerald-500" />
{:else}
<RefreshCw class="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
{/if}
</button>
</PageHeader>
<div class="flex items-center gap-2">
<!-- Tab toggle -->
<div class="flex items-center gap-0.5 bg-zinc-200 dark:bg-zinc-700 rounded-md p-0.5">
<button
class="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium transition-colors {activeTab === 'browse' ? 'bg-white dark:bg-zinc-900 shadow-sm' : 'text-muted-foreground hover:text-foreground'}"
onclick={() => activeTab = 'browse'}
>
<Package class="w-3.5 h-3.5" />
Browse
</button>
<button
class="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium transition-colors {activeTab === 'sources' ? 'bg-white dark:bg-zinc-900 shadow-sm' : 'text-muted-foreground hover:text-foreground'}"
onclick={() => activeTab = 'sources'}
>
<Settings2 class="w-3.5 h-3.5" />
Sources
</button>
</div>
</div>
</div>
{#if activeTab === 'browse'}
<!-- Filter bar -->
<div class="shrink-0 flex flex-wrap items-center gap-2">
<div class="relative">
<Search class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
placeholder="Search templates..."
class="pl-9 w-64 h-8 text-sm"
bind:value={searchQuery}
onkeydown={(e) => e.key === 'Escape' && (searchQuery = '')}
/>
</div>
<!-- Category filter -->
{#if allCategories.length > 0}
<Select.Root type="multiple" bind:value={selectedCategories}>
<Select.Trigger size="sm" class="w-44 text-sm">
<span class="truncate">
{#if selectedCategories.length === 0}
All categories
{:else if selectedCategories.length === 1}
{selectedCategories[0]}
{:else}
{selectedCategories.length} categories
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-64 overflow-y-auto">
{#each allCategories as category}
<Select.Item value={category}>{category}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/if}
<!-- Source filter -->
{#if allSources.length > 1}
<Select.Root type="multiple" bind:value={selectedSources}>
<Select.Trigger size="sm" class="w-48 text-sm">
<span class="truncate">
{#if selectedSources.length === 0}
All sources
{:else if selectedSources.length === 1}
{selectedSources[0]}
{:else}
{selectedSources.length} sources
{/if}
</span>
</Select.Trigger>
<Select.Content>
{#each allSources as source}
<Select.Item value={source}>{source}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/if}
<!-- Active filter badges -->
{#if selectedCategories.length > 0 || selectedSources.length > 0 || searchQuery}
<Button
size="sm"
variant="ghost"
class="text-xs"
onclick={() => { selectedCategories = []; selectedSources = []; searchQuery = ''; }}
>
Clear filters
</Button>
{/if}
</div>
<!-- Card grid -->
<div class="flex-1 overflow-y-auto min-h-0">
{#if loading}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 p-1">
{#each Array(12) as _}
<div class="rounded-xl border bg-card p-4 space-y-3">
<div class="flex items-start gap-3">
<Skeleton class="w-10 h-10 rounded-lg" />
<div class="flex-1 space-y-2">
<Skeleton class="h-4 w-3/4" />
<Skeleton class="h-3 w-1/3" />
</div>
</div>
<div class="space-y-1.5">
<Skeleton class="h-3 w-full" />
<Skeleton class="h-3 w-full" />
<Skeleton class="h-3 w-2/3" />
</div>
<div class="flex gap-1.5">
<Skeleton class="h-4 w-14 rounded-full" />
<Skeleton class="h-4 w-16 rounded-full" />
</div>
</div>
{/each}
</div>
{:else if filteredTemplates.length === 0}
<div class="flex items-center justify-center h-full">
<EmptyState
icon={Package}
title={templates.length === 0 ? 'No template sources configured' : 'No templates match your filters'}
description={templates.length === 0 ? 'Go to the Sources tab to enable template catalogs' : 'Try adjusting your search or filter criteria'}
/>
</div>
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 p-1">
{#each filteredTemplates as template (template.id)}
<TemplateCard
{template}
loading={loadingTemplateId === template.id}
onclick={() => handleCardClick(template)}
/>
{/each}
</div>
{/if}
</div>
{:else}
<!-- Sources tab -->
<div class="flex-1 overflow-y-auto min-h-0 p-1">
<TemplateSourcesTab onSourcesChanged={handleSourcesChanged} />
</div>
{/if}
</div>
<!-- StackModal for deploying templates -->
<StackModal
bind:open={showStackModal}
mode="create"
initialCompose={stackModalCompose}
initialStackName={stackModalName}
onClose={() => showStackModal = false}
onSuccess={() => {
showStackModal = false;
toast.success('Stack deployed from library template');
}}
/>
+108
View File
@@ -0,0 +1,108 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge';
import { Package, Star, Download, Loader2 } from 'lucide-svelte';
import type { TemplateItem } from '../api/templates/+server';
interface Props {
template: TemplateItem;
loading?: boolean;
onclick: () => void;
}
let { template, loading = false, onclick }: Props = $props();
let logoError = $state(false);
const MAX_CATEGORIES = 3;
const visibleCategories = $derived(template.categories.slice(0, MAX_CATEGORIES));
const overflowCount = $derived(Math.max(0, template.categories.length - MAX_CATEGORIES));
function formatPulls(pulls: number): string {
if (pulls >= 1_000_000) return `${(pulls / 1_000_000).toFixed(1)}M`;
if (pulls >= 1_000) return `${(pulls / 1_000).toFixed(1)}K`;
return String(pulls);
}
// Convert markdown links [text](url) to HTML <a> tags, strip other HTML
function renderDescription(text: string): string {
return text
.replace(/<a\s+href="([^"]+)"[^>]*>([^<]+)<\/a>/gi, '[$2]($1)') // normalize HTML links to markdown first
.replace(/<[^>]+>/g, '') // strip remaining HTML tags
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener" class="text-primary hover:underline">$1</a>')
.trim();
}
</script>
<button
class="text-left w-full group"
onclick={onclick}
disabled={loading}
>
<Card.Root class="h-full gap-0 py-0 transition-all hover:border-primary/50 hover:shadow-md group-focus-visible:ring-2 group-focus-visible:ring-ring {loading ? 'opacity-60' : ''}">
<Card.Header class="p-3 pb-1.5">
<div class="flex items-start gap-2.5">
<!-- Logo -->
<div class="w-8 h-8 rounded-md bg-muted flex items-center justify-center flex-shrink-0 overflow-hidden">
{#if template.logo && !logoError}
<img
src={template.logo}
alt={template.title}
class="w-8 h-8 object-contain rounded-md"
loading="lazy"
onerror={() => logoError = true}
/>
{:else}
<Package class="w-4 h-4 text-muted-foreground" />
{/if}
</div>
<!-- Title + source -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<Card.Title class="text-sm font-semibold truncate flex-1">{template.title}</Card.Title>
{#if loading}
<Loader2 class="w-3.5 h-3.5 animate-spin text-muted-foreground" />
{/if}
</div>
<Badge variant="outline" class="text-2xs px-1.5 py-0 font-normal">
{template.source}
</Badge>
</div>
</div>
</Card.Header>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<Card.Content class="px-3 pb-2 pt-0" onclick={(e: MouseEvent) => { if ((e.target as HTMLElement).tagName === 'A') e.stopPropagation(); }}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<p class="text-xs text-muted-foreground line-clamp-2">
{@html renderDescription(template.description) || 'No description available'}
</p>
</Card.Content>
<Card.Footer class="px-3 pb-3 pt-0 flex items-center gap-1.5 flex-wrap">
{#each visibleCategories as category}
<Badge variant="secondary" class="text-2xs px-1.5 py-0">
{category}
</Badge>
{/each}
{#if overflowCount > 0}
<span class="text-2xs text-muted-foreground">+{overflowCount}</span>
{/if}
<!-- LinuxServer metadata -->
{#if template.stars || template.pulls}
<div class="ml-auto flex items-center gap-2 text-2xs text-muted-foreground">
{#if template.stars}
<span class="flex items-center gap-0.5">
<Star class="w-3 h-3" />
{template.stars}
</span>
{/if}
{#if template.pulls}
<span class="flex items-center gap-0.5">
<Download class="w-3 h-3" />
{formatPulls(template.pulls)}
</span>
{/if}
</div>
{/if}
</Card.Footer>
</Card.Root>
</button>
@@ -0,0 +1,254 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { Plus, Trash2, Globe, Loader2, CheckCircle2, XCircle, ShieldCheck } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import type { TemplateSource } from '$lib/server/templates';
interface Props {
onSourcesChanged: () => void;
}
let { onSourcesChanged }: Props = $props();
let sources = $state<TemplateSource[]>([]);
let loading = $state(true);
let addingNew = $state(false);
let newName = $state('');
let newUrl = $state('');
let validating = $state(false);
let validationResults = $state<Map<string, { ok: boolean; count?: number; error?: string }>>(new Map());
async function loadSources() {
loading = true;
try {
const response = await fetch('/api/templates/sources');
if (response.ok) {
sources = await response.json();
}
} catch {
toast.error('Failed to load template sources');
} finally {
loading = false;
}
}
async function toggleSource(source: TemplateSource) {
const newEnabled = !source.enabled;
source.enabled = newEnabled;
sources = sources;
try {
const response = await fetch('/api/templates/sources', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: source.id, enabled: newEnabled })
});
if (!response.ok) throw new Error();
onSourcesChanged();
} catch {
source.enabled = !newEnabled;
sources = sources;
toast.error('Failed to update source');
}
}
async function removeSource(source: TemplateSource) {
try {
const response = await fetch(`/api/templates/sources?id=${source.id}`, { method: 'DELETE' });
if (!response.ok) throw new Error();
sources = sources.filter(s => s.id !== source.id);
toast.success('Source removed');
onSourcesChanged();
} catch {
toast.error('Failed to remove source');
}
}
async function addSource() {
if (!newName.trim() || !newUrl.trim()) return;
try {
const response = await fetch('/api/templates/sources', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName.trim(), url: newUrl.trim() })
});
if (!response.ok) throw new Error();
const newSource = await response.json();
sources = [...sources, newSource];
newName = '';
newUrl = '';
addingNew = false;
toast.success('Source added');
onSourcesChanged();
} catch {
toast.error('Failed to add source');
}
}
async function validateAllSources() {
validating = true;
validationResults = new Map();
let failedCount = 0;
const checks = sources.map(async (source) => {
const key = source.sourceId;
try {
const response = await fetch(source.url, {
signal: AbortSignal.timeout(15000)
});
if (!response.ok) {
validationResults.set(key, { ok: false, error: `HTTP ${response.status}` });
failedCount++;
return;
}
const data = await response.json();
const templates = Array.isArray(data) ? data : (data.templates || []);
validationResults.set(key, { ok: true, count: templates.length });
} catch (error) {
const msg = error instanceof Error ? error.message : 'Connection failed';
validationResults.set(key, { ok: false, error: msg });
failedCount++;
}
});
await Promise.allSettled(checks);
validationResults = new Map(validationResults);
validating = false;
if (failedCount > 0) {
toast.warning(`${failedCount} source(s) failed validation`);
} else {
toast.success('All sources are reachable');
}
}
async function disableInactive() {
let disabled = 0;
for (const source of sources) {
const result = validationResults.get(source.sourceId);
if (result && !result.ok && source.enabled) {
await fetch('/api/templates/sources', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: source.id, enabled: false })
});
source.enabled = false;
disabled++;
}
}
sources = sources;
if (disabled > 0) {
toast.success(`Disabled ${disabled} inactive source(s)`);
onSourcesChanged();
}
}
$effect(() => {
loadSources();
});
</script>
<div class="space-y-4 max-w-3xl">
<div class="flex items-center justify-between">
<p class="text-sm text-muted-foreground">
Configure template catalog sources. Templates are fetched and cached for 1 hour.
</p>
<div class="flex items-center gap-2">
<Button size="sm" variant="outline" onclick={validateAllSources} disabled={validating}>
{#if validating}
<Loader2 class="w-3.5 h-3.5 mr-1.5 animate-spin" />
Validating...
{:else}
<ShieldCheck class="w-3.5 h-3.5 mr-1.5" />
Validate
{/if}
</Button>
{#if validationResults.size > 0 && [...validationResults.values()].some(v => !v.ok)}
<Button size="sm" variant="outline" onclick={disableInactive}>
<XCircle class="w-3.5 h-3.5 mr-1.5" />
Disable inactive
</Button>
{/if}
<Button size="sm" onclick={() => addingNew = !addingNew}>
<Plus class="w-3.5 h-3.5 mr-1.5" />
Add source
</Button>
</div>
</div>
{#if addingNew}
<Card.Root class="gap-0 py-0 border-dashed border-primary/50">
<Card.Content class="p-3">
<div class="flex items-end gap-3">
<div class="flex-1 space-y-1">
<label for="new-source-name" class="text-xs font-medium text-muted-foreground">Name</label>
<Input id="new-source-name" bind:value={newName} placeholder="My templates" class="h-8 text-sm" />
</div>
<div class="flex-[2] space-y-1">
<label for="new-source-url" class="text-xs font-medium text-muted-foreground">URL</label>
<Input id="new-source-url" bind:value={newUrl} placeholder="https://example.com/templates.json" class="h-8 text-sm" />
</div>
<Button size="sm" onclick={addSource} disabled={!newName.trim() || !newUrl.trim()}>Add</Button>
<Button size="sm" variant="ghost" onclick={() => addingNew = false}>Cancel</Button>
</div>
</Card.Content>
</Card.Root>
{/if}
{#if loading}
<div class="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 class="w-5 h-5 animate-spin mr-2" />
Loading sources...
</div>
{:else}
<div class="space-y-2">
{#each sources as source (source.id)}
{@const validation = validationResults.get(source.sourceId)}
<Card.Root class="gap-0 py-0">
<Card.Content class="py-3 px-4">
<div class="flex items-center gap-4">
<div class="w-8 h-8 rounded-md bg-muted flex items-center justify-center flex-shrink-0">
{#if validation}
{#if validation.ok}
<CheckCircle2 class="w-4 h-4 text-emerald-500" />
{:else}
<XCircle class="w-4 h-4 text-destructive" />
{/if}
{:else}
<Globe class="w-4 h-4 text-muted-foreground" />
{/if}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{source.name}</span>
{#if validation?.ok && validation.count !== undefined}
<span class="text-xs text-muted-foreground">({validation.count} templates)</span>
{/if}
</div>
<div class="text-xs text-muted-foreground truncate">{source.url}</div>
{#if validation && !validation.ok}
<div class="text-xs text-destructive mt-0.5">{validation.error}</div>
{/if}
</div>
<TogglePill
checked={source.enabled}
onchange={() => toggleSource(source)}
/>
{#if !source.builtin}
<Button
size="icon-sm"
variant="ghost"
onclick={() => removeSource(source)}
>
<Trash2 class="w-3.5 h-3.5 text-destructive" />
</Button>
{/if}
</div>
</Card.Content>
</Card.Root>
{/each}
</div>
{/if}
</div>
+25
View File
@@ -574,6 +574,20 @@ function webSocketPlugin(): Plugin {
return;
}
const authFn = (globalThis as any).__authenticateWsUpgrade;
if (typeof authFn !== 'function') {
ws.send(JSON.stringify({ type: 'error', message: 'service unavailable' }));
ws.close(1011, 'service unavailable');
return;
}
const wsAuth = await authFn(req.headers);
if (!wsAuth) {
ws.send(JSON.stringify({ type: 'error', message: 'Unauthorized' }));
ws.close(1008, 'unauthorized');
return;
}
(ws as any).__auth = wsAuth;
// Assign unique connection ID to this WebSocket
const connId = `ws-${++wsConnectionCounter}`;
meta.connId = connId;
@@ -594,6 +608,17 @@ function webSocketPlugin(): Plugin {
return;
}
const canAccessFn = (globalThis as any).__canAccessEnvForUser;
if (typeof canAccessFn === 'function') {
const ok = await canAccessFn(wsAuth, envId);
if (!ok) {
console.warn(`[WS] env access denied: user=${wsAuth.username} envId=${envId}`);
ws.send(JSON.stringify({ type: 'error', message: 'Access denied for this environment' }));
ws.close(1008, 'env access denied');
return;
}
}
const target = getDockerTarget(envId);
try {