#!/usr/bin/env python3 """ provision-redmine.py -- Redmine SSO login + API key extraction. Flow: 1. Cloudron login (establish OIDC session) 2. Redmine SSO via "Continue with KNEL Cloud" button 3. Navigate to /my/account 4. Find and generate API key Usage: docker compose run --rm --entrypoint python3 provision provision-redmine.py """ import os, sys, time, re 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") REDMINE_URL = os.environ.get("REDMINE_URL", "https://projects.knownelement.com") STATE_DIR = Path("/app/state") BW_ITEM = "vp-techops Cloudron" REDMINE_BW_ITEM = "vp-techops Redmine" EMAIL = "tsgstaff-coo-vptechops@turnsys.com" USERNAME = "vptechops" def dump(page, label): ts = time.strftime("%H%M%S") try: page.screenshot(path=str(STATE_DIR / f"redmine-{label}-{ts}.png"), full_page=True) except Exception: pass elements = page.evaluate("""() => { const results = []; document.querySelectorAll('input, button, a, [role="button"], label, code, pre, .ui.message, #api-access-key, [class*="api"]').forEach(el => { const tag = el.tagName.toLowerCase(); const text = (el.textContent || '').trim().substring(0, 80); const id = el.id || ''; const type = el.getAttribute('type') || ''; const value = el.getAttribute('value') || ''; const href = (el.getAttribute('href') || '').substring(0, 50); const cls = (el.getAttribute('class') || '').substring(0, 60); const vis = el.offsetParent !== null; if (text || id || type || value || href) { results.push('<'+tag+'> id='+id+' type='+type+' value='+value.substring(0,30)+' class='+cls+' vis='+vis+' text="'+text+'"'); } }); return results; }""") body = page.evaluate("() => document.body.innerText.substring(0, 500)") (STATE_DIR / f"redmine-{label}-{ts}.txt").write_text(f"URL: {page.url}\n\nBody: {body}\n\nElements:\n" + "\n".join(elements)) print(f" [{label}] {len(elements)} elements") def cloudron_login(page, bw): password = bw.get_item_password(BW_ITEM) page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=15000) page.wait_for_timeout(2000) if "login" not in page.url.lower() and "openid" not in page.url.lower(): return 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(3000) totp = page.query_selector("#inputTotpToken") if totp and totp.is_visible(): code = bw.get_totp(BW_ITEM) totp.click() page.keyboard.type(code) page.locator("#totpTokenSubmitButton").click() page.wait_for_timeout(5000) 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() 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: Cloudron login print("=== STEP 1: Cloudron login ===") cloudron_login(page, bw) print(f" Cloudron: {page.url}") # Step 2: Redmine SSO print("=== STEP 2: Redmine SSO ===") page.goto(f"{REDMINE_URL}/login", wait_until="domcontentloaded", timeout=30000) page.wait_for_timeout(3000) dump(page, "01-login-page") # Click SSO button sso_btn = page.locator('#login-oauth-submit-1') if sso_btn.count() == 0: sso_btn = page.locator('button:has-text("KNEL"), button:has-text("Cloud"), button:has-text("Continue")') if sso_btn.count() > 0 and sso_btn.first.is_visible(): print(f" Clicking SSO button...") sso_btn.first.click() page.wait_for_timeout(5000) dump(page, "02-after-sso-click") # Handle OIDC consent if needed if "openid" in page.url.lower(): print(f" OIDC page: {page.url}") for consent in ["Continue", "Authorize", "Allow", "Accept"]: cbtn = page.locator(f'[role="button"]:has-text("{consent}"), button:has-text("{consent}")') if cbtn.count() > 0 and cbtn.first.is_visible(): cbtn.first.click() page.wait_for_timeout(5000) print(f" Clicked consent: {consent}") break dump(page, "03-after-consent") else: print(" SSO button not found!") current_url = page.url body = page.evaluate("() => document.body.innerText.substring(0, 300)") print(f" Current URL: {current_url}") print(f" Body: {body[:200]}") if "/login" in current_url: print(" STILL ON LOGIN PAGE -- SSO may have failed") browser.close() return print(" Redmine SSO: SUCCESS!") # Step 3: Navigate to account page for API key print("=== STEP 3: Navigate to /my/account ===") page.goto(f"{REDMINE_URL}/my/account", wait_until="domcontentloaded", timeout=30000) page.wait_for_timeout(3000) dump(page, "04-account-page") body = page.evaluate("() => document.body.innerText") print(f" Account body: {body[:300]}") # Step 4: Look for existing API key or generate new one print("=== STEP 4: Find/generate API key ===") # Check if API key already exists on the page api_key = "" api_key_el = page.query_selector('#api-access-key, .api-access-key') if api_key_el: text = api_key_el.text_content().strip() # Redmine API keys are 40-char hex or alphanumeric matches = re.findall(r'[a-f0-9]{40}|[A-Za-z0-9]{40}', text) if matches: api_key = matches[0] print(f" Found existing API key: {api_key[:12]}...") if not api_key: # Look in page text for the key matches = re.findall(r'\b([a-f0-9]{40})\b', body) if matches: api_key = matches[0] print(f" Found API key in page text: {api_key[:12]}...") if not api_key: # Try clicking "Show" first to reveal an existing key show_btn = page.locator('.api-key-actions a:has-text("Show"), a:has-text("Show")') if show_btn.count() > 0 and show_btn.first.is_visible(): print(" Clicking Show to reveal existing key...") show_btn.first.click() page.wait_for_timeout(2000) # Check the now-visible #api-access-key pre element api_el = page.query_selector('#api-access-key') if api_el: text = api_el.text_content().strip() matches = re.findall(r'[a-f0-9]{40}', text) if matches: api_key = matches[0] print(f" Revealed API key: {api_key[:12]}...") if not api_key: # Generate via the Reset link in the API access key section. # Use JS to find the Reset link that is a sibling of #api-access-key's container. print(" No key visible, clicking API key Reset...") reset_clicked = page.evaluate("""() => { // Find the API access key section and its Reset link const apiSection = document.querySelector('#api-access-key'); if (!apiSection) return false; // Walk up to the parent container, then find Reset link within it let container = apiSection.closest('div, p, fieldset'); while (container && container.parentElement) { const reset = Array.from(container.querySelectorAll('a, button')).find(el => el.textContent.trim().toLowerCase() === 'reset' && el.offsetParent !== null ); if (reset) { reset.click(); return true; } container = container.parentElement; if (container.tagName === 'FIELDSET' || container.tagName === 'FORM') break; } return false; }""") if reset_clicked: page.wait_for_timeout(3000) dump(page, "05-after-reset") # Handle confirmation dialog if Redmine asks confirm = page.locator('button:has-text("OK"), button:has-text("Confirm"), button:has-text("Yes"), input[value="OK"], input[value="Yes"]') if confirm.count() > 0 and confirm.first.is_visible(): print(" Clicking confirmation...") confirm.first.click() page.wait_for_timeout(3000) dump(page, "06-after-confirm") # Now check for the key body = page.evaluate("() => document.body.innerText") # Try #api-access-key element first api_el = page.query_selector('#api-access-key') if api_el: text = api_el.text_content().strip() matches = re.findall(r'[a-f0-9]{40}', text) if matches: api_key = matches[0] print(f" Generated API key: {api_key[:12]}...") # Fallback: scan full body if not api_key: matches = re.findall(r'\b([a-f0-9]{40})\b', body) if matches: api_key = matches[0] print(f" Found API key in body: {api_key[:12]}...") else: print(" Could not find API key Reset link via DOM traversal") # Also try data attributes if not api_key: api_el = page.query_selector('[data-key], [data-api-key]') if api_el: api_key = api_el.get_attribute("data-key") or api_el.get_attribute("data-api-key") or "" if api_key: print(f" Found in data attr: {api_key[:12]}...") if api_key: print(f"\n API KEY: {api_key}") print("=== STEP 5: Store in Bitwarden ===") existing = bw.get_item_id(REDMINE_BW_ITEM) if existing: bw.update_item(REDMINE_BW_ITEM, password=api_key) print(f" Updated BW item '{REDMINE_BW_ITEM}'") else: bw.create_item( name=REDMINE_BW_ITEM, username=USERNAME, password=api_key, uris=[REDMINE_URL], collection_name="default", ) print(f" Created BW item '{REDMINE_BW_ITEM}'") else: print(" Could not extract API key") # Dump all elements with 'api' in their attributes api_elements = page.evaluate("""() => { return Array.from(document.querySelectorAll('*')).filter(el => { const id = (el.id || '').toLowerCase(); const cls = (el.getAttribute('class') || '').toLowerCase(); return id.includes('api') || cls.includes('api'); }).map(el => ({ tag: el.tagName, id: el.id, cls: el.getAttribute('class') || '', text: (el.textContent || '').trim().substring(0, 80), })); }""") print(f" API-related elements: {api_elements}") browser.close() print("\n=== DONE ===") if __name__ == "__main__": main()