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.
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user