Cloudron's 2FA enrollment flow discovered via comprehensive DOM dump:
1. Profile page -> click "Setup" to start 2FA
2. Cloudron defaults to Passkey -> click "switchToTotp"
3. TOTP secret appears as base32 text -> extract via regex
4. Enter code in #totpTokenInput -> click Enable
Fixed wrong selectors in cloudron_panel_login: the OIDC TOTP field is
#inputTotpToken (not #inputTotp as previously assumed). Verified full
2FA round-trip: password login -> TOTP prompt -> code entry -> #/apps.
2FA is now enabled on the vp-techops Cloudron account with TOTP secret
stored in Bitwarden. This removes a hard blocker for CMMC L3 compliance.
Working flows:
- Cloudron panel login (Pankow Vue UI: keyboard.type + role=button)
- Gitea SSO via Cloudron OIDC (redirects, auto-consent, authenticated)
- Gitea API token generation (JS-based form fill for hidden elements)
- Token extraction from flash-info message (regex for 40-char hex)
- Token verified via Gitea API (user=vptechops)
- Token stored in Bitwarden as "vp-techops Gitea"
Issues remaining:
- Redmine SSO: OIDC consent completes but redirects back to login page
(likely Redmine OAuth config or user sync issue)
- Discourse: SSO button not found (needs different selector)
- 2FA: enable button not found on Cloudron profile page
(TOTP section exists but button selector needs investigation)
- Gitea: stale token cleanup needed (old duplicate from failed runs)
Key pattern established for Cloudron SSO across all apps:
1. cloudron_panel_login() to establish session
2. sso_login() clicks app-specific SSO button
3. OIDC handles auth automatically (session already active)
4. Redirect back to app authenticated
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
CRITICAL FIX: The --enable-2fa flow was creating duplicate BW items
instead of updating in place, which led to ambiguous item resolution
and data integrity issues. This was a severe failure in core
credential lifecycle operations.
Changes:
- bw_helper.py: Complete rewrite with safety guarantees
- update_item(): modifies existing item in place by ID, preserves
all fields not being updated
- create_item(): refuses to create duplicates (raises if item exists)
- get_item_id(): resolves name to ID, raises on ambiguous matches
- get_item(): returns full item JSON
- NO delete_item method exists by design -- credential deletion
is a manual operation only
- provision-agent.py: --enable-2fa now uses update_item() instead
of create_item() to add TOTP to existing credentials
- Dockerfile: non-root user with correct BW state directory ownership
- docker-compose.yml: bind mount for BW state (proper permissions)
- test_bw_helper.py: 10 tests covering full lifecycle
(create, read, duplicate rejection, update password, update TOTP,
field preservation, no-delete verification)
Tests 1-4 verified passing against live Vaultwarden instance.
- requirements.txt: added pytest
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
Security:
- Container now runs as non-root user 'provision' (CMMC/STIG audit
requirement). Root execution would fail security audits.
- BW state persisted in named volume at /home/provision/.config/
Bitwarden CLI to avoid slow re-auth on every run.
SSO refactoring:
- cloudron_panel_login(): establishes Cloudron panel session once,
shared across all subsequent app SSO flows
- sso_login(): clicks app-specific SSO/OAuth button, handles Cloudron
OIDC login + consent redirect, properly detects failures
- Detects "You do not have access" OIDC rejections (access control
issue, not a selector bug)
- Per-app SSO button selectors passed as parameters for extensibility
to future Cloudron apps (Dolibarr, Paperless, Firefly, etc.)
Debug:
- _debug_dump() captures screenshot + DOM at failure points
- Added dumps at Gitea token, Redmine key, and SSO failure locations
Current blocker: vp-techops Cloudron user not yet granted access to
Gitea/Redmine/Discourse apps. Cloudron 2FA confirmation pending.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
- Fix Cloudron invite form selectors to match real page IDs
(#inputUsername, #inputDisplayName, #inputPassword, #inputPasswordRepeat)
- Fix docker-compose.yml: use env_file instead of ${VAR} interpolation
(password contains $ chars that docker-compose corrupts)
- Update CLOUDRON_BASE from tsys-cloudron.knel.net to my.knownelement.com
Phase 1 Cloudron enrollment now works end-to-end: invite accepted,
password set, TOTP extracted, credential stored in Bitwarden.
Phase 2 (Gitea/Redmine/Discourse) needs selector updates for
the current UI versions of each system.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
Replace npm-based @bitwarden/cli with the pre-compiled native Rust bw
binary (v2026.7.0) to eliminate Node.js from the credential management
layer for CMMC/ITAR/STIG audit readiness.
Changes:
- Dockerfile: download native bw binary instead of npm install; add
python3-pip for Playwright dependencies
- bw_helper.py: renamed from bw-helper.py (Python can't import hyphens);
added BW_SERVER config for self-hosted instance; use --passwordfile
for unlock (more reliable with native binary); removed TOTP from
login flow (API key auth does not require it)
- provision-agent.py: pass BW_SERVER env var to BitwardenHelper
- docker-compose.yml: add BW_SERVER env var
- .env.example: add BW_SERVER, document TOTP as optional
Verified: dry-run passes, bw status/auth/generate all work inside
the provisioner container against pwvault.turnsys.com.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
bw login --apikey prompts for a TOTP code when 2FA is enabled on the
BW account. The previous code didn't pass one, so it would hang or
fail. Now generates a TOTP from BW_TOTP_SECRET and passes via --code.
Changes:
- BitwardenHelper.__init__ accepts totp_secret param
- login() generates a pyotp code and passes --code when secret is set
- provision-agent.py passes BW_TOTP_SECRET from environment
- docker-compose.yml and .env.example updated for the new var
- BW_PASSWORD removed from the login env (only needed for unlock via stdin)
The BW account's own TOTP secret lives in ~/.config/bw/env alongside
the other BW access info — the one exception (can't store BW's 2FA in
BW itself).
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
Email domain bug (would have caused all provisioning to fail):
- Cloudron email default was tsys-cloudron.knel.net (the dashboard host)
instead of turnsys.com (the actual identity domain). Fixed in 3 places.
- Added explicit cloudron_email field to all agents in agents.yaml.example.
Other fixes:
- STATE_DIR.mkdir() moved from module level to main() so --dry-run and
--help work outside the container.
- IndexError guard: password_inputs[0] crashes if zero fields found.
- State file save moved to finally block so partial results survive
provisioning failures.
- Exception in provision_agent no longer re-raised (was preventing state
file write and summary reporting).
- BW item_exists no longer swallows network/session errors as 'not found'
(was causing duplicate credential creation).
- Redundant -u flag in bw generate (-uluns → -ulns).
- Dockerfile: npx install with || true → npm install -g (silent failure
would cause runtime 'bw: command not found').
- Added .dockerignore to prevent .env/agents.yaml/state from entering image.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2