homelab ha token: dedicated openclaw/ha-tokens secret + least-priv RBAC for emo
Some checks are pending
Build infra CLI / build (push) Waiting to run
ci/woodpecker/push/default Pipeline was successful

`ha token` originally read openclaw/openclaw-secrets -> skill_secrets, which only
cluster admins can read — so it hung/failed for the non-admin operator it was
built for (emo = emil.barzin@gmail.com, OIDC group "Home Server Admins", whose
identity is deliberately barred from secrets in the openclaw namespace).

Split the HA tokens into a dedicated secret openclaw/ha-tokens (keys sofia/london)
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 a separate object).
emo now resolves the HA token with their own identity, WITHOUT gaining the rest
of skill_secrets (slack_webhook, uptime_kuma_password). openclaw's own deployment
keeps reading openclaw-secrets — purely additive.

- stacks/openclaw/ha_tokens.tf: new secret + least-privilege Role/RoleBinding
- cli/cmd_ha.go: read openclaw/ha-tokens (raw base64 per-instance key); drop JSON parse
- README + ADR-0012 updated; VERSION -> v0.7.1

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-06-21 10:45:32 +00:00
parent a091689603
commit b1bbe42821
6 changed files with 100 additions and 51 deletions

View file

@ -162,7 +162,7 @@ and a cwd-relative path, neither of which holds in an arbitrary session.
| Command | Tier | What it does | | 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] -- <cmd>` | write | run `<cmd>` 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 | | `ha ssh [--instance sofia\|london] [-i KEY] -- <cmd>` | write | run `<cmd>` 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` `--instance` defaults to **sofia** (the devvm shares the Sofia LAN). `ha token`

View file

@ -1 +1 @@
v0.7.0 v0.7.1

View file

@ -2,7 +2,6 @@ package main
import ( import (
"encoding/base64" "encoding/base64"
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@ -14,23 +13,24 @@ import (
// host-level work (config files, docker, add-ons). Entity state/control stays // host-level work (config files, docker, add-ons). Entity state/control stays
// with the MCP — see docs/adr/0012. // with the MCP — see docs/adr/0012.
// //
// The token lives in a k8s Secret (a JSON blob of several skill tokens), the // The token lives in the dedicated k8s Secret openclaw/ha-tokens (one key per
// same place the openclaw agent reads it from. `ha token` resolves it on demand // instance), split out of openclaw-secrets so non-admin operators (emo / "Home
// via the ambient kubeconfig, so it never depends on a pre-set env var (the gap // Server Admins") can read JUST the HA token, not the full skill_secrets blob.
// that made agents re-derive the kubectl|base64|jq pipeline every session). // `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 { type haInstance struct {
name string // sofia | london name string // sofia | london
sshUser string // SSH login on the HA host sshUser string // SSH login on the HA host
sshHost string // host reachable from the devvm (Sofia LAN) 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 ( const (
haDefaultInstance = "sofia" haDefaultInstance = "sofia"
haSecretNamespace = "openclaw" haSecretNamespace = "openclaw"
haSecretName = "openclaw-secrets" haSecretName = "ha-tokens" // dedicated, least-privilege; see stacks/openclaw/ha_tokens.tf
haSecretField = "skill_secrets" // a base64 JSON blob: {token-name: token}
) )
// haInstances maps instance name → connection/secret facts. sofia is the default // 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` // (192.168.8.x) is only reachable remotely, so `ha ssh --instance london`
// generally won't connect from here (token resolution still works). // generally won't connect from here (token resolution still works).
var haInstances = map[string]haInstance{ var haInstances = map[string]haInstance{
"sofia": {name: "sofia", sshUser: "vbarzin", sshHost: "192.168.1.8", secretKey: "home_assistant_sofia_token"}, "sofia": {name: "sofia", sshUser: "vbarzin", sshHost: "192.168.1.8", secretKey: "sofia"},
"london": {name: "london", sshUser: "hassio", sshHost: "192.168.8.103", secretKey: "home_assistant_token"}, "london": {name: "london", sshUser: "hassio", sshHost: "192.168.8.103", secretKey: "london"},
} }
func haCommands() []Command { func haCommands() []Command {
@ -63,22 +63,14 @@ func resolveHAInstance(name string) (haInstance, error) {
return inst, nil return inst, nil
} }
// parseSkillSecret decodes the base64 skill_secrets blob (as returned by kubectl // decodeSecretValue base64-decodes a k8s Secret `.data.<key>` value as returned
// jsonpath, trailing whitespace tolerated) and returns the value for key. // by kubectl jsonpath (trailing whitespace tolerated).
func parseSkillSecret(b64, key string) (string, error) { func decodeSecretValue(b64 string) (string, error) {
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64))
if err != nil { 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 return string(raw), nil
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
} }
func haToken(args []string) error { func haToken(args []string) error {
@ -95,14 +87,14 @@ func haToken(args []string) error {
return err return err
} }
b64, err := kubectlCapture(haSecretNamespace, "get", "secret", haSecretName, b64, err := kubectlCapture(haSecretNamespace, "get", "secret", haSecretName,
"-o", "jsonpath={.data."+haSecretField+"}") "-o", "jsonpath={.data."+inst.secretKey+"}")
if err != nil { 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 == "" { 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 { if err != nil {
return err return err
} }

View file

@ -12,10 +12,10 @@ func TestResolveHAInstance(t *testing.T) {
if got, err := resolveHAInstance(""); err != nil || got.name != "sofia" { if got, err := resolveHAInstance(""); err != nil || got.name != "sofia" {
t.Fatalf(`resolveHAInstance("") = %+v, %v; want sofia`, got, err) 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) 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) t.Fatalf("london = %+v, %v", got, err)
} }
if _, err := resolveHAInstance("paris"); err == nil { if _, err := resolveHAInstance("paris"); err == nil {
@ -23,22 +23,19 @@ func TestResolveHAInstance(t *testing.T) {
} }
} }
func TestParseSkillSecret(t *testing.T) { func TestDecodeSecretValue(t *testing.T) {
blob := base64.StdEncoding.EncodeToString([]byte( // k8s stores Secret values base64-encoded; `kubectl -o jsonpath={.data.<k>}`
`{"home_assistant_sofia_token":"tok-sofia","home_assistant_token":"tok-london","slack_webhook":"https://x"}`)) // returns that base64, which decodeSecretValue turns back into the raw token.
enc := base64.StdEncoding.EncodeToString([]byte("tok-sofia"))
if got, err := parseSkillSecret(blob, "home_assistant_sofia_token"); err != nil || got != "tok-sofia" { if got, err := decodeSecretValue(enc); err != nil || got != "tok-sofia" {
t.Fatalf("parseSkillSecret sofia = %q, %v; want tok-sofia", got, err) t.Fatalf("decodeSecretValue = %q, %v; want tok-sofia", got, err)
} }
// kubectl jsonpath output can carry trailing whitespace/newline — must tolerate it // trailing whitespace/newline from jsonpath output must be tolerated
if got, err := parseSkillSecret(blob+"\n", "home_assistant_token"); err != nil || got != "tok-london" { if got, err := decodeSecretValue(enc + "\n"); err != nil || got != "tok-sofia" {
t.Fatalf("parseSkillSecret london (trailing ws) = %q, %v; want tok-london", got, err) t.Fatalf("decodeSecretValue (trailing ws) = %q, %v; want tok-sofia", got, err)
} }
if _, err := parseSkillSecret(blob, "missing_key"); err == nil { if _, err := decodeSecretValue("not-base64!!"); err == nil {
t.Fatalf("parseSkillSecret should error on a key absent from the blob") t.Fatalf("decodeSecretValue should error on undecodable base64")
}
if _, err := parseSkillSecret("not-base64!!", "home_assistant_sofia_token"); err == nil {
t.Fatalf("parseSkillSecret should error on undecodable base64")
} }
} }

View file

@ -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 *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). 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 - **`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 the dedicated k8s Secret `openclaw/ha-tokens` (one key per instance: `sofia` /
blob of several tokens), and prints the per-instance key `london`) via the ambient kubeconfig. This is robust to env drift — the precise
(`home_assistant_sofia_token` / `home_assistant_token`) via the ambient failure that made agents re-derive the pipeline. Read-tier, prints the bare
kubeconfig. This is robust to env drift — the precise failure that made agents token to stdout so it composes in `$(…)`, mirroring `memory secret`.
re-derive the pipeline. Read-tier, prints the bare token to stdout so it - **The token is split into its own least-privilege secret** (`stacks/openclaw/ha_tokens.tf`).
composes in `$(…)`, mirroring `memory secret`. 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 - **`ha ssh` is deterministic and per-user.** Flags are fixed for unattended
use: `-F /dev/null` (ignore user ssh-config), `StrictHostKeyChecking=no` + use: `-F /dev/null` (ignore user ssh-config), `StrictHostKeyChecking=no` +
`UserKnownHostsFile=/dev/null` (no host-key prompt/record — agents have no `UserKnownHostsFile=/dev/null` (no host-key prompt/record — agents have no

View file

@ -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 <sofia|london>` 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"
}
}