Compare commits

...
15 Commits
Author SHA1 Message Date
TSYS Group COO 5e1d043890 feat: merge-invites.py -- wire Charles's invite file into the manifest
Parses the loose invites file (name,url per line; blank lines and
comments skipped), normalizes names (vpsecops -> vp-secops), extracts
email/username/displayName from the invite URL query params, and
merges into agents.yaml. Unknown agents are appended phase1-only.

Duplicate invite tokens are skipped loudly (stale copy-paste guard)
while valid entries still merge. Verified live: caught the svp-knel
line reusing coo's token, merged 5 agents, appended vp-investing and
vp-trading.
2026-08-14 09:31:45 -05:00
TSYS Group COO 6d89f16610 feat: consolidate proven Discourse/Redmine flows into provision-agent.py
Replaced the stale session-1 provision_discourse/provision_redmine with
the flows proven on vp-techops this session. Both are now parameterized
by the agent manifest (username derived from name, hyphens stripped,
overridable via username: field).

Discourse: login modal -> OpenID button -> signup on first login ->
RSA User API key flow (PKCS1v15 decrypt, JSON payload).

Redmine: KNEL Cloud SSO button -> consent -> Show/Reset on the API
access key section via targeted DOM traversal.

Added docs/JOURNAL.md with all working selectors, flows, gotchas, and
verification results so future sessions do not rediscover them.
2026-08-14 08:23:37 -05:00
TSYS Group COO f414b0b7ff docs: update STATUS.md -- Redmine SSO+API key complete, no blockers 2026-08-13 23:41:15 -05:00
TSYS Group COO 2258e1bd04 feat: Redmine SSO login + API key extraction working
Redmine SSO flow: Cloudron login -> click "Continue with KNEL Cloud"
button (#login-oauth-submit-1) -> authenticated. Required Cloudron
admin to grant vp-techops access to the Redmine app first.

API key was already present but hidden. Click "Show" in the
.api-key-actions section to reveal it from the #api-access-key
pre element. Key verified via X-Redmine-API-Key header.
2026-08-13 23:41:00 -05:00
TSYS Group COO fcd484ff97 docs: update STATUS.md with session 2 progress
Completed: BW sync fix, Cloudron 2FA, Discourse SSO+API key, Gitea token.
Blocked: Redmine SSO needs Cloudron app access granted by admin.
2026-08-13 21:30:03 -05:00
TSYS Group COO 2d01a9f962 feat: Discourse SSO + User API key extraction working
Discourse SSO flow: Cloudron login -> click "Log In" -> click OpenID
Connect button -> complete signup (enter username) -> logged in.

User API key generated via Discourse RSA-based flow:
1. Generate RSA keypair, submit public key
2. Authorize request on Discourse
3. Capture encrypted payload from POST response
4. Decrypt with PKCS1v15 padding (Discourse uses this, not OAEP)
5. Parse JSON to extract the key field

API key verified working: User-Api-Key header returns 30 topics from
/latest.json. Key stored in Bitwarden as "vp-techops Discourse".

Redmine SSO is blocked: Cloudron returns "You do not have access" --
the vp-techops user needs app access granted by Cloudron admin.

Also added cryptography==44.0.1 to requirements for RSA operations.
2026-08-13 21:25:29 -05:00
TSYS Group COO be2f607839 feat: enable Cloudron TOTP 2FA for vp-techops (CMMC compliance)
Cloudron's 2FA enrollment flow discovered via comprehensive DOM dump:
1. Profile page -> click "Setup" to start 2FA
2. Cloudron defaults to Passkey -> click "switchToTotp"
3. TOTP secret appears as base32 text -> extract via regex
4. Enter code in #totpTokenInput -> click Enable

Fixed wrong selectors in cloudron_panel_login: the OIDC TOTP field is
#inputTotpToken (not #inputTotp as previously assumed). Verified full
2FA round-trip: password login -> TOTP prompt -> code entry -> #/apps.

2FA is now enabled on the vp-techops Cloudron account with TOTP secret
stored in Bitwarden. This removes a hard blocker for CMMC L3 compliance.
2026-08-13 21:01:22 -05:00
TSYS Group COO f633a10f80 fix: resolve BW state sync issue -- add sync() to login lifecycle
The provisioner's BitwardenHelper.login() was missing the critical
`bw sync` step that the host wrapper includes. Without syncing after
login, the container's local vault cache was empty/stale, causing items
to vanish between container runs. Added sync() call at end of login()
and before list_items().

Also fixed container UID/GID to match host user (1002:1002) for proper
bind-mount access, and added source-code volume mounts for fast iteration.

Verified with 5-phase cross-container persistence test (create in
container A, verify in fresh container B, update in C, confirm in D).
2026-08-13 20:49:39 -05:00
TSYS Group COO c0eb1b383b feat: Gitea SSO + token generation working end-to-end
Working flows:
- Cloudron panel login (Pankow Vue UI: keyboard.type + role=button)
- Gitea SSO via Cloudron OIDC (redirects, auto-consent, authenticated)
- Gitea API token generation (JS-based form fill for hidden elements)
- Token extraction from flash-info message (regex for 40-char hex)
- Token verified via Gitea API (user=vptechops)
- Token stored in Bitwarden as "vp-techops Gitea"

Issues remaining:
- Redmine SSO: OIDC consent completes but redirects back to login page
  (likely Redmine OAuth config or user sync issue)
- Discourse: SSO button not found (needs different selector)
- 2FA: enable button not found on Cloudron profile page
  (TOTP section exists but button selector needs investigation)
- Gitea: stale token cleanup needed (old duplicate from failed runs)

Key pattern established for Cloudron SSO across all apps:
  1. cloudron_panel_login() to establish session
  2. sso_login() clicks app-specific SSO button
  3. OIDC handles auth automatically (session already active)
  4. Redirect back to app authenticated

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-08-13 18:37:20 -05:00
TSYS Group COO f638105614 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
2026-08-13 14:47:57 -05:00
TSYS Group COO 04ece5234a feat: non-root container, reusable SSO flow, OIDC access detection
Security:
- Container now runs as non-root user 'provision' (CMMC/STIG audit
  requirement). Root execution would fail security audits.
- BW state persisted in named volume at /home/provision/.config/
  Bitwarden CLI to avoid slow re-auth on every run.

SSO refactoring:
- cloudron_panel_login(): establishes Cloudron panel session once,
  shared across all subsequent app SSO flows
- sso_login(): clicks app-specific SSO/OAuth button, handles Cloudron
  OIDC login + consent redirect, properly detects failures
- Detects "You do not have access" OIDC rejections (access control
  issue, not a selector bug)
- Per-app SSO button selectors passed as parameters for extensibility
  to future Cloudron apps (Dolibarr, Paperless, Firefly, etc.)

Debug:
- _debug_dump() captures screenshot + DOM at failure points
- Added dumps at Gitea token, Redmine key, and SSO failure locations

Current blocker: vp-techops Cloudron user not yet granted access to
Gitea/Redmine/Discourse apps. Cloudron 2FA confirmation pending.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-08-13 14:01:22 -05:00
TSYS Group COO 3569a09afd fix: Cloudron selectors, docker-compose env_file, CLOUDRON_BASE URL
- Fix Cloudron invite form selectors to match real page IDs
  (#inputUsername, #inputDisplayName, #inputPassword, #inputPasswordRepeat)
- Fix docker-compose.yml: use env_file instead of ${VAR} interpolation
  (password contains $ chars that docker-compose corrupts)
- Update CLOUDRON_BASE from tsys-cloudron.knel.net to my.knownelement.com

Phase 1 Cloudron enrollment now works end-to-end: invite accepted,
password set, TOTP extracted, credential stored in Bitwarden.
Phase 2 (Gitea/Redmine/Discourse) needs selector updates for
the current UI versions of each system.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-08-13 13:20:43 -05:00
TSYS Group COO 8c90d6809b feat: replace Node.js bw CLI with native Rust binary + fix module name
Replace npm-based @bitwarden/cli with the pre-compiled native Rust bw
binary (v2026.7.0) to eliminate Node.js from the credential management
layer for CMMC/ITAR/STIG audit readiness.

Changes:
- Dockerfile: download native bw binary instead of npm install; add
  python3-pip for Playwright dependencies
- bw_helper.py: renamed from bw-helper.py (Python can't import hyphens);
  added BW_SERVER config for self-hosted instance; use --passwordfile
  for unlock (more reliable with native binary); removed TOTP from
  login flow (API key auth does not require it)
- provision-agent.py: pass BW_SERVER env var to BitwardenHelper
- docker-compose.yml: add BW_SERVER env var
- .env.example: add BW_SERVER, document TOTP as optional

Verified: dry-run passes, bw status/auth/generate all work inside
the provisioner container against pwvault.turnsys.com.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-08-13 13:00:53 -05:00
TSYS Group COO 7534964c13 fix: handle 2FA on the Bitwarden account during API login [#442]
bw login --apikey prompts for a TOTP code when 2FA is enabled on the
BW account. The previous code didn't pass one, so it would hang or
fail. Now generates a TOTP from BW_TOTP_SECRET and passes via --code.

Changes:
- BitwardenHelper.__init__ accepts totp_secret param
- login() generates a pyotp code and passes --code when secret is set
- provision-agent.py passes BW_TOTP_SECRET from environment
- docker-compose.yml and .env.example updated for the new var
- BW_PASSWORD removed from the login env (only needed for unlock via stdin)

The BW account's own TOTP secret lives in ~/.config/bw/env alongside
the other BW access info — the one exception (can't store BW's 2FA in
BW itself).

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-08-13 12:14:16 -05:00
TSYS Group COO 9b4502f55d fix: critical bugs in agent identity provisioning [#442]
Email domain bug (would have caused all provisioning to fail):
- Cloudron email default was tsys-cloudron.knel.net (the dashboard host)
  instead of turnsys.com (the actual identity domain). Fixed in 3 places.
- Added explicit cloudron_email field to all agents in agents.yaml.example.

Other fixes:
- STATE_DIR.mkdir() moved from module level to main() so --dry-run and
  --help work outside the container.
- IndexError guard: password_inputs[0] crashes if zero fields found.
- State file save moved to finally block so partial results survive
  provisioning failures.
- Exception in provision_agent no longer re-raised (was preventing state
  file write and summary reporting).
- BW item_exists no longer swallows network/session errors as 'not found'
  (was causing duplicate credential creation).
- Redundant -u flag in bw generate (-uluns → -ulns).
- Dockerfile: npx install with || true → npm install -g (silent failure
  would cause runtime 'bw: command not found').
- Added .dockerignore to prevent .env/agents.yaml/state from entering image.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-08-13 11:21:38 -05:00
24 changed files with 3471 additions and 403 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.gitignore
.env
agents.yaml
state/
__pycache__/
*.pyc
*.md
.crush/
+7
View File
@@ -3,5 +3,12 @@ BW_CLIENTID=
BW_CLIENTSECRET=
BW_PASSWORD=
# Self-hosted Bitwarden/Vaultwarden server URL
BW_SERVER=https://pwvault.turnsys.com
# TOTP secret for the BW account's own 2FA (optional — API key auth
# does not require TOTP; kept for backwards compatibility)
BW_TOTP_SECRET=
# Set to true for debugging (shows browser window — requires display)
HEADFUL=false
+2
View File
@@ -3,3 +3,5 @@ __pycache__/
*.pyc
.env
agents.yaml
bw-state/
invites.txt
+19 -3
View File
@@ -4,18 +4,34 @@ RUN apt-get update && \
apt-get install -y --no-install-recommends \
jq \
unzip \
wget \
python3-pip \
libzbar0 \
libzbar-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Bitwarden CLI
RUN npx -y @bitwarden/cli@2026.7.0 || true
# Install Bitwarden CLI — native Rust binary (no Node.js/npm)
# Same CLI interface as the npm package but zero runtime dependencies.
ARG BW_CLI_VERSION=2026.7.0
RUN wget -q -O /tmp/bw.zip \
"https://github.com/bitwarden/clients/releases/download/cli-v${BW_CLI_VERSION}/bw-linux-${BW_CLI_VERSION}.zip" && \
unzip -o /tmp/bw.zip -d /usr/local/bin/ && \
chmod +x /usr/local/bin/bw && \
rm /tmp/bw.zip
# Install Python dependencies
COPY requirements.txt /tmp/
RUN pip install --no-cache-dir -r /tmp/requirements.txt
RUN python3 -m pip install --no-cache-dir --break-system-packages -r /tmp/requirements.txt
# Create non-root user for Playwright, matching host UID/GID for bind-mount access
RUN groupadd -r -g 1002 provision && useradd -r -u 1002 -g provision -G audio,video -m -d /home/provision provision \
&& mkdir -p "/home/provision/.config/Bitwarden CLI" \
&& chown -R provision:provision /home/provision
WORKDIR /app
COPY . .
RUN chown -R provision:provision /app
USER provision
ENTRYPOINT ["python3", "/app/provision-agent.py"]
+45 -23
View File
@@ -1,33 +1,55 @@
# STATUS.md — Agent Identity Provisioning
## Current State
**Last updated:** 2026-08-13 (Session 2)
**Phase:** Active development — vp-techops provisioning in progress
**Phase:** Development — building the Playwright automation. Not yet executable (awaiting Cloudron invite links from user).
## Current State (Session 2)
**Ticket:** [#442](https://projects.knownelement.com/issues/442)
### Completed
## What's Built
- [x] **BW state sync fixed** — added `sync()` to `login()` lifecycle; 5-phase cross-container persistence test passes
- [x] **Container UID/GID fixed** — provision user now matches host TSGCOO (1002:1002)
- [x] **Cloudron 2FA enabled** — TOTP on vp-techops account, secret stored in BW, full round-trip verified
- [x] **Discourse SSO + signup** — account created (username: vptechops), SSO via OpenID Connect working
- [x] **Discourse API key** — User API key generated via RSA flow, stored in BW, verified working
- [x] **Redmine SSO + API key** — SSO working after Charles granted Cloudron app access. API key extracted via "Show" button, verified via `X-Redmine-API-Key` header.
- [x] **Gitea token** — stored in BW, verified working (user=vptechops, active=true)
- [x] **BW vault** — 3 items: Cloudron (TOTP), Discourse (API key), Gitea (token)
- [x] Repo created: `TSYSGroupCorporate/agent-identity-provisioning`
- [x] Framework adopted (check-rules.sh, hooks, Makefile)
- [x] Dockerfile (Playwright v1.52.0 + bw CLI + Python deps)
- [x] docker-compose.yml
- [x] agents.yaml.example (manifest template)
- [ ] provision-agent.py (main Playwright script) — IN PROGRESS
- [ ] bw-helper.py (BW CLI wrapper) — IN PROGRESS
- [ ] Shellcheck on all scripts
- [ ] Install hooks
- [ ] Initial commit + push
### Blocked (needs Charles)
## Blockers
(none currently)
- **User must provide:** Cloudron invite links (manifest), BW account credentials
- **Discourse admin key:** needed for VP SecOps category creation — assign to vp-techops agent after provisioning
### Remaining
## Inbox
- [ ] Gitea token cleanup (multiple stale tokens may exist from session 1 iterations)
- [ ] Integrate all flows into provision-agent.py main script
- [ ] Provision remaining agents (vp-secops, vp-techcompliance, coo, svp-knel, svp-tctc)
- User wants `bw-run.sh` in TSYSGroupAIOS (DONE — needs commit)
- Cross-linking audit tracked as #441
- BW migration of reachableceo keys tracked as #440 (due Aug 19)
- Discourse admin key → assign to vp-techops agent
- User will create `coo` Linux account + BW account, then run provisioning from dedicated session
## BW Vault State
| Item | Username | Password | TOTP |
|---|---|---|---|
| vp-techops Cloudron | tsgstaff-coo-vptechops@turnsys.com | 32 chars | Enabled |
| vp-techops Discourse | vptechops | 32-char API key | N/A |
| vp-techops Gitea | vptechops | 40-char token | N/A |
| vp-techops Redmine | vptechops | 40-char API key | N/A |
## Key Technical Discoveries (Session 2)
1. **Cloudron 2FA flow:** Profile -> Setup -> switchToTotp (Cloudron defaults to Passkey) -> extract base32 secret -> #totpTokenInput -> Enable
2. **Cloudron OIDC TOTP field:** `#inputTotpToken` (not `#inputTotp` as previously assumed)
3. **Discourse SSO:** Click `.login-button` -> click `button:has-text("OpenID")` in modal
4. **Discourse User API Key:** RSA-based flow with PKCS1v15 padding (not OAEP). Response payload is JSON: `{"key":"...","nonce":"..."}`
5. **Discourse API auth:** Use `User-Api-Key` header (not `Api-Key`)
## Provisioner Container
- Image: `agent-identity-provisioning-provision:latest`
- UID/GID: 1002:1002 (matches host TSGCOO)
- Source code mounted as read-only volumes for fast iteration
- BW state persists via `./bw-state` bind mount + `sync()` after every login
## Ticket
[#442](https://projects.knownelement.com/issues/442)
+12 -10
View File
@@ -1,11 +1,13 @@
# WORKING.md
# WORKING.md — Active Session Tracker
- [x] Create Gitea repo
- [x] Adopt TSYSGroupAIOS framework
- [x] Build Dockerfile + docker-compose.yml
- [x] Build manifest template (agents.yaml.example)
- [x] Write provision-agent.py
- [x] Write bw-helper.py
- [x] Install git hooks
- [x] Shellcheck on all scripts
- [x] Commit + push
Agent work only. User actions (deploy, review, UAT) are NOT tracked here.
The human decides when the work is "done".
## Current Tasks
- [ ] Add update_item() to bw_helper.py (root cause of credential deletion)
- [ ] Fix --enable-2fa to update item in place, never create duplicates
- [ ] Add duplicate-prevention safeguard to create_item()
- [ ] Write credential lifecycle tests (create, read, update, never delete)
- [ ] Verify 2FA is enabled and working end-to-end
- [ ] Update STATUS.md
+8
View File
@@ -10,6 +10,9 @@
agents:
- name: vp-techops
display_name: "VP TechOps"
cloudron_email: "vp-techops@turnsys.com"
# username defaults to name with hyphens stripped (vp-techops -> vptechops)
username: "vptechops"
priority: Q3
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
systems:
@@ -28,6 +31,7 @@ agents:
- name: vp-secops
display_name: "VP SecOps"
cloudron_email: "vp-secops@turnsys.com"
priority: Q3
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
systems:
@@ -46,6 +50,7 @@ agents:
- name: vp-techcompliance
display_name: "VP TechCompliance"
cloudron_email: "vp-techcompliance@turnsys.com"
priority: Q3
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
systems:
@@ -65,18 +70,21 @@ agents:
# Q4 agents — enroll in Cloudron only (Phase 1), no system access yet
- name: coo
display_name: "Chief Operating Officer"
cloudron_email: "coo@turnsys.com"
priority: Q4
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
systems: {}
- name: svp-knel
display_name: "SVP KNEL"
cloudron_email: "svp-knel@turnsys.com"
priority: Q4
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
systems: {}
- name: svp-tctc
display_name: "SVP TCTC"
cloudron_email: "svp-tctc@turnsys.com"
priority: Q4
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
systems: {}
-187
View File
@@ -1,187 +0,0 @@
#!/usr/bin/env python3
"""
bw-helper.py — Bitwarden CLI wrapper for agent identity provisioning.
Provides a clean Python interface to the `bw` CLI for:
- Password generation
- Item creation/retrieval in collections
- TOTP code generation
- Session management
All BW commands run via subprocess. The BW session is established once
and reused across calls.
"""
import json
import os
import subprocess
import sys
from typing import Optional
class BitwardenHelper:
"""Wrapper around the Bitwarden CLI for credential management."""
def __init__(self, client_id: str, client_secret: str, password: str):
self.client_id = client_id
self.client_secret = client_secret
self.password = password
self.session: Optional[str] = None
def _run_bw(self, args: list[str], capture: bool = True) -> str:
"""Run a bw CLI command with the active session."""
env = os.environ.copy()
if self.session:
env["BW_SESSION"] = self.session
result = subprocess.run(
["bw"] + args,
capture_output=capture,
text=True,
env=env,
)
if result.returncode != 0:
raise RuntimeError(
f"bw {' '.join(args)} failed: {result.stderr.strip()}"
)
return result.stdout.strip() if capture else ""
def login(self) -> None:
"""Authenticate via API key and unlock the vault."""
env = os.environ.copy()
env["BW_CLIENTID"] = self.client_id
env["BW_CLIENTSECRET"] = self.client_secret
env["BW_PASSWORD"] = self.password
result = subprocess.run(
["bw", "login", "--apikey"],
capture_output=True,
text=True,
env=env,
)
if result.returncode != 0 and "already" not in result.stderr.lower():
raise RuntimeError(f"BW login failed: {result.stderr.strip()}")
self.session = subprocess.run(
["bw", "unlock", "--raw"],
capture_output=True,
text=True,
env=env,
input=self.password + "\n",
).stdout.strip()
if not self.session:
raise RuntimeError("BW unlock failed — no session token returned")
def generate_password(self, length: int = 32) -> str:
"""Generate a strong password."""
return self._run_bw(["generate", "-uluns", "--length", str(length)])
def get_totp(self, item_name: str) -> str:
"""Get the current TOTP code for a Bitwarden item."""
return self._run_bw(["get", "totp", item_name])
def create_item(
self,
name: str,
username: str,
password: str,
uris: list[str],
collection_name: str,
totp_secret: Optional[str] = None,
custom_fields: Optional[dict[str, str]] = None,
) -> str:
"""Create a login item in a Bitwarden collection.
Returns the item ID.
"""
item = {
"type": 1, # LOGIN
"name": name,
"login": {
"username": username,
"password": password,
"uris": [{"uri": u, "match": None} for u in uris],
},
"collectionIds": [], # resolved by collection_name below
}
if totp_secret:
item["login"]["totp"] = totp_secret
fields = []
if custom_fields:
for key, value in custom_fields.items():
fields.append({"name": key, "value": value, "type": 0})
if fields:
item["fields"] = fields
# Resolve collection ID
collection_id = self._get_collection_id(collection_name)
if collection_id:
item["collectionIds"] = [collection_id]
# Create via BW CLI
encoded = json.dumps(item)
result = subprocess.run(
["bw", "encode"],
input=encoded,
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"bw encode failed: {result.stderr.strip()}")
encoded_item = result.stdout.strip()
output = self._run_bw(["create", "item", encoded_item])
created = json.loads(output)
return created.get("id", "")
def _get_collection_id(self, collection_name: str) -> Optional[str]:
"""Look up a collection ID by name. Returns None if not found."""
try:
output = self._run_bw(["list", "collections"])
collections = json.loads(output)
for col in collections:
if col.get("name", "").lower() == collection_name.lower():
return col.get("id")
except (RuntimeError, json.JSONDecodeError):
pass
return None
def create_collection(self, collection_name: str, org_id: str) -> str:
"""Create a collection in an organization."""
item = {"name": collection_name, "organizationId": org_id}
encoded = json.dumps(item)
result = subprocess.run(
["bw", "encode"],
input=encoded,
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"bw encode failed: {result.stderr.strip()}")
encoded_item = result.stdout.strip()
output = self._run_bw(["create", "collection", encoded_item])
created = json.loads(output)
return created.get("id", "")
def item_exists(self, name: str) -> bool:
"""Check if a Bitwarden item with this name already exists."""
try:
self._run_bw(["get", "item", name])
return True
except RuntimeError:
return False
def get_item_password(self, name: str) -> str:
"""Get the password field from a Bitwarden item."""
return self._run_bw(["get", "password", name])
def get_item_uri(self, name: str) -> str:
"""Get the URI from a Bitwarden item."""
output = self._run_bw(["get", "item", name])
item = json.loads(output)
uris = item.get("login", {}).get("uris", [])
return uris[0]["uri"] if uris else ""
+321
View File
@@ -0,0 +1,321 @@
#!/usr/bin/env python3
"""
bw_helper.py -- Bitwarden CLI wrapper for agent identity provisioning.
Provides a clean Python interface to the `bw` CLI for:
- Password generation
- Item creation/retrieval/updating in collections
- TOTP code generation
- Session management
All BW commands run via subprocess. The BW session is established once
and reused across calls.
SAFETY RULES:
- This module NEVER deletes items. There is no delete_item method.
- create_item() refuses to create duplicates.
- update_item() modifies an existing item in place by ID.
- get_item_id() is the canonical way to resolve an item -- it uses the
BW search API and raises on ambiguity (multiple matches).
"""
import json
import os
import subprocess
import sys
import tempfile
from typing import Optional
class BitwardenHelper:
"""Wrapper around the Bitwarden CLI for credential management."""
def __init__(self, client_id: str, client_secret: str, password: str,
totp_secret: str = "", server_url: str = ""):
self.client_id = client_id
self.client_secret = client_secret
self.password = password
self.totp_secret = totp_secret
self.server_url = server_url
self.session: Optional[str] = None
def _run_bw(self, args: list[str], capture: bool = True) -> str:
"""Run a bw CLI command with the active session."""
env = os.environ.copy()
if self.session:
env["BW_SESSION"] = self.session
result = subprocess.run(
["bw"] + args,
capture_output=capture,
text=True,
env=env,
)
if result.returncode != 0:
raise RuntimeError(
f"bw {' '.join(args)} failed: {result.stderr.strip()}"
)
return result.stdout.strip() if capture else ""
def _encode(self, data: dict) -> str:
"""Encode a dict to base64 for bw CLI input."""
encoded = json.dumps(data)
result = subprocess.run(
["bw", "encode"],
input=encoded,
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"bw encode failed: {result.stderr.strip()}")
return result.stdout.strip()
def login(self) -> None:
"""Authenticate via API key and unlock the vault.
Configures the BW server URL (for self-hosted instances), logs in
via API key, and unlocks the vault. API key auth does not require
TOTP -- the key itself is obtained from an authenticated session.
"""
env = os.environ.copy()
env["BW_CLIENTID"] = self.client_id
env["BW_CLIENTSECRET"] = self.client_secret
# Configure server URL for self-hosted instances
if self.server_url:
subprocess.run(
["bw", "config", "server", self.server_url],
capture_output=True, text=True, env=env,
)
# Login via API key (tolerates already-logged-in state)
result = subprocess.run(
["bw", "login", "--apikey"],
capture_output=True, text=True, env=env,
)
if result.returncode != 0 and "already" not in result.stderr.lower():
raise RuntimeError(f"BW login failed: {result.stderr.strip()}")
# Unlock via password file (more reliable than stdin with native binary)
with tempfile.NamedTemporaryFile(mode="w", suffix=".pw", delete=False) as pw_file:
pw_file.write(self.password)
pw_file_path = pw_file.name
try:
self.session = subprocess.run(
["bw", "unlock", "--passwordfile", pw_file_path, "--raw"],
capture_output=True, text=True, env=env,
).stdout.strip()
finally:
os.unlink(pw_file_path)
if not self.session:
raise RuntimeError("BW unlock failed -- no session token returned")
self.sync()
def sync(self) -> None:
"""Sync the local vault cache with the server.
Must be called after login and before any read to guarantee
the local cache reflects the latest server state. Without this,
items created by other clients (e.g. the host bw wrapper) will
not appear in list/search results.
"""
self._run_bw(["sync"])
def generate_password(self, length: int = 32) -> str:
"""Generate a strong password."""
return self._run_bw(["generate", "-ulns", "--length", str(length)])
def get_totp(self, item_name: str) -> str:
"""Get the current TOTP code for a Bitwarden item."""
return self._run_bw(["get", "totp", item_name])
# -------------------------------------------------------------------
# Item ID resolution -- the safe way to reference items
# -------------------------------------------------------------------
def get_item_id(self, name: str) -> Optional[str]:
"""Resolve an item name to its BW ID.
Returns the item ID if exactly one match exists, None if no match,
and raises RuntimeError if multiple items share the name (ambiguous).
"""
try:
output = self._run_bw(["list", "items", "--search", name])
items = json.loads(output)
# Filter to exact name matches (bw search is fuzzy)
matches = [i for i in items if i.get("name") == name]
if len(matches) == 0:
return None
if len(matches) > 1:
ids = ", ".join(m["id"] for m in matches)
raise RuntimeError(
f"Multiple BW items named '{name}': {ids}. "
f"This is a data integrity issue -- resolve manually."
)
return matches[0]["id"]
except json.JSONDecodeError:
return None
def get_item(self, name: str) -> Optional[dict]:
"""Get the full item JSON by name. Returns None if not found."""
item_id = self.get_item_id(name)
if not item_id:
return None
output = self._run_bw(["get", "item", item_id])
return json.loads(output)
# -------------------------------------------------------------------
# Create / Update -- never duplicate, never delete
# -------------------------------------------------------------------
def create_item(
self,
name: str,
username: str,
password: str,
uris: list[str],
collection_name: str,
totp_secret: Optional[str] = None,
custom_fields: Optional[dict[str, str]] = None,
) -> str:
"""Create a login item in a Bitwarden collection.
Raises RuntimeError if an item with this name already exists.
Use update_item() to modify an existing item.
Returns the item ID.
"""
# SAFEGUARD: refuse to create duplicates
existing_id = self.get_item_id(name)
if existing_id:
raise RuntimeError(
f"Item '{name}' already exists (id={existing_id}). "
f"Use update_item() to modify it. "
f"This safeguard prevents credential duplication."
)
item = {
"type": 1, # LOGIN
"name": name,
"login": {
"username": username,
"password": password,
"uris": [{"uri": u, "match": None} for u in uris],
},
"collectionIds": [],
}
if totp_secret:
item["login"]["totp"] = totp_secret
if custom_fields:
item["fields"] = [
{"name": k, "value": v, "type": 0}
for k, v in custom_fields.items()
]
collection_id = self._get_collection_id(collection_name)
if collection_id:
item["collectionIds"] = [collection_id]
encoded_item = self._encode(item)
output = self._run_bw(["create", "item", encoded_item])
created = json.loads(output)
return created.get("id", "")
def update_item(
self,
name: str,
password: Optional[str] = None,
totp_secret: Optional[str] = None,
uris: Optional[list[str]] = None,
custom_fields: Optional[dict[str, str]] = None,
) -> str:
"""Update an existing item in place by name.
Only the provided fields are updated; others remain unchanged.
If the item does not exist, raises RuntimeError.
Returns the item ID.
"""
item_id = self.get_item_id(name)
if not item_id:
raise RuntimeError(
f"Cannot update: item '{name}' not found. "
f"Use create_item() to create it first."
)
# Fetch the current item to preserve existing fields
current = json.loads(self._run_bw(["get", "item", item_id]))
# Apply updates only to provided fields
if password is not None:
current["login"]["password"] = password
if totp_secret is not None:
current["login"]["totp"] = totp_secret
if uris is not None:
current["login"]["uris"] = [{"uri": u, "match": None} for u in uris]
if custom_fields is not None:
current["fields"] = [
{"name": k, "value": v, "type": 0}
for k, v in custom_fields.items()
]
encoded_item = self._encode(current)
output = self._run_bw(["edit", "item", item_id, encoded_item])
updated = json.loads(output)
return updated.get("id", item_id)
# -------------------------------------------------------------------
# Read helpers
# -------------------------------------------------------------------
def item_exists(self, name: str) -> bool:
"""Check if a Bitwarden item with this name already exists."""
return self.get_item_id(name) is not None
def get_item_password(self, name: str) -> str:
"""Get the password field from a Bitwarden item."""
item_id = self.get_item_id(name)
if not item_id:
raise RuntimeError(f"Item '{name}' not found")
return self._run_bw(["get", "password", item_id])
def get_item_uri(self, name: str) -> str:
"""Get the URI from a Bitwarden item."""
item = self.get_item(name)
if not item:
return ""
uris = item.get("login", {}).get("uris", [])
return uris[0]["uri"] if uris else ""
def list_items(self) -> list[dict]:
"""List all items in the vault."""
self.sync()
output = self._run_bw(["list", "items"])
return json.loads(output)
# -------------------------------------------------------------------
# Collections
# -------------------------------------------------------------------
def _get_collection_id(self, collection_name: str) -> Optional[str]:
"""Look up a collection ID by name. Returns None if not found."""
try:
output = self._run_bw(["list", "collections"])
collections = json.loads(output)
for col in collections:
if col.get("name", "").lower() == collection_name.lower():
return col.get("id")
except (RuntimeError, json.JSONDecodeError):
pass
return None
def create_collection(self, collection_name: str, org_id: str) -> str:
"""Create a collection in an organization."""
item = {"name": collection_name, "organizationId": org_id}
encoded_item = self._encode(item)
output = self._run_bw(["create", "collection", encoded_item])
created = json.loads(output)
return created.get("id", "")
+17 -6
View File
@@ -2,12 +2,23 @@ services:
provision:
build: .
container_name: tsys-agent-provisioner
environment:
- BW_CLIENTID=${BW_CLIENTID}
- BW_CLIENTSECRET=${BW_CLIENTSECRET}
- BW_PASSWORD=${BW_PASSWORD}
- HEADFUL=${HEADFUL:-false}
env_file:
- ${BW_ENV_FILE:-${HOME}/.config/bw/env}
user: "provision"
volumes:
- ./agents.yaml:/app/agents.yaml:ro
- ./state:/app/state
network_mode: host
- ./bw-state:/home/provision/.config/Bitwarden CLI
- ./bw_helper.py:/app/bw_helper.py:ro
- ./provision-agent.py:/app/provision-agent.py:ro
- ./test_bw_helper.py:/app/test_bw_helper.py:ro
- ./test_bw_persistence.py:/app/test_bw_persistence.py:ro
- ./dump-cloudron-dom.py:/app/dump-cloudron-dom.py:ro
- ./enable-cloudron-2fa.py:/app/enable-cloudron-2fa.py:ro
- ./verify-cloudron-2fa.py:/app/verify-cloudron-2fa.py:ro
- ./investigate-2fa-login.py:/app/investigate-2fa-login.py:ro
- ./dump-sso-flows.py:/app/dump-sso-flows.py:ro
- ./provision-discourse.py:/app/provision-discourse.py:ro
- ./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
+84
View File
@@ -0,0 +1,84 @@
# JOURNAL.md — Agent Identity Provisioning
> Append-only decision & pattern log. One section per change. Never delete or reorder.
---
## 2026-08-13 — Session 2: BW sync fix, 2FA, all four systems proven
**Commits:** f633a10, be2f607, 2d01a9f, 2258e1b, fcd484f, f414b0b
### Decisions
1. **BW sync lifecycle**: `BitwardenHelper.login()` must end with `bw sync`.
Root cause of session-1 "vanishing items": the container's local cache
was never synced after login. `list_items()` also syncs before reading.
2. **Container UID/GID**: provision user is 1002:1002, matching the host
TSGCOO account, so bind-mount state files are owned by the invoking user.
During rapid iteration `:latest` tagging with overwrite is acceptable.
3. **Source mounted read-only** into the container (bw_helper.py,
provision-agent.py, test files) so selector iterations do not require
image rebuilds.
4. **One-off scripts kept**: the exploration scripts (dump-cloudron-dom.py,
enable-cloudron-2fa.py, etc.) remain in the repo as proven references;
their flows have been consolidated into provision-agent.py.
### Patterns (selectors and flows that WORK)
**Cloudron panel (Pankow/Vue):**
- Login: `#inputUsername` / `#inputPassword`, type via `page.keyboard.type()`
(never `fill()`), submit via `[role="button"]:has-text("Log in")`.
- 2FA prompt on OIDC login: `#inputTotpToken` + `#totpTokenSubmitButton`
(NOT `#inputTotp`).
- 2FA enrollment: `#/profile` -> click `text=Setup` -> click
`text=switchToTotp` (Cloudron defaults to Passkey) -> secret is base32
text on the page (regex `[A-Z2-7]{16,}`) -> enter code in
`#totpTokenInput` -> click Enable.
**Gitea (proven session 1):**
- SSO button: `a[href*="oauth2/cloudron"]` at `/user/login`.
- Token page `/user/settings/applications`: fill `#name` via JS evaluate,
scopes are radio buttons, extract 40-hex from `.ui.info.message`.
**Discourse:**
- Login modal via `.login-button`, then `button:has-text("OpenID")`.
- First SSO lands on `/signup` with email pre-authenticated: fill
`#new-account-username`, click Sign Up.
- User API key: RSA keypair -> `/user-api-key/new?...&public_key=<PEM>` ->
click Authorize -> capture POST response -> decrypt with **PKCS1v15**
(not OAEP) -> payload JSON `{"key": "..."}`.
- API auth header is `User-Api-Key` (admin keys use `Api-Key`).
**Redmine:**
- SSO button: `#login-oauth-submit-1` ("Continue with KNEL Cloud").
- Prereq: Cloudron admin must grant the user access to the Redmine app,
otherwise OIDC shows "You do not have access" and redirects back.
- API key: `/my/account` -> click Show in `.api-key-actions` -> read
`#api-access-key` (40-hex). If absent, click the Reset link found by
DOM traversal from `#api-access-key` (generic `a:has-text("Reset")`
clicks the wrong section and logs you out).
### Username derivation
Manifest `name` is hyphenated (vp-techops); app usernames are not
(vptechops). Default: `agent.get("username", name.replace("-", ""))`.
Override with an explicit `username:` field in agents.yaml.
### Gotchas
- Em dashes (U+2014) break Python source; use `--`.
- Python f-string interpolation inside JS template literals does not work;
build JS strings with plain concatenation inside evaluate().
- Discourse admin API keys page is admin-only; User API keys are the
self-service path.
- Gitea token page needs `wait_until="domcontentloaded"` (networkidle
times out).
### Verification results (vp-techops)
| System | Credential | Verified via |
|---|---|---|
| Cloudron | password + TOTP | full login round-trip |
| Gitea | 40-char token | `GET /api/v1/user` -> vptechops |
| Discourse | 32-char user key | `GET /latest.json` with User-Api-Key |
| Redmine | 40-char API key | `GET /users/current.json` -> id 11 |
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""
dump-cloudron-dom.py -- Comprehensive DOM dump of Cloudron profile page.
Captures ALL interactive elements (not just standard form inputs) to find
the TOTP enable button that previous dumps missed. Cloudron uses Pankow/Vue
components where buttons are often <div role="button"> not <button>.
Usage:
docker compose run --rm --entrypoint python3 provision dump-cloudron-dom.py
"""
import os
import sys
import time
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")
STATE_DIR = Path("/app/state")
STATE_DIR.mkdir(parents=True, exist_ok=True)
BW_ITEM = "vp-techops Cloudron"
def full_dom_dump(page, label):
"""Dump every visible element with role, text, classes, and attributes."""
ts = time.strftime("%H%M%S")
screenshot_path = STATE_DIR / f"domdump-{label}-{ts}.png"
text_path = STATE_DIR / f"domdump-{label}-{ts}.txt"
try:
page.screenshot(path=str(screenshot_path), full_page=True)
except Exception:
pass
try:
elements = page.evaluate("""() => {
const results = [];
// Capture ALL potentially interactive elements
const selector = [
'input', 'button', 'select', 'textarea', 'label',
'a', 'span', 'div', 'code', 'pre',
'[role]', '[onclick]', '[tabindex]',
'[class*="btn"]', '[class*="button"]', '[class*="totp"]',
'[class*="modal"]', '[class*="dialog"]', '[class*="card"]',
'.pankow-button', '.pankow-card',
].join(', ');
document.querySelectorAll(selector).forEach(el => {
const tag = el.tagName.toLowerCase();
const text = (el.textContent || '').trim().substring(0, 120);
const role = el.getAttribute('role') || '';
const id = el.id || '';
const name = el.getAttribute('name') || '';
const type = el.getAttribute('type') || '';
const value = el.getAttribute('value') || '';
const href = el.getAttribute('href') || '';
const placeholder = el.getAttribute('placeholder') || '';
const cls = (el.className || '').substring(0, 80);
const tabindex = el.getAttribute('tabindex') || '';
const visible = el.offsetParent !== null;
// Only log elements with useful info, skip pure containers
if (text || id || name || type || value || role || href || placeholder ||
cls.includes('btn') || cls.includes('button') || cls.includes('totp') ||
cls.includes('modal') || cls.includes('dialog')) {
results.push({
tag, text: text.substring(0, 80), role, id, name, type,
value, href, placeholder, cls: cls.substring(0, 60),
tabindex, visible
});
}
});
return results;
}""")
lines = [f"URL: {page.url}", f"Elements: {len(elements)}", ""]
for el in elements:
vis = "V" if el["visible"] else "H"
parts = [f"[{vis}] <{el['tag']}>"]
if el["role"]:
parts.append(f"role={el['role']}")
if el["id"]:
parts.append(f"id={el['id']}")
if el["name"]:
parts.append(f"name={el['name']}")
if el["type"]:
parts.append(f"type={el['type']}")
if el["value"]:
parts.append(f"value={el['value'][:40]}")
if el["href"]:
parts.append(f"href={el['href'][:60]}")
if el["placeholder"]:
parts.append(f"placeholder={el['placeholder']}")
if el["cls"]:
parts.append(f"class={el['cls']}")
if el["tabindex"]:
parts.append(f"tabindex={el['tabindex']}")
if el["text"]:
parts.append(f'text="{el["text"]}"')
lines.append(" ".join(parts))
text_path.write_text("\n".join(lines))
print(f"Dump saved: {screenshot_path.name}, {text_path.name} ({len(elements)} elements)")
# Print TOTP-related elements to stdout for immediate visibility
print("\n=== TOTP-RELATED ELEMENTS ===")
for el in elements:
combined = f"{el['tag']} {el['text']} {el['cls']} {el['id']} {el['role']}".lower()
if "totp" in combined or "2fa" in combined or "authenticator" in combined:
print(f" <{el['tag']}> role={el['role']} id={el['id']} "
f"class={el['cls']} text=\"{el['text']}\" visible={el['visible']}")
print("\n=== ALL role=button ELEMENTS ===")
for el in elements:
if el["role"] == "button":
print(f" <{el['tag']}> id={el['id']} class={el['cls']} "
f'text="{el["text"]}" visible={el["visible"]}')
except Exception as e:
print(f"Dump failed: {e}")
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)
email = "tsgstaff-coo-vptechops@turnsys.com"
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 to Cloudron panel
print("=== LOGGING IN 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)
btn = page.locator('[role="button"]:has-text("Log in")')
if btn.count() > 0:
btn.first.click()
page.wait_for_timeout(5000)
print(f"Post-login URL: {page.url}")
print(f"Logged in: {'login' not in page.url.lower()}")
# Step 2: Navigate to profile page
print("\n=== NAVIGATING TO #/profile ===")
page.evaluate('() => window.location.hash = "#/profile"')
page.wait_for_timeout(3000)
print(f"Profile URL: {page.url}")
# Step 3: Comprehensive DOM dump
print("\n=== DUMPING PROFILE PAGE DOM ===")
full_dom_dump(page, "profile-initial")
# Step 4: Try to find and click TOTP setup
print("\n=== LOOKING FOR TOTP SETUP BUTTON ===")
# Try various approaches to find the TOTP enable button
clicked = False
for desc, selector in [
("role=button near TOTP", '[role="button"]:near(:text("TOTP"))'),
("button text Enable TOTP", 'button:has-text("Enable")'),
("role=button text Enable", '[role="button"]:has-text("Enable")'),
("text=Setup TOTP", 'text=Setup'),
("role=button text Setup", '[role="button"]:has-text("Setup")'),
("role=button text TOTP", '[role="button"]:has-text("TOTP")'),
("class totp-button", '[class*="totp"]'),
("a text Enable", 'a:has-text("Enable")'),
]:
try:
loc = page.locator(selector)
if loc.count() > 0:
print(f" Found: {desc} ({loc.count()} matches)")
# Dump before clicking
full_dom_dump(page, f"pre-click-{desc.replace(' ', '-')}")
loc.first.click()
page.wait_for_timeout(3000)
clicked = True
print(f" Clicked: {desc}")
break
except Exception as e:
print(f" {desc}: {e}")
if clicked:
print("\n=== DUMPING POST-CLICK STATE (modal/dialog?) ===")
full_dom_dump(page, "post-totp-click")
else:
print("\n!!! Could not find any TOTP button to click")
# Step 5: Dump the full page text for context
print("\n=== PAGE TEXT (searchable) ===")
body_text = page.evaluate("() => document.body.innerText")
# Print only lines mentioning totp, 2fa, enable, setup, authenticator
for line in body_text.split("\n"):
low = line.lower().strip()
if any(w in low for w in ["totp", "2fa", "enable", "setup", "authenticat", "factor", "passkey", "security key"]):
print(f" {line.strip()}")
browser.close()
if __name__ == "__main__":
main()
+293
View File
@@ -0,0 +1,293 @@
#!/usr/bin/env python3
"""
dump-sso-flows.py -- Comprehensive DOM dump and SSO attempt for Redmine + Discourse.
Establishes Cloudron OIDC session first, then navigates to each app's
login page, dumps the DOM, and attempts the SSO flow.
Usage:
docker compose run --rm --entrypoint python3 provision dump-sso-flows.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")
GITEA_URL = os.environ.get("GITEA_URL", "https://git.knownelement.com")
REDMINE_URL = os.environ.get("REDMINE_URL", "https://projects.knownelement.com")
DISCOURSE_URL = os.environ.get("DISCOURSE_URL", "https://community.turnsys.com")
STATE_DIR = Path("/app/state")
BW_ITEM = "vp-techops Cloudron"
EMAIL = "tsgstaff-coo-vptechops@turnsys.com"
def dump(page, label):
"""Save screenshot + comprehensive element dump."""
ts = time.strftime("%H%M%S")
try:
page.screenshot(path=str(STATE_DIR / f"sso-{label}-{ts}.png"), full_page=True)
except Exception:
pass
elements = page.evaluate("""() => {
const results = [];
const selector = 'input, button, select, textarea, label, a, [role="button"], ' +
'h1, h2, h3, h4, code, pre, form, [class*="oauth"], [class*="login"], ' +
'[class*="btn"], [id*="login"], [id*="oauth"], [id*="sso"]';
document.querySelectorAll(selector).forEach(el => {
const tag = el.tagName.toLowerCase();
const text = (el.textContent || '').trim().substring(0, 80);
const role = el.getAttribute('role') || '';
const id = el.id || '';
const name = el.getAttribute('name') || '';
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 action = el.getAttribute('action') || '';
const visible = el.offsetParent !== null;
if (text || id || name || type || value || href || role || action ||
cls.includes('btn') || cls.includes('oauth') || cls.includes('login')) {
let parts = '<' + tag + '> ';
if (role) parts += 'role=' + role + ' ';
if (id) parts += 'id=' + id + ' ';
if (type) parts += 'type=' + type + ' ';
if (name) parts += 'name=' + name + ' ';
if (href) parts += 'href=' + href + ' ';
if (cls) parts += 'class=' + cls + ' ';
parts += 'vis=' + visible + ' text="' + text + '"';
results.push(parts);
}
});
return results;
}""")
body = page.evaluate("() => document.body.innerText.substring(0, 300)")
text_path = STATE_DIR / f"sso-{label}-{ts}.txt"
text_path.write_text(f"URL: {page.url}\n\nBody: {body}\n\nElements:\n" + "\n".join(elements))
print(f" [{label}] {len(elements)} elements -> {text_path.name}")
def cloudron_login(page, bw):
"""Login to Cloudron panel with TOTP support."""
password = bw.get_item_password(BW_ITEM)
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() and "openid" not in page.url.lower():
print(f" Cloudron session already active ({page.url})")
return True
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)
# Handle TOTP
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)
# Handle consent page
for consent in ["Continue", "Authorize", "Allow", "Accept"]:
loc = page.locator(f'[role="button"]:has-text("{consent}"), button:has-text("{consent}")')
if loc.count() > 0 and loc.first.is_visible():
loc.first.click()
page.wait_for_timeout(3000)
break
logged_in = "login" not in page.url.lower() and "openid" not in page.url.lower()
print(f" Cloudron login: {'SUCCESS' if logged_in else 'CHECKING...'} ({page.url})")
return logged_in
def try_redmine_sso(page, bw):
"""Attempt Redmine SSO login."""
print("\n=== REDMINE SSO ===")
page.goto(f"{REDMINE_URL}/login", wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(3000)
dump(page, "redmine-login-initial")
# Look for SSO/OAuth button
print(" Looking for SSO button...")
sso_selectors = [
'#login-oauth-submit-1',
'button:has-text("KNEL")',
'button:has-text("Cloud")',
'button:has-text("Continue")',
'a:has-text("KNEL")',
'a:has-text("Cloud")',
'[class*="oauth"]',
'input[name="oauth2"]',
]
for sel in sso_selectors:
loc = page.locator(sel)
if loc.count() > 0 and loc.first.is_visible():
print(f" Found SSO button: {sel}")
loc.first.click()
page.wait_for_timeout(5000)
dump(page, "redmine-after-sso-click")
# Check if we hit OIDC consent page
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)
break
dump(page, "redmine-after-consent")
# Check if logged in
current_url = page.url
body = page.evaluate("() => document.body.innerText.substring(0, 200)")
if "/login" not in current_url:
print(f" Redmine SSO result: LOGGED IN ({current_url})")
return True
else:
print(f" Redmine SSO result: still on login page")
print(f" Body: {body[:100]}")
return False
print(" No SSO button found")
return False
def try_discourse_sso(page, bw):
"""Attempt Discourse SSO login."""
print("\n=== DISCOURSE SSO ===")
page.goto(DISCOURSE_URL, wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(3000)
dump(page, "discourse-initial")
# Click "Log In" button to open modal
print(" Looking for Log In button...")
login_clicked = False
for sel in [
'.login-button',
'.header-buttons .login-button',
'button:has-text("Log In")',
'[role="button"]:has-text("Log In")',
'.btn:has-text("Log In")',
'a:has-text("Log In")',
]:
loc = page.locator(sel)
if loc.count() > 0 and loc.first.is_visible():
loc.first.click()
page.wait_for_timeout(3000)
login_clicked = True
print(f" Clicked login button: {sel}")
break
if not login_clicked:
print(" Could not find Log In button")
dump(page, "discourse-no-login-btn")
return False
dump(page, "discourse-login-modal")
# Look for SSO/OIDC button inside modal
print(" Looking for SSO button in modal...")
sso_selectors = [
'button:has-text("OpenID")',
'button:has-text("Connect")',
'button:has-text("Cloud")',
'button:has-text("KNEL")',
'[class*="oauth"]',
'[class*="openid"]',
'[class*="sso"]',
'a:has-text("OpenID")',
'a:has-text("Connect")',
'button[class*="social"]',
'.login-buttons button',
'.auth-buttons button',
'[data-login-method]',
]
for sel in sso_selectors:
loc = page.locator(sel)
if loc.count() > 0 and loc.first.is_visible():
print(f" Found SSO button: {sel} ({loc.count()} matches)")
loc.first.click()
page.wait_for_timeout(5000)
dump(page, "discourse-after-sso-click")
# Handle OIDC consent
if "openid" in page.url.lower():
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)
break
dump(page, "discourse-after-consent")
body = page.evaluate("() => document.body.innerText.substring(0, 200)")
print(f" Discourse SSO result URL: {page.url}")
print(f" Body: {body[:100]}")
return True
print(" No SSO button found in modal")
# Dump ALL buttons in the modal for debugging
buttons = page.evaluate("""() => {
return Array.from(document.querySelectorAll('button, [role="button"], a.btn')).map(el => ({
tag: el.tagName,
text: (el.textContent || '').trim().substring(0, 50),
cls: (el.getAttribute('class') || '').substring(0, 60),
visible: el.offsetParent !== null,
}));
}""")
print(f" All buttons: {buttons}")
return False
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()
print("=== Establishing Cloudron OIDC session ===")
cloudron_login(page, bw)
# Try Redmine SSO
redmine_ok = try_redmine_sso(page, bw)
# Re-establish Cloudron session for Discourse (may have been consumed)
print("\n=== Re-establishing Cloudron session ===")
cloudron_login(page, bw)
# Try Discourse SSO
discourse_ok = try_discourse_sso(page, bw)
print(f"\n=== RESULTS ===")
print(f" Redmine SSO: {'SUCCESS' if redmine_ok else 'FAILED'}")
print(f" Discourse SSO: {'SUCCESS' if discourse_ok else 'FAILED'}")
browser.close()
if __name__ == "__main__":
main()
+309
View File
@@ -0,0 +1,309 @@
#!/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()
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""
investigate-2fa-login.py -- Detailed investigation of the TOTP login flow.
Dumps the OIDC interaction page at multiple stages to find the TOTP prompt.
Usage:
docker compose run --rm --entrypoint python3 provision investigate-2fa-login.py
"""
import os, sys, time
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")
STATE_DIR = Path("/app/state")
BW_ITEM = "vp-techops Cloudron"
EMAIL = "tsgstaff-coo-vptechops@turnsys.com"
def dump_page_state(page, label):
"""Dump URL, all visible inputs/buttons, and page text snippet."""
ts = time.strftime("%H%M%S")
page.screenshot(path=str(STATE_DIR / f"investigate-{label}-{ts}.png"), full_page=True)
url = page.url
inputs = page.evaluate("""() => {
return Array.from(document.querySelectorAll(
'input, [role="button"], button, [id*="totp" i], [id*="Totp"]'
)).filter(el => {
return el.offsetParent !== null || el.style.display !== 'none';
}).map(el => ({
tag: el.tagName,
type: el.type || '',
id: el.id || '',
name: el.getAttribute('name') || '',
role: el.getAttribute('role') || '',
placeholder: el.placeholder || '',
text: (el.textContent || '').trim().substring(0, 40),
visible: el.offsetParent !== null,
}));
}""")
body_text = page.evaluate("() => document.body.innerText.substring(0, 500)")
print(f"\n--- {label} ---")
print(f"URL: {url}")
print(f"Inputs/buttons ({len(inputs)}):")
for inp in inputs:
print(f" <{inp['tag']}> type={inp['type']} id={inp['id']} name={inp['name']} "
f"role={inp['role']} placeholder={inp['placeholder']} text={inp['text']} "
f"visible={inp['visible']}")
print(f"Body text: {body_text[:200]}")
(STATE_DIR / f"investigate-{label}-{ts}.txt").write_text(
f"URL: {url}\n\nInputs: {inputs}\n\nBody: {body_text}")
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)
page = browser.new_context(viewport={"width": 1280, "height": 1024}).new_page()
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=15000)
page.wait_for_timeout(2000)
dump_page_state(page, "01-initial-load")
# Fill password and submit
page.wait_for_selector("#inputPassword", timeout=15000)
page.click("#inputUsername")
page.keyboard.type(EMAIL)
page.click("#inputPassword")
page.keyboard.type(password)
dump_page_state(page, "02-form-filled")
page.locator('[role="button"]:has-text("Log in")').first.click()
# Wait and check multiple times for TOTP field
for wait in [2, 3, 5, 5]:
page.wait_for_timeout(wait * 1000)
dump_page_state(page, f"03-after-login-{wait}s")
totp = page.query_selector("#inputTotp")
if totp and totp.is_visible():
print(f"\n=== TOTP FIELD FOUND after {wait}s! ===")
code = bw.get_totp(BW_ITEM)
print(f"Entering TOTP: {code}")
totp.click()
page.keyboard.type(code)
page.locator('[role="button"]:has-text("Log in")').first.click()
page.wait_for_timeout(5000)
dump_page_state(page, "04-after-totp")
print(f"Final URL: {page.url}")
break
else:
print("\n=== No TOTP field found at any wait interval ===")
# Check if we're already logged in
if "login" not in page.url.lower() and "openid" not in page.url.lower():
print("Already past login (maybe session was active)")
browser.close()
if __name__ == "__main__":
main()
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""
merge-invites.py -- Merge ~/cloudron-invites.txt into agents.yaml.
Parses the loose invite file format (name, url -- extra spaces and
blank lines tolerated), extracts email/username/displayName from each
invite URL's query params, and updates (or appends) agents in the
manifest.
Safety:
- Detects duplicate invite tokens across entries and SKIPS the later
duplicate (stale copy-paste), reporting it loudly.
- Never overwrites an existing valid invite with a placeholder.
- Names are normalized: vpsecops -> vp-secops, svpknel -> svp-knel,
vpinvesting -> vp-investing (coo stays coo). Unknown agents are
appended with systems: {} (phase1-only).
Input format -- one agent per line, blank lines and # comments skipped:
agent-name,https://my.knownelement.com/setupaccount.html?inviteToken=...&email=...
The invite URL already contains email/username/displayName as query
params; the line only adds the canonical agent name (hyphenated, e.g.
vp-secops). Loose names like vpsecops are auto-normalized.
Run (reads ro-mounted agents.yaml, writes merged copy to state/):
docker compose run --rm --entrypoint python3 \
-v "$HOME/cloudron-invites.txt:/invites.txt:ro" \
provision merge-invites.py
cp state/agents-merged.yaml agents.yaml
Exit codes: 0 = clean, 1 = skipped duplicates or missing agents (manifest
still written with what was valid).
"""
import re
import sys
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
import yaml
INVITE_PATH = sys.argv[1] if len(sys.argv) > 1 else "/invites.txt"
MANIFEST_IN = sys.argv[2] if len(sys.argv) > 2 else "/app/agents.yaml"
MANIFEST_OUT = sys.argv[3] if len(sys.argv) > 3 else "/app/state/agents-merged.yaml"
# Loose-file name -> canonical manifest name
NAME_MAP = {
"vpsecops": "vp-secops",
"svpknel": "svp-knel",
"svptctc": "svp-tctc",
"vptechops": "vp-techops",
"vptechcompliance": "vp-techcompliance",
}
def canonical_name(raw: str) -> str:
name = raw.strip().lower().replace("_", "-")
if name in NAME_MAP:
return NAME_MAP[name]
# Generic: vpfoo -> vp-foo, svpfoo -> svp-foo (only when no hyphen yet)
m = re.match(r"^(svp|vp)([a-z].*)$", name)
if m:
return f"{m.group(1)}-{m.group(2)}"
return name
def parse_invite_file(path: str) -> list[dict]:
entries = []
for lineno, line in enumerate(Path(path).read_text().splitlines(), 1):
line = line.strip()
if not line or line.startswith("#"):
continue
if "," not in line:
print(f" line {lineno}: no comma, skipping: {line[:60]}")
continue
raw_name, raw_url = line.split(",", 1)
raw_url = raw_url.strip()
if "inviteToken=" not in raw_url:
print(f" line {lineno}: URL has no inviteToken, skipping: {raw_name.strip()}")
continue
qs = parse_qs(urlparse(raw_url).query)
get1 = lambda k: unquote(qs.get(k, [""])[0]) # noqa: E731
token = get1("inviteToken")
entries.append({
"line": lineno,
"raw_name": raw_name.strip(),
"name": canonical_name(raw_name),
"url": raw_url,
"token": token,
"email": get1("email"),
"username": get1("username"),
"display_name": get1("displayName"),
})
return entries
def main():
invite_file = Path(INVITE_PATH)
if not invite_file.exists():
print(f"ERROR: invite file not found: {INVITE_PATH}")
sys.exit(2)
print(f"=== Parsing {INVITE_PATH} ===")
entries = parse_invite_file(str(invite_file))
if not entries:
print("ERROR: no valid entries parsed")
sys.exit(2)
problems = 0
# Duplicate-token detection (stale copy-paste guard)
seen_tokens: dict[str, dict] = {}
valid = []
for e in entries:
if e["token"] in seen_tokens:
first = seen_tokens[e["token"]]
print(f" !! DUPLICATE TOKEN line {e['line']} ({e['name']}): same invite as "
f"line {first['line']} ({first['name']}) / {first['email']}")
print(f" Skipping {e['name']} -- Charles must issue a fresh invite for it.")
problems += 1
else:
seen_tokens[e["token"]] = e
valid.append(e)
# Cross-check: entry email/username must not equal ANOTHER entry's
# (catches svpknel lines that carry coo's params under a new token)
by_email = {}
for e in valid:
if e["email"] in by_email and e["token"] != by_email[e["email"]]["token"]:
print(f" !! {e['name']} (line {e['line']}) reuses email {e['email']} "
f"already claimed by {by_email[e['email']]['name']}")
problems += 1
else:
by_email[e["email"]] = e
for e in valid:
print(f" {e['name']:20s} email={e['email']:45s} username={e['username']}")
# Load manifest (read-only mount)
data = yaml.safe_load(Path(MANIFEST_IN).read_text())
agents = data.get("agents", [])
by_name = {a["name"]: a for a in agents}
print(f"\n=== Merging into {MANIFEST_OUT} ===")
for e in valid:
agent = by_name.get(e["name"])
if agent is None:
print(f" + {e['name']}: new agent, appending (phase1-only)")
agent = {
"name": e["name"],
"display_name": e["display_name"] or e["name"],
"cloudron_email": e["email"],
"username": e["username"],
"priority": "Q4",
"cloudron_invite": e["url"],
"systems": {},
}
agents.append(agent)
by_name[e["name"]] = agent
else:
old = agent.get("cloudron_invite", "")
if "REPLACE" in old or not old:
agent["cloudron_invite"] = e["url"]
print(f" ~ {e['name']}: invite set")
elif old.strip() == e["url"]:
print(f" = {e['name']}: invite unchanged")
else:
agent["cloudron_invite"] = e["url"]
print(f" ~ {e['name']}: invite REPLACED (was different URL)")
# Sync email/username to what Cloudron actually issued
if e["email"]:
agent["cloudron_email"] = e["email"]
if e["username"]:
agent["username"] = e["username"]
data["agents"] = agents
# Report agents still without a valid invite
print("\n=== Manifest state ===")
for a in agents:
inv = a.get("cloudron_invite", "")
state = "READY" if inv and "REPLACE" not in inv else "NO VALID INVITE"
if state != "READY":
problems += 1
print(f" {a['name']:20s} [{state}] {a.get('display_name', '')}")
out_path = Path(MANIFEST_OUT)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True))
print(f"\nManifest written: {MANIFEST_OUT}")
print(f"Next: cp state/agents-merged.yaml agents.yaml")
sys.exit(1 if problems else 0)
if __name__ == "__main__":
main()
+674 -158
View File
File diff suppressed because it is too large Load Diff
+299
View File
@@ -0,0 +1,299 @@
#!/usr/bin/env python3
"""
provision-discourse-apikey.py -- Generate Discourse User API key for vp-techops.
Discourse User API Keys require an RSA-based flow:
1. Generate RSA key pair
2. Submit public key with the API key request
3. User authorizes the request
4. Decrypt the returned API key with private key
Usage:
docker compose run --rm --entrypoint python3 provision provision-discourse-apikey.py
"""
import base64
import json
import os
import re
import secrets
import sys
import time
import uuid
from pathlib import Path
from urllib.parse import quote_plus
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
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")
DISCOURSE_URL = os.environ.get("DISCOURSE_URL", "https://community.turnsys.com")
STATE_DIR = Path("/app/state")
BW_ITEM = "vp-techops Cloudron"
DISCOURSE_BW_ITEM = "vp-techops Discourse"
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"discourse-apikey-{label}-{ts}.png"), full_page=True)
except Exception:
pass
body = page.evaluate("() => document.body.innerText.substring(0, 500)")
print(f" [{label}] URL: {page.url}")
print(f" Body: {body[:200]}")
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" in page.url.lower() or "openid" in page.url.lower():
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 discourse_sso(page, bw):
"""Login to Discourse via SSO."""
page.goto(DISCOURSE_URL, wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(3000)
if page.query_selector("#current-user, .current-user"):
return True
login_btn = page.locator(".login-button, button:has-text('Log In')")
if login_btn.count() > 0:
login_btn.first.click()
page.wait_for_timeout(3000)
sso_btn = page.locator('button:has-text("OpenID")')
if sso_btn.count() > 0:
sso_btn.first.click()
page.wait_for_timeout(5000)
if "/signup" in page.url:
page.wait_for_timeout(2000)
username_input = page.locator('#new-account-username, input[name="username"]')
if username_input.count() > 0 and username_input.first.is_visible():
username_input.first.click()
page.keyboard.type(USERNAME)
page.wait_for_timeout(1000)
for btn_text in ["Create Account", "Sign Up", "Register"]:
loc = page.locator(f'button:has-text("{btn_text}")')
if loc.count() > 0 and loc.first.is_visible():
loc.first.click()
page.wait_for_timeout(5000)
break
return page.query_selector("#current-user, .current-user") is not None
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()
# Generate RSA key pair for User API Key flow
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()
public_pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("ascii")
print(f"Generated RSA key pair (public key: {len(public_pem)} bytes)")
nonce = secrets.token_hex(16)
client_id = str(uuid.uuid4())
app_name = "TSG-Agent-VP-TechOps"
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 ===")
cloudron_login(page, bw)
discourse_sso(page, bw)
page.goto(DISCOURSE_URL, wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(3000)
logged_in = page.query_selector("#current-user, .current-user") is not None
print(f" Logged in: {logged_in}")
if not logged_in:
print(" FAILED to login")
browser.close()
return
# Step 2: Request User API Key with RSA public key
print("=== STEP 2: Request User API Key ===")
params = (
f"?application_name={quote_plus(app_name)}"
f"&client_id={client_id}"
f"&nonce={nonce}"
f"&scopes=read%2Cwrite"
f"&public_key={quote_plus(public_pem)}"
)
# Capture API responses
api_responses = []
def handle_response(response):
url = response.url
if "user-api-key" in url and response.request.method == "POST":
try:
api_responses.append(response.text())
except Exception:
pass
page.on("response", handle_response)
page.goto(
f"{DISCOURSE_URL}/user-api-key/new{params}",
wait_until="domcontentloaded",
timeout=30000,
)
page.wait_for_timeout(3000)
dump(page, "01-apikey-request")
body = page.evaluate("() => document.body.innerText")
print(f" Page body: {body[:300]}")
# Step 3: Authorize the request
print("=== STEP 3: Authorize ===")
authorized = False
for btn_text in ["Authorize", "Approve", "Continue", "Allow", "Yes"]:
loc = page.locator(f'button:has-text("{btn_text}"), [role="button"]:has-text("{btn_text}"), .btn-primary')
if loc.count() > 0 and loc.first.is_visible():
loc.first.click()
authorized = True
page.wait_for_timeout(3000)
print(f" Clicked: {btn_text}")
break
if not authorized:
# Maybe it's a form with just a submit button
submit = page.locator('button[type="submit"], input[type="submit"], .btn-primary')
if submit.count() > 0 and submit.first.is_visible():
submit.first.click()
authorized = True
page.wait_for_timeout(3000)
print(" Clicked submit button")
dump(page, "02-after-authorize")
# Step 4: Extract and decrypt API key
print("=== STEP 4: Extract API key ===")
api_key = ""
# Check captured POST responses
for resp_text in api_responses:
print(f" Captured response: {resp_text[:200]}")
try:
data = json.loads(resp_text)
encrypted_raw = data.get("key") or data.get("payload") or ""
if encrypted_raw:
encrypted_clean = encrypted_raw.replace("\n", "").replace("\r", "").replace(" ", "")
encrypted_bytes = base64.b64decode(encrypted_clean)
print(f" Encrypted payload: {len(encrypted_bytes)} bytes")
# Try multiple padding schemes (Discourse version-dependent)
paddings = [
("OAEP-SHA256", padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(), label=None)),
("OAEP-SHA1", padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA1()),
algorithm=hashes.SHA1(), label=None)),
("PKCS1v15", padding.PKCS1v15()),
]
for name, pad in paddings:
try:
decrypted = private_key.decrypt(encrypted_bytes, pad).decode("ascii")
# Decrypted payload is JSON: {"key":"...","nonce":"...","push":false,"api":4}
try:
key_data = json.loads(decrypted)
api_key = key_data.get("key", decrypted)
except json.JSONDecodeError:
api_key = decrypted # fallback: key is plaintext
print(f" Decrypted with {name}: {api_key[:12]}...")
break
except Exception:
continue
except Exception as e:
print(f" Decryption failed: {e}")
# If no captured response, check page body for JSON or plaintext key
if not api_key:
body = page.evaluate("() => document.body.innerText")
# Try to find and decrypt the encrypted payload in the page
# The page shows: "please paste the following key..." followed by base64 RSA-encrypted text
key_match = re.search(r'(?:key|application):?\s*\n*\s*([A-Za-z0-9+/\n\r\s={30,}]+)', body)
if key_match:
encrypted = key_match.group(1).replace("\n", "").replace("\r", "").replace(" ", "").strip()
try:
api_key = private_key.decrypt(
base64.b64decode(encrypted),
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
).decode("ascii")
print(f" Decrypted from page body: {api_key[:12]}...")
except Exception:
pass
# Look for unencrypted key (fallback)
if not api_key:
matches = re.findall(r'[a-f0-9]{64}', body)
if matches:
api_key = matches[0]
print(f" Found unencrypted key: {api_key[:12]}...")
if api_key:
print(f"\n API KEY: {api_key}")
print("=== STEP 5: Store in Bitwarden ===")
existing = bw.get_item_id(DISCOURSE_BW_ITEM)
if existing:
bw.update_item(DISCOURSE_BW_ITEM, password=api_key)
print(f" Updated BW item '{DISCOURSE_BW_ITEM}'")
else:
bw.create_item(
name=DISCOURSE_BW_ITEM,
username=USERNAME,
password=api_key,
uris=[DISCOURSE_URL],
collection_name="default",
)
print(f" Created BW item '{DISCOURSE_BW_ITEM}'")
else:
print(" Could not extract API key")
# Dump all visible elements for debugging
body = page.evaluate("() => document.body.innerText")
print(f" Full body: {body[:500]}")
browser.close()
print("\n=== DONE ===")
if __name__ == "__main__":
main()
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""
provision-discourse.py -- Complete Discourse SSO signup and API key extraction.
Flow:
1. Login to Cloudron panel (establish OIDC session)
2. Click Discourse login -> SSO via OpenID Connect
3. Complete signup page (enter username)
4. Check for API key generation options
Usage:
docker compose run --rm --entrypoint python3 provision provision-discourse.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")
DISCOURSE_URL = os.environ.get("DISCOURSE_URL", "https://community.turnsys.com")
STATE_DIR = Path("/app/state")
BW_ITEM = "vp-techops Cloudron"
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"discourse-{label}-{ts}.png"), full_page=True)
except Exception:
pass
body = page.evaluate("() => document.body.innerText.substring(0, 500)")
print(f" [{label}] URL: {page.url}")
print(f" Body: {body[:200]}")
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: Cloudron login
print("=== STEP 1: Cloudron login ===")
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=15000)
page.wait_for_timeout(2000)
if "login" in page.url.lower() or "openid" in page.url.lower():
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)
print(f" Cloudron: {page.url}")
# Step 2: Discourse SSO
print("=== STEP 2: Discourse SSO ===")
page.goto(DISCOURSE_URL, wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(3000)
# Click Log In button
login_btn = page.locator(".login-button")
if login_btn.count() == 0:
login_btn = page.locator('button:has-text("Log In")')
if login_btn.count() > 0:
login_btn.first.click()
page.wait_for_timeout(3000)
dump(page, "01-login-modal")
# Click OpenID Connect button
sso_btn = page.locator('button:has-text("OpenID")')
if sso_btn.count() > 0:
sso_btn.first.click()
page.wait_for_timeout(5000)
dump(page, "02-after-sso")
# Step 3: Handle signup or login complete
print("=== STEP 3: Handle signup/login ===")
if "/signup" in page.url:
print(" On signup page -- need to create account")
# Fill username
page.wait_for_timeout(2000)
# Look for username input
username_input = None
for sel in ['#new-account-username', 'input[name="username"]', 'input#new-account-username']:
loc = page.locator(sel)
if loc.count() > 0 and loc.first.is_visible():
username_input = loc.first
break
if username_input:
username_input.click()
page.keyboard.type(USERNAME)
page.wait_for_timeout(1000)
print(f" Entered username: {USERNAME}")
dump(page, "03-username-entered")
# Look for create/submit button
for btn_text in ["Create Account", "Sign Up", "Register", "Submit", "Continue"]:
loc = page.locator(f'button:has-text("{btn_text}"), [role="button"]:has-text("{btn_text}")')
if loc.count() > 0 and loc.first.is_visible():
loc.first.click()
page.wait_for_timeout(5000)
print(f" Clicked: {btn_text}")
break
dump(page, "04-after-signup")
else:
print(" Could not find username input")
dump(page, "03-no-username-input")
elif "/login" in page.url:
print(" Back on login page")
else:
print(" Appears logged in!")
# Step 4: Check if we're authenticated now
print("=== STEP 4: Verify authentication ===")
page.goto(DISCOURSE_URL, wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(3000)
# Check for user avatar or logged-in indicators
body = page.evaluate("() => document.body.innerText")
has_avatar = page.query_selector("#current-user, .current-user, [data-user-card]")
is_logged_in = has_avatar is not None or USERNAME in body
if is_logged_in:
print(f" Discourse login SUCCESSFUL!")
else:
print(f" Discourse login status unclear")
dump(page, "05-status-check")
# Step 5: Check for API key options
print("=== STEP 5: Check API key options ===")
# Try user API key generation endpoint
page.goto(f"{DISCOURSE_URL}/u/{USERNAME}/preferences/account", wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(3000)
dump(page, "06-account-preferences")
# Look for API key section
body = page.evaluate("() => document.body.innerText")
if "api key" in body.lower():
print(" API key section found!")
else:
print(" No API key section in user preferences")
# Try the user API key generation flow
page.goto(f"{DISCOURSE_URL}/user-api-key/new", wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(3000)
dump(page, "07-user-api-key")
body = page.evaluate("() => document.body.innerText")
print(f" User API key page: {body[:200]}")
browser.close()
print("\n=== DONE ===")
if __name__ == "__main__":
main()
+288
View File
@@ -0,0 +1,288 @@
#!/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()
+2
View File
@@ -4,3 +4,5 @@ pyotp==2.9.0
qrcode==7.4.2
Pillow==10.4.0
pyzbar==0.1.9
pytest==8.3.2
cryptography==44.0.1
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""
test_bw_helper.py -- Tests for Bitwarden credential lifecycle.
Tests the create-read-update cycle with explicit assertions.
The delete operation is intentionally absent -- it does not exist
in BitwardenHelper and never should.
Run inside the provisioner container:
python3 -m pytest test_bw_helper.py -v
"""
import json
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(__file__))
from bw_helper import BitwardenHelper
@pytest.fixture(scope="module")
def bw():
"""Create a connected BitwardenHelper instance."""
helper = 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", ""),
)
helper.login()
return helper
@pytest.fixture
def test_item_name():
"""Test prefix to avoid collision with real credentials."""
return "TEST-LIFECYCLE-CREDENTIAL"
class TestCredentialLifecycle:
"""Test the full credential lifecycle: create, read, update, no-delete."""
def test_create_item(self, bw, test_item_name):
"""Create a credential and verify it exists."""
# Clean up if a previous test left something
item_id = bw.get_item_id(test_item_name)
if item_id:
# Use BW CLI directly -- helper has no delete method by design
bw._run_bw(["delete", "item", item_id])
item_id = bw.create_item(
name=test_item_name,
username="test-user@example.com",
password="OriginalPassword123!",
uris=["https://test.example.com"],
collection_name="test",
)
assert item_id, "create_item should return an item ID"
assert bw.item_exists(test_item_name), "item should exist after creation"
def test_read_password(self, bw, test_item_name):
"""Read back the password we just created."""
pw = bw.get_item_password(test_item_name)
assert pw == "OriginalPassword123!", "password should match what was created"
def test_create_duplicate_rejected(self, bw, test_item_name):
"""create_item must refuse to create a duplicate."""
with pytest.raises(RuntimeError, match="already exists"):
bw.create_item(
name=test_item_name,
username="other@example.com",
password="DifferentPassword!",
uris=["https://other.example.com"],
collection_name="test",
)
def test_update_password(self, bw, test_item_name):
"""Update the password in place -- no new item created."""
original_id = bw.get_item_id(test_item_name)
updated_id = bw.update_item(
test_item_name,
password="UpdatedPassword456@",
)
assert updated_id == original_id, "update must preserve the same item ID"
pw = bw.get_item_password(test_item_name)
assert pw == "UpdatedPassword456@", "password should be updated"
def test_update_totp(self, bw, test_item_name):
"""Add TOTP secret to existing item without creating duplicate."""
original_id = bw.get_item_id(test_item_name)
totp_secret = "JBSWY3DPEHPK3PXP"
updated_id = bw.update_item(test_item_name, totp_secret=totp_secret)
assert updated_id == original_id, "update must preserve the same item ID"
item = bw.get_item(test_item_name)
assert item["login"]["totp"] == totp_secret, "TOTP should be set"
def test_update_preserves_other_fields(self, bw, test_item_name):
"""Updating one field must not blank out others."""
# Update only password
bw.update_item(test_item_name, password="FinalPassword789!")
item = bw.get_item(test_item_name)
# Username should be unchanged
assert item["login"]["username"] == "test-user@example.com", \
"username must be preserved across password update"
# Password should be the new one
assert item["login"]["password"] == "FinalPassword789!", \
"password should be the updated value"
# TOTP should still be there from previous test
assert item["login"].get("totp") == "JBSWY3DPEHPK3PXP", \
"TOTP must be preserved across password update"
def test_get_item_id_ambiguous_raises(self, bw, test_item_name):
"""get_item_id must raise if multiple items share a name."""
# This test verifies the safeguard; we can't easily create a duplicate
# through the API (create_item blocks it), so we test the logic
# by checking it works for a unique name
item_id = bw.get_item_id(test_item_name)
assert item_id is not None, "should find the test item"
def test_item_exists_returns_bool(self, bw, test_item_name):
"""item_exists returns True for existing, False for missing."""
assert bw.item_exists(test_item_name) is True
assert bw.item_exists("NONEXISTENT-ITEM-12345") is False
def test_cleanup(self, bw, test_item_name):
"""Remove the test item using BW CLI directly (test-only)."""
item_id = bw.get_item_id(test_item_name)
if item_id:
bw._run_bw(["delete", "item", item_id])
assert not bw.item_exists(test_item_name), "test item should be cleaned up"
class TestNoDeleteMethod:
"""Verify that BitwardenHelper has no delete capability by design."""
def test_no_delete_item_method(self):
"""BitwardenHelper must not expose a delete_item method."""
assert not hasattr(BitwardenHelper, "delete_item"), \
"BitwardenHelper must NEVER have a delete_item method. " \
"Credential deletion is a manual operation only."
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
test_bw_persistence.py -- Cross-container BW state persistence test.
Verifies that BW items created in one container run are visible in a
subsequent container run (the "state sync" issue from session 1).
This test is designed to be invoked twice:
Run 1 (create): python3 test_bw_persistence.py create
Run 2 (verify): python3 test_bw_persistence.py verify
Run 3 (update): python3 test_bw_persistence.py update
Run 4 (confirm): python3 test_bw_persistence.py confirm
Run 5 (cleanup): python3 test_bw_persistence.py cleanup
Each run is a SEPARATE container invocation. If the bw-state bind mount
and sync logic are working, run 2 will see the item created in run 1,
and run 4 will see the update from run 3.
Usage:
docker compose run --rm --entrypoint python3 provision \
test_bw_persistence.py <phase>
"""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
from bw_helper import BitwardenHelper
TEST_ITEM = "TEST-PERSISTENCE-CROSSCONTAINER"
TEST_PASSWORD_ORIG = "OriginalPersistPassword123!"
TEST_PASSWORD_UPDATED = "UpdatedPersistPassword456@"
def make_helper() -> BitwardenHelper:
return 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", ""),
)
def phase_create():
bw = make_helper()
bw.login()
existing = bw.get_item_id(TEST_ITEM)
if existing:
bw._run_bw(["delete", "item", existing])
item_id = bw.create_item(
name=TEST_ITEM,
username="persist-test@example.com",
password=TEST_PASSWORD_ORIG,
uris=["https://persist.example.com"],
collection_name="test",
)
assert item_id, "create should return an ID"
print(f"CREATE_OK: item_id={item_id}")
print(f"HOST_SYNC_CHECK: run 'bw list items' on host to verify visibility")
def phase_verify():
bw = make_helper()
bw.login()
assert bw.item_exists(TEST_ITEM), \
"FRESH CONTAINER CANNOT SEE ITEM CREATED BY PREVIOUS CONTAINER"
pw = bw.get_item_password(TEST_ITEM)
assert pw == TEST_PASSWORD_ORIG, \
f"Password mismatch: expected {TEST_PASSWORD_ORIG}, got {pw}"
print("VERIFY_OK: item visible in fresh container run")
def phase_update():
bw = make_helper()
bw.login()
original_id = bw.get_item_id(TEST_ITEM)
assert original_id, "item must exist before update"
updated_id = bw.update_item(TEST_ITEM, password=TEST_PASSWORD_UPDATED)
assert updated_id == original_id, "update must preserve item ID"
print(f"UPDATE_OK: item_id={updated_id}")
def phase_confirm():
bw = make_helper()
bw.login()
pw = bw.get_item_password(TEST_ITEM)
assert pw == TEST_PASSWORD_UPDATED, \
f"Update did not persist: expected {TEST_PASSWORD_UPDATED}, got {pw}"
print("CONFIRM_OK: update visible in fresh container run")
def phase_cleanup():
bw = make_helper()
bw.login()
item_id = bw.get_item_id(TEST_ITEM)
if item_id:
bw._run_bw(["delete", "item", item_id])
assert not bw.item_exists(TEST_ITEM), "cleanup failed"
print("CLEANUP_OK")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: test_bw_persistence.py <create|verify|update|confirm|cleanup>")
sys.exit(1)
phase = sys.argv[1]
phases = {
"create": phase_create,
"verify": phase_verify,
"update": phase_update,
"confirm": phase_confirm,
"cleanup": phase_cleanup,
}
if phase not in phases:
print(f"Unknown phase: {phase}")
sys.exit(1)
phases[phase]()
print("SUCCESS")
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""
verify-cloudron-2fa.py -- Verify 2FA round-trip with correct TOTP selector.
The Cloudron OIDC login uses #inputTotpToken (not #inputTotp).
Usage:
docker compose run --rm --entrypoint python3 provision verify-cloudron-2fa.py
"""
import os, sys, time
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")
STATE_DIR = Path("/app/state")
BW_ITEM = "vp-techops Cloudron"
EMAIL = "tsgstaff-coo-vptechops@turnsys.com"
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)
page = browser.new_context(viewport={"width": 1280, "height": 1024}).new_page()
print("=== Fresh login (should prompt for TOTP) ===")
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(3000)
# Check for TOTP token field (correct selector: #inputTotpToken)
totp_input = page.query_selector("#inputTotpToken")
if totp_input and totp_input.is_visible():
print(" TOTP prompt appeared! 2FA is confirmed working.")
totp_code = bw.get_totp(BW_ITEM)
print(f" Entering TOTP code: {totp_code}")
totp_input.click()
page.keyboard.type(totp_code)
page.locator("#totpTokenSubmitButton").click()
page.wait_for_timeout(5000)
print(f" Post-TOTP URL: {page.url}")
if "login" not in page.url.lower() and "openid" not in page.url.lower():
print(" FULL 2FA ROUND-TRIP VERIFIED!")
else:
body = page.evaluate("() => document.body.innerText.substring(0, 200)")
print(f" Still on login/OIDC page. Body: {body}")
else:
print(" WARNING: No TOTP prompt appeared")
print(f" URL: {page.url}")
page.screenshot(path=str(STATE_DIR / "verify-2fa-final.png"))
browser.close()
if __name__ == "__main__":
main()