diff --git a/cli/README.md b/cli/README.md index 0ee27093..adc06920 100644 --- a/cli/README.md +++ b/cli/README.md @@ -162,7 +162,7 @@ and a cwd-relative path, neither of which holds in an arbitrary session. | Command | Tier | What it does | |---|---|---| -| `ha token [--instance sofia\|london]` | read | print the long-lived HA API token, resolved live from k8s Secret `openclaw/openclaw-secrets` (`skill_secrets` JSON) via the ambient kubeconfig — no pre-set env var. Use as `curl -H "Authorization: Bearer $(homelab ha token)" …` | +| `ha token [--instance sofia\|london]` | read | print the long-lived HA API token, resolved live from the dedicated k8s Secret `openclaw/ha-tokens` (key per instance) via the ambient kubeconfig — no pre-set env var. Use as `curl -H "Authorization: Bearer $(homelab ha token)" …`. The secret is a least-privilege carve-out (`stacks/openclaw/ha_tokens.tf`): the `Home Server Admins` group can read *just* it, so non-admin operators get the HA token without the rest of `skill_secrets` (slack webhook, uptime-kuma password) | | `ha ssh [--instance sofia\|london] [-i KEY] -- ` | write | run `` on the HA host over ssh with deterministic non-interactive flags (explicit key = the invoking user's `~/.ssh/id_ed25519`, no user ssh-config, no known_hosts prompt). sofia (`vbarzin@192.168.1.8`) is reachable from the devvm LAN; london is documented but generally remote | `--instance` defaults to **sofia** (the devvm shares the Sofia LAN). `ha token` diff --git a/cli/VERSION b/cli/VERSION index 8b20e485..63f2359f 100644 --- a/cli/VERSION +++ b/cli/VERSION @@ -1 +1 @@ -v0.7.0 +v0.7.1 diff --git a/cli/cmd_ha.go b/cli/cmd_ha.go index 4553dec0..2309bdfc 100644 --- a/cli/cmd_ha.go +++ b/cli/cmd_ha.go @@ -2,7 +2,6 @@ package main import ( "encoding/base64" - "encoding/json" "fmt" "os" "path/filepath" @@ -14,23 +13,24 @@ import ( // host-level work (config files, docker, add-ons). Entity state/control stays // with the MCP — see docs/adr/0012. // -// The token lives in a k8s Secret (a JSON blob of several skill tokens), the -// same place the openclaw agent reads it from. `ha token` resolves it on demand -// via the ambient kubeconfig, so it never depends on a pre-set env var (the gap -// that made agents re-derive the kubectl|base64|jq pipeline every session). +// The token lives in the dedicated k8s Secret openclaw/ha-tokens (one key per +// instance), split out of openclaw-secrets so non-admin operators (emo / "Home +// Server Admins") can read JUST the HA token, not the full skill_secrets blob. +// `ha token` resolves it on demand via the ambient kubeconfig, so it never +// depends on a pre-set env var (the gap that made agents re-derive the +// kubectl|base64|jq pipeline every session). type haInstance struct { name string // sofia | london sshUser string // SSH login on the HA host sshHost string // host reachable from the devvm (Sofia LAN) - secretKey string // key inside skill_secrets holding this instance's token + secretKey string // key inside the openclaw/ha-tokens Secret holding this token } const ( haDefaultInstance = "sofia" haSecretNamespace = "openclaw" - haSecretName = "openclaw-secrets" - haSecretField = "skill_secrets" // a base64 JSON blob: {token-name: token} + haSecretName = "ha-tokens" // dedicated, least-privilege; see stacks/openclaw/ha_tokens.tf ) // haInstances maps instance name → connection/secret facts. sofia is the default @@ -38,8 +38,8 @@ const ( // (192.168.8.x) is only reachable remotely, so `ha ssh --instance london` // generally won't connect from here (token resolution still works). var haInstances = map[string]haInstance{ - "sofia": {name: "sofia", sshUser: "vbarzin", sshHost: "192.168.1.8", secretKey: "home_assistant_sofia_token"}, - "london": {name: "london", sshUser: "hassio", sshHost: "192.168.8.103", secretKey: "home_assistant_token"}, + "sofia": {name: "sofia", sshUser: "vbarzin", sshHost: "192.168.1.8", secretKey: "sofia"}, + "london": {name: "london", sshUser: "hassio", sshHost: "192.168.8.103", secretKey: "london"}, } func haCommands() []Command { @@ -63,22 +63,14 @@ func resolveHAInstance(name string) (haInstance, error) { return inst, nil } -// parseSkillSecret decodes the base64 skill_secrets blob (as returned by kubectl -// jsonpath, trailing whitespace tolerated) and returns the value for key. -func parseSkillSecret(b64, key string) (string, error) { +// decodeSecretValue base64-decodes a k8s Secret `.data.` value as returned +// by kubectl jsonpath (trailing whitespace tolerated). +func decodeSecretValue(b64 string) (string, error) { raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) if err != nil { - return "", fmt.Errorf("decode %s: %w", haSecretField, err) + return "", fmt.Errorf("base64-decode secret value: %w", err) } - var m map[string]string - if err := json.Unmarshal(raw, &m); err != nil { - return "", fmt.Errorf("parse %s json: %w", haSecretField, err) - } - v, ok := m[key] - if !ok { - return "", fmt.Errorf("key %q not present in %s", key, haSecretField) - } - return v, nil + return string(raw), nil } func haToken(args []string) error { @@ -95,14 +87,14 @@ func haToken(args []string) error { return err } b64, err := kubectlCapture(haSecretNamespace, "get", "secret", haSecretName, - "-o", "jsonpath={.data."+haSecretField+"}") + "-o", "jsonpath={.data."+inst.secretKey+"}") if err != nil { - return fmt.Errorf("read secret %s/%s (kubeconfig set?): %w", haSecretNamespace, haSecretName, err) + return fmt.Errorf("read secret %s/%s (kubeconfig set? RBAC?): %w", haSecretNamespace, haSecretName, err) } if b64 == "" { - return fmt.Errorf("secret %s/%s has no %q field", haSecretNamespace, haSecretName, haSecretField) + return fmt.Errorf("secret %s/%s has no %q key", haSecretNamespace, haSecretName, inst.secretKey) } - tok, err := parseSkillSecret(b64, inst.secretKey) + tok, err := decodeSecretValue(b64) if err != nil { return err } diff --git a/cli/cmd_ha_test.go b/cli/cmd_ha_test.go index 7493b26e..9dc10e11 100644 --- a/cli/cmd_ha_test.go +++ b/cli/cmd_ha_test.go @@ -12,10 +12,10 @@ func TestResolveHAInstance(t *testing.T) { if got, err := resolveHAInstance(""); err != nil || got.name != "sofia" { t.Fatalf(`resolveHAInstance("") = %+v, %v; want sofia`, got, err) } - if got, err := resolveHAInstance("sofia"); err != nil || got.secretKey != "home_assistant_sofia_token" { + if got, err := resolveHAInstance("sofia"); err != nil || got.secretKey != "sofia" { t.Fatalf("sofia secretKey = %q, %v", got.secretKey, err) } - if got, err := resolveHAInstance("london"); err != nil || got.secretKey != "home_assistant_token" || got.sshUser != "hassio" { + if got, err := resolveHAInstance("london"); err != nil || got.secretKey != "london" || got.sshUser != "hassio" { t.Fatalf("london = %+v, %v", got, err) } if _, err := resolveHAInstance("paris"); err == nil { @@ -23,22 +23,19 @@ func TestResolveHAInstance(t *testing.T) { } } -func TestParseSkillSecret(t *testing.T) { - blob := base64.StdEncoding.EncodeToString([]byte( - `{"home_assistant_sofia_token":"tok-sofia","home_assistant_token":"tok-london","slack_webhook":"https://x"}`)) - - if got, err := parseSkillSecret(blob, "home_assistant_sofia_token"); err != nil || got != "tok-sofia" { - t.Fatalf("parseSkillSecret sofia = %q, %v; want tok-sofia", got, err) +func TestDecodeSecretValue(t *testing.T) { + // k8s stores Secret values base64-encoded; `kubectl -o jsonpath={.data.}` + // returns that base64, which decodeSecretValue turns back into the raw token. + enc := base64.StdEncoding.EncodeToString([]byte("tok-sofia")) + if got, err := decodeSecretValue(enc); err != nil || got != "tok-sofia" { + t.Fatalf("decodeSecretValue = %q, %v; want tok-sofia", got, err) } - // kubectl jsonpath output can carry trailing whitespace/newline — must tolerate it - if got, err := parseSkillSecret(blob+"\n", "home_assistant_token"); err != nil || got != "tok-london" { - t.Fatalf("parseSkillSecret london (trailing ws) = %q, %v; want tok-london", got, err) + // trailing whitespace/newline from jsonpath output must be tolerated + if got, err := decodeSecretValue(enc + "\n"); err != nil || got != "tok-sofia" { + t.Fatalf("decodeSecretValue (trailing ws) = %q, %v; want tok-sofia", got, err) } - if _, err := parseSkillSecret(blob, "missing_key"); err == nil { - t.Fatalf("parseSkillSecret should error on a key absent from the blob") - } - if _, err := parseSkillSecret("not-base64!!", "home_assistant_sofia_token"); err == nil { - t.Fatalf("parseSkillSecret should error on undecodable base64") + if _, err := decodeSecretValue("not-base64!!"); err == nil { + t.Fatalf("decodeSecretValue should error on undecodable base64") } } diff --git a/docs/adr/0012-homelab-ha-verbs.md b/docs/adr/0012-homelab-ha-verbs.md index 465a3014..379f8ee5 100644 --- a/docs/adr/0012-homelab-ha-verbs.md +++ b/docs/adr/0012-homelab-ha-verbs.md @@ -19,12 +19,20 @@ gap for every user in every directory. *resolution* and host *SSH*, neither of which an API-only MCP can provide. The value is endpoint/secret/host resolution, exactly like `net`/`dns` (ADR-0010). - **`ha token` resolves live from the cluster, not from an env var.** It reads - k8s Secret `openclaw/openclaw-secrets`, field `skill_secrets` (a base64 JSON - blob of several tokens), and prints the per-instance key - (`home_assistant_sofia_token` / `home_assistant_token`) via the ambient - kubeconfig. This is robust to env drift — the precise failure that made agents - re-derive the pipeline. Read-tier, prints the bare token to stdout so it - composes in `$(…)`, mirroring `memory secret`. + the dedicated k8s Secret `openclaw/ha-tokens` (one key per instance: `sofia` / + `london`) via the ambient kubeconfig. This is robust to env drift — the precise + failure that made agents re-derive the pipeline. Read-tier, prints the bare + token to stdout so it composes in `$(…)`, mirroring `memory secret`. +- **The token is split into its own least-privilege secret** (`stacks/openclaw/ha_tokens.tf`). + It was originally read from `openclaw-secrets` → `skill_secrets` (a JSON blob + also holding `slack_webhook` + `uptime_kuma_password`), which only cluster + admins can read — so the verb hung/failed for the non-admin operator it was + built for (emo = `emil.barzin@gmail.com`, group `Home Server Admins`, whose + OIDC identity is barred from secrets in `openclaw`). `ha-tokens` carries only + the HA tokens, with a Role+RoleBinding granting `get` on *just that secret* to + the `Home Server Admins` group (k8s RBAC can't scope to a JSON sub-key, hence + the separate object). openclaw's own deployment keeps reading `openclaw-secrets` + — this is purely additive. - **`ha ssh` is deterministic and per-user.** Flags are fixed for unattended use: `-F /dev/null` (ignore user ssh-config), `StrictHostKeyChecking=no` + `UserKnownHostsFile=/dev/null` (no host-key prompt/record — agents have no diff --git a/stacks/openclaw/ha_tokens.tf b/stacks/openclaw/ha_tokens.tf new file mode 100644 index 00000000..6f81ee9b --- /dev/null +++ b/stacks/openclaw/ha_tokens.tf @@ -0,0 +1,52 @@ +# Dedicated secret holding ONLY the Home Assistant API tokens, split out of +# openclaw-secrets so the `homelab ha token` CLI verb can serve non-admin +# operators (emo = emil.barzin@gmail.com, group "Home Server Admins") WITHOUT +# granting them read on the full skill_secrets blob (which also carries +# slack_webhook + uptime_kuma_password). openclaw's own deployment keeps reading +# openclaw-secrets — this is purely an additive, least-privilege carve-out for +# the CLI. See infra/cli/cmd_ha.go + docs/adr/0012. +resource "kubernetes_secret" "ha_tokens" { + metadata { + name = "ha-tokens" + namespace = kubernetes_namespace.openclaw.metadata[0].name + } + data = { + # keys match the homelab `ha token --instance ` mapping + sofia = local.skill_secrets["home_assistant_sofia_token"] + london = local.skill_secrets["home_assistant_token"] + } + type = "Opaque" +} + +# get on JUST the ha-tokens secret (resource_names pins it to this one object), +# bound to the "Home Server Admins" OIDC group — the group emo authenticates +# into. Scope deliberately excludes openclaw-secrets and every other secret. +resource "kubernetes_role" "ha_tokens_reader" { + metadata { + name = "ha-tokens-reader" + namespace = kubernetes_namespace.openclaw.metadata[0].name + } + rule { + api_groups = [""] + resources = ["secrets"] + resource_names = [kubernetes_secret.ha_tokens.metadata[0].name] + verbs = ["get"] + } +} + +resource "kubernetes_role_binding" "ha_tokens_reader" { + metadata { + name = "ha-tokens-reader" + namespace = kubernetes_namespace.openclaw.metadata[0].name + } + role_ref { + api_group = "rbac.authorization.k8s.io" + kind = "Role" + name = kubernetes_role.ha_tokens_reader.metadata[0].name + } + subject { + kind = "Group" + name = "Home Server Admins" + api_group = "rbac.authorization.k8s.io" + } +}