Compare commits
4
Commits
5e1d043890
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4a54f553e | ||
|
|
1b8bd843d9 | ||
|
|
c461f85efa | ||
|
|
87298df1ba |
@@ -1,55 +1,52 @@
|
|||||||
# STATUS.md — Agent Identity Provisioning
|
# STATUS.md — Agent Identity Provisioning
|
||||||
|
|
||||||
**Last updated:** 2026-08-13 (Session 2)
|
**Last updated:** 2026-08-14 (Session 3)
|
||||||
**Phase:** Active development — vp-techops provisioning in progress
|
**Phase:** 10/10 agent identities enrolled in Cloudron with TOTP 2FA. App credentials partial.
|
||||||
|
|
||||||
## Current State (Session 2)
|
## Identity Matrix (session 3 end)
|
||||||
|
|
||||||
### Completed
|
| Agent | Cloudron | 2FA | Gitea | Discourse | Redmine |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| vp-techops | ✅ | ✅ | ✅ token+verified | ✅ user key | ✅ key verified |
|
||||||
|
| vp-secops | ✅ | ✅ | ✅ token+verified | ❌ see blocker | ❌ see blocker |
|
||||||
|
| vp-techcompliance | ✅ | ✅ | ✅ token+verified | ❌ see blocker | ❌ see blocker |
|
||||||
|
| coo | ✅ | ✅ | Q4 phase1 | Q4 phase1 | Q4 phase1 |
|
||||||
|
| svp-knel | ✅ | ✅ | Q4 phase1 | Q4 phase1 | Q4 phase1 |
|
||||||
|
| svp-tctc | ✅ | ✅ | Q4 phase1 | Q4 phase1 | Q4 phase1 |
|
||||||
|
| vp-investing | ✅ | ✅ | Q4 phase1 | Q4 phase1 | Q4 phase1 |
|
||||||
|
| vp-trading | ✅ | ✅ | Q4 phase1 | Q4 phase1 | Q4 phase1 |
|
||||||
|
| vp-compliance | ✅ | ✅ | Q4 phase1 | Q4 phase1 | Q4 phase1 |
|
||||||
|
| vp-facilities | ✅ | ✅ | Q4 phase1 | Q4 phase1 | Q4 phase1 |
|
||||||
|
|
||||||
- [x] **BW state sync fixed** — added `sync()` to `login()` lifecycle; 5-phase cross-container persistence test passes
|
BW vault: 15 items across 10 agents (see JOURNAL.md session 3 table).
|
||||||
- [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)
|
|
||||||
|
|
||||||
### Blocked (needs Charles)
|
## Blockers (Charles actions)
|
||||||
|
|
||||||
(none currently)
|
- **Redmine for vp-secops/vp-techcompliance**: Cloudron app access not
|
||||||
|
granted (OIDC bounce-back to /login, same class as session-2 Redmine fix).
|
||||||
|
- **Discourse for vp-secops/vp-techcompliance**: OIDC email auth works and
|
||||||
|
the signup form appears each run (account creation not persisting).
|
||||||
|
Suspect the "must approve users" site setting or a signup validation
|
||||||
|
error -- needs a look at Discourse admin. API cannot diagnose (user
|
||||||
|
keys get 404 on /u/*.json).
|
||||||
|
- **vp-techops stale Gitea tokens** from session 1 (revoke all but stored).
|
||||||
|
- **vptechops org perms**: currently broad (admin on repos/orgs used
|
||||||
|
during bootstrap). Dial back once per-agent ownership is settled.
|
||||||
|
|
||||||
### Remaining
|
## Tooling State
|
||||||
|
|
||||||
- [ ] Gitea token cleanup (multiple stale tokens may exist from session 1 iterations)
|
- Provisioner loop proven at scale: 8-agent batch run + cleanup pass.
|
||||||
- [ ] Integrate all flows into provision-agent.py main script
|
Invite acceptance + 2FA selectors verified on all 10 identities.
|
||||||
- [ ] Provision remaining agents (vp-secops, vp-techcompliance, coo, svp-knel, svp-tctc)
|
- tea configured (login `git.knownelement.com`, vptechops) + git credential
|
||||||
|
helper installed -- NOTE: pushes from this host authenticate as
|
||||||
|
vptechops globally.
|
||||||
|
- Repo moved: KNEL/agent-identity-provisioning (transferred from
|
||||||
|
TSYSGroupCorporate; owned by VP TechOps). All work pushed.
|
||||||
|
|
||||||
## BW Vault State
|
## Next
|
||||||
|
|
||||||
| Item | Username | Password | TOTP |
|
- Clear the two blockers above, then re-run
|
||||||
|---|---|---|---|
|
`docker compose run --rm provision --agent vp-secops` (idempotent).
|
||||||
| vp-techops Cloudron | tsgstaff-coo-vptechops@turnsys.com | 32 chars | Enabled |
|
- Add director/IC identities to agents.yaml once H22026.md settles the
|
||||||
| vp-techops Discourse | vptechops | 32-char API key | N/A |
|
org chart (worker/reviewer pairs, audit independence).
|
||||||
| vp-techops Gitea | vptechops | 40-char token | N/A |
|
- Q4 agents: fill `systems:` blocks as their charters firm up.
|
||||||
| 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)
|
|
||||||
|
|||||||
+56
-4
@@ -24,6 +24,7 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ class BitwardenHelper:
|
|||||||
self.totp_secret = totp_secret
|
self.totp_secret = totp_secret
|
||||||
self.server_url = server_url
|
self.server_url = server_url
|
||||||
self.session: Optional[str] = None
|
self.session: Optional[str] = None
|
||||||
|
self._last_sync: float = 0.0
|
||||||
|
|
||||||
def _run_bw(self, args: list[str], capture: bool = True) -> str:
|
def _run_bw(self, args: list[str], capture: bool = True) -> str:
|
||||||
"""Run a bw CLI command with the active session."""
|
"""Run a bw CLI command with the active session."""
|
||||||
@@ -121,14 +123,59 @@ class BitwardenHelper:
|
|||||||
not appear in list/search results.
|
not appear in list/search results.
|
||||||
"""
|
"""
|
||||||
self._run_bw(["sync"])
|
self._run_bw(["sync"])
|
||||||
|
self._last_sync = time.monotonic()
|
||||||
|
|
||||||
|
def _sync_if_stale(self, max_age_s: float = 30.0) -> None:
|
||||||
|
"""Re-sync if the cache is older than max_age_s seconds.
|
||||||
|
|
||||||
|
Multiple containers share this vault (host wrapper, provisioner
|
||||||
|
runs). A read performed on a stale cache sees ghosts: items that
|
||||||
|
exist server-side appear missing (or vice versa). Cheap enough
|
||||||
|
to run before every read.
|
||||||
|
"""
|
||||||
|
if time.monotonic() - self._last_sync > max_age_s:
|
||||||
|
self.sync()
|
||||||
|
|
||||||
|
def _run_bw_with_retry(self, args: list[str], retries: int = 3) -> str:
|
||||||
|
"""Run a bw command, retrying on transient failures.
|
||||||
|
|
||||||
|
The bw CLI occasionally returns empty output or non-JSON errors
|
||||||
|
under load (observed: empty create response, 'Expecting value'
|
||||||
|
JSON decode upstream). Retry with backoff before failing.
|
||||||
|
"""
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
try:
|
||||||
|
return self._run_bw(args)
|
||||||
|
except (RuntimeError, json.JSONDecodeError) as e:
|
||||||
|
last_err = e
|
||||||
|
msg = str(e)
|
||||||
|
# Non-retryable failures: re-raise immediately
|
||||||
|
if "not found" in msg.lower() or "already exists" in msg.lower():
|
||||||
|
raise
|
||||||
|
if attempt < retries:
|
||||||
|
delay = 2 * attempt
|
||||||
|
time.sleep(delay)
|
||||||
|
self.sync()
|
||||||
|
raise RuntimeError(
|
||||||
|
f"bw {' '.join(args)} failed after {retries} retries: {last_err}"
|
||||||
|
)
|
||||||
|
|
||||||
def generate_password(self, length: int = 32) -> str:
|
def generate_password(self, length: int = 32) -> str:
|
||||||
"""Generate a strong password."""
|
"""Generate a strong password."""
|
||||||
return self._run_bw(["generate", "-ulns", "--length", str(length)])
|
return self._run_bw(["generate", "-ulns", "--length", str(length)])
|
||||||
|
|
||||||
def get_totp(self, item_name: str) -> str:
|
def get_totp(self, item_name: str) -> str:
|
||||||
"""Get the current TOTP code for a Bitwarden item."""
|
"""Get the current TOTP code for a Bitwarden item.
|
||||||
return self._run_bw(["get", "totp", item_name])
|
|
||||||
|
Resolves via exact-name match first -- bw's search is fuzzy and
|
||||||
|
shared substrings (e.g. "coo" in every tsgstaff-coo-* username)
|
||||||
|
make name-based gets ambiguous.
|
||||||
|
"""
|
||||||
|
item_id = self.get_item_id(item_name)
|
||||||
|
if not item_id:
|
||||||
|
raise RuntimeError(f"Item '{item_name}' not found")
|
||||||
|
return self._run_bw(["get", "totp", item_id])
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
# Item ID resolution -- the safe way to reference items
|
# Item ID resolution -- the safe way to reference items
|
||||||
@@ -139,8 +186,11 @@ class BitwardenHelper:
|
|||||||
|
|
||||||
Returns the item ID if exactly one match exists, None if no match,
|
Returns the item ID if exactly one match exists, None if no match,
|
||||||
and raises RuntimeError if multiple items share the name (ambiguous).
|
and raises RuntimeError if multiple items share the name (ambiguous).
|
||||||
|
Always syncs if the cache is stale -- containers share this vault
|
||||||
|
and a stale cache sees ghosts.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
self._sync_if_stale()
|
||||||
output = self._run_bw(["list", "items", "--search", name])
|
output = self._run_bw(["list", "items", "--search", name])
|
||||||
items = json.loads(output)
|
items = json.loads(output)
|
||||||
# Filter to exact name matches (bw search is fuzzy)
|
# Filter to exact name matches (bw search is fuzzy)
|
||||||
@@ -220,8 +270,9 @@ class BitwardenHelper:
|
|||||||
item["collectionIds"] = [collection_id]
|
item["collectionIds"] = [collection_id]
|
||||||
|
|
||||||
encoded_item = self._encode(item)
|
encoded_item = self._encode(item)
|
||||||
output = self._run_bw(["create", "item", encoded_item])
|
output = self._run_bw_with_retry(["create", "item", encoded_item])
|
||||||
created = json.loads(output)
|
created = json.loads(output)
|
||||||
|
self.sync()
|
||||||
return created.get("id", "")
|
return created.get("id", "")
|
||||||
|
|
||||||
def update_item(
|
def update_item(
|
||||||
@@ -263,8 +314,9 @@ class BitwardenHelper:
|
|||||||
]
|
]
|
||||||
|
|
||||||
encoded_item = self._encode(current)
|
encoded_item = self._encode(current)
|
||||||
output = self._run_bw(["edit", "item", item_id, encoded_item])
|
output = self._run_bw_with_retry(["edit", "item", item_id, encoded_item])
|
||||||
updated = json.loads(output)
|
updated = json.loads(output)
|
||||||
|
self.sync()
|
||||||
return updated.get("id", item_id)
|
return updated.get("id", item_id)
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
diagnose-sso-chain.py -- Trace the full SSO navigation chain for one agent.
|
||||||
|
|
||||||
|
Captures every navigation, page title, and visible messages during:
|
||||||
|
1. Cloudron panel login (establishes OIDC session)
|
||||||
|
2. Redmine SSO attempt (click -> ... -> final URL)
|
||||||
|
3. Discourse SSO attempt (modal -> OIDC -> signup/login -> final state)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
docker compose run --rm --entrypoint python3 \
|
||||||
|
-e AGENT=vp-secops provision diagnose-sso-chain.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 = "https://my.knownelement.com"
|
||||||
|
REDMINE_URL = "https://projects.knownelement.com"
|
||||||
|
DISCOURSE_URL = "https://community.turnsys.com"
|
||||||
|
STATE_DIR = Path("/app/state")
|
||||||
|
|
||||||
|
AGENT = os.environ.get("AGENT", "vp-secops")
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot(page, label, trail):
|
||||||
|
body = ""
|
||||||
|
try:
|
||||||
|
body = page.evaluate("() => document.body.innerText.substring(0, 250)")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
entry = f"[{label}] {page.url}\n body: {' | '.join(body.splitlines()[:6])}"
|
||||||
|
trail.append(entry)
|
||||||
|
print(entry)
|
||||||
|
|
||||||
|
|
||||||
|
def cloudron_login(page, bw):
|
||||||
|
item = f"{AGENT} Cloudron"
|
||||||
|
email_map = {
|
||||||
|
"vp-secops": "tsgstaff-coo-vpsecops@turnsys.com",
|
||||||
|
"vp-techcompliance": "tsgstaff-coo-vptechcompliance@turnsys.com",
|
||||||
|
}
|
||||||
|
email = email_map.get(AGENT, f"{AGENT}@turnsys.com")
|
||||||
|
password = bw.get_item_password(item)
|
||||||
|
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="domcontentloaded", timeout=30000)
|
||||||
|
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(4000)
|
||||||
|
totp = page.query_selector("#inputTotpToken")
|
||||||
|
if totp and totp.is_visible():
|
||||||
|
totp.click()
|
||||||
|
page.keyboard.type(bw.get_totp(item))
|
||||||
|
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()
|
||||||
|
|
||||||
|
trail = []
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
context = browser.new_context(viewport={"width": 1280, "height": 1024})
|
||||||
|
page = context.new_page()
|
||||||
|
|
||||||
|
page.on("framenavigated", lambda frame: None) # no-op; we poll manually
|
||||||
|
|
||||||
|
print(f"=== {AGENT}: Cloudron login ===")
|
||||||
|
cloudron_login(page, bw)
|
||||||
|
snapshot(page, "cloudron", trail)
|
||||||
|
|
||||||
|
print(f"\n=== {AGENT}: Redmine SSO chain ===")
|
||||||
|
page.goto(f"{REDMINE_URL}/login", wait_until="domcontentloaded", timeout=30000)
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
snapshot(page, "redmine-login", trail)
|
||||||
|
page.locator("#login-oauth-submit-1").first.click()
|
||||||
|
for i in range(6):
|
||||||
|
page.wait_for_timeout(1500)
|
||||||
|
snapshot(page, f"redmine+{(i+1)*1.5}s", trail)
|
||||||
|
if "projects.knownelement.com" in page.url and "/login" not in page.url:
|
||||||
|
break
|
||||||
|
# capture any flash error
|
||||||
|
err = page.evaluate("""() => {
|
||||||
|
const f = document.querySelector('#flash_notice, #flash_error, .flash, .error, .message');
|
||||||
|
return f ? f.textContent.trim() : '';
|
||||||
|
}""")
|
||||||
|
print(f" flash/error element: {err!r}")
|
||||||
|
|
||||||
|
print(f"\n=== {AGENT}: Discourse SSO chain ===")
|
||||||
|
page.goto(DISCOURSE_URL, wait_until="domcontentloaded", timeout=30000)
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
snapshot(page, "discourse-home", trail)
|
||||||
|
login_btn = page.locator(".login-button")
|
||||||
|
if login_btn.count() > 0:
|
||||||
|
login_btn.first.click()
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
snapshot(page, "discourse-modal", trail)
|
||||||
|
sso = page.locator('button:has-text("OpenID")')
|
||||||
|
if sso.count() > 0:
|
||||||
|
sso.first.click()
|
||||||
|
for i in range(6):
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
snapshot(page, f"discourse+{(i+1)*2}s", trail)
|
||||||
|
if "/signup" not in page.url and "/login" not in page.url:
|
||||||
|
break
|
||||||
|
if "/signup" in page.url:
|
||||||
|
body = page.evaluate("() => document.body.innerText")
|
||||||
|
print(f" SIGNUP PAGE body: {body[:300]!r}")
|
||||||
|
|
||||||
|
page.screenshot(path=str(STATE_DIR / f"sso-chain-{AGENT}-{time.strftime('%H%M%S')}.png"),
|
||||||
|
full_page=True)
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
(STATE_DIR / f"sso-chain-{AGENT}-{time.strftime('%H%M%S')}.txt").write_text(
|
||||||
|
"\n".join(trail))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
diagnose-vp-compliance.py -- Reproduce the vp-compliance login with full
|
||||||
|
DOM dumps at every step to find where it diverges from the other agents.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
docker compose run --rm --entrypoint python3 provision diagnose-vp-compliance.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 = "https://my.knownelement.com"
|
||||||
|
STATE_DIR = Path("/app/state")
|
||||||
|
ITEM = "vp-compliance Cloudron"
|
||||||
|
EMAIL = "tsgstaff-coo-vpcompliance@turnsys.com"
|
||||||
|
|
||||||
|
|
||||||
|
def dump(page, label):
|
||||||
|
ts = time.strftime("%H%M%S")
|
||||||
|
page.screenshot(path=str(STATE_DIR / f"diag-vpc-{label}-{ts}.png"), full_page=True)
|
||||||
|
body = page.evaluate("() => document.body.innerText.substring(0, 300)")
|
||||||
|
els = page.evaluate("""() => Array.from(document.querySelectorAll(
|
||||||
|
'input, [role="button"], button, .ui.message')).map(el => ({
|
||||||
|
tag: el.tagName, id: el.id, type: el.type||'',
|
||||||
|
vis: el.offsetParent !== null, text: (el.textContent||'').trim().substring(0,50)}))""")
|
||||||
|
print(f"--- {label} ---")
|
||||||
|
print(f"URL: {page.url}")
|
||||||
|
print(f"BODY: {body[:200]}")
|
||||||
|
for e in els:
|
||||||
|
print(f" <{e['tag']}> id={e['id']} type={e['type']} vis={e['vis']} text={e['text']!r}")
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
# Resolve by exact name to dodge the fuzzy-search ambiguity
|
||||||
|
item_id = bw.get_item_id(ITEM)
|
||||||
|
item = json.loads(bw._run_bw(["get", "item", item_id]))
|
||||||
|
password = item["login"]["password"]
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
context = browser.new_context(viewport={"width": 1280, "height": 1024})
|
||||||
|
page = context.new_page()
|
||||||
|
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=20000)
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
dump(page, "01-login-page")
|
||||||
|
|
||||||
|
page.wait_for_selector("#inputPassword", timeout=15000)
|
||||||
|
page.click("#inputUsername")
|
||||||
|
page.keyboard.type(EMAIL)
|
||||||
|
page.click("#inputPassword")
|
||||||
|
page.keyboard.type(password)
|
||||||
|
dump(page, "02-filled")
|
||||||
|
|
||||||
|
btn = page.locator('[role="button"]:has-text("Log in")')
|
||||||
|
print(f"login button count={btn.count()} visible={btn.first.is_visible() if btn.count() else '?'}")
|
||||||
|
print(f"login button classes: {btn.first.get_attribute('class') if btn.count() else '?'}")
|
||||||
|
btn.first.click()
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
dump(page, "03-after-submit")
|
||||||
|
|
||||||
|
totp = page.query_selector("#inputTotpToken")
|
||||||
|
print(f"TOTP field present={totp is not None} visible={totp.is_visible() if totp else False}")
|
||||||
|
if totp and totp.is_visible():
|
||||||
|
code = bw._run_bw(["get", "totp", item_id])
|
||||||
|
totp.click()
|
||||||
|
page.keyboard.type(code)
|
||||||
|
page.locator("#totpTokenSubmitButton").click()
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
dump(page, "04-after-totp")
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
import json # noqa: E402
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -22,3 +22,10 @@ services:
|
|||||||
- ./provision-discourse-apikey.py:/app/provision-discourse-apikey.py:ro
|
- ./provision-discourse-apikey.py:/app/provision-discourse-apikey.py:ro
|
||||||
- ./provision-redmine.py:/app/provision-redmine.py:ro
|
- ./provision-redmine.py:/app/provision-redmine.py:ro
|
||||||
- ./merge-invites.py:/app/merge-invites.py:ro
|
- ./merge-invites.py:/app/merge-invites.py:ro
|
||||||
|
- ./dump-invite-page.py:/app/dump-invite-page.py:ro
|
||||||
|
- ./validate-all-logins.py:/app/validate-all-logins.py:ro
|
||||||
|
- ./diagnose-vp-compliance.py:/app/diagnose-vp-compliance.py:ro
|
||||||
|
- ./validate-coo.py:/app/validate-coo.py:ro
|
||||||
|
- ./diagnose-sso-chain.py:/app/diagnose-sso-chain.py:ro
|
||||||
|
- ./probe-redmine-access.py:/app/probe-redmine-access.py:ro
|
||||||
|
- ./probe-discourse-signup.py:/app/probe-discourse-signup.py:ro
|
||||||
|
|||||||
@@ -82,3 +82,56 @@ Override with an explicit `username:` field in agents.yaml.
|
|||||||
| Gitea | 40-char token | `GET /api/v1/user` -> vptechops |
|
| Gitea | 40-char token | `GET /api/v1/user` -> vptechops |
|
||||||
| Discourse | 32-char user key | `GET /latest.json` with User-Api-Key |
|
| Discourse | 32-char user key | `GET /latest.json` with User-Api-Key |
|
||||||
| Redmine | 40-char API key | `GET /users/current.json` -> id 11 |
|
| Redmine | 40-char API key | `GET /users/current.json` -> id 11 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-08-14 — Session 3: 8 agents enrolled, repo transfer, KNEL org layout
|
||||||
|
|
||||||
|
**Commits:** a96990d (org-buildout), 657939d/e3e5451 (KNELSecretsManager),
|
||||||
|
c564531 (TSYSGroupAIOS), 87298df (aip)
|
||||||
|
|
||||||
|
### Decisions
|
||||||
|
|
||||||
|
1. **Repo ownership split**: org-buildout is docs-only (BW CLI source
|
||||||
|
moved out); BW CLI lives in KNEL/KNELSecretsManager with ADR-002;
|
||||||
|
agent-identity-provisioning transferred TSYSGroupCorporate -> KNEL,
|
||||||
|
owned by VP TechOps. All 31 KNEL repos cloned to ~/projects/KNEL/.
|
||||||
|
2. **Gitea links commits to accounts by EMAIL.** Registries must use
|
||||||
|
Cloudron-issued addresses (tsgstaff-coo-*@turnsys.com), not synthetic
|
||||||
|
ones. Fixed in clone-as.sh/agent-profile.sh.
|
||||||
|
3. **tea credential helper** installed globally (vptechops token) --
|
||||||
|
clones over HTTPS work with clean URLs. NOTE: all pushes from this
|
||||||
|
host to git.knownelement.com now authenticate as vptechops; watch
|
||||||
|
attribution when pushing from TSGCOO-owned repos.
|
||||||
|
|
||||||
|
### Patterns proven this session
|
||||||
|
|
||||||
|
**Invite acceptance (setupaccount.html):** fields #inputDisplayName,
|
||||||
|
#inputPassword, #inputPasswordRepeat (username prefilled). Pankow forms
|
||||||
|
require click + keyboard.type. Submit is <div role="button"> "Set up",
|
||||||
|
disabled until form valid.
|
||||||
|
|
||||||
|
**2FA on fresh accounts:** after acceptance the SPA sits on
|
||||||
|
setupaccount.html; hash navigation cannot leave it -- goto panel root
|
||||||
|
first. Forced-2FA screen says "Set up passkey" (profile says "Setup")
|
||||||
|
-- match both. TOTP flow identical to session 2.
|
||||||
|
|
||||||
|
### Results (10 identities)
|
||||||
|
|
||||||
|
| Agent | Cloudron | 2FA | Gitea | Discourse | Redmine |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| vp-techops | yes | yes | token | key | key |
|
||||||
|
| vp-secops | yes | 2nd pass | BLOCKED (no app access) | acct created, retry | BLOCKED |
|
||||||
|
| vp-techcompliance | yes | 2nd pass | BLOCKED (no app access) | acct created, retry | BLOCKED |
|
||||||
|
| coo/svp-knel/svp-tctc | yes | yes | - (Q4 phase1) | - | - |
|
||||||
|
| vp-investing/vp-trading | yes | yes | - | - | - |
|
||||||
|
| vp-compliance | yes | yes | - | - | - |
|
||||||
|
| vp-facilities | cleanup pass | cleanup pass | - | - | - |
|
||||||
|
|
||||||
|
### Open items for Charles
|
||||||
|
|
||||||
|
- Grant Gitea/Redmine (and Discourse approval if required) app access
|
||||||
|
for vp-secops and vp-techcompliance -- Gitea OIDC returns
|
||||||
|
"The authorization request was denied." until granted (same class as
|
||||||
|
the session-2 Redmine blocker).
|
||||||
|
- vp-techops stale Gitea tokens cleanup still pending.
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
dump-invite-page.py -- DOM dump of a Cloudron invite/setup page (pre-acceptance).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
docker compose run --rm --entrypoint python3 \
|
||||||
|
-e AGENT=vp-secops provision dump-invite-page.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os, sys, time
|
||||||
|
from pathlib import Path
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
STATE_DIR = Path("/app/state")
|
||||||
|
|
||||||
|
AGENT = os.environ.get("AGENT", "vp-secops")
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
with open("/app/agents.yaml") as f:
|
||||||
|
manifest = yaml.safe_load(f)
|
||||||
|
agent = next(a for a in manifest["agents"] if a["name"] == AGENT)
|
||||||
|
invite_url = agent["cloudron_invite"]
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
page = browser.new_context(viewport={"width": 1280, "height": 1024}).new_page()
|
||||||
|
page.goto(invite_url, wait_until="networkidle", timeout=20000)
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
|
||||||
|
ts = time.strftime("%H%M%S")
|
||||||
|
page.screenshot(path=str(STATE_DIR / f"invite-{AGENT}-{ts}.png"), full_page=True)
|
||||||
|
elements = page.evaluate("""() => {
|
||||||
|
const out = [];
|
||||||
|
document.querySelectorAll('input, button, [role="button"], label, a, h1, h2, h3, form').forEach(el => {
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
const text = (el.textContent || '').trim().substring(0, 60);
|
||||||
|
const id = el.id || '';
|
||||||
|
const type = el.getAttribute('type') || '';
|
||||||
|
const cls = (el.getAttribute('class') || '').substring(0, 50);
|
||||||
|
const role = el.getAttribute('role') || '';
|
||||||
|
const vis = el.offsetParent !== null;
|
||||||
|
out.push(`<${tag}> id=${id} type=${type} role=${role} class=${cls} vis=${vis} text="${text}"`);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}""")
|
||||||
|
body = page.evaluate("() => document.body.innerText.substring(0, 400)")
|
||||||
|
out = f"URL: {page.url}\n\nBODY:\n{body}\n\nELEMENTS:\n" + "\n".join(elements)
|
||||||
|
(STATE_DIR / f"invite-{AGENT}-{ts}.txt").write_text(out)
|
||||||
|
print(out)
|
||||||
|
browser.close()
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
probe-discourse-signup.py -- Dump Discourse signup validation state.
|
||||||
|
|
||||||
|
Gets an agent to the OIDC-authenticated /signup page, fills a candidate
|
||||||
|
username, clicks Create Account, and dumps every field hint/error plus
|
||||||
|
the page state 8s later. Reveals why "account created" never sticks.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
docker compose run --rm --entrypoint python3 \
|
||||||
|
-e AGENT=vp-secops provision probe-discourse-signup.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 = "https://my.knownelement.com"
|
||||||
|
DISCOURSE_URL = "https://community.turnsys.com"
|
||||||
|
STATE_DIR = Path("/app/state")
|
||||||
|
AGENT = os.environ.get("AGENT", "vp-secops")
|
||||||
|
|
||||||
|
EMAILS = {
|
||||||
|
"vp-secops": "tsgstaff-coo-vpsecops@turnsys.com",
|
||||||
|
"svp-knel": "tsgstaff-coo-svpknel@turnsys.com",
|
||||||
|
}
|
||||||
|
USERNAME = AGENT.replace("-", "")
|
||||||
|
|
||||||
|
|
||||||
|
def field_state(page):
|
||||||
|
return page.evaluate("""() => {
|
||||||
|
const out = [];
|
||||||
|
document.querySelectorAll('input, .tip, .invalid, .good, .bad, .warning, [class*="hint"]').forEach(el => {
|
||||||
|
const id = el.id || '';
|
||||||
|
const val = (el.value || '').substring(0, 40);
|
||||||
|
const text = (el.textContent || '').trim().substring(0, 100);
|
||||||
|
const cls = (el.getAttribute('class') || '').substring(0, 60);
|
||||||
|
const vis = el.offsetParent !== null;
|
||||||
|
if (text || val || id) out.push(`<${el.tagName.toLowerCase()}> id=${id} class=${cls} value="${val}" vis=${vis} text="${text}"`);
|
||||||
|
});
|
||||||
|
return out.join('\\n');
|
||||||
|
}""")
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
item = f"{AGENT} Cloudron"
|
||||||
|
email = EMAILS.get(AGENT, f"{AGENT}@turnsys.com")
|
||||||
|
password = bw.get_item_password(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()
|
||||||
|
|
||||||
|
# Panel login
|
||||||
|
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=30000)
|
||||||
|
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(4000)
|
||||||
|
totp = page.query_selector("#inputTotpToken")
|
||||||
|
if totp and totp.is_visible():
|
||||||
|
totp.click()
|
||||||
|
page.keyboard.type(bw.get_totp(item))
|
||||||
|
page.locator("#totpTokenSubmitButton").click()
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
print(f"PANEL: {page.url}")
|
||||||
|
|
||||||
|
# Discourse: login modal -> OpenID
|
||||||
|
page.goto(DISCOURSE_URL, wait_until="domcontentloaded", timeout=30000)
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
page.locator(".login-button").first.click()
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
page.locator('button:has-text("OpenID")').first.click()
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
|
||||||
|
# Handle any OIDC interaction
|
||||||
|
for _ in range(4):
|
||||||
|
pw = page.query_selector("#inputPassword")
|
||||||
|
if pw and pw.is_visible():
|
||||||
|
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(4000)
|
||||||
|
continue
|
||||||
|
t = page.query_selector("#inputTotpToken")
|
||||||
|
if t and t.is_visible():
|
||||||
|
t.click(); page.keyboard.type(bw.get_totp(item))
|
||||||
|
page.locator("#totpTokenSubmitButton").click()
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
continue
|
||||||
|
if "community.turnsys.com" in page.url:
|
||||||
|
break
|
||||||
|
page.wait_for_timeout(2500)
|
||||||
|
|
||||||
|
print(f"POST-OIDC: {page.url}")
|
||||||
|
if "/signup" not in page.url:
|
||||||
|
print("NOT ON SIGNUP -- dumping body:")
|
||||||
|
print(page.evaluate("() => document.body.innerText.substring(0, 400)"))
|
||||||
|
browser.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fill username
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
inp = page.locator("#new-account-username, input[name='username']").first
|
||||||
|
inp.click()
|
||||||
|
page.keyboard.type(USERNAME)
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
print(f"\n=== FIELD STATE after typing {USERNAME} ===")
|
||||||
|
print(field_state(page))
|
||||||
|
|
||||||
|
# Click create
|
||||||
|
for btn in ["Create Account", "Sign Up"]:
|
||||||
|
loc = page.locator(f'button:has-text("{btn}")')
|
||||||
|
if loc.count() > 0 and loc.first.is_visible():
|
||||||
|
loc.first.click()
|
||||||
|
print(f"\nClicked: {btn}")
|
||||||
|
break
|
||||||
|
|
||||||
|
page.wait_for_timeout(8000)
|
||||||
|
print(f"\n=== 8s LATER: URL={page.url} ===")
|
||||||
|
print(field_state(page))
|
||||||
|
body = page.evaluate("() => document.body.innerText.substring(0, 400)")
|
||||||
|
print(f"BODY: {body!r}")
|
||||||
|
page.screenshot(path=str(STATE_DIR / f"discourse-signup-{AGENT}-{time.strftime('%H%M%S')}.png"), full_page=True)
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
probe-redmine-access.py -- What does the Redmine OIDC page actually say?
|
||||||
|
|
||||||
|
Logs in via panel, clicks Redmine SSO, dumps the interaction page text
|
||||||
|
verbatim. Distinguishes: login form vs consent vs "You do not have access".
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
docker compose run --rm --entrypoint python3 \
|
||||||
|
-e AGENT=svp-knel provision probe-redmine-access.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 = "https://my.knownelement.com"
|
||||||
|
REDMINE_URL = "https://projects.knownelement.com"
|
||||||
|
STATE_DIR = Path("/app/state")
|
||||||
|
AGENT = os.environ.get("AGENT", "svp-knel")
|
||||||
|
|
||||||
|
EMAILS = {
|
||||||
|
"svp-knel": "tsgstaff-coo-svpknel@turnsys.com",
|
||||||
|
"vp-secops": "tsgstaff-coo-vpsecops@turnsys.com",
|
||||||
|
"coo": "tsgstaff-coo@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()
|
||||||
|
item = f"{AGENT} Cloudron"
|
||||||
|
email = EMAILS[AGENT]
|
||||||
|
password = bw.get_item_password(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()
|
||||||
|
|
||||||
|
# Panel login (networkidle like the working loop code)
|
||||||
|
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=30000)
|
||||||
|
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(4000)
|
||||||
|
totp = page.query_selector("#inputTotpToken")
|
||||||
|
if totp and totp.is_visible():
|
||||||
|
totp.click()
|
||||||
|
page.keyboard.type(bw.get_totp(item))
|
||||||
|
page.locator("#totpTokenSubmitButton").click()
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
print(f"PANEL: {page.url}")
|
||||||
|
|
||||||
|
# Redmine SSO
|
||||||
|
page.goto(f"{REDMINE_URL}/login", wait_until="domcontentloaded", timeout=30000)
|
||||||
|
page.wait_for_timeout(2000)
|
||||||
|
page.locator("#login-oauth-submit-1").first.click()
|
||||||
|
page.wait_for_timeout(6000)
|
||||||
|
|
||||||
|
print(f"\nOIDC URL: {page.url}")
|
||||||
|
body = page.evaluate("() => document.body.innerText")
|
||||||
|
print("OIDC BODY (verbatim):")
|
||||||
|
print(body)
|
||||||
|
page.screenshot(path=str(STATE_DIR / f"redmine-access-{AGENT}-{time.strftime('%H%M%S')}.png"))
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+114
-28
@@ -119,28 +119,33 @@ def enroll_cloudron(
|
|||||||
log.info(f"[{name}] Generated password ({len(password)} chars)")
|
log.info(f"[{name}] Generated password ({len(password)} chars)")
|
||||||
|
|
||||||
# Navigate to invite link
|
# Navigate to invite link
|
||||||
|
# Proven selectors (session 3 DOM dump of setupaccount.html):
|
||||||
|
# #inputUsername (prefilled from invite), #inputDisplayName,
|
||||||
|
# #inputPassword, #inputPasswordRepeat,
|
||||||
|
# submit = <div role="button"> "Set up" (disabled until form valid)
|
||||||
page.goto(invite_url, wait_until="networkidle")
|
page.goto(invite_url, wait_until="networkidle")
|
||||||
page.wait_for_timeout(2000)
|
page.wait_for_timeout(2000)
|
||||||
|
|
||||||
# Fill in the invite acceptance form using real Cloudron selectors
|
page.wait_for_selector("#inputPassword", timeout=15000)
|
||||||
page.wait_for_selector('#inputPassword', timeout=15000)
|
|
||||||
|
|
||||||
page.fill('#inputPassword', password)
|
# Pankow/Vue forms need click + keyboard.type, never fill()
|
||||||
page.fill('#inputPasswordRepeat', password)
|
page.click("#inputDisplayName")
|
||||||
|
page.keyboard.type(display_name)
|
||||||
|
page.click("#inputPassword")
|
||||||
|
page.keyboard.type(password)
|
||||||
|
page.click("#inputPasswordRepeat")
|
||||||
|
page.keyboard.type(password)
|
||||||
|
|
||||||
# Set display name if field exists
|
# Submit button is a div[role=button]; starts disabled, enables on valid input
|
||||||
display_field = page.query_selector('#inputDisplayName')
|
setup_btn = page.locator('[role="button"]:has-text("Set up")')
|
||||||
if display_field:
|
page.wait_for_timeout(1000)
|
||||||
display_field.fill(display_name)
|
if setup_btn.count() > 0 and setup_btn.first.is_visible():
|
||||||
|
setup_btn.first.click()
|
||||||
# Submit — Cloudron uses a button with class btn-primary or type submit
|
|
||||||
submit = page.query_selector('button[type="submit"], button.btn-primary, button:has-text("Setup"), button:has-text("Create"), button:has-text("Accept")')
|
|
||||||
if submit:
|
|
||||||
submit.click()
|
|
||||||
else:
|
else:
|
||||||
page.keyboard.press('Enter')
|
log.warning(f"[{name}] Set up button not found/clickable, pressing Enter")
|
||||||
|
page.keyboard.press("Enter")
|
||||||
|
|
||||||
page.wait_for_load_state("networkidle")
|
page.wait_for_timeout(5000)
|
||||||
log.info(f"[{name}] Invite accepted")
|
log.info(f"[{name}] Invite accepted")
|
||||||
|
|
||||||
# Enable 2FA
|
# Enable 2FA
|
||||||
@@ -177,6 +182,12 @@ def enable_cloudron_2fa(page: Page, agent: dict, bw: BitwardenHelper) -> str:
|
|||||||
name = agent["name"]
|
name = agent["name"]
|
||||||
log.info(f"[{name}] Enabling 2FA on Cloudron account")
|
log.info(f"[{name}] Enabling 2FA on Cloudron account")
|
||||||
|
|
||||||
|
# Fresh accounts sit on setupaccount.html ("Your account is ready") --
|
||||||
|
# hash navigation cannot leave that page. Load the panel root first.
|
||||||
|
if "setupaccount" in page.url:
|
||||||
|
page.goto(f"{CLOUDRON_BASE}/", wait_until="networkidle", timeout=20000)
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
|
||||||
page.evaluate('() => window.location.hash = "#/profile"')
|
page.evaluate('() => window.location.hash = "#/profile"')
|
||||||
page.wait_for_timeout(3000)
|
page.wait_for_timeout(3000)
|
||||||
|
|
||||||
@@ -198,11 +209,15 @@ def enable_cloudron_2fa(page: Page, agent: dict, bw: BitwardenHelper) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
# Step 1: Click "Setup" to start 2FA enrollment
|
# Step 1: Click "Setup" to start 2FA enrollment
|
||||||
|
# (forced-enrollment screen says "Set up passkey"; profile says "Setup")
|
||||||
setup_clicked = False
|
setup_clicked = False
|
||||||
for selector in [
|
for selector in [
|
||||||
'text=Setup',
|
'text=Setup',
|
||||||
|
'text=Set up',
|
||||||
'[role="button"]:has-text("Setup")',
|
'[role="button"]:has-text("Setup")',
|
||||||
|
'[role="button"]:has-text("Set up")',
|
||||||
'button:has-text("Setup")',
|
'button:has-text("Setup")',
|
||||||
|
'button:has-text("Set up")',
|
||||||
'a:has-text("Setup")',
|
'a:has-text("Setup")',
|
||||||
]:
|
]:
|
||||||
loc = page.locator(selector)
|
loc = page.locator(selector)
|
||||||
@@ -546,6 +561,69 @@ def sso_login(page: Page, system_url: str, agent: dict, bw: BitwardenHelper,
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def handle_oidc_interaction(page: Page, agent: dict, bw: BitwardenHelper) -> None:
|
||||||
|
"""Handle a Cloudron OIDC interaction page: login form, TOTP, consent.
|
||||||
|
|
||||||
|
Each Cloudron app has its own OIDC client and the panel session is
|
||||||
|
NOT always shared -- the interaction page may present a fresh Pankow
|
||||||
|
login form (#inputPassword), a TOTP prompt (#inputTotpToken), a
|
||||||
|
consent page, or any combination. Safe to call speculatively; every
|
||||||
|
step checks visibility first.
|
||||||
|
|
||||||
|
Cloudron's Pankow forms use #input* IDs (Gitea/Redmine/Discourse
|
||||||
|
login pages use different IDs, so there is no cross-app collision).
|
||||||
|
"""
|
||||||
|
name = agent["name"]
|
||||||
|
cloudron_item = f"{name} Cloudron"
|
||||||
|
|
||||||
|
# Poll: the redirect to the OIDC interaction may still be in flight
|
||||||
|
for _ in range(6):
|
||||||
|
on_interaction = ("openid" in page.url or "interaction" in page.url
|
||||||
|
or "my.knownelement.com" in page.url)
|
||||||
|
|
||||||
|
# 1. Login form (Cloudron Pankow IDs -- no cross-app collision)
|
||||||
|
pw = page.query_selector("#inputPassword")
|
||||||
|
if pw and pw.is_visible():
|
||||||
|
email = agent.get("cloudron_email", f"{name}@turnsys.com")
|
||||||
|
password = bw.get_item_password(cloudron_item)
|
||||||
|
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(4000)
|
||||||
|
log.info(f"[{name}] OIDC interaction: login form submitted")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 2. TOTP
|
||||||
|
totp = page.query_selector("#inputTotpToken")
|
||||||
|
if totp and totp.is_visible():
|
||||||
|
code = bw.get_totp(cloudron_item)
|
||||||
|
totp.click()
|
||||||
|
page.keyboard.type(code)
|
||||||
|
page.locator("#totpTokenSubmitButton").click()
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
log.info(f"[{name}] OIDC interaction: TOTP submitted")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 3. Consent (only on the interaction page itself)
|
||||||
|
if on_interaction:
|
||||||
|
for text in ["Continue", "Authorize", "Allow", "Accept"]:
|
||||||
|
loc = page.locator(
|
||||||
|
f'[role="button"]:has-text("{text}"), button:has-text("{text}")'
|
||||||
|
)
|
||||||
|
if loc.count() > 0 and loc.first.is_visible():
|
||||||
|
loc.first.click()
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
log.info(f"[{name}] OIDC interaction: consent clicked ({text})")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Done if we've left the interaction pages
|
||||||
|
if not on_interaction:
|
||||||
|
return
|
||||||
|
page.wait_for_timeout(2500)
|
||||||
|
|
||||||
|
|
||||||
def provision_gitea(page: Page, agent: dict, bw: BitwardenHelper) -> str:
|
def provision_gitea(page: Page, agent: dict, bw: BitwardenHelper) -> str:
|
||||||
"""Generate a Gitea API token via SSO login. Returns the token."""
|
"""Generate a Gitea API token via SSO login. Returns the token."""
|
||||||
name = agent["name"]
|
name = agent["name"]
|
||||||
@@ -680,7 +758,12 @@ def provision_discourse(page: Page, agent: dict, bw: BitwardenHelper) -> str:
|
|||||||
return bw.get_item_password(item_name)
|
return bw.get_item_password(item_name)
|
||||||
|
|
||||||
url = discourse_cfg.get("url", DISCOURSE_URL)
|
url = discourse_cfg.get("url", DISCOURSE_URL)
|
||||||
username = agent.get("username", name.replace("-", ""))
|
# Discourse caps usernames at 20 chars. Invite-issued usernames are
|
||||||
|
# often longer (tsgstaff-coo-svptctc-vpinvesting), which fails signup
|
||||||
|
# validation silently. Default to the short form unless overridden.
|
||||||
|
username = agent.get("discourse_username", name.replace("-", ""))
|
||||||
|
if len(username) > 20:
|
||||||
|
username = name.replace("-", "")[:20]
|
||||||
|
|
||||||
# --- SSO login ---
|
# --- SSO login ---
|
||||||
page.goto(f"{url}/", wait_until="domcontentloaded", timeout=30000)
|
page.goto(f"{url}/", wait_until="domcontentloaded", timeout=30000)
|
||||||
@@ -695,14 +778,23 @@ def provision_discourse(page: Page, agent: dict, bw: BitwardenHelper) -> str:
|
|||||||
sso_btn = page.locator('button:has-text("OpenID")')
|
sso_btn = page.locator('button:has-text("OpenID")')
|
||||||
if sso_btn.count() > 0:
|
if sso_btn.count() > 0:
|
||||||
sso_btn.first.click()
|
sso_btn.first.click()
|
||||||
page.wait_for_timeout(5000)
|
page.wait_for_timeout(3000)
|
||||||
|
|
||||||
# First login: /signup with email already authenticated by OIDC
|
# The OIDC interaction may need a fresh login/TOTP/consent
|
||||||
|
handle_oidc_interaction(page, agent, bw)
|
||||||
|
page.wait_for_timeout(3000)
|
||||||
|
|
||||||
|
# First login: /signup with email already authenticated by OIDC.
|
||||||
|
# Discourse PREFILLS the username field (truncated OIDC username)
|
||||||
|
# -- appending without clearing produces a too-long concatenation.
|
||||||
|
# Select-all + delete before typing.
|
||||||
if "/signup" in page.url:
|
if "/signup" in page.url:
|
||||||
page.wait_for_timeout(2000)
|
page.wait_for_timeout(2000)
|
||||||
username_input = page.locator('#new-account-username, input[name="username"]')
|
username_input = page.locator('#new-account-username, input[name="username"]')
|
||||||
if username_input.count() > 0 and username_input.first.is_visible():
|
if username_input.count() > 0 and username_input.first.is_visible():
|
||||||
username_input.first.click()
|
username_input.first.click()
|
||||||
|
page.keyboard.press("Control+a")
|
||||||
|
page.keyboard.press("Delete")
|
||||||
page.keyboard.type(username)
|
page.keyboard.type(username)
|
||||||
page.wait_for_timeout(1000)
|
page.wait_for_timeout(1000)
|
||||||
for btn_text in ["Create Account", "Sign Up", "Register"]:
|
for btn_text in ["Create Account", "Sign Up", "Register"]:
|
||||||
@@ -867,17 +959,11 @@ def provision_redmine(page: Page, agent: dict, bw: BitwardenHelper) -> str:
|
|||||||
)
|
)
|
||||||
if sso_btn.count() > 0 and sso_btn.first.is_visible():
|
if sso_btn.count() > 0 and sso_btn.first.is_visible():
|
||||||
sso_btn.first.click()
|
sso_btn.first.click()
|
||||||
page.wait_for_timeout(5000)
|
page.wait_for_timeout(3000)
|
||||||
|
|
||||||
if "openid" in page.url.lower():
|
# The OIDC interaction may need a fresh login/TOTP/consent
|
||||||
for consent in ["Continue", "Authorize", "Allow", "Accept"]:
|
handle_oidc_interaction(page, agent, bw)
|
||||||
cbtn = page.locator(
|
page.wait_for_timeout(3000)
|
||||||
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
|
|
||||||
|
|
||||||
if "/login" in page.url:
|
if "/login" in page.url:
|
||||||
log.error(f"[{name}] Redmine SSO failed (still on login page)")
|
log.error(f"[{name}] Redmine SSO failed (still on login page)")
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
validate-all-logins.py -- End-to-end phase-1 validation for all COO agents.
|
||||||
|
|
||||||
|
For each agent in the manifest (excluding non-COO identities):
|
||||||
|
1. Fresh Cloudron login: username + password + TOTP from BW
|
||||||
|
2. Assert we land on an authenticated page
|
||||||
|
3. If the agent has Gitea/Redmine/Discourse credentials in BW, verify
|
||||||
|
each API credential against its API
|
||||||
|
|
||||||
|
Prints a PASS/FAIL matrix. Exits 1 if any login fails.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
docker compose run --rm --entrypoint python3 provision validate-all-logins.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
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 = "https://git.knownelement.com"
|
||||||
|
REDMINE_URL = "https://projects.knownelement.com"
|
||||||
|
DISCOURSE_URL = "https://community.turnsys.com"
|
||||||
|
STATE_DIR = Path("/app/state")
|
||||||
|
|
||||||
|
# Identities that are NOT owned by the COO BW account
|
||||||
|
EXCLUDE = {"cco", "ctpo", "board-secretariat", "director-audit"}
|
||||||
|
|
||||||
|
|
||||||
|
def verify_gitea(token):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{GITEA_URL}/api/v1/user",
|
||||||
|
headers={"Authorization": f"token {token}"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as r:
|
||||||
|
return json.load(r).get("login")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_redmine(key):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{REDMINE_URL}/users/current.json",
|
||||||
|
headers={"X-Redmine-API-Key": key},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as r:
|
||||||
|
return json.load(r).get("user", {}).get("login")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_discourse(key):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{DISCOURSE_URL}/notifications.json",
|
||||||
|
headers={"User-Api-Key": key},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as r:
|
||||||
|
r.read()
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_agent(bw, context, agent, results):
|
||||||
|
"""Validate one agent: fresh login + API credential checks."""
|
||||||
|
name = agent["name"]
|
||||||
|
email = agent.get("cloudron_email", f"{name}@turnsys.com")
|
||||||
|
item = f"{name} Cloudron"
|
||||||
|
row = {"name": name, "cloudron": False, "totp": False,
|
||||||
|
"gitea": None, "redmine": None, "discourse": None, "err": ""}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not bw.item_exists(item):
|
||||||
|
row["err"] = "no BW item"
|
||||||
|
results.append(row)
|
||||||
|
print(f"[{name:20s}] FAIL: no Cloudron item in BW")
|
||||||
|
return
|
||||||
|
|
||||||
|
password = bw.get_item_password(item)
|
||||||
|
|
||||||
|
page = context.new_page()
|
||||||
|
page.goto(f"{CLOUDRON_BASE}/login.html",
|
||||||
|
wait_until="networkidle", timeout=20000)
|
||||||
|
page.wait_for_timeout(1500)
|
||||||
|
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(4000)
|
||||||
|
|
||||||
|
totp_el = page.query_selector("#inputTotpToken")
|
||||||
|
if totp_el and totp_el.is_visible():
|
||||||
|
row["totp"] = True # 2FA prompt appeared = 2FA is ON
|
||||||
|
code = bw.get_totp(item)
|
||||||
|
totp_el.click()
|
||||||
|
page.keyboard.type(code)
|
||||||
|
page.locator("#totpTokenSubmitButton").click()
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
|
||||||
|
authed = ("login" not in page.url.lower()
|
||||||
|
and "openid" not in page.url.lower()
|
||||||
|
and "setupaccount" not in page.url.lower())
|
||||||
|
row["cloudron"] = authed
|
||||||
|
if not authed:
|
||||||
|
page.screenshot(path=str(
|
||||||
|
STATE_DIR / f"validate-fail-{name}-{time.strftime('%H%M%S')}.png"))
|
||||||
|
row["err"] = f"login stuck at {page.url[:60]}"
|
||||||
|
page.close()
|
||||||
|
except Exception as e:
|
||||||
|
row["err"] = str(e)[:80]
|
||||||
|
|
||||||
|
# API credential checks (failures recorded, not fatal)
|
||||||
|
for sysname, verifier, bwitem in [
|
||||||
|
("gitea", verify_gitea, f"{name} Gitea"),
|
||||||
|
("redmine", verify_redmine, f"{name} Redmine"),
|
||||||
|
("discourse", verify_discourse, f"{name} Discourse"),
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
if bw.item_exists(bwitem):
|
||||||
|
row[sysname] = verifier(bw.get_item_password(bwitem))
|
||||||
|
else:
|
||||||
|
row[sysname] = "-"
|
||||||
|
except Exception as e:
|
||||||
|
row[sysname] = f"ERR {str(e)[:40]}"
|
||||||
|
|
||||||
|
results.append(row)
|
||||||
|
g = row["gitea"] if row["gitea"] is not None else "?"
|
||||||
|
r = row["redmine"] if row["redmine"] is not None else "?"
|
||||||
|
d = row["discourse"] if row["discourse"] is not None else "?"
|
||||||
|
status = "PASS" if row["cloudron"] and row["totp"] else "FAIL"
|
||||||
|
print(f"[{name:20s}] {status} cloudron={row['cloudron']} "
|
||||||
|
f"2fa={row['totp']} gitea={g} redmine={r} discourse={d} "
|
||||||
|
f"{row['err']}")
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
manifest = yaml.safe_load(open("/app/agents.yaml"))
|
||||||
|
agents = [a for a in manifest["agents"] if a["name"] not in EXCLUDE]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
context = browser.new_context(viewport={"width": 1280, "height": 1024})
|
||||||
|
|
||||||
|
for agent in agents:
|
||||||
|
# Fresh context per agent: a shared context carries the previous
|
||||||
|
# agent's session cookie and login.html never shows the form.
|
||||||
|
context = browser.new_context(viewport={"width": 1280, "height": 1024})
|
||||||
|
try:
|
||||||
|
_validate_agent(bw, context, agent, results)
|
||||||
|
finally:
|
||||||
|
context.close()
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
print("\n=== VALIDATION MATRIX ===")
|
||||||
|
fails = 0
|
||||||
|
for row in results:
|
||||||
|
ok = row["cloudron"] and row["totp"]
|
||||||
|
if not ok:
|
||||||
|
fails += 1
|
||||||
|
print(f" {row['name']:20s} {'OK ' if ok else 'FAIL'} {row['err']}")
|
||||||
|
|
||||||
|
sys.exit(1 if fails else 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
validate-coo.py -- Targeted re-validation of the coo identity.
|
||||||
|
|
||||||
|
Verifies the get_totp exact-name fix: fresh login with password + TOTP.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
docker compose run --rm --entrypoint python3 provision validate-coo.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
from bw_helper import BitwardenHelper
|
||||||
|
|
||||||
|
CLOUDRON_BASE = "https://my.knownelement.com"
|
||||||
|
ITEM = "coo Cloudron"
|
||||||
|
EMAIL = "tsgstaff-coo@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(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()
|
||||||
|
|
||||||
|
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="domcontentloaded", timeout=30000)
|
||||||
|
page.wait_for_timeout(1500)
|
||||||
|
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(4000)
|
||||||
|
|
||||||
|
totp_el = page.query_selector("#inputTotpToken")
|
||||||
|
if totp_el and totp_el.is_visible():
|
||||||
|
print("2FA prompt: YES")
|
||||||
|
code = bw.get_totp(ITEM)
|
||||||
|
print(f"TOTP resolved by exact name: {code}")
|
||||||
|
totp_el.click()
|
||||||
|
page.keyboard.type(code)
|
||||||
|
page.locator("#totpTokenSubmitButton").click()
|
||||||
|
page.wait_for_timeout(5000)
|
||||||
|
else:
|
||||||
|
print("2FA prompt: NO (problem)")
|
||||||
|
|
||||||
|
authed = ("login" not in page.url.lower()
|
||||||
|
and "openid" not in page.url.lower()
|
||||||
|
and "setupaccount" not in page.url.lower())
|
||||||
|
print(f"coo login: {'PASS' if authed else 'FAIL'} ({page.url})")
|
||||||
|
sys.exit(0 if authed else 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user