Compare commits

...

33 Commits

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

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

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

Fixes #360

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

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

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

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

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

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

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

Fixes #819
2026-04-19 16:01:50 +02:00
Tim Huge d10f6dfd6d Fix: Remove Telegram link preview 2026-04-19 15:57:52 +02:00
228 changed files with 33064 additions and 2576 deletions
+2 -2
View File
@@ -37,7 +37,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
" - busybox" \
" - tzdata" \
" - docker-cli" \
" - docker-compose=5.1.3-r0" \
" - docker-compose=5.1.4-r5" \
" - docker-cli-buildx" \
" - sqlite" \
" - postgresql-client" \
@@ -93,7 +93,7 @@ RUN cp -r node_modules/better-sqlite3/build /tmp/better-sqlite3-build \
&& rm -rf node_modules/@types /tmp/better-sqlite3-build
# Build Go collector
FROM --platform=$BUILDPLATFORM golang:1.25.9 AS go-builder
FROM --platform=$BUILDPLATFORM golang:1.25.11 AS go-builder
ARG TARGETARCH
WORKDIR /app
COPY collector/ ./collector/
+102
View File
@@ -36,6 +36,108 @@ Dockhand is a modern, efficient Docker management application providing real-tim
- **Database**: SQLite or PostgreSQL via Drizzle ORM
- **Docker**: direct docker API calls.
## Screenshots
<table>
<tr>
<td width="50%">
<img src="docs/screenshot1.webp" alt="Environments overview">
<p align="center"><sub><sub><sub><b>Environments overview</b> — manage every Docker host from one place</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot2.webp" alt="Environment dashboard">
<p align="center"><sub><sub><sub><b>Environment dashboard</b> — live CPU, memory and disk metrics per host</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot3.webp" alt="Containers">
<p align="center"><sub><sub><sub><b>Containers</b> — real-time status, resources and port mappings</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot6.webp" alt="Compose stacks">
<p align="center"><sub><sub><sub><b>Compose stacks</b> — deploy and orchestrate multi-container apps</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot7.webp" alt="Compose editor">
<p align="center"><sub><sub><sub><b>Compose editor</b> — edit YAML side-by-side with env variables</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot8.webp" alt="Images">
<p align="center"><sub><sub><sub><b>Images</b> — track tags, sizes, updates and clean up unused</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot4.webp" alt="Logs and terminal">
<p align="center"><sub><sub><sub><b>Logs &amp; terminal</b> — stream logs with a shell next to them</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot5.webp" alt="Interactive shell">
<p align="center"><sub><sub><sub><b>Interactive shell</b> — exec straight into any container</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot10.webp" alt="Add environment">
<p align="center"><sub><sub><sub><b>Add environment</b> — connect via socket, agent or direct TCP</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot9.webp" alt="Settings and theming">
<p align="center"><sub><sub><sub><b>Settings &amp; theming</b> — themes, fonts, scanners and schedules</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot11.webp" alt="Network graph">
<p align="center"><sub><sub><sub><b>Network graph</b> — visualize how services connect across stacks</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot13.webp" alt="Container file browser">
<p align="center"><sub><sub><sub><b>Container files</b> — browse, edit, upload and download in-place</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot12.webp" alt="Image layers">
<p align="center"><sub><sub><sub><b>Image layers</b> — inspect every layer, its size and contents</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot14.webp" alt="Vulnerability scanning">
<p align="center"><sub><sub><sub><b>Vulnerability scans</b> — Grype &amp; Trivy CVE results per image</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot15.webp" alt="Volume browser">
<p align="center"><sub><sub><sub><b>Volume browser</b> — explore and edit files inside any volume</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot19.webp" alt="Stack graph editor">
<p align="center"><sub><sub><sub><b>Stack graph editor</b> — visual editor for services, networks and secrets</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot16.webp" alt="Deploy from Git">
<p align="center"><sub><sub><sub><b>Deploy from Git</b> — pull stacks from repos with webhooks &amp; auto-sync</sub></sub></sub></p>
</td>
<td width="50%">
<img src="docs/screenshot17.webp" alt="Schedules">
<p align="center"><sub><sub><sub><b>Schedules</b> — cron-style automation for prune, updates and cleanup</sub></sub></sub></p>
</td>
</tr>
<tr>
<td width="50%">
<img src="docs/screenshot18.webp" alt="Activity log">
<p align="center"><sub><sub><sub><b>Activity log</b> — audit every action across all environments</sub></sub></sub></p>
</td>
<td width="50%"></td>
</tr>
</table>
## License
Dockhand is licensed under the [Business Source License 1.1](LICENSE.txt) (BSL 1.1).
+1 -1
View File
@@ -1 +1 @@
v1.0.26
v1.0.34
+1 -1
View File
@@ -1,3 +1,3 @@
module github.com/Finsys/dockhand/collector
go 1.25.9
go 1.25.11
+67 -21
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
}
@@ -421,7 +447,7 @@ func (m *manager) collectMetrics(env *environment) {
sCtx, sCancel := context.WithTimeout(env.ctx, 10*time.Second)
defer sCancel()
sResp, sErr := env.doRequest(sCtx, "GET", fmt.Sprintf("/containers/%s/stats?stream=false&one-shot=true", id))
sResp, sErr := env.doRequest(sCtx, "GET", fmt.Sprintf("/containers/%s/stats?stream=false", id))
if sErr != nil {
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
}
+1 -1
View File
@@ -10,7 +10,7 @@ PGID=${PGID:-1001}
export BODY_SIZE_LIMIT=${BODY_SIZE_LIMIT:-2G}
# Default command (--expose-gc allows forced GC from /api/debug/memory?gc=true)
# Custom CA: set NODE_EXTRA_CA_CERTS=/path/to/ca.crt (appends to built-in CAs)
# Custom CA: set NODE_EXTRA_CA_CERTS=/path/to/ca.crt (appends to built-in CAs, git ops auto-merge with system CAs)
# Enterprise (system CA store): set NODE_OPTIONS="--use-openssl-ca"
if [ "$MEMORY_MONITOR" = "true" ]; then
DEFAULT_CMD="node --dns-result-order=ipv4first --no-network-family-autoselection --expose-gc /app/server.js"
Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

@@ -0,0 +1,2 @@
ALTER TABLE "git_stacks" ADD COLUMN "context_dir" text;--> statement-breakpoint
ALTER TABLE "git_stacks" ADD COLUMN "no_build_cache" boolean DEFAULT false;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "git_stacks" ADD COLUMN "synced_files" text;
+12
View File
@@ -0,0 +1,12 @@
CREATE TABLE "template_sources" (
"id" serial PRIMARY KEY NOT NULL,
"source_id" text NOT NULL,
"name" text NOT NULL,
"url" text NOT NULL,
"enabled" boolean DEFAULT true,
"builtin" boolean DEFAULT false,
"sort_order" integer DEFAULT 0,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "template_sources_source_id_unique" UNIQUE("source_id")
);
+4 -4
View File
@@ -2352,14 +2352,14 @@
"primaryKey": false,
"notNull": false
},
"external_compose_path": {
"name": "external_compose_path",
"compose_path": {
"name": "compose_path",
"type": "text",
"primaryKey": false,
"notNull": false
},
"external_env_path": {
"name": "external_env_path",
"env_path": {
"name": "env_path",
"type": "text",
"primaryKey": false,
"notNull": false
+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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -43,6 +43,27 @@
"when": 1775312212996,
"tag": "0005_add_api_tokens",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1777220350655,
"tag": "0006_add_git_stack_context_dir",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1781158711008,
"tag": "0007_add_synced_files",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1781620381909,
"tag": "0008_add_template_sources",
"breakpoints": true
}
]
}
@@ -0,0 +1,2 @@
ALTER TABLE `git_stacks` ADD `context_dir` text;--> statement-breakpoint
ALTER TABLE `git_stacks` ADD `no_build_cache` integer DEFAULT false;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE `git_stacks` ADD `synced_files` text;
+13
View File
@@ -0,0 +1,13 @@
CREATE TABLE `template_sources` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`source_id` text NOT NULL,
`name` text NOT NULL,
`url` text NOT NULL,
`enabled` integer DEFAULT true,
`builtin` integer DEFAULT false,
`sort_order` integer DEFAULT 0,
`created_at` text DEFAULT CURRENT_TIMESTAMP,
`updated_at` text DEFAULT CURRENT_TIMESTAMP
);
--> statement-breakpoint
CREATE UNIQUE INDEX `template_sources_source_id_unique` ON `template_sources` (`source_id`);
+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",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -43,6 +43,27 @@
"when": 1775311743346,
"tag": "0005_add_api_tokens",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1777220350655,
"tag": "0006_add_git_stack_context_dir",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1781158702731,
"tag": "0007_add_synced_files",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1781620376161,
"tag": "0008_add_template_sources",
"breakpoints": true
}
]
}
+10 -9
View File
@@ -1,7 +1,7 @@
{
"name": "dockhand",
"private": true,
"version": "1.0.26",
"version": "1.0.34",
"type": "module",
"scripts": {
"dev": "npx vite dev",
@@ -63,8 +63,9 @@
"@codemirror/lang-python": "6.2.1",
"@codemirror/lang-sql": "6.10.0",
"@codemirror/lang-xml": "6.1.0",
"@codemirror/lang-yaml": "6.1.2",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.1",
"@codemirror/legacy-modes": "6.5.3",
"@codemirror/search": "6.6.0",
"@codemirror/state": "6.5.4",
"@codemirror/theme-one-dark": "6.1.3",
@@ -76,19 +77,19 @@
"better-sqlite3": "11.7.0",
"croner": "9.1.0",
"cronstrue": "3.9.0",
"devalue": "5.6.4",
"devalue": "5.8.1",
"drizzle-orm": "0.45.2",
"fast-xml-parser": "5.5.8",
"fast-xml-parser": "5.7.3",
"js-yaml": "4.1.1",
"ldapts": "8.1.3",
"nodemailer": "8.0.5",
"nodemailer": "8.0.9",
"otpauth": "9.4.1",
"postgres": "3.4.8",
"qrcode": "1.5.4",
"rollup": "4.60.0",
"svelte-sonner": "1.0.7",
"undici": "7.24.5",
"ws": "8.18.0"
"ws": "8.21.0"
},
"devDependencies": {
"@internationalized/date": "^3.10.1",
@@ -116,10 +117,10 @@
"d3-shape": "^3.2.0",
"drizzle-kit": "0.31.8",
"layerchart": "^1.0.13",
"lucide-svelte": "^0.562.0",
"lucide-svelte": "0.562.0",
"mode-watcher": "^1.1.0",
"postcss": "^8.5.6",
"svelte": "5.53.5",
"svelte": "5.55.7",
"svelte-check": "^4.3.5",
"svelte-easy-crop": "^5.0.0",
"tailwind-merge": "^3.4.0",
@@ -137,6 +138,6 @@
"@codemirror/search": "6.6.0",
"@lezer/common": "1.5.0",
"@lezer/highlight": "1.2.3",
"devalue": "5.6.4"
"devalue": "5.8.1"
}
}
+128 -10
View File
@@ -8,11 +8,12 @@
* Usage: node ./server.js
*/
import { createServer, request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import { createServer as createHttpServer, request as httpRequest } from 'node:http';
import { createServer as createHttpsServer, request as httpsRequest } from 'node:https';
import { createConnection } from 'node:net';
import { connect as tlsConnect, rootCertificates } from 'node:tls';
import { randomUUID } from 'node:crypto';
import { randomUUID, X509Certificate } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { WebSocketServer } from 'ws';
import { handler } from './build/handler.js';
@@ -28,10 +29,82 @@ console.warn = (...args) => _warn(ts(), ...args);
const PORT = parseInt(process.env.PORT || '3000', 10);
const HOST = process.env.HOST || '0.0.0.0';
// Create HTTP server with SvelteKit handler
const server = createServer((req, res) => {
handler(req, res);
});
// Optional native HTTPS listener (#1102). Off by default to keep existing
// deployments unchanged. When HTTPS_MODE=on, HTTPS_CERT_PATH and
// HTTPS_KEY_PATH must both point to readable PEM files.
const HTTPS_MODE = (process.env.HTTPS_MODE || 'off').toLowerCase();
const useHttps = HTTPS_MODE === 'on';
let server;
if (useHttps) {
const certPath = process.env.HTTPS_CERT_PATH;
const keyPath = process.env.HTTPS_KEY_PATH;
const caPath = process.env.HTTPS_CA_PATH;
console.log('[HTTPS] mode=on');
console.log(`[HTTPS] cert=${certPath || '(missing)'}`);
console.log(`[HTTPS] key=${keyPath || '(missing)'}`);
console.log(`[HTTPS] ca=${caPath || '(none)'}`);
if (!certPath || !keyPath) {
console.error('[HTTPS] HTTPS_MODE=on requires HTTPS_CERT_PATH and HTTPS_KEY_PATH');
process.exit(1);
}
let certPem, keyPem, caPem;
try {
certPem = readFileSync(certPath);
keyPem = readFileSync(keyPath);
if (caPath) caPem = readFileSync(caPath);
} catch (e) {
console.error(`[HTTPS] Failed to read cert/key file: ${e.message}`);
process.exit(1);
}
// Parse cert metadata so operators can confirm they mounted the right file.
try {
const x509 = new X509Certificate(certPem);
console.log(`[HTTPS] cert subject: ${x509.subject.replace(/\n/g, ', ')}`);
console.log(`[HTTPS] cert issuer: ${x509.issuer.replace(/\n/g, ', ')}`);
console.log(`[HTTPS] cert SAN: ${x509.subjectAltName || '(none)'}`);
console.log(`[HTTPS] cert valid: ${x509.validFrom}${x509.validTo}`);
const expiresAt = new Date(x509.validTo).getTime();
const daysLeft = Math.floor((expiresAt - Date.now()) / 86400000);
if (daysLeft < 0) {
console.warn(`[HTTPS] WARNING: certificate expired ${-daysLeft} day(s) ago`);
} else if (daysLeft < 30) {
console.warn(`[HTTPS] WARNING: certificate expires in ${daysLeft} day(s)`);
} else {
console.log(`[HTTPS] cert expires in ${daysLeft} day(s)`);
}
} catch (e) {
console.error(`[HTTPS] Failed to parse certificate: ${e.message}`);
process.exit(1);
}
const tlsOptions = { cert: certPem, key: keyPem };
if (caPem) tlsOptions.ca = caPem;
// HSTS — only meaningful over HTTPS, so wired only here. Default 1 year;
// set HSTS_MAX_AGE=0 to disable.
const hstsMaxAge = parseInt(process.env.HSTS_MAX_AGE ?? '31536000', 10);
const hstsHeader = hstsMaxAge > 0 ? `max-age=${hstsMaxAge}` : null;
if (hstsHeader) {
console.log(`[HTTPS] HSTS enabled: ${hstsHeader}`);
} else {
console.log('[HTTPS] HSTS disabled (HSTS_MAX_AGE=0)');
}
server = createHttpsServer(tlsOptions, (req, res) => {
if (hstsHeader) res.setHeader('Strict-Transport-Security', hstsHeader);
handler(req, res);
});
} else {
console.log(`[HTTPS] mode=off (set HTTPS_MODE=on to enable native TLS)`);
server = createHttpServer((req, res) => {
handler(req, res);
});
}
// Create WebSocket server attached to the HTTP server
const wss = new WebSocketServer({ noServer: true });
@@ -95,7 +168,7 @@ globalThis.__terminalHandleExecMessage = (msg) => {
};
// Handle WebSocket upgrade
server.on('upgrade', (req, socket, head) => {
server.on('upgrade', async (req, socket, head) => {
const url = new URL(req.url || '/', `http://${req.headers.host}`);
// Only handle our specific WebSocket paths
@@ -107,7 +180,30 @@ server.on('upgrade', (req, socket, head) => {
return;
}
let wsAuth = null;
if (isTerminal) {
try {
if (typeof globalThis.__authenticateWsUpgrade !== 'function') {
socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
wsAuth = await globalThis.__authenticateWsUpgrade(req.headers);
if (!wsAuth) {
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
} catch (err) {
console.error('[WS] auth error during upgrade:', err);
socket.write('HTTP/1.1 500 Internal Server Error\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
}
wss.handleUpgrade(req, socket, head, (ws) => {
if (wsAuth) ws.__auth = wsAuth;
wss.emit('connection', ws, req);
});
});
@@ -150,6 +246,22 @@ async function handleTerminalConnection(ws, url, connId) {
return;
}
if (ws.__auth && typeof globalThis.__canAccessEnvForUser === 'function') {
try {
const ok = await globalThis.__canAccessEnvForUser(ws.__auth, envId);
if (!ok) {
console.warn(`[WS] env access denied: user=${ws.__auth.username} envId=${envId}`);
ws.send(JSON.stringify({ type: 'error', message: 'Access denied for this environment' }));
ws.close(1008, 'env access denied');
return;
}
} catch (err) {
console.error('[WS] env access check failed:', err);
ws.close(1011, 'internal error');
return;
}
}
try {
// Resolve Docker target via SvelteKit app's database
let target;
@@ -430,7 +542,12 @@ function handleHawserConnection(ws, connId, remoteIp) {
// Use the global hawser message handler injected by the SvelteKit app
if (typeof globalThis.__hawserHandleMessage === 'function') {
await globalThis.__hawserHandleMessage(ws, msg, connId, remoteIp);
try {
await globalThis.__hawserHandleMessage(ws, msg, connId, remoteIp);
} catch (handlerError) {
console.error('[Hawser WS] Handler error:', handlerError);
// Don't close connection - let it recover
}
} else {
console.warn('[Hawser WS] No global handler registered');
ws.send(JSON.stringify({ type: 'error', message: 'Server not ready' }));
@@ -453,7 +570,8 @@ function handleHawserConnection(ws, connId, remoteIp) {
// Start the server
server.listen(PORT, HOST, () => {
console.log(`Listening on http://${HOST}:${PORT}/ with WebSocket`);
const scheme = useHttps ? 'https' : 'http';
console.log(`Listening on ${scheme}://${HOST}:${PORT}/ with WebSocket`);
});
+65
View File
@@ -74,6 +74,33 @@ html {
max-width: calc(90px * var(--grid-font-size-scale, 1)) !important;
}
/* Scrollbar theming WebKit only (Sencho-style). No global * selector and
* no scrollbar-width override, so Firefox/native scrollbars render at OS
* default width. Dark-mode thumb bumped to be visible on dark surfaces. */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
/* Light mode: medium gray that holds up against white. Pale border-color
* at 50% was nearly invisible. */
background: hsl(0 0% 60% / 0.6);
border-radius: 4px;
transition: background 150ms ease;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(0 0% 40% / 0.8);
}
.dark ::-webkit-scrollbar-thumb {
background: hsl(0 0% 50% / 0.5);
}
.dark ::-webkit-scrollbar-thumb:hover {
background: hsl(0 0% 65% / 0.7);
}
:root {
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
@@ -1314,6 +1341,16 @@ html {
line-height: 14px;
}
/* Icon animation toggle (#1169): when html.no-icon-animation is set, the
common Tailwind animation utilities collapse to no-op. This keeps the
layout (spinners still occupy space) but removes the motion. */
html.no-icon-animation .animate-spin,
html.no-icon-animation .animate-pulse,
html.no-icon-animation .animate-bounce,
html.no-icon-animation .animate-ping {
animation: none !important;
}
/* Icon glow utilities - standard size (4px blur, 0.6 opacity) */
.glow-green { filter: drop-shadow(0 0 4px rgba(34, 197, 94, 0.6)); }
.glow-green-sm { filter: drop-shadow(0 0 3px rgba(34, 197, 94, 0.5)); }
@@ -1753,3 +1790,31 @@ html {
.ansi-dim { opacity: 0.7; }
.ansi-italic { font-style: italic; }
.ansi-underline { text-decoration: underline; }
/* Log line numbers */
.log-line {
min-height: 1.2em;
}
pre.show-line-numbers {
counter-reset: log-line;
}
pre.show-line-numbers .log-line {
counter-increment: log-line;
padding-left: 4.5em;
position: relative;
}
pre.show-line-numbers .log-line::before {
content: counter(log-line);
position: absolute;
left: 0;
width: 3.5em;
text-align: right;
padding-right: 0.75em;
user-select: none;
color: rgb(113 113 122); /* zinc-500 */
border-right: 1px solid rgb(63 63 70); /* zinc-700 */
}
:where(.light, .light *) pre.show-line-numbers .log-line::before {
color: rgb(156 163 175); /* gray-400 */
border-right-color: rgb(209 213 219); /* gray-300 */
}
+5 -4
View File
@@ -3,11 +3,12 @@
import type { AuthenticatedUser } from '$lib/server/auth';
// Build-time constants injected by Vite
declare const __BUILD_DATE__: string | null;
declare const __BUILD_COMMIT__: string | null;
declare global {
// Build-time constants injected by Vite
const __APP_VERSION__: string | null;
const __BUILD_DATE__: string | null;
const __BUILD_COMMIT__: string | null;
namespace App {
// interface Error {}
interface Locals {
+5 -10
View File
@@ -18,6 +18,11 @@ import { join } from 'path';
import type { HandleServerError, Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
import { startRssTracker, stopRssTracker, rssBeforeOp, rssAfterOp } from '$lib/server/rss-tracker';
import { getClientIp } from '$lib/server/client-ip';
// Side-effect import: installs globalThis.__authenticateWsUpgrade and
// globalThis.__canAccessEnvForUser used by the raw WS upgrade handlers in
// server.js / vite.config.ts to authenticate /api/containers/*/exec.
import '$lib/server/ws-auth';
// Content types worth compressing
const COMPRESSIBLE_TYPES = [
@@ -218,16 +223,6 @@ setInterval(() => {
}
}, BEARER_COOLDOWN_MS).unref?.();
function getClientIp(event: { request: Request; getClientAddress?: () => string }): string {
// Prefer socket-level IP (SvelteKit resolves proxy headers via adapter config)
// This prevents X-Forwarded-For spoofing to bypass rate limiting
try {
const addr = event.getClientAddress?.();
if (addr) return addr;
} catch { /* getClientAddress may throw if unavailable */ }
return 'unknown';
}
function recordBearerFailure(ip: string): void {
const now = Date.now();
const entry = bearerFailCounts.get(ip);
@@ -0,0 +1,37 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { themeStore } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth';
import { toast } from 'svelte-sonner';
interface Props {
userId?: number; // omit for global default (login page / auth-disabled)
}
let { userId }: Props = $props();
// Same "skip applying" rule as ThemeSelector: don't toggle the live document
// when the admin is editing the global default while logged in (their own
// per-user preference still drives their session).
const skipApply = $derived($authStore.loading ? true : ($authStore.authEnabled && !userId));
let checked = $state(true);
$effect(() => {
checked = $themeStore.animateIcons;
});
function onToggle(value: boolean) {
checked = value;
themeStore.setPreference('animateIcons', value, userId, skipApply);
toast.success(value ? 'Icon animation enabled' : 'Icon animation disabled');
}
</script>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Animate icons</Label>
<TogglePill {checked} onchange={onToggle} />
</div>
<p class="text-xs text-muted-foreground">Spinners during pulls, scans and updates.</p>
</div>
@@ -4,14 +4,7 @@
import { Progress } from '$lib/components/ui/progress';
import { Check, X, Loader2, Circle, Ban } from 'lucide-svelte';
import { onDestroy } from 'svelte';
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
import { formatBytes } from '$lib/utils/format';
const progressText: Record<string, string> = {
remove: 'removing',
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import { GitPullRequestArrow } from 'lucide-svelte';
import { parseChangelogTokens, tokenHref, type ChangelogToken } from '$lib/utils/changelog-tokens';
let { text }: { text: string } = $props();
type Group = { kind: 'text'; value: string } | { kind: 'refs'; refs: ChangelogToken[] };
const groups = $derived.by<Group[]>(() => {
const tokens = parseChangelogTokens(text);
const result: Group[] = [];
let textBuf = '';
let refBuf: ChangelogToken[] = [];
const flushText = () => {
if (textBuf) {
result.push({ kind: 'text', value: textBuf });
textBuf = '';
}
};
const flushRefs = () => {
if (refBuf.length) {
result.push({ kind: 'refs', refs: refBuf });
refBuf = [];
}
};
for (const t of tokens) {
if (t.kind === 'text') {
// If the gap between consecutive ref groups is only "glue" (whitespace,
// commas, parens), keep collecting into the same refs group. Otherwise
// it ends the group.
if (refBuf.length && /^[\s,()]*$/.test(t.value)) {
continue;
}
if (refBuf.length) {
flushRefs();
}
// Strip a trailing " (" left over before the upcoming refs group.
textBuf += t.value;
} else {
// Trim trailing glue from textBuf so we don't render "foo (".
if (refBuf.length === 0) {
textBuf = textBuf.replace(/[\s(]+$/, '');
}
flushText();
refBuf.push(t);
}
}
flushRefs();
// Trim trailing glue (e.g. ")") from leftover text.
textBuf = textBuf.replace(/^[\s,)]+/, '');
flushText();
return result;
});
function refLabel(token: ChangelogToken): string {
if (token.kind === 'issue') return `#${token.num}`;
if (token.kind === 'pr') return `#${token.num}`;
if (token.kind === 'user') return `@${token.name}`;
return '';
}
function refTitle(token: ChangelogToken): string {
if (token.kind === 'issue') return `Issue #${token.num}`;
if (token.kind === 'pr') return `Pull request #${token.num}`;
if (token.kind === 'user') return `@${token.name} on GitHub`;
return '';
}
</script>
<span class="text-sm">
{#each groups as group, i (i)}
{#if group.kind === 'text'}
{group.value}
{:else}
<span class="changelog-refs">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
{#each group.refs as ref, j (j)}
{#if j > 0}<span class="changelog-refs-sep"> · </span>{/if}
<a
href={tokenHref(ref)}
target="_blank"
rel="noopener noreferrer"
title={refTitle(ref)}
class="changelog-refs-link"
>{#if ref.kind === 'pr'}<GitPullRequestArrow class="changelog-pr-icon" />{/if}{refLabel(ref)}</a>
{/each}
</span>
{/if}
{/each}
</span>
<style>
.changelog-refs {
display: inline;
opacity: 0.55;
margin-left: 4px;
font-size: 0.75em;
}
.changelog-refs svg {
display: inline;
width: 10px;
height: 10px;
vertical-align: -1px;
margin-right: 3px;
}
.changelog-refs-link {
color: inherit;
text-decoration: none;
}
.changelog-refs-link:hover {
text-decoration: underline;
}
.changelog-refs-sep {
color: inherit;
}
.changelog-refs-link :global(.changelog-pr-icon) {
display: inline;
width: 10px;
height: 10px;
vertical-align: -1px;
margin-right: 2px;
}
</style>
+35 -8
View File
@@ -1,14 +1,18 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { EditorState, StateField, StateEffect, RangeSet } from '@codemirror/state';
import { EditorState, StateField, StateEffect, RangeSet, Prec } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, gutter, GutterMarker, Decoration, WidgetType, type DecorationSet } from '@codemirror/view';
// Note: Secret masking was removed - secrets are now excluded from the raw editor entirely
// and are only stored in the database (never written to .env file)
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, StreamLanguage, type StreamParser } from '@codemirror/language';
import { defaultKeymap, history, historyKeymap, indentWithTab, insertNewlineAndIndent } from '@codemirror/commands';
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, indentUnit, StreamLanguage, type StreamParser } from '@codemirror/language';
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
import { toml } from '@codemirror/legacy-modes/mode/toml';
import { properties } from '@codemirror/legacy-modes/mode/properties';
// Simple dotenv/env file language parser
const dotenvParser: StreamParser<{ inValue: boolean }> = {
@@ -405,7 +409,7 @@
new RegExp(`(?<!\\$)\\$\\{${marker.name}\\?`),
new RegExp(`(?<!\\$)\\$\\{${marker.name}:\\+`),
new RegExp(`(?<!\\$)\\$\\{${marker.name}\\+`),
new RegExp(`(?<!\\$)\\$${marker.name}(?![a-zA-Z0-9_])`)
new RegExp(`(?<![A-Za-z0-9\\$])\\$${marker.name}(?![a-zA-Z0-9_])`)
];
const hasVariable = varPatterns.some(p => p.test(line));
@@ -496,6 +500,21 @@
initialSpacer: () => new VariableGutterMarker('required')
});
// YAML Enter handler: after a key-only line ending with ":", indent one level
// deeper than what the default indent service returns (it can't predict child
// indent when no child content exists yet).
function yamlNewlineAndIndent(view: EditorView): boolean {
const { state } = view;
const line = state.doc.lineAt(state.selection.main.head);
const withoutComment = line.text.trimEnd().replace(/#.*$/, '').trimEnd();
if (!withoutComment.endsWith(':')) return false;
insertNewlineAndIndent(view);
const unit = state.facet(indentUnit);
const cursor = view.state.selection.main.head;
view.dispatch({ changes: { from: cursor, insert: unit }, selection: { anchor: cursor + unit.length } });
return true;
}
// Get language extension based on language name
function getLanguageExtension(lang: string) {
switch (lang) {
@@ -527,12 +546,18 @@
return xml();
case 'sql':
return sql();
case 'dockerfile':
case 'shell':
case 'bash':
case 'sh':
// No dedicated shell/dockerfile support, use basic highlighting
return [];
return StreamLanguage.define(shell);
case 'dockerfile':
return StreamLanguage.define(dockerFile);
case 'toml':
return StreamLanguage.define(toml);
case 'ini':
case 'conf':
case 'properties':
return StreamLanguage.define(properties);
case 'dotenv':
case 'env':
return StreamLanguage.define(dotenvParser);
@@ -671,7 +696,9 @@
]),
...themeExtensions,
EditorView.lineWrapping,
getLanguageExtension(language)
EditorState.tabSize.of(2),
getLanguageExtension(language),
...(language === 'yaml' ? [Prec.high(keymap.of([{ key: 'Enter', run: yamlNewlineAndIndent }]))] : [])
].flat();
if (readonly) {
+3 -8
View File
@@ -9,6 +9,7 @@
import { onMount } from 'svelte';
import { appendEnvParam } from '$lib/stores/environment';
import { watchJob } from '$lib/utils/sse-fetch';
import { formatBytes } from '$lib/utils/format';
interface LayerProgress {
id: string;
@@ -98,12 +99,6 @@
localStorage.setItem('logTheme', logDarkMode ? 'dark' : 'light');
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
@@ -314,7 +309,7 @@
class="h-10"
>
{#if isPulling}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
<Download class="w-4 h-4 mr-2 animate-spin" />
Pulling...
{:else}
<Download class="w-4 h-4" />
@@ -332,7 +327,7 @@
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
{#if status === 'pulling'}
<Loader2 class="w-4 h-4 animate-spin text-blue-600" />
<Download class="w-4 h-4 animate-spin text-blue-600" />
<span class="text-sm">Pulling layers...</span>
{:else if status === 'complete'}
<CheckCircle2 class="w-4 h-4 text-green-600" />
+4 -2
View File
@@ -38,6 +38,7 @@
imageName: string;
envId?: number | null;
autoStart?: boolean;
activeScanner?: 'grype' | 'trivy';
onComplete?: (results: ScanResult[]) => void;
onError?: (error: string) => void;
onStatusChange?: (status: ScanStatus) => void;
@@ -47,6 +48,7 @@
imageName,
envId = null,
autoStart = false,
activeScanner = $bindable<'grype' | 'trivy'>('grype'),
onComplete,
onError,
onStatusChange
@@ -226,7 +228,7 @@
<Shield class="w-4 h-4 text-muted-foreground" />
<span class="text-sm text-muted-foreground">Ready to scan</span>
{:else if status === 'scanning'}
<Loader2 class="w-4 h-4 animate-spin text-blue-600" />
<Shield class="w-4 h-4 animate-spin text-blue-600" />
<span class="text-sm">Scanning for vulnerabilities...</span>
{:else if status === 'complete'}
{#if hasCriticalOrHigh}
@@ -362,7 +364,7 @@
{:else}
<!-- Scan Results -->
<div class="flex-1 min-h-0 overflow-auto">
<ScanResultsView {results} />
<ScanResultsView {results} bind:activeScanner />
</div>
{/if}
</div>
+6 -10
View File
@@ -114,12 +114,7 @@
}
const key = trimmed.slice(0, eqIndex).trim();
let value = trimmed.slice(eqIndex + 1);
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
const value = trimmed.slice(eqIndex + 1);
if (key) {
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
@@ -200,8 +195,8 @@
* Sync rawContent TO variables.
* Parses raw content for non-secrets, preserves existing secrets.
*/
function syncRawToVariables() {
const { vars, warnings } = parseRawContent(rawContent);
function syncRawToVariables(content?: string) {
const { vars, warnings } = parseRawContent(content ?? rawContent);
parseWarnings = warnings;
// Preserve existing secrets (they're not in rawContent)
@@ -240,8 +235,9 @@
// Form → Text: sync variables to raw (preserves comments)
syncVariablesToRaw();
} else if (newMode === 'form' && viewMode === 'text') {
// Text → Form: sync raw to variables (preserves secrets)
syncRawToVariables();
// Text → Form: use textEditorContent which falls back to generatedRawContent
// when rawContent is empty (fixes vars lost on view switch for git stacks)
syncRawToVariables(textEditorContent);
}
viewMode = newMode;
+3 -2
View File
@@ -3,6 +3,7 @@
import { Button } from '$lib/components/ui/button';
import { Sparkles, Bug, Zap, CheckCircle, ScrollText } from 'lucide-svelte';
import { compareVersions } from '$lib/utils/version';
import ChangelogText from '$lib/components/ChangelogText.svelte';
interface ChangelogEntry {
version: string;
@@ -62,11 +63,11 @@
<span class="text-muted-foreground font-normal">({release.date})</span>
</h3>
<div class="space-y-1.5 ml-1">
{#each release.changes as change}
{#each [...release.changes].sort((a, b) => a.type === b.type ? 0 : a.type === 'feature' ? -1 : 1) as change}
{@const { icon: Icon, class: iconClass } = getChangeIcon(change.type)}
<div class="flex items-start gap-2">
<Icon class="w-4 h-4 mt-0.5 shrink-0 {iconClass}" />
<span class="text-sm">{change.text}</span>
<ChangelogText text={change.text} />
</div>
{/each}
</div>
+26 -1
View File
@@ -22,11 +22,16 @@
User,
ClipboardList,
Activity,
Timer
Timer,
LibraryBig
} from 'lucide-svelte';
import { licenseStore } from '$lib/stores/license';
import { authStore, hasAnyAccess } from '$lib/stores/auth';
import * as Avatar from '$lib/components/ui/avatar';
import * as Tooltip from '$lib/components/ui/tooltip';
const appVersion = __APP_VERSION__ || 'unknown';
const buildCommit = __BUILD_COMMIT__ ?? null;
import type { Permissions } from '$lib/stores/auth';
@@ -97,6 +102,7 @@
{ href: '/images', Icon: Images, label: 'Images', permission: 'images' },
{ href: '/volumes', Icon: HardDrive, label: 'Volumes', permission: 'volumes' },
{ href: '/networks', Icon: Network, label: 'Networks', permission: 'networks' },
{ href: '/templates', Icon: LibraryBig, label: 'Templates', permission: 'templates' },
{ href: '/registry', Icon: Download, label: 'Registry', permission: 'registries' },
{ href: '/activity', Icon: Activity, label: 'Activity', permission: 'activity' },
{ href: '/schedules', Icon: Timer, label: 'Schedules', permission: 'schedules' },
@@ -155,6 +161,25 @@
</Sidebar.Group>
</Sidebar.Content>
<!-- Version (expanded sidebar only) -->
<div class="group-data-[state=collapsed]:hidden px-3 py-2 mt-auto text-center">
<Tooltip.Root>
<Tooltip.Trigger>
<span class="text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-default">
{appVersion}
</span>
</Tooltip.Trigger>
<Tooltip.Content side="top" align="start" sideOffset={8} class="text-xs">
<div class="space-y-0.5">
<div class="flex items-center gap-1.5"><svg class="w-4 h-4 shrink-0" viewBox="0 0 24 18" fill="currentColor"><path d="M23.76 8.68c-.26-.18-.86-.58-1.53-.58-.24 0-.48.04-.72.12-.12-.84-.68-1.56-1.34-2.14l-.28-.22-.24.26c-.28.34-.48.72-.56 1.14-.1.42-.06.82.1 1.2-.42.22-.88.36-1.32.42-.24.04-.48.06-.72.06H.78a.77.77 0 0 0-.78.78c-.02 1.46.22 2.9.72 4.24.56 1.44 1.4 2.5 2.5 3.16 1.26.74 3.32 1.16 5.64 1.16.98 0 2-.1 2.98-.3a11.5 11.5 0 0 0 3.3-1.3 9.67 9.67 0 0 0 2.54-2.34c1.16-1.42 1.86-3.02 2.34-4.38h.2c1.22 0 1.98-.48 2.4-.9.28-.26.5-.58.64-.94l.08-.24-.28-.2zM2.74 8.84H4.7c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H2.74c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.72 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H5.46c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H8.22c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zM5.46 6.2h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18H5.46c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18H8.22c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm0-2.64h1.96c.1 0 .18-.08.18-.18V1.74c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 5.28h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18z"/></svg><span class="font-mono">fnsys/dockhand:{appVersion}</span></div>
{#if buildCommit}
<div>Commit: <span class="font-mono">{buildCommit.slice(0, 7)}</span></div>
{/if}
</div>
</Tooltip.Content>
</Tooltip.Root>
</div>
<!-- User info footer (only when auth is enabled) -->
{#if $authStore.authEnabled && $authStore.authenticated && $authStore.user}
<Sidebar.Footer class="border-t">
+4 -12
View File
@@ -19,7 +19,7 @@
// Detect schedule type from cron expression
function detectScheduleType(cron: string): 'daily' | 'weekly' | 'custom' {
const parts = cron.split(' ');
if (parts.length < 5) return 'custom';
if (parts.length !== 5) return 'custom';
const [min, hr, day, month, dow] = parts;
@@ -137,23 +137,15 @@
onchange(newValue);
}
// Validate cron expression
// Validate cron expression (supports 5-field and 6-field with seconds)
function isValidCron(cron: string): boolean {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return false;
const [min, hr, day, month, dow] = parts;
if (parts.length !== 5 && parts.length !== 6) return false;
// Basic pattern validation (number, *, */n, range, list)
const cronFieldPattern = /^(\*|(\*\/\d+)|\d+(-\d+)?(,\d+(-\d+)?)*)$/;
return (
cronFieldPattern.test(min) &&
cronFieldPattern.test(hr) &&
cronFieldPattern.test(day) &&
cronFieldPattern.test(month) &&
cronFieldPattern.test(dow)
);
return parts.every((part) => cronFieldPattern.test(part));
}
// Human-readable description using cronstrue
+28 -9
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Cpu, MemoryStick, Box, Globe, ChevronDown, Check, HardDrive, Clock, Wifi, WifiOff, Route, UndoDot, Icon, AlertCircle, Loader2, Search, X } from 'lucide-svelte';
import { Cpu, MemoryStick, Box, Globe, ChevronDown, Check, HardDrive, Clock, Wifi, WifiOff, Route, UndoDot, Icon, AlertCircle, Loader2, Search, Server, X } from 'lucide-svelte';
import { whale } from '@lucide/lab';
import { Button } from '$lib/components/ui/button';
import { currentEnvironment, environments, type Environment } from '$lib/stores/environment';
@@ -9,6 +9,7 @@
import { toast } from 'svelte-sonner';
import { themeStore, type FontSize } from '$lib/stores/theme';
import { getTimeFormat } from '$lib/stores/settings';
import { formatBytes } from '$lib/utils/format';
// Font size scaling for header
let fontSize = $state<FontSize>('normal');
@@ -94,6 +95,22 @@
}
}
// Display string for the env hostname / IP in the header (#962).
// Show both when available; drop only the field that is unknown/empty.
// Hide the whole block when neither is meaningful (e.g. hawser-edge
// reports 'unknown' for both).
const hostLabel = $derived.by(() => {
if (!hostInfo) return '';
const isMeaningful = (v: string | undefined) => {
const t = (v || '').trim();
return t && t.toLowerCase() !== 'unknown';
};
const h = isMeaningful(hostInfo.hostname) ? hostInfo.hostname.trim() : '';
const ip = isMeaningful(hostInfo.ipAddress) ? hostInfo.ipAddress.trim() : '';
if (h && ip && h !== ip) return `${h} (${ip})`;
return h || ip;
});
// Reactive environment list from store
let envList = $derived($environments);
const showSearch = $derived(envList.length > 8);
@@ -218,14 +235,6 @@
(diskUsage.Volumes?.reduce((sum: number, v: any) => sum + (v.UsageData?.Size || 0), 0) || 0);
});
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
async function switchEnvironment(envId: number) {
// Don't switch if already on this environment
if (Number(envId) === Number(currentEnvId)) {
@@ -456,6 +465,16 @@
{#if hostInfo}
<span class="text-border">|</span>
<!-- Hostname / IP (#962) — first info segment after the env dropdown.
Hidden on narrow viewports to keep the strip readable. -->
{#if hostLabel}
<div class="hidden xl:flex items-center gap-1" title="Daemon hostname / IP">
<Server class="{iconSizeClass()}" />
<span>{hostLabel}</span>
</div>
<span class="hidden xl:inline text-border">|</span>
{/if}
<!-- Platform/OS -->
<span class="hidden md:inline">{hostInfo.platform} {hostInfo.arch}</span>
+2 -1
View File
@@ -10,7 +10,7 @@ export const containerColumns: ColumnConfig[] = [
{ id: 'uptime', label: 'Uptime', sortable: true, sortField: 'uptime', width: 80, minWidth: 60 },
{ id: 'restartCount', label: 'Restarts', width: 70, minWidth: 50 },
{ id: 'cpu', label: 'CPU', sortable: true, sortField: 'cpu', width: 50, minWidth: 40, align: 'right' },
{ id: 'memory', label: 'Memory', sortable: true, sortField: 'memory', width: 60, minWidth: 50, align: 'right' },
{ id: 'memory', label: 'Memory', sortable: true, sortField: 'memory', width: 95, minWidth: 70, align: 'right' },
{ id: 'networkIO', label: 'Net I/O', width: 85, minWidth: 70, align: 'right' },
{ id: 'diskIO', label: 'Disk I/O', width: 85, minWidth: 70, align: 'right' },
{ id: 'ip', label: 'IP', sortable: true, sortField: 'ip', width: 100, minWidth: 80 },
@@ -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 },
+170 -1
View File
@@ -1,4 +1,173 @@
[
{
"version": "1.0.34",
"date": "2026-06-17",
"changes": [
{ "type": "feature", "text": "raw file download — no tar wrapping (#1180)" },
{ "type": "fix", "text": "update modal stuck after closing mid-pull (#1094)" },
{ "type": "fix", "text": "vulnerability scans on Podman hosts (direct TCP and Hawser) (#1076)" },
{ "type": "fix", "text": "crash-looping containers now appear in the logs page list (#227)" },
{ "type": "feature", "text": "filter containers by \"Update available\" (#1063)" },
{ "type": "feature", "text": "show hostname / IP of the selected environment in the top header (#962)" },
{ "type": "feature", "text": "internal auth and validation hardening and dependency bumps" },
{ "type": "feature", "text": "Traefik and Pangolin integration — surface proxy URLs on container and stack panels (#2)" },
{ "type": "feature", "text": "release-notes link next to images with updates available (#538)" },
{ "type": "feature", "text": "lifecycle action buttons in the container details modal (#461)" },
{ "type": "feature", "text": "template library — browse and deploy compose templates from configurable sources (#48)" },
{ "type": "fix", "text": "file browser fails on containers with ls in /usr/sbin (#1185)" }
],
"imageTag": "fnsys/dockhand:v1.0.34"
},
{
"version": "1.0.33",
"date": "2026-06-15",
"changes": [
{ "type": "feature", "text": "in-place container property updates without restart — restart policy, CPU/memory limits (#1153)" },
{ "type": "feature", "text": "clickable stack badge in container and volume inspect modals (#1121)" },
{ "type": "feature", "text": "clickable stack badge in volumes list row (#1122)" },
{ "type": "feature", "text": "volumes list shows driver_opts type (NFS, CIFS, etc.) with sort and filter (#1123)" },
{ "type": "feature", "text": "Bark iOS notifications (#1095, PR#1097, @undirectlookable)" },
{ "type": "feature", "text": "Signal notifications via signal-cli-rest-api (#1099)" },
{ "type": "feature", "text": "Apprise passthrough — forward to a self-hosted caronc/apprise-api server (#1099)" },
{ "type": "fix", "text": "env editor flagged Docker/Compose built-ins as MISSING (#141)" },
{ "type": "fix", "text": "YAML editor indentation was inconsistent when pressing Enter (#1156)" },
{ "type": "feature", "text": "`dockhand.update=false`, `dockhand.hidden=true` and `localhost/*` images skip registry polling (#1083)" },
{ "type": "fix", "text": "registry authentication for image pulls (#1105)" },
{ "type": "feature", "text": "native HTTPS listener, off by default (#1102)" },
{ "type": "fix", "text": "environments stuck \"Failed\" after VPN/Tailscale tunnel drops until agent restart (#1160)" },
{ "type": "fix", "text": "health_status events flooding container_events table (#1165)" },
{ "type": "fix", "text": "git stack sync removes files deleted from the repo (hash-verified) (#966, #1162)" },
{ "type": "feature", "text": "upload TLS/mTLS certificate files in environment editor (#125)" },
{ "type": "feature", "text": "syntax highlighting for shell, Dockerfile, TOML, INI/conf and .env files in the file browser viewer (#1055)" },
{ "type": "feature", "text": "Animated icons now configurable (#1169)" },
{ "type": "fix", "text": "stack deploys ignored the env's configured socket path (#1172)" },
{ "type": "fix", "text": "environment names with characters that break path resolution (e.g. `*`) are now rejected (#1179)" }
],
"imageTag": "fnsys/dockhand:v1.0.33"
},
{
"version": "1.0.32",
"date": "2026-06-06",
"changes": [
{ "type": "feature", "text": "container details tweaks: process count, label filter, copy all labels (#812)" },
{ "type": "feature", "text": "log improvements (#1130)" },
{ "type": "fix", "text": "cleared Resources fields not persisted on container edit (#1119)" },
{ "type": "fix", "text": "long container names overflowed in activity event details dialog (#1129)" },
{ "type": "fix", "text": "git stack recreate and start operations ignored Dockhand-stored env vars (#1132)" },
{ "type": "fix", "text": "dashboard stopped count reset to 0 after refresh for gracefully stopped containers (#1133)" },
{ "type": "fix", "text": "auto-update preserves runtime `-e` env and `-l` label overrides (#1135)" },
{ "type": "fix", "text": "git stack volume binds resolved to wrong host path when compose was in a subdirectory (#1139)" },
{ "type": "fix", "text": "git stacks: subdir compose files now find their adjacent env files (#1136)" },
{ "type": "feature", "text": "env editor doesn't flag Docker/Compose built-in variables as unused (#141)" },
{ "type": "feature", "text": "container network mode: share another container's network namespace (#161)" }
],
"imageTag": "fnsys/dockhand:v1.0.32"
},
{
"version": "1.0.31",
"date": "2026-05-30",
"changes": [
{ "type": "fix", "text": "502 Bad Gateway behind nginx-based reverse proxies — SvelteKit 2.51+ bloated the Link response header, pinned to 2.50.0 (#1114)" }
],
"imageTag": "fnsys/dockhand:v1.0.31"
},
{
"version": "1.0.30",
"date": "2026-05-30",
"changes": [
{ "type": "feature", "text": "time range filter for log viewer — filter logs by From/To date and time (#1068)" },
{ "type": "feature", "text": "configurable tail line count in log viewer — choose from 100 to all lines (#1066)" },
{ "type": "feature", "text": "toggleable line numbers in log viewer (#1067)" },
{ "type": "feature", "text": "\"some unused\" image filter — show images with both used and unused tags for selective cleanup (#621)" },
{ "type": "feature", "text": "IP binding and port ranges in container port mappings (#581)" },
{ "type": "feature", "text": "remove individual containers directly from stacks page (#576)" },
{ "type": "fix", "text": "scan cache lookup by tag name never matched — results now resolved via image digest (#1064)" },
{ "type": "fix", "text": "image-baked env vars not updated during auto-update container recreation (#1061)" },
{ "type": "fix", "text": "git stack deploy via Hawser fails with \"Invalid string length\" when repo has large files (#1040)" },
{ "type": "feature", "text": "Gotify notification priority via URL query param — gotify://host/token?priority=5 (#1033)" },
{ "type": "fix", "text": "consistent action button order across container and stack views (#1079)" },
{ "type": "feature", "text": "named custom URL labels — dockhand.url=[Name](https://...) markdown syntax (#1065)" },
{ "type": "fix", "text": "HTTPS git credentials no longer leaked in process arguments (#1081)" },
{ "type": "feature", "text": "bump Docker Compose to 5.1.4 (GHSA-pmwq-pjrm-6p5r)" },
{ "type": "feature", "text": "dockhand.order label to control container display order within stacks (#847)" },
{ "type": "feature", "text": "live network attach/detach for running containers — join or leave Docker networks without restarting (#1051)" },
{ "type": "fix", "text": "environment variable values with nested quotes progressively corrupted on each save (#1036, #1086)" }
],
"imageTag": "fnsys/dockhand:v1.0.30"
},
{
"version": "1.0.29",
"date": "2026-05-17",
"changes": [
{ "type": "feature", "text": "optionally display internal (exposed) container ports alongside published ports (#193)" },
{ "type": "feature", "text": "show app version in sidebar with build info tooltip (#209)" },
{ "type": "feature", "text": "central label management — rename or delete labels across all environments (#661)" },
{ "type": "feature", "text": "find next available host port when creating or editing containers (#116)" },
{ "type": "feature", "text": "theme-aware scrollbar styling — scrollbars adapt to dark/light mode and color palettes (#462)" },
{ "type": "fix", "text": "update buttons (single, selected, and all) now respect the \"confirm dangerous actions\" setting (#638, #751)" },
{ "type": "feature", "text": "custom URL labels - dockhand.url or dockhand.port.{port}.url to add links alongside container ports (#266)" },
{ "type": "feature", "text": "generate and copy token for Hawser Standard mode with run command hint (#337)" },
{ "type": "fix", "text": "environment stack directory not cleaned up when environment is deleted (#1023)" },
{ "type": "feature", "text": "toggle to hide timestamps and container name prefix in log viewer (#124)" },
{ "type": "fix", "text": "Podman containers health status not showing (#737)" },
{ "type": "fix", "text": "containers with exit code 0 (init/migration) no longer cause stack \"partial\" status (#1026)" },
{ "type": "fix", "text": "stats stream 400 on reconnect by skipping overlapping fetches (#1044)" },
{ "type": "fix", "text": "env var validation false positive for values containing $ followed by text (#1048)" },
{ "type": "fix", "text": "git-repos directory not cleaned up when environment is deleted (#1049)" },
{ "type": "fix", "text": "webhook secret auto-generated when left empty despite hint saying otherwise (#1050)" },
{ "type": "feature", "text": "scan reports — combined or individual Grype/Trivy (#1056)" }
],
"imageTag": "fnsys/dockhand:v1.0.29"
},
{
"version": "1.0.28",
"date": "2026-05-09",
"changes": [
{ "type": "feature", "text": "context directory for git stacks — reference files from anywhere in the repo (#864)" },
{ "type": "feature", "text": "no-cache build option for git stacks (#880)" },
{ "type": "fix", "text": "env vars lost when switching between raw/form view (#964)" },
{ "type": "fix", "text": "compose name property not respected during stack scan (#922)" },
{ "type": "feature", "text": "editable schedule for scanner cache cleanup (#979)" },
{ "type": "fix", "text": "container labels cannot be deleted (#984)" },
{ "type": "fix", "text": "env var values leaked in deploy logs — now all values are redacted (#985)" },
{ "type": "fix", "text": "volume export keeps helper container alive, preventing volume prune/deletion (#983)" },
{ "type": "fix", "text": "ntfy self-hosted notifications fail when using ?auth= query parameter (#840)" },
{ "type": "fix", "text": "scrollbar appears in dashboard tiles when content overflows (#969)" },
{ "type": "fix", "text": "case-sensitive environment sort order — lowercase names sorted after uppercase (#975)" },
{ "type": "fix", "text": "inaccurate dashboard CPU gauge caused by one-shot stats flag (#932)" },
{ "type": "feature", "text": "ntfy notifications support ?tags=, ?title=, and ?priority= URL query parameters (#689)" },
{ "type": "fix", "text": "stack .env file wiped when saving from graph view (#988)" },
{ "type": "feature", "text": "dismiss update available indicators without updating (#853)" },
{ "type": "feature", "text": "public IP setting available for hawser-edge environments — enables clickable port links (#350)" },
{ "type": "fix", "text": "git stack creation silently destroys existing stacks with the same name (#1001)" },
{ "type": "feature", "text": "static IP/MAC address configuration for containers (#297)" }
],
"imageTag": "fnsys/dockhand:v1.0.28"
},
{
"version": "1.0.27",
"comingSoon": false,
"date": "2026-04-26",
"changes": [
{ "type": "feature", "text": "network graph visualization on networks page (#894, @Penlane)" },
{ "type": "feature", "text": "customizable compose template for new stacks in settings (#632, @oratory)" },
{ "type": "feature", "text": "Microsoft Teams notifications via Power Automate Workflows (#355, @slokhorst)" },
{ "type": "feature", "text": "container label controls: dockhand.update, dockhand.hidden, dockhand.notify (#6, #53, #94, #215)" },
{ "type": "feature", "text": "configurable label filter matching mode (any/all) for environment dashboard (#607)" },
{ "type": "feature", "text": "log search filter mode to hide non-matching lines (#916)" },
{ "type": "feature", "text": "inline terminal on logs page with resizable split layout (#900)" },
{ "type": "fix", "text": "disable Telegram link preview in notifications (#910, @deenle)" },
{ "type": "fix", "text": "cron editor rejects 6-field expressions with seconds (#839, @GiulioSavini)" },
{ "type": "fix", "text": "mirror Dockhand's ExtraHosts into scanner and self-update containers (#836, @YewFence)" },
{ "type": "fix", "text": "duplicate volume binds during container recreate (#765, @itsDNNS)" },
{ "type": "fix", "text": "log timestamp formatting not applied on main logs page (#882)" },
{ "type": "fix", "text": "uploaded files now inherit container user ownership (#732, @ivanjx)" },
{ "type": "fix", "text": "extraneous backslash in Telegram notification environment name (#955)" },
{ "type": "fix", "text": "collapse ports into ranges only if 3 or more consecutive ports" },
{ "type": "fix", "text": "git operations auto-merge system CAs with custom cert (#967)" }
],
"imageTag": "fnsys/dockhand:v1.0.27"
},
{
"version": "1.0.26",
"date": "2026-04-19",
@@ -12,7 +181,7 @@
{ "type": "fix", "text": "clicking stack name toggles stats accordion instead of just opening editor (#628)" },
{ "type": "fix", "text": "scheduled image prune notifications missing environment name (#770)" },
{ "type": "fix", "text": "Gotify, ntfy, Pushover, and webhook notifications missing environment name (#943)" },
{ "type": "fix", "text": "MFA code field not recognized by Bitwarden and other password managers (#566)" }
{ "type": "fix", "text": "MFA code field not recognized by Bitwarden and other password managers (#566)" }
],
"imageTag": "fnsys/dockhand:v1.0.26"
},
+32 -5
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
// ============================================
@@ -223,7 +231,7 @@ function setSessionCookie(cookies: Cookies, sessionId: string, maxAge: number, r
path: '/',
httpOnly: true, // Prevents XSS attacks from reading cookie
secure: isSecureContext(request), // Protocol-aware: checks x-forwarded-proto or NODE_ENV
sameSite: 'strict', // CSRF protection
sameSite: 'lax', // Lax required for OIDC/SSO cross-site redirects
maxAge: maxAge // Session timeout in seconds
});
}
@@ -241,11 +249,22 @@ function getSessionIdFromCookies(cookies: Cookies): string | null {
export async function validateSession(cookies: Cookies): Promise<AuthenticatedUser | null> {
const sessionId = getSessionIdFromCookies(cookies);
if (!sessionId) return null;
return validateSessionById(sessionId);
}
/**
* Validate a session by raw session ID (without the SvelteKit Cookies object).
*
* Used by WebSocket upgrade handlers in server.js / vite.config.ts that only
* have a raw Cookie header string. Mirrors validateSession() semantics:
* returns the AuthenticatedUser on success, null on missing/expired/disabled.
*/
export async function validateSessionById(sessionId: string): Promise<AuthenticatedUser | null> {
if (!sessionId) return null;
const session = await dbGetSession(sessionId);
if (!session) return null;
// Check if session is expired
const expiresAt = new Date(session.expiresAt);
if (expiresAt < new Date()) {
await dbDeleteSession(sessionId);
@@ -258,6 +277,13 @@ export async function validateSession(cookies: Cookies): Promise<AuthenticatedUs
return await buildAuthenticatedUser(user, session.provider as 'local' | 'ldap' | 'oidc');
}
/**
* Cookie name used for browser session auth. Exported so raw header parsers
* (WebSocket upgrade handlers) can look it up without re-encoding the
* constant.
*/
export const SESSION_COOKIE = SESSION_COOKIE_NAME;
/**
* Destroy a session (logout)
*/
@@ -461,13 +487,14 @@ export async function authenticateLocal(
const user = await getUserByUsername(username);
if (!user) {
// Use constant time to prevent timing attacks
await hashPassword('dummy');
await verifyPassword(password, await getDummyAuthHash());
return { success: false, error: 'Invalid username or password' };
}
if (!user.isActive) {
return { success: false, error: 'Account is disabled' };
await verifyPassword(password, await getDummyAuthHash());
console.warn(`[Auth] Login attempt for disabled account: user=${username}`);
return { success: false, error: 'Invalid username or password' };
}
const validPassword = await verifyPassword(password, user.passwordHash);
+41
View File
@@ -0,0 +1,41 @@
/**
* Resolve the client IP for rate limiting, logging, and audit.
*
* Defaults to the socket-level IP via getClientAddress(). X-Forwarded-For
* is consulted only when TRUST_FORWARDED_HEADERS=true is set explicitly
* intended for deployments behind a reverse proxy (Traefik, nginx, Caddy)
* that controls XFF. In that mode the right-most XFF entry (closest to the
* trusted proxy) is returned; earlier entries in the chain are ignored.
*/
type IpEventLike = {
request: Request;
getClientAddress?: () => string;
};
function normalize(ip: string | null | undefined): string {
if (!ip) return 'unknown';
if (ip === '::1' || ip === '::ffff:127.0.0.1') return '127.0.0.1';
if (ip.startsWith('::ffff:')) return ip.substring(7);
return ip;
}
export function getClientIp(event: IpEventLike): string {
if (process.env.TRUST_FORWARDED_HEADERS === 'true') {
const xff = event.request.headers.get('x-forwarded-for');
if (xff) {
const parts = xff.split(',').map((p) => p.trim()).filter(Boolean);
if (parts.length > 0) return normalize(parts[parts.length - 1]);
}
const realIp = event.request.headers.get('x-real-ip');
if (realIp) return normalize(realIp.trim());
}
try {
const addr = event.getClientAddress?.();
if (addr) return normalize(addr);
} catch {
// getClientAddress may throw if unavailable (test contexts, raw upgrades)
}
return 'unknown';
}
@@ -0,0 +1,72 @@
/**
* Helpers for surfacing env/label divergence between a running
* container and its image. Pure read-only never used to mutate
* the container; used only to power UI hints.
*
* Background: as of #1135 / commit 0f989bd7 revert, Dockhand no
* longer "merges" image-baked env or labels into a container during
* auto-update. The container's Config.Env and Config.Labels are
* preserved verbatim (so a user's runtime `-e` / `-l` override is
* never silently wiped). The trade-off, originally raised by #1061,
* is that an image's updated default env/label values do not
* automatically propagate to running containers.
*
* These helpers let the UI surface "this container's value differs
* from the image's current value" so users can decide whether to
* Remove & Deploy. We do NOT try to classify "user-set vs
* image-baked" that information isn't recoverable from Docker.
*/
/** Parse a Docker env list (`KEY=value` strings) into a Map. */
function parseEnv(entries: string[]): Map<string, string> {
const m = new Map<string, string>();
for (const e of entries) {
const i = e.indexOf('=');
if (i === -1) {
m.set(e, '');
} else {
m.set(e.slice(0, i), e.slice(i + 1));
}
}
return m;
}
/**
* Keys where the container's env value differs from the image's
* CURRENT env value. Keys present in only one side are excluded
* they're either user-only or image-only, neither of which is
* "divergence" we can usefully act on.
*/
export function detectImageEnvDivergence(
containerEnv: string[],
imageEnv: string[]
): string[] {
const cont = parseEnv(containerEnv);
const img = parseEnv(imageEnv);
const diff: string[] = [];
for (const [k, v] of cont) {
if (img.has(k) && img.get(k) !== v) {
diff.push(k);
}
}
return diff;
}
/**
* Keys where the container's label value differs from the image's
* CURRENT label value. Same semantics as detectImageEnvDivergence.
*/
export function detectImageLabelDivergence(
containerLabels: Record<string, string> | null | undefined,
imageLabels: Record<string, string> | null | undefined
): string[] {
const cont = containerLabels || {};
const img = imageLabels || {};
const diff: string[] = [];
for (const [k, v] of Object.entries(cont)) {
if (k in img && img[k] !== v) {
diff.push(k);
}
}
return diff;
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Dockhand Container Label Controls
*
* Docker container labels that control Dockhand behavior:
* - dockhand.update=false Skip this container during auto-updates and batch updates
* - dockhand.hidden=true Hide this container from the Dockhand UI
* - dockhand.notify=false Suppress notifications for this container's events
* - dockhand.url=<url> Custom clickable URL displayed alongside container ports
* - dockhand.port.<hostPort>.url=<url> Override the click URL for a specific published port
* - dockhand.order=<int> Controls display order within a stack (lower = first, default 0)
*
* All label values are case-insensitive and accept: true/yes/1 and false/no/0.
* The opt-out model means labels override DB settings (label wins).
*/
/** Recognized Dockhand label keys */
export const DOCKHAND_LABELS = {
UPDATE: 'dockhand.update',
HIDDEN: 'dockhand.hidden',
NOTIFY: 'dockhand.notify',
URL: 'dockhand.url',
ORDER: 'dockhand.order',
} as const;
const TRUTHY_VALUES = new Set(['true', 'yes', '1']);
const FALSY_VALUES = new Set(['false', 'no', '0']);
/**
* Parse a label value as a boolean.
* Returns true for: true, TRUE, yes, YES, 1
* Returns false for: false, FALSE, no, NO, 0
* Returns undefined for missing or unrecognized values.
*/
function parseLabelBool(value: string | undefined | null): boolean | undefined {
if (value == null) return undefined;
const normalized = value.trim().toLowerCase();
if (TRUTHY_VALUES.has(normalized)) return true;
if (FALSY_VALUES.has(normalized)) return false;
return undefined;
}
/**
* Get a label value from a Docker labels object.
*/
function getLabel(labels: Record<string, string> | undefined | null, key: string): string | undefined {
if (!labels) return undefined;
return labels[key];
}
/**
* Check if a container should be skipped during auto-updates.
* Returns true if dockhand.update is explicitly set to false/no/0.
* Default (no label): allow updates (opt-out model).
*/
export function isUpdateDisabledByLabel(labels: Record<string, string> | undefined | null): boolean {
const value = parseLabelBool(getLabel(labels, DOCKHAND_LABELS.UPDATE));
return value === false; // explicitly disabled
}
/**
* Check if a container should be hidden from the UI.
* Returns true if dockhand.hidden is explicitly set to true/yes/1.
* Default (no label): visible (opt-out model).
*/
export function isHiddenByLabel(labels: Record<string, string> | undefined | null): boolean {
const value = parseLabelBool(getLabel(labels, DOCKHAND_LABELS.HIDDEN));
return value === true; // explicitly hidden
}
/**
* Check if notifications should be suppressed for this container.
* Returns true if dockhand.notify is explicitly set to false/no/0.
* Default (no label): send notifications (opt-out model).
*/
export function isNotifyDisabledByLabel(labels: Record<string, string> | undefined | null): boolean {
const value = parseLabelBool(getLabel(labels, DOCKHAND_LABELS.NOTIFY));
return value === false; // explicitly disabled
}
/**
* Get the custom URL from dockhand.url label.
* Returns the URL string if set, or undefined.
*/
export function getCustomUrl(labels: Record<string, string> | undefined | null): string | undefined {
const value = getLabel(labels, DOCKHAND_LABELS.URL);
return value?.trim() || undefined;
}
/**
* Get the sort order value from dockhand.order label.
* Returns the parsed integer, or 0 for missing/invalid values.
*/
export function getOrderValue(labels: Record<string, string> | undefined | null): number {
const value = getLabel(labels, DOCKHAND_LABELS.ORDER);
if (value == null) return 0;
const parsed = parseInt(value.trim(), 10);
return Number.isNaN(parsed) ? 0 : parsed;
}
/**
* Extract all Dockhand label states from a container's labels.
* Useful for including in API responses so the frontend knows about label overrides.
*/
export function getDockhandLabels(labels: Record<string, string> | undefined | null): {
updateDisabled: boolean;
hidden: boolean;
notifyDisabled: boolean;
customUrl?: string;
} {
return {
updateDisabled: isUpdateDisabledByLabel(labels),
hidden: isHiddenByLabel(labels),
notifyDisabled: isNotifyDisabledByLabel(labels),
customUrl: getCustomUrl(labels),
};
}
+190 -15
View File
@@ -78,6 +78,7 @@ import {
import type { AllGridPreferences, GridId, GridColumnPreferences } from '$lib/types';
import { encrypt, decrypt } from './encryption.js';
import { parseEnvInterpolation } from './env-interpolation';
// Re-export for backwards compatibility
export { db, isPostgres, isSqlite };
@@ -112,7 +113,7 @@ export function initDatabase() {
// =============================================================================
export async function getEnvironments(): Promise<Environment[]> {
const results = await db.select().from(environments).orderBy(asc(environments.name));
const results = await db.select().from(environments).orderBy(sql`lower(${environments.name})`);
return results.map((e: Environment) => ({
...e,
tlsKey: decrypt(e.tlsKey),
@@ -387,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',
@@ -404,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) {
@@ -434,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);
}
@@ -2066,6 +2074,7 @@ export async function getGitStacksByRepositoryId(repositoryId: number): Promise<
}
export async function deleteGitRepository(id: number): Promise<boolean> {
console.log(`[GitStack] Deleting git repository id=${id} (will cascade-delete git_stacks, set null on stack_sources FKs)`);
await db.delete(gitRepositories).where(eq(gitRepositories.id, id));
return true;
}
@@ -2086,13 +2095,16 @@ export interface GitStackData {
autoUpdateCron: string;
webhookEnabled: boolean;
webhookSecret: string | null;
contextDir: string | null;
buildOnDeploy: boolean;
noBuildCache: boolean;
repullImages: boolean;
forceRedeploy: boolean;
lastSync: string | null;
lastCommit: string | null;
syncStatus: GitSyncStatus;
syncError: string | null;
syncedFiles?: string | null; // JSON manifest { commit, files: { relPath: sha256 } } from last successful deploy
createdAt: string;
updatedAt: string;
}
@@ -2122,7 +2134,9 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
@@ -2153,7 +2167,9 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
@@ -2184,7 +2200,9 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
@@ -2217,7 +2235,9 @@ export async function getGitStacksForEnvironmentOnly(environmentId: number): Pro
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
@@ -2248,7 +2268,9 @@ export async function getGitStacksForEnvironmentOnly(environmentId: number): Pro
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
@@ -2280,13 +2302,16 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
syncError: gitStacks.syncError,
syncedFiles: gitStacks.syncedFiles,
createdAt: gitStacks.createdAt,
updatedAt: gitStacks.updatedAt,
repoName: gitRepositories.name,
@@ -2312,13 +2337,16 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
syncError: row.syncError,
syncedFiles: row.syncedFiles ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
repository: {
@@ -2344,7 +2372,9 @@ export async function getGitStackByName(stackName: string, environmentId?: numbe
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
@@ -2381,7 +2411,9 @@ export async function getGitStackByName(stackName: string, environmentId?: numbe
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
@@ -2413,7 +2445,9 @@ export async function getGitStackByWebhookSecret(secret: string): Promise<GitSta
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
@@ -2445,7 +2479,9 @@ export async function getGitStackByWebhookSecret(secret: string): Promise<GitSta
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
@@ -2475,7 +2511,9 @@ export async function createGitStack(data: {
autoUpdateCron?: string;
webhookEnabled?: boolean;
webhookSecret?: string | null;
contextDir?: string | null;
buildOnDeploy?: boolean;
noBuildCache?: boolean;
repullImages?: boolean;
forceRedeploy?: boolean;
}): Promise<GitStackWithRepo> {
@@ -2485,12 +2523,14 @@ export async function createGitStack(data: {
repositoryId: data.repositoryId,
composePath: data.composePath || 'compose.yaml',
envFilePath: data.envFilePath || null,
contextDir: data.contextDir || null,
autoUpdate: data.autoUpdate || false,
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
autoUpdateCron: data.autoUpdateCron || '0 3 * * *',
webhookEnabled: data.webhookEnabled || false,
webhookSecret: data.webhookSecret || null,
buildOnDeploy: data.buildOnDeploy ?? false,
noBuildCache: data.noBuildCache ?? false,
repullImages: data.repullImages ?? false,
forceRedeploy: data.forceRedeploy ?? false
}).returning();
@@ -2509,19 +2549,23 @@ export async function updateGitStack(id: number, data: Partial<GitStackData>): P
if (data.autoUpdateCron !== undefined) updateData.autoUpdateCron = data.autoUpdateCron;
if (data.webhookEnabled !== undefined) updateData.webhookEnabled = data.webhookEnabled;
if (data.webhookSecret !== undefined) updateData.webhookSecret = data.webhookSecret;
if (data.contextDir !== undefined) updateData.contextDir = data.contextDir;
if (data.buildOnDeploy !== undefined) updateData.buildOnDeploy = data.buildOnDeploy;
if (data.noBuildCache !== undefined) updateData.noBuildCache = data.noBuildCache;
if (data.repullImages !== undefined) updateData.repullImages = data.repullImages;
if (data.forceRedeploy !== undefined) updateData.forceRedeploy = data.forceRedeploy;
if (data.lastSync !== undefined) updateData.lastSync = data.lastSync;
if (data.lastCommit !== undefined) updateData.lastCommit = data.lastCommit;
if (data.syncStatus !== undefined) updateData.syncStatus = data.syncStatus;
if (data.syncError !== undefined) updateData.syncError = data.syncError;
if (data.syncedFiles !== undefined) updateData.syncedFiles = data.syncedFiles;
await db.update(gitStacks).set(updateData).where(eq(gitStacks.id, id));
return getGitStack(id);
}
export async function deleteGitStack(id: number): Promise<boolean> {
console.log(`[GitStack] Deleting git_stacks row id=${id}`);
await db.delete(gitStacks).where(eq(gitStacks.id, id));
return true;
}
@@ -2546,7 +2590,9 @@ export async function getEnabledAutoUpdateGitStacks(): Promise<GitStackWithRepo[
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
@@ -2576,7 +2622,9 @@ export async function getEnabledAutoUpdateGitStacks(): Promise<GitStackWithRepo[
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
@@ -2607,7 +2655,9 @@ export async function getAllAutoUpdateGitStacks(): Promise<GitStackWithRepo[]> {
autoUpdateCron: gitStacks.autoUpdateCron,
webhookEnabled: gitStacks.webhookEnabled,
webhookSecret: gitStacks.webhookSecret,
contextDir: gitStacks.contextDir,
buildOnDeploy: gitStacks.buildOnDeploy,
noBuildCache: gitStacks.noBuildCache,
repullImages: gitStacks.repullImages,
forceRedeploy: gitStacks.forceRedeploy,
lastSync: gitStacks.lastSync,
@@ -2636,7 +2686,9 @@ export async function getAllAutoUpdateGitStacks(): Promise<GitStackWithRepo[]> {
autoUpdateCron: row.autoUpdateCron,
webhookEnabled: row.webhookEnabled,
webhookSecret: row.webhookSecret,
contextDir: row.contextDir ?? null,
buildOnDeploy: row.buildOnDeploy ?? false,
noBuildCache: row.noBuildCache ?? false,
repullImages: row.repullImages ?? false,
forceRedeploy: row.forceRedeploy ?? false,
lastSync: row.lastSync,
@@ -2781,11 +2833,21 @@ export async function upsertStackSource(data: {
const existing = await getStackSource(data.stackName, data.environmentId);
if (existing) {
const newRepoId = data.gitRepositoryId || null;
const newStackId = data.gitStackId || null;
const changes: string[] = [];
if (data.sourceType !== existing.sourceType) changes.push(`sourceType: ${existing.sourceType}${data.sourceType}`);
if (newRepoId !== existing.gitRepositoryId) changes.push(`gitRepoId: ${existing.gitRepositoryId}${newRepoId}`);
if (newStackId !== existing.gitStackId) changes.push(`gitStackId: ${existing.gitStackId}${newStackId}`);
if (changes.length > 0) {
console.log(`[GitStack] Updating stack_sources "${data.stackName}" env=${data.environmentId}: ${changes.join(', ')}`);
}
await db.update(stackSources)
.set({
sourceType: data.sourceType,
gitRepositoryId: data.gitRepositoryId || null,
gitStackId: data.gitStackId || null,
gitRepositoryId: newRepoId,
gitStackId: newStackId,
composePath: data.composePath ?? null,
envPath: data.envPath ?? null,
updatedAt: new Date().toISOString()
@@ -2793,6 +2855,7 @@ export async function upsertStackSource(data: {
.where(eq(stackSources.id, existing.id));
return getStackSource(data.stackName, data.environmentId) as Promise<StackSourceData>;
} else {
console.log(`[GitStack] Creating stack_sources "${data.stackName}" env=${data.environmentId} type=${data.sourceType} repoId=${data.gitRepositoryId || null} stackId=${data.gitStackId || null}`);
await db.insert(stackSources).values({
stackName: data.stackName,
environmentId: data.environmentId ?? null,
@@ -2826,6 +2889,7 @@ export async function updateStackSource(
}
export async function deleteStackSource(stackName: string, environmentId?: number | null): Promise<boolean> {
console.log(`[GitStack] Deleting stack_sources "${stackName}" env=${environmentId}`);
// Delete matching record (either with specific envId or NULL)
await db.delete(stackSources)
.where(and(
@@ -3193,14 +3257,16 @@ export async function getAuditLogs(filters: AuditLogFilters = {}): Promise<Audit
// Labels filter - find environments with matching labels first
let labelFilteredEnvIds: number[] | undefined;
if (filters.labels && filters.labels.length > 0) {
// Get environments that have ANY of the specified labels
const labelFilterMode = await getSetting('label_filter_mode') ?? 'any';
const allEnvs = await db.select({ id: environments.id, labels: environments.labels }).from(environments);
labelFilteredEnvIds = allEnvs
.filter(env => {
if (!env.labels) return false;
try {
const envLabels = JSON.parse(env.labels) as string[];
return filters.labels!.some(label => envLabels.includes(label));
return labelFilterMode === 'all'
? filters.labels!.every(label => envLabels.includes(label))
: filters.labels!.some(label => envLabels.includes(label));
} catch {
return false;
}
@@ -3408,14 +3474,16 @@ export async function getContainerEvents(filters: ContainerEventFilters = {}): P
// Labels filter - find environments with matching labels first
let labelFilteredEnvIds: number[] | undefined;
if (filters.labels && filters.labels.length > 0) {
// Get environments that have ANY of the specified labels
const labelFilterMode = await getSetting('label_filter_mode') ?? 'any';
const allEnvs = await db.select({ id: environments.id, labels: environments.labels }).from(environments);
labelFilteredEnvIds = allEnvs
.filter(env => {
if (!env.labels) return false;
try {
const envLabels = JSON.parse(env.labels) as string[];
return filters.labels!.some(label => envLabels.includes(label));
return labelFilterMode === 'all'
? filters.labels!.every(label => envLabels.includes(label))
: filters.labels!.some(label => envLabels.includes(label));
} catch {
return false;
}
@@ -3535,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;
}
/**
@@ -4025,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
@@ -4038,8 +4118,11 @@ const SCHEDULE_CLEANUP_CRON_KEY = 'schedule_cleanup_cron';
const EVENT_CLEANUP_CRON_KEY = 'event_cleanup_cron';
const SCHEDULE_CLEANUP_ENABLED_KEY = 'schedule_cleanup_enabled';
const EVENT_CLEANUP_ENABLED_KEY = 'event_cleanup_enabled';
const SCANNER_CLEANUP_CRON_KEY = 'scanner_cleanup_cron';
const SCANNER_CLEANUP_ENABLED_KEY = 'scanner_cleanup_enabled';
const DEFAULT_SCHEDULE_CLEANUP_CRON = '0 3 * * *'; // Daily at 3 AM
const DEFAULT_EVENT_CLEANUP_CRON = '30 3 * * *'; // Daily at 3:30 AM
const DEFAULT_SCANNER_CLEANUP_CRON = '0 3 * * 0'; // Weekly Sunday at 3 AM
export async function getScheduleRetentionDays(): Promise<number> {
const result = await db.select().from(settings).where(eq(settings.key, SCHEDULE_RETENTION_KEY));
@@ -4173,6 +4256,50 @@ export async function setEventCleanupEnabled(enabled: boolean): Promise<void> {
}
}
export async function getScannerCleanupCron(): Promise<string> {
const result = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_CRON_KEY));
if (result[0]) {
return result[0].value || DEFAULT_SCANNER_CLEANUP_CRON;
}
return DEFAULT_SCANNER_CLEANUP_CRON;
}
export async function setScannerCleanupCron(cron: string): Promise<void> {
const existing = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_CRON_KEY));
if (existing.length > 0) {
await db.update(settings)
.set({ value: cron, updatedAt: new Date().toISOString() })
.where(eq(settings.key, SCANNER_CLEANUP_CRON_KEY));
} else {
await db.insert(settings).values({
key: SCANNER_CLEANUP_CRON_KEY,
value: cron
});
}
}
export async function getScannerCleanupEnabled(): Promise<boolean> {
const result = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_ENABLED_KEY));
if (result[0]) {
return result[0].value === 'true';
}
return true; // Enabled by default
}
export async function setScannerCleanupEnabled(enabled: boolean): Promise<void> {
const existing = await db.select().from(settings).where(eq(settings.key, SCANNER_CLEANUP_ENABLED_KEY));
if (existing.length > 0) {
await db.update(settings)
.set({ value: enabled ? 'true' : 'false', updatedAt: new Date().toISOString() })
.where(eq(settings.key, SCANNER_CLEANUP_ENABLED_KEY));
} else {
await db.insert(settings).values({
key: SCANNER_CLEANUP_ENABLED_KEY,
value: enabled ? 'true' : 'false'
});
}
}
// =============================================================================
// EXTERNAL STACK PATHS
// =============================================================================
@@ -4207,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
// =============================================================================
@@ -4629,6 +4767,43 @@ export async function getSecretKeyNames(
return new Set(vars.filter(v => v.isSecret).map(v => v.key));
}
/**
* Get the set of env var keys that should be masked in container inspect responses.
* Handles two cases:
* 1. Direct match: env var key == secret key in DB (e.g., DB_PASS=${DB_PASS})
* 2. Interpolation: env var key differs from secret key (e.g., MYSQL_PASSWORD=${db_secret})
* Detected by parsing the compose file for ${variable} references in environment: sections.
*
* @param composeContent - Optional compose file content. If provided, interpolation
* references are parsed to detect secrets injected under different key names.
*/
export async function getSecretKeysToMask(
stackName: string,
environmentId?: number | null,
composeContent?: string | null
): Promise<Set<string>> {
const vars = await getStackEnvVars(stackName, environmentId, true);
const secretKeyNames = new Set(vars.filter(v => v.isSecret).map(v => v.key));
if (secretKeyNames.size === 0) return secretKeyNames;
// If we have compose content, parse interpolation references to find
// container env keys that map to secret interpolation variables.
// e.g., "MYSQL_PASSWORD=${db_secret}" → if db_secret is a secret, mask MYSQL_PASSWORD too.
if (composeContent) {
const interpolated = parseEnvInterpolation(composeContent);
for (const [containerKey, varName] of interpolated) {
if (secretKeyNames.has(varName)) {
secretKeyNames.add(containerKey);
}
}
}
return secretKeyNames;
}
export { parseEnvInterpolation } from './env-interpolation';
/**
* Get count of environment variables for a stack.
* @param stackName - Name of the stack
+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([
+18 -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,20 +308,23 @@ 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'),
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
webhookEnabled: integer('webhook_enabled', { mode: 'boolean' }).default(false),
webhookSecret: text('webhook_secret'),
contextDir: text('context_dir'), // Working directory relative to repo root (null = compose file's directory)
buildOnDeploy: integer('build_on_deploy', { mode: 'boolean' }).default(false),
noBuildCache: integer('no_build_cache', { mode: 'boolean' }).default(false),
repullImages: integer('repull_images', { mode: 'boolean' }).default(false),
forceRedeploy: integer('force_redeploy', { mode: 'boolean' }).default(false),
lastSync: text('last_sync'),
lastCommit: text('last_commit'),
syncStatus: text('sync_status').default('pending'),
syncError: text('sync_error'),
syncedFiles: text('synced_files'), // JSON manifest { relativePath: sha256hex } of files written on last sync
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
}, (table) => ({
@@ -502,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
// =============================================================================
+18 -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,20 +311,23 @@ 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'),
autoUpdateCron: text('auto_update_cron').default('0 3 * * *'),
webhookEnabled: boolean('webhook_enabled').default(false),
webhookSecret: text('webhook_secret'),
contextDir: text('context_dir'), // Working directory relative to repo root (null = compose file's directory)
buildOnDeploy: boolean('build_on_deploy').default(false),
noBuildCache: boolean('no_build_cache').default(false),
repullImages: boolean('repull_images').default(false),
forceRedeploy: boolean('force_redeploy').default(false),
lastSync: timestamp('last_sync', { mode: 'string' }),
lastCommit: text('last_commit'),
syncStatus: text('sync_status').default('pending'),
syncError: text('sync_error'),
syncedFiles: text('synced_files'), // JSON manifest { relativePath: sha256hex } of files written on last sync
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
}, (table) => ({
@@ -504,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()
});
+670 -149
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
/**
* Parse compose YAML to extract environment variable interpolation mappings.
* Returns pairs of [containerEnvKey, interpolationVariable].
*
* Handles patterns:
* - VAR=${ref}
* - VAR=${ref:-default}
* - VAR=${ref:+alt}
* - VAR=${ref?error}
*
* Only extracts from `environment:` sections (list format: `- KEY=value`).
*/
export function parseEnvInterpolation(composeContent: string): Array<[string, string]> {
const results: Array<[string, string]> = [];
// Step 1: Find lines matching `- ENV_KEY=...${...}...`
const linePattern = /^\s*-\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)/gm;
let lineMatch;
while ((lineMatch = linePattern.exec(composeContent)) !== null) {
const containerKey = lineMatch[1];
const valueStr = lineMatch[2];
// Step 2: Extract all ${VAR} references from the value
const varPattern = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?:[:\-\+\?][^}]*)?\}/g;
let varMatch;
while ((varMatch = varPattern.exec(valueStr)) !== null) {
const varName = varMatch[1];
// Only add if names differ — same-name case handled by direct key matching
if (containerKey !== varName) {
results.push([containerKey, varName]);
}
}
}
return results;
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Parse .env file content into key-value pairs.
* Preserves values exactly as written no quote stripping.
* Docker Compose handles its own quote interpretation at runtime.
*/
export function parseEnvVars(content: string): Record<string, string> {
const result: Record<string, string> = {};
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex === -1) continue;
const key = trimmed.substring(0, eqIndex).trim();
const value = trimmed.substring(eqIndex + 1).trim();
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
result[key] = value;
}
}
return result;
}
+435
View File
@@ -0,0 +1,435 @@
/**
* Git stack deletion sync (#966, #1162).
*
* Propagates upstream file deletions to the stack deploy directory using the
* per-stack manifest: a file is deleted ONLY when the manifest of files
* Dockhand wrote on the previous sync lists it, the new clone no longer
* contains it, AND the bytes on disk still match what Dockhand wrote
* (nobody modified it locally).
*
* Every failure mode degrades to "delete less" never to user-data loss:
* - user-created files (volume data) never in the manifest untouchable
* - locally modified files hash mismatch skip
* - first sync after upgrade / fresh DB empty manifest nothing to delete
* - broken clone walk (empty / compose missing) deletionSafetyCheck blocks
* ALL deletions for that sync (guards against mass-deleting managed files
* due to a Dockhand bug; those files are repo-restorable anyway)
*
* History rewrites are irrelevant by design: deletion converges the deploy
* dir toward the clone state, regardless of how the commits got there.
*/
import { createHash } from 'node:crypto';
import { readdirSync, readFileSync, unlinkSync, rmdirSync, lstatSync } from 'node:fs';
import { join, resolve, sep, dirname, basename, isAbsolute } from 'node:path';
// =============================================================================
// Types
// =============================================================================
export type DeletionSkipReason =
| 'locally-modified' // disk bytes differ from what Dockhand wrote
| 'load-bearing' // compose/.env files are never auto-deleted
| 'invalid-path' // absolute or escaping the stack directory
| 'already-absent' // nothing to do (benign)
| 'agent-no-support' // Hawser agent too old to apply deletions
| 'apply-failed'; // unexpected error during unlink
export interface FileToDelete {
path: string; // relative to the stack deploy dir, '/' separators
hash: string; // sha256 hex of the content Dockhand wrote
}
export interface DeletionSkip {
path: string;
reason: DeletionSkipReason;
}
export interface DeletionPlan {
toDelete: FileToDelete[];
skipped: DeletionSkip[];
}
export interface DeletionApplyResult {
deleted: string[];
skipped: DeletionSkip[];
}
/** Manifest of files Dockhand wrote on the last successful sync. */
export interface SyncManifest {
/** Full commit hash the manifest files were taken from. Null = legacy/bootstrap. */
commit: string | null;
/** relative path → sha256 hex of written content */
files: Record<string, string>;
}
export interface SyncFileChange {
file: string;
status: 'added' | 'updated' | 'removed' | 'skipped';
reason?: string; // human-readable, only for skipped
}
export interface SyncChangeSummary {
changes: SyncFileChange[];
unchangedCount: number;
}
// =============================================================================
// Constants
// =============================================================================
/** Files that are never auto-deleted, regardless of what the sources say. */
export const LOAD_BEARING_FILES = new Set([
'docker-compose.yml',
'docker-compose.yaml',
'compose.yml',
'compose.yaml',
'.env'
]);
// NOTE: deletion skips are FINAL by design. A deletion is attempted exactly
// once — at the sync where the file first disappears from the clone. Any
// skip (old agent, hash mismatch, apply error) is logged and the file simply
// stays on disk as unmanaged residue. There is deliberately no
// carry-forward/retry state: it would require tracking per-file retry status
// indefinitely (e.g. waiting for an agent upgrade that may never happen).
// Worst case is always "a stale file survives" — visible in the logs,
// recoverable manually. With an old Hawser agent the behavior is identical
// to before this feature existed: nothing is ever deleted remotely.
/** Human-readable explanation for each skip reason (shown in logs and activity). */
export function skipReasonMessage(reason: DeletionSkipReason): string {
switch (reason) {
case 'locally-modified':
return 'deleted from the repository, but the file was modified on this machine since Dockhand deployed it — refusing to delete local changes';
case 'load-bearing':
return 'core stack file — never auto-deleted';
case 'invalid-path':
return 'invalid path outside the stack directory — ignored';
case 'already-absent':
return 'already absent';
case 'agent-no-support':
return 'the Hawser agent does not support file deletion sync — file left on the remote host (upgrade the agent to enable cleanup of future deletions)';
case 'apply-failed':
return 'could not be deleted — leaving the file in place';
default:
// Unknown reason (e.g., from a newer agent)
return 'could not be deleted — leaving the file in place';
}
}
const KNOWN_SKIP_REASONS: ReadonlySet<string> = new Set<DeletionSkipReason>([
'locally-modified',
'load-bearing',
'invalid-path',
'already-absent',
'agent-no-support',
'apply-failed'
]);
/** Normalize a reason string from an external source (Hawser agent). */
export function normalizeSkipReason(reason: string): DeletionSkipReason {
return (KNOWN_SKIP_REASONS.has(reason) ? reason : 'apply-failed') as DeletionSkipReason;
}
// =============================================================================
// Manifest (de)serialization
// =============================================================================
export function parseManifest(raw: string | null | undefined): SyncManifest {
if (!raw) return { commit: null, files: {} };
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && typeof parsed.files === 'object' && parsed.files !== null) {
const files: Record<string, string> = {};
for (const [k, v] of Object.entries(parsed.files)) {
if (typeof v === 'string') files[k] = v;
}
return { commit: typeof parsed.commit === 'string' ? parsed.commit : null, files };
}
} catch {
// Corrupt manifest → behave like a fresh bootstrap (fail closed: no deletions)
}
return { commit: null, files: {} };
}
export function serializeManifest(manifest: SyncManifest): string {
return JSON.stringify(manifest);
}
// =============================================================================
// Hashing
// =============================================================================
export function hashContent(content: Buffer | string): string {
return createHash('sha256').update(content).digest('hex');
}
/**
* Walk a directory and hash every regular file (raw bytes).
* Returns { relativePath: sha256hex } with '/' separators.
* Skips .git directories (mirrors the cpSync filter used by the deploy copy).
*/
export function hashDirFiles(dir: string): Record<string, string> {
const result: Record<string, string> = {};
const root = resolve(dir);
const walk = (current: string, relPrefix: string) => {
let entries;
try {
entries = readdirSync(current, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.name === '.git') continue;
const abs = join(current, entry.name);
const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
walk(abs, rel);
} else if (entry.isFile()) {
try {
result[rel] = hashContent(readFileSync(abs));
} catch {
// Unreadable file: leave out of the manifest → never a deletion candidate
}
}
// Symlinks and other special entries are intentionally excluded:
// Dockhand only writes regular files, so only regular files are managed.
}
};
walk(root, '');
return result;
}
// =============================================================================
// Path safety
// =============================================================================
/** A relative path is safe when it cannot escape the stack directory. */
export function isSafeRelPath(p: string): boolean {
if (!p || isAbsolute(p) || p.includes('\\')) return false;
const segments = p.split('/');
return segments.every((s) => s !== '' && s !== '.' && s !== '..');
}
/** Resolve relPath inside root; returns null when it would escape root. */
function containedPath(root: string, relPath: string): string | null {
if (!isSafeRelPath(relPath)) return null;
const abs = resolve(root, relPath);
if (abs !== root && abs.startsWith(root + sep)) return abs;
return null;
}
// =============================================================================
// Core: manifest vs clone
// =============================================================================
/**
* Sanity guard run BEFORE computing any deletions: when the new-clone walk
* looks broken (no files at all, or the compose file itself is missing from
* the walk even though it was just read from that tree), every manifest
* entry would become a deletion candidate a Dockhand bug, not a repo
* change. Returns a human-readable reason to skip ALL deletions this sync,
* or null when it is safe to proceed.
*/
export function deletionSafetyCheck(
manifestFiles: Record<string, string>,
newFiles: Record<string, string>,
composeFileName: string | undefined
): string | null {
if (Object.keys(manifestFiles).length === 0) return null; // nothing to delete anyway
if (Object.keys(newFiles).length === 0) {
return 'the new clone appears empty — skipping all deletions this sync (likely a sync problem, not repository changes)';
}
if (composeFileName && !(composeFileName in newFiles)) {
return `the compose file "${composeFileName}" is missing from the new clone walk — skipping all deletions this sync (likely a sync problem, not repository changes)`;
}
return null;
}
/**
* Compute the deletion plan: manifest entries that are absent from the new
* clone. The hash recorded in the manifest travels with each entry the
* applier deletes only files whose disk bytes still match it.
*
* @param manifestFiles files Dockhand wrote on the last sync (path hash)
* @param newFiles files in the new clone that will be written (path hash)
*/
export function computeDeletions(
manifestFiles: Record<string, string>,
newFiles: Record<string, string>
): DeletionPlan {
const toDelete: FileToDelete[] = [];
const skipped: DeletionSkip[] = [];
for (const [path, hash] of Object.entries(manifestFiles)) {
if (path in newFiles) continue; // still present in the repo
if (!isSafeRelPath(path)) {
skipped.push({ path, reason: 'invalid-path' });
continue;
}
if (LOAD_BEARING_FILES.has(basename(path))) {
skipped.push({ path, reason: 'load-bearing' });
continue;
}
toDelete.push({ path, hash });
}
return { toDelete, skipped };
}
// =============================================================================
// Applier — the single chokepoint that touches the filesystem
// =============================================================================
/**
* Apply a deletion list inside a stack directory.
*
* Structurally incapable of touching anything outside stackDir:
* every path is containment-checked, only regular files whose content still
* matches the recorded hash are unlinked, and directory cleanup uses rmdir
* (never recursive) so directories holding any other content survive.
*/
export function applyFileDeletions(stackDir: string, files: FileToDelete[]): DeletionApplyResult {
const root = resolve(stackDir);
const deleted: string[] = [];
const skipped: DeletionSkip[] = [];
const parentDirs = new Set<string>();
for (const { path, hash } of files) {
const abs = containedPath(root, path);
if (!abs) {
skipped.push({ path, reason: 'invalid-path' });
continue;
}
// Defense in depth: computeDeletions already filters these, but the
// applier also runs on lists from external sources (Hawser payloads).
if (LOAD_BEARING_FILES.has(basename(path))) {
skipped.push({ path, reason: 'load-bearing' });
continue;
}
let stat;
try {
stat = lstatSync(abs);
} catch {
skipped.push({ path, reason: 'already-absent' });
continue;
}
// Dockhand only writes regular files. Anything else (symlink, dir,
// socket) means the user replaced it — treat as locally modified.
if (!stat.isFile()) {
skipped.push({ path, reason: 'locally-modified' });
continue;
}
try {
if (hashContent(readFileSync(abs)) !== hash) {
skipped.push({ path, reason: 'locally-modified' });
continue;
}
unlinkSync(abs);
deleted.push(path);
} catch {
skipped.push({ path, reason: 'apply-failed' });
continue;
}
// Collect parent dir chain (inside root) for empty-dir cleanup
let dir = dirname(abs);
while (dir !== root && dir.startsWith(root + sep)) {
parentDirs.add(dir);
dir = dirname(dir);
}
}
// Deepest-first rmdir; fails harmlessly when a directory still has content
const dirsByDepth = [...parentDirs].sort((a, b) => b.length - a.length);
for (const dir of dirsByDepth) {
try {
rmdirSync(dir);
} catch {
// ENOTEMPTY/ENOENT/etc. — directory stays, which is always safe
}
}
return { deleted, skipped };
}
// =============================================================================
// Manifest evolution
// =============================================================================
/**
* Build the manifest to persist after a sync.
*
* Trivial by design: the manifest is always exactly the files written this
* sync, at this sync's commit. Skipped deletions are FINAL (see note above)
* the affected files drop out of the manifest and become unmanaged residue.
*/
export function buildNextManifest(newCommit: string, newFiles: Record<string, string>): SyncManifest {
return { commit: newCommit, files: { ...newFiles } };
}
// =============================================================================
// Sync summary (per-file status table)
// =============================================================================
export function buildSyncChangeSummary(
previousFiles: Record<string, string>,
newFiles: Record<string, string>,
applyResult: DeletionApplyResult,
planSkipped: DeletionSkip[]
): SyncChangeSummary {
const changes: SyncFileChange[] = [];
let unchangedCount = 0;
for (const [path, hash] of Object.entries(newFiles)) {
const oldHash = previousFiles[path];
if (oldHash === undefined) {
changes.push({ file: path, status: 'added' });
} else if (oldHash !== hash) {
changes.push({ file: path, status: 'updated' });
} else {
unchangedCount++;
}
}
for (const path of applyResult.deleted) {
changes.push({ file: path, status: 'removed' });
}
// Benign "already absent" results are not interesting in the summary
const interestingSkips = [...planSkipped, ...applyResult.skipped].filter(
(s) => s.reason !== 'already-absent'
);
for (const skip of interestingSkips) {
changes.push({ file: skip.path, status: 'skipped', reason: skipReasonMessage(skip.reason) });
}
return { changes, unchangedCount };
}
/** Render the summary as aligned text lines for console and job output. */
export function formatChangeTable(summary: SyncChangeSummary): string[] {
const { changes, unchangedCount } = summary;
const counts = { added: 0, updated: 0, removed: 0, skipped: 0 };
for (const c of changes) counts[c.status]++;
const header = `${counts.added} added, ${counts.updated} updated, ${counts.removed} removed, ${counts.skipped} skipped, ${unchangedCount} unchanged`;
if (changes.length === 0) {
return [header];
}
const fileWidth = Math.min(60, Math.max(4, ...changes.map((c) => c.file.length)));
const lines = [header, `${'STATUS'.padEnd(9)} ${'FILE'.padEnd(fileWidth)} REASON`];
for (const c of changes) {
lines.push(`${c.status.padEnd(9)} ${c.file.padEnd(fileWidth)} ${c.reason ?? ''}`.trimEnd());
}
return lines;
}
+326 -58
View File
@@ -15,6 +15,85 @@ import {
type GitStackWithRepo
} from './db';
import { deployStack, getStackDir } from './stacks';
import {
parseManifest,
serializeManifest,
hashDirFiles,
computeDeletions,
buildNextManifest,
buildSyncChangeSummary,
formatChangeTable,
skipReasonMessage,
deletionSafetyCheck,
type DeletionPlan,
type DeletionApplyResult,
type DeletionSkip,
type SyncManifest
} from './git-deletions';
const MERGED_CA_BUNDLE_PATH = '/tmp/dockhand-merged-ca-bundle.crt';
let mergedCaBundleReady = false;
/**
* Create a merged CA bundle combining system CAs with the custom cert from
* NODE_EXTRA_CA_CERTS. GIT_SSL_CAINFO replaces the default CA store, so without
* merging, public CAs (GitHub, GitLab) break.
*/
function getMergedCaBundlePath(): string {
if (mergedCaBundleReady && existsSync(MERGED_CA_BUNDLE_PATH)) {
console.log(`[Git] Using cached merged CA bundle: ${MERGED_CA_BUNDLE_PATH}`);
return MERGED_CA_BUNDLE_PATH;
}
const customCertPath = process.env.NODE_EXTRA_CA_CERTS!;
console.log(`[Git] NODE_EXTRA_CA_CERTS set to: ${customCertPath}`);
const systemCaPaths = [
process.env.SSL_CERT_FILE,
'/etc/ssl/certs/ca-certificates.crt',
'/etc/pki/tls/certs/ca-bundle.crt',
'/etc/ssl/cert.pem'
];
let systemCaContent = '';
let systemCaSource = '';
for (const caPath of systemCaPaths) {
if (caPath && existsSync(caPath)) {
try {
systemCaContent = readFileSync(caPath, 'utf-8');
systemCaSource = caPath;
console.log(`[Git] Found system CA bundle: ${caPath} (${systemCaContent.split('-----BEGIN CERTIFICATE-----').length - 1} certs)`);
break;
} catch (err) {
console.log(`[Git] Failed to read system CA bundle ${caPath}: ${err}`);
}
}
}
if (!systemCaSource) {
console.log(`[Git] No system CA bundle found, using custom cert only: ${customCertPath}`);
}
try {
const customCaContent = readFileSync(customCertPath, 'utf-8');
const customCertCount = customCaContent.split('-----BEGIN CERTIFICATE-----').length - 1;
console.log(`[Git] Custom CA file contains ${customCertCount} cert(s)`);
const merged = systemCaContent
? systemCaContent.trimEnd() + '\n' + customCaContent.trimEnd() + '\n'
: customCaContent;
writeFileSync(MERGED_CA_BUNDLE_PATH, merged);
mergedCaBundleReady = true;
const totalCerts = merged.split('-----BEGIN CERTIFICATE-----').length - 1;
console.log(`[Git] Created merged CA bundle: ${MERGED_CA_BUNDLE_PATH} (${totalCerts} total certs — system from ${systemCaSource || 'none'} + custom from ${customCertPath})`);
} catch (err) {
console.warn(`[Git] Failed to create merged CA bundle, falling back to custom cert only: ${customCertPath}`, err);
return customCertPath;
}
return MERGED_CA_BUNDLE_PATH;
}
/**
* Collect stdout, stderr and exit code from a spawned process.
@@ -45,22 +124,19 @@ if (!existsSync(GIT_REPOS_DIR)) {
mkdirSync(GIT_REPOS_DIR, { recursive: true });
}
export function getGitReposDir(): string {
return GIT_REPOS_DIR;
}
/**
* Mask sensitive values in environment variables for safe logging.
* Redact all env var values for safe logging. Only key names are preserved.
*/
function maskSecrets(vars: Record<string, string>): Record<string, string> {
const masked: Record<string, string> = {};
const secretPatterns = /password|secret|token|key|api_key|apikey|auth|credential|private/i;
for (const [key, value] of Object.entries(vars)) {
if (secretPatterns.test(key)) {
masked[key] = '***';
} else if (value.length > 50) {
masked[key] = value.substring(0, 10) + '...(truncated)';
} else {
masked[key] = value;
}
function redactEnvVarsForLog(vars: Record<string, string>): Record<string, string> {
const redacted: Record<string, string> = {};
for (const key of Object.keys(vars)) {
redacted[key] = '***';
}
return masked;
return redacted;
}
function getRepoPath(repoId: number): string {
@@ -153,14 +229,30 @@ async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
SSH_AUTH_SOCK: ''
};
// Pass custom CA certificate to git CLI (NODE_EXTRA_CA_CERTS only affects Node.js)
// Pass custom CA certificate to git CLI (NODE_EXTRA_CA_CERTS only affects Node.js).
// GIT_SSL_CAINFO replaces the default CA store, so we merge system CAs with the
// custom cert so both self-signed repos and public repos (GitHub etc.) work (#967).
if (process.env.NODE_EXTRA_CA_CERTS) {
env.GIT_SSL_CAINFO = process.env.NODE_EXTRA_CA_CERTS;
env.GIT_SSL_CAINFO = getMergedCaBundlePath();
}
// Ensure current UID is resolvable for SSH/git operations
await ensurePasswdEntry(env);
// For HTTPS password/token auth, inject credentials via http.extraHeader env vars
// instead of embedding them in the URL (which leaks via /proc/<pid>/cmdline, #1081).
// Uses GIT_CONFIG_COUNT mechanism (git >= 2.31) to set Authorization header.
if (credential?.authType === 'password' && (credential.username || credential.password)) {
const token = credential.password || '';
const username = credential.username || '';
// Use Basic auth (base64 of username:password) — works with GitHub PATs,
// GitLab tokens, Gitea tokens, and standard username/password combos.
const basicAuth = Buffer.from(`${username}:${token}`).toString('base64');
env.GIT_CONFIG_COUNT = '1';
env.GIT_CONFIG_KEY_0 = 'http.extraHeader';
env.GIT_CONFIG_VALUE_0 = `Authorization: Basic ${basicAuth}`;
}
if (credential?.authType === 'ssh' && credential.sshPrivateKey) {
// Write SSH key to /tmp instead of data volume — some filesystems (TrueNAS ZFS,
// NFS, CIFS) silently ignore chmod, leaving the key group-readable (e.g. 0670).
@@ -215,24 +307,20 @@ function cleanupSshKey(credential: GitCredential | null): void {
}
function buildRepoUrl(url: string, credential: GitCredential | null): string {
// For SSH URLs or no auth, return as-is
if (!credential || credential.authType !== 'password' || url.startsWith('git@')) {
return url;
}
// For HTTPS with password auth, embed credentials
try {
const parsed = new URL(url);
if (credential.username) {
parsed.username = credential.username;
// Never embed credentials in the URL — they leak via /proc/<pid>/cmdline (see #1081).
// HTTPS credentials are injected via GIT_CONFIG_COUNT env vars in buildGitEnv().
// Strip any existing credentials from the URL for safety.
if (credential?.authType === 'password' && !url.startsWith('git@')) {
try {
const parsed = new URL(url);
parsed.username = '';
parsed.password = '';
return parsed.toString();
} catch {
return url;
}
if (credential.password) {
parsed.password = credential.password;
}
return parsed.toString();
} catch {
return url;
}
return url;
}
async function execGit(args: string[], cwd: string, env: GitEnv): Promise<{ stdout: string; stderr: string; code: number }> {
@@ -290,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;
@@ -302,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 {
@@ -777,15 +957,15 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
// (e.g., config files, scripts, additional env files)
let changedFiles: string[] = [];
if (commitChanged) {
// Get the directory containing the compose file (relative to repo root)
const composeDirRelative = dirname(gitStack.composePath);
console.log(`${logPrefix} Checking for changes in directory: ${composeDirRelative || '(root)'}`);
// Use contextDir if set, otherwise fall back to compose file's directory
const diffDirRelative = gitStack.contextDir || dirname(gitStack.composePath);
console.log(`${logPrefix} Checking for changes in directory: ${diffDirRelative || '(root)'}`);
const diffResult = await getChangedFilesInDir(
repoPath,
previousCommit,
newCommit,
composeDirRelative || '.',
diffDirRelative || '.',
env
);
@@ -827,10 +1007,29 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
console.log(`${logPrefix} Compose content:`);
console.log(composeContent);
// Determine the compose directory and filename (for copying all files)
const composeDir = dirname(composePath);
const composeFileName = basename(gitStack.composePath); // e.g., "docker-compose.yaml"
console.log(`${logPrefix} Compose directory:`, composeDir);
// Determine the source directory and compose filename
// If contextDir is set, use it as the source directory (relative to repo root)
// and compute composeFileName as relative path from contextDir to compose file
let composeDir: string;
let composeFileName: string;
if (gitStack.contextDir) {
const contextDirAbsolute = resolve(repoPath, gitStack.contextDir);
// Validate: context dir must be within repo
if (!contextDirAbsolute.startsWith(repoPath)) {
throw new Error('Context directory must be within the repository');
}
// Validate: compose file must be within context directory
const relCompose = relative(contextDirAbsolute, composePath);
if (relCompose.startsWith('..')) {
throw new Error('Compose file must be within the context directory');
}
composeDir = contextDirAbsolute;
composeFileName = relCompose; // e.g., "apps/myapp/compose.yaml"
} else {
composeDir = dirname(composePath);
composeFileName = basename(gitStack.composePath); // e.g., "docker-compose.yaml"
}
console.log(`${logPrefix} Source directory (composeDir):`, composeDir);
console.log(`${logPrefix} Compose filename:`, composeFileName);
// Read env file if configured (optional - don't fail if missing)
@@ -862,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',
@@ -890,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);
@@ -932,7 +1143,7 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
console.log(`${logPrefix} Sync result - env file vars:`, syncResult.envFileVars ? Object.keys(syncResult.envFileVars).length : 0);
if (syncResult.envFileVars && Object.keys(syncResult.envFileVars).length > 0) {
console.log(`${logPrefix} Env file var keys:`, Object.keys(syncResult.envFileVars).join(', '));
console.log(`${logPrefix} Env file vars (masked):`, JSON.stringify(maskSecrets(syncResult.envFileVars), null, 2));
console.log(`${logPrefix} Env file vars (masked):`, JSON.stringify(redactEnvVarsForLog(syncResult.envFileVars), null, 2));
}
// Check if there are changes - skip redeploy if no changes and not forced
@@ -972,7 +1183,9 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
envFileName: syncResult.envFileName, // Env file relative to compose dir (for --env-file flag, optional)
forceRecreate,
build: gitStack.buildOnDeploy,
pullPolicy: gitStack.repullImages ? 'always' : undefined
noBuildCache: gitStack.noBuildCache,
pullPolicy: gitStack.repullImages ? 'always' : undefined,
filesToDelete: syncResult.deletionPlan?.toDelete
});
console.log(`${logPrefix} ----------------------------------------`);
@@ -983,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
@@ -1148,15 +1374,15 @@ export async function deployGitStackWithProgress(
// Normalize to 7-char short hash for comparison (DB stores 7-char, git returns 40-char)
const commitChanged = previousCommit?.substring(0, 7) !== newCommit.substring(0, 7);
// Check if any files in the compose file's directory have changed
// Check if any files in the context/compose directory have changed
// (for consistency with syncGitStack, though this function always deploys)
if (commitChanged) {
const composeDir = dirname(gitStack.composePath);
const diffDir = gitStack.contextDir || dirname(gitStack.composePath);
const diffResult = await getChangedFilesInDir(
repoPath,
previousCommit,
newCommit,
composeDir || '.',
diffDir || '.',
env
);
updated = diffResult.changed;
@@ -1177,8 +1403,24 @@ export async function deployGitStackWithProgress(
const composeContent = readFileSync(composePath, 'utf-8');
// Determine the compose directory (for copying all files)
const composeDir = dirname(composePath);
// Determine the source directory and compose filename
let composeDir: string;
let progressComposeFileName: string;
if (gitStack.contextDir) {
const contextDirAbsolute = resolve(repoPath, gitStack.contextDir);
if (!contextDirAbsolute.startsWith(repoPath)) {
throw new Error('Context directory must be within the repository');
}
const relCompose = relative(contextDirAbsolute, composePath);
if (relCompose.startsWith('..')) {
throw new Error('Compose file must be within the context directory');
}
composeDir = contextDirAbsolute;
progressComposeFileName = relCompose;
} else {
composeDir = dirname(composePath);
progressComposeFileName = basename(gitStack.composePath);
}
// Read env file if configured (optional - don't fail if missing)
let envFileVars: Record<string, string> | undefined;
@@ -1197,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',
@@ -1210,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;
@@ -1225,16 +1484,31 @@ export async function deployGitStackWithProgress(
compose: composeContent,
envId: gitStack.environmentId,
sourceDir: composeDir, // Copy entire directory from git repo
composeFileName: basename(gitStack.composePath), // Use original compose filename from repo
composeFileName: progressComposeFileName, // Compose filename relative to source dir
envFileName, // Env file relative to compose dir (for --env-file flag, optional)
build: gitStack.buildOnDeploy,
pullPolicy: gitStack.repullImages ? 'always' : undefined
noBuildCache: gitStack.noBuildCache,
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, basename(gitStack.composePath));
const resolvedComposePath = join(stackDir, progressComposeFileName);
await upsertStackSource({
stackName: gitStack.stackName,
@@ -1347,13 +1621,7 @@ export function parseEnvFileContent(content: string, stackName?: string): Record
}
const key = trimmed.substring(0, eqIndex).trim();
let value = trimmed.substring(eqIndex + 1).trim();
// Handle quoted values
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
const value = trimmed.substring(eqIndex + 1).trim();
// Only add if key is valid env var name
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
@@ -1365,7 +1633,7 @@ export function parseEnvFileContent(content: string, stackName?: string): Record
console.log(`${logPrefix} Parsed env vars count:`, Object.keys(result).length);
console.log(`${logPrefix} Parsed env var keys:`, Object.keys(result).join(', '));
console.log(`${logPrefix} Parsed env vars (masked):`, JSON.stringify(maskSecrets(result), null, 2));
console.log(`${logPrefix} Parsed env vars (masked):`, JSON.stringify(redactEnvVarsForLog(result), null, 2));
if (skippedLines.length > 0) {
console.log(`${logPrefix} Skipped lines (${skippedLines.length}):`, skippedLines.slice(0, 10).join('; '));
}
+59 -29
View File
@@ -8,7 +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';
@@ -177,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,
@@ -191,24 +199,26 @@ export async function handleEdgeContainerEvent(
// Broadcast to SSE clients
containerEventEmitter.emit('event', savedEvent);
// Prepare notification
const actionLabel = event.action.charAt(0).toUpperCase() + event.action.slice(1);
const containerLabel = event.containerName || event.containerId.substring(0, 12);
const notificationType =
event.action === 'die' || event.action === 'kill' || event.action === 'oom'
? 'error'
: event.action === 'stop'
? 'warning'
: event.action === 'start'
? 'success'
: 'info';
// Check dockhand.notify label before sending notification
// Docker includes container labels in actorAttributes
if (!isNotifyDisabledByLabel(event.actorAttributes)) {
const actionLabel = event.action.charAt(0).toUpperCase() + event.action.slice(1);
const containerLabel = event.containerName || event.containerId.substring(0, 12);
const notificationType =
event.action === 'die' || event.action === 'kill' || event.action === 'oom'
? 'error'
: event.action === 'stop'
? 'warning'
: event.action === 'start'
? 'success'
: 'info';
// Send notification
await sendEnvironmentNotification(environmentId, event.action as ContainerEventAction, {
title: `Container ${actionLabel}`,
message: `Container "${containerLabel}" ${event.action}${event.image ? ` (${event.image})` : ''}`,
type: notificationType as 'success' | 'error' | 'warning' | 'info'
}, event.image);
await sendEnvironmentNotification(environmentId, event.action as ContainerEventAction, {
title: `Container ${actionLabel}`,
message: `Container "${containerLabel}" ${event.action}${event.image ? ` (${event.image})` : ''}`,
type: notificationType as 'success' | 'error' | 'warning' | 'info'
}, event.image);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error('[Hawser] Error handling container event:', errorMsg);
@@ -255,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
*/
@@ -269,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
}
+65 -23
View File
@@ -34,6 +34,8 @@ let cachedMounts: Array<{ source: string; destination: string }> | null = null;
// Used by scanner to replicate how Dockhand connects to Docker
let cachedOwnDockerHost: string | null = null;
let cachedOwnNetworkMode: string | null = null;
let cachedOwnAllNetworks: string[] | null = null;
let cachedOwnExtraHosts: string[] | null = null;
/**
* Get our own container ID
@@ -85,12 +87,11 @@ export async function detectHostDataDir(): Promise<string | null> {
if (process.env.HOST_DATA_DIR) {
cachedHostDataDir = process.env.HOST_DATA_DIR;
console.log(`[HostPath] Using HOST_DATA_DIR from environment: ${cachedHostDataDir}`);
return cachedHostDataDir;
}
const containerId = getOwnContainerId();
if (!containerId) {
console.warn('[HostPath] Running in Docker but could not detect container ID');
console.warn('[HostPath] Running in Docker but could not detect container ID; ExtraHosts will not be mirrored to sidecars');
return null;
}
@@ -140,6 +141,9 @@ export async function detectHostDataDir(): Promise<string | null> {
Config?: {
Env?: string[];
};
HostConfig?: {
ExtraHosts?: string[];
};
NetworkSettings?: {
Networks?: Record<string, unknown>;
};
@@ -163,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(
@@ -171,11 +178,25 @@ 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(', ')})`);
}
}
cachedOwnExtraHosts = containerInfo.HostConfig?.ExtraHosts?.length
? [...containerInfo.HostConfig.ExtraHosts]
: null;
if (cachedOwnExtraHosts) {
console.log(`[HostPath] Detected own ExtraHosts: ${cachedOwnExtraHosts.join(', ')}`);
}
// Explicit override wins for DATA_DIR path, but we still inspect to populate
// mounts/network/DOCKER_HOST/ExtraHosts caches for sibling sidecars.
if (cachedHostDataDir) {
return cachedHostDataDir;
}
// Find the mount for our DATA_DIR
const dataMount = containerInfo.Mounts?.find(m => m.Destination === dataDir);
@@ -229,6 +250,24 @@ export function getOwnNetworkMode(): string | null {
return cachedOwnNetworkMode;
}
/**
* All Docker networks Dockhand itself is attached to. The scanner uses
* this to detect split-network setups and warn that socket-proxy may not
* be reachable from the network it actually joins (#1011).
*/
export function getOwnAllNetworks(): string[] {
return cachedOwnAllNetworks ? [...cachedOwnAllNetworks] : [];
}
/**
* Get the ExtraHosts entries configured on Dockhand itself.
* Used to mirror host aliases into sibling sidecar containers.
* Populated by detectHostDataDir() at startup.
*/
export function getOwnExtraHosts(): string[] | null {
return cachedOwnExtraHosts ? [...cachedOwnExtraHosts] : null;
}
/**
* Translate a container path to host path
*
@@ -360,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);
}
+36
View File
@@ -0,0 +1,36 @@
type HostConfigLike = {
Binds?: string[] | null;
Mounts?: Array<{ Target?: string | null }> | null;
};
type InspectMountLike = {
Type?: string | null;
Name?: string | null;
Destination?: string | null;
};
/** Build extra bind strings for volume mounts missing from HostConfig. */
export function getAdditionalVolumeBinds(
hostConfig: HostConfigLike,
mounts: InspectMountLike[]
): string[] {
const existingMountTargets = new Set((hostConfig.Binds || []).map((bind: string) => {
const parts = bind.split(':');
return parts.length >= 2 ? parts[1] : parts[0];
}));
for (const mount of hostConfig.Mounts || []) {
if (mount?.Target) existingMountTargets.add(mount.Target);
}
const additionalBinds: string[] = [];
for (const mount of mounts || []) {
if (mount.Type === 'volume' && mount.Name && mount.Destination) {
if (!existingMountTargets.has(mount.Destination)) {
additionalBinds.push(`${mount.Name}:${mount.Destination}`);
}
}
}
return additionalBinds;
}
-697
View File
@@ -1,697 +0,0 @@
import nodemailer from 'nodemailer';
import {
getEnabledNotificationSettings,
getEnabledEnvironmentNotifications,
getEnvironment,
type NotificationSettingData,
type SmtpConfig,
type AppriseConfig,
type NotificationEventType
} from './db';
// Escape special characters for Telegram Markdown
function escapeTelegramMarkdown(text: string): string {
// Escape characters that have special meaning in Telegram Markdown
return text
.replace(/\\/g, '\\\\') // Escape backslashes first
.replace(/_/g, '\\_') // Underscore (italic)
.replace(/\*/g, '\\*') // Asterisk (bold)
.replace(/\[/g, '\\[') // Opening bracket (link)
.replace(/\]/g, '\\]') // Closing bracket (link)
.replace(/`/g, '\\`'); // Backtick (code)
}
/** Drain a response body to release the underlying socket/TLS connection. */
async function drainResponse(response: Response): Promise<void> {
if (!response.bodyUsed) {
try { await response.arrayBuffer(); } catch {}
}
}
export interface NotificationPayload {
title: string;
message: string;
type?: 'info' | 'success' | 'warning' | 'error';
environmentId?: number;
environmentName?: string;
}
// Result type for functions that can return detailed errors
export interface NotificationResult {
success: boolean;
error?: string;
}
// Send notification via SMTP
async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise<NotificationResult> {
try {
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.username ? {
user: config.username,
pass: config.password
} : undefined,
tls: config.skipTlsVerify ? {
rejectUnauthorized: false
} : undefined
});
const envBadge = payload.environmentName
? `<span style="display: inline-block; background: #3b82f6; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px;">${payload.environmentName}</span>`
: '';
const envText = payload.environmentName ? ` [${payload.environmentName}]` : '';
const html = `
<div style="font-family: sans-serif; padding: 20px;">
<h2 style="margin: 0 0 10px 0;">${payload.title}${envBadge}</h2>
<p style="margin: 0; white-space: pre-wrap;">${payload.message}</p>
<hr style="margin: 20px 0; border: none; border-top: 1px solid #eee;">
<p style="margin: 0; font-size: 12px; color: #666;">Sent by Dockhand</p>
</div>
`;
await transporter.sendMail({
from: config.from_name ? `"${config.from_name}" <${config.from_email}>` : config.from_email,
to: config.to_emails.join(', '),
subject: `[Dockhand]${envText} ${payload.title}`,
text: `${payload.title}${envText}\n\n${payload.message}`,
html
});
return { success: true };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return { success: false, error: `SMTP error: ${errorMsg}` };
}
}
// Parse Apprise URL and send notification
async function sendAppriseNotification(config: AppriseConfig, payload: NotificationPayload): Promise<NotificationResult> {
const errors: string[] = [];
for (const url of config.urls) {
try {
const result = await sendToAppriseUrl(url, payload);
if (!result.success && result.error) {
errors.push(result.error);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
errors.push(`Failed to send: ${errorMsg}`);
}
}
if (errors.length > 0) {
return { success: false, error: errors.join('; ') };
}
return { success: true };
}
// Send to a single Apprise URL
async function sendToAppriseUrl(url: string, payload: NotificationPayload): Promise<NotificationResult> {
try {
// Extract protocol from Apprise URL format (protocol://...)
// Note: Can't use new URL() because custom schemes like 'tgram://' are not valid URLs
const protocolMatch = url.match(/^([a-z]+):\/\//i);
if (!protocolMatch) {
return { success: false, error: 'Invalid Apprise URL format - missing protocol' };
}
const protocol = protocolMatch[1].toLowerCase();
// Handle different notification services
switch (protocol) {
case 'discord':
case 'discords':
return await sendDiscord(url, payload);
case 'slack':
case 'slacks':
return await sendSlack(url, payload);
case 'mmost':
case 'mmosts':
return await sendMattermost(url, payload);
case 'tgram':
return await sendTelegram(url, payload);
case 'gotify':
case 'gotifys':
return await sendGotify(url, payload);
case 'ntfy':
case 'ntfys':
return await sendNtfy(url, payload);
case 'pushover':
return await sendPushover(url, payload);
case 'json':
case 'jsons':
return await sendGenericWebhook(url, payload);
default:
return { success: false, error: `Unsupported Apprise protocol: ${protocol}` };
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return { success: false, error: `Failed to parse Apprise URL: ${errorMsg}` };
}
}
// Discord webhook
async function sendDiscord(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// discord://webhook_id/webhook_token or discords://...
const url = appriseUrl.replace(/^discords?:\/\//, 'https://discord.com/api/webhooks/');
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
embeds: [{
title: titleWithEnv,
description: payload.message,
color: payload.type === 'error' ? 0xff0000 : payload.type === 'warning' ? 0xffaa00 : payload.type === 'success' ? 0x00ff00 : 0x0099ff,
...(payload.environmentName && {
footer: { text: `Environment: ${payload.environmentName}` }
})
}]
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Discord error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Discord connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Slack webhook
async function sendSlack(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// slack://token_a/token_b/token_c or webhook URL
let url: string;
if (appriseUrl.includes('hooks.slack.com')) {
url = appriseUrl.replace(/^slacks?:\/\//, 'https://');
} else {
const parts = appriseUrl.replace(/^slacks?:\/\//, '').split('/');
url = `https://hooks.slack.com/services/${parts.join('/')}`;
}
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `*${payload.title}*${envTag}\n${payload.message}`
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Slack error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Slack connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Mattermost webhook
async function sendMattermost(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// mmost://[botname@]hostname[:port][/path]/token or mmosts://...
const isSecure = appriseUrl.startsWith('mmosts');
const protocol = isSecure ? 'https' : 'http';
// Remove the scheme
let urlPart = appriseUrl.replace(/^mmosts?:\/\//, '');
// Check for botname (username@hostname format)
let username: string | undefined;
const atIndex = urlPart.indexOf('@');
if (atIndex !== -1) {
username = urlPart.substring(0, atIndex);
urlPart = urlPart.substring(atIndex + 1);
}
// The token is the last segment, everything else is hostname[:port][/path]
const lastSlashIndex = urlPart.lastIndexOf('/');
if (lastSlashIndex === -1) {
return { success: false, error: 'Invalid Mattermost URL format. Expected: mmost://[botname@]hostname[:port][/path]/token' };
}
const token = urlPart.substring(lastSlashIndex + 1);
const hostAndPath = urlPart.substring(0, lastSlashIndex);
// Build the webhook URL: {protocol}://{hostname}[:{port}][/{path}]/hooks/{token}
const url = `${protocol}://${hostAndPath}/hooks/${token}`;
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
const body: Record<string, string> = {
text: `*${payload.title}*${envTag}\n${payload.message}`
};
if (username) {
body.username = username;
}
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Mattermost error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Mattermost connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Telegram
async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// tgram://bot_token/chat_id:topic_id?
const match = appriseUrl.match(/^tgram:\/\/([^/]+)\/([^:\/]+)(?::(\d+))?$/);
if (!match) {
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, topicIdStr] = match;
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
// Escape markdown special characters in title and message
const escapedTitle = escapeTelegramMarkdown(payload.title);
const escapedMessage = escapeTelegramMarkdown(payload.message);
const envTag = payload.environmentName ? ` \\[${escapeTelegramMarkdown(payload.environmentName)}\\]` : '';
const topicId = topicIdStr ? parseInt(topicIdStr, 10) : undefined;
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'
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({})) as { description?: string };
const errorMsg = errorData.description || response.statusText;
return { success: false, error: `Telegram error ${response.status}: ${errorMsg}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Telegram connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Gotify
async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// gotify://hostname/token or gotifys://hostname/token
// gotify://hostname/subpath/token (subpath support)
const match = appriseUrl.match(/^gotifys?:\/\/([^/]+)\/(.+)/);
if (!match) {
return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' };
}
const [, hostname, pathPart] = match;
const protocol = appriseUrl.startsWith('gotifys') ? 'https' : 'http';
// Token is always the last path segment; anything before it is a subpath
const lastSlash = pathPart.lastIndexOf('/');
const subpath = lastSlash >= 0 ? pathPart.substring(0, lastSlash) : '';
const token = lastSlash >= 0 ? pathPart.substring(lastSlash + 1) : pathPart;
const url = `${protocol}://${hostname}${subpath ? '/' + subpath : ''}/message?token=${token}`;
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: titleWithEnv,
message: payload.message,
priority: payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Gotify error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Gotify connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// ntfy
async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// Supported formats:
// ntfy://topic (public ntfy.sh)
// ntfy://host/topic (custom server, no auth)
// ntfy://user:pass@host/topic (custom server with auth)
// ntfys:// variants for HTTPS
const isSecure = appriseUrl.startsWith('ntfys');
const path = appriseUrl.replace(/^ntfys?:\/\//, '');
let url: string;
let authHeader: string | null = null;
// Check for user:pass@host/topic format (Basic auth)
const basicMatch = path.match(/^([^:]+):([^@]+)@(.+)$/);
if (basicMatch) {
const [, user, pass, hostAndTopic] = basicMatch;
const basic = Buffer.from(`${user}:${pass}`).toString('base64');
authHeader = `Basic ${basic}`;
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
} else if (path.includes('@') && path.includes('/')) {
// token@host/topic -> Bearer token auth
const tokenMatch = path.match(/^([^@]+)@(.+)$/);
if (tokenMatch) {
const [, token, hostAndTopic] = tokenMatch;
authHeader = `Bearer ${token}`;
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
} else {
// Fallback to custom server without auth
url = `${isSecure ? 'https' : 'http'}://${path}`;
}
} else if (path.includes('/')) {
// Custom server without auth
url = `${isSecure ? 'https' : 'http'}://${path}`;
} else {
// Default ntfy.sh
url = `https://ntfy.sh/${path}`;
}
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
const headers: Record<string, string> = {
'Title': titleWithEnv,
'Priority': payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3',
'Tags': payload.type || 'info'
};
if (authHeader) {
headers['Authorization'] = authHeader;
}
try {
const response = await fetch(url, {
method: 'POST',
headers,
body: payload.message
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `ntfy error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `ntfy connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Pushover
async function sendPushover(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// pushover://user_key/api_token
const match = appriseUrl.match(/^pushover:\/\/([^/]+)\/(.+)/);
if (!match) {
return { success: false, error: 'Invalid Pushover URL format. Expected: pushover://user_key/api_token' };
}
const [, userKey, apiToken] = match;
const url = 'https://api.pushover.net/1/messages.json';
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)}` };
}
}
// 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`;
}

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