plevin 0.1.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.
- plevin/__init__.py +399 -0
- plevin/address.py +133 -0
- plevin/derive.py +111 -0
- plevin/extra.py +168 -0
- plevin/models.py +179 -0
- plevin/py.typed +0 -0
- plevin/reader.py +404 -0
- plevin-0.1.0.dist-info/METADATA +368 -0
- plevin-0.1.0.dist-info/RECORD +11 -0
- plevin-0.1.0.dist-info/WHEEL +4 -0
- plevin-0.1.0.dist-info/licenses/LICENSE +201 -0
plevin/__init__.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Location, network and abuse information for any IP address in one offline file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from importlib import import_module
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
from os import PathLike
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from time import time as now
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from . import address, derive
|
|
16
|
+
from .address import (
|
|
17
|
+
BENCHMARK,
|
|
18
|
+
DOCUMENTATION,
|
|
19
|
+
LINK_LOCAL,
|
|
20
|
+
LOOPBACK,
|
|
21
|
+
MAPPED,
|
|
22
|
+
MULTICAST,
|
|
23
|
+
PRIVATE,
|
|
24
|
+
RESERVED,
|
|
25
|
+
SHARED,
|
|
26
|
+
SIXTOFOUR,
|
|
27
|
+
TEREDO,
|
|
28
|
+
UNIQUE_LOCAL,
|
|
29
|
+
Value,
|
|
30
|
+
parse,
|
|
31
|
+
purpose,
|
|
32
|
+
span,
|
|
33
|
+
spelled,
|
|
34
|
+
tunnel,
|
|
35
|
+
)
|
|
36
|
+
from .extra import clock, country
|
|
37
|
+
from .models import (
|
|
38
|
+
Abuse,
|
|
39
|
+
Carrier,
|
|
40
|
+
City,
|
|
41
|
+
Country,
|
|
42
|
+
District,
|
|
43
|
+
Metro,
|
|
44
|
+
Network,
|
|
45
|
+
Operator,
|
|
46
|
+
Place,
|
|
47
|
+
Region,
|
|
48
|
+
Result,
|
|
49
|
+
Time,
|
|
50
|
+
)
|
|
51
|
+
from .reader import CACHED, Cache, File, Found
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"Abuse",
|
|
55
|
+
"Carrier",
|
|
56
|
+
"City",
|
|
57
|
+
"Country",
|
|
58
|
+
"District",
|
|
59
|
+
"Metro",
|
|
60
|
+
"Network",
|
|
61
|
+
"Operator",
|
|
62
|
+
"Place",
|
|
63
|
+
"Plevin",
|
|
64
|
+
"Region",
|
|
65
|
+
"Result",
|
|
66
|
+
"Time",
|
|
67
|
+
"address",
|
|
68
|
+
"database",
|
|
69
|
+
"lookup",
|
|
70
|
+
"use",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
__version__ = version("plevin")
|
|
75
|
+
except PackageNotFoundError: # pragma: no cover
|
|
76
|
+
__version__ = "0.0.0"
|
|
77
|
+
|
|
78
|
+
ENVIRONMENT = "PLEVIN_DB"
|
|
79
|
+
PACKAGES = ("plevin_db", "plevin_db_place", "plevin_db_network", "plevin_db_country")
|
|
80
|
+
MISSING = (
|
|
81
|
+
"no database found: install one of "
|
|
82
|
+
f"{', '.join(name.replace('_', '-') for name in PACKAGES)}, set {ENVIRONMENT},"
|
|
83
|
+
" or pass a path"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
WIDE = 1 << 128
|
|
87
|
+
ANSWERED = 1 << 10
|
|
88
|
+
|
|
89
|
+
Rows = dict[str, Any]
|
|
90
|
+
Ground = tuple[Any, Any, Any, Any, str | None, City | None, Country | None]
|
|
91
|
+
Wires = tuple[int | None, str | None, str | None, Any, Operator | None, Carrier | None]
|
|
92
|
+
Stored = tuple[tuple[Ground, str] | None, tuple[Wires, int | None] | None,
|
|
93
|
+
Abuse | None]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _text(value: Any) -> str | None:
|
|
97
|
+
return str(value) if value else None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _count(value: Any) -> int | None:
|
|
101
|
+
return int(value) if value else None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _metro(rows: Rows | None) -> Metro | None:
|
|
105
|
+
if rows is None:
|
|
106
|
+
return None
|
|
107
|
+
return Metro(code=_count(rows.get("code")), label=_text(rows.get("label")))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _district(rows: Rows | None) -> District | None:
|
|
111
|
+
if rows is None:
|
|
112
|
+
return None
|
|
113
|
+
return District(id=_count(rows.get("id")), code=_text(rows.get("code")),
|
|
114
|
+
name=_text(rows.get("name")))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _region(rows: Rows | None) -> Region | None:
|
|
118
|
+
if rows is None:
|
|
119
|
+
return None
|
|
120
|
+
return Region(id=_count(rows.get("id")), code=_text(rows.get("code")),
|
|
121
|
+
iso=_text(rows.get("iso")), name=_text(rows.get("name")),
|
|
122
|
+
type=_text(rows.get("type")))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Shaped:
|
|
126
|
+
"""One model per row object the file hands back, and the file hands back one."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, build: Callable[..., Any]) -> None:
|
|
129
|
+
self.build = build
|
|
130
|
+
self.held: dict[Any, tuple[Rows, Any]] = {}
|
|
131
|
+
|
|
132
|
+
def __call__(self, rows: Rows | None, *rest: Any) -> Any:
|
|
133
|
+
if rows is None:
|
|
134
|
+
return None
|
|
135
|
+
key = (id(rows), rest)
|
|
136
|
+
found = self.held.get(key)
|
|
137
|
+
if found is not None and found[0] is rows:
|
|
138
|
+
return found[1]
|
|
139
|
+
if len(self.held) >= CACHED:
|
|
140
|
+
self.held.clear()
|
|
141
|
+
model = self.held[key] = (rows, self.build(rows, *rest))
|
|
142
|
+
return model[1]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _built_city(rows: Rows) -> City:
|
|
146
|
+
kind = str(rows.get("type", ""))
|
|
147
|
+
return City(
|
|
148
|
+
id=_count(rows.get("id")),
|
|
149
|
+
name=_text(rows.get("name")),
|
|
150
|
+
ascii=_text(rows.get("ascii")),
|
|
151
|
+
country=_text(rows.get("country")),
|
|
152
|
+
population=_count(rows.get("population")),
|
|
153
|
+
elevation=_count(rows.get("elevation")),
|
|
154
|
+
postal=_text(rows.get("postal")),
|
|
155
|
+
postal_partial=_text(rows.get("postal_partial")),
|
|
156
|
+
timezone=_text(rows.get("timezone")),
|
|
157
|
+
type=_text(kind),
|
|
158
|
+
capital=_text(derive.capital(kind)),
|
|
159
|
+
region=_region(rows.get("region")),
|
|
160
|
+
district=_district(rows.get("district")),
|
|
161
|
+
metro=_metro(rows.get("metro")),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
_city = Shaped(_built_city)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _place(rows: Rows | None) -> tuple[Ground, str] | None:
|
|
169
|
+
"""The place without its clock, which is the one part an address does not fix."""
|
|
170
|
+
if rows is None:
|
|
171
|
+
return None
|
|
172
|
+
city = _city(rows.get("city"))
|
|
173
|
+
code = "" if city is None or city.country is None else city.country
|
|
174
|
+
zone = "" if city is None or city.timezone is None else city.timezone
|
|
175
|
+
ground = (rows.get("lat"), rows.get("lon"), rows.get("accuracy"),
|
|
176
|
+
rows.get("confidence"), _text(rows.get("granularity")), city,
|
|
177
|
+
country(code))
|
|
178
|
+
return ground, zone
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _built_operator(rows: Rows, handle: str) -> Operator:
|
|
182
|
+
company = str(rows.get("company", ""))
|
|
183
|
+
website = str(rows.get("website", ""))
|
|
184
|
+
mailbox = str(rows.get("abuse_email", ""))
|
|
185
|
+
return Operator(
|
|
186
|
+
company=_text(company),
|
|
187
|
+
brand=_text(derive.brand(handle, company)),
|
|
188
|
+
domain=_text(derive.domain(website, mailbox)),
|
|
189
|
+
website=_text(website),
|
|
190
|
+
category=_text(rows.get("category")),
|
|
191
|
+
tier=_count(rows.get("tier")),
|
|
192
|
+
peering=_count(rows.get("peering")),
|
|
193
|
+
scope=_text(rows.get("scope")),
|
|
194
|
+
rir=_text(rows.get("rir")),
|
|
195
|
+
since=_count(rows.get("since")),
|
|
196
|
+
street=_text(rows.get("street")),
|
|
197
|
+
state=_text(rows.get("state")),
|
|
198
|
+
postal=_text(rows.get("postal")),
|
|
199
|
+
country=_text(rows.get("country")),
|
|
200
|
+
abuse_email=_text(mailbox),
|
|
201
|
+
city=_city(rows.get("city")),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
_operator = Shaped(_built_operator)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _carrier(rows: Rows | None, user_type: str) -> Carrier | None:
|
|
209
|
+
if rows is None and not user_type:
|
|
210
|
+
return None
|
|
211
|
+
held = rows or {}
|
|
212
|
+
return Carrier(
|
|
213
|
+
user_type=_text(user_type),
|
|
214
|
+
user_count=_count(held.get("user_count")),
|
|
215
|
+
mcc=_count(held.get("mcc")),
|
|
216
|
+
mnc=_count(held.get("mnc")),
|
|
217
|
+
is_mobile=user_type == "cellular",
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _abuse(record: Rows | None, system: Rows | None, user_type: str) -> Abuse | None:
|
|
222
|
+
if record is None and system is None:
|
|
223
|
+
return None
|
|
224
|
+
held = record or {}
|
|
225
|
+
named, inferred = derive.service(str(held.get("service", "")), user_type)
|
|
226
|
+
evidence = str(held.get("evidence", "")) or inferred
|
|
227
|
+
return Abuse(
|
|
228
|
+
name=_text(held.get("name")),
|
|
229
|
+
service=_text(named),
|
|
230
|
+
evidence=_text(evidence),
|
|
231
|
+
risk=held.get("risk"),
|
|
232
|
+
network_risk=None if system is None else system.get("risk"),
|
|
233
|
+
last_seen_days=_count(held.get("last_seen_days")),
|
|
234
|
+
is_anycast=bool(held.get("is_anycast")),
|
|
235
|
+
is_satellite=bool(held.get("is_satellite")),
|
|
236
|
+
is_hosting_provider=user_type in derive.SERVERS,
|
|
237
|
+
is_proxy=named in derive.PROXIES,
|
|
238
|
+
is_public_proxy=named == "public_proxy",
|
|
239
|
+
is_residential_proxy=named == "residential_proxy",
|
|
240
|
+
is_anonymous_vpn=named == "anonymous_vpn",
|
|
241
|
+
is_tor_exit_node=named == "tor_exit_node",
|
|
242
|
+
is_private_relay=named == "private_relay",
|
|
243
|
+
is_anonymous=bool(named),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _network(rows: Rows | None, user_type: str) -> tuple[Wires, int | None] | None:
|
|
248
|
+
"""The network without its span, which the address the lookup asked about fixes."""
|
|
249
|
+
if rows is None:
|
|
250
|
+
return None
|
|
251
|
+
handle = str(rows.get("handle", ""))
|
|
252
|
+
wires = (_count(rows.get("asn")), _text(handle), _text(rows.get("rpki")),
|
|
253
|
+
rows.get("roas"), _operator(rows.get("operator"), handle),
|
|
254
|
+
_carrier(rows.get("carrier"), user_type))
|
|
255
|
+
return wires, _count(rows.get("prefix"))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _user_type(record: Rows | None, system: Rows | None) -> str:
|
|
259
|
+
"""The ASN's type sits on the system row; the record carries only an override."""
|
|
260
|
+
held, below = record or {}, system or {}
|
|
261
|
+
return str(held.get("user_type") or below.get("user_type") or "")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _stored(rows: Rows) -> Stored:
|
|
265
|
+
"""Everything a boundary answers that no address of it changes, built once."""
|
|
266
|
+
network = rows.get("network")
|
|
267
|
+
system = None if network is None else network.get("abuse")
|
|
268
|
+
user_type = _user_type(rows.get("abuse"), system)
|
|
269
|
+
return (_place(rows.get("place")), _network(network, user_type),
|
|
270
|
+
_abuse(rows.get("abuse"), system, user_type))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _result(value: int, wide: bool, stored: Stored | None,
|
|
274
|
+
moment: datetime | None) -> Result:
|
|
275
|
+
text, expanded, arpa = spelled(value, wide)
|
|
276
|
+
marks = purpose(value, wide)
|
|
277
|
+
through, embedded = tunnel(value, wide)
|
|
278
|
+
ground, wires, abuse = stored or (None, None, None)
|
|
279
|
+
return Result(
|
|
280
|
+
ip=text,
|
|
281
|
+
version=6 if wide else 4,
|
|
282
|
+
number=value,
|
|
283
|
+
compressed=text,
|
|
284
|
+
expanded=expanded,
|
|
285
|
+
arpa=arpa,
|
|
286
|
+
is_global=marks == 0,
|
|
287
|
+
is_bogon=marks != 0,
|
|
288
|
+
is_private=marks & PRIVATE != 0,
|
|
289
|
+
is_loopback=marks & LOOPBACK != 0,
|
|
290
|
+
is_multicast=marks & MULTICAST != 0,
|
|
291
|
+
is_reserved=marks & RESERVED != 0,
|
|
292
|
+
is_link_local=marks & LINK_LOCAL != 0,
|
|
293
|
+
is_unique_local=marks & UNIQUE_LOCAL != 0,
|
|
294
|
+
is_documentation=marks & DOCUMENTATION != 0,
|
|
295
|
+
is_shared=marks & SHARED != 0,
|
|
296
|
+
is_benchmark=marks & BENCHMARK != 0,
|
|
297
|
+
is_ipv4_mapped=through == MAPPED,
|
|
298
|
+
is_6to4=through == SIXTOFOUR,
|
|
299
|
+
is_teredo=through == TEREDO,
|
|
300
|
+
tunnel=through,
|
|
301
|
+
embedded_ipv4=embedded,
|
|
302
|
+
found=stored is not None,
|
|
303
|
+
place=None if ground is None else Place(*ground[0], clock(ground[1], moment)),
|
|
304
|
+
network=None if wires is None else _spanned(*wires, value, wide),
|
|
305
|
+
abuse=abuse,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _spanned(wires: Wires, prefix: int | None, value: int, wide: bool) -> Network:
|
|
310
|
+
asn, handle, rpki, roas, operator, carrier = wires
|
|
311
|
+
cidr, start, end = (None, None, None) if prefix is None else span(
|
|
312
|
+
value, wide, prefix)
|
|
313
|
+
return Network(asn, handle, prefix, cidr, start, end, rpki, roas, operator, carrier)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _found() -> Path:
|
|
317
|
+
"""The database a reader that was given no path should open."""
|
|
318
|
+
named = os.environ.get(ENVIRONMENT)
|
|
319
|
+
if named:
|
|
320
|
+
return Path(named)
|
|
321
|
+
for package in PACKAGES:
|
|
322
|
+
try:
|
|
323
|
+
module = import_module(package)
|
|
324
|
+
except ModuleNotFoundError:
|
|
325
|
+
continue
|
|
326
|
+
return Path(module.PATH)
|
|
327
|
+
raise LookupError(MISSING)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class Plevin:
|
|
331
|
+
"""One database, opened once and asked as often as a log has addresses."""
|
|
332
|
+
|
|
333
|
+
def __init__(self, path: str | PathLike[str] | None = None) -> None:
|
|
334
|
+
self.file = File(_found() if path is None else path)
|
|
335
|
+
self.stored = Cache(self._stored)
|
|
336
|
+
self.results = Cache(self._result, ANSWERED)
|
|
337
|
+
self.second = 0
|
|
338
|
+
|
|
339
|
+
@property
|
|
340
|
+
def path(self) -> str:
|
|
341
|
+
return self.file.path
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def built(self) -> str:
|
|
345
|
+
return self.file.built
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def selection(self) -> str:
|
|
349
|
+
return self.file.selection
|
|
350
|
+
|
|
351
|
+
@property
|
|
352
|
+
def fields(self) -> list[str]:
|
|
353
|
+
return self.file.fields
|
|
354
|
+
|
|
355
|
+
def _stored(self, found: Found) -> Stored:
|
|
356
|
+
return _stored(self.file.answers[found])
|
|
357
|
+
|
|
358
|
+
def _result(self, key: int) -> Result:
|
|
359
|
+
wide = key >= WIDE
|
|
360
|
+
value = key - WIDE if wide else key
|
|
361
|
+
found = self.file.locate(value, wide)
|
|
362
|
+
return _result(value, wide, None if found is None else self.stored[found], None)
|
|
363
|
+
|
|
364
|
+
def lookup(self, value: Value, moment: datetime | None = None) -> Result:
|
|
365
|
+
"""One address, however it is written, as everything the file answers."""
|
|
366
|
+
number, wide = parse(value)
|
|
367
|
+
if moment is not None:
|
|
368
|
+
found = self.file.locate(number, wide)
|
|
369
|
+
held = None if found is None else self.stored[found]
|
|
370
|
+
return _result(number, wide, held, moment)
|
|
371
|
+
second = int(now())
|
|
372
|
+
if second != self.second:
|
|
373
|
+
self.second = second
|
|
374
|
+
self.results.clear()
|
|
375
|
+
answer: Result = self.results[number + WIDE if wide else number]
|
|
376
|
+
return answer
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
_opened: Plevin | None = None
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def database() -> Plevin:
|
|
383
|
+
"""The database the module reads, opened on the first address asked of it."""
|
|
384
|
+
global _opened
|
|
385
|
+
if _opened is None:
|
|
386
|
+
_opened = Plevin()
|
|
387
|
+
return _opened
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def use(path: str | PathLike[str] | None) -> Plevin:
|
|
391
|
+
"""Read a database of your own from here on, or forget the one in use."""
|
|
392
|
+
global _opened
|
|
393
|
+
_opened = None if path is None else Plevin(path)
|
|
394
|
+
return database()
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def lookup(value: Value, moment: datetime | None = None) -> Result:
|
|
398
|
+
"""One address, however it is written, as everything the file answers."""
|
|
399
|
+
return database().lookup(value, moment)
|
plevin/address.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""What an address is before any database is opened."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from bisect import bisect_right
|
|
6
|
+
from ipaddress import IPv4Address, IPv6Address, ip_address, ip_network
|
|
7
|
+
from socket import AF_INET, AF_INET6, inet_pton
|
|
8
|
+
|
|
9
|
+
Value = str | int | bytes | bytearray | memoryview | IPv4Address | IPv6Address
|
|
10
|
+
|
|
11
|
+
PRIVATE, LOOPBACK, MULTICAST, RESERVED = 1, 2, 4, 8
|
|
12
|
+
LINK_LOCAL, UNIQUE_LOCAL, DOCUMENTATION, SHARED, BENCHMARK = 16, 32, 64, 128, 256
|
|
13
|
+
|
|
14
|
+
SPECIAL_V4 = (
|
|
15
|
+
("0.0.0.0/8", RESERVED),
|
|
16
|
+
("10.0.0.0/8", PRIVATE),
|
|
17
|
+
("100.64.0.0/10", PRIVATE | SHARED),
|
|
18
|
+
("127.0.0.0/8", PRIVATE | LOOPBACK),
|
|
19
|
+
("169.254.0.0/16", PRIVATE | LINK_LOCAL),
|
|
20
|
+
("172.16.0.0/12", PRIVATE),
|
|
21
|
+
("192.0.0.0/24", RESERVED),
|
|
22
|
+
("192.0.2.0/24", RESERVED | DOCUMENTATION),
|
|
23
|
+
("192.88.99.0/24", RESERVED),
|
|
24
|
+
("192.168.0.0/16", PRIVATE),
|
|
25
|
+
("198.18.0.0/15", RESERVED | BENCHMARK),
|
|
26
|
+
("198.51.100.0/24", RESERVED | DOCUMENTATION),
|
|
27
|
+
("203.0.113.0/24", RESERVED | DOCUMENTATION),
|
|
28
|
+
("224.0.0.0/4", MULTICAST),
|
|
29
|
+
("240.0.0.0/4", RESERVED),
|
|
30
|
+
)
|
|
31
|
+
SPECIAL_V6 = (
|
|
32
|
+
("::/128", RESERVED),
|
|
33
|
+
("::1/128", PRIVATE | LOOPBACK),
|
|
34
|
+
("::ffff:0:0/96", RESERVED),
|
|
35
|
+
("64:ff9b::/96", RESERVED),
|
|
36
|
+
("64:ff9b:1::/48", RESERVED),
|
|
37
|
+
("100::/64", RESERVED),
|
|
38
|
+
("2001::/23", RESERVED),
|
|
39
|
+
("2001:db8::/32", RESERVED | DOCUMENTATION),
|
|
40
|
+
("2002::/16", RESERVED),
|
|
41
|
+
("3fff::/20", RESERVED | DOCUMENTATION),
|
|
42
|
+
("fc00::/7", PRIVATE | UNIQUE_LOCAL),
|
|
43
|
+
("fe80::/10", PRIVATE | LINK_LOCAL),
|
|
44
|
+
("ff00::/8", MULTICAST),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
MAPPED = "ipv4-mapped"
|
|
48
|
+
SIXTOFOUR = "6to4"
|
|
49
|
+
TEREDO = "teredo"
|
|
50
|
+
NAT64 = "nat64"
|
|
51
|
+
NAT64_PREFIX = 0x0064FF9B << 96
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _table(rows: tuple[tuple[str, int], ...]) -> tuple[list[int], list[tuple[int, int]]]:
|
|
55
|
+
"""The rows as first addresses to bisect, and what each range ends at and marks."""
|
|
56
|
+
spans = []
|
|
57
|
+
for text, marks in rows:
|
|
58
|
+
span = ip_network(text)
|
|
59
|
+
spans.append((int(span.network_address), int(span.broadcast_address), marks))
|
|
60
|
+
spans.sort()
|
|
61
|
+
return [first for first, _, _ in spans], [(last, marks) for _, last, marks in spans]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
SPECIAL = (_table(SPECIAL_V4), _table(SPECIAL_V6))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse(value: Value) -> tuple[int, bool]:
|
|
68
|
+
"""An address however it is written, as its number and whether it is a v6 one."""
|
|
69
|
+
if isinstance(value, str):
|
|
70
|
+
wide = ":" in value
|
|
71
|
+
try:
|
|
72
|
+
packed = inet_pton(AF_INET6 if wide else AF_INET, value)
|
|
73
|
+
except OSError:
|
|
74
|
+
return parse(ip_address(value))
|
|
75
|
+
return int.from_bytes(packed, "big"), wide
|
|
76
|
+
if isinstance(value, bytes | bytearray | memoryview):
|
|
77
|
+
return parse(ip_address(bytes(value)))
|
|
78
|
+
if isinstance(value, IPv4Address | IPv6Address):
|
|
79
|
+
return int(value), value.version == 6
|
|
80
|
+
if value < 0 or value > 0xFFFFFFFF_FFFFFFFF_FFFFFFFF_FFFFFFFF:
|
|
81
|
+
raise ValueError(f"{value} is not an address")
|
|
82
|
+
return value, value > 0xFFFFFFFF
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def written(value: int, wide: bool) -> str:
|
|
86
|
+
"""An address as text: v4 from its octets, v6 through the shortening rules."""
|
|
87
|
+
if wide:
|
|
88
|
+
return str(IPv6Address(value))
|
|
89
|
+
return f"{value >> 24}.{value >> 16 & 255}.{value >> 8 & 255}.{value & 255}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def spelled(value: int, wide: bool) -> tuple[str, str, str]:
|
|
93
|
+
"""How the address reads short and in full, and the name a resolver asks by."""
|
|
94
|
+
if wide:
|
|
95
|
+
address = IPv6Address(value)
|
|
96
|
+
return str(address), address.exploded, address.reverse_pointer
|
|
97
|
+
octets = [str(value >> shift & 255) for shift in (24, 16, 8, 0)]
|
|
98
|
+
text = ".".join(octets)
|
|
99
|
+
return text, text, ".".join(reversed(octets)) + ".in-addr.arpa"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def tunnel(value: int, wide: bool) -> tuple[str | None, str | None]:
|
|
103
|
+
"""The v4 address a v6 one carries, and the tunnel that puts it there."""
|
|
104
|
+
if not wide:
|
|
105
|
+
return None, None
|
|
106
|
+
address = IPv6Address(value)
|
|
107
|
+
if address.ipv4_mapped is not None:
|
|
108
|
+
return MAPPED, str(address.ipv4_mapped)
|
|
109
|
+
if address.sixtofour is not None:
|
|
110
|
+
return SIXTOFOUR, str(address.sixtofour)
|
|
111
|
+
if address.teredo is not None:
|
|
112
|
+
return TEREDO, str(address.teredo[1])
|
|
113
|
+
if value >> 32 == NAT64_PREFIX >> 32:
|
|
114
|
+
return NAT64, written(value & 0xFFFFFFFF, False)
|
|
115
|
+
return None, None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def purpose(value: int, wide: bool) -> int:
|
|
119
|
+
"""What an address is where it is not the internet, bisected out of the table."""
|
|
120
|
+
starts, ranges = SPECIAL[wide]
|
|
121
|
+
at = bisect_right(starts, value) - 1
|
|
122
|
+
if at >= 0:
|
|
123
|
+
last, marks = ranges[at]
|
|
124
|
+
if value <= last:
|
|
125
|
+
return marks
|
|
126
|
+
return RESERVED if wide and value >> 125 != 1 else 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def span(value: int, wide: bool, prefix: int) -> tuple[str, str, str]:
|
|
130
|
+
"""The announcement the address falls in, masked out of the address itself."""
|
|
131
|
+
spare = (128 if wide else 32) - prefix
|
|
132
|
+
start = written(value >> spare << spare, wide)
|
|
133
|
+
return f"{start}/{prefix}", start, written(value | (1 << spare) - 1, wide)
|
plevin/derive.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""The answers the file does not store, read off the ones it does."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
|
|
8
|
+
FORMS = frozenset(
|
|
9
|
+
"inc incorporated llc ltd ltda limited gmbh mbh ag kgaa ohg ev eg sa sab saa sau"
|
|
10
|
+
" sal saog sac sas sarl srl spa nv bv cv asa aps oyj kft zrt nyrt doo sro ooo zao"
|
|
11
|
+
" pao pjsc jsc ojsc llp plc pte pteltd pty corp corporation company holding"
|
|
12
|
+
" holdings group uab sia tov oao pt sdn bhd coltd coltda eireli ead ood eood sti"
|
|
13
|
+
" ltdsti spzoo".split()
|
|
14
|
+
)
|
|
15
|
+
TAILS = frozenset(
|
|
16
|
+
"de me epp co as ab ad dd bt lc lp se sl slu sp z oo zoo oy ao esp kg network"
|
|
17
|
+
" networks net telecom telecoms telecommunication telecommunications"
|
|
18
|
+
" telecomunicaciones comunicaciones communication communications hosting solutions"
|
|
19
|
+
" services service technologies technology tech systems system data datacenter"
|
|
20
|
+
" datacentre cloud internet online isp international global enterprises enterprise"
|
|
21
|
+
" backbone provider providers of and".split()
|
|
22
|
+
)
|
|
23
|
+
LEAD = frozenset(
|
|
24
|
+
"the llc ltd gmbh sarl ooo zao pao ao oao jsc ojsc pjsc uab sia tov pt pp ps ip"
|
|
25
|
+
" spolka".split()
|
|
26
|
+
)
|
|
27
|
+
TAIL = FORMS | TAILS | frozenset({""})
|
|
28
|
+
TLDS = (".com", ".net", ".org", ".io")
|
|
29
|
+
|
|
30
|
+
TRADING = re.compile(r"(?i).*\b(?:trading as|d/b/a|dba)\b\s*")
|
|
31
|
+
ALIAS = re.compile(r"\(.*?\)|,.*|\s+-\s+.*")
|
|
32
|
+
BARE = re.compile(r"[^0-9a-z]")
|
|
33
|
+
NUMBERED = re.compile(r"AS\d+", re.I)
|
|
34
|
+
HANDLE_TAIL = re.compile(r"-(AS|AP|US|UK|DE|FR|IN|CN|JP|EU|NET|COM|ORG)$")
|
|
35
|
+
NETWORK_TAIL = re.compile(r"(NET|COM|TEL|WEB|LINE)$")
|
|
36
|
+
AUTHORITY = re.compile(r"[/?#]")
|
|
37
|
+
|
|
38
|
+
SERVERS = frozenset({"hosting", "cdn", "content"})
|
|
39
|
+
ACCESS = frozenset({"residential", "cellular"})
|
|
40
|
+
PROXIES = frozenset({"public_proxy", "residential_proxy"})
|
|
41
|
+
NAMES = 1 << 13
|
|
42
|
+
CAPITALS = {"national capital": "country", "regional capital": "region",
|
|
43
|
+
"district capital": "district"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _cased(text: str) -> str:
|
|
47
|
+
"""Shouted words stop shouting, `GOOGLE` reading as `Google` and `IBM` as `IBM`."""
|
|
48
|
+
return " ".join(
|
|
49
|
+
word.title() if word.isalpha() and word.isupper() and len(word) > 4 else word
|
|
50
|
+
for word in text.split(" ")
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _from_company(company: str) -> str:
|
|
55
|
+
"""Aliases, legal forms and the words every network shares, all stripped."""
|
|
56
|
+
words = ALIAS.sub("", TRADING.sub("", company)).split()
|
|
57
|
+
tokens = [token for word in words if (token := word.strip("\"'"))]
|
|
58
|
+
while len(tokens) > 1 and BARE.sub("", tokens[-1].lower()) in TAIL:
|
|
59
|
+
tokens.pop()
|
|
60
|
+
while tokens and BARE.sub("", tokens[0].lower()) in LEAD:
|
|
61
|
+
tokens.pop(0)
|
|
62
|
+
name = " ".join(tokens)
|
|
63
|
+
cut = next((len(tld) for tld in TLDS if name.lower().endswith(tld)), 0)
|
|
64
|
+
return _cased(name[: len(name) - cut])
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _from_handle(handle: str) -> str:
|
|
68
|
+
"""The first word of a registry handle, its country and network tails gone."""
|
|
69
|
+
words = handle.split()
|
|
70
|
+
head = HANDLE_TAIL.sub("", words[0]) if words else ""
|
|
71
|
+
if NUMBERED.fullmatch(head):
|
|
72
|
+
return ""
|
|
73
|
+
if len(head) > 4 and head.isupper():
|
|
74
|
+
head = NETWORK_TAIL.sub("", head)
|
|
75
|
+
return _cased(head)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@lru_cache(maxsize=NAMES)
|
|
79
|
+
def brand(handle: str, company: str) -> str:
|
|
80
|
+
"""The name a network goes by: its handle where the company only spells it out."""
|
|
81
|
+
legal, short = _from_company(company), _from_handle(handle)
|
|
82
|
+
if not legal or not short:
|
|
83
|
+
return legal or short
|
|
84
|
+
if legal.lower() == short.lower():
|
|
85
|
+
return legal if short.isupper() else short
|
|
86
|
+
return short if legal.lower().startswith(f"{short.lower()} ") else legal
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@lru_cache(maxsize=NAMES)
|
|
90
|
+
def domain(website: str, mailbox: str) -> str:
|
|
91
|
+
"""The bare host the website names, else the one the abuse mailbox does."""
|
|
92
|
+
authority = AUTHORITY.split(website.rpartition("//")[2], maxsplit=1)[0]
|
|
93
|
+
host = authority.rpartition("@")[2].partition(":")[0]
|
|
94
|
+
site = (host or mailbox.partition("@")[2]).lower().removeprefix("www.")
|
|
95
|
+
box = mailbox.partition("@")[2].lower()
|
|
96
|
+
top = site.rpartition(".")[2]
|
|
97
|
+
if len(top) > 2 and box.partition(".")[0] == top and site != box:
|
|
98
|
+
return box
|
|
99
|
+
return site
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def service(named: str, user_type: str) -> tuple[str, str]:
|
|
103
|
+
"""A public proxy on an access network is someone's home line, resold."""
|
|
104
|
+
if named == "public_proxy" and user_type in ACCESS:
|
|
105
|
+
return "residential_proxy", "inferred"
|
|
106
|
+
return named, ""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def capital(city_type: str) -> str:
|
|
110
|
+
"""The city type already says which capital it is, so nothing stores it twice."""
|
|
111
|
+
return CAPITALS.get(city_type, "")
|