studio-console 1.3.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,285 @@
1
+ # studio_console/cloudflare/cf_api.py
2
+ """Cloudflare API v4 client — stdlib only, no third-party deps."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import urllib.error
8
+ import urllib.request
9
+ from typing import Any
10
+
11
+
12
+ class CloudflareError(Exception):
13
+ def __init__(self, message: str, status_code: int = 0, errors: list | None = None) -> None:
14
+ super().__init__(message)
15
+ self.status_code = status_code
16
+ self.errors = errors or []
17
+
18
+ def __str__(self) -> str:
19
+ if self.errors:
20
+ details = "; ".join(
21
+ e.get("message", str(e)) for e in self.errors if isinstance(e, dict)
22
+ )
23
+ return f"{super().__str__()} ({details})"
24
+ return super().__str__()
25
+
26
+
27
+ class CloudflareAPI:
28
+ """Thin wrapper around the Cloudflare API v4 endpoints used by studio-console."""
29
+
30
+ BASE = "https://api.cloudflare.com/client/v4"
31
+
32
+ def __init__(self, token: str, account_id: str = "") -> None:
33
+ self.token = token
34
+ self.account_id = account_id
35
+
36
+ # ------------------------------------------------------------------
37
+ # Internal HTTP helpers
38
+ # ------------------------------------------------------------------
39
+
40
+ def _request(
41
+ self,
42
+ method: str,
43
+ path: str,
44
+ body: dict | None = None,
45
+ ) -> Any:
46
+ url = f"{self.BASE}{path}"
47
+ data = json.dumps(body).encode() if body is not None else None
48
+ headers = {
49
+ "Authorization": f"Bearer {self.token}",
50
+ "Content-Type": "application/json",
51
+ }
52
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
53
+ try:
54
+ with urllib.request.urlopen(req, timeout=15) as resp:
55
+ result = json.loads(resp.read().decode())
56
+ except urllib.error.HTTPError as e:
57
+ try:
58
+ result = json.loads(e.read().decode())
59
+ except Exception:
60
+ raise CloudflareError(str(e), e.code)
61
+ errors = result.get("errors", [])
62
+ msg = errors[0].get("message", str(e)) if errors else str(e)
63
+ raise CloudflareError(msg, e.code, errors)
64
+ except (urllib.error.URLError, OSError) as e:
65
+ raise CloudflareError(f"Network error: {e}")
66
+
67
+ if not result.get("success"):
68
+ errors = result.get("errors", [])
69
+ msg = errors[0].get("message", "Unknown error") if errors else "Unknown error"
70
+ raise CloudflareError(msg, errors=errors)
71
+
72
+ return result.get("result")
73
+
74
+ def _get(self, path: str) -> Any:
75
+ return self._request("GET", path)
76
+
77
+ def _post(self, path: str, body: dict) -> Any:
78
+ return self._request("POST", path, body)
79
+
80
+ def _put(self, path: str, body: dict) -> Any:
81
+ return self._request("PUT", path, body)
82
+
83
+ def _delete(self, path: str) -> Any:
84
+ return self._request("DELETE", path)
85
+
86
+ def _acct(self, path: str) -> str:
87
+ """Build an account-scoped path."""
88
+ if not self.account_id:
89
+ raise CloudflareError("account_id is required for this operation")
90
+ return f"/accounts/{self.account_id}{path}"
91
+
92
+ # ------------------------------------------------------------------
93
+ # Account discovery
94
+ # ------------------------------------------------------------------
95
+
96
+ def verify_token(self) -> bool:
97
+ """Verify the token is valid via /user/tokens/verify. Returns True if active.
98
+
99
+ Raises CloudflareError on auth/permission/network failures (carrying
100
+ status_code) so callers can distinguish 'expired/invalid' from
101
+ 'insufficient scopes' (403). A non-active-but-successful response
102
+ returns False without raising.
103
+ """
104
+ result = self._get("/user/tokens/verify")
105
+ return isinstance(result, dict) and result.get("status") == "active"
106
+
107
+ def list_accounts(self) -> list[dict]:
108
+ """List accounts. Requires Account:Read permission — may return [] with tunnel-only tokens."""
109
+ result = self._get("/accounts")
110
+ return result if isinstance(result, list) else []
111
+
112
+ def list_memberships(self) -> list[dict]:
113
+ """List account memberships. Works with tokens that have no Account:Read permission."""
114
+ result = self._get("/memberships?status=accepted")
115
+ return result if isinstance(result, list) else []
116
+
117
+ # ------------------------------------------------------------------
118
+ # Tunnel management
119
+ # ------------------------------------------------------------------
120
+
121
+ def list_tunnels(self) -> list[dict]:
122
+ result = self._get(self._acct("/cfd_tunnel?is_deleted=false"))
123
+ return result if isinstance(result, list) else []
124
+
125
+ def create_tunnel(self, name: str) -> dict:
126
+ return self._post(self._acct("/cfd_tunnel"), {"name": name, "tunnel_secret": _random_secret()})
127
+
128
+ def get_tunnel_token(self, tunnel_id: str) -> str:
129
+ result = self._get(self._acct(f"/cfd_tunnel/{tunnel_id}/token"))
130
+ return result if isinstance(result, str) else ""
131
+
132
+ def delete_tunnel(self, tunnel_id: str) -> None:
133
+ self._delete(self._acct(f"/cfd_tunnel/{tunnel_id}"))
134
+
135
+ def get_tunnel_config(self, tunnel_id: str) -> list[dict]:
136
+ """Return the tunnel's current ingress rules (without the catch-all)."""
137
+ result = self._get(self._acct(f"/cfd_tunnel/{tunnel_id}/configurations"))
138
+ if not isinstance(result, dict):
139
+ return []
140
+ rules = result.get("config", {}).get("ingress", []) or []
141
+ return [r for r in rules if "hostname" in r]
142
+
143
+ def put_tunnel_config(self, tunnel_id: str, ingress: list[dict]) -> dict:
144
+ """Replace the full ingress rule set for a tunnel.
145
+
146
+ ingress is a list of dicts: {"hostname": "...", "service": "..."}.
147
+ A catch-all rule {"service": "http_status:404"} is appended automatically
148
+ if not already present (Cloudflare requires it as the last rule).
149
+ """
150
+ rules = list(ingress)
151
+ if not rules or "hostname" in rules[-1]:
152
+ rules.append({"service": "http_status:404"})
153
+ body = {"config": {"ingress": rules}}
154
+ return self._put(self._acct(f"/cfd_tunnel/{tunnel_id}/configurations"), body)
155
+
156
+ # ------------------------------------------------------------------
157
+ # DNS records
158
+ # ------------------------------------------------------------------
159
+
160
+ def list_zones(self) -> list[dict]:
161
+ result = self._get("/zones")
162
+ return result if isinstance(result, list) else []
163
+
164
+ def list_dns_records(self, zone_id: str, name: str = "") -> list[dict]:
165
+ path = f"/zones/{zone_id}/dns_records"
166
+ if name:
167
+ path += f"?name={name}"
168
+ result = self._get(path)
169
+ return result if isinstance(result, list) else []
170
+
171
+ def create_dns_record(self, zone_id: str, name: str, tunnel_id: str) -> dict:
172
+ """Create a CNAME record pointing a hostname at a Cloudflare tunnel."""
173
+ return self._post(
174
+ f"/zones/{zone_id}/dns_records",
175
+ {
176
+ "type": "CNAME",
177
+ "name": name,
178
+ "content": f"{tunnel_id}.cfargotunnel.com",
179
+ "proxied": True,
180
+ "ttl": 1, # Auto
181
+ },
182
+ )
183
+
184
+ def delete_dns_record(self, zone_id: str, record_id: str) -> None:
185
+ self._delete(f"/zones/{zone_id}/dns_records/{record_id}")
186
+
187
+ def upsert_dns_record(self, zone_id: str, name: str, tunnel_id: str) -> dict:
188
+ """Create or replace a CNAME record for the given hostname."""
189
+ existing = self.list_dns_records(zone_id, name=name)
190
+ for record in existing:
191
+ if record.get("type") == "CNAME":
192
+ self.delete_dns_record(zone_id, record["id"])
193
+ return self.create_dns_record(zone_id, name, tunnel_id)
194
+
195
+ # ------------------------------------------------------------------
196
+ # Zero Trust Access — Applications
197
+ # ------------------------------------------------------------------
198
+
199
+ def list_access_apps(self) -> list[dict]:
200
+ result = self._get(self._acct("/access/apps"))
201
+ return result if isinstance(result, list) else []
202
+
203
+ def create_access_app(self, name: str, domains: list[str]) -> dict:
204
+ """Create a self-hosted Zero Trust Access application."""
205
+ # Primary domain is the first in the list; others added as destinations
206
+ destinations = [{"type": "public", "uri": d} for d in domains]
207
+ return self._post(
208
+ self._acct("/access/apps"),
209
+ {
210
+ "name": name,
211
+ "type": "self_hosted",
212
+ "domain": domains[0],
213
+ "destinations": destinations,
214
+ "session_duration": "24h",
215
+ "allowed_idps": [],
216
+ "auto_redirect_to_identity": False,
217
+ },
218
+ )
219
+
220
+ def update_access_app_domains(self, app_id: str, domains: list[str]) -> dict:
221
+ """Replace the domain list on an existing Access app."""
222
+ # CF requires a full PUT — fetch existing to preserve required fields (type, name, etc.)
223
+ existing = self._get(self._acct(f"/access/apps/{app_id}"))
224
+ if not isinstance(existing, dict):
225
+ existing = {}
226
+ destinations = [{"type": "public", "uri": d} for d in domains]
227
+ body = {**existing, "domain": domains[0], "destinations": destinations}
228
+ return self._put(self._acct(f"/access/apps/{app_id}"), body)
229
+
230
+ def delete_access_app(self, app_id: str) -> None:
231
+ self._delete(self._acct(f"/access/apps/{app_id}"))
232
+
233
+ # ------------------------------------------------------------------
234
+ # Zero Trust Access — Policies
235
+ # ------------------------------------------------------------------
236
+
237
+ def list_access_policies(self, app_id: str) -> list[dict]:
238
+ result = self._get(self._acct(f"/access/apps/{app_id}/policies"))
239
+ return result if isinstance(result, list) else []
240
+
241
+ def create_ip_bypass_policy(self, app_id: str, name: str, ip_ranges: list[str]) -> dict:
242
+ """Create a Bypass policy that allows listed IPs without authentication."""
243
+ include = [{"ip": {"ip": cidr}} for cidr in ip_ranges]
244
+ return self._post(
245
+ self._acct(f"/access/apps/{app_id}/policies"),
246
+ {
247
+ "name": name,
248
+ "decision": "bypass",
249
+ "include": include,
250
+ "exclude": [],
251
+ "require": [],
252
+ "precedence": 1,
253
+ },
254
+ )
255
+
256
+ def update_ip_bypass_policy(
257
+ self, app_id: str, policy_id: str, name: str, ip_ranges: list[str]
258
+ ) -> dict:
259
+ """Replace the IP list on an existing bypass policy."""
260
+ include = [{"ip": {"ip": cidr}} for cidr in ip_ranges]
261
+ return self._put(
262
+ self._acct(f"/access/apps/{app_id}/policies/{policy_id}"),
263
+ {
264
+ "name": name,
265
+ "decision": "bypass",
266
+ "include": include,
267
+ "exclude": [],
268
+ "require": [],
269
+ },
270
+ )
271
+
272
+ def delete_access_policy(self, app_id: str, policy_id: str) -> None:
273
+ self._delete(self._acct(f"/access/apps/{app_id}/policies/{policy_id}"))
274
+
275
+
276
+ # ------------------------------------------------------------------
277
+ # Helpers
278
+ # ------------------------------------------------------------------
279
+
280
+
281
+ def _random_secret() -> str:
282
+ """Generate a 32-byte random secret encoded as base64 (required by CF tunnel create)."""
283
+ import base64
284
+ import secrets as _secrets
285
+ return base64.b64encode(_secrets.token_bytes(32)).decode()