Compare commits

...

15 Commits

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

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

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

Fixes #360

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

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

Bark notifications are sent with POST JSON payloads containing the device key, title, and body. The notification settings modal now
lists Bark examples in the Apprise URL placeholder and support text.
2026-06-06 17:04:59 +02:00
jarek 3cbcfa3cdb 1.0.32 2026-06-06 16:18:05 +02:00
jarek 00bd09df55 1.0.31 2026-05-30 12:23:36 +02:00
jarek c8b3acc07e 1.0.30 2026-05-30 08:42:21 +02:00
jarek e7100f8926 1.0.29 2026-05-17 08:02:31 +02:00
196 changed files with 23798 additions and 2310 deletions
+2 -2
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.1.4-r5" \
" - docker-cli-buildx" \
" - sqlite" \
" - postgresql-client" \
@@ -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.11 AS go-builder
ARG TARGETARCH
WORKDIR /app
COPY collector/ ./collector/
+100 -4
View File
@@ -37,10 +37,106 @@ Dockhand is a modern, efficient Docker management application providing real-tim
- **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"> |
<table>
<tr>
<td width="50%">
<img src="docs/screenshot1.webp" alt="Environments overview">
<p align="center"><sub><sub><sub><b>Environments overview</b> — manage every Docker host from one place</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot2.webp" alt="Environment dashboard">
<p align="center"><sub><sub><sub><b>Environment dashboard</b> — live CPU, memory and disk metrics per host</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot3.webp" alt="Containers">
<p align="center"><sub><sub><sub><b>Containers</b> — real-time status, resources and port mappings</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot6.webp" alt="Compose stacks">
<p align="center"><sub><sub><sub><b>Compose stacks</b> — deploy and orchestrate multi-container apps</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot7.webp" alt="Compose editor">
<p align="center"><sub><sub><sub><b>Compose editor</b> — edit YAML side-by-side with env variables</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot8.webp" alt="Images">
<p align="center"><sub><sub><sub><b>Images</b> — track tags, sizes, updates and clean up unused</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot4.webp" alt="Logs and terminal">
<p align="center"><sub><sub><sub><b>Logs &amp; terminal</b> — stream logs with a shell next to them</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot5.webp" alt="Interactive shell">
<p align="center"><sub><sub><sub><b>Interactive shell</b> — exec straight into any container</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot10.webp" alt="Add environment">
<p align="center"><sub><sub><sub><b>Add environment</b> — connect via socket, agent or direct TCP</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot9.webp" alt="Settings and theming">
<p align="center"><sub><sub><sub><b>Settings &amp; theming</b> — themes, fonts, scanners and schedules</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot11.webp" alt="Network graph">
<p align="center"><sub><sub><sub><b>Network graph</b> — visualize how services connect across stacks</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot13.webp" alt="Container file browser">
<p align="center"><sub><sub><sub><b>Container files</b> — browse, edit, upload and download in-place</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot12.webp" alt="Image layers">
<p align="center"><sub><sub><sub><b>Image layers</b> — inspect every layer, its size and contents</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot14.webp" alt="Vulnerability scanning">
<p align="center"><sub><sub><sub><b>Vulnerability scans</b> — Grype &amp; Trivy CVE results per image</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot15.webp" alt="Volume browser">
<p align="center"><sub><sub><sub><b>Volume browser</b> — explore and edit files inside any volume</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot19.webp" alt="Stack graph editor">
<p align="center"><sub><sub><sub><b>Stack graph editor</b> — visual editor for services, networks and secrets</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot16.webp" alt="Deploy from Git">
<p align="center"><sub><sub><sub><b>Deploy from Git</b> — pull stacks from repos with webhooks &amp; auto-sync</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot17.webp" alt="Schedules">
<p align="center"><sub><sub><sub><b>Schedules</b> — cron-style automation for prune, updates and cleanup</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot18.webp" alt="Activity log">
<p align="center"><sub><sub><sub><b>Activity log</b> — audit every action across all environments</sub></sub></sub></p>
</td>
<td width="50%"></td>
</tr>
</table>
## License
+1 -1
View File
@@ -1 +1 @@
v1.0.28
v1.0.34
+1 -1
View File
@@ -1,3 +1,3 @@
module github.com/Finsys/dockhand/collector
go 1.25.9
go 1.25.11
+66 -20
View File
@@ -221,13 +221,19 @@ func buildClients(cfg *EnvConfig) (client *http.Client, streamClient *http.Clien
baseURL = "http://localhost"
case "http":
// Explicit dial timeout and TCP keepalive so connections over dead
// tunnels (VPN/Tailscale drops) are detected at kernel level instead
// of hanging indefinitely.
tcpDial := (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 15 * time.Second}).DialContext
transport = &http.Transport{
DialContext: tcpDial,
MaxIdleConns: 16,
MaxIdleConnsPerHost: 16,
MaxConnsPerHost: 16,
IdleConnTimeout: 90 * time.Second,
}
streamTransport = &http.Transport{
DialContext: tcpDial,
MaxIdleConns: 4,
MaxIdleConnsPerHost: 4,
MaxConnsPerHost: 4,
@@ -242,7 +248,9 @@ func buildClients(cfg *EnvConfig) (client *http.Client, streamClient *http.Clien
}
streamTLSCfg := tlsCfg.Clone()
tcpDial := (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 15 * time.Second}).DialContext
transport = &http.Transport{
DialContext: tcpDial,
TLSClientConfig: tlsCfg,
MaxIdleConns: 16,
MaxIdleConnsPerHost: 16,
@@ -250,6 +258,7 @@ func buildClients(cfg *EnvConfig) (client *http.Client, streamClient *http.Clien
IdleConnTimeout: 90 * time.Second,
}
streamTransport = &http.Transport{
DialContext: tcpDial,
TLSClientConfig: streamTLSCfg,
MaxIdleConns: 4,
MaxIdleConnsPerHost: 4,
@@ -322,15 +331,32 @@ func (e *environment) doStreamRequest(ctx context.Context, method, path string)
return e.streamClient.Do(req)
}
func (e *environment) ping(ctx context.Context) bool {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
resp, err := e.doRequest(ctx, "GET", "/_ping")
if err != nil {
return false
func (e *environment) ping(ctx context.Context) error {
attempt := func() error {
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
resp, err := e.doRequest(pingCtx, "GET", "/_ping")
if err != nil {
return err
}
drainAndClose(resp)
if resp.StatusCode != 200 {
return fmt.Errorf("ping returned status %d", resp.StatusCode)
}
return nil
}
drainAndClose(resp)
return resp.StatusCode == 200
if err := attempt(); err == nil {
return nil
} else if ctx.Err() != nil {
return err
}
// Stale pooled connections (e.g. after a VPN/tunnel drop) hang requests
// until timeout while the host is actually reachable. Evict the pool and
// retry once on a guaranteed-fresh connection.
e.closeTransports()
return attempt()
}
// ---------------------------------------------------------------------------
@@ -358,11 +384,11 @@ func (m *manager) runMetrics(env *environment) {
}
func (m *manager) collectMetrics(env *environment) {
if !env.ping(env.ctx) {
if err := env.ping(env.ctx); err != nil {
if env.online || !env.statusReported {
env.online = false
env.statusReported = true
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable"})
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable: " + err.Error()})
}
return
}
@@ -558,11 +584,11 @@ func (m *manager) runEvents(env *environment) {
}
// Stream mode
if !env.ping(env.ctx) {
if err := env.ping(env.ctx); err != nil {
if env.online || !env.statusReported {
env.online = false
env.statusReported = true
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable"})
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable: " + err.Error()})
}
if !waitOrCancel(reconnectDelay) {
return
@@ -609,12 +635,32 @@ func (m *manager) runEvents(env *environment) {
// Force-close the body on context cancellation so scanner.Scan()
// unblocks. Without this, the goroutine can leak if the transport's
// internal cancel watcher doesn't fire (Go runtime implementation detail).
//
// The watchdog ticker handles half-open connections (e.g. after a
// VPN/tunnel drop): the stream client has no timeout, so Scan() would
// otherwise block forever on a dead connection that never errors.
// A failed ping (which retries on a fresh connection internally)
// means the host is unreachable — close the body so the reconnect
// loop takes over.
bodyDone := make(chan struct{})
var closeBodyOnce sync.Once
closeBody := func() { closeBodyOnce.Do(func() { resp.Body.Close() }) }
go func() {
select {
case <-env.ctx.Done():
resp.Body.Close()
case <-bodyDone:
watchdog := time.NewTicker(90 * time.Second)
defer watchdog.Stop()
for {
select {
case <-env.ctx.Done():
closeBody()
return
case <-bodyDone:
return
case <-watchdog.C:
if env.ping(env.ctx) != nil {
closeBody()
return
}
}
}
}()
@@ -638,7 +684,7 @@ func (m *manager) runEvents(env *environment) {
}
}
close(bodyDone)
resp.Body.Close()
closeBody()
if env.ctx.Err() != nil {
return
@@ -653,11 +699,11 @@ func (m *manager) runEvents(env *environment) {
}
func (m *manager) pollEvents(env *environment) {
if !env.ping(env.ctx) {
if err := env.ping(env.ctx); err != nil {
if env.online || !env.statusReported {
env.online = false
env.statusReported = true
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable"})
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable: " + err.Error()})
}
return
}
@@ -736,7 +782,7 @@ func (m *manager) runDiskChecks(env *environment) {
}
func (m *manager) checkDisk(env *environment) {
if !env.ping(env.ctx) {
if env.ping(env.ctx) != nil {
return
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

+1
View File
@@ -0,0 +1 @@
ALTER TABLE "git_stacks" ADD COLUMN "synced_files" text;
+12
View File
@@ -0,0 +1,12 @@
CREATE TABLE "template_sources" (
"id" serial PRIMARY KEY NOT NULL,
"source_id" text NOT NULL,
"name" text NOT NULL,
"url" text NOT NULL,
"enabled" boolean DEFAULT true,
"builtin" boolean DEFAULT false,
"sort_order" integer DEFAULT 0,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "template_sources_source_id_unique" UNIQUE("source_id")
);
+4 -4
View File
@@ -2352,14 +2352,14 @@
"primaryKey": false,
"notNull": false
},
"external_compose_path": {
"name": "external_compose_path",
"compose_path": {
"name": "compose_path",
"type": "text",
"primaryKey": false,
"notNull": false
},
"external_env_path": {
"name": "external_env_path",
"env_path": {
"name": "env_path",
"type": "text",
"primaryKey": false,
"notNull": false
+4 -4
View File
@@ -2373,14 +2373,14 @@
"primaryKey": false,
"notNull": false
},
"external_compose_path": {
"name": "external_compose_path",
"compose_path": {
"name": "compose_path",
"type": "text",
"primaryKey": false,
"notNull": false
},
"external_env_path": {
"name": "external_env_path",
"env_path": {
"name": "env_path",
"type": "text",
"primaryKey": false,
"notNull": false
+4 -4
View File
@@ -2373,14 +2373,14 @@
"primaryKey": false,
"notNull": false
},
"external_compose_path": {
"name": "external_compose_path",
"compose_path": {
"name": "compose_path",
"type": "text",
"primaryKey": false,
"notNull": false
},
"external_env_path": {
"name": "external_env_path",
"env_path": {
"name": "env_path",
"type": "text",
"primaryKey": false,
"notNull": false
+4 -4
View File
@@ -2386,14 +2386,14 @@
"primaryKey": false,
"notNull": false
},
"external_compose_path": {
"name": "external_compose_path",
"compose_path": {
"name": "compose_path",
"type": "text",
"primaryKey": false,
"notNull": false
},
"external_env_path": {
"name": "external_env_path",
"env_path": {
"name": "env_path",
"type": "text",
"primaryKey": false,
"notNull": false
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -50,6 +50,20 @@
"when": 1777220350655,
"tag": "0006_add_git_stack_context_dir",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1781158711008,
"tag": "0007_add_synced_files",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1781620381909,
"tag": "0008_add_template_sources",
"breakpoints": true
}
]
}
+1
View File
@@ -0,0 +1 @@
ALTER TABLE `git_stacks` ADD `synced_files` text;
+13
View File
@@ -0,0 +1,13 @@
CREATE TABLE `template_sources` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`source_id` text NOT NULL,
`name` text NOT NULL,
`url` text NOT NULL,
`enabled` integer DEFAULT true,
`builtin` integer DEFAULT false,
`sort_order` integer DEFAULT 0,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`updated_at` text DEFAULT CURRENT_TIMESTAMP
);
--> statement-breakpoint
CREATE UNIQUE INDEX `template_sources_source_id_unique` ON `template_sources` (`source_id`);
+2 -2
View File
@@ -940,7 +940,7 @@
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'compose.yaml'"
"default": "'docker-compose.yml'"
},
"environment_id": {
"name": "environment_id",
@@ -1099,7 +1099,7 @@
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'compose.yaml'"
"default": "'docker-compose.yml'"
},
"env_file_path": {
"name": "env_file_path",
+2 -2
View File
@@ -1051,7 +1051,7 @@
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'compose.yaml'"
"default": "'docker-compose.yml'"
},
"environment_id": {
"name": "environment_id",
@@ -1210,7 +1210,7 @@
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'compose.yaml'"
"default": "'docker-compose.yml'"
},
"env_file_path": {
"name": "env_file_path",
+2 -2
View File
@@ -1051,7 +1051,7 @@
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'compose.yaml'"
"default": "'docker-compose.yml'"
},
"environment_id": {
"name": "environment_id",
@@ -1210,7 +1210,7 @@
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'compose.yaml'"
"default": "'docker-compose.yml'"
},
"env_file_path": {
"name": "env_file_path",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -50,6 +50,20 @@
"when": 1777220350655,
"tag": "0006_add_git_stack_context_dir",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1781158702731,
"tag": "0007_add_synced_files",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1781620376161,
"tag": "0008_add_template_sources",
"breakpoints": true
}
]
}
+9 -8
View File
@@ -1,7 +1,7 @@
{
"name": "dockhand",
"private": true,
"version": "1.0.28",
"version": "1.0.34",
"type": "module",
"scripts": {
"dev": "npx vite dev",
@@ -63,8 +63,9 @@
"@codemirror/lang-python": "6.2.1",
"@codemirror/lang-sql": "6.10.0",
"@codemirror/lang-xml": "6.1.0",
"@codemirror/lang-yaml": "6.1.2",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.1",
"@codemirror/legacy-modes": "6.5.3",
"@codemirror/search": "6.6.0",
"@codemirror/state": "6.5.4",
"@codemirror/theme-one-dark": "6.1.3",
@@ -76,19 +77,19 @@
"better-sqlite3": "11.7.0",
"croner": "9.1.0",
"cronstrue": "3.9.0",
"devalue": "5.6.4",
"devalue": "5.8.1",
"drizzle-orm": "0.45.2",
"fast-xml-parser": "5.7.3",
"js-yaml": "4.1.1",
"ldapts": "8.1.3",
"nodemailer": "8.0.5",
"nodemailer": "8.0.9",
"otpauth": "9.4.1",
"postgres": "3.4.8",
"qrcode": "1.5.4",
"rollup": "4.60.0",
"svelte-sonner": "1.0.7",
"undici": "7.24.5",
"ws": "8.18.0"
"ws": "8.21.0"
},
"devDependencies": {
"@internationalized/date": "^3.10.1",
@@ -116,10 +117,10 @@
"d3-shape": "^3.2.0",
"drizzle-kit": "0.31.8",
"layerchart": "^1.0.13",
"lucide-svelte": "^0.562.0",
"lucide-svelte": "0.562.0",
"mode-watcher": "^1.1.0",
"postcss": "^8.5.6",
"svelte": "5.53.5",
"svelte": "5.55.7",
"svelte-check": "^4.3.5",
"svelte-easy-crop": "^5.0.0",
"tailwind-merge": "^3.4.0",
@@ -137,6 +138,6 @@
"@codemirror/search": "6.6.0",
"@lezer/common": "1.5.0",
"@lezer/highlight": "1.2.3",
"devalue": "5.6.4"
"devalue": "5.8.1"
}
}
+123 -10
View File
@@ -8,11 +8,12 @@
* Usage: node ./server.js
*/
import { createServer, request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import { createServer as createHttpServer, request as httpRequest } from 'node:http';
import { createServer as createHttpsServer, request as httpsRequest } from 'node:https';
import { createConnection } from 'node:net';
import { connect as tlsConnect, rootCertificates } from 'node:tls';
import { randomUUID } from 'node:crypto';
import { randomUUID, X509Certificate } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { WebSocketServer } from 'ws';
import { handler } from './build/handler.js';
@@ -28,10 +29,82 @@ console.warn = (...args) => _warn(ts(), ...args);
const PORT = parseInt(process.env.PORT || '3000', 10);
const HOST = process.env.HOST || '0.0.0.0';
// Create HTTP server with SvelteKit handler
const server = createServer((req, res) => {
handler(req, res);
});
// Optional native HTTPS listener (#1102). Off by default to keep existing
// deployments unchanged. When HTTPS_MODE=on, HTTPS_CERT_PATH and
// HTTPS_KEY_PATH must both point to readable PEM files.
const HTTPS_MODE = (process.env.HTTPS_MODE || 'off').toLowerCase();
const useHttps = HTTPS_MODE === 'on';
let server;
if (useHttps) {
const certPath = process.env.HTTPS_CERT_PATH;
const keyPath = process.env.HTTPS_KEY_PATH;
const caPath = process.env.HTTPS_CA_PATH;
console.log('[HTTPS] mode=on');
console.log(`[HTTPS] cert=${certPath || '(missing)'}`);
console.log(`[HTTPS] key=${keyPath || '(missing)'}`);
console.log(`[HTTPS] ca=${caPath || '(none)'}`);
if (!certPath || !keyPath) {
console.error('[HTTPS] HTTPS_MODE=on requires HTTPS_CERT_PATH and HTTPS_KEY_PATH');
process.exit(1);
}
let certPem, keyPem, caPem;
try {
certPem = readFileSync(certPath);
keyPem = readFileSync(keyPath);
if (caPath) caPem = readFileSync(caPath);
} catch (e) {
console.error(`[HTTPS] Failed to read cert/key file: ${e.message}`);
process.exit(1);
}
// Parse cert metadata so operators can confirm they mounted the right file.
try {
const x509 = new X509Certificate(certPem);
console.log(`[HTTPS] cert subject: ${x509.subject.replace(/\n/g, ', ')}`);
console.log(`[HTTPS] cert issuer: ${x509.issuer.replace(/\n/g, ', ')}`);
console.log(`[HTTPS] cert SAN: ${x509.subjectAltName || '(none)'}`);
console.log(`[HTTPS] cert valid: ${x509.validFrom}${x509.validTo}`);
const expiresAt = new Date(x509.validTo).getTime();
const daysLeft = Math.floor((expiresAt - Date.now()) / 86400000);
if (daysLeft < 0) {
console.warn(`[HTTPS] WARNING: certificate expired ${-daysLeft} day(s) ago`);
} else if (daysLeft < 30) {
console.warn(`[HTTPS] WARNING: certificate expires in ${daysLeft} day(s)`);
} else {
console.log(`[HTTPS] cert expires in ${daysLeft} day(s)`);
}
} catch (e) {
console.error(`[HTTPS] Failed to parse certificate: ${e.message}`);
process.exit(1);
}
const tlsOptions = { cert: certPem, key: keyPem };
if (caPem) tlsOptions.ca = caPem;
// HSTS — only meaningful over HTTPS, so wired only here. Default 1 year;
// set HSTS_MAX_AGE=0 to disable.
const hstsMaxAge = parseInt(process.env.HSTS_MAX_AGE ?? '31536000', 10);
const hstsHeader = hstsMaxAge > 0 ? `max-age=${hstsMaxAge}` : null;
if (hstsHeader) {
console.log(`[HTTPS] HSTS enabled: ${hstsHeader}`);
} else {
console.log('[HTTPS] HSTS disabled (HSTS_MAX_AGE=0)');
}
server = createHttpsServer(tlsOptions, (req, res) => {
if (hstsHeader) res.setHeader('Strict-Transport-Security', hstsHeader);
handler(req, res);
});
} else {
console.log(`[HTTPS] mode=off (set HTTPS_MODE=on to enable native TLS)`);
server = createHttpServer((req, res) => {
handler(req, res);
});
}
// Create WebSocket server attached to the HTTP server
const wss = new WebSocketServer({ noServer: true });
@@ -95,7 +168,7 @@ globalThis.__terminalHandleExecMessage = (msg) => {
};
// Handle WebSocket upgrade
server.on('upgrade', (req, socket, head) => {
server.on('upgrade', async (req, socket, head) => {
const url = new URL(req.url || '/', `http://${req.headers.host}`);
// Only handle our specific WebSocket paths
@@ -107,7 +180,30 @@ server.on('upgrade', (req, socket, head) => {
return;
}
let wsAuth = null;
if (isTerminal) {
try {
if (typeof globalThis.__authenticateWsUpgrade !== 'function') {
socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
wsAuth = await globalThis.__authenticateWsUpgrade(req.headers);
if (!wsAuth) {
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
} catch (err) {
console.error('[WS] auth error during upgrade:', err);
socket.write('HTTP/1.1 500 Internal Server Error\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
}
wss.handleUpgrade(req, socket, head, (ws) => {
if (wsAuth) ws.__auth = wsAuth;
wss.emit('connection', ws, req);
});
});
@@ -150,6 +246,22 @@ async function handleTerminalConnection(ws, url, connId) {
return;
}
if (ws.__auth && typeof globalThis.__canAccessEnvForUser === 'function') {
try {
const ok = await globalThis.__canAccessEnvForUser(ws.__auth, envId);
if (!ok) {
console.warn(`[WS] env access denied: user=${ws.__auth.username} envId=${envId}`);
ws.send(JSON.stringify({ type: 'error', message: 'Access denied for this environment' }));
ws.close(1008, 'env access denied');
return;
}
} catch (err) {
console.error('[WS] env access check failed:', err);
ws.close(1011, 'internal error');
return;
}
}
try {
// Resolve Docker target via SvelteKit app's database
let target;
@@ -191,7 +303,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)
@@ -458,7 +570,8 @@ function handleHawserConnection(ws, connId, remoteIp) {
// Start the server
server.listen(PORT, HOST, () => {
console.log(`Listening on http://${HOST}:${PORT}/ with WebSocket`);
const scheme = useHttps ? 'https' : 'http';
console.log(`Listening on ${scheme}://${HOST}:${PORT}/ with WebSocket`);
});
+65
View File
@@ -74,6 +74,33 @@ html {
max-width: calc(90px * var(--grid-font-size-scale, 1)) !important;
}
/* Scrollbar theming WebKit only (Sencho-style). No global * selector and
* no scrollbar-width override, so Firefox/native scrollbars render at OS
* default width. Dark-mode thumb bumped to be visible on dark surfaces. */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
/* Light mode: medium gray that holds up against white. Pale border-color
* at 50% was nearly invisible. */
background: hsl(0 0% 60% / 0.6);
border-radius: 4px;
transition: background 150ms ease;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(0 0% 40% / 0.8);
}
.dark ::-webkit-scrollbar-thumb {
background: hsl(0 0% 50% / 0.5);
}
.dark ::-webkit-scrollbar-thumb:hover {
background: hsl(0 0% 65% / 0.7);
}
:root {
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
@@ -1314,6 +1341,16 @@ html {
line-height: 14px;
}
/* Icon animation toggle (#1169): when html.no-icon-animation is set, the
common Tailwind animation utilities collapse to no-op. This keeps the
layout (spinners still occupy space) but removes the motion. */
html.no-icon-animation .animate-spin,
html.no-icon-animation .animate-pulse,
html.no-icon-animation .animate-bounce,
html.no-icon-animation .animate-ping {
animation: none !important;
}
/* Icon glow utilities - standard size (4px blur, 0.6 opacity) */
.glow-green { filter: drop-shadow(0 0 4px rgba(34, 197, 94, 0.6)); }
.glow-green-sm { filter: drop-shadow(0 0 3px rgba(34, 197, 94, 0.5)); }
@@ -1753,3 +1790,31 @@ html {
.ansi-dim { opacity: 0.7; }
.ansi-italic { font-style: italic; }
.ansi-underline { text-decoration: underline; }
/* Log line numbers */
.log-line {
min-height: 1.2em;
}
pre.show-line-numbers {
counter-reset: log-line;
}
pre.show-line-numbers .log-line {
counter-increment: log-line;
padding-left: 4.5em;
position: relative;
}
pre.show-line-numbers .log-line::before {
content: counter(log-line);
position: absolute;
left: 0;
width: 3.5em;
text-align: right;
padding-right: 0.75em;
user-select: none;
color: rgb(113 113 122); /* zinc-500 */
border-right: 1px solid rgb(63 63 70); /* zinc-700 */
}
:where(.light, .light *) pre.show-line-numbers .log-line::before {
color: rgb(156 163 175); /* gray-400 */
border-right-color: rgb(209 213 219); /* gray-300 */
}
+5 -4
View File
@@ -3,11 +3,12 @@
import type { AuthenticatedUser } from '$lib/server/auth';
// Build-time constants injected by Vite
declare const __BUILD_DATE__: string | null;
declare const __BUILD_COMMIT__: string | null;
declare global {
// Build-time constants injected by Vite
const __APP_VERSION__: string | null;
const __BUILD_DATE__: string | null;
const __BUILD_COMMIT__: string | null;
namespace App {
// interface Error {}
interface Locals {
+5 -10
View File
@@ -18,6 +18,11 @@ import { join } from 'path';
import type { HandleServerError, Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { startRssTracker, stopRssTracker, rssBeforeOp, rssAfterOp } from '$lib/server/rss-tracker';
import { getClientIp } from '$lib/server/client-ip';
// Side-effect import: installs globalThis.__authenticateWsUpgrade and
// globalThis.__canAccessEnvForUser used by the raw WS upgrade handlers in
// server.js / vite.config.ts to authenticate /api/containers/*/exec.
import '$lib/server/ws-auth';
// Content types worth compressing
const COMPRESSIBLE_TYPES = [
@@ -218,16 +223,6 @@ setInterval(() => {
}
}, 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);
@@ -0,0 +1,37 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { themeStore } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth';
import { toast } from 'svelte-sonner';
interface Props {
userId?: number; // omit for global default (login page / auth-disabled)
}
let { userId }: Props = $props();
// Same "skip applying" rule as ThemeSelector: don't toggle the live document
// when the admin is editing the global default while logged in (their own
// per-user preference still drives their session).
const skipApply = $derived($authStore.loading ? true : ($authStore.authEnabled && !userId));
let checked = $state(true);
$effect(() => {
checked = $themeStore.animateIcons;
});
function onToggle(value: boolean) {
checked = value;
themeStore.setPreference('animateIcons', value, userId, skipApply);
toast.success(value ? 'Icon animation enabled' : 'Icon animation disabled');
}
</script>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Animate icons</Label>
<TogglePill {checked} onchange={onToggle} />
</div>
<p class="text-xs text-muted-foreground">Spinners during pulls, scans and updates.</p>
</div>
@@ -4,14 +4,7 @@
import { Progress } from '$lib/components/ui/progress';
import { Check, X, Loader2, Circle, Ban } from 'lucide-svelte';
import { onDestroy } from 'svelte';
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
import { formatBytes } from '$lib/utils/format';
const progressText: Record<string, string> = {
remove: 'removing',
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import { GitPullRequestArrow } from 'lucide-svelte';
import { parseChangelogTokens, tokenHref, type ChangelogToken } from '$lib/utils/changelog-tokens';
let { text }: { text: string } = $props();
type Group = { kind: 'text'; value: string } | { kind: 'refs'; refs: ChangelogToken[] };
const groups = $derived.by<Group[]>(() => {
const tokens = parseChangelogTokens(text);
const result: Group[] = [];
let textBuf = '';
let refBuf: ChangelogToken[] = [];
const flushText = () => {
if (textBuf) {
result.push({ kind: 'text', value: textBuf });
textBuf = '';
}
};
const flushRefs = () => {
if (refBuf.length) {
result.push({ kind: 'refs', refs: refBuf });
refBuf = [];
}
};
for (const t of tokens) {
if (t.kind === 'text') {
// If the gap between consecutive ref groups is only "glue" (whitespace,
// commas, parens), keep collecting into the same refs group. Otherwise
// it ends the group.
if (refBuf.length && /^[\s,()]*$/.test(t.value)) {
continue;
}
if (refBuf.length) {
flushRefs();
}
// Strip a trailing " (" left over before the upcoming refs group.
textBuf += t.value;
} else {
// Trim trailing glue from textBuf so we don't render "foo (".
if (refBuf.length === 0) {
textBuf = textBuf.replace(/[\s(]+$/, '');
}
flushText();
refBuf.push(t);
}
}
flushRefs();
// Trim trailing glue (e.g. ")") from leftover text.
textBuf = textBuf.replace(/^[\s,)]+/, '');
flushText();
return result;
});
function refLabel(token: ChangelogToken): string {
if (token.kind === 'issue') return `#${token.num}`;
if (token.kind === 'pr') return `#${token.num}`;
if (token.kind === 'user') return `@${token.name}`;
return '';
}
function refTitle(token: ChangelogToken): string {
if (token.kind === 'issue') return `Issue #${token.num}`;
if (token.kind === 'pr') return `Pull request #${token.num}`;
if (token.kind === 'user') return `@${token.name} on GitHub`;
return '';
}
</script>
<span class="text-sm">
{#each groups as group, i (i)}
{#if group.kind === 'text'}
{group.value}
{:else}
<span class="changelog-refs">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
{#each group.refs as ref, j (j)}
{#if j > 0}<span class="changelog-refs-sep"> · </span>{/if}
<a
href={tokenHref(ref)}
target="_blank"
rel="noopener noreferrer"
title={refTitle(ref)}
class="changelog-refs-link"
>{#if ref.kind === 'pr'}<GitPullRequestArrow class="changelog-pr-icon" />{/if}{refLabel(ref)}</a>
{/each}
</span>
{/if}
{/each}
</span>
<style>
.changelog-refs {
display: inline;
opacity: 0.55;
margin-left: 4px;
font-size: 0.75em;
}
.changelog-refs svg {
display: inline;
width: 10px;
height: 10px;
vertical-align: -1px;
margin-right: 3px;
}
.changelog-refs-link {
color: inherit;
text-decoration: none;
}
.changelog-refs-link:hover {
text-decoration: underline;
}
.changelog-refs-sep {
color: inherit;
}
.changelog-refs-link :global(.changelog-pr-icon) {
display: inline;
width: 10px;
height: 10px;
vertical-align: -1px;
margin-right: 2px;
}
</style>
+35 -8
View File
@@ -1,14 +1,18 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { EditorState, StateField, StateEffect, RangeSet } from '@codemirror/state';
import { EditorState, StateField, StateEffect, RangeSet, Prec } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, gutter, GutterMarker, Decoration, WidgetType, type DecorationSet } from '@codemirror/view';
// Note: Secret masking was removed - secrets are now excluded from the raw editor entirely
// and are only stored in the database (never written to .env file)
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, StreamLanguage, type StreamParser } from '@codemirror/language';
import { defaultKeymap, history, historyKeymap, indentWithTab, insertNewlineAndIndent } from '@codemirror/commands';
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, indentUnit, StreamLanguage, type StreamParser } from '@codemirror/language';
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
import { toml } from '@codemirror/legacy-modes/mode/toml';
import { properties } from '@codemirror/legacy-modes/mode/properties';
// Simple dotenv/env file language parser
const dotenvParser: StreamParser<{ inValue: boolean }> = {
@@ -405,7 +409,7 @@
new RegExp(`(?<!\\$)\\$\\{${marker.name}\\?`),
new RegExp(`(?<!\\$)\\$\\{${marker.name}:\\+`),
new RegExp(`(?<!\\$)\\$\\{${marker.name}\\+`),
new RegExp(`(?<!\\$)\\$${marker.name}(?![a-zA-Z0-9_])`)
new RegExp(`(?<![A-Za-z0-9\\$])\\$${marker.name}(?![a-zA-Z0-9_])`)
];
const hasVariable = varPatterns.some(p => p.test(line));
@@ -496,6 +500,21 @@
initialSpacer: () => new VariableGutterMarker('required')
});
// YAML Enter handler: after a key-only line ending with ":", indent one level
// deeper than what the default indent service returns (it can't predict child
// indent when no child content exists yet).
function yamlNewlineAndIndent(view: EditorView): boolean {
const { state } = view;
const line = state.doc.lineAt(state.selection.main.head);
const withoutComment = line.text.trimEnd().replace(/#.*$/, '').trimEnd();
if (!withoutComment.endsWith(':')) return false;
insertNewlineAndIndent(view);
const unit = state.facet(indentUnit);
const cursor = view.state.selection.main.head;
view.dispatch({ changes: { from: cursor, insert: unit }, selection: { anchor: cursor + unit.length } });
return true;
}
// Get language extension based on language name
function getLanguageExtension(lang: string) {
switch (lang) {
@@ -527,12 +546,18 @@
return xml();
case 'sql':
return sql();
case 'dockerfile':
case 'shell':
case 'bash':
case 'sh':
// No dedicated shell/dockerfile support, use basic highlighting
return [];
return StreamLanguage.define(shell);
case 'dockerfile':
return StreamLanguage.define(dockerFile);
case 'toml':
return StreamLanguage.define(toml);
case 'ini':
case 'conf':
case 'properties':
return StreamLanguage.define(properties);
case 'dotenv':
case 'env':
return StreamLanguage.define(dotenvParser);
@@ -671,7 +696,9 @@
]),
...themeExtensions,
EditorView.lineWrapping,
getLanguageExtension(language)
EditorState.tabSize.of(2),
getLanguageExtension(language),
...(language === 'yaml' ? [Prec.high(keymap.of([{ key: 'Enter', run: yamlNewlineAndIndent }]))] : [])
].flat();
if (readonly) {
+3 -8
View File
@@ -9,6 +9,7 @@
import { onMount } from 'svelte';
import { appendEnvParam } from '$lib/stores/environment';
import { watchJob } from '$lib/utils/sse-fetch';
import { formatBytes } from '$lib/utils/format';
interface LayerProgress {
id: string;
@@ -98,12 +99,6 @@
localStorage.setItem('logTheme', logDarkMode ? 'dark' : 'light');
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
@@ -314,7 +309,7 @@
class="h-10"
>
{#if isPulling}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
<Download class="w-4 h-4 mr-2 animate-spin" />
Pulling...
{:else}
<Download class="w-4 h-4" />
@@ -332,7 +327,7 @@
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
{#if status === 'pulling'}
<Loader2 class="w-4 h-4 animate-spin text-blue-600" />
<Download class="w-4 h-4 animate-spin text-blue-600" />
<span class="text-sm">Pulling layers...</span>
{:else if status === 'complete'}
<CheckCircle2 class="w-4 h-4 text-green-600" />
+4 -2
View File
@@ -38,6 +38,7 @@
imageName: string;
envId?: number | null;
autoStart?: boolean;
activeScanner?: 'grype' | 'trivy';
onComplete?: (results: ScanResult[]) => void;
onError?: (error: string) => void;
onStatusChange?: (status: ScanStatus) => void;
@@ -47,6 +48,7 @@
imageName,
envId = null,
autoStart = false,
activeScanner = $bindable<'grype' | 'trivy'>('grype'),
onComplete,
onError,
onStatusChange
@@ -226,7 +228,7 @@
<Shield class="w-4 h-4 text-muted-foreground" />
<span class="text-sm text-muted-foreground">Ready to scan</span>
{:else if status === 'scanning'}
<Loader2 class="w-4 h-4 animate-spin text-blue-600" />
<Shield class="w-4 h-4 animate-spin text-blue-600" />
<span class="text-sm">Scanning for vulnerabilities...</span>
{:else if status === 'complete'}
{#if hasCriticalOrHigh}
@@ -362,7 +364,7 @@
{:else}
<!-- Scan Results -->
<div class="flex-1 min-h-0 overflow-auto">
<ScanResultsView {results} />
<ScanResultsView {results} bind:activeScanner />
</div>
{/if}
</div>
+1 -6
View File
@@ -114,12 +114,7 @@
}
const key = trimmed.slice(0, eqIndex).trim();
let value = trimmed.slice(eqIndex + 1);
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
const value = trimmed.slice(eqIndex + 1);
if (key) {
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
+2 -1
View File
@@ -3,6 +3,7 @@
import { Button } from '$lib/components/ui/button';
import { Sparkles, Bug, Zap, CheckCircle, ScrollText } from 'lucide-svelte';
import { compareVersions } from '$lib/utils/version';
import ChangelogText from '$lib/components/ChangelogText.svelte';
interface ChangelogEntry {
version: string;
@@ -66,7 +67,7 @@
{@const { icon: Icon, class: iconClass } = getChangeIcon(change.type)}
<div class="flex items-start gap-2">
<Icon class="w-4 h-4 mt-0.5 shrink-0 {iconClass}" />
<span class="text-sm">{change.text}</span>
<ChangelogText text={change.text} />
</div>
{/each}
</div>
+26 -1
View File
@@ -22,11 +22,16 @@
User,
ClipboardList,
Activity,
Timer
Timer,
LibraryBig
} from 'lucide-svelte';
import { licenseStore } from '$lib/stores/license';
import { authStore, hasAnyAccess } from '$lib/stores/auth';
import * as Avatar from '$lib/components/ui/avatar';
import * as Tooltip from '$lib/components/ui/tooltip';
const appVersion = __APP_VERSION__ || 'unknown';
const buildCommit = __BUILD_COMMIT__ ?? null;
import type { Permissions } from '$lib/stores/auth';
@@ -97,6 +102,7 @@
{ href: '/images', Icon: Images, label: 'Images', permission: 'images' },
{ href: '/volumes', Icon: HardDrive, label: 'Volumes', permission: 'volumes' },
{ href: '/networks', Icon: Network, label: 'Networks', permission: 'networks' },
{ href: '/templates', Icon: LibraryBig, label: 'Templates', permission: 'templates' },
{ href: '/registry', Icon: Download, label: 'Registry', permission: 'registries' },
{ href: '/activity', Icon: Activity, label: 'Activity', permission: 'activity' },
{ href: '/schedules', Icon: Timer, label: 'Schedules', permission: 'schedules' },
@@ -155,6 +161,25 @@
</Sidebar.Group>
</Sidebar.Content>
<!-- Version (expanded sidebar only) -->
<div class="group-data-[state=collapsed]:hidden px-3 py-2 mt-auto text-center">
<Tooltip.Root>
<Tooltip.Trigger>
<span class="text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-default">
{appVersion}
</span>
</Tooltip.Trigger>
<Tooltip.Content side="top" align="start" sideOffset={8} class="text-xs">
<div class="space-y-0.5">
<div class="flex items-center gap-1.5"><svg class="w-4 h-4 shrink-0" viewBox="0 0 24 18" fill="currentColor"><path d="M23.76 8.68c-.26-.18-.86-.58-1.53-.58-.24 0-.48.04-.72.12-.12-.84-.68-1.56-1.34-2.14l-.28-.22-.24.26c-.28.34-.48.72-.56 1.14-.1.42-.06.82.1 1.2-.42.22-.88.36-1.32.42-.24.04-.48.06-.72.06H.78a.77.77 0 0 0-.78.78c-.02 1.46.22 2.9.72 4.24.56 1.44 1.4 2.5 2.5 3.16 1.26.74 3.32 1.16 5.64 1.16.98 0 2-.1 2.98-.3a11.5 11.5 0 0 0 3.3-1.3 9.67 9.67 0 0 0 2.54-2.34c1.16-1.42 1.86-3.02 2.34-4.38h.2c1.22 0 1.98-.48 2.4-.9.28-.26.5-.58.64-.94l.08-.24-.28-.2zM2.74 8.84H4.7c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H2.74c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.72 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H5.46c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H8.22c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zM5.46 6.2h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18H5.46c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18H8.22c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm0-2.64h1.96c.1 0 .18-.08.18-.18V1.74c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 5.28h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18z"/></svg><span class="font-mono">fnsys/dockhand:{appVersion}</span></div>
{#if buildCommit}
<div>Commit: <span class="font-mono">{buildCommit.slice(0, 7)}</span></div>
{/if}
</div>
</Tooltip.Content>
</Tooltip.Root>
</div>
<!-- User info footer (only when auth is enabled) -->
{#if $authStore.authEnabled && $authStore.authenticated && $authStore.user}
<Sidebar.Footer class="border-t">
+28 -9
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Cpu, MemoryStick, Box, Globe, ChevronDown, Check, HardDrive, Clock, Wifi, WifiOff, Route, UndoDot, Icon, AlertCircle, Loader2, Search, X } from 'lucide-svelte';
import { Cpu, MemoryStick, Box, Globe, ChevronDown, Check, HardDrive, Clock, Wifi, WifiOff, Route, UndoDot, Icon, AlertCircle, Loader2, Search, Server, X } from 'lucide-svelte';
import { whale } from '@lucide/lab';
import { Button } from '$lib/components/ui/button';
import { currentEnvironment, environments, type Environment } from '$lib/stores/environment';
@@ -9,6 +9,7 @@
import { toast } from 'svelte-sonner';
import { themeStore, type FontSize } from '$lib/stores/theme';
import { getTimeFormat } from '$lib/stores/settings';
import { formatBytes } from '$lib/utils/format';
// Font size scaling for header
let fontSize = $state<FontSize>('normal');
@@ -94,6 +95,22 @@
}
}
// Display string for the env hostname / IP in the header (#962).
// Show both when available; drop only the field that is unknown/empty.
// Hide the whole block when neither is meaningful (e.g. hawser-edge
// reports 'unknown' for both).
const hostLabel = $derived.by(() => {
if (!hostInfo) return '';
const isMeaningful = (v: string | undefined) => {
const t = (v || '').trim();
return t && t.toLowerCase() !== 'unknown';
};
const h = isMeaningful(hostInfo.hostname) ? hostInfo.hostname.trim() : '';
const ip = isMeaningful(hostInfo.ipAddress) ? hostInfo.ipAddress.trim() : '';
if (h && ip && h !== ip) return `${h} (${ip})`;
return h || ip;
});
// Reactive environment list from store
let envList = $derived($environments);
const showSearch = $derived(envList.length > 8);
@@ -218,14 +235,6 @@
(diskUsage.Volumes?.reduce((sum: number, v: any) => sum + (v.UsageData?.Size || 0), 0) || 0);
});
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
async function switchEnvironment(envId: number) {
// Don't switch if already on this environment
if (Number(envId) === Number(currentEnvId)) {
@@ -456,6 +465,16 @@
{#if hostInfo}
<span class="text-border">|</span>
<!-- Hostname / IP (#962) — first info segment after the env dropdown.
Hidden on narrow viewports to keep the strip readable. -->
{#if hostLabel}
<div class="hidden xl:flex items-center gap-1" title="Daemon hostname / IP">
<Server class="{iconSizeClass()}" />
<span>{hostLabel}</span>
</div>
<span class="hidden xl:inline text-border">|</span>
{/if}
<!-- Platform/OS -->
<span class="hidden md:inline">{hostInfo.platform} {hostInfo.arch}</span>
+1
View File
@@ -76,6 +76,7 @@ export const volumeColumns: ColumnConfig[] = [
{ id: 'select', label: '', fixed: 'start', width: 32, resizable: false },
{ id: 'name', label: 'Name', sortable: true, sortField: 'name', width: 400, minWidth: 150, grow: true },
{ id: 'driver', label: 'Driver', sortable: true, sortField: 'driver', width: 80, minWidth: 60 },
{ id: 'type', label: 'Type', sortable: true, sortField: 'type', width: 80, minWidth: 60 },
{ id: 'scope', label: 'Scope', width: 70, minWidth: 50 },
{ id: 'stack', label: 'Stack', sortable: true, sortField: 'stack', width: 120, minWidth: 80 },
{ id: 'usedBy', label: 'Used by', width: 150, minWidth: 80 },
+120
View File
@@ -1,4 +1,124 @@
[
{
"version": "1.0.34",
"date": "2026-06-17",
"changes": [
{ "type": "feature", "text": "raw file download — no tar wrapping (#1180)" },
{ "type": "fix", "text": "update modal stuck after closing mid-pull (#1094)" },
{ "type": "fix", "text": "vulnerability scans on Podman hosts (direct TCP and Hawser) (#1076)" },
{ "type": "fix", "text": "crash-looping containers now appear in the logs page list (#227)" },
{ "type": "feature", "text": "filter containers by \"Update available\" (#1063)" },
{ "type": "feature", "text": "show hostname / IP of the selected environment in the top header (#962)" },
{ "type": "feature", "text": "internal auth and validation hardening and dependency bumps" },
{ "type": "feature", "text": "Traefik and Pangolin integration — surface proxy URLs on container and stack panels (#2)" },
{ "type": "feature", "text": "release-notes link next to images with updates available (#538)" },
{ "type": "feature", "text": "lifecycle action buttons in the container details modal (#461)" },
{ "type": "feature", "text": "template library — browse and deploy compose templates from configurable sources (#48)" },
{ "type": "fix", "text": "file browser fails on containers with ls in /usr/sbin (#1185)" }
],
"imageTag": "fnsys/dockhand:v1.0.34"
},
{
"version": "1.0.33",
"date": "2026-06-15",
"changes": [
{ "type": "feature", "text": "in-place container property updates without restart — restart policy, CPU/memory limits (#1153)" },
{ "type": "feature", "text": "clickable stack badge in container and volume inspect modals (#1121)" },
{ "type": "feature", "text": "clickable stack badge in volumes list row (#1122)" },
{ "type": "feature", "text": "volumes list shows driver_opts type (NFS, CIFS, etc.) with sort and filter (#1123)" },
{ "type": "feature", "text": "Bark iOS notifications (#1095, PR#1097, @undirectlookable)" },
{ "type": "feature", "text": "Signal notifications via signal-cli-rest-api (#1099)" },
{ "type": "feature", "text": "Apprise passthrough — forward to a self-hosted caronc/apprise-api server (#1099)" },
{ "type": "fix", "text": "env editor flagged Docker/Compose built-ins as MISSING (#141)" },
{ "type": "fix", "text": "YAML editor indentation was inconsistent when pressing Enter (#1156)" },
{ "type": "feature", "text": "`dockhand.update=false`, `dockhand.hidden=true` and `localhost/*` images skip registry polling (#1083)" },
{ "type": "fix", "text": "registry authentication for image pulls (#1105)" },
{ "type": "feature", "text": "native HTTPS listener, off by default (#1102)" },
{ "type": "fix", "text": "environments stuck \"Failed\" after VPN/Tailscale tunnel drops until agent restart (#1160)" },
{ "type": "fix", "text": "health_status events flooding container_events table (#1165)" },
{ "type": "fix", "text": "git stack sync removes files deleted from the repo (hash-verified) (#966, #1162)" },
{ "type": "feature", "text": "upload TLS/mTLS certificate files in environment editor (#125)" },
{ "type": "feature", "text": "syntax highlighting for shell, Dockerfile, TOML, INI/conf and .env files in the file browser viewer (#1055)" },
{ "type": "feature", "text": "Animated icons now configurable (#1169)" },
{ "type": "fix", "text": "stack deploys ignored the env's configured socket path (#1172)" },
{ "type": "fix", "text": "environment names with characters that break path resolution (e.g. `*`) are now rejected (#1179)" }
],
"imageTag": "fnsys/dockhand:v1.0.33"
},
{
"version": "1.0.32",
"date": "2026-06-06",
"changes": [
{ "type": "feature", "text": "container details tweaks: process count, label filter, copy all labels (#812)" },
{ "type": "feature", "text": "log improvements (#1130)" },
{ "type": "fix", "text": "cleared Resources fields not persisted on container edit (#1119)" },
{ "type": "fix", "text": "long container names overflowed in activity event details dialog (#1129)" },
{ "type": "fix", "text": "git stack recreate and start operations ignored Dockhand-stored env vars (#1132)" },
{ "type": "fix", "text": "dashboard stopped count reset to 0 after refresh for gracefully stopped containers (#1133)" },
{ "type": "fix", "text": "auto-update preserves runtime `-e` env and `-l` label overrides (#1135)" },
{ "type": "fix", "text": "git stack volume binds resolved to wrong host path when compose was in a subdirectory (#1139)" },
{ "type": "fix", "text": "git stacks: subdir compose files now find their adjacent env files (#1136)" },
{ "type": "feature", "text": "env editor doesn't flag Docker/Compose built-in variables as unused (#141)" },
{ "type": "feature", "text": "container network mode: share another container's network namespace (#161)" }
],
"imageTag": "fnsys/dockhand:v1.0.32"
},
{
"version": "1.0.31",
"date": "2026-05-30",
"changes": [
{ "type": "fix", "text": "502 Bad Gateway behind nginx-based reverse proxies — SvelteKit 2.51+ bloated the Link response header, pinned to 2.50.0 (#1114)" }
],
"imageTag": "fnsys/dockhand:v1.0.31"
},
{
"version": "1.0.30",
"date": "2026-05-30",
"changes": [
{ "type": "feature", "text": "time range filter for log viewer — filter logs by From/To date and time (#1068)" },
{ "type": "feature", "text": "configurable tail line count in log viewer — choose from 100 to all lines (#1066)" },
{ "type": "feature", "text": "toggleable line numbers in log viewer (#1067)" },
{ "type": "feature", "text": "\"some unused\" image filter — show images with both used and unused tags for selective cleanup (#621)" },
{ "type": "feature", "text": "IP binding and port ranges in container port mappings (#581)" },
{ "type": "feature", "text": "remove individual containers directly from stacks page (#576)" },
{ "type": "fix", "text": "scan cache lookup by tag name never matched — results now resolved via image digest (#1064)" },
{ "type": "fix", "text": "image-baked env vars not updated during auto-update container recreation (#1061)" },
{ "type": "fix", "text": "git stack deploy via Hawser fails with \"Invalid string length\" when repo has large files (#1040)" },
{ "type": "feature", "text": "Gotify notification priority via URL query param — gotify://host/token?priority=5 (#1033)" },
{ "type": "fix", "text": "consistent action button order across container and stack views (#1079)" },
{ "type": "feature", "text": "named custom URL labels — dockhand.url=[Name](https://...) markdown syntax (#1065)" },
{ "type": "fix", "text": "HTTPS git credentials no longer leaked in process arguments (#1081)" },
{ "type": "feature", "text": "bump Docker Compose to 5.1.4 (GHSA-pmwq-pjrm-6p5r)" },
{ "type": "feature", "text": "dockhand.order label to control container display order within stacks (#847)" },
{ "type": "feature", "text": "live network attach/detach for running containers — join or leave Docker networks without restarting (#1051)" },
{ "type": "fix", "text": "environment variable values with nested quotes progressively corrupted on each save (#1036, #1086)" }
],
"imageTag": "fnsys/dockhand:v1.0.30"
},
{
"version": "1.0.29",
"date": "2026-05-17",
"changes": [
{ "type": "feature", "text": "optionally display internal (exposed) container ports alongside published ports (#193)" },
{ "type": "feature", "text": "show app version in sidebar with build info tooltip (#209)" },
{ "type": "feature", "text": "central label management — rename or delete labels across all environments (#661)" },
{ "type": "feature", "text": "find next available host port when creating or editing containers (#116)" },
{ "type": "feature", "text": "theme-aware scrollbar styling — scrollbars adapt to dark/light mode and color palettes (#462)" },
{ "type": "fix", "text": "update buttons (single, selected, and all) now respect the \"confirm dangerous actions\" setting (#638, #751)" },
{ "type": "feature", "text": "custom URL labels - dockhand.url or dockhand.port.{port}.url to add links alongside container ports (#266)" },
{ "type": "feature", "text": "generate and copy token for Hawser Standard mode with run command hint (#337)" },
{ "type": "fix", "text": "environment stack directory not cleaned up when environment is deleted (#1023)" },
{ "type": "feature", "text": "toggle to hide timestamps and container name prefix in log viewer (#124)" },
{ "type": "fix", "text": "Podman containers health status not showing (#737)" },
{ "type": "fix", "text": "containers with exit code 0 (init/migration) no longer cause stack \"partial\" status (#1026)" },
{ "type": "fix", "text": "stats stream 400 on reconnect by skipping overlapping fetches (#1044)" },
{ "type": "fix", "text": "env var validation false positive for values containing $ followed by text (#1048)" },
{ "type": "fix", "text": "git-repos directory not cleaned up when environment is deleted (#1049)" },
{ "type": "fix", "text": "webhook secret auto-generated when left empty despite hint saying otherwise (#1050)" },
{ "type": "feature", "text": "scan reports — combined or individual Grype/Trivy (#1056)" }
],
"imageTag": "fnsys/dockhand:v1.0.29"
},
{
"version": "1.0.28",
"date": "2026-05-09",
+31 -4
View File
@@ -137,6 +137,14 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
}
}
let dummyAuthHashCache: Promise<string> | null = null;
export function getDummyAuthHash(): Promise<string> {
if (!dummyAuthHashCache) {
dummyAuthHashCache = hashPassword(`dummy-${Math.random()}-${Date.now()}`);
}
return dummyAuthHashCache;
}
// ============================================
// Session Management
// ============================================
@@ -241,11 +249,22 @@ function getSessionIdFromCookies(cookies: Cookies): string | null {
export async function validateSession(cookies: Cookies): Promise<AuthenticatedUser | null> {
const sessionId = getSessionIdFromCookies(cookies);
if (!sessionId) return null;
return validateSessionById(sessionId);
}
/**
* Validate a session by raw session ID (without the SvelteKit Cookies object).
*
* Used by WebSocket upgrade handlers in server.js / vite.config.ts that only
* have a raw Cookie header string. Mirrors validateSession() semantics:
* returns the AuthenticatedUser on success, null on missing/expired/disabled.
*/
export async function validateSessionById(sessionId: string): Promise<AuthenticatedUser | null> {
if (!sessionId) return null;
const session = await dbGetSession(sessionId);
if (!session) return null;
// Check if session is expired
const expiresAt = new Date(session.expiresAt);
if (expiresAt < new Date()) {
await dbDeleteSession(sessionId);
@@ -258,6 +277,13 @@ export async function validateSession(cookies: Cookies): Promise<AuthenticatedUs
return await buildAuthenticatedUser(user, session.provider as 'local' | 'ldap' | 'oidc');
}
/**
* Cookie name used for browser session auth. Exported so raw header parsers
* (WebSocket upgrade handlers) can look it up without re-encoding the
* constant.
*/
export const SESSION_COOKIE = SESSION_COOKIE_NAME;
/**
* Destroy a session (logout)
*/
@@ -461,13 +487,14 @@ export async function authenticateLocal(
const user = await getUserByUsername(username);
if (!user) {
// Use constant time to prevent timing attacks
await hashPassword('dummy');
await verifyPassword(password, await getDummyAuthHash());
return { success: false, error: 'Invalid username or password' };
}
if (!user.isActive) {
return { success: false, error: 'Account is disabled' };
await verifyPassword(password, await getDummyAuthHash());
console.warn(`[Auth] Login attempt for disabled account: user=${username}`);
return { success: false, error: 'Invalid username or password' };
}
const validPassword = await verifyPassword(password, user.passwordHash);
+41
View File
@@ -0,0 +1,41 @@
/**
* Resolve the client IP for rate limiting, logging, and audit.
*
* Defaults to the socket-level IP via getClientAddress(). X-Forwarded-For
* is consulted only when TRUST_FORWARDED_HEADERS=true is set explicitly
* intended for deployments behind a reverse proxy (Traefik, nginx, Caddy)
* that controls XFF. In that mode the right-most XFF entry (closest to the
* trusted proxy) is returned; earlier entries in the chain are ignored.
*/
type IpEventLike = {
request: Request;
getClientAddress?: () => string;
};
function normalize(ip: string | null | undefined): string {
if (!ip) return 'unknown';
if (ip === '::1' || ip === '::ffff:127.0.0.1') return '127.0.0.1';
if (ip.startsWith('::ffff:')) return ip.substring(7);
return ip;
}
export function getClientIp(event: IpEventLike): string {
if (process.env.TRUST_FORWARDED_HEADERS === 'true') {
const xff = event.request.headers.get('x-forwarded-for');
if (xff) {
const parts = xff.split(',').map((p) => p.trim()).filter(Boolean);
if (parts.length > 0) return normalize(parts[parts.length - 1]);
}
const realIp = event.request.headers.get('x-real-ip');
if (realIp) return normalize(realIp.trim());
}
try {
const addr = event.getClientAddress?.();
if (addr) return normalize(addr);
} catch {
// getClientAddress may throw if unavailable (test contexts, raw upgrades)
}
return 'unknown';
}
@@ -0,0 +1,72 @@
/**
* Helpers for surfacing env/label divergence between a running
* container and its image. Pure read-only never used to mutate
* the container; used only to power UI hints.
*
* Background: as of #1135 / commit 0f989bd7 revert, Dockhand no
* longer "merges" image-baked env or labels into a container during
* auto-update. The container's Config.Env and Config.Labels are
* preserved verbatim (so a user's runtime `-e` / `-l` override is
* never silently wiped). The trade-off, originally raised by #1061,
* is that an image's updated default env/label values do not
* automatically propagate to running containers.
*
* These helpers let the UI surface "this container's value differs
* from the image's current value" so users can decide whether to
* Remove & Deploy. We do NOT try to classify "user-set vs
* image-baked" that information isn't recoverable from Docker.
*/
/** Parse a Docker env list (`KEY=value` strings) into a Map. */
function parseEnv(entries: string[]): Map<string, string> {
const m = new Map<string, string>();
for (const e of entries) {
const i = e.indexOf('=');
if (i === -1) {
m.set(e, '');
} else {
m.set(e.slice(0, i), e.slice(i + 1));
}
}
return m;
}
/**
* Keys where the container's env value differs from the image's
* CURRENT env value. Keys present in only one side are excluded
* they're either user-only or image-only, neither of which is
* "divergence" we can usefully act on.
*/
export function detectImageEnvDivergence(
containerEnv: string[],
imageEnv: string[]
): string[] {
const cont = parseEnv(containerEnv);
const img = parseEnv(imageEnv);
const diff: string[] = [];
for (const [k, v] of cont) {
if (img.has(k) && img.get(k) !== v) {
diff.push(k);
}
}
return diff;
}
/**
* Keys where the container's label value differs from the image's
* CURRENT label value. Same semantics as detectImageEnvDivergence.
*/
export function detectImageLabelDivergence(
containerLabels: Record<string, string> | null | undefined,
imageLabels: Record<string, string> | null | undefined
): string[] {
const cont = containerLabels || {};
const img = imageLabels || {};
const diff: string[] = [];
for (const [k, v] of Object.entries(cont)) {
if (k in img && img[k] !== v) {
diff.push(k);
}
}
return diff;
}
+27
View File
@@ -5,6 +5,9 @@
* - dockhand.update=false Skip this container during auto-updates and batch updates
* - dockhand.hidden=true Hide this container from the Dockhand UI
* - dockhand.notify=false Suppress notifications for this container's events
* - dockhand.url=<url> Custom clickable URL displayed alongside container ports
* - dockhand.port.<hostPort>.url=<url> Override the click URL for a specific published port
* - dockhand.order=<int> Controls display order within a stack (lower = first, default 0)
*
* All label values are case-insensitive and accept: true/yes/1 and false/no/0.
* The opt-out model means labels override DB settings (label wins).
@@ -15,6 +18,8 @@ export const DOCKHAND_LABELS = {
UPDATE: 'dockhand.update',
HIDDEN: 'dockhand.hidden',
NOTIFY: 'dockhand.notify',
URL: 'dockhand.url',
ORDER: 'dockhand.order',
} as const;
const TRUTHY_VALUES = new Set(['true', 'yes', '1']);
@@ -72,6 +77,26 @@ export function isNotifyDisabledByLabel(labels: Record<string, string> | undefin
return value === false; // explicitly disabled
}
/**
* Get the custom URL from dockhand.url label.
* Returns the URL string if set, or undefined.
*/
export function getCustomUrl(labels: Record<string, string> | undefined | null): string | undefined {
const value = getLabel(labels, DOCKHAND_LABELS.URL);
return value?.trim() || undefined;
}
/**
* Get the sort order value from dockhand.order label.
* Returns the parsed integer, or 0 for missing/invalid values.
*/
export function getOrderValue(labels: Record<string, string> | undefined | null): number {
const value = getLabel(labels, DOCKHAND_LABELS.ORDER);
if (value == null) return 0;
const parsed = parseInt(value.trim(), 10);
return Number.isNaN(parsed) ? 0 : parsed;
}
/**
* Extract all Dockhand label states from a container's labels.
* Useful for including in API responses so the frontend knows about label overrides.
@@ -80,10 +105,12 @@ export function getDockhandLabels(labels: Record<string, string> | undefined | n
updateDisabled: boolean;
hidden: boolean;
notifyDisabled: boolean;
customUrl?: string;
} {
return {
updateDisabled: isUpdateDisabledByLabel(labels),
hidden: isHiddenByLabel(labels),
notifyDisabled: isNotifyDisabledByLabel(labels),
customUrl: getCustomUrl(labels),
};
}
+42 -8
View File
@@ -388,15 +388,17 @@ export async function getUserThemePreferences(userId: number): Promise<{
gridFontSize: string;
terminalFont: string;
editorFont: string;
animateIcons: boolean;
}> {
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont] = await Promise.all([
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, animateIcons] = await Promise.all([
getUserSetting(userId, 'light_theme'),
getUserSetting(userId, 'dark_theme'),
getUserSetting(userId, 'font'),
getUserSetting(userId, 'font_size'),
getUserSetting(userId, 'grid_font_size'),
getUserSetting(userId, 'terminal_font'),
getUserSetting(userId, 'editor_font')
getUserSetting(userId, 'editor_font'),
getUserSetting(userId, 'animate_icons')
]);
return {
lightTheme: lightTheme || 'default',
@@ -405,13 +407,15 @@ export async function getUserThemePreferences(userId: number): Promise<{
fontSize: fontSize || 'normal',
gridFontSize: gridFontSize || 'normal',
terminalFont: terminalFont || 'system-mono',
editorFont: editorFont || 'system-mono'
editorFont: editorFont || 'system-mono',
// Default ON — only false when explicitly stored
animateIcons: animateIcons === 'false' ? false : true
};
}
export async function setUserThemePreferences(
userId: number,
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string }
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean }
): Promise<void> {
const updates: Promise<void>[] = [];
if (prefs.lightTheme !== undefined) {
@@ -435,6 +439,9 @@ export async function setUserThemePreferences(
if (prefs.editorFont !== undefined) {
updates.push(setUserSetting(userId, 'editor_font', prefs.editorFont));
}
if (prefs.animateIcons !== undefined) {
updates.push(setUserSetting(userId, 'animate_icons', prefs.animateIcons ? 'true' : 'false'));
}
await Promise.all(updates);
}
@@ -2097,6 +2104,7 @@ export interface GitStackData {
lastCommit: string | null;
syncStatus: GitSyncStatus;
syncError: string | null;
syncedFiles?: string | null; // JSON manifest { commit, files: { relPath: sha256 } } from last successful deploy
createdAt: string;
updatedAt: string;
}
@@ -2303,6 +2311,7 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
syncError: gitStacks.syncError,
syncedFiles: gitStacks.syncedFiles,
createdAt: gitStacks.createdAt,
updatedAt: gitStacks.updatedAt,
repoName: gitRepositories.name,
@@ -2337,6 +2346,7 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
syncError: row.syncError,
syncedFiles: row.syncedFiles ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
repository: {
@@ -2548,6 +2558,7 @@ export async function updateGitStack(id: number, data: Partial<GitStackData>): P
if (data.lastCommit !== undefined) updateData.lastCommit = data.lastCommit;
if (data.syncStatus !== undefined) updateData.syncStatus = data.syncStatus;
if (data.syncError !== undefined) updateData.syncError = data.syncError;
if (data.syncedFiles !== undefined) updateData.syncedFiles = data.syncedFiles;
await db.update(gitStacks).set(updateData).where(eq(gitStacks.id, id));
return getGitStack(id);
@@ -3592,9 +3603,15 @@ export async function getContainerEventActions(): Promise<string[]> {
export async function deleteOldContainerEvents(keepDays = 30): Promise<number> {
const cutoffDate = new Date(Date.now() - keepDays * 24 * 60 * 60 * 1000).toISOString();
await db.delete(containerEvents)
const countResult = await db.select({ count: sql<number>`count(*)` })
.from(containerEvents)
.where(sql`timestamp < ${cutoffDate}`);
return 0;
const count = Number(countResult[0]?.count ?? 0);
if (count > 0) {
await db.delete(containerEvents)
.where(sql`timestamp < ${cutoffDate}`);
}
return count;
}
/**
@@ -4082,9 +4099,15 @@ export async function getRecentExecutionsForSchedule(
export async function cleanupOldExecutions(retentionDays: number): Promise<number> {
const cutoffDate = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
const result = await db.delete(scheduleExecutions)
const countResult = await db.select({ count: sql<number>`count(*)` })
.from(scheduleExecutions)
.where(sql`triggered_at < ${cutoffDate}`);
return 0; // SQLite/PG don't return count consistently
const count = Number(countResult[0]?.count ?? 0);
if (count > 0) {
await db.delete(scheduleExecutions)
.where(sql`triggered_at < ${cutoffDate}`);
}
return count;
}
// Settings helpers for retention
@@ -4311,6 +4334,17 @@ export async function setExternalStackPaths(paths: string[]): Promise<void> {
}
}
/**
* Idempotently add a directory to the external stack paths allowlist.
* Returns true if the path was newly added (false if already present).
*/
export async function addExternalStackPath(dir: string): Promise<boolean> {
const current = await getExternalStackPaths();
if (current.includes(dir)) return false;
await setExternalStackPaths([...current, dir]);
return true;
}
// =============================================================================
// PRIMARY STACK LOCATION
// =============================================================================
+27 -3
View File
@@ -769,7 +769,8 @@ async function seedDatabase(): Promise<void> {
license: ['manage'],
audit_logs: ['view'],
activity: ['view'],
schedules: ['view', 'edit', 'run']
schedules: ['view', 'edit', 'run'],
templates: ['view', 'deploy', 'manage']
});
const operatorPermissions = JSON.stringify({
@@ -788,7 +789,8 @@ async function seedDatabase(): Promise<void> {
license: [],
audit_logs: [],
activity: ['view'],
schedules: ['view', 'edit', 'run']
schedules: ['view', 'edit', 'run'],
templates: ['view', 'deploy']
});
const viewerPermissions = JSON.stringify({
@@ -807,9 +809,31 @@ async function seedDatabase(): Promise<void> {
license: [],
audit_logs: [],
activity: ['view'],
schedules: ['view']
schedules: ['view'],
templates: ['view']
});
// Seed template sources if table is empty
const existingTemplateSources = await db.select().from(schema.templateSources);
if (existingTemplateSources.length === 0) {
// Inline defaults to avoid circular dependency (library.ts imports db/drizzle)
const defaultSources = [
{ sourceId: 'portainer-lissy93', name: 'Portainer templates (Lissy93)', url: 'https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json', enabled: true, builtin: true, sortOrder: 0 },
{ sourceId: 'ntv-one', name: 'NTV-One (consolidated)', url: 'https://raw.githubusercontent.com/ntv-one/portainer/main/template.json', enabled: false, builtin: true, sortOrder: 1 },
{ sourceId: 'mlva', name: 'MLVA (TheLustriVA)', url: 'https://raw.githubusercontent.com/TheLustriVA/portainer-templates-Nov-2022-collection/main/templates_2_2_rc_2_2.json', enabled: false, builtin: true, sortOrder: 2 },
{ sourceId: 'selfhostedpro', name: 'SelfHostedPro', url: 'https://raw.githubusercontent.com/SelfhostedPro/selfhosted_templates/master/Template/portainer-v2.json', enabled: false, builtin: true, sortOrder: 3 },
{ sourceId: 'portainer-qballjos', name: 'Qballjos (homelab)', url: 'https://raw.githubusercontent.com/Qballjos/portainer_templates/master/Template/template.json', enabled: false, builtin: true, sortOrder: 4 },
{ sourceId: 'lsio-technorabilia', name: 'LinuxServer.io (Technorabilia)', url: 'https://raw.githubusercontent.com/technorabilia/portainer-templates/main/lsio/templates/templates.json', enabled: true, builtin: true, sortOrder: 5 },
{ sourceId: 'mikestraney', name: 'MikeStraney', url: 'https://raw.githubusercontent.com/mikestraney/portainer-templates/master/templates.json', enabled: false, builtin: true, sortOrder: 6 },
{ sourceId: 'pi-hosted-amd64', name: 'Pi-Hosted (amd64)', url: 'https://raw.githubusercontent.com/pi-hosted/pi-hosted/master/template/portainer-v2-amd64.json', enabled: false, builtin: true, sortOrder: 7 },
{ sourceId: 'pi-hosted-arm64', name: 'Pi-Hosted (arm64)', url: 'https://raw.githubusercontent.com/pi-hosted/pi-hosted/master/template/portainer-v2-arm64.json', enabled: false, builtin: true, sortOrder: 8 },
];
for (const source of defaultSources) {
await db.insert(schema.templateSources).values(source);
}
logStep('Created default template sources');
}
const existingRoles = await db.select().from(schema.roles);
if (existingRoles.length === 0) {
await db.insert(schema.roles).values([
+16 -2
View File
@@ -288,7 +288,7 @@ export const gitRepositories = sqliteTable('git_repositories', {
url: text('url').notNull(),
branch: text('branch').default('main'),
credentialId: integer('credential_id').references(() => gitCredentials.id, { onDelete: 'set null' }),
composePath: text('compose_path').default('compose.yaml'),
composePath: text('compose_path').default('docker-compose.yml'), // Reverted to original value (#1110)
environmentId: integer('environment_id'),
autoUpdate: integer('auto_update', { mode: 'boolean' }).default(false),
autoUpdateSchedule: text('auto_update_schedule').default('daily'),
@@ -308,7 +308,7 @@ export const gitStacks = sqliteTable('git_stacks', {
stackName: text('stack_name').notNull(),
environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }),
repositoryId: integer('repository_id').notNull().references(() => gitRepositories.id, { onDelete: 'cascade' }),
composePath: text('compose_path').default('compose.yaml'),
composePath: text('compose_path').default('docker-compose.yml'), // Reverted to original value (#1110)
envFilePath: text('env_file_path'), // Path to .env file in repository (e.g., ".env", "config/.env.prod")
autoUpdate: integer('auto_update', { mode: 'boolean' }).default(false),
autoUpdateSchedule: text('auto_update_schedule').default('daily'),
@@ -324,6 +324,7 @@ export const gitStacks = sqliteTable('git_stacks', {
lastCommit: text('last_commit'),
syncStatus: text('sync_status').default('pending'),
syncError: text('sync_error'),
syncedFiles: text('synced_files'), // JSON manifest { relativePath: sha256hex } of files written on last sync
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
}, (table) => ({
@@ -504,6 +505,19 @@ export const userPreferences = sqliteTable('user_preferences', {
unique().on(table.userId, table.environmentId, table.key)
]);
// Template sources
export const templateSources = sqliteTable('template_sources', {
id: integer('id').primaryKey({ autoIncrement: true }),
sourceId: text('source_id').notNull().unique(), // stable identifier (e.g., 'portainer-lissy93')
name: text('name').notNull(),
url: text('url').notNull(),
enabled: integer('enabled', { mode: 'boolean' }).default(true),
builtin: integer('builtin', { mode: 'boolean' }).default(false),
sortOrder: integer('sort_order').default(0),
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
});
// =============================================================================
// TYPE EXPORTS
// =============================================================================
+16 -2
View File
@@ -291,7 +291,7 @@ export const gitRepositories = pgTable('git_repositories', {
url: text('url').notNull(),
branch: text('branch').default('main'),
credentialId: integer('credential_id').references(() => gitCredentials.id, { onDelete: 'set null' }),
composePath: text('compose_path').default('compose.yaml'),
composePath: text('compose_path').default('docker-compose.yml'), // Reverted to original value (#1110)
environmentId: integer('environment_id'),
autoUpdate: boolean('auto_update').default(false),
autoUpdateSchedule: text('auto_update_schedule').default('daily'),
@@ -311,7 +311,7 @@ export const gitStacks = pgTable('git_stacks', {
stackName: text('stack_name').notNull(),
environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }),
repositoryId: integer('repository_id').notNull().references(() => gitRepositories.id, { onDelete: 'cascade' }),
composePath: text('compose_path').default('compose.yaml'),
composePath: text('compose_path').default('docker-compose.yml'), // Reverted to original value (#1110)
envFilePath: text('env_file_path'), // Path to .env file in repository (e.g., ".env", "config/.env.prod")
autoUpdate: boolean('auto_update').default(false),
autoUpdateSchedule: text('auto_update_schedule').default('daily'),
@@ -327,6 +327,7 @@ export const gitStacks = pgTable('git_stacks', {
lastCommit: text('last_commit'),
syncStatus: text('sync_status').default('pending'),
syncError: text('sync_error'),
syncedFiles: text('synced_files'), // JSON manifest { relativePath: sha256hex } of files written on last sync
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
}, (table) => ({
@@ -506,3 +507,16 @@ export const userPreferences = pgTable('user_preferences', {
}, (table) => [
unique().on(table.userId, table.environmentId, table.key)
]);
// Template sources
export const templateSources = pgTable('template_sources', {
id: serial('id').primaryKey(),
sourceId: text('source_id').notNull().unique(), // stable identifier (e.g., 'portainer-lissy93')
name: text('name').notNull(),
url: text('url').notNull(),
enabled: boolean('enabled').default(true),
builtin: boolean('builtin').default(false),
sortOrder: integer('sort_order').default(0),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
});
+615 -136
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
/**
* Parse .env file content into key-value pairs.
* Preserves values exactly as written no quote stripping.
* Docker Compose handles its own quote interpretation at runtime.
*/
export function parseEnvVars(content: string): Record<string, string> {
const result: Record<string, string> = {};
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex === -1) continue;
const key = trimmed.substring(0, eqIndex).trim();
const value = trimmed.substring(eqIndex + 1).trim();
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
result[key] = value;
}
}
return result;
}
+435
View File
@@ -0,0 +1,435 @@
/**
* Git stack deletion sync (#966, #1162).
*
* Propagates upstream file deletions to the stack deploy directory using the
* per-stack manifest: a file is deleted ONLY when the manifest of files
* Dockhand wrote on the previous sync lists it, the new clone no longer
* contains it, AND the bytes on disk still match what Dockhand wrote
* (nobody modified it locally).
*
* Every failure mode degrades to "delete less" never to user-data loss:
* - user-created files (volume data) never in the manifest untouchable
* - locally modified files hash mismatch skip
* - first sync after upgrade / fresh DB empty manifest nothing to delete
* - broken clone walk (empty / compose missing) deletionSafetyCheck blocks
* ALL deletions for that sync (guards against mass-deleting managed files
* due to a Dockhand bug; those files are repo-restorable anyway)
*
* History rewrites are irrelevant by design: deletion converges the deploy
* dir toward the clone state, regardless of how the commits got there.
*/
import { createHash } from 'node:crypto';
import { readdirSync, readFileSync, unlinkSync, rmdirSync, lstatSync } from 'node:fs';
import { join, resolve, sep, dirname, basename, isAbsolute } from 'node:path';
// =============================================================================
// Types
// =============================================================================
export type DeletionSkipReason =
| 'locally-modified' // disk bytes differ from what Dockhand wrote
| 'load-bearing' // compose/.env files are never auto-deleted
| 'invalid-path' // absolute or escaping the stack directory
| 'already-absent' // nothing to do (benign)
| 'agent-no-support' // Hawser agent too old to apply deletions
| 'apply-failed'; // unexpected error during unlink
export interface FileToDelete {
path: string; // relative to the stack deploy dir, '/' separators
hash: string; // sha256 hex of the content Dockhand wrote
}
export interface DeletionSkip {
path: string;
reason: DeletionSkipReason;
}
export interface DeletionPlan {
toDelete: FileToDelete[];
skipped: DeletionSkip[];
}
export interface DeletionApplyResult {
deleted: string[];
skipped: DeletionSkip[];
}
/** Manifest of files Dockhand wrote on the last successful sync. */
export interface SyncManifest {
/** Full commit hash the manifest files were taken from. Null = legacy/bootstrap. */
commit: string | null;
/** relative path → sha256 hex of written content */
files: Record<string, string>;
}
export interface SyncFileChange {
file: string;
status: 'added' | 'updated' | 'removed' | 'skipped';
reason?: string; // human-readable, only for skipped
}
export interface SyncChangeSummary {
changes: SyncFileChange[];
unchangedCount: number;
}
// =============================================================================
// Constants
// =============================================================================
/** Files that are never auto-deleted, regardless of what the sources say. */
export const LOAD_BEARING_FILES = new Set([
'docker-compose.yml',
'docker-compose.yaml',
'compose.yml',
'compose.yaml',
'.env'
]);
// NOTE: deletion skips are FINAL by design. A deletion is attempted exactly
// once — at the sync where the file first disappears from the clone. Any
// skip (old agent, hash mismatch, apply error) is logged and the file simply
// stays on disk as unmanaged residue. There is deliberately no
// carry-forward/retry state: it would require tracking per-file retry status
// indefinitely (e.g. waiting for an agent upgrade that may never happen).
// Worst case is always "a stale file survives" — visible in the logs,
// recoverable manually. With an old Hawser agent the behavior is identical
// to before this feature existed: nothing is ever deleted remotely.
/** Human-readable explanation for each skip reason (shown in logs and activity). */
export function skipReasonMessage(reason: DeletionSkipReason): string {
switch (reason) {
case 'locally-modified':
return 'deleted from the repository, but the file was modified on this machine since Dockhand deployed it — refusing to delete local changes';
case 'load-bearing':
return 'core stack file — never auto-deleted';
case 'invalid-path':
return 'invalid path outside the stack directory — ignored';
case 'already-absent':
return 'already absent';
case 'agent-no-support':
return 'the Hawser agent does not support file deletion sync — file left on the remote host (upgrade the agent to enable cleanup of future deletions)';
case 'apply-failed':
return 'could not be deleted — leaving the file in place';
default:
// Unknown reason (e.g., from a newer agent)
return 'could not be deleted — leaving the file in place';
}
}
const KNOWN_SKIP_REASONS: ReadonlySet<string> = new Set<DeletionSkipReason>([
'locally-modified',
'load-bearing',
'invalid-path',
'already-absent',
'agent-no-support',
'apply-failed'
]);
/** Normalize a reason string from an external source (Hawser agent). */
export function normalizeSkipReason(reason: string): DeletionSkipReason {
return (KNOWN_SKIP_REASONS.has(reason) ? reason : 'apply-failed') as DeletionSkipReason;
}
// =============================================================================
// Manifest (de)serialization
// =============================================================================
export function parseManifest(raw: string | null | undefined): SyncManifest {
if (!raw) return { commit: null, files: {} };
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && typeof parsed.files === 'object' && parsed.files !== null) {
const files: Record<string, string> = {};
for (const [k, v] of Object.entries(parsed.files)) {
if (typeof v === 'string') files[k] = v;
}
return { commit: typeof parsed.commit === 'string' ? parsed.commit : null, files };
}
} catch {
// Corrupt manifest → behave like a fresh bootstrap (fail closed: no deletions)
}
return { commit: null, files: {} };
}
export function serializeManifest(manifest: SyncManifest): string {
return JSON.stringify(manifest);
}
// =============================================================================
// Hashing
// =============================================================================
export function hashContent(content: Buffer | string): string {
return createHash('sha256').update(content).digest('hex');
}
/**
* Walk a directory and hash every regular file (raw bytes).
* Returns { relativePath: sha256hex } with '/' separators.
* Skips .git directories (mirrors the cpSync filter used by the deploy copy).
*/
export function hashDirFiles(dir: string): Record<string, string> {
const result: Record<string, string> = {};
const root = resolve(dir);
const walk = (current: string, relPrefix: string) => {
let entries;
try {
entries = readdirSync(current, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.name === '.git') continue;
const abs = join(current, entry.name);
const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
walk(abs, rel);
} else if (entry.isFile()) {
try {
result[rel] = hashContent(readFileSync(abs));
} catch {
// Unreadable file: leave out of the manifest → never a deletion candidate
}
}
// Symlinks and other special entries are intentionally excluded:
// Dockhand only writes regular files, so only regular files are managed.
}
};
walk(root, '');
return result;
}
// =============================================================================
// Path safety
// =============================================================================
/** A relative path is safe when it cannot escape the stack directory. */
export function isSafeRelPath(p: string): boolean {
if (!p || isAbsolute(p) || p.includes('\\')) return false;
const segments = p.split('/');
return segments.every((s) => s !== '' && s !== '.' && s !== '..');
}
/** Resolve relPath inside root; returns null when it would escape root. */
function containedPath(root: string, relPath: string): string | null {
if (!isSafeRelPath(relPath)) return null;
const abs = resolve(root, relPath);
if (abs !== root && abs.startsWith(root + sep)) return abs;
return null;
}
// =============================================================================
// Core: manifest vs clone
// =============================================================================
/**
* Sanity guard run BEFORE computing any deletions: when the new-clone walk
* looks broken (no files at all, or the compose file itself is missing from
* the walk even though it was just read from that tree), every manifest
* entry would become a deletion candidate a Dockhand bug, not a repo
* change. Returns a human-readable reason to skip ALL deletions this sync,
* or null when it is safe to proceed.
*/
export function deletionSafetyCheck(
manifestFiles: Record<string, string>,
newFiles: Record<string, string>,
composeFileName: string | undefined
): string | null {
if (Object.keys(manifestFiles).length === 0) return null; // nothing to delete anyway
if (Object.keys(newFiles).length === 0) {
return 'the new clone appears empty — skipping all deletions this sync (likely a sync problem, not repository changes)';
}
if (composeFileName && !(composeFileName in newFiles)) {
return `the compose file "${composeFileName}" is missing from the new clone walk — skipping all deletions this sync (likely a sync problem, not repository changes)`;
}
return null;
}
/**
* Compute the deletion plan: manifest entries that are absent from the new
* clone. The hash recorded in the manifest travels with each entry the
* applier deletes only files whose disk bytes still match it.
*
* @param manifestFiles files Dockhand wrote on the last sync (path hash)
* @param newFiles files in the new clone that will be written (path hash)
*/
export function computeDeletions(
manifestFiles: Record<string, string>,
newFiles: Record<string, string>
): DeletionPlan {
const toDelete: FileToDelete[] = [];
const skipped: DeletionSkip[] = [];
for (const [path, hash] of Object.entries(manifestFiles)) {
if (path in newFiles) continue; // still present in the repo
if (!isSafeRelPath(path)) {
skipped.push({ path, reason: 'invalid-path' });
continue;
}
if (LOAD_BEARING_FILES.has(basename(path))) {
skipped.push({ path, reason: 'load-bearing' });
continue;
}
toDelete.push({ path, hash });
}
return { toDelete, skipped };
}
// =============================================================================
// Applier — the single chokepoint that touches the filesystem
// =============================================================================
/**
* Apply a deletion list inside a stack directory.
*
* Structurally incapable of touching anything outside stackDir:
* every path is containment-checked, only regular files whose content still
* matches the recorded hash are unlinked, and directory cleanup uses rmdir
* (never recursive) so directories holding any other content survive.
*/
export function applyFileDeletions(stackDir: string, files: FileToDelete[]): DeletionApplyResult {
const root = resolve(stackDir);
const deleted: string[] = [];
const skipped: DeletionSkip[] = [];
const parentDirs = new Set<string>();
for (const { path, hash } of files) {
const abs = containedPath(root, path);
if (!abs) {
skipped.push({ path, reason: 'invalid-path' });
continue;
}
// Defense in depth: computeDeletions already filters these, but the
// applier also runs on lists from external sources (Hawser payloads).
if (LOAD_BEARING_FILES.has(basename(path))) {
skipped.push({ path, reason: 'load-bearing' });
continue;
}
let stat;
try {
stat = lstatSync(abs);
} catch {
skipped.push({ path, reason: 'already-absent' });
continue;
}
// Dockhand only writes regular files. Anything else (symlink, dir,
// socket) means the user replaced it — treat as locally modified.
if (!stat.isFile()) {
skipped.push({ path, reason: 'locally-modified' });
continue;
}
try {
if (hashContent(readFileSync(abs)) !== hash) {
skipped.push({ path, reason: 'locally-modified' });
continue;
}
unlinkSync(abs);
deleted.push(path);
} catch {
skipped.push({ path, reason: 'apply-failed' });
continue;
}
// Collect parent dir chain (inside root) for empty-dir cleanup
let dir = dirname(abs);
while (dir !== root && dir.startsWith(root + sep)) {
parentDirs.add(dir);
dir = dirname(dir);
}
}
// Deepest-first rmdir; fails harmlessly when a directory still has content
const dirsByDepth = [...parentDirs].sort((a, b) => b.length - a.length);
for (const dir of dirsByDepth) {
try {
rmdirSync(dir);
} catch {
// ENOTEMPTY/ENOENT/etc. — directory stays, which is always safe
}
}
return { deleted, skipped };
}
// =============================================================================
// Manifest evolution
// =============================================================================
/**
* Build the manifest to persist after a sync.
*
* Trivial by design: the manifest is always exactly the files written this
* sync, at this sync's commit. Skipped deletions are FINAL (see note above)
* the affected files drop out of the manifest and become unmanaged residue.
*/
export function buildNextManifest(newCommit: string, newFiles: Record<string, string>): SyncManifest {
return { commit: newCommit, files: { ...newFiles } };
}
// =============================================================================
// Sync summary (per-file status table)
// =============================================================================
export function buildSyncChangeSummary(
previousFiles: Record<string, string>,
newFiles: Record<string, string>,
applyResult: DeletionApplyResult,
planSkipped: DeletionSkip[]
): SyncChangeSummary {
const changes: SyncFileChange[] = [];
let unchangedCount = 0;
for (const [path, hash] of Object.entries(newFiles)) {
const oldHash = previousFiles[path];
if (oldHash === undefined) {
changes.push({ file: path, status: 'added' });
} else if (oldHash !== hash) {
changes.push({ file: path, status: 'updated' });
} else {
unchangedCount++;
}
}
for (const path of applyResult.deleted) {
changes.push({ file: path, status: 'removed' });
}
// Benign "already absent" results are not interesting in the summary
const interestingSkips = [...planSkipped, ...applyResult.skipped].filter(
(s) => s.reason !== 'already-absent'
);
for (const skip of interestingSkips) {
changes.push({ file: skip.path, status: 'skipped', reason: skipReasonMessage(skip.reason) });
}
return { changes, unchangedCount };
}
/** Render the summary as aligned text lines for console and job output. */
export function formatChangeTable(summary: SyncChangeSummary): string[] {
const { changes, unchangedCount } = summary;
const counts = { added: 0, updated: 0, removed: 0, skipped: 0 };
for (const c of changes) counts[c.status]++;
const header = `${counts.added} added, ${counts.updated} updated, ${counts.removed} removed, ${counts.skipped} skipped, ${unchangedCount} unchanged`;
if (changes.length === 0) {
return [header];
}
const fileWidth = Math.min(60, Math.max(4, ...changes.map((c) => c.file.length)));
const lines = [header, `${'STATUS'.padEnd(9)} ${'FILE'.padEnd(fileWidth)} REASON`];
for (const c of changes) {
lines.push(`${c.status.padEnd(9)} ${c.file.padEnd(fileWidth)} ${c.reason ?? ''}`.trimEnd());
}
return lines;
}
+198 -26
View File
@@ -15,6 +15,21 @@ import {
type GitStackWithRepo
} from './db';
import { deployStack, getStackDir } from './stacks';
import {
parseManifest,
serializeManifest,
hashDirFiles,
computeDeletions,
buildNextManifest,
buildSyncChangeSummary,
formatChangeTable,
skipReasonMessage,
deletionSafetyCheck,
type DeletionPlan,
type DeletionApplyResult,
type DeletionSkip,
type SyncManifest
} from './git-deletions';
const MERGED_CA_BUNDLE_PATH = '/tmp/dockhand-merged-ca-bundle.crt';
let mergedCaBundleReady = false;
@@ -109,6 +124,10 @@ if (!existsSync(GIT_REPOS_DIR)) {
mkdirSync(GIT_REPOS_DIR, { recursive: true });
}
export function getGitReposDir(): string {
return GIT_REPOS_DIR;
}
/**
* Redact all env var values for safe logging. Only key names are preserved.
*/
@@ -220,6 +239,20 @@ async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
// Ensure current UID is resolvable for SSH/git operations
await ensurePasswdEntry(env);
// For HTTPS password/token auth, inject credentials via http.extraHeader env vars
// instead of embedding them in the URL (which leaks via /proc/<pid>/cmdline, #1081).
// Uses GIT_CONFIG_COUNT mechanism (git >= 2.31) to set Authorization header.
if (credential?.authType === 'password' && (credential.username || credential.password)) {
const token = credential.password || '';
const username = credential.username || '';
// Use Basic auth (base64 of username:password) — works with GitHub PATs,
// GitLab tokens, Gitea tokens, and standard username/password combos.
const basicAuth = Buffer.from(`${username}:${token}`).toString('base64');
env.GIT_CONFIG_COUNT = '1';
env.GIT_CONFIG_KEY_0 = 'http.extraHeader';
env.GIT_CONFIG_VALUE_0 = `Authorization: Basic ${basicAuth}`;
}
if (credential?.authType === 'ssh' && credential.sshPrivateKey) {
// Write SSH key to /tmp instead of data volume — some filesystems (TrueNAS ZFS,
// NFS, CIFS) silently ignore chmod, leaving the key group-readable (e.g. 0670).
@@ -274,24 +307,20 @@ function cleanupSshKey(credential: GitCredential | null): void {
}
function buildRepoUrl(url: string, credential: GitCredential | null): string {
// For SSH URLs or no auth, return as-is
if (!credential || credential.authType !== 'password' || url.startsWith('git@')) {
return url;
}
// For HTTPS with password auth, embed credentials
try {
const parsed = new URL(url);
if (credential.username) {
parsed.username = credential.username;
// Never embed credentials in the URL — they leak via /proc/<pid>/cmdline (see #1081).
// HTTPS credentials are injected via GIT_CONFIG_COUNT env vars in buildGitEnv().
// Strip any existing credentials from the URL for safety.
if (credential?.authType === 'password' && !url.startsWith('git@')) {
try {
const parsed = new URL(url);
parsed.username = '';
parsed.password = '';
return parsed.toString();
} catch {
return url;
}
if (credential.password) {
parsed.password = credential.password;
}
return parsed.toString();
} catch {
return url;
}
return url;
}
async function execGit(args: string[], cwd: string, env: GitEnv): Promise<{ stdout: string; stderr: string; code: number }> {
@@ -349,6 +378,93 @@ async function getChangedFilesInDir(
return { changed: changedFiles.length > 0, files: changedFiles };
}
/**
* Compute the deletion plan for a sync: hash the new clone's compose dir and
* diff against the manifest from the last sync. Deletions converge the deploy
* dir toward the clone state; the applier additionally verifies each file's
* disk hash. A sanity guard blocks ALL deletions when the clone walk looks
* broken (empty, or missing the compose file).
*/
async function computeSyncDeletionPlan(options: {
logPrefix: string;
composeDir: string; // absolute path inside the clone
composeFileName: string | undefined; // compose file relative to composeDir
rawManifest: string | null | undefined;
}): Promise<{ plan: DeletionPlan; newFiles: Record<string, string>; previousManifest: SyncManifest }> {
const { logPrefix, composeDir, composeFileName, rawManifest } = options;
const previousManifest = parseManifest(rawManifest);
const newFiles = hashDirFiles(composeDir);
const manifestSize = Object.keys(previousManifest.files).length;
console.log(`${logPrefix} Deletion sync: manifest has ${manifestSize} file(s)${manifestSize === 0 ? ' (first sync — nothing will be deleted)' : ''}`);
// First sync / legacy manifest: nothing was recorded, so nothing can be deleted
if (manifestSize === 0) {
return { plan: { toDelete: [], skipped: [] }, newFiles, previousManifest };
}
const blocked = deletionSafetyCheck(previousManifest.files, newFiles, composeFileName);
if (blocked) {
console.warn(`${logPrefix} Deletion sync: ${blocked}`);
return { plan: { toDelete: [], skipped: [] }, newFiles, previousManifest };
}
const plan = computeDeletions(previousManifest.files, newFiles);
for (const file of plan.toDelete) {
console.log(`${logPrefix} Deletion sync: will remove "${file.path}" — deleted from the repository`);
}
for (const skip of plan.skipped) {
console.warn(`${logPrefix} Deletion sync: keeping "${skip.path}" — ${skipReasonMessage(skip.reason)}`);
}
return { plan, newFiles, previousManifest };
}
/**
* Persist the manifest after a deploy and log the per-file change summary.
* Called only after a successful deploy (locally applied or agent-confirmed).
*/
async function finalizeDeletionSync(options: {
stackId: number;
logPrefix: string;
previousManifest: SyncManifest;
newCommitFull: string;
newFiles: Record<string, string>;
plan: DeletionPlan;
applyResult: DeletionApplyResult | undefined;
onLine?: (line: string) => void; // extra sink (job output)
}): Promise<void> {
const { stackId, logPrefix, previousManifest, newCommitFull, newFiles, plan, applyResult, onLine } = options;
// No apply result means deletions were requested but nothing reported back
// (defensive — executors always return one). Logged as skips; skips are final.
const effectiveApply: DeletionApplyResult = applyResult ?? {
deleted: [],
skipped: plan.toDelete.map((f): DeletionSkip => ({ path: f.path, reason: 'apply-failed' }))
};
// Pass only the plan-stage skips; buildSyncChangeSummary already merges
// in effectiveApply.skipped itself. Concatenating here duplicated every
// apply-stage skip (locally-modified, agent-no-support, apply-failed).
const summary = buildSyncChangeSummary(previousManifest.files, newFiles, effectiveApply, plan.skipped);
const tableLines = formatChangeTable(summary);
console.log(`${logPrefix} Sync file changes: ${tableLines[0]}`);
for (const line of tableLines.slice(1)) {
console.log(`${logPrefix} ${line}`);
}
if (onLine) {
onLine(`File changes: ${tableLines[0]}`);
for (const line of tableLines.slice(1)) onLine(line);
}
const nextManifest = buildNextManifest(newCommitFull, newFiles);
await updateGitStack(stackId, { syncedFiles: serializeManifest(nextManifest) });
console.log(`${logPrefix} Manifest persisted: ${Object.keys(nextManifest.files).length} file(s) at commit ${nextManifest.commit?.substring(0, 7)}`);
}
export interface SyncResult {
success: boolean;
commit?: string;
@@ -361,6 +477,11 @@ export interface SyncResult {
error?: string;
updated?: boolean;
changedFiles?: string[]; // List of files that changed (for logging/debugging)
// Deletion sync (#966/#1162): manifest-vs-clone data
deletionPlan?: DeletionPlan; // Files safe to delete (manifest entries absent from the new clone) + plan-stage skips
newFiles?: Record<string, string>; // path → sha256 of files in the new clone (next manifest)
newCommitFull?: string; // Full 40-char commit hash (manifest commit)
previousManifest?: SyncManifest; // Manifest from the last successful sync
}
export interface TestResult {
@@ -940,6 +1061,14 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
console.log(`${logPrefix} No env file path configured`);
}
// Deletion sync (#966): manifest-vs-clone deletion plan
const deletionData = await computeSyncDeletionPlan({
logPrefix,
composeDir,
composeFileName,
rawManifest: gitStack.syncedFiles
});
// Update git stack status
await updateGitStack(stackId, {
syncStatus: 'synced',
@@ -968,7 +1097,11 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
envFileVars,
envFileName,
updated,
changedFiles
changedFiles,
deletionPlan: deletionData.plan,
newFiles: deletionData.newFiles,
newCommitFull: newCommit,
previousManifest: deletionData.previousManifest
};
} catch (error: any) {
cleanupSshKey(credential);
@@ -1051,7 +1184,8 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
forceRecreate,
build: gitStack.buildOnDeploy,
noBuildCache: gitStack.noBuildCache,
pullPolicy: gitStack.repullImages ? 'always' : undefined
pullPolicy: gitStack.repullImages ? 'always' : undefined,
filesToDelete: syncResult.deletionPlan?.toDelete
});
console.log(`${logPrefix} ----------------------------------------`);
@@ -1062,6 +1196,19 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
if (result.error) console.log(`${logPrefix} Error:`, result.error);
if (result.success) {
// Deletion sync: persist manifest + log per-file change summary
if (syncResult.previousManifest && syncResult.newFiles && syncResult.newCommitFull && syncResult.deletionPlan) {
await finalizeDeletionSync({
stackId,
logPrefix,
previousManifest: syncResult.previousManifest,
newCommitFull: syncResult.newCommitFull,
newFiles: syncResult.newFiles,
plan: syncResult.deletionPlan,
applyResult: result.deletion
});
}
// Record the stack source with resolved compose path for consistency
const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId);
const resolvedComposePath = syncResult.composeFileName
@@ -1292,6 +1439,15 @@ export async function deployGitStackWithProgress(
}
}
// Deletion sync (#966): manifest-vs-clone deletion plan
const logPrefix = `[Stack:${gitStack.stackName}]`;
const deletionData = await computeSyncDeletionPlan({
logPrefix,
composeDir,
composeFileName: progressComposeFileName,
rawManifest: gitStack.syncedFiles
});
// Update git stack status
await updateGitStack(stackId, {
syncStatus: 'synced',
@@ -1305,6 +1461,14 @@ export async function deployGitStackWithProgress(
// Step 5: Deploying stack
// Uses `docker compose up -d --remove-orphans` which only recreates changed services
onProgress({ status: 'deploying', message: `Deploying ${gitStack.stackName}...`, step: 5, totalSteps });
if (deletionData.plan.toDelete.length > 0) {
onProgress({
status: 'deploying',
message: `Removing ${deletionData.plan.toDelete.length} file(s) deleted from the repository...`,
step: 5,
totalSteps
});
}
// Determine env filename relative to compose dir (same logic as syncGitStack)
let envFileName: string | undefined;
@@ -1324,10 +1488,24 @@ export async function deployGitStackWithProgress(
envFileName, // Env file relative to compose dir (for --env-file flag, optional)
build: gitStack.buildOnDeploy,
noBuildCache: gitStack.noBuildCache,
pullPolicy: gitStack.repullImages ? 'always' : undefined
pullPolicy: gitStack.repullImages ? 'always' : undefined,
filesToDelete: deletionData.plan.toDelete
});
if (result.success) {
// Deletion sync: persist manifest + log per-file change summary.
// onLine feeds the per-file change table into the deploy progress popover.
await finalizeDeletionSync({
stackId,
logPrefix,
previousManifest: deletionData.previousManifest,
newCommitFull: newCommit,
newFiles: deletionData.newFiles,
plan: deletionData.plan,
applyResult: result.deletion,
onLine: (line) => onProgress({ status: 'deploying', message: line, step: 5, totalSteps })
});
// Record the stack source with resolved compose path for consistency
const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId);
const resolvedComposePath = join(stackDir, progressComposeFileName);
@@ -1443,13 +1621,7 @@ export function parseEnvFileContent(content: string, stackName?: string): Record
}
const key = trimmed.substring(0, eqIndex).trim();
let value = trimmed.substring(eqIndex + 1).trim();
// Handle quoted values
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
const value = trimmed.substring(eqIndex + 1).trim();
// Only add if key is valid env var name
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
+39 -12
View File
@@ -8,8 +8,9 @@
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 { sendEnvironmentNotification } from './notifications/index.js';
import { isNotifyDisabledByLabel } from './container-labels.js';
import { isHealthTransition } from './subprocess-manager.js';
import { pushMetric } from './metrics-store.js';
import { secureGetRandomValues, secureRandomUUID } from './crypto-fallback.js';
import { hashPassword, verifyPassword } from './auth.js';
@@ -178,6 +179,12 @@ export async function handleEdgeContainerEvent(
// Log the event
console.log(`[Hawser] Container event from env ${environmentId}: ${event.action} ${event.containerName || event.containerId}`);
// Only store health_status events on transitions (healthy↔unhealthy)
// to avoid flooding the DB with repeated identical health checks
if (!isHealthTransition(environmentId, event.containerId, event.action)) {
return;
}
// Save to database
const savedEvent = await logContainerEvent({
environmentId,
@@ -258,6 +265,16 @@ export async function handleEdgeMetrics(
// Register global handler for metrics
globalThis.__hawserHandleMetrics = handleEdgeMetrics;
let dummyHawserHash: string | null = null;
async function getDummyHawserHash(): Promise<string> {
if (!dummyHawserHash) {
dummyHawserHash = await hashPassword('hawser_init_seed');
}
return dummyHawserHash;
}
// Warm the lazy init so first-call latency is consistent.
getDummyHawserHash().catch(() => {});
/**
* Validate a Hawser token
*/
@@ -272,22 +289,32 @@ export async function validateHawserToken(
.from(hawserTokens)
.where(and(eq(hawserTokens.tokenPrefix, prefix), eq(hawserTokens.isActive, true)));
if (candidates.length === 0) {
await verifyPassword(token, await getDummyHawserHash());
return { valid: false };
}
for (const t of candidates) {
try {
const isValid = await verifyPassword(token, t.token);
if (isValid) {
// Update last used timestamp
await db
.update(hawserTokens)
.set({ lastUsed: new Date().toISOString() })
.where(eq(hawserTokens.id, t.id));
if (!isValid) continue;
return {
valid: true,
environmentId: t.environmentId ?? undefined,
tokenId: t.id
};
// Expiry check intentionally runs after the hash verify.
if (t.expiresAt && new Date(t.expiresAt) < new Date()) {
return { valid: false };
}
// Update last used timestamp
await db
.update(hawserTokens)
.set({ lastUsed: new Date().toISOString() })
.where(eq(hawserTokens.id, t.id));
return {
valid: true,
environmentId: t.environmentId ?? undefined,
tokenId: t.id
};
} catch {
// Invalid hash format, skip
}
+38 -21
View File
@@ -34,6 +34,7 @@ let cachedMounts: Array<{ source: string; destination: string }> | null = null;
// Used by scanner to replicate how Dockhand connects to Docker
let cachedOwnDockerHost: string | null = null;
let cachedOwnNetworkMode: string | null = null;
let cachedOwnAllNetworks: string[] | null = null;
let cachedOwnExtraHosts: string[] | null = null;
/**
@@ -166,7 +167,10 @@ export async function detectHostDataDir(): Promise<string | null> {
}
}
// Cache Dockhand's network (prefer non-default for service discovery)
// Cache Dockhand's networks. Picks one as the primary networkMode
// (custom net first, falling back to bridge) and keeps the full list
// so callers can warn when a setup is fragile — e.g. socket-proxy
// living on a network other than the one the scanner joins (#1011).
const networks = containerInfo.NetworkSettings?.Networks;
if (networks) {
const custom = Object.keys(networks).filter(
@@ -174,8 +178,9 @@ export async function detectHostDataDir(): Promise<string | null> {
);
cachedOwnNetworkMode = custom.length > 0 ? custom[0]
: networks.bridge ? 'bridge' : null;
cachedOwnAllNetworks = Object.keys(networks);
if (cachedOwnNetworkMode) {
console.log(`[HostPath] Detected own network: ${cachedOwnNetworkMode}`);
console.log(`[HostPath] Detected own network: ${cachedOwnNetworkMode} (all: ${cachedOwnAllNetworks.join(', ')})`);
}
}
@@ -245,6 +250,15 @@ export function getOwnNetworkMode(): string | null {
return cachedOwnNetworkMode;
}
/**
* All Docker networks Dockhand itself is attached to. The scanner uses
* this to detect split-network setups and warn that socket-proxy may not
* be reachable from the network it actually joins (#1011).
*/
export function getOwnAllNetworks(): string[] {
return cachedOwnAllNetworks ? [...cachedOwnAllNetworks] : [];
}
/**
* Get the ExtraHosts entries configured on Dockhand itself.
* Used to mirror host aliases into sibling sidecar containers.
@@ -385,36 +399,39 @@ export function extractUidFromSocketPath(socketPath: string): string | null {
export function rewriteComposeVolumePaths(composeContent: string, workingDir: string): { content: string; modified: boolean; changes: string[] } {
const changes: string[] = [];
// Try to translate workingDir to host path using ANY cached mount
// This handles both DATA_DIR mounts and external mounts (e.g., /external-stacks)
const hostWorkingDir = translateContainerPathViaMount(workingDir);
if (!hostWorkingDir) {
// Can't translate - workingDir is not under any known mount
return { content: composeContent, modified: false, changes };
}
// Parse compose content line by line to find and rewrite volume mounts
// Parse compose content line by line to find and rewrite volume mounts.
// We look for patterns like:
// - ./something:/container/path
// - ../something:/container/path
// - "./something:/container/path"
// - './something:/container/path'
// - '../something:/container/path'
const lines = composeContent.split('\n');
const modifiedLines: string[] = [];
for (const line of lines) {
// Match volume mount patterns with relative paths
// Handles: - ./path:/dest, - "./path:/dest", - './path:/dest'
const volumeMatch = line.match(/^(\s*-\s*)(['"]?)(\.\/[^'":\s]+)(\2)(:.+)$/);
// Match volume mount patterns with relative paths.
// Handles ./path and ../path, optionally quoted with single or double quotes.
const volumeMatch = line.match(/^(\s*-\s*)(['"]?)(\.\.?\/[^'":\s]+)(\2)(:.+)$/);
if (volumeMatch) {
const [, prefix, quote, relativeSrc, , destPart] = volumeMatch;
// Convert relative path to absolute host path
const absoluteHostPath = hostWorkingDir + '/' + relativeSrc.substring(2); // Remove ./
// Resolve to an absolute container path, then translate to a host
// path via any known mount. Each line is translated independently so
// `../foo` can escape workingDir into a sibling that may map to a
// different mount than workingDir itself.
const absoluteContainerPath = resolve(workingDir, relativeSrc);
const absoluteHostPath = translateContainerPathViaMount(absoluteContainerPath);
const newLine = `${prefix}${absoluteHostPath}${destPart}`;
modifiedLines.push(newLine);
changes.push(` ${relativeSrc} -> ${absoluteHostPath}`);
if (absoluteHostPath) {
const newLine = `${prefix}${absoluteHostPath}${destPart}`;
modifiedLines.push(newLine);
changes.push(` ${relativeSrc} -> ${absoluteHostPath}`);
} else {
// Can't translate — leave line unchanged. Compose will resolve
// it relative to its cwd; if that's wrong the deploy fails
// loudly, which is better than producing a misleading host path.
modifiedLines.push(line);
}
} else {
modifiedLines.push(line);
}
-758
View File
@@ -1,758 +0,0 @@
import nodemailer from 'nodemailer';
import {
getEnabledNotificationSettings,
getEnabledEnvironmentNotifications,
getEnvironment,
type NotificationSettingData,
type SmtpConfig,
type AppriseConfig,
type NotificationEventType
} from './db';
import { escapeTelegramMarkdown, parseTelegramUrl, buildGotifyUrl, parseWorkflowsUrl, buildWorkflowsHttpUrl } from '$lib/utils/notification-parsers';
/** Drain a response body to release the underlying socket/TLS connection. */
async function drainResponse(response: Response): Promise<void> {
if (!response.bodyUsed) {
try { await response.arrayBuffer(); } catch {}
}
}
export interface NotificationPayload {
title: string;
message: string;
type?: 'info' | 'success' | 'warning' | 'error';
environmentId?: number;
environmentName?: string;
}
// Result type for functions that can return detailed errors
export interface NotificationResult {
success: boolean;
error?: string;
}
// Send notification via SMTP
async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise<NotificationResult> {
try {
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.username ? {
user: config.username,
pass: config.password
} : undefined,
tls: config.skipTlsVerify ? {
rejectUnauthorized: false
} : undefined
});
const envBadge = payload.environmentName
? `<span style="display: inline-block; background: #3b82f6; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px;">${payload.environmentName}</span>`
: '';
const envText = payload.environmentName ? ` [${payload.environmentName}]` : '';
const html = `
<div style="font-family: sans-serif; padding: 20px;">
<h2 style="margin: 0 0 10px 0;">${payload.title}${envBadge}</h2>
<p style="margin: 0; white-space: pre-wrap;">${payload.message}</p>
<hr style="margin: 20px 0; border: none; border-top: 1px solid #eee;">
<p style="margin: 0; font-size: 12px; color: #666;">Sent by Dockhand</p>
</div>
`;
await transporter.sendMail({
from: config.from_name ? `"${config.from_name}" <${config.from_email}>` : config.from_email,
to: config.to_emails.join(', '),
subject: `[Dockhand]${envText} ${payload.title}`,
text: `${payload.title}${envText}\n\n${payload.message}`,
html
});
return { success: true };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return { success: false, error: `SMTP error: ${errorMsg}` };
}
}
// Parse Apprise URL and send notification
async function sendAppriseNotification(config: AppriseConfig, payload: NotificationPayload): Promise<NotificationResult> {
const errors: string[] = [];
for (const url of config.urls) {
try {
const result = await sendToAppriseUrl(url, payload);
if (!result.success && result.error) {
errors.push(result.error);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
errors.push(`Failed to send: ${errorMsg}`);
}
}
if (errors.length > 0) {
return { success: false, error: errors.join('; ') };
}
return { success: true };
}
// Send to a single Apprise URL
async function sendToAppriseUrl(url: string, payload: NotificationPayload): Promise<NotificationResult> {
try {
// Extract protocol from Apprise URL format (protocol://...)
// Note: Can't use new URL() because custom schemes like 'tgram://' are not valid URLs
const protocolMatch = url.match(/^([a-z]+):\/\//i);
if (!protocolMatch) {
return { success: false, error: 'Invalid Apprise URL format - missing protocol' };
}
const protocol = protocolMatch[1].toLowerCase();
// Handle different notification services
switch (protocol) {
case 'discord':
case 'discords':
return await sendDiscord(url, payload);
case 'slack':
case 'slacks':
return await sendSlack(url, payload);
case 'mmost':
case 'mmosts':
return await sendMattermost(url, payload);
case 'tgram':
return await sendTelegram(url, payload);
case 'gotify':
case 'gotifys':
return await sendGotify(url, payload);
case 'ntfy':
case 'ntfys':
return await sendNtfy(url, payload);
case 'pushover':
return await sendPushover(url, payload);
case 'json':
case 'jsons':
return await sendGenericWebhook(url, payload);
case 'workflows':
return await sendWorkflows(url, payload);
default:
return { success: false, error: `Unsupported Apprise protocol: ${protocol}` };
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return { success: false, error: `Failed to parse Apprise URL: ${errorMsg}` };
}
}
// Discord webhook
async function sendDiscord(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// discord://webhook_id/webhook_token or discords://...
const url = appriseUrl.replace(/^discords?:\/\//, 'https://discord.com/api/webhooks/');
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
embeds: [{
title: titleWithEnv,
description: payload.message,
color: payload.type === 'error' ? 0xff0000 : payload.type === 'warning' ? 0xffaa00 : payload.type === 'success' ? 0x00ff00 : 0x0099ff,
...(payload.environmentName && {
footer: { text: `Environment: ${payload.environmentName}` }
})
}]
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Discord error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Discord connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Slack webhook
async function sendSlack(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// slack://token_a/token_b/token_c or webhook URL
let url: string;
if (appriseUrl.includes('hooks.slack.com')) {
url = appriseUrl.replace(/^slacks?:\/\//, 'https://');
} else {
const parts = appriseUrl.replace(/^slacks?:\/\//, '').split('/');
url = `https://hooks.slack.com/services/${parts.join('/')}`;
}
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `*${payload.title}*${envTag}\n${payload.message}`
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Slack error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Slack connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Mattermost webhook
async function sendMattermost(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// mmost://[botname@]hostname[:port][/path]/token or mmosts://...
const isSecure = appriseUrl.startsWith('mmosts');
const protocol = isSecure ? 'https' : 'http';
// Remove the scheme
let urlPart = appriseUrl.replace(/^mmosts?:\/\//, '');
// Check for botname (username@hostname format)
let username: string | undefined;
const atIndex = urlPart.indexOf('@');
if (atIndex !== -1) {
username = urlPart.substring(0, atIndex);
urlPart = urlPart.substring(atIndex + 1);
}
// The token is the last segment, everything else is hostname[:port][/path]
const lastSlashIndex = urlPart.lastIndexOf('/');
if (lastSlashIndex === -1) {
return { success: false, error: 'Invalid Mattermost URL format. Expected: mmost://[botname@]hostname[:port][/path]/token' };
}
const token = urlPart.substring(lastSlashIndex + 1);
const hostAndPath = urlPart.substring(0, lastSlashIndex);
// Build the webhook URL: {protocol}://{hostname}[:{port}][/{path}]/hooks/{token}
const url = `${protocol}://${hostAndPath}/hooks/${token}`;
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
const body: Record<string, string> = {
text: `*${payload.title}*${envTag}\n${payload.message}`
};
if (username) {
body.username = username;
}
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Mattermost error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Mattermost connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Telegram
async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
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' };
}
const { botToken, chatId, topicId } = parsed;
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
// Escape markdown special characters in title and message
const escapedTitle = escapeTelegramMarkdown(payload.title);
const escapedMessage = escapeTelegramMarkdown(payload.message);
const envTag = payload.environmentName ? ` [${escapeTelegramMarkdown(payload.environmentName)}]` : '';
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text: `*${escapedTitle}*${envTag}\n${escapedMessage}`,
...(topicId ? { message_thread_id: topicId } : {}),
parse_mode: 'Markdown',
link_preview_options: {
is_disabled: true
}
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({})) as { description?: string };
const errorMsg = errorData.description || response.statusText;
return { success: false, error: `Telegram error ${response.status}: ${errorMsg}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Telegram connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Gotify
async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const url = buildGotifyUrl(appriseUrl);
if (!url) {
return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' };
}
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({
title: titleWithEnv,
message: payload.message,
priority: payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Gotify error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Gotify connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// ntfy
async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// Supported formats:
// ntfy://topic (public ntfy.sh)
// ntfy://host/topic (custom server, no auth)
// ntfy://user:pass@host/topic (custom server with basic auth)
// ntfy://token@host/topic (custom server with bearer token)
// ntfy://host/topic?auth=BASE64 (custom server with base64-encoded bearer token)
// Query params: ?tags=ship,whale &title=Custom &priority=5
// ntfys:// variants for HTTPS
const isSecure = appriseUrl.startsWith('ntfys');
const path = appriseUrl.replace(/^ntfys?:\/\//, '');
let url: string;
let authHeader: string | null = null;
// 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(/^([^:]+):([^@]+)@(.+)$/);
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('/')) {
// token@host/topic -> Bearer token auth
const tokenMatch = cleanPath.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}`;
}
} else if (cleanPath.includes('/')) {
// Custom server without auth
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
} else {
// Default ntfy.sh
url = `https://ntfy.sh/${cleanPath}`;
}
// 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
};
if (authHeader) {
headers['Authorization'] = authHeader;
}
try {
const response = await fetch(url, {
method: 'POST',
headers,
body: payload.message
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `ntfy error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `ntfy connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Pushover
async function sendPushover(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// pushover://user_key/api_token
const match = appriseUrl.match(/^pushover:\/\/([^/]+)\/(.+)/);
if (!match) {
return { success: false, error: 'Invalid Pushover URL format. Expected: pushover://user_key/api_token' };
}
const [, userKey, apiToken] = match;
const url = 'https://api.pushover.net/1/messages.json';
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({
token: apiToken,
user: userKey,
title: titleWithEnv,
message: payload.message,
priority: payload.type === 'error' ? 1 : 0
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Pushover error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Pushover connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Generic JSON webhook
async function sendGenericWebhook(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// json://hostname/path or jsons://hostname/path
const url = appriseUrl.replace(/^jsons?:\/\//, appriseUrl.startsWith('jsons') ? 'https://' : 'http://');
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: payload.title,
message: payload.message,
type: payload.type || 'info',
environment: payload.environmentName || null,
timestamp: new Date().toISOString()
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Webhook error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Webhook connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// 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 }[] }> {
const settings = await getEnabledNotificationSettings();
const results: { name: string; success: boolean }[] = [];
for (const setting of settings) {
let result: NotificationResult = { success: false };
if (setting.type === 'smtp') {
result = await sendSmtpNotification(setting.config as SmtpConfig, payload);
} else if (setting.type === 'apprise') {
result = await sendAppriseNotification(setting.config as AppriseConfig, payload);
}
results.push({ name: setting.name, success: result.success });
}
return {
success: results.every(r => r.success),
results
};
}
// Test a specific notification setting
export async function testNotification(setting: NotificationSettingData): Promise<NotificationResult> {
const payload: NotificationPayload = {
title: 'Dockhand Test Notification',
message: 'This is a test notification from Dockhand. If you receive this, your notification settings are configured correctly.',
type: 'info'
};
if (setting.type === 'smtp') {
return await sendSmtpNotification(setting.config as SmtpConfig, payload);
} else if (setting.type === 'apprise') {
return await sendAppriseNotification(setting.config as AppriseConfig, payload);
}
return { success: false, error: 'Unknown notification type' };
}
// Map Docker action to notification event type
function mapActionToEventType(action: string): NotificationEventType | null {
const mapping: Record<string, NotificationEventType> = {
'start': 'container_started',
'stop': 'container_stopped',
'restart': 'container_restarted',
'die': 'container_exited',
'kill': 'container_exited',
'oom': 'container_oom',
'health_status: unhealthy': 'container_unhealthy',
'health_status: healthy': 'container_healthy',
'pull': 'image_pulled'
};
return mapping[action] || null;
}
// Scanner image patterns to exclude from notifications
const SCANNER_IMAGE_PATTERNS = [
'anchore/grype',
'aquasec/trivy',
'ghcr.io/anchore/grype',
'ghcr.io/aquasecurity/trivy'
];
function isScannerContainer(image: string | null | undefined): boolean {
if (!image) return false;
const lowerImage = image.toLowerCase();
return SCANNER_IMAGE_PATTERNS.some(pattern => lowerImage.includes(pattern.toLowerCase()));
}
// Send notification for an environment-specific event
export async function sendEnvironmentNotification(
environmentId: number,
action: string,
payload: Omit<NotificationPayload, 'environmentId' | 'environmentName'>,
image?: string | null
): Promise<{ success: boolean; sent: number }> {
const eventType = mapActionToEventType(action);
if (!eventType) {
// Not a notifiable event type
return { success: true, sent: 0 };
}
// Get environment name
const env = await getEnvironment(environmentId);
if (!env) {
return { success: false, sent: 0 };
}
// Get enabled notification channels for this environment and event type
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
if (envNotifications.length === 0) {
return { success: true, sent: 0 };
}
const enrichedPayload: NotificationPayload = {
...payload,
environmentId,
environmentName: env.name
};
// Check if this is a scanner container
const isScanner = isScannerContainer(image);
let sent = 0;
let allSuccess = true;
// Skip all notifications for scanner containers (Trivy, Grype)
if (isScanner) {
return { success: true, sent: 0 };
}
for (const notif of envNotifications) {
try {
let result: NotificationResult = { success: false };
if (notif.channelType === 'smtp') {
result = await sendSmtpNotification(notif.config as SmtpConfig, enrichedPayload);
} else if (notif.channelType === 'apprise') {
result = await sendAppriseNotification(notif.config as AppriseConfig, enrichedPayload);
}
if (result.success) sent++;
else allSuccess = false;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[Notifications] Failed to send to channel ${notif.channelName}:`, errorMsg);
allSuccess = false;
}
}
return { success: allSuccess, sent };
}
// Send notification for a specific event type (not mapped from Docker action)
// Used for auto-update, git sync, vulnerability, and system events
export async function sendEventNotification(
eventType: NotificationEventType,
payload: NotificationPayload,
environmentId?: number
): Promise<{ success: boolean; sent: number }> {
// Get environment name if provided
let enrichedPayload = { ...payload };
if (environmentId) {
const env = await getEnvironment(environmentId);
if (env) {
enrichedPayload.environmentId = environmentId;
enrichedPayload.environmentName = env.name;
}
}
// Get enabled notification channels for this event type
let channels: Array<{
channel_type: 'smtp' | 'apprise';
channel_name: string;
config: SmtpConfig | AppriseConfig;
}> = [];
if (environmentId) {
// Environment-specific: get channels subscribed to this env and event type
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
channels = envNotifications
.filter(n => n.channelType && n.channelName)
.map(n => ({
channel_type: n.channelType!,
channel_name: n.channelName!,
config: n.config
}));
} else {
// System-wide: get all globally enabled channels that subscribe to this event type
const globalSettings = await getEnabledNotificationSettings();
channels = globalSettings
.filter(s => s.eventTypes?.includes(eventType))
.map(s => ({
channel_type: s.type,
channel_name: s.name,
config: s.config
}));
}
if (channels.length === 0) {
return { success: true, sent: 0 };
}
let sent = 0;
let allSuccess = true;
for (const channel of channels) {
try {
let result: NotificationResult = { success: false };
if (channel.channel_type === 'smtp') {
result = await sendSmtpNotification(channel.config as SmtpConfig, enrichedPayload);
} else if (channel.channel_type === 'apprise') {
result = await sendAppriseNotification(channel.config as AppriseConfig, enrichedPayload);
}
if (result.success) sent++;
else allSuccess = false;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[Notifications] Failed to send to channel ${channel.channel_name}:`, errorMsg);
allSuccess = false;
}
}
return { success: allSuccess, sent };
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Apprise passthrough POST to a self-hosted caronc/apprise-api server.
*
* Users configure all their providers (Signal, Matrix, MQTT, IFTTT, AWS SNS,
* dozens more) in their own Apprise server; Dockhand just forwards each
* notification once. The big win: every provider Apprise upstream supports
* is now reachable from Dockhand without us having to write a sender for it.
*
* Supported formats:
* apprise://host[:port]/key → HTTP, stateful (Apprise stored config key)
* apprises://host[:port]/key → HTTPS variant
* apprise://host[:port]/prefix/key → path-prefixed Apprise behind a reverse proxy
* apprise://host[:port]/key?tag=devops → optional tag filter
*
* Setup docs: https://github.com/caronc/apprise-api
*/
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendApprise(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const isSecure = appriseUrl.startsWith('apprises');
const raw = appriseUrl.replace(/^apprises?:\/\//, '');
let cleanPath = raw;
let queryParams = new URLSearchParams();
const qIndex = raw.indexOf('?');
if (qIndex !== -1) {
queryParams = new URLSearchParams(raw.substring(qIndex + 1));
cleanPath = raw.substring(0, qIndex);
}
const parts = cleanPath.split('/').filter(Boolean);
if (parts.length < 2) {
return { success: false, error: 'Invalid Apprise URL. Expected: apprise://host[:port]/key' };
}
const hostPort = parts[0];
// The Apprise key is the last path segment. Anything between host and key
// is a path prefix (some users mount Apprise behind a reverse proxy
// at /apprise/ — we preserve that).
const key = parts[parts.length - 1];
const pathPrefix = parts.slice(1, -1).join('/');
const baseUrl = `${isSecure ? 'https' : 'http'}://${hostPort}${pathPrefix ? '/' + pathPrefix : ''}`;
// Map our payload type to Apprise's NotifyType. 'error' → 'failure' is
// the only rename; everything else lines up.
const apprisesType = payload.type === 'error'
? 'failure'
: payload.type === 'warning'
? 'warning'
: payload.type === 'success'
? 'success'
: 'info';
const titleWithEnv = payload.environmentName
? `${payload.title} [${payload.environmentName}]`
: payload.title;
const body: Record<string, unknown> = {
title: titleWithEnv,
body: payload.message,
type: apprisesType
};
const tag = queryParams.get('tag');
if (tag) body.tag = tag;
const format = queryParams.get('format');
if (format) body.format = format; // text | markdown | html
try {
const response = await fetch(`${baseUrl}/notify/${encodeURIComponent(key)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
// Apprise-API uses specific status codes:
// 200 → success, 204 → key not configured, 424 → at least one
// downstream provider failed or tag didn't match.
if (response.status === 204) {
return { success: false, error: `Apprise: no configuration found for key "${key}"` };
}
if (response.status === 424) {
const text = await response.text().catch(() => '');
return { success: false, error: `Apprise: at least one downstream provider failed${text ? `${text}` : ''}` };
}
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Apprise error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Apprise connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Bark iOS push via bark-server (https://github.com/Finb/bark-server).
*
* Supported formats:
* bark://device_key → uses official api.day.app over HTTPS
* bark://host/device_key → custom server over HTTP
* bark://host[:port]/k1/k2/... → multi-device batch (Apprise convention)
* barks://host[:port]/... → HTTPS variant
*
* Query params honored (per https://bark.day.app/#/en-us/tutorial):
* ?sound=name, ?level=active|timeSensitive|critical|passive,
* ?group=, ?icon=, ?url=, ?badge=N, ?copy=, ?subtitle=,
* ?volume=, ?ttl=, ?call=1, ?autoCopy=1, ?isArchive=1, ?action=none
*/
import type { NotificationPayload, NotificationResult } from './shared';
export async function sendBark(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const isSecure = appriseUrl.startsWith('barks');
const path = appriseUrl.replace(/^barks?:\/\//, '');
// Split off query string before slicing the path so '?' in a device key
// (in principle possible, though Bark's keys are 22-char base62) doesn't
// confuse the parser.
let cleanPath = path;
let queryParams = new URLSearchParams();
const qIndex = path.indexOf('?');
if (qIndex !== -1) {
queryParams = new URLSearchParams(path.substring(qIndex + 1));
cleanPath = path.substring(0, qIndex);
}
if (!cleanPath) {
return { success: false, error: 'Invalid Bark URL format. Expected: bark://device_key, bark://host/device_key, or barks://host/device_key' };
}
let baseUrl: string;
let deviceKeys: string[];
if (!cleanPath.includes('/')) {
// bark://device_key → official server, HTTPS regardless of bark:// vs barks://
baseUrl = 'https://api.day.app';
deviceKeys = [cleanPath];
} else {
const parts = cleanPath.split('/').filter(Boolean);
if (parts.length < 2) {
return { success: false, error: 'Invalid Bark URL format. Expected: bark://device_key, bark://host/device_key, or barks://host/device_key' };
}
const hostPort = parts[0];
deviceKeys = parts.slice(1);
baseUrl = `${isSecure ? 'https' : 'http'}://${hostPort}`;
}
// Map our payload type to Bark's `level`. Query-supplied level wins.
// info → active (banner + sound, doesn't bypass DND)
// warning → timeSensitive (cuts through Focus modes)
// error → critical (cuts through silent mode; user must enable)
const defaultLevel = payload.type === 'error'
? 'critical'
: payload.type === 'warning'
? 'timeSensitive'
: 'active';
const level = queryParams.get('level') || defaultLevel;
const titleWithEnv = payload.environmentName
? `${payload.title} [${payload.environmentName}]`
: payload.title;
const body: Record<string, unknown> = {
title: titleWithEnv,
body: payload.message,
level
};
// Single-target uses device_key; batch uses device_keys (per Bark API v2).
if (deviceKeys.length === 1) {
body.device_key = deviceKeys[0];
} else {
body.device_keys = deviceKeys;
}
// String passthroughs Bark understands. Unknown params are dropped on the
// server side anyway so no point forwarding them.
const passthroughString = ['sound', 'group', 'icon', 'url', 'copy', 'subtitle', 'category', 'ciphertext', 'isArchive', 'autoCopy', 'call', 'action', 'volume'];
for (const key of passthroughString) {
const v = queryParams.get(key);
if (v !== null && v !== '') body[key] = v;
}
// Numeric passthroughs.
for (const key of ['badge', 'ttl']) {
const v = queryParams.get(key);
if (v !== null && v !== '') {
const n = parseInt(v, 10);
if (!Number.isNaN(n)) body[key] = n;
}
}
try {
const response = await fetch(`${baseUrl}/push`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(body)
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Bark error ${response.status}: ${text || response.statusText}` };
}
// Bark returns HTTP 200 with { code, message, timestamp } — `code !== 200`
// signals a logical failure (e.g. invalid device key) that we'd otherwise
// swallow as a success.
const json: any = await response.json().catch(() => null);
if (json && typeof json.code === 'number' && json.code !== 200) {
return { success: false, error: `Bark error: ${json.message || `code ${json.code}`}` };
}
return { success: true };
} catch (error) {
return { success: false, error: `Bark connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+34
View File
@@ -0,0 +1,34 @@
/** Discord webhook notifications. discord:// or discords://. */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendDiscord(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// discord://webhook_id/webhook_token or discords://...
const url = appriseUrl.replace(/^discords?:\/\//, 'https://discord.com/api/webhooks/');
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
embeds: [{
title: titleWithEnv,
description: payload.message,
color: payload.type === 'error' ? 0xff0000 : payload.type === 'warning' ? 0xffaa00 : payload.type === 'success' ? 0x00ff00 : 0x0099ff,
...(payload.environmentName && {
footer: { text: `Environment: ${payload.environmentName}` }
})
}]
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Discord error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Discord connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
@@ -0,0 +1,30 @@
/** Generic JSON webhook. json:// or jsons:// (HTTPS). */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendGenericWebhook(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// json://hostname/path or jsons://hostname/path
const url = appriseUrl.replace(/^jsons?:\/\//, appriseUrl.startsWith('jsons') ? 'https://' : 'http://');
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: payload.title,
message: payload.message,
type: payload.type || 'info',
environment: payload.environmentName || null,
timestamp: new Date().toISOString()
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Webhook error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Webhook connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+34
View File
@@ -0,0 +1,34 @@
/** Gotify. gotify:// or gotifys:// (HTTPS). */
import { buildGotifyUrl } from '$lib/utils/notification-parsers';
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const parsed = buildGotifyUrl(appriseUrl);
if (!parsed) {
return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' };
}
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
const defaultPriority = payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2;
try {
const response = await fetch(parsed.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: titleWithEnv,
message: payload.message,
priority: parsed.priority ?? defaultPriority
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Gotify error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Gotify connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+314
View File
@@ -0,0 +1,314 @@
/**
* Notification router picks the right per-provider sender based on the
* channel type (SMTP / Apprise URL) and (for Apprise URLs) the URL scheme.
*
* Public surface used by API routes and the rest of the app:
* - sendNotification (fan out to every enabled channel)
* - testNotification (one channel, with a fixed test payload)
* - sendEnvironmentNotification (Docker container event matching channels)
* - sendEventNotification (auto-update / git / vuln / system events)
* - NotificationPayload, NotificationResult types
*
* Per-provider implementations live in sibling files (./bark, ./discord, ).
* This file orchestrates only it never knows what's inside a Bark or
* Telegram URL.
*/
import {
getEnabledNotificationSettings,
getEnabledEnvironmentNotifications,
getEnvironment,
type NotificationSettingData,
type SmtpConfig,
type AppriseConfig,
type NotificationEventType
} from '../db';
import type { NotificationPayload, NotificationResult } from './shared';
export type { NotificationPayload, NotificationResult } from './shared';
import { sendSmtpNotification } from './smtp';
import { sendDiscord } from './discord';
import { sendSlack } from './slack';
import { sendMattermost } from './mattermost';
import { sendTelegram } from './telegram';
import { sendGotify } from './gotify';
import { sendNtfy } from './ntfy';
import { sendBark } from './bark';
import { sendSignal } from './signal';
import { sendApprise } from './apprise';
import { sendPushover } from './pushover';
import { sendGenericWebhook } from './generic-webhook';
import { sendWorkflows } from './workflows';
// Send to every URL in an Apprise channel. Errors are aggregated so a single
// bad URL doesn't silently mask a healthy one.
async function sendAppriseNotification(config: AppriseConfig, payload: NotificationPayload): Promise<NotificationResult> {
const errors: string[] = [];
for (const url of config.urls) {
try {
const result = await sendToAppriseUrl(url, payload);
if (!result.success && result.error) {
errors.push(result.error);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
errors.push(`Failed to send: ${errorMsg}`);
}
}
if (errors.length > 0) {
return { success: false, error: errors.join('; ') };
}
return { success: true };
}
// Route a single Apprise URL to the right sender. The switch is the ONLY
// place that needs to grow when a new provider is added.
async function sendToAppriseUrl(url: string, payload: NotificationPayload): Promise<NotificationResult> {
try {
// Custom schemes like 'tgram://' aren't valid URLs to new URL(),
// so we match the prefix directly.
const protocolMatch = url.match(/^([a-z]+):\/\//i);
if (!protocolMatch) {
return { success: false, error: 'Invalid Apprise URL format - missing protocol' };
}
const protocol = protocolMatch[1].toLowerCase();
switch (protocol) {
case 'discord':
case 'discords':
return await sendDiscord(url, payload);
case 'slack':
case 'slacks':
return await sendSlack(url, payload);
case 'mmost':
case 'mmosts':
return await sendMattermost(url, payload);
case 'tgram':
return await sendTelegram(url, payload);
case 'gotify':
case 'gotifys':
return await sendGotify(url, payload);
case 'ntfy':
case 'ntfys':
return await sendNtfy(url, payload);
case 'bark':
case 'barks':
return await sendBark(url, payload);
case 'signal':
case 'signals':
return await sendSignal(url, payload);
case 'apprise':
case 'apprises':
return await sendApprise(url, payload);
case 'pushover':
return await sendPushover(url, payload);
case 'json':
case 'jsons':
return await sendGenericWebhook(url, payload);
case 'workflows':
return await sendWorkflows(url, payload);
default:
return { success: false, error: `Unsupported Apprise protocol: ${protocol}` };
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return { success: false, error: `Failed to parse Apprise URL: ${errorMsg}` };
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export async function sendNotification(payload: NotificationPayload): Promise<{ success: boolean; results: { name: string; success: boolean }[] }> {
const settings = await getEnabledNotificationSettings();
const results: { name: string; success: boolean }[] = [];
for (const setting of settings) {
let result: NotificationResult = { success: false };
if (setting.type === 'smtp') {
result = await sendSmtpNotification(setting.config as SmtpConfig, payload);
} else if (setting.type === 'apprise') {
result = await sendAppriseNotification(setting.config as AppriseConfig, payload);
}
results.push({ name: setting.name, success: result.success });
}
return {
success: results.every(r => r.success),
results
};
}
export async function testNotification(setting: NotificationSettingData): Promise<NotificationResult> {
const payload: NotificationPayload = {
title: 'Dockhand Test Notification',
message: 'This is a test notification from Dockhand. If you receive this, your notification settings are configured correctly.',
type: 'info'
};
if (setting.type === 'smtp') {
return await sendSmtpNotification(setting.config as SmtpConfig, payload);
} else if (setting.type === 'apprise') {
return await sendAppriseNotification(setting.config as AppriseConfig, payload);
}
return { success: false, error: 'Unknown notification type' };
}
// Map Docker action to notification event type
function mapActionToEventType(action: string): NotificationEventType | null {
const mapping: Record<string, NotificationEventType> = {
'start': 'container_started',
'stop': 'container_stopped',
'restart': 'container_restarted',
'die': 'container_exited',
'kill': 'container_exited',
'oom': 'container_oom',
'health_status: unhealthy': 'container_unhealthy',
'health_status: healthy': 'container_healthy',
'pull': 'image_pulled'
};
return mapping[action] || null;
}
// Scanner image patterns to exclude from notifications
const SCANNER_IMAGE_PATTERNS = [
'anchore/grype',
'aquasec/trivy',
'ghcr.io/anchore/grype',
'ghcr.io/aquasecurity/trivy'
];
function isScannerContainer(image: string | null | undefined): boolean {
if (!image) return false;
const lowerImage = image.toLowerCase();
return SCANNER_IMAGE_PATTERNS.some(pattern => lowerImage.includes(pattern.toLowerCase()));
}
export async function sendEnvironmentNotification(
environmentId: number,
action: string,
payload: Omit<NotificationPayload, 'environmentId' | 'environmentName'>,
image?: string | null
): Promise<{ success: boolean; sent: number }> {
const eventType = mapActionToEventType(action);
if (!eventType) {
return { success: true, sent: 0 };
}
const env = await getEnvironment(environmentId);
if (!env) {
return { success: false, sent: 0 };
}
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
if (envNotifications.length === 0) {
return { success: true, sent: 0 };
}
const enrichedPayload: NotificationPayload = {
...payload,
environmentId,
environmentName: env.name
};
// Skip all notifications for scanner containers (Trivy, Grype)
if (isScannerContainer(image)) {
return { success: true, sent: 0 };
}
let sent = 0;
let allSuccess = true;
for (const notif of envNotifications) {
try {
let result: NotificationResult = { success: false };
if (notif.channelType === 'smtp') {
result = await sendSmtpNotification(notif.config as SmtpConfig, enrichedPayload);
} else if (notif.channelType === 'apprise') {
result = await sendAppriseNotification(notif.config as AppriseConfig, enrichedPayload);
}
if (result.success) sent++;
else allSuccess = false;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[Notifications] Failed to send to channel ${notif.channelName}:`, errorMsg);
allSuccess = false;
}
}
return { success: allSuccess, sent };
}
export async function sendEventNotification(
eventType: NotificationEventType,
payload: NotificationPayload,
environmentId?: number
): Promise<{ success: boolean; sent: number }> {
let enrichedPayload = { ...payload };
if (environmentId) {
const env = await getEnvironment(environmentId);
if (env) {
enrichedPayload.environmentId = environmentId;
enrichedPayload.environmentName = env.name;
}
}
let channels: Array<{
channel_type: 'smtp' | 'apprise';
channel_name: string;
config: SmtpConfig | AppriseConfig;
}> = [];
if (environmentId) {
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
channels = envNotifications
.filter(n => n.channelType && n.channelName)
.map(n => ({
channel_type: n.channelType!,
channel_name: n.channelName!,
config: n.config
}));
} else {
const globalSettings = await getEnabledNotificationSettings();
channels = globalSettings
.filter(s => s.eventTypes?.includes(eventType))
.map(s => ({
channel_type: s.type,
channel_name: s.name,
config: s.config
}));
}
if (channels.length === 0) {
return { success: true, sent: 0 };
}
let sent = 0;
let allSuccess = true;
for (const channel of channels) {
try {
let result: NotificationResult = { success: false };
if (channel.channel_type === 'smtp') {
result = await sendSmtpNotification(channel.config as SmtpConfig, enrichedPayload);
} else if (channel.channel_type === 'apprise') {
result = await sendAppriseNotification(channel.config as AppriseConfig, enrichedPayload);
}
if (result.success) sent++;
else allSuccess = false;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[Notifications] Failed to send to channel ${channel.channel_name}:`, errorMsg);
allSuccess = false;
}
}
return { success: allSuccess, sent };
}
@@ -0,0 +1,55 @@
/** Mattermost incoming webhook. mmost:// or mmosts:// (HTTPS). */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendMattermost(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// mmost://[botname@]hostname[:port][/path]/token or mmosts://...
const isSecure = appriseUrl.startsWith('mmosts');
const protocol = isSecure ? 'https' : 'http';
let urlPart = appriseUrl.replace(/^mmosts?:\/\//, '');
// Check for botname (username@hostname format)
let username: string | undefined;
const atIndex = urlPart.indexOf('@');
if (atIndex !== -1) {
username = urlPart.substring(0, atIndex);
urlPart = urlPart.substring(atIndex + 1);
}
// The token is the last segment, everything else is hostname[:port][/path]
const lastSlashIndex = urlPart.lastIndexOf('/');
if (lastSlashIndex === -1) {
return { success: false, error: 'Invalid Mattermost URL format. Expected: mmost://[botname@]hostname[:port][/path]/token' };
}
const token = urlPart.substring(lastSlashIndex + 1);
const hostAndPath = urlPart.substring(0, lastSlashIndex);
const url = `${protocol}://${hostAndPath}/hooks/${token}`;
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
const body: Record<string, string> = {
text: `*${payload.title}*${envTag}\n${payload.message}`
};
if (username) {
body.username = username;
}
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Mattermost error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Mattermost connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+88
View File
@@ -0,0 +1,88 @@
/** ntfy.sh + self-hosted ntfy. ntfy:// or ntfys:// (HTTPS). */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// Supported formats:
// ntfy://topic (public ntfy.sh)
// ntfy://host/topic (custom server, no auth)
// ntfy://user:pass@host/topic (custom server with basic auth)
// ntfy://token@host/topic (custom server with bearer token)
// ntfy://host/topic?auth=BASE64 (custom server with base64-encoded bearer token)
// Query params: ?tags=ship,whale &title=Custom &priority=5
// ntfys:// variants for HTTPS
const isSecure = appriseUrl.startsWith('ntfys');
const path = appriseUrl.replace(/^ntfys?:\/\//, '');
let url: string;
let authHeader: string | null = null;
let queryAuth: string | null = null;
let queryTags: string | null = null;
let queryTitle: string | null = null;
let queryPriority: string | null = null;
let cleanPath = path;
const qIndex = path.indexOf('?');
if (qIndex !== -1) {
const params = new URLSearchParams(path.substring(qIndex + 1));
queryAuth = params.get('auth');
queryTags = params.get('tags');
queryTitle = params.get('title');
queryPriority = params.get('priority');
cleanPath = path.substring(0, qIndex);
}
const basicMatch = cleanPath.match(/^([^:]+):([^@]+)@(.+)$/);
if (basicMatch) {
const [, user, pass, hostAndTopic] = basicMatch;
const basic = Buffer.from(`${user}:${pass}`).toString('base64');
authHeader = `Basic ${basic}`;
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
} else if (cleanPath.includes('@') && cleanPath.includes('/')) {
const tokenMatch = cleanPath.match(/^([^@]+)@(.+)$/);
if (tokenMatch) {
const [, token, hostAndTopic] = tokenMatch;
authHeader = `Bearer ${token}`;
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
} else {
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
}
} else if (cleanPath.includes('/')) {
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
} else {
url = `https://ntfy.sh/${cleanPath}`;
}
if (!authHeader && queryAuth) {
const decoded = Buffer.from(queryAuth, 'base64').toString();
authHeader = decoded.startsWith('Bearer ') ? decoded : `Bearer ${decoded}`;
}
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
const defaultTags = payload.type || 'info';
const headers: Record<string, string> = {
'Title': queryTitle || titleWithEnv,
'Priority': queryPriority || (payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3'),
'Tags': queryTags ? `${queryTags},${defaultTags}` : defaultTags
};
if (authHeader) {
headers['Authorization'] = authHeader;
}
try {
const response = await fetch(url, {
method: 'POST',
headers,
body: payload.message
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `ntfy error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `ntfy connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+36
View File
@@ -0,0 +1,36 @@
/** Pushover. pushover://user_key/api_token. */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendPushover(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const match = appriseUrl.match(/^pushover:\/\/([^/]+)\/(.+)/);
if (!match) {
return { success: false, error: 'Invalid Pushover URL format. Expected: pushover://user_key/api_token' };
}
const [, userKey, apiToken] = match;
const url = 'https://api.pushover.net/1/messages.json';
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({
token: apiToken,
user: userKey,
title: titleWithEnv,
message: payload.message,
priority: payload.type === 'error' ? 1 : 0
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Pushover error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Pushover connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Shared types + helpers used by every notification provider.
*
* Imported by the router (./index.ts) and by every per-provider file
* (discord.ts, slack.ts, ). Keeps the providers free of cross-imports
* each provider only depends on this module.
*/
export interface NotificationPayload {
title: string;
message: string;
type?: 'info' | 'success' | 'warning' | 'error';
environmentId?: number;
environmentName?: string;
}
export interface NotificationResult {
success: boolean;
error?: string;
}
/** Drain a response body to release the underlying socket/TLS connection. */
export async function drainResponse(response: Response): Promise<void> {
if (!response.bodyUsed) {
try { await response.arrayBuffer(); } catch {}
}
}
/** Append `[env name]` to a title when present. Used by every provider. */
export function titleWithEnv(payload: NotificationPayload): string {
return payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Signal via bbernhard/signal-cli-rest-api
* (https://github.com/bbernhard/signal-cli-rest-api).
*
* Supported formats:
* signal://host[:port]/+source/+target1[/+target2/...]
* signals://host[:port]/+source/+target1[/+target2/...] (HTTPS)
*
* `+source` is the sender's registered Signal number (E.164 format). The '+'
* is optional in the URL we re-add it. Recipients can be Signal phone
* numbers (numeric, '+' gets added) or group IDs (signal-cli's "group.<base64>"
* form, passed through untouched).
*/
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendSignal(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const isSecure = appriseUrl.startsWith('signals');
const raw = appriseUrl.replace(/^signals?:\/\//, '');
// Strip query string so a future `?foo=bar` doesn't end up in the last
// recipient. Currently we don't honor any params, but the parsing should
// be forward-compatible.
const qIndex = raw.indexOf('?');
const cleanPath = qIndex === -1 ? raw : raw.substring(0, qIndex);
const parts = cleanPath.split('/').filter(Boolean);
if (parts.length < 3) {
return { success: false, error: 'Invalid Signal URL. Expected: signal://host[:port]/+source/+target1[/+target2/...]' };
}
const hostPort = parts[0];
// Phone numbers may or may not start with '+' in the URL — Signal needs
// the '+'. Group IDs (signal-cli's "group.<base64>" form) and other
// non-numeric recipients are passed through untouched.
const normalize = (n: string) => {
if (n.startsWith('+')) return n;
if (/^\d+$/.test(n)) return `+${n}`;
return n;
};
const source = normalize(parts[1]);
const recipients = parts.slice(2).map(normalize);
// signal-cli-rest-api uses 'message' for body and 'number' for sender;
// title is prepended to the body since Signal messages don't have a title field.
const titleWithEnv = payload.environmentName
? `${payload.title} [${payload.environmentName}]`
: payload.title;
const messageText = `${titleWithEnv}\n\n${payload.message}`;
const baseUrl = `${isSecure ? 'https' : 'http'}://${hostPort}`;
try {
const response = await fetch(`${baseUrl}/v2/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
number: source,
recipients,
message: messageText
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Signal error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Signal connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+34
View File
@@ -0,0 +1,34 @@
/** Slack incoming webhook. slack:// or slacks:// or a raw hooks.slack.com URL. */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendSlack(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// slack://token_a/token_b/token_c or webhook URL
let url: string;
if (appriseUrl.includes('hooks.slack.com')) {
url = appriseUrl.replace(/^slacks?:\/\//, 'https://');
} else {
const parts = appriseUrl.replace(/^slacks?:\/\//, '').split('/');
url = `https://hooks.slack.com/services/${parts.join('/')}`;
}
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `*${payload.title}*${envTag}\n${payload.message}`
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Slack error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Slack connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+48
View File
@@ -0,0 +1,48 @@
/** SMTP email notifications via nodemailer. */
import nodemailer from 'nodemailer';
import type { SmtpConfig } from '../db';
import type { NotificationPayload, NotificationResult } from './shared';
export async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise<NotificationResult> {
try {
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.username ? {
user: config.username,
pass: config.password
} : undefined,
tls: config.skipTlsVerify ? {
rejectUnauthorized: false
} : undefined
});
const envBadge = payload.environmentName
? `<span style="display: inline-block; background: #3b82f6; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px;">${payload.environmentName}</span>`
: '';
const envText = payload.environmentName ? ` [${payload.environmentName}]` : '';
const html = `
<div style="font-family: sans-serif; padding: 20px;">
<h2 style="margin: 0 0 10px 0;">${payload.title}${envBadge}</h2>
<p style="margin: 0; white-space: pre-wrap;">${payload.message}</p>
<hr style="margin: 20px 0; border: none; border-top: 1px solid #eee;">
<p style="margin: 0; font-size: 12px; color: #666;">Sent by Dockhand</p>
</div>
`;
await transporter.sendMail({
from: config.from_name ? `"${config.from_name}" <${config.from_email}>` : config.from_email,
to: config.to_emails.join(', '),
subject: `[Dockhand]${envText} ${payload.title}`,
text: `${payload.title}${envText}\n\n${payload.message}`,
html
});
return { success: true };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return { success: false, error: `SMTP error: ${errorMsg}` };
}
}
+43
View File
@@ -0,0 +1,43 @@
/** Telegram bot. tgram://bot_token/chat_id[:topic_id]. */
import { escapeTelegramMarkdown, parseTelegramUrl } from '$lib/utils/notification-parsers';
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export 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' };
}
const { botToken, chatId, topicId } = parsed;
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
const escapedTitle = escapeTelegramMarkdown(payload.title);
const escapedMessage = escapeTelegramMarkdown(payload.message);
const envTag = payload.environmentName ? ` [${escapeTelegramMarkdown(payload.environmentName)}]` : '';
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text: `*${escapedTitle}*${envTag}\n${escapedMessage}`,
...(topicId ? { message_thread_id: topicId } : {}),
parse_mode: 'Markdown',
link_preview_options: {
is_disabled: true
}
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({})) as { description?: string };
const errorMsg = errorData.description || response.statusText;
return { success: false, error: `Telegram error ${response.status}: ${errorMsg}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Telegram connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+56
View File
@@ -0,0 +1,56 @@
/** Microsoft Power Automate Workflows (e.g. Microsoft Teams). workflows://. */
import { parseWorkflowsUrl, buildWorkflowsHttpUrl } from '$lib/utils/notification-parsers';
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export 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)}` };
}
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Encode the AuthConfig JSON as base64url **with `=` padding** for the
* Docker X-Registry-Auth header. The Docker daemon decodes the header with
* Go's `base64.URLEncoding.DecodeString`, which is base64url with padding
* unpadded base64url (Node's default 'base64url' Buffer encoding) is
* silently treated as malformed, causing the daemon to fall back to
* anonymous and trip the registry rate limit (#1105).
*
* Reference: moby/api/pkg/authconfig/authconfig.go uses
* `base64.URLEncoding.EncodeToString` / `DecodeString`.
*/
export function encodeRegistryAuth(authConfig: object): string {
const unpadded = Buffer.from(JSON.stringify(authConfig)).toString('base64url');
return unpadded + '='.repeat((4 - (unpadded.length % 4)) % 4);
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Detect the on-host Docker socket path for a remote daemon (#1076).
*
* Used by the vulnerability scanner when it needs to bind-mount the daemon
* socket into a helper container running on that daemon. Docker daemons use
* /var/run/docker.sock; Podman uses /run/podman/podman.sock (rootful) or
* /run/user/UID/podman/podman.sock (rootless). Hardcoding /var/run/docker.sock
* breaks Podman with a mkdir-permission-denied error.
*
* Detection runs against the remote daemon over the same connection
* Dockhand already uses (socket / direct TCP / Hawser), so no agent change
* is required.
*
* Result is cached per envId for 5 minutes daemon identity doesn't change
* during a process lifetime in practice, but the short TTL lets us recover
* if the user reconfigures an env to point at a different daemon.
*/
import { dockerFetch } from './docker';
const CACHE_TTL_MS = 5 * 60 * 1000;
const cache = new Map<number, { path: string; expires: number }>();
const DEFAULT_DOCKER_SOCKET = '/var/run/docker.sock';
const PODMAN_ROOTFUL_SOCKET = '/run/podman/podman.sock';
export function clearRemoteSocketCache(envId?: number): void {
if (envId === undefined) cache.clear();
else cache.delete(envId);
}
/**
* Returns the absolute path to the daemon's API socket on its own host.
*
* Best-effort: any failure falls back to /var/run/docker.sock, which matches
* the historic behaviour and is correct for stock Docker.
*/
export async function detectRemoteSocketPath(envId: number | undefined): Promise<string> {
if (envId === undefined) return DEFAULT_DOCKER_SOCKET;
const cached = cache.get(envId);
if (cached && cached.expires > Date.now()) return cached.path;
let path = DEFAULT_DOCKER_SOCKET;
try {
const isPodman = await daemonIsPodman(envId);
if (isPodman) {
path = (await detectPodmanSocketPath(envId)) ?? PODMAN_ROOTFUL_SOCKET;
}
} catch (err) {
console.warn(
`[Scanner] detectRemoteSocketPath(env=${envId}) failed, defaulting to ${DEFAULT_DOCKER_SOCKET}:`,
(err as Error)?.message ?? err
);
}
cache.set(envId, { path, expires: Date.now() + CACHE_TTL_MS });
return path;
}
/**
* Returns true when the remote daemon identifies itself as Podman.
* Used by both the scanner socket-path detection and the env list pill.
* Any transport / parse failure returns false callers treat "unknown"
* as "assume Docker" so a transient network hiccup never breaks the UI.
*/
export async function daemonIsPodman(envId: number): Promise<boolean> {
try {
// Docker-compat /version returns Components[].Name. Podman labels
// itself "Podman Engine"; Docker uses "Engine".
const res = await dockerFetch('/version', {}, envId);
if (!res.ok) return false;
const data = (await res.json()) as { Components?: Array<{ Name?: string }> };
const components = data.Components ?? [];
return components.some((c) => typeof c?.Name === 'string' && c.Name.includes('Podman'));
} catch {
return false;
}
}
interface PodmanLibpodInfo {
host?: {
security?: { rootless?: boolean };
idMappings?: { uidmap?: Array<{ host_id?: number }> };
};
}
async function detectPodmanSocketPath(envId: number): Promise<string | null> {
// Podman's native /libpod/info exposes rootless flag + uid mapping.
// Versioned path: /v4.0.0/libpod/info works across all Podman 4.x/5.x.
const res = await dockerFetch('/v4.0.0/libpod/info', {}, envId);
if (!res.ok) return null;
const info = (await res.json()) as PodmanLibpodInfo;
const isRootless = info.host?.security?.rootless === true;
if (!isRootless) return PODMAN_ROOTFUL_SOCKET;
// The first uidmap entry's host_id is the user the daemon runs as.
// Example uidmap: [{ container_id: 0, host_id: 1000, size: 1 }, ...]
const uid = info.host?.idMappings?.uidmap?.[0]?.host_id;
if (typeof uid !== 'number' || !Number.isInteger(uid) || uid < 0) {
// No usable uid — leave it to the caller's default
return null;
}
return `/run/user/${uid}/podman/podman.sock`;
}
+44 -6
View File
@@ -16,13 +16,15 @@ import {
} from './docker';
import { getEnvironment, getEnvSetting, getSetting } from './db';
import { sendEventNotification } from './notifications';
import { detectRemoteSocketPath } from './scanner-socket-detect';
import {
getHostDockerSocket,
getHostDataDir,
extractUidFromSocketPath,
getOwnDockerHost,
getOwnExtraHosts,
getOwnNetworkMode
getOwnNetworkMode,
getOwnAllNetworks
} from './host-path';
import { resolve } from 'node:path';
import { mkdir, chown, rm } from 'node:fs/promises';
@@ -632,7 +634,7 @@ async function runScannerContainerCore(
let rootlessUid: string | undefined;
let scannerNetworkMode: string | undefined;
let scannerDockerHost: string | undefined;
const scannerExtraHosts = !isHawser ? getOwnExtraHosts() ?? undefined : undefined;
let 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.
@@ -641,9 +643,19 @@ async function runScannerContainerCore(
const ownDockerHost = getOwnDockerHost();
if (!isHawser && ownDockerHost?.startsWith('tcp://')) {
// TCP mode: scanner uses the same DOCKER_HOST + network as Dockhand
// TCP mode: scanner uses the same DOCKER_HOST + network as Dockhand.
scannerDockerHost = ownDockerHost;
scannerNetworkMode = getOwnNetworkMode() ?? undefined;
const allNets = getOwnAllNetworks();
if (allNets.length > 1) {
// Multiple custom networks — if socket-proxy lives on a network
// other than the one we picked, DNS will fail. Make the choice
// visible so users with split-network setups can colocate
// socket-proxy with Dockhand on the primary network (#1011).
console.warn(
`[Scanner] Dockhand is on multiple networks (${allNets.join(', ')}); scanner will only join "${scannerNetworkMode}". If DOCKER_HOST=${scannerDockerHost} fails to resolve, put socket-proxy on this network.`
);
}
console.log(
`[Scanner] TCP mode (from container inspect) - DOCKER_HOST=${scannerDockerHost}, network=${scannerNetworkMode ?? 'default'}`
);
@@ -651,9 +663,35 @@ async function runScannerContainerCore(
console.log(`[Scanner] Reusing ExtraHosts from Dockhand: ${scannerExtraHosts.join(', ')}`);
}
} else if (isHawser) {
// Hawser: scanner runs on remote host, uses remote host's standard Docker socket
hostSocketPath = '/var/run/docker.sock';
console.log(`[Scanner] Remote scan via Hawser (${connectionType}) - using standard socket path`);
// Hawser: scanner runs on remote host. Detect the actual socket path
// because rootless Podman uses /run/user/UID/podman/podman.sock, not
// /var/run/docker.sock (#1076). Falls back to the standard path on
// detection failure — no regression for stock Docker hosts.
hostSocketPath = await detectRemoteSocketPath(envId);
console.log(`[Scanner] Remote scan via Hawser (${connectionType}) - detected socket path: ${hostSocketPath}`);
} else if (connectionType === 'direct' && env?.host) {
// Direct TCP to a remote daemon (e.g. Docker over TCP, Podman over TCP).
// The scanner container is created on the REMOTE daemon, not on
// Dockhand's host. Binding "/var/run/docker.sock" from Dockhand's
// host into a remote-daemon container is nonsense — on remote Docker
// it silently creates an empty dir (scans fall back to registry); on
// rootless Podman it errors with "mkdir /var/run/docker.sock:
// permission denied" (#1076, #1011). Instead, tell the scanner to
// talk to the daemon over the same TCP endpoint Dockhand uses.
// `host.containers.internal` resolves to the daemon's host from
// inside the scanner container on both Docker (with `host-gateway`)
// and Podman (built-in).
scannerDockerHost = `tcp://host.containers.internal:${env.port}`;
// Add the host-gateway mapping so Docker honours the hostname.
// Podman recognises host.containers.internal natively; the extra
// mapping is harmless there.
scannerExtraHosts = [
...(scannerExtraHosts ?? []),
'host.containers.internal:host-gateway'
];
console.log(
`[Scanner] Direct TCP env (${env.protocol ?? 'http'}://${env.host}:${env.port}) - DOCKER_HOST=${scannerDockerHost} (#1076, #1011)`
);
} else {
// Local socket — detect host socket path (handles rootless Docker)
hostSocketPath = getHostDockerSocket();
@@ -38,7 +38,7 @@ 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';
import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
// =============================================================================
// TYPES
@@ -382,6 +382,18 @@ export async function runContainerUpdate(
return;
}
// Hidden containers are excluded from update polling and auto-updates (#1083)
if (isHiddenByLabel(inspectData.Config?.Labels)) {
log(`Skipping - dockhand.hidden=true label set on container`);
await updateScheduleExecution(execution.id, {
status: 'skipped',
completedAt: new Date().toISOString(),
duration: Date.now() - startTime,
details: { reason: 'Skipped by dockhand.hidden=true 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,7 @@ 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 { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
import { recreateContainer } from './container-update';
interface UpdateInfo {
@@ -105,9 +105,12 @@ export async function runEnvUpdateCheckJob(
// Clear pending updates at the start - we'll re-add as we discover updates
await clearPendingContainerUpdates(environmentId);
// Get all containers in this environment
const containers = await listContainers(true, environmentId);
await log(`Found ${containers.length} containers`);
// Get all containers in this environment, excluding ones hidden via
// dockhand.hidden=true (consistent with manual check-updates, #1083).
const allContainers = await listContainers(true, environmentId);
const containers = allContainers.filter(c => !isHiddenByLabel(c.labels));
const hiddenCount = allContainers.length - containers.length;
await log(`Found ${containers.length} containers${hiddenCount ? ` (${hiddenCount} hidden by label)` : ''}`);
const updatesAvailable: UpdateInfo[] = [];
let checkedCount = 0;
@@ -204,7 +207,7 @@ export async function runEnvUpdateCheckJob(
.map(u => {
const currentShort = u.currentDigest?.substring(0, 12) || 'unknown';
const newShort = u.newDigest?.substring(0, 12) || 'unknown';
return `- ${u.containerName} (${u.imageName})\n ${currentShort}... -> ${newShort}...`;
return `- ${u.containerName} (${u.imageName}): ${currentShort} ${newShort}`;
})
.join('\n');
+394 -33
View File
@@ -5,10 +5,19 @@
* All lifecycle operations use docker compose commands.
*/
import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve, dirname, basename } from 'node:path';
import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync, realpathSync } from 'node:fs';
import { join, resolve, dirname, basename, normalize as pathNormalize, sep as pathSep } from 'node:path';
import { spawn as nodeSpawn } from 'node:child_process';
import type { ChildProcess } from 'node:child_process';
import {
applyFileDeletions,
hashDirFiles,
skipReasonMessage,
normalizeSkipReason,
type FileToDelete,
type DeletionApplyResult,
type DeletionSkipReason
} from './git-deletions';
import {
getEnvironment,
getSecretEnvVarsAsRecord,
@@ -25,12 +34,15 @@ import {
removePendingContainerUpdate,
deleteAutoUpdateSchedule,
getAutoUpdateSetting,
getStackSourceByComposePath
getStackSourceByComposePath,
getExternalStackPaths,
addExternalStackPath
} from './db';
import { unregisterSchedule } from './scheduler';
import { deleteGitStackFiles, parseEnvFileContent } from './git';
import { cleanPem } from '$lib/utils/pem';
import { rewriteComposeVolumePaths, getHostDataDir } from './host-path';
import { getOrderValue } from './container-labels';
// =============================================================================
// TYPES
@@ -60,6 +72,8 @@ export interface StackOperationResult {
error?: string;
/** The docker compose command that was executed (for debugging/testing) */
command?: string;
/** Result of applying git deletion sync (files removed / kept, with reasons) */
deletion?: DeletionApplyResult;
}
/**
@@ -77,7 +91,9 @@ export interface ContainerDetail {
networks: Array<{ name: string; ipAddress: string }>;
volumeCount: number;
restartCount: number;
exitCode?: number;
created: number;
labels: Record<string, string>;
}
/**
@@ -108,6 +124,8 @@ export interface DeployStackOptions {
envPath?: string; // Custom env file path (for adopted/imported stacks)
composeFileName?: string; // Compose filename to use (e.g., "docker-compose.yaml") for git stacks
envFileName?: string; // Env filename relative to compose dir (e.g., ".env") for git stacks
/** Git deletion sync (#966): files confirmed safe to delete from the stack dir */
filesToDelete?: FileToDelete[];
}
// =============================================================================
@@ -227,8 +245,14 @@ function collectProcess(proc: ChildProcess): Promise<{ exitCode: number; stdout:
* Used to send files to Hawser for remote deployments.
* Binary files are base64-encoded with a "base64:" prefix to preserve all bytes.
*/
// Max file size: 10 MB per file, 256 MB total payload
const MAX_FILE_SIZE = 10 * 1024 * 1024;
const MAX_TOTAL_SIZE = 256 * 1024 * 1024;
async function readDirFilesAsMap(dirPath: string): Promise<Record<string, string>> {
const files: Record<string, string> = {};
let totalSize = 0;
const skipped: string[] = [];
async function scanDir(currentPath: string, relativePath: string = ''): Promise<void> {
const entries = readdirSync(currentPath, { withFileTypes: true });
@@ -241,7 +265,21 @@ async function readDirFilesAsMap(dirPath: string): Promise<Record<string, string
if (entry.name === '.git') continue;
await scanDir(fullPath, relPath);
} else if (entry.isFile()) {
const fileSize = statSync(fullPath).size;
if (fileSize > MAX_FILE_SIZE) {
skipped.push(`${relPath} (${(fileSize / 1024 / 1024).toFixed(1)} MB)`);
continue;
}
if (totalSize + fileSize > MAX_TOTAL_SIZE) {
skipped.push(`${relPath} (would exceed ${MAX_TOTAL_SIZE / 1024 / 1024} MB total limit)`);
continue;
}
const bytes = readFileSync(fullPath);
totalSize += fileSize;
if (isBinaryContent(bytes)) {
files[relPath] = `base64:${bytes.toString('base64')}`;
} else {
@@ -252,6 +290,11 @@ async function readDirFilesAsMap(dirPath: string): Promise<Record<string, string
}
await scanDir(dirPath);
if (skipped.length > 0) {
console.log(`[readDirFilesAsMap] Skipped ${skipped.length} file(s) exceeding size limits: ${skipped.join(', ')}`);
}
return files;
}
@@ -307,6 +350,125 @@ export async function getStackDir(stackName: string, envId?: number | null): Pro
return join(stacksDir, stackName);
}
/**
* Filenames a stack is allowed to write. Compose files in the conventional
* names + .env. Anything else is rejected even when the directory is in
* the allowlist, so this code path can only ever produce stack-shaped files.
*/
const ALLOWED_STACK_FILENAMES = new Set([
'docker-compose.yml',
'docker-compose.yaml',
'compose.yml',
'compose.yaml',
'.env'
]);
/**
* Resolve a path against the parent's realpath when the parent exists, so
* symlinks resolve to their canonical location. We can't realpath the leaf
* because the file may not exist yet (new stack).
*/
function resolveStackPath(input: string): string {
const abs = resolve(input);
const parent = dirname(abs);
try {
if (existsSync(parent)) {
return join(realpathSync(parent), basename(abs));
}
} catch {
// realpath may fail on permission errors; fall through to the plain resolve.
}
return abs;
}
function isInside(child: string, parent: string): boolean {
const c = pathNormalize(child);
const p = pathNormalize(parent);
if (c === p) return true;
return c.startsWith(p.endsWith(pathSep) ? p : p + pathSep);
}
export interface StackPathValidation {
ok: boolean;
error?: string;
resolved?: string;
}
/**
* Validate that a custom compose or env file path is writable by this code
* path. A path is accepted when it lives inside getStacksDir() or any
* directory in the external_stack_paths allowlist (admin-controlled, set
* in Settings General), its filename is one of the conventional
* compose/env names, and it does not contain .. segments. The parent is
* resolved via realpath so a symlinked component inside an allowlisted dir
* cannot point elsewhere.
*
* Callers that need to grandfather an existing admin-configured path should
* call validateStackPathWithGrandfather() instead.
*/
export async function validateStackPath(input: string): Promise<StackPathValidation> {
if (!input || typeof input !== 'string') {
return { ok: false, error: 'Path is required' };
}
const resolvedPath = resolveStackPath(input);
// Normalized form must not contain a .. segment.
const segments = pathNormalize(resolvedPath).split(pathSep);
if (segments.includes('..')) {
return { ok: false, error: 'Path traversal not allowed' };
}
const filename = basename(resolvedPath);
if (!ALLOWED_STACK_FILENAMES.has(filename)) {
return {
ok: false,
error: `File "${filename}" is not an allowed stack filename (expected one of: ${[...ALLOWED_STACK_FILENAMES].join(', ')})`
};
}
const stacksDir = getStacksDir();
if (isInside(resolvedPath, stacksDir)) {
return { ok: true, resolved: resolvedPath };
}
const allowlist = await getExternalStackPaths();
for (const dir of allowlist) {
if (!dir) continue;
const dirResolved = resolve(dir);
if (isInside(resolvedPath, dirResolved)) {
return { ok: true, resolved: resolvedPath };
}
}
return {
ok: false,
error: `Path "${resolvedPath}" is not inside an allowed stack directory. Add its parent directory in Settings → General → External stack paths.`
};
}
/**
* Same as validateStackPath, but when the path comes from an existing
* stack source row (a stored custom path the admin set previously) and
* fails the allowlist check, add its parent dir to external_stack_paths
* and re-validate. Lets old custom-path configurations keep working
* without operator intervention.
*/
export async function validateStackPathWithGrandfather(
input: string,
isPreExisting: boolean
): Promise<StackPathValidation> {
const first = await validateStackPath(input);
if (first.ok || !isPreExisting) return first;
const parent = dirname(resolveStackPath(input));
const added = await addExternalStackPath(parent);
if (added) {
console.log(`[Stack] Grandfathered pre-existing custom path: ${parent}`);
}
return validateStackPath(input);
}
/**
* Find stack directory, checking paths in order:
* 1. Database: Custom composePath in stackSources table (adopted/imported stacks)
@@ -513,6 +675,31 @@ export async function saveStackComposeFile(
const source = await getStackSource(name, envId);
const composePath = options?.composePath || source?.composePath;
// Path-allowlist validation. Pre-existing stored paths grandfather into
// the allowlist; new caller-supplied paths must already be inside it.
// See validateStackPath() docs.
if (composePath) {
const fromDb = !options?.composePath && !!source?.composePath;
const v = await validateStackPathWithGrandfather(composePath, fromDb);
if (!v.ok) return { success: false, error: v.error };
}
if (options?.envPath) {
const v = await validateStackPath(options.envPath);
if (!v.ok) return { success: false, error: v.error };
} else if (source?.envPath && !options?.envPath) {
// Grandfather a DB-stored env path that may have been configured before validation.
const v = await validateStackPathWithGrandfather(source.envPath, true);
if (!v.ok) return { success: false, error: v.error };
}
if (options?.oldComposePath) {
const v = await validateStackPathWithGrandfather(options.oldComposePath, true);
if (!v.ok) return { success: false, error: v.error };
}
if (options?.oldEnvPath) {
const v = await validateStackPathWithGrandfather(options.oldEnvPath, true);
if (!v.ok) return { success: false, error: v.error };
}
// Handle compose file move/rename when path changes
if (options?.oldComposePath && options?.composePath &&
options.oldComposePath !== options.composePath &&
@@ -802,6 +989,10 @@ interface ComposeCommandOptions {
serviceName?: string;
/** Compose filename for Hawser (e.g., "docker-compose.prod.yml") - extracted from composePath */
composeFileName?: string;
/** Git deletion sync (#966): files to delete on the Hawser agent's stack dir */
filesToDelete?: FileToDelete[];
/** On down: ask the Hawser agent to remove the stack directory entirely (#1162, stack deletion only) */
removeFiles?: boolean;
}
/**
@@ -827,6 +1018,16 @@ function findComposeOverrideFile(stackDir: string, composeFileName: string): str
/**
* Execute a docker compose command locally via child_process.spawn.
*
* Heads up on paths: `stackDir` is the cpSync target / fallback working
* directory, but it's not always where the compose file lives git stacks
* with a contextDir can put the compose file in a subdirectory. Anything
* compose-adjacent (spawn cwd, .env discovery, compose.override.yaml
* lookup, .env.dockhand write, volume-path rewriter) anchors on
* `composeFileDir = dirname(composeFile)`. The two are equal for the
* common case and the change is transparent; only the subdir case is
* affected. If you add anything new that touches a compose-adjacent file,
* use `composeFileDir`, not `stackDir`.
*
* @param tlsConfig - TLS configuration for remote Docker connections (certs written to temp files)
* @param envVars - Non-secret environment variables (from .env file, passed for backward compat)
* @param secretVars - Secret environment variables (injected via shell env, NEVER written to disk)
@@ -879,13 +1080,22 @@ async function executeLocalCompose(
writeFileSync(composeFile, composeContent);
}
// Anchor for everything compose-adjacent: the directory the compose file
// itself lives in. Equal to stackDir for the common case (compose at
// stack root), but different when a git stack puts the compose file in
// a subdirectory of the context dir. Bugs #1136 and #1139 both stemmed
// from anchoring on stackDir instead of this.
const composeFileDir = dirname(composeFile);
// Rewrite relative volume paths for host path translation (in memory only, not saved to disk)
// This is needed when Dockhand runs inside Docker - the Docker daemon on the host
// can't see container paths like /app/data/..., so we translate them to host paths
// Only do this for local Docker (no dockerHost) - for remote Docker the paths wouldn't make sense
// Resolve relative paths against the COMPOSE FILE'S directory, not stackDir, so
// subdir compose files with ./ and ../ binds resolve correctly (#1139).
let finalComposeContent = composeContent;
if (!dockerHost && getHostDataDir()) {
const rewriteResult = rewriteComposeVolumePaths(composeContent, stackDir);
const rewriteResult = rewriteComposeVolumePaths(composeContent, composeFileDir);
if (rewriteResult.modified) {
finalComposeContent = rewriteResult.content;
console.log(`${logPrefix} [HostPath] Translating relative volume paths for Docker host:`);
@@ -923,9 +1133,25 @@ async function executeLocalCompose(
}
// Check if .env file exists on disk (for legacy support decision)
const defaultEnvPath = join(stackDir, '.env');
const defaultEnvPath = join(composeFileDir, '.env');
const hasEnvFile = existsSync(defaultEnvPath) || (customEnvPath && existsSync(customEnvPath));
// One-line audit of all path notions used below. Next time something is
// off (compose can't find a file, volume bind points at the wrong
// place, env vars don't reach the container), grep for "[PathAudit]"
// in the log — the mismatch is usually obvious. The "subdir=yes" flag
// is the canary for the case where stackDir and composeFileDir diverge.
console.log(
`${logPrefix} [PathAudit] ` +
`stackDir=${stackDir} ` +
`composeFile=${composeFile} ` +
`composeFileDir=${composeFileDir} ` +
`subdir=${composeFileDir !== stackDir ? 'yes' : 'no'} ` +
`defaultEnvPath=${defaultEnvPath} (exists=${existsSync(defaultEnvPath)}) ` +
`customEnvPath=${customEnvPath ?? '(none)'}` +
(customEnvPath ? ` (exists=${existsSync(customEnvPath)})` : '')
);
// LEGACY SUPPORT: Only inject envVars via shell if NO .env file exists
// This is for stacks created with older Dockhand versions that stored env vars
// in DB but didn't write .env files to disk.
@@ -987,14 +1213,14 @@ async function executeLocalCompose(
// Host path translation: must pipe modified content via stdin
args.push('-f', '-');
// Also include override file if it exists (needs path translation too)
const overrideFile = findComposeOverrideFile(stackDir, basename(composeFile));
const overrideFile = findComposeOverrideFile(composeFileDir, basename(composeFile));
if (overrideFile) {
let overrideContent = readFileSync(overrideFile, 'utf-8');
if (getHostDataDir()) {
const rewrite = rewriteComposeVolumePaths(overrideContent, stackDir);
const rewrite = rewriteComposeVolumePaths(overrideContent, composeFileDir);
if (rewrite.modified) overrideContent = rewrite.content;
}
tempOverridePath = join(stackDir, '.compose.override.translated.yaml');
tempOverridePath = join(composeFileDir, '.compose.override.translated.yaml');
writeFileSync(tempOverridePath, overrideContent);
args.push('-f', tempOverridePath);
console.log(`${logPrefix} Including override file (path-translated): ${basename(overrideFile)}`);
@@ -1002,7 +1228,7 @@ async function executeLocalCompose(
} else if (customComposePath) {
// Custom path (imported/adopted stacks): must use -f to point to non-standard location
args.push('-f', composeFile);
const overrideFile = findComposeOverrideFile(stackDir, basename(composeFile));
const overrideFile = findComposeOverrideFile(composeFileDir, basename(composeFile));
if (overrideFile) {
args.push('-f', overrideFile);
console.log(`${logPrefix} Including override file: ${basename(overrideFile)}`);
@@ -1028,7 +1254,7 @@ async function executeLocalCompose(
// Only written when useOverrideFile is true (git stacks). Internal/adopted stacks
// already have their non-secrets in the .env file written by the UI.
if (useOverrideFile && envVars && Object.keys(envVars).length > 0) {
const overrideEnvPath = join(stackDir, '.env.dockhand');
const overrideEnvPath = join(composeFileDir, '.env.dockhand');
const header = '# Auto-generated by Dockhand. Do not edit - changes will be overwritten on next deploy.\n';
const lines = Object.entries(envVars).map(([k, v]) => `${k}=${v}`);
writeFileSync(overrideEnvPath, header + lines.join('\n') + '\n');
@@ -1098,9 +1324,9 @@ async function executeLocalCompose(
}
try {
console.log(`${logPrefix} Spawning docker compose process...`);
console.log(`${logPrefix} Spawning docker compose process from ${composeFileDir}: ${args.join(' ')}`);
const proc = nodeSpawn(args[0], args.slice(1), {
cwd: stackDir,
cwd: composeFileDir,
env: spawnEnv,
stdio: [useStdin ? 'pipe' : 'inherit', 'pipe', 'pipe']
});
@@ -1222,7 +1448,9 @@ async function executeComposeViaHawser(
composeFileName?: string,
build?: boolean,
noBuildCache?: boolean,
pullPolicy?: string
pullPolicy?: string,
filesToDelete?: FileToDelete[],
removeFiles?: boolean
): Promise<StackOperationResult> {
const logPrefix = `[Stack:${stackName}]`;
// Import dockerFetch dynamically to avoid circular dependency
@@ -1299,7 +1527,14 @@ async function executeComposeViaHawser(
noBuildCache: (build && noBuildCache) || false,
pullPolicy: pullPolicy || '',
registries, // Registry credentials for docker login
serviceName // Target specific service only (with --no-deps)
serviceName, // Target specific service only (with --no-deps)
// Git deletion sync (#966): agent re-verifies containment + content
// hash per file before deleting. Old agents ignore this field.
filesToDelete: filesToDelete && filesToDelete.length > 0
? filesToDelete.map(f => ({ path: f.path, sha256: f.hash }))
: undefined,
// Stack deletion (#1162): remove the agent-side stack dir on down
removeFiles: removeFiles || false
});
console.log(`${logPrefix} Sending request to Hawser agent...`);
@@ -1317,6 +1552,8 @@ async function executeComposeViaHawser(
success: boolean;
output?: string;
error?: string;
deletedFiles?: string[];
skippedFiles?: { path: string; reason: string }[];
};
console.log(`${logPrefix} ----------------------------------------`);
@@ -1330,24 +1567,61 @@ async function executeComposeViaHawser(
console.log(`${logPrefix} Error:`, result.error);
}
// Git deletion sync: interpret the agent's report. An agent that supports
// the feature always returns deletedFiles/skippedFiles (possibly empty
// arrays) when filesToDelete was sent. An old agent ignores the field and
// returns neither — every requested deletion is marked agent-no-support.
// Skips are FINAL (no carry-forward, no retry): the files stay on the
// remote host as unmanaged residue, identical to pre-feature behavior.
let deletion: DeletionApplyResult | undefined;
if (filesToDelete && filesToDelete.length > 0) {
if (result.deletedFiles !== undefined || result.skippedFiles !== undefined) {
deletion = {
deleted: result.deletedFiles ?? [],
skipped: (result.skippedFiles ?? []).map(s => ({
path: s.path,
reason: normalizeSkipReason(s.reason || 'apply-failed')
}))
};
for (const path of deletion.deleted) {
console.log(`${logPrefix} Agent removed "${path}" — deleted from the repository`);
}
for (const skip of deletion.skipped) {
if (skip.reason === 'already-absent') continue;
console.warn(`${logPrefix} Agent kept "${skip.path}" — ${skipReasonMessage(skip.reason)}`);
}
} else {
deletion = {
deleted: [],
skipped: filesToDelete.map(f => ({ path: f.path, reason: 'agent-no-support' as DeletionSkipReason }))
};
console.warn(`${logPrefix} ${skipReasonMessage('agent-no-support')} (${filesToDelete.length} file(s) affected)`);
}
}
if (result.success) {
return {
success: true,
output: result.output || `Stack "${stackName}" ${operation} completed via Hawser`
output: result.output || `Stack "${stackName}" ${operation} completed via Hawser`,
deletion
};
} else {
return {
success: false,
output: result.output || '',
error: result.error || `Compose ${operation} failed`
error: result.error || `Compose ${operation} failed`,
deletion
};
}
} catch (err: any) {
console.log(`${logPrefix} EXCEPTION in executeComposeViaHawser:`, err.message);
const isStringLength = err.message?.includes('Invalid string length');
return {
success: false,
output: '',
error: `Failed to ${operation} via Hawser: ${err.message}`
error: isStringLength
? `Stack files too large to send via Hawser. The repository may contain large binary files. Consider using a .dockerignore or moving large files out of the compose directory.`
: `Failed to ${operation} via Hawser: ${err.message}`
};
}
}
@@ -1365,7 +1639,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, noBuildCache, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName, filesToDelete, removeFiles } = options;
// Get environment configuration
const env = envId ? await getEnvironment(envId) : null;
@@ -1455,7 +1729,9 @@ async function executeComposeCommand(
composeFileName,
build,
noBuildCache,
pullPolicy
pullPolicy,
filesToDelete,
removeFiles
);
}
@@ -1494,12 +1770,20 @@ async function executeComposeCommand(
}
case 'socket':
default:
default: {
// Honor the environment's configured socket path. Without this,
// docker compose falls back to /var/run/docker.sock regardless of
// the env's setting — wrong daemon for rootless/multi-socket hosts
// (#1172). Default '/var/run/docker.sock' is left as undefined so
// the CLI's own default applies (preserves existing behavior).
const sock = env.socketPath && env.socketPath !== '/var/run/docker.sock'
? `unix://${env.socketPath}`
: undefined;
return executeLocalCompose(
operation,
stackName,
composeContent,
undefined, // dockerHost
sock,
undefined, // tlsConfig
envVars,
secretVars,
@@ -1515,6 +1799,7 @@ async function executeComposeCommand(
noBuildCache,
pullPolicy
);
}
}
}
@@ -1545,6 +1830,12 @@ export async function listComposeStacks(envId?: number | null): Promise<ComposeS
const result: ComposeStackInfo[] = Array.from(stacks.entries()).map(([name, containerIds]) => {
const stackContainers = containers.filter((c) => containerIds.has(c.id));
const runningCount = stackContainers.filter((c) => c.state === 'running').length;
// Containers that exited with code 0 are "completed" (e.g., init/migration containers)
// and should not count against stack health
const completedCount = stackContainers.filter((c) =>
c.state === 'exited' && c.exitCode === 0
).length;
const activeTotal = stackContainers.length - completedCount;
const containerDetails: ContainerDetail[] = stackContainers
.map((c) => {
@@ -1580,21 +1871,30 @@ export async function listComposeStacks(envId?: number | null): Promise<ComposeS
networks,
volumeCount,
restartCount: c.restartCount || 0,
created: c.created
exitCode: c.exitCode,
created: c.created,
labels: c.labels || {}
};
})
.sort((a, b) => a.service.localeCompare(b.service));
.sort((a, b) => {
const orderA = getOrderValue(a.labels);
const orderB = getOrderValue(b.labels);
if (orderA !== orderB) return orderA - orderB;
return a.service.localeCompare(b.service);
});
return {
name,
containers: Array.from(containerIds),
containerDetails,
status:
runningCount === stackContainers.length
? 'running'
: runningCount === 0
? 'stopped'
: 'partial'
activeTotal === 0
? 'stopped'
: runningCount >= activeTotal
? 'running'
: runningCount === 0
? 'stopped'
: 'partial'
};
});
@@ -1860,7 +2160,11 @@ export async function startStack(
return withContainerFallback(stackName, envId, 'start');
}
const opts = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath };
// Check if this is a git stack - git stacks need useOverrideFile to write .env.dockhand
const source = await getStackSource(stackName, envId);
const isGitStack = source?.sourceType === 'git';
const opts: ComposeCommandOptions = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack };
// Check if containers exist for this stack. If they do, use 'start' to resume
// them (preserves container IDs, avoids Traefik race conditions from recreation).
@@ -1928,7 +2232,13 @@ export async function restartStack(
return withContainerFallback(stackName, envId, 'restart');
}
const opts: ComposeCommandOptions = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath };
// Git stacks need useOverrideFile to write .env.dockhand with DB overrides.
// Non-git stacks still pass nonSecretVars for legacy support (stacks without
// .env files on disk get vars injected via shell env at executeLocalCompose).
const source = await getStackSource(stackName, envId);
const isGitStack = source?.sourceType === 'git';
const opts: ComposeCommandOptions = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack };
let composeResult: StackOperationResult;
@@ -1997,6 +2307,24 @@ export async function removeStack(
if (composeResult.success) {
const envVars = await getNonSecretEnvVarsAsRecord(stackName, envId);
const secretVars = await getSecretEnvVarsAsRecord(stackName, envId);
// Stack removal cleanup (#1162): the agent deletes ONLY what Dockhand
// explicitly lists. The list is the local staging dir contents — exactly
// the files Dockhand ever wrote for this stack (compose, .env,
// .env.dockhand, git files), never user volume data (that exists only on
// the agent host). Each entry is hash-verified agent-side; the agent's
// stack dir is removed only if nothing else remains in it.
// Only built for Dockhand-managed staging dirs (inside DATA_DIR/stacks).
let removalFiles: FileToDelete[] | undefined;
if (composeResult.stackDir) {
const resolvedStaging = resolve(composeResult.stackDir);
if (resolvedStaging.startsWith(resolve(getStacksDir()) + '/')) {
removalFiles = Object.entries(hashDirFiles(resolvedStaging)).map(
([path, hash]) => ({ path, hash })
);
}
}
const downResult = await executeComposeCommand(
'down',
{
@@ -2005,7 +2333,10 @@ export async function removeStack(
removeVolumes,
workingDir: composeResult.stackDir,
composePath: composeResult.composePath ?? undefined,
envPath: composeResult.envPath ?? undefined
envPath: composeResult.envPath ?? undefined,
// Full stack removal: the Hawser agent cleans its stack dir (#1162)
removeFiles: true,
filesToDelete: removalFiles
},
composeResult.content!,
envVars,
@@ -2176,7 +2507,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, noBuildCache, pullPolicy, composePath, envPath, composeFileName, envFileName, filesToDelete } = options;
const logPrefix = `[Stack:${name}]`;
console.log(`${logPrefix} ========================================`);
@@ -2208,6 +2539,7 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
let actualComposePath: string | undefined;
let actualEnvPath: string | undefined = envPath; // Start with provided envPath (for adopted stacks)
let stackFiles: Record<string, string> | undefined;
let localDeletionResult: DeletionApplyResult | undefined;
if (composePath) {
// Adopted/imported stack: use the original compose file location
@@ -2260,6 +2592,21 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
filter: (src) => !src.includes('/.git/') && !src.endsWith('/.git')
});
console.log(`${logPrefix} Copied ${sourceDir} -> ${workingDir}`);
// Git deletion sync (#966): remove files that were deleted from the
// repository. The list is manifest entries absent from the new clone;
// the applier re-verifies containment + content hash per file, so
// volume data and locally modified files are never touched.
if (filesToDelete && filesToDelete.length > 0) {
localDeletionResult = applyFileDeletions(workingDir, filesToDelete);
for (const path of localDeletionResult.deleted) {
console.log(`${logPrefix} Removed "${path}" — deleted from the repository`);
}
for (const skip of localDeletionResult.skipped) {
if (skip.reason === 'already-absent') continue;
console.warn(`${logPrefix} Kept "${skip.path}" — ${skipReasonMessage(skip.reason)}`);
}
}
} else {
// Internal stack: check if a custom path exists in DB (adopted/imported stacks)
const source = await getStackSource(name, envId);
@@ -2331,7 +2678,8 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
envPath: actualEnvPath,
useOverrideFile: isGitStack,
// Pass compose filename for Hawser (extracted from path or provided explicitly)
composeFileName: composeFileName || (actualComposePath ? basename(actualComposePath) : undefined)
composeFileName: composeFileName || (actualComposePath ? basename(actualComposePath) : undefined),
filesToDelete
},
compose,
isGitStack ? dbNonSecretVars : undefined,
@@ -2347,6 +2695,11 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
if (result.error) {
console.log(`${logPrefix} Error:`, result.error);
}
// Deletion result: the remote (Hawser) result is authoritative when present;
// for local deployments the local applier's result is the truth.
if (!result.deletion && localDeletionResult) {
result.deletion = localDeletionResult;
}
return result;
});
}
@@ -2493,6 +2846,10 @@ export async function writeStackEnvFile(
envId?: number | null,
customEnvPath?: string
): Promise<void> {
if (customEnvPath) {
const v = await validateStackPath(customEnvPath);
if (!v.ok) throw new Error(v.error || 'Invalid env path');
}
let envFilePath: string;
if (customEnvPath) {
envFilePath = customEnvPath;
@@ -2538,6 +2895,10 @@ export async function writeRawStackEnvFile(
envId?: number | null,
customEnvPath?: string
): Promise<void> {
if (customEnvPath) {
const v = await validateStackPath(customEnvPath);
if (!v.ok) throw new Error(v.error || 'Invalid env path');
}
let envFilePath: string;
if (customEnvPath) {
envFilePath = customEnvPath;
+35
View File
@@ -98,6 +98,35 @@ const envNames: Map<number, string> = new Map();
// Track which envIds are currently configured in Go
const configuredEnvs: Set<number> = new Set();
// Health status transition tracking: only store DB events when status changes
// Key: `${envId}-${containerId}` → last known sub-status (e.g. "healthy", "unhealthy")
const lastHealthStatus: Map<string, string> = new Map();
/**
* Check if a health_status event represents a transition (and should be stored in DB).
* Non-health events always return true. Repeated identical health statuses return false.
* Also clears tracking on container destroy/die events.
*/
export function isHealthTransition(envId: number, containerId: string, action: string): boolean {
// Clear tracking when container is removed
if (action === 'destroy' || action === 'die') {
lastHealthStatus.delete(`${envId}-${containerId}`);
return true;
}
if (!action.startsWith('health_status')) return true;
// Extract sub-status: "health_status: healthy" → "healthy"
const subStatus = action.includes(':') ? action.split(':').pop()!.trim() : action;
const key = `${envId}-${containerId}`;
const previous = lastHealthStatus.get(key);
if (previous === subStatus) return false; // Same status, skip DB write
lastHealthStatus.set(key, subStatus);
return true;
}
// Dedup cleanup interval
let dedupCleanupInterval: ReturnType<typeof setInterval> | null = null;
@@ -265,6 +294,12 @@ async function handleContainerEvent(msg: GoMessage): Promise<void> {
const timestamp = new Date(Math.floor(event.timeNano / 1000000)).toISOString();
// Skip redundant health_status events (only store transitions: healthy↔unhealthy)
if (!isHealthTransition(msg.envId, containerId, action)) {
rssAfterOp('events', before);
return;
}
// Sub-category: DB insert
const dbBefore = rssBeforeOp();
try {
+64
View File
@@ -0,0 +1,64 @@
/**
* Single-file tar extraction for raw downloads (#1180).
*
* Docker's /archive endpoint always wraps file contents in a USTAR tar.
* When the user picks the "no archive" download format and the path is a
* regular file, we strip the wrapper and emit the bytes verbatim.
*
* Only handles the first regular-file entry the caller has already
* guaranteed (via stat) that the requested path is a single file, so the
* tar contains exactly one entry.
*/
/**
* Extract the bytes of the first regular file entry in a USTAR tar.
* Returns the file content as a Uint8Array.
*
* Throws when no regular file entry is found (e.g. the tar contained only
* a directory header) that's an unexpected state, since the caller is
* supposed to have already verified the path points to a file.
*/
export function extractFirstFileFromTar(tarData: Uint8Array): Uint8Array {
let offset = 0;
while (offset + 512 <= tarData.length) {
const header = tarData.subarray(offset, offset + 512);
// Two consecutive zero blocks mark end-of-archive.
if (isZeroBlock(header)) break;
const name = readString(header, 0, 100);
const sizeOctal = readString(header, 124, 12).trim();
const size = sizeOctal ? parseInt(sizeOctal, 8) : 0;
const typeFlag = header[156];
// Regular file: typeflag '0' (0x30) or NUL (0x00, legacy)
const isRegularFile = typeFlag === 0x30 || typeFlag === 0x00;
if (isRegularFile && name && size >= 0) {
const start = offset + 512;
const end = start + size;
if (end > tarData.length) {
throw new Error('Truncated tar archive');
}
return tarData.subarray(start, end);
}
// Skip header + content (padded to 512-byte boundary)
offset += 512 + Math.ceil(size / 512) * 512;
}
throw new Error('No regular file entry found in tar archive');
}
function isZeroBlock(block: Uint8Array): boolean {
for (let i = 0; i < block.length; i++) {
if (block[i] !== 0) return false;
}
return true;
}
function readString(buf: Uint8Array, offset: number, length: number): string {
let end = offset;
const limit = offset + length;
while (end < limit && buf[end] !== 0) end++;
return new TextDecoder('utf-8').decode(buf.subarray(offset, end));
}
+105
View File
@@ -0,0 +1,105 @@
import { db } from './db/drizzle';
import { asc } from 'drizzle-orm';
// Dynamic schema import (same pattern as db.ts)
const isPostgres = !!process.env.DATABASE_URL;
const schema = isPostgres
? await import('./db/schema/pg-schema.js')
: await import('./db/schema/index.js');
const { templateSources } = schema;
export interface TemplateSource {
id: number;
sourceId: string;
name: string;
url: string;
enabled: boolean;
builtin: boolean;
sortOrder: number;
}
export const DEFAULT_TEMPLATE_SOURCES: Omit<TemplateSource, 'id'>[] = [
// Large collections
{ sourceId: 'portainer-lissy93', name: 'Portainer templates (Lissy93)', url: 'https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json', enabled: true, builtin: true, sortOrder: 0 },
{ sourceId: 'ntv-one', name: 'NTV-One (consolidated)', url: 'https://raw.githubusercontent.com/ntv-one/portainer/main/template.json', enabled: false, builtin: true, sortOrder: 1 },
{ sourceId: 'mlva', name: 'MLVA (TheLustriVA)', url: 'https://raw.githubusercontent.com/TheLustriVA/portainer-templates-Nov-2022-collection/main/templates_2_2_rc_2_2.json', enabled: false, builtin: true, sortOrder: 2 },
{ sourceId: 'selfhostedpro', name: 'SelfHostedPro', url: 'https://raw.githubusercontent.com/SelfhostedPro/selfhosted_templates/master/Template/portainer-v2.json', enabled: false, builtin: true, sortOrder: 3 },
// Homelab / self-hosted
{ sourceId: 'portainer-qballjos', name: 'Qballjos (homelab)', url: 'https://raw.githubusercontent.com/Qballjos/portainer_templates/master/Template/template.json', enabled: false, builtin: true, sortOrder: 4 },
{ sourceId: 'lsio-technorabilia', name: 'LinuxServer.io (Technorabilia)', url: 'https://raw.githubusercontent.com/technorabilia/portainer-templates/main/lsio/templates/templates.json', enabled: true, builtin: true, sortOrder: 5 },
{ sourceId: 'mikestraney', name: 'MikeStraney', url: 'https://raw.githubusercontent.com/mikestraney/portainer-templates/master/templates.json', enabled: false, builtin: true, sortOrder: 6 },
// ARM / Raspberry Pi
{ sourceId: 'pi-hosted-amd64', name: 'Pi-Hosted (amd64)', url: 'https://raw.githubusercontent.com/pi-hosted/pi-hosted/master/template/portainer-v2-amd64.json', enabled: false, builtin: true, sortOrder: 7 },
{ sourceId: 'pi-hosted-arm64', name: 'Pi-Hosted (arm64)', url: 'https://raw.githubusercontent.com/pi-hosted/pi-hosted/master/template/portainer-v2-arm64.json', enabled: false, builtin: true, sortOrder: 8 },
];
/**
* Seed default sources into the library_sources table if empty.
*/
export async function seedTemplateSources(): Promise<void> {
const existing = await db.select().from(templateSources);
if (existing.length > 0) return;
for (const source of DEFAULT_TEMPLATE_SOURCES) {
await db.insert(templateSources).values({
sourceId: source.sourceId,
name: source.name,
url: source.url,
enabled: source.enabled,
builtin: source.builtin,
sortOrder: source.sortOrder,
});
}
}
export async function getTemplateSources(): Promise<TemplateSource[]> {
const rows = await db.select().from(templateSources).orderBy(asc(templateSources.sortOrder));
return rows.map(r => ({
id: r.id,
sourceId: r.sourceId,
name: r.name,
url: r.url,
enabled: r.enabled ?? true,
builtin: r.builtin ?? false,
sortOrder: r.sortOrder ?? 0,
}));
}
export async function updateTemplateSource(id: number, updates: { enabled?: boolean; name?: string; url?: string }): Promise<void> {
const { eq } = await import('drizzle-orm');
await db.update(templateSources)
.set({ ...updates, updatedAt: new Date().toISOString() })
.where(eq(templateSources.id, id));
}
export async function addTemplateSource(source: { name: string; url: string }): Promise<TemplateSource> {
const sourceId = `custom-${Date.now()}`;
const maxOrder = await db.select().from(templateSources).orderBy(asc(templateSources.sortOrder));
const nextOrder = maxOrder.length > 0 ? (maxOrder[maxOrder.length - 1].sortOrder ?? 0) + 1 : 0;
const result = await db.insert(templateSources).values({
sourceId,
name: source.name,
url: source.url,
enabled: true,
builtin: false,
sortOrder: nextOrder,
}).returning();
const r = result[0];
return {
id: r.id,
sourceId: r.sourceId,
name: r.name,
url: r.url,
enabled: r.enabled ?? true,
builtin: r.builtin ?? false,
sortOrder: r.sortOrder ?? 0,
};
}
export async function deleteTemplateSource(id: number): Promise<void> {
const { eq } = await import('drizzle-orm');
await db.delete(templateSources).where(eq(templateSources.id, id));
}
+96
View File
@@ -0,0 +1,96 @@
import { validateSessionById, isAuthEnabled, SESSION_COOKIE } from './auth';
import { validateApiToken } from './api-tokens';
import { isEnterprise } from './license';
import { userHasAdminRole, userCanAccessEnvironment } from './db';
export interface WsUpgradeAuth {
userId: number;
username: string;
isAdmin: boolean;
authDisabled: boolean;
}
function parseCookieHeader(header: string | undefined): Record<string, string> {
if (!header) return {};
const out: Record<string, string> = {};
for (const part of header.split(';')) {
const eq = part.indexOf('=');
if (eq < 0) continue;
const k = part.slice(0, eq).trim();
let v = part.slice(eq + 1).trim();
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
if (k) out[k] = decodeURIComponent(v);
}
return out;
}
type LowercasedHeaders = Record<string, string | string[] | undefined>;
function pickHeader(headers: LowercasedHeaders, name: string): string | undefined {
const v = headers[name];
if (Array.isArray(v)) return v[0];
return v;
}
export async function authenticateWsUpgrade(
headers: LowercasedHeaders
): Promise<WsUpgradeAuth | null> {
const authEnabled = await isAuthEnabled();
if (!authEnabled) {
return { userId: -1, username: '__bootstrap__', isAdmin: true, authDisabled: true };
}
const cookieHeader = pickHeader(headers, 'cookie');
const cookies = parseCookieHeader(cookieHeader);
const sessionId = cookies[SESSION_COOKIE];
if (sessionId) {
const user = await validateSessionById(sessionId);
if (user) {
const enterprise = await isEnterprise();
const isAdmin = enterprise ? await userHasAdminRole(user.id) : true;
return { userId: user.id, username: user.username, isAdmin, authDisabled: false };
}
}
const authHeader = pickHeader(headers, 'authorization');
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.slice(7).trim();
const user = await validateApiToken(token);
if (user) {
const enterprise = await isEnterprise();
const isAdmin = enterprise ? await userHasAdminRole(user.id) : true;
return { userId: user.id, username: user.username, isAdmin, authDisabled: false };
}
}
return null;
}
export async function canAccessEnvForUser(
auth: WsUpgradeAuth,
environmentId: number | undefined | null
): Promise<boolean> {
if (auth.authDisabled) return true;
if (auth.isAdmin) return true;
const enterprise = await isEnterprise();
if (!enterprise) return true;
if (environmentId == null) {
return false;
}
return userCanAccessEnvironment(auth.userId, environmentId);
}
declare global {
// eslint-disable-next-line no-var
var __authenticateWsUpgrade:
| ((headers: LowercasedHeaders) => Promise<WsUpgradeAuth | null>)
| undefined;
// eslint-disable-next-line no-var
var __canAccessEnvForUser:
| ((auth: WsUpgradeAuth, envId: number | undefined | null) => Promise<boolean>)
| undefined;
}
globalThis.__authenticateWsUpgrade = authenticateWsUpgrade;
globalThis.__canAccessEnvForUser = canAccessEnvForUser;
+1
View File
@@ -18,6 +18,7 @@ export interface Permissions {
audit_logs: string[];
activity: string[];
schedules: string[];
templates: string[];
}
export interface AuthUser {
+28
View File
@@ -0,0 +1,28 @@
import { writable, get } from 'svelte/store';
import { browser } from '$app/environment';
const store = writable<Record<string, string>>({});
let loaded = false;
async function load() {
if (!browser || loaded) return;
loaded = true;
try {
const res = await fetch('/api/labels');
if (res.ok) {
const data = await res.json();
store.set(data.colors || {});
}
} catch {
// ignore
}
}
export const labelColorOverrides = {
subscribe: store.subscribe,
load,
reload: async () => {
loaded = false;
await load();
}
};

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