proxyscope 0.1.0__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.
- proxyscope/__init__.py +2 -0
- proxyscope/app/__init__.py +0 -0
- proxyscope/app/config/__init__.py +0 -0
- proxyscope/app/config/matching.py +154 -0
- proxyscope/app/config/models.py +25 -0
- proxyscope/app/config/runtime.py +460 -0
- proxyscope/app/config/serialization.py +85 -0
- proxyscope/app/editing/__init__.py +0 -0
- proxyscope/app/editing/modifier.py +98 -0
- proxyscope/app/editing/policy.py +58 -0
- proxyscope/app/editing/response.py +274 -0
- proxyscope/app/logging/__init__.py +0 -0
- proxyscope/app/logging/observability.py +26 -0
- proxyscope/app/logging/request_response.py +179 -0
- proxyscope/app/logging/setup.py +11 -0
- proxyscope/app/main.py +90 -0
- proxyscope/app/runtime/__init__.py +0 -0
- proxyscope/app/runtime/cli.py +567 -0
- proxyscope/app/runtime/commands.py +419 -0
- proxyscope/app/runtime/input.py +151 -0
- proxyscope/app/runtime/journal.py +160 -0
- proxyscope/app/runtime/replay.py +134 -0
- proxyscope/app/runtime/tui/__init__.py +3 -0
- proxyscope/app/runtime/tui/renderer.py +531 -0
- proxyscope/mitm/__init__.py +0 -0
- proxyscope/mitm/certificates.py +149 -0
- proxyscope/mitm/tunnel.py +255 -0
- proxyscope/proxy/__init__.py +0 -0
- proxyscope/proxy/connect_tunnel.py +116 -0
- proxyscope/proxy/forwarding.py +76 -0
- proxyscope/proxy/http1_request_rewriter.py +167 -0
- proxyscope/proxy/http1_response_modifier_rewriter.py +232 -0
- proxyscope/proxy/http1_sniffer.py +162 -0
- proxyscope/proxy/http_bridge.py +35 -0
- proxyscope/proxy/server.py +383 -0
- proxyscope/proxy/tunnel_registry.py +33 -0
- proxyscope/proxy/types.py +8 -0
- proxyscope-0.1.0.dist-info/METADATA +165 -0
- proxyscope-0.1.0.dist-info/RECORD +42 -0
- proxyscope-0.1.0.dist-info/WHEEL +4 -0
- proxyscope-0.1.0.dist-info/entry_points.txt +3 -0
- proxyscope-0.1.0.dist-info/licenses/LICENSE +21 -0
proxyscope/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from urllib.parse import urlsplit
|
|
3
|
+
|
|
4
|
+
from proxyscope.app.config.models import PolicyRule
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def normalize_whitelist_entry(value: str) -> str:
|
|
8
|
+
raw_value = value.strip()
|
|
9
|
+
if not raw_value:
|
|
10
|
+
raise ValueError("Whitelist entry must not be empty.")
|
|
11
|
+
|
|
12
|
+
if "://" in raw_value:
|
|
13
|
+
parsed = urlsplit(raw_value)
|
|
14
|
+
host = parsed.hostname
|
|
15
|
+
elif "/" in raw_value:
|
|
16
|
+
parsed = urlsplit(f"http://{raw_value}")
|
|
17
|
+
host = parsed.hostname
|
|
18
|
+
else:
|
|
19
|
+
parsed = urlsplit(f"http://{raw_value}")
|
|
20
|
+
host = parsed.hostname
|
|
21
|
+
|
|
22
|
+
if not host:
|
|
23
|
+
raise ValueError(f"Invalid whitelist entry: {value}")
|
|
24
|
+
return host.lower()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalize_modification_url(value: str) -> str:
|
|
28
|
+
raw_value = value.strip()
|
|
29
|
+
if not raw_value:
|
|
30
|
+
raise ValueError("Modification URL must not be empty.")
|
|
31
|
+
|
|
32
|
+
if "://" not in raw_value:
|
|
33
|
+
raw_value = f"http://{raw_value}"
|
|
34
|
+
|
|
35
|
+
parsed = urlsplit(raw_value)
|
|
36
|
+
if not parsed.hostname:
|
|
37
|
+
raise ValueError(f"Invalid modification URL: {value}")
|
|
38
|
+
|
|
39
|
+
scheme = parsed.scheme.lower() or "http"
|
|
40
|
+
host = parsed.hostname.lower()
|
|
41
|
+
port = parsed.port
|
|
42
|
+
path = parsed.path or "/"
|
|
43
|
+
normalized_path = path if path.startswith("/") else f"/{path}"
|
|
44
|
+
netloc = f"{host}:{port}" if port is not None else host
|
|
45
|
+
return f"{scheme}://{netloc}{normalized_path}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def normalize_http_method(method: str | None) -> str:
|
|
49
|
+
if method is None:
|
|
50
|
+
raise ValueError("HTTP method must not be empty.")
|
|
51
|
+
normalized = method.strip().upper()
|
|
52
|
+
if not normalized:
|
|
53
|
+
raise ValueError("HTTP method must not be empty.")
|
|
54
|
+
return normalized
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def request_url_candidates(url: str) -> tuple[str, ...]:
|
|
58
|
+
normalized = normalize_modification_url(url)
|
|
59
|
+
alternate = _alternate_scheme_url(normalized)
|
|
60
|
+
if alternate is None:
|
|
61
|
+
return (normalized,)
|
|
62
|
+
return (normalized, alternate)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def policy_rule_matches_request(*, rule: PolicyRule, method: str, url_candidates: tuple[str, ...]) -> bool:
|
|
66
|
+
methods = rule.match.methods
|
|
67
|
+
if methods is not None and method not in methods:
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
url_exact = rule.match.url_exact
|
|
71
|
+
url_prefix = rule.match.url_prefix
|
|
72
|
+
if url_exact is None and url_prefix is None:
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
for candidate in url_candidates:
|
|
76
|
+
if url_exact is not None and candidate == url_exact:
|
|
77
|
+
return True
|
|
78
|
+
if url_prefix is not None and candidate.startswith(url_prefix):
|
|
79
|
+
return True
|
|
80
|
+
# Host-wide fallback when configured with "/"
|
|
81
|
+
candidate_parsed = _parse_normalized_modification_url(candidate)
|
|
82
|
+
if url_exact is not None:
|
|
83
|
+
entry = _parse_normalized_modification_url(url_exact)
|
|
84
|
+
if entry.host == candidate_parsed.host and entry.port == candidate_parsed.port:
|
|
85
|
+
if entry.path == "/" or candidate_parsed.path.startswith(entry.path):
|
|
86
|
+
return True
|
|
87
|
+
if url_prefix is not None:
|
|
88
|
+
entry = _parse_normalized_modification_url(url_prefix)
|
|
89
|
+
if entry.host == candidate_parsed.host and entry.port == candidate_parsed.port:
|
|
90
|
+
if entry.path == "/" or candidate_parsed.path.startswith(entry.path):
|
|
91
|
+
return True
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def rule_matches_url(rule: PolicyRule, normalized_url: str) -> bool:
|
|
96
|
+
target = rule.match.url_exact or rule.match.url_prefix
|
|
97
|
+
if target is None:
|
|
98
|
+
return False
|
|
99
|
+
return target == normalized_url
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def first_rule_method(rule: PolicyRule) -> str | None:
|
|
103
|
+
methods = rule.match.methods
|
|
104
|
+
if not methods:
|
|
105
|
+
return None
|
|
106
|
+
return methods[0]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def rule_method_display(rule: PolicyRule) -> str:
|
|
110
|
+
method = first_rule_method(rule)
|
|
111
|
+
return method or "*"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def rule_url_display(rule: PolicyRule) -> str:
|
|
115
|
+
return rule.match.url_exact or rule.match.url_prefix or "*"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def policy_description(rule: PolicyRule) -> str:
|
|
119
|
+
state = "on" if rule.enabled else "off"
|
|
120
|
+
method = rule_method_display(rule)
|
|
121
|
+
target = rule_url_display(rule)
|
|
122
|
+
if rule.action == "open_editor":
|
|
123
|
+
return f"{rule.name} [{state}] open_editor {method} {target}"
|
|
124
|
+
if rule.action == "static_response" and rule.static_response is not None:
|
|
125
|
+
return (
|
|
126
|
+
f"{rule.name} [{state}] static_response {method} {target} "
|
|
127
|
+
f"-> {rule.static_response.status_code} {rule.static_response.reason}"
|
|
128
|
+
)
|
|
129
|
+
return f"{rule.name} [{state}] {rule.action} {method} {target}"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _alternate_scheme_url(normalized_url: str) -> str | None:
|
|
133
|
+
parsed = urlsplit(normalized_url)
|
|
134
|
+
scheme = parsed.scheme.lower()
|
|
135
|
+
if scheme not in {"http", "https"}:
|
|
136
|
+
return None
|
|
137
|
+
alternate_scheme = "https" if scheme == "http" else "http"
|
|
138
|
+
return f"{alternate_scheme}://{parsed.netloc}{parsed.path or '/'}"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True)
|
|
142
|
+
class _NormalizedModUrl:
|
|
143
|
+
host: str
|
|
144
|
+
port: int | None
|
|
145
|
+
path: str
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _parse_normalized_modification_url(value: str) -> _NormalizedModUrl:
|
|
149
|
+
parsed = urlsplit(value)
|
|
150
|
+
return _NormalizedModUrl(
|
|
151
|
+
host=(parsed.hostname or "").lower(),
|
|
152
|
+
port=parsed.port,
|
|
153
|
+
path=parsed.path or "/",
|
|
154
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass(frozen=True)
|
|
5
|
+
class RequestMatchRule:
|
|
6
|
+
methods: tuple[str, ...] | None = None
|
|
7
|
+
url_exact: str | None = None
|
|
8
|
+
url_prefix: str | None = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class StaticResponseTemplate:
|
|
13
|
+
status_code: int
|
|
14
|
+
reason: str
|
|
15
|
+
headers: dict[str, str]
|
|
16
|
+
body: bytes
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class PolicyRule:
|
|
21
|
+
name: str
|
|
22
|
+
enabled: bool
|
|
23
|
+
action: str # "open_editor" | "static_response"
|
|
24
|
+
match: RequestMatchRule
|
|
25
|
+
static_response: StaticResponseTemplate | None = None
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
from collections.abc import Iterable
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from threading import RLock
|
|
6
|
+
|
|
7
|
+
from proxyscope.app.config.matching import (
|
|
8
|
+
first_rule_method,
|
|
9
|
+
normalize_http_method,
|
|
10
|
+
normalize_modification_url,
|
|
11
|
+
normalize_whitelist_entry,
|
|
12
|
+
policy_description,
|
|
13
|
+
policy_rule_matches_request,
|
|
14
|
+
request_url_candidates,
|
|
15
|
+
rule_matches_url,
|
|
16
|
+
rule_method_display,
|
|
17
|
+
rule_url_display,
|
|
18
|
+
)
|
|
19
|
+
from proxyscope.app.config.models import PolicyRule, RequestMatchRule, StaticResponseTemplate
|
|
20
|
+
from proxyscope.app.config.serialization import (
|
|
21
|
+
parse_policy_rule as parse_policy_rule_payload,
|
|
22
|
+
serialize_policy_rule as serialize_policy_rule_payload,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RuntimeConfig:
|
|
27
|
+
"""
|
|
28
|
+
Runtime configuration shared by logging, policy matching, and UI.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
log_level: int = logging.INFO,
|
|
35
|
+
log_whitelist: Iterable[str] | None = None,
|
|
36
|
+
cache_invalidation_enabled: bool = True,
|
|
37
|
+
config_path: str | Path | None = None,
|
|
38
|
+
policy_rules: Iterable[PolicyRule] | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._lock = RLock()
|
|
41
|
+
self._log_level = log_level
|
|
42
|
+
self._log_whitelist: set[str] = set()
|
|
43
|
+
self._cache_invalidation_enabled = cache_invalidation_enabled
|
|
44
|
+
self._config_path = Path(config_path) if config_path is not None else None
|
|
45
|
+
self._policy_rules: list[PolicyRule] = list(policy_rules or [])
|
|
46
|
+
|
|
47
|
+
if log_whitelist is not None:
|
|
48
|
+
for entry in log_whitelist:
|
|
49
|
+
self._log_whitelist.add(normalize_whitelist_entry(entry))
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def config_path(self) -> Path | None:
|
|
53
|
+
with self._lock:
|
|
54
|
+
return self._config_path
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def log_level(self) -> int:
|
|
58
|
+
with self._lock:
|
|
59
|
+
return self._log_level
|
|
60
|
+
|
|
61
|
+
def log_level_name(self) -> str:
|
|
62
|
+
with self._lock:
|
|
63
|
+
return logging.getLevelName(self._log_level)
|
|
64
|
+
|
|
65
|
+
def set_log_level(self, value: str | int) -> int:
|
|
66
|
+
with self._lock:
|
|
67
|
+
if isinstance(value, int):
|
|
68
|
+
new_level = value
|
|
69
|
+
else:
|
|
70
|
+
normalized = value.strip().upper()
|
|
71
|
+
if not normalized:
|
|
72
|
+
raise ValueError("Log level must not be empty.")
|
|
73
|
+
new_level = getattr(logging, normalized, None)
|
|
74
|
+
if not isinstance(new_level, int):
|
|
75
|
+
raise ValueError(f"Unsupported log level: {value}")
|
|
76
|
+
self._log_level = new_level
|
|
77
|
+
self._persist_if_configured()
|
|
78
|
+
return new_level
|
|
79
|
+
|
|
80
|
+
def whitelist_entries(self) -> tuple[str, ...]:
|
|
81
|
+
with self._lock:
|
|
82
|
+
return tuple(sorted(self._log_whitelist))
|
|
83
|
+
|
|
84
|
+
def add_whitelist_entry(self, value: str) -> str:
|
|
85
|
+
normalized_host = normalize_whitelist_entry(value)
|
|
86
|
+
with self._lock:
|
|
87
|
+
self._log_whitelist.add(normalized_host)
|
|
88
|
+
self._persist_if_configured()
|
|
89
|
+
return normalized_host
|
|
90
|
+
|
|
91
|
+
def remove_whitelist_entry(self, value: str) -> bool:
|
|
92
|
+
normalized_host = normalize_whitelist_entry(value)
|
|
93
|
+
removed = False
|
|
94
|
+
with self._lock:
|
|
95
|
+
if normalized_host in self._log_whitelist:
|
|
96
|
+
self._log_whitelist.remove(normalized_host)
|
|
97
|
+
removed = True
|
|
98
|
+
if removed:
|
|
99
|
+
self._persist_if_configured()
|
|
100
|
+
return removed
|
|
101
|
+
|
|
102
|
+
def clear_whitelist(self) -> None:
|
|
103
|
+
with self._lock:
|
|
104
|
+
self._log_whitelist.clear()
|
|
105
|
+
self._persist_if_configured()
|
|
106
|
+
|
|
107
|
+
def should_log_for_host(self, host: str | None) -> bool:
|
|
108
|
+
with self._lock:
|
|
109
|
+
if not self._log_whitelist:
|
|
110
|
+
return True
|
|
111
|
+
if host is None:
|
|
112
|
+
return False
|
|
113
|
+
normalized_host = normalize_whitelist_entry(host)
|
|
114
|
+
return normalized_host in self._log_whitelist
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def cache_invalidation_enabled(self) -> bool:
|
|
118
|
+
with self._lock:
|
|
119
|
+
return self._cache_invalidation_enabled
|
|
120
|
+
|
|
121
|
+
def set_cache_invalidation_enabled(self, enabled: bool) -> bool:
|
|
122
|
+
with self._lock:
|
|
123
|
+
self._cache_invalidation_enabled = enabled
|
|
124
|
+
self._persist_if_configured()
|
|
125
|
+
return enabled
|
|
126
|
+
|
|
127
|
+
def toggle_cache_invalidation(self) -> bool:
|
|
128
|
+
with self._lock:
|
|
129
|
+
self._cache_invalidation_enabled = not self._cache_invalidation_enabled
|
|
130
|
+
enabled = self._cache_invalidation_enabled
|
|
131
|
+
self._persist_if_configured()
|
|
132
|
+
return enabled
|
|
133
|
+
|
|
134
|
+
def policy_rules(self) -> tuple[PolicyRule, ...]:
|
|
135
|
+
with self._lock:
|
|
136
|
+
return tuple(self._policy_rules)
|
|
137
|
+
|
|
138
|
+
def set_policy_rules(self, rules: Iterable[PolicyRule]) -> None:
|
|
139
|
+
with self._lock:
|
|
140
|
+
self._policy_rules = list(rules)
|
|
141
|
+
self._persist_if_configured()
|
|
142
|
+
|
|
143
|
+
def add_policy_rule(self, rule: PolicyRule) -> None:
|
|
144
|
+
with self._lock:
|
|
145
|
+
self._policy_rules.append(rule)
|
|
146
|
+
self._persist_if_configured()
|
|
147
|
+
|
|
148
|
+
def clear_policy_rules(self) -> None:
|
|
149
|
+
with self._lock:
|
|
150
|
+
self._policy_rules.clear()
|
|
151
|
+
self._persist_if_configured()
|
|
152
|
+
|
|
153
|
+
def policy_descriptions(self) -> tuple[str, ...]:
|
|
154
|
+
with self._lock:
|
|
155
|
+
return tuple(policy_description(rule) for rule in self._policy_rules)
|
|
156
|
+
|
|
157
|
+
def remove_policy_rule(self, name: str) -> bool:
|
|
158
|
+
normalized = name.strip()
|
|
159
|
+
if not normalized:
|
|
160
|
+
raise ValueError("Policy name must not be empty.")
|
|
161
|
+
removed = False
|
|
162
|
+
with self._lock:
|
|
163
|
+
kept: list[PolicyRule] = []
|
|
164
|
+
for rule in self._policy_rules:
|
|
165
|
+
if rule.name == normalized:
|
|
166
|
+
removed = True
|
|
167
|
+
continue
|
|
168
|
+
kept.append(rule)
|
|
169
|
+
self._policy_rules = kept
|
|
170
|
+
if removed:
|
|
171
|
+
self._persist_if_configured()
|
|
172
|
+
return removed
|
|
173
|
+
|
|
174
|
+
def get_policy_rule(self, name: str) -> PolicyRule | None:
|
|
175
|
+
normalized = name.strip()
|
|
176
|
+
if not normalized:
|
|
177
|
+
raise ValueError("Policy name must not be empty.")
|
|
178
|
+
with self._lock:
|
|
179
|
+
for rule in self._policy_rules:
|
|
180
|
+
if rule.name == normalized:
|
|
181
|
+
return rule
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
def replace_policy_rule(self, name: str, replacement: PolicyRule) -> bool:
|
|
185
|
+
normalized = name.strip()
|
|
186
|
+
if not normalized:
|
|
187
|
+
raise ValueError("Policy name must not be empty.")
|
|
188
|
+
replaced = False
|
|
189
|
+
with self._lock:
|
|
190
|
+
updated: list[PolicyRule] = []
|
|
191
|
+
for rule in self._policy_rules:
|
|
192
|
+
if not replaced and rule.name == normalized:
|
|
193
|
+
updated.append(replacement)
|
|
194
|
+
replaced = True
|
|
195
|
+
continue
|
|
196
|
+
updated.append(rule)
|
|
197
|
+
self._policy_rules = updated
|
|
198
|
+
if replaced:
|
|
199
|
+
self._persist_if_configured()
|
|
200
|
+
return replaced
|
|
201
|
+
|
|
202
|
+
def set_policy_rule_enabled(self, name: str, *, enabled: bool) -> bool:
|
|
203
|
+
normalized = name.strip()
|
|
204
|
+
if not normalized:
|
|
205
|
+
raise ValueError("Policy name must not be empty.")
|
|
206
|
+
changed = False
|
|
207
|
+
with self._lock:
|
|
208
|
+
updated: list[PolicyRule] = []
|
|
209
|
+
for rule in self._policy_rules:
|
|
210
|
+
if rule.name != normalized:
|
|
211
|
+
updated.append(rule)
|
|
212
|
+
continue
|
|
213
|
+
updated.append(
|
|
214
|
+
PolicyRule(
|
|
215
|
+
name=rule.name,
|
|
216
|
+
enabled=enabled,
|
|
217
|
+
action=rule.action,
|
|
218
|
+
match=rule.match,
|
|
219
|
+
static_response=rule.static_response,
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
changed = True
|
|
223
|
+
self._policy_rules = updated
|
|
224
|
+
if changed:
|
|
225
|
+
self._persist_if_configured()
|
|
226
|
+
return changed
|
|
227
|
+
|
|
228
|
+
def add_static_response_rule(
|
|
229
|
+
self,
|
|
230
|
+
*,
|
|
231
|
+
url: str,
|
|
232
|
+
status_code: int = 200,
|
|
233
|
+
reason: str = "OK",
|
|
234
|
+
headers: dict[str, str] | None = None,
|
|
235
|
+
body: bytes = b"",
|
|
236
|
+
method: str | None = None,
|
|
237
|
+
url_prefix: bool = False,
|
|
238
|
+
name: str | None = None,
|
|
239
|
+
) -> str:
|
|
240
|
+
normalized_url = normalize_modification_url(url)
|
|
241
|
+
normalized_method = normalize_http_method(method) if method is not None else None
|
|
242
|
+
rule_name = name or f"static-response-{len(self._policy_rules) + 1}"
|
|
243
|
+
match = RequestMatchRule(
|
|
244
|
+
methods=(normalized_method,) if normalized_method is not None else None,
|
|
245
|
+
url_prefix=normalized_url if url_prefix else None,
|
|
246
|
+
url_exact=None if url_prefix else normalized_url,
|
|
247
|
+
)
|
|
248
|
+
self.add_policy_rule(
|
|
249
|
+
PolicyRule(
|
|
250
|
+
name=rule_name,
|
|
251
|
+
enabled=True,
|
|
252
|
+
action="static_response",
|
|
253
|
+
match=match,
|
|
254
|
+
static_response=StaticResponseTemplate(
|
|
255
|
+
status_code=status_code,
|
|
256
|
+
reason=reason,
|
|
257
|
+
headers=dict(headers or {}),
|
|
258
|
+
body=body,
|
|
259
|
+
),
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
return rule_name
|
|
263
|
+
|
|
264
|
+
def modification_whitelist_entries(self) -> tuple[str, ...]:
|
|
265
|
+
with self._lock:
|
|
266
|
+
entries = [rule for rule in self._policy_rules if rule.action == "open_editor" and rule.enabled]
|
|
267
|
+
return tuple(f"{rule_method_display(rule)} {rule_url_display(rule)}" for rule in entries)
|
|
268
|
+
|
|
269
|
+
def open_editor_policy_entries(self) -> tuple[str, ...]:
|
|
270
|
+
return self.modification_whitelist_entries()
|
|
271
|
+
|
|
272
|
+
def add_open_editor_policy(self, value: str, *, method: str = "GET") -> str:
|
|
273
|
+
return self.add_modification_whitelist_entry(value, method=method)
|
|
274
|
+
|
|
275
|
+
def remove_open_editor_policy(self, value: str, *, method: str | None = None) -> bool:
|
|
276
|
+
return self.remove_modification_whitelist_entry(value, method=method)
|
|
277
|
+
|
|
278
|
+
def clear_open_editor_policies(self) -> None:
|
|
279
|
+
self.clear_modification_whitelist()
|
|
280
|
+
|
|
281
|
+
def add_modification_whitelist_entry(self, value: str, *, method: str = "GET") -> str:
|
|
282
|
+
normalized_url = normalize_modification_url(value)
|
|
283
|
+
normalized_method = normalize_http_method(method)
|
|
284
|
+
name = f"open-editor-{len(self._policy_rules) + 1}"
|
|
285
|
+
self.add_policy_rule(
|
|
286
|
+
PolicyRule(
|
|
287
|
+
name=name,
|
|
288
|
+
enabled=True,
|
|
289
|
+
action="open_editor",
|
|
290
|
+
match=RequestMatchRule(methods=(normalized_method,), url_exact=normalized_url),
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
return f"{normalized_method} {normalized_url}"
|
|
294
|
+
|
|
295
|
+
def remove_modification_whitelist_entry(self, value: str, *, method: str | None = None) -> bool:
|
|
296
|
+
normalized_url = normalize_modification_url(value)
|
|
297
|
+
normalized_method = normalize_http_method(method) if method is not None else None
|
|
298
|
+
removed = False
|
|
299
|
+
with self._lock:
|
|
300
|
+
kept: list[PolicyRule] = []
|
|
301
|
+
for rule in self._policy_rules:
|
|
302
|
+
if rule.action != "open_editor":
|
|
303
|
+
kept.append(rule)
|
|
304
|
+
continue
|
|
305
|
+
if not rule_matches_url(rule, normalized_url):
|
|
306
|
+
kept.append(rule)
|
|
307
|
+
continue
|
|
308
|
+
rule_method = first_rule_method(rule)
|
|
309
|
+
if normalized_method is not None and rule_method != normalized_method:
|
|
310
|
+
kept.append(rule)
|
|
311
|
+
continue
|
|
312
|
+
removed = True
|
|
313
|
+
self._policy_rules = kept
|
|
314
|
+
if removed:
|
|
315
|
+
self._persist_if_configured()
|
|
316
|
+
return removed
|
|
317
|
+
|
|
318
|
+
def clear_modification_whitelist(self) -> None:
|
|
319
|
+
with self._lock:
|
|
320
|
+
self._policy_rules = [rule for rule in self._policy_rules if rule.action != "open_editor"]
|
|
321
|
+
self._persist_if_configured()
|
|
322
|
+
|
|
323
|
+
def should_modify_response_for_request(self, *, method: str, url: str) -> bool:
|
|
324
|
+
normalized_method = normalize_http_method(method)
|
|
325
|
+
candidates = request_url_candidates(url)
|
|
326
|
+
with self._lock:
|
|
327
|
+
for rule in self._policy_rules:
|
|
328
|
+
if not rule.enabled or rule.action != "open_editor":
|
|
329
|
+
continue
|
|
330
|
+
if policy_rule_matches_request(rule=rule, method=normalized_method, url_candidates=candidates):
|
|
331
|
+
return True
|
|
332
|
+
return False
|
|
333
|
+
|
|
334
|
+
def get_static_response_template_for_request(
|
|
335
|
+
self,
|
|
336
|
+
*,
|
|
337
|
+
method: str,
|
|
338
|
+
url: str,
|
|
339
|
+
) -> StaticResponseTemplate | None:
|
|
340
|
+
normalized_method = normalize_http_method(method)
|
|
341
|
+
candidates = request_url_candidates(url)
|
|
342
|
+
with self._lock:
|
|
343
|
+
for rule in self._policy_rules:
|
|
344
|
+
if not rule.enabled or rule.action != "static_response":
|
|
345
|
+
continue
|
|
346
|
+
if policy_rule_matches_request(rule=rule, method=normalized_method, url_candidates=candidates):
|
|
347
|
+
return rule.static_response
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
def attach_config_path(self, path: str | Path) -> None:
|
|
351
|
+
with self._lock:
|
|
352
|
+
self._config_path = Path(path)
|
|
353
|
+
|
|
354
|
+
def save_to_path(self, path: str | Path) -> Path:
|
|
355
|
+
resolved = Path(path)
|
|
356
|
+
with self._lock:
|
|
357
|
+
self._config_path = resolved
|
|
358
|
+
self.save()
|
|
359
|
+
return resolved
|
|
360
|
+
|
|
361
|
+
def reload_from_attached_file(self) -> bool:
|
|
362
|
+
with self._lock:
|
|
363
|
+
path = self._config_path
|
|
364
|
+
if path is None:
|
|
365
|
+
return False
|
|
366
|
+
reloaded = RuntimeConfig.load_from_file(path)
|
|
367
|
+
with self._lock:
|
|
368
|
+
self._log_level = reloaded.log_level
|
|
369
|
+
self._log_whitelist = set(reloaded.whitelist_entries())
|
|
370
|
+
self._cache_invalidation_enabled = reloaded.cache_invalidation_enabled
|
|
371
|
+
self._policy_rules = list(reloaded.policy_rules())
|
|
372
|
+
return True
|
|
373
|
+
|
|
374
|
+
def save(self) -> None:
|
|
375
|
+
with self._lock:
|
|
376
|
+
path = self._config_path
|
|
377
|
+
payload = self._to_dict_locked()
|
|
378
|
+
if path is None:
|
|
379
|
+
return
|
|
380
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
381
|
+
path.write_text(json.dumps(payload, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
|
|
382
|
+
|
|
383
|
+
def _persist_if_configured(self) -> None:
|
|
384
|
+
with self._lock:
|
|
385
|
+
has_path = self._config_path is not None
|
|
386
|
+
if has_path:
|
|
387
|
+
self.save()
|
|
388
|
+
|
|
389
|
+
def _to_dict_locked(self) -> dict:
|
|
390
|
+
return {
|
|
391
|
+
"log_level": logging.getLevelName(self._log_level),
|
|
392
|
+
"log_whitelist": sorted(self._log_whitelist),
|
|
393
|
+
"cache_invalidation_enabled": self._cache_invalidation_enabled,
|
|
394
|
+
"policies": [serialize_policy_rule_payload(rule) for rule in self._policy_rules],
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
@classmethod
|
|
398
|
+
def load_from_file(cls, path: str | Path) -> "RuntimeConfig":
|
|
399
|
+
resolved_path = Path(path)
|
|
400
|
+
if not resolved_path.exists():
|
|
401
|
+
return cls(config_path=resolved_path)
|
|
402
|
+
|
|
403
|
+
raw = json.loads(resolved_path.read_text(encoding="utf-8"))
|
|
404
|
+
level_raw = str(raw.get("log_level", "INFO")).strip().upper()
|
|
405
|
+
level_value = getattr(logging, level_raw, logging.INFO)
|
|
406
|
+
whitelist_raw = raw.get("log_whitelist", [])
|
|
407
|
+
cache_enabled = bool(raw.get("cache_invalidation_enabled", False))
|
|
408
|
+
|
|
409
|
+
rules: list[PolicyRule] = []
|
|
410
|
+
for item in raw.get("policies", []):
|
|
411
|
+
parsed = parse_policy_rule_payload(item)
|
|
412
|
+
if parsed is not None:
|
|
413
|
+
rules.append(parsed)
|
|
414
|
+
|
|
415
|
+
return cls(
|
|
416
|
+
log_level=level_value,
|
|
417
|
+
log_whitelist=whitelist_raw,
|
|
418
|
+
cache_invalidation_enabled=cache_enabled,
|
|
419
|
+
config_path=resolved_path,
|
|
420
|
+
policy_rules=rules,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
_runtime_config = RuntimeConfig()
|
|
425
|
+
_runtime_config_lock = RLock()
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def set_runtime_config(config: RuntimeConfig) -> None:
|
|
429
|
+
global _runtime_config
|
|
430
|
+
with _runtime_config_lock:
|
|
431
|
+
_runtime_config = config
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def get_runtime_config() -> RuntimeConfig:
|
|
435
|
+
with _runtime_config_lock:
|
|
436
|
+
return _runtime_config
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def should_log_for_host(host: str | None) -> bool:
|
|
440
|
+
return get_runtime_config().should_log_for_host(host)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def is_cache_invalidation_enabled() -> bool:
|
|
444
|
+
return get_runtime_config().cache_invalidation_enabled
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def should_modify_response_for_request(*, method: str, url: str) -> bool:
|
|
448
|
+
return get_runtime_config().should_modify_response_for_request(method=method, url=url)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def get_static_response_template_for_request(*, method: str, url: str) -> StaticResponseTemplate | None:
|
|
452
|
+
return get_runtime_config().get_static_response_template_for_request(method=method, url=url)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def serialize_policy_rule(rule: PolicyRule) -> dict:
|
|
456
|
+
return serialize_policy_rule_payload(rule)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def parse_policy_rule(data: object) -> PolicyRule | None:
|
|
460
|
+
return parse_policy_rule_payload(data)
|