From 4c47819da8ec0a8842a724b78e954e0c690dfa44 Mon Sep 17 00:00:00 2001 From: m Date: Fri, 8 May 2026 11:28:02 +0200 Subject: [PATCH] fix(t-paliad-151): base64-decode PALIADIN_SSH_PRIVATE_KEY env var MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dokploy stores compose env vars in a single-line `.env` file, which silently truncates multi-line values to their first line. Empirically verified inside the running paliad container: a multi-line PEM arrived as just `-----BEGIN OPENSSH PRIVATE KEY-----\n` (36 bytes) and `ssh -i …` failed with `Load key: error in libcrypto`. decodePaliadinPrivateKey now accepts either: - raw PEM (multi-line, starts with `-----` and contains a newline) — used as-is for local-dev convenience - base64-encoded PEM — decoded into raw PEM. Survives the .env one-line-per-key round-trip. Whitespace (spaces / line breaks) inside the base64 blob is stripped before decoding so an OpenSSH-keygen-helper-style 64-char-wrap is also accepted. After deploy, m needs to update the Dokploy PALIADIN_SSH_PRIVATE_KEY secret to the base64-encoded form: base64 -w0 < ~/.paliad-staging/paliad-prod-key …and redeploy. Then sshd's libcrypto loads the key correctly and the shim's command= path runs. Refs m/paliad#12 --- cmd/server/main.go | 54 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) 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.