Skip to content

Commit 1d79787

Browse files
authored
Merge pull request #92 from coderbuzz/fix/wave5-misc
fix: wave 5 — base path, backup, stats, assets/CSP, login throttle, cleanup
2 parents 9d12608 + b4ddbd2 commit 1d79787

38 files changed

Lines changed: 764 additions & 611 deletions

‎README.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ Create a `.env` file in the project root or set these environment variables:
116116
| `DOCKIFY_DEV_MOCK` | `false` | No | Enable mock SSH for local development (no real VMs needed) |
117117
| `CLOUDFLARE_API_TOKEN` | — | No | Cloudflare API token for automatic DNS A records |
118118
| `CLOUDFLARE_ZONE_ID` | — | No | Cloudflare zone ID |
119-
| `DOCKIFY_BASE_PATH` | — | No | URL prefix when behind a reverse proxy (e.g. `/proxy/9898`) |
119+
| `DOCKIFY_BASE_PATH` | — | No | URL prefix when behind a reverse proxy (e.g. `/proxy/9898`); links and redirects stay under it. A proxy can send `X-Forwarded-Prefix` per request instead |
120+
| `DOCKIFY_TRUST_PROXY` | `false` | No | `true` takes the client IP from `X-Forwarded-For`/`X-Real-IP` (login throttle, logs). Set it only when every request comes through a proxy that sets these headers |
120121

121122
### Authentication
122123

@@ -134,6 +135,7 @@ DOCKIFY_ADMIN_PASSWORD=secret123 docker compose up -d
134135
- **Default username:** `admin` (configurable via `DOCKIFY_ADMIN_USER`)
135136
- **No password set:** Dockify refuses to start unless it binds to loopback (`DOCKIFY_HOST=127.0.0.1`, `::1` or `localhost`) or `DOCKIFY_ALLOW_NO_AUTH=true` is set; in both cases the web UI has **no authentication**
136137
- **Password set:** All routes (except `/health` and `/api/webhook/*`) require login
138+
- **Failed logins:** each one waits 1 s; more than 10 from one client IP within 15 minutes get `429` until the 15 minutes are over. The client IP is the TCP peer unless `DOCKIFY_TRUST_PROXY=true`. Behind a reverse proxy (e.g. Caddy in front of `127.0.0.1:8080`) set `DOCKIFY_TRUST_PROXY=true` and keep the port unreachable from outside; otherwise every client counts as the proxy and failed logins from anyone lock everyone out for 15 minutes
137139
- **Session:** Cookie-based (`HttpOnly`, `SameSite=Lax`, `Secure` behind HTTPS), expires after 24 hours
138140
- **Cross-site requests:** a form or script on another site cannot change anything (`403`). Behind a reverse proxy, forward the original `Host` header (Caddy does this by default)
139141
- **Logout:** Visit `/logout`
@@ -467,7 +469,7 @@ No DNS automation occurs if the env vars are not set.
467469

468470
## Backup & Restore
469471

470-
Export your configuration (servers + apps + secrets + config files) as YAML and import it into another instance.
472+
Export your configuration (servers + apps with their resource limits, command, ports and every domain + secrets + config files) as YAML and import it into another instance.
471473

472474
### Encrypted Export
473475

@@ -487,7 +489,7 @@ When importing on a new instance, all servers and their SSH keys are restored au
487489
Upload a previously exported YAML file via the Import page (`/import`):
488490

489491
- **merge** (default): skip entries that already exist by name
490-
- **replace**: delete all existing servers and apps before import
492+
- **replace**: delete all existing servers and apps before import; refused unless every app is stopped, a draft or never deployed (stop or undeploy running apps first)
491493
- Encrypted values are validated by attempting decryption before the actual import
492494

493495
### Unified Backup Page
@@ -512,6 +514,7 @@ Dockify's web UI is built with Go `html/template` + HTMX and fully custom CSS (n
512514

513515
- **Dark/Light mode** — toggle via nav button, persisted in `localStorage` (`dockify-theme`)
514516
- **HTMX partial updates** — resource cards, log viewer, and status badges update without full page reloads
517+
- **No CDN scripts** — htmx and xterm.js are embedded in the binary and served from `/static/`; a Content-Security-Policy limits scripts to Dockify itself (only Google Fonts is loaded from outside)
515518
- **Responsive** — adapts to mobile screen sizes (single breakpoint at 600px)
516519
- **Status badges** — color-coded for all server and app states
517520
- **Confirm dialogs** — destructive actions (undeploy, delete, rollback) require confirmation

‎SPEC.md‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Dockify is a self-hosted Docker app deployment platform — single Go binary, SQ
1212
| Database | SQLite via `modernc.org/sqlite` | Pure Go, no CGo, embedded |
1313
| Router | `github.com/go-chi/chi/v5` | Middleware, groups, path params |
1414
| SSH | `golang.org/x/crypto/ssh` | Std SSH client, PTY sessions |
15-
| Web UI | Go `html/template` + HTMX | Embedded via `embed`, no JS framework, no build step |
15+
| Web UI | Go `html/template` + HTMX | Embedded via `embed` (templates and the vendored htmx/xterm files, no CDN), no JS framework, no build step |
1616
| CSS | Fully custom (no framework) | CSS variables, dark/light mode, single `<style>` block |
1717
| Terminal | xterm.js + WebSocket | Interactive SSH console in browser |
1818
| Docker | Multi-stage build | Published to `ghcr.io/coderbuzz/dockify` |
@@ -43,7 +43,7 @@ internal/
4343
handler.go # JSON API + web UI handlers (apps, deployments, secrets, files, logs, stats)
4444
scheduler/scheduler.go # Auto-select least-loaded server
4545
caddy/client.go # Caddy Admin API client: route CRUD and config save via `docker exec caddy curl` over SSH
46-
cloudflare/client.go # Cloudflare DNS API v4 (list, create, upsert records)
46+
cloudflare/client.go # Cloudflare DNS API v4 (list, create, update, delete records)
4747
webhook/handler.go # GitHub + GitLab webhook receiver, HMAC/token validation
4848
settings/
4949
settings.go # Webhook secret, GitHub Releases update check, systemd-run self-update
@@ -57,7 +57,8 @@ internal/
5757
console.go # WebSocket upgrade, PTY relay for SSH and container consoles
5858
stats_ws.go # WebSocket real-time server stats (1s interval)
5959
app_stats_ws.go # WebSocket real-time app container stats
60-
templates.go # Embedded templates, template funcs, Render
60+
templates.go # Embedded templates, template funcs, Render (into a buffer, then write)
61+
static/ # Vendored htmx 2.0.4, xterm 5.3.0, xterm-addon-fit 0.8.0 (SOURCES.md: URLs + sha256)
6162
templates/ # HTML templates (pages, layout, partials)
6263
layout.html # head/nav/scripts blocks, all CSS (single <style> block)
6364
dashboard.html # Dashboard with stats cards + server/app summary tables
@@ -106,7 +107,8 @@ docker-compose.yml # Dockify + Caddy reverse proxy (mode 1)
106107
| `DOCKIFY_ALLOW_NO_AUTH` | `false` | No | Allow a passwordless UI on a non-loopback bind |
107108
| `DOCKIFY_API_TOKEN` | — | No | `Authorization: Bearer` token accepted on all protected routes (scripts/agents) |
108109
| `DOCKIFY_DEV_MOCK` | `false` | No | Enable mock SSH client for local development |
109-
| `DOCKIFY_BASE_PATH` | — | No | URL prefix when behind reverse proxy (e.g. `/proxy/9898`) |
110+
| `DOCKIFY_BASE_PATH` | — | No | URL prefix when behind reverse proxy (e.g. `/proxy/9898`); `X-Forwarded-Prefix` overrides it per request. Templates use links relative to `<base href>`, redirects go through `web.Redirect`, which prepends the prefix |
111+
| `DOCKIFY_TRUST_PROXY` | `false` | No | Apply `chimw.RealIP` (client IP from `X-Forwarded-For`/`X-Real-IP`); off = TCP peer address |
110112
| `CLOUDFLARE_API_TOKEN` | — | No | Cloudflare API token for DNS automation (Zone:DNS:Edit scope) |
111113
| `CLOUDFLARE_ZONE_ID` | — | No | Cloudflare zone ID for the domain |
112114

@@ -254,7 +256,7 @@ Written by the background collectors, pruned after 7 days, `created_at` in UTC.
254256
| POST | `/logout` | Clear session, redirect to login |
255257
| POST | `/api/webhook/github` | GitHub push webhook (HMAC-SHA256), body ≤ 1 MiB |
256258
| POST | `/api/webhook/gitlab` | GitLab push webhook (token), body ≤ 1 MiB |
257-
| GET | `/static/*` | Static files (`web/static/`) |
259+
| GET | `/static/*` | Vendored htmx/xterm assets embedded in the binary (`internal/http/static/`, sources in `SOURCES.md`); one-week cache, no directory listing |
258260

259261
Protected routes accept the session cookie or `Authorization: Bearer $DOCKIFY_API_TOKEN`. A POST/PATCH/DELETE sent by another site (`Sec-Fetch-Site`, or `Origin` ≠ `Host` for older browsers) gets `403`; bearer-token requests are exempt. WebSocket handshakes must be same-origin. Request bodies under `/api/servers` and `/api/apps` are capped at 1 MiB.
260262

@@ -313,7 +315,7 @@ Protected routes accept the session cookie or `Authorization: Bearer $DOCKIFY_AP
313315
| DELETE | `/api/apps/:id/secrets/:key` | Delete secret |
314316
| GET | `/api/apps/:id/files` | List config files |
315317
| POST | `/api/apps/:id/files` | Set config file `{path, content}`; path validated (see Input validation) |
316-
| DELETE | `/api/apps/:id/files/:path` | Delete config file |
318+
| DELETE | `/api/apps/:id/files/*path` | Delete config file (nested paths like `conf/app.ini` included) |
317319
| GET | `/api/apps/:id/console` | WebSocket upgrade → shell in the app's first service container |
318320
| GET | `/api/apps/:id/stats` | Latest container stats |
319321
| GET | `/api/apps/:id/stats/history` | Container stats history (`?range=`, default `1h`) |
@@ -421,7 +423,7 @@ Init is idempotent — re-running skips components that already exist (Caddy con
421423
### Export
422424

423425
1. Query all servers (name, host, port, user, SSH key, registry host/user/token)
424-
2. Query all apps (name, domain, port, compose, git_repo, git_branch, auth_user, auth_pass, compose_mode, server name mapping)
426+
2. Query all apps (name, domain, port, compose, git_repo, git_branch, auth_user, auth_pass, compose_mode, memory_limit, cpu_limit, log_max_size, log_max_file, command, ports, ulimits_nofile, server name mapping) and `domains`: every active route's domain, the primary first
425427
3. Query all app secrets and config files
426428
4. If passphrase provided: encrypt SSH keys, registry tokens, secrets, auth_pass, and file contents with AES-GCM (PBKDF2 key derivation, 600,000 iterations, `enc:` prefix)
427429
5. Generate YAML document
@@ -432,9 +434,9 @@ Init is idempotent — re-running skips components that already exist (Caddy con
432434
1. Parse YAML file
433435
2. If passphrase provided: attempt decryption of all `enc:` values to validate passphrase (including SSH keys)
434436
3. In **merge** mode (default): skip entries that already exist by same name
435-
4. In **replace** mode: delete all existing servers and apps, then import
437+
4. In **replace** mode: refuse with "stop or undeploy running apps before a replace import" unless every existing app is `draft`, `created` or `stopped`; otherwise undeploy all apps and delete all servers (the first error stops the import), then import
436438
5. Create servers with SSH key from export (fallback to `"pending"` if key is empty — user must enter key manually)
437-
6. Create apps (status = created, ready for deploy)
439+
6. Create apps (status = created, ready for deploy) with one route per entry in `domains` (exports without `domains` get a route for `domain`)
438440
7. Import secrets and config files
439441
8. Redirect to servers page
440442

@@ -548,8 +550,10 @@ All styles live in `internal/http/templates/layout.html` as a single `<style>` b
548550
- Cloudflare API token requires minimal scope: Zone:DNS:Edit
549551
- App containers on internal `dockify` Docker network only — no public port exposure except via Caddy
550552
- Webhook authentication: HMAC-SHA256 (GitHub), token with constant-time compare (GitLab), auto-generated 64-char hex secret; always on (no disable), fails closed
551-
- Session auth: cookie-based (`dockify_session`, `HttpOnly`, `SameSite=Lax`, `Secure` when the request is TLS or `X-Forwarded-Proto: https`), 24-hour expiry, in-memory sessions
553+
- Session auth: cookie-based (`dockify_session`, `HttpOnly`, `SameSite=Lax`, `Secure` when the request is TLS or `X-Forwarded-Proto: https`), 24-hour expiry, in-memory sessions; expired sessions are dropped on each login
554+
- Login: user and password compared in constant time; a failure waits 1 s; more than 10 failures from one client IP within 15 minutes (counted from the first) answer `429` until the window ends. The client IP is `RemoteAddr`; `chimw.RealIP` (trusting `X-Forwarded-For`/`X-Real-IP`) applies only with `DOCKIFY_TRUST_PROXY=true`
552555
- Cross-site requests: unsafe methods from another site get `403` (Go `http.CrossOriginProtection`), WebSocket handshakes must be same-origin, no CORS headers. A reverse proxy in front of Dockify must forward the original `Host` header (Caddy does by default)
556+
- Every response carries `Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com data:; img-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'self'` (inline scripts/styles are used in the templates), `X-Content-Type-Options: nosniff` and `Referrer-Policy: same-origin`. Browser scripts come from the binary, not a CDN; only Google Fonts is external
553557
- HTTP server: `ReadHeaderTimeout` 10s, `IdleTimeout` 120s; request bodies capped (API JSON 1 MiB, webhooks 1 MiB, import 16 MiB)
554558
- HTTP Basic Auth for apps enforced at Caddy proxy level with bcrypt
555559
- Backup encryption: AES-GCM with PBKDF2 passphrase (600,000 iterations, 256-bit key)

‎cmd/dockify/main.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ func main() {
9494
serverListAdapter := &serverLister{svc: svc}
9595

9696
webhookSecretFn := func() string { s, _ := settingsSvc.GetWebhookSecret(); return s }
97-
router := httppkg.NewRouter(svc, appSvc, httppkg.Render, serverListAdapter, cfg.AdminUser, cfg.AdminPass, cfg.APIToken, cfg.SSHKeyDir, webhookSecretFn, settingsHandler, backupHandler, version, cfg.DevMock)
97+
router := httppkg.NewRouter(svc, appSvc, httppkg.Render, serverListAdapter, cfg.AdminUser, cfg.AdminPass, cfg.APIToken, cfg.SSHKeyDir, webhookSecretFn, settingsHandler, backupHandler, version, cfg.DevMock, cfg.TrustProxy)
9898

9999
if cfg.AdminPass == "" {
100100
log.Println("WARNING: No admin password set (DOCKIFY_ADMIN_PASSWORD). Web UI has no authentication.")

‎internal/app/handler.go‎

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"strconv"
1111
"strings"
1212

13+
"github.com/coderbuzz/dockify/internal/web"
1314
"github.com/go-chi/chi/v5"
1415
)
1516

@@ -482,7 +483,7 @@ func (h *Handler) SetFile(w http.ResponseWriter, r *http.Request) {
482483

483484
func (h *Handler) DeleteFile(w http.ResponseWriter, r *http.Request) {
484485
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
485-
path, err := ValidFilePath(chi.URLParam(r, "path"))
486+
path, err := ValidFilePath(chi.URLParam(r, "*"))
486487
if err != nil {
487488
jsonResponse(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
488489
return
@@ -822,7 +823,7 @@ func (h *WebHandler) AppAddForm(w http.ResponseWriter, r *http.Request, render R
822823
go h.service.Deploy(app.ID)
823824
}
824825

825-
http.Redirect(w, r, "/apps/"+strconv.FormatInt(app.ID, 10), http.StatusSeeOther)
826+
web.Redirect(w, r, "/apps/"+strconv.FormatInt(app.ID, 10), http.StatusSeeOther)
826827
}
827828

828829
func (h *WebHandler) AppEditPage(w http.ResponseWriter, r *http.Request, render RenderFunc) {
@@ -1100,7 +1101,7 @@ func (h *WebHandler) AppEditForm(w http.ResponseWriter, r *http.Request, render
11001101
if flashMsg != "" {
11011102
redirectURL += "?flash=" + flashMsg
11021103
}
1103-
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
1104+
web.Redirect(w, r, redirectURL, http.StatusSeeOther)
11041105
}
11051106

11061107
func (h *WebHandler) AppDetailPage(w http.ResponseWriter, r *http.Request, render RenderFunc) {
@@ -1173,7 +1174,7 @@ func (h *WebHandler) refuseBusy(w http.ResponseWriter, r *http.Request, id int64
11731174
if !h.service.Busy(id) {
11741175
return false
11751176
}
1176-
http.Redirect(w, r, fmt.Sprintf("/apps/%d?flash=%s", id, url.QueryEscape(busyMsg)), http.StatusSeeOther)
1177+
web.Redirect(w, r, fmt.Sprintf("/apps/%d?flash=%s", id, url.QueryEscape(busyMsg)), http.StatusSeeOther)
11771178
return true
11781179
}
11791180

@@ -1185,15 +1186,15 @@ func (h *WebHandler) AppDeleteWeb(w http.ResponseWriter, r *http.Request, render
11851186

11861187
go h.service.Undeploy(id)
11871188

1188-
http.Redirect(w, r, "/apps", http.StatusSeeOther)
1189+
web.Redirect(w, r, "/apps", http.StatusSeeOther)
11891190
}
11901191

11911192
func (h *WebHandler) AppClearDeploymentsWeb(w http.ResponseWriter, r *http.Request, render RenderFunc) {
11921193
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
11931194

11941195
_ = h.service.ClearDeployments(id)
11951196

1196-
http.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
1197+
web.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
11971198
}
11981199

11991200
func (h *WebHandler) AppRedeployWeb(w http.ResponseWriter, r *http.Request, render RenderFunc) {
@@ -1204,7 +1205,7 @@ func (h *WebHandler) AppRedeployWeb(w http.ResponseWriter, r *http.Request, rend
12041205

12051206
go h.service.Redeploy(id)
12061207

1207-
http.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
1208+
web.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
12081209
}
12091210

12101211
func (h *WebHandler) AppDeployWeb(w http.ResponseWriter, r *http.Request, render RenderFunc) {
@@ -1217,7 +1218,7 @@ func (h *WebHandler) AppDeployWeb(w http.ResponseWriter, r *http.Request, render
12171218
}
12181219

12191220
if app.ServerID == 0 {
1220-
http.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10)+"/edit", http.StatusSeeOther)
1221+
web.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10)+"/edit", http.StatusSeeOther)
12211222
return
12221223
}
12231224
if h.refuseBusy(w, r, id) {
@@ -1227,7 +1228,7 @@ func (h *WebHandler) AppDeployWeb(w http.ResponseWriter, r *http.Request, render
12271228
h.service.UpdateStatus(id, StatusDeploying)
12281229
go h.service.Redeploy(id)
12291230

1230-
http.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
1231+
web.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
12311232
}
12321233

12331234
func (h *WebHandler) AppRollbackWeb(w http.ResponseWriter, r *http.Request, render RenderFunc) {
@@ -1241,7 +1242,7 @@ func (h *WebHandler) AppRollbackWeb(w http.ResponseWriter, r *http.Request, rend
12411242
return
12421243
}
12431244

1244-
http.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
1245+
web.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
12451246
}
12461247

12471248
func (h *WebHandler) AppStopWeb(w http.ResponseWriter, r *http.Request, render RenderFunc) {
@@ -1255,7 +1256,7 @@ func (h *WebHandler) AppStopWeb(w http.ResponseWriter, r *http.Request, render R
12551256
return
12561257
}
12571258

1258-
http.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
1259+
web.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
12591260
}
12601261

12611262
func (h *WebHandler) AppStartWeb(w http.ResponseWriter, r *http.Request, render RenderFunc) {
@@ -1269,7 +1270,7 @@ func (h *WebHandler) AppStartWeb(w http.ResponseWriter, r *http.Request, render
12691270
return
12701271
}
12711272

1272-
http.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
1273+
web.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
12731274
}
12741275

12751276
func (h *WebHandler) AppRedeployAll(w http.ResponseWriter, r *http.Request, render RenderFunc) {
@@ -1287,7 +1288,7 @@ func (h *WebHandler) AppRedeployAll(w http.ResponseWriter, r *http.Request, rend
12871288
}
12881289
}
12891290

1290-
http.Redirect(w, r, "/apps?flash="+url.QueryEscape(fmt.Sprintf("Redeploying %d running apps...", count)), http.StatusSeeOther)
1291+
web.Redirect(w, r, "/apps?flash="+url.QueryEscape(fmt.Sprintf("Redeploying %d running apps...", count)), http.StatusSeeOther)
12911292
}
12921293

12931294
func saveFormEnvVars(r *http.Request, svc *Service, appID int64) {

0 commit comments

Comments
 (0)