Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efb634701c | |||
| aa45be6844 | |||
| 0eaf52fa66 | |||
| eb5cf32d68 | |||
| 83c3a5ea09 | |||
| d465ecfe96 | |||
| fd9b18ea31 | |||
| 89505713f1 | |||
| 085f03c178 | |||
| c06d794b92 | |||
| b7a8cca387 | |||
| 3cbcfa3cdb |
@@ -37,7 +37,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
|
||||
" - busybox" \
|
||||
" - tzdata" \
|
||||
" - docker-cli" \
|
||||
" - docker-compose=5.1.4-r3" \
|
||||
" - docker-compose=5.1.4-r5" \
|
||||
" - docker-cli-buildx" \
|
||||
" - sqlite" \
|
||||
" - postgresql-client" \
|
||||
@@ -93,7 +93,7 @@ RUN cp -r node_modules/better-sqlite3/build /tmp/better-sqlite3-build \
|
||||
&& rm -rf node_modules/@types /tmp/better-sqlite3-build
|
||||
|
||||
# Build Go collector
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25.10 AS go-builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25.11 AS go-builder
|
||||
ARG TARGETARCH
|
||||
WORKDIR /app
|
||||
COPY collector/ ./collector/
|
||||
|
||||
@@ -37,10 +37,106 @@ Dockhand is a modern, efficient Docker management application providing real-tim
|
||||
- **Docker**: direct docker API calls.
|
||||
|
||||
## Screenshots
|
||||
| Light Mode | Dark Mode |
|
||||
| --- | --- |
|
||||
| <img src="docs/dashboard1.webp" width="600" alt="Dashboard 1 Light"> | <img src="docs/dashboard2.webp" width="600" alt="Dashboard 2 Dark"> |
|
||||
| <img src="docs/dashboard3.webp" width="600" alt="Dashboard 3 Light"> | <img src="docs/dashboard4.webp" width="600" alt="Dashboard 4 Dark"> |
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot1.webp" alt="Environments overview">
|
||||
<p align="center"><sub><sub><sub><b>Environments overview</b> — manage every Docker host from one place</sub></sub></sub></p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot2.webp" alt="Environment dashboard">
|
||||
<p align="center"><sub><sub><sub><b>Environment dashboard</b> — live CPU, memory and disk metrics per host</sub></sub></sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot3.webp" alt="Containers">
|
||||
<p align="center"><sub><sub><sub><b>Containers</b> — real-time status, resources and port mappings</sub></sub></sub></p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot6.webp" alt="Compose stacks">
|
||||
<p align="center"><sub><sub><sub><b>Compose stacks</b> — deploy and orchestrate multi-container apps</sub></sub></sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot7.webp" alt="Compose editor">
|
||||
<p align="center"><sub><sub><sub><b>Compose editor</b> — edit YAML side-by-side with env variables</sub></sub></sub></p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot8.webp" alt="Images">
|
||||
<p align="center"><sub><sub><sub><b>Images</b> — track tags, sizes, updates and clean up unused</sub></sub></sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot4.webp" alt="Logs and terminal">
|
||||
<p align="center"><sub><sub><sub><b>Logs & 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 & 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 & 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 & auto-sync</sub></sub></sub></p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot17.webp" alt="Schedules">
|
||||
<p align="center"><sub><sub><sub><b>Schedules</b> — cron-style automation for prune, updates and cleanup</sub></sub></sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<img src="docs/screenshot18.webp" alt="Activity log">
|
||||
<p align="center"><sub><sub><sub><b>Activity log</b> — audit every action across all environments</sub></sub></sub></p>
|
||||
</td>
|
||||
<td width="50%"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
module github.com/Finsys/dockhand/collector
|
||||
|
||||
go 1.25.10
|
||||
go 1.25.11
|
||||
|
||||
@@ -221,13 +221,19 @@ func buildClients(cfg *EnvConfig) (client *http.Client, streamClient *http.Clien
|
||||
baseURL = "http://localhost"
|
||||
|
||||
case "http":
|
||||
// Explicit dial timeout and TCP keepalive so connections over dead
|
||||
// tunnels (VPN/Tailscale drops) are detected at kernel level instead
|
||||
// of hanging indefinitely.
|
||||
tcpDial := (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 15 * time.Second}).DialContext
|
||||
transport = &http.Transport{
|
||||
DialContext: tcpDial,
|
||||
MaxIdleConns: 16,
|
||||
MaxIdleConnsPerHost: 16,
|
||||
MaxConnsPerHost: 16,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
}
|
||||
streamTransport = &http.Transport{
|
||||
DialContext: tcpDial,
|
||||
MaxIdleConns: 4,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
MaxConnsPerHost: 4,
|
||||
@@ -242,7 +248,9 @@ func buildClients(cfg *EnvConfig) (client *http.Client, streamClient *http.Clien
|
||||
}
|
||||
streamTLSCfg := tlsCfg.Clone()
|
||||
|
||||
tcpDial := (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 15 * time.Second}).DialContext
|
||||
transport = &http.Transport{
|
||||
DialContext: tcpDial,
|
||||
TLSClientConfig: tlsCfg,
|
||||
MaxIdleConns: 16,
|
||||
MaxIdleConnsPerHost: 16,
|
||||
@@ -250,6 +258,7 @@ func buildClients(cfg *EnvConfig) (client *http.Client, streamClient *http.Clien
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
}
|
||||
streamTransport = &http.Transport{
|
||||
DialContext: tcpDial,
|
||||
TLSClientConfig: streamTLSCfg,
|
||||
MaxIdleConns: 4,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
@@ -322,15 +331,32 @@ func (e *environment) doStreamRequest(ctx context.Context, method, path string)
|
||||
return e.streamClient.Do(req)
|
||||
}
|
||||
|
||||
func (e *environment) ping(ctx context.Context) bool {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
resp, err := e.doRequest(ctx, "GET", "/_ping")
|
||||
if err != nil {
|
||||
return false
|
||||
func (e *environment) ping(ctx context.Context) error {
|
||||
attempt := func() error {
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
resp, err := e.doRequest(pingCtx, "GET", "/_ping")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
drainAndClose(resp)
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("ping returned status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
drainAndClose(resp)
|
||||
return resp.StatusCode == 200
|
||||
|
||||
if err := attempt(); err == nil {
|
||||
return nil
|
||||
} else if ctx.Err() != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Stale pooled connections (e.g. after a VPN/tunnel drop) hang requests
|
||||
// until timeout while the host is actually reachable. Evict the pool and
|
||||
// retry once on a guaranteed-fresh connection.
|
||||
e.closeTransports()
|
||||
return attempt()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -358,11 +384,11 @@ func (m *manager) runMetrics(env *environment) {
|
||||
}
|
||||
|
||||
func (m *manager) collectMetrics(env *environment) {
|
||||
if !env.ping(env.ctx) {
|
||||
if err := env.ping(env.ctx); err != nil {
|
||||
if env.online || !env.statusReported {
|
||||
env.online = false
|
||||
env.statusReported = true
|
||||
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable"})
|
||||
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable: " + err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -558,11 +584,11 @@ func (m *manager) runEvents(env *environment) {
|
||||
}
|
||||
|
||||
// Stream mode
|
||||
if !env.ping(env.ctx) {
|
||||
if err := env.ping(env.ctx); err != nil {
|
||||
if env.online || !env.statusReported {
|
||||
env.online = false
|
||||
env.statusReported = true
|
||||
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable"})
|
||||
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable: " + err.Error()})
|
||||
}
|
||||
if !waitOrCancel(reconnectDelay) {
|
||||
return
|
||||
@@ -609,12 +635,32 @@ func (m *manager) runEvents(env *environment) {
|
||||
// Force-close the body on context cancellation so scanner.Scan()
|
||||
// unblocks. Without this, the goroutine can leak if the transport's
|
||||
// internal cancel watcher doesn't fire (Go runtime implementation detail).
|
||||
//
|
||||
// The watchdog ticker handles half-open connections (e.g. after a
|
||||
// VPN/tunnel drop): the stream client has no timeout, so Scan() would
|
||||
// otherwise block forever on a dead connection that never errors.
|
||||
// A failed ping (which retries on a fresh connection internally)
|
||||
// means the host is unreachable — close the body so the reconnect
|
||||
// loop takes over.
|
||||
bodyDone := make(chan struct{})
|
||||
var closeBodyOnce sync.Once
|
||||
closeBody := func() { closeBodyOnce.Do(func() { resp.Body.Close() }) }
|
||||
go func() {
|
||||
select {
|
||||
case <-env.ctx.Done():
|
||||
resp.Body.Close()
|
||||
case <-bodyDone:
|
||||
watchdog := time.NewTicker(90 * time.Second)
|
||||
defer watchdog.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-env.ctx.Done():
|
||||
closeBody()
|
||||
return
|
||||
case <-bodyDone:
|
||||
return
|
||||
case <-watchdog.C:
|
||||
if env.ping(env.ctx) != nil {
|
||||
closeBody()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -638,7 +684,7 @@ func (m *manager) runEvents(env *environment) {
|
||||
}
|
||||
}
|
||||
close(bodyDone)
|
||||
resp.Body.Close()
|
||||
closeBody()
|
||||
|
||||
if env.ctx.Err() != nil {
|
||||
return
|
||||
@@ -653,11 +699,11 @@ func (m *manager) runEvents(env *environment) {
|
||||
}
|
||||
|
||||
func (m *manager) pollEvents(env *environment) {
|
||||
if !env.ping(env.ctx) {
|
||||
if err := env.ping(env.ctx); err != nil {
|
||||
if env.online || !env.statusReported {
|
||||
env.online = false
|
||||
env.statusReported = true
|
||||
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable"})
|
||||
m.send(OutMessage{Type: "env_status", EnvID: env.id, Online: boolPtr(false), Error: "Docker not reachable: " + err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -736,7 +782,7 @@ func (m *manager) runDiskChecks(env *environment) {
|
||||
}
|
||||
|
||||
func (m *manager) checkDisk(env *environment) {
|
||||
if !env.ping(env.ctx) {
|
||||
if env.ping(env.ctx) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 365 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 150 KiB |
@@ -0,0 +1 @@
|
||||
ALTER TABLE "git_stacks" ADD COLUMN "synced_files" text;
|
||||
@@ -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")
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2386,14 +2386,14 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"external_compose_path": {
|
||||
"name": "external_compose_path",
|
||||
"compose_path": {
|
||||
"name": "compose_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"external_env_path": {
|
||||
"name": "external_env_path",
|
||||
"env_path": {
|
||||
"name": "env_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
|
||||
@@ -50,6 +50,20 @@
|
||||
"when": 1777220350655,
|
||||
"tag": "0006_add_git_stack_context_dir",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1781158711008,
|
||||
"tag": "0007_add_synced_files",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1781620381909,
|
||||
"tag": "0008_add_template_sources",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `git_stacks` ADD `synced_files` text;
|
||||
@@ -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`);
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -50,6 +50,20 @@
|
||||
"when": 1777220350655,
|
||||
"tag": "0006_add_git_stack_context_dir",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1781158702731,
|
||||
"tag": "0007_add_synced_files",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1781620376161,
|
||||
"tag": "0008_add_template_sources",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dockhand",
|
||||
"private": true,
|
||||
"version": "1.0.31",
|
||||
"version": "1.0.34",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npx vite dev",
|
||||
@@ -63,8 +63,9 @@
|
||||
"@codemirror/lang-python": "6.2.1",
|
||||
"@codemirror/lang-sql": "6.10.0",
|
||||
"@codemirror/lang-xml": "6.1.0",
|
||||
"@codemirror/lang-yaml": "6.1.2",
|
||||
"@codemirror/lang-yaml": "6.1.3",
|
||||
"@codemirror/language": "6.12.1",
|
||||
"@codemirror/legacy-modes": "6.5.3",
|
||||
"@codemirror/search": "6.6.0",
|
||||
"@codemirror/state": "6.5.4",
|
||||
"@codemirror/theme-one-dark": "6.1.3",
|
||||
@@ -81,14 +82,14 @@
|
||||
"fast-xml-parser": "5.7.3",
|
||||
"js-yaml": "4.1.1",
|
||||
"ldapts": "8.1.3",
|
||||
"nodemailer": "8.0.5",
|
||||
"nodemailer": "8.0.9",
|
||||
"otpauth": "9.4.1",
|
||||
"postgres": "3.4.8",
|
||||
"qrcode": "1.5.4",
|
||||
"rollup": "4.60.0",
|
||||
"svelte-sonner": "1.0.7",
|
||||
"undici": "7.24.5",
|
||||
"ws": "8.20.1"
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.10.1",
|
||||
@@ -116,7 +117,7 @@
|
||||
"d3-shape": "^3.2.0",
|
||||
"drizzle-kit": "0.31.8",
|
||||
"layerchart": "^1.0.13",
|
||||
"lucide-svelte": "^0.562.0",
|
||||
"lucide-svelte": "0.562.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"postcss": "^8.5.6",
|
||||
"svelte": "5.55.7",
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
* Usage: node ./server.js
|
||||
*/
|
||||
|
||||
import { createServer, request as httpRequest } from 'node:http';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import { createServer as createHttpServer, request as httpRequest } from 'node:http';
|
||||
import { createServer as createHttpsServer, request as httpsRequest } from 'node:https';
|
||||
import { createConnection } from 'node:net';
|
||||
import { connect as tlsConnect, rootCertificates } from 'node:tls';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { randomUUID, X509Certificate } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { handler } from './build/handler.js';
|
||||
|
||||
@@ -28,10 +29,82 @@ console.warn = (...args) => _warn(ts(), ...args);
|
||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
|
||||
// Create HTTP server with SvelteKit handler
|
||||
const server = createServer((req, res) => {
|
||||
handler(req, res);
|
||||
});
|
||||
// Optional native HTTPS listener (#1102). Off by default to keep existing
|
||||
// deployments unchanged. When HTTPS_MODE=on, HTTPS_CERT_PATH and
|
||||
// HTTPS_KEY_PATH must both point to readable PEM files.
|
||||
const HTTPS_MODE = (process.env.HTTPS_MODE || 'off').toLowerCase();
|
||||
const useHttps = HTTPS_MODE === 'on';
|
||||
|
||||
let server;
|
||||
if (useHttps) {
|
||||
const certPath = process.env.HTTPS_CERT_PATH;
|
||||
const keyPath = process.env.HTTPS_KEY_PATH;
|
||||
const caPath = process.env.HTTPS_CA_PATH;
|
||||
|
||||
console.log('[HTTPS] mode=on');
|
||||
console.log(`[HTTPS] cert=${certPath || '(missing)'}`);
|
||||
console.log(`[HTTPS] key=${keyPath || '(missing)'}`);
|
||||
console.log(`[HTTPS] ca=${caPath || '(none)'}`);
|
||||
|
||||
if (!certPath || !keyPath) {
|
||||
console.error('[HTTPS] HTTPS_MODE=on requires HTTPS_CERT_PATH and HTTPS_KEY_PATH');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let certPem, keyPem, caPem;
|
||||
try {
|
||||
certPem = readFileSync(certPath);
|
||||
keyPem = readFileSync(keyPath);
|
||||
if (caPath) caPem = readFileSync(caPath);
|
||||
} catch (e) {
|
||||
console.error(`[HTTPS] Failed to read cert/key file: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse cert metadata so operators can confirm they mounted the right file.
|
||||
try {
|
||||
const x509 = new X509Certificate(certPem);
|
||||
console.log(`[HTTPS] cert subject: ${x509.subject.replace(/\n/g, ', ')}`);
|
||||
console.log(`[HTTPS] cert issuer: ${x509.issuer.replace(/\n/g, ', ')}`);
|
||||
console.log(`[HTTPS] cert SAN: ${x509.subjectAltName || '(none)'}`);
|
||||
console.log(`[HTTPS] cert valid: ${x509.validFrom} → ${x509.validTo}`);
|
||||
const expiresAt = new Date(x509.validTo).getTime();
|
||||
const daysLeft = Math.floor((expiresAt - Date.now()) / 86400000);
|
||||
if (daysLeft < 0) {
|
||||
console.warn(`[HTTPS] WARNING: certificate expired ${-daysLeft} day(s) ago`);
|
||||
} else if (daysLeft < 30) {
|
||||
console.warn(`[HTTPS] WARNING: certificate expires in ${daysLeft} day(s)`);
|
||||
} else {
|
||||
console.log(`[HTTPS] cert expires in ${daysLeft} day(s)`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[HTTPS] Failed to parse certificate: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const tlsOptions = { cert: certPem, key: keyPem };
|
||||
if (caPem) tlsOptions.ca = caPem;
|
||||
|
||||
// HSTS — only meaningful over HTTPS, so wired only here. Default 1 year;
|
||||
// set HSTS_MAX_AGE=0 to disable.
|
||||
const hstsMaxAge = parseInt(process.env.HSTS_MAX_AGE ?? '31536000', 10);
|
||||
const hstsHeader = hstsMaxAge > 0 ? `max-age=${hstsMaxAge}` : null;
|
||||
if (hstsHeader) {
|
||||
console.log(`[HTTPS] HSTS enabled: ${hstsHeader}`);
|
||||
} else {
|
||||
console.log('[HTTPS] HSTS disabled (HSTS_MAX_AGE=0)');
|
||||
}
|
||||
|
||||
server = createHttpsServer(tlsOptions, (req, res) => {
|
||||
if (hstsHeader) res.setHeader('Strict-Transport-Security', hstsHeader);
|
||||
handler(req, res);
|
||||
});
|
||||
} else {
|
||||
console.log(`[HTTPS] mode=off (set HTTPS_MODE=on to enable native TLS)`);
|
||||
server = createHttpServer((req, res) => {
|
||||
handler(req, res);
|
||||
});
|
||||
}
|
||||
|
||||
// Create WebSocket server attached to the HTTP server
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
@@ -95,7 +168,7 @@ globalThis.__terminalHandleExecMessage = (msg) => {
|
||||
};
|
||||
|
||||
// Handle WebSocket upgrade
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
server.on('upgrade', async (req, socket, head) => {
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
||||
|
||||
// Only handle our specific WebSocket paths
|
||||
@@ -107,7 +180,30 @@ server.on('upgrade', (req, socket, head) => {
|
||||
return;
|
||||
}
|
||||
|
||||
let wsAuth = null;
|
||||
if (isTerminal) {
|
||||
try {
|
||||
if (typeof globalThis.__authenticateWsUpgrade !== 'function') {
|
||||
socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wsAuth = await globalThis.__authenticateWsUpgrade(req.headers);
|
||||
if (!wsAuth) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WS] auth error during upgrade:', err);
|
||||
socket.write('HTTP/1.1 500 Internal Server Error\r\nConnection: close\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
if (wsAuth) ws.__auth = wsAuth;
|
||||
wss.emit('connection', ws, req);
|
||||
});
|
||||
});
|
||||
@@ -150,6 +246,22 @@ async function handleTerminalConnection(ws, url, connId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ws.__auth && typeof globalThis.__canAccessEnvForUser === 'function') {
|
||||
try {
|
||||
const ok = await globalThis.__canAccessEnvForUser(ws.__auth, envId);
|
||||
if (!ok) {
|
||||
console.warn(`[WS] env access denied: user=${ws.__auth.username} envId=${envId}`);
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Access denied for this environment' }));
|
||||
ws.close(1008, 'env access denied');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WS] env access check failed:', err);
|
||||
ws.close(1011, 'internal error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve Docker target via SvelteKit app's database
|
||||
let target;
|
||||
@@ -458,7 +570,8 @@ function handleHawserConnection(ws, connId, remoteIp) {
|
||||
|
||||
// Start the server
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`Listening on http://${HOST}:${PORT}/ with WebSocket`);
|
||||
const scheme = useHttps ? 'https' : 'http';
|
||||
console.log(`Listening on ${scheme}://${HOST}:${PORT}/ with WebSocket`);
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -74,28 +74,31 @@ html {
|
||||
max-width: calc(90px * var(--grid-font-size-scale, 1)) !important;
|
||||
}
|
||||
|
||||
/* Scrollbar theming */
|
||||
* {
|
||||
scrollbar-color: hsl(var(--border) / 0.5) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* Scrollbar theming — WebKit only (Sencho-style). No global * selector and
|
||||
* no scrollbar-width override, so Firefox/native scrollbars render at OS
|
||||
* default width. Dark-mode thumb bumped to be visible on dark surfaces. */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--border) / 0.5);
|
||||
/* Light mode: medium gray that holds up against white. Pale border-color
|
||||
* at 50% was nearly invisible. */
|
||||
background: hsl(0 0% 60% / 0.6);
|
||||
border-radius: 4px;
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--border) / 0.7);
|
||||
background: hsl(0 0% 40% / 0.8);
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: hsl(0 0% 50% / 0.5);
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(0 0% 65% / 0.7);
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -1338,6 +1341,16 @@ html {
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
/* Icon animation toggle (#1169): when html.no-icon-animation is set, the
|
||||
common Tailwind animation utilities collapse to no-op. This keeps the
|
||||
layout (spinners still occupy space) but removes the motion. */
|
||||
html.no-icon-animation .animate-spin,
|
||||
html.no-icon-animation .animate-pulse,
|
||||
html.no-icon-animation .animate-bounce,
|
||||
html.no-icon-animation .animate-ping {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
/* Icon glow utilities - standard size (4px blur, 0.6 opacity) */
|
||||
.glow-green { filter: drop-shadow(0 0 4px rgba(34, 197, 94, 0.6)); }
|
||||
.glow-green-sm { filter: drop-shadow(0 0 3px rgba(34, 197, 94, 0.5)); }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,14 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { EditorState, StateField, StateEffect, RangeSet } from '@codemirror/state';
|
||||
import { EditorState, StateField, StateEffect, RangeSet, Prec } from '@codemirror/state';
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, gutter, GutterMarker, Decoration, WidgetType, type DecorationSet } from '@codemirror/view';
|
||||
// Note: Secret masking was removed - secrets are now excluded from the raw editor entirely
|
||||
// and are only stored in the database (never written to .env file)
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
||||
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, StreamLanguage, type StreamParser } from '@codemirror/language';
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab, insertNewlineAndIndent } from '@codemirror/commands';
|
||||
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, indentUnit, StreamLanguage, type StreamParser } from '@codemirror/language';
|
||||
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||
import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
|
||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||
import { shell } from '@codemirror/legacy-modes/mode/shell';
|
||||
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
|
||||
import { toml } from '@codemirror/legacy-modes/mode/toml';
|
||||
import { properties } from '@codemirror/legacy-modes/mode/properties';
|
||||
|
||||
// Simple dotenv/env file language parser
|
||||
const dotenvParser: StreamParser<{ inValue: boolean }> = {
|
||||
@@ -496,6 +500,21 @@
|
||||
initialSpacer: () => new VariableGutterMarker('required')
|
||||
});
|
||||
|
||||
// YAML Enter handler: after a key-only line ending with ":", indent one level
|
||||
// deeper than what the default indent service returns (it can't predict child
|
||||
// indent when no child content exists yet).
|
||||
function yamlNewlineAndIndent(view: EditorView): boolean {
|
||||
const { state } = view;
|
||||
const line = state.doc.lineAt(state.selection.main.head);
|
||||
const withoutComment = line.text.trimEnd().replace(/#.*$/, '').trimEnd();
|
||||
if (!withoutComment.endsWith(':')) return false;
|
||||
insertNewlineAndIndent(view);
|
||||
const unit = state.facet(indentUnit);
|
||||
const cursor = view.state.selection.main.head;
|
||||
view.dispatch({ changes: { from: cursor, insert: unit }, selection: { anchor: cursor + unit.length } });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get language extension based on language name
|
||||
function getLanguageExtension(lang: string) {
|
||||
switch (lang) {
|
||||
@@ -527,12 +546,18 @@
|
||||
return xml();
|
||||
case 'sql':
|
||||
return sql();
|
||||
case 'dockerfile':
|
||||
case 'shell':
|
||||
case 'bash':
|
||||
case 'sh':
|
||||
// No dedicated shell/dockerfile support, use basic highlighting
|
||||
return [];
|
||||
return StreamLanguage.define(shell);
|
||||
case 'dockerfile':
|
||||
return StreamLanguage.define(dockerFile);
|
||||
case 'toml':
|
||||
return StreamLanguage.define(toml);
|
||||
case 'ini':
|
||||
case 'conf':
|
||||
case 'properties':
|
||||
return StreamLanguage.define(properties);
|
||||
case 'dotenv':
|
||||
case 'env':
|
||||
return StreamLanguage.define(dotenvParser);
|
||||
@@ -671,7 +696,9 @@
|
||||
]),
|
||||
...themeExtensions,
|
||||
EditorView.lineWrapping,
|
||||
getLanguageExtension(language)
|
||||
EditorState.tabSize.of(2),
|
||||
getLanguageExtension(language),
|
||||
...(language === 'yaml' ? [Prec.high(keymap.of([{ key: 'Enter', run: yamlNewlineAndIndent }]))] : [])
|
||||
].flat();
|
||||
|
||||
if (readonly) {
|
||||
|
||||
@@ -309,7 +309,7 @@
|
||||
class="h-10"
|
||||
>
|
||||
{#if isPulling}
|
||||
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
|
||||
<Download class="w-4 h-4 mr-2 animate-spin" />
|
||||
Pulling...
|
||||
{:else}
|
||||
<Download class="w-4 h-4" />
|
||||
@@ -327,7 +327,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if status === 'pulling'}
|
||||
<Loader2 class="w-4 h-4 animate-spin text-blue-600" />
|
||||
<Download class="w-4 h-4 animate-spin text-blue-600" />
|
||||
<span class="text-sm">Pulling layers...</span>
|
||||
{:else if status === 'complete'}
|
||||
<CheckCircle2 class="w-4 h-4 text-green-600" />
|
||||
|
||||
@@ -228,7 +228,7 @@
|
||||
<Shield class="w-4 h-4 text-muted-foreground" />
|
||||
<span class="text-sm text-muted-foreground">Ready to scan</span>
|
||||
{:else if status === 'scanning'}
|
||||
<Loader2 class="w-4 h-4 animate-spin text-blue-600" />
|
||||
<Shield class="w-4 h-4 animate-spin text-blue-600" />
|
||||
<span class="text-sm">Scanning for vulnerabilities...</span>
|
||||
{:else if status === 'complete'}
|
||||
{#if hasCriticalOrHigh}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Sparkles, Bug, Zap, CheckCircle, ScrollText } from 'lucide-svelte';
|
||||
import { compareVersions } from '$lib/utils/version';
|
||||
import ChangelogText from '$lib/components/ChangelogText.svelte';
|
||||
|
||||
interface ChangelogEntry {
|
||||
version: string;
|
||||
@@ -66,7 +67,7 @@
|
||||
{@const { icon: Icon, class: iconClass } = getChangeIcon(change.type)}
|
||||
<div class="flex items-start gap-2">
|
||||
<Icon class="w-4 h-4 mt-0.5 shrink-0 {iconClass}" />
|
||||
<span class="text-sm">{change.text}</span>
|
||||
<ChangelogText text={change.text} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
User,
|
||||
ClipboardList,
|
||||
Activity,
|
||||
Timer
|
||||
Timer,
|
||||
LibraryBig
|
||||
} from 'lucide-svelte';
|
||||
import { licenseStore } from '$lib/stores/license';
|
||||
import { authStore, hasAnyAccess } from '$lib/stores/auth';
|
||||
@@ -101,6 +102,7 @@
|
||||
{ href: '/images', Icon: Images, label: 'Images', permission: 'images' },
|
||||
{ href: '/volumes', Icon: HardDrive, label: 'Volumes', permission: 'volumes' },
|
||||
{ href: '/networks', Icon: Network, label: 'Networks', permission: 'networks' },
|
||||
{ href: '/templates', Icon: LibraryBig, label: 'Templates', permission: 'templates' },
|
||||
{ href: '/registry', Icon: Download, label: 'Registry', permission: 'registries' },
|
||||
{ href: '/activity', Icon: Activity, label: 'Activity', permission: 'activity' },
|
||||
{ href: '/schedules', Icon: Timer, label: 'Schedules', permission: 'schedules' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Cpu, MemoryStick, Box, Globe, ChevronDown, Check, HardDrive, Clock, Wifi, WifiOff, Route, UndoDot, Icon, AlertCircle, Loader2, Search, X } from 'lucide-svelte';
|
||||
import { Cpu, MemoryStick, Box, Globe, ChevronDown, Check, HardDrive, Clock, Wifi, WifiOff, Route, UndoDot, Icon, AlertCircle, Loader2, Search, Server, X } from 'lucide-svelte';
|
||||
import { whale } from '@lucide/lab';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { currentEnvironment, environments, type Environment } from '$lib/stores/environment';
|
||||
@@ -95,6 +95,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Display string for the env hostname / IP in the header (#962).
|
||||
// Show both when available; drop only the field that is unknown/empty.
|
||||
// Hide the whole block when neither is meaningful (e.g. hawser-edge
|
||||
// reports 'unknown' for both).
|
||||
const hostLabel = $derived.by(() => {
|
||||
if (!hostInfo) return '';
|
||||
const isMeaningful = (v: string | undefined) => {
|
||||
const t = (v || '').trim();
|
||||
return t && t.toLowerCase() !== 'unknown';
|
||||
};
|
||||
const h = isMeaningful(hostInfo.hostname) ? hostInfo.hostname.trim() : '';
|
||||
const ip = isMeaningful(hostInfo.ipAddress) ? hostInfo.ipAddress.trim() : '';
|
||||
if (h && ip && h !== ip) return `${h} (${ip})`;
|
||||
return h || ip;
|
||||
});
|
||||
|
||||
// Reactive environment list from store
|
||||
let envList = $derived($environments);
|
||||
const showSearch = $derived(envList.length > 8);
|
||||
@@ -449,6 +465,16 @@
|
||||
{#if hostInfo}
|
||||
<span class="text-border">|</span>
|
||||
|
||||
<!-- Hostname / IP (#962) — first info segment after the env dropdown.
|
||||
Hidden on narrow viewports to keep the strip readable. -->
|
||||
{#if hostLabel}
|
||||
<div class="hidden xl:flex items-center gap-1" title="Daemon hostname / IP">
|
||||
<Server class="{iconSizeClass()}" />
|
||||
<span>{hostLabel}</span>
|
||||
</div>
|
||||
<span class="hidden xl:inline text-border">|</span>
|
||||
{/if}
|
||||
|
||||
<!-- Platform/OS -->
|
||||
<span class="hidden md:inline">{hostInfo.platform} {hostInfo.arch}</span>
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -1,4 +1,68 @@
|
||||
[
|
||||
{
|
||||
"version": "1.0.34",
|
||||
"date": "2026-06-17",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "raw file download — no tar wrapping (#1180)" },
|
||||
{ "type": "fix", "text": "update modal stuck after closing mid-pull (#1094)" },
|
||||
{ "type": "fix", "text": "vulnerability scans on Podman hosts (direct TCP and Hawser) (#1076)" },
|
||||
{ "type": "fix", "text": "crash-looping containers now appear in the logs page list (#227)" },
|
||||
{ "type": "feature", "text": "filter containers by \"Update available\" (#1063)" },
|
||||
{ "type": "feature", "text": "show hostname / IP of the selected environment in the top header (#962)" },
|
||||
{ "type": "feature", "text": "internal auth and validation hardening and dependency bumps" },
|
||||
{ "type": "feature", "text": "Traefik and Pangolin integration — surface proxy URLs on container and stack panels (#2)" },
|
||||
{ "type": "feature", "text": "release-notes link next to images with updates available (#538)" },
|
||||
{ "type": "feature", "text": "lifecycle action buttons in the container details modal (#461)" },
|
||||
{ "type": "feature", "text": "template library — browse and deploy compose templates from configurable sources (#48)" },
|
||||
{ "type": "fix", "text": "file browser fails on containers with ls in /usr/sbin (#1185)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.34"
|
||||
},
|
||||
{
|
||||
"version": "1.0.33",
|
||||
"date": "2026-06-15",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "in-place container property updates without restart — restart policy, CPU/memory limits (#1153)" },
|
||||
{ "type": "feature", "text": "clickable stack badge in container and volume inspect modals (#1121)" },
|
||||
{ "type": "feature", "text": "clickable stack badge in volumes list row (#1122)" },
|
||||
{ "type": "feature", "text": "volumes list shows driver_opts type (NFS, CIFS, etc.) with sort and filter (#1123)" },
|
||||
{ "type": "feature", "text": "Bark iOS notifications (#1095, PR#1097, @undirectlookable)" },
|
||||
{ "type": "feature", "text": "Signal notifications via signal-cli-rest-api (#1099)" },
|
||||
{ "type": "feature", "text": "Apprise passthrough — forward to a self-hosted caronc/apprise-api server (#1099)" },
|
||||
{ "type": "fix", "text": "env editor flagged Docker/Compose built-ins as MISSING (#141)" },
|
||||
{ "type": "fix", "text": "YAML editor indentation was inconsistent when pressing Enter (#1156)" },
|
||||
{ "type": "feature", "text": "`dockhand.update=false`, `dockhand.hidden=true` and `localhost/*` images skip registry polling (#1083)" },
|
||||
{ "type": "fix", "text": "registry authentication for image pulls (#1105)" },
|
||||
{ "type": "feature", "text": "native HTTPS listener, off by default (#1102)" },
|
||||
{ "type": "fix", "text": "environments stuck \"Failed\" after VPN/Tailscale tunnel drops until agent restart (#1160)" },
|
||||
{ "type": "fix", "text": "health_status events flooding container_events table (#1165)" },
|
||||
{ "type": "fix", "text": "git stack sync removes files deleted from the repo (hash-verified) (#966, #1162)" },
|
||||
{ "type": "feature", "text": "upload TLS/mTLS certificate files in environment editor (#125)" },
|
||||
{ "type": "feature", "text": "syntax highlighting for shell, Dockerfile, TOML, INI/conf and .env files in the file browser viewer (#1055)" },
|
||||
{ "type": "feature", "text": "Animated icons now configurable (#1169)" },
|
||||
{ "type": "fix", "text": "stack deploys ignored the env's configured socket path (#1172)" },
|
||||
{ "type": "fix", "text": "environment names with characters that break path resolution (e.g. `*`) are now rejected (#1179)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.33"
|
||||
},
|
||||
{
|
||||
"version": "1.0.32",
|
||||
"date": "2026-06-06",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "container details tweaks: process count, label filter, copy all labels (#812)" },
|
||||
{ "type": "feature", "text": "log improvements (#1130)" },
|
||||
{ "type": "fix", "text": "cleared Resources fields not persisted on container edit (#1119)" },
|
||||
{ "type": "fix", "text": "long container names overflowed in activity event details dialog (#1129)" },
|
||||
{ "type": "fix", "text": "git stack recreate and start operations ignored Dockhand-stored env vars (#1132)" },
|
||||
{ "type": "fix", "text": "dashboard stopped count reset to 0 after refresh for gracefully stopped containers (#1133)" },
|
||||
{ "type": "fix", "text": "auto-update preserves runtime `-e` env and `-l` label overrides (#1135)" },
|
||||
{ "type": "fix", "text": "git stack volume binds resolved to wrong host path when compose was in a subdirectory (#1139)" },
|
||||
{ "type": "fix", "text": "git stacks: subdir compose files now find their adjacent env files (#1136)" },
|
||||
{ "type": "feature", "text": "env editor doesn't flag Docker/Compose built-in variables as unused (#141)" },
|
||||
{ "type": "feature", "text": "container network mode: share another container's network namespace (#161)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.32"
|
||||
},
|
||||
{
|
||||
"version": "1.0.31",
|
||||
"date": "2026-05-30",
|
||||
|
||||
@@ -137,6 +137,14 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
|
||||
}
|
||||
}
|
||||
|
||||
let dummyAuthHashCache: Promise<string> | null = null;
|
||||
export function getDummyAuthHash(): Promise<string> {
|
||||
if (!dummyAuthHashCache) {
|
||||
dummyAuthHashCache = hashPassword(`dummy-${Math.random()}-${Date.now()}`);
|
||||
}
|
||||
return dummyAuthHashCache;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Session Management
|
||||
// ============================================
|
||||
@@ -241,11 +249,22 @@ function getSessionIdFromCookies(cookies: Cookies): string | null {
|
||||
export async function validateSession(cookies: Cookies): Promise<AuthenticatedUser | null> {
|
||||
const sessionId = getSessionIdFromCookies(cookies);
|
||||
if (!sessionId) return null;
|
||||
return validateSessionById(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a session by raw session ID (without the SvelteKit Cookies object).
|
||||
*
|
||||
* Used by WebSocket upgrade handlers in server.js / vite.config.ts that only
|
||||
* have a raw Cookie header string. Mirrors validateSession() semantics:
|
||||
* returns the AuthenticatedUser on success, null on missing/expired/disabled.
|
||||
*/
|
||||
export async function validateSessionById(sessionId: string): Promise<AuthenticatedUser | null> {
|
||||
if (!sessionId) return null;
|
||||
|
||||
const session = await dbGetSession(sessionId);
|
||||
if (!session) return null;
|
||||
|
||||
// Check if session is expired
|
||||
const expiresAt = new Date(session.expiresAt);
|
||||
if (expiresAt < new Date()) {
|
||||
await dbDeleteSession(sessionId);
|
||||
@@ -258,6 +277,13 @@ export async function validateSession(cookies: Cookies): Promise<AuthenticatedUs
|
||||
return await buildAuthenticatedUser(user, session.provider as 'local' | 'ldap' | 'oidc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie name used for browser session auth. Exported so raw header parsers
|
||||
* (WebSocket upgrade handlers) can look it up without re-encoding the
|
||||
* constant.
|
||||
*/
|
||||
export const SESSION_COOKIE = SESSION_COOKIE_NAME;
|
||||
|
||||
/**
|
||||
* Destroy a session (logout)
|
||||
*/
|
||||
@@ -461,13 +487,14 @@ export async function authenticateLocal(
|
||||
const user = await getUserByUsername(username);
|
||||
|
||||
if (!user) {
|
||||
// Use constant time to prevent timing attacks
|
||||
await hashPassword('dummy');
|
||||
await verifyPassword(password, await getDummyAuthHash());
|
||||
return { success: false, error: 'Invalid username or password' };
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
return { success: false, error: 'Account is disabled' };
|
||||
await verifyPassword(password, await getDummyAuthHash());
|
||||
console.warn(`[Auth] Login attempt for disabled account: user=${username}`);
|
||||
return { success: false, error: 'Invalid username or password' };
|
||||
}
|
||||
|
||||
const validPassword = await verifyPassword(password, user.passwordHash);
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Merge container env vars with new image env vars during auto-update.
|
||||
* Image-baked vars get updated to the new image's values.
|
||||
* User-set vars (not present in old image) are preserved.
|
||||
* Env vars removed from the new image are dropped.
|
||||
*/
|
||||
export function mergeImageEnvVars(
|
||||
containerEnv: string[],
|
||||
oldImageEnv: string[],
|
||||
newImageEnv: string[]
|
||||
): string[] {
|
||||
const getKey = (entry: string) => entry.split('=')[0];
|
||||
const oldImageKeys = new Set(oldImageEnv.map(getKey));
|
||||
|
||||
const merged: string[] = [];
|
||||
|
||||
// Keep user-set env vars (key not present in old image)
|
||||
for (const entry of containerEnv) {
|
||||
if (!oldImageKeys.has(getKey(entry))) {
|
||||
merged.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Add all new image env vars (updates changed values, adds new ones)
|
||||
for (const entry of newImageEnv) {
|
||||
const key = getKey(entry);
|
||||
// Skip if user already set this key (user wins)
|
||||
if (!merged.some(e => getKey(e) === key)) {
|
||||
merged.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Helpers for surfacing env/label divergence between a running
|
||||
* container and its image. Pure read-only — never used to mutate
|
||||
* the container; used only to power UI hints.
|
||||
*
|
||||
* Background: as of #1135 / commit 0f989bd7 revert, Dockhand no
|
||||
* longer "merges" image-baked env or labels into a container during
|
||||
* auto-update. The container's Config.Env and Config.Labels are
|
||||
* preserved verbatim (so a user's runtime `-e` / `-l` override is
|
||||
* never silently wiped). The trade-off, originally raised by #1061,
|
||||
* is that an image's updated default env/label values do not
|
||||
* automatically propagate to running containers.
|
||||
*
|
||||
* These helpers let the UI surface "this container's value differs
|
||||
* from the image's current value" so users can decide whether to
|
||||
* Remove & Deploy. We do NOT try to classify "user-set vs
|
||||
* image-baked" — that information isn't recoverable from Docker.
|
||||
*/
|
||||
|
||||
/** Parse a Docker env list (`KEY=value` strings) into a Map. */
|
||||
function parseEnv(entries: string[]): Map<string, string> {
|
||||
const m = new Map<string, string>();
|
||||
for (const e of entries) {
|
||||
const i = e.indexOf('=');
|
||||
if (i === -1) {
|
||||
m.set(e, '');
|
||||
} else {
|
||||
m.set(e.slice(0, i), e.slice(i + 1));
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys where the container's env value differs from the image's
|
||||
* CURRENT env value. Keys present in only one side are excluded —
|
||||
* they're either user-only or image-only, neither of which is
|
||||
* "divergence" we can usefully act on.
|
||||
*/
|
||||
export function detectImageEnvDivergence(
|
||||
containerEnv: string[],
|
||||
imageEnv: string[]
|
||||
): string[] {
|
||||
const cont = parseEnv(containerEnv);
|
||||
const img = parseEnv(imageEnv);
|
||||
const diff: string[] = [];
|
||||
for (const [k, v] of cont) {
|
||||
if (img.has(k) && img.get(k) !== v) {
|
||||
diff.push(k);
|
||||
}
|
||||
}
|
||||
return diff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys where the container's label value differs from the image's
|
||||
* CURRENT label value. Same semantics as detectImageEnvDivergence.
|
||||
*/
|
||||
export function detectImageLabelDivergence(
|
||||
containerLabels: Record<string, string> | null | undefined,
|
||||
imageLabels: Record<string, string> | null | undefined
|
||||
): string[] {
|
||||
const cont = containerLabels || {};
|
||||
const img = imageLabels || {};
|
||||
const diff: string[] = [];
|
||||
for (const [k, v] of Object.entries(cont)) {
|
||||
if (k in img && img[k] !== v) {
|
||||
diff.push(k);
|
||||
}
|
||||
}
|
||||
return diff;
|
||||
}
|
||||
@@ -388,15 +388,17 @@ export async function getUserThemePreferences(userId: number): Promise<{
|
||||
gridFontSize: string;
|
||||
terminalFont: string;
|
||||
editorFont: string;
|
||||
animateIcons: boolean;
|
||||
}> {
|
||||
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont] = await Promise.all([
|
||||
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, animateIcons] = await Promise.all([
|
||||
getUserSetting(userId, 'light_theme'),
|
||||
getUserSetting(userId, 'dark_theme'),
|
||||
getUserSetting(userId, 'font'),
|
||||
getUserSetting(userId, 'font_size'),
|
||||
getUserSetting(userId, 'grid_font_size'),
|
||||
getUserSetting(userId, 'terminal_font'),
|
||||
getUserSetting(userId, 'editor_font')
|
||||
getUserSetting(userId, 'editor_font'),
|
||||
getUserSetting(userId, 'animate_icons')
|
||||
]);
|
||||
return {
|
||||
lightTheme: lightTheme || 'default',
|
||||
@@ -405,13 +407,15 @@ export async function getUserThemePreferences(userId: number): Promise<{
|
||||
fontSize: fontSize || 'normal',
|
||||
gridFontSize: gridFontSize || 'normal',
|
||||
terminalFont: terminalFont || 'system-mono',
|
||||
editorFont: editorFont || 'system-mono'
|
||||
editorFont: editorFont || 'system-mono',
|
||||
// Default ON — only false when explicitly stored
|
||||
animateIcons: animateIcons === 'false' ? false : true
|
||||
};
|
||||
}
|
||||
|
||||
export async function setUserThemePreferences(
|
||||
userId: number,
|
||||
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string }
|
||||
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean }
|
||||
): Promise<void> {
|
||||
const updates: Promise<void>[] = [];
|
||||
if (prefs.lightTheme !== undefined) {
|
||||
@@ -435,6 +439,9 @@ export async function setUserThemePreferences(
|
||||
if (prefs.editorFont !== undefined) {
|
||||
updates.push(setUserSetting(userId, 'editor_font', prefs.editorFont));
|
||||
}
|
||||
if (prefs.animateIcons !== undefined) {
|
||||
updates.push(setUserSetting(userId, 'animate_icons', prefs.animateIcons ? 'true' : 'false'));
|
||||
}
|
||||
await Promise.all(updates);
|
||||
}
|
||||
|
||||
@@ -2097,6 +2104,7 @@ export interface GitStackData {
|
||||
lastCommit: string | null;
|
||||
syncStatus: GitSyncStatus;
|
||||
syncError: string | null;
|
||||
syncedFiles?: string | null; // JSON manifest { commit, files: { relPath: sha256 } } from last successful deploy
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -2303,6 +2311,7 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
|
||||
lastCommit: gitStacks.lastCommit,
|
||||
syncStatus: gitStacks.syncStatus,
|
||||
syncError: gitStacks.syncError,
|
||||
syncedFiles: gitStacks.syncedFiles,
|
||||
createdAt: gitStacks.createdAt,
|
||||
updatedAt: gitStacks.updatedAt,
|
||||
repoName: gitRepositories.name,
|
||||
@@ -2337,6 +2346,7 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
|
||||
lastCommit: row.lastCommit,
|
||||
syncStatus: row.syncStatus,
|
||||
syncError: row.syncError,
|
||||
syncedFiles: row.syncedFiles ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
repository: {
|
||||
@@ -2548,6 +2558,7 @@ export async function updateGitStack(id: number, data: Partial<GitStackData>): P
|
||||
if (data.lastCommit !== undefined) updateData.lastCommit = data.lastCommit;
|
||||
if (data.syncStatus !== undefined) updateData.syncStatus = data.syncStatus;
|
||||
if (data.syncError !== undefined) updateData.syncError = data.syncError;
|
||||
if (data.syncedFiles !== undefined) updateData.syncedFiles = data.syncedFiles;
|
||||
|
||||
await db.update(gitStacks).set(updateData).where(eq(gitStacks.id, id));
|
||||
return getGitStack(id);
|
||||
@@ -3592,9 +3603,15 @@ export async function getContainerEventActions(): Promise<string[]> {
|
||||
|
||||
export async function deleteOldContainerEvents(keepDays = 30): Promise<number> {
|
||||
const cutoffDate = new Date(Date.now() - keepDays * 24 * 60 * 60 * 1000).toISOString();
|
||||
await db.delete(containerEvents)
|
||||
const countResult = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(containerEvents)
|
||||
.where(sql`timestamp < ${cutoffDate}`);
|
||||
return 0;
|
||||
const count = Number(countResult[0]?.count ?? 0);
|
||||
if (count > 0) {
|
||||
await db.delete(containerEvents)
|
||||
.where(sql`timestamp < ${cutoffDate}`);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4082,9 +4099,15 @@ export async function getRecentExecutionsForSchedule(
|
||||
|
||||
export async function cleanupOldExecutions(retentionDays: number): Promise<number> {
|
||||
const cutoffDate = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
|
||||
const result = await db.delete(scheduleExecutions)
|
||||
const countResult = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(scheduleExecutions)
|
||||
.where(sql`triggered_at < ${cutoffDate}`);
|
||||
return 0; // SQLite/PG don't return count consistently
|
||||
const count = Number(countResult[0]?.count ?? 0);
|
||||
if (count > 0) {
|
||||
await db.delete(scheduleExecutions)
|
||||
.where(sql`triggered_at < ${cutoffDate}`);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// Settings helpers for retention
|
||||
@@ -4311,6 +4334,17 @@ export async function setExternalStackPaths(paths: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently add a directory to the external stack paths allowlist.
|
||||
* Returns true if the path was newly added (false if already present).
|
||||
*/
|
||||
export async function addExternalStackPath(dir: string): Promise<boolean> {
|
||||
const current = await getExternalStackPaths();
|
||||
if (current.includes(dir)) return false;
|
||||
await setExternalStackPaths([...current, dir]);
|
||||
return true;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PRIMARY STACK LOCATION
|
||||
// =============================================================================
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -288,7 +288,7 @@ export const gitRepositories = sqliteTable('git_repositories', {
|
||||
url: text('url').notNull(),
|
||||
branch: text('branch').default('main'),
|
||||
credentialId: integer('credential_id').references(() => gitCredentials.id, { onDelete: 'set null' }),
|
||||
composePath: text('compose_path').default('compose.yaml'),
|
||||
composePath: text('compose_path').default('docker-compose.yml'), // Reverted to original value (#1110)
|
||||
environmentId: integer('environment_id'),
|
||||
autoUpdate: integer('auto_update', { mode: 'boolean' }).default(false),
|
||||
autoUpdateSchedule: text('auto_update_schedule').default('daily'),
|
||||
@@ -308,7 +308,7 @@ export const gitStacks = sqliteTable('git_stacks', {
|
||||
stackName: text('stack_name').notNull(),
|
||||
environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }),
|
||||
repositoryId: integer('repository_id').notNull().references(() => gitRepositories.id, { onDelete: 'cascade' }),
|
||||
composePath: text('compose_path').default('compose.yaml'),
|
||||
composePath: text('compose_path').default('docker-compose.yml'), // Reverted to original value (#1110)
|
||||
envFilePath: text('env_file_path'), // Path to .env file in repository (e.g., ".env", "config/.env.prod")
|
||||
autoUpdate: integer('auto_update', { mode: 'boolean' }).default(false),
|
||||
autoUpdateSchedule: text('auto_update_schedule').default('daily'),
|
||||
@@ -324,6 +324,7 @@ export const gitStacks = sqliteTable('git_stacks', {
|
||||
lastCommit: text('last_commit'),
|
||||
syncStatus: text('sync_status').default('pending'),
|
||||
syncError: text('sync_error'),
|
||||
syncedFiles: text('synced_files'), // JSON manifest { relativePath: sha256hex } of files written on last sync
|
||||
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
|
||||
}, (table) => ({
|
||||
@@ -504,6 +505,19 @@ export const userPreferences = sqliteTable('user_preferences', {
|
||||
unique().on(table.userId, table.environmentId, table.key)
|
||||
]);
|
||||
|
||||
// Template sources
|
||||
export const templateSources = sqliteTable('template_sources', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
sourceId: text('source_id').notNull().unique(), // stable identifier (e.g., 'portainer-lissy93')
|
||||
name: text('name').notNull(),
|
||||
url: text('url').notNull(),
|
||||
enabled: integer('enabled', { mode: 'boolean' }).default(true),
|
||||
builtin: integer('builtin', { mode: 'boolean' }).default(false),
|
||||
sortOrder: integer('sort_order').default(0),
|
||||
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// TYPE EXPORTS
|
||||
// =============================================================================
|
||||
|
||||
@@ -291,7 +291,7 @@ export const gitRepositories = pgTable('git_repositories', {
|
||||
url: text('url').notNull(),
|
||||
branch: text('branch').default('main'),
|
||||
credentialId: integer('credential_id').references(() => gitCredentials.id, { onDelete: 'set null' }),
|
||||
composePath: text('compose_path').default('compose.yaml'),
|
||||
composePath: text('compose_path').default('docker-compose.yml'), // Reverted to original value (#1110)
|
||||
environmentId: integer('environment_id'),
|
||||
autoUpdate: boolean('auto_update').default(false),
|
||||
autoUpdateSchedule: text('auto_update_schedule').default('daily'),
|
||||
@@ -311,7 +311,7 @@ export const gitStacks = pgTable('git_stacks', {
|
||||
stackName: text('stack_name').notNull(),
|
||||
environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }),
|
||||
repositoryId: integer('repository_id').notNull().references(() => gitRepositories.id, { onDelete: 'cascade' }),
|
||||
composePath: text('compose_path').default('compose.yaml'),
|
||||
composePath: text('compose_path').default('docker-compose.yml'), // Reverted to original value (#1110)
|
||||
envFilePath: text('env_file_path'), // Path to .env file in repository (e.g., ".env", "config/.env.prod")
|
||||
autoUpdate: boolean('auto_update').default(false),
|
||||
autoUpdateSchedule: text('auto_update_schedule').default('daily'),
|
||||
@@ -327,6 +327,7 @@ export const gitStacks = pgTable('git_stacks', {
|
||||
lastCommit: text('last_commit'),
|
||||
syncStatus: text('sync_status').default('pending'),
|
||||
syncError: text('sync_error'),
|
||||
syncedFiles: text('synced_files'), // JSON manifest { relativePath: sha256hex } of files written on last sync
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
|
||||
}, (table) => ({
|
||||
@@ -506,3 +507,16 @@ export const userPreferences = pgTable('user_preferences', {
|
||||
}, (table) => [
|
||||
unique().on(table.userId, table.environmentId, table.key)
|
||||
]);
|
||||
|
||||
// Template sources
|
||||
export const templateSources = pgTable('template_sources', {
|
||||
id: serial('id').primaryKey(),
|
||||
sourceId: text('source_id').notNull().unique(), // stable identifier (e.g., 'portainer-lissy93')
|
||||
name: text('name').notNull(),
|
||||
url: text('url').notNull(),
|
||||
enabled: boolean('enabled').default(true),
|
||||
builtin: boolean('builtin').default(false),
|
||||
sortOrder: integer('sort_order').default(0),
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os';
|
||||
import { mergeImageEnvVars } from './container-env-merge';
|
||||
import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import * as http from 'node:http';
|
||||
@@ -16,6 +15,7 @@ import { createHash } from 'node:crypto';
|
||||
import type { Environment } from './db';
|
||||
import { getStackEnvVarsAsRecord } from './db';
|
||||
import { getAdditionalVolumeBinds } from './mount-dedupe';
|
||||
import { encodeRegistryAuth } from './registry-auth';
|
||||
import { isSystemContainer } from './scheduler/tasks/update-utils';
|
||||
import { deepDiff } from '../utils/diff.js';
|
||||
|
||||
@@ -287,7 +287,7 @@ const envCache = new Map<number, CachedEnv>();
|
||||
const CACHE_TTL = 30 * 60 * 1000;
|
||||
|
||||
// All known Docker Hub hostname variations for credential matching
|
||||
const DOCKER_HUB_HOSTS = new Set([
|
||||
export const DOCKER_HUB_HOSTS = new Set([
|
||||
'docker.io', 'hub.docker.com', 'registry.hub.docker.com',
|
||||
'index.docker.io', 'registry-1.docker.io', 'registry.docker.io', 'docker.com'
|
||||
]);
|
||||
@@ -1216,6 +1216,61 @@ export async function renameContainer(id: string, newName: string, envId?: numbe
|
||||
await assertDockerResponse(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* In-place container property update. Wraps Docker's POST /containers/{id}/update,
|
||||
* which is the only API that changes container properties WITHOUT recreating it.
|
||||
*
|
||||
* The accepted body field set is fixed by Docker — anything outside this set
|
||||
* requires container recreation (image, env, ports, networks, mounts, etc).
|
||||
* Whitelist enforced here so callers can't accidentally smuggle a
|
||||
* recreate-only field through this code path.
|
||||
*/
|
||||
export const IN_PLACE_UPDATE_FIELDS = [
|
||||
// Restart policy — the headline use case (#1153)
|
||||
'RestartPolicy',
|
||||
// CPU
|
||||
'CpuShares', 'CpuPeriod', 'CpuQuota', 'CpuRealtimePeriod', 'CpuRealtimeRuntime',
|
||||
'CpusetCpus', 'CpusetMems', 'NanoCpus',
|
||||
// Memory
|
||||
'Memory', 'MemorySwap', 'MemoryReservation', 'MemorySwappiness', 'KernelMemory',
|
||||
// Block I/O
|
||||
'BlkioWeight', 'BlkioWeightDevice',
|
||||
'BlkioDeviceReadBps', 'BlkioDeviceWriteBps',
|
||||
'BlkioDeviceReadIOps', 'BlkioDeviceWriteIOps',
|
||||
// Misc
|
||||
'PidsLimit'
|
||||
] as const;
|
||||
export type InPlaceUpdateField = typeof IN_PLACE_UPDATE_FIELDS[number];
|
||||
|
||||
export interface UpdateContainerRuntimeResult {
|
||||
Warnings: string[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an in-place update. `updates` keys MUST be from IN_PLACE_UPDATE_FIELDS;
|
||||
* unknown keys are silently dropped (not passed to Docker) so a malicious or
|
||||
* confused caller can't sneak a recreate-only field through.
|
||||
*/
|
||||
export async function updateContainerRuntime(
|
||||
id: string,
|
||||
updates: Partial<Record<InPlaceUpdateField, unknown>>,
|
||||
envId?: number | null
|
||||
): Promise<UpdateContainerRuntimeResult> {
|
||||
const allowed = new Set<string>(IN_PLACE_UPDATE_FIELDS);
|
||||
const body: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (allowed.has(key) && value !== undefined) body[key] = value;
|
||||
}
|
||||
if (Object.keys(body).length === 0) {
|
||||
throw new Error('No updatable fields provided');
|
||||
}
|
||||
return dockerJsonRequest<UpdateContainerRuntimeResult>(
|
||||
`/containers/${id}/update`,
|
||||
{ method: 'POST', body: JSON.stringify(body) },
|
||||
envId
|
||||
);
|
||||
}
|
||||
|
||||
export async function getContainerLogs(id: string, tail: number | 'all' = 100, envId?: number | null, since?: string, until?: string): Promise<string> {
|
||||
// Check if container has TTY enabled
|
||||
const info = await inspectContainer(id, envId);
|
||||
@@ -1299,6 +1354,11 @@ export interface CreateContainerOptions {
|
||||
restartPolicy?: string;
|
||||
restartMaxRetries?: number;
|
||||
networkMode?: string;
|
||||
/** Additional networks attached after creation via POST /networks/<name>/connect.
|
||||
* Must NOT include the primary (which lives in networkMode + EndpointsConfig). */
|
||||
additionalNetworks?: string[];
|
||||
/** @deprecated use networkMode + additionalNetworks. Kept only so old callers
|
||||
* (auto-update inspect path) keep working — see backward-compat block below. */
|
||||
networks?: string[];
|
||||
/** Network aliases for the primary network */
|
||||
networkAliases?: string[];
|
||||
@@ -1454,92 +1514,67 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
||||
containerConfig.Volumes = options.volumes;
|
||||
}
|
||||
|
||||
if (options.networkMode) {
|
||||
containerConfig.HostConfig.NetworkMode = options.networkMode;
|
||||
|
||||
// Build endpoint config for primary network with aliases, static IP, and gateway priority
|
||||
const hasNetworkConfig = options.networkAliases?.length || options.networkIpv4Address || options.networkIpv6Address || options.networkGwPriority !== undefined;
|
||||
if (hasNetworkConfig) {
|
||||
const endpointConfig: any = {};
|
||||
|
||||
if (options.networkAliases && options.networkAliases.length > 0) {
|
||||
endpointConfig.Aliases = options.networkAliases;
|
||||
}
|
||||
|
||||
if (options.networkIpv4Address || options.networkIpv6Address) {
|
||||
endpointConfig.IPAMConfig = {};
|
||||
if (options.networkIpv4Address) {
|
||||
endpointConfig.IPAMConfig.IPv4Address = options.networkIpv4Address;
|
||||
}
|
||||
if (options.networkIpv6Address) {
|
||||
endpointConfig.IPAMConfig.IPv6Address = options.networkIpv6Address;
|
||||
}
|
||||
}
|
||||
|
||||
// Gateway priority (Docker Engine 28+)
|
||||
if (options.networkGwPriority !== undefined) {
|
||||
endpointConfig.GwPriority = options.networkGwPriority;
|
||||
}
|
||||
|
||||
containerConfig.NetworkingConfig = {
|
||||
EndpointsConfig: {
|
||||
[options.networkMode]: endpointConfig
|
||||
}
|
||||
};
|
||||
}
|
||||
// Backward-compat: callers (and the auto-update inspect path) may still pass
|
||||
// the legacy "networks" array which conflated primary + extras. Normalize to
|
||||
// the new model: anything that isn't the primary becomes an additional network.
|
||||
if (options.networks && !options.additionalNetworks) {
|
||||
const primary = options.networkMode || '';
|
||||
options.additionalNetworks = options.networks.filter(n => n !== primary);
|
||||
}
|
||||
|
||||
if (options.networks && options.networks.length > 0) {
|
||||
containerConfig.HostConfig.NetworkMode = options.networks[0];
|
||||
// Networking model:
|
||||
// - networkMode is the SINGLE source of truth for the primary network
|
||||
// - EndpointsConfig has exactly ONE entry, keyed by networkMode, carrying
|
||||
// the form's IPv4/IPv6/aliases/gwPriority
|
||||
// - additionalNetworks (extras only — NEVER includes the primary) are
|
||||
// attached after container creation via POST /networks/{name}/connect
|
||||
// - Shared modes (host / none / container:X / service:X) skip both
|
||||
// EndpointsConfig and additionalNetworks: Docker rejects them.
|
||||
const primaryMode = options.networkMode || '';
|
||||
const isSharedMode = primaryMode === 'host' || primaryMode === 'none' || primaryMode.startsWith('container:') || primaryMode.startsWith('service:');
|
||||
|
||||
// Build endpoint configs for all networks
|
||||
const endpointsConfig: Record<string, any> = {};
|
||||
if (primaryMode) {
|
||||
containerConfig.HostConfig.NetworkMode = primaryMode;
|
||||
}
|
||||
|
||||
for (const network of options.networks) {
|
||||
const isFirstNetwork = network === options.networks[0];
|
||||
const netCfg = options.networkConfigs?.[network];
|
||||
// Build the single EndpointsConfig entry for the primary (only for non-shared modes).
|
||||
if (primaryMode && !isSharedMode) {
|
||||
const endpointConfig: any = {};
|
||||
if (options.networkAliases?.length) {
|
||||
endpointConfig.Aliases = options.networkAliases;
|
||||
}
|
||||
if (options.networkIpv4Address || options.networkIpv6Address) {
|
||||
endpointConfig.IPAMConfig = {};
|
||||
if (options.networkIpv4Address) endpointConfig.IPAMConfig.IPv4Address = options.networkIpv4Address;
|
||||
if (options.networkIpv6Address) endpointConfig.IPAMConfig.IPv6Address = options.networkIpv6Address;
|
||||
}
|
||||
if (options.networkGwPriority !== undefined) {
|
||||
endpointConfig.GwPriority = options.networkGwPriority;
|
||||
}
|
||||
containerConfig.NetworkingConfig = {
|
||||
EndpointsConfig: {
|
||||
[primaryMode]: endpointConfig
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Extras attached after creation. Empty array for shared modes.
|
||||
const extraNetworks: { name: string; config: any }[] = [];
|
||||
if (!isSharedMode && options.additionalNetworks?.length) {
|
||||
for (const netName of options.additionalNetworks) {
|
||||
if (netName === primaryMode) continue; // primary lives in EndpointsConfig, not here
|
||||
const netCfg = options.networkConfigs?.[netName];
|
||||
const endpointConfig: any = {};
|
||||
|
||||
// Per-network config from networkConfigs (takes precedence)
|
||||
if (netCfg) {
|
||||
if (netCfg.aliases && netCfg.aliases.length > 0) {
|
||||
endpointConfig.Aliases = netCfg.aliases;
|
||||
}
|
||||
if (netCfg.aliases?.length) endpointConfig.Aliases = netCfg.aliases;
|
||||
if (netCfg.ipv4Address || netCfg.ipv6Address) {
|
||||
endpointConfig.IPAMConfig = {};
|
||||
if (netCfg.ipv4Address) {
|
||||
endpointConfig.IPAMConfig.IPv4Address = netCfg.ipv4Address;
|
||||
}
|
||||
if (netCfg.ipv6Address) {
|
||||
endpointConfig.IPAMConfig.IPv6Address = netCfg.ipv6Address;
|
||||
}
|
||||
}
|
||||
} else if (isFirstNetwork) {
|
||||
// Backward compat: apply flat fields to first network if no networkConfigs
|
||||
if (options.networkAliases && options.networkAliases.length > 0) {
|
||||
endpointConfig.Aliases = options.networkAliases;
|
||||
}
|
||||
if (options.networkIpv4Address || options.networkIpv6Address) {
|
||||
endpointConfig.IPAMConfig = {};
|
||||
if (options.networkIpv4Address) {
|
||||
endpointConfig.IPAMConfig.IPv4Address = options.networkIpv4Address;
|
||||
}
|
||||
if (options.networkIpv6Address) {
|
||||
endpointConfig.IPAMConfig.IPv6Address = options.networkIpv6Address;
|
||||
}
|
||||
}
|
||||
// Gateway priority (Docker Engine 28+)
|
||||
if (options.networkGwPriority !== undefined) {
|
||||
endpointConfig.GwPriority = options.networkGwPriority;
|
||||
if (netCfg.ipv4Address) endpointConfig.IPAMConfig.IPv4Address = netCfg.ipv4Address;
|
||||
if (netCfg.ipv6Address) endpointConfig.IPAMConfig.IPv6Address = netCfg.ipv6Address;
|
||||
}
|
||||
}
|
||||
|
||||
endpointsConfig[network] = endpointConfig;
|
||||
extraNetworks.push({ name: netName, config: endpointConfig });
|
||||
}
|
||||
|
||||
containerConfig.NetworkingConfig = {
|
||||
EndpointsConfig: endpointsConfig
|
||||
};
|
||||
}
|
||||
|
||||
if (options.privileged !== undefined) {
|
||||
@@ -1772,6 +1807,39 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
||||
containerConfig.Domainname = options.domainname;
|
||||
}
|
||||
|
||||
// Shared network modes (host / container:X / service:X) inherit the
|
||||
// namespace's networking, so Docker rejects any field that would override it.
|
||||
// Strip them defensively — the merge in updateContainer carries values from
|
||||
// the old config that may not be valid for the new mode (e.g. switching from
|
||||
// bridge → container:foo with a leftover Hostname).
|
||||
// Mirrors the same logic in recreateContainerFromInspect.
|
||||
{
|
||||
const primary = containerConfig.HostConfig?.NetworkMode || '';
|
||||
const isHost = primary === 'host';
|
||||
const isContainer = primary.startsWith('container:');
|
||||
const isService = primary.startsWith('service:');
|
||||
if (isHost || isContainer || isService) {
|
||||
delete containerConfig.Hostname;
|
||||
delete containerConfig.Domainname;
|
||||
delete containerConfig.MacAddress;
|
||||
}
|
||||
if (isContainer || isService) {
|
||||
// container:X also shares ports / DNS / hosts with the target
|
||||
delete containerConfig.ExposedPorts;
|
||||
if (containerConfig.HostConfig) {
|
||||
delete containerConfig.HostConfig.PortBindings;
|
||||
delete containerConfig.HostConfig.PublishAllPorts;
|
||||
delete containerConfig.HostConfig.Dns;
|
||||
delete containerConfig.HostConfig.DnsOptions;
|
||||
delete containerConfig.HostConfig.DnsSearch;
|
||||
delete containerConfig.HostConfig.ExtraHosts;
|
||||
delete containerConfig.HostConfig.Links;
|
||||
}
|
||||
// NetworkingConfig is meaningless when sharing a namespace
|
||||
delete containerConfig.NetworkingConfig;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await dockerJsonRequest<{ Id: string }>(
|
||||
`/containers/create?name=${encodeURIComponent(options.name)}`,
|
||||
{
|
||||
@@ -1781,6 +1849,18 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
||||
envId
|
||||
);
|
||||
|
||||
// Attach additional networks now that the container exists. Docker only allows
|
||||
// a single network at create time, so anything beyond the primary is connected here.
|
||||
for (const extra of extraNetworks) {
|
||||
try {
|
||||
await connectContainerToNetworkRaw(extra.name, result.Id, extra.config, envId);
|
||||
} catch (err: any) {
|
||||
console.error(`Failed to attach additional network "${extra.name}":`, err?.message || err);
|
||||
// Don't fail the whole create — primary network is already connected.
|
||||
// Caller (updateContainer rollback path) will surface this if needed.
|
||||
}
|
||||
}
|
||||
|
||||
return { id: result.Id, start: () => startContainer(result.Id, envId) };
|
||||
}
|
||||
|
||||
@@ -1901,50 +1981,6 @@ export async function recreateContainerFromInspect(
|
||||
HostConfig: hostConfig
|
||||
};
|
||||
|
||||
// 4a. Update image-embedded labels and env vars to match the new image.
|
||||
// Docker's create API uses exactly the labels/env you pass, ignoring the new image's
|
||||
// embedded values. We inspect both old and new images to distinguish image-origin
|
||||
// values from user-set values, then merge accordingly.
|
||||
try {
|
||||
const [oldImageInspect, newImageInspect] = await Promise.all([
|
||||
inspectImage(config.Image, envId),
|
||||
inspectImage(newImage, envId)
|
||||
]);
|
||||
|
||||
// Merge labels
|
||||
const oldImageLabels: Record<string, string> = (oldImageInspect as any)?.Config?.Labels || {};
|
||||
const newImageLabels: Record<string, string> = (newImageInspect as any)?.Config?.Labels || {};
|
||||
const containerLabels: Record<string, string> = createConfig.Labels || {};
|
||||
|
||||
const mergedLabels: Record<string, string> = {};
|
||||
|
||||
// Keep user-set labels (not present in old image)
|
||||
for (const [k, v] of Object.entries(containerLabels)) {
|
||||
if (!(k in oldImageLabels)) {
|
||||
mergedLabels[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
// Add all new image labels (overrides old image labels)
|
||||
for (const [k, v] of Object.entries(newImageLabels)) {
|
||||
mergedLabels[k] = v;
|
||||
}
|
||||
|
||||
createConfig.Labels = mergedLabels;
|
||||
log?.(`Updated image labels: ${Object.keys(newImageLabels).length} from new image, ${Object.keys(mergedLabels).length} total`);
|
||||
|
||||
// Merge env vars (same logic: image-baked vars get updated, user-set vars preserved)
|
||||
const oldImageEnv: string[] = (oldImageInspect as any)?.Config?.Env || [];
|
||||
const newImageEnv: string[] = (newImageInspect as any)?.Config?.Env || [];
|
||||
const containerEnv: string[] = createConfig.Env || [];
|
||||
|
||||
createConfig.Env = mergeImageEnvVars(containerEnv, oldImageEnv, newImageEnv);
|
||||
log?.(`Updated image env vars: ${newImageEnv.length} from new image, ${createConfig.Env.length} total`);
|
||||
} catch (e) {
|
||||
log?.(`Warning: could not update image labels/env: ${e}`);
|
||||
// Fall through with old values — non-fatal
|
||||
}
|
||||
|
||||
// Strip default MemorySwappiness — Podman + cgroupv2 rejects it.
|
||||
// Docker returns -1, Podman returns 0 when unset.
|
||||
const swappiness = createConfig.HostConfig?.MemorySwappiness;
|
||||
@@ -2424,20 +2460,44 @@ export async function updateContainer(id: string, options: Partial<CreateContain
|
||||
// Extract ALL existing container options
|
||||
const existingOptions = extractContainerOptions(oldContainerInfo);
|
||||
|
||||
// Merge user-provided options on top of existing options
|
||||
// User options take precedence, but we preserve everything not explicitly provided
|
||||
// Per-network fields (aliases, static IPs, MAC, gateway priority) are scoped to
|
||||
// a single network. When the user switches the primary network, the values
|
||||
// extracted from the old network must NOT follow the container — e.g. compose
|
||||
// service aliases from "anton" applied to the default bridge fail with
|
||||
// "invalid endpoint settings" because the default bridge doesn't accept aliases.
|
||||
if (options.networkMode && options.networkMode !== networkMode) {
|
||||
existingOptions.networkAliases = undefined;
|
||||
existingOptions.networkIpv4Address = undefined;
|
||||
existingOptions.networkIpv6Address = undefined;
|
||||
existingOptions.networkGwPriority = undefined;
|
||||
existingOptions.macAddress = undefined;
|
||||
}
|
||||
|
||||
// Merge user-provided options on top of existing options.
|
||||
// Semantics (#1119):
|
||||
// - key absent from `options` → preserve existingOptions[key]
|
||||
// - key present with explicit `null` → CLEAR the field (treat as undefined)
|
||||
// - key present with any other value → use the user's value
|
||||
//
|
||||
// Without the null-as-clear handling, clearing a Resources field in the UI
|
||||
// (which sends null) would merge over the existing value identically to a
|
||||
// preserved field — Memory limit, etc. would silently stick around.
|
||||
const userOptions: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(options)) {
|
||||
userOptions[k] = v === null ? undefined : v;
|
||||
}
|
||||
const mergedOptions: CreateContainerOptions = {
|
||||
...existingOptions,
|
||||
...options,
|
||||
...userOptions,
|
||||
// Replace labels, but preserve Docker internal labels (com.docker.*)
|
||||
labels: options.labels !== undefined
|
||||
labels: options.labels !== undefined && options.labels !== null
|
||||
? {
|
||||
...Object.fromEntries(
|
||||
Object.entries(existingOptions.labels || {}).filter(([k]) => k.startsWith('com.docker.'))
|
||||
),
|
||||
...options.labels
|
||||
}
|
||||
: existingOptions.labels
|
||||
: options.labels === null ? {} : existingOptions.labels
|
||||
};
|
||||
|
||||
// 1. Stop old container
|
||||
@@ -2549,6 +2609,28 @@ export async function listImages(envId?: number | null): Promise<ImageInfo[]> {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic log for registry auth headers (#1105).
|
||||
* Logs lengths and boundary char codes — never the plaintext credentials.
|
||||
* Header prefix is safe to log: it encodes the start of `{"username":"...`.
|
||||
*/
|
||||
function logAuthDiagnostics(
|
||||
tag: string,
|
||||
registry: string,
|
||||
serveraddress: string,
|
||||
username: string,
|
||||
password: string,
|
||||
authHeader: string
|
||||
): void {
|
||||
const userLast = username.length ? username.charCodeAt(username.length - 1).toString(16) : 'na';
|
||||
const pwLast = password.length ? password.charCodeAt(password.length - 1).toString(16) : 'na';
|
||||
console.log(
|
||||
`${tag} auth: registry=${registry} user(len=${username.length},last=0x${userLast}) ` +
|
||||
`pw(len=${password.length},last=0x${pwLast}) serveraddress=${serveraddress} ` +
|
||||
`authHeader(len=${authHeader.length},prefix=${authHeader.slice(0, 16)})`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build X-Registry-Auth header for authenticated Docker image pulls.
|
||||
* Looks up stored registry credentials and returns a headers object
|
||||
@@ -2562,16 +2644,19 @@ export async function buildRegistryAuthHeader(imageName: string): Promise<Record
|
||||
if (creds) {
|
||||
// Docker Engine requires 'https://index.docker.io/v1/' as serveraddress
|
||||
// for Docker Hub auth — just the hostname is treated as unauthenticated
|
||||
const serveraddress = DOCKER_HUB_HOSTS.has(registry)
|
||||
? 'https://index.docker.io/v1/'
|
||||
: registry;
|
||||
console.log(`[Pull] Using credentials for ${serveraddress} (user: ${creds.username})`);
|
||||
const isHub = DOCKER_HUB_HOSTS.has(registry);
|
||||
const serveraddress = isHub ? 'https://index.docker.io/v1/' : registry;
|
||||
if (isHub) {
|
||||
console.log(`[Registry] docker-hub variant '${registry}' canonicalized to https://index.docker.io/v1/ for auth`);
|
||||
}
|
||||
const authConfig = {
|
||||
username: creds.username,
|
||||
password: creds.password,
|
||||
serveraddress
|
||||
};
|
||||
headers['X-Registry-Auth'] = Buffer.from(JSON.stringify(authConfig)).toString('base64');
|
||||
const authHeader = encodeRegistryAuth(authConfig);
|
||||
headers['X-Registry-Auth'] = authHeader;
|
||||
logAuthDiagnostics('[Pull]', registry, serveraddress, creds.username, creds.password, authHeader);
|
||||
} else {
|
||||
console.log(`[Pull] No credentials found for ${registry}`);
|
||||
}
|
||||
@@ -2614,11 +2699,19 @@ export async function pullImage(imageName: string, onProgress?: (data: any) => v
|
||||
// Look up registry credentials for authenticated pulls
|
||||
const headers = await buildRegistryAuthHeader(imageName);
|
||||
|
||||
// Diagnostic logging (#1105): what we're sending to the daemon
|
||||
console.log(`[Pull] POST ${url} headers=${Object.keys(headers).join(',') || '(none)'}`);
|
||||
|
||||
// Use streaming: true for longer timeout on edge environments
|
||||
const response = await dockerFetch(url, { method: 'POST', streaming: true, headers }, envId);
|
||||
|
||||
// Diagnostic logging (#1105): daemon response status
|
||||
console.log(`[Pull] response status=${response.status} ${response.statusText}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to pull image: ${await response.text()}`);
|
||||
const body = await response.text();
|
||||
console.error(`[Pull] error body: ${body}`);
|
||||
throw new Error(`Failed to pull image: ${body}`);
|
||||
}
|
||||
|
||||
// Stream the response for progress updates
|
||||
@@ -2640,6 +2733,9 @@ export async function pullImage(imageName: string, onProgress?: (data: any) => v
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
if (data.error || data.errorDetail) {
|
||||
console.error(`[Pull] stream error: ${line}`);
|
||||
}
|
||||
if (onProgress) onProgress(data);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
@@ -2776,7 +2872,10 @@ async function findRegistryCredentials(registryHost: string): Promise<{ username
|
||||
if (stored.fullRegistry === requested.fullRegistry ||
|
||||
(stored.host === requested.host && !stored.path)) {
|
||||
if (reg.username && reg.password) {
|
||||
return { username: reg.username, password: reg.password };
|
||||
const via = stored.fullRegistry === requested.fullRegistry ? 'full' : 'host-only';
|
||||
console.log(`[Registry] matched stored=${reg.url} requested=${registryHost} via=${via}`);
|
||||
// Normalize legacy creds saved before #1105 trim fix
|
||||
return { username: reg.username.trim(), password: reg.password.trim() };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2788,12 +2887,20 @@ async function findRegistryCredentials(registryHost: string): Promise<{ username
|
||||
const stored = parseRegistryUrl(reg.url);
|
||||
if (DOCKER_HUB_HOSTS.has(stored.host)) {
|
||||
if (reg.username && reg.password) {
|
||||
return { username: reg.username, password: reg.password };
|
||||
console.log(`[Registry] matched stored=${reg.url} requested=${registryHost} via=hub-alias`);
|
||||
return { username: reg.username.trim(), password: reg.password.trim() };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No match — log what we tried so support cases are diagnosable
|
||||
const candidates = registries.map(r => parseRegistryUrl(r.url).host).join(', ');
|
||||
console.log(
|
||||
`[Registry] no match for requested=${registryHost} ` +
|
||||
`(hub-alias=${DOCKER_HUB_HOSTS.has(requested.host)}); ` +
|
||||
`candidates=[${candidates || 'none configured'}]`
|
||||
);
|
||||
return null;
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||
@@ -2871,10 +2978,14 @@ async function getRegistryBearerToken(registry: string, repo: string): Promise<s
|
||||
const service = serviceMatch ? serviceMatch[1] : '';
|
||||
const scope = `repository:${repo}:pull`;
|
||||
|
||||
// Step 3: Request token from realm (with credentials if available)
|
||||
// Step 3: Request token from realm (with credentials if available).
|
||||
// Empty scope is allowed — means "no specific resource permission".
|
||||
// Useful for credential validation: some registries (Docker Hub)
|
||||
// reject privileged scopes like registry:catalog:* even for valid
|
||||
// users, so omitting scope is the only reliable login check.
|
||||
const tokenUrl = new URL(realm);
|
||||
if (service) tokenUrl.searchParams.set('service', service);
|
||||
tokenUrl.searchParams.set('scope', scope);
|
||||
if (scope) tokenUrl.searchParams.set('scope', scope);
|
||||
|
||||
const tokenHeaders: Record<string, string> = { 'User-Agent': 'Dockhand/1.0' };
|
||||
|
||||
@@ -2988,10 +3099,14 @@ export async function getRegistryAuthHeader(
|
||||
const realm = realmMatch[1];
|
||||
const service = serviceMatch ? serviceMatch[1] : '';
|
||||
|
||||
// Step 3: Request token from realm (with credentials if available)
|
||||
// Step 3: Request token from realm (with credentials if available).
|
||||
// Empty scope is allowed — means "no specific resource permission".
|
||||
// Useful for credential validation: some registries (Docker Hub)
|
||||
// reject privileged scopes like registry:catalog:* even for valid
|
||||
// users, so omitting scope is the only reliable login check.
|
||||
const tokenUrl = new URL(realm);
|
||||
if (service) tokenUrl.searchParams.set('service', service);
|
||||
tokenUrl.searchParams.set('scope', scope);
|
||||
if (scope) tokenUrl.searchParams.set('scope', scope);
|
||||
|
||||
const tokenHeaders: Record<string, string> = { 'User-Agent': 'Dockhand/1.0' };
|
||||
|
||||
@@ -3053,6 +3168,250 @@ export async function getRegistryAuth(
|
||||
return { baseUrl, orgPath: parsed.path, authHeader };
|
||||
}
|
||||
|
||||
// --- Harbor fallback for catalog and image search ---
|
||||
// Harbor denies access to the V2 _catalog endpoint for robot accounts.
|
||||
// We detect Harbor and use the native project API as a fallback.
|
||||
|
||||
/** Harbor detection cache per host (TTL 5 min). Only definitive (non-error) detections are cached. */
|
||||
const harborDetectionCache = new Map<string, { isHarbor: boolean; ts: number }>();
|
||||
const HARBOR_CACHE_TTL = 5 * 60 * 1000;
|
||||
|
||||
export interface HarborCatalogResult {
|
||||
repositories: string[];
|
||||
/** Pagination cursor: "harbor:<page>" or null if last page */
|
||||
nextLast: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects whether a registry is a Harbor instance.
|
||||
* Checks for service="harbor-registry" in the WWW-Authenticate header from /v2/,
|
||||
* then confirms via /api/v2.0/ping. Result is cached for 5 min per host.
|
||||
*/
|
||||
export async function isHarborRegistry(registryUrl: string): Promise<boolean> {
|
||||
const parsed = parseRegistryUrl(registryUrl);
|
||||
const host = parsed.host;
|
||||
|
||||
const cached = harborDetectionCache.get(host);
|
||||
if (cached && Date.now() - cached.ts < HARBOR_CACHE_TTL) {
|
||||
return cached.isHarbor;
|
||||
}
|
||||
|
||||
// Respect the registry's configured scheme and port (parsed.host includes the port).
|
||||
const baseUrl = `${parsed.protocol}://${parsed.host}`;
|
||||
|
||||
let isHarbor = false;
|
||||
try {
|
||||
// Step 1: check the WWW-Authenticate header from /v2/
|
||||
const challengeResp = await fetch(`${baseUrl}/v2/`, {
|
||||
method: 'GET',
|
||||
headers: { 'User-Agent': 'Dockhand/1.0' }
|
||||
});
|
||||
const wwwAuth = challengeResp.headers.get('WWW-Authenticate') || '';
|
||||
if (wwwAuth.toLowerCase().includes('service="harbor-registry"')) {
|
||||
// Step 2: confirm via /api/v2.0/ping
|
||||
const pingResp = await fetch(`${baseUrl}/api/v2.0/ping`, {
|
||||
method: 'GET',
|
||||
headers: { 'User-Agent': 'Dockhand/1.0' }
|
||||
});
|
||||
if (pingResp.ok) {
|
||||
const body = await pingResp.text();
|
||||
if (body.includes('Pong')) {
|
||||
isHarbor = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A definitive answer was obtained (no network error): cache it.
|
||||
harborDetectionCache.set(host, { isHarbor, ts: Date.now() });
|
||||
} catch {
|
||||
// Network error: detection is indeterminate. Do NOT cache, so a transient
|
||||
// outage can't pin "not Harbor" for the whole TTL and keep returning 403s
|
||||
// once Harbor recovers.
|
||||
}
|
||||
|
||||
return isHarbor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Basic auth header for the Harbor API from a registry object.
|
||||
* Credentials are trimmed: pasted values often carry trailing whitespace that
|
||||
* silently breaks Basic auth.
|
||||
*/
|
||||
function getHarborBasicAuth(registry: { username?: string | null; password?: string | null }): string | null {
|
||||
const username = registry.username?.trim();
|
||||
const password = registry.password?.trim();
|
||||
if (username && password) {
|
||||
return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Builds the common headers (JSON + Basic auth) for Harbor API requests. */
|
||||
function harborHeaders(registry: { username?: string | null; password?: string | null }): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'Dockhand/1.0'
|
||||
};
|
||||
const authHeader = getHarborBasicAuth(registry);
|
||||
if (authHeader) headers['Authorization'] = authHeader;
|
||||
return headers;
|
||||
}
|
||||
|
||||
/** Removes Harbor query-grammar control characters so a search term can't break or alter the q filter. */
|
||||
function sanitizeHarborQueryTerm(term: string): string {
|
||||
// Harbor's query grammar uses , = ~ ( ) as control characters.
|
||||
return term.replace(/[,=~()]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Follows Harbor list-endpoint pagination, collecting every item's name.
|
||||
* Terminates on a short page or once X-Total-Count is reached; a safety cap
|
||||
* bounds pathological servers (a warning is logged if it is hit).
|
||||
*/
|
||||
async function harborPaginateNames(
|
||||
urlForPage: (page: number, pageSize: number) => string,
|
||||
headers: Record<string, string>,
|
||||
options: { throwOnFirstError: boolean; label: string }
|
||||
): Promise<string[]> {
|
||||
const names: string[] = [];
|
||||
const pageSize = 100;
|
||||
const maxPages = 100; // ~10k items: defensive bound against a server that never returns a short page
|
||||
let total = 0;
|
||||
let page = 1;
|
||||
while (page <= maxPages) {
|
||||
const resp = await fetch(urlForPage(page, pageSize), { headers });
|
||||
if (!resp.ok) {
|
||||
if (page === 1 && options.throwOnFirstError) {
|
||||
throw new Error(`Harbor API error ${resp.status} while ${options.label}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (page === 1) total = parseInt(resp.headers.get('X-Total-Count') || '0', 10);
|
||||
const items: Array<{ name: string }> = await resp.json();
|
||||
for (const item of items) names.push(item.name);
|
||||
if (items.length < pageSize) break;
|
||||
if (total > 0 && names.length >= total) break;
|
||||
page++;
|
||||
}
|
||||
if (page > maxPages) {
|
||||
console.warn(`[Harbor] Pagination safety cap (${maxPages} pages) reached while ${options.label}; the list may be truncated`);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Enumerates the names of all Harbor projects the account can access. */
|
||||
async function harborListAllProjects(baseUrl: string, headers: Record<string, string>): Promise<string[]> {
|
||||
return harborPaginateNames(
|
||||
(page, size) => `${baseUrl}/projects?page=${page}&page_size=${size}`,
|
||||
headers,
|
||||
{ throwOnFirstError: true, label: 'listing projects' }
|
||||
);
|
||||
}
|
||||
|
||||
/** Enumerates the names of all repositories within a single Harbor project. */
|
||||
async function harborListProjectRepositories(baseUrl: string, project: string, headers: Record<string, string>): Promise<string[]> {
|
||||
return harborPaginateNames(
|
||||
(page, size) => `${baseUrl}/projects/${encodeURIComponent(project)}/repositories?page=${page}&page_size=${size}`,
|
||||
headers,
|
||||
{ throwOnFirstError: false, label: `listing repositories of project ${project}` }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists repositories via the Harbor project API.
|
||||
* With orgPath set, paginates a single project natively (using X-Total-Count).
|
||||
* Without orgPath, enumerates every accessible project and all of their
|
||||
* repositories, then paginates the flattened list so the UI gets a correct
|
||||
* hasMore signal instead of a silently truncated first page.
|
||||
* @param page - page number (1-based)
|
||||
* @param pageSize - number of results per page
|
||||
*/
|
||||
export async function harborListRepositories(
|
||||
registry: { url: string; username?: string | null; password?: string | null },
|
||||
orgPath: string,
|
||||
page: number = 1,
|
||||
pageSize: number = 100
|
||||
): Promise<HarborCatalogResult> {
|
||||
const parsed = parseRegistryUrl(registry.url);
|
||||
const baseUrl = `${parsed.protocol}://${parsed.host}/api/v2.0`;
|
||||
const headers = harborHeaders(registry);
|
||||
|
||||
if (orgPath) {
|
||||
// Single project: native page-based pagination.
|
||||
const project = orgPath.replace(/^\//, '');
|
||||
const url = `${baseUrl}/projects/${encodeURIComponent(project)}/repositories?page=${page}&page_size=${pageSize}`;
|
||||
const resp = await fetch(url, { headers });
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Harbor API error ${resp.status} for project ${project}`);
|
||||
}
|
||||
const totalCount = parseInt(resp.headers.get('X-Total-Count') || '0', 10);
|
||||
const repos: Array<{ name: string }> = await resp.json();
|
||||
const repositories = repos.map(r => r.name);
|
||||
const hasMore = page * pageSize < totalCount;
|
||||
return { repositories, nextLast: hasMore ? `harbor:${page + 1}` : null };
|
||||
}
|
||||
|
||||
// No orgPath: enumerate all projects and all of their repositories, then
|
||||
// slice the flattened list for the requested page.
|
||||
const projects = await harborListAllProjects(baseUrl, headers);
|
||||
const all: string[] = [];
|
||||
for (const project of projects) {
|
||||
const repos = await harborListProjectRepositories(baseUrl, project, headers);
|
||||
for (const name of repos) all.push(name);
|
||||
}
|
||||
|
||||
const start = (page - 1) * pageSize;
|
||||
const repositories = all.slice(start, start + pageSize);
|
||||
const hasMore = start + pageSize < all.length;
|
||||
return { repositories, nextLast: hasMore ? `harbor:${page + 1}` : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches repositories via the Harbor API using filter q=name=~{term}.
|
||||
* Iterates through all accessible projects (or a single one if orgPath is set).
|
||||
* The term is sanitized for the Harbor query grammar; a client-side substring
|
||||
* check on the original term keeps the results precise.
|
||||
*/
|
||||
export async function harborSearchRepositories(
|
||||
registry: { url: string; username?: string | null; password?: string | null },
|
||||
term: string,
|
||||
orgPath: string,
|
||||
limit: number = 25
|
||||
): Promise<string[]> {
|
||||
const parsed = parseRegistryUrl(registry.url);
|
||||
const baseUrl = `${parsed.protocol}://${parsed.host}/api/v2.0`;
|
||||
const headers = harborHeaders(registry);
|
||||
|
||||
const termLower = term.toLowerCase();
|
||||
const safeTerm = sanitizeHarborQueryTerm(term);
|
||||
const results: string[] = [];
|
||||
|
||||
const projectNames = orgPath
|
||||
? [orgPath.replace(/^\//, '')]
|
||||
: await harborListAllProjects(baseUrl, headers);
|
||||
|
||||
// Search each project using the Harbor filter
|
||||
for (const project of projectNames) {
|
||||
if (results.length >= limit) break;
|
||||
|
||||
const q = encodeURIComponent(`name=~${safeTerm}`);
|
||||
const url = `${baseUrl}/projects/${encodeURIComponent(project)}/repositories?q=${q}&page=1&page_size=${limit}`;
|
||||
const resp = await fetch(url, { headers });
|
||||
if (!resp.ok) continue;
|
||||
|
||||
const repos: Array<{ name: string }> = await resp.json();
|
||||
for (const r of repos) {
|
||||
// The server filter ran on the sanitized term, so re-check the original
|
||||
// term client-side to guarantee precise substring matches.
|
||||
if (r.name.toLowerCase().includes(termLower)) {
|
||||
results.push(r.name);
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the registry for the current manifest digest of an image.
|
||||
* Simple HEAD request to get Docker-Content-Digest header.
|
||||
@@ -3136,6 +3495,17 @@ export async function checkImageUpdateAvailable(
|
||||
};
|
||||
}
|
||||
|
||||
// Skip update check for images tagged against a localhost registry.
|
||||
// These never resolve from inside Dockhand's container and only produce
|
||||
// ECONNREFUSED noise (see #1083).
|
||||
if (/^localhost(:\d+)?\//.test(imageName)) {
|
||||
return {
|
||||
hasUpdate: false,
|
||||
isLocalImage: true,
|
||||
currentDigest: currentImageId
|
||||
};
|
||||
}
|
||||
|
||||
// Get current image info to get RepoDigests
|
||||
let currentImageInfo: any;
|
||||
try {
|
||||
@@ -3466,7 +3836,11 @@ export async function listVolumes(envId?: number | null): Promise<VolumeInfo[]>
|
||||
scope: volume.Scope,
|
||||
created: volume.CreatedAt,
|
||||
labels: volume.Labels || {},
|
||||
usedBy: volumeUsageMap.get(volume.Name) || []
|
||||
usedBy: volumeUsageMap.get(volume.Name) || [],
|
||||
// Surface driver_opts (e.g. NFS/CIFS type+device+o) so the UI can
|
||||
// distinguish network-backed volumes from plain local ones. Docker's
|
||||
// /volumes endpoint already includes this — we were dropping it.
|
||||
options: volume.Options && Object.keys(volume.Options).length > 0 ? volume.Options : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -4454,11 +4828,21 @@ export async function pushImage(
|
||||
// Parse tag to get registry info
|
||||
const [repo, tag = 'latest'] = imageTag.split(':');
|
||||
|
||||
// Create X-Registry-Auth header
|
||||
const authHeader = Buffer.from(JSON.stringify(authConfig)).toString('base64');
|
||||
const authHeader = encodeRegistryAuth(authConfig);
|
||||
logAuthDiagnostics(
|
||||
'[Push]',
|
||||
authConfig.serveraddress,
|
||||
authConfig.serveraddress,
|
||||
authConfig.username ?? '',
|
||||
authConfig.password ?? '',
|
||||
authHeader
|
||||
);
|
||||
|
||||
const pushUrl = `/images/${encodeURIComponent(imageTag)}/push`;
|
||||
console.log(`[Push] POST ${pushUrl} headers=X-Registry-Auth`);
|
||||
|
||||
const response = await dockerFetch(
|
||||
`/images/${encodeURIComponent(imageTag)}/push`,
|
||||
pushUrl,
|
||||
{
|
||||
method: 'POST',
|
||||
streaming: true,
|
||||
@@ -4469,8 +4853,11 @@ export async function pushImage(
|
||||
envId
|
||||
);
|
||||
|
||||
console.log(`[Push] response status=${response.status} ${response.statusText}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
console.error(`[Push] error body: ${error}`);
|
||||
throw new Error(`Failed to push image: ${error}`);
|
||||
}
|
||||
|
||||
@@ -4494,6 +4881,7 @@ export async function pushImage(
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
if (data.error) {
|
||||
console.error(`[Push] stream error: ${line}`);
|
||||
throw new Error(data.error);
|
||||
}
|
||||
if (onProgress) onProgress(data);
|
||||
@@ -4698,18 +5086,20 @@ export async function listContainerDirectory(
|
||||
// Sanitize path to prevent command injection
|
||||
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
||||
|
||||
// Commands to try in order of preference
|
||||
// Commands to try in order of preference (includes /usr/sbin/ls for Wolfi/busybox images)
|
||||
const commands = useSimpleLs
|
||||
? [
|
||||
['ls', '-la', safePath],
|
||||
['/bin/ls', '-la', safePath],
|
||||
['/usr/bin/ls', '-la', safePath],
|
||||
['/usr/sbin/ls', '-la', safePath],
|
||||
]
|
||||
: [
|
||||
['ls', '-la', '--time-style=long-iso', safePath],
|
||||
['ls', '-la', safePath],
|
||||
['/bin/ls', '-la', safePath],
|
||||
['/usr/bin/ls', '-la', safePath],
|
||||
['/usr/sbin/ls', '-la', safePath],
|
||||
];
|
||||
|
||||
let lastError: Error | null = null;
|
||||
@@ -4792,7 +5182,7 @@ export async function statContainerPath(
|
||||
containerId: string,
|
||||
path: string,
|
||||
envId?: number | null
|
||||
): Promise<{ name: string; size: number; mode: number; mtime: string; linkTarget?: string }> {
|
||||
): Promise<{ name: string; size: number; mode: number; mtime: string; linkTarget?: string; isDir: boolean }> {
|
||||
// Sanitize path
|
||||
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
||||
|
||||
@@ -4814,7 +5204,10 @@ export async function statContainerPath(
|
||||
}
|
||||
|
||||
const statJson = Buffer.from(statHeader, 'base64').toString('utf-8');
|
||||
return JSON.parse(statJson);
|
||||
const stat = JSON.parse(statJson);
|
||||
// Go's os.FileMode encodes the file type in the high bits. ModeDir = 1<<31.
|
||||
// Docker emits mode in that format, so the directory bit lives at 0x80000000.
|
||||
return { ...stat, isDir: (stat.mode & 0x80000000) !== 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5367,6 +5760,22 @@ export async function getVolumeArchive(
|
||||
// Note: Container is kept alive for reuse. Cache TTL will handle cleanup.
|
||||
}
|
||||
|
||||
/**
|
||||
* Stat a path inside a volume via the helper container.
|
||||
* Returns the same shape as statContainerPath (#1180 raw download needs isDir).
|
||||
*/
|
||||
export async function statVolumePath(
|
||||
volumeName: string,
|
||||
path: string,
|
||||
envId?: number | null,
|
||||
readOnly: boolean = true
|
||||
): Promise<{ name: string; size: number; mode: number; mtime: string; linkTarget?: string; isDir: boolean }> {
|
||||
const containerId = await getOrCreateVolumeHelperContainer(volumeName, envId, readOnly);
|
||||
const safePath = path.replace(/[;&|`$(){}[\]<>'"\\]/g, '');
|
||||
const fullPath = `${VOLUME_MOUNT_PATH}${safePath.startsWith('/') ? safePath : '/' + safePath}`;
|
||||
return statContainerPath(containerId, fullPath, envId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file content from volume
|
||||
* Uses cached helper containers for better performance.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -15,6 +15,21 @@ import {
|
||||
type GitStackWithRepo
|
||||
} from './db';
|
||||
import { deployStack, getStackDir } from './stacks';
|
||||
import {
|
||||
parseManifest,
|
||||
serializeManifest,
|
||||
hashDirFiles,
|
||||
computeDeletions,
|
||||
buildNextManifest,
|
||||
buildSyncChangeSummary,
|
||||
formatChangeTable,
|
||||
skipReasonMessage,
|
||||
deletionSafetyCheck,
|
||||
type DeletionPlan,
|
||||
type DeletionApplyResult,
|
||||
type DeletionSkip,
|
||||
type SyncManifest
|
||||
} from './git-deletions';
|
||||
|
||||
const MERGED_CA_BUNDLE_PATH = '/tmp/dockhand-merged-ca-bundle.crt';
|
||||
let mergedCaBundleReady = false;
|
||||
@@ -363,6 +378,93 @@ async function getChangedFilesInDir(
|
||||
return { changed: changedFiles.length > 0, files: changedFiles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the deletion plan for a sync: hash the new clone's compose dir and
|
||||
* diff against the manifest from the last sync. Deletions converge the deploy
|
||||
* dir toward the clone state; the applier additionally verifies each file's
|
||||
* disk hash. A sanity guard blocks ALL deletions when the clone walk looks
|
||||
* broken (empty, or missing the compose file).
|
||||
*/
|
||||
async function computeSyncDeletionPlan(options: {
|
||||
logPrefix: string;
|
||||
composeDir: string; // absolute path inside the clone
|
||||
composeFileName: string | undefined; // compose file relative to composeDir
|
||||
rawManifest: string | null | undefined;
|
||||
}): Promise<{ plan: DeletionPlan; newFiles: Record<string, string>; previousManifest: SyncManifest }> {
|
||||
const { logPrefix, composeDir, composeFileName, rawManifest } = options;
|
||||
|
||||
const previousManifest = parseManifest(rawManifest);
|
||||
const newFiles = hashDirFiles(composeDir);
|
||||
|
||||
const manifestSize = Object.keys(previousManifest.files).length;
|
||||
console.log(`${logPrefix} Deletion sync: manifest has ${manifestSize} file(s)${manifestSize === 0 ? ' (first sync — nothing will be deleted)' : ''}`);
|
||||
|
||||
// First sync / legacy manifest: nothing was recorded, so nothing can be deleted
|
||||
if (manifestSize === 0) {
|
||||
return { plan: { toDelete: [], skipped: [] }, newFiles, previousManifest };
|
||||
}
|
||||
|
||||
const blocked = deletionSafetyCheck(previousManifest.files, newFiles, composeFileName);
|
||||
if (blocked) {
|
||||
console.warn(`${logPrefix} Deletion sync: ${blocked}`);
|
||||
return { plan: { toDelete: [], skipped: [] }, newFiles, previousManifest };
|
||||
}
|
||||
|
||||
const plan = computeDeletions(previousManifest.files, newFiles);
|
||||
|
||||
for (const file of plan.toDelete) {
|
||||
console.log(`${logPrefix} Deletion sync: will remove "${file.path}" — deleted from the repository`);
|
||||
}
|
||||
for (const skip of plan.skipped) {
|
||||
console.warn(`${logPrefix} Deletion sync: keeping "${skip.path}" — ${skipReasonMessage(skip.reason)}`);
|
||||
}
|
||||
|
||||
return { plan, newFiles, previousManifest };
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the manifest after a deploy and log the per-file change summary.
|
||||
* Called only after a successful deploy (locally applied or agent-confirmed).
|
||||
*/
|
||||
async function finalizeDeletionSync(options: {
|
||||
stackId: number;
|
||||
logPrefix: string;
|
||||
previousManifest: SyncManifest;
|
||||
newCommitFull: string;
|
||||
newFiles: Record<string, string>;
|
||||
plan: DeletionPlan;
|
||||
applyResult: DeletionApplyResult | undefined;
|
||||
onLine?: (line: string) => void; // extra sink (job output)
|
||||
}): Promise<void> {
|
||||
const { stackId, logPrefix, previousManifest, newCommitFull, newFiles, plan, applyResult, onLine } = options;
|
||||
|
||||
// No apply result means deletions were requested but nothing reported back
|
||||
// (defensive — executors always return one). Logged as skips; skips are final.
|
||||
const effectiveApply: DeletionApplyResult = applyResult ?? {
|
||||
deleted: [],
|
||||
skipped: plan.toDelete.map((f): DeletionSkip => ({ path: f.path, reason: 'apply-failed' }))
|
||||
};
|
||||
|
||||
// Pass only the plan-stage skips; buildSyncChangeSummary already merges
|
||||
// in effectiveApply.skipped itself. Concatenating here duplicated every
|
||||
// apply-stage skip (locally-modified, agent-no-support, apply-failed).
|
||||
const summary = buildSyncChangeSummary(previousManifest.files, newFiles, effectiveApply, plan.skipped);
|
||||
const tableLines = formatChangeTable(summary);
|
||||
|
||||
console.log(`${logPrefix} Sync file changes: ${tableLines[0]}`);
|
||||
for (const line of tableLines.slice(1)) {
|
||||
console.log(`${logPrefix} ${line}`);
|
||||
}
|
||||
if (onLine) {
|
||||
onLine(`File changes: ${tableLines[0]}`);
|
||||
for (const line of tableLines.slice(1)) onLine(line);
|
||||
}
|
||||
|
||||
const nextManifest = buildNextManifest(newCommitFull, newFiles);
|
||||
await updateGitStack(stackId, { syncedFiles: serializeManifest(nextManifest) });
|
||||
console.log(`${logPrefix} Manifest persisted: ${Object.keys(nextManifest.files).length} file(s) at commit ${nextManifest.commit?.substring(0, 7)}`);
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
commit?: string;
|
||||
@@ -375,6 +477,11 @@ export interface SyncResult {
|
||||
error?: string;
|
||||
updated?: boolean;
|
||||
changedFiles?: string[]; // List of files that changed (for logging/debugging)
|
||||
// Deletion sync (#966/#1162): manifest-vs-clone data
|
||||
deletionPlan?: DeletionPlan; // Files safe to delete (manifest entries absent from the new clone) + plan-stage skips
|
||||
newFiles?: Record<string, string>; // path → sha256 of files in the new clone (next manifest)
|
||||
newCommitFull?: string; // Full 40-char commit hash (manifest commit)
|
||||
previousManifest?: SyncManifest; // Manifest from the last successful sync
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
@@ -954,6 +1061,14 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
|
||||
console.log(`${logPrefix} No env file path configured`);
|
||||
}
|
||||
|
||||
// Deletion sync (#966): manifest-vs-clone deletion plan
|
||||
const deletionData = await computeSyncDeletionPlan({
|
||||
logPrefix,
|
||||
composeDir,
|
||||
composeFileName,
|
||||
rawManifest: gitStack.syncedFiles
|
||||
});
|
||||
|
||||
// Update git stack status
|
||||
await updateGitStack(stackId, {
|
||||
syncStatus: 'synced',
|
||||
@@ -982,7 +1097,11 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
|
||||
envFileVars,
|
||||
envFileName,
|
||||
updated,
|
||||
changedFiles
|
||||
changedFiles,
|
||||
deletionPlan: deletionData.plan,
|
||||
newFiles: deletionData.newFiles,
|
||||
newCommitFull: newCommit,
|
||||
previousManifest: deletionData.previousManifest
|
||||
};
|
||||
} catch (error: any) {
|
||||
cleanupSshKey(credential);
|
||||
@@ -1065,7 +1184,8 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
|
||||
forceRecreate,
|
||||
build: gitStack.buildOnDeploy,
|
||||
noBuildCache: gitStack.noBuildCache,
|
||||
pullPolicy: gitStack.repullImages ? 'always' : undefined
|
||||
pullPolicy: gitStack.repullImages ? 'always' : undefined,
|
||||
filesToDelete: syncResult.deletionPlan?.toDelete
|
||||
});
|
||||
|
||||
console.log(`${logPrefix} ----------------------------------------`);
|
||||
@@ -1076,6 +1196,19 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
|
||||
if (result.error) console.log(`${logPrefix} Error:`, result.error);
|
||||
|
||||
if (result.success) {
|
||||
// Deletion sync: persist manifest + log per-file change summary
|
||||
if (syncResult.previousManifest && syncResult.newFiles && syncResult.newCommitFull && syncResult.deletionPlan) {
|
||||
await finalizeDeletionSync({
|
||||
stackId,
|
||||
logPrefix,
|
||||
previousManifest: syncResult.previousManifest,
|
||||
newCommitFull: syncResult.newCommitFull,
|
||||
newFiles: syncResult.newFiles,
|
||||
plan: syncResult.deletionPlan,
|
||||
applyResult: result.deletion
|
||||
});
|
||||
}
|
||||
|
||||
// Record the stack source with resolved compose path for consistency
|
||||
const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId);
|
||||
const resolvedComposePath = syncResult.composeFileName
|
||||
@@ -1306,6 +1439,15 @@ export async function deployGitStackWithProgress(
|
||||
}
|
||||
}
|
||||
|
||||
// Deletion sync (#966): manifest-vs-clone deletion plan
|
||||
const logPrefix = `[Stack:${gitStack.stackName}]`;
|
||||
const deletionData = await computeSyncDeletionPlan({
|
||||
logPrefix,
|
||||
composeDir,
|
||||
composeFileName: progressComposeFileName,
|
||||
rawManifest: gitStack.syncedFiles
|
||||
});
|
||||
|
||||
// Update git stack status
|
||||
await updateGitStack(stackId, {
|
||||
syncStatus: 'synced',
|
||||
@@ -1319,6 +1461,14 @@ export async function deployGitStackWithProgress(
|
||||
// Step 5: Deploying stack
|
||||
// Uses `docker compose up -d --remove-orphans` which only recreates changed services
|
||||
onProgress({ status: 'deploying', message: `Deploying ${gitStack.stackName}...`, step: 5, totalSteps });
|
||||
if (deletionData.plan.toDelete.length > 0) {
|
||||
onProgress({
|
||||
status: 'deploying',
|
||||
message: `Removing ${deletionData.plan.toDelete.length} file(s) deleted from the repository...`,
|
||||
step: 5,
|
||||
totalSteps
|
||||
});
|
||||
}
|
||||
|
||||
// Determine env filename relative to compose dir (same logic as syncGitStack)
|
||||
let envFileName: string | undefined;
|
||||
@@ -1338,10 +1488,24 @@ export async function deployGitStackWithProgress(
|
||||
envFileName, // Env file relative to compose dir (for --env-file flag, optional)
|
||||
build: gitStack.buildOnDeploy,
|
||||
noBuildCache: gitStack.noBuildCache,
|
||||
pullPolicy: gitStack.repullImages ? 'always' : undefined
|
||||
pullPolicy: gitStack.repullImages ? 'always' : undefined,
|
||||
filesToDelete: deletionData.plan.toDelete
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// Deletion sync: persist manifest + log per-file change summary.
|
||||
// onLine feeds the per-file change table into the deploy progress popover.
|
||||
await finalizeDeletionSync({
|
||||
stackId,
|
||||
logPrefix,
|
||||
previousManifest: deletionData.previousManifest,
|
||||
newCommitFull: newCommit,
|
||||
newFiles: deletionData.newFiles,
|
||||
plan: deletionData.plan,
|
||||
applyResult: result.deletion,
|
||||
onLine: (line) => onProgress({ status: 'deploying', message: line, step: 5, totalSteps })
|
||||
});
|
||||
|
||||
// Record the stack source with resolved compose path for consistency
|
||||
const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId);
|
||||
const resolvedComposePath = join(stackDir, progressComposeFileName);
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
import { db, hawserTokens, environments, eq, and } from './db/drizzle.js';
|
||||
import { logContainerEvent, type ContainerEventAction } from './db.js';
|
||||
import { containerEventEmitter } from './event-collector.js';
|
||||
import { sendEnvironmentNotification } from './notifications.js';
|
||||
import { sendEnvironmentNotification } from './notifications/index.js';
|
||||
import { isNotifyDisabledByLabel } from './container-labels.js';
|
||||
import { isHealthTransition } from './subprocess-manager.js';
|
||||
import { pushMetric } from './metrics-store.js';
|
||||
import { secureGetRandomValues, secureRandomUUID } from './crypto-fallback.js';
|
||||
import { hashPassword, verifyPassword } from './auth.js';
|
||||
@@ -178,6 +179,12 @@ export async function handleEdgeContainerEvent(
|
||||
// Log the event
|
||||
console.log(`[Hawser] Container event from env ${environmentId}: ${event.action} ${event.containerName || event.containerId}`);
|
||||
|
||||
// Only store health_status events on transitions (healthy↔unhealthy)
|
||||
// to avoid flooding the DB with repeated identical health checks
|
||||
if (!isHealthTransition(environmentId, event.containerId, event.action)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save to database
|
||||
const savedEvent = await logContainerEvent({
|
||||
environmentId,
|
||||
@@ -258,6 +265,16 @@ export async function handleEdgeMetrics(
|
||||
// Register global handler for metrics
|
||||
globalThis.__hawserHandleMetrics = handleEdgeMetrics;
|
||||
|
||||
let dummyHawserHash: string | null = null;
|
||||
async function getDummyHawserHash(): Promise<string> {
|
||||
if (!dummyHawserHash) {
|
||||
dummyHawserHash = await hashPassword('hawser_init_seed');
|
||||
}
|
||||
return dummyHawserHash;
|
||||
}
|
||||
// Warm the lazy init so first-call latency is consistent.
|
||||
getDummyHawserHash().catch(() => {});
|
||||
|
||||
/**
|
||||
* Validate a Hawser token
|
||||
*/
|
||||
@@ -272,22 +289,32 @@ export async function validateHawserToken(
|
||||
.from(hawserTokens)
|
||||
.where(and(eq(hawserTokens.tokenPrefix, prefix), eq(hawserTokens.isActive, true)));
|
||||
|
||||
if (candidates.length === 0) {
|
||||
await verifyPassword(token, await getDummyHawserHash());
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
for (const t of candidates) {
|
||||
try {
|
||||
const isValid = await verifyPassword(token, t.token);
|
||||
if (isValid) {
|
||||
// Update last used timestamp
|
||||
await db
|
||||
.update(hawserTokens)
|
||||
.set({ lastUsed: new Date().toISOString() })
|
||||
.where(eq(hawserTokens.id, t.id));
|
||||
if (!isValid) continue;
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
environmentId: t.environmentId ?? undefined,
|
||||
tokenId: t.id
|
||||
};
|
||||
// Expiry check intentionally runs after the hash verify.
|
||||
if (t.expiresAt && new Date(t.expiresAt) < new Date()) {
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
// Update last used timestamp
|
||||
await db
|
||||
.update(hawserTokens)
|
||||
.set({ lastUsed: new Date().toISOString() })
|
||||
.where(eq(hawserTokens.id, t.id));
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
environmentId: t.environmentId ?? undefined,
|
||||
tokenId: t.id
|
||||
};
|
||||
} catch {
|
||||
// Invalid hash format, skip
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ let cachedMounts: Array<{ source: string; destination: string }> | null = null;
|
||||
// Used by scanner to replicate how Dockhand connects to Docker
|
||||
let cachedOwnDockerHost: string | null = null;
|
||||
let cachedOwnNetworkMode: string | null = null;
|
||||
let cachedOwnAllNetworks: string[] | null = null;
|
||||
let cachedOwnExtraHosts: string[] | null = null;
|
||||
|
||||
/**
|
||||
@@ -166,7 +167,10 @@ export async function detectHostDataDir(): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// Cache Dockhand's network (prefer non-default for service discovery)
|
||||
// Cache Dockhand's networks. Picks one as the primary networkMode
|
||||
// (custom net first, falling back to bridge) and keeps the full list
|
||||
// so callers can warn when a setup is fragile — e.g. socket-proxy
|
||||
// living on a network other than the one the scanner joins (#1011).
|
||||
const networks = containerInfo.NetworkSettings?.Networks;
|
||||
if (networks) {
|
||||
const custom = Object.keys(networks).filter(
|
||||
@@ -174,8 +178,9 @@ export async function detectHostDataDir(): Promise<string | null> {
|
||||
);
|
||||
cachedOwnNetworkMode = custom.length > 0 ? custom[0]
|
||||
: networks.bridge ? 'bridge' : null;
|
||||
cachedOwnAllNetworks = Object.keys(networks);
|
||||
if (cachedOwnNetworkMode) {
|
||||
console.log(`[HostPath] Detected own network: ${cachedOwnNetworkMode}`);
|
||||
console.log(`[HostPath] Detected own network: ${cachedOwnNetworkMode} (all: ${cachedOwnAllNetworks.join(', ')})`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +250,15 @@ export function getOwnNetworkMode(): string | null {
|
||||
return cachedOwnNetworkMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* All Docker networks Dockhand itself is attached to. The scanner uses
|
||||
* this to detect split-network setups and warn that socket-proxy may not
|
||||
* be reachable from the network it actually joins (#1011).
|
||||
*/
|
||||
export function getOwnAllNetworks(): string[] {
|
||||
return cachedOwnAllNetworks ? [...cachedOwnAllNetworks] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ExtraHosts entries configured on Dockhand itself.
|
||||
* Used to mirror host aliases into sibling sidecar containers.
|
||||
@@ -385,36 +399,39 @@ export function extractUidFromSocketPath(socketPath: string): string | null {
|
||||
export function rewriteComposeVolumePaths(composeContent: string, workingDir: string): { content: string; modified: boolean; changes: string[] } {
|
||||
const changes: string[] = [];
|
||||
|
||||
// Try to translate workingDir to host path using ANY cached mount
|
||||
// This handles both DATA_DIR mounts and external mounts (e.g., /external-stacks)
|
||||
const hostWorkingDir = translateContainerPathViaMount(workingDir);
|
||||
|
||||
if (!hostWorkingDir) {
|
||||
// Can't translate - workingDir is not under any known mount
|
||||
return { content: composeContent, modified: false, changes };
|
||||
}
|
||||
|
||||
// Parse compose content line by line to find and rewrite volume mounts
|
||||
// Parse compose content line by line to find and rewrite volume mounts.
|
||||
// We look for patterns like:
|
||||
// - ./something:/container/path
|
||||
// - ../something:/container/path
|
||||
// - "./something:/container/path"
|
||||
// - './something:/container/path'
|
||||
// - '../something:/container/path'
|
||||
const lines = composeContent.split('\n');
|
||||
const modifiedLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
// Match volume mount patterns with relative paths
|
||||
// Handles: - ./path:/dest, - "./path:/dest", - './path:/dest'
|
||||
const volumeMatch = line.match(/^(\s*-\s*)(['"]?)(\.\/[^'":\s]+)(\2)(:.+)$/);
|
||||
// Match volume mount patterns with relative paths.
|
||||
// Handles ./path and ../path, optionally quoted with single or double quotes.
|
||||
const volumeMatch = line.match(/^(\s*-\s*)(['"]?)(\.\.?\/[^'":\s]+)(\2)(:.+)$/);
|
||||
|
||||
if (volumeMatch) {
|
||||
const [, prefix, quote, relativeSrc, , destPart] = volumeMatch;
|
||||
// Convert relative path to absolute host path
|
||||
const absoluteHostPath = hostWorkingDir + '/' + relativeSrc.substring(2); // Remove ./
|
||||
// Resolve to an absolute container path, then translate to a host
|
||||
// path via any known mount. Each line is translated independently so
|
||||
// `../foo` can escape workingDir into a sibling that may map to a
|
||||
// different mount than workingDir itself.
|
||||
const absoluteContainerPath = resolve(workingDir, relativeSrc);
|
||||
const absoluteHostPath = translateContainerPathViaMount(absoluteContainerPath);
|
||||
|
||||
const newLine = `${prefix}${absoluteHostPath}${destPart}`;
|
||||
modifiedLines.push(newLine);
|
||||
changes.push(` ${relativeSrc} -> ${absoluteHostPath}`);
|
||||
if (absoluteHostPath) {
|
||||
const newLine = `${prefix}${absoluteHostPath}${destPart}`;
|
||||
modifiedLines.push(newLine);
|
||||
changes.push(` ${relativeSrc} -> ${absoluteHostPath}`);
|
||||
} else {
|
||||
// Can't translate — leave line unchanged. Compose will resolve
|
||||
// it relative to its cwd; if that's wrong the deploy fails
|
||||
// loudly, which is better than producing a misleading host path.
|
||||
modifiedLines.push(line);
|
||||
}
|
||||
} else {
|
||||
modifiedLines.push(line);
|
||||
}
|
||||
|
||||
@@ -1,759 +0,0 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import {
|
||||
getEnabledNotificationSettings,
|
||||
getEnabledEnvironmentNotifications,
|
||||
getEnvironment,
|
||||
type NotificationSettingData,
|
||||
type SmtpConfig,
|
||||
type AppriseConfig,
|
||||
type NotificationEventType
|
||||
} from './db';
|
||||
|
||||
import { escapeTelegramMarkdown, parseTelegramUrl, buildGotifyUrl, parseWorkflowsUrl, buildWorkflowsHttpUrl } from '$lib/utils/notification-parsers';
|
||||
|
||||
/** Drain a response body to release the underlying socket/TLS connection. */
|
||||
async function drainResponse(response: Response): Promise<void> {
|
||||
if (!response.bodyUsed) {
|
||||
try { await response.arrayBuffer(); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export interface NotificationPayload {
|
||||
title: string;
|
||||
message: string;
|
||||
type?: 'info' | 'success' | 'warning' | 'error';
|
||||
environmentId?: number;
|
||||
environmentName?: string;
|
||||
}
|
||||
|
||||
// Result type for functions that can return detailed errors
|
||||
export interface NotificationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Send notification via SMTP
|
||||
async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: config.username ? {
|
||||
user: config.username,
|
||||
pass: config.password
|
||||
} : undefined,
|
||||
tls: config.skipTlsVerify ? {
|
||||
rejectUnauthorized: false
|
||||
} : undefined
|
||||
});
|
||||
|
||||
const envBadge = payload.environmentName
|
||||
? `<span style="display: inline-block; background: #3b82f6; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px;">${payload.environmentName}</span>`
|
||||
: '';
|
||||
const envText = payload.environmentName ? ` [${payload.environmentName}]` : '';
|
||||
|
||||
const html = `
|
||||
<div style="font-family: sans-serif; padding: 20px;">
|
||||
<h2 style="margin: 0 0 10px 0;">${payload.title}${envBadge}</h2>
|
||||
<p style="margin: 0; white-space: pre-wrap;">${payload.message}</p>
|
||||
<hr style="margin: 20px 0; border: none; border-top: 1px solid #eee;">
|
||||
<p style="margin: 0; font-size: 12px; color: #666;">Sent by Dockhand</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
await transporter.sendMail({
|
||||
from: config.from_name ? `"${config.from_name}" <${config.from_email}>` : config.from_email,
|
||||
to: config.to_emails.join(', '),
|
||||
subject: `[Dockhand]${envText} ${payload.title}`,
|
||||
text: `${payload.title}${envText}\n\n${payload.message}`,
|
||||
html
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return { success: false, error: `SMTP error: ${errorMsg}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Apprise URL and send notification
|
||||
async function sendAppriseNotification(config: AppriseConfig, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const url of config.urls) {
|
||||
try {
|
||||
const result = await sendToAppriseUrl(url, payload);
|
||||
if (!result.success && result.error) {
|
||||
errors.push(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
errors.push(`Failed to send: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { success: false, error: errors.join('; ') };
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Send to a single Apprise URL
|
||||
async function sendToAppriseUrl(url: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
try {
|
||||
// Extract protocol from Apprise URL format (protocol://...)
|
||||
// Note: Can't use new URL() because custom schemes like 'tgram://' are not valid URLs
|
||||
const protocolMatch = url.match(/^([a-z]+):\/\//i);
|
||||
if (!protocolMatch) {
|
||||
return { success: false, error: 'Invalid Apprise URL format - missing protocol' };
|
||||
}
|
||||
const protocol = protocolMatch[1].toLowerCase();
|
||||
|
||||
// Handle different notification services
|
||||
switch (protocol) {
|
||||
case 'discord':
|
||||
case 'discords':
|
||||
return await sendDiscord(url, payload);
|
||||
case 'slack':
|
||||
case 'slacks':
|
||||
return await sendSlack(url, payload);
|
||||
case 'mmost':
|
||||
case 'mmosts':
|
||||
return await sendMattermost(url, payload);
|
||||
case 'tgram':
|
||||
return await sendTelegram(url, payload);
|
||||
case 'gotify':
|
||||
case 'gotifys':
|
||||
return await sendGotify(url, payload);
|
||||
case 'ntfy':
|
||||
case 'ntfys':
|
||||
return await sendNtfy(url, payload);
|
||||
case 'pushover':
|
||||
return await sendPushover(url, payload);
|
||||
case 'json':
|
||||
case 'jsons':
|
||||
return await sendGenericWebhook(url, payload);
|
||||
case 'workflows':
|
||||
return await sendWorkflows(url, payload);
|
||||
default:
|
||||
return { success: false, error: `Unsupported Apprise protocol: ${protocol}` };
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
return { success: false, error: `Failed to parse Apprise URL: ${errorMsg}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Discord webhook
|
||||
async function sendDiscord(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// discord://webhook_id/webhook_token or discords://...
|
||||
const url = appriseUrl.replace(/^discords?:\/\//, 'https://discord.com/api/webhooks/');
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
embeds: [{
|
||||
title: titleWithEnv,
|
||||
description: payload.message,
|
||||
color: payload.type === 'error' ? 0xff0000 : payload.type === 'warning' ? 0xffaa00 : payload.type === 'success' ? 0x00ff00 : 0x0099ff,
|
||||
...(payload.environmentName && {
|
||||
footer: { text: `Environment: ${payload.environmentName}` }
|
||||
})
|
||||
}]
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Discord error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Discord connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Slack webhook
|
||||
async function sendSlack(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// slack://token_a/token_b/token_c or webhook URL
|
||||
let url: string;
|
||||
if (appriseUrl.includes('hooks.slack.com')) {
|
||||
url = appriseUrl.replace(/^slacks?:\/\//, 'https://');
|
||||
} else {
|
||||
const parts = appriseUrl.replace(/^slacks?:\/\//, '').split('/');
|
||||
url = `https://hooks.slack.com/services/${parts.join('/')}`;
|
||||
}
|
||||
|
||||
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: `*${payload.title}*${envTag}\n${payload.message}`
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Slack error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Slack connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Mattermost webhook
|
||||
async function sendMattermost(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// mmost://[botname@]hostname[:port][/path]/token or mmosts://...
|
||||
const isSecure = appriseUrl.startsWith('mmosts');
|
||||
const protocol = isSecure ? 'https' : 'http';
|
||||
|
||||
// Remove the scheme
|
||||
let urlPart = appriseUrl.replace(/^mmosts?:\/\//, '');
|
||||
|
||||
// Check for botname (username@hostname format)
|
||||
let username: string | undefined;
|
||||
const atIndex = urlPart.indexOf('@');
|
||||
if (atIndex !== -1) {
|
||||
username = urlPart.substring(0, atIndex);
|
||||
urlPart = urlPart.substring(atIndex + 1);
|
||||
}
|
||||
|
||||
// The token is the last segment, everything else is hostname[:port][/path]
|
||||
const lastSlashIndex = urlPart.lastIndexOf('/');
|
||||
if (lastSlashIndex === -1) {
|
||||
return { success: false, error: 'Invalid Mattermost URL format. Expected: mmost://[botname@]hostname[:port][/path]/token' };
|
||||
}
|
||||
|
||||
const token = urlPart.substring(lastSlashIndex + 1);
|
||||
const hostAndPath = urlPart.substring(0, lastSlashIndex);
|
||||
|
||||
// Build the webhook URL: {protocol}://{hostname}[:{port}][/{path}]/hooks/{token}
|
||||
const url = `${protocol}://${hostAndPath}/hooks/${token}`;
|
||||
|
||||
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
|
||||
const body: Record<string, string> = {
|
||||
text: `*${payload.title}*${envTag}\n${payload.message}`
|
||||
};
|
||||
|
||||
if (username) {
|
||||
body.username = username;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Mattermost error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Mattermost connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Telegram
|
||||
async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const parsed = parseTelegramUrl(appriseUrl);
|
||||
if (!parsed) {
|
||||
return { success: false, error: 'Invalid Telegram URL format. Expected: tgram://bot_token/chat_id or tgram://bot_token/chat_id:topic_id' };
|
||||
}
|
||||
|
||||
const { botToken, chatId, topicId } = parsed;
|
||||
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
|
||||
|
||||
// Escape markdown special characters in title and message
|
||||
const escapedTitle = escapeTelegramMarkdown(payload.title);
|
||||
const escapedMessage = escapeTelegramMarkdown(payload.message);
|
||||
const envTag = payload.environmentName ? ` [${escapeTelegramMarkdown(payload.environmentName)}]` : '';
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: `*${escapedTitle}*${envTag}\n${escapedMessage}`,
|
||||
...(topicId ? { message_thread_id: topicId } : {}),
|
||||
parse_mode: 'Markdown',
|
||||
link_preview_options: {
|
||||
is_disabled: true
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({})) as { description?: string };
|
||||
const errorMsg = errorData.description || response.statusText;
|
||||
return { success: false, error: `Telegram error ${response.status}: ${errorMsg}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Telegram connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Gotify
|
||||
async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const parsed = buildGotifyUrl(appriseUrl);
|
||||
if (!parsed) {
|
||||
return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' };
|
||||
}
|
||||
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
const defaultPriority = payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2;
|
||||
|
||||
try {
|
||||
const response = await fetch(parsed.url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: titleWithEnv,
|
||||
message: payload.message,
|
||||
priority: parsed.priority ?? defaultPriority
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Gotify error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Gotify connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// ntfy
|
||||
async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// Supported formats:
|
||||
// ntfy://topic (public ntfy.sh)
|
||||
// ntfy://host/topic (custom server, no auth)
|
||||
// ntfy://user:pass@host/topic (custom server with basic auth)
|
||||
// ntfy://token@host/topic (custom server with bearer token)
|
||||
// ntfy://host/topic?auth=BASE64 (custom server with base64-encoded bearer token)
|
||||
// Query params: ?tags=ship,whale &title=Custom &priority=5
|
||||
// ntfys:// variants for HTTPS
|
||||
const isSecure = appriseUrl.startsWith('ntfys');
|
||||
const path = appriseUrl.replace(/^ntfys?:\/\//, '');
|
||||
|
||||
let url: string;
|
||||
let authHeader: string | null = null;
|
||||
|
||||
// Extract query parameters (?auth=, ?tags=, ?title=, ?priority=)
|
||||
let queryAuth: string | null = null;
|
||||
let queryTags: string | null = null;
|
||||
let queryTitle: string | null = null;
|
||||
let queryPriority: string | null = null;
|
||||
let cleanPath = path;
|
||||
const qIndex = path.indexOf('?');
|
||||
if (qIndex !== -1) {
|
||||
const params = new URLSearchParams(path.substring(qIndex + 1));
|
||||
queryAuth = params.get('auth');
|
||||
queryTags = params.get('tags');
|
||||
queryTitle = params.get('title');
|
||||
queryPriority = params.get('priority');
|
||||
cleanPath = path.substring(0, qIndex);
|
||||
}
|
||||
|
||||
// Check for user:pass@host/topic format (Basic auth)
|
||||
const basicMatch = cleanPath.match(/^([^:]+):([^@]+)@(.+)$/);
|
||||
if (basicMatch) {
|
||||
const [, user, pass, hostAndTopic] = basicMatch;
|
||||
const basic = Buffer.from(`${user}:${pass}`).toString('base64');
|
||||
authHeader = `Basic ${basic}`;
|
||||
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
|
||||
} else if (cleanPath.includes('@') && cleanPath.includes('/')) {
|
||||
// token@host/topic -> Bearer token auth
|
||||
const tokenMatch = cleanPath.match(/^([^@]+)@(.+)$/);
|
||||
if (tokenMatch) {
|
||||
const [, token, hostAndTopic] = tokenMatch;
|
||||
authHeader = `Bearer ${token}`;
|
||||
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
|
||||
} else {
|
||||
// Fallback to custom server without auth
|
||||
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
|
||||
}
|
||||
} else if (cleanPath.includes('/')) {
|
||||
// Custom server without auth
|
||||
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
|
||||
} else {
|
||||
// Default ntfy.sh
|
||||
url = `https://ntfy.sh/${cleanPath}`;
|
||||
}
|
||||
|
||||
// Apply ?auth= as fallback if no explicit auth was set
|
||||
if (!authHeader && queryAuth) {
|
||||
const decoded = Buffer.from(queryAuth, 'base64').toString();
|
||||
authHeader = decoded.startsWith('Bearer ') ? decoded : `Bearer ${decoded}`;
|
||||
}
|
||||
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
const defaultTags = payload.type || 'info';
|
||||
const headers: Record<string, string> = {
|
||||
'Title': queryTitle || titleWithEnv,
|
||||
'Priority': queryPriority || (payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3'),
|
||||
'Tags': queryTags ? `${queryTags},${defaultTags}` : defaultTags
|
||||
};
|
||||
|
||||
if (authHeader) {
|
||||
headers['Authorization'] = authHeader;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: payload.message
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `ntfy error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `ntfy connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Pushover
|
||||
async function sendPushover(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// pushover://user_key/api_token
|
||||
const match = appriseUrl.match(/^pushover:\/\/([^/]+)\/(.+)/);
|
||||
if (!match) {
|
||||
return { success: false, error: 'Invalid Pushover URL format. Expected: pushover://user_key/api_token' };
|
||||
}
|
||||
|
||||
const [, userKey, apiToken] = match;
|
||||
const url = 'https://api.pushover.net/1/messages.json';
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: apiToken,
|
||||
user: userKey,
|
||||
title: titleWithEnv,
|
||||
message: payload.message,
|
||||
priority: payload.type === 'error' ? 1 : 0
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Pushover error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Pushover connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Generic JSON webhook
|
||||
async function sendGenericWebhook(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// json://hostname/path or jsons://hostname/path
|
||||
const url = appriseUrl.replace(/^jsons?:\/\//, appriseUrl.startsWith('jsons') ? 'https://' : 'http://');
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: payload.title,
|
||||
message: payload.message,
|
||||
type: payload.type || 'info',
|
||||
environment: payload.environmentName || null,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Webhook error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Webhook connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
// Microsoft Power Automate Workflows, for e.g. Microsoft Teams
|
||||
async function sendWorkflows(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const parsed = parseWorkflowsUrl(appriseUrl);
|
||||
if (!parsed) {
|
||||
return { success: false, error: 'Invalid Workflows URL format. Expected: workflows://hostname/workflow/signature' };
|
||||
}
|
||||
|
||||
const url = buildWorkflowsHttpUrl(parsed.hostname, parsed.workflow, parsed.signature);
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'message',
|
||||
attachments: [
|
||||
{
|
||||
contentType: 'application/vnd.microsoft.card.adaptive',
|
||||
content: {
|
||||
$schema: 'https://adaptivecards.io/schemas/adaptive-card.json',
|
||||
type: 'AdaptiveCard',
|
||||
version: '1.2',
|
||||
body: [
|
||||
{
|
||||
type: 'TextBlock',
|
||||
style: 'heading',
|
||||
wrap: true,
|
||||
text: titleWithEnv
|
||||
},
|
||||
{
|
||||
type: 'TextBlock',
|
||||
style: 'default',
|
||||
wrap: true,
|
||||
text: payload.message
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Workflows error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Workflows connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Send notification to all enabled channels
|
||||
export async function sendNotification(payload: NotificationPayload): Promise<{ success: boolean; results: { name: string; success: boolean }[] }> {
|
||||
const settings = await getEnabledNotificationSettings();
|
||||
const results: { name: string; success: boolean }[] = [];
|
||||
|
||||
for (const setting of settings) {
|
||||
let result: NotificationResult = { success: false };
|
||||
|
||||
if (setting.type === 'smtp') {
|
||||
result = await sendSmtpNotification(setting.config as SmtpConfig, payload);
|
||||
} else if (setting.type === 'apprise') {
|
||||
result = await sendAppriseNotification(setting.config as AppriseConfig, payload);
|
||||
}
|
||||
|
||||
results.push({ name: setting.name, success: result.success });
|
||||
}
|
||||
|
||||
return {
|
||||
success: results.every(r => r.success),
|
||||
results
|
||||
};
|
||||
}
|
||||
|
||||
// Test a specific notification setting
|
||||
export async function testNotification(setting: NotificationSettingData): Promise<NotificationResult> {
|
||||
const payload: NotificationPayload = {
|
||||
title: 'Dockhand Test Notification',
|
||||
message: 'This is a test notification from Dockhand. If you receive this, your notification settings are configured correctly.',
|
||||
type: 'info'
|
||||
};
|
||||
|
||||
if (setting.type === 'smtp') {
|
||||
return await sendSmtpNotification(setting.config as SmtpConfig, payload);
|
||||
} else if (setting.type === 'apprise') {
|
||||
return await sendAppriseNotification(setting.config as AppriseConfig, payload);
|
||||
}
|
||||
|
||||
return { success: false, error: 'Unknown notification type' };
|
||||
}
|
||||
|
||||
// Map Docker action to notification event type
|
||||
function mapActionToEventType(action: string): NotificationEventType | null {
|
||||
const mapping: Record<string, NotificationEventType> = {
|
||||
'start': 'container_started',
|
||||
'stop': 'container_stopped',
|
||||
'restart': 'container_restarted',
|
||||
'die': 'container_exited',
|
||||
'kill': 'container_exited',
|
||||
'oom': 'container_oom',
|
||||
'health_status: unhealthy': 'container_unhealthy',
|
||||
'health_status: healthy': 'container_healthy',
|
||||
'pull': 'image_pulled'
|
||||
};
|
||||
return mapping[action] || null;
|
||||
}
|
||||
|
||||
// Scanner image patterns to exclude from notifications
|
||||
const SCANNER_IMAGE_PATTERNS = [
|
||||
'anchore/grype',
|
||||
'aquasec/trivy',
|
||||
'ghcr.io/anchore/grype',
|
||||
'ghcr.io/aquasecurity/trivy'
|
||||
];
|
||||
|
||||
function isScannerContainer(image: string | null | undefined): boolean {
|
||||
if (!image) return false;
|
||||
const lowerImage = image.toLowerCase();
|
||||
return SCANNER_IMAGE_PATTERNS.some(pattern => lowerImage.includes(pattern.toLowerCase()));
|
||||
}
|
||||
|
||||
// Send notification for an environment-specific event
|
||||
export async function sendEnvironmentNotification(
|
||||
environmentId: number,
|
||||
action: string,
|
||||
payload: Omit<NotificationPayload, 'environmentId' | 'environmentName'>,
|
||||
image?: string | null
|
||||
): Promise<{ success: boolean; sent: number }> {
|
||||
const eventType = mapActionToEventType(action);
|
||||
if (!eventType) {
|
||||
// Not a notifiable event type
|
||||
return { success: true, sent: 0 };
|
||||
}
|
||||
|
||||
// Get environment name
|
||||
const env = await getEnvironment(environmentId);
|
||||
if (!env) {
|
||||
return { success: false, sent: 0 };
|
||||
}
|
||||
|
||||
// Get enabled notification channels for this environment and event type
|
||||
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
|
||||
if (envNotifications.length === 0) {
|
||||
return { success: true, sent: 0 };
|
||||
}
|
||||
|
||||
const enrichedPayload: NotificationPayload = {
|
||||
...payload,
|
||||
environmentId,
|
||||
environmentName: env.name
|
||||
};
|
||||
|
||||
// Check if this is a scanner container
|
||||
const isScanner = isScannerContainer(image);
|
||||
|
||||
let sent = 0;
|
||||
let allSuccess = true;
|
||||
|
||||
// Skip all notifications for scanner containers (Trivy, Grype)
|
||||
if (isScanner) {
|
||||
return { success: true, sent: 0 };
|
||||
}
|
||||
|
||||
for (const notif of envNotifications) {
|
||||
try {
|
||||
let result: NotificationResult = { success: false };
|
||||
if (notif.channelType === 'smtp') {
|
||||
result = await sendSmtpNotification(notif.config as SmtpConfig, enrichedPayload);
|
||||
} else if (notif.channelType === 'apprise') {
|
||||
result = await sendAppriseNotification(notif.config as AppriseConfig, enrichedPayload);
|
||||
}
|
||||
if (result.success) sent++;
|
||||
else allSuccess = false;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[Notifications] Failed to send to channel ${notif.channelName}:`, errorMsg);
|
||||
allSuccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: allSuccess, sent };
|
||||
}
|
||||
|
||||
// Send notification for a specific event type (not mapped from Docker action)
|
||||
// Used for auto-update, git sync, vulnerability, and system events
|
||||
export async function sendEventNotification(
|
||||
eventType: NotificationEventType,
|
||||
payload: NotificationPayload,
|
||||
environmentId?: number
|
||||
): Promise<{ success: boolean; sent: number }> {
|
||||
// Get environment name if provided
|
||||
let enrichedPayload = { ...payload };
|
||||
if (environmentId) {
|
||||
const env = await getEnvironment(environmentId);
|
||||
if (env) {
|
||||
enrichedPayload.environmentId = environmentId;
|
||||
enrichedPayload.environmentName = env.name;
|
||||
}
|
||||
}
|
||||
|
||||
// Get enabled notification channels for this event type
|
||||
let channels: Array<{
|
||||
channel_type: 'smtp' | 'apprise';
|
||||
channel_name: string;
|
||||
config: SmtpConfig | AppriseConfig;
|
||||
}> = [];
|
||||
|
||||
if (environmentId) {
|
||||
// Environment-specific: get channels subscribed to this env and event type
|
||||
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
|
||||
channels = envNotifications
|
||||
.filter(n => n.channelType && n.channelName)
|
||||
.map(n => ({
|
||||
channel_type: n.channelType!,
|
||||
channel_name: n.channelName!,
|
||||
config: n.config
|
||||
}));
|
||||
} else {
|
||||
// System-wide: get all globally enabled channels that subscribe to this event type
|
||||
const globalSettings = await getEnabledNotificationSettings();
|
||||
channels = globalSettings
|
||||
.filter(s => s.eventTypes?.includes(eventType))
|
||||
.map(s => ({
|
||||
channel_type: s.type,
|
||||
channel_name: s.name,
|
||||
config: s.config
|
||||
}));
|
||||
}
|
||||
|
||||
if (channels.length === 0) {
|
||||
return { success: true, sent: 0 };
|
||||
}
|
||||
|
||||
let sent = 0;
|
||||
let allSuccess = true;
|
||||
|
||||
for (const channel of channels) {
|
||||
try {
|
||||
let result: NotificationResult = { success: false };
|
||||
if (channel.channel_type === 'smtp') {
|
||||
result = await sendSmtpNotification(channel.config as SmtpConfig, enrichedPayload);
|
||||
} else if (channel.channel_type === 'apprise') {
|
||||
result = await sendAppriseNotification(channel.config as AppriseConfig, enrichedPayload);
|
||||
}
|
||||
if (result.success) sent++;
|
||||
else allSuccess = false;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[Notifications] Failed to send to channel ${channel.channel_name}:`, errorMsg);
|
||||
allSuccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: allSuccess, sent };
|
||||
}
|
||||
@@ -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)}` };
|
||||
}
|
||||
}
|
||||
@@ -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)}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}` };
|
||||
}
|
||||
}
|
||||
@@ -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)}` };
|
||||
}
|
||||
}
|
||||
@@ -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)}` };
|
||||
}
|
||||
}
|
||||
@@ -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)}` };
|
||||
}
|
||||
}
|
||||
@@ -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)}` };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}` };
|
||||
}
|
||||
}
|
||||
@@ -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)}` };
|
||||
}
|
||||
}
|
||||
@@ -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}` };
|
||||
}
|
||||
}
|
||||
@@ -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)}` };
|
||||
}
|
||||
}
|
||||
@@ -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)}` };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -16,13 +16,15 @@ import {
|
||||
} from './docker';
|
||||
import { getEnvironment, getEnvSetting, getSetting } from './db';
|
||||
import { sendEventNotification } from './notifications';
|
||||
import { detectRemoteSocketPath } from './scanner-socket-detect';
|
||||
import {
|
||||
getHostDockerSocket,
|
||||
getHostDataDir,
|
||||
extractUidFromSocketPath,
|
||||
getOwnDockerHost,
|
||||
getOwnExtraHosts,
|
||||
getOwnNetworkMode
|
||||
getOwnNetworkMode,
|
||||
getOwnAllNetworks
|
||||
} from './host-path';
|
||||
import { resolve } from 'node:path';
|
||||
import { mkdir, chown, rm } from 'node:fs/promises';
|
||||
@@ -632,7 +634,7 @@ async function runScannerContainerCore(
|
||||
let rootlessUid: string | undefined;
|
||||
let scannerNetworkMode: string | undefined;
|
||||
let scannerDockerHost: string | undefined;
|
||||
const scannerExtraHosts = !isHawser ? getOwnExtraHosts() ?? undefined : undefined;
|
||||
let scannerExtraHosts = !isHawser ? getOwnExtraHosts() ?? undefined : undefined;
|
||||
|
||||
// Check if Dockhand itself uses TCP to reach Docker (e.g., socket proxy).
|
||||
// Detected at startup from Dockhand's own container inspect data.
|
||||
@@ -641,9 +643,19 @@ async function runScannerContainerCore(
|
||||
const ownDockerHost = getOwnDockerHost();
|
||||
|
||||
if (!isHawser && ownDockerHost?.startsWith('tcp://')) {
|
||||
// TCP mode: scanner uses the same DOCKER_HOST + network as Dockhand
|
||||
// TCP mode: scanner uses the same DOCKER_HOST + network as Dockhand.
|
||||
scannerDockerHost = ownDockerHost;
|
||||
scannerNetworkMode = getOwnNetworkMode() ?? undefined;
|
||||
const allNets = getOwnAllNetworks();
|
||||
if (allNets.length > 1) {
|
||||
// Multiple custom networks — if socket-proxy lives on a network
|
||||
// other than the one we picked, DNS will fail. Make the choice
|
||||
// visible so users with split-network setups can colocate
|
||||
// socket-proxy with Dockhand on the primary network (#1011).
|
||||
console.warn(
|
||||
`[Scanner] Dockhand is on multiple networks (${allNets.join(', ')}); scanner will only join "${scannerNetworkMode}". If DOCKER_HOST=${scannerDockerHost} fails to resolve, put socket-proxy on this network.`
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`[Scanner] TCP mode (from container inspect) - DOCKER_HOST=${scannerDockerHost}, network=${scannerNetworkMode ?? 'default'}`
|
||||
);
|
||||
@@ -651,9 +663,35 @@ async function runScannerContainerCore(
|
||||
console.log(`[Scanner] Reusing ExtraHosts from Dockhand: ${scannerExtraHosts.join(', ')}`);
|
||||
}
|
||||
} else if (isHawser) {
|
||||
// Hawser: scanner runs on remote host, uses remote host's standard Docker socket
|
||||
hostSocketPath = '/var/run/docker.sock';
|
||||
console.log(`[Scanner] Remote scan via Hawser (${connectionType}) - using standard socket path`);
|
||||
// Hawser: scanner runs on remote host. Detect the actual socket path
|
||||
// because rootless Podman uses /run/user/UID/podman/podman.sock, not
|
||||
// /var/run/docker.sock (#1076). Falls back to the standard path on
|
||||
// detection failure — no regression for stock Docker hosts.
|
||||
hostSocketPath = await detectRemoteSocketPath(envId);
|
||||
console.log(`[Scanner] Remote scan via Hawser (${connectionType}) - detected socket path: ${hostSocketPath}`);
|
||||
} else if (connectionType === 'direct' && env?.host) {
|
||||
// Direct TCP to a remote daemon (e.g. Docker over TCP, Podman over TCP).
|
||||
// The scanner container is created on the REMOTE daemon, not on
|
||||
// Dockhand's host. Binding "/var/run/docker.sock" from Dockhand's
|
||||
// host into a remote-daemon container is nonsense — on remote Docker
|
||||
// it silently creates an empty dir (scans fall back to registry); on
|
||||
// rootless Podman it errors with "mkdir /var/run/docker.sock:
|
||||
// permission denied" (#1076, #1011). Instead, tell the scanner to
|
||||
// talk to the daemon over the same TCP endpoint Dockhand uses.
|
||||
// `host.containers.internal` resolves to the daemon's host from
|
||||
// inside the scanner container on both Docker (with `host-gateway`)
|
||||
// and Podman (built-in).
|
||||
scannerDockerHost = `tcp://host.containers.internal:${env.port}`;
|
||||
// Add the host-gateway mapping so Docker honours the hostname.
|
||||
// Podman recognises host.containers.internal natively; the extra
|
||||
// mapping is harmless there.
|
||||
scannerExtraHosts = [
|
||||
...(scannerExtraHosts ?? []),
|
||||
'host.containers.internal:host-gateway'
|
||||
];
|
||||
console.log(
|
||||
`[Scanner] Direct TCP env (${env.protocol ?? 'http'}://${env.host}:${env.port}) - DOCKER_HOST=${scannerDockerHost} (#1076, #1011)`
|
||||
);
|
||||
} else {
|
||||
// Local socket — detect host socket path (handles rootless Docker)
|
||||
hostSocketPath = getHostDockerSocket();
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner';
|
||||
import { sendEventNotification } from '../../notifications';
|
||||
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
|
||||
import { isUpdateDisabledByLabel } from '../../container-labels';
|
||||
import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
@@ -382,6 +382,18 @@ export async function runContainerUpdate(
|
||||
return;
|
||||
}
|
||||
|
||||
// Hidden containers are excluded from update polling and auto-updates (#1083)
|
||||
if (isHiddenByLabel(inspectData.Config?.Labels)) {
|
||||
log(`Skipping - dockhand.hidden=true label set on container`);
|
||||
await updateScheduleExecution(execution.id, {
|
||||
status: 'skipped',
|
||||
completedAt: new Date().toISOString(),
|
||||
duration: Date.now() - startTime,
|
||||
details: { reason: 'Skipped by dockhand.hidden=true label' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip digest-pinned images - they are explicitly locked to a specific version
|
||||
if (isDigestBasedImage(imageNameFromConfig)) {
|
||||
log(`Skipping ${containerName} - image pinned to specific digest`);
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
import { sendEventNotification } from '../../notifications';
|
||||
import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner';
|
||||
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
|
||||
import { isUpdateDisabledByLabel } from '../../container-labels';
|
||||
import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
|
||||
import { recreateContainer } from './container-update';
|
||||
|
||||
interface UpdateInfo {
|
||||
@@ -105,9 +105,12 @@ export async function runEnvUpdateCheckJob(
|
||||
// Clear pending updates at the start - we'll re-add as we discover updates
|
||||
await clearPendingContainerUpdates(environmentId);
|
||||
|
||||
// Get all containers in this environment
|
||||
const containers = await listContainers(true, environmentId);
|
||||
await log(`Found ${containers.length} containers`);
|
||||
// Get all containers in this environment, excluding ones hidden via
|
||||
// dockhand.hidden=true (consistent with manual check-updates, #1083).
|
||||
const allContainers = await listContainers(true, environmentId);
|
||||
const containers = allContainers.filter(c => !isHiddenByLabel(c.labels));
|
||||
const hiddenCount = allContainers.length - containers.length;
|
||||
await log(`Found ${containers.length} containers${hiddenCount ? ` (${hiddenCount} hidden by label)` : ''}`);
|
||||
|
||||
const updatesAvailable: UpdateInfo[] = [];
|
||||
let checkedCount = 0;
|
||||
|
||||
@@ -5,10 +5,19 @@
|
||||
* All lifecycle operations use docker compose commands.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve, dirname, basename } from 'node:path';
|
||||
import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync, realpathSync } from 'node:fs';
|
||||
import { join, resolve, dirname, basename, normalize as pathNormalize, sep as pathSep } from 'node:path';
|
||||
import { spawn as nodeSpawn } from 'node:child_process';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import {
|
||||
applyFileDeletions,
|
||||
hashDirFiles,
|
||||
skipReasonMessage,
|
||||
normalizeSkipReason,
|
||||
type FileToDelete,
|
||||
type DeletionApplyResult,
|
||||
type DeletionSkipReason
|
||||
} from './git-deletions';
|
||||
import {
|
||||
getEnvironment,
|
||||
getSecretEnvVarsAsRecord,
|
||||
@@ -25,7 +34,9 @@ import {
|
||||
removePendingContainerUpdate,
|
||||
deleteAutoUpdateSchedule,
|
||||
getAutoUpdateSetting,
|
||||
getStackSourceByComposePath
|
||||
getStackSourceByComposePath,
|
||||
getExternalStackPaths,
|
||||
addExternalStackPath
|
||||
} from './db';
|
||||
import { unregisterSchedule } from './scheduler';
|
||||
import { deleteGitStackFiles, parseEnvFileContent } from './git';
|
||||
@@ -61,6 +72,8 @@ export interface StackOperationResult {
|
||||
error?: string;
|
||||
/** The docker compose command that was executed (for debugging/testing) */
|
||||
command?: string;
|
||||
/** Result of applying git deletion sync (files removed / kept, with reasons) */
|
||||
deletion?: DeletionApplyResult;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,6 +124,8 @@ export interface DeployStackOptions {
|
||||
envPath?: string; // Custom env file path (for adopted/imported stacks)
|
||||
composeFileName?: string; // Compose filename to use (e.g., "docker-compose.yaml") for git stacks
|
||||
envFileName?: string; // Env filename relative to compose dir (e.g., ".env") for git stacks
|
||||
/** Git deletion sync (#966): files confirmed safe to delete from the stack dir */
|
||||
filesToDelete?: FileToDelete[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -335,6 +350,125 @@ export async function getStackDir(stackName: string, envId?: number | null): Pro
|
||||
return join(stacksDir, stackName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filenames a stack is allowed to write. Compose files in the conventional
|
||||
* names + .env. Anything else is rejected even when the directory is in
|
||||
* the allowlist, so this code path can only ever produce stack-shaped files.
|
||||
*/
|
||||
const ALLOWED_STACK_FILENAMES = new Set([
|
||||
'docker-compose.yml',
|
||||
'docker-compose.yaml',
|
||||
'compose.yml',
|
||||
'compose.yaml',
|
||||
'.env'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Resolve a path against the parent's realpath when the parent exists, so
|
||||
* symlinks resolve to their canonical location. We can't realpath the leaf
|
||||
* because the file may not exist yet (new stack).
|
||||
*/
|
||||
function resolveStackPath(input: string): string {
|
||||
const abs = resolve(input);
|
||||
const parent = dirname(abs);
|
||||
try {
|
||||
if (existsSync(parent)) {
|
||||
return join(realpathSync(parent), basename(abs));
|
||||
}
|
||||
} catch {
|
||||
// realpath may fail on permission errors; fall through to the plain resolve.
|
||||
}
|
||||
return abs;
|
||||
}
|
||||
|
||||
function isInside(child: string, parent: string): boolean {
|
||||
const c = pathNormalize(child);
|
||||
const p = pathNormalize(parent);
|
||||
if (c === p) return true;
|
||||
return c.startsWith(p.endsWith(pathSep) ? p : p + pathSep);
|
||||
}
|
||||
|
||||
export interface StackPathValidation {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
resolved?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a custom compose or env file path is writable by this code
|
||||
* path. A path is accepted when it lives inside getStacksDir() or any
|
||||
* directory in the external_stack_paths allowlist (admin-controlled, set
|
||||
* in Settings → General), its filename is one of the conventional
|
||||
* compose/env names, and it does not contain .. segments. The parent is
|
||||
* resolved via realpath so a symlinked component inside an allowlisted dir
|
||||
* cannot point elsewhere.
|
||||
*
|
||||
* Callers that need to grandfather an existing admin-configured path should
|
||||
* call validateStackPathWithGrandfather() instead.
|
||||
*/
|
||||
export async function validateStackPath(input: string): Promise<StackPathValidation> {
|
||||
if (!input || typeof input !== 'string') {
|
||||
return { ok: false, error: 'Path is required' };
|
||||
}
|
||||
|
||||
const resolvedPath = resolveStackPath(input);
|
||||
|
||||
// Normalized form must not contain a .. segment.
|
||||
const segments = pathNormalize(resolvedPath).split(pathSep);
|
||||
if (segments.includes('..')) {
|
||||
return { ok: false, error: 'Path traversal not allowed' };
|
||||
}
|
||||
|
||||
const filename = basename(resolvedPath);
|
||||
if (!ALLOWED_STACK_FILENAMES.has(filename)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `File "${filename}" is not an allowed stack filename (expected one of: ${[...ALLOWED_STACK_FILENAMES].join(', ')})`
|
||||
};
|
||||
}
|
||||
|
||||
const stacksDir = getStacksDir();
|
||||
if (isInside(resolvedPath, stacksDir)) {
|
||||
return { ok: true, resolved: resolvedPath };
|
||||
}
|
||||
|
||||
const allowlist = await getExternalStackPaths();
|
||||
for (const dir of allowlist) {
|
||||
if (!dir) continue;
|
||||
const dirResolved = resolve(dir);
|
||||
if (isInside(resolvedPath, dirResolved)) {
|
||||
return { ok: true, resolved: resolvedPath };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: `Path "${resolvedPath}" is not inside an allowed stack directory. Add its parent directory in Settings → General → External stack paths.`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as validateStackPath, but when the path comes from an existing
|
||||
* stack source row (a stored custom path the admin set previously) and
|
||||
* fails the allowlist check, add its parent dir to external_stack_paths
|
||||
* and re-validate. Lets old custom-path configurations keep working
|
||||
* without operator intervention.
|
||||
*/
|
||||
export async function validateStackPathWithGrandfather(
|
||||
input: string,
|
||||
isPreExisting: boolean
|
||||
): Promise<StackPathValidation> {
|
||||
const first = await validateStackPath(input);
|
||||
if (first.ok || !isPreExisting) return first;
|
||||
|
||||
const parent = dirname(resolveStackPath(input));
|
||||
const added = await addExternalStackPath(parent);
|
||||
if (added) {
|
||||
console.log(`[Stack] Grandfathered pre-existing custom path: ${parent}`);
|
||||
}
|
||||
return validateStackPath(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find stack directory, checking paths in order:
|
||||
* 1. Database: Custom composePath in stackSources table (adopted/imported stacks)
|
||||
@@ -541,6 +675,31 @@ export async function saveStackComposeFile(
|
||||
const source = await getStackSource(name, envId);
|
||||
const composePath = options?.composePath || source?.composePath;
|
||||
|
||||
// Path-allowlist validation. Pre-existing stored paths grandfather into
|
||||
// the allowlist; new caller-supplied paths must already be inside it.
|
||||
// See validateStackPath() docs.
|
||||
if (composePath) {
|
||||
const fromDb = !options?.composePath && !!source?.composePath;
|
||||
const v = await validateStackPathWithGrandfather(composePath, fromDb);
|
||||
if (!v.ok) return { success: false, error: v.error };
|
||||
}
|
||||
if (options?.envPath) {
|
||||
const v = await validateStackPath(options.envPath);
|
||||
if (!v.ok) return { success: false, error: v.error };
|
||||
} else if (source?.envPath && !options?.envPath) {
|
||||
// Grandfather a DB-stored env path that may have been configured before validation.
|
||||
const v = await validateStackPathWithGrandfather(source.envPath, true);
|
||||
if (!v.ok) return { success: false, error: v.error };
|
||||
}
|
||||
if (options?.oldComposePath) {
|
||||
const v = await validateStackPathWithGrandfather(options.oldComposePath, true);
|
||||
if (!v.ok) return { success: false, error: v.error };
|
||||
}
|
||||
if (options?.oldEnvPath) {
|
||||
const v = await validateStackPathWithGrandfather(options.oldEnvPath, true);
|
||||
if (!v.ok) return { success: false, error: v.error };
|
||||
}
|
||||
|
||||
// Handle compose file move/rename when path changes
|
||||
if (options?.oldComposePath && options?.composePath &&
|
||||
options.oldComposePath !== options.composePath &&
|
||||
@@ -830,6 +989,10 @@ interface ComposeCommandOptions {
|
||||
serviceName?: string;
|
||||
/** Compose filename for Hawser (e.g., "docker-compose.prod.yml") - extracted from composePath */
|
||||
composeFileName?: string;
|
||||
/** Git deletion sync (#966): files to delete on the Hawser agent's stack dir */
|
||||
filesToDelete?: FileToDelete[];
|
||||
/** On down: ask the Hawser agent to remove the stack directory entirely (#1162, stack deletion only) */
|
||||
removeFiles?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -855,6 +1018,16 @@ function findComposeOverrideFile(stackDir: string, composeFileName: string): str
|
||||
/**
|
||||
* Execute a docker compose command locally via child_process.spawn.
|
||||
*
|
||||
* Heads up on paths: `stackDir` is the cpSync target / fallback working
|
||||
* directory, but it's not always where the compose file lives — git stacks
|
||||
* with a contextDir can put the compose file in a subdirectory. Anything
|
||||
* compose-adjacent (spawn cwd, .env discovery, compose.override.yaml
|
||||
* lookup, .env.dockhand write, volume-path rewriter) anchors on
|
||||
* `composeFileDir = dirname(composeFile)`. The two are equal for the
|
||||
* common case and the change is transparent; only the subdir case is
|
||||
* affected. If you add anything new that touches a compose-adjacent file,
|
||||
* use `composeFileDir`, not `stackDir`.
|
||||
*
|
||||
* @param tlsConfig - TLS configuration for remote Docker connections (certs written to temp files)
|
||||
* @param envVars - Non-secret environment variables (from .env file, passed for backward compat)
|
||||
* @param secretVars - Secret environment variables (injected via shell env, NEVER written to disk)
|
||||
@@ -907,13 +1080,22 @@ async function executeLocalCompose(
|
||||
writeFileSync(composeFile, composeContent);
|
||||
}
|
||||
|
||||
// Anchor for everything compose-adjacent: the directory the compose file
|
||||
// itself lives in. Equal to stackDir for the common case (compose at
|
||||
// stack root), but different when a git stack puts the compose file in
|
||||
// a subdirectory of the context dir. Bugs #1136 and #1139 both stemmed
|
||||
// from anchoring on stackDir instead of this.
|
||||
const composeFileDir = dirname(composeFile);
|
||||
|
||||
// Rewrite relative volume paths for host path translation (in memory only, not saved to disk)
|
||||
// This is needed when Dockhand runs inside Docker - the Docker daemon on the host
|
||||
// can't see container paths like /app/data/..., so we translate them to host paths
|
||||
// Only do this for local Docker (no dockerHost) - for remote Docker the paths wouldn't make sense
|
||||
// Resolve relative paths against the COMPOSE FILE'S directory, not stackDir, so
|
||||
// subdir compose files with ./ and ../ binds resolve correctly (#1139).
|
||||
let finalComposeContent = composeContent;
|
||||
if (!dockerHost && getHostDataDir()) {
|
||||
const rewriteResult = rewriteComposeVolumePaths(composeContent, stackDir);
|
||||
const rewriteResult = rewriteComposeVolumePaths(composeContent, composeFileDir);
|
||||
if (rewriteResult.modified) {
|
||||
finalComposeContent = rewriteResult.content;
|
||||
console.log(`${logPrefix} [HostPath] Translating relative volume paths for Docker host:`);
|
||||
@@ -951,9 +1133,25 @@ async function executeLocalCompose(
|
||||
}
|
||||
|
||||
// Check if .env file exists on disk (for legacy support decision)
|
||||
const defaultEnvPath = join(stackDir, '.env');
|
||||
const defaultEnvPath = join(composeFileDir, '.env');
|
||||
const hasEnvFile = existsSync(defaultEnvPath) || (customEnvPath && existsSync(customEnvPath));
|
||||
|
||||
// One-line audit of all path notions used below. Next time something is
|
||||
// off (compose can't find a file, volume bind points at the wrong
|
||||
// place, env vars don't reach the container), grep for "[PathAudit]"
|
||||
// in the log — the mismatch is usually obvious. The "subdir=yes" flag
|
||||
// is the canary for the case where stackDir and composeFileDir diverge.
|
||||
console.log(
|
||||
`${logPrefix} [PathAudit] ` +
|
||||
`stackDir=${stackDir} ` +
|
||||
`composeFile=${composeFile} ` +
|
||||
`composeFileDir=${composeFileDir} ` +
|
||||
`subdir=${composeFileDir !== stackDir ? 'yes' : 'no'} ` +
|
||||
`defaultEnvPath=${defaultEnvPath} (exists=${existsSync(defaultEnvPath)}) ` +
|
||||
`customEnvPath=${customEnvPath ?? '(none)'}` +
|
||||
(customEnvPath ? ` (exists=${existsSync(customEnvPath)})` : '')
|
||||
);
|
||||
|
||||
// LEGACY SUPPORT: Only inject envVars via shell if NO .env file exists
|
||||
// This is for stacks created with older Dockhand versions that stored env vars
|
||||
// in DB but didn't write .env files to disk.
|
||||
@@ -1015,14 +1213,14 @@ async function executeLocalCompose(
|
||||
// Host path translation: must pipe modified content via stdin
|
||||
args.push('-f', '-');
|
||||
// Also include override file if it exists (needs path translation too)
|
||||
const overrideFile = findComposeOverrideFile(stackDir, basename(composeFile));
|
||||
const overrideFile = findComposeOverrideFile(composeFileDir, basename(composeFile));
|
||||
if (overrideFile) {
|
||||
let overrideContent = readFileSync(overrideFile, 'utf-8');
|
||||
if (getHostDataDir()) {
|
||||
const rewrite = rewriteComposeVolumePaths(overrideContent, stackDir);
|
||||
const rewrite = rewriteComposeVolumePaths(overrideContent, composeFileDir);
|
||||
if (rewrite.modified) overrideContent = rewrite.content;
|
||||
}
|
||||
tempOverridePath = join(stackDir, '.compose.override.translated.yaml');
|
||||
tempOverridePath = join(composeFileDir, '.compose.override.translated.yaml');
|
||||
writeFileSync(tempOverridePath, overrideContent);
|
||||
args.push('-f', tempOverridePath);
|
||||
console.log(`${logPrefix} Including override file (path-translated): ${basename(overrideFile)}`);
|
||||
@@ -1030,7 +1228,7 @@ async function executeLocalCompose(
|
||||
} else if (customComposePath) {
|
||||
// Custom path (imported/adopted stacks): must use -f to point to non-standard location
|
||||
args.push('-f', composeFile);
|
||||
const overrideFile = findComposeOverrideFile(stackDir, basename(composeFile));
|
||||
const overrideFile = findComposeOverrideFile(composeFileDir, basename(composeFile));
|
||||
if (overrideFile) {
|
||||
args.push('-f', overrideFile);
|
||||
console.log(`${logPrefix} Including override file: ${basename(overrideFile)}`);
|
||||
@@ -1056,7 +1254,7 @@ async function executeLocalCompose(
|
||||
// Only written when useOverrideFile is true (git stacks). Internal/adopted stacks
|
||||
// already have their non-secrets in the .env file written by the UI.
|
||||
if (useOverrideFile && envVars && Object.keys(envVars).length > 0) {
|
||||
const overrideEnvPath = join(stackDir, '.env.dockhand');
|
||||
const overrideEnvPath = join(composeFileDir, '.env.dockhand');
|
||||
const header = '# Auto-generated by Dockhand. Do not edit - changes will be overwritten on next deploy.\n';
|
||||
const lines = Object.entries(envVars).map(([k, v]) => `${k}=${v}`);
|
||||
writeFileSync(overrideEnvPath, header + lines.join('\n') + '\n');
|
||||
@@ -1126,9 +1324,9 @@ async function executeLocalCompose(
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`${logPrefix} Spawning docker compose process...`);
|
||||
console.log(`${logPrefix} Spawning docker compose process from ${composeFileDir}: ${args.join(' ')}`);
|
||||
const proc = nodeSpawn(args[0], args.slice(1), {
|
||||
cwd: stackDir,
|
||||
cwd: composeFileDir,
|
||||
env: spawnEnv,
|
||||
stdio: [useStdin ? 'pipe' : 'inherit', 'pipe', 'pipe']
|
||||
});
|
||||
@@ -1250,7 +1448,9 @@ async function executeComposeViaHawser(
|
||||
composeFileName?: string,
|
||||
build?: boolean,
|
||||
noBuildCache?: boolean,
|
||||
pullPolicy?: string
|
||||
pullPolicy?: string,
|
||||
filesToDelete?: FileToDelete[],
|
||||
removeFiles?: boolean
|
||||
): Promise<StackOperationResult> {
|
||||
const logPrefix = `[Stack:${stackName}]`;
|
||||
// Import dockerFetch dynamically to avoid circular dependency
|
||||
@@ -1327,7 +1527,14 @@ async function executeComposeViaHawser(
|
||||
noBuildCache: (build && noBuildCache) || false,
|
||||
pullPolicy: pullPolicy || '',
|
||||
registries, // Registry credentials for docker login
|
||||
serviceName // Target specific service only (with --no-deps)
|
||||
serviceName, // Target specific service only (with --no-deps)
|
||||
// Git deletion sync (#966): agent re-verifies containment + content
|
||||
// hash per file before deleting. Old agents ignore this field.
|
||||
filesToDelete: filesToDelete && filesToDelete.length > 0
|
||||
? filesToDelete.map(f => ({ path: f.path, sha256: f.hash }))
|
||||
: undefined,
|
||||
// Stack deletion (#1162): remove the agent-side stack dir on down
|
||||
removeFiles: removeFiles || false
|
||||
});
|
||||
|
||||
console.log(`${logPrefix} Sending request to Hawser agent...`);
|
||||
@@ -1345,6 +1552,8 @@ async function executeComposeViaHawser(
|
||||
success: boolean;
|
||||
output?: string;
|
||||
error?: string;
|
||||
deletedFiles?: string[];
|
||||
skippedFiles?: { path: string; reason: string }[];
|
||||
};
|
||||
|
||||
console.log(`${logPrefix} ----------------------------------------`);
|
||||
@@ -1358,16 +1567,50 @@ async function executeComposeViaHawser(
|
||||
console.log(`${logPrefix} Error:`, result.error);
|
||||
}
|
||||
|
||||
// Git deletion sync: interpret the agent's report. An agent that supports
|
||||
// the feature always returns deletedFiles/skippedFiles (possibly empty
|
||||
// arrays) when filesToDelete was sent. An old agent ignores the field and
|
||||
// returns neither — every requested deletion is marked agent-no-support.
|
||||
// Skips are FINAL (no carry-forward, no retry): the files stay on the
|
||||
// remote host as unmanaged residue, identical to pre-feature behavior.
|
||||
let deletion: DeletionApplyResult | undefined;
|
||||
if (filesToDelete && filesToDelete.length > 0) {
|
||||
if (result.deletedFiles !== undefined || result.skippedFiles !== undefined) {
|
||||
deletion = {
|
||||
deleted: result.deletedFiles ?? [],
|
||||
skipped: (result.skippedFiles ?? []).map(s => ({
|
||||
path: s.path,
|
||||
reason: normalizeSkipReason(s.reason || 'apply-failed')
|
||||
}))
|
||||
};
|
||||
for (const path of deletion.deleted) {
|
||||
console.log(`${logPrefix} Agent removed "${path}" — deleted from the repository`);
|
||||
}
|
||||
for (const skip of deletion.skipped) {
|
||||
if (skip.reason === 'already-absent') continue;
|
||||
console.warn(`${logPrefix} Agent kept "${skip.path}" — ${skipReasonMessage(skip.reason)}`);
|
||||
}
|
||||
} else {
|
||||
deletion = {
|
||||
deleted: [],
|
||||
skipped: filesToDelete.map(f => ({ path: f.path, reason: 'agent-no-support' as DeletionSkipReason }))
|
||||
};
|
||||
console.warn(`${logPrefix} ${skipReasonMessage('agent-no-support')} (${filesToDelete.length} file(s) affected)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
output: result.output || `Stack "${stackName}" ${operation} completed via Hawser`
|
||||
output: result.output || `Stack "${stackName}" ${operation} completed via Hawser`,
|
||||
deletion
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
output: result.output || '',
|
||||
error: result.error || `Compose ${operation} failed`
|
||||
error: result.error || `Compose ${operation} failed`,
|
||||
deletion
|
||||
};
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -1396,7 +1639,7 @@ async function executeComposeCommand(
|
||||
envVars?: Record<string, string>,
|
||||
secretVars?: Record<string, string>
|
||||
): Promise<StackOperationResult> {
|
||||
const { stackName, envId, forceRecreate, build, noBuildCache, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName } = options;
|
||||
const { stackName, envId, forceRecreate, build, noBuildCache, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName, filesToDelete, removeFiles } = options;
|
||||
|
||||
// Get environment configuration
|
||||
const env = envId ? await getEnvironment(envId) : null;
|
||||
@@ -1486,7 +1729,9 @@ async function executeComposeCommand(
|
||||
composeFileName,
|
||||
build,
|
||||
noBuildCache,
|
||||
pullPolicy
|
||||
pullPolicy,
|
||||
filesToDelete,
|
||||
removeFiles
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1525,12 +1770,20 @@ async function executeComposeCommand(
|
||||
}
|
||||
|
||||
case 'socket':
|
||||
default:
|
||||
default: {
|
||||
// Honor the environment's configured socket path. Without this,
|
||||
// docker compose falls back to /var/run/docker.sock regardless of
|
||||
// the env's setting — wrong daemon for rootless/multi-socket hosts
|
||||
// (#1172). Default '/var/run/docker.sock' is left as undefined so
|
||||
// the CLI's own default applies (preserves existing behavior).
|
||||
const sock = env.socketPath && env.socketPath !== '/var/run/docker.sock'
|
||||
? `unix://${env.socketPath}`
|
||||
: undefined;
|
||||
return executeLocalCompose(
|
||||
operation,
|
||||
stackName,
|
||||
composeContent,
|
||||
undefined, // dockerHost
|
||||
sock,
|
||||
undefined, // tlsConfig
|
||||
envVars,
|
||||
secretVars,
|
||||
@@ -1546,6 +1799,7 @@ async function executeComposeCommand(
|
||||
noBuildCache,
|
||||
pullPolicy
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1906,7 +2160,11 @@ export async function startStack(
|
||||
return withContainerFallback(stackName, envId, 'start');
|
||||
}
|
||||
|
||||
const opts = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath };
|
||||
// Check if this is a git stack - git stacks need useOverrideFile to write .env.dockhand
|
||||
const source = await getStackSource(stackName, envId);
|
||||
const isGitStack = source?.sourceType === 'git';
|
||||
|
||||
const opts: ComposeCommandOptions = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack };
|
||||
|
||||
// Check if containers exist for this stack. If they do, use 'start' to resume
|
||||
// them (preserves container IDs, avoids Traefik race conditions from recreation).
|
||||
@@ -1974,7 +2232,13 @@ export async function restartStack(
|
||||
return withContainerFallback(stackName, envId, 'restart');
|
||||
}
|
||||
|
||||
const opts: ComposeCommandOptions = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath };
|
||||
// Git stacks need useOverrideFile to write .env.dockhand with DB overrides.
|
||||
// Non-git stacks still pass nonSecretVars for legacy support (stacks without
|
||||
// .env files on disk get vars injected via shell env at executeLocalCompose).
|
||||
const source = await getStackSource(stackName, envId);
|
||||
const isGitStack = source?.sourceType === 'git';
|
||||
|
||||
const opts: ComposeCommandOptions = { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath, useOverrideFile: isGitStack };
|
||||
|
||||
let composeResult: StackOperationResult;
|
||||
|
||||
@@ -2043,6 +2307,24 @@ export async function removeStack(
|
||||
if (composeResult.success) {
|
||||
const envVars = await getNonSecretEnvVarsAsRecord(stackName, envId);
|
||||
const secretVars = await getSecretEnvVarsAsRecord(stackName, envId);
|
||||
|
||||
// Stack removal cleanup (#1162): the agent deletes ONLY what Dockhand
|
||||
// explicitly lists. The list is the local staging dir contents — exactly
|
||||
// the files Dockhand ever wrote for this stack (compose, .env,
|
||||
// .env.dockhand, git files), never user volume data (that exists only on
|
||||
// the agent host). Each entry is hash-verified agent-side; the agent's
|
||||
// stack dir is removed only if nothing else remains in it.
|
||||
// Only built for Dockhand-managed staging dirs (inside DATA_DIR/stacks).
|
||||
let removalFiles: FileToDelete[] | undefined;
|
||||
if (composeResult.stackDir) {
|
||||
const resolvedStaging = resolve(composeResult.stackDir);
|
||||
if (resolvedStaging.startsWith(resolve(getStacksDir()) + '/')) {
|
||||
removalFiles = Object.entries(hashDirFiles(resolvedStaging)).map(
|
||||
([path, hash]) => ({ path, hash })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const downResult = await executeComposeCommand(
|
||||
'down',
|
||||
{
|
||||
@@ -2051,7 +2333,10 @@ export async function removeStack(
|
||||
removeVolumes,
|
||||
workingDir: composeResult.stackDir,
|
||||
composePath: composeResult.composePath ?? undefined,
|
||||
envPath: composeResult.envPath ?? undefined
|
||||
envPath: composeResult.envPath ?? undefined,
|
||||
// Full stack removal: the Hawser agent cleans its stack dir (#1162)
|
||||
removeFiles: true,
|
||||
filesToDelete: removalFiles
|
||||
},
|
||||
composeResult.content!,
|
||||
envVars,
|
||||
@@ -2222,7 +2507,7 @@ export async function removeStack(
|
||||
* Uses stack locking to prevent concurrent deployments.
|
||||
*/
|
||||
export async function deployStack(options: DeployStackOptions): Promise<StackOperationResult> {
|
||||
const { name, compose, envId, sourceDir, forceRecreate, build, noBuildCache, pullPolicy, composePath, envPath, composeFileName, envFileName } = options;
|
||||
const { name, compose, envId, sourceDir, forceRecreate, build, noBuildCache, pullPolicy, composePath, envPath, composeFileName, envFileName, filesToDelete } = options;
|
||||
const logPrefix = `[Stack:${name}]`;
|
||||
|
||||
console.log(`${logPrefix} ========================================`);
|
||||
@@ -2254,6 +2539,7 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
let actualComposePath: string | undefined;
|
||||
let actualEnvPath: string | undefined = envPath; // Start with provided envPath (for adopted stacks)
|
||||
let stackFiles: Record<string, string> | undefined;
|
||||
let localDeletionResult: DeletionApplyResult | undefined;
|
||||
|
||||
if (composePath) {
|
||||
// Adopted/imported stack: use the original compose file location
|
||||
@@ -2306,6 +2592,21 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
filter: (src) => !src.includes('/.git/') && !src.endsWith('/.git')
|
||||
});
|
||||
console.log(`${logPrefix} Copied ${sourceDir} -> ${workingDir}`);
|
||||
|
||||
// Git deletion sync (#966): remove files that were deleted from the
|
||||
// repository. The list is manifest entries absent from the new clone;
|
||||
// the applier re-verifies containment + content hash per file, so
|
||||
// volume data and locally modified files are never touched.
|
||||
if (filesToDelete && filesToDelete.length > 0) {
|
||||
localDeletionResult = applyFileDeletions(workingDir, filesToDelete);
|
||||
for (const path of localDeletionResult.deleted) {
|
||||
console.log(`${logPrefix} Removed "${path}" — deleted from the repository`);
|
||||
}
|
||||
for (const skip of localDeletionResult.skipped) {
|
||||
if (skip.reason === 'already-absent') continue;
|
||||
console.warn(`${logPrefix} Kept "${skip.path}" — ${skipReasonMessage(skip.reason)}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Internal stack: check if a custom path exists in DB (adopted/imported stacks)
|
||||
const source = await getStackSource(name, envId);
|
||||
@@ -2377,7 +2678,8 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
envPath: actualEnvPath,
|
||||
useOverrideFile: isGitStack,
|
||||
// Pass compose filename for Hawser (extracted from path or provided explicitly)
|
||||
composeFileName: composeFileName || (actualComposePath ? basename(actualComposePath) : undefined)
|
||||
composeFileName: composeFileName || (actualComposePath ? basename(actualComposePath) : undefined),
|
||||
filesToDelete
|
||||
},
|
||||
compose,
|
||||
isGitStack ? dbNonSecretVars : undefined,
|
||||
@@ -2393,6 +2695,11 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
|
||||
if (result.error) {
|
||||
console.log(`${logPrefix} Error:`, result.error);
|
||||
}
|
||||
// Deletion result: the remote (Hawser) result is authoritative when present;
|
||||
// for local deployments the local applier's result is the truth.
|
||||
if (!result.deletion && localDeletionResult) {
|
||||
result.deletion = localDeletionResult;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
@@ -2539,6 +2846,10 @@ export async function writeStackEnvFile(
|
||||
envId?: number | null,
|
||||
customEnvPath?: string
|
||||
): Promise<void> {
|
||||
if (customEnvPath) {
|
||||
const v = await validateStackPath(customEnvPath);
|
||||
if (!v.ok) throw new Error(v.error || 'Invalid env path');
|
||||
}
|
||||
let envFilePath: string;
|
||||
if (customEnvPath) {
|
||||
envFilePath = customEnvPath;
|
||||
@@ -2584,6 +2895,10 @@ export async function writeRawStackEnvFile(
|
||||
envId?: number | null,
|
||||
customEnvPath?: string
|
||||
): Promise<void> {
|
||||
if (customEnvPath) {
|
||||
const v = await validateStackPath(customEnvPath);
|
||||
if (!v.ok) throw new Error(v.error || 'Invalid env path');
|
||||
}
|
||||
let envFilePath: string;
|
||||
if (customEnvPath) {
|
||||
envFilePath = customEnvPath;
|
||||
|
||||
@@ -98,6 +98,35 @@ const envNames: Map<number, string> = new Map();
|
||||
// Track which envIds are currently configured in Go
|
||||
const configuredEnvs: Set<number> = new Set();
|
||||
|
||||
// Health status transition tracking: only store DB events when status changes
|
||||
// Key: `${envId}-${containerId}` → last known sub-status (e.g. "healthy", "unhealthy")
|
||||
const lastHealthStatus: Map<string, string> = new Map();
|
||||
|
||||
/**
|
||||
* Check if a health_status event represents a transition (and should be stored in DB).
|
||||
* Non-health events always return true. Repeated identical health statuses return false.
|
||||
* Also clears tracking on container destroy/die events.
|
||||
*/
|
||||
export function isHealthTransition(envId: number, containerId: string, action: string): boolean {
|
||||
// Clear tracking when container is removed
|
||||
if (action === 'destroy' || action === 'die') {
|
||||
lastHealthStatus.delete(`${envId}-${containerId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!action.startsWith('health_status')) return true;
|
||||
|
||||
// Extract sub-status: "health_status: healthy" → "healthy"
|
||||
const subStatus = action.includes(':') ? action.split(':').pop()!.trim() : action;
|
||||
const key = `${envId}-${containerId}`;
|
||||
const previous = lastHealthStatus.get(key);
|
||||
|
||||
if (previous === subStatus) return false; // Same status, skip DB write
|
||||
|
||||
lastHealthStatus.set(key, subStatus);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Dedup cleanup interval
|
||||
let dedupCleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
@@ -265,6 +294,12 @@ async function handleContainerEvent(msg: GoMessage): Promise<void> {
|
||||
|
||||
const timestamp = new Date(Math.floor(event.timeNano / 1000000)).toISOString();
|
||||
|
||||
// Skip redundant health_status events (only store transitions: healthy↔unhealthy)
|
||||
if (!isHealthTransition(msg.envId, containerId, action)) {
|
||||
rssAfterOp('events', before);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sub-category: DB insert
|
||||
const dbBefore = rssBeforeOp();
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Single-file tar extraction for raw downloads (#1180).
|
||||
*
|
||||
* Docker's /archive endpoint always wraps file contents in a USTAR tar.
|
||||
* When the user picks the "no archive" download format and the path is a
|
||||
* regular file, we strip the wrapper and emit the bytes verbatim.
|
||||
*
|
||||
* Only handles the first regular-file entry — the caller has already
|
||||
* guaranteed (via stat) that the requested path is a single file, so the
|
||||
* tar contains exactly one entry.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extract the bytes of the first regular file entry in a USTAR tar.
|
||||
* Returns the file content as a Uint8Array.
|
||||
*
|
||||
* Throws when no regular file entry is found (e.g. the tar contained only
|
||||
* a directory header) — that's an unexpected state, since the caller is
|
||||
* supposed to have already verified the path points to a file.
|
||||
*/
|
||||
export function extractFirstFileFromTar(tarData: Uint8Array): Uint8Array {
|
||||
let offset = 0;
|
||||
while (offset + 512 <= tarData.length) {
|
||||
const header = tarData.subarray(offset, offset + 512);
|
||||
|
||||
// Two consecutive zero blocks mark end-of-archive.
|
||||
if (isZeroBlock(header)) break;
|
||||
|
||||
const name = readString(header, 0, 100);
|
||||
const sizeOctal = readString(header, 124, 12).trim();
|
||||
const size = sizeOctal ? parseInt(sizeOctal, 8) : 0;
|
||||
const typeFlag = header[156];
|
||||
|
||||
// Regular file: typeflag '0' (0x30) or NUL (0x00, legacy)
|
||||
const isRegularFile = typeFlag === 0x30 || typeFlag === 0x00;
|
||||
|
||||
if (isRegularFile && name && size >= 0) {
|
||||
const start = offset + 512;
|
||||
const end = start + size;
|
||||
if (end > tarData.length) {
|
||||
throw new Error('Truncated tar archive');
|
||||
}
|
||||
return tarData.subarray(start, end);
|
||||
}
|
||||
|
||||
// Skip header + content (padded to 512-byte boundary)
|
||||
offset += 512 + Math.ceil(size / 512) * 512;
|
||||
}
|
||||
throw new Error('No regular file entry found in tar archive');
|
||||
}
|
||||
|
||||
function isZeroBlock(block: Uint8Array): boolean {
|
||||
for (let i = 0; i < block.length; i++) {
|
||||
if (block[i] !== 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readString(buf: Uint8Array, offset: number, length: number): string {
|
||||
let end = offset;
|
||||
const limit = offset + length;
|
||||
while (end < limit && buf[end] !== 0) end++;
|
||||
return new TextDecoder('utf-8').decode(buf.subarray(offset, end));
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { db } from './db/drizzle';
|
||||
import { asc } from 'drizzle-orm';
|
||||
|
||||
// Dynamic schema import (same pattern as db.ts)
|
||||
const isPostgres = !!process.env.DATABASE_URL;
|
||||
const schema = isPostgres
|
||||
? await import('./db/schema/pg-schema.js')
|
||||
: await import('./db/schema/index.js');
|
||||
|
||||
const { templateSources } = schema;
|
||||
|
||||
export interface TemplateSource {
|
||||
id: number;
|
||||
sourceId: string;
|
||||
name: string;
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
builtin: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_TEMPLATE_SOURCES: Omit<TemplateSource, 'id'>[] = [
|
||||
// Large collections
|
||||
{ sourceId: 'portainer-lissy93', name: 'Portainer templates (Lissy93)', url: 'https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json', enabled: true, builtin: true, sortOrder: 0 },
|
||||
{ sourceId: 'ntv-one', name: 'NTV-One (consolidated)', url: 'https://raw.githubusercontent.com/ntv-one/portainer/main/template.json', enabled: false, builtin: true, sortOrder: 1 },
|
||||
{ sourceId: 'mlva', name: 'MLVA (TheLustriVA)', url: 'https://raw.githubusercontent.com/TheLustriVA/portainer-templates-Nov-2022-collection/main/templates_2_2_rc_2_2.json', enabled: false, builtin: true, sortOrder: 2 },
|
||||
{ sourceId: 'selfhostedpro', name: 'SelfHostedPro', url: 'https://raw.githubusercontent.com/SelfhostedPro/selfhosted_templates/master/Template/portainer-v2.json', enabled: false, builtin: true, sortOrder: 3 },
|
||||
// Homelab / self-hosted
|
||||
{ sourceId: 'portainer-qballjos', name: 'Qballjos (homelab)', url: 'https://raw.githubusercontent.com/Qballjos/portainer_templates/master/Template/template.json', enabled: false, builtin: true, sortOrder: 4 },
|
||||
{ sourceId: 'lsio-technorabilia', name: 'LinuxServer.io (Technorabilia)', url: 'https://raw.githubusercontent.com/technorabilia/portainer-templates/main/lsio/templates/templates.json', enabled: true, builtin: true, sortOrder: 5 },
|
||||
{ sourceId: 'mikestraney', name: 'MikeStraney', url: 'https://raw.githubusercontent.com/mikestraney/portainer-templates/master/templates.json', enabled: false, builtin: true, sortOrder: 6 },
|
||||
// ARM / Raspberry Pi
|
||||
{ sourceId: 'pi-hosted-amd64', name: 'Pi-Hosted (amd64)', url: 'https://raw.githubusercontent.com/pi-hosted/pi-hosted/master/template/portainer-v2-amd64.json', enabled: false, builtin: true, sortOrder: 7 },
|
||||
{ sourceId: 'pi-hosted-arm64', name: 'Pi-Hosted (arm64)', url: 'https://raw.githubusercontent.com/pi-hosted/pi-hosted/master/template/portainer-v2-arm64.json', enabled: false, builtin: true, sortOrder: 8 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Seed default sources into the library_sources table if empty.
|
||||
*/
|
||||
export async function seedTemplateSources(): Promise<void> {
|
||||
const existing = await db.select().from(templateSources);
|
||||
if (existing.length > 0) return;
|
||||
|
||||
for (const source of DEFAULT_TEMPLATE_SOURCES) {
|
||||
await db.insert(templateSources).values({
|
||||
sourceId: source.sourceId,
|
||||
name: source.name,
|
||||
url: source.url,
|
||||
enabled: source.enabled,
|
||||
builtin: source.builtin,
|
||||
sortOrder: source.sortOrder,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTemplateSources(): Promise<TemplateSource[]> {
|
||||
const rows = await db.select().from(templateSources).orderBy(asc(templateSources.sortOrder));
|
||||
return rows.map(r => ({
|
||||
id: r.id,
|
||||
sourceId: r.sourceId,
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
enabled: r.enabled ?? true,
|
||||
builtin: r.builtin ?? false,
|
||||
sortOrder: r.sortOrder ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function updateTemplateSource(id: number, updates: { enabled?: boolean; name?: string; url?: string }): Promise<void> {
|
||||
const { eq } = await import('drizzle-orm');
|
||||
await db.update(templateSources)
|
||||
.set({ ...updates, updatedAt: new Date().toISOString() })
|
||||
.where(eq(templateSources.id, id));
|
||||
}
|
||||
|
||||
export async function addTemplateSource(source: { name: string; url: string }): Promise<TemplateSource> {
|
||||
const sourceId = `custom-${Date.now()}`;
|
||||
const maxOrder = await db.select().from(templateSources).orderBy(asc(templateSources.sortOrder));
|
||||
const nextOrder = maxOrder.length > 0 ? (maxOrder[maxOrder.length - 1].sortOrder ?? 0) + 1 : 0;
|
||||
|
||||
const result = await db.insert(templateSources).values({
|
||||
sourceId,
|
||||
name: source.name,
|
||||
url: source.url,
|
||||
enabled: true,
|
||||
builtin: false,
|
||||
sortOrder: nextOrder,
|
||||
}).returning();
|
||||
|
||||
const r = result[0];
|
||||
return {
|
||||
id: r.id,
|
||||
sourceId: r.sourceId,
|
||||
name: r.name,
|
||||
url: r.url,
|
||||
enabled: r.enabled ?? true,
|
||||
builtin: r.builtin ?? false,
|
||||
sortOrder: r.sortOrder ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteTemplateSource(id: number): Promise<void> {
|
||||
const { eq } = await import('drizzle-orm');
|
||||
await db.delete(templateSources).where(eq(templateSources.id, id));
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { validateSessionById, isAuthEnabled, SESSION_COOKIE } from './auth';
|
||||
import { validateApiToken } from './api-tokens';
|
||||
import { isEnterprise } from './license';
|
||||
import { userHasAdminRole, userCanAccessEnvironment } from './db';
|
||||
|
||||
export interface WsUpgradeAuth {
|
||||
userId: number;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
authDisabled: boolean;
|
||||
}
|
||||
|
||||
function parseCookieHeader(header: string | undefined): Record<string, string> {
|
||||
if (!header) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const part of header.split(';')) {
|
||||
const eq = part.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const k = part.slice(0, eq).trim();
|
||||
let v = part.slice(eq + 1).trim();
|
||||
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
||||
if (k) out[k] = decodeURIComponent(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type LowercasedHeaders = Record<string, string | string[] | undefined>;
|
||||
|
||||
function pickHeader(headers: LowercasedHeaders, name: string): string | undefined {
|
||||
const v = headers[name];
|
||||
if (Array.isArray(v)) return v[0];
|
||||
return v;
|
||||
}
|
||||
|
||||
export async function authenticateWsUpgrade(
|
||||
headers: LowercasedHeaders
|
||||
): Promise<WsUpgradeAuth | null> {
|
||||
const authEnabled = await isAuthEnabled();
|
||||
if (!authEnabled) {
|
||||
return { userId: -1, username: '__bootstrap__', isAdmin: true, authDisabled: true };
|
||||
}
|
||||
|
||||
const cookieHeader = pickHeader(headers, 'cookie');
|
||||
const cookies = parseCookieHeader(cookieHeader);
|
||||
const sessionId = cookies[SESSION_COOKIE];
|
||||
|
||||
if (sessionId) {
|
||||
const user = await validateSessionById(sessionId);
|
||||
if (user) {
|
||||
const enterprise = await isEnterprise();
|
||||
const isAdmin = enterprise ? await userHasAdminRole(user.id) : true;
|
||||
return { userId: user.id, username: user.username, isAdmin, authDisabled: false };
|
||||
}
|
||||
}
|
||||
|
||||
const authHeader = pickHeader(headers, 'authorization');
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.slice(7).trim();
|
||||
const user = await validateApiToken(token);
|
||||
if (user) {
|
||||
const enterprise = await isEnterprise();
|
||||
const isAdmin = enterprise ? await userHasAdminRole(user.id) : true;
|
||||
return { userId: user.id, username: user.username, isAdmin, authDisabled: false };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function canAccessEnvForUser(
|
||||
auth: WsUpgradeAuth,
|
||||
environmentId: number | undefined | null
|
||||
): Promise<boolean> {
|
||||
if (auth.authDisabled) return true;
|
||||
if (auth.isAdmin) return true;
|
||||
const enterprise = await isEnterprise();
|
||||
if (!enterprise) return true;
|
||||
if (environmentId == null) {
|
||||
return false;
|
||||
}
|
||||
return userCanAccessEnvironment(auth.userId, environmentId);
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __authenticateWsUpgrade:
|
||||
| ((headers: LowercasedHeaders) => Promise<WsUpgradeAuth | null>)
|
||||
| undefined;
|
||||
// eslint-disable-next-line no-var
|
||||
var __canAccessEnvForUser:
|
||||
| ((auth: WsUpgradeAuth, envId: number | undefined | null) => Promise<boolean>)
|
||||
| undefined;
|
||||
}
|
||||
|
||||
globalThis.__authenticateWsUpgrade = authenticateWsUpgrade;
|
||||
globalThis.__canAccessEnvForUser = canAccessEnvForUser;
|
||||
@@ -18,6 +18,7 @@ export interface Permissions {
|
||||
audit_logs: string[];
|
||||
activity: string[];
|
||||
schedules: string[];
|
||||
templates: string[];
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { browser } from '$app/environment';
|
||||
|
||||
export type TimeFormat = '12h' | '24h';
|
||||
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
|
||||
export type DownloadFormat = 'tar' | 'tar.gz';
|
||||
export type DownloadFormat = 'tar' | 'tar.gz' | 'raw';
|
||||
export type EventCollectionMode = 'stream' | 'poll';
|
||||
export type LabelFilterMode = 'any' | 'all';
|
||||
|
||||
@@ -24,7 +24,8 @@ export interface AppSettings {
|
||||
eventCleanupEnabled: boolean;
|
||||
scannerCleanupCron: string;
|
||||
scannerCleanupEnabled: boolean;
|
||||
logBufferSizeKb: number;
|
||||
logBufferSizeKb: number; // legacy, retained for migration — UI uses logMaxLines
|
||||
logMaxLines: number; // line-count cap for the log buffer (replaces KB-based limit)
|
||||
defaultTimezone: string;
|
||||
eventCollectionMode: EventCollectionMode;
|
||||
eventPollInterval: number;
|
||||
@@ -38,6 +39,8 @@ export interface AppSettings {
|
||||
defaultTrivyImage: string;
|
||||
defaultComposeTemplate: string;
|
||||
labelFilterMode: LabelFilterMode;
|
||||
honorProxyLabels: boolean;
|
||||
showImageChangelogLinks: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
@@ -58,6 +61,7 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
scannerCleanupCron: '0 3 * * 0',
|
||||
scannerCleanupEnabled: true,
|
||||
logBufferSizeKb: 500,
|
||||
logMaxLines: 2000,
|
||||
defaultTimezone: 'UTC',
|
||||
eventCollectionMode: 'stream',
|
||||
eventPollInterval: 60000,
|
||||
@@ -70,6 +74,8 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
defaultGrypeImage: 'anchore/grype:v0.110.0',
|
||||
defaultTrivyImage: 'aquasec/trivy:0.69.3',
|
||||
labelFilterMode: 'any',
|
||||
honorProxyLabels: true,
|
||||
showImageChangelogLinks: true,
|
||||
defaultComposeTemplate: `version: "3.8"
|
||||
|
||||
services:
|
||||
@@ -90,6 +96,20 @@ services:
|
||||
`
|
||||
};
|
||||
|
||||
// Derive logMaxLines from a settings payload — prefers the new field; if absent
|
||||
// (older DB), converts the legacy KB value at ~8 lines/KB (Docker log lines
|
||||
// average ~120 chars). Clamps to a sensible range.
|
||||
function deriveLogMaxLines(s: { logMaxLines?: number; logBufferSizeKb?: number }): number {
|
||||
// Cap at 2000 — anything larger thrashes the browser when rendering with no
|
||||
// virtualization. Old DBs may have absurd values from when the cap was 50K;
|
||||
// snap them down here so users don't get a stuck page after upgrade.
|
||||
if (typeof s.logMaxLines === 'number' && s.logMaxLines > 0) {
|
||||
return Math.min(2000, Math.max(100, s.logMaxLines));
|
||||
}
|
||||
const kb = s.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb;
|
||||
return Math.min(2000, Math.max(100, Math.round(kb * 8)));
|
||||
}
|
||||
|
||||
// Create a writable store for app settings
|
||||
function createSettingsStore() {
|
||||
const { subscribe, set, update } = writable<AppSettings>(DEFAULT_SETTINGS);
|
||||
@@ -122,6 +142,7 @@ function createSettingsStore() {
|
||||
scannerCleanupCron: settings.scannerCleanupCron ?? DEFAULT_SETTINGS.scannerCleanupCron,
|
||||
scannerCleanupEnabled: settings.scannerCleanupEnabled ?? DEFAULT_SETTINGS.scannerCleanupEnabled,
|
||||
logBufferSizeKb: settings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
|
||||
logMaxLines: deriveLogMaxLines(settings),
|
||||
defaultTimezone: settings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
|
||||
eventCollectionMode: settings.eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode,
|
||||
eventPollInterval: settings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval,
|
||||
@@ -134,7 +155,9 @@ function createSettingsStore() {
|
||||
defaultGrypeImage: settings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
|
||||
defaultTrivyImage: settings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage,
|
||||
defaultComposeTemplate: settings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
||||
labelFilterMode: settings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode
|
||||
labelFilterMode: settings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
|
||||
honorProxyLabels: settings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
|
||||
showImageChangelogLinks: settings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
@@ -172,6 +195,7 @@ function createSettingsStore() {
|
||||
scannerCleanupCron: updatedSettings.scannerCleanupCron ?? DEFAULT_SETTINGS.scannerCleanupCron,
|
||||
scannerCleanupEnabled: updatedSettings.scannerCleanupEnabled ?? DEFAULT_SETTINGS.scannerCleanupEnabled,
|
||||
logBufferSizeKb: updatedSettings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
|
||||
logMaxLines: deriveLogMaxLines(updatedSettings),
|
||||
defaultTimezone: updatedSettings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
|
||||
eventCollectionMode: updatedSettings.eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode,
|
||||
eventPollInterval: updatedSettings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval,
|
||||
@@ -184,7 +208,9 @@ function createSettingsStore() {
|
||||
defaultGrypeImage: updatedSettings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
|
||||
defaultTrivyImage: updatedSettings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage,
|
||||
defaultComposeTemplate: updatedSettings.defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
|
||||
labelFilterMode: updatedSettings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode
|
||||
labelFilterMode: updatedSettings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
|
||||
honorProxyLabels: updatedSettings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
|
||||
showImageChangelogLinks: updatedSettings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -330,6 +356,13 @@ function createSettingsStore() {
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setLogMaxLines: (value: number) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, logMaxLines: value };
|
||||
saveSettings({ logMaxLines: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setDefaultTimezone: (value: string) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, defaultTimezone: value };
|
||||
@@ -421,6 +454,20 @@ function createSettingsStore() {
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setHonorProxyLabels: (value: boolean) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, honorProxyLabels: value };
|
||||
saveSettings({ honorProxyLabels: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setShowImageChangelogLinks: (value: boolean) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, showImageChangelogLinks: value };
|
||||
saveSettings({ showImageChangelogLinks: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
// Manual refresh from database
|
||||
refresh: () => {
|
||||
initialized = false;
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface ThemePreferences {
|
||||
gridFontSize: FontSize;
|
||||
terminalFont: string;
|
||||
editorFont: string;
|
||||
animateIcons: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'dockhand-theme';
|
||||
@@ -31,7 +32,8 @@ const defaultPrefs: ThemePreferences = {
|
||||
fontSize: 'normal',
|
||||
gridFontSize: 'normal',
|
||||
terminalFont: 'system-mono',
|
||||
editorFont: 'system-mono'
|
||||
editorFont: 'system-mono',
|
||||
animateIcons: true
|
||||
};
|
||||
|
||||
// Font size scale mapping
|
||||
@@ -100,7 +102,12 @@ function createThemeStore() {
|
||||
fontSize: data.fontSize || data.font_size || 'normal',
|
||||
gridFontSize: data.gridFontSize || data.grid_font_size || 'normal',
|
||||
terminalFont: data.terminalFont || data.terminal_font || 'system-mono',
|
||||
editorFont: data.editorFont || data.editor_font || 'system-mono'
|
||||
editorFont: data.editorFont || data.editor_font || 'system-mono',
|
||||
// Default ON (#1169)
|
||||
animateIcons:
|
||||
data.animateIcons === undefined && data.animate_icons === undefined
|
||||
? true
|
||||
: !!(data.animateIcons ?? data.animate_icons)
|
||||
};
|
||||
set(prefs);
|
||||
saveToStorage(prefs);
|
||||
@@ -198,6 +205,9 @@ export function applyTheme(prefs: ThemePreferences) {
|
||||
|
||||
// Apply editor font
|
||||
applyEditorFont(prefs.editorFont);
|
||||
|
||||
// Apply icon animation toggle (#1169) — single class on <html> drives a CSS rule in app.css
|
||||
document.documentElement.classList.toggle('no-icon-animation', !prefs.animateIcons);
|
||||
}
|
||||
|
||||
// Apply font to document
|
||||
|
||||
@@ -65,6 +65,10 @@ export interface VolumeInfo {
|
||||
createdAt?: string;
|
||||
created: string; // Alias for createdAt, populated by API
|
||||
usedBy?: VolumeUsage[]; // Containers using this volume
|
||||
// driver_opts from the underlying volume — present for non-trivially
|
||||
// configured volumes (NFS, CIFS, BTRFS subvolumes, etc.). The 'type'
|
||||
// key here is what the volumes list surfaces as the Type column.
|
||||
options?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface NetworkInfo {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
export type ChangelogToken =
|
||||
| { kind: 'text'; value: string }
|
||||
| { kind: 'issue'; num: number }
|
||||
| { kind: 'pr'; num: number }
|
||||
| { kind: 'user'; name: string };
|
||||
|
||||
const PATTERN = /PR#(\d+)|#(\d+)|@([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,38}))/g;
|
||||
|
||||
export function parseChangelogTokens(text: string): ChangelogToken[] {
|
||||
const tokens: ChangelogToken[] = [];
|
||||
let lastIndex = 0;
|
||||
for (const match of text.matchAll(PATTERN)) {
|
||||
const start = match.index ?? 0;
|
||||
if (start > lastIndex) {
|
||||
tokens.push({ kind: 'text', value: text.slice(lastIndex, start) });
|
||||
}
|
||||
if (match[1]) {
|
||||
tokens.push({ kind: 'pr', num: Number(match[1]) });
|
||||
} else if (match[2]) {
|
||||
tokens.push({ kind: 'issue', num: Number(match[2]) });
|
||||
} else if (match[3]) {
|
||||
tokens.push({ kind: 'user', name: match[3] });
|
||||
}
|
||||
lastIndex = start + match[0].length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
tokens.push({ kind: 'text', value: text.slice(lastIndex) });
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export const GITHUB_REPO = 'Finsys/dockhand';
|
||||
|
||||
export function tokenHref(token: ChangelogToken): string | null {
|
||||
switch (token.kind) {
|
||||
case 'issue':
|
||||
return `https://github.com/${GITHUB_REPO}/issues/${token.num}`;
|
||||
case 'pr':
|
||||
return `https://github.com/${GITHUB_REPO}/pull/${token.num}`;
|
||||
case 'user':
|
||||
return `https://github.com/${token.name}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Resolve a changelog / release-notes URL for a container image (#538).
|
||||
*
|
||||
* Three tiers in priority order:
|
||||
* 1. `dockhand.changelog.url` label — explicit override set by the image
|
||||
* author or at runtime via `--label dockhand.changelog.url=…`. Wins over
|
||||
* everything. Pattern matches the existing dockhand.* label convention.
|
||||
* 2. `org.opencontainers.image.source` label — the OCI standard. When it
|
||||
* points at github.com the canonical changelog page is `<source>/releases`.
|
||||
* 3. GHCR images — `ghcr.io/<owner>/<repo>` is always the same as
|
||||
* `github.com/<owner>/<repo>`, so the release page is deterministic.
|
||||
*
|
||||
* No tier 3-style fuzzy match against Docker Hub. Wrong-repo matches are a
|
||||
* worse UX than no link, and there is no good answer for unlabelled
|
||||
* upstream images like `nginx:latest` (nginx's changelog isn't on GitHub).
|
||||
*
|
||||
* Pure function: deterministic, no I/O, safe to call from a render loop.
|
||||
*/
|
||||
|
||||
const GITHUB_HOST = 'github.com';
|
||||
const GHCR_PREFIX = 'ghcr.io/';
|
||||
|
||||
function stripTrailingSlash(s: string): string {
|
||||
return s.endsWith('/') ? s.slice(0, -1) : s;
|
||||
}
|
||||
|
||||
function stripImageTag(image: string): string {
|
||||
// "image@sha256:…" — split on @ first so we don't lose the digest fragment.
|
||||
const atIdx = image.indexOf('@');
|
||||
const withoutDigest = atIdx >= 0 ? image.slice(0, atIdx) : image;
|
||||
const colonIdx = withoutDigest.lastIndexOf(':');
|
||||
// A colon before a slash is a port in a registry hostname, not a tag.
|
||||
if (colonIdx > withoutDigest.lastIndexOf('/')) {
|
||||
return withoutDigest.slice(0, colonIdx);
|
||||
}
|
||||
return withoutDigest;
|
||||
}
|
||||
|
||||
export function resolveChangelogUrl(
|
||||
imageName: string | null | undefined,
|
||||
labels?: Record<string, string> | null
|
||||
): string | null {
|
||||
if (!imageName) return null;
|
||||
|
||||
const override = labels?.['dockhand.changelog.url'];
|
||||
if (override && override.trim()) return override.trim();
|
||||
|
||||
const source = labels?.['org.opencontainers.image.source'];
|
||||
if (source && source.includes(GITHUB_HOST)) {
|
||||
return stripTrailingSlash(source) + '/releases';
|
||||
}
|
||||
|
||||
if (imageName.startsWith(GHCR_PREFIX)) {
|
||||
const repo = stripImageTag(imageName.slice(GHCR_PREFIX.length));
|
||||
// Sanity guard: GHCR images are always owner/repo. Single-segment values
|
||||
// like `ghcr.io/something` are malformed; skip rather than emit a bad URL.
|
||||
if (repo.split('/').length >= 2) {
|
||||
return `https://github.com/${repo}/releases`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||