altifyhwid 1.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.
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: altifyhwid
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Windows hardware identity matching across three or more hardware categories
|
|
5
|
+
Author: Altify
|
|
6
|
+
Keywords: hardware,hwid,windows,device,identity
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
11
|
+
Classifier: Topic :: System :: Hardware
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# Altify HWID
|
|
16
|
+
|
|
17
|
+
Recognize a Windows device using three or more matching hardware categories.
|
|
18
|
+
Python 3.10 or newer. No third-party runtime dependencies.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
After the maintainer publishes this project to PyPI:
|
|
23
|
+
|
|
24
|
+
```shell
|
|
25
|
+
python -m pip install altifyhwid
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The PyPI distribution name is `altifyhwid`; the Python import is `altifyhwid`.
|
|
29
|
+
|
|
30
|
+
## Use
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import altifyhwid as hwid
|
|
34
|
+
|
|
35
|
+
device_id = hwid.get_id()
|
|
36
|
+
print(device_id)
|
|
37
|
+
|
|
38
|
+
result = hwid.identify()
|
|
39
|
+
print(result.to_dict())
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Hardware collection is cached for 60 seconds per process. Database matching
|
|
43
|
+
still runs on each call. When both the ID and details are needed, call
|
|
44
|
+
`identify()` once and read `result.device_id`. Use `get_id(cache_ttl=0)` for a
|
|
45
|
+
fresh scan or `clear_cache()` to discard cached snapshots.
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from altifyhwid import hwid
|
|
49
|
+
|
|
50
|
+
print(hwid())
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Default database on Windows:
|
|
54
|
+
`%LOCALAPPDATA%\Altify\HWID\devices.sqlite3`.
|
|
55
|
+
Preserve this database to preserve device IDs. Override it with `db_path` or
|
|
56
|
+
the `ALTIFY_HWID_DB` environment variable. Explicit `db_path` takes priority.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
import altifyhwid
|
|
60
|
+
|
|
61
|
+
# Reuse the registry created by the earlier standalone script:
|
|
62
|
+
device_id = altifyhwid.get_id(
|
|
63
|
+
db_path=r"C:\MyApp\settings\AltifyHWID\devices.sqlite3"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Match only, never write (no enrollment, no refresh, no last_seen update):
|
|
67
|
+
device_id = altifyhwid.get_id(read_only=True, create=False)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`identify()` accepts `create=False` (match only), `refresh=False` (do not
|
|
71
|
+
store newly seen hardware on a match), and `read_only=True` (open the
|
|
72
|
+
database read-only; implies both). `IdentityResult.stored_kinds` lists the
|
|
73
|
+
identifier kinds written to the registry by that call.
|
|
74
|
+
|
|
75
|
+
## Command line
|
|
76
|
+
|
|
77
|
+
```shell
|
|
78
|
+
python -m altifyhwid --json
|
|
79
|
+
python -m altifyhwid scan
|
|
80
|
+
python -m altifyhwid scan --raw
|
|
81
|
+
python -m altifyhwid --no-tpm --no-monitors
|
|
82
|
+
python -m altifyhwid list
|
|
83
|
+
python -m altifyhwid merge KEEP_ID ABSORB_ID
|
|
84
|
+
python -m altifyhwid delete DEVICE_ID
|
|
85
|
+
python -m altifyhwid -v
|
|
86
|
+
altifyhwid --json
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`scan` reports categories, counts, and collection warnings without saving an
|
|
90
|
+
identity. `--raw` also prints hardware identifiers and diagnostic context.
|
|
91
|
+
Programmatic diagnostic context is available with
|
|
92
|
+
`collect(include_diagnostics=True)`. TPM is queried by default, but a missing
|
|
93
|
+
or inaccessible endorsement public key contributes no match evidence.
|
|
94
|
+
`--no-refresh`, `--no-create`, and `--read-only` mirror the API flags.
|
|
95
|
+
`-v` logs collector timings, PowerShell exit codes with a stderr tail, and
|
|
96
|
+
registry activity to stderr. Exit code 0 is success, 2 is a reported error
|
|
97
|
+
(JSON on stderr), 1 is an unexpected error. Console entry point
|
|
98
|
+
`altifyhwid` requires the Scripts directory of the environment on PATH; the
|
|
99
|
+
`python -m altifyhwid` form works without that extra PATH requirement.
|
|
100
|
+
|
|
101
|
+
## Matching
|
|
102
|
+
|
|
103
|
+
The categories are firmware, storage, RAM, network, display, TPM public key,
|
|
104
|
+
and CPU serial where available. Related SMBIOS fields together count as one
|
|
105
|
+
firmware vote. Multiple disks or RAM modules do not multiply category votes.
|
|
106
|
+
CPU/GPU models and ordinary CPU ProcessorId values do not count as unique IDs.
|
|
107
|
+
|
|
108
|
+
A new enrollment gets a random UUID. Later scans reuse it when at least three
|
|
109
|
+
categories match that enrollment. Use `min_matches=4` or higher to require
|
|
110
|
+
more evidence.
|
|
111
|
+
|
|
112
|
+
On a match the registry also stores identifiers it has not seen before for
|
|
113
|
+
that device (a replaced disk, new RAM, a new network adapter), so gradual
|
|
114
|
+
hardware changes keep the same ID as long as each scan still matches three
|
|
115
|
+
categories. Old identifiers are kept, so a reverted swap still matches. Each
|
|
116
|
+
device keeps at most 128 identifiers per kind; beyond that the oldest ones not
|
|
117
|
+
present in the current scan are dropped. Pass `refresh=False` to disable this.
|
|
118
|
+
|
|
119
|
+
Insufficient usable categories raise `InsufficientHardwareError`. `create=False`
|
|
120
|
+
requires an existing match and otherwise raises `NoMatchError`.
|
|
121
|
+
|
|
122
|
+
Multiple qualifying records raise `AmbiguousMatchError`; `.candidates` lists
|
|
123
|
+
the two strongest, best first, each with `match_count`. Resolve it once with
|
|
124
|
+
the registry tools below (usually `merge` the weaker into the stronger).
|
|
125
|
+
|
|
126
|
+
## Registry maintenance
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from altifyhwid import Registry
|
|
130
|
+
|
|
131
|
+
registry = Registry() # same defaults as identify()
|
|
132
|
+
registry.list_devices() # dicts with device_id, created_at,
|
|
133
|
+
# last_seen, token_count, groups, kinds
|
|
134
|
+
registry.list_devices(all_namespaces=True)
|
|
135
|
+
registry.merge("ALTIFY-KEEP...", "ALTIFY-DUPLICATE...")
|
|
136
|
+
registry.delete("ALTIFY-OLD...")
|
|
137
|
+
registry.backup(r"D:\backups\devices.sqlite3")
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`merge` moves the identifiers of the absorbed enrollments onto the kept one and
|
|
141
|
+
removes the absorbed records. Both must be in the namespace of the registry.
|
|
142
|
+
`delete` and `merge` refuse a `read_only=True` registry.
|
|
143
|
+
|
|
144
|
+
## Integrity, backups, and schema
|
|
145
|
+
|
|
146
|
+
Each registry path is checked with `PRAGMA quick_check` once per process. A
|
|
147
|
+
corrupt or non-SQLite file raises `RegistryError` naming the newest backup.
|
|
148
|
+
|
|
149
|
+
After any change to enrollments (a new device, stored identifiers, merge,
|
|
150
|
+
delete) the registry writes `devices.sqlite3.bak1` next to the database, at
|
|
151
|
+
most once per `backup_interval` seconds (default one day), and rotates older
|
|
152
|
+
copies to `.bak2` and so on up to `backup_copies` (default 2; 0 disables).
|
|
153
|
+
Backup failures are logged and never fail the identity call. To restore, stop
|
|
154
|
+
the application and copy `.bak1` over `devices.sqlite3`.
|
|
155
|
+
|
|
156
|
+
The database schema is version 2. A schema 1 registry (created by 1.0.x) is
|
|
157
|
+
migrated automatically on the first writable open; a `read_only=True` open
|
|
158
|
+
refuses it until then. A registry written by a newer module version is refused
|
|
159
|
+
rather than modified.
|
|
160
|
+
|
|
161
|
+
Only HMAC tokens are persisted, but the key is in the same database: this is
|
|
162
|
+
pseudonymization, not encryption. No data is uploaded by this module. Collection
|
|
163
|
+
uses read-only Windows queries, bounded worker threads, and timeouts. TPM and
|
|
164
|
+
some provider queries can be unavailable under ordinary user permissions.
|
|
165
|
+
|
|
166
|
+
## Logging
|
|
167
|
+
|
|
168
|
+
The module logs to `logging.getLogger("altifyhwid")` and stays silent unless
|
|
169
|
+
the application configures logging. `WARNING` covers failed collector batches
|
|
170
|
+
(exit code, duration, last 2 KiB of PowerShell stderr), ambiguous matches,
|
|
171
|
+
rollback problems, and failed automatic backups. `INFO` covers enrollments,
|
|
172
|
+
migrations, backups, and scan summaries. `DEBUG` covers per-batch timings and
|
|
173
|
+
match details. Hardware identifiers are never logged.
|
|
174
|
+
|
|
175
|
+
## Scope
|
|
176
|
+
|
|
177
|
+
Hardware collection requires Windows. Registry matching can run elsewhere with
|
|
178
|
+
an explicitly supplied `HardwareSnapshot`. Separate registry databases produce
|
|
179
|
+
separate IDs; installing this package does not create a shared identity service.
|
|
180
|
+
For multiple clients, use a trusted central registry through your own
|
|
181
|
+
authenticated transport. Client-supplied hardware values are not attestation.
|
|
182
|
+
|
|
183
|
+
This is heuristic recognition, not a guarantee of uniqueness or resistance to
|
|
184
|
+
spoofing. Shared components, OEM duplicates, cloned VMs, and extensive hardware
|
|
185
|
+
changes can produce incorrect matches or new IDs. Storing newly seen hardware
|
|
186
|
+
on a match also means a wrong match teaches the wrong record; raise
|
|
187
|
+
`min_matches` or set `refresh=False` where that matters. Proprietary kernel
|
|
188
|
+
anti-cheat internals are not implemented.
|
|
189
|
+
|
|
190
|
+
## Resource limits
|
|
191
|
+
|
|
192
|
+
Collection uses at most four concurrent workers and an overall deadline. One
|
|
193
|
+
scan runs at a time per process; a caller waits up to `timeout` seconds for an
|
|
194
|
+
active scan, then gets the cached result when the same options were scanned,
|
|
195
|
+
or runs its own scan with a fresh deadline. Cache reads and `clear_cache()`
|
|
196
|
+
never wait behind a scan. The scan cache holds at most eight immutable
|
|
197
|
+
snapshots. No workers run between calls. Provider results are limited to 128
|
|
198
|
+
rows and Python reads at most 2 MiB of output per collector batch. SQLite
|
|
199
|
+
connections and temporary output files close when each operation completes.
|
|
200
|
+
|
|
201
|
+
The registry uses SQLite WAL mode, so readers never block the single writer.
|
|
202
|
+
Opening an existing registry takes no write lock; only enrollment, refresh,
|
|
203
|
+
merge, delete, and migration do. Backup has a deadline and removes incomplete
|
|
204
|
+
output on failure. Matching uses an index and retrieves at most two qualifying
|
|
205
|
+
records to detect ambiguity.
|
|
206
|
+
|
|
207
|
+
## Tests
|
|
208
|
+
|
|
209
|
+
```shell
|
|
210
|
+
py -m unittest discover -s tests -v
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The suite covers normalization, snapshot building from recorded provider
|
|
214
|
+
output, enrollment and matching, refresh and its cap, ambiguity and merge,
|
|
215
|
+
read-only mode, schema migration, integrity failures, backups and rotation,
|
|
216
|
+
collector caching and locking, PowerShell failure handling, and the command
|
|
217
|
+
line. It runs without hardware access. `ALTIFY_LIVE=1` adds one live
|
|
218
|
+
collection on the current Windows PC.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
altifyhwid.py,sha256=qIae8omdkuD84AjPqoimexltL7Ot26Ud60d609tWqrs,44952
|
|
2
|
+
altifyhwid-1.1.0.dist-info/METADATA,sha256=zGcW_dzj-2ApAl-uwB6LpktVXyjmcIRSpgNGpO15fjQ,9408
|
|
3
|
+
altifyhwid-1.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
4
|
+
altifyhwid-1.1.0.dist-info/entry_points.txt,sha256=sPSjY8P4GoX_fmbcavqxLS_SiR-OEP4F7w5ScVCGTPk,48
|
|
5
|
+
altifyhwid-1.1.0.dist-info/top_level.txt,sha256=oWe5IZM2QrUrqlmQIMoxigdpC4RjzZneaWWP8x24bDs,11
|
|
6
|
+
altifyhwid-1.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
altifyhwid
|
altifyhwid.py
ADDED
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import argparse
|
|
3
|
+
import base64
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
5
|
+
from collections import OrderedDict
|
|
6
|
+
from contextlib import closing, contextmanager
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
import hmac
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import math
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import re
|
|
16
|
+
import secrets
|
|
17
|
+
import sqlite3
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
from threading import Lock
|
|
22
|
+
from time import monotonic, time
|
|
23
|
+
from types import MappingProxyType
|
|
24
|
+
from typing import Any, Mapping
|
|
25
|
+
import unicodedata
|
|
26
|
+
import uuid
|
|
27
|
+
__version__ = '1.1.0'
|
|
28
|
+
__all__ = ['get_id', 'hwid', 'identify', 'collect', 'clear_cache', 'Registry', 'HardwareSnapshot', 'IdentityResult', 'AltifyError', 'CollectionError', 'RegistryError', 'InsufficientHardwareError', 'AmbiguousMatchError', 'NoMatchError']
|
|
29
|
+
_LOG = logging.getLogger('altifyhwid')
|
|
30
|
+
_LOG.addHandler(logging.NullHandler())
|
|
31
|
+
_KINDS = {'system_uuid': 'firmware', 'system_serial': 'firmware', 'baseboard_serial': 'firmware', 'bios_serial': 'firmware', 'chassis_serial': 'firmware', 'disk_serial': 'storage', 'disk_unique_id': 'storage', 'ram_serial': 'memory', 'nic_permanent_mac': 'network', 'nic_mac': 'network', 'tpm_ek_sha256': 'tpm', 'monitor_serial': 'display', 'cpu_serial': 'cpu'}
|
|
32
|
+
_PLACEHOLDERS = {'UNKNOWN', 'NONE', 'NULL', 'NA', 'NOTAPPLICABLE', 'NOTAVAILABLE', 'NOTSPECIFIED', 'UNSPECIFIED', 'INVALID', 'DEFAULT', 'DEFAULTSTRING', 'SYSTEMSERIALNUMBER', 'SERIALNUMBER', 'CHASSISSERIALNUMBER', 'BASEBOARDSERIALNUMBER', 'OEM', '0123456789', '123456789', '1234567890', '0123456789ABCDEF', '12345678', 'SYSTEMUUID', 'NOASSETTAG'}
|
|
33
|
+
_SCHEMA = 2
|
|
34
|
+
_MAX_OUTPUT = 2 * 1024 * 1024
|
|
35
|
+
_STDERR_TAIL = 2048
|
|
36
|
+
_TOKEN_CAP = 128
|
|
37
|
+
_CACHE = OrderedDict()
|
|
38
|
+
_CACHE_LOCK = Lock()
|
|
39
|
+
_SCAN_LOCK = Lock()
|
|
40
|
+
_CHECKED = OrderedDict()
|
|
41
|
+
_CHECKED_LOCK = Lock()
|
|
42
|
+
_GROUP_SQL = 'CASE t.kind ' + ' '.join((f"WHEN '{kind}' THEN '{group}'" for kind, group in _KINDS.items())) + ' END'
|
|
43
|
+
_MATCH_SQL = 'SELECT t.device_id,GROUP_CONCAT(DISTINCT t.kind),COUNT(DISTINCT ' + _GROUP_SQL + ') AS n FROM incoming i CROSS JOIN tokens t INDEXED BY tokens_lookup JOIN devices d ON d.device_id=t.device_id WHERE t.kind=i.kind AND t.digest=i.digest AND d.namespace=? GROUP BY t.device_id HAVING n>=? ORDER BY n DESC,t.device_id LIMIT 2'
|
|
44
|
+
_LIST_SQL = 'SELECT d.device_id,d.namespace,d.created_at,d.last_seen,COUNT(t.digest),GROUP_CONCAT(DISTINCT t.kind) FROM devices d LEFT JOIN tokens t ON t.device_id=d.device_id {where} GROUP BY d.device_id ORDER BY d.created_at,d.rowid'
|
|
45
|
+
class AltifyError(RuntimeError):
|
|
46
|
+
pass
|
|
47
|
+
class CollectionError(AltifyError):
|
|
48
|
+
pass
|
|
49
|
+
class RegistryError(AltifyError):
|
|
50
|
+
pass
|
|
51
|
+
class NoMatchError(AltifyError):
|
|
52
|
+
pass
|
|
53
|
+
class InsufficientHardwareError(AltifyError):
|
|
54
|
+
def __init__(self, groups: tuple[str, ...], minimum: int, warnings=()):
|
|
55
|
+
self.groups, self.minimum, self.warnings = (groups, minimum, tuple(warnings))
|
|
56
|
+
super().__init__(f"Need {minimum} hardware groups; found {len(groups)} ({', '.join(groups) or 'none'}). Run: py -m altifyhwid scan")
|
|
57
|
+
class AmbiguousMatchError(AltifyError):
|
|
58
|
+
def __init__(self, candidates: tuple[dict[str, Any], ...]):
|
|
59
|
+
self.candidates = candidates
|
|
60
|
+
super().__init__('Multiple records meet the threshold. No record selected; .candidates lists the two strongest. Run: py -m altifyhwid list, then merge or delete the duplicates')
|
|
61
|
+
def _default_registry_path() -> Path:
|
|
62
|
+
override = os.environ.get('ALTIFY_HWID_DB')
|
|
63
|
+
if override:
|
|
64
|
+
return Path(override).expanduser()
|
|
65
|
+
if sys.platform == 'win32':
|
|
66
|
+
base = Path(os.environ.get('LOCALAPPDATA') or Path.home() / 'AppData' / 'Local')
|
|
67
|
+
elif sys.platform == 'darwin':
|
|
68
|
+
base = Path.home() / 'Library' / 'Application Support'
|
|
69
|
+
else:
|
|
70
|
+
base = Path(os.environ.get('XDG_DATA_HOME') or Path.home() / '.local' / 'share')
|
|
71
|
+
return base / 'Altify' / 'HWID' / 'devices.sqlite3'
|
|
72
|
+
def _now() -> str:
|
|
73
|
+
return datetime.now(timezone.utc).isoformat(timespec='seconds')
|
|
74
|
+
def _normalize(kind: str, value: Any) -> str | None:
|
|
75
|
+
if not isinstance(value, str) or len(value) > 1024:
|
|
76
|
+
return None
|
|
77
|
+
value = re.sub('\\s+', '', unicodedata.normalize('NFKC', value).upper().strip('\x00 '))
|
|
78
|
+
compact = re.sub('[^A-Z0-9]', '', value)
|
|
79
|
+
if len(compact) < 4 or compact in _PLACEHOLDERS or len(set(compact)) == 1 or any((s in compact for s in ('TOBEFILLEDBY', 'NOTSPECIFIED', 'DEFAULTSTRING'))):
|
|
80
|
+
return None
|
|
81
|
+
if kind == 'system_uuid':
|
|
82
|
+
try:
|
|
83
|
+
parsed = uuid.UUID(value)
|
|
84
|
+
except ValueError:
|
|
85
|
+
return None
|
|
86
|
+
if parsed.int in (0, (1 << 128) - 1):
|
|
87
|
+
return None
|
|
88
|
+
return str(parsed).upper()
|
|
89
|
+
if kind in ('nic_mac', 'nic_permanent_mac'):
|
|
90
|
+
mac = re.sub('[:-]', '', value)
|
|
91
|
+
if not re.fullmatch('[0-9A-F]{12}', mac) or int(mac[:2], 16) & 3:
|
|
92
|
+
return None
|
|
93
|
+
return mac
|
|
94
|
+
if kind == 'tpm_ek_sha256':
|
|
95
|
+
value = re.sub('[:-]', '', value)
|
|
96
|
+
if not re.fullmatch('[0-9A-F]{64}', value):
|
|
97
|
+
return None
|
|
98
|
+
return value
|
|
99
|
+
def _freeze(value):
|
|
100
|
+
if isinstance(value, Mapping):
|
|
101
|
+
return MappingProxyType({k: _freeze(v) for k, v in value.items()})
|
|
102
|
+
if isinstance(value, (list, tuple)):
|
|
103
|
+
return tuple(map(_freeze, value))
|
|
104
|
+
return value
|
|
105
|
+
def _plain(value):
|
|
106
|
+
if isinstance(value, Mapping):
|
|
107
|
+
return {k: _plain(v) for k, v in value.items()}
|
|
108
|
+
if isinstance(value, tuple):
|
|
109
|
+
return list(map(_plain, value))
|
|
110
|
+
return value
|
|
111
|
+
@dataclass(frozen=True, slots=True)
|
|
112
|
+
class HardwareSnapshot:
|
|
113
|
+
signals: Mapping[str, tuple[str, ...]]
|
|
114
|
+
diagnostics: Mapping[str, Any] = field(default_factory=dict)
|
|
115
|
+
warnings: tuple[str, ...] = ()
|
|
116
|
+
collected_at: str = field(default_factory=_now)
|
|
117
|
+
_groups: tuple[str, ...] = field(init=False, repr=False)
|
|
118
|
+
def __post_init__(self):
|
|
119
|
+
if not isinstance(self.signals, Mapping) or set(self.signals) - _KINDS.keys():
|
|
120
|
+
raise ValueError('signals must contain supported identifier kinds')
|
|
121
|
+
cleaned, total = ({}, 0)
|
|
122
|
+
for kind, values in self.signals.items():
|
|
123
|
+
if isinstance(values, str):
|
|
124
|
+
values = (values,)
|
|
125
|
+
if not isinstance(values, (tuple, list, set)) or len(values) > 128:
|
|
126
|
+
raise ValueError(f'{kind}: supply at most 128 strings')
|
|
127
|
+
total += len(values)
|
|
128
|
+
if total > 512 or any((not isinstance(v, str) for v in values)):
|
|
129
|
+
raise ValueError('Snapshot permits at most 512 string identifiers')
|
|
130
|
+
values = tuple(sorted({v for raw in values if (v := _normalize(kind, raw))}))
|
|
131
|
+
if values:
|
|
132
|
+
cleaned[kind] = values
|
|
133
|
+
object.__setattr__(self, 'signals', MappingProxyType(cleaned))
|
|
134
|
+
object.__setattr__(self, 'diagnostics', _freeze(self.diagnostics))
|
|
135
|
+
object.__setattr__(self, 'warnings', tuple(self.warnings))
|
|
136
|
+
object.__setattr__(self, '_groups', tuple(sorted({_KINDS[k] for k in cleaned})))
|
|
137
|
+
@classmethod
|
|
138
|
+
def from_signals(cls, signals: Mapping[str, Any], **kwargs) -> HardwareSnapshot:
|
|
139
|
+
return cls(signals, **kwargs)
|
|
140
|
+
@classmethod
|
|
141
|
+
def from_dict(cls, payload: Mapping[str, Any]) -> HardwareSnapshot:
|
|
142
|
+
if not isinstance(payload, Mapping) or payload.get('format') != 'altify-hwid-v1':
|
|
143
|
+
raise ValueError('Expected altify-hwid-v1 snapshot')
|
|
144
|
+
if payload.get('redacted') is not False or 'signals' not in payload:
|
|
145
|
+
raise ValueError('Snapshot is redacted; export with to_dict(raw=True)')
|
|
146
|
+
return cls(payload['signals'])
|
|
147
|
+
@property
|
|
148
|
+
def groups(self) -> tuple[str, ...]:
|
|
149
|
+
return self._groups
|
|
150
|
+
def to_dict(self, *, raw: bool=False) -> dict[str, Any]:
|
|
151
|
+
result = {'format': 'altify-hwid-v1', 'redacted': not raw, 'collected_at': self.collected_at, 'groups': list(self.groups), 'counts': {k: len(v) for k, v in self.signals.items()}, 'warnings': list(self.warnings)}
|
|
152
|
+
if raw:
|
|
153
|
+
result['signals'] = {k: list(v) for k, v in self.signals.items()}
|
|
154
|
+
result['diagnostics'] = _plain(self.diagnostics)
|
|
155
|
+
return result
|
|
156
|
+
@dataclass(frozen=True, slots=True)
|
|
157
|
+
class IdentityResult:
|
|
158
|
+
device_id: str
|
|
159
|
+
created: bool
|
|
160
|
+
matched_groups: tuple[str, ...]
|
|
161
|
+
matched_kinds: tuple[str, ...]
|
|
162
|
+
available_groups: tuple[str, ...]
|
|
163
|
+
warnings: tuple[str, ...]
|
|
164
|
+
stored_kinds: tuple[str, ...] = ()
|
|
165
|
+
@property
|
|
166
|
+
def match_count(self) -> int:
|
|
167
|
+
return len(self.matched_groups)
|
|
168
|
+
def to_dict(self) -> dict[str, Any]:
|
|
169
|
+
return {'device_id': self.device_id, 'created': self.created, 'match_count': self.match_count, 'matched_groups': list(self.matched_groups), 'matched_kinds': list(self.matched_kinds), 'available_groups': list(self.available_groups), 'stored_kinds': list(self.stored_kinds), 'warnings': list(self.warnings)}
|
|
170
|
+
_PS_HEADER = '\n$ErrorActionPreference = \'Stop\'\n$ProgressPreference = \'SilentlyContinue\'\n$WarningPreference = \'SilentlyContinue\'\n[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)\n$OutputEncoding = [Console]::OutputEncoding\n$out = [ordered]@{data=@{}; errors=@()}\nfunction Read-Cim($key, $class, $properties, $namespace=\'root/cimv2\') {\n try {\n $rows = @(Get-CimInstance -ClassName $class -Namespace $namespace `\n -Property $properties -OperationTimeoutSec 5 -ErrorAction Stop |\n Select-Object -First 129 -Property $properties)\n if ($rows.Count -gt 128) { throw \'Provider row limit exceeded\' }\n $out.data[$key] = $rows\n } catch {\n $rows = @()\n $out.errors += "$key unavailable ($($_.Exception.GetType().Name))"\n }\n}\n'
|
|
171
|
+
_BATCHES = {'firmware': "\nRead-Cim 'system' 'Win32_ComputerSystemProduct' @('UUID','IdentifyingNumber','Vendor','Name')\nRead-Cim 'board' 'Win32_BaseBoard' @('SerialNumber','Manufacturer','Product')\nRead-Cim 'bios' 'Win32_BIOS' @('SerialNumber','Manufacturer','SMBIOSBIOSVersion')\nRead-Cim 'chassis' 'Win32_SystemEnclosure' @('SerialNumber')\nRead-Cim 'cpu' 'Win32_Processor' @('SerialNumber','ProcessorId','Name','Manufacturer')\n", 'storage': "\nRead-Cim 'physical_disks' 'MSFT_PhysicalDisk' @('SerialNumber','UniqueId','UniqueIdFormat','BusType','FriendlyName') 'root/Microsoft/Windows/Storage'\nRead-Cim 'disks' 'Win32_DiskDrive' @('SerialNumber','InterfaceType','Model','PNPDeviceID')\n", 'memory_network': "\nRead-Cim 'memory' 'Win32_PhysicalMemory' @('SerialNumber','Manufacturer','PartNumber','Capacity')\nRead-Cim 'adapters' 'MSFT_NetAdapter' @('PermanentAddress','HardwareInterface','Virtual','InterfaceDescription','PNPDeviceID') 'root/StandardCimv2'\nRead-Cim 'legacy_adapters' 'Win32_NetworkAdapter' @('MACAddress','PhysicalAdapter','Name','PNPDeviceID')\n", 'context': "\nRead-Cim 'gpu' 'Win32_VideoController' @('Name','PNPDeviceID','DriverVersion')\ntry {\n $out.data['windows'] = @{MachineGuid=(Get-ItemProperty -LiteralPath 'HKLM:\\SOFTWARE\\Microsoft\\Cryptography' -Name MachineGuid -ErrorAction Stop).MachineGuid}\n} catch { $out.errors += 'Windows installation context unavailable' }\n", 'monitors': "\nRead-Cim 'monitors' 'WmiMonitorID' @('ManufacturerName','ProductCodeID','SerialNumberID','Active') 'root/wmi'\n", 'tpm': '\ntry {\n $ek = Get-TpmEndorsementKeyInfo -HashAlgorithm Sha256 -ErrorAction Stop\n if ($ek.IsPresent -and $ek.PublicKeyHash) {\n $out.data[\'tpm\'] = @{PublicKeyHash=[string]$ek.PublicKeyHash}\n } else { $out.errors += \'TPM endorsement public key unavailable\' }\n} catch { $out.errors += "TPM public key unavailable ($($_.Exception.GetType().Name)); try an elevated host if needed" }\n'}
|
|
172
|
+
def _powershell_exe() -> str:
|
|
173
|
+
root = Path(os.environ.get('SystemRoot', 'C:\\Windows'))
|
|
174
|
+
for system in ('Sysnative', 'System32'):
|
|
175
|
+
exe = root / system / 'WindowsPowerShell' / 'v1.0' / 'powershell.exe'
|
|
176
|
+
if exe.is_file():
|
|
177
|
+
return str(exe)
|
|
178
|
+
raise CollectionError('Windows PowerShell was not found in the Windows directory')
|
|
179
|
+
def _tail(handle) -> str:
|
|
180
|
+
handle.seek(0, os.SEEK_END)
|
|
181
|
+
handle.seek(max(0, handle.tell() - _STDERR_TAIL))
|
|
182
|
+
return handle.read().decode('utf-8', 'replace').strip()
|
|
183
|
+
def _run_batch(exe: str, name: str, timeout: float) -> dict[str, Any]:
|
|
184
|
+
if timeout <= 0:
|
|
185
|
+
_LOG.warning('%s: collection deadline reached before the batch started', name)
|
|
186
|
+
return {'errors': [f'{name}: collection deadline reached']}
|
|
187
|
+
script = _PS_HEADER + _BATCHES[name] + '\n$out | ConvertTo-Json -Depth 8 -Compress\n'
|
|
188
|
+
encoded = base64.b64encode(script.encode('utf-16-le')).decode('ascii')
|
|
189
|
+
started = monotonic()
|
|
190
|
+
try:
|
|
191
|
+
with tempfile.TemporaryFile() as output, tempfile.TemporaryFile() as errors:
|
|
192
|
+
proc = subprocess.run([exe, '-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], stdin=subprocess.DEVNULL, stdout=output, stderr=errors, timeout=timeout, check=False, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
|
|
193
|
+
elapsed = monotonic() - started
|
|
194
|
+
if proc.returncode:
|
|
195
|
+
_LOG.warning('%s: PowerShell exited with code %d after %.1fs: %s', name, proc.returncode, elapsed, _tail(errors) or 'no stderr')
|
|
196
|
+
return {'errors': [f'{name}: PowerShell exited with code {proc.returncode}']}
|
|
197
|
+
output.seek(0, os.SEEK_END)
|
|
198
|
+
if output.tell() > _MAX_OUTPUT:
|
|
199
|
+
raise ValueError('Collector output limit exceeded')
|
|
200
|
+
output.seek(0)
|
|
201
|
+
raw = output.read(_MAX_OUTPUT + 1)
|
|
202
|
+
if len(raw) > _MAX_OUTPUT:
|
|
203
|
+
raise ValueError('Collector output limit exceeded')
|
|
204
|
+
result = json.loads(raw.decode('utf-8-sig'))
|
|
205
|
+
if not isinstance(result, dict) or not isinstance(result.get('data'), dict):
|
|
206
|
+
raise ValueError('Invalid collector response')
|
|
207
|
+
_LOG.debug('%s: collected %d bytes in %.1fs with %d provider errors', name, len(raw), elapsed, len(result.get('errors') or []))
|
|
208
|
+
return result
|
|
209
|
+
except subprocess.TimeoutExpired:
|
|
210
|
+
_LOG.warning('%s: collection timed out after %.1fs', name, timeout)
|
|
211
|
+
return {'errors': [f'{name}: collection timed out']}
|
|
212
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
213
|
+
_LOG.warning('%s: collection failed after %.1fs (%s: %s)', name, monotonic() - started, type(exc).__name__, exc)
|
|
214
|
+
return {'errors': [f'{name}: collection failed ({type(exc).__name__})']}
|
|
215
|
+
def _rows(data: Mapping[str, Any], key: str) -> list[dict[str, Any]]:
|
|
216
|
+
value = data.get(key, [])
|
|
217
|
+
return [value] if isinstance(value, dict) else [r for r in value or [] if isinstance(r, dict)]
|
|
218
|
+
def _edid_text(value: Any) -> str:
|
|
219
|
+
if isinstance(value, str):
|
|
220
|
+
return value.strip('\x00 ')
|
|
221
|
+
if not isinstance(value, list):
|
|
222
|
+
return ''
|
|
223
|
+
return ''.join((chr(n) for n in value if isinstance(n, int) and 32 <= n < 127)).strip()
|
|
224
|
+
def _looks_virtual(value: Any) -> bool:
|
|
225
|
+
text = str(value or '').upper()
|
|
226
|
+
return any((word in text for word in ('VIRTUAL', 'VMWARE', 'VBOX', 'VIRTIO', 'QEMU', 'XEN ', 'HYPER-V', 'TAP-WINDOWS', 'TUNNEL')))
|
|
227
|
+
def _snapshot_from_data(data: Mapping[str, Any], warnings=(), include_diagnostics=False) -> HardwareSnapshot:
|
|
228
|
+
signals: dict[str, list[str]] = {}
|
|
229
|
+
def add(kind, value):
|
|
230
|
+
normalized = _normalize(kind, value)
|
|
231
|
+
if normalized:
|
|
232
|
+
signals.setdefault(kind, []).append(normalized)
|
|
233
|
+
for section, prop, kind in (('system', 'UUID', 'system_uuid'), ('system', 'IdentifyingNumber', 'system_serial'), ('board', 'SerialNumber', 'baseboard_serial'), ('bios', 'SerialNumber', 'bios_serial'), ('chassis', 'SerialNumber', 'chassis_serial'), ('cpu', 'SerialNumber', 'cpu_serial'), ('memory', 'SerialNumber', 'ram_serial'), ('tpm', 'PublicKeyHash', 'tpm_ek_sha256')):
|
|
234
|
+
for row in _rows(data, section):
|
|
235
|
+
add(kind, row.get(prop))
|
|
236
|
+
for row in _rows(data, 'physical_disks'):
|
|
237
|
+
if row.get('BusType') not in (1, 2, 3, 4, 6, 8, 10, 11, 17, 18):
|
|
238
|
+
continue
|
|
239
|
+
if _looks_virtual(row.get('FriendlyName')):
|
|
240
|
+
continue
|
|
241
|
+
add('disk_serial', row.get('SerialNumber'))
|
|
242
|
+
if row.get('UniqueIdFormat') in (2, 3, 8):
|
|
243
|
+
add('disk_unique_id', row.get('UniqueId'))
|
|
244
|
+
for row in _rows(data, 'disks'):
|
|
245
|
+
pnp = str(row.get('PNPDeviceID') or '').upper()
|
|
246
|
+
if str(row.get('InterfaceType') or '').upper() == 'USB' or pnp.startswith('USB'):
|
|
247
|
+
continue
|
|
248
|
+
if not _looks_virtual(row.get('Model')):
|
|
249
|
+
add('disk_serial', row.get('SerialNumber'))
|
|
250
|
+
permanent_pnp = set()
|
|
251
|
+
for row in _rows(data, 'adapters'):
|
|
252
|
+
if row.get('HardwareInterface') is not True or row.get('Virtual') is True:
|
|
253
|
+
continue
|
|
254
|
+
if _looks_virtual(row.get('InterfaceDescription')):
|
|
255
|
+
continue
|
|
256
|
+
value = _normalize('nic_permanent_mac', row.get('PermanentAddress'))
|
|
257
|
+
if value:
|
|
258
|
+
add('nic_permanent_mac', value)
|
|
259
|
+
permanent_pnp.add(str(row.get('PNPDeviceID') or '').upper())
|
|
260
|
+
for row in _rows(data, 'legacy_adapters'):
|
|
261
|
+
pnp = str(row.get('PNPDeviceID') or '').upper()
|
|
262
|
+
if row.get('PhysicalAdapter') is not True or _looks_virtual(row.get('Name')) or (not pnp.startswith(('PCI\\', 'USB\\'))):
|
|
263
|
+
continue
|
|
264
|
+
if pnp not in permanent_pnp:
|
|
265
|
+
add('nic_permanent_mac', row.get('MACAddress'))
|
|
266
|
+
for row in _rows(data, 'monitors'):
|
|
267
|
+
if row.get('Active') is not True:
|
|
268
|
+
continue
|
|
269
|
+
serial = _edid_text(row.get('SerialNumberID'))
|
|
270
|
+
maker = _edid_text(row.get('ManufacturerName'))
|
|
271
|
+
model = _edid_text(row.get('ProductCodeID'))
|
|
272
|
+
if _normalize('monitor_serial', serial) and maker and model:
|
|
273
|
+
add('monitor_serial', f'{maker}|{model}|{serial}')
|
|
274
|
+
return HardwareSnapshot.from_signals(signals, diagnostics=data if include_diagnostics else {}, warnings=tuple(warnings))
|
|
275
|
+
def clear_cache() -> None:
|
|
276
|
+
with _CACHE_LOCK:
|
|
277
|
+
_CACHE.clear()
|
|
278
|
+
def _cache_get(key, cache_ttl: float) -> HardwareSnapshot | None:
|
|
279
|
+
with _CACHE_LOCK:
|
|
280
|
+
cached = _CACHE.get(key)
|
|
281
|
+
if cached and cache_ttl and monotonic() - cached[0] < cache_ttl:
|
|
282
|
+
_CACHE.move_to_end(key)
|
|
283
|
+
return cached[1]
|
|
284
|
+
_CACHE.pop(key, None)
|
|
285
|
+
return None
|
|
286
|
+
def collect(*, include_tpm: bool=True, include_monitors: bool=True, include_diagnostics: bool=False, timeout: float=35.0, cache_ttl: float=60.0) -> HardwareSnapshot:
|
|
287
|
+
if sys.platform != 'win32':
|
|
288
|
+
raise CollectionError('Live hardware collection requires Windows 10/11. Registry works cross-platform.')
|
|
289
|
+
if type(timeout) not in (float, int) or not math.isfinite(timeout) or (not 1 <= timeout <= 300):
|
|
290
|
+
raise ValueError('timeout must be between 1 and 300 seconds')
|
|
291
|
+
if type(cache_ttl) not in (float, int) or not math.isfinite(cache_ttl) or (not 0 <= cache_ttl <= 3600):
|
|
292
|
+
raise ValueError('cache_ttl must be between 0 and 3600 seconds')
|
|
293
|
+
key = (bool(include_tpm), bool(include_monitors), bool(include_diagnostics))
|
|
294
|
+
cached = _cache_get(key, cache_ttl)
|
|
295
|
+
if cached is not None:
|
|
296
|
+
return cached
|
|
297
|
+
if not _SCAN_LOCK.acquire(timeout=timeout):
|
|
298
|
+
raise CollectionError('Timed out waiting for an active hardware scan')
|
|
299
|
+
try:
|
|
300
|
+
cached = _cache_get(key, cache_ttl)
|
|
301
|
+
if cached is not None:
|
|
302
|
+
return cached
|
|
303
|
+
exe = _powershell_exe()
|
|
304
|
+
names = ['firmware', 'storage', 'memory_network']
|
|
305
|
+
if include_diagnostics:
|
|
306
|
+
names.append('context')
|
|
307
|
+
if include_monitors:
|
|
308
|
+
names.append('monitors')
|
|
309
|
+
if include_tpm:
|
|
310
|
+
names.append('tpm')
|
|
311
|
+
deadline = monotonic() + timeout
|
|
312
|
+
_LOG.debug('Scanning batches %s with a %.0fs deadline', ','.join(names), timeout)
|
|
313
|
+
data, warnings = ({}, [])
|
|
314
|
+
def run(name):
|
|
315
|
+
return _run_batch(exe, name, deadline - monotonic())
|
|
316
|
+
with ThreadPoolExecutor(max_workers=4, thread_name_prefix='AltifyHWID') as pool:
|
|
317
|
+
jobs = {pool.submit(run, name) for name in names}
|
|
318
|
+
for job in as_completed(jobs):
|
|
319
|
+
jobs.remove(job)
|
|
320
|
+
result = job.result()
|
|
321
|
+
data.update(result.get('data', {}))
|
|
322
|
+
warnings.extend(result.get('errors', []))
|
|
323
|
+
snapshot = _snapshot_from_data(data, warnings, include_diagnostics)
|
|
324
|
+
_LOG.info('Collected %d hardware groups (%s) with %d warnings in %.1fs', len(snapshot.groups), ','.join(snapshot.groups) or 'none', len(warnings), timeout - (deadline - monotonic()))
|
|
325
|
+
if len(snapshot.groups) >= 3:
|
|
326
|
+
with _CACHE_LOCK:
|
|
327
|
+
_CACHE[key] = (monotonic(), snapshot)
|
|
328
|
+
while len(_CACHE) > 8:
|
|
329
|
+
_CACHE.popitem(last=False)
|
|
330
|
+
return snapshot
|
|
331
|
+
finally:
|
|
332
|
+
_SCAN_LOCK.release()
|
|
333
|
+
def _migrate_1(con: sqlite3.Connection) -> None:
|
|
334
|
+
con.execute("ALTER TABLE tokens ADD COLUMN added_at TEXT NOT NULL DEFAULT ''")
|
|
335
|
+
con.execute("UPDATE tokens SET added_at=(SELECT created_at FROM devices WHERE devices.device_id=tokens.device_id) WHERE added_at=''")
|
|
336
|
+
_MIGRATIONS = {1: _migrate_1}
|
|
337
|
+
def _rollback(con: sqlite3.Connection | None) -> None:
|
|
338
|
+
if con is not None and con.in_transaction:
|
|
339
|
+
try:
|
|
340
|
+
con.rollback()
|
|
341
|
+
except sqlite3.Error as exc:
|
|
342
|
+
_LOG.warning('Rollback failed: %s', exc)
|
|
343
|
+
class Registry:
|
|
344
|
+
__slots__ = ('namespace', 'min_matches', 'path', 'read_only', 'backup_copies', 'backup_interval', '_key')
|
|
345
|
+
def __init__(self, db_path: str | os.PathLike | None=None, *, namespace: str='default', min_matches: int=3, read_only: bool=False, backup_copies: int=2, backup_interval: float=86400.0):
|
|
346
|
+
if type(min_matches) is not int or not 3 <= min_matches <= len(set(_KINDS.values())):
|
|
347
|
+
raise ValueError('min_matches must be an integer between 3 and 7')
|
|
348
|
+
if not isinstance(namespace, str) or not namespace.strip() or len(namespace) > 128:
|
|
349
|
+
raise ValueError('namespace must contain 1 to 128 characters')
|
|
350
|
+
if type(backup_copies) is not int or not 0 <= backup_copies <= 16:
|
|
351
|
+
raise ValueError('backup_copies must be an integer between 0 and 16')
|
|
352
|
+
if type(backup_interval) not in (float, int) or not math.isfinite(backup_interval) or backup_interval < 0:
|
|
353
|
+
raise ValueError('backup_interval must be zero or more seconds')
|
|
354
|
+
self.namespace, self.min_matches, self.read_only = (namespace, min_matches, bool(read_only))
|
|
355
|
+
self.backup_copies, self.backup_interval = (backup_copies, backup_interval)
|
|
356
|
+
self.path = (Path(db_path).expanduser() if db_path is not None else _default_registry_path()).resolve()
|
|
357
|
+
self._key = None
|
|
358
|
+
try:
|
|
359
|
+
if self.read_only:
|
|
360
|
+
if not self.path.is_file():
|
|
361
|
+
raise RegistryError(f'Registry {self.path} does not exist; a read-only registry cannot be created')
|
|
362
|
+
else:
|
|
363
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
364
|
+
self._check_integrity()
|
|
365
|
+
with self._transaction(write=False) as con:
|
|
366
|
+
key = self._load(con)
|
|
367
|
+
if key is None:
|
|
368
|
+
if self.read_only:
|
|
369
|
+
raise RegistryError(f'Registry {self.path} needs initialization or a schema migration; open it without read_only first')
|
|
370
|
+
with self._transaction() as con:
|
|
371
|
+
key = self._load(con) or self._setup(con)
|
|
372
|
+
self._key = key
|
|
373
|
+
except OSError as exc:
|
|
374
|
+
raise RegistryError(f'Cannot access registry {self.path}: {exc}') from exc
|
|
375
|
+
def _connect(self) -> sqlite3.Connection:
|
|
376
|
+
if self.read_only:
|
|
377
|
+
con = sqlite3.connect(self.path.as_uri() + '?mode=ro', uri=True, timeout=30, isolation_level=None)
|
|
378
|
+
else:
|
|
379
|
+
con = sqlite3.connect(str(self.path), timeout=30, isolation_level=None)
|
|
380
|
+
try:
|
|
381
|
+
if not self.read_only:
|
|
382
|
+
try:
|
|
383
|
+
mode = con.execute('PRAGMA journal_mode=WAL').fetchone()[0]
|
|
384
|
+
if str(mode).lower() != 'wal':
|
|
385
|
+
_LOG.debug('Registry %s stays in journal mode %s', self.path, mode)
|
|
386
|
+
except sqlite3.OperationalError as exc:
|
|
387
|
+
_LOG.debug('Could not enable WAL on %s: %s', self.path, exc)
|
|
388
|
+
con.execute('PRAGMA foreign_keys=ON')
|
|
389
|
+
con.execute('PRAGMA synchronous=FULL')
|
|
390
|
+
except BaseException:
|
|
391
|
+
con.close()
|
|
392
|
+
raise
|
|
393
|
+
return con
|
|
394
|
+
@contextmanager
|
|
395
|
+
def _transaction(self, write: bool=True):
|
|
396
|
+
con = None
|
|
397
|
+
try:
|
|
398
|
+
con = self._connect()
|
|
399
|
+
con.execute('BEGIN IMMEDIATE' if write and not self.read_only else 'BEGIN')
|
|
400
|
+
yield con
|
|
401
|
+
con.execute('COMMIT')
|
|
402
|
+
except sqlite3.Error as exc:
|
|
403
|
+
_rollback(con)
|
|
404
|
+
raise RegistryError(f'Registry database error: {exc}') from exc
|
|
405
|
+
except BaseException:
|
|
406
|
+
_rollback(con)
|
|
407
|
+
raise
|
|
408
|
+
finally:
|
|
409
|
+
if con is not None:
|
|
410
|
+
con.close()
|
|
411
|
+
def _check_integrity(self) -> None:
|
|
412
|
+
with _CHECKED_LOCK:
|
|
413
|
+
if self.path in _CHECKED:
|
|
414
|
+
_CHECKED.move_to_end(self.path)
|
|
415
|
+
return
|
|
416
|
+
if not self.path.is_file() or self.path.stat().st_size == 0:
|
|
417
|
+
return
|
|
418
|
+
try:
|
|
419
|
+
with closing(self._connect()) as con:
|
|
420
|
+
row = con.execute('PRAGMA quick_check(1)').fetchone()
|
|
421
|
+
except sqlite3.Error as exc:
|
|
422
|
+
raise RegistryError(f'Registry {self.path} is unreadable ({exc}); restore it from {self._backup_path(1)}') from exc
|
|
423
|
+
if not row or row[0] != 'ok':
|
|
424
|
+
raise RegistryError(f"Registry {self.path} failed its integrity check ({row[0] if row else 'no result'}); restore it from {self._backup_path(1)}")
|
|
425
|
+
with _CHECKED_LOCK:
|
|
426
|
+
_CHECKED[self.path] = True
|
|
427
|
+
while len(_CHECKED) > 64:
|
|
428
|
+
_CHECKED.popitem(last=False)
|
|
429
|
+
@staticmethod
|
|
430
|
+
def _tables(con: sqlite3.Connection) -> set[str]:
|
|
431
|
+
return {r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
|
432
|
+
@staticmethod
|
|
433
|
+
def _version(text: Any) -> int:
|
|
434
|
+
if not isinstance(text, str) or not text.isdigit():
|
|
435
|
+
raise RegistryError('Unsupported registry schema; preserve the database and use its original API version')
|
|
436
|
+
version = int(text)
|
|
437
|
+
if version > _SCHEMA:
|
|
438
|
+
raise RegistryError(f'Registry schema {version} is newer than this module supports ({_SCHEMA}); upgrade altifyhwid')
|
|
439
|
+
return version
|
|
440
|
+
@staticmethod
|
|
441
|
+
def _read_key(con: sqlite3.Connection) -> bytes:
|
|
442
|
+
key = con.execute("SELECT value FROM meta WHERE key='hmac_key'").fetchone()
|
|
443
|
+
if not key or not isinstance(key[0], str) or not re.fullmatch('[0-9a-f]{64}', key[0]):
|
|
444
|
+
raise RegistryError('Registry HMAC key is missing/corrupt; restore a complete backup')
|
|
445
|
+
return bytes.fromhex(key[0])
|
|
446
|
+
def _load(self, con: sqlite3.Connection) -> bytes | None:
|
|
447
|
+
tables = self._tables(con)
|
|
448
|
+
if 'meta' not in tables:
|
|
449
|
+
return None
|
|
450
|
+
row = con.execute("SELECT value FROM meta WHERE key='schema'").fetchone()
|
|
451
|
+
if not row or self._version(row[0]) < _SCHEMA:
|
|
452
|
+
return None
|
|
453
|
+
if not {'devices', 'tokens'} <= tables:
|
|
454
|
+
raise RegistryError('Registry tables are missing; restore a complete backup')
|
|
455
|
+
return self._read_key(con)
|
|
456
|
+
def _setup(self, con: sqlite3.Connection) -> bytes:
|
|
457
|
+
tables = self._tables(con)
|
|
458
|
+
row = con.execute("SELECT value FROM meta WHERE key='schema'").fetchone() if 'meta' in tables else None
|
|
459
|
+
if row is None:
|
|
460
|
+
if tables - {'meta'} or ('meta' in tables and con.execute('SELECT COUNT(*) FROM meta').fetchone()[0]):
|
|
461
|
+
raise RegistryError('Database is not a new or recognized Altify registry')
|
|
462
|
+
con.execute('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)')
|
|
463
|
+
con.executemany('INSERT INTO meta VALUES (?,?)', [('schema', str(_SCHEMA)), ('hmac_key', secrets.token_hex(32))])
|
|
464
|
+
con.execute('CREATE TABLE devices (device_id TEXT PRIMARY KEY, namespace TEXT NOT NULL, created_at TEXT NOT NULL, last_seen TEXT NOT NULL)')
|
|
465
|
+
con.execute("CREATE TABLE tokens (device_id TEXT NOT NULL REFERENCES devices(device_id) ON DELETE CASCADE, kind TEXT NOT NULL, digest TEXT NOT NULL, added_at TEXT NOT NULL DEFAULT '', PRIMARY KEY(device_id, kind, digest))")
|
|
466
|
+
con.execute('CREATE INDEX tokens_lookup ON tokens(kind,digest)')
|
|
467
|
+
_LOG.info('Created registry %s (schema %d)', self.path, _SCHEMA)
|
|
468
|
+
else:
|
|
469
|
+
version = self._version(row[0])
|
|
470
|
+
if not {'devices', 'tokens'} <= tables:
|
|
471
|
+
raise RegistryError('Registry tables are missing; restore a complete backup')
|
|
472
|
+
while version < _SCHEMA:
|
|
473
|
+
_MIGRATIONS[version](con)
|
|
474
|
+
version += 1
|
|
475
|
+
con.execute("UPDATE meta SET value=? WHERE key='schema'", (str(version),))
|
|
476
|
+
_LOG.info('Migrated registry %s to schema %d', self.path, version)
|
|
477
|
+
return self._read_key(con)
|
|
478
|
+
def _digest(self, kind: str, value: str) -> str:
|
|
479
|
+
payload = json.dumps(['altify-hwid-v1', self.namespace, kind, value], ensure_ascii=True, separators=(',', ':')).encode('ascii')
|
|
480
|
+
return hmac.digest(self._key, payload, 'sha256').hex()
|
|
481
|
+
def _check_key(self, con: sqlite3.Connection) -> None:
|
|
482
|
+
current_key = con.execute("SELECT value FROM meta WHERE key='hmac_key'").fetchone()
|
|
483
|
+
if not current_key or current_key[0] != self._key.hex():
|
|
484
|
+
raise RegistryError('Registry key changed while this instance was open; reopen the registry')
|
|
485
|
+
def _require_writable(self) -> None:
|
|
486
|
+
if self.read_only:
|
|
487
|
+
raise RegistryError('Registry was opened with read_only=True')
|
|
488
|
+
def _owned(self, con: sqlite3.Connection, device_id: Any) -> None:
|
|
489
|
+
if not isinstance(device_id, str) or not device_id:
|
|
490
|
+
raise ValueError('device_id must be a non-empty string')
|
|
491
|
+
if not con.execute('SELECT 1 FROM devices WHERE device_id=? AND namespace=?', (device_id, self.namespace)).fetchone():
|
|
492
|
+
raise NoMatchError(f'{device_id} is not enrolled in namespace {self.namespace!r}')
|
|
493
|
+
def _refresh(self, con: sqlite3.Connection, device_id: str, digests: dict[str, list[str]], stamp: str) -> tuple[str, ...]:
|
|
494
|
+
stored = []
|
|
495
|
+
for kind, values in digests.items():
|
|
496
|
+
cursor = con.executemany('INSERT OR IGNORE INTO tokens(device_id,kind,digest,added_at) VALUES (?,?,?,?)', [(device_id, kind, digest, stamp) for digest in values])
|
|
497
|
+
if cursor.rowcount <= 0:
|
|
498
|
+
continue
|
|
499
|
+
stored.append(kind)
|
|
500
|
+
excess = con.execute('SELECT COUNT(*) FROM tokens WHERE device_id=? AND kind=?', (device_id, kind)).fetchone()[0] - _TOKEN_CAP
|
|
501
|
+
if excess > 0:
|
|
502
|
+
con.execute('DELETE FROM tokens WHERE rowid IN (SELECT t.rowid FROM tokens t WHERE t.device_id=? AND t.kind=? AND NOT EXISTS (SELECT 1 FROM incoming i WHERE i.kind=t.kind AND i.digest=t.digest) ORDER BY t.added_at,t.rowid LIMIT ?)', (device_id, kind, excess))
|
|
503
|
+
_LOG.debug('Pruned %d stale %s tokens from %s', excess, kind, device_id)
|
|
504
|
+
return tuple(stored)
|
|
505
|
+
def resolve(self, snapshot: HardwareSnapshot, *, create: bool=True, refresh: bool=True) -> IdentityResult:
|
|
506
|
+
if not isinstance(snapshot, HardwareSnapshot):
|
|
507
|
+
raise TypeError('snapshot must be HardwareSnapshot')
|
|
508
|
+
if len(snapshot.groups) < self.min_matches:
|
|
509
|
+
raise InsufficientHardwareError(snapshot.groups, self.min_matches, snapshot.warnings)
|
|
510
|
+
write = not self.read_only
|
|
511
|
+
create, refresh = (bool(create) and write, bool(refresh) and write)
|
|
512
|
+
digests = {kind: [self._digest(kind, value) for value in values] for kind, values in snapshot.signals.items()}
|
|
513
|
+
tokens = [(kind, digest) for kind, values in digests.items() for digest in values]
|
|
514
|
+
changed = False
|
|
515
|
+
with self._transaction(write=write) as con:
|
|
516
|
+
self._check_key(con)
|
|
517
|
+
con.execute('CREATE TEMP TABLE incoming (kind TEXT, digest TEXT, PRIMARY KEY(kind,digest))')
|
|
518
|
+
con.executemany('INSERT INTO incoming VALUES (?,?)', tokens)
|
|
519
|
+
candidates = []
|
|
520
|
+
for device_id, kinds, count in con.execute(_MATCH_SQL, (self.namespace, self.min_matches)):
|
|
521
|
+
kinds = tuple(sorted(kinds.split(',')))
|
|
522
|
+
groups = tuple(sorted({_KINDS[k] for k in kinds}))
|
|
523
|
+
candidates.append({'device_id': device_id, 'match_count': count, 'matched_groups': groups, 'matched_kinds': kinds})
|
|
524
|
+
if len(candidates) > 1:
|
|
525
|
+
_LOG.warning('Ambiguous match in namespace %r: %s', self.namespace, ', '.join((f"{c['device_id']} ({c['match_count']} groups)" for c in candidates)))
|
|
526
|
+
raise AmbiguousMatchError(tuple(candidates))
|
|
527
|
+
stamp = _now()
|
|
528
|
+
if candidates:
|
|
529
|
+
candidate = candidates[0]
|
|
530
|
+
device_id = candidate['device_id']
|
|
531
|
+
stored = ()
|
|
532
|
+
if write:
|
|
533
|
+
con.execute('UPDATE devices SET last_seen=? WHERE device_id=? AND last_seen<>?', (stamp, device_id, stamp))
|
|
534
|
+
if refresh:
|
|
535
|
+
stored = self._refresh(con, device_id, digests, stamp)
|
|
536
|
+
changed = bool(stored)
|
|
537
|
+
_LOG.debug('Matched %s on %d groups (%s); stored new tokens for %s', device_id, candidate['match_count'], ','.join(candidate['matched_groups']), ','.join(stored) or 'nothing')
|
|
538
|
+
result = IdentityResult(device_id, False, candidate['matched_groups'], candidate['matched_kinds'], snapshot.groups, snapshot.warnings, stored)
|
|
539
|
+
else:
|
|
540
|
+
if not create:
|
|
541
|
+
raise NoMatchError(f'No enrollment matches at least {self.min_matches} hardware groups')
|
|
542
|
+
device_id = 'ALTIFY-' + str(uuid.uuid4()).upper()
|
|
543
|
+
con.execute('INSERT INTO devices VALUES (?,?,?,?)', (device_id, self.namespace, stamp, stamp))
|
|
544
|
+
con.executemany('INSERT INTO tokens(device_id,kind,digest,added_at) VALUES (?,?,?,?)', [(device_id, k, v, stamp) for k, v in tokens])
|
|
545
|
+
changed = True
|
|
546
|
+
_LOG.info('Enrolled %s with %d groups (%s)', device_id, len(snapshot.groups), ','.join(snapshot.groups))
|
|
547
|
+
result = IdentityResult(device_id, True, (), (), snapshot.groups, snapshot.warnings, tuple(sorted(digests)))
|
|
548
|
+
if changed:
|
|
549
|
+
self._auto_backup()
|
|
550
|
+
return result
|
|
551
|
+
def list_devices(self, *, all_namespaces: bool=False) -> list[dict[str, Any]]:
|
|
552
|
+
sql = _LIST_SQL.format(where='' if all_namespaces else 'WHERE d.namespace=?')
|
|
553
|
+
with self._transaction(write=False) as con:
|
|
554
|
+
rows = con.execute(sql, () if all_namespaces else (self.namespace,)).fetchall()
|
|
555
|
+
devices = []
|
|
556
|
+
for device_id, namespace, created_at, last_seen, count, kinds in rows:
|
|
557
|
+
kinds = tuple(sorted(kinds.split(','))) if kinds else ()
|
|
558
|
+
devices.append({'device_id': device_id, 'namespace': namespace, 'created_at': created_at, 'last_seen': last_seen, 'token_count': count, 'groups': sorted({_KINDS[k] for k in kinds if k in _KINDS}), 'kinds': list(kinds)})
|
|
559
|
+
return devices
|
|
560
|
+
def delete(self, *device_ids: str) -> int:
|
|
561
|
+
self._require_writable()
|
|
562
|
+
if not device_ids:
|
|
563
|
+
raise ValueError('Supply at least one device_id')
|
|
564
|
+
with self._transaction() as con:
|
|
565
|
+
self._check_key(con)
|
|
566
|
+
for device_id in device_ids:
|
|
567
|
+
self._owned(con, device_id)
|
|
568
|
+
deleted = sum((con.execute('DELETE FROM devices WHERE device_id=? AND namespace=?', (device_id, self.namespace)).rowcount for device_id in set(device_ids)))
|
|
569
|
+
_LOG.info('Deleted %d enrollment(s) from namespace %r', deleted, self.namespace)
|
|
570
|
+
self._auto_backup()
|
|
571
|
+
return deleted
|
|
572
|
+
def merge(self, keep_id: str, *absorb_ids: str) -> dict[str, Any]:
|
|
573
|
+
self._require_writable()
|
|
574
|
+
if not absorb_ids:
|
|
575
|
+
raise ValueError('Supply the device_id to keep followed by at least one device_id to absorb')
|
|
576
|
+
if keep_id in absorb_ids or len(set(absorb_ids)) != len(absorb_ids):
|
|
577
|
+
raise ValueError('device_ids must be distinct and must not include the kept device_id')
|
|
578
|
+
stamp = _now()
|
|
579
|
+
with self._transaction() as con:
|
|
580
|
+
self._check_key(con)
|
|
581
|
+
for device_id in (keep_id, *absorb_ids):
|
|
582
|
+
self._owned(con, device_id)
|
|
583
|
+
for device_id in absorb_ids:
|
|
584
|
+
con.execute('INSERT OR IGNORE INTO tokens(device_id,kind,digest,added_at) SELECT ?,kind,digest,added_at FROM tokens WHERE device_id=?', (keep_id, device_id))
|
|
585
|
+
con.execute('DELETE FROM devices WHERE device_id=?', (device_id,))
|
|
586
|
+
con.execute('UPDATE devices SET last_seen=? WHERE device_id=?', (stamp, keep_id))
|
|
587
|
+
count = con.execute('SELECT COUNT(*) FROM tokens WHERE device_id=?', (keep_id,)).fetchone()[0]
|
|
588
|
+
_LOG.info('Merged %s into %s (%d tokens)', ','.join(absorb_ids), keep_id, count)
|
|
589
|
+
self._auto_backup()
|
|
590
|
+
return {'device_id': keep_id, 'merged': list(absorb_ids), 'token_count': count}
|
|
591
|
+
def _backup_path(self, index: int) -> Path:
|
|
592
|
+
return self.path.with_name(f'{self.path.name}.bak{index}')
|
|
593
|
+
def _auto_backup(self) -> None:
|
|
594
|
+
if self.read_only or not self.backup_copies:
|
|
595
|
+
return
|
|
596
|
+
newest = self._backup_path(1)
|
|
597
|
+
staging = self.path.with_name(f'{self.path.name}.bak-{secrets.token_hex(4)}.tmp')
|
|
598
|
+
try:
|
|
599
|
+
if newest.is_file() and time() - newest.stat().st_mtime < self.backup_interval:
|
|
600
|
+
return
|
|
601
|
+
self.backup(staging)
|
|
602
|
+
for index in range(self.backup_copies, 1, -1):
|
|
603
|
+
older = self._backup_path(index - 1)
|
|
604
|
+
if older.is_file():
|
|
605
|
+
os.replace(older, self._backup_path(index))
|
|
606
|
+
os.replace(staging, newest)
|
|
607
|
+
_LOG.info('Backed up registry to %s', newest)
|
|
608
|
+
except Exception as exc:
|
|
609
|
+
staging.unlink(missing_ok=True)
|
|
610
|
+
_LOG.warning('Automatic registry backup failed: %s', exc)
|
|
611
|
+
def backup(self, destination: str | os.PathLike, *, timeout: float=30.0) -> Path:
|
|
612
|
+
if type(timeout) not in (float, int) or not math.isfinite(timeout) or (not 0 < timeout <= 300):
|
|
613
|
+
raise ValueError('backup timeout must be between 0 and 300 seconds')
|
|
614
|
+
target = Path(destination).expanduser().resolve()
|
|
615
|
+
if target == self.path:
|
|
616
|
+
raise ValueError('Backup destination must differ from the live database')
|
|
617
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
618
|
+
with target.open('xb'):
|
|
619
|
+
pass
|
|
620
|
+
deadline = monotonic() + timeout
|
|
621
|
+
def progress(status, remaining, total):
|
|
622
|
+
if monotonic() >= deadline:
|
|
623
|
+
raise RegistryError('Backup timed out')
|
|
624
|
+
try:
|
|
625
|
+
with closing(sqlite3.connect(self.path.as_uri() + '?mode=ro', uri=True, timeout=timeout)) as source:
|
|
626
|
+
with closing(sqlite3.connect(str(target), timeout=timeout)) as dest:
|
|
627
|
+
source.backup(dest, pages=256, progress=progress, sleep=0.05)
|
|
628
|
+
except sqlite3.Error as exc:
|
|
629
|
+
target.unlink(missing_ok=True)
|
|
630
|
+
raise RegistryError(f'Backup failed: {exc}') from exc
|
|
631
|
+
except BaseException:
|
|
632
|
+
target.unlink(missing_ok=True)
|
|
633
|
+
raise
|
|
634
|
+
return target
|
|
635
|
+
def identify(db_path: str | os.PathLike | None=None, *, namespace: str='default', min_matches: int=3, create: bool=True, refresh: bool=True, read_only: bool=False, include_tpm: bool=True, include_monitors: bool=True, timeout: float=35.0, cache_ttl: float=60.0, snapshot: HardwareSnapshot | None=None) -> IdentityResult:
|
|
636
|
+
if type(min_matches) is not int or not 3 <= min_matches <= 7:
|
|
637
|
+
raise ValueError('min_matches must be an integer between 3 and 7')
|
|
638
|
+
if not isinstance(namespace, str) or not namespace.strip() or len(namespace) > 128:
|
|
639
|
+
raise ValueError('namespace must contain 1 to 128 characters')
|
|
640
|
+
if snapshot is None:
|
|
641
|
+
snapshot = collect(include_tpm=include_tpm, include_monitors=include_monitors, timeout=timeout, cache_ttl=cache_ttl)
|
|
642
|
+
if not isinstance(snapshot, HardwareSnapshot):
|
|
643
|
+
raise TypeError('snapshot must be HardwareSnapshot')
|
|
644
|
+
if len(snapshot.groups) < min_matches:
|
|
645
|
+
raise InsufficientHardwareError(snapshot.groups, min_matches, snapshot.warnings)
|
|
646
|
+
return Registry(db_path, namespace=namespace, min_matches=min_matches, read_only=read_only).resolve(snapshot, create=create, refresh=refresh)
|
|
647
|
+
def get_id(*args, **kwargs) -> str:
|
|
648
|
+
return identify(*args, **kwargs).device_id
|
|
649
|
+
hwid = get_id
|
|
650
|
+
def _main() -> int:
|
|
651
|
+
parser = argparse.ArgumentParser(description='Altify: persistent identity from 3+ hardware groups')
|
|
652
|
+
parser.add_argument('action', nargs='?', choices=('identify', 'scan', 'list', 'merge', 'delete'), default='identify')
|
|
653
|
+
parser.add_argument('ids', nargs='*', metavar='DEVICE_ID', help='merge: KEEP_ID ABSORB_ID...; delete: DEVICE_ID...')
|
|
654
|
+
parser.add_argument('--db', help='Registry path (default: per-user Altify/HWID data folder)')
|
|
655
|
+
parser.add_argument('--namespace', default='default')
|
|
656
|
+
parser.add_argument('--all-namespaces', action='store_true', help='list: include every namespace')
|
|
657
|
+
parser.add_argument('--min-matches', type=int, default=3, choices=range(3, 8))
|
|
658
|
+
parser.add_argument('--no-create', action='store_true', help='Only match an existing enrollment')
|
|
659
|
+
parser.add_argument('--no-refresh', action='store_true', help='Do not store newly seen hardware on a match')
|
|
660
|
+
parser.add_argument('--read-only', action='store_true', help='Never write to the registry')
|
|
661
|
+
parser.add_argument('--no-tpm', action='store_true')
|
|
662
|
+
parser.add_argument('--no-monitors', action='store_true')
|
|
663
|
+
parser.add_argument('--timeout', type=float, default=35, help='Overall collection deadline in seconds')
|
|
664
|
+
parser.add_argument('--json', action='store_true', help='Print detailed identity result as JSON')
|
|
665
|
+
parser.add_argument('--raw', action='store_true', help='Include RAW identifiers in scan output')
|
|
666
|
+
parser.add_argument('-v', '--verbose', action='store_true', help='Log collector and registry activity to stderr')
|
|
667
|
+
args = parser.parse_args()
|
|
668
|
+
if args.raw and args.action != 'scan':
|
|
669
|
+
parser.error('--raw requires the scan action')
|
|
670
|
+
if args.all_namespaces and args.action != 'list':
|
|
671
|
+
parser.error('--all-namespaces requires the list action')
|
|
672
|
+
if args.ids and args.action not in ('merge', 'delete'):
|
|
673
|
+
parser.error('device ids are only accepted by merge and delete')
|
|
674
|
+
if args.action == 'merge' and len(args.ids) < 2:
|
|
675
|
+
parser.error('merge needs KEEP_ID and at least one ABSORB_ID')
|
|
676
|
+
if args.action == 'delete' and not args.ids:
|
|
677
|
+
parser.error('delete needs at least one DEVICE_ID')
|
|
678
|
+
if args.verbose:
|
|
679
|
+
logging.basicConfig(level=logging.DEBUG, stream=sys.stderr, format='%(levelname)s %(name)s: %(message)s')
|
|
680
|
+
try:
|
|
681
|
+
if args.action in ('list', 'merge', 'delete'):
|
|
682
|
+
registry = Registry(args.db, namespace=args.namespace, min_matches=args.min_matches, read_only=args.read_only)
|
|
683
|
+
if args.action == 'list':
|
|
684
|
+
output = registry.list_devices(all_namespaces=args.all_namespaces)
|
|
685
|
+
elif args.action == 'merge':
|
|
686
|
+
output = registry.merge(args.ids[0], *args.ids[1:])
|
|
687
|
+
else:
|
|
688
|
+
output = {'deleted': registry.delete(*args.ids)}
|
|
689
|
+
print(json.dumps(output, indent=2))
|
|
690
|
+
return 0
|
|
691
|
+
snapshot = collect(include_tpm=not args.no_tpm, include_monitors=not args.no_monitors, include_diagnostics=args.raw, timeout=args.timeout, cache_ttl=0)
|
|
692
|
+
if args.action == 'scan':
|
|
693
|
+
print(json.dumps(snapshot.to_dict(raw=args.raw), indent=2, ensure_ascii=True))
|
|
694
|
+
return 0
|
|
695
|
+
result = identify(args.db, namespace=args.namespace, min_matches=args.min_matches, create=not args.no_create, refresh=not args.no_refresh, read_only=args.read_only, snapshot=snapshot)
|
|
696
|
+
print(json.dumps(result.to_dict(), indent=2) if args.json else result.device_id)
|
|
697
|
+
if not args.json:
|
|
698
|
+
for warning in result.warnings:
|
|
699
|
+
print('Warning: ' + warning, file=sys.stderr)
|
|
700
|
+
return 0
|
|
701
|
+
except (AltifyError, ValueError, OSError) as exc:
|
|
702
|
+
error = {'error': type(exc).__name__, 'message': str(exc)}
|
|
703
|
+
if isinstance(exc, AmbiguousMatchError):
|
|
704
|
+
error['candidates'] = exc.candidates
|
|
705
|
+
if isinstance(exc, InsufficientHardwareError):
|
|
706
|
+
error['warnings'] = exc.warnings
|
|
707
|
+
print(json.dumps(error, indent=2), file=sys.stderr)
|
|
708
|
+
return 2
|
|
709
|
+
except Exception as exc:
|
|
710
|
+
_LOG.exception('Unexpected error')
|
|
711
|
+
print(json.dumps({'error': type(exc).__name__, 'message': f'Unexpected error: {exc}'}, indent=2), file=sys.stderr)
|
|
712
|
+
return 1
|
|
713
|
+
if __name__ == '__main__':
|
|
714
|
+
raise SystemExit(_main())
|