Compare commits
15
Commits
8ce279276f
...
5e1d043890
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e1d043890 | ||
|
|
6d89f16610 | ||
|
|
f414b0b7ff | ||
|
|
2258e1bd04 | ||
|
|
fcd484ff97 | ||
|
|
2d01a9f962 | ||
|
|
be2f607839 | ||
|
|
f633a10f80 | ||
|
|
c0eb1b383b | ||
|
|
f638105614 | ||
|
|
04ece5234a | ||
|
|
3569a09afd | ||
|
|
8c90d6809b | ||
|
|
7534964c13 | ||
|
|
9b4502f55d |
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
agents.yaml
|
||||
state/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.md
|
||||
.crush/
|
||||
@@ -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
|
||||
|
||||
@@ -3,3 +3,5 @@ __pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
agents.yaml
|
||||
bw-state/
|
||||
invites.txt
|
||||
|
||||
+19
-3
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user