Compare commits

..

161 Commits

Author SHA1 Message Date
jarek efb634701c 1.0.34 2026-06-17 08:21:38 +02:00
Pascal GUINET aa45be6844 SYS-11240 Address review feedback on Harbor catalog/search fallback
- isHarborRegistry: derive scheme and port from the registry URL via
  parseRegistryUrl instead of hardcoding https:// so HTTP-only mirrors and
  non-443 ports are detected (same scheme handling as getRegistryAuthHeader).
- isHarborRegistry: only cache the detection result when a definitive answer
  was obtained; a transient network error no longer pins "not Harbor" for the
  whole TTL and keeps returning _catalog 403s after Harbor recovers.
- getHarborBasicAuth: trim username/password (pasted credentials with trailing
  whitespace silently broke Basic auth).
- harborListRepositories: implement real cross-project pagination for the
  no-orgPath case (enumerate every project and all repos, paginate the
  flattened list) so >100 projects / >100 repos no longer truncate silently
  with a hardcoded hasMore=false.
- harborSearchRepositories: sanitize the search term against Harbor's query
  grammar (, = ~ ( )) so special characters can't break or alter the q filter;
  paginate the project list as well.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:08:46 +02:00
Pascal GUINET 0eaf52fa66 SYS-11240 Translate all code comments from French to English
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-16 09:08:46 +02:00
Pascal GUINET eb5cf32d68 Add Harbor fallback for catalog and search endpoints
Harbor denies access to the V2 _catalog endpoint for robot accounts
(scope registry:catalog:* returns an empty JWT). This adds automatic
Harbor detection and falls back to the native Harbor project API
(/api/v2.0/projects/{name}/repositories) for both catalog listing
and image search.

- Detect Harbor via WWW-Authenticate header + /api/v2.0/ping (cached 5min)
- List repositories through Harbor project API with pagination
- Search repositories using Harbor's q=name=~ filter
- Transparent fallback: no configuration change required

Fixes #360

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-16 09:08:46 +02:00
jarek 83c3a5ea09 1.0.33 2026-06-15 14:56:51 +02:00
Jarek Krochmalski d465ecfe96 readme and screenshots 2026-06-08 18:08:42 +02:00
David Weston fd9b18ea31 Explain choice of default value 2026-06-07 20:03:34 +02:00
David Weston 89505713f1 Manually adjust snapshots 2026-06-07 20:03:34 +02:00
David Weston 085f03c178 Revert default value 2026-06-07 20:03:34 +02:00
David Weston c06d794b92 Update migration snapshot 2026-06-07 20:03:34 +02:00
undirectlookable b7a8cca387 feat(notifications): add Bark push support
Add Bark as a supported Apprise notification protocol.

Supported URL formats:
- bark://bark_key uses the official Bark server at https://api.day.app/
- bark://host/bark_key uses a custom Bark server over HTTP
- barks://host/bark_key uses a custom Bark server over HTTPS

Bark notifications are sent with POST JSON payloads containing the device key, title, and body. The notification settings modal now
lists Bark examples in the Apprise URL placeholder and support text.
2026-06-06 17:04:59 +02:00
jarek 3cbcfa3cdb 1.0.32 2026-06-06 16:18:05 +02:00
jarek 00bd09df55 1.0.31 2026-05-30 12:23:36 +02:00
jarek c8b3acc07e 1.0.30 2026-05-30 08:42:21 +02:00
jarek e7100f8926 1.0.29 2026-05-17 08:02:31 +02:00
jarek d9054ff347 1.0.28 2026-05-09 10:13:53 +02:00
jarek 002d969a5d 1.0.28 2026-05-09 09:55:18 +02:00
Juha Kovanen 91ef3e3c9b fix: prevent WebSocket connection drops on hawser handler errors
Wrap globalThis.__hawserHandleMessage in try-catch to prevent unhandled
promise rejections from closing WebSocket connections abruptly.

Previously, if the async handler threw an error, the WebSocket would close
with code 1006 (abnormal closure) without proper error logging. This caused
connections to die after 1-2 seconds, triggering rapid reconnection storms
and preventing stats from being retrieved.

The inner try-catch ensures handler errors are logged but don't close the
connection, allowing the agent to recover and continue processing messages.
2026-05-05 13:12:50 +02:00
jarek 7e3797cbfe 1.0.27 2026-04-26 08:01:32 +02:00
Ivan Kara ccfda4c054 Adjust uploaded files permission 2026-04-19 18:56:01 +02:00
Sebastiaan Lokhorst 28a6211457 Add Apprise workflows:// notification format
For sending messages to e.g. Microsoft Teams
2026-04-19 16:44:41 +02:00
Dennis Braun 7c123833b5 fix: avoid duplicate volume binds during recreate 2026-04-19 16:26:56 +02:00
YewFence a1def17750 chore: delete the unnecessary functions called 2026-04-19 16:22:01 +02:00
YewFence 94657735fb feat: mirror Dockhand's ExtraHosts into scanner and self-update sidecar containers
Add `extraHosts` option to `runContainer` and `runContainerWithStreaming` so arbitrary `HostConfig.ExtraHosts` entries can be passed when spawning containers.

Expose `getOwnExtraHosts()` from `host-path.ts` and forward the cached entries into scanner and self-updater containers, ensuring custom host aliases (e.g. internal registry hostnames) are available inside those sidecars without additional user configuration.
2026-04-19 16:22:01 +02:00
Penlane 74741d2a01 fix: improve canvas resize 2026-04-19 16:18:48 +02:00
Penlane 94591fef48 feat: include NetworkGraph in Networks page 2026-04-19 16:18:48 +02:00
Penlane 44b06e8fc6 feat: add NetworkGraphModal 2026-04-19 16:18:48 +02:00
Penlane e35d485ae9 feat: add NetworkGraphViewer 2026-04-19 16:18:48 +02:00
FlyingT f27c0b066f Update README.md 2026-04-19 16:08:18 +02:00
FlyingT 4840ac024d Add files via upload 2026-04-19 16:08:18 +02:00
FlyingT d3aacfa94b Add files via upload 2026-04-19 16:08:18 +02:00
GiulioSavini 8671dfaf32 fix: allow 6-field cron expressions with seconds
The cron editor rejected sub-minute expressions like `*/30 * * * * *`
because validation required exactly 5 fields. Now accepts both 5-field
(standard) and 6-field (with seconds) cron expressions.

Also fixes schedule type auto-detection to correctly fall back to
'custom' for 6-field expressions instead of misinterpreting field
positions.

Fixes #819
2026-04-19 16:01:50 +02:00
Tim Huge d10f6dfd6d Fix: Remove Telegram link preview 2026-04-19 15:57:52 +02:00
jarek 22e0429094 V1.0.26 2026-04-19 13:06:27 +02:00
jarek 1a34f73ae3 port ranges also in stacks display 2026-04-19 10:28:47 +02:00
jarek aaaf252d4c Bearer token authentication fails with enterprise license active 2026-04-19 10:12:59 +02:00
jarek 1bf5dec60f persist sort order across page navigation for all data grids (#861, #912) 2026-04-19 09:48:59 +02:00
jarek d7a458f158 show git repository URL and branch in git stack edit modal (#856) 2026-04-19 09:40:33 +02:00
jarek a7990e2167 persist sort order across page navigation for all data grids (#861, #912) 2026-04-19 09:40:20 +02:00
jarek 8bb95d0a1b MFA code field not recognized by Bitwarden and other password managers (#566) 2026-04-19 09:40:01 +02:00
jarek b8f06426e3 option to delete associated volumes when removing a stack (#655) 2026-04-19 09:39:42 +02:00
jarek 0af1ee6eb2 Gotify, ntfy, Pushover, and webhook notifications missing environment name (#943) 2026-04-19 09:39:03 +02:00
jarek ac19a67cce Bearer token authentication fails with enterprise license active 2026-04-19 09:35:32 +02:00
jarek 380fcc34ec scheduled image prune notifications missing environment name (#770) 2026-04-19 09:31:38 +02:00
jarek 32e44c746b MFA code field not recognized by Bitwarden and other password managers (#566) 2026-04-19 09:31:20 +02:00
jarek 84e0a0bf14 collapse consecutive port mappings into ranges in container list (#821) 2026-04-19 09:30:42 +02:00
jarek c44f244b1d v1.0.25 2026-04-18 08:40:17 +02:00
jarek afee09866d v1.0.25 2026-04-18 08:34:20 +02:00
yokkkoso c210ef0a8e feat: support telegram topics in supergroups 2026-04-08 06:12:03 +02:00
jarek 7fe4b25563 Dockerfile for baseline builds 2026-04-05 06:39:53 +02:00
jarek 7f26c0a585 v1.0.24 2026-04-03 18:32:46 +02:00
jarek 2027c9d44c v1.0.23 2026-04-03 13:53:12 +02:00
jarek 0bb10cabb9 v1.0.23 2026-04-03 11:51:42 +02:00
jarek e9a9f0ca25 shims for the baseline build 2026-03-26 08:23:12 +01:00
jarek 17dafec9de v1.0.22 2026-03-21 14:46:17 +01:00
jarek b55e1e5aad v1.0.22 2026-03-21 14:29:30 +01:00
Jarek Krochmalski aefa5e7925 Update SECURITY.md 2026-03-15 09:37:22 +01:00
Jarek Krochmalski 63c576e059 Create SECURITY.md 2026-03-15 06:13:45 +01:00
Dennis Braun a6016afdaa 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 0b3658793a v1.0.21 2026-03-13 09:29:02 +01:00
jarek 05d771d9ba v1.0.21 2026-03-13 08:31:38 +01:00
jarek 55f3101a19 v1.0.21 2026-03-13 08:22:46 +01:00
Jarek Krochmalski 790ce092ee 1.0.20 2026-03-06 20:03:10 +01:00
Jarek Krochmalski 7729d7e326 1.0.20 2026-03-03 13:02:00 +01:00
Jarek Krochmalski bcd10c1407 1.0.20 2026-03-03 12:18:17 +01:00
Jarek Krochmalski a04040e1e9 1.0.20 2026-03-03 10:29:01 +01:00
Jarek Krochmalski c26fa2d10f 1.0.20 2026-03-03 10:17:41 +01:00
Matt Boris d51bfb0d60 fix: cap docker API version (fixes #679) 2026-03-03 07:12:56 +01:00
jarek 5527d19198 v1.0.20 2026-03-02 13:10:03 +01:00
jarek 2829e7c0e9 v1.0.20 2026-03-02 10:54:30 +01:00
jarek 1066ce9eb1 v1.0.20 2026-03-02 10:41:42 +01:00
jarek bc00bbfe5c v1.0.19 2026-03-02 09:12:33 +01:00
jarek 9c451aedf9 v1.0.19 2026-03-02 07:59:58 +01:00
jarek 4b430340db 1.0.18 2026-02-16 16:19:55 +01:00
jarek 0372737f3d 1.0.18 2026-02-16 15:43:05 +01:00
jarek 33bdc39b49 1.0.18 2026-02-16 13:37:19 +01:00
jarek 1baedd134d 1.0.18 2026-02-16 13:17:09 +01:00
jarek ae3aea2296 1.0.18 2026-02-16 12:59:42 +01:00
jarek 3a7b856047 1.0.18 2026-02-16 12:46:28 +01:00
jarek ae42baa67c 1.0.18 2026-02-16 09:08:12 +01:00
jarek 83a5a557b0 1.0.18 2026-02-16 09:05:51 +01:00
jarek c43bdbcee6 1.0.18 2026-02-16 08:53:25 +01:00
jarek f8dcb84c41 1.0.18 2026-02-16 08:47:29 +01:00
jarek 8ee4fe4d68 1.0.18 2026-02-16 08:46:56 +01:00
jarek d83ca684d7 cleaner logos 2026-02-16 08:16:51 +01:00
jarek e5becfd87f 1.0.18 updater 2026-02-16 08:16:37 +01:00
Aaron Bird d12196f53a 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
2026-02-13 09:35:39 +01:00
Florian Hoss ef26d38fce 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 133c9f1e8f 1.0.17 2026-02-09 20:50:41 +01:00
jarek cb8be12f1a 1.0.16 2026-02-09 14:48:48 +01:00
jarek 48b9bde8ae 1.0.16 2026-02-09 10:15:21 +01:00
jarek 1cb47eaa9c 1.0.15 2026-02-08 10:27:56 +01:00
jarek 265bbc65df 1.0.15 2026-02-08 10:21:18 +01:00
jarek 188ba1967d 1.0.15 2026-02-08 09:59:06 +01:00
jarek 9d2266dffe release job 2026-02-07 09:59:30 +01:00
Jarek Krochmalski 4ab6abf924 Delete static/logo_dark.webp 2026-02-06 17:06:27 +01:00
Jarek Krochmalski af9cb55729 Delete static/logo_light.webp 2026-02-06 17:06:10 +01:00
Jarek Krochmalski d7a553cd8d Delete static/logo.png 2026-02-06 17:05:46 +01:00
TimElschner e5fec4df71 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 071571eca9 chore(gitignore): add local dev auth files 2026-02-06 16:48:29 +01:00
Matt Boris 1229ecc1d9 chore(login): autofocus on the username field 2026-02-06 16:48:29 +01:00
Matt Boris 03992ae227 chore(mfa): autofocus on the mfa code field on login 2026-02-06 16:48:29 +01:00
Jarek Krochmalski 48e9a3f5ec Update bug-report.yml 2026-02-06 08:13:26 +01:00
Jarek Krochmalski d7eaa5ef70 Update bug-report.yml 2026-02-06 08:10:08 +01:00
Jarek Krochmalski 5b1b7ecb71 Update bug-report.yml 2026-02-06 08:09:38 +01:00
Jarek Krochmalski de1cad422e Update bug-report.yml 2026-02-06 08:08:35 +01:00
Jarek Krochmalski 86448e5b20 Update bug-report.yml 2026-02-06 08:07:32 +01:00
shamoon 8be07ea8dc Add bug report, FR templates, config 2026-02-06 08:04:44 +01:00
shamoon ffde535390 Add basic PR template 2026-02-06 08:04:44 +01:00
shamoon cf0e9ab50d Add basic CONTRIBUTING.md 2026-02-06 08:04:44 +01:00
shamoon 95f263c3a6 Ignore node_modules, .svelte-kit, and bun.lock 2026-02-06 08:04:44 +01:00
shamoon 83063d757a Only show on update 2026-02-03 09:25:01 +01:00
shamoon 6b49d13236 Add option to pull image before container update 2026-02-03 09:25:01 +01:00
jarek 610548ed66 1.0.14 2026-01-31 09:35:19 +01:00
jarek 8f3a7eb435 1.0.13 2026-01-28 07:34:12 +01:00
Jarek Krochmalski a88d3d5788 Update README.md 2026-01-24 06:13:41 +01:00
Jarek Krochmalski ac84b20bb0 Update README.md 2026-01-24 06:03:27 +01:00
jarek 0b62c5e3bd #96 2026-01-23 14:39:16 +01:00
Viktoras 241b04247e Honor DATA_DIR env var in sqlite operations related to hawser connections 2026-01-23 14:29:24 +01:00
jarek bbdb9841fd 1.0.12 2026-01-22 16:46:42 +01:00
jarek 1d1e85f1fa 1.0.12 2026-01-22 16:46:17 +01:00
jarek 86a06d9de0 1.0.12 2026-01-22 16:23:26 +01:00
jarek a3cc26d958 1.0.11 2026-01-20 15:39:08 +01:00
FlintyLemming fe48d63164 feat: add SYS_RAWIO to container capabilities list 2026-01-19 19:01:51 +01:00
Jarek Krochmalski 21aa4a9854 Create ai-opt-out 2026-01-19 13:00:00 +01:00
Jarek Krochmalski 27baab1a86 Update README.md 2026-01-19 12:50:10 +01:00
Jarek Krochmalski 33a7add751 Update README.md 2026-01-19 12:48:48 +01:00
Jarek Krochmalski 7abda79214 Update README.md 2026-01-19 12:44:05 +01:00
Jarek Krochmalski 9905b17f3d Update package.json 2026-01-19 08:16:41 +01:00
jarek 6483cea6c6 1.0.10 2026-01-18 09:56:38 +01:00
jarek c185d00dc3 1.0.9 2026-01-17 15:06:14 +01:00
jarek 62636426bf 1.0.8 2026-01-14 08:18:20 +01:00
sieren 027aee434c 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 f2657a3d4d 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 851e56bc57 1.0.7 2026-01-11 09:01:42 +01:00
Jarek Krochmalski c15355e159 1.0.7 2026-01-11 07:17:25 +01:00
Jarek Krochmalski 7643807717 1.0.7 2026-01-11 07:16:18 +01:00
jarek bd7b832394 1.0.6 2026-01-03 14:56:20 +01:00
jarek 66e723052d missing scripts 2026-01-03 13:21:38 +01:00
jarek 80c000c601 1.0.5 2026-01-03 09:10:38 +01:00
jarek f2102003e3 1.0.5 2026-01-02 15:39:51 +01:00
jarek a1e07b1a10 1.0.5 2026-01-02 15:29:56 +01:00
Jarek Krochmalski b89470e965 Update README.md 2026-01-02 13:38:16 +01:00
Jarek Krochmalski 942c8d440b Update README.md 2026-01-02 13:36:32 +01:00
jarek 607d340b71 1.0.5 2026-01-02 12:24:43 +01:00
jarek 659d074d00 1.0.5 2026-01-01 16:32:08 +01:00
jarek 07a5f03aa9 1.0.5 2026-01-01 16:05:10 +01:00
jarek 242f8df49d 1.0.5 2026-01-01 16:00:34 +01:00
jarek 5475112806 compose example 2025-12-29 15:29:33 +01:00
Jarek Krochmalski db9981f2b0 bmac 2025-12-29 13:46:13 +01:00
Jarek Krochmalski c7b9ae7243 bmac 2025-12-29 13:39:36 +01:00
Jarek Krochmalski a0bc234c8a Update README.md 2025-12-29 13:37:26 +01:00
Jarek Krochmalski 0ef9982aff Update LICENSE.txt 2025-12-29 09:27:27 +01:00
jarek 5194b3a993 ignore 2025-12-29 09:08:29 +01:00
jarek 62ab0a3065 drizzle config 2025-12-29 09:08:06 +01:00
Jarek Krochmalski 9c85535a9b cleanup .DS_Store 2025-12-29 08:55:55 +01:00
jarek 9bf4b74e2e proper src structure, dockerfile, entrypoint 2025-12-29 08:40:56 +01:00
jarek 73c9f580a1 proper src structure, dockerfile, entrypoint 2025-12-29 08:40:11 +01:00
Jarek Krochmalski e5828c7d31 Update README.md 2025-12-29 06:47:46 +01:00
Jarek Krochmalski 8afdea8795 Update README.md 2025-12-29 06:47:22 +01:00
Jarek Krochmalski ba8d6ce068 Update README.md 2025-12-28 21:40:06 +01:00
404 changed files with 61819 additions and 9861 deletions
+53 -67
View File
@@ -1,30 +1,21 @@
# syntax=docker/dockerfile:1.4
# =============================================================================
# Dockhand Docker Image - Security-Hardened Build
# Dockhand Docker Image - Node.js Runtime (Security-Hardened Build)
# =============================================================================
# This Dockerfile builds a custom Wolfi OS from scratch using apko, ensuring:
# - Full transparency (no dependency on pre-built Chainguard images)
# - Reproducible builds from open-source Wolfi packages
# - Minimal attack surface with only required packages
#
# Bun is copied from the official oven/bun image (app-builder stage).
# For CPUs without AVX support (Celeron, Atom, pre-Haswell), build with:
# docker build --build-arg BUN_VARIANT=baseline -t dockhand:baseline .
# Uses Node.js instead of Bun to eliminate BoringSSL native memory leaks
# on mTLS connections. Same Wolfi-based security-hardened OS.
# =============================================================================
# -----------------------------------------------------------------------------
# Stage 1: OS Generator (Alpine + apko tool)
# -----------------------------------------------------------------------------
# We use Alpine because it has a shell. This lets us download and run apko
# to build our custom Wolfi OS from scratch using open-source packages.
FROM alpine:3.21 AS os-builder
ARG TARGETARCH
WORKDIR /work
# Install apko tool (latest stable release)
# apko is the tool Chainguard uses to build their images - we use it directly
# Install apko tool
ARG APKO_VERSION=0.30.34
RUN apk add --no-cache curl unzip \
&& ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "arm64" || echo "amd64") \
@@ -32,9 +23,7 @@ RUN apk add --no-cache curl unzip \
| tar -xz --strip-components=1 -C /usr/local/bin \
&& chmod +x /usr/local/bin/apko
# Generate apko.yaml for current target architecture only
# We build single-arch to avoid multi-arch layer confusion in extraction
# Note: Bun is NOT included here - it's copied from app-builder stage for CPU compatibility
# Generate apko.yaml — Node.js binary comes from node:24-slim, not Wolfi
RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") \
&& printf '%s\n' \
"contents:" \
@@ -48,15 +37,18 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
" - busybox" \
" - tzdata" \
" - docker-cli" \
" - docker-compose" \
" - docker-compose=5.1.4-r5" \
" - docker-cli-buildx" \
" - sqlite" \
" - postgresql-client" \
" - git" \
" - openssh-client" \
" - openssh-keygen" \
" - curl" \
" - tini" \
" - su-exec" \
" - glibc" \
" - libstdc++" \
"entrypoint:" \
" command: /bin/sh -l" \
"archs:" \
@@ -64,7 +56,6 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
> apko.yaml
# Build the OS tarball and extract rootfs
# apko creates an OCI tarball - we need to extract the actual filesystem layer
RUN apko build apko.yaml dockhand-base:latest output.tar \
&& mkdir -p rootfs \
&& tar -xf output.tar \
@@ -72,63 +63,56 @@ RUN apko build apko.yaml dockhand-base:latest output.tar \
&& tar -xzf "$LAYER" -C rootfs
# -----------------------------------------------------------------------------
# Stage 2: Application Builder
# Stage 2: Application Builder (pure Node.js)
# -----------------------------------------------------------------------------
# Using Debian to avoid Alpine musl thread creation issues
# Alpine's musl libc causes rayon/tokio thread pool panics during svelte-adapter-bun build
FROM oven/bun:1.3.5-debian AS app-builder
# Build argument for Bun variant (regular or baseline)
# baseline is for CPUs without AVX support (Celeron, Atom, pre-Haswell)
ARG BUN_VARIANT=regular
ARG TARGETARCH
FROM --platform=$TARGETPLATFORM node:24-slim AS app-builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends jq git curl unzip ca-certificates && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends \
jq git curl python3 make g++ libnss-wrapper \
&& rm -rf /var/lib/apt/lists/* \
&& cp "$(dpkg -L libnss-wrapper | grep 'libnss_wrapper\.so$')" /usr/local/lib/libnss_wrapper.so
# Copy package files and install ALL dependencies (needed for build)
COPY package.json bun.lock* bunfig.toml ./
RUN bun install --frozen-lockfile
# 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
# Copy source code and build
COPY . .
RUN npm run build
# Build with parallelism - dedicated build VM has 16 CPUs and 32GB RAM
RUN NODE_OPTIONS="--max-old-space-size=8192 --max-semi-space-size=128" bun 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
# Prepare production node_modules (do this in builder where we have compilers)
# This ensures native addons compile correctly before copying to hardened runtime
RUN rm -rf node_modules && bun install --production --frozen-lockfile \
&& rm -rf node_modules/@types node_modules/bun-types
# Download baseline Bun binary if BUN_VARIANT=baseline (for CPUs without AVX)
# Only applies to amd64 - ARM64 doesn't have AVX concept
ARG BUN_VERSION=1.3.5
RUN if [ "$BUN_VARIANT" = "baseline" ] && [ "$TARGETARCH" = "amd64" ]; then \
echo "Downloading Bun baseline binary for CPUs without AVX support..." && \
curl -fsSL "https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-x64-baseline.zip" -o /tmp/bun.zip && \
unzip -o /tmp/bun.zip -d /tmp && \
cp /tmp/bun-linux-x64-baseline/bun /usr/local/bin/bun && \
chmod +x /usr/local/bin/bun && \
rm -rf /tmp/bun.zip /tmp/bun-linux-x64-baseline && \
echo "Bun baseline binary installed successfully"; \
fi
# Build Go collector
FROM --platform=$BUILDPLATFORM golang:1.25.11 AS go-builder
ARG TARGETARCH
WORKDIR /app
COPY collector/ ./collector/
RUN cd collector && CGO_ENABLED=0 GOARCH=$TARGETARCH go build -o /app/bin/collection-worker .
# -----------------------------------------------------------------------------
# Stage 3: Final Image (Scratch + Custom Wolfi OS)
# -----------------------------------------------------------------------------
FROM scratch
# Install our custom-built Wolfi OS (now we have /bin/sh!)
# Install custom Wolfi OS with Node.js
COPY --from=os-builder /work/rootfs/ /
# Copy Bun from official image - ensures compatibility with all x86_64 CPUs (no AVX2 requirement)
# Wolfi's bun package requires AVX2 which breaks on Celeron/Atom CPUs
# For baseline builds (BUN_VARIANT=baseline), this contains the baseline binary (no AVX requirement)
# For regular builds, this contains the standard oven/bun binary
COPY --from=app-builder /usr/local/bin/bun /usr/bin/bun
# Copy Node.js binary from official node:24-slim (platform-correct, conservative CPU baseline)
# Wolfi's nodejs-24 targets ARMv8.1+ which causes SIGILL on Cortex-A53 (Raspberry Pi 3+)
COPY --from=app-builder /usr/local/bin/node /usr/local/bin/node
# Copy libnss_wrapper for git SSH with arbitrary UIDs
COPY --from=app-builder /usr/local/lib/libnss_wrapper.so /usr/lib/libnss_wrapper.so
WORKDIR /app
@@ -143,20 +127,22 @@ ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
PUID=1001 \
PGID=1001
# Create docker compose plugin symlink (we use `docker compose` syntax, Wolfi has standalone binary)
# Note: docker-cli-buildx package already creates the buildx symlink
# Create docker compose plugin symlink
RUN mkdir -p /usr/libexec/docker/cli-plugins \
&& ln -s /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 (using busybox commands)
# Create dockhand user and group
RUN addgroup -g 1001 dockhand \
&& adduser -u 1001 -G dockhand -h /home/dockhand -D dockhand
# Copy application files with correct ownership (avoids layer duplication from chown -R)
# Copy application files with correct ownership
COPY --from=app-builder --chown=dockhand:dockhand /app/node_modules ./node_modules
COPY --from=app-builder --chown=dockhand:dockhand /app/package.json ./
COPY --from=app-builder --chown=dockhand:dockhand /app/build ./build
COPY --from=app-builder --chown=dockhand:dockhand /app/build/subprocesses/ ./subprocesses/
COPY --from=app-builder --chown=dockhand:dockhand /app/server.js ./
# Copy Go collector binary
COPY --from=go-builder --chown=dockhand:dockhand /app/bin/collection-worker ./bin/collection-worker
# Copy database migrations
COPY --chown=dockhand:dockhand drizzle/ ./drizzle/
@@ -165,22 +151,22 @@ COPY --chown=dockhand:dockhand drizzle-pg/ ./drizzle-pg/
# Copy legal documents
COPY --chown=dockhand:dockhand LICENSE.txt PRIVACY.txt ./
# Copy entrypoint script (root-owned, executable)
COPY docker-entrypoint.sh /usr/local/bin/
# Copy entrypoint script
COPY docker-entrypoint-node.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Copy emergency scripts
COPY --chown=dockhand:dockhand scripts/emergency/ ./scripts/
RUN chmod +x ./scripts/*.sh ./scripts/**/*.sh 2>/dev/null || true
# Create data directories with correct ownership
# Create data directories
RUN mkdir -p /home/dockhand/.dockhand/stacks /app/data \
&& chown dockhand:dockhand /app/data /home/dockhand /home/dockhand/.dockhand /home/dockhand/.dockhand/stacks
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000/ || exit 1
CMD curl -f http://localhost:${PORT:-3000}/ || exit 1
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
CMD ["bun", "run", "./build/index.js"]
CMD []
+132
View File
@@ -0,0 +1,132 @@
# syntax=docker/dockerfile:1.4
# =============================================================================
# Dockhand Docker Image - Baseline Build (Alpine/musl, amd64 only)
# =============================================================================
# For older x86_64 hardware without AVX2/SSE4.2 (TrueNAS, older Intel Atom/Celeron)
# Uses node:24-alpine (musl libc) compiled conservatively for all x86_64 CPUs.
# The Wolfi/glibc build crashes with SIGILL on CPUs that don't support the
# microarchitecture level Wolfi packages are compiled for.
# =============================================================================
# -----------------------------------------------------------------------------
# Stage 1: Application Builder (Alpine - musl-compatible native addons)
# -----------------------------------------------------------------------------
# IMPORTANT: Must use alpine builder so native addons (better-sqlite3) are
# compiled against musl libc, not glibc. Cross-ABI copies would not work.
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
# 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.json package-lock.json ./
RUN npm ci --ignore-scripts \
&& npm rebuild better-sqlite3 argon2
# 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
# -----------------------------------------------------------------------------
# Stage 2: Go Collector Builder
# -----------------------------------------------------------------------------
FROM golang:1.25.8 AS go-builder
WORKDIR /app
COPY collector/ ./collector/
RUN cd collector && CGO_ENABLED=0 go build -o /app/bin/collection-worker .
# -----------------------------------------------------------------------------
# Stage 3: Final Image (Alpine-based runtime)
# -----------------------------------------------------------------------------
FROM node:24-alpine
# Install runtime packages
RUN apk add --no-cache \
ca-certificates \
tzdata \
docker-cli \
docker-compose \
docker-cli-buildx \
sqlite \
postgresql-client \
git \
openssh \
curl \
tini \
su-exec \
libstdc++
# Create docker compose plugin symlink (skip if package already installed it there)
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
# Create dockhand user and group
RUN addgroup -g 1001 dockhand \
&& adduser -u 1001 -G dockhand -h /home/dockhand -D dockhand
WORKDIR /app
# Set up environment variables
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
NODE_ENV=production \
PORT=3000 \
HOST=0.0.0.0 \
DATA_DIR=/app/data \
HOME=/home/dockhand \
PUID=1001 \
PGID=1001 \
LD_PRELOAD=/usr/lib/libgetrandom-shim.so
# Copy application files with correct ownership
COPY --from=app-builder --chown=dockhand:dockhand /app/node_modules ./node_modules
COPY --from=app-builder --chown=dockhand:dockhand /app/package.json ./
COPY --from=app-builder --chown=dockhand:dockhand /app/build ./build
COPY --from=app-builder --chown=dockhand:dockhand /app/server.js ./
# Copy Go collector binary
COPY --from=go-builder --chown=dockhand:dockhand /app/bin/collection-worker ./bin/collection-worker
# Copy database migrations
COPY --chown=dockhand:dockhand drizzle/ ./drizzle/
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
# Copy emergency scripts
COPY --chown=dockhand:dockhand scripts/emergency/ ./scripts/
RUN chmod +x ./scripts/*.sh ./scripts/**/*.sh 2>/dev/null || true
# Create data directories
RUN mkdir -p /home/dockhand/.dockhand/stacks /app/data \
&& chown dockhand:dockhand /app/data /home/dockhand /home/dockhand/.dockhand /home/dockhand/.dockhand/stacks
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:${PORT:-3000}/ || exit 1
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
CMD []
+102
View File
@@ -36,6 +36,108 @@ 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).
+27
View File
@@ -0,0 +1,27 @@
## How to Report a Security Flaw
Keeping Dockhand secure is a **top** priority. We highly value community contributions that help protect our users.
> [!IMPORTANT]
> If you discover a security vulnerability, please do not create a public GitHub issue - this can expose users to risk before a fix is available.
> If you find a security vulnerability, we ask that you keep it private and avoid opening a public issue on GitHub.
> Instead, please email us directly at [[security@dockhand.pro](mailto:security@dockhand.pro)]. This inbox has the highest priority.
## Details to Include
To help us track down and resolve the bug as efficiently as possible, please provide the following information in your email:
- A clear explanation of the flaw
- A step-by-step guide on how to reproduce the issue
- The specific Dockhand versions and host environments where the bug is present
- Any ideas you have for a patch or temporary workaround
## Our take
Once you submit a report, we promise to:
- Confirm receipt of your message within a couple of hours
- Swiftly investigate and verify the vulnerability
- Roll out a secure patch as quickly as possible
- Keep you updated throughout the entire patching process
We deeply appreciate your commitment to responsible disclosure and your help in keeping the Dockhand ecosystem safe.
+1
View File
@@ -0,0 +1 @@
v1.0.34
+4
View File
@@ -7,3 +7,7 @@ exact = true
[run]
# Enable source maps for better error messages
sourcemap = "external"
[test]
# Disable auth before any integration test runs
preload = ["./tests/helpers/preload.ts"]
+3
View File
@@ -0,0 +1,3 @@
module github.com/Finsys/dockhand/collector
go 1.25.11
+995
View File
@@ -0,0 +1,995 @@
// Collection worker for Dockhand.
//
// A lightweight Go binary that handles background Docker API calls for
// metrics collection, event streaming, and disk usage checks.
// Communicates with the Node.js parent process via JSON lines on
// stdin (commands) and stdout (results).
package main
import (
"bufio"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"math"
"net"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
// ---------------------------------------------------------------------------
// IPC message types
// ---------------------------------------------------------------------------
// Inbound (stdin) messages from Node.js parent.
type InMessage struct {
Type string `json:"type"`
EnvID int `json:"envId,omitempty"`
Name string `json:"name,omitempty"`
Config *EnvConfig `json:"config,omitempty"`
ConnectionType string `json:"connectionType,omitempty"`
HawserToken string `json:"hawserToken,omitempty"`
IntervalMs int `json:"intervalMs,omitempty"`
Mode string `json:"mode,omitempty"`
PollIntervalMs int `json:"pollIntervalMs,omitempty"`
}
type EnvConfig struct {
Type string `json:"type"` // "socket", "http", "https"
SocketPath string `json:"socketPath,omitempty"`
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
CA string `json:"ca,omitempty"`
Cert string `json:"cert,omitempty"`
Key string `json:"key,omitempty"`
SkipVerify bool `json:"skipVerify,omitempty"`
}
// Outbound (stdout) messages to Node.js parent.
type OutMessage struct {
Type string `json:"type"`
EnvID int `json:"envId,omitempty"`
// Status
Online *bool `json:"online,omitempty"`
Error string `json:"error,omitempty"`
// Events
Event json.RawMessage `json:"event,omitempty"`
// Disk
Data json.RawMessage `json:"data,omitempty"`
Info json.RawMessage `json:"info,omitempty"`
// Metrics
CPU *float64 `json:"cpu,omitempty"`
MemPct *float64 `json:"memPercent,omitempty"`
MemUsed *int64 `json:"memUsed,omitempty"`
MemTotal *int64 `json:"memTotal,omitempty"`
CPUCount *int `json:"cpuCount,omitempty"`
}
// ---------------------------------------------------------------------------
// Docker API response types (minimal, only what we need)
// ---------------------------------------------------------------------------
type containerInfo struct {
ID string `json:"Id"`
State string `json:"State"`
}
type containerStats struct {
CPUStats struct {
CPUUsage struct {
TotalUsage uint64 `json:"total_usage"`
} `json:"cpu_usage"`
SystemCPUUsage uint64 `json:"system_cpu_usage"`
OnlineCPUs int `json:"online_cpus"`
} `json:"cpu_stats"`
PrecpuStats struct {
CPUUsage struct {
TotalUsage uint64 `json:"total_usage"`
} `json:"cpu_usage"`
SystemCPUUsage uint64 `json:"system_cpu_usage"`
} `json:"precpu_stats"`
MemoryStats struct {
Usage uint64 `json:"usage"`
Stats struct {
InactiveFile uint64 `json:"inactive_file"`
TotalInactiveFile uint64 `json:"total_inactive_file"`
} `json:"stats"`
} `json:"memory_stats"`
}
type dockerInfo struct {
MemTotal int64 `json:"MemTotal"`
NCPU int `json:"NCPU"`
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const statsConcurrency = 8 // Max parallel stats calls per environment
// ---------------------------------------------------------------------------
// Environment manager
// ---------------------------------------------------------------------------
type environment struct {
id int
name string
connectionType string
hawserToken string
client *http.Client
streamClient *http.Client
transport *http.Transport
streamTransport *http.Transport
baseURL string
cancel context.CancelFunc
ctx context.Context
online bool
statusReported bool // true after first env_status message sent
}
// closeTransports releases idle connections held by the environment's HTTP transports.
// Must be called when an environment is removed or reconfigured to prevent connection pool leaks.
func (e *environment) closeTransports() {
if e.transport != nil {
e.transport.CloseIdleConnections()
}
if e.streamTransport != nil {
e.streamTransport.CloseIdleConnections()
}
}
type manager struct {
mu sync.Mutex
envs map[int]*environment
metricsInterval time.Duration
eventMode string // "stream" or "poll"
pollInterval time.Duration
diskInterval time.Duration
output *json.Encoder
outputMu sync.Mutex
}
func newManager(output *json.Encoder) *manager {
return &manager{
envs: make(map[int]*environment),
metricsInterval: 30 * time.Second,
eventMode: "stream",
pollInterval: 60 * time.Second,
diskInterval: 5 * time.Minute,
output: output,
}
}
func (m *manager) send(msg OutMessage) {
m.outputMu.Lock()
defer m.outputMu.Unlock()
_ = m.output.Encode(msg)
}
func boolPtr(v bool) *bool { return &v }
func float64Ptr(v float64) *float64 { return &v }
func int64Ptr(v int64) *int64 { return &v }
func intPtr(v int) *int { return &v }
// drainAndClose discards a response body and closes it (for connection reuse).
func drainAndClose(resp *http.Response) {
if resp != nil && resp.Body != nil {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}
// ---------------------------------------------------------------------------
// Docker HTTP client construction
// ---------------------------------------------------------------------------
func buildClients(cfg *EnvConfig) (client *http.Client, streamClient *http.Client, tp *http.Transport, stp *http.Transport, baseURL string, err error) {
var transport *http.Transport
var streamTransport *http.Transport
switch cfg.Type {
case "socket":
socketPath := cfg.SocketPath
if socketPath == "" {
socketPath = "/var/run/docker.sock"
}
dial := func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
}
transport = &http.Transport{
DialContext: dial,
MaxIdleConns: 16,
MaxIdleConnsPerHost: 16,
MaxConnsPerHost: 16,
IdleConnTimeout: 90 * time.Second,
}
streamTransport = &http.Transport{
DialContext: dial,
MaxIdleConns: 4,
MaxIdleConnsPerHost: 4,
MaxConnsPerHost: 4,
IdleConnTimeout: 0,
}
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,
IdleConnTimeout: 0,
}
baseURL = fmt.Sprintf("http://%s:%d", cfg.Host, cfg.Port)
case "https":
tlsCfg, tlsErr := buildTLSConfig(cfg)
if tlsErr != nil {
return nil, nil, nil, nil, "", tlsErr
}
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,
MaxConnsPerHost: 16,
IdleConnTimeout: 90 * time.Second,
}
streamTransport = &http.Transport{
DialContext: tcpDial,
TLSClientConfig: streamTLSCfg,
MaxIdleConns: 4,
MaxIdleConnsPerHost: 4,
MaxConnsPerHost: 4,
IdleConnTimeout: 0,
}
baseURL = fmt.Sprintf("https://%s:%d", cfg.Host, cfg.Port)
default:
return nil, nil, nil, nil, "", fmt.Errorf("unsupported connection type: %s", cfg.Type)
}
client = &http.Client{Transport: transport, Timeout: 30 * time.Second}
streamClient = &http.Client{Transport: streamTransport, Timeout: 0}
return client, streamClient, transport, streamTransport, baseURL, nil
}
func buildTLSConfig(cfg *EnvConfig) (*tls.Config, error) {
tlsCfg := &tls.Config{
InsecureSkipVerify: cfg.SkipVerify,
ServerName: cfg.Host, // Explicit SNI for IP-based hosts
}
if cfg.CA != "" {
// Start from system cert pool so intermediate CAs can chain to system roots
pool, err := x509.SystemCertPool()
if err != nil {
pool = x509.NewCertPool()
}
if !pool.AppendCertsFromPEM([]byte(cfg.CA)) {
return nil, fmt.Errorf("failed to parse CA certificate")
}
tlsCfg.RootCAs = pool
}
if cfg.Cert != "" && cfg.Key != "" {
cert, err := tls.X509KeyPair([]byte(cfg.Cert), []byte(cfg.Key))
if err != nil {
return nil, fmt.Errorf("failed to parse client cert/key: %w", err)
}
tlsCfg.Certificates = []tls.Certificate{cert}
}
return tlsCfg, nil
}
// ---------------------------------------------------------------------------
// Docker API helpers
// ---------------------------------------------------------------------------
func (e *environment) doRequest(ctx context.Context, method, path string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, e.baseURL+path, nil)
if err != nil {
return nil, err
}
if e.hawserToken != "" {
req.Header.Set("X-Hawser-Token", e.hawserToken)
}
return e.client.Do(req)
}
func (e *environment) doStreamRequest(ctx context.Context, method, path string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, e.baseURL+path, nil)
if err != nil {
return nil, err
}
if e.hawserToken != "" {
req.Header.Set("X-Hawser-Token", e.hawserToken)
}
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
}
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()
}
// ---------------------------------------------------------------------------
// Metrics collection goroutine
// ---------------------------------------------------------------------------
func (m *manager) runMetrics(env *environment) {
m.collectMetrics(env)
ticker := time.NewTicker(m.metricsInterval)
defer ticker.Stop()
for {
select {
case <-env.ctx.Done():
return
case <-ticker.C:
m.mu.Lock()
interval := m.metricsInterval
m.mu.Unlock()
ticker.Reset(interval)
m.collectMetrics(env)
}
}
}
func (m *manager) collectMetrics(env *environment) {
if err := env.ping(env.ctx); err != nil {
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()})
}
return
}
if !env.online || !env.statusReported {
env.online = true
env.statusReported = true
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(true)})
}
// List running containers
ctx, cancel := context.WithTimeout(env.ctx, 15*time.Second)
defer cancel()
resp, err := env.doRequest(ctx, "GET", "/containers/json?all=false")
if err != nil {
m.send(OutMessage{Type: "error", EnvID: env.id, Error: fmt.Sprintf("list containers: %s", err)})
return
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
io.Copy(io.Discard, resp.Body)
return
}
var containers []containerInfo
if err := json.NewDecoder(resp.Body).Decode(&containers); err != nil {
return
}
// Filter to running containers only
running := make([]containerInfo, 0, len(containers))
for _, c := range containers {
if c.State == "running" {
running = append(running, c)
}
}
// Collect stats per container (parallel, bounded concurrency)
type statsResult struct {
cpu float64
mem uint64
}
results := make([]statsResult, len(running))
var wg sync.WaitGroup
sem := make(chan struct{}, statsConcurrency)
for i, c := range running {
wg.Add(1)
go func(idx int, id string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
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))
if sErr != nil {
return
}
defer sResp.Body.Close()
if sResp.StatusCode/100 != 2 {
io.Copy(io.Discard, sResp.Body)
return
}
var stats containerStats
if json.NewDecoder(sResp.Body).Decode(&stats) != nil {
return
}
cpuDelta := float64(stats.CPUStats.CPUUsage.TotalUsage - stats.PrecpuStats.CPUUsage.TotalUsage)
sysDelta := float64(stats.CPUStats.SystemCPUUsage - stats.PrecpuStats.SystemCPUUsage)
cpuCount := stats.CPUStats.OnlineCPUs
if cpuCount == 0 {
cpuCount = 1
}
var cpuPct float64
if sysDelta > 0 && cpuDelta > 0 {
cpuPct = (cpuDelta / sysDelta) * float64(cpuCount) * 100
}
memUsage := stats.MemoryStats.Usage
memCache := stats.MemoryStats.Stats.InactiveFile
if memCache == 0 {
memCache = stats.MemoryStats.Stats.TotalInactiveFile
}
actualMem := memUsage
if memCache > 0 && memCache < memUsage {
actualMem = memUsage - memCache
}
results[idx] = statsResult{cpu: cpuPct, mem: actualMem}
}(i, c.ID)
}
wg.Wait()
var totalCPU float64
var totalMem uint64
for _, r := range results {
totalCPU += r.cpu
totalMem += r.mem
}
// Get docker info for MemTotal and NCPU
iCtx, iCancel := context.WithTimeout(env.ctx, 10*time.Second)
defer iCancel()
var info dockerInfo
iResp, iErr := env.doRequest(iCtx, "GET", "/info")
if iErr == nil {
defer iResp.Body.Close()
if iResp.StatusCode/100 == 2 {
json.NewDecoder(iResp.Body).Decode(&info)
} else {
io.Copy(io.Discard, iResp.Body)
}
}
memTotal := info.MemTotal
cpuCount := info.NCPU
if cpuCount == 0 {
cpuCount = 1
}
normalizedCPU := totalCPU / float64(cpuCount)
var memPct float64
if memTotal > 0 {
memPct = (float64(totalMem) / float64(memTotal)) * 100
}
if !math.IsNaN(normalizedCPU) && !math.IsInf(normalizedCPU, 0) && memTotal > 0 {
m.send(OutMessage{
Type: "metrics",
EnvID: env.id,
CPU: float64Ptr(normalizedCPU),
MemPct: float64Ptr(memPct),
MemUsed: int64Ptr(int64(totalMem)),
MemTotal: int64Ptr(memTotal),
CPUCount: intPtr(cpuCount),
})
}
}
// ---------------------------------------------------------------------------
// Event streaming goroutine
// ---------------------------------------------------------------------------
func (m *manager) runEvents(env *environment) {
reconnectDelay := 5 * time.Second
maxReconnectDelay := 60 * time.Second
// Reusable timer to avoid time.After leaks in select statements.
// Stopped and drained between uses to prevent firing stale timers.
delayTimer := time.NewTimer(0)
if !delayTimer.Stop() {
<-delayTimer.C
}
waitOrCancel := func(d time.Duration) bool {
delayTimer.Reset(d)
select {
case <-env.ctx.Done():
if !delayTimer.Stop() {
<-delayTimer.C
}
return false
case <-delayTimer.C:
return true
}
}
for {
if env.ctx.Err() != nil {
return
}
m.mu.Lock()
mode := m.eventMode
pollInterval := m.pollInterval
m.mu.Unlock()
if mode == "poll" {
m.pollEvents(env)
if !waitOrCancel(pollInterval) {
return
}
continue
}
// Stream mode
if err := env.ping(env.ctx); err != nil {
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()})
}
if !waitOrCancel(reconnectDelay) {
return
}
reconnectDelay = minDuration(reconnectDelay*2, maxReconnectDelay)
continue
}
if !env.online || !env.statusReported {
env.online = true
env.statusReported = true
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(true)})
}
reconnectDelay = 5 * time.Second
// Open event stream
resp, err := env.doStreamRequest(env.ctx, "GET", "/events?type=container")
if err != nil {
if env.ctx.Err() != nil {
return
}
env.online = false
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: err.Error()})
if !waitOrCancel(reconnectDelay) {
return
}
reconnectDelay = minDuration(reconnectDelay*2, maxReconnectDelay)
continue
}
if resp.StatusCode/100 != 2 {
drainAndClose(resp)
if !waitOrCancel(reconnectDelay) {
return
}
reconnectDelay = minDuration(reconnectDelay*2, maxReconnectDelay)
continue
}
// Read events line-by-line with a bounded buffer.
// Docker events are newline-delimited JSON; using bufio.Scanner
// avoids json.Decoder's unbounded internal buffer growth.
//
// 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
}
}
}
}()
eventScanner := bufio.NewScanner(resp.Body)
eventScanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // 64KB initial, 1MB max
for eventScanner.Scan() {
if env.ctx.Err() != nil {
break
}
line := eventScanner.Bytes()
if len(line) == 0 {
continue
}
// Validate JSON and forward as raw message
if json.Valid(line) {
m.send(OutMessage{
Type: "container_event",
EnvID: env.id,
Event: json.RawMessage(append([]byte(nil), line...)),
})
}
}
close(bodyDone)
closeBody()
if env.ctx.Err() != nil {
return
}
// Stream ended — reconnect
if !waitOrCancel(reconnectDelay) {
return
}
reconnectDelay = minDuration(reconnectDelay*2, maxReconnectDelay)
}
}
func (m *manager) pollEvents(env *environment) {
if err := env.ping(env.ctx); err != nil {
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()})
}
return
}
if !env.online || !env.statusReported {
env.online = true
env.statusReported = true
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(true)})
}
now := time.Now().Unix()
since := now - 30
ctx, cancel := context.WithTimeout(env.ctx, 15*time.Second)
defer cancel()
resp, err := env.doRequest(ctx, "GET", fmt.Sprintf("/events?type=container&since=%d&until=%d", since, now))
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
io.Copy(io.Discard, resp.Body)
return
}
pollScanner := bufio.NewScanner(resp.Body)
pollScanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for pollScanner.Scan() {
line := pollScanner.Bytes()
if len(line) == 0 {
continue
}
if json.Valid(line) {
m.send(OutMessage{
Type: "container_event",
EnvID: env.id,
Event: json.RawMessage(append([]byte(nil), line...)),
})
}
}
}
// ---------------------------------------------------------------------------
// Disk usage check goroutine
// ---------------------------------------------------------------------------
func (m *manager) runDiskChecks(env *environment) {
if os.Getenv("SKIP_DF_COLLECTION") != "" {
return
}
initDelay := time.NewTimer(10 * time.Second)
select {
case <-env.ctx.Done():
if !initDelay.Stop() {
<-initDelay.C
}
return
case <-initDelay.C:
}
m.checkDisk(env)
ticker := time.NewTicker(m.diskInterval)
defer ticker.Stop()
for {
select {
case <-env.ctx.Done():
return
case <-ticker.C:
m.checkDisk(env)
}
}
}
func (m *manager) checkDisk(env *environment) {
if env.ping(env.ctx) != nil {
return
}
ctx, cancel := context.WithTimeout(env.ctx, 20*time.Second)
defer cancel()
resp, err := env.doRequest(ctx, "GET", "/system/df")
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
io.Copy(io.Discard, resp.Body)
return
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10MB cap
if err != nil {
return
}
// Also fetch /info for DriverStatus (percentage-based disk warnings)
var infoBody json.RawMessage
iCtx, iCancel := context.WithTimeout(env.ctx, 10*time.Second)
defer iCancel()
iResp, iErr := env.doRequest(iCtx, "GET", "/info")
if iErr == nil {
if iResp.StatusCode/100 == 2 {
infoBody, _ = io.ReadAll(io.LimitReader(iResp.Body, 2*1024*1024)) // 2MB cap
} else {
io.Copy(io.Discard, iResp.Body)
}
iResp.Body.Close()
}
m.send(OutMessage{
Type: "disk_usage",
EnvID: env.id,
Data: json.RawMessage(body),
Info: infoBody,
})
}
// ---------------------------------------------------------------------------
// Environment lifecycle
// ---------------------------------------------------------------------------
func (m *manager) configure(msg InMessage) {
m.mu.Lock()
defer m.mu.Unlock()
if existing, ok := m.envs[msg.EnvID]; ok {
existing.cancel()
existing.closeTransports()
delete(m.envs, msg.EnvID)
}
if msg.Config == nil {
return
}
if msg.ConnectionType == "hawser-edge" {
return
}
client, streamClient, transport, streamTransport, baseURL, err := buildClients(msg.Config)
if err != nil {
m.send(OutMessage{Type: "error", EnvID: msg.EnvID, Error: fmt.Sprintf("configure: %s", err)})
return
}
ctx, cancel := context.WithCancel(context.Background())
env := &environment{
id: msg.EnvID,
name: msg.Name,
connectionType: msg.ConnectionType,
hawserToken: msg.HawserToken,
client: client,
streamClient: streamClient,
transport: transport,
streamTransport: streamTransport,
baseURL: baseURL,
cancel: cancel,
ctx: ctx,
}
m.envs[msg.EnvID] = env
go m.runMetrics(env)
go m.runEvents(env)
go m.runDiskChecks(env)
fmt.Fprintf(os.Stderr, "[collector] configured env %d (%s) type=%s base=%s\n", env.id, env.name, msg.ConnectionType, baseURL)
}
func (m *manager) remove(envID int) {
m.mu.Lock()
defer m.mu.Unlock()
if env, ok := m.envs[envID]; ok {
env.cancel()
env.closeTransports()
delete(m.envs, envID)
fmt.Fprintf(os.Stderr, "[collector] removed env %d\n", envID)
}
}
func (m *manager) shutdown() {
m.mu.Lock()
defer m.mu.Unlock()
for id, env := range m.envs {
env.cancel()
env.closeTransports()
delete(m.envs, id)
}
fmt.Fprintf(os.Stderr, "[collector] shutdown complete\n")
}
func (m *manager) setMetricsInterval(ms int) {
m.mu.Lock()
defer m.mu.Unlock()
if ms > 0 {
m.metricsInterval = time.Duration(ms) * time.Millisecond
fmt.Fprintf(os.Stderr, "[collector] metrics interval set to %dms\n", ms)
}
}
func (m *manager) setEventMode(mode string, pollMs int) {
m.mu.Lock()
defer m.mu.Unlock()
if mode != "" {
m.eventMode = mode
}
if pollMs > 0 {
m.pollInterval = time.Duration(pollMs) * time.Millisecond
}
fmt.Fprintf(os.Stderr, "[collector] event mode=%s pollInterval=%dms\n", m.eventMode, m.pollInterval/time.Millisecond)
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
func main() {
fmt.Fprintf(os.Stderr, "[collector] starting...\n")
encoder := json.NewEncoder(os.Stdout)
mgr := newManager(encoder)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
go func() {
<-sigCh
fmt.Fprintf(os.Stderr, "[collector] received signal, shutting down\n")
mgr.shutdown()
os.Exit(0)
}()
mgr.send(OutMessage{Type: "ready"})
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) // 64KB initial, grows to 10MB if needed
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var msg InMessage
if err := json.Unmarshal(line, &msg); err != nil {
fmt.Fprintf(os.Stderr, "[collector] invalid message: %s\n", err)
continue
}
switch msg.Type {
case "configure":
mgr.configure(msg)
case "remove":
mgr.remove(msg.EnvID)
case "set_metrics_interval":
mgr.setMetricsInterval(msg.IntervalMs)
case "set_event_mode":
mgr.setEventMode(msg.Mode, msg.PollIntervalMs)
case "shutdown":
mgr.shutdown()
os.Exit(0)
default:
fmt.Fprintf(os.Stderr, "[collector] unknown message type: %s\n", msg.Type)
}
}
// stdin closed — parent process exited or pipe broke. Shut down cleanly
// so Node.js can restart us if needed.
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "[collector] stdin read error: %v\n", err)
}
fmt.Fprintf(os.Stderr, "[collector] stdin closed, exiting\n")
mgr.shutdown()
}
func minDuration(a, b time.Duration) time.Duration {
if a < b {
return a
}
return b
}
+193
View File
@@ -0,0 +1,193 @@
#!/bin/sh
set -e
# Dockhand Docker Entrypoint (Node.js)
# === Configuration ===
PUID=${PUID:-1001}
PGID=${PGID:-1001}
# Increase body size limit for container file uploads (default 512KB is too small)
export BODY_SIZE_LIMIT=${BODY_SIZE_LIMIT:-2G}
# Default command (--expose-gc allows forced GC from /api/debug/memory?gc=true)
# Custom CA: set NODE_EXTRA_CA_CERTS=/path/to/ca.crt (appends to built-in CAs, git ops auto-merge with system 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"
else
DEFAULT_CMD="node --dns-result-order=ipv4first --no-network-family-autoselection /app/server.js"
fi
# === Detect if running as root ===
RUNNING_AS_ROOT=false
if [ "$(id -u)" = "0" ]; then
RUNNING_AS_ROOT=true
fi
# === Non-root mode (user: directive in compose) ===
if [ "$RUNNING_AS_ROOT" = "false" ]; then
echo "Running as user $(id -u):$(id -g) (set via container user directive)"
DATA_DIR="${DATA_DIR:-/app/data}"
if [ ! -d "$DATA_DIR/db" ]; then
echo "Creating database directory at $DATA_DIR/db"
mkdir -p "$DATA_DIR/db" 2>/dev/null || {
echo "ERROR: Cannot create $DATA_DIR/db directory"
echo "Ensure the data volume is mounted with correct permissions for user $(id -u):$(id -g)"
exit 1
}
fi
if [ ! -d "$DATA_DIR/stacks" ]; then
mkdir -p "$DATA_DIR/stacks" 2>/dev/null || true
fi
SOCKET_PATH="/var/run/docker.sock"
if [ -S "$SOCKET_PATH" ]; then
if test -r "$SOCKET_PATH" 2>/dev/null; then
echo "Docker socket accessible at $SOCKET_PATH"
if [ -z "$DOCKHAND_HOSTNAME" ]; then
DETECTED_HOSTNAME=$(curl -s --unix-socket "$SOCKET_PATH" http://localhost/info 2>/dev/null | sed -n 's/.*"Name":"\([^"]*\)".*/\1/p')
if [ -n "$DETECTED_HOSTNAME" ]; then
export DOCKHAND_HOSTNAME="$DETECTED_HOSTNAME"
echo "Detected Docker host hostname: $DOCKHAND_HOSTNAME"
fi
fi
else
SOCKET_GID=$(stat -c '%g' "$SOCKET_PATH" 2>/dev/null || echo "unknown")
echo "WARNING: Docker socket not readable by user $(id -u)"
echo "Add --group-add $SOCKET_GID to your docker run command"
fi
else
echo "No Docker socket found at $SOCKET_PATH"
echo "Configure Docker environments via the web UI (Settings > Environments)"
fi
if [ "$1" = "" ]; then
exec $DEFAULT_CMD
else
exec "$@"
fi
fi
# === User Setup ===
if [ "$PUID" = "0" ]; then
echo "Running as root user (PUID=0)"
RUN_USER="root"
elif [ "$RUNNING_AS_ROOT" = "true" ] && [ "$PUID" = "1001" ] && [ "$PGID" = "1001" ]; then
echo "Running as root user"
RUN_USER="root"
else
RUN_USER="dockhand"
if [ "$PUID" != "1001" ] || [ "$PGID" != "1001" ]; then
echo "Configuring user with PUID=$PUID PGID=$PGID"
deluser dockhand 2>/dev/null || true
delgroup dockhand 2>/dev/null || true
SKIP_USER_CREATE=false
EXISTING=$(awk -F: -v uid="$PUID" '$3 == uid { print $1 }' /etc/passwd)
if [ -n "$EXISTING" ]; then
echo "WARNING: UID $PUID already in use by '$EXISTING'. Using default UID 1001."
PUID=1001
fi
TARGET_GROUP=$(awk -F: -v gid="$PGID" '$3 == gid { print $1 }' /etc/group)
if [ -z "$TARGET_GROUP" ]; then
addgroup -g "$PGID" dockhand
TARGET_GROUP="dockhand"
fi
if [ "$SKIP_USER_CREATE" = "false" ]; then
adduser -u "$PUID" -G "$TARGET_GROUP" -h /home/dockhand -D dockhand
fi
fi
# === Directory Ownership ===
# Only chown Dockhand's own subdirectories, not the entire /app/data tree.
# Recursive chown on /app/data breaks stack volumes mounted with relative paths
# (e.g. ./postgresql:/var/lib/postgresql) that need different ownership (#719).
DATA_DIR="${DATA_DIR:-/app/data}"
chown "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
for subdir in db stacks git-repos tmp icons snapshots scanner-cache; do
if [ -d "$DATA_DIR/$subdir" ]; then
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR/$subdir" 2>/dev/null || true
fi
done
if [ "$RUN_USER" = "dockhand" ]; then
chown -R dockhand:dockhand /home/dockhand 2>/dev/null || true
fi
if [ -n "$DATA_DIR" ] && [ "$DATA_DIR" != "/app/data" ] && [ "$DATA_DIR" != "./data" ]; then
mkdir -p "$DATA_DIR"
chown "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
for subdir in db stacks git-repos tmp icons snapshots scanner-cache; do
if [ -d "$DATA_DIR/$subdir" ]; then
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR/$subdir" 2>/dev/null || true
fi
done
fi
fi
# === Docker Socket Access ===
SOCKET_PATH="/var/run/docker.sock"
if [ -S "$SOCKET_PATH" ]; then
if [ "$RUN_USER" != "root" ]; then
SOCKET_GID=$(stat -c '%g' "$SOCKET_PATH" 2>/dev/null || echo "")
if [ -n "$SOCKET_GID" ]; then
if ! su-exec "$RUN_USER" test -r "$SOCKET_PATH" 2>/dev/null; then
echo "Docker socket GID: $SOCKET_GID - adding $RUN_USER to docker group..."
DOCKER_GROUP=$(awk -F: -v gid="$SOCKET_GID" '$3 == gid { print $1 }' /etc/group)
if [ -z "$DOCKER_GROUP" ]; then
DOCKER_GROUP="docker"
addgroup -g "$SOCKET_GID" "$DOCKER_GROUP" 2>/dev/null || true
fi
addgroup "$RUN_USER" "$DOCKER_GROUP" 2>/dev/null || \
adduser "$RUN_USER" "$DOCKER_GROUP" 2>/dev/null || true
if su-exec "$RUN_USER" test -r "$SOCKET_PATH" 2>/dev/null; then
echo "Docker socket accessible at $SOCKET_PATH"
else
echo "WARNING: Could not grant Docker socket access to $RUN_USER"
echo "Try running container with: --group-add $SOCKET_GID"
fi
else
echo "Docker socket accessible at $SOCKET_PATH"
fi
fi
else
echo "Docker socket accessible at $SOCKET_PATH"
fi
if [ -z "$DOCKHAND_HOSTNAME" ]; then
DETECTED_HOSTNAME=$(curl -s --unix-socket "$SOCKET_PATH" http://localhost/info 2>/dev/null | sed -n 's/.*"Name":"\([^"]*\)".*/\1/p')
if [ -n "$DETECTED_HOSTNAME" ]; then
export DOCKHAND_HOSTNAME="$DETECTED_HOSTNAME"
echo "Detected Docker host hostname: $DOCKHAND_HOSTNAME"
fi
else
echo "Using configured hostname: $DOCKHAND_HOSTNAME"
fi
else
echo "No local Docker socket mounted (this is normal when using socket-proxy or remote Docker)"
echo "Configure your Docker environment via the web UI: Settings > Environments"
fi
# === Run Application ===
if [ "$RUN_USER" = "root" ]; then
if [ "$1" = "" ]; then
exec $DEFAULT_CMD
else
exec "$@"
fi
else
echo "Running as user: $RUN_USER"
if [ "$1" = "" ]; then
exec su-exec "$RUN_USER" $DEFAULT_CMD
else
exec su-exec "$RUN_USER" "$@"
fi
fi
+16 -2
View File
@@ -113,14 +113,28 @@ else
fi
# === Directory Ownership ===
chown -R "$RUN_USER":"$RUN_USER" /app/data 2>/dev/null || true
# Only chown Dockhand's own subdirectories, not the entire /app/data tree.
# Recursive chown on /app/data breaks stack volumes mounted with relative paths
# (e.g. ./postgresql:/var/lib/postgresql) that need different ownership (#719).
DATA_DIR="${DATA_DIR:-/app/data}"
chown "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
for subdir in db stacks git-repos tmp icons snapshots scanner-cache; do
if [ -d "$DATA_DIR/$subdir" ]; then
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR/$subdir" 2>/dev/null || true
fi
done
if [ "$RUN_USER" = "dockhand" ]; then
chown -R dockhand:dockhand /home/dockhand 2>/dev/null || true
fi
if [ -n "$DATA_DIR" ] && [ "$DATA_DIR" != "/app/data" ] && [ "$DATA_DIR" != "./data" ]; then
mkdir -p "$DATA_DIR"
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
chown "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
for subdir in db stacks git-repos tmp icons snapshots scanner-cache; do
if [ -d "$DATA_DIR/$subdir" ]; then
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR/$subdir" 2>/dev/null || true
fi
done
fi
fi
Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

@@ -0,0 +1,3 @@
ALTER TABLE "git_stacks" ADD COLUMN "build_on_deploy" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "git_stacks" ADD COLUMN "repull_images" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "git_stacks" ADD COLUMN "force_redeploy" boolean DEFAULT false;
+21
View File
@@ -0,0 +1,21 @@
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");
@@ -0,0 +1,2 @@
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
@@ -0,0 +1 @@
ALTER TABLE "git_stacks" ADD COLUMN "synced_files" text;
+12
View File
@@ -0,0 +1,12 @@
CREATE TABLE "template_sources" (
"id" serial PRIMARY KEY NOT NULL,
"source_id" text NOT NULL,
"name" text NOT NULL,
"url" text NOT NULL,
"enabled" boolean DEFAULT true,
"builtin" boolean DEFAULT false,
"sort_order" integer DEFAULT 0,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "template_sources_source_id_unique" UNIQUE("source_id")
);
+4 -4
View File
@@ -2352,14 +2352,14 @@
"primaryKey": false,
"notNull": false
},
"external_compose_path": {
"name": "external_compose_path",
"compose_path": {
"name": "compose_path",
"type": "text",
"primaryKey": false,
"notNull": false
},
"external_env_path": {
"name": "external_env_path",
"env_path": {
"name": "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
File diff suppressed because it is too large Load Diff
+35
View File
@@ -29,6 +29,41 @@
"when": 1767687362730,
"tag": "0003_add_stack_paths",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"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
}
]
}
@@ -0,0 +1,3 @@
ALTER TABLE `git_stacks` ADD `build_on_deploy` integer DEFAULT false;--> statement-breakpoint
ALTER TABLE `git_stacks` ADD `repull_images` integer DEFAULT false;--> statement-breakpoint
ALTER TABLE `git_stacks` ADD `force_redeploy` integer DEFAULT false;
+16
View File
@@ -0,0 +1,16 @@
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`);
@@ -0,0 +1,2 @@
ALTER TABLE `git_stacks` ADD `context_dir` text;--> statement-breakpoint
ALTER TABLE `git_stacks` ADD `no_build_cache` integer DEFAULT false;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE `git_stacks` ADD `synced_files` text;
+13
View File
@@ -0,0 +1,13 @@
CREATE TABLE `template_sources` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`source_id` text NOT NULL,
`name` text NOT NULL,
`url` text NOT NULL,
`enabled` integer DEFAULT true,
`builtin` integer DEFAULT false,
`sort_order` integer DEFAULT 0,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`updated_at` text DEFAULT CURRENT_TIMESTAMP
);
--> statement-breakpoint
CREATE UNIQUE INDEX `template_sources_source_id_unique` ON `template_sources` (`source_id`);
File diff suppressed because it is too large Load Diff
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
+35
View File
@@ -29,6 +29,41 @@
"when": 1767689000000,
"tag": "0003_add_stack_paths",
"breakpoints": true
},
{
"idx": 4,
"version": "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
}
]
}
+54 -31
View File
@@ -1,17 +1,17 @@
{
"name": "dockhand",
"private": true,
"version": "1.0.16",
"version": "1.0.34",
"type": "module",
"scripts": {
"dev": "bunx --bun vite dev",
"prebuild": "bunx license-checker --json --production | jq 'to_entries | map({name: (.key | split(\"@\")[0:-1] | join(\"@\")), version: (.key | split(\"@\")[-1]), license: .value.licenses, repository: .value.repository}) | sort_by(.name)' > src/lib/data/dependencies.json.tmp && mv src/lib/data/dependencies.json.tmp src/lib/data/dependencies.json || true",
"build": "bunx --bun vite build && bun scripts/patch-build.ts && bun scripts/build-subprocesses.ts",
"start": "bun ./build/index.js",
"preview": "bun ./build/index.js",
"prepare": "bunx --bun svelte-kit sync || echo ''",
"check": "bunx --bun svelte-kit sync && bunx --bun svelte-check --tsconfig ./tsconfig.json",
"check:watch": "bunx --bun svelte-kit sync && bunx --bun svelte-check --tsconfig ./tsconfig.json --watch",
"dev": "npx vite dev",
"prebuild": "npx license-checker --json --production | jq 'to_entries | map({name: (.key | split(\"@\")[0:-1] | join(\"@\")), version: (.key | split(\"@\")[-1]), license: .value.licenses, repository: .value.repository}) | sort_by(.name)' > src/lib/data/dependencies.json.tmp && mv src/lib/data/dependencies.json.tmp src/lib/data/dependencies.json || true",
"build": "npx vite build",
"start": "node ./server.js",
"preview": "node ./build/index.js",
"prepare": "npx svelte-kit sync || echo ''",
"check": "npx svelte-kit sync && npx svelte-check --tsconfig ./tsconfig.json",
"check:watch": "npx svelte-kit sync && npx svelte-check --tsconfig ./tsconfig.json --watch",
"test": "bun test",
"test:smoke": "bun test tests/api-smoke.test.ts",
"test:containers": "bun test tests/container-lifecycle.test.ts",
@@ -31,11 +31,26 @@
"test:files": "bun test tests/container-files.test.ts",
"test:license": "bun test tests/license.test.ts",
"test:activity": "bun test tests/activity-dashboard.test.ts",
"test:health": "bun test tests/health-system.test.ts",
"test:containers:advanced": "bun test tests/container-advanced.test.ts",
"test:networks:advanced": "bun test tests/network-advanced.test.ts",
"test:volumes:advanced": "bun test tests/volume-advanced.test.ts",
"test:prune": "bun test tests/prune-operations.test.ts",
"test:schedules": "bun test tests/schedule-management.test.ts",
"test:preferences": "bun test tests/settings-preferences.test.ts",
"test:stacks:advanced": "bun test tests/stack-advanced.test.ts",
"test:system": "bun test tests/system-info.test.ts",
"test:auth": "bun test tests/auth-settings.test.ts",
"test:config-sets": "bun test tests/config-sets.test.ts",
"test:registries": "bun test tests/registries.test.ts",
"test:activity:advanced": "bun test tests/activity-advanced.test.ts",
"test:env-settings": "bun test tests/environment-settings.test.ts",
"test:git-creds": "bun test tests/git-credentials.test.ts",
"test:all": "bun test tests/",
"test:quick": "bun test tests/api-smoke.test.ts tests/notifications.test.ts",
"test:integration": "bun test tests/api-smoke.test.ts tests/crud-operations.test.ts tests/scheduling.test.ts tests/hawser-connection.test.ts",
"test:e2e": "bunx playwright test tests/e2e/",
"generate:legal": "bun scripts/generate-legal-pages.ts"
"test:e2e": "npx playwright test tests/e2e/",
"generate:legal": "node scripts/generate-legal-pages.ts"
},
"dependencies": {
"@codemirror/autocomplete": "6.20.0",
@@ -48,59 +63,66 @@
"@codemirror/lang-python": "6.2.1",
"@codemirror/lang-sql": "6.10.0",
"@codemirror/lang-xml": "6.1.0",
"@codemirror/lang-yaml": "6.1.2",
"@codemirror/lang-yaml": "6.1.3",
"@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",
"@codemirror/view": "6.39.11",
"@lezer/highlight": "1.2.3",
"@lucide/lab": "^0.1.2",
"codemirror": "6.0.2",
"@lucide/lab": "0.1.2",
"ansi_up": "6.0.6",
"argon2": "0.41.1",
"better-sqlite3": "11.7.0",
"croner": "9.1.0",
"cronstrue": "3.9.0",
"drizzle-orm": "0.45.1",
"hash-wasm": "4.12.0",
"js-yaml": "^4.1.1",
"ldapts": "^8.1.3",
"nodemailer": "^7.0.12",
"otpauth": "^9.4.1",
"devalue": "5.8.1",
"drizzle-orm": "0.45.2",
"fast-xml-parser": "5.7.3",
"js-yaml": "4.1.1",
"ldapts": "8.1.3",
"nodemailer": "8.0.9",
"otpauth": "9.4.1",
"postgres": "3.4.8",
"qrcode": "^1.5.4",
"svelte-dnd-action": "0.9.69",
"svelte-sonner": "1.0.7"
"qrcode": "1.5.4",
"rollup": "4.60.0",
"svelte-sonner": "1.0.7",
"undici": "7.24.5",
"ws": "8.21.0"
},
"devDependencies": {
"@internationalized/date": "^3.10.1",
"@layerstack/tailwind": "^1.0.1",
"@lucide/svelte": "^0.562.0",
"@playwright/test": "1.57.0",
"@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "2.50.0",
"@sveltejs/vite-plugin-svelte": "6.2.4",
"@tailwindcss/vite": "^4.1.18",
"@types/bun": "1.3.6",
"@types/better-sqlite3": "^7.6.12",
"@types/js-yaml": "^4.0.9",
"@types/nodemailer": "7.0.5",
"@types/node": "^22.10.0",
"@types/nodemailer": "7.0.11",
"@types/qrcode": "^1.5.6",
"@types/ws": "^8.5.13",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"autoprefixer": "^10.4.23",
"bits-ui": "^2.15.4",
"bits-ui": "2.15.4",
"clsx": "^2.1.1",
"cytoscape": "^3.33.1",
"d3-scale": "^4.0.2",
"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.47.1",
"svelte-adapter-bun": "1.0.1",
"svelte": "5.55.7",
"svelte-check": "^4.3.5",
"svelte-easy-crop": "^5.0.0",
"svelte-virtual-scroll-list": "^1.3.0",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.18",
@@ -115,6 +137,7 @@
"@codemirror/commands": "6.10.1",
"@codemirror/search": "6.6.0",
"@lezer/common": "1.5.0",
"@lezer/highlight": "1.2.3"
"@lezer/highlight": "1.2.3",
"devalue": "5.8.1"
}
}
-31
View File
@@ -1,31 +0,0 @@
/**
* Build subprocess scripts as standalone bundles for production.
*
* Subprocesses run via Bun.spawn and need all dependencies bundled
* since they can't access the SvelteKit build output's chunked modules.
*/
const subprocesses = ['metrics-subprocess', 'event-subprocess'];
console.log('[build-subprocesses] Bundling subprocess scripts...');
for (const name of subprocesses) {
const result = await Bun.build({
entrypoints: [`./src/lib/server/subprocesses/${name}.ts`],
outdir: './build/subprocesses',
target: 'bun',
minify: false
});
if (!result.success) {
console.error(`[build-subprocesses] Failed to bundle ${name}:`);
for (const log of result.logs) {
console.error(log);
}
process.exit(1);
}
console.log(`[build-subprocesses] Bundled ${name}.js`);
}
console.log('[build-subprocesses] Done');
-690
View File
@@ -1,690 +0,0 @@
/**
* Post-build script to fix svelte-adapter-bun WebSocket issue
* The adapter calls server.websocket() which doesn't exist in SvelteKit.
*
* IMPORTANT: Terminal WebSocket logic is shared with vite.config.ts
* Core functions like resolveDockerTarget are defined in:
* src/lib/server/ws-terminal-shared.ts
*
* When updating WebSocket terminal handling, update the shared module
* and this file will use the same logic at build time.
*/
import { join } from 'node:path';
const BUILD_DIR = join(import.meta.dir, '../build');
async function patchHandler() {
const handlerPath = join(BUILD_DIR, 'handler.js');
const handlerFile = Bun.file(handlerPath);
if (!await handlerFile.exists()) {
console.error('handler.js not found');
process.exit(1);
}
let content = await handlerFile.text();
// Replace broken server.websocket() call
content = content.replace(
'const websocket = server.websocket();',
'const websocket = null;'
);
// Add WebSocket upgrade detection before ssr handler
const ssrIndex = content.indexOf('var ssr = async (request, bunServer) => {');
if (ssrIndex > -1) {
const upgradeCode = `
var handleUpgrade = (request, bunServer) => {
const url = new URL(request.url);
const isUpgrade = request.headers.get('connection')?.toLowerCase().includes('upgrade') &&
request.headers.get('upgrade')?.toLowerCase() === 'websocket';
if (!isUpgrade) return null;
// Handle terminal exec WebSocket
if (url.pathname.includes('/api/containers/') && url.pathname.includes('/exec')) {
const pathParts = url.pathname.split('/');
const containerIdIndex = pathParts.indexOf('containers') + 1;
const containerId = pathParts[containerIdIndex];
const shell = url.searchParams.get('shell') || '/bin/sh';
const user = url.searchParams.get('user') || 'root';
const envId = url.searchParams.get('envId') ? parseInt(url.searchParams.get('envId'), 10) : undefined;
if (bunServer.upgrade(request, { data: { type: 'terminal', containerId, shell, user, envId } })) {
return new Response(null, { status: 101 });
}
}
// Handle Hawser Edge WebSocket
if (url.pathname === '/api/hawser/connect') {
if (bunServer.upgrade(request, { data: { type: 'hawser' } })) {
return new Response(null, { status: 101 });
}
}
return null;
};
`;
content = content.slice(0, ssrIndex) + upgradeCode + content.slice(ssrIndex);
}
// Modify handler to check for upgrade first
content = content.replace(
'return ssr(request, server2);',
'const upgradeResponse = handleUpgrade(request, server2); if (upgradeResponse) return upgradeResponse; return ssr(request, server2);'
);
await Bun.write(handlerPath, content);
console.log('✓ Patched handler.js');
}
async function patchIndex() {
const indexPath = join(BUILD_DIR, 'index.js');
const indexFile = Bun.file(indexPath);
if (!await indexFile.exists()) {
console.error('index.js not found');
process.exit(1);
}
let content = await indexFile.text();
const wsHandler = `
import { existsSync as _existsSync, readFileSync as _readFileSync } from 'fs';
import { homedir as _homedir } from 'os';
import { Database as _Database } from 'bun:sqlite';
import { SQL as _SQL } from 'bun';
import { join as _join } from 'path';
import { createDecipheriv as _createDecipheriv } from 'node:crypto';
// Encryption/decryption for sensitive fields
const _ENCRYPTED_PREFIX = 'enc:v1:';
const _IV_LENGTH = 12;
const _AUTH_TAG_LENGTH = 16;
let _encryptionKey = null;
function _getEncryptionKey() {
if (_encryptionKey) return _encryptionKey;
const dataDir = process.env.DATA_DIR || _join(process.cwd(), 'data');
const keyPath = _join(dataDir, '.encryption_key');
const envKey = process.env.ENCRYPTION_KEY;
if (_existsSync(keyPath)) {
try {
_encryptionKey = _readFileSync(keyPath);
return _encryptionKey;
} catch {}
}
if (envKey) {
try {
_encryptionKey = Buffer.from(envKey, 'base64');
return _encryptionKey;
} catch {}
}
return null;
}
function _decrypt(value) {
if (!value || !value.startsWith(_ENCRYPTED_PREFIX)) return value;
const key = _getEncryptionKey();
if (!key) { console.error('[WS] Cannot decrypt: no encryption key'); return value; }
try {
const payload = value.substring(_ENCRYPTED_PREFIX.length);
const combined = Buffer.from(payload, 'base64');
if (combined.length < _IV_LENGTH + _AUTH_TAG_LENGTH + 1) return value;
const iv = combined.subarray(0, _IV_LENGTH);
const authTag = combined.subarray(_IV_LENGTH, _IV_LENGTH + _AUTH_TAG_LENGTH);
const ciphertext = combined.subarray(_IV_LENGTH + _AUTH_TAG_LENGTH);
const decipher = _createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
} catch (e) { console.error('[WS] Decryption failed:', e); return value; }
}
// Database connection (supports both SQLite and PostgreSQL)
let _db = null;
let _isPostgres = false;
function _getDb() {
if (!_db) {
const dbUrl = process.env.DATABASE_URL;
if (dbUrl && (dbUrl.startsWith('postgres://') || dbUrl.startsWith('postgresql://'))) {
_db = new _SQL(dbUrl);
_isPostgres = true;
} else {
const _dbPath = process.env.DATA_DIR ? _join(process.env.DATA_DIR, 'db', 'dockhand.db') : _join(process.cwd(), 'data', 'db', 'dockhand.db');
if (_existsSync(_dbPath)) {
_db = new _Database(_dbPath);
}
}
}
return _db;
}
async function _getEnvironment(id) {
const db = _getDb();
if (!db) return null;
let row;
if (_isPostgres) {
const result = await db.unsafe('SELECT * FROM environments WHERE id = $1', [id]);
row = result[0];
} else {
row = db.prepare('SELECT * FROM environments WHERE id = ?').get(id);
}
return row ? { ...row, is_local: Boolean(row.is_local), connection_type: row.connection_type, hawser_token: row.hawser_token } : null;
}
function detectDockerSocket() {
if (process.env.DOCKER_SOCKET && _existsSync(process.env.DOCKER_SOCKET)) return process.env.DOCKER_SOCKET;
if (process.env.DOCKER_HOST?.startsWith('unix://')) {
const p = process.env.DOCKER_HOST.replace('unix://', '');
if (_existsSync(p)) return p;
}
for (const s of ['/var/run/docker.sock', _homedir() + '/.docker/run/docker.sock', _homedir() + '/.orbstack/run/docker.sock', '/run/docker.sock']) {
if (_existsSync(s)) return s;
}
return '/var/run/docker.sock';
}
const dockerSocketPath = detectDockerSocket();
console.log('Detected Docker socket at:', dockerSocketPath);
const dockerStreams = new Map();
let _wsConnCounter = 0;
async function _getDockerTarget(envId) {
if (!envId) return { type: 'unix', socket: dockerSocketPath };
const env = await _getEnvironment(envId);
if (!env) return { type: 'unix', socket: dockerSocketPath };
// Check for socket connection type (local Unix socket)
if (env.is_local || env.connection_type === 'socket' || !env.connection_type) {
return { type: 'unix', socket: env.socket_path || dockerSocketPath };
}
if (env.connection_type === 'hawser-edge') return { type: 'hawser-edge', environmentId: envId };
// Build TLS config if using HTTPS
const protocol = env.protocol || 'http';
const useTls = protocol === 'https';
let tls = null;
if (useTls) {
tls = {
rejectUnauthorized: !env.tls_skip_verify,
ca: env.tls_ca || undefined,
cert: env.tls_cert || undefined,
// tls_key is encrypted - decrypt it
key: _decrypt(env.tls_key) || undefined
};
}
// hawser_token is also encrypted
const hawserToken = env.connection_type === 'hawser-standard' && env.hawser_token
? _decrypt(env.hawser_token) || undefined
: undefined;
return {
type: useTls ? 'tls' : 'tcp',
host: env.host,
port: env.port || 2375,
hawserToken,
tls
};
}
async function createExec(containerId, cmd, user, target) {
const headers = { 'Content-Type': 'application/json' };
const fetchOpts = {
method: 'POST',
headers,
body: JSON.stringify({ AttachStdin: true, AttachStdout: true, AttachStderr: true, Tty: true, Cmd: cmd, User: user })
};
let url;
if (target.type === 'unix') {
url = 'http://localhost/containers/' + containerId + '/exec';
fetchOpts.unix = target.socket;
} else {
const protocol = target.type === 'tls' ? 'https' : 'http';
url = protocol + '://' + target.host + ':' + target.port + '/containers/' + containerId + '/exec';
if (target.hawserToken) headers['X-Hawser-Token'] = target.hawserToken;
if (target.tls) {
fetchOpts.tls = {
sessionTimeout: 0,
servername: target.host,
rejectUnauthorized: target.tls.rejectUnauthorized
};
if (target.tls.ca) fetchOpts.tls.ca = [target.tls.ca];
if (target.tls.cert) fetchOpts.tls.cert = [target.tls.cert];
if (target.tls.key) fetchOpts.tls.key = target.tls.key;
fetchOpts.keepalive = false;
}
}
const res = await fetch(url, fetchOpts);
if (!res.ok) throw new Error('Failed to create exec: ' + (await res.text()));
return res.json();
}
async function resizeExec(execId, cols, rows, target) {
try {
const fetchOpts = { method: 'POST' };
let url;
if (target.type === 'unix') {
url = 'http://localhost/exec/' + execId + '/resize?h=' + rows + '&w=' + cols;
fetchOpts.unix = target.socket;
} else {
const protocol = target.type === 'tls' ? 'https' : 'http';
url = protocol + '://' + target.host + ':' + target.port + '/exec/' + execId + '/resize?h=' + rows + '&w=' + cols;
if (target.hawserToken) fetchOpts.headers = { 'X-Hawser-Token': target.hawserToken };
if (target.tls) {
fetchOpts.tls = {
sessionTimeout: 0,
servername: target.host,
rejectUnauthorized: target.tls.rejectUnauthorized
};
if (target.tls.ca) fetchOpts.tls.ca = [target.tls.ca];
if (target.tls.cert) fetchOpts.tls.cert = [target.tls.cert];
if (target.tls.key) fetchOpts.tls.key = target.tls.key;
fetchOpts.keepalive = false;
}
}
await fetch(url, fetchOpts);
} catch {}
}
// ============ Hawser Edge Support ============
// Global edge connections map (shared with hawser.ts via globalThis)
if (!globalThis.__hawserEdgeConnections) globalThis.__hawserEdgeConnections = new Map();
const _edgeConnections = globalThis.__hawserEdgeConnections;
// Map WebSocket to environmentId for quick lookup
const _wsToEnvId = new Map();
// Edge exec sessions (execId -> frontend WebSocket)
const _edgeExecSessions = new Map();
// Validate Hawser token against database
async function _validateHawserToken(token) {
const db = _getDb();
if (!db) return { valid: false };
let tokens;
if (_isPostgres) {
tokens = await db.unsafe('SELECT * FROM hawser_tokens WHERE is_active = true');
} else {
tokens = db.prepare('SELECT * FROM hawser_tokens WHERE is_active = 1').all();
}
for (const t of tokens) {
try {
const isValid = await Bun.password.verify(token, t.token);
if (isValid) {
if (_isPostgres) {
await db.unsafe('UPDATE hawser_tokens SET last_used = NOW() WHERE id = $1', [t.id]);
} else {
db.prepare('UPDATE hawser_tokens SET last_used = datetime(\\'now\\') WHERE id = ?').run(t.id);
}
return { valid: true, environmentId: t.environment_id, tokenId: t.id };
}
} catch {}
}
return { valid: false };
}
// Update environment status in database
async function _updateEnvStatus(envId, conn) {
const db = _getDb();
if (!db) return;
try {
if (conn) {
if (_isPostgres) {
await db.unsafe('UPDATE environments SET hawser_last_seen = NOW(), hawser_agent_id = $1, hawser_agent_name = $2, hawser_version = $3, hawser_capabilities = $4 WHERE id = $5',
[conn.agentId, conn.agentName, conn.agentVersion, JSON.stringify(conn.capabilities || []), envId]);
} else {
db.prepare('UPDATE environments SET hawser_last_seen = datetime(\\'now\\'), hawser_agent_id = ?, hawser_agent_name = ?, hawser_version = ?, hawser_capabilities = ? WHERE id = ?')
.run(conn.agentId, conn.agentName, conn.agentVersion, JSON.stringify(conn.capabilities || []), envId);
}
} else {
if (_isPostgres) {
await db.unsafe('UPDATE environments SET hawser_last_seen = NOW() WHERE id = $1', [envId]);
} else {
db.prepare('UPDATE environments SET hawser_last_seen = datetime(\\'now\\') WHERE id = ?').run(envId);
}
}
} catch {}
}
// Handle Hawser Edge protocol messages
async function _handleHawserMessage(ws, msg) {
if (msg.type === 'hello') {
console.log('[Hawser] Hello from agent:', msg.agentName, '(' + msg.agentId + ')');
const validation = await _validateHawserToken(msg.token);
if (!validation.valid) {
console.log('[Hawser] Invalid token');
ws.send(JSON.stringify({ type: 'error', error: 'Invalid token' }));
ws.close();
return;
}
const envId = validation.environmentId;
const existing = _edgeConnections.get(envId);
if (existing) {
const pendingCount = existing.pendingRequests.size;
const streamCount = existing.pendingStreamRequests.size;
console.log('[Hawser] Replacing existing connection for env', envId, '- rejecting', pendingCount, 'pending requests and', streamCount, 'stream requests');
// Reject all pending requests before closing
for (const [requestId, pending] of existing.pendingRequests) {
clearTimeout(pending.timeout);
pending.reject(new Error('Connection replaced by new agent'));
}
for (const [requestId, pending] of existing.pendingStreamRequests) {
pending.onEnd?.('Connection replaced by new agent');
}
existing.pendingRequests.clear();
existing.pendingStreamRequests.clear();
existing.ws.close(1000, 'Replaced');
_wsToEnvId.delete(existing.ws);
}
const conn = {
ws, environmentId: envId, agentId: msg.agentId, agentName: msg.agentName,
agentVersion: msg.version || 'unknown', dockerVersion: msg.dockerVersion || 'unknown',
hostname: msg.hostname || 'unknown', capabilities: msg.capabilities || [],
connectedAt: new Date(), lastHeartbeat: new Date(),
pendingRequests: new Map(), pendingStreamRequests: new Map(),
pingInterval: null
};
_edgeConnections.set(envId, conn);
_wsToEnvId.set(ws, envId);
await _updateEnvStatus(envId, conn);
ws.send(JSON.stringify({ type: 'welcome', environmentId: envId, message: 'Connected to Dockhand' }));
// Start server-side ping interval to keep connection alive through Traefik/proxies (5s)
conn.pingInterval = setInterval(() => {
try { ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() })); }
catch { if (conn.pingInterval) { clearInterval(conn.pingInterval); conn.pingInterval = null; } }
}, 5000);
console.log('[Hawser] Agent', msg.agentName, 'connected for env', envId);
} else if (msg.type === 'ping') {
const envId = _wsToEnvId.get(ws);
if (envId) { const c = _edgeConnections.get(envId); if (c) c.lastHeartbeat = new Date(); }
ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
} else if (msg.type === 'pong') {
const envId = _wsToEnvId.get(ws);
if (envId) { const c = _edgeConnections.get(envId); if (c) c.lastHeartbeat = new Date(); }
} else if (msg.type === 'response') {
const envId = _wsToEnvId.get(ws);
if (!envId) {
console.warn('[Hawser] Response from unknown WebSocket, requestId=' + msg.requestId);
return;
}
const conn = _edgeConnections.get(envId);
if (conn) {
const pending = conn.pendingRequests.get(msg.requestId);
if (pending) {
clearTimeout(pending.timeout);
conn.pendingRequests.delete(msg.requestId);
pending.resolve({ statusCode: msg.statusCode, headers: msg.headers || {}, body: msg.body || '', isBinary: msg.isBinary || false });
} else {
console.warn('[Hawser] Response for unknown request ' + msg.requestId + ' on env ' + envId);
}
}
} else if (msg.type === 'stream') {
const envId = _wsToEnvId.get(ws);
if (!envId) {
console.warn('[Hawser] Stream data from unknown WebSocket, requestId=' + msg.requestId);
return;
}
const conn = _edgeConnections.get(envId);
if (conn?.pendingStreamRequests) {
const pending = conn.pendingStreamRequests.get(msg.requestId);
if (pending) {
pending.onData(msg.data, msg.stream);
} else {
console.warn('[Hawser] Stream data for unknown request ' + msg.requestId + ' on env ' + envId);
}
}
} else if (msg.type === 'stream_end') {
const envId = _wsToEnvId.get(ws);
if (!envId) {
console.warn('[Hawser] Stream end from unknown WebSocket, requestId=' + msg.requestId);
return;
}
const conn = _edgeConnections.get(envId);
if (conn?.pendingStreamRequests) {
const pending = conn.pendingStreamRequests.get(msg.requestId);
if (pending) {
conn.pendingStreamRequests.delete(msg.requestId);
pending.onEnd(msg.reason);
} else {
console.warn('[Hawser] Stream end for unknown request ' + msg.requestId + ' on env ' + envId);
}
}
} else if (msg.type === 'exec_ready') {
const session = _edgeExecSessions.get(msg.execId);
if (session?.ws?.readyState === 1) console.log('[Hawser] Exec ready:', msg.execId);
} else if (msg.type === 'exec_output') {
const session = _edgeExecSessions.get(msg.execId);
if (session?.ws?.readyState === 1) {
const data = Buffer.from(msg.data, 'base64').toString('utf-8');
session.ws.send(JSON.stringify({ type: 'output', data }));
}
} else if (msg.type === 'exec_end') {
const session = _edgeExecSessions.get(msg.execId);
if (session) {
console.log('[Hawser] Exec ended:', msg.execId);
if (session.ws?.readyState === 1) { session.ws.send(JSON.stringify({ type: 'exit' })); session.ws.close(); }
_edgeExecSessions.delete(msg.execId);
}
} else if (msg.type === 'container_event') {
const envId = _wsToEnvId.get(ws);
if (envId && msg.event) {
// Call the global handler registered by hawser.ts
if (globalThis.__hawserHandleContainerEvent) {
globalThis.__hawserHandleContainerEvent(envId, msg.event).catch((err) => {
console.error('[Hawser] Error handling container event:', err);
});
}
}
} else if (msg.type === 'metrics') {
// Metrics from agent - save to database for dashboard graphs
const envId = _wsToEnvId.get(ws);
if (envId && msg.metrics) {
if (globalThis.__hawserHandleMetrics) {
globalThis.__hawserHandleMetrics(envId, msg.metrics).catch((err) => {
console.error('[Hawser] Error saving metrics:', err);
});
}
}
}
}
// Expose send function for hawser.ts module
globalThis.__hawserSendMessage = (envId, message) => {
const conn = _edgeConnections.get(envId);
if (!conn?.ws) return false;
try { conn.ws.send(message); return true; } catch { return false; }
};
// ============ Combined WebSocket Handler ============
const combinedWebsocket = {
async open(ws) {
const connType = ws.data?.type;
// Hawser Edge connection - wait for hello message
if (connType === 'hawser') {
console.log('[Hawser] New connection pending authentication');
return;
}
// Terminal connection
const connId = 'ws-' + (++_wsConnCounter);
ws.data = ws.data || {};
ws.data.connId = connId;
const { containerId, shell, user, envId } = ws.data;
if (!containerId) { ws.send(JSON.stringify({ type: 'error', message: 'No container ID' })); ws.close(); return; }
const target = await _getDockerTarget(envId);
console.log('[Terminal WS] Target:', JSON.stringify({ type: target.type, host: target.host, port: target.port, hasTls: !!target.tls, hasCa: !!target.tls?.ca, hasCert: !!target.tls?.cert, hasKey: !!target.tls?.key }));
// Handle Hawser Edge terminal
if (target.type === 'hawser-edge') {
const conn = _edgeConnections.get(target.environmentId);
if (!conn) { ws.send(JSON.stringify({ type: 'error', message: 'Edge agent not connected' })); ws.close(); return; }
const execId = crypto.randomUUID();
_edgeExecSessions.set(execId, { ws, execId, environmentId: target.environmentId });
ws.data.edgeExecId = execId;
conn.ws.send(JSON.stringify({ type: 'exec_start', execId, containerId, cmd: shell || '/bin/sh', user: user || 'root', cols: 120, rows: 30 }));
return;
}
try {
console.log('[Terminal WS] Creating exec for container:', containerId);
const exec = await createExec(containerId, [shell || '/bin/sh'], user || 'root', target);
console.log('[Terminal WS] Exec created:', exec?.Id);
const execId = exec.Id;
let dockerStream;
let headersStripped = false;
let isChunked = false;
const socketHandler = {
data(socket, data) {
if (ws.readyState === 1) {
let text = new TextDecoder().decode(data);
if (!headersStripped) {
if (text.toLowerCase().includes('transfer-encoding: chunked')) isChunked = true;
const i = text.indexOf('\\r\\n\\r\\n');
if (i > -1) { text = text.slice(i + 4); headersStripped = true; }
else if (text.startsWith('HTTP/')) return;
}
if (isChunked && text) text = text.replace(/^[0-9a-fA-F]+\\r\\n/gm, '').replace(/\\r\\n$/g, '');
if (text) ws.send(JSON.stringify({ type: 'output', data: text }));
}
},
close() { if (ws.readyState === 1) { ws.send(JSON.stringify({ type: 'exit' })); ws.close(); } },
error(socket, error) {
console.error('[Terminal WS] Socket error:', error?.message || error);
if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'error', message: 'Connection error: ' + (error?.message || 'Unknown error') }));
},
connectError(socket, error) {
console.error('[Terminal WS] Connect error:', error?.message || error);
if (ws.readyState === 1) { ws.send(JSON.stringify({ type: 'error', message: 'Failed to connect: ' + (error?.message || 'Unknown error') })); ws.close(); }
},
open(socket) {
const body = JSON.stringify({ Detach: false, Tty: true });
const tokenHeader = (target.type === 'tcp' || target.type === 'tls') && target.hawserToken ? 'X-Hawser-Token: ' + target.hawserToken + '\\r\\n' : '';
// Use actual host for proper routing through reverse proxies like Caddy
const host = target.host || 'localhost';
socket.write('POST /exec/' + execId + '/start HTTP/1.1\\r\\nHost: ' + host + '\\r\\nContent-Type: application/json\\r\\n' + tokenHeader + 'Connection: Upgrade\\r\\nUpgrade: tcp\\r\\nContent-Length: ' + body.length + '\\r\\n\\r\\n' + body);
}
};
if (target.type === 'unix') {
dockerStream = await Bun.connect({ unix: target.socket, socket: socketHandler });
} else {
const connectOpts = { hostname: target.host, port: target.port, socket: socketHandler };
if (target.tls) {
connectOpts.tls = {
sessionTimeout: 0,
servername: target.host,
rejectUnauthorized: target.tls.rejectUnauthorized
};
if (target.tls.ca) connectOpts.tls.ca = [target.tls.ca];
if (target.tls.cert) connectOpts.tls.cert = [target.tls.cert];
if (target.tls.key) connectOpts.tls.key = target.tls.key;
}
console.log('[Terminal WS] Connecting to:', connectOpts.hostname, connectOpts.port, 'TLS:', !!connectOpts.tls);
dockerStream = await Bun.connect(connectOpts);
console.log('[Terminal WS] Connected!');
}
dockerStreams.set(connId, { stream: dockerStream, execId, target });
} catch (e) { console.error('[Terminal WS] Error:', e); ws.send(JSON.stringify({ type: 'error', message: e.message })); ws.close(); }
},
async message(ws, message) {
const connType = ws.data?.type;
// Hawser Edge message
if (connType === 'hawser') {
try {
let msgStr = typeof message === 'string' ? message : message instanceof ArrayBuffer ? new TextDecoder().decode(message) : Buffer.isBuffer(message) ? message.toString('utf-8') : new TextDecoder().decode(new Uint8Array(message));
const msg = JSON.parse(msgStr);
await _handleHawserMessage(ws, msg);
} catch (e) {
console.error('[Hawser] Error:', e.message);
ws.send(JSON.stringify({ type: 'error', error: e.message }));
}
return;
}
// Edge exec session input
const edgeExecId = ws.data?.edgeExecId;
if (edgeExecId) {
const session = _edgeExecSessions.get(edgeExecId);
if (session) {
const conn = _edgeConnections.get(session.environmentId);
if (conn) {
try {
const msg = JSON.parse(message.toString());
if (msg.type === 'input') conn.ws.send(JSON.stringify({ type: 'exec_input', execId: edgeExecId, data: Buffer.from(msg.data).toString('base64') }));
else if (msg.type === 'resize') conn.ws.send(JSON.stringify({ type: 'exec_resize', execId: edgeExecId, cols: msg.cols, rows: msg.rows }));
} catch {}
}
}
return;
}
// Terminal message
const connId = ws.data?.connId;
if (!connId) return;
const d = dockerStreams.get(connId);
if (!d) return;
try {
const msg = JSON.parse(message.toString());
if (msg.type === 'input' && d.stream) d.stream.write(msg.data);
else if (msg.type === 'resize' && d.execId) resizeExec(d.execId, msg.cols, msg.rows, d.target);
} catch { if (d.stream) d.stream.write(message); }
},
close(ws) {
const connType = ws.data?.type;
// Hawser Edge disconnection
if (connType === 'hawser') {
const envId = _wsToEnvId.get(ws);
if (envId) {
const conn = _edgeConnections.get(envId);
if (conn) {
console.log('[Hawser] Agent disconnected:', conn.agentId);
// Clear server-side ping interval
if (conn.pingInterval) { clearInterval(conn.pingInterval); conn.pingInterval = null; }
for (const [, p] of conn.pendingRequests) { clearTimeout(p.timeout); p.reject(new Error('Connection closed')); }
for (const [, p] of conn.pendingStreamRequests) { p.onEnd('Connection closed'); }
_edgeConnections.delete(envId);
_updateEnvStatus(envId, null);
}
_wsToEnvId.delete(ws);
}
return;
}
// Edge exec session close
const edgeExecId = ws.data?.edgeExecId;
if (edgeExecId) {
const session = _edgeExecSessions.get(edgeExecId);
if (session) {
const conn = _edgeConnections.get(session.environmentId);
if (conn) conn.ws.send(JSON.stringify({ type: 'exec_end', execId: edgeExecId, reason: 'user_closed' }));
_edgeExecSessions.delete(edgeExecId);
}
return;
}
// Terminal close
const connId = ws.data?.connId;
if (!connId) return;
const d = dockerStreams.get(connId);
if (d?.stream) d.stream.end();
dockerStreams.delete(connId);
}
};
`;
const insertPoint = content.indexOf('var path = env(');
if (insertPoint > -1) {
content = content.slice(0, insertPoint) + wsHandler + content.slice(insertPoint);
}
content = content.replace(
'var { fetch: handlerFetch, websocket } = getHandler();',
'var { fetch: handlerFetch, websocket: _ } = getHandler(); var websocket = combinedWebsocket;'
);
await Bun.write(indexPath, content);
console.log('✓ Patched index.js');
}
console.log('Patching build...');
await patchHandler();
await patchIndex();
console.log('✓ Done');
+577
View File
@@ -0,0 +1,577 @@
/**
* Production Server Wrapper
*
* Wraps @sveltejs/adapter-node's output with WebSocket support for:
* - Terminal exec connections (xterm.js Docker exec)
* - Hawser Edge agent connections
*
* Usage: node ./server.js
*/
import { createServer as createHttpServer, request as httpRequest } from 'node:http';
import { createServer as createHttpsServer, 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 { WebSocketServer } from 'ws';
import { handler } from './build/handler.js';
// Patch console to prepend ISO timestamps
const _log = console.log;
const _error = console.error;
const _warn = console.warn;
const ts = () => new Date().toISOString();
console.log = (...args) => _log(ts(), ...args);
console.error = (...args) => _error(ts(), ...args);
console.warn = (...args) => _warn(ts(), ...args);
const PORT = parseInt(process.env.PORT || '3000', 10);
const HOST = process.env.HOST || '0.0.0.0';
// 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 WebSocket server attached to the HTTP server
const wss = new WebSocketServer({ noServer: true });
// Track connections
const wsConnections = new Map();
let wsConnectionCounter = 0;
// Track Edge exec sessions: execId -> { ws, environmentId }
const edgeExecSessions = new Map();
// Register global send function for Hawser Edge WebSocket messages.
// hawser.ts checks this first, and handleEdgeExec uses it for terminal relay.
// Reads from __hawserEdgeConnections which is populated by hawser.ts.
globalThis.__hawserSendMessage = (envId, message) => {
const connections = globalThis.__hawserEdgeConnections;
if (!connections) return false;
const conn = connections.get(envId);
if (!conn || !conn.ws) return false;
try {
conn.ws.send(message);
return true;
} catch (e) {
console.error('[Hawser WS] sendMessage error:', e);
return false;
}
};
// Register global handler for exec messages from Hawser Edge agents
// Called by hawser.ts when it receives exec_ready/exec_output/exec_end/error messages
globalThis.__terminalHandleExecMessage = (msg) => {
const execId = msg.execId || msg.requestId;
if (!execId) return;
const session = edgeExecSessions.get(execId);
if (!session || session.ws.readyState !== 1) return;
if (msg.type === 'exec_ready') {
// Agent is ready, frontend is already waiting for output
return;
}
if (msg.type === 'exec_output') {
const data = Buffer.from(msg.data, 'base64').toString('utf-8');
session.ws.send(JSON.stringify({ type: 'output', data }));
return;
}
if (msg.type === 'exec_end') {
session.ws.send(JSON.stringify({ type: 'exit' }));
session.ws.close();
edgeExecSessions.delete(execId);
return;
}
if (msg.type === 'error') {
session.ws.send(JSON.stringify({ type: 'error', message: msg.error || msg.message }));
session.ws.close();
edgeExecSessions.delete(execId);
}
};
// Handle WebSocket upgrade
server.on('upgrade', async (req, socket, head) => {
const url = new URL(req.url || '/', `http://${req.headers.host}`);
// Only handle our specific WebSocket paths
const isTerminal = url.pathname.includes('/api/containers/') && url.pathname.includes('/exec');
const isHawser = url.pathname === '/api/hawser/connect';
if (!isTerminal && !isHawser) {
socket.destroy();
return;
}
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);
});
});
wss.on('connection', (ws, req) => {
const url = new URL(req.url || '/', `http://${req.headers.host}`);
const connId = `ws-${++wsConnectionCounter}`;
const remoteIp = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
|| req.socket.remoteAddress
|| 'unknown';
if (url.pathname === '/api/hawser/connect') {
handleHawserConnection(ws, connId, remoteIp);
} else {
handleTerminalConnection(ws, url, connId);
}
});
/**
* Handle terminal exec WebSocket connections.
* Supports all connection types: socket, direct TCP/TLS, hawser-standard, hawser-edge.
*
* Uses globalThis functions exposed by the SvelteKit app (docker.ts):
* - __terminalGetTarget(envId) - resolves connection info from environment
* - __terminalCreateExec(containerId, shell, user, envId) - creates exec via Docker API
* - __terminalResizeExec(execId, cols, rows, envId) - resizes exec terminal
*/
async function handleTerminalConnection(ws, url, connId) {
const pathParts = url.pathname.split('/');
const containerIdIndex = pathParts.indexOf('containers') + 1;
const containerId = pathParts[containerIdIndex];
const shell = url.searchParams.get('shell') || '/bin/sh';
const user = url.searchParams.get('user') || 'root';
const envIdParam = url.searchParams.get('envId');
const envId = envIdParam ? parseInt(envIdParam, 10) : undefined;
if (!containerId) {
ws.send(JSON.stringify({ type: 'error', message: 'No container ID' }));
ws.close();
return;
}
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;
if (typeof globalThis.__terminalGetTarget === 'function') {
target = await globalThis.__terminalGetTarget(envId);
} else {
// Fallback: local socket only (SvelteKit not yet loaded)
target = { type: 'socket', connectionType: 'socket', socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock' };
}
// Handle Hawser Edge mode - relay through agent WebSocket
if (target.connectionType === 'hawser-edge') {
handleEdgeExec(ws, connId, containerId, shell, user, target.environmentId);
return;
}
// Create exec instance via SvelteKit app (handles all connection types)
let execId;
if (typeof globalThis.__terminalCreateExec === 'function') {
execId = await globalThis.__terminalCreateExec(containerId, shell, user, envId);
} else {
// Fallback: create exec directly via local socket
execId = await createExecLocal(containerId, shell, user, target.socketPath || '/var/run/docker.sock');
}
// Open raw bidirectional stream to Docker for the exec session
const startBody = JSON.stringify({ Detach: false, Tty: true });
let dockerStream;
if (target.type === 'socket') {
const socketPath = target.socketPath || '/var/run/docker.sock';
dockerStream = createConnection({ path: socketPath });
} else if (target.type === 'https' && target.tls) {
const tlsOpts = {
host: target.host,
port: target.port,
servername: target.host,
rejectUnauthorized: target.tls.rejectUnauthorized ?? true
};
if (target.tls.ca) tlsOpts.ca = [target.tls.ca, ...rootCertificates];
if (target.tls.cert) tlsOpts.cert = [target.tls.cert];
if (target.tls.key) tlsOpts.key = target.tls.key;
dockerStream = tlsConnect(tlsOpts);
} else {
// Plain HTTP (direct TCP or hawser-standard)
dockerStream = createConnection({ host: target.host, port: target.port });
}
dockerStream.on('connect', () => {
const host = target.host || 'localhost';
const tokenHeader = target.hawserToken ? `X-Hawser-Token: ${target.hawserToken}\r\n` : '';
dockerStream.write(
`POST /exec/${execId}/start HTTP/1.1\r\n` +
`Host: ${host}\r\n` +
`Content-Type: application/json\r\n` +
`${tokenHeader}` +
`Connection: Upgrade\r\n` +
`Upgrade: tcp\r\n` +
`Content-Length: ${Buffer.byteLength(startBody)}\r\n` +
`\r\n` +
startBody
);
});
let headersStripped = false;
let isChunked = false;
dockerStream.on('data', (data) => {
if (ws.readyState !== 1) return;
let text = data.toString('utf-8');
if (!headersStripped) {
if (text.toLowerCase().includes('transfer-encoding: chunked')) {
isChunked = true;
}
const headerEnd = text.indexOf('\r\n\r\n');
if (headerEnd > -1) {
text = text.slice(headerEnd + 4);
headersStripped = true;
} else if (text.startsWith('HTTP/')) {
return;
}
}
if (isChunked && text) {
text = text.replace(/^[0-9a-fA-F]+\r\n/gm, '').replace(/\r\n$/g, '');
}
if (text) {
ws.send(JSON.stringify({ type: 'output', data: text }));
}
});
dockerStream.on('close', () => {
if (ws.readyState === 1) {
ws.send(JSON.stringify({ type: 'exit' }));
ws.close();
}
});
dockerStream.on('error', (err) => {
console.error('[Terminal WS] Socket error:', err.message);
if (ws.readyState === 1) {
ws.send(JSON.stringify({ type: 'error', message: err.message }));
}
});
// Forward terminal input from browser to Docker
ws.on('message', (data) => {
try {
const msg = JSON.parse(data.toString());
if (msg.type === 'input' && msg.data) {
dockerStream.write(msg.data);
} else if (msg.type === 'resize' && msg.cols && msg.rows) {
// Use SvelteKit's resize function if available (works for all connection types)
if (typeof globalThis.__terminalResizeExec === 'function') {
globalThis.__terminalResizeExec(execId, msg.cols, msg.rows, envId).catch(() => {});
} else {
// Fallback: resize via local socket
const socketPath = target.socketPath || '/var/run/docker.sock';
const resizeReq = httpRequest({
socketPath,
path: `/exec/${execId}/resize?h=${msg.rows}&w=${msg.cols}`,
method: 'POST',
}, () => {});
resizeReq.on('error', () => {});
resizeReq.end();
}
}
} catch {}
});
ws.on('close', () => {
dockerStream.destroy();
});
wsConnections.set(connId, { stream: dockerStream, ws });
} catch (err) {
console.error('[Terminal WS] Error:', err.message);
if (ws.readyState === 1) {
ws.send(JSON.stringify({ type: 'error', message: err.message }));
ws.close();
}
}
ws.on('close', () => {
wsConnections.delete(connId);
});
}
/**
* Handle Hawser Edge exec session.
* Sends exec commands through the Hawser WebSocket relay.
*/
function handleEdgeExec(ws, connId, containerId, shell, user, environmentId) {
if (typeof globalThis.__hawserSendMessage !== 'function') {
ws.send(JSON.stringify({ type: 'error', message: 'Edge agent handler not ready' }));
ws.close();
return;
}
const execId = randomUUID();
edgeExecSessions.set(execId, { ws, execId, environmentId });
// Send exec_start to the Hawser agent
const execStartMsg = JSON.stringify({
type: 'exec_start',
execId,
containerId,
cmd: shell,
user,
cols: 120,
rows: 30
});
const sent = globalThis.__hawserSendMessage(environmentId, execStartMsg);
if (!sent) {
edgeExecSessions.delete(execId);
ws.send(JSON.stringify({ type: 'error', message: 'Edge agent not connected' }));
ws.close();
return;
}
// Forward terminal input/resize from browser to agent
ws.on('message', (data) => {
try {
const msg = JSON.parse(data.toString());
if (msg.type === 'input' && msg.data) {
const inputMsg = JSON.stringify({
type: 'exec_input',
execId,
data: Buffer.from(msg.data).toString('base64')
});
globalThis.__hawserSendMessage(environmentId, inputMsg);
} else if (msg.type === 'resize' && msg.cols && msg.rows) {
const resizeMsg = JSON.stringify({
type: 'exec_resize',
execId,
cols: msg.cols,
rows: msg.rows
});
globalThis.__hawserSendMessage(environmentId, resizeMsg);
}
} catch {}
});
ws.on('close', () => {
// Notify agent that exec session ended
if (typeof globalThis.__hawserSendMessage === 'function') {
const endMsg = JSON.stringify({
type: 'exec_end',
execId,
reason: 'user_closed'
});
globalThis.__hawserSendMessage(environmentId, endMsg);
}
edgeExecSessions.delete(execId);
wsConnections.delete(connId);
});
wsConnections.set(connId, { ws });
}
/**
* Fallback: Create exec via local Docker socket (used before SvelteKit app is loaded)
*/
function createExecLocal(containerId, shell, user, socketPath) {
const createBody = JSON.stringify({
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: true,
Cmd: [shell],
User: user
});
return new Promise((resolve, reject) => {
const req = httpRequest({
socketPath,
path: `/containers/${containerId}/exec`,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(createBody),
},
}, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
try {
const body = JSON.parse(Buffer.concat(chunks).toString());
if (res.statusCode === 201 && body.Id) {
resolve(body.Id);
} else {
reject(new Error(body.message || `Exec create failed: ${res.statusCode}`));
}
} catch (e) {
reject(new Error('Failed to parse exec response'));
}
});
res.on('error', reject);
});
req.on('error', reject);
req.write(createBody);
req.end();
});
}
/**
* Handle Hawser Edge WebSocket connections.
* The full Hawser protocol is handled by the SvelteKit app
* via the global hawser connection manager.
*/
function handleHawserConnection(ws, connId, remoteIp) {
console.log('[Hawser WS] New connection pending authentication');
ws.on('message', async (data) => {
try {
const msg = JSON.parse(data.toString());
// Use the global hawser message handler injected by the SvelteKit app
if (typeof globalThis.__hawserHandleMessage === 'function') {
try {
await globalThis.__hawserHandleMessage(ws, msg, connId, remoteIp);
} catch (handlerError) {
console.error('[Hawser WS] Handler error:', handlerError);
// Don't close connection - let it recover
}
} else {
console.warn('[Hawser WS] No global handler registered');
ws.send(JSON.stringify({ type: 'error', message: 'Server not ready' }));
}
} catch (err) {
console.error('[Hawser WS] Message parse error:', err.message);
}
});
ws.on('close', () => {
if (typeof globalThis.__hawserHandleDisconnect === 'function') {
globalThis.__hawserHandleDisconnect(ws, connId);
}
});
ws.on('error', (err) => {
console.error('[Hawser WS] Connection error:', err.message);
});
}
// Start the server
server.listen(PORT, HOST, () => {
const scheme = useHttps ? 'https' : 'http';
console.log(`Listening on ${scheme}://${HOST}:${PORT}/ with WebSocket`);
});
+53
View File
@@ -0,0 +1,53 @@
/*
* getrandom() shim for old kernels (< 3.17) that lack the syscall.
*
* musl libc calls getrandom() which returns ENOSYS on kernel 3.10.x
* (e.g. Synology DS1513+). This shim intercepts the call and falls
* back to /dev/urandom, which is cryptographically secure after boot
* and is the same entropy source getrandom() reads from on modern kernels.
*
* Usage: LD_PRELOAD=/usr/lib/libgetrandom-shim.so <command>
*/
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <sys/syscall.h>
#include <unistd.h>
#ifndef SYS_getrandom
# ifdef __x86_64__
# define SYS_getrandom 318
# elif defined(__aarch64__)
# define SYS_getrandom 278
# else
# error "Unsupported architecture"
# endif
#endif
ssize_t getrandom(void *buf, size_t buflen, unsigned int flags) {
/* Try the real syscall first */
long ret = syscall(SYS_getrandom, buf, buflen, flags);
if (ret >= 0 || errno != ENOSYS)
return (ssize_t)ret;
/* Kernel too old — fall back to /dev/urandom */
int fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC);
if (fd < 0)
return -1;
ssize_t total = 0;
while ((size_t)total < buflen) {
ssize_t n = read(fd, (char *)buf + total, buflen - (size_t)total);
if (n <= 0) {
if (n < 0 && errno == EINTR)
continue;
close(fd);
return -1;
}
total += n;
}
close(fd);
return total;
}
+103
View File
@@ -74,6 +74,33 @@ 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;
@@ -1314,6 +1341,16 @@ 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)); }
@@ -1715,3 +1752,69 @@ html {
}
/* ansi_up color classes (use_classes = true) — shared by all log viewers */
.ansi-black-fg { color: #3f3f46; }
.ansi-red-fg { color: #ef4444; }
.ansi-green-fg { color: #22c55e; }
.ansi-yellow-fg { color: #eab308; }
.ansi-blue-fg { color: #3b82f6; }
.ansi-magenta-fg { color: #d946ef; }
.ansi-cyan-fg { color: #06b6d4; }
.ansi-white-fg { color: #e4e4e7; }
.ansi-bright-black-fg { color: #71717a; }
.ansi-bright-red-fg { color: #f87171; }
.ansi-bright-green-fg { color: #4ade80; }
.ansi-bright-yellow-fg { color: #facc15; }
.ansi-bright-blue-fg { color: #60a5fa; }
.ansi-bright-magenta-fg { color: #e879f9; }
.ansi-bright-cyan-fg { color: #22d3ee; }
.ansi-bright-white-fg { color: #fafafa; }
.ansi-black-bg { background-color: #18181b; }
.ansi-red-bg { background-color: #dc2626; }
.ansi-green-bg { background-color: #16a34a; }
.ansi-yellow-bg { background-color: #ca8a04; }
.ansi-blue-bg { background-color: #2563eb; }
.ansi-magenta-bg { background-color: #c026d3; }
.ansi-cyan-bg { background-color: #0891b2; }
.ansi-white-bg { background-color: #d4d4d8; }
.ansi-bright-black-bg { background-color: #52525b; }
.ansi-bright-red-bg { background-color: #ef4444; }
.ansi-bright-green-bg { background-color: #22c55e; }
.ansi-bright-yellow-bg { background-color: #eab308; }
.ansi-bright-blue-bg { background-color: #3b82f6; }
.ansi-bright-magenta-bg { background-color: #d946ef; }
.ansi-bright-cyan-bg { background-color: #06b6d4; }
.ansi-bright-white-bg { background-color: #fafafa; }
.ansi-bold { font-weight: bold; }
.ansi-dim { opacity: 0.7; }
.ansi-italic { font-style: italic; }
.ansi-underline { text-decoration: underline; }
/* 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 */
}
+5 -4
View File
@@ -3,11 +3,12 @@
import type { AuthenticatedUser } from '$lib/server/auth';
// Build-time constants injected by Vite
declare const __BUILD_DATE__: string | null;
declare const __BUILD_COMMIT__: string | null;
declare global {
// Build-time constants injected by Vite
const __APP_VERSION__: string | null;
const __BUILD_DATE__: string | null;
const __BUILD_COMMIT__: string | null;
namespace App {
// interface Error {}
interface Locals {
+191 -43
View File
@@ -1,18 +1,90 @@
// v1.0.12
import '$lib/server/dns-dispatcher.js';
import { initDatabase, hasAdminUser } from '$lib/server/db';
import { startSubprocesses, stopSubprocesses } from '$lib/server/subprocess-manager';
import { startScheduler } from '$lib/server/scheduler';
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';
import { detectHostDataDir } from '$lib/server/host-path';
import { listContainers, removeContainer } from '$lib/server/docker';
import { migrateCredentials } from '$lib/server/encryption';
import { gzipSync } from 'node:zlib';
import { rmSync, readdirSync, existsSync } from 'fs';
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 = [
'application/json',
'text/html',
'text/plain',
'text/css',
'application/javascript',
'text/javascript',
'application/xml',
'text/xml',
'image/svg+xml'
];
// Minimum response size to bother compressing (1KB)
const MIN_COMPRESS_SIZE = 1024;
function shouldCompress(request: Request, response: Response): boolean {
const acceptEncoding = request.headers.get('accept-encoding') || '';
if (!acceptEncoding.includes('gzip')) return false;
if (response.headers.has('content-encoding')) return false;
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('text/event-stream')) return false;
if (contentType.includes('octet-stream')) return false;
if (contentType.startsWith('image/') && !contentType.includes('svg')) return false;
const isCompressible = COMPRESSIBLE_TYPES.some(type => contentType.includes(type));
if (!isCompressible) return false;
const contentLength = response.headers.get('content-length');
if (contentLength && parseInt(contentLength) < MIN_COMPRESS_SIZE) return false;
return true;
}
async function compressResponse(request: Request, response: Response): Promise<Response> {
if (!shouldCompress(request, response)) return response;
const body = await response.arrayBuffer();
if (body.byteLength < MIN_COMPRESS_SIZE) return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers
});
const gzipBefore = rssBeforeOp();
const compressed = gzipSync(new Uint8Array(body));
rssAfterOp('gzip', gzipBefore);
const headers = new Headers(response.headers);
headers.set('content-encoding', 'gzip');
headers.set('vary', 'Accept-Encoding');
headers.delete('content-length');
return new Response(compressed, {
status: response.status,
statusText: response.statusText,
headers
});
}
// Cleanup orphaned scanner version containers from previous runs
async function cleanupOrphanedScannerContainers() {
@@ -98,11 +170,12 @@ if (!initialized) {
cleanupOrphanedScannerContainers().catch(err => {
console.error('Failed to cleanup orphaned scanner containers:', err);
});
// Start background subprocesses for metrics and event collection (isolated processes)
// Start background subprocesses for metrics and event collection (worker thread)
startSubprocesses().catch(err => {
console.error('Failed to start background subprocesses:', err);
});
startScheduler(); // Start unified scheduler for auto-updates and git syncs (async)
startRssTracker(); // Start RSS memory tracking (no-op unless MEMORY_MONITOR=true)
// Check license expiry on startup and then daily (with HMR guard)
checkLicenseExpiry().catch(err => {
@@ -119,6 +192,7 @@ if (!initialized) {
// Graceful shutdown handling
const shutdown = async () => {
console.log('[Server] Shutting down...');
stopRssTracker();
await stopSubprocesses();
process.exit(0);
};
@@ -131,6 +205,48 @@ 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',
@@ -175,55 +291,87 @@ export const handle: Handle = async ({ event, resolve }) => {
return resolve(event);
}
// WebSocket upgrade for terminal connections is handled by the build patch (scripts/patch-build.ts)
// This is necessary because svelte-adapter-bun expects server.websocket() which doesn't exist in SvelteKit
const httpBefore = rssBeforeOp();
try {
// Check if auth is enabled
const authEnabled = await isAuthEnabled();
// Check if auth is enabled
const authEnabled = await isAuthEnabled();
// If auth is disabled, allow everything (app works as before)
if (!authEnabled) {
event.locals.user = null;
event.locals.authEnabled = false;
return resolve(event);
}
// Auth is enabled - check session
const user = await validateSession(event.cookies);
event.locals.user = user;
event.locals.authEnabled = true;
// Public paths don't require authentication
if (isPublicPath(event.url.pathname)) {
return resolve(event);
}
// If not authenticated
if (!user) {
// Special case: allow user creation when auth is enabled but no admin exists yet
// 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 resolve(event);
// If auth is disabled, allow everything
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)));
}
// API routes return 401
if (event.url.pathname.startsWith('/api/')) {
return new Response(
JSON.stringify({ error: 'Unauthorized', message: 'Authentication required' }),
{
status: 401,
headers: { 'Content-Type': 'application/json' }
// 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);
}
}
}
// UI routes redirect to login
const redirectUrl = encodeURIComponent(event.url.pathname + event.url.search);
redirect(307, `/login?redirect=${redirectUrl}`);
}
event.locals.user = user;
event.locals.authEnabled = true;
return resolve(event);
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)));
}
// If not authenticated
if (!user) {
// Special case: allow user creation when auth is enabled but no admin exists yet
// 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)));
}
// API routes return 401
if (event.url.pathname.startsWith('/api/')) {
return new Response(
JSON.stringify({ error: 'Unauthorized', message: 'Authentication required' }),
{
status: 401,
headers: { 'Content-Type': 'application/json' }
}
);
}
// UI routes redirect to login
const redirectUrl = encodeURIComponent(event.url.pathname + event.url.search);
redirect(307, `/login?redirect=${redirectUrl}`);
}
return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event)));
} finally {
rssAfterOp('http', httpBefore);
}
};
export const handleError: HandleServerError = ({ error, event }) => {
@@ -0,0 +1,37 @@
<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>
+32 -12
View File
@@ -8,9 +8,26 @@
imageUrl: string;
onCancel: () => void;
onSave: (dataUrl: string) => void;
cropShape?: 'round' | 'rect';
outputSize?: number;
outputFormat?: 'image/jpeg' | 'image/webp';
outputQuality?: number;
title?: string;
saveLabel?: string;
}
let { show, imageUrl, onCancel, onSave }: Props = $props();
let {
show,
imageUrl,
onCancel,
onSave,
cropShape = 'round',
outputSize = 256,
outputFormat = 'image/jpeg',
outputQuality = 0.9,
title = 'Crop avatar',
saveLabel = 'Save avatar'
}: Props = $props();
// Cropper state
let crop = $state({ x: 0, y: 0 });
@@ -144,9 +161,9 @@
return;
}
// Set canvas size to output size (256x256 for avatar)
canvas.width = 256;
canvas.height = 256;
// Set canvas size to output size
canvas.width = outputSize;
canvas.height = outputSize;
// Ensure we use a square crop area to avoid stretching
// Center the square within the original crop area
@@ -163,12 +180,12 @@
size,
0,
0,
256,
256
outputSize,
outputSize
);
// Convert to data URL
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
const dataUrl = canvas.toDataURL(outputFormat, outputQuality);
resolve(dataUrl);
};
@@ -204,16 +221,18 @@
handleCancel();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if show && imageUrl}
<div class="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4">
<div class="fixed inset-0 bg-black/80 z-[200] flex items-center justify-center p-4">
<div class="bg-background rounded-lg w-full max-w-2xl max-h-[90vh] flex flex-col shadow-2xl">
<!-- Header -->
<div class="p-4 border-b">
<h3 class="text-lg font-semibold">Crop avatar</h3>
<h3 class="text-lg font-semibold">{title}</h3>
<p class="text-sm text-muted-foreground mt-1">
Drag to reposition. Use the slider to zoom.
</p>
@@ -226,7 +245,8 @@
bind:crop
bind:zoom
aspect={1}
cropShape="round"
minZoom={0.5}
cropShape={cropShape}
showGrid={false}
on:cropcomplete={onCropComplete}
on:mediaLoaded={onMediaLoaded}
@@ -239,7 +259,7 @@
<ZoomOut class="w-5 h-5 text-muted-foreground shrink-0" />
<input
type="range"
min="1"
min="0.5"
max="3"
step="0.1"
bind:value={zoom}
@@ -266,7 +286,7 @@
disabled={saving || !imageLoaded}
>
<Check class="w-4 h-4" />
{saving ? 'Uploading...' : !imageLoaded ? 'Loading...' : 'Save avatar'}
{saving ? 'Uploading...' : !imageLoaded ? 'Loading...' : saveLabel}
</Button>
</div>
</div>
+33 -49
View File
@@ -4,6 +4,7 @@
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';
const progressText: Record<string, string> = {
remove: 'removing',
@@ -30,6 +31,7 @@
items: Array<{ id: string; name: string }>;
envId?: number;
options?: Record<string, any>;
totalSize?: number;
onClose: () => void;
onComplete: () => void;
}
@@ -42,6 +44,7 @@
items,
envId,
options = {},
totalSize,
onClose,
onComplete
}: Props = $props();
@@ -60,7 +63,7 @@
let successCount = $state(0);
let failCount = $state(0);
let cancelledCount = $state(0);
let abortController: AbortController | null = null;
let cancelled = false;
// Progress calculation
const progress = $derived(() => {
@@ -78,9 +81,7 @@
// Cleanup on destroy
onDestroy(() => {
if (abortController) {
abortController.abort();
}
cancelled = true;
});
async function startOperation() {
@@ -96,20 +97,13 @@
successCount = 0;
failCount = 0;
cancelledCount = 0;
abortController = new AbortController();
cancelled = false;
try {
const response = await fetch(`/api/batch${envId ? `?env=${envId}` : ''}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
operation,
entityType,
items,
options
}),
signal: abortController.signal
body: JSON.stringify({ operation, entityType, items, options })
});
if (!response.ok) {
@@ -117,52 +111,44 @@
throw new Error(error.error || 'Request failed');
}
if (!response.body) {
throw new Error('No response body');
}
const data = await response.json();
const { jobId } = data;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
// Poll job for progress events
let cursor = 0;
while (!cancelled) {
const jobRes = await fetch(`/api/jobs/${jobId}`);
if (!jobRes.ok) break;
const job = await jobRes.json();
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const event: BatchEvent = JSON.parse(line.slice(6));
handleEvent(event);
} catch {
// Ignore parse errors
}
}
// Process new lines since last poll
const newLines = job.lines.slice(cursor);
cursor = job.lines.length;
for (const line of newLines) {
handleEvent(line.data as BatchEvent);
}
if (job.status !== 'running') break;
await new Promise((r) => setTimeout(r, 500));
}
} catch (error: any) {
if (error.name === 'AbortError') {
// User cancelled - mark remaining as cancelled
let cancelled = 0;
if (cancelled) {
// Mark remaining items as cancelled
let cancelCount = 0;
itemStates = itemStates.map(item => {
if (item.status === 'pending' || item.status === 'processing') {
cancelled++;
cancelCount++;
return { ...item, status: 'cancelled' as ItemStatus };
}
return item;
});
cancelledCount = cancelled;
} else {
console.error('Batch operation error:', error);
cancelledCount = cancelCount;
}
} catch (error: any) {
console.error('Batch operation error:', error);
} finally {
isRunning = false;
isComplete = true;
abortController = null;
}
}
@@ -185,9 +171,7 @@
}
function handleCancel() {
if (abortController) {
abortController.abort();
}
cancelled = true;
}
function handleClose() {
@@ -233,7 +217,7 @@
{#if isRunning}
Processing {items.length} {entityType}...
{:else if isComplete}
Completed: {successCount} succeeded{#if failCount > 0}, {failCount} failed{/if}{#if cancelledCount > 0}, {cancelledCount} cancelled{/if}
Completed: {successCount} succeeded{#if failCount > 0}, {failCount} failed{/if}{#if cancelledCount > 0}, {cancelledCount} cancelled{/if}{#if totalSize && successCount > 0} ({formatBytes(totalSize)}){/if}
{:else}
Preparing to {operation} {items.length} {entityType}...
{/if}
+127
View File
@@ -0,0 +1,127 @@
<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>
+35 -8
View File
@@ -1,14 +1,18 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { EditorState, StateField, StateEffect, RangeSet } from '@codemirror/state';
import { EditorState, StateField, StateEffect, RangeSet, Prec } 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 } from '@codemirror/commands';
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, StreamLanguage, type StreamParser } from '@codemirror/language';
import { defaultKeymap, history, historyKeymap, indentWithTab, insertNewlineAndIndent } from '@codemirror/commands';
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, indentUnit, 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 }> = {
@@ -405,7 +409,7 @@
new RegExp(`(?<!\\$)\\$\\{${marker.name}\\?`),
new RegExp(`(?<!\\$)\\$\\{${marker.name}:\\+`),
new RegExp(`(?<!\\$)\\$\\{${marker.name}\\+`),
new RegExp(`(?<!\\$)\\$${marker.name}(?![a-zA-Z0-9_])`)
new RegExp(`(?<![A-Za-z0-9\\$])\\$${marker.name}(?![a-zA-Z0-9_])`)
];
const hasVariable = varPatterns.some(p => p.test(line));
@@ -496,6 +500,21 @@
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) {
@@ -527,12 +546,18 @@
return xml();
case 'sql':
return sql();
case 'dockerfile':
case 'shell':
case 'bash':
case 'sh':
// No dedicated shell/dockerfile support, use basic highlighting
return [];
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);
case 'dotenv':
case 'env':
return StreamLanguage.define(dotenvParser);
@@ -671,7 +696,9 @@
]),
...themeExtensions,
EditorView.lineWrapping,
getLanguageExtension(language)
EditorState.tabSize.of(2),
getLanguageExtension(language),
...(language === 'yaml' ? [Prec.high(keymap.of([{ key: 'Enter', run: yamlNewlineAndIndent }]))] : [])
].flat();
if (readonly) {
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation';
import * as Command from '$lib/components/ui/command';
import {
@@ -183,7 +183,7 @@
// Load data when dialog opens
$effect(() => {
if (open) {
loadData();
untrack(() => loadData());
}
});
+13 -6
View File
@@ -19,6 +19,7 @@
onConfirm: () => void;
onOpenChange: (open: boolean) => void;
children: Snippet<[{ open: boolean }]>;
extraContent?: Snippet;
}
let {
@@ -35,7 +36,8 @@
disabled = false,
onConfirm,
onOpenChange,
children
children,
extraContent
}: Props = $props();
const triggerClass = $derived(unstyled
@@ -103,11 +105,16 @@
align={position === 'left' ? 'start' : 'end'}
sideOffset={8}
>
<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 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>
</Popover.Content>
</Popover.Root>
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import { getIconComponent, isCustomIcon } from '$lib/utils/icons';
import type { Component } from 'svelte';
interface Props {
icon: string;
envId: number;
class?: string;
cacheBust?: string | number;
}
let { icon, envId, class: className = 'w-4 h-4', cacheBust }: Props = $props();
const isCustom = $derived(isCustomIcon(icon));
const LucideIcon = $derived(!isCustom ? getIconComponent(icon) : null) as Component | null;
const imgSrc = $derived(isCustom ? `/api/environments/${envId}/icon${cacheBust ? `?v=${cacheBust}` : ''}` : '');
</script>
{#if isCustom}
<img src={imgSrc} alt="" class="{className} rounded-full object-cover" />
{:else if LucideIcon}
<LucideIcon class={className} />
{/if}
+12 -2
View File
@@ -1,13 +1,15 @@
<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, onToggleTheme }: Props = $props();
let { logs, darkMode = true, timezone, onToggleTheme }: Props = $props();
// Parse log lines with timestamp and content
function parseLogLine(line: string): { timestamp: string; content: string; type: 'trivy' | 'grype' | 'error' | 'default' } {
@@ -44,7 +46,15 @@
}
function formatTimestamp(timestamp: string): string {
return timestamp.split('T')[1]?.replace('Z', '') || timestamp;
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);
}
</script>
+8 -35
View File
@@ -8,6 +8,8 @@
import { CheckCircle2, XCircle, Loader2, AlertCircle, Terminal, Sun, Moon, Download } from 'lucide-svelte';
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;
@@ -97,12 +99,6 @@
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`;
@@ -168,33 +164,10 @@
throw new Error('Failed to start pull');
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim() || !line.startsWith('data: ')) continue;
try {
const data = JSON.parse(line.slice(6));
handlePullProgress(data);
} catch (e) {
// Ignore parse errors
}
}
}
const { jobId } = await response.json();
await watchJob(jobId, (line) => {
handlePullProgress(line.data as any);
});
if (status === 'pulling') {
duration = Date.now() - startTime;
@@ -336,7 +309,7 @@
class="h-10"
>
{#if isPulling}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
<Download class="w-4 h-4 mr-2 animate-spin" />
Pulling...
{:else}
<Download class="w-4 h-4" />
@@ -354,7 +327,7 @@
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
{#if status === 'pulling'}
<Loader2 class="w-4 h-4 animate-spin text-blue-600" />
<Download 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" />
+6 -32
View File
@@ -2,6 +2,7 @@
import { tick, onMount } from 'svelte';
import { CheckCircle2, XCircle, Loader2, AlertCircle, Terminal, Sun, Moon, Upload } from 'lucide-svelte';
import { appendEnvParam } from '$lib/stores/environment';
import { watchJob } from '$lib/utils/sse-fetch';
type PushStatus = 'idle' | 'pushing' | 'complete' | 'error';
@@ -144,39 +145,12 @@
return;
}
// Handle SSE stream
const reader = pushResponse.body?.getReader();
if (!reader) {
errorMessage = 'No response body';
status = 'error';
onError?.(errorMessage);
return;
}
const { jobId } = await pushResponse.json();
await watchJob(jobId, (line) => {
handlePushProgress(line.data as any);
});
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
handlePushProgress(data);
} catch (e) {
// Ignore parse errors
}
}
}
}
// If stream ended without complete/error status
// If job ended without an explicit complete/error event
if (status === 'pushing') {
status = 'complete';
statusMessage = 'Image pushed successfully!';
+9 -27
View File
@@ -5,6 +5,7 @@
import { Loader2, AlertCircle, Terminal, Sun, Moon, ShieldCheck, ShieldAlert, ShieldX, Shield } from 'lucide-svelte';
import { onMount } from 'svelte';
import { appendEnvParam } from '$lib/stores/environment';
import { watchJob } from '$lib/utils/sse-fetch';
import ScanResultsView from '../../routes/images/ScanResultsView.svelte';
export interface ScanResult {
@@ -37,6 +38,7 @@
imageName: string;
envId?: number | null;
autoStart?: boolean;
activeScanner?: 'grype' | 'trivy';
onComplete?: (results: ScanResult[]) => void;
onError?: (error: string) => void;
onStatusChange?: (status: ScanStatus) => void;
@@ -46,6 +48,7 @@
imageName,
envId = null,
autoStart = false,
activeScanner = $bindable<'grype' | 'trivy'>('grype'),
onComplete,
onError,
onStatusChange
@@ -148,31 +151,10 @@
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error('No response body');
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
handleScanProgress(data);
} catch (e) {
// Ignore parse errors
}
}
}
}
const { jobId } = await response.json();
await watchJob(jobId, (line) => {
handleScanProgress(line.data as any);
});
// If stream ended without complete status
if (status === 'scanning') {
@@ -246,7 +228,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'}
<Loader2 class="w-4 h-4 animate-spin text-blue-600" />
<Shield class="w-4 h-4 animate-spin text-blue-600" />
<span class="text-sm">Scanning for vulnerabilities...</span>
{:else if status === 'complete'}
{#if hasCriticalOrHigh}
@@ -382,7 +364,7 @@
{:else}
<!-- Scan Results -->
<div class="flex-1 min-h-0 overflow-auto">
<ScanResultsView {results} />
<ScanResultsView {results} bind:activeScanner />
</div>
{/if}
</div>
+6 -10
View File
@@ -114,12 +114,7 @@
}
const key = trimmed.slice(0, eqIndex).trim();
let value = trimmed.slice(eqIndex + 1);
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
const value = trimmed.slice(eqIndex + 1);
if (key) {
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
@@ -200,8 +195,8 @@
* Sync rawContent TO variables.
* Parses raw content for non-secrets, preserves existing secrets.
*/
function syncRawToVariables() {
const { vars, warnings } = parseRawContent(rawContent);
function syncRawToVariables(content?: string) {
const { vars, warnings } = parseRawContent(content ?? rawContent);
parseWarnings = warnings;
// Preserve existing secrets (they're not in rawContent)
@@ -240,8 +235,9 @@
// Form → Text: sync variables to raw (preserves comments)
syncVariablesToRaw();
} else if (newMode === 'form' && viewMode === 'text') {
// Text → Form: sync raw to variables (preserves secrets)
syncRawToVariables();
// Text → Form: use textEditorContent which falls back to generatedRawContent
// when rawContent is empty (fixes vars lost on view switch for git stacks)
syncRawToVariables(textEditorContent);
}
viewMode = newMode;
+9 -7
View File
@@ -43,15 +43,17 @@
let selectedEditorFont = $state('system-mono');
onMount(async () => {
// Load monospace fonts for dropdown previews
// Load bundled monospace fonts for dropdown previews
const fontsToLoad = monospaceFonts.filter(f => f.googleFont);
if (fontsToLoad.length > 0) {
const families = fontsToLoad.map(f => `family=${f.googleFont}`).join('&');
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = `https://fonts.googleapis.com/css2?${families}&display=swap`;
link.onload = () => { monoFontsLoaded = true; };
document.head.appendChild(link);
let loaded = 0;
for (const font of fontsToLoad) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = `/fonts/${font.id}/font.css`;
link.onload = () => { if (++loaded >= fontsToLoad.length) monoFontsLoaded = true; };
document.head.appendChild(link);
}
} else {
monoFontsLoaded = true;
}
+16 -1
View File
@@ -29,7 +29,22 @@
'Europe/Kyiv': 'Europe/Kiev',
'Asia/Ho_Chi_Minh': 'Asia/Saigon',
'America/Nuuk': 'America/Godthab',
'Pacific/Kanton': 'Pacific/Enderbury'
'Pacific/Kanton': 'Pacific/Enderbury',
'Asia/Kolkata': 'Asia/Calcutta',
'Asia/Kathmandu': 'Asia/Katmandu',
'Asia/Yangon': 'Asia/Rangoon',
'Asia/Kashgar': 'Asia/Urumqi',
'Atlantic/Faroe': 'Atlantic/Faeroe',
'Europe/Uzhgorod': 'Europe/Kiev',
'Europe/Zaporozhye': 'Europe/Kiev',
'America/Atikokan': 'America/Coral_Harbour',
'America/Argentina/Buenos_Aires': 'America/Buenos_Aires',
'America/Argentina/Catamarca': 'America/Catamarca',
'America/Argentina/Cordoba': 'America/Cordoba',
'America/Argentina/Jujuy': 'America/Jujuy',
'America/Argentina/Mendoza': 'America/Mendoza',
'Pacific/Pohnpei': 'Pacific/Ponape',
'Pacific/Chuuk': 'Pacific/Truk'
};
// Reverse map: canonical → modern alias names (for display hints)
+3 -2
View File
@@ -3,6 +3,7 @@
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;
@@ -62,11 +63,11 @@
<span class="text-muted-foreground font-normal">({release.date})</span>
</h3>
<div class="space-y-1.5 ml-1">
{#each release.changes as change}
{#each [...release.changes].sort((a, b) => a.type === b.type ? 0 : a.type === 'feature' ? -1 : 1) 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}" />
<span class="text-sm">{change.text}</span>
<ChangelogText text={change.text} />
</div>
{/each}
</div>
+26 -1
View File
@@ -22,11 +22,16 @@
User,
ClipboardList,
Activity,
Timer
Timer,
LibraryBig
} 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';
@@ -97,6 +102,7 @@
{ href: '/images', Icon: Images, label: 'Images', permission: 'images' },
{ href: '/volumes', Icon: HardDrive, label: 'Volumes', permission: 'volumes' },
{ href: '/networks', Icon: Network, label: 'Networks', permission: 'networks' },
{ href: '/templates', Icon: LibraryBig, label: 'Templates', permission: 'templates' },
{ href: '/registry', Icon: Download, label: 'Registry', permission: 'registries' },
{ href: '/activity', Icon: Activity, label: 'Activity', permission: 'activity' },
{ href: '/schedules', Icon: Timer, label: 'Schedules', permission: 'schedules' },
@@ -155,6 +161,25 @@
</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 -17
View File
@@ -19,17 +19,20 @@
// 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 [, , day, month, dow] = parts;
const [min, hr, day, month, dow] = parts;
// Weekly: specific day of week (0-6), day and month are wildcards
if (dow !== '*' && day === '*' && month === '*') {
// Simple minute and hour: plain numbers only (not */n, ranges, or lists)
const isSimpleNumber = (s: string) => /^\d+$/.test(s);
// Weekly: specific single day of week (0-6), day and month are wildcards, simple min/hour
if (dow !== '*' && /^\d$/.test(dow) && day === '*' && month === '*' && isSimpleNumber(min) && isSimpleNumber(hr)) {
return 'weekly';
}
// Daily: all wildcards except minute and hour
if (day === '*' && month === '*' && dow === '*') {
// Daily: all wildcards except simple minute and hour
if (day === '*' && month === '*' && dow === '*' && isSimpleNumber(min) && isSimpleNumber(hr)) {
return 'daily';
}
@@ -134,23 +137,15 @@
onchange(newValue);
}
// Validate cron expression
// Validate cron expression (supports 5-field and 6-field with seconds)
function isValidCron(cron: string): boolean {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return false;
const [min, hr, day, month, dow] = parts;
if (parts.length !== 5 && parts.length !== 6) return false;
// Basic pattern validation (number, *, */n, range, list)
const cronFieldPattern = /^(\*|(\*\/\d+)|\d+(-\d+)?(,\d+(-\d+)?)*)$/;
return (
cronFieldPattern.test(min) &&
cronFieldPattern.test(hr) &&
cronFieldPattern.test(day) &&
cronFieldPattern.test(month) &&
cronFieldPattern.test(dow)
);
return parts.every((part) => cronFieldPattern.test(part));
}
// Human-readable description using cronstrue
+30 -8
View File
@@ -329,18 +329,40 @@
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;
if (sortState?.field === field) {
onSortChange({
field,
direction: sortState.direction === 'asc' ? 'desc' : 'asc'
});
} else {
onSortChange({ field, direction: 'asc' });
}
const newState: DataGridSortState = sortState?.field === field
? { field, direction: sortState.direction === 'asc' ? 'desc' : 'asc' }
: { field, direction: 'asc' };
onSortChange(newState);
}
// Virtual scroll state
+104 -20
View File
@@ -1,14 +1,15 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Cpu, MemoryStick, Box, Globe, ChevronDown, Check, HardDrive, Clock, Wifi, WifiOff, Route, UndoDot, Icon, AlertCircle, Loader2 } from 'lucide-svelte';
import { Cpu, MemoryStick, Box, Globe, ChevronDown, Check, HardDrive, Clock, Wifi, WifiOff, Route, UndoDot, Icon, AlertCircle, Loader2, Search, Server, X } from 'lucide-svelte';
import { whale } from '@lucide/lab';
import { Button } from '$lib/components/ui/button';
import { currentEnvironment, environments, type Environment } from '$lib/stores/environment';
import { sseConnected } from '$lib/stores/events';
import { getIconComponent } from '$lib/utils/icons';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import { toast } from 'svelte-sonner';
import { themeStore, type FontSize } from '$lib/stores/theme';
import { formatTime } from '$lib/stores/settings';
import { getTimeFormat } from '$lib/stores/settings';
import { formatBytes } from '$lib/utils/format';
// Font size scaling for header
let fontSize = $state<FontSize>('normal');
@@ -77,6 +78,8 @@
let diskUsageLoading = $state(false);
let envAbortController: AbortController | null = null; // Aborts ALL requests when switching envs
let showDropdown = $state(false);
let searchTerm = $state('');
let searchInputRef = $state<HTMLInputElement | null>(null);
let currentEnvId = $state<number | null>(null);
let lastUpdated = $state<Date>(new Date());
let isConnected = $state(false);
@@ -92,8 +95,40 @@
}
}
// 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);
const filteredEnvList = $derived(
searchTerm.trim()
? envList.filter((e: Environment) => e.name.toLowerCase().includes(searchTerm.toLowerCase()))
: envList
);
// Clear search and focus when dropdown opens/closes
$effect(() => {
if (showDropdown && showSearch) {
// Use tick to wait for DOM render
setTimeout(() => searchInputRef?.focus(), 0);
} else {
searchTerm = '';
}
});
sseConnected.subscribe(v => isConnected = v);
@@ -200,14 +235,6 @@
(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)) {
@@ -305,6 +332,20 @@
hostInfo ? ((hostInfo.totalMemory - hostInfo.freeMemory) / hostInfo.totalMemory) * 100 : 0
);
let currentTimezone = $derived(
$environments.find((e: Environment) => Number(e.id) === Number(currentEnvId))?.timezone ?? 'UTC'
);
function formatLastUpdated(date: Date, timezone: string): string {
return new Intl.DateTimeFormat('en-GB', {
timeZone: timezone,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: getTimeFormat() === '12h'
}).format(date);
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.env-dropdown')) {
@@ -335,14 +376,12 @@
class="flex items-center gap-1.5 -ml-1 px-1 py-1 rounded-md hover:bg-muted transition-colors cursor-pointer"
>
{#if hostInfo?.environment && Number(hostInfo.environment.id) === Number(currentEnvId)}
{@const EnvIcon = getIconComponent(hostInfo.environment.icon || 'globe')}
<EnvIcon class="{iconSizeLargeClass()} text-primary" />
<EnvironmentIcon icon={hostInfo.environment.icon || 'globe'} envId={hostInfo.environment.id} class="{iconSizeLargeClass()} text-primary" />
<span class="font-medium text-foreground">{hostInfo.environment.name}</span>
{:else if currentEnvId && envList.length > 0}
{@const currentEnv = envList.find(e => Number(e.id) === Number(currentEnvId))}
{#if currentEnv}
{@const EnvIcon = getIconComponent(currentEnv.icon || 'globe')}
<EnvIcon class="{iconSizeLargeClass()} text-primary" />
<EnvironmentIcon icon={currentEnv.icon || 'globe'} envId={currentEnv.id} class="{iconSizeLargeClass()} text-primary" />
<span class="font-medium text-foreground">{currentEnv.name}</span>
{:else}
<Globe class="{iconSizeLargeClass()} text-muted-foreground" />
@@ -357,9 +396,40 @@
{#if showDropdown && envList.length > 0}
<div class="absolute top-full left-0 mt-1 min-w-56 w-max max-w-80 bg-popover border rounded-md shadow-lg z-50">
<div class="py-1">
{#each envList as env (env.id)}
{@const EnvIcon = getIconComponent(env.icon || 'globe')}
{#if showSearch}
<div class="sticky top-0 bg-popover border-b px-2 py-1.5">
<div class="relative">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<input
bind:this={searchInputRef}
bind:value={searchTerm}
type="text"
placeholder="Search environments..."
class="w-full pl-7 pr-7 py-1 text-sm bg-transparent border rounded focus:outline-none focus:ring-1 focus:ring-ring"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => {
if (e.key === 'Escape') {
if (searchTerm) {
searchTerm = '';
} else {
showDropdown = false;
}
}
}}
/>
{#if searchTerm}
<button
class="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-muted"
onclick={(e) => { e.stopPropagation(); searchTerm = ''; searchInputRef?.focus(); }}
>
<X class="w-3 h-3 text-muted-foreground" />
</button>
{/if}
</div>
</div>
{/if}
<div class="py-1 max-h-[calc(100vh-8rem)] overflow-y-auto">
{#each filteredEnvList as env (env.id)}
{@const isOffline = offlineEnvIds.has(env.id)}
{@const isSwitching = switchingEnvId === env.id}
<button
@@ -373,7 +443,7 @@
{:else if isOffline}
<WifiOff class="{iconSizeLargeClass()} text-destructive shrink-0" />
{:else}
<EnvIcon class="{iconSizeLargeClass()} text-muted-foreground shrink-0" />
<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="{iconSizeLargeClass()} text-muted-foreground shrink-0" />
{/if}
<span class="flex-1 whitespace-nowrap" class:text-muted-foreground={isOffline}>{env.name}</span>
{#if isOffline && !isSwitching}
@@ -382,6 +452,10 @@
<Check class="{iconSizeLargeClass()} text-primary shrink-0" />
{/if}
</button>
{:else}
<div class="px-3 py-2 text-sm text-muted-foreground">
No matching environments
</div>
{/each}
</div>
</div>
@@ -391,6 +465,16 @@
{#if hostInfo}
<span class="text-border">|</span>
<!-- Hostname / IP (#962) — first info segment after the env dropdown.
Hidden on narrow viewports to keep the strip readable. -->
{#if hostLabel}
<div class="hidden xl:flex items-center gap-1" title="Daemon hostname / IP">
<Server class="{iconSizeClass()}" />
<span>{hostLabel}</span>
</div>
<span class="hidden xl:inline text-border">|</span>
{/if}
<!-- Platform/OS -->
<span class="hidden md:inline">{hostInfo.platform} {hostInfo.arch}</span>
@@ -452,7 +536,7 @@
class="flex items-center gap-2 {isConnected ? 'text-emerald-500' : 'text-muted-foreground'}"
title={isConnected ? 'Live updates connected' : 'Live updates disconnected'}
>
<span class="text-muted-foreground">{formatTime(lastUpdated, { includeSeconds: true })}</span>
<span class="text-muted-foreground" title={currentTimezone}>{formatLastUpdated(lastUpdated, currentTimezone)}</span>
{#if isConnected}
<Wifi class="{iconSizeLargeClass()}" />
<span class="font-medium">Live</span>
+36 -20
View File
@@ -1,44 +1,60 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Sun, Moon } from 'lucide-svelte';
import { onMount } from 'svelte';
import { Sun, Moon, Monitor } from 'lucide-svelte';
import { onMount, onDestroy } from 'svelte';
import { onDarkModeChange } from '$lib/stores/theme';
let isDark = $state(false);
type ThemeMode = 'light' | 'dark' | 'system';
let mode = $state<ThemeMode>('system');
let mediaQuery: MediaQueryList | null = null;
onMount(() => {
// Check for saved preference or system preference
const saved = localStorage.getItem('theme');
if (saved) {
isDark = saved === 'dark';
} else {
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
updateTheme();
const saved = localStorage.getItem('theme') as ThemeMode | null;
mode = saved === 'light' || saved === 'dark' || saved === 'system' ? saved : 'system';
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', onSystemChange);
applyMode();
});
function updateTheme() {
onDestroy(() => {
mediaQuery?.removeEventListener('change', onSystemChange);
});
function onSystemChange() {
if (mode === 'system') {
applyMode();
}
}
function applyMode() {
const isDark = mode === 'dark' || (mode === 'system' && !!mediaQuery?.matches);
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
localStorage.setItem('theme', isDark ? 'dark' : 'light');
// Apply the correct theme colors for the new mode
onDarkModeChange();
}
function toggleTheme() {
isDark = !isDark;
updateTheme();
function cycleTheme() {
const order: ThemeMode[] = ['light', 'dark', 'system'];
mode = order[(order.indexOf(mode) + 1) % order.length];
localStorage.setItem('theme', mode);
applyMode();
}
</script>
<Button variant="ghost" size="icon" onclick={toggleTheme} class="h-9 w-9">
{#if isDark}
<Button variant="ghost" size="icon" onclick={cycleTheme} class="h-9 w-9" title={mode === 'system' ? 'Theme: system' : mode === 'dark' ? 'Theme: dark' : 'Theme: light'}>
{#if mode === 'dark'}
<Moon class="h-4 w-4" />
{:else if mode === 'light'}
<Sun class="h-4 w-4" />
{:else}
<Moon class="h-4 w-4" />
<Monitor class="h-4 w-4" />
{/if}
<span class="sr-only">Toggle theme</span>
</Button>
@@ -1,7 +1,9 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { AlertCircle, Copy, Check, AlertTriangle, CheckCircle2, XCircle } from 'lucide-svelte';
import { AlertCircle, Copy, Check, XCircle, AlertTriangle, CheckCircle2 } from 'lucide-svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { copyToClipboard } from '$lib/utils/clipboard';
interface Props {
open: boolean;
@@ -12,7 +14,7 @@
}
let { open = $bindable(), title, message, details, onClose }: Props = $props();
let copied = $state(false);
let copied = $state<'ok' | 'error' | null>(null);
interface ParsedOutput {
warnings: string[];
@@ -87,9 +89,9 @@
async function copyError() {
const text = details ? `${message}\n\n${details}` : message;
await navigator.clipboard.writeText(text);
copied = true;
setTimeout(() => (copied = false), 2000);
const ok = await copyToClipboard(text);
copied = ok ? 'ok' : 'error';
setTimeout(() => (copied = null), 2000);
}
function handleClose() {
@@ -145,7 +147,14 @@
class="absolute top-2 right-2 p-1 rounded text-zinc-400 hover:text-zinc-600 dark:text-zinc-500 dark:hover:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-opacity"
title="Copy error"
>
{#if copied}
{#if copied === 'error'}
<Tooltip.Root open>
<Tooltip.Trigger>
<XCircle class="w-3.5 h-3.5 text-red-500" />
</Tooltip.Trigger>
<Tooltip.Content>Copy requires HTTPS</Tooltip.Content>
</Tooltip.Root>
{:else if copied === 'ok'}
<Check class="w-3.5 h-3.5" />
{:else}
<Copy class="w-3.5 h-3.5" />
@@ -57,6 +57,10 @@ class SidebarState {
? (this.openMobile = !this.openMobile)
: this.setOpen(!this.open);
};
destroy = () => {
this.#isMobile.destroy();
};
}
const SYMBOL_KEY = "scn-sidebar";
+22 -3
View File
@@ -10,11 +10,11 @@ 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: 60, minWidth: 50, align: 'right' },
{ id: 'memory', label: 'Memory', sortable: true, sortField: 'memory', width: 95, minWidth: 70, 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 },
{ id: 'ports', label: 'Ports', width: 120, minWidth: 60 },
{ id: 'ports', label: 'Ports', sortable: true, sortField: 'ports', width: 120, minWidth: 60 },
{ id: 'autoUpdate', label: 'Auto-update', width: 95, minWidth: 70 },
{ id: 'stack', label: 'Stack', sortable: true, sortField: 'stack', width: 100, minWidth: 60 },
{ id: 'actions', label: '', fixed: 'end', width: 200, minWidth: 150, resizable: true }
@@ -76,6 +76,7 @@ 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 },
@@ -118,6 +119,23 @@ export const scheduleColumns: ColumnConfig[] = [
{ id: 'actions', label: '', fixed: 'end', width: 100, resizable: false }
];
// Environment grid columns (dashboard list view)
export const environmentColumns: ColumnConfig[] = [
{ id: 'status', label: '', width: 36, resizable: false },
{ id: 'name', label: 'Environment', sortable: true, sortField: 'name', width: 180, minWidth: 100, grow: true },
{ id: 'connection', label: 'Connection', sortable: true, sortField: 'connection', width: 110, minWidth: 80 },
{ id: 'host', label: 'Host', sortable: true, sortField: 'host', width: 150, minWidth: 80 },
{ id: 'containers', label: 'Containers', sortable: true, sortField: 'containers', width: 100, minWidth: 70 },
{ id: 'updates', label: 'Updates', sortable: true, sortField: 'updates', width: 75, minWidth: 55 },
{ id: 'cpu', label: 'CPU', sortable: true, sortField: 'cpu', width: 110, minWidth: 80 },
{ id: 'memory', label: 'Memory', sortable: true, sortField: 'memory', width: 110, minWidth: 80 },
{ id: 'images', label: 'Images', sortable: true, sortField: 'images', width: 65, minWidth: 50 },
{ id: 'volumes', label: 'Volumes', sortable: true, sortField: 'volumes', width: 70, minWidth: 50 },
{ id: 'stacks', label: 'Stacks', sortable: true, sortField: 'stacks', width: 85, minWidth: 65 },
{ id: 'events', label: 'Events', sortable: true, sortField: 'events', width: 65, minWidth: 50 },
{ id: 'labels', label: 'Labels', width: 150, minWidth: 80 }
];
// Map of grid ID to column definitions
export const gridColumnConfigs: Record<GridId, ColumnConfig[]> = {
containers: containerColumns,
@@ -128,7 +146,8 @@ export const gridColumnConfigs: Record<GridId, ColumnConfig[]> = {
volumes: volumeColumns,
activity: activityColumns,
schedules: scheduleColumns,
audit: auditColumns
audit: auditColumns,
environments: environmentColumns
};
// Get configurable columns (not fixed)
+337
View File
@@ -1,4 +1,341 @@
[
{
"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",
"changes": [
{ "type": "feature", "text": "theme toggle with system option — auto-follows OS light/dark preference (#803)" },
{ "type": "feature", "text": "custom user option for terminal shell sessions, persisted per container (#830)" },
{ "type": "feature", "text": "redeploy button for internal stacks with pull/build/force-recreate options (#152)" },
{ "type": "feature", "text": "build, re-pull images and force redeployment options for git stacks (#792, #472)" },
{ "type": "fix", "text": "allow underscores in hostname validation (#790)" },
{ "type": "fix", "text": "HTTPS git repos with self-signed CA certificates fail to clone/pull (#842)" },
{ "type": "fix", "text": "stack restart fails for containers using network_mode: service:<container> — added recreate option (#844)" },
{ "type": "fix", "text": "git stack sync deletes data in relative volume paths (#831)" },
{ "type": "fix", "text": "batch update skips Hawser containers (#485)" },
{ "type": "fix", "text": "registry delete fails for multi-arch/OCI manifest images" },
{ "type": "fix", "text": "scanner cache cleanup to prevent volume bloat (#808)" },
{ "type": "fix", "text": "negotiate Docker API version for scanner/updater sidecar containers (#759)" },
{ "type": "fix", "text": "scan vulnerability counts mismatch with displayed list (#705)" }
],
"imageTag": "fnsys/dockhand:v1.0.23"
},
{
"version": "1.0.22",
"date": "2026-03-21",
"changes": [
{ "type": "feature", "text": "dashboard list view with inline search and connection filters (#740)" },
{ "type": "feature", "text": "custom environment icon (#754)" },
{ "type": "feature", "text": "show +N indicator for containers with multiple IP addresses (#644)" },
{ "type": "feature", "text": "bundle all fonts locally for privacy and offline use (#734)" },
{ "type": "fix", "text": "respect PROXY settings when checking for container updates" },
{ "type": "fix", "text": "git stacks force-redeploy after a failed sync (#693)" },
{ "type": "fix", "text": "What's New modal shown before login, exposing version info (#717)" },
{ "type": "fix", "text": "git repository files not removed from disk on delete (#671)" },
{ "type": "fix", "text": "recursive chown at startup breaks stack volumes with different ownership (#719)" },
{ "type": "fix", "text": "missing notification event toggles for container healthy, image prune events (#659)" },
{ "type": "fix", "text": "container disappears when edit fails (e.g. invalid memory/swap) (#736)" },
{ "type": "fix", "text": "regression: network container count always shows 0 (#761)" },
{ "type": "fix", "text": "Grype/Trivy scan containers don't inherit proxy env vars (#780)" },
{ "type": "fix", "text": "pin vulnerability scanner images to specific versions not :latest" }
],
"imageTag": "fnsys/dockhand:v1.0.22"
},
{
"version": "1.0.21",
"date": "2026-03-13",
"changes": [
{ "type": "feature", "text": "option to truncate port list (#702)" },
{ "type": "feature", "text": "log viewer supports ANSII 256 colors (#743)" },
{ "type": "fix", "text": "IPv6 Problems (#714, #731)" },
{ "type": "fix", "text": "polling storm & mass disconnect (#733, #741)" },
{ "type": "fix", "text": "custom cron schedule displayed incorrectly (#727)" },
{ "type": "fix", "text": "wrong cron schedule (#706)" },
{ "type": "fix", "text": "file browser does not allow upload over 512 KB (#687)" },
{ "type": "fix", "text": "can't set memory swappiness when using Podman (#691)" },
{ "type": "fix", "text": "compose API negotiation fix (#692, #696)" },
{ "type": "fix", "text": "not deployed git stacks continue to show the Down action (#694)" },
{ "type": "fix", "text": "display time doesn't reflect time zone (#735)" },
{ "type": "fix", "text": "prune dangling images counter not working (#718)" },
{ "type": "fix", "text": "own PORT env not used in HEALTHCHECK (#745)" }
],
"imageTag": "fnsys/dockhand:v1.0.21"
},
{
"version": "1.0.20",
"date": "2026-03-02",
"changes": [
{ "type": "fix", "text": "regression on Synology DSM" },
{ "type": "fix", "text": "Fix ARM64 regression: Go collector crashing on Raspberry Pi and other ARM devices" },
{ "type": "fix", "text": "autoupdate hangs on \"waiting for Dockhand\"" }
],
"imageTag": "fnsys/dockhand:v1.0.20"
},
{
"version": "1.0.19",
"date": "2026-03-01",
"changes": [
{ "type": "feature", "text": "Inline logs panel on stacks page — view container logs without leaving the page" },
{ "type": "feature", "text": "Make ports column sortable in containers grid" },
{ "type": "feature", "text": "Structured auth logging with client IP (login/logout/MFA/OIDC events)" },
{ "type": "fix", "text": "Fix memory leak: TLS context accumulation for HTTPS environments (Bun)" },
{ "type": "fix", "text": "Fix security scanning on Docker with custom logging drivers (Loki, Fluentd, etc.)" },
{ "type": "fix", "text": "Fix grouped log viewer not auto-scrolling on new entries" },
{ "type": "fix", "text": "Fix container recreation error messages not surfacing actual Docker errors" },
{ "type": "fix", "text": "Fix LDAP group-to-role mapping" },
{ "type": "fix", "text": "Fix container file browser hiding old files" },
{ "type": "fix", "text": "Fix SSH key permission issues on NAS filesystems" },
{ "type": "fix", "text": "Fix binary file corruption when syncing stacks to Hawser agents" },
{ "type": "fix", "text": "Fix UI timeout issues for long running operations" }
],
"imageTag": "fnsys/dockhand:v1.0.19"
},
{
"version": "1.0.18",
"date": "2026-02-16",
"changes": [
{ "type": "feature", "text": "Dockhand self-update from the UI" },
{ "type": "feature", "text": "Show freed disk space after image removal and pruning" },
{ "type": "feature", "text": "Handle dynamically-spawned child containers in stack stop/down/restart/remove" },
{ "type": "feature", "text": "Git webhooks are logged to the audit log" },
{ "type": "feature", "text": "Add Mattermost notification support" },
{ "type": "feature", "text": "Configurable disk usage warning threshold per environment" },
{ "type": "fix", "text": "Fix file upload CSRF 403 error on plain HTTP deployments" },
{ "type": "fix", "text": "Fix scanner container /wait timeout causing empty scan output" },
{ "type": "fix", "text": "Fix saving adopted external stack failing with 'Stack directory not found'" },
{ "type": "fix", "text": "Add Bearer token auth support for ntfy notifications" },
{ "type": "fix", "text": "Fix git SSH failing with 'No user exists for uid' with arbitrary UIDs" },
{ "type": "fix", "text": "Fix command palette flooding API requests on open" },
{ "type": "fix", "text": "Normalize stack names when adopting to prevent uppercase rejection" },
{ "type": "fix", "text": "Fix container update failing for shared network modes (container:<name>, host, none)" }
],
"imageTag": "fnsys/dockhand:v1.0.18"
},
{
"version": "1.0.17",
"date": "2026-02-09",
"changes": [
{ "type": "fix", "text": "Fix scanner failure on rootless Docker" },
{ "type": "fix", "text": "Increase Hawser compose operation timeout" },
{ "type": "fix", "text": "Fix regression in stack container updates" }
],
"imageTag": "fnsys/dockhand:v1.0.17"
},
{
"version": "1.0.16",
"date": "2026-02-09",
+290 -8
View File
@@ -5,6 +5,12 @@
"license": "MIT",
"repository": "https://github.com/codemirror/autocomplete"
},
{
"name": "@codemirror/commands",
"version": "6.10.0",
"license": "MIT",
"repository": "https://github.com/codemirror/commands"
},
{
"name": "@codemirror/commands",
"version": "6.10.1",
@@ -215,12 +221,24 @@
"license": "MIT",
"repository": "https://github.com/paulmillr/noble-hashes"
},
{
"name": "@phc/format",
"version": "1.0.0",
"license": "MIT",
"repository": "https://github.com/simonepri/phc-format"
},
{
"name": "@sveltejs/acorn-typescript",
"version": "1.0.8",
"license": "MIT",
"repository": "https://github.com/sveltejs/acorn-typescript"
},
{
"name": "@types/better-sqlite3",
"version": "7.6.13",
"license": "MIT",
"repository": "https://github.com/DefinitelyTyped/DefinitelyTyped"
},
{
"name": "@types/estree",
"version": "1.0.8",
@@ -233,6 +251,12 @@
"license": "MIT",
"repository": "https://github.com/DefinitelyTyped/DefinitelyTyped"
},
{
"name": "@types/trusted-types",
"version": "2.0.7",
"license": "MIT",
"repository": "https://github.com/DefinitelyTyped/DefinitelyTyped"
},
{
"name": "acorn",
"version": "8.15.0",
@@ -251,6 +275,18 @@
"license": "MIT",
"repository": "https://github.com/chalk/ansi-styles"
},
{
"name": "ansi_up",
"version": "6.0.6",
"license": "MIT",
"repository": "https://github.com/drudru/ansi_up"
},
{
"name": "argon2",
"version": "0.41.1",
"license": "MIT",
"repository": "https://github.com/ranisalt/node-argon2"
},
{
"name": "argparse",
"version": "2.0.1",
@@ -259,7 +295,7 @@
},
{
"name": "aria-query",
"version": "5.3.2",
"version": "5.3.1",
"license": "Apache-2.0",
"repository": "https://github.com/A11yance/aria-query"
},
@@ -269,6 +305,36 @@
"license": "Apache-2.0",
"repository": "https://github.com/A11yance/axobject-query"
},
{
"name": "base64-js",
"version": "1.5.1",
"license": "MIT",
"repository": "https://github.com/beatgammit/base64-js"
},
{
"name": "better-sqlite3",
"version": "11.10.0",
"license": "MIT",
"repository": "https://github.com/WiseLibs/better-sqlite3"
},
{
"name": "bindings",
"version": "1.5.0",
"license": "MIT",
"repository": "https://github.com/TooTallNate/node-bindings"
},
{
"name": "bl",
"version": "4.1.0",
"license": "MIT",
"repository": "https://github.com/rvagg/bl"
},
{
"name": "buffer",
"version": "5.7.1",
"license": "MIT",
"repository": "https://github.com/feross/buffer"
},
{
"name": "bun-types",
"version": "1.3.6",
@@ -281,6 +347,12 @@
"license": "MIT",
"repository": "https://github.com/sindresorhus/camelcase"
},
{
"name": "chownr",
"version": "1.1.4",
"license": "ISC",
"repository": "https://github.com/isaacs/chownr"
},
{
"name": "cliui",
"version": "6.0.0",
@@ -335,9 +407,33 @@
"license": "MIT",
"repository": "https://github.com/sindresorhus/decamelize"
},
{
"name": "decompress-response",
"version": "6.0.0",
"license": "MIT",
"repository": "https://github.com/sindresorhus/decompress-response"
},
{
"name": "deep-extend",
"version": "0.6.0",
"license": "MIT",
"repository": "https://github.com/unclechu/node-deep-extend"
},
{
"name": "detect-libc",
"version": "2.1.2",
"license": "Apache-2.0",
"repository": "https://github.com/lovell/detect-libc"
},
{
"name": "devalue",
"version": "5.6.2",
"version": "5.6.3",
"license": "MIT",
"repository": "https://github.com/sveltejs/devalue"
},
{
"name": "devalue",
"version": "5.6.4",
"license": "MIT",
"repository": "https://github.com/sveltejs/devalue"
},
@@ -349,7 +445,7 @@
},
{
"name": "dockhand",
"version": "1.0.3",
"version": "1.0.18",
"license": "UNLICENSED",
"repository": null
},
@@ -365,6 +461,12 @@
"license": "MIT",
"repository": "https://github.com/mathiasbynens/emoji-regex"
},
{
"name": "end-of-stream",
"version": "1.4.5",
"license": "MIT",
"repository": "https://github.com/mafintosh/end-of-stream"
},
{
"name": "esm-env",
"version": "1.2.2",
@@ -373,16 +475,34 @@
},
{
"name": "esrap",
"version": "2.2.1",
"version": "2.2.3",
"license": "MIT",
"repository": "https://github.com/sveltejs/esrap"
},
{
"name": "expand-template",
"version": "2.0.3",
"license": "(MIT OR WTFPL)",
"repository": "https://github.com/ralphtheninja/expand-template"
},
{
"name": "file-uri-to-path",
"version": "1.0.0",
"license": "MIT",
"repository": "https://github.com/TooTallNate/file-uri-to-path"
},
{
"name": "find-up",
"version": "4.1.0",
"license": "MIT",
"repository": "https://github.com/sindresorhus/find-up"
},
{
"name": "fs-constants",
"version": "1.0.0",
"license": "MIT",
"repository": "https://github.com/mafintosh/fs-constants"
},
{
"name": "get-caller-file",
"version": "2.0.5",
@@ -390,10 +510,28 @@
"repository": "https://github.com/stefanpenner/get-caller-file"
},
{
"name": "hash-wasm",
"version": "4.12.0",
"name": "github-from-package",
"version": "0.0.0",
"license": "MIT",
"repository": "https://github.com/Daninet/hash-wasm"
"repository": "https://github.com/substack/github-from-package"
},
{
"name": "ieee754",
"version": "1.2.1",
"license": "BSD-3-Clause",
"repository": "https://github.com/feross/ieee754"
},
{
"name": "inherits",
"version": "2.0.4",
"license": "ISC",
"repository": "https://github.com/isaacs/inherits"
},
{
"name": "ini",
"version": "1.3.8",
"license": "ISC",
"repository": "https://github.com/isaacs/ini"
},
{
"name": "is-fullwidth-code-point",
@@ -437,12 +575,60 @@
"license": "MIT",
"repository": "https://github.com/Rich-Harris/magic-string"
},
{
"name": "mimic-response",
"version": "3.1.0",
"license": "MIT",
"repository": "https://github.com/sindresorhus/mimic-response"
},
{
"name": "minimist",
"version": "1.2.8",
"license": "MIT",
"repository": "https://github.com/minimistjs/minimist"
},
{
"name": "mkdirp-classic",
"version": "0.5.3",
"license": "MIT",
"repository": "https://github.com/mafintosh/mkdirp-classic"
},
{
"name": "napi-build-utils",
"version": "2.0.0",
"license": "MIT",
"repository": "https://github.com/inspiredware/napi-build-utils"
},
{
"name": "node-abi",
"version": "3.87.0",
"license": "MIT",
"repository": "https://github.com/electron/node-abi"
},
{
"name": "node-addon-api",
"version": "8.5.0",
"license": "MIT",
"repository": "https://github.com/nodejs/node-addon-api"
},
{
"name": "node-gyp-build",
"version": "4.8.4",
"license": "MIT",
"repository": "https://github.com/prebuild/node-gyp-build"
},
{
"name": "nodemailer",
"version": "7.0.12",
"license": "MIT-0",
"repository": "https://github.com/nodemailer/nodemailer"
},
{
"name": "once",
"version": "1.4.0",
"license": "ISC",
"repository": "https://github.com/isaacs/once"
},
{
"name": "otpauth",
"version": "9.4.1",
@@ -485,6 +671,18 @@
"license": "Unlicense",
"repository": "https://github.com/porsager/postgres"
},
{
"name": "prebuild-install",
"version": "7.1.3",
"license": "MIT",
"repository": "https://github.com/prebuild/prebuild-install"
},
{
"name": "pump",
"version": "3.0.3",
"license": "MIT",
"repository": "https://github.com/mafintosh/pump"
},
{
"name": "punycode",
"version": "2.3.1",
@@ -497,6 +695,18 @@
"license": "MIT",
"repository": "https://github.com/soldair/node-qrcode"
},
{
"name": "rc",
"version": "1.2.8",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"repository": "https://github.com/dominictarr/rc"
},
{
"name": "readable-stream",
"version": "3.6.2",
"license": "MIT",
"repository": "https://github.com/nodejs/readable-stream"
},
{
"name": "require-directory",
"version": "2.1.1",
@@ -515,12 +725,36 @@
"license": "MIT",
"repository": "https://github.com/svecosystem/runed"
},
{
"name": "safe-buffer",
"version": "5.2.1",
"license": "MIT",
"repository": "https://github.com/feross/safe-buffer"
},
{
"name": "semver",
"version": "7.7.4",
"license": "ISC",
"repository": "https://github.com/npm/node-semver"
},
{
"name": "set-blocking",
"version": "2.0.0",
"license": "ISC",
"repository": "https://github.com/yargs/set-blocking"
},
{
"name": "simple-concat",
"version": "1.0.1",
"license": "MIT",
"repository": "https://github.com/feross/simple-concat"
},
{
"name": "simple-get",
"version": "4.0.1",
"license": "MIT",
"repository": "https://github.com/feross/simple-get"
},
{
"name": "strict-event-emitter-types",
"version": "2.0.0",
@@ -533,12 +767,24 @@
"license": "MIT",
"repository": "https://github.com/sindresorhus/string-width"
},
{
"name": "string_decoder",
"version": "1.3.0",
"license": "MIT",
"repository": "https://github.com/nodejs/string_decoder"
},
{
"name": "strip-ansi",
"version": "6.0.1",
"license": "MIT",
"repository": "https://github.com/chalk/strip-ansi"
},
{
"name": "strip-json-comments",
"version": "2.0.1",
"license": "MIT",
"repository": "https://github.com/sindresorhus/strip-json-comments"
},
{
"name": "style-mod",
"version": "4.1.3",
@@ -547,7 +793,7 @@
},
{
"name": "svelte",
"version": "5.46.4",
"version": "5.53.5",
"license": "MIT",
"repository": "https://github.com/sveltejs/svelte"
},
@@ -563,18 +809,42 @@
"license": "MIT",
"repository": "https://github.com/wobsoriano/svelte-sonner"
},
{
"name": "tar-fs",
"version": "2.1.4",
"license": "MIT",
"repository": "https://github.com/mafintosh/tar-fs"
},
{
"name": "tar-stream",
"version": "2.2.0",
"license": "MIT",
"repository": "https://github.com/mafintosh/tar-stream"
},
{
"name": "tr46",
"version": "6.0.0",
"license": "MIT",
"repository": "https://github.com/jsdom/tr46"
},
{
"name": "tunnel-agent",
"version": "0.6.0",
"license": "Apache-2.0",
"repository": "https://github.com/mikeal/tunnel-agent"
},
{
"name": "undici-types",
"version": "7.16.0",
"license": "MIT",
"repository": "https://github.com/nodejs/undici"
},
{
"name": "util-deprecate",
"version": "1.0.2",
"license": "MIT",
"repository": "https://github.com/TooTallNate/util-deprecate"
},
{
"name": "w3c-keyname",
"version": "2.2.8",
@@ -605,6 +875,18 @@
"license": "MIT",
"repository": "https://github.com/chalk/wrap-ansi"
},
{
"name": "wrappy",
"version": "1.0.2",
"license": "ISC",
"repository": "https://github.com/npm/wrappy"
},
{
"name": "ws",
"version": "8.19.0",
"license": "MIT",
"repository": "https://github.com/websockets/ws"
},
{
"name": "y18n",
"version": "4.0.3",
+17 -5
View File
@@ -5,6 +5,9 @@ const DEFAULT_MOBILE_BREAKPOINT = 768;
export class IsMobile {
#breakpoint: number;
#current = $state(false);
#handleResize: (() => void) | null = null;
#handleMediaChange: ((e: MediaQueryListEvent) => void) | null = null;
#mql: MediaQueryList | null = null;
constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) {
this.#breakpoint = breakpoint;
@@ -14,22 +17,31 @@ export class IsMobile {
this.#current = window.innerWidth < this.#breakpoint;
// Listen for resize events
const handleResize = () => {
this.#handleResize = () => {
this.#current = window.innerWidth < this.#breakpoint;
};
window.addEventListener('resize', handleResize);
window.addEventListener('resize', this.#handleResize);
// Also use matchMedia for more reliable detection
const mql = window.matchMedia(`(max-width: ${this.#breakpoint - 1}px)`);
const handleMediaChange = (e: MediaQueryListEvent) => {
this.#mql = window.matchMedia(`(max-width: ${this.#breakpoint - 1}px)`);
this.#handleMediaChange = (e: MediaQueryListEvent) => {
this.#current = e.matches;
};
mql.addEventListener('change', handleMediaChange);
this.#mql.addEventListener('change', this.#handleMediaChange);
}
}
get current() {
return this.#current;
}
destroy() {
if (this.#handleResize) {
window.removeEventListener('resize', this.#handleResize);
}
if (this.#mql && this.#handleMediaChange) {
this.#mql.removeEventListener('change', this.#handleMediaChange);
}
}
}
+274
View File
@@ -0,0 +1,274 @@
/**
* 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;
}
+5 -3
View File
@@ -9,6 +9,7 @@ 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;
@@ -21,7 +22,8 @@ export interface AuditContext {
* Extract audit context from a request event
*/
export async function getAuditContext(event: RequestEvent): Promise<AuditContext> {
const auth = await authorize(event.cookies);
const ctx = getRequestContext();
const user = ctx?.user ?? (await authorize(event.cookies)).user;
// Get IP address from various headers (proxied requests)
const forwardedFor = event.request.headers.get('x-forwarded-for');
@@ -40,8 +42,8 @@ export async function getAuditContext(event: RequestEvent): Promise<AuditContext
const userAgent = event.request.headers.get('user-agent') || null;
return {
userId: auth.user?.id ?? null,
username: auth.user?.username ?? 'anonymous',
userId: user?.id ?? null,
username: user?.username ?? 'anonymous',
ipAddress,
userAgent
};
+138 -67
View File
@@ -2,16 +2,16 @@
* Core Authentication Module
*
* Security features:
* - Argon2id password hashing via Bun.password (memory-hard, timing-attack resistant)
* - Argon2id password hashing via argon2 (memory-hard, timing-attack resistant)
* - Cryptographically secure 32-byte random session tokens
* - HttpOnly cookies (prevents XSS from reading tokens)
* - Secure flag (HTTPS only in production)
* - Secure flag (protocol-aware: x-forwarded-proto or COOKIE_SECURE env var, default off)
* - SameSite=Strict (CSRF protection)
*/
import os from 'node:os';
import { secureRandomBytes, usingFallback } from './crypto-fallback';
import { argon2id, argon2Verify } from 'hash-wasm';
import { createHash } from 'node:crypto';
import argon2 from 'argon2';
import type { Cookies } from '@sveltejs/kit';
import {
getAuthSettings,
@@ -43,6 +43,8 @@ import {
} from './db';
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';
@@ -98,42 +100,25 @@ export interface LoginResult {
// Password Hashing (Argon2id)
// ============================================
// Argon2id parameters (matching Bun.password defaults)
// Argon2id parameters
const ARGON2_MEMORY_COST = 65536; // 64 MB in kibibytes
const ARGON2_TIME_COST = 3; // 3 iterations
const ARGON2_PARALLELISM = 1; // Single-threaded
const ARGON2_HASH_LENGTH = 32; // 256-bit output
const ARGON2_SALT_LENGTH = 16; // 128-bit salt
/**
* Hash a password using Argon2id
*
* On modern kernels (>=3.17): Uses Bun's native password API (faster)
* On old kernels (<3.17): Uses hash-wasm (WASM-based, no getrandom dependency)
*
* Argon2id is the recommended variant - resistant to both side-channel and GPU attacks
* Uses the argon2 npm package (C binding) for native performance.
* Returns PHC format: $argon2id$v=19$m=65536,t=3,p=1$...
*/
export async function hashPassword(password: string): Promise<string> {
// On old kernels, Bun.password.hash() crashes because it internally uses getrandom()
// Use hash-wasm as a fallback which is pure WASM and doesn't depend on the syscall
if (usingFallback()) {
const salt = secureRandomBytes(ARGON2_SALT_LENGTH);
return argon2id({
password,
salt,
iterations: ARGON2_TIME_COST,
parallelism: ARGON2_PARALLELISM,
memorySize: ARGON2_MEMORY_COST,
hashLength: ARGON2_HASH_LENGTH,
outputType: 'encoded' // Returns PHC format: $argon2id$v=19$m=65536,t=3,p=1$...
});
}
// Modern kernels: use Bun's native implementation (faster)
return Bun.password.hash(password, {
algorithm: 'argon2id',
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: ARGON2_MEMORY_COST,
timeCost: ARGON2_TIME_COST
timeCost: ARGON2_TIME_COST,
parallelism: ARGON2_PARALLELISM,
hashLength: ARGON2_HASH_LENGTH
});
}
@@ -141,22 +126,25 @@ export async function hashPassword(password: string): Promise<string> {
* Verify a password against a hash
* Uses constant-time comparison internally
*
* Both Bun.password and hash-wasm use the same PHC format, so hashes are compatible
* The argon2 npm package uses standard PHC format, compatible with existing hashes
*/
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
try {
// On old kernels, use hash-wasm for verification
if (usingFallback()) {
return await argon2Verify({ password, hash });
}
// Modern kernels: use Bun's native implementation
return await Bun.password.verify(password, hash);
} catch {
return await argon2.verify(hash, password);
} catch (e) {
console.error('[Auth] argon2.verify() threw unexpectedly:', e);
return false;
}
}
let dummyAuthHashCache: Promise<string> | null = null;
export function getDummyAuthHash(): Promise<string> {
if (!dummyAuthHashCache) {
dummyAuthHashCache = hashPassword(`dummy-${Math.random()}-${Date.now()}`);
}
return dummyAuthHashCache;
}
// ============================================
// Session Management
// ============================================
@@ -169,14 +157,43 @@ function generateSessionToken(): string {
return secureRandomBytes(32).toString('base64url');
}
/**
* Determine whether to set the Secure flag on the session cookie.
*
* Priority:
* 1. COOKIE_SECURE env var ('true' / 'false') explicit override
* 2. x-forwarded-proto header set by Traefik / Nginx / Caddy
* 3. false default, matches v1.0.18 Bun behavior
*
* Defaulting to false (not NODE_ENV) is intentional: Dockhand is commonly
* run over plain HTTP in homelabs. Setting Secure unconditionally in production
* causes a login loop when there is no HTTPS reverse proxy, because browsers
* silently discard Secure cookies on HTTP connections.
*
* Users behind an HTTPS reverse proxy get Secure cookies automatically via
* x-forwarded-proto. Users who terminate TLS in the app itself can opt in
* with COOKIE_SECURE=true.
*/
function isSecureContext(request?: Request): boolean {
if (process.env.COOKIE_SECURE === 'false') return false;
if (process.env.COOKIE_SECURE === 'true') return true;
if (request) {
const proto = request.headers.get('x-forwarded-proto');
if (proto === 'https') return true;
}
return false;
}
/**
* Create a new session for a user
* @param provider - Auth provider: 'local', or provider name like 'Keycloak', 'Azure AD', etc.
* @param request - Optional incoming request (used to detect HTTPS via x-forwarded-proto)
*/
export async function createUserSession(
userId: number,
provider: string,
cookies: Cookies
cookies: Cookies,
request?: Request
): Promise<Session> {
// Clean up expired sessions periodically
await deleteExpiredSessions();
@@ -198,7 +215,7 @@ export async function createUserSession(
const session = await dbCreateSession(sessionId, userId, provider, expiresAt);
// Set secure cookie
setSessionCookie(cookies, sessionId, sessionTimeout);
setSessionCookie(cookies, sessionId, sessionTimeout, request);
// Update user's last login time
await updateUser(userId, { lastLogin: new Date().toISOString() });
@@ -209,12 +226,12 @@ export async function createUserSession(
/**
* Set the session cookie with secure attributes
*/
function setSessionCookie(cookies: Cookies, sessionId: string, maxAge: number): void {
function setSessionCookie(cookies: Cookies, sessionId: string, maxAge: number, request?: Request): void {
cookies.set(SESSION_COOKIE_NAME, sessionId, {
path: '/',
httpOnly: true, // Prevents XSS attacks from reading cookie
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
sameSite: 'strict', // CSRF protection
secure: isSecureContext(request), // Protocol-aware: checks x-forwarded-proto or NODE_ENV
sameSite: 'lax', // Lax required for OIDC/SSO cross-site redirects
maxAge: maxAge // Session timeout in seconds
});
}
@@ -232,11 +249,22 @@ function getSessionIdFromCookies(cookies: Cookies): string | null {
export async function validateSession(cookies: Cookies): Promise<AuthenticatedUser | null> {
const sessionId = getSessionIdFromCookies(cookies);
if (!sessionId) return null;
return validateSessionById(sessionId);
}
/**
* Validate a session by raw session ID (without the SvelteKit Cookies object).
*
* Used by WebSocket upgrade handlers in server.js / vite.config.ts that only
* have a raw Cookie header string. Mirrors validateSession() semantics:
* returns the AuthenticatedUser on success, null on missing/expired/disabled.
*/
export async function validateSessionById(sessionId: string): Promise<AuthenticatedUser | null> {
if (!sessionId) return null;
const session = await dbGetSession(sessionId);
if (!session) return null;
// Check if session is expired
const expiresAt = new Date(session.expiresAt);
if (expiresAt < new Date()) {
await dbDeleteSession(sessionId);
@@ -249,6 +277,13 @@ export async function validateSession(cookies: Cookies): Promise<AuthenticatedUs
return await buildAuthenticatedUser(user, session.provider as 'local' | 'ldap' | 'oidc');
}
/**
* Cookie name used for browser session auth. Exported so raw header parsers
* (WebSocket upgrade handlers) can look it up without re-encoding the
* constant.
*/
export const SESSION_COOKIE = SESSION_COOKIE_NAME;
/**
* Destroy a session (logout)
*/
@@ -452,13 +487,14 @@ export async function authenticateLocal(
const user = await getUserByUsername(username);
if (!user) {
// Use constant time to prevent timing attacks
await hashPassword('dummy');
await verifyPassword(password, await getDummyAuthHash());
return { success: false, error: 'Invalid username or password' };
}
if (!user.isActive) {
return { success: false, error: 'Account is disabled' };
await verifyPassword(password, await getDummyAuthHash());
console.warn(`[Auth] Login attempt for disabled account: user=${username}`);
return { success: false, error: 'Invalid username or password' };
}
const validPassword = await verifyPassword(password, user.passwordHash);
@@ -563,6 +599,19 @@ export async function authenticateLdap(
return { success: false, error: 'Invalid username or password' };
}
/**
* Escape special characters in an LDAP filter value (RFC 4515).
* Prevents LDAP injection via wildcards or control characters.
*/
function escapeLdapFilterValue(value: string): string {
return value
.replace(/\\/g, '\\5c')
.replace(/\*/g, '\\2a')
.replace(/\(/g, '\\28')
.replace(/\)/g, '\\29')
.replace(/\0/g, '\\00');
}
/**
* Try authentication against a specific LDAP configuration
*/
@@ -585,8 +634,10 @@ async function tryLdapAuth(
await client.bind(config.bindDn, config.bindPassword);
}
// Search for the user
const filter = config.userFilter.replace('{{username}}', username);
// Escape the username before interpolating into the LDAP filter (RFC 4515)
// to prevent LDAP injection via wildcards or special characters.
const safeUsername = escapeLdapFilterValue(username);
const filter = config.userFilter.replace('{{username}}', safeUsername);
const { searchEntries } = await client.search(config.baseDn, {
scope: 'sub',
filter: filter,
@@ -599,9 +650,11 @@ async function tryLdapAuth(
]
});
// Use a single generic error for both "not found" and "wrong password"
// to avoid leaking whether a username exists via response content or timing.
if (searchEntries.length === 0) {
await client.unbind();
return { success: false, error: 'User not found' };
return { success: false, error: 'Invalid username or password' };
}
const userEntry = searchEntries[0];
@@ -666,11 +719,11 @@ async function tryLdapAuth(
if (adminRole) {
const hasAdminRole = await userHasAdminRole(user.id);
if (shouldBeAdmin && !hasAdminRole) {
// Assign Admin role
await assignUserRole(user.id, adminRole.id, null);
} else if (!shouldBeAdmin && hasAdminRole && config.adminGroup) {
// Remove Admin role if user is no longer in admin group
await removeUserRole(user.id, adminRole.id);
}
// Note: We don't remove Admin role if not in LDAP group anymore
// to prevent accidental lockouts (same behavior as before)
}
// Process role mappings (Enterprise feature)
@@ -683,18 +736,36 @@ async function tryLdapAuth(
const userExistingRoles = await getUserRoles(user.id);
const existingRoleIds = new Set(userExistingRoles.map(r => r.roleId));
for (const mapping of roleMappings) {
// Skip if user already has this role
if (existingRoleIds.has(mapping.roleId)) continue;
// All role IDs referenced in mappings (these are LDAP-managed)
const mappedRoleIds = new Set(roleMappings.map(m => m.roleId));
// Check if user is a member of the LDAP group
// Determine which mapped roles user should have based on current group membership
const shouldHaveRoleIds = new Set<number>();
for (const mapping of roleMappings) {
const isInGroup = await checkLdapGroupMembership(config, userDn, mapping.groupDn);
if (isInGroup) {
await assignUserRole(user.id, mapping.roleId, undefined);
shouldHaveRoleIds.add(mapping.roleId);
}
}
// Add roles user should have but doesn't
for (const roleId of shouldHaveRoleIds) {
if (!existingRoleIds.has(roleId)) {
await assignUserRole(user.id, roleId, undefined);
}
}
// Remove mapped roles user has but shouldn't
for (const roleId of mappedRoleIds) {
if (existingRoleIds.has(roleId) && !shouldHaveRoleIds.has(roleId)) {
await removeUserRole(user.id, roleId);
}
}
}
// Clear cached token permissions after role sync
invalidateTokenCacheForUser(user.id);
if (!user.isActive) {
return { success: false, error: 'Account is disabled' };
}
@@ -744,8 +815,9 @@ async function checkLdapGroupMembership(
let groupFilter: string;
if (config.groupFilter) {
// User provided custom filter
searchBase = config.groupBaseDn || groupDnOrName;
// User provided custom filter - use group DN directly when it's a full DN
// to avoid searching all groups under groupBaseDn
searchBase = isFullDn ? groupDnOrName : (config.groupBaseDn || groupDnOrName);
groupFilter = config.groupFilter
.replace('{{username}}', userDn)
.replace('{{user_dn}}', userDn)
@@ -765,7 +837,7 @@ async function checkLdapGroupMembership(
}
const { searchEntries } = await client.search(searchBase, {
scope: isFullDn && !config.groupFilter ? 'base' : 'sub',
scope: isFullDn ? 'base' : 'sub',
filter: groupFilter,
sizeLimit: 1
});
@@ -825,9 +897,7 @@ function generateBackupCodes(): string[] {
async function hashBackupCode(code: string): Promise<string> {
// Normalize: uppercase, remove spaces and dashes
const normalized = code.toUpperCase().replace(/[\s-]/g, '');
const hasher = new Bun.CryptoHasher('sha256');
hasher.update(normalized);
return hasher.digest('hex');
return createHash('sha256').update(normalized).digest('hex');
}
/**
@@ -1172,9 +1242,7 @@ async function getOidcDiscovery(issuerUrl: string): Promise<OidcDiscoveryDocumen
*/
function generatePkce(): { codeVerifier: string; codeChallenge: string } {
const codeVerifier = secureRandomBytes(32).toString('base64url');
const hasher = new Bun.CryptoHasher('sha256');
hasher.update(codeVerifier);
const codeChallenge = hasher.digest('base64url') as string;
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
return { codeVerifier, codeChallenge };
}
@@ -1412,6 +1480,9 @@ export async function handleOidcCallback(
}
}
// Clear cached token permissions after role sync
invalidateTokenCacheForUser(user.id);
if (!user.isActive) {
return { success: false, error: 'Account is disabled' };
}
+11 -7
View File
@@ -40,6 +40,7 @@ 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 */
@@ -113,7 +114,10 @@ export interface AuthorizationContext {
export async function authorize(cookies: Cookies): Promise<AuthorizationContext> {
const authEnabled = await isAuthEnabled();
const enterprise = await isEnterprise();
const user = authEnabled ? await validateSession(cookies) : null;
// Try request context first (set by hook — handles both cookie and Bearer)
const reqCtx = getRequestContext();
const user = reqCtx?.user ?? (authEnabled ? await validateSession(cookies) : null);
// Determine admin status:
// - Free edition: all authenticated users are effectively admins (full access)
@@ -155,8 +159,8 @@ export async function authorize(cookies: Cookies): Promise<AuthorizationContext>
// Must be authenticated
if (!user) return false;
// Admins can access all environments
if (user.isAdmin) return true;
// Admins can access all environments (use fresh isAdmin, not cached user.isAdmin)
if (isAdmin) return true;
// In free edition, all authenticated users have full access
if (!enterprise) return true;
@@ -172,8 +176,8 @@ export async function authorize(cookies: Cookies): Promise<AuthorizationContext>
// Must be authenticated
if (!user) return [];
// Admins can access all environments
if (user.isAdmin) return null;
// Admins can access all environments (use fresh isAdmin, not cached user.isAdmin)
if (isAdmin) return null;
// In free edition, all authenticated users have full access
if (!enterprise) return null;
@@ -189,8 +193,8 @@ export async function authorize(cookies: Cookies): Promise<AuthorizationContext>
// Must be authenticated
if (!user) return false;
// Admins can always manage users
if (user.isAdmin) return true;
// Admins can always manage users (use fresh isAdmin, not cached user.isAdmin)
if (isAdmin) return true;
// In free edition, all authenticated users have full access
if (!enterprise) return true;
+41
View File
@@ -0,0 +1,41 @@
/**
* Resolve the client IP for rate limiting, logging, and audit.
*
* Defaults to the socket-level IP via getClientAddress(). X-Forwarded-For
* is consulted only when TRUST_FORWARDED_HEADERS=true is set explicitly
* intended for deployments behind a reverse proxy (Traefik, nginx, Caddy)
* that controls XFF. In that mode the right-most XFF entry (closest to the
* trusted proxy) is returned; earlier entries in the chain are ignored.
*/
type IpEventLike = {
request: Request;
getClientAddress?: () => string;
};
function normalize(ip: string | null | undefined): string {
if (!ip) return 'unknown';
if (ip === '::1' || ip === '::ffff:127.0.0.1') return '127.0.0.1';
if (ip.startsWith('::ffff:')) return ip.substring(7);
return ip;
}
export function getClientIp(event: IpEventLike): string {
if (process.env.TRUST_FORWARDED_HEADERS === 'true') {
const xff = event.request.headers.get('x-forwarded-for');
if (xff) {
const parts = xff.split(',').map((p) => p.trim()).filter(Boolean);
if (parts.length > 0) return normalize(parts[parts.length - 1]);
}
const realIp = event.request.headers.get('x-real-ip');
if (realIp) return normalize(realIp.trim());
}
try {
const addr = event.getClientAddress?.();
if (addr) return normalize(addr);
} catch {
// getClientAddress may throw if unavailable (test contexts, raw upgrades)
}
return 'unknown';
}
@@ -0,0 +1,72 @@
/**
* 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
@@ -0,0 +1,116 @@
/**
* 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),
};
}
+1 -1
View File
@@ -2,7 +2,7 @@
* Crypto Fallback for Old Linux Kernels
*
* The getrandom() syscall was added in Linux 3.17. On older kernels (like 3.10.x),
* Bun's built-in crypto functions will fail with "getrandom() failed to provide entropy".
* Node.js crypto functions may fail with "getrandom() failed to provide entropy".
*
* This module provides fallback implementations that read from /dev/urandom directly
* when running on kernels older than 3.17.
+347 -62
View File
@@ -7,7 +7,6 @@
import {
db,
rawClient,
isPostgres,
isSqlite,
eq,
@@ -79,6 +78,7 @@ 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 +113,7 @@ export function initDatabase() {
// =============================================================================
export async function getEnvironments(): Promise<Environment[]> {
const results = await db.select().from(environments).orderBy(asc(environments.name));
const results = await db.select().from(environments).orderBy(sql`lower(${environments.name})`);
return results.map((e: Environment) => ({
...e,
tlsKey: decrypt(e.tlsKey),
@@ -200,6 +200,10 @@ export async function deleteEnvironment(id: number): Promise<boolean> {
const env = await getEnvironment(id);
if (!env) return false;
// Clean up in-memory metrics
const { clearEnvironmentMetrics } = await import('./metrics-store.js');
clearEnvironmentMetrics(id);
// Clean up related records that don't have cascade delete defined
try {
await db.delete(hostMetrics).where(eq(hostMetrics.environmentId, id));
@@ -384,15 +388,17 @@ export async function getUserThemePreferences(userId: number): Promise<{
gridFontSize: string;
terminalFont: string;
editorFont: string;
animateIcons: boolean;
}> {
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont] = await Promise.all([
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, animateIcons] = 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, 'editor_font'),
getUserSetting(userId, 'animate_icons')
]);
return {
lightTheme: lightTheme || 'default',
@@ -401,13 +407,15 @@ export async function getUserThemePreferences(userId: number): Promise<{
fontSize: fontSize || 'normal',
gridFontSize: gridFontSize || 'normal',
terminalFont: terminalFont || 'system-mono',
editorFont: editorFont || 'system-mono'
editorFont: editorFont || 'system-mono',
// Default ON — only false when explicitly stored
animateIcons: animateIcons === 'false' ? false : true
};
}
export async function setUserThemePreferences(
userId: number,
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string }
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean }
): Promise<void> {
const updates: Promise<void>[] = [];
if (prefs.lightTheme !== undefined) {
@@ -431,6 +439,9 @@ 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);
}
@@ -576,45 +587,28 @@ export async function saveHostMetric(
memoryPercent: number,
memoryUsed: number,
memoryTotal: number,
environmentId?: number
environmentId?: number,
_skipEnvCheck = false
): Promise<void> {
// Verify environment exists before inserting (avoids FK violations on deleted envs)
if (environmentId) {
const env = await getEnvironment(environmentId);
if (!env) return;
}
await db.insert(hostMetrics).values({
environmentId: environmentId || null,
cpuPercent,
memoryPercent,
memoryUsed,
memoryTotal
});
// Cleanup old metrics (keep last 24 hours)
const cutoff24h = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
await db.delete(hostMetrics).where(sql`timestamp < ${cutoff24h}`);
// Delegated to in-memory ring buffer (no DB writes)
if (!environmentId) return;
const { pushMetric } = await import('./metrics-store.js');
pushMetric(environmentId, cpuPercent, memoryPercent, memoryUsed, memoryTotal);
}
export async function getHostMetrics(limit = 60, environmentId?: number): Promise<HostMetric[]> {
if (environmentId) {
return db.select().from(hostMetrics)
.where(eq(hostMetrics.environmentId, environmentId))
.orderBy(desc(hostMetrics.timestamp))
.limit(limit);
const { getMetricsHistory } = await import('./metrics-store.js');
// getMetricsHistory returns oldest-first, but callers expect newest-first
return getMetricsHistory(environmentId, limit).reverse();
}
return db.select().from(hostMetrics)
.orderBy(desc(hostMetrics.timestamp))
.limit(limit);
const { getAllMetrics } = await import('./metrics-store.js');
return getAllMetrics(limit);
}
export async function getLatestHostMetrics(environmentId: number): Promise<HostMetric | null> {
const results = await db.select().from(hostMetrics)
.where(eq(hostMetrics.environmentId, environmentId))
.orderBy(desc(hostMetrics.timestamp))
.limit(1);
return results[0] ?? null;
const { getLatestMetric } = await import('./metrics-store.js');
return getLatestMetric(environmentId);
}
// =============================================================================
@@ -779,6 +773,7 @@ export const NOTIFICATION_EVENT_TYPES = [
{ id: 'container_restarted', label: 'Container restarted', description: 'When a container restarts (manual or automatic)', group: 'container', scope: 'environment' },
{ id: 'container_exited', label: 'Container exited', description: 'When a container exits unexpectedly', group: 'container', scope: 'environment' },
{ id: 'container_unhealthy', label: 'Container unhealthy', description: 'When a container health check fails', group: 'container', scope: 'environment' },
{ id: 'container_healthy', label: 'Container healthy', description: 'When a container health check recovers', group: 'container', scope: 'environment' },
{ id: 'container_oom', label: 'Out of memory', description: 'When a container is killed due to out of memory', group: 'container', scope: 'environment' },
{ id: 'container_updated', label: 'Container updated', description: 'When a container image is updated', group: 'container', scope: 'environment' },
{ id: 'image_pulled', label: 'Image pulled', description: 'When a new image is pulled', group: 'container', scope: 'environment' },
@@ -832,6 +827,13 @@ export const ENVIRONMENT_NOTIFICATION_EVENTS = NOTIFICATION_EVENT_TYPES.filter(e
export type NotificationEventType = typeof NOTIFICATION_EVENT_TYPES[number]['id'];
const environmentEventIds = new Set(ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id));
/** Strip system-scoped events (e.g. license_expiring) from environment notification records */
function filterEnvironmentEventTypes(eventTypes: string[]): string[] {
return eventTypes.filter(id => environmentEventIds.has(id));
}
export interface NotificationSettingData {
id: number;
type: 'smtp' | 'apprise';
@@ -995,7 +997,7 @@ export async function getEnvironmentNotifications(environmentId: number): Promis
return rows.map((row: any) => ({
...row,
eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id)
eventTypes: filterEnvironmentEventTypes(row.eventTypes ? JSON.parse(row.eventTypes) : ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id))
})) as EnvironmentNotificationData[];
}
@@ -1022,7 +1024,7 @@ export async function getEnvironmentNotification(environmentId: number, notifica
if (!rows[0]) return null;
return {
...rows[0],
eventTypes: rows[0].eventTypes ? JSON.parse(rows[0].eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id)
eventTypes: filterEnvironmentEventTypes(rows[0].eventTypes ? JSON.parse(rows[0].eventTypes) : ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id))
} as EnvironmentNotificationData;
}
@@ -1032,7 +1034,7 @@ export async function createEnvironmentNotification(data: {
enabled?: boolean;
eventTypes?: NotificationEventType[];
}): Promise<EnvironmentNotificationData> {
const eventTypes = data.eventTypes || NOTIFICATION_EVENT_TYPES.map(e => e.id);
const eventTypes = data.eventTypes || ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id);
await db.insert(environmentNotifications).values({
environmentId: data.environmentId,
notificationId: data.notificationId,
@@ -1100,7 +1102,7 @@ export async function getEnabledEnvironmentNotifications(
return rows
.map(row => ({
...row,
eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id),
eventTypes: filterEnvironmentEventTypes(row.eventTypes ? JSON.parse(row.eventTypes) : ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id)),
config: decryptNotificationConfig(row.channelType ?? 'apprise', row.config)
}))
.filter(row => !eventType || row.eventTypes.includes(eventType)) as (EnvironmentNotificationData & { config: any })[];
@@ -1191,6 +1193,37 @@ 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);
@@ -2032,7 +2065,16 @@ export async function updateGitRepository(id: number, data: Partial<GitRepositor
return getGitRepository(id);
}
export async function getGitStacksByRepositoryId(repositoryId: number): Promise<Array<{ id: number; stackName: string; environmentId: number | null }>> {
return db.select({
id: gitStacks.id,
stackName: gitStacks.stackName,
environmentId: gitStacks.environmentId
}).from(gitStacks).where(eq(gitStacks.repositoryId, repositoryId));
}
export async function deleteGitRepository(id: number): Promise<boolean> {
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;
}
@@ -2053,10 +2095,16 @@ 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;
}
@@ -2086,6 +2134,11 @@ 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,
@@ -2114,6 +2167,11 @@ 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,
@@ -2142,6 +2200,11 @@ 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,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
@@ -2172,6 +2235,11 @@ 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,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2200,6 +2268,11 @@ 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,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
@@ -2229,10 +2302,16 @@ 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,
@@ -2258,10 +2337,16 @@ 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: {
@@ -2287,6 +2372,11 @@ 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,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2321,6 +2411,11 @@ 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,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
@@ -2350,6 +2445,11 @@ 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,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2379,6 +2479,11 @@ 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,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
@@ -2406,6 +2511,11 @@ 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> {
const result = await db.insert(gitStacks).values({
stackName: data.stackName,
@@ -2413,11 +2523,16 @@ 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
webhookSecret: data.webhookSecret || null,
buildOnDeploy: data.buildOnDeploy ?? false,
noBuildCache: data.noBuildCache ?? false,
repullImages: data.repullImages ?? false,
forceRedeploy: data.forceRedeploy ?? false
}).returning();
return getGitStack(result[0].id) as Promise<GitStackWithRepo>;
}
@@ -2434,16 +2549,23 @@ 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;
}
@@ -2468,6 +2590,11 @@ 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,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2495,6 +2622,11 @@ 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,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
@@ -2523,6 +2655,11 @@ 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,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2549,6 +2686,11 @@ 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,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
@@ -2691,11 +2833,21 @@ 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: data.gitRepositoryId || null,
gitStackId: data.gitStackId || null,
gitRepositoryId: newRepoId,
gitStackId: newStackId,
composePath: data.composePath ?? null,
envPath: data.envPath ?? null,
updatedAt: new Date().toISOString()
@@ -2703,6 +2855,7 @@ 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,
@@ -2736,6 +2889,7 @@ 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(
@@ -2982,12 +3136,12 @@ export async function deleteOldScans(keepDays = 30): Promise<number> {
export type AuditAction =
| 'create' | 'update' | 'delete' | 'start' | 'stop' | 'restart' | 'down'
| 'pause' | 'unpause' | 'pull' | 'push' | 'prune' | 'login'
| 'logout' | 'view' | 'exec' | 'connect' | 'disconnect' | 'deploy' | 'sync' | 'rename';
| 'logout' | 'view' | 'exec' | 'connect' | 'disconnect' | 'deploy' | 'sync' | 'rename' | 'webhook';
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';
| 'config_set' | 'notification' | 'oidc_provider' | 'ldap_config' | 'git_stack' | 'api_token';
export interface AuditLogData {
id: number;
@@ -3103,14 +3257,16 @@ 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) {
// Get environments that have ANY of the specified labels
const labelFilterMode = await getSetting('label_filter_mode') ?? 'any';
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 filters.labels!.some(label => envLabels.includes(label));
return labelFilterMode === 'all'
? filters.labels!.every(label => envLabels.includes(label))
: filters.labels!.some(label => envLabels.includes(label));
} catch {
return false;
}
@@ -3268,20 +3424,23 @@ export interface ContainerEventResult {
offset: number;
}
export async function logContainerEvent(data: ContainerEventCreateData): Promise<ContainerEventData> {
// Timestamp is already an ISO-8601 string from event-subprocess
// Both SQLite and PostgreSQL schemas use mode: 'string' so we pass it directly
const result = await db.insert(containerEvents).values({
export async function logContainerEvent(
data: ContainerEventCreateData
): Promise<ContainerEventData> {
const attrs = data.actorAttributes ? JSON.stringify(data.actorAttributes) : null;
const [inserted] = await db.insert(containerEvents).values({
environmentId: data.environmentId ?? null,
containerId: data.containerId,
containerName: data.containerName ?? null,
image: data.image ?? null,
action: data.action,
actorAttributes: data.actorAttributes ? JSON.stringify(data.actorAttributes) : null,
actorAttributes: attrs,
timestamp: data.timestamp
}).returning();
}).returning({ id: containerEvents.id });
return getContainerEvent(result[0].id) as Promise<ContainerEventData>;
const event = await getContainerEvent(inserted.id);
return event!;
}
export async function getContainerEvent(id: number): Promise<ContainerEventData | undefined> {
@@ -3315,14 +3474,16 @@ 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) {
// Get environments that have ANY of the specified labels
const labelFilterMode = await getSetting('label_filter_mode') ?? 'any';
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 filters.labels!.some(label => envLabels.includes(label));
return labelFilterMode === 'all'
? filters.labels!.every(label => envLabels.includes(label))
: filters.labels!.some(label => envLabels.includes(label));
} catch {
return false;
}
@@ -3442,9 +3603,15 @@ 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();
await db.delete(containerEvents)
const countResult = await db.select({ count: sql<number>`count(*)` })
.from(containerEvents)
.where(sql`timestamp < ${cutoffDate}`);
return 0;
const count = Number(countResult[0]?.count ?? 0);
if (count > 0) {
await db.delete(containerEvents)
.where(sql`timestamp < ${cutoffDate}`);
}
return count;
}
/**
@@ -3932,9 +4099,15 @@ 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 result = await db.delete(scheduleExecutions)
const countResult = await db.select({ count: sql<number>`count(*)` })
.from(scheduleExecutions)
.where(sql`triggered_at < ${cutoffDate}`);
return 0; // SQLite/PG don't return count consistently
const count = Number(countResult[0]?.count ?? 0);
if (count > 0) {
await db.delete(scheduleExecutions)
.where(sql`triggered_at < ${cutoffDate}`);
}
return count;
}
// Settings helpers for retention
@@ -3945,8 +4118,11 @@ 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));
@@ -4080,6 +4256,50 @@ 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
// =============================================================================
@@ -4114,6 +4334,17 @@ export async function setExternalStackPaths(paths: string[]): Promise<void> {
}
}
/**
* Idempotently add a directory to the external stack paths allowlist.
* Returns true if the path was newly added (false if already present).
*/
export async function addExternalStackPath(dir: string): Promise<boolean> {
const current = await getExternalStackPaths();
if (current.includes(dir)) return false;
await setExternalStackPaths([...current, dir]);
return true;
}
// =============================================================================
// PRIMARY STACK LOCATION
// =============================================================================
@@ -4501,11 +4732,16 @@ export async function setStackEnvVars(
));
}
// Insert new vars
// Insert new vars (deduplicate by key - last entry wins)
if (variables.length > 0) {
const seen = new Map<string, { key: string; value: string; isSecret?: boolean }>();
for (const v of variables) {
seen.set(v.key, v);
}
const deduped = Array.from(seen.values());
const now = new Date().toISOString();
await db.insert(stackEnvironmentVariables).values(
variables.map(v => ({
deduped.map(v => ({
stackName,
environmentId,
key: v.key,
@@ -4519,6 +4755,55 @@ 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

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