v0.106.0: offsite provisioning SLICE 2 — controller apply-bridge
On startup reconcile the hub-served offsite: descriptor into a key-only offbox target. internal/offsiteapply.Bridge: verify-pin box host key vs host_fingerprint (NO blind TOFU) → consume the one-time password (single-use, never logged) → sshpass ssh-copy-id -s -f install + verify → configure offbox → EscrowState=pending (fork-4 via Manager.ApplyOffsiteTarget) → persist a descriptor-hash marker LAST. Idempotent + fail-safe. Seams faked in tests; both red-proofs run+reverted. Dockerfile + sshpass. NOT yet live-applied (supervised end-to-end next runbook). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
package offsiteapply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/knownhosts"
|
||||
)
|
||||
|
||||
// --- func adapters (convenient wiring in main.go) ---
|
||||
|
||||
type ConsumerFunc func(ctx context.Context) (string, error)
|
||||
|
||||
func (f ConsumerFunc) Consume(ctx context.Context) (string, error) { return f(ctx) }
|
||||
|
||||
type EnablerFunc func(ctx context.Context, host, user string, port int, repoPath, privPEM, knownHosts string) error
|
||||
|
||||
func (f EnablerFunc) ConfigureOffbox(ctx context.Context, host, user string, port int, repoPath, privPEM, knownHosts string) error {
|
||||
return f(ctx, host, user, port, repoPath, privPEM, knownHosts)
|
||||
}
|
||||
|
||||
// --- HTTPConsumer: POST the hub consume-password endpoint with the per-customer API key ---
|
||||
|
||||
type HTTPConsumer struct {
|
||||
HubURL string
|
||||
CustomerID string
|
||||
APIKey string
|
||||
HC *http.Client
|
||||
}
|
||||
|
||||
func (c HTTPConsumer) Consume(ctx context.Context) (string, error) {
|
||||
if c.HubURL == "" || c.CustomerID == "" || c.APIKey == "" {
|
||||
return "", fmt.Errorf("offsite-apply: consume: hub url/customer/apikey not configured")
|
||||
}
|
||||
hc := c.HC
|
||||
if hc == nil {
|
||||
hc = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
url := strings.TrimRight(c.HubURL, "/") + "/api/v1/offsite/consume-password/" + c.CustomerID
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return "", fmt.Errorf("no unconsumed offsite password (already consumed or none provisioned)")
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("consume: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &body); err != nil || body.Password == "" {
|
||||
return "", fmt.Errorf("consume: malformed response")
|
||||
}
|
||||
return body.Password, nil // NEVER logged
|
||||
}
|
||||
|
||||
// --- KeyscanScanner: capture the box host key (x/crypto/ssh, no binary) → fingerprint + known_hosts line ---
|
||||
|
||||
type KeyscanScanner struct {
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
var errScanCaptured = errors.New("host key captured")
|
||||
|
||||
func (s KeyscanScanner) Scan(ctx context.Context, host string, port int) (string, string, error) {
|
||||
timeout := s.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
var fp, line string
|
||||
cfg := &ssh.ClientConfig{
|
||||
User: "felhom-keyscan",
|
||||
Timeout: timeout,
|
||||
HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error {
|
||||
fp = ssh.FingerprintSHA256(key)
|
||||
line = knownhosts.Line([]string{knownhosts.Normalize(net.JoinHostPort(host, strconv.Itoa(port)))}, key)
|
||||
return errScanCaptured
|
||||
},
|
||||
}
|
||||
d := net.Dialer{Timeout: timeout}
|
||||
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("dial: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
c, _, _, herr := ssh.NewClientConn(conn, host, cfg)
|
||||
if c != nil {
|
||||
c.Close()
|
||||
}
|
||||
if fp != "" && line != "" {
|
||||
return fp, line, nil
|
||||
}
|
||||
return "", "", fmt.Errorf("host-key handshake: %w", herr)
|
||||
}
|
||||
|
||||
// --- ED25519KeyGen: a fresh keypair (OpenSSH private PEM + authorized_keys pub line) ---
|
||||
|
||||
type ED25519KeyGen struct{}
|
||||
|
||||
func (ED25519KeyGen) Generate() (string, string, error) {
|
||||
pub, priv, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
block, err := ssh.MarshalPrivateKey(priv, "felhom-offbox")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
sshPub, err := ssh.NewPublicKey(pub)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
privPEM := string(pem.EncodeToMemory(block))
|
||||
pubLine := string(ssh.MarshalAuthorizedKey(sshPub)) // includes trailing newline
|
||||
return privPEM, pubLine, nil
|
||||
}
|
||||
|
||||
// --- SSHCopyIDInstaller: install the pubkey via the proven `sshpass -e ssh-copy-id -p N -s -f`, verify ---
|
||||
|
||||
type SSHCopyIDInstaller struct{}
|
||||
|
||||
func (SSHCopyIDInstaller) Install(ctx context.Context, host, user string, port int, password, privPEM, pubAuthorized string) error {
|
||||
work, err := os.MkdirTemp("", "felhom-keyinstall-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(work)
|
||||
pubPath := filepath.Join(work, "id.pub")
|
||||
privPath := filepath.Join(work, "id")
|
||||
if err := os.WriteFile(pubPath, []byte(pubAuthorized), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(privPath, []byte(privPEM), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
// Install (SSHPASS env is read by `sshpass -e`; the password never appears on argv).
|
||||
install := exec.CommandContext(ctx, "sshpass", "-e", "ssh-copy-id", "-p", strconv.Itoa(port), "-s", "-f",
|
||||
"-i", pubPath, "-o", "StrictHostKeyChecking=accept-new", user+"@"+host)
|
||||
install.Env = append(os.Environ(), "SSHPASS="+password)
|
||||
if out, err := install.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("ssh-copy-id: %w: %s", err, truncate(out))
|
||||
}
|
||||
// Verify passwordless key auth (an SFTP no-op; the box's restricted shell only offers SFTP).
|
||||
verify := exec.CommandContext(ctx, "sftp", "-b", "-", "-P", strconv.Itoa(port),
|
||||
"-i", privPath, "-oBatchMode=yes", "-oStrictHostKeyChecking=accept-new", user+"@"+host)
|
||||
verify.Stdin = strings.NewReader("pwd\n")
|
||||
if out, err := verify.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("key-auth verify failed after install: %w: %s", err, truncate(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func truncate(b []byte) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) > 300 {
|
||||
return s[:300] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user