keepup-gateway 0.6.0__tar.gz

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,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: keepup-gateway
3
+ Version: 0.6.0
4
+ Summary: One way in for the application's services: a route table, a proxy, a balancer and the sign-in at the edge.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: keepup-admin>=0.5.0
8
+ Requires-Dist: keepup-db>=0.4.0
9
+ Requires-Dist: fastapi<1,>=0.128
10
+ Requires-Dist: starlette<2,>=1.3.1
11
+ Requires-Dist: httpx<1,>=0.28
12
+ Requires-Dist: websockets<18,>=13
13
+
14
+ # keepup-gateway
15
+
16
+ One way in for the application's services: a route table, a proxy, a balancer and the sign-in at the edge.
17
+
18
+ One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
19
+ panels: the base is installed as `keepup-admin` and imported as `keepup`, and
20
+ this distribution carries one part of it. It is installed on its own
21
+ (`pip install keepup-gateway`) or together with the base (`pip install "keepup-admin[gateway]"`),
22
+ and it declares itself to the kernel through its
23
+ `[project.entry-points."keepup.plugins"]` entry point.
24
+
25
+ Every request comes to the application first; the gateway finds the route it is
26
+ addressed to, checks the sign-in and the right when the route asks for them, and
27
+ passes the request on to a microservice, a subsystem of the application, or any
28
+ other address. A service behind the gateway learns who is calling from signed
29
+ `X-Keepup-*` headers and checks them with `keepup_gateway.verify_identity`.
@@ -0,0 +1,16 @@
1
+ # keepup-gateway
2
+
3
+ One way in for the application's services: a route table, a proxy, a balancer and the sign-in at the edge.
4
+
5
+ One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
6
+ panels: the base is installed as `keepup-admin` and imported as `keepup`, and
7
+ this distribution carries one part of it. It is installed on its own
8
+ (`pip install keepup-gateway`) or together with the base (`pip install "keepup-admin[gateway]"`),
9
+ and it declares itself to the kernel through its
10
+ `[project.entry-points."keepup.plugins"]` entry point.
11
+
12
+ Every request comes to the application first; the gateway finds the route it is
13
+ addressed to, checks the sign-in and the right when the route asks for them, and
14
+ passes the request on to a microservice, a subsystem of the application, or any
15
+ other address. A service behind the gateway learns who is calling from signed
16
+ `X-Keepup-*` headers and checks them with `keepup_gateway.verify_identity`.
@@ -0,0 +1,28 @@
1
+ """One way in for the application's services.
2
+
3
+ This distribution provides `gateway` and declares one plugin, `gateway`, in the
4
+ `keepup.plugins` group. It depends on `keepup-admin` for the constructor and for
5
+ the kernel's question "who is calling" (`keepup.kernel.security`), and on nothing
6
+ of another capability: a deployment with no sign-in can still route.
7
+
8
+ The pure parts -- the table, the balancer, the headers and the identity a
9
+ service checks -- import nothing beyond the standard library, so a service
10
+ behind the gateway can verify what it was sent without installing FastAPI.
11
+ """
12
+
13
+ __version__ = "0.6.0"
14
+
15
+ from keepup_gateway.identity import IdentityError, sign_identity, verify_identity # noqa: E402
16
+
17
+ _PLUGIN_NAMES = {"GatewayPlugin", "SERVICE_GATEWAY"}
18
+
19
+ __all__ = ["GatewayPlugin", "SERVICE_GATEWAY", "IdentityError", "sign_identity",
20
+ "verify_identity"]
21
+
22
+
23
+ def __getattr__(name):
24
+ if name in _PLUGIN_NAMES:
25
+ from keepup_gateway import plugin
26
+
27
+ return getattr(plugin, name)
28
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,142 @@
1
+ """A service telling the gateway it is here, and repeating it while it is.
2
+
3
+ The application behind the gateway installs `keepup-gateway` too, and its
4
+ `announce` section says which gateway, under what name and prefixes, and at which
5
+ address it is reached. It registers at the start and again every `interval`
6
+ seconds, well inside the record's time to live; on a normal stop it takes its
7
+ address back, and on a crash the record runs out by itself.
8
+
9
+ The loop is an asyncio task of the plugin, not a job of the scheduler: a headless
10
+ subsystem need not install `keepup-tasks` to be found.
11
+ """
12
+
13
+ import asyncio
14
+ import json
15
+ import logging
16
+ from typing import Any, Dict, Mapping, Optional
17
+
18
+ import httpx
19
+
20
+ from keepup_gateway.registry import (
21
+ ACTION_DEREGISTER,
22
+ ACTION_REGISTER,
23
+ DEFAULT_TTL_SECONDS,
24
+ sign_registration,
25
+ )
26
+ from keepup_gateway.table import TableError
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ __all__ = ["REGISTER_PATH", "Announcer", "parse_announce"]
31
+
32
+ REGISTER_PATH = "/api/gateway/services"
33
+ #: The route keys an announcement may carry to the gateway.
34
+ _ROUTE_KEYS = ("methods", "strip_prefix", "auth", "permission", "timeout",
35
+ "connect_timeout", "max_body_bytes", "websocket")
36
+
37
+
38
+ def parse_announce(section: Optional[Mapping[str, Any]]) -> Optional[Dict[str, Any]]:
39
+ """The ``announce`` section, checked; None when there is none.
40
+
41
+ Raises:
42
+ TableError: naming what is missing.
43
+ """
44
+ if not section:
45
+ return None
46
+ if not isinstance(section, Mapping):
47
+ raise TableError("gateway: announce must be an object")
48
+ for key in ("gateway", "name", "address"):
49
+ if not isinstance(section.get(key), str) or not section[key].strip():
50
+ raise TableError(f"gateway: announce.{key} is required")
51
+ prefixes = section.get("prefixes", section.get("prefix"))
52
+ if isinstance(prefixes, str):
53
+ prefixes = [prefixes]
54
+ if not isinstance(prefixes, list) or not prefixes \
55
+ or not all(isinstance(item, str) and item.strip() for item in prefixes):
56
+ raise TableError("gateway: announce.prefixes is required -- a list of paths, "
57
+ "or announce.prefix for one")
58
+ ttl = section.get("ttl", DEFAULT_TTL_SECONDS)
59
+ interval = section.get("interval", ttl / 3)
60
+ for key, value in (("ttl", ttl), ("interval", interval)):
61
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
62
+ raise TableError(f"gateway: announce.{key} must be a positive number")
63
+ if interval >= ttl:
64
+ raise TableError("gateway: announce.interval must be shorter than announce.ttl, "
65
+ "or the record runs out between two announcements")
66
+ record = {"name": section["name"].strip(),
67
+ "prefixes": [item.strip() for item in prefixes],
68
+ "address": section["address"].strip().rstrip("/"), "ttl": ttl}
69
+ record.update({key: section[key] for key in _ROUTE_KEYS if key in section})
70
+ return {"gateway": section["gateway"].strip().rstrip("/"), "interval": float(interval),
71
+ "record": record}
72
+
73
+
74
+ class Announcer:
75
+ """Registers one service with one gateway, over and over.
76
+
77
+ Args:
78
+ settings: what :func:`parse_announce` returned.
79
+ secret: the secret shared with the gateway.
80
+ transport: an httpx transport, for a test.
81
+ """
82
+
83
+ def __init__(self, settings: Mapping[str, Any], secret: str,
84
+ transport: Optional[httpx.AsyncBaseTransport] = None):
85
+ self.gateway = settings["gateway"]
86
+ self.interval = settings["interval"]
87
+ self.record = dict(settings["record"])
88
+ self.secret = secret
89
+ self._client = httpx.AsyncClient(transport=transport, timeout=5.0)
90
+ self._task: Optional[asyncio.Task] = None
91
+ self.last_error: Optional[str] = None
92
+ self.registered = False
93
+
94
+ def start(self) -> None:
95
+ """Begin announcing, in the background."""
96
+ if self._task is None:
97
+ self._task = asyncio.get_running_loop().create_task(self._loop())
98
+
99
+ async def stop(self) -> None:
100
+ """Stop announcing and take the address back."""
101
+ if self._task is not None:
102
+ self._task.cancel()
103
+ try:
104
+ await self._task
105
+ except asyncio.CancelledError:
106
+ pass
107
+ self._task = None
108
+ await self.send(ACTION_DEREGISTER)
109
+ await self._client.aclose()
110
+
111
+ async def _loop(self) -> None:
112
+ while True:
113
+ await self.send(ACTION_REGISTER)
114
+ await asyncio.sleep(self.interval)
115
+
116
+ async def send(self, action: str) -> bool:
117
+ """One registration or one withdrawal; a failure is logged and retried later."""
118
+ record = self.record if action == ACTION_REGISTER else {
119
+ "name": self.record["name"], "address": self.record["address"]}
120
+ body = json.dumps(record, sort_keys=True).encode("utf-8")
121
+ headers = {"Content-Type": "application/json",
122
+ **sign_registration(self.secret, action, body)}
123
+ method = "POST" if action == ACTION_REGISTER else "DELETE"
124
+ try:
125
+ answer = await self._client.request(method, self.gateway + REGISTER_PATH,
126
+ content=body, headers=headers)
127
+ except httpx.HTTPError as error:
128
+ return self._failed(f"the gateway did not answer: {error}")
129
+ if answer.status_code >= 300:
130
+ return self._failed(f"the gateway answered {answer.status_code}: "
131
+ f"{answer.text[:200]}")
132
+ if self.last_error is not None or not self.registered:
133
+ logger.info("Gateway: %s %s at %s", action, self.record["name"], self.gateway)
134
+ self.last_error = None
135
+ self.registered = action == ACTION_REGISTER
136
+ return True
137
+
138
+ def _failed(self, reason: str) -> bool:
139
+ if reason != self.last_error:
140
+ logger.warning("Gateway announcement of %s: %s", self.record["name"], reason)
141
+ self.last_error = reason
142
+ return False
@@ -0,0 +1,192 @@
1
+ """The applications behind the gateway, as the administrator sees them.
2
+
3
+ A route marked ``application`` stands for a keepup application of its own -- a
4
+ headless subsystem, a microservice built on the framework. The administrator
5
+ wants to see it the way the panel shows its own runtime: which plugins it runs,
6
+ which of them failed and why, whether it is up.
7
+
8
+ The subsystem may have no panel, no `keepup-auth` and no `keepup-modules`, so the
9
+ panel's own `/api/admin/plugins` -- guarded by the panel's sign-in -- is not
10
+ something to count on. What it has instead is `keepup-gateway`, installed with
11
+ the same `identity_secret`: it answers `GET /api/gateway/self` to the gateway's
12
+ signed identity of an administrator, with its runtime's own report. The gateway
13
+ asks every address of such a route for that, for `/api/health` (public in every
14
+ keepup application) and for `/metrics`, at once and with a short timeout -- a
15
+ card for a dead address says so instead of holding the page.
16
+ """
17
+
18
+ import asyncio
19
+ from typing import Any, Dict, List, Optional
20
+
21
+ import httpx
22
+
23
+ from keepup_gateway.identity import Caller, sign_identity
24
+ from keepup_gateway.headers import request_id_of
25
+
26
+ __all__ = [
27
+ "SELF_PATH",
28
+ "HEALTH_PATH",
29
+ "METRICS_PATH",
30
+ "PROBE_TIMEOUT_SECONDS",
31
+ "ADMIN_ROLE",
32
+ "describe_self",
33
+ "Prober",
34
+ ]
35
+
36
+ SELF_PATH = "/api/gateway/self"
37
+ HEALTH_PATH = "/api/health"
38
+ METRICS_PATH = "/metrics"
39
+ #: A card is a look, not a request somebody waits on: an address that does not
40
+ #: answer in this long is shown as not answering.
41
+ PROBE_TIMEOUT_SECONDS = 3.0
42
+ #: The role whose signed identity may read a runtime's report.
43
+ ADMIN_ROLE = "ADMIN"
44
+ #: What of a plugin's row the card shows.
45
+ _PLUGIN_FIELDS = ("id", "name", "kind", "distribution", "enabled", "initialized",
46
+ "outcome", "reason", "degraded")
47
+
48
+
49
+ def describe_self(manager: Any, settings: Any = None) -> Dict[str, Any]:
50
+ """What this application says about itself to the gateway.
51
+
52
+ Args:
53
+ manager: the runtime the plugin was loaded by (or a 0.3.0 manager).
54
+ settings: the application's settings, when known.
55
+
56
+ Returns:
57
+ Its name, this replica, the framework's version and every plugin's state.
58
+ """
59
+ import keepup
60
+ from keepup.instance import get_instance_id
61
+
62
+ settings = settings if settings is not None else getattr(manager, "settings", None)
63
+ return {
64
+ "application": getattr(settings, "project_name", None) or getattr(settings, "title", None)
65
+ or "keepup application",
66
+ "title": getattr(settings, "title", None),
67
+ "instance": get_instance_id(),
68
+ "keepup": getattr(keepup, "__version__", None),
69
+ "plugins": _plugins_of(manager),
70
+ }
71
+
72
+
73
+ def _plugins_of(manager: Any) -> List[Dict[str, Any]]:
74
+ """Every plugin's state, from the runtime's report or from a manager's plugins."""
75
+ report = getattr(manager, "report", None)
76
+ if callable(report):
77
+ return [{field: row.get(field) for field in _PLUGIN_FIELDS} for row in report()]
78
+ plugins = getattr(manager, "plugins", None) or {}
79
+ return [{"id": plugin_id, "name": getattr(plugin, "name", plugin_id),
80
+ "initialized": bool(getattr(plugin, "initialized", False)),
81
+ "outcome": "running" if getattr(plugin, "initialized", False) else None}
82
+ for plugin_id, plugin in dict(plugins).items()]
83
+
84
+
85
+ class Prober:
86
+ """Asks the addresses of application routes what they are.
87
+
88
+ Args:
89
+ client: the gateway's HTTP client.
90
+ secret: what the administrator's identity is signed with.
91
+ timeout: seconds per question.
92
+ """
93
+
94
+ def __init__(self, client: httpx.AsyncClient, secret: str,
95
+ timeout: float = PROBE_TIMEOUT_SECONDS):
96
+ self.client = client
97
+ self.secret = secret
98
+ self.timeout = timeout
99
+
100
+ async def cards(self, routes, balancer, caller: Caller) -> List[Dict[str, Any]]:
101
+ """One entry per application, each with a card per address.
102
+
103
+ A registered service holds several prefixes and is one application: its
104
+ routes are shown once, with all their prefixes, and each address is
105
+ asked once.
106
+
107
+ Args:
108
+ routes: the table.
109
+ balancer: whose view of the addresses the card shows beside the answers.
110
+ caller: the administrator who asked, signed into each question.
111
+ """
112
+ groups: Dict[str, Dict[str, Any]] = {}
113
+ for route in sorted((route for route in routes if route.application),
114
+ key=lambda item: (item.group or item.id, item.prefix)):
115
+ key = route.group or route.id
116
+ entry = groups.setdefault(key, {"route": f"service:{route.group}" if route.group
117
+ else route.id,
118
+ "prefixes": [], "addresses": {}, "ids": []})
119
+ entry["prefixes"].append(route.prefix)
120
+ entry["ids"].append(route.id)
121
+ for address in route.upstreams:
122
+ entry["addresses"].setdefault(address, route.id)
123
+ asked = [(entry, address, route_id) for entry in groups.values()
124
+ for address, route_id in entry["addresses"].items()]
125
+ answers = await asyncio.gather(*(self.card(address, caller)
126
+ for _, address, _ in asked))
127
+ out = {id(entry): {"route": entry["route"], "prefix": entry["prefixes"][0],
128
+ "prefixes": entry["prefixes"], "addresses": []}
129
+ for entry in groups.values()}
130
+ for (entry, address, route_id), answer in zip(asked, answers):
131
+ out[id(entry)]["addresses"].append({**answer, **balancer.state(route_id, address)})
132
+ return [out[id(entry)] for entry in groups.values()]
133
+
134
+ async def card(self, address: str, caller: Caller) -> Dict[str, Any]:
135
+ """What one address answers to the three questions."""
136
+ own, health, metrics = await asyncio.gather(
137
+ self._get(address, SELF_PATH, caller),
138
+ self._get(address, HEALTH_PATH, None),
139
+ self._get(address, METRICS_PATH, caller),
140
+ )
141
+ card: Dict[str, Any] = {"address": address, "reachable": any(
142
+ answer["status"] is not None for answer in (own, health, metrics))}
143
+ card["self"] = _json_or_error(own)
144
+ card["health"] = {"status": health["status"], "error": health["error"],
145
+ "state": (_json(health) or {}).get("status")}
146
+ card["metrics"] = {"status": metrics["status"], "error": metrics["error"],
147
+ "series": _series(metrics) if metrics["status"] == 200 else None}
148
+ return card
149
+
150
+ async def _get(self, address: str, path: str, caller: Optional[Caller]) -> Dict[str, Any]:
151
+ """One question: the status and the body, or the reason there is none."""
152
+ request_id = request_id_of({})
153
+ headers = {"X-Request-Id": request_id}
154
+ if caller is not None and self.secret:
155
+ headers.update(sign_identity(caller, self.secret, request_id))
156
+ try:
157
+ answer = await self.client.get(address + path, headers=headers,
158
+ timeout=self.timeout)
159
+ except httpx.HTTPError as error:
160
+ return {"status": None, "body": b"", "error": str(error)[:200] or type(error).__name__}
161
+ return {"status": answer.status_code, "body": answer.content, "error": None}
162
+
163
+
164
+ def _json(answer: Dict[str, Any]) -> Optional[Dict[str, Any]]:
165
+ """The body as an object, or None."""
166
+ import json
167
+
168
+ try:
169
+ value = json.loads(answer["body"] or b"")
170
+ except (ValueError, TypeError):
171
+ return None
172
+ return value if isinstance(value, dict) else None
173
+
174
+
175
+ def _json_or_error(answer: Dict[str, Any]) -> Dict[str, Any]:
176
+ """The application's own report, or why it could not be read."""
177
+ if answer["status"] == 200 and _json(answer) is not None:
178
+ return {"status": 200, **_json(answer)}
179
+ reason = answer["error"]
180
+ if reason is None:
181
+ reason = {401: "the address does not accept the gateway's identity -- is keepup-gateway "
182
+ "installed there with the same identity_secret?",
183
+ 403: "the identity was accepted, the role was not",
184
+ 404: "the address has no keepup-gateway, or it has no identity_secret"
185
+ }.get(answer["status"], f"answered {answer['status']}")
186
+ return {"status": answer["status"], "error": reason}
187
+
188
+
189
+ def _series(answer: Dict[str, Any]) -> int:
190
+ """How many samples a Prometheus answer carries."""
191
+ text = (answer["body"] or b"").decode("utf-8", "replace")
192
+ return sum(1 for line in text.splitlines() if line and not line.startswith("#"))
@@ -0,0 +1,73 @@
1
+ """Which address of a route gets the next request.
2
+
3
+ Round robin over the addresses that have not failed lately. An address that did
4
+ not take a connection or did not answer in time is left out for the route's
5
+ cooldown. The state is this replica's own, the way a balancer's always is: each
6
+ replica finds out for itself whether it reaches an address, and sharing that
7
+ through the database would turn every proxied request into a write.
8
+ """
9
+
10
+ import threading
11
+ import time
12
+ from typing import Callable, Dict, List, Optional, Tuple
13
+
14
+ __all__ = ["Balancer"]
15
+
16
+
17
+ class Balancer:
18
+ """The turn and the cooldowns of every route, in this replica.
19
+
20
+ Args:
21
+ clock: what tells the time; a test passes its own.
22
+ """
23
+
24
+ def __init__(self, clock: Callable[[], float] = time.monotonic):
25
+ self._clock = clock
26
+ self._lock = threading.Lock()
27
+ self._turn: Dict[str, int] = {}
28
+ self._cooling: Dict[Tuple[str, str], float] = {}
29
+ self._failures: Dict[Tuple[str, str], str] = {}
30
+
31
+ def order(self, route_id: str, upstreams: Tuple[str, ...]) -> List[str]:
32
+ """The addresses to try for one request, the next in turn first.
33
+
34
+ Addresses that are cooling go after the live ones rather than being
35
+ dropped: when every address failed a moment ago, trying one is better
36
+ than refusing a request that might have passed.
37
+ """
38
+ with self._lock:
39
+ start = self._turn.get(route_id, 0)
40
+ self._turn[route_id] = (start + 1) % max(1, len(upstreams))
41
+ now = self._clock()
42
+ turned = [upstreams[(start + step) % len(upstreams)]
43
+ for step in range(len(upstreams))]
44
+ live = [address for address in turned
45
+ if self._cooling.get((route_id, address), 0.0) <= now]
46
+ cooling = [address for address in turned if address not in live]
47
+ return live + cooling
48
+
49
+ def failed(self, route_id: str, address: str, cooldown: float, reason: str = "") -> None:
50
+ """Leave an address out of the turn for a while."""
51
+ with self._lock:
52
+ self._cooling[(route_id, address)] = self._clock() + cooldown
53
+ self._failures[(route_id, address)] = reason
54
+
55
+ def answered(self, route_id: str, address: str) -> None:
56
+ """An address answered: it is back in the turn at once."""
57
+ with self._lock:
58
+ self._cooling.pop((route_id, address), None)
59
+ self._failures.pop((route_id, address), None)
60
+
61
+ def state(self, route_id: str, address: str) -> Dict[str, Optional[object]]:
62
+ """What the administrator's report says about one address."""
63
+ with self._lock:
64
+ until = self._cooling.get((route_id, address))
65
+ left = None if until is None else until - self._clock()
66
+ if left is not None and left <= 0:
67
+ left = None
68
+ return {
69
+ "address": address,
70
+ "state": "live" if left is None else "cooling",
71
+ "cooling_seconds": None if left is None else round(left, 1),
72
+ "last_failure": self._failures.get((route_id, address)) if left else None,
73
+ }
@@ -0,0 +1,104 @@
1
+ """The headers that cross the gateway, in each direction.
2
+
3
+ What describes the connection rather than the message (RFC 9110 7.6.1) stays on
4
+ its side; what only the gateway may say about the caller is cut from the
5
+ client's request on every route, signed in or not -- a header a service trusts
6
+ must never be one a client can write. Pure: headers in, headers out.
7
+ """
8
+
9
+ import re
10
+ import uuid
11
+ from typing import Iterable, List, Mapping, Optional, Tuple
12
+
13
+ from keepup_gateway.identity import REQUEST_ID, is_identity_header
14
+
15
+ __all__ = ["HOP_BY_HOP", "request_id_of", "to_upstream", "to_client"]
16
+
17
+ HOP_BY_HOP = frozenset({
18
+ "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te",
19
+ "trailer", "trailers", "transfer-encoding", "upgrade", "proxy-connection",
20
+ })
21
+ #: Recomputed on each side: the body is re-framed by whoever sends it on.
22
+ FRAMING = frozenset({"content-length", "host"})
23
+
24
+ #: A request id a client may choose: short, and nothing a log line could be broken by.
25
+ _REQUEST_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
26
+
27
+ Pairs = List[Tuple[str, str]]
28
+
29
+
30
+ def request_id_of(headers: Mapping[str, str]) -> str:
31
+ """The client's request id when it is a usable one, a new one otherwise."""
32
+ given = _get(headers, REQUEST_ID)
33
+ if given and _REQUEST_ID.match(given):
34
+ return given
35
+ return uuid.uuid4().hex
36
+
37
+
38
+ def to_upstream(pairs: Iterable[Tuple[str, str]], *, client_host: str, scheme: str,
39
+ host: str, prefix: str, request_id: str,
40
+ added: Optional[Mapping[str, str]] = None) -> Pairs:
41
+ """The headers a service is sent.
42
+
43
+ Args:
44
+ pairs: the client's headers, as (name, value) pairs -- a name may repeat.
45
+ client_host: the address the request came from.
46
+ scheme: ``http`` or ``https``, as the client used it.
47
+ host: the host the client asked for.
48
+ prefix: the route's prefix, for a service that builds its own links.
49
+ request_id: the id this request travels under.
50
+ added: the route's own headers and the signed identity.
51
+ """
52
+ pairs = list(pairs)
53
+ named = _connection_names(pairs)
54
+ forwarded_for = None
55
+ out: Pairs = []
56
+ for name, value in pairs:
57
+ lowered = name.lower()
58
+ if lowered in HOP_BY_HOP or lowered in FRAMING or lowered in named:
59
+ continue
60
+ if is_identity_header(lowered) or lowered.startswith("x-forwarded-") \
61
+ or lowered == REQUEST_ID.lower():
62
+ if lowered == "x-forwarded-for":
63
+ forwarded_for = value
64
+ continue
65
+ out.append((name, value))
66
+ chain = f"{forwarded_for}, {client_host}" if forwarded_for else client_host
67
+ out.extend([
68
+ ("X-Forwarded-For", chain),
69
+ ("X-Forwarded-Proto", scheme),
70
+ ("X-Forwarded-Host", host),
71
+ ("X-Forwarded-Prefix", prefix),
72
+ (REQUEST_ID, request_id),
73
+ ])
74
+ for name, value in (added or {}).items():
75
+ out.append((name, value))
76
+ return out
77
+
78
+
79
+ def to_client(pairs: Iterable[Tuple[str, str]], request_id: str) -> Pairs:
80
+ """The headers the client is sent back: the service's, minus the connection's."""
81
+ pairs = list(pairs)
82
+ named = _connection_names(pairs)
83
+ out = [(name, value) for name, value in pairs
84
+ if name.lower() not in HOP_BY_HOP and name.lower() not in FRAMING
85
+ and name.lower() not in named and name.lower() != REQUEST_ID.lower()]
86
+ out.append((REQUEST_ID, request_id))
87
+ return out
88
+
89
+
90
+ def _connection_names(pairs: Pairs) -> frozenset:
91
+ """The headers ``Connection`` names: they belong to this hop too."""
92
+ names = set()
93
+ for name, value in pairs:
94
+ if name.lower() == "connection":
95
+ names.update(part.strip().lower() for part in value.split(",") if part.strip())
96
+ return frozenset(names)
97
+
98
+
99
+ def _get(headers: Mapping[str, str], name: str) -> str:
100
+ """A header's value, whatever the case of its name."""
101
+ value = headers.get(name) if hasattr(headers, "get") else None
102
+ if value is None and isinstance(headers, dict):
103
+ value = {key.lower(): item for key, item in headers.items()}.get(name.lower())
104
+ return str(value or "")