diff --git a/cmd/server/main.go b/cmd/server/main.go index 32c9ca0..8bb6bbe 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/base64" "fmt" "log" "net/http" @@ -9,6 +10,7 @@ import ( "os/exec" "os/signal" "strconv" + "strings" "syscall" // Embed Go's IANA tz database into the binary so time.LoadLocation works @@ -262,7 +264,15 @@ func buildPaliadinRemoteConfig(host string) (services.RemotePaliadinConfig, erro cfg.SSHPort = n } - keyPath, err := writeSecretFile("paliadin-id_ed25519-", os.Getenv("PALIADIN_SSH_PRIVATE_KEY"), 0o600) + // Dokploy stores compose env vars in a single-line .env file: multi-line + // PEM bodies get truncated to the first line. Base64-encode the + // private key in the secret to survive that round-trip; here we + // detect base64 vs raw PEM and decode either way. + keyBlob, err := decodePaliadinPrivateKey(os.Getenv("PALIADIN_SSH_PRIVATE_KEY")) + if err != nil { + return cfg, fmt.Errorf("PALIADIN_SSH_PRIVATE_KEY: %w", err) + } + keyPath, err := writeSecretFile("paliadin-id_ed25519-", keyBlob, 0o600) if err != nil { return cfg, fmt.Errorf("PALIADIN_SSH_PRIVATE_KEY: %w", err) } @@ -283,6 +293,48 @@ func buildPaliadinRemoteConfig(host string) (services.RemotePaliadinConfig, erro return cfg, nil } +// decodePaliadinPrivateKey accepts either a raw PEM (multi-line) or a +// base64-encoded PEM. Returns the raw PEM bytes ready to write to a +// keyfile. Empty input → ("", nil) so the caller can distinguish +// "secret not set" from "decode failed". +// +// Why base64: Dokploy stores compose env vars in a one-line-per-key +// .env file, which silently truncates multi-line values to their first +// line. Empirically, a multi-line `-----BEGIN OPENSSH PRIVATE KEY-----` +// arrived inside the container as just the BEGIN header (36 bytes). +// Base64-encoding the key in the Dokploy secret survives that +// round-trip. We still accept raw PEM for local-dev convenience. +func decodePaliadinPrivateKey(blob string) (string, error) { + blob = strings.TrimSpace(blob) + if blob == "" { + return "", nil + } + // Raw PEM: starts with ----- and contains a newline. Use as-is. + if strings.HasPrefix(blob, "-----") && strings.Contains(blob, "\n") { + return blob + "\n", nil + } + // Otherwise treat as base64. Strip any whitespace OpenSSH keygen + // helpers might insert (line breaks every 64 chars in some tools). + clean := strings.Map(func(r rune) rune { + if r == ' ' || r == '\n' || r == '\r' || r == '\t' { + return -1 + } + return r + }, blob) + decoded, err := base64.StdEncoding.DecodeString(clean) + if err != nil { + return "", fmt.Errorf("not raw PEM (no newline) and base64 decode failed: %w", err) + } + out := string(decoded) + if !strings.HasPrefix(out, "-----BEGIN") { + return "", fmt.Errorf("decoded body does not look like a PEM key (no -----BEGIN prefix)") + } + if !strings.HasSuffix(out, "\n") { + out += "\n" + } + return out, nil +} + // writeSecretFile writes blob to a tmpfile with the given mode and // returns its path. Returns ("", nil) when blob is empty so callers // can distinguish "not set" from real I/O errors.