Compare commits

..

110 Commits

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

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

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

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

🤖 Generated with AI assistance (Claude Opus 4.5)
2026-02-13 09:35:39 +01:00
Florian Hoss 3140e4f074 Add Bearer token auth support to sendNtfy
Enhanced the sendNtfy function to support Bearer token authentication in addition to Basic auth. Now, URLs in the format token@host/topic will use Bearer tokens, improving flexibility for different notification server setups.
2026-02-13 09:28:17 +01:00
jarek 988e65bd5b 1.0.17 2026-02-09 20:50:41 +01:00
jarek a5360e9d53 1.0.16 2026-02-09 14:48:48 +01:00
jarek c9239f195a 1.0.16 2026-02-09 10:15:21 +01:00
jarek 9daa647709 1.0.15 2026-02-08 10:27:56 +01:00
jarek 38fa758d8a 1.0.15 2026-02-08 10:21:18 +01:00
jarek e829e60217 1.0.15 2026-02-08 09:59:06 +01:00
jarek 7ed20ece39 release job 2026-02-07 09:59:30 +01:00
Jarek Krochmalski 6149b3d935 Delete static/logo_dark.webp 2026-02-06 17:06:27 +01:00
Jarek Krochmalski 139e798e77 Delete static/logo_light.webp 2026-02-06 17:06:10 +01:00
Jarek Krochmalski 2f7f5efc27 Delete static/logo.png 2026-02-06 17:05:46 +01:00
TimElschner 4cd7f1c4ef Add missing static assets (favicons, logos, webmanifest)
The static/ directory containing favicons, apple-touch-icons, logos,
robots.txt and site.webmanifest was not included in the repository,
even though app.html references these files. This causes missing
icons and broken manifest when building from source.
2026-02-06 16:54:29 +01:00
Matt Boris 2e1cb7fdaf chore(gitignore): add local dev auth files 2026-02-06 16:48:29 +01:00
Matt Boris a46154acf7 chore(login): autofocus on the username field 2026-02-06 16:48:29 +01:00
Matt Boris 4627b70fcf chore(mfa): autofocus on the mfa code field on login 2026-02-06 16:48:29 +01:00
Jarek Krochmalski 54a14889de Update bug-report.yml 2026-02-06 08:13:26 +01:00
Jarek Krochmalski 79c02984f0 Update bug-report.yml 2026-02-06 08:10:08 +01:00
Jarek Krochmalski b2989d0aaf Update bug-report.yml 2026-02-06 08:09:38 +01:00
Jarek Krochmalski f9fdfef4cb Update bug-report.yml 2026-02-06 08:08:35 +01:00
Jarek Krochmalski 927858578b Update bug-report.yml 2026-02-06 08:07:32 +01:00
shamoon afb0e734ee Add bug report, FR templates, config 2026-02-06 08:04:44 +01:00
shamoon 6122fa43da Add basic PR template 2026-02-06 08:04:44 +01:00
shamoon 45bedca86d Add basic CONTRIBUTING.md 2026-02-06 08:04:44 +01:00
shamoon 1aca2a10cb Ignore node_modules, .svelte-kit, and bun.lock 2026-02-06 08:04:44 +01:00
shamoon 70e2166548 Only show on update 2026-02-03 09:25:01 +01:00
shamoon ced84b583d Add option to pull image before container update 2026-02-03 09:25:01 +01:00
jarek 53be8f8b20 1.0.14 2026-01-31 09:35:19 +01:00
jarek 236475577b 1.0.13 2026-01-28 07:34:12 +01:00
Jarek Krochmalski 7d6f6f2efd Update README.md 2026-01-24 06:13:41 +01:00
Jarek Krochmalski 193dc44a71 Update README.md 2026-01-24 06:03:27 +01:00
jarek 1036cd0ec6 #96 2026-01-23 14:39:16 +01:00
Viktoras 1a95f5ad05 Honor DATA_DIR env var in sqlite operations related to hawser connections 2026-01-23 14:29:24 +01:00
jarek fd35a0adc0 1.0.12 2026-01-22 16:46:42 +01:00
jarek dd6c5fd3e5 1.0.12 2026-01-22 16:46:17 +01:00
jarek 0303f54e2b 1.0.12 2026-01-22 16:23:26 +01:00
jarek 7f9862f9a0 1.0.11 2026-01-20 15:39:08 +01:00
FlintyLemming 750c9c1910 feat: add SYS_RAWIO to container capabilities list 2026-01-19 19:01:51 +01:00
Jarek Krochmalski 566d80019d Create ai-opt-out 2026-01-19 13:00:00 +01:00
Jarek Krochmalski 261d94032c Update README.md 2026-01-19 12:50:10 +01:00
Jarek Krochmalski 6cb948e84c Update README.md 2026-01-19 12:48:48 +01:00
Jarek Krochmalski 80a5bbde99 Update README.md 2026-01-19 12:44:05 +01:00
Jarek Krochmalski fd744ed9a2 Update package.json 2026-01-19 08:16:41 +01:00
jarek 6d9b509493 1.0.10 2026-01-18 09:56:38 +01:00
jarek e8ab07ec3f 1.0.9 2026-01-17 15:06:14 +01:00
jarek 107e9c3758 1.0.8 2026-01-14 08:18:20 +01:00
sieren f972378117 Mobile: Only show total of stacks
The detailed display of stacks (following x/x/x/x) is too wide
for mobile display.
So for mobile display only, we limit this information to the total number
of stacks.
2026-01-12 14:25:53 +01:00
sieren f588ed787b Improve Environment Layout on Mobile
Do not use the grid layout on mobile but show each tile
in a scrollable list instead.
2026-01-12 14:25:53 +01:00
Jarek Krochmalski 6baf6c23e8 1.0.7 2026-01-11 09:01:42 +01:00
Jarek Krochmalski 6382b4083e 1.0.7 2026-01-11 07:17:25 +01:00
Jarek Krochmalski b269b8d50d 1.0.7 2026-01-11 07:16:18 +01:00
jarek 410d542c58 1.0.6 2026-01-03 14:56:20 +01:00
jarek a02115e6bc missing scripts 2026-01-03 13:21:38 +01:00
jarek 86e4c9eb56 1.0.5 2026-01-03 09:10:38 +01:00
jarek c46870afd1 1.0.5 2026-01-02 15:39:51 +01:00
jarek a8a5623c10 1.0.5 2026-01-02 15:29:56 +01:00
Jarek Krochmalski 059ecbb1dc Update README.md 2026-01-02 13:38:16 +01:00
Jarek Krochmalski 3eab42169c Update README.md 2026-01-02 13:36:32 +01:00
jarek 6a7116a5b7 1.0.5 2026-01-02 12:24:43 +01:00
jarek 215f52b1f0 1.0.5 2026-01-01 16:32:08 +01:00
jarek de62327a07 1.0.5 2026-01-01 16:05:10 +01:00
jarek cd6544aedb 1.0.5 2026-01-01 16:00:34 +01:00
jarek c60db2930c compose example 2025-12-29 15:29:33 +01:00
Jarek Krochmalski 695acd922e bmac 2025-12-29 13:46:13 +01:00
Jarek Krochmalski fcb36c4646 bmac 2025-12-29 13:39:36 +01:00
Jarek Krochmalski 53ca99ac77 Update README.md 2025-12-29 13:37:26 +01:00
Jarek Krochmalski 81fcc28d0b Update LICENSE.txt 2025-12-29 09:27:27 +01:00
jarek 522154cd68 ignore 2025-12-29 09:08:29 +01:00
jarek 9db6e67a61 drizzle config 2025-12-29 09:08:06 +01:00
Jarek Krochmalski ba05d16d79 cleanup .DS_Store 2025-12-29 08:55:55 +01:00
jarek f4a57ecfd3 proper src structure, dockerfile, entrypoint 2025-12-29 08:40:56 +01:00
jarek ab8743bdae proper src structure, dockerfile, entrypoint 2025-12-29 08:40:11 +01:00
Jarek Krochmalski e536388a7a Update README.md 2025-12-29 06:47:46 +01:00
Jarek Krochmalski 497fbdb635 Update README.md 2025-12-29 06:47:22 +01:00
Jarek Krochmalski 53d60fdddd Update README.md 2025-12-28 21:40:06 +01:00
128 changed files with 673 additions and 17477 deletions
+4 -4
View File
@@ -37,7 +37,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
" - busybox" \
" - tzdata" \
" - docker-cli" \
" - docker-compose=5.1.3-r2" \
" - docker-compose=5.0.2-r1" \
" - docker-cli-buildx" \
" - sqlite" \
" - postgresql-client" \
@@ -77,8 +77,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Copy package files and install dependencies (--ignore-scripts blocks malicious postinstall hooks)
COPY package.json package-lock.json ./
RUN MAKEFLAGS="-j$(nproc)" npm ci --ignore-scripts \
&& MAKEFLAGS="-j$(nproc)" npm rebuild better-sqlite3 argon2
RUN npm ci --ignore-scripts \
&& npm rebuild better-sqlite3 argon2
# Copy source code and build
COPY . .
@@ -93,7 +93,7 @@ RUN cp -r node_modules/better-sqlite3/build /tmp/better-sqlite3-build \
&& rm -rf node_modules/@types /tmp/better-sqlite3-build
# Build Go collector
FROM --platform=$BUILDPLATFORM golang:1.25.9 AS go-builder
FROM --platform=$BUILDPLATFORM golang:1.25.8 AS go-builder
ARG TARGETARCH
WORKDIR /app
COPY collector/ ./collector/
+13 -26
View File
@@ -18,33 +18,25 @@ FROM node:24-alpine AS app-builder
WORKDIR /app
# Install build dependencies
RUN apk add --no-cache git curl python3 make g++ gcc musl-dev
RUN apk add --no-cache git curl python3 make g++
# Build getrandom shim for old kernels (< 3.17) that lack the syscall
COPY shims/getrandom-shim.c /tmp/
RUN gcc -shared -fPIC -O2 -o /tmp/libgetrandom-shim.so /tmp/getrandom-shim.c
# Copy package files and install dependencies (--ignore-scripts blocks malicious postinstall hooks)
# Copy package files and install dependencies
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts \
&& npm rebuild better-sqlite3 argon2
RUN npm ci
# Copy source code and build
COPY . .
RUN npm run build
# Production dependencies only
# Preserve better-sqlite3 native addon (no prebuilds exist for Node 24 ABI 137)
RUN cp -r node_modules/better-sqlite3/build /tmp/better-sqlite3-build \
&& rm -rf node_modules \
&& npm ci --omit=dev --ignore-scripts \
&& cp -r /tmp/better-sqlite3-build node_modules/better-sqlite3/build \
&& rm -rf node_modules/@types /tmp/better-sqlite3-build
# Production dependencies only (rebuilds native addons against musl)
RUN rm -rf node_modules \
&& npm ci --omit=dev \
&& rm -rf node_modules/@types
# -----------------------------------------------------------------------------
# Stage 2: Go Collector Builder
# -----------------------------------------------------------------------------
FROM golang:1.25.8 AS go-builder
FROM golang:1.24 AS go-builder
WORKDIR /app
COPY collector/ ./collector/
RUN cd collector && CGO_ENABLED=0 go build -o /app/bin/collection-worker .
@@ -70,10 +62,9 @@ RUN apk add --no-cache \
su-exec \
libstdc++
# Create docker compose plugin symlink (skip if package already installed it there)
# Create docker compose plugin symlink
RUN mkdir -p /usr/libexec/docker/cli-plugins \
&& [ -x /usr/libexec/docker/cli-plugins/docker-compose ] \
|| ln -sf /usr/bin/docker-compose /usr/libexec/docker/cli-plugins/docker-compose
&& ln -sf /usr/bin/docker-compose /usr/libexec/docker/cli-plugins/docker-compose
# Create dockhand user and group
RUN addgroup -g 1001 dockhand \
@@ -89,8 +80,7 @@ ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
DATA_DIR=/app/data \
HOME=/home/dockhand \
PUID=1001 \
PGID=1001 \
LD_PRELOAD=/usr/lib/libgetrandom-shim.so
PGID=1001
# Copy application files with correct ownership
COPY --from=app-builder --chown=dockhand:dockhand /app/node_modules ./node_modules
@@ -108,9 +98,6 @@ COPY --chown=dockhand:dockhand drizzle-pg/ ./drizzle-pg/
# Copy legal documents
COPY --chown=dockhand:dockhand LICENSE.txt PRIVACY.txt ./
# Copy getrandom shim for old kernels (Synology DS1513+ with kernel 3.10.x)
COPY --from=app-builder /tmp/libgetrandom-shim.so /usr/lib/libgetrandom-shim.so
# Copy entrypoint script
COPY docker-entrypoint-node.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
@@ -126,7 +113,7 @@ RUN mkdir -p /home/dockhand/.dockhand/stacks /app/data \
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:${PORT:-3000}/ || exit 1
CMD curl -f http://localhost:3000/ || exit 1
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
CMD []
CMD ["node", "/app/server.js"]
-6
View File
@@ -36,12 +36,6 @@ Dockhand is a modern, efficient Docker management application providing real-tim
- **Database**: SQLite or PostgreSQL via Drizzle ORM
- **Docker**: direct docker API calls.
## Screenshots
| Light Mode | Dark Mode |
| --- | --- |
| <img src="docs/dashboard1.webp" width="600" alt="Dashboard 1 Light"> | <img src="docs/dashboard2.webp" width="600" alt="Dashboard 2 Dark"> |
| <img src="docs/dashboard3.webp" width="600" alt="Dashboard 3 Light"> | <img src="docs/dashboard4.webp" width="600" alt="Dashboard 4 Dark"> |
## License
Dockhand is licensed under the [Business Source License 1.1](LICENSE.txt) (BSL 1.1).
+1 -1
View File
@@ -1 +1 @@
v1.0.28
v1.0.23
+1 -1
View File
@@ -1,3 +1,3 @@
module github.com/Finsys/dockhand/collector
go 1.25.9
go 1.25
+1 -1
View File
@@ -421,7 +421,7 @@ func (m *manager) collectMetrics(env *environment) {
sCtx, sCancel := context.WithTimeout(env.ctx, 10*time.Second)
defer sCancel()
sResp, sErr := env.doRequest(sCtx, "GET", fmt.Sprintf("/containers/%s/stats?stream=false", id))
sResp, sErr := env.doRequest(sCtx, "GET", fmt.Sprintf("/containers/%s/stats?stream=false&one-shot=true", id))
if sErr != nil {
return
}
+1 -1
View File
@@ -10,7 +10,7 @@ PGID=${PGID:-1001}
export BODY_SIZE_LIMIT=${BODY_SIZE_LIMIT:-2G}
# Default command (--expose-gc allows forced GC from /api/debug/memory?gc=true)
# Custom CA: set NODE_EXTRA_CA_CERTS=/path/to/ca.crt (appends to built-in CAs, git ops auto-merge with system CAs)
# Custom CA: set NODE_EXTRA_CA_CERTS=/path/to/ca.crt (appends to built-in CAs)
# Enterprise (system CA store): set NODE_OPTIONS="--use-openssl-ca"
if [ "$MEMORY_MONITOR" = "true" ]; then
DEFAULT_CMD="node --dns-result-order=ipv4first --no-network-family-autoselection --expose-gc /app/server.js"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

-21
View File
@@ -1,21 +0,0 @@
CREATE TABLE IF NOT EXISTS "api_tokens" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"name" text NOT NULL,
"token_hash" text NOT NULL,
"token_prefix" text NOT NULL,
"last_used" timestamp,
"expires_at" timestamp,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "api_tokens_token_hash_unique" UNIQUE("token_hash")
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "api_tokens_user_id_idx" ON "api_tokens" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "api_tokens_token_prefix_idx" ON "api_tokens" USING btree ("token_prefix");
@@ -1,2 +0,0 @@
ALTER TABLE "git_stacks" ADD COLUMN "context_dir" text;--> statement-breakpoint
ALTER TABLE "git_stacks" ADD COLUMN "no_build_cache" boolean DEFAULT false;
+2 -2
View File
@@ -1,6 +1,6 @@
{
"id": "cefce4cc-994a-4b79-b55a-e995211b8f6a",
"prevId": "b10cba96-4947-484f-84a2-efb65205381f",
"id": "b10cba96-4947-484f-84a2-efb65205381f",
"prevId": "eef8322a-0ccc-418c-b0f6-f51972a1850e",
"version": "7",
"dialect": "postgresql",
"tables": {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-14
View File
@@ -36,20 +36,6 @@
"when": 1774155653752,
"tag": "0004_add_git_stack_deploy_options",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1775312212996,
"tag": "0005_add_api_tokens",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1777220350655,
"tag": "0006_add_git_stack_context_dir",
"breakpoints": true
}
]
}
-16
View File
@@ -1,16 +0,0 @@
CREATE TABLE `api_tokens` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`name` text NOT NULL,
`token_hash` text NOT NULL,
`token_prefix` text NOT NULL,
`last_used` text,
`expires_at` text,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`updated_at` text DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `api_tokens_token_hash_unique` ON `api_tokens` (`token_hash`);--> statement-breakpoint
CREATE INDEX `api_tokens_user_id_idx` ON `api_tokens` (`user_id`);--> statement-breakpoint
CREATE INDEX `api_tokens_token_prefix_idx` ON `api_tokens` (`token_prefix`);
@@ -1,2 +0,0 @@
ALTER TABLE `git_stacks` ADD `context_dir` text;--> statement-breakpoint
ALTER TABLE `git_stacks` ADD `no_build_cache` integer DEFAULT false;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-14
View File
@@ -36,20 +36,6 @@
"when": 1774155653752,
"tag": "0004_add_git_stack_deploy_options",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1775311743346,
"tag": "0005_add_api_tokens",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1777220350655,
"tag": "0006_add_git_stack_context_dir",
"breakpoints": true
}
]
}
+5 -6
View File
@@ -1,7 +1,7 @@
{
"name": "dockhand",
"private": true,
"version": "1.0.28",
"version": "1.0.23",
"type": "module",
"scripts": {
"dev": "npx vite dev",
@@ -77,11 +77,11 @@
"croner": "9.1.0",
"cronstrue": "3.9.0",
"devalue": "5.6.4",
"drizzle-orm": "0.45.2",
"fast-xml-parser": "5.7.3",
"drizzle-orm": "0.45.1",
"fast-xml-parser": "5.5.8",
"js-yaml": "4.1.1",
"ldapts": "8.1.3",
"nodemailer": "8.0.5",
"nodemailer": "8.0.4",
"otpauth": "9.4.1",
"postgres": "3.4.8",
"qrcode": "1.5.4",
@@ -136,7 +136,6 @@
"@codemirror/commands": "6.10.1",
"@codemirror/search": "6.6.0",
"@lezer/common": "1.5.0",
"@lezer/highlight": "1.2.3",
"devalue": "5.6.4"
"@lezer/highlight": "1.2.3"
}
}
+2 -7
View File
@@ -191,7 +191,7 @@ async function handleTerminalConnection(ws, url, connId) {
};
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];
if (target.tls.key) tlsOpts.key = target.tls.key;
dockerStream = tlsConnect(tlsOpts);
} else {
// Plain HTTP (direct TCP or hawser-standard)
@@ -430,12 +430,7 @@ function handleHawserConnection(ws, connId, remoteIp) {
// Use the global hawser message handler injected by the SvelteKit app
if (typeof globalThis.__hawserHandleMessage === 'function') {
try {
await globalThis.__hawserHandleMessage(ws, msg, connId, remoteIp);
} catch (handlerError) {
console.error('[Hawser WS] Handler error:', handlerError);
// Don't close connection - let it recover
}
await globalThis.__hawserHandleMessage(ws, msg, connId, remoteIp);
} else {
console.warn('[Hawser WS] No global handler registered');
ws.send(JSON.stringify({ type: 'error', message: 'Server not ready' }));
+7 -91
View File
@@ -4,8 +4,6 @@ import { initDatabase, hasAdminUser } from '$lib/server/db';
import { startSubprocesses, stopSubprocesses } from '$lib/server/subprocess-manager';
import { startScheduler } from '$lib/server/scheduler';
import { isAuthEnabled, validateSession } from '$lib/server/auth';
import { validateApiToken } from '$lib/server/api-tokens';
import { requestContext } from '$lib/server/request-context';
import { setServerStartTime } from '$lib/server/uptime';
import { checkLicenseExpiry, getHostname } from '$lib/server/license';
import { initCryptoFallback } from '$lib/server/crypto-fallback';
@@ -200,58 +198,6 @@ if (!initialized) {
}
}
// Bearer token auth failure rate limiting (per IP, 5-minute cooldown after 10 failures)
const bearerFailCounts = new Map<string, { count: number; firstFail: number }>();
const BEARER_FAIL_WINDOW_MS = 60_000; // 1-minute sliding window
const BEARER_FAIL_MAX = 15; // max failures per window
const BEARER_COOLDOWN_MS = 5 * 60 * 1000; // 5-minute cooldown after exceeding limit
const bearerCooldowns = new Map<string, number>(); // IP → cooldown-until timestamp
// Periodic cleanup
setInterval(() => {
const now = Date.now();
for (const [ip, until] of bearerCooldowns) {
if (now > until) bearerCooldowns.delete(ip);
}
for (const [ip, entry] of bearerFailCounts) {
if (now - entry.firstFail > BEARER_FAIL_WINDOW_MS) bearerFailCounts.delete(ip);
}
}, BEARER_COOLDOWN_MS).unref?.();
function getClientIp(event: { request: Request; getClientAddress?: () => string }): string {
// Prefer socket-level IP (SvelteKit resolves proxy headers via adapter config)
// This prevents X-Forwarded-For spoofing to bypass rate limiting
try {
const addr = event.getClientAddress?.();
if (addr) return addr;
} catch { /* getClientAddress may throw if unavailable */ }
return 'unknown';
}
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',
@@ -301,51 +247,21 @@ export const handle: Handle = async ({ event, resolve }) => {
// Check if auth is enabled
const authEnabled = await isAuthEnabled();
// If auth is disabled, allow everything
// If auth is disabled, allow everything (app works as before)
if (!authEnabled) {
event.locals.user = null;
event.locals.authEnabled = false;
const ctx = { user: null, authEnabled: false, authMethod: 'none' as const };
return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event)));
}
// Auth is enabled - check session first
let user = await validateSession(event.cookies);
let authMethod: 'cookie' | 'bearer' | 'none' = user ? 'cookie' : 'none';
// If no session, try Bearer token on API routes
if (!user && event.url.pathname.startsWith('/api/')) {
const authHeader = event.request.headers.get('authorization');
if (authHeader && authHeader.startsWith('Bearer dh_') && authHeader.length <= 207) {
const clientIp = getClientIp(event);
// Rate limit failed Bearer attempts
if (isBearerRateLimited(clientIp)) {
return new Response(
JSON.stringify({ error: 'Too many failed authentication attempts' }),
{ status: 429, headers: { 'Content-Type': 'application/json', 'Retry-After': '300' } }
);
}
const token = authHeader.substring(7); // strip "Bearer "
user = await validateApiToken(token);
if (user) {
authMethod = 'bearer';
} else {
recordBearerFailure(clientIp);
}
}
return compressResponse(event.request, await resolve(event));
}
// Auth is enabled - check session
const user = await validateSession(event.cookies);
event.locals.user = user;
event.locals.authEnabled = true;
const ctx = { user, authEnabled: true, authMethod };
// Public paths don't require authentication
if (isPublicPath(event.url.pathname)) {
return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event)));
return compressResponse(event.request, await resolve(event));
}
// If not authenticated
@@ -354,7 +270,7 @@ export const handle: Handle = async ({ event, resolve }) => {
// This enables the first admin user to be created during initial setup
const noAdminSetupMode = !(await hasAdminUser());
if (noAdminSetupMode && event.url.pathname === '/api/users' && event.request.method === 'POST') {
return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event)));
return compressResponse(event.request, await resolve(event));
}
// API routes return 401
@@ -373,7 +289,7 @@ export const handle: Handle = async ({ event, resolve }) => {
redirect(307, `/login?redirect=${redirectUrl}`);
}
return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event)));
return compressResponse(event.request, await resolve(event));
} finally {
rssAfterOp('http', httpBefore);
}
+6 -13
View File
@@ -19,7 +19,6 @@
onConfirm: () => void;
onOpenChange: (open: boolean) => void;
children: Snippet<[{ open: boolean }]>;
extraContent?: Snippet;
}
let {
@@ -36,8 +35,7 @@
disabled = false,
onConfirm,
onOpenChange,
children,
extraContent
children
}: Props = $props();
const triggerClass = $derived(unstyled
@@ -105,16 +103,11 @@
align={position === 'left' ? 'start' : 'end'}
sideOffset={8}
>
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-2">
<span class="text-xs whitespace-nowrap">{action} {itemType} {#if displayName}<strong>{displayName}</strong>{/if}?</span>
<Button size="sm" {variant} class="h-6 px-2 text-xs" onclick={handleConfirm}>
{confirmText}
</Button>
</div>
{#if extraContent}
{@render extraContent()}
{/if}
<div class="flex items-center gap-2">
<span class="text-xs whitespace-nowrap">{action} {itemType} {#if displayName}<strong>{displayName}</strong>{/if}?</span>
<Button size="sm" {variant} class="h-6 px-2 text-xs" onclick={handleConfirm}>
{confirmText}
</Button>
</div>
</Popover.Content>
</Popover.Root>
+2 -12
View File
@@ -1,15 +1,13 @@
<script lang="ts">
import { Sun, Moon } from 'lucide-svelte';
import { getTimeFormat } from '$lib/stores/settings';
interface Props {
logs: string | null;
darkMode?: boolean;
timezone?: string;
onToggleTheme?: () => void;
}
let { logs, darkMode = true, timezone, onToggleTheme }: Props = $props();
let { logs, darkMode = true, onToggleTheme }: Props = $props();
// Parse log lines with timestamp and content
function parseLogLine(line: string): { timestamp: string; content: string; type: 'trivy' | 'grype' | 'error' | 'default' } {
@@ -46,15 +44,7 @@
}
function formatTimestamp(timestamp: string): string {
const d = new Date(timestamp);
if (isNaN(d.getTime())) return timestamp;
return new Intl.DateTimeFormat('en-GB', {
timeZone: timezone || undefined,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: getTimeFormat() === '12h'
}).format(d);
return timestamp.split('T')[1]?.replace('Z', '') || timestamp;
}
</script>
+4 -5
View File
@@ -200,8 +200,8 @@
* Sync rawContent TO variables.
* Parses raw content for non-secrets, preserves existing secrets.
*/
function syncRawToVariables(content?: string) {
const { vars, warnings } = parseRawContent(content ?? rawContent);
function syncRawToVariables() {
const { vars, warnings } = parseRawContent(rawContent);
parseWarnings = warnings;
// Preserve existing secrets (they're not in rawContent)
@@ -240,9 +240,8 @@
// Form → Text: sync variables to raw (preserves comments)
syncVariablesToRaw();
} else if (newMode === 'form' && viewMode === 'text') {
// Text → Form: use textEditorContent which falls back to generatedRawContent
// when rawContent is empty (fixes vars lost on view switch for git stacks)
syncRawToVariables(textEditorContent);
// Text → Form: sync raw to variables (preserves secrets)
syncRawToVariables();
}
viewMode = newMode;
+1 -1
View File
@@ -62,7 +62,7 @@
<span class="text-muted-foreground font-normal">({release.date})</span>
</h3>
<div class="space-y-1.5 ml-1">
{#each [...release.changes].sort((a, b) => a.type === b.type ? 0 : a.type === 'feature' ? -1 : 1) as change}
{#each release.changes as change}
{@const { icon: Icon, class: iconClass } = getChangeIcon(change.type)}
<div class="flex items-start gap-2">
<Icon class="w-4 h-4 mt-0.5 shrink-0 {iconClass}" />
+12 -4
View File
@@ -19,7 +19,7 @@
// Detect schedule type from cron expression
function detectScheduleType(cron: string): 'daily' | 'weekly' | 'custom' {
const parts = cron.split(' ');
if (parts.length !== 5) return 'custom';
if (parts.length < 5) return 'custom';
const [min, hr, day, month, dow] = parts;
@@ -137,15 +137,23 @@
onchange(newValue);
}
// Validate cron expression (supports 5-field and 6-field with seconds)
// Validate cron expression
function isValidCron(cron: string): boolean {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5 && parts.length !== 6) return false;
if (parts.length !== 5) return false;
const [min, hr, day, month, dow] = parts;
// Basic pattern validation (number, *, */n, range, list)
const cronFieldPattern = /^(\*|(\*\/\d+)|\d+(-\d+)?(,\d+(-\d+)?)*)$/;
return parts.every((part) => cronFieldPattern.test(part));
return (
cronFieldPattern.test(min) &&
cronFieldPattern.test(hr) &&
cronFieldPattern.test(day) &&
cronFieldPattern.test(month) &&
cronFieldPattern.test(dow)
);
}
// Human-readable description using cronstrue
+8 -30
View File
@@ -329,40 +329,18 @@
onExpandChange?.(key, nowExpanded);
}
// Sort persistence
const SORT_STORAGE_KEY = `dockhand-${gridId}-sort`;
let sortInitialized = false;
// Restore saved sort on mount
onMount(() => {
if (!onSortChange) return;
try {
const saved = localStorage.getItem(SORT_STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved) as DataGridSortState;
if (parsed.field && parsed.direction) {
onSortChange(parsed);
}
}
} catch {}
sortInitialized = true;
});
// Persist sort state whenever it changes (after init)
$effect(() => {
if (!sortInitialized || !sortState) return;
try { localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(sortState)); } catch {}
});
// Sort helpers
function toggleSort(field: string) {
if (!onSortChange) return;
const newState: DataGridSortState = sortState?.field === field
? { field, direction: sortState.direction === 'asc' ? 'desc' : 'asc' }
: { field, direction: 'asc' };
onSortChange(newState);
if (sortState?.field === field) {
onSortChange({
field,
direction: sortState.direction === 'asc' ? 'desc' : 'asc'
});
} else {
onSortChange({ field, direction: 'asc' });
}
}
// Virtual scroll state
+1 -1
View File
@@ -10,7 +10,7 @@ export const containerColumns: ColumnConfig[] = [
{ id: 'uptime', label: 'Uptime', sortable: true, sortField: 'uptime', width: 80, minWidth: 60 },
{ id: 'restartCount', label: 'Restarts', width: 70, minWidth: 50 },
{ id: 'cpu', label: 'CPU', sortable: true, sortField: 'cpu', width: 50, minWidth: 40, align: 'right' },
{ id: 'memory', label: 'Memory', sortable: true, sortField: 'memory', width: 95, minWidth: 70, align: 'right' },
{ id: 'memory', label: 'Memory', sortable: true, sortField: 'memory', width: 60, minWidth: 50, align: 'right' },
{ id: 'networkIO', label: 'Net I/O', width: 85, minWidth: 70, align: 'right' },
{ id: 'diskIO', label: 'Disk I/O', width: 85, minWidth: 70, align: 'right' },
{ id: 'ip', label: 'IP', sortable: true, sortField: 'ip', width: 100, minWidth: 80 },
-96
View File
@@ -1,100 +1,4 @@
[
{
"version": "1.0.28",
"date": "2026-05-09",
"changes": [
{ "type": "feature", "text": "context directory for git stacks — reference files from anywhere in the repo (#864)" },
{ "type": "feature", "text": "no-cache build option for git stacks (#880)" },
{ "type": "fix", "text": "env vars lost when switching between raw/form view (#964)" },
{ "type": "fix", "text": "compose name property not respected during stack scan (#922)" },
{ "type": "feature", "text": "editable schedule for scanner cache cleanup (#979)" },
{ "type": "fix", "text": "container labels cannot be deleted (#984)" },
{ "type": "fix", "text": "env var values leaked in deploy logs — now all values are redacted (#985)" },
{ "type": "fix", "text": "volume export keeps helper container alive, preventing volume prune/deletion (#983)" },
{ "type": "fix", "text": "ntfy self-hosted notifications fail when using ?auth= query parameter (#840)" },
{ "type": "fix", "text": "scrollbar appears in dashboard tiles when content overflows (#969)" },
{ "type": "fix", "text": "case-sensitive environment sort order — lowercase names sorted after uppercase (#975)" },
{ "type": "fix", "text": "inaccurate dashboard CPU gauge caused by one-shot stats flag (#932)" },
{ "type": "feature", "text": "ntfy notifications support ?tags=, ?title=, and ?priority= URL query parameters (#689)" },
{ "type": "fix", "text": "stack .env file wiped when saving from graph view (#988)" },
{ "type": "feature", "text": "dismiss update available indicators without updating (#853)" },
{ "type": "feature", "text": "public IP setting available for hawser-edge environments — enables clickable port links (#350)" },
{ "type": "fix", "text": "git stack creation silently destroys existing stacks with the same name (#1001)" },
{ "type": "feature", "text": "static IP/MAC address configuration for containers (#297)" }
],
"imageTag": "fnsys/dockhand:v1.0.28"
},
{
"version": "1.0.27",
"comingSoon": false,
"date": "2026-04-26",
"changes": [
{ "type": "feature", "text": "network graph visualization on networks page (#894, @Penlane)" },
{ "type": "feature", "text": "customizable compose template for new stacks in settings (#632, @oratory)" },
{ "type": "feature", "text": "Microsoft Teams notifications via Power Automate Workflows (#355, @slokhorst)" },
{ "type": "feature", "text": "container label controls: dockhand.update, dockhand.hidden, dockhand.notify (#6, #53, #94, #215)" },
{ "type": "feature", "text": "configurable label filter matching mode (any/all) for environment dashboard (#607)" },
{ "type": "feature", "text": "log search filter mode to hide non-matching lines (#916)" },
{ "type": "feature", "text": "inline terminal on logs page with resizable split layout (#900)" },
{ "type": "fix", "text": "disable Telegram link preview in notifications (#910, @deenle)" },
{ "type": "fix", "text": "cron editor rejects 6-field expressions with seconds (#839, @GiulioSavini)" },
{ "type": "fix", "text": "mirror Dockhand's ExtraHosts into scanner and self-update containers (#836, @YewFence)" },
{ "type": "fix", "text": "duplicate volume binds during container recreate (#765, @itsDNNS)" },
{ "type": "fix", "text": "log timestamp formatting not applied on main logs page (#882)" },
{ "type": "fix", "text": "uploaded files now inherit container user ownership (#732, @ivanjx)" },
{ "type": "fix", "text": "extraneous backslash in Telegram notification environment name (#955)" },
{ "type": "fix", "text": "collapse ports into ranges only if 3 or more consecutive ports" },
{ "type": "fix", "text": "git operations auto-merge system CAs with custom cert (#967)" }
],
"imageTag": "fnsys/dockhand:v1.0.27"
},
{
"version": "1.0.26",
"date": "2026-04-19",
"changes": [
{ "type": "feature", "text": "persist sort order across page navigation for all data grids (#861, #912)" },
{ "type": "feature", "text": "show git repository URL and branch in git stack edit modal (#856)" },
{ "type": "feature", "text": "show memory limit alongside usage in containers and stacks views (#893)" },
{ "type": "feature", "text": "option to delete associated volumes when removing a stack (#655)" },
{ "type": "feature", "text": "collapse consecutive port mappings into ranges in container list (#821)" },
{ "type": "fix", "text": "bearer token authentication fails with enterprise license active" },
{ "type": "fix", "text": "clicking stack name toggles stats accordion instead of just opening editor (#628)" },
{ "type": "fix", "text": "scheduled image prune notifications missing environment name (#770)" },
{ "type": "fix", "text": "Gotify, ntfy, Pushover, and webhook notifications missing environment name (#943)" },
{ "type": "fix", "text": "MFA code field not recognized by Bitwarden and other password managers (#566)" }
],
"imageTag": "fnsys/dockhand:v1.0.26"
},
{
"version": "1.0.25",
"date": "2026-04-18",
"comingSoon": false,
"changes": [
{ "type": "feature", "text": "API token authentication — Bearer tokens for CI/CD pipelines and scripts" },
{ "type": "feature", "text": "Telegram topic support — send notifications to supergroup topics (#855)" },
{ "type": "fix", "text": "allow removing healthcheck, ports, and honor startAfterUpdate=false during container edit (#892)" },
{ "type": "fix", "text": "validate stack names and prevent broken DB entries on invalid input (#876)" },
{ "type": "fix", "text": "use per-environment timezone for schedule execution log timestamps (#882)" },
{ "type": "fix", "text": "\"Pull image before update\" and \"Start after update\" settings ignored (#909)" },
{ "type": "fix", "text": "image prune timeout on hawser-standard when pruning many images (#905)" },
{ "type": "fix", "text": "bump Docker Compose to 5.1.3" },
{ "type": "fix", "text": "mask secret environment variables in container inspect modal (#924)" },
{ "type": "fix", "text": "viewer role can toggle, delete, and run schedules (#923)" },
{ "type": "fix", "text": "settings show defaults instead of saved values after login until page refresh (#921)" },
{ "type": "fix", "text": "settings toggle notifications show wrong state (#931)" },
{ "type": "fix", "text": "stack memory tooltip shows inflated total on multi-container stacks (#936)" }
],
"imageTag": "fnsys/dockhand:v1.0.25"
},
{
"version": "1.0.24",
"date": "2026-04-03",
"changes": [
{ "type": "fix", "text": "browsing HTTP registries fails with SSL error (#868)" },
{ "type": "fix", "text": "git stack deploy options (build, re-pull, force redeploy) not persisted in edit dialog" }
],
"imageTag": "fnsys/dockhand:v1.0.24"
},
{
"version": "1.0.23",
"date": "2026-04-03",
-274
View File
@@ -1,274 +0,0 @@
/**
* API Token Management
*
* Provides Bearer token authentication for CI/CD pipelines and scripts.
* Tokens use `dh_` prefix, Argon2id hashing, and prefix-based lookup.
*
* Performance: An in-memory cache (SHA-256 key, 60s TTL) avoids running
* Argon2id on every request. First request: ~100ms. Subsequent: ~0ms.
*/
import { createHash } from 'node:crypto';
import { db, eq, and } from '$lib/server/db/drizzle';
import { hashPassword, verifyPassword, type AuthenticatedUser } from './auth';
import { secureRandomBytes } from './crypto-fallback';
import { getUserRoles, userHasAdminRole, type Permissions } from './db';
import { isEnterprise } from './license';
import { tokenCache, ensureCleanupInterval, invalidateTokenCacheForUser, clearTokenCache } from './token-cache';
// Re-export cache functions so existing consumers don't need to change imports
export { invalidateTokenCacheForUser, clearTokenCache } from './token-cache';
// Dynamic schema import (same pattern as db.ts)
let apiTokensTable: any;
async function getApiTokensTable() {
if (apiTokensTable) return apiTokensTable;
const isPostgres = !!(process.env.DATABASE_URL && (
process.env.DATABASE_URL.startsWith('postgres://') ||
process.env.DATABASE_URL.startsWith('postgresql://')
));
const schema = isPostgres
? await import('./db/schema/pg-schema.js')
: await import('./db/schema/index.js');
apiTokensTable = schema.apiTokens;
return apiTokensTable;
}
// Token format: dh_ + 32 bytes base64url = dh_ + 43 chars
const TOKEN_PREFIX = 'dh_';
const TOKEN_BYTES = 32;
const PREFIX_LENGTH = 8; // chars after dh_ stored for identification
const MAX_TOKEN_LENGTH = 200;
const CACHE_TTL = 60_000; // 60 seconds
function cacheKey(rawToken: string): string {
return createHash('sha256').update(rawToken).digest('hex');
}
// Pre-computed dummy hash for timing protection on invalid prefixes
let dummyHash: string | null = null;
async function getDummyHash(): Promise<string> {
if (!dummyHash) {
dummyHash = await hashPassword('dh_dummy_token_for_timing_protection');
}
return dummyHash;
}
// Initialize dummy hash on import (fire and forget)
void getDummyHash();
/**
* Generate a new API token.
* Returns the plaintext token (shown once) and the database record.
*/
export async function generateApiToken(
userId: number,
name: string,
expiresAt?: string | null
): Promise<{ token: string; id: number; tokenPrefix: string }> {
const table = await getApiTokensTable();
// Generate random token
const randomBytes = secureRandomBytes(TOKEN_BYTES);
const rawToken = TOKEN_PREFIX + randomBytes.toString('base64url');
const tokenPrefix = rawToken.substring(TOKEN_PREFIX.length, TOKEN_PREFIX.length + PREFIX_LENGTH);
// Hash for storage
const tokenHash = await hashPassword(rawToken);
const result = await db.insert(table).values({
userId,
name,
tokenHash,
tokenPrefix,
expiresAt: expiresAt || null
}).returning();
return {
token: rawToken,
id: result[0].id,
tokenPrefix
};
}
/**
* Validate a Bearer token and return the associated user.
* Uses cache to avoid Argon2id on every request.
*/
export async function validateApiToken(rawToken: string): Promise<AuthenticatedUser | null> {
// Input validation
if (!rawToken || rawToken.length > MAX_TOKEN_LENGTH || !rawToken.startsWith(TOKEN_PREFIX)) {
return null;
}
// Check cache first
ensureCleanupInterval();
const key = cacheKey(rawToken);
const cached = tokenCache.get(key);
if (cached && cached.expiresAt > Date.now()) {
return cached.user;
}
const table = await getApiTokensTable();
// Extract prefix for lookup
const prefix = rawToken.substring(TOKEN_PREFIX.length, TOKEN_PREFIX.length + PREFIX_LENGTH);
// Find tokens with matching prefix (deleted tokens are gone, no isActive filter needed)
const candidates = await db
.select()
.from(table)
.where(eq(table.tokenPrefix, prefix));
if (candidates.length === 0) {
// Timing protection: run Argon2id anyway
await verifyPassword(rawToken, await getDummyHash());
return null;
}
// Verify against each candidate (usually just one)
for (const candidate of candidates) {
const valid = await verifyPassword(rawToken, candidate.tokenHash);
if (!valid) continue;
// Check expiration AFTER hash verification to avoid timing oracle
if (candidate.expiresAt && new Date(candidate.expiresAt) < new Date()) {
continue;
}
// Build AuthenticatedUser from the token's user
const user = await buildUserFromToken(candidate);
if (!user) continue;
// Update lastUsed (fire and forget — non-critical audit field)
void db.update(table)
.set({ lastUsed: new Date().toISOString() })
.where(eq(table.id, candidate.id))
.catch((err) => {
if (typeof process !== 'undefined' && process.env.DB_VERBOSE_LOGGING === 'true') {
console.debug('[api-tokens] lastUsed update failed:', err?.message);
}
});
// Cache the result — cap TTL at token expiry time if sooner
let cacheTtl = CACHE_TTL;
if (candidate.expiresAt) {
const timeUntilExpiry = new Date(candidate.expiresAt).getTime() - Date.now();
if (timeUntilExpiry < cacheTtl) {
cacheTtl = Math.max(0, timeUntilExpiry);
}
}
tokenCache.set(key, { user, expiresAt: Date.now() + cacheTtl });
return user;
}
return null;
}
/**
* Build an AuthenticatedUser from a token's database record.
*/
async function buildUserFromToken(tokenRecord: any): Promise<AuthenticatedUser | null> {
// Import getUserWithoutPassword dynamically to avoid circular deps
// This avoids keeping passwordHash in memory unnecessarily
const { getUserWithoutPassword } = await import('./db');
const dbUser = await getUserWithoutPassword(tokenRecord.userId);
if (!dbUser || !dbUser.isActive) return null;
const enterprise = await isEnterprise();
let isAdmin = false;
let permissions: Permissions;
if (!enterprise) {
// Free edition: everyone is effectively admin
isAdmin = true;
const { getRoleByName } = await import('./db');
const adminRole = await getRoleByName('Admin');
permissions = adminRole?.permissions ?? {} as Permissions;
} else {
isAdmin = await userHasAdminRole(dbUser.id);
const userRoleAssignments = await getUserRoles(dbUser.id);
// Merge permissions from all roles
permissions = {} as Permissions;
for (const assignment of userRoleAssignments) {
if (!assignment.role) continue;
const rolePerms = typeof assignment.role.permissions === 'string'
? JSON.parse(assignment.role.permissions)
: assignment.role.permissions;
if (!rolePerms) continue;
for (const [key, actions] of Object.entries(rolePerms)) {
if (!permissions[key as keyof Permissions]) {
permissions[key as keyof Permissions] = [];
}
for (const action of actions as string[]) {
if (!permissions[key as keyof Permissions].includes(action)) {
permissions[key as keyof Permissions].push(action);
}
}
}
}
}
// Determine provider from authProvider field
let provider: 'local' | 'ldap' | 'oidc' = 'local';
if (dbUser.authProvider?.startsWith('ldap')) provider = 'ldap';
else if (dbUser.authProvider?.startsWith('oidc')) provider = 'oidc';
return {
id: dbUser.id,
username: dbUser.username,
email: dbUser.email ?? undefined,
displayName: dbUser.displayName ?? undefined,
avatar: dbUser.avatar ?? undefined,
isAdmin,
provider,
permissions
};
}
/**
* List all tokens for a user (no hashes returned).
*/
export async function listUserTokens(userId: number) {
const table = await getApiTokensTable();
return db
.select({
id: table.id,
name: table.name,
tokenPrefix: table.tokenPrefix,
lastUsed: table.lastUsed,
expiresAt: table.expiresAt,
createdAt: table.createdAt
})
.from(table)
.where(eq(table.userId, userId));
}
/**
* Revoke (delete) a token. Owner or admin can revoke.
*/
export async function revokeApiToken(tokenId: number, requestingUserId: number, isAdmin: boolean): Promise<boolean> {
const table = await getApiTokensTable();
// Find the token
const [token] = await db.select().from(table).where(eq(table.id, tokenId));
if (!token) return false;
// Check ownership or admin
if (token.userId !== requestingUserId && !isAdmin) {
return false;
}
// Hard-delete
await db.delete(table).where(eq(table.id, tokenId));
// Clear cache — we can't map prefix to SHA-256 cache keys, so clear all
clearTokenCache();
return true;
}
+3 -5
View File
@@ -9,7 +9,6 @@ import type { RequestEvent } from '@sveltejs/kit';
import { isEnterprise } from './license';
import { logAuditEvent, type AuditAction, type AuditEntityType, type AuditLogCreateData } from './db';
import { authorize } from './authorize';
import { getRequestContext } from './request-context';
export interface AuditContext {
userId?: number | null;
@@ -22,8 +21,7 @@ export interface AuditContext {
* Extract audit context from a request event
*/
export async function getAuditContext(event: RequestEvent): Promise<AuditContext> {
const ctx = getRequestContext();
const user = ctx?.user ?? (await authorize(event.cookies)).user;
const auth = await authorize(event.cookies);
// Get IP address from various headers (proxied requests)
const forwardedFor = event.request.headers.get('x-forwarded-for');
@@ -42,8 +40,8 @@ export async function getAuditContext(event: RequestEvent): Promise<AuditContext
const userAgent = event.request.headers.get('user-agent') || null;
return {
userId: user?.id ?? null,
username: user?.username ?? 'anonymous',
userId: auth.user?.id ?? null,
username: auth.user?.username ?? 'anonymous',
ipAddress,
userAgent
};
+1 -8
View File
@@ -44,7 +44,6 @@ import {
import { Client as LdapClient } from 'ldapts';
import { isEnterprise } from './license';
import { secureRandomBytes } from './crypto-fallback';
import { invalidateTokenCacheForUser } from './token-cache';
// Session cookie name
const SESSION_COOKIE_NAME = 'dockhand_session';
@@ -223,7 +222,7 @@ function setSessionCookie(cookies: Cookies, sessionId: string, maxAge: number, r
path: '/',
httpOnly: true, // Prevents XSS attacks from reading cookie
secure: isSecureContext(request), // Protocol-aware: checks x-forwarded-proto or NODE_ENV
sameSite: 'lax', // Lax required for OIDC/SSO cross-site redirects
sameSite: 'strict', // CSRF protection
maxAge: maxAge // Session timeout in seconds
});
}
@@ -736,9 +735,6 @@ async function tryLdapAuth(
}
}
// Clear cached token permissions after role sync
invalidateTokenCacheForUser(user.id);
if (!user.isActive) {
return { success: false, error: 'Account is disabled' };
}
@@ -1453,9 +1449,6 @@ export async function handleOidcCallback(
}
}
// Clear cached token permissions after role sync
invalidateTokenCacheForUser(user.id);
if (!user.isActive) {
return { success: false, error: 'Account is disabled' };
}
+7 -11
View File
@@ -40,7 +40,6 @@ import type { Permissions } from './db';
import { getUserAccessibleEnvironments, userCanAccessEnvironment, userHasAdminRole } from './db';
import { validateSession, isAuthEnabled, checkPermission, type AuthenticatedUser } from './auth';
import { isEnterprise } from './license';
import { getRequestContext } from './request-context';
export interface AuthorizationContext {
/** Whether authentication is enabled globally */
@@ -114,10 +113,7 @@ export interface AuthorizationContext {
export async function authorize(cookies: Cookies): Promise<AuthorizationContext> {
const authEnabled = await isAuthEnabled();
const enterprise = await isEnterprise();
// Try request context first (set by hook — handles both cookie and Bearer)
const reqCtx = getRequestContext();
const user = reqCtx?.user ?? (authEnabled ? await validateSession(cookies) : null);
const user = authEnabled ? await validateSession(cookies) : null;
// Determine admin status:
// - Free edition: all authenticated users are effectively admins (full access)
@@ -159,8 +155,8 @@ export async function authorize(cookies: Cookies): Promise<AuthorizationContext>
// Must be authenticated
if (!user) return false;
// Admins can access all environments (use fresh isAdmin, not cached user.isAdmin)
if (isAdmin) return true;
// Admins can access all environments
if (user.isAdmin) return true;
// In free edition, all authenticated users have full access
if (!enterprise) return true;
@@ -176,8 +172,8 @@ export async function authorize(cookies: Cookies): Promise<AuthorizationContext>
// Must be authenticated
if (!user) return [];
// Admins can access all environments (use fresh isAdmin, not cached user.isAdmin)
if (isAdmin) return null;
// Admins can access all environments
if (user.isAdmin) return null;
// In free edition, all authenticated users have full access
if (!enterprise) return null;
@@ -193,8 +189,8 @@ export async function authorize(cookies: Cookies): Promise<AuthorizationContext>
// Must be authenticated
if (!user) return false;
// Admins can always manage users (use fresh isAdmin, not cached user.isAdmin)
if (isAdmin) return true;
// Admins can always manage users
if (user.isAdmin) return true;
// In free edition, all authenticated users have full access
if (!enterprise) return true;
-89
View File
@@ -1,89 +0,0 @@
/**
* Dockhand Container Label Controls
*
* Docker container labels that control Dockhand behavior:
* - dockhand.update=false Skip this container during auto-updates and batch updates
* - dockhand.hidden=true Hide this container from the Dockhand UI
* - dockhand.notify=false Suppress notifications for this container's events
*
* 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',
} 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
}
/**
* 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;
} {
return {
updateDisabled: isUpdateDisabledByLabel(labels),
hidden: isHiddenByLabel(labels),
notifyDisabled: isNotifyDisabledByLabel(labels),
};
}
+8 -198
View File
@@ -78,7 +78,6 @@ import {
import type { AllGridPreferences, GridId, GridColumnPreferences } from '$lib/types';
import { encrypt, decrypt } from './encryption.js';
import { parseEnvInterpolation } from './env-interpolation';
// Re-export for backwards compatibility
export { db, isPostgres, isSqlite };
@@ -113,7 +112,7 @@ export function initDatabase() {
// =============================================================================
export async function getEnvironments(): Promise<Environment[]> {
const results = await db.select().from(environments).orderBy(sql`lower(${environments.name})`);
const results = await db.select().from(environments).orderBy(asc(environments.name));
return results.map((e: Environment) => ({
...e,
tlsKey: decrypt(e.tlsKey),
@@ -1186,37 +1185,6 @@ export async function getUser(id: number): Promise<UserData | null> {
return results[0] as UserData || null;
}
export interface SafeUserData {
id: number;
username: string;
email: string | null;
displayName: string | null;
avatar: string | null;
authProvider: string | null;
mfaEnabled: boolean;
isActive: boolean;
lastLogin: string | null;
createdAt: string;
updatedAt: string;
}
export async function getUserWithoutPassword(id: number): Promise<SafeUserData | null> {
const results = await db.select({
id: users.id,
username: users.username,
email: users.email,
displayName: users.displayName,
avatar: users.avatar,
authProvider: users.authProvider,
mfaEnabled: users.mfaEnabled,
isActive: users.isActive,
lastLogin: users.lastLogin,
createdAt: users.createdAt,
updatedAt: users.updatedAt
}).from(users).where(eq(users.id, id));
return results[0] as SafeUserData || null;
}
export async function hasAdminUser(): Promise<boolean> {
// Check if any user has the Admin role assigned
const adminRole = await db.select().from(roles).where(eq(roles.name, 'Admin')).limit(1);
@@ -2067,7 +2035,6 @@ export async function getGitStacksByRepositoryId(repositoryId: number): Promise<
}
export async function deleteGitRepository(id: number): Promise<boolean> {
console.log(`[GitStack] Deleting git repository id=${id} (will cascade-delete git_stacks, set null on stack_sources FKs)`);
await db.delete(gitRepositories).where(eq(gitRepositories.id, id));
return true;
}
@@ -2088,9 +2055,7 @@ export interface GitStackData {
autoUpdateCron: string;
webhookEnabled: boolean;
webhookSecret: string | null;
contextDir: string | null;
buildOnDeploy: boolean;
noBuildCache: boolean;
repullImages: boolean;
forceRedeploy: boolean;
lastSync: string | null;
@@ -2126,11 +2091,6 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2159,11 +2119,6 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
@@ -2192,9 +2147,7 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
@@ -2227,9 +2180,7 @@ export async function getGitStacksForEnvironmentOnly(environmentId: number): Pro
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
@@ -2260,9 +2211,7 @@ export async function getGitStacksForEnvironmentOnly(environmentId: number): Pro
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
@@ -2294,9 +2243,7 @@ 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,
@@ -2328,9 +2275,7 @@ 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,
@@ -2362,9 +2307,7 @@ export async function getGitStackByName(stackName: string, environmentId?: numbe
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
@@ -2401,9 +2344,7 @@ export async function getGitStackByName(stackName: string, environmentId?: numbe
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
@@ -2435,9 +2376,7 @@ export async function getGitStackByWebhookSecret(secret: string): Promise<GitSta
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
@@ -2469,9 +2408,7 @@ export async function getGitStackByWebhookSecret(secret: string): Promise<GitSta
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
@@ -2501,9 +2438,7 @@ export async function createGitStack(data: {
autoUpdateCron?: string;
webhookEnabled?: boolean;
webhookSecret?: string | null;
contextDir?: string | null;
buildOnDeploy?: boolean;
noBuildCache?: boolean;
repullImages?: boolean;
forceRedeploy?: boolean;
}): Promise<GitStackWithRepo> {
@@ -2513,14 +2448,12 @@ export async function createGitStack(data: {
repositoryId: data.repositoryId,
composePath: data.composePath || 'compose.yaml',
envFilePath: data.envFilePath || null,
contextDir: data.contextDir || null,
autoUpdate: data.autoUpdate || false,
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
webhookEnabled: data.webhookEnabled || false,
webhookSecret: data.webhookSecret || null,
buildOnDeploy: data.buildOnDeploy ?? false,
noBuildCache: data.noBuildCache ?? false,
repullImages: data.repullImages ?? false,
forceRedeploy: data.forceRedeploy ?? false
}).returning();
@@ -2539,9 +2472,7 @@ 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;
@@ -2554,7 +2485,6 @@ export async function updateGitStack(id: number, data: Partial<GitStackData>): P
}
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;
}
@@ -2579,9 +2509,7 @@ export async function getEnabledAutoUpdateGitStacks(): Promise<GitStackWithRepo[
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
@@ -2611,9 +2539,7 @@ export async function getEnabledAutoUpdateGitStacks(): Promise<GitStackWithRepo[
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
@@ -2644,9 +2570,7 @@ export async function getAllAutoUpdateGitStacks(): Promise<GitStackWithRepo[]> {
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
@@ -2675,9 +2599,7 @@ export async function getAllAutoUpdateGitStacks(): Promise<GitStackWithRepo[]> {
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
@@ -2822,21 +2744,11 @@ export async function upsertStackSource(data: {
const existing = await getStackSource(data.stackName, data.environmentId);
if (existing) {
const newRepoId = data.gitRepositoryId || null;
const newStackId = data.gitStackId || null;
const changes: string[] = [];
if (data.sourceType !== existing.sourceType) changes.push(`sourceType: ${existing.sourceType}${data.sourceType}`);
if (newRepoId !== existing.gitRepositoryId) changes.push(`gitRepoId: ${existing.gitRepositoryId}${newRepoId}`);
if (newStackId !== existing.gitStackId) changes.push(`gitStackId: ${existing.gitStackId}${newStackId}`);
if (changes.length > 0) {
console.log(`[GitStack] Updating stack_sources "${data.stackName}" env=${data.environmentId}: ${changes.join(', ')}`);
}
await db.update(stackSources)
.set({
sourceType: data.sourceType,
gitRepositoryId: newRepoId,
gitStackId: newStackId,
gitRepositoryId: data.gitRepositoryId || null,
gitStackId: data.gitStackId || null,
composePath: data.composePath ?? null,
envPath: data.envPath ?? null,
updatedAt: new Date().toISOString()
@@ -2844,7 +2756,6 @@ export async function upsertStackSource(data: {
.where(eq(stackSources.id, existing.id));
return getStackSource(data.stackName, data.environmentId) as Promise<StackSourceData>;
} else {
console.log(`[GitStack] Creating stack_sources "${data.stackName}" env=${data.environmentId} type=${data.sourceType} repoId=${data.gitRepositoryId || null} stackId=${data.gitStackId || null}`);
await db.insert(stackSources).values({
stackName: data.stackName,
environmentId: data.environmentId ?? null,
@@ -2878,7 +2789,6 @@ export async function updateStackSource(
}
export async function deleteStackSource(stackName: string, environmentId?: number | null): Promise<boolean> {
console.log(`[GitStack] Deleting stack_sources "${stackName}" env=${environmentId}`);
// Delete matching record (either with specific envId or NULL)
await db.delete(stackSources)
.where(and(
@@ -3130,7 +3040,7 @@ export type AuditAction =
export type AuditEntityType =
| 'container' | 'image' | 'stack' | 'volume' | 'network'
| 'user' | 'role' | 'settings' | 'environment' | 'registry' | 'git_repository' | 'git_credential'
| 'config_set' | 'notification' | 'oidc_provider' | 'ldap_config' | 'git_stack' | 'api_token';
| 'config_set' | 'notification' | 'oidc_provider' | 'ldap_config' | 'git_stack';
export interface AuditLogData {
id: number;
@@ -3246,16 +3156,14 @@ export async function getAuditLogs(filters: AuditLogFilters = {}): Promise<Audit
// Labels filter - find environments with matching labels first
let labelFilteredEnvIds: number[] | undefined;
if (filters.labels && filters.labels.length > 0) {
const labelFilterMode = await getSetting('label_filter_mode') ?? 'any';
// Get environments that have ANY of the specified labels
const allEnvs = await db.select({ id: environments.id, labels: environments.labels }).from(environments);
labelFilteredEnvIds = allEnvs
.filter(env => {
if (!env.labels) return false;
try {
const envLabels = JSON.parse(env.labels) as string[];
return labelFilterMode === 'all'
? filters.labels!.every(label => envLabels.includes(label))
: filters.labels!.some(label => envLabels.includes(label));
return filters.labels!.some(label => envLabels.includes(label));
} catch {
return false;
}
@@ -3463,16 +3371,14 @@ export async function getContainerEvents(filters: ContainerEventFilters = {}): P
// Labels filter - find environments with matching labels first
let labelFilteredEnvIds: number[] | undefined;
if (filters.labels && filters.labels.length > 0) {
const labelFilterMode = await getSetting('label_filter_mode') ?? 'any';
// Get environments that have ANY of the specified labels
const allEnvs = await db.select({ id: environments.id, labels: environments.labels }).from(environments);
labelFilteredEnvIds = allEnvs
.filter(env => {
if (!env.labels) return false;
try {
const envLabels = JSON.parse(env.labels) as string[];
return labelFilterMode === 'all'
? filters.labels!.every(label => envLabels.includes(label))
: filters.labels!.some(label => envLabels.includes(label));
return filters.labels!.some(label => envLabels.includes(label));
} catch {
return false;
}
@@ -4095,11 +4001,8 @@ const SCHEDULE_CLEANUP_CRON_KEY = 'schedule_cleanup_cron';
const EVENT_CLEANUP_CRON_KEY = 'event_cleanup_cron';
const SCHEDULE_CLEANUP_ENABLED_KEY = 'schedule_cleanup_enabled';
const EVENT_CLEANUP_ENABLED_KEY = 'event_cleanup_enabled';
const SCANNER_CLEANUP_CRON_KEY = 'scanner_cleanup_cron';
const SCANNER_CLEANUP_ENABLED_KEY = 'scanner_cleanup_enabled';
const DEFAULT_SCHEDULE_CLEANUP_CRON = '0 3 * * *'; // Daily at 3 AM
const DEFAULT_EVENT_CLEANUP_CRON = '30 3 * * *'; // Daily at 3:30 AM
const DEFAULT_SCANNER_CLEANUP_CRON = '0 3 * * 0'; // Weekly Sunday at 3 AM
export async function getScheduleRetentionDays(): Promise<number> {
const result = await db.select().from(settings).where(eq(settings.key, SCHEDULE_RETENTION_KEY));
@@ -4233,50 +4136,6 @@ export async function setEventCleanupEnabled(enabled: boolean): Promise<void> {
}
}
export async function getScannerCleanupCron(): Promise<string> {
const result = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_CRON_KEY));
if (result[0]) {
return result[0].value || DEFAULT_SCANNER_CLEANUP_CRON;
}
return DEFAULT_SCANNER_CLEANUP_CRON;
}
export async function setScannerCleanupCron(cron: string): Promise<void> {
const existing = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_CRON_KEY));
if (existing.length > 0) {
await db.update(settings)
.set({ value: cron, updatedAt: new Date().toISOString() })
.where(eq(settings.key, SCANNER_CLEANUP_CRON_KEY));
} else {
await db.insert(settings).values({
key: SCANNER_CLEANUP_CRON_KEY,
value: cron
});
}
}
export async function getScannerCleanupEnabled(): Promise<boolean> {
const result = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_ENABLED_KEY));
if (result[0]) {
return result[0].value === 'true';
}
return true; // Enabled by default
}
export async function setScannerCleanupEnabled(enabled: boolean): Promise<void> {
const existing = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_ENABLED_KEY));
if (existing.length > 0) {
await db.update(settings)
.set({ value: enabled ? 'true' : 'false', updatedAt: new Date().toISOString() })
.where(eq(settings.key, SCANNER_CLEANUP_ENABLED_KEY));
} else {
await db.insert(settings).values({
key: SCANNER_CLEANUP_ENABLED_KEY,
value: enabled ? 'true' : 'false'
});
}
}
// =============================================================================
// EXTERNAL STACK PATHS
// =============================================================================
@@ -4721,55 +4580,6 @@ export async function setStackEnvVars(
}
}
/**
* Get the set of secret key names for a stack.
* Used to mask secret values in container inspect responses.
*/
export async function getSecretKeyNames(
stackName: string,
environmentId?: number | null
): Promise<Set<string>> {
const vars = await getStackEnvVars(stackName, environmentId, true);
return new Set(vars.filter(v => v.isSecret).map(v => v.key));
}
/**
* Get the set of env var keys that should be masked in container inspect responses.
* Handles two cases:
* 1. Direct match: env var key == secret key in DB (e.g., DB_PASS=${DB_PASS})
* 2. Interpolation: env var key differs from secret key (e.g., MYSQL_PASSWORD=${db_secret})
* Detected by parsing the compose file for ${variable} references in environment: sections.
*
* @param composeContent - Optional compose file content. If provided, interpolation
* references are parsed to detect secrets injected under different key names.
*/
export async function getSecretKeysToMask(
stackName: string,
environmentId?: number | null,
composeContent?: string | null
): Promise<Set<string>> {
const vars = await getStackEnvVars(stackName, environmentId, true);
const secretKeyNames = new Set(vars.filter(v => v.isSecret).map(v => v.key));
if (secretKeyNames.size === 0) return secretKeyNames;
// If we have compose content, parse interpolation references to find
// container env keys that map to secret interpolation variables.
// e.g., "MYSQL_PASSWORD=${db_secret}" → if db_secret is a secret, mask MYSQL_PASSWORD too.
if (composeContent) {
const interpolated = parseEnvInterpolation(composeContent);
for (const [containerKey, varName] of interpolated) {
if (secretKeyNames.has(varName)) {
secretKeyNames.add(containerKey);
}
}
}
return secretKeyNames;
}
export { parseEnvInterpolation } from './env-interpolation';
/**
* Get count of environment variables for a stack.
* @param stackName - Name of the stack
+4 -8
View File
@@ -335,8 +335,7 @@ const REQUIRED_TABLES = [
'audit_logs',
'container_events',
'schedule_executions',
'user_preferences',
'api_tokens'
'user_preferences'
];
/**
@@ -769,7 +768,7 @@ async function seedDatabase(): Promise<void> {
license: ['manage'],
audit_logs: ['view'],
activity: ['view'],
schedules: ['view', 'edit', 'run']
schedules: ['view']
});
const operatorPermissions = JSON.stringify({
@@ -788,7 +787,7 @@ async function seedDatabase(): Promise<void> {
license: [],
audit_logs: [],
activity: ['view'],
schedules: ['view', 'edit', 'run']
schedules: ['view']
});
const viewerPermissions = JSON.stringify({
@@ -899,7 +898,6 @@ export const userPreferences = schemaProxy.userPreferences;
export const scheduleExecutions = schemaProxy.scheduleExecutions;
export const stackEnvironmentVariables = schemaProxy.stackEnvironmentVariables;
export const pendingContainerUpdates = schemaProxy.pendingContainerUpdates;
export const apiTokens = schemaProxy.apiTokens;
// Re-export types from SQLite schema (they're compatible with PostgreSQL)
export type {
@@ -958,9 +956,7 @@ export type {
StackEnvironmentVariable,
NewStackEnvironmentVariable,
PendingContainerUpdate,
NewPendingContainerUpdate,
ApiToken,
NewApiToken
NewPendingContainerUpdate
} from './schema/index.js';
export { eq, and, or, desc, asc, like, sql, inArray, isNull, isNotNull } from 'drizzle-orm';
-24
View File
@@ -315,9 +315,7 @@ export const gitStacks = sqliteTable('git_stacks', {
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
webhookEnabled: integer('webhook_enabled', { mode: 'boolean' }).default(false),
webhookSecret: text('webhook_secret'),
contextDir: text('context_dir'), // Working directory relative to repo root (null = compose file's directory)
buildOnDeploy: integer('build_on_deploy', { mode: 'boolean' }).default(false),
noBuildCache: integer('no_build_cache', { mode: 'boolean' }).default(false),
repullImages: integer('repull_images', { mode: 'boolean' }).default(false),
forceRedeploy: integer('force_redeploy', { mode: 'boolean' }).default(false),
lastSync: text('last_sync'),
@@ -469,25 +467,6 @@ export const pendingContainerUpdates = sqliteTable('pending_container_updates',
envContainerUnique: unique().on(table.environmentId, table.containerId)
}));
// =============================================================================
// API TOKENS TABLE
// =============================================================================
export const apiTokens = sqliteTable('api_tokens', {
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
tokenHash: text('token_hash').notNull().unique(),
tokenPrefix: text('token_prefix').notNull(),
lastUsed: text('last_used'),
expiresAt: text('expires_at'),
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
}, (table) => ({
userIdIdx: index('api_tokens_user_id_idx').on(table.userId),
tokenPrefixIdx: index('api_tokens_token_prefix_idx').on(table.tokenPrefix)
}));
// =============================================================================
// USER PREFERENCES TABLE (unified key-value store)
// =============================================================================
@@ -591,6 +570,3 @@ export type NewStackEnvironmentVariable = typeof stackEnvironmentVariables.$infe
export type PendingContainerUpdate = typeof pendingContainerUpdates.$inferSelect;
export type NewPendingContainerUpdate = typeof pendingContainerUpdates.$inferInsert;
export type ApiToken = typeof apiTokens.$inferSelect;
export type NewApiToken = typeof apiTokens.$inferInsert;
-21
View File
@@ -318,9 +318,7 @@ export const gitStacks = pgTable('git_stacks', {
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
webhookEnabled: boolean('webhook_enabled').default(false),
webhookSecret: text('webhook_secret'),
contextDir: text('context_dir'), // Working directory relative to repo root (null = compose file's directory)
buildOnDeploy: boolean('build_on_deploy').default(false),
noBuildCache: boolean('no_build_cache').default(false),
repullImages: boolean('repull_images').default(false),
forceRedeploy: boolean('force_redeploy').default(false),
lastSync: timestamp('last_sync', { mode: 'string' }),
@@ -472,25 +470,6 @@ export const pendingContainerUpdates = pgTable('pending_container_updates', {
envContainerUnique: unique().on(table.environmentId, table.containerId)
}));
// =============================================================================
// API TOKENS TABLE
// =============================================================================
export const apiTokens = pgTable('api_tokens', {
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
tokenHash: text('token_hash').notNull().unique(),
tokenPrefix: text('token_prefix').notNull(),
lastUsed: timestamp('last_used', { mode: 'string' }),
expiresAt: timestamp('expires_at', { mode: 'string' }),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
}, (table) => ({
userIdIdx: index('api_tokens_user_id_idx').on(table.userId),
tokenPrefixIdx: index('api_tokens_token_prefix_idx').on(table.tokenPrefix)
}));
// =============================================================================
// USER PREFERENCES TABLE (unified key-value store)
// =============================================================================
+45 -101
View File
@@ -14,7 +14,6 @@ import * as tls from 'node:tls';
import { createHash } from 'node:crypto';
import type { Environment } from './db';
import { getStackEnvVarsAsRecord } from './db';
import { getAdditionalVolumeBinds } from './mount-dedupe';
import { isSystemContainer } from './scheduler/tasks/update-utils';
import { deepDiff } from '../utils/diff.js';
@@ -416,8 +415,7 @@ export function httpsAgentRequest(
if (!streaming) {
const isComposeOperation = path === '/_hawser/compose';
const composeTimeoutMs = parseInt(process.env.COMPOSE_TIMEOUT || '900') * 1000;
const isPrune = path.endsWith('/prune');
reqOptions.timeout = isComposeOperation ? composeTimeoutMs : isPrune ? 300000 : 30000;
reqOptions.timeout = isComposeOperation ? composeTimeoutMs : 30000;
}
// Honor AbortSignal from caller (e.g., AbortSignal.timeout(5000) for ping)
@@ -886,7 +884,7 @@ export async function dockerFetch(
body,
headers,
streaming || false,
(streaming || path === '/_hawser/compose' || path.endsWith('/prune')) ? 300000 : 30000, // 5 min for streaming/compose/prune, 30s for normal
(streaming || path === '/_hawser/compose') ? 300000 : 30000, // 5 min for streaming/compose, 30s for normal
isBinary,
fetchOptions.signal ?? undefined
);
@@ -962,8 +960,7 @@ export async function dockerFetch(
if (!streaming && !finalOptions.signal) {
const isComposeOperation = path === '/_hawser/compose';
const composeTimeoutMs = parseInt(process.env.COMPOSE_TIMEOUT || '900') * 1000;
const isPrune = path.endsWith('/prune');
finalOptions.signal = AbortSignal.timeout(isComposeOperation ? composeTimeoutMs : isPrune ? 300000 : 30000);
finalOptions.signal = AbortSignal.timeout(isComposeOperation ? composeTimeoutMs : 30000);
}
try {
@@ -1228,9 +1225,9 @@ export interface DeviceRequest {
export interface CreateContainerOptions {
name: string;
image: string;
ports?: { [key: string]: { HostIp?: string; HostPort: string } } | null;
ports?: { [key: string]: { HostIp?: string; HostPort: string } };
volumes?: { [key: string]: {} };
volumeBinds?: string[] | null;
volumeBinds?: string[];
env?: string[];
labels?: { [key: string]: string };
cmd?: string[];
@@ -1248,15 +1245,9 @@ export interface CreateContainerOptions {
networkIpv6Address?: string;
/** Gateway priority for the primary network (Docker Engine 28+) */
networkGwPriority?: number;
/** Per-network endpoint configuration (IPv4, IPv6, aliases) */
networkConfigs?: Record<string, {
ipv4Address?: string;
ipv6Address?: string;
aliases?: string[];
}>;
user?: string | null;
privileged?: boolean;
healthcheck?: HealthcheckConfig | null;
healthcheck?: HealthcheckConfig;
memory?: number;
memoryReservation?: number;
memorySwap?: number;
@@ -1347,10 +1338,7 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
containerConfig.User = options.user ?? '';
}
if (options.healthcheck === null) {
// Explicitly disable healthcheck (user cleared it)
containerConfig.Healthcheck = { Test: ["NONE"] };
} else if (options.healthcheck) {
if (options.healthcheck) {
containerConfig.Healthcheck = {};
if (options.healthcheck.test && options.healthcheck.test.length > 0) {
containerConfig.Healthcheck.Test = options.healthcheck.test;
@@ -1369,11 +1357,7 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
}
}
if (options.ports === null) {
// Explicitly clear ports (user removed all mappings)
containerConfig.ExposedPorts = {};
containerConfig.HostConfig.PortBindings = {};
} else if (options.ports) {
if (options.ports) {
containerConfig.ExposedPorts = {};
containerConfig.HostConfig.PortBindings = {};
@@ -1383,10 +1367,7 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
}
}
if (options.volumeBinds === null) {
// Explicitly clear volume binds (user removed all)
containerConfig.HostConfig.Binds = [];
} else if (options.volumeBinds && options.volumeBinds.length > 0) {
if (options.volumeBinds && options.volumeBinds.length > 0) {
containerConfig.HostConfig.Binds = options.volumeBinds;
}
@@ -1437,25 +1418,10 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
for (const network of options.networks) {
const isFirstNetwork = network === options.networks[0];
const netCfg = options.networkConfigs?.[network];
const endpointConfig: any = {};
// Per-network config from networkConfigs (takes precedence)
if (netCfg) {
if (netCfg.aliases && netCfg.aliases.length > 0) {
endpointConfig.Aliases = netCfg.aliases;
}
if (netCfg.ipv4Address || netCfg.ipv6Address) {
endpointConfig.IPAMConfig = {};
if (netCfg.ipv4Address) {
endpointConfig.IPAMConfig.IPv4Address = netCfg.ipv4Address;
}
if (netCfg.ipv6Address) {
endpointConfig.IPAMConfig.IPv6Address = netCfg.ipv6Address;
}
}
} else if (isFirstNetwork) {
// Backward compat: apply flat fields to first network if no networkConfigs
// Apply aliases, static IP, and gateway priority only to the first (primary) network
if (isFirstNetwork) {
if (options.networkAliases && options.networkAliases.length > 0) {
endpointConfig.Aliases = options.networkAliases;
}
@@ -1638,22 +1604,9 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
containerConfig.StopTimeout = options.stopTimeout;
}
// MAC address — set both top-level (API <1.44) and endpoint config (API 1.44+)
// MAC address
if (options.macAddress) {
containerConfig.MacAddress = options.macAddress;
// For Docker API 1.44+, MacAddress must be in EndpointConfig
const primaryNetwork = options.networks?.[0] || options.networkMode || 'bridge';
if (containerConfig.NetworkingConfig?.EndpointsConfig?.[primaryNetwork]) {
containerConfig.NetworkingConfig.EndpointsConfig[primaryNetwork].MacAddress = options.macAddress;
} else {
containerConfig.NetworkingConfig = containerConfig.NetworkingConfig || { EndpointsConfig: {} };
containerConfig.NetworkingConfig.EndpointsConfig = containerConfig.NetworkingConfig.EndpointsConfig || {};
containerConfig.NetworkingConfig.EndpointsConfig[primaryNetwork] = {
...containerConfig.NetworkingConfig.EndpointsConfig[primaryNetwork],
MacAddress: options.macAddress
};
}
}
// Extra hosts (/etc/hosts entries)
@@ -1952,7 +1905,20 @@ export async function recreateContainerFromInspect(
}
}
const additionalBinds = getAdditionalVolumeBinds(hostConfig, inspectData.Mounts || []);
// Preserve anonymous volumes from Mounts not in HostConfig.Binds
const existingBinds = new Set((hostConfig.Binds || []).map((b: string) => {
const parts = b.split(':');
return parts.length >= 2 ? parts[1] : parts[0];
}));
const mounts = inspectData.Mounts || [];
const additionalBinds: string[] = [];
for (const mount of mounts) {
if (mount.Type === 'volume' && mount.Name && mount.Destination) {
if (!existingBinds.has(mount.Destination)) {
additionalBinds.push(`${mount.Name}:${mount.Destination}`);
}
}
}
if (additionalBinds.length > 0) {
createConfig.HostConfig = {
...hostConfig,
@@ -2359,15 +2325,11 @@ export async function updateContainer(id: string, options: Partial<CreateContain
const mergedOptions: CreateContainerOptions = {
...existingOptions,
...options,
// Replace labels, but preserve Docker internal labels (com.docker.*)
labels: options.labels !== undefined
? {
...Object.fromEntries(
Object.entries(existingOptions.labels || {}).filter(([k]) => k.startsWith('com.docker.'))
),
...options.labels
}
: existingOptions.labels
// Special handling for labels - merge instead of replace to preserve Docker internal labels
labels: {
...existingOptions.labels,
...options.labels
}
};
// 1. Stop old container
@@ -2431,7 +2393,7 @@ export async function updateContainer(id: string, options: Partial<CreateContain
}
// 5. Start if needed
if (startAfterUpdate) {
if (startAfterUpdate || wasRunning) {
try {
await newContainer.start();
} catch (startError) {
@@ -2663,9 +2625,7 @@ function parseImageReference(imageName: string): { registry: string; repo: strin
* 'ghcr.io' -> { host: 'ghcr.io', path: '', fullRegistry: 'ghcr.io' }
* 'registry.example.com:5000/myorg' -> { host: 'registry.example.com:5000', path: '/myorg', fullRegistry: 'registry.example.com:5000/myorg' }
*/
export function parseRegistryUrl(url: string): { host: string; path: string; fullRegistry: string; protocol: string } {
// Detect protocol (default to https)
const protocol = url.startsWith('http://') ? 'http' : 'https';
export function parseRegistryUrl(url: string): { host: string; path: string; fullRegistry: string } {
// Remove protocol
const withoutProtocol = url.replace(/^https?:\/\//, '');
// Remove trailing slash
@@ -2673,11 +2633,11 @@ export function parseRegistryUrl(url: string): { host: string; path: string; ful
// Split on first slash (after port if present)
const slashIndex = trimmed.indexOf('/');
if (slashIndex === -1) {
return { host: trimmed, path: '', fullRegistry: trimmed, protocol };
return { host: trimmed, path: '', fullRegistry: trimmed };
}
const host = trimmed.substring(0, slashIndex);
const path = trimmed.substring(slashIndex); // includes leading /
return { host, path, fullRegistry: trimmed, protocol };
return { host, path, fullRegistry: trimmed };
}
/**
@@ -2862,7 +2822,7 @@ export async function getRegistryAuthHeader(
try {
// Parse URL to extract host (V2 API is always at the host root)
const parsed = parseRegistryUrl(registryUrl);
const apiBaseUrl = `${parsed.protocol}://${parsed.host}`;
const apiBaseUrl = `https://${parsed.host}`;
// Step 1: Challenge request to /v2/ (always at registry root, not under org path)
const challengeResponse = await fetch(`${apiBaseUrl}/v2/`, {
@@ -2971,7 +2931,7 @@ export async function getRegistryAuth(
const parsed = parseRegistryUrl(registry.url);
// V2 API endpoints are always at the registry host root
const baseUrl = `${parsed.protocol}://${parsed.host}`;
const baseUrl = `https://${parsed.host}`;
// Get auth header using proper token flow
const credentials = registry.username && registry.password
@@ -3882,25 +3842,19 @@ export async function getContainerTop(id: string, envId?: number | null): Promis
export async function execInContainer(
containerId: string,
cmd: string[],
envId?: number | null,
user?: string | null
envId?: number | null
): Promise<string> {
const execBody: any = {
Cmd: cmd,
AttachStdout: true,
AttachStderr: true,
Tty: false
};
if (user) {
execBody.User = user;
}
// Create exec instance
const execCreate = await dockerJsonRequest<{ Id: string }>(
`/containers/${containerId}/exec`,
{
method: 'POST',
body: JSON.stringify(execBody)
body: JSON.stringify({
Cmd: cmd,
AttachStdout: true,
AttachStderr: true,
Tty: false
})
},
envId
);
@@ -3996,7 +3950,6 @@ export async function runContainer(options: {
cmd: string[];
binds?: string[];
env?: string[];
extraHosts?: string[];
name?: string;
envId?: number | null;
}): Promise<{ stdout: string; stderr: string }> {
@@ -4018,10 +3971,6 @@ export async function runContainer(options: {
}
};
if (options.extraHosts && options.extraHosts.length > 0) {
containerConfig.HostConfig.ExtraHosts = options.extraHosts;
}
const createResult = await dockerJsonRequest<{ Id: string }>(
`/containers/create?name=${encodeURIComponent(containerName)}`,
{
@@ -4081,7 +4030,6 @@ export async function runContainerWithStreaming(options: {
cmd: string[];
binds?: string[];
env?: string[];
extraHosts?: string[];
name?: string;
user?: string;
envId?: number | null;
@@ -4109,10 +4057,6 @@ export async function runContainerWithStreaming(options: {
}
};
if (options.extraHosts && options.extraHosts.length > 0) {
containerConfig.HostConfig.ExtraHosts = options.extraHosts;
}
// Set user if specified (needed for rootless Docker socket access)
if (options.user) {
containerConfig.User = options.user;
-36
View File
@@ -1,36 +0,0 @@
/**
* Parse compose YAML to extract environment variable interpolation mappings.
* Returns pairs of [containerEnvKey, interpolationVariable].
*
* Handles patterns:
* - VAR=${ref}
* - VAR=${ref:-default}
* - VAR=${ref:+alt}
* - VAR=${ref?error}
*
* Only extracts from `environment:` sections (list format: `- KEY=value`).
*/
export function parseEnvInterpolation(composeContent: string): Array<[string, string]> {
const results: Array<[string, string]> = [];
// Step 1: Find lines matching `- ENV_KEY=...${...}...`
const linePattern = /^\s*-\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)/gm;
let lineMatch;
while ((lineMatch = linePattern.exec(composeContent)) !== null) {
const containerKey = lineMatch[1];
const valueStr = lineMatch[2];
// Step 2: Extract all ${VAR} references from the value
const varPattern = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?:[:\-\+\?][^}]*)?\}/g;
let varMatch;
while ((varMatch = varPattern.exec(valueStr)) !== null) {
const varName = varMatch[1];
// Only add if names differ — same-name case handled by direct key matching
if (containerKey !== varName) {
results.push([containerKey, varName]);
}
}
}
return results;
}
+32 -128
View File
@@ -16,70 +16,6 @@ import {
} from './db';
import { deployStack, getStackDir } from './stacks';
const MERGED_CA_BUNDLE_PATH = '/tmp/dockhand-merged-ca-bundle.crt';
let mergedCaBundleReady = false;
/**
* Create a merged CA bundle combining system CAs with the custom cert from
* NODE_EXTRA_CA_CERTS. GIT_SSL_CAINFO replaces the default CA store, so without
* merging, public CAs (GitHub, GitLab) break.
*/
function getMergedCaBundlePath(): string {
if (mergedCaBundleReady && existsSync(MERGED_CA_BUNDLE_PATH)) {
console.log(`[Git] Using cached merged CA bundle: ${MERGED_CA_BUNDLE_PATH}`);
return MERGED_CA_BUNDLE_PATH;
}
const customCertPath = process.env.NODE_EXTRA_CA_CERTS!;
console.log(`[Git] NODE_EXTRA_CA_CERTS set to: ${customCertPath}`);
const systemCaPaths = [
process.env.SSL_CERT_FILE,
'/etc/ssl/certs/ca-certificates.crt',
'/etc/pki/tls/certs/ca-bundle.crt',
'/etc/ssl/cert.pem'
];
let systemCaContent = '';
let systemCaSource = '';
for (const caPath of systemCaPaths) {
if (caPath && existsSync(caPath)) {
try {
systemCaContent = readFileSync(caPath, 'utf-8');
systemCaSource = caPath;
console.log(`[Git] Found system CA bundle: ${caPath} (${systemCaContent.split('-----BEGIN CERTIFICATE-----').length - 1} certs)`);
break;
} catch (err) {
console.log(`[Git] Failed to read system CA bundle ${caPath}: ${err}`);
}
}
}
if (!systemCaSource) {
console.log(`[Git] No system CA bundle found, using custom cert only: ${customCertPath}`);
}
try {
const customCaContent = readFileSync(customCertPath, 'utf-8');
const customCertCount = customCaContent.split('-----BEGIN CERTIFICATE-----').length - 1;
console.log(`[Git] Custom CA file contains ${customCertCount} cert(s)`);
const merged = systemCaContent
? systemCaContent.trimEnd() + '\n' + customCaContent.trimEnd() + '\n'
: customCaContent;
writeFileSync(MERGED_CA_BUNDLE_PATH, merged);
mergedCaBundleReady = true;
const totalCerts = merged.split('-----BEGIN CERTIFICATE-----').length - 1;
console.log(`[Git] Created merged CA bundle: ${MERGED_CA_BUNDLE_PATH} (${totalCerts} total certs — system from ${systemCaSource || 'none'} + custom from ${customCertPath})`);
} catch (err) {
console.warn(`[Git] Failed to create merged CA bundle, falling back to custom cert only: ${customCertPath}`, err);
return customCertPath;
}
return MERGED_CA_BUNDLE_PATH;
}
/**
* Collect stdout, stderr and exit code from a spawned process.
*/
@@ -110,14 +46,21 @@ if (!existsSync(GIT_REPOS_DIR)) {
}
/**
* Redact all env var values for safe logging. Only key names are preserved.
* Mask sensitive values in environment variables for safe logging.
*/
function redactEnvVarsForLog(vars: Record<string, string>): Record<string, string> {
const redacted: Record<string, string> = {};
for (const key of Object.keys(vars)) {
redacted[key] = '***';
function maskSecrets(vars: Record<string, string>): Record<string, string> {
const masked: Record<string, string> = {};
const secretPatterns = /password|secret|token|key|api_key|apikey|auth|credential|private/i;
for (const [key, value] of Object.entries(vars)) {
if (secretPatterns.test(key)) {
masked[key] = '***';
} else if (value.length > 50) {
masked[key] = value.substring(0, 10) + '...(truncated)';
} else {
masked[key] = value;
}
}
return redacted;
return masked;
}
function getRepoPath(repoId: number): string {
@@ -210,11 +153,9 @@ async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
SSH_AUTH_SOCK: ''
};
// Pass custom CA certificate to git CLI (NODE_EXTRA_CA_CERTS only affects Node.js).
// GIT_SSL_CAINFO replaces the default CA store, so we merge system CAs with the
// custom cert so both self-signed repos and public repos (GitHub etc.) work (#967).
// Pass custom CA certificate to git CLI (NODE_EXTRA_CA_CERTS only affects Node.js)
if (process.env.NODE_EXTRA_CA_CERTS) {
env.GIT_SSL_CAINFO = getMergedCaBundlePath();
env.GIT_SSL_CAINFO = process.env.NODE_EXTRA_CA_CERTS;
}
// Ensure current UID is resolvable for SSH/git operations
@@ -836,15 +777,15 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
// (e.g., config files, scripts, additional env files)
let changedFiles: string[] = [];
if (commitChanged) {
// Use contextDir if set, otherwise fall back to compose file's directory
const diffDirRelative = gitStack.contextDir || dirname(gitStack.composePath);
console.log(`${logPrefix} Checking for changes in directory: ${diffDirRelative || '(root)'}`);
// Get the directory containing the compose file (relative to repo root)
const composeDirRelative = dirname(gitStack.composePath);
console.log(`${logPrefix} Checking for changes in directory: ${composeDirRelative || '(root)'}`);
const diffResult = await getChangedFilesInDir(
repoPath,
previousCommit,
newCommit,
diffDirRelative || '.',
composeDirRelative || '.',
env
);
@@ -886,29 +827,10 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
console.log(`${logPrefix} Compose content:`);
console.log(composeContent);
// Determine the source directory and compose filename
// If contextDir is set, use it as the source directory (relative to repo root)
// and compute composeFileName as relative path from contextDir to compose file
let composeDir: string;
let composeFileName: string;
if (gitStack.contextDir) {
const contextDirAbsolute = resolve(repoPath, gitStack.contextDir);
// Validate: context dir must be within repo
if (!contextDirAbsolute.startsWith(repoPath)) {
throw new Error('Context directory must be within the repository');
}
// Validate: compose file must be within context directory
const relCompose = relative(contextDirAbsolute, composePath);
if (relCompose.startsWith('..')) {
throw new Error('Compose file must be within the context directory');
}
composeDir = contextDirAbsolute;
composeFileName = relCompose; // e.g., "apps/myapp/compose.yaml"
} else {
composeDir = dirname(composePath);
composeFileName = basename(gitStack.composePath); // e.g., "docker-compose.yaml"
}
console.log(`${logPrefix} Source directory (composeDir):`, composeDir);
// Determine the compose directory and filename (for copying all files)
const composeDir = dirname(composePath);
const composeFileName = basename(gitStack.composePath); // e.g., "docker-compose.yaml"
console.log(`${logPrefix} Compose directory:`, composeDir);
console.log(`${logPrefix} Compose filename:`, composeFileName);
// Read env file if configured (optional - don't fail if missing)
@@ -1010,7 +932,7 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
console.log(`${logPrefix} Sync result - env file vars:`, syncResult.envFileVars ? Object.keys(syncResult.envFileVars).length : 0);
if (syncResult.envFileVars && Object.keys(syncResult.envFileVars).length > 0) {
console.log(`${logPrefix} Env file var keys:`, Object.keys(syncResult.envFileVars).join(', '));
console.log(`${logPrefix} Env file vars (masked):`, JSON.stringify(redactEnvVarsForLog(syncResult.envFileVars), null, 2));
console.log(`${logPrefix} Env file vars (masked):`, JSON.stringify(maskSecrets(syncResult.envFileVars), null, 2));
}
// Check if there are changes - skip redeploy if no changes and not forced
@@ -1050,7 +972,6 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
envFileName: syncResult.envFileName, // Env file relative to compose dir (for --env-file flag, optional)
forceRecreate,
build: gitStack.buildOnDeploy,
noBuildCache: gitStack.noBuildCache,
pullPolicy: gitStack.repullImages ? 'always' : undefined
});
@@ -1227,15 +1148,15 @@ export async function deployGitStackWithProgress(
// Normalize to 7-char short hash for comparison (DB stores 7-char, git returns 40-char)
const commitChanged = previousCommit?.substring(0, 7) !== newCommit.substring(0, 7);
// Check if any files in the context/compose directory have changed
// Check if any files in the compose file's directory have changed
// (for consistency with syncGitStack, though this function always deploys)
if (commitChanged) {
const diffDir = gitStack.contextDir || dirname(gitStack.composePath);
const composeDir = dirname(gitStack.composePath);
const diffResult = await getChangedFilesInDir(
repoPath,
previousCommit,
newCommit,
diffDir || '.',
composeDir || '.',
env
);
updated = diffResult.changed;
@@ -1256,24 +1177,8 @@ export async function deployGitStackWithProgress(
const composeContent = readFileSync(composePath, 'utf-8');
// Determine the source directory and compose filename
let composeDir: string;
let progressComposeFileName: string;
if (gitStack.contextDir) {
const contextDirAbsolute = resolve(repoPath, gitStack.contextDir);
if (!contextDirAbsolute.startsWith(repoPath)) {
throw new Error('Context directory must be within the repository');
}
const relCompose = relative(contextDirAbsolute, composePath);
if (relCompose.startsWith('..')) {
throw new Error('Compose file must be within the context directory');
}
composeDir = contextDirAbsolute;
progressComposeFileName = relCompose;
} else {
composeDir = dirname(composePath);
progressComposeFileName = basename(gitStack.composePath);
}
// Determine the compose directory (for copying all files)
const composeDir = dirname(composePath);
// Read env file if configured (optional - don't fail if missing)
let envFileVars: Record<string, string> | undefined;
@@ -1320,17 +1225,16 @@ export async function deployGitStackWithProgress(
compose: composeContent,
envId: gitStack.environmentId,
sourceDir: composeDir, // Copy entire directory from git repo
composeFileName: progressComposeFileName, // Compose filename relative to source dir
composeFileName: basename(gitStack.composePath), // Use original compose filename from repo
envFileName, // Env file relative to compose dir (for --env-file flag, optional)
build: gitStack.buildOnDeploy,
noBuildCache: gitStack.noBuildCache,
pullPolicy: gitStack.repullImages ? 'always' : undefined
});
if (result.success) {
// Record the stack source with resolved compose path for consistency
const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId);
const resolvedComposePath = join(stackDir, progressComposeFileName);
const resolvedComposePath = join(stackDir, basename(gitStack.composePath));
await upsertStackSource({
stackName: gitStack.stackName,
@@ -1461,7 +1365,7 @@ export function parseEnvFileContent(content: string, stackName?: string): Record
console.log(`${logPrefix} Parsed env vars count:`, Object.keys(result).length);
console.log(`${logPrefix} Parsed env var keys:`, Object.keys(result).join(', '));
console.log(`${logPrefix} Parsed env vars (masked):`, JSON.stringify(redactEnvVarsForLog(result), null, 2));
console.log(`${logPrefix} Parsed env vars (masked):`, JSON.stringify(maskSecrets(result), null, 2));
if (skippedLines.length > 0) {
console.log(`${logPrefix} Skipped lines (${skippedLines.length}):`, skippedLines.slice(0, 10).join('; '));
}
+17 -20
View File
@@ -9,7 +9,6 @@ import { db, hawserTokens, environments, eq, and } from './db/drizzle.js';
import { logContainerEvent, type ContainerEventAction } from './db.js';
import { containerEventEmitter } from './event-collector.js';
import { sendEnvironmentNotification } from './notifications.js';
import { isNotifyDisabledByLabel } from './container-labels.js';
import { pushMetric } from './metrics-store.js';
import { secureGetRandomValues, secureRandomUUID } from './crypto-fallback.js';
import { hashPassword, verifyPassword } from './auth.js';
@@ -192,26 +191,24 @@ export async function handleEdgeContainerEvent(
// Broadcast to SSE clients
containerEventEmitter.emit('event', savedEvent);
// Check dockhand.notify label before sending notification
// Docker includes container labels in actorAttributes
if (!isNotifyDisabledByLabel(event.actorAttributes)) {
const actionLabel = event.action.charAt(0).toUpperCase() + event.action.slice(1);
const containerLabel = event.containerName || event.containerId.substring(0, 12);
const notificationType =
event.action === 'die' || event.action === 'kill' || event.action === 'oom'
? 'error'
: event.action === 'stop'
? 'warning'
: event.action === 'start'
? 'success'
: 'info';
// Prepare notification
const actionLabel = event.action.charAt(0).toUpperCase() + event.action.slice(1);
const containerLabel = event.containerName || event.containerId.substring(0, 12);
const notificationType =
event.action === 'die' || event.action === 'kill' || event.action === 'oom'
? 'error'
: event.action === 'stop'
? 'warning'
: event.action === 'start'
? 'success'
: 'info';
await sendEnvironmentNotification(environmentId, event.action as ContainerEventAction, {
title: `Container ${actionLabel}`,
message: `Container "${containerLabel}" ${event.action}${event.image ? ` (${event.image})` : ''}`,
type: notificationType as 'success' | 'error' | 'warning' | 'info'
}, event.image);
}
// Send notification
await sendEnvironmentNotification(environmentId, event.action as ContainerEventAction, {
title: `Container ${actionLabel}`,
message: `Container "${containerLabel}" ${event.action}${event.image ? ` (${event.image})` : ''}`,
type: notificationType as 'success' | 'error' | 'warning' | 'info'
}, event.image);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error('[Hawser] Error handling container event:', errorMsg);
+2 -27
View File
@@ -34,7 +34,6 @@ let cachedMounts: Array<{ source: string; destination: string }> | null = null;
// Used by scanner to replicate how Dockhand connects to Docker
let cachedOwnDockerHost: string | null = null;
let cachedOwnNetworkMode: string | null = null;
let cachedOwnExtraHosts: string[] | null = null;
/**
* Get our own container ID
@@ -86,11 +85,12 @@ export async function detectHostDataDir(): Promise<string | null> {
if (process.env.HOST_DATA_DIR) {
cachedHostDataDir = process.env.HOST_DATA_DIR;
console.log(`[HostPath] Using HOST_DATA_DIR from environment: ${cachedHostDataDir}`);
return cachedHostDataDir;
}
const containerId = getOwnContainerId();
if (!containerId) {
console.warn('[HostPath] Running in Docker but could not detect container ID; ExtraHosts will not be mirrored to sidecars');
console.warn('[HostPath] Running in Docker but could not detect container ID');
return null;
}
@@ -140,9 +140,6 @@ export async function detectHostDataDir(): Promise<string | null> {
Config?: {
Env?: string[];
};
HostConfig?: {
ExtraHosts?: string[];
};
NetworkSettings?: {
Networks?: Record<string, unknown>;
};
@@ -179,19 +176,6 @@ export async function detectHostDataDir(): Promise<string | null> {
}
}
cachedOwnExtraHosts = containerInfo.HostConfig?.ExtraHosts?.length
? [...containerInfo.HostConfig.ExtraHosts]
: null;
if (cachedOwnExtraHosts) {
console.log(`[HostPath] Detected own ExtraHosts: ${cachedOwnExtraHosts.join(', ')}`);
}
// Explicit override wins for DATA_DIR path, but we still inspect to populate
// mounts/network/DOCKER_HOST/ExtraHosts caches for sibling sidecars.
if (cachedHostDataDir) {
return cachedHostDataDir;
}
// Find the mount for our DATA_DIR
const dataMount = containerInfo.Mounts?.find(m => m.Destination === dataDir);
@@ -245,15 +229,6 @@ export function getOwnNetworkMode(): string | null {
return cachedOwnNetworkMode;
}
/**
* Get the ExtraHosts entries configured on Dockhand itself.
* Used to mirror host aliases into sibling sidecar containers.
* Populated by detectHostDataDir() at startup.
*/
export function getOwnExtraHosts(): string[] | null {
return cachedOwnExtraHosts ? [...cachedOwnExtraHosts] : null;
}
/**
* Translate a container path to host path
*
-36
View File
@@ -1,36 +0,0 @@
type HostConfigLike = {
Binds?: string[] | null;
Mounts?: Array<{ Target?: string | null }> | null;
};
type InspectMountLike = {
Type?: string | null;
Name?: string | null;
Destination?: string | null;
};
/** Build extra bind strings for volume mounts missing from HostConfig. */
export function getAdditionalVolumeBinds(
hostConfig: HostConfigLike,
mounts: InspectMountLike[]
): string[] {
const existingMountTargets = new Set((hostConfig.Binds || []).map((bind: string) => {
const parts = bind.split(':');
return parts.length >= 2 ? parts[1] : parts[0];
}));
for (const mount of hostConfig.Mounts || []) {
if (mount?.Target) existingMountTargets.add(mount.Target);
}
const additionalBinds: string[] = [];
for (const mount of mounts || []) {
if (mount.Type === 'volume' && mount.Name && mount.Destination) {
if (!existingMountTargets.has(mount.Destination)) {
additionalBinds.push(`${mount.Name}:${mount.Destination}`);
}
}
}
return additionalBinds;
}
+42 -111
View File
@@ -9,7 +9,17 @@ import {
type NotificationEventType
} from './db';
import { escapeTelegramMarkdown, parseTelegramUrl, buildGotifyUrl, parseWorkflowsUrl, buildWorkflowsHttpUrl } from '$lib/utils/notification-parsers';
// Escape special characters for Telegram Markdown
function escapeTelegramMarkdown(text: string): string {
// Escape characters that have special meaning in Telegram Markdown
return text
.replace(/\\/g, '\\\\') // Escape backslashes first
.replace(/_/g, '\\_') // Underscore (italic)
.replace(/\*/g, '\\*') // Asterisk (bold)
.replace(/\[/g, '\\[') // Opening bracket (link)
.replace(/\]/g, '\\]') // Closing bracket (link)
.replace(/`/g, '\\`'); // Backtick (code)
}
/** Drain a response body to release the underlying socket/TLS connection. */
async function drainResponse(response: Response): Promise<void> {
@@ -134,8 +144,6 @@ async function sendToAppriseUrl(url: string, payload: NotificationPayload): Prom
case 'json':
case 'jsons':
return await sendGenericWebhook(url, payload);
case 'workflows':
return await sendWorkflows(url, payload);
default:
return { success: false, error: `Unsupported Apprise protocol: ${protocol}` };
}
@@ -269,18 +277,19 @@ async function sendMattermost(appriseUrl: string, payload: NotificationPayload):
// Telegram
async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const parsed = parseTelegramUrl(appriseUrl);
if (!parsed) {
return { success: false, error: 'Invalid Telegram URL format. Expected: tgram://bot_token/chat_id or tgram://bot_token/chat_id:topic_id' };
// tgram://bot_token/chat_id
const match = appriseUrl.match(/^tgram:\/\/([^/]+)\/(.+)/);
if (!match) {
return { success: false, error: 'Invalid Telegram URL format. Expected: tgram://bot_token/chat_id' };
}
const { botToken, chatId, topicId } = parsed;
const [, botToken, chatId] = match;
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
// Escape markdown special characters in title and message
const escapedTitle = escapeTelegramMarkdown(payload.title);
const escapedMessage = escapeTelegramMarkdown(payload.message);
const envTag = payload.environmentName ? ` [${escapeTelegramMarkdown(payload.environmentName)}]` : '';
const envTag = payload.environmentName ? ` \\[${escapeTelegramMarkdown(payload.environmentName)}\\]` : '';
try {
const response = await fetch(url, {
@@ -289,11 +298,7 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P
body: JSON.stringify({
chat_id: chatId,
text: `*${escapedTitle}*${envTag}\n${escapedMessage}`,
...(topicId ? { message_thread_id: topicId } : {}),
parse_mode: 'Markdown',
link_preview_options: {
is_disabled: true
}
parse_mode: 'Markdown'
})
});
@@ -311,19 +316,27 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P
// Gotify
async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const url = buildGotifyUrl(appriseUrl);
if (!url) {
// gotify://hostname/token or gotifys://hostname/token
// gotify://hostname/subpath/token (subpath support)
const match = appriseUrl.match(/^gotifys?:\/\/([^/]+)\/(.+)/);
if (!match) {
return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' };
}
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
const [, hostname, pathPart] = match;
const protocol = appriseUrl.startsWith('gotifys') ? 'https' : 'http';
// Token is always the last path segment; anything before it is a subpath
const lastSlash = pathPart.lastIndexOf('/');
const subpath = lastSlash >= 0 ? pathPart.substring(0, lastSlash) : '';
const token = lastSlash >= 0 ? pathPart.substring(lastSlash + 1) : pathPart;
const url = `${protocol}://${hostname}${subpath ? '/' + subpath : ''}/message?token=${token}`;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: titleWithEnv,
title: payload.title,
message: payload.message,
priority: payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2
})
@@ -345,10 +358,7 @@ async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promi
// Supported formats:
// ntfy://topic (public ntfy.sh)
// ntfy://host/topic (custom server, no auth)
// ntfy://user:pass@host/topic (custom server with basic auth)
// ntfy://token@host/topic (custom server with bearer token)
// ntfy://host/topic?auth=BASE64 (custom server with base64-encoded bearer token)
// Query params: ?tags=ship,whale &title=Custom &priority=5
// ntfy://user:pass@host/topic (custom server with auth)
// ntfys:// variants for HTTPS
const isSecure = appriseUrl.startsWith('ntfys');
const path = appriseUrl.replace(/^ntfys?:\/\//, '');
@@ -356,60 +366,36 @@ async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promi
let url: string;
let authHeader: string | null = null;
// Extract query parameters (?auth=, ?tags=, ?title=, ?priority=)
let queryAuth: string | null = null;
let queryTags: string | null = null;
let queryTitle: string | null = null;
let queryPriority: string | null = null;
let cleanPath = path;
const qIndex = path.indexOf('?');
if (qIndex !== -1) {
const params = new URLSearchParams(path.substring(qIndex + 1));
queryAuth = params.get('auth');
queryTags = params.get('tags');
queryTitle = params.get('title');
queryPriority = params.get('priority');
cleanPath = path.substring(0, qIndex);
}
// Check for user:pass@host/topic format (Basic auth)
const basicMatch = cleanPath.match(/^([^:]+):([^@]+)@(.+)$/);
const basicMatch = path.match(/^([^:]+):([^@]+)@(.+)$/);
if (basicMatch) {
const [, user, pass, hostAndTopic] = basicMatch;
const basic = Buffer.from(`${user}:${pass}`).toString('base64');
authHeader = `Basic ${basic}`;
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
} else if (cleanPath.includes('@') && cleanPath.includes('/')) {
} else if (path.includes('@') && path.includes('/')) {
// token@host/topic -> Bearer token auth
const tokenMatch = cleanPath.match(/^([^@]+)@(.+)$/);
const tokenMatch = path.match(/^([^@]+)@(.+)$/);
if (tokenMatch) {
const [, token, hostAndTopic] = tokenMatch;
authHeader = `Bearer ${token}`;
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
} else {
// Fallback to custom server without auth
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
url = `${isSecure ? 'https' : 'http'}://${path}`;
}
} else if (cleanPath.includes('/')) {
} else if (path.includes('/')) {
// Custom server without auth
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
url = `${isSecure ? 'https' : 'http'}://${path}`;
} else {
// Default ntfy.sh
url = `https://ntfy.sh/${cleanPath}`;
url = `https://ntfy.sh/${path}`;
}
// Apply ?auth= as fallback if no explicit auth was set
if (!authHeader && queryAuth) {
const decoded = Buffer.from(queryAuth, 'base64').toString();
authHeader = decoded.startsWith('Bearer ') ? decoded : `Bearer ${decoded}`;
}
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
const defaultTags = payload.type || 'info';
const headers: Record<string, string> = {
'Title': queryTitle || titleWithEnv,
'Priority': queryPriority || (payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3'),
'Tags': queryTags ? `${queryTags},${defaultTags}` : defaultTags
'Title': payload.title,
'Priority': payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3',
'Tags': payload.type || 'info'
};
if (authHeader) {
@@ -444,7 +430,6 @@ async function sendPushover(appriseUrl: string, payload: NotificationPayload): P
const [, userKey, apiToken] = match;
const url = 'https://api.pushover.net/1/messages.json';
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
const response = await fetch(url, {
@@ -453,7 +438,7 @@ async function sendPushover(appriseUrl: string, payload: NotificationPayload): P
body: JSON.stringify({
token: apiToken,
user: userKey,
title: titleWithEnv,
title: payload.title,
message: payload.message,
priority: payload.type === 'error' ? 1 : 0
})
@@ -483,7 +468,6 @@ async function sendGenericWebhook(appriseUrl: string, payload: NotificationPaylo
title: payload.title,
message: payload.message,
type: payload.type || 'info',
environment: payload.environmentName || null,
timestamp: new Date().toISOString()
})
});
@@ -498,59 +482,6 @@ async function sendGenericWebhook(appriseUrl: string, payload: NotificationPaylo
return { success: false, error: `Webhook connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Microsoft Power Automate Workflows, for e.g. Microsoft Teams
async function sendWorkflows(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const parsed = parseWorkflowsUrl(appriseUrl);
if (!parsed) {
return { success: false, error: 'Invalid Workflows URL format. Expected: workflows://hostname/workflow/signature' };
}
const url = buildWorkflowsHttpUrl(parsed.hostname, parsed.workflow, parsed.signature);
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'message',
attachments: [
{
contentType: 'application/vnd.microsoft.card.adaptive',
content: {
$schema: 'https://adaptivecards.io/schemas/adaptive-card.json',
type: 'AdaptiveCard',
version: '1.2',
body: [
{
type: 'TextBlock',
style: 'heading',
wrap: true,
text: titleWithEnv
},
{
type: 'TextBlock',
style: 'default',
wrap: true,
text: payload.message
}
]
}
}
]
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Workflows error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Workflows connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Send notification to all enabled channels
export async function sendNotification(payload: NotificationPayload): Promise<{ success: boolean; results: { name: string; success: boolean }[] }> {
-23
View File
@@ -1,23 +0,0 @@
/**
* Request-scoped context using AsyncLocalStorage.
*
* The hook sets the authenticated user (from cookie or Bearer token)
* and wraps resolve() in requestContext.run(). Downstream code like
* authorize() reads the pre-resolved user from here instead of
* re-validating the session.
*/
import { AsyncLocalStorage } from 'node:async_hooks';
import type { AuthenticatedUser } from './auth';
export interface RequestContext {
user: AuthenticatedUser | null;
authEnabled: boolean;
authMethod: 'cookie' | 'bearer' | 'none';
}
export const requestContext = new AsyncLocalStorage<RequestContext>();
export function getRequestContext(): RequestContext | undefined {
return requestContext.getStore();
}
+2 -20
View File
@@ -16,14 +16,7 @@ import {
} from './docker';
import { getEnvironment, getEnvSetting, getSetting } from './db';
import { sendEventNotification } from './notifications';
import {
getHostDockerSocket,
getHostDataDir,
extractUidFromSocketPath,
getOwnDockerHost,
getOwnExtraHosts,
getOwnNetworkMode
} from './host-path';
import { getHostDockerSocket, getHostDataDir, extractUidFromSocketPath, getOwnDockerHost, getOwnNetworkMode } from './host-path';
import { resolve } from 'node:path';
import { mkdir, chown, rm } from 'node:fs/promises';
@@ -632,7 +625,6 @@ async function runScannerContainerCore(
let rootlessUid: string | undefined;
let scannerNetworkMode: string | undefined;
let scannerDockerHost: string | undefined;
const scannerExtraHosts = !isHawser ? getOwnExtraHosts() ?? undefined : undefined;
// Check if Dockhand itself uses TCP to reach Docker (e.g., socket proxy).
// Detected at startup from Dockhand's own container inspect data.
@@ -644,12 +636,7 @@ async function runScannerContainerCore(
// TCP mode: scanner uses the same DOCKER_HOST + network as Dockhand
scannerDockerHost = ownDockerHost;
scannerNetworkMode = getOwnNetworkMode() ?? undefined;
console.log(
`[Scanner] TCP mode (from container inspect) - DOCKER_HOST=${scannerDockerHost}, network=${scannerNetworkMode ?? 'default'}`
);
if (scannerExtraHosts?.length) {
console.log(`[Scanner] Reusing ExtraHosts from Dockhand: ${scannerExtraHosts.join(', ')}`);
}
console.log(`[Scanner] TCP mode (from container inspect) - DOCKER_HOST=${scannerDockerHost}, network=${scannerNetworkMode ?? 'default'}`);
} else if (isHawser) {
// Hawser: scanner runs on remote host, uses remote host's standard Docker socket
hostSocketPath = '/var/run/docker.sock';
@@ -666,10 +653,6 @@ async function runScannerContainerCore(
console.log(`[Scanner] Rootless Docker detected (UID ${rootlessUid})`);
console.log(`[Scanner] Scanner will run as root inside container (maps to UID ${rootlessUid} on host via user namespace)`);
}
if (scannerExtraHosts?.length) {
console.log(`[Scanner] Reusing ExtraHosts from Dockhand: ${scannerExtraHosts.join(', ')}`);
}
}
// Determine cache storage strategy based on environment
@@ -739,7 +722,6 @@ async function runScannerContainerCore(
cmd,
binds,
env: envVars,
extraHosts: scannerExtraHosts,
name: `dockhand-${scannerType}-${Date.now()}`,
envId,
networkMode: scannerNetworkMode,
+41 -50
View File
@@ -17,12 +17,10 @@ import {
getGitStack,
getScheduleCleanupCron,
getEventCleanupCron,
getScannerCleanupCron,
getScheduleRetentionDays,
getEventRetentionDays,
getScheduleCleanupEnabled,
getEventCleanupEnabled,
getScannerCleanupEnabled,
getEnvironments,
getEnvUpdateCheckSettings,
getAllEnvUpdateCheckSettings,
@@ -66,31 +64,6 @@ let scannerCacheCleanupJob: Cron | null = null;
// Scheduler state
let isRunning = false;
/**
* Scanner cache cleanup function that cleans local and all remote environments.
* Shared between cron job, timezone refresh, and manual trigger.
*/
async function scannerCleanupAllEnvs(): Promise<{ volumes: string[]; dirs: string[] }> {
const { cleanupScannerCache } = await import('../scanner');
const envs = await getEnvironments();
// Clean local cache (volumes + bind mount dirs)
const localResult = await cleanupScannerCache();
// Clean remote environment volumes
for (const env of envs) {
try {
const envResult = await cleanupScannerCache(env.id);
localResult.volumes.push(...envResult.volumes);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.log(`[Scanner] Skipping cache cleanup for env "${env.name}" (id=${env.id}): ${msg}`);
}
}
return localResult;
}
/**
* Clean up stale 'syncing' states from git stacks.
* Called on startup to recover from crashes during sync operations.
@@ -134,7 +107,6 @@ export async function startScheduler(): Promise<void> {
// Get cron expressions and default timezone from database
const scheduleCleanupCron = await getScheduleCleanupCron();
const eventCleanupCron = await getEventCleanupCron();
const scannerCleanupCron = await getScannerCleanupCron();
const defaultTimezone = await getDefaultTimezone();
// Start system cleanup jobs (static schedules with default timezone)
@@ -162,18 +134,35 @@ export async function startScheduler(): Promise<void> {
await runVolumeHelperCleanupJob('cron', volumeCleanupFns);
});
// Scanner cache cleanup to prevent DB volume bloat (configurable schedule)
const scannerCleanupEnabled = await getScannerCleanupEnabled();
if (scannerCleanupEnabled) {
scannerCacheCleanupJob = new Cron(scannerCleanupCron, { timezone: defaultTimezone, legacyMode: false }, async () => {
await runScannerCacheCleanupJob('cron', scannerCleanupAllEnvs);
});
}
// Scanner cache cleanup runs weekly (Sunday 3am) to prevent DB volume bloat
const scannerCleanupFn = async () => {
const { cleanupScannerCache } = await import('../scanner');
const envs = await getEnvironments();
// Clean local cache (volumes + bind mount dirs)
const localResult = await cleanupScannerCache();
// Clean remote environment volumes
for (const env of envs) {
try {
const envResult = await cleanupScannerCache(env.id);
localResult.volumes.push(...envResult.volumes);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.log(`[Scanner] Skipping cache cleanup for env "${env.name}" (id=${env.id}): ${msg}`);
}
}
return localResult;
};
scannerCacheCleanupJob = new Cron('0 3 * * 0', { timezone: defaultTimezone, legacyMode: false }, async () => {
await runScannerCacheCleanupJob('cron', scannerCleanupFn);
});
console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`);
console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`);
console.log(`[Scheduler] Volume helper cleanup: every 30 minutes [${defaultTimezone}]`);
console.log(`[Scheduler] Scanner cache cleanup: ${scannerCleanupEnabled ? scannerCleanupCron : 'disabled'} [${defaultTimezone}]`);
console.log(`[Scheduler] Scanner cache cleanup: weekly (Sunday 3am) [${defaultTimezone}]`);
// Register all dynamic schedules from database
await refreshAllSchedules();
@@ -508,8 +497,6 @@ export async function refreshSystemJobs(): Promise<void> {
// Get current settings
const scheduleCleanupCron = await getScheduleCleanupCron();
const eventCleanupCron = await getEventCleanupCron();
const scannerCleanupCron = await getScannerCleanupCron();
const scannerCleanupEnabled = await getScannerCleanupEnabled();
const defaultTimezone = await getDefaultTimezone();
// Cleanup functions to pass to the job
@@ -549,16 +536,18 @@ export async function refreshSystemJobs(): Promise<void> {
await runVolumeHelperCleanupJob('cron', volumeCleanupFns);
});
if (scannerCleanupEnabled) {
scannerCacheCleanupJob = new Cron(scannerCleanupCron, { timezone: defaultTimezone, legacyMode: false }, async () => {
await runScannerCacheCleanupJob('cron', scannerCleanupAllEnvs);
});
}
const scannerCleanupFn = async () => {
const { cleanupScannerCache } = await import('../scanner');
return cleanupScannerCache();
};
scannerCacheCleanupJob = new Cron('0 3 * * 0', { timezone: defaultTimezone, legacyMode: false }, async () => {
await runScannerCacheCleanupJob('cron', scannerCleanupFn);
});
console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`);
console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`);
console.log(`[Scheduler] Volume helper cleanup: every 30 minutes [${defaultTimezone}]`);
console.log(`[Scheduler] Scanner cache cleanup: ${scannerCleanupEnabled ? scannerCleanupCron : 'disabled'} [${defaultTimezone}]`);
console.log(`[Scheduler] Scanner cache cleanup: weekly (Sunday 3am) [${defaultTimezone}]`);
}
// =============================================================================
@@ -693,7 +682,11 @@ export async function triggerSystemJob(jobId: string): Promise<{ success: boolea
});
return { success: true };
} else if (jobId === String(SYSTEM_SCANNER_CLEANUP_ID) || jobId === 'scanner-cache-cleanup') {
runScannerCacheCleanupJob('manual', scannerCleanupAllEnvs);
const scannerCleanupFn = async () => {
const { cleanupScannerCache } = await import('../scanner');
return cleanupScannerCache();
};
runScannerCacheCleanupJob('manual', scannerCleanupFn);
return { success: true };
} else {
return { success: false, error: 'Unknown system job ID' };
@@ -719,10 +712,8 @@ export async function getSystemSchedules(): Promise<SystemScheduleInfo[]> {
const eventRetention = await getEventRetentionDays();
const scheduleCleanupCron = await getScheduleCleanupCron();
const eventCleanupCron = await getEventCleanupCron();
const scannerCleanupCron = await getScannerCleanupCron();
const scheduleCleanupEnabled = await getScheduleCleanupEnabled();
const eventCleanupEnabled = await getEventCleanupEnabled();
const scannerCleanupEnabled = await getScannerCleanupEnabled();
return [
{
@@ -760,10 +751,10 @@ export async function getSystemSchedules(): Promise<SystemScheduleInfo[]> {
type: 'system_cleanup' as const,
name: 'Scanner cache cleanup',
description: 'Removes scanner vulnerability database cache to reclaim disk space',
cronExpression: scannerCleanupCron,
nextRun: scannerCleanupEnabled ? getNextRun(scannerCleanupCron)?.toISOString() ?? null : null,
cronExpression: '0 3 * * 0',
nextRun: getNextRun('0 3 * * 0')?.toISOString() ?? null,
isSystem: true,
enabled: scannerCleanupEnabled
enabled: true
}
];
}
@@ -38,7 +38,6 @@ import {
import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner';
import { sendEventNotification } from '../../notifications';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
import { isUpdateDisabledByLabel } from '../../container-labels';
// =============================================================================
// TYPES
@@ -370,18 +369,6 @@ export async function runContainerUpdate(
return;
}
// Check dockhand.update label (label wins over DB settings)
if (isUpdateDisabledByLabel(inspectData.Config?.Labels)) {
log(`Skipping - dockhand.update=false label set on container`);
await updateScheduleExecution(execution.id, {
status: 'skipped',
completedAt: new Date().toISOString(),
duration: Date.now() - startTime,
details: { reason: 'Skipped by dockhand.update=false label' }
});
return;
}
// Skip digest-pinned images - they are explicitly locked to a specific version
if (isDigestBasedImage(imageNameFromConfig)) {
log(`Skipping ${containerName} - image pinned to specific digest`);
@@ -31,7 +31,6 @@ import {
import { sendEventNotification } from '../../notifications';
import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
import { isUpdateDisabledByLabel } from '../../container-labels';
import { recreateContainer } from './container-update';
interface UpdateInfo {
@@ -130,12 +129,6 @@ export async function runEnvUpdateCheckJob(
continue;
}
// Check dockhand.update label (label wins over DB settings)
if (isUpdateDisabledByLabel(inspectData.Config?.Labels)) {
await log(` [${container.name}] Skipping - dockhand.update=false label`);
continue;
}
checkedCount++;
await log(` Checking: ${container.name} (${imageName})`);
@@ -124,7 +124,7 @@ export async function runImagePrune(
// Send success notification only when something was actually cleaned up
if (imagesRemoved > 0) {
await sendEventNotification('image_prune_success', {
title: `Image prune completed${env.name}`,
title: 'Image prune completed',
message: `${imagesRemoved} unused images removed, ${formatBytes(spaceReclaimed)} disk space reclaimed`,
type: 'success'
}, envId);
@@ -142,7 +142,7 @@ export async function runImagePrune(
// Send failure notification
await sendEventNotification('image_prune_failed', {
title: `Image prune failed${env.name}`,
title: 'Image prune failed',
message: `Failed to prune images: ${error.message}`,
type: 'error'
}, envId);
@@ -11,7 +11,6 @@ import {
getEventRetentionDays,
getScheduleCleanupEnabled,
getEventCleanupEnabled,
getScannerCleanupEnabled,
createScheduleExecution,
updateScheduleExecution,
appendScheduleExecutionLog
@@ -211,14 +210,6 @@ export async function runScannerCacheCleanupJob(
triggeredBy: ScheduleTrigger = 'cron',
cleanupFn?: () => Promise<{ volumes: string[]; dirs: string[] }>
): Promise<void> {
// Check if cleanup is enabled (skip check if manually triggered)
if (triggeredBy === 'cron') {
const enabled = await getScannerCleanupEnabled();
if (!enabled) {
return; // Skip execution if disabled
}
}
const startTime = Date.now();
const execution = await createScheduleExecution({
+18 -28
View File
@@ -60,20 +60,19 @@ async function isComposeFile(filePath: string): Promise<boolean> {
}
/**
* Parse compose file metadata: top-level `name` property and service count.
* The `name` property (if present) should be used as the stack name instead of the directory name,
* matching Docker Compose's behavior with `com.docker.compose.project`.
* Count the number of services defined in a compose file
* Parses YAML to reliably count top-level keys under 'services:' section
*/
function parseComposeMetadata(filePath: string): { name: string | null; serviceCount: number } {
async function countServices(filePath: string): Promise<number> {
try {
const content = readFileSync(filePath, 'utf-8');
const doc = yaml.load(content) as Record<string, unknown> | null;
const name = typeof doc?.name === 'string' ? doc.name.trim() : null;
const serviceCount = doc?.services && typeof doc.services === 'object'
? Object.keys(doc.services).length : 0;
return { name, serviceCount };
if (doc?.services && typeof doc.services === 'object') {
return Object.keys(doc.services).length;
}
return 0;
} catch {
return { name: null, serviceCount: 0 };
return 0;
}
}
@@ -123,12 +122,13 @@ async function scanPath(basePath: string): Promise<{ stacks: DiscoveredStack[];
for (const pattern of COMPOSE_PATTERNS) {
const composePath = join(currentPath, pattern);
if (existsSync(composePath)) {
// Found a stack! Use compose name property if defined, otherwise directory name
const { name: composeName, serviceCount } = parseComposeMetadata(composePath);
const stackName = normalizeStackName(composeName || basename(currentPath));
// Found a stack! Stack name = directory name
const stackName = normalizeStackName(basename(currentPath));
if (stackName) {
// Check for .env file
const envPath = join(currentPath, '.env');
// Count services in compose file
const serviceCount = await countServices(composePath);
discovered.push({
name: stackName,
composePath,
@@ -166,13 +166,14 @@ async function scanPath(basePath: string): Promise<{ stacks: DiscoveredStack[];
if (lowerName.endsWith('.yml') || lowerName.endsWith('.yaml')) {
// Validate it's actually a compose file
if (await isComposeFile(entryPath)) {
const { name: composeName, serviceCount } = parseComposeMetadata(entryPath);
const stackName = normalizeStackName(
composeName || entry.name.replace(/\.(yml|yaml)$/i, '')
entry.name.replace(/\.(yml|yaml)$/i, '')
);
if (stackName) {
// Check for .env file in same directory
const envPath = join(currentPath, '.env');
// Count services in compose file
const serviceCount = await countServices(entryPath);
discovered.push({
name: stackName,
composePath: entryPath,
@@ -213,18 +214,8 @@ export async function adoptStack(
return { success: false, error: 'Already adopted' };
}
// If the compose file has a top-level `name:` property, prefer it over the passed name.
// This ensures Docker's project name (from the label) matches Dockhand's stack name.
let stackNameSource = stack.name;
if (stack.composePath && existsSync(stack.composePath)) {
const { name: composeName } = parseComposeMetadata(stack.composePath);
if (composeName) {
stackNameSource = composeName;
}
}
// Check for name conflict within the same environment
let finalName = normalizeStackName(stackNameSource);
let finalName = normalizeStackName(stack.name);
const existingNames = new Set(
existingSources
.filter((s) => s.environmentId === environmentId)
@@ -233,12 +224,11 @@ export async function adoptStack(
if (existingNames.has(finalName)) {
// Append suffix to make unique
const baseName = finalName;
let suffix = 1;
while (existingNames.has(`${baseName}-${suffix}`)) {
while (existingNames.has(`${stack.name}-${suffix}`)) {
suffix++;
}
finalName = `${baseName}-${suffix}`;
finalName = `${stack.name}-${suffix}`;
}
// Create stack source record - use 'internal' since we know the file paths
+24 -33
View File
@@ -102,7 +102,6 @@ export interface DeployStackOptions {
sourceDir?: string; // Directory to copy all files from (for git stacks)
forceRecreate?: boolean;
build?: boolean; // Build images before starting (--build)
noBuildCache?: boolean; // Disable build cache (--no-cache, requires --build)
pullPolicy?: string; // Pull policy: 'always' | 'missing' | 'never'
composePath?: string; // Custom compose file path (for adopted/imported stacks)
envPath?: string; // Custom env file path (for adopted/imported stacks)
@@ -260,14 +259,23 @@ async function readDirFilesAsMap(dirPath: string): Promise<Record<string, string
// =============================================================================
/**
* Redact all env var values for safe logging. Only key names are preserved.
* Mask sensitive values in environment variables for safe logging.
* Masks values for keys containing common secret patterns and truncates long values.
*/
function redactEnvVarsForLog(vars: Record<string, string>): Record<string, string> {
const redacted: Record<string, string> = {};
for (const key of Object.keys(vars)) {
redacted[key] = '***';
function maskSecrets(vars: Record<string, string>): Record<string, string> {
const masked: Record<string, string> = {};
const secretPatterns = /password|secret|token|key|api_key|apikey|auth|credential|private/i;
for (const [key, value] of Object.entries(vars)) {
if (secretPatterns.test(key)) {
masked[key] = '***';
} else if (value.length > 50) {
// Truncate long values that might be secrets
masked[key] = value.substring(0, 10) + '...(truncated)';
} else {
masked[key] = value;
}
}
return redacted;
return masked;
}
// =============================================================================
@@ -744,10 +752,9 @@ async function loginToRegistries(dockerHost?: string, logPrefix = '[Stack]', api
}
try {
// Extract registry host from URL (parseRegistryUrl handles bare hostnames like 'ghcr.io')
const { parseRegistryUrl } = await import('./docker.js');
const { host } = parseRegistryUrl(reg.url);
const registryHost = host;
// Extract registry host from URL
const url = new URL(reg.url);
const registryHost = url.host;
console.log(`${logPrefix} Logging into registry: ${registryHost}`);
@@ -786,7 +793,6 @@ interface ComposeCommandOptions {
envId?: number | null;
forceRecreate?: boolean;
build?: boolean; // Build images before starting (--build)
noBuildCache?: boolean; // Disable build cache (--no-cache, requires --build)
pullPolicy?: string; // Pull policy: 'always' | 'missing' | 'never'
removeVolumes?: boolean;
stackFiles?: Record<string, string>; // All files to send to Hawser
@@ -850,7 +856,6 @@ async function executeLocalCompose(
useOverrideFile?: boolean,
serviceName?: string,
build?: boolean,
noBuildCache?: boolean,
pullPolicy?: string
): Promise<StackOperationResult> {
const logPrefix = `[Stack:${stackName}]`;
@@ -1044,7 +1049,6 @@ async function executeLocalCompose(
args.push('up', '-d', '--remove-orphans');
if (forceRecreate) args.push('--force-recreate');
if (build) args.push('--build');
if (build && noBuildCache) args.push('--no-cache');
if (pullPolicy) args.push('--pull', pullPolicy);
// If targeting a specific service, only update that service
if (serviceName) {
@@ -1089,7 +1093,7 @@ async function executeLocalCompose(
console.log(`${logPrefix} Service name:`, serviceName ?? '(all services)');
console.log(`${logPrefix} Env vars count:`, envVars ? Object.keys(envVars).length : 0);
if (envVars && Object.keys(envVars).length > 0) {
console.log(`${logPrefix} Env vars being injected (masked):`, JSON.stringify(redactEnvVarsForLog(envVars), null, 2));
console.log(`${logPrefix} Env vars being injected (masked):`, JSON.stringify(maskSecrets(envVars), null, 2));
}
// Login to registries before pulling images
@@ -1221,7 +1225,6 @@ async function executeComposeViaHawser(
serviceName?: string,
composeFileName?: string,
build?: boolean,
noBuildCache?: boolean,
pullPolicy?: string
): Promise<StackOperationResult> {
const logPrefix = `[Stack:${stackName}]`;
@@ -1245,7 +1248,7 @@ async function executeComposeViaHawser(
console.log(`${logPrefix} Non-secret env vars count:`, envVars ? Object.keys(envVars).length : 0);
console.log(`${logPrefix} Secret env vars count:`, secretCount);
if (allEnvVars && Object.keys(allEnvVars).length > 0) {
console.log(`${logPrefix} All env vars being sent (masked):`, JSON.stringify(redactEnvVarsForLog(allEnvVars), null, 2));
console.log(`${logPrefix} All env vars being sent (masked):`, JSON.stringify(maskSecrets(allEnvVars), null, 2));
}
console.log(`${logPrefix} Compose content length:`, composeContent.length, 'chars');
console.log(`${logPrefix} Stack files count:`, stackFiles ? Object.keys(stackFiles).length : 0);
@@ -1296,7 +1299,6 @@ async function executeComposeViaHawser(
forceRecreate: forceRecreate || false,
removeVolumes: removeVolumes || false,
build: build || false,
noBuildCache: (build && noBuildCache) || false,
pullPolicy: pullPolicy || '',
registries, // Registry credentials for docker login
serviceName // Target specific service only (with --no-deps)
@@ -1365,7 +1367,7 @@ async function executeComposeCommand(
envVars?: Record<string, string>,
secretVars?: Record<string, string>
): Promise<StackOperationResult> {
const { stackName, envId, forceRecreate, build, noBuildCache, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName } = options;
const { stackName, envId, forceRecreate, build, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName } = options;
// Get environment configuration
const env = envId ? await getEnvironment(envId) : null;
@@ -1389,7 +1391,6 @@ async function executeComposeCommand(
useOverrideFile,
serviceName,
build,
noBuildCache,
pullPolicy
);
}
@@ -1454,7 +1455,6 @@ async function executeComposeCommand(
serviceName,
composeFileName,
build,
noBuildCache,
pullPolicy
);
}
@@ -1488,7 +1488,6 @@ async function executeComposeCommand(
useOverrideFile,
serviceName,
build,
noBuildCache,
pullPolicy
);
}
@@ -1512,7 +1511,6 @@ async function executeComposeCommand(
useOverrideFile,
serviceName,
build,
noBuildCache,
pullPolicy
);
}
@@ -1983,8 +1981,7 @@ export async function downStack(
export async function removeStack(
stackName: string,
envId?: number | null,
force = false,
removeVolumes = false
force = false
): Promise<StackOperationResult> {
return withStackLock(stackName, async () => {
// Get compose file (may not exist for external stacks)
@@ -2002,7 +1999,6 @@ export async function removeStack(
{
stackName,
envId,
removeVolumes,
workingDir: composeResult.stackDir,
composePath: composeResult.composePath ?? undefined,
envPath: composeResult.envPath ?? undefined
@@ -2176,7 +2172,7 @@ export async function removeStack(
* Uses stack locking to prevent concurrent deployments.
*/
export async function deployStack(options: DeployStackOptions): Promise<StackOperationResult> {
const { name, compose, envId, sourceDir, forceRecreate, build, noBuildCache, pullPolicy, composePath, envPath, composeFileName, envFileName } = options;
const { name, compose, envId, sourceDir, forceRecreate, build, pullPolicy, composePath, envPath, composeFileName, envFileName } = options;
const logPrefix = `[Stack:${name}]`;
console.log(`${logPrefix} ========================================`);
@@ -2254,11 +2250,7 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
// and would be destroyed, causing data loss (#831).
console.log(`${logPrefix} Copying source directory to stack directory...`);
mkdirSync(workingDir, { recursive: true });
cpSync(sourceDir, workingDir, {
recursive: true,
force: true,
filter: (src) => !src.includes('/.git/') && !src.endsWith('/.git')
});
cpSync(sourceDir, workingDir, { recursive: true, force: true });
console.log(`${logPrefix} Copied ${sourceDir} -> ${workingDir}`);
} else {
// Internal stack: check if a custom path exists in DB (adopted/imported stacks)
@@ -2323,7 +2315,6 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
envId,
forceRecreate,
build,
noBuildCache,
pullPolicy,
stackFiles,
workingDir,
+17 -22
View File
@@ -24,7 +24,6 @@ import {
type ContainerEventAction
} from './db';
import { sendEnvironmentNotification, sendEventNotification } from './notifications';
import { isNotifyDisabledByLabel } from './container-labels';
import { rssBeforeOp, rssAfterOp } from './rss-tracker';
import { pushMetric } from './metrics-store';
@@ -286,28 +285,24 @@ async function handleContainerEvent(msg: GoMessage): Promise<void> {
// Sub-category: notification
const notifBefore = rssBeforeOp();
const actionLabel = action.startsWith('health_status')
? action.includes('unhealthy') ? 'Unhealthy' : 'Healthy'
: action.charAt(0).toUpperCase() + action.slice(1);
const containerLabel = containerName || containerId.substring(0, 12);
const notificationType =
action === 'die' || action === 'kill' || action === 'oom' || action.includes('unhealthy')
? 'error'
: action === 'stop'
? 'warning'
: action === 'start' || (action.includes('healthy') && !action.includes('unhealthy'))
? 'success'
: 'info';
// Check dockhand.notify label — Docker includes container labels in event Actor.Attributes
if (!isNotifyDisabledByLabel(event.Actor?.Attributes)) {
const actionLabel = action.startsWith('health_status')
? action.includes('unhealthy') ? 'Unhealthy' : 'Healthy'
: action.charAt(0).toUpperCase() + action.slice(1);
const containerLabel = containerName || containerId.substring(0, 12);
const notificationType =
action === 'die' || action === 'kill' || action === 'oom' || action.includes('unhealthy')
? 'error'
: action === 'stop'
? 'warning'
: action === 'start' || (action.includes('healthy') && !action.includes('unhealthy'))
? 'success'
: 'info';
sendEnvironmentNotification(msg.envId, action, {
title: `Container ${actionLabel}`,
message: `Container "${containerLabel}" ${action}${image ? ` (${image})` : ''}`,
type: notificationType
}, image).catch(() => {});
}
sendEnvironmentNotification(msg.envId, action, {
title: `Container ${actionLabel}`,
message: `Container "${containerLabel}" ${action}${image ? ` (${image})` : ''}`,
type: notificationType
}, image).catch(() => {});
rssAfterOp('events_notif', notifBefore);
rssAfterOp('events', before);
}
-109
View File
@@ -1,109 +0,0 @@
/**
* API Token Cache LRU with TTL
*
* In-memory cache for validated API tokens. Without this, every Bearer
* request would run Argon2id verification (~64MB RAM, ~100ms CPU per call).
*
* Cache key is SHA-256(fullToken) safe to hold in memory (not reversible).
* TTL is 60s by default, capped at the token's expiry time if sooner.
*
* Uses a bounded LRU (max 1024 entries) to cap memory usage. On overflow
* the least-recently-used entry is evicted. Expired entries are also pruned
* every 5 minutes.
*
* Separated from api-tokens.ts to avoid circular dependencies
* (auth.ts api-tokens.ts).
*/
export interface TokenCacheEntry {
user: { id: number; [key: string]: any };
expiresAt: number;
}
const MAX_CACHE_SIZE = 1024;
/**
* Simple LRU cache backed by a Map.
* Map iteration order is insertion order, so we delete-and-re-set on every
* access to move the entry to the "newest" position. The oldest entry
* (Map.keys().next()) is evicted when the cache exceeds MAX_CACHE_SIZE.
*/
class LruTokenCache {
private map = new Map<string, TokenCacheEntry>();
get(key: string): TokenCacheEntry | undefined {
const entry = this.map.get(key);
if (!entry) return undefined;
// Move to end (most-recently-used)
this.map.delete(key);
this.map.set(key, entry);
return entry;
}
set(key: string, entry: TokenCacheEntry): void {
// If key already exists, delete first so re-insert moves it to end
if (this.map.has(key)) {
this.map.delete(key);
}
this.map.set(key, entry);
// Evict oldest if over capacity
if (this.map.size > MAX_CACHE_SIZE) {
const oldest = this.map.keys().next().value!;
this.map.delete(oldest);
}
}
delete(key: string): void {
this.map.delete(key);
}
clear(): void {
this.map.clear();
}
entries(): IterableIterator<[string, TokenCacheEntry]> {
return this.map.entries();
}
get size(): number {
return this.map.size;
}
}
export const tokenCache = new LruTokenCache();
/**
* Invalidate all cached tokens for a specific user.
* Call when user permissions change, roles are updated, or user is deleted/deactivated.
*/
export function invalidateTokenCacheForUser(userId: number): void {
for (const [key, entry] of tokenCache.entries()) {
if (entry.user.id === userId) {
tokenCache.delete(key);
}
}
}
/**
* Clear the entire token cache.
* Called on revocation, role permission edits, and license state changes.
*/
export function clearTokenCache(): void {
tokenCache.clear();
}
// Periodic cleanup every 5 minutes
let cleanupInterval: ReturnType<typeof setInterval> | undefined;
export function ensureCleanupInterval(): void {
if (cleanupInterval) return;
cleanupInterval = setInterval(() => {
const now = Date.now();
for (const [key, entry] of tokenCache.entries()) {
if (entry.expiresAt <= now) {
tokenCache.delete(key);
}
}
}, 5 * 60 * 1000);
if (cleanupInterval.unref) cleanupInterval.unref();
}
+3 -11
View File
@@ -2,7 +2,6 @@ import { writable, get } from 'svelte/store';
import { browser } from '$app/environment';
import type { ContainerInfo, ContainerStats } from '$lib/types';
import { appendEnvParam, clearStaleEnvironment, environments } from '$lib/stores/environment';
import { appSettings } from '$lib/stores/settings';
import { toast } from 'svelte-sonner';
export interface AutoUpdateSetting {
@@ -71,16 +70,9 @@ function createContainerStore() {
const [min, hr, , , dow] = parts;
const hourNum = parseInt(hr);
const minNum = parseInt(min);
const is12Hour = get(appSettings).timeFormat === '12h';
let timeStr: string;
if (is12Hour) {
const ampm = hourNum >= 12 ? 'PM' : 'AM';
const hour12 = hourNum === 0 ? 12 : hourNum > 12 ? hourNum - 12 : hourNum;
timeStr = `${hour12}:${minNum.toString().padStart(2, '0')} ${ampm}`;
} else {
timeStr = `${hourNum.toString().padStart(2, '0')}:${minNum.toString().padStart(2, '0')}`;
}
const ampm = hourNum >= 12 ? 'PM' : 'AM';
const hour12 = hourNum === 0 ? 12 : hourNum > 12 ? hourNum - 12 : hourNum;
const timeStr = `${hour12}:${minNum.toString().padStart(2, '0')} ${ampm}`;
if (scheduleType === 'daily' || dow === '*') {
return { label: 'daily', tooltip: `Daily at ${timeStr}` };
+4 -69
View File
@@ -5,7 +5,6 @@ export type TimeFormat = '12h' | '24h';
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
export type DownloadFormat = 'tar' | 'tar.gz';
export type EventCollectionMode = 'stream' | 'poll';
export type LabelFilterMode = 'any' | 'all';
export interface AppSettings {
confirmDestructive: boolean;
@@ -22,8 +21,6 @@ export interface AppSettings {
eventCleanupCron: string;
scheduleCleanupEnabled: boolean;
eventCleanupEnabled: boolean;
scannerCleanupCron: string;
scannerCleanupEnabled: boolean;
logBufferSizeKb: number;
defaultTimezone: string;
eventCollectionMode: EventCollectionMode;
@@ -35,8 +32,6 @@ export interface AppSettings {
primaryStackLocation: string | null;
defaultGrypeImage: string;
defaultTrivyImage: string;
defaultComposeTemplate: string;
labelFilterMode: LabelFilterMode;
}
const DEFAULT_SETTINGS: AppSettings = {
@@ -54,8 +49,6 @@ const DEFAULT_SETTINGS: AppSettings = {
eventCleanupCron: '30 3 * * *',
scheduleCleanupEnabled: true,
eventCleanupEnabled: true,
scannerCleanupCron: '0 3 * * 0',
scannerCleanupEnabled: true,
logBufferSizeKb: 500,
defaultTimezone: 'UTC',
eventCollectionMode: 'stream',
@@ -66,26 +59,7 @@ const DEFAULT_SETTINGS: AppSettings = {
externalStackPaths: [],
primaryStackLocation: null,
defaultGrypeImage: 'anchore/grype:v0.110.0',
defaultTrivyImage: 'aquasec/trivy:0.69.3',
labelFilterMode: 'any',
defaultComposeTemplate: `version: "3.8"
services:
app:
image: nginx:alpine
ports:
- "8080:80"
environment:
- APP_ENV=\${APP_ENV:-production}
volumes:
- ./html:/usr/share/nginx/html:ro
restart: unless-stopped
# Add more services as needed
# networks:
# default:
# driver: bridge
`
defaultTrivyImage: 'aquasec/trivy:0.69.3'
};
// Create a writable store for app settings
@@ -117,8 +91,6 @@ function createSettingsStore() {
eventCleanupCron: settings.eventCleanupCron ?? DEFAULT_SETTINGS.eventCleanupCron,
scheduleCleanupEnabled: settings.scheduleCleanupEnabled ?? DEFAULT_SETTINGS.scheduleCleanupEnabled,
eventCleanupEnabled: settings.eventCleanupEnabled ?? DEFAULT_SETTINGS.eventCleanupEnabled,
scannerCleanupCron: settings.scannerCleanupCron ?? DEFAULT_SETTINGS.scannerCleanupCron,
scannerCleanupEnabled: settings.scannerCleanupEnabled ?? DEFAULT_SETTINGS.scannerCleanupEnabled,
logBufferSizeKb: settings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
defaultTimezone: settings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
eventCollectionMode: settings.eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode,
@@ -129,9 +101,7 @@ function createSettingsStore() {
externalStackPaths: settings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
primaryStackLocation: settings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation,
defaultGrypeImage: settings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
defaultTrivyImage: settings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage,
defaultComposeTemplate: settings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: settings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode
defaultTrivyImage: settings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage
});
}
} catch {
@@ -166,8 +136,6 @@ function createSettingsStore() {
eventCleanupCron: updatedSettings.eventCleanupCron ?? DEFAULT_SETTINGS.eventCleanupCron,
scheduleCleanupEnabled: updatedSettings.scheduleCleanupEnabled ?? DEFAULT_SETTINGS.scheduleCleanupEnabled,
eventCleanupEnabled: updatedSettings.eventCleanupEnabled ?? DEFAULT_SETTINGS.eventCleanupEnabled,
scannerCleanupCron: updatedSettings.scannerCleanupCron ?? DEFAULT_SETTINGS.scannerCleanupCron,
scannerCleanupEnabled: updatedSettings.scannerCleanupEnabled ?? DEFAULT_SETTINGS.scannerCleanupEnabled,
logBufferSizeKb: updatedSettings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
defaultTimezone: updatedSettings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
eventCollectionMode: updatedSettings.eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode,
@@ -178,9 +146,7 @@ function createSettingsStore() {
externalStackPaths: updatedSettings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
primaryStackLocation: updatedSettings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation,
defaultGrypeImage: updatedSettings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
defaultTrivyImage: updatedSettings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage,
defaultComposeTemplate: updatedSettings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: updatedSettings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode
defaultTrivyImage: updatedSettings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage
});
}
} catch (error) {
@@ -305,20 +271,6 @@ function createSettingsStore() {
return newSettings;
});
},
setScannerCleanupCron: (value: string) => {
update((current) => {
const newSettings = { ...current, scannerCleanupCron: value };
saveSettings({ scannerCleanupCron: value });
return newSettings;
});
},
setScannerCleanupEnabled: (value: boolean) => {
update((current) => {
const newSettings = { ...current, scannerCleanupEnabled: value };
saveSettings({ scannerCleanupEnabled: value });
return newSettings;
});
},
setLogBufferSizeKb: (value: number) => {
update((current) => {
const newSettings = { ...current, logBufferSizeKb: value };
@@ -396,25 +348,8 @@ function createSettingsStore() {
return newSettings;
});
},
setDefaultComposeTemplate: (value: string) => {
update((current) => {
const newSettings = { ...current, defaultComposeTemplate: value };
saveSettings({ defaultComposeTemplate: value });
return newSettings;
});
},
setLabelFilterMode: (value: LabelFilterMode) => {
update((current) => {
const newSettings = { ...current, labelFilterMode: value };
saveSettings({ labelFilterMode: value });
return newSettings;
});
},
// Manual refresh from database
refresh: () => {
initialized = false;
return loadSettings();
}
refresh: loadSettings
};
}
-47
View File
@@ -1,47 +0,0 @@
// Pure parsing/building functions for notification providers.
// Extracted from notifications.ts so unit tests can import without pulling in DB deps.
// --- Telegram ---
// Escape special characters for Telegram legacy Markdown (parse_mode: 'Markdown')
// Only _ * ` [ need escaping — ] and other chars are not special in legacy mode
export function escapeTelegramMarkdown(text: string): string {
return text
.replace(/_/g, '\\_') // Underscore (italic)
.replace(/\*/g, '\\*') // Asterisk (bold)
.replace(/`/g, '\\`') // Backtick (code)
.replace(/\[/g, '\\['); // Opening bracket (link)
}
export function parseTelegramUrl(url: string): { botToken: string; chatId: string; topicId?: number } | null {
const match = url.match(/^tgram:\/\/([^/]+)\/([^:\/]+)(?::(\d+))?$/);
if (!match) return null;
const [, botToken, chatId, topicIdStr] = match;
return { botToken, chatId, topicId: topicIdStr ? parseInt(topicIdStr, 10) : undefined };
}
// --- Gotify ---
export function buildGotifyUrl(appriseUrl: string): string | null {
const match = appriseUrl.match(/^gotifys?:\/\/([^/]+)\/(.+)/);
if (!match) return null;
const [, hostname, pathPart] = match;
const protocol = appriseUrl.startsWith('gotifys') ? 'https' : 'http';
const lastSlash = pathPart.lastIndexOf('/');
const subpath = lastSlash >= 0 ? pathPart.substring(0, lastSlash) : '';
const token = lastSlash >= 0 ? pathPart.substring(lastSlash + 1) : pathPart;
return `${protocol}://${hostname}${subpath ? '/' + subpath : ''}/message?token=${token}`;
}
// --- Workflows (Microsoft Power Automate) ---
export function parseWorkflowsUrl(appriseUrl: string): { hostname: string; workflow: string; signature: string } | null {
const match = appriseUrl.match(/^workflows?:\/\/([^/]+)\/(.+)\/(.+)/);
if (!match) return null;
const [, hostname, workflow, signature] = match;
return { hostname, workflow, signature };
}
export function buildWorkflowsHttpUrl(hostname: string, workflow: string, signature: string): string {
return `https://${hostname}/powerautomate/automations/direct/workflows/${workflow}/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=${signature}`;
}
-81
View File
@@ -1,81 +0,0 @@
export interface PortMapping {
publicPort: number;
privatePort: number;
display: string;
isRange?: boolean;
}
interface PortInfo {
PublicPort?: number;
PrivatePort?: number;
publicPort?: number;
privatePort?: number;
}
/**
* Format Docker port mappings, collapsing consecutive ranges of 3+ ports.
* Accepts both Docker API format (PublicPort/PrivatePort) and camelCase (publicPort/privatePort).
* e.g. 8080:8080, 8081:8081, 8082:8082 8080-8082:8080-8082
* But 80:80, 81:81 stay as individual ports (only 2 consecutive).
*/
export function formatPorts(ports: PortInfo[] | undefined | null): PortMapping[] {
if (!ports || ports.length === 0) return [];
const seen = new Set<string>();
const individual = ports
.filter(p => (p.PublicPort || p.publicPort))
.map(p => ({
publicPort: p.PublicPort || p.publicPort!,
privatePort: p.PrivatePort || p.privatePort!,
display: `${p.PublicPort || p.publicPort}:${p.PrivatePort || p.privatePort}`
}))
.filter(p => {
const key = p.display;
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.sort((a, b) => a.publicPort - b.publicPort);
// Collapse consecutive port ranges (3+ ports only)
if (individual.length <= 1) return individual;
const result: PortMapping[] = [];
let rangeStart = 0;
let rangeEnd = 0;
for (let i = 1; i < individual.length; i++) {
const curr = individual[i];
const start = individual[rangeStart];
const prev = individual[rangeEnd];
const offset = curr.publicPort - start.publicPort;
const expectedPrivate = start.privatePort + offset;
if (curr.publicPort === prev.publicPort + 1 && curr.privatePort === expectedPrivate) {
rangeEnd = i;
} else {
flushRange(individual, rangeStart, rangeEnd, result);
rangeStart = i;
rangeEnd = i;
}
}
flushRange(individual, rangeStart, rangeEnd, result);
return result;
}
function flushRange(items: PortMapping[], start: number, end: number, result: PortMapping[]) {
const rangeLen = end - start + 1;
if (rangeLen >= 3) {
// Collapse into range
result.push({
publicPort: items[start].publicPort,
privatePort: items[start].privatePort,
display: `${items[start].publicPort}-${items[end].publicPort}:${items[start].privatePort}-${items[end].privatePort}`,
isRange: true
});
} else {
// Keep as individual ports
for (let i = start; i <= end; i++) {
result.push(items[i]);
}
}
}
+7 -9
View File
@@ -22,7 +22,6 @@
import { getLabelColor, getLabelBgColor } from '$lib/utils/label-colors';
import { Input } from '$lib/components/ui/input';
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
import { appSettings } from '$lib/stores/settings';
const LABEL_FILTER_STORAGE_KEY = 'dockhand-dashboard-label-filter';
@@ -211,10 +210,10 @@
if (filterLabels.length === 0) {
return tiles;
}
const matchFn = $appSettings.labelFilterMode === 'all'
? (tileLabels: string[]) => filterLabels.every(label => tileLabels.includes(label))
: (tileLabels: string[]) => filterLabels.some(label => tileLabels.includes(label));
return tiles.filter(t => matchFn(t.stats?.labels || []));
return tiles.filter(t => {
const tileLabels = t.stats?.labels || [];
return tileLabels.some(label => filterLabels.includes(label));
});
});
// Filter grid items based on selected labels
@@ -222,12 +221,11 @@
if (filterLabels.length === 0) {
return gridItems;
}
const matchFn = $appSettings.labelFilterMode === 'all'
? (tileLabels: string[]) => filterLabels.every(label => tileLabels.includes(label))
: (tileLabels: string[]) => filterLabels.some(label => tileLabels.includes(label));
// Filter to only show tiles whose environments have at least one matching label
return gridItems.filter(item => {
const tile = tiles.find(t => t.id === item.id);
return matchFn(tile?.stats?.labels || []);
const tileLabels = tile?.stats?.labels || [];
return tileLabels.some(label => filterLabels.includes(label));
});
});
const orderedGridItems = $derived.by(() => {
-164
View File
@@ -1,164 +0,0 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { generateApiToken, listUserTokens } from '$lib/server/api-tokens';
import { isAuthEnabled, verifyPassword } from '$lib/server/auth';
import { getUser } from '$lib/server/db';
import { audit } from '$lib/server/audit';
import { getRequestContext } from '$lib/server/request-context';
// Password confirmation rate limiting (per userId)
const pwFailCounts = new Map<number, { count: number; firstFail: number }>();
const pwCooldowns = new Map<number, number>();
const PW_FAIL_WINDOW = 60_000; // 1-minute sliding window
const PW_FAIL_MAX = 5; // max failures per window
const PW_COOLDOWN = 5 * 60 * 1000; // 5-minute cooldown
const MAX_TOKENS_PER_USER = 25;
// Periodic cleanup
setInterval(() => {
const now = Date.now();
for (const [id, until] of pwCooldowns) {
if (now > until) pwCooldowns.delete(id);
}
for (const [id, entry] of pwFailCounts) {
if (now - entry.firstFail > PW_FAIL_WINDOW) pwFailCounts.delete(id);
}
}, PW_COOLDOWN).unref?.();
function isPwRateLimited(userId: number): boolean {
const until = pwCooldowns.get(userId);
if (!until) return false;
if (Date.now() > until) {
pwCooldowns.delete(userId);
return false;
}
return true;
}
function recordPwFailure(userId: number): void {
const now = Date.now();
const entry = pwFailCounts.get(userId);
if (!entry || now - entry.firstFail > PW_FAIL_WINDOW) {
pwFailCounts.set(userId, { count: 1, firstFail: now });
return;
}
entry.count++;
if (entry.count >= PW_FAIL_MAX) {
pwCooldowns.set(userId, now + PW_COOLDOWN);
pwFailCounts.delete(userId);
}
}
/**
* GET /api/auth/tokens - List the current user's API tokens
*/
export const GET: RequestHandler = async ({ cookies }) => {
const authEnabled = await isAuthEnabled();
if (!authEnabled) {
return json({ error: 'Authentication is not enabled' }, { status: 400 });
}
const auth = await authorize(cookies);
if (!auth.isAuthenticated || !auth.user) {
return json({ error: 'Authentication required' }, { status: 401 });
}
const tokens = await listUserTokens(auth.user.id);
return json(tokens);
};
/**
* POST /api/auth/tokens - Create a new API token
*/
export const POST: RequestHandler = async (event) => {
const { cookies, request } = event;
const authEnabled = await isAuthEnabled();
if (!authEnabled) {
return json({ error: 'Authentication is not enabled' }, { status: 400 });
}
const auth = await authorize(cookies);
if (!auth.isAuthenticated || !auth.user) {
return json({ error: 'Authentication required' }, { status: 401 });
}
// Token creation requires a cookie session — a Bearer token cannot create new tokens
const reqCtx = getRequestContext();
if (reqCtx?.authMethod === 'bearer') {
return json({ error: 'Token creation requires a session login, not a Bearer token' }, { status: 403 });
}
let body: any;
try {
body = await request.json();
} catch {
return json({ error: 'Invalid request body' }, { status: 400 });
}
const { name, expiresAt, password } = body;
// Local users must confirm their password to create tokens
// SSO/OIDC and LDAP users skip this (they authenticated via their IdP)
if (auth.user.provider === 'local') {
if (isPwRateLimited(auth.user.id)) {
return json({ error: 'Too many failed password attempts. Try again later.' }, { status: 429 });
}
if (!password || typeof password !== 'string') {
return json({ error: 'Password is required to create an API token' }, { status: 400 });
}
const dbUser = await getUser(auth.user.id);
if (!dbUser) {
return json({ error: 'User not found' }, { status: 404 });
}
const valid = await verifyPassword(password, dbUser.passwordHash);
if (!valid) {
recordPwFailure(auth.user.id);
return json({ error: 'Invalid password' }, { status: 403 });
}
}
// L2: Per-user token count limit
const existingTokens = await listUserTokens(auth.user.id);
if (existingTokens.length >= MAX_TOKENS_PER_USER) {
return json({ error: `Maximum of ${MAX_TOKENS_PER_USER} API tokens per user` }, { status: 400 });
}
// Validate name
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return json({ error: 'Token name is required' }, { status: 400 });
}
if (name.length > 255) {
return json({ error: 'Token name must be 255 characters or less' }, { status: 400 });
}
// Validate expiration
if (expiresAt) {
const expiryDate = new Date(expiresAt);
if (isNaN(expiryDate.getTime())) {
return json({ error: 'Invalid expiration date' }, { status: 400 });
}
if (expiryDate <= new Date()) {
return json({ error: 'Expiration date must be in the future' }, { status: 400 });
}
}
const result = await generateApiToken(
auth.user.id,
name.trim(),
expiresAt || null
);
await audit(event, 'create', 'api_token', {
entityId: String(result.id),
entityName: name.trim(),
description: `API token "${name.trim()}" created`
});
return json({
id: result.id,
token: result.token,
tokenPrefix: result.tokenPrefix
}, { status: 201, headers: { 'Cache-Control': 'no-store' } });
};
@@ -1,47 +0,0 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { revokeApiToken } from '$lib/server/api-tokens';
import { isAuthEnabled } from '$lib/server/auth';
import { getRequestContext } from '$lib/server/request-context';
import { audit } from '$lib/server/audit';
/**
* DELETE /api/auth/tokens/[id] - Revoke an API token
*/
export const DELETE: RequestHandler = async (event) => {
const { cookies, params } = event;
const authEnabled = await isAuthEnabled();
if (!authEnabled) {
return json({ error: 'Authentication is not enabled' }, { status: 400 });
}
// Bearer tokens cannot manage tokens (prevent leaked token from revoking others)
const reqCtx = getRequestContext();
if (reqCtx?.authMethod === 'bearer') {
return json({ error: 'Token management requires a cookie session' }, { status: 403 });
}
const auth = await authorize(cookies);
if (!auth.isAuthenticated || !auth.user) {
return json({ error: 'Authentication required' }, { status: 401 });
}
const tokenId = parseInt(params.id);
if (isNaN(tokenId)) {
return json({ error: 'Invalid token ID' }, { status: 400 });
}
const success = await revokeApiToken(tokenId, auth.user.id, auth.isAdmin);
if (!success) {
return json({ error: 'Token not found or access denied' }, { status: 404 });
}
await audit(event, 'delete', 'api_token', {
entityId: params.id,
description: `API token revoked`
});
return json({ success: true });
};
@@ -7,7 +7,6 @@ import {
deleteAutoUpdateSchedule
} from '$lib/server/db';
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ params, url }) => {
try {
@@ -39,12 +38,7 @@ export const GET: RequestHandler = async ({ params, url }) => {
}
};
export const POST: RequestHandler = async ({ params, url, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
export const POST: RequestHandler = async ({ params, url, request }) => {
try {
const containerName = decodeURIComponent(params.containerName);
const envIdParam = url.searchParams.get('env');
@@ -66,7 +60,7 @@ export const POST: RequestHandler = async ({ params, url, request, cookies }) =>
let scheduleType: 'daily' | 'weekly' | 'custom' = 'custom';
if (cronExpression) {
const parts = cronExpression.split(' ');
if (parts.length === 5) {
if (parts.length >= 5) {
const [, , day, month, dow] = parts;
if (dow !== '*' && day === '*' && month === '*') {
scheduleType = 'weekly';
@@ -107,12 +101,7 @@ export const POST: RequestHandler = async ({ params, url, request, cookies }) =>
}
};
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
export const DELETE: RequestHandler = async ({ params, url }) => {
try {
const containerName = decodeURIComponent(params.containerName);
const envIdParam = url.searchParams.get('env');
+1 -4
View File
@@ -3,7 +3,6 @@ import { listContainers, createContainer, pullImage, EnvironmentNotFoundError, D
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { hasEnvironments } from '$lib/server/db';
import { isHiddenByLabel } from '$lib/server/container-labels';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, cookies }) => {
@@ -35,9 +34,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
try {
const containers = await listContainers(all, envIdNum);
// Filter out containers with dockhand.hidden=true label
const visible = containers.filter(c => !isHiddenByLabel(c.labels));
return json(visible);
return json(containers);
} catch (error: any) {
// Return 404 for missing environment so frontend can clear stale localStorage
if (error instanceof EnvironmentNotFoundError) {
+1 -22
View File
@@ -4,8 +4,7 @@ import {
removeContainer,
getContainerLogs
} from '$lib/server/docker';
import { deleteAutoUpdateSchedule, getAutoUpdateSetting, getSecretKeysToMask, removePendingContainerUpdate } from '$lib/server/db';
import { getStackComposeFile } from '$lib/server/stacks';
import { deleteAutoUpdateSchedule, getAutoUpdateSetting, removePendingContainerUpdate } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { unregisterSchedule } from '$lib/server/scheduler';
@@ -34,26 +33,6 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
try {
const details = await inspectContainer(params.id, envIdNum);
// Mask secret env vars for containers belonging to a Compose stack.
// Uses compose file parsing to detect interpolation (e.g., MYSQL_PASSWORD=${db_secret}).
const stackName = details.Config?.Labels?.['com.docker.compose.project'];
if (stackName && Array.isArray(details.Config?.Env)) {
const composeResult = await getStackComposeFile(stackName, envIdNum).catch(() => null);
const secretKeys = await getSecretKeysToMask(stackName, envIdNum, composeResult?.content);
if (secretKeys.size > 0) {
details.Config.Env = details.Config.Env.map((entry: string) => {
const eqIdx = entry.indexOf('=');
if (eqIdx === -1) return entry;
const key = entry.substring(0, eqIdx);
if (secretKeys.has(key)) {
return `${key}=***`;
}
return entry;
});
}
}
return json(details);
} catch (error: any) {
if (error?.statusCode === 404) {
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
import { putContainerArchive, inspectContainer, execInContainer } from '$lib/server/docker';
import { putContainerArchive } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
@@ -111,15 +111,6 @@ export const POST: RequestHandler = async ({ params, url, request, cookies }) =>
return json({ error: 'No files provided' }, { status: 400 });
}
// We'll inspect the container once to determine its default user
let defaultUser: string | undefined;
try {
const inspectData = await inspectContainer(params.id, envIdNum);
defaultUser = inspectData.Config.User || undefined;
} catch (e) {
console.warn('Failed to inspect container for user info', e);
}
// For simplicity, we'll upload files one at a time
// A more sophisticated implementation could pack multiple files into one tar
const uploaded: string[] = [];
@@ -137,22 +128,6 @@ export const POST: RequestHandler = async ({ params, url, request, cookies }) =>
envId ? parseInt(envId) : undefined
);
// chown the uploaded file
if (defaultUser) {
const targetPath = path.endsWith('/') ? `${path}${file.name}` : `${path}/${file.name}`;
const ownerGroup = defaultUser.includes(':') ? defaultUser : `${defaultUser}:${defaultUser}`;
try {
await execInContainer(
params.id,
['chown', '-R', ownerGroup, targetPath],
envId ? parseInt(envId) : undefined,
'root'
);
} catch (e) {
console.warn('Failed to set ownership on', targetPath, e);
}
}
uploaded.push(file.name);
} catch (err: any) {
errors.push(`${file.name}: ${err.message}`);
@@ -1,8 +1,6 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { inspectContainer } from '$lib/server/docker';
import { getSecretKeysToMask } from '$lib/server/db';
import { getStackComposeFile } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
@@ -22,26 +20,6 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
try {
const containerData = await inspectContainer(params.id, envIdNum);
// Mask secret env vars for containers belonging to a Compose stack.
// Uses compose file parsing to detect interpolation (e.g., MYSQL_PASSWORD=${db_secret}).
const stackName = containerData.Config?.Labels?.['com.docker.compose.project'];
if (stackName && Array.isArray(containerData.Config?.Env)) {
const composeResult = await getStackComposeFile(stackName, envIdNum).catch(() => null);
const secretKeys = await getSecretKeysToMask(stackName, envIdNum, composeResult?.content);
if (secretKeys.size > 0) {
containerData.Config.Env = containerData.Config.Env.map((entry: string) => {
const eqIdx = entry.indexOf('=');
if (eqIdx === -1) return entry;
const key = entry.substring(0, eqIdx);
if (secretKeys.has(key)) {
return `${key}=***`;
}
return entry;
});
}
}
return json(containerData);
} catch (error) {
console.error('Failed to inspect container:', error);
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
import { inspectContainer, pullImage, updateContainer, type CreateContainerOptions } from '$lib/server/docker';
import { pullImage, updateContainer, type CreateContainerOptions } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { removePendingContainerUpdate } from '$lib/server/db';
@@ -25,29 +25,6 @@ export const POST: RequestHandler = async (event) => {
const body = await request.json();
const { startAfterUpdate, repullImage, ...options } = body;
// Resolve masked secret values (***) back to real values from the current container.
// The GET endpoint masks secrets in Config.Env, so the edit modal sends *** for unchanged secrets.
if (Array.isArray(options.env) && options.env.some((e: string) => e.endsWith('=***'))) {
const currentData = await inspectContainer(params.id, envIdNum);
const currentEnvMap = new Map<string, string>();
for (const entry of currentData.Config?.Env || []) {
const eqIdx = entry.indexOf('=');
if (eqIdx !== -1) {
currentEnvMap.set(entry.substring(0, eqIdx), entry.substring(eqIdx + 1));
}
}
options.env = options.env.map((entry: string) => {
const eqIdx = entry.indexOf('=');
if (eqIdx === -1) return entry;
const key = entry.substring(0, eqIdx);
const value = entry.substring(eqIdx + 1);
if (value === '***' && currentEnvMap.has(key)) {
return `${key}=${currentEnvMap.get(key)}`;
}
return entry;
});
}
if (repullImage) {
console.log(`Pulling image...`);
try {
@@ -15,7 +15,6 @@ import { auditContainer } from '$lib/server/audit';
import { getScannerSettings, scanImage } from '$lib/server/scanner';
import { saveVulnerabilityScan, removePendingContainerUpdate, type VulnerabilityCriteria } from '$lib/server/db';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
import { isUpdateDisabledByLabel } from '$lib/server/container-labels';
import { recreateContainer } from '$lib/server/scheduler/tasks/container-update';
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
@@ -174,22 +173,6 @@ export const POST: RequestHandler = async (event) => {
continue;
}
// Skip containers with dockhand.update=false label
if (isUpdateDisabledByLabel(config.Labels)) {
sendData({
type: 'progress',
containerId,
containerName,
step: 'skipped',
current: i + 1,
total: containerIds.length,
success: true,
message: `Skipping ${containerName} - dockhand.update=false label`
});
skippedCount++;
continue;
}
// Skip digest-pinned images - they are explicitly locked to a specific version
if (isDigestBasedImage(imageName)) {
sendData({
@@ -4,7 +4,6 @@ import { authorize } from '$lib/server/authorize';
import { listContainers, pullImage, inspectContainer } from '$lib/server/docker';
import { auditContainer } from '$lib/server/audit';
import { recreateContainer } from '$lib/server/scheduler/tasks/container-update';
import { isUpdateDisabledByLabel } from '$lib/server/container-labels';
export interface BatchUpdateResult {
containerId: string;
@@ -63,17 +62,6 @@ export const POST: RequestHandler = async (event) => {
const imageName = config.Image;
const containerName = container.name;
// Skip containers with dockhand.update=false label
if (isUpdateDisabledByLabel(config.Labels)) {
results.push({
containerId,
containerName,
success: true,
error: 'Skipped - dockhand.update=false label'
});
continue;
}
// Pull latest image first
try {
await pullImage(imageName, undefined, envIdNum);
@@ -4,7 +4,6 @@ import { authorize } from '$lib/server/authorize';
import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker';
import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db';
import { isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
import { isUpdateDisabledByLabel } from '$lib/server/container-labels';
import { createJobResponse } from '$lib/server/sse';
export interface UpdateCheckResult {
@@ -17,7 +16,6 @@ export interface UpdateCheckResult {
error?: string;
isLocalImage?: boolean;
systemContainer?: 'dockhand' | 'hawser' | null;
updateDisabled?: boolean;
}
/**
@@ -66,7 +64,6 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
}
const result = await checkImageUpdateAvailable(imageName, currentImageId, envIdNum);
const updateDisabled = isUpdateDisabledByLabel(inspectData.Config?.Labels);
return {
containerId: container.id,
@@ -77,8 +74,7 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
newDigest: result.registryDigest,
error: result.error,
isLocalImage: result.isLocalImage,
systemContainer: isSystemContainer(imageName) || null,
updateDisabled
systemContainer: isSystemContainer(imageName) || null
};
} catch (error: any) {
return {
@@ -106,12 +102,12 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, containers.length) }, () => runNext()));
const updatesFound = results.filter(r => r.hasUpdate && !r.systemContainer && !r.updateDisabled).length;
const updatesFound = results.filter(r => r.hasUpdate && !r.systemContainer).length;
// Save containers with updates to the database for persistence
if (envIdNum) {
for (const result of results) {
if (result.hasUpdate && !result.systemContainer && !result.updateDisabled) {
if (result.hasUpdate && !result.systemContainer) {
await addPendingContainerUpdate(
envIdNum,
result.containerId,
@@ -1,7 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { getPendingContainerUpdates, removePendingContainerUpdate, clearPendingContainerUpdates } from '$lib/server/db';
import { getPendingContainerUpdates, removePendingContainerUpdate } from '$lib/server/db';
/**
* Get pending container updates for an environment.
@@ -48,8 +48,8 @@ export const DELETE: RequestHandler = async ({ url, cookies }) => {
const containerId = url.searchParams.get('containerId');
const envIdNum = envId ? parseInt(envId) : undefined;
if (!envIdNum) {
return json({ error: 'Environment ID required' }, { status: 400 });
if (!envIdNum || !containerId) {
return json({ error: 'Environment ID and container ID required' }, { status: 400 });
}
// Need manage permission to delete
@@ -58,11 +58,7 @@ export const DELETE: RequestHandler = async ({ url, cookies }) => {
}
try {
if (containerId) {
await removePendingContainerUpdate(envIdNum, containerId);
} else {
await clearPendingContainerUpdates(envIdNum);
}
await removePendingContainerUpdate(envIdNum, containerId);
return json({ success: true });
} catch (error: any) {
console.error('Error removing pending update:', error);
-2
View File
@@ -3,7 +3,6 @@ import type { RequestHandler } from './$types';
import { getEnvironments, getEnvironmentByName, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, getImagePruneSettings, type Environment } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditEnvironment } from '$lib/server/audit';
import { invalidateTokenCacheForUser } from '$lib/server/api-tokens';
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
import { cleanPem } from '$lib/utils/pem';
@@ -131,7 +130,6 @@ export const POST: RequestHandler = async (event) => {
const adminRole = await getRoleByName('Admin');
if (adminRole) {
await assignUserRole(user.id, adminRole.id, env.id);
invalidateTokenCacheForUser(user.id);
}
} catch (roleError) {
// Log but don't fail - environment was created successfully
@@ -115,7 +115,6 @@ export const DELETE: RequestHandler = async (event) => {
// Delete git stack clone directories before cascade deletes the DB rows
const stacks = await getGitStacksByRepositoryId(id);
console.log(`[GitStack] Repository "${repository.name}" (id=${id}) deletion affects ${stacks.length} stacks: ${stacks.map(s => s.stackName).join(', ')}`);
for (const stack of stacks) {
await deleteGitStackFiles(stack.id, stack.stackName, stack.environmentId);
}
+1 -10
View File
@@ -7,8 +7,7 @@ import {
getGitRepository,
createGitRepository,
upsertStackSource,
setStackEnvVars,
getStackSource
setStackEnvVars
} from '$lib/server/db';
import { deployGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
@@ -62,12 +61,6 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'Stack name must start with a letter or number, and contain only letters, numbers, hyphens, and underscores' }, { status: 400 });
}
// Check for name conflicts with existing stacks (regular/external/git)
const existing = await getStackSource(trimmedStackName, data.environmentId || null);
if (existing) {
return json({ error: 'A stack with this name already exists on this environment' }, { status: 409 });
}
// Either repositoryId or new repo details (url, branch) must be provided
let repositoryId = data.repositoryId;
@@ -127,9 +120,7 @@ export const POST: RequestHandler = async (event) => {
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
webhookEnabled: data.webhookEnabled || false,
webhookSecret: webhookSecret,
contextDir: data.contextDir || null,
buildOnDeploy: data.buildOnDeploy ?? false,
noBuildCache: data.noBuildCache ?? false,
repullImages: data.repullImages ?? false,
forceRedeploy: data.forceRedeploy ?? false
});
@@ -73,9 +73,7 @@ export const PUT: RequestHandler = async (event) => {
autoUpdateCron: data.autoUpdateCron,
webhookEnabled: data.webhookEnabled,
webhookSecret: data.webhookSecret,
contextDir: data.contextDir,
buildOnDeploy: data.buildOnDeploy,
noBuildCache: data.noBuildCache,
repullImages: data.repullImages,
forceRedeploy: data.forceRedeploy
});
-6
View File
@@ -7,7 +7,6 @@ import {
getHostname
} from '$lib/server/license';
import { authorize } from '$lib/server/authorize';
import { clearTokenCache } from '$lib/server/api-tokens';
// GET /api/license - Get current license status
// Any authenticated user can view license status (needed to determine if RBAC applies)
@@ -60,9 +59,6 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
);
}
// Permission model changes between free/enterprise — clear cached tokens
clearTokenCache();
return json({
success: true,
license: result.license
@@ -85,8 +81,6 @@ export const DELETE: RequestHandler = async ({ cookies }) => {
try {
await deactivateLicense();
// Permission model changes between free/enterprise — clear cached tokens
clearTokenCache();
return json({ success: true });
} catch (error) {
console.error('Error deactivating license:', error);
+2 -4
View File
@@ -2,7 +2,6 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { getUser, updateUser as dbUpdateUser, deleteUserSessions, userHasAdminRole } from '$lib/server/db';
import { validateSession, hashPassword, isAuthEnabled } from '$lib/server/auth';
import { invalidateTokenCacheForUser } from '$lib/server/api-tokens';
// GET /api/profile - Get current user's profile
export const GET: RequestHandler = async ({ cookies }) => {
@@ -86,9 +85,8 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
}
updateData.passwordHash = await hashPassword(data.newPassword);
// Invalidate other sessions and token cache on password change
await deleteUserSessions(currentUser.id);
invalidateTokenCacheForUser(currentUser.id);
// Invalidate other sessions on password change
deleteUserSessions(currentUser.id);
}
const user = await dbUpdateUser(currentUser.id, updateData);
+2 -2
View File
@@ -46,8 +46,8 @@ export const GET: RequestHandler = async ({ url }) => {
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
return json({ error: 'Catalog listing not available. This registry may not support the _catalog endpoint (common with GitLab and Harbor). Try searching for images by name instead.' }, { status: response.status });
if (response.status === 401) {
return json({ error: 'Authentication failed. Please check your credentials.' }, { status: 401 });
}
if (response.status === 404) {
return json({ error: 'Registry does not support V2 catalog API' }, { status: 404 });
+15 -37
View File
@@ -1,7 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRegistry } from '$lib/server/db';
import { getRegistryAuth, parseRegistryUrl } from '$lib/server/docker';
import { getRegistryAuth } from '$lib/server/docker';
interface SearchResult {
name: string;
@@ -46,50 +46,28 @@ async function searchDockerHub(term: string, limit: number): Promise<SearchResul
async function searchPrivateRegistry(registry: any, term: string, limit: number): Promise<SearchResult[]> {
const results: string[] = [];
const { orgPath } = parseRegistryUrl(registry.url);
const orgPrefix = orgPath ? orgPath.replace(/^\//, '') : '';
// Strategy 1: Direct image lookup — try the exact term and org-prefixed variants
// This uses per-repository auth scope which works on all V2 registries (GitLab, Harbor, etc.)
const directCandidates: string[] = [];
// Strategy 1: If term looks like an image name (contains /), try direct lookup first
// This is much faster than iterating through catalog for large registries like ghcr.io
if (term.includes('/')) {
directCandidates.push(term);
}
// If registry URL has an org path (e.g., https://registry.example.com/group),
// try prepending it to the search term
if (orgPrefix && !term.startsWith(orgPrefix + '/')) {
directCandidates.push(`${orgPrefix}/${term}`);
}
for (const candidate of directCandidates) {
if (results.length >= limit) break;
const exists = await tryDirectImageLookup(registry, candidate);
if (exists && !results.includes(candidate)) {
results.push(candidate);
const directResult = await tryDirectImageLookup(registry, term);
if (directResult) {
results.push(term);
}
}
// Strategy 2: Fall back to catalog search for partial/fuzzy matches
// Some registries (GitLab, Harbor) don't support _catalog for deploy tokens,
// so catch errors gracefully and return whatever we have from direct lookup
// Strategy 2: Fall back to catalog search for partial matches or if direct lookup failed
if (results.length < limit) {
try {
const catalogResults = await searchCatalog(registry, term, limit - results.length);
for (const name of catalogResults) {
if (!results.includes(name)) {
results.push(name);
}
}
} catch (e: any) {
// Catalog not supported but we have direct lookup results — that's fine
if (results.length > 0) {
console.warn(`[Registry] Catalog search failed (using direct lookup results): ${e.message}`);
} else {
throw e;
const catalogResults = await searchCatalog(registry, term, limit - results.length);
// Add catalog results, avoiding duplicates
for (const name of catalogResults) {
if (!results.includes(name)) {
results.push(name);
}
}
}
// Return results in the same format as Docker Hub
return results.map((name: string) => ({
name,
description: '',
@@ -158,8 +136,8 @@ async function searchCatalog(registry: any, term: string, limit: number): Promis
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new Error('Authentication failed. This registry may not support catalog listing (common with GitLab and Harbor deploy tokens).');
if (response.status === 401) {
throw new Error('Authentication failed');
}
throw new Error(`Registry returned error: ${response.status}`);
}
+2 -2
View File
@@ -93,8 +93,8 @@ async function fetchRegistryTags(registry: any, imageName: string): Promise<TagI
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new Error('Authentication failed. Please check your credentials and permissions.');
if (response.status === 401) {
throw new Error('Authentication failed');
}
if (response.status === 404) {
throw new Error('Image not found in registry');
-7
View File
@@ -8,7 +8,6 @@ import {
import { authorize } from '$lib/server/authorize';
import { auditRole } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
import { clearTokenCache } from '$lib/server/api-tokens';
// GET /api/roles/[id] - Get a specific role
export const GET: RequestHandler = async ({ params, cookies }) => {
@@ -76,9 +75,6 @@ export const PUT: RequestHandler = async (event) => {
return json({ error: 'Failed to update role' }, { status: 500 });
}
// Clear token cache — any cached user with this role has stale permissions
clearTokenCache();
// Compute diff for audit
const diff = computeAuditDiff(existingRole, role);
@@ -132,9 +128,6 @@ export const DELETE: RequestHandler = async (event) => {
return json({ error: 'Failed to delete role' }, { status: 500 });
}
// Clear token cache — users with this role may have stale cached permissions
clearTokenCache();
// Audit log
await auditRole(event, 'delete', id, role.name);
@@ -13,14 +13,8 @@ import {
deleteImagePruneSettings
} from '$lib/server/db';
import { unregisterSchedule } from '$lib/server/scheduler';
import { authorize } from '$lib/server/authorize';
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
export const DELETE: RequestHandler = async ({ params }) => {
try {
const { type, id } = params;
const scheduleId = parseInt(id, 10);
@@ -11,14 +11,8 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { triggerContainerUpdate, triggerGitStackSync, triggerSystemJob, triggerEnvUpdateCheck, triggerImagePrune } from '$lib/server/scheduler';
import { authorize } from '$lib/server/authorize';
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('schedules', 'run')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
export const POST: RequestHandler = async ({ params }) => {
try {
const { type, id } = params;
const scheduleId = parseInt(id, 10);
@@ -7,14 +7,8 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getAutoUpdateSettingById, updateAutoUpdateSettingById, getGitStack, updateGitStack, getEnvUpdateCheckSettings, setEnvUpdateCheckSettings, getImagePruneSettings, setImagePruneSettings } from '$lib/server/db';
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
import { authorize } from '$lib/server/authorize';
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
export const POST: RequestHandler = async ({ params }) => {
try {
const { type, id } = params;
const scheduleId = parseInt(id, 10);
@@ -8,7 +8,6 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getScheduleExecution, deleteScheduleExecution } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ params }) => {
try {
@@ -29,12 +28,7 @@ export const GET: RequestHandler = async ({ params }) => {
}
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
export const DELETE: RequestHandler = async ({ params }) => {
try {
const id = parseInt(params.id, 10);
if (isNaN(id)) {
@@ -2,17 +2,13 @@ import { json, type RequestHandler } from '@sveltejs/kit';
import {
setScheduleCleanupEnabled,
setEventCleanupEnabled,
setScannerCleanupEnabled,
getScheduleCleanupEnabled,
getEventCleanupEnabled,
getScannerCleanupEnabled
getEventCleanupEnabled
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { refreshSystemJobs } from '$lib/server/scheduler';
const SYSTEM_SCHEDULE_CLEANUP_ID = 1;
const SYSTEM_EVENT_CLEANUP_ID = 2;
const SYSTEM_SCANNER_CLEANUP_ID = 4;
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
@@ -36,11 +32,6 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
const currentEnabled = await getEventCleanupEnabled();
await setEventCleanupEnabled(!currentEnabled);
return json({ success: true, enabled: !currentEnabled });
} else if (systemId === SYSTEM_SCANNER_CLEANUP_ID) {
const currentEnabled = await getScannerCleanupEnabled();
await setScannerCleanupEnabled(!currentEnabled);
await refreshSystemJobs();
return json({ success: true, enabled: !currentEnabled });
} else {
return json({ error: 'Unknown system schedule' }, { status: 400 });
}
+15 -15
View File
@@ -1,13 +1,6 @@
import { json } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { getAdditionalVolumeBinds } from '$lib/server/mount-dedupe';
import {
getOwnContainerId,
getHostDockerSocket,
getOwnDockerHost,
getOwnExtraHosts,
getOwnNetworkMode
} from '$lib/server/host-path';
import { getOwnContainerId, getHostDockerSocket, getOwnDockerHost, getOwnNetworkMode } from '$lib/server/host-path';
import { buildRegistryAuthHeader, unixSocketRequest, unixSocketStreamRequest } from '$lib/server/docker';
import type { RequestHandler } from './$types';
import { prefersJSON, sseToJSON } from '$lib/server/sse';
@@ -167,7 +160,20 @@ function buildCreateConfig(inspectData: any, newImage: string): any {
// Otherwise the old container's hostname is inherited, breaking self-identification
delete createConfig.Hostname;
const additionalBinds = getAdditionalVolumeBinds(hostConfig, inspectData.Mounts || []);
// Preserve anonymous volumes from Mounts not in HostConfig.Binds
const existingBinds = new Set((hostConfig.Binds || []).map((b: string) => {
const parts = b.split(':');
return parts.length >= 2 ? parts[1] : parts[0];
}));
const mounts = inspectData.Mounts || [];
const additionalBinds: string[] = [];
for (const mount of mounts) {
if (mount.Type === 'volume' && mount.Name && mount.Destination) {
if (!existingBinds.has(mount.Destination)) {
additionalBinds.push(`${mount.Name}:${mount.Destination}`);
}
}
}
if (additionalBinds.length > 0) {
createConfig.HostConfig = {
...createConfig.HostConfig,
@@ -389,12 +395,6 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
// Configure updater's Docker access based on connection type
const tcpHost = getDockerTcpHost();
const updaterHostConfig: Record<string, unknown> = { AutoRemove: true };
const updaterExtraHosts = getOwnExtraHosts() ?? undefined;
if (updaterExtraHosts?.length) {
updaterHostConfig.ExtraHosts = updaterExtraHosts;
console.log(`[SelfUpdate] Reusing ExtraHosts for updater: ${updaterExtraHosts.join(', ')}`);
}
if (tcpHost) {
// TCP: pass DOCKER_HOST so docker CLI in sidecar uses TCP
+9 -76
View File
@@ -14,10 +14,6 @@ import {
setScheduleCleanupEnabled,
getEventCleanupEnabled,
setEventCleanupEnabled,
getScannerCleanupCron,
setScannerCleanupCron,
getScannerCleanupEnabled,
setScannerCleanupEnabled,
getDefaultTimezone,
setDefaultTimezone,
getEventCollectionMode,
@@ -56,8 +52,6 @@ export interface GeneralSettings {
eventCleanupCron: string;
scheduleCleanupEnabled: boolean;
eventCleanupEnabled: boolean;
scannerCleanupCron: string;
scannerCleanupEnabled: boolean;
logBufferSizeKb: number;
defaultTimezone: string;
// Background monitoring settings
@@ -83,13 +77,9 @@ export interface GeneralSettings {
// Scanner images
defaultGrypeImage: string;
defaultTrivyImage: string;
// Compose template
defaultComposeTemplate: string;
// Label filter mode
labelFilterMode: 'any' | 'all';
}
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled' | 'scannerCleanupCron' | 'scannerCleanupEnabled'> = {
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled'> = {
confirmDestructive: true,
showStoppedContainers: true,
highlightUpdates: true,
@@ -115,26 +105,7 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
externalStackPaths: [],
primaryStackLocation: null,
defaultGrypeImage: DEFAULT_GRYPE_IMAGE,
defaultTrivyImage: DEFAULT_TRIVY_IMAGE,
labelFilterMode: 'any' as const,
defaultComposeTemplate: `version: "3.8"
services:
app:
image: nginx:alpine
ports:
- "8080:80"
environment:
- APP_ENV=\${APP_ENV:-production}
volumes:
- ./html:/usr/share/nginx/html:ro
restart: unless-stopped
# Add more services as needed
# networks:
# default:
# driver: bridge
`
defaultTrivyImage: DEFAULT_TRIVY_IMAGE
};
const VALID_LIGHT_THEMES = ['default', 'catppuccin', 'rose-pine', 'nord', 'solarized', 'gruvbox', 'alucard', 'github', 'material', 'atom-one'];
@@ -171,8 +142,6 @@ export const GET: RequestHandler = async ({ cookies }) => {
eventCleanupCron,
scheduleCleanupEnabled,
eventCleanupEnabled,
scannerCleanupCron,
scannerCleanupEnabled,
logBufferSizeKb,
defaultTimezone,
eventCollectionMode,
@@ -190,9 +159,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
externalStackPaths,
primaryStackLocation,
defaultGrypeImage,
defaultTrivyImage,
defaultComposeTemplate,
labelFilterMode
defaultTrivyImage
] = await Promise.all([
getSetting('confirm_destructive'),
getSetting('show_stopped_containers'),
@@ -208,8 +175,6 @@ export const GET: RequestHandler = async ({ cookies }) => {
getEventCleanupCron(),
getScheduleCleanupEnabled(),
getEventCleanupEnabled(),
getScannerCleanupCron(),
getScannerCleanupEnabled(),
getSetting('log_buffer_size_kb'),
getDefaultTimezone(),
getEventCollectionMode(),
@@ -227,9 +192,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
getExternalStackPaths(),
getPrimaryStackLocation(),
getSetting('default_grype_image'),
getSetting('default_trivy_image'),
getSetting('default_compose_template'),
getSetting('label_filter_mode')
getSetting('default_trivy_image')
]);
const settings: GeneralSettings = {
@@ -247,8 +210,6 @@ export const GET: RequestHandler = async ({ cookies }) => {
eventCleanupCron,
scheduleCleanupEnabled,
eventCleanupEnabled,
scannerCleanupCron,
scannerCleanupEnabled,
logBufferSizeKb: logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
defaultTimezone: defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
eventCollectionMode: (eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode) as EventCollectionMode,
@@ -266,9 +227,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
externalStackPaths,
primaryStackLocation,
defaultGrypeImage: defaultGrypeImage ?? DEFAULT_GRYPE_IMAGE,
defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE,
defaultComposeTemplate: defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode
defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE
};
return json(settings);
@@ -286,7 +245,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const body = await request.json();
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode } = body;
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage } = body;
if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive);
@@ -330,14 +289,6 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (eventCleanupEnabled !== undefined && typeof eventCleanupEnabled === 'boolean') {
await setEventCleanupEnabled(eventCleanupEnabled);
}
if (scannerCleanupCron !== undefined && typeof scannerCleanupCron === 'string') {
await setScannerCleanupCron(scannerCleanupCron);
await refreshSystemJobs();
}
if (scannerCleanupEnabled !== undefined && typeof scannerCleanupEnabled === 'boolean') {
await setScannerCleanupEnabled(scannerCleanupEnabled);
await refreshSystemJobs();
}
if (logBufferSizeKb !== undefined && typeof logBufferSizeKb === 'number') {
// Clamp to reasonable range: 100KB - 5000KB (5MB)
await setSetting('log_buffer_size_kb', Math.max(100, Math.min(5000, logBufferSizeKb)));
@@ -413,12 +364,6 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (defaultTrivyImage !== undefined && typeof defaultTrivyImage === 'string') {
await setSetting('default_trivy_image', defaultTrivyImage);
}
if (defaultComposeTemplate !== undefined && typeof defaultComposeTemplate === 'string') {
await setSetting('default_compose_template', defaultComposeTemplate);
}
if (labelFilterMode !== undefined && (labelFilterMode === 'any' || labelFilterMode === 'all')) {
await setSetting('label_filter_mode', labelFilterMode);
}
// Fetch all settings in parallel for the response
const [
@@ -436,8 +381,6 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
eventCleanupCronVal,
scheduleCleanupEnabledVal,
eventCleanupEnabledVal,
scannerCleanupCronVal,
scannerCleanupEnabledVal,
logBufferSizeKbVal,
defaultTimezoneVal,
eventCollectionModeVal,
@@ -455,9 +398,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
externalStackPathsVal,
primaryStackLocationVal,
defaultGrypeImageVal,
defaultTrivyImageVal,
defaultComposeTemplateVal,
labelFilterModeVal
defaultTrivyImageVal
] = await Promise.all([
getSetting('confirm_destructive'),
getSetting('show_stopped_containers'),
@@ -473,8 +414,6 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getEventCleanupCron(),
getScheduleCleanupEnabled(),
getEventCleanupEnabled(),
getScannerCleanupCron(),
getScannerCleanupEnabled(),
getSetting('log_buffer_size_kb'),
getDefaultTimezone(),
getEventCollectionMode(),
@@ -492,9 +431,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getExternalStackPaths(),
getPrimaryStackLocation(),
getSetting('default_grype_image'),
getSetting('default_trivy_image'),
getSetting('default_compose_template'),
getSetting('label_filter_mode')
getSetting('default_trivy_image')
]);
const settings: GeneralSettings = {
@@ -512,8 +449,6 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
eventCleanupCron: eventCleanupCronVal,
scheduleCleanupEnabled: scheduleCleanupEnabledVal,
eventCleanupEnabled: eventCleanupEnabledVal,
scannerCleanupCron: scannerCleanupCronVal,
scannerCleanupEnabled: scannerCleanupEnabledVal,
logBufferSizeKb: logBufferSizeKbVal ?? DEFAULT_SETTINGS.logBufferSizeKb,
defaultTimezone: defaultTimezoneVal ?? DEFAULT_SETTINGS.defaultTimezone,
eventCollectionMode: (eventCollectionModeVal ?? DEFAULT_SETTINGS.eventCollectionMode) as EventCollectionMode,
@@ -531,9 +466,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
externalStackPaths: externalStackPathsVal,
primaryStackLocation: primaryStackLocationVal,
defaultGrypeImage: defaultGrypeImageVal ?? DEFAULT_GRYPE_IMAGE,
defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE,
defaultComposeTemplate: defaultComposeTemplateVal ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: labelFilterModeVal ?? DEFAULT_SETTINGS.labelFilterMode
defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE
};
return json(settings);
+1 -4
View File
@@ -144,13 +144,10 @@ export const POST: RequestHandler = async (event) => {
}
// ALWAYS save compose file first - deployStack expects it to exist
const saveResult = await saveStackComposeFile(name, compose, true, envIdNum, {
await saveStackComposeFile(name, compose, true, envIdNum, {
composePath: composePath || undefined,
envPath: envPath || undefined
});
if (!saveResult.success) {
return json({ error: saveResult.error }, { status: 400 });
}
// Save environment variables BEFORE deploying so they're available during start
if (rawEnvContent || (envVars && Array.isArray(envVars) && envVars.length > 0)) {
+2 -3
View File
@@ -9,7 +9,6 @@ export const DELETE: RequestHandler = async (event) => {
const auth = await authorize(cookies);
const force = url.searchParams.get('force') === 'true';
const volumes = url.searchParams.get('volumes') === 'true';
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
@@ -25,10 +24,10 @@ export const DELETE: RequestHandler = async (event) => {
try {
const stackName = decodeURIComponent(params.name);
const result = await removeStack(stackName, envIdNum, force, volumes);
const result = await removeStack(stackName, envIdNum, force);
// Audit log
await auditStack(event, 'delete', stackName, envIdNum, { force, volumes });
await auditStack(event, 'delete', stackName, envIdNum, { force });
if (!result.success) {
return json({ success: false, error: result.error }, { status: 400 });
-2
View File
@@ -11,7 +11,6 @@ import {
} from '$lib/server/db';
import { hashPassword, createUserSession } from '$lib/server/auth';
import { authorize } from '$lib/server/authorize';
import { invalidateTokenCacheForUser } from '$lib/server/api-tokens';
import { auditUser } from '$lib/server/audit';
// GET /api/users - List all users
@@ -109,7 +108,6 @@ export const POST: RequestHandler = async (event) => {
const adminRole = await getRoleByName('Admin');
if (adminRole) {
await assignUserRole(user.id, adminRole.id, null);
invalidateTokenCacheForUser(user.id);
}
}
+3 -12
View File
@@ -16,7 +16,6 @@ import { hashPassword } from '$lib/server/auth';
import { authorize } from '$lib/server/authorize';
import { auditUser } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
import { invalidateTokenCacheForUser } from '$lib/server/api-tokens';
// GET /api/users/[id] - Get a specific user
// Free for all - local users are needed for basic auth
@@ -126,7 +125,6 @@ export const PUT: RequestHandler = async (event) => {
if (isDeactivating) {
updateData.isActive = false;
await deleteUserSessions(userId);
invalidateTokenCacheForUser(userId);
}
// Disable authentication
@@ -144,7 +142,6 @@ export const PUT: RequestHandler = async (event) => {
if (adminRole) {
await removeUserRole(userId, adminRole.id, null);
}
invalidateTokenCacheForUser(userId);
}
return json({
@@ -173,10 +170,9 @@ export const PUT: RequestHandler = async (event) => {
}
if (data.isActive !== undefined) {
updateData.isActive = data.isActive;
// If deactivating, invalidate all sessions and token cache
// If deactivating, invalidate all sessions
if (!data.isActive) {
await deleteUserSessions(userId);
invalidateTokenCacheForUser(userId);
}
}
}
@@ -187,9 +183,8 @@ export const PUT: RequestHandler = async (event) => {
return json({ error: 'Password must be at least 8 characters' }, { status: 400 });
}
updateData.passwordHash = await hashPassword(data.password);
// Invalidate all sessions and token cache on password change
// Invalidate all sessions on password change (except current)
await deleteUserSessions(userId);
invalidateTokenCacheForUser(userId);
}
const user = await dbUpdateUser(userId, updateData);
@@ -203,10 +198,8 @@ export const PUT: RequestHandler = async (event) => {
if (adminRole) {
if (shouldPromote) {
await assignUserRole(userId, adminRole.id, null);
invalidateTokenCacheForUser(userId);
} else if (shouldDemote) {
await removeUserRole(userId, adminRole.id, null);
invalidateTokenCacheForUser(userId);
}
}
@@ -291,7 +284,6 @@ export const DELETE: RequestHandler = async (event) => {
// User confirmed - proceed with deletion and disable auth
await deleteUserSessions(id);
invalidateTokenCacheForUser(id);
const deleted = await dbDeleteUser(id);
if (!deleted) {
return json({ error: 'Failed to delete user' }, { status: 500 });
@@ -307,9 +299,8 @@ export const DELETE: RequestHandler = async (event) => {
}
}
// Delete all sessions and invalidate token cache
// Delete all sessions first
await deleteUserSessions(id);
invalidateTokenCacheForUser(id);
const deleted = await dbDeleteUser(id);
if (!deleted) {
@@ -10,7 +10,6 @@ import {
getRole
} from '$lib/server/db';
import { auditUser } from '$lib/server/audit';
import { invalidateTokenCacheForUser } from '$lib/server/api-tokens';
// GET /api/users/[id]/roles - Get roles assigned to a user
export const GET: RequestHandler = async ({ params, cookies }) => {
@@ -70,7 +69,6 @@ export const POST: RequestHandler = async (event) => {
}
const userRole = await assignUserRole(userId, roleId, environmentId);
invalidateTokenCacheForUser(userId);
// Audit log - role assigned
const role = await getRole(roleId);
@@ -119,7 +117,6 @@ export const DELETE: RequestHandler = async (event) => {
if (!deleted) {
return json({ error: 'Role assignment not found' }, { status: 404 });
}
invalidateTokenCacheForUser(userId);
// Audit log - role removed
if (user) {
@@ -1,7 +1,7 @@
import { gzipSync } from 'node:zlib';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getVolumeArchive, releaseVolumeHelperContainer } from '$lib/server/docker';
import { getVolumeArchive } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
@@ -36,34 +36,16 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
let body: ReadableStream<Uint8Array> | Uint8Array = response.body!;
if (format === 'tar.gz') {
// Compress with gzip — fully consumes the archive stream
// Compress with gzip
const tarData = new Uint8Array(await response.arrayBuffer());
body = gzipSync(tarData);
contentType = 'application/gzip';
extension = '.tar.gz';
// Data fully read, release helper container immediately
releaseVolumeHelperContainer(params.name, envIdNum).catch(() => {});
} else {
// For streaming tar, wrap the stream to release on completion
const reader = body.getReader();
body = new ReadableStream({
async pull(controller) {
const { done, value } = await reader.read();
if (done) {
controller.close();
releaseVolumeHelperContainer(params.name, envIdNum).catch(() => {});
} else {
controller.enqueue(value);
}
},
cancel() {
reader.cancel();
releaseVolumeHelperContainer(params.name, envIdNum).catch(() => {});
}
});
}
// Note: Helper container is cached and reused for subsequent requests.
// Cache TTL handles cleanup automatically.
const headers: Record<string, string> = {
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="${filename}${extension}"`
@@ -84,9 +66,6 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
} catch (error: any) {
console.error('Failed to export volume:', error);
// Best-effort cleanup on error
releaseVolumeHelperContainer(params.name, envIdNum).catch(() => {});
if (error.message?.includes('No such file or directory')) {
return json({ error: 'Path not found' }, { status: 404 });
}

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