Compare commits

..

12 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
161 changed files with 21002 additions and 1890 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.4-r3" \
" - 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.10 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.31
v1.0.34
+1 -1
View File
@@ -1,3 +1,3 @@
module github.com/Finsys/dockhand/collector
go 1.25.10
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
}
]
}
+6 -5
View File
@@ -1,7 +1,7 @@
{
"name": "dockhand",
"private": true,
"version": "1.0.31",
"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",
@@ -81,14 +82,14 @@
"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.20.1"
"ws": "8.21.0"
},
"devDependencies": {
"@internationalized/date": "^3.10.1",
@@ -116,7 +117,7 @@
"d3-shape": "^3.2.0",
"drizzle-kit": "0.31.8",
"layerchart": "^1.0.13",
"lucide-svelte": "^0.562.0",
"lucide-svelte": "0.562.0",
"mode-watcher": "^1.1.0",
"postcss": "^8.5.6",
"svelte": "5.55.7",
+122 -9
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;
@@ -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`);
});
+24 -11
View File
@@ -74,28 +74,31 @@ html {
max-width: calc(90px * var(--grid-font-size-scale, 1)) !important;
}
/* Scrollbar theming */
* {
scrollbar-color: hsl(var(--border) / 0.5) transparent;
scrollbar-width: thin;
}
/* 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 {
background: hsl(var(--border) / 0.5);
/* 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(var(--border) / 0.7);
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 {
@@ -1338,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)); }
+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>
+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>
+34 -7
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 }> = {
@@ -496,6 +500,21 @@
initialSpacer: () => new VariableGutterMarker('required')
});
// YAML Enter handler: after a key-only line ending with ":", indent one level
// deeper than what the default indent service returns (it can't predict child
// indent when no child content exists yet).
function yamlNewlineAndIndent(view: EditorView): boolean {
const { state } = view;
const line = state.doc.lineAt(state.selection.main.head);
const withoutComment = line.text.trimEnd().replace(/#.*$/, '').trimEnd();
if (!withoutComment.endsWith(':')) return false;
insertNewlineAndIndent(view);
const unit = state.facet(indentUnit);
const cursor = view.state.selection.main.head;
view.dispatch({ changes: { from: cursor, insert: unit }, selection: { anchor: cursor + unit.length } });
return true;
}
// Get language extension based on language name
function getLanguageExtension(lang: string) {
switch (lang) {
@@ -527,12 +546,18 @@
return xml();
case 'sql':
return sql();
case 'dockerfile':
case 'shell':
case 'bash':
case 'sh':
// No dedicated shell/dockerfile support, use basic highlighting
return [];
return StreamLanguage.define(shell);
case 'dockerfile':
return StreamLanguage.define(dockerFile);
case 'toml':
return StreamLanguage.define(toml);
case 'ini':
case 'conf':
case 'properties':
return StreamLanguage.define(properties);
case 'dotenv':
case 'env':
return StreamLanguage.define(dotenvParser);
@@ -671,7 +696,9 @@
]),
...themeExtensions,
EditorView.lineWrapping,
getLanguageExtension(language)
EditorState.tabSize.of(2),
getLanguageExtension(language),
...(language === 'yaml' ? [Prec.high(keymap.of([{ key: 'Enter', run: yamlNewlineAndIndent }]))] : [])
].flat();
if (readonly) {
+2 -2
View File
@@ -309,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" />
@@ -327,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" />
+1 -1
View File
@@ -228,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}
+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>
+3 -1
View File
@@ -22,7 +22,8 @@
User,
ClipboardList,
Activity,
Timer
Timer,
LibraryBig
} from 'lucide-svelte';
import { licenseStore } from '$lib/stores/license';
import { authStore, hasAnyAccess } from '$lib/stores/auth';
@@ -101,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' },
+27 -1
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';
@@ -95,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);
@@ -449,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 },
+64
View File
@@ -1,4 +1,68 @@
[
{
"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",
+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';
}
-34
View File
@@ -1,34 +0,0 @@
/**
* Merge container env vars with new image env vars during auto-update.
* Image-baked vars get updated to the new image's values.
* User-set vars (not present in old image) are preserved.
* Env vars removed from the new image are dropped.
*/
export function mergeImageEnvVars(
containerEnv: string[],
oldImageEnv: string[],
newImageEnv: string[]
): string[] {
const getKey = (entry: string) => entry.split('=')[0];
const oldImageKeys = new Set(oldImageEnv.map(getKey));
const merged: string[] = [];
// Keep user-set env vars (key not present in old image)
for (const entry of containerEnv) {
if (!oldImageKeys.has(getKey(entry))) {
merged.push(entry);
}
}
// Add all new image env vars (updates changed values, adds new ones)
for (const entry of newImageEnv) {
const key = getKey(entry);
// Skip if user already set this key (user wins)
if (!merged.some(e => getKey(e) === key)) {
merged.push(entry);
}
}
return merged;
}
@@ -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;
}
+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()
});
+554 -145
View File
@@ -6,7 +6,6 @@
*/
import { homedir } from 'node:os';
import { mergeImageEnvVars } from './container-env-merge';
import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import * as http from 'node:http';
@@ -16,6 +15,7 @@ import { createHash } from 'node:crypto';
import type { Environment } from './db';
import { getStackEnvVarsAsRecord } from './db';
import { getAdditionalVolumeBinds } from './mount-dedupe';
import { encodeRegistryAuth } from './registry-auth';
import { isSystemContainer } from './scheduler/tasks/update-utils';
import { deepDiff } from '../utils/diff.js';
@@ -287,7 +287,7 @@ const envCache = new Map<number, CachedEnv>();
const CACHE_TTL = 30 * 60 * 1000;
// All known Docker Hub hostname variations for credential matching
const DOCKER_HUB_HOSTS = new Set([
export const DOCKER_HUB_HOSTS = new Set([
'docker.io', 'hub.docker.com', 'registry.hub.docker.com',
'index.docker.io', 'registry-1.docker.io', 'registry.docker.io', 'docker.com'
]);
@@ -1216,6 +1216,61 @@ export async function renameContainer(id: string, newName: string, envId?: numbe
await assertDockerResponse(response);
}
/**
* In-place container property update. Wraps Docker's POST /containers/{id}/update,
* which is the only API that changes container properties WITHOUT recreating it.
*
* The accepted body field set is fixed by Docker anything outside this set
* requires container recreation (image, env, ports, networks, mounts, etc).
* Whitelist enforced here so callers can't accidentally smuggle a
* recreate-only field through this code path.
*/
export const IN_PLACE_UPDATE_FIELDS = [
// Restart policy — the headline use case (#1153)
'RestartPolicy',
// CPU
'CpuShares', 'CpuPeriod', 'CpuQuota', 'CpuRealtimePeriod', 'CpuRealtimeRuntime',
'CpusetCpus', 'CpusetMems', 'NanoCpus',
// Memory
'Memory', 'MemorySwap', 'MemoryReservation', 'MemorySwappiness', 'KernelMemory',
// Block I/O
'BlkioWeight', 'BlkioWeightDevice',
'BlkioDeviceReadBps', 'BlkioDeviceWriteBps',
'BlkioDeviceReadIOps', 'BlkioDeviceWriteIOps',
// Misc
'PidsLimit'
] as const;
export type InPlaceUpdateField = typeof IN_PLACE_UPDATE_FIELDS[number];
export interface UpdateContainerRuntimeResult {
Warnings: string[] | null;
}
/**
* Apply an in-place update. `updates` keys MUST be from IN_PLACE_UPDATE_FIELDS;
* unknown keys are silently dropped (not passed to Docker) so a malicious or
* confused caller can't sneak a recreate-only field through.
*/
export async function updateContainerRuntime(
id: string,
updates: Partial<Record<InPlaceUpdateField, unknown>>,
envId?: number | null
): Promise<UpdateContainerRuntimeResult> {
const allowed = new Set<string>(IN_PLACE_UPDATE_FIELDS);
const body: Record<string, unknown> = {};
for (const [key, value] of Object.entries(updates)) {
if (allowed.has(key) && value !== undefined) body[key] = value;
}
if (Object.keys(body).length === 0) {
throw new Error('No updatable fields provided');
}
return dockerJsonRequest<UpdateContainerRuntimeResult>(
`/containers/${id}/update`,
{ method: 'POST', body: JSON.stringify(body) },
envId
);
}
export async function getContainerLogs(id: string, tail: number | 'all' = 100, envId?: number | null, since?: string, until?: string): Promise<string> {
// Check if container has TTY enabled
const info = await inspectContainer(id, envId);
@@ -1299,6 +1354,11 @@ export interface CreateContainerOptions {
restartPolicy?: string;
restartMaxRetries?: number;
networkMode?: string;
/** Additional networks attached after creation via POST /networks/<name>/connect.
* Must NOT include the primary (which lives in networkMode + EndpointsConfig). */
additionalNetworks?: string[];
/** @deprecated use networkMode + additionalNetworks. Kept only so old callers
* (auto-update inspect path) keep working see backward-compat block below. */
networks?: string[];
/** Network aliases for the primary network */
networkAliases?: string[];
@@ -1454,92 +1514,67 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
containerConfig.Volumes = options.volumes;
}
if (options.networkMode) {
containerConfig.HostConfig.NetworkMode = options.networkMode;
// Build endpoint config for primary network with aliases, static IP, and gateway priority
const hasNetworkConfig = options.networkAliases?.length || options.networkIpv4Address || options.networkIpv6Address || options.networkGwPriority !== undefined;
if (hasNetworkConfig) {
const endpointConfig: any = {};
if (options.networkAliases && options.networkAliases.length > 0) {
endpointConfig.Aliases = options.networkAliases;
}
if (options.networkIpv4Address || options.networkIpv6Address) {
endpointConfig.IPAMConfig = {};
if (options.networkIpv4Address) {
endpointConfig.IPAMConfig.IPv4Address = options.networkIpv4Address;
}
if (options.networkIpv6Address) {
endpointConfig.IPAMConfig.IPv6Address = options.networkIpv6Address;
}
}
// Gateway priority (Docker Engine 28+)
if (options.networkGwPriority !== undefined) {
endpointConfig.GwPriority = options.networkGwPriority;
}
containerConfig.NetworkingConfig = {
EndpointsConfig: {
[options.networkMode]: endpointConfig
}
};
}
// Backward-compat: callers (and the auto-update inspect path) may still pass
// the legacy "networks" array which conflated primary + extras. Normalize to
// the new model: anything that isn't the primary becomes an additional network.
if (options.networks && !options.additionalNetworks) {
const primary = options.networkMode || '';
options.additionalNetworks = options.networks.filter(n => n !== primary);
}
if (options.networks && options.networks.length > 0) {
containerConfig.HostConfig.NetworkMode = options.networks[0];
// Networking model:
// - networkMode is the SINGLE source of truth for the primary network
// - EndpointsConfig has exactly ONE entry, keyed by networkMode, carrying
// the form's IPv4/IPv6/aliases/gwPriority
// - additionalNetworks (extras only — NEVER includes the primary) are
// attached after container creation via POST /networks/{name}/connect
// - Shared modes (host / none / container:X / service:X) skip both
// EndpointsConfig and additionalNetworks: Docker rejects them.
const primaryMode = options.networkMode || '';
const isSharedMode = primaryMode === 'host' || primaryMode === 'none' || primaryMode.startsWith('container:') || primaryMode.startsWith('service:');
// Build endpoint configs for all networks
const endpointsConfig: Record<string, any> = {};
if (primaryMode) {
containerConfig.HostConfig.NetworkMode = primaryMode;
}
for (const network of options.networks) {
const isFirstNetwork = network === options.networks[0];
const netCfg = options.networkConfigs?.[network];
// Build the single EndpointsConfig entry for the primary (only for non-shared modes).
if (primaryMode && !isSharedMode) {
const endpointConfig: any = {};
if (options.networkAliases?.length) {
endpointConfig.Aliases = options.networkAliases;
}
if (options.networkIpv4Address || options.networkIpv6Address) {
endpointConfig.IPAMConfig = {};
if (options.networkIpv4Address) endpointConfig.IPAMConfig.IPv4Address = options.networkIpv4Address;
if (options.networkIpv6Address) endpointConfig.IPAMConfig.IPv6Address = options.networkIpv6Address;
}
if (options.networkGwPriority !== undefined) {
endpointConfig.GwPriority = options.networkGwPriority;
}
containerConfig.NetworkingConfig = {
EndpointsConfig: {
[primaryMode]: endpointConfig
}
};
}
// Extras attached after creation. Empty array for shared modes.
const extraNetworks: { name: string; config: any }[] = [];
if (!isSharedMode && options.additionalNetworks?.length) {
for (const netName of options.additionalNetworks) {
if (netName === primaryMode) continue; // primary lives in EndpointsConfig, not here
const netCfg = options.networkConfigs?.[netName];
const endpointConfig: any = {};
// Per-network config from networkConfigs (takes precedence)
if (netCfg) {
if (netCfg.aliases && netCfg.aliases.length > 0) {
endpointConfig.Aliases = netCfg.aliases;
}
if (netCfg.aliases?.length) endpointConfig.Aliases = netCfg.aliases;
if (netCfg.ipv4Address || netCfg.ipv6Address) {
endpointConfig.IPAMConfig = {};
if (netCfg.ipv4Address) {
endpointConfig.IPAMConfig.IPv4Address = netCfg.ipv4Address;
}
if (netCfg.ipv6Address) {
endpointConfig.IPAMConfig.IPv6Address = netCfg.ipv6Address;
}
}
} else if (isFirstNetwork) {
// Backward compat: apply flat fields to first network if no networkConfigs
if (options.networkAliases && options.networkAliases.length > 0) {
endpointConfig.Aliases = options.networkAliases;
}
if (options.networkIpv4Address || options.networkIpv6Address) {
endpointConfig.IPAMConfig = {};
if (options.networkIpv4Address) {
endpointConfig.IPAMConfig.IPv4Address = options.networkIpv4Address;
}
if (options.networkIpv6Address) {
endpointConfig.IPAMConfig.IPv6Address = options.networkIpv6Address;
}
}
// Gateway priority (Docker Engine 28+)
if (options.networkGwPriority !== undefined) {
endpointConfig.GwPriority = options.networkGwPriority;
if (netCfg.ipv4Address) endpointConfig.IPAMConfig.IPv4Address = netCfg.ipv4Address;
if (netCfg.ipv6Address) endpointConfig.IPAMConfig.IPv6Address = netCfg.ipv6Address;
}
}
endpointsConfig[network] = endpointConfig;
extraNetworks.push({ name: netName, config: endpointConfig });
}
containerConfig.NetworkingConfig = {
EndpointsConfig: endpointsConfig
};
}
if (options.privileged !== undefined) {
@@ -1772,6 +1807,39 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
containerConfig.Domainname = options.domainname;
}
// Shared network modes (host / container:X / service:X) inherit the
// namespace's networking, so Docker rejects any field that would override it.
// Strip them defensively — the merge in updateContainer carries values from
// the old config that may not be valid for the new mode (e.g. switching from
// bridge → container:foo with a leftover Hostname).
// Mirrors the same logic in recreateContainerFromInspect.
{
const primary = containerConfig.HostConfig?.NetworkMode || '';
const isHost = primary === 'host';
const isContainer = primary.startsWith('container:');
const isService = primary.startsWith('service:');
if (isHost || isContainer || isService) {
delete containerConfig.Hostname;
delete containerConfig.Domainname;
delete containerConfig.MacAddress;
}
if (isContainer || isService) {
// container:X also shares ports / DNS / hosts with the target
delete containerConfig.ExposedPorts;
if (containerConfig.HostConfig) {
delete containerConfig.HostConfig.PortBindings;
delete containerConfig.HostConfig.PublishAllPorts;
delete containerConfig.HostConfig.Dns;
delete containerConfig.HostConfig.DnsOptions;
delete containerConfig.HostConfig.DnsSearch;
delete containerConfig.HostConfig.ExtraHosts;
delete containerConfig.HostConfig.Links;
}
// NetworkingConfig is meaningless when sharing a namespace
delete containerConfig.NetworkingConfig;
}
}
const result = await dockerJsonRequest<{ Id: string }>(
`/containers/create?name=${encodeURIComponent(options.name)}`,
{
@@ -1781,6 +1849,18 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
envId
);
// Attach additional networks now that the container exists. Docker only allows
// a single network at create time, so anything beyond the primary is connected here.
for (const extra of extraNetworks) {
try {
await connectContainerToNetworkRaw(extra.name, result.Id, extra.config, envId);
} catch (err: any) {
console.error(`Failed to attach additional network "${extra.name}":`, err?.message || err);
// Don't fail the whole create — primary network is already connected.
// Caller (updateContainer rollback path) will surface this if needed.
}
}
return { id: result.Id, start: () => startContainer(result.Id, envId) };
}
@@ -1901,50 +1981,6 @@ export async function recreateContainerFromInspect(
HostConfig: hostConfig
};
// 4a. Update image-embedded labels and env vars to match the new image.
// Docker's create API uses exactly the labels/env you pass, ignoring the new image's
// embedded values. We inspect both old and new images to distinguish image-origin
// values from user-set values, then merge accordingly.
try {
const [oldImageInspect, newImageInspect] = await Promise.all([
inspectImage(config.Image, envId),
inspectImage(newImage, envId)
]);
// Merge labels
const oldImageLabels: Record<string, string> = (oldImageInspect as any)?.Config?.Labels || {};
const newImageLabels: Record<string, string> = (newImageInspect as any)?.Config?.Labels || {};
const containerLabels: Record<string, string> = createConfig.Labels || {};
const mergedLabels: Record<string, string> = {};
// Keep user-set labels (not present in old image)
for (const [k, v] of Object.entries(containerLabels)) {
if (!(k in oldImageLabels)) {
mergedLabels[k] = v;
}
}
// Add all new image labels (overrides old image labels)
for (const [k, v] of Object.entries(newImageLabels)) {
mergedLabels[k] = v;
}
createConfig.Labels = mergedLabels;
log?.(`Updated image labels: ${Object.keys(newImageLabels).length} from new image, ${Object.keys(mergedLabels).length} total`);
// Merge env vars (same logic: image-baked vars get updated, user-set vars preserved)
const oldImageEnv: string[] = (oldImageInspect as any)?.Config?.Env || [];
const newImageEnv: string[] = (newImageInspect as any)?.Config?.Env || [];
const containerEnv: string[] = createConfig.Env || [];
createConfig.Env = mergeImageEnvVars(containerEnv, oldImageEnv, newImageEnv);
log?.(`Updated image env vars: ${newImageEnv.length} from new image, ${createConfig.Env.length} total`);
} catch (e) {
log?.(`Warning: could not update image labels/env: ${e}`);
// Fall through with old values — non-fatal
}
// Strip default MemorySwappiness — Podman + cgroupv2 rejects it.
// Docker returns -1, Podman returns 0 when unset.
const swappiness = createConfig.HostConfig?.MemorySwappiness;
@@ -2424,20 +2460,44 @@ export async function updateContainer(id: string, options: Partial<CreateContain
// Extract ALL existing container options
const existingOptions = extractContainerOptions(oldContainerInfo);
// Merge user-provided options on top of existing options
// User options take precedence, but we preserve everything not explicitly provided
// Per-network fields (aliases, static IPs, MAC, gateway priority) are scoped to
// a single network. When the user switches the primary network, the values
// extracted from the old network must NOT follow the container — e.g. compose
// service aliases from "anton" applied to the default bridge fail with
// "invalid endpoint settings" because the default bridge doesn't accept aliases.
if (options.networkMode && options.networkMode !== networkMode) {
existingOptions.networkAliases = undefined;
existingOptions.networkIpv4Address = undefined;
existingOptions.networkIpv6Address = undefined;
existingOptions.networkGwPriority = undefined;
existingOptions.macAddress = undefined;
}
// Merge user-provided options on top of existing options.
// Semantics (#1119):
// - key absent from `options` → preserve existingOptions[key]
// - key present with explicit `null` → CLEAR the field (treat as undefined)
// - key present with any other value → use the user's value
//
// Without the null-as-clear handling, clearing a Resources field in the UI
// (which sends null) would merge over the existing value identically to a
// preserved field — Memory limit, etc. would silently stick around.
const userOptions: Record<string, unknown> = {};
for (const [k, v] of Object.entries(options)) {
userOptions[k] = v === null ? undefined : v;
}
const mergedOptions: CreateContainerOptions = {
...existingOptions,
...options,
...userOptions,
// Replace labels, but preserve Docker internal labels (com.docker.*)
labels: options.labels !== undefined
labels: options.labels !== undefined && options.labels !== null
? {
...Object.fromEntries(
Object.entries(existingOptions.labels || {}).filter(([k]) => k.startsWith('com.docker.'))
),
...options.labels
}
: existingOptions.labels
: options.labels === null ? {} : existingOptions.labels
};
// 1. Stop old container
@@ -2549,6 +2609,28 @@ export async function listImages(envId?: number | null): Promise<ImageInfo[]> {
}));
}
/**
* Diagnostic log for registry auth headers (#1105).
* Logs lengths and boundary char codes never the plaintext credentials.
* Header prefix is safe to log: it encodes the start of `{"username":"...`.
*/
function logAuthDiagnostics(
tag: string,
registry: string,
serveraddress: string,
username: string,
password: string,
authHeader: string
): void {
const userLast = username.length ? username.charCodeAt(username.length - 1).toString(16) : 'na';
const pwLast = password.length ? password.charCodeAt(password.length - 1).toString(16) : 'na';
console.log(
`${tag} auth: registry=${registry} user(len=${username.length},last=0x${userLast}) ` +
`pw(len=${password.length},last=0x${pwLast}) serveraddress=${serveraddress} ` +
`authHeader(len=${authHeader.length},prefix=${authHeader.slice(0, 16)})`
);
}
/**
* Build X-Registry-Auth header for authenticated Docker image pulls.
* Looks up stored registry credentials and returns a headers object
@@ -2562,16 +2644,19 @@ export async function buildRegistryAuthHeader(imageName: string): Promise<Record
if (creds) {
// Docker Engine requires 'https://index.docker.io/v1/' as serveraddress
// for Docker Hub auth — just the hostname is treated as unauthenticated
const serveraddress = DOCKER_HUB_HOSTS.has(registry)
? 'https://index.docker.io/v1/'
: registry;
console.log(`[Pull] Using credentials for ${serveraddress} (user: ${creds.username})`);
const isHub = DOCKER_HUB_HOSTS.has(registry);
const serveraddress = isHub ? 'https://index.docker.io/v1/' : registry;
if (isHub) {
console.log(`[Registry] docker-hub variant '${registry}' canonicalized to https://index.docker.io/v1/ for auth`);
}
const authConfig = {
username: creds.username,
password: creds.password,
serveraddress
};
headers['X-Registry-Auth'] = Buffer.from(JSON.stringify(authConfig)).toString('base64');
const authHeader = encodeRegistryAuth(authConfig);
headers['X-Registry-Auth'] = authHeader;
logAuthDiagnostics('[Pull]', registry, serveraddress, creds.username, creds.password, authHeader);
} else {
console.log(`[Pull] No credentials found for ${registry}`);
}
@@ -2614,11 +2699,19 @@ export async function pullImage(imageName: string, onProgress?: (data: any) => v
// Look up registry credentials for authenticated pulls
const headers = await buildRegistryAuthHeader(imageName);
// Diagnostic logging (#1105): what we're sending to the daemon
console.log(`[Pull] POST ${url} headers=${Object.keys(headers).join(',') || '(none)'}`);
// Use streaming: true for longer timeout on edge environments
const response = await dockerFetch(url, { method: 'POST', streaming: true, headers }, envId);
// Diagnostic logging (#1105): daemon response status
console.log(`[Pull] response status=${response.status} ${response.statusText}`);
if (!response.ok) {
throw new Error(`Failed to pull image: ${await response.text()}`);
const body = await response.text();
console.error(`[Pull] error body: ${body}`);
throw new Error(`Failed to pull image: ${body}`);
}
// Stream the response for progress updates
@@ -2640,6 +2733,9 @@ export async function pullImage(imageName: string, onProgress?: (data: any) => v
if (line.trim()) {
try {
const data = JSON.parse(line);
if (data.error || data.errorDetail) {
console.error(`[Pull] stream error: ${line}`);
}
if (onProgress) onProgress(data);
} catch {
// Ignore parse errors
@@ -2776,7 +2872,10 @@ async function findRegistryCredentials(registryHost: string): Promise<{ username
if (stored.fullRegistry === requested.fullRegistry ||
(stored.host === requested.host && !stored.path)) {
if (reg.username && reg.password) {
return { username: reg.username, password: reg.password };
const via = stored.fullRegistry === requested.fullRegistry ? 'full' : 'host-only';
console.log(`[Registry] matched stored=${reg.url} requested=${registryHost} via=${via}`);
// Normalize legacy creds saved before #1105 trim fix
return { username: reg.username.trim(), password: reg.password.trim() };
}
}
}
@@ -2788,12 +2887,20 @@ async function findRegistryCredentials(registryHost: string): Promise<{ username
const stored = parseRegistryUrl(reg.url);
if (DOCKER_HUB_HOSTS.has(stored.host)) {
if (reg.username && reg.password) {
return { username: reg.username, password: reg.password };
console.log(`[Registry] matched stored=${reg.url} requested=${registryHost} via=hub-alias`);
return { username: reg.username.trim(), password: reg.password.trim() };
}
}
}
}
// No match — log what we tried so support cases are diagnosable
const candidates = registries.map(r => parseRegistryUrl(r.url).host).join(', ');
console.log(
`[Registry] no match for requested=${registryHost} ` +
`(hub-alias=${DOCKER_HUB_HOSTS.has(requested.host)}); ` +
`candidates=[${candidates || 'none configured'}]`
);
return null;
} catch (e) {
const errorMsg = e instanceof Error ? e.message : String(e);
@@ -2871,10 +2978,14 @@ async function getRegistryBearerToken(registry: string, repo: string): Promise<s
const service = serviceMatch ? serviceMatch[1] : '';
const scope = `repository:${repo}:pull`;
// Step 3: Request token from realm (with credentials if available)
// Step 3: Request token from realm (with credentials if available).
// Empty scope is allowed — means "no specific resource permission".
// Useful for credential validation: some registries (Docker Hub)
// reject privileged scopes like registry:catalog:* even for valid
// users, so omitting scope is the only reliable login check.
const tokenUrl = new URL(realm);
if (service) tokenUrl.searchParams.set('service', service);
tokenUrl.searchParams.set('scope', scope);
if (scope) tokenUrl.searchParams.set('scope', scope);
const tokenHeaders: Record<string, string> = { 'User-Agent': 'Dockhand/1.0' };
@@ -2988,10 +3099,14 @@ export async function getRegistryAuthHeader(
const realm = realmMatch[1];
const service = serviceMatch ? serviceMatch[1] : '';
// Step 3: Request token from realm (with credentials if available)
// Step 3: Request token from realm (with credentials if available).
// Empty scope is allowed — means "no specific resource permission".
// Useful for credential validation: some registries (Docker Hub)
// reject privileged scopes like registry:catalog:* even for valid
// users, so omitting scope is the only reliable login check.
const tokenUrl = new URL(realm);
if (service) tokenUrl.searchParams.set('service', service);
tokenUrl.searchParams.set('scope', scope);
if (scope) tokenUrl.searchParams.set('scope', scope);
const tokenHeaders: Record<string, string> = { 'User-Agent': 'Dockhand/1.0' };
@@ -3053,6 +3168,250 @@ export async function getRegistryAuth(
return { baseUrl, orgPath: parsed.path, authHeader };
}
// --- Harbor fallback for catalog and image search ---
// Harbor denies access to the V2 _catalog endpoint for robot accounts.
// We detect Harbor and use the native project API as a fallback.
/** Harbor detection cache per host (TTL 5 min). Only definitive (non-error) detections are cached. */
const harborDetectionCache = new Map<string, { isHarbor: boolean; ts: number }>();
const HARBOR_CACHE_TTL = 5 * 60 * 1000;
export interface HarborCatalogResult {
repositories: string[];
/** Pagination cursor: "harbor:<page>" or null if last page */
nextLast: string | null;
}
/**
* Detects whether a registry is a Harbor instance.
* Checks for service="harbor-registry" in the WWW-Authenticate header from /v2/,
* then confirms via /api/v2.0/ping. Result is cached for 5 min per host.
*/
export async function isHarborRegistry(registryUrl: string): Promise<boolean> {
const parsed = parseRegistryUrl(registryUrl);
const host = parsed.host;
const cached = harborDetectionCache.get(host);
if (cached && Date.now() - cached.ts < HARBOR_CACHE_TTL) {
return cached.isHarbor;
}
// Respect the registry's configured scheme and port (parsed.host includes the port).
const baseUrl = `${parsed.protocol}://${parsed.host}`;
let isHarbor = false;
try {
// Step 1: check the WWW-Authenticate header from /v2/
const challengeResp = await fetch(`${baseUrl}/v2/`, {
method: 'GET',
headers: { 'User-Agent': 'Dockhand/1.0' }
});
const wwwAuth = challengeResp.headers.get('WWW-Authenticate') || '';
if (wwwAuth.toLowerCase().includes('service="harbor-registry"')) {
// Step 2: confirm via /api/v2.0/ping
const pingResp = await fetch(`${baseUrl}/api/v2.0/ping`, {
method: 'GET',
headers: { 'User-Agent': 'Dockhand/1.0' }
});
if (pingResp.ok) {
const body = await pingResp.text();
if (body.includes('Pong')) {
isHarbor = true;
}
}
}
// A definitive answer was obtained (no network error): cache it.
harborDetectionCache.set(host, { isHarbor, ts: Date.now() });
} catch {
// Network error: detection is indeterminate. Do NOT cache, so a transient
// outage can't pin "not Harbor" for the whole TTL and keep returning 403s
// once Harbor recovers.
}
return isHarbor;
}
/**
* Builds the Basic auth header for the Harbor API from a registry object.
* Credentials are trimmed: pasted values often carry trailing whitespace that
* silently breaks Basic auth.
*/
function getHarborBasicAuth(registry: { username?: string | null; password?: string | null }): string | null {
const username = registry.username?.trim();
const password = registry.password?.trim();
if (username && password) {
return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
}
return null;
}
/** Builds the common headers (JSON + Basic auth) for Harbor API requests. */
function harborHeaders(registry: { username?: string | null; password?: string | null }): Record<string, string> {
const headers: Record<string, string> = {
'Accept': 'application/json',
'User-Agent': 'Dockhand/1.0'
};
const authHeader = getHarborBasicAuth(registry);
if (authHeader) headers['Authorization'] = authHeader;
return headers;
}
/** Removes Harbor query-grammar control characters so a search term can't break or alter the q filter. */
function sanitizeHarborQueryTerm(term: string): string {
// Harbor's query grammar uses , = ~ ( ) as control characters.
return term.replace(/[,=~()]/g, '');
}
/**
* Follows Harbor list-endpoint pagination, collecting every item's name.
* Terminates on a short page or once X-Total-Count is reached; a safety cap
* bounds pathological servers (a warning is logged if it is hit).
*/
async function harborPaginateNames(
urlForPage: (page: number, pageSize: number) => string,
headers: Record<string, string>,
options: { throwOnFirstError: boolean; label: string }
): Promise<string[]> {
const names: string[] = [];
const pageSize = 100;
const maxPages = 100; // ~10k items: defensive bound against a server that never returns a short page
let total = 0;
let page = 1;
while (page <= maxPages) {
const resp = await fetch(urlForPage(page, pageSize), { headers });
if (!resp.ok) {
if (page === 1 && options.throwOnFirstError) {
throw new Error(`Harbor API error ${resp.status} while ${options.label}`);
}
break;
}
if (page === 1) total = parseInt(resp.headers.get('X-Total-Count') || '0', 10);
const items: Array<{ name: string }> = await resp.json();
for (const item of items) names.push(item.name);
if (items.length < pageSize) break;
if (total > 0 && names.length >= total) break;
page++;
}
if (page > maxPages) {
console.warn(`[Harbor] Pagination safety cap (${maxPages} pages) reached while ${options.label}; the list may be truncated`);
}
return names;
}
/** Enumerates the names of all Harbor projects the account can access. */
async function harborListAllProjects(baseUrl: string, headers: Record<string, string>): Promise<string[]> {
return harborPaginateNames(
(page, size) => `${baseUrl}/projects?page=${page}&page_size=${size}`,
headers,
{ throwOnFirstError: true, label: 'listing projects' }
);
}
/** Enumerates the names of all repositories within a single Harbor project. */
async function harborListProjectRepositories(baseUrl: string, project: string, headers: Record<string, string>): Promise<string[]> {
return harborPaginateNames(
(page, size) => `${baseUrl}/projects/${encodeURIComponent(project)}/repositories?page=${page}&page_size=${size}`,
headers,
{ throwOnFirstError: false, label: `listing repositories of project ${project}` }
);
}
/**
* Lists repositories via the Harbor project API.
* With orgPath set, paginates a single project natively (using X-Total-Count).
* Without orgPath, enumerates every accessible project and all of their
* repositories, then paginates the flattened list so the UI gets a correct
* hasMore signal instead of a silently truncated first page.
* @param page - page number (1-based)
* @param pageSize - number of results per page
*/
export async function harborListRepositories(
registry: { url: string; username?: string | null; password?: string | null },
orgPath: string,
page: number = 1,
pageSize: number = 100
): Promise<HarborCatalogResult> {
const parsed = parseRegistryUrl(registry.url);
const baseUrl = `${parsed.protocol}://${parsed.host}/api/v2.0`;
const headers = harborHeaders(registry);
if (orgPath) {
// Single project: native page-based pagination.
const project = orgPath.replace(/^\//, '');
const url = `${baseUrl}/projects/${encodeURIComponent(project)}/repositories?page=${page}&page_size=${pageSize}`;
const resp = await fetch(url, { headers });
if (!resp.ok) {
throw new Error(`Harbor API error ${resp.status} for project ${project}`);
}
const totalCount = parseInt(resp.headers.get('X-Total-Count') || '0', 10);
const repos: Array<{ name: string }> = await resp.json();
const repositories = repos.map(r => r.name);
const hasMore = page * pageSize < totalCount;
return { repositories, nextLast: hasMore ? `harbor:${page + 1}` : null };
}
// No orgPath: enumerate all projects and all of their repositories, then
// slice the flattened list for the requested page.
const projects = await harborListAllProjects(baseUrl, headers);
const all: string[] = [];
for (const project of projects) {
const repos = await harborListProjectRepositories(baseUrl, project, headers);
for (const name of repos) all.push(name);
}
const start = (page - 1) * pageSize;
const repositories = all.slice(start, start + pageSize);
const hasMore = start + pageSize < all.length;
return { repositories, nextLast: hasMore ? `harbor:${page + 1}` : null };
}
/**
* Searches repositories via the Harbor API using filter q=name=~{term}.
* Iterates through all accessible projects (or a single one if orgPath is set).
* The term is sanitized for the Harbor query grammar; a client-side substring
* check on the original term keeps the results precise.
*/
export async function harborSearchRepositories(
registry: { url: string; username?: string | null; password?: string | null },
term: string,
orgPath: string,
limit: number = 25
): Promise<string[]> {
const parsed = parseRegistryUrl(registry.url);
const baseUrl = `${parsed.protocol}://${parsed.host}/api/v2.0`;
const headers = harborHeaders(registry);
const termLower = term.toLowerCase();
const safeTerm = sanitizeHarborQueryTerm(term);
const results: string[] = [];
const projectNames = orgPath
? [orgPath.replace(/^\//, '')]
: await harborListAllProjects(baseUrl, headers);
// Search each project using the Harbor filter
for (const project of projectNames) {
if (results.length >= limit) break;
const q = encodeURIComponent(`name=~${safeTerm}`);
const url = `${baseUrl}/projects/${encodeURIComponent(project)}/repositories?q=${q}&page=1&page_size=${limit}`;
const resp = await fetch(url, { headers });
if (!resp.ok) continue;
const repos: Array<{ name: string }> = await resp.json();
for (const r of repos) {
// The server filter ran on the sanitized term, so re-check the original
// term client-side to guarantee precise substring matches.
if (r.name.toLowerCase().includes(termLower)) {
results.push(r.name);
if (results.length >= limit) break;
}
}
}
return results;
}
/**
* Check the registry for the current manifest digest of an image.
* Simple HEAD request to get Docker-Content-Digest header.
@@ -3136,6 +3495,17 @@ export async function checkImageUpdateAvailable(
};
}
// Skip update check for images tagged against a localhost registry.
// These never resolve from inside Dockhand's container and only produce
// ECONNREFUSED noise (see #1083).
if (/^localhost(:\d+)?\//.test(imageName)) {
return {
hasUpdate: false,
isLocalImage: true,
currentDigest: currentImageId
};
}
// Get current image info to get RepoDigests
let currentImageInfo: any;
try {
@@ -3466,7 +3836,11 @@ export async function listVolumes(envId?: number | null): Promise<VolumeInfo[]>
scope: volume.Scope,
created: volume.CreatedAt,
labels: volume.Labels || {},
usedBy: volumeUsageMap.get(volume.Name) || []
usedBy: volumeUsageMap.get(volume.Name) || [],
// Surface driver_opts (e.g. NFS/CIFS type+device+o) so the UI can
// distinguish network-backed volumes from plain local ones. Docker's
// /volumes endpoint already includes this — we were dropping it.
options: volume.Options && Object.keys(volume.Options).length > 0 ? volume.Options : undefined
}));
}
@@ -4454,11 +4828,21 @@ export async function pushImage(
// Parse tag to get registry info
const [repo, tag = 'latest'] = imageTag.split(':');
// Create X-Registry-Auth header
const authHeader = Buffer.from(JSON.stringify(authConfig)).toString('base64');
const authHeader = encodeRegistryAuth(authConfig);
logAuthDiagnostics(
'[Push]',
authConfig.serveraddress,
authConfig.serveraddress,
authConfig.username ?? '',
authConfig.password ?? '',
authHeader
);
const pushUrl = `/images/${encodeURIComponent(imageTag)}/push`;
console.log(`[Push] POST ${pushUrl} headers=X-Registry-Auth`);
const response = await dockerFetch(
`/images/${encodeURIComponent(imageTag)}/push`,
pushUrl,
{
method: 'POST',
streaming: true,
@@ -4469,8 +4853,11 @@ export async function pushImage(
envId
);
console.log(`[Push] response status=${response.status} ${response.statusText}`);
if (!response.ok) {
const error = await response.text();
console.error(`[Push] error body: ${error}`);
throw new Error(`Failed to push image: ${error}`);
}
@@ -4494,6 +4881,7 @@ export async function pushImage(
try {
const data = JSON.parse(line);
if (data.error) {
console.error(`[Push] stream error: ${line}`);
throw new Error(data.error);
}
if (onProgress) onProgress(data);
@@ -4698,18 +5086,20 @@ export async function listContainerDirectory(
// Sanitize path to prevent command injection
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
// Commands to try in order of preference
// Commands to try in order of preference (includes /usr/sbin/ls for Wolfi/busybox images)
const commands = useSimpleLs
? [
['ls', '-la', safePath],
['/bin/ls', '-la', safePath],
['/usr/bin/ls', '-la', safePath],
['/usr/sbin/ls', '-la', safePath],
]
: [
['ls', '-la', '--time-style=long-iso', safePath],
['ls', '-la', safePath],
['/bin/ls', '-la', safePath],
['/usr/bin/ls', '-la', safePath],
['/usr/sbin/ls', '-la', safePath],
];
let lastError: Error | null = null;
@@ -4792,7 +5182,7 @@ export async function statContainerPath(
containerId: string,
path: string,
envId?: number | null
): Promise<{ name: string; size: number; mode: number; mtime: string; linkTarget?: string }> {
): Promise<{ name: string; size: number; mode: number; mtime: string; linkTarget?: string; isDir: boolean }> {
// Sanitize path
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
@@ -4814,7 +5204,10 @@ export async function statContainerPath(
}
const statJson = Buffer.from(statHeader, 'base64').toString('utf-8');
return JSON.parse(statJson);
const stat = JSON.parse(statJson);
// Go's os.FileMode encodes the file type in the high bits. ModeDir = 1<<31.
// Docker emits mode in that format, so the directory bit lives at 0x80000000.
return { ...stat, isDir: (stat.mode & 0x80000000) !== 0 };
}
/**
@@ -5367,6 +5760,22 @@ export async function getVolumeArchive(
// Note: Container is kept alive for reuse. Cache TTL will handle cleanup.
}
/**
* Stat a path inside a volume via the helper container.
* Returns the same shape as statContainerPath (#1180 raw download needs isDir).
*/
export async function statVolumePath(
volumeName: string,
path: string,
envId?: number | null,
readOnly: boolean = true
): Promise<{ name: string; size: number; mode: number; mtime: string; linkTarget?: string; isDir: boolean }> {
const containerId = await getOrCreateVolumeHelperContainer(volumeName, envId, readOnly);
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
const fullPath = `${VOLUME_MOUNT_PATH}${safePath.startsWith('/') ? safePath : '/' + safePath}`;
return statContainerPath(containerId, fullPath, envId);
}
/**
* Read file content from volume
* Uses cached helper containers for better performance.
+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;
}
+167 -3
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;
@@ -363,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;
@@ -375,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 {
@@ -954,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',
@@ -982,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);
@@ -1065,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} ----------------------------------------`);
@@ -1076,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
@@ -1306,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',
@@ -1319,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;
@@ -1338,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);
+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);
}
-759
View File
@@ -1,759 +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 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)}` };
}
}
// 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;
+340 -25
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,7 +34,9 @@ import {
removePendingContainerUpdate,
deleteAutoUpdateSchedule,
getAutoUpdateSetting,
getStackSourceByComposePath
getStackSourceByComposePath,
getExternalStackPaths,
addExternalStackPath
} from './db';
import { unregisterSchedule } from './scheduler';
import { deleteGitStackFiles, parseEnvFileContent } from './git';
@@ -61,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;
}
/**
@@ -111,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[];
}
// =============================================================================
@@ -335,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)
@@ -541,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 &&
@@ -830,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;
}
/**
@@ -855,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)
@@ -907,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:`);
@@ -951,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.
@@ -1015,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)}`);
@@ -1030,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)}`);
@@ -1056,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');
@@ -1126,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']
});
@@ -1250,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
@@ -1327,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...`);
@@ -1345,6 +1552,8 @@ async function executeComposeViaHawser(
success: boolean;
output?: string;
error?: string;
deletedFiles?: string[];
skippedFiles?: { path: string; reason: string }[];
};
console.log(`${logPrefix} ----------------------------------------`);
@@ -1358,16 +1567,50 @@ 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) {
@@ -1396,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;
@@ -1486,7 +1729,9 @@ async function executeComposeCommand(
composeFileName,
build,
noBuildCache,
pullPolicy
pullPolicy,
filesToDelete,
removeFiles
);
}
@@ -1525,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,
@@ -1546,6 +1799,7 @@ async function executeComposeCommand(
noBuildCache,
pullPolicy
);
}
}
}
@@ -1906,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).
@@ -1974,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;
@@ -2043,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',
{
@@ -2051,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,
@@ -2222,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} ========================================`);
@@ -2254,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
@@ -2306,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);
@@ -2377,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,
@@ -2393,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;
});
}
@@ -2539,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;
@@ -2584,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 {
+51 -4
View File
@@ -3,7 +3,7 @@ import { browser } from '$app/environment';
export type TimeFormat = '12h' | '24h';
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
export type DownloadFormat = 'tar' | 'tar.gz';
export type DownloadFormat = 'tar' | 'tar.gz' | 'raw';
export type EventCollectionMode = 'stream' | 'poll';
export type LabelFilterMode = 'any' | 'all';
@@ -24,7 +24,8 @@ export interface AppSettings {
eventCleanupEnabled: boolean;
scannerCleanupCron: string;
scannerCleanupEnabled: boolean;
logBufferSizeKb: number;
logBufferSizeKb: number; // legacy, retained for migration — UI uses logMaxLines
logMaxLines: number; // line-count cap for the log buffer (replaces KB-based limit)
defaultTimezone: string;
eventCollectionMode: EventCollectionMode;
eventPollInterval: number;
@@ -38,6 +39,8 @@ export interface AppSettings {
defaultTrivyImage: string;
defaultComposeTemplate: string;
labelFilterMode: LabelFilterMode;
honorProxyLabels: boolean;
showImageChangelogLinks: boolean;
}
const DEFAULT_SETTINGS: AppSettings = {
@@ -58,6 +61,7 @@ const DEFAULT_SETTINGS: AppSettings = {
scannerCleanupCron: '0 3 * * 0',
scannerCleanupEnabled: true,
logBufferSizeKb: 500,
logMaxLines: 2000,
defaultTimezone: 'UTC',
eventCollectionMode: 'stream',
eventPollInterval: 60000,
@@ -70,6 +74,8 @@ const DEFAULT_SETTINGS: AppSettings = {
defaultGrypeImage: 'anchore/grype:v0.110.0',
defaultTrivyImage: 'aquasec/trivy:0.69.3',
labelFilterMode: 'any',
honorProxyLabels: true,
showImageChangelogLinks: true,
defaultComposeTemplate: `version: "3.8"
services:
@@ -90,6 +96,20 @@ services:
`
};
// Derive logMaxLines from a settings payload — prefers the new field; if absent
// (older DB), converts the legacy KB value at ~8 lines/KB (Docker log lines
// average ~120 chars). Clamps to a sensible range.
function deriveLogMaxLines(s: { logMaxLines?: number; logBufferSizeKb?: number }): number {
// Cap at 2000 — anything larger thrashes the browser when rendering with no
// virtualization. Old DBs may have absurd values from when the cap was 50K;
// snap them down here so users don't get a stuck page after upgrade.
if (typeof s.logMaxLines === 'number' && s.logMaxLines > 0) {
return Math.min(2000, Math.max(100, s.logMaxLines));
}
const kb = s.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb;
return Math.min(2000, Math.max(100, Math.round(kb * 8)));
}
// Create a writable store for app settings
function createSettingsStore() {
const { subscribe, set, update } = writable<AppSettings>(DEFAULT_SETTINGS);
@@ -122,6 +142,7 @@ function createSettingsStore() {
scannerCleanupCron: settings.scannerCleanupCron ?? DEFAULT_SETTINGS.scannerCleanupCron,
scannerCleanupEnabled: settings.scannerCleanupEnabled ?? DEFAULT_SETTINGS.scannerCleanupEnabled,
logBufferSizeKb: settings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
logMaxLines: deriveLogMaxLines(settings),
defaultTimezone: settings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
eventCollectionMode: settings.eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode,
eventPollInterval: settings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval,
@@ -134,7 +155,9 @@ function createSettingsStore() {
defaultGrypeImage: settings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
defaultTrivyImage: settings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage,
defaultComposeTemplate: settings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: settings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode
labelFilterMode: settings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
honorProxyLabels: settings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
showImageChangelogLinks: settings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks
});
}
} catch {
@@ -172,6 +195,7 @@ function createSettingsStore() {
scannerCleanupCron: updatedSettings.scannerCleanupCron ?? DEFAULT_SETTINGS.scannerCleanupCron,
scannerCleanupEnabled: updatedSettings.scannerCleanupEnabled ?? DEFAULT_SETTINGS.scannerCleanupEnabled,
logBufferSizeKb: updatedSettings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
logMaxLines: deriveLogMaxLines(updatedSettings),
defaultTimezone: updatedSettings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
eventCollectionMode: updatedSettings.eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode,
eventPollInterval: updatedSettings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval,
@@ -184,7 +208,9 @@ function createSettingsStore() {
defaultGrypeImage: updatedSettings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
defaultTrivyImage: updatedSettings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage,
defaultComposeTemplate: updatedSettings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: updatedSettings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode
labelFilterMode: updatedSettings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
honorProxyLabels: updatedSettings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
showImageChangelogLinks: updatedSettings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks
});
}
} catch (error) {
@@ -330,6 +356,13 @@ function createSettingsStore() {
return newSettings;
});
},
setLogMaxLines: (value: number) => {
update((current) => {
const newSettings = { ...current, logMaxLines: value };
saveSettings({ logMaxLines: value });
return newSettings;
});
},
setDefaultTimezone: (value: string) => {
update((current) => {
const newSettings = { ...current, defaultTimezone: value };
@@ -421,6 +454,20 @@ function createSettingsStore() {
return newSettings;
});
},
setHonorProxyLabels: (value: boolean) => {
update((current) => {
const newSettings = { ...current, honorProxyLabels: value };
saveSettings({ honorProxyLabels: value });
return newSettings;
});
},
setShowImageChangelogLinks: (value: boolean) => {
update((current) => {
const newSettings = { ...current, showImageChangelogLinks: value };
saveSettings({ showImageChangelogLinks: value });
return newSettings;
});
},
// Manual refresh from database
refresh: () => {
initialized = false;
+12 -2
View File
@@ -20,6 +20,7 @@ export interface ThemePreferences {
gridFontSize: FontSize;
terminalFont: string;
editorFont: string;
animateIcons: boolean;
}
const STORAGE_KEY = 'dockhand-theme';
@@ -31,7 +32,8 @@ const defaultPrefs: ThemePreferences = {
fontSize: 'normal',
gridFontSize: 'normal',
terminalFont: 'system-mono',
editorFont: 'system-mono'
editorFont: 'system-mono',
animateIcons: true
};
// Font size scale mapping
@@ -100,7 +102,12 @@ function createThemeStore() {
fontSize: data.fontSize || data.font_size || 'normal',
gridFontSize: data.gridFontSize || data.grid_font_size || 'normal',
terminalFont: data.terminalFont || data.terminal_font || 'system-mono',
editorFont: data.editorFont || data.editor_font || 'system-mono'
editorFont: data.editorFont || data.editor_font || 'system-mono',
// Default ON (#1169)
animateIcons:
data.animateIcons === undefined && data.animate_icons === undefined
? true
: !!(data.animateIcons ?? data.animate_icons)
};
set(prefs);
saveToStorage(prefs);
@@ -198,6 +205,9 @@ export function applyTheme(prefs: ThemePreferences) {
// Apply editor font
applyEditorFont(prefs.editorFont);
// Apply icon animation toggle (#1169) — single class on <html> drives a CSS rule in app.css
document.documentElement.classList.toggle('no-icon-animation', !prefs.animateIcons);
}
// Apply font to document
+4
View File
@@ -65,6 +65,10 @@ export interface VolumeInfo {
createdAt?: string;
created: string; // Alias for createdAt, populated by API
usedBy?: VolumeUsage[]; // Containers using this volume
// driver_opts from the underlying volume — present for non-trivially
// configured volumes (NFS, CIFS, BTRFS subvolumes, etc.). The 'type'
// key here is what the volumes list surfaces as the Type column.
options?: Record<string, string>;
}
export interface NetworkInfo {
+45
View File
@@ -0,0 +1,45 @@
export type ChangelogToken =
| { kind: 'text'; value: string }
| { kind: 'issue'; num: number }
| { kind: 'pr'; num: number }
| { kind: 'user'; name: string };
const PATTERN = /PR#(\d+)|#(\d+)|@([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,38}))/g;
export function parseChangelogTokens(text: string): ChangelogToken[] {
const tokens: ChangelogToken[] = [];
let lastIndex = 0;
for (const match of text.matchAll(PATTERN)) {
const start = match.index ?? 0;
if (start > lastIndex) {
tokens.push({ kind: 'text', value: text.slice(lastIndex, start) });
}
if (match[1]) {
tokens.push({ kind: 'pr', num: Number(match[1]) });
} else if (match[2]) {
tokens.push({ kind: 'issue', num: Number(match[2]) });
} else if (match[3]) {
tokens.push({ kind: 'user', name: match[3] });
}
lastIndex = start + match[0].length;
}
if (lastIndex < text.length) {
tokens.push({ kind: 'text', value: text.slice(lastIndex) });
}
return tokens;
}
export const GITHUB_REPO = 'Finsys/dockhand';
export function tokenHref(token: ChangelogToken): string | null {
switch (token.kind) {
case 'issue':
return `https://github.com/${GITHUB_REPO}/issues/${token.num}`;
case 'pr':
return `https://github.com/${GITHUB_REPO}/pull/${token.num}`;
case 'user':
return `https://github.com/${token.name}`;
default:
return null;
}
}
+63
View File
@@ -0,0 +1,63 @@
/**
* Resolve a changelog / release-notes URL for a container image (#538).
*
* Three tiers in priority order:
* 1. `dockhand.changelog.url` label explicit override set by the image
* author or at runtime via `--label dockhand.changelog.url=…`. Wins over
* everything. Pattern matches the existing dockhand.* label convention.
* 2. `org.opencontainers.image.source` label the OCI standard. When it
* points at github.com the canonical changelog page is `<source>/releases`.
* 3. GHCR images `ghcr.io/<owner>/<repo>` is always the same as
* `github.com/<owner>/<repo>`, so the release page is deterministic.
*
* No tier 3-style fuzzy match against Docker Hub. Wrong-repo matches are a
* worse UX than no link, and there is no good answer for unlabelled
* upstream images like `nginx:latest` (nginx's changelog isn't on GitHub).
*
* Pure function: deterministic, no I/O, safe to call from a render loop.
*/
const GITHUB_HOST = 'github.com';
const GHCR_PREFIX = 'ghcr.io/';
function stripTrailingSlash(s: string): string {
return s.endsWith('/') ? s.slice(0, -1) : s;
}
function stripImageTag(image: string): string {
// "image@sha256:…" — split on @ first so we don't lose the digest fragment.
const atIdx = image.indexOf('@');
const withoutDigest = atIdx >= 0 ? image.slice(0, atIdx) : image;
const colonIdx = withoutDigest.lastIndexOf(':');
// A colon before a slash is a port in a registry hostname, not a tag.
if (colonIdx > withoutDigest.lastIndexOf('/')) {
return withoutDigest.slice(0, colonIdx);
}
return withoutDigest;
}
export function resolveChangelogUrl(
imageName: string | null | undefined,
labels?: Record<string, string> | null
): string | null {
if (!imageName) return null;
const override = labels?.['dockhand.changelog.url'];
if (override && override.trim()) return override.trim();
const source = labels?.['org.opencontainers.image.source'];
if (source && source.includes(GITHUB_HOST)) {
return stripTrailingSlash(source) + '/releases';
}
if (imageName.startsWith(GHCR_PREFIX)) {
const repo = stripImageTag(imageName.slice(GHCR_PREFIX.length));
// Sanity guard: GHCR images are always owner/repo. Single-segment values
// like `ghcr.io/something` are malformed; skip rather than emit a bad URL.
if (repo.split('/').length >= 2) {
return `https://github.com/${repo}/releases`;
}
}
return null;
}

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