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:
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user