Add SSH-managed node registry with connection testing and reauth.

Register hosts under Containers/VMs/Docker with encrypted key storage, and require re-authentication for sensitive account changes.
This commit is contained in:
2026-07-18 16:39:10 +02:00
parent b93b7519ec
commit f4dc8f63d7
31 changed files with 4801 additions and 223 deletions
+33 -5
View File
@@ -66,11 +66,12 @@ func (manager *SessionManager) CreateSession(
}
record := settings.SessionRecord{
ID: sessionID,
UserID: userID,
CreatedAt: now,
LastSeenAt: now,
ExpiresAt: now.Add(lifetime),
ID: sessionID,
UserID: userID,
CreatedAt: now,
LastSeenAt: now,
LastReauthAt: now,
ExpiresAt: now.Add(lifetime),
}
filtered = append(filtered, record)
store.Sessions = filtered
@@ -142,6 +143,7 @@ func (manager *SessionManager) LookupValidSession(
}
found.ID = newID
found.CreatedAt = now
// LastReauthAt is preserved across rotation.
setSessionCookie(writer, newID, int(found.ExpiresAt.Sub(now).Seconds()))
}
@@ -153,6 +155,32 @@ func (manager *SessionManager) LookupValidSession(
return *found, nil
}
// MarkReauth updates LastReauthAt for the session identified by sessionID.
func (manager *SessionManager) MarkReauth(sessionID string) error {
manager.mu.Lock()
defer manager.mu.Unlock()
store, err := settings.LoadSessionsOrEmpty(manager.configDir, manager.key)
if err != nil {
return err
}
now := time.Now().UTC()
found := false
for index := range store.Sessions {
if store.Sessions[index].ID != sessionID {
continue
}
store.Sessions[index].LastReauthAt = now
found = true
break
}
if !found {
return ErrSessionNotFound
}
return settings.SaveSessions(manager.configDir, store, manager.key)
}
// InvalidateUserSessions removes all persisted sessions for the given user ID.
func (manager *SessionManager) InvalidateUserSessions(userID string) error {
manager.mu.Lock()
+73
View File
@@ -0,0 +1,73 @@
package auth
import (
"fmt"
"net"
"strings"
"time"
"golang.org/x/crypto/ssh"
)
const defaultSSHPort = "22"
const sshDialTimeout = 10 * time.Second
// TestSSHConnection dials host over SSH using the given private key and username.
// Host key verification is intentionally skipped for this connectivity check;
// trust-on-first-use / known_hosts can be added later.
func TestSSHConnection(hostIP string, username string, privateKeyPEM string, passphrase string) error {
hostIP = strings.TrimSpace(hostIP)
username = strings.TrimSpace(username)
if hostIP == "" {
return fmt.Errorf("host IP is required")
}
if username == "" {
return fmt.Errorf("username is required")
}
signer, err := parseSigner(privateKeyPEM, passphrase)
if err != nil {
return err
}
config := &ssh.ClientConfig{
User: username,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: sshDialTimeout,
}
address := net.JoinHostPort(hostIP, defaultSSHPort)
client, err := ssh.Dial("tcp", address, config)
if err != nil {
return fmt.Errorf("ssh dial %s: %w", address, err)
}
defer client.Close()
return nil
}
func parseSigner(privateKeyPEM string, passphrase string) (ssh.Signer, error) {
trimmed := strings.TrimSpace(privateKeyPEM)
if trimmed == "" {
return nil, fmt.Errorf("private key is required")
}
var (
signer ssh.Signer
err error
)
if strings.TrimSpace(passphrase) == "" {
signer, err = ssh.ParsePrivateKey([]byte(trimmed))
} else {
signer, err = ssh.ParsePrivateKeyWithPassphrase(
[]byte(trimmed),
[]byte(passphrase),
)
}
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
return signer, nil
}
+19
View File
@@ -0,0 +1,19 @@
package auth
import (
"testing"
)
func TestTestSSHConnectionRejectsEmptyHost(t *testing.T) {
err := TestSSHConnection("", "clustercanvas", "not-a-key", "")
if err == nil {
t.Fatal("expected error")
}
}
func TestTestSSHConnectionRejectsInvalidKey(t *testing.T) {
err := TestSSHConnection("127.0.0.1", "clustercanvas", "not-a-key", "")
if err == nil {
t.Fatal("expected error")
}
}
+145
View File
@@ -0,0 +1,145 @@
package auth
import (
"crypto"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"encoding/pem"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/ssh"
)
const (
SSHKeyAlgoEd25519 = "ed25519"
SSHKeyAlgoRSA = "rsa"
DefaultSSHUsername = "clustercanvas"
DefaultRSABits = 4096
DefaultEd25519Rounds = 100
)
// GeneratedSSHKey is the result of generating or importing an SSH key pair.
type GeneratedSSHKey struct {
PrivateKeyPEM string
PublicKey string
Algorithm string
RSABits int
KDFRounds int
}
// GenerateSSHKey creates an OpenSSH private key and authorized_keys public line.
//
// Private keys are written without an OpenSSH passphrase. At-rest protection is
// provided by ClusterCanvas AES-GCM (node-keys.enc). KDFRounds is retained as
// generation metadata (ssh-keygen -a) for future passphrase-protected exports;
// golang.org/x/crypto/ssh hardcodes bcrypt rounds when encrypting.
func GenerateSSHKey(algorithm string, rsaBits int, kdfRounds int) (GeneratedSSHKey, error) {
algorithm = strings.ToLower(strings.TrimSpace(algorithm))
switch algorithm {
case SSHKeyAlgoEd25519:
if kdfRounds <= 0 {
kdfRounds = DefaultEd25519Rounds
}
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return GeneratedSSHKey{}, fmt.Errorf("generate ed25519 key: %w", err)
}
return marshalGeneratedKey(privateKey, SSHKeyAlgoEd25519, 0, kdfRounds)
case SSHKeyAlgoRSA:
if rsaBits == 0 {
rsaBits = DefaultRSABits
}
if !ValidRSABits(rsaBits) {
return GeneratedSSHKey{}, fmt.Errorf("rsa bits must be one of 2048, 3072, or 4096")
}
privateKey, err := rsa.GenerateKey(rand.Reader, rsaBits)
if err != nil {
return GeneratedSSHKey{}, fmt.Errorf("generate rsa key: %w", err)
}
return marshalGeneratedKey(privateKey, SSHKeyAlgoRSA, rsaBits, 0)
default:
return GeneratedSSHKey{}, fmt.Errorf("algorithm must be %q or %q", SSHKeyAlgoEd25519, SSHKeyAlgoRSA)
}
}
// ParseSSHPrivateKey accepts an OpenSSH/PEM private key and derives the public key.
func ParseSSHPrivateKey(privateKeyPEM string) (GeneratedSSHKey, error) {
trimmed := strings.TrimSpace(privateKeyPEM)
if trimmed == "" {
return GeneratedSSHKey{}, errors.New("private_key is required")
}
rawKey, err := ssh.ParseRawPrivateKey([]byte(trimmed))
if err != nil {
return GeneratedSSHKey{}, fmt.Errorf("invalid private key: %w", err)
}
signer, ok := rawKey.(crypto.Signer)
if !ok {
return GeneratedSSHKey{}, errors.New("private key does not support signing")
}
sshPublicKey, err := ssh.NewPublicKey(signer.Public())
if err != nil {
return GeneratedSSHKey{}, fmt.Errorf("derive public key: %w", err)
}
algorithm := SSHKeyAlgoEd25519
rsaBits := 0
switch key := rawKey.(type) {
case *ed25519.PrivateKey, ed25519.PrivateKey:
algorithm = SSHKeyAlgoEd25519
case *rsa.PrivateKey:
algorithm = SSHKeyAlgoRSA
rsaBits = key.N.BitLen()
default:
// Keep OpenSSH type string for unusual keys.
algorithm = sshPublicKey.Type()
}
return GeneratedSSHKey{
PrivateKeyPEM: trimmed + "\n",
PublicKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPublicKey))),
Algorithm: algorithm,
RSABits: rsaBits,
}, nil
}
// ValidRSABits reports whether bits is an allowed RSA size.
func ValidRSABits(bits int) bool {
switch bits {
case 2048, 3072, 4096:
return true
default:
return false
}
}
func marshalGeneratedKey(privateKey crypto.PrivateKey, algorithm string, rsaBits int, kdfRounds int) (GeneratedSSHKey, error) {
block, err := ssh.MarshalPrivateKey(privateKey, "")
if err != nil {
return GeneratedSSHKey{}, fmt.Errorf("marshal private key: %w", err)
}
signer, ok := privateKey.(crypto.Signer)
if !ok {
return GeneratedSSHKey{}, errors.New("private key does not support signing")
}
sshPublicKey, err := ssh.NewPublicKey(signer.Public())
if err != nil {
return GeneratedSSHKey{}, fmt.Errorf("derive public key: %w", err)
}
privatePEM := string(pem.EncodeToMemory(block))
return GeneratedSSHKey{
PrivateKeyPEM: privatePEM,
PublicKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPublicKey))),
Algorithm: algorithm,
RSABits: rsaBits,
KDFRounds: kdfRounds,
}, nil
}
+63
View File
@@ -0,0 +1,63 @@
package auth
import (
"strings"
"testing"
)
func TestGenerateSSHKeyEd25519(t *testing.T) {
generated, err := GenerateSSHKey(SSHKeyAlgoEd25519, 0, 100)
if err != nil {
t.Fatalf("GenerateSSHKey: %v", err)
}
if generated.Algorithm != SSHKeyAlgoEd25519 {
t.Fatalf("algorithm = %q", generated.Algorithm)
}
if generated.KDFRounds != 100 {
t.Fatalf("kdf rounds = %d", generated.KDFRounds)
}
if !strings.Contains(generated.PrivateKeyPEM, "BEGIN OPENSSH PRIVATE KEY") {
t.Fatal("expected openssh private key")
}
if !strings.HasPrefix(generated.PublicKey, "ssh-ed25519 ") {
t.Fatalf("public key = %q", generated.PublicKey)
}
parsed, err := ParseSSHPrivateKey(generated.PrivateKeyPEM)
if err != nil {
t.Fatalf("ParseSSHPrivateKey: %v", err)
}
if parsed.PublicKey != generated.PublicKey {
t.Fatalf("parsed public key mismatch")
}
}
func TestGenerateSSHKeyRSABits(t *testing.T) {
generated, err := GenerateSSHKey(SSHKeyAlgoRSA, 2048, 0)
if err != nil {
t.Fatalf("GenerateSSHKey: %v", err)
}
if generated.RSABits != 2048 {
t.Fatalf("rsa bits = %d", generated.RSABits)
}
if !strings.HasPrefix(generated.PublicKey, "ssh-rsa ") {
t.Fatalf("public key = %q", generated.PublicKey)
}
}
func TestGenerateSSHKeyRejectsInvalidRSABits(t *testing.T) {
_, err := GenerateSSHKey(SSHKeyAlgoRSA, 1024, 0)
if err == nil {
t.Fatal("expected error")
}
}
func TestNewUUIDFormat(t *testing.T) {
value, err := NewUUID()
if err != nil {
t.Fatalf("NewUUID: %v", err)
}
if len(value) != 36 {
t.Fatalf("uuid length = %d", len(value))
}
}
+24
View File
@@ -0,0 +1,24 @@
package auth
import (
"crypto/rand"
"fmt"
)
// NewUUID returns a random RFC 4122 version-4 UUID string.
func NewUUID() (string, error) {
buffer := make([]byte, 16)
if _, err := rand.Read(buffer); err != nil {
return "", fmt.Errorf("uuid: %w", err)
}
buffer[6] = (buffer[6] & 0x0f) | 0x40
buffer[8] = (buffer[8] & 0x3f) | 0x80
return fmt.Sprintf(
"%x-%x-%x-%x-%x",
buffer[0:4],
buffer[4:6],
buffer[6:8],
buffer[8:10],
buffer[10:16],
), nil
}