This commit is contained in:
jarek
2026-03-13 08:22:10 +01:00
parent 790ce092ee
commit 55f3101a19
51 changed files with 763 additions and 599 deletions
+3 -3
View File
@@ -37,7 +37,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
" - busybox" \
" - tzdata" \
" - docker-cli" \
" - docker-compose" \
" - docker-compose=5.0.2-r1" \
" - docker-cli-buildx" \
" - sqlite" \
" - postgresql-client" \
@@ -162,7 +162,7 @@ RUN mkdir -p /home/dockhand/.dockhand/stacks /app/data \
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000/ || exit 1
CMD curl -f http://localhost:${PORT:-3000}/ || exit 1
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
CMD ["node", "/app/server.js"]
CMD []
+1
View File
@@ -0,0 +1 @@
v1.0.21
+10 -1
View File
@@ -274,7 +274,11 @@ func buildTLSConfig(cfg *EnvConfig) (*tls.Config, error) {
}
if cfg.CA != "" {
pool := x509.NewCertPool()
// Start from system cert pool so intermediate CAs can chain to system roots
pool, err := x509.SystemCertPool()
if err != nil {
pool = x509.NewCertPool()
}
if !pool.AppendCertsFromPEM([]byte(cfg.CA)) {
return nil, fmt.Errorf("failed to parse CA certificate")
}
@@ -928,6 +932,11 @@ func main() {
}
}
// stdin closed — parent process exited or pipe broke. Shut down cleanly
// so Node.js can restart us if needed.
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "[collector] stdin read error: %v\n", err)
}
fmt.Fprintf(os.Stderr, "[collector] stdin closed, exiting\n")
mgr.shutdown()
}
+5 -2
View File
@@ -6,11 +6,14 @@ set -e
PUID=${PUID:-1001}
PGID=${PGID:-1001}
# Increase body size limit for container file uploads (default 512KB is too small)
export BODY_SIZE_LIMIT=${BODY_SIZE_LIMIT:-2G}
# Default command (--expose-gc allows forced GC from /api/debug/memory?gc=true)
if [ "$MEMORY_MONITOR" = "true" ]; then
DEFAULT_CMD="node --expose-gc /app/server.js"
DEFAULT_CMD="node --use-openssl-ca --dns-result-order=ipv4first --no-network-family-autoselection --expose-gc /app/server.js"
else
DEFAULT_CMD="node /app/server.js"
DEFAULT_CMD="node --use-openssl-ca --dns-result-order=ipv4first --no-network-family-autoselection /app/server.js"
fi
# === Detect if running as root ===
+3 -1
View File
@@ -1,7 +1,7 @@
{
"name": "dockhand",
"private": true,
"version": "1.0.20",
"version": "1.0.21",
"type": "module",
"scripts": {
"dev": "npx vite dev",
@@ -71,6 +71,7 @@
"@codemirror/view": "6.39.11",
"@lezer/highlight": "1.2.3",
"@lucide/lab": "^0.1.2",
"ansi_up": "6.0.6",
"argon2": "^0.41.1",
"better-sqlite3": "^11.7.0",
"codemirror": "6.0.2",
@@ -86,6 +87,7 @@
"qrcode": "^1.5.4",
"svelte-dnd-action": "0.9.69",
"svelte-sonner": "1.0.7",
"undici": "7.22.0",
"ws": "^8.18.0"
},
"devDependencies": {
+9
View File
@@ -16,6 +16,15 @@ import { randomUUID } from 'node:crypto';
import { WebSocketServer } from 'ws';
import { handler } from './build/handler.js';
// Patch console to prepend ISO timestamps
const _log = console.log;
const _error = console.error;
const _warn = console.warn;
const ts = () => new Date().toISOString();
console.log = (...args) => _log(ts(), ...args);
console.error = (...args) => _error(ts(), ...args);
console.warn = (...args) => _warn(ts(), ...args);
const PORT = parseInt(process.env.PORT || '3000', 10);
const HOST = process.env.HOST || '0.0.0.0';
+38
View File
@@ -1715,3 +1715,41 @@ html {
}
/* ansi_up color classes (use_classes = true) — shared by all log viewers */
.ansi-black-fg { color: #3f3f46; }
.ansi-red-fg { color: #ef4444; }
.ansi-green-fg { color: #22c55e; }
.ansi-yellow-fg { color: #eab308; }
.ansi-blue-fg { color: #3b82f6; }
.ansi-magenta-fg { color: #d946ef; }
.ansi-cyan-fg { color: #06b6d4; }
.ansi-white-fg { color: #e4e4e7; }
.ansi-bright-black-fg { color: #71717a; }
.ansi-bright-red-fg { color: #f87171; }
.ansi-bright-green-fg { color: #4ade80; }
.ansi-bright-yellow-fg { color: #facc15; }
.ansi-bright-blue-fg { color: #60a5fa; }
.ansi-bright-magenta-fg { color: #e879f9; }
.ansi-bright-cyan-fg { color: #22d3ee; }
.ansi-bright-white-fg { color: #fafafa; }
.ansi-black-bg { background-color: #18181b; }
.ansi-red-bg { background-color: #dc2626; }
.ansi-green-bg { background-color: #16a34a; }
.ansi-yellow-bg { background-color: #ca8a04; }
.ansi-blue-bg { background-color: #2563eb; }
.ansi-magenta-bg { background-color: #c026d3; }
.ansi-cyan-bg { background-color: #0891b2; }
.ansi-white-bg { background-color: #d4d4d8; }
.ansi-bright-black-bg { background-color: #52525b; }
.ansi-bright-red-bg { background-color: #ef4444; }
.ansi-bright-green-bg { background-color: #22c55e; }
.ansi-bright-yellow-bg { background-color: #eab308; }
.ansi-bright-blue-bg { background-color: #3b82f6; }
.ansi-bright-magenta-bg { background-color: #d946ef; }
.ansi-bright-cyan-bg { background-color: #06b6d4; }
.ansi-bright-white-bg { background-color: #fafafa; }
.ansi-bold { font-weight: bold; }
.ansi-dim { opacity: 0.7; }
.ansi-italic { font-style: italic; }
.ansi-underline { text-decoration: underline; }
+1
View File
@@ -1,4 +1,5 @@
// v1.0.12
import '$lib/server/dns-dispatcher.js';
import { initDatabase, hasAdminUser } from '$lib/server/db';
import { startSubprocesses, stopSubprocesses } from '$lib/server/subprocess-manager';
import { startScheduler } from '$lib/server/scheduler';
+8 -5
View File
@@ -21,15 +21,18 @@
const parts = cron.split(' ');
if (parts.length < 5) return 'custom';
const [, , day, month, dow] = parts;
const [min, hr, day, month, dow] = parts;
// Weekly: specific day of week (0-6), day and month are wildcards
if (dow !== '*' && day === '*' && month === '*') {
// Simple minute and hour: plain numbers only (not */n, ranges, or lists)
const isSimpleNumber = (s: string) => /^\d+$/.test(s);
// Weekly: specific single day of week (0-6), day and month are wildcards, simple min/hour
if (dow !== '*' && /^\d$/.test(dow) && day === '*' && month === '*' && isSimpleNumber(min) && isSimpleNumber(hr)) {
return 'weekly';
}
// Daily: all wildcards except minute and hour
if (day === '*' && month === '*' && dow === '*') {
// Daily: all wildcards except simple minute and hour
if (day === '*' && month === '*' && dow === '*' && isSimpleNumber(min) && isSimpleNumber(hr)) {
return 'daily';
}
+16 -2
View File
@@ -8,7 +8,7 @@
import { getIconComponent } from '$lib/utils/icons';
import { toast } from 'svelte-sonner';
import { themeStore, type FontSize } from '$lib/stores/theme';
import { formatTime } from '$lib/stores/settings';
import { getTimeFormat } from '$lib/stores/settings';
// Font size scaling for header
let fontSize = $state<FontSize>('normal');
@@ -305,6 +305,20 @@
hostInfo ? ((hostInfo.totalMemory - hostInfo.freeMemory) / hostInfo.totalMemory) * 100 : 0
);
let currentTimezone = $derived(
$environments.find((e: Environment) => Number(e.id) === Number(currentEnvId))?.timezone ?? 'UTC'
);
function formatLastUpdated(date: Date, timezone: string): string {
return new Intl.DateTimeFormat('en-GB', {
timeZone: timezone,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: getTimeFormat() === '12h'
}).format(date);
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.env-dropdown')) {
@@ -452,7 +466,7 @@
class="flex items-center gap-2 {isConnected ? 'text-emerald-500' : 'text-muted-foreground'}"
title={isConnected ? 'Live updates connected' : 'Live updates disconnected'}
>
<span class="text-muted-foreground">{formatTime(lastUpdated, { includeSeconds: true })}</span>
<span class="text-muted-foreground" title={currentTimezone}>{formatLastUpdated(lastUpdated, currentTimezone)}</span>
{#if isConnected}
<Wifi class="{iconSizeLargeClass()}" />
<span class="font-medium">Live</span>
+20
View File
@@ -1,4 +1,24 @@
[
{
"version": "1.0.21",
"date": "2026-03-13",
"changes": [
{ "type": "feature", "text": "option to truncate port list (#702)" },
{ "type": "feature", "text": "log viewer supports ANSII 256 colors (#743)" },
{ "type": "fix", "text": "IPv6 Problems (#714, #731)" },
{ "type": "fix", "text": "polling storm & mass disconnect (#733, #741)" },
{ "type": "fix", "text": "custom cron schedule displayed incorrectly (#727)" },
{ "type": "fix", "text": "wrong cron schedule (#706)" },
{ "type": "fix", "text": "file browser does not allow upload over 512 KB (#687)" },
{ "type": "fix", "text": "can't set memory swappiness when using Podman (#691)" },
{ "type": "fix", "text": "compose API negotiation fix (#692, #696)" },
{ "type": "fix", "text": "not deployed git stacks continue to show the Down action (#694)" },
{ "type": "fix", "text": "display time doesn't reflect time zone (#735)" },
{ "type": "fix", "text": "prune dangling images counter not working (#718)" },
{ "type": "fix", "text": "own PORT env not used in HEALTHCHECK (#745)" }
],
"imageTag": "fnsys/dockhand:v1.0.21"
},
{
"version": "1.0.20",
"date": "2026-03-02",
+91
View File
@@ -0,0 +1,91 @@
import { setGlobalDispatcher, Agent } from 'undici';
import dns from 'node:dns';
import net from 'node:net';
const origLookup = dns.lookup.bind(dns);
// DNS cache: hostname → { address, family, expiresAt } (positive)
// DNS negative cache: hostname → { error, expiresAt } (failed lookups)
const dnsCache = new Map<string, { address: string; family: number; expiresAt: number }>();
const dnsNegCache = new Map<string, { error: Error; expiresAt: number }>();
const DNS_TTL_MS = 30_000;
const DNS_NEG_TTL_MS = 10_000; // Cache failures for 10s to prevent DNS server storms
// In-flight deduplication: hostname → pending Promise<{address, family}>
const inFlight = new Map<string, Promise<{ address: string; family: number }>>();
function lookupWithCache(hostname: string): Promise<{ address: string; family: number }> {
// Positive cache hit
const cached = dnsCache.get(hostname);
if (cached) {
if (cached.expiresAt > Date.now()) {
return Promise.resolve({ address: cached.address, family: cached.family });
}
dnsCache.delete(hostname); // evict stale entry
}
// Negative cache hit — don't hammer DNS for recently-failed hostnames
const negCached = dnsNegCache.get(hostname);
if (negCached) {
if (negCached.expiresAt > Date.now()) {
return Promise.reject(negCached.error);
}
dnsNegCache.delete(hostname);
}
// In-flight deduplication
const pending = inFlight.get(hostname);
if (pending) return pending;
// Use getaddrinfo (libc) as primary — works through Docker's embedded DNS (127.0.0.11)
// and respects --dns-result-order=ipv4first from entrypoint. This matches Bun's native
// behavior which worked reliably on NAS environments where c-ares failed (#676).
const promise = new Promise<{ address: string; family: number }>((resolve, reject) => {
origLookup(hostname, { all: false }, (err, address, family) => {
if (err) {
// Cache the failure so parallel/subsequent requests don't all hammer DNS
dnsNegCache.set(hostname, { error: err, expiresAt: Date.now() + DNS_NEG_TTL_MS });
reject(err);
} else {
const result = { address: address as string, family: family as number };
dnsCache.set(hostname, { ...result, expiresAt: Date.now() + DNS_TTL_MS });
resolve(result);
}
});
}).finally(() => {
inFlight.delete(hostname);
});
inFlight.set(hostname, promise);
return promise;
}
setGlobalDispatcher(
new Agent({
connect: {
// Undici default is 10s. Increase to 30s for NAS environments with slow NAT/firewalls (#676).
timeout: 30_000,
lookup(hostname: string, opts: any, cb: any) {
if (typeof opts === 'function') {
cb = opts;
opts = {};
}
// IP addresses / localhost → no DNS needed
if (net.isIP(hostname) || hostname === 'localhost') {
return origLookup(hostname, opts, cb);
}
lookupWithCache(hostname)
.then(({ address, family }) => {
if (opts.all) {
cb(null, [{ address, family }]);
} else {
cb(null, address, family);
}
})
.catch((err) => cb(err));
}
}
})
);
+48 -32
View File
@@ -10,6 +10,7 @@ import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import * as http from 'node:http';
import * as https from 'node:https';
import * as tls from 'node:tls';
import { createHash } from 'node:crypto';
import type { Environment } from './db';
import { getStackEnvVarsAsRecord } from './db';
@@ -345,7 +346,12 @@ function getHttpsAgent(config: DockerClientConfig): https.Agent {
timeout: 30000,
};
if (config.ca) agentOptions.ca = config.ca;
if (config.ca) {
// Include both the custom CA and Node.js built-in root certificates.
// Node.js replaces the entire CA store when `ca` is set, unlike Bun which appends.
// Without this, certs signed by intermediate CAs fail with "unable to get local issuer certificate".
agentOptions.ca = [config.ca, ...tls.rootCertificates];
}
if (config.cert) agentOptions.cert = config.cert;
if (config.key) agentOptions.key = config.key;
if (config.skipVerify) agentOptions.rejectUnauthorized = false;
@@ -874,7 +880,8 @@ export async function dockerFetch(
headers,
streaming || false,
(streaming || path === '/_hawser/compose') ? 300000 : 30000, // 5 min for streaming/compose, 30s for normal
isBinary
isBinary,
fetchOptions.signal ?? undefined
);
const elapsed = Date.now() - startTime;
// Only warn for slow requests, but skip /stats which is expected to be slow (5-10s)
@@ -1233,7 +1240,7 @@ export interface CreateContainerOptions {
networkIpv6Address?: string;
/** Gateway priority for the primary network (Docker Engine 28+) */
networkGwPriority?: number;
user?: string;
user?: string | null;
privileged?: boolean;
healthcheck?: HealthcheckConfig;
memory?: number;
@@ -1286,7 +1293,7 @@ export interface CreateContainerOptions {
// Device requests (GPU access, etc.)
deviceRequests?: DeviceRequest[];
// Container runtime (e.g., 'runc', 'nvidia' for GPU containers)
runtime?: string;
runtime?: string | null;
// Read-only root filesystem
readonlyRootfs?: boolean;
// CPU pinning (e.g., "0-3", "0,1")
@@ -1322,8 +1329,8 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
containerConfig.Cmd = options.cmd;
}
if (options.user) {
containerConfig.User = options.user;
if (options.user !== undefined) {
containerConfig.User = options.user ?? '';
}
if (options.healthcheck) {
@@ -1436,7 +1443,7 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
};
}
if (options.privileged) {
if (options.privileged !== undefined) {
containerConfig.HostConfig.Privileged = options.privileged;
}
@@ -1614,8 +1621,8 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
}
// Container runtime (e.g., 'nvidia' for GPU containers)
if (options.runtime) {
containerConfig.HostConfig.Runtime = options.runtime;
if (options.runtime !== undefined) {
containerConfig.HostConfig.Runtime = options.runtime ?? '';
}
// Read-only root filesystem
@@ -1782,6 +1789,13 @@ export async function recreateContainerFromInspect(
HostConfig: hostConfig
};
// Strip default MemorySwappiness — Podman + cgroupv2 rejects it.
// Docker returns -1, Podman returns 0 when unset.
const swappiness = createConfig.HostConfig?.MemorySwappiness;
if (swappiness == null || swappiness === -1 || swappiness === 0) {
delete createConfig.HostConfig.MemorySwappiness;
}
// container:<name> mode shares the network namespace — Docker rejects
// networking-related fields on the dependent container since they're
// owned by the network provider container
@@ -1883,6 +1897,11 @@ export async function recreateContainerFromInspect(
[initialNetworkName]: endpointConfig
}
};
// Container-level MacAddress conflicts with endpoint-level MacAddress.
// Docker requires them to match or the top-level one to be empty.
// The MAC is preserved in the endpoint config (correct location per API v1.44+),
// so clear the top-level one to avoid the conflict error.
delete createConfig.MacAddress;
}
// 5. Create new container
@@ -2236,7 +2255,7 @@ export function extractContainerOptions(inspectData: any): CreateContainerOption
groupAdd: hostConfig.GroupAdd?.length > 0 ? hostConfig.GroupAdd : undefined,
// Memory swappiness
memorySwappiness: hostConfig.MemorySwappiness !== null ? hostConfig.MemorySwappiness : undefined,
memorySwappiness: hostConfig.MemorySwappiness != null && hostConfig.MemorySwappiness !== -1 && hostConfig.MemorySwappiness !== 0 ? hostConfig.MemorySwappiness : undefined,
// User namespace mode
usernsMode: hostConfig.UsernsMode || undefined
@@ -2665,6 +2684,10 @@ async function getRegistryBearerToken(registry: string, repo: string): Promise<s
const cause = (e as any)?.cause;
const causeMsg = cause ? ` (cause: ${cause})` : '';
console.error('[Registry] Failed to get bearer token:', errorMsg + causeMsg);
const causeStr = String(cause ?? errorMsg);
if (causeStr.includes('EAI_AGAIN') || causeStr.includes('ENOTFOUND')) {
console.error('[Registry] DNS resolution failed. If you are on a NAS (Synology, uGreen, QNAP), try adding --dns=8.8.8.8 to your docker run command or set {"dns": ["8.8.8.8"]} in /etc/docker/daemon.json');
}
return null;
}
}
@@ -2845,9 +2868,16 @@ export async function getRegistryManifestDigest(imageName: string): Promise<stri
return null;
}
return response.headers.get('Docker-Content-Digest');
const digest = response.headers.get('Docker-Content-Digest');
await drainResponse(response);
return digest;
} catch (e) {
console.error(`[Registry] ${imageName}: ${e}`);
const causeStr = String((e as any)?.cause ?? e);
if (causeStr.includes('EAI_AGAIN') || causeStr.includes('ENOTFOUND')) {
console.error(`[Registry] ${imageName}: DNS resolution failed. If you are on a NAS (Synology, uGreen, QNAP), add --dns=8.8.8.8 to your docker run command.`);
} else {
console.error(`[Registry] ${imageName}: ${e}`);
}
return null;
}
}
@@ -3111,15 +3141,18 @@ export async function getDockerVersion(envId?: number | null) {
*/
export async function dockerPing(envId: number): Promise<boolean> {
try {
// Edge connections go WebSocket → agent → Docker daemon, which adds latency on slow hosts.
// Use a longer timeout for edge to avoid false negatives on overloaded NAS/VPS devices.
const config = await getDockerConfig(envId).catch(() => null);
const timeoutMs = config?.connectionType === 'hawser-edge' ? 20000 : 5000;
const response = await dockerFetch('/_ping', {
signal: AbortSignal.timeout(5000)
signal: AbortSignal.timeout(timeoutMs)
}, envId);
await drainResponse(response);
return response.ok;
} catch (error: any) {
const msg = error?.message || String(error);
if (msg.includes('unreachable')) {
const config = await getDockerConfig(envId).catch(() => null);
console.warn(`[Docker] ${config?.connectionType || 'direct'} ${config?.host || envId}: /_ping failed - host unreachable`);
}
return false;
@@ -3286,24 +3319,7 @@ export interface NetworkInfo {
export async function listNetworks(envId?: number | null): Promise<NetworkInfo[]> {
const networks = await dockerJsonRequest<any[]>('/networks', {}, envId);
// Docker's /networks endpoint returns empty Containers - we need to inspect each network
// to get the actual connected containers. Run inspections in parallel for performance.
const networkDetails = await Promise.all(
networks.map(async (network: any) => {
try {
const details = await dockerJsonRequest<any>(`/networks/${network.Id}`, {}, envId);
return {
...network,
Containers: details.Containers || {}
};
} catch {
// If inspection fails, return network with empty containers
return network;
}
})
);
return networkDetails.map((network: any) => ({
return networks.map((network: any) => ({
id: network.Id,
name: network.Name,
driver: network.Driver,
+81 -18
View File
@@ -44,6 +44,7 @@ export interface EdgeConnection {
lastHeartbeat: number;
pendingRequests: Map<string, PendingRequest>;
pendingStreamRequests: Map<string, PendingStreamRequest>;
pingInterval?: ReturnType<typeof setInterval>;
lastMetrics?: {
uptime?: number;
cpuUsage?: number;
@@ -77,7 +78,7 @@ declare global {
var __hawserSendMessage: ((envId: number, message: string) => boolean) | undefined;
var __hawserHandleContainerEvent: ((envId: number, event: ContainerEventMessage['event']) => Promise<void>) | undefined;
var __hawserHandleMetrics: ((envId: number, metrics: MetricsMessage['metrics']) => Promise<void>) | undefined;
var __hawserHandleMessage: ((ws: any, msg: any, connId: string) => Promise<void>) | undefined;
var __hawserHandleMessage: ((ws: any, msg: any, connId: string, remoteIp?: string) => Promise<void>) | undefined;
var __hawserHandleDisconnect: ((ws: any, connId: string) => void) | undefined;
var __terminalHandleExecMessage: ((msg: any) => void) | undefined;
}
@@ -119,6 +120,11 @@ export function initializeEdgeManager(): void {
conn.pendingRequests.clear();
conn.pendingStreamRequests.clear();
if (conn.pingInterval) {
clearInterval(conn.pingInterval);
conn.pingInterval = undefined;
}
conn.ws.close(1001, 'Connection timeout');
edgeConnections.delete(envId);
updateEnvironmentStatus(envId, null);
@@ -255,11 +261,15 @@ globalThis.__hawserHandleMetrics = handleEdgeMetrics;
export async function validateHawserToken(
token: string
): Promise<{ valid: boolean; environmentId?: number; tokenId?: number }> {
// Get all active tokens
const tokens = await db.select().from(hawserTokens).where(eq(hawserTokens.isActive, true));
// Fast path: lookup by token prefix (first 8 chars) instead of iterating all tokens.
// This reduces O(N) Argon2id verifications to O(1) DB lookup + 1 verify.
const prefix = token.substring(0, 8);
const candidates = await db
.select()
.from(hawserTokens)
.where(and(eq(hawserTokens.tokenPrefix, prefix), eq(hawserTokens.isActive, true)));
// Check each token (tokens are hashed)
for (const t of tokens) {
for (const t of candidates) {
try {
const isValid = await verifyPassword(token, t.token);
if (isValid) {
@@ -276,7 +286,7 @@ export async function validateHawserToken(
};
}
} catch {
// Invalid hash, continue checking
// Invalid hash format, skip
}
}
@@ -371,6 +381,12 @@ export function closeEdgeConnection(environmentId: number): void {
`Rejecting ${pendingCount} pending requests and ${streamCount} stream requests.`
);
// Clear ping interval
if (connection.pingInterval) {
clearInterval(connection.pingInterval);
connection.pingInterval = undefined;
}
// Reject all pending requests
for (const [requestId, pending] of connection.pendingRequests) {
console.log(`[Hawser] Rejecting pending request ${requestId} due to environment deletion`);
@@ -427,6 +443,12 @@ export function handleEdgeConnection(
existing.pendingRequests.clear();
existing.pendingStreamRequests.clear();
// Clear ping interval before closing
if (existing.pingInterval) {
clearInterval(existing.pingInterval);
existing.pingInterval = undefined;
}
// Immediately destroy TCP socket — no graceful close needed for replaced connections
if (typeof existing.ws.terminate === 'function') {
existing.ws.terminate();
@@ -452,6 +474,17 @@ export function handleEdgeConnection(
edgeConnections.set(environmentId, connection);
// Start server-side ping interval to keep connection alive.
// 5s is conservative against reverse proxies with aggressive idle timeouts.
connection.pingInterval = setInterval(() => {
try {
connection.ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
} catch {
clearInterval(connection.pingInterval!);
connection.pingInterval = undefined;
}
}, 5000);
// Update environment record
updateEnvironmentStatus(environmentId, connection);
@@ -499,7 +532,8 @@ export async function sendEdgeRequest(
headers?: Record<string, string>,
streaming = false,
timeout = 30000,
isBinary = false
isBinary = false,
signal?: AbortSignal
): Promise<EdgeResponse> {
const connection = edgeConnections.get(environmentId);
if (!connection) {
@@ -517,6 +551,27 @@ export async function sendEdgeRequest(
reject(new Error('Request timeout'));
}, timeout);
// Honor AbortSignal from caller (e.g., AbortSignal.timeout(5000) for dockerPing)
if (signal) {
if (signal.aborted) {
clearTimeout(timeoutHandle);
reject(new Error('Request aborted'));
return;
}
signal.addEventListener(
'abort',
() => {
connection.pendingRequests.delete(requestId);
if (streaming) {
connection.pendingStreamRequests.delete(requestId);
}
clearTimeout(timeoutHandle);
reject(new Error('Request aborted'));
},
{ once: true }
);
}
// For streaming requests, the Go agent sends 'stream' messages instead of a single 'response'.
// We need to register a stream handler that collects all data and resolves when complete.
if (streaming) {
@@ -792,6 +847,12 @@ export function handleHeartbeat(environmentId: number): void {
export function handleDisconnect(environmentId: number): void {
const connection = edgeConnections.get(environmentId);
if (connection) {
// Clear ping interval
if (connection.pingInterval) {
clearInterval(connection.pingInterval);
connection.pingInterval = undefined;
}
// Reject all pending requests
for (const [, pending] of connection.pendingRequests) {
clearTimeout(pending.timeout);
@@ -983,11 +1044,12 @@ export type HawserMessage =
const wsToEnvId = new Map<any, number>();
// Auth fail cache to prevent brute-force token validation.
// Entries are periodically cleaned up to prevent unbounded growth.
// 5 min cooldown — hawser agents use exponential backoff (30-60s),
// so a short cooldown lets every retry through.
const hawserAuthFailCache = new Map<string, number>();
const HAWSER_AUTH_FAIL_COOLDOWN_MS = 30_000;
const HAWSER_AUTH_FAIL_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
// Periodic cleanup of expired auth fail entries (every 60s)
// Periodic cleanup of expired auth fail entries
setInterval(() => {
const now = Date.now();
for (const [key, timestamp] of hawserAuthFailCache) {
@@ -995,7 +1057,7 @@ setInterval(() => {
hawserAuthFailCache.delete(key);
}
}
}, 60_000);
}, HAWSER_AUTH_FAIL_COOLDOWN_MS);
// ─── Reconnection storm throttle ───
// Tracks per-environment reconnection frequency to detect storms
@@ -1007,7 +1069,7 @@ interface ReconnectTrackerEntry {
}
const reconnectTracker = new Map<number, ReconnectTrackerEntry>();
const RECONNECT_WINDOW_MS = 2 * 60 * 1000; // 2-minute sliding window
const RECONNECT_BURST = 3; // allow 3 reconnections per window
const RECONNECT_BURST = 10; // allow 10 reconnections per window
const COOLDOWN_LEVELS_SECS = [30, 60, 120, 300]; // escalating cooldown in seconds
const STABLE_THRESHOLD_MS = 5 * 60 * 1000; // stable connection resets tracker
const STALE_TRACKER_MS = 10 * 60 * 1000; // clean up stale tracker entries
@@ -1062,13 +1124,14 @@ function recordReconnection(envId: number): { allowed: true } | { allowed: false
*
* Registered as globalThis.__hawserHandleMessage for server.js to call.
*/
async function handleHawserWsMessage(ws: any, msg: any, connId: string): Promise<void> {
async function handleHawserWsMessage(ws: any, msg: any, connId: string, remoteIp?: string): Promise<void> {
if (msg.type === 'hello') {
const remoteAddr = connId;
const rateLimitKey = remoteIp || connId;
// Rate limit auth failures
const lastFail = hawserAuthFailCache.get(remoteAddr);
// Rate limit auth failures by remote IP (not connId which is unique per connection)
const lastFail = hawserAuthFailCache.get(rateLimitKey);
if (lastFail && Date.now() - lastFail < HAWSER_AUTH_FAIL_COOLDOWN_MS) {
console.log(`[Hawser WS] Rate limited ${connId} (IP: ${rateLimitKey}) — ${Math.round((Date.now() - lastFail) / 1000)}s since last fail`);
ws.send(JSON.stringify({ type: 'error', message: 'Too many failed attempts' }));
ws.close(1008, 'Rate limited');
return;
@@ -1083,8 +1146,8 @@ async function handleHawserWsMessage(ws: any, msg: any, connId: string): Promise
try {
const result = await validateHawserToken(msg.token);
if (!result.valid || !result.environmentId) {
console.log(`[Hawser WS] Authentication failed for connection ${connId}`);
hawserAuthFailCache.set(remoteAddr, Date.now());
console.log(`[Hawser WS] Authentication failed for connection ${connId} (IP: ${rateLimitKey})`);
hawserAuthFailCache.set(rateLimitKey, Date.now());
ws.send(JSON.stringify({ type: 'error', message: 'Invalid token' }));
ws.close(1008, 'Invalid token');
return;
+32
View File
@@ -0,0 +1,32 @@
import { Cron } from 'croner';
/**
* Get the next run time for a cron expression.
* Uses legacyMode: false so day-of-month + day-of-week use AND logic.
* @param cronExpression - The cron expression
* @param timezone - Optional IANA timezone (e.g., 'Europe/Warsaw'). Defaults to local timezone.
*/
export function getNextRun(cronExpression: string, timezone?: string): Date | null {
try {
const options = timezone ? { timezone, legacyMode: false } : { legacyMode: false };
const job = new Cron(cronExpression, options);
const next = job.nextRun();
job.stop();
return next;
} catch {
return null;
}
}
/**
* Check if a cron expression is valid.
*/
export function isValidCron(cronExpression: string): boolean {
try {
const job = new Cron(cronExpression, { legacyMode: false });
job.stop();
return true;
} catch {
return false;
}
}
+10 -41
View File
@@ -107,11 +107,11 @@ export async function startScheduler(): Promise<void> {
const defaultTimezone = await getDefaultTimezone();
// Start system cleanup jobs (static schedules with default timezone)
cleanupJob = new Cron(scheduleCleanupCron, { timezone: defaultTimezone }, async () => {
cleanupJob = new Cron(scheduleCleanupCron, { timezone: defaultTimezone, legacyMode: false }, async () => {
await runScheduleCleanupJob();
});
eventCleanupJob = new Cron(eventCleanupCron, { timezone: defaultTimezone }, async () => {
eventCleanupJob = new Cron(eventCleanupCron, { timezone: defaultTimezone, legacyMode: false }, async () => {
await runEventCleanupJob();
});
@@ -127,15 +127,10 @@ export async function startScheduler(): Promise<void> {
};
// Volume helper cleanup runs every 30 minutes to clean up expired browse containers
volumeHelperCleanupJob = new Cron('*/30 * * * *', { timezone: defaultTimezone }, async () => {
volumeHelperCleanupJob = new Cron('*/30 * * * *', { timezone: defaultTimezone, legacyMode: false }, async () => {
await runVolumeHelperCleanupJob('cron', volumeCleanupFns);
});
// Run volume helper cleanup immediately on startup to clean up stale containers
runVolumeHelperCleanupJob('startup', volumeCleanupFns).catch(err => {
const errorMsg = err instanceof Error ? err.message : String(err);
console.error('[Scheduler] Error during startup volume helper cleanup:', errorMsg);
});
console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`);
console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`);
@@ -331,7 +326,7 @@ export async function registerSchedule(
const timezone = environmentId ? await getEnvironmentTimezone(environmentId) : 'UTC';
// Create new Cron instance with timezone
const job = new Cron(cronExpression, { timezone }, async () => {
const job = new Cron(cronExpression, { timezone, legacyMode: false }, async () => {
// Defensive check: verify schedule still exists and is enabled
if (type === 'container_update') {
const setting = await getAutoUpdateSettingById(scheduleId);
@@ -494,15 +489,15 @@ export async function refreshSystemJobs(): Promise<void> {
}
// Re-create with new timezone
cleanupJob = new Cron(scheduleCleanupCron, { timezone: defaultTimezone }, async () => {
cleanupJob = new Cron(scheduleCleanupCron, { timezone: defaultTimezone, legacyMode: false }, async () => {
await runScheduleCleanupJob();
});
eventCleanupJob = new Cron(eventCleanupCron, { timezone: defaultTimezone }, async () => {
eventCleanupJob = new Cron(eventCleanupCron, { timezone: defaultTimezone, legacyMode: false }, async () => {
await runEventCleanupJob();
});
volumeHelperCleanupJob = new Cron('*/30 * * * *', { timezone: defaultTimezone }, async () => {
volumeHelperCleanupJob = new Cron('*/30 * * * *', { timezone: defaultTimezone, legacyMode: false }, async () => {
await runVolumeHelperCleanupJob('cron', volumeCleanupFns);
});
@@ -654,35 +649,9 @@ export async function triggerSystemJob(jobId: string): Promise<{ success: boolea
// UTILITY FUNCTIONS
// =============================================================================
/**
* Get the next run time for a cron expression.
* @param cronExpression - The cron expression
* @param timezone - Optional IANA timezone (e.g., 'Europe/Warsaw'). Defaults to local timezone.
*/
export function getNextRun(cronExpression: string, timezone?: string): Date | null {
try {
const options = timezone ? { timezone } : undefined;
const job = new Cron(cronExpression, options);
const next = job.nextRun();
job.stop();
return next;
} catch {
return null;
}
}
/**
* Check if a cron expression is valid.
*/
export function isValidCron(cronExpression: string): boolean {
try {
const job = new Cron(cronExpression);
job.stop();
return true;
} catch {
return false;
}
}
// Imported from cron-utils.ts (isolated from DB deps for unit test compatibility)
import { getNextRun, isValidCron } from './cron-utils';
export { getNextRun, isValidCron };
/**
* Get system schedules info for the API.
+11 -6
View File
@@ -74,12 +74,17 @@ export async function runImagePrune(
// Extract space reclaimed and images removed from result
const spaceReclaimed = result?.SpaceReclaimed || 0;
// Count unique images by filtering Untagged entries that are not digest references
// Docker returns multiple entries per image: Untagged (tag), Untagged (digest @sha256:), Deleted (layers)
// We only count tag-based Untagged entries to get actual image count
const imagesRemoved = result?.ImagesDeleted
?.filter((img: any) => img.Untagged && !img.Untagged.includes('@sha256:'))
.length || 0;
// Count unique images removed.
// Docker returns: Untagged (tag), Untagged (digest @sha256:), Deleted (layer sha256:)
// For tagged images: count Untagged entries that are NOT digest references (tag-based)
// For dangling images: there are no tag-based entries, only digest-based Untagged entries
// So count tag-based Untagged first, fall back to digest-based Untagged for dangling prune
const deleted = result?.ImagesDeleted || [];
const tagEntries = deleted.filter((img: any) => img.Untagged && !img.Untagged.includes('@sha256:'));
const digestEntries = deleted.filter((img: any) => img.Untagged && img.Untagged.includes('@sha256:'));
const imagesRemoved = tagEntries.length > 0
? tagEntries.length
: digestEntries.length;
// Format space for human-readable output
const formatBytes = (bytes: number): string => {
+7 -94
View File
@@ -3,7 +3,6 @@
*
* Provides compose-first stack operations for internal, git, and external stacks.
* All lifecycle operations use docker compose commands.
* v1.0.20
*/
import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync } from 'node:fs';
@@ -139,10 +138,6 @@ const stackLocks = new Map<string, Promise<void>>();
// Track active TLS temp directories for cleanup on unexpected process exit
const activeTlsDirs = new Set<string>();
// Cache of envId → daemon max API version (e.g. "1.43")
// Populated lazily to avoid CLI/daemon version mismatch on older Docker hosts (e.g. Synology)
const dockerApiVersionCache = new Map<string, string>();
// Register cleanup handlers once at module load
if (typeof process !== 'undefined') {
const cleanupTlsDirs = () => {
@@ -158,85 +153,6 @@ if (typeof process !== 'undefined') {
process.on('SIGTERM', () => { cleanupTlsDirs(); process.exit(143); });
}
/**
* Fetch and cache the Docker daemon's maximum supported API version for a given environment.
* Used to set DOCKER_API_VERSION when spawning docker compose, preventing version mismatch
* errors on older Docker hosts (e.g. Synology DSM).
*
* Strategy:
* 1. Try Dockhand's HTTP API call to the daemon (works for all environment types)
* 2. Fall back to `docker version` CLI command (works for local socket connections)
*/
async function getDockerApiVersionForCli(envId: number | null | undefined): Promise<string | undefined> {
const key = String(envId ?? 'local');
if (dockerApiVersionCache.has(key)) return dockerApiVersionCache.get(key);
// Strategy 1: Use Dockhand's HTTP API to query the daemon
if (envId) {
try {
const { getDockerVersion } = await import('./docker.js');
const version = await getDockerVersion(envId) as { ApiVersion?: string };
const apiVersion: string | undefined = version?.ApiVersion;
if (apiVersion) {
console.log(`[Docker API Version] Detected daemon API version ${apiVersion} for env ${key} (via HTTP API)`);
dockerApiVersionCache.set(key, apiVersion);
return apiVersion;
}
} catch (err: any) {
console.warn(`[Docker API Version] HTTP API query failed for env ${key}: ${err?.message || err}`);
}
}
// Strategy 2: Fall back to `docker version` CLI command
// This handles local socket connections where envId is null and also
// cases where the HTTP API query fails (e.g. daemon quirks on Synology)
try {
const apiVersion = await getDockerApiVersionViaCli();
if (apiVersion) {
console.log(`[Docker API Version] Detected daemon API version ${apiVersion} for env ${key} (via CLI)`);
dockerApiVersionCache.set(key, apiVersion);
return apiVersion;
}
} catch (err: any) {
console.warn(`[Docker API Version] CLI query failed for env ${key}: ${err?.message || err}`);
}
console.warn(`[Docker API Version] Could not detect daemon API version for env ${key}`);
return undefined;
}
/**
* Get the Docker daemon's API version using the `docker version` CLI command.
* This is a fallback for when the HTTP API query fails or envId is null.
*/
function getDockerApiVersionViaCli(): Promise<string | undefined> {
return new Promise((resolve) => {
const proc = nodeSpawn('docker', ['version', '--format', '{{.Server.APIVersion}}'], {
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 5000,
// Use the minimum Docker API version (1.25) for this probe command.
// This ensures the probe itself doesn't fail due to the version mismatch
// we're trying to detect.
env: {
PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin',
DOCKER_API_VERSION: '1.25'
}
});
let stdout = '';
proc.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
proc.stderr?.on('data', () => {}); // drain stderr to prevent pipe buffer blocking
proc.on('close', (code) => {
const version = stdout.trim();
if (code === 0 && /^\d+\.\d+$/.test(version)) {
resolve(version);
} else {
resolve(undefined);
}
});
proc.on('error', () => resolve(undefined));
});
}
/**
* Execute a function with exclusive lock on a stack.
* Prevents race conditions when multiple operations target the same stack.
@@ -821,7 +737,7 @@ async function loginToRegistries(dockerHost?: string, logPrefix = '[Stack]', api
if (dockerHost) {
spawnEnv.DOCKER_HOST = dockerHost;
}
// Cap Docker CLI API version to prevent version mismatch errors
// Pass through explicit DOCKER_API_VERSION if provided by caller
if (apiVersion) {
spawnEnv.DOCKER_API_VERSION = apiVersion;
}
@@ -997,13 +913,10 @@ async function executeLocalCompose(
spawnEnv.DOCKER_HOST = process.env.DOCKER_HOST;
}
// Auto-cap Docker CLI API version to the daemon's max supported version.
// This fixes compatibility with older Docker daemons (e.g. Synology DSM) that
// reject newer client versions. DOCKER_API_VERSION env var overrides this if set.
const daemonApiVersion = process.env.DOCKER_API_VERSION
?? await getDockerApiVersionForCli(envId);
if (daemonApiVersion) {
spawnEnv.DOCKER_API_VERSION = daemonApiVersion;
// Honor explicit DOCKER_API_VERSION override from environment (user-controlled).
// Otherwise let compose negotiate natively — 5.0.2 handles old daemons correctly.
if (process.env.DOCKER_API_VERSION) {
spawnEnv.DOCKER_API_VERSION = process.env.DOCKER_API_VERSION;
}
// Check if .env file exists on disk (for legacy support decision)
@@ -1162,7 +1075,7 @@ async function executeLocalCompose(
console.log(`${logPrefix} Working directory:`, stackDir);
console.log(`${logPrefix} Compose file:`, composeFile);
console.log(`${logPrefix} DOCKER_HOST:`, dockerHost || '(local socket)');
console.log(`${logPrefix} DOCKER_API_VERSION:`, daemonApiVersion || '(not set - using CLI default)');
console.log(`${logPrefix} DOCKER_API_VERSION:`, spawnEnv.DOCKER_API_VERSION || '(not set - native negotiation)');
console.log(`${logPrefix} Force recreate:`, forceRecreate ?? false);
console.log(`${logPrefix} Remove volumes:`, removeVolumes ?? false);
console.log(`${logPrefix} Service name:`, serviceName ?? '(all services)');
@@ -1173,7 +1086,7 @@ async function executeLocalCompose(
// Login to registries before pulling images
if (operation === 'up' || operation === 'pull') {
await loginToRegistries(dockerHost, logPrefix, daemonApiVersion);
await loginToRegistries(dockerHost, logPrefix, spawnEnv.DOCKER_API_VERSION);
}
try {
+1
View File
@@ -17,6 +17,7 @@ export interface Environment {
socketPath?: string;
connectionType?: 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge';
publicIp?: string | null;
timezone?: string;
}
const STORAGE_KEY = 'dockhand:environment';
+11
View File
@@ -26,6 +26,7 @@ export interface AppSettings {
eventCollectionMode: EventCollectionMode;
eventPollInterval: number;
metricsCollectionInterval: number;
compactPorts: boolean;
externalStackPaths: string[];
primaryStackLocation: string | null;
}
@@ -50,6 +51,7 @@ const DEFAULT_SETTINGS: AppSettings = {
eventCollectionMode: 'stream',
eventPollInterval: 60000,
metricsCollectionInterval: 30000,
compactPorts: false,
externalStackPaths: [],
primaryStackLocation: null
};
@@ -88,6 +90,7 @@ function createSettingsStore() {
eventCollectionMode: settings.eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode,
eventPollInterval: settings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval,
metricsCollectionInterval: settings.metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval,
compactPorts: settings.compactPorts ?? DEFAULT_SETTINGS.compactPorts,
externalStackPaths: settings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
primaryStackLocation: settings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation
});
@@ -129,6 +132,7 @@ function createSettingsStore() {
eventCollectionMode: updatedSettings.eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode,
eventPollInterval: updatedSettings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval,
metricsCollectionInterval: updatedSettings.metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval,
compactPorts: updatedSettings.compactPorts ?? DEFAULT_SETTINGS.compactPorts,
externalStackPaths: updatedSettings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
primaryStackLocation: updatedSettings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation
});
@@ -290,6 +294,13 @@ function createSettingsStore() {
return newSettings;
});
},
setCompactPorts: (value: boolean) => {
update((current) => {
const newSettings = { ...current, compactPorts: value };
saveSettings({ compactPorts: value });
return newSettings;
});
},
setExternalStackPaths: (value: string[]) => {
update((current) => {
const newSettings = { ...current, externalStackPaths: value };
+10 -4
View File
@@ -30,8 +30,11 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
const details = await inspectContainer(params.id, envIdNum);
return json(details);
} catch (error) {
console.error('Error inspecting container:', error);
} catch (error: any) {
if (error?.statusCode === 404) {
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
}
console.error('Error inspecting container:', error?.message || error);
return json({ error: 'Failed to inspect container' }, { status: 500 });
}
};
@@ -96,8 +99,11 @@ export const DELETE: RequestHandler = async (event) => {
}
return json({ success: true });
} catch (error) {
console.error('Error removing container:', error);
} catch (error: any) {
if (error?.statusCode === 404) {
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
}
console.error('Error removing container:', error?.message || error);
return json({ error: 'Failed to remove container' }, { status: 500 });
}
};
@@ -26,7 +26,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
return json(result);
} catch (error: any) {
console.error('Error listing container directory:', error);
if (error?.statusCode === 404) {
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
}
console.error('Error listing container directory:', error?.message || error);
return json({ error: error.message || 'Failed to list directory' }, { status: 500 });
}
};
@@ -36,7 +36,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
return json({ content, path });
} catch (error: any) {
console.error('Error reading container file:', error);
if (error?.statusCode === 404 && !error.message?.includes('No such file')) {
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
}
console.error('Error reading container file:', error?.message || error);
const msg = error.message || String(error);
if (msg.includes('No such file or directory')) {
@@ -92,7 +95,7 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) =>
return json({ success: true, path });
} catch (error: any) {
console.error('Error writing container file:', error);
console.error('Error writing container file:', error?.message || error);
const msg = error.message || String(error);
if (msg.includes('Permission denied')) {
@@ -76,7 +76,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
return new Response(body, { headers });
} catch (error: any) {
console.error('Error downloading container file:', error);
console.error('Error downloading container file:', error?.message || error);
if (error.message?.includes('No such file or directory')) {
return new Response(JSON.stringify({ error: 'File not found' }), {
@@ -140,7 +140,7 @@ export const POST: RequestHandler = async ({ params, url, request, cookies }) =>
errors: errors.length > 0 ? errors : undefined
});
} catch (error: any) {
console.error('Error uploading to container:', error);
console.error('Error uploading to container:', error?.message || error);
if (error.message?.includes('Permission denied')) {
return json({ error: 'Permission denied to write to this path' }, { status: 403 });
@@ -46,8 +46,11 @@ export const POST: RequestHandler = async (event) => {
}
return json({ success: true });
} catch (error) {
console.error('Error renaming container:', error);
} catch (error: any) {
if (error?.statusCode === 404) {
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
}
console.error('Error renaming container:', error?.message || error);
return json({ error: 'Failed to rename container' }, { status: 500 });
}
};
@@ -38,8 +38,11 @@ export const POST: RequestHandler = async (event) => {
await auditContainer(event, 'restart', params.id, containerName, envIdNum);
return json({ success: true });
} catch (error) {
console.error('Error restarting container:', error);
} catch (error: any) {
if (error?.statusCode === 404) {
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
}
console.error('Error restarting container:', error?.message || error);
return json({ error: 'Failed to restart container' }, { status: 500 });
}
};
@@ -31,8 +31,11 @@ export const POST: RequestHandler = async (event) => {
await auditContainer(event, 'start', params.id, containerName, envIdNum);
return json({ success: true });
} catch (error) {
console.error('Error starting container:', error);
} catch (error: any) {
if (error?.statusCode === 404) {
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
}
console.error('Error starting container:', error?.message || error);
return json({ error: 'Failed to start container' }, { status: 500 });
}
};
@@ -31,8 +31,11 @@ export const POST: RequestHandler = async (event) => {
await auditContainer(event, 'stop', params.id, containerName, envIdNum);
return json({ success: true });
} catch (error) {
console.error('Error stopping container:', error);
} catch (error: any) {
if (error?.statusCode === 404) {
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
}
console.error('Error stopping container:', error?.message || error);
return json({ error: 'Failed to stop container' }, { status: 500 });
}
};
@@ -47,8 +47,11 @@ export const POST: RequestHandler = async (event) => {
await auditContainer(event, 'update', container.id, options.name, envIdNum, { ...options, startAfterUpdate });
return json({ success: true, id: container.id });
} catch (error) {
console.error('Error updating container:', error);
return json({ error: 'Failed to update container', details: String(error) }, { status: 500 });
} catch (error: any) {
if (error?.statusCode === 404) {
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
}
console.error('Error updating container:', error?.message || error);
return json({ error: 'Failed to update container', details: error?.message || String(error) }, { status: 500 });
}
};
@@ -4,6 +4,7 @@ import { authorize } from '$lib/server/authorize';
import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker';
import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db';
import { isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
import { createJobResponse } from '$lib/server/sse';
export interface UpdateCheckResult {
containerId: string;
@@ -19,9 +20,9 @@ export interface UpdateCheckResult {
/**
* Check all containers for available image updates.
* Returns all results at once after checking in parallel.
* Returns progress events during checking, final result when done.
*/
export const POST: RequestHandler = async ({ url, cookies }) => {
export const POST: RequestHandler = async ({ url, cookies, request }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -32,21 +33,21 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
return createJobResponse(async (send) => {
// Clear existing pending updates for this environment before checking
if (envIdNum) {
await clearPendingContainerUpdates(envIdNum);
}
const allContainers = await listContainers(true, envIdNum);
// Include all containers (system containers get flagged, not filtered)
const containers = allContainers;
send('progress', { checked: 0, total: containers.length });
// Check container for updates
let checked = 0;
const checkContainer = async (container: typeof containers[0]): Promise<UpdateCheckResult> => {
try {
// Get container's image name from config
const inspectData = await inspectContainer(container.id, envIdNum) as any;
const imageName = inspectData.Config?.Image;
const currentImageId = inspectData.Image;
@@ -62,7 +63,6 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
};
}
// Use shared update detection function
const result = await checkImageUpdateAvailable(imageName, currentImageId, envIdNum);
return {
@@ -88,13 +88,23 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
}
};
// Check all containers in parallel
const results = await Promise.all(containers.map(checkContainer));
// Sliding window concurrency limit to avoid DNS threadpool saturation (#676).
const CONCURRENCY = 20;
const results: UpdateCheckResult[] = new Array(containers.length);
let next = 0;
async function runNext(): Promise<void> {
while (next < containers.length) {
const idx = next++;
results[idx] = await checkContainer(containers[idx]);
checked++;
send('progress', { checked, total: containers.length });
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, containers.length) }, () => runNext()));
const updatesFound = results.filter(r => r.hasUpdate).length;
// Save containers with updates to the database for persistence
// Skip system containers (Dockhand/Hawser) - they use their own update paths
if (envIdNum) {
for (const result of results) {
if (result.hasUpdate && !result.systemContainer) {
@@ -108,13 +118,10 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
}
}
return json({
send('result', {
total: containers.length,
updatesFound,
results
});
} catch (error: any) {
console.error('Error checking for updates:', error);
return json({ error: 'Failed to check for updates', details: error.message }, { status: 500 });
}
}, request);
};
@@ -24,6 +24,7 @@ import { authorize } from '$lib/server/authorize';
import { prefersJSON, sseToJSON } from '$lib/server/sse';
import type { EnvironmentStats } from '../+server';
import { parseLabels } from '$lib/utils/label-colors';
import { isEdgeConnected } from '$lib/server/hawser';
// Skip disk usage collection (Synology NAS performance fix)
@@ -249,6 +250,22 @@ async function getEnvironmentStatsProgressive(
loading: { ...envStats.loading }
});
// For edge envs with no connected agent, skip the 5s ping and fail immediately.
// On restart, agents take 30-70s to reconnect — without this check, every open
// dashboard tab fires a 5s ping per edge env simultaneously, creating a flood.
if (env.connectionType === 'hawser-edge' && !isEdgeConnected(env.id)) {
envStats.online = false;
envStats.error = 'Agent not connected';
envStats.loading = undefined;
onPartialUpdate({
id: env.id,
online: false,
error: 'Agent not connected',
loading: undefined
});
return envStats;
}
// Quick reachability check — if ping fails, skip all expensive Docker API calls
if (!await dockerPing(env.id)) {
envStats.online = false;
+7 -1
View File
@@ -80,8 +80,14 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'An environment with this name already exists' }, { status: 409 });
}
// Host is required for direct and hawser-standard connections
// Validate connection type
const validConnectionTypes = ['socket', 'direct', 'hawser-standard', 'hawser-edge'];
const connectionType = data.connectionType || 'socket';
if (!validConnectionTypes.includes(connectionType)) {
return json({ error: `Invalid connection type: ${connectionType}` }, { status: 400 });
}
// Host is required for direct and hawser-standard connections
if ((connectionType === 'direct' || connectionType === 'hawser-standard') && !data.host) {
return json({ error: 'Host is required for this connection type' }, { status: 400 });
}
+1 -1
View File
@@ -152,7 +152,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
return json(hostInfo);
} catch (error) {
console.error('Failed to get host info:', error);
console.error('Failed to get host info:', (error as Error)?.message ?? error);
return json({ error: 'Failed to get host info' }, { status: 500 });
}
};
+6 -1
View File
@@ -49,7 +49,9 @@ async function fetchDockerHubTags(imageName: string, page: number = 1, pageSize:
if (response.status === 404) {
throw new Error('Image not found on Docker Hub');
}
throw new Error(`Docker Hub returned error: ${response.status}`);
const err = new Error(`Docker Hub returned error: ${response.status}`) as any;
err.statusCode = response.status;
throw err;
}
const data = await response.json();
@@ -162,6 +164,9 @@ export const GET: RequestHandler = async ({ url }) => {
if (error.code === 'ENOTFOUND') {
return json({ error: 'Registry host not found' }, { status: 503 });
}
if (error.statusCode) {
return json({ error: error.message || 'Failed to fetch tags' }, { status: error.statusCode });
}
return json({ error: error.message || 'Failed to fetch tags' }, { status: 500 });
}
+51 -19
View File
@@ -1,26 +1,41 @@
import { json } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { getOwnContainerId, getHostDockerSocket } from '$lib/server/host-path';
import { getOwnContainerId, getHostDockerSocket, getOwnDockerHost, getOwnNetworkMode } from '$lib/server/host-path';
import { buildRegistryAuthHeader, unixSocketRequest, unixSocketStreamRequest } from '$lib/server/docker';
import type { RequestHandler } from './$types';
import { prefersJSON, sseToJSON } from '$lib/server/sse';
const UPDATER_IMAGE = 'fnsys/dockhand-updater:latest';
const UPDATER_LABEL = 'dockhand.updater';
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
/** Fetch from the local Docker socket (buffered). */
function localDockerFetch(path: string, options: RequestInit = {}): Promise<Response> {
return unixSocketRequest(DOCKER_SOCKET, path, options);
/** Get TCP Docker host if configured, null otherwise. */
function getDockerTcpHost(): string | null {
const dockerHost = process.env.DOCKER_HOST || getOwnDockerHost();
return dockerHost?.startsWith('tcp://') ? dockerHost : null;
}
/** Fetch from the local Docker socket (streaming body for pull progress). */
/** Fetch from the local Docker (buffered). Supports TCP and Unix socket. */
function localDockerFetch(path: string, options: RequestInit = {}): Promise<Response> {
const tcpHost = getDockerTcpHost();
if (tcpHost) {
return fetch(tcpHost.replace('tcp://', 'http://') + path, options);
}
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
return unixSocketRequest(socketPath, path, options);
}
/** Fetch from the local Docker (streaming body for pull progress). */
function localDockerStreamFetch(path: string, options: RequestInit = {}): Promise<Response> {
return unixSocketStreamRequest(DOCKER_SOCKET, path, options);
const tcpHost = getDockerTcpHost();
if (tcpHost) {
return fetch(tcpHost.replace('tcp://', 'http://') + path, options);
}
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
return unixSocketStreamRequest(socketPath, path, options);
}
/**
* Pull an image via local Docker socket, streaming progress via callback.
* Pull an image via local Docker, streaming progress via callback.
*/
async function pullImageLocal(imageName: string, onProgress?: (line: string) => void): Promise<void> {
let fromImage = imageName;
@@ -79,9 +94,14 @@ async function pullImageLocal(imageName: string, onProgress?: (line: string) =>
}
/**
* Check if Docker socket is mounted read-write
* Check if Docker access allows write operations.
* TCP connections always allow writes (no RO mount concept).
* Socket connections check if the mount is read-write.
*/
async function isDockerSocketWritable(containerId: string): Promise<boolean> {
async function isDockerWritable(containerId: string): Promise<boolean> {
// TCP connections don't have mount-level RO/RW — access implies full control
if (getDockerTcpHost()) return true;
const response = await localDockerFetch(`/containers/${containerId}/json`);
if (!response.ok) return false;
@@ -235,7 +255,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
return json({ error: 'Not running in Docker' }, { status: 400 });
}
const writable = await isDockerSocketWritable(containerId);
const writable = await isDockerWritable(containerId);
if (!writable) {
return json({
error: 'Docker socket is mounted read-only. Self-update requires read-write Docker socket access.'
@@ -252,8 +272,6 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
return json({ error: 'Failed to determine container name' }, { status: 500 });
}
const socketHostPath = getHostDockerSocket();
// Start SSE stream for preparation progress
const encoder = new TextEncoder();
let controllerClosed = false;
@@ -353,6 +371,25 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
...networkEnvVars
];
// Configure updater's Docker access based on connection type
const tcpHost = getDockerTcpHost();
const updaterHostConfig: Record<string, unknown> = { AutoRemove: true };
if (tcpHost) {
// TCP: pass DOCKER_HOST so docker CLI in sidecar uses TCP
updaterEnv.push(`DOCKER_HOST=${tcpHost}`);
// Put sidecar on same network so it can reach the Docker TCP endpoint
const network = getOwnNetworkMode();
if (network) {
updaterHostConfig.NetworkMode = network;
}
send('log', { message: `Updater using TCP: ${tcpHost}` });
} else {
// Socket: bind-mount the host Docker socket
const socketHostPath = getHostDockerSocket();
updaterHostConfig.Binds = [`${socketHostPath}:/var/run/docker.sock`];
}
console.log('[SelfUpdate] Creating updater container...');
const updaterResponse = await localDockerFetch('/containers/create?name=dockhand-updater', {
method: 'POST',
@@ -363,12 +400,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
Labels: {
[UPDATER_LABEL]: 'true'
},
HostConfig: {
AutoRemove: true,
Binds: [
`${socketHostPath}:/var/run/docker.sock`
]
}
HostConfig: updaterHostConfig
})
});
+13 -5
View File
@@ -1,15 +1,23 @@
import { json } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { getOwnContainerId } from '$lib/server/host-path';
import { getOwnContainerId, getOwnDockerHost } from '$lib/server/host-path';
import { getRegistryManifestDigest, unixSocketRequest } from '$lib/server/docker';
import { compareVersions } from '$lib/utils/version';
import type { RequestHandler } from './$types';
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
/** Fetch from the local Docker socket directly (not through environment routing) */
/** Fetch from the local Docker directly (not through environment routing) */
function localDockerFetch(path: string, options: RequestInit = {}): Promise<Response> {
return unixSocketRequest(DOCKER_SOCKET, path, options);
const dockerHost = process.env.DOCKER_HOST || getOwnDockerHost();
if (dockerHost?.startsWith('tcp://')) {
// TCP connection (socat proxy, socket-proxy, remote Docker)
const url = dockerHost.replace('tcp://', 'http://') + path;
return fetch(url, options);
}
// Unix socket (default)
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
return unixSocketRequest(socketPath, path, options);
}
/**
@@ -1,13 +1,17 @@
import { json } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { getOwnDockerHost } from '$lib/server/host-path';
import { unixSocketRequest } from '$lib/server/docker';
import type { RequestHandler } from './$types';
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
/** Fetch from the local Docker socket directly */
/** Fetch from the local Docker directly. Supports TCP and Unix socket. */
function localDockerFetch(path: string): Promise<Response> {
return unixSocketRequest(DOCKER_SOCKET, path);
const dockerHost = process.env.DOCKER_HOST || getOwnDockerHost();
if (dockerHost?.startsWith('tcp://')) {
return fetch(dockerHost.replace('tcp://', 'http://') + path);
}
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
return unixSocketRequest(socketPath, path);
}
/**
+13 -1
View File
@@ -65,6 +65,8 @@ export interface GeneralSettings {
gridFontSize: string;
terminalFont: string;
editorFont: string;
// Compact ports
compactPorts: boolean;
// External stack paths
externalStackPaths: string[];
// Primary stack location
@@ -85,6 +87,7 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
eventCollectionMode: 'stream',
eventPollInterval: 60000,
metricsCollectionInterval: 30000,
compactPorts: false,
lightTheme: 'default',
darkTheme: 'default',
font: 'system',
@@ -140,6 +143,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
gridFontSize,
terminalFont,
editorFont,
compactPorts,
externalStackPaths,
primaryStackLocation
] = await Promise.all([
@@ -169,6 +173,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
getSetting('theme_grid_font_size'),
getSetting('theme_terminal_font'),
getSetting('theme_editor_font'),
getSetting('compact_ports'),
getExternalStackPaths(),
getPrimaryStackLocation()
]);
@@ -200,6 +205,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
gridFontSize: gridFontSize ?? DEFAULT_SETTINGS.gridFontSize,
terminalFont: terminalFont ?? DEFAULT_SETTINGS.terminalFont,
editorFont: editorFont ?? DEFAULT_SETTINGS.editorFont,
compactPorts: compactPorts ?? DEFAULT_SETTINGS.compactPorts,
externalStackPaths,
primaryStackLocation
};
@@ -219,7 +225,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, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, externalStackPaths, primaryStackLocation } = body;
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, externalStackPaths, primaryStackLocation } = body;
if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive);
@@ -312,6 +318,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (editorFont !== undefined && VALID_EDITOR_FONTS.includes(editorFont)) {
await setSetting('theme_editor_font', editorFont);
}
if (compactPorts !== undefined) {
await setSetting('compact_ports', compactPorts);
}
if (externalStackPaths !== undefined && Array.isArray(externalStackPaths)) {
// Filter to valid non-empty strings
const validPaths = externalStackPaths.filter((p: unknown) => typeof p === 'string' && p.trim());
@@ -355,6 +364,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
gridFontSizeVal,
terminalFontVal,
editorFontVal,
compactPortsVal,
externalStackPathsVal,
primaryStackLocationVal
] = await Promise.all([
@@ -384,6 +394,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getSetting('theme_grid_font_size'),
getSetting('theme_terminal_font'),
getSetting('theme_editor_font'),
getSetting('compact_ports'),
getExternalStackPaths(),
getPrimaryStackLocation()
]);
@@ -415,6 +426,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
gridFontSize: gridFontSizeVal ?? DEFAULT_SETTINGS.gridFontSize,
terminalFont: terminalFontVal ?? DEFAULT_SETTINGS.terminalFont,
editorFont: editorFontVal ?? DEFAULT_SETTINGS.editorFont,
compactPorts: compactPortsVal ?? DEFAULT_SETTINGS.compactPorts,
externalStackPaths: externalStackPathsVal,
primaryStackLocation: primaryStackLocationVal
};
+11 -5
View File
@@ -24,9 +24,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
const volume = await inspectVolume(params.name, envIdNum);
return json(volume);
} catch (error) {
console.error('Failed to inspect volume:', error);
return json({ error: 'Failed to inspect volume' }, { status: 500 });
} catch (error: any) {
const status = error.statusCode ?? 500;
console.error(`Failed to inspect volume ${params.name}: ${error.message}`);
return json({ error: 'Failed to inspect volume' }, { status });
}
};
@@ -57,7 +58,12 @@ export const DELETE: RequestHandler = async (event) => {
return json({ success: true });
} catch (error: any) {
console.error('Failed to remove volume:', error);
return json({ error: 'Failed to remove volume', details: error.message }, { status: 500 });
const status = error.statusCode ?? 500;
if (status === 404) {
console.warn(`Failed to remove volume ${params.name}: ${error.message}`);
} else {
console.error(`Failed to remove volume ${params.name}: ${error.message}`);
}
return json({ error: 'Failed to remove volume', details: error.message }, { status });
}
};
+51 -7
View File
@@ -81,6 +81,7 @@
import { appSettings } from '$lib/stores/settings';
import { canAccess } from '$lib/stores/auth';
import { vulnerabilityCriteriaIcons } from '$lib/utils/update-steps';
import { watchJob } from '$lib/utils/sse-fetch';
import { ipToNumber } from '$lib/utils/ip';
import { formatHostPortUrl } from '$lib/utils/url';
import { detectShells, getBestShell, hasAvailableShell, USER_OPTIONS, type ShellDetectionResult } from '$lib/utils/shell-detection';
@@ -262,6 +263,8 @@
// Update check state
let updateCheckStatus = $state<'idle' | 'checking' | 'found' | 'none' | 'error'>('idle');
let updateCheckProgress = $state({ checked: 0, total: 0 });
let updateCheckBtnEl = $state<HTMLButtonElement | null>(null);
let showBatchUpdateModal = $state(false);
const batchUpdateContainerIds = $derived($containerStore.pendingUpdateIds);
const batchUpdateContainerNames = $derived($containerStore.pendingUpdateNames);
@@ -423,6 +426,13 @@
async function checkForUpdates() {
updateCheckStatus = 'checking';
updateCheckProgress = { checked: 0, total: 0 };
// Lock button width to prevent layout shift
if (updateCheckBtnEl) {
updateCheckBtnEl.style.minWidth = `${updateCheckBtnEl.offsetWidth}px`;
}
try {
const response = await fetch(appendEnvParam('/api/containers/check-updates', envId), {
method: 'POST'
@@ -430,9 +440,20 @@
if (!response.ok) {
updateCheckStatus = 'error';
pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000));
if (updateCheckBtnEl) updateCheckBtnEl.style.minWidth = '';
return;
}
const data = await response.json();
const { jobId } = await response.json();
const data: any = await watchJob(jobId, (line) => {
if (line.event === 'progress') {
updateCheckProgress = line.data as { checked: number; total: number };
}
});
// Unlock button width
if (updateCheckBtnEl) updateCheckBtnEl.style.minWidth = '';
const containersWithUpdates = data.results.filter((r: any) => r.hasUpdate);
const failedChecks = data.results.filter((r: any) => r.error && !r.hasUpdate).length;
const failedSuffix = failedChecks > 0 ? ` (${failedChecks} failed to check)` : '';
@@ -459,6 +480,7 @@
} catch (error) {
updateCheckStatus = 'error';
pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000));
if (updateCheckBtnEl) updateCheckBtnEl.style.minWidth = '';
}
}
@@ -1369,22 +1391,35 @@
</Button>
{/if}
<Button
bind:ref={updateCheckBtnEl}
size="sm"
variant="outline"
onclick={checkForUpdates}
disabled={updateCheckStatus === 'checking'}
title="Check for available updates"
class="relative overflow-hidden"
>
{#if updateCheckStatus === 'checking'}
<CircleArrowUp class="w-3.5 h-3.5 mr-1 animate-spin" />
<CircleArrowUp class="w-3.5 h-3.5 animate-spin" />
{#if updateCheckProgress.total > 0}
<span class="tabular-nums">Checking {String(updateCheckProgress.checked).padStart(String(updateCheckProgress.total).length, '\u2007')}/{updateCheckProgress.total}</span>
<div
class="absolute bottom-0 left-0 h-px bg-foreground transition-[width] duration-150 ease-out"
style="width: {(updateCheckProgress.checked / updateCheckProgress.total) * 100}%"
></div>
{:else}
Check for updates
{/if}
{:else if updateCheckStatus === 'none' || updateCheckStatus === 'found'}
<Check class="w-3.5 h-3.5 mr-1 text-green-600" />
Check for updates
{:else if updateCheckStatus === 'error'}
<XCircle class="w-3.5 h-3.5 mr-1 text-destructive" />
Check for updates
{:else}
<CircleArrowUp class="w-3.5 h-3.5" />
Check for updates
{/if}
Check for updates
</Button>
{#if updatableContainersCount > 0}
<Button
@@ -1791,8 +1826,11 @@
<code class="text-xs">{getContainerIp(container.networks)}</code>
{:else if column.id === 'ports'}
{#if ports.length > 0}
<div class="flex flex-wrap gap-1">
{#each ports as port}
{@const compactPorts = $appSettings.compactPorts}
{@const displayPorts = compactPorts && ports.length > 1 ? [ports[0]] : ports}
{@const remainingCount = ports.length - 1}
<div class="flex {compactPorts ? 'flex-nowrap' : 'flex-wrap'} gap-1">
{#each displayPorts as port}
{@const url = currentEnvDetails ? getPortUrl(port.publicPort) : null}
{#if url}
<a
@@ -1800,16 +1838,22 @@
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
class="inline-flex items-center gap-0.5 text-xs bg-muted hover:bg-blue-500/20 hover:text-blue-500 px-1 py-0.5 rounded transition-colors"
class="inline-flex items-center gap-0.5 text-xs bg-muted hover:bg-blue-500/20 hover:text-blue-500 px-1 py-0.5 rounded transition-colors shrink-0"
title="Open {url} in new tab"
>
<code>{port.display}</code>
<ExternalLink class="w-2.5 h-2.5 text-muted-foreground" />
</a>
{:else}
<code class="text-xs bg-muted px-1 py-0.5 rounded">{port.display}</code>
<code class="text-xs bg-muted px-1 py-0.5 rounded shrink-0">{port.display}</code>
{/if}
{/each}
{#if compactPorts && remainingCount > 0}
<span
class="text-xs bg-muted text-muted-foreground px-1 py-0.5 rounded cursor-default shrink-0"
title={ports.map(p => p.display).join(', ')}
>+{remainingCount}</span>
{/if}
</div>
{:else}
<span class="text-gray-400 dark:text-gray-600 text-xs">-</span>
@@ -219,6 +219,10 @@
// Store scan result, individual scanner results, and vulnerabilities
const containerProgress = progress.find(p => p.containerId === data.containerId);
if (containerProgress) {
// Add combined summary log when multiple scanners were used
if (data.message && data.scannerResults && data.scannerResults.length > 1) {
containerProgress.scanLogs.push({ message: data.message });
}
containerProgress.scanResult = data.scanResult;
containerProgress.scannerResults = data.scannerResults;
containerProgress.vulnerabilities = data.vulnerabilities;
@@ -583,6 +587,9 @@ const severityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2,
{#each sortedVulns(item.vulnerabilities).slice(0, 50) as vuln}
<tr class="border-b border-muted/50">
<td class="py-1 pr-2 font-mono">
{#if item.scannerResults && item.scannerResults.length > 1 && vuln.scanner}
<Badge variant="outline" class="px-1 py-0 text-[9px] mr-1 {vuln.scanner === 'grype' ? 'border-blue-400 text-blue-500' : 'border-emerald-400 text-emerald-500'}">{vuln.scanner === 'grype' ? 'G' : 'T'}</Badge>
{/if}
{#if vuln.link}
<a href={vuln.link} target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:underline inline-flex items-center gap-0.5">
{vuln.id}
@@ -913,8 +913,8 @@
networks: selectedNetworks.length > 0 ? selectedNetworks : undefined,
startAfterUpdate,
repullImage,
user: containerUser.trim() || undefined,
privileged: privilegedMode || undefined,
user: containerUser.trim() || null,
privileged: privilegedMode,
healthcheck,
memory: parseMemory(memoryLimit),
memoryReservation: parseMemory(memoryReservation),
@@ -926,7 +926,7 @@
capDrop: capDrop.length > 0 ? capDrop : undefined,
devices: devices.length > 0 ? devices : undefined,
deviceRequests,
runtime: runtime || undefined,
runtime: runtime || null,
dns: dnsServers.length > 0 ? dnsServers : undefined,
dnsSearch: dnsSearch.length > 0 ? dnsSearch : undefined,
dnsOptions: dnsOptions.length > 0 ? dnsOptions : undefined,
+5 -120
View File
@@ -18,6 +18,9 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
import { currentEnvironment, environments, appendEnvParam } from '$lib/stores/environment';
import { appSettings } from '$lib/stores/settings';
import { NoEnvironment } from '$lib/components/ui/empty-state';
import { AnsiUp } from 'ansi_up';
const ansiUp = new AnsiUp();
ansiUp.use_classes = true;
// Track if we've handled the initial container from URL
let initialContainerHandled = $state(false);
@@ -1347,94 +1350,11 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
.replace(/>/g, '&gt;');
}
// ANSI color code to CSS class mapping
const ansiColorMap: Record<string, string> = {
'30': 'ansi-black',
'31': 'ansi-red',
'32': 'ansi-green',
'33': 'ansi-yellow',
'34': 'ansi-blue',
'35': 'ansi-magenta',
'36': 'ansi-cyan',
'37': 'ansi-white',
'90': 'ansi-bright-black',
'91': 'ansi-bright-red',
'92': 'ansi-bright-green',
'93': 'ansi-bright-yellow',
'94': 'ansi-bright-blue',
'95': 'ansi-bright-magenta',
'96': 'ansi-bright-cyan',
'97': 'ansi-bright-white',
// Background colors
'40': 'ansi-bg-black',
'41': 'ansi-bg-red',
'42': 'ansi-bg-green',
'43': 'ansi-bg-yellow',
'44': 'ansi-bg-blue',
'45': 'ansi-bg-magenta',
'46': 'ansi-bg-cyan',
'47': 'ansi-bg-white',
// Text styles
'1': 'ansi-bold',
'2': 'ansi-dim',
'3': 'ansi-italic',
'4': 'ansi-underline',
};
// Convert ANSI escape codes to HTML spans with CSS classes
function ansiToHtml(text: string): string {
// Strip Docker log stream header bytes (control characters at start of lines)
// These appear as bytes 0x00-0x02 followed by stream data
let cleaned = text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1A]/g, '');
// First escape HTML
let escaped = escapeHtml(cleaned);
// Match ANSI escape sequences: ESC[ followed by codes and ending with m
// ESC can be \x1b, \033, or \e
const ansiRegex = /\x1b\[([0-9;]*)m/g;
let result = '';
let lastIndex = 0;
let openSpans = 0;
let match;
while ((match = ansiRegex.exec(escaped)) !== null) {
// Add text before this match
result += escaped.slice(lastIndex, match.index);
lastIndex = ansiRegex.lastIndex;
const codes = match[1].split(';').filter(c => c !== '');
for (const code of codes) {
if (code === '0' || code === '39' || code === '49' || code === '') {
// Reset code - close all open spans
while (openSpans > 0) {
result += '</span>';
openSpans--;
}
} else if (ansiColorMap[code]) {
result += `<span class="${ansiColorMap[code]}">`;
openSpans++;
}
}
}
// Add remaining text
result += escaped.slice(lastIndex);
// Close any remaining open spans
while (openSpans > 0) {
result += '</span>';
openSpans--;
}
return result;
return ansiUp.ansi_to_html(text);
}
// Highlighted logs with search matches and ANSI color support (single container mode)
@@ -2258,39 +2178,4 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
outline: 2px solid rgb(250, 204, 21);
}
/* ANSI color classes - foreground colors */
:global(.ansi-black) { color: #3f3f46; }
:global(.ansi-red) { color: #ef4444; }
:global(.ansi-green) { color: #22c55e; }
:global(.ansi-yellow) { color: #eab308; }
:global(.ansi-blue) { color: #3b82f6; }
:global(.ansi-magenta) { color: #d946ef; }
:global(.ansi-cyan) { color: #06b6d4; }
:global(.ansi-white) { color: #e4e4e7; }
/* Bright foreground colors */
:global(.ansi-bright-black) { color: #71717a; }
:global(.ansi-bright-red) { color: #f87171; }
:global(.ansi-bright-green) { color: #4ade80; }
:global(.ansi-bright-yellow) { color: #facc15; }
:global(.ansi-bright-blue) { color: #60a5fa; }
:global(.ansi-bright-magenta) { color: #e879f9; }
:global(.ansi-bright-cyan) { color: #22d3ee; }
:global(.ansi-bright-white) { color: #fafafa; }
/* Background colors */
:global(.ansi-bg-black) { background-color: #18181b; }
:global(.ansi-bg-red) { background-color: #dc2626; }
:global(.ansi-bg-green) { background-color: #16a34a; }
:global(.ansi-bg-yellow) { background-color: #ca8a04; }
:global(.ansi-bg-blue) { background-color: #2563eb; }
:global(.ansi-bg-magenta) { background-color: #c026d3; }
:global(.ansi-bg-cyan) { background-color: #0891b2; }
:global(.ansi-bg-white) { background-color: #d4d4d8; }
/* Text styles */
:global(.ansi-bold) { font-weight: bold; }
:global(.ansi-dim) { opacity: 0.7; }
:global(.ansi-italic) { font-style: italic; }
:global(.ansi-underline) { text-decoration: underline; }
</style>
+15 -16
View File
@@ -4,6 +4,9 @@
import * as Select from '$lib/components/ui/select';
import { themeStore } from '$lib/stores/theme';
import { getMonospaceFont } from '$lib/themes';
import { AnsiUp } from 'ansi_up';
const ansiUp = new AnsiUp();
ansiUp.use_classes = true;
interface Props {
logs: string;
@@ -139,25 +142,20 @@
}
}
// Escape HTML to prevent XSS
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// Highlighted logs with search matches
// Highlighted logs with search matches and ANSI color support
let highlightedLogs = $derived(() => {
const escaped = escapeHtml(logs || '');
if (!logSearchQuery.trim()) return escaped;
const withAnsi = ansiUp.ansi_to_html(logs || '');
if (!logSearchQuery.trim()) return withAnsi;
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedQuery = escapeHtml(query);
const regex = new RegExp(`(${escapedQuery})`, 'gi');
return escaped.replace(regex, '<mark class="search-match">$1</mark>');
const escapedQuery = query.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Split by HTML tags and only process text parts
const parts = withAnsi.split(/(<[^>]*>)/);
return parts.map(part => {
if (part.startsWith('<')) return part;
return part.replace(new RegExp(`(${escapedQuery})`, 'gi'), '<mark class="search-match">$1</mark>');
}).join('');
});
// Update match count after render
@@ -322,4 +320,5 @@
box-shadow: 0 0 8px rgba(234, 179, 8, 0.9), 0 0 16px rgba(234, 179, 8, 0.5);
outline: 2px solid rgb(250, 204, 21);
}
</style>
+5 -125
View File
@@ -6,6 +6,9 @@
import { appSettings } from '$lib/stores/settings';
import { themeStore } from '$lib/stores/theme';
import { getMonospaceFont } from '$lib/themes';
import { AnsiUp } from 'ansi_up';
const ansiUp = new AnsiUp();
ansiUp.use_classes = true;
interface Props {
containerId: string;
@@ -519,100 +522,13 @@
}
}
// Escape HTML to prevent XSS
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// ANSI color code to CSS class mapping
const ansiColorMap: Record<string, string> = {
'30': 'ansi-black',
'31': 'ansi-red',
'32': 'ansi-green',
'33': 'ansi-yellow',
'34': 'ansi-blue',
'35': 'ansi-magenta',
'36': 'ansi-cyan',
'37': 'ansi-white',
'90': 'ansi-bright-black',
'91': 'ansi-bright-red',
'92': 'ansi-bright-green',
'93': 'ansi-bright-yellow',
'94': 'ansi-bright-blue',
'95': 'ansi-bright-magenta',
'96': 'ansi-bright-cyan',
'97': 'ansi-bright-white',
'40': 'ansi-bg-black',
'41': 'ansi-bg-red',
'42': 'ansi-bg-green',
'43': 'ansi-bg-yellow',
'44': 'ansi-bg-blue',
'45': 'ansi-bg-magenta',
'46': 'ansi-bg-cyan',
'47': 'ansi-bg-white',
'1': 'ansi-bold',
'2': 'ansi-dim',
'3': 'ansi-italic',
'4': 'ansi-underline',
};
// Convert ANSI escape codes to HTML spans with CSS classes
function ansiToHtml(text: string): string {
// Strip control characters
let cleaned = text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1A]/g, '');
// Escape HTML
let escaped = escapeHtml(cleaned);
// Match ANSI escape sequences
const ansiRegex = /\x1b\[([0-9;]*)m/g;
let result = '';
let lastIndex = 0;
let openSpans = 0;
let match;
while ((match = ansiRegex.exec(escaped)) !== null) {
result += escaped.slice(lastIndex, match.index);
lastIndex = ansiRegex.lastIndex;
const codes = match[1].split(';').filter(c => c !== '');
for (const code of codes) {
if (code === '0' || code === '39' || code === '49' || code === '') {
while (openSpans > 0) {
result += '</span>';
openSpans--;
}
} else if (ansiColorMap[code]) {
result += `<span class="${ansiColorMap[code]}">`;
openSpans++;
}
}
}
result += escaped.slice(lastIndex);
while (openSpans > 0) {
result += '</span>';
openSpans--;
}
return result;
}
// Highlighted logs with search matches and ANSI color support
let highlightedLogs = $derived(() => {
const withAnsi = ansiToHtml(logs || '');
const withAnsi = ansiUp.ansi_to_html(logs || '');
if (!logSearchQuery.trim()) return withAnsi;
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedQuery = escapeHtml(query);
const escapedQuery = query.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Split by HTML tags and only process text parts
const parts = withAnsi.split(/(<[^>]*>)/);
@@ -897,42 +813,6 @@
outline: 2px solid rgb(250, 204, 21);
}
/* ANSI color classes - foreground colors */
:global(.ansi-black) { color: #3f3f46; }
:global(.ansi-red) { color: #ef4444; }
:global(.ansi-green) { color: #22c55e; }
:global(.ansi-yellow) { color: #eab308; }
:global(.ansi-blue) { color: #3b82f6; }
:global(.ansi-magenta) { color: #d946ef; }
:global(.ansi-cyan) { color: #06b6d4; }
:global(.ansi-white) { color: #e4e4e7; }
/* Bright foreground colors */
:global(.ansi-bright-black) { color: #71717a; }
:global(.ansi-bright-red) { color: #f87171; }
:global(.ansi-bright-green) { color: #4ade80; }
:global(.ansi-bright-yellow) { color: #facc15; }
:global(.ansi-bright-blue) { color: #60a5fa; }
:global(.ansi-bright-magenta) { color: #e879f9; }
:global(.ansi-bright-cyan) { color: #22d3ee; }
:global(.ansi-bright-white) { color: #fafafa; }
/* Background colors */
:global(.ansi-bg-black) { background-color: #18181b; }
:global(.ansi-bg-red) { background-color: #dc2626; }
:global(.ansi-bg-green) { background-color: #16a34a; }
:global(.ansi-bg-yellow) { background-color: #ca8a04; }
:global(.ansi-bg-blue) { background-color: #2563eb; }
:global(.ansi-bg-magenta) { background-color: #c026d3; }
:global(.ansi-bg-cyan) { background-color: #0891b2; }
:global(.ansi-bg-white) { background-color: #d4d4d8; }
/* Text styles */
:global(.ansi-bold) { font-weight: bold; }
:global(.ansi-dim) { opacity: 0.7; }
:global(.ansi-italic) { font-style: italic; }
:global(.ansi-underline) { text-decoration: underline; }
/* Fade-in animation for logs */
@keyframes fadeIn {
from {
@@ -307,7 +307,21 @@
* Strip protocol and port from a host/IP string
*/
function stripHostProtocol(value: string): string {
return value.replace(/^(?:\w+:\/\/)/, '').replace(/[:/].*$/, '');
// Strip protocol prefix (e.g., tcp://, https://)
const stripped = value.replace(/^(?:\w+:\/\/)/, '');
// Handle bracketed IPv6 with optional port: [::1]:port → ::1
if (stripped.startsWith('[')) {
return stripped.replace(/^\[([^\]]+)\].*$/, '$1');
}
// Handle plain IPv6 (2+ colons = IPv6, not IPv4:port or hostname:port)
if ((stripped.match(/:/g) || []).length > 1) {
return stripped;
}
// IPv4 or hostname: strip :port and path
return stripped.replace(/[:/].*$/, '');
}
/**
@@ -215,28 +215,26 @@
testingAll = true;
// Process environments sequentially to avoid overwhelming the system
// This is especially important for Edge environments that have longer timeouts
for (const env of environments) {
// Mark this environment as testing
testingEnvs.add(env.id);
testingEnvs = new Set(testingEnvs);
// Show all spinners immediately, then test all envs in parallel.
// Sequential testing was wrong for edge envs: 30s timeout × N envs = N×30s total wait.
// Parallel: all timeouts run concurrently, total wait is max(individual timeouts) = 30s.
environments.forEach(env => testingEnvs.add(env.id));
testingEnvs = new Set(testingEnvs);
try {
const response = await fetch(`/api/environments/${env.id}/test`, {
method: 'POST'
});
const result = await response.json();
testResults[env.id] = result;
} catch (error) {
testResults[env.id] = { success: false, error: 'Connection failed' };
}
testResults = { ...testResults };
// Mark this environment as done
testingEnvs.delete(env.id);
testingEnvs = new Set(testingEnvs);
}
await Promise.all(
environments.map(async (env) => {
try {
const response = await fetch(`/api/environments/${env.id}/test`, { method: 'POST' });
testResults[env.id] = await response.json();
} catch {
testResults[env.id] = { success: false, error: 'Connection failed' };
} finally {
testingEnvs.delete(env.id);
testingEnvs = new Set(testingEnvs);
}
testResults = { ...testResults };
})
);
testingAll = false;
}
@@ -19,6 +19,7 @@
let confirmDestructive = $derived($appSettings.confirmDestructive);
let showStoppedContainers = $derived($appSettings.showStoppedContainers);
let highlightUpdates = $derived($appSettings.highlightUpdates);
let compactPorts = $derived($appSettings.compactPorts);
let timeFormat = $derived($appSettings.timeFormat);
let dateFormat = $derived($appSettings.dateFormat);
let downloadFormat = $derived($appSettings.downloadFormat);
@@ -178,6 +179,20 @@
</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>Compact port display</Label>
<TogglePill
checked={compactPorts}
onchange={() => {
appSettings.setCompactPorts(!compactPorts);
toast.success(compactPorts ? 'Showing all ports' : 'Compact port display enabled');
}}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Show first port with +N count instead of all ports</p>
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Time format</Label>
+1 -1
View File
@@ -1797,7 +1797,7 @@
{/if}
{/if}
{/if}
{#if $canAccess('stacks', 'stop')}
{#if $canAccess('stacks', 'stop') && stack.status !== 'created' && stack.status !== 'not deployed'}
<ConfirmPopover
open={confirmDownName === stack.name}
action="Down"
+25 -28
View File
@@ -360,7 +360,7 @@ function dockerHttpRequest(method: string, path: string, target: DockerTarget, b
opts.host = target.host;
opts.port = target.port;
opts.rejectUnauthorized = target.tls.rejectUnauthorized ?? true;
if (target.tls.ca) opts.ca = [target.tls.ca];
if (target.tls.ca) opts.ca = [target.tls.ca, ...tls.rootCertificates];
if (target.tls.cert) opts.cert = [target.tls.cert];
if (target.tls.key) opts.key = target.tls.key;
req = https.request(opts);
@@ -556,11 +556,14 @@ function webSocketPlugin(): Plugin {
const wss = new WebSocketServer({ server: httpServer });
// Per-connection metadata
const wsMetadata = new Map<WsWebSocket, { url: string; connId?: string; edgeExecId?: string }>();
const wsMetadata = new Map<WsWebSocket, { url: string; connId?: string; edgeExecId?: string; remoteIp?: string }>();
wss.on('connection', (ws: WsWebSocket, req: any) => {
const url = new URL(req.url || '/', `http://localhost:${WS_PORT}`);
const meta = { url: req.url || '/' };
const remoteIp = (req.headers?.['x-forwarded-for'] || '').split(',')[0].trim()
|| req.socket?.remoteAddress
|| 'unknown';
const meta = { url: req.url || '/', remoteIp };
wsMetadata.set(ws, meta);
// Handle connection open logic
@@ -630,7 +633,7 @@ function webSocketPlugin(): Plugin {
servername: target.host,
rejectUnauthorized: target.tls.rejectUnauthorized ?? true
};
if (target.tls.ca) tlsOpts.ca = [target.tls.ca];
if (target.tls.ca) tlsOpts.ca = [target.tls.ca, ...tls.rootCertificates];
if (target.tls.cert) tlsOpts.cert = [target.tls.cert];
if (target.tls.key) tlsOpts.key = target.tls.key;
dockerStream = tls.connect(tlsOpts);
@@ -814,7 +817,7 @@ function webSocketPlugin(): Plugin {
// Rate limiter for failed Hawser token auth (dev mode)
const hawserAuthFailCache = new Map<string, number>();
const HAWSER_AUTH_FAIL_COOLDOWN_MS = 60_000;
const HAWSER_AUTH_FAIL_COOLDOWN_MS = 5 * 60_000; // 5 minutes
// ─── Reconnection storm throttle (mirrors hawser.ts) ───
interface ReconnectTrackerEntry {
@@ -871,8 +874,10 @@ async function handleHawserMessage(ws: any, msg: any) {
const agentId = msg.agentId || 'unknown';
console.log(`[Hawser WS] Hello from agent: ${msg.agentName} (${agentId})`);
// Rate-limit agents that recently failed auth - skip expensive Argon2id verification
const lastFail = hawserAuthFailCache.get(agentId);
// Rate-limit by remote IP (not agentId which is attacker-controlled)
const meta = wsMetadata.get(ws);
const rateLimitKey = meta?.remoteIp || agentId;
const lastFail = hawserAuthFailCache.get(rateLimitKey);
if (lastFail && (Date.now() - lastFail) < HAWSER_AUTH_FAIL_COOLDOWN_MS) {
ws.send(JSON.stringify({ type: 'error', error: 'Rate limited - retry later' }));
ws.close();
@@ -887,12 +892,15 @@ async function handleHawserMessage(ws: any, msg: any) {
return;
}
// Token validation using proper Argon2id verification (same as production)
const tokens = db.prepare('SELECT * FROM hawser_tokens WHERE is_active = 1').all() as any[];
// Fast path: lookup by token prefix (first 8 chars) instead of iterating all tokens.
// This reduces O(N) Argon2id verifications to O(1) DB lookup + 1 verify.
const prefix = msg.token.substring(0, 8);
const candidates = db.prepare(
'SELECT * FROM hawser_tokens WHERE token_prefix = ? AND is_active = 1'
).all(prefix) as any[];
// Validate token using Argon2id hash verification
let matchedToken: any = null;
for (const t of tokens) {
for (const t of candidates) {
try {
const isValid = await argon2.verify(t.token, msg.token);
if (isValid) {
@@ -900,19 +908,19 @@ async function handleHawserMessage(ws: any, msg: any) {
break;
}
} catch {
// If verification fails, continue to next token
// Invalid hash format, skip
}
}
if (!matchedToken) {
console.log('[Hawser WS] Invalid token');
hawserAuthFailCache.set(agentId, Date.now());
console.log(`[Hawser WS] Invalid token (IP: ${rateLimitKey})`);
hawserAuthFailCache.set(rateLimitKey, Date.now());
ws.send(JSON.stringify({ type: 'error', error: 'Invalid token' }));
ws.close();
return;
}
// Clear any previous failure on successful auth
hawserAuthFailCache.delete(agentId);
hawserAuthFailCache.delete(rateLimitKey);
const environmentId = matchedToken.environment_id;
@@ -1010,19 +1018,8 @@ async function handleHawserMessage(ws: any, msg: any) {
message: `Welcome ${msg.agentName}! Connected to Dockhand dev server.`
}));
// Start server-side ping interval to keep connection alive through Traefik/proxies
// Traefik has ~10s idle timeout, so we ping every 5 seconds
connection.pingInterval = setInterval(() => {
try {
ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
} catch (e) {
// Connection likely closed, clear interval
if (connection.pingInterval) {
clearInterval(connection.pingInterval);
connection.pingInterval = undefined;
}
}
}, 5000);
// Note: server-side ping interval is managed by hawser.ts handleEdgeConnection()
// via the shared edgeConnections map — no duplicate interval needed here.
console.log(`[Hawser WS] Agent ${msg.agentName} connected for environment ${environmentId}`);
} else if (msg.type === 'ping') {