Compare commits

..

110 Commits

Author SHA1 Message Date
jarek 50bc746660 v1.0.23 2026-04-03 13:53:12 +02:00
jarek 81c03b5dc5 v1.0.23 2026-04-03 11:51:42 +02:00
jarek 4a7c971cf8 shims for the baseline build 2026-03-26 08:23:12 +01:00
jarek faa2b9d571 v1.0.22 2026-03-21 14:46:17 +01:00
jarek 2ca41703f2 v1.0.22 2026-03-21 14:29:30 +01:00
Jarek Krochmalski c19d73c509 Update SECURITY.md 2026-03-15 09:37:22 +01:00
Jarek Krochmalski 7e869b582a Create SECURITY.md 2026-03-15 06:13:45 +01:00
Dennis Braun d0e5edcc98 fix: propagate DOCKER_API_VERSION to updater sidecar
The dockhand-updater image ships Docker CLI 29.2.1 (API 1.53), which
fails on hosts running older Docker daemons (e.g. Synology DSM with
Docker 24.0.2 / API 1.43). Every docker command in update.sh returns
"client version 1.53 is too new".

Query the daemon's API version via /version and pass it as
DOCKER_API_VERSION to the updater container env. If the env var is
already set on the main container, forward that instead.

Fixes #759
2026-03-13 18:38:02 +01:00
jarek a621f7abbc v1.0.21 2026-03-13 09:29:02 +01:00
jarek 725798f327 v1.0.21 2026-03-13 08:31:38 +01:00
jarek 83adb275cd v1.0.21 2026-03-13 08:22:46 +01:00
Jarek Krochmalski 80a9c8b60a 1.0.20 2026-03-06 20:03:10 +01:00
Jarek Krochmalski 07be45ace5 1.0.20 2026-03-03 13:02:00 +01:00
Jarek Krochmalski f9bc2a13d1 1.0.20 2026-03-03 12:18:17 +01:00
Jarek Krochmalski a84c11113c 1.0.20 2026-03-03 10:29:01 +01:00
Jarek Krochmalski 464fcb4231 1.0.20 2026-03-03 10:17:41 +01:00
Matt Boris 0c894d906f fix: cap docker API version (fixes #679) 2026-03-03 07:12:56 +01:00
jarek 1c16efd872 v1.0.20 2026-03-02 13:10:03 +01:00
jarek 77ec974d09 v1.0.20 2026-03-02 10:54:30 +01:00
jarek e9e521656c v1.0.20 2026-03-02 10:41:42 +01:00
jarek c618328d83 v1.0.19 2026-03-02 09:12:33 +01:00
jarek 76e8faef83 v1.0.19 2026-03-02 07:59:58 +01:00
jarek 32c2919f05 1.0.18 2026-02-16 16:19:55 +01:00
jarek b2b4d3d975 1.0.18 2026-02-16 15:43:05 +01:00
jarek fa7f3be2f5 1.0.18 2026-02-16 13:37:19 +01:00
jarek c525a99d57 1.0.18 2026-02-16 13:17:09 +01:00
jarek 3f23dfb9f1 1.0.18 2026-02-16 12:59:42 +01:00
jarek e0548f69ef 1.0.18 2026-02-16 12:46:28 +01:00
jarek d4eb5a5237 1.0.18 2026-02-16 09:08:12 +01:00
jarek c2b1708b66 1.0.18 2026-02-16 09:05:51 +01:00
jarek 5633e063e1 1.0.18 2026-02-16 08:53:25 +01:00
jarek eade47e962 1.0.18 2026-02-16 08:47:29 +01:00
jarek 3f99719cda 1.0.18 2026-02-16 08:46:56 +01:00
jarek de243ce06d cleaner logos 2026-02-16 08:16:51 +01:00
jarek dd0e778bf9 1.0.18 updater 2026-02-16 08:16:37 +01:00
Aaron Bird 52de17e4e6 feat: add Mattermost notification support
Add mmost:// and mmosts:// (secure) Apprise URL support for Mattermost
incoming webhooks. Supports optional botname override and custom paths.

- Add sendMattermost() function following existing notification patterns
- Update NotificationModal with Mattermost in examples and description

🤖 Generated with AI assistance (Claude Opus 4.5)
2026-02-13 09:35:39 +01:00
Florian Hoss 3140e4f074 Add Bearer token auth support to sendNtfy
Enhanced the sendNtfy function to support Bearer token authentication in addition to Basic auth. Now, URLs in the format token@host/topic will use Bearer tokens, improving flexibility for different notification server setups.
2026-02-13 09:28:17 +01:00
jarek 988e65bd5b 1.0.17 2026-02-09 20:50:41 +01:00
jarek a5360e9d53 1.0.16 2026-02-09 14:48:48 +01:00
jarek c9239f195a 1.0.16 2026-02-09 10:15:21 +01:00
jarek 9daa647709 1.0.15 2026-02-08 10:27:56 +01:00
jarek 38fa758d8a 1.0.15 2026-02-08 10:21:18 +01:00
jarek e829e60217 1.0.15 2026-02-08 09:59:06 +01:00
jarek 7ed20ece39 release job 2026-02-07 09:59:30 +01:00
Jarek Krochmalski 6149b3d935 Delete static/logo_dark.webp 2026-02-06 17:06:27 +01:00
Jarek Krochmalski 139e798e77 Delete static/logo_light.webp 2026-02-06 17:06:10 +01:00
Jarek Krochmalski 2f7f5efc27 Delete static/logo.png 2026-02-06 17:05:46 +01:00
TimElschner 4cd7f1c4ef Add missing static assets (favicons, logos, webmanifest)
The static/ directory containing favicons, apple-touch-icons, logos,
robots.txt and site.webmanifest was not included in the repository,
even though app.html references these files. This causes missing
icons and broken manifest when building from source.
2026-02-06 16:54:29 +01:00
Matt Boris 2e1cb7fdaf chore(gitignore): add local dev auth files 2026-02-06 16:48:29 +01:00
Matt Boris a46154acf7 chore(login): autofocus on the username field 2026-02-06 16:48:29 +01:00
Matt Boris 4627b70fcf chore(mfa): autofocus on the mfa code field on login 2026-02-06 16:48:29 +01:00
Jarek Krochmalski 54a14889de Update bug-report.yml 2026-02-06 08:13:26 +01:00
Jarek Krochmalski 79c02984f0 Update bug-report.yml 2026-02-06 08:10:08 +01:00
Jarek Krochmalski b2989d0aaf Update bug-report.yml 2026-02-06 08:09:38 +01:00
Jarek Krochmalski f9fdfef4cb Update bug-report.yml 2026-02-06 08:08:35 +01:00
Jarek Krochmalski 927858578b Update bug-report.yml 2026-02-06 08:07:32 +01:00
shamoon afb0e734ee Add bug report, FR templates, config 2026-02-06 08:04:44 +01:00
shamoon 6122fa43da Add basic PR template 2026-02-06 08:04:44 +01:00
shamoon 45bedca86d Add basic CONTRIBUTING.md 2026-02-06 08:04:44 +01:00
shamoon 1aca2a10cb Ignore node_modules, .svelte-kit, and bun.lock 2026-02-06 08:04:44 +01:00
shamoon 70e2166548 Only show on update 2026-02-03 09:25:01 +01:00
shamoon ced84b583d Add option to pull image before container update 2026-02-03 09:25:01 +01:00
jarek 53be8f8b20 1.0.14 2026-01-31 09:35:19 +01:00
jarek 236475577b 1.0.13 2026-01-28 07:34:12 +01:00
Jarek Krochmalski 7d6f6f2efd Update README.md 2026-01-24 06:13:41 +01:00
Jarek Krochmalski 193dc44a71 Update README.md 2026-01-24 06:03:27 +01:00
jarek 1036cd0ec6 #96 2026-01-23 14:39:16 +01:00
Viktoras 1a95f5ad05 Honor DATA_DIR env var in sqlite operations related to hawser connections 2026-01-23 14:29:24 +01:00
jarek fd35a0adc0 1.0.12 2026-01-22 16:46:42 +01:00
jarek dd6c5fd3e5 1.0.12 2026-01-22 16:46:17 +01:00
jarek 0303f54e2b 1.0.12 2026-01-22 16:23:26 +01:00
jarek 7f9862f9a0 1.0.11 2026-01-20 15:39:08 +01:00
FlintyLemming 750c9c1910 feat: add SYS_RAWIO to container capabilities list 2026-01-19 19:01:51 +01:00
Jarek Krochmalski 566d80019d Create ai-opt-out 2026-01-19 13:00:00 +01:00
Jarek Krochmalski 261d94032c Update README.md 2026-01-19 12:50:10 +01:00
Jarek Krochmalski 6cb948e84c Update README.md 2026-01-19 12:48:48 +01:00
Jarek Krochmalski 80a5bbde99 Update README.md 2026-01-19 12:44:05 +01:00
Jarek Krochmalski fd744ed9a2 Update package.json 2026-01-19 08:16:41 +01:00
jarek 6d9b509493 1.0.10 2026-01-18 09:56:38 +01:00
jarek e8ab07ec3f 1.0.9 2026-01-17 15:06:14 +01:00
jarek 107e9c3758 1.0.8 2026-01-14 08:18:20 +01:00
sieren f972378117 Mobile: Only show total of stacks
The detailed display of stacks (following x/x/x/x) is too wide
for mobile display.
So for mobile display only, we limit this information to the total number
of stacks.
2026-01-12 14:25:53 +01:00
sieren f588ed787b Improve Environment Layout on Mobile
Do not use the grid layout on mobile but show each tile
in a scrollable list instead.
2026-01-12 14:25:53 +01:00
Jarek Krochmalski 6baf6c23e8 1.0.7 2026-01-11 09:01:42 +01:00
Jarek Krochmalski 6382b4083e 1.0.7 2026-01-11 07:17:25 +01:00
Jarek Krochmalski b269b8d50d 1.0.7 2026-01-11 07:16:18 +01:00
jarek 410d542c58 1.0.6 2026-01-03 14:56:20 +01:00
jarek a02115e6bc missing scripts 2026-01-03 13:21:38 +01:00
jarek 86e4c9eb56 1.0.5 2026-01-03 09:10:38 +01:00
jarek c46870afd1 1.0.5 2026-01-02 15:39:51 +01:00
jarek a8a5623c10 1.0.5 2026-01-02 15:29:56 +01:00
Jarek Krochmalski 059ecbb1dc Update README.md 2026-01-02 13:38:16 +01:00
Jarek Krochmalski 3eab42169c Update README.md 2026-01-02 13:36:32 +01:00
jarek 6a7116a5b7 1.0.5 2026-01-02 12:24:43 +01:00
jarek 215f52b1f0 1.0.5 2026-01-01 16:32:08 +01:00
jarek de62327a07 1.0.5 2026-01-01 16:05:10 +01:00
jarek cd6544aedb 1.0.5 2026-01-01 16:00:34 +01:00
jarek c60db2930c compose example 2025-12-29 15:29:33 +01:00
Jarek Krochmalski 695acd922e bmac 2025-12-29 13:46:13 +01:00
Jarek Krochmalski fcb36c4646 bmac 2025-12-29 13:39:36 +01:00
Jarek Krochmalski 53ca99ac77 Update README.md 2025-12-29 13:37:26 +01:00
Jarek Krochmalski 81fcc28d0b Update LICENSE.txt 2025-12-29 09:27:27 +01:00
jarek 522154cd68 ignore 2025-12-29 09:08:29 +01:00
jarek 9db6e67a61 drizzle config 2025-12-29 09:08:06 +01:00
Jarek Krochmalski ba05d16d79 cleanup .DS_Store 2025-12-29 08:55:55 +01:00
jarek f4a57ecfd3 proper src structure, dockerfile, entrypoint 2025-12-29 08:40:56 +01:00
jarek ab8743bdae proper src structure, dockerfile, entrypoint 2025-12-29 08:40:11 +01:00
Jarek Krochmalski e536388a7a Update README.md 2025-12-29 06:47:46 +01:00
Jarek Krochmalski 497fbdb635 Update README.md 2025-12-29 06:47:22 +01:00
Jarek Krochmalski 53d60fdddd Update README.md 2025-12-28 21:40:06 +01:00
260 changed files with 2729 additions and 41021 deletions
+4 -4
View File
@@ -37,7 +37,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
" - busybox" \
" - tzdata" \
" - docker-cli" \
" - docker-compose=5.1.4-r5" \
" - docker-compose=5.0.2-r1" \
" - docker-cli-buildx" \
" - sqlite" \
" - postgresql-client" \
@@ -77,8 +77,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Copy package files and install dependencies (--ignore-scripts blocks malicious postinstall hooks)
COPY package.json package-lock.json ./
RUN MAKEFLAGS="-j$(nproc)" npm ci --ignore-scripts \
&& MAKEFLAGS="-j$(nproc)" npm rebuild better-sqlite3 argon2
RUN npm ci --ignore-scripts \
&& npm rebuild better-sqlite3 argon2
# Copy source code and build
COPY . .
@@ -93,7 +93,7 @@ RUN cp -r node_modules/better-sqlite3/build /tmp/better-sqlite3-build \
&& rm -rf node_modules/@types /tmp/better-sqlite3-build
# Build Go collector
FROM --platform=$BUILDPLATFORM golang:1.25.11 AS go-builder
FROM --platform=$BUILDPLATFORM golang:1.25.8 AS go-builder
ARG TARGETARCH
WORKDIR /app
COPY collector/ ./collector/
+13 -26
View File
@@ -18,33 +18,25 @@ FROM node:24-alpine AS app-builder
WORKDIR /app
# Install build dependencies
RUN apk add --no-cache git curl python3 make g++ gcc musl-dev
RUN apk add --no-cache git curl python3 make g++
# Build getrandom shim for old kernels (< 3.17) that lack the syscall
COPY shims/getrandom-shim.c /tmp/
RUN gcc -shared -fPIC -O2 -o /tmp/libgetrandom-shim.so /tmp/getrandom-shim.c
# Copy package files and install dependencies (--ignore-scripts blocks malicious postinstall hooks)
# Copy package files and install dependencies
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts \
&& npm rebuild better-sqlite3 argon2
RUN npm ci
# Copy source code and build
COPY . .
RUN npm run build
# Production dependencies only
# Preserve better-sqlite3 native addon (no prebuilds exist for Node 24 ABI 137)
RUN cp -r node_modules/better-sqlite3/build /tmp/better-sqlite3-build \
&& rm -rf node_modules \
&& npm ci --omit=dev --ignore-scripts \
&& cp -r /tmp/better-sqlite3-build node_modules/better-sqlite3/build \
&& rm -rf node_modules/@types /tmp/better-sqlite3-build
# Production dependencies only (rebuilds native addons against musl)
RUN rm -rf node_modules \
&& npm ci --omit=dev \
&& rm -rf node_modules/@types
# -----------------------------------------------------------------------------
# Stage 2: Go Collector Builder
# -----------------------------------------------------------------------------
FROM golang:1.25.8 AS go-builder
FROM golang:1.24 AS go-builder
WORKDIR /app
COPY collector/ ./collector/
RUN cd collector && CGO_ENABLED=0 go build -o /app/bin/collection-worker .
@@ -70,10 +62,9 @@ RUN apk add --no-cache \
su-exec \
libstdc++
# Create docker compose plugin symlink (skip if package already installed it there)
# Create docker compose plugin symlink
RUN mkdir -p /usr/libexec/docker/cli-plugins \
&& [ -x /usr/libexec/docker/cli-plugins/docker-compose ] \
|| ln -sf /usr/bin/docker-compose /usr/libexec/docker/cli-plugins/docker-compose
&& ln -sf /usr/bin/docker-compose /usr/libexec/docker/cli-plugins/docker-compose
# Create dockhand user and group
RUN addgroup -g 1001 dockhand \
@@ -89,8 +80,7 @@ ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
DATA_DIR=/app/data \
HOME=/home/dockhand \
PUID=1001 \
PGID=1001 \
LD_PRELOAD=/usr/lib/libgetrandom-shim.so
PGID=1001
# Copy application files with correct ownership
COPY --from=app-builder --chown=dockhand:dockhand /app/node_modules ./node_modules
@@ -108,9 +98,6 @@ COPY --chown=dockhand:dockhand drizzle-pg/ ./drizzle-pg/
# Copy legal documents
COPY --chown=dockhand:dockhand LICENSE.txt PRIVACY.txt ./
# Copy getrandom shim for old kernels (Synology DS1513+ with kernel 3.10.x)
COPY --from=app-builder /tmp/libgetrandom-shim.so /usr/lib/libgetrandom-shim.so
# Copy entrypoint script
COPY docker-entrypoint-node.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
@@ -126,7 +113,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:${PORT:-3000}/ || exit 1
CMD curl -f http://localhost:3000/ || exit 1
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
CMD []
CMD ["node", "/app/server.js"]
-102
View File
@@ -36,108 +36,6 @@ Dockhand is a modern, efficient Docker management application providing real-tim
- **Database**: SQLite or PostgreSQL via Drizzle ORM
- **Docker**: direct docker API calls.
## Screenshots
<table>
<tr>
<td width="50%">
<img src="docs/screenshot1.webp" alt="Environments overview">
<p align="center"><sub><sub><sub><b>Environments overview</b> — manage every Docker host from one place</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot2.webp" alt="Environment dashboard">
<p align="center"><sub><sub><sub><b>Environment dashboard</b> — live CPU, memory and disk metrics per host</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot3.webp" alt="Containers">
<p align="center"><sub><sub><sub><b>Containers</b> — real-time status, resources and port mappings</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot6.webp" alt="Compose stacks">
<p align="center"><sub><sub><sub><b>Compose stacks</b> — deploy and orchestrate multi-container apps</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot7.webp" alt="Compose editor">
<p align="center"><sub><sub><sub><b>Compose editor</b> — edit YAML side-by-side with env variables</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot8.webp" alt="Images">
<p align="center"><sub><sub><sub><b>Images</b> — track tags, sizes, updates and clean up unused</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot4.webp" alt="Logs and terminal">
<p align="center"><sub><sub><sub><b>Logs &amp; terminal</b> — stream logs with a shell next to them</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot5.webp" alt="Interactive shell">
<p align="center"><sub><sub><sub><b>Interactive shell</b> — exec straight into any container</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot10.webp" alt="Add environment">
<p align="center"><sub><sub><sub><b>Add environment</b> — connect via socket, agent or direct TCP</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot9.webp" alt="Settings and theming">
<p align="center"><sub><sub><sub><b>Settings &amp; theming</b> — themes, fonts, scanners and schedules</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot11.webp" alt="Network graph">
<p align="center"><sub><sub><sub><b>Network graph</b> — visualize how services connect across stacks</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot13.webp" alt="Container file browser">
<p align="center"><sub><sub><sub><b>Container files</b> — browse, edit, upload and download in-place</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot12.webp" alt="Image layers">
<p align="center"><sub><sub><sub><b>Image layers</b> — inspect every layer, its size and contents</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot14.webp" alt="Vulnerability scanning">
<p align="center"><sub><sub><sub><b>Vulnerability scans</b> — Grype &amp; Trivy CVE results per image</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot15.webp" alt="Volume browser">
<p align="center"><sub><sub><sub><b>Volume browser</b> — explore and edit files inside any volume</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot19.webp" alt="Stack graph editor">
<p align="center"><sub><sub><sub><b>Stack graph editor</b> — visual editor for services, networks and secrets</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot16.webp" alt="Deploy from Git">
<p align="center"><sub><sub><sub><b>Deploy from Git</b> — pull stacks from repos with webhooks &amp; auto-sync</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot17.webp" alt="Schedules">
<p align="center"><sub><sub><sub><b>Schedules</b> — cron-style automation for prune, updates and cleanup</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot18.webp" alt="Activity log">
<p align="center"><sub><sub><sub><b>Activity log</b> — audit every action across all environments</sub></sub></sub></p>
</td>
<td width="50%"></td>
</tr>
</table>
## License
Dockhand is licensed under the [Business Source License 1.1](LICENSE.txt) (BSL 1.1).
+1 -1
View File
@@ -1 +1 @@
v1.0.34
v1.0.23
+1 -1
View File
@@ -1,3 +1,3 @@
module github.com/Finsys/dockhand/collector
go 1.25.11
go 1.25
+21 -67
View File
@@ -221,19 +221,13 @@ func buildClients(cfg *EnvConfig) (client *http.Client, streamClient *http.Clien
baseURL = "http://localhost"
case "http":
// Explicit dial timeout and TCP keepalive so connections over dead
// tunnels (VPN/Tailscale drops) are detected at kernel level instead
// of hanging indefinitely.
tcpDial := (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 15 * time.Second}).DialContext
transport = &http.Transport{
DialContext: tcpDial,
MaxIdleConns: 16,
MaxIdleConnsPerHost: 16,
MaxConnsPerHost: 16,
IdleConnTimeout: 90 * time.Second,
}
streamTransport = &http.Transport{
DialContext: tcpDial,
MaxIdleConns: 4,
MaxIdleConnsPerHost: 4,
MaxConnsPerHost: 4,
@@ -248,9 +242,7 @@ func buildClients(cfg *EnvConfig) (client *http.Client, streamClient *http.Clien
}
streamTLSCfg := tlsCfg.Clone()
tcpDial := (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 15 * time.Second}).DialContext
transport = &http.Transport{
DialContext: tcpDial,
TLSClientConfig: tlsCfg,
MaxIdleConns: 16,
MaxIdleConnsPerHost: 16,
@@ -258,7 +250,6 @@ func buildClients(cfg *EnvConfig) (client *http.Client, streamClient *http.Clien
IdleConnTimeout: 90 * time.Second,
}
streamTransport = &http.Transport{
DialContext: tcpDial,
TLSClientConfig: streamTLSCfg,
MaxIdleConns: 4,
MaxIdleConnsPerHost: 4,
@@ -331,32 +322,15 @@ func (e *environment) doStreamRequest(ctx context.Context, method, path string)
return e.streamClient.Do(req)
}
func (e *environment) ping(ctx context.Context) error {
attempt := func() error {
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
resp, err := e.doRequest(pingCtx, "GET", "/_ping")
if err != nil {
return err
}
drainAndClose(resp)
if resp.StatusCode != 200 {
return fmt.Errorf("ping returned status %d", resp.StatusCode)
}
return nil
func (e *environment) ping(ctx context.Context) bool {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
resp, err := e.doRequest(ctx, "GET", "/_ping")
if err != nil {
return false
}
if err := attempt(); err == nil {
return nil
} else if ctx.Err() != nil {
return err
}
// Stale pooled connections (e.g. after a VPN/tunnel drop) hang requests
// until timeout while the host is actually reachable. Evict the pool and
// retry once on a guaranteed-fresh connection.
e.closeTransports()
return attempt()
drainAndClose(resp)
return resp.StatusCode == 200
}
// ---------------------------------------------------------------------------
@@ -384,11 +358,11 @@ func (m *manager) runMetrics(env *environment) {
}
func (m *manager) collectMetrics(env *environment) {
if err := env.ping(env.ctx); err != nil {
if !env.ping(env.ctx) {
if env.online || !env.statusReported {
env.online = false
env.statusReported = true
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable: " + err.Error()})
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable"})
}
return
}
@@ -447,7 +421,7 @@ func (m *manager) collectMetrics(env *environment) {
sCtx, sCancel := context.WithTimeout(env.ctx, 10*time.Second)
defer sCancel()
sResp, sErr := env.doRequest(sCtx, "GET", fmt.Sprintf("/containers/%s/stats?stream=false", id))
sResp, sErr := env.doRequest(sCtx, "GET", fmt.Sprintf("/containers/%s/stats?stream=false&one-shot=true", id))
if sErr != nil {
return
}
@@ -584,11 +558,11 @@ func (m *manager) runEvents(env *environment) {
}
// Stream mode
if err := env.ping(env.ctx); err != nil {
if !env.ping(env.ctx) {
if env.online || !env.statusReported {
env.online = false
env.statusReported = true
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable: " + err.Error()})
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable"})
}
if !waitOrCancel(reconnectDelay) {
return
@@ -635,32 +609,12 @@ func (m *manager) runEvents(env *environment) {
// Force-close the body on context cancellation so scanner.Scan()
// unblocks. Without this, the goroutine can leak if the transport's
// internal cancel watcher doesn't fire (Go runtime implementation detail).
//
// The watchdog ticker handles half-open connections (e.g. after a
// VPN/tunnel drop): the stream client has no timeout, so Scan() would
// otherwise block forever on a dead connection that never errors.
// A failed ping (which retries on a fresh connection internally)
// means the host is unreachable — close the body so the reconnect
// loop takes over.
bodyDone := make(chan struct{})
var closeBodyOnce sync.Once
closeBody := func() { closeBodyOnce.Do(func() { resp.Body.Close() }) }
go func() {
watchdog := time.NewTicker(90 * time.Second)
defer watchdog.Stop()
for {
select {
case <-env.ctx.Done():
closeBody()
return
case <-bodyDone:
return
case <-watchdog.C:
if env.ping(env.ctx) != nil {
closeBody()
return
}
}
select {
case <-env.ctx.Done():
resp.Body.Close()
case <-bodyDone:
}
}()
@@ -684,7 +638,7 @@ func (m *manager) runEvents(env *environment) {
}
}
close(bodyDone)
closeBody()
resp.Body.Close()
if env.ctx.Err() != nil {
return
@@ -699,11 +653,11 @@ func (m *manager) runEvents(env *environment) {
}
func (m *manager) pollEvents(env *environment) {
if err := env.ping(env.ctx); err != nil {
if !env.ping(env.ctx) {
if env.online || !env.statusReported {
env.online = false
env.statusReported = true
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable: " + err.Error()})
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable"})
}
return
}
@@ -782,7 +736,7 @@ func (m *manager) runDiskChecks(env *environment) {
}
func (m *manager) checkDisk(env *environment) {
if env.ping(env.ctx) != nil {
if !env.ping(env.ctx) {
return
}
+1 -1
View File
@@ -10,7 +10,7 @@ PGID=${PGID:-1001}
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, git ops auto-merge with system CAs)
# 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 --dns-result-order=ipv4first --no-network-family-autoselection --expose-gc /app/server.js"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

-21
View File
@@ -1,21 +0,0 @@
CREATE TABLE IF NOT EXISTS "api_tokens" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"name" text NOT NULL,
"token_hash" text NOT NULL,
"token_prefix" text NOT NULL,
"last_used" timestamp,
"expires_at" timestamp,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "api_tokens_token_hash_unique" UNIQUE("token_hash")
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "api_tokens_user_id_idx" ON "api_tokens" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "api_tokens_token_prefix_idx" ON "api_tokens" USING btree ("token_prefix");
@@ -1,2 +0,0 @@
ALTER TABLE "git_stacks" ADD COLUMN "context_dir" text;--> statement-breakpoint
ALTER TABLE "git_stacks" ADD COLUMN "no_build_cache" boolean DEFAULT false;
-1
View File
@@ -1 +0,0 @@
ALTER TABLE "git_stacks" ADD COLUMN "synced_files" text;
-12
View File
@@ -1,12 +0,0 @@
CREATE TABLE "template_sources" (
"id" serial PRIMARY KEY NOT NULL,
"source_id" text NOT NULL,
"name" text NOT NULL,
"url" text NOT NULL,
"enabled" boolean DEFAULT true,
"builtin" boolean DEFAULT false,
"sort_order" integer DEFAULT 0,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "template_sources_source_id_unique" UNIQUE("source_id")
);
+4 -4
View File
@@ -2352,14 +2352,14 @@
"primaryKey": false,
"notNull": false
},
"compose_path": {
"name": "compose_path",
"external_compose_path": {
"name": "external_compose_path",
"type": "text",
"primaryKey": false,
"notNull": false
},
"env_path": {
"name": "env_path",
"external_env_path": {
"name": "external_env_path",
"type": "text",
"primaryKey": false,
"notNull": false
+6 -6
View File
@@ -1,6 +1,6 @@
{
"id": "cefce4cc-994a-4b79-b55a-e995211b8f6a",
"prevId": "b10cba96-4947-484f-84a2-efb65205381f",
"id": "b10cba96-4947-484f-84a2-efb65205381f",
"prevId": "eef8322a-0ccc-418c-b0f6-f51972a1850e",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -2373,14 +2373,14 @@
"primaryKey": false,
"notNull": false
},
"compose_path": {
"name": "compose_path",
"external_compose_path": {
"name": "external_compose_path",
"type": "text",
"primaryKey": false,
"notNull": false
},
"env_path": {
"name": "env_path",
"external_env_path": {
"name": "external_env_path",
"type": "text",
"primaryKey": false,
"notNull": false
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-28
View File
@@ -36,34 +36,6 @@
"when": 1774155653752,
"tag": "0004_add_git_stack_deploy_options",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1775312212996,
"tag": "0005_add_api_tokens",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1777220350655,
"tag": "0006_add_git_stack_context_dir",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1781158711008,
"tag": "0007_add_synced_files",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1781620381909,
"tag": "0008_add_template_sources",
"breakpoints": true
}
]
}
-16
View File
@@ -1,16 +0,0 @@
CREATE TABLE `api_tokens` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`name` text NOT NULL,
`token_hash` text NOT NULL,
`token_prefix` text NOT NULL,
`last_used` text,
`expires_at` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`updated_at` text DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `api_tokens_token_hash_unique` ON `api_tokens` (`token_hash`);--> statement-breakpoint
CREATE INDEX `api_tokens_user_id_idx` ON `api_tokens` (`user_id`);--> statement-breakpoint
CREATE INDEX `api_tokens_token_prefix_idx` ON `api_tokens` (`token_prefix`);
@@ -1,2 +0,0 @@
ALTER TABLE `git_stacks` ADD `context_dir` text;--> statement-breakpoint
ALTER TABLE `git_stacks` ADD `no_build_cache` integer DEFAULT false;
-1
View File
@@ -1 +0,0 @@
ALTER TABLE `git_stacks` ADD `synced_files` text;
-13
View File
@@ -1,13 +0,0 @@
CREATE TABLE `template_sources` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`source_id` text NOT NULL,
`name` text NOT NULL,
`url` text NOT NULL,
`enabled` integer DEFAULT true,
`builtin` integer DEFAULT false,
`sort_order` integer DEFAULT 0,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`updated_at` text DEFAULT CURRENT_TIMESTAMP
);
--> statement-breakpoint
CREATE UNIQUE INDEX `template_sources_source_id_unique` ON `template_sources` (`source_id`);
+2 -2
View File
@@ -940,7 +940,7 @@
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'docker-compose.yml'"
"default": "'compose.yaml'"
},
"environment_id": {
"name": "environment_id",
@@ -1099,7 +1099,7 @@
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'docker-compose.yml'"
"default": "'compose.yaml'"
},
"env_file_path": {
"name": "env_file_path",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-28
View File
@@ -36,34 +36,6 @@
"when": 1774155653752,
"tag": "0004_add_git_stack_deploy_options",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1775311743346,
"tag": "0005_add_api_tokens",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1777220350655,
"tag": "0006_add_git_stack_context_dir",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1781158702731,
"tag": "0007_add_synced_files",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1781620376161,
"tag": "0008_add_template_sources",
"breakpoints": true
}
]
}
+10 -12
View File
@@ -1,7 +1,7 @@
{
"name": "dockhand",
"private": true,
"version": "1.0.34",
"version": "1.0.23",
"type": "module",
"scripts": {
"dev": "npx vite dev",
@@ -63,9 +63,8 @@
"@codemirror/lang-python": "6.2.1",
"@codemirror/lang-sql": "6.10.0",
"@codemirror/lang-xml": "6.1.0",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/lang-yaml": "6.1.2",
"@codemirror/language": "6.12.1",
"@codemirror/legacy-modes": "6.5.3",
"@codemirror/search": "6.6.0",
"@codemirror/state": "6.5.4",
"@codemirror/theme-one-dark": "6.1.3",
@@ -77,19 +76,19 @@
"better-sqlite3": "11.7.0",
"croner": "9.1.0",
"cronstrue": "3.9.0",
"devalue": "5.8.1",
"drizzle-orm": "0.45.2",
"fast-xml-parser": "5.7.3",
"devalue": "5.6.4",
"drizzle-orm": "0.45.1",
"fast-xml-parser": "5.5.8",
"js-yaml": "4.1.1",
"ldapts": "8.1.3",
"nodemailer": "8.0.9",
"nodemailer": "8.0.4",
"otpauth": "9.4.1",
"postgres": "3.4.8",
"qrcode": "1.5.4",
"rollup": "4.60.0",
"svelte-sonner": "1.0.7",
"undici": "7.24.5",
"ws": "8.21.0"
"ws": "8.18.0"
},
"devDependencies": {
"@internationalized/date": "^3.10.1",
@@ -117,10 +116,10 @@
"d3-shape": "^3.2.0",
"drizzle-kit": "0.31.8",
"layerchart": "^1.0.13",
"lucide-svelte": "0.562.0",
"lucide-svelte": "^0.562.0",
"mode-watcher": "^1.1.0",
"postcss": "^8.5.6",
"svelte": "5.55.7",
"svelte": "5.53.5",
"svelte-check": "^4.3.5",
"svelte-easy-crop": "^5.0.0",
"tailwind-merge": "^3.4.0",
@@ -137,7 +136,6 @@
"@codemirror/commands": "6.10.1",
"@codemirror/search": "6.6.0",
"@lezer/common": "1.5.0",
"@lezer/highlight": "1.2.3",
"devalue": "5.8.1"
"@lezer/highlight": "1.2.3"
}
}
+10 -128
View File
@@ -8,12 +8,11 @@
* Usage: node ./server.js
*/
import { createServer as createHttpServer, request as httpRequest } from 'node:http';
import { createServer as createHttpsServer, request as httpsRequest } from 'node:https';
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, X509Certificate } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { WebSocketServer } from 'ws';
import { handler } from './build/handler.js';
@@ -29,82 +28,10 @@ console.warn = (...args) => _warn(ts(), ...args);
const PORT = parseInt(process.env.PORT || '3000', 10);
const HOST = process.env.HOST || '0.0.0.0';
// Optional native HTTPS listener (#1102). Off by default to keep existing
// deployments unchanged. When HTTPS_MODE=on, HTTPS_CERT_PATH and
// HTTPS_KEY_PATH must both point to readable PEM files.
const HTTPS_MODE = (process.env.HTTPS_MODE || 'off').toLowerCase();
const useHttps = HTTPS_MODE === 'on';
let server;
if (useHttps) {
const certPath = process.env.HTTPS_CERT_PATH;
const keyPath = process.env.HTTPS_KEY_PATH;
const caPath = process.env.HTTPS_CA_PATH;
console.log('[HTTPS] mode=on');
console.log(`[HTTPS] cert=${certPath || '(missing)'}`);
console.log(`[HTTPS] key=${keyPath || '(missing)'}`);
console.log(`[HTTPS] ca=${caPath || '(none)'}`);
if (!certPath || !keyPath) {
console.error('[HTTPS] HTTPS_MODE=on requires HTTPS_CERT_PATH and HTTPS_KEY_PATH');
process.exit(1);
}
let certPem, keyPem, caPem;
try {
certPem = readFileSync(certPath);
keyPem = readFileSync(keyPath);
if (caPath) caPem = readFileSync(caPath);
} catch (e) {
console.error(`[HTTPS] Failed to read cert/key file: ${e.message}`);
process.exit(1);
}
// Parse cert metadata so operators can confirm they mounted the right file.
try {
const x509 = new X509Certificate(certPem);
console.log(`[HTTPS] cert subject: ${x509.subject.replace(/\n/g, ', ')}`);
console.log(`[HTTPS] cert issuer: ${x509.issuer.replace(/\n/g, ', ')}`);
console.log(`[HTTPS] cert SAN: ${x509.subjectAltName || '(none)'}`);
console.log(`[HTTPS] cert valid: ${x509.validFrom}${x509.validTo}`);
const expiresAt = new Date(x509.validTo).getTime();
const daysLeft = Math.floor((expiresAt - Date.now()) / 86400000);
if (daysLeft < 0) {
console.warn(`[HTTPS] WARNING: certificate expired ${-daysLeft} day(s) ago`);
} else if (daysLeft < 30) {
console.warn(`[HTTPS] WARNING: certificate expires in ${daysLeft} day(s)`);
} else {
console.log(`[HTTPS] cert expires in ${daysLeft} day(s)`);
}
} catch (e) {
console.error(`[HTTPS] Failed to parse certificate: ${e.message}`);
process.exit(1);
}
const tlsOptions = { cert: certPem, key: keyPem };
if (caPem) tlsOptions.ca = caPem;
// HSTS — only meaningful over HTTPS, so wired only here. Default 1 year;
// set HSTS_MAX_AGE=0 to disable.
const hstsMaxAge = parseInt(process.env.HSTS_MAX_AGE ?? '31536000', 10);
const hstsHeader = hstsMaxAge > 0 ? `max-age=${hstsMaxAge}` : null;
if (hstsHeader) {
console.log(`[HTTPS] HSTS enabled: ${hstsHeader}`);
} else {
console.log('[HTTPS] HSTS disabled (HSTS_MAX_AGE=0)');
}
server = createHttpsServer(tlsOptions, (req, res) => {
if (hstsHeader) res.setHeader('Strict-Transport-Security', hstsHeader);
handler(req, res);
});
} else {
console.log(`[HTTPS] mode=off (set HTTPS_MODE=on to enable native TLS)`);
server = createHttpServer((req, res) => {
handler(req, res);
});
}
// 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 });
@@ -168,7 +95,7 @@ globalThis.__terminalHandleExecMessage = (msg) => {
};
// Handle WebSocket upgrade
server.on('upgrade', async (req, socket, head) => {
server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url || '/', `http://${req.headers.host}`);
// Only handle our specific WebSocket paths
@@ -180,30 +107,7 @@ server.on('upgrade', async (req, socket, head) => {
return;
}
let wsAuth = null;
if (isTerminal) {
try {
if (typeof globalThis.__authenticateWsUpgrade !== 'function') {
socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
wsAuth = await globalThis.__authenticateWsUpgrade(req.headers);
if (!wsAuth) {
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
} catch (err) {
console.error('[WS] auth error during upgrade:', err);
socket.write('HTTP/1.1 500 Internal Server Error\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
}
wss.handleUpgrade(req, socket, head, (ws) => {
if (wsAuth) ws.__auth = wsAuth;
wss.emit('connection', ws, req);
});
});
@@ -246,22 +150,6 @@ async function handleTerminalConnection(ws, url, connId) {
return;
}
if (ws.__auth && typeof globalThis.__canAccessEnvForUser === 'function') {
try {
const ok = await globalThis.__canAccessEnvForUser(ws.__auth, envId);
if (!ok) {
console.warn(`[WS] env access denied: user=${ws.__auth.username} envId=${envId}`);
ws.send(JSON.stringify({ type: 'error', message: 'Access denied for this environment' }));
ws.close(1008, 'env access denied');
return;
}
} catch (err) {
console.error('[WS] env access check failed:', err);
ws.close(1011, 'internal error');
return;
}
}
try {
// Resolve Docker target via SvelteKit app's database
let target;
@@ -542,12 +430,7 @@ function handleHawserConnection(ws, connId, remoteIp) {
// Use the global hawser message handler injected by the SvelteKit app
if (typeof globalThis.__hawserHandleMessage === 'function') {
try {
await globalThis.__hawserHandleMessage(ws, msg, connId, remoteIp);
} catch (handlerError) {
console.error('[Hawser WS] Handler error:', handlerError);
// Don't close connection - let it recover
}
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' }));
@@ -570,8 +453,7 @@ function handleHawserConnection(ws, connId, remoteIp) {
// Start the server
server.listen(PORT, HOST, () => {
const scheme = useHttps ? 'https' : 'http';
console.log(`Listening on ${scheme}://${HOST}:${PORT}/ with WebSocket`);
console.log(`Listening on http://${HOST}:${PORT}/ with WebSocket`);
});
-65
View File
@@ -74,33 +74,6 @@ html {
max-width: calc(90px * var(--grid-font-size-scale, 1)) !important;
}
/* Scrollbar theming WebKit only (Sencho-style). No global * selector and
* no scrollbar-width override, so Firefox/native scrollbars render at OS
* default width. Dark-mode thumb bumped to be visible on dark surfaces. */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
/* Light mode: medium gray that holds up against white. Pale border-color
* at 50% was nearly invisible. */
background: hsl(0 0% 60% / 0.6);
border-radius: 4px;
transition: background 150ms ease;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(0 0% 40% / 0.8);
}
.dark ::-webkit-scrollbar-thumb {
background: hsl(0 0% 50% / 0.5);
}
.dark ::-webkit-scrollbar-thumb:hover {
background: hsl(0 0% 65% / 0.7);
}
:root {
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
@@ -1341,16 +1314,6 @@ html {
line-height: 14px;
}
/* Icon animation toggle (#1169): when html.no-icon-animation is set, the
common Tailwind animation utilities collapse to no-op. This keeps the
layout (spinners still occupy space) but removes the motion. */
html.no-icon-animation .animate-spin,
html.no-icon-animation .animate-pulse,
html.no-icon-animation .animate-bounce,
html.no-icon-animation .animate-ping {
animation: none !important;
}
/* Icon glow utilities - standard size (4px blur, 0.6 opacity) */
.glow-green { filter: drop-shadow(0 0 4px rgba(34, 197, 94, 0.6)); }
.glow-green-sm { filter: drop-shadow(0 0 3px rgba(34, 197, 94, 0.5)); }
@@ -1790,31 +1753,3 @@ html {
.ansi-dim { opacity: 0.7; }
.ansi-italic { font-style: italic; }
.ansi-underline { text-decoration: underline; }
/* Log line numbers */
.log-line {
min-height: 1.2em;
}
pre.show-line-numbers {
counter-reset: log-line;
}
pre.show-line-numbers .log-line {
counter-increment: log-line;
padding-left: 4.5em;
position: relative;
}
pre.show-line-numbers .log-line::before {
content: counter(log-line);
position: absolute;
left: 0;
width: 3.5em;
text-align: right;
padding-right: 0.75em;
user-select: none;
color: rgb(113 113 122); /* zinc-500 */
border-right: 1px solid rgb(63 63 70); /* zinc-700 */
}
:where(.light, .light *) pre.show-line-numbers .log-line::before {
color: rgb(156 163 175); /* gray-400 */
border-right-color: rgb(209 213 219); /* gray-300 */
}
+4 -5
View File
@@ -3,12 +3,11 @@
import type { AuthenticatedUser } from '$lib/server/auth';
declare global {
// Build-time constants injected by Vite
const __APP_VERSION__: string | null;
const __BUILD_DATE__: string | null;
const __BUILD_COMMIT__: string | null;
// Build-time constants injected by Vite
declare const __BUILD_DATE__: string | null;
declare const __BUILD_COMMIT__: string | null;
declare global {
namespace App {
// interface Error {}
interface Locals {
+7 -86
View File
@@ -4,8 +4,6 @@ import { initDatabase, hasAdminUser } from '$lib/server/db';
import { startSubprocesses, stopSubprocesses } from '$lib/server/subprocess-manager';
import { startScheduler } from '$lib/server/scheduler';
import { isAuthEnabled, validateSession } from '$lib/server/auth';
import { validateApiToken } from '$lib/server/api-tokens';
import { requestContext } from '$lib/server/request-context';
import { setServerStartTime } from '$lib/server/uptime';
import { checkLicenseExpiry, getHostname } from '$lib/server/license';
import { initCryptoFallback } from '$lib/server/crypto-fallback';
@@ -18,11 +16,6 @@ import { join } from 'path';
import type { HandleServerError, Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { startRssTracker, stopRssTracker, rssBeforeOp, rssAfterOp } from '$lib/server/rss-tracker';
import { getClientIp } from '$lib/server/client-ip';
// Side-effect import: installs globalThis.__authenticateWsUpgrade and
// globalThis.__canAccessEnvForUser used by the raw WS upgrade handlers in
// server.js / vite.config.ts to authenticate /api/containers/*/exec.
import '$lib/server/ws-auth';
// Content types worth compressing
const COMPRESSIBLE_TYPES = [
@@ -205,48 +198,6 @@ if (!initialized) {
}
}
// Bearer token auth failure rate limiting (per IP, 5-minute cooldown after 10 failures)
const bearerFailCounts = new Map<string, { count: number; firstFail: number }>();
const BEARER_FAIL_WINDOW_MS = 60_000; // 1-minute sliding window
const BEARER_FAIL_MAX = 15; // max failures per window
const BEARER_COOLDOWN_MS = 5 * 60 * 1000; // 5-minute cooldown after exceeding limit
const bearerCooldowns = new Map<string, number>(); // IP → cooldown-until timestamp
// Periodic cleanup
setInterval(() => {
const now = Date.now();
for (const [ip, until] of bearerCooldowns) {
if (now > until) bearerCooldowns.delete(ip);
}
for (const [ip, entry] of bearerFailCounts) {
if (now - entry.firstFail > BEARER_FAIL_WINDOW_MS) bearerFailCounts.delete(ip);
}
}, BEARER_COOLDOWN_MS).unref?.();
function recordBearerFailure(ip: string): void {
const now = Date.now();
const entry = bearerFailCounts.get(ip);
if (!entry || now - entry.firstFail > BEARER_FAIL_WINDOW_MS) {
bearerFailCounts.set(ip, { count: 1, firstFail: now });
return;
}
entry.count++;
if (entry.count >= BEARER_FAIL_MAX) {
bearerCooldowns.set(ip, now + BEARER_COOLDOWN_MS);
bearerFailCounts.delete(ip);
}
}
function isBearerRateLimited(ip: string): boolean {
const until = bearerCooldowns.get(ip);
if (!until) return false;
if (Date.now() > until) {
bearerCooldowns.delete(ip);
return false;
}
return true;
}
// Routes that don't require authentication
const PUBLIC_PATHS = [
'/login',
@@ -296,51 +247,21 @@ export const handle: Handle = async ({ event, resolve }) => {
// Check if auth is enabled
const authEnabled = await isAuthEnabled();
// If auth is disabled, allow everything
// If auth is disabled, allow everything (app works as before)
if (!authEnabled) {
event.locals.user = null;
event.locals.authEnabled = false;
const ctx = { user: null, authEnabled: false, authMethod: 'none' as const };
return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event)));
}
// Auth is enabled - check session first
let user = await validateSession(event.cookies);
let authMethod: 'cookie' | 'bearer' | 'none' = user ? 'cookie' : 'none';
// If no session, try Bearer token on API routes
if (!user && event.url.pathname.startsWith('/api/')) {
const authHeader = event.request.headers.get('authorization');
if (authHeader && authHeader.startsWith('Bearer dh_') && authHeader.length <= 207) {
const clientIp = getClientIp(event);
// Rate limit failed Bearer attempts
if (isBearerRateLimited(clientIp)) {
return new Response(
JSON.stringify({ error: 'Too many failed authentication attempts' }),
{ status: 429, headers: { 'Content-Type': 'application/json', 'Retry-After': '300' } }
);
}
const token = authHeader.substring(7); // strip "Bearer "
user = await validateApiToken(token);
if (user) {
authMethod = 'bearer';
} else {
recordBearerFailure(clientIp);
}
}
return compressResponse(event.request, await resolve(event));
}
// Auth is enabled - check session
const user = await validateSession(event.cookies);
event.locals.user = user;
event.locals.authEnabled = true;
const ctx = { user, authEnabled: true, authMethod };
// Public paths don't require authentication
if (isPublicPath(event.url.pathname)) {
return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event)));
return compressResponse(event.request, await resolve(event));
}
// If not authenticated
@@ -349,7 +270,7 @@ export const handle: Handle = async ({ event, resolve }) => {
// This enables the first admin user to be created during initial setup
const noAdminSetupMode = !(await hasAdminUser());
if (noAdminSetupMode && event.url.pathname === '/api/users' && event.request.method === 'POST') {
return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event)));
return compressResponse(event.request, await resolve(event));
}
// API routes return 401
@@ -368,7 +289,7 @@ export const handle: Handle = async ({ event, resolve }) => {
redirect(307, `/login?redirect=${redirectUrl}`);
}
return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event)));
return compressResponse(event.request, await resolve(event));
} finally {
rssAfterOp('http', httpBefore);
}
@@ -1,37 +0,0 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { themeStore } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth';
import { toast } from 'svelte-sonner';
interface Props {
userId?: number; // omit for global default (login page / auth-disabled)
}
let { userId }: Props = $props();
// Same "skip applying" rule as ThemeSelector: don't toggle the live document
// when the admin is editing the global default while logged in (their own
// per-user preference still drives their session).
const skipApply = $derived($authStore.loading ? true : ($authStore.authEnabled && !userId));
let checked = $state(true);
$effect(() => {
checked = $themeStore.animateIcons;
});
function onToggle(value: boolean) {
checked = value;
themeStore.setPreference('animateIcons', value, userId, skipApply);
toast.success(value ? 'Icon animation enabled' : 'Icon animation disabled');
}
</script>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Animate icons</Label>
<TogglePill {checked} onchange={onToggle} />
</div>
<p class="text-xs text-muted-foreground">Spinners during pulls, scans and updates.</p>
</div>
@@ -4,7 +4,14 @@
import { Progress } from '$lib/components/ui/progress';
import { Check, X, Loader2, Circle, Ban } from 'lucide-svelte';
import { onDestroy } from 'svelte';
import { formatBytes } from '$lib/utils/format';
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
const progressText: Record<string, string> = {
remove: 'removing',
-127
View File
@@ -1,127 +0,0 @@
<script lang="ts">
import { GitPullRequestArrow } from 'lucide-svelte';
import { parseChangelogTokens, tokenHref, type ChangelogToken } from '$lib/utils/changelog-tokens';
let { text }: { text: string } = $props();
type Group = { kind: 'text'; value: string } | { kind: 'refs'; refs: ChangelogToken[] };
const groups = $derived.by<Group[]>(() => {
const tokens = parseChangelogTokens(text);
const result: Group[] = [];
let textBuf = '';
let refBuf: ChangelogToken[] = [];
const flushText = () => {
if (textBuf) {
result.push({ kind: 'text', value: textBuf });
textBuf = '';
}
};
const flushRefs = () => {
if (refBuf.length) {
result.push({ kind: 'refs', refs: refBuf });
refBuf = [];
}
};
for (const t of tokens) {
if (t.kind === 'text') {
// If the gap between consecutive ref groups is only "glue" (whitespace,
// commas, parens), keep collecting into the same refs group. Otherwise
// it ends the group.
if (refBuf.length && /^[\s,()]*$/.test(t.value)) {
continue;
}
if (refBuf.length) {
flushRefs();
}
// Strip a trailing " (" left over before the upcoming refs group.
textBuf += t.value;
} else {
// Trim trailing glue from textBuf so we don't render "foo (".
if (refBuf.length === 0) {
textBuf = textBuf.replace(/[\s(]+$/, '');
}
flushText();
refBuf.push(t);
}
}
flushRefs();
// Trim trailing glue (e.g. ")") from leftover text.
textBuf = textBuf.replace(/^[\s,)]+/, '');
flushText();
return result;
});
function refLabel(token: ChangelogToken): string {
if (token.kind === 'issue') return `#${token.num}`;
if (token.kind === 'pr') return `#${token.num}`;
if (token.kind === 'user') return `@${token.name}`;
return '';
}
function refTitle(token: ChangelogToken): string {
if (token.kind === 'issue') return `Issue #${token.num}`;
if (token.kind === 'pr') return `Pull request #${token.num}`;
if (token.kind === 'user') return `@${token.name} on GitHub`;
return '';
}
</script>
<span class="text-sm">
{#each groups as group, i (i)}
{#if group.kind === 'text'}
{group.value}
{:else}
<span class="changelog-refs">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
{#each group.refs as ref, j (j)}
{#if j > 0}<span class="changelog-refs-sep"> · </span>{/if}
<a
href={tokenHref(ref)}
target="_blank"
rel="noopener noreferrer"
title={refTitle(ref)}
class="changelog-refs-link"
>{#if ref.kind === 'pr'}<GitPullRequestArrow class="changelog-pr-icon" />{/if}{refLabel(ref)}</a>
{/each}
</span>
{/if}
{/each}
</span>
<style>
.changelog-refs {
display: inline;
opacity: 0.55;
margin-left: 4px;
font-size: 0.75em;
}
.changelog-refs svg {
display: inline;
width: 10px;
height: 10px;
vertical-align: -1px;
margin-right: 3px;
}
.changelog-refs-link {
color: inherit;
text-decoration: none;
}
.changelog-refs-link:hover {
text-decoration: underline;
}
.changelog-refs-sep {
color: inherit;
}
.changelog-refs-link :global(.changelog-pr-icon) {
display: inline;
width: 10px;
height: 10px;
vertical-align: -1px;
margin-right: 2px;
}
</style>
+8 -35
View File
@@ -1,18 +1,14 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { EditorState, StateField, StateEffect, RangeSet, Prec } from '@codemirror/state';
import { EditorState, StateField, StateEffect, RangeSet } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, gutter, GutterMarker, Decoration, WidgetType, type DecorationSet } from '@codemirror/view';
// Note: Secret masking was removed - secrets are now excluded from the raw editor entirely
// and are only stored in the database (never written to .env file)
import { defaultKeymap, history, historyKeymap, indentWithTab, insertNewlineAndIndent } from '@codemirror/commands';
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, indentUnit, StreamLanguage, type StreamParser } from '@codemirror/language';
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, StreamLanguage, type StreamParser } from '@codemirror/language';
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
import { toml } from '@codemirror/legacy-modes/mode/toml';
import { properties } from '@codemirror/legacy-modes/mode/properties';
// Simple dotenv/env file language parser
const dotenvParser: StreamParser<{ inValue: boolean }> = {
@@ -409,7 +405,7 @@
new RegExp(`(?<!\\$)\\$\\{${marker.name}\\?`),
new RegExp(`(?<!\\$)\\$\\{${marker.name}:\\+`),
new RegExp(`(?<!\\$)\\$\\{${marker.name}\\+`),
new RegExp(`(?<![A-Za-z0-9\\$])\\$${marker.name}(?![a-zA-Z0-9_])`)
new RegExp(`(?<!\\$)\\$${marker.name}(?![a-zA-Z0-9_])`)
];
const hasVariable = varPatterns.some(p => p.test(line));
@@ -500,21 +496,6 @@
initialSpacer: () => new VariableGutterMarker('required')
});
// YAML Enter handler: after a key-only line ending with ":", indent one level
// deeper than what the default indent service returns (it can't predict child
// indent when no child content exists yet).
function yamlNewlineAndIndent(view: EditorView): boolean {
const { state } = view;
const line = state.doc.lineAt(state.selection.main.head);
const withoutComment = line.text.trimEnd().replace(/#.*$/, '').trimEnd();
if (!withoutComment.endsWith(':')) return false;
insertNewlineAndIndent(view);
const unit = state.facet(indentUnit);
const cursor = view.state.selection.main.head;
view.dispatch({ changes: { from: cursor, insert: unit }, selection: { anchor: cursor + unit.length } });
return true;
}
// Get language extension based on language name
function getLanguageExtension(lang: string) {
switch (lang) {
@@ -546,18 +527,12 @@
return xml();
case 'sql':
return sql();
case 'dockerfile':
case 'shell':
case 'bash':
case 'sh':
return StreamLanguage.define(shell);
case 'dockerfile':
return StreamLanguage.define(dockerFile);
case 'toml':
return StreamLanguage.define(toml);
case 'ini':
case 'conf':
case 'properties':
return StreamLanguage.define(properties);
// No dedicated shell/dockerfile support, use basic highlighting
return [];
case 'dotenv':
case 'env':
return StreamLanguage.define(dotenvParser);
@@ -696,9 +671,7 @@
]),
...themeExtensions,
EditorView.lineWrapping,
EditorState.tabSize.of(2),
getLanguageExtension(language),
...(language === 'yaml' ? [Prec.high(keymap.of([{ key: 'Enter', run: yamlNewlineAndIndent }]))] : [])
getLanguageExtension(language)
].flat();
if (readonly) {
+6 -13
View File
@@ -19,7 +19,6 @@
onConfirm: () => void;
onOpenChange: (open: boolean) => void;
children: Snippet<[{ open: boolean }]>;
extraContent?: Snippet;
}
let {
@@ -36,8 +35,7 @@
disabled = false,
onConfirm,
onOpenChange,
children,
extraContent
children
}: Props = $props();
const triggerClass = $derived(unstyled
@@ -105,16 +103,11 @@
align={position === 'left' ? 'start' : 'end'}
sideOffset={8}
>
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-2">
<span class="text-xs whitespace-nowrap">{action} {itemType} {#if displayName}<strong>{displayName}</strong>{/if}?</span>
<Button size="sm" {variant} class="h-6 px-2 text-xs" onclick={handleConfirm}>
{confirmText}
</Button>
</div>
{#if extraContent}
{@render extraContent()}
{/if}
<div class="flex items-center gap-2">
<span class="text-xs whitespace-nowrap">{action} {itemType} {#if displayName}<strong>{displayName}</strong>{/if}?</span>
<Button size="sm" {variant} class="h-6 px-2 text-xs" onclick={handleConfirm}>
{confirmText}
</Button>
</div>
</Popover.Content>
</Popover.Root>
+2 -12
View File
@@ -1,15 +1,13 @@
<script lang="ts">
import { Sun, Moon } from 'lucide-svelte';
import { getTimeFormat } from '$lib/stores/settings';
interface Props {
logs: string | null;
darkMode?: boolean;
timezone?: string;
onToggleTheme?: () => void;
}
let { logs, darkMode = true, timezone, onToggleTheme }: Props = $props();
let { logs, darkMode = true, onToggleTheme }: Props = $props();
// Parse log lines with timestamp and content
function parseLogLine(line: string): { timestamp: string; content: string; type: 'trivy' | 'grype' | 'error' | 'default' } {
@@ -46,15 +44,7 @@
}
function formatTimestamp(timestamp: string): string {
const d = new Date(timestamp);
if (isNaN(d.getTime())) return timestamp;
return new Intl.DateTimeFormat('en-GB', {
timeZone: timezone || undefined,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: getTimeFormat() === '12h'
}).format(d);
return timestamp.split('T')[1]?.replace('Z', '') || timestamp;
}
</script>
+8 -3
View File
@@ -9,7 +9,6 @@
import { onMount } from 'svelte';
import { appendEnvParam } from '$lib/stores/environment';
import { watchJob } from '$lib/utils/sse-fetch';
import { formatBytes } from '$lib/utils/format';
interface LayerProgress {
id: string;
@@ -99,6 +98,12 @@
localStorage.setItem('logTheme', logDarkMode ? 'dark' : 'light');
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
@@ -309,7 +314,7 @@
class="h-10"
>
{#if isPulling}
<Download class="w-4 h-4 mr-2 animate-spin" />
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Pulling...
{:else}
<Download class="w-4 h-4" />
@@ -327,7 +332,7 @@
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
{#if status === 'pulling'}
<Download class="w-4 h-4 animate-spin text-blue-600" />
<Loader2 class="w-4 h-4 animate-spin text-blue-600" />
<span class="text-sm">Pulling layers...</span>
{:else if status === 'complete'}
<CheckCircle2 class="w-4 h-4 text-green-600" />
+2 -4
View File
@@ -38,7 +38,6 @@
imageName: string;
envId?: number | null;
autoStart?: boolean;
activeScanner?: 'grype' | 'trivy';
onComplete?: (results: ScanResult[]) => void;
onError?: (error: string) => void;
onStatusChange?: (status: ScanStatus) => void;
@@ -48,7 +47,6 @@
imageName,
envId = null,
autoStart = false,
activeScanner = $bindable<'grype' | 'trivy'>('grype'),
onComplete,
onError,
onStatusChange
@@ -228,7 +226,7 @@
<Shield class="w-4 h-4 text-muted-foreground" />
<span class="text-sm text-muted-foreground">Ready to scan</span>
{:else if status === 'scanning'}
<Shield class="w-4 h-4 animate-spin text-blue-600" />
<Loader2 class="w-4 h-4 animate-spin text-blue-600" />
<span class="text-sm">Scanning for vulnerabilities...</span>
{:else if status === 'complete'}
{#if hasCriticalOrHigh}
@@ -364,7 +362,7 @@
{:else}
<!-- Scan Results -->
<div class="flex-1 min-h-0 overflow-auto">
<ScanResultsView {results} bind:activeScanner />
<ScanResultsView {results} />
</div>
{/if}
</div>
+10 -6
View File
@@ -114,7 +114,12 @@
}
const key = trimmed.slice(0, eqIndex).trim();
const value = trimmed.slice(eqIndex + 1);
let value = trimmed.slice(eqIndex + 1);
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (key) {
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
@@ -195,8 +200,8 @@
* Sync rawContent TO variables.
* Parses raw content for non-secrets, preserves existing secrets.
*/
function syncRawToVariables(content?: string) {
const { vars, warnings } = parseRawContent(content ?? rawContent);
function syncRawToVariables() {
const { vars, warnings } = parseRawContent(rawContent);
parseWarnings = warnings;
// Preserve existing secrets (they're not in rawContent)
@@ -235,9 +240,8 @@
// Form → Text: sync variables to raw (preserves comments)
syncVariablesToRaw();
} else if (newMode === 'form' && viewMode === 'text') {
// Text → Form: use textEditorContent which falls back to generatedRawContent
// when rawContent is empty (fixes vars lost on view switch for git stacks)
syncRawToVariables(textEditorContent);
// Text → Form: sync raw to variables (preserves secrets)
syncRawToVariables();
}
viewMode = newMode;
+2 -3
View File
@@ -3,7 +3,6 @@
import { Button } from '$lib/components/ui/button';
import { Sparkles, Bug, Zap, CheckCircle, ScrollText } from 'lucide-svelte';
import { compareVersions } from '$lib/utils/version';
import ChangelogText from '$lib/components/ChangelogText.svelte';
interface ChangelogEntry {
version: string;
@@ -63,11 +62,11 @@
<span class="text-muted-foreground font-normal">({release.date})</span>
</h3>
<div class="space-y-1.5 ml-1">
{#each [...release.changes].sort((a, b) => a.type === b.type ? 0 : a.type === 'feature' ? -1 : 1) as change}
{#each release.changes as change}
{@const { icon: Icon, class: iconClass } = getChangeIcon(change.type)}
<div class="flex items-start gap-2">
<Icon class="w-4 h-4 mt-0.5 shrink-0 {iconClass}" />
<ChangelogText text={change.text} />
<span class="text-sm">{change.text}</span>
</div>
{/each}
</div>
+1 -26
View File
@@ -22,16 +22,11 @@
User,
ClipboardList,
Activity,
Timer,
LibraryBig
Timer
} from 'lucide-svelte';
import { licenseStore } from '$lib/stores/license';
import { authStore, hasAnyAccess } from '$lib/stores/auth';
import * as Avatar from '$lib/components/ui/avatar';
import * as Tooltip from '$lib/components/ui/tooltip';
const appVersion = __APP_VERSION__ || 'unknown';
const buildCommit = __BUILD_COMMIT__ ?? null;
import type { Permissions } from '$lib/stores/auth';
@@ -102,7 +97,6 @@
{ href: '/images', Icon: Images, label: 'Images', permission: 'images' },
{ href: '/volumes', Icon: HardDrive, label: 'Volumes', permission: 'volumes' },
{ href: '/networks', Icon: Network, label: 'Networks', permission: 'networks' },
{ href: '/templates', Icon: LibraryBig, label: 'Templates', permission: 'templates' },
{ href: '/registry', Icon: Download, label: 'Registry', permission: 'registries' },
{ href: '/activity', Icon: Activity, label: 'Activity', permission: 'activity' },
{ href: '/schedules', Icon: Timer, label: 'Schedules', permission: 'schedules' },
@@ -161,25 +155,6 @@
</Sidebar.Group>
</Sidebar.Content>
<!-- Version (expanded sidebar only) -->
<div class="group-data-[state=collapsed]:hidden px-3 py-2 mt-auto text-center">
<Tooltip.Root>
<Tooltip.Trigger>
<span class="text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-default">
{appVersion}
</span>
</Tooltip.Trigger>
<Tooltip.Content side="top" align="start" sideOffset={8} class="text-xs">
<div class="space-y-0.5">
<div class="flex items-center gap-1.5"><svg class="w-4 h-4 shrink-0" viewBox="0 0 24 18" fill="currentColor"><path d="M23.76 8.68c-.26-.18-.86-.58-1.53-.58-.24 0-.48.04-.72.12-.12-.84-.68-1.56-1.34-2.14l-.28-.22-.24.26c-.28.34-.48.72-.56 1.14-.1.42-.06.82.1 1.2-.42.22-.88.36-1.32.42-.24.04-.48.06-.72.06H.78a.77.77 0 0 0-.78.78c-.02 1.46.22 2.9.72 4.24.56 1.44 1.4 2.5 2.5 3.16 1.26.74 3.32 1.16 5.64 1.16.98 0 2-.1 2.98-.3a11.5 11.5 0 0 0 3.3-1.3 9.67 9.67 0 0 0 2.54-2.34c1.16-1.42 1.86-3.02 2.34-4.38h.2c1.22 0 1.98-.48 2.4-.9.28-.26.5-.58.64-.94l.08-.24-.28-.2zM2.74 8.84H4.7c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H2.74c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.72 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H5.46c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H8.22c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zM5.46 6.2h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18H5.46c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18H8.22c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm0-2.64h1.96c.1 0 .18-.08.18-.18V1.74c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 5.28h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18z"/></svg><span class="font-mono">fnsys/dockhand:{appVersion}</span></div>
{#if buildCommit}
<div>Commit: <span class="font-mono">{buildCommit.slice(0, 7)}</span></div>
{/if}
</div>
</Tooltip.Content>
</Tooltip.Root>
</div>
<!-- User info footer (only when auth is enabled) -->
{#if $authStore.authEnabled && $authStore.authenticated && $authStore.user}
<Sidebar.Footer class="border-t">
+12 -4
View File
@@ -19,7 +19,7 @@
// Detect schedule type from cron expression
function detectScheduleType(cron: string): 'daily' | 'weekly' | 'custom' {
const parts = cron.split(' ');
if (parts.length !== 5) return 'custom';
if (parts.length < 5) return 'custom';
const [min, hr, day, month, dow] = parts;
@@ -137,15 +137,23 @@
onchange(newValue);
}
// Validate cron expression (supports 5-field and 6-field with seconds)
// Validate cron expression
function isValidCron(cron: string): boolean {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5 && parts.length !== 6) return false;
if (parts.length !== 5) return false;
const [min, hr, day, month, dow] = parts;
// Basic pattern validation (number, *, */n, range, list)
const cronFieldPattern = /^(\*|(\*\/\d+)|\d+(-\d+)?(,\d+(-\d+)?)*)$/;
return parts.every((part) => cronFieldPattern.test(part));
return (
cronFieldPattern.test(min) &&
cronFieldPattern.test(hr) &&
cronFieldPattern.test(day) &&
cronFieldPattern.test(month) &&
cronFieldPattern.test(dow)
);
}
// Human-readable description using cronstrue
+8 -30
View File
@@ -329,40 +329,18 @@
onExpandChange?.(key, nowExpanded);
}
// Sort persistence
const SORT_STORAGE_KEY = `dockhand-${gridId}-sort`;
let sortInitialized = false;
// Restore saved sort on mount
onMount(() => {
if (!onSortChange) return;
try {
const saved = localStorage.getItem(SORT_STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved) as DataGridSortState;
if (parsed.field && parsed.direction) {
onSortChange(parsed);
}
}
} catch {}
sortInitialized = true;
});
// Persist sort state whenever it changes (after init)
$effect(() => {
if (!sortInitialized || !sortState) return;
try { localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(sortState)); } catch {}
});
// Sort helpers
function toggleSort(field: string) {
if (!onSortChange) return;
const newState: DataGridSortState = sortState?.field === field
? { field, direction: sortState.direction === 'asc' ? 'desc' : 'asc' }
: { field, direction: 'asc' };
onSortChange(newState);
if (sortState?.field === field) {
onSortChange({
field,
direction: sortState.direction === 'asc' ? 'desc' : 'asc'
});
} else {
onSortChange({ field, direction: 'asc' });
}
}
// Virtual scroll state
+9 -28
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Cpu, MemoryStick, Box, Globe, ChevronDown, Check, HardDrive, Clock, Wifi, WifiOff, Route, UndoDot, Icon, AlertCircle, Loader2, Search, Server, X } 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';
@@ -9,7 +9,6 @@
import { toast } from 'svelte-sonner';
import { themeStore, type FontSize } from '$lib/stores/theme';
import { getTimeFormat } from '$lib/stores/settings';
import { formatBytes } from '$lib/utils/format';
// Font size scaling for header
let fontSize = $state<FontSize>('normal');
@@ -95,22 +94,6 @@
}
}
// Display string for the env hostname / IP in the header (#962).
// Show both when available; drop only the field that is unknown/empty.
// Hide the whole block when neither is meaningful (e.g. hawser-edge
// reports 'unknown' for both).
const hostLabel = $derived.by(() => {
if (!hostInfo) return '';
const isMeaningful = (v: string | undefined) => {
const t = (v || '').trim();
return t && t.toLowerCase() !== 'unknown';
};
const h = isMeaningful(hostInfo.hostname) ? hostInfo.hostname.trim() : '';
const ip = isMeaningful(hostInfo.ipAddress) ? hostInfo.ipAddress.trim() : '';
if (h && ip && h !== ip) return `${h} (${ip})`;
return h || ip;
});
// Reactive environment list from store
let envList = $derived($environments);
const showSearch = $derived(envList.length > 8);
@@ -235,6 +218,14 @@
(diskUsage.Volumes?.reduce((sum: number, v: any) => sum + (v.UsageData?.Size || 0), 0) || 0);
});
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
async function switchEnvironment(envId: number) {
// Don't switch if already on this environment
if (Number(envId) === Number(currentEnvId)) {
@@ -465,16 +456,6 @@
{#if hostInfo}
<span class="text-border">|</span>
<!-- Hostname / IP (#962) — first info segment after the env dropdown.
Hidden on narrow viewports to keep the strip readable. -->
{#if hostLabel}
<div class="hidden xl:flex items-center gap-1" title="Daemon hostname / IP">
<Server class="{iconSizeClass()}" />
<span>{hostLabel}</span>
</div>
<span class="hidden xl:inline text-border">|</span>
{/if}
<!-- Platform/OS -->
<span class="hidden md:inline">{hostInfo.platform} {hostInfo.arch}</span>
+1 -2
View File
@@ -10,7 +10,7 @@ export const containerColumns: ColumnConfig[] = [
{ id: 'uptime', label: 'Uptime', sortable: true, sortField: 'uptime', width: 80, minWidth: 60 },
{ id: 'restartCount', label: 'Restarts', width: 70, minWidth: 50 },
{ id: 'cpu', label: 'CPU', sortable: true, sortField: 'cpu', width: 50, minWidth: 40, align: 'right' },
{ id: 'memory', label: 'Memory', sortable: true, sortField: 'memory', width: 95, minWidth: 70, align: 'right' },
{ id: 'memory', label: 'Memory', sortable: true, sortField: 'memory', width: 60, minWidth: 50, align: 'right' },
{ id: 'networkIO', label: 'Net I/O', width: 85, minWidth: 70, align: 'right' },
{ id: 'diskIO', label: 'Disk I/O', width: 85, minWidth: 70, align: 'right' },
{ id: 'ip', label: 'IP', sortable: true, sortField: 'ip', width: 100, minWidth: 80 },
@@ -76,7 +76,6 @@ export const volumeColumns: ColumnConfig[] = [
{ id: 'select', label: '', fixed: 'start', width: 32, resizable: false },
{ id: 'name', label: 'Name', sortable: true, sortField: 'name', width: 400, minWidth: 150, grow: true },
{ id: 'driver', label: 'Driver', sortable: true, sortField: 'driver', width: 80, minWidth: 60 },
{ id: 'type', label: 'Type', sortable: true, sortField: 'type', width: 80, minWidth: 60 },
{ id: 'scope', label: 'Scope', width: 70, minWidth: 50 },
{ id: 'stack', label: 'Stack', sortable: true, sortField: 'stack', width: 120, minWidth: 80 },
{ id: 'usedBy', label: 'Used by', width: 150, minWidth: 80 },
-216
View File
@@ -1,220 +1,4 @@
[
{
"version": "1.0.34",
"date": "2026-06-17",
"changes": [
{ "type": "feature", "text": "raw file download — no tar wrapping (#1180)" },
{ "type": "fix", "text": "update modal stuck after closing mid-pull (#1094)" },
{ "type": "fix", "text": "vulnerability scans on Podman hosts (direct TCP and Hawser) (#1076)" },
{ "type": "fix", "text": "crash-looping containers now appear in the logs page list (#227)" },
{ "type": "feature", "text": "filter containers by \"Update available\" (#1063)" },
{ "type": "feature", "text": "show hostname / IP of the selected environment in the top header (#962)" },
{ "type": "feature", "text": "internal auth and validation hardening and dependency bumps" },
{ "type": "feature", "text": "Traefik and Pangolin integration — surface proxy URLs on container and stack panels (#2)" },
{ "type": "feature", "text": "release-notes link next to images with updates available (#538)" },
{ "type": "feature", "text": "lifecycle action buttons in the container details modal (#461)" },
{ "type": "feature", "text": "template library — browse and deploy compose templates from configurable sources (#48)" },
{ "type": "fix", "text": "file browser fails on containers with ls in /usr/sbin (#1185)" }
],
"imageTag": "fnsys/dockhand:v1.0.34"
},
{
"version": "1.0.33",
"date": "2026-06-15",
"changes": [
{ "type": "feature", "text": "in-place container property updates without restart — restart policy, CPU/memory limits (#1153)" },
{ "type": "feature", "text": "clickable stack badge in container and volume inspect modals (#1121)" },
{ "type": "feature", "text": "clickable stack badge in volumes list row (#1122)" },
{ "type": "feature", "text": "volumes list shows driver_opts type (NFS, CIFS, etc.) with sort and filter (#1123)" },
{ "type": "feature", "text": "Bark iOS notifications (#1095, PR#1097, @undirectlookable)" },
{ "type": "feature", "text": "Signal notifications via signal-cli-rest-api (#1099)" },
{ "type": "feature", "text": "Apprise passthrough — forward to a self-hosted caronc/apprise-api server (#1099)" },
{ "type": "fix", "text": "env editor flagged Docker/Compose built-ins as MISSING (#141)" },
{ "type": "fix", "text": "YAML editor indentation was inconsistent when pressing Enter (#1156)" },
{ "type": "feature", "text": "`dockhand.update=false`, `dockhand.hidden=true` and `localhost/*` images skip registry polling (#1083)" },
{ "type": "fix", "text": "registry authentication for image pulls (#1105)" },
{ "type": "feature", "text": "native HTTPS listener, off by default (#1102)" },
{ "type": "fix", "text": "environments stuck \"Failed\" after VPN/Tailscale tunnel drops until agent restart (#1160)" },
{ "type": "fix", "text": "health_status events flooding container_events table (#1165)" },
{ "type": "fix", "text": "git stack sync removes files deleted from the repo (hash-verified) (#966, #1162)" },
{ "type": "feature", "text": "upload TLS/mTLS certificate files in environment editor (#125)" },
{ "type": "feature", "text": "syntax highlighting for shell, Dockerfile, TOML, INI/conf and .env files in the file browser viewer (#1055)" },
{ "type": "feature", "text": "Animated icons now configurable (#1169)" },
{ "type": "fix", "text": "stack deploys ignored the env's configured socket path (#1172)" },
{ "type": "fix", "text": "environment names with characters that break path resolution (e.g. `*`) are now rejected (#1179)" }
],
"imageTag": "fnsys/dockhand:v1.0.33"
},
{
"version": "1.0.32",
"date": "2026-06-06",
"changes": [
{ "type": "feature", "text": "container details tweaks: process count, label filter, copy all labels (#812)" },
{ "type": "feature", "text": "log improvements (#1130)" },
{ "type": "fix", "text": "cleared Resources fields not persisted on container edit (#1119)" },
{ "type": "fix", "text": "long container names overflowed in activity event details dialog (#1129)" },
{ "type": "fix", "text": "git stack recreate and start operations ignored Dockhand-stored env vars (#1132)" },
{ "type": "fix", "text": "dashboard stopped count reset to 0 after refresh for gracefully stopped containers (#1133)" },
{ "type": "fix", "text": "auto-update preserves runtime `-e` env and `-l` label overrides (#1135)" },
{ "type": "fix", "text": "git stack volume binds resolved to wrong host path when compose was in a subdirectory (#1139)" },
{ "type": "fix", "text": "git stacks: subdir compose files now find their adjacent env files (#1136)" },
{ "type": "feature", "text": "env editor doesn't flag Docker/Compose built-in variables as unused (#141)" },
{ "type": "feature", "text": "container network mode: share another container's network namespace (#161)" }
],
"imageTag": "fnsys/dockhand:v1.0.32"
},
{
"version": "1.0.31",
"date": "2026-05-30",
"changes": [
{ "type": "fix", "text": "502 Bad Gateway behind nginx-based reverse proxies — SvelteKit 2.51+ bloated the Link response header, pinned to 2.50.0 (#1114)" }
],
"imageTag": "fnsys/dockhand:v1.0.31"
},
{
"version": "1.0.30",
"date": "2026-05-30",
"changes": [
{ "type": "feature", "text": "time range filter for log viewer — filter logs by From/To date and time (#1068)" },
{ "type": "feature", "text": "configurable tail line count in log viewer — choose from 100 to all lines (#1066)" },
{ "type": "feature", "text": "toggleable line numbers in log viewer (#1067)" },
{ "type": "feature", "text": "\"some unused\" image filter — show images with both used and unused tags for selective cleanup (#621)" },
{ "type": "feature", "text": "IP binding and port ranges in container port mappings (#581)" },
{ "type": "feature", "text": "remove individual containers directly from stacks page (#576)" },
{ "type": "fix", "text": "scan cache lookup by tag name never matched — results now resolved via image digest (#1064)" },
{ "type": "fix", "text": "image-baked env vars not updated during auto-update container recreation (#1061)" },
{ "type": "fix", "text": "git stack deploy via Hawser fails with \"Invalid string length\" when repo has large files (#1040)" },
{ "type": "feature", "text": "Gotify notification priority via URL query param — gotify://host/token?priority=5 (#1033)" },
{ "type": "fix", "text": "consistent action button order across container and stack views (#1079)" },
{ "type": "feature", "text": "named custom URL labels — dockhand.url=[Name](https://...) markdown syntax (#1065)" },
{ "type": "fix", "text": "HTTPS git credentials no longer leaked in process arguments (#1081)" },
{ "type": "feature", "text": "bump Docker Compose to 5.1.4 (GHSA-pmwq-pjrm-6p5r)" },
{ "type": "feature", "text": "dockhand.order label to control container display order within stacks (#847)" },
{ "type": "feature", "text": "live network attach/detach for running containers — join or leave Docker networks without restarting (#1051)" },
{ "type": "fix", "text": "environment variable values with nested quotes progressively corrupted on each save (#1036, #1086)" }
],
"imageTag": "fnsys/dockhand:v1.0.30"
},
{
"version": "1.0.29",
"date": "2026-05-17",
"changes": [
{ "type": "feature", "text": "optionally display internal (exposed) container ports alongside published ports (#193)" },
{ "type": "feature", "text": "show app version in sidebar with build info tooltip (#209)" },
{ "type": "feature", "text": "central label management — rename or delete labels across all environments (#661)" },
{ "type": "feature", "text": "find next available host port when creating or editing containers (#116)" },
{ "type": "feature", "text": "theme-aware scrollbar styling — scrollbars adapt to dark/light mode and color palettes (#462)" },
{ "type": "fix", "text": "update buttons (single, selected, and all) now respect the \"confirm dangerous actions\" setting (#638, #751)" },
{ "type": "feature", "text": "custom URL labels - dockhand.url or dockhand.port.{port}.url to add links alongside container ports (#266)" },
{ "type": "feature", "text": "generate and copy token for Hawser Standard mode with run command hint (#337)" },
{ "type": "fix", "text": "environment stack directory not cleaned up when environment is deleted (#1023)" },
{ "type": "feature", "text": "toggle to hide timestamps and container name prefix in log viewer (#124)" },
{ "type": "fix", "text": "Podman containers health status not showing (#737)" },
{ "type": "fix", "text": "containers with exit code 0 (init/migration) no longer cause stack \"partial\" status (#1026)" },
{ "type": "fix", "text": "stats stream 400 on reconnect by skipping overlapping fetches (#1044)" },
{ "type": "fix", "text": "env var validation false positive for values containing $ followed by text (#1048)" },
{ "type": "fix", "text": "git-repos directory not cleaned up when environment is deleted (#1049)" },
{ "type": "fix", "text": "webhook secret auto-generated when left empty despite hint saying otherwise (#1050)" },
{ "type": "feature", "text": "scan reports — combined or individual Grype/Trivy (#1056)" }
],
"imageTag": "fnsys/dockhand:v1.0.29"
},
{
"version": "1.0.28",
"date": "2026-05-09",
"changes": [
{ "type": "feature", "text": "context directory for git stacks — reference files from anywhere in the repo (#864)" },
{ "type": "feature", "text": "no-cache build option for git stacks (#880)" },
{ "type": "fix", "text": "env vars lost when switching between raw/form view (#964)" },
{ "type": "fix", "text": "compose name property not respected during stack scan (#922)" },
{ "type": "feature", "text": "editable schedule for scanner cache cleanup (#979)" },
{ "type": "fix", "text": "container labels cannot be deleted (#984)" },
{ "type": "fix", "text": "env var values leaked in deploy logs — now all values are redacted (#985)" },
{ "type": "fix", "text": "volume export keeps helper container alive, preventing volume prune/deletion (#983)" },
{ "type": "fix", "text": "ntfy self-hosted notifications fail when using ?auth= query parameter (#840)" },
{ "type": "fix", "text": "scrollbar appears in dashboard tiles when content overflows (#969)" },
{ "type": "fix", "text": "case-sensitive environment sort order — lowercase names sorted after uppercase (#975)" },
{ "type": "fix", "text": "inaccurate dashboard CPU gauge caused by one-shot stats flag (#932)" },
{ "type": "feature", "text": "ntfy notifications support ?tags=, ?title=, and ?priority= URL query parameters (#689)" },
{ "type": "fix", "text": "stack .env file wiped when saving from graph view (#988)" },
{ "type": "feature", "text": "dismiss update available indicators without updating (#853)" },
{ "type": "feature", "text": "public IP setting available for hawser-edge environments — enables clickable port links (#350)" },
{ "type": "fix", "text": "git stack creation silently destroys existing stacks with the same name (#1001)" },
{ "type": "feature", "text": "static IP/MAC address configuration for containers (#297)" }
],
"imageTag": "fnsys/dockhand:v1.0.28"
},
{
"version": "1.0.27",
"comingSoon": false,
"date": "2026-04-26",
"changes": [
{ "type": "feature", "text": "network graph visualization on networks page (#894, @Penlane)" },
{ "type": "feature", "text": "customizable compose template for new stacks in settings (#632, @oratory)" },
{ "type": "feature", "text": "Microsoft Teams notifications via Power Automate Workflows (#355, @slokhorst)" },
{ "type": "feature", "text": "container label controls: dockhand.update, dockhand.hidden, dockhand.notify (#6, #53, #94, #215)" },
{ "type": "feature", "text": "configurable label filter matching mode (any/all) for environment dashboard (#607)" },
{ "type": "feature", "text": "log search filter mode to hide non-matching lines (#916)" },
{ "type": "feature", "text": "inline terminal on logs page with resizable split layout (#900)" },
{ "type": "fix", "text": "disable Telegram link preview in notifications (#910, @deenle)" },
{ "type": "fix", "text": "cron editor rejects 6-field expressions with seconds (#839, @GiulioSavini)" },
{ "type": "fix", "text": "mirror Dockhand's ExtraHosts into scanner and self-update containers (#836, @YewFence)" },
{ "type": "fix", "text": "duplicate volume binds during container recreate (#765, @itsDNNS)" },
{ "type": "fix", "text": "log timestamp formatting not applied on main logs page (#882)" },
{ "type": "fix", "text": "uploaded files now inherit container user ownership (#732, @ivanjx)" },
{ "type": "fix", "text": "extraneous backslash in Telegram notification environment name (#955)" },
{ "type": "fix", "text": "collapse ports into ranges only if 3 or more consecutive ports" },
{ "type": "fix", "text": "git operations auto-merge system CAs with custom cert (#967)" }
],
"imageTag": "fnsys/dockhand:v1.0.27"
},
{
"version": "1.0.26",
"date": "2026-04-19",
"changes": [
{ "type": "feature", "text": "persist sort order across page navigation for all data grids (#861, #912)" },
{ "type": "feature", "text": "show git repository URL and branch in git stack edit modal (#856)" },
{ "type": "feature", "text": "show memory limit alongside usage in containers and stacks views (#893)" },
{ "type": "feature", "text": "option to delete associated volumes when removing a stack (#655)" },
{ "type": "feature", "text": "collapse consecutive port mappings into ranges in container list (#821)" },
{ "type": "fix", "text": "bearer token authentication fails with enterprise license active" },
{ "type": "fix", "text": "clicking stack name toggles stats accordion instead of just opening editor (#628)" },
{ "type": "fix", "text": "scheduled image prune notifications missing environment name (#770)" },
{ "type": "fix", "text": "Gotify, ntfy, Pushover, and webhook notifications missing environment name (#943)" },
{ "type": "fix", "text": "MFA code field not recognized by Bitwarden and other password managers (#566)" }
],
"imageTag": "fnsys/dockhand:v1.0.26"
},
{
"version": "1.0.25",
"date": "2026-04-18",
"comingSoon": false,
"changes": [
{ "type": "feature", "text": "API token authentication — Bearer tokens for CI/CD pipelines and scripts" },
{ "type": "feature", "text": "Telegram topic support — send notifications to supergroup topics (#855)" },
{ "type": "fix", "text": "allow removing healthcheck, ports, and honor startAfterUpdate=false during container edit (#892)" },
{ "type": "fix", "text": "validate stack names and prevent broken DB entries on invalid input (#876)" },
{ "type": "fix", "text": "use per-environment timezone for schedule execution log timestamps (#882)" },
{ "type": "fix", "text": "\"Pull image before update\" and \"Start after update\" settings ignored (#909)" },
{ "type": "fix", "text": "image prune timeout on hawser-standard when pruning many images (#905)" },
{ "type": "fix", "text": "bump Docker Compose to 5.1.3" },
{ "type": "fix", "text": "mask secret environment variables in container inspect modal (#924)" },
{ "type": "fix", "text": "viewer role can toggle, delete, and run schedules (#923)" },
{ "type": "fix", "text": "settings show defaults instead of saved values after login until page refresh (#921)" },
{ "type": "fix", "text": "settings toggle notifications show wrong state (#931)" },
{ "type": "fix", "text": "stack memory tooltip shows inflated total on multi-container stacks (#936)" }
],
"imageTag": "fnsys/dockhand:v1.0.25"
},
{
"version": "1.0.24",
"date": "2026-04-03",
"changes": [
{ "type": "fix", "text": "browsing HTTP registries fails with SSL error (#868)" },
{ "type": "fix", "text": "git stack deploy options (build, re-pull, force redeploy) not persisted in edit dialog" }
],
"imageTag": "fnsys/dockhand:v1.0.24"
},
{
"version": "1.0.23",
"date": "2026-04-03",
-274
View File
@@ -1,274 +0,0 @@
/**
* API Token Management
*
* Provides Bearer token authentication for CI/CD pipelines and scripts.
* Tokens use `dh_` prefix, Argon2id hashing, and prefix-based lookup.
*
* Performance: An in-memory cache (SHA-256 key, 60s TTL) avoids running
* Argon2id on every request. First request: ~100ms. Subsequent: ~0ms.
*/
import { createHash } from 'node:crypto';
import { db, eq, and } from '$lib/server/db/drizzle';
import { hashPassword, verifyPassword, type AuthenticatedUser } from './auth';
import { secureRandomBytes } from './crypto-fallback';
import { getUserRoles, userHasAdminRole, type Permissions } from './db';
import { isEnterprise } from './license';
import { tokenCache, ensureCleanupInterval, invalidateTokenCacheForUser, clearTokenCache } from './token-cache';
// Re-export cache functions so existing consumers don't need to change imports
export { invalidateTokenCacheForUser, clearTokenCache } from './token-cache';
// Dynamic schema import (same pattern as db.ts)
let apiTokensTable: any;
async function getApiTokensTable() {
if (apiTokensTable) return apiTokensTable;
const isPostgres = !!(process.env.DATABASE_URL && (
process.env.DATABASE_URL.startsWith('postgres://') ||
process.env.DATABASE_URL.startsWith('postgresql://')
));
const schema = isPostgres
? await import('./db/schema/pg-schema.js')
: await import('./db/schema/index.js');
apiTokensTable = schema.apiTokens;
return apiTokensTable;
}
// Token format: dh_ + 32 bytes base64url = dh_ + 43 chars
const TOKEN_PREFIX = 'dh_';
const TOKEN_BYTES = 32;
const PREFIX_LENGTH = 8; // chars after dh_ stored for identification
const MAX_TOKEN_LENGTH = 200;
const CACHE_TTL = 60_000; // 60 seconds
function cacheKey(rawToken: string): string {
return createHash('sha256').update(rawToken).digest('hex');
}
// Pre-computed dummy hash for timing protection on invalid prefixes
let dummyHash: string | null = null;
async function getDummyHash(): Promise<string> {
if (!dummyHash) {
dummyHash = await hashPassword('dh_dummy_token_for_timing_protection');
}
return dummyHash;
}
// Initialize dummy hash on import (fire and forget)
void getDummyHash();
/**
* Generate a new API token.
* Returns the plaintext token (shown once) and the database record.
*/
export async function generateApiToken(
userId: number,
name: string,
expiresAt?: string | null
): Promise<{ token: string; id: number; tokenPrefix: string }> {
const table = await getApiTokensTable();
// Generate random token
const randomBytes = secureRandomBytes(TOKEN_BYTES);
const rawToken = TOKEN_PREFIX + randomBytes.toString('base64url');
const tokenPrefix = rawToken.substring(TOKEN_PREFIX.length, TOKEN_PREFIX.length + PREFIX_LENGTH);
// Hash for storage
const tokenHash = await hashPassword(rawToken);
const result = await db.insert(table).values({
userId,
name,
tokenHash,
tokenPrefix,
expiresAt: expiresAt || null
}).returning();
return {
token: rawToken,
id: result[0].id,
tokenPrefix
};
}
/**
* Validate a Bearer token and return the associated user.
* Uses cache to avoid Argon2id on every request.
*/
export async function validateApiToken(rawToken: string): Promise<AuthenticatedUser | null> {
// Input validation
if (!rawToken || rawToken.length > MAX_TOKEN_LENGTH || !rawToken.startsWith(TOKEN_PREFIX)) {
return null;
}
// Check cache first
ensureCleanupInterval();
const key = cacheKey(rawToken);
const cached = tokenCache.get(key);
if (cached && cached.expiresAt > Date.now()) {
return cached.user;
}
const table = await getApiTokensTable();
// Extract prefix for lookup
const prefix = rawToken.substring(TOKEN_PREFIX.length, TOKEN_PREFIX.length + PREFIX_LENGTH);
// Find tokens with matching prefix (deleted tokens are gone, no isActive filter needed)
const candidates = await db
.select()
.from(table)
.where(eq(table.tokenPrefix, prefix));
if (candidates.length === 0) {
// Timing protection: run Argon2id anyway
await verifyPassword(rawToken, await getDummyHash());
return null;
}
// Verify against each candidate (usually just one)
for (const candidate of candidates) {
const valid = await verifyPassword(rawToken, candidate.tokenHash);
if (!valid) continue;
// Check expiration AFTER hash verification to avoid timing oracle
if (candidate.expiresAt && new Date(candidate.expiresAt) < new Date()) {
continue;
}
// Build AuthenticatedUser from the token's user
const user = await buildUserFromToken(candidate);
if (!user) continue;
// Update lastUsed (fire and forget — non-critical audit field)
void db.update(table)
.set({ lastUsed: new Date().toISOString() })
.where(eq(table.id, candidate.id))
.catch((err) => {
if (typeof process !== 'undefined' && process.env.DB_VERBOSE_LOGGING === 'true') {
console.debug('[api-tokens] lastUsed update failed:', err?.message);
}
});
// Cache the result — cap TTL at token expiry time if sooner
let cacheTtl = CACHE_TTL;
if (candidate.expiresAt) {
const timeUntilExpiry = new Date(candidate.expiresAt).getTime() - Date.now();
if (timeUntilExpiry < cacheTtl) {
cacheTtl = Math.max(0, timeUntilExpiry);
}
}
tokenCache.set(key, { user, expiresAt: Date.now() + cacheTtl });
return user;
}
return null;
}
/**
* Build an AuthenticatedUser from a token's database record.
*/
async function buildUserFromToken(tokenRecord: any): Promise<AuthenticatedUser | null> {
// Import getUserWithoutPassword dynamically to avoid circular deps
// This avoids keeping passwordHash in memory unnecessarily
const { getUserWithoutPassword } = await import('./db');
const dbUser = await getUserWithoutPassword(tokenRecord.userId);
if (!dbUser || !dbUser.isActive) return null;
const enterprise = await isEnterprise();
let isAdmin = false;
let permissions: Permissions;
if (!enterprise) {
// Free edition: everyone is effectively admin
isAdmin = true;
const { getRoleByName } = await import('./db');
const adminRole = await getRoleByName('Admin');
permissions = adminRole?.permissions ?? {} as Permissions;
} else {
isAdmin = await userHasAdminRole(dbUser.id);
const userRoleAssignments = await getUserRoles(dbUser.id);
// Merge permissions from all roles
permissions = {} as Permissions;
for (const assignment of userRoleAssignments) {
if (!assignment.role) continue;
const rolePerms = typeof assignment.role.permissions === 'string'
? JSON.parse(assignment.role.permissions)
: assignment.role.permissions;
if (!rolePerms) continue;
for (const [key, actions] of Object.entries(rolePerms)) {
if (!permissions[key as keyof Permissions]) {
permissions[key as keyof Permissions] = [];
}
for (const action of actions as string[]) {
if (!permissions[key as keyof Permissions].includes(action)) {
permissions[key as keyof Permissions].push(action);
}
}
}
}
}
// Determine provider from authProvider field
let provider: 'local' | 'ldap' | 'oidc' = 'local';
if (dbUser.authProvider?.startsWith('ldap')) provider = 'ldap';
else if (dbUser.authProvider?.startsWith('oidc')) provider = 'oidc';
return {
id: dbUser.id,
username: dbUser.username,
email: dbUser.email ?? undefined,
displayName: dbUser.displayName ?? undefined,
avatar: dbUser.avatar ?? undefined,
isAdmin,
provider,
permissions
};
}
/**
* List all tokens for a user (no hashes returned).
*/
export async function listUserTokens(userId: number) {
const table = await getApiTokensTable();
return db
.select({
id: table.id,
name: table.name,
tokenPrefix: table.tokenPrefix,
lastUsed: table.lastUsed,
expiresAt: table.expiresAt,
createdAt: table.createdAt
})
.from(table)
.where(eq(table.userId, userId));
}
/**
* Revoke (delete) a token. Owner or admin can revoke.
*/
export async function revokeApiToken(tokenId: number, requestingUserId: number, isAdmin: boolean): Promise<boolean> {
const table = await getApiTokensTable();
// Find the token
const [token] = await db.select().from(table).where(eq(table.id, tokenId));
if (!token) return false;
// Check ownership or admin
if (token.userId !== requestingUserId && !isAdmin) {
return false;
}
// Hard-delete
await db.delete(table).where(eq(table.id, tokenId));
// Clear cache — we can't map prefix to SHA-256 cache keys, so clear all
clearTokenCache();
return true;
}
+3 -5
View File
@@ -9,7 +9,6 @@ import type { RequestEvent } from '@sveltejs/kit';
import { isEnterprise } from './license';
import { logAuditEvent, type AuditAction, type AuditEntityType, type AuditLogCreateData } from './db';
import { authorize } from './authorize';
import { getRequestContext } from './request-context';
export interface AuditContext {
userId?: number | null;
@@ -22,8 +21,7 @@ export interface AuditContext {
* Extract audit context from a request event
*/
export async function getAuditContext(event: RequestEvent): Promise<AuditContext> {
const ctx = getRequestContext();
const user = ctx?.user ?? (await authorize(event.cookies)).user;
const auth = await authorize(event.cookies);
// Get IP address from various headers (proxied requests)
const forwardedFor = event.request.headers.get('x-forwarded-for');
@@ -42,8 +40,8 @@ export async function getAuditContext(event: RequestEvent): Promise<AuditContext
const userAgent = event.request.headers.get('user-agent') || null;
return {
userId: user?.id ?? null,
username: user?.username ?? 'anonymous',
userId: auth.user?.id ?? null,
username: auth.user?.username ?? 'anonymous',
ipAddress,
userAgent
};
+5 -39
View File
@@ -44,7 +44,6 @@ import {
import { Client as LdapClient } from 'ldapts';
import { isEnterprise } from './license';
import { secureRandomBytes } from './crypto-fallback';
import { invalidateTokenCacheForUser } from './token-cache';
// Session cookie name
const SESSION_COOKIE_NAME = 'dockhand_session';
@@ -137,14 +136,6 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
}
}
let dummyAuthHashCache: Promise<string> | null = null;
export function getDummyAuthHash(): Promise<string> {
if (!dummyAuthHashCache) {
dummyAuthHashCache = hashPassword(`dummy-${Math.random()}-${Date.now()}`);
}
return dummyAuthHashCache;
}
// ============================================
// Session Management
// ============================================
@@ -231,7 +222,7 @@ function setSessionCookie(cookies: Cookies, sessionId: string, maxAge: number, r
path: '/',
httpOnly: true, // Prevents XSS attacks from reading cookie
secure: isSecureContext(request), // Protocol-aware: checks x-forwarded-proto or NODE_ENV
sameSite: 'lax', // Lax required for OIDC/SSO cross-site redirects
sameSite: 'strict', // CSRF protection
maxAge: maxAge // Session timeout in seconds
});
}
@@ -249,22 +240,11 @@ function getSessionIdFromCookies(cookies: Cookies): string | null {
export async function validateSession(cookies: Cookies): Promise<AuthenticatedUser | null> {
const sessionId = getSessionIdFromCookies(cookies);
if (!sessionId) return null;
return validateSessionById(sessionId);
}
/**
* Validate a session by raw session ID (without the SvelteKit Cookies object).
*
* Used by WebSocket upgrade handlers in server.js / vite.config.ts that only
* have a raw Cookie header string. Mirrors validateSession() semantics:
* returns the AuthenticatedUser on success, null on missing/expired/disabled.
*/
export async function validateSessionById(sessionId: string): Promise<AuthenticatedUser | null> {
if (!sessionId) return null;
const session = await dbGetSession(sessionId);
if (!session) return null;
// Check if session is expired
const expiresAt = new Date(session.expiresAt);
if (expiresAt < new Date()) {
await dbDeleteSession(sessionId);
@@ -277,13 +257,6 @@ export async function validateSessionById(sessionId: string): Promise<Authentica
return await buildAuthenticatedUser(user, session.provider as 'local' | 'ldap' | 'oidc');
}
/**
* Cookie name used for browser session auth. Exported so raw header parsers
* (WebSocket upgrade handlers) can look it up without re-encoding the
* constant.
*/
export const SESSION_COOKIE = SESSION_COOKIE_NAME;
/**
* Destroy a session (logout)
*/
@@ -487,14 +460,13 @@ export async function authenticateLocal(
const user = await getUserByUsername(username);
if (!user) {
await verifyPassword(password, await getDummyAuthHash());
// Use constant time to prevent timing attacks
await hashPassword('dummy');
return { success: false, error: 'Invalid username or password' };
}
if (!user.isActive) {
await verifyPassword(password, await getDummyAuthHash());
console.warn(`[Auth] Login attempt for disabled account: user=${username}`);
return { success: false, error: 'Invalid username or password' };
return { success: false, error: 'Account is disabled' };
}
const validPassword = await verifyPassword(password, user.passwordHash);
@@ -763,9 +735,6 @@ async function tryLdapAuth(
}
}
// Clear cached token permissions after role sync
invalidateTokenCacheForUser(user.id);
if (!user.isActive) {
return { success: false, error: 'Account is disabled' };
}
@@ -1480,9 +1449,6 @@ export async function handleOidcCallback(
}
}
// Clear cached token permissions after role sync
invalidateTokenCacheForUser(user.id);
if (!user.isActive) {
return { success: false, error: 'Account is disabled' };
}
+7 -11
View File
@@ -40,7 +40,6 @@ import type { Permissions } from './db';
import { getUserAccessibleEnvironments, userCanAccessEnvironment, userHasAdminRole } from './db';
import { validateSession, isAuthEnabled, checkPermission, type AuthenticatedUser } from './auth';
import { isEnterprise } from './license';
import { getRequestContext } from './request-context';
export interface AuthorizationContext {
/** Whether authentication is enabled globally */
@@ -114,10 +113,7 @@ export interface AuthorizationContext {
export async function authorize(cookies: Cookies): Promise<AuthorizationContext> {
const authEnabled = await isAuthEnabled();
const enterprise = await isEnterprise();
// Try request context first (set by hook — handles both cookie and Bearer)
const reqCtx = getRequestContext();
const user = reqCtx?.user ?? (authEnabled ? await validateSession(cookies) : null);
const user = authEnabled ? await validateSession(cookies) : null;
// Determine admin status:
// - Free edition: all authenticated users are effectively admins (full access)
@@ -159,8 +155,8 @@ export async function authorize(cookies: Cookies): Promise<AuthorizationContext>
// Must be authenticated
if (!user) return false;
// Admins can access all environments (use fresh isAdmin, not cached user.isAdmin)
if (isAdmin) return true;
// Admins can access all environments
if (user.isAdmin) return true;
// In free edition, all authenticated users have full access
if (!enterprise) return true;
@@ -176,8 +172,8 @@ export async function authorize(cookies: Cookies): Promise<AuthorizationContext>
// Must be authenticated
if (!user) return [];
// Admins can access all environments (use fresh isAdmin, not cached user.isAdmin)
if (isAdmin) return null;
// Admins can access all environments
if (user.isAdmin) return null;
// In free edition, all authenticated users have full access
if (!enterprise) return null;
@@ -193,8 +189,8 @@ export async function authorize(cookies: Cookies): Promise<AuthorizationContext>
// Must be authenticated
if (!user) return false;
// Admins can always manage users (use fresh isAdmin, not cached user.isAdmin)
if (isAdmin) return true;
// Admins can always manage users
if (user.isAdmin) return true;
// In free edition, all authenticated users have full access
if (!enterprise) return true;
-41
View File
@@ -1,41 +0,0 @@
/**
* Resolve the client IP for rate limiting, logging, and audit.
*
* Defaults to the socket-level IP via getClientAddress(). X-Forwarded-For
* is consulted only when TRUST_FORWARDED_HEADERS=true is set explicitly
* intended for deployments behind a reverse proxy (Traefik, nginx, Caddy)
* that controls XFF. In that mode the right-most XFF entry (closest to the
* trusted proxy) is returned; earlier entries in the chain are ignored.
*/
type IpEventLike = {
request: Request;
getClientAddress?: () => string;
};
function normalize(ip: string | null | undefined): string {
if (!ip) return 'unknown';
if (ip === '::1' || ip === '::ffff:127.0.0.1') return '127.0.0.1';
if (ip.startsWith('::ffff:')) return ip.substring(7);
return ip;
}
export function getClientIp(event: IpEventLike): string {
if (process.env.TRUST_FORWARDED_HEADERS === 'true') {
const xff = event.request.headers.get('x-forwarded-for');
if (xff) {
const parts = xff.split(',').map((p) => p.trim()).filter(Boolean);
if (parts.length > 0) return normalize(parts[parts.length - 1]);
}
const realIp = event.request.headers.get('x-real-ip');
if (realIp) return normalize(realIp.trim());
}
try {
const addr = event.getClientAddress?.();
if (addr) return normalize(addr);
} catch {
// getClientAddress may throw if unavailable (test contexts, raw upgrades)
}
return 'unknown';
}
@@ -1,72 +0,0 @@
/**
* Helpers for surfacing env/label divergence between a running
* container and its image. Pure read-only never used to mutate
* the container; used only to power UI hints.
*
* Background: as of #1135 / commit 0f989bd7 revert, Dockhand no
* longer "merges" image-baked env or labels into a container during
* auto-update. The container's Config.Env and Config.Labels are
* preserved verbatim (so a user's runtime `-e` / `-l` override is
* never silently wiped). The trade-off, originally raised by #1061,
* is that an image's updated default env/label values do not
* automatically propagate to running containers.
*
* These helpers let the UI surface "this container's value differs
* from the image's current value" so users can decide whether to
* Remove & Deploy. We do NOT try to classify "user-set vs
* image-baked" that information isn't recoverable from Docker.
*/
/** Parse a Docker env list (`KEY=value` strings) into a Map. */
function parseEnv(entries: string[]): Map<string, string> {
const m = new Map<string, string>();
for (const e of entries) {
const i = e.indexOf('=');
if (i === -1) {
m.set(e, '');
} else {
m.set(e.slice(0, i), e.slice(i + 1));
}
}
return m;
}
/**
* Keys where the container's env value differs from the image's
* CURRENT env value. Keys present in only one side are excluded
* they're either user-only or image-only, neither of which is
* "divergence" we can usefully act on.
*/
export function detectImageEnvDivergence(
containerEnv: string[],
imageEnv: string[]
): string[] {
const cont = parseEnv(containerEnv);
const img = parseEnv(imageEnv);
const diff: string[] = [];
for (const [k, v] of cont) {
if (img.has(k) && img.get(k) !== v) {
diff.push(k);
}
}
return diff;
}
/**
* Keys where the container's label value differs from the image's
* CURRENT label value. Same semantics as detectImageEnvDivergence.
*/
export function detectImageLabelDivergence(
containerLabels: Record<string, string> | null | undefined,
imageLabels: Record<string, string> | null | undefined
): string[] {
const cont = containerLabels || {};
const img = imageLabels || {};
const diff: string[] = [];
for (const [k, v] of Object.entries(cont)) {
if (k in img && img[k] !== v) {
diff.push(k);
}
}
return diff;
}
-116
View File
@@ -1,116 +0,0 @@
/**
* Dockhand Container Label Controls
*
* Docker container labels that control Dockhand behavior:
* - dockhand.update=false Skip this container during auto-updates and batch updates
* - dockhand.hidden=true Hide this container from the Dockhand UI
* - dockhand.notify=false Suppress notifications for this container's events
* - dockhand.url=<url> Custom clickable URL displayed alongside container ports
* - dockhand.port.<hostPort>.url=<url> Override the click URL for a specific published port
* - dockhand.order=<int> Controls display order within a stack (lower = first, default 0)
*
* All label values are case-insensitive and accept: true/yes/1 and false/no/0.
* The opt-out model means labels override DB settings (label wins).
*/
/** Recognized Dockhand label keys */
export const DOCKHAND_LABELS = {
UPDATE: 'dockhand.update',
HIDDEN: 'dockhand.hidden',
NOTIFY: 'dockhand.notify',
URL: 'dockhand.url',
ORDER: 'dockhand.order',
} as const;
const TRUTHY_VALUES = new Set(['true', 'yes', '1']);
const FALSY_VALUES = new Set(['false', 'no', '0']);
/**
* Parse a label value as a boolean.
* Returns true for: true, TRUE, yes, YES, 1
* Returns false for: false, FALSE, no, NO, 0
* Returns undefined for missing or unrecognized values.
*/
function parseLabelBool(value: string | undefined | null): boolean | undefined {
if (value == null) return undefined;
const normalized = value.trim().toLowerCase();
if (TRUTHY_VALUES.has(normalized)) return true;
if (FALSY_VALUES.has(normalized)) return false;
return undefined;
}
/**
* Get a label value from a Docker labels object.
*/
function getLabel(labels: Record<string, string> | undefined | null, key: string): string | undefined {
if (!labels) return undefined;
return labels[key];
}
/**
* Check if a container should be skipped during auto-updates.
* Returns true if dockhand.update is explicitly set to false/no/0.
* Default (no label): allow updates (opt-out model).
*/
export function isUpdateDisabledByLabel(labels: Record<string, string> | undefined | null): boolean {
const value = parseLabelBool(getLabel(labels, DOCKHAND_LABELS.UPDATE));
return value === false; // explicitly disabled
}
/**
* Check if a container should be hidden from the UI.
* Returns true if dockhand.hidden is explicitly set to true/yes/1.
* Default (no label): visible (opt-out model).
*/
export function isHiddenByLabel(labels: Record<string, string> | undefined | null): boolean {
const value = parseLabelBool(getLabel(labels, DOCKHAND_LABELS.HIDDEN));
return value === true; // explicitly hidden
}
/**
* Check if notifications should be suppressed for this container.
* Returns true if dockhand.notify is explicitly set to false/no/0.
* Default (no label): send notifications (opt-out model).
*/
export function isNotifyDisabledByLabel(labels: Record<string, string> | undefined | null): boolean {
const value = parseLabelBool(getLabel(labels, DOCKHAND_LABELS.NOTIFY));
return value === false; // explicitly disabled
}
/**
* Get the custom URL from dockhand.url label.
* Returns the URL string if set, or undefined.
*/
export function getCustomUrl(labels: Record<string, string> | undefined | null): string | undefined {
const value = getLabel(labels, DOCKHAND_LABELS.URL);
return value?.trim() || undefined;
}
/**
* Get the sort order value from dockhand.order label.
* Returns the parsed integer, or 0 for missing/invalid values.
*/
export function getOrderValue(labels: Record<string, string> | undefined | null): number {
const value = getLabel(labels, DOCKHAND_LABELS.ORDER);
if (value == null) return 0;
const parsed = parseInt(value.trim(), 10);
return Number.isNaN(parsed) ? 0 : parsed;
}
/**
* Extract all Dockhand label states from a container's labels.
* Useful for including in API responses so the frontend knows about label overrides.
*/
export function getDockhandLabels(labels: Record<string, string> | undefined | null): {
updateDisabled: boolean;
hidden: boolean;
notifyDisabled: boolean;
customUrl?: string;
} {
return {
updateDisabled: isUpdateDisabledByLabel(labels),
hidden: isHiddenByLabel(labels),
notifyDisabled: isNotifyDisabledByLabel(labels),
customUrl: getCustomUrl(labels),
};
}
+16 -240
View File
@@ -78,7 +78,6 @@ import {
import type { AllGridPreferences, GridId, GridColumnPreferences } from '$lib/types';
import { encrypt, decrypt } from './encryption.js';
import { parseEnvInterpolation } from './env-interpolation';
// Re-export for backwards compatibility
export { db, isPostgres, isSqlite };
@@ -113,7 +112,7 @@ export function initDatabase() {
// =============================================================================
export async function getEnvironments(): Promise<Environment[]> {
const results = await db.select().from(environments).orderBy(sql`lower(${environments.name})`);
const results = await db.select().from(environments).orderBy(asc(environments.name));
return results.map((e: Environment) => ({
...e,
tlsKey: decrypt(e.tlsKey),
@@ -388,17 +387,15 @@ export async function getUserThemePreferences(userId: number): Promise<{
gridFontSize: string;
terminalFont: string;
editorFont: string;
animateIcons: boolean;
}> {
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, animateIcons] = await Promise.all([
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont] = await Promise.all([
getUserSetting(userId, 'light_theme'),
getUserSetting(userId, 'dark_theme'),
getUserSetting(userId, 'font'),
getUserSetting(userId, 'font_size'),
getUserSetting(userId, 'grid_font_size'),
getUserSetting(userId, 'terminal_font'),
getUserSetting(userId, 'editor_font'),
getUserSetting(userId, 'animate_icons')
getUserSetting(userId, 'editor_font')
]);
return {
lightTheme: lightTheme || 'default',
@@ -407,15 +404,13 @@ export async function getUserThemePreferences(userId: number): Promise<{
fontSize: fontSize || 'normal',
gridFontSize: gridFontSize || 'normal',
terminalFont: terminalFont || 'system-mono',
editorFont: editorFont || 'system-mono',
// Default ON — only false when explicitly stored
animateIcons: animateIcons === 'false' ? false : true
editorFont: editorFont || 'system-mono'
};
}
export async function setUserThemePreferences(
userId: number,
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean }
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string }
): Promise<void> {
const updates: Promise<void>[] = [];
if (prefs.lightTheme !== undefined) {
@@ -439,9 +434,6 @@ export async function setUserThemePreferences(
if (prefs.editorFont !== undefined) {
updates.push(setUserSetting(userId, 'editor_font', prefs.editorFont));
}
if (prefs.animateIcons !== undefined) {
updates.push(setUserSetting(userId, 'animate_icons', prefs.animateIcons ? 'true' : 'false'));
}
await Promise.all(updates);
}
@@ -1193,37 +1185,6 @@ export async function getUser(id: number): Promise<UserData | null> {
return results[0] as UserData || null;
}
export interface SafeUserData {
id: number;
username: string;
email: string | null;
displayName: string | null;
avatar: string | null;
authProvider: string | null;
mfaEnabled: boolean;
isActive: boolean;
lastLogin: string | null;
createdAt: string;
updatedAt: string;
}
export async function getUserWithoutPassword(id: number): Promise<SafeUserData | null> {
const results = await db.select({
id: users.id,
username: users.username,
email: users.email,
displayName: users.displayName,
avatar: users.avatar,
authProvider: users.authProvider,
mfaEnabled: users.mfaEnabled,
isActive: users.isActive,
lastLogin: users.lastLogin,
createdAt: users.createdAt,
updatedAt: users.updatedAt
}).from(users).where(eq(users.id, id));
return results[0] as SafeUserData || null;
}
export async function hasAdminUser(): Promise<boolean> {
// Check if any user has the Admin role assigned
const adminRole = await db.select().from(roles).where(eq(roles.name, 'Admin')).limit(1);
@@ -2074,7 +2035,6 @@ export async function getGitStacksByRepositoryId(repositoryId: number): Promise<
}
export async function deleteGitRepository(id: number): Promise<boolean> {
console.log(`[GitStack] Deleting git repository id=${id} (will cascade-delete git_stacks, set null on stack_sources FKs)`);
await db.delete(gitRepositories).where(eq(gitRepositories.id, id));
return true;
}
@@ -2095,16 +2055,13 @@ export interface GitStackData {
autoUpdateCron: string;
webhookEnabled: boolean;
webhookSecret: string | null;
contextDir: string | null;
buildOnDeploy: boolean;
noBuildCache: boolean;
repullImages: boolean;
forceRedeploy: boolean;
lastSync: string | null;
lastCommit: string | null;
syncStatus: GitSyncStatus;
syncError: string | null;
syncedFiles?: string | null; // JSON manifest { commit, files: { relPath: sha256 } } from last successful deploy
createdAt: string;
updatedAt: string;
}
@@ -2134,11 +2091,6 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2167,11 +2119,6 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2200,9 +2147,7 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
@@ -2235,9 +2180,7 @@ export async function getGitStacksForEnvironmentOnly(environmentId: number): Pro
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
@@ -2268,9 +2211,7 @@ export async function getGitStacksForEnvironmentOnly(environmentId: number): Pro
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
@@ -2302,16 +2243,13 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
syncError: gitStacks.syncError,
syncedFiles: gitStacks.syncedFiles,
createdAt: gitStacks.createdAt,
updatedAt: gitStacks.updatedAt,
repoName: gitRepositories.name,
@@ -2337,16 +2275,13 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
syncError: row.syncError,
syncedFiles: row.syncedFiles ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
repository: {
@@ -2372,9 +2307,7 @@ export async function getGitStackByName(stackName: string, environmentId?: numbe
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
@@ -2411,9 +2344,7 @@ export async function getGitStackByName(stackName: string, environmentId?: numbe
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
@@ -2445,9 +2376,7 @@ export async function getGitStackByWebhookSecret(secret: string): Promise<GitSta
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
@@ -2479,9 +2408,7 @@ export async function getGitStackByWebhookSecret(secret: string): Promise<GitSta
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
@@ -2511,9 +2438,7 @@ export async function createGitStack(data: {
autoUpdateCron?: string;
webhookEnabled?: boolean;
webhookSecret?: string | null;
contextDir?: string | null;
buildOnDeploy?: boolean;
noBuildCache?: boolean;
repullImages?: boolean;
forceRedeploy?: boolean;
}): Promise<GitStackWithRepo> {
@@ -2523,14 +2448,12 @@ export async function createGitStack(data: {
repositoryId: data.repositoryId,
composePath: data.composePath || 'compose.yaml',
envFilePath: data.envFilePath || null,
contextDir: data.contextDir || null,
autoUpdate: data.autoUpdate || false,
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
webhookEnabled: data.webhookEnabled || false,
webhookSecret: data.webhookSecret || null,
buildOnDeploy: data.buildOnDeploy ?? false,
noBuildCache: data.noBuildCache ?? false,
repullImages: data.repullImages ?? false,
forceRedeploy: data.forceRedeploy ?? false
}).returning();
@@ -2549,23 +2472,19 @@ export async function updateGitStack(id: number, data: Partial<GitStackData>): P
if (data.autoUpdateCron !== undefined) updateData.autoUpdateCron = data.autoUpdateCron;
if (data.webhookEnabled !== undefined) updateData.webhookEnabled = data.webhookEnabled;
if (data.webhookSecret !== undefined) updateData.webhookSecret = data.webhookSecret;
if (data.contextDir !== undefined) updateData.contextDir = data.contextDir;
if (data.buildOnDeploy !== undefined) updateData.buildOnDeploy = data.buildOnDeploy;
if (data.noBuildCache !== undefined) updateData.noBuildCache = data.noBuildCache;
if (data.repullImages !== undefined) updateData.repullImages = data.repullImages;
if (data.forceRedeploy !== undefined) updateData.forceRedeploy = data.forceRedeploy;
if (data.lastSync !== undefined) updateData.lastSync = data.lastSync;
if (data.lastCommit !== undefined) updateData.lastCommit = data.lastCommit;
if (data.syncStatus !== undefined) updateData.syncStatus = data.syncStatus;
if (data.syncError !== undefined) updateData.syncError = data.syncError;
if (data.syncedFiles !== undefined) updateData.syncedFiles = data.syncedFiles;
await db.update(gitStacks).set(updateData).where(eq(gitStacks.id, id));
return getGitStack(id);
}
export async function deleteGitStack(id: number): Promise<boolean> {
console.log(`[GitStack] Deleting git_stacks row id=${id}`);
await db.delete(gitStacks).where(eq(gitStacks.id, id));
return true;
}
@@ -2590,9 +2509,7 @@ export async function getEnabledAutoUpdateGitStacks(): Promise<GitStackWithRepo[
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
@@ -2622,9 +2539,7 @@ export async function getEnabledAutoUpdateGitStacks(): Promise<GitStackWithRepo[
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
@@ -2655,9 +2570,7 @@ export async function getAllAutoUpdateGitStacks(): Promise<GitStackWithRepo[]> {
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
@@ -2686,9 +2599,7 @@ export async function getAllAutoUpdateGitStacks(): Promise<GitStackWithRepo[]> {
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
@@ -2833,21 +2744,11 @@ export async function upsertStackSource(data: {
const existing = await getStackSource(data.stackName, data.environmentId);
if (existing) {
const newRepoId = data.gitRepositoryId || null;
const newStackId = data.gitStackId || null;
const changes: string[] = [];
if (data.sourceType !== existing.sourceType) changes.push(`sourceType: ${existing.sourceType}${data.sourceType}`);
if (newRepoId !== existing.gitRepositoryId) changes.push(`gitRepoId: ${existing.gitRepositoryId}${newRepoId}`);
if (newStackId !== existing.gitStackId) changes.push(`gitStackId: ${existing.gitStackId}${newStackId}`);
if (changes.length > 0) {
console.log(`[GitStack] Updating stack_sources "${data.stackName}" env=${data.environmentId}: ${changes.join(', ')}`);
}
await db.update(stackSources)
.set({
sourceType: data.sourceType,
gitRepositoryId: newRepoId,
gitStackId: newStackId,
gitRepositoryId: data.gitRepositoryId || null,
gitStackId: data.gitStackId || null,
composePath: data.composePath ?? null,
envPath: data.envPath ?? null,
updatedAt: new Date().toISOString()
@@ -2855,7 +2756,6 @@ export async function upsertStackSource(data: {
.where(eq(stackSources.id, existing.id));
return getStackSource(data.stackName, data.environmentId) as Promise<StackSourceData>;
} else {
console.log(`[GitStack] Creating stack_sources "${data.stackName}" env=${data.environmentId} type=${data.sourceType} repoId=${data.gitRepositoryId || null} stackId=${data.gitStackId || null}`);
await db.insert(stackSources).values({
stackName: data.stackName,
environmentId: data.environmentId ?? null,
@@ -2889,7 +2789,6 @@ export async function updateStackSource(
}
export async function deleteStackSource(stackName: string, environmentId?: number | null): Promise<boolean> {
console.log(`[GitStack] Deleting stack_sources "${stackName}" env=${environmentId}`);
// Delete matching record (either with specific envId or NULL)
await db.delete(stackSources)
.where(and(
@@ -3141,7 +3040,7 @@ export type AuditAction =
export type AuditEntityType =
| 'container' | 'image' | 'stack' | 'volume' | 'network'
| 'user' | 'role' | 'settings' | 'environment' | 'registry' | 'git_repository' | 'git_credential'
| 'config_set' | 'notification' | 'oidc_provider' | 'ldap_config' | 'git_stack' | 'api_token';
| 'config_set' | 'notification' | 'oidc_provider' | 'ldap_config' | 'git_stack';
export interface AuditLogData {
id: number;
@@ -3257,16 +3156,14 @@ export async function getAuditLogs(filters: AuditLogFilters = {}): Promise<Audit
// Labels filter - find environments with matching labels first
let labelFilteredEnvIds: number[] | undefined;
if (filters.labels && filters.labels.length > 0) {
const labelFilterMode = await getSetting('label_filter_mode') ?? 'any';
// Get environments that have ANY of the specified labels
const allEnvs = await db.select({ id: environments.id, labels: environments.labels }).from(environments);
labelFilteredEnvIds = allEnvs
.filter(env => {
if (!env.labels) return false;
try {
const envLabels = JSON.parse(env.labels) as string[];
return labelFilterMode === 'all'
? filters.labels!.every(label => envLabels.includes(label))
: filters.labels!.some(label => envLabels.includes(label));
return filters.labels!.some(label => envLabels.includes(label));
} catch {
return false;
}
@@ -3474,16 +3371,14 @@ export async function getContainerEvents(filters: ContainerEventFilters = {}): P
// Labels filter - find environments with matching labels first
let labelFilteredEnvIds: number[] | undefined;
if (filters.labels && filters.labels.length > 0) {
const labelFilterMode = await getSetting('label_filter_mode') ?? 'any';
// Get environments that have ANY of the specified labels
const allEnvs = await db.select({ id: environments.id, labels: environments.labels }).from(environments);
labelFilteredEnvIds = allEnvs
.filter(env => {
if (!env.labels) return false;
try {
const envLabels = JSON.parse(env.labels) as string[];
return labelFilterMode === 'all'
? filters.labels!.every(label => envLabels.includes(label))
: filters.labels!.some(label => envLabels.includes(label));
return filters.labels!.some(label => envLabels.includes(label));
} catch {
return false;
}
@@ -3603,15 +3498,9 @@ export async function getContainerEventActions(): Promise<string[]> {
export async function deleteOldContainerEvents(keepDays = 30): Promise<number> {
const cutoffDate = new Date(Date.now() - keepDays * 24 * 60 * 60 * 1000).toISOString();
const countResult = await db.select({ count: sql<number>`count(*)` })
.from(containerEvents)
await db.delete(containerEvents)
.where(sql`timestamp < ${cutoffDate}`);
const count = Number(countResult[0]?.count ?? 0);
if (count > 0) {
await db.delete(containerEvents)
.where(sql`timestamp < ${cutoffDate}`);
}
return count;
return 0;
}
/**
@@ -4099,15 +3988,9 @@ export async function getRecentExecutionsForSchedule(
export async function cleanupOldExecutions(retentionDays: number): Promise<number> {
const cutoffDate = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
const countResult = await db.select({ count: sql<number>`count(*)` })
.from(scheduleExecutions)
const result = await db.delete(scheduleExecutions)
.where(sql`triggered_at < ${cutoffDate}`);
const count = Number(countResult[0]?.count ?? 0);
if (count > 0) {
await db.delete(scheduleExecutions)
.where(sql`triggered_at < ${cutoffDate}`);
}
return count;
return 0; // SQLite/PG don't return count consistently
}
// Settings helpers for retention
@@ -4118,11 +4001,8 @@ const SCHEDULE_CLEANUP_CRON_KEY = 'schedule_cleanup_cron';
const EVENT_CLEANUP_CRON_KEY = 'event_cleanup_cron';
const SCHEDULE_CLEANUP_ENABLED_KEY = 'schedule_cleanup_enabled';
const EVENT_CLEANUP_ENABLED_KEY = 'event_cleanup_enabled';
const SCANNER_CLEANUP_CRON_KEY = 'scanner_cleanup_cron';
const SCANNER_CLEANUP_ENABLED_KEY = 'scanner_cleanup_enabled';
const DEFAULT_SCHEDULE_CLEANUP_CRON = '0 3 * * *'; // Daily at 3 AM
const DEFAULT_EVENT_CLEANUP_CRON = '30 3 * * *'; // Daily at 3:30 AM
const DEFAULT_SCANNER_CLEANUP_CRON = '0 3 * * 0'; // Weekly Sunday at 3 AM
export async function getScheduleRetentionDays(): Promise<number> {
const result = await db.select().from(settings).where(eq(settings.key, SCHEDULE_RETENTION_KEY));
@@ -4256,50 +4136,6 @@ export async function setEventCleanupEnabled(enabled: boolean): Promise<void> {
}
}
export async function getScannerCleanupCron(): Promise<string> {
const result = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_CRON_KEY));
if (result[0]) {
return result[0].value || DEFAULT_SCANNER_CLEANUP_CRON;
}
return DEFAULT_SCANNER_CLEANUP_CRON;
}
export async function setScannerCleanupCron(cron: string): Promise<void> {
const existing = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_CRON_KEY));
if (existing.length > 0) {
await db.update(settings)
.set({ value: cron, updatedAt: new Date().toISOString() })
.where(eq(settings.key, SCANNER_CLEANUP_CRON_KEY));
} else {
await db.insert(settings).values({
key: SCANNER_CLEANUP_CRON_KEY,
value: cron
});
}
}
export async function getScannerCleanupEnabled(): Promise<boolean> {
const result = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_ENABLED_KEY));
if (result[0]) {
return result[0].value === 'true';
}
return true; // Enabled by default
}
export async function setScannerCleanupEnabled(enabled: boolean): Promise<void> {
const existing = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_ENABLED_KEY));
if (existing.length > 0) {
await db.update(settings)
.set({ value: enabled ? 'true' : 'false', updatedAt: new Date().toISOString() })
.where(eq(settings.key, SCANNER_CLEANUP_ENABLED_KEY));
} else {
await db.insert(settings).values({
key: SCANNER_CLEANUP_ENABLED_KEY,
value: enabled ? 'true' : 'false'
});
}
}
// =============================================================================
// EXTERNAL STACK PATHS
// =============================================================================
@@ -4334,17 +4170,6 @@ export async function setExternalStackPaths(paths: string[]): Promise<void> {
}
}
/**
* Idempotently add a directory to the external stack paths allowlist.
* Returns true if the path was newly added (false if already present).
*/
export async function addExternalStackPath(dir: string): Promise<boolean> {
const current = await getExternalStackPaths();
if (current.includes(dir)) return false;
await setExternalStackPaths([...current, dir]);
return true;
}
// =============================================================================
// PRIMARY STACK LOCATION
// =============================================================================
@@ -4755,55 +4580,6 @@ export async function setStackEnvVars(
}
}
/**
* Get the set of secret key names for a stack.
* Used to mask secret values in container inspect responses.
*/
export async function getSecretKeyNames(
stackName: string,
environmentId?: number | null
): Promise<Set<string>> {
const vars = await getStackEnvVars(stackName, environmentId, true);
return new Set(vars.filter(v => v.isSecret).map(v => v.key));
}
/**
* Get the set of env var keys that should be masked in container inspect responses.
* Handles two cases:
* 1. Direct match: env var key == secret key in DB (e.g., DB_PASS=${DB_PASS})
* 2. Interpolation: env var key differs from secret key (e.g., MYSQL_PASSWORD=${db_secret})
* Detected by parsing the compose file for ${variable} references in environment: sections.
*
* @param composeContent - Optional compose file content. If provided, interpolation
* references are parsed to detect secrets injected under different key names.
*/
export async function getSecretKeysToMask(
stackName: string,
environmentId?: number | null,
composeContent?: string | null
): Promise<Set<string>> {
const vars = await getStackEnvVars(stackName, environmentId, true);
const secretKeyNames = new Set(vars.filter(v => v.isSecret).map(v => v.key));
if (secretKeyNames.size === 0) return secretKeyNames;
// If we have compose content, parse interpolation references to find
// container env keys that map to secret interpolation variables.
// e.g., "MYSQL_PASSWORD=${db_secret}" → if db_secret is a secret, mask MYSQL_PASSWORD too.
if (composeContent) {
const interpolated = parseEnvInterpolation(composeContent);
for (const [containerKey, varName] of interpolated) {
if (secretKeyNames.has(varName)) {
secretKeyNames.add(containerKey);
}
}
}
return secretKeyNames;
}
export { parseEnvInterpolation } from './env-interpolation';
/**
* Get count of environment variables for a stack.
* @param stackName - Name of the stack
+5 -33
View File
@@ -335,8 +335,7 @@ const REQUIRED_TABLES = [
'audit_logs',
'container_events',
'schedule_executions',
'user_preferences',
'api_tokens'
'user_preferences'
];
/**
@@ -769,8 +768,7 @@ async function seedDatabase(): Promise<void> {
license: ['manage'],
audit_logs: ['view'],
activity: ['view'],
schedules: ['view', 'edit', 'run'],
templates: ['view', 'deploy', 'manage']
schedules: ['view']
});
const operatorPermissions = JSON.stringify({
@@ -789,8 +787,7 @@ async function seedDatabase(): Promise<void> {
license: [],
audit_logs: [],
activity: ['view'],
schedules: ['view', 'edit', 'run'],
templates: ['view', 'deploy']
schedules: ['view']
});
const viewerPermissions = JSON.stringify({
@@ -809,31 +806,9 @@ async function seedDatabase(): Promise<void> {
license: [],
audit_logs: [],
activity: ['view'],
schedules: ['view'],
templates: ['view']
schedules: ['view']
});
// Seed template sources if table is empty
const existingTemplateSources = await db.select().from(schema.templateSources);
if (existingTemplateSources.length === 0) {
// Inline defaults to avoid circular dependency (library.ts imports db/drizzle)
const defaultSources = [
{ sourceId: 'portainer-lissy93', name: 'Portainer templates (Lissy93)', url: 'https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json', enabled: true, builtin: true, sortOrder: 0 },
{ sourceId: 'ntv-one', name: 'NTV-One (consolidated)', url: 'https://raw.githubusercontent.com/ntv-one/portainer/main/template.json', enabled: false, builtin: true, sortOrder: 1 },
{ sourceId: 'mlva', name: 'MLVA (TheLustriVA)', url: 'https://raw.githubusercontent.com/TheLustriVA/portainer-templates-Nov-2022-collection/main/templates_2_2_rc_2_2.json', enabled: false, builtin: true, sortOrder: 2 },
{ sourceId: 'selfhostedpro', name: 'SelfHostedPro', url: 'https://raw.githubusercontent.com/SelfhostedPro/selfhosted_templates/master/Template/portainer-v2.json', enabled: false, builtin: true, sortOrder: 3 },
{ sourceId: 'portainer-qballjos', name: 'Qballjos (homelab)', url: 'https://raw.githubusercontent.com/Qballjos/portainer_templates/master/Template/template.json', enabled: false, builtin: true, sortOrder: 4 },
{ sourceId: 'lsio-technorabilia', name: 'LinuxServer.io (Technorabilia)', url: 'https://raw.githubusercontent.com/technorabilia/portainer-templates/main/lsio/templates/templates.json', enabled: true, builtin: true, sortOrder: 5 },
{ sourceId: 'mikestraney', name: 'MikeStraney', url: 'https://raw.githubusercontent.com/mikestraney/portainer-templates/master/templates.json', enabled: false, builtin: true, sortOrder: 6 },
{ sourceId: 'pi-hosted-amd64', name: 'Pi-Hosted (amd64)', url: 'https://raw.githubusercontent.com/pi-hosted/pi-hosted/master/template/portainer-v2-amd64.json', enabled: false, builtin: true, sortOrder: 7 },
{ sourceId: 'pi-hosted-arm64', name: 'Pi-Hosted (arm64)', url: 'https://raw.githubusercontent.com/pi-hosted/pi-hosted/master/template/portainer-v2-arm64.json', enabled: false, builtin: true, sortOrder: 8 },
];
for (const source of defaultSources) {
await db.insert(schema.templateSources).values(source);
}
logStep('Created default template sources');
}
const existingRoles = await db.select().from(schema.roles);
if (existingRoles.length === 0) {
await db.insert(schema.roles).values([
@@ -923,7 +898,6 @@ export const userPreferences = schemaProxy.userPreferences;
export const scheduleExecutions = schemaProxy.scheduleExecutions;
export const stackEnvironmentVariables = schemaProxy.stackEnvironmentVariables;
export const pendingContainerUpdates = schemaProxy.pendingContainerUpdates;
export const apiTokens = schemaProxy.apiTokens;
// Re-export types from SQLite schema (they're compatible with PostgreSQL)
export type {
@@ -982,9 +956,7 @@ export type {
StackEnvironmentVariable,
NewStackEnvironmentVariable,
PendingContainerUpdate,
NewPendingContainerUpdate,
ApiToken,
NewApiToken
NewPendingContainerUpdate
} from './schema/index.js';
export { eq, and, or, desc, asc, like, sql, inArray, isNull, isNotNull } from 'drizzle-orm';
+2 -40
View File
@@ -288,7 +288,7 @@ export const gitRepositories = sqliteTable('git_repositories', {
url: text('url').notNull(),
branch: text('branch').default('main'),
credentialId: integer('credential_id').references(() => gitCredentials.id, { onDelete: 'set null' }),
composePath: text('compose_path').default('docker-compose.yml'), // Reverted to original value (#1110)
composePath: text('compose_path').default('compose.yaml'),
environmentId: integer('environment_id'),
autoUpdate: integer('auto_update', { mode: 'boolean' }).default(false),
autoUpdateSchedule: text('auto_update_schedule').default('daily'),
@@ -308,23 +308,20 @@ export const gitStacks = sqliteTable('git_stacks', {
stackName: text('stack_name').notNull(),
environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }),
repositoryId: integer('repository_id').notNull().references(() => gitRepositories.id, { onDelete: 'cascade' }),
composePath: text('compose_path').default('docker-compose.yml'), // Reverted to original value (#1110)
composePath: text('compose_path').default('compose.yaml'),
envFilePath: text('env_file_path'), // Path to .env file in repository (e.g., ".env", "config/.env.prod")
autoUpdate: integer('auto_update', { mode: 'boolean' }).default(false),
autoUpdateSchedule: text('auto_update_schedule').default('daily'),
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
webhookEnabled: integer('webhook_enabled', { mode: 'boolean' }).default(false),
webhookSecret: text('webhook_secret'),
contextDir: text('context_dir'), // Working directory relative to repo root (null = compose file's directory)
buildOnDeploy: integer('build_on_deploy', { mode: 'boolean' }).default(false),
noBuildCache: integer('no_build_cache', { mode: 'boolean' }).default(false),
repullImages: integer('repull_images', { mode: 'boolean' }).default(false),
forceRedeploy: integer('force_redeploy', { mode: 'boolean' }).default(false),
lastSync: text('last_sync'),
lastCommit: text('last_commit'),
syncStatus: text('sync_status').default('pending'),
syncError: text('sync_error'),
syncedFiles: text('synced_files'), // JSON manifest { relativePath: sha256hex } of files written on last sync
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
}, (table) => ({
@@ -470,25 +467,6 @@ export const pendingContainerUpdates = sqliteTable('pending_container_updates',
envContainerUnique: unique().on(table.environmentId, table.containerId)
}));
// =============================================================================
// API TOKENS TABLE
// =============================================================================
export const apiTokens = sqliteTable('api_tokens', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
tokenHash: text('token_hash').notNull().unique(),
tokenPrefix: text('token_prefix').notNull(),
lastUsed: text('last_used'),
expiresAt: text('expires_at'),
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
}, (table) => ({
userIdIdx: index('api_tokens_user_id_idx').on(table.userId),
tokenPrefixIdx: index('api_tokens_token_prefix_idx').on(table.tokenPrefix)
}));
// =============================================================================
// USER PREFERENCES TABLE (unified key-value store)
// =============================================================================
@@ -505,19 +483,6 @@ export const userPreferences = sqliteTable('user_preferences', {
unique().on(table.userId, table.environmentId, table.key)
]);
// Template sources
export const templateSources = sqliteTable('template_sources', {
id: integer('id').primaryKey({ autoIncrement: true }),
sourceId: text('source_id').notNull().unique(), // stable identifier (e.g., 'portainer-lissy93')
name: text('name').notNull(),
url: text('url').notNull(),
enabled: integer('enabled', { mode: 'boolean' }).default(true),
builtin: integer('builtin', { mode: 'boolean' }).default(false),
sortOrder: integer('sort_order').default(0),
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
});
// =============================================================================
// TYPE EXPORTS
// =============================================================================
@@ -605,6 +570,3 @@ export type NewStackEnvironmentVariable = typeof stackEnvironmentVariables.$infe
export type PendingContainerUpdate = typeof pendingContainerUpdates.$inferSelect;
export type NewPendingContainerUpdate = typeof pendingContainerUpdates.$inferInsert;
export type ApiToken = typeof apiTokens.$inferSelect;
export type NewApiToken = typeof apiTokens.$inferInsert;
+2 -37
View File
@@ -291,7 +291,7 @@ export const gitRepositories = pgTable('git_repositories', {
url: text('url').notNull(),
branch: text('branch').default('main'),
credentialId: integer('credential_id').references(() => gitCredentials.id, { onDelete: 'set null' }),
composePath: text('compose_path').default('docker-compose.yml'), // Reverted to original value (#1110)
composePath: text('compose_path').default('compose.yaml'),
environmentId: integer('environment_id'),
autoUpdate: boolean('auto_update').default(false),
autoUpdateSchedule: text('auto_update_schedule').default('daily'),
@@ -311,23 +311,20 @@ export const gitStacks = pgTable('git_stacks', {
stackName: text('stack_name').notNull(),
environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }),
repositoryId: integer('repository_id').notNull().references(() => gitRepositories.id, { onDelete: 'cascade' }),
composePath: text('compose_path').default('docker-compose.yml'), // Reverted to original value (#1110)
composePath: text('compose_path').default('compose.yaml'),
envFilePath: text('env_file_path'), // Path to .env file in repository (e.g., ".env", "config/.env.prod")
autoUpdate: boolean('auto_update').default(false),
autoUpdateSchedule: text('auto_update_schedule').default('daily'),
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
webhookEnabled: boolean('webhook_enabled').default(false),
webhookSecret: text('webhook_secret'),
contextDir: text('context_dir'), // Working directory relative to repo root (null = compose file's directory)
buildOnDeploy: boolean('build_on_deploy').default(false),
noBuildCache: boolean('no_build_cache').default(false),
repullImages: boolean('repull_images').default(false),
forceRedeploy: boolean('force_redeploy').default(false),
lastSync: timestamp('last_sync', { mode: 'string' }),
lastCommit: text('last_commit'),
syncStatus: text('sync_status').default('pending'),
syncError: text('sync_error'),
syncedFiles: text('synced_files'), // JSON manifest { relativePath: sha256hex } of files written on last sync
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
}, (table) => ({
@@ -473,25 +470,6 @@ export const pendingContainerUpdates = pgTable('pending_container_updates', {
envContainerUnique: unique().on(table.environmentId, table.containerId)
}));
// =============================================================================
// API TOKENS TABLE
// =============================================================================
export const apiTokens = pgTable('api_tokens', {
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
tokenHash: text('token_hash').notNull().unique(),
tokenPrefix: text('token_prefix').notNull(),
lastUsed: timestamp('last_used', { mode: 'string' }),
expiresAt: timestamp('expires_at', { mode: 'string' }),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
}, (table) => ({
userIdIdx: index('api_tokens_user_id_idx').on(table.userId),
tokenPrefixIdx: index('api_tokens_token_prefix_idx').on(table.tokenPrefix)
}));
// =============================================================================
// USER PREFERENCES TABLE (unified key-value store)
// =============================================================================
@@ -507,16 +485,3 @@ export const userPreferences = pgTable('user_preferences', {
}, (table) => [
unique().on(table.userId, table.environmentId, table.key)
]);
// Template sources
export const templateSources = pgTable('template_sources', {
id: serial('id').primaryKey(),
sourceId: text('source_id').notNull().unique(), // stable identifier (e.g., 'portainer-lissy93')
name: text('name').notNull(),
url: text('url').notNull(),
enabled: boolean('enabled').default(true),
builtin: boolean('builtin').default(false),
sortOrder: integer('sort_order').default(0),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
});
+168 -703
View File
File diff suppressed because it is too large Load Diff
-36
View File
@@ -1,36 +0,0 @@
/**
* Parse compose YAML to extract environment variable interpolation mappings.
* Returns pairs of [containerEnvKey, interpolationVariable].
*
* Handles patterns:
* - VAR=${ref}
* - VAR=${ref:-default}
* - VAR=${ref:+alt}
* - VAR=${ref?error}
*
* Only extracts from `environment:` sections (list format: `- KEY=value`).
*/
export function parseEnvInterpolation(composeContent: string): Array<[string, string]> {
const results: Array<[string, string]> = [];
// Step 1: Find lines matching `- ENV_KEY=...${...}...`
const linePattern = /^\s*-\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)/gm;
let lineMatch;
while ((lineMatch = linePattern.exec(composeContent)) !== null) {
const containerKey = lineMatch[1];
const valueStr = lineMatch[2];
// Step 2: Extract all ${VAR} references from the value
const varPattern = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?:[:\-\+\?][^}]*)?\}/g;
let varMatch;
while ((varMatch = varPattern.exec(valueStr)) !== null) {
const varName = varMatch[1];
// Only add if names differ — same-name case handled by direct key matching
if (containerKey !== varName) {
results.push([containerKey, varName]);
}
}
}
return results;
}
-25
View File
@@ -1,25 +0,0 @@
/**
* Parse .env file content into key-value pairs.
* Preserves values exactly as written no quote stripping.
* Docker Compose handles its own quote interpretation at runtime.
*/
export function parseEnvVars(content: string): Record<string, string> {
const result: Record<string, string> = {};
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex === -1) continue;
const key = trimmed.substring(0, eqIndex).trim();
const value = trimmed.substring(eqIndex + 1).trim();
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
result[key] = value;
}
}
return result;
}
-435
View File
@@ -1,435 +0,0 @@
/**
* Git stack deletion sync (#966, #1162).
*
* Propagates upstream file deletions to the stack deploy directory using the
* per-stack manifest: a file is deleted ONLY when the manifest of files
* Dockhand wrote on the previous sync lists it, the new clone no longer
* contains it, AND the bytes on disk still match what Dockhand wrote
* (nobody modified it locally).
*
* Every failure mode degrades to "delete less" never to user-data loss:
* - user-created files (volume data) never in the manifest untouchable
* - locally modified files hash mismatch skip
* - first sync after upgrade / fresh DB empty manifest nothing to delete
* - broken clone walk (empty / compose missing) deletionSafetyCheck blocks
* ALL deletions for that sync (guards against mass-deleting managed files
* due to a Dockhand bug; those files are repo-restorable anyway)
*
* History rewrites are irrelevant by design: deletion converges the deploy
* dir toward the clone state, regardless of how the commits got there.
*/
import { createHash } from 'node:crypto';
import { readdirSync, readFileSync, unlinkSync, rmdirSync, lstatSync } from 'node:fs';
import { join, resolve, sep, dirname, basename, isAbsolute } from 'node:path';
// =============================================================================
// Types
// =============================================================================
export type DeletionSkipReason =
| 'locally-modified' // disk bytes differ from what Dockhand wrote
| 'load-bearing' // compose/.env files are never auto-deleted
| 'invalid-path' // absolute or escaping the stack directory
| 'already-absent' // nothing to do (benign)
| 'agent-no-support' // Hawser agent too old to apply deletions
| 'apply-failed'; // unexpected error during unlink
export interface FileToDelete {
path: string; // relative to the stack deploy dir, '/' separators
hash: string; // sha256 hex of the content Dockhand wrote
}
export interface DeletionSkip {
path: string;
reason: DeletionSkipReason;
}
export interface DeletionPlan {
toDelete: FileToDelete[];
skipped: DeletionSkip[];
}
export interface DeletionApplyResult {
deleted: string[];
skipped: DeletionSkip[];
}
/** Manifest of files Dockhand wrote on the last successful sync. */
export interface SyncManifest {
/** Full commit hash the manifest files were taken from. Null = legacy/bootstrap. */
commit: string | null;
/** relative path → sha256 hex of written content */
files: Record<string, string>;
}
export interface SyncFileChange {
file: string;
status: 'added' | 'updated' | 'removed' | 'skipped';
reason?: string; // human-readable, only for skipped
}
export interface SyncChangeSummary {
changes: SyncFileChange[];
unchangedCount: number;
}
// =============================================================================
// Constants
// =============================================================================
/** Files that are never auto-deleted, regardless of what the sources say. */
export const LOAD_BEARING_FILES = new Set([
'docker-compose.yml',
'docker-compose.yaml',
'compose.yml',
'compose.yaml',
'.env'
]);
// NOTE: deletion skips are FINAL by design. A deletion is attempted exactly
// once — at the sync where the file first disappears from the clone. Any
// skip (old agent, hash mismatch, apply error) is logged and the file simply
// stays on disk as unmanaged residue. There is deliberately no
// carry-forward/retry state: it would require tracking per-file retry status
// indefinitely (e.g. waiting for an agent upgrade that may never happen).
// Worst case is always "a stale file survives" — visible in the logs,
// recoverable manually. With an old Hawser agent the behavior is identical
// to before this feature existed: nothing is ever deleted remotely.
/** Human-readable explanation for each skip reason (shown in logs and activity). */
export function skipReasonMessage(reason: DeletionSkipReason): string {
switch (reason) {
case 'locally-modified':
return 'deleted from the repository, but the file was modified on this machine since Dockhand deployed it — refusing to delete local changes';
case 'load-bearing':
return 'core stack file — never auto-deleted';
case 'invalid-path':
return 'invalid path outside the stack directory — ignored';
case 'already-absent':
return 'already absent';
case 'agent-no-support':
return 'the Hawser agent does not support file deletion sync — file left on the remote host (upgrade the agent to enable cleanup of future deletions)';
case 'apply-failed':
return 'could not be deleted — leaving the file in place';
default:
// Unknown reason (e.g., from a newer agent)
return 'could not be deleted — leaving the file in place';
}
}
const KNOWN_SKIP_REASONS: ReadonlySet<string> = new Set<DeletionSkipReason>([
'locally-modified',
'load-bearing',
'invalid-path',
'already-absent',
'agent-no-support',
'apply-failed'
]);
/** Normalize a reason string from an external source (Hawser agent). */
export function normalizeSkipReason(reason: string): DeletionSkipReason {
return (KNOWN_SKIP_REASONS.has(reason) ? reason : 'apply-failed') as DeletionSkipReason;
}
// =============================================================================
// Manifest (de)serialization
// =============================================================================
export function parseManifest(raw: string | null | undefined): SyncManifest {
if (!raw) return { commit: null, files: {} };
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && typeof parsed.files === 'object' && parsed.files !== null) {
const files: Record<string, string> = {};
for (const [k, v] of Object.entries(parsed.files)) {
if (typeof v === 'string') files[k] = v;
}
return { commit: typeof parsed.commit === 'string' ? parsed.commit : null, files };
}
} catch {
// Corrupt manifest → behave like a fresh bootstrap (fail closed: no deletions)
}
return { commit: null, files: {} };
}
export function serializeManifest(manifest: SyncManifest): string {
return JSON.stringify(manifest);
}
// =============================================================================
// Hashing
// =============================================================================
export function hashContent(content: Buffer | string): string {
return createHash('sha256').update(content).digest('hex');
}
/**
* Walk a directory and hash every regular file (raw bytes).
* Returns { relativePath: sha256hex } with '/' separators.
* Skips .git directories (mirrors the cpSync filter used by the deploy copy).
*/
export function hashDirFiles(dir: string): Record<string, string> {
const result: Record<string, string> = {};
const root = resolve(dir);
const walk = (current: string, relPrefix: string) => {
let entries;
try {
entries = readdirSync(current, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.name === '.git') continue;
const abs = join(current, entry.name);
const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
walk(abs, rel);
} else if (entry.isFile()) {
try {
result[rel] = hashContent(readFileSync(abs));
} catch {
// Unreadable file: leave out of the manifest → never a deletion candidate
}
}
// Symlinks and other special entries are intentionally excluded:
// Dockhand only writes regular files, so only regular files are managed.
}
};
walk(root, '');
return result;
}
// =============================================================================
// Path safety
// =============================================================================
/** A relative path is safe when it cannot escape the stack directory. */
export function isSafeRelPath(p: string): boolean {
if (!p || isAbsolute(p) || p.includes('\\')) return false;
const segments = p.split('/');
return segments.every((s) => s !== '' && s !== '.' && s !== '..');
}
/** Resolve relPath inside root; returns null when it would escape root. */
function containedPath(root: string, relPath: string): string | null {
if (!isSafeRelPath(relPath)) return null;
const abs = resolve(root, relPath);
if (abs !== root && abs.startsWith(root + sep)) return abs;
return null;
}
// =============================================================================
// Core: manifest vs clone
// =============================================================================
/**
* Sanity guard run BEFORE computing any deletions: when the new-clone walk
* looks broken (no files at all, or the compose file itself is missing from
* the walk even though it was just read from that tree), every manifest
* entry would become a deletion candidate a Dockhand bug, not a repo
* change. Returns a human-readable reason to skip ALL deletions this sync,
* or null when it is safe to proceed.
*/
export function deletionSafetyCheck(
manifestFiles: Record<string, string>,
newFiles: Record<string, string>,
composeFileName: string | undefined
): string | null {
if (Object.keys(manifestFiles).length === 0) return null; // nothing to delete anyway
if (Object.keys(newFiles).length === 0) {
return 'the new clone appears empty — skipping all deletions this sync (likely a sync problem, not repository changes)';
}
if (composeFileName && !(composeFileName in newFiles)) {
return `the compose file "${composeFileName}" is missing from the new clone walk — skipping all deletions this sync (likely a sync problem, not repository changes)`;
}
return null;
}
/**
* Compute the deletion plan: manifest entries that are absent from the new
* clone. The hash recorded in the manifest travels with each entry the
* applier deletes only files whose disk bytes still match it.
*
* @param manifestFiles files Dockhand wrote on the last sync (path hash)
* @param newFiles files in the new clone that will be written (path hash)
*/
export function computeDeletions(
manifestFiles: Record<string, string>,
newFiles: Record<string, string>
): DeletionPlan {
const toDelete: FileToDelete[] = [];
const skipped: DeletionSkip[] = [];
for (const [path, hash] of Object.entries(manifestFiles)) {
if (path in newFiles) continue; // still present in the repo
if (!isSafeRelPath(path)) {
skipped.push({ path, reason: 'invalid-path' });
continue;
}
if (LOAD_BEARING_FILES.has(basename(path))) {
skipped.push({ path, reason: 'load-bearing' });
continue;
}
toDelete.push({ path, hash });
}
return { toDelete, skipped };
}
// =============================================================================
// Applier — the single chokepoint that touches the filesystem
// =============================================================================
/**
* Apply a deletion list inside a stack directory.
*
* Structurally incapable of touching anything outside stackDir:
* every path is containment-checked, only regular files whose content still
* matches the recorded hash are unlinked, and directory cleanup uses rmdir
* (never recursive) so directories holding any other content survive.
*/
export function applyFileDeletions(stackDir: string, files: FileToDelete[]): DeletionApplyResult {
const root = resolve(stackDir);
const deleted: string[] = [];
const skipped: DeletionSkip[] = [];
const parentDirs = new Set<string>();
for (const { path, hash } of files) {
const abs = containedPath(root, path);
if (!abs) {
skipped.push({ path, reason: 'invalid-path' });
continue;
}
// Defense in depth: computeDeletions already filters these, but the
// applier also runs on lists from external sources (Hawser payloads).
if (LOAD_BEARING_FILES.has(basename(path))) {
skipped.push({ path, reason: 'load-bearing' });
continue;
}
let stat;
try {
stat = lstatSync(abs);
} catch {
skipped.push({ path, reason: 'already-absent' });
continue;
}
// Dockhand only writes regular files. Anything else (symlink, dir,
// socket) means the user replaced it — treat as locally modified.
if (!stat.isFile()) {
skipped.push({ path, reason: 'locally-modified' });
continue;
}
try {
if (hashContent(readFileSync(abs)) !== hash) {
skipped.push({ path, reason: 'locally-modified' });
continue;
}
unlinkSync(abs);
deleted.push(path);
} catch {
skipped.push({ path, reason: 'apply-failed' });
continue;
}
// Collect parent dir chain (inside root) for empty-dir cleanup
let dir = dirname(abs);
while (dir !== root && dir.startsWith(root + sep)) {
parentDirs.add(dir);
dir = dirname(dir);
}
}
// Deepest-first rmdir; fails harmlessly when a directory still has content
const dirsByDepth = [...parentDirs].sort((a, b) => b.length - a.length);
for (const dir of dirsByDepth) {
try {
rmdirSync(dir);
} catch {
// ENOTEMPTY/ENOENT/etc. — directory stays, which is always safe
}
}
return { deleted, skipped };
}
// =============================================================================
// Manifest evolution
// =============================================================================
/**
* Build the manifest to persist after a sync.
*
* Trivial by design: the manifest is always exactly the files written this
* sync, at this sync's commit. Skipped deletions are FINAL (see note above)
* the affected files drop out of the manifest and become unmanaged residue.
*/
export function buildNextManifest(newCommit: string, newFiles: Record<string, string>): SyncManifest {
return { commit: newCommit, files: { ...newFiles } };
}
// =============================================================================
// Sync summary (per-file status table)
// =============================================================================
export function buildSyncChangeSummary(
previousFiles: Record<string, string>,
newFiles: Record<string, string>,
applyResult: DeletionApplyResult,
planSkipped: DeletionSkip[]
): SyncChangeSummary {
const changes: SyncFileChange[] = [];
let unchangedCount = 0;
for (const [path, hash] of Object.entries(newFiles)) {
const oldHash = previousFiles[path];
if (oldHash === undefined) {
changes.push({ file: path, status: 'added' });
} else if (oldHash !== hash) {
changes.push({ file: path, status: 'updated' });
} else {
unchangedCount++;
}
}
for (const path of applyResult.deleted) {
changes.push({ file: path, status: 'removed' });
}
// Benign "already absent" results are not interesting in the summary
const interestingSkips = [...planSkipped, ...applyResult.skipped].filter(
(s) => s.reason !== 'already-absent'
);
for (const skip of interestingSkips) {
changes.push({ file: skip.path, status: 'skipped', reason: skipReasonMessage(skip.reason) });
}
return { changes, unchangedCount };
}
/** Render the summary as aligned text lines for console and job output. */
export function formatChangeTable(summary: SyncChangeSummary): string[] {
const { changes, unchangedCount } = summary;
const counts = { added: 0, updated: 0, removed: 0, skipped: 0 };
for (const c of changes) counts[c.status]++;
const header = `${counts.added} added, ${counts.updated} updated, ${counts.removed} removed, ${counts.skipped} skipped, ${unchangedCount} unchanged`;
if (changes.length === 0) {
return [header];
}
const fileWidth = Math.min(60, Math.max(4, ...changes.map((c) => c.file.length)));
const lines = [header, `${'STATUS'.padEnd(9)} ${'FILE'.padEnd(fileWidth)} REASON`];
for (const c of changes) {
lines.push(`${c.status.padEnd(9)} ${c.file.padEnd(fileWidth)} ${c.reason ?? ''}`.trimEnd());
}
return lines;
}
+59 -327
View File
@@ -15,85 +15,6 @@ import {
type GitStackWithRepo
} from './db';
import { deployStack, getStackDir } from './stacks';
import {
parseManifest,
serializeManifest,
hashDirFiles,
computeDeletions,
buildNextManifest,
buildSyncChangeSummary,
formatChangeTable,
skipReasonMessage,
deletionSafetyCheck,
type DeletionPlan,
type DeletionApplyResult,
type DeletionSkip,
type SyncManifest
} from './git-deletions';
const MERGED_CA_BUNDLE_PATH = '/tmp/dockhand-merged-ca-bundle.crt';
let mergedCaBundleReady = false;
/**
* Create a merged CA bundle combining system CAs with the custom cert from
* NODE_EXTRA_CA_CERTS. GIT_SSL_CAINFO replaces the default CA store, so without
* merging, public CAs (GitHub, GitLab) break.
*/
function getMergedCaBundlePath(): string {
if (mergedCaBundleReady && existsSync(MERGED_CA_BUNDLE_PATH)) {
console.log(`[Git] Using cached merged CA bundle: ${MERGED_CA_BUNDLE_PATH}`);
return MERGED_CA_BUNDLE_PATH;
}
const customCertPath = process.env.NODE_EXTRA_CA_CERTS!;
console.log(`[Git] NODE_EXTRA_CA_CERTS set to: ${customCertPath}`);
const systemCaPaths = [
process.env.SSL_CERT_FILE,
'/etc/ssl/certs/ca-certificates.crt',
'/etc/pki/tls/certs/ca-bundle.crt',
'/etc/ssl/cert.pem'
];
let systemCaContent = '';
let systemCaSource = '';
for (const caPath of systemCaPaths) {
if (caPath && existsSync(caPath)) {
try {
systemCaContent = readFileSync(caPath, 'utf-8');
systemCaSource = caPath;
console.log(`[Git] Found system CA bundle: ${caPath} (${systemCaContent.split('-----BEGIN CERTIFICATE-----').length - 1} certs)`);
break;
} catch (err) {
console.log(`[Git] Failed to read system CA bundle ${caPath}: ${err}`);
}
}
}
if (!systemCaSource) {
console.log(`[Git] No system CA bundle found, using custom cert only: ${customCertPath}`);
}
try {
const customCaContent = readFileSync(customCertPath, 'utf-8');
const customCertCount = customCaContent.split('-----BEGIN CERTIFICATE-----').length - 1;
console.log(`[Git] Custom CA file contains ${customCertCount} cert(s)`);
const merged = systemCaContent
? systemCaContent.trimEnd() + '\n' + customCaContent.trimEnd() + '\n'
: customCaContent;
writeFileSync(MERGED_CA_BUNDLE_PATH, merged);
mergedCaBundleReady = true;
const totalCerts = merged.split('-----BEGIN CERTIFICATE-----').length - 1;
console.log(`[Git] Created merged CA bundle: ${MERGED_CA_BUNDLE_PATH} (${totalCerts} total certs — system from ${systemCaSource || 'none'} + custom from ${customCertPath})`);
} catch (err) {
console.warn(`[Git] Failed to create merged CA bundle, falling back to custom cert only: ${customCertPath}`, err);
return customCertPath;
}
return MERGED_CA_BUNDLE_PATH;
}
/**
* Collect stdout, stderr and exit code from a spawned process.
@@ -124,19 +45,22 @@ if (!existsSync(GIT_REPOS_DIR)) {
mkdirSync(GIT_REPOS_DIR, { recursive: true });
}
export function getGitReposDir(): string {
return GIT_REPOS_DIR;
}
/**
* Redact all env var values for safe logging. Only key names are preserved.
* Mask sensitive values in environment variables for safe logging.
*/
function redactEnvVarsForLog(vars: Record<string, string>): Record<string, string> {
const redacted: Record<string, string> = {};
for (const key of Object.keys(vars)) {
redacted[key] = '***';
function maskSecrets(vars: Record<string, string>): Record<string, string> {
const masked: Record<string, string> = {};
const secretPatterns = /password|secret|token|key|api_key|apikey|auth|credential|private/i;
for (const [key, value] of Object.entries(vars)) {
if (secretPatterns.test(key)) {
masked[key] = '***';
} else if (value.length > 50) {
masked[key] = value.substring(0, 10) + '...(truncated)';
} else {
masked[key] = value;
}
}
return redacted;
return masked;
}
function getRepoPath(repoId: number): string {
@@ -229,30 +153,14 @@ async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
SSH_AUTH_SOCK: ''
};
// Pass custom CA certificate to git CLI (NODE_EXTRA_CA_CERTS only affects Node.js).
// GIT_SSL_CAINFO replaces the default CA store, so we merge system CAs with the
// custom cert so both self-signed repos and public repos (GitHub etc.) work (#967).
// Pass custom CA certificate to git CLI (NODE_EXTRA_CA_CERTS only affects Node.js)
if (process.env.NODE_EXTRA_CA_CERTS) {
env.GIT_SSL_CAINFO = getMergedCaBundlePath();
env.GIT_SSL_CAINFO = process.env.NODE_EXTRA_CA_CERTS;
}
// Ensure current UID is resolvable for SSH/git operations
await ensurePasswdEntry(env);
// For HTTPS password/token auth, inject credentials via http.extraHeader env vars
// instead of embedding them in the URL (which leaks via /proc/<pid>/cmdline, #1081).
// Uses GIT_CONFIG_COUNT mechanism (git >= 2.31) to set Authorization header.
if (credential?.authType === 'password' && (credential.username || credential.password)) {
const token = credential.password || '';
const username = credential.username || '';
// Use Basic auth (base64 of username:password) — works with GitHub PATs,
// GitLab tokens, Gitea tokens, and standard username/password combos.
const basicAuth = Buffer.from(`${username}:${token}`).toString('base64');
env.GIT_CONFIG_COUNT = '1';
env.GIT_CONFIG_KEY_0 = 'http.extraHeader';
env.GIT_CONFIG_VALUE_0 = `Authorization: Basic ${basicAuth}`;
}
if (credential?.authType === 'ssh' && credential.sshPrivateKey) {
// Write SSH key to /tmp instead of data volume — some filesystems (TrueNAS ZFS,
// NFS, CIFS) silently ignore chmod, leaving the key group-readable (e.g. 0670).
@@ -307,20 +215,24 @@ function cleanupSshKey(credential: GitCredential | null): void {
}
function buildRepoUrl(url: string, credential: GitCredential | null): string {
// Never embed credentials in the URL — they leak via /proc/<pid>/cmdline (see #1081).
// HTTPS credentials are injected via GIT_CONFIG_COUNT env vars in buildGitEnv().
// Strip any existing credentials from the URL for safety.
if (credential?.authType === 'password' && !url.startsWith('git@')) {
try {
const parsed = new URL(url);
parsed.username = '';
parsed.password = '';
return parsed.toString();
} catch {
return url;
}
// For SSH URLs or no auth, return as-is
if (!credential || credential.authType !== 'password' || url.startsWith('git@')) {
return url;
}
// For HTTPS with password auth, embed credentials
try {
const parsed = new URL(url);
if (credential.username) {
parsed.username = credential.username;
}
if (credential.password) {
parsed.password = credential.password;
}
return parsed.toString();
} catch {
return url;
}
return url;
}
async function execGit(args: string[], cwd: string, env: GitEnv): Promise<{ stdout: string; stderr: string; code: number }> {
@@ -378,93 +290,6 @@ async function getChangedFilesInDir(
return { changed: changedFiles.length > 0, files: changedFiles };
}
/**
* Compute the deletion plan for a sync: hash the new clone's compose dir and
* diff against the manifest from the last sync. Deletions converge the deploy
* dir toward the clone state; the applier additionally verifies each file's
* disk hash. A sanity guard blocks ALL deletions when the clone walk looks
* broken (empty, or missing the compose file).
*/
async function computeSyncDeletionPlan(options: {
logPrefix: string;
composeDir: string; // absolute path inside the clone
composeFileName: string | undefined; // compose file relative to composeDir
rawManifest: string | null | undefined;
}): Promise<{ plan: DeletionPlan; newFiles: Record<string, string>; previousManifest: SyncManifest }> {
const { logPrefix, composeDir, composeFileName, rawManifest } = options;
const previousManifest = parseManifest(rawManifest);
const newFiles = hashDirFiles(composeDir);
const manifestSize = Object.keys(previousManifest.files).length;
console.log(`${logPrefix} Deletion sync: manifest has ${manifestSize} file(s)${manifestSize === 0 ? ' (first sync — nothing will be deleted)' : ''}`);
// First sync / legacy manifest: nothing was recorded, so nothing can be deleted
if (manifestSize === 0) {
return { plan: { toDelete: [], skipped: [] }, newFiles, previousManifest };
}
const blocked = deletionSafetyCheck(previousManifest.files, newFiles, composeFileName);
if (blocked) {
console.warn(`${logPrefix} Deletion sync: ${blocked}`);
return { plan: { toDelete: [], skipped: [] }, newFiles, previousManifest };
}
const plan = computeDeletions(previousManifest.files, newFiles);
for (const file of plan.toDelete) {
console.log(`${logPrefix} Deletion sync: will remove "${file.path}" — deleted from the repository`);
}
for (const skip of plan.skipped) {
console.warn(`${logPrefix} Deletion sync: keeping "${skip.path}" — ${skipReasonMessage(skip.reason)}`);
}
return { plan, newFiles, previousManifest };
}
/**
* Persist the manifest after a deploy and log the per-file change summary.
* Called only after a successful deploy (locally applied or agent-confirmed).
*/
async function finalizeDeletionSync(options: {
stackId: number;
logPrefix: string;
previousManifest: SyncManifest;
newCommitFull: string;
newFiles: Record<string, string>;
plan: DeletionPlan;
applyResult: DeletionApplyResult | undefined;
onLine?: (line: string) => void; // extra sink (job output)
}): Promise<void> {
const { stackId, logPrefix, previousManifest, newCommitFull, newFiles, plan, applyResult, onLine } = options;
// No apply result means deletions were requested but nothing reported back
// (defensive — executors always return one). Logged as skips; skips are final.
const effectiveApply: DeletionApplyResult = applyResult ?? {
deleted: [],
skipped: plan.toDelete.map((f): DeletionSkip => ({ path: f.path, reason: 'apply-failed' }))
};
// Pass only the plan-stage skips; buildSyncChangeSummary already merges
// in effectiveApply.skipped itself. Concatenating here duplicated every
// apply-stage skip (locally-modified, agent-no-support, apply-failed).
const summary = buildSyncChangeSummary(previousManifest.files, newFiles, effectiveApply, plan.skipped);
const tableLines = formatChangeTable(summary);
console.log(`${logPrefix} Sync file changes: ${tableLines[0]}`);
for (const line of tableLines.slice(1)) {
console.log(`${logPrefix} ${line}`);
}
if (onLine) {
onLine(`File changes: ${tableLines[0]}`);
for (const line of tableLines.slice(1)) onLine(line);
}
const nextManifest = buildNextManifest(newCommitFull, newFiles);
await updateGitStack(stackId, { syncedFiles: serializeManifest(nextManifest) });
console.log(`${logPrefix} Manifest persisted: ${Object.keys(nextManifest.files).length} file(s) at commit ${nextManifest.commit?.substring(0, 7)}`);
}
export interface SyncResult {
success: boolean;
commit?: string;
@@ -477,11 +302,6 @@ export interface SyncResult {
error?: string;
updated?: boolean;
changedFiles?: string[]; // List of files that changed (for logging/debugging)
// Deletion sync (#966/#1162): manifest-vs-clone data
deletionPlan?: DeletionPlan; // Files safe to delete (manifest entries absent from the new clone) + plan-stage skips
newFiles?: Record<string, string>; // path → sha256 of files in the new clone (next manifest)
newCommitFull?: string; // Full 40-char commit hash (manifest commit)
previousManifest?: SyncManifest; // Manifest from the last successful sync
}
export interface TestResult {
@@ -957,15 +777,15 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
// (e.g., config files, scripts, additional env files)
let changedFiles: string[] = [];
if (commitChanged) {
// Use contextDir if set, otherwise fall back to compose file's directory
const diffDirRelative = gitStack.contextDir || dirname(gitStack.composePath);
console.log(`${logPrefix} Checking for changes in directory: ${diffDirRelative || '(root)'}`);
// Get the directory containing the compose file (relative to repo root)
const composeDirRelative = dirname(gitStack.composePath);
console.log(`${logPrefix} Checking for changes in directory: ${composeDirRelative || '(root)'}`);
const diffResult = await getChangedFilesInDir(
repoPath,
previousCommit,
newCommit,
diffDirRelative || '.',
composeDirRelative || '.',
env
);
@@ -1007,29 +827,10 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
console.log(`${logPrefix} Compose content:`);
console.log(composeContent);
// Determine the source directory and compose filename
// If contextDir is set, use it as the source directory (relative to repo root)
// and compute composeFileName as relative path from contextDir to compose file
let composeDir: string;
let composeFileName: string;
if (gitStack.contextDir) {
const contextDirAbsolute = resolve(repoPath, gitStack.contextDir);
// Validate: context dir must be within repo
if (!contextDirAbsolute.startsWith(repoPath)) {
throw new Error('Context directory must be within the repository');
}
// Validate: compose file must be within context directory
const relCompose = relative(contextDirAbsolute, composePath);
if (relCompose.startsWith('..')) {
throw new Error('Compose file must be within the context directory');
}
composeDir = contextDirAbsolute;
composeFileName = relCompose; // e.g., "apps/myapp/compose.yaml"
} else {
composeDir = dirname(composePath);
composeFileName = basename(gitStack.composePath); // e.g., "docker-compose.yaml"
}
console.log(`${logPrefix} Source directory (composeDir):`, composeDir);
// Determine the compose directory and filename (for copying all files)
const composeDir = dirname(composePath);
const composeFileName = basename(gitStack.composePath); // e.g., "docker-compose.yaml"
console.log(`${logPrefix} Compose directory:`, composeDir);
console.log(`${logPrefix} Compose filename:`, composeFileName);
// Read env file if configured (optional - don't fail if missing)
@@ -1061,14 +862,6 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
console.log(`${logPrefix} No env file path configured`);
}
// Deletion sync (#966): manifest-vs-clone deletion plan
const deletionData = await computeSyncDeletionPlan({
logPrefix,
composeDir,
composeFileName,
rawManifest: gitStack.syncedFiles
});
// Update git stack status
await updateGitStack(stackId, {
syncStatus: 'synced',
@@ -1097,11 +890,7 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
envFileVars,
envFileName,
updated,
changedFiles,
deletionPlan: deletionData.plan,
newFiles: deletionData.newFiles,
newCommitFull: newCommit,
previousManifest: deletionData.previousManifest
changedFiles
};
} catch (error: any) {
cleanupSshKey(credential);
@@ -1143,7 +932,7 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
console.log(`${logPrefix} Sync result - env file vars:`, syncResult.envFileVars ? Object.keys(syncResult.envFileVars).length : 0);
if (syncResult.envFileVars && Object.keys(syncResult.envFileVars).length > 0) {
console.log(`${logPrefix} Env file var keys:`, Object.keys(syncResult.envFileVars).join(', '));
console.log(`${logPrefix} Env file vars (masked):`, JSON.stringify(redactEnvVarsForLog(syncResult.envFileVars), null, 2));
console.log(`${logPrefix} Env file vars (masked):`, JSON.stringify(maskSecrets(syncResult.envFileVars), null, 2));
}
// Check if there are changes - skip redeploy if no changes and not forced
@@ -1183,9 +972,7 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
envFileName: syncResult.envFileName, // Env file relative to compose dir (for --env-file flag, optional)
forceRecreate,
build: gitStack.buildOnDeploy,
noBuildCache: gitStack.noBuildCache,
pullPolicy: gitStack.repullImages ? 'always' : undefined,
filesToDelete: syncResult.deletionPlan?.toDelete
pullPolicy: gitStack.repullImages ? 'always' : undefined
});
console.log(`${logPrefix} ----------------------------------------`);
@@ -1196,19 +983,6 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
if (result.error) console.log(`${logPrefix} Error:`, result.error);
if (result.success) {
// Deletion sync: persist manifest + log per-file change summary
if (syncResult.previousManifest && syncResult.newFiles && syncResult.newCommitFull && syncResult.deletionPlan) {
await finalizeDeletionSync({
stackId,
logPrefix,
previousManifest: syncResult.previousManifest,
newCommitFull: syncResult.newCommitFull,
newFiles: syncResult.newFiles,
plan: syncResult.deletionPlan,
applyResult: result.deletion
});
}
// Record the stack source with resolved compose path for consistency
const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId);
const resolvedComposePath = syncResult.composeFileName
@@ -1374,15 +1148,15 @@ export async function deployGitStackWithProgress(
// 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 context/compose directory have changed
// Check if any files in the compose file's directory have changed
// (for consistency with syncGitStack, though this function always deploys)
if (commitChanged) {
const diffDir = gitStack.contextDir || dirname(gitStack.composePath);
const composeDir = dirname(gitStack.composePath);
const diffResult = await getChangedFilesInDir(
repoPath,
previousCommit,
newCommit,
diffDir || '.',
composeDir || '.',
env
);
updated = diffResult.changed;
@@ -1403,24 +1177,8 @@ export async function deployGitStackWithProgress(
const composeContent = readFileSync(composePath, 'utf-8');
// Determine the source directory and compose filename
let composeDir: string;
let progressComposeFileName: string;
if (gitStack.contextDir) {
const contextDirAbsolute = resolve(repoPath, gitStack.contextDir);
if (!contextDirAbsolute.startsWith(repoPath)) {
throw new Error('Context directory must be within the repository');
}
const relCompose = relative(contextDirAbsolute, composePath);
if (relCompose.startsWith('..')) {
throw new Error('Compose file must be within the context directory');
}
composeDir = contextDirAbsolute;
progressComposeFileName = relCompose;
} else {
composeDir = dirname(composePath);
progressComposeFileName = basename(gitStack.composePath);
}
// Determine the compose directory (for copying all files)
const composeDir = dirname(composePath);
// Read env file if configured (optional - don't fail if missing)
let envFileVars: Record<string, string> | undefined;
@@ -1439,15 +1197,6 @@ export async function deployGitStackWithProgress(
}
}
// Deletion sync (#966): manifest-vs-clone deletion plan
const logPrefix = `[Stack:${gitStack.stackName}]`;
const deletionData = await computeSyncDeletionPlan({
logPrefix,
composeDir,
composeFileName: progressComposeFileName,
rawManifest: gitStack.syncedFiles
});
// Update git stack status
await updateGitStack(stackId, {
syncStatus: 'synced',
@@ -1461,14 +1210,6 @@ export async function deployGitStackWithProgress(
// Step 5: Deploying stack
// Uses `docker compose up -d --remove-orphans` which only recreates changed services
onProgress({ status: 'deploying', message: `Deploying ${gitStack.stackName}...`, step: 5, totalSteps });
if (deletionData.plan.toDelete.length > 0) {
onProgress({
status: 'deploying',
message: `Removing ${deletionData.plan.toDelete.length} file(s) deleted from the repository...`,
step: 5,
totalSteps
});
}
// Determine env filename relative to compose dir (same logic as syncGitStack)
let envFileName: string | undefined;
@@ -1484,31 +1225,16 @@ export async function deployGitStackWithProgress(
compose: composeContent,
envId: gitStack.environmentId,
sourceDir: composeDir, // Copy entire directory from git repo
composeFileName: progressComposeFileName, // Compose filename relative to source dir
composeFileName: basename(gitStack.composePath), // Use original compose filename from repo
envFileName, // Env file relative to compose dir (for --env-file flag, optional)
build: gitStack.buildOnDeploy,
noBuildCache: gitStack.noBuildCache,
pullPolicy: gitStack.repullImages ? 'always' : undefined,
filesToDelete: deletionData.plan.toDelete
pullPolicy: gitStack.repullImages ? 'always' : undefined
});
if (result.success) {
// Deletion sync: persist manifest + log per-file change summary.
// onLine feeds the per-file change table into the deploy progress popover.
await finalizeDeletionSync({
stackId,
logPrefix,
previousManifest: deletionData.previousManifest,
newCommitFull: newCommit,
newFiles: deletionData.newFiles,
plan: deletionData.plan,
applyResult: result.deletion,
onLine: (line) => onProgress({ status: 'deploying', message: line, step: 5, totalSteps })
});
// Record the stack source with resolved compose path for consistency
const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId);
const resolvedComposePath = join(stackDir, progressComposeFileName);
const resolvedComposePath = join(stackDir, basename(gitStack.composePath));
await upsertStackSource({
stackName: gitStack.stackName,
@@ -1621,7 +1347,13 @@ export function parseEnvFileContent(content: string, stackName?: string): Record
}
const key = trimmed.substring(0, eqIndex).trim();
const value = trimmed.substring(eqIndex + 1).trim();
let value = trimmed.substring(eqIndex + 1).trim();
// Handle quoted values
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
// Only add if key is valid env var name
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
@@ -1633,7 +1365,7 @@ export function parseEnvFileContent(content: string, stackName?: string): Record
console.log(`${logPrefix} Parsed env vars count:`, Object.keys(result).length);
console.log(`${logPrefix} Parsed env var keys:`, Object.keys(result).join(', '));
console.log(`${logPrefix} Parsed env vars (masked):`, JSON.stringify(redactEnvVarsForLog(result), null, 2));
console.log(`${logPrefix} Parsed env vars (masked):`, JSON.stringify(maskSecrets(result), null, 2));
if (skippedLines.length > 0) {
console.log(`${logPrefix} Skipped lines (${skippedLines.length}):`, skippedLines.slice(0, 10).join('; '));
}
+29 -59
View File
@@ -8,9 +8,7 @@
import { db, hawserTokens, environments, eq, and } from './db/drizzle.js';
import { logContainerEvent, type ContainerEventAction } from './db.js';
import { containerEventEmitter } from './event-collector.js';
import { sendEnvironmentNotification } from './notifications/index.js';
import { isNotifyDisabledByLabel } from './container-labels.js';
import { isHealthTransition } from './subprocess-manager.js';
import { sendEnvironmentNotification } from './notifications.js';
import { pushMetric } from './metrics-store.js';
import { secureGetRandomValues, secureRandomUUID } from './crypto-fallback.js';
import { hashPassword, verifyPassword } from './auth.js';
@@ -179,12 +177,6 @@ export async function handleEdgeContainerEvent(
// Log the event
console.log(`[Hawser] Container event from env ${environmentId}: ${event.action} ${event.containerName || event.containerId}`);
// Only store health_status events on transitions (healthy↔unhealthy)
// to avoid flooding the DB with repeated identical health checks
if (!isHealthTransition(environmentId, event.containerId, event.action)) {
return;
}
// Save to database
const savedEvent = await logContainerEvent({
environmentId,
@@ -199,26 +191,24 @@ export async function handleEdgeContainerEvent(
// Broadcast to SSE clients
containerEventEmitter.emit('event', savedEvent);
// Check dockhand.notify label before sending notification
// Docker includes container labels in actorAttributes
if (!isNotifyDisabledByLabel(event.actorAttributes)) {
const actionLabel = event.action.charAt(0).toUpperCase() + event.action.slice(1);
const containerLabel = event.containerName || event.containerId.substring(0, 12);
const notificationType =
event.action === 'die' || event.action === 'kill' || event.action === 'oom'
? 'error'
: event.action === 'stop'
? 'warning'
: event.action === 'start'
? 'success'
: 'info';
// Prepare notification
const actionLabel = event.action.charAt(0).toUpperCase() + event.action.slice(1);
const containerLabel = event.containerName || event.containerId.substring(0, 12);
const notificationType =
event.action === 'die' || event.action === 'kill' || event.action === 'oom'
? 'error'
: event.action === 'stop'
? 'warning'
: event.action === 'start'
? 'success'
: 'info';
await sendEnvironmentNotification(environmentId, event.action as ContainerEventAction, {
title: `Container ${actionLabel}`,
message: `Container "${containerLabel}" ${event.action}${event.image ? ` (${event.image})` : ''}`,
type: notificationType as 'success' | 'error' | 'warning' | 'info'
}, event.image);
}
// Send notification
await sendEnvironmentNotification(environmentId, event.action as ContainerEventAction, {
title: `Container ${actionLabel}`,
message: `Container "${containerLabel}" ${event.action}${event.image ? ` (${event.image})` : ''}`,
type: notificationType as 'success' | 'error' | 'warning' | 'info'
}, event.image);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error('[Hawser] Error handling container event:', errorMsg);
@@ -265,16 +255,6 @@ export async function handleEdgeMetrics(
// Register global handler for metrics
globalThis.__hawserHandleMetrics = handleEdgeMetrics;
let dummyHawserHash: string | null = null;
async function getDummyHawserHash(): Promise<string> {
if (!dummyHawserHash) {
dummyHawserHash = await hashPassword('hawser_init_seed');
}
return dummyHawserHash;
}
// Warm the lazy init so first-call latency is consistent.
getDummyHawserHash().catch(() => {});
/**
* Validate a Hawser token
*/
@@ -289,32 +269,22 @@ export async function validateHawserToken(
.from(hawserTokens)
.where(and(eq(hawserTokens.tokenPrefix, prefix), eq(hawserTokens.isActive, true)));
if (candidates.length === 0) {
await verifyPassword(token, await getDummyHawserHash());
return { valid: false };
}
for (const t of candidates) {
try {
const isValid = await verifyPassword(token, t.token);
if (!isValid) continue;
if (isValid) {
// Update last used timestamp
await db
.update(hawserTokens)
.set({ lastUsed: new Date().toISOString() })
.where(eq(hawserTokens.id, t.id));
// Expiry check intentionally runs after the hash verify.
if (t.expiresAt && new Date(t.expiresAt) < new Date()) {
return { valid: false };
return {
valid: true,
environmentId: t.environmentId ?? undefined,
tokenId: t.id
};
}
// Update last used timestamp
await db
.update(hawserTokens)
.set({ lastUsed: new Date().toISOString() })
.where(eq(hawserTokens.id, t.id));
return {
valid: true,
environmentId: t.environmentId ?? undefined,
tokenId: t.id
};
} catch {
// Invalid hash format, skip
}
+23 -65
View File
@@ -34,8 +34,6 @@ let cachedMounts: Array<{ source: string; destination: string }> | null = null;
// Used by scanner to replicate how Dockhand connects to Docker
let cachedOwnDockerHost: string | null = null;
let cachedOwnNetworkMode: string | null = null;
let cachedOwnAllNetworks: string[] | null = null;
let cachedOwnExtraHosts: string[] | null = null;
/**
* Get our own container ID
@@ -87,11 +85,12 @@ export async function detectHostDataDir(): Promise<string | null> {
if (process.env.HOST_DATA_DIR) {
cachedHostDataDir = process.env.HOST_DATA_DIR;
console.log(`[HostPath] Using HOST_DATA_DIR from environment: ${cachedHostDataDir}`);
return cachedHostDataDir;
}
const containerId = getOwnContainerId();
if (!containerId) {
console.warn('[HostPath] Running in Docker but could not detect container ID; ExtraHosts will not be mirrored to sidecars');
console.warn('[HostPath] Running in Docker but could not detect container ID');
return null;
}
@@ -141,9 +140,6 @@ export async function detectHostDataDir(): Promise<string | null> {
Config?: {
Env?: string[];
};
HostConfig?: {
ExtraHosts?: string[];
};
NetworkSettings?: {
Networks?: Record<string, unknown>;
};
@@ -167,10 +163,7 @@ export async function detectHostDataDir(): Promise<string | null> {
}
}
// Cache Dockhand's networks. Picks one as the primary networkMode
// (custom net first, falling back to bridge) and keeps the full list
// so callers can warn when a setup is fragile — e.g. socket-proxy
// living on a network other than the one the scanner joins (#1011).
// Cache Dockhand's network (prefer non-default for service discovery)
const networks = containerInfo.NetworkSettings?.Networks;
if (networks) {
const custom = Object.keys(networks).filter(
@@ -178,25 +171,11 @@ export async function detectHostDataDir(): Promise<string | null> {
);
cachedOwnNetworkMode = custom.length > 0 ? custom[0]
: networks.bridge ? 'bridge' : null;
cachedOwnAllNetworks = Object.keys(networks);
if (cachedOwnNetworkMode) {
console.log(`[HostPath] Detected own network: ${cachedOwnNetworkMode} (all: ${cachedOwnAllNetworks.join(', ')})`);
console.log(`[HostPath] Detected own network: ${cachedOwnNetworkMode}`);
}
}
cachedOwnExtraHosts = containerInfo.HostConfig?.ExtraHosts?.length
? [...containerInfo.HostConfig.ExtraHosts]
: null;
if (cachedOwnExtraHosts) {
console.log(`[HostPath] Detected own ExtraHosts: ${cachedOwnExtraHosts.join(', ')}`);
}
// Explicit override wins for DATA_DIR path, but we still inspect to populate
// mounts/network/DOCKER_HOST/ExtraHosts caches for sibling sidecars.
if (cachedHostDataDir) {
return cachedHostDataDir;
}
// Find the mount for our DATA_DIR
const dataMount = containerInfo.Mounts?.find(m => m.Destination === dataDir);
@@ -250,24 +229,6 @@ export function getOwnNetworkMode(): string | null {
return cachedOwnNetworkMode;
}
/**
* All Docker networks Dockhand itself is attached to. The scanner uses
* this to detect split-network setups and warn that socket-proxy may not
* be reachable from the network it actually joins (#1011).
*/
export function getOwnAllNetworks(): string[] {
return cachedOwnAllNetworks ? [...cachedOwnAllNetworks] : [];
}
/**
* Get the ExtraHosts entries configured on Dockhand itself.
* Used to mirror host aliases into sibling sidecar containers.
* Populated by detectHostDataDir() at startup.
*/
export function getOwnExtraHosts(): string[] | null {
return cachedOwnExtraHosts ? [...cachedOwnExtraHosts] : null;
}
/**
* Translate a container path to host path
*
@@ -399,39 +360,36 @@ export function extractUidFromSocketPath(socketPath: string): string | null {
export function rewriteComposeVolumePaths(composeContent: string, workingDir: string): { content: string; modified: boolean; changes: string[] } {
const changes: string[] = [];
// Parse compose content line by line to find and rewrite volume mounts.
// Try to translate workingDir to host path using ANY cached mount
// This handles both DATA_DIR mounts and external mounts (e.g., /external-stacks)
const hostWorkingDir = translateContainerPathViaMount(workingDir);
if (!hostWorkingDir) {
// Can't translate - workingDir is not under any known mount
return { content: composeContent, modified: false, changes };
}
// Parse compose content line by line to find and rewrite volume mounts
// We look for patterns like:
// - ./something:/container/path
// - ../something:/container/path
// - "./something:/container/path"
// - '../something:/container/path'
// - './something:/container/path'
const lines = composeContent.split('\n');
const modifiedLines: string[] = [];
for (const line of lines) {
// Match volume mount patterns with relative paths.
// Handles ./path and ../path, optionally quoted with single or double quotes.
const volumeMatch = line.match(/^(\s*-\s*)(['"]?)(\.\.?\/[^'":\s]+)(\2)(:.+)$/);
// Match volume mount patterns with relative paths
// Handles: - ./path:/dest, - "./path:/dest", - './path:/dest'
const volumeMatch = line.match(/^(\s*-\s*)(['"]?)(\.\/[^'":\s]+)(\2)(:.+)$/);
if (volumeMatch) {
const [, prefix, quote, relativeSrc, , destPart] = volumeMatch;
// Resolve to an absolute container path, then translate to a host
// path via any known mount. Each line is translated independently so
// `../foo` can escape workingDir into a sibling that may map to a
// different mount than workingDir itself.
const absoluteContainerPath = resolve(workingDir, relativeSrc);
const absoluteHostPath = translateContainerPathViaMount(absoluteContainerPath);
// Convert relative path to absolute host path
const absoluteHostPath = hostWorkingDir + '/' + relativeSrc.substring(2); // Remove ./
if (absoluteHostPath) {
const newLine = `${prefix}${absoluteHostPath}${destPart}`;
modifiedLines.push(newLine);
changes.push(` ${relativeSrc} -> ${absoluteHostPath}`);
} else {
// Can't translate — leave line unchanged. Compose will resolve
// it relative to its cwd; if that's wrong the deploy fails
// loudly, which is better than producing a misleading host path.
modifiedLines.push(line);
}
const newLine = `${prefix}${absoluteHostPath}${destPart}`;
modifiedLines.push(newLine);
changes.push(` ${relativeSrc} -> ${absoluteHostPath}`);
} else {
modifiedLines.push(line);
}
-36
View File
@@ -1,36 +0,0 @@
type HostConfigLike = {
Binds?: string[] | null;
Mounts?: Array<{ Target?: string | null }> | null;
};
type InspectMountLike = {
Type?: string | null;
Name?: string | null;
Destination?: string | null;
};
/** Build extra bind strings for volume mounts missing from HostConfig. */
export function getAdditionalVolumeBinds(
hostConfig: HostConfigLike,
mounts: InspectMountLike[]
): string[] {
const existingMountTargets = new Set((hostConfig.Binds || []).map((bind: string) => {
const parts = bind.split(':');
return parts.length >= 2 ? parts[1] : parts[0];
}));
for (const mount of hostConfig.Mounts || []) {
if (mount?.Target) existingMountTargets.add(mount.Target);
}
const additionalBinds: string[] = [];
for (const mount of mounts || []) {
if (mount.Type === 'volume' && mount.Name && mount.Destination) {
if (!existingMountTargets.has(mount.Destination)) {
additionalBinds.push(`${mount.Name}:${mount.Destination}`);
}
}
}
return additionalBinds;
}
+689
View File
@@ -0,0 +1,689 @@
import nodemailer from 'nodemailer';
import {
getEnabledNotificationSettings,
getEnabledEnvironmentNotifications,
getEnvironment,
type NotificationSettingData,
type SmtpConfig,
type AppriseConfig,
type NotificationEventType
} from './db';
// Escape special characters for Telegram Markdown
function escapeTelegramMarkdown(text: string): string {
// Escape characters that have special meaning in Telegram Markdown
return text
.replace(/\\/g, '\\\\') // Escape backslashes first
.replace(/_/g, '\\_') // Underscore (italic)
.replace(/\*/g, '\\*') // Asterisk (bold)
.replace(/\[/g, '\\[') // Opening bracket (link)
.replace(/\]/g, '\\]') // Closing bracket (link)
.replace(/`/g, '\\`'); // Backtick (code)
}
/** Drain a response body to release the underlying socket/TLS connection. */
async function drainResponse(response: Response): Promise<void> {
if (!response.bodyUsed) {
try { await response.arrayBuffer(); } catch {}
}
}
export interface NotificationPayload {
title: string;
message: string;
type?: 'info' | 'success' | 'warning' | 'error';
environmentId?: number;
environmentName?: string;
}
// Result type for functions that can return detailed errors
export interface NotificationResult {
success: boolean;
error?: string;
}
// Send notification via SMTP
async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise<NotificationResult> {
try {
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>`
: '';
const envText = payload.environmentName ? ` [${payload.environmentName}]` : '';
const html = `
<div style="font-family: sans-serif; padding: 20px;">
<h2 style="margin: 0 0 10px 0;">${payload.title}${envBadge}</h2>
<p style="margin: 0; white-space: pre-wrap;">${payload.message}</p>
<hr style="margin: 20px 0; border: none; border-top: 1px solid #eee;">
<p style="margin: 0; font-size: 12px; color: #666;">Sent by Dockhand</p>
</div>
`;
await transporter.sendMail({
from: config.from_name ? `"${config.from_name}" <${config.from_email}>` : config.from_email,
to: config.to_emails.join(', '),
subject: `[Dockhand]${envText} ${payload.title}`,
text: `${payload.title}${envText}\n\n${payload.message}`,
html
});
return { success: true };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return { success: false, error: `SMTP error: ${errorMsg}` };
}
}
// Parse Apprise URL and send notification
async function sendAppriseNotification(config: AppriseConfig, payload: NotificationPayload): Promise<NotificationResult> {
const errors: string[] = [];
for (const url of config.urls) {
try {
const result = await sendToAppriseUrl(url, payload);
if (!result.success && result.error) {
errors.push(result.error);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
errors.push(`Failed to send: ${errorMsg}`);
}
}
if (errors.length > 0) {
return { success: false, error: errors.join('; ') };
}
return { success: true };
}
// Send to a single Apprise URL
async function sendToAppriseUrl(url: string, payload: NotificationPayload): Promise<NotificationResult> {
try {
// Extract protocol from Apprise URL format (protocol://...)
// Note: Can't use new URL() because custom schemes like 'tgram://' are not valid URLs
const protocolMatch = url.match(/^([a-z]+):\/\//i);
if (!protocolMatch) {
return { success: false, error: 'Invalid Apprise URL format - missing protocol' };
}
const protocol = protocolMatch[1].toLowerCase();
// Handle different notification services
switch (protocol) {
case 'discord':
case 'discords':
return await sendDiscord(url, payload);
case 'slack':
case 'slacks':
return await sendSlack(url, payload);
case 'mmost':
case 'mmosts':
return await sendMattermost(url, payload);
case 'tgram':
return await sendTelegram(url, payload);
case 'gotify':
case 'gotifys':
return await sendGotify(url, payload);
case 'ntfy':
case 'ntfys':
return await sendNtfy(url, payload);
case 'pushover':
return await sendPushover(url, payload);
case 'json':
case 'jsons':
return await sendGenericWebhook(url, payload);
default:
return { success: false, error: `Unsupported Apprise protocol: ${protocol}` };
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return { success: false, error: `Failed to parse Apprise URL: ${errorMsg}` };
}
}
// Discord webhook
async function sendDiscord(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// discord://webhook_id/webhook_token or discords://...
const url = appriseUrl.replace(/^discords?:\/\//, 'https://discord.com/api/webhooks/');
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
embeds: [{
title: titleWithEnv,
description: payload.message,
color: payload.type === 'error' ? 0xff0000 : payload.type === 'warning' ? 0xffaa00 : payload.type === 'success' ? 0x00ff00 : 0x0099ff,
...(payload.environmentName && {
footer: { text: `Environment: ${payload.environmentName}` }
})
}]
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Discord error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Discord connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Slack webhook
async function sendSlack(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// slack://token_a/token_b/token_c or webhook URL
let url: string;
if (appriseUrl.includes('hooks.slack.com')) {
url = appriseUrl.replace(/^slacks?:\/\//, 'https://');
} else {
const parts = appriseUrl.replace(/^slacks?:\/\//, '').split('/');
url = `https://hooks.slack.com/services/${parts.join('/')}`;
}
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `*${payload.title}*${envTag}\n${payload.message}`
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Slack error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Slack connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Mattermost webhook
async function sendMattermost(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// mmost://[botname@]hostname[:port][/path]/token or mmosts://...
const isSecure = appriseUrl.startsWith('mmosts');
const protocol = isSecure ? 'https' : 'http';
// Remove the scheme
let urlPart = appriseUrl.replace(/^mmosts?:\/\//, '');
// Check for botname (username@hostname format)
let username: string | undefined;
const atIndex = urlPart.indexOf('@');
if (atIndex !== -1) {
username = urlPart.substring(0, atIndex);
urlPart = urlPart.substring(atIndex + 1);
}
// The token is the last segment, everything else is hostname[:port][/path]
const lastSlashIndex = urlPart.lastIndexOf('/');
if (lastSlashIndex === -1) {
return { success: false, error: 'Invalid Mattermost URL format. Expected: mmost://[botname@]hostname[:port][/path]/token' };
}
const token = urlPart.substring(lastSlashIndex + 1);
const hostAndPath = urlPart.substring(0, lastSlashIndex);
// Build the webhook URL: {protocol}://{hostname}[:{port}][/{path}]/hooks/{token}
const url = `${protocol}://${hostAndPath}/hooks/${token}`;
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
const body: Record<string, string> = {
text: `*${payload.title}*${envTag}\n${payload.message}`
};
if (username) {
body.username = username;
}
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Mattermost error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Mattermost connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Telegram
async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// tgram://bot_token/chat_id
const match = appriseUrl.match(/^tgram:\/\/([^/]+)\/(.+)/);
if (!match) {
return { success: false, error: 'Invalid Telegram URL format. Expected: tgram://bot_token/chat_id' };
}
const [, botToken, chatId] = match;
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
// Escape markdown special characters in title and message
const escapedTitle = escapeTelegramMarkdown(payload.title);
const escapedMessage = escapeTelegramMarkdown(payload.message);
const envTag = payload.environmentName ? ` \\[${escapeTelegramMarkdown(payload.environmentName)}\\]` : '';
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text: `*${escapedTitle}*${envTag}\n${escapedMessage}`,
parse_mode: 'Markdown'
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({})) as { description?: string };
const errorMsg = errorData.description || response.statusText;
return { success: false, error: `Telegram error ${response.status}: ${errorMsg}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Telegram connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Gotify
async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// gotify://hostname/token or gotifys://hostname/token
// gotify://hostname/subpath/token (subpath support)
const match = appriseUrl.match(/^gotifys?:\/\/([^/]+)\/(.+)/);
if (!match) {
return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' };
}
const [, hostname, pathPart] = match;
const protocol = appriseUrl.startsWith('gotifys') ? 'https' : 'http';
// Token is always the last path segment; anything before it is a subpath
const lastSlash = pathPart.lastIndexOf('/');
const subpath = lastSlash >= 0 ? pathPart.substring(0, lastSlash) : '';
const token = lastSlash >= 0 ? pathPart.substring(lastSlash + 1) : pathPart;
const url = `${protocol}://${hostname}${subpath ? '/' + subpath : ''}/message?token=${token}`;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: payload.title,
message: payload.message,
priority: payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Gotify error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Gotify connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// ntfy
async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// Supported formats:
// ntfy://topic (public ntfy.sh)
// ntfy://host/topic (custom server, no auth)
// ntfy://user:pass@host/topic (custom server with auth)
// ntfys:// variants for HTTPS
const isSecure = appriseUrl.startsWith('ntfys');
const path = appriseUrl.replace(/^ntfys?:\/\//, '');
let url: string;
let authHeader: string | null = null;
// Check for user:pass@host/topic format (Basic auth)
const basicMatch = path.match(/^([^:]+):([^@]+)@(.+)$/);
if (basicMatch) {
const [, user, pass, hostAndTopic] = basicMatch;
const basic = Buffer.from(`${user}:${pass}`).toString('base64');
authHeader = `Basic ${basic}`;
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
} else if (path.includes('@') && path.includes('/')) {
// token@host/topic -> Bearer token auth
const tokenMatch = path.match(/^([^@]+)@(.+)$/);
if (tokenMatch) {
const [, token, hostAndTopic] = tokenMatch;
authHeader = `Bearer ${token}`;
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
} else {
// Fallback to custom server without auth
url = `${isSecure ? 'https' : 'http'}://${path}`;
}
} else if (path.includes('/')) {
// Custom server without auth
url = `${isSecure ? 'https' : 'http'}://${path}`;
} else {
// Default ntfy.sh
url = `https://ntfy.sh/${path}`;
}
const headers: Record<string, string> = {
'Title': payload.title,
'Priority': payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3',
'Tags': payload.type || 'info'
};
if (authHeader) {
headers['Authorization'] = authHeader;
}
try {
const response = await fetch(url, {
method: 'POST',
headers,
body: payload.message
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `ntfy error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `ntfy connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Pushover
async function sendPushover(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// pushover://user_key/api_token
const match = appriseUrl.match(/^pushover:\/\/([^/]+)\/(.+)/);
if (!match) {
return { success: false, error: 'Invalid Pushover URL format. Expected: pushover://user_key/api_token' };
}
const [, userKey, apiToken] = match;
const url = 'https://api.pushover.net/1/messages.json';
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: apiToken,
user: userKey,
title: payload.title,
message: payload.message,
priority: payload.type === 'error' ? 1 : 0
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Pushover error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Pushover connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Generic JSON webhook
async function sendGenericWebhook(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// json://hostname/path or jsons://hostname/path
const url = appriseUrl.replace(/^jsons?:\/\//, appriseUrl.startsWith('jsons') ? 'https://' : 'http://');
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: payload.title,
message: payload.message,
type: payload.type || 'info',
timestamp: new Date().toISOString()
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Webhook error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Webhook connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Send notification to all enabled channels
export async function sendNotification(payload: NotificationPayload): Promise<{ success: boolean; results: { name: string; success: boolean }[] }> {
const settings = await getEnabledNotificationSettings();
const results: { name: string; success: boolean }[] = [];
for (const setting of settings) {
let result: NotificationResult = { success: false };
if (setting.type === 'smtp') {
result = await sendSmtpNotification(setting.config as SmtpConfig, payload);
} else if (setting.type === 'apprise') {
result = await sendAppriseNotification(setting.config as AppriseConfig, payload);
}
results.push({ name: setting.name, success: result.success });
}
return {
success: results.every(r => r.success),
results
};
}
// Test a specific notification setting
export async function testNotification(setting: NotificationSettingData): Promise<NotificationResult> {
const payload: NotificationPayload = {
title: 'Dockhand Test Notification',
message: 'This is a test notification from Dockhand. If you receive this, your notification settings are configured correctly.',
type: 'info'
};
if (setting.type === 'smtp') {
return await sendSmtpNotification(setting.config as SmtpConfig, payload);
} else if (setting.type === 'apprise') {
return await sendAppriseNotification(setting.config as AppriseConfig, payload);
}
return { success: false, error: 'Unknown notification type' };
}
// Map Docker action to notification event type
function mapActionToEventType(action: string): NotificationEventType | null {
const mapping: Record<string, NotificationEventType> = {
'start': 'container_started',
'stop': 'container_stopped',
'restart': 'container_restarted',
'die': 'container_exited',
'kill': 'container_exited',
'oom': 'container_oom',
'health_status: unhealthy': 'container_unhealthy',
'health_status: healthy': 'container_healthy',
'pull': 'image_pulled'
};
return mapping[action] || null;
}
// Scanner image patterns to exclude from notifications
const SCANNER_IMAGE_PATTERNS = [
'anchore/grype',
'aquasec/trivy',
'ghcr.io/anchore/grype',
'ghcr.io/aquasecurity/trivy'
];
function isScannerContainer(image: string | null | undefined): boolean {
if (!image) return false;
const lowerImage = image.toLowerCase();
return SCANNER_IMAGE_PATTERNS.some(pattern => lowerImage.includes(pattern.toLowerCase()));
}
// Send notification for an environment-specific event
export async function sendEnvironmentNotification(
environmentId: number,
action: string,
payload: Omit<NotificationPayload, 'environmentId' | 'environmentName'>,
image?: string | null
): Promise<{ success: boolean; sent: number }> {
const eventType = mapActionToEventType(action);
if (!eventType) {
// Not a notifiable event type
return { success: true, sent: 0 };
}
// Get environment name
const env = await getEnvironment(environmentId);
if (!env) {
return { success: false, sent: 0 };
}
// Get enabled notification channels for this environment and event type
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
if (envNotifications.length === 0) {
return { success: true, sent: 0 };
}
const enrichedPayload: NotificationPayload = {
...payload,
environmentId,
environmentName: env.name
};
// Check if this is a scanner container
const isScanner = isScannerContainer(image);
let sent = 0;
let allSuccess = true;
// Skip all notifications for scanner containers (Trivy, Grype)
if (isScanner) {
return { success: true, sent: 0 };
}
for (const notif of envNotifications) {
try {
let result: NotificationResult = { success: false };
if (notif.channelType === 'smtp') {
result = await sendSmtpNotification(notif.config as SmtpConfig, enrichedPayload);
} else if (notif.channelType === 'apprise') {
result = await sendAppriseNotification(notif.config as AppriseConfig, enrichedPayload);
}
if (result.success) sent++;
else allSuccess = false;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[Notifications] Failed to send to channel ${notif.channelName}:`, errorMsg);
allSuccess = false;
}
}
return { success: allSuccess, sent };
}
// Send notification for a specific event type (not mapped from Docker action)
// Used for auto-update, git sync, vulnerability, and system events
export async function sendEventNotification(
eventType: NotificationEventType,
payload: NotificationPayload,
environmentId?: number
): Promise<{ success: boolean; sent: number }> {
// Get environment name if provided
let enrichedPayload = { ...payload };
if (environmentId) {
const env = await getEnvironment(environmentId);
if (env) {
enrichedPayload.environmentId = environmentId;
enrichedPayload.environmentName = env.name;
}
}
// Get enabled notification channels for this event type
let channels: Array<{
channel_type: 'smtp' | 'apprise';
channel_name: string;
config: SmtpConfig | AppriseConfig;
}> = [];
if (environmentId) {
// Environment-specific: get channels subscribed to this env and event type
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
channels = envNotifications
.filter(n => n.channelType && n.channelName)
.map(n => ({
channel_type: n.channelType!,
channel_name: n.channelName!,
config: n.config
}));
} else {
// System-wide: get all globally enabled channels that subscribe to this event type
const globalSettings = await getEnabledNotificationSettings();
channels = globalSettings
.filter(s => s.eventTypes?.includes(eventType))
.map(s => ({
channel_type: s.type,
channel_name: s.name,
config: s.config
}));
}
if (channels.length === 0) {
return { success: true, sent: 0 };
}
let sent = 0;
let allSuccess = true;
for (const channel of channels) {
try {
let result: NotificationResult = { success: false };
if (channel.channel_type === 'smtp') {
result = await sendSmtpNotification(channel.config as SmtpConfig, enrichedPayload);
} else if (channel.channel_type === 'apprise') {
result = await sendAppriseNotification(channel.config as AppriseConfig, enrichedPayload);
}
if (result.success) sent++;
else allSuccess = false;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[Notifications] Failed to send to channel ${channel.channel_name}:`, errorMsg);
allSuccess = false;
}
}
return { success: allSuccess, sent };
}
-93
View File
@@ -1,93 +0,0 @@
/**
* Apprise passthrough POST to a self-hosted caronc/apprise-api server.
*
* Users configure all their providers (Signal, Matrix, MQTT, IFTTT, AWS SNS,
* dozens more) in their own Apprise server; Dockhand just forwards each
* notification once. The big win: every provider Apprise upstream supports
* is now reachable from Dockhand without us having to write a sender for it.
*
* Supported formats:
* apprise://host[:port]/key → HTTP, stateful (Apprise stored config key)
* apprises://host[:port]/key → HTTPS variant
* apprise://host[:port]/prefix/key → path-prefixed Apprise behind a reverse proxy
* apprise://host[:port]/key?tag=devops → optional tag filter
*
* Setup docs: https://github.com/caronc/apprise-api
*/
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendApprise(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const isSecure = appriseUrl.startsWith('apprises');
const raw = appriseUrl.replace(/^apprises?:\/\//, '');
let cleanPath = raw;
let queryParams = new URLSearchParams();
const qIndex = raw.indexOf('?');
if (qIndex !== -1) {
queryParams = new URLSearchParams(raw.substring(qIndex + 1));
cleanPath = raw.substring(0, qIndex);
}
const parts = cleanPath.split('/').filter(Boolean);
if (parts.length < 2) {
return { success: false, error: 'Invalid Apprise URL. Expected: apprise://host[:port]/key' };
}
const hostPort = parts[0];
// The Apprise key is the last path segment. Anything between host and key
// is a path prefix (some users mount Apprise behind a reverse proxy
// at /apprise/ — we preserve that).
const key = parts[parts.length - 1];
const pathPrefix = parts.slice(1, -1).join('/');
const baseUrl = `${isSecure ? 'https' : 'http'}://${hostPort}${pathPrefix ? '/' + pathPrefix : ''}`;
// Map our payload type to Apprise's NotifyType. 'error' → 'failure' is
// the only rename; everything else lines up.
const apprisesType = payload.type === 'error'
? 'failure'
: payload.type === 'warning'
? 'warning'
: payload.type === 'success'
? 'success'
: 'info';
const titleWithEnv = payload.environmentName
? `${payload.title} [${payload.environmentName}]`
: payload.title;
const body: Record<string, unknown> = {
title: titleWithEnv,
body: payload.message,
type: apprisesType
};
const tag = queryParams.get('tag');
if (tag) body.tag = tag;
const format = queryParams.get('format');
if (format) body.format = format; // text | markdown | html
try {
const response = await fetch(`${baseUrl}/notify/${encodeURIComponent(key)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
// Apprise-API uses specific status codes:
// 200 → success, 204 → key not configured, 424 → at least one
// downstream provider failed or tag didn't match.
if (response.status === 204) {
return { success: false, error: `Apprise: no configuration found for key "${key}"` };
}
if (response.status === 424) {
const text = await response.text().catch(() => '');
return { success: false, error: `Apprise: at least one downstream provider failed${text ? `${text}` : ''}` };
}
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Apprise error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Apprise connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
-118
View File
@@ -1,118 +0,0 @@
/**
* Bark iOS push via bark-server (https://github.com/Finb/bark-server).
*
* Supported formats:
* bark://device_key → uses official api.day.app over HTTPS
* bark://host/device_key → custom server over HTTP
* bark://host[:port]/k1/k2/... → multi-device batch (Apprise convention)
* barks://host[:port]/... → HTTPS variant
*
* Query params honored (per https://bark.day.app/#/en-us/tutorial):
* ?sound=name, ?level=active|timeSensitive|critical|passive,
* ?group=, ?icon=, ?url=, ?badge=N, ?copy=, ?subtitle=,
* ?volume=, ?ttl=, ?call=1, ?autoCopy=1, ?isArchive=1, ?action=none
*/
import type { NotificationPayload, NotificationResult } from './shared';
export async function sendBark(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const isSecure = appriseUrl.startsWith('barks');
const path = appriseUrl.replace(/^barks?:\/\//, '');
// Split off query string before slicing the path so '?' in a device key
// (in principle possible, though Bark's keys are 22-char base62) doesn't
// confuse the parser.
let cleanPath = path;
let queryParams = new URLSearchParams();
const qIndex = path.indexOf('?');
if (qIndex !== -1) {
queryParams = new URLSearchParams(path.substring(qIndex + 1));
cleanPath = path.substring(0, qIndex);
}
if (!cleanPath) {
return { success: false, error: 'Invalid Bark URL format. Expected: bark://device_key, bark://host/device_key, or barks://host/device_key' };
}
let baseUrl: string;
let deviceKeys: string[];
if (!cleanPath.includes('/')) {
// bark://device_key → official server, HTTPS regardless of bark:// vs barks://
baseUrl = 'https://api.day.app';
deviceKeys = [cleanPath];
} else {
const parts = cleanPath.split('/').filter(Boolean);
if (parts.length < 2) {
return { success: false, error: 'Invalid Bark URL format. Expected: bark://device_key, bark://host/device_key, or barks://host/device_key' };
}
const hostPort = parts[0];
deviceKeys = parts.slice(1);
baseUrl = `${isSecure ? 'https' : 'http'}://${hostPort}`;
}
// Map our payload type to Bark's `level`. Query-supplied level wins.
// info → active (banner + sound, doesn't bypass DND)
// warning → timeSensitive (cuts through Focus modes)
// error → critical (cuts through silent mode; user must enable)
const defaultLevel = payload.type === 'error'
? 'critical'
: payload.type === 'warning'
? 'timeSensitive'
: 'active';
const level = queryParams.get('level') || defaultLevel;
const titleWithEnv = payload.environmentName
? `${payload.title} [${payload.environmentName}]`
: payload.title;
const body: Record<string, unknown> = {
title: titleWithEnv,
body: payload.message,
level
};
// Single-target uses device_key; batch uses device_keys (per Bark API v2).
if (deviceKeys.length === 1) {
body.device_key = deviceKeys[0];
} else {
body.device_keys = deviceKeys;
}
// String passthroughs Bark understands. Unknown params are dropped on the
// server side anyway so no point forwarding them.
const passthroughString = ['sound', 'group', 'icon', 'url', 'copy', 'subtitle', 'category', 'ciphertext', 'isArchive', 'autoCopy', 'call', 'action', 'volume'];
for (const key of passthroughString) {
const v = queryParams.get(key);
if (v !== null && v !== '') body[key] = v;
}
// Numeric passthroughs.
for (const key of ['badge', 'ttl']) {
const v = queryParams.get(key);
if (v !== null && v !== '') {
const n = parseInt(v, 10);
if (!Number.isNaN(n)) body[key] = n;
}
}
try {
const response = await fetch(`${baseUrl}/push`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(body)
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Bark error ${response.status}: ${text || response.statusText}` };
}
// Bark returns HTTP 200 with { code, message, timestamp } — `code !== 200`
// signals a logical failure (e.g. invalid device key) that we'd otherwise
// swallow as a success.
const json: any = await response.json().catch(() => null);
if (json && typeof json.code === 'number' && json.code !== 200) {
return { success: false, error: `Bark error: ${json.message || `code ${json.code}`}` };
}
return { success: true };
} catch (error) {
return { success: false, error: `Bark connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
-34
View File
@@ -1,34 +0,0 @@
/** Discord webhook notifications. discord:// or discords://. */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendDiscord(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// discord://webhook_id/webhook_token or discords://...
const url = appriseUrl.replace(/^discords?:\/\//, 'https://discord.com/api/webhooks/');
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
embeds: [{
title: titleWithEnv,
description: payload.message,
color: payload.type === 'error' ? 0xff0000 : payload.type === 'warning' ? 0xffaa00 : payload.type === 'success' ? 0x00ff00 : 0x0099ff,
...(payload.environmentName && {
footer: { text: `Environment: ${payload.environmentName}` }
})
}]
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Discord error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Discord connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
@@ -1,30 +0,0 @@
/** Generic JSON webhook. json:// or jsons:// (HTTPS). */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendGenericWebhook(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// json://hostname/path or jsons://hostname/path
const url = appriseUrl.replace(/^jsons?:\/\//, appriseUrl.startsWith('jsons') ? 'https://' : 'http://');
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: payload.title,
message: payload.message,
type: payload.type || 'info',
environment: payload.environmentName || null,
timestamp: new Date().toISOString()
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Webhook error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Webhook connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
-34
View File
@@ -1,34 +0,0 @@
/** Gotify. gotify:// or gotifys:// (HTTPS). */
import { buildGotifyUrl } from '$lib/utils/notification-parsers';
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const parsed = buildGotifyUrl(appriseUrl);
if (!parsed) {
return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' };
}
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
const defaultPriority = payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2;
try {
const response = await fetch(parsed.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: titleWithEnv,
message: payload.message,
priority: parsed.priority ?? defaultPriority
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Gotify error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Gotify connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
-314
View File
@@ -1,314 +0,0 @@
/**
* Notification router picks the right per-provider sender based on the
* channel type (SMTP / Apprise URL) and (for Apprise URLs) the URL scheme.
*
* Public surface used by API routes and the rest of the app:
* - sendNotification (fan out to every enabled channel)
* - testNotification (one channel, with a fixed test payload)
* - sendEnvironmentNotification (Docker container event matching channels)
* - sendEventNotification (auto-update / git / vuln / system events)
* - NotificationPayload, NotificationResult types
*
* Per-provider implementations live in sibling files (./bark, ./discord, ).
* This file orchestrates only it never knows what's inside a Bark or
* Telegram URL.
*/
import {
getEnabledNotificationSettings,
getEnabledEnvironmentNotifications,
getEnvironment,
type NotificationSettingData,
type SmtpConfig,
type AppriseConfig,
type NotificationEventType
} from '../db';
import type { NotificationPayload, NotificationResult } from './shared';
export type { NotificationPayload, NotificationResult } from './shared';
import { sendSmtpNotification } from './smtp';
import { sendDiscord } from './discord';
import { sendSlack } from './slack';
import { sendMattermost } from './mattermost';
import { sendTelegram } from './telegram';
import { sendGotify } from './gotify';
import { sendNtfy } from './ntfy';
import { sendBark } from './bark';
import { sendSignal } from './signal';
import { sendApprise } from './apprise';
import { sendPushover } from './pushover';
import { sendGenericWebhook } from './generic-webhook';
import { sendWorkflows } from './workflows';
// Send to every URL in an Apprise channel. Errors are aggregated so a single
// bad URL doesn't silently mask a healthy one.
async function sendAppriseNotification(config: AppriseConfig, payload: NotificationPayload): Promise<NotificationResult> {
const errors: string[] = [];
for (const url of config.urls) {
try {
const result = await sendToAppriseUrl(url, payload);
if (!result.success && result.error) {
errors.push(result.error);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
errors.push(`Failed to send: ${errorMsg}`);
}
}
if (errors.length > 0) {
return { success: false, error: errors.join('; ') };
}
return { success: true };
}
// Route a single Apprise URL to the right sender. The switch is the ONLY
// place that needs to grow when a new provider is added.
async function sendToAppriseUrl(url: string, payload: NotificationPayload): Promise<NotificationResult> {
try {
// Custom schemes like 'tgram://' aren't valid URLs to new URL(),
// so we match the prefix directly.
const protocolMatch = url.match(/^([a-z]+):\/\//i);
if (!protocolMatch) {
return { success: false, error: 'Invalid Apprise URL format - missing protocol' };
}
const protocol = protocolMatch[1].toLowerCase();
switch (protocol) {
case 'discord':
case 'discords':
return await sendDiscord(url, payload);
case 'slack':
case 'slacks':
return await sendSlack(url, payload);
case 'mmost':
case 'mmosts':
return await sendMattermost(url, payload);
case 'tgram':
return await sendTelegram(url, payload);
case 'gotify':
case 'gotifys':
return await sendGotify(url, payload);
case 'ntfy':
case 'ntfys':
return await sendNtfy(url, payload);
case 'bark':
case 'barks':
return await sendBark(url, payload);
case 'signal':
case 'signals':
return await sendSignal(url, payload);
case 'apprise':
case 'apprises':
return await sendApprise(url, payload);
case 'pushover':
return await sendPushover(url, payload);
case 'json':
case 'jsons':
return await sendGenericWebhook(url, payload);
case 'workflows':
return await sendWorkflows(url, payload);
default:
return { success: false, error: `Unsupported Apprise protocol: ${protocol}` };
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return { success: false, error: `Failed to parse Apprise URL: ${errorMsg}` };
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export async function sendNotification(payload: NotificationPayload): Promise<{ success: boolean; results: { name: string; success: boolean }[] }> {
const settings = await getEnabledNotificationSettings();
const results: { name: string; success: boolean }[] = [];
for (const setting of settings) {
let result: NotificationResult = { success: false };
if (setting.type === 'smtp') {
result = await sendSmtpNotification(setting.config as SmtpConfig, payload);
} else if (setting.type === 'apprise') {
result = await sendAppriseNotification(setting.config as AppriseConfig, payload);
}
results.push({ name: setting.name, success: result.success });
}
return {
success: results.every(r => r.success),
results
};
}
export async function testNotification(setting: NotificationSettingData): Promise<NotificationResult> {
const payload: NotificationPayload = {
title: 'Dockhand Test Notification',
message: 'This is a test notification from Dockhand. If you receive this, your notification settings are configured correctly.',
type: 'info'
};
if (setting.type === 'smtp') {
return await sendSmtpNotification(setting.config as SmtpConfig, payload);
} else if (setting.type === 'apprise') {
return await sendAppriseNotification(setting.config as AppriseConfig, payload);
}
return { success: false, error: 'Unknown notification type' };
}
// Map Docker action to notification event type
function mapActionToEventType(action: string): NotificationEventType | null {
const mapping: Record<string, NotificationEventType> = {
'start': 'container_started',
'stop': 'container_stopped',
'restart': 'container_restarted',
'die': 'container_exited',
'kill': 'container_exited',
'oom': 'container_oom',
'health_status: unhealthy': 'container_unhealthy',
'health_status: healthy': 'container_healthy',
'pull': 'image_pulled'
};
return mapping[action] || null;
}
// Scanner image patterns to exclude from notifications
const SCANNER_IMAGE_PATTERNS = [
'anchore/grype',
'aquasec/trivy',
'ghcr.io/anchore/grype',
'ghcr.io/aquasecurity/trivy'
];
function isScannerContainer(image: string | null | undefined): boolean {
if (!image) return false;
const lowerImage = image.toLowerCase();
return SCANNER_IMAGE_PATTERNS.some(pattern => lowerImage.includes(pattern.toLowerCase()));
}
export async function sendEnvironmentNotification(
environmentId: number,
action: string,
payload: Omit<NotificationPayload, 'environmentId' | 'environmentName'>,
image?: string | null
): Promise<{ success: boolean; sent: number }> {
const eventType = mapActionToEventType(action);
if (!eventType) {
return { success: true, sent: 0 };
}
const env = await getEnvironment(environmentId);
if (!env) {
return { success: false, sent: 0 };
}
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
if (envNotifications.length === 0) {
return { success: true, sent: 0 };
}
const enrichedPayload: NotificationPayload = {
...payload,
environmentId,
environmentName: env.name
};
// Skip all notifications for scanner containers (Trivy, Grype)
if (isScannerContainer(image)) {
return { success: true, sent: 0 };
}
let sent = 0;
let allSuccess = true;
for (const notif of envNotifications) {
try {
let result: NotificationResult = { success: false };
if (notif.channelType === 'smtp') {
result = await sendSmtpNotification(notif.config as SmtpConfig, enrichedPayload);
} else if (notif.channelType === 'apprise') {
result = await sendAppriseNotification(notif.config as AppriseConfig, enrichedPayload);
}
if (result.success) sent++;
else allSuccess = false;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[Notifications] Failed to send to channel ${notif.channelName}:`, errorMsg);
allSuccess = false;
}
}
return { success: allSuccess, sent };
}
export async function sendEventNotification(
eventType: NotificationEventType,
payload: NotificationPayload,
environmentId?: number
): Promise<{ success: boolean; sent: number }> {
let enrichedPayload = { ...payload };
if (environmentId) {
const env = await getEnvironment(environmentId);
if (env) {
enrichedPayload.environmentId = environmentId;
enrichedPayload.environmentName = env.name;
}
}
let channels: Array<{
channel_type: 'smtp' | 'apprise';
channel_name: string;
config: SmtpConfig | AppriseConfig;
}> = [];
if (environmentId) {
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
channels = envNotifications
.filter(n => n.channelType && n.channelName)
.map(n => ({
channel_type: n.channelType!,
channel_name: n.channelName!,
config: n.config
}));
} else {
const globalSettings = await getEnabledNotificationSettings();
channels = globalSettings
.filter(s => s.eventTypes?.includes(eventType))
.map(s => ({
channel_type: s.type,
channel_name: s.name,
config: s.config
}));
}
if (channels.length === 0) {
return { success: true, sent: 0 };
}
let sent = 0;
let allSuccess = true;
for (const channel of channels) {
try {
let result: NotificationResult = { success: false };
if (channel.channel_type === 'smtp') {
result = await sendSmtpNotification(channel.config as SmtpConfig, enrichedPayload);
} else if (channel.channel_type === 'apprise') {
result = await sendAppriseNotification(channel.config as AppriseConfig, enrichedPayload);
}
if (result.success) sent++;
else allSuccess = false;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[Notifications] Failed to send to channel ${channel.channel_name}:`, errorMsg);
allSuccess = false;
}
}
return { success: allSuccess, sent };
}
@@ -1,55 +0,0 @@
/** Mattermost incoming webhook. mmost:// or mmosts:// (HTTPS). */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendMattermost(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// mmost://[botname@]hostname[:port][/path]/token or mmosts://...
const isSecure = appriseUrl.startsWith('mmosts');
const protocol = isSecure ? 'https' : 'http';
let urlPart = appriseUrl.replace(/^mmosts?:\/\//, '');
// Check for botname (username@hostname format)
let username: string | undefined;
const atIndex = urlPart.indexOf('@');
if (atIndex !== -1) {
username = urlPart.substring(0, atIndex);
urlPart = urlPart.substring(atIndex + 1);
}
// The token is the last segment, everything else is hostname[:port][/path]
const lastSlashIndex = urlPart.lastIndexOf('/');
if (lastSlashIndex === -1) {
return { success: false, error: 'Invalid Mattermost URL format. Expected: mmost://[botname@]hostname[:port][/path]/token' };
}
const token = urlPart.substring(lastSlashIndex + 1);
const hostAndPath = urlPart.substring(0, lastSlashIndex);
const url = `${protocol}://${hostAndPath}/hooks/${token}`;
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
const body: Record<string, string> = {
text: `*${payload.title}*${envTag}\n${payload.message}`
};
if (username) {
body.username = username;
}
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Mattermost error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Mattermost connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
-88
View File
@@ -1,88 +0,0 @@
/** ntfy.sh + self-hosted ntfy. ntfy:// or ntfys:// (HTTPS). */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// Supported formats:
// ntfy://topic (public ntfy.sh)
// ntfy://host/topic (custom server, no auth)
// ntfy://user:pass@host/topic (custom server with basic auth)
// ntfy://token@host/topic (custom server with bearer token)
// ntfy://host/topic?auth=BASE64 (custom server with base64-encoded bearer token)
// Query params: ?tags=ship,whale &title=Custom &priority=5
// ntfys:// variants for HTTPS
const isSecure = appriseUrl.startsWith('ntfys');
const path = appriseUrl.replace(/^ntfys?:\/\//, '');
let url: string;
let authHeader: string | null = null;
let queryAuth: string | null = null;
let queryTags: string | null = null;
let queryTitle: string | null = null;
let queryPriority: string | null = null;
let cleanPath = path;
const qIndex = path.indexOf('?');
if (qIndex !== -1) {
const params = new URLSearchParams(path.substring(qIndex + 1));
queryAuth = params.get('auth');
queryTags = params.get('tags');
queryTitle = params.get('title');
queryPriority = params.get('priority');
cleanPath = path.substring(0, qIndex);
}
const basicMatch = cleanPath.match(/^([^:]+):([^@]+)@(.+)$/);
if (basicMatch) {
const [, user, pass, hostAndTopic] = basicMatch;
const basic = Buffer.from(`${user}:${pass}`).toString('base64');
authHeader = `Basic ${basic}`;
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
} else if (cleanPath.includes('@') && cleanPath.includes('/')) {
const tokenMatch = cleanPath.match(/^([^@]+)@(.+)$/);
if (tokenMatch) {
const [, token, hostAndTopic] = tokenMatch;
authHeader = `Bearer ${token}`;
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
} else {
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
}
} else if (cleanPath.includes('/')) {
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
} else {
url = `https://ntfy.sh/${cleanPath}`;
}
if (!authHeader && queryAuth) {
const decoded = Buffer.from(queryAuth, 'base64').toString();
authHeader = decoded.startsWith('Bearer ') ? decoded : `Bearer ${decoded}`;
}
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
const defaultTags = payload.type || 'info';
const headers: Record<string, string> = {
'Title': queryTitle || titleWithEnv,
'Priority': queryPriority || (payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3'),
'Tags': queryTags ? `${queryTags},${defaultTags}` : defaultTags
};
if (authHeader) {
headers['Authorization'] = authHeader;
}
try {
const response = await fetch(url, {
method: 'POST',
headers,
body: payload.message
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `ntfy error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `ntfy connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}

Some files were not shown because too many files have changed in this diff Show More