diff --git a/Dockerfile b/Dockerfile index 67afcd3..5e8d10a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,15 @@ RUN wget -q -O /tmp/bw.zip \ COPY requirements.txt /tmp/ RUN python3 -m pip install --no-cache-dir --break-system-packages -r /tmp/requirements.txt +# Create non-root user for Playwright +RUN groupadd -r provision && useradd -r -g provision -G audio,video -m -d /home/provision provision \ + && mkdir -p /home/provision/.config \ + && chown -R provision:provision /home/provision + WORKDIR /app COPY . . +RUN chown -R provision:provision /app + +USER provision ENTRYPOINT ["python3", "/app/provision-agent.py"] diff --git a/docker-compose.yml b/docker-compose.yml index 48a0b66..f2c9b34 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,12 @@ services: container_name: tsys-agent-provisioner env_file: - ${BW_ENV_FILE:-${HOME}/.config/bw/env} + user: "provision" volumes: - ./agents.yaml:/app/agents.yaml:ro - ./state:/app/state + - tsys-bw-cli-state:/home/provision/.config/Bitwarden CLI network_mode: host + +volumes: + tsys-bw-cli-state: diff --git a/provision-agent.py b/provision-agent.py index 3c712b6..427a8a6 100644 --- a/provision-agent.py +++ b/provision-agent.py @@ -51,6 +51,37 @@ REDMINE_URL = os.environ.get("REDMINE_URL", "https://projects.knownelement.com") STATE_DIR = Path("/app/state") + +def _debug_dump(page: Page, label: str) -> None: + """Save screenshot + DOM dump for debugging selector issues.""" + STATE_DIR.mkdir(parents=True, exist_ok=True) + ts = time.strftime("%H%M%S") + screenshot_path = STATE_DIR / f"debug-{label}-{ts}.png" + dom_path = STATE_DIR / f"debug-{label}-{ts}.txt" + try: + page.screenshot(path=str(screenshot_path), full_page=True) + except Exception: + pass + try: + elements = page.evaluate("""() => { + const results = []; + document.querySelectorAll('input, button, select, textarea, label, h2, h3, h4, h5, code, .ui.header, .ui.message, [data-tab], .tw-font-semibold').forEach(el => { + const tag = el.tagName.toLowerCase(); + const text = el.textContent.trim().substring(0, 80); + const id = el.id || ''; + const name = el.getAttribute('name') || ''; + const type = el.getAttribute('type') || ''; + const value = el.getAttribute('value') || ''; + const cls = (el.className || '').substring(0, 50); + if (text || id || name || type || value) results.push(`<${tag}> id=${id} name=${name} type=${type} value=${value} class=${cls} text="${text}"`); + }); + return results.join('\\n'); + }""") + dom_path.write_text(f"URL: {page.url}\\n\\n{elements}") + log.info(f" Debug dump saved: {screenshot_path.name}, {dom_path.name}") + except Exception as e: + log.warning(f" Debug dump failed: {e}") + # --------------------------------------------------------------------------- # Cloudron enrollment # --------------------------------------------------------------------------- @@ -214,53 +245,168 @@ def decode_qr_from_base64(data_uri: str) -> str: # --------------------------------------------------------------------------- -def sso_login(page: Page, system_url: str, agent: dict, bw: BitwardenHelper) -> bool: +def cloudron_panel_login(page: Page, agent: dict, bw: BitwardenHelper) -> bool: + """ + Login to the Cloudron panel to establish an SSO session. + + Once authenticated to the Cloudron panel, subsequent SSO flows to + Cloudron-managed apps (Gitea, Redmine, Discourse, etc.) auto-approve + without requiring a separate consent click. + + Returns True if login succeeded or session already active. + """ + name = agent["name"] + cloudron_email = agent.get("cloudron_email", f"{name}@turnsys.com") + cloudron_item = f"{name} Cloudron" + password = bw.get_item_password(cloudron_item) + + log.info(f"[{name}] Establishing Cloudron panel session") + + page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle") + page.wait_for_timeout(2000) + + # Already logged in? + if "login" not in page.url.lower(): + log.info(f"[{name}] Cloudron panel session already active") + return True + + # Fill Cloudron login form (same selectors as invite page) + page.wait_for_selector("#inputPassword", timeout=15000) + page.fill("#inputUsername", cloudron_email) + page.fill("#inputPassword", password) + + # Submit via the btn-primary button (Cloudron uses Angular) + page.evaluate('''() => { + const btn = document.querySelector("button.btn-primary"); + if (btn) btn.click(); + }''') + page.wait_for_timeout(5000) + + # Handle TOTP if prompted + totp_input = page.query_selector("#inputTotp") + if totp_input and totp_input.is_visible(): + totp_code = bw.get_totp(cloudron_item) + totp_input.fill(totp_code) + page.evaluate('''() => { + const btn = document.querySelector("button.btn-primary"); + if (btn) btn.click(); + }''') + page.wait_for_timeout(5000) + + if "login" not in page.url.lower(): + log.info(f"[{name}] Cloudron panel login successful") + return True + + log.error(f"[{name}] Cloudron panel login failed (still on login page)") + return False + + +def sso_login(page: Page, system_url: str, agent: dict, bw: BitwardenHelper, + sso_button_selector: str = "") -> bool: """ Log into a Cloudron-managed app via SSO. + Requires an active Cloudron panel session (call cloudron_panel_login first). + Clicks the SSO/OAuth button on the app's login page, which redirects + through Cloudron's OIDC provider and back to the app authenticated. + + Args: + sso_button_selector: CSS selector for the SSO button. If empty, + tries common patterns (oauth2 link, "cloudron" text, etc.). + Returns True if login succeeded. """ name = agent["name"] log.info(f"[{name}] SSO login: {system_url}") - # Navigate to the app — should redirect to Cloudron SSO page.goto(system_url, wait_until="networkidle") + page.wait_for_timeout(2000) - # If already logged in (SSO session), we're done + # Already authenticated? if not page.query_selector('input[type="password"]'): - log.info(f"[{name}] SSO session active — already logged in") + log.info(f"[{name}] Already authenticated at {system_url}") return True - # Fill Cloudron SSO login form - cloudron_item = f"{name} Cloudron" - cloudron_email = agent.get("cloudron_email", f"{name}@turnsys.com") - password = bw.get_item_password(cloudron_item) + # Click the SSO/OAuth button + sso_clicked = False - user_input = page.query_selector('input[name="username"], input[type="email"], input[name="email"]') - pass_input = page.query_selector('input[type="password"]') + if sso_button_selector: + btn = page.locator(sso_button_selector) + if btn.count() > 0: + btn.first.click() + sso_clicked = True - if user_input: - user_input.fill(cloudron_email) - if pass_input: - pass_input.fill(password) + if not sso_clicked: + for selector in [ + 'a[href*="oauth2/cloudron"]', + 'a[href*="oauth"]', + 'button[id*="login-oauth"]', + 'a:has-text("cloudron")', + 'a:has-text("Cloud")', + 'button:has-text("cloudron")', + 'button:has-text("Cloud")', + ]: + btn = page.locator(selector) + if btn.count() > 0 and btn.first.is_visible(): + btn.first.click() + sso_clicked = True + break - # Handle 2FA if prompted - submit = page.query_selector('button[type="submit"], button:has-text("Sign in"), button:has-text("Log in")') - if submit: - submit.click() - page.wait_for_load_state("networkidle") + if not sso_clicked: + log.error(f"[{name}] Could not find SSO button at {system_url}") + _debug_dump(page, f"sso-no-button-{name}") + return False - # Check for TOTP prompt - totp_input = page.query_selector('input[name="totpToken"], input[name="token"], input[placeholder*="code"], input[autocomplete*="one-time-code"]') - if totp_input: - totp_code = bw.get_totp(cloudron_item) - totp_input.fill(totp_code) - submit2 = page.query_selector('button[type="submit"]') - if submit2: - submit2.click() - page.wait_for_load_state("networkidle") + page.wait_for_timeout(5000) + log.info(f"[{name}] SSO redirect: {page.url}") - log.info(f"[{name}] SSO login complete for {system_url}") + # Handle Cloudron OIDC login page if redirected + if "openid" in page.url or "interaction" in page.url: + if not page.query_selector('input[type="password"]'): + log.info(f"[{name}] OIDC auto-consent (already authenticated)") + else: + log.info(f"[{name}] Handling Cloudron OIDC login") + cloudron_email = agent.get("cloudron_email", f"{name}@turnsys.com") + cloudron_item = f"{name} Cloudron" + password = bw.get_item_password(cloudron_item) + + page.wait_for_selector("#inputPassword", timeout=15000) + page.fill("#inputUsername", cloudron_email) + page.fill("#inputPassword", password) + + # Submit: Cloudron OIDC uses different button patterns than the panel + page.evaluate('''() => { + const selectors = [ + "button.btn-primary", + 'button[type="submit"]', + 'input[type="submit"]', + 'button:has-text("Log in")', + ]; + for (const sel of selectors) { + const el = document.querySelector(sel); + if (el) { el.click(); return; } + } + }''') + page.wait_for_timeout(5000) + + # After OIDC login, may need consent — or may redirect back to app + page.wait_for_timeout(3000) + + page.wait_for_timeout(3000) + log.info(f"[{name}] SSO result: {page.url}") + + # Verify we're back on the target app (not stuck on OIDC/login) + if "openid" in page.url or "interaction" in page.url: + log.error(f"[{name}] SSO login failed (stuck on OIDC page)") + _debug_dump(page, f"sso-stuck-{name}") + return False + + if "login" in page.url.lower(): + log.error(f"[{name}] SSO login failed (still on login page)") + _debug_dump(page, f"sso-failed-{name}") + return False + + log.info(f"[{name}] SSO login successful") return True @@ -282,10 +428,13 @@ def provision_gitea(page: Page, agent: dict, bw: BitwardenHelper) -> str: url = gitea_cfg.get("url", GITEA_URL) token_name = gitea_cfg.get("token_name", f"{name}-api") - sso_login(page, f"{url}/user/login", agent, bw) + sso_login(page, f"{url}/user/login", agent, bw, + sso_button_selector='a[href*="oauth2/cloudron"]') # Navigate to API token settings page.goto(f"{url}/user/settings/applications", wait_until="networkidle") + page.wait_for_timeout(2000) + _debug_dump(page, f"gitea-settings-{name}") # Generate new token name_input = page.query_selector('input[name="name"]') @@ -306,12 +455,12 @@ def provision_gitea(page: Page, agent: dict, bw: BitwardenHelper) -> str: # Extract the generated token token_el = page.query_selector('.ui.info.message code, .ui.message code, input[readonly]') if not token_el: - # Try the new Gitea UI token_el = page.query_selector('.token-value, .access-token') token = token_el.text_content().strip() if token_el else "" if not token: log.error(f"[{name}] Could not extract Gitea API token") + _debug_dump(page, f"gitea-no-token-{name}") return "" log.info(f"[{name}] Gitea token generated: {token[:8]}...") @@ -353,7 +502,8 @@ def provision_discourse(page: Page, agent: dict, bw: BitwardenHelper) -> str: url = discourse_cfg.get("url", DISCOURSE_URL) - sso_login(page, f"{url}/", agent, bw) + sso_login(page, f"{url}/", agent, bw, + sso_button_selector='button[id*="login-oauth"], a[href*="auth"]') # Try to generate an API key from user preferences # Note: In Discourse, only admin can create API keys via UI @@ -414,13 +564,15 @@ def provision_redmine(page: Page, agent: dict, bw: BitwardenHelper) -> str: url = redmine_cfg.get("url", REDMINE_URL) - sso_login(page, f"{url}/login", agent, bw) + sso_login(page, f"{url}/login", agent, bw, + sso_button_selector='button[id*="login-oauth"]') # Navigate to account page where API key lives page.goto(f"{url}/my/account", wait_until="networkidle") + page.wait_for_timeout(2000) + _debug_dump(page, f"redmine-account-{name}") # The API key is in the right sidebar under "API access key" - # Click "Show" to reveal it show_link = page.query_selector('a:has-text("Show"), #api_access_key + a, a[href*="access_key"]') if show_link: show_link.click() @@ -430,7 +582,6 @@ def provision_redmine(page: Page, agent: dict, bw: BitwardenHelper) -> str: api_key = key_el.text_content().strip() if key_el else "" if not api_key: - # If there's no existing key, try to reset/generate reset_link = page.query_selector('a:has-text("Reset"), a:has-text("Generate")') if reset_link: reset_link.click() @@ -442,6 +593,7 @@ def provision_redmine(page: Page, agent: dict, bw: BitwardenHelper) -> str: if not api_key: log.error(f"[{name}] Could not get Redmine API key") + _debug_dump(page, f"redmine-no-key-{name}") return "" log.info(f"[{name}] Redmine API key obtained: {api_key[:8]}...") @@ -545,7 +697,12 @@ def provision_agent( log.info(f"[{name}] No systems configured — Phase 1 only") return results - # Phase 2: System access (fresh page for each system to avoid SSO conflicts) + # Establish Cloudron panel session for SSO (shared across all apps) + sso_page = context.new_page() + cloudron_panel_login(sso_page, agent, bw) + sso_page.close() + + # Phase 2: System access (fresh page for each system) for system_name in ["gitea", "discourse", "redmine"]: system_page = context.new_page() try: