diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index 0dcad2c..8aec6ab 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -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: diff --git a/deployment/scripts/docker-build-and-push.sh b/deployment/scripts/docker-build-and-push.sh index 1624a4d..9b9f1e6 100755 --- a/deployment/scripts/docker-build-and-push.sh +++ b/deployment/scripts/docker-build-and-push.sh @@ -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 diff --git a/deployment/scripts/version-manager.sh b/deployment/scripts/version-manager.sh index 8c61131..0de1c52 100755 --- a/deployment/scripts/version-manager.sh +++ b/deployment/scripts/version-manager.sh @@ -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 diff --git a/src/help.html b/src/help.html index 1e4f3e7..714bf08 100644 --- a/src/help.html +++ b/src/help.html @@ -337,6 +337,13 @@

RECORDING_ENABLED: Enable/disable session recording (true or false)

RECORDING_RETENTION_DAYS: Number of days to keep recordings (default: 30)

+ +

📡 Installation Telemetry

+

Starting with version 0.5.0, WebSSH collects limited installation telemetry to understand active installations and deployed versions.

+

The telemetry contains only a randomly generated installation ID, WebSSH version, operating system, operating system version, CPU architecture, and Python runtime information.

+

Telemetry is enabled by default. To disable it, set the following environment variable:

+

STRIX_TELEMETRY_ENABLED=false

+

WebSSH does not send hostnames, usernames, IP addresses, SSH credentials, private keys, commands, terminal contents, or filesystem data.

🔒 Auto-Lock & Auto-Disconnect

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.

@@ -383,7 +390,7 @@

📖 Project page: danaildr/webssh

🐳 Docker Hub: danaildr/webssh

📧 Support: See the project documentation for setup, env vars, and reverse proxy config.

-

🔄 Version: WebSSH Multi-Connection Terminal v0.4.5

+

🔄 Version: WebSSH Multi-Connection Terminal v0.5.0

⚡ Technologies: Python, Paramiko, WebSockets, HTML5, CSS3, JavaScript

diff --git a/src/webssh_backend/__init__.py b/src/webssh_backend/__init__.py index a812c08..af0d60e 100644 --- a/src/webssh_backend/__init__.py +++ b/src/webssh_backend/__init__.py @@ -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. diff --git a/src/webssh_backend/config.py b/src/webssh_backend/config.py index 494e4ef..d383c5f 100644 --- a/src/webssh_backend/config.py +++ b/src/webssh_backend/config.py @@ -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" diff --git a/src/webssh_backend/database.py b/src/webssh_backend/database.py index 8473e31..cb25e59 100644 --- a/src/webssh_backend/database.py +++ b/src/webssh_backend/database.py @@ -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: diff --git a/src/webssh_backend/main.py b/src/webssh_backend/main.py index 619cfc2..7e15ed0 100644 --- a/src/webssh_backend/main.py +++ b/src/webssh_backend/main.py @@ -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): diff --git a/src/webssh_backend/telemetry.py b/src/webssh_backend/telemetry.py new file mode 100644 index 0000000..1bf8de7 --- /dev/null +++ b/src/webssh_backend/telemetry.py @@ -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 \ No newline at end of file diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py new file mode 100644 index 0000000..56c39e9 --- /dev/null +++ b/tests/test_telemetry.py @@ -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"]) \ No newline at end of file