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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hbclient = hb_client.hbclient:main
@@ -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