Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/dockify/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
242 changes: 207 additions & 35 deletions internal/backup/backup.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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)
Expand All @@ -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)
}

Expand All @@ -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)
Expand All @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -166,24 +311,53 @@ 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"
}
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
Expand Down Expand Up @@ -214,5 +388,3 @@ func (s *Service) findAppByName(name string) (*app.App, error) {
}
return nil, nil
}


17 changes: 13 additions & 4 deletions internal/backup/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})) {
Expand All @@ -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 {
Expand All @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading
Loading