From 11a0b48d770926bef9c5d7379b5219e329585339 Mon Sep 17 00:00:00 2001 From: Indra Gunawan Date: Wed, 1 Jul 2026 06:31:40 +0700 Subject: [PATCH 1/2] feat: improve dev experience - add clickable URL log line on startup - fix SSHKeyDir default to derive from DataDir - add meta refresh 2s on server init status - change app deploy meta refresh from 3s to 2s --- cmd/dockify/main.go | 1 + internal/config/config.go | 5 +++-- internal/http/templates/apps_detail.html | 2 +- internal/http/templates/servers_detail.html | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/cmd/dockify/main.go b/cmd/dockify/main.go index eb13870..f03cae7 100644 --- a/cmd/dockify/main.go +++ b/cmd/dockify/main.go @@ -95,6 +95,7 @@ func main() { go func() { log.Printf("Dockify v%s starting on %s", version, cfg.Addr()) + log.Printf("Open: http://localhost:%s", cfg.Port) log.Printf("Data dir: %s", cfg.DataDir) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("failed to serve: %v", err) diff --git a/internal/config/config.go b/internal/config/config.go index 8c35e80..5ac85ee 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,11 +21,12 @@ type Config struct { } func Load() *Config { + dataDir := getEnv("DOCKIFY_DATA_DIR", "/var/lib/dockify") cfg := &Config{ Host: getEnv("DOCKIFY_HOST", "0.0.0.0"), Port: getEnv("DOCKIFY_PORT", "8080"), - DataDir: getEnv("DOCKIFY_DATA_DIR", "/var/lib/dockify"), - SSHKeyDir: getEnv("DOCKIFY_SSH_KEY_DIR", "/var/lib/dockify/keys"), + DataDir: dataDir, + SSHKeyDir: getEnv("DOCKIFY_SSH_KEY_DIR", filepath.Join(dataDir, "keys")), AdminUser: getEnv("DOCKIFY_ADMIN_USER", "admin"), AdminPass: os.Getenv("DOCKIFY_ADMIN_PASSWORD"), CloudflareAPIToken: os.Getenv("CLOUDFLARE_API_TOKEN"), diff --git a/internal/http/templates/apps_detail.html b/internal/http/templates/apps_detail.html index db0e966..ab16dcd 100644 --- a/internal/http/templates/apps_detail.html +++ b/internal/http/templates/apps_detail.html @@ -1,6 +1,6 @@ -{{template "head" .}}{{if eq .App.Status "deploying"}}{{end}} +{{template "head" .}}{{if eq .App.Status "deploying"}}{{end}} {{template "nav" .}}
diff --git a/internal/http/templates/servers_detail.html b/internal/http/templates/servers_detail.html index 6340f84..da53120 100644 --- a/internal/http/templates/servers_detail.html +++ b/internal/http/templates/servers_detail.html @@ -1,6 +1,6 @@ -{{template "head" .}} +{{template "head" .}}{{if eq .Server.Status "initializing"}}{{end}} {{template "nav" .}}
From 28bcb84bd75b2684e0f7455cab3396eb62d7c0a9 Mon Sep 17 00:00:00 2001 From: Indra Gunawan Date: Wed, 1 Jul 2026 07:40:27 +0700 Subject: [PATCH 2/2] feat: export/import with encrypted secrets, config files, and unique_service_name - AES-256-GCM + PBKDF2 encryption for secret values on export - Passphrase form (auto-generate + copy) on export page - Passphrase input on import page - Validate all encrypted values before executing import - Export/import secrets, config files, and unique_service_name - Fix export route from GET to POST - Human-friendly error messages for decrypt failures - Equal-height cards in app detail grid - Fix settings page links to use absolute paths --- internal/backup/backup.go | 242 +++++++++++++++++++---- internal/backup/handler.go | 17 +- internal/http/router.go | 2 +- internal/http/templates/apps_detail.html | 4 +- internal/http/templates/export.html | 73 ++++++- internal/http/templates/import.html | 7 +- internal/http/templates/layout.html | 1 + internal/http/templates/settings.html | 4 +- 8 files changed, 296 insertions(+), 54 deletions(-) diff --git a/internal/backup/backup.go b/internal/backup/backup.go index c7e5f34..9b3b801 100644 --- a/internal/backup/backup.go +++ b/internal/backup/backup.go @@ -1,7 +1,14 @@ package backup import ( + "crypto/aes" + "crypto/cipher" + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha256" + "encoding/base64" "fmt" + "io" "strings" "github.com/coderbuzz/dockify/internal/app" @@ -12,9 +19,9 @@ import ( const Version = 1 type ExportData struct { - Version int `yaml:"version"` - Servers []ExportServer `yaml:"servers"` - Apps []ExportApp `yaml:"apps"` + Version int `yaml:"version"` + Servers []ExportServer `yaml:"servers"` + Apps []ExportApp `yaml:"apps"` } type ExportServer struct { @@ -24,16 +31,29 @@ type ExportServer struct { User string `yaml:"user"` } +type ExportSecret struct { + Key string `yaml:"key"` + Value string `yaml:"value"` +} + +type ExportFile struct { + Path string `yaml:"path"` + Content string `yaml:"content"` +} + type ExportApp struct { - Name string `yaml:"name"` - ServerName string `yaml:"server_name"` - Domain string `yaml:"domain"` - Port int `yaml:"port"` - Compose string `yaml:"compose"` - GitRepo string `yaml:"git_repo,omitempty"` - GitBranch string `yaml:"git_branch,omitempty"` - AuthUser string `yaml:"auth_user,omitempty"` - AuthPass string `yaml:"auth_pass,omitempty"` + Name string `yaml:"name"` + ServerName string `yaml:"server_name"` + Domain string `yaml:"domain"` + Port int `yaml:"port"` + Compose string `yaml:"compose"` + GitRepo string `yaml:"git_repo,omitempty"` + GitBranch string `yaml:"git_branch,omitempty"` + AuthUser string `yaml:"auth_user,omitempty"` + AuthPass string `yaml:"auth_pass,omitempty"` + UniqueServiceName bool `yaml:"unique_service_name,omitempty"` + Secrets []ExportSecret `yaml:"secrets,omitempty"` + Files []ExportFile `yaml:"files,omitempty"` } type Service struct { @@ -46,7 +66,82 @@ func NewService(serverSvc *server.Service, appSvc *app.Service, keyDir string) * return &Service{serverSvc: serverSvc, appSvc: appSvc, keyDir: keyDir} } -func (s *Service) Export() (string, error) { +func encrypt(plaintext, passphrase string) (string, error) { + salt := make([]byte, 16) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return "", err + } + key, err := pbkdf2.Key(sha256.New, passphrase, salt, 600000, 32) + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, aead.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + ciphertext := aead.Seal(nil, nonce, []byte(plaintext), nil) + raw := make([]byte, 0, len(salt)+len(nonce)+len(ciphertext)) + raw = append(raw, salt...) + raw = append(raw, nonce...) + raw = append(raw, ciphertext...) + return "enc:" + base64.StdEncoding.EncodeToString(raw), nil +} + +func decrypt(encoded, passphrase string) (string, error) { + if !strings.HasPrefix(encoded, "enc:") { + return encoded, nil + } + raw, err := base64.StdEncoding.DecodeString(encoded[4:]) + if err != nil { + return "", fmt.Errorf("decode: %w", err) + } + if len(raw) < 28 { + return "", fmt.Errorf("invalid encrypted data") + } + salt, raw := raw[:16], raw[16:] + nonce, ciphertext := raw[:12], raw[12:] + key, err := pbkdf2.Key(sha256.New, passphrase, salt, 600000, 32) + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + plaintext, err := aead.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("message authentication failed: %w", err) + } + return string(plaintext), nil +} + +func validateDecrypt(value, passphrase string) error { + if !strings.HasPrefix(value, "enc:") { + return nil + } + if passphrase == "" { + return fmt.Errorf("has an encrypted value but no passphrase was provided") + } + _, err := decrypt(value, passphrase) + if err != nil { + return fmt.Errorf("wrong passphrase or corrupted data") + } + return nil +} + +func (s *Service) Export(passphrase string) (string, error) { servers, err := s.serverSvc.List() if err != nil { return "", fmt.Errorf("list servers: %w", err) @@ -72,16 +167,51 @@ func (s *Service) Export() (string, error) { for _, a := range apps { ea := ExportApp{ - Name: a.Name, - ServerName: serverNameMap[a.ServerID], - Domain: a.Domain, - Port: a.Port, - Compose: a.Compose, - GitRepo: a.GitRepo, - GitBranch: a.GitBranch, - AuthUser: a.AuthUser, - AuthPass: a.AuthPass, + Name: a.Name, + ServerName: serverNameMap[a.ServerID], + Domain: a.Domain, + Port: a.Port, + Compose: a.Compose, + GitRepo: a.GitRepo, + GitBranch: a.GitBranch, + AuthUser: a.AuthUser, + UniqueServiceName: a.UniqueServiceName, + } + + if a.AuthPass != "" { + if passphrase != "" { + ea.AuthPass, err = encrypt(a.AuthPass, passphrase) + if err != nil { + return "", fmt.Errorf("encrypt auth_pass for %q: %w", a.Name, err) + } + } else { + ea.AuthPass = a.AuthPass + } } + + secrets, err := s.appSvc.ListSecrets(a.ID) + if err != nil { + return "", fmt.Errorf("list secrets for %q: %w", a.Name, err) + } + for _, sec := range secrets { + val := sec.Value + if passphrase != "" { + val, err = encrypt(sec.Value, passphrase) + if err != nil { + return "", fmt.Errorf("encrypt secret %q for %q: %w", sec.Key, a.Name, err) + } + } + ea.Secrets = append(ea.Secrets, ExportSecret{Key: sec.Key, Value: val}) + } + + files, err := s.appSvc.ListFiles(a.ID) + if err != nil { + return "", fmt.Errorf("list files for %q: %w", a.Name, err) + } + for _, f := range files { + ea.Files = append(ea.Files, ExportFile{Path: f.Path, Content: f.Content}) + } + data.Apps = append(data.Apps, ea) } @@ -93,12 +223,15 @@ func (s *Service) Export() (string, error) { sb := strings.Builder{} sb.WriteString("# Dockify Configuration Export\n") sb.WriteString("# Auth passwords (if present) are included as saved. SSH keys are not exported.\n") + if passphrase != "" { + sb.WriteString("# Secret values are encrypted with the provided passphrase.\n") + } sb.WriteString("# Remove or edit entries before import as needed.\n") sb.Write(out) return sb.String(), nil } -func (s *Service) Import(yamlData string, mode string) (string, error) { +func (s *Service) Import(yamlData, passphrase, mode string) (string, error) { var data ExportData if err := yaml.Unmarshal([]byte(yamlData), &data); err != nil { return "", fmt.Errorf("parse yaml: %w", err) @@ -110,6 +243,17 @@ func (s *Service) Import(yamlData string, mode string) (string, error) { var logLines []string + for _, ea := range data.Apps { + if err := validateDecrypt(ea.AuthPass, passphrase); err != nil { + return "", fmt.Errorf("%q: %w", ea.Name, err) + } + for _, sec := range ea.Secrets { + if err := validateDecrypt(sec.Value, passphrase); err != nil { + return "", fmt.Errorf("%q secret %q: %w", ea.Name, sec.Key, err) + } + } + } + if mode == "replace" { apps, _ := s.appSvc.List() for _, a := range apps { @@ -124,6 +268,7 @@ func (s *Service) Import(yamlData string, mode string) (string, error) { } serverIDs := map[string]int64{} + var err error for _, es := range data.Servers { existing, _ := s.findServerByName(es.Name) @@ -166,16 +311,25 @@ func (s *Service) Import(yamlData string, mode string) (string, error) { continue } + authPass := ea.AuthPass + if passphrase != "" { + authPass, err = decrypt(ea.AuthPass, passphrase) + if err != nil { + return strings.Join(logLines, "\n"), fmt.Errorf("%q: wrong passphrase or corrupted data", ea.Name) + } + } + ap := &app.App{ - Name: ea.Name, - ServerID: sid, - Domain: ea.Domain, - Port: ea.Port, - Compose: ea.Compose, - GitRepo: ea.GitRepo, - GitBranch: ea.GitBranch, - AuthUser: ea.AuthUser, - AuthPass: ea.AuthPass, + Name: ea.Name, + ServerID: sid, + Domain: ea.Domain, + Port: ea.Port, + Compose: ea.Compose, + GitRepo: ea.GitRepo, + GitBranch: ea.GitBranch, + AuthUser: ea.AuthUser, + AuthPass: authPass, + UniqueServiceName: ea.UniqueServiceName, } if ap.GitBranch == "" { ap.GitBranch = "main" @@ -183,7 +337,27 @@ func (s *Service) Import(yamlData string, mode string) (string, error) { if err := s.appSvc.Create(ap); err != nil { return strings.Join(logLines, "\n"), fmt.Errorf("create app %q: %w", ea.Name, err) } - logLines = append(logLines, fmt.Sprintf("Created app %q (%s)", ea.Name, ea.Domain)) + + for _, sec := range ea.Secrets { + val := sec.Value + if passphrase != "" { + val, err = decrypt(sec.Value, passphrase) + if err != nil { + return strings.Join(logLines, "\n"), fmt.Errorf("%q: wrong passphrase or corrupted data", ea.Name) + } + } + if err := s.appSvc.SetSecret(ap.ID, sec.Key, val); err != nil { + return strings.Join(logLines, "\n"), fmt.Errorf("set secret %q for %q: %w", sec.Key, ea.Name, err) + } + } + + for _, f := range ea.Files { + if err := s.appSvc.SetFile(ap.ID, f.Path, f.Content); err != nil { + return strings.Join(logLines, "\n"), fmt.Errorf("set file %q for %q: %w", f.Path, ea.Name, err) + } + } + + logLines = append(logLines, fmt.Sprintf("Created app %q (%s) with %d secrets and %d files", ea.Name, ea.Domain, len(ea.Secrets), len(ea.Files))) } return strings.Join(logLines, "\n"), nil @@ -214,5 +388,3 @@ func (s *Service) findAppByName(name string) (*app.App, error) { } return nil, nil } - - diff --git a/internal/backup/handler.go b/internal/backup/handler.go index 82aaf7f..36a5c45 100644 --- a/internal/backup/handler.go +++ b/internal/backup/handler.go @@ -14,18 +14,26 @@ func NewHandler(service *Service) *Handler { } func (h *Handler) ExportDownload(w http.ResponseWriter, r *http.Request) { - yamlStr, err := h.service.Export() + passphrase := r.FormValue("passphrase") + + yamlStr, err := h.service.Export(passphrase) if err != nil { http.Error(w, "export: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/x-yaml; charset=utf-8") - w.Header().Set("Content-Disposition", "attachment; filename=dockify-export.yaml") + if passphrase != "" { + w.Header().Set("Content-Disposition", "attachment; filename=dockify-export.yaml") + } else { + w.Header().Set("Content-Disposition", "attachment; filename=dockify-export.yaml") + } w.Write([]byte(yamlStr)) } func (h *Handler) ExportPage(w http.ResponseWriter, r *http.Request, render func(w http.ResponseWriter, r *http.Request, status int, name string, data interface{})) { - render(w, r, http.StatusOK, "export.html", map[string]interface{}{"Title": "Export"}) + render(w, r, http.StatusOK, "export.html", map[string]interface{}{ + "Title": "Export", + }) } func (h *Handler) ImportPage(w http.ResponseWriter, r *http.Request, render func(w http.ResponseWriter, r *http.Request, status int, name string, data interface{})) { @@ -40,6 +48,7 @@ func (h *Handler) ImportUpload(w http.ResponseWriter, r *http.Request, render fu if mode != "replace" && mode != "merge" { mode = "merge" } + passphrase := r.FormValue("passphrase") file, _, err := r.FormFile("file") if err != nil { @@ -60,7 +69,7 @@ func (h *Handler) ImportUpload(w http.ResponseWriter, r *http.Request, render fu return } - logOutput, err := h.service.Import(string(raw), mode) + logOutput, err := h.service.Import(string(raw), passphrase, mode) if err != nil { render(w, r, http.StatusOK, "import.html", map[string]interface{}{ "Title": "Import", diff --git a/internal/http/router.go b/internal/http/router.go index 727fe81..dbf6240 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -188,7 +188,7 @@ func NewRouter(svc *server.Service, appSvc *app.Service, render RenderFunc, serv r.Get("/export", func(w http.ResponseWriter, r *http.Request) { backupHandler.ExportPage(w, r, render) }) - r.Get("/api/backup/export", backupHandler.ExportDownload) + r.Post("/api/backup/export", backupHandler.ExportDownload) r.Get("/import", func(w http.ResponseWriter, r *http.Request) { backupHandler.ImportPage(w, r, render) }) diff --git a/internal/http/templates/apps_detail.html b/internal/http/templates/apps_detail.html index ab16dcd..11d7b75 100644 --- a/internal/http/templates/apps_detail.html +++ b/internal/http/templates/apps_detail.html @@ -15,7 +15,7 @@

{{.App.Name}} {{.App.Status}}{{if .App.Domain}}{{.App.Domain}}:{{.App.Port}}{{else}}Internal Service{{end}}

-
+

Details

@@ -31,7 +31,7 @@

Details

Docker Compose

-
{{.App.Compose}}
+
{{.App.Compose}}
diff --git a/internal/http/templates/export.html b/internal/http/templates/export.html index cabdc9c..45b2480 100644 --- a/internal/http/templates/export.html +++ b/internal/http/templates/export.html @@ -12,24 +12,79 @@

Export Configuration

-
-

Download Backup

-

- Export all servers and apps configuration to a YAML file. - SSH keys are not exported — you must re-add them after import. -

- Download YAML -
+ +
+

Download Backup

+

+ Export all servers and apps configuration to a YAML file. + SSH keys are not exported — you must re-add them after import. +

+ + + +

+ ⚠ Secret values will be encrypted with this passphrase. + Store it safely — without it you cannot import this backup. +

+ + +
+

What's exported

  • Server: name, host, port, user (no SSH key)
  • -
  • Apps: name, domain, port, compose, git repo/branch, auth credentials
  • +
  • Apps: name, domain, port, compose, git repo/branch, auth credentials, secrets, config files
  • +
  • Secrets are encrypted with your passphrase
  • You can edit the YAML file to remove entries before importing
+ + + {{template "scripts" .}} diff --git a/internal/http/templates/import.html b/internal/http/templates/import.html index 5c0e572..298987d 100644 --- a/internal/http/templates/import.html +++ b/internal/http/templates/import.html @@ -19,13 +19,18 @@
{{.Message}}
{{end}} - +

Upload Export File

+ +
Import Mode
@@ -61,7 +61,7 @@

Import

Import servers and apps from a previously exported YAML file. Supports merge and replace modes.

- Go to Import Page + Go to Import Page