mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-18 03:20:43 +03:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| faa2b9d571 | |||
| 2ca41703f2 | |||
| c19d73c509 | |||
| 7e869b582a | |||
| d0e5edcc98 | |||
| a621f7abbc | |||
| 725798f327 | |||
| 83adb275cd | |||
| 80a9c8b60a | |||
| 07be45ace5 |
+4
-4
@@ -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" \
|
||||
@@ -89,7 +89,7 @@ RUN rm -rf node_modules \
|
||||
&& rm -rf node_modules/@types
|
||||
|
||||
# Build Go collector
|
||||
FROM --platform=$BUILDPLATFORM golang:1.24 AS go-builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25.8 AS go-builder
|
||||
ARG TARGETARCH
|
||||
WORKDIR /app
|
||||
COPY collector/ ./collector/
|
||||
@@ -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 []
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
## How to Report a Security Flaw
|
||||
|
||||
Keeping Dockhand secure is a **top** priority. We highly value community contributions that help protect our users.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> If you discover a security vulnerability, please do not create a public GitHub issue - this can expose users to risk before a fix is available.
|
||||
> If you find a security vulnerability, we ask that you keep it private and avoid opening a public issue on GitHub.
|
||||
> Instead, please email us directly at [[security@dockhand.pro](mailto:security@dockhand.pro)]. This inbox has the highest priority.
|
||||
|
||||
## Details to Include
|
||||
|
||||
To help us track down and resolve the bug as efficiently as possible, please provide the following information in your email:
|
||||
- A clear explanation of the flaw
|
||||
- A step-by-step guide on how to reproduce the issue
|
||||
- The specific Dockhand versions and host environments where the bug is present
|
||||
- Any ideas you have for a patch or temporary workaround
|
||||
|
||||
|
||||
## Our take
|
||||
|
||||
Once you submit a report, we promise to:
|
||||
- Confirm receipt of your message within a couple of hours
|
||||
- Swiftly investigate and verify the vulnerability
|
||||
- Roll out a secure patch as quickly as possible
|
||||
- Keep you updated throughout the entire patching process
|
||||
|
||||
We deeply appreciate your commitment to responsible disclosure and your help in keeping the Dockhand ecosystem safe.
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
module github.com/Finsys/dockhand/collector
|
||||
|
||||
go 1.24
|
||||
go 1.25
|
||||
|
||||
+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,16 @@ 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)
|
||||
# Custom CA: set NODE_EXTRA_CA_CERTS=/path/to/ca.crt (appends to built-in CAs)
|
||||
# Enterprise (system CA store): set NODE_OPTIONS="--use-openssl-ca"
|
||||
if [ "$MEMORY_MONITOR" = "true" ]; then
|
||||
DEFAULT_CMD="node --expose-gc /app/server.js"
|
||||
DEFAULT_CMD="node --dns-result-order=ipv4first --no-network-family-autoselection --expose-gc /app/server.js"
|
||||
else
|
||||
DEFAULT_CMD="node /app/server.js"
|
||||
DEFAULT_CMD="node --dns-result-order=ipv4first --no-network-family-autoselection /app/server.js"
|
||||
fi
|
||||
|
||||
# === Detect if running as root ===
|
||||
@@ -97,14 +102,29 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
chown -R "$RUN_USER":"$RUN_USER" /app/data 2>/dev/null || true
|
||||
# === Directory Ownership ===
|
||||
# Only chown Dockhand's own subdirectories, not the entire /app/data tree.
|
||||
# Recursive chown on /app/data breaks stack volumes mounted with relative paths
|
||||
# (e.g. ./postgresql:/var/lib/postgresql) that need different ownership (#719).
|
||||
DATA_DIR="${DATA_DIR:-/app/data}"
|
||||
chown "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
|
||||
for subdir in db stacks git-repos tmp icons snapshots scanner-cache; do
|
||||
if [ -d "$DATA_DIR/$subdir" ]; then
|
||||
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR/$subdir" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
if [ "$RUN_USER" = "dockhand" ]; then
|
||||
chown -R dockhand:dockhand /home/dockhand 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ -n "$DATA_DIR" ] && [ "$DATA_DIR" != "/app/data" ] && [ "$DATA_DIR" != "./data" ]; then
|
||||
mkdir -p "$DATA_DIR"
|
||||
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
|
||||
chown "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
|
||||
for subdir in db stacks git-repos tmp icons snapshots scanner-cache; do
|
||||
if [ -d "$DATA_DIR/$subdir" ]; then
|
||||
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR/$subdir" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
+16
-2
@@ -113,14 +113,28 @@ else
|
||||
fi
|
||||
|
||||
# === Directory Ownership ===
|
||||
chown -R "$RUN_USER":"$RUN_USER" /app/data 2>/dev/null || true
|
||||
# Only chown Dockhand's own subdirectories, not the entire /app/data tree.
|
||||
# Recursive chown on /app/data breaks stack volumes mounted with relative paths
|
||||
# (e.g. ./postgresql:/var/lib/postgresql) that need different ownership (#719).
|
||||
DATA_DIR="${DATA_DIR:-/app/data}"
|
||||
chown "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
|
||||
for subdir in db stacks git-repos tmp icons snapshots scanner-cache; do
|
||||
if [ -d "$DATA_DIR/$subdir" ]; then
|
||||
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR/$subdir" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
if [ "$RUN_USER" = "dockhand" ]; then
|
||||
chown -R dockhand:dockhand /home/dockhand 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ -n "$DATA_DIR" ] && [ "$DATA_DIR" != "/app/data" ] && [ "$DATA_DIR" != "./data" ]; then
|
||||
mkdir -p "$DATA_DIR"
|
||||
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
|
||||
chown "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
|
||||
for subdir in db stacks git-repos tmp icons snapshots scanner-cache; do
|
||||
if [ -d "$DATA_DIR/$subdir" ]; then
|
||||
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR/$subdir" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
+4
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dockhand",
|
||||
"private": true,
|
||||
"version": "1.0.18",
|
||||
"version": "1.0.22",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npx vite dev",
|
||||
@@ -71,12 +71,13 @@
|
||||
"@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",
|
||||
"croner": "9.1.0",
|
||||
"cronstrue": "3.9.0",
|
||||
"devalue": "5.6.3",
|
||||
"devalue": "5.6.4",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"ldapts": "^8.1.3",
|
||||
@@ -86,6 +87,7 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"svelte-dnd-action": "0.9.69",
|
||||
"svelte-sonner": "1.0.7",
|
||||
"undici": "7.24.5",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* Production Server Wrapper
|
||||
*
|
||||
* Wraps @sveltejs/adapter-node's output with WebSocket support for:
|
||||
* - Terminal exec connections (xterm.js ↔ Docker exec)
|
||||
* - Hawser Edge agent connections
|
||||
*
|
||||
* Usage: node ./server.js
|
||||
*/
|
||||
|
||||
import { createServer, request as httpRequest } from 'node:http';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import { createConnection } from 'node:net';
|
||||
import { connect as tlsConnect, rootCertificates } from 'node:tls';
|
||||
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';
|
||||
|
||||
// Create HTTP server with SvelteKit handler
|
||||
const server = createServer((req, res) => {
|
||||
handler(req, res);
|
||||
});
|
||||
|
||||
// Create WebSocket server attached to the HTTP server
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
// Track connections
|
||||
const wsConnections = new Map();
|
||||
let wsConnectionCounter = 0;
|
||||
|
||||
// Track Edge exec sessions: execId -> { ws, environmentId }
|
||||
const edgeExecSessions = new Map();
|
||||
|
||||
// Register global send function for Hawser Edge WebSocket messages.
|
||||
// hawser.ts checks this first, and handleEdgeExec uses it for terminal relay.
|
||||
// Reads from __hawserEdgeConnections which is populated by hawser.ts.
|
||||
globalThis.__hawserSendMessage = (envId, message) => {
|
||||
const connections = globalThis.__hawserEdgeConnections;
|
||||
if (!connections) return false;
|
||||
const conn = connections.get(envId);
|
||||
if (!conn || !conn.ws) return false;
|
||||
try {
|
||||
conn.ws.send(message);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('[Hawser WS] sendMessage error:', e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Register global handler for exec messages from Hawser Edge agents
|
||||
// Called by hawser.ts when it receives exec_ready/exec_output/exec_end/error messages
|
||||
globalThis.__terminalHandleExecMessage = (msg) => {
|
||||
const execId = msg.execId || msg.requestId;
|
||||
if (!execId) return;
|
||||
|
||||
const session = edgeExecSessions.get(execId);
|
||||
if (!session || session.ws.readyState !== 1) return;
|
||||
|
||||
if (msg.type === 'exec_ready') {
|
||||
// Agent is ready, frontend is already waiting for output
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'exec_output') {
|
||||
const data = Buffer.from(msg.data, 'base64').toString('utf-8');
|
||||
session.ws.send(JSON.stringify({ type: 'output', data }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'exec_end') {
|
||||
session.ws.send(JSON.stringify({ type: 'exit' }));
|
||||
session.ws.close();
|
||||
edgeExecSessions.delete(execId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'error') {
|
||||
session.ws.send(JSON.stringify({ type: 'error', message: msg.error || msg.message }));
|
||||
session.ws.close();
|
||||
edgeExecSessions.delete(execId);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle WebSocket upgrade
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
||||
|
||||
// Only handle our specific WebSocket paths
|
||||
const isTerminal = url.pathname.includes('/api/containers/') && url.pathname.includes('/exec');
|
||||
const isHawser = url.pathname === '/api/hawser/connect';
|
||||
|
||||
if (!isTerminal && !isHawser) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, req);
|
||||
});
|
||||
});
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
||||
const connId = `ws-${++wsConnectionCounter}`;
|
||||
const remoteIp = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
|
||||
|| req.socket.remoteAddress
|
||||
|| 'unknown';
|
||||
|
||||
if (url.pathname === '/api/hawser/connect') {
|
||||
handleHawserConnection(ws, connId, remoteIp);
|
||||
} else {
|
||||
handleTerminalConnection(ws, url, connId);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle terminal exec WebSocket connections.
|
||||
* Supports all connection types: socket, direct TCP/TLS, hawser-standard, hawser-edge.
|
||||
*
|
||||
* Uses globalThis functions exposed by the SvelteKit app (docker.ts):
|
||||
* - __terminalGetTarget(envId) - resolves connection info from environment
|
||||
* - __terminalCreateExec(containerId, shell, user, envId) - creates exec via Docker API
|
||||
* - __terminalResizeExec(execId, cols, rows, envId) - resizes exec terminal
|
||||
*/
|
||||
async function handleTerminalConnection(ws, url, connId) {
|
||||
const pathParts = url.pathname.split('/');
|
||||
const containerIdIndex = pathParts.indexOf('containers') + 1;
|
||||
const containerId = pathParts[containerIdIndex];
|
||||
const shell = url.searchParams.get('shell') || '/bin/sh';
|
||||
const user = url.searchParams.get('user') || 'root';
|
||||
const envIdParam = url.searchParams.get('envId');
|
||||
const envId = envIdParam ? parseInt(envIdParam, 10) : undefined;
|
||||
|
||||
if (!containerId) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'No container ID' }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve Docker target via SvelteKit app's database
|
||||
let target;
|
||||
if (typeof globalThis.__terminalGetTarget === 'function') {
|
||||
target = await globalThis.__terminalGetTarget(envId);
|
||||
} else {
|
||||
// Fallback: local socket only (SvelteKit not yet loaded)
|
||||
target = { type: 'socket', connectionType: 'socket', socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock' };
|
||||
}
|
||||
|
||||
// Handle Hawser Edge mode - relay through agent WebSocket
|
||||
if (target.connectionType === 'hawser-edge') {
|
||||
handleEdgeExec(ws, connId, containerId, shell, user, target.environmentId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create exec instance via SvelteKit app (handles all connection types)
|
||||
let execId;
|
||||
if (typeof globalThis.__terminalCreateExec === 'function') {
|
||||
execId = await globalThis.__terminalCreateExec(containerId, shell, user, envId);
|
||||
} else {
|
||||
// Fallback: create exec directly via local socket
|
||||
execId = await createExecLocal(containerId, shell, user, target.socketPath || '/var/run/docker.sock');
|
||||
}
|
||||
|
||||
// Open raw bidirectional stream to Docker for the exec session
|
||||
const startBody = JSON.stringify({ Detach: false, Tty: true });
|
||||
let dockerStream;
|
||||
|
||||
if (target.type === 'socket') {
|
||||
const socketPath = target.socketPath || '/var/run/docker.sock';
|
||||
dockerStream = createConnection({ path: socketPath });
|
||||
} else if (target.type === 'https' && target.tls) {
|
||||
const tlsOpts = {
|
||||
host: target.host,
|
||||
port: target.port,
|
||||
servername: target.host,
|
||||
rejectUnauthorized: target.tls.rejectUnauthorized ?? true
|
||||
};
|
||||
if (target.tls.ca) tlsOpts.ca = [target.tls.ca, ...rootCertificates];
|
||||
if (target.tls.cert) tlsOpts.cert = [target.tls.cert];
|
||||
if (target.tls.key) tlsOpts.key = target.tls.key;
|
||||
dockerStream = tlsConnect(tlsOpts);
|
||||
} else {
|
||||
// Plain HTTP (direct TCP or hawser-standard)
|
||||
dockerStream = createConnection({ host: target.host, port: target.port });
|
||||
}
|
||||
|
||||
dockerStream.on('connect', () => {
|
||||
const host = target.host || 'localhost';
|
||||
const tokenHeader = target.hawserToken ? `X-Hawser-Token: ${target.hawserToken}\r\n` : '';
|
||||
dockerStream.write(
|
||||
`POST /exec/${execId}/start HTTP/1.1\r\n` +
|
||||
`Host: ${host}\r\n` +
|
||||
`Content-Type: application/json\r\n` +
|
||||
`${tokenHeader}` +
|
||||
`Connection: Upgrade\r\n` +
|
||||
`Upgrade: tcp\r\n` +
|
||||
`Content-Length: ${Buffer.byteLength(startBody)}\r\n` +
|
||||
`\r\n` +
|
||||
startBody
|
||||
);
|
||||
});
|
||||
|
||||
let headersStripped = false;
|
||||
let isChunked = false;
|
||||
|
||||
dockerStream.on('data', (data) => {
|
||||
if (ws.readyState !== 1) return;
|
||||
|
||||
let text = data.toString('utf-8');
|
||||
if (!headersStripped) {
|
||||
if (text.toLowerCase().includes('transfer-encoding: chunked')) {
|
||||
isChunked = true;
|
||||
}
|
||||
const headerEnd = text.indexOf('\r\n\r\n');
|
||||
if (headerEnd > -1) {
|
||||
text = text.slice(headerEnd + 4);
|
||||
headersStripped = true;
|
||||
} else if (text.startsWith('HTTP/')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isChunked && text) {
|
||||
text = text.replace(/^[0-9a-fA-F]+\r\n/gm, '').replace(/\r\n$/g, '');
|
||||
}
|
||||
if (text) {
|
||||
ws.send(JSON.stringify({ type: 'output', data: text }));
|
||||
}
|
||||
});
|
||||
|
||||
dockerStream.on('close', () => {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(JSON.stringify({ type: 'exit' }));
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
dockerStream.on('error', (err) => {
|
||||
console.error('[Terminal WS] Socket error:', err.message);
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: err.message }));
|
||||
}
|
||||
});
|
||||
|
||||
// Forward terminal input from browser to Docker
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type === 'input' && msg.data) {
|
||||
dockerStream.write(msg.data);
|
||||
} else if (msg.type === 'resize' && msg.cols && msg.rows) {
|
||||
// Use SvelteKit's resize function if available (works for all connection types)
|
||||
if (typeof globalThis.__terminalResizeExec === 'function') {
|
||||
globalThis.__terminalResizeExec(execId, msg.cols, msg.rows, envId).catch(() => {});
|
||||
} else {
|
||||
// Fallback: resize via local socket
|
||||
const socketPath = target.socketPath || '/var/run/docker.sock';
|
||||
const resizeReq = httpRequest({
|
||||
socketPath,
|
||||
path: `/exec/${execId}/resize?h=${msg.rows}&w=${msg.cols}`,
|
||||
method: 'POST',
|
||||
}, () => {});
|
||||
resizeReq.on('error', () => {});
|
||||
resizeReq.end();
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
dockerStream.destroy();
|
||||
});
|
||||
|
||||
wsConnections.set(connId, { stream: dockerStream, ws });
|
||||
} catch (err) {
|
||||
console.error('[Terminal WS] Error:', err.message);
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: err.message }));
|
||||
ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
ws.on('close', () => {
|
||||
wsConnections.delete(connId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Hawser Edge exec session.
|
||||
* Sends exec commands through the Hawser WebSocket relay.
|
||||
*/
|
||||
function handleEdgeExec(ws, connId, containerId, shell, user, environmentId) {
|
||||
if (typeof globalThis.__hawserSendMessage !== 'function') {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Edge agent handler not ready' }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const execId = randomUUID();
|
||||
edgeExecSessions.set(execId, { ws, execId, environmentId });
|
||||
|
||||
// Send exec_start to the Hawser agent
|
||||
const execStartMsg = JSON.stringify({
|
||||
type: 'exec_start',
|
||||
execId,
|
||||
containerId,
|
||||
cmd: shell,
|
||||
user,
|
||||
cols: 120,
|
||||
rows: 30
|
||||
});
|
||||
|
||||
const sent = globalThis.__hawserSendMessage(environmentId, execStartMsg);
|
||||
if (!sent) {
|
||||
edgeExecSessions.delete(execId);
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Edge agent not connected' }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward terminal input/resize from browser to agent
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type === 'input' && msg.data) {
|
||||
const inputMsg = JSON.stringify({
|
||||
type: 'exec_input',
|
||||
execId,
|
||||
data: Buffer.from(msg.data).toString('base64')
|
||||
});
|
||||
globalThis.__hawserSendMessage(environmentId, inputMsg);
|
||||
} else if (msg.type === 'resize' && msg.cols && msg.rows) {
|
||||
const resizeMsg = JSON.stringify({
|
||||
type: 'exec_resize',
|
||||
execId,
|
||||
cols: msg.cols,
|
||||
rows: msg.rows
|
||||
});
|
||||
globalThis.__hawserSendMessage(environmentId, resizeMsg);
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
// Notify agent that exec session ended
|
||||
if (typeof globalThis.__hawserSendMessage === 'function') {
|
||||
const endMsg = JSON.stringify({
|
||||
type: 'exec_end',
|
||||
execId,
|
||||
reason: 'user_closed'
|
||||
});
|
||||
globalThis.__hawserSendMessage(environmentId, endMsg);
|
||||
}
|
||||
edgeExecSessions.delete(execId);
|
||||
wsConnections.delete(connId);
|
||||
});
|
||||
|
||||
wsConnections.set(connId, { ws });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: Create exec via local Docker socket (used before SvelteKit app is loaded)
|
||||
*/
|
||||
function createExecLocal(containerId, shell, user, socketPath) {
|
||||
const createBody = JSON.stringify({
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
Tty: true,
|
||||
Cmd: [shell],
|
||||
User: user
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = httpRequest({
|
||||
socketPath,
|
||||
path: `/containers/${containerId}/exec`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(createBody),
|
||||
},
|
||||
}, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString());
|
||||
if (res.statusCode === 201 && body.Id) {
|
||||
resolve(body.Id);
|
||||
} else {
|
||||
reject(new Error(body.message || `Exec create failed: ${res.statusCode}`));
|
||||
}
|
||||
} catch (e) {
|
||||
reject(new Error('Failed to parse exec response'));
|
||||
}
|
||||
});
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(createBody);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Hawser Edge WebSocket connections.
|
||||
* The full Hawser protocol is handled by the SvelteKit app
|
||||
* via the global hawser connection manager.
|
||||
*/
|
||||
function handleHawserConnection(ws, connId, remoteIp) {
|
||||
console.log('[Hawser WS] New connection pending authentication');
|
||||
|
||||
ws.on('message', async (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
|
||||
// Use the global hawser message handler injected by the SvelteKit app
|
||||
if (typeof globalThis.__hawserHandleMessage === 'function') {
|
||||
await globalThis.__hawserHandleMessage(ws, msg, connId, remoteIp);
|
||||
} else {
|
||||
console.warn('[Hawser WS] No global handler registered');
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Server not ready' }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Hawser WS] Message parse error:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
if (typeof globalThis.__hawserHandleDisconnect === 'function') {
|
||||
globalThis.__hawserHandleDisconnect(ws, connId);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('[Hawser WS] Connection error:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
// Start the server
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`Listening on http://${HOST}:${PORT}/ with WebSocket`);
|
||||
});
|
||||
+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';
|
||||
|
||||
@@ -8,9 +8,26 @@
|
||||
imageUrl: string;
|
||||
onCancel: () => void;
|
||||
onSave: (dataUrl: string) => void;
|
||||
cropShape?: 'round' | 'rect';
|
||||
outputSize?: number;
|
||||
outputFormat?: 'image/jpeg' | 'image/webp';
|
||||
outputQuality?: number;
|
||||
title?: string;
|
||||
saveLabel?: string;
|
||||
}
|
||||
|
||||
let { show, imageUrl, onCancel, onSave }: Props = $props();
|
||||
let {
|
||||
show,
|
||||
imageUrl,
|
||||
onCancel,
|
||||
onSave,
|
||||
cropShape = 'round',
|
||||
outputSize = 256,
|
||||
outputFormat = 'image/jpeg',
|
||||
outputQuality = 0.9,
|
||||
title = 'Crop avatar',
|
||||
saveLabel = 'Save avatar'
|
||||
}: Props = $props();
|
||||
|
||||
// Cropper state
|
||||
let crop = $state({ x: 0, y: 0 });
|
||||
@@ -144,9 +161,9 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Set canvas size to output size (256x256 for avatar)
|
||||
canvas.width = 256;
|
||||
canvas.height = 256;
|
||||
// Set canvas size to output size
|
||||
canvas.width = outputSize;
|
||||
canvas.height = outputSize;
|
||||
|
||||
// Ensure we use a square crop area to avoid stretching
|
||||
// Center the square within the original crop area
|
||||
@@ -163,12 +180,12 @@
|
||||
size,
|
||||
0,
|
||||
0,
|
||||
256,
|
||||
256
|
||||
outputSize,
|
||||
outputSize
|
||||
);
|
||||
|
||||
// Convert to data URL
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
|
||||
const dataUrl = canvas.toDataURL(outputFormat, outputQuality);
|
||||
resolve(dataUrl);
|
||||
};
|
||||
|
||||
@@ -204,16 +221,18 @@
|
||||
handleCancel();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if show && imageUrl}
|
||||
<div class="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4">
|
||||
<div class="fixed inset-0 bg-black/80 z-[200] flex items-center justify-center p-4">
|
||||
<div class="bg-background rounded-lg w-full max-w-2xl max-h-[90vh] flex flex-col shadow-2xl">
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b">
|
||||
<h3 class="text-lg font-semibold">Crop avatar</h3>
|
||||
<h3 class="text-lg font-semibold">{title}</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Drag to reposition. Use the slider to zoom.
|
||||
</p>
|
||||
@@ -226,7 +245,8 @@
|
||||
bind:crop
|
||||
bind:zoom
|
||||
aspect={1}
|
||||
cropShape="round"
|
||||
minZoom={0.5}
|
||||
cropShape={cropShape}
|
||||
showGrid={false}
|
||||
on:cropcomplete={onCropComplete}
|
||||
on:mediaLoaded={onMediaLoaded}
|
||||
@@ -239,7 +259,7 @@
|
||||
<ZoomOut class="w-5 h-5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
min="0.5"
|
||||
max="3"
|
||||
step="0.1"
|
||||
bind:value={zoom}
|
||||
@@ -266,7 +286,7 @@
|
||||
disabled={saving || !imageLoaded}
|
||||
>
|
||||
<Check class="w-4 h-4" />
|
||||
{saving ? 'Uploading...' : !imageLoaded ? 'Loading...' : 'Save avatar'}
|
||||
{saving ? 'Uploading...' : !imageLoaded ? 'Loading...' : saveLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { getIconComponent, isCustomIcon } from '$lib/utils/icons';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
icon: string;
|
||||
envId: number;
|
||||
class?: string;
|
||||
cacheBust?: string | number;
|
||||
}
|
||||
|
||||
let { icon, envId, class: className = 'w-4 h-4', cacheBust }: Props = $props();
|
||||
|
||||
const isCustom = $derived(isCustomIcon(icon));
|
||||
const LucideIcon = $derived(!isCustom ? getIconComponent(icon) : null) as Component | null;
|
||||
const imgSrc = $derived(isCustom ? `/api/environments/${envId}/icon${cacheBust ? `?v=${cacheBust}` : ''}` : '');
|
||||
</script>
|
||||
|
||||
{#if isCustom}
|
||||
<img src={imgSrc} alt="" class="{className} rounded-full object-cover" />
|
||||
{:else if LucideIcon}
|
||||
<LucideIcon class={className} />
|
||||
{/if}
|
||||
@@ -43,15 +43,17 @@
|
||||
let selectedEditorFont = $state('system-mono');
|
||||
|
||||
onMount(async () => {
|
||||
// Load monospace fonts for dropdown previews
|
||||
// Load bundled monospace fonts for dropdown previews
|
||||
const fontsToLoad = monospaceFonts.filter(f => f.googleFont);
|
||||
if (fontsToLoad.length > 0) {
|
||||
const families = fontsToLoad.map(f => `family=${f.googleFont}`).join('&');
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = `https://fonts.googleapis.com/css2?${families}&display=swap`;
|
||||
link.onload = () => { monoFontsLoaded = true; };
|
||||
document.head.appendChild(link);
|
||||
let loaded = 0;
|
||||
for (const font of fontsToLoad) {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = `/fonts/${font.id}/font.css`;
|
||||
link.onload = () => { if (++loaded >= fontsToLoad.length) monoFontsLoaded = true; };
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
} else {
|
||||
monoFontsLoaded = true;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,22 @@
|
||||
'Europe/Kyiv': 'Europe/Kiev',
|
||||
'Asia/Ho_Chi_Minh': 'Asia/Saigon',
|
||||
'America/Nuuk': 'America/Godthab',
|
||||
'Pacific/Kanton': 'Pacific/Enderbury'
|
||||
'Pacific/Kanton': 'Pacific/Enderbury',
|
||||
'Asia/Kolkata': 'Asia/Calcutta',
|
||||
'Asia/Kathmandu': 'Asia/Katmandu',
|
||||
'Asia/Yangon': 'Asia/Rangoon',
|
||||
'Asia/Kashgar': 'Asia/Urumqi',
|
||||
'Atlantic/Faroe': 'Atlantic/Faeroe',
|
||||
'Europe/Uzhgorod': 'Europe/Kiev',
|
||||
'Europe/Zaporozhye': 'Europe/Kiev',
|
||||
'America/Atikokan': 'America/Coral_Harbour',
|
||||
'America/Argentina/Buenos_Aires': 'America/Buenos_Aires',
|
||||
'America/Argentina/Catamarca': 'America/Catamarca',
|
||||
'America/Argentina/Cordoba': 'America/Cordoba',
|
||||
'America/Argentina/Jujuy': 'America/Jujuy',
|
||||
'America/Argentina/Mendoza': 'America/Mendoza',
|
||||
'Pacific/Pohnpei': 'Pacific/Ponape',
|
||||
'Pacific/Chuuk': 'Pacific/Truk'
|
||||
};
|
||||
|
||||
// Reverse map: canonical → modern alias names (for display hints)
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Cpu, MemoryStick, Box, Globe, ChevronDown, Check, HardDrive, Clock, Wifi, WifiOff, Route, UndoDot, Icon, AlertCircle, Loader2 } from 'lucide-svelte';
|
||||
import { Cpu, MemoryStick, Box, Globe, ChevronDown, Check, HardDrive, Clock, Wifi, WifiOff, Route, UndoDot, Icon, AlertCircle, Loader2, Search, X } from 'lucide-svelte';
|
||||
import { whale } from '@lucide/lab';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { currentEnvironment, environments, type Environment } from '$lib/stores/environment';
|
||||
import { sseConnected } from '$lib/stores/events';
|
||||
import { getIconComponent } from '$lib/utils/icons';
|
||||
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
||||
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');
|
||||
@@ -77,6 +77,8 @@
|
||||
let diskUsageLoading = $state(false);
|
||||
let envAbortController: AbortController | null = null; // Aborts ALL requests when switching envs
|
||||
let showDropdown = $state(false);
|
||||
let searchTerm = $state('');
|
||||
let searchInputRef = $state<HTMLInputElement | null>(null);
|
||||
let currentEnvId = $state<number | null>(null);
|
||||
let lastUpdated = $state<Date>(new Date());
|
||||
let isConnected = $state(false);
|
||||
@@ -94,6 +96,22 @@
|
||||
|
||||
// Reactive environment list from store
|
||||
let envList = $derived($environments);
|
||||
const showSearch = $derived(envList.length > 8);
|
||||
const filteredEnvList = $derived(
|
||||
searchTerm.trim()
|
||||
? envList.filter((e: Environment) => e.name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
: envList
|
||||
);
|
||||
|
||||
// Clear search and focus when dropdown opens/closes
|
||||
$effect(() => {
|
||||
if (showDropdown && showSearch) {
|
||||
// Use tick to wait for DOM render
|
||||
setTimeout(() => searchInputRef?.focus(), 0);
|
||||
} else {
|
||||
searchTerm = '';
|
||||
}
|
||||
});
|
||||
|
||||
sseConnected.subscribe(v => isConnected = v);
|
||||
|
||||
@@ -305,6 +323,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')) {
|
||||
@@ -335,14 +367,12 @@
|
||||
class="flex items-center gap-1.5 -ml-1 px-1 py-1 rounded-md hover:bg-muted transition-colors cursor-pointer"
|
||||
>
|
||||
{#if hostInfo?.environment && Number(hostInfo.environment.id) === Number(currentEnvId)}
|
||||
{@const EnvIcon = getIconComponent(hostInfo.environment.icon || 'globe')}
|
||||
<EnvIcon class="{iconSizeLargeClass()} text-primary" />
|
||||
<EnvironmentIcon icon={hostInfo.environment.icon || 'globe'} envId={hostInfo.environment.id} class="{iconSizeLargeClass()} text-primary" />
|
||||
<span class="font-medium text-foreground">{hostInfo.environment.name}</span>
|
||||
{:else if currentEnvId && envList.length > 0}
|
||||
{@const currentEnv = envList.find(e => Number(e.id) === Number(currentEnvId))}
|
||||
{#if currentEnv}
|
||||
{@const EnvIcon = getIconComponent(currentEnv.icon || 'globe')}
|
||||
<EnvIcon class="{iconSizeLargeClass()} text-primary" />
|
||||
<EnvironmentIcon icon={currentEnv.icon || 'globe'} envId={currentEnv.id} class="{iconSizeLargeClass()} text-primary" />
|
||||
<span class="font-medium text-foreground">{currentEnv.name}</span>
|
||||
{:else}
|
||||
<Globe class="{iconSizeLargeClass()} text-muted-foreground" />
|
||||
@@ -357,9 +387,40 @@
|
||||
|
||||
{#if showDropdown && envList.length > 0}
|
||||
<div class="absolute top-full left-0 mt-1 min-w-56 w-max max-w-80 bg-popover border rounded-md shadow-lg z-50">
|
||||
<div class="py-1">
|
||||
{#each envList as env (env.id)}
|
||||
{@const EnvIcon = getIconComponent(env.icon || 'globe')}
|
||||
{#if showSearch}
|
||||
<div class="sticky top-0 bg-popover border-b px-2 py-1.5">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<input
|
||||
bind:this={searchInputRef}
|
||||
bind:value={searchTerm}
|
||||
type="text"
|
||||
placeholder="Search environments..."
|
||||
class="w-full pl-7 pr-7 py-1 text-sm bg-transparent border rounded focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (searchTerm) {
|
||||
searchTerm = '';
|
||||
} else {
|
||||
showDropdown = false;
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if searchTerm}
|
||||
<button
|
||||
class="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-muted"
|
||||
onclick={(e) => { e.stopPropagation(); searchTerm = ''; searchInputRef?.focus(); }}
|
||||
>
|
||||
<X class="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="py-1 max-h-[calc(100vh-8rem)] overflow-y-auto">
|
||||
{#each filteredEnvList as env (env.id)}
|
||||
{@const isOffline = offlineEnvIds.has(env.id)}
|
||||
{@const isSwitching = switchingEnvId === env.id}
|
||||
<button
|
||||
@@ -373,7 +434,7 @@
|
||||
{:else if isOffline}
|
||||
<WifiOff class="{iconSizeLargeClass()} text-destructive shrink-0" />
|
||||
{:else}
|
||||
<EnvIcon class="{iconSizeLargeClass()} text-muted-foreground shrink-0" />
|
||||
<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="{iconSizeLargeClass()} text-muted-foreground shrink-0" />
|
||||
{/if}
|
||||
<span class="flex-1 whitespace-nowrap" class:text-muted-foreground={isOffline}>{env.name}</span>
|
||||
{#if isOffline && !isSwitching}
|
||||
@@ -382,6 +443,10 @@
|
||||
<Check class="{iconSizeLargeClass()} text-primary shrink-0" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<div class="px-3 py-2 text-sm text-muted-foreground">
|
||||
No matching environments
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -452,7 +517,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>
|
||||
|
||||
@@ -118,6 +118,23 @@ export const scheduleColumns: ColumnConfig[] = [
|
||||
{ id: 'actions', label: '', fixed: 'end', width: 100, resizable: false }
|
||||
];
|
||||
|
||||
// Environment grid columns (dashboard list view)
|
||||
export const environmentColumns: ColumnConfig[] = [
|
||||
{ id: 'status', label: '', width: 36, resizable: false },
|
||||
{ id: 'name', label: 'Environment', sortable: true, sortField: 'name', width: 180, minWidth: 100, grow: true },
|
||||
{ id: 'connection', label: 'Connection', sortable: true, sortField: 'connection', width: 110, minWidth: 80 },
|
||||
{ id: 'host', label: 'Host', sortable: true, sortField: 'host', width: 150, minWidth: 80 },
|
||||
{ id: 'containers', label: 'Containers', sortable: true, sortField: 'containers', width: 100, minWidth: 70 },
|
||||
{ id: 'updates', label: 'Updates', sortable: true, sortField: 'updates', width: 75, minWidth: 55 },
|
||||
{ id: 'cpu', label: 'CPU', sortable: true, sortField: 'cpu', width: 110, minWidth: 80 },
|
||||
{ id: 'memory', label: 'Memory', sortable: true, sortField: 'memory', width: 110, minWidth: 80 },
|
||||
{ id: 'images', label: 'Images', sortable: true, sortField: 'images', width: 65, minWidth: 50 },
|
||||
{ id: 'volumes', label: 'Volumes', sortable: true, sortField: 'volumes', width: 70, minWidth: 50 },
|
||||
{ id: 'stacks', label: 'Stacks', sortable: true, sortField: 'stacks', width: 85, minWidth: 65 },
|
||||
{ id: 'events', label: 'Events', sortable: true, sortField: 'events', width: 65, minWidth: 50 },
|
||||
{ id: 'labels', label: 'Labels', width: 150, minWidth: 80 }
|
||||
];
|
||||
|
||||
// Map of grid ID to column definitions
|
||||
export const gridColumnConfigs: Record<GridId, ColumnConfig[]> = {
|
||||
containers: containerColumns,
|
||||
@@ -128,7 +145,8 @@ export const gridColumnConfigs: Record<GridId, ColumnConfig[]> = {
|
||||
volumes: volumeColumns,
|
||||
activity: activityColumns,
|
||||
schedules: scheduleColumns,
|
||||
audit: auditColumns
|
||||
audit: auditColumns,
|
||||
environments: environmentColumns
|
||||
};
|
||||
|
||||
// Get configurable columns (not fixed)
|
||||
|
||||
@@ -1,4 +1,45 @@
|
||||
[
|
||||
{
|
||||
"version": "1.0.22",
|
||||
"comingSoon": true,
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "dashboard list view with inline search and connection filters (#740)" },
|
||||
{ "type": "feature", "text": "custom environment icon (#754)" },
|
||||
{ "type": "feature", "text": "show +N indicator for containers with multiple IP addresses (#644)" },
|
||||
{ "type": "feature", "text": "bundle all fonts locally for privacy and offline use (#734)" },
|
||||
{ "type": "fix", "text": "respect PROXY settings when checking for container updates" },
|
||||
{ "type": "fix", "text": "git stacks force-redeploy after a failed sync (#693)" },
|
||||
{ "type": "fix", "text": "What's New modal shown before login, exposing version info (#717)" },
|
||||
{ "type": "fix", "text": "git repository files not removed from disk on delete (#671)" },
|
||||
{ "type": "fix", "text": "recursive chown at startup breaks stack volumes with different ownership (#719)" },
|
||||
{ "type": "fix", "text": "missing notification event toggles for container healthy, image prune events (#659)" },
|
||||
{ "type": "fix", "text": "container disappears when edit fails (e.g. invalid memory/swap) (#736)" },
|
||||
{ "type": "fix", "text": "regression: network container count always shows 0 (#761)" },
|
||||
{ "type": "fix", "text": "Grype/Trivy scan containers don't inherit proxy env vars (#780)" },
|
||||
{ "type": "fix", "text": "pin vulnerability scanner images to specific versions not :latest" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.22"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
|
||||
@@ -275,6 +275,12 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/chalk/ansi-styles"
|
||||
},
|
||||
{
|
||||
"name": "ansi_up",
|
||||
"version": "6.0.6",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/drudru/ansi_up"
|
||||
},
|
||||
{
|
||||
"name": "argon2",
|
||||
"version": "0.41.1",
|
||||
@@ -289,7 +295,7 @@
|
||||
},
|
||||
{
|
||||
"name": "aria-query",
|
||||
"version": "5.3.2",
|
||||
"version": "5.3.1",
|
||||
"license": "Apache-2.0",
|
||||
"repository": "https://github.com/A11yance/aria-query"
|
||||
},
|
||||
@@ -425,6 +431,12 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sveltejs/devalue"
|
||||
},
|
||||
{
|
||||
"name": "devalue",
|
||||
"version": "5.6.4",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sveltejs/devalue"
|
||||
},
|
||||
{
|
||||
"name": "dijkstrajs",
|
||||
"version": "1.0.3",
|
||||
@@ -781,7 +793,7 @@
|
||||
},
|
||||
{
|
||||
"name": "svelte",
|
||||
"version": "5.53.1",
|
||||
"version": "5.53.5",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sveltejs/svelte"
|
||||
},
|
||||
|
||||
+19
-4
@@ -819,6 +819,13 @@ export const ENVIRONMENT_NOTIFICATION_EVENTS = NOTIFICATION_EVENT_TYPES.filter(e
|
||||
|
||||
export type NotificationEventType = typeof NOTIFICATION_EVENT_TYPES[number]['id'];
|
||||
|
||||
const environmentEventIds = new Set(ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id));
|
||||
|
||||
/** Strip system-scoped events (e.g. license_expiring) from environment notification records */
|
||||
function filterEnvironmentEventTypes(eventTypes: string[]): string[] {
|
||||
return eventTypes.filter(id => environmentEventIds.has(id));
|
||||
}
|
||||
|
||||
export interface NotificationSettingData {
|
||||
id: number;
|
||||
type: 'smtp' | 'apprise';
|
||||
@@ -982,7 +989,7 @@ export async function getEnvironmentNotifications(environmentId: number): Promis
|
||||
|
||||
return rows.map((row: any) => ({
|
||||
...row,
|
||||
eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id)
|
||||
eventTypes: filterEnvironmentEventTypes(row.eventTypes ? JSON.parse(row.eventTypes) : ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id))
|
||||
})) as EnvironmentNotificationData[];
|
||||
}
|
||||
|
||||
@@ -1009,7 +1016,7 @@ export async function getEnvironmentNotification(environmentId: number, notifica
|
||||
if (!rows[0]) return null;
|
||||
return {
|
||||
...rows[0],
|
||||
eventTypes: rows[0].eventTypes ? JSON.parse(rows[0].eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id)
|
||||
eventTypes: filterEnvironmentEventTypes(rows[0].eventTypes ? JSON.parse(rows[0].eventTypes) : ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id))
|
||||
} as EnvironmentNotificationData;
|
||||
}
|
||||
|
||||
@@ -1019,7 +1026,7 @@ export async function createEnvironmentNotification(data: {
|
||||
enabled?: boolean;
|
||||
eventTypes?: NotificationEventType[];
|
||||
}): Promise<EnvironmentNotificationData> {
|
||||
const eventTypes = data.eventTypes || NOTIFICATION_EVENT_TYPES.map(e => e.id);
|
||||
const eventTypes = data.eventTypes || ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id);
|
||||
await db.insert(environmentNotifications).values({
|
||||
environmentId: data.environmentId,
|
||||
notificationId: data.notificationId,
|
||||
@@ -1087,7 +1094,7 @@ export async function getEnabledEnvironmentNotifications(
|
||||
return rows
|
||||
.map(row => ({
|
||||
...row,
|
||||
eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id),
|
||||
eventTypes: filterEnvironmentEventTypes(row.eventTypes ? JSON.parse(row.eventTypes) : ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id)),
|
||||
config: decryptNotificationConfig(row.channelType ?? 'apprise', row.config)
|
||||
}))
|
||||
.filter(row => !eventType || row.eventTypes.includes(eventType)) as (EnvironmentNotificationData & { config: any })[];
|
||||
@@ -2019,6 +2026,14 @@ export async function updateGitRepository(id: number, data: Partial<GitRepositor
|
||||
return getGitRepository(id);
|
||||
}
|
||||
|
||||
export async function getGitStacksByRepositoryId(repositoryId: number): Promise<Array<{ id: number; stackName: string; environmentId: number | null }>> {
|
||||
return db.select({
|
||||
id: gitStacks.id,
|
||||
stackName: gitStacks.stackName,
|
||||
environmentId: gitStacks.environmentId
|
||||
}).from(gitStacks).where(eq(gitStacks.repositoryId, repositoryId));
|
||||
}
|
||||
|
||||
export async function deleteGitRepository(id: number): Promise<boolean> {
|
||||
await db.delete(gitRepositories).where(eq(gitRepositories.id, id));
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { setGlobalDispatcher, Agent, EnvHttpProxyAgent } 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;
|
||||
}
|
||||
|
||||
// Shared connect options for DNS lookup
|
||||
const connectOptions = {
|
||||
// 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));
|
||||
}
|
||||
};
|
||||
|
||||
// Use EnvHttpProxyAgent when HTTP(S)_PROXY env vars are set, otherwise plain Agent.
|
||||
// Node.js fetch/undici does NOT respect proxy env vars by default — EnvHttpProxyAgent
|
||||
// reads HTTP_PROXY, HTTPS_PROXY, and NO_PROXY automatically.
|
||||
const hasProxy = process.env.HTTP_PROXY || process.env.HTTPS_PROXY ||
|
||||
process.env.http_proxy || process.env.https_proxy;
|
||||
|
||||
if (hasProxy) {
|
||||
const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy ||
|
||||
process.env.HTTP_PROXY || process.env.http_proxy;
|
||||
console.log(`[DNS] HTTP proxy detected (${proxyUrl}), using EnvHttpProxyAgent`);
|
||||
setGlobalDispatcher(new EnvHttpProxyAgent({ connect: connectOptions }));
|
||||
} else {
|
||||
setGlobalDispatcher(new Agent({ connect: connectOptions }));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
|
||||
/**
|
||||
* Checks if a value contains path traversal or injection characters.
|
||||
* Rejects: .., /, \, null bytes, % (catches double-encoding).
|
||||
*/
|
||||
function containsPathTraversal(value: string): boolean {
|
||||
return value.includes('..') || value.includes('/') || value.includes('\\') || value.includes('\0') || value.includes('%');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a Docker resource ID/name from URL params.
|
||||
* Returns a 400 Response if invalid, null if valid.
|
||||
*/
|
||||
export function validateDockerIdParam(id: string, resourceType = 'resource'): Response | null {
|
||||
if (!id || containsPathTraversal(id)) {
|
||||
return json({ error: `Invalid ${resourceType} ID` }, { status: 400 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+208
-50
@@ -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;
|
||||
@@ -813,6 +819,11 @@ export async function dockerFetch(
|
||||
options: DockerFetchOptions = {},
|
||||
envId?: number | null
|
||||
): Promise<Response> {
|
||||
// Guard against path traversal — legitimate Docker API paths never contain '..'
|
||||
if (path.includes('..')) {
|
||||
throw new Error('Invalid Docker API path');
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const config = await getDockerConfig(envId);
|
||||
const { streaming, ...fetchOptions } = options;
|
||||
@@ -874,7 +885,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)
|
||||
@@ -970,7 +982,7 @@ export async function dockerFetch(
|
||||
/**
|
||||
* Make a JSON request to Docker API
|
||||
*/
|
||||
async function dockerJsonRequest<T>(
|
||||
export async function dockerJsonRequest<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
envId?: number | null
|
||||
@@ -1233,7 +1245,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 +1298,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 +1334,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 +1448,7 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
||||
};
|
||||
}
|
||||
|
||||
if (options.privileged) {
|
||||
if (options.privileged !== undefined) {
|
||||
containerConfig.HostConfig.Privileged = options.privileged;
|
||||
}
|
||||
|
||||
@@ -1614,8 +1626,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 +1794,47 @@ export async function recreateContainerFromInspect(
|
||||
HostConfig: hostConfig
|
||||
};
|
||||
|
||||
// 4a. Update image-embedded labels to match the new image.
|
||||
// Docker's create API uses exactly the labels you pass, ignoring the new image's
|
||||
// embedded labels. We inspect both old and new images to distinguish image-origin
|
||||
// labels from user-set labels, then merge accordingly.
|
||||
try {
|
||||
const [oldImageInspect, newImageInspect] = await Promise.all([
|
||||
inspectImage(config.Image, envId),
|
||||
inspectImage(newImage, envId)
|
||||
]);
|
||||
const oldImageLabels: Record<string, string> = (oldImageInspect as any)?.Config?.Labels || {};
|
||||
const newImageLabels: Record<string, string> = (newImageInspect as any)?.Config?.Labels || {};
|
||||
const containerLabels: Record<string, string> = createConfig.Labels || {};
|
||||
|
||||
const mergedLabels: Record<string, string> = {};
|
||||
|
||||
// Keep user-set labels (not present in old image)
|
||||
for (const [k, v] of Object.entries(containerLabels)) {
|
||||
if (!(k in oldImageLabels)) {
|
||||
mergedLabels[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
// Add all new image labels (overrides old image labels)
|
||||
for (const [k, v] of Object.entries(newImageLabels)) {
|
||||
mergedLabels[k] = v;
|
||||
}
|
||||
|
||||
createConfig.Labels = mergedLabels;
|
||||
log?.(`Updated image labels: ${Object.keys(newImageLabels).length} from new image, ${Object.keys(mergedLabels).length} total`);
|
||||
} catch (e) {
|
||||
log?.(`Warning: could not update image labels: ${e}`);
|
||||
// Fall through with old labels — non-fatal
|
||||
}
|
||||
|
||||
// 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 +1936,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 +2294,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
|
||||
@@ -2250,6 +2308,14 @@ export function extractContainerOptions(inspectData: any): CreateContainerOption
|
||||
export async function updateContainer(id: string, options: Partial<CreateContainerOptions>, startAfterUpdate = false, envId?: number | null) {
|
||||
const oldContainerInfo = await inspectContainer(id, envId);
|
||||
const wasRunning = oldContainerInfo.State.Running;
|
||||
const name = oldContainerInfo.Name?.replace(/^\//, '') || '';
|
||||
const oldContainerId = oldContainerInfo.Id;
|
||||
const networks: Record<string, any> = oldContainerInfo.NetworkSettings?.Networks || {};
|
||||
const hostConfig = oldContainerInfo.HostConfig || {};
|
||||
const networkMode = hostConfig.NetworkMode || '';
|
||||
const isSharedNetwork = networkMode.startsWith('container:') ||
|
||||
networkMode.startsWith('service:') ||
|
||||
networkMode === 'host' || networkMode === 'none';
|
||||
|
||||
// Extract ALL existing container options
|
||||
const existingOptions = extractContainerOptions(oldContainerInfo);
|
||||
@@ -2266,18 +2332,81 @@ export async function updateContainer(id: string, options: Partial<CreateContain
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Stop old container
|
||||
if (wasRunning) {
|
||||
await stopContainer(id, envId);
|
||||
}
|
||||
|
||||
await removeContainer(id, true, envId);
|
||||
// 2. Rename old container to free the name (instead of removing — allows rollback)
|
||||
await dockerFetch(
|
||||
`/containers/${oldContainerId}/rename?name=${encodeURIComponent(name + '-old')}`,
|
||||
{ method: 'POST' },
|
||||
envId
|
||||
).then(async r => { if (!r.ok) throw new Error('Failed to rename old container'); await drainResponse(r); });
|
||||
|
||||
const newContainer = await createContainer(mergedOptions, envId);
|
||||
|
||||
if (startAfterUpdate || wasRunning) {
|
||||
await newContainer.start();
|
||||
// 3. Disconnect networks from old container to free static IPs
|
||||
if (!isSharedNetwork) {
|
||||
for (const [, netConfig] of Object.entries(networks)) {
|
||||
const nc = netConfig as any;
|
||||
if (nc.NetworkID) {
|
||||
await disconnectContainerFromNetwork(nc.NetworkID, oldContainerId, true, envId).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rollback helper: restore old container on failure
|
||||
const rollback = async () => {
|
||||
try {
|
||||
// Rename back
|
||||
await dockerFetch(
|
||||
`/containers/${oldContainerId}/rename?name=${encodeURIComponent(name)}`,
|
||||
{ method: 'POST' },
|
||||
envId
|
||||
).then(r => drainResponse(r)).catch(() => {});
|
||||
|
||||
// Reconnect networks
|
||||
if (!isSharedNetwork) {
|
||||
for (const [, netConfig] of Object.entries(networks)) {
|
||||
const nc = netConfig as any;
|
||||
if (nc.NetworkID) {
|
||||
await connectContainerToNetworkRaw(nc.NetworkID, oldContainerId, nc, envId).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restart if it was running
|
||||
if (wasRunning) {
|
||||
await startContainer(oldContainerId, envId).catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
// Rollback is best-effort
|
||||
}
|
||||
};
|
||||
|
||||
// 4. Create new container
|
||||
let newContainer;
|
||||
try {
|
||||
newContainer = await createContainer(mergedOptions, envId);
|
||||
} catch (createError) {
|
||||
await rollback();
|
||||
throw createError;
|
||||
}
|
||||
|
||||
// 5. Start if needed
|
||||
if (startAfterUpdate || wasRunning) {
|
||||
try {
|
||||
await newContainer.start();
|
||||
} catch (startError) {
|
||||
// Remove failed new container and restore old one
|
||||
await removeContainer(newContainer.id, true, envId).catch(() => {});
|
||||
await rollback();
|
||||
throw startError;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Remove old container (success path only)
|
||||
await removeContainer(oldContainerId, true, envId).catch(() => {});
|
||||
|
||||
return newContainer;
|
||||
}
|
||||
|
||||
@@ -2665,6 +2794,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 +2978,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;
|
||||
}
|
||||
}
|
||||
@@ -3104,6 +3244,22 @@ export async function getDockerVersion(envId?: number | null) {
|
||||
return dockerJsonRequest('/version', {}, envId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Docker daemon's API version string for a given environment.
|
||||
* Used to pin DOCKER_API_VERSION when spawning sidecar containers (scanner, updater)
|
||||
* whose bundled Docker CLI may be newer than the host daemon supports.
|
||||
* Returns null if the version cannot be determined.
|
||||
* Requires an envId — local Docker (no environment) must query /version directly.
|
||||
*/
|
||||
export async function getNegotiatedApiVersion(envId: number): Promise<string | null> {
|
||||
try {
|
||||
const versionInfo = await getDockerVersion(envId);
|
||||
return versionInfo?.ApiVersion || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight ping check for Docker daemon availability.
|
||||
* Uses /_ping endpoint which returns "OK" as plain text with minimal overhead.
|
||||
@@ -3111,15 +3267,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;
|
||||
@@ -3284,26 +3443,31 @@ export interface NetworkInfo {
|
||||
}
|
||||
|
||||
export async function listNetworks(envId?: number | null): Promise<NetworkInfo[]> {
|
||||
const networks = await dockerJsonRequest<any[]>('/networks', {}, envId);
|
||||
const [networks, containers] = await Promise.all([
|
||||
dockerJsonRequest<any[]>('/networks', {}, envId),
|
||||
dockerJsonRequest<any[]>('/containers/json?all=true', {}, 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;
|
||||
// Build map of networkId -> container info from container network settings
|
||||
const networkContainers = new Map<string, Record<string, { name: string; ipv4Address: string }>>();
|
||||
for (const container of containers) {
|
||||
const nets = container.NetworkSettings?.Networks;
|
||||
if (!nets) continue;
|
||||
const containerName = (container.Names?.[0] || '').replace(/^\//, '');
|
||||
for (const [, netInfo] of Object.entries(nets as Record<string, any>)) {
|
||||
const netId = netInfo.NetworkID;
|
||||
if (!netId) continue;
|
||||
if (!networkContainers.has(netId)) {
|
||||
networkContainers.set(netId, {});
|
||||
}
|
||||
})
|
||||
);
|
||||
networkContainers.get(netId)![container.Id] = {
|
||||
name: containerName,
|
||||
ipv4Address: netInfo.IPAddress || ''
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return networkDetails.map((network: any) => ({
|
||||
return networks.map((network: any) => ({
|
||||
id: network.Id,
|
||||
name: network.Name,
|
||||
driver: network.Driver,
|
||||
@@ -3319,13 +3483,7 @@ export async function listNetworks(envId?: number | null): Promise<NetworkInfo[]
|
||||
auxAddress: cfg.AuxAddress || cfg.auxAddress
|
||||
}))
|
||||
},
|
||||
containers: Object.entries(network.Containers || {}).reduce((acc: any, [id, data]: [string, any]) => {
|
||||
acc[id] = {
|
||||
name: data.Name,
|
||||
ipv4Address: data.IPv4Address
|
||||
};
|
||||
return acc;
|
||||
}, {})
|
||||
containers: networkContainers.get(network.Id) || {}
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -3954,8 +4112,9 @@ export async function runContainerWithStreaming(options: {
|
||||
// Container has exited. Now fetch stdout reliably (no race condition).
|
||||
const stdout = await fetchContainerStdout(containerId, config, options.envId);
|
||||
|
||||
// If stdout is empty and exit code is non-zero, fetch stderr for debugging
|
||||
// If stdout is empty and exit code is non-zero, fetch stderr and throw
|
||||
if (stdout.length === 0 && exitCode !== 0) {
|
||||
let stderrText = '';
|
||||
try {
|
||||
const stderrResponse = await dockerFetch(
|
||||
`/containers/${containerId}/logs?stdout=false&stderr=true&follow=false`,
|
||||
@@ -3964,13 +4123,12 @@ export async function runContainerWithStreaming(options: {
|
||||
);
|
||||
const stderrBuffer = Buffer.from(await stderrResponse.arrayBuffer());
|
||||
const stderrOutput = demuxDockerStream(stderrBuffer, { separateStreams: true });
|
||||
const stderrText = typeof stderrOutput === 'string' ? stderrOutput : stderrOutput.stderr;
|
||||
if (stderrText) {
|
||||
console.error(`[runContainerWithStreaming] Container stderr: ${stderrText.substring(0, 1000)}`);
|
||||
}
|
||||
stderrText = typeof stderrOutput === 'string' ? stderrOutput : stderrOutput.stderr;
|
||||
} catch {
|
||||
// Ignore stderr fetch errors
|
||||
}
|
||||
const detail = stderrText ? stderrText.substring(0, 1000) : 'no stderr output';
|
||||
throw new Error(`Container exited with code ${exitCode}: ${detail}`);
|
||||
}
|
||||
|
||||
return stdout;
|
||||
@@ -4745,7 +4903,7 @@ function getVolumeCacheKey(volumeName: string, envId?: number | null): string {
|
||||
/**
|
||||
* Ensure the volume helper image (busybox) is available, pulling if necessary
|
||||
*/
|
||||
async function ensureVolumeHelperImage(envId?: number | null): Promise<void> {
|
||||
export async function ensureVolumeHelperImage(envId?: number | null): Promise<void> {
|
||||
// Check if image exists
|
||||
const response = await dockerFetch(`/images/${encodeURIComponent(VOLUME_HELPER_IMAGE)}/json`, {}, envId);
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { resolve } from 'path';
|
||||
import { existsSync, mkdirSync, writeFileSync, unlinkSync, readFileSync } from 'fs';
|
||||
|
||||
function getIconsDir(): string {
|
||||
const dataDir = process.env.DATA_DIR || './data';
|
||||
const dir = resolve(dataDir, 'icons');
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
export function saveEnvironmentIcon(envId: number, base64Data: string): void {
|
||||
const dir = getIconsDir();
|
||||
// Strip data URL prefix if present
|
||||
const base64 = base64Data.replace(/^data:image\/\w+;base64,/, '');
|
||||
const buffer = Buffer.from(base64, 'base64');
|
||||
writeFileSync(resolve(dir, `env-${envId}.webp`), buffer);
|
||||
}
|
||||
|
||||
export function deleteEnvironmentIcon(envId: number): void {
|
||||
const dir = getIconsDir();
|
||||
const path = resolve(dir, `env-${envId}.webp`);
|
||||
if (existsSync(path)) {
|
||||
unlinkSync(path);
|
||||
}
|
||||
}
|
||||
|
||||
export function getEnvironmentIconBuffer(envId: number): Buffer | null {
|
||||
const dir = getIconsDir();
|
||||
const path = resolve(dir, `env-${envId}.webp`);
|
||||
if (!existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
return readFileSync(path);
|
||||
}
|
||||
+10
-6
@@ -88,7 +88,7 @@ let _nssWrapperNeeded = false;
|
||||
async function ensurePasswdEntry(env: GitEnv): Promise<void> {
|
||||
if (_nssWrapperChecked) {
|
||||
if (_nssWrapperNeeded) {
|
||||
env.LD_PRELOAD = NSS_WRAPPER_LIB;
|
||||
env.LD_PRELOAD = env.LD_PRELOAD ? `${env.LD_PRELOAD}:${NSS_WRAPPER_LIB}` : NSS_WRAPPER_LIB;
|
||||
env.NSS_WRAPPER_PASSWD = TMP_PASSWD;
|
||||
env.NSS_WRAPPER_GROUP = TMP_GROUP;
|
||||
}
|
||||
@@ -136,7 +136,7 @@ async function ensurePasswdEntry(env: GitEnv): Promise<void> {
|
||||
}
|
||||
|
||||
_nssWrapperNeeded = true;
|
||||
env.LD_PRELOAD = NSS_WRAPPER_LIB;
|
||||
env.LD_PRELOAD = env.LD_PRELOAD ? `${env.LD_PRELOAD}:${NSS_WRAPPER_LIB}` : NSS_WRAPPER_LIB;
|
||||
env.NSS_WRAPPER_PASSWD = TMP_PASSWD;
|
||||
env.NSS_WRAPPER_GROUP = TMP_GROUP;
|
||||
console.log(`[git] Created temp passwd for UID ${uid} with libnss_wrapper`);
|
||||
@@ -733,7 +733,8 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
|
||||
|
||||
// Always re-clone to ensure clean state (handles branch/URL/credential changes, force pushes, etc.)
|
||||
// Blobless clones fetch all commits (for git diff) but download blobs on-demand
|
||||
const previousCommit = await getPreviousCommit(repoPath, env);
|
||||
// Fall back to DB lastCommit when repo dir was deleted by a previous failed sync (#693)
|
||||
const previousCommit = await getPreviousCommit(repoPath, env) ?? gitStack.lastCommit ?? null;
|
||||
if (existsSync(repoPath)) {
|
||||
console.log(`${logPrefix} Removing existing clone for fresh sync...`);
|
||||
rmSync(repoPath, { recursive: true, force: true });
|
||||
@@ -762,7 +763,8 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
|
||||
// Check if commit changed
|
||||
const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
|
||||
const newCommit = newCommitResult.stdout.trim();
|
||||
const commitChanged = previousCommit !== newCommit;
|
||||
// Normalize to 7-char short hash for comparison (DB stores 7-char, git returns 40-char)
|
||||
const commitChanged = previousCommit?.substring(0, 7) !== newCommit.substring(0, 7);
|
||||
console.log(`${logPrefix} Previous commit: ${previousCommit || '(none)'}, new commit: ${newCommit.substring(0, 7)}, commit changed: ${commitChanged}`);
|
||||
|
||||
// Check if any files in the compose file's directory have changed
|
||||
@@ -1101,7 +1103,8 @@ export async function deployGitStackWithProgress(
|
||||
|
||||
// Always re-clone to ensure clean state (handles branch/URL/credential changes, force pushes, etc.)
|
||||
// Shallow clones are fast so this is acceptable
|
||||
const previousCommit = await getPreviousCommit(repoPath, env);
|
||||
// Fall back to DB lastCommit when repo dir was deleted by a previous failed sync (#693)
|
||||
const previousCommit = await getPreviousCommit(repoPath, env) ?? gitStack.lastCommit ?? null;
|
||||
|
||||
// Step 2: Cloning
|
||||
onProgress({ status: 'cloning', message: 'Cloning repository...', step: 2, totalSteps });
|
||||
@@ -1130,7 +1133,8 @@ export async function deployGitStackWithProgress(
|
||||
// Check if commit changed
|
||||
const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
|
||||
const newCommit = newCommitResult.stdout.trim();
|
||||
const commitChanged = previousCommit !== newCommit;
|
||||
// Normalize to 7-char short hash for comparison (DB stores 7-char, git returns 40-char)
|
||||
const commitChanged = previousCommit?.substring(0, 7) !== newCommit.substring(0, 7);
|
||||
|
||||
// Check if any files in the compose file's directory have changed
|
||||
// (for consistency with syncGitStack, though this function always deploys)
|
||||
|
||||
+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;
|
||||
|
||||
@@ -42,47 +42,21 @@ export interface NotificationResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// SMTP transporter cache — reuses connections instead of creating a new TLS pool per notification.
|
||||
const transporterCache = new Map<string, { transporter: ReturnType<typeof nodemailer.createTransport>; lastUsed: number }>();
|
||||
|
||||
function getOrCreateTransporter(config: SmtpConfig): ReturnType<typeof nodemailer.createTransport> {
|
||||
const key = `${config.host}:${config.port}:${config.secure}:${config.username || ''}`;
|
||||
const cached = transporterCache.get(key);
|
||||
if (cached) {
|
||||
cached.lastUsed = Date.now();
|
||||
return cached.transporter;
|
||||
}
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: config.username ? {
|
||||
user: config.username,
|
||||
pass: config.password
|
||||
} : undefined,
|
||||
tls: config.skipTlsVerify ? {
|
||||
rejectUnauthorized: false
|
||||
} : undefined
|
||||
});
|
||||
transporterCache.set(key, { transporter, lastUsed: Date.now() });
|
||||
return transporter;
|
||||
}
|
||||
|
||||
// Clean up idle transporters every 10 minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of transporterCache) {
|
||||
if (now - entry.lastUsed > 10 * 60 * 1000) {
|
||||
entry.transporter.close();
|
||||
transporterCache.delete(key);
|
||||
}
|
||||
}
|
||||
}, 10 * 60 * 1000);
|
||||
|
||||
// Send notification via SMTP
|
||||
async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
try {
|
||||
const transporter = getOrCreateTransporter(config);
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: config.username ? {
|
||||
user: config.username,
|
||||
pass: config.password
|
||||
} : undefined,
|
||||
tls: config.skipTlsVerify ? {
|
||||
rejectUnauthorized: false
|
||||
} : undefined
|
||||
});
|
||||
|
||||
const envBadge = payload.environmentName
|
||||
? `<span style="display: inline-block; background: #3b82f6; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px;">${payload.environmentName}</span>`
|
||||
|
||||
+65
-26
@@ -11,7 +11,8 @@ import {
|
||||
runContainer,
|
||||
runContainerWithStreaming,
|
||||
inspectImage,
|
||||
checkImageUpdateAvailable
|
||||
checkImageUpdateAvailable,
|
||||
getNegotiatedApiVersion
|
||||
} from './docker';
|
||||
import { getEnvironment, getEnvSetting, getSetting } from './db';
|
||||
import { sendEventNotification } from './notifications';
|
||||
@@ -108,6 +109,10 @@ const inProgressScans = new Map<string, Promise<string>>();
|
||||
export const DEFAULT_GRYPE_ARGS = '-o json -v {image}';
|
||||
export const DEFAULT_TRIVY_ARGS = 'image --format json {image}';
|
||||
|
||||
// Pinned scanner images — avoid :latest after the March 2026 Trivy supply chain attack
|
||||
export const DEFAULT_GRYPE_IMAGE = 'anchore/grype:v0.110.0';
|
||||
export const DEFAULT_TRIVY_IMAGE = 'aquasec/trivy:0.69.3';
|
||||
|
||||
export interface VulnerabilitySeverity {
|
||||
critical: number;
|
||||
high: number;
|
||||
@@ -150,28 +155,36 @@ export interface ScanProgress {
|
||||
output?: string; // Line of scanner output
|
||||
}
|
||||
|
||||
// Get global default scanner CLI args from general settings (or fallback to hardcoded defaults)
|
||||
// Get global default scanner CLI args and images from general settings (or fallback to hardcoded defaults)
|
||||
export async function getGlobalScannerDefaults(): Promise<{
|
||||
grypeArgs: string;
|
||||
trivyArgs: string;
|
||||
grypeImage: string;
|
||||
trivyImage: string;
|
||||
}> {
|
||||
const [grypeArgs, trivyArgs] = await Promise.all([
|
||||
const [grypeArgs, trivyArgs, grypeImage, trivyImage] = await Promise.all([
|
||||
getSetting('default_grype_args'),
|
||||
getSetting('default_trivy_args')
|
||||
getSetting('default_trivy_args'),
|
||||
getSetting('default_grype_image'),
|
||||
getSetting('default_trivy_image')
|
||||
]);
|
||||
return {
|
||||
grypeArgs: grypeArgs ?? DEFAULT_GRYPE_ARGS,
|
||||
trivyArgs: trivyArgs ?? DEFAULT_TRIVY_ARGS
|
||||
trivyArgs: trivyArgs ?? DEFAULT_TRIVY_ARGS,
|
||||
grypeImage: grypeImage ?? DEFAULT_GRYPE_IMAGE,
|
||||
trivyImage: trivyImage ?? DEFAULT_TRIVY_IMAGE
|
||||
};
|
||||
}
|
||||
|
||||
// Get scanner settings (scanner type is per-environment, CLI args are global)
|
||||
// Get scanner settings (scanner type is per-environment, CLI args and images are global)
|
||||
export async function getScannerSettings(envId?: number): Promise<{
|
||||
scanner: ScannerType;
|
||||
grypeArgs: string;
|
||||
trivyArgs: string;
|
||||
grypeImage: string;
|
||||
trivyImage: string;
|
||||
}> {
|
||||
// CLI args are always global - no need for per-env settings
|
||||
// CLI args and images are always global - no need for per-env settings
|
||||
const [globalDefaults, scanner] = await Promise.all([
|
||||
getGlobalScannerDefaults(),
|
||||
getEnvSetting('vulnerability_scanner', envId)
|
||||
@@ -180,25 +193,31 @@ export async function getScannerSettings(envId?: number): Promise<{
|
||||
return {
|
||||
scanner: scanner || 'none',
|
||||
grypeArgs: globalDefaults.grypeArgs,
|
||||
trivyArgs: globalDefaults.trivyArgs
|
||||
trivyArgs: globalDefaults.trivyArgs,
|
||||
grypeImage: globalDefaults.grypeImage,
|
||||
trivyImage: globalDefaults.trivyImage
|
||||
};
|
||||
}
|
||||
|
||||
// Optimized version that accepts pre-cached global defaults (avoids redundant DB calls)
|
||||
// Only looks up scanner type per-environment since CLI args are global
|
||||
// Only looks up scanner type per-environment since CLI args and images are global
|
||||
export async function getScannerSettingsWithDefaults(
|
||||
envId: number | undefined,
|
||||
globalDefaults: { grypeArgs: string; trivyArgs: string }
|
||||
globalDefaults: { grypeArgs: string; trivyArgs: string; grypeImage: string; trivyImage: string }
|
||||
): Promise<{
|
||||
scanner: ScannerType;
|
||||
grypeArgs: string;
|
||||
trivyArgs: string;
|
||||
grypeImage: string;
|
||||
trivyImage: string;
|
||||
}> {
|
||||
const scanner = await getEnvSetting('vulnerability_scanner', envId) || 'none';
|
||||
return {
|
||||
scanner,
|
||||
grypeArgs: globalDefaults.grypeArgs,
|
||||
trivyArgs: globalDefaults.trivyArgs
|
||||
trivyArgs: globalDefaults.trivyArgs,
|
||||
grypeImage: globalDefaults.grypeImage,
|
||||
trivyImage: globalDefaults.trivyImage
|
||||
};
|
||||
}
|
||||
|
||||
@@ -668,6 +687,28 @@ async function runScannerContainerCore(
|
||||
? [`GRYPE_DB_CACHE_DIR=${dbPath}`]
|
||||
: [`TRIVY_CACHE_DIR=${dbPath}`];
|
||||
|
||||
// Pin Docker API version so scanner's bundled Docker client doesn't request
|
||||
// a version newer than the host daemon supports (e.g. grype ships client v1.53
|
||||
// but the host may only support up to v1.43).
|
||||
const apiVersion = await getNegotiatedApiVersion(envId);
|
||||
if (apiVersion) {
|
||||
envVars.push(`DOCKER_API_VERSION=${apiVersion}`);
|
||||
console.log(`[Scanner] Using negotiated Docker API version: ${apiVersion}`);
|
||||
}
|
||||
|
||||
// Propagate proxy env vars so scanners can reach the internet in proxied environments
|
||||
const proxyVarNames = [
|
||||
'HTTP_PROXY', 'http_proxy',
|
||||
'HTTPS_PROXY', 'https_proxy',
|
||||
'NO_PROXY', 'no_proxy',
|
||||
'ALL_PROXY', 'all_proxy',
|
||||
];
|
||||
for (const name of proxyVarNames) {
|
||||
if (process.env[name]) {
|
||||
envVars.push(`${name}=${process.env[name]}`);
|
||||
}
|
||||
}
|
||||
|
||||
// In TCP mode, pass DOCKER_HOST so scanner connects to Docker via TCP
|
||||
if (scannerDockerHost) {
|
||||
envVars.push(`DOCKER_HOST=${scannerDockerHost}`);
|
||||
@@ -697,11 +738,7 @@ async function runScannerContainerCore(
|
||||
});
|
||||
|
||||
console.log(`[Scanner] ${scannerType} container completed, output length: ${output.length}`);
|
||||
if (output.length === 0) {
|
||||
console.error(`[Scanner] WARNING: Empty output from ${scannerType} container`);
|
||||
console.error(`[Scanner] This may indicate the scanner couldn't access Docker`);
|
||||
console.error(`[Scanner] Docker access: ${scannerDockerHost ? `TCP ${scannerDockerHost}` : `socket ${hostSocketPath}`}`);
|
||||
} else if (output.length < 100) {
|
||||
if (output.length < 100) {
|
||||
console.log(`[Scanner] ${scannerType} output preview: ${output}`);
|
||||
}
|
||||
|
||||
@@ -715,8 +752,7 @@ export async function scanWithGrype(
|
||||
onProgress?: (progress: ScanProgress) => void
|
||||
): Promise<ScanResult> {
|
||||
const startTime = Date.now();
|
||||
const scannerImage = 'anchore/grype:latest';
|
||||
const { grypeArgs } = await getScannerSettings(envId);
|
||||
const { grypeArgs, grypeImage: scannerImage } = await getScannerSettings(envId);
|
||||
|
||||
onProgress?.({
|
||||
stage: 'checking',
|
||||
@@ -813,8 +849,7 @@ export async function scanWithTrivy(
|
||||
onProgress?: (progress: ScanProgress) => void
|
||||
): Promise<ScanResult> {
|
||||
const startTime = Date.now();
|
||||
const scannerImage = 'aquasec/trivy:latest';
|
||||
const { trivyArgs } = await getScannerSettings(envId);
|
||||
const { trivyArgs, trivyImage: scannerImage } = await getScannerSettings(envId);
|
||||
|
||||
onProgress?.({
|
||||
stage: 'checking',
|
||||
@@ -978,9 +1013,10 @@ export async function checkScannerAvailability(envId?: number): Promise<{
|
||||
grype: boolean;
|
||||
trivy: boolean;
|
||||
}> {
|
||||
const defaults = await getGlobalScannerDefaults();
|
||||
const [grypeAvailable, trivyAvailable] = await Promise.all([
|
||||
isScannerImageAvailable('anchore/grype', envId),
|
||||
isScannerImageAvailable('aquasec/trivy', envId)
|
||||
isScannerImageAvailable(defaults.grypeImage, envId),
|
||||
isScannerImageAvailable(defaults.trivyImage, envId)
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -995,12 +1031,14 @@ async function getScannerVersion(
|
||||
envId?: number
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const scannerImage = scannerType === 'grype' ? 'anchore/grype:latest' : 'aquasec/trivy:latest';
|
||||
const defaults = await getGlobalScannerDefaults();
|
||||
const scannerImage = scannerType === 'grype' ? defaults.grypeImage : defaults.trivyImage;
|
||||
|
||||
// Check if image exists first
|
||||
// Check if image exists first — match by repo name prefix, not exact tag
|
||||
const imageRepo = scannerType === 'grype' ? 'anchore/grype' : 'aquasec/trivy';
|
||||
const images = await listImages(envId);
|
||||
const hasImage = images.some((img) =>
|
||||
img.tags?.some((tag: string) => tag === scannerImage)
|
||||
img.tags?.some((tag: string) => tag.startsWith(imageRepo + ':'))
|
||||
);
|
||||
if (!hasImage) return null;
|
||||
|
||||
@@ -1062,10 +1100,11 @@ export async function checkScannerUpdates(envId?: number): Promise<{
|
||||
};
|
||||
|
||||
try {
|
||||
const defaults = await getGlobalScannerDefaults();
|
||||
const images = await listImages(envId);
|
||||
|
||||
// Check both scanners
|
||||
for (const [scanner, imageName] of [['grype', 'anchore/grype:latest'], ['trivy', 'aquasec/trivy:latest']] as const) {
|
||||
for (const [scanner, imageName] of [['grype', defaults.grypeImage], ['trivy', defaults.trivyImage]] as const) {
|
||||
try {
|
||||
// Find local image
|
||||
const localImage = images.find((img) =>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -13,10 +13,14 @@ export interface GridItem {
|
||||
|
||||
export interface DashboardPreferences {
|
||||
gridLayout: GridItem[];
|
||||
locked: boolean;
|
||||
viewMode: 'grid' | 'list';
|
||||
}
|
||||
|
||||
const defaultPreferences: DashboardPreferences = {
|
||||
gridLayout: []
|
||||
gridLayout: [],
|
||||
locked: false,
|
||||
viewMode: 'grid'
|
||||
};
|
||||
|
||||
// Environment info from API
|
||||
@@ -147,16 +151,20 @@ function createDashboardStore() {
|
||||
const data = await response.json();
|
||||
// Handle migration from old format
|
||||
if (data.gridLayout && Array.isArray(data.gridLayout)) {
|
||||
set({ gridLayout: data.gridLayout });
|
||||
set({
|
||||
gridLayout: data.gridLayout,
|
||||
locked: data.locked ?? false,
|
||||
viewMode: data.viewMode ?? 'grid'
|
||||
});
|
||||
} else {
|
||||
set({ gridLayout: [] });
|
||||
set(defaultPreferences);
|
||||
}
|
||||
} else {
|
||||
set({ gridLayout: [] });
|
||||
set(defaultPreferences);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard preferences:', error);
|
||||
set({ gridLayout: [] });
|
||||
set(defaultPreferences);
|
||||
} finally {
|
||||
// Always mark as initialized so saves can proceed
|
||||
initialized = true;
|
||||
@@ -206,6 +214,24 @@ function createDashboardStore() {
|
||||
return newPrefs;
|
||||
});
|
||||
},
|
||||
setLocked: (locked: boolean) => {
|
||||
update(prefs => {
|
||||
const newPrefs = { ...prefs, locked };
|
||||
if (initialized) {
|
||||
scheduleSave(newPrefs);
|
||||
}
|
||||
return newPrefs;
|
||||
});
|
||||
},
|
||||
setViewMode: (viewMode: 'grid' | 'list') => {
|
||||
update(prefs => {
|
||||
const newPrefs = { ...prefs, viewMode };
|
||||
if (initialized) {
|
||||
scheduleSave(newPrefs);
|
||||
}
|
||||
return newPrefs;
|
||||
});
|
||||
},
|
||||
reset: () => {
|
||||
initialized = false;
|
||||
set(defaultPreferences);
|
||||
|
||||
@@ -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,8 +26,12 @@ export interface AppSettings {
|
||||
eventCollectionMode: EventCollectionMode;
|
||||
eventPollInterval: number;
|
||||
metricsCollectionInterval: number;
|
||||
compactPorts: boolean;
|
||||
formatLogTimestamps: boolean;
|
||||
externalStackPaths: string[];
|
||||
primaryStackLocation: string | null;
|
||||
defaultGrypeImage: string;
|
||||
defaultTrivyImage: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
@@ -50,8 +54,12 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
eventCollectionMode: 'stream',
|
||||
eventPollInterval: 60000,
|
||||
metricsCollectionInterval: 30000,
|
||||
compactPorts: false,
|
||||
formatLogTimestamps: false,
|
||||
externalStackPaths: [],
|
||||
primaryStackLocation: null
|
||||
primaryStackLocation: null,
|
||||
defaultGrypeImage: 'anchore/grype:v0.110.0',
|
||||
defaultTrivyImage: 'aquasec/trivy:0.69.3'
|
||||
};
|
||||
|
||||
// Create a writable store for app settings
|
||||
@@ -88,8 +96,12 @@ 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,
|
||||
formatLogTimestamps: settings.formatLogTimestamps ?? DEFAULT_SETTINGS.formatLogTimestamps,
|
||||
externalStackPaths: settings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
|
||||
primaryStackLocation: settings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation
|
||||
primaryStackLocation: settings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation,
|
||||
defaultGrypeImage: settings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
|
||||
defaultTrivyImage: settings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
@@ -129,8 +141,12 @@ 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,
|
||||
formatLogTimestamps: updatedSettings.formatLogTimestamps ?? DEFAULT_SETTINGS.formatLogTimestamps,
|
||||
externalStackPaths: updatedSettings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
|
||||
primaryStackLocation: updatedSettings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation
|
||||
primaryStackLocation: updatedSettings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation,
|
||||
defaultGrypeImage: updatedSettings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
|
||||
defaultTrivyImage: updatedSettings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -290,6 +306,20 @@ function createSettingsStore() {
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setCompactPorts: (value: boolean) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, compactPorts: value };
|
||||
saveSettings({ compactPorts: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setFormatLogTimestamps: (value: boolean) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, formatLogTimestamps: value };
|
||||
saveSettings({ formatLogTimestamps: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setExternalStackPaths: (value: string[]) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, externalStackPaths: value };
|
||||
@@ -304,6 +334,20 @@ function createSettingsStore() {
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setDefaultGrypeImage: (value: string) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, defaultGrypeImage: value };
|
||||
saveSettings({ defaultGrypeImage: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setDefaultTrivyImage: (value: string) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, defaultTrivyImage: value };
|
||||
saveSettings({ defaultTrivyImage: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
// Manual refresh from database
|
||||
refresh: loadSettings
|
||||
};
|
||||
@@ -419,3 +463,19 @@ export function getTimeFormat(): TimeFormat {
|
||||
export function getDateFormat(): DateFormat {
|
||||
return cachedDateFormat;
|
||||
}
|
||||
|
||||
// Regex matching ISO 8601 timestamps at the start of log lines (after optional container prefix)
|
||||
// Matches: 2026-01-12T07:47:44.449821093Z or 2026-01-12T07:47:44Z
|
||||
const ISO_TIMESTAMP_RE = /(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.\d+)?Z/g;
|
||||
|
||||
/**
|
||||
* Replace ISO 8601 timestamps in log text with formatted local timestamps.
|
||||
* Uses the user's configured date/time format settings.
|
||||
*/
|
||||
export function formatLogTimestamps(text: string): string {
|
||||
return text.replace(ISO_TIMESTAMP_RE, (_match, dateTimePart) => {
|
||||
const d = new Date(_match);
|
||||
if (isNaN(d.getTime())) return _match;
|
||||
return `${formatDatePart(d)} ${formatTimePart(d, true)}`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ function applyEditorFont(fontId: string) {
|
||||
document.documentElement.style.setProperty('--font-editor', fontMeta.family);
|
||||
}
|
||||
|
||||
// Load Google Font dynamically
|
||||
// Load bundled font CSS (fonts are bundled in static/fonts/)
|
||||
function loadGoogleFont(font: FontMeta) {
|
||||
if (!font.googleFont) return;
|
||||
|
||||
@@ -274,7 +274,7 @@ function loadGoogleFont(font: FontMeta) {
|
||||
const link = document.createElement('link');
|
||||
link.id = linkId;
|
||||
link.rel = 'stylesheet';
|
||||
link.href = `https://fonts.googleapis.com/css2?family=${font.googleFont}&display=swap`;
|
||||
link.href = `/fonts/${font.id}/font.css`;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
|
||||
@@ -120,7 +120,6 @@ export const monospaceFonts: FontMeta[] = [
|
||||
{ id: 'ubuntu-mono', name: 'Ubuntu Mono', family: "'Ubuntu Mono', monospace", googleFont: 'Ubuntu+Mono:wght@400;700' },
|
||||
{ id: 'space-mono', name: 'Space Mono', family: "'Space Mono', monospace", googleFont: 'Space+Mono:wght@400;700' },
|
||||
{ id: 'inconsolata', name: 'Inconsolata', family: "'Inconsolata', monospace", googleFont: 'Inconsolata:wght@400;500;600;700' },
|
||||
{ id: 'hack', name: 'Hack', family: "'Hack', monospace", googleFont: 'Hack:wght@400;700' },
|
||||
{ id: 'anonymous-pro', name: 'Anonymous Pro', family: "'Anonymous Pro', monospace", googleFont: 'Anonymous+Pro:wght@400;700' },
|
||||
{ id: 'dm-mono', name: 'DM Mono', family: "'DM Mono', monospace", googleFont: 'DM+Mono:wght@400;500' },
|
||||
{ id: 'red-hat-mono', name: 'Red Hat Mono', family: "'Red Hat Mono', monospace", googleFont: 'Red+Hat+Mono:wght@400;500;600;700' },
|
||||
|
||||
+2
-2
@@ -28,7 +28,7 @@ export interface ContainerInfo {
|
||||
rw: boolean;
|
||||
}>;
|
||||
networkMode: string;
|
||||
networks: string[];
|
||||
networks: Record<string, { ipAddress: string }>;
|
||||
/**
|
||||
* Identifies system containers (Dockhand, Hawser) that cannot be updated from within Dockhand.
|
||||
* - 'dockhand': The Dockhand container itself
|
||||
@@ -164,7 +164,7 @@ export interface GitRepository {
|
||||
}
|
||||
|
||||
// Grid column configuration types
|
||||
export type GridId = 'containers' | 'images' | 'imageTags' | 'networks' | 'stacks' | 'volumes' | 'activity' | 'schedules' | 'audit';
|
||||
export type GridId = 'containers' | 'images' | 'imageTags' | 'networks' | 'stacks' | 'volumes' | 'activity' | 'schedules' | 'audit' | 'environments';
|
||||
|
||||
export interface ColumnConfig {
|
||||
id: string;
|
||||
|
||||
@@ -23,6 +23,7 @@ export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
if (ok) return true;
|
||||
|
||||
@@ -43,4 +43,8 @@ export function getIconComponent(iconName: string): ComponentType {
|
||||
return iconMap[iconName] || Globe;
|
||||
}
|
||||
|
||||
export function isCustomIcon(icon: string | null | undefined): boolean {
|
||||
return !!icon && icon.startsWith('custom:');
|
||||
}
|
||||
|
||||
export { iconMap };
|
||||
|
||||
@@ -82,9 +82,6 @@
|
||||
// Check auth status
|
||||
authStore.check();
|
||||
|
||||
// Check What's New popup
|
||||
checkWhatsNew();
|
||||
|
||||
return () => {
|
||||
disconnectSSE();
|
||||
};
|
||||
@@ -113,6 +110,15 @@
|
||||
localStorage.setItem('dockhand-whats-new-version', currentVersion);
|
||||
}
|
||||
}
|
||||
|
||||
// Show What's New only after auth resolves — avoids leaking version info on login page (#717)
|
||||
let whatsNewChecked = false;
|
||||
$effect(() => {
|
||||
if (!whatsNewChecked && !$authStore.loading && (!$authStore.authEnabled || $authStore.authenticated)) {
|
||||
whatsNewChecked = true;
|
||||
checkWhatsNew();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
||||
+131
-4
@@ -5,7 +5,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { RefreshCw, LayoutGrid, Loader2, Server, Tags, Square, RectangleVertical, Rows3, LayoutTemplate, Maximize2, Plus } from 'lucide-svelte';
|
||||
import { RefreshCw, LayoutGrid, Loader2, Server, Tags, Square, RectangleVertical, Rows3, LayoutTemplate, Maximize2, Plus, Lock, LockOpen, List, Search, Plug, Route, UndoDot } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
@@ -14,11 +14,14 @@
|
||||
import EnvironmentTile from './dashboard/EnvironmentTile.svelte';
|
||||
import EnvironmentTileSkeleton from './dashboard/EnvironmentTileSkeleton.svelte';
|
||||
import DraggableGrid, { type GridItemLayout } from './dashboard/DraggableGrid.svelte';
|
||||
import EnvironmentListView from './dashboard/EnvironmentListView.svelte';
|
||||
import { dashboardPreferences, dashboardData, GRID_COLS, GRID_ROW_HEIGHT, type TileItem } from '$lib/stores/dashboard';
|
||||
import { currentEnvironment, environments } from '$lib/stores/environment';
|
||||
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
|
||||
import type { EnvironmentStats } from './api/dashboard/stats/+server';
|
||||
import { getLabelColor, getLabelBgColor } from '$lib/utils/label-colors';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
|
||||
|
||||
const LABEL_FILTER_STORAGE_KEY = 'dockhand-dashboard-label-filter';
|
||||
|
||||
@@ -52,6 +55,42 @@
|
||||
const mobileWatcher = new IsMobile();
|
||||
const isMobile = $derived.by(() => mobileWatcher.current);
|
||||
|
||||
// Dashboard lock and view mode from preferences
|
||||
let locked = $state(false);
|
||||
let viewMode = $state<'grid' | 'list'>('grid');
|
||||
|
||||
// List view filter state
|
||||
let listSearchQuery = $state('');
|
||||
let listConnectionFilter = $state<string[]>([]);
|
||||
const connectionOptions = [
|
||||
{ value: 'socket', label: 'Socket' },
|
||||
{ value: 'direct', label: 'Direct', icon: Plug },
|
||||
{ value: 'hawser-standard', label: 'Standard', icon: Route },
|
||||
{ value: 'hawser-edge', label: 'Edge', icon: UndoDot }
|
||||
];
|
||||
|
||||
// Count of list-filtered results (for header display)
|
||||
const listFilteredCount = $derived.by(() => {
|
||||
let result = filteredTiles;
|
||||
if (listConnectionFilter.length > 0) {
|
||||
result = result.filter(t => {
|
||||
const type = t.stats?.connectionType || 'socket';
|
||||
return listConnectionFilter.includes(type);
|
||||
});
|
||||
}
|
||||
const q = listSearchQuery.trim().toLowerCase();
|
||||
if (q) {
|
||||
result = result.filter(t => {
|
||||
const s = t.stats;
|
||||
if (!s) return false;
|
||||
return s.name.toLowerCase().includes(q) ||
|
||||
s.host?.toLowerCase().includes(q) ||
|
||||
s.labels?.some(l => l.toLowerCase().includes(q));
|
||||
});
|
||||
}
|
||||
return result.length;
|
||||
});
|
||||
|
||||
// Subscribe to environments store's loaded flag for quick "loaded" detection
|
||||
// When loaded, immediately create skeleton tiles so the UI shows something useful
|
||||
// The SSE stream will then update these tiles with real stats
|
||||
@@ -166,6 +205,17 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Filter tiles for list view based on selected labels
|
||||
const filteredTiles = $derived.by(() => {
|
||||
if (filterLabels.length === 0) {
|
||||
return tiles;
|
||||
}
|
||||
return tiles.filter(t => {
|
||||
const tileLabels = t.stats?.labels || [];
|
||||
return tileLabels.some(label => filterLabels.includes(label));
|
||||
});
|
||||
});
|
||||
|
||||
// Filter grid items based on selected labels
|
||||
const filteredGridItems = $derived.by(() => {
|
||||
if (filterLabels.length === 0) {
|
||||
@@ -217,6 +267,8 @@
|
||||
|
||||
// Subscribe to preferences store to load saved layout
|
||||
const unsubscribePrefs = dashboardPreferences.subscribe(prefs => {
|
||||
locked = prefs.locked;
|
||||
viewMode = prefs.viewMode;
|
||||
if (prefs.gridLayout.length > 0 && tiles.length > 0 && !prefsLoaded) {
|
||||
// Apply saved layout
|
||||
gridItems = prefs.gridLayout.map(item => ({
|
||||
@@ -614,8 +666,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLocked() {
|
||||
locked = !locked;
|
||||
dashboardPreferences.setLocked(locked);
|
||||
}
|
||||
|
||||
function switchToListView() {
|
||||
viewMode = 'list';
|
||||
dashboardPreferences.setViewMode('list');
|
||||
// Remove focus from trigger button
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}
|
||||
|
||||
// Apply autolayout - arrange all tiles with specified dimensions
|
||||
function applyAutoLayout(width: number, height: number) {
|
||||
// Switch to grid view when selecting a grid layout
|
||||
if (viewMode !== 'grid') {
|
||||
viewMode = 'grid';
|
||||
dashboardPreferences.setViewMode('grid');
|
||||
}
|
||||
const tileIds = tiles.map(t => t.id);
|
||||
const newGridItems: GridItemLayout[] = [];
|
||||
|
||||
@@ -928,7 +999,7 @@
|
||||
<!-- Header -->
|
||||
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
|
||||
<div class="flex items-center gap-4">
|
||||
<PageHeader icon={LayoutGrid} title="Environments" />
|
||||
<PageHeader icon={LayoutGrid} title="Environments" count={tiles.length} />
|
||||
|
||||
<!-- Label filter toggles (only show if there are labels) -->
|
||||
{#if allLabels.length > 0}
|
||||
@@ -960,6 +1031,33 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- List view filters (search + connection type) -->
|
||||
{#if viewMode === 'list'}
|
||||
<div class="flex items-center gap-2 mr-2">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search environments..."
|
||||
bind:value={listSearchQuery}
|
||||
onkeydown={(e) => e.key === 'Escape' && (listSearchQuery = '')}
|
||||
class="pl-8 h-8 w-52 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<MultiSelectFilter
|
||||
bind:value={listConnectionFilter}
|
||||
options={connectionOptions}
|
||||
placeholder="All connections"
|
||||
pluralLabel="connections"
|
||||
width="w-48"
|
||||
defaultIcon={Plug}
|
||||
/>
|
||||
{#if listSearchQuery || listConnectionFilter.length > 0}
|
||||
<span class="text-xs text-muted-foreground whitespace-nowrap">{listFilteredCount} of {filteredTiles.length}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add environment button -->
|
||||
<button
|
||||
onclick={() => goto('/settings?tab=environments&new=true')}
|
||||
@@ -969,6 +1067,21 @@
|
||||
<Plus class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<!-- Lock toggle (only in grid view) -->
|
||||
{#if viewMode === 'grid'}
|
||||
<button
|
||||
onclick={toggleLocked}
|
||||
class="p-1.5 rounded hover:bg-muted transition-colors"
|
||||
title={locked ? 'Unlock tiles' : 'Lock tiles'}
|
||||
>
|
||||
{#if locked}
|
||||
<Lock class="w-4 h-4 text-primary" />
|
||||
{:else}
|
||||
<LockOpen class="w-4 h-4" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Autolayout dropdown -->
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
@@ -976,7 +1089,7 @@
|
||||
<button
|
||||
{...props}
|
||||
class="p-1.5 rounded hover:bg-muted transition-colors"
|
||||
title="Auto-layout tiles"
|
||||
title="Layout options"
|
||||
>
|
||||
<LayoutTemplate class="w-4 h-4" />
|
||||
</button>
|
||||
@@ -999,6 +1112,11 @@
|
||||
<Maximize2 class="w-4 h-4" />
|
||||
<span>Full</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={switchToListView} class="flex items-center gap-2 cursor-pointer">
|
||||
<List class="w-4 h-4" />
|
||||
<span>List</span>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
@@ -1032,8 +1150,16 @@
|
||||
Go to Settings
|
||||
</Button>
|
||||
</div>
|
||||
{:else if viewMode === 'list'}
|
||||
<!-- List view -->
|
||||
<EnvironmentListView
|
||||
tiles={filteredTiles}
|
||||
searchQuery={listSearchQuery}
|
||||
connectionFilter={listConnectionFilter}
|
||||
onrowclick={handleTileClick}
|
||||
/>
|
||||
{:else if filteredGridItems.length === 0}
|
||||
<!-- Filter shows no results -->
|
||||
<!-- Filter shows no results (grid view) -->
|
||||
<div class="flex flex-col items-center justify-center h-64 text-muted-foreground">
|
||||
<div class="w-16 h-16 mb-4 rounded-2xl border-2 border-dashed border-muted-foreground/30 flex items-center justify-center">
|
||||
<Tags class="w-8 h-8 opacity-40" />
|
||||
@@ -1086,6 +1212,7 @@
|
||||
maxW={2}
|
||||
minH={1}
|
||||
maxH={4}
|
||||
{locked}
|
||||
onchange={handleGridChange}
|
||||
onitemclick={handleTileClick}
|
||||
>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
} from 'lucide-svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import { currentEnvironment, environments as environmentsStore } from '$lib/stores/environment';
|
||||
import { getIconComponent } from '$lib/utils/icons';
|
||||
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
||||
import { canAccess } from '$lib/stores/auth';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
@@ -683,14 +683,17 @@
|
||||
<!-- Environment filter -->
|
||||
{#if environments.length > 0}
|
||||
{@const selectedEnv = environments.find(e => e.id === filterEnvironmentId)}
|
||||
{@const SelectedEnvIcon = selectedEnv ? getIconComponent(selectedEnv.icon || 'globe') : Server}
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={filterEnvironmentId !== null ? String(filterEnvironmentId) : undefined}
|
||||
onValueChange={(v) => filterEnvironmentId = v ? parseInt(v) : null}
|
||||
>
|
||||
<Select.Trigger size="sm" class="w-44 text-sm">
|
||||
<SelectedEnvIcon class="w-3.5 h-3.5 mr-1.5 text-muted-foreground shrink-0" />
|
||||
{#if selectedEnv}
|
||||
<EnvironmentIcon icon={selectedEnv.icon || 'globe'} envId={selectedEnv.id} class="w-3.5 h-3.5 mr-1.5 text-muted-foreground shrink-0" />
|
||||
{:else}
|
||||
<Server class="w-3.5 h-3.5 mr-1.5 text-muted-foreground shrink-0" />
|
||||
{/if}
|
||||
<span class="truncate">
|
||||
{#if filterEnvironmentId === null}
|
||||
Environment
|
||||
@@ -705,9 +708,8 @@
|
||||
All environments
|
||||
</Select.Item>
|
||||
{#each environments as env}
|
||||
{@const EnvIcon = getIconComponent(env.icon || 'globe')}
|
||||
<Select.Item value={String(env.id)}>
|
||||
<EnvIcon class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
{env.name}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
@@ -814,9 +816,8 @@
|
||||
<span class="font-mono text-xs whitespace-nowrap">{formatTimestamp(event.timestamp)}</span>
|
||||
{:else if column.id === 'environment'}
|
||||
{#if event.environmentName}
|
||||
{@const EventEnvIcon = getIconComponent(event.environmentIcon || 'globe')}
|
||||
<div class="flex items-center gap-1 text-xs">
|
||||
<EventEnvIcon class="w-3 h-3 text-muted-foreground shrink-0" />
|
||||
<EnvironmentIcon icon={event.environmentIcon || 'globe'} envId={event.environmentId || 0} class="w-3 h-3 text-muted-foreground shrink-0" />
|
||||
<span class="truncate">{event.environmentName}</span>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getOidcConfig } from '$lib/server/db';
|
||||
// GET /api/auth/oidc/[id]/initiate - Start OIDC authentication flow
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
// Check if auth is enabled
|
||||
if (!isAuthEnabled()) {
|
||||
if (!await isAuthEnabled()) {
|
||||
return json({ error: 'Authentication is not enabled' }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export const GET: RequestHandler = async ({ params, url }) => {
|
||||
// POST /api/auth/oidc/[id]/initiate - Get authorization URL without redirect
|
||||
export const POST: RequestHandler = async ({ params, request }) => {
|
||||
// Check if auth is enabled
|
||||
if (!isAuthEnabled()) {
|
||||
if (!await isAuthEnabled()) {
|
||||
return json({ error: 'Authentication is not enabled' }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { validateSession, testOidcConnection, isAuthEnabled } from '$lib/server/
|
||||
export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
// When auth is disabled, allow access (for initial setup)
|
||||
// When auth is enabled, require admin
|
||||
if (isAuthEnabled()) {
|
||||
if (await isAuthEnabled()) {
|
||||
const user = await validateSession(cookies);
|
||||
if (!user || !user.isAdmin) {
|
||||
return json({ error: 'Admin access required' }, { status: 403 });
|
||||
|
||||
@@ -7,7 +7,7 @@ import { auditAuth } from '$lib/server/audit';
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
const { url, cookies } = event;
|
||||
// Check if auth is enabled
|
||||
if (!isAuthEnabled()) {
|
||||
if (!await isAuthEnabled()) {
|
||||
throw redirect(302, '/login?error=auth_disabled');
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,13 @@ import { deleteAutoUpdateSchedule, getAutoUpdateSetting, removePendingContainerU
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { unregisterSchedule } from '$lib/server/scheduler';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
@@ -30,14 +34,20 @@ 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 });
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
const { params, url, cookies } = event;
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const force = url.searchParams.get('force') === 'true';
|
||||
@@ -96,8 +106,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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,8 +9,12 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { createExec, getDockerConnectionInfo } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request, cookies, url }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !auth.isAuthenticated) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { listContainerDirectory } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const path = url.searchParams.get('path') || '/';
|
||||
@@ -26,7 +30,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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { chmodContainerPath } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { readContainerFile, writeContainerFile } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// Max file size for reading (1MB)
|
||||
const MAX_FILE_SIZE = 1024 * 1024;
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const path = url.searchParams.get('path');
|
||||
@@ -36,7 +40,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')) {
|
||||
@@ -57,6 +64,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
};
|
||||
|
||||
export const PUT: RequestHandler = async ({ params, url, cookies, request }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const path = url.searchParams.get('path');
|
||||
@@ -92,7 +102,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')) {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createContainerFile, createContainerDirectory } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { deleteContainerPath } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const path = url.searchParams.get('path');
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import { getContainerArchive, statContainerPath } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const path = url.searchParams.get('path');
|
||||
@@ -76,7 +80,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' }), {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { renameContainerPath } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { putContainerArchive } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
@@ -84,6 +85,9 @@ function createTarArchive(filename: string, content: Uint8Array): Uint8Array {
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ params, url, request, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const path = url.searchParams.get('path');
|
||||
@@ -140,7 +144,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 });
|
||||
|
||||
@@ -2,8 +2,12 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { inspectContainer } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getContainerLogs } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const tail = parseInt(url.searchParams.get('tail') || '100');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getEnvironment } from '$lib/server/db';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import { unixSocketRequest, unixSocketStreamRequest, httpsAgentRequest } from '$lib/server/docker';
|
||||
import type { DockerClientConfig as BaseDockerClientConfig } from '$lib/server/docker';
|
||||
import { sendEdgeRequest, sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser';
|
||||
@@ -254,6 +255,9 @@ async function handleEdgeLogsStream(containerId: string, tail: string, environme
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const containerId = params.id;
|
||||
|
||||
@@ -3,9 +3,13 @@ import type { RequestHandler } from './$types';
|
||||
import { pauseContainer, inspectContainer } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, url, cookies } = event;
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
|
||||
@@ -3,10 +3,14 @@ import { renameContainer, inspectContainer } from '$lib/server/docker';
|
||||
import { renameAutoUpdateSchedule } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, request, url, cookies } = event;
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
@@ -46,8 +50,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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,10 +2,14 @@ import { json } from '@sveltejs/kit';
|
||||
import { restartContainer, inspectContainer } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, url, cookies } = event;
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
@@ -38,8 +42,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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { execInContainer } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// Shell paths to check
|
||||
@@ -12,6 +13,9 @@ const SHELLS_TO_CHECK = [
|
||||
];
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
|
||||
@@ -2,10 +2,14 @@ import { json } from '@sveltejs/kit';
|
||||
import { startContainer, inspectContainer } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, url, cookies } = event;
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
@@ -31,8 +35,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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
|
||||
import { getContainerStats, EnvironmentNotFoundError } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { hasEnvironments } from '$lib/server/db';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
|
||||
function calculateCpuPercent(stats: any): number {
|
||||
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
|
||||
@@ -70,6 +71,9 @@ function calculateMemoryUsage(memoryStats: any): { usage: number; raw: number; c
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
|
||||
@@ -2,10 +2,14 @@ import { json } from '@sveltejs/kit';
|
||||
import { stopContainer, inspectContainer } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, url, cookies } = event;
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
@@ -31,8 +35,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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { execInContainer, getContainerTop } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
|
||||
function parsePsOutput(output: string): { Titles: string[]; Processes: string[][] } | null {
|
||||
const lines = output.trim().split('\n').filter(line => line.trim());
|
||||
@@ -28,6 +29,9 @@ function parsePsOutput(output: string): { Titles: string[]; Processes: string[][
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
|
||||
@@ -3,9 +3,13 @@ import type { RequestHandler } from './$types';
|
||||
import { unpauseContainer, inspectContainer } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, url, cookies } = event;
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
|
||||
@@ -3,10 +3,14 @@ import { pullImage, updateContainer, type CreateContainerOptions } from '$lib/se
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { removePendingContainerUpdate } from '$lib/server/db';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, request, url, cookies } = event;
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
@@ -47,8 +51,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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -273,7 +273,6 @@ export const POST: RequestHandler = async (event) => {
|
||||
|
||||
let scanBlocked = false;
|
||||
let blockReason = '';
|
||||
let finalScanResult: ScanResult | undefined;
|
||||
let individualScannerResults: ScannerResult[] = [];
|
||||
|
||||
try {
|
||||
@@ -290,17 +289,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
});
|
||||
|
||||
if (scanResults.length > 0) {
|
||||
const scanSummary = combineScanSummaries(scanResults);
|
||||
finalScanResult = {
|
||||
critical: scanSummary.critical,
|
||||
high: scanSummary.high,
|
||||
medium: scanSummary.medium,
|
||||
low: scanSummary.low,
|
||||
negligible: scanSummary.negligible,
|
||||
unknown: scanSummary.unknown
|
||||
};
|
||||
|
||||
// Build individual scanner results
|
||||
// Build individual scanner results (used by frontend)
|
||||
individualScannerResults = scanResults.map(result => ({
|
||||
scanner: result.scanner as 'grype' | 'trivy',
|
||||
critical: result.summary.critical,
|
||||
@@ -333,8 +322,9 @@ export const POST: RequestHandler = async (event) => {
|
||||
} catch { /* ignore save errors */ }
|
||||
}
|
||||
|
||||
// Check if blocked
|
||||
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, undefined);
|
||||
// Check if blocked (combineScanSummaries uses Math.max for security check)
|
||||
const combinedForBlockCheck = combineScanSummaries(scanResults);
|
||||
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, combinedForBlockCheck, undefined);
|
||||
if (blocked) {
|
||||
scanBlocked = true;
|
||||
blockReason = reason;
|
||||
@@ -355,15 +345,21 @@ export const POST: RequestHandler = async (event) => {
|
||||
scanner: v.scanner
|
||||
}));
|
||||
|
||||
// Build scan message from individual results
|
||||
const totalCritical = individualScannerResults.reduce((s, r) => s + r.critical, 0);
|
||||
const totalHigh = individualScannerResults.reduce((s, r) => s + r.high, 0);
|
||||
const totalMedium = individualScannerResults.reduce((s, r) => s + r.medium, 0);
|
||||
const totalLow = individualScannerResults.reduce((s, r) => s + r.low, 0);
|
||||
const hasVulns = totalCritical + totalHigh + totalMedium + totalLow > 0;
|
||||
|
||||
sendData({
|
||||
type: 'scan_complete',
|
||||
containerId,
|
||||
containerName,
|
||||
scanResult: finalScanResult,
|
||||
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
|
||||
vulnerabilities: vulnerabilities.length > 0 ? vulnerabilities : undefined,
|
||||
message: finalScanResult
|
||||
? `Scan complete: ${finalScanResult.critical} critical, ${finalScanResult.high} high, ${finalScanResult.medium} medium, ${finalScanResult.low} low`
|
||||
message: hasVulns
|
||||
? `Scan complete: ${totalCritical} critical, ${totalHigh} high, ${totalMedium} medium, ${totalLow} low`
|
||||
: 'Scan complete: no vulnerabilities found'
|
||||
});
|
||||
|
||||
@@ -398,7 +394,6 @@ export const POST: RequestHandler = async (event) => {
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
scanResult: finalScanResult,
|
||||
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
|
||||
blockReason,
|
||||
message: `Update blocked: ${blockReason}`
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -1,26 +1,59 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { getDashboardPreferences, saveDashboardPreferences } from '$lib/server/db';
|
||||
import { getUserPreference, setUserPreference } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
// Store all dashboard prefs as a single JSON blob to avoid the chained .where() bug
|
||||
// in getUserPreference/setUserPreference (chained .where() replaces instead of ANDing)
|
||||
const DASHBOARD_PREFS_KEY = 'dashboard_prefs';
|
||||
|
||||
interface StoredDashboardPrefs {
|
||||
gridLayout: any[];
|
||||
locked: boolean;
|
||||
viewMode: 'grid' | 'list';
|
||||
}
|
||||
|
||||
async function getPrefs(userId: number | null): Promise<StoredDashboardPrefs> {
|
||||
const stored = await getUserPreference<StoredDashboardPrefs>({
|
||||
userId,
|
||||
environmentId: null,
|
||||
key: DASHBOARD_PREFS_KEY
|
||||
});
|
||||
|
||||
if (stored && typeof stored === 'object' && Array.isArray(stored.gridLayout)) {
|
||||
return {
|
||||
gridLayout: stored.gridLayout,
|
||||
locked: stored.locked ?? false,
|
||||
viewMode: stored.viewMode ?? 'grid'
|
||||
};
|
||||
}
|
||||
|
||||
// Migration: try reading from old dashboard_layout key
|
||||
const oldLayout = await getUserPreference<any[]>({
|
||||
userId,
|
||||
environmentId: null,
|
||||
key: 'dashboard_layout'
|
||||
});
|
||||
|
||||
return {
|
||||
gridLayout: Array.isArray(oldLayout) ? oldLayout : [],
|
||||
locked: false,
|
||||
viewMode: 'grid'
|
||||
};
|
||||
}
|
||||
|
||||
async function savePrefs(userId: number | null, prefs: StoredDashboardPrefs): Promise<void> {
|
||||
await setUserPreference(
|
||||
{ userId, environmentId: null, key: DASHBOARD_PREFS_KEY },
|
||||
prefs
|
||||
);
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
try {
|
||||
// Get user-specific preferences, or fall back to global preferences
|
||||
const userId = auth.user?.id ?? null;
|
||||
const prefs = await getDashboardPreferences(userId);
|
||||
|
||||
// If no preferences exist, return empty gridLayout
|
||||
if (!prefs) {
|
||||
return json({
|
||||
id: 0,
|
||||
userId: null,
|
||||
gridLayout: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
const prefs = await getPrefs(userId);
|
||||
return json(prefs);
|
||||
} catch (error) {
|
||||
console.error('Failed to get dashboard preferences:', error);
|
||||
@@ -33,19 +66,23 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { gridLayout } = body;
|
||||
const userId = auth.user?.id ?? null;
|
||||
|
||||
if (!gridLayout || !Array.isArray(gridLayout)) {
|
||||
return json({ error: 'gridLayout is required and must be an array' }, { status: 400 });
|
||||
// Load current prefs and merge changes
|
||||
const current = await getPrefs(userId);
|
||||
|
||||
if (body.gridLayout && Array.isArray(body.gridLayout)) {
|
||||
current.gridLayout = body.gridLayout;
|
||||
}
|
||||
if (body.locked !== undefined) {
|
||||
current.locked = body.locked;
|
||||
}
|
||||
if (body.viewMode !== undefined) {
|
||||
current.viewMode = body.viewMode;
|
||||
}
|
||||
|
||||
const userId = auth.user?.id ?? null;
|
||||
const prefs = await saveDashboardPreferences({
|
||||
userId,
|
||||
gridLayout
|
||||
});
|
||||
|
||||
return json(prefs);
|
||||
await savePrefs(userId, current);
|
||||
return json(current);
|
||||
} catch (error) {
|
||||
console.error('Failed to save dashboard preferences:', error);
|
||||
return json({ error: 'Failed to save dashboard preferences' }, { status: 500 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import dependencies from '$lib/data/dependencies.json';
|
||||
import { DEFAULT_GRYPE_IMAGE, DEFAULT_TRIVY_IMAGE } from '$lib/server/scanner';
|
||||
|
||||
// Extract version tag from image string (e.g., "anchore/grype:v0.110.0" -> "v0.110.0")
|
||||
function imageTag(image: string): string {
|
||||
return image.split(':')[1] || 'latest';
|
||||
}
|
||||
|
||||
// External tools used by Dockhand (Docker images)
|
||||
const externalTools = [
|
||||
{
|
||||
name: 'anchore/grype',
|
||||
version: 'latest',
|
||||
version: imageTag(DEFAULT_GRYPE_IMAGE),
|
||||
license: 'Apache-2.0',
|
||||
repository: 'https://github.com/anchore/grype'
|
||||
},
|
||||
{
|
||||
name: 'aquasec/trivy',
|
||||
version: 'latest',
|
||||
version: imageTag(DEFAULT_TRIVY_IMAGE),
|
||||
license: 'Apache-2.0',
|
||||
repository: 'https://github.com/aquasecurity/trivy'
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { cleanPem } from '$lib/utils/pem';
|
||||
import { unregisterSchedule } from '$lib/server/scheduler';
|
||||
import { closeEdgeConnection } from '$lib/server/hawser';
|
||||
import { computeAuditDiff } from '$lib/utils/diff';
|
||||
import { deleteEnvironmentIcon } from '$lib/server/env-icons';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
@@ -167,6 +168,9 @@ export const DELETE: RequestHandler = async (event) => {
|
||||
return json({ error: 'Cannot delete this environment' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Clean up custom icon file if exists
|
||||
deleteEnvironmentIcon(id);
|
||||
|
||||
// Clean up public IP entry for this environment
|
||||
await deleteEnvironmentPublicIp(id);
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getEnvironment, updateEnvironment } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { saveEnvironmentIcon, deleteEnvironmentIcon, getEnvironmentIconBuffer } from '$lib/server/env-icons';
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const id = parseInt(params.id);
|
||||
const buffer = getEnvironmentIconBuffer(id);
|
||||
|
||||
if (!buffer) {
|
||||
return json({ error: 'No custom icon' }, { status: 404 });
|
||||
}
|
||||
|
||||
return new Response(new Uint8Array(buffer), {
|
||||
headers: {
|
||||
'Content-Type': 'image/webp',
|
||||
'Cache-Control': 'public, max-age=3600'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const id = parseInt(params.id);
|
||||
const env = await getEnvironment(id);
|
||||
if (!env) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
if (!data.image || typeof data.image !== 'string') {
|
||||
return json({ error: 'Missing image data' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate size (~200KB base64 limit)
|
||||
if (data.image.length > 300_000) {
|
||||
return json({ error: 'Image too large' }, { status: 400 });
|
||||
}
|
||||
|
||||
saveEnvironmentIcon(id, data.image);
|
||||
const iconValue = `custom:env-${id}.webp`;
|
||||
await updateEnvironment(id, { icon: iconValue });
|
||||
|
||||
return json({ success: true, icon: iconValue });
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const id = parseInt(params.id);
|
||||
const env = await getEnvironment(id);
|
||||
if (!env) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
deleteEnvironmentIcon(id);
|
||||
await updateEnvironment(id, { icon: 'globe' });
|
||||
|
||||
return json({ success: true, icon: 'globe' });
|
||||
};
|
||||
@@ -13,7 +13,23 @@ const TIMEZONE_ALIASES: Record<string, string> = {
|
||||
'Europe/Kyiv': 'Europe/Kiev',
|
||||
'Asia/Ho_Chi_Minh': 'Asia/Saigon',
|
||||
'America/Nuuk': 'America/Godthab',
|
||||
'Pacific/Kanton': 'Pacific/Enderbury'
|
||||
'Pacific/Kanton': 'Pacific/Enderbury',
|
||||
// Modern IANA names that Node.js ICU maps to legacy names
|
||||
'Asia/Kolkata': 'Asia/Calcutta',
|
||||
'Asia/Kathmandu': 'Asia/Katmandu',
|
||||
'Asia/Yangon': 'Asia/Rangoon',
|
||||
'Asia/Kashgar': 'Asia/Urumqi',
|
||||
'Atlantic/Faroe': 'Atlantic/Faeroe',
|
||||
'Europe/Uzhgorod': 'Europe/Kiev',
|
||||
'Europe/Zaporozhye': 'Europe/Kiev',
|
||||
'America/Atikokan': 'America/Coral_Harbour',
|
||||
'America/Argentina/Buenos_Aires': 'America/Buenos_Aires',
|
||||
'America/Argentina/Catamarca': 'America/Catamarca',
|
||||
'America/Argentina/Cordoba': 'America/Cordoba',
|
||||
'America/Argentina/Jujuy': 'America/Jujuy',
|
||||
'America/Argentina/Mendoza': 'America/Mendoza',
|
||||
'Pacific/Pohnpei': 'Pacific/Ponape',
|
||||
'Pacific/Chuuk': 'Pacific/Truk'
|
||||
};
|
||||
|
||||
function normalizeTimezone(tz: string): string {
|
||||
|
||||
@@ -4,9 +4,10 @@ import {
|
||||
getGitRepository,
|
||||
updateGitRepository,
|
||||
deleteGitRepository,
|
||||
getGitCredentials
|
||||
getGitCredentials,
|
||||
getGitStacksByRepositoryId
|
||||
} from '$lib/server/db';
|
||||
import { deleteRepositoryFiles } from '$lib/server/git';
|
||||
import { deleteRepositoryFiles, deleteGitStackFiles } from '$lib/server/git';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditGitRepository } from '$lib/server/audit';
|
||||
import { computeAuditDiff } from '$lib/utils/diff';
|
||||
@@ -112,7 +113,13 @@ export const DELETE: RequestHandler = async (event) => {
|
||||
return json({ error: 'Repository not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Delete repository files first
|
||||
// Delete git stack clone directories before cascade deletes the DB rows
|
||||
const stacks = await getGitStacksByRepositoryId(id);
|
||||
for (const stack of stacks) {
|
||||
await deleteGitStackFiles(stack.id, stack.stackName, stack.environmentId);
|
||||
}
|
||||
|
||||
// Delete repository clone directory
|
||||
deleteRepositoryFiles(id);
|
||||
|
||||
const deleted = await deleteGitRepository(id);
|
||||
|
||||
@@ -17,10 +17,10 @@ function verifySignature(payload: string, signature: string | null, secret: stri
|
||||
.createHmac('sha256', secret)
|
||||
.update(payload)
|
||||
.digest('hex');
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(signature),
|
||||
Buffer.from(expectedSignature)
|
||||
);
|
||||
const sigBuf = Buffer.from(signature);
|
||||
const expectedBuf = Buffer.from(expectedSignature);
|
||||
if (sigBuf.length !== expectedBuf.length) return false;
|
||||
return crypto.timingSafeEqual(sigBuf, expectedBuf);
|
||||
}
|
||||
|
||||
// GitLab uses X-Gitlab-Token which should match exactly
|
||||
|
||||
@@ -17,10 +17,10 @@ function verifySignature(payload: string, signature: string | null, secret: stri
|
||||
.createHmac('sha256', secret)
|
||||
.update(payload)
|
||||
.digest('hex');
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(signature),
|
||||
Buffer.from(expectedSignature)
|
||||
);
|
||||
const sigBuf = Buffer.from(signature);
|
||||
const expectedBuf = Buffer.from(expectedSignature);
|
||||
if (sigBuf.length !== expectedBuf.length) return false;
|
||||
return crypto.timingSafeEqual(sigBuf, expectedBuf);
|
||||
}
|
||||
|
||||
// GitLab uses X-Gitlab-Token which should match exactly
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,10 +2,14 @@ import { json } from '@sveltejs/kit';
|
||||
import { removeImage, inspectImage } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditImage } from '$lib/server/audit';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
const { params, url, cookies } = event;
|
||||
const invalid = validateDockerIdParam(params.id, 'image');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const force = url.searchParams.get('force') === 'true';
|
||||
|
||||
@@ -3,9 +3,13 @@ import { exportImage, inspectImage } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { createGzip } from 'zlib';
|
||||
import { Readable } from 'stream';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'image');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
|
||||
@@ -2,8 +2,12 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getImageHistory } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'image');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { tagImage } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'image');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
|
||||
@@ -10,7 +10,9 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
|
||||
// Return as plain text if requested
|
||||
if (url.searchParams.get('format') === 'text') {
|
||||
return text(content);
|
||||
return text(content, {
|
||||
headers: { 'content-type': 'text/plain; charset=utf-8' }
|
||||
});
|
||||
}
|
||||
|
||||
return json({ content });
|
||||
|
||||
@@ -10,7 +10,9 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
|
||||
// Return as plain text if requested
|
||||
if (url.searchParams.get('format') === 'text') {
|
||||
return text(content);
|
||||
return text(content, {
|
||||
headers: { 'content-type': 'text/plain; charset=utf-8' }
|
||||
});
|
||||
}
|
||||
|
||||
return json({ content });
|
||||
|
||||
@@ -3,8 +3,12 @@ import type { RequestHandler } from './$types';
|
||||
import { removeNetwork, inspectNetwork } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditNetwork } from '$lib/server/audit';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'network');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
@@ -32,6 +36,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
const { params, url, cookies } = event;
|
||||
const invalid = validateDockerIdParam(params.id, 'network');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
|
||||
@@ -3,9 +3,13 @@ import type { RequestHandler } from './$types';
|
||||
import { connectContainerToNetwork, inspectNetwork } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditNetwork } from '$lib/server/audit';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, url, request, cookies } = event;
|
||||
const invalid = validateDockerIdParam(params.id, 'network');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
@@ -25,6 +29,9 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'Container ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const invalidContainer = validateDockerIdParam(containerId, 'container');
|
||||
if (invalidContainer) return invalidContainer;
|
||||
|
||||
// Get network name for audit
|
||||
let networkName = params.id;
|
||||
try {
|
||||
|
||||
@@ -3,9 +3,13 @@ import type { RequestHandler } from './$types';
|
||||
import { disconnectContainerFromNetwork, inspectNetwork } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditNetwork } from '$lib/server/audit';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { params, url, request, cookies } = event;
|
||||
const invalid = validateDockerIdParam(params.id, 'network');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
@@ -25,6 +29,9 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'Container ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const invalidContainer = validateDockerIdParam(containerId, 'container');
|
||||
if (invalidContainer) return invalidContainer;
|
||||
|
||||
// Get network name for audit
|
||||
let networkName = params.id;
|
||||
try {
|
||||
|
||||
@@ -2,8 +2,12 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { inspectNetwork } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'network');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
|
||||
@@ -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,46 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
...networkEnvVars
|
||||
];
|
||||
|
||||
// Pin Docker API version so the updater's bundled Docker CLI
|
||||
// doesn't request a version newer than the host daemon supports
|
||||
// (e.g. Synology DSM with Docker 24.x / API 1.43)
|
||||
if (process.env.DOCKER_API_VERSION) {
|
||||
updaterEnv.push(`DOCKER_API_VERSION=${process.env.DOCKER_API_VERSION}`);
|
||||
console.log(`[SelfUpdate] Forwarding explicit DOCKER_API_VERSION: ${process.env.DOCKER_API_VERSION}`);
|
||||
} else {
|
||||
try {
|
||||
const versionResp = await localDockerFetch('/version');
|
||||
if (versionResp.ok) {
|
||||
const versionInfo = await versionResp.json() as { ApiVersion?: string };
|
||||
if (versionInfo.ApiVersion) {
|
||||
updaterEnv.push(`DOCKER_API_VERSION=${versionInfo.ApiVersion}`);
|
||||
console.log(`[SelfUpdate] Using negotiated Docker API version: ${versionInfo.ApiVersion}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
console.warn('[SelfUpdate] Could not detect Docker API version, updater will negotiate on its own');
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +421,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,6 +35,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
|
||||
const containerId = getOwnContainerId();
|
||||
if (!containerId) {
|
||||
console.log('[SelfUpdate] Not running in Docker, skipping update check');
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
error: 'Not running in Docker'
|
||||
@@ -37,6 +46,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
// Inspect own container to get current image info
|
||||
const inspectResponse = await localDockerFetch(`/containers/${containerId}/json`);
|
||||
if (!inspectResponse.ok) {
|
||||
console.log(`[SelfUpdate] Failed to inspect container ${containerId.substring(0, 12)}: ${inspectResponse.status}`);
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
error: 'Failed to inspect own container'
|
||||
@@ -53,7 +63,10 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const currentImageId = inspectData.Image || '';
|
||||
const containerName = inspectData.Name?.replace(/^\//, '') || '';
|
||||
|
||||
console.log(`[SelfUpdate] Container: ${containerId.substring(0, 12)}, image: ${currentImage}, tag: ${currentImage.split(':').pop() || 'latest'}`);
|
||||
|
||||
if (!currentImage) {
|
||||
console.log('[SelfUpdate] Could not determine current image from inspect data');
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
error: 'Could not determine current image'
|
||||
@@ -65,6 +78,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
|
||||
// Digest-based images (e.g. image@sha256:...) can't be checked for updates
|
||||
if (currentImage.includes('@sha256:')) {
|
||||
console.log('[SelfUpdate] Image pinned by digest, cannot check for updates');
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
@@ -86,6 +100,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
// Version-based check: compare against latest released version from changelog
|
||||
const currentTagVersion = versionMatch[1];
|
||||
const suffix = versionMatch[2] || ''; // '-baseline' or ''
|
||||
console.log(`[SelfUpdate] Version-based check: current=${currentTagVersion}${suffix}`);
|
||||
|
||||
try {
|
||||
const changelogResponse = await fetch(
|
||||
@@ -94,6 +109,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
);
|
||||
|
||||
if (!changelogResponse.ok) {
|
||||
console.log(`[SelfUpdate] Failed to fetch changelog from GitHub: ${changelogResponse.status}`);
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
@@ -114,6 +130,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const latestRelease = changelog.find(entry => !entry.comingSoon);
|
||||
|
||||
if (!latestRelease) {
|
||||
console.log('[SelfUpdate] No released version found in changelog');
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
@@ -125,12 +142,14 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
|
||||
const latestVersion = latestRelease.version;
|
||||
const hasNewer = compareVersions(latestVersion, currentTagVersion) > 0;
|
||||
console.log(`[SelfUpdate] Latest changelog version: ${latestVersion}, current: ${currentTagVersion}, hasNewer: ${hasNewer}`);
|
||||
|
||||
if (hasNewer) {
|
||||
// Build new image tag preserving registry prefix and suffix
|
||||
const newTag = `v${latestVersion.replace(/^v/, '')}${suffix}`;
|
||||
const newImage = `${imageWithoutTag}:${newTag}`;
|
||||
|
||||
console.log(`[SelfUpdate] Update available: ${currentImage} → ${newImage}`);
|
||||
return json({
|
||||
updateAvailable: true,
|
||||
currentImage,
|
||||
@@ -141,6 +160,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[SelfUpdate] Up to date (version ${currentTagVersion})`);
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
@@ -148,6 +168,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
isComposeManaged
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(`[SelfUpdate] Version check failed: ${err}`);
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
@@ -159,10 +180,12 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
}
|
||||
|
||||
// Digest-based check for mutable tags (:latest, :baseline, etc.)
|
||||
console.log(`[SelfUpdate] Digest-based check for mutable tag: ${tag}`);
|
||||
|
||||
// Inspect image via local Docker socket to get RepoDigests
|
||||
const imageResponse = await localDockerFetch(`/images/${encodeURIComponent(currentImageId)}/json`);
|
||||
if (!imageResponse.ok) {
|
||||
console.log(`[SelfUpdate] Failed to inspect image ${currentImageId}: ${imageResponse.status}`);
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
@@ -184,6 +207,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
if (localDigests.length === 0) {
|
||||
console.log('[SelfUpdate] No RepoDigests found — local/untagged image, cannot check registry');
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
@@ -194,9 +218,12 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[SelfUpdate] Local digests: ${localDigests.map(d => d.substring(0, 19)).join(', ')}`);
|
||||
|
||||
// Query registry for latest digest
|
||||
const registryDigest = await getRegistryManifestDigest(currentImage);
|
||||
if (!registryDigest) {
|
||||
console.log(`[SelfUpdate] Could not query registry for ${currentImage}`);
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
currentImage,
|
||||
@@ -208,6 +235,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
}
|
||||
|
||||
const hasUpdate = !localDigests.includes(registryDigest);
|
||||
console.log(`[SelfUpdate] Registry digest: ${registryDigest.substring(0, 19)}, match: ${!hasUpdate}, updateAvailable: ${hasUpdate}`);
|
||||
|
||||
return json({
|
||||
updateAvailable: hasUpdate,
|
||||
@@ -219,6 +247,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
isComposeManaged
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(`[SelfUpdate] Check failed with error: ${err}`);
|
||||
return json({
|
||||
updateAvailable: false,
|
||||
error: 'Check failed: ' + String(err)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { refreshSystemJobs } from '$lib/server/scheduler';
|
||||
import { sendToEventSubprocess, sendToMetricsSubprocess } from '$lib/server/subprocess-manager';
|
||||
import { DEFAULT_GRYPE_IMAGE, DEFAULT_TRIVY_IMAGE } from '$lib/server/scanner';
|
||||
|
||||
export type TimeFormat = '12h' | '24h';
|
||||
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
|
||||
@@ -65,10 +66,17 @@ export interface GeneralSettings {
|
||||
gridFontSize: string;
|
||||
terminalFont: string;
|
||||
editorFont: string;
|
||||
// Compact ports
|
||||
compactPorts: boolean;
|
||||
// Log timestamp formatting
|
||||
formatLogTimestamps: boolean;
|
||||
// External stack paths
|
||||
externalStackPaths: string[];
|
||||
// Primary stack location
|
||||
primaryStackLocation: string | null;
|
||||
// Scanner images
|
||||
defaultGrypeImage: string;
|
||||
defaultTrivyImage: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled'> = {
|
||||
@@ -85,13 +93,19 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
|
||||
eventCollectionMode: 'stream',
|
||||
eventPollInterval: 60000,
|
||||
metricsCollectionInterval: 30000,
|
||||
compactPorts: false,
|
||||
formatLogTimestamps: false,
|
||||
lightTheme: 'default',
|
||||
darkTheme: 'default',
|
||||
font: 'system',
|
||||
fontSize: 'normal',
|
||||
gridFontSize: 'normal',
|
||||
terminalFont: 'system-mono',
|
||||
editorFont: 'system-mono'
|
||||
editorFont: 'system-mono',
|
||||
externalStackPaths: [],
|
||||
primaryStackLocation: null,
|
||||
defaultGrypeImage: DEFAULT_GRYPE_IMAGE,
|
||||
defaultTrivyImage: DEFAULT_TRIVY_IMAGE
|
||||
};
|
||||
|
||||
const VALID_LIGHT_THEMES = ['default', 'catppuccin', 'rose-pine', 'nord', 'solarized', 'gruvbox', 'alucard', 'github', 'material', 'atom-one'];
|
||||
@@ -140,8 +154,12 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
gridFontSize,
|
||||
terminalFont,
|
||||
editorFont,
|
||||
compactPorts,
|
||||
formatLogTimestamps,
|
||||
externalStackPaths,
|
||||
primaryStackLocation
|
||||
primaryStackLocation,
|
||||
defaultGrypeImage,
|
||||
defaultTrivyImage
|
||||
] = await Promise.all([
|
||||
getSetting('confirm_destructive'),
|
||||
getSetting('show_stopped_containers'),
|
||||
@@ -169,8 +187,12 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
getSetting('theme_grid_font_size'),
|
||||
getSetting('theme_terminal_font'),
|
||||
getSetting('theme_editor_font'),
|
||||
getSetting('compact_ports'),
|
||||
getSetting('format_log_timestamps'),
|
||||
getExternalStackPaths(),
|
||||
getPrimaryStackLocation()
|
||||
getPrimaryStackLocation(),
|
||||
getSetting('default_grype_image'),
|
||||
getSetting('default_trivy_image')
|
||||
]);
|
||||
|
||||
const settings: GeneralSettings = {
|
||||
@@ -200,8 +222,12 @@ 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,
|
||||
formatLogTimestamps: formatLogTimestamps ?? DEFAULT_SETTINGS.formatLogTimestamps,
|
||||
externalStackPaths,
|
||||
primaryStackLocation
|
||||
primaryStackLocation,
|
||||
defaultGrypeImage: defaultGrypeImage ?? DEFAULT_GRYPE_IMAGE,
|
||||
defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE
|
||||
};
|
||||
|
||||
return json(settings);
|
||||
@@ -219,7 +245,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, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage } = body;
|
||||
|
||||
if (confirmDestructive !== undefined) {
|
||||
await setSetting('confirm_destructive', confirmDestructive);
|
||||
@@ -312,6 +338,12 @@ 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 (formatLogTimestamps !== undefined) {
|
||||
await setSetting('format_log_timestamps', formatLogTimestamps);
|
||||
}
|
||||
if (externalStackPaths !== undefined && Array.isArray(externalStackPaths)) {
|
||||
// Filter to valid non-empty strings
|
||||
const validPaths = externalStackPaths.filter((p: unknown) => typeof p === 'string' && p.trim());
|
||||
@@ -326,6 +358,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
await setPrimaryStackLocation(null);
|
||||
}
|
||||
}
|
||||
if (defaultGrypeImage !== undefined && typeof defaultGrypeImage === 'string') {
|
||||
await setSetting('default_grype_image', defaultGrypeImage);
|
||||
}
|
||||
if (defaultTrivyImage !== undefined && typeof defaultTrivyImage === 'string') {
|
||||
await setSetting('default_trivy_image', defaultTrivyImage);
|
||||
}
|
||||
|
||||
// Fetch all settings in parallel for the response
|
||||
const [
|
||||
@@ -355,8 +393,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
gridFontSizeVal,
|
||||
terminalFontVal,
|
||||
editorFontVal,
|
||||
compactPortsVal,
|
||||
formatLogTimestampsVal,
|
||||
externalStackPathsVal,
|
||||
primaryStackLocationVal
|
||||
primaryStackLocationVal,
|
||||
defaultGrypeImageVal,
|
||||
defaultTrivyImageVal
|
||||
] = await Promise.all([
|
||||
getSetting('confirm_destructive'),
|
||||
getSetting('show_stopped_containers'),
|
||||
@@ -384,8 +426,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
getSetting('theme_grid_font_size'),
|
||||
getSetting('theme_terminal_font'),
|
||||
getSetting('theme_editor_font'),
|
||||
getSetting('compact_ports'),
|
||||
getSetting('format_log_timestamps'),
|
||||
getExternalStackPaths(),
|
||||
getPrimaryStackLocation()
|
||||
getPrimaryStackLocation(),
|
||||
getSetting('default_grype_image'),
|
||||
getSetting('default_trivy_image')
|
||||
]);
|
||||
|
||||
const settings: GeneralSettings = {
|
||||
@@ -415,8 +461,12 @@ 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,
|
||||
formatLogTimestamps: formatLogTimestampsVal ?? DEFAULT_SETTINGS.formatLogTimestamps,
|
||||
externalStackPaths: externalStackPathsVal,
|
||||
primaryStackLocation: primaryStackLocationVal
|
||||
primaryStackLocation: primaryStackLocationVal,
|
||||
defaultGrypeImage: defaultGrypeImageVal ?? DEFAULT_GRYPE_IMAGE,
|
||||
defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE
|
||||
};
|
||||
|
||||
return json(settings);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { getEnvSetting, setEnvSetting, getEnvironment } from '$lib/server/db';
|
||||
import { getEnvSetting, setEnvSetting, getEnvironment, setSetting } from '$lib/server/db';
|
||||
import {
|
||||
checkScannerAvailability,
|
||||
getScannerVersions,
|
||||
@@ -15,6 +15,8 @@ export interface ScannerSettings {
|
||||
scanner: ScannerType;
|
||||
grypeArgs: string;
|
||||
trivyArgs: string;
|
||||
grypeImage: string;
|
||||
trivyImage: string;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
@@ -39,7 +41,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const settings: ScannerSettings = {
|
||||
scanner: await getEnvSetting('vulnerability_scanner', parsedEnvId) || 'none',
|
||||
grypeArgs: await getEnvSetting('grype_cli_args', parsedEnvId) || globalDefaults.grypeArgs,
|
||||
trivyArgs: await getEnvSetting('trivy_cli_args', parsedEnvId) || globalDefaults.trivyArgs
|
||||
trivyArgs: await getEnvSetting('trivy_cli_args', parsedEnvId) || globalDefaults.trivyArgs,
|
||||
grypeImage: globalDefaults.grypeImage,
|
||||
trivyImage: globalDefaults.trivyImage
|
||||
};
|
||||
|
||||
// Fast path: return just settings without Docker checks
|
||||
@@ -80,7 +84,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { scanner, grypeArgs, trivyArgs, envId } = body;
|
||||
const { scanner, grypeArgs, trivyArgs, grypeImage, trivyImage, envId } = body;
|
||||
const parsedEnvId = envId ? parseInt(envId) : undefined;
|
||||
|
||||
// Permission check with environment context
|
||||
@@ -104,6 +108,12 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
if (trivyArgs !== undefined) {
|
||||
await setEnvSetting('trivy_cli_args', trivyArgs, parsedEnvId);
|
||||
}
|
||||
if (grypeImage !== undefined && typeof grypeImage === 'string') {
|
||||
await setSetting('default_grype_image', grypeImage);
|
||||
}
|
||||
if (trivyImage !== undefined && typeof trivyImage === 'string') {
|
||||
await setSetting('default_trivy_image', trivyImage);
|
||||
}
|
||||
|
||||
// Get global defaults for fallback
|
||||
const globalDefaults = await getGlobalScannerDefaults();
|
||||
@@ -113,7 +123,9 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
||||
settings: {
|
||||
scanner: await getEnvSetting('vulnerability_scanner', parsedEnvId) || 'none',
|
||||
grypeArgs: await getEnvSetting('grype_cli_args', parsedEnvId) || globalDefaults.grypeArgs,
|
||||
trivyArgs: await getEnvSetting('trivy_cli_args', parsedEnvId) || globalDefaults.trivyArgs
|
||||
trivyArgs: await getEnvSetting('trivy_cli_args', parsedEnvId) || globalDefaults.trivyArgs,
|
||||
grypeImage: globalDefaults.grypeImage,
|
||||
trivyImage: globalDefaults.trivyImage
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -154,6 +166,9 @@ export const DELETE: RequestHandler = async ({ url, cookies }) => {
|
||||
const removed: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
// Get configured scanner images
|
||||
const globalDefaults = await getGlobalScannerDefaults();
|
||||
|
||||
// Determine which images to remove
|
||||
const scannersToRemove: ('grype' | 'trivy')[] =
|
||||
scanner === 'grype' ? ['grype'] :
|
||||
@@ -161,7 +176,7 @@ export const DELETE: RequestHandler = async ({ url, cookies }) => {
|
||||
['grype', 'trivy'];
|
||||
|
||||
for (const scannerType of scannersToRemove) {
|
||||
const imageName = scannerType === 'grype' ? 'anchore/grype' : 'aquasec/trivy';
|
||||
const imageName = scannerType === 'grype' ? globalDefaults.grypeImage.split(':')[0] : globalDefaults.trivyImage.split(':')[0];
|
||||
|
||||
// Find the image
|
||||
const image = images.find((img) =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { readdirSync, statSync, existsSync } from 'node:fs';
|
||||
import { join, basename } from 'node:path';
|
||||
import { readdirSync, statSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { join, basename, isAbsolute } from 'node:path';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
export interface FileEntry {
|
||||
@@ -13,6 +13,49 @@ export interface FileEntry {
|
||||
mode: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/system/files
|
||||
* Create a directory
|
||||
*
|
||||
* Body: { path: string }
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
if (auth.authEnabled && !await auth.can('stacks', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const path = body.path;
|
||||
|
||||
if (!path || typeof path !== 'string') {
|
||||
return json({ error: 'Path is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!isAbsolute(path)) {
|
||||
return json({ error: 'Path must be absolute' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (path.includes('..')) {
|
||||
return json({ error: 'Path must not contain ..' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (existsSync(path)) {
|
||||
return json({ error: 'Path already exists' }, { status: 409 });
|
||||
}
|
||||
|
||||
mkdirSync(path, { recursive: true });
|
||||
|
||||
return json({ success: true, path });
|
||||
} catch (error) {
|
||||
console.error('Error creating directory:', error);
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
return json({ error: `Failed to create directory: ${message}` }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/system/files
|
||||
* Browse Dockhand's local filesystem (for mount browsing)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user