azure-dev-proxy 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.
- azure_dev_proxy/__init__.py +1 -0
- azure_dev_proxy/__main__.py +104 -0
- azure_dev_proxy/auth.py +382 -0
- azure_dev_proxy/config.py +367 -0
- azure_dev_proxy/default-config.json +34 -0
- azure_dev_proxy/idle_timer.py +62 -0
- azure_dev_proxy/listeners.py +108 -0
- azure_dev_proxy/proxy.py +478 -0
- azure_dev_proxy/routers/__init__.py +3 -0
- azure_dev_proxy/routers/_base.py +31 -0
- azure_dev_proxy/routers/graphiql.py +96 -0
- azure_dev_proxy/routers/management.py +247 -0
- azure_dev_proxy/routers/reverse_proxy.py +174 -0
- azure_dev_proxy/routers/utils.py +220 -0
- azure_dev_proxy/static/app.js +124 -0
- azure_dev_proxy/static/graphiql.html +109 -0
- azure_dev_proxy/static/index.html +90 -0
- azure_dev_proxy/static/login.html +26 -0
- azure_dev_proxy/tokens.py +242 -0
- azure_dev_proxy-0.1.0.dist-info/METADATA +8 -0
- azure_dev_proxy-0.1.0.dist-info/RECORD +24 -0
- azure_dev_proxy-0.1.0.dist-info/WHEEL +4 -0
- azure_dev_proxy-0.1.0.dist-info/entry_points.txt +2 -0
- azure_dev_proxy-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import asdict, dataclass
|
|
10
|
+
from importlib.resources import files
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
|
|
14
|
+
import idna
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Config:
|
|
19
|
+
"""
|
|
20
|
+
Routing settings for Azure-authenticated HTTPS destinations.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
token_rules: Host rules, with exact hosts and longer wildcard suffixes
|
|
24
|
+
taking precedence regardless of configuration order.
|
|
25
|
+
trusted_origins: Normalized additional HTTP(S) browser origins allowed
|
|
26
|
+
to forward.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
token_rules: tuple[TokenRule, ...]
|
|
30
|
+
trusted_origins: tuple[str, ...] = ()
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
object.__setattr__(
|
|
34
|
+
self,
|
|
35
|
+
"trusted_origins",
|
|
36
|
+
tuple(self.normalize_origin(origin) for origin in self.trusted_origins),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def parse(cls, value: object) -> Config:
|
|
41
|
+
"""Validate a decoded JSON configuration and normalize its hostnames."""
|
|
42
|
+
required = {"token_rules"}
|
|
43
|
+
if not isinstance(value, dict) or not required <= set(value) <= required | {
|
|
44
|
+
"trusted_origins",
|
|
45
|
+
"query_string_substitutions_by_host",
|
|
46
|
+
}:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
"Configuration requires token_rules; trusted_origins is optional"
|
|
49
|
+
)
|
|
50
|
+
rules = value["token_rules"]
|
|
51
|
+
if not isinstance(rules, list):
|
|
52
|
+
raise ValueError("Configuration requires a token-rule array")
|
|
53
|
+
parsed = []
|
|
54
|
+
for rule in rules:
|
|
55
|
+
if (
|
|
56
|
+
not isinstance(rule, dict)
|
|
57
|
+
or not {"host"} <= set(rule) <= {"host", "resource"}
|
|
58
|
+
or not isinstance(rule["host"], str)
|
|
59
|
+
or rule.get("resource") is not None
|
|
60
|
+
and not isinstance(rule["resource"], str)
|
|
61
|
+
):
|
|
62
|
+
raise ValueError("Invalid token rule")
|
|
63
|
+
parsed.append(TokenRule(rule["host"], rule.get("resource")))
|
|
64
|
+
origins = value.get("trusted_origins", [])
|
|
65
|
+
if not isinstance(origins, list) or not all(
|
|
66
|
+
isinstance(origin, str) for origin in origins
|
|
67
|
+
):
|
|
68
|
+
raise ValueError("Trusted origins must be an array of HTTP(S) origins")
|
|
69
|
+
return cls(tuple(parsed), tuple(origins))
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def defaults(cls) -> Config:
|
|
73
|
+
"""The bundled routing defaults, without reading or writing a user file."""
|
|
74
|
+
return cls.parse(
|
|
75
|
+
json.loads(cls._default_json(), object_pairs_hook=cls._unique_fields)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def load(cls, path: Path) -> Config:
|
|
80
|
+
"""Load JSON, atomically installing defaults if the file is missing."""
|
|
81
|
+
try:
|
|
82
|
+
data = path.read_text(encoding="utf-8")
|
|
83
|
+
except FileNotFoundError:
|
|
84
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
data = cls._default_json()
|
|
86
|
+
with tempfile.NamedTemporaryFile(
|
|
87
|
+
mode="w", encoding="utf-8", dir=path.parent
|
|
88
|
+
) as temporary:
|
|
89
|
+
temporary.write(data)
|
|
90
|
+
temporary.flush()
|
|
91
|
+
os.fsync(temporary.fileno())
|
|
92
|
+
try:
|
|
93
|
+
os.link(temporary.name, path)
|
|
94
|
+
except FileExistsError:
|
|
95
|
+
pass
|
|
96
|
+
data = path.read_text(encoding="utf-8")
|
|
97
|
+
try:
|
|
98
|
+
value = json.loads(data, object_pairs_hook=cls._unique_fields)
|
|
99
|
+
except json.JSONDecodeError as error:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"Invalid JSON configuration at line {error.lineno}, column {error.colno}"
|
|
102
|
+
) from None
|
|
103
|
+
return cls.parse(value)
|
|
104
|
+
|
|
105
|
+
def as_dict(self) -> dict[str, object]:
|
|
106
|
+
"""Return the routing settings as a JSON-compatible object."""
|
|
107
|
+
return {
|
|
108
|
+
"token_rules": [asdict(rule) for rule in self.token_rules],
|
|
109
|
+
"trusted_origins": list(self.trusted_origins),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def update_rule(
|
|
114
|
+
cls, path: Path, rule: TokenRule, *, delete: bool = False
|
|
115
|
+
) -> Config:
|
|
116
|
+
"""
|
|
117
|
+
Atomically replace or delete a persisted hostname mapping.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
path: Existing JSON configuration file. Writable symlinks are
|
|
121
|
+
followed without replacing the symlink itself.
|
|
122
|
+
rule: Mapping to save, or the hostname to remove when deleting.
|
|
123
|
+
delete: Remove all mappings for this normalized hostname instead
|
|
124
|
+
of saving the supplied mapping.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
The configuration after the successful write. File permissions
|
|
128
|
+
and unrelated JSON settings are retained.
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
OSError: The file cannot be read or replaced, or is read-only.
|
|
132
|
+
ValueError: The current file is not a valid configuration.
|
|
133
|
+
"""
|
|
134
|
+
path = path.resolve(strict=True)
|
|
135
|
+
if not os.access(path, os.W_OK):
|
|
136
|
+
raise PermissionError("The configuration file is not writable")
|
|
137
|
+
value = json.loads(
|
|
138
|
+
path.read_text(encoding="utf-8"), object_pairs_hook=cls._unique_fields
|
|
139
|
+
)
|
|
140
|
+
current = cls.parse(value)
|
|
141
|
+
value["token_rules"] = [
|
|
142
|
+
entry
|
|
143
|
+
for entry, parsed in zip(value["token_rules"], current.token_rules)
|
|
144
|
+
if parsed.host != rule.host
|
|
145
|
+
]
|
|
146
|
+
if not delete:
|
|
147
|
+
value["token_rules"].append(asdict(rule))
|
|
148
|
+
value.pop("query_string_substitutions_by_host", None)
|
|
149
|
+
updated = cls.parse(value)
|
|
150
|
+
temporary = tempfile.NamedTemporaryFile(
|
|
151
|
+
mode="w",
|
|
152
|
+
encoding="utf-8",
|
|
153
|
+
dir=path.parent,
|
|
154
|
+
delete=False,
|
|
155
|
+
)
|
|
156
|
+
temporary_path = Path(temporary.name)
|
|
157
|
+
try:
|
|
158
|
+
with temporary:
|
|
159
|
+
temporary.write(json.dumps(value, indent=2, ensure_ascii=True) + "\n")
|
|
160
|
+
temporary.flush()
|
|
161
|
+
os.fsync(temporary.fileno())
|
|
162
|
+
temporary_path.chmod(stat.S_IMODE(path.stat().st_mode))
|
|
163
|
+
os.replace(temporary_path, path)
|
|
164
|
+
finally:
|
|
165
|
+
temporary_path.unlink(missing_ok=True)
|
|
166
|
+
return updated
|
|
167
|
+
|
|
168
|
+
def resource_for(self, origin: str) -> str | None:
|
|
169
|
+
"""
|
|
170
|
+
Select this configuration's token audience for an HTTPS destination.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
origin: Destination origin. Non-HTTPS origins always return None,
|
|
174
|
+
even when a hostname rule matches.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
The most specific matching rule's resource, or the normalized
|
|
178
|
+
destination origin when that rule has no explicit resource.
|
|
179
|
+
Unmatched destinations return None.
|
|
180
|
+
|
|
181
|
+
Raises:
|
|
182
|
+
ValueError: An HTTPS origin has an invalid or missing authority.
|
|
183
|
+
"""
|
|
184
|
+
parsed = urlsplit(origin)
|
|
185
|
+
if parsed.scheme != "https":
|
|
186
|
+
return None
|
|
187
|
+
if not parsed.hostname:
|
|
188
|
+
raise ValueError("An HTTPS destination origin is required")
|
|
189
|
+
authority = self.normalize_authority(parsed.netloc)
|
|
190
|
+
host = urlsplit("https://" + authority).hostname or ""
|
|
191
|
+
rule = min(
|
|
192
|
+
(rule for rule in self.token_rules if rule.matches(host)),
|
|
193
|
+
key=lambda rule: rule.priority,
|
|
194
|
+
default=None,
|
|
195
|
+
)
|
|
196
|
+
if rule is None:
|
|
197
|
+
return None
|
|
198
|
+
return rule.resource if rule.resource is not None else "https://" + authority
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def normalize_host(host: str) -> str:
|
|
202
|
+
"""
|
|
203
|
+
Normalize a DNS hostname or IP address for routing and rule matching.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
host: Hostname without a port or IPv6 brackets. DNS names use
|
|
207
|
+
nontransitional UTS #46; trailing dots are removed.
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
ValueError: The hostname is invalid.
|
|
211
|
+
"""
|
|
212
|
+
host = host.rstrip(".")
|
|
213
|
+
try:
|
|
214
|
+
return str(ipaddress.ip_address(host))
|
|
215
|
+
except ValueError:
|
|
216
|
+
pass
|
|
217
|
+
try:
|
|
218
|
+
host = (
|
|
219
|
+
idna.encode(host, uts46=True, transitional=False, std3_rules=True)
|
|
220
|
+
.decode("ascii")
|
|
221
|
+
.rstrip(".")
|
|
222
|
+
)
|
|
223
|
+
except idna.IDNAError as error:
|
|
224
|
+
raise ValueError("Invalid hostname") from error
|
|
225
|
+
return host
|
|
226
|
+
|
|
227
|
+
@classmethod
|
|
228
|
+
def normalize_authority(cls, authority: str, *, default_port: int = 443) -> str:
|
|
229
|
+
"""
|
|
230
|
+
Normalize a URL authority, rejecting credentials and invalid ports.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
authority: Hostname or bracketed IPv6 address with an optional port.
|
|
234
|
+
default_port: Port omitted from the result when explicitly present;
|
|
235
|
+
defaults to HTTPS port 443. HTTP callers should pass 80.
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
ValueError: The authority is invalid.
|
|
239
|
+
"""
|
|
240
|
+
if (
|
|
241
|
+
not authority
|
|
242
|
+
or any(c in authority for c in "/\\?#@%")
|
|
243
|
+
or any(c.isspace() or ord(c) < 32 or ord(c) == 127 for c in authority)
|
|
244
|
+
):
|
|
245
|
+
raise ValueError("Invalid destination authority")
|
|
246
|
+
parsed = urlsplit("https://" + authority)
|
|
247
|
+
if not parsed.hostname:
|
|
248
|
+
raise ValueError("Missing destination hostname")
|
|
249
|
+
# urlsplit().hostname lowercases Unicode before IDNA can apply UTS #46.
|
|
250
|
+
raw_host = (
|
|
251
|
+
parsed.netloc.partition("]")[0][1:]
|
|
252
|
+
if parsed.netloc.startswith("[")
|
|
253
|
+
else parsed.netloc.partition(":")[0]
|
|
254
|
+
)
|
|
255
|
+
host = cls.normalize_host(raw_host)
|
|
256
|
+
port = parsed.port
|
|
257
|
+
if authority.endswith(":") or port is not None and not 1 <= port <= 65535:
|
|
258
|
+
raise ValueError("Invalid destination port")
|
|
259
|
+
if ":" in host:
|
|
260
|
+
host = f"[{host}]"
|
|
261
|
+
return host if port in (None, default_port) else f"{host}:{port}"
|
|
262
|
+
|
|
263
|
+
@classmethod
|
|
264
|
+
def normalize_origin(cls, value: str) -> str:
|
|
265
|
+
"""
|
|
266
|
+
Normalize an HTTP(S) browser origin, omitting its scheme's default port.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
value: Exact HTTP(S) origin without credentials, path, query,
|
|
270
|
+
fragment or wildcards.
|
|
271
|
+
|
|
272
|
+
Raises:
|
|
273
|
+
ValueError: The origin is invalid.
|
|
274
|
+
"""
|
|
275
|
+
if any(c.isspace() or ord(c) < 32 or ord(c) == 127 for c in value):
|
|
276
|
+
raise ValueError("Trusted origins must not contain whitespace or controls")
|
|
277
|
+
parsed = urlsplit(value)
|
|
278
|
+
if (
|
|
279
|
+
parsed.scheme not in {"http", "https"}
|
|
280
|
+
or parsed.path
|
|
281
|
+
or "?" in value
|
|
282
|
+
or "#" in value
|
|
283
|
+
):
|
|
284
|
+
raise ValueError(
|
|
285
|
+
"Trusted origins require an HTTP(S) scheme and no path, query or fragment"
|
|
286
|
+
)
|
|
287
|
+
default_port = 80 if parsed.scheme == "http" else 443
|
|
288
|
+
authority = cls.normalize_authority(parsed.netloc, default_port=default_port)
|
|
289
|
+
return f"{parsed.scheme}://{authority}"
|
|
290
|
+
|
|
291
|
+
@staticmethod
|
|
292
|
+
def default_path() -> Path:
|
|
293
|
+
"""The native per-user configuration path for the current platform."""
|
|
294
|
+
if sys.platform == "win32":
|
|
295
|
+
directory = os.environ.get("APPDATA")
|
|
296
|
+
root = Path(directory) if directory else Path.home() / "AppData" / "Roaming"
|
|
297
|
+
elif sys.platform == "darwin":
|
|
298
|
+
root = Path.home() / "Library" / "Application Support"
|
|
299
|
+
else:
|
|
300
|
+
directory = os.environ.get("XDG_CONFIG_HOME")
|
|
301
|
+
root = Path(directory) if directory else Path.home() / ".config"
|
|
302
|
+
return root / "azure-dev-proxy" / "config.json"
|
|
303
|
+
|
|
304
|
+
@staticmethod
|
|
305
|
+
def _default_json() -> str:
|
|
306
|
+
return (
|
|
307
|
+
files("azure_dev_proxy")
|
|
308
|
+
.joinpath("default-config.json")
|
|
309
|
+
.read_text(encoding="utf-8")
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def _unique_fields(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
314
|
+
result: dict[str, object] = {}
|
|
315
|
+
for key, value in pairs:
|
|
316
|
+
if key in result:
|
|
317
|
+
raise ValueError("Duplicate JSON configuration field")
|
|
318
|
+
result[key] = value
|
|
319
|
+
return result
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@dataclass(frozen=True)
|
|
323
|
+
class TokenRule:
|
|
324
|
+
"""
|
|
325
|
+
A hostname pattern and the Azure token resource it selects.
|
|
326
|
+
|
|
327
|
+
Attributes:
|
|
328
|
+
host: Exact hostname or wildcard DNS suffix beginning with ``*.``.
|
|
329
|
+
resource: HTTPS token audience, or None for the destination origin.
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
host: str
|
|
333
|
+
resource: str | None = None
|
|
334
|
+
|
|
335
|
+
def __post_init__(self) -> None:
|
|
336
|
+
wildcard = self.host.startswith("*.")
|
|
337
|
+
host = Config.normalize_host(self.host[2:] if wildcard else self.host)
|
|
338
|
+
if wildcard and ":" in host:
|
|
339
|
+
raise ValueError("Wildcard rules require a DNS suffix")
|
|
340
|
+
object.__setattr__(self, "host", "*." + host if wildcard else host)
|
|
341
|
+
if self.resource is not None:
|
|
342
|
+
if any(c.isspace() or ord(c) < 32 or ord(c) == 127 for c in self.resource):
|
|
343
|
+
raise ValueError(
|
|
344
|
+
"Resource overrides must not contain whitespace or controls"
|
|
345
|
+
)
|
|
346
|
+
parsed = urlsplit(self.resource)
|
|
347
|
+
if (
|
|
348
|
+
parsed.scheme != "https"
|
|
349
|
+
or not parsed.netloc
|
|
350
|
+
or "?" in self.resource
|
|
351
|
+
or "#" in self.resource
|
|
352
|
+
):
|
|
353
|
+
raise ValueError(
|
|
354
|
+
"Resource overrides must be HTTPS URLs without query or fragment"
|
|
355
|
+
)
|
|
356
|
+
Config.normalize_authority(parsed.netloc)
|
|
357
|
+
|
|
358
|
+
def matches(self, host: str) -> bool:
|
|
359
|
+
host = Config.normalize_host(host)
|
|
360
|
+
if self.host.startswith("*."):
|
|
361
|
+
return host.endswith(self.host[1:]) and host != self.host[2:]
|
|
362
|
+
return host == self.host
|
|
363
|
+
|
|
364
|
+
@property
|
|
365
|
+
def priority(self) -> tuple[bool, int, str]:
|
|
366
|
+
"""The exact-host-first, longest-suffix-first matching priority."""
|
|
367
|
+
return self.host.startswith("*."), -len(self.host), self.host
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"token_rules": [
|
|
3
|
+
{"host": "management.azure.com", "resource": "https://management.azure.com/"},
|
|
4
|
+
{"host": "*.blob.core.windows.net", "resource": "https://storage.azure.com/"},
|
|
5
|
+
{"host": "*.dfs.core.windows.net", "resource": "https://storage.azure.com/"},
|
|
6
|
+
{"host": "*.file.core.windows.net", "resource": "https://storage.azure.com/"},
|
|
7
|
+
{"host": "*.queue.core.windows.net", "resource": "https://storage.azure.com/"},
|
|
8
|
+
{"host": "*.table.core.windows.net", "resource": "https://storage.azure.com/"},
|
|
9
|
+
{"host": "*.vault.azure.net", "resource": "https://vault.azure.net/"},
|
|
10
|
+
{"host": "*.managedhsm.azure.net", "resource": "https://managedhsm.azure.net/"},
|
|
11
|
+
{"host": "*.azconfig.io", "resource": "https://appconfig.azure.com"},
|
|
12
|
+
{"host": "*.servicebus.windows.net", "resource": "https://servicebus.azure.net/"},
|
|
13
|
+
{"host": "*.kusto.windows.net", "resource": "https://api.kusto.windows.net"},
|
|
14
|
+
{"host": "*.search.windows.net", "resource": "https://search.azure.com"},
|
|
15
|
+
{"host": "*.openai.azure.com", "resource": "https://cognitiveservices.azure.com/"},
|
|
16
|
+
{"host": "*.cognitiveservices.azure.com", "resource": "https://cognitiveservices.azure.com/"},
|
|
17
|
+
{"host": "*.dev.azuresynapse.net", "resource": "https://dev.azuresynapse.net/"},
|
|
18
|
+
{"host": "*.ingest.monitor.azure.com", "resource": "https://monitor.azure.com/"},
|
|
19
|
+
{"host": "api.loganalytics.io"},
|
|
20
|
+
{"host": "api.loganalytics.azure.com", "resource": "https://api.loganalytics.io"},
|
|
21
|
+
{"host": "api.applicationinsights.io"},
|
|
22
|
+
{"host": "graph.microsoft.com"},
|
|
23
|
+
{"host": "*.sharepoint.com"},
|
|
24
|
+
{"host": "api.powerbi.com", "resource": "https://analysis.windows.net/powerbi/api"},
|
|
25
|
+
{"host": "*.analysis.windows.net", "resource": "https://analysis.windows.net/powerbi/api"},
|
|
26
|
+
{"host": "api.fabric.microsoft.com"},
|
|
27
|
+
{"host": "onelake.dfs.fabric.microsoft.com", "resource": "https://storage.azure.com/"},
|
|
28
|
+
{"host": "*.azure.net"},
|
|
29
|
+
{"host": "*.azure-test.net"},
|
|
30
|
+
{"host": "dev.azure.com", "resource": "https://app.vssps.visualstudio.com"},
|
|
31
|
+
{"host": "*.visualstudio.com", "resource": "https://app.vssps.visualstudio.com"}
|
|
32
|
+
],
|
|
33
|
+
"trusted_origins": []
|
|
34
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Generator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
|
|
8
|
+
from aiohttp import web
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class IdleTracker:
|
|
12
|
+
"""
|
|
13
|
+
Request activity and an optional idle-exit deadline for one proxy event loop.
|
|
14
|
+
|
|
15
|
+
Requests remain active until their response or stream finishes or is
|
|
16
|
+
cancelled. Waiting for an idle deadline is opt-in; the proxy service leaves
|
|
17
|
+
it disabled by default. This tracker does not start or restart processes.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._active = 0
|
|
22
|
+
self._idle_since = time.monotonic()
|
|
23
|
+
self._changed = asyncio.Event()
|
|
24
|
+
self._stopping = False
|
|
25
|
+
|
|
26
|
+
@contextmanager
|
|
27
|
+
def request(self) -> Generator[None]:
|
|
28
|
+
"""Keep the process active until a request finishes or is cancelled."""
|
|
29
|
+
if self._stopping:
|
|
30
|
+
raise web.HTTPServiceUnavailable(text="Proxy shutting down\n")
|
|
31
|
+
self._active += 1
|
|
32
|
+
self._changed.set()
|
|
33
|
+
try:
|
|
34
|
+
yield
|
|
35
|
+
finally:
|
|
36
|
+
self._active -= 1
|
|
37
|
+
if not self._active:
|
|
38
|
+
self._idle_since = time.monotonic()
|
|
39
|
+
self._changed.set()
|
|
40
|
+
|
|
41
|
+
async def wait_for_idle(self, timeout: float) -> None:
|
|
42
|
+
"""
|
|
43
|
+
Wait for inactivity, then reject new requests so the service can exit.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
timeout: Inactive seconds required after the last active request
|
|
47
|
+
finishes. Open pages and unused keep-alive connections do not
|
|
48
|
+
count as active requests.
|
|
49
|
+
"""
|
|
50
|
+
while True:
|
|
51
|
+
self._changed.clear()
|
|
52
|
+
if self._active:
|
|
53
|
+
await self._changed.wait()
|
|
54
|
+
continue
|
|
55
|
+
remaining = self._idle_since + timeout - time.monotonic()
|
|
56
|
+
if remaining <= 0:
|
|
57
|
+
self._stopping = True
|
|
58
|
+
return
|
|
59
|
+
try:
|
|
60
|
+
await asyncio.wait_for(self._changed.wait(), remaining)
|
|
61
|
+
except TimeoutError:
|
|
62
|
+
pass
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import errno
|
|
4
|
+
import ipaddress
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
from contextlib import AbstractContextManager
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
|
|
11
|
+
LOG = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LoopbackListeners(AbstractContextManager[list[socket.socket]]):
|
|
15
|
+
"""
|
|
16
|
+
Loopback TCP listeners for direct or systemd-activated proxy startup.
|
|
17
|
+
|
|
18
|
+
When systemd supplies LISTEN_PID and LISTEN_FDS, the context adopts its
|
|
19
|
+
listening sockets and checks their addresses and ports. Otherwise, it binds
|
|
20
|
+
IPv4 loopback and IPv6 loopback when available. Direct binding is the normal
|
|
21
|
+
startup mode on Windows and on Linux without a configured systemd socket.
|
|
22
|
+
|
|
23
|
+
Exiting closes this process's sockets. A configured systemd socket unit can
|
|
24
|
+
retain the listening endpoint; direct startup has no external listener.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
port: Configured loopback TCP port, also required for inherited sockets.
|
|
28
|
+
Zero selects a free port shared by directly bound IPv4 and IPv6.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, port: int) -> None:
|
|
32
|
+
self._port = port
|
|
33
|
+
self._sockets: list[socket.socket] = []
|
|
34
|
+
|
|
35
|
+
def __enter__(self) -> list[socket.socket]:
|
|
36
|
+
"""Return the inherited or newly bound sockets owned by this context."""
|
|
37
|
+
try:
|
|
38
|
+
if "LISTEN_PID" in os.environ or "LISTEN_FDS" in os.environ:
|
|
39
|
+
if int(os.environ.get("LISTEN_PID", "0")) != os.getpid():
|
|
40
|
+
raise ValueError("Invalid systemd activation PID")
|
|
41
|
+
count = int(os.environ.get("LISTEN_FDS", "0"))
|
|
42
|
+
if not 1 <= count <= 2:
|
|
43
|
+
raise ValueError("Systemd activation requires one or two listeners")
|
|
44
|
+
for descriptor in range(3, 3 + count):
|
|
45
|
+
listener = socket.socket(fileno=descriptor)
|
|
46
|
+
self._sockets.append(listener)
|
|
47
|
+
if (
|
|
48
|
+
listener.family not in {socket.AF_INET, socket.AF_INET6}
|
|
49
|
+
or listener.getsockopt(socket.SOL_SOCKET, socket.SO_TYPE)
|
|
50
|
+
!= socket.SOCK_STREAM
|
|
51
|
+
or not listener.getsockopt(
|
|
52
|
+
socket.SOL_SOCKET, socket.SO_ACCEPTCONN
|
|
53
|
+
)
|
|
54
|
+
):
|
|
55
|
+
raise ValueError("Activation requires listening TCP sockets")
|
|
56
|
+
address = listener.getsockname()
|
|
57
|
+
if (
|
|
58
|
+
not ipaddress.ip_address(address[0]).is_loopback
|
|
59
|
+
or address[1] != self._port
|
|
60
|
+
):
|
|
61
|
+
raise ValueError(
|
|
62
|
+
"Activation requires loopback listeners on the configured port"
|
|
63
|
+
)
|
|
64
|
+
listener.set_inheritable(False)
|
|
65
|
+
listener.setblocking(False)
|
|
66
|
+
else:
|
|
67
|
+
self._bind(socket.AF_INET, "127.0.0.1")
|
|
68
|
+
try:
|
|
69
|
+
self._bind(socket.AF_INET6, "::1")
|
|
70
|
+
except OSError as error:
|
|
71
|
+
if error.errno not in {
|
|
72
|
+
errno.EAFNOSUPPORT,
|
|
73
|
+
errno.EPROTONOSUPPORT,
|
|
74
|
+
errno.EADDRNOTAVAIL,
|
|
75
|
+
errno.ENODEV,
|
|
76
|
+
}:
|
|
77
|
+
raise
|
|
78
|
+
LOG.warning(
|
|
79
|
+
"IPv6 loopback unavailable; listening on IPv4 loopback only"
|
|
80
|
+
)
|
|
81
|
+
return self._sockets
|
|
82
|
+
except BaseException:
|
|
83
|
+
self.__exit__(None, None, None)
|
|
84
|
+
raise
|
|
85
|
+
|
|
86
|
+
def _bind(self, family: socket.AddressFamily, host: str) -> None:
|
|
87
|
+
listener = socket.socket(family, socket.SOCK_STREAM)
|
|
88
|
+
try:
|
|
89
|
+
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
90
|
+
if family == socket.AF_INET6:
|
|
91
|
+
listener.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
|
|
92
|
+
listener.bind((host, self._port))
|
|
93
|
+
self._port = listener.getsockname()[1]
|
|
94
|
+
listener.setblocking(False)
|
|
95
|
+
except BaseException:
|
|
96
|
+
listener.close()
|
|
97
|
+
raise
|
|
98
|
+
self._sockets.append(listener)
|
|
99
|
+
|
|
100
|
+
def __exit__(
|
|
101
|
+
self,
|
|
102
|
+
exc_type: type[BaseException] | None,
|
|
103
|
+
exc_value: BaseException | None,
|
|
104
|
+
traceback: TracebackType | None,
|
|
105
|
+
) -> None:
|
|
106
|
+
for listener in self._sockets:
|
|
107
|
+
listener.close()
|
|
108
|
+
self._sockets.clear()
|