diff --git a/.air.toml b/.air.toml index 3ce719e..ce2d566 100644 --- a/.air.toml +++ b/.air.toml @@ -13,7 +13,7 @@ cmd = "go build -o ./tmp/dockify ./cmd/dockify" exclude_dir = ["assets", "tmp", "vendor", "data", "scripts"] exclude_regex = ["_test\\.go"] exclude_unchanged = true - full_bin = "DOCKIFY_DATA_DIR=./data ./tmp/dockify serve" + full_bin = "DOCKIFY_HOST=${DOCKIFY_HOST:-127.0.0.1} DOCKIFY_DATA_DIR=./data ./tmp/dockify serve" include_dir = ["cmd", "internal"] include_ext = ["go", "html"] kill_delay = "500ms" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ebbe8be..76b81e5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,9 @@ name: Build and Publish on: push: + branches: [main] tags: ['v*'] + pull_request: env: REGISTRY: ghcr.io @@ -22,12 +24,23 @@ jobs: uses: actions/setup-go@v5 with: go-version: '1.26' + check-latest: true - name: Vet run: go vet ./... + - name: Format + run: | + out=$(gofmt -l .) + if [ -n "$out" ]; then echo "gofmt needed:"; echo "$out"; exit 1; fi + - name: Test - run: go test ./... + run: go test -race ./... + + - name: Vulnerability check + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + "$(go env GOPATH)/bin/govulncheck" ./... - name: Smoke test run: bash scripts/smoke-test.sh @@ -40,13 +53,16 @@ jobs: -ldflags="-s -w -X main.version=${VERSION}" \ -o dockify-linux-amd64 \ ./cmd/dockify + sha256sum dockify-linux-amd64 > dockify-linux-amd64.sha256 - name: Upload binary if: startsWith(github.ref, 'refs/tags/v') uses: actions/upload-artifact@v4 with: name: dockify-linux-amd64 - path: dockify-linux-amd64 + path: | + dockify-linux-amd64 + dockify-linux-amd64.sha256 retention-days: 7 - name: Set up Docker Buildx @@ -113,5 +129,7 @@ jobs: - name: Create release uses: softprops/action-gh-release@v2 with: - files: dockify-linux-amd64 + files: | + dockify-linux-amd64 + dockify-linux-amd64.sha256 generate_release_notes: true diff --git a/AGENTS.md b/AGENTS.md index 72b7d63..3086a1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,14 +30,14 @@ go vet ./... # lint go mod tidy # clean dependencies # Run once (no auto-reload) -DOCKIFY_DATA_DIR=./data go run ./cmd/dockify serve +DOCKIFY_HOST=127.0.0.1 DOCKIFY_DATA_DIR=./data go run ./cmd/dockify serve # Verify templates parse go test ./internal/http/... -run TestTemplates # Build and run with Docker docker build -t dockify . -docker run -p 8080:8080 -v $(pwd)/data:/var/lib/dockify dockify +docker run -p 127.0.0.1:8080:8080 -e DOCKIFY_ALLOW_NO_AUTH=true -v $(pwd)/data:/var/lib/dockify dockify ``` ### Dev Mode diff --git a/README.md b/README.md index 3a04af6..6971d15 100644 --- a/README.md +++ b/README.md @@ -75,13 +75,17 @@ curl -fsSL https://raw.githubusercontent.com/coderbuzz/dockify/main/scripts/inst cd /opt/dockify && docker compose up -d ``` +The image binds `DOCKIFY_HOST=0.0.0.0`, so the container needs `DOCKIFY_ADMIN_PASSWORD` in `/opt/dockify/.env` (the installer asks for it) or it exits at start; `DOCKIFY_ALLOW_NO_AUTH=true` overrides this. + +Worker SSH keys live in the `dockify_data` volume (`/var/lib/dockify/keys`); the host's `~/.ssh` is not mounted. Dockify generates each worker's key when you add the server and shows the public key on the server page (Step 2). Older compose files mounted `${HOME}/.ssh` read-only, which made adding a server fail with `read-only file system`: re-download `docker-compose.yml` and run `docker compose up -d`. + **Option D: Build from source (development)** ```bash git clone https://github.com/coderbuzz/dockify.git cd dockify go build -o dockify ./cmd/dockify -./dockify serve +DOCKIFY_HOST=127.0.0.1 ./dockify serve # or set DOCKIFY_ADMIN_PASSWORD ``` **All installation methods share the same architecture for worker VMs:** @@ -106,7 +110,8 @@ Create a `.env` file in the project root or set these environment variables: | `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** | +| `DOCKIFY_ADMIN_PASSWORD` | — | Yes, unless bound to loopback | Web UI password. Without it Dockify refuses to start on a non-loopback `DOCKIFY_HOST` | +| `DOCKIFY_ALLOW_NO_AUTH` | `false` | No | `true` lets Dockify start without a password on a non-loopback bind (web UI open to anyone who can reach it) | | `DOCKIFY_API_TOKEN` | — | No | Bearer token for scripts/agents calling `/api/*` (needs `DOCKIFY_ADMIN_PASSWORD` set) | | `DOCKIFY_DEV_MOCK` | `false` | No | Enable mock SSH for local development (no real VMs needed) | | `CLOUDFLARE_API_TOKEN` | — | No | Cloudflare API token for automatic DNS A records | @@ -127,7 +132,7 @@ DOCKIFY_ADMIN_PASSWORD=secret123 docker compose up -d ``` - **Default username:** `admin` (configurable via `DOCKIFY_ADMIN_USER`) -- **No password set:** Web UI has **no authentication** (open to anyone) +- **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** - **Password set:** All routes (except `/health` and `/api/webhook/*`) require login - **Session:** Cookie-based (`HttpOnly`, `SameSite=Lax`, `Secure` behind HTTPS), expires after 24 hours - **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) @@ -178,7 +183,7 @@ The `public_key` is also returned by `POST /api/servers` (send no `ssh_key`) and 2. Install Docker via `get.docker.com` (if not present) 3. Install Docker Compose plugin (if not present) 4. Create `dockify` Docker network -5. Deploy Caddy container (port 80/443 + Admin API on localhost:2019) +5. Deploy Caddy container `caddy:2` (port 80/443 + Admin API on localhost:2019). A `caddy` container Dockify did not create (no `/opt/dockify/caddy` mount, no `dockify.managed=true` label) is left alone and init fails until it is removed or renamed 6. Collect CPU, RAM, Disk info 7. Status → **online**. Ready to deploy apps. @@ -497,7 +502,7 @@ The **Settings** page (`/settings`) provides centralized management: |---|---| | **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` | +| **Run Update** | Triggers a self-update via `systemd-run` — runs `update.sh` from the latest release tag found by the update check (refused when none is known); output in `journalctl -u dockify-upgrade` | The **About** page (`/about`) shows the current version, project description, and a **Sponsor** link. An update progress bar with live polling keeps you informed during self-updates. @@ -535,6 +540,8 @@ A universal update script detects your install mode and updates accordingly: curl -fsSL https://raw.githubusercontent.com/coderbuzz/dockify/main/scripts/update.sh | bash ``` +Binary installs are checked against the release's `dockify-linux-amd64.sha256` before the binary is replaced; the update aborts if the file is missing (releases published before checksums have none) or does not match. `DOCKIFY_VERSION=vX.Y.Z` pins the release. + Or manually per mode: **Mode 1 (Docker Compose):** diff --git a/SPEC.md b/SPEC.md index 6ed6b29..f96cbed 100644 --- a/SPEC.md +++ b/SPEC.md @@ -89,7 +89,7 @@ scripts/ Dockerfile # Multi-stage Docker build docker-compose.yml # Dockify + Caddy reverse proxy (mode 1) .air.toml # Air live-reload config -.github/workflows/build.yml # CI: vet, test, smoke test, build binary + Docker, release on v* tag +.github/workflows/build.yml # CI on PRs, main and v* tags: vet, gofmt -l, test -race, govulncheck, smoke test; v* tag also builds binary + .sha256 + Docker, releases ``` ## Environment Variables @@ -102,7 +102,8 @@ docker-compose.yml # Dockify + Caddy reverse proxy (mode 1) | `DOCKIFY_SSH_KEY_DIR` | `$DATA_DIR/keys` | No | Per-server SSH private key files | | `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. Not set = no authentication | +| `DOCKIFY_ADMIN_PASSWORD` | — | Unless loopback | Web UI password. Not set = no authentication, and `serve` exits unless `DOCKIFY_HOST` is loopback or `DOCKIFY_ALLOW_NO_AUTH=true` | +| `DOCKIFY_ALLOW_NO_AUTH` | `false` | No | Allow a passwordless UI on a non-loopback bind | | `DOCKIFY_API_TOKEN` | — | No | `Authorization: Bearer` token accepted on all protected routes (scripts/agents) | | `DOCKIFY_DEV_MOCK` | `false` | No | Enable mock SSH client for local development | | `DOCKIFY_BASE_PATH` | — | No | URL prefix when behind reverse proxy (e.g. `/proxy/9898`) | @@ -353,7 +354,7 @@ Protected routes accept the session cookie or `Authorization: Bearer $DOCKIFY_AP | GET | `/api/settings/webhook-secret` | Get webhook secret | | POST | `/api/settings/webhook-secret/roll` | Regenerate webhook secret (it cannot be disabled) | | GET | `/api/settings/update/check` | Check latest GitHub Release version (`?force=1` skips the 5-minute cache) | -| POST | `/api/settings/update/run` | Trigger self-update via systemd-run | +| POST | `/api/settings/update/run` | Trigger self-update: `systemd-run --unit=dockify-upgrade /bin/bash -c` fetching `scripts/update.sh` from the latest release tag (no temp file; refused when no tag is known); `update.sh` verifies the binary against the release's `.sha256` | | POST | `/api/backup/export` | Download YAML export | | POST | `/api/backup/import` | Upload YAML import (multipart: `file` + `mode`), ≤ 16 MiB | @@ -397,10 +398,11 @@ When a server is initialized (`POST /api/servers/:id/init`): 2. Install Docker via `get.docker.com` (if not present) 3. Install Docker Compose plugin (if not present) 4. Create `dockify` Docker network -5. Deploy Caddy container (ports 80/443 + Admin API on localhost:2019) -6. Collect CPU cores (`nproc`), RAM total (`/proc/meminfo`), disk total (`df -BG`) -7. Collect CPU usage (`/proc/stat`), RAM usage (`/proc/meminfo`), disk usage (`df -BG`) -8. Status → online +5. Check an existing `caddy` container (`docker inspect caddy --format '{{json .Mounts}} {{json .Config.Labels}}'`): it is Dockify-managed when a mount source is `/opt/dockify/caddy` (current layout) or `/opt/dockify/caddy/config.json` (old layout), or it has the label `dockify.managed=true`. Any other `caddy` container (running or stopped) → status `error`, init stops: remove or rename it first +6. Deploy Caddy container `caddy:2` with `--label dockify.managed=true` (ports 80/443 + Admin API on localhost:2019); a running managed container is migrated to `/opt/dockify/caddy:/etc/caddy` when its admin API answers +7. Collect CPU cores (`nproc`), RAM total (`/proc/meminfo`), disk total (`df -BG`) +8. Collect CPU usage (`/proc/stat`), RAM usage (`/proc/meminfo`), disk usage (`df -BG`) +9. Status → online Init is idempotent — re-running skips components that already exist (Caddy container, Docker, network). diff --git a/cmd/dockify/main.go b/cmd/dockify/main.go index 70fa572..f09fe1f 100644 --- a/cmd/dockify/main.go +++ b/cmd/dockify/main.go @@ -40,6 +40,9 @@ func main() { } cfg := config.Load() + if err := cfg.CheckAuth(); err != nil { + log.Fatal(err) + } database, err := db.Open(cfg.DBPath()) if err != nil { diff --git a/docker-compose.yml b/docker-compose.yml index 3bd2b34..318f1e5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,12 +8,10 @@ services: env_file: .env volumes: - dockify_data:/var/lib/dockify - - ${HOME}/.ssh:/home/dockify/.ssh:ro environment: DOCKIFY_HOST: 0.0.0.0 DOCKIFY_PORT: 8080 DOCKIFY_DATA_DIR: /var/lib/dockify - DOCKIFY_SSH_KEY_DIR: /home/dockify/.ssh networks: - dockify diff --git a/internal/app/model.go b/internal/app/model.go index 012ffa6..3633c55 100644 --- a/internal/app/model.go +++ b/internal/app/model.go @@ -7,27 +7,27 @@ import ( ) type App struct { - ID int64 `json:"id"` - Name string `json:"name"` - ServerID int64 `json:"server_id"` - Domain string `json:"domain"` - Port int `json:"port"` - Compose string `json:"compose"` - GitRepo string `json:"git_repo"` - GitBranch string `json:"git_branch"` - AuthUser string `json:"auth_user"` - AuthPass string `json:"auth_pass"` - Status string `json:"status"` - ComposeMode string `json:"compose_mode"` - MemoryLimit string `json:"memory_limit"` - CPULimit string `json:"cpu_limit"` - LogMaxSize string `json:"log_max_size"` - LogMaxFile string `json:"log_max_file"` - Command string `json:"command"` - Ports string `json:"ports"` - UlimitsNofile string `json:"ulimits_nofile"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + Name string `json:"name"` + ServerID int64 `json:"server_id"` + Domain string `json:"domain"` + Port int `json:"port"` + Compose string `json:"compose"` + GitRepo string `json:"git_repo"` + GitBranch string `json:"git_branch"` + AuthUser string `json:"auth_user"` + AuthPass string `json:"auth_pass"` + Status string `json:"status"` + ComposeMode string `json:"compose_mode"` + MemoryLimit string `json:"memory_limit"` + CPULimit string `json:"cpu_limit"` + LogMaxSize string `json:"log_max_size"` + LogMaxFile string `json:"log_max_file"` + Command string `json:"command"` + Ports string `json:"ports"` + UlimitsNofile string `json:"ulimits_nofile"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type Deployment struct { @@ -42,19 +42,19 @@ type Deployment struct { } type ContainerStats struct { - ID int64 `json:"id"` - AppID int64 `json:"app_id"` - ServerID int64 `json:"server_id"` - ContainerName string `json:"container_name"` - CPUPercent float64 `json:"cpu_percent"` - MemUsageBytes int64 `json:"mem_usage_bytes"` - MemLimitBytes int64 `json:"mem_limit_bytes"` - MemPercent float64 `json:"mem_percent"` - NetIORxBytes int64 `json:"net_io_rx_bytes"` - NetIOTxBytes int64 `json:"net_io_tx_bytes"` - BlockIORead int64 `json:"block_io_read"` - BlockIOWrite int64 `json:"block_io_write"` - CreatedAt time.Time `json:"created_at"` + ID int64 `json:"id"` + AppID int64 `json:"app_id"` + ServerID int64 `json:"server_id"` + ContainerName string `json:"container_name"` + CPUPercent float64 `json:"cpu_percent"` + MemUsageBytes int64 `json:"mem_usage_bytes"` + MemLimitBytes int64 `json:"mem_limit_bytes"` + MemPercent float64 `json:"mem_percent"` + NetIORxBytes int64 `json:"net_io_rx_bytes"` + NetIOTxBytes int64 `json:"net_io_tx_bytes"` + BlockIORead int64 `json:"block_io_read"` + BlockIOWrite int64 `json:"block_io_write"` + CreatedAt time.Time `json:"created_at"` } type RouteStats struct { diff --git a/internal/cloudflare/client.go b/internal/cloudflare/client.go index e3fb7d3..a83d7de 100644 --- a/internal/cloudflare/client.go +++ b/internal/cloudflare/client.go @@ -21,9 +21,9 @@ type DNSRecord struct { } type apiResponse struct { - Success bool `json:"success"` - Errors []apiError `json:"errors"` - Messages []string `json:"messages"` + Success bool `json:"success"` + Errors []apiError `json:"errors"` + Messages []string `json:"messages"` Result json.RawMessage `json:"result"` } diff --git a/internal/config/config.go b/internal/config/config.go index 2a21664..5005e2d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,8 +1,11 @@ package config import ( + "errors" + "net" "os" "path/filepath" + "strings" ) type Config struct { @@ -18,7 +21,8 @@ type Config struct { CloudflareAPIToken string CloudflareZoneID string - DevMock bool + AllowNoAuth bool + DevMock bool } func Load() *Config { @@ -34,7 +38,8 @@ func Load() *Config { CloudflareAPIToken: os.Getenv("CLOUDFLARE_API_TOKEN"), CloudflareZoneID: os.Getenv("CLOUDFLARE_ZONE_ID"), - DevMock: os.Getenv("DOCKIFY_DEV_MOCK") == "true", + AllowNoAuth: os.Getenv("DOCKIFY_ALLOW_NO_AUTH") == "true", + DevMock: os.Getenv("DOCKIFY_DEV_MOCK") == "true", } os.MkdirAll(cfg.DataDir, 0700) @@ -55,6 +60,18 @@ func (c *Config) AuthEnabled() bool { return c.AdminPass != "" } +// CheckAuth refuses a passwordless UI on a non-loopback bind unless the +// operator opted in with DOCKIFY_ALLOW_NO_AUTH=true. +func (c *Config) CheckAuth() error { + h := strings.Trim(c.Host, "[]") + if c.AdminPass != "" || c.AllowNoAuth || h == "localhost" || net.ParseIP(h).IsLoopback() { + return nil + } + return errors.New("refusing to start: no DOCKIFY_ADMIN_PASSWORD set and DOCKIFY_HOST=" + c.Host + + " is reachable from the network. Set DOCKIFY_ADMIN_PASSWORD, bind to DOCKIFY_HOST=127.0.0.1," + + " or set DOCKIFY_ALLOW_NO_AUTH=true to run without authentication") +} + func getEnv(key, defaultVal string) string { if v := os.Getenv(key); v != "" { return v diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6bc9964..5c212fe 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -69,3 +69,23 @@ func TestDBPath(t *testing.T) { t.Errorf("DBPath: expected /var/lib/dockify/dockify.db, got %s", cfg.DBPath()) } } + +func TestCheckAuth(t *testing.T) { + cases := []struct { + cfg Config + ok bool + }{ + {Config{Host: "0.0.0.0"}, false}, + {Config{Host: "10.0.0.5"}, false}, + {Config{Host: "0.0.0.0", AdminPass: "x"}, true}, + {Config{Host: "0.0.0.0", AllowNoAuth: true}, true}, + {Config{Host: "127.0.0.1"}, true}, + {Config{Host: "::1"}, true}, + {Config{Host: "localhost"}, true}, + } + for _, c := range cases { + if err := c.cfg.CheckAuth(); (err == nil) != c.ok { + t.Errorf("%+v: got err=%v, want ok=%v", c.cfg, err, c.ok) + } + } +} diff --git a/internal/server/service.go b/internal/server/service.go index f6c0dc2..320eb81 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -2,6 +2,7 @@ package server import ( "context" + "encoding/json" "fmt" "log" "strings" @@ -242,11 +243,18 @@ func (s *Service) InitWorker(id int64) error { log.Printf("Warning: failed to create dockify network: %v", err) } + // Never touch a caddy container (running or stopped) that Dockify did not create. + inspect, err := client.Exec(`docker inspect caddy --format '{{json .Mounts}} {{json .Config.Labels}}' 2>/dev/null`) + if err == nil && strings.TrimSpace(inspect) != "" && !caddyManaged(inspect) { + s.repo.UpdateStatus(id, StatusError) + return fmt.Errorf("a caddy container not created by Dockify exists on %s (running or stopped); remove or rename it first", server.Name) + } + caddyRunning, _ := client.Exec("docker ps -q --filter name=^/caddy$") baseConfig := `{"apps":{"http":{"servers":{"srv0":{"listen":[":80",":443"],"metrics":{}}}}}}` if strings.TrimSpace(caddyRunning) == "" { log.Printf("Deploying Caddy on %s...", server.Name) - caddyRun := fmt.Sprintf(`docker pull caddy:latest 2>/dev/null + caddyRun := fmt.Sprintf(`docker pull caddy:2 2>/dev/null if [ -d /opt/dockify/caddy/config.json ]; then sudo rm -rf /opt/dockify/caddy/config.json 2>/dev/null || rm -rf /opt/dockify/caddy/config.json 2>/dev/null fi @@ -263,8 +271,9 @@ docker run -d \ -p 127.0.0.1:2019:2019 \ -v caddy_data:/data \ -v /opt/dockify/caddy:/etc/caddy \ + --label dockify.managed=true \ --restart unless-stopped \ - caddy:latest caddy run --config /etc/caddy/config.json + caddy:2 caddy run --config /etc/caddy/config.json sleep 2 if docker ps -q --filter name=^/caddy$ --filter status=running | grep -q .; then if docker exec caddy curl -sf -o /dev/null -X PATCH http://localhost:2019/config/metrics -H 'Content-Type: application/json' -d '{}' 2>/dev/null; then @@ -288,7 +297,7 @@ fi`, baseConfig) } } else { log.Printf("Caddy already running on %s, checking config...", server.Name) - migrateCmd := `docker pull caddy:latest 2>/dev/null + migrateCmd := `docker pull caddy:2 2>/dev/null if [ -d /opt/dockify/caddy/config.json ]; then sudo rm -rf /opt/dockify/caddy/config.json 2>/dev/null || rm -rf /opt/dockify/caddy/config.json 2>/dev/null fi @@ -307,8 +316,9 @@ if docker exec caddy curl -sf -o /dev/null -X PATCH http://localhost:2019/config -p 127.0.0.1:2019:2019 \ -v caddy_data:/data \ -v /opt/dockify/caddy:/etc/caddy \ + --label dockify.managed=true \ --restart unless-stopped \ - caddy:latest caddy run --config /etc/caddy/config.json + caddy:2 caddy run --config /etc/caddy/config.json echo "METRICS_ENABLED" else echo "METRICS_UNAVAILABLE" @@ -331,6 +341,28 @@ fi` return nil } +// caddyManaged reports whether `docker inspect caddy --format '{{json .Mounts}} +// {{json .Config.Labels}}'` describes a container Dockify created: one mounting +// /opt/dockify/caddy (current layout) or /opt/dockify/caddy/config.json (old +// layout), or one labelled dockify.managed=true. +func caddyManaged(inspect string) bool { + dec := json.NewDecoder(strings.NewReader(inspect)) + var mounts []struct{ Source string } + var labels map[string]string + if dec.Decode(&mounts) != nil || dec.Decode(&labels) != nil { + return false + } + if labels["dockify.managed"] == "true" { + return true + } + for _, m := range mounts { + if m.Source == "/opt/dockify/caddy" || m.Source == "/opt/dockify/caddy/config.json" { + return true + } + } + return false +} + func (s *Service) RefreshResources(id int64) error { server, err := s.repo.Get(id) if err != nil || server == nil { diff --git a/internal/server/service_test.go b/internal/server/service_test.go index dd74c7a..af01ca6 100644 --- a/internal/server/service_test.go +++ b/internal/server/service_test.go @@ -173,3 +173,23 @@ func TestHostKeyStoreAndClear(t *testing.T) { } } } + +func TestCaddyManaged(t *testing.T) { + imageLabels := `{"org.opencontainers.image.title":"Caddy"}` + cases := []struct { + name, inspect string + want bool + }{ + {"current layout", `[{"Type":"volume","Name":"caddy_data","Source":"/var/lib/docker/volumes/caddy_data/_data","Destination":"/data"},{"Type":"bind","Source":"/opt/dockify/caddy","Destination":"/etc/caddy"}] ` + imageLabels, true}, + {"old layout", `[{"Type":"bind","Source":"/opt/dockify/caddy/config.json","Destination":"/data/config.json"}] ` + imageLabels, true}, + {"labelled", `[] {"dockify.managed":"true"}`, true}, + {"foreign", `[{"Type":"bind","Source":"/srv/caddy/Caddyfile","Destination":"/etc/caddy/Caddyfile"}] ` + imageLabels, false}, + {"foreign no labels", `[{"Type":"bind","Source":"/opt/dockify-other","Destination":"/etc/caddy"}] null`, false}, + {"garbage", `Error: No such object: caddy`, false}, + } + for _, c := range cases { + if got := caddyManaged(c.inspect + "\n"); got != c.want { + t.Errorf("%s: got %v, want %v", c.name, got, c.want) + } + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 86fc4e0..35b616c 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -8,8 +8,8 @@ import ( "fmt" "log" "net/http" - "os" "os/exec" + "regexp" "sync" "time" ) @@ -107,27 +107,33 @@ func (s *Service) CheckUpdate(force bool) (*UpdateInfo, error) { } func (s *Service) RunUpdate() error { - script := `#!/bin/bash -exec > /tmp/dockify-update.log 2>&1 -echo "Update started $(date)" -sleep 3 -export DOCKIFY_FORCE=y -curl -fsSL https://raw.githubusercontent.com/coderbuzz/dockify/main/scripts/update.sh | bash -echo "Update finished $(date)" -` - path := "/tmp/dockify-upgrade.sh" - if err := os.WriteFile(path, []byte(script), 0755); err != nil { - return fmt.Errorf("write upgrade script: %w", err) + info, _ := s.CheckUpdate(false) + argv, err := upgradeArgv(info.Latest) + if err != nil { + return err } - cmd := exec.Command("systemd-run", "--no-block", "--unit=dockify-upgrade", "--collect", path) - output, err := cmd.CombinedOutput() + output, err := exec.Command(argv[0], argv[1:]...).CombinedOutput() if err != nil { return fmt.Errorf("systemd-run: %w: %s", err, output) } - log.Printf("Update triggered via systemd-run: %s", output) + log.Printf("Update to %s triggered via systemd-run (journalctl -u dockify-upgrade): %s", info.Latest, output) return nil } +var releaseTag = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`) + +// upgradeArgv runs update.sh from the release tag in a transient unit, so it +// survives the dockify restart. Nothing is written to disk; output goes to the journal. +func upgradeArgv(tag string) ([]string, error) { + if !releaseTag.MatchString(tag) { + return nil, fmt.Errorf("no latest release tag known (got %q); check for updates first", tag) + } + url := "https://raw.githubusercontent.com/coderbuzz/dockify/" + tag + "/scripts/update.sh" + return []string{"systemd-run", "--no-block", "--unit=dockify-upgrade", "--collect", + "--setenv=DOCKIFY_FORCE=y", "--setenv=DOCKIFY_VERSION=" + tag, + "/bin/bash", "-c", "set -o pipefail; sleep 3; curl -fsSL " + url + " | bash"}, nil +} + func (s *Service) ensureWebhookSecret() { _, err := s.GetWebhookSecret() if err != nil { diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go new file mode 100644 index 0000000..5222ced --- /dev/null +++ b/internal/settings/settings_test.go @@ -0,0 +1,25 @@ +package settings + +import ( + "strings" + "testing" +) + +func TestUpgradeArgv(t *testing.T) { + argv, err := upgradeArgv("v0.7.0") + if err != nil { + t.Fatal(err) + } + all := strings.Join(argv, " ") + if !strings.Contains(all, "https://raw.githubusercontent.com/coderbuzz/dockify/v0.7.0/scripts/update.sh") { + t.Errorf("argv lacks the tag URL: %s", all) + } + if strings.Contains(all, "/tmp") || strings.Contains(all, "/main/") { + t.Errorf("argv uses /tmp or main: %s", all) + } + for _, bad := range []string{"", "v", "main", "v1.0.0; rm -rf /"} { + if _, err := upgradeArgv(bad); err == nil { + t.Errorf("tag %q accepted", bad) + } + } +} diff --git a/internal/ssh/mock.go b/internal/ssh/mock.go index 4e6da69..b6bdf31 100644 --- a/internal/ssh/mock.go +++ b/internal/ssh/mock.go @@ -23,9 +23,10 @@ func NewMockClient() *MockClient { {"docker stats", `{"BlockIO":"1.5MB / 0B","CPUPerc":"12.34%","Container":"abc12345","ID":"abc12345","MemPerc":"8.20%","MemUsage":"256MiB / 3.125GiB","Name":"app","NetIO":"45.6MB / 12.3MB","PIDs":"3"}`}, {"docker compose version", "Docker Compose version v2.24.0"}, {"docker compose", "app\n"}, + {"{{json .Mounts}}", `[{"Type":"bind","Source":"/opt/dockify/caddy","Destination":"/etc/caddy"}] {"dockify.managed":"true"}`}, {"docker inspect caddy", `["caddy","run","--config","/etc/caddy/config.json"]`}, {"localhost:2019/config/apps/http/servers/srv0/routes", "200"}, // add route (with -w http_code) - {"localhost:2019/id/", "200"}, // remove/verify route + {"localhost:2019/id/", "200"}, // remove/verify route {"docker", "Docker version 28.0.4, build b8034c0"}, {"uptime", "10:00:00 up 30 days, 0 users, load average: 0.08, 0.03, 0.01"}, {"nproc", "4"}, diff --git a/scripts/install.sh b/scripts/install.sh index fbbf6cd..9337650 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -28,12 +28,13 @@ prompt() { } prompt_secret() { - local var="$1" msg="$2" + local var="$1" msg="$2" required="$3" local val="${!var}" if [ -z "$val" ] && [ -n "$TTY" ]; then read -s -p "$msg" val < "$TTY" || true echo "" fi + if [ -z "$val" ] && [ -n "$required" ]; then echo "Error: $msg is required"; exit 1; fi printf -v "$var" "%s" "$val" } @@ -42,9 +43,9 @@ prompt MODE "Install mode (1=Docker Compose with Caddy, 2=Binary only, 3=Binary if [ "$MODE" = "2" ] || [ "$MODE" = "3" ]; then INSTALL_BIN="${INSTALL_BIN:-/usr/local/bin}" echo "" - echo "Admin credentials (optional - empty password = no auth)" + echo "Admin credentials" prompt ADMIN_USER "Admin username [admin]: " admin - prompt_secret ADMIN_PASS "Admin password (optional, Enter to skip): " + prompt_secret ADMIN_PASS "Admin password: " required prompt CF_TOKEN "Cloudflare API Token (optional): " prompt CF_ZONE "Cloudflare Zone ID (optional): " prompt BASE_PATH "Base path (optional): " @@ -160,9 +161,9 @@ else prompt CF_TOKEN "Cloudflare API Token: " prompt CF_ZONE "Cloudflare Zone ID: " echo "" - echo "Admin credentials (optional - empty password = no auth)" + echo "Admin credentials" prompt ADMIN_USER "Admin username [admin]: " admin - prompt_secret ADMIN_PASS "Admin password: " + prompt_secret ADMIN_PASS "Admin password: " required prompt BASE_PATH "Base path: " echo "" diff --git a/scripts/update-latest.sh b/scripts/update-latest.sh index a9dae7c..d092cef 100755 --- a/scripts/update-latest.sh +++ b/scripts/update-latest.sh @@ -42,11 +42,21 @@ fi TMP_DIR=$(mktemp -d) trap 'rm -rf "$TMP_DIR"' EXIT -BINARY="$TMP_DIR/dockify" +BINARY="$TMP_DIR/dockify-linux-amd64" +URL="https://github.com/coderbuzz/dockify/releases/latest/download/dockify-linux-amd64" echo "Downloading latest Dockify (no API call)..." -curl -fsSL -o "$BINARY" \ - "https://github.com/coderbuzz/dockify/releases/latest/download/dockify-linux-amd64" +curl -fsSL -o "$BINARY" "$URL" + +echo "Verifying checksum..." +if ! curl -fsSL -o "$BINARY.sha256" "$URL.sha256"; then + echo -e "${RED}Error: the latest release has no dockify-linux-amd64.sha256 (releases before checksums were published have none). Keeping current version.${NC}" + exit 1 +fi +if ! (cd "$TMP_DIR" && sha256sum -c dockify-linux-amd64.sha256); then + echo -e "${RED}Error: checksum mismatch. Keeping current version.${NC}" + exit 1 +fi chmod +x "$BINARY" echo "" diff --git a/scripts/update.sh b/scripts/update.sh index 8d65414..7d4200d 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -32,11 +32,14 @@ fi echo "Detected mode: $MODE" echo "" -# Fetch latest version from GitHub -echo "Fetching latest version..." -VERSION=$(curl -fsSL "https://api.github.com/repos/coderbuzz/dockify/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/') +# DOCKIFY_VERSION pins the release (set by the in-app updater); otherwise ask GitHub +VERSION="${DOCKIFY_VERSION:-}" +if [ -z "$VERSION" ]; then + echo "Fetching latest version..." + VERSION=$(curl -fsSL "https://api.github.com/repos/coderbuzz/dockify/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/') +fi if [ -z "$VERSION" ]; then echo "Error: could not fetch latest version"; exit 1; fi -echo "Latest: $VERSION" +echo "Version: $VERSION" echo "" if [ "$MODE" = "docker" ]; then @@ -52,11 +55,21 @@ fi # Use a temp dir so stale files never block a future run TMP_DIR=$(mktemp -d) trap 'rm -rf "$TMP_DIR"' EXIT -BINARY="$TMP_DIR/dockify" +BINARY="$TMP_DIR/dockify-linux-amd64" +URL="https://github.com/coderbuzz/dockify/releases/download/${VERSION}/dockify-linux-amd64" echo "Downloading Dockify $VERSION..." -curl -fsSL -o "$BINARY" \ - "https://github.com/coderbuzz/dockify/releases/download/${VERSION}/dockify-linux-amd64" +curl -fsSL -o "$BINARY" "$URL" + +echo "Verifying checksum..." +if ! curl -fsSL -o "$BINARY.sha256" "$URL.sha256"; then + echo -e "${RED}Error: $VERSION has no dockify-linux-amd64.sha256 (releases before checksums were published have none). Keeping current version.${NC}" + exit 1 +fi +if ! (cd "$TMP_DIR" && sha256sum -c dockify-linux-amd64.sha256); then + echo -e "${RED}Error: checksum mismatch for $VERSION. Keeping current version.${NC}" + exit 1 +fi chmod +x "$BINARY" echo ""