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.
@@ -0,0 +1,14 @@
1
+ .cache/
2
+ .tmp/
3
+ node_modules/
4
+ dist/
5
+ target/
6
+ .venv/
7
+ __pycache__/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ *.egg-info/
11
+ *.pyc
12
+ python/build/
13
+ python/dist/
14
+ *.tgz
@@ -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.
@@ -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
@@ -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,8 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ from filelock import FileLock
5
+
6
+ with FileLock(Path(sys.argv[1]) / ".update.lock"):
7
+ print("locked", flush=True)
8
+ sys.stdin.read(1)
@@ -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