chrome-service: grant emo shared browser access (noVNC + homelab browser CLI)
All checks were successful
ci/woodpecker/push/default Pipeline was successful
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:
parent
50077b43d4
commit
2e50c1235c
4 changed files with 214 additions and 8 deletions
|
|
@ -293,6 +293,42 @@ Key facts:
|
|||
byte-identical copy of `files/stealth.js`, guarded by a drift test — so the
|
||||
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
|
||||
|
||||
- **Anti-bot vs stealth arms race** — when an upstream beats us (DRM
|
||||
|
|
|
|||
|
|
@ -240,6 +240,79 @@ EOF
|
|||
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
|
||||
# T3_PORT never clobbers an injected CLAUDE_CODE_OAUTH_TOKEN, and vice-versa. Mode 0600.
|
||||
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
|
||||
fi
|
||||
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)
|
||||
fi
|
||||
refresh_codex_mirror "$os_user" # all tiers — mirror of the managed claudeMd
|
||||
|
|
|
|||
|
|
@ -49,14 +49,15 @@ resource "authentik_policy_expression" "admin_services_restriction" {
|
|||
|
||||
host = request.context.get("host", "")
|
||||
|
||||
# chrome-service noVNC (chrome.viktorbarzin.me) exposes Viktor's LIVE
|
||||
# logged-in browser sessions, so lock it to Viktor's own accounts ONLY.
|
||||
# "Home Server Admins" is NOT sufficient — emo (emil.barzin@gmail.com) is a
|
||||
# member. akadmin kept as break-glass. The homelab-browser CDP path is
|
||||
# already RBAC-gated (emo = oidc-power-user-readonly, no pods/portforward),
|
||||
# so this closes the only remaining, human, noVNC path. Match username OR
|
||||
# email so neither attribute alone can lock Viktor out.
|
||||
CHROME_ALLOWED = {"akadmin", "akadmin@viktorbarzin.me", "vbarzin@gmail.com"}
|
||||
# chrome-service noVNC (chrome.viktorbarzin.me) exposes LIVE logged-in browser
|
||||
# sessions from the SHARED persistent profile. Originally Viktor-only.
|
||||
# 2026-06-28 (Viktor's explicit decision): emo SHARES Viktor's browser, so emo
|
||||
# (emil.barzin / emil.barzin@gmail.com) is allowed in for noVNC form-filling +
|
||||
# captcha solving. Trade-off accepted: emo can therefore reach Viktor's warmed
|
||||
# sessions (the CLI half is the emo-browser ServiceAccount in
|
||||
# stacks/chrome-service/rbac.tf). akadmin kept as break-glass. Match username OR
|
||||
# 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":
|
||||
return request.user.username in CHROME_ALLOWED or request.user.email in CHROME_ALLOWED
|
||||
|
||||
|
|
|
|||
95
stacks/chrome-service/rbac.tf
Normal file
95
stacks/chrome-service/rbac.tf
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue