diff --git a/README.md b/README.md index cfb5e3f..3474354 100644 --- a/README.md +++ b/README.md @@ -95,26 +95,22 @@ Open `https://` or `http://:8080`. Create a `.env` file in the project root or set these environment variables: -```env -# Domain for Caddy reverse proxy (auto HTTPS). Only needed for Mode 1 (Docker Compose). -DOMAIN=dockify.example.com - -# Admin credentials (required for web UI login). -# If DOCKIFY_ADMIN_PASSWORD is not set, the web UI has no authentication. -DOCKIFY_ADMIN_USER=admin -DOCKIFY_ADMIN_PASSWORD=your-secure-password - -# Cloudflare API credentials (optional, enables automatic DNS A record creation on deploy) -CLOUDFLARE_API_TOKEN= -CLOUDFLARE_ZONE_ID= - -# Optional: base path when behind a reverse proxy (e.g., code-server: /proxy/9898) -DOCKIFY_BASE_PATH= -``` +| Variable | Default | Required | Description | +|---|---|---|---| +| `DOCKIFY_HOST` | `0.0.0.0` | No | Network interface to bind | +| `DOCKIFY_PORT` | `8080` | No | HTTP port | +| `DOCKIFY_DATA_DIR` | `/var/lib/dockify` | No | SQLite DB + data storage directory | +| `DOCKIFY_SSH_KEY_DIR` | `/var/lib/dockify/keys` | No | Per-server SSH private key storage | +| `DOMAIN` | — | Mode 1 | Domain for Caddy reverse proxy (auto HTTPS) | +| `DOCKIFY_ADMIN_USER` | `admin` | No | Web UI login username | +| `DOCKIFY_ADMIN_PASSWORD` | — | No | Web UI password. If not set, the web UI has **no authentication** | +| `CLOUDFLARE_API_TOKEN` | — | No | Cloudflare API token for automatic DNS A records | +| `CLOUDFLARE_ZONE_ID` | — | No | Cloudflare zone ID | +| `DOCKIFY_BASE_PATH` | — | No | URL prefix when behind a reverse proxy (e.g. `/proxy/9898`) | ### Authentication -The web UI is protected by a login page. To enable authentication, set `DOCKIFY_ADMIN_PASSWORD` in your `.env` or environment: +To enable web UI authentication, set `DOCKIFY_ADMIN_PASSWORD`: ```bash # In .env @@ -131,9 +127,7 @@ DOCKIFY_ADMIN_PASSWORD=secret123 docker compose up -d - **Session:** Cookie-based, expires after 24 hours - **Logout:** Visit `/logout` -The login page is at `/login`. After successful login, you are redirected to the dashboard. Webhook endpoints (`/api/webhook/github`, `/api/webhook/gitlab`) and health check (`/health`) do not require authentication. - -Dockify runs with sensible defaults. Only `DOMAIN` is required for Option A. `CLOUDFLARE_*` is optional and only needed if you want automated DNS records. `DOCKIFY_BASE_PATH` is only needed when accessing Dockify through a URL prefix (e.g., code-server proxy). +The login page is at `/login`. Webhook endpoints (`/api/webhook/github`, `/api/webhook/gitlab`) and health check (`/health`) do not require authentication. ### Step 2: Prepare a Worker VM @@ -208,29 +202,54 @@ ssh-copy-id -i ~/.ssh/dockify.pub root@ Dockify can auto-deploy on every push via GitHub or GitLab webhooks. When an app is created with a `Git Repo URL` and `Branch`, Dockify matches incoming webhooks by repo + branch and triggers a redeploy. -Each app with a Git repo gets an auto-generated **webhook secret** (64-character hex string). You can view and roll (re-generate) it in the app detail page. +A global **webhook secret** is auto-generated on first startup. See the [Webhook Security](#webhook-security) section below for details on how it works and how to configure it. ### Setup 1. In your **app repo** (the one you want to auto-deploy), go to **Settings → Webhooks → Add webhook** 2. Fill in: ``` - Payload URL: https://dockify.amg.id/api/webhook/github + Payload URL: https://dockify.example.com/api/webhook/github Content type: application/json - Secret: + Secret: Events: Just the push event ``` 3. In the Dockify UI, when creating the app, fill in: - **Git Repo URL:** `https://github.com/user/repo.git` - **Branch:** `main` -Dockify verifies incoming webhooks using HMAC-SHA256 (GitHub) or secret token (GitLab). If the secret doesn't match, the webhook is rejected with 401. Apps created before the secret feature still work without verification (no secret set). +Dockify verifies incoming webhooks using HMAC-SHA256 (GitHub) or secret token (GitLab). If the secret doesn't match, the webhook is rejected with 401. Non-push events are ignored gracefully (returns 200 with `"ignored"`). + +### Webhook Security + +Dockify auto-generates a global **webhook secret** (64-character hex) on first startup, stored in the database. You can view, copy, regenerate (roll), or disable it on the **Settings** page (`/settings`). When disabled, webhooks are accepted without signature verification. + +When the webhook secret is set, Dockify validates incoming webhooks: + +- **GitHub** — HMAC-SHA256 via `X-Hub-Signature-256` header +- **GitLab** — plain token comparison via `X-Gitlab-Token` header + +If the signature or token does not match, the webhook is rejected with `401 Unauthorized`. + +**To configure:** -Dockify ignores non-push events gracefully (returns 200 with `"ignored"`). +1. Go to **Settings** in the Dockify web UI +2. Click **Show** → **Copy** the webhook secret +3. Paste it as the **Secret** in your GitHub/GitLab webhook settings +4. For GitHub Actions, pass the secret via `${{ secrets.DOCKIFY_WEBHOOK_SECRET }}` (see examples below) -### Sample GitHub Actions Workflow +> **Note:** When you **Roll** the secret, the old one stops working immediately. Update all CI secrets to match. Use **Disable** to turn off secret verification entirely. -You can also trigger deploy from GitHub Actions directly, without setting up a webhook. This gives you more control (run tests first, then deploy): +### Trigger Deploy via GitHub Actions + +You can trigger deploys from GitHub Actions with or without webhook secret verification. + +Add repository secrets (**Settings → Secrets and variables → Actions**): + +- `DOCKIFY_URL` — your Dockify URL (e.g. `https://dockify.example.com`) +- `DOCKIFY_WEBHOOK_SECRET` — the webhook secret from Dockify Settings (for Option B) + +**Option A: Without webhook secret** (simple; disable the webhook secret on the Settings page first, or use when no secret is configured) ```yaml name: Deploy via Dockify @@ -252,8 +271,10 @@ jobs: # npm test, go test, etc. - name: Trigger Dockify deploy + env: + DOCKIFY_URL: ${{ secrets.DOCKIFY_URL }} run: | - curl -s -X POST https://dockify.example.com/api/webhook/github \ + curl -s -X POST $DOCKIFY_URL/api/webhook/github \ -H "Content-Type: application/json" \ -d '{ "ref": "refs/heads/main", @@ -264,6 +285,51 @@ jobs: }' ``` +**Option B: With webhook secret** (HMAC-SHA256, recommended for public-facing deployments) + +```yaml +name: Deploy via Dockify + +on: + push: + branches: [main] + +jobs: + test-and-deploy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Run tests + run: | + echo "Running tests..." + # npm test, go test, etc. + + - name: Trigger Dockify deploy + env: + DOCKIFY_URL: ${{ secrets.DOCKIFY_URL }} + DOCKIFY_WEBHOOK_SECRET: ${{ secrets.DOCKIFY_WEBHOOK_SECRET }} + run: | + REPO="https://github.com/${{ github.repository }}.git" + BRANCH="${GITHUB_REF#refs/heads/}" + COMMIT="${{ github.sha }}" + + PAYLOAD=$(cat </.env` on the worker VM. + +Manage via the app detail page in the Web UI, or the API: + +``` +GET /api/apps/:id/secrets List secrets +POST /api/apps/:id/secrets Set a secret (body: `{"key":"...","value":"..."}`) +DELETE /api/apps/:id/secrets/:key Delete a secret +``` + +### Config Files + +Each app can have arbitrary config files. On deploy, they are written to `/opt/dockify/apps//` on the worker VM. + +``` +GET /api/apps/:id/files List files +POST /api/apps/:id/files Set a file (body: `{"path":"...","content":"..."}`) +DELETE /api/apps/:id/files/:path Delete a file +``` + +### Stop / Start + +Pause and resume an app without undeploying or losing configuration: + +``` +POST /api/apps/:id/stop Runs `docker compose stop` +POST /api/apps/:id/start Runs `docker compose start` +``` + +### Unique Service Name + +When enabled, Dockify renames the first service in the docker-compose file to a sanitized version of the app name (replacing `.`, `_`, spaces with `-`). This prevents service name conflicts when multiple apps use the same compose template. + +### HTTP Basic Auth + +Set `auth_user` and `auth_pass` on an app to require HTTP basic auth before accessing it. Caddy enforces this at the proxy level — the app itself does not need to handle authentication. + +## Server Features + +### Resource Monitoring + +Dockify collects CPU, RAM, and disk usage from all online servers every 60 seconds. Resource data is visible on server detail pages and used by the scheduler to pick the least-loaded server for new deployments. + +``` +POST /api/servers/:id/refresh Manually refresh a server's resource metrics +``` + +### Server Edit + +Update a server's host, port, user, or SSH key after creation: + +``` +PATCH /api/servers/:id Partial update of server fields +``` + +## Backup & Restore + +Export your configuration (servers + apps) as YAML and import it into another instance. + +### Export + +``` +GET /api/backup/export Download YAML config file +``` + +The export includes all servers (name, host, port, user) and apps (name, domain, port, compose, git repo, auth settings). **SSH keys are not exported** — you must re-enter them after import. + +### Import + +Upload a previously exported YAML file via the Import page (`/import`) or API: + +``` +POST /api/backup/import Upload YAML file (multipart: `file` + `mode`) +``` + +- **merge** (default): skip entries that already exist by name +- **replace**: delete all existing servers and apps before import + +## Settings & Updates + +The **Settings** page (`/settings`) provides: + +| Feature | Description | +|---|---| +| **Webhook Secret** | View, copy, or roll (regenerate) the global webhook secret | +| **Update Check** | Checks GitHub Releases for a newer version | +| **Run Update** | Triggers a self-update via `systemd-run` — downloads and runs the latest `update.sh` | + +Manual update via command line: + ## Updating Dockify A universal update script detects your install mode and updates accordingly: @@ -305,12 +465,14 @@ dockify/ ├── cmd/dockify/main.go ├── internal/ │ ├── ssh/ # SSH client, remote exec, worker init -│ ├── vm/ # VM CRUD, resource monitoring +│ ├── server/ # Server CRUD, resource monitoring │ ├── app/ # App CRUD, deployment │ ├── caddy/ # Caddy Admin API client │ ├── cloudflare/ # Cloudflare DNS API │ ├── webhook/ # Git webhook handler -│ ├── scheduler/ # Idle VM selection +│ ├── scheduler/ # Idle server selection +│ ├── settings/ # Global settings, update checker +│ ├── backup/ # Export/import YAML config │ ├── db/ # SQLite layer │ └── http/ # HTTP server, handlers, templates ├── web/static/ # CSS, JS (embedded) diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..0ded532 --- /dev/null +++ b/TODO.md @@ -0,0 +1,16 @@ +# TODO + +## Webhook Secret + +- [ ] **Settings page**: Add toggle to disable/clear the webhook secret. + - Currently the secret is auto-generated at startup (`internal/settings/settings.go:31` `ensureWebhookSecret()`) and cannot be disabled — only show/hide, copy, roll. + - Affected: `internal/http/templates/settings.html`, `internal/settings/settings.go`, `internal/settings/handler.go` + - After adding disable, update README Option A note to mention this feature. + +- [ ] **Settings template**: Replace hardcoded `dockify.amg.id` with a generic example domain in the GitHub Actions workflow snippet. + - File: `internal/http/templates/settings.html:33` + +## Backup + +- [ ] **Fix misleading YAML header comment**: `internal/backup/backup.go:95` says "Secrets (SSH keys, auth passwords if present) are included as saved" but SSH keys are **not** included in the export (`ExportServer` struct has no SSH key field). Either fix the comment or add SSH key export support. + - File: `internal/backup/backup.go:94-96` diff --git a/cmd/dockify/main.go b/cmd/dockify/main.go index 44c7bc8..7db3c06 100644 --- a/cmd/dockify/main.go +++ b/cmd/dockify/main.go @@ -62,7 +62,6 @@ func main() { appSvc := app.NewService(appRepo, serverRepo, cfClient, sch) settingsSvc := settings.NewService(database, version) - webhookSecret, _ := settingsSvc.GetWebhookSecret() settingsHandler := settings.NewHandler(settingsSvc) backupSvc := backup.NewService(svc, appSvc, cfg.SSHKeyDir) @@ -70,7 +69,8 @@ func main() { serverListAdapter := &serverLister{svc: svc} - router := httppkg.NewRouter(svc, appSvc, httppkg.Render, serverListAdapter, cfg.AdminUser, cfg.AdminPass, cfg.SSHKeyDir, webhookSecret, settingsHandler, backupHandler, version) + webhookSecretFn := func() string { s, _ := settingsSvc.GetWebhookSecret(); return s } + router := httppkg.NewRouter(svc, appSvc, httppkg.Render, serverListAdapter, cfg.AdminUser, cfg.AdminPass, cfg.SSHKeyDir, webhookSecretFn, settingsHandler, backupHandler, version) if cfg.AdminPass == "" { log.Println("WARNING: No admin password set (DOCKIFY_ADMIN_PASSWORD). Web UI has no authentication.") diff --git a/internal/backup/backup.go b/internal/backup/backup.go index f73c61c..c7e5f34 100644 --- a/internal/backup/backup.go +++ b/internal/backup/backup.go @@ -92,7 +92,7 @@ func (s *Service) Export() (string, error) { sb := strings.Builder{} sb.WriteString("# Dockify Configuration Export\n") - sb.WriteString("# Secrets (SSH keys, auth passwords if present) are included as saved.\n") + sb.WriteString("# Auth passwords (if present) are included as saved. SSH keys are not exported.\n") sb.WriteString("# Remove or edit entries before import as needed.\n") sb.Write(out) return sb.String(), nil diff --git a/internal/http/router.go b/internal/http/router.go index a269a44..727fe81 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -14,7 +14,7 @@ import ( chimw "github.com/go-chi/chi/v5/middleware" ) -func NewRouter(svc *server.Service, appSvc *app.Service, render RenderFunc, serverListAdapter app.ServerRepo, cfgUser, cfgPass, sshKeyDir, webhookSecret string, settingsHandler *settings.Handler, backupHandler *backup.Handler, version string) *chi.Mux { +func NewRouter(svc *server.Service, appSvc *app.Service, render RenderFunc, serverListAdapter app.ServerRepo, cfgUser, cfgPass, sshKeyDir string, webhookSecretFn func() string, settingsHandler *settings.Handler, backupHandler *backup.Handler, version string) *chi.Mux { r := chi.NewRouter() r.Use(chimw.Logger) @@ -31,7 +31,7 @@ func NewRouter(svc *server.Service, appSvc *app.Service, render RenderFunc, serv appAPIHandler := app.NewHandler(appSvc) appWebHandler := app.NewWebHandler(appSvc, serverListAdapter) - whHandler := webhook.NewHandler(appSvc, webhookSecret) + whHandler := webhook.NewHandler(appSvc, webhookSecretFn) // Public routes (no auth required) r.Get("/health", func(w http.ResponseWriter, r *http.Request) { @@ -174,6 +174,8 @@ func NewRouter(svc *server.Service, appSvc *app.Service, render RenderFunc, serv }) r.Get("/api/settings/webhook-secret", settingsHandler.GetWebhookSecret) r.Post("/api/settings/webhook-secret/roll", settingsHandler.RollWebhookSecret) + r.Post("/api/settings/webhook-secret/disable", settingsHandler.DisableWebhookSecret) + r.Post("/api/settings/webhook-secret/enable", settingsHandler.EnableWebhookSecret) r.Post("/api/settings/update/run", settingsHandler.RunUpdate) r.Get("/about", func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/http/templates/settings.html b/internal/http/templates/settings.html index e07bc70..6c1be21 100644 --- a/internal/http/templates/settings.html +++ b/internal/http/templates/settings.html @@ -12,15 +12,17 @@

Webhook Secret

+ {{if .WebhookSecret}}

Use this secret in GitHub Actions to authenticate webhook deploy notifications.

-
+
- + +
GitHub Actions workflow snippet: @@ -30,12 +32,18 @@

Webhook Secret

run: | PAYLOAD='{"ref":"refs/heads/'${GITHUB_REF#refs/heads/}'","after":"'${{"{{"}} github.sha {{"}}"}}'","repository":{"clone_url":"https://github.com/'${{"{{"}} github.repository {{"}}"}}'.git"}}' SIG=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$DOCKIFY_WEBHOOK_SECRET" | sed 's/^.* //') - curl -s -X POST https://dockify.amg.id/api/webhook/github \ + curl -s -X POST https://dockify.example.com/api/webhook/github \ -H "Content-Type: application/json" \ -H "X-GitHub-Event: push" \ -H "X-Hub-Signature-256: sha256=$SIG" \ -d "$PAYLOAD"
+ {{else}} +

+ Webhook secret is disabled. Webhooks are accepted without signature verification. +

+ + {{end}}
@@ -77,6 +85,16 @@

Import

if (visible) document.getElementById('wh-secret').type = 'text'; }); } +function disableSecret() { + if (!confirm('Disable webhook secret verification? Webhooks will be accepted without a signature.')) return; + fetch('api/settings/webhook-secret/disable', {method:'POST'}) + .then(r => { if (r.ok) location.reload(); }); +} +function enableSecret() { + fetch('api/settings/webhook-secret/enable', {method:'POST'}) + .then(r => r.json()) + .then(d => { if (d.secret) location.reload(); }); +} {{template "scripts" .}} diff --git a/internal/settings/handler.go b/internal/settings/handler.go index 06d0402..6221b89 100644 --- a/internal/settings/handler.go +++ b/internal/settings/handler.go @@ -30,6 +30,15 @@ func (h *Handler) RollWebhookSecret(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusOK, map[string]string{"secret": secret}) } +func (h *Handler) EnableWebhookSecret(w http.ResponseWriter, r *http.Request) { + secret, err := h.service.GetWebhookSecret() + if err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + jsonResponse(w, http.StatusOK, map[string]string{"secret": secret}) +} + func (h *Handler) GetWebhookSecret(w http.ResponseWriter, r *http.Request) { secret, err := h.service.GetWebhookSecret() if err != nil { @@ -39,6 +48,14 @@ func (h *Handler) GetWebhookSecret(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusOK, map[string]string{"secret": secret}) } +func (h *Handler) DisableWebhookSecret(w http.ResponseWriter, r *http.Request) { + if err := h.service.DisableWebhookSecret(); err != nil { + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + jsonResponse(w, http.StatusOK, map[string]string{"secret": ""}) +} + func (h *Handler) CheckUpdate(w http.ResponseWriter, r *http.Request) { info, err := h.service.CheckUpdate() if err != nil { diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 3cfcd5f..0c4a133 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -117,6 +117,11 @@ func (s *Service) RegenerateWebhookSecret() (string, error) { return secret, nil } +func (s *Service) DisableWebhookSecret() error { + _, err := s.db.Exec("DELETE FROM settings WHERE key = ?", webhookSecretKey) + return err +} + func generateSecret() string { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { diff --git a/internal/webhook/handler.go b/internal/webhook/handler.go index 7afb76f..a0cfeb4 100644 --- a/internal/webhook/handler.go +++ b/internal/webhook/handler.go @@ -16,12 +16,12 @@ type WebhookService interface { } type Handler struct { - service WebhookService - webhookSecret string + service WebhookService + webhookSecretFn func() string } -func NewHandler(service WebhookService, webhookSecret string) *Handler { - return &Handler{service: service, webhookSecret: webhookSecret} +func NewHandler(service WebhookService, webhookSecretFn func() string) *Handler { + return &Handler{service: service, webhookSecretFn: webhookSecretFn} } func (h *Handler) GitHub(w http.ResponseWriter, r *http.Request) { @@ -54,8 +54,9 @@ func (h *Handler) GitHub(w http.ResponseWriter, r *http.Request) { repo := payload.Repo.CloneURL commitSHA := payload.After + secret := h.webhookSecretFn() sig := r.Header.Get("X-Hub-Signature-256") - if h.webhookSecret != "" && !verifyHMACSHA256(sig, body, h.webhookSecret) { + if secret != "" && !verifyHMACSHA256(sig, body, secret) { log.Printf("Webhook: invalid signature for %s@%s", repo, branch) http.Error(w, "invalid signature", http.StatusUnauthorized) return @@ -96,8 +97,9 @@ func (h *Handler) GitLab(w http.ResponseWriter, r *http.Request) { repo := payload.Project.GitHTTPURL commitSHA := payload.After + secret := h.webhookSecretFn() token := r.Header.Get("X-Gitlab-Token") - if h.webhookSecret != "" && token != h.webhookSecret { + if secret != "" && token != secret { log.Printf("Webhook: invalid token for %s@%s", repo, branch) http.Error(w, "invalid token", http.StatusUnauthorized) return