Merge: t-paliad-151 fix — base64-decode PALIADIN_SSH_PRIVATE_KEY env var

Dokploy's .env mechanism truncates multi-line env vars to first line.
Empirically: the multi-line PEM arrived as just `-----BEGIN OPENSSH
PRIVATE KEY-----\n` (36 bytes) inside the container, ssh -i failed
with `Load key: error in libcrypto`.

Go now decodes the env value as either raw PEM (multi-line) or
base64-encoded PEM. Whitespace inside base64 stripped before decode.
Dokploy secret already updated to the base64 form alongside this
merge.

Refs m/paliad#12
This commit is contained in:
m
2026-05-08 11:28:53 +02:00

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"encoding/base64"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
@@ -9,6 +10,7 @@ import (
"os/exec" "os/exec"
"os/signal" "os/signal"
"strconv" "strconv"
"strings"
"syscall" "syscall"
// Embed Go's IANA tz database into the binary so time.LoadLocation works // 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 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 { if err != nil {
return cfg, fmt.Errorf("PALIADIN_SSH_PRIVATE_KEY: %w", err) return cfg, fmt.Errorf("PALIADIN_SSH_PRIVATE_KEY: %w", err)
} }
@@ -283,6 +293,48 @@ func buildPaliadinRemoteConfig(host string) (services.RemotePaliadinConfig, erro
return cfg, nil 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 // writeSecretFile writes blob to a tmpfile with the given mode and
// returns its path. Returns ("", nil) when blob is empty so callers // returns its path. Returns ("", nil) when blob is empty so callers
// can distinguish "not set" from real I/O errors. // can distinguish "not set" from real I/O errors.