mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-17 19:09:33 +03:00
v1.0.21
This commit is contained in:
+3
-3
@@ -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 []
|
||||
|
||||
+10
-1
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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,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';
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// 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>
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
// 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>
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
// 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>
|
||||
|
||||
@@ -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
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user