feat(t-paliad-151) RemotePaliadinService + main.go env-var routing

Phase B step 2: lands the Paliadin backend that talks to mRiver via
ssh + paliadin-shim. Local backend untouched — selection happens in
cmd/server/main.go based on PALIADIN_REMOTE_HOST.

Files:
- internal/services/paliadin_remote.go (new) — RemotePaliadinService
  + RemotePaliadinConfig, with five SSH knobs (Host/Port/User/KeyPath/
  KnownHostsPath). RunTurn does insertTurnRow → healthGate → bootstrap
  → callShim run-turn → splitTrailer → completeTurn, mirroring the
  local path's audit-row contract. ResetSession sends shim 'reset'.
  callShim runs `ssh -F /dev/null -i <key> -p <port> -o … host -- verb
  args`; ControlMaster intentionally not enabled (design §6.8).
- internal/services/paliadin_remote.go also adds DisabledPaliadinService
  (returns ErrPaliadinDisabled from RunTurn/ResetSession; DB methods
  inherited from paliadinDB still work) so cmd/server/main.go can wire
  a non-nil Paliadin even when neither local tmux nor remote SSH is
  available.
- ErrMRiverUnreachable sentinel for the friendly error code.
- classifySSHError translates ssh exit 124 / Permission denied /
  network errors into the audit-row error_code field.
- Compile-time conformance: var _ Paliadin = (*Local|*Remote|*Disabled)
  PaliadinService(nil).

cmd/server/main.go switch:
  PALIADIN_REMOTE_HOST set → NewRemotePaliadinService
  else: tmux on PATH → NewLocalPaliadinService
  else: NewDisabledPaliadinService

buildPaliadinRemoteConfig materialises PALIADIN_SSH_PRIVATE_KEY +
PALIADIN_KNOWN_HOSTS (multi-line Dokploy secrets) into chmod-600/644
tmpfiles at boot. Defaults: SSHUser=m, SSHPort=22022 (bypasses
Tailscale SSH on :22, see design §4.5). Fails fast on a configured
remote-host without the matching key/known_hosts secrets.

Local-tmux mode now requires `tmux` actually be on PATH at boot
(exec.LookPath gate); previously the constructor unconditionally
returned a service whose RunTurn would fail at runtime with
ErrTmuxUnavailable. The handler-level "friendly error" UX is
unchanged: DisabledPaliadinService surfaces ErrPaliadinDisabled which
the frontend renders the same way.

Build green; existing paliadin_test.go still passes (it tests
package-level helpers, untouched). Remote-specific tests land in B4.

Refs m/paliad#12
This commit is contained in:
m
2026-05-08 02:16:50 +02:00
parent 56a3dc961e
commit 0c8a2f1a95
2 changed files with 433 additions and 14 deletions

View File

@@ -2,10 +2,13 @@ package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"os/signal"
"strconv"
"syscall"
// Embed Go's IANA tz database into the binary so time.LoadLocation works
@@ -165,20 +168,34 @@ func main() {
CardLayout: services.NewCardLayoutService(pool),
}
// t-paliad-146 — Paliadin PoC. Always wired when DATABASE_URL
// is set; the per-request handler gate (requirePaliadinOwner)
// restricts access to the single owner email
// (services.PaliadinOwnerEmail). All other authenticated users
// get a 404 — the route effectively does not exist for them.
// On hosts without tmux + the `claude` CLI (e.g. the Dokploy
// container), the owner gate still applies; if m ever hits the
// route from such a host, the service returns "tmux unavailable"
// without ever invoking shell-out.
tmuxSession := os.Getenv("PALIADIN_TMUX_SESSION")
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
svcBundle.Paliadin = services.NewLocalPaliadinService(pool, users, tmuxSession, responseDir)
log.Printf("paliadin: wired (owner=%s; gate is per-request, not per-deploy)",
services.PaliadinOwnerEmail)
// Paliadin backend selection (t-paliad-146 + t-paliad-151):
// PALIADIN_REMOTE_HOST set → RemotePaliadinService (ssh to mRiver)
// else: local tmux available → LocalPaliadinService (PoC path)
// else: DisabledPaliadinService (handlers still 404 for non-owners
// via the gate; for m, RunTurn returns ErrPaliadinDisabled
// which surfaces as a friendly error).
//
// All three implement services.Paliadin; the per-request handler
// gate (requirePaliadinOwner) is unchanged and applies to every
// backend.
if remoteHost := os.Getenv("PALIADIN_REMOTE_HOST"); remoteHost != "" {
cfg, err := buildPaliadinRemoteConfig(remoteHost)
if err != nil {
log.Fatalf("paliadin: remote config: %v", err)
}
svcBundle.Paliadin = services.NewRemotePaliadinService(pool, users, cfg)
log.Printf("paliadin: remote mode → ssh %s@%s:%d (owner=%s)",
cfg.SSHUser, cfg.SSHHost, cfg.SSHPort, services.PaliadinOwnerEmail)
} else if _, err := exec.LookPath("tmux"); err == nil {
tmuxSession := os.Getenv("PALIADIN_TMUX_SESSION")
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
svcBundle.Paliadin = services.NewLocalPaliadinService(pool, users, tmuxSession, responseDir)
log.Printf("paliadin: local tmux mode (owner=%s)", services.PaliadinOwnerEmail)
} else {
svcBundle.Paliadin = services.NewDisabledPaliadinService(pool, users)
log.Printf("paliadin: disabled (no PALIADIN_REMOTE_HOST, no local tmux; owner=%s)",
services.PaliadinOwnerEmail)
}
// Wire ApprovalService into the entity services so Create / Update /
// Complete / Delete consult paliad.approval_policies (t-paliad-138).
// Without this wiring, the policies and request tables exist but no
@@ -217,3 +234,83 @@ func main() {
log.Fatal(err)
}
}
// buildPaliadinRemoteConfig assembles a RemotePaliadinConfig from
// environment variables, materialising the SSH private key and
// known_hosts blobs into chmod-600/644 tmpfiles for OpenSSH to read.
//
// The blobs travel as Dokploy secrets (multi-line env vars). We never
// persist them to disk — tmpfiles live for the process lifetime in
// /tmp and disappear on container restart. Re-creating them every boot
// is fine; the keys themselves rotate independently via Dokploy
// secret updates.
//
// Required: PALIADIN_REMOTE_HOST, PALIADIN_SSH_PRIVATE_KEY, PALIADIN_KNOWN_HOSTS.
// Optional: PALIADIN_REMOTE_USER (default "m"), PALIADIN_REMOTE_PORT
// (default 22022 — bypasses Tailscale SSH on :22, see design §4.5).
func buildPaliadinRemoteConfig(host string) (services.RemotePaliadinConfig, error) {
cfg := services.RemotePaliadinConfig{
SSHHost: host,
SSHUser: cmpOr(os.Getenv("PALIADIN_REMOTE_USER"), "m"),
SSHPort: 22022,
}
if p := os.Getenv("PALIADIN_REMOTE_PORT"); p != "" {
n, err := strconv.Atoi(p)
if err != nil || n <= 0 || n > 65535 {
return cfg, fmt.Errorf("PALIADIN_REMOTE_PORT %q: not a valid port", p)
}
cfg.SSHPort = n
}
keyPath, err := writeSecretFile("paliadin-id_ed25519-", os.Getenv("PALIADIN_SSH_PRIVATE_KEY"), 0o600)
if err != nil {
return cfg, fmt.Errorf("PALIADIN_SSH_PRIVATE_KEY: %w", err)
}
if keyPath == "" {
return cfg, fmt.Errorf("PALIADIN_REMOTE_HOST set but PALIADIN_SSH_PRIVATE_KEY empty")
}
cfg.SSHKeyPath = keyPath
knownHostsPath, err := writeSecretFile("paliadin-known_hosts-", os.Getenv("PALIADIN_KNOWN_HOSTS"), 0o644)
if err != nil {
return cfg, fmt.Errorf("PALIADIN_KNOWN_HOSTS: %w", err)
}
if knownHostsPath == "" {
return cfg, fmt.Errorf("PALIADIN_REMOTE_HOST set but PALIADIN_KNOWN_HOSTS empty")
}
cfg.KnownHostsPath = knownHostsPath
return cfg, 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.
func writeSecretFile(prefix, blob string, mode os.FileMode) (string, error) {
if blob == "" {
return "", nil
}
f, err := os.CreateTemp("", prefix+"*")
if err != nil {
return "", err
}
if _, err := f.WriteString(blob); err != nil {
_ = f.Close()
_ = os.Remove(f.Name())
return "", err
}
if err := f.Close(); err != nil {
return "", err
}
if err := os.Chmod(f.Name(), mode); err != nil {
return "", err
}
return f.Name(), nil
}
func cmpOr(s, fallback string) string {
if s != "" {
return s
}
return fallback
}