pyhubblenetwork 0.0.2__tar.gz → 0.0.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyhubblenetwork-0.0.2 → pyhubblenetwork-0.0.4}/PKG-INFO +5 -5
- {pyhubblenetwork-0.0.2 → pyhubblenetwork-0.0.4}/README.md +4 -4
- {pyhubblenetwork-0.0.2 → pyhubblenetwork-0.0.4}/pyproject.toml +1 -1
- {pyhubblenetwork-0.0.2 → pyhubblenetwork-0.0.4}/src/hubblenetwork/__init__.py +5 -0
- {pyhubblenetwork-0.0.2 → pyhubblenetwork-0.0.4}/src/hubblenetwork/ble.py +66 -2
- {pyhubblenetwork-0.0.2 → pyhubblenetwork-0.0.4}/src/hubblenetwork/cli.py +117 -63
- {pyhubblenetwork-0.0.2 → pyhubblenetwork-0.0.4}/src/hubblenetwork/cloud.py +89 -69
- {pyhubblenetwork-0.0.2 → pyhubblenetwork-0.0.4}/src/hubblenetwork/crypto.py +2 -4
- {pyhubblenetwork-0.0.2 → pyhubblenetwork-0.0.4}/src/hubblenetwork/device.py +0 -1
- {pyhubblenetwork-0.0.2 → pyhubblenetwork-0.0.4}/src/hubblenetwork/errors.py +4 -0
- {pyhubblenetwork-0.0.2 → pyhubblenetwork-0.0.4}/src/hubblenetwork/org.py +32 -45
- {pyhubblenetwork-0.0.2 → pyhubblenetwork-0.0.4}/src/hubblenetwork/packets.py +0 -1
- {pyhubblenetwork-0.0.2 → pyhubblenetwork-0.0.4}/.gitignore +0 -0
- {pyhubblenetwork-0.0.2 → pyhubblenetwork-0.0.4}/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyhubblenetwork
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.4
|
|
4
4
|
Summary: Hubble SDK host-side tools
|
|
5
5
|
Author-email: Paul Buckley <paul@hubble.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -84,9 +84,9 @@ pip install -e '.[dev]'
|
|
|
84
84
|
### Scan locally, then ingest to backend
|
|
85
85
|
|
|
86
86
|
```python
|
|
87
|
-
from hubblenetwork import ble, Organization
|
|
87
|
+
from hubblenetwork import ble, Organization, Credentials
|
|
88
88
|
|
|
89
|
-
org = Organization(org_id="org_123", api_token="sk_XXX")
|
|
89
|
+
org = Organization(Credentials(org_id="org_123", api_token="sk_XXX"))
|
|
90
90
|
pkts = ble.scan(timeout=5.0)
|
|
91
91
|
if len(pkts) > 0:
|
|
92
92
|
org.ingest_packet(pkts[0])
|
|
@@ -97,9 +97,9 @@ else:
|
|
|
97
97
|
### Manage devices and query packets
|
|
98
98
|
|
|
99
99
|
```python
|
|
100
|
-
from hubblenetwork import Organization
|
|
100
|
+
from hubblenetwork import Organization, Credentials
|
|
101
101
|
|
|
102
|
-
org = Organization(org_id="org_123", api_token="sk_XXX")
|
|
102
|
+
org = Organization(Credentials(org_id="org_123", api_token="sk_XXX"))
|
|
103
103
|
|
|
104
104
|
# Create a new device
|
|
105
105
|
new_dev = org.register_device()
|
|
@@ -61,9 +61,9 @@ pip install -e '.[dev]'
|
|
|
61
61
|
### Scan locally, then ingest to backend
|
|
62
62
|
|
|
63
63
|
```python
|
|
64
|
-
from hubblenetwork import ble, Organization
|
|
64
|
+
from hubblenetwork import ble, Organization, Credentials
|
|
65
65
|
|
|
66
|
-
org = Organization(org_id="org_123", api_token="sk_XXX")
|
|
66
|
+
org = Organization(Credentials(org_id="org_123", api_token="sk_XXX"))
|
|
67
67
|
pkts = ble.scan(timeout=5.0)
|
|
68
68
|
if len(pkts) > 0:
|
|
69
69
|
org.ingest_packet(pkts[0])
|
|
@@ -74,9 +74,9 @@ else:
|
|
|
74
74
|
### Manage devices and query packets
|
|
75
75
|
|
|
76
76
|
```python
|
|
77
|
-
from hubblenetwork import Organization
|
|
77
|
+
from hubblenetwork import Organization, Credentials
|
|
78
78
|
|
|
79
|
-
org = Organization(org_id="org_123", api_token="sk_XXX")
|
|
79
|
+
org = Organization(Credentials(org_id="org_123", api_token="sk_XXX"))
|
|
80
80
|
|
|
81
81
|
# Create a new device
|
|
82
82
|
new_dev = org.register_device()
|
|
@@ -11,6 +11,8 @@ from .packets import Location, EncryptedPacket, DecryptedPacket
|
|
|
11
11
|
from .device import Device
|
|
12
12
|
from .org import Organization
|
|
13
13
|
from .crypto import decrypt
|
|
14
|
+
from .errors import InvalidCredentialsError
|
|
15
|
+
from .cloud import Credentials, Environment
|
|
14
16
|
|
|
15
17
|
__all__ = [
|
|
16
18
|
"ble",
|
|
@@ -21,4 +23,7 @@ __all__ = [
|
|
|
21
23
|
"DecryptedPacket",
|
|
22
24
|
"Device",
|
|
23
25
|
"Organization",
|
|
26
|
+
"Credentials",
|
|
27
|
+
"Environment",
|
|
28
|
+
"InvalidCredentialsError",
|
|
24
29
|
]
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
# hubblenetwork/ble.py
|
|
2
2
|
from __future__ import annotations
|
|
3
|
-
|
|
4
3
|
import asyncio
|
|
5
4
|
from datetime import datetime, timezone
|
|
6
|
-
from typing import Optional
|
|
5
|
+
from typing import Optional, List
|
|
7
6
|
import geocoder
|
|
8
7
|
|
|
9
8
|
from bleak import BleakScanner
|
|
@@ -90,3 +89,68 @@ def scan(timeout: float) -> List[EncryptedPacket]:
|
|
|
90
89
|
# Create a task and block until it’s done via a new Future
|
|
91
90
|
return loop.run_until_complete(_scan_async(timeout)) # type: ignore[func-returns-value]
|
|
92
91
|
return loop.run_until_complete(_scan_async(timeout))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def scan_single(timeout: float) -> Optional[EncryptedPacket]:
|
|
95
|
+
"""
|
|
96
|
+
Scan for a BLE advertisement that includes service data for UUID 0xFCA6 and
|
|
97
|
+
return it.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
async def _scan_async(ttl: float) -> List[EncryptedPacket]:
|
|
101
|
+
done = asyncio.Event()
|
|
102
|
+
packet: Optional[EncryptedPacket] = None
|
|
103
|
+
|
|
104
|
+
def on_detect(device, adv_data) -> None:
|
|
105
|
+
nonlocal packet
|
|
106
|
+
|
|
107
|
+
# If we already found a packet, ignore further callbacks
|
|
108
|
+
if packet is not None:
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
# Normalize to a dict; bleak provides service_data as {uuid_str: bytes}
|
|
112
|
+
service_data = getattr(adv_data, "service_data", None) or {}
|
|
113
|
+
service_uuids = getattr(adv_data, "service_uuids", None) or []
|
|
114
|
+
payload = None
|
|
115
|
+
|
|
116
|
+
if _TARGET_UUID not in service_uuids:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
# Keys are 128-bit UUID strings; compare lowercased
|
|
120
|
+
for uuid_str, data in service_data.items():
|
|
121
|
+
if (uuid_str or "").lower() == _TARGET_UUID:
|
|
122
|
+
payload = bytes(data)
|
|
123
|
+
break
|
|
124
|
+
|
|
125
|
+
if payload is None:
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
rssi = getattr(adv_data, "rssi", getattr(device, "rssi", 0)) or 0
|
|
129
|
+
packet = EncryptedPacket(
|
|
130
|
+
timestamp=int(datetime.now(timezone.utc).timestamp()),
|
|
131
|
+
location=_get_location(),
|
|
132
|
+
payload=payload,
|
|
133
|
+
rssi=int(rssi),
|
|
134
|
+
)
|
|
135
|
+
done.set()
|
|
136
|
+
|
|
137
|
+
# Start scanning and wait for first match or timeout
|
|
138
|
+
async with BleakScanner(detection_callback=on_detect):
|
|
139
|
+
try:
|
|
140
|
+
await asyncio.wait_for(done.wait(), timeout=ttl)
|
|
141
|
+
except asyncio.TimeoutError:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
return packet
|
|
145
|
+
|
|
146
|
+
# Run the async scanner. If there's already a running event loop (e.g., Jupyter),
|
|
147
|
+
# you can adapt this to use `await _scan_async(timeout)` instead.
|
|
148
|
+
try:
|
|
149
|
+
return asyncio.run(_scan_async(timeout))
|
|
150
|
+
except RuntimeError:
|
|
151
|
+
# Fallback for environments with an active loop (e.g., notebooks/async apps)
|
|
152
|
+
loop = asyncio.get_event_loop()
|
|
153
|
+
if loop.is_running():
|
|
154
|
+
# Create a task and block until it’s done via a new Future
|
|
155
|
+
return loop.run_until_complete(_scan_async(timeout)) # type: ignore[func-returns-value]
|
|
156
|
+
return loop.run_until_complete(_scan_async(timeout))
|
|
@@ -4,15 +4,16 @@ from __future__ import annotations
|
|
|
4
4
|
import click
|
|
5
5
|
import os
|
|
6
6
|
import json
|
|
7
|
-
import time
|
|
8
|
-
import base64
|
|
9
7
|
import sys
|
|
10
|
-
|
|
8
|
+
import time
|
|
9
|
+
from datetime import datetime
|
|
11
10
|
from typing import Optional
|
|
12
11
|
from hubblenetwork import Organization
|
|
13
12
|
from hubblenetwork import Device, DecryptedPacket, EncryptedPacket
|
|
14
13
|
from hubblenetwork import ble as ble_mod
|
|
15
14
|
from hubblenetwork import decrypt
|
|
15
|
+
from hubblenetwork import cloud
|
|
16
|
+
from hubblenetwork import InvalidCredentialsError
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
def _get_env_or_fail(name: str) -> str:
|
|
@@ -34,28 +35,53 @@ def _get_org_and_token(org_id, token) -> tuple[str, str]:
|
|
|
34
35
|
return org_id, token
|
|
35
36
|
|
|
36
37
|
|
|
38
|
+
def _print_packet_table_header() -> None:
|
|
39
|
+
click.echo(
|
|
40
|
+
"\nTIME RSSI LAT LON PAYLOAD (B)"
|
|
41
|
+
)
|
|
42
|
+
click.echo(
|
|
43
|
+
"---------------------------------------------------------------------------"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _print_packet_table_row(pkt) -> None:
|
|
48
|
+
ts = datetime.fromtimestamp(pkt.timestamp).strftime("%c")
|
|
49
|
+
click.echo(
|
|
50
|
+
f"{ts} {pkt.rssi} {pkt.location.lat:.6f} {pkt.location.lon:.6f} ",
|
|
51
|
+
nl=False,
|
|
52
|
+
)
|
|
53
|
+
if isinstance(pkt, DecryptedPacket):
|
|
54
|
+
click.echo(f'payload: "{pkt.payload}"')
|
|
55
|
+
elif isinstance(pkt, EncryptedPacket):
|
|
56
|
+
click.echo(f"{pkt.payload.hex()} ({len(pkt.payload)} bytes)")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _print_packet_pretty(pkt) -> None:
|
|
60
|
+
ts = datetime.fromtimestamp(pkt.timestamp).strftime("%c")
|
|
61
|
+
loc = pkt.location
|
|
62
|
+
loc_str = (
|
|
63
|
+
f"{loc.lat:.6f},{loc.lon:.6f}"
|
|
64
|
+
if getattr(loc, "lat", None) is not None
|
|
65
|
+
else "unknown"
|
|
66
|
+
)
|
|
67
|
+
click.echo(click.style("=== BLE packet ===", bold=True))
|
|
68
|
+
click.echo(f"time: {ts}")
|
|
69
|
+
click.echo(f"rssi: {pkt.rssi} dBm")
|
|
70
|
+
click.echo(f"loc: {loc_str}")
|
|
71
|
+
# Show both hex and length
|
|
72
|
+
if isinstance(pkt, DecryptedPacket):
|
|
73
|
+
click.echo(f'payload: "{pkt.payload}"')
|
|
74
|
+
elif isinstance(pkt, EncryptedPacket):
|
|
75
|
+
click.echo(f"payload: {pkt.payload.hex()} ({len(pkt.payload)} bytes)")
|
|
76
|
+
|
|
77
|
+
|
|
37
78
|
def _print_packets_pretty(pkts) -> None:
|
|
38
79
|
if len(pkts) == 0:
|
|
39
80
|
click.echo("No packets!")
|
|
40
81
|
return
|
|
41
82
|
"""Pretty-print an EncryptedPacket."""
|
|
42
83
|
for pkt in pkts:
|
|
43
|
-
|
|
44
|
-
loc = pkt.location
|
|
45
|
-
loc_str = (
|
|
46
|
-
f"{loc.lat:.6f},{loc.lon:.6f}"
|
|
47
|
-
if getattr(loc, "lat", None) is not None
|
|
48
|
-
else "unknown"
|
|
49
|
-
)
|
|
50
|
-
click.echo(click.style("=== BLE packet ===", bold=True))
|
|
51
|
-
click.echo(f"time: {ts}")
|
|
52
|
-
click.echo(f"rssi: {pkt.rssi} dBm")
|
|
53
|
-
click.echo(f"loc: {loc_str}")
|
|
54
|
-
# Show both hex and length
|
|
55
|
-
if isinstance(pkt, DecryptedPacket):
|
|
56
|
-
click.echo(f'payload: "{pkt.payload}"')
|
|
57
|
-
elif isinstance(pkt, EncryptedPacket):
|
|
58
|
-
click.echo(f"payload: {pkt.payload.hex()} ({len(pkt.payload)} bytes)")
|
|
84
|
+
_print_packet_pretty(pkt)
|
|
59
85
|
|
|
60
86
|
|
|
61
87
|
def _print_packets_csv(pkts) -> None:
|
|
@@ -122,6 +148,36 @@ def cli() -> None:
|
|
|
122
148
|
# top-level group; subcommands are added below
|
|
123
149
|
|
|
124
150
|
|
|
151
|
+
@cli.command("validate-credentials")
|
|
152
|
+
@click.option(
|
|
153
|
+
"--org-id",
|
|
154
|
+
"-o",
|
|
155
|
+
type=str,
|
|
156
|
+
envvar="HUBBLE_ORG_ID",
|
|
157
|
+
default=None,
|
|
158
|
+
show_default=False,
|
|
159
|
+
help="Organization ID (if not using HUBBLE_ORG_ID env var)",
|
|
160
|
+
)
|
|
161
|
+
@click.option(
|
|
162
|
+
"--token",
|
|
163
|
+
"-t",
|
|
164
|
+
type=str,
|
|
165
|
+
envvar="HUBBLE_API_TOKEN",
|
|
166
|
+
default=None,
|
|
167
|
+
show_default=False,
|
|
168
|
+
help="Token (if not using HUBBLE_API_TOKEN env var)",
|
|
169
|
+
)
|
|
170
|
+
def validate_credentials(org_id, token) -> None:
|
|
171
|
+
"""Validate the given credentials"""
|
|
172
|
+
# subgroup for organization-related commands
|
|
173
|
+
credentials = cloud.Credentials(org_id, token)
|
|
174
|
+
env = cloud.get_env_from_credentials(credentials)
|
|
175
|
+
if env:
|
|
176
|
+
click.echo(f'Valid credentials (env="{env.name}")')
|
|
177
|
+
else:
|
|
178
|
+
click.secho("Invalid credentials!", fg="red", err=True)
|
|
179
|
+
|
|
180
|
+
|
|
125
181
|
@cli.group()
|
|
126
182
|
def ble() -> None:
|
|
127
183
|
"""BLE utilities."""
|
|
@@ -133,7 +189,6 @@ def ble() -> None:
|
|
|
133
189
|
"--timeout",
|
|
134
190
|
"-t",
|
|
135
191
|
type=int,
|
|
136
|
-
default=5,
|
|
137
192
|
show_default=False,
|
|
138
193
|
help="Timeout when scanning",
|
|
139
194
|
)
|
|
@@ -146,48 +201,44 @@ def ble() -> None:
|
|
|
146
201
|
help="Attempt to decrypt any received packet with the given key",
|
|
147
202
|
)
|
|
148
203
|
@click.option("--ingest", is_flag=True)
|
|
149
|
-
def ble_scan(
|
|
204
|
+
def ble_scan(
|
|
205
|
+
timeout: Optional(int) = None, ingest: bool = False, key: str = None
|
|
206
|
+
) -> None:
|
|
150
207
|
"""
|
|
151
208
|
Scan for UUID 0xFCA6 and print the first packet found within TIMEOUT seconds.
|
|
152
209
|
|
|
153
210
|
Example:
|
|
154
211
|
hubblenetwork ble scan 1
|
|
155
212
|
"""
|
|
156
|
-
click.secho(
|
|
157
|
-
|
|
158
|
-
)
|
|
159
|
-
pkts = ble_mod.scan(timeout=timeout)
|
|
160
|
-
if len(pkts) == 0:
|
|
161
|
-
click.secho(f"[WARNING] No packet found within {timeout:.2f}s", fg="yellow")
|
|
162
|
-
raise SystemExit(1)
|
|
163
|
-
click.echo("[COMPLETE]")
|
|
164
|
-
|
|
165
|
-
click.echo("\n[INFO] Encrypted packets received:")
|
|
166
|
-
_print_packets(pkts)
|
|
167
|
-
|
|
168
|
-
# If we have a key, attempt to decrypt
|
|
169
|
-
if key:
|
|
170
|
-
key = bytearray(base64.b64decode(key))
|
|
171
|
-
decrypted_pkts = []
|
|
172
|
-
for pkt in pkts:
|
|
173
|
-
decrypted_pkt = decrypt(key, pkt)
|
|
174
|
-
if decrypted_pkt:
|
|
175
|
-
decrypted_pkts.append(decrypted_pkt)
|
|
176
|
-
if len(decrypted_pkts) > 0:
|
|
177
|
-
click.echo("\n[INFO] Locally decrypted packets:")
|
|
178
|
-
_print_packets(decrypted_pkts)
|
|
179
|
-
else:
|
|
180
|
-
click.secho("\n[WARNING] No locally decryptable packets found", fg="yellow")
|
|
213
|
+
click.secho("[INFO] Scanning for Hubble devices...")
|
|
214
|
+
_print_packet_table_header()
|
|
181
215
|
|
|
182
216
|
if ingest:
|
|
183
|
-
click.echo("[INFO] Ingesting packet(s) into the backend... ", nl=False)
|
|
184
217
|
org = Organization(
|
|
185
218
|
org_id=_get_env_or_fail("HUBBLE_ORG_ID"),
|
|
186
219
|
api_token=_get_env_or_fail("HUBBLE_API_TOKEN"),
|
|
187
220
|
)
|
|
188
|
-
|
|
221
|
+
|
|
222
|
+
start = time.monotonic()
|
|
223
|
+
deadline = None if timeout is None else start + timeout
|
|
224
|
+
|
|
225
|
+
while deadline is None or time.monotonic() < deadline:
|
|
226
|
+
this_timeout = None if deadline is None else max(deadline - time.monotonic(), 0)
|
|
227
|
+
|
|
228
|
+
pkt = ble_mod.scan_single(timeout=this_timeout)
|
|
229
|
+
if not pkt:
|
|
230
|
+
break
|
|
231
|
+
|
|
232
|
+
# If we have a key, attempt to decrypt
|
|
233
|
+
if key:
|
|
234
|
+
decrypted_pkt = decrypt(key, pkt)
|
|
235
|
+
if decrypted_pkt:
|
|
236
|
+
_print_packet_table_row(pkt)
|
|
237
|
+
else:
|
|
238
|
+
_print_packet_table_row(pkt)
|
|
239
|
+
|
|
240
|
+
if ingest:
|
|
189
241
|
org.ingest_packet(pkt)
|
|
190
|
-
click.echo("[SUCCESS]")
|
|
191
242
|
|
|
192
243
|
|
|
193
244
|
pass_orgcfg = click.make_pass_decorator(Organization, ensure=True)
|
|
@@ -212,21 +263,14 @@ pass_orgcfg = click.make_pass_decorator(Organization, ensure=True)
|
|
|
212
263
|
show_default=False,
|
|
213
264
|
help="Token (if not using HUBBLE_API_TOKEN env var)",
|
|
214
265
|
)
|
|
215
|
-
@click.option(
|
|
216
|
-
"--url",
|
|
217
|
-
"-u",
|
|
218
|
-
type=str,
|
|
219
|
-
envvar="HUBBLE_BASE_URL",
|
|
220
|
-
default=None,
|
|
221
|
-
show_default=False,
|
|
222
|
-
help="Base URL to override production (if not using HUBBLE_BASE_URL env var)",
|
|
223
|
-
)
|
|
224
266
|
@click.pass_context
|
|
225
|
-
def org(ctx, org_id, token
|
|
267
|
+
def org(ctx, org_id, token) -> None:
|
|
226
268
|
"""Organization utilities."""
|
|
227
269
|
# subgroup for organization-related commands
|
|
228
|
-
|
|
229
|
-
|
|
270
|
+
try:
|
|
271
|
+
ctx.obj = Organization(org_id=org_id, api_token=token)
|
|
272
|
+
except InvalidCredentialsError as e:
|
|
273
|
+
raise click.BadParameter(str(e))
|
|
230
274
|
|
|
231
275
|
|
|
232
276
|
@org.command("info")
|
|
@@ -247,9 +291,19 @@ def list_devices(org: Organization) -> None:
|
|
|
247
291
|
|
|
248
292
|
|
|
249
293
|
@org.command("register-device")
|
|
294
|
+
@click.option(
|
|
295
|
+
"--encryption",
|
|
296
|
+
"-e",
|
|
297
|
+
type=str,
|
|
298
|
+
default=None,
|
|
299
|
+
show_default=False, # show default in --help
|
|
300
|
+
help="Encryption type [AES-256-CTR, AES-128-CTR]",
|
|
301
|
+
)
|
|
250
302
|
@pass_orgcfg
|
|
251
|
-
def register_device(org: Organization) -> None:
|
|
252
|
-
|
|
303
|
+
def register_device(org: Organization, encryption) -> None:
|
|
304
|
+
if encryption:
|
|
305
|
+
click.secho(f'[INFO] Overriding default encryption, using "{encryption}"')
|
|
306
|
+
click.secho(str(org.register_device(encryption=encryption)))
|
|
253
307
|
|
|
254
308
|
|
|
255
309
|
@org.command("set-device-name")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# hubble/cloud_api.py
|
|
2
2
|
from __future__ import annotations
|
|
3
|
+
from dataclasses import dataclass
|
|
3
4
|
import httpx
|
|
4
5
|
import time
|
|
5
6
|
import base64
|
|
@@ -13,11 +14,23 @@ from .errors import (
|
|
|
13
14
|
raise_for_response,
|
|
14
15
|
)
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Environment:
|
|
20
|
+
name: str
|
|
21
|
+
url: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Credentials:
|
|
26
|
+
org_id: str
|
|
27
|
+
api_token: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_ENVIRONMENTS = [
|
|
31
|
+
Environment("PROD", "https://api.hubble.com"),
|
|
32
|
+
Environment("TESTING", "https://api-testing.hubblenetwork.io"),
|
|
33
|
+
]
|
|
21
34
|
|
|
22
35
|
|
|
23
36
|
def _auth_headers(api_token: str) -> dict[str, str]:
|
|
@@ -28,69 +41,64 @@ def _auth_headers(api_token: str) -> dict[str, str]:
|
|
|
28
41
|
}
|
|
29
42
|
|
|
30
43
|
|
|
31
|
-
def _list_devices_endpoint(
|
|
32
|
-
return f"/org/{org_id}/devices"
|
|
44
|
+
def _list_devices_endpoint(credentials: Credentials) -> str:
|
|
45
|
+
return f"/org/{credentials.org_id}/devices"
|
|
33
46
|
|
|
34
47
|
|
|
35
|
-
def _register_device_endpoint(
|
|
36
|
-
return f"/v2/org/{org_id}/devices"
|
|
48
|
+
def _register_device_endpoint(credentials: Credentials) -> str:
|
|
49
|
+
return f"/v2/org/{credentials.org_id}/devices"
|
|
37
50
|
|
|
38
51
|
|
|
39
|
-
def _retrieve_org_packets_endpoint(
|
|
40
|
-
return f"/org/{org_id}/packets"
|
|
52
|
+
def _retrieve_org_packets_endpoint(credentials: Credentials) -> str:
|
|
53
|
+
return f"/org/{credentials.org_id}/packets"
|
|
41
54
|
|
|
42
55
|
|
|
43
|
-
def _ingest_packets_endpoint(
|
|
44
|
-
return f"/org/{org_id}/packets"
|
|
56
|
+
def _ingest_packets_endpoint(credentials: Credentials) -> str:
|
|
57
|
+
return f"/org/{credentials.org_id}/packets"
|
|
45
58
|
|
|
46
59
|
|
|
47
|
-
def _update_device_endpoint(
|
|
48
|
-
return f"/org/{org_id}/devices/{device_id}"
|
|
60
|
+
def _update_device_endpoint(credentials: Credentials, device_id: str) -> str:
|
|
61
|
+
return f"/org/{credentials.org_id}/devices/{device_id}"
|
|
49
62
|
|
|
50
63
|
|
|
51
|
-
def
|
|
52
|
-
return f"/org/{org_id}"
|
|
64
|
+
def _retrieve_org_metadata_endpoint(credentials: Credentials) -> str:
|
|
65
|
+
return f"/org/{credentials.org_id}"
|
|
53
66
|
|
|
54
67
|
|
|
55
|
-
def
|
|
56
|
-
|
|
68
|
+
def _validate_key_endpoint(credentials: Credentials) -> str:
|
|
69
|
+
return f"/org/{credentials.org_id}/check"
|
|
57
70
|
|
|
58
71
|
|
|
59
72
|
def cloud_request(
|
|
73
|
+
*,
|
|
60
74
|
method: str,
|
|
61
75
|
path: str,
|
|
62
|
-
|
|
63
|
-
|
|
76
|
+
env: Environment,
|
|
77
|
+
credentials: Optional[Credentials] = None,
|
|
64
78
|
json: Any = None,
|
|
65
79
|
timeout_s: float = 10.0,
|
|
66
80
|
params: Optional[MutableMapping[str, Any]] = None,
|
|
67
|
-
base_url: Optional[str] = None,
|
|
68
81
|
) -> Any:
|
|
69
82
|
"""
|
|
70
83
|
Make a single HTTP request to the Hubble Cloud API and return parsed JSON.
|
|
71
84
|
|
|
72
85
|
- `method`: "GET", "POST", etc.
|
|
73
86
|
- `path`: endpoint path (e.g., "/devices" or "orgs/{id}/devices")
|
|
74
|
-
- `
|
|
75
|
-
- `
|
|
76
|
-
(skip or embed in `path` if your endpoint uses a path param instead)
|
|
87
|
+
- `credentials`: Credentials to use for this call
|
|
88
|
+
- `env`: Environment to call into (typically prod or testing)
|
|
77
89
|
- `json`: request JSON body (for POST/PUT/PATCH)
|
|
78
90
|
- `timeout_s`: request timeout in seconds
|
|
79
91
|
- `params`: optional HTTP request parameters
|
|
80
|
-
- `base_url`: URL to use in place of default production URL
|
|
81
92
|
"""
|
|
82
|
-
|
|
83
|
-
base_url = base_url if base_url is not None else default_env_url
|
|
84
|
-
base_url = base_url.rstrip("/")
|
|
85
|
-
url = f"{base_url}/api/{path}"
|
|
93
|
+
url = f"{env.url.rstrip('/')}/api/{path.lstrip('/')}"
|
|
86
94
|
|
|
87
95
|
# headers
|
|
88
96
|
headers: MutableMapping[str, str] = {
|
|
89
97
|
"Accept": "application/json",
|
|
90
98
|
"Content-Type": "application/json",
|
|
91
99
|
}
|
|
92
|
-
if
|
|
93
|
-
headers["Authorization"] = f"Bearer {api_token}"
|
|
100
|
+
if credentials:
|
|
101
|
+
headers["Authorization"] = f"Bearer {credentials.api_token}"
|
|
94
102
|
|
|
95
103
|
try:
|
|
96
104
|
with httpx.Client(timeout=timeout_s) as client:
|
|
@@ -118,31 +126,44 @@ def cloud_request(
|
|
|
118
126
|
raise BackendError(f"Non-JSON response from {url}") from e
|
|
119
127
|
|
|
120
128
|
|
|
129
|
+
def get_env_from_credentials(credentials: Credentials) -> Optional[Environment]:
|
|
130
|
+
for env in _ENVIRONMENTS:
|
|
131
|
+
try:
|
|
132
|
+
# If this call fails then we know we don't have the
|
|
133
|
+
# credentials for this environment
|
|
134
|
+
cloud_request(
|
|
135
|
+
method="GET",
|
|
136
|
+
path=_validate_key_endpoint(credentials),
|
|
137
|
+
credentials=credentials,
|
|
138
|
+
env=env,
|
|
139
|
+
)
|
|
140
|
+
return env
|
|
141
|
+
except:
|
|
142
|
+
pass
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
121
146
|
def register_device(
|
|
122
|
-
*,
|
|
123
|
-
org_id: str,
|
|
124
|
-
api_token: str,
|
|
125
|
-
base_url: Optional[str] = None,
|
|
147
|
+
*, credentials: Credentials, env: Environment, encryption: str = "AES-256-CTR"
|
|
126
148
|
) -> Any:
|
|
127
149
|
"""Create a new device and return it."""
|
|
128
150
|
data = {
|
|
129
151
|
"n_devices": 1,
|
|
130
|
-
"encryption": "AES-256-CTR",
|
|
152
|
+
"encryption": "AES-256-CTR" if not encryption else encryption,
|
|
131
153
|
}
|
|
132
154
|
return cloud_request(
|
|
133
155
|
method="POST",
|
|
134
|
-
|
|
135
|
-
|
|
156
|
+
env=env,
|
|
157
|
+
path=_register_device_endpoint(credentials),
|
|
158
|
+
credentials=credentials,
|
|
136
159
|
json=data,
|
|
137
|
-
base_url=base_url,
|
|
138
160
|
)
|
|
139
161
|
|
|
140
162
|
|
|
141
163
|
def update_device(
|
|
142
164
|
*,
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
base_url: Optional[str] = None,
|
|
165
|
+
credentials: Credentials,
|
|
166
|
+
env: Environment,
|
|
146
167
|
name: str,
|
|
147
168
|
device_id: str,
|
|
148
169
|
) -> Any:
|
|
@@ -153,15 +174,17 @@ def update_device(
|
|
|
153
174
|
}
|
|
154
175
|
return cloud_request(
|
|
155
176
|
method="PATCH",
|
|
156
|
-
|
|
157
|
-
|
|
177
|
+
env=env,
|
|
178
|
+
path=_update_device_endpoint(credentials, device_id),
|
|
179
|
+
credentials=credentials,
|
|
158
180
|
json=data,
|
|
159
|
-
base_url=base_url,
|
|
160
181
|
)
|
|
161
182
|
|
|
162
183
|
|
|
163
184
|
def list_devices(
|
|
164
|
-
*,
|
|
185
|
+
*,
|
|
186
|
+
credentials: Credentials,
|
|
187
|
+
env: Environment,
|
|
165
188
|
) -> list[Any]:
|
|
166
189
|
"""
|
|
167
190
|
List devices for the org (keys typically omitted).
|
|
@@ -172,19 +195,18 @@ def list_devices(
|
|
|
172
195
|
"""
|
|
173
196
|
return cloud_request(
|
|
174
197
|
method="GET",
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
198
|
+
env=env,
|
|
199
|
+
path=_list_devices_endpoint(credentials),
|
|
200
|
+
credentials=credentials,
|
|
178
201
|
)
|
|
179
202
|
|
|
180
203
|
|
|
181
204
|
def retrieve_packets(
|
|
182
205
|
*,
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
device_id:
|
|
206
|
+
credentials: Credentials,
|
|
207
|
+
env: Environment,
|
|
208
|
+
device_id: str,
|
|
186
209
|
days: int = 7,
|
|
187
|
-
base_url: Optional[str] = None,
|
|
188
210
|
) -> Any:
|
|
189
211
|
"""Fetch decrypted packets for a device."""
|
|
190
212
|
params = {"start": (int(time.time()) - (days * 24 * 60 * 60))}
|
|
@@ -192,19 +214,18 @@ def retrieve_packets(
|
|
|
192
214
|
params["device_id"] = device_id
|
|
193
215
|
return cloud_request(
|
|
194
216
|
method="GET",
|
|
195
|
-
|
|
196
|
-
|
|
217
|
+
env=env,
|
|
218
|
+
path=_retrieve_org_packets_endpoint(credentials),
|
|
219
|
+
credentials=credentials,
|
|
197
220
|
params=params,
|
|
198
|
-
base_url=base_url,
|
|
199
221
|
)
|
|
200
222
|
|
|
201
223
|
|
|
202
224
|
def ingest_packet(
|
|
203
225
|
*,
|
|
204
|
-
|
|
205
|
-
|
|
226
|
+
credentials: Credentials,
|
|
227
|
+
env: Environment,
|
|
206
228
|
packet: EncryptedPacket,
|
|
207
|
-
base_url: Optional[str] = None,
|
|
208
229
|
) -> Any:
|
|
209
230
|
body = {
|
|
210
231
|
"ble_locations": [
|
|
@@ -229,18 +250,17 @@ def ingest_packet(
|
|
|
229
250
|
}
|
|
230
251
|
return cloud_request(
|
|
231
252
|
method="POST",
|
|
232
|
-
|
|
233
|
-
|
|
253
|
+
env=env,
|
|
254
|
+
path=_ingest_packets_endpoint(credentials),
|
|
255
|
+
credentials=credentials,
|
|
234
256
|
json=body,
|
|
235
|
-
base_url=base_url,
|
|
236
257
|
)
|
|
237
258
|
|
|
238
259
|
|
|
239
260
|
def retrieve_org_metadata(
|
|
240
261
|
*,
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
base_url: Optional[str] = None,
|
|
262
|
+
credentials: Credentials,
|
|
263
|
+
env: Environment,
|
|
244
264
|
) -> Any:
|
|
245
265
|
"""
|
|
246
266
|
Get organizational metadata
|
|
@@ -251,7 +271,7 @@ def retrieve_org_metadata(
|
|
|
251
271
|
"""
|
|
252
272
|
return cloud_request(
|
|
253
273
|
method="GET",
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
274
|
+
env=env,
|
|
275
|
+
path=_retrieve_org_metadata_endpoint(credentials),
|
|
276
|
+
credentials=credentials,
|
|
257
277
|
)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
from typing import Optional
|
|
2
3
|
from Crypto.Cipher import AES
|
|
3
4
|
from Crypto.Hash import CMAC
|
|
4
5
|
from Crypto.Protocol.KDF import SP800_108_Counter
|
|
@@ -57,10 +58,8 @@ def decrypt(
|
|
|
57
58
|
) -> Optional[DecryptedPacket]:
|
|
58
59
|
ble_adv = encrypted_pkt.payload
|
|
59
60
|
seq_no = int.from_bytes(ble_adv[0:2], "big") & 0x3FF
|
|
60
|
-
device_id = ble_adv[2:6].hex()
|
|
61
61
|
auth_tag = ble_adv[6:10]
|
|
62
62
|
encrypted_payload = ble_adv[10:]
|
|
63
|
-
day_offset = 0
|
|
64
63
|
|
|
65
64
|
time_counter = int(datetime.now(timezone.utc).timestamp()) // 86400
|
|
66
65
|
|
|
@@ -69,8 +68,7 @@ def decrypt(
|
|
|
69
68
|
tag = _get_auth_tag(daily_key, encrypted_payload)
|
|
70
69
|
|
|
71
70
|
if tag == auth_tag:
|
|
72
|
-
|
|
73
|
-
nonce = _get_nonce(key, time_counter, seq_no)
|
|
71
|
+
nonce = _get_nonce(key, time_counter + t, seq_no)
|
|
74
72
|
decrypted_payload = _aes_decrypt(daily_key, nonce, encrypted_payload)
|
|
75
73
|
return DecryptedPacket(
|
|
76
74
|
timestamp=encrypted_pkt.timestamp,
|
|
@@ -40,6 +40,10 @@ class APITimeout(BackendError):
|
|
|
40
40
|
"""The API call exceeded its allowed timeout."""
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
class InvalidCredentialsError(BackendError):
|
|
44
|
+
"""Invalid credentials passed in"""
|
|
45
|
+
|
|
46
|
+
|
|
43
47
|
# Request/response semantics
|
|
44
48
|
class ValidationError(BackendError):
|
|
45
49
|
"""The request was invalid (schema/semantics)."""
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
# hubble/org.py
|
|
2
2
|
from __future__ import annotations
|
|
3
|
-
from dataclasses import dataclass
|
|
4
3
|
from typing import Optional, List
|
|
5
4
|
|
|
6
5
|
from . import cloud
|
|
7
|
-
from .packets import DecryptedPacket, Location
|
|
6
|
+
from .packets import DecryptedPacket, EncryptedPacket, Location
|
|
8
7
|
from .device import Device
|
|
9
|
-
from .errors import
|
|
8
|
+
from .errors import InvalidCredentialsError
|
|
10
9
|
|
|
11
10
|
|
|
12
11
|
class Organization:
|
|
@@ -15,42 +14,35 @@ class Organization:
|
|
|
15
14
|
Used to manage devices and fetch decrypted packets from the backend.
|
|
16
15
|
"""
|
|
17
16
|
|
|
18
|
-
org_id: str
|
|
19
|
-
api_token: str
|
|
20
|
-
|
|
21
|
-
api_base_url: str
|
|
22
|
-
env: str
|
|
23
17
|
name: str
|
|
24
18
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
self.api_token = api_token
|
|
19
|
+
credentials: cloud.Credentials
|
|
20
|
+
env: cloud.Environment
|
|
28
21
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
org_id: Optional(str) = None,
|
|
25
|
+
api_token: Optional(str) = None,
|
|
26
|
+
credentials: Optional(cloud.Credentials) = None,
|
|
27
|
+
) -> str:
|
|
28
|
+
if credentials:
|
|
29
|
+
self.credentials = credentials
|
|
30
|
+
else:
|
|
31
|
+
self.credentials = cloud.Credentials(org_id, api_token)
|
|
32
|
+
self.env = cloud.get_env_from_credentials(self.credentials)
|
|
33
|
+
if not self.env:
|
|
34
|
+
raise InvalidCredentialsError("Invalid credentials passed in.")
|
|
35
|
+
self.name = cloud.retrieve_org_metadata(
|
|
36
|
+
credentials=self.credentials, env=self.env
|
|
37
|
+
)["name"]
|
|
44
38
|
|
|
45
|
-
def register_device(self) -> Device:
|
|
39
|
+
def register_device(self, encryption: Optional[str] = None) -> Device:
|
|
46
40
|
"""
|
|
47
41
|
Register a new device in this organization and return it.
|
|
48
42
|
Returned Device will have an ID and provisioned key.
|
|
49
43
|
"""
|
|
50
44
|
resp = cloud.register_device(
|
|
51
|
-
|
|
52
|
-
api_token=self.api_token,
|
|
53
|
-
base_url=self.api_base_url,
|
|
45
|
+
credentials=self.credentials, env=self.env, encryption=encryption
|
|
54
46
|
)
|
|
55
47
|
# Currently, only registering a single device and taking the
|
|
56
48
|
# first in the returned list
|
|
@@ -63,11 +55,10 @@ class Organization:
|
|
|
63
55
|
Returned Device will have an ID and provisioned key.
|
|
64
56
|
"""
|
|
65
57
|
resp = cloud.update_device(
|
|
66
|
-
|
|
67
|
-
|
|
58
|
+
credentials=self.credentials,
|
|
59
|
+
env=self.env,
|
|
68
60
|
name=name,
|
|
69
61
|
device_id=device_id,
|
|
70
|
-
base_url=self.api_base_url,
|
|
71
62
|
)
|
|
72
63
|
return Device(id=resp["id"], name=resp["name"])
|
|
73
64
|
|
|
@@ -79,9 +70,7 @@ class Organization:
|
|
|
79
70
|
list[Device]
|
|
80
71
|
"""
|
|
81
72
|
|
|
82
|
-
payload = cloud.list_devices(
|
|
83
|
-
org_id=self.org_id, api_token=self.api_token, base_url=self.api_base_url
|
|
84
|
-
)
|
|
73
|
+
payload = cloud.list_devices(credentials=self.credentials, env=self.env)
|
|
85
74
|
raw_list = payload["devices"]
|
|
86
75
|
|
|
87
76
|
# Turn each JSON object into a Device
|
|
@@ -96,11 +85,10 @@ class Organization:
|
|
|
96
85
|
or None if none exists.
|
|
97
86
|
"""
|
|
98
87
|
resp = cloud.retrieve_packets(
|
|
99
|
-
|
|
100
|
-
|
|
88
|
+
credentials=self.credentials,
|
|
89
|
+
env=self.env,
|
|
101
90
|
device_id=device.id,
|
|
102
91
|
days=days,
|
|
103
|
-
base_url=self.api_base_url,
|
|
104
92
|
)
|
|
105
93
|
packets = []
|
|
106
94
|
for packet in resp["packets"]:
|
|
@@ -108,9 +96,9 @@ class Organization:
|
|
|
108
96
|
DecryptedPacket(
|
|
109
97
|
timestamp=int(packet["device"]["timestamp"]),
|
|
110
98
|
device_id=packet["device"]["id"],
|
|
111
|
-
device_name=
|
|
112
|
-
|
|
113
|
-
|
|
99
|
+
device_name=(
|
|
100
|
+
packet["device"]["name"] if "name" in packet["device"] else ""
|
|
101
|
+
),
|
|
114
102
|
location=Location(
|
|
115
103
|
lat=packet["location"]["latitude"],
|
|
116
104
|
lon=packet["location"]["longitude"],
|
|
@@ -126,8 +114,7 @@ class Organization:
|
|
|
126
114
|
|
|
127
115
|
def ingest_packet(self, packet: EncryptedPacket) -> None:
|
|
128
116
|
cloud.ingest_packet(
|
|
129
|
-
|
|
130
|
-
|
|
117
|
+
credentials=self.credentials,
|
|
118
|
+
env=self.env,
|
|
131
119
|
packet=packet,
|
|
132
|
-
base_url=self.api_base_url,
|
|
133
120
|
)
|
|
File without changes
|
|
File without changes
|