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.
@@ -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()