#!/usr/bin/env python3 """ enable-cloudron-2fa.py -- Enable TOTP 2FA on the vp-techops Cloudron account. Flow discovered via DOM dump: 1. Login to Cloudron panel 2. Navigate to #/profile 3. Click "Setup" for 2FA enrollment 4. Click "switchToTotp" link (Cloudron defaults to Passkey) 5. Extract TOTP secret from the TOTP setup form 6. Generate TOTP code, enter it, confirm 7. Verify 2FA is enabled Usage: docker compose run --rm --entrypoint python3 provision enable-cloudron-2fa.py """ import os import re import sys import time from pathlib import Path import pyotp 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" EMAIL = "tsgstaff-coo-vptechops@turnsys.com" def dump(page, label): """Save screenshot + simplified text dump.""" ts = time.strftime("%H%M%S") try: page.screenshot(path=str(STATE_DIR / f"2fa-{label}-{ts}.png"), full_page=True) except Exception: pass text = page.evaluate("() => document.body.innerText") (STATE_DIR / f"2fa-{label}-{ts}.txt").write_text(f"URL: {page.url}\n\n{text[:3000]}") print(f" [{label}] URL: {page.url}") 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) 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 === print("=== STEP 1: Login 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) page.locator('[role="button"]:has-text("Log in")').first.click() page.wait_for_timeout(5000) print(f" Logged in: {page.url}") # === Step 2: Navigate to profile === print("=== STEP 2: Navigate to #/profile ===") page.evaluate('() => window.location.hash = "#/profile"') page.wait_for_timeout(3000) dump(page, "01-profile") # === Step 3: Find and click 2FA Setup === print("=== STEP 3: Click 2FA Setup ===") # Look for "Setup" text or enable button near TOTP setup_clicked = False for selector in [ 'text=Setup', '[role="button"]:has-text("Setup")', 'button:has-text("Setup")', 'a:has-text("Setup")', 'text=Enable', ]: loc = page.locator(selector) if loc.count() > 0 and loc.first.is_visible(): loc.first.click() page.wait_for_timeout(3000) setup_clicked = True print(f" Clicked: {selector}") break if not setup_clicked: print(" ERROR: Could not find Setup button") dump(page, "ERROR-no-setup") browser.close() return dump(page, "02-after-setup-click") # === Step 4: Switch from Passkey to TOTP === print("=== STEP 4: Switch to TOTP mode ===") page_text = page.evaluate("() => document.body.innerText") if "switchToTotp" in page_text or "TOTP" in page_text: # Click the switchToTotp link switched = False for selector in [ 'text=switchToTotp', 'text=profile.enable2FA.switchToTotp', 'a:has-text("TOTP")', '[role="button"]:has-text("TOTP")', 'text=Use TOTP', 'text=totp', ]: loc = page.locator(selector) if loc.count() > 0 and loc.first.is_visible(): loc.first.click() page.wait_for_timeout(3000) switched = True print(f" Clicked: {selector}") break if not switched: print(" WARNING: Could not find switchToTotp link, dumping page") dump(page, "ERROR-no-switch") else: print(" Already in TOTP mode (no Passkey option visible)") dump(page, "03-totp-mode") # === Step 5: Extract TOTP secret === print("=== STEP 5: Extract TOTP secret ===") page_text = page.evaluate("() => document.body.innerText") # Look for the secret key in the page text # TOTP secrets are typically base32: uppercase A-Z, 2-7, = padding secret = "" # Method 1: Look for a code/pre element with the secret code_els = page.query_selector_all("code, pre, .totp-secret, [class*='secret']") for el in code_els: text = el.text_content().strip() if text and re.match(r'^[A-Z2-7=]+$', text.replace(" ", "")): secret = text.replace(" ", "") print(f" Found secret in element: {secret[:8]}...") break # Method 2: Look in page text for base32 strings if not secret: # Cloudron typically shows the secret in groups of 4 chars matches = re.findall(r'[A-Z2-7]{16,}=?', page_text.replace(" ", "")) if matches: secret = matches[0] print(f" Found secret in text: {secret[:8]}...") # Method 3: Look for a readonly input if not secret: secret_input = page.query_selector('input[readonly], input[type="text"]') if secret_input: val = secret_input.get_attribute("value") or "" if val and re.match(r'^[A-Z2-7=]+$', val.replace(" ", "")): secret = val.replace(" ", "") print(f" Found secret in input: {secret[:8]}...") # Method 4: Try QR code if not secret: print(" No text secret found, trying QR code...") qr_img = page.query_selector('img[src*="data:image"]') if qr_img: import base64, io from PIL import Image from pyzbar.pyzbar import decode as pyzbar_decode qr_src = qr_img.get_attribute("src") header, b64data = qr_src.split(",", 1) img_bytes = base64.b64decode(b64data) img = Image.open(io.BytesIO(img_bytes)) decoded = pyzbar_decode(img) if decoded: uri = decoded[0].data.decode() if "secret=" in uri: secret = uri.split("secret=")[1].split("&")[0] print(f" Found secret in QR: {secret[:8]}...") if not secret: print(" ERROR: Could not extract TOTP secret") # Dump all inputs and their attributes inputs = page.evaluate("""() => { return Array.from(document.querySelectorAll('input, [role="textbox"]')).map(el => ({ tag: el.tagName, type: el.type, id: el.id, name: el.name, value: (el.value || '').substring(0, 40), placeholder: el.placeholder || '', readonly: el.readOnly, })); }""") print(f" All inputs: {inputs}") dump(page, "ERROR-no-secret") browser.close() return print(f" TOTP Secret: {secret}") # === Step 6: Enter TOTP confirmation code === print("=== STEP 6: Enter TOTP confirmation code ===") totp_code = pyotp.TOTP(secret).now() print(f" TOTP Code: {totp_code}") # Find the TOTP token input token_input = None for selector in [ '#totpTokenInput', 'input[name="totpToken"]', 'input[name="token"]', 'input[placeholder*="TOTP" i]', 'input[placeholder*="code" i]', 'input[placeholder*="token" i]', 'input[type="text"]:visible', 'input[type="number"]:visible', ]: loc = page.locator(selector) if loc.count() > 0 and loc.first.is_visible(): token_input = loc.first print(f" Found token input: {selector}") break if not token_input: # Fallback: scan all visible text inputs inputs = page.query_selector_all('input[type="text"], input[type="number"], input:not([type])') for inp in inputs: try: if inp.is_visible(): token_input = inp print(f" Found fallback token input") break except Exception: continue if token_input: # Use keyboard.type for Vue/Pankow compatibility token_input.click() page.keyboard.type(totp_code) page.wait_for_timeout(500) dump(page, "04-token-entered") # Click confirm button print("=== STEP 7: Confirm 2FA ===") confirmed = False for btn_text in ["Confirm", "Enable", "Verify", "OK", "Save", "Done", "Continue"]: loc = page.locator(f'[role="button"]:has-text("{btn_text}"), button:has-text("{btn_text}")') if loc.count() > 0 and loc.first.is_visible(): loc.first.click() page.wait_for_timeout(3000) confirmed = True print(f" Clicked confirm: {btn_text}") break if not confirmed: # Try form submit page.evaluate("() => { const f = document.querySelector('form'); if (f) f.requestSubmit(); }") page.wait_for_timeout(3000) print(" Submitted form directly") dump(page, "05-after-confirm") # === Step 8: Verify 2FA is enabled === print("=== STEP 8: Verify 2FA enabled ===") page_text = page.evaluate("() => document.body.innerText") if any(w in page_text.lower() for w in ["enabled", "2fa is enabled", "totp is enabled"]): print(" 2FA appears ENABLED!") else: print(f" 2FA status unclear, checking page text...") for line in page_text.split("\n"): low = line.lower().strip() if any(w in low for w in ["totp", "2fa", "enable", "disable", "verified"]): print(f" {line.strip()}") else: print(" ERROR: Could not find TOTP token input") dump(page, "ERROR-no-token-input") # Store the TOTP secret in BW print(f"\n=== STORING TOTP SECRET IN BITWARDEN ===") item = bw.get_item(BW_ITEM) if item: current_totp = item.get("login", {}).get("totp", "") if current_totp == secret: print(" TOTP secret already stored in BW") else: bw.update_item(BW_ITEM, totp_secret=secret) print(f" Updated BW item '{BW_ITEM}' with TOTP secret") else: print(f" WARNING: BW item '{BW_ITEM}' not found") browser.close() print("\n=== DONE ===") if __name__ == "__main__": main()