Files
agent-identity-provisioning/dump-cloudron-dom.py
T
TSYS Group COO be2f607839 feat: enable Cloudron TOTP 2FA for vp-techops (CMMC compliance)
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.
2026-08-13 21:01:22 -05:00

223 lines
8.8 KiB
Python

#!/usr/bin/env python3
"""
dump-cloudron-dom.py -- Comprehensive DOM dump of Cloudron profile page.
Captures ALL interactive elements (not just standard form inputs) to find
the TOTP enable button that previous dumps missed. Cloudron uses Pankow/Vue
components where buttons are often <div role="button"> not <button>.
Usage:
docker compose run --rm --entrypoint python3 provision dump-cloudron-dom.py
"""
import os
import sys
import time
from pathlib import Path
from playwright.sync_api import sync_playwright
sys.path.insert(0, os.path.dirname(__file__))
from bw_helper import BitwardenHelper
CLOUDRON_BASE = os.environ.get("CLOUDRON_BASE", "https://my.knownelement.com")
STATE_DIR = Path("/app/state")
STATE_DIR.mkdir(parents=True, exist_ok=True)
BW_ITEM = "vp-techops Cloudron"
def full_dom_dump(page, label):
"""Dump every visible element with role, text, classes, and attributes."""
ts = time.strftime("%H%M%S")
screenshot_path = STATE_DIR / f"domdump-{label}-{ts}.png"
text_path = STATE_DIR / f"domdump-{label}-{ts}.txt"
try:
page.screenshot(path=str(screenshot_path), full_page=True)
except Exception:
pass
try:
elements = page.evaluate("""() => {
const results = [];
// Capture ALL potentially interactive elements
const selector = [
'input', 'button', 'select', 'textarea', 'label',
'a', 'span', 'div', 'code', 'pre',
'[role]', '[onclick]', '[tabindex]',
'[class*="btn"]', '[class*="button"]', '[class*="totp"]',
'[class*="modal"]', '[class*="dialog"]', '[class*="card"]',
'.pankow-button', '.pankow-card',
].join(', ');
document.querySelectorAll(selector).forEach(el => {
const tag = el.tagName.toLowerCase();
const text = (el.textContent || '').trim().substring(0, 120);
const role = el.getAttribute('role') || '';
const id = el.id || '';
const name = el.getAttribute('name') || '';
const type = el.getAttribute('type') || '';
const value = el.getAttribute('value') || '';
const href = el.getAttribute('href') || '';
const placeholder = el.getAttribute('placeholder') || '';
const cls = (el.className || '').substring(0, 80);
const tabindex = el.getAttribute('tabindex') || '';
const visible = el.offsetParent !== null;
// Only log elements with useful info, skip pure containers
if (text || id || name || type || value || role || href || placeholder ||
cls.includes('btn') || cls.includes('button') || cls.includes('totp') ||
cls.includes('modal') || cls.includes('dialog')) {
results.push({
tag, text: text.substring(0, 80), role, id, name, type,
value, href, placeholder, cls: cls.substring(0, 60),
tabindex, visible
});
}
});
return results;
}""")
lines = [f"URL: {page.url}", f"Elements: {len(elements)}", ""]
for el in elements:
vis = "V" if el["visible"] else "H"
parts = [f"[{vis}] <{el['tag']}>"]
if el["role"]:
parts.append(f"role={el['role']}")
if el["id"]:
parts.append(f"id={el['id']}")
if el["name"]:
parts.append(f"name={el['name']}")
if el["type"]:
parts.append(f"type={el['type']}")
if el["value"]:
parts.append(f"value={el['value'][:40]}")
if el["href"]:
parts.append(f"href={el['href'][:60]}")
if el["placeholder"]:
parts.append(f"placeholder={el['placeholder']}")
if el["cls"]:
parts.append(f"class={el['cls']}")
if el["tabindex"]:
parts.append(f"tabindex={el['tabindex']}")
if el["text"]:
parts.append(f'text="{el["text"]}"')
lines.append(" ".join(parts))
text_path.write_text("\n".join(lines))
print(f"Dump saved: {screenshot_path.name}, {text_path.name} ({len(elements)} elements)")
# Print TOTP-related elements to stdout for immediate visibility
print("\n=== TOTP-RELATED ELEMENTS ===")
for el in elements:
combined = f"{el['tag']} {el['text']} {el['cls']} {el['id']} {el['role']}".lower()
if "totp" in combined or "2fa" in combined or "authenticator" in combined:
print(f" <{el['tag']}> role={el['role']} id={el['id']} "
f"class={el['cls']} text=\"{el['text']}\" visible={el['visible']}")
print("\n=== ALL role=button ELEMENTS ===")
for el in elements:
if el["role"] == "button":
print(f" <{el['tag']}> id={el['id']} class={el['cls']} "
f'text="{el["text"]}" visible={el["visible"]}')
except Exception as e:
print(f"Dump failed: {e}")
def main():
bw = BitwardenHelper(
client_id=os.environ["BW_CLIENTID"],
client_secret=os.environ["BW_CLIENTSECRET"],
password=os.environ["BW_PASSWORD"],
server_url=os.environ.get("BW_SERVER", ""),
)
bw.login()
password = bw.get_item_password(BW_ITEM)
email = "tsgstaff-coo-vptechops@turnsys.com"
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1280, "height": 1024})
page = context.new_page()
# Step 1: Login to Cloudron panel
print("=== LOGGING IN TO CLOUDRON ===")
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=15000)
page.wait_for_timeout(2000)
page.wait_for_selector("#inputPassword", timeout=15000)
page.click("#inputUsername")
page.keyboard.type(email)
page.click("#inputPassword")
page.keyboard.type(password)
btn = page.locator('[role="button"]:has-text("Log in")')
if btn.count() > 0:
btn.first.click()
page.wait_for_timeout(5000)
print(f"Post-login URL: {page.url}")
print(f"Logged in: {'login' not in page.url.lower()}")
# Step 2: Navigate to profile page
print("\n=== NAVIGATING TO #/profile ===")
page.evaluate('() => window.location.hash = "#/profile"')
page.wait_for_timeout(3000)
print(f"Profile URL: {page.url}")
# Step 3: Comprehensive DOM dump
print("\n=== DUMPING PROFILE PAGE DOM ===")
full_dom_dump(page, "profile-initial")
# Step 4: Try to find and click TOTP setup
print("\n=== LOOKING FOR TOTP SETUP BUTTON ===")
# Try various approaches to find the TOTP enable button
clicked = False
for desc, selector in [
("role=button near TOTP", '[role="button"]:near(:text("TOTP"))'),
("button text Enable TOTP", 'button:has-text("Enable")'),
("role=button text Enable", '[role="button"]:has-text("Enable")'),
("text=Setup TOTP", 'text=Setup'),
("role=button text Setup", '[role="button"]:has-text("Setup")'),
("role=button text TOTP", '[role="button"]:has-text("TOTP")'),
("class totp-button", '[class*="totp"]'),
("a text Enable", 'a:has-text("Enable")'),
]:
try:
loc = page.locator(selector)
if loc.count() > 0:
print(f" Found: {desc} ({loc.count()} matches)")
# Dump before clicking
full_dom_dump(page, f"pre-click-{desc.replace(' ', '-')}")
loc.first.click()
page.wait_for_timeout(3000)
clicked = True
print(f" Clicked: {desc}")
break
except Exception as e:
print(f" {desc}: {e}")
if clicked:
print("\n=== DUMPING POST-CLICK STATE (modal/dialog?) ===")
full_dom_dump(page, "post-totp-click")
else:
print("\n!!! Could not find any TOTP button to click")
# Step 5: Dump the full page text for context
print("\n=== PAGE TEXT (searchable) ===")
body_text = page.evaluate("() => document.body.innerText")
# Print only lines mentioning totp, 2fa, enable, setup, authenticator
for line in body_text.split("\n"):
low = line.lower().strip()
if any(w in low for w in ["totp", "2fa", "enable", "setup", "authenticat", "factor", "passkey", "security key"]):
print(f" {line.strip()}")
browser.close()
if __name__ == "__main__":
main()