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_CLIENTSECRET=
|
||||||
BW_PASSWORD=
|
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)
|
# Set to true for debugging (shows browser window — requires display)
|
||||||
HEADFUL=false
|
HEADFUL=false
|
||||||
|
|||||||
@@ -3,3 +3,5 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
.env
|
.env
|
||||||
agents.yaml
|
agents.yaml
|
||||||
|
bw-state/
|
||||||
|
invites.txt
|
||||||
|
|||||||
+19
-3
@@ -4,18 +4,34 @@ RUN apt-get update && \
|
|||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
jq \
|
jq \
|
||||||
unzip \
|
unzip \
|
||||||
|
wget \
|
||||||
|
python3-pip \
|
||||||
libzbar0 \
|
libzbar0 \
|
||||||
libzbar-dev \
|
libzbar-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install Bitwarden CLI
|
# Install Bitwarden CLI — native Rust binary (no Node.js/npm)
|
||||||
RUN npx -y @bitwarden/cli@2026.7.0 || true
|
# 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
|
# Install Python dependencies
|
||||||
COPY requirements.txt /tmp/
|
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
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN chown -R provision:provision /app
|
||||||
|
|
||||||
|
USER provision
|
||||||
|
|
||||||
ENTRYPOINT ["python3", "/app/provision-agent.py"]
|
ENTRYPOINT ["python3", "/app/provision-agent.py"]
|
||||||
|
|||||||
@@ -1,33 +1,55 @@
|
|||||||
# STATUS.md — Agent Identity Provisioning
|
# 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`
|
### Blocked (needs Charles)
|
||||||
- [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
|
|
||||||
|
|
||||||
## Blockers
|
(none currently)
|
||||||
|
|
||||||
- **User must provide:** Cloudron invite links (manifest), BW account credentials
|
### Remaining
|
||||||
- **Discourse admin key:** needed for VP SecOps category creation — assign to vp-techops agent after provisioning
|
|
||||||
|
|
||||||
## 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)
|
## BW Vault State
|
||||||
- Cross-linking audit tracked as #441
|
|
||||||
- BW migration of reachableceo keys tracked as #440 (due Aug 19)
|
| Item | Username | Password | TOTP |
|
||||||
- Discourse admin key → assign to vp-techops agent
|
|---|---|---|---|
|
||||||
- User will create `coo` Linux account + BW account, then run provisioning from dedicated session
|
| 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
|
Agent work only. User actions (deploy, review, UAT) are NOT tracked here.
|
||||||
- [x] Adopt TSYSGroupAIOS framework
|
The human decides when the work is "done".
|
||||||
- [x] Build Dockerfile + docker-compose.yml
|
|
||||||
- [x] Build manifest template (agents.yaml.example)
|
## Current Tasks
|
||||||
- [x] Write provision-agent.py
|
|
||||||
- [x] Write bw-helper.py
|
- [ ] Add update_item() to bw_helper.py (root cause of credential deletion)
|
||||||
- [x] Install git hooks
|
- [ ] Fix --enable-2fa to update item in place, never create duplicates
|
||||||
- [x] Shellcheck on all scripts
|
- [ ] Add duplicate-prevention safeguard to create_item()
|
||||||
- [x] Commit + push
|
- [ ] 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:
|
agents:
|
||||||
- name: vp-techops
|
- name: vp-techops
|
||||||
display_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
|
priority: Q3
|
||||||
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
|
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
|
||||||
systems:
|
systems:
|
||||||
@@ -28,6 +31,7 @@ agents:
|
|||||||
|
|
||||||
- name: vp-secops
|
- name: vp-secops
|
||||||
display_name: "VP SecOps"
|
display_name: "VP SecOps"
|
||||||
|
cloudron_email: "vp-secops@turnsys.com"
|
||||||
priority: Q3
|
priority: Q3
|
||||||
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
|
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
|
||||||
systems:
|
systems:
|
||||||
@@ -46,6 +50,7 @@ agents:
|
|||||||
|
|
||||||
- name: vp-techcompliance
|
- name: vp-techcompliance
|
||||||
display_name: "VP TechCompliance"
|
display_name: "VP TechCompliance"
|
||||||
|
cloudron_email: "vp-techcompliance@turnsys.com"
|
||||||
priority: Q3
|
priority: Q3
|
||||||
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
|
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
|
||||||
systems:
|
systems:
|
||||||
@@ -65,18 +70,21 @@ agents:
|
|||||||
# Q4 agents — enroll in Cloudron only (Phase 1), no system access yet
|
# Q4 agents — enroll in Cloudron only (Phase 1), no system access yet
|
||||||
- name: coo
|
- name: coo
|
||||||
display_name: "Chief Operating Officer"
|
display_name: "Chief Operating Officer"
|
||||||
|
cloudron_email: "coo@turnsys.com"
|
||||||
priority: Q4
|
priority: Q4
|
||||||
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
|
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
|
||||||
systems: {}
|
systems: {}
|
||||||
|
|
||||||
- name: svp-knel
|
- name: svp-knel
|
||||||
display_name: "SVP KNEL"
|
display_name: "SVP KNEL"
|
||||||
|
cloudron_email: "svp-knel@turnsys.com"
|
||||||
priority: Q4
|
priority: Q4
|
||||||
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
|
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
|
||||||
systems: {}
|
systems: {}
|
||||||
|
|
||||||
- name: svp-tctc
|
- name: svp-tctc
|
||||||
display_name: "SVP TCTC"
|
display_name: "SVP TCTC"
|
||||||
|
cloudron_email: "svp-tctc@turnsys.com"
|
||||||
priority: Q4
|
priority: Q4
|
||||||
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
|
cloudron_invite: "https://tsys-cloudron.knel.net/invitation/REPLACE_WITH_TOKEN"
|
||||||
systems: {}
|
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:
|
provision:
|
||||||
build: .
|
build: .
|
||||||
container_name: tsys-agent-provisioner
|
container_name: tsys-agent-provisioner
|
||||||
environment:
|
env_file:
|
||||||
- BW_CLIENTID=${BW_CLIENTID}
|
- ${BW_ENV_FILE:-${HOME}/.config/bw/env}
|
||||||
- BW_CLIENTSECRET=${BW_CLIENTSECRET}
|
user: "provision"
|
||||||
- BW_PASSWORD=${BW_PASSWORD}
|
|
||||||
- HEADFUL=${HEADFUL:-false}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./agents.yaml:/app/agents.yaml:ro
|
- ./agents.yaml:/app/agents.yaml:ro
|
||||||
- ./state:/app/state
|
- ./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()
|
||||||
+690
-174
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
|
qrcode==7.4.2
|
||||||
Pillow==10.4.0
|
Pillow==10.4.0
|
||||||
pyzbar==0.1.9
|
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