From d9ab9fb98c64962e44f7b0e497f069e11a5420e7 Mon Sep 17 00:00:00 2001 From: codex Date: Fri, 24 Apr 2026 18:07:14 -0300 Subject: [PATCH] recovery(metis): simplify node password schema --- README.md | 2 +- pkg/plan/inject_test.go | 12 +++---- pkg/plan/node_identity.go | 37 ++++----------------- pkg/plan/node_metadata.go | 14 ++------ pkg/plan/node_secrets_test.go | 62 +++++++++++------------------------ pkg/secrets/vault.go | 14 +++----- pkg/secrets/vault_test.go | 11 +++---- pkg/service/remote_helpers.go | 4 --- 8 files changed, 46 insertions(+), 110 deletions(-) diff --git a/README.md b/README.md index 0205c25..ca7ebdd 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Metis produces fully configured recovery SD cards for any node in the lab (RPi 4 - `metis-sentinel` stays slim for the DaemonSet that samples node facts - Class overlays: define `boot_overlay`/`root_overlay` on a class to merge static files into boot/root at burn time (e.g., cloud-init/netplan drop-ins, GPU driver configs). Per-node config still injects hostname/IP/k3s/SSH/Longhorn. - Linux loop-mount helper (losetup/mount) exists for automation; wiring into CLI burn is next. Windows writer/GUI stub forthcoming. -- Vault: Metis can read per-node secrets from `kv/data/atlas/nodes/` using VAULT_ADDR plus either VAULT_TOKEN or AppRole (VAULT_ROLE_ID/VAULT_SECRET_ID). Expected fields: ssh_password, k3s_token, cloud_init, extra map. +- Vault: Metis can read per-node secrets from `kv/data/atlas/nodes/` using VAULT_ADDR plus either VAULT_TOKEN or AppRole (VAULT_ROLE_ID/VAULT_SECRET_ID). Expected fields: atlas_password, root_password, k3s_token, cloud_init, extra map. - Sentinel: `metis-sentinel` collects host facts and can either print them, write local history, or push them into the Metis service. The intended deployment shape is a DaemonSet on cluster nodes plus an Ariadne-triggered Metis watch that recomputes recommended class targets and drift history. - Facts aggregation: `metis facts --inventory inv.yaml --snapshots ./snapshots` reads sentinel snapshot JSON files and prints per-class drift summary (kernels, containerd, k3s, package samples). Use exported ConfigMaps or `METIS_SENTINEL_OUT` history as input. - `metis config --inventory inv.yaml --node titan-13` prints the merged node config (hostname/IP/k3s labels/taints/Longhorn UUIDs and optional USB scratch metadata). diff --git a/pkg/plan/inject_test.go b/pkg/plan/inject_test.go index 20b2f0d..95b4e9b 100644 --- a/pkg/plan/inject_test.go +++ b/pkg/plan/inject_test.go @@ -131,7 +131,7 @@ func TestSecretsWrite(t *testing.T) { Hostname: "n1", IP: "10.0.0.1", } - sec := &secrets.NodeSecrets{K3sToken: "tok", SSHPassword: "pw", Extra: map[string]string{"foo": "bar"}} + sec := &secrets.NodeSecrets{K3sToken: "tok", AtlasPassword: "pw", Extra: map[string]string{"foo": "bar"}} files, err := buildFiles(cfg, sec) if err != nil { t.Fatalf("buildFiles: %v", err) @@ -187,10 +187,10 @@ func TestBuildFilesAddsPasswordArtifactsAndRedactsSecrets(t *testing.T) { }, } sec := &secrets.NodeSecrets{ - SSHPassword: "atlas-pass", - RootPassword: "root-pass", - K3sToken: "super-secret-token", - Extra: map[string]string{"api_key": "secret"}, + AtlasPassword: "atlas-pass", + RootPassword: "root-pass", + K3sToken: "super-secret-token", + Extra: map[string]string{"api_key": "secret"}, } files, err := buildFiles(cfg, sec) if err != nil { @@ -220,7 +220,7 @@ func TestBuildFilesAddsPasswordArtifactsAndRedactsSecrets(t *testing.T) { if strings.Contains(secretsJSON, "atlas-pass") || strings.Contains(secretsJSON, "root-pass") || strings.Contains(secretsJSON, "super-secret-token") { t.Fatalf("secrets.json should be redacted: %s", secretsJSON) } - if !strings.Contains(secretsJSON, `"has_ssh_password": true`) || !strings.Contains(secretsJSON, `"extra_keys": [`) { + if !strings.Contains(secretsJSON, `"has_atlas_password": true`) || !strings.Contains(secretsJSON, `"extra_keys": [`) { t.Fatalf("secrets.json should keep redacted debug metadata: %s", secretsJSON) } } diff --git a/pkg/plan/node_identity.go b/pkg/plan/node_identity.go index 7813fff..769e210 100644 --- a/pkg/plan/node_identity.go +++ b/pkg/plan/node_identity.go @@ -45,15 +45,9 @@ func firstbootEnvContent(cfg *config.NodeConfig, sec *secrets.NodeSecrets) strin if value := effectiveAtlasPassword(sec); value != "" { b.WriteString(fmt.Sprintf("METIS_ATLAS_PASSWORD=%s\n", shellQuote(value))) } - if value := effectiveAtlasPasswordHash(sec); value != "" { - b.WriteString(fmt.Sprintf("METIS_ATLAS_PASSWORD_HASH=%s\n", shellQuote(value))) - } if value := strings.TrimSpace(sec.RootPassword); value != "" { b.WriteString(fmt.Sprintf("METIS_ROOT_PASSWORD=%s\n", shellQuote(value))) } - if value := strings.TrimSpace(sec.RootPasswordHash); value != "" { - b.WriteString(fmt.Sprintf("METIS_ROOT_PASSWORD_HASH=%s\n", shellQuote(value))) - } } return b.String() } @@ -93,9 +87,7 @@ fi atlas_user="${METIS_ATLAS_USER:-atlas}" ssh_user="${METIS_SSH_USER:-${atlas_user}}" atlas_password="${METIS_ATLAS_PASSWORD:-}" -atlas_password_hash="${METIS_ATLAS_PASSWORD_HASH:-}" root_password="${METIS_ROOT_PASSWORD:-}" -root_password_hash="${METIS_ROOT_PASSWORD_HASH:-}" group_list=() for group_name in "${default_groups[@]}"; do @@ -126,15 +118,9 @@ ensure_user() { apply_password() { local user_name="$1" local plain_password="$2" - local hash_password="$3" if ! id "${user_name}" >/dev/null 2>&1; then return 0 fi - if [ -n "${hash_password}" ]; then - usermod -p "${hash_password}" "${user_name}" - passwd -u "${user_name}" >/dev/null 2>&1 || true - return 0 - fi if [ -n "${plain_password}" ]; then printf '%s:%s\n' "${user_name}" "${plain_password}" | chpasswd passwd -u "${user_name}" >/dev/null 2>&1 || true @@ -164,11 +150,8 @@ if [ -n "${ssh_user}" ] && [ "${ssh_user}" != "root" ] && [ "${ssh_user}" != "${ ensure_user "${ssh_user}" fi -apply_password root "${root_password}" "${root_password_hash}" -apply_password "${atlas_user}" "${atlas_password}" "${atlas_password_hash}" -if [ -n "${ssh_user}" ] && [ "${ssh_user}" != "root" ] && [ "${ssh_user}" != "${atlas_user}" ]; then - apply_password "${ssh_user}" "${atlas_password}" "${atlas_password_hash}" -fi +apply_password root "${root_password}" +apply_password "${atlas_user}" "${atlas_password}" if [ -s "${key_file}" ]; then install_keys root @@ -202,21 +185,14 @@ func hasNodePasswords(sec *secrets.NodeSecrets) bool { if sec == nil { return false } - return effectiveAtlasPassword(sec) != "" || effectiveAtlasPasswordHash(sec) != "" || firstNonEmptyString(sec.RootPassword, sec.RootPasswordHash) != "" + return effectiveAtlasPassword(sec) != "" || strings.TrimSpace(sec.RootPassword) != "" } func effectiveAtlasPassword(sec *secrets.NodeSecrets) string { if sec == nil { return "" } - return firstNonEmptyString(sec.AtlasPassword, sec.SSHPassword) -} - -func effectiveAtlasPasswordHash(sec *secrets.NodeSecrets) string { - if sec == nil { - return "" - } - return firstNonEmptyString(sec.AtlasPasswordHash, sec.SSHPasswordHash) + return firstNonEmptyString(sec.AtlasPassword) } func firstNonEmptyString(values ...string) string { @@ -233,9 +209,8 @@ func redactedSecretsForImage(sec *secrets.NodeSecrets) map[string]any { return nil } debug := map[string]any{ - "has_ssh_password": firstNonEmptyString(sec.SSHPassword, sec.SSHPasswordHash) != "", - "has_atlas_password": firstNonEmptyString(sec.AtlasPassword, sec.AtlasPasswordHash) != "", - "has_root_password": firstNonEmptyString(sec.RootPassword, sec.RootPasswordHash) != "", + "has_atlas_password": strings.TrimSpace(sec.AtlasPassword) != "", + "has_root_password": strings.TrimSpace(sec.RootPassword) != "", "has_k3s_token": strings.TrimSpace(sec.K3sToken) != "", "has_cloud_init_override": strings.TrimSpace(sec.CloudInit) != "", } diff --git a/pkg/plan/node_metadata.go b/pkg/plan/node_metadata.go index 29bd172..4ba2367 100644 --- a/pkg/plan/node_metadata.go +++ b/pkg/plan/node_metadata.go @@ -26,14 +26,10 @@ func fetchSecrets(hostname string) *secrets.NodeSecrets { func nodeSecretsFromEnv() *secrets.NodeSecrets { sec := &secrets.NodeSecrets{ - SSHPassword: strings.TrimSpace(os.Getenv("METIS_NODE_SSH_PASSWORD")), - SSHPasswordHash: strings.TrimSpace(os.Getenv("METIS_NODE_SSH_PASSWORD_HASH")), - AtlasPassword: strings.TrimSpace(os.Getenv("METIS_NODE_ATLAS_PASSWORD")), - AtlasPasswordHash: strings.TrimSpace(os.Getenv("METIS_NODE_ATLAS_PASSWORD_HASH")), - RootPassword: strings.TrimSpace(os.Getenv("METIS_NODE_ROOT_PASSWORD")), - RootPasswordHash: strings.TrimSpace(os.Getenv("METIS_NODE_ROOT_PASSWORD_HASH")), + AtlasPassword: strings.TrimSpace(os.Getenv("METIS_NODE_ATLAS_PASSWORD")), + RootPassword: strings.TrimSpace(os.Getenv("METIS_NODE_ROOT_PASSWORD")), } - if sec.SSHPassword == "" && sec.SSHPasswordHash == "" && sec.AtlasPassword == "" && sec.AtlasPasswordHash == "" && sec.RootPassword == "" && sec.RootPasswordHash == "" { + if sec.AtlasPassword == "" && sec.RootPassword == "" { return nil } return sec @@ -47,12 +43,8 @@ func mergeNodeSecrets(base, override *secrets.NodeSecrets) *secrets.NodeSecrets return base } merged := *base - merged.SSHPassword = firstNonEmptyString(override.SSHPassword, base.SSHPassword) - merged.SSHPasswordHash = firstNonEmptyString(override.SSHPasswordHash, base.SSHPasswordHash) merged.AtlasPassword = firstNonEmptyString(override.AtlasPassword, base.AtlasPassword) - merged.AtlasPasswordHash = firstNonEmptyString(override.AtlasPasswordHash, base.AtlasPasswordHash) merged.RootPassword = firstNonEmptyString(override.RootPassword, base.RootPassword) - merged.RootPasswordHash = firstNonEmptyString(override.RootPasswordHash, base.RootPasswordHash) merged.K3sToken = firstNonEmptyString(override.K3sToken, base.K3sToken) merged.CloudInit = firstNonEmptyString(override.CloudInit, base.CloudInit) if len(base.Extra) > 0 || len(override.Extra) > 0 { diff --git a/pkg/plan/node_secrets_test.go b/pkg/plan/node_secrets_test.go index 9772c3e..4e5f71e 100644 --- a/pkg/plan/node_secrets_test.go +++ b/pkg/plan/node_secrets_test.go @@ -13,29 +13,15 @@ func TestNodeSecretHelpers(t *testing.T) { if got := effectiveAtlasPassword(nil); got != "" { t.Fatalf("effectiveAtlasPassword(nil) = %q", got) } - if got := effectiveAtlasPasswordHash(nil); got != "" { - t.Fatalf("effectiveAtlasPasswordHash(nil) = %q", got) - } - sec := &secrets.NodeSecrets{SSHPassword: "ssh-pass", SSHPasswordHash: "$ssh$hash"} - if got := effectiveAtlasPassword(sec); got != "ssh-pass" { - t.Fatalf("effectiveAtlasPassword fallback = %q", got) - } - if got := effectiveAtlasPasswordHash(sec); got != "$ssh$hash" { - t.Fatalf("effectiveAtlasPasswordHash fallback = %q", got) - } - sec.AtlasPassword = "atlas-pass" - sec.AtlasPasswordHash = "$atlas$hash" + sec := &secrets.NodeSecrets{AtlasPassword: "atlas-pass"} if got := effectiveAtlasPassword(sec); got != "atlas-pass" { - t.Fatalf("effectiveAtlasPassword explicit = %q", got) - } - if got := effectiveAtlasPasswordHash(sec); got != "$atlas$hash" { - t.Fatalf("effectiveAtlasPasswordHash explicit = %q", got) + t.Fatalf("effectiveAtlasPassword = %q", got) } if got := firstNonEmptyString("", " value ", "ignored"); got != "value" { t.Fatalf("firstNonEmptyString = %q", got) } - if !hasNodePasswords(&secrets.NodeSecrets{RootPasswordHash: "$root$hash"}) { - t.Fatal("expected root password hash to count as password material") + if !hasNodePasswords(&secrets.NodeSecrets{RootPassword: "root-pass"}) { + t.Fatal("expected root password to count as password material") } if hasNodePasswords(&secrets.NodeSecrets{}) { t.Fatal("empty node secrets should not count as password material") @@ -47,21 +33,17 @@ func TestNodeSecretHelpers(t *testing.T) { } func TestNodeSecretsFromEnvAndMergeNodeSecrets(t *testing.T) { - t.Setenv("METIS_NODE_SSH_PASSWORD", "ssh-pass") - t.Setenv("METIS_NODE_SSH_PASSWORD_HASH", "$ssh$hash") t.Setenv("METIS_NODE_ATLAS_PASSWORD", "atlas-pass") - t.Setenv("METIS_NODE_ATLAS_PASSWORD_HASH", "$atlas$hash") t.Setenv("METIS_NODE_ROOT_PASSWORD", "root-pass") - t.Setenv("METIS_NODE_ROOT_PASSWORD_HASH", "$root$hash") envSecrets := nodeSecretsFromEnv() - if envSecrets == nil || envSecrets.RootPassword != "root-pass" || envSecrets.AtlasPasswordHash != "$atlas$hash" { + if envSecrets == nil || envSecrets.RootPassword != "root-pass" || envSecrets.AtlasPassword != "atlas-pass" { t.Fatalf("nodeSecretsFromEnv = %#v", envSecrets) } merged := mergeNodeSecrets(&secrets.NodeSecrets{ - SSHPassword: "base-ssh", - K3sToken: "base-token", - CloudInit: "base-cloud", - Extra: map[string]string{"base": "1"}, + AtlasPassword: "base-atlas", + K3sToken: "base-token", + CloudInit: "base-cloud", + Extra: map[string]string{"base": "1"}, }, &secrets.NodeSecrets{ AtlasPassword: "override-atlas", RootPassword: "override-root", @@ -75,40 +57,36 @@ func TestNodeSecretsFromEnvAndMergeNodeSecrets(t *testing.T) { if merged.Extra["base"] != "1" || merged.Extra["override"] != "2" { t.Fatalf("mergeNodeSecrets extras = %#v", merged.Extra) } - if got := mergeNodeSecrets(nil, envSecrets); got.RootPasswordHash != "$root$hash" { + if got := mergeNodeSecrets(nil, envSecrets); got.RootPassword != "root-pass" { t.Fatalf("mergeNodeSecrets nil base = %#v", got) } - if got := mergeNodeSecrets(envSecrets, nil); got.SSHPassword != "ssh-pass" { + if got := mergeNodeSecrets(envSecrets, nil); got.AtlasPassword != "atlas-pass" { t.Fatalf("mergeNodeSecrets nil override = %#v", got) } - t.Setenv("METIS_NODE_SSH_PASSWORD", "") - t.Setenv("METIS_NODE_SSH_PASSWORD_HASH", "") t.Setenv("METIS_NODE_ATLAS_PASSWORD", "") - t.Setenv("METIS_NODE_ATLAS_PASSWORD_HASH", "") t.Setenv("METIS_NODE_ROOT_PASSWORD", "") - t.Setenv("METIS_NODE_ROOT_PASSWORD_HASH", "") if got := nodeSecretsFromEnv(); got != nil { t.Fatalf("expected empty env secrets to collapse to nil, got %#v", got) } } -func TestFirstbootEnvContentIncludesHashes(t *testing.T) { +func TestFirstbootEnvContentIncludesPasswords(t *testing.T) { cfg := &config.NodeConfig{ Hostname: "titan-15", SSHUser: "atlas", K3s: config.K3sConfig{Version: "v1.31.5+k3s1"}, } content := firstbootEnvContent(cfg, &secrets.NodeSecrets{ - AtlasPasswordHash: "$atlas$hash", - RootPasswordHash: "$root$hash", + AtlasPassword: "atlas-pass", + RootPassword: "root-pass", }) if !reflect.DeepEqual(parseEnvLines(content), map[string]string{ - "METIS_HOSTNAME": "'titan-15'", - "METIS_SSH_USER": "'atlas'", - "METIS_ATLAS_USER": "'atlas'", - "METIS_K3S_VERSION": "'v1.31.5+k3s1'", - "METIS_ATLAS_PASSWORD_HASH": "'$atlas$hash'", - "METIS_ROOT_PASSWORD_HASH": "'$root$hash'", + "METIS_HOSTNAME": "'titan-15'", + "METIS_SSH_USER": "'atlas'", + "METIS_ATLAS_USER": "'atlas'", + "METIS_K3S_VERSION": "'v1.31.5+k3s1'", + "METIS_ATLAS_PASSWORD": "'atlas-pass'", + "METIS_ROOT_PASSWORD": "'root-pass'", }) { t.Fatalf("firstbootEnvContent = %q", content) } diff --git a/pkg/secrets/vault.go b/pkg/secrets/vault.go index 74a0712..cf2b2f4 100644 --- a/pkg/secrets/vault.go +++ b/pkg/secrets/vault.go @@ -15,15 +15,11 @@ import ( // NodeSecrets holds per-node secret material to inject at burn time. // These should live in Vault at kv/data/atlas/nodes/. type NodeSecrets struct { - SSHPassword string `json:"ssh_password,omitempty"` - SSHPasswordHash string `json:"ssh_password_hash,omitempty"` - AtlasPassword string `json:"atlas_password,omitempty"` - AtlasPasswordHash string `json:"atlas_password_hash,omitempty"` - RootPassword string `json:"root_password,omitempty"` - RootPasswordHash string `json:"root_password_hash,omitempty"` - K3sToken string `json:"k3s_token,omitempty"` - CloudInit string `json:"cloud_init,omitempty"` - Extra map[string]string `json:"extra,omitempty"` + AtlasPassword string `json:"atlas_password,omitempty"` + RootPassword string `json:"root_password,omitempty"` + K3sToken string `json:"k3s_token,omitempty"` + CloudInit string `json:"cloud_init,omitempty"` + Extra map[string]string `json:"extra,omitempty"` } // Client fetches node secrets from Vault using either a token or AppRole. diff --git a/pkg/secrets/vault_test.go b/pkg/secrets/vault_test.go index 839ff80..5950264 100644 --- a/pkg/secrets/vault_test.go +++ b/pkg/secrets/vault_test.go @@ -16,11 +16,10 @@ func TestFetchNodeReturnsData(t *testing.T) { _ = json.NewEncoder(w).Encode(map[string]any{ "data": map[string]any{ "data": map[string]any{ - "ssh_password": "p1", - "atlas_password_hash": "$atlas$hash", - "root_password": "root-pw", - "k3s_token": "t1", - "cloud_init": "ci", + "atlas_password": "atlas-pw", + "root_password": "root-pw", + "k3s_token": "t1", + "cloud_init": "ci", }, }, }) @@ -35,7 +34,7 @@ func TestFetchNodeReturnsData(t *testing.T) { if err != nil { t.Fatalf("fetch: %v", err) } - if sec.SSHPassword != "p1" || sec.AtlasPasswordHash != "$atlas$hash" || sec.RootPassword != "root-pw" || sec.K3sToken != "t1" || sec.CloudInit != "ci" { + if sec.AtlasPassword != "atlas-pw" || sec.RootPassword != "root-pw" || sec.K3sToken != "t1" || sec.CloudInit != "ci" { t.Fatalf("unexpected secrets: %+v", sec) } } diff --git a/pkg/service/remote_helpers.go b/pkg/service/remote_helpers.go index 37f260e..c77e7c2 100644 --- a/pkg/service/remote_helpers.go +++ b/pkg/service/remote_helpers.go @@ -448,12 +448,8 @@ export METIS_SSH_KEY_HECATE_DB="{{ .Data.data.hecate_db_pub }}" secretPath := fmt.Sprintf("kv/data/atlas/nodes/%s", nodeHostname) annotations["vault.hashicorp.com/agent-inject-secret-metis-node-secrets-env.sh"] = secretPath annotations["vault.hashicorp.com/agent-inject-template-metis-node-secrets-env.sh"] = `{{ with secret "` + secretPath + `" }} -export METIS_NODE_SSH_PASSWORD="{{ .Data.data.ssh_password }}" -export METIS_NODE_SSH_PASSWORD_HASH="{{ .Data.data.ssh_password_hash }}" export METIS_NODE_ATLAS_PASSWORD="{{ .Data.data.atlas_password }}" -export METIS_NODE_ATLAS_PASSWORD_HASH="{{ .Data.data.atlas_password_hash }}" export METIS_NODE_ROOT_PASSWORD="{{ .Data.data.root_password }}" -export METIS_NODE_ROOT_PASSWORD_HASH="{{ .Data.data.root_password_hash }}" {{ end }}` } return annotations