Coverage for daklib/rpc_peer.py: 100%
66 statements
« prev ^ index » next coverage.py v7.6.0, created at 2026-08-03 16:46 +0000
« prev ^ index » next coverage.py v7.6.0, created at 2026-08-03 16:46 +0000
1# SPDX-License-Identifier: GPL-2.0-or-later
2# © 2026, Ansgar 🙀 <ansgar@debian.org>
4"""
5Peer address handling for the DAK RPC server.
6"""
8import contextvars
9import ipaddress
10import logging
11from collections.abc import Callable
12from dataclasses import dataclass
13from typing import Any, override
15import grpc
17logger = logging.getLogger(__name__)
19current_peer: contextvars.ContextVar["PeerAddress | None"] = contextvars.ContextVar(
20 "current_peer", default=None
21)
24@dataclass(frozen=True)
25class PeerAddress:
26 ip: ipaddress.IPv4Address | ipaddress.IPv6Address
27 port: int = 0
29 @override
30 def __str__(self) -> str:
31 if isinstance(self.ip, ipaddress.IPv6Address):
32 return f"[{self.ip}]:{self.port}"
33 return f"{self.ip}:{self.port}"
35 @classmethod
36 def from_forwarded_header(cls, value: str) -> "PeerAddress | None":
37 """Parse the client address from a header like X-Forwarded-For.
39 Only the last entry of a comma-separated list is used: it is the
40 one appended by the trusted reverse proxy; earlier entries are
41 client-controlled. Entries must be bare IP addresses; the port is
42 not available (0).
43 """
44 addr = value.rsplit(",", 1)[-1].strip()
45 try:
46 return cls(ip=ipaddress.ip_address(addr))
47 except ValueError:
48 return None
50 @classmethod
51 def from_grpc_peer(cls, peer: str) -> "PeerAddress | None":
52 """Parse a gRPC peer string like `ipv4:1.2.3.4:5678` or
53 `ipv6:[::1]:5678`.
55 Returns None for peers without an IP address (unix sockets).
56 """
57 if peer.startswith("ipv4:"):
58 host, sep, port_str = peer.removeprefix("ipv4:").rpartition(":")
59 elif peer.startswith("ipv6:"):
60 host, sep, port_str = peer.removeprefix("ipv6:").rpartition(":")
61 if not (host.startswith("[") and host.endswith("]")):
62 return None
63 host = host[1:-1]
64 else:
65 return None
66 if not sep:
67 return None
68 try:
69 port = int(port_str)
70 if not 0 <= port <= 65535:
71 return None
72 return cls(ip=ipaddress.ip_address(host), port=port)
73 except ValueError:
74 return None
77class PeerAddressInterceptor(grpc.ServerInterceptor):
78 """gRPC interceptor that records the client's peer address.
80 The address is taken from `peer_header` if configured, falling back to
81 the connection's transport address. The header must only be configured
82 when all connections arrive via a trusted reverse proxy that sets it
83 (e.g. via a unix socket only the proxy can reach); otherwise clients
84 can spoof their address.
85 """
87 def __init__(self, peer_header: str | None = None) -> None:
88 # gRPC metadata keys are always lowercase
89 self._peer_header = peer_header.lower() if peer_header else None
91 @override
92 def intercept_service(
93 self,
94 continuation: "Callable[[grpc.HandlerCallDetails], grpc.RpcMethodHandler[Any, Any] | None]",
95 handler_call_details: grpc.HandlerCallDetails,
96 ) -> "grpc.RpcMethodHandler[Any, Any] | None":
97 peer: PeerAddress | None = None
98 if self._peer_header is not None:
99 metadata = dict(handler_call_details.invocation_metadata)
100 value = metadata.get(self._peer_header)
101 if isinstance(value, str):
102 peer = PeerAddress.from_forwarded_header(value)
103 if peer is None:
104 logger.warning(
105 "invalid peer address in %s header: %r",
106 self._peer_header,
107 value,
108 )
110 current_peer.set(peer)
112 handler = continuation(handler_call_details)
114 if peer is not None or handler is None or handler.unary_unary is None:
115 return handler
117 # No trusted header: resolve the transport address, which is only
118 # reachable via the ServicerContext inside the handler.
119 original_fn = handler.unary_unary
121 def peer_wrapper(request: Any, context: grpc.ServicerContext) -> Any:
122 current_peer.set(PeerAddress.from_grpc_peer(context.peer()))
123 return original_fn(request, context)
125 return grpc.unary_unary_rpc_method_handler(
126 peer_wrapper,
127 request_deserializer=handler.request_deserializer,
128 response_serializer=handler.response_serializer,
129 )