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, quotaGB int) error func (f EnablerFunc) ConfigureOffbox(ctx context.Context, host, user string, port int, repoPath, privPEM, knownHosts string, quotaGB int) error { return f(ctx, host, user, port, repoPath, privPEM, knownHosts, quotaGB) } // --- 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, knownHosts string) error { if strings.TrimSpace(knownHosts) == "" { return fmt.Errorf("ssh-copy-id: empty known_hosts — refusing to install without a pinned host key") } // ssh-copy-id -s (SFTP mode) mktemp's its batch file under ~/.ssh and dies LOCALLY if the directory // doesn't exist — the container image ships without /root/.ssh (live finding: the one-time password was // consumed, then the install failed before ever connecting). if home, err := os.UserHomeDir(); err == nil { if err := os.MkdirAll(filepath.Join(home, ".ssh"), 0o700); err != nil { return fmt.Errorf("ssh-copy-id: ensure ~/.ssh (needed by -s mode): %w", err) } } 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") khPath := filepath.Join(work, "known_hosts") if err := os.WriteFile(pubPath, []byte(pubAuthorized), 0o600); err != nil { return err } if err := os.WriteFile(privPath, []byte(privPEM), 0o600); err != nil { return err } // Pin the scanner-VERIFIED host key: StrictHostKeyChecking=yes against this known_hosts refuses any // other key (no accept-new/TOFU) — the ssh-copy-id + verify sessions connect ONLY to the box whose // fingerprint the bridge already matched against the hub descriptor. if err := os.WriteFile(khPath, []byte(knownHosts+"\n"), 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=yes", "-o", "UserKnownHostsFile="+khPath, 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=yes", "-oUserKnownHostsFile="+khPath, 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 } // --- SFTPKeyAuthProber: does the ALREADY-INSTALLED key still authenticate? (key-auth-first) --- // SFTPKeyAuthProber probes passwordless auth with the existing installed key (KeyPath), pinned to the // freshly-verified knownHosts line. No key file → ok=false (fresh guest). The probe never logs secrets. type SFTPKeyAuthProber struct { KeyPath string // the installed key, e.g. /offbox/ssh_key Timeout time.Duration // per-probe budget; 0 → 20s } func (p SFTPKeyAuthProber) Probe(ctx context.Context, host, user string, port int, knownHosts string) (string, bool) { pem, err := os.ReadFile(p.KeyPath) if err != nil { return "", false // no existing key — a fresh guest; take the full path } timeout := p.Timeout if timeout == 0 { timeout = 20 * time.Second } pctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() work, err := os.MkdirTemp("", "felhom-keyprobe-") if err != nil { return "", false } defer os.RemoveAll(work) khPath := filepath.Join(work, "known_hosts") if err := os.WriteFile(khPath, []byte(knownHosts+"\n"), 0o600); err != nil { return "", false } probe := exec.CommandContext(pctx, "sftp", "-b", "-", "-P", strconv.Itoa(port), "-i", p.KeyPath, "-oBatchMode=yes", "-oConnectTimeout=10", "-oStrictHostKeyChecking=yes", "-oUserKnownHostsFile="+khPath, user+"@"+host) probe.Stdin = strings.NewReader("pwd\n") if err := probe.Run(); err != nil { return "", false // auth refused / unreachable — fall through to the full path } return string(pem), true } func truncate(b []byte) string { s := strings.TrimSpace(string(b)) if len(s) > 300 { return s[:300] + "…" } return s }