diff --git a/docker-compose.yml b/docker-compose.yml index a738038..07b1725 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,3 +22,4 @@ services: - ./provision-discourse-apikey.py:/app/provision-discourse-apikey.py:ro - ./provision-redmine.py:/app/provision-redmine.py:ro - ./merge-invites.py:/app/merge-invites.py:ro + - ./dump-invite-page.py:/app/dump-invite-page.py:ro diff --git a/dump-invite-page.py b/dump-invite-page.py new file mode 100644 index 0000000..aa7c16c --- /dev/null +++ b/dump-invite-page.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +""" +dump-invite-page.py -- DOM dump of a Cloudron invite/setup page (pre-acceptance). + +Usage: + docker compose run --rm --entrypoint python3 \ + -e AGENT=vp-secops provision dump-invite-page.py +""" + +import os, sys, time +from pathlib import Path +from playwright.sync_api import sync_playwright + +STATE_DIR = Path("/app/state") + +AGENT = os.environ.get("AGENT", "vp-secops") + +import yaml +with open("/app/agents.yaml") as f: + manifest = yaml.safe_load(f) +agent = next(a for a in manifest["agents"] if a["name"] == AGENT) +invite_url = agent["cloudron_invite"] + +with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_context(viewport={"width": 1280, "height": 1024}).new_page() + page.goto(invite_url, wait_until="networkidle", timeout=20000) + page.wait_for_timeout(3000) + + ts = time.strftime("%H%M%S") + page.screenshot(path=str(STATE_DIR / f"invite-{AGENT}-{ts}.png"), full_page=True) + elements = page.evaluate("""() => { + const out = []; + document.querySelectorAll('input, button, [role="button"], label, a, h1, h2, h3, form').forEach(el => { + const tag = el.tagName.toLowerCase(); + const text = (el.textContent || '').trim().substring(0, 60); + const id = el.id || ''; + const type = el.getAttribute('type') || ''; + const cls = (el.getAttribute('class') || '').substring(0, 50); + const role = el.getAttribute('role') || ''; + const vis = el.offsetParent !== null; + out.push(`<${tag}> id=${id} type=${type} role=${role} class=${cls} vis=${vis} text="${text}"`); + }); + return out; + }""") + body = page.evaluate("() => document.body.innerText.substring(0, 400)") + out = f"URL: {page.url}\n\nBODY:\n{body}\n\nELEMENTS:\n" + "\n".join(elements) + (STATE_DIR / f"invite-{AGENT}-{ts}.txt").write_text(out) + print(out) + browser.close() diff --git a/provision-agent.py b/provision-agent.py index 2b14997..1da97e3 100644 --- a/provision-agent.py +++ b/provision-agent.py @@ -119,28 +119,33 @@ def enroll_cloudron( log.info(f"[{name}] Generated password ({len(password)} chars)") # Navigate to invite link + # Proven selectors (session 3 DOM dump of setupaccount.html): + # #inputUsername (prefilled from invite), #inputDisplayName, + # #inputPassword, #inputPasswordRepeat, + # submit =
"Set up" (disabled until form valid) page.goto(invite_url, wait_until="networkidle") page.wait_for_timeout(2000) - # Fill in the invite acceptance form using real Cloudron selectors - page.wait_for_selector('#inputPassword', timeout=15000) + page.wait_for_selector("#inputPassword", timeout=15000) - page.fill('#inputPassword', password) - page.fill('#inputPasswordRepeat', password) + # Pankow/Vue forms need click + keyboard.type, never fill() + page.click("#inputDisplayName") + page.keyboard.type(display_name) + page.click("#inputPassword") + page.keyboard.type(password) + page.click("#inputPasswordRepeat") + page.keyboard.type(password) - # Set display name if field exists - display_field = page.query_selector('#inputDisplayName') - if display_field: - display_field.fill(display_name) - - # Submit — Cloudron uses a button with class btn-primary or type submit - submit = page.query_selector('button[type="submit"], button.btn-primary, button:has-text("Setup"), button:has-text("Create"), button:has-text("Accept")') - if submit: - submit.click() + # Submit button is a div[role=button]; starts disabled, enables on valid input + setup_btn = page.locator('[role="button"]:has-text("Set up")') + page.wait_for_timeout(1000) + if setup_btn.count() > 0 and setup_btn.first.is_visible(): + setup_btn.first.click() else: - page.keyboard.press('Enter') + log.warning(f"[{name}] Set up button not found/clickable, pressing Enter") + page.keyboard.press("Enter") - page.wait_for_load_state("networkidle") + page.wait_for_timeout(5000) log.info(f"[{name}] Invite accepted") # Enable 2FA @@ -177,6 +182,12 @@ def enable_cloudron_2fa(page: Page, agent: dict, bw: BitwardenHelper) -> str: name = agent["name"] log.info(f"[{name}] Enabling 2FA on Cloudron account") + # Fresh accounts sit on setupaccount.html ("Your account is ready") -- + # hash navigation cannot leave that page. Load the panel root first. + if "setupaccount" in page.url: + page.goto(f"{CLOUDRON_BASE}/", wait_until="networkidle", timeout=20000) + page.wait_for_timeout(3000) + page.evaluate('() => window.location.hash = "#/profile"') page.wait_for_timeout(3000) @@ -198,11 +209,15 @@ def enable_cloudron_2fa(page: Page, agent: dict, bw: BitwardenHelper) -> str: return "" # Step 1: Click "Setup" to start 2FA enrollment + # (forced-enrollment screen says "Set up passkey"; profile says "Setup") setup_clicked = False for selector in [ 'text=Setup', + 'text=Set up', '[role="button"]:has-text("Setup")', + '[role="button"]:has-text("Set up")', 'button:has-text("Setup")', + 'button:has-text("Set up")', 'a:has-text("Setup")', ]: loc = page.locator(selector)