diff --git a/README.md b/README.md index 2045384..2b21a7e 100644 --- a/README.md +++ b/README.md @@ -460,12 +460,12 @@ Export your configuration (servers + apps + secrets + config files) as YAML and Backups can be protected with a passphrase using **AES-GCM encryption** (PBKDF2 key derivation, 600,000 iterations): -- Secrets are encrypted before export with prefix `enc:` -- Auth passwords and config file contents are encrypted +- Secrets, SSH keys, auth passwords, and config file contents are encrypted before export +- Encrypted values use the prefix `enc:` with base64 salt + nonce + ciphertext - The export page includes a client-side passphrase generator (32-character hex, via `crypto.getRandomValues`) -- Without a passphrase, secrets are exported as plaintext (still safe for offline backups) +- Without a passphrase, sensitive data is exported as plaintext (still useful for offline/internal backups) -SSH keys are **never** exported — you must re-enter them after import. +When importing on a new instance, all servers and their SSH keys are restored automatically — no need to re-enter keys one by one, as long as the target VMs are reachable or use the same key pair. ### Import diff --git a/SPEC.md b/SPEC.md index 2e60aa2..e4da57e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -350,20 +350,20 @@ Init is idempotent — re-running skips components that already exist (Caddy con ### Export -1. Query all servers (name, host, port, user — **no SSH keys**) +1. Query all servers (name, host, port, user, SSH key) 2. Query all apps (name, domain, port, compose, git_repo, git_branch, auth_user, auth_pass, compose_mode, server name mapping) 3. Query all app secrets and config files -4. If passphrase provided: encrypt secrets, auth_pass, and file contents with AES-GCM (PBKDF2 key derivation, 600,000 iterations, `enc:` prefix) +4. If passphrase provided: encrypt SSH keys, secrets, auth_pass, and file contents with AES-GCM (PBKDF2 key derivation, 600,000 iterations, `enc:` prefix) 5. Generate YAML document 6. Download as `dockify-config.yaml` ### Import 1. Parse YAML file -2. If passphrase provided: attempt decryption of all `enc:` values to validate passphrase +2. If passphrase provided: attempt decryption of all `enc:` values to validate passphrase (including SSH keys) 3. In **merge** mode (default): skip entries that already exist by same name 4. In **replace** mode: delete all existing servers and apps, then import -5. Create servers (user must re-enter SSH keys after import) +5. Create servers with SSH key from export (fallback to `"pending"` if key is empty — user must enter key manually) 6. Create apps (status = created, ready for deploy) 7. Import secrets and config files 8. Redirect to servers page diff --git a/internal/backup/backup.go b/internal/backup/backup.go index 0a48cea..d1e82b8 100644 --- a/internal/backup/backup.go +++ b/internal/backup/backup.go @@ -25,10 +25,11 @@ type ExportData struct { } type ExportServer struct { - Name string `yaml:"name"` - Host string `yaml:"host"` - Port int `yaml:"port"` - User string `yaml:"user"` + Name string `yaml:"name"` + Host string `yaml:"host"` + Port int `yaml:"port"` + User string `yaml:"user"` + SSHKey string `yaml:"ssh_key,omitempty"` } type ExportSecret struct { @@ -157,12 +158,23 @@ func (s *Service) Export(passphrase string) (string, error) { serverNameMap := map[int64]string{} for _, svr := range servers { serverNameMap[svr.ID] = svr.Name - data.Servers = append(data.Servers, ExportServer{ + es := ExportServer{ Name: svr.Name, Host: svr.Host, Port: svr.Port, User: svr.User, - }) + } + if svr.SSHKey != "" { + if passphrase != "" { + es.SSHKey, err = encrypt(svr.SSHKey, passphrase) + if err != nil { + return "", fmt.Errorf("encrypt ssh_key for %q: %w", svr.Name, err) + } + } else { + es.SSHKey = svr.SSHKey + } + } + data.Servers = append(data.Servers, es) } for _, a := range apps { @@ -229,9 +241,8 @@ func (s *Service) Export(passphrase string) (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 and config file contents are encrypted with the provided passphrase.\n") + sb.WriteString("# All sensitive values (secrets, auth passwords, config files, SSH keys) are encrypted.\n") } sb.WriteString("# Remove or edit entries before import as needed.\n") sb.Write(out) @@ -250,6 +261,11 @@ func (s *Service) Import(yamlData, passphrase, mode string) (string, error) { var logLines []string + for _, es := range data.Servers { + if err := validateDecrypt(es.SSHKey, passphrase); err != nil { + return "", fmt.Errorf("server %q ssh_key: %w", es.Name, err) + } + } for _, ea := range data.Apps { if err := validateDecrypt(ea.AuthPass, passphrase); err != nil { return "", fmt.Errorf("%q: %w", ea.Name, err) @@ -289,12 +305,23 @@ func (s *Service) Import(yamlData, passphrase, mode string) (string, error) { serverIDs[es.Name] = existing.ID continue } + sshKey := es.SSHKey + if passphrase != "" && es.SSHKey != "" { + sshKey, err = decrypt(es.SSHKey, passphrase) + if err != nil { + return strings.Join(logLines, "\n"), fmt.Errorf("server %q: wrong passphrase or corrupted data", es.Name) + } + } + if sshKey == "" { + sshKey = "pending" + } + svr := &server.Server{ Name: es.Name, Host: es.Host, Port: es.Port, User: es.User, - SSHKey: "pending", + SSHKey: sshKey, Status: "pending", } if svr.Port == 0 { diff --git a/internal/http/templates/export.html b/internal/http/templates/export.html index 45b2480..2333430 100644 --- a/internal/http/templates/export.html +++ b/internal/http/templates/export.html @@ -17,7 +17,7 @@

Download Backup

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

- ⚠ Secret values will be encrypted with this passphrase. - Store it safely — without it you cannot import this backup. + ⚠ All sensitive values (secrets, auth passwords, SSH keys, config files) will be encrypted. + Store the passphrase safely — without it you cannot import this backup.

@@ -42,7 +42,7 @@

Download Backup

What's exported