feat: add telemetry collection functionality

This commit is contained in:
dido committed 2026-09-24 14:54:17 +03:00
1 parent 5141bcdb0f
commit 5a285ab1a6
10 files changed
+412 -5

No files matched your search

+3
View File
@@ -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:
+1 -1
View File
@@ -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
+20 -2
View File
@@ -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
View File
@@ -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>
+1 -1
View File
@@ -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.
+7
View File
@@ -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"
+65
View File
@@ -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:
+16
View File
@@ -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):
+191
View File
@@ -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
+100
View File
@@ -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"])