dn42-ipdb 0.1.1__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.
- dn42_ipdb-0.1.1/.gitignore +14 -0
- dn42_ipdb-0.1.1/LICENSE +21 -0
- dn42_ipdb-0.1.1/PKG-INFO +67 -0
- dn42_ipdb-0.1.1/README.md +52 -0
- dn42_ipdb-0.1.1/pyproject.toml +44 -0
- dn42_ipdb-0.1.1/src/dn42_ipdb/__init__.py +325 -0
- dn42_ipdb-0.1.1/src/dn42_ipdb/py.typed +0 -0
- dn42_ipdb-0.1.1/tests/data/LICENSE-Unicode.txt +41 -0
- dn42_ipdb-0.1.1/tests/data/asn-index.json +132 -0
- dn42_ipdb-0.1.1/tests/data/asn.mmdb +0 -0
- dn42_ipdb-0.1.1/tests/data/city.mmdb +0 -0
- dn42_ipdb-0.1.1/tests/data/connection-type.mmdb +0 -0
- dn42_ipdb-0.1.1/tests/data/country.mmdb +0 -0
- dn42_ipdb-0.1.1/tests/data/metadata.json +36 -0
- dn42_ipdb-0.1.1/tests/hold_lock.py +8 -0
- dn42_ipdb-0.1.1/tests/test_geoip2.py +32 -0
- dn42_ipdb-0.1.1/tests/test_ipdb.py +254 -0
dn42_ipdb-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BIRD Chinese Community
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
dn42_ipdb-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dn42-ipdb
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Local GeoIP2-compatible DN42 intelligence and verified database updates
|
|
5
|
+
Project-URL: Repository, https://github.com/bird-chinese-community/DN42-IPDB-SDK
|
|
6
|
+
Project-URL: Issues, https://github.com/bird-chinese-community/DN42-IPDB-SDK/issues
|
|
7
|
+
Author-email: BIRD Chinese Community <sdk.opensouce@birdcc.link>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Requires-Dist: filelock<4,>=3.20
|
|
12
|
+
Requires-Dist: httpx<1,>=0.28
|
|
13
|
+
Requires-Dist: maxminddb<4,>=3.0
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# dn42-ipdb
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
pip install dn42-ipdb
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import asyncio
|
|
24
|
+
from dn42_ipdb import download_release, lookup, lookup_asn, open_cached_bundle
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def main():
|
|
28
|
+
await download_release(".cache/dn42")
|
|
29
|
+
with open_cached_bundle(".cache/dn42") as database:
|
|
30
|
+
print(lookup(database, "172.23.33.0"))
|
|
31
|
+
print(lookup_asn(database, 4242420803))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
asyncio.run(main())
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`open_cached_bundle(cache_dir)` opens the verified current bundle without
|
|
38
|
+
network activity. `metadata(bundle)` returns MMDB metadata and
|
|
39
|
+
`release_metadata(bundle)` returns the release JSON metadata. A match contains
|
|
40
|
+
`prefix_length` and `data`. Unknown IP networks are absent from `records`;
|
|
41
|
+
unknown ASNs return `None`.
|
|
42
|
+
|
|
43
|
+
`download_release` accepts a `base_url` HTTPS mirror and a `timeout` in seconds.
|
|
44
|
+
Loopback HTTP is accepted for tests. The default timeout is 30 seconds and
|
|
45
|
+
task cancellation stops pending downloads. Each database or JSON sidecar is
|
|
46
|
+
limited to 32 MiB; the checksum manifest to 64 KiB. The Python cache lives
|
|
47
|
+
under `cache_dir/python`.
|
|
48
|
+
|
|
49
|
+
The SDK reads verified bytes with MaxMind's pure Python `MODE_FD` reader.
|
|
50
|
+
The official `geoip2` typed reader also supports the bundle. Use its memory
|
|
51
|
+
mode on Windows with `maxminddb` 3.2.0:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from geoip2.database import Reader
|
|
55
|
+
from maxminddb import MODE_MEMORY
|
|
56
|
+
|
|
57
|
+
with Reader("asn.mmdb", mode=MODE_MEMORY) as database:
|
|
58
|
+
print(database.asn("172.23.33.0").autonomous_system_number)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The 3.2.0 Windows C extension can decode DN42's unsigned ASN values as
|
|
62
|
+
negative numbers. The [upstream correction](https://github.com/maxmind/MaxMind-DB-Reader-python/commit/51791b8b5d970f92dc7c5fa645bde2c7bd15475d)
|
|
63
|
+
is on its development branch. SDK CI validates all four official GeoIP2 typed
|
|
64
|
+
methods in memory mode on every platform, and its default mode on Linux and
|
|
65
|
+
macOS.
|
|
66
|
+
|
|
67
|
+
Copyright 2026 BIRD Chinese Community. MIT license.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# dn42-ipdb
|
|
2
|
+
|
|
3
|
+
```sh
|
|
4
|
+
pip install dn42-ipdb
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
import asyncio
|
|
9
|
+
from dn42_ipdb import download_release, lookup, lookup_asn, open_cached_bundle
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def main():
|
|
13
|
+
await download_release(".cache/dn42")
|
|
14
|
+
with open_cached_bundle(".cache/dn42") as database:
|
|
15
|
+
print(lookup(database, "172.23.33.0"))
|
|
16
|
+
print(lookup_asn(database, 4242420803))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
asyncio.run(main())
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`open_cached_bundle(cache_dir)` opens the verified current bundle without
|
|
23
|
+
network activity. `metadata(bundle)` returns MMDB metadata and
|
|
24
|
+
`release_metadata(bundle)` returns the release JSON metadata. A match contains
|
|
25
|
+
`prefix_length` and `data`. Unknown IP networks are absent from `records`;
|
|
26
|
+
unknown ASNs return `None`.
|
|
27
|
+
|
|
28
|
+
`download_release` accepts a `base_url` HTTPS mirror and a `timeout` in seconds.
|
|
29
|
+
Loopback HTTP is accepted for tests. The default timeout is 30 seconds and
|
|
30
|
+
task cancellation stops pending downloads. Each database or JSON sidecar is
|
|
31
|
+
limited to 32 MiB; the checksum manifest to 64 KiB. The Python cache lives
|
|
32
|
+
under `cache_dir/python`.
|
|
33
|
+
|
|
34
|
+
The SDK reads verified bytes with MaxMind's pure Python `MODE_FD` reader.
|
|
35
|
+
The official `geoip2` typed reader also supports the bundle. Use its memory
|
|
36
|
+
mode on Windows with `maxminddb` 3.2.0:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from geoip2.database import Reader
|
|
40
|
+
from maxminddb import MODE_MEMORY
|
|
41
|
+
|
|
42
|
+
with Reader("asn.mmdb", mode=MODE_MEMORY) as database:
|
|
43
|
+
print(database.asn("172.23.33.0").autonomous_system_number)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The 3.2.0 Windows C extension can decode DN42's unsigned ASN values as
|
|
47
|
+
negative numbers. The [upstream correction](https://github.com/maxmind/MaxMind-DB-Reader-python/commit/51791b8b5d970f92dc7c5fa645bde2c7bd15475d)
|
|
48
|
+
is on its development branch. SDK CI validates all four official GeoIP2 typed
|
|
49
|
+
methods in memory mode on every platform, and its default mode on Linux and
|
|
50
|
+
macOS.
|
|
51
|
+
|
|
52
|
+
Copyright 2026 BIRD Chinese Community. MIT license.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dn42-ipdb"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Local GeoIP2-compatible DN42 intelligence and verified database updates"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "BIRD Chinese Community", email = "sdk.opensouce@birdcc.link" }]
|
|
13
|
+
dependencies = ["maxminddb>=3.0,<4", "httpx>=0.28,<1", "filelock>=3.20,<4"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Repository = "https://github.com/bird-chinese-community/DN42-IPDB-SDK"
|
|
17
|
+
Issues = "https://github.com/bird-chinese-community/DN42-IPDB-SDK/issues"
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest>=8.4",
|
|
22
|
+
"ruff>=0.14",
|
|
23
|
+
"mypy>=1.18",
|
|
24
|
+
"aiohttp>=3.12",
|
|
25
|
+
"geoip2>=5.1",
|
|
26
|
+
"semver>=3.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/dn42_ipdb"]
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.sdist]
|
|
33
|
+
include = ["/src", "/tests", "/LICENSE", "/README.md", "/pyproject.toml"]
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
testpaths = ["tests"]
|
|
37
|
+
|
|
38
|
+
[tool.ruff]
|
|
39
|
+
target-version = "py314"
|
|
40
|
+
line-length = 88
|
|
41
|
+
|
|
42
|
+
[tool.mypy]
|
|
43
|
+
python_version = "3.14"
|
|
44
|
+
strict = true
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import hashlib
|
|
3
|
+
import ipaddress
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from contextlib import ExitStack, contextmanager
|
|
11
|
+
from importlib.metadata import version
|
|
12
|
+
from io import BytesIO
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from secrets import token_hex
|
|
15
|
+
from urllib.parse import urljoin, urlsplit
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
import maxminddb
|
|
19
|
+
from filelock import AsyncFileLock, FileLock
|
|
20
|
+
|
|
21
|
+
__version__ = version("dn42-ipdb")
|
|
22
|
+
DEFAULT_RELEASE_URL = "https://github.com/bird-chinese-community/DN42-IPDB-Releases/releases/latest/download/"
|
|
23
|
+
MAX_DATABASE_BYTES = 32 * 1024 * 1024
|
|
24
|
+
MAX_MANIFEST_BYTES = 64 * 1024
|
|
25
|
+
ARTIFACT_TYPES = {
|
|
26
|
+
"country": ("country.mmdb", "DN42-GeoIP2-Country", "DN42-Country"),
|
|
27
|
+
"city": ("city.mmdb", "DN42-GeoIP2-City", "DN42-City"),
|
|
28
|
+
"asn": ("asn.mmdb", "DN42-GeoLite2-ASN", "DN42-ASN"),
|
|
29
|
+
"connection_type": (
|
|
30
|
+
"connection-type.mmdb",
|
|
31
|
+
"DN42-GeoIP2-Connection-Type",
|
|
32
|
+
"DN42-Connection-Type",
|
|
33
|
+
),
|
|
34
|
+
}
|
|
35
|
+
ARTIFACT_FILES = [item[0] for item in ARTIFACT_TYPES.values()] + [
|
|
36
|
+
"asn-index.json",
|
|
37
|
+
"metadata.json",
|
|
38
|
+
"LICENSE-Unicode.txt",
|
|
39
|
+
]
|
|
40
|
+
type Record = dict[str, object]
|
|
41
|
+
type Bundle = tuple[dict[str, maxminddb.Reader], dict[str, Record], Record]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_bounded(path: Path, limit: int) -> bytes:
|
|
45
|
+
with path.open("rb") as stream:
|
|
46
|
+
if not path.is_file() or os.fstat(stream.fileno()).st_size > limit:
|
|
47
|
+
raise ValueError(f"Invalid cache file size: {path}")
|
|
48
|
+
data = stream.read(limit + 1)
|
|
49
|
+
if len(data) > limit:
|
|
50
|
+
raise ValueError(f"Cache file exceeds size limit: {path}")
|
|
51
|
+
return data
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@contextmanager
|
|
55
|
+
def open_bundle(directory: str | Path) -> Iterator[Bundle]:
|
|
56
|
+
"""Load an immutable in-memory release for local lookups."""
|
|
57
|
+
path = Path(directory)
|
|
58
|
+
index = json.loads(_read_bounded(path / "asn-index.json", MAX_DATABASE_BYTES))
|
|
59
|
+
release = json.loads(_read_bounded(path / "metadata.json", MAX_DATABASE_BYTES))
|
|
60
|
+
if (
|
|
61
|
+
not isinstance(index, dict)
|
|
62
|
+
or index.get("schema_version") != "2"
|
|
63
|
+
or not isinstance(index.get("asns"), dict)
|
|
64
|
+
):
|
|
65
|
+
raise ValueError("Invalid ASN index schema")
|
|
66
|
+
if (
|
|
67
|
+
not isinstance(release, dict)
|
|
68
|
+
or not release.get("release_repository")
|
|
69
|
+
or not release.get("copyright")
|
|
70
|
+
):
|
|
71
|
+
raise ValueError("Invalid release metadata")
|
|
72
|
+
readers = {}
|
|
73
|
+
try:
|
|
74
|
+
for key, (filename, kind, legacy) in ARTIFACT_TYPES.items():
|
|
75
|
+
data = _read_bounded(path / filename, MAX_DATABASE_BYTES)
|
|
76
|
+
reader = maxminddb.open_database(BytesIO(data), mode=maxminddb.MODE_FD)
|
|
77
|
+
readers[key] = reader
|
|
78
|
+
if reader.metadata().database_type not in {kind, legacy}:
|
|
79
|
+
raise ValueError(f"{filename}: expected {kind} database")
|
|
80
|
+
yield readers, index["asns"], release
|
|
81
|
+
finally:
|
|
82
|
+
for reader in readers.values():
|
|
83
|
+
reader.close()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def lookup(bundle: Bundle, address: str) -> Record:
|
|
87
|
+
"""Return raw GeoIP2-compatible records, preserving the dn42 extension."""
|
|
88
|
+
if "%" in address:
|
|
89
|
+
raise ValueError(f"Invalid IP address: {address}")
|
|
90
|
+
ip = ipaddress.ip_address(address)
|
|
91
|
+
records = {}
|
|
92
|
+
for key, reader in bundle[0].items():
|
|
93
|
+
record, prefix_length = reader.get_with_prefix_len(str(ip))
|
|
94
|
+
if record:
|
|
95
|
+
records[key] = {"prefix_length": prefix_length, "data": record}
|
|
96
|
+
return {"ip": str(ip), "records": records}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def lookup_asn(bundle: Bundle, asn: int) -> Record | None:
|
|
100
|
+
"""Return member-prefix observations without classifying the entire AS."""
|
|
101
|
+
if isinstance(asn, bool) or not isinstance(asn, int) or not 0 <= asn <= 4294967295:
|
|
102
|
+
raise ValueError(f"Invalid ASN: {asn}")
|
|
103
|
+
return bundle[1].get(str(asn))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def metadata(bundle: Bundle) -> dict[str, Record]:
|
|
107
|
+
output = {}
|
|
108
|
+
for key, reader in bundle[0].items():
|
|
109
|
+
value = reader.metadata()
|
|
110
|
+
output[key] = {
|
|
111
|
+
"database_type": value.database_type,
|
|
112
|
+
"description": value.description,
|
|
113
|
+
"build_epoch": value.build_epoch,
|
|
114
|
+
"languages": value.languages,
|
|
115
|
+
"ip_version": value.ip_version,
|
|
116
|
+
}
|
|
117
|
+
return output
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def release_metadata(bundle: Bundle) -> Record:
|
|
121
|
+
return bundle[2]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _verify(directory: Path, checksums: dict[str, str]) -> None:
|
|
125
|
+
for filename in ARTIFACT_FILES:
|
|
126
|
+
data = _read_bounded(directory / filename, MAX_DATABASE_BYTES)
|
|
127
|
+
if hashlib.sha256(data).hexdigest() != checksums[filename]:
|
|
128
|
+
raise ValueError(f"Checksum mismatch: {filename}")
|
|
129
|
+
with open_bundle(directory):
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _read_pointer(cache: Path) -> tuple[str, dict[str, str]] | None:
|
|
134
|
+
data = _read_bounded(cache / "current.json", MAX_MANIFEST_BYTES)
|
|
135
|
+
try:
|
|
136
|
+
pointer = json.loads(data)
|
|
137
|
+
except json.JSONDecodeError:
|
|
138
|
+
return None
|
|
139
|
+
if not isinstance(pointer, dict):
|
|
140
|
+
return None
|
|
141
|
+
current = pointer.get("version")
|
|
142
|
+
checksums = pointer.get("checksums")
|
|
143
|
+
if (
|
|
144
|
+
not isinstance(current, str)
|
|
145
|
+
or re.fullmatch(r"[0-9a-f]{64}", current) is None
|
|
146
|
+
or not isinstance(checksums, dict)
|
|
147
|
+
):
|
|
148
|
+
return None
|
|
149
|
+
for filename in ARTIFACT_FILES:
|
|
150
|
+
digest = checksums.get(filename)
|
|
151
|
+
if not isinstance(digest, str) or re.fullmatch(r"[0-9a-f]{64}", digest) is None:
|
|
152
|
+
return None
|
|
153
|
+
return current, checksums
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@contextmanager
|
|
157
|
+
def open_cached_bundle(cache_dir: str | Path) -> Iterator[Bundle]:
|
|
158
|
+
"""Read the verified current cache without network activity."""
|
|
159
|
+
cache = Path(cache_dir) / "python"
|
|
160
|
+
with ExitStack() as stack:
|
|
161
|
+
with FileLock(cache / ".update.lock", timeout=0):
|
|
162
|
+
pointer = _read_pointer(cache)
|
|
163
|
+
if pointer is None:
|
|
164
|
+
raise ValueError(f"Invalid cache pointer: {cache / 'current.json'}")
|
|
165
|
+
current, checksums = pointer
|
|
166
|
+
directory = cache / current
|
|
167
|
+
_verify(directory, checksums)
|
|
168
|
+
bundle = stack.enter_context(open_bundle(directory))
|
|
169
|
+
yield bundle
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def _download(
|
|
173
|
+
client: httpx.AsyncClient, address: str, limit: int, base: str
|
|
174
|
+
) -> bytes:
|
|
175
|
+
initial = urlsplit(base)
|
|
176
|
+
current = address
|
|
177
|
+
for redirects in range(11):
|
|
178
|
+
target = urlsplit(current)
|
|
179
|
+
if (
|
|
180
|
+
target.username
|
|
181
|
+
or target.password
|
|
182
|
+
or (
|
|
183
|
+
target.scheme != "https"
|
|
184
|
+
and not (
|
|
185
|
+
initial.scheme == "http"
|
|
186
|
+
and target.netloc == initial.netloc
|
|
187
|
+
and target.scheme == "http"
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
):
|
|
191
|
+
raise ValueError("Insecure release redirect")
|
|
192
|
+
async with client.stream("GET", current, follow_redirects=False) as response:
|
|
193
|
+
if response.is_redirect:
|
|
194
|
+
location = response.headers.get("location")
|
|
195
|
+
if not location or redirects == 10:
|
|
196
|
+
raise ValueError("Invalid release redirect")
|
|
197
|
+
current = urljoin(current, location)
|
|
198
|
+
continue
|
|
199
|
+
response.raise_for_status()
|
|
200
|
+
length = response.headers.get("content-length")
|
|
201
|
+
if length is not None and int(length) > limit:
|
|
202
|
+
raise ValueError(f"Download {address}: size limit exceeded")
|
|
203
|
+
chunks = []
|
|
204
|
+
size = 0
|
|
205
|
+
async for chunk in response.aiter_bytes():
|
|
206
|
+
size += len(chunk)
|
|
207
|
+
if size > limit:
|
|
208
|
+
raise ValueError(f"Download {address}: size limit exceeded")
|
|
209
|
+
chunks.append(chunk)
|
|
210
|
+
return b"".join(chunks)
|
|
211
|
+
raise ValueError("Invalid release redirect")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
async def download_release(
|
|
215
|
+
cache_dir: str | Path,
|
|
216
|
+
*,
|
|
217
|
+
base_url: str = DEFAULT_RELEASE_URL,
|
|
218
|
+
timeout: float = 30,
|
|
219
|
+
) -> Record:
|
|
220
|
+
"""Verify seven files before activating a bundle in cache_dir/python."""
|
|
221
|
+
if not cache_dir:
|
|
222
|
+
raise ValueError("cache_dir is required")
|
|
223
|
+
base = urlsplit(base_url)
|
|
224
|
+
loopback = base.scheme == "http" and base.hostname in {"127.0.0.1", "::1"}
|
|
225
|
+
if (
|
|
226
|
+
not base.hostname
|
|
227
|
+
or base.username
|
|
228
|
+
or base.password
|
|
229
|
+
or (base.scheme != "https" and not loopback)
|
|
230
|
+
):
|
|
231
|
+
raise ValueError("Release URL requires HTTPS or loopback HTTP")
|
|
232
|
+
if not base_url.endswith("/"):
|
|
233
|
+
base_url += "/"
|
|
234
|
+
cache = Path(cache_dir) / "python"
|
|
235
|
+
cache.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
236
|
+
async with (
|
|
237
|
+
asyncio.timeout(timeout),
|
|
238
|
+
AsyncFileLock(cache / ".update.lock", timeout=timeout),
|
|
239
|
+
httpx.AsyncClient(
|
|
240
|
+
timeout=timeout,
|
|
241
|
+
follow_redirects=False,
|
|
242
|
+
headers={"user-agent": f"DN42-IPDB-SDK-Python/{__version__}"},
|
|
243
|
+
) as client,
|
|
244
|
+
):
|
|
245
|
+
checksums = json.loads(
|
|
246
|
+
await _download(
|
|
247
|
+
client,
|
|
248
|
+
urljoin(base_url, "checksums.json"),
|
|
249
|
+
MAX_MANIFEST_BYTES,
|
|
250
|
+
base_url,
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
if not isinstance(checksums, dict):
|
|
254
|
+
raise TypeError("Invalid checksum manifest")
|
|
255
|
+
for filename in ARTIFACT_FILES:
|
|
256
|
+
digest = checksums.get(filename)
|
|
257
|
+
if (
|
|
258
|
+
not isinstance(digest, str)
|
|
259
|
+
or re.fullmatch(r"[0-9a-f]{64}", digest) is None
|
|
260
|
+
):
|
|
261
|
+
raise ValueError(f"Invalid checksum for {filename}")
|
|
262
|
+
previous = None
|
|
263
|
+
recovered_cache = False
|
|
264
|
+
current = cache / "current.json"
|
|
265
|
+
if current.exists():
|
|
266
|
+
pointer = _read_pointer(cache)
|
|
267
|
+
if pointer is None:
|
|
268
|
+
recovered_cache = True
|
|
269
|
+
else:
|
|
270
|
+
previous, old_checksums = pointer
|
|
271
|
+
directory = cache / previous
|
|
272
|
+
if all(
|
|
273
|
+
old_checksums[name] == checksums[name] for name in ARTIFACT_FILES
|
|
274
|
+
):
|
|
275
|
+
_verify(directory, checksums)
|
|
276
|
+
return {"directory": str(directory), "changed": False}
|
|
277
|
+
stage = Path(tempfile.mkdtemp(prefix=".stage-", dir=cache))
|
|
278
|
+
try:
|
|
279
|
+
for filename in ARTIFACT_FILES:
|
|
280
|
+
data = await _download(
|
|
281
|
+
client, urljoin(base_url, filename), MAX_DATABASE_BYTES, base_url
|
|
282
|
+
)
|
|
283
|
+
if hashlib.sha256(data).hexdigest() != checksums[filename]:
|
|
284
|
+
raise ValueError(f"Checksum mismatch: {filename}")
|
|
285
|
+
(stage / filename).write_bytes(data)
|
|
286
|
+
_verify(stage, checksums)
|
|
287
|
+
current_version = token_hex(32)
|
|
288
|
+
destination = cache / current_version
|
|
289
|
+
pending = cache / f".current-{current_version}"
|
|
290
|
+
committed = False
|
|
291
|
+
try:
|
|
292
|
+
pending.write_text(
|
|
293
|
+
json.dumps({"version": current_version, "checksums": checksums}),
|
|
294
|
+
encoding="utf-8",
|
|
295
|
+
)
|
|
296
|
+
for entry in cache.iterdir():
|
|
297
|
+
if (
|
|
298
|
+
entry.is_symlink()
|
|
299
|
+
or not entry.is_dir()
|
|
300
|
+
or re.fullmatch(r"[0-9a-f]{64}", entry.name) is None
|
|
301
|
+
or entry.name in {current_version, previous}
|
|
302
|
+
):
|
|
303
|
+
continue
|
|
304
|
+
files = list(entry.iterdir())
|
|
305
|
+
if len(files) == len(ARTIFACT_FILES) and all(
|
|
306
|
+
not file.is_symlink()
|
|
307
|
+
and file.is_file()
|
|
308
|
+
and file.name in ARTIFACT_FILES
|
|
309
|
+
for file in files
|
|
310
|
+
):
|
|
311
|
+
shutil.rmtree(entry)
|
|
312
|
+
os.replace(stage, destination)
|
|
313
|
+
os.replace(pending, current)
|
|
314
|
+
committed = True
|
|
315
|
+
result: Record = {"directory": str(destination), "changed": True}
|
|
316
|
+
if recovered_cache:
|
|
317
|
+
result["recovered_cache"] = True
|
|
318
|
+
return result
|
|
319
|
+
finally:
|
|
320
|
+
if not committed and destination.exists():
|
|
321
|
+
shutil.rmtree(destination)
|
|
322
|
+
pending.unlink(missing_ok=True)
|
|
323
|
+
finally:
|
|
324
|
+
if stage.exists():
|
|
325
|
+
shutil.rmtree(stage)
|
|
File without changes
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
UNICODE LICENSE V3
|
|
2
|
+
|
|
3
|
+
COPYRIGHT AND PERMISSION NOTICE
|
|
4
|
+
|
|
5
|
+
Copyright © 2015-2024 Unicode, Inc.
|
|
6
|
+
|
|
7
|
+
NOTICE TO USER: Carefully read the following legal agreement. BY
|
|
8
|
+
DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
|
|
9
|
+
SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
|
|
10
|
+
TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
|
|
11
|
+
DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a
|
|
14
|
+
copy of data files and any associated documentation (the "Data Files") or
|
|
15
|
+
software and any associated documentation (the "Software") to deal in the
|
|
16
|
+
Data Files or Software without restriction, including without limitation
|
|
17
|
+
the rights to use, copy, modify, merge, publish, distribute, and/or sell
|
|
18
|
+
copies of the Data Files or Software, and to permit persons to whom the
|
|
19
|
+
Data Files or Software are furnished to do so, provided that either (a)
|
|
20
|
+
this copyright and permission notice appear with all copies of the Data
|
|
21
|
+
Files or Software, or (b) this copyright and permission notice appear in
|
|
22
|
+
associated Documentation.
|
|
23
|
+
|
|
24
|
+
THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
|
|
25
|
+
KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
26
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
|
|
27
|
+
THIRD PARTY RIGHTS.
|
|
28
|
+
|
|
29
|
+
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
|
|
30
|
+
BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
|
|
31
|
+
OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
|
|
32
|
+
WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
|
|
33
|
+
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
|
|
34
|
+
FILES OR SOFTWARE.
|
|
35
|
+
|
|
36
|
+
Except as contained in this notice, the name of a copyright holder shall
|
|
37
|
+
not be used in advertising or otherwise to promote the sale, use or other
|
|
38
|
+
dealings in these Data Files or Software without prior written
|
|
39
|
+
authorization of the copyright holder.
|
|
40
|
+
|
|
41
|
+
SPDX-License-Identifier: Unicode-3.0
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
{
|
|
2
|
+
"asns": {
|
|
3
|
+
"4242420001": {
|
|
4
|
+
"asn": 4242420001,
|
|
5
|
+
"as_name": "TestNetwork",
|
|
6
|
+
"registered_countries": [
|
|
7
|
+
{
|
|
8
|
+
"iso_code": "DE",
|
|
9
|
+
"names": {
|
|
10
|
+
"en": "Germany"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"prefixes": [
|
|
15
|
+
"172.20.0.0/24",
|
|
16
|
+
"172.20.0.1/32",
|
|
17
|
+
"fd42:1234::/48",
|
|
18
|
+
"fd42:1234::1/128"
|
|
19
|
+
],
|
|
20
|
+
"observed_network_types": [
|
|
21
|
+
"anycast",
|
|
22
|
+
"public-resolver"
|
|
23
|
+
],
|
|
24
|
+
"observations": [
|
|
25
|
+
{
|
|
26
|
+
"target": "172.20.0.1",
|
|
27
|
+
"asn": 4242420001,
|
|
28
|
+
"kind": "irr",
|
|
29
|
+
"status": "exact",
|
|
30
|
+
"method": "registry-route-object",
|
|
31
|
+
"source": "data/route/test",
|
|
32
|
+
"authority": "registry-object",
|
|
33
|
+
"observed_at": "2023-11-14T22:13:20Z"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"target": "172.20.0.1",
|
|
37
|
+
"kind": "network_type",
|
|
38
|
+
"value": "public-resolver",
|
|
39
|
+
"status": "observed",
|
|
40
|
+
"method": "dns-query",
|
|
41
|
+
"source": "dns://172.20.0.1:53",
|
|
42
|
+
"authority": "dns-response",
|
|
43
|
+
"observed_at": "2023-11-14T22:13:20Z"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"target": "172.20.0.1",
|
|
47
|
+
"kind": "network_type",
|
|
48
|
+
"value": "anycast",
|
|
49
|
+
"status": "published",
|
|
50
|
+
"method": "published-service",
|
|
51
|
+
"source": "https://example.org/dns",
|
|
52
|
+
"authority": "service-documentation",
|
|
53
|
+
"observed_at": "2023-11-14T22:13:20Z"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"target": "172.20.0.1",
|
|
57
|
+
"kind": "ptr",
|
|
58
|
+
"value": "resolver.test.dn42.",
|
|
59
|
+
"status": "observed",
|
|
60
|
+
"method": "dns-ptr",
|
|
61
|
+
"source": "dns://172.20.0.1:53",
|
|
62
|
+
"authority": "dns-response",
|
|
63
|
+
"observed_at": "2023-11-14T22:13:20Z"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"target": "172.20.0.1",
|
|
67
|
+
"asn": 4242420001,
|
|
68
|
+
"kind": "roa",
|
|
69
|
+
"status": "valid",
|
|
70
|
+
"method": "dn42-registry-derived-roa",
|
|
71
|
+
"source": "https://example.org/roa.json",
|
|
72
|
+
"authority": "registry-derived",
|
|
73
|
+
"observed_at": "2023-11-14T22:13:20Z"
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"target": "fd42:1234::1",
|
|
77
|
+
"asn": 4242420001,
|
|
78
|
+
"kind": "irr",
|
|
79
|
+
"status": "exact",
|
|
80
|
+
"method": "registry-route-object",
|
|
81
|
+
"source": "data/route/test",
|
|
82
|
+
"authority": "registry-object",
|
|
83
|
+
"observed_at": "2023-11-14T22:13:20Z"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"target": "fd42:1234::1",
|
|
87
|
+
"kind": "network_type",
|
|
88
|
+
"value": "public-resolver",
|
|
89
|
+
"status": "observed",
|
|
90
|
+
"method": "dns-query",
|
|
91
|
+
"source": "dns://172.20.0.1:53",
|
|
92
|
+
"authority": "dns-response",
|
|
93
|
+
"observed_at": "2023-11-14T22:13:20Z"
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"target": "fd42:1234::1",
|
|
97
|
+
"kind": "network_type",
|
|
98
|
+
"value": "anycast",
|
|
99
|
+
"status": "published",
|
|
100
|
+
"method": "published-service",
|
|
101
|
+
"source": "https://example.org/dns",
|
|
102
|
+
"authority": "service-documentation",
|
|
103
|
+
"observed_at": "2023-11-14T22:13:20Z"
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"target": "fd42:1234::1",
|
|
107
|
+
"kind": "ptr",
|
|
108
|
+
"value": "resolver.test.dn42.",
|
|
109
|
+
"status": "observed",
|
|
110
|
+
"method": "dns-ptr",
|
|
111
|
+
"source": "dns://172.20.0.1:53",
|
|
112
|
+
"authority": "dns-response",
|
|
113
|
+
"observed_at": "2023-11-14T22:13:20Z"
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"target": "fd42:1234::1",
|
|
117
|
+
"asn": 4242420001,
|
|
118
|
+
"kind": "roa",
|
|
119
|
+
"status": "valid",
|
|
120
|
+
"method": "dn42-registry-derived-roa",
|
|
121
|
+
"source": "https://example.org/roa.json",
|
|
122
|
+
"authority": "registry-derived",
|
|
123
|
+
"observed_at": "2023-11-14T22:13:20Z"
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
"built_at": "2023-11-14T22:13:20Z",
|
|
129
|
+
"copyright": "Copyright 2026 BIRD Chinese Community (database compilation). Source data retains its respective ownership and licenses.",
|
|
130
|
+
"release_repository": "https://github.com/bird-chinese-community/DN42-IPDB-Releases",
|
|
131
|
+
"schema_version": "2"
|
|
132
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"attributions": [
|
|
3
|
+
{
|
|
4
|
+
"license": "Unicode-3.0",
|
|
5
|
+
"license_file": "LICENSE-Unicode.txt",
|
|
6
|
+
"name": "Unicode CLDR English names",
|
|
7
|
+
"source": "https://github.com/unicode-org/cldr-json/tree/7d2b9cb11cec08f4d333ad73209deb3df0711c3b"
|
|
8
|
+
}
|
|
9
|
+
],
|
|
10
|
+
"built_at": "2023-11-14T22:13:20Z",
|
|
11
|
+
"copyright": "Copyright 2026 BIRD Chinese Community (database compilation). Source data retains its respective ownership and licenses.",
|
|
12
|
+
"database_types": {
|
|
13
|
+
"asn.mmdb": "DN42-GeoLite2-ASN",
|
|
14
|
+
"city.mmdb": "DN42-GeoIP2-City",
|
|
15
|
+
"connection-type.mmdb": "DN42-GeoIP2-Connection-Type",
|
|
16
|
+
"country.mmdb": "DN42-GeoIP2-Country"
|
|
17
|
+
},
|
|
18
|
+
"generator": "DN42-IPDB",
|
|
19
|
+
"ip_version": 6,
|
|
20
|
+
"languages": [
|
|
21
|
+
"en"
|
|
22
|
+
],
|
|
23
|
+
"publisher": "BIRD Chinese Community",
|
|
24
|
+
"registry_commit": "",
|
|
25
|
+
"registry_committed_at": "0001-01-01T00:00:00Z",
|
|
26
|
+
"release_repository": "https://github.com/bird-chinese-community/DN42-IPDB-Releases",
|
|
27
|
+
"schema_version": "2",
|
|
28
|
+
"semantics": {
|
|
29
|
+
"connection_type": "Explicit access classification only; absent when unknown. Overlay tunnels do not identify subscriber access technology.",
|
|
30
|
+
"location": "Operator-authorized GeoFeed claim; registry registration country is separate.",
|
|
31
|
+
"network_types": "Published service roles or observed BGP path characteristics; method and source describe the evidence.",
|
|
32
|
+
"ptr": "Reverse DNS observations apply only to their queried host address.",
|
|
33
|
+
"roa_status": "Registry-derived DN42 origin policy; method and origin ASN are attached to each observation. No cryptographic RPKI validation is implied."
|
|
34
|
+
},
|
|
35
|
+
"source_documents": []
|
|
36
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from geoip2.database import Reader
|
|
6
|
+
from maxminddb import MODE_AUTO, MODE_MEMORY
|
|
7
|
+
|
|
8
|
+
FIXTURES = Path(__file__).with_name("data")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.parametrize(
|
|
12
|
+
"mode", [MODE_MEMORY] if sys.platform == "win32" else [MODE_MEMORY, MODE_AUTO]
|
|
13
|
+
)
|
|
14
|
+
def test_official_geoip2_readers(mode):
|
|
15
|
+
for address in ["172.20.0.1", "fd42:1234::1"]:
|
|
16
|
+
with Reader(FIXTURES / "country.mmdb", mode=mode) as database:
|
|
17
|
+
country = database.country(address)
|
|
18
|
+
assert country.country.iso_code == "US"
|
|
19
|
+
assert country.country.name == "United States"
|
|
20
|
+
assert country.registered_country.iso_code == "DE"
|
|
21
|
+
assert country.registered_country.name == "Germany"
|
|
22
|
+
with Reader(FIXTURES / "city.mmdb", mode=mode) as database:
|
|
23
|
+
city = database.city(address)
|
|
24
|
+
assert city.city.name == "Test City"
|
|
25
|
+
assert city.subdivisions.most_specific.name == "California"
|
|
26
|
+
with Reader(FIXTURES / "asn.mmdb", mode=mode) as database:
|
|
27
|
+
asn = database.asn(address)
|
|
28
|
+
assert asn.autonomous_system_number == 4242420001
|
|
29
|
+
assert asn.autonomous_system_organization == "TestNetwork"
|
|
30
|
+
with Reader(FIXTURES / "connection-type.mmdb", mode=mode) as database:
|
|
31
|
+
connection = database.connection_type(address)
|
|
32
|
+
assert connection.network is not None
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import pytest
|
|
12
|
+
from aiohttp import web
|
|
13
|
+
from dn42_ipdb import (
|
|
14
|
+
ARTIFACT_FILES,
|
|
15
|
+
download_release,
|
|
16
|
+
lookup,
|
|
17
|
+
lookup_asn,
|
|
18
|
+
metadata,
|
|
19
|
+
open_bundle,
|
|
20
|
+
open_cached_bundle,
|
|
21
|
+
release_metadata,
|
|
22
|
+
)
|
|
23
|
+
from filelock import Timeout
|
|
24
|
+
|
|
25
|
+
FIXTURES = Path(__file__).with_name("data")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@contextmanager
|
|
29
|
+
def fixture_server():
|
|
30
|
+
files = {name: (FIXTURES / name).read_bytes() for name in ARTIFACT_FILES}
|
|
31
|
+
state = {
|
|
32
|
+
"files": files,
|
|
33
|
+
"requests": [],
|
|
34
|
+
"failure": "",
|
|
35
|
+
"stall": False,
|
|
36
|
+
"bad_manifest": False,
|
|
37
|
+
"bad_checksum": False,
|
|
38
|
+
"redirect": "",
|
|
39
|
+
}
|
|
40
|
+
loop = asyncio.new_event_loop()
|
|
41
|
+
|
|
42
|
+
async def handle(request):
|
|
43
|
+
name = request.match_info["name"]
|
|
44
|
+
state["requests"].append(name)
|
|
45
|
+
if state["redirect"]:
|
|
46
|
+
raise web.HTTPFound(state["redirect"])
|
|
47
|
+
if name == state["failure"]:
|
|
48
|
+
return web.Response(status=503)
|
|
49
|
+
if name == "checksums.json":
|
|
50
|
+
checksums = (
|
|
51
|
+
{}
|
|
52
|
+
if state["bad_manifest"]
|
|
53
|
+
else {
|
|
54
|
+
name: hashlib.sha256(data).hexdigest()
|
|
55
|
+
for name, data in files.items()
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
if state["bad_checksum"]:
|
|
59
|
+
checksums["city.mmdb"] = "0" * 64
|
|
60
|
+
return web.json_response(checksums)
|
|
61
|
+
if state["stall"] and name == "city.mmdb":
|
|
62
|
+
await asyncio.sleep(10)
|
|
63
|
+
data = files.get(name)
|
|
64
|
+
return web.Response(body=data) if data is not None else web.Response(status=404)
|
|
65
|
+
|
|
66
|
+
async def start():
|
|
67
|
+
app = web.Application()
|
|
68
|
+
app.router.add_get("/{name}", handle)
|
|
69
|
+
runner = web.AppRunner(app, shutdown_timeout=0.1)
|
|
70
|
+
await runner.setup()
|
|
71
|
+
site = web.TCPSite(runner, "127.0.0.1", 0)
|
|
72
|
+
await site.start()
|
|
73
|
+
return runner
|
|
74
|
+
|
|
75
|
+
thread = threading.Thread(target=loop.run_forever, daemon=True)
|
|
76
|
+
thread.start()
|
|
77
|
+
runner = asyncio.run_coroutine_threadsafe(start(), loop).result(5)
|
|
78
|
+
address = runner.addresses[0]
|
|
79
|
+
try:
|
|
80
|
+
yield f"http://127.0.0.1:{address[1]}/", state
|
|
81
|
+
finally:
|
|
82
|
+
asyncio.run_coroutine_threadsafe(runner.cleanup(), loop).result(5)
|
|
83
|
+
loop.call_soon_threadsafe(loop.stop)
|
|
84
|
+
thread.join(5)
|
|
85
|
+
loop.close()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_local_lookup():
|
|
89
|
+
with open_bundle(FIXTURES) as database:
|
|
90
|
+
for address in ["172.20.0.1", "fd42:1234::1"]:
|
|
91
|
+
result = lookup(database, address)
|
|
92
|
+
assert (
|
|
93
|
+
result["records"]["asn"]["data"]["autonomous_system_number"]
|
|
94
|
+
== 4242420001
|
|
95
|
+
)
|
|
96
|
+
assert (
|
|
97
|
+
result["records"]["city"]["data"]["country"]["names"]["en"]
|
|
98
|
+
== "United States"
|
|
99
|
+
)
|
|
100
|
+
assert result["records"]["city"]["data"]["dn42"]["schema_version"] == "2"
|
|
101
|
+
assert lookup_asn(database, 4242420001)["asn"] == 4242420001
|
|
102
|
+
assert lookup_asn(database, 4242420999) is None
|
|
103
|
+
assert lookup(database, "203.0.113.1")["records"] == {}
|
|
104
|
+
with pytest.raises(ValueError):
|
|
105
|
+
lookup(database, "bad-ip")
|
|
106
|
+
with pytest.raises(ValueError):
|
|
107
|
+
lookup_asn(database, -1)
|
|
108
|
+
assert len(metadata(database)) == 4
|
|
109
|
+
assert "DN42-IPDB-Releases" in release_metadata(database)["release_repository"]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_download_reuse_and_local_cache(tmp_path):
|
|
113
|
+
with fixture_server() as (url, state):
|
|
114
|
+
first = asyncio.run(download_release(tmp_path, base_url=url))
|
|
115
|
+
assert first["changed"] is True
|
|
116
|
+
assert len(state["requests"]) == 8
|
|
117
|
+
second = asyncio.run(download_release(tmp_path, base_url=url))
|
|
118
|
+
assert second == {"directory": first["directory"], "changed": False}
|
|
119
|
+
assert len(state["requests"]) == 9
|
|
120
|
+
with open_cached_bundle(tmp_path) as database:
|
|
121
|
+
assert lookup_asn(database, 4242420001)["asn"] == 4242420001
|
|
122
|
+
lookup(database, "172.20.0.1")
|
|
123
|
+
assert len(state["requests"]) == 9
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_reject_insecure_redirect_before_request(tmp_path):
|
|
127
|
+
with fixture_server() as (url, state), fixture_server() as (target, target_state):
|
|
128
|
+
state["redirect"] = f"{target}checksums.json"
|
|
129
|
+
with pytest.raises(ValueError, match="Insecure release redirect"):
|
|
130
|
+
asyncio.run(download_release(tmp_path, base_url=url))
|
|
131
|
+
assert len(state["requests"]) == 1
|
|
132
|
+
assert target_state["requests"] == []
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_update_failures_preserve_pointer(tmp_path):
|
|
136
|
+
with fixture_server() as (url, state):
|
|
137
|
+
asyncio.run(download_release(tmp_path, base_url=url))
|
|
138
|
+
pointer = tmp_path / "python/current.json"
|
|
139
|
+
original = pointer.read_bytes()
|
|
140
|
+
state["files"]["metadata.json"] = json.dumps(
|
|
141
|
+
{
|
|
142
|
+
"schema_version": "2",
|
|
143
|
+
"release_repository": "test",
|
|
144
|
+
"copyright": "BIRDCC",
|
|
145
|
+
"revision": 1,
|
|
146
|
+
}
|
|
147
|
+
).encode()
|
|
148
|
+
state["failure"] = "asn.mmdb"
|
|
149
|
+
with pytest.raises(httpx.HTTPStatusError, match="503"):
|
|
150
|
+
asyncio.run(download_release(tmp_path, base_url=url))
|
|
151
|
+
assert pointer.read_bytes() == original
|
|
152
|
+
state["failure"] = ""
|
|
153
|
+
state["files"]["city.mmdb"] = state["files"]["asn.mmdb"]
|
|
154
|
+
with pytest.raises(ValueError, match="expected.*City"):
|
|
155
|
+
asyncio.run(download_release(tmp_path, base_url=url))
|
|
156
|
+
assert pointer.read_bytes() == original
|
|
157
|
+
state["bad_checksum"] = True
|
|
158
|
+
with pytest.raises(ValueError, match="Checksum mismatch"):
|
|
159
|
+
asyncio.run(download_release(tmp_path, base_url=url))
|
|
160
|
+
assert pointer.read_bytes() == original
|
|
161
|
+
state["bad_manifest"] = True
|
|
162
|
+
with pytest.raises(ValueError, match="Invalid checksum"):
|
|
163
|
+
asyncio.run(download_release(tmp_path, base_url=url))
|
|
164
|
+
assert pointer.read_bytes() == original
|
|
165
|
+
with open_cached_bundle(tmp_path) as database:
|
|
166
|
+
assert lookup_asn(database, 4242420001)["asn"] == 4242420001
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_cancel_before_publication(tmp_path):
|
|
170
|
+
with fixture_server() as (url, state):
|
|
171
|
+
state["stall"] = True
|
|
172
|
+
|
|
173
|
+
async def run():
|
|
174
|
+
task = asyncio.create_task(download_release(tmp_path, base_url=url))
|
|
175
|
+
await asyncio.sleep(0.1)
|
|
176
|
+
task.cancel()
|
|
177
|
+
with pytest.raises(asyncio.CancelledError):
|
|
178
|
+
await task
|
|
179
|
+
|
|
180
|
+
asyncio.run(run())
|
|
181
|
+
assert not (tmp_path / "python/current.json").exists()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_retention_with_loaded_reader(tmp_path):
|
|
185
|
+
with fixture_server() as (url, state):
|
|
186
|
+
first = asyncio.run(download_release(tmp_path, base_url=url))
|
|
187
|
+
custom = tmp_path / "python" / ("f" * 64)
|
|
188
|
+
custom.mkdir()
|
|
189
|
+
(custom / "user.txt").write_text("keep")
|
|
190
|
+
with open_cached_bundle(tmp_path) as database:
|
|
191
|
+
for revision in [1, 2, 3]:
|
|
192
|
+
state["files"]["metadata.json"] = json.dumps(
|
|
193
|
+
{
|
|
194
|
+
"schema_version": "2",
|
|
195
|
+
"release_repository": "test",
|
|
196
|
+
"copyright": "BIRDCC",
|
|
197
|
+
"revision": revision,
|
|
198
|
+
}
|
|
199
|
+
).encode()
|
|
200
|
+
asyncio.run(download_release(tmp_path, base_url=url))
|
|
201
|
+
assert not Path(first["directory"]).exists()
|
|
202
|
+
assert (
|
|
203
|
+
lookup(database, "172.20.0.1")["records"]["city"]["data"]["dn42"][
|
|
204
|
+
"schema_version"
|
|
205
|
+
]
|
|
206
|
+
== "2"
|
|
207
|
+
)
|
|
208
|
+
assert lookup_asn(database, 4242420001)["asn"] == 4242420001
|
|
209
|
+
assert (custom / "user.txt").read_text() == "keep"
|
|
210
|
+
assert (
|
|
211
|
+
len(
|
|
212
|
+
[
|
|
213
|
+
entry
|
|
214
|
+
for entry in (tmp_path / "python").iterdir()
|
|
215
|
+
if len(entry.name) == 64
|
|
216
|
+
]
|
|
217
|
+
)
|
|
218
|
+
== 3
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_cross_process_lock(tmp_path):
|
|
223
|
+
with fixture_server() as (url, _):
|
|
224
|
+
asyncio.run(download_release(tmp_path, base_url=url))
|
|
225
|
+
child = subprocess.Popen(
|
|
226
|
+
[
|
|
227
|
+
sys.executable,
|
|
228
|
+
str(Path(__file__).with_name("hold_lock.py")),
|
|
229
|
+
str(tmp_path / "python"),
|
|
230
|
+
],
|
|
231
|
+
stdin=subprocess.PIPE,
|
|
232
|
+
stdout=subprocess.PIPE,
|
|
233
|
+
text=True,
|
|
234
|
+
)
|
|
235
|
+
try:
|
|
236
|
+
assert child.stdout.readline() == "locked\n"
|
|
237
|
+
with pytest.raises(Timeout), open_cached_bundle(tmp_path):
|
|
238
|
+
pass
|
|
239
|
+
with pytest.raises(TimeoutError):
|
|
240
|
+
asyncio.run(download_release(tmp_path, base_url=url, timeout=0.1))
|
|
241
|
+
finally:
|
|
242
|
+
child.communicate("x", timeout=5)
|
|
243
|
+
assert child.returncode == 0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_recover_malformed_pointer(tmp_path):
|
|
247
|
+
with fixture_server() as (url, _):
|
|
248
|
+
asyncio.run(download_release(tmp_path, base_url=url))
|
|
249
|
+
(tmp_path / "python/current.json").write_text("{")
|
|
250
|
+
result = asyncio.run(download_release(tmp_path, base_url=url))
|
|
251
|
+
assert result["changed"] is True
|
|
252
|
+
assert result["recovered_cache"] is True
|
|
253
|
+
with open_cached_bundle(tmp_path) as database:
|
|
254
|
+
assert lookup_asn(database, 4242420001)["asn"] == 4242420001
|