sentinel-python-client 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sentinel/__init__.py +69 -0
- sentinel/_async_client.py +56 -0
- sentinel/_client.py +56 -0
- sentinel/_exceptions.py +54 -0
- sentinel/_http.py +192 -0
- sentinel/_replay.py +32 -0
- sentinel/_signature.py +119 -0
- sentinel/models/__init__.py +24 -0
- sentinel/models/license.py +63 -0
- sentinel/models/page.py +15 -0
- sentinel/models/requests.py +184 -0
- sentinel/models/validation.py +102 -0
- sentinel/services/__init__.py +1 -0
- sentinel/services/_async_license.py +172 -0
- sentinel/services/_async_operations.py +80 -0
- sentinel/services/_license.py +193 -0
- sentinel/services/_operations.py +74 -0
- sentinel/util/__init__.py +5 -0
- sentinel/util/fingerprint.py +383 -0
- sentinel/util/public_ip.py +29 -0
- sentinel_python_client-2.0.0.dist-info/METADATA +41 -0
- sentinel_python_client-2.0.0.dist-info/RECORD +24 -0
- sentinel_python_client-2.0.0.dist-info/WHEEL +4 -0
- sentinel_python_client-2.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from sentinel._exceptions import SentinelApiError
|
|
6
|
+
from sentinel._http import ApiResponse, SentinelHttpClient
|
|
7
|
+
from sentinel._replay import ReplayProtector
|
|
8
|
+
from sentinel._signature import SignatureVerifier
|
|
9
|
+
from sentinel.models.license import License
|
|
10
|
+
from sentinel.models.page import Page
|
|
11
|
+
from sentinel.models.requests import (
|
|
12
|
+
CreateLicenseRequest,
|
|
13
|
+
ListLicensesRequest,
|
|
14
|
+
UpdateLicenseRequest,
|
|
15
|
+
ValidationRequest,
|
|
16
|
+
)
|
|
17
|
+
from sentinel.models.validation import (
|
|
18
|
+
BlacklistFailureDetails,
|
|
19
|
+
ExcessiveIpsFailureDetails,
|
|
20
|
+
ExcessiveServersFailureDetails,
|
|
21
|
+
FailureDetails,
|
|
22
|
+
ValidationDetails,
|
|
23
|
+
ValidationResult,
|
|
24
|
+
ValidationResultType,
|
|
25
|
+
)
|
|
26
|
+
from sentinel.services._operations import (
|
|
27
|
+
LicenseConnectionOperations,
|
|
28
|
+
LicenseIpOperations,
|
|
29
|
+
LicenseServerOperations,
|
|
30
|
+
LicenseSubUserOperations,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
_BASE_PATH = "/api/v2/licenses"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LicenseService:
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
http_client: SentinelHttpClient,
|
|
40
|
+
signature_verifier: SignatureVerifier | None = None,
|
|
41
|
+
replay_protector: ReplayProtector | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
self._http = http_client
|
|
44
|
+
self._sig = signature_verifier
|
|
45
|
+
self._replay = replay_protector
|
|
46
|
+
self.connections = LicenseConnectionOperations(http_client)
|
|
47
|
+
self.servers = LicenseServerOperations(http_client)
|
|
48
|
+
self.ips = LicenseIpOperations(http_client)
|
|
49
|
+
self.sub_users = LicenseSubUserOperations(http_client)
|
|
50
|
+
|
|
51
|
+
def validate(self, request: ValidationRequest) -> ValidationResult:
|
|
52
|
+
body = request.to_body()
|
|
53
|
+
if "server" not in body:
|
|
54
|
+
from sentinel.util.fingerprint import generate_fingerprint
|
|
55
|
+
|
|
56
|
+
body["server"] = generate_fingerprint()
|
|
57
|
+
|
|
58
|
+
resp = self._http.request(
|
|
59
|
+
"POST", f"{_BASE_PATH}/validate", json_body=body, allowed_statuses={403}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if resp.http_status == 200:
|
|
63
|
+
return self._parse_validation_success(resp)
|
|
64
|
+
if resp.http_status == 403:
|
|
65
|
+
return self._parse_validation_failure(resp)
|
|
66
|
+
raise SentinelApiError(
|
|
67
|
+
http_status=resp.http_status,
|
|
68
|
+
type=resp.type,
|
|
69
|
+
message=resp.message or "Unknown error",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def create(self, request: CreateLicenseRequest) -> License:
|
|
73
|
+
resp = self._http.request("POST", _BASE_PATH, json_body=request.to_body())
|
|
74
|
+
return License.model_validate(resp.require_result()["license"])
|
|
75
|
+
|
|
76
|
+
def get(self, key: str) -> License:
|
|
77
|
+
resp = self._http.request("GET", f"{_BASE_PATH}/{key}")
|
|
78
|
+
return License.model_validate(resp.require_result()["license"])
|
|
79
|
+
|
|
80
|
+
def list(self, request: ListLicensesRequest) -> Page[License]:
|
|
81
|
+
resp = self._http.request("GET", _BASE_PATH, query_params=request.to_query_params())
|
|
82
|
+
page_data = resp.require_result()["page"]
|
|
83
|
+
licenses = [License.model_validate(item) for item in page_data["content"]]
|
|
84
|
+
meta = page_data["page"]
|
|
85
|
+
return Page(
|
|
86
|
+
content=licenses,
|
|
87
|
+
size=meta["size"],
|
|
88
|
+
number=meta["number"],
|
|
89
|
+
total_elements=meta["totalElements"],
|
|
90
|
+
total_pages=meta["totalPages"],
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def update(self, key: str, request: UpdateLicenseRequest) -> License:
|
|
94
|
+
resp = self._http.request("PATCH", f"{_BASE_PATH}/{key}", json_body=request.to_body())
|
|
95
|
+
return License.model_validate(resp.require_result()["license"])
|
|
96
|
+
|
|
97
|
+
def delete(self, key: str) -> None:
|
|
98
|
+
self._http.request("DELETE", f"{_BASE_PATH}/{key}")
|
|
99
|
+
|
|
100
|
+
def regenerate_key(self, key: str, new_key: str | None = None) -> License:
|
|
101
|
+
params = {"newKey": new_key} if new_key else None
|
|
102
|
+
resp = self._http.request(
|
|
103
|
+
"POST", f"{_BASE_PATH}/{key}/regenerate-key", query_params=params
|
|
104
|
+
)
|
|
105
|
+
return License.model_validate(resp.require_result()["license"])
|
|
106
|
+
|
|
107
|
+
def _parse_validation_success(self, resp: ApiResponse) -> ValidationResult:
|
|
108
|
+
validation = resp.require_result()["validation"]
|
|
109
|
+
nonce = validation["nonce"]
|
|
110
|
+
timestamp = validation["timestamp"]
|
|
111
|
+
signature = validation.get("signature")
|
|
112
|
+
details_data = validation["details"]
|
|
113
|
+
|
|
114
|
+
expiration_str = details_data.get("expiration")
|
|
115
|
+
expiration = datetime.fromisoformat(expiration_str) if expiration_str else None
|
|
116
|
+
server_count = details_data["serverCount"]
|
|
117
|
+
max_servers = details_data["maxServers"]
|
|
118
|
+
ip_count = details_data["ipCount"]
|
|
119
|
+
max_ips = details_data["maxIps"]
|
|
120
|
+
tier = details_data.get("tier")
|
|
121
|
+
|
|
122
|
+
raw_entitlements = details_data.get("entitlements")
|
|
123
|
+
entitlements_list: list[str] | None = None
|
|
124
|
+
entitlements_set: set[str] = set()
|
|
125
|
+
if raw_entitlements is not None:
|
|
126
|
+
entitlements_list = list(raw_entitlements)
|
|
127
|
+
entitlements_set = set(raw_entitlements)
|
|
128
|
+
|
|
129
|
+
details = ValidationDetails(
|
|
130
|
+
expiration=expiration,
|
|
131
|
+
server_count=server_count,
|
|
132
|
+
max_servers=max_servers,
|
|
133
|
+
ip_count=ip_count,
|
|
134
|
+
max_ips=max_ips,
|
|
135
|
+
tier=tier,
|
|
136
|
+
entitlements=entitlements_set,
|
|
137
|
+
)
|
|
138
|
+
result = ValidationResult.success(details=details, message=resp.message or "")
|
|
139
|
+
|
|
140
|
+
if self._sig is not None:
|
|
141
|
+
self._sig.verify(
|
|
142
|
+
signature_base64=signature,
|
|
143
|
+
nonce=nonce,
|
|
144
|
+
timestamp=timestamp,
|
|
145
|
+
expiration=expiration_str,
|
|
146
|
+
server_count=server_count,
|
|
147
|
+
max_servers=max_servers,
|
|
148
|
+
ip_count=ip_count,
|
|
149
|
+
max_ips=max_ips,
|
|
150
|
+
tier=tier,
|
|
151
|
+
entitlements=entitlements_list,
|
|
152
|
+
)
|
|
153
|
+
if self._replay is not None:
|
|
154
|
+
self._replay.check(nonce, timestamp)
|
|
155
|
+
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
def _parse_validation_failure(self, resp: ApiResponse) -> ValidationResult:
|
|
159
|
+
result_type = ValidationResultType.from_string(resp.type)
|
|
160
|
+
if result_type == ValidationResultType.UNKNOWN:
|
|
161
|
+
raise SentinelApiError(
|
|
162
|
+
http_status=resp.http_status,
|
|
163
|
+
type=resp.type,
|
|
164
|
+
message=resp.message or "Unknown error",
|
|
165
|
+
)
|
|
166
|
+
failure_details = _parse_failure_details(result_type, resp.result)
|
|
167
|
+
return ValidationResult.failure(
|
|
168
|
+
type=result_type,
|
|
169
|
+
message=resp.message or "",
|
|
170
|
+
failure_details=failure_details,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _parse_failure_details(
|
|
175
|
+
result_type: ValidationResultType,
|
|
176
|
+
result: dict | None,
|
|
177
|
+
) -> FailureDetails | None:
|
|
178
|
+
if result is None:
|
|
179
|
+
return None
|
|
180
|
+
try:
|
|
181
|
+
if result_type == ValidationResultType.BLACKLISTED_LICENSE:
|
|
182
|
+
bl = result["blacklist"]
|
|
183
|
+
return BlacklistFailureDetails(
|
|
184
|
+
timestamp=datetime.fromisoformat(bl["timestamp"]),
|
|
185
|
+
reason=bl.get("reason"),
|
|
186
|
+
)
|
|
187
|
+
if result_type == ValidationResultType.EXCESSIVE_SERVERS:
|
|
188
|
+
return ExcessiveServersFailureDetails(max_servers=result["maxServers"])
|
|
189
|
+
if result_type == ValidationResultType.EXCESSIVE_IPS:
|
|
190
|
+
return ExcessiveIpsFailureDetails(max_ips=result["maxIps"])
|
|
191
|
+
except (KeyError, ValueError):
|
|
192
|
+
pass
|
|
193
|
+
return None
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from sentinel._http import SentinelHttpClient
|
|
4
|
+
from sentinel.models.license import License, SubUser
|
|
5
|
+
|
|
6
|
+
_BASE_PATH = "/api/v2/licenses"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LicenseConnectionOperations:
|
|
10
|
+
def __init__(self, http_client: SentinelHttpClient) -> None:
|
|
11
|
+
self._http = http_client
|
|
12
|
+
|
|
13
|
+
def add(self, key: str, connections: dict[str, str]) -> License:
|
|
14
|
+
resp = self._http.request("POST", f"{_BASE_PATH}/{key}/connections", json_body=connections)
|
|
15
|
+
return License.model_validate(resp.require_result()["license"])
|
|
16
|
+
|
|
17
|
+
def remove(self, key: str, platforms: set[str]) -> License:
|
|
18
|
+
resp = self._http.request(
|
|
19
|
+
"DELETE",
|
|
20
|
+
f"{_BASE_PATH}/{key}/connections",
|
|
21
|
+
multi_query_params={"platforms": sorted(platforms)},
|
|
22
|
+
)
|
|
23
|
+
return License.model_validate(resp.require_result()["license"])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LicenseServerOperations:
|
|
27
|
+
def __init__(self, http_client: SentinelHttpClient) -> None:
|
|
28
|
+
self._http = http_client
|
|
29
|
+
|
|
30
|
+
def add(self, key: str, identifiers: set[str]) -> License:
|
|
31
|
+
resp = self._http.request(
|
|
32
|
+
"POST", f"{_BASE_PATH}/{key}/servers", json_body=sorted(identifiers)
|
|
33
|
+
)
|
|
34
|
+
return License.model_validate(resp.require_result()["license"])
|
|
35
|
+
|
|
36
|
+
def remove(self, key: str, identifiers: set[str]) -> License:
|
|
37
|
+
resp = self._http.request(
|
|
38
|
+
"DELETE",
|
|
39
|
+
f"{_BASE_PATH}/{key}/servers",
|
|
40
|
+
multi_query_params={"servers": sorted(identifiers)},
|
|
41
|
+
)
|
|
42
|
+
return License.model_validate(resp.require_result()["license"])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LicenseIpOperations:
|
|
46
|
+
def __init__(self, http_client: SentinelHttpClient) -> None:
|
|
47
|
+
self._http = http_client
|
|
48
|
+
|
|
49
|
+
def add(self, key: str, addresses: set[str]) -> License:
|
|
50
|
+
resp = self._http.request("POST", f"{_BASE_PATH}/{key}/ips", json_body=sorted(addresses))
|
|
51
|
+
return License.model_validate(resp.require_result()["license"])
|
|
52
|
+
|
|
53
|
+
def remove(self, key: str, addresses: set[str]) -> License:
|
|
54
|
+
resp = self._http.request(
|
|
55
|
+
"DELETE",
|
|
56
|
+
f"{_BASE_PATH}/{key}/ips",
|
|
57
|
+
multi_query_params={"ips": sorted(addresses)},
|
|
58
|
+
)
|
|
59
|
+
return License.model_validate(resp.require_result()["license"])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class LicenseSubUserOperations:
|
|
63
|
+
def __init__(self, http_client: SentinelHttpClient) -> None:
|
|
64
|
+
self._http = http_client
|
|
65
|
+
|
|
66
|
+
def add(self, key: str, sub_users: list[SubUser]) -> License:
|
|
67
|
+
body = [su.model_dump() for su in sub_users]
|
|
68
|
+
resp = self._http.request("POST", f"{_BASE_PATH}/{key}/sub-users", json_body=body)
|
|
69
|
+
return License.model_validate(resp.require_result()["license"])
|
|
70
|
+
|
|
71
|
+
def remove(self, key: str, sub_users: list[SubUser]) -> License:
|
|
72
|
+
body = [su.model_dump() for su in sub_users]
|
|
73
|
+
resp = self._http.request("POST", f"{_BASE_PATH}/{key}/sub-users/remove", json_body=body)
|
|
74
|
+
return License.model_validate(resp.require_result()["license"])
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
_PROCESS_TIMEOUT = 5
|
|
12
|
+
_CONTAINER_MARKERS = (
|
|
13
|
+
"docker",
|
|
14
|
+
"containerd",
|
|
15
|
+
"kubepods",
|
|
16
|
+
"podman",
|
|
17
|
+
"libpod",
|
|
18
|
+
"lxc",
|
|
19
|
+
"machine.slice",
|
|
20
|
+
)
|
|
21
|
+
_IGNORED_PREFIXES = (
|
|
22
|
+
"lo",
|
|
23
|
+
"docker",
|
|
24
|
+
"br-",
|
|
25
|
+
"veth",
|
|
26
|
+
"cni",
|
|
27
|
+
"flannel",
|
|
28
|
+
"cali",
|
|
29
|
+
"virbr",
|
|
30
|
+
"podman",
|
|
31
|
+
"vmnet",
|
|
32
|
+
"vboxnet",
|
|
33
|
+
"awdl",
|
|
34
|
+
"llw",
|
|
35
|
+
"utun",
|
|
36
|
+
"dummy",
|
|
37
|
+
"zt",
|
|
38
|
+
"tailscale",
|
|
39
|
+
"wg",
|
|
40
|
+
"tun",
|
|
41
|
+
"tap",
|
|
42
|
+
"isatap",
|
|
43
|
+
"teredo",
|
|
44
|
+
"bond",
|
|
45
|
+
"team",
|
|
46
|
+
)
|
|
47
|
+
_INVALID_VALUES = frozenset(
|
|
48
|
+
{
|
|
49
|
+
"unknown",
|
|
50
|
+
"none",
|
|
51
|
+
"null",
|
|
52
|
+
"not specified",
|
|
53
|
+
"not available",
|
|
54
|
+
"not settable",
|
|
55
|
+
"not applicable",
|
|
56
|
+
"invalid",
|
|
57
|
+
"n/a",
|
|
58
|
+
"na",
|
|
59
|
+
"default string",
|
|
60
|
+
"system serial number",
|
|
61
|
+
"system product name",
|
|
62
|
+
"system manufacturer",
|
|
63
|
+
"chassis serial number",
|
|
64
|
+
"base board serial number",
|
|
65
|
+
"no asset information",
|
|
66
|
+
"type1productconfigid",
|
|
67
|
+
"o.e.m.",
|
|
68
|
+
"empty",
|
|
69
|
+
"unspecified",
|
|
70
|
+
"default",
|
|
71
|
+
"serial",
|
|
72
|
+
"none specified",
|
|
73
|
+
"123456789",
|
|
74
|
+
"1234567890",
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
_cached: str | None = None
|
|
79
|
+
_lock = threading.Lock()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def generate_fingerprint() -> str:
|
|
83
|
+
global _cached
|
|
84
|
+
if _cached is not None:
|
|
85
|
+
return _cached
|
|
86
|
+
with _lock:
|
|
87
|
+
if _cached is not None:
|
|
88
|
+
return _cached
|
|
89
|
+
result = _sha256_hex(_get_platform_id())[:32]
|
|
90
|
+
_cached = result
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _get_platform_id() -> str:
|
|
95
|
+
system = platform.system().lower()
|
|
96
|
+
|
|
97
|
+
if "linux" in system:
|
|
98
|
+
mid = _read_linux_machine_id()
|
|
99
|
+
if mid is not None:
|
|
100
|
+
return mid
|
|
101
|
+
elif "darwin" in system:
|
|
102
|
+
mid = _read_mac_uuid()
|
|
103
|
+
if mid is not None:
|
|
104
|
+
return mid
|
|
105
|
+
elif "windows" in system:
|
|
106
|
+
mid = _read_windows_machine_guid()
|
|
107
|
+
if mid is not None:
|
|
108
|
+
return mid
|
|
109
|
+
elif "bsd" in system:
|
|
110
|
+
mid = _read_bsd_host_uuid()
|
|
111
|
+
if mid is not None:
|
|
112
|
+
return mid
|
|
113
|
+
|
|
114
|
+
return _fallback_fingerprint()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _read_linux_machine_id() -> str | None:
|
|
118
|
+
mid = _read_text_file("/etc/machine-id")
|
|
119
|
+
if mid is not None:
|
|
120
|
+
return mid
|
|
121
|
+
return _read_text_file("/var/lib/dbus/machine-id")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _read_mac_uuid() -> str | None:
|
|
125
|
+
output = _run_command("ioreg", "-rd1", "-c", "IOPlatformExpertDevice")
|
|
126
|
+
if output is None:
|
|
127
|
+
return None
|
|
128
|
+
for line in output.split("\n"):
|
|
129
|
+
if "IOPlatformUUID" in line:
|
|
130
|
+
eq_idx = line.find("=")
|
|
131
|
+
if eq_idx < 0:
|
|
132
|
+
continue
|
|
133
|
+
start = line.find('"', eq_idx)
|
|
134
|
+
if start < 0:
|
|
135
|
+
continue
|
|
136
|
+
end = line.find('"', start + 1)
|
|
137
|
+
if end > start:
|
|
138
|
+
return _normalize_identifier(line[start + 1 : end])
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _read_windows_machine_guid() -> str | None:
|
|
143
|
+
if sys.platform == "win32":
|
|
144
|
+
try:
|
|
145
|
+
import winreg
|
|
146
|
+
|
|
147
|
+
with winreg.OpenKey(
|
|
148
|
+
winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Cryptography"
|
|
149
|
+
) as key:
|
|
150
|
+
value, _ = winreg.QueryValueEx(key, "MachineGuid")
|
|
151
|
+
return _normalize_identifier(str(value))
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
output = _run_command(
|
|
155
|
+
"reg", "query", r"HKLM\SOFTWARE\Microsoft\Cryptography", "/v", "MachineGuid"
|
|
156
|
+
)
|
|
157
|
+
if output is None:
|
|
158
|
+
return None
|
|
159
|
+
for line in output.split("\n"):
|
|
160
|
+
if "MachineGuid" in line:
|
|
161
|
+
parts = line.strip().split()
|
|
162
|
+
if len(parts) >= 3:
|
|
163
|
+
return _normalize_identifier(parts[-1])
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _read_bsd_host_uuid() -> str | None:
|
|
168
|
+
output = _run_command("sysctl", "-n", "kern.hostuuid")
|
|
169
|
+
if output is None:
|
|
170
|
+
return None
|
|
171
|
+
return _normalize_identifier(output)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _fallback_fingerprint() -> str:
|
|
175
|
+
parts: list[str] = []
|
|
176
|
+
|
|
177
|
+
stable_id = _read_stable_host_id()
|
|
178
|
+
if stable_id is not None:
|
|
179
|
+
parts.append(stable_id)
|
|
180
|
+
|
|
181
|
+
dmi = _read_dmi_composite()
|
|
182
|
+
if dmi is not None:
|
|
183
|
+
parts.append(dmi)
|
|
184
|
+
|
|
185
|
+
parts.append(platform.system())
|
|
186
|
+
parts.append(platform.machine())
|
|
187
|
+
|
|
188
|
+
containerized = _is_containerized()
|
|
189
|
+
|
|
190
|
+
hostname = None
|
|
191
|
+
if not containerized:
|
|
192
|
+
hostname = _resolve_hostname()
|
|
193
|
+
if hostname is not None:
|
|
194
|
+
parts.append(hostname)
|
|
195
|
+
|
|
196
|
+
macs = _read_mac_addresses(containerized)
|
|
197
|
+
if not macs and containerized:
|
|
198
|
+
macs = _read_mac_addresses(False)
|
|
199
|
+
parts.extend(macs)
|
|
200
|
+
|
|
201
|
+
if not macs and hostname is None:
|
|
202
|
+
parts.append(os.environ.get("USER", os.environ.get("USERNAME", "")))
|
|
203
|
+
parts.append(os.path.expanduser("~"))
|
|
204
|
+
|
|
205
|
+
joined = "\0".join(parts)
|
|
206
|
+
if not joined.strip():
|
|
207
|
+
return (
|
|
208
|
+
"static-fallback" + "\0" + platform.python_implementation() + "\0" + platform.version()
|
|
209
|
+
)
|
|
210
|
+
return joined
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _resolve_hostname() -> str | None:
|
|
214
|
+
for var in ("HOSTNAME", "COMPUTERNAME"):
|
|
215
|
+
val = os.environ.get(var)
|
|
216
|
+
if val and val.strip():
|
|
217
|
+
return val
|
|
218
|
+
val = _read_text_file("/etc/hostname")
|
|
219
|
+
if val is not None:
|
|
220
|
+
return val
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _read_stable_host_id() -> str | None:
|
|
225
|
+
paths = [
|
|
226
|
+
"/sys/class/dmi/id/product_serial",
|
|
227
|
+
"/sys/devices/virtual/dmi/id/product_serial",
|
|
228
|
+
"/sys/class/dmi/id/board_serial",
|
|
229
|
+
"/sys/devices/virtual/dmi/id/board_serial",
|
|
230
|
+
"/sys/class/dmi/id/product_uuid",
|
|
231
|
+
"/sys/devices/virtual/dmi/id/product_uuid",
|
|
232
|
+
]
|
|
233
|
+
for path in paths:
|
|
234
|
+
val = _read_text_file(path)
|
|
235
|
+
if val is not None:
|
|
236
|
+
return val
|
|
237
|
+
|
|
238
|
+
val = _read_binary_text_file("/proc/device-tree/serial-number")
|
|
239
|
+
if val is not None:
|
|
240
|
+
return val
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _read_dmi_composite() -> str | None:
|
|
245
|
+
parts: list[str] = []
|
|
246
|
+
for path in [
|
|
247
|
+
"/sys/class/dmi/id/board_vendor",
|
|
248
|
+
"/sys/class/dmi/id/board_name",
|
|
249
|
+
"/sys/class/dmi/id/product_name",
|
|
250
|
+
"/sys/class/dmi/id/sys_vendor",
|
|
251
|
+
]:
|
|
252
|
+
val = _read_text_file(path)
|
|
253
|
+
if val is not None:
|
|
254
|
+
parts.append(val)
|
|
255
|
+
return "\0".join(parts) if parts else None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _read_mac_addresses(skip_locally_administered: bool) -> list[str]:
|
|
259
|
+
try:
|
|
260
|
+
node = uuid.getnode()
|
|
261
|
+
if node and not (node >> 40) & 0x01:
|
|
262
|
+
mac_hex = f"{node:012x}"
|
|
263
|
+
mac_bytes = bytes.fromhex(mac_hex)
|
|
264
|
+
if not _is_all_zeros(mac_bytes) and not _is_all_ones(mac_bytes):
|
|
265
|
+
if not (skip_locally_administered and (mac_bytes[0] & 0x02)):
|
|
266
|
+
return [mac_hex]
|
|
267
|
+
except Exception:
|
|
268
|
+
pass
|
|
269
|
+
return []
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _is_containerized() -> bool:
|
|
273
|
+
if os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv"):
|
|
274
|
+
return True
|
|
275
|
+
for var in ("KUBERNETES_SERVICE_HOST", "container", "DOTNET_RUNNING_IN_CONTAINER"):
|
|
276
|
+
if os.environ.get(var) is not None:
|
|
277
|
+
return True
|
|
278
|
+
cgroup = _read_raw_text_file("/proc/1/cgroup")
|
|
279
|
+
if cgroup is not None:
|
|
280
|
+
lower = cgroup.lower()
|
|
281
|
+
for marker in _CONTAINER_MARKERS:
|
|
282
|
+
if marker in lower:
|
|
283
|
+
return True
|
|
284
|
+
self_cgroup = _read_raw_text_file("/proc/self/cgroup")
|
|
285
|
+
if self_cgroup is not None:
|
|
286
|
+
for line in self_cgroup.split("\n"):
|
|
287
|
+
if line.startswith("0::"):
|
|
288
|
+
path = line[3:].strip().lower()
|
|
289
|
+
for marker in _CONTAINER_MARKERS:
|
|
290
|
+
if marker in path:
|
|
291
|
+
return True
|
|
292
|
+
mountinfo = _read_raw_text_file("/proc/1/mountinfo")
|
|
293
|
+
if mountinfo is not None:
|
|
294
|
+
for line in mountinfo.split("\n"):
|
|
295
|
+
if " / / " in line and "overlay" in line:
|
|
296
|
+
return True
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _normalize_identifier(raw_value: str | None) -> str | None:
|
|
301
|
+
if raw_value is None:
|
|
302
|
+
return None
|
|
303
|
+
value = raw_value.strip()
|
|
304
|
+
if not value:
|
|
305
|
+
return None
|
|
306
|
+
lower = value.lower()
|
|
307
|
+
|
|
308
|
+
if lower in _INVALID_VALUES:
|
|
309
|
+
return None
|
|
310
|
+
if lower.startswith("to be filled"):
|
|
311
|
+
return None
|
|
312
|
+
if lower.startswith("0123456789"):
|
|
313
|
+
return None
|
|
314
|
+
compact = lower.replace("-", "").replace(":", "").replace(" ", "")
|
|
315
|
+
if compact == "03000200040005000006000700080009":
|
|
316
|
+
return None
|
|
317
|
+
if len(compact) <= 3:
|
|
318
|
+
return None
|
|
319
|
+
if all(c == "0" for c in compact):
|
|
320
|
+
return None
|
|
321
|
+
if all(c == "f" for c in compact):
|
|
322
|
+
return None
|
|
323
|
+
if all(c == "1" for c in compact):
|
|
324
|
+
return None
|
|
325
|
+
if all(c == "x" for c in compact):
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
return lower
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _run_command(*args: str) -> str | None:
|
|
332
|
+
try:
|
|
333
|
+
result = subprocess.run(
|
|
334
|
+
args,
|
|
335
|
+
capture_output=True,
|
|
336
|
+
text=True,
|
|
337
|
+
timeout=_PROCESS_TIMEOUT,
|
|
338
|
+
)
|
|
339
|
+
if result.returncode != 0:
|
|
340
|
+
return None
|
|
341
|
+
return result.stdout
|
|
342
|
+
except Exception:
|
|
343
|
+
return None
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _read_text_file(path: str) -> str | None:
|
|
347
|
+
try:
|
|
348
|
+
with open(path) as f:
|
|
349
|
+
return _normalize_identifier(f.read())
|
|
350
|
+
except Exception:
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _read_binary_text_file(path: str) -> str | None:
|
|
355
|
+
try:
|
|
356
|
+
with open(path, "rb") as f:
|
|
357
|
+
data = f.read()
|
|
358
|
+
length = data.find(b"\x00")
|
|
359
|
+
if length < 0:
|
|
360
|
+
length = len(data)
|
|
361
|
+
return _normalize_identifier(data[:length].decode("utf-8", errors="replace"))
|
|
362
|
+
except Exception:
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _read_raw_text_file(path: str) -> str | None:
|
|
367
|
+
try:
|
|
368
|
+
with open(path) as f:
|
|
369
|
+
return f.read()
|
|
370
|
+
except Exception:
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _is_all_zeros(data: bytes) -> bool:
|
|
375
|
+
return all(b == 0 for b in data)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _is_all_ones(data: bytes) -> bool:
|
|
379
|
+
return all(b == 0xFF for b in data)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _sha256_hex(value: str) -> str:
|
|
383
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
_CHECKIP_URL = "https://checkip.amazonaws.com"
|
|
6
|
+
_TIMEOUT = 5.0
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_public_ip() -> str | None:
|
|
10
|
+
try:
|
|
11
|
+
response = httpx.get(_CHECKIP_URL, timeout=_TIMEOUT)
|
|
12
|
+
if response.status_code != 200:
|
|
13
|
+
return None
|
|
14
|
+
ip = response.text.strip()
|
|
15
|
+
return ip if ip else None
|
|
16
|
+
except Exception:
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def async_get_public_ip() -> str | None:
|
|
21
|
+
try:
|
|
22
|
+
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
|
23
|
+
response = await client.get(_CHECKIP_URL)
|
|
24
|
+
if response.status_code != 200:
|
|
25
|
+
return None
|
|
26
|
+
ip = response.text.strip()
|
|
27
|
+
return ip if ip else None
|
|
28
|
+
except Exception:
|
|
29
|
+
return None
|