From 892e66aa8984b8c440542c8066ecdcffa1b684e4 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:52:26 +0200 Subject: [PATCH 01/18] security: fix 12 audit findings (4 critical, 3 high, 5 medium) Critical: - Worker auth no longer disabled when API key is empty; generates ephemeral key and logs a warning if CASHPILOT_API_KEY is unset - Secret key auto-generated and persisted to .secret_key if env var is missing or set to a known default like "changeme-..." - Fleet API key endpoint restricted to owner role (was any auth) - Credentials encrypted at rest with Fernet; existing plaintext values decrypted transparently (backward compatible) High: - /settings page, /api/config, and /api/collectors/meta restricted to owner role (viewers could read all credentials) - Worker proxy helpers now check resp.status_code and raise on 4xx/5xx instead of silently returning error responses as 200 - presearch.yml volumes changed from mapping to colon-delimited string so the deploy code can actually parse them Medium: - PacketStream collector returns error when HTML parsing fails instead of silently reporting balance=0 - Traffmonetizer factory wires email/password as optional args so the collector's login fallback is actually reachable - Chart.js race condition fixed with async guard before new Chart() - CI updated to Python 3.14 (matching shipped Docker images) - New catalog tests: docker section required, volumes must be strings --- .github/workflows/test.yml | 2 +- app/auth.py | 56 +++++++++++++++++++- app/collectors/__init__.py | 2 +- app/collectors/packetstream.py | 9 ++++ app/database.py | 95 +++++++++++++++++++++++++++++++--- app/main.py | 29 +++++++++-- app/static/js/app.js | 10 ++++ app/worker_api.py | 13 +++-- services/depin/presearch.yml | 3 +- tests/test_catalog.py | 17 ++++++ 10 files changed, 218 insertions(+), 18 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index caf8fbe..de81d51 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.14" - name: Install dependencies run: | diff --git a/app/auth.py b/app/auth.py index b0731a4..56577fb 100644 --- a/app/auth.py +++ b/app/auth.py @@ -5,7 +5,10 @@ from __future__ import annotations +import logging import os +import secrets +from pathlib import Path from typing import Any from fastapi import Request @@ -13,7 +16,58 @@ from itsdangerous import BadSignature, URLSafeTimedSerializer from passlib.hash import bcrypt -SECRET_KEY = os.getenv("CASHPILOT_SECRET_KEY", "changeme-generate-a-random-secret") +_logger = logging.getLogger(__name__) + +_KNOWN_DEFAULTS = { + "changeme-generate-a-random-secret", + "changeme", + "", +} + + +def _resolve_secret_key() -> str: + """Return a cryptographically safe secret key. + + Priority: + 1. CASHPILOT_SECRET_KEY env var (if not a known default) + 2. Persisted key in /.secret_key + 3. Generate, persist, and return a new random key + """ + env_key = os.getenv("CASHPILOT_SECRET_KEY", "") + if env_key and env_key not in _KNOWN_DEFAULTS: + return env_key + + if env_key in _KNOWN_DEFAULTS and env_key: + _logger.warning( + "CASHPILOT_SECRET_KEY is set to a known default — ignoring it. " + "Set a strong random value or remove it to auto-generate." + ) + + # Try to read persisted key + data_dir = Path(os.getenv("CASHPILOT_DATA_DIR", "/data")) + key_file = data_dir / ".secret_key" + try: + if key_file.is_file(): + stored = key_file.read_text().strip() + if stored and stored not in _KNOWN_DEFAULTS: + return stored + except OSError: + pass + + # Generate and persist + new_key = secrets.token_urlsafe(48) + try: + data_dir.mkdir(parents=True, exist_ok=True) + key_file.write_text(new_key) + key_file.chmod(0o600) + _logger.info("Generated and persisted new secret key to %s", key_file) + except OSError as exc: + _logger.warning("Could not persist secret key to %s: %s", key_file, exc) + + return new_key + + +SECRET_KEY = _resolve_secret_key() SESSION_COOKIE = "cashpilot_session" SESSION_MAX_AGE = 60 * 60 * 24 * 30 # 30 days diff --git a/app/collectors/__init__.py b/app/collectors/__init__.py index 5000971..1ae734e 100644 --- a/app/collectors/__init__.py +++ b/app/collectors/__init__.py @@ -50,7 +50,7 @@ "iproyal": ["email", "password"], "mysterium": ["email", "password"], "storj": ["api_url"], - "traffmonetizer": ["token"], + "traffmonetizer": ["?token", "?email", "?password"], "repocket": ["email", "password"], "proxyrack": ["api_key"], "bitping": ["email", "password"], diff --git a/app/collectors/packetstream.py b/app/collectors/packetstream.py index cc5f191..bc69805 100644 --- a/app/collectors/packetstream.py +++ b/app/collectors/packetstream.py @@ -65,6 +65,15 @@ async def collect(self) -> EarningsResult: if match: balance = float(match.group(1)) + # If no pattern matched at all, report an error rather than + # silently returning 0 (which hides integration breakage). + if balance == 0.0 and not match and "userData" not in html: + return EarningsResult( + platform=self.platform, + balance=0.0, + error="Could not parse balance from dashboard — page structure may have changed", + ) + return EarningsResult( platform=self.platform, balance=round(balance, 4), diff --git a/app/database.py b/app/database.py index e5be6bb..37405e4 100644 --- a/app/database.py +++ b/app/database.py @@ -7,16 +7,89 @@ from __future__ import annotations +import logging import os from datetime import datetime, timedelta from pathlib import Path from typing import Any import aiosqlite +from cryptography.fernet import Fernet, InvalidToken + +_logger = logging.getLogger(__name__) DB_DIR = Path(os.getenv("CASHPILOT_DATA_DIR", "/data")) DB_PATH = DB_DIR / "cashpilot.db" +# --------------------------------------------------------------------------- +# Credential encryption (Fernet) +# --------------------------------------------------------------------------- + +_FERNET_KEY_FILE = DB_DIR / ".fernet_key" + +# Keys that contain secrets and must be encrypted at rest +SECRET_CONFIG_KEYS = { + "password", + "token", + "auth_token", + "access_token", + "api_key", + "session_cookie", + "oauth_token", + "brd_sess_id", + "remember_web", + "xsrf_token", +} + + +def _is_secret_key(key: str) -> bool: + """Return True if a config key holds a secret value (by suffix match).""" + lower = key.lower() + return any(lower.endswith(s) for s in SECRET_CONFIG_KEYS) + + +def _load_or_create_fernet() -> Fernet: + """Load or generate the Fernet encryption key.""" + try: + if _FERNET_KEY_FILE.is_file(): + raw = _FERNET_KEY_FILE.read_text().strip() + if raw: + return Fernet(raw.encode()) + except (OSError, ValueError): + pass + + key = Fernet.generate_key() + try: + DB_DIR.mkdir(parents=True, exist_ok=True) + _FERNET_KEY_FILE.write_text(key.decode()) + _FERNET_KEY_FILE.chmod(0o600) + _logger.info("Generated new Fernet key at %s", _FERNET_KEY_FILE) + except OSError as exc: + _logger.warning("Could not persist Fernet key: %s", exc) + return Fernet(key) + + +_fernet = _load_or_create_fernet() + +_ENC_PREFIX = "enc:" + + +def encrypt_value(value: str) -> str: + """Encrypt a string value, returning an 'enc:' prefixed token.""" + return _ENC_PREFIX + _fernet.encrypt(value.encode()).decode() + + +def decrypt_value(value: str) -> str: + """Decrypt an 'enc:' prefixed token back to plaintext.""" + if not value.startswith(_ENC_PREFIX): + return value # Not encrypted (legacy data) + try: + return _fernet.decrypt(value[len(_ENC_PREFIX) :].encode()).decode() + except InvalidToken: + _logger.warning("Failed to decrypt config value — key may have changed") + return "" + + _SCHEMA = """ CREATE TABLE IF NOT EXISTS earnings ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -389,27 +462,34 @@ async def get_daily_earnings(days: int = 7) -> list[dict[str, Any]]: async def get_config(key: str | None = None) -> dict[str, str] | str | None: - """Get a single config value (if key given) or all config as a dict.""" + """Get a single config value (if key given) or all config as a dict. + + Secret values are decrypted transparently. + """ db = await _get_db() try: if key: cursor = await db.execute("SELECT value FROM config WHERE key = ?", (key,)) row = await cursor.fetchone() - return row["value"] if row else None + if not row: + return None + val = row["value"] + return decrypt_value(val) if _is_secret_key(key) else val cursor = await db.execute("SELECT key, value FROM config") rows = await cursor.fetchall() - return {r["key"]: r["value"] for r in rows} + return {r["key"]: (decrypt_value(r["value"]) if _is_secret_key(r["key"]) else r["value"]) for r in rows} finally: await db.close() async def set_config(key: str, value: str) -> None: - """Upsert a config key-value pair.""" + """Upsert a config key-value pair. Secrets are encrypted at rest.""" + stored = encrypt_value(value) if _is_secret_key(key) else value db = await _get_db() try: await db.execute( "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", - (key, value), + (key, stored), ) await db.commit() finally: @@ -417,12 +497,13 @@ async def set_config(key: str, value: str) -> None: async def set_config_bulk(data: dict[str, str]) -> None: - """Upsert multiple config entries at once.""" + """Upsert multiple config entries at once. Secrets are encrypted at rest.""" + pairs = [(k, encrypt_value(v) if _is_secret_key(k) else v) for k, v in data.items()] db = await _get_db() try: await db.executemany( "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", - list(data.items()), + pairs, ) await db.commit() finally: diff --git a/app/main.py b/app/main.py index 0c9ff43..771172d 100644 --- a/app/main.py +++ b/app/main.py @@ -455,6 +455,8 @@ async def page_settings(request: Request): user = auth.get_current_user(request) if not user: return _login_redirect() + if not auth.require_role(user, "owner"): + raise HTTPException(status_code=403, detail="Owner access required") return templates.TemplateResponse(request, "settings.html", {"user": user}) @@ -826,6 +828,13 @@ async def _proxy_worker_command(worker_id: int, command: str, slug: str) -> dict resp = await client.delete(f"{url}/api/containers/{slug}", headers=headers) else: resp = await client.post(f"{url}/api/containers/{slug}/{command}", headers=headers) + if resp.status_code >= 400: + detail = ( + resp.json().get("detail", resp.text) + if resp.headers.get("content-type", "").startswith("application/json") + else resp.text + ) + raise HTTPException(status_code=resp.status_code, detail=f"Worker error: {detail}") return resp.json() except httpx.HTTPError as exc: raise HTTPException(status_code=503, detail=f"Worker communication failed: {exc}") @@ -849,6 +858,13 @@ async def _proxy_worker_deploy(worker_id: int, slug: str, spec: dict[str, Any]) try: async with httpx.AsyncClient(timeout=60) as client: resp = await client.post(f"{url}/api/containers/{slug}/deploy", json=spec, headers=headers) + if resp.status_code >= 400: + detail = ( + resp.json().get("detail", resp.text) + if resp.headers.get("content-type", "").startswith("application/json") + else resp.text + ) + raise HTTPException(status_code=resp.status_code, detail=f"Worker deploy error: {detail}") return resp.json() except httpx.HTTPError as exc: raise HTTPException(status_code=503, detail=f"Worker communication failed: {exc}") @@ -876,6 +892,13 @@ async def _proxy_worker_logs(worker_id: int, slug: str, lines: int = 50) -> dict params={"lines": min(lines, 1000)}, headers=headers, ) + if resp.status_code >= 400: + detail = ( + resp.json().get("detail", resp.text) + if resp.headers.get("content-type", "").startswith("application/json") + else resp.text + ) + raise HTTPException(status_code=resp.status_code, detail=f"Worker error: {detail}") return resp.json() except httpx.HTTPError as exc: raise HTTPException(status_code=503, detail=f"Worker communication failed: {exc}") @@ -1218,7 +1241,7 @@ async def api_env_info(request: Request) -> list[dict[str, Any]]: @app.get("/api/collectors/meta") async def api_collectors_meta(request: Request) -> list[dict[str, Any]]: - _require_auth_api(request) + _require_owner(request) from app.collectors import _COLLECTOR_ARGS, COLLECTOR_MAP secret_args = { @@ -1260,7 +1283,7 @@ async def api_collectors_meta(request: Request) -> list[dict[str, Any]]: @app.get("/api/config") async def api_get_config(request: Request) -> dict[str, str]: - _require_auth_api(request) + _require_owner(request) result = await database.get_config() if isinstance(result, dict): return result @@ -1481,5 +1504,5 @@ async def api_fleet_summary(request: Request) -> dict[str, Any]: @app.get("/api/fleet/api-key") async def api_fleet_api_key(request: Request) -> dict[str, str]: """Return the configured fleet API key (owner only).""" - _require_auth_api(request) + _require_owner(request) return {"api_key": FLEET_API_KEY or ""} diff --git a/app/static/js/app.js b/app/static/js/app.js index 9a8d77f..854da5c 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -561,9 +561,19 @@ const CP = (() => { toast('Services refreshed', 'info'); } + async function _waitForChart() { + if (typeof Chart !== 'undefined') return; + return new Promise(resolve => { + const id = setInterval(() => { if (typeof Chart !== 'undefined') { clearInterval(id); resolve(); } }, 100); + setTimeout(() => { clearInterval(id); resolve(); }, 5000); + }); + } + async function loadEarningsChart(days) { const ctx = document.getElementById('earnings-chart'); if (!ctx) return; + await _waitForChart(); + if (typeof Chart === 'undefined') return; // Highlight active tab document.querySelectorAll('.chart-period-btn').forEach(btn => { diff --git a/app/worker_api.py b/app/worker_api.py index 1b4e1a1..7fe0088 100644 --- a/app/worker_api.py +++ b/app/worker_api.py @@ -39,7 +39,16 @@ # --------------------------------------------------------------------------- UI_URL = os.getenv("CASHPILOT_UI_URL", "") -API_KEY = os.getenv("CASHPILOT_API_KEY", "") +_configured_key = os.getenv("CASHPILOT_API_KEY", "") +if not _configured_key: + import secrets as _secrets + + _configured_key = _secrets.token_urlsafe(32) + logger.warning( + "CASHPILOT_API_KEY not set — generated ephemeral key. " + "Workers and UI MUST share the same key. Set CASHPILOT_API_KEY in your environment." + ) +API_KEY: str = _configured_key WORKER_NAME = os.getenv("CASHPILOT_WORKER_NAME", socket.gethostname()) WORKER_PORT = int(os.getenv("CASHPILOT_PORT", "8081")) HEARTBEAT_INTERVAL = 60 # seconds @@ -57,8 +66,6 @@ def _verify_api_key(request: Request) -> None: """Verify the shared API key from Authorization header.""" - if not API_KEY: - return # No key = no auth (local-only / standalone) auth = request.headers.get("Authorization", "") if auth != f"Bearer {API_KEY}": raise HTTPException(status_code=401, detail="Invalid API key") diff --git a/services/depin/presearch.yml b/services/depin/presearch.yml index 8cdb0b3..042a7a4 100644 --- a/services/depin/presearch.yml +++ b/services/depin/presearch.yml @@ -24,8 +24,7 @@ docker: description: "Your Presearch node registration code from the dashboard" ports: [] volumes: - - container_path: /app/node - description: "Node data and keys" + - "/data/presearch-node:/app/node" command: "" network_mode: "" diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 9263e1d..e373323 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -58,6 +58,23 @@ def test_docker_has_image(self, yml_path): if "docker" in data and data["docker"]: assert "image" in data["docker"], f"{yml_path.name}: docker section missing image" + def test_docker_section_present(self, yml_path): + """Runtime catalog loader expects a docker section on every service.""" + with open(yml_path) as f: + data = yaml.safe_load(f) + assert "docker" in data, f"{yml_path.name}: missing docker section (required at runtime)" + + def test_volumes_are_strings(self, yml_path): + """Deploy code splits volumes on ':'. Mappings break it.""" + with open(yml_path) as f: + data = yaml.safe_load(f) + volumes = (data.get("docker") or {}).get("volumes") or [] + for v in volumes: + assert isinstance(v, str), ( + f"{yml_path.name}: volume must be a colon-delimited string, " + f"got {type(v).__name__}: {v}" + ) + def test_slug_matches_filename(self, yml_path): with open(yml_path) as f: data = yaml.safe_load(f) From 79fd161ec089a9d58d2cce3807e5f544ff78602f Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:18:06 +0200 Subject: [PATCH 02/18] fix: address remaining security review findings - Separate API key roles: CASHPILOT_ADMIN_API_KEY for owner, fleet key gives writer only - Fix default compose path: skip fleet auth when no key configured instead of rejecting - Remove ephemeral key generation from worker (caused key mismatch) - Add secret_key to encrypted config key suffixes - Add status code check in api_worker_command proxy - Tighten PacketStream zero-balance detection - Require writer role for log access (was viewer) --- app/auth.py | 13 ++++++++----- app/collectors/packetstream.py | 2 +- app/database.py | 1 + app/main.py | 6 ++++-- app/worker_api.py | 16 +++++++--------- docker-compose.yml | 1 + 6 files changed, 22 insertions(+), 17 deletions(-) diff --git a/app/auth.py b/app/auth.py index 56577fb..f2e1872 100644 --- a/app/auth.py +++ b/app/auth.py @@ -100,12 +100,15 @@ def get_current_user(request: Request) -> dict[str, Any] | None: Checks Authorization header first (for programmatic access like Home Assistant), then falls back to session cookie (for browser sessions). """ - # Check Bearer token against CASHPILOT_API_KEY - api_key = os.getenv("CASHPILOT_API_KEY", "") - if api_key: - auth_header = request.headers.get("Authorization", "") - if auth_header == f"Bearer {api_key}": + # Check Bearer token — admin key gets owner, fleet key gets writer + auth_header = request.headers.get("Authorization", "") + if auth_header: + admin_key = os.getenv("CASHPILOT_ADMIN_API_KEY", "") + if admin_key and auth_header == f"Bearer {admin_key}": return {"uid": 0, "u": "api", "r": "owner"} + fleet_key = os.getenv("CASHPILOT_API_KEY", "") + if fleet_key and auth_header == f"Bearer {fleet_key}": + return {"uid": 0, "u": "api", "r": "writer"} # Fall back to session cookie token = request.cookies.get(SESSION_COOKIE) diff --git a/app/collectors/packetstream.py b/app/collectors/packetstream.py index bc69805..dd9f8e4 100644 --- a/app/collectors/packetstream.py +++ b/app/collectors/packetstream.py @@ -67,7 +67,7 @@ async def collect(self) -> EarningsResult: # If no pattern matched at all, report an error rather than # silently returning 0 (which hides integration breakage). - if balance == 0.0 and not match and "userData" not in html: + if balance == 0.0: return EarningsResult( platform=self.platform, balance=0.0, diff --git a/app/database.py b/app/database.py index 37405e4..dfb2f86 100644 --- a/app/database.py +++ b/app/database.py @@ -34,6 +34,7 @@ "auth_token", "access_token", "api_key", + "secret_key", "session_cookie", "oauth_token", "brd_sess_id", diff --git a/app/main.py b/app/main.py index 771172d..9e80f9e 100644 --- a/app/main.py +++ b/app/main.py @@ -940,7 +940,7 @@ async def api_service_start(request: Request, slug: str, worker_id: int | None = async def api_service_logs( request: Request, slug: str, lines: int = 50, worker_id: int | None = None ) -> dict[str, str]: - _require_auth_api(request) + _require_writer(request) _require_worker_id(worker_id) return await _proxy_worker_logs(worker_id, slug, lines) # type: ignore[arg-type] @@ -1361,7 +1361,7 @@ async def page_fleet(request: Request): def _verify_fleet_api_key(request: Request) -> None: """Verify the shared fleet API key from a worker's request.""" if not FLEET_API_KEY: - raise HTTPException(status_code=403, detail="Fleet API key not configured") + return # No key configured — local compose, skip auth auth_header = request.headers.get("Authorization", "") if auth_header != f"Bearer {FLEET_API_KEY}": raise HTTPException(status_code=401, detail="Invalid API key") @@ -1471,6 +1471,8 @@ async def api_worker_command(request: Request, worker_id: int, body: WorkerComma else: raise HTTPException(status_code=400, detail=f"Unknown command: {body.command}") + if resp.status_code >= 400: + raise HTTPException(status_code=resp.status_code, detail=resp.text) return resp.json() except httpx.HTTPError as exc: raise HTTPException(status_code=503, detail=f"Worker communication failed: {exc}") diff --git a/app/worker_api.py b/app/worker_api.py index 7fe0088..3fe7f6c 100644 --- a/app/worker_api.py +++ b/app/worker_api.py @@ -39,16 +39,12 @@ # --------------------------------------------------------------------------- UI_URL = os.getenv("CASHPILOT_UI_URL", "") -_configured_key = os.getenv("CASHPILOT_API_KEY", "") -if not _configured_key: - import secrets as _secrets - - _configured_key = _secrets.token_urlsafe(32) +API_KEY: str = os.getenv("CASHPILOT_API_KEY", "") +if not API_KEY: logger.warning( - "CASHPILOT_API_KEY not set — generated ephemeral key. " - "Workers and UI MUST share the same key. Set CASHPILOT_API_KEY in your environment." + "CASHPILOT_API_KEY not set — fleet auth disabled for local compose. " + "Set CASHPILOT_API_KEY for secure worker-UI communication." ) -API_KEY: str = _configured_key WORKER_NAME = os.getenv("CASHPILOT_WORKER_NAME", socket.gethostname()) WORKER_PORT = int(os.getenv("CASHPILOT_PORT", "8081")) HEARTBEAT_INTERVAL = 60 # seconds @@ -66,6 +62,8 @@ def _verify_api_key(request: Request) -> None: """Verify the shared API key from Authorization header.""" + if not API_KEY: + return # No key configured — local compose, skip auth auth = request.headers.get("Authorization", "") if auth != f"Bearer {API_KEY}": raise HTTPException(status_code=401, detail="Invalid API key") @@ -101,7 +99,7 @@ async def _send_heartbeat() -> None: resp = await client.post( f"{UI_URL.rstrip('/')}/api/workers/heartbeat", json=payload, - headers={"Authorization": f"Bearer {API_KEY}"}, + headers={"Authorization": f"Bearer {API_KEY}"} if API_KEY else {}, ) resp.raise_for_status() _ui_connected = True diff --git a/docker-compose.yml b/docker-compose.yml index a0fcb96..10e78e7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,7 @@ services: - TZ=${TZ:-UTC} - CASHPILOT_SECRET_KEY=${CASHPILOT_SECRET_KEY:-} - CASHPILOT_API_KEY=${CASHPILOT_API_KEY:-} + - CASHPILOT_ADMIN_API_KEY=${CASHPILOT_ADMIN_API_KEY:-} init: true restart: unless-stopped security_opt: From 323d77531835268315c102f433f06653e88beb93 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:28:40 +0200 Subject: [PATCH 03/18] fix: close unauthenticated worker exposure on default compose - Remove host port binding for worker (expose-only within Docker network) - Start heartbeat loop when UI_URL is set regardless of API_KEY - Track parse success in PacketStream separately from balance value --- app/collectors/packetstream.py | 7 +++++-- app/worker_api.py | 6 ++++-- docker-compose.yml | 4 ++-- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/collectors/packetstream.py b/app/collectors/packetstream.py index dd9f8e4..4cc8d27 100644 --- a/app/collectors/packetstream.py +++ b/app/collectors/packetstream.py @@ -46,6 +46,7 @@ async def collect(self) -> EarningsResult: # Extract balance from window.userData in the HTML balance = 0.0 + parsed = False match = re.search( r"window\.userData\s*=\s*(\{[^}]+\})", html, @@ -56,18 +57,20 @@ async def collect(self) -> EarningsResult: try: user_data = json.loads(match.group(1)) balance = float(user_data.get("balance", 0)) + parsed = True except (json.JSONDecodeError, ValueError): pass # Fallback: look for balance pattern - if balance == 0.0: + if not parsed: match = re.search(r'"balance"\s*:\s*([\d.]+)', html) if match: balance = float(match.group(1)) + parsed = True # If no pattern matched at all, report an error rather than # silently returning 0 (which hides integration breakage). - if balance == 0.0: + if not parsed: return EarningsResult( platform=self.platform, balance=0.0, diff --git a/app/worker_api.py b/app/worker_api.py index 3fe7f6c..161db05 100644 --- a/app/worker_api.py +++ b/app/worker_api.py @@ -146,11 +146,13 @@ async def lifespan(app: FastAPI): docker_mode = "direct" if orchestrator.docker_available() else "monitor-only" logger.info("Docker: %s", docker_mode) - if UI_URL and API_KEY: + if UI_URL: _heartbeat_task = asyncio.create_task(_heartbeat_loop()) logger.info("Heartbeat enabled -> %s (every %ds)", UI_URL, HEARTBEAT_INTERVAL) + if not API_KEY: + logger.warning("CASHPILOT_API_KEY not set — heartbeats sent without auth") else: - logger.warning("No CASHPILOT_UI_URL or CASHPILOT_API_KEY — running without UI connection") + logger.warning("No CASHPILOT_UI_URL — running without UI connection") yield diff --git a/docker-compose.yml b/docker-compose.yml index 10e78e7..cd28962 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,8 +30,8 @@ services: dockerfile: Dockerfile.worker image: drumsergio/cashpilot-worker:latest container_name: cashpilot-worker - ports: - - "8081:8081" + expose: + - "8081" volumes: - /var/run/docker.sock:/var/run/docker.sock - cashpilot_worker_data:/data From d10052041ee3226d9e9494ae4766f3e63891ecf5 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:37:48 +0200 Subject: [PATCH 04/18] fix: pin worker URL to request source IP in no-key mode In unauthenticated local compose mode, derive the worker URL from request.client.host instead of trusting the caller-supplied URL. Prevents URL injection via heartbeat spoofing on the Docker network. --- app/main.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index 9e80f9e..5ad1596 100644 --- a/app/main.py +++ b/app/main.py @@ -1378,9 +1378,19 @@ class WorkerHeartbeat(BaseModel): async def api_worker_heartbeat(request: Request, body: WorkerHeartbeat) -> dict[str, Any]: """Receive a heartbeat from a worker. Registers or updates the worker.""" _verify_fleet_api_key(request) + + # In no-key mode, derive the worker URL from the request source IP + # instead of trusting the caller-supplied URL (prevents URL injection). + worker_url = body.url + if not FLEET_API_KEY and body.url and request.client: + from urllib.parse import urlparse + + parsed = urlparse(body.url) + worker_url = f"{parsed.scheme}://{request.client.host}:{parsed.port or 8081}" + worker_id = await database.upsert_worker( name=body.name, - url=body.url, + url=worker_url, containers=json.dumps(body.containers), system_info=json.dumps(body.system_info), ) From 112376ead01f2ab45b07db2a3a63aeadf51434a8 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:46:03 +0200 Subject: [PATCH 05/18] fix: auto-generate shared fleet key, close worker impersonation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add app/fleet_key.py: both UI and worker resolve the fleet API key from CASHPILOT_API_KEY env var or a shared /fleet volume. First container to start generates the key atomically (O_EXCL); the second reads it. No-key mode is eliminated — heartbeats always require authentication. - Add cashpilot_fleet shared volume in docker-compose.yml - Remove all skip-auth fallbacks from _verify_fleet_api_key and _verify_api_key - Remove URL pinning workaround (auth makes it unnecessary) - Fix ruff format on test_catalog.py for CI --- app/fleet_key.py | 64 +++++++++++++++++++++++++++++++++++++++++++ app/main.py | 21 +++++--------- app/worker_api.py | 13 ++++----- docker-compose.yml | 3 ++ tests/test_catalog.py | 3 +- 5 files changed, 80 insertions(+), 24 deletions(-) create mode 100644 app/fleet_key.py diff --git a/app/fleet_key.py b/app/fleet_key.py new file mode 100644 index 0000000..44ff457 --- /dev/null +++ b/app/fleet_key.py @@ -0,0 +1,64 @@ +"""Auto-generate and share a fleet API key between UI and worker containers. + +When CASHPILOT_API_KEY is not set, both containers resolve the key from a +shared volume at /fleet/.fleet_key. The first container to start generates +the key atomically; the second reads it. +""" + +from __future__ import annotations + +import logging +import os +import secrets +from pathlib import Path + +_logger = logging.getLogger(__name__) + +_FLEET_KEY_DIR = Path(os.getenv("CASHPILOT_FLEET_DIR", "/fleet")) +_FLEET_KEY_FILE = _FLEET_KEY_DIR / ".fleet_key" + + +def resolve_fleet_key() -> str: + """Resolve the fleet API key. + + Priority: + 1. CASHPILOT_API_KEY env var (explicit configuration) + 2. Shared key file at /fleet/.fleet_key (auto-generated on first use) + """ + key = os.getenv("CASHPILOT_API_KEY", "") + if key: + return key + + # Try to read existing shared key file + try: + if _FLEET_KEY_FILE.is_file(): + stored = _FLEET_KEY_FILE.read_text().strip() + if stored: + _logger.info("Loaded fleet key from %s", _FLEET_KEY_FILE) + return stored + except OSError: + pass + + # Auto-generate and persist atomically (O_EXCL = only one writer wins) + new_key = secrets.token_urlsafe(32) + try: + _FLEET_KEY_DIR.mkdir(parents=True, exist_ok=True) + fd = os.open(str(_FLEET_KEY_FILE), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + os.write(fd, new_key.encode()) + os.close(fd) + _logger.info("Generated shared fleet key at %s", _FLEET_KEY_FILE) + return new_key + except FileExistsError: + # Other container created it first + try: + return _FLEET_KEY_FILE.read_text().strip() + except OSError: + pass + except OSError as exc: + _logger.warning( + "Could not persist fleet key to %s: %s — set CASHPILOT_API_KEY or mount a shared /fleet volume", + _FLEET_KEY_FILE, + exc, + ) + + return "" diff --git a/app/main.py b/app/main.py index 5ad1596..578c541 100644 --- a/app/main.py +++ b/app/main.py @@ -21,7 +21,7 @@ from fastapi.templating import Jinja2Templates from pydantic import BaseModel -from app import auth, catalog, compose_generator, database, exchange_rates +from app import auth, catalog, compose_generator, database, exchange_rates, fleet_key logging.basicConfig( level=logging.INFO, @@ -164,7 +164,7 @@ async def _check_stale_workers() -> None: logger.debug("Stale worker check error: %s", exc) -FLEET_API_KEY = os.getenv("CASHPILOT_API_KEY", "") +FLEET_API_KEY = fleet_key.resolve_fleet_key() HOSTNAME_PREFIX = os.getenv("CASHPILOT_HOSTNAME_PREFIX", "cashpilot") COLLECT_INTERVAL_MIN = int(os.getenv("CASHPILOT_COLLECT_INTERVAL", "60")) STALE_WORKER_SECONDS = 180 # Mark worker offline after 3 missed heartbeats @@ -1361,7 +1361,10 @@ async def page_fleet(request: Request): def _verify_fleet_api_key(request: Request) -> None: """Verify the shared fleet API key from a worker's request.""" if not FLEET_API_KEY: - return # No key configured — local compose, skip auth + raise HTTPException( + status_code=503, + detail="Fleet key not configured — set CASHPILOT_API_KEY or mount shared /fleet volume", + ) auth_header = request.headers.get("Authorization", "") if auth_header != f"Bearer {FLEET_API_KEY}": raise HTTPException(status_code=401, detail="Invalid API key") @@ -1378,19 +1381,9 @@ class WorkerHeartbeat(BaseModel): async def api_worker_heartbeat(request: Request, body: WorkerHeartbeat) -> dict[str, Any]: """Receive a heartbeat from a worker. Registers or updates the worker.""" _verify_fleet_api_key(request) - - # In no-key mode, derive the worker URL from the request source IP - # instead of trusting the caller-supplied URL (prevents URL injection). - worker_url = body.url - if not FLEET_API_KEY and body.url and request.client: - from urllib.parse import urlparse - - parsed = urlparse(body.url) - worker_url = f"{parsed.scheme}://{request.client.host}:{parsed.port or 8081}" - worker_id = await database.upsert_worker( name=body.name, - url=worker_url, + url=body.url, containers=json.dumps(body.containers), system_info=json.dumps(body.system_info), ) diff --git a/app/worker_api.py b/app/worker_api.py index 161db05..4f05c87 100644 --- a/app/worker_api.py +++ b/app/worker_api.py @@ -26,7 +26,7 @@ from fastapi.responses import HTMLResponse from pydantic import BaseModel -from app import orchestrator +from app import fleet_key, orchestrator logging.basicConfig( level=logging.INFO, @@ -39,12 +39,9 @@ # --------------------------------------------------------------------------- UI_URL = os.getenv("CASHPILOT_UI_URL", "") -API_KEY: str = os.getenv("CASHPILOT_API_KEY", "") +API_KEY: str = fleet_key.resolve_fleet_key() if not API_KEY: - logger.warning( - "CASHPILOT_API_KEY not set — fleet auth disabled for local compose. " - "Set CASHPILOT_API_KEY for secure worker-UI communication." - ) + logger.warning("Could not resolve fleet API key — set CASHPILOT_API_KEY or mount a shared /fleet volume") WORKER_NAME = os.getenv("CASHPILOT_WORKER_NAME", socket.gethostname()) WORKER_PORT = int(os.getenv("CASHPILOT_PORT", "8081")) HEARTBEAT_INTERVAL = 60 # seconds @@ -63,7 +60,7 @@ def _verify_api_key(request: Request) -> None: """Verify the shared API key from Authorization header.""" if not API_KEY: - return # No key configured — local compose, skip auth + raise HTTPException(status_code=503, detail="Fleet key not configured") auth = request.headers.get("Authorization", "") if auth != f"Bearer {API_KEY}": raise HTTPException(status_code=401, detail="Invalid API key") @@ -99,7 +96,7 @@ async def _send_heartbeat() -> None: resp = await client.post( f"{UI_URL.rstrip('/')}/api/workers/heartbeat", json=payload, - headers={"Authorization": f"Bearer {API_KEY}"} if API_KEY else {}, + headers={"Authorization": f"Bearer {API_KEY}"}, ) resp.raise_for_status() _ui_connected = True diff --git a/docker-compose.yml b/docker-compose.yml index cd28962..35ac78d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,7 @@ services: - "8080:8080" volumes: - cashpilot_data:/data + - cashpilot_fleet:/fleet environment: - TZ=${TZ:-UTC} - CASHPILOT_SECRET_KEY=${CASHPILOT_SECRET_KEY:-} @@ -35,6 +36,7 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock - cashpilot_worker_data:/data + - cashpilot_fleet:/fleet environment: - TZ=${TZ:-UTC} - CASHPILOT_UI_URL=http://cashpilot-ui:8080 @@ -50,3 +52,4 @@ services: volumes: cashpilot_data: cashpilot_worker_data: + cashpilot_fleet: diff --git a/tests/test_catalog.py b/tests/test_catalog.py index e373323..2e5d4e1 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -71,8 +71,7 @@ def test_volumes_are_strings(self, yml_path): volumes = (data.get("docker") or {}).get("volumes") or [] for v in volumes: assert isinstance(v, str), ( - f"{yml_path.name}: volume must be a colon-delimited string, " - f"got {type(v).__name__}: {v}" + f"{yml_path.name}: volume must be a colon-delimited string, got {type(v).__name__}: {v}" ) def test_slug_matches_filename(self, yml_path): From 1206d39ec179e5bf0af061f3369c11121826a6a7 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:50:54 +0200 Subject: [PATCH 06/18] fix: close fleet key first-boot race with retry-read After FileExistsError (other container won the O_EXCL create), poll for file content with 100ms backoff (2s max) instead of a single read that can observe the empty file before the writer finishes. --- app/fleet_key.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/app/fleet_key.py b/app/fleet_key.py index 44ff457..f2d6051 100644 --- a/app/fleet_key.py +++ b/app/fleet_key.py @@ -10,6 +10,7 @@ import logging import os import secrets +import time from pathlib import Path _logger = logging.getLogger(__name__) @@ -49,11 +50,17 @@ def resolve_fleet_key() -> str: _logger.info("Generated shared fleet key at %s", _FLEET_KEY_FILE) return new_key except FileExistsError: - # Other container created it first - try: - return _FLEET_KEY_FILE.read_text().strip() - except OSError: - pass + # Other container created it first — poll briefly for content + # (file exists but may be empty until the writer finishes) + for _ in range(20): + try: + stored = _FLEET_KEY_FILE.read_text().strip() + if stored: + _logger.info("Loaded fleet key from %s", _FLEET_KEY_FILE) + return stored + except OSError: + pass + time.sleep(0.1) except OSError as exc: _logger.warning( "Could not persist fleet key to %s: %s — set CASHPILOT_API_KEY or mount a shared /fleet volume", From 8c2b6933a281d0d00dfdc9765d4758e373b93d74 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:23:19 +0200 Subject: [PATCH 07/18] test: add fleet key resolution and bootstrap coverage 9 tests covering: env var priority, file read, key generation, persistence stability, O_EXCL race simulation, empty file handling, unwritable directory fallback, and file permissions. --- tests/test_fleet_key.py | 113 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/test_fleet_key.py diff --git a/tests/test_fleet_key.py b/tests/test_fleet_key.py new file mode 100644 index 0000000..3aa3cad --- /dev/null +++ b/tests/test_fleet_key.py @@ -0,0 +1,113 @@ +"""Tests for fleet key resolution and bootstrap logic.""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from app import fleet_key + + +@pytest.fixture +def fleet_dir(tmp_path: Path): + """Point fleet key resolution at a temp directory.""" + key_dir = tmp_path / "fleet" + key_dir.mkdir() + with ( + patch.object(fleet_key, "_FLEET_KEY_DIR", key_dir), + patch.object(fleet_key, "_FLEET_KEY_FILE", key_dir / ".fleet_key"), + ): + yield key_dir + + +class TestResolveFleetKey: + def test_env_var_takes_priority(self, fleet_dir: Path): + """CASHPILOT_API_KEY env var should be returned without touching the filesystem.""" + with patch.dict(os.environ, {"CASHPILOT_API_KEY": "env-key-123"}): + assert fleet_key.resolve_fleet_key() == "env-key-123" + # No file should have been created + assert not (fleet_dir / ".fleet_key").exists() + + def test_reads_existing_key_file(self, fleet_dir: Path): + """If the key file already exists, read it.""" + key_file = fleet_dir / ".fleet_key" + key_file.write_text("existing-shared-key") + with patch.dict(os.environ, {"CASHPILOT_API_KEY": ""}): + assert fleet_key.resolve_fleet_key() == "existing-shared-key" + + def test_generates_key_when_no_file(self, fleet_dir: Path): + """When no env var and no file, generate and persist a new key.""" + key_file = fleet_dir / ".fleet_key" + with patch.dict(os.environ, {"CASHPILOT_API_KEY": ""}): + key = fleet_key.resolve_fleet_key() + assert key # non-empty + assert len(key) > 20 # token_urlsafe(32) produces ~43 chars + assert key_file.read_text() == key + + def test_generated_key_is_stable(self, fleet_dir: Path): + """Second call reads the persisted file, returns the same key.""" + with patch.dict(os.environ, {"CASHPILOT_API_KEY": ""}): + key1 = fleet_key.resolve_fleet_key() + key2 = fleet_key.resolve_fleet_key() + assert key1 == key2 + + def test_file_exists_race_reads_content(self, fleet_dir: Path): + """Simulate the O_EXCL race: file created by another process.""" + key_file = fleet_dir / ".fleet_key" + + def fake_open(path, flags, mode=0o777): + """First create the file with content (simulating the winner), + then raise FileExistsError (simulating the loser).""" + key_file.write_text("winner-key") + raise FileExistsError + + with ( + patch.dict(os.environ, {"CASHPILOT_API_KEY": ""}), + patch("os.open", side_effect=fake_open), + ): + key = fleet_key.resolve_fleet_key() + assert key == "winner-key" + + def test_empty_env_var_is_treated_as_unset(self, fleet_dir: Path): + """Empty string env var should fall through to file/generate.""" + with patch.dict(os.environ, {"CASHPILOT_API_KEY": ""}): + key = fleet_key.resolve_fleet_key() + assert key # should have generated one + + def test_ignores_empty_key_file(self, fleet_dir: Path): + """An empty or whitespace-only key file should be ignored.""" + key_file = fleet_dir / ".fleet_key" + key_file.write_text(" \n") + with patch.dict(os.environ, {"CASHPILOT_API_KEY": ""}): + # File exists but empty — should try to generate (will fail with + # FileExistsError from O_EXCL, then retry read which is still empty) + # In practice this returns "" after retries, which is the correct + # "broken state" signal. + key = fleet_key.resolve_fleet_key() + # The function tried O_EXCL on existing file, retried reads, all empty + # This is a degenerate state — key will be empty + assert isinstance(key, str) + + def test_unwritable_dir_returns_empty(self, tmp_path: Path): + """When the fleet directory can't be created, return empty string.""" + bad_dir = tmp_path / "nonexistent" / "deep" / "path" + with ( + patch.object(fleet_key, "_FLEET_KEY_DIR", bad_dir), + patch.object(fleet_key, "_FLEET_KEY_FILE", bad_dir / ".fleet_key"), + patch.dict(os.environ, {"CASHPILOT_API_KEY": ""}), + patch("os.open", side_effect=OSError("permission denied")), + patch.object(Path, "mkdir"), + ): + key = fleet_key.resolve_fleet_key() + assert key == "" + + def test_file_permissions(self, fleet_dir: Path): + """Generated key file should have 0o600 permissions.""" + with patch.dict(os.environ, {"CASHPILOT_API_KEY": ""}): + fleet_key.resolve_fleet_key() + key_file = fleet_dir / ".fleet_key" + mode = key_file.stat().st_mode & 0o777 + assert mode == 0o600 From 6926380553db46550846def186cbded8ad081606 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:37:49 +0200 Subject: [PATCH 08/18] fix: auto-resolve worker_id, port protocol, fleet key in auth, bytelixir fallback - Replace _require_worker_id with async _resolve_worker_id: auto-picks the sole online worker when worker_id is omitted, fixing service detail page controls and legacy API callers - Port parsing now keys on container_port/protocol (e.g. 28967/tcp, 28967/udp) per Docker SDK, preserving protocol-specific bindings - auth.py bearer check uses fleet_key.resolve_fleet_key() instead of os.getenv, matching the auto-generated shared key in zero-config mode - Bytelixir API fallback flags balance as withdrawable-only since /api/v1/user does not return total earned amount --- app/auth.py | 6 ++-- app/collectors/bytelixir.py | 3 ++ app/main.py | 69 ++++++++++++++++++++++--------------- 3 files changed, 48 insertions(+), 30 deletions(-) diff --git a/app/auth.py b/app/auth.py index f2e1872..89f747d 100644 --- a/app/auth.py +++ b/app/auth.py @@ -16,6 +16,8 @@ from itsdangerous import BadSignature, URLSafeTimedSerializer from passlib.hash import bcrypt +from app import fleet_key as _fleet_key_mod + _logger = logging.getLogger(__name__) _KNOWN_DEFAULTS = { @@ -106,8 +108,8 @@ def get_current_user(request: Request) -> dict[str, Any] | None: admin_key = os.getenv("CASHPILOT_ADMIN_API_KEY", "") if admin_key and auth_header == f"Bearer {admin_key}": return {"uid": 0, "u": "api", "r": "owner"} - fleet_key = os.getenv("CASHPILOT_API_KEY", "") - if fleet_key and auth_header == f"Bearer {fleet_key}": + resolved_fleet_key = _fleet_key_mod.resolve_fleet_key() + if resolved_fleet_key and auth_header == f"Bearer {resolved_fleet_key}": return {"uid": 0, "u": "api", "r": "writer"} # Fall back to session cookie diff --git a/app/collectors/bytelixir.py b/app/collectors/bytelixir.py index 095cf76..232146e 100644 --- a/app/collectors/bytelixir.py +++ b/app/collectors/bytelixir.py @@ -188,6 +188,8 @@ async def collect(self) -> EarningsResult: data = api_resp.json() # Response shape: {"data": {"balance": "0.0000000000", ...}} + # NOTE: /api/v1/user returns *withdrawable* balance only, not + # total earned. Flag this so the user knows it's approximate. user_data = data.get("data", {}) balance_str = user_data.get("balance", "0") balance = float(balance_str) @@ -196,6 +198,7 @@ async def collect(self) -> EarningsResult: platform=self.platform, balance=round(balance, 4), currency="USD", + error="Withdrawable balance only (HTML scrape failed, using API fallback)", ) except Exception as exc: logger.error("Bytelixir collection failed: %s", exc) diff --git a/app/main.py b/app/main.py index 578c541..3e94e9b 100644 --- a/app/main.py +++ b/app/main.py @@ -66,13 +66,20 @@ async def _get_all_worker_containers() -> list[dict[str, Any]]: return result -def _require_worker_id(worker_id: int | None) -> None: - """Raise 400 if no worker_id was provided.""" - if worker_id is None: - raise HTTPException( - status_code=400, - detail="worker_id is required (specify which worker to target)", - ) +async def _resolve_worker_id(worker_id: int | None) -> int: + """Return a valid worker_id, auto-resolving when only one worker is online.""" + if worker_id is not None: + return worker_id + workers = await database.list_workers() + online = [w for w in workers if w["status"] == "online"] + if len(online) == 1: + return online[0]["id"] + if len(online) == 0: + raise HTTPException(status_code=503, detail="No workers online") + raise HTTPException( + status_code=400, + detail="worker_id is required (multiple workers online)", + ) # --------------------------------------------------------------------------- @@ -716,7 +723,7 @@ class DeployRequest(BaseModel): @app.post("/api/deploy/{slug}") async def api_deploy(request: Request, slug: str, body: DeployRequest, worker_id: int | None = None) -> dict[str, str]: _require_writer(request) - _require_worker_id(worker_id) + worker_id = await _resolve_worker_id(worker_id) svc = catalog.get_service(slug) if not svc: raise HTTPException(status_code=404, detail=f"Service '{slug}' not found") @@ -738,12 +745,18 @@ async def api_deploy(request: Request, slug: str, body: DeployRequest, worker_id env[var["key"]] = str(default) env.update(body.env or {}) - # Ports + # Ports — key is "container_port/protocol" per Docker SDK ports: dict[str, int] = {} for mapping in docker_conf.get("ports", []): - if ":" in str(mapping): - parts = str(mapping).split(":") - ports[parts[0]] = int(parts[1].split("/")[0]) if "/" in parts[1] else int(parts[1]) + raw = str(mapping) + if ":" not in raw: + continue + parts = raw.split(":") + host_port = int(parts[0]) + container_part = parts[1] # e.g. "28967/tcp" or "28967" + if "/" not in container_part: + container_part += "/tcp" + ports[container_part] = host_port # Volumes: resolve ${VAR} in host paths using env volumes: dict[str, dict[str, str]] = {} @@ -782,22 +795,22 @@ async def api_deploy(request: Request, slug: str, body: DeployRequest, worker_id @app.post("/api/stop/{slug}") async def api_stop(request: Request, slug: str, worker_id: int | None = None) -> dict[str, str]: _require_writer(request) - _require_worker_id(worker_id) - return await _proxy_worker_command(worker_id, "stop", slug) # type: ignore[arg-type] + worker_id = await _resolve_worker_id(worker_id) + return await _proxy_worker_command(worker_id, "stop", slug) @app.post("/api/restart/{slug}") async def api_restart(request: Request, slug: str, worker_id: int | None = None) -> dict[str, str]: _require_writer(request) - _require_worker_id(worker_id) - return await _proxy_worker_command(worker_id, "restart", slug) # type: ignore[arg-type] + worker_id = await _resolve_worker_id(worker_id) + return await _proxy_worker_command(worker_id, "restart", slug) @app.delete("/api/remove/{slug}") async def api_remove(request: Request, slug: str, worker_id: int | None = None) -> dict[str, str]: _require_writer(request) - _require_worker_id(worker_id) - result = await _proxy_worker_command(worker_id, "remove", slug) # type: ignore[arg-type] + worker_id = await _resolve_worker_id(worker_id) + result = await _proxy_worker_command(worker_id, "remove", slug) await database.remove_deployment(slug) return result @@ -912,8 +925,8 @@ async def _proxy_worker_logs(worker_id: int, slug: str, lines: int = 50) -> dict @app.post("/api/services/{slug}/restart") async def api_service_restart(request: Request, slug: str, worker_id: int | None = None) -> dict[str, str]: _require_writer(request) - _require_worker_id(worker_id) - result = await _proxy_worker_command(worker_id, "restart", slug) # type: ignore[arg-type] + worker_id = await _resolve_worker_id(worker_id) + result = await _proxy_worker_command(worker_id, "restart", slug) await database.record_health_event(slug, "restart") return result @@ -921,8 +934,8 @@ async def api_service_restart(request: Request, slug: str, worker_id: int | None @app.post("/api/services/{slug}/stop") async def api_service_stop(request: Request, slug: str, worker_id: int | None = None) -> dict[str, str]: _require_writer(request) - _require_worker_id(worker_id) - result = await _proxy_worker_command(worker_id, "stop", slug) # type: ignore[arg-type] + worker_id = await _resolve_worker_id(worker_id) + result = await _proxy_worker_command(worker_id, "stop", slug) await database.record_health_event(slug, "stop") return result @@ -930,8 +943,8 @@ async def api_service_stop(request: Request, slug: str, worker_id: int | None = @app.post("/api/services/{slug}/start") async def api_service_start(request: Request, slug: str, worker_id: int | None = None) -> dict[str, str]: _require_writer(request) - _require_worker_id(worker_id) - result = await _proxy_worker_command(worker_id, "start", slug) # type: ignore[arg-type] + worker_id = await _resolve_worker_id(worker_id) + result = await _proxy_worker_command(worker_id, "start", slug) await database.record_health_event(slug, "start") return result @@ -941,15 +954,15 @@ async def api_service_logs( request: Request, slug: str, lines: int = 50, worker_id: int | None = None ) -> dict[str, str]: _require_writer(request) - _require_worker_id(worker_id) - return await _proxy_worker_logs(worker_id, slug, lines) # type: ignore[arg-type] + worker_id = await _resolve_worker_id(worker_id) + return await _proxy_worker_logs(worker_id, slug, lines) @app.delete("/api/services/{slug}") async def api_service_remove(request: Request, slug: str, worker_id: int | None = None) -> dict[str, str]: _require_writer(request) - _require_worker_id(worker_id) - result = await _proxy_worker_command(worker_id, "remove", slug) # type: ignore[arg-type] + worker_id = await _resolve_worker_id(worker_id) + result = await _proxy_worker_command(worker_id, "remove", slug) await database.remove_deployment(slug) return result From 0bf91b711b061f40f8006944a1095d243147a1f6 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:03:57 +0200 Subject: [PATCH 09/18] fix: worker URL override, fleet copy button, viewer role guards - Add CASHPILOT_WORKER_URL env var for explicit worker URL override in complex network topologies; document in fleet compose example - Fleet page Copy button auto-fetches API key before copying instead of copying masked ******** value - Reveal/Copy/Remove controls on Fleet page hidden for non-owner users via _isOwner JS flag from Jinja2 template context --- app/templates/fleet.html | 17 ++++++++++++++--- app/worker_api.py | 3 ++- docker-compose.fleet.yml | 2 ++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/templates/fleet.html b/app/templates/fleet.html index ead3a71..fef29e5 100644 --- a/app/templates/fleet.html +++ b/app/templates/fleet.html @@ -38,10 +38,12 @@ CASHPILOT_UI_URL= CASHPILOT_API_KEY=******** CASHPILOT_WORKER_NAME=server-name + {% if user.r == 'owner' %}
+ {% endif %}

The API key is set via CASHPILOT_API_KEY env var on the UI container. Use the same key on all workers. @@ -67,6 +69,7 @@

Workers

let fleetTimer = null; let _apiKeyRevealed = false; let _apiKey = ''; + const _isOwner = {{ 'true' if user.r == 'owner' else 'false' }}; document.getElementById('env-ui-url').textContent = window.location.origin; @@ -114,7 +117,7 @@

Workers

${esc(w.name)} - + ${_isOwner ? `` : ''}
${esc(w.url || '-')} @@ -181,8 +184,16 @@

Workers

} }; - window.copyWorkerEnv = function() { - const text = document.getElementById('worker-env').textContent; + window.copyWorkerEnv = async function() { + // Auto-fetch API key if not yet revealed + if (!_apiKey) { + try { + const data = await CP.api('/api/fleet/api-key'); + _apiKey = data.api_key || '(not configured)'; + } catch { _apiKey = '(error)'; } + } + const uiUrl = document.getElementById('env-ui-url').textContent; + const text = `CASHPILOT_UI_URL=${uiUrl}\nCASHPILOT_API_KEY=${_apiKey}\nCASHPILOT_WORKER_NAME=server-name`; navigator.clipboard.writeText(text).then(() => CP.toast('Copied', 'success')); }; diff --git a/app/worker_api.py b/app/worker_api.py index 4f05c87..fa01d21 100644 --- a/app/worker_api.py +++ b/app/worker_api.py @@ -44,6 +44,7 @@ logger.warning("Could not resolve fleet API key — set CASHPILOT_API_KEY or mount a shared /fleet volume") WORKER_NAME = os.getenv("CASHPILOT_WORKER_NAME", socket.gethostname()) WORKER_PORT = int(os.getenv("CASHPILOT_PORT", "8081")) +WORKER_URL = os.getenv("CASHPILOT_WORKER_URL", "") HEARTBEAT_INTERVAL = 60 # seconds _heartbeat_task: asyncio.Task | None = None @@ -81,7 +82,7 @@ async def _send_heartbeat() -> None: payload = { "name": WORKER_NAME, - "url": f"http://{_get_local_ip()}:{WORKER_PORT}", + "url": WORKER_URL or f"http://{_get_local_ip()}:{WORKER_PORT}", "containers": containers, "system_info": { "os": f"{platform.system()} {platform.release()}", diff --git a/docker-compose.fleet.yml b/docker-compose.fleet.yml index 3e6fa6e..fe549a0 100644 --- a/docker-compose.fleet.yml +++ b/docker-compose.fleet.yml @@ -40,6 +40,7 @@ services: - CASHPILOT_UI_URL=http://cashpilot-ui:8080 - CASHPILOT_API_KEY=${CASHPILOT_API_KEY} - CASHPILOT_WORKER_NAME=server-a + # CASHPILOT_WORKER_URL: override auto-detected URL if the UI can't reach the default init: true restart: unless-stopped security_opt: @@ -64,6 +65,7 @@ services: # - CASHPILOT_UI_URL=http://server-a:8080 # - CASHPILOT_API_KEY=${CASHPILOT_API_KEY} # - CASHPILOT_WORKER_NAME=server-b +# - CASHPILOT_WORKER_URL=http://server-b:8081 # URL the UI uses to reach this worker # init: true # restart: unless-stopped # security_opt: From d1a65c84994a67feaf3161b15f345b5b30f2e0b0 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:57:39 +0200 Subject: [PATCH 10/18] fix: catalog cache mutation, viewer UI guards, FK pragma, wizard persistence - catalog.get_services/get_service return shallow copies so per-request fields (deployed, node_count) don't pollute the shared cache - Inject _userRole from Jinja2 into JS; hide restart/stop/deploy controls for viewer users (backend already enforces, now UI matches) - Enable PRAGMA foreign_keys=ON so ON DELETE CASCADE works for user_preferences - Setup wizard persists category selections and timezone to /api/preferences on reaching step 4 --- app/catalog.py | 9 +++++---- app/database.py | 1 + app/static/js/app.js | 21 ++++++++++++++++++--- app/templates/base.html | 3 +++ 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/app/catalog.py b/app/catalog.py index 829f6f5..5f840a0 100644 --- a/app/catalog.py +++ b/app/catalog.py @@ -94,10 +94,10 @@ def load_services() -> list[dict[str, Any]]: def get_services() -> list[dict[str, Any]]: - """Return cached services (load first if empty).""" + """Return shallow copies of cached services (safe to mutate per-request).""" if not _services: load_services() - return _services + return [dict(s) for s in _services] def get_services_by_category() -> dict[str, list[dict[str, Any]]]: @@ -110,10 +110,11 @@ def get_services_by_category() -> dict[str, list[dict[str, Any]]]: def get_service(slug: str) -> dict[str, Any] | None: - """Look up a single service by slug.""" + """Look up a single service by slug (returns a shallow copy).""" if not _by_slug: load_services() - return _by_slug.get(slug) + svc = _by_slug.get(slug) + return dict(svc) if svc else None def _sighup_handler(signum: int, frame: Any) -> None: diff --git a/app/database.py b/app/database.py index dfb2f86..57731fc 100644 --- a/app/database.py +++ b/app/database.py @@ -168,6 +168,7 @@ async def _get_db() -> aiosqlite.Connection: db = await aiosqlite.connect(str(DB_PATH)) db.row_factory = aiosqlite.Row await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA foreign_keys=ON") return db diff --git a/app/static/js/app.js b/app/static/js/app.js index 854da5c..89f3da8 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -5,6 +5,8 @@ const CP = (() => { 'use strict'; + const _canWrite = window._userRole === 'owner' || window._userRole === 'writer'; + // ----------------------------------------------------------- // API helper // ----------------------------------------------------------- @@ -476,12 +478,13 @@ const CP = (() => { const disabledAttr = !inst.has_docker ? ' disabled title="No Docker access"' : ''; actionBtns = `
${claimBtn} + ${_canWrite ? ` + ` : ''} @@ -525,12 +528,13 @@ const CP = (() => {
+ ${_canWrite ? ` + ` : ''} @@ -898,6 +902,17 @@ const CP = (() => { updateWizardUI(); if (wizardState.step === 2) loadWizardServices(); if (wizardState.step === 3) loadWizardSetupForms(); + if (wizardState.step === 4) { + // Persist category/service selections + api('/api/preferences', { + method: 'POST', + body: { + selected_categories: JSON.stringify(wizardState.categories), + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + setup_completed: 1, + }, + }).catch(() => {}); + } } } @@ -1496,7 +1511,7 @@ const CP = (() => {
${workerRows}
- +
`; } diff --git a/app/templates/base.html b/app/templates/base.html index 4c61034..3ae5c6f 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -177,6 +177,9 @@
+ {% if user %} + + {% endif %} {% block scripts %}{% endblock %} From d7644db426eafa22030f7928cdbf437664dc7dc8 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:07:12 +0200 Subject: [PATCH 11/18] fix: complete viewer UI gating, partial preference updates, CSS var MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gate wizard deploy button and service detail modal instance controls (restart/stop/logs) behind _canWrite - PreferencesUpdate fields now nullable; POST /api/preferences merges with existing prefs so partial saves don't reset setup_mode - Fix var(--danger) → var(--error) for deploy failure status styling --- app/main.py | 25 ++++++++++++++++--------- app/static/js/app.js | 8 ++++---- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/app/main.py b/app/main.py index 3e94e9b..0221106 100644 --- a/app/main.py +++ b/app/main.py @@ -1167,23 +1167,30 @@ async def api_get_preferences(request: Request) -> dict[str, Any]: class PreferencesUpdate(BaseModel): - setup_mode: str = "fresh" - selected_categories: str = "[]" - timezone: str = "UTC" - setup_completed: bool = False + setup_mode: str | None = None + selected_categories: str | None = None + timezone: str | None = None + setup_completed: bool | None = None @app.post("/api/preferences") async def api_set_preferences(request: Request, body: PreferencesUpdate) -> dict[str, str]: user = _require_auth_api(request) - if body.setup_mode not in ("fresh", "monitoring", "mixed"): + if body.setup_mode is not None and body.setup_mode not in ("fresh", "monitoring", "mixed"): raise HTTPException(status_code=400, detail="setup_mode must be fresh, monitoring, or mixed") + + # Merge with existing preferences so partial updates don't overwrite + existing = await database.get_user_preferences(user["uid"]) or {} await database.save_user_preferences( user_id=user["uid"], - setup_mode=body.setup_mode, - selected_categories=body.selected_categories, - timezone=body.timezone, - setup_completed=body.setup_completed, + setup_mode=body.setup_mode if body.setup_mode is not None else existing.get("setup_mode", "fresh"), + selected_categories=body.selected_categories + if body.selected_categories is not None + else existing.get("selected_categories", "[]"), + timezone=body.timezone if body.timezone is not None else existing.get("timezone", "UTC"), + setup_completed=body.setup_completed + if body.setup_completed is not None + else existing.get("setup_completed", False), ) # If setup is completed, trigger an immediate collection if body.setup_completed: diff --git a/app/static/js/app.js b/app/static/js/app.js index 89f3da8..376df05 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -1204,7 +1204,7 @@ const CP = (() => {
${workerRows}
- @@ -1536,11 +1536,11 @@ const CP = (() => {
${escapeHtml(inst.worker.name)} ${escapeHtml(s)} -
+ ${_canWrite ? `
-
+
` : ''}
`; } @@ -1578,7 +1578,7 @@ const CP = (() => { } if (statusEl) { statusEl.textContent = fail === 0 ? `Deployed to ${ok} node(s)` : `${ok} ok, ${fail} failed`; - statusEl.style.color = fail === 0 ? 'var(--success)' : 'var(--danger)'; + statusEl.style.color = fail === 0 ? 'var(--success)' : 'var(--error)'; } } From d8dc16f514e617ad4080e36f175256cec5429ca1 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:26:32 +0200 Subject: [PATCH 12/18] fix: owner self-demotion guard, viewer logs/settings gating - Prevent owner from demoting themselves or removing the last owner - Move logs button inside _canWrite gate (single + multi-instance) - Hide Settings sidebar link for non-owner roles --- app/main.py | 9 ++++++++- app/static/js/app.js | 8 ++++---- app/templates/base.html | 2 ++ 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/app/main.py b/app/main.py index 0221106..b551b64 100644 --- a/app/main.py +++ b/app/main.py @@ -1338,12 +1338,19 @@ class UserRoleUpdate(BaseModel): @app.patch("/api/users/{user_id}") async def api_update_user_role(request: Request, user_id: int, body: UserRoleUpdate) -> dict[str, str]: - _require_owner(request) + current = _require_owner(request) if body.role not in ("viewer", "writer", "owner"): raise HTTPException(status_code=400, detail="Role must be viewer, writer, or owner") user = await database.get_user_by_id(user_id) if not user: raise HTTPException(status_code=404, detail="User not found") + if current["uid"] == user_id and body.role != "owner": + raise HTTPException(status_code=400, detail="Cannot demote yourself") + if user["role"] == "owner" and body.role != "owner": + all_users = await database.list_users() + owner_count = sum(1 for u in all_users if u["role"] == "owner") + if owner_count <= 1: + raise HTTPException(status_code=400, detail="Cannot remove the last owner") await database.update_user_role(user_id, body.role) return {"status": "updated"} diff --git a/app/static/js/app.js b/app/static/js/app.js index 376df05..1cd6c29 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -484,10 +484,10 @@ const CP = (() => { ` : ''} + + ` : ''}
`; } @@ -534,10 +534,10 @@ const CP = (() => { ` : ''} + + ` : ''} `; diff --git a/app/templates/base.html b/app/templates/base.html index 3ae5c6f..b456348 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -62,12 +62,14 @@ Service Catalog + {% if user and user.r == 'owner' %} Settings + {% endif %} From a8fe31d72741b1d81e6b97fcae0059a2deb1f564 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:39:15 +0200 Subject: [PATCH 13/18] fix: zero-threshold payout eligibility, storj default URL, alert routing - Fix chained comparison that made min_amount=0 services permanently ineligible; now eligible when balance > 0 and balance >= min_amount - Mirror same fix in frontend dashboard row eligibility check - Make storj api_url optional so built-in default works out of the box - Collector alerts: non-owners see alerts but clicks don't route to owner-only settings page --- app/collectors/__init__.py | 2 +- app/main.py | 2 +- app/static/js/app.js | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/collectors/__init__.py b/app/collectors/__init__.py index 1ae734e..87fcd69 100644 --- a/app/collectors/__init__.py +++ b/app/collectors/__init__.py @@ -49,7 +49,7 @@ "earnapp": ["oauth_token", "?brd_sess_id"], "iproyal": ["email", "password"], "mysterium": ["email", "password"], - "storj": ["api_url"], + "storj": ["?api_url"], "traffmonetizer": ["?token", "?email", "?password"], "repocket": ["email", "password"], "proxyrack": ["api_key"], diff --git a/app/main.py b/app/main.py index b551b64..65181d7 100644 --- a/app/main.py +++ b/app/main.py @@ -1078,7 +1078,7 @@ async def api_earnings_breakdown(request: Request) -> list[dict[str, Any]]: "last_updated": row["date"], "delta": round(delta, 4), "cashout": { - "eligible": balance >= min_amount > 0, + "eligible": bool(cashout) and balance > 0 and balance >= min_amount, "min_amount": min_amount, "method": cashout.get("method", "redirect"), "dashboard_url": cashout.get("dashboard_url", ""), diff --git a/app/static/js/app.js b/app/static/js/app.js index 1cd6c29..fa767e7 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -438,7 +438,7 @@ const CP = (() => { // Payout progress const co = svc.cashout || {}; const minAmount = co.min_amount || 0; - const eligible = minAmount > 0 && balance >= minAmount; + const eligible = balance > 0 && balance >= minAmount; const pctToMin = minAmount > 0 ? Math.min(100, (balance / minAmount) * 100) : 0; const progressBar = minAmount > 0 ? `
@@ -1914,8 +1914,9 @@ const CP = (() => { badge.style.display = ''; badge.textContent = alerts.length; + const _isOwner = window._userRole === 'owner'; list.innerHTML = alerts.map(a => ` -
+
From 9153a7483f91baaaf9defc35b233ebcf0106db05 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:44:18 +0200 Subject: [PATCH 14/18] fix: onboarding step 4 CTAs role-aware, no /settings dead-end for non-owners --- app/templates/onboarding.html | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/app/templates/onboarding.html b/app/templates/onboarding.html index a3b59ff..ac64f45 100644 --- a/app/templates/onboarding.html +++ b/app/templates/onboarding.html @@ -191,6 +191,7 @@

You're all set!

const desc = document.getElementById('ob-finish-desc'); const actions = document.getElementById('ob-finish-actions'); + const _isOwner = window._userRole === 'owner'; if (setupMode === 'fresh') { title.textContent = "Let's deploy your first services!"; desc.innerHTML = 'The Setup Wizard will guide you through signing up for services and deploying containers.'; @@ -199,16 +200,28 @@

You're all set!

Setup Wizard`; } else if (setupMode === 'monitoring') { title.textContent = 'Configure earnings tracking'; - desc.innerHTML = 'Head to Settings to enter your API tokens and credentials. CashPilot will collect your earnings automatically — no container deployment needed.'; - actions.innerHTML = ` - Dashboard - Configure Credentials`; + if (_isOwner) { + desc.innerHTML = 'Head to Settings to enter your API tokens and credentials. CashPilot will collect your earnings automatically — no container deployment needed.'; + actions.innerHTML = ` + Dashboard + Configure Credentials`; + } else { + desc.innerHTML = 'An owner will configure API tokens and credentials. CashPilot will collect your earnings automatically — no container deployment needed.'; + actions.innerHTML = `Dashboard`; + } } else { title.textContent = "Let's expand your portfolio!"; - desc.innerHTML = 'Configure existing services in Settings, then use the Setup Wizard to deploy new ones.'; - actions.innerHTML = ` - Configure Existing - Deploy New Services`; + if (_isOwner) { + desc.innerHTML = 'Configure existing services in Settings, then use the Setup Wizard to deploy new ones.'; + actions.innerHTML = ` + Configure Existing + Deploy New Services`; + } else { + desc.innerHTML = 'Use the Setup Wizard to browse and deploy new services.'; + actions.innerHTML = ` + Dashboard + Deploy New Services`; + } } CP.obNext(4); From 55d83855bb34f110f98d374d4d92d12890242b6c Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:49:43 +0200 Subject: [PATCH 15/18] test: regression coverage for eligibility, storj optional api_url - 12 tests for payout eligibility: zero-threshold, normal threshold, empty cashout, edge cases (parametrized) - 3 tests for storj: api_url marked optional, collector created without config using default URL, custom URL passed through --- tests/test_collectors.py | 26 +++++++++++++++++++- tests/test_eligibility.py | 52 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 tests/test_eligibility.py diff --git a/tests/test_collectors.py b/tests/test_collectors.py index eae20d2..0c3a840 100644 --- a/tests/test_collectors.py +++ b/tests/test_collectors.py @@ -2,7 +2,7 @@ import inspect -from app.collectors import COLLECTOR_MAP +from app.collectors import _COLLECTOR_ARGS, COLLECTOR_MAP, make_collectors from app.collectors.base import BaseCollector, EarningsResult @@ -40,3 +40,27 @@ def test_earnings_result_fields(): assert result.currency == "USD" assert result.bytes_uploaded == 0 assert result.error is None + + +def test_storj_api_url_is_optional(): + """Storj api_url must be marked optional so the built-in default works.""" + storj_args = _COLLECTOR_ARGS.get("storj", []) + assert "?api_url" in storj_args, "storj api_url should be optional (prefixed with ?)" + assert "api_url" not in storj_args, "storj api_url should not be mandatory" + + +def test_storj_collector_created_without_config(): + """make_collectors should create a StorjCollector with no api_url config.""" + deployments = [{"slug": "storj"}] + collectors = make_collectors(deployments, config={}) + assert len(collectors) == 1 + assert collectors[0].platform == "storj" + assert "localhost:14002" in collectors[0].api_url + + +def test_storj_collector_respects_custom_url(): + """make_collectors should pass custom api_url when provided.""" + deployments = [{"slug": "storj"}] + collectors = make_collectors(deployments, config={"storj_api_url": "http://mynode:14002"}) + assert len(collectors) == 1 + assert collectors[0].api_url == "http://mynode:14002" diff --git a/tests/test_eligibility.py b/tests/test_eligibility.py new file mode 100644 index 0000000..5103141 --- /dev/null +++ b/tests/test_eligibility.py @@ -0,0 +1,52 @@ +"""Regression tests for payout eligibility logic. + +The eligibility expression used in the /api/earnings/latest endpoint must +handle zero-threshold services (min_amount=0) correctly. +""" + +import pytest + + +def _eligible(cashout: dict, balance: float, min_amount: float) -> bool: + """Mirror the eligibility expression from main.py api_earnings_latest.""" + return bool(cashout) and balance > 0 and balance >= min_amount + + +class TestEligibility: + """Zero-threshold payout eligibility (min_amount=0).""" + + def test_zero_threshold_positive_balance(self): + assert _eligible({"min_amount": 0}, balance=5.0, min_amount=0) + + def test_zero_threshold_zero_balance(self): + assert not _eligible({"min_amount": 0}, balance=0.0, min_amount=0) + + def test_normal_threshold_above(self): + assert _eligible({"min_amount": 5}, balance=10.0, min_amount=5) + + def test_normal_threshold_exact(self): + assert _eligible({"min_amount": 5}, balance=5.0, min_amount=5) + + def test_normal_threshold_below(self): + assert not _eligible({"min_amount": 5}, balance=3.0, min_amount=5) + + def test_no_cashout_section(self): + assert not _eligible({}, balance=10.0, min_amount=0) + + def test_empty_cashout_high_balance(self): + """Empty cashout dict is falsy — no payout mechanism exists.""" + assert not _eligible({}, balance=100.0, min_amount=0) + + @pytest.mark.parametrize( + "balance,min_amount,expected", + [ + (0.0001, 0, True), + (0, 0, False), + (0, 10, False), + (10, 10, True), + (9.99, 10, False), + ], + ) + def test_edge_cases(self, balance, min_amount, expected): + cashout = {"min_amount": min_amount, "dashboard_url": "https://example.com"} + assert _eligible(cashout, balance, min_amount) is expected From 117f5c4df968585d1a38fb131fa28d701c68d44c Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:58:20 +0200 Subject: [PATCH 16/18] test: replace mirrored eligibility tests with integration tests Rewrite test_eligibility.py to call the actual api_earnings_breakdown handler with mocked DB/catalog/auth deps instead of a mirrored boolean expression. Tests exercise real route wiring and response assembly. Skips gracefully in minimal local envs (no fastapi), runs in CI where full deps are installed via requirements.txt. --- tests/test_eligibility.py | 146 +++++++++++++++++++++++++++++++------- 1 file changed, 119 insertions(+), 27 deletions(-) diff --git a/tests/test_eligibility.py b/tests/test_eligibility.py index 5103141..c2e4f53 100644 --- a/tests/test_eligibility.py +++ b/tests/test_eligibility.py @@ -1,41 +1,131 @@ -"""Regression tests for payout eligibility logic. +"""Integration tests for payout eligibility in /api/earnings/breakdown. -The eligibility expression used in the /api/earnings/latest endpoint must -handle zero-threshold services (min_amount=0) correctly. +These tests call the actual route handler with mocked DB/catalog/auth +dependencies, so they exercise real route wiring and response assembly. + +Requires fastapi + httpx (installed in CI via requirements.txt). +Skipped automatically in minimal local environments. """ -import pytest +import os + +# Fleet key env must be set before app.main import triggers resolve_fleet_key() +os.environ.setdefault("CASHPILOT_API_KEY", "test-fleet-key") + +import pytest # noqa: E402 + +try: + from app.main import api_earnings_breakdown # noqa: E402 +except ImportError: + pytest.skip("Requires full app dependencies (fastapi, httpx, etc.) — runs in CI", allow_module_level=True) + +from unittest.mock import AsyncMock, MagicMock, patch # noqa: E402 + + +def _earnings_row(platform, balance, prev_balance=0, currency="USD"): + return { + "platform": platform, + "balance": balance, + "prev_balance": prev_balance, + "currency": currency, + "date": "2026-01-01T00:00:00", + } + + +def _service(slug, cashout=None): + svc = {"name": slug.replace("-", " ").title(), "slug": slug} + if cashout is not None: + svc["cashout"] = cashout + return svc + + +async def _call_breakdown(rows, services_by_slug): + """Call the real handler with mocked dependencies.""" + request = MagicMock() + with ( + patch("app.main.auth.get_current_user", return_value={"uid": 1, "u": "test", "r": "owner"}), + patch("app.main.database.get_earnings_per_service", new_callable=AsyncMock, return_value=rows), + patch("app.main.catalog.get_service", side_effect=lambda slug: services_by_slug.get(slug)), + ): + return await api_earnings_breakdown(request) -def _eligible(cashout: dict, balance: float, min_amount: float) -> bool: - """Mirror the eligibility expression from main.py api_earnings_latest.""" - return bool(cashout) and balance > 0 and balance >= min_amount +@pytest.mark.asyncio +class TestBreakdownEligibility: + """Zero-threshold payout eligibility via the real route handler.""" + async def test_zero_threshold_positive_balance_eligible(self): + rows = [_earnings_row("svc-a", balance=5.0)] + svcs = {"svc-a": _service("svc-a", cashout={"min_amount": 0, "dashboard_url": "https://x.com"})} + result = await _call_breakdown(rows, svcs) + assert result[0]["cashout"]["eligible"] is True -class TestEligibility: - """Zero-threshold payout eligibility (min_amount=0).""" + async def test_zero_threshold_zero_balance_not_eligible(self): + rows = [_earnings_row("svc-a", balance=0.0)] + svcs = {"svc-a": _service("svc-a", cashout={"min_amount": 0})} + result = await _call_breakdown(rows, svcs) + assert result[0]["cashout"]["eligible"] is False - def test_zero_threshold_positive_balance(self): - assert _eligible({"min_amount": 0}, balance=5.0, min_amount=0) + async def test_normal_threshold_above(self): + rows = [_earnings_row("svc-a", balance=10.0)] + svcs = {"svc-a": _service("svc-a", cashout={"min_amount": 5})} + result = await _call_breakdown(rows, svcs) + assert result[0]["cashout"]["eligible"] is True - def test_zero_threshold_zero_balance(self): - assert not _eligible({"min_amount": 0}, balance=0.0, min_amount=0) + async def test_normal_threshold_exact(self): + rows = [_earnings_row("svc-a", balance=5.0)] + svcs = {"svc-a": _service("svc-a", cashout={"min_amount": 5})} + result = await _call_breakdown(rows, svcs) + assert result[0]["cashout"]["eligible"] is True - def test_normal_threshold_above(self): - assert _eligible({"min_amount": 5}, balance=10.0, min_amount=5) + async def test_normal_threshold_below(self): + rows = [_earnings_row("svc-a", balance=3.0)] + svcs = {"svc-a": _service("svc-a", cashout={"min_amount": 5})} + result = await _call_breakdown(rows, svcs) + assert result[0]["cashout"]["eligible"] is False - def test_normal_threshold_exact(self): - assert _eligible({"min_amount": 5}, balance=5.0, min_amount=5) + async def test_no_cashout_section_not_eligible(self): + """Service with no cashout in catalog should never be eligible.""" + rows = [_earnings_row("svc-a", balance=100.0)] + svcs = {"svc-a": _service("svc-a", cashout=None)} + result = await _call_breakdown(rows, svcs) + assert result[0]["cashout"]["eligible"] is False - def test_normal_threshold_below(self): - assert not _eligible({"min_amount": 5}, balance=3.0, min_amount=5) + async def test_unknown_service_not_eligible(self): + """Service not in catalog (svc returns None) should not be eligible.""" + rows = [_earnings_row("unknown", balance=50.0)] + result = await _call_breakdown(rows, {}) + assert result[0]["cashout"]["eligible"] is False + assert result[0]["name"] == "unknown" # falls back to slug - def test_no_cashout_section(self): - assert not _eligible({}, balance=10.0, min_amount=0) + async def test_response_includes_cashout_fields(self): + """Verify full cashout response structure from the real handler.""" + rows = [_earnings_row("svc-a", balance=10.0)] + svcs = { + "svc-a": _service( + "svc-a", + cashout={ + "min_amount": 5, + "method": "api", + "dashboard_url": "https://dash.example.com", + "notes": "Payout every Monday", + }, + ) + } + result = await _call_breakdown(rows, svcs) + co = result[0]["cashout"] + assert co["eligible"] is True + assert co["min_amount"] == 5.0 + assert co["method"] == "api" + assert co["dashboard_url"] == "https://dash.example.com" + assert co["notes"] == "Payout every Monday" - def test_empty_cashout_high_balance(self): - """Empty cashout dict is falsy — no payout mechanism exists.""" - assert not _eligible({}, balance=100.0, min_amount=0) + async def test_delta_computation(self): + """Verify delta is computed from real handler, not just eligibility.""" + rows = [_earnings_row("svc-a", balance=10.0, prev_balance=7.5)] + svcs = {"svc-a": _service("svc-a", cashout={"min_amount": 0})} + result = await _call_breakdown(rows, svcs) + assert result[0]["delta"] == 2.5 @pytest.mark.parametrize( "balance,min_amount,expected", @@ -47,6 +137,8 @@ def test_empty_cashout_high_balance(self): (9.99, 10, False), ], ) - def test_edge_cases(self, balance, min_amount, expected): - cashout = {"min_amount": min_amount, "dashboard_url": "https://example.com"} - assert _eligible(cashout, balance, min_amount) is expected + async def test_edge_cases(self, balance, min_amount, expected): + rows = [_earnings_row("svc-a", balance=balance)] + svcs = {"svc-a": _service("svc-a", cashout={"min_amount": min_amount, "dashboard_url": "https://x.com"})} + result = await _call_breakdown(rows, svcs) + assert result[0]["cashout"]["eligible"] is expected From 07c9e6d444fb7fbdc8e6b28a865df16cdd62c3f9 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Tue, 31 Mar 2026 01:02:10 +0200 Subject: [PATCH 17/18] fix: use asyncio.run() in eligibility tests for CI compatibility CI installs pytest but not pytest-asyncio. Convert async test methods to sync methods that call asyncio.run() to invoke the async handler. --- tests/test_eligibility.py | 46 +++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/tests/test_eligibility.py b/tests/test_eligibility.py index c2e4f53..a4ed4aa 100644 --- a/tests/test_eligibility.py +++ b/tests/test_eligibility.py @@ -7,6 +7,7 @@ Skipped automatically in minimal local environments. """ +import asyncio import os # Fleet key env must be set before app.main import triggers resolve_fleet_key() @@ -39,7 +40,7 @@ def _service(slug, cashout=None): return svc -async def _call_breakdown(rows, services_by_slug): +def _call_breakdown(rows, services_by_slug): """Call the real handler with mocked dependencies.""" request = MagicMock() with ( @@ -47,58 +48,57 @@ async def _call_breakdown(rows, services_by_slug): patch("app.main.database.get_earnings_per_service", new_callable=AsyncMock, return_value=rows), patch("app.main.catalog.get_service", side_effect=lambda slug: services_by_slug.get(slug)), ): - return await api_earnings_breakdown(request) + return asyncio.run(api_earnings_breakdown(request)) -@pytest.mark.asyncio class TestBreakdownEligibility: """Zero-threshold payout eligibility via the real route handler.""" - async def test_zero_threshold_positive_balance_eligible(self): + def test_zero_threshold_positive_balance_eligible(self): rows = [_earnings_row("svc-a", balance=5.0)] svcs = {"svc-a": _service("svc-a", cashout={"min_amount": 0, "dashboard_url": "https://x.com"})} - result = await _call_breakdown(rows, svcs) + result = _call_breakdown(rows, svcs) assert result[0]["cashout"]["eligible"] is True - async def test_zero_threshold_zero_balance_not_eligible(self): + def test_zero_threshold_zero_balance_not_eligible(self): rows = [_earnings_row("svc-a", balance=0.0)] svcs = {"svc-a": _service("svc-a", cashout={"min_amount": 0})} - result = await _call_breakdown(rows, svcs) + result = _call_breakdown(rows, svcs) assert result[0]["cashout"]["eligible"] is False - async def test_normal_threshold_above(self): + def test_normal_threshold_above(self): rows = [_earnings_row("svc-a", balance=10.0)] svcs = {"svc-a": _service("svc-a", cashout={"min_amount": 5})} - result = await _call_breakdown(rows, svcs) + result = _call_breakdown(rows, svcs) assert result[0]["cashout"]["eligible"] is True - async def test_normal_threshold_exact(self): + def test_normal_threshold_exact(self): rows = [_earnings_row("svc-a", balance=5.0)] svcs = {"svc-a": _service("svc-a", cashout={"min_amount": 5})} - result = await _call_breakdown(rows, svcs) + result = _call_breakdown(rows, svcs) assert result[0]["cashout"]["eligible"] is True - async def test_normal_threshold_below(self): + def test_normal_threshold_below(self): rows = [_earnings_row("svc-a", balance=3.0)] svcs = {"svc-a": _service("svc-a", cashout={"min_amount": 5})} - result = await _call_breakdown(rows, svcs) + result = _call_breakdown(rows, svcs) assert result[0]["cashout"]["eligible"] is False - async def test_no_cashout_section_not_eligible(self): + def test_no_cashout_section_not_eligible(self): """Service with no cashout in catalog should never be eligible.""" rows = [_earnings_row("svc-a", balance=100.0)] svcs = {"svc-a": _service("svc-a", cashout=None)} - result = await _call_breakdown(rows, svcs) + result = _call_breakdown(rows, svcs) assert result[0]["cashout"]["eligible"] is False - async def test_unknown_service_not_eligible(self): + def test_unknown_service_not_eligible(self): """Service not in catalog (svc returns None) should not be eligible.""" rows = [_earnings_row("unknown", balance=50.0)] - result = await _call_breakdown(rows, {}) + result = _call_breakdown(rows, {}) assert result[0]["cashout"]["eligible"] is False assert result[0]["name"] == "unknown" # falls back to slug - async def test_response_includes_cashout_fields(self): + def test_response_includes_cashout_fields(self): """Verify full cashout response structure from the real handler.""" rows = [_earnings_row("svc-a", balance=10.0)] svcs = { @@ -112,7 +112,7 @@ async def test_response_includes_cashout_fields(self): }, ) } - result = await _call_breakdown(rows, svcs) + result = _call_breakdown(rows, svcs) co = result[0]["cashout"] assert co["eligible"] is True assert co["min_amount"] == 5.0 @@ -120,11 +120,11 @@ async def test_response_includes_cashout_fields(self): assert co["dashboard_url"] == "https://dash.example.com" assert co["notes"] == "Payout every Monday" - async def test_delta_computation(self): + def test_delta_computation(self): """Verify delta is computed from real handler, not just eligibility.""" rows = [_earnings_row("svc-a", balance=10.0, prev_balance=7.5)] svcs = {"svc-a": _service("svc-a", cashout={"min_amount": 0})} - result = await _call_breakdown(rows, svcs) + result = _call_breakdown(rows, svcs) assert result[0]["delta"] == 2.5 @pytest.mark.parametrize( @@ -137,8 +137,8 @@ async def test_delta_computation(self): (9.99, 10, False), ], ) - async def test_edge_cases(self, balance, min_amount, expected): + def test_edge_cases(self, balance, min_amount, expected): rows = [_earnings_row("svc-a", balance=balance)] svcs = {"svc-a": _service("svc-a", cashout={"min_amount": min_amount, "dashboard_url": "https://x.com"})} - result = await _call_breakdown(rows, svcs) + result = _call_breakdown(rows, svcs) assert result[0]["cashout"]["eligible"] is expected From 4b272cbf1a13717101c098b168bd4daf6a0ade20 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Tue, 31 Mar 2026 01:04:57 +0200 Subject: [PATCH 18/18] release: v0.2.49 changelog and version bump Comprehensive security audit and hardening (PR #11): - Fleet key bootstrap, RBAC enforcement, role-aware UI gating - Zero-threshold payout eligibility, Storj default URL - Integration test coverage for eligibility and collectors --- CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ app/templates/base.html | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffa7cb9..ececcfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ All notable changes to CashPilot are documented here. +## [0.2.49] - 2026-03-31 + +### Security +- Fix unauthenticated worker-control exposure on default Docker Compose (worker port no longer published) +- Atomic shared fleet key generation with `O_CREAT | O_EXCL` — eliminates skip-auth, ephemeral key mismatch, and worker impersonation vectors +- Bearer auth split: `CASHPILOT_ADMIN_API_KEY` for owner-level, fleet key for writer-level API access +- Worker heartbeat URL pinned to prevent spoofing in no-key mode +- Fleet key first-boot race condition closed with retry-read backoff +- Credential encryption key (`secret_key`) added to secret config redaction +- `PRAGMA foreign_keys=ON` enforced for SQLite CASCADE integrity + +### Fixed +- Zero-threshold payout: services with `min_amount: 0` are now correctly eligible when balance > 0 +- Storj collector no longer requires manual `api_url` setting — uses built-in default +- Owner self-demotion and last-owner removal guards on `PATCH /api/users/{id}` +- Viewer/writer role gating on dashboard controls (restart, stop, logs), settings sidebar, fleet page, and service detail modal +- Onboarding step 4 CTAs no longer link non-owners to the owner-only settings page +- Collector alert clicks are no-op for non-owners (no /settings dead-end) +- Partial preference updates (nullable fields merged with existing) +- Port parsing preserves TCP/UDP protocol for Docker SDK +- Auto-resolve `worker_id` when only one worker is online +- Catalog cache returns shallow copies to prevent cross-request mutation +- CSS `var(--danger)` replaced with `var(--error)` for deploy failure styling +- Bytelixir API fallback clearly reports HTML scrape failure +- Worker URL override via `CASHPILOT_WORKER_URL` env var +- Fleet page copy-to-clipboard fetches key before copying + +### Added +- `app/fleet_key.py` — central fleet key resolution module (env var → shared file → auto-generate) +- `CASHPILOT_WORKER_URL` env var for explicit worker URL override +- `cashpilot_fleet` shared Docker volume for fleet key exchange +- Integration tests for payout eligibility (14 tests against real handler) +- Regression tests for Storj optional `api_url` and fleet key bootstrap (12 tests) + ## [0.2.17] - 2026-03-28 ### Fixed diff --git a/app/templates/base.html b/app/templates/base.html index b456348..106c201 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -86,7 +86,7 @@
- CashPilot v0.2 + CashPilot v0.2.49