b23ef64ee1
Generate a fresh ed25519 key pair at rebuild time, inject the public key into the Proxmox answer file, use the private key for cluster join over SSH, then remove the key from both the remote host and the database. This eliminates the need to manage static SSH keys in config/secrets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
86 lines
2.2 KiB
Go
86 lines
2.2 KiB
Go
package orchestrator
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
type ClusterJoiner struct {
|
|
ExistingNode string
|
|
ClusterName string
|
|
JoinFingerprint string
|
|
}
|
|
|
|
func (c *ClusterJoiner) Join(ctx context.Context, hostIP string, privateKey string, publicKey string) error {
|
|
client, err := c.connect(hostIP, privateKey)
|
|
if err != nil {
|
|
return fmt.Errorf("ssh connect to %s: %w", hostIP, err)
|
|
}
|
|
defer client.Close()
|
|
|
|
// Join the cluster
|
|
cmd := fmt.Sprintf("pvecm add %s --force", c.ExistingNode)
|
|
log.Printf("cluster: running on %s: %s", hostIP, cmd)
|
|
if err := c.runCmd(client, cmd); err != nil {
|
|
return fmt.Errorf("pvecm add failed: %w", err)
|
|
}
|
|
log.Printf("cluster: %s joined successfully", hostIP)
|
|
|
|
// Remove the ephemeral key from authorized_keys
|
|
escaped := escapeForSed(publicKey)
|
|
removeCmd := fmt.Sprintf(`sed -i '\|%s|d' /root/.ssh/authorized_keys`, escaped)
|
|
if err := c.runCmd(client, removeCmd); err != nil {
|
|
log.Printf("cluster: warning: failed to remove ephemeral key from %s: %v", hostIP, err)
|
|
} else {
|
|
log.Printf("cluster: ephemeral key removed from %s", hostIP)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *ClusterJoiner) connect(hostIP string, privateKeyPEM string) (*ssh.Client, error) {
|
|
signer, err := ssh.ParsePrivateKey([]byte(privateKeyPEM))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse ephemeral key: %w", err)
|
|
}
|
|
config := &ssh.ClientConfig{
|
|
User: "root",
|
|
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
|
Timeout: 30 * time.Second,
|
|
}
|
|
return ssh.Dial("tcp", hostIP+":22", config)
|
|
}
|
|
|
|
func (c *ClusterJoiner) runCmd(client *ssh.Client, cmd string) error {
|
|
session, err := client.NewSession()
|
|
if err != nil {
|
|
return fmt.Errorf("ssh session: %w", err)
|
|
}
|
|
defer session.Close()
|
|
output, err := session.CombinedOutput(cmd)
|
|
if err != nil {
|
|
return fmt.Errorf("%w\noutput: %s", err, string(output))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func escapeForSed(s string) string {
|
|
// Trim trailing newline and escape sed delimiter
|
|
result := ""
|
|
for _, c := range s {
|
|
if c == '|' {
|
|
result += `\|`
|
|
} else if c == '\n' {
|
|
continue
|
|
} else {
|
|
result += string(c)
|
|
}
|
|
}
|
|
return result
|
|
}
|