fix: credential lifecycle safety -- update_item, duplicate prevention, tests
CRITICAL FIX: The --enable-2fa flow was creating duplicate BW items
instead of updating in place, which led to ambiguous item resolution
and data integrity issues. This was a severe failure in core
credential lifecycle operations.
Changes:
- bw_helper.py: Complete rewrite with safety guarantees
- update_item(): modifies existing item in place by ID, preserves
all fields not being updated
- create_item(): refuses to create duplicates (raises if item exists)
- get_item_id(): resolves name to ID, raises on ambiguous matches
- get_item(): returns full item JSON
- NO delete_item method exists by design -- credential deletion
is a manual operation only
- provision-agent.py: --enable-2fa now uses update_item() instead
of create_item() to add TOTP to existing credentials
- Dockerfile: non-root user with correct BW state directory ownership
- docker-compose.yml: bind mount for BW state (proper permissions)
- test_bw_helper.py: 10 tests covering full lifecycle
(create, read, duplicate rejection, update password, update TOTP,
field preservation, no-delete verification)
Tests 1-4 verified passing against live Vaultwarden instance.
- requirements.txt: added pytest
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
This commit is contained in:
+120
-42
@@ -162,57 +162,110 @@ def enroll_cloudron(
|
||||
|
||||
def enable_cloudron_2fa(page: Page, agent: dict, bw: BitwardenHelper) -> str:
|
||||
"""
|
||||
Navigate to Cloudron 2FA settings and enable TOTP.
|
||||
Navigate to Cloudron security settings and enable TOTP 2FA.
|
||||
|
||||
Returns the TOTP secret.
|
||||
Cloudron's security page uses Angular with #inputTotpToggle and
|
||||
#inputTotpSecret/#inputTotpToken selectors.
|
||||
|
||||
Returns the TOTP secret (empty string if already enabled or failed).
|
||||
"""
|
||||
name = agent["name"]
|
||||
log.info(f"[{name}] Enabling 2FA on Cloudron account")
|
||||
|
||||
# Navigate to account settings
|
||||
page.goto(f"{CLOUDRON_BASE}/settings.html#account", wait_until="networkidle")
|
||||
page.wait_for_timeout(2000)
|
||||
# Navigate to security/2FA settings. Cloudron users access their own
|
||||
# settings at /#/profile (not /#/security which is admin-only).
|
||||
page.evaluate('() => window.location.hash = "#/profile"')
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
# Click "Enable 2FA" button
|
||||
enable_btn = page.query_selector('button:has-text("Enable"), button:has-text("2FA"), a:has-text("Enable")')
|
||||
if not enable_btn:
|
||||
log.warning(f"[{name}] Could not find 2FA enable button — may already be enabled")
|
||||
# If that didn't work, try clicking the profile/settings nav link
|
||||
if "#/profile" not in page.url:
|
||||
for nav_text in ["Profile", "Settings", "Account", "Security"]:
|
||||
sec_link = page.locator(f'a[href*="#/profile"], a[href*="#/security"], a:has-text("{nav_text}")')
|
||||
if sec_link.count() > 0:
|
||||
sec_link.first.click()
|
||||
page.wait_for_timeout(3000)
|
||||
break
|
||||
|
||||
_debug_dump(page, f"cloudron-security-{name}")
|
||||
|
||||
# Check if 2FA is already enabled by looking at page text
|
||||
page_text = page.evaluate("() => document.body.innerText")
|
||||
if "totp" in page_text.lower() and "enabled" in page_text.lower():
|
||||
log.info(f"[{name}] 2FA already enabled on Cloudron")
|
||||
return ""
|
||||
|
||||
enable_btn.click()
|
||||
page.wait_for_timeout(2000)
|
||||
# Click the TOTP enable/setup button on the profile page
|
||||
# Cloudron profile has a TOTP section with an Enable button
|
||||
totp_clicked = False
|
||||
for selector in [
|
||||
'button:has-text("Enable")',
|
||||
'button:has-text("Setup")',
|
||||
'a:has-text("Enable")',
|
||||
'input[value="Enable"]',
|
||||
'button:has-text("TOTP")',
|
||||
]:
|
||||
btn = page.locator(selector)
|
||||
if btn.count() > 0:
|
||||
btn.first.click()
|
||||
page.wait_for_timeout(3000)
|
||||
totp_clicked = True
|
||||
break
|
||||
|
||||
# Extract TOTP secret from the QR code or the manual entry text
|
||||
# Cloudron shows a QR code and a text secret
|
||||
secret_text = page.query_selector('.modal-body code, .two-factor-secret, input[readonly]')
|
||||
if secret_text:
|
||||
totp_secret = secret_text.text_content().strip().replace(" ", "")
|
||||
else:
|
||||
# Try to extract from QR image source (base64)
|
||||
if not totp_clicked:
|
||||
log.warning(f"[{name}] Could not find TOTP enable button on profile page")
|
||||
_debug_dump(page, f"cloudron-2fa-no-btn-{name}")
|
||||
return ""
|
||||
|
||||
_debug_dump(page, f"cloudron-2fa-modal-{name}")
|
||||
|
||||
# Extract TOTP secret — Cloudron shows it in a modal after clicking Enable
|
||||
secret_el = page.query_selector('input[readonly], code, .totp-secret, #inputTotpSecret')
|
||||
totp_secret = ""
|
||||
if secret_el:
|
||||
totp_secret = secret_el.get_attribute("value") or secret_el.text_content()
|
||||
totp_secret = totp_secret.strip().replace(" ", "")
|
||||
|
||||
# Try QR code if text secret not found
|
||||
if not totp_secret:
|
||||
qr_img = page.query_selector('img[src*="data:image"]')
|
||||
if qr_img:
|
||||
qr_src = qr_img.get_attribute("src")
|
||||
totp_secret = decode_qr_from_base64(qr_src)
|
||||
else:
|
||||
log.error(f"[{name}] Could not extract TOTP secret from 2FA page")
|
||||
return ""
|
||||
|
||||
if not totp_secret:
|
||||
log.error(f"[{name}] Could not extract TOTP secret from Cloudron 2FA modal")
|
||||
return ""
|
||||
|
||||
log.info(f"[{name}] Extracted TOTP secret: {totp_secret[:4]}...")
|
||||
|
||||
# Generate current TOTP code and confirm
|
||||
# Generate current TOTP code and enter it to confirm
|
||||
import pyotp
|
||||
totp_code = pyotp.TOTP(totp_secret).now()
|
||||
|
||||
code_input = page.query_selector('input[name="totpToken"], input[name="token"], input[placeholder*="code"]')
|
||||
# Cloudron TOTP confirmation input
|
||||
code_input = page.query_selector('#totpTokenInput, #inputTotpToken, input[name="totpToken"]')
|
||||
if not code_input:
|
||||
inputs = page.query_selector_all('input[type="text"]')
|
||||
for inp in inputs:
|
||||
if inp.is_visible():
|
||||
code_input = inp
|
||||
break
|
||||
|
||||
if code_input:
|
||||
code_input.fill(totp_code)
|
||||
confirm_btn = page.query_selector('button:has-text("Confirm"), button:has-text("Enable"), button[type="submit"]')
|
||||
if confirm_btn:
|
||||
confirm_btn.click()
|
||||
page.wait_for_timeout(2000)
|
||||
log.info(f"[{name}] 2FA confirmed")
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# Click confirm button using Playwright locator
|
||||
for btn_text in ["Confirm", "Enable", "Verify", "OK"]:
|
||||
btn = page.locator(f'button:has-text("{btn_text}")')
|
||||
if btn.count() > 0:
|
||||
btn.first.click()
|
||||
break
|
||||
page.wait_for_timeout(3000)
|
||||
log.info(f"[{name}] 2FA confirmation submitted")
|
||||
else:
|
||||
log.warning(f"[{name}] Could not find TOTP confirmation input")
|
||||
_debug_dump(page, f"cloudron-2fa-no-input-{name}")
|
||||
|
||||
return totp_secret
|
||||
|
||||
@@ -262,24 +315,35 @@ def cloudron_panel_login(page: Page, agent: dict, bw: BitwardenHelper) -> bool:
|
||||
|
||||
log.info(f"[{name}] Establishing Cloudron panel session")
|
||||
|
||||
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle")
|
||||
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=15000)
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
# Already logged in?
|
||||
if "login" not in page.url.lower():
|
||||
# Already logged in? (must be on the panel, not redirected to OIDC)
|
||||
if "login" not in page.url.lower() and "openid" not in page.url.lower():
|
||||
log.info(f"[{name}] Cloudron panel session already active")
|
||||
return True
|
||||
|
||||
# If redirected to OIDC login page, fill that too
|
||||
# (Cloudron login.html and OIDC login look similar, both use #inputUsername/#inputPassword)
|
||||
|
||||
# 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();
|
||||
}''')
|
||||
# Submit: Cloudron uses different button types on panel vs OIDC pages
|
||||
page.evaluate("""() => {
|
||||
const selectors = [
|
||||
"button.btn-primary",
|
||||
'button[type="submit"]',
|
||||
'input[type="submit"]',
|
||||
"#loginSubmitButton",
|
||||
];
|
||||
for (const sel of selectors) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) { el.click(); return; }
|
||||
}
|
||||
}""")
|
||||
page.wait_for_timeout(5000)
|
||||
|
||||
# Handle TOTP if prompted
|
||||
@@ -288,16 +352,18 @@ def cloudron_panel_login(page: Page, agent: dict, bw: BitwardenHelper) -> bool:
|
||||
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();
|
||||
for (const sel of ["button.btn-primary", 'button[type="submit"]', 'input[type="submit"]']) {
|
||||
const el = document.querySelector(sel);
|
||||
if (el) { el.click(); return; }
|
||||
}
|
||||
}''')
|
||||
page.wait_for_timeout(5000)
|
||||
|
||||
if "login" not in page.url.lower():
|
||||
if "login" not in page.url.lower() and "openid" 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)")
|
||||
log.error(f"[{name}] Cloudron panel login failed (still on login page: {page.url})")
|
||||
return False
|
||||
|
||||
|
||||
@@ -744,6 +810,7 @@ def main():
|
||||
parser.add_argument("--manifest", default="agents.yaml", help="Path to manifest file")
|
||||
parser.add_argument("--agent", help="Provision only this agent")
|
||||
parser.add_argument("--phase1-only", action="store_true", help="Cloudron enrollment only")
|
||||
parser.add_argument("--enable-2fa", action="store_true", help="Enable 2FA on existing Cloudron accounts")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Validate manifest without browser")
|
||||
parser.add_argument("--headed", action="store_true", help="Show browser (debugging)")
|
||||
args = parser.parse_args()
|
||||
@@ -797,8 +864,19 @@ def main():
|
||||
)
|
||||
log.info(f"=== Provisioning: {agent['name']} ===")
|
||||
try:
|
||||
result = provision_agent(context, agent, bw, args.phase1_only)
|
||||
all_results.append(result)
|
||||
if args.enable_2fa:
|
||||
page = context.new_page()
|
||||
cloudron_panel_login(page, agent, bw)
|
||||
totp_secret = enable_cloudron_2fa(page, agent, bw)
|
||||
if totp_secret:
|
||||
item_name = f"{agent['name']} Cloudron"
|
||||
bw.update_item(item_name, totp_secret=totp_secret)
|
||||
log.info(f"[{agent['name']}] Updated BW item with TOTP secret (in place)")
|
||||
page.close()
|
||||
all_results.append({"name": agent["name"], "2fa": bool(totp_secret)})
|
||||
else:
|
||||
result = provision_agent(context, agent, bw, args.phase1_only)
|
||||
all_results.append(result)
|
||||
except Exception as e:
|
||||
log.error(f"FAILED: {agent['name']}: {e}")
|
||||
all_results.append({"name": agent["name"], "error": str(e)})
|
||||
|
||||
Reference in New Issue
Block a user