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
+93
View File
@@ -0,0 +1,93 @@
package settings
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
)
// LoadNodes reads and parses nodes.json from dir.
func LoadNodes(dir string) (NodeStore, error) {
path := filepath.Join(dir, NodesFileName)
payload, err := os.ReadFile(path)
if err != nil {
return NodeStore{}, err
}
var store NodeStore
if err := json.Unmarshal(payload, &store); err != nil {
return NodeStore{}, fmt.Errorf("parse nodes: %w", err)
}
if store.Nodes == nil {
store.Nodes = []Node{}
}
return store, nil
}
// LoadNodesOrEmpty returns an empty store when nodes.json is missing.
func LoadNodesOrEmpty(dir string) (NodeStore, error) {
store, err := LoadNodes(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return NodeStore{Nodes: []Node{}}, nil
}
return NodeStore{}, err
}
return store, nil
}
// SaveNodes writes nodes.json to dir with mode 0640.
func SaveNodes(dir string, store NodeStore) error {
if err := ensureConfigDir(dir); err != nil {
return err
}
if store.Nodes == nil {
store.Nodes = []Node{}
}
payload, err := json.MarshalIndent(store, "", " ")
if err != nil {
return fmt.Errorf("encode nodes: %w", err)
}
payload = append(payload, '\n')
path := filepath.Join(dir, NodesFileName)
if err := os.WriteFile(path, payload, 0o640); err != nil {
return fmt.Errorf("write nodes: %w", err)
}
return nil
}
// LoadNodeKeys decrypts node-keys.enc from dir using key.
func LoadNodeKeys(dir string, key []byte) (NodeKeyStore, error) {
var store NodeKeyStore
if err := loadEncryptedJSON(dir, NodeKeysFileName, key, &store); err != nil {
return NodeKeyStore{}, err
}
if store.Keys == nil {
store.Keys = []NodeKeyEntry{}
}
return store, nil
}
// LoadNodeKeysOrEmpty returns an empty store when node-keys.enc is missing.
func LoadNodeKeysOrEmpty(dir string, key []byte) (NodeKeyStore, error) {
store, err := LoadNodeKeys(dir, key)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return NodeKeyStore{Keys: []NodeKeyEntry{}}, nil
}
return NodeKeyStore{}, err
}
return store, nil
}
// SaveNodeKeys encrypts the node key store and writes node-keys.enc.
func SaveNodeKeys(dir string, store NodeKeyStore, key []byte) error {
if store.Keys == nil {
store.Keys = []NodeKeyEntry{}
}
return saveEncryptedJSON(dir, NodeKeysFileName, key, store)
}
+73
View File
@@ -0,0 +1,73 @@
package settings
import (
"encoding/base64"
"testing"
"time"
)
func TestNodesAndNodeKeysRoundTrip(t *testing.T) {
dir := t.TempDir()
keyBytes := make([]byte, 32)
for index := range keyBytes {
keyBytes[index] = byte(index + 3)
}
t.Setenv(ConfigKeyEnvVar, base64.StdEncoding.EncodeToString(keyBytes))
store := NodeStore{
Nodes: []Node{
{
ID: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
Kind: NodeKindContainer,
Name: "ct1",
HostIP: "127.0.0.1",
Username: "clustercanvas",
GroupName: AdministratorsGroupName,
PublicKey: "ssh-ed25519 AAAA",
KeyAlgo: "ed25519",
CreatedAt: time.Now().UTC(),
},
},
}
if err := SaveNodes(dir, store); err != nil {
t.Fatalf("SaveNodes: %v", err)
}
loaded, err := LoadNodes(dir)
if err != nil {
t.Fatalf("LoadNodes: %v", err)
}
if len(loaded.Nodes) != 1 || loaded.Nodes[0].Name != "ct1" {
t.Fatalf("loaded = %+v", loaded)
}
keyStore := NodeKeyStore{
Keys: []NodeKeyEntry{
{
NodeID: loaded.Nodes[0].ID,
PrivateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----\n",
Algorithm: "ed25519",
KDFRounds: 100,
},
},
}
if err := SaveNodeKeys(dir, keyStore, keyBytes); err != nil {
t.Fatalf("SaveNodeKeys: %v", err)
}
loadedKeys, err := LoadNodeKeys(dir, keyBytes)
if err != nil {
t.Fatalf("LoadNodeKeys: %v", err)
}
if len(loadedKeys.Keys) != 1 || loadedKeys.Keys[0].KDFRounds != 100 {
t.Fatalf("keys = %+v", loadedKeys)
}
}
func TestLoadNodesOrEmptyMissing(t *testing.T) {
store, err := LoadNodesOrEmpty(t.TempDir())
if err != nil {
t.Fatalf("LoadNodesOrEmpty: %v", err)
}
if len(store.Nodes) != 0 {
t.Fatalf("expected empty, got %d", len(store.Nodes))
}
}
+2
View File
@@ -16,6 +16,8 @@ const (
SecretsFileName = "secrets.enc"
PasswordsFileName = "passwords.enc"
SessionsFileName = "sessions.enc"
NodesFileName = "nodes.json"
NodeKeysFileName = "node-keys.enc"
)
// ResolveDir returns the config directory using precedence:
+48 -5
View File
@@ -85,6 +85,48 @@ type Secrets struct {
// Placeholder until Proxmox/API secrets exist.
}
// NodeKind identifies which left-nav category a managed host belongs to.
type NodeKind string
const (
NodeKindContainer NodeKind = "container"
NodeKindVM NodeKind = "vm"
NodeKindDocker NodeKind = "docker"
)
// Node is a remote host ClusterCanvas manages over SSH.
type Node struct {
ID string `json:"id"`
Kind NodeKind `json:"kind"`
Name string `json:"name"`
HostIP string `json:"host_ip"`
Username string `json:"username"`
GroupName string `json:"group_name"`
PublicKey string `json:"public_key"`
KeyAlgo string `json:"key_algo"`
CreatedAt time.Time `json:"created_at"`
}
// NodeStore is the plain JSON payload in nodes.json.
type NodeStore struct {
Nodes []Node `json:"nodes"`
}
// NodeKeyEntry holds private key material for one node, keyed by node UUID.
type NodeKeyEntry struct {
NodeID string `json:"node_id"`
PrivateKey string `json:"private_key"`
Passphrase string `json:"passphrase,omitempty"`
Algorithm string `json:"algorithm"`
RSABits int `json:"rsa_bits,omitempty"`
KDFRounds int `json:"kdf_rounds,omitempty"`
}
// NodeKeyStore is the plaintext JSON payload inside node-keys.enc.
type NodeKeyStore struct {
Keys []NodeKeyEntry `json:"keys"`
}
// UserCredential is one account stored in passwords.enc.
type UserCredential struct {
ID string `json:"id"`
@@ -105,11 +147,12 @@ type PasswordStore struct {
// SessionRecord is one server-side session stored in sessions.enc.
type SessionRecord struct {
ID string `json:"id"`
UserID string `json:"user_id"`
CreatedAt time.Time `json:"created_at"`
LastSeenAt time.Time `json:"last_seen_at"`
ExpiresAt time.Time `json:"expires_at"`
ID string `json:"id"`
UserID string `json:"user_id"`
CreatedAt time.Time `json:"created_at"`
LastSeenAt time.Time `json:"last_seen_at"`
LastReauthAt time.Time `json:"last_reauth_at"`
ExpiresAt time.Time `json:"expires_at"`
}
// SessionStore is the plaintext JSON payload inside sessions.enc.