Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 12 additions & 4 deletions app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
from pathlib import Path
from typing import Any

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

from app import fleet_key as _fleet_key_mod

Expand Down Expand Up @@ -78,12 +78,13 @@ def _resolve_secret_key() -> str:

def hash_password(password: str) -> str:
# bcrypt enforces a 72-byte limit; truncate UTF-8 bytes (not characters)
# to avoid ValueError on strict backends and passlib/bcrypt >=4.1 compat issues
return bcrypt.hash(password.encode("utf-8")[:72])
pw = password.encode("utf-8")[:72]
return _bcrypt.hashpw(pw, _bcrypt.gensalt()).decode("ascii")


def verify_password(password: str, hashed: str) -> bool:
return bcrypt.verify(password.encode("utf-8")[:72], hashed)
pw = password.encode("utf-8")[:72]
return _bcrypt.checkpw(pw, hashed.encode("ascii"))


def create_session_token(user_id: int, username: str, role: str) -> str:
Expand Down Expand Up @@ -120,13 +121,20 @@ def get_current_user(request: Request) -> dict[str, Any] | None:
return decode_session_token(token)


_SECURE_COOKIE = os.getenv("CASHPILOT_SECURE_COOKIE", "auto").lower()


def set_session_cookie(response: RedirectResponse, token: str) -> RedirectResponse:
use_secure = _SECURE_COOKIE == "true" or (
_SECURE_COOKIE == "auto" and os.getenv("CASHPILOT_BASE_URL", "").startswith("https")
)
response.set_cookie(
SESSION_COOKIE,
token,
max_age=SESSION_MAX_AGE,
httponly=True,
samesite="lax",
secure=use_secure,
)
return response

Expand Down
12 changes: 6 additions & 6 deletions app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ async def upsert_earnings(
date: str | None = None,
) -> None:
"""Insert or update an earnings record for a platform + date."""
date = date or datetime.utcnow().strftime("%Y-%m-%d")
date = date or datetime.now(datetime.UTC).strftime("%Y-%m-%d")
db = await _get_db()
try:
await db.execute(
Expand Down Expand Up @@ -305,9 +305,9 @@ async def get_earnings_dashboard_summary() -> dict[str, Any]:
"""Return aggregated earnings stats for the dashboard."""
db = await _get_db()
try:
today = datetime.utcnow().strftime("%Y-%m-%d")
yesterday = (datetime.utcnow() - timedelta(days=1)).strftime("%Y-%m-%d")
first_of_month = datetime.utcnow().replace(day=1).strftime("%Y-%m-%d")
today = datetime.now(datetime.UTC).strftime("%Y-%m-%d")
yesterday = (datetime.now(datetime.UTC) - timedelta(days=1)).strftime("%Y-%m-%d")
first_of_month = datetime.now(datetime.UTC).replace(day=1).strftime("%Y-%m-%d")

# Total: sum of latest balance per platform (USD only for now)
cursor = await db.execute(
Expand Down Expand Up @@ -377,7 +377,7 @@ async def get_earnings_dashboard_summary() -> dict[str, Any]:
month_earned = max(0.0, row["earned"])

# Yesterday's delta for percentage change
day_before = (datetime.utcnow() - timedelta(days=2)).strftime("%Y-%m-%d")
day_before = (datetime.now(datetime.UTC) - timedelta(days=2)).strftime("%Y-%m-%d")
cursor = await db.execute(
"""
SELECT COALESCE(SUM(y.balance - COALESCE(dy.balance, 0)), 0) as earned
Expand Down Expand Up @@ -472,7 +472,7 @@ async def get_daily_earnings(days: int = 7) -> list[dict[str, Any]]:
balance_by_date[row["date"]] = row["total_balance"]

# Generate result for exactly `days` days
now = datetime.utcnow()
now = datetime.now(datetime.UTC)
result = []
for i in range(days - 1, -1, -1):
d = now - timedelta(days=i)
Expand Down
3 changes: 3 additions & 0 deletions app/exchange_rates.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,7 @@ def to_usd(amount: float, currency: str) -> float | None:
return amount
if currency in _crypto_usd:
return amount * _crypto_usd[currency]
# Fiat: _fiat_rates stores USD->X rates, so divide to get X->USD
if currency in _fiat_rates and _fiat_rates[currency] > 0:
return amount / _fiat_rates[currency]
return None
113 changes: 76 additions & 37 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@

import asyncio
import contextlib
import ipaddress
import json
import logging
import os
import re
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from typing import Any
from urllib.parse import urlparse

import httpx
from apscheduler.schedulers.asyncio import AsyncIOScheduler
Expand All @@ -21,6 +25,7 @@
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from starlette.middleware.base import BaseHTTPMiddleware

from app import auth, catalog, compose_generator, database, exchange_rates, fleet_key

Expand Down Expand Up @@ -159,7 +164,9 @@ async def _run_collection() -> None:
config = await database.get_config() or {}
if not isinstance(config, dict):
config = {}
collectors = __import__("app.collectors", fromlist=["make_collectors"]).make_collectors(deployments, config)
from app.collectors import make_collectors

collectors = make_collectors(deployments, config)
alerts: list[dict[str, str]] = []
for collector in collectors:
result = await collector.collect()
Expand Down Expand Up @@ -197,12 +204,10 @@ async def _check_stale_workers() -> None:
"""Mark workers as offline if they haven't sent a heartbeat recently."""
try:
workers = await database.list_workers()
from datetime import datetime, timedelta

cutoff = datetime.utcnow() - timedelta(seconds=STALE_WORKER_SECONDS)
cutoff = datetime.now(datetime.UTC) - timedelta(seconds=STALE_WORKER_SECONDS)
for w in workers:
if w["status"] == "online" and w.get("last_heartbeat"):
last = datetime.fromisoformat(w["last_heartbeat"])
last = datetime.fromisoformat(w["last_heartbeat"]).replace(tzinfo=datetime.UTC)
if last < cutoff:
await database.set_worker_status(w["id"], "offline")
logger.info("Worker '%s' marked offline (last heartbeat: %s)", w["name"], w["last_heartbeat"])
Expand Down Expand Up @@ -245,6 +250,20 @@ async def lifespan(app: FastAPI):
lifespan=lifespan,
)


# Security headers middleware
class _SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
return response


app.add_middleware(_SecurityHeadersMiddleware)

# Static files and templates
app.mount("/static", StaticFiles(directory="app/static"), name="static")
templates = Jinja2Templates(directory="app/templates")
Expand Down Expand Up @@ -384,6 +403,22 @@ async def do_register(
if not user or user.get("r") != "owner":
raise HTTPException(status_code=403, detail="Only owners can add users")

if not re.match(r"^[a-zA-Z0-9_-]{3,32}$", username):
return templates.TemplateResponse(
request,
"auth.html",
{
"title": "Create Account" if is_first else "Add User",
"subtitle": "Create the first admin account" if is_first else "Add a new user",
"mode": "register",
"action": "/register",
"button_text": "Create Account",
"error": "Username must be 3-32 alphanumeric characters (a-z, 0-9, _ -)",
"is_first": is_first,
},
status_code=400,
)

if password != password_confirm:
return templates.TemplateResponse(
request,
Expand All @@ -400,7 +435,7 @@ async def do_register(
status_code=400,
)

if len(password) < 6:
if len(password) < 8:
return templates.TemplateResponse(
request,
"auth.html",
Expand All @@ -410,7 +445,7 @@ async def do_register(
"mode": "register",
"action": "/register",
"button_text": "Create Account",
"error": "Password must be at least 6 characters",
"error": "Password must be at least 8 characters",
"is_first": is_first,
},
status_code=400,
Expand Down Expand Up @@ -749,8 +784,6 @@ async def api_deploy(request: Request, slug: str, body: DeployRequest, worker_id
raise HTTPException(status_code=400, detail=f"Service '{slug}' has no Docker image")

# Build full env: YAML defaults + {hostname} substitution + user overrides
import re

hn = body.hostname or HOSTNAME_PREFIX
env: dict[str, str] = {}
for var in docker_conf.get("env", []):
Expand Down Expand Up @@ -834,21 +867,47 @@ async def api_remove(request: Request, slug: str, worker_id: int | None = None)
# Helpers: proxy commands / logs to worker nodes
# ---------------------------------------------------------------------------

_ALLOWED_WORKER_SCHEMES = {"http", "https"}

async def _proxy_worker_command(worker_id: int, command: str, slug: str) -> dict[str, str]:
"""Forward a container command (restart/stop/start/remove) to a worker."""
worker = await database.get_worker(worker_id)

def _validate_worker_url(raw_url: str) -> str:
"""Validate and return a safe worker URL; raise 400 on SSRF-risky targets."""
parsed = urlparse(raw_url)
if parsed.scheme not in _ALLOWED_WORKER_SCHEMES:
raise HTTPException(status_code=400, detail=f"Invalid worker URL scheme: {parsed.scheme}")
host = parsed.hostname or ""
if not host:
raise HTTPException(status_code=400, detail="Worker URL has no host")
try:
addr = ipaddress.ip_address(host)
if addr.is_loopback or addr.is_link_local:
raise HTTPException(status_code=400, detail="Worker URL points to loopback/link-local address")
except ValueError:
# hostname, not IP — allow (e.g. tailscale DNS names)
if host in ("localhost", "localhost.localdomain"):
raise HTTPException(status_code=400, detail="Worker URL points to localhost")
return raw_url.rstrip("/")


def _get_verified_worker_url(worker: dict[str, Any]) -> tuple[str, dict[str, str]]:
"""Validate a worker record and return (url, headers)."""
if not worker:
raise HTTPException(status_code=404, detail="Worker not found")
if worker["status"] != "online":
raise HTTPException(status_code=503, detail="Worker is offline")
if not worker["url"]:
raise HTTPException(status_code=503, detail="Worker URL not known")

url = worker["url"].rstrip("/")
headers = {}
url = _validate_worker_url(worker["url"])
headers: dict[str, str] = {}
if FLEET_API_KEY:
headers["Authorization"] = f"Bearer {FLEET_API_KEY}"
return url, headers


async def _proxy_worker_command(worker_id: int, command: str, slug: str) -> dict[str, str]:
"""Forward a container command (restart/stop/start/remove) to a worker."""
worker = await database.get_worker(worker_id)
url, headers = _get_verified_worker_url(worker)

try:
async with httpx.AsyncClient(timeout=30) as client:
Expand All @@ -871,17 +930,7 @@ async def _proxy_worker_command(worker_id: int, command: str, slug: str) -> dict
async def _proxy_worker_deploy(worker_id: int, slug: str, spec: dict[str, Any]) -> dict[str, Any]:
"""Forward a deploy command with full spec to a worker."""
worker = await database.get_worker(worker_id)
if not worker:
raise HTTPException(status_code=404, detail="Worker not found")
if worker["status"] != "online":
raise HTTPException(status_code=503, detail="Worker is offline")
if not worker["url"]:
raise HTTPException(status_code=503, detail="Worker URL not known")

url = worker["url"].rstrip("/")
headers = {}
if FLEET_API_KEY:
headers["Authorization"] = f"Bearer {FLEET_API_KEY}"
url, headers = _get_verified_worker_url(worker)

try:
async with httpx.AsyncClient(timeout=60) as client:
Expand All @@ -901,17 +950,7 @@ async def _proxy_worker_deploy(worker_id: int, slug: str, spec: dict[str, Any])
async def _proxy_worker_logs(worker_id: int, slug: str, lines: int = 50) -> dict[str, str]:
"""Forward a logs request to a worker."""
worker = await database.get_worker(worker_id)
if not worker:
raise HTTPException(status_code=404, detail="Worker not found")
if worker["status"] != "online":
raise HTTPException(status_code=503, detail="Worker is offline")
if not worker["url"]:
raise HTTPException(status_code=503, detail="Worker URL not known")

url = worker["url"].rstrip("/")
headers = {}
if FLEET_API_KEY:
headers["Authorization"] = f"Bearer {FLEET_API_KEY}"
url, headers = _get_verified_worker_url(worker)

try:
async with httpx.AsyncClient(timeout=30) as client:
Expand Down
2 changes: 1 addition & 1 deletion app/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ <h3 class="modal-title" id="service-detail-title">Service</h3>
{% if user %}
<script>window._userRole = '{{ user.r }}';</script>
{% endif %}
<script async src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script async src="https://cdn.jsdelivr.net/npm/chart.js@4.4.8/dist/chart.umd.min.js" integrity="sha384-T/4KgSWuZEPozpPz7rnnp/5lDSnpY1VPJCojf1S81uTHS1E38qgLfMgVsAeRCWc4" crossorigin="anonymous"></script>
<script src="/static/js/app.js"></script>
{% block scripts %}{% endblock %}
</body>
Expand Down
19 changes: 10 additions & 9 deletions app/worker_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import platform
import socket
from contextlib import asynccontextmanager
from html import escape as _esc
from typing import Any

import httpx
Expand Down Expand Up @@ -181,9 +182,9 @@ async def worker_status_page():
status_color = "#22c55e" if c.get("status") == "running" else "#ef4444"
container_rows += f"""
<tr>
<td>{c.get("slug", "unknown")}</td>
<td><span style="color:{status_color}">{c.get("status", "unknown")}</span></td>
<td>{c.get("image", "")}</td>
<td>{_esc(str(c.get("slug", "unknown")))}</td>
<td><span style="color:{status_color}">{_esc(str(c.get("status", "unknown")))}</span></td>
<td>{_esc(str(c.get("image", "")))}</td>
<td>{c.get("cpu_percent", 0)}%</td>
<td>{c.get("memory_mb", 0)} MB</td>
</tr>"""
Expand All @@ -192,17 +193,17 @@ async def worker_status_page():
container_rows = '<tr><td colspan="5" style="text-align:center;color:#6b7280">No managed containers</td></tr>'

ui_status = (
f'<span style="color:#22c55e">Connected</span> to <code>{UI_URL}</code>'
f'<span style="color:#22c55e">Connected</span> to <code>{_esc(UI_URL)}</code>'
if _ui_connected
else '<span style="color:#ef4444">Disconnected</span>' + (f" — {_last_error}" if _last_error else "")
else '<span style="color:#ef4444">Disconnected</span>' + (f" — {_esc(_last_error)}" if _last_error else "")
)

return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CashPilot Worker — {WORKER_NAME}</title>
<title>CashPilot Worker — {_esc(WORKER_NAME)}</title>
<meta http-equiv="refresh" content="30">
<style>
* {{ margin:0; padding:0; box-sizing:border-box; }}
Expand All @@ -225,9 +226,9 @@ async def worker_status_page():
<div class="card">
<h2>Worker Info</h2>
<div class="info">
<div><label>Name</label><span>{WORKER_NAME}</span></div>
<div><label>Host</label><span>{socket.gethostname()}</span></div>
<div><label>Platform</label><span>{platform.system()} {platform.machine()}</span></div>
<div><label>Name</label><span>{_esc(WORKER_NAME)}</span></div>
<div><label>Host</label><span>{_esc(socket.gethostname())}</span></div>
<div><label>Platform</label><span>{_esc(platform.system())} {_esc(platform.machine())}</span></div>
<div><label>Docker</label><span>{"Available" if orchestrator.docker_available() else "Not available"}</span></div>
<div><label>UI Connection</label><span>{ui_status}</span></div>
<div><label>Last Heartbeat</label><span>{_last_heartbeat}</span></div>
Expand Down
3 changes: 1 addition & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,5 @@ httpx>=0.28
apscheduler>=3.10
python-multipart>=0.0.18
cryptography>=44.0
passlib[bcrypt]>=1.7
bcrypt>=4.0,<5.1
bcrypt>=4.0
itsdangerous>=2.2
Loading
Loading