feat: add telemetry collection functionality
This commit is contained in:
1 parent
5141bcdb0f
commit
5a285ab1a6
10 files changed
+412
-5
No files matched your search
@@ -19,7 +19,10 @@ services:
|
||||
# Session Recording Configuration
|
||||
- RECORDING_ENABLED=true
|
||||
- RECORDING_RETENTION_DAYS=30
|
||||
- STRIX_TELEMETRY_ENABLED=${STRIX_TELEMETRY_ENABLED:-true}
|
||||
# Security hardening
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
tmpfs:
|
||||
|
||||
@@ -7,7 +7,7 @@ set -e
|
||||
|
||||
# Configuration
|
||||
IMAGE_NAME="danaildr/webssh"
|
||||
VERSION="0.4.5"
|
||||
VERSION="0.5.0"
|
||||
PLATFORMS="linux/amd64,linux/arm64,linux/arm/v7"
|
||||
|
||||
# Colors
|
||||
|
||||
@@ -8,6 +8,7 @@ set -e
|
||||
# Configuration
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
VERSION_FILE="$ROOT_DIR/src/help.html"
|
||||
PACKAGE_VERSION_FILE="$ROOT_DIR/src/webssh_backend/__init__.py"
|
||||
BUILD_SCRIPT="$ROOT_DIR/deployment/scripts/docker-build-and-push.sh"
|
||||
GITHUB_WORKFLOW="$ROOT_DIR/.github/workflows/build-and-publish.yml"
|
||||
|
||||
@@ -23,13 +24,21 @@ echo -e "${BLUE}=================================${NC}"
|
||||
|
||||
# Function to get current version
|
||||
get_current_version() {
|
||||
if [[ -f "$VERSION_FILE" ]]; then
|
||||
grep -o 'WebSSH Multi-Connection Terminal v' "$VERSION_FILE" | sed 's/.*v\([0-9.]\+\).*/\1/'
|
||||
if [[ -f "$PACKAGE_VERSION_FILE" ]]; then
|
||||
sed -n 's/^__version__ = "\([0-9.]\+\)"$/\1/p' "$PACKAGE_VERSION_FILE"
|
||||
else
|
||||
echo "0.0.0"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to update the package version
|
||||
update_package_version() {
|
||||
local new_version=$1
|
||||
echo -e "${GREEN}📦 Updating $PACKAGE_VERSION_FILE to v$new_version${NC}"
|
||||
sed -i "s/^__version__ = \"[0-9.]\+\"$/__version__ = \"$new_version\"/" "$PACKAGE_VERSION_FILE"
|
||||
echo -e "${GREEN}✅ $PACKAGE_VERSION_FILE updated to v$new_version${NC}"
|
||||
}
|
||||
|
||||
# Function to update version in help.html
|
||||
update_help_version() {
|
||||
local new_version=$1
|
||||
@@ -121,6 +130,7 @@ main() {
|
||||
read -p "Enter new version (X.Y.Z): " new_version
|
||||
if validate_version "$new_version"; then
|
||||
update_help_version "$new_version"
|
||||
update_package_version "$new_version"
|
||||
update_build_version "$new_version"
|
||||
update_github_workflow "$new_version"
|
||||
update_dockerfile_version "$new_version"
|
||||
@@ -135,6 +145,7 @@ main() {
|
||||
patch=$((${BASH_REMATCH[3]} + 1))
|
||||
new_version="${major}.${minor}.${patch}"
|
||||
update_help_version "$new_version"
|
||||
update_package_version "$new_version"
|
||||
update_build_version "$new_version"
|
||||
update_github_workflow "$new_version"
|
||||
update_dockerfile_version "$new_version"
|
||||
@@ -151,6 +162,7 @@ main() {
|
||||
patch=0
|
||||
new_version="${major}.${minor}.${patch}"
|
||||
update_help_version "$new_version"
|
||||
update_package_version "$new_version"
|
||||
update_build_version "$new_version"
|
||||
update_github_workflow "$new_version"
|
||||
update_dockerfile_version "$new_version"
|
||||
@@ -167,6 +179,7 @@ main() {
|
||||
patch=0
|
||||
new_version="${major}.${minor}.${patch}"
|
||||
update_help_version "$new_version"
|
||||
update_package_version "$new_version"
|
||||
update_build_version "$new_version"
|
||||
update_github_workflow "$new_version"
|
||||
update_dockerfile_version "$new_version"
|
||||
@@ -198,6 +211,11 @@ if [[ ! -f "$VERSION_FILE" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$PACKAGE_VERSION_FILE" ]]; then
|
||||
echo -e "${RED}❌ $PACKAGE_VERSION_FILE not found${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$BUILD_SCRIPT" ]]; then
|
||||
echo -e "${RED}❌ $BUILD_SCRIPT not found${NC}"
|
||||
exit 1
|
||||
|
||||
+8
-1
@@ -337,6 +337,13 @@
|
||||
<p><strong>RECORDING_ENABLED:</strong> Enable/disable session recording (<code>true</code> or <code>false</code>)</p>
|
||||
|
||||
<p><strong>RECORDING_RETENTION_DAYS:</strong> Number of days to keep recordings (default: 30)</p>
|
||||
|
||||
<h3>📡 Installation Telemetry</h3>
|
||||
<p>Starting with version 0.5.0, WebSSH collects limited installation telemetry to understand active installations and deployed versions.</p>
|
||||
<p>The telemetry contains only a randomly generated installation ID, WebSSH version, operating system, operating system version, CPU architecture, and Python runtime information.</p>
|
||||
<p>Telemetry is enabled by default. To disable it, set the following environment variable:</p>
|
||||
<p><code>STRIX_TELEMETRY_ENABLED=false</code></p>
|
||||
<p>WebSSH does not send hostnames, usernames, IP addresses, SSH credentials, private keys, commands, terminal contents, or filesystem data.</p>
|
||||
|
||||
<h3>🔒 Auto-Lock & Auto-Disconnect</h3>
|
||||
<p>WebSSH can automatically lock sessions after a period of inactivity and require your WebSSH password to unlock. Sessions can also be automatically disconnected after a longer period.</p>
|
||||
@@ -383,7 +390,7 @@
|
||||
<p>📖 <strong>Project page:</strong> <a href="https://danaildr.eu/projects/webssh.html" target="_blank" rel="noopener">danaildr/webssh</a></p>
|
||||
<p>🐳 <strong>Docker Hub:</strong> <a href="https://hub.docker.com/r/danaildr/webssh" target="_blank" rel="noopener">danaildr/webssh</a></p>
|
||||
<p>📧 <strong>Support:</strong> See the <a href="https://danaildr.eu/projects/webssh.html" target="_blank" rel="noopener">project documentation</a> for setup, env vars, and reverse proxy config.</p>
|
||||
<p>🔄 <strong>Version:</strong> WebSSH Multi-Connection Terminal v0.4.5</p>
|
||||
<p>🔄 <strong>Version:</strong> WebSSH Multi-Connection Terminal v0.5.0</p>
|
||||
<p>⚡ <strong>Technologies:</strong> Python, Paramiko, WebSockets, HTML5, CSS3, JavaScript</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -18,7 +18,7 @@ Usage:
|
||||
from webssh_backend.main import main
|
||||
asyncio.run(main())
|
||||
"""
|
||||
__version__ = "0.4.0"
|
||||
__version__ = "0.5.0"
|
||||
|
||||
# Note: Specific imports are intentionally NOT done at package level
|
||||
# to avoid loading all dependencies when only specific modules are needed.
|
||||
|
||||
@@ -120,6 +120,13 @@ RECORDING_RETENTION_DAYS = int(os.environ.get("RECORDING_RETENTION_DAYS", "30"))
|
||||
RECORDINGS_PATH = os.environ.get("RECORDINGS_PATH", os.path.join(STORAGE_DIR, "recordings"))
|
||||
logger.info(f"Session recording: enabled={RECORDING_ENABLED}, retention={RECORDING_RETENTION_DAYS}days, path={RECORDINGS_PATH}")
|
||||
|
||||
# StrixTelemetry configuration
|
||||
STRIX_TELEMETRY_ENABLED = os.environ.get("STRIX_TELEMETRY_ENABLED", "true").lower() in ("true", "1", "yes")
|
||||
STRIX_TELEMETRY_URL = os.environ.get("STRIX_TELEMETRY_URL", "реалния.домейн.тук").rstrip("/")
|
||||
STRIX_TELEMETRY_PRODUCT = os.environ.get("STRIX_TELEMETRY_PRODUCT", "реалния-продукт-UUID-тук")
|
||||
STRIX_TELEMETRY_TIMEOUT = float(os.environ.get("STRIX_TELEMETRY_TIMEOUT", "10"))
|
||||
STRIX_TELEMETRY_INTERVAL = int(os.environ.get("STRIX_TELEMETRY_INTERVAL", "86400"))
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET = os.environ.get("JWT_SECRET", secrets.token_hex(32))
|
||||
JWT_ALGORITHM = "HS256"
|
||||
|
||||
@@ -86,6 +86,21 @@ class CredentialsManager:
|
||||
logger.info("Added disconnect_timeout column to connections table")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS telemetry_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
installation_uuid TEXT,
|
||||
register_request_id TEXT,
|
||||
credential_id TEXT,
|
||||
credential_secret_encrypted BLOB,
|
||||
expires_at TEXT,
|
||||
last_heartbeat_at REAL,
|
||||
last_heartbeat_request_id TEXT,
|
||||
last_reported_version TEXT,
|
||||
update_request_id TEXT,
|
||||
renewal_request_id TEXT
|
||||
)
|
||||
''')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(f"Credentials database initialized at {self.db_path}")
|
||||
@@ -450,6 +465,56 @@ class CredentialsManager:
|
||||
logger.error(f"Failed to get old recordings: {e}")
|
||||
return []
|
||||
|
||||
def get_telemetry_state(self) -> Optional[Dict[str, Any]]:
|
||||
"""Return the encrypted telemetry state, if it exists."""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
row = conn.execute('SELECT * FROM telemetry_state WHERE id = 1').fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
'installation_uuid': row[1],
|
||||
'register_request_id': row[2],
|
||||
'credential_id': row[3],
|
||||
'credential_secret': self._decrypt(row[4]) if row[4] else None,
|
||||
'expires_at': row[5],
|
||||
'last_heartbeat_at': row[6],
|
||||
'last_heartbeat_request_id': row[7],
|
||||
'last_reported_version': row[8],
|
||||
'update_request_id': row[9],
|
||||
'renewal_request_id': row[10],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read telemetry state: {e}")
|
||||
return None
|
||||
|
||||
def save_telemetry_state(self, state: Dict[str, Any]) -> bool:
|
||||
"""Persist telemetry state with the credential secret encrypted."""
|
||||
try:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.execute('''
|
||||
INSERT OR REPLACE INTO telemetry_state
|
||||
(id, installation_uuid, register_request_id, credential_id,
|
||||
credential_secret_encrypted, expires_at, last_heartbeat_at,
|
||||
last_heartbeat_request_id, last_reported_version, update_request_id,
|
||||
renewal_request_id)
|
||||
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
state.get('installation_uuid'), state.get('register_request_id'),
|
||||
state.get('credential_id'),
|
||||
self._encrypt(state['credential_secret']) if state.get('credential_secret') else None,
|
||||
state.get('expires_at'), state.get('last_heartbeat_at'),
|
||||
state.get('last_heartbeat_request_id'), state.get('last_reported_version'),
|
||||
state.get('update_request_id'), state.get('renewal_request_id'),
|
||||
))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save telemetry state: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# Initialize credentials manager (singleton)
|
||||
try:
|
||||
|
||||
@@ -12,6 +12,7 @@ from .http_api import LoginHandler
|
||||
from .recording import cleanup_old_recordings
|
||||
from .sftp_handler import handle_sftp_client
|
||||
from .ssh_handler import handle_client
|
||||
from .telemetry import TelemetryClient
|
||||
|
||||
|
||||
async def periodic_cleanup():
|
||||
@@ -24,6 +25,17 @@ async def periodic_cleanup():
|
||||
logger.error(f"Periodic cleanup error: {e}")
|
||||
|
||||
|
||||
async def periodic_telemetry():
|
||||
"""Run telemetry outside the event loop and isolate all telemetry failures."""
|
||||
client = TelemetryClient()
|
||||
while True:
|
||||
try:
|
||||
await asyncio.to_thread(client.run_cycle)
|
||||
except Exception as e:
|
||||
logger.warning(f"Telemetry cycle failed: {type(e).__name__}")
|
||||
await asyncio.sleep(client.next_interval())
|
||||
|
||||
|
||||
async def main():
|
||||
logger.info(f"WebSSH server starting on ws://{HOST}:{WS_PORT}")
|
||||
logger.info(f"SFTP server starting on ws://{HOST}:{SFTP_WS_PORT}")
|
||||
@@ -40,6 +52,10 @@ async def main():
|
||||
cleanup_task = asyncio.create_task(periodic_cleanup())
|
||||
logger.info(f"Started periodic cleanup task (retention: {RECORDING_RETENTION_DAYS} days)")
|
||||
|
||||
if TelemetryClient.is_enabled():
|
||||
asyncio.create_task(periodic_telemetry())
|
||||
logger.info("Started StrixTelemetry task")
|
||||
|
||||
# Start both WebSocket servers
|
||||
async with websockets.serve(handle_client, HOST, WS_PORT, max_size=MAX_MESSAGE_SIZE, ping_interval=20, ping_timeout=10), \
|
||||
websockets.serve(handle_sftp_client, HOST, SFTP_WS_PORT, max_size=MAX_UPLOAD_SIZE + 1024*1024, ping_interval=20, ping_timeout=10):
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Small, failure-isolated client for the StrixTelemetry API."""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import platform
|
||||
import secrets
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from .config import (
|
||||
STRIX_TELEMETRY_ENABLED,
|
||||
STRIX_TELEMETRY_INTERVAL,
|
||||
STRIX_TELEMETRY_PRODUCT,
|
||||
STRIX_TELEMETRY_TIMEOUT,
|
||||
STRIX_TELEMETRY_URL,
|
||||
logger,
|
||||
)
|
||||
from .database import creds_manager
|
||||
from . import __version__
|
||||
|
||||
PROTOCOL = "1"
|
||||
RENEWAL_THRESHOLD_DAYS = 15
|
||||
MAX_ATTEMPTS = 2
|
||||
|
||||
|
||||
def canonical_payload(method: str, path: str, timestamp: int, nonce: str, request_id: str, body: bytes) -> str:
|
||||
return "\n".join((
|
||||
method.upper(), path, str(timestamp), nonce, request_id,
|
||||
hashlib.sha256(body).hexdigest(),
|
||||
))
|
||||
|
||||
|
||||
def sign_request(secret: str, method: str, path: str, timestamp: int, nonce: str, request_id: str, body: bytes) -> str:
|
||||
payload = canonical_payload(method, path, timestamp, nonce, request_id, body)
|
||||
return hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _json_body(payload: Dict[str, Any]) -> bytes:
|
||||
return json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
|
||||
|
||||
def _platform_data() -> Dict[str, str]:
|
||||
return {
|
||||
"version": __version__,
|
||||
"os": platform.system(),
|
||||
"os_version": platform.release(),
|
||||
"architecture": platform.machine(),
|
||||
"runtime": platform.python_implementation(),
|
||||
"runtime_version": platform.python_version(),
|
||||
}
|
||||
|
||||
|
||||
class TelemetryClient:
|
||||
def __init__(self, opener: Optional[Callable[..., Any]] = None):
|
||||
self.opener = opener or urllib.request.urlopen
|
||||
|
||||
@staticmethod
|
||||
def is_enabled() -> bool:
|
||||
return STRIX_TELEMETRY_ENABLED and bool(STRIX_TELEMETRY_URL) and creds_manager is not None
|
||||
|
||||
def next_interval(self) -> int:
|
||||
return max(60, STRIX_TELEMETRY_INTERVAL + secrets.randbelow(max(1, STRIX_TELEMETRY_INTERVAL // 10 + 1)) - STRIX_TELEMETRY_INTERVAL // 20)
|
||||
|
||||
def run_cycle(self) -> None:
|
||||
if not self.is_enabled():
|
||||
return
|
||||
try:
|
||||
state = self._state()
|
||||
if not state.get("credential_secret"):
|
||||
self.register(state)
|
||||
state = self._state()
|
||||
if not state.get("credential_secret"):
|
||||
return
|
||||
if self._renewal_due(state):
|
||||
self.renew(state)
|
||||
state = self._state()
|
||||
if state.get("last_reported_version") != __version__:
|
||||
self.update(state)
|
||||
state = self._state()
|
||||
if not state.get("last_heartbeat_at") or time.time() - state["last_heartbeat_at"] >= STRIX_TELEMETRY_INTERVAL:
|
||||
self.heartbeat(state)
|
||||
except Exception as e:
|
||||
logger.warning(f"Telemetry operation failed: {type(e).__name__}")
|
||||
|
||||
def register(self, state: Optional[Dict[str, Any]] = None) -> None:
|
||||
state = state or self._state()
|
||||
state.setdefault("installation_uuid", str(uuid.uuid4()))
|
||||
state.setdefault("register_request_id", str(uuid.uuid4()))
|
||||
self._save(state)
|
||||
response = self._request("/api/v1/telemetry/register", state["register_request_id"], {**_platform_data(), "installation_uuid": state["installation_uuid"]})
|
||||
data = self._response_data(response)
|
||||
installation_uuid = data.get("installation_uuid")
|
||||
credential = data.get("credential", "")
|
||||
expires_at = data.get("expires_at")
|
||||
if installation_uuid != state["installation_uuid"] or credential.count(".") != 1 or not expires_at:
|
||||
raise ValueError("malformed telemetry registration response")
|
||||
credential_id, secret = credential.split(".", 1)
|
||||
if not credential_id or not secret:
|
||||
raise ValueError("malformed telemetry credential")
|
||||
state.update({"credential_id": credential_id, "credential_secret": secret, "expires_at": expires_at})
|
||||
self._save(state)
|
||||
|
||||
def heartbeat(self, state: Optional[Dict[str, Any]] = None) -> None:
|
||||
state = state or self._state()
|
||||
request_id = state.get("last_heartbeat_request_id") or str(uuid.uuid4())
|
||||
state["last_heartbeat_request_id"] = request_id
|
||||
self._save(state)
|
||||
self._request("/api/v1/telemetry/heartbeat", request_id, {**_platform_data(), "installation_uuid": state["installation_uuid"]}, state)
|
||||
state.update({"last_heartbeat_at": time.time(), "last_heartbeat_request_id": None})
|
||||
self._save(state)
|
||||
|
||||
def renew(self, state: Optional[Dict[str, Any]] = None) -> None:
|
||||
state = state or self._state()
|
||||
request_id = state.get("renewal_request_id") or str(uuid.uuid4())
|
||||
state["renewal_request_id"] = request_id
|
||||
self._save(state)
|
||||
data = self._response_data(self._request("/api/v1/telemetry/renew", request_id, {}, state))
|
||||
credential = data.get("credential", "")
|
||||
if credential.count(".") != 1 or not data.get("expires_at"):
|
||||
raise ValueError("malformed telemetry renewal response")
|
||||
credential_id, secret = credential.split(".", 1)
|
||||
state.update({"credential_id": credential_id, "credential_secret": secret, "expires_at": data["expires_at"], "renewal_request_id": None})
|
||||
self._save(state)
|
||||
|
||||
def update(self, state: Optional[Dict[str, Any]] = None) -> None:
|
||||
state = state or self._state()
|
||||
request_id = state.get("update_request_id") or str(uuid.uuid4())
|
||||
state["update_request_id"] = request_id
|
||||
self._save(state)
|
||||
self._request("/api/v1/telemetry/update", request_id, {**_platform_data(), "installation_uuid": state["installation_uuid"]}, state)
|
||||
state.update({"last_reported_version": __version__, "update_request_id": None})
|
||||
self._save(state)
|
||||
|
||||
def uninstall(self) -> None:
|
||||
if not self.is_enabled():
|
||||
return
|
||||
state = self._state()
|
||||
if state.get("credential_secret"):
|
||||
self._request("/api/v1/telemetry/uninstall", str(uuid.uuid4()), {}, state)
|
||||
|
||||
def _request(self, path: str, request_id: str, payload: Dict[str, Any], state: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
body = _json_body(payload)
|
||||
for attempt in range(MAX_ATTEMPTS):
|
||||
timestamp = int(time.time())
|
||||
nonce = str(uuid.uuid4())
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json", "X-Strix-Protocol": PROTOCOL, "X-Strix-Product": STRIX_TELEMETRY_PRODUCT, "X-Strix-Request-Id": request_id}
|
||||
if state and state.get("credential_secret"):
|
||||
credential = f"{state['credential_id']}.{state['credential_secret']}"
|
||||
headers.update({"X-Strix-Credential": credential, "X-Strix-Timestamp": str(timestamp), "X-Strix-Nonce": nonce, "X-Strix-Signature": sign_request(state["credential_secret"], "POST", path, timestamp, nonce, request_id, body)})
|
||||
request = urllib.request.Request(STRIX_TELEMETRY_URL + path, data=body, headers=headers, method="POST")
|
||||
try:
|
||||
with self.opener(request, timeout=STRIX_TELEMETRY_TIMEOUT) as response:
|
||||
if not 200 <= response.status < 300:
|
||||
raise urllib.error.HTTPError(request.full_url, response.status, "telemetry request failed", response.headers, None)
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, OSError, ValueError) as error:
|
||||
if isinstance(error, urllib.error.HTTPError) and 400 <= error.code < 500:
|
||||
raise
|
||||
if attempt + 1 == MAX_ATTEMPTS:
|
||||
raise
|
||||
time.sleep(2 ** attempt)
|
||||
raise RuntimeError("telemetry request failed")
|
||||
|
||||
def _state(self) -> Dict[str, Any]:
|
||||
return creds_manager.get_telemetry_state() or {}
|
||||
|
||||
def _save(self, state: Dict[str, Any]) -> None:
|
||||
if not creds_manager.save_telemetry_state(state):
|
||||
raise OSError("telemetry state could not be persisted")
|
||||
|
||||
@staticmethod
|
||||
def _response_data(response: Dict[str, Any]) -> Dict[str, Any]:
|
||||
data = response.get("data")
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("malformed telemetry response")
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _renewal_due(state: Dict[str, Any]) -> bool:
|
||||
expires_at = state.get("expires_at")
|
||||
if not expires_at:
|
||||
return True
|
||||
try:
|
||||
expiry = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
|
||||
return expiry <= datetime.now(timezone.utc) + timedelta(days=RENEWAL_THRESHOLD_DAYS)
|
||||
except (TypeError, ValueError):
|
||||
return True
|
||||
@@ -0,0 +1,100 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import unittest
|
||||
import urllib.error
|
||||
from unittest.mock import patch
|
||||
|
||||
import webssh_backend.telemetry as telemetry
|
||||
from webssh_backend.telemetry import TelemetryClient, canonical_payload, sign_request
|
||||
|
||||
|
||||
class TelemetrySigningTests(unittest.TestCase):
|
||||
def test_canonical_payload_has_exact_newlines_and_body_hash(self):
|
||||
body = b'{"version":"1.0.0"}'
|
||||
expected = "POST\n/api/v1/telemetry/heartbeat\n1700000000\nnonce\nrequest\n" + hashlib.sha256(body).hexdigest()
|
||||
self.assertEqual(canonical_payload("post", "/api/v1/telemetry/heartbeat", 1700000000, "nonce", "request", body), expected)
|
||||
self.assertFalse(expected.endswith("\n"))
|
||||
|
||||
def test_signature_is_lowercase_hmac_sha256(self):
|
||||
body = b"{}"
|
||||
canonical = canonical_payload("POST", "/path", 1, "n", "r", body)
|
||||
expected = hmac.new(b"secret", canonical.encode(), hashlib.sha256).hexdigest()
|
||||
self.assertEqual(sign_request("secret", "POST", "/path", 1, "n", "r", body), expected)
|
||||
self.assertEqual(len(expected), 64)
|
||||
self.assertEqual(expected, expected.lower())
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
status = 201
|
||||
headers = {}
|
||||
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
import json
|
||||
return json.dumps(self.payload).encode()
|
||||
|
||||
|
||||
class FakeState:
|
||||
def __init__(self, state=None):
|
||||
self.state = state or {}
|
||||
|
||||
def get_telemetry_state(self):
|
||||
return dict(self.state) if self.state else None
|
||||
|
||||
def save_telemetry_state(self, state):
|
||||
self.state = dict(state)
|
||||
return True
|
||||
|
||||
|
||||
class TelemetryClientTests(unittest.TestCase):
|
||||
def test_disabled_telemetry_does_not_call_opener(self):
|
||||
called = []
|
||||
|
||||
def opener(request, timeout):
|
||||
called.append(request)
|
||||
return FakeResponse({"data": {}})
|
||||
|
||||
with patch.object(telemetry, "creds_manager", FakeState({"credential_secret": "secret"})), patch.object(telemetry, "STRIX_TELEMETRY_ENABLED", False), patch.object(telemetry, "STRIX_TELEMETRY_URL", "https://telemetry.test"):
|
||||
TelemetryClient(opener).uninstall()
|
||||
self.assertEqual(called, [])
|
||||
|
||||
def test_registration_persists_installation_and_credential(self):
|
||||
state = FakeState()
|
||||
opener = lambda request, timeout: FakeResponse({"data": {
|
||||
"installation_uuid": state.state["installation_uuid"],
|
||||
"credential": "credential-id.secret-value",
|
||||
"expires_at": "2099-01-01T00:00:00Z",
|
||||
}})
|
||||
with patch.object(telemetry, "creds_manager", state), patch.object(telemetry, "STRIX_TELEMETRY_URL", "https://telemetry.test"):
|
||||
TelemetryClient(opener).register()
|
||||
self.assertTrue(state.state["installation_uuid"])
|
||||
self.assertEqual(state.state["credential_id"], "credential-id")
|
||||
self.assertEqual(state.state["credential_secret"], "secret-value")
|
||||
|
||||
def test_retry_reuses_request_id_and_regenerates_nonce(self):
|
||||
state = FakeState({
|
||||
"installation_uuid": "installation",
|
||||
"credential_id": "credential-id",
|
||||
"credential_secret": "secret-value",
|
||||
})
|
||||
requests = []
|
||||
|
||||
def opener(request, timeout):
|
||||
requests.append(request)
|
||||
if len(requests) == 1:
|
||||
raise urllib.error.URLError("temporary failure")
|
||||
return FakeResponse({"data": {"accepted": True}})
|
||||
|
||||
with patch.object(telemetry, "creds_manager", state), patch.object(telemetry, "STRIX_TELEMETRY_URL", "https://telemetry.test"), patch.object(telemetry.time, "sleep"):
|
||||
TelemetryClient(opener).heartbeat()
|
||||
self.assertEqual(len(requests), 2)
|
||||
self.assertEqual(requests[0].headers["X-strix-request-id"], requests[1].headers["X-strix-request-id"])
|
||||
self.assertNotEqual(requests[0].headers["X-strix-nonce"], requests[1].headers["X-strix-nonce"])
|
||||
Reference in new issue
Block a user