hb-client 0.5.3__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.
- hb_client/__init__.py +31 -0
- hb_client/hbclient.py +1094 -0
- hb_client-0.5.3.dist-info/METADATA +139 -0
- hb_client-0.5.3.dist-info/RECORD +8 -0
- hb_client-0.5.3.dist-info/WHEEL +5 -0
- hb_client-0.5.3.dist-info/entry_points.txt +2 -0
- hb_client-0.5.3.dist-info/licenses/LICENSE +17 -0
- hb_client-0.5.3.dist-info/top_level.txt +1 -0
hb_client/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
from .hbclient import (
|
|
4
|
+
CLI_NAME,
|
|
5
|
+
CONFIG_DIR_NAME,
|
|
6
|
+
HbClient,
|
|
7
|
+
HbConfig,
|
|
8
|
+
KeyManager,
|
|
9
|
+
cmd_login,
|
|
10
|
+
cmd_logout,
|
|
11
|
+
cmd_status,
|
|
12
|
+
parse_time_duration,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
__version__ = version(__name__)
|
|
17
|
+
except PackageNotFoundError:
|
|
18
|
+
__version__ = "unknown"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"CLI_NAME",
|
|
22
|
+
"CONFIG_DIR_NAME",
|
|
23
|
+
"HbClient",
|
|
24
|
+
"HbConfig",
|
|
25
|
+
"KeyManager",
|
|
26
|
+
"cmd_login",
|
|
27
|
+
"cmd_status",
|
|
28
|
+
"cmd_logout",
|
|
29
|
+
"parse_time_duration",
|
|
30
|
+
"__version__",
|
|
31
|
+
]
|
hb_client/hbclient.py
ADDED
|
@@ -0,0 +1,1094 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import base64
|
|
3
|
+
import getpass
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
import re
|
|
9
|
+
import socket
|
|
10
|
+
import struct
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
import zlib
|
|
16
|
+
from contextlib import suppress
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict
|
|
20
|
+
|
|
21
|
+
from filelock import FileLock, Timeout
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger(__name__)
|
|
24
|
+
log.addHandler(logging.NullHandler())
|
|
25
|
+
|
|
26
|
+
CLI_NAME = "hbclient"
|
|
27
|
+
CONFIG_DIR_NAME = "hbclient"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"CLI_NAME",
|
|
31
|
+
"CONFIG_DIR_NAME",
|
|
32
|
+
"HbClient",
|
|
33
|
+
"HbConfig",
|
|
34
|
+
"KeyManager",
|
|
35
|
+
"parse_time_duration",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
"""
|
|
39
|
+
hbclient — Secure Heartbeat Client Library
|
|
40
|
+
==========================================================
|
|
41
|
+
|
|
42
|
+
A secure, high-reliability heartbeat client designed to send encrypted status
|
|
43
|
+
updates to a central Nuclei monitoring server.
|
|
44
|
+
|
|
45
|
+
PyPI: https://pypi.org/project/hb-client
|
|
46
|
+
|
|
47
|
+
Key Features:
|
|
48
|
+
- **Security**: AES-GCM encryption for UDP packets with CRC32 integrity checks.
|
|
49
|
+
- **Authentication**: OAuth Device Flow for automatic key rotation.
|
|
50
|
+
- **Resilience**: Transparent DNS resolution, jittered retries, and atomic
|
|
51
|
+
file I/O to handle network instability or crashes gracefully.
|
|
52
|
+
- **Thread Safety**: File locking (fcntl) ensures safe multi-process access
|
|
53
|
+
to credential files.
|
|
54
|
+
|
|
55
|
+
Installation:
|
|
56
|
+
pip install hb-client
|
|
57
|
+
|
|
58
|
+
Usage:
|
|
59
|
+
from hb_client import HbClient, HbConfig
|
|
60
|
+
|
|
61
|
+
config = HbConfig(server="hb.example.com", serverport=8333)
|
|
62
|
+
client = HbClient(name="my-app", interval=60, config=config)
|
|
63
|
+
client.send(task="startup")
|
|
64
|
+
|
|
65
|
+
# CLI usage
|
|
66
|
+
# hbclient send --app my-app --task deploy --interval 60
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
71
|
+
except ImportError as e:
|
|
72
|
+
raise ImportError(
|
|
73
|
+
"Missing required cryptography library. Please run: pip install cryptography"
|
|
74
|
+
) from e
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class HbConfig:
|
|
79
|
+
"""
|
|
80
|
+
Configuration settings for the Heartbeat Client.
|
|
81
|
+
|
|
82
|
+
This dataclass defines default thresholds and intervals for heartbeat behavior,
|
|
83
|
+
network resolution frequency, and alert logic multipliers.
|
|
84
|
+
|
|
85
|
+
Attributes:
|
|
86
|
+
server (str): The hostname of the heartbeat server (UDP). Defaults to "hb".
|
|
87
|
+
serverport (int): The UDP port number for the server. Defaults to 8333.
|
|
88
|
+
debug (bool): If True, enables verbose logging output. Defaults to False.
|
|
89
|
+
MINIMUM_INTERVAL_SEC (int): Hard floor (30s) for sending heartbeats to prevent
|
|
90
|
+
network storming. Cannot be overridden by user inputs.
|
|
91
|
+
DNS_REFRESH_SEC (int): Interval in seconds between re-resolving the server's IP.
|
|
92
|
+
Default: 4 hours.
|
|
93
|
+
ALERT_INTERVAL_MULTIPLIER_LOW (float): Multiplier for alert threshold when
|
|
94
|
+
intervals < 1 day. Default: 2.25.
|
|
95
|
+
ALERT_INTERVAL_MULTIPLIER_HIGH (float): Multiplier for alert threshold when
|
|
96
|
+
intervals >= 1 day. Default: 1.25.
|
|
97
|
+
DUPE_SEND_DELAY_SEC (float | None): Optional delay in seconds before attempting
|
|
98
|
+
a duplicate send if the first fails. Value is clamped to [0.1, 5.0].
|
|
99
|
+
Default: None (no duplicate send).
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
server: str = "hb"
|
|
103
|
+
serverport: int = 8333
|
|
104
|
+
debug: bool = False
|
|
105
|
+
MINIMUM_INTERVAL_SEC: int = 30
|
|
106
|
+
DNS_REFRESH_SEC: int = 4 * 60 * 60
|
|
107
|
+
ALERT_INTERVAL_MULTIPLIER_LOW: float = 2.25
|
|
108
|
+
ALERT_INTERVAL_MULTIPLIER_HIGH: float = 1.25
|
|
109
|
+
DUPE_SEND_DELAY_SEC: float | None = None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class KeyManager:
|
|
113
|
+
"""
|
|
114
|
+
Manages encryption keys and secure credentials for the client session.
|
|
115
|
+
|
|
116
|
+
This class handles the lifecycle of API tokens and AES secrets, ensuring they are
|
|
117
|
+
stored securely on disk with strict permissions. It supports atomic writes to
|
|
118
|
+
prevent corruption during crashes and implements thread-safe loading using file locks.
|
|
119
|
+
|
|
120
|
+
Features:
|
|
121
|
+
- **Atomic Writes**: Uses a temporary file + `os.replace` pattern to ensure
|
|
122
|
+
the key file is never left in a partial state.
|
|
123
|
+
- **Hot-Reload**: Checks file modification times (`mtime`) to avoid unnecessary
|
|
124
|
+
disk I/O if keys haven't changed.
|
|
125
|
+
- **Rotation Logic**: Automatically detects near-expiration tokens (with jitter)
|
|
126
|
+
and attempts non-blocking rotation via the server's OAuth endpoint.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(self, server_url: str) -> None:
|
|
130
|
+
"""
|
|
131
|
+
Initialize the Key Manager.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
server_url (str): The base HTTPS URL of the server used for OAuth
|
|
135
|
+
token endpoints (e.g., https://hb.example.com).
|
|
136
|
+
"""
|
|
137
|
+
self.config_dir = self._config_dir()
|
|
138
|
+
self.key_file = os.path.join(self.config_dir, "keys.json")
|
|
139
|
+
self.server_url = server_url.rstrip("/")
|
|
140
|
+
self.keys: Dict[str, Any] = {}
|
|
141
|
+
self._last_mtime: float = 0.0
|
|
142
|
+
|
|
143
|
+
if os.path.isdir(self.config_dir):
|
|
144
|
+
os.chmod(self.config_dir, 0o700)
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def _config_dir() -> str:
|
|
148
|
+
"""Return the config directory for keys.json."""
|
|
149
|
+
xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
|
|
150
|
+
if xdg_config_home:
|
|
151
|
+
return str(Path(xdg_config_home).expanduser() / CONFIG_DIR_NAME)
|
|
152
|
+
|
|
153
|
+
posix_path = Path(os.path.expanduser(f"~/.config/{CONFIG_DIR_NAME}"))
|
|
154
|
+
with suppress(OSError):
|
|
155
|
+
posix_path.parent.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
return str(posix_path)
|
|
157
|
+
|
|
158
|
+
if os.name == "nt":
|
|
159
|
+
appdata = os.environ.get("APPDATA")
|
|
160
|
+
if appdata:
|
|
161
|
+
return str(Path(appdata) / CONFIG_DIR_NAME)
|
|
162
|
+
return str(Path.home() / "AppData" / "Roaming" / CONFIG_DIR_NAME)
|
|
163
|
+
if sys.platform == "darwin":
|
|
164
|
+
return str(
|
|
165
|
+
Path.home() / "Library" / "Application Support" / CONFIG_DIR_NAME
|
|
166
|
+
)
|
|
167
|
+
return str(Path.home() / ".config" / CONFIG_DIR_NAME)
|
|
168
|
+
|
|
169
|
+
def load(self, force: bool = False) -> bool:
|
|
170
|
+
"""
|
|
171
|
+
Load key material from disk into memory.
|
|
172
|
+
|
|
173
|
+
Optimized for performance by checking the file's modification time (`mtime`).
|
|
174
|
+
If the file hasn't changed since the last load and `force` is False,
|
|
175
|
+
it returns immediately without reading the disk.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
force (bool): If True, forces a re-read from disk even if mtime matches.
|
|
179
|
+
Used during login/logout or manual refresh operations.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
bool: True if keys were successfully loaded or are already current;
|
|
183
|
+
False if the file is missing, unreadable, or invalid JSON.
|
|
184
|
+
"""
|
|
185
|
+
if not os.path.exists(self.key_file):
|
|
186
|
+
return False
|
|
187
|
+
mtime = os.stat(self.key_file).st_mtime
|
|
188
|
+
if not force and mtime <= self._last_mtime:
|
|
189
|
+
return True
|
|
190
|
+
try:
|
|
191
|
+
with open(self.key_file, "r") as f:
|
|
192
|
+
self.keys = json.load(f)
|
|
193
|
+
self._last_mtime = mtime
|
|
194
|
+
return True
|
|
195
|
+
except (json.JSONDecodeError, IOError):
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
def _atomic_write(self, data: Dict[str, Any]) -> None:
|
|
199
|
+
"""
|
|
200
|
+
Write key data to disk atomically with strict file permissions.
|
|
201
|
+
|
|
202
|
+
This method ensures that the `keys.json` file is never left in a corrupted
|
|
203
|
+
or partial state. It writes to a temporary file with mode 0o600 (read/write
|
|
204
|
+
owner only), flushes and syncs the data, then atomically renames it over
|
|
205
|
+
the original.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
data (dict): The dictionary containing keys like 'access_token',
|
|
209
|
+
'aes_secret', 'key_id', etc.
|
|
210
|
+
|
|
211
|
+
Raises:
|
|
212
|
+
OSError: If the system fails to create or write to the temporary file.
|
|
213
|
+
"""
|
|
214
|
+
write_dir = self._config_dir()
|
|
215
|
+
os.makedirs(write_dir, exist_ok=True)
|
|
216
|
+
os.chmod(write_dir, 0o700)
|
|
217
|
+
write_key_file = os.path.join(write_dir, "keys.json")
|
|
218
|
+
tmp_file = write_key_file + ".tmp"
|
|
219
|
+
|
|
220
|
+
# Bypass default umask: force file creation with strict owner-only read/write
|
|
221
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
222
|
+
mode = 0o600 # -rw-------
|
|
223
|
+
fd = os.open(tmp_file, flags, mode)
|
|
224
|
+
|
|
225
|
+
with open(fd, "w") as f:
|
|
226
|
+
json.dump(data, f)
|
|
227
|
+
f.flush()
|
|
228
|
+
os.fsync(f.fileno())
|
|
229
|
+
|
|
230
|
+
# Atomic replace preserves the strict permissions of the tmp file
|
|
231
|
+
os.replace(tmp_file, write_key_file)
|
|
232
|
+
self.config_dir = write_dir
|
|
233
|
+
self.key_file = write_key_file
|
|
234
|
+
self._last_mtime = os.stat(self.key_file).st_mtime
|
|
235
|
+
|
|
236
|
+
def is_enrolled(self) -> bool:
|
|
237
|
+
"""Return True when an enrollment file exists on disk."""
|
|
238
|
+
return os.path.exists(self.key_file)
|
|
239
|
+
|
|
240
|
+
def has_valid_send_keys(self) -> bool:
|
|
241
|
+
"""Return True when key material is present and usable for encryption."""
|
|
242
|
+
if not self.keys:
|
|
243
|
+
return False
|
|
244
|
+
try:
|
|
245
|
+
key_id = self.keys.get("key_id")
|
|
246
|
+
aes_secret_b64 = self.keys.get("aes_secret")
|
|
247
|
+
if not isinstance(key_id, int) or not isinstance(aes_secret_b64, str):
|
|
248
|
+
return False
|
|
249
|
+
secret = base64.b64decode(aes_secret_b64)
|
|
250
|
+
return len(secret) in (16, 24, 32)
|
|
251
|
+
except (ValueError, TypeError):
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
def get_key_file_path(self) -> str:
|
|
255
|
+
"""Return the absolute path to the keys file."""
|
|
256
|
+
return os.path.abspath(self.key_file)
|
|
257
|
+
|
|
258
|
+
def is_expired(self) -> bool:
|
|
259
|
+
"""Return True when key expiration is known and in the past."""
|
|
260
|
+
expires_at = self.keys.get("expires_at")
|
|
261
|
+
if not isinstance(expires_at, (int, float)):
|
|
262
|
+
return False
|
|
263
|
+
return time.time() >= float(expires_at)
|
|
264
|
+
|
|
265
|
+
def needs_rotation(self) -> bool:
|
|
266
|
+
"""
|
|
267
|
+
Determine if the current keys are near expiration and require rotation.
|
|
268
|
+
|
|
269
|
+
Uses a randomized jitter window (7-10 days before expiry) to prevent
|
|
270
|
+
"thundering herd" problems where many clients try to rotate at the exact
|
|
271
|
+
same moment when a global token expires.
|
|
272
|
+
|
|
273
|
+
Always returns True for already-expired keys to support recovery scenarios
|
|
274
|
+
where the server admin has manually extended expiration.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
True if immediate or near-term rotation is required; False otherwise.
|
|
278
|
+
|
|
279
|
+
Example:
|
|
280
|
+
>>> km.keys = {"expires_at": time.time() + 86400} # expires tomorrow
|
|
281
|
+
>>> km.needs_rotation() # Likely True (within 7-10 day jitter window)
|
|
282
|
+
True
|
|
283
|
+
"""
|
|
284
|
+
if not self.keys:
|
|
285
|
+
return False
|
|
286
|
+
|
|
287
|
+
# Always attempt rotation for expired keys - the server may have extended
|
|
288
|
+
# the expiration manually, and we want to give clients a chance to recover.
|
|
289
|
+
if self.is_expired():
|
|
290
|
+
return True
|
|
291
|
+
|
|
292
|
+
expires_at_raw = self.keys.get("expires_at")
|
|
293
|
+
if not isinstance(expires_at_raw, (int, float)):
|
|
294
|
+
# Legacy fallback if an old file exists without the field
|
|
295
|
+
last_rotated_raw = self.keys.get("last_rotated_at", 0)
|
|
296
|
+
last_rotated_at = (
|
|
297
|
+
float(last_rotated_raw) if isinstance(last_rotated_raw, (int, float)) else 0.0
|
|
298
|
+
)
|
|
299
|
+
age = time.time() - last_rotated_at
|
|
300
|
+
return age > (30 * 86400)
|
|
301
|
+
|
|
302
|
+
# Jitter: Rotate 7 to 10 days BEFORE the server's exact expiration
|
|
303
|
+
jitter_seconds = random.uniform(7, 10) * 86400
|
|
304
|
+
|
|
305
|
+
expires_at = float(expires_at_raw)
|
|
306
|
+
return time.time() > (expires_at - jitter_seconds)
|
|
307
|
+
|
|
308
|
+
def rotate_optimistic(self) -> bool:
|
|
309
|
+
"""
|
|
310
|
+
Perform a non-blocking key rotation using the OAuth Device Flow.
|
|
311
|
+
|
|
312
|
+
Attempts to refresh credentials by exchanging the current ``access_token``
|
|
313
|
+
for a new set of tokens and AES secrets. Does not block indefinitely;
|
|
314
|
+
if the server is unreachable or takes too long, the client continues
|
|
315
|
+
operating with existing keys until they truly expire.
|
|
316
|
+
|
|
317
|
+
Workflow:
|
|
318
|
+
1. Check for an existing ``access_token`` to validate current session.
|
|
319
|
+
2. Call ``/api/auth/token/rotate/``.
|
|
320
|
+
3. If successful, update local cache and trigger atomic write.
|
|
321
|
+
4. If failed (network/auth), silently ignore to ensure high availability.
|
|
322
|
+
|
|
323
|
+
Note:
|
|
324
|
+
Uses a file lock (non-blocking) to prevent concurrent rotation by
|
|
325
|
+
multiple processes. Returns immediately if the lock is held.
|
|
326
|
+
"""
|
|
327
|
+
if not os.path.exists(self.key_file):
|
|
328
|
+
return False
|
|
329
|
+
|
|
330
|
+
lock_path = self.key_file + ".lock"
|
|
331
|
+
lock = FileLock(lock_path, timeout=0)
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
with lock:
|
|
335
|
+
with open(self.key_file, "r") as f:
|
|
336
|
+
current_keys = json.load(f)
|
|
337
|
+
|
|
338
|
+
# Double-check: another process may have rotated while we waited for the lock.
|
|
339
|
+
# Re-evaluate expiration using the freshly-read keys.
|
|
340
|
+
expires_at_raw = current_keys.get("expires_at")
|
|
341
|
+
if isinstance(expires_at_raw, (int, float)):
|
|
342
|
+
expires_at = float(expires_at_raw)
|
|
343
|
+
# Always proceed if keys are expired (server may have extended them)
|
|
344
|
+
if time.time() < expires_at:
|
|
345
|
+
jitter_seconds = random.uniform(7, 10) * 86400
|
|
346
|
+
if time.time() <= (expires_at - jitter_seconds):
|
|
347
|
+
# Keys were already refreshed by another process; no rotation needed.
|
|
348
|
+
self.keys = current_keys
|
|
349
|
+
self._last_mtime = os.stat(self.key_file).st_mtime
|
|
350
|
+
return True
|
|
351
|
+
|
|
352
|
+
req = urllib.request.Request(
|
|
353
|
+
f"{self.server_url}/api/auth/token/rotate/",
|
|
354
|
+
headers={
|
|
355
|
+
"Authorization": f"Bearer {current_keys.get('access_token')}"
|
|
356
|
+
},
|
|
357
|
+
method="POST",
|
|
358
|
+
)
|
|
359
|
+
resp = urllib.request.urlopen(req, timeout=3)
|
|
360
|
+
new_data = json.loads(resp.read().decode())
|
|
361
|
+
current_keys.update(
|
|
362
|
+
{
|
|
363
|
+
"access_token": new_data["access_token"],
|
|
364
|
+
"aes_secret": new_data["aes_secret"],
|
|
365
|
+
"key_id": new_data["key_id"],
|
|
366
|
+
"expires_at": new_data.get("expires_at"),
|
|
367
|
+
"last_rotated_at": int(time.time()),
|
|
368
|
+
}
|
|
369
|
+
)
|
|
370
|
+
self._atomic_write(current_keys)
|
|
371
|
+
self.keys = current_keys
|
|
372
|
+
return True
|
|
373
|
+
except Timeout:
|
|
374
|
+
# Another process is rotating right now
|
|
375
|
+
return False
|
|
376
|
+
except (urllib.error.URLError, OSError, KeyError, json.JSONDecodeError) as e:
|
|
377
|
+
# Fail open: data plane continues with old keys.
|
|
378
|
+
# It's better to log this if a logging framework were in use.
|
|
379
|
+
log.warning("Failed to rotate key during optimistic check: %s", e)
|
|
380
|
+
return False
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
default_hb_config = HbConfig()
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class HbClient:
|
|
387
|
+
"""
|
|
388
|
+
Heartbeat client for sending encrypted status updates to a Nuclei monitoring server.
|
|
389
|
+
|
|
390
|
+
Orchestrates the heartbeat cycle including DNS resolution, key management,
|
|
391
|
+
payload encryption (AES-GCM), and UDP transmission with optional duplicate
|
|
392
|
+
send for reliability.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
name: Application or service name identifying this client.
|
|
396
|
+
interval: Expected interval between heartbeats in seconds.
|
|
397
|
+
alert_after: Threshold for alerting if no heartbeat is received.
|
|
398
|
+
If None, calculated based on ``interval`` and config multipliers.
|
|
399
|
+
task: Optional specific task name associated with the heartbeat.
|
|
400
|
+
version: Optional version string of the application.
|
|
401
|
+
port: The listening port of the application being monitored.
|
|
402
|
+
config: Configuration object for global settings (DNS, crypto, etc.).
|
|
403
|
+
Defaults to a global singleton if not provided.
|
|
404
|
+
blocking: If True, adds a small sleep after sending to prevent tight looping.
|
|
405
|
+
**kwargs: Overrides for server hostname (``servername``), port
|
|
406
|
+
(``serverport``), and HTTPS URL (``server_url``).
|
|
407
|
+
|
|
408
|
+
Attributes:
|
|
409
|
+
cfg: The active configuration object.
|
|
410
|
+
server_url: The constructed HTTPS URL for the backend API.
|
|
411
|
+
myhostname: Fully qualified domain name of the current machine.
|
|
412
|
+
server_ips: Set of resolved IP addresses for the server.
|
|
413
|
+
key_manager: Instance handling credential loading and rotation.
|
|
414
|
+
|
|
415
|
+
Example:
|
|
416
|
+
>>> from hb_client import HbClient, HbConfig
|
|
417
|
+
>>> config = HbConfig(server="hb.example.com")
|
|
418
|
+
>>> client = HbClient(name="my-app", interval=60, config=config)
|
|
419
|
+
>>> client.send(task="deployment") # Returns True on success
|
|
420
|
+
True
|
|
421
|
+
"""
|
|
422
|
+
|
|
423
|
+
def __init__(
|
|
424
|
+
self,
|
|
425
|
+
name: str,
|
|
426
|
+
interval: int,
|
|
427
|
+
alert_after: int | None = None,
|
|
428
|
+
task: str | None = None,
|
|
429
|
+
version: str | None = None,
|
|
430
|
+
port: int | None = None,
|
|
431
|
+
config: HbConfig | None = None,
|
|
432
|
+
blocking: bool = True,
|
|
433
|
+
servername: str | None = None,
|
|
434
|
+
serverport: int | None = None,
|
|
435
|
+
server_url: str | None = None,
|
|
436
|
+
strict_security: bool = True,
|
|
437
|
+
):
|
|
438
|
+
self.cfg = config or default_hb_config
|
|
439
|
+
self.servername = servername or self.cfg.server
|
|
440
|
+
self.serverport = serverport or self.cfg.serverport
|
|
441
|
+
self.server_url = server_url or f"https://{self.servername}:{self.serverport}"
|
|
442
|
+
self.strict_security = strict_security
|
|
443
|
+
|
|
444
|
+
self.blocking_delay: float = 0.1 if blocking else 0.0
|
|
445
|
+
self.interval = interval
|
|
446
|
+
self.alert_after = alert_after or int(
|
|
447
|
+
interval
|
|
448
|
+
* (
|
|
449
|
+
self.cfg.ALERT_INTERVAL_MULTIPLIER_LOW
|
|
450
|
+
if interval < 86400
|
|
451
|
+
else self.cfg.ALERT_INTERVAL_MULTIPLIER_HIGH
|
|
452
|
+
)
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
self.myhostname = socket.getfqdn()
|
|
456
|
+
self.server_ips: set[str] = set()
|
|
457
|
+
self._last_dns_resolve: float = 0.0
|
|
458
|
+
self._update_dns()
|
|
459
|
+
|
|
460
|
+
self.name = name
|
|
461
|
+
self.port = port
|
|
462
|
+
self.task = task
|
|
463
|
+
self.version = version
|
|
464
|
+
self._last_sent_hb: float = 0.0
|
|
465
|
+
self.key_manager = KeyManager(self.server_url)
|
|
466
|
+
self._sock: socket.socket | None = None
|
|
467
|
+
self._initialize_security()
|
|
468
|
+
|
|
469
|
+
def _initialize_security(self) -> None:
|
|
470
|
+
"""Enforce secure-by-default behavior during client construction."""
|
|
471
|
+
loaded = self.key_manager.load(force=True)
|
|
472
|
+
has_keys = loaded and self.key_manager.has_valid_send_keys()
|
|
473
|
+
if not has_keys:
|
|
474
|
+
if self.strict_security:
|
|
475
|
+
raise RuntimeError(
|
|
476
|
+
"Strict security mode requires enrolled valid keys. "
|
|
477
|
+
f"Run '{CLI_NAME} login' first."
|
|
478
|
+
)
|
|
479
|
+
return
|
|
480
|
+
|
|
481
|
+
# Attempt rotation when stale; strict mode fails only when expired + refresh failed.
|
|
482
|
+
if self.key_manager.needs_rotation():
|
|
483
|
+
rotated = self.key_manager.rotate_optimistic()
|
|
484
|
+
if not rotated:
|
|
485
|
+
self.key_manager.load(force=True)
|
|
486
|
+
if self.strict_security and self.key_manager.is_expired():
|
|
487
|
+
raise RuntimeError(
|
|
488
|
+
"Key is expired and refresh failed in strict security mode. "
|
|
489
|
+
"Re-run enrollment or restore server connectivity."
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
def _get_socket(self) -> socket.socket:
|
|
493
|
+
"""Reuse a UDP socket for this client instance."""
|
|
494
|
+
if self._sock is None:
|
|
495
|
+
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
496
|
+
self._sock.settimeout(None)
|
|
497
|
+
return self._sock
|
|
498
|
+
|
|
499
|
+
def close(self) -> None:
|
|
500
|
+
"""Close the client's UDP socket."""
|
|
501
|
+
if self._sock is not None:
|
|
502
|
+
with suppress(OSError):
|
|
503
|
+
self._sock.close()
|
|
504
|
+
self._sock = None
|
|
505
|
+
|
|
506
|
+
def _update_dns(self, ignore_errors: bool = False) -> bool:
|
|
507
|
+
"""
|
|
508
|
+
Refresh the list of IP addresses for the configured server hostname.
|
|
509
|
+
|
|
510
|
+
This method checks if the DNS cache (stored in `_last_dns_resolve`) is stale.
|
|
511
|
+
If so, it performs a fresh `gethostbyname_ex` lookup to handle dynamic IP changes.
|
|
512
|
+
|
|
513
|
+
Args:
|
|
514
|
+
ignore_errors (bool): If True, suppresses exceptions and returns False on failure.
|
|
515
|
+
If False, an assertion ensures `server_ips` remains valid.
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
bool: True if the DNS list was successfully updated or was still fresh;
|
|
519
|
+
False if no update occurred or an error happened with `ignore_errors=True`.
|
|
520
|
+
"""
|
|
521
|
+
if time.time() - self._last_dns_resolve < self.cfg.DNS_REFRESH_SEC:
|
|
522
|
+
return False
|
|
523
|
+
log.debug("refreshing DNS lookup for %s", self.servername)
|
|
524
|
+
try:
|
|
525
|
+
_, _, new_server_ips = socket.gethostbyname_ex(self.servername)
|
|
526
|
+
if new_server_ips:
|
|
527
|
+
if set(new_server_ips) == self.server_ips:
|
|
528
|
+
log.debug(
|
|
529
|
+
"... no changes for %s (%d IPs)",
|
|
530
|
+
self.servername,
|
|
531
|
+
len(self.server_ips),
|
|
532
|
+
)
|
|
533
|
+
else:
|
|
534
|
+
log.debug(
|
|
535
|
+
"... updated for %s from %s to -> %s",
|
|
536
|
+
self.servername,
|
|
537
|
+
self.server_ips,
|
|
538
|
+
new_server_ips,
|
|
539
|
+
)
|
|
540
|
+
self.server_ips = set(new_server_ips)
|
|
541
|
+
self._last_dns_resolve = time.time()
|
|
542
|
+
except Exception as exc:
|
|
543
|
+
log.warning(
|
|
544
|
+
"FAILED DNS lookup for %s: %s (%s)",
|
|
545
|
+
self.servername,
|
|
546
|
+
exc,
|
|
547
|
+
type(exc).__name__,
|
|
548
|
+
)
|
|
549
|
+
if not ignore_errors:
|
|
550
|
+
raise RuntimeError(
|
|
551
|
+
f"DNS resolution failed for '{self.servername}'. "
|
|
552
|
+
f"Server IPs remain at {self.server_ips}. "
|
|
553
|
+
"Check that the server hostname is resolvable."
|
|
554
|
+
)
|
|
555
|
+
else:
|
|
556
|
+
return False
|
|
557
|
+
return True
|
|
558
|
+
|
|
559
|
+
def make_message(self) -> Dict[str, Any]:
|
|
560
|
+
"""
|
|
561
|
+
Construct the raw heartbeat metadata dictionary.
|
|
562
|
+
|
|
563
|
+
Returns a JSON-compatible dict containing essential client identification
|
|
564
|
+
and status data.
|
|
565
|
+
|
|
566
|
+
Returns:
|
|
567
|
+
Dictionary with keys: ``h`` (hostname), ``n`` (name), ``i`` (interval),
|
|
568
|
+
``@`` (timestamp), ``!`` (alert threshold), and optional ``t`` (task),
|
|
569
|
+
``v`` (version), ``p`` (port).
|
|
570
|
+
|
|
571
|
+
Example:
|
|
572
|
+
>>> client = HbClient(name="test", interval=60)
|
|
573
|
+
>>> msg = client.make_message()
|
|
574
|
+
>>> "n" in msg and "i" in msg and "@" in msg
|
|
575
|
+
True
|
|
576
|
+
"""
|
|
577
|
+
metadata = {
|
|
578
|
+
"h": self.myhostname,
|
|
579
|
+
"n": self.name,
|
|
580
|
+
"i": self.interval,
|
|
581
|
+
"@": int(time.time()),
|
|
582
|
+
"!": int(self.alert_after),
|
|
583
|
+
}
|
|
584
|
+
if self.task:
|
|
585
|
+
metadata["t"] = self.task
|
|
586
|
+
if self.version:
|
|
587
|
+
metadata["v"] = self.version
|
|
588
|
+
if self.port is not None:
|
|
589
|
+
metadata["p"] = self.port
|
|
590
|
+
return metadata
|
|
591
|
+
|
|
592
|
+
def send(
|
|
593
|
+
self, final_report: str | None = None, strict_interval: bool = False
|
|
594
|
+
) -> bool:
|
|
595
|
+
"""
|
|
596
|
+
Construct, encrypt, and transmit the heartbeat packet via UDP.
|
|
597
|
+
|
|
598
|
+
This is the core execution method. It performs the following steps:
|
|
599
|
+
1. Refreshes DNS if necessary.
|
|
600
|
+
2. Enforces minimum interval constraints to prevent flooding.
|
|
601
|
+
3. Loads keys from disk and triggers rotation if expiration is near.
|
|
602
|
+
4. Serializes metadata (and optional final report) to JSON.
|
|
603
|
+
5. Encrypts the payload using AES-GCM if valid keys exist; otherwise sends plaintext.
|
|
604
|
+
6. Packs the binary packet with headers (Magic, Version, KeyID), Nonce, and CRC32.
|
|
605
|
+
7. Sends the packet to all known server IPs. Implements a "duplex" send strategy
|
|
606
|
+
for reliability by sending twice with a delay if configured.
|
|
607
|
+
|
|
608
|
+
Args:
|
|
609
|
+
final_report (str | None): An optional status message to include at the end of the session.
|
|
610
|
+
If longer than 1024 chars, it is truncated and marked.
|
|
611
|
+
strict_interval (bool): If True, strictly enforces the configured `interval`
|
|
612
|
+
rather than the minimum safety interval.
|
|
613
|
+
|
|
614
|
+
Returns:
|
|
615
|
+
bool: True if the packet was successfully sent to at least one server IP; False otherwise.
|
|
616
|
+
"""
|
|
617
|
+
self._update_dns(ignore_errors=True)
|
|
618
|
+
since_last_hb = time.time() - self._last_sent_hb
|
|
619
|
+
if since_last_hb < self.cfg.MINIMUM_INTERVAL_SEC:
|
|
620
|
+
return False
|
|
621
|
+
if strict_interval and since_last_hb < self.interval:
|
|
622
|
+
return False
|
|
623
|
+
|
|
624
|
+
# Hot Reload & Rotate
|
|
625
|
+
self.key_manager.load()
|
|
626
|
+
if self.key_manager.needs_rotation():
|
|
627
|
+
rotated = self.key_manager.rotate_optimistic()
|
|
628
|
+
if not rotated:
|
|
629
|
+
self.key_manager.load(force=True)
|
|
630
|
+
if self.strict_security and self.key_manager.is_expired():
|
|
631
|
+
raise RuntimeError(
|
|
632
|
+
"Key rotation failed and key is expired in strict security mode."
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
metadata = self.make_message()
|
|
636
|
+
if final_report:
|
|
637
|
+
metadata["f"] = (
|
|
638
|
+
final_report[0:1000] + f" (TRUNCATED {1000-len(final_report)} bytes ...)"
|
|
639
|
+
if len(final_report) > 1000
|
|
640
|
+
else final_report
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
json_bytes = json.dumps(metadata, allow_nan=False).encode("utf-8")
|
|
644
|
+
|
|
645
|
+
# Binary Packing vs Cleartext Fallback
|
|
646
|
+
if self.key_manager.has_valid_send_keys():
|
|
647
|
+
key_id = self.key_manager.keys["key_id"]
|
|
648
|
+
aes_secret = base64.b64decode(self.key_manager.keys["aes_secret"])
|
|
649
|
+
nonce = os.urandom(12)
|
|
650
|
+
aesgcm = AESGCM(aes_secret)
|
|
651
|
+
# Encrypt payload; associated_data=None
|
|
652
|
+
encrypted_data = aesgcm.encrypt(nonce, json_bytes, associated_data=None)
|
|
653
|
+
|
|
654
|
+
# Header: Magic(0xDB, 0x01) + KeyID (4 bytes Big Endian)
|
|
655
|
+
header = struct.pack(">BBI", 0xDB, 0x01, key_id)
|
|
656
|
+
payload_without_crc = header + nonce + encrypted_data
|
|
657
|
+
# Append CRC32 checksum
|
|
658
|
+
final_packet = payload_without_crc + struct.pack(
|
|
659
|
+
">I", zlib.crc32(payload_without_crc) & 0xFFFFFFFF
|
|
660
|
+
)
|
|
661
|
+
else:
|
|
662
|
+
if self.strict_security:
|
|
663
|
+
raise RuntimeError(
|
|
664
|
+
"Strict security mode forbids plaintext heartbeat transmission "
|
|
665
|
+
"without valid key material."
|
|
666
|
+
)
|
|
667
|
+
if self.key_manager.is_enrolled():
|
|
668
|
+
key_path = self.key_manager.get_key_file_path()
|
|
669
|
+
raise RuntimeError(
|
|
670
|
+
f"Enrollment exists but key material is invalid at {key_path}; "
|
|
671
|
+
"refusing plaintext fallback. Please re-login or delete the old keys."
|
|
672
|
+
)
|
|
673
|
+
# Legacy non-strict mode: allow bootstrap plaintext before enrollment.
|
|
674
|
+
final_packet = json_bytes
|
|
675
|
+
|
|
676
|
+
sock = self._get_socket()
|
|
677
|
+
|
|
678
|
+
def deliver_it() -> bool:
|
|
679
|
+
was_sent = False
|
|
680
|
+
for dest_ip in self.server_ips:
|
|
681
|
+
try:
|
|
682
|
+
sock.sendto(final_packet, (dest_ip, self.serverport))
|
|
683
|
+
was_sent = True
|
|
684
|
+
self._last_sent_hb = time.time()
|
|
685
|
+
except socket.timeout:
|
|
686
|
+
pass # Expected for unreachable hosts in this context
|
|
687
|
+
return was_sent
|
|
688
|
+
|
|
689
|
+
# Primary send attempt
|
|
690
|
+
was_sent = deliver_it()
|
|
691
|
+
|
|
692
|
+
# Optional "Dupe" send for redundancy
|
|
693
|
+
if self.cfg.DUPE_SEND_DELAY_SEC:
|
|
694
|
+
time.sleep(max(0.1, min(self.cfg.DUPE_SEND_DELAY_SEC, 5.0)))
|
|
695
|
+
was_sent = deliver_it() or was_sent
|
|
696
|
+
|
|
697
|
+
time.sleep(self.blocking_delay)
|
|
698
|
+
return was_sent
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def cmd_login(args: "argparse.Namespace") -> None:
|
|
702
|
+
"""
|
|
703
|
+
Execute the OAuth Device Flow to enroll the client with the server.
|
|
704
|
+
|
|
705
|
+
Authenticates the client by:
|
|
706
|
+
1. Requesting a ``device_code`` and ``user_code`` from the server.
|
|
707
|
+
2. Prompting the user to visit a URL and enter the code.
|
|
708
|
+
3. Polling the server for approval status (with 10-minute timeout).
|
|
709
|
+
4. Saving the access token and secrets atomically on success.
|
|
710
|
+
|
|
711
|
+
Args:
|
|
712
|
+
args: Parsed command-line namespace containing ``server_url``.
|
|
713
|
+
|
|
714
|
+
Raises:
|
|
715
|
+
SystemExit: If the server is unreachable, the code is incorrect,
|
|
716
|
+
polling times out after 10 minutes, or the user provides invalid input.
|
|
717
|
+
|
|
718
|
+
Example:
|
|
719
|
+
# On the CLI:
|
|
720
|
+
# hbclient --server-url https://hb.example.com:8333 login
|
|
721
|
+
#
|
|
722
|
+
# Visit https://hb.example.com/verify, enter ABCD-1234, wait for approval.
|
|
723
|
+
"""
|
|
724
|
+
def _detect_local_username() -> str:
|
|
725
|
+
# Prefer POSIX euid-root naming when available.
|
|
726
|
+
try:
|
|
727
|
+
if hasattr(os, "geteuid") and os.geteuid() == 0:
|
|
728
|
+
return "root"
|
|
729
|
+
except OSError:
|
|
730
|
+
pass
|
|
731
|
+
|
|
732
|
+
for resolver in (
|
|
733
|
+
lambda: os.getlogin(),
|
|
734
|
+
lambda: getpass.getuser(),
|
|
735
|
+
lambda: os.environ.get("USER"),
|
|
736
|
+
lambda: os.environ.get("USERNAME"),
|
|
737
|
+
):
|
|
738
|
+
try:
|
|
739
|
+
value = resolver()
|
|
740
|
+
if value:
|
|
741
|
+
return str(value)
|
|
742
|
+
except Exception:
|
|
743
|
+
continue
|
|
744
|
+
return "None"
|
|
745
|
+
|
|
746
|
+
server_url = args.server_url.rstrip("/")
|
|
747
|
+
km = KeyManager(server_url)
|
|
748
|
+
my_hostname = socket.getfqdn()
|
|
749
|
+
my_username = _detect_local_username()
|
|
750
|
+
req = urllib.request.Request(
|
|
751
|
+
f"{server_url}/api/auth/device/init/",
|
|
752
|
+
data=json.dumps({"client_name": f"{my_username}@{my_hostname}"}).encode(),
|
|
753
|
+
headers={"Content-Type": "application/json"},
|
|
754
|
+
method="POST",
|
|
755
|
+
)
|
|
756
|
+
try:
|
|
757
|
+
data = json.loads(urllib.request.urlopen(req).read().decode())
|
|
758
|
+
except (urllib.error.URLError, OSError) as e:
|
|
759
|
+
print(f"Failed to contact server: {e}", file=sys.stderr)
|
|
760
|
+
sys.exit(1)
|
|
761
|
+
|
|
762
|
+
print(f"\n1. Please visit: {data['verification_uri']}")
|
|
763
|
+
print(f"2. Enter code: {data['user_code']}\n")
|
|
764
|
+
print("Waiting for approval...", end="", flush=True)
|
|
765
|
+
|
|
766
|
+
poll_start = time.time()
|
|
767
|
+
while True:
|
|
768
|
+
if poll_start + 600 < time.time():
|
|
769
|
+
print(
|
|
770
|
+
"\nAuth polling timed out after 10 minutes. The server may be unreachable."
|
|
771
|
+
)
|
|
772
|
+
sys.exit(1)
|
|
773
|
+
time.sleep(data.get("interval", 5))
|
|
774
|
+
print(".", end="", flush=True)
|
|
775
|
+
req = urllib.request.Request(
|
|
776
|
+
f"{server_url}/api/auth/device/poll/",
|
|
777
|
+
data=json.dumps({"device_code": data["device_code"]}).encode(),
|
|
778
|
+
headers={"Content-Type": "application/json"},
|
|
779
|
+
method="POST",
|
|
780
|
+
)
|
|
781
|
+
try:
|
|
782
|
+
success_data = json.loads(
|
|
783
|
+
urllib.request.urlopen(req, timeout=10).read().decode()
|
|
784
|
+
)
|
|
785
|
+
break
|
|
786
|
+
except urllib.error.HTTPError as e:
|
|
787
|
+
if e.code == 400:
|
|
788
|
+
err_data = json.loads(e.read().decode())
|
|
789
|
+
if err_data.get("error") == "authorization_pending":
|
|
790
|
+
continue
|
|
791
|
+
else:
|
|
792
|
+
print(f"\nAuth failed: {err_data.get('error')}")
|
|
793
|
+
sys.exit(1)
|
|
794
|
+
print(f"\nServer error: {e}")
|
|
795
|
+
sys.exit(1)
|
|
796
|
+
|
|
797
|
+
success_data["last_rotated_at"] = int(time.time())
|
|
798
|
+
km._atomic_write(success_data)
|
|
799
|
+
print(f"\n✅ Successfully enrolled! Keys saved to {km.key_file}")
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def cmd_status(args: "argparse.Namespace") -> None:
|
|
803
|
+
"""
|
|
804
|
+
Display the current authentication status and key expiration details.
|
|
805
|
+
|
|
806
|
+
Prints:
|
|
807
|
+
- The configured Server URL.
|
|
808
|
+
- The active Key ID.
|
|
809
|
+
- Time remaining until expiration, or age of the last rotation.
|
|
810
|
+
|
|
811
|
+
Args:
|
|
812
|
+
args: Parsed command-line namespace containing ``server_url``.
|
|
813
|
+
|
|
814
|
+
Example:
|
|
815
|
+
# hbclient --server-url https://hb.example.com:8333 status
|
|
816
|
+
# Server URL: https://hb.example.com:8333
|
|
817
|
+
# Active Key ID: 42
|
|
818
|
+
# Keys expire in: 12.3 days
|
|
819
|
+
"""
|
|
820
|
+
km = KeyManager(args.server_url)
|
|
821
|
+
if not km.load():
|
|
822
|
+
print(f"Not enrolled. Run '{CLI_NAME} login' first.")
|
|
823
|
+
return
|
|
824
|
+
print(f"Server URL: {args.server_url}")
|
|
825
|
+
print(f"Active Key ID: {km.keys.get('key_id')}")
|
|
826
|
+
|
|
827
|
+
expires_at = km.keys.get("expires_at")
|
|
828
|
+
if expires_at:
|
|
829
|
+
days_left = (expires_at - time.time()) / 86400
|
|
830
|
+
print(f"Keys expire in: {days_left:.1f} days")
|
|
831
|
+
else:
|
|
832
|
+
age = (time.time() - km.keys.get("last_rotated_at", 0)) / 86400
|
|
833
|
+
print(f"Keys last rotated: {age:.1f} days ago")
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def cmd_logout(args: "argparse.Namespace") -> None:
|
|
837
|
+
"""
|
|
838
|
+
Revoke credentials on the server and delete local key files.
|
|
839
|
+
|
|
840
|
+
Ensures a clean session termination by:
|
|
841
|
+
1. Calling the server's revocation endpoint with the current access token.
|
|
842
|
+
2. Deleting the local ``keys.json`` file.
|
|
843
|
+
|
|
844
|
+
If the server is unreachable, the operation fails unless ``--force`` is passed,
|
|
845
|
+
which allows local key destruction without server confirmation.
|
|
846
|
+
|
|
847
|
+
Args:
|
|
848
|
+
args: Parsed arguments including ``server_url`` and optional ``force``.
|
|
849
|
+
|
|
850
|
+
Raises:
|
|
851
|
+
SystemExit: If revocation fails and ``--force`` is not set.
|
|
852
|
+
|
|
853
|
+
Example:
|
|
854
|
+
# hbclient --server-url https://hb.example.com:8333 logout
|
|
855
|
+
# hbclient --server-url https://hb.example.com:8333 logout --force
|
|
856
|
+
"""
|
|
857
|
+
km = KeyManager(args.server_url)
|
|
858
|
+
if not km.load():
|
|
859
|
+
print("No active session found.")
|
|
860
|
+
return
|
|
861
|
+
|
|
862
|
+
try:
|
|
863
|
+
req = urllib.request.Request(
|
|
864
|
+
f"{args.server_url.rstrip('/')}/api/auth/token/revoke/",
|
|
865
|
+
headers={"Authorization": f"Bearer {km.keys.get('access_token')}"},
|
|
866
|
+
method="POST",
|
|
867
|
+
)
|
|
868
|
+
urllib.request.urlopen(req, timeout=10)
|
|
869
|
+
print("✅ Server successfully revoked access.")
|
|
870
|
+
except (urllib.error.URLError, OSError) as e:
|
|
871
|
+
print(f"❌ Server revocation failed: {e}", file=sys.stderr)
|
|
872
|
+
if not getattr(args, "force", False):
|
|
873
|
+
print("Aborting. The server must be reachable to securely revoke the token.")
|
|
874
|
+
print("Use --force to delete local keys anyway, or check your connection.")
|
|
875
|
+
sys.exit(1)
|
|
876
|
+
print("⚠️ Proceeding with local key destruction due to --force flag.")
|
|
877
|
+
|
|
878
|
+
try:
|
|
879
|
+
os.remove(km.key_file)
|
|
880
|
+
print("✅ Local keys destroyed.")
|
|
881
|
+
except OSError:
|
|
882
|
+
pass
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def parse_time_duration(duration_str: str) -> int:
|
|
886
|
+
"""
|
|
887
|
+
Parse a human-readable time duration string into seconds.
|
|
888
|
+
|
|
889
|
+
Supported formats:
|
|
890
|
+
- Naked numbers (e.g., "300") -> treated as seconds
|
|
891
|
+
- Numbers with suffixes: s, m, h, d, w, M, y
|
|
892
|
+
(seconds, minutes, hours, days, weeks, months, years)
|
|
893
|
+
|
|
894
|
+
Args:
|
|
895
|
+
duration_str: A string like "1h", "5m", "1.5d", "3w", "1M", "1.4y", or "300"
|
|
896
|
+
|
|
897
|
+
Returns:
|
|
898
|
+
An integer > 0 representing the number of seconds
|
|
899
|
+
|
|
900
|
+
Raises:
|
|
901
|
+
ValueError: If the input cannot be parsed or results in non-positive value
|
|
902
|
+
"""
|
|
903
|
+
if not duration_str or not isinstance(duration_str, str):
|
|
904
|
+
raise ValueError("Input must be a non-empty string")
|
|
905
|
+
|
|
906
|
+
duration_str = duration_str.strip()
|
|
907
|
+
|
|
908
|
+
# Define conversion factors to seconds
|
|
909
|
+
conversions = {
|
|
910
|
+
"s": 1, # seconds
|
|
911
|
+
"m": 60, # minutes
|
|
912
|
+
"h": 3600, # hours
|
|
913
|
+
"d": 86400, # days
|
|
914
|
+
"w": 604800, # weeks (7 * 86400)
|
|
915
|
+
"M": 2592000, # months (30 * 86400)
|
|
916
|
+
"y": 31536000, # years (365 * 86400)
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
# Match number (integer or decimal) followed by optional unit suffix
|
|
920
|
+
pattern = r"^(\d+(?:\.\d+)?)([smhdwMy])?$"
|
|
921
|
+
match = re.match(pattern, duration_str)
|
|
922
|
+
|
|
923
|
+
if not match:
|
|
924
|
+
raise ValueError(
|
|
925
|
+
f"Invalid duration format: '{duration_str}'. "
|
|
926
|
+
"Expected a number optionally followed by a unit suffix. "
|
|
927
|
+
"Valid suffixes are: s (seconds), m (minutes), h (hours), "
|
|
928
|
+
"d (days), w (weeks), M (months, 30 days), y (years). "
|
|
929
|
+
"Examples: '5m', '1.5h', '2d', '3w', '1M', '0.5y'"
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
value_str = match.group(1)
|
|
933
|
+
unit = match.group(2) if match.group(2) else "s" # default to seconds
|
|
934
|
+
|
|
935
|
+
try:
|
|
936
|
+
value = float(value_str)
|
|
937
|
+
except ValueError as e:
|
|
938
|
+
raise ValueError(f"Invalid numeric value '{value_str}': {e}")
|
|
939
|
+
|
|
940
|
+
if value <= 0:
|
|
941
|
+
raise ValueError(f"Duration value must be greater than 0, got {value}")
|
|
942
|
+
|
|
943
|
+
seconds = value * conversions[unit]
|
|
944
|
+
|
|
945
|
+
# Convert to integer (truncates decimal part)
|
|
946
|
+
result = int(seconds)
|
|
947
|
+
|
|
948
|
+
if result <= 0:
|
|
949
|
+
raise ValueError(
|
|
950
|
+
f"Calculated duration results in non-positive value. "
|
|
951
|
+
f"Input '{duration_str}' equals {seconds} seconds, which truncates to {result}"
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
return result
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def main() -> None:
|
|
958
|
+
"""
|
|
959
|
+
CLI entry point for the heartbeat client.
|
|
960
|
+
|
|
961
|
+
Subcommands:
|
|
962
|
+
login: Enroll this device via OAuth Device Flow.
|
|
963
|
+
Required: ``--server-url`` (HTTPS URL for key management).
|
|
964
|
+
status: Show current key status (key ID, expiration).
|
|
965
|
+
Required: ``--server-url``.
|
|
966
|
+
logout: Revoke keys on the server and delete local config.
|
|
967
|
+
Optional: ``--force`` (delete local keys even if server is unreachable).
|
|
968
|
+
send: Send a heartbeat packet.
|
|
969
|
+
Required: ``--app``, ``--task``, ``--interval``.
|
|
970
|
+
Optional: ``--alert-after``, ``--port``, ``--version``,
|
|
971
|
+
``--final-report``, ``--debug``.
|
|
972
|
+
|
|
973
|
+
Legacy mode: If an unknown command is passed followed by ``--task``, the CLI
|
|
974
|
+
assumes the user intended ``send --app <command> --task ...``.
|
|
975
|
+
|
|
976
|
+
Examples:
|
|
977
|
+
# Enroll
|
|
978
|
+
hbclient --server-url https://hb.example.com:8333 login
|
|
979
|
+
|
|
980
|
+
# Check enrollment
|
|
981
|
+
hbclient --server-url https://hb.example.com:8333 status
|
|
982
|
+
|
|
983
|
+
# Send a heartbeat
|
|
984
|
+
hbclient send --app my-app --task deploy --interval 60
|
|
985
|
+
|
|
986
|
+
# Send with human-readable duration
|
|
987
|
+
hbclient send --app my-app --task deploy --interval 1h
|
|
988
|
+
"""
|
|
989
|
+
# Ensure output is unbuffered, like 'python -u'
|
|
990
|
+
sys.stdout.reconfigure(write_through=True) # type: ignore
|
|
991
|
+
sys.stderr.reconfigure(write_through=True) # type: ignore
|
|
992
|
+
|
|
993
|
+
# --- THE STRICT LEGACY INTERCEPTOR ---
|
|
994
|
+
known_commands = ["login", "send", "status", "logout", "-h", "--help", "help"]
|
|
995
|
+
|
|
996
|
+
if (
|
|
997
|
+
len(sys.argv) > 1
|
|
998
|
+
and sys.argv[1] not in known_commands
|
|
999
|
+
and not sys.argv[1].startswith("-")
|
|
1000
|
+
):
|
|
1001
|
+
# ONLY intercept if --task is explicitly provided
|
|
1002
|
+
if "--task" in sys.argv:
|
|
1003
|
+
app_name = sys.argv.pop(1)
|
|
1004
|
+
sys.argv.insert(1, "send")
|
|
1005
|
+
sys.argv.insert(2, "--app")
|
|
1006
|
+
sys.argv.insert(3, app_name)
|
|
1007
|
+
|
|
1008
|
+
# Transparently map 'help' to '-h' for better UX
|
|
1009
|
+
if len(sys.argv) > 1 and sys.argv[1] == "help":
|
|
1010
|
+
sys.argv[1] = "-h"
|
|
1011
|
+
|
|
1012
|
+
parser = argparse.ArgumentParser(description="Heartbeat Client Utility")
|
|
1013
|
+
parser.add_argument(
|
|
1014
|
+
"--server", default=default_hb_config.server, help="UDP Server hostname"
|
|
1015
|
+
)
|
|
1016
|
+
parser.add_argument(
|
|
1017
|
+
"--serverport",
|
|
1018
|
+
type=int,
|
|
1019
|
+
default=default_hb_config.serverport,
|
|
1020
|
+
help="UDP Server port",
|
|
1021
|
+
)
|
|
1022
|
+
parser.add_argument("--server-url", default=None, help="HTTPS URL for key management")
|
|
1023
|
+
|
|
1024
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
1025
|
+
|
|
1026
|
+
subparsers.add_parser("login", help="Enroll this device via OAuth Device Flow")
|
|
1027
|
+
subparsers.add_parser("status", help="Show current key status")
|
|
1028
|
+
|
|
1029
|
+
p_logout = subparsers.add_parser("logout", help="Revoke keys and delete local config")
|
|
1030
|
+
p_logout.add_argument(
|
|
1031
|
+
"--force",
|
|
1032
|
+
action="store_true",
|
|
1033
|
+
help="Force local logout even if server is unreachable",
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
p_send = subparsers.add_parser("send", help="Send a heartbeat")
|
|
1037
|
+
p_send.add_argument("--app", "-a", required=True, help="App name")
|
|
1038
|
+
p_send.add_argument(
|
|
1039
|
+
"--task", "-t", required=True, help="Task name"
|
|
1040
|
+
) # <--- NOW STRICTLY REQUIRED
|
|
1041
|
+
p_send.add_argument(
|
|
1042
|
+
"--interval",
|
|
1043
|
+
"-i",
|
|
1044
|
+
required=True,
|
|
1045
|
+
type=str,
|
|
1046
|
+
default=60,
|
|
1047
|
+
help="Heartbeat interval in seconds or human durations e.g. 6h 2.5d 3w ...",
|
|
1048
|
+
)
|
|
1049
|
+
p_send.add_argument(
|
|
1050
|
+
"--alert-after",
|
|
1051
|
+
"-A",
|
|
1052
|
+
type=str,
|
|
1053
|
+
help="Alert threshold in seconds or human durations e.g. 12h 6.25d 11w ...",
|
|
1054
|
+
)
|
|
1055
|
+
p_send.add_argument("--port", "-p", type=int, help="App port (optional)")
|
|
1056
|
+
p_send.add_argument("--version", "-v", help="Version string for the app (optional)")
|
|
1057
|
+
p_send.add_argument(
|
|
1058
|
+
"--final-report",
|
|
1059
|
+
"-R",
|
|
1060
|
+
help="Send a final status message and exit, use double quotes to include spaces",
|
|
1061
|
+
)
|
|
1062
|
+
p_send.add_argument("--debug", "-d", action="store_true", default=False)
|
|
1063
|
+
|
|
1064
|
+
args = parser.parse_args()
|
|
1065
|
+
if not args.server_url:
|
|
1066
|
+
args.server_url = f"https://{args.server}:{args.serverport}"
|
|
1067
|
+
|
|
1068
|
+
if args.command == "login":
|
|
1069
|
+
cmd_login(args)
|
|
1070
|
+
elif args.command == "status":
|
|
1071
|
+
cmd_status(args)
|
|
1072
|
+
elif args.command == "logout":
|
|
1073
|
+
cmd_logout(args)
|
|
1074
|
+
elif args.command == "send":
|
|
1075
|
+
client = HbClient(
|
|
1076
|
+
name=args.app,
|
|
1077
|
+
interval=parse_time_duration(args.interval),
|
|
1078
|
+
alert_after=(
|
|
1079
|
+
parse_time_duration(args.alert_after) if args.alert_after else None
|
|
1080
|
+
),
|
|
1081
|
+
task=args.task,
|
|
1082
|
+
version=args.version,
|
|
1083
|
+
port=args.port,
|
|
1084
|
+
servername=args.server,
|
|
1085
|
+
serverport=args.serverport,
|
|
1086
|
+
server_url=args.server_url,
|
|
1087
|
+
config=HbConfig(debug=args.debug),
|
|
1088
|
+
)
|
|
1089
|
+
if not client.send(final_report=args.final_report):
|
|
1090
|
+
sys.exit(1)
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
if __name__ == "__main__":
|
|
1094
|
+
main()
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hb-client
|
|
3
|
+
Version: 0.5.3
|
|
4
|
+
Summary: Secure heartbeat client for the Nuclei monitoring ecosystem
|
|
5
|
+
Author-email: "J. Will Pierce" <willp@users.noreply.github.com>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/willp/heartbeat-client
|
|
8
|
+
Project-URL: Repository, https://github.com/willp/heartbeat-client
|
|
9
|
+
Project-URL: Documentation, https://github.com/willp/heartbeat-client/blob/main/README.md
|
|
10
|
+
Project-URL: Issues, https://github.com/willp/heartbeat-client/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/willp/heartbeat-client/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: heartbeat,monitoring,nuclei,client,heartbeat-monitoring
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
|
+
Classifier: Operating System :: OS Independent
|
|
22
|
+
Classifier: Topic :: System :: Monitoring
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: cryptography>=41.0.0
|
|
27
|
+
Requires-Dist: filelock>=3.12.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: build>=1.2.2; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest>=8.3.0; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff>=0.6.0; extra == "dev"
|
|
32
|
+
Requires-Dist: twine>=5.1.0; extra == "dev"
|
|
33
|
+
Provides-Extra: typecheck
|
|
34
|
+
Requires-Dist: mypy>=1.11.0; extra == "typecheck"
|
|
35
|
+
Provides-Extra: release
|
|
36
|
+
Requires-Dist: commitizen>=3.29.0; extra == "release"
|
|
37
|
+
Dynamic: license-file
|
|
38
|
+
|
|
39
|
+
# hb-client
|
|
40
|
+
|
|
41
|
+
A secure, high-reliability heartbeat client for monitoring your systems
|
|
42
|
+
using `hbserver` and `hbwatcher`.
|
|
43
|
+
|
|
44
|
+
## Project Scope
|
|
45
|
+
|
|
46
|
+
This repository contains only the Python client library and CLI for sending
|
|
47
|
+
heartbeat packets and managing local enrollment keys.
|
|
48
|
+
|
|
49
|
+
Related repositories:
|
|
50
|
+
|
|
51
|
+
- `hbserver` (`hb_backend`): API/authentication and key lifecycle services
|
|
52
|
+
- `hbwatcher` (`hb_watcher`): monitoring process that evaluates heartbeat data
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install hb-client
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
See [MIGRATION.md](MIGRATION.md) if upgrading from older distribution or config paths.
|
|
61
|
+
|
|
62
|
+
This installs:
|
|
63
|
+
|
|
64
|
+
- the Python package `hb_client`
|
|
65
|
+
- the CLI command `hbclient`
|
|
66
|
+
|
|
67
|
+
## Quick Start
|
|
68
|
+
|
|
69
|
+
### As a library
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from hb_client import HbClient, HbConfig
|
|
73
|
+
|
|
74
|
+
config = HbConfig(server="hb.example.com", serverport=8333)
|
|
75
|
+
client = HbClient(name="my-service", interval=60, config=config)
|
|
76
|
+
client.send(task="deployment-complete")
|
|
77
|
+
client.close()
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### From the CLI
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
hbclient --server-url https://hb.example.com:8333 login
|
|
84
|
+
hbclient --server-url https://hb.example.com:8333 status
|
|
85
|
+
hbclient send --app my-service --task deploy --interval 60
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Features
|
|
89
|
+
|
|
90
|
+
- **AES-GCM encrypted UDP transport** with CRC32 integrity
|
|
91
|
+
- **OAuth Device Flow** for key management
|
|
92
|
+
- **Secure by default** with `strict_security=True`
|
|
93
|
+
- **Transparent DNS resolution** with configurable refresh intervals
|
|
94
|
+
- **Deterministic key rotation** with jitter to prevent thundering herd
|
|
95
|
+
- **Atomic file I/O** for crash-safe credential storage
|
|
96
|
+
- **Human-readable duration parsing** (e.g., `6h`, `2.5d`, `3w`)
|
|
97
|
+
|
|
98
|
+
## Configuration
|
|
99
|
+
|
|
100
|
+
Enrollment keys are stored under:
|
|
101
|
+
|
|
102
|
+
- Linux: `$XDG_CONFIG_HOME/hbclient/` or `~/.config/hbclient/`
|
|
103
|
+
- macOS: `~/Library/Application Support/hbclient/`
|
|
104
|
+
- Windows: `%APPDATA%/hbclient/`
|
|
105
|
+
|
|
106
|
+
## Security Mode (`strict_security`)
|
|
107
|
+
|
|
108
|
+
`HbClient` accepts `strict_security` (default: `True`). If enrollment is missing, construction raises `RuntimeError` with guidance to run:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
hbclient --server-url https://hb.example.com:8333 login
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## CLI Reference
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
hbclient [--server SERVER] [--serverport PORT] [--server-url URL] COMMAND
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Development
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
pip install -e ".[dev]"
|
|
124
|
+
make test
|
|
125
|
+
make pre-release
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Releasing
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
pip install -e ".[release]"
|
|
132
|
+
cz bump
|
|
133
|
+
make pre-release
|
|
134
|
+
python -m twine upload dist/hb_client-<version>*
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
Apache-2.0 — see [LICENSE](LICENSE)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
hb_client/__init__.py,sha256=tu2JaNEH9H6cXSCQIblML1zA6fEJphu-D7ksIStoKCg,541
|
|
2
|
+
hb_client/hbclient.py,sha256=NygxBSGbWXT_1ahb-9lg69nejOL6akc9qBuKgTpb0M8,41729
|
|
3
|
+
hb_client-0.5.3.dist-info/licenses/LICENSE,sha256=aYAYloxaA1fqzO1ZsrB0iEz-PxxQMa06JZRIUT1o9FQ,710
|
|
4
|
+
hb_client-0.5.3.dist-info/METADATA,sha256=oOXg8gHNhX4Wm9wVOyVE0E-crHN3tXUoreSjHsBMOwc,4054
|
|
5
|
+
hb_client-0.5.3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
6
|
+
hb_client-0.5.3.dist-info/entry_points.txt,sha256=Zd6LZb8LlA34rh2iruPvMfp48SZtl5CMtN6Pl_dLot0,53
|
|
7
|
+
hb_client-0.5.3.dist-info/top_level.txt,sha256=shEJ53OW9btAbGlzdeCJlwTATNHEtaaGmO-C7nNVP2U,10
|
|
8
|
+
hb_client-0.5.3.dist-info/RECORD,,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Copyright 2026, J. Will Pierce
|
|
6
|
+
|
|
7
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
8
|
+
you may not use this file except in compliance with the License.
|
|
9
|
+
You may obtain a copy of the License at
|
|
10
|
+
|
|
11
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
|
|
13
|
+
Unless required by applicable law or agreed to in writing, software
|
|
14
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
15
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
16
|
+
See the License for the specific language governing permissions and
|
|
17
|
+
limitations under the License.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hb_client
|