From 3795fc6ece44ef5bc4d518fe9063db7753d9b43f Mon Sep 17 00:00:00 2001 From: DrummyFloyd Date: Sat, 25 Jul 2026 07:56:21 +0200 Subject: [PATCH 1/7] docs: document the session model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions were undocumented user-side: nothing said where a session lives, that signing out is global, or that deactivating a user takes effect on their next request rather than in eight hours. Says all of it now, plus what changed with the cookie: the portal keeps no credential in the browser, a reload keeps you signed in, and an SSO round-trip lands on a clean URL. Records the one residual — a desktop connection already open survives revocation, since the tunnel is authorized when it opens. --- docs/concepts/governance.md | 60 +++++++++++++++++++++++++++++ docs/installation/oidc-authentik.md | 5 +++ 2 files changed, 65 insertions(+) diff --git a/docs/concepts/governance.md b/docs/concepts/governance.md index c3f083f..5566b50 100644 --- a/docs/concepts/governance.md +++ b/docs/concepts/governance.md @@ -182,6 +182,66 @@ the source of truth, Git seeds them. If ArgoCD manages them, a UI edit is a manual override that the next sync overwrites; configure `selfHeal`/`ignoreDifferences` accordingly. +## Sessions + +Signing in — locally or through SSO — sets an **httpOnly session +cookie**. Nothing else: the portal stores no token, in `localStorage` or +anywhere else, and page scripts cannot read the cookie. There is no +credential in the browser for a malicious script to steal. + +What that means day to day: + +- **Reloading the page keeps you signed in.** The cookie survives it; the + portal re-reads your profile on load. +- **Nothing appears in the URL.** After an SSO round-trip you land on the + portal with a clean address bar. +- Sessions last `apiServer.accessTokenTTL` (8 h by default). That bound is + a fallback, not the security control — see below. + +### Changes take effect on the next request, not in 8 hours + +Every authenticated request re-checks the account behind the session. So +these are **immediate**, without waiting for any token to expire: + +| Action | Effect | +|---|---| +| Deactivate a user | Their next request is refused | +| Change a user's role | Their session stops; they sign in again with the new role | +| Reset or change a password | Existing sessions end | +| Sign out | Every session of that account ends | + +:::warning Signing out is global, not per device + +Signing out on one browser ends that account's sessions **everywhere**. +That is the intended default for a security control — per-device sign-out +would require the platform to track each session individually, which it +deliberately does not. + +::: + +**One residual to know**: a desktop connection already open keeps running +until it is closed. The tunnel is authorized once, when the connection +opens, so revocation gates every *new* connection rather than tearing +down a live one. Pause or delete the workspace to cut an in-progress +session immediately. + +### Scripts and CI + +The cookie is for browsers. Anything else authenticates with the +`Authorization: Bearer` header, using the token `POST /api/v1/auth/login` +returns: + +```sh +TOKEN=$(curl -s -X POST https://waas.example.com/api/v1/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"ci","password":"…"}' | jq -r .data.accessToken) + +curl -H "Authorization: Bearer $TOKEN" https://waas.example.com/api/v1/workspaces +``` + +The same rules apply to it — deactivate the account and the token stops +working on the next call. + ## Audit The api-server journals `workspace.created/denied/paused/resumed`, diff --git a/docs/installation/oidc-authentik.md b/docs/installation/oidc-authentik.md index 57fbc24..d39367c 100644 --- a/docs/installation/oidc-authentik.md +++ b/docs/installation/oidc-authentik.md @@ -119,6 +119,11 @@ policies (the "everyone gets the default policy" symptom) — check the provider's `profile` scope mapping is selected, then re-login (groups sync at login, not continuously). +The round-trip ends on the portal with a clean URL: the session arrives +as an httpOnly cookie, never in the address bar, and WaaS keeps no token +from Authentik — the IdP session is used once, at sign-in. See +[Sessions](../concepts/governance#sessions). + ## Going OIDC-only Once SSO is proven, `disableLocalLogin: true` turns off local From 5a9d5cf1c031b28ccaf4eabc74f82e8835a6ffdd Mon Sep 17 00:00:00 2001 From: DrummyFloyd Date: Sat, 25 Jul 2026 10:31:07 +0200 Subject: [PATCH 2/7] docs: document the per-user namespace default and its migration waas defaults workload placement to waas-{user} in chart 0.3.0. Records what an existing install inherits: workspaces stay put, retained homes do not follow, shared namespaces become an explicit opt-in. Adds the PlacementDenied case the prefix rule now produces. --- docs/concepts/placement.md | 86 ++++++++++++++++++++++++------ docs/concepts/volumes.md | 9 ++++ docs/installation/configuration.md | 7 +++ docs/troubleshooting.md | 10 ++-- 4 files changed, 93 insertions(+), 19 deletions(-) diff --git a/docs/concepts/placement.md b/docs/concepts/placement.md index c60c825..40f2406 100644 --- a/docs/concepts/placement.md +++ b/docs/concepts/placement.md @@ -22,7 +22,16 @@ From highest to lowest priority — enforced server-side: `workspaces.defaultNamespacePattern`, shared by the operator and the api-server. An invalid pattern makes both components **refuse to start** — never a silent fallback; -3. **built-in `waas-workspaces`**: a single shared namespace. +3. **built-in `waas-{user}`**: one namespace per user. + +The built-in default isolates because the namespace is where every +per-user protection attaches — ownership label, policy-derived quota, +default-deny NetworkPolicy — and because it matches the `waas-` +prefix the webhook already treats as the owner's territory. A **shared** +namespace remains perfectly legitimate, but it is an explicit admin +choice (a literal pattern on a template or in +`workspaces.defaultNamespacePattern`), no longer what you get by +default. Patterns accept these placeholders — each value is sanitized (NFKD, lowercase, DNS-1123) and hash-suffixed on truncation/collision, so two @@ -42,6 +51,28 @@ workspaces — existing ones keep their namespace, by design. Moving a workspace means recreating it. ::: +:::warning Default changed in chart 0.3.0 + +The built-in default moved from the shared `waas-workspaces` to the +per-user `waas-{user}`. Existing workspaces are untouched (their +`spec.targetNamespace` is frozen); only **new** ones land per-user. To +keep the previous behavior, declare it explicitly: + +```yaml +workspaces: + defaultNamespacePattern: "waas-workspaces" +``` + +**Retained home volumes do not follow.** A PVC is namespaced and only +attachable in the namespace it was left in, so homes retained in the old +shared namespace no longer appear when creating a workspace that now +resolves to `waas-`. Moving that data is a storage operation, +outside the platform's scope: use the usual tooling (VolumeSnapshot, or +a backup/restore round-trip with Longhorn, Velero or equivalent). +Keeping the shared pattern, as above, avoids the question entirely. + +::: + The portal shows the resolved namespace at creation time, and the template editor lists the valid placeholders. @@ -51,14 +82,15 @@ Created on first workload if missing — never modified afterward (your admin edits are not overwritten): - **Labels**: `app.kubernetes.io/managed-by=waas-operator`, the owner - label, and Pod Security labels (`enforce=baseline`, + label (personal namespaces only — the nominal case under the per-user + default), and Pod Security labels (`enforce=baseline`, `warn=restricted`) — plus the template's `placement.namespaceLabels/Annotations` (a server-side denylist filters reserved domains: `kubernetes.io`, `xorhub.io`, `argoproj.io`, service-mesh injectors, …). - A **`waas-quota` ResourceQuota** derived from the owner's policy - aggregate caps (defense in depth — the webhook remains the primary - enforcement). + aggregate caps, personal namespaces only (defense in depth — the + webhook remains the primary enforcement). - A **default-deny ingress NetworkPolicy**: only the platform namespace (where guacd/wwt run) can reach the desktops. Egress stays open. - **No user RBAC**: users never talk to the Kubernetes API directly — @@ -70,16 +102,24 @@ are **create-only** (above), workload metadata converges **by rollout**, and home-PVC metadata (`spec.homeVolume`) is **synced in place** with removal tracking — see [Volumes](volumes). -Shared namespaces (the built-in default, or `{os}`/`{templateName}` -patterns) get **neither** an ownership label **nor** an auto -ResourceQuota — a shared namespace quota would cap the whole team at -one person's budget; set your own namespace quota if you want one. +Shared namespaces — now the opt-in exception: a literal pattern like +`waas-workspaces`, or `{os}`/`{templateName}` — get **neither** an +ownership label **nor** an auto ResourceQuota, since they host several +owners and a shared quota would cap the whole team at one person's +budget; set your own namespace quota if you want one. The webhook stays +the per-user enforcement either way. For non-admins, a namespace **deviating** from the server-resolved -default must either match the `waas-` prefix (recomputed from the -trusted identity) or be an existing namespace labeled with their -ownership — anti-spoofing is webhook-enforced, fail-closed. System -namespaces (`kube-*`, the platform namespace) are refused for everyone. +default must either be an existing namespace labeled with their +ownership, or match the `waas-` prefix recomputed from the trusted +identity — and in that second case only while the namespace is free or +already theirs. Since `waas-{user}` is now the default, a name inside +someone's prefix territory (`waas-alice-lab` for `alice`) may well be +the personal namespace of the user `alice-lab`, quota and retained +volumes included: the prefix is a name rule, not proof of ownership, so +the webhook refuses it with `PlacementDenied`. Anti-spoofing is +webhook-enforced, fail-closed. System namespaces (`kube-*`, the platform +namespace) are refused for everyone. ## Namespace cleanup @@ -95,6 +135,11 @@ creation**: [retained home volume](volumes) keeps it alive, and the volume's later deletion re-triggers the janitor. +With the per-user default, `Retain` means one `waas-` namespace +per user survives the deletion of their last workspace — coherent with +the reason `Retain` exists (the retained home PVC lives there) and +reused by that user's next workspace. + ## Workload naming The api-server computes a workload name from the display name @@ -106,9 +151,18 @@ renames the compute. ## One pitfall to know about A template's `env.valueFrom.secretKeyRef` resolves in the **pod's** -namespace — the target namespace. A placed template referencing a -Secret that only exists in the platform namespace breaks at startup -(`CreateContainerConfigError`): provision the Secret in the target -namespaces (External Secrets/Vault), or don't place that template. +namespace — the target namespace, never the platform namespace, +whatever the pattern. A template referencing a Secret that only exists +in the platform namespace breaks at startup +(`CreateContainerConfigError`). + +What the per-user default changes is that the target namespace is no +longer **known in advance**, so the Secret cannot simply be +pre-provisioned once. Two supported ways out: provision it into the +target namespaces (External Secrets/Vault), or pin the template to a +shared namespace known in advance where the Secret is pre-provisioned — +what the dev `dev-ssh` template does with +`placement.namespace: waas-workspaces`. + Protocol `credentialsSecretRef`s are **not** affected — they resolve server-side in the platform namespace. diff --git a/docs/concepts/volumes.md b/docs/concepts/volumes.md index e232edf..c4d9628 100644 --- a/docs/concepts/volumes.md +++ b/docs/concepts/volumes.md @@ -66,6 +66,15 @@ spec: The webhook checks: same owner, volume actually retained, same target namespace. The operator then re-labels the volume as live. +:::caution After a placement-pattern change +"Same target namespace" is a hard constraint — a PVC is only attachable +where it lives. Volumes retained under a previous pattern (e.g. the +shared `waas-workspaces`, the built-in default before chart 0.3.0) do +**not** appear when the new workspace resolves to `waas-`. Moving +them is a storage operation: see the [migration +note](placement#how-the-target-namespace-is-resolved). +::: + ## Template metadata on home volumes A template can stamp labels and annotations on the home PVC via diff --git a/docs/installation/configuration.md b/docs/installation/configuration.md index d40ecb8..ef4cc21 100644 --- a/docs/installation/configuration.md +++ b/docs/installation/configuration.md @@ -83,6 +83,13 @@ workspaces: defaultNamespacePattern: "waas-{user}" # workload namespace pattern ``` +`waas-{user}` — one namespace per user — is also the built-in default +since chart 0.3.0 (it was the shared `waas-workspaces` before). A shared +namespace is still supported, as an explicit choice: +`defaultNamespacePattern: "waas-workspaces"`. Existing workspaces never +move, and **retained home volumes do not follow a default change** — see +the migration note in [Placement](../concepts/placement). + The pattern accepts `{user}`, `{workspace}`, `{templateName}` and `{os}` placeholders; an invalid pattern makes the operator and api-server **refuse to start** rather than silently fall back. Details diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8cd7bce..33d6b33 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -30,6 +30,7 @@ HTTP 403, the `Ready` condition and the portal alike. | `QuotaExceeded` | count, running count or aggregate over the policy limit | delete/pause something, or raise the policy. "running workspace quota reached" also denies **resume** — expected with `maxRunningWorkspaces`, pausing another workspace frees the slot (or create the workspace paused) | | `IdentityViolation` | `spec.owner` ≠ your authenticated user, or forged identity annotations | set `owner` to your own username; never set `waas.xorhub.io/*` identity annotations | | `OverrideNotAllowed` | an override field is not delegated to you | template ∩ policy `overrides.allowedFields` must contain the field | +| `PlacementDenied` | the `targetNamespace` you asked for is neither the resolved default, nor labeled with your ownership, nor a free name in your `waas-` territory — a name inside your prefix that already belongs to another user is refused | drop `targetNamespace` to take the default, or ask an admin (they may place anywhere) | A user whose group mirror is empty matches only subjects-less policies — that's the "everyone gets the default policy" symptom, not a priority @@ -44,9 +45,12 @@ bug: groups sync from the IdP at every SSO login (or via admin edit). `WAAS_DESKTOP_PASSWORD`** — under the platform this is injected automatically; standalone/custom setups must provide it. Legacy `VNC_PW`/`RDP_PASSWORD` are refused with an explicit error. -- Placed template + `CreateContainerConfigError`: a template - `secretKeyRef` resolves in the **target** namespace — provision the - Secret there. See [Placement](concepts/placement#one-pitfall-to-know-about). +- `CreateContainerConfigError`: a template `secretKeyRef` resolves in + the **target** namespace, never the platform one — and with the + per-user default that namespace is not known in advance. Provision the + Secret there (External Secrets/Vault), or pin the template to a shared + namespace where it is pre-provisioned. See + [Placement](concepts/placement#one-pitfall-to-know-about). - `PullSecretMissing` condition: the `WorkspaceImage`'s `imagePullSecretRef` points at a missing Secret — fail-closed, retried automatically once fixed. From 80097f79f18ddb0ef7ecef030299553b30ac838b Mon Sep 17 00:00:00 2001 From: DrummyFloyd Date: Sat, 25 Jul 2026 10:31:46 +0200 Subject: [PATCH 3/7] docs: describe the default-deny desktop egress policy Placed namespaces now block egress by default (IMDS, RFC1918, hence the kube-apiserver and the platform namespace). Covers the GKE Service-CIDR gap, the extraAllowedCIDRs way out, and the CNI escape hatch. --- docs/concepts/placement.md | 45 +++++++++++++++++++++++++++--- docs/installation/configuration.md | 25 +++++++++++++++++ docs/troubleshooting.md | 16 +++++++++++ 3 files changed, 82 insertions(+), 4 deletions(-) diff --git a/docs/concepts/placement.md b/docs/concepts/placement.md index 40f2406..2dbe2f0 100644 --- a/docs/concepts/placement.md +++ b/docs/concepts/placement.md @@ -91,10 +91,12 @@ admin edits are not overwritten): - A **`waas-quota` ResourceQuota** derived from the owner's policy aggregate caps, personal namespaces only (defense in depth — the webhook remains the primary enforcement). -- A **default-deny ingress NetworkPolicy**: only the platform namespace - (where guacd/wwt run) can reach the desktops. Egress stays open. -- **No user RBAC**: users never talk to the Kubernetes API directly — - everything goes through the portal or your GitOps pipeline. +- A **`waas-default-ingress` NetworkPolicy**, ingress *and* egress + (see below). It is the one bootstrap object the operator **reconciles** + — drift on it is healed. +- **No user RBAC**, and no ServiceAccount token mounted in the desktop + pod: users never talk to the Kubernetes API directly — everything goes + through the portal or your GitOps pipeline. Namespace metadata is only one of the template's three metadata surfaces, each with its own sync model: namespace labels/annotations @@ -121,6 +123,41 @@ the webhook refuses it with `PlacementDenied`. Anti-spoofing is webhook-enforced, fail-closed. System namespaces (`kube-*`, the platform namespace) are refused for everyone. +### What desktops can reach: the `waas-default-ingress` policy + +Despite its (historical, kept to avoid orphaning policies already +stamped on existing namespaces) name, it shapes both directions: + +- **Ingress**: denied except from the CR namespace and the release + namespace — that's where guacd/wwt run, and they need to reach the + desktops. +- **Egress**: **default-deny** as well. DNS (53 UDP+TCP, any + destination) is always allowed — a desktop that cannot resolve is a + broken desktop — then the public internet minus + `operator.desktopEgress.blockedCIDRs`, plus any `extraAllowedCIDRs`. + +The blocked defaults are the cloud IMDS `/32` and the RFC1918 ranges, so +a desktop reaches neither the kube-apiserver nor the platform namespace. + +:::warning Those defaults assume RFC1918 cluster networks +On a provider that puts the Service CIDR outside RFC1918 — GKE defaults +to `34.118.224.0/20` — the kube-apiserver stays reachable from desktops +until you append your own range. Check it: + +```sh +kubectl get svc kubernetes -n default -o jsonpath='{.spec.clusterIP}' +``` + +Emptying `blockedCIDRs` means "carve out **nothing**", not "use the +defaults" — override entries, never reset the key. +::: + +Desktops needing an internal service (a package mirror, a private Git) +get it through `extraAllowedCIDRs`, which wins over the blocked ranges. +`operator.desktopEgress.enabled: false` falls back to the historical +ingress-only policy — the escape hatch for CNIs that do not enforce +egress rules. + ## Namespace cleanup `placement.cleanup` on the template, **frozen on the namespace at diff --git a/docs/installation/configuration.md b/docs/installation/configuration.md index ef4cc21..93984d9 100644 --- a/docs/installation/configuration.md +++ b/docs/installation/configuration.md @@ -95,6 +95,31 @@ The pattern accepts `{user}`, `{workspace}`, `{templateName}` and api-server **refuse to start** rather than silently fall back. Details and the precedence chain: [Placement](../concepts/placement). +## Desktop egress + +Placed namespaces get a default-deny egress policy: DNS always, then the +internet minus the blocked ranges. + +```yaml +operator: + desktopEgress: + enabled: true # false = historical ingress-only policy (CNI escape hatch) + allowInternet: true # false = DNS + extraAllowedCIDRs only + blockedCIDRs: # defaults: cloud IMDS + RFC1918 + - 169.254.169.254/32 + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + extraAllowedCIDRs: [] # wins over blockedCIDRs (your mirrors, internal Git…) +``` + +The defaults assume RFC1918 cluster networks. **On GKE** (Service CIDR +`34.118.224.0/20` by default) append your own range or the kube-apiserver +stays reachable from desktops — check with `kubectl get svc kubernetes -n +default -o jsonpath='{.spec.clusterIP}'`. Emptying the list carves out +nothing rather than restoring the defaults. Rationale and the full rule +set: [Placement](../concepts/placement#what-desktops-can-reach-the-waas-default-ingress-policy). + ## Bootstrap policies and catalogs ```yaml diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 33d6b33..fd11c64 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -55,6 +55,22 @@ bug: groups sync from the IdP at every SSO login (or via admin edit). `imagePullSecretRef` points at a missing Secret — fail-closed, retried automatically once fixed. +## The desktop can't reach something on the network + +Placed namespaces carry a **default-deny egress** policy: DNS is always +open, the public internet is allowed minus +`operator.desktopEgress.blockedCIDRs` — the cloud IMDS and, by default, +every RFC1918 range. So an internal service (package mirror, private +Git, on-prem API) is blocked until it is listed in +`operator.desktopEgress.extraAllowedCIDRs`, which wins over the blocked +ranges. Symptoms are timeouts, not errors: name resolution keeps +working. Full rule set: +[Placement](concepts/placement#what-desktops-can-reach-the-waas-default-ingress-policy). + +If your CNI does not enforce NetworkPolicy **egress**, the policy is +inert — `operator.desktopEgress.enabled: false` makes that explicit +rather than leaving a policy you believe in. + ## Video works, no sound `enable-audio` alone is not enough over VNC: the template's `vnc` From f9249ec9d5ddbf5653fd07a9574b3b7363301718 Mon Sep 17 00:00:00 2001 From: DrummyFloyd Date: Sat, 25 Jul 2026 10:32:00 +0200 Subject: [PATCH 4/7] docs: explain why placed namespaces enforce baseline Nothing in the catalog requires it: the level is the cluster admin's call, and an empty securityContext keeps their add-if-absent mutation policy in charge. Gives the procedure to raise it to restricted. --- docs/concepts/placement.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/concepts/placement.md b/docs/concepts/placement.md index 2dbe2f0..9fb1881 100644 --- a/docs/concepts/placement.md +++ b/docs/concepts/placement.md @@ -158,6 +158,37 @@ get it through `extraAllowedCIDRs`, which wins over the blocked ranges. ingress-only policy — the escape hatch for CNIs that do not enforce egress rules. +### Pod Security: why `baseline`, and how to raise it + +`enforce=baseline` is **not** a statement that desktops need baseline. +Measured against the published catalog, the images start and serve +normally under `restricted` given `allowPrivilegeEscalation: false`, +`capabilities.drop: [ALL]` and `seccompProfile: RuntimeDefault`. + +The level is `baseline` because **choosing it is the cluster +administrator's call, not the platform's** — the same reason the desktop +container ships no `securityContext` of its own. A hardened cluster +usually fills that field with a cluster-wide mutation policy (Kyverno, +`MutatingAdmissionPolicy`, Gatekeeper), and those are almost always +written *add-if-absent*: a pre-filled field would silently take desktop +pods out of your policy. `warn=restricted` is the canary — the cluster +tells you what raising `enforce` would cost, without breaking a session. + +To raise it: + +```sh +kubectl label ns \ + pod-security.kubernetes.io/enforce=restricted --overwrite +``` + +The bootstrap is create-only, so the operator never reverts your label. +Pair it with a template `workload.securityContext` (or a mutation +policy) supplying the three controls above, otherwise the namespace +refuses its own pods. The label cannot be set through +`placement.namespaceLabels` — every `*.kubernetes.io` key is denied +there, so a template author, who is not necessarily a platform admin, +can never lower a namespace to `privileged`. + ## Namespace cleanup `placement.cleanup` on the template, **frozen on the namespace at From 1b9774e9fa868bea929c23958260366b37d65779 Mon Sep 17 00:00:00 2001 From: DrummyFloyd Date: Sat, 25 Jul 2026 10:32:38 +0200 Subject: [PATCH 5/7] docs: describe the remote-workspace target restriction In-cluster targets are refused at create, update and connect. States the two deliberate exceptions (RFC1918, unresolvable names) and that guacd re-resolving at dial time keeps this a guardrail, not a boundary. --- docs/guides/remote-workspaces.md | 31 ++++++++++++++++++++++++++++++ docs/installation/configuration.md | 14 ++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/docs/guides/remote-workspaces.md b/docs/guides/remote-workspaces.md index 5b24742..fc5680f 100644 --- a/docs/guides/remote-workspaces.md +++ b/docs/guides/remote-workspaces.md @@ -26,6 +26,37 @@ managed elsewhere. - Clipboard policies apply to remote sessions exactly as to provisioned ones. +## Targets the api-server refuses + +A "remote machine" pointed at the cluster itself would turn the feature +into a pivot into your own network, so the api-server rejects: +loopback, link-local (the cloud IMDS at `169.254.169.254` included), the +kube-apiserver ClusterIP, single-label names, `*.svc` and +`*.` names, plus any CIDR listed in +`apiServer.remoteBlockedCIDRs`. Add your cluster's **pod and service +CIDRs** there — they cannot be discovered from inside a pod. The cluster +DNS domain is auto-discovered from the api-server pod's +`/etc/resolv.conf` (`cluster.local` fallback); override it with +`apiServer.clusterDomain` only if that discovery is wrong for your +cluster. + +The check runs at create, update **and** connect, so entries registered +before the guard existed are covered too. Two deliberate +non-restrictions: + +- **RFC1918 addresses stay allowed** — a legitimate remote machine + commonly sits on a private LAN reached over VPN or peering. That is + also why `remoteBlockedCIDRs` does not default to those ranges. +- **A hostname that fails to resolve stays allowed** — registering a + machine that is off, or behind DNS the api-server cannot see, must + keep working. + +:::warning Guardrail, not a security boundary +guacd re-resolves the name when it dials, so DNS rebinding bypasses the +check. The structural answer is an egress NetworkPolicy on the platform +pods (guacd/wwt), which does not exist yet. +::: + ## Wake-on-LAN A remote machine with a registered MAC address can be woken from the diff --git a/docs/installation/configuration.md b/docs/installation/configuration.md index 93984d9..8508287 100644 --- a/docs/installation/configuration.md +++ b/docs/installation/configuration.md @@ -120,6 +120,20 @@ default -o jsonpath='{.spec.clusterIP}'`. Emptying the list carves out nothing rather than restoring the defaults. Rationale and the full rule set: [Placement](../concepts/placement#what-desktops-can-reach-the-waas-default-ingress-policy). +## Remote-workspace target guard + +```yaml +apiServer: + clusterDomain: "" # empty = discovered from the pod's resolv.conf + remoteBlockedCIDRs: [] # add your cluster's pod and service CIDRs +``` + +Remote workspaces may not point back at the cluster; the built-in filter +(loopback, link-local/IMDS, kube-apiserver ClusterIP, in-cluster names) +cannot know your pod/service CIDRs, so list them here. Scope and +deliberate exceptions: [Remote +workspaces](../guides/remote-workspaces#targets-the-api-server-refuses). + ## Bootstrap policies and catalogs ```yaml From 5c17f68a0a92524fce2baeeaa0c5a7ad59fa608f Mon Sep 17 00:00:00 2001 From: DrummyFloyd Date: Sat, 25 Jul 2026 10:33:04 +0200 Subject: [PATCH 6/7] docs: state what triggers a catalog sync Creating or repointing a WorkspaceImage now syncs at once; the ticker and Sync now cover content republished under an unchanged source, and a zero interval leaves failed syncs unretried. --- docs/admin/bootstrap-governance.md | 33 ++++++++++++++++++++++++------ docs/admin/daily-operations.md | 7 +++++++ docs/images/build-your-own.md | 11 +++++++--- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/docs/admin/bootstrap-governance.md b/docs/admin/bootstrap-governance.md index 66529d7..8099855 100644 --- a/docs/admin/bootstrap-governance.md +++ b/docs/admin/bootstrap-governance.md @@ -79,12 +79,33 @@ adminPolicy: `catalogs.waasImages` (on by default) renders a **registry-wide** `WorkspaceImage`: it approves *every* image under `docker.io/xorhub` -for the protocols `vnc`/`rdp`/`ssh`, and the api-server periodically -syncs the picker metadata (names, icons, versions, recommended -sizing/securityContext) from the published -[`catalog-waas-images.yaml`](https://github.com/XoRHub/waas-images/blob/main/catalog-waas-images.yaml) -(`apiServer.catalogSyncInterval`, default 6 h — cosmetic metadata only, -the approval itself never changes without an admin). +for the protocols `vnc`/`rdp`/`ssh`, and the api-server syncs the picker +metadata (names, icons, versions, recommended sizing/securityContext) +from the published +[`catalog-waas-images.yaml`](https://github.com/XoRHub/waas-images/blob/main/catalog-waas-images.yaml). +Cosmetic metadata only — the approval itself never changes without an +admin. + +Three things trigger that sync: + +- **creation or a source change**, without waiting for a tick — on the + console's PUT (synchronously, so the saved form already shows the + entry count) and on a `kubectl apply` alike, where entries appear + within seconds. The discriminant is `spec.catalog` itself: editing + display fields never refetches, only a **moved source** does; +- the **periodic pass**, `apiServer.catalogSyncInterval` (default 6 h); +- the per-image **Sync now** button (`POST + /api/v1/admin/images/{name}/sync`), the escape hatch when the manifest + *content* changed under an unchanged source — no automatic trigger can + see that. + +:::caution Setting `catalogSyncInterval` ≤ 0 +It disables **only the ticker** — creation/source-change and manual +syncs keep working — but nothing then retries a **failed** sync either. +A transient fetch failure while applying a new image leaves it without +catalog entries until an admin clicks *Sync now* or the source itself +moves. Only reasonable where syncs are driven deliberately. +::: Implication: combined with the default policy's `images: []`, **any authenticated user can run any official XorHub desktop image** on day diff --git a/docs/admin/daily-operations.md b/docs/admin/daily-operations.md index 0510e53..8cda832 100644 --- a/docs/admin/daily-operations.md +++ b/docs/admin/daily-operations.md @@ -89,6 +89,13 @@ quota) that's fine, just mirror the change in Git. images appear in the picker on their own (catalog sync) — review the [waas-images releases](https://github.com/XoRHub/waas-images) rather than the picker. +- **A new or repointed image syncs immediately** — console save or + `kubectl apply`, both show the discovered entries within seconds, and + the catalog table surfaces `lastSyncTime`, the entry count and + `lastSyncError`. Use **Sync now** when the catalog file was + re-published under the *same* URL (nothing can observe that), or to + retry after a failed fetch. See [the sync + triggers](bootstrap-governance#the-bootstrap-catalogs--what-approved-means-here). ### Policy and quota care diff --git a/docs/images/build-your-own.md b/docs/images/build-your-own.md index 2d8cda5..7cce481 100644 --- a/docs/images/build-your-own.md +++ b/docs/images/build-your-own.md @@ -182,9 +182,14 @@ manifest `description`, truncated), `icon`, `architectures` (from `archs:`), a `profile` and a `recommended` deployment block derived from the hardening doctrine. -The WaaS api-server periodically syncs that file (see -`catalogs.waasImages` in the [chart values](../installation/configuration)), -so a merged image shows up in the workspace-creation picker on its own. +The WaaS api-server syncs that file (see `catalogs.waasImages` in the +[chart values](../installation/configuration)), so a merged image shows +up in the workspace-creation picker on its own. Note the timing: your +image lands in a catalog file whose **URL does not change**, so it +appears on the next periodic pass (6 h by default) or when an admin hits +*Sync now* — the immediate syncs only fire when a `WorkspaceImage`'s +source itself is created or moved. Details: +[Bootstrap governance](../admin/bootstrap-governance#the-bootstrap-catalogs--what-approved-means-here). Two things are still admin-side in the platform: the [`WorkspaceImage`](../reference/crds/workspaceimage) approval if you run your own curated catalog, and `allowedGroups` gating for `-dev` From 3da0d89f5271c5d88e34d8a33079f99b1a2adc53 Mon Sep 17 00:00:00 2001 From: DrummyFloyd Date: Sat, 25 Jul 2026 10:33:19 +0200 Subject: [PATCH 7/7] docs: cover postgres.sslMode and apiServer.streamTokenTTL Both are new chart values; the stream token needs the warning against lengthening it, since it travels in the URL. --- docs/installation/configuration.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/installation/configuration.md b/docs/installation/configuration.md index 8508287..7af7fa1 100644 --- a/docs/installation/configuration.md +++ b/docs/installation/configuration.md @@ -72,6 +72,12 @@ postgres: externalURLSecretRef: { name: waas-db, key: database-url } ``` +An external URL carries its own `sslmode` — securing that connection is +yours to do. For the **bundled** instance the chart builds the URL and +pins `postgres.sslMode: disable`: the bundled StatefulSet serves no TLS, +so that is a description of reality rather than a downgrade. Raise it +only after wiring certificates into that instance yourself. + ## Workspace placement Where workspace **workloads** (pods, services, home PVCs) land — the @@ -167,5 +173,12 @@ Every component exposes `replicas`, `resources`, `deploymentLabels/Annotations` and `podLabels/Annotations` (`operator.*`, `apiServer.*`, `wwt.*`, `frontend.*`, `guacd.*`, `postgres.*`). Session-related tunables live under `apiServer.*` -(`accessTokenTTL`, `connectionTokenTTL`, `eventsPollInterval`, -`catalogSyncInterval`). +(`accessTokenTTL`, `connectionTokenTTL`, `streamTokenTTL`, +`eventsPollInterval`, `catalogSyncInterval`). + +`streamTokenTTL` (2 min) is the lifetime of the token the portal mints +for the live-events stream. It rides the URL query string — `EventSource` +cannot set headers — so it lands in proxy access logs, and the short TTL +is what keeps such a leak worthless. Do not lengthen it to "avoid +reconnections": the frontend re-mints on every reconnect anyway, so a +longer TTL buys nothing and only widens the window.