ipregistry-fastapi 1.0.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.
- ipregistry_fastapi/__init__.py +74 -0
- ipregistry_fastapi/blocking.py +145 -0
- ipregistry_fastapi/dependencies.py +72 -0
- ipregistry_fastapi/exceptions.py +32 -0
- ipregistry_fastapi/guards.py +81 -0
- ipregistry_fastapi/ip.py +153 -0
- ipregistry_fastapi/lifespan.py +65 -0
- ipregistry_fastapi/middleware.py +144 -0
- ipregistry_fastapi/py.typed +0 -0
- ipregistry_fastapi/service.py +202 -0
- ipregistry_fastapi/settings.py +128 -0
- ipregistry_fastapi/testing.py +186 -0
- ipregistry_fastapi/version.py +17 -0
- ipregistry_fastapi-1.0.0.dist-info/METADATA +359 -0
- ipregistry_fastapi-1.0.0.dist-info/RECORD +17 -0
- ipregistry_fastapi-1.0.0.dist-info/WHEEL +4 -0
- ipregistry_fastapi-1.0.0.dist-info/licenses/LICENSE.txt +201 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Official Ipregistry integration for FastAPI.
|
|
3
|
+
|
|
4
|
+
Dependency-driven IP geolocation and threat detection for every request,
|
|
5
|
+
built on the asynchronous Ipregistry Python client.
|
|
6
|
+
|
|
7
|
+
Copyright 2026 Ipregistry (https://ipregistry.co).
|
|
8
|
+
|
|
9
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
10
|
+
you may not use this file except in compliance with the License.
|
|
11
|
+
You may obtain a copy of the License at
|
|
12
|
+
|
|
13
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
14
|
+
|
|
15
|
+
Unless required by applicable law or agreed to in writing, software
|
|
16
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
17
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
18
|
+
See the License for the specific language governing permissions and
|
|
19
|
+
limitations under the License.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from ipregistry import (
|
|
23
|
+
ApiError as ApiError,
|
|
24
|
+
ApiResponse as ApiResponse,
|
|
25
|
+
Carrier as Carrier,
|
|
26
|
+
ClientError as ClientError,
|
|
27
|
+
Company as Company,
|
|
28
|
+
Connection as Connection,
|
|
29
|
+
Country as Country,
|
|
30
|
+
Currency as Currency,
|
|
31
|
+
ErrorCode as ErrorCode,
|
|
32
|
+
IpInfo as IpInfo,
|
|
33
|
+
IpregistryError as IpregistryError,
|
|
34
|
+
IpregistryLookupError as IpregistryLookupError,
|
|
35
|
+
Location as Location,
|
|
36
|
+
RequesterIpInfo as RequesterIpInfo,
|
|
37
|
+
Security as Security,
|
|
38
|
+
TimeZone as TimeZone,
|
|
39
|
+
UserAgent as UserAgent,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
from .blocking import (
|
|
43
|
+
Blocker as Blocker,
|
|
44
|
+
CountryBlocker as CountryBlocker,
|
|
45
|
+
ThreatBlocker as ThreatBlocker,
|
|
46
|
+
block_countries as block_countries,
|
|
47
|
+
block_threats as block_threats,
|
|
48
|
+
)
|
|
49
|
+
from .dependencies import (
|
|
50
|
+
ClientIpDep as ClientIpDep,
|
|
51
|
+
IpInfoDep as IpInfoDep,
|
|
52
|
+
IpregistryDep as IpregistryDep,
|
|
53
|
+
get_client_ip as get_client_ip,
|
|
54
|
+
get_ip_info as get_ip_info,
|
|
55
|
+
get_ipregistry as get_ipregistry,
|
|
56
|
+
)
|
|
57
|
+
from .exceptions import MissingApiKeyError as MissingApiKeyError
|
|
58
|
+
from .guards import is_bot as is_bot, is_eu as is_eu, is_threat as is_threat
|
|
59
|
+
from .ip import (
|
|
60
|
+
IpExtractor as IpExtractor,
|
|
61
|
+
IpSource as IpSource,
|
|
62
|
+
anonymize_ip as anonymize_ip,
|
|
63
|
+
build_ip_extractor as build_ip_extractor,
|
|
64
|
+
is_public_ip as is_public_ip,
|
|
65
|
+
)
|
|
66
|
+
from .lifespan import add_ipregistry as add_ipregistry, ipregistry_lifespan as ipregistry_lifespan
|
|
67
|
+
from .middleware import IpregistryMiddleware as IpregistryMiddleware
|
|
68
|
+
from .service import Ipregistry as Ipregistry
|
|
69
|
+
from .settings import (
|
|
70
|
+
DEFAULT_BASE_URL as DEFAULT_BASE_URL,
|
|
71
|
+
EU_BASE_URL as EU_BASE_URL,
|
|
72
|
+
IpregistrySettings as IpregistrySettings,
|
|
73
|
+
)
|
|
74
|
+
from .version import __version__ as __version__
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright 2026 Ipregistry (https://ipregistry.co).
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from typing import Iterable, Literal, Union
|
|
21
|
+
|
|
22
|
+
from fastapi import HTTPException
|
|
23
|
+
from fastapi.requests import HTTPConnection
|
|
24
|
+
from ipregistry import IpInfo
|
|
25
|
+
|
|
26
|
+
from .dependencies import SERVICE_UNAVAILABLE_DETAIL, IpregistryDep
|
|
27
|
+
from .guards import is_threat
|
|
28
|
+
|
|
29
|
+
_COUNTRY_CODE_PATTERN = re.compile(r"^[A-Za-z]{2}$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Blocker:
|
|
33
|
+
"""Base for blocking rules, usable both as a FastAPI dependency and as a
|
|
34
|
+
middleware action."""
|
|
35
|
+
|
|
36
|
+
async def __call__(self, connection: HTTPConnection, service: IpregistryDep) -> None:
|
|
37
|
+
info = await service.for_request(connection)
|
|
38
|
+
if info is None and not service.settings.fail_open and service.request_error(connection) is not None:
|
|
39
|
+
raise HTTPException(status_code=503, detail=SERVICE_UNAVAILABLE_DETAIL)
|
|
40
|
+
self.check(info)
|
|
41
|
+
|
|
42
|
+
def check(self, info: IpInfo | None) -> None:
|
|
43
|
+
"""Raise HTTPException when the request must be blocked."""
|
|
44
|
+
raise NotImplementedError
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CountryBlocker(Blocker):
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
countries: Iterable[str],
|
|
51
|
+
mode: Literal["block", "allow"],
|
|
52
|
+
unknown: Literal["allow", "block"],
|
|
53
|
+
status_code: int,
|
|
54
|
+
detail: str,
|
|
55
|
+
):
|
|
56
|
+
codes = set()
|
|
57
|
+
for country in countries:
|
|
58
|
+
if not isinstance(country, str) or not _COUNTRY_CODE_PATTERN.match(country):
|
|
59
|
+
raise ValueError(f"Invalid ISO 3166-1 alpha-2 country code: {country!r}")
|
|
60
|
+
codes.add(country.upper())
|
|
61
|
+
if not codes:
|
|
62
|
+
raise ValueError("At least one country code is required")
|
|
63
|
+
if mode not in ("block", "allow"):
|
|
64
|
+
raise ValueError(f"Invalid mode {mode!r}: expected 'block' or 'allow'")
|
|
65
|
+
if unknown not in ("allow", "block"):
|
|
66
|
+
raise ValueError(f"Invalid unknown policy {unknown!r}: expected 'allow' or 'block'")
|
|
67
|
+
self.countries = frozenset(codes)
|
|
68
|
+
self.mode = mode
|
|
69
|
+
self.unknown = unknown
|
|
70
|
+
self.status_code = status_code
|
|
71
|
+
self.detail = detail
|
|
72
|
+
|
|
73
|
+
def check(self, info: IpInfo | None) -> None:
|
|
74
|
+
location = info.location if info is not None else None
|
|
75
|
+
country = location.country if location is not None else None
|
|
76
|
+
code = country.code if country is not None else None
|
|
77
|
+
if code is None:
|
|
78
|
+
if self.unknown == "block":
|
|
79
|
+
raise HTTPException(status_code=self.status_code, detail=self.detail)
|
|
80
|
+
return
|
|
81
|
+
blocked = code in self.countries if self.mode == "block" else code not in self.countries
|
|
82
|
+
if blocked:
|
|
83
|
+
raise HTTPException(status_code=self.status_code, detail=self.detail)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def block_countries(
|
|
87
|
+
*countries: Union[str, Iterable[str]],
|
|
88
|
+
mode: Literal["block", "allow"] = "block",
|
|
89
|
+
unknown: Literal["allow", "block"] = "allow",
|
|
90
|
+
status_code: int = 451,
|
|
91
|
+
detail: str = "Access restricted in your region.",
|
|
92
|
+
) -> CountryBlocker:
|
|
93
|
+
"""Block (or, with mode='allow', restrict to) visitors from the given
|
|
94
|
+
ISO 3166-1 alpha-2 countries.
|
|
95
|
+
|
|
96
|
+
Use as a route dependency (dependencies=[Depends(block_countries("XX"))])
|
|
97
|
+
or as a middleware action. Requests whose country cannot be determined are
|
|
98
|
+
allowed unless unknown='block'. Responds with 451 Unavailable For Legal
|
|
99
|
+
Reasons by default. Country codes are validated eagerly, at declaration time.
|
|
100
|
+
"""
|
|
101
|
+
if len(countries) == 1 and not isinstance(countries[0], str):
|
|
102
|
+
countries = tuple(countries[0])
|
|
103
|
+
return CountryBlocker(countries, mode, unknown, status_code, detail)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ThreatBlocker(Blocker):
|
|
107
|
+
def __init__(self, signals: dict[str, bool], status_code: int, detail: str):
|
|
108
|
+
self.signals = signals
|
|
109
|
+
self.status_code = status_code
|
|
110
|
+
self.detail = detail
|
|
111
|
+
|
|
112
|
+
def check(self, info: IpInfo | None) -> None:
|
|
113
|
+
if is_threat(info, **self.signals):
|
|
114
|
+
raise HTTPException(status_code=self.status_code, detail=self.detail)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def block_threats(
|
|
118
|
+
*,
|
|
119
|
+
proxy: bool = False,
|
|
120
|
+
tor: bool = False,
|
|
121
|
+
vpn: bool = False,
|
|
122
|
+
relay: bool = False,
|
|
123
|
+
anonymous: bool = False,
|
|
124
|
+
cloud_provider: bool = False,
|
|
125
|
+
bogon: bool = False,
|
|
126
|
+
status_code: int = 403,
|
|
127
|
+
detail: str = "Access denied.",
|
|
128
|
+
) -> ThreatBlocker:
|
|
129
|
+
"""Block visitors flagged as threats by Ipregistry.
|
|
130
|
+
|
|
131
|
+
Known threats, attackers and abusers are always blocked. Anonymization and
|
|
132
|
+
infrastructure signals (proxy, tor, vpn, relay, anonymous, cloud_provider,
|
|
133
|
+
bogon) are opt-in. Requests without security data are allowed (fail open).
|
|
134
|
+
Use as a route dependency or as a middleware action.
|
|
135
|
+
"""
|
|
136
|
+
signals = {
|
|
137
|
+
"proxy": proxy,
|
|
138
|
+
"tor": tor,
|
|
139
|
+
"vpn": vpn,
|
|
140
|
+
"relay": relay,
|
|
141
|
+
"anonymous": anonymous,
|
|
142
|
+
"cloud_provider": cloud_provider,
|
|
143
|
+
"bogon": bogon,
|
|
144
|
+
}
|
|
145
|
+
return ThreatBlocker(signals, status_code, detail)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright 2026 Ipregistry (https://ipregistry.co).
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Annotated, Optional
|
|
20
|
+
|
|
21
|
+
from fastapi import Depends, HTTPException
|
|
22
|
+
from fastapi.requests import HTTPConnection
|
|
23
|
+
from ipregistry import IpInfo
|
|
24
|
+
|
|
25
|
+
from .service import Ipregistry
|
|
26
|
+
|
|
27
|
+
SERVICE_UNAVAILABLE_DETAIL = "IP intelligence is temporarily unavailable."
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def get_ipregistry(connection: HTTPConnection) -> Ipregistry:
|
|
31
|
+
"""Provide the application's Ipregistry service.
|
|
32
|
+
|
|
33
|
+
Uses the service attached with add_ipregistry; otherwise lazily creates one
|
|
34
|
+
from the environment and stores it on the application state.
|
|
35
|
+
"""
|
|
36
|
+
service = getattr(connection.app.state, "ipregistry", None)
|
|
37
|
+
if service is None:
|
|
38
|
+
service = Ipregistry()
|
|
39
|
+
connection.app.state.ipregistry = service
|
|
40
|
+
return service
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
IpregistryDep = Annotated[Ipregistry, Depends(get_ipregistry)]
|
|
44
|
+
"""The Ipregistry service, for explicit lookups and access to the raw client."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def get_ip_info(connection: HTTPConnection, service: IpregistryDep) -> Optional[IpInfo]:
|
|
48
|
+
"""Provide IP intelligence for the request's client, in both HTTP and
|
|
49
|
+
WebSocket endpoints.
|
|
50
|
+
|
|
51
|
+
A single lookup is performed per request and shared by every consumer.
|
|
52
|
+
Returns None when no public client IP is available or, with fail_open
|
|
53
|
+
enabled (the default), when the lookup fails. With fail_open disabled a
|
|
54
|
+
lookup failure responds with 503.
|
|
55
|
+
"""
|
|
56
|
+
info = await service.for_request(connection)
|
|
57
|
+
if info is None and not service.settings.fail_open and service.request_error(connection) is not None:
|
|
58
|
+
raise HTTPException(status_code=503, detail=SERVICE_UNAVAILABLE_DETAIL)
|
|
59
|
+
return info
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
IpInfoDep = Annotated[Optional[IpInfo], Depends(get_ip_info)]
|
|
63
|
+
"""IP intelligence for the request's client, or None when unavailable."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def get_client_ip(connection: HTTPConnection, service: IpregistryDep) -> Optional[str]:
|
|
67
|
+
"""Provide the client IP resolved by the configured IP source, or None."""
|
|
68
|
+
return service.client_ip(connection)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
ClientIpDep = Annotated[Optional[str], Depends(get_client_ip)]
|
|
72
|
+
"""The resolved public client IP address, or None."""
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright 2026 Ipregistry (https://ipregistry.co).
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from ipregistry import IpregistryError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MissingApiKeyError(IpregistryError):
|
|
21
|
+
"""Raised when an Ipregistry client is built without an API key.
|
|
22
|
+
|
|
23
|
+
Set the IPREGISTRY_API_KEY environment variable or pass
|
|
24
|
+
IpregistrySettings(api_key=...) explicitly.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, message: str | None = None):
|
|
28
|
+
super().__init__(
|
|
29
|
+
message
|
|
30
|
+
or "No Ipregistry API key configured. Set the IPREGISTRY_API_KEY environment "
|
|
31
|
+
"variable or pass IpregistrySettings(api_key=...) explicitly."
|
|
32
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright 2026 Ipregistry (https://ipregistry.co).
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Union
|
|
20
|
+
|
|
21
|
+
from fastapi.requests import HTTPConnection
|
|
22
|
+
from ipregistry import IpInfo, UserAgents
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_eu(info: IpInfo | None, *, assume_eu: bool = False) -> bool:
|
|
26
|
+
"""Return True when the IP is located in the European Union.
|
|
27
|
+
|
|
28
|
+
Useful for GDPR consent flows. When the EU membership is unknown (no data),
|
|
29
|
+
returns assume_eu, so pass assume_eu=True to err on the side of showing a
|
|
30
|
+
consent banner.
|
|
31
|
+
"""
|
|
32
|
+
in_eu = info.location.in_eu if info is not None and info.location is not None else None
|
|
33
|
+
return assume_eu if in_eu is None else in_eu
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_threat(
|
|
37
|
+
info: IpInfo | None,
|
|
38
|
+
*,
|
|
39
|
+
proxy: bool = False,
|
|
40
|
+
tor: bool = False,
|
|
41
|
+
vpn: bool = False,
|
|
42
|
+
relay: bool = False,
|
|
43
|
+
anonymous: bool = False,
|
|
44
|
+
cloud_provider: bool = False,
|
|
45
|
+
bogon: bool = False,
|
|
46
|
+
) -> bool:
|
|
47
|
+
"""Return True when the IP is a known threat.
|
|
48
|
+
|
|
49
|
+
Known threats, attackers and abusers are always flagged. Anonymization
|
|
50
|
+
signals (proxy, tor, vpn, relay, anonymous) and infrastructure signals
|
|
51
|
+
(cloud_provider, bogon) are opt-in since they are not inherently malicious.
|
|
52
|
+
Returns False when no security data is available (fail open).
|
|
53
|
+
"""
|
|
54
|
+
security = info.security if info is not None else None
|
|
55
|
+
if security is None:
|
|
56
|
+
return False
|
|
57
|
+
signals = [security.is_threat, security.is_attacker, security.is_abuser]
|
|
58
|
+
if proxy:
|
|
59
|
+
signals.append(security.is_proxy)
|
|
60
|
+
if tor:
|
|
61
|
+
signals.extend((security.is_tor, security.is_tor_exit))
|
|
62
|
+
if vpn:
|
|
63
|
+
signals.append(security.is_vpn)
|
|
64
|
+
if relay:
|
|
65
|
+
signals.append(security.is_relay)
|
|
66
|
+
if anonymous:
|
|
67
|
+
signals.append(security.is_anonymous)
|
|
68
|
+
if cloud_provider:
|
|
69
|
+
signals.append(security.is_cloud_provider)
|
|
70
|
+
if bogon:
|
|
71
|
+
signals.append(security.is_bogon)
|
|
72
|
+
return any(bool(signal) for signal in signals)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def is_bot(subject: Union[HTTPConnection, str, None]) -> bool:
|
|
76
|
+
"""Return True when the request or User-Agent string looks like a bot or
|
|
77
|
+
crawler. Heuristic string check; no API call and no credit consumed."""
|
|
78
|
+
if subject is None:
|
|
79
|
+
return False
|
|
80
|
+
header = subject if isinstance(subject, str) else subject.headers.get("user-agent", "")
|
|
81
|
+
return bool(header) and UserAgents.is_bot(header)
|
ipregistry_fastapi/ip.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright 2026 Ipregistry (https://ipregistry.co).
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import ipaddress
|
|
20
|
+
from typing import Callable, Optional, Union
|
|
21
|
+
|
|
22
|
+
from fastapi.requests import HTTPConnection
|
|
23
|
+
|
|
24
|
+
IpExtractor = Callable[[HTTPConnection], Optional[str]]
|
|
25
|
+
"""A callable that extracts the client IP address from an HTTP or WebSocket connection, or None."""
|
|
26
|
+
|
|
27
|
+
IpSource = Union[str, IpExtractor]
|
|
28
|
+
"""An IP source: 'client', a trusted-proxy preset name, 'header:<name>' or a callable."""
|
|
29
|
+
|
|
30
|
+
PRESET_HEADERS: dict[str, tuple[str, ...]] = {
|
|
31
|
+
"auto": ("x-real-ip", "x-forwarded-for"),
|
|
32
|
+
"cloudflare": ("cf-connecting-ip",),
|
|
33
|
+
"nginx": ("x-real-ip", "x-forwarded-for"),
|
|
34
|
+
"forwarded-for": ("x-forwarded-for",),
|
|
35
|
+
}
|
|
36
|
+
"""Headers consulted, in order, for each trusted-proxy preset."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def sanitize_ip(value: str | None) -> str | None:
|
|
40
|
+
"""Normalize a raw header or address value to a bare IP string.
|
|
41
|
+
|
|
42
|
+
Strips whitespace, IPv6 brackets (with an optional port, e.g. '[::1]:8080'),
|
|
43
|
+
an IPv4 port suffix (e.g. '1.2.3.4:443') and IPv6 zone identifiers ('%eth0').
|
|
44
|
+
Returns None when nothing is left.
|
|
45
|
+
"""
|
|
46
|
+
if value is None:
|
|
47
|
+
return None
|
|
48
|
+
value = value.strip()
|
|
49
|
+
if value.startswith("["):
|
|
50
|
+
end = value.find("]")
|
|
51
|
+
if end == -1:
|
|
52
|
+
return None
|
|
53
|
+
value = value[1:end]
|
|
54
|
+
elif value.count(":") == 1 and "." in value:
|
|
55
|
+
value = value.split(":", 1)[0]
|
|
56
|
+
zone = value.find("%")
|
|
57
|
+
if zone != -1:
|
|
58
|
+
value = value[:zone]
|
|
59
|
+
return value or None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_valid_ip(value: str) -> bool:
|
|
63
|
+
"""Return True when the value is a valid IPv4 or IPv6 address."""
|
|
64
|
+
try:
|
|
65
|
+
ipaddress.ip_address(value)
|
|
66
|
+
except ValueError:
|
|
67
|
+
return False
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def is_public_ip(value: str) -> bool:
|
|
72
|
+
"""Return True when the value is a valid, publicly routable IP address.
|
|
73
|
+
|
|
74
|
+
Private, loopback, link-local, carrier-grade NAT and other reserved ranges
|
|
75
|
+
return False: the Ipregistry API rejects them with RESERVED_IP_ADDRESS, so
|
|
76
|
+
lookups for such addresses are skipped.
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
address = ipaddress.ip_address(value)
|
|
80
|
+
except ValueError:
|
|
81
|
+
return False
|
|
82
|
+
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None:
|
|
83
|
+
address = address.ipv4_mapped
|
|
84
|
+
return address.is_global and not address.is_multicast
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def anonymize_ip(value: str) -> str:
|
|
88
|
+
"""Mask an IP address for log output; this library never logs full client IPs."""
|
|
89
|
+
try:
|
|
90
|
+
address = ipaddress.ip_address(value)
|
|
91
|
+
except ValueError:
|
|
92
|
+
return "<invalid>"
|
|
93
|
+
if isinstance(address, ipaddress.IPv4Address):
|
|
94
|
+
octets = str(address).split(".")
|
|
95
|
+
return ".".join(octets[:3]) + ".x"
|
|
96
|
+
groups = address.exploded.split(":")
|
|
97
|
+
return ":".join(groups[:3]) + "::x"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def build_ip_extractor(source: IpSource = "client") -> IpExtractor:
|
|
101
|
+
"""Build an IP extractor from an IP source specification.
|
|
102
|
+
|
|
103
|
+
Accepts 'client' (the ASGI client address), a trusted-proxy preset name
|
|
104
|
+
('auto', 'cloudflare', 'nginx', 'forwarded-for'), 'header:<name>' for a
|
|
105
|
+
custom header, or a callable taking a connection and returning an IP or None.
|
|
106
|
+
|
|
107
|
+
Only configure headers set by a proxy you control: client-supplied headers
|
|
108
|
+
are trivially spoofable.
|
|
109
|
+
"""
|
|
110
|
+
if callable(source):
|
|
111
|
+
return source
|
|
112
|
+
|
|
113
|
+
normalized = source.strip().lower()
|
|
114
|
+
if normalized == "client":
|
|
115
|
+
return _extract_from_client
|
|
116
|
+
|
|
117
|
+
if normalized.startswith("header:"):
|
|
118
|
+
header = normalized.split(":", 1)[1].strip()
|
|
119
|
+
if not header:
|
|
120
|
+
raise ValueError("Invalid IP source: 'header:' requires a header name, e.g. 'header:x-client-ip'")
|
|
121
|
+
headers: tuple[str, ...] = (header,)
|
|
122
|
+
elif normalized in PRESET_HEADERS:
|
|
123
|
+
headers = PRESET_HEADERS[normalized]
|
|
124
|
+
else:
|
|
125
|
+
presets = ", ".join(sorted(PRESET_HEADERS))
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"Unknown IP source {source!r}: expected 'client', a preset ({presets}), "
|
|
128
|
+
"'header:<name>' or a callable"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def extract(connection: HTTPConnection) -> str | None:
|
|
132
|
+
return _extract_from_headers(connection, headers)
|
|
133
|
+
|
|
134
|
+
return extract
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _extract_from_client(connection: HTTPConnection) -> str | None:
|
|
138
|
+
client = connection.client
|
|
139
|
+
if client is None or not client.host:
|
|
140
|
+
return None
|
|
141
|
+
ip = sanitize_ip(client.host)
|
|
142
|
+
return ip if ip is not None and is_valid_ip(ip) else None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _extract_from_headers(connection: HTTPConnection, headers: tuple[str, ...]) -> str | None:
|
|
146
|
+
for name in headers:
|
|
147
|
+
raw = connection.headers.get(name)
|
|
148
|
+
if not raw:
|
|
149
|
+
continue
|
|
150
|
+
ip = sanitize_ip(raw.split(",", 1)[0])
|
|
151
|
+
if ip is not None and is_valid_ip(ip):
|
|
152
|
+
return ip
|
|
153
|
+
return None
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright 2026 Ipregistry (https://ipregistry.co).
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from contextlib import asynccontextmanager
|
|
20
|
+
from typing import AsyncIterator
|
|
21
|
+
|
|
22
|
+
from fastapi import FastAPI
|
|
23
|
+
from ipregistry import AsyncIpregistryClient
|
|
24
|
+
|
|
25
|
+
from .ip import IpSource
|
|
26
|
+
from .service import Ipregistry
|
|
27
|
+
from .settings import IpregistrySettings
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def add_ipregistry(
|
|
31
|
+
app: FastAPI,
|
|
32
|
+
settings: IpregistrySettings | None = None,
|
|
33
|
+
*,
|
|
34
|
+
client: AsyncIpregistryClient | None = None,
|
|
35
|
+
ip_extractor: IpSource | None = None,
|
|
36
|
+
service: Ipregistry | None = None,
|
|
37
|
+
) -> Ipregistry:
|
|
38
|
+
"""Attach an Ipregistry service to the application state.
|
|
39
|
+
|
|
40
|
+
The service is picked up by the dependencies (IpInfoDep, IpregistryDep, ...)
|
|
41
|
+
and the middleware. Pass a pre-built service (e.g. an IpregistryFake in
|
|
42
|
+
tests) or let one be created from the given settings and environment.
|
|
43
|
+
"""
|
|
44
|
+
if service is None:
|
|
45
|
+
service = Ipregistry(settings, client=client, ip_extractor=ip_extractor)
|
|
46
|
+
app.state.ipregistry = service
|
|
47
|
+
return service
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@asynccontextmanager
|
|
51
|
+
async def ipregistry_lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
52
|
+
"""Application lifespan managing the Ipregistry service lifecycle.
|
|
53
|
+
|
|
54
|
+
Use directly (FastAPI(lifespan=ipregistry_lifespan)) or compose it inside
|
|
55
|
+
your own lifespan with an AsyncExitStack. Creates a service from the
|
|
56
|
+
environment unless one was already attached with add_ipregistry, and
|
|
57
|
+
releases its HTTP resources on shutdown.
|
|
58
|
+
"""
|
|
59
|
+
service = getattr(app.state, "ipregistry", None)
|
|
60
|
+
if service is None:
|
|
61
|
+
service = add_ipregistry(app)
|
|
62
|
+
try:
|
|
63
|
+
yield
|
|
64
|
+
finally:
|
|
65
|
+
await service.aclose()
|