Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
892e66a
security: fix 12 audit findings (4 critical, 3 high, 5 medium)
GeiserX Mar 30, 2026
79fd161
fix: address remaining security review findings
GeiserX Mar 30, 2026
323d775
fix: close unauthenticated worker exposure on default compose
GeiserX Mar 30, 2026
d100520
fix: pin worker URL to request source IP in no-key mode
GeiserX Mar 30, 2026
112376e
fix: auto-generate shared fleet key, close worker impersonation
GeiserX Mar 30, 2026
1206d39
fix: close fleet key first-boot race with retry-read
GeiserX Mar 30, 2026
8c2b693
test: add fleet key resolution and bootstrap coverage
GeiserX Mar 30, 2026
6926380
fix: auto-resolve worker_id, port protocol, fleet key in auth, byteli…
GeiserX Mar 30, 2026
0bf91b7
fix: worker URL override, fleet copy button, viewer role guards
GeiserX Mar 30, 2026
d1a65c8
fix: catalog cache mutation, viewer UI guards, FK pragma, wizard pers…
GeiserX Mar 30, 2026
d7644db
fix: complete viewer UI gating, partial preference updates, CSS var
GeiserX Mar 30, 2026
d8dc16f
fix: owner self-demotion guard, viewer logs/settings gating
GeiserX Mar 30, 2026
a8fe31d
fix: zero-threshold payout eligibility, storj default URL, alert routing
GeiserX Mar 30, 2026
9153a74
fix: onboarding step 4 CTAs role-aware, no /settings dead-end for non…
GeiserX Mar 30, 2026
55d8385
test: regression coverage for eligibility, storj optional api_url
GeiserX Mar 30, 2026
117f5c4
test: replace mirrored eligibility tests with integration tests
GeiserX Mar 30, 2026
07c9e6d
fix: use asyncio.run() in eligibility tests for CI compatibility
GeiserX Mar 30, 2026
4b272cb
release: v0.2.49 changelog and version bump
GeiserX Mar 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:

- uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.14"

- name: Install dependencies
run: |
Expand Down
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 65 additions & 6 deletions app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,71 @@

from __future__ import annotations

import logging
import os
import secrets
from pathlib import Path
from typing import Any

from fastapi import Request
from fastapi.responses import RedirectResponse
from itsdangerous import BadSignature, URLSafeTimedSerializer
from passlib.hash import bcrypt

SECRET_KEY = os.getenv("CASHPILOT_SECRET_KEY", "changeme-generate-a-random-secret")
from app import fleet_key as _fleet_key_mod

_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 <data_dir>/.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

Expand Down Expand Up @@ -46,12 +102,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"}
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
token = request.cookies.get(SESSION_COOKIE)
Expand Down
9 changes: 5 additions & 4 deletions app/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]]:
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions app/collectors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@
"earnapp": ["oauth_token", "?brd_sess_id"],
"iproyal": ["email", "password"],
"mysterium": ["email", "password"],
"storj": ["api_url"],
"traffmonetizer": ["token"],
"storj": ["?api_url"],
"traffmonetizer": ["?token", "?email", "?password"],
"repocket": ["email", "password"],
"proxyrack": ["api_key"],
"bitping": ["email", "password"],
Expand Down
3 changes: 3 additions & 0 deletions app/collectors/bytelixir.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
14 changes: 13 additions & 1 deletion app/collectors/packetstream.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -56,14 +57,25 @@ 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 not parsed:
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,
Expand Down
97 changes: 90 additions & 7 deletions app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,90 @@

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",
"secret_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,
Expand Down Expand Up @@ -94,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


Expand Down Expand Up @@ -389,40 +464,48 @@ 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:
await db.close()


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:
Expand Down
Loading
Loading