OpenCloud¶
OpenCloud is the household's file sync and share:
one Go binary, no database, with desktop, iOS and Android clients. Upstream
publishes no open Helm chart, so opencloud/ is plain manifests around the
official image: deployment.yaml, service.yaml, pvc.yaml,
httproute.yaml, servicemonitor.yaml, secrets.yaml, networkpolicy.yaml,
namespace.yaml and two ConfigMaps, csp.yaml and
authentik-blueprint.yaml, per
Conventions → Official upstream sources only.
At a glance¶
| URL | cloud.infra.k8s.wlkr.ch, LAN and VPN only |
| Authentication | Authentik OIDC for every client; the built-in identity provider does not run |
| Storage | rook-ceph-block PVC for /var/lib/opencloud, the PosixFS driver |
| Database | None; metadata lives in extended attributes and the embedded NATS store |
| Secrets | kv/opencloud/config in OpenBao, generated once |
| Files | opencloud/ |
Storage¶
PosixFS keeps the files on the volume in the tree the user sees, with the
metadata in xattrs, which ext4 provides, and versions, trash and the search
index beside them. One volume holds the whole instance, so the nightly
Velero snapshot is a complete
copy, and a restore is readable with ls. The driver's non-collaborative
mode requires that nothing else writes to the tree, which a single
Deployment on a ReadWriteOnce volume guarantees.
The alternative, blobs in an S3 bucket with metadata on a volume, splits one instance across two backup mechanisms, one of which does not exist for buckets here, and upstream documents no migration into or out of it.
Upstream's backup guidance is to stop the instance first. The RBD snapshot is crash-consistent instead: metadata is written per operation on the same filesystem, and the nightly Velero run sees no sync traffic. The search index is rebuilt if it disagrees after a restore; nothing is lost.
Configuration¶
The values in opencloud/deployment.yaml that are not self-explanatory:
| Setting | Why |
|---|---|
opencloudeu/opencloud-rolling, not the production opencloudeu/opencloud |
Rolling ships every three weeks on a current Alpine; the production line gets a release about every six months, and its image carried a fixable critical in an Alpine package that the image-scan gate refuses. Move to the production line at a release that passes the gate. Either way a major bump waits for a human |
args: ["server"], no init |
opencloud init only writes random secrets and IDs into opencloud.yaml. Every one of them arrives as an environment variable from opencloud-env instead, so /etc/opencloud is an emptyDir and the pod carries nothing a backup would have to protect |
strategy: Recreate, one replica |
The volume is ReadWriteOnce, and PosixFS in non-collaborative mode tolerates one writer |
runAsUser: 1000, fsGroup: 1000, read-only root, the namespace enforces restricted |
The image runs as that user and writes only under /var/lib/opencloud, /etc/opencloud and /tmp, the last two emptyDirs |
OC_EXCLUDE_RUN_SERVICES=idp |
Authentik is the identity provider; the built-in one would be a second login |
OC_OIDC_ISSUER=https://auth.k8s.wlkr.ch/ |
The four Authentik providers run in global issuer mode, whose issuer is the bare hostname; see OIDC |
PROXY_CSP_CONFIG_FILE_LOCATION, the policy in opencloud/csp.yaml |
The default Content-Security-Policy allows connections and frames to the server itself only, so the browser refuses the discovery, token and silent-renew requests to an external IdP and the page spins forever. The file adds auth.k8s.wlkr.ch to connect-src and frame-src; OpenCloud merges it into the default rather than replacing it, so the default's update.opencloud.eu and GitHub app-store hosts stay in the browser policy. They matter only to a browser, the update check is off, and the server itself reaches neither |
PROXY_OIDC_REWRITE_WELLKNOWN |
The desktop and mobile clients discover the identity provider through the server's own /.well-known/openid-configuration |
PROXY_OIDC_ACCESS_TOKEN_VERIFY_METHOD=jwt |
Authentik's access tokens are JWTs, so the server checks signature and issuer itself instead of calling userinfo on every request |
PROXY_OIDC_AUDIENCES, the four client IDs |
Authentik writes the client ID into aud, so the server accepts only tokens minted for one of its own clients, not any token from the same issuer. Without the list the proxy logs at every start that audience validation is off |
PROXY_AUTOPROVISION_ACCOUNTS, with sub as the username |
Accounts are created in the built-in IDM on first login and keyed on the sub claim, which never changes, per Conventions → Authentication. Upstream's own autoprovisioning example does the same. Users see display names, never this value |
GRAPH_USERNAME_MATCH=none |
Authentik's sub is a SHA-256 hex string, and the default rule refuses a username that starts with a digit: Invalid username, and the first login ends in a 400 |
FRONTEND_READONLY_USER_ATTRIBUTES |
Name and mail come from Authentik on every login, so the profile page must not offer to edit them |
PROXY_ROLE_ASSIGNMENT_DRIVER=oidc on the groups claim, GRAPH_ASSIGN_DEFAULT_USER_ROLE=false |
The role comes from Authentik group membership, and a user in neither group gets no role and no access; see Usage |
STORAGE_USERS_ID_CACHE_STORE=nats-js-kv |
PosixFS requires the NATS key-value store for its id cache; the embedded NATS provides it |
FRONTEND_CHECK_FOR_UPDATES=false |
The web frontend's version check is the one outbound call the server makes on its own. Nothing phones home, the same rule as every platform component |
PROXY_DEBUG_ADDR=0.0.0.0:9205 |
Every service in the binary has a debug port; the proxy's carries the request metrics, the health endpoints and the one ServiceMonitor |
HTTPRoute on infra-gateway, not apps-gateway |
Only apps-gateway gets a public DNS record. Phones sync on the LAN and the VPN; moving to the public gateway is one route change |
OIDC¶
OpenCloud's web, desktop, iOS and Android clients are four OIDC clients with
IDs fixed by the clients (web, OpenCloudDesktop, OpenCloudIOS,
OpenCloudAndroid), all public, authorization code with PKCE, so
opencloud/authentik-blueprint.yaml holds four providers and four
applications. Only the web one is visible in the Authentik library; the
native ones carry blank://blank as their launch URL.
The server and the native clients verify every token against the one
issuer the server advertises, and Authentik derives a provider's issuer from
its application slug. All four providers therefore use issuer_mode:
global, and the platform rewrites /.well-known/openid-configuration on
auth.k8s.wlkr.ch to the opencloud application's document; see
One issuer for OpenCloud.
The desktop client redirects to a loopback address with a random port, so its redirect URI is a regular expression; the mobile ones are custom-scheme URLs the apps register. There are no client secrets, so nothing lands in OpenBao for Authentik's side, and the platform repository only mounts the blueprint.
Secrets¶
secrets.yaml generates what opencloud init would: a Password
generator for the JWT, transfer, URL-signing and service-account secrets,
the machine-auth and system-user API keys and the IDM service passwords,
which the users, groups, auth-basic and graph services also receive
as their LDAP bind passwords, and one UUID generator per ID, because a UUID generator yields one
value and a PushSecret reads one generator. Each PushSecret writes its
keys into kv/opencloud/config once; the ExternalSecret templates them
into opencloud-env under the environment variable names. Losing the
OpenBao path and regenerating is a new instance: the JWT secret signs the
internal tokens and the IDs name the storage mount and the system user, so
the data on the volume would no longer match.
idm-admin-password is the built-in IDM's admin account, which the first
start creates. With the IdP excluded nothing presents a login form for it, so
there is no local break-glass account here, not even the one
rule 6
allows: Authentik is the only way in.
Network policy¶
The rules in opencloud/networkpolicy.yaml, and why each is there. The
shape is Conventions → Ship a CiliumNetworkPolicy.
| Rule | Why |
|---|---|
Ingress from the namespace, from host and remote-node; egress to the namespace |
The rules every policy carries: What every policy contains |
Ingress from ingress on 9200 |
The Gateway reaches the server directly; OpenCloud handles its own login, and the desktop and mobile clients arrive the same way |
Ingress from Prometheus on 9205, GET /metrics only |
The proxy's metrics; the health endpoints on the same port are the kubelet's, covered by host and remote-node |
| Egress from the server to the Authentik server pods on 9000 | Discovery, the signing keys and the token endpoint go to auth.k8s.wlkr.ch, which resolves to the Gateway's own address, private but world to Cilium. The Gateway's Envoy then checks this policy against the Authentik pod it picks and answers 403 itself when that pod is not listed, so both the name and the pod are allowed |
Egress from the server to auth.k8s.wlkr.ch on 443 |
The other half of the same path. Hubble records one dropped SYN to this name at each pod start: the first packet after a fresh DNS answer races the FQDN policy update and the retransmit passes, the known artefact, not a missing rule |
No app store, no update check, no mail, no object store: the search index and NATS run inside the process.
Usage¶
Membership in one of two Authentik groups is an account:
| Group | OpenCloud role |
|---|---|
opencloudAdmin |
Administrator |
opencloudUser |
User |
A user in neither group can log in to Authentik but is refused by OpenCloud
with no role assigned. The groups also appear as groups inside OpenCloud,
because autoprovisioning mirrors the groups claim.
Clients: the desktop client for macOS 13 and later, Windows and Linux, the iOS app from the App Store and the Android app from Google Play or F-Droid. Each takes the server URL and opens Authentik in the browser.
Health check¶
kubectl -n opencloud get pods,pvc,externalsecret,pushsecret
curl -s https://cloud.infra.k8s.wlkr.ch/.well-known/openid-configuration | jq -r .issuer
The pod is Running, every PushSecret and the ExternalSecret are
Ready, and the issuer printed is https://auth.k8s.wlkr.ch/. After a
first start, the idm.boltdb on the volume is larger than the 32 KiB of an
empty Bolt file; see Pitfalls.
Pitfalls¶
| Symptom | Cause |
|---|---|
ExternalSecret stays SecretSyncedError on the first sync |
The PushSecrets and the ExternalSecret are in different waves but OpenBao is written asynchronously; the retry covers it within minutes |
The web page shows the loading animation forever, the console reports a Content-Security-Policy violation for auth.k8s.wlkr.ch |
The served policy is the default, not csp.yaml: the ConfigMap or PROXY_CSP_CONFIG_FILE_LOCATION is missing, or the mount path differs from the variable |
| Login succeeds in Authentik, OpenCloud answers 403 | The user is in neither group, or the issuer in the token differs from OC_OIDC_ISSUER: a provider without issuer_mode: global |
| The desktop client's login tab never returns | The loopback redirect did not match the regular expression; the port is random and the path may be empty |
| Prometheus shows the target down | The policy allows GET /metrics only; any other path on 9205 from Prometheus is refused |
Login ends on "Not logged in"; the log shows failed to add user with LDAP Result Code 49 from graph and not found from idm |
The IDM database under idm/ on the volume is a bare Bolt file: a first pod that died during the bootstrap, as in the row below, left it created but without the service users, and every later start sees the file and skips the bootstrap. Remove the idm directory (Recovery); the next start bootstraps it. Only safe while no account exists yet, which is the case when this symptom appears |
The pod exits with error parsing mapping JSON: unexpected end of JSON input from the search service |
The Bleve index under search/ on the volume was created by a pod that died before writing the mapping, which happens when another service fails on the same first start. Remove the search directory (Recovery); the index is rebuilt from the files on the next start |
Recovery¶
Removing a directory from the volume¶
For the two first-start failures above. The volume is ReadWriteOnce and
stays attached to the node of the crashing pod, so the debug pod goes on that
node; scaling the Deployment to zero first does not hold, self-heal restores
the replica within a minute.
-
Start a debug pod beside the crashing one, on its node and from its image, with the
restrictedcontext the namespace enforces:NODE=$(kubectl -n opencloud get pod -l app.kubernetes.io/name=opencloud -o jsonpath='{.items[0].spec.nodeName}') IMAGE=$(kubectl -n opencloud get deploy opencloud -o jsonpath='{.spec.template.spec.containers[0].image}') kubectl -n opencloud apply -f - <<EOF apiVersion: v1 kind: Pod metadata: name: opencloud-debug spec: nodeName: $NODE restartPolicy: Never automountServiceAccountToken: false securityContext: runAsNonRoot: true runAsUser: 1000 runAsGroup: 1000 fsGroup: 1000 seccompProfile: type: RuntimeDefault containers: - name: shell image: $IMAGE command: ["sleep", "3600"] securityContext: allowPrivilegeEscalation: false capabilities: drop: ["ALL"] volumeMounts: - name: data mountPath: /var/lib/opencloud volumes: - name: data persistentVolumeClaim: claimName: opencloud-data EOF -
Remove the directory,
idmorsearch: -
Delete the debug pod; the container's next restart recreates what was removed: