diff --git a/web/cloudflare/README.md b/web/cloudflare/README.md index e18b2c4..5b07f73 100644 --- a/web/cloudflare/README.md +++ b/web/cloudflare/README.md @@ -29,10 +29,15 @@ npx wrangler@3 secret put OPENDOTA_API_KEY Pages project `climperor-relations` needs bindings `DB`, `MATCHES`, `SYNC_QUEUE` (see `web/frontend/wrangler.toml`). `provision.py` binds production **and** preview -with matching `fail_open`. Secrets already used by auth: -`STEAM_API_KEY`, `SESSION_SECRET`, optional `PLAYER_PAGES_PUBLISH_SECRET`. -Worker also needs `STEAM_API_KEY` (`python web/cloudflare/put_worker_secrets.py` -after staging via `_stage_steam_key.py`). +with matching `fail_open`. Auth secrets on Pages (prod+preview): + +```powershell +python web/cloudflare/_stage_steam_auth.py # via keyzoo refining/steam +python web/cloudflare/put_pages_secrets.py # via keyzoo refining/cloudflare +``` + +Optional `PLAYER_PAGES_PUBLISH_SECRET`. Worker also needs `STEAM_API_KEY` +(`python web/cloudflare/put_worker_secrets.py` after `_stage_steam_key.py`). **R2**: if create returns “enable R2”, open https://dash.cloudflare.com/?to=/:account/r2 once, then re-run `provision.py` diff --git a/web/cloudflare/_dump_pages_env.py b/web/cloudflare/_dump_pages_env.py new file mode 100644 index 0000000..79e5478 --- /dev/null +++ b/web/cloudflare/_dump_pages_env.py @@ -0,0 +1,69 @@ +"""Dump Pages env_var names/types and latest deployment id (no secret values).""" + +from __future__ import annotations + +import json +import os +import sys +import urllib.request + +ACCT = "510534f7f6284344aadaf2f5a0794d48" +PROJ = "climperor-relations" + + +def get(path: str) -> dict: + email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get( + "KEYZOO_ASSET_META_USERNAME" + ) + key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get( + "KEYZOO_ASSET_SECRET_GLOBAL_API_KEY" + ) + req = urllib.request.Request( + f"https://api.cloudflare.com/client/v4{path}", + headers={"X-Auth-Email": email, "X-Auth-Key": key}, + ) + with urllib.request.urlopen(req, timeout=60) as r: + return json.load(r) + + +def main() -> int: + p = get(f"/accounts/{ACCT}/pages/projects/{PROJ}")["result"] + dc = p.get("deployment_configs") or {} + for env in ("production", "preview"): + ev = (dc.get(env) or {}).get("env_vars") or {} + print(env, "keys", sorted(ev.keys())) + for k, v in sorted(ev.items()): + print( + " ", + k, + "type=", + (v or {}).get("type"), + "has_value=", + "value" in (v or {}), + ) + deps = get(f"/accounts/{ACCT}/pages/projects/{PROJ}/deployments?per_page=2")[ + "result" + ] + for d0 in deps or []: + print( + "deployment", + d0.get("id"), + d0.get("environment"), + d0.get("created_on"), + d0.get("url"), + ) + if d0.get("id"): + det = get( + f"/accounts/{ACCT}/pages/projects/{PROJ}/deployments/{d0['id']}" + )["result"] + # Some APIs nest env under build_config / env_vars + for k in sorted(det.keys()): + if "env" in k.lower() or k in ("aliases", "is_skipped", "production_branch"): + val = det.get(k) + s = json.dumps(val, ensure_ascii=False) if not isinstance(val, str) else val + print(f" {k}: {s[:240]}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/web/cloudflare/_stage_steam_auth.py b/web/cloudflare/_stage_steam_auth.py new file mode 100644 index 0000000..ddde3ac --- /dev/null +++ b/web/cloudflare/_stage_steam_auth.py @@ -0,0 +1,48 @@ +"""Stage Steam API key + session secret for Pages/Worker secret put (no echo).""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +REFRESH = Path(__file__).resolve().parents[1] / ".refresh" +STEAM_TMP = REFRESH / "steam_key.tmp" +SESSION_TMP = REFRESH / "session_secret.tmp" + + +def _write(path: Path, value: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(value, encoding="utf-8") + try: + os.chmod(path, 0o600) + except OSError: + pass + + +def main() -> int: + steam = ( + os.environ.get("STEAM_API_KEY") + or os.environ.get("KEYZOO_ASSET_SECRET_WEB_API_KEY") + or "" + ).strip() + session = ( + os.environ.get("SESSION_SECRET") + or os.environ.get("KEYZOO_ASSET_SECRET_SESSION_SECRET") + or "" + ).strip() + if not steam or not session: + print("missing steam key or session secret", file=sys.stderr) + return 2 + _write(STEAM_TMP, steam) + _write(SESSION_TMP, session) + print( + f"staged {STEAM_TMP.name} len={len(steam)}; " + f"{SESSION_TMP.name} len={len(session)}", + flush=True, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web/cloudflare/put_pages_secrets.py b/web/cloudflare/put_pages_secrets.py new file mode 100644 index 0000000..e899ad2 --- /dev/null +++ b/web/cloudflare/put_pages_secrets.py @@ -0,0 +1,155 @@ +"""Put STEAM_API_KEY + SESSION_SECRET on climperor-relations Pages (prod+preview). + +Reads secrets from env or staged temp files under web/.refresh/ (no secret echo). +Prefers `wrangler pages secret put`; falls back to Pages project PATCH. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path + +ACCT = "510534f7f6284344aadaf2f5a0794d48" +PROJ = "climperor-relations" +HERE = Path(__file__).resolve().parent +FRONTEND = HERE.parents[0] / "frontend" +REFRESH = Path(__file__).resolve().parents[1] / ".refresh" +STEAM_TMP = REFRESH / "steam_key.tmp" +SESSION_TMP = REFRESH / "session_secret.tmp" + + +def _creds() -> tuple[str, str]: + email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get( + "KEYZOO_ASSET_META_USERNAME" + ) + key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get( + "KEYZOO_ASSET_SECRET_GLOBAL_API_KEY" + ) + if not email or not key: + raise SystemExit("missing Cloudflare credentials") + return email, key + + +def _secret(name: str, env_keys: list[str], staged: Path) -> str: + for k in env_keys: + v = (os.environ.get(k) or "").strip() + if v: + return v + if staged.is_file(): + v = staged.read_text(encoding="utf-8").strip() + try: + staged.unlink() + except OSError: + pass + if v: + return v + raise SystemExit(f"missing {name}") + + +def cf(method: str, path: str, body: dict | None = None) -> dict: + email, key = _creds() + data = None if body is None else json.dumps(body).encode("utf-8") + req = urllib.request.Request( + f"https://api.cloudflare.com/client/v4{path}", + data=data, + method=method, + headers={ + "X-Auth-Email": email, + "X-Auth-Key": key, + "Content-Type": "application/json", + "User-Agent": "climperor-put-pages-secrets", + }, + ) + try: + with urllib.request.urlopen(req, timeout=60) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + raw = e.read().decode("utf-8", errors="replace") + raise SystemExit(f"CF API {e.code}: {raw[:500]}") from e + + +def _wrangler_put(name: str, value: str) -> None: + email, key = _creds() + env = os.environ.copy() + env["CLOUDFLARE_EMAIL"] = email + env["CLOUDFLARE_API_KEY"] = key + env["CLOUDFLARE_ACCOUNT_ID"] = ACCT + env["CI"] = "true" + print(f"wrangler pages secret put {name} …", flush=True) + cmd = ( + f'npx --yes wrangler@3 pages secret put {name} ' + f'--project-name {PROJ}' + ) + proc = subprocess.run( + cmd, + input=value + "\n", + text=True, + capture_output=True, + env=env, + cwd=str(FRONTEND if FRONTEND.is_dir() else HERE), + shell=True, + ) + if proc.returncode != 0: + print(proc.stderr or proc.stdout, file=sys.stderr) + raise SystemExit(proc.returncode) + tail = (proc.stdout or "").strip().splitlines() + if tail: + print(tail[-1], flush=True) + + +def main() -> int: + steam = _secret( + "STEAM_API_KEY", + ["STEAM_API_KEY", "KEYZOO_ASSET_SECRET_WEB_API_KEY"], + STEAM_TMP, + ) + session = _secret( + "SESSION_SECRET", + ["SESSION_SECRET", "KEYZOO_ASSET_SECRET_SESSION_SECRET"], + SESSION_TMP, + ) + # Official path: wrangler pages secret put (production). + _wrangler_put("STEAM_API_KEY", steam) + _wrangler_put("SESSION_SECRET", session) + + # Also patch preview so *.pages.dev preview deploys work. + proj = cf("GET", f"/accounts/{ACCT}/pages/projects/{PROJ}") + result = proj.get("result") or {} + dc = result.get("deployment_configs") or {} + secrets = { + "STEAM_API_KEY": {"type": "secret_text", "value": steam}, + "SESSION_SECRET": {"type": "secret_text", "value": session}, + } + preview_cfg = dc.get("preview") or {} + preview_entry: dict = {"env_vars": secrets} + wch = preview_cfg.get("wrangler_config_hash") + if wch: + preview_entry["wrangler_config_hash"] = wch + out = cf( + "PATCH", + f"/accounts/{ACCT}/pages/projects/{PROJ}", + {"deployment_configs": {"preview": preview_entry}}, + ) + if not out.get("success"): + print("preview patch soft-fail", flush=True) + + check = cf("GET", f"/accounts/{ACCT}/pages/projects/{PROJ}") + cdc = (check.get("result") or {}).get("deployment_configs") or {} + for env_name in ("production", "preview"): + ev = ((cdc.get(env_name) or {}).get("env_vars") or {}) + names = sorted(ev.keys()) + types = { + k: (ev[k] or {}).get("type") for k in ("STEAM_API_KEY", "SESSION_SECRET") + } + print(f"{env_name}: env_vars={names} secret_types={types}", flush=True) + print("ok", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())