chrome-service: grant emo shared browser access (noVNC + homelab browser CLI)
All checks were successful
ci/woodpecker/push/default Pipeline was successful

Viktor asked to give emo access to the cluster's headed Chrome so he can fill
in forms and get past anti-bot / captcha pages. emo was deliberately locked
out of chrome-service (noVNC Authentik allowlist was Viktor-only + his
power-user RBAC has no pods/portforward). Viktor's explicit decision: SHARE
his existing browser rather than stand up an isolated per-user instance,
accepting that emo can therefore reach Viktor's warmed logged-in sessions
(CDP has no per-context auth, so the single shared persistent profile is
reachable by anyone who can drive the browser). emo's CLI use is hands-off
(his agent can run it unattended).

- authentik: add emo (emil.barzin / emil.barzin@gmail.com) to CHROME_ALLOWED
  so the admin-services-restriction policy admits him to chrome.viktorbarzin.me
  (noVNC). Reverses the prior Viktor-only lock; comment updated to record why.
- chrome-service/rbac.tf (new): emo-browser ServiceAccount + long-lived token
  (dashboard-sa.tf pattern), a chrome-service-portforward Role granting
  pods/portforward, and a cluster read-only binding (oidc-power-user-readonly)
  so the SA can resolve the Service and emo's normal read access doesn't regress.
- t3-provision-users.sh: install_browser_kubeconfig installs a dual-context
  kubeconfig for any user with a <user>-browser SA — SA token as the default
  context (non-interactive, works headless), personal OIDC retained as the
  oidc@homelab named context. emo's OIDC-only kubeconfig can't authenticate the
  headless agent session that homelab browser needs.
- docs/architecture/chrome-service.md: document the shared-browser multi-user
  access model, the session-exposure trade-off, and how to grant/revoke a user.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-06-28 15:20:07 +00:00
parent 50077b43d4
commit 2e50c1235c
4 changed files with 214 additions and 8 deletions

View file

@ -293,6 +293,42 @@ Key facts:
byte-identical copy of `files/stealth.js`, guarded by a drift test — so the byte-identical copy of `files/stealth.js`, guarded by a drift test — so the
CLI's stealth never diverges from the in-cluster callers'. CLI's stealth never diverges from the in-cluster callers'.
## Multi-user access (sharing the browser)
There is ONE chrome-service browser with ONE persistent profile, warmed with
**Viktor's** logged-in sessions. CDP has no per-context auth, so anyone who can
drive the browser — over the noVNC view OR the CDP/`homelab browser` path — can
reach the persistent profile (`browser.contexts[0]`) and therefore Viktor's
sessions. Access is gated accordingly, per user.
**Decision (2026-06-28):** emo (`emil.barzin` / `emil.barzin@gmail.com`) SHARES
Viktor's browser for form-filling + captcha solving, rather than getting an
isolated instance. The session-exposure trade-off above was explicitly accepted.
Two independent grants make up "browser access" for a user:
1. **noVNC (interactive view, `chrome.viktorbarzin.me`)** — gated by the Authentik
`admin-services-restriction` policy: the `CHROME_ALLOWED` set
(`stacks/authentik/admin-services-restriction.tf`) matches the user's Authentik
username OR email. Add the user there. No kubeconfig/RBAC needed.
2. **CLI (`homelab browser`, CDP over port-forward)** — needs `pods/portforward`
in `chrome-service` PLUS a non-interactive credential (a normal devvm user's
kubeconfig is interactive-OIDC-only and can't authenticate a headless agent
session). Provided by a per-user **ServiceAccount** with a long-lived token
(`stacks/chrome-service/rbac.tf`, e.g. `emo-browser`): `pods/portforward` in
this namespace + cluster read-only (`oidc-power-user-readonly`, so it can also
resolve the Service and doesn't regress the user's normal read). The devvm
provisioner (`scripts/t3-provision-users.sh``install_browser_kubeconfig`)
reads that token and installs it as the user's DEFAULT kubeconfig context
(`<user>-browser@homelab`), keeping their personal OIDC login as the
`oidc@homelab` named context. The SA's existence is the source of truth for who
gets the CLI — the provisioner no-ops for users without a `<user>-browser` SA.
**To grant another user:** add them to `CHROME_ALLOWED` (noVNC) and/or add a
`<user>-browser` SA + bindings mirroring `emo-browser` in `rbac.tf` (CLI), then run
the provisioner. To revoke: remove from `CHROME_ALLOWED` and delete the SA (rotate
a token by deleting its `<user>-browser-token` Secret).
## Limits + risks ## Limits + risks
- **Anti-bot vs stealth arms race** — when an upstream beats us (DRM - **Anti-bot vs stealth arms race** — when an upstream beats us (DRM

View file

@ -240,6 +240,79 @@ EOF
log "wrote OIDC kubeconfig -> $user:~/.kube/config" log "wrote OIDC kubeconfig -> $user:~/.kube/config"
} }
# Hands-off chrome-service browser credential. For a user who has a
# `<os_user>-browser` ServiceAccount in the chrome-service namespace (created in
# stacks/chrome-service/rbac.tf), install a DUAL-CONTEXT kubeconfig whose DEFAULT
# context authenticates with that SA's long-lived token — so `homelab browser`
# (which shells out to `kubectl port-forward -n chrome-service`) works
# non-interactively, even from a headless agent session (the user's interactive
# OIDC login can't authenticate a headless kubectl). The user's personal OIDC
# identity is retained as the `oidc@homelab` named context
# (`kubectl --context oidc@homelab`). TF (the SA's existence) is the source of
# truth for WHO gets this — there is no roster flag. Idempotent (cmp-guarded; SA
# tokens are stable) + best-effort (cluster/secret unreachable -> WARN, never aborts).
install_browser_kubeconfig() {
local user="$1" home kc sa secret token server ca tmp
home="$(getent passwd "$user" | cut -d: -f6)"
[[ -z "$home" ]] && return 0
sa="${user}-browser"
secret="${sa}-token"
[[ -r "$ADMIN_KUBECONFIG" ]] || return 0
# Gate: only users with a chrome-service browser SA (TF-driven). Best-effort read.
KUBECONFIG="$ADMIN_KUBECONFIG" kubectl --request-timeout=10s -n chrome-service get serviceaccount "$sa" >/dev/null 2>&1 || return 0
token="$(KUBECONFIG="$ADMIN_KUBECONFIG" kubectl --request-timeout=10s -n chrome-service get secret "$secret" -o jsonpath='{.data.token}' 2>/dev/null | base64 -d 2>/dev/null || true)"
[[ -n "$token" ]] || { log "WARN: browser SA token not ready for $user (secret chrome-service/$secret) — skipped"; return 0; }
server="$(KUBECONFIG="$ADMIN_KUBECONFIG" kubectl config view --raw --minify -o jsonpath='{.clusters[0].cluster.server}')"
ca="$(KUBECONFIG="$ADMIN_KUBECONFIG" kubectl config view --raw --minify -o jsonpath='{.clusters[0].cluster.certificate-authority-data}')"
[[ -n "$server" && -n "$ca" ]] || { log "WARN: could not read cluster server/CA -> skip browser kubeconfig for $user"; return 0; }
kc="$home/.kube/config"
tmp="$(mktemp)"
cat > "$tmp" <<EOF
apiVersion: v1
kind: Config
clusters:
- name: homelab
cluster:
server: $server
certificate-authority-data: $ca
contexts:
- name: ${sa}@homelab
context:
cluster: homelab
user: $sa
- name: oidc@homelab
context:
cluster: homelab
user: oidc
current-context: ${sa}@homelab
users:
- name: $sa
user:
token: $token
- name: oidc
user:
exec:
apiVersion: client.authentication.k8s.io/v1beta1
command: kubectl
args:
- oidc-login
- get-token
- --oidc-issuer-url=$OIDC_ISSUER
- --oidc-client-id=kubernetes
- --oidc-extra-scope=email
- --oidc-extra-scope=profile
- --oidc-extra-scope=groups
interactiveMode: IfAvailable
EOF
if cmp -s "$tmp" "$kc" 2>/dev/null; then rm -f "$tmp"; return 0; fi # already current -> no churn
if [[ "$DRY_RUN" == 1 ]]; then echo "[dry-run] dual-context (SA default + OIDC) browser kubeconfig -> $user:$kc"; rm -f "$tmp"; return 0; fi
install -d -o "$user" -g "$user" -m 0700 "$home/.kube"
install -o "$user" -g "$user" -m 0600 "$tmp" "$kc" || { log "WARN: failed to write browser kubeconfig for $user"; rm -f "$tmp"; return 0; }
rm -f "$tmp"
log "wrote dual-context browser kubeconfig (SA default + OIDC) -> $user:~/.kube/config"
return 0
}
# Idempotently set KEY=VALUE in a t3-serve env file, PRESERVING other lines — so writing # Idempotently set KEY=VALUE in a t3-serve env file, PRESERVING other lines — so writing
# T3_PORT never clobbers an injected CLAUDE_CODE_OAUTH_TOKEN, and vice-versa. Mode 0600. # T3_PORT never clobbers an injected CLAUDE_CODE_OAUTH_TOKEN, and vice-versa. Mode 0600.
env_set() { env_set() {
@ -594,6 +667,7 @@ while IFS=$'\t' read -r os_user tier shell groups_csv code_layout repos_csv; do
refresh_user_clone "$os_user" code refresh_user_clone "$os_user" code
fi fi
install_user_kubeconfig "$os_user" install_user_kubeconfig "$os_user"
install_browser_kubeconfig "$os_user" # hands-off chrome-service CLI cred (no-op unless the user has a browser SA)
deploy_user_launcher "$os_user" # keep ~/start-claude.sh current (skel only seeds new accounts) deploy_user_launcher "$os_user" # keep ~/start-claude.sh current (skel only seeds new accounts)
fi fi
refresh_codex_mirror "$os_user" # all tiers — mirror of the managed claudeMd refresh_codex_mirror "$os_user" # all tiers — mirror of the managed claudeMd

View file

@ -49,14 +49,15 @@ resource "authentik_policy_expression" "admin_services_restriction" {
host = request.context.get("host", "") host = request.context.get("host", "")
# chrome-service noVNC (chrome.viktorbarzin.me) exposes Viktor's LIVE # chrome-service noVNC (chrome.viktorbarzin.me) exposes LIVE logged-in browser
# logged-in browser sessions, so lock it to Viktor's own accounts ONLY. # sessions from the SHARED persistent profile. Originally Viktor-only.
# "Home Server Admins" is NOT sufficient emo (emil.barzin@gmail.com) is a # 2026-06-28 (Viktor's explicit decision): emo SHARES Viktor's browser, so emo
# member. akadmin kept as break-glass. The homelab-browser CDP path is # (emil.barzin / emil.barzin@gmail.com) is allowed in for noVNC form-filling +
# already RBAC-gated (emo = oidc-power-user-readonly, no pods/portforward), # captcha solving. Trade-off accepted: emo can therefore reach Viktor's warmed
# so this closes the only remaining, human, noVNC path. Match username OR # sessions (the CLI half is the emo-browser ServiceAccount in
# email so neither attribute alone can lock Viktor out. # stacks/chrome-service/rbac.tf). akadmin kept as break-glass. Match username OR
CHROME_ALLOWED = {"akadmin", "akadmin@viktorbarzin.me", "vbarzin@gmail.com"} # email so neither attribute alone can lock anyone out.
CHROME_ALLOWED = {"akadmin", "akadmin@viktorbarzin.me", "vbarzin@gmail.com", "emil.barzin", "emil.barzin@gmail.com"}
if host == "chrome.viktorbarzin.me": if host == "chrome.viktorbarzin.me":
return request.user.username in CHROME_ALLOWED or request.user.email in CHROME_ALLOWED return request.user.username in CHROME_ALLOWED or request.user.email in CHROME_ALLOWED

View file

@ -0,0 +1,95 @@
# emo's hands-off "homelab browser" credential + chrome-service port-forward RBAC.
#
# Access decision (2026-06-28, Viktor's explicit call): emo SHARES Viktor's single
# chrome-service browser rather than getting an isolated instance. The noVNC half of
# that grant is the Authentik allowlist in
# stacks/authentik/admin-services-restriction.tf (CHROME_ALLOWED); THIS file is the
# CLI half it lets emo's `homelab browser` reach the headed Chrome over CDP.
#
# `homelab browser` shells out to `kubectl port-forward -n chrome-service svc/chrome-service`
# (cli/browser.go). emo's normal kubeconfig is interactive-OIDC-only (kubelogin) and
# can't authenticate a headless agent session, and his power-user tier has no
# pods/portforward. So we mint a dedicated ServiceAccount with a long-lived token
# (the dashboard-sa.tf pattern) that the devvm provisioner installs as emo's DEFAULT
# kubeconfig context (scripts/t3-provision-users.sh install_browser_kubeconfig); his
# personal OIDC login stays available as the `oidc@homelab` named context.
#
# TRADE-OFF (accepted): CDP access == full control of the shared browser, including
# the persistent profile (browser.contexts[0]) where Viktor's warmed logins live.
# CDP has no per-context auth, so this SA can reach Viktor's sessions. That is inherent
# to sharing one browser (the isolated per-user instance was declined).
# See docs/architecture/chrome-service.md "Multi-user access".
resource "kubernetes_service_account" "emo_browser" {
metadata {
name = "emo-browser"
namespace = kubernetes_namespace.chrome_service.metadata[0].name
}
}
# Long-lived (non-expiring) token for the SA the devvm provisioner reads this and
# writes it into emo's kubeconfig. Same pattern as stacks/rbac/.../dashboard-sa.tf.
resource "kubernetes_secret" "emo_browser_token" {
metadata {
name = "emo-browser-token"
namespace = kubernetes_namespace.chrome_service.metadata[0].name
annotations = {
"kubernetes.io/service-account.name" = kubernetes_service_account.emo_browser.metadata[0].name
}
}
type = "kubernetes.io/service-account-token"
wait_for_service_account_token = true
}
# The ONLY verb emo's SA lacks for `kubectl port-forward svc/chrome-service`: the
# port-forward subresource. (get/list of pods + services + endpoints comes from the
# cluster-read binding below.) Namespace-scoped to chrome-service.
resource "kubernetes_role" "browser_portforward" {
metadata {
name = "chrome-service-portforward"
namespace = kubernetes_namespace.chrome_service.metadata[0].name
}
rule {
api_groups = [""]
resources = ["pods/portforward"]
verbs = ["create"]
}
}
resource "kubernetes_role_binding" "emo_browser_portforward" {
metadata {
name = "emo-browser-portforward"
namespace = kubernetes_namespace.chrome_service.metadata[0].name
}
role_ref {
api_group = "rbac.authorization.k8s.io"
kind = "Role"
name = kubernetes_role.browser_portforward.metadata[0].name
}
subject {
kind = "ServiceAccount"
name = kubernetes_service_account.emo_browser.metadata[0].name
namespace = kubernetes_namespace.chrome_service.metadata[0].name
}
}
# Cluster-wide read-only (NO secrets), mirroring emo's power-user OIDC access, bound
# to the SA. Needed because the SA becomes emo's DEFAULT kubectl context, so without
# this his everyday `kubectl get ...` would regress AND port-forward itself needs
# get/list on services + pods + endpoints (all covered by oidc-power-user-readonly).
# That ClusterRole is defined in stacks/rbac (modules/rbac/main.tf); referenced by name.
resource "kubernetes_cluster_role_binding" "emo_browser_readonly" {
metadata {
name = "emo-browser-readonly"
}
role_ref {
api_group = "rbac.authorization.k8s.io"
kind = "ClusterRole"
name = "oidc-power-user-readonly"
}
subject {
kind = "ServiceAccount"
name = kubernetes_service_account.emo_browser.metadata[0].name
namespace = kubernetes_namespace.chrome_service.metadata[0].name
}
}