diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e4d7f54 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +fleetledger/.env diff --git a/fleetledger/.env-example b/fleetledger/.env-example new file mode 100644 index 0000000..ced0806 --- /dev/null +++ b/fleetledger/.env-example @@ -0,0 +1,7 @@ +# Copy this file to .env and fill in strong values for production. +SESSION_SECRET=changeme_super_secret_value +# Set to 1 for HTTPS deployments; set to 0 only for local HTTP testing. +SESSION_COOKIE_SECURE=1 +DATABASE_PATH=/data/fleetledger.db +# Optional: Fernet key for encrypting management passwords (leave empty to disable) +ENCRYPTION_KEY= diff --git a/fleetledger/Dockerfile b/fleetledger/Dockerfile new file mode 100644 index 0000000..34bc2d4 --- /dev/null +++ b/fleetledger/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +ENV DATABASE_PATH=/data/fleetledger.db + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/fleetledger/README.md b/fleetledger/README.md new file mode 100644 index 0000000..bcf64fc --- /dev/null +++ b/fleetledger/README.md @@ -0,0 +1,86 @@ +# FleetLedger + +FleetLedger is a small self-hosted web app to keep track of your rented servers: + +- VPS, dedicated servers, storage boxes, managed services +- Provider, location, IPs, hardware +- Monthly / yearly pricing and contract dates +- Simple access info (management URLs, SSH user + key hint) +- Multi-user support with per-user data separation +- Admin user management (activate / deactivate users) +- Dark-mode-first UI with PWA support (installable as an app) +- Per-user **map view** for server locations +- Admin **global dashboard** for fleet-wide stats + +> **Security note:** FleetLedger is *not* a full password manager. +> It is intentionally designed to store only **management password(s) optionally** and +> only **SSH key *names*** (no private keys). + +--- + +## Features + +- **Authentication & Users** + - User registration + login (session cookie based) + - First registered user becomes **admin** + - Admin can view all users and activate/deactivate them + - Deactivated users cannot log in and will be logged out automatically + +- **Server Management** + - Each user has their own list of servers (no cross-visibility) + - Create / edit / archive (soft-delete) servers + - Fields include: + - General: name, hostname, type (VPS, dedicated, storage, managed, other), provider, location, tags + - Network: IPv4, IPv6 + - Billing: price, currency, billing period (monthly/yearly/other), contract start/end + - Hardware: CPU model, core count, RAM, storage size & type + - Access: management URL, management user, management password (optional), SSH user, SSH key hint + - Free-form notes + - Contract badges: + - **"abgelaufen"** (expired): contract end in the past + - **"läuft bald aus"** (expiring soon): contract end within the next 30 days + - Detail view also shows how many days until / since contract end + +- **Per-user Dashboard & Map** + - On `/`: small dashboard row showing: + - number of active servers + - estimated total monthly cost + - how many contracts are expiring soon / already expired + - On `/map`: Leaflet-based map showing all non-archived servers of the logged-in user + - Marker position is derived from the `location` string (city/datacenter name) + - Multiple servers per city are slightly offset so all markers remain clickable + - Click on a marker → opens the server details page + +- **Admin Global Dashboard** + - On `/admin/dashboard` (admin only): + - Global counts: users, servers, monthly cost, expiring soon, expired + - Breakdown by provider (server count, monthly total, expiring soon, expired) + - List of contracts expiring soon and already expired + +- **Security** + - Passwords hashed with **bcrypt** (`passlib[bcrypt]`) + - Optional encryption for management passwords using **Fernet** (`cryptography`) + - No private SSH keys are stored, only name/hint strings + - Jinja2 auto-escaping enabled; no untrusted HTML is rendered with `|safe` + - Management URLs are restricted to `http://` or `https://` (no `javascript:` links, etc.) + +- **UI / UX** + - TailwindCSS via CDN for quick styling + - Dark mode is **enabled by default** + - Theme preference stored in `localStorage` and toggleable via a small button + - Responsive layout, works well on mobile + - PWA manifest and service worker for a simple offline-friendly experience + +--- + +## Quick Start (Docker) + +### 0. Environment + +Kopiere `.env-example` nach `.env` und setze mindestens ein starkes `SESSION_SECRET`. Für lokale HTTP-Tests kannst du `SESSION_COOKIE_SECURE=0` setzen, in Produktion sollte es `1` bleiben. Optional kannst du einen `ENCRYPTION_KEY` (Fernet) hinterlegen, um Management-Passwörter zu speichern. + +### 1. Clone / copy the repository + +```bash +git clone https://example.com/your/fleetledger.git +cd fleetledger diff --git a/fleetledger/app/__init__.py b/fleetledger/app/__init__.py new file mode 100644 index 0000000..2487607 --- /dev/null +++ b/fleetledger/app/__init__.py @@ -0,0 +1 @@ +# Empty file to mark "app" as a package. diff --git a/fleetledger/app/auth.py b/fleetledger/app/auth.py new file mode 100644 index 0000000..d72ee59 --- /dev/null +++ b/fleetledger/app/auth.py @@ -0,0 +1,75 @@ +from typing import Optional + +from fastapi import Depends, HTTPException, Request, status +from passlib.context import CryptContext +from sqlmodel import Session, select + +from .db import get_session +from .models import User + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(password: str) -> str: + """Hash a password using bcrypt.""" + return pwd_context.hash(password) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against a stored bcrypt hash.""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_current_user( + request: Request, + session: Session = Depends(get_session), +) -> Optional[User]: + """ + Retrieve the currently logged-in user based on the session cookie. + + Returns None if no user is logged in or the user is inactive. + """ + user_id = request.session.get("user_id") + if not user_id: + return None + user = session.get(User, user_id) + if not user or not user.is_active: + # Clear session if the user was deactivated. + request.session.clear() + return None + return user + + +def require_current_user( + current_user: Optional[User] = Depends(get_current_user), +) -> User: + """ + Require an authenticated user. + + If not authenticated, redirect to /login with a 303 status. + """ + if not current_user: + raise HTTPException( + status_code=status.HTTP_303_SEE_OTHER, + headers={"Location": "/login"}, + ) + return current_user + + +def require_admin( + current_user: Optional[User] = Depends(get_current_user), +) -> User: + """ + Require an authenticated admin user. + + Non-authenticated users are redirected to /login. + Non-admins receive HTTP 403. + """ + if not current_user: + raise HTTPException( + status_code=status.HTTP_303_SEE_OTHER, + headers={"Location": "/login"}, + ) + if not current_user.is_admin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + return current_user diff --git a/fleetledger/app/db.py b/fleetledger/app/db.py new file mode 100644 index 0000000..efb7132 --- /dev/null +++ b/fleetledger/app/db.py @@ -0,0 +1,21 @@ +from sqlmodel import SQLModel, create_engine, Session +import os + +# SQLite database path, mapped to a Docker volume for persistence +DB_PATH = os.getenv("DATABASE_PATH", "/data/fleetledger.db") + +engine = create_engine( + f"sqlite:///{DB_PATH}", + connect_args={"check_same_thread": False}, +) + + +def init_db() -> None: + """Create all tables if they do not exist yet.""" + SQLModel.metadata.create_all(engine) + + +def get_session(): + """FastAPI dependency that yields a SQLModel session.""" + with Session(engine) as session: + yield session diff --git a/fleetledger/app/main.py b/fleetledger/app/main.py new file mode 100644 index 0000000..6ff6774 --- /dev/null +++ b/fleetledger/app/main.py @@ -0,0 +1,783 @@ +import os +from datetime import datetime +from typing import Optional + +from fastapi import Depends, FastAPI, Form, HTTPException, Request, status +from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from sqlmodel import Session, select +from starlette.middleware.sessions import SessionMiddleware + +from .db import init_db, get_session +from .models import Server, User +from .utils import ( + encrypt_secret, + decrypt_secret, + can_encrypt, + ensure_csrf_token, + validate_csrf, +) +from .auth import ( + hash_password, + verify_password, + get_current_user, + require_current_user, + require_admin, +) + +app = FastAPI(title="FleetLedger") + +app.mount("/static", StaticFiles(directory="app/static"), name="static") +templates = Jinja2Templates(directory="app/templates") + +# Session middleware (server-side session based on signed cookie) +SESSION_SECRET = os.getenv("SESSION_SECRET") +if not SESSION_SECRET or SESSION_SECRET.startswith("CHANGE_ME"): + raise RuntimeError( + "SESSION_SECRET environment variable must be set to a strong random value." + ) + +SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "1") != "0" + +app.add_middleware( + SessionMiddleware, + secret_key=SESSION_SECRET, + same_site="lax", + https_only=SESSION_COOKIE_SECURE, + cookie_name="fleetledger_session", + max_age=60 * 60 * 24 * 30, # 30 days +) + + +@app.on_event("startup") +def on_startup() -> None: + """Initialize database on startup.""" + init_db() + + +@app.get("/manifest.webmanifest") +def manifest() -> FileResponse: + """Serve the PWA manifest.""" + return FileResponse( + "app/static/manifest.webmanifest", media_type="application/manifest+json" + ) + + +@app.get("/service-worker.js", include_in_schema=False) +def service_worker() -> FileResponse: + """Serve the service worker from root scope.""" + return FileResponse( + "app/static/service-worker.js", + media_type="application/javascript", + headers={"Service-Worker-Allowed": "/"}, + ) + + +# ------------- Auth: Register / Login / Logout ------------- + + +@app.get("/register", response_class=HTMLResponse) +def register_form( + request: Request, + session: Session = Depends(get_session), + current_user: Optional[User] = Depends(get_current_user), +): + """ + Render the registration form. + + If at least one user already exists, only admins may register new users. + """ + user_count = len(session.exec(select(User)).all()) + if user_count > 0 and (not current_user or not current_user.is_admin): + return RedirectResponse("/", status_code=303) + + csrf_token = ensure_csrf_token(request) + + return templates.TemplateResponse( + "register.html", + { + "request": request, + "error": None, + "current_user": current_user, + "csrf_token": csrf_token, + }, + ) + + +@app.post("/register") +def register( + request: Request, + username: str = Form(...), + email: str = Form(""), + password: str = Form(...), + password_confirm: str = Form(...), + csrf_token: str = Form(...), + session: Session = Depends(get_session), + current_user: Optional[User] = Depends(get_current_user), +): + """ + Handle registration submissions. + + - First user becomes admin automatically. + - Later users can only be created by admins. + """ + if not validate_csrf(request, csrf_token): + token = ensure_csrf_token(request) + return templates.TemplateResponse( + "register.html", + { + "request": request, + "error": "Ungültiger Sicherheits-Token. Bitte erneut versuchen.", + "current_user": current_user, + "csrf_token": token, + }, + status_code=400, + ) + + user_count = len(session.exec(select(User)).all()) + if user_count > 0 and (not current_user or not current_user.is_admin): + return RedirectResponse("/", status_code=303) + + error = None + if password != password_confirm: + error = "Passwords do not match." + else: + existing = session.exec( + select(User).where(User.username == username) + ).first() + if existing: + error = "Username is already taken." + + csrf_token = ensure_csrf_token(request) + + if error: + return templates.TemplateResponse( + "register.html", + { + "request": request, + "error": error, + "current_user": current_user, + "csrf_token": csrf_token, + }, + status_code=400, + ) + + user = User( + username=username, + email=email or None, + password_hash=hash_password(password), + is_active=True, + is_admin=(user_count == 0), # first user is admin + ) + session.add(user) + session.commit() + session.refresh(user) + + # Auto-login after registration + request.session["user_id"] = user.id + return RedirectResponse("/", status_code=303) + + +@app.get("/login", response_class=HTMLResponse) +def login_form( + request: Request, + current_user: Optional[User] = Depends(get_current_user), +): + """Render the login form.""" + if current_user: + return RedirectResponse("/", status_code=303) + csrf_token = ensure_csrf_token(request) + return templates.TemplateResponse( + "login.html", + { + "request": request, + "error": None, + "current_user": current_user, + "csrf_token": csrf_token, + }, + ) + + +@app.post("/login") +def login( + request: Request, + username: str = Form(...), + password: str = Form(...), + csrf_token: str = Form(...), + session: Session = Depends(get_session), +): + """Handle login submissions.""" + if not validate_csrf(request, csrf_token): + token = ensure_csrf_token(request) + return templates.TemplateResponse( + "login.html", + { + "request": request, + "error": "Ungültiger Sicherheits-Token. Bitte erneut versuchen.", + "current_user": None, + "csrf_token": token, + }, + status_code=400, + ) + + user = session.exec(select(User).where(User.username == username)).first() + error = None + if not user or not verify_password(password, user.password_hash): + error = "Invalid username or password." + elif not user.is_active: + error = "User is deactivated." + + csrf_token = ensure_csrf_token(request) + + if error: + return templates.TemplateResponse( + "login.html", + { + "request": request, + "error": error, + "current_user": None, + "csrf_token": csrf_token, + }, + status_code=400, + ) + + request.session["user_id"] = user.id + return RedirectResponse("/", status_code=303) + + +@app.get("/logout") +def logout(request: Request): + """Log out the current user and clear the session.""" + request.session.clear() + return RedirectResponse("/login", status_code=303) + + +# ------------- Admin: User management ------------- + + +@app.get("/users", response_class=HTMLResponse) +def list_users( + request: Request, + session: Session = Depends(get_session), + current_user: User = Depends(require_admin), +): + """List all users (admin only).""" + users = session.exec(select(User).order_by(User.username)).all() + csrf_token = ensure_csrf_token(request) + return templates.TemplateResponse( + "users_list.html", + { + "request": request, + "users": users, + "current_user": current_user, + "csrf_token": csrf_token, + }, + ) + + +@app.post("/users/{user_id}/toggle-active") +def toggle_user_active( + user_id: int, + request: Request, + csrf_token: str = Form(...), + session: Session = Depends(get_session), + current_user: User = Depends(require_admin), +): + """ + Toggle a user's active state (admin only). + + An admin cannot deactivate themselves to avoid lockout. + """ + if not validate_csrf(request, csrf_token): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid CSRF token." + ) + + user = session.get(User, user_id) + if not user: + return RedirectResponse("/users", status_code=303) + + if user.id == current_user.id: + return RedirectResponse("/users", status_code=303) + + user.is_active = not user.is_active + session.add(user) + session.commit() + return RedirectResponse("/users", status_code=303) + + +# ------------- Admin: Global dashboard ------------- + + +@app.get("/admin/dashboard", response_class=HTMLResponse) +def admin_dashboard( + request: Request, + session: Session = Depends(get_session), + current_user: User = Depends(require_admin), +): + """ + Global admin dashboard aggregating all non-archived servers across all users. + """ + servers = session.exec( + select(Server).where(Server.archived == False) + ).all() + users = session.exec(select(User)).all() + + total_servers = len(servers) + total_users = len(users) + expiring_soon = sum(1 for s in servers if s.is_expiring_soon) + expired = sum(1 for s in servers if s.is_expired) + + monthly_total = 0.0 + currencies = set() + for s in servers: + if not s.price: + continue + if s.currency: + currencies.add(s.currency) + if s.billing_period == "yearly": + monthly_total += s.price / 12.0 + else: + monthly_total += s.price + + monthly_currency = None + if len(currencies) == 1: + monthly_currency = next(iter(currencies)) + + # Provider-level stats + provider_stats = {} + for s in servers: + provider = s.provider or "Unknown" + if provider not in provider_stats: + provider_stats[provider] = { + "count": 0, + "monthly_total": 0.0, + "expiring_soon": 0, + "expired": 0, + "currency_set": set(), + } + ps = provider_stats[provider] + ps["count"] += 1 + if s.price: + if s.currency: + ps["currency_set"].add(s.currency) + if s.billing_period == "yearly": + ps["monthly_total"] += s.price / 12.0 + else: + ps["monthly_total"] += s.price + if s.is_expiring_soon: + ps["expiring_soon"] += 1 + if s.is_expired: + ps["expired"] += 1 + + # Contracts expiring soon and expired, for small lists + expiring_soon_list = sorted( + [s for s in servers if s.is_expiring_soon], + key=lambda s: s.contract_end or datetime.max.date(), + ) + expired_list = sorted( + [s for s in servers if s.is_expired], + key=lambda s: s.contract_end or datetime.min.date(), + reverse=True, + ) + + stats = { + "total_servers": total_servers, + "total_users": total_users, + "expiring_soon": expiring_soon, + "expired": expired, + "monthly_total": monthly_total, + "monthly_currency": monthly_currency, + "mixed_currencies": len(currencies) > 1, + } + + return templates.TemplateResponse( + "admin_dashboard.html", + { + "request": request, + "current_user": current_user, + "stats": stats, + "provider_stats": provider_stats, + "expiring_soon_list": expiring_soon_list, + "expired_list": expired_list, + }, + ) + + +# ------------- Server views (CRUD, per user) ------------- + + +@app.get("/", response_class=HTMLResponse) +def list_servers( + request: Request, + session: Session = Depends(get_session), + current_user: User = Depends(require_current_user), +): + """ + List all non-archived servers owned by the current user. + Additionally compute some summary stats for the dashboard. + """ + servers = session.exec( + select(Server) + .where(Server.archived == False) + .where(Server.owner_id == current_user.id) + .order_by(Server.provider, Server.name) + ).all() + + # --- Dashboard stats --- + total_servers = len(servers) + expiring_soon = sum(1 for s in servers if s.is_expiring_soon) + expired = sum(1 for s in servers if s.is_expired) + + # Approximate total monthly cost + monthly_total = 0.0 + currencies = set() + for s in servers: + if not s.price: + continue + if s.currency: + currencies.add(s.currency) + if s.billing_period == "yearly": + monthly_total += s.price / 12.0 + else: + # treat "monthly" and "other" as monthly for the purpose of the overview + monthly_total += s.price + + monthly_currency = None + if len(currencies) == 1: + monthly_currency = next(iter(currencies)) + + stats = { + "total_servers": total_servers, + "expiring_soon": expiring_soon, + "expired": expired, + "monthly_total": monthly_total, + "monthly_currency": monthly_currency, + "mixed_currencies": len(currencies) > 1, + } + + return templates.TemplateResponse( + "servers_list.html", + { + "request": request, + "servers": servers, + "can_encrypt": can_encrypt(), + "current_user": current_user, + "stats": stats, + }, + ) + + +@app.get("/servers/new", response_class=HTMLResponse) +def new_server_form( + request: Request, + current_user: User = Depends(require_current_user), +): + """Render a blank form for creating a new server.""" + csrf_token = ensure_csrf_token(request) + return templates.TemplateResponse( + "server_form.html", + { + "request": request, + "server": None, + "can_encrypt": can_encrypt(), + "current_user": current_user, + "csrf_token": csrf_token, + }, + ) + + +@app.post("/servers/new") +def create_server( + request: Request, + name: str = Form(...), + provider: str = Form(...), + hostname: str = Form(""), + type: str = Form("vps"), + location: str = Form(""), + ipv4: str = Form(""), + ipv6: str = Form(""), + billing_period: str = Form("monthly"), + price: float = Form(0.0), + currency: str = Form("EUR"), + contract_start: Optional[str] = Form(None), + contract_end: Optional[str] = Form(None), + cpu_model: str = Form(""), + cpu_cores: int = Form(0), + ram_mb: int = Form(0), + storage_gb: int = Form(0), + storage_type: str = Form(""), + tags: str = Form(""), + mgmt_url: str = Form(""), + mgmt_user: str = Form(""), + mgmt_password: str = Form(""), + ssh_user: str = Form(""), + ssh_key_hint: str = Form(""), + notes: str = Form(""), + csrf_token: str = Form(...), + session: Session = Depends(get_session), + current_user: User = Depends(require_current_user), +): + """Create a new server entry for the current user.""" + if not validate_csrf(request, csrf_token): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid CSRF token." + ) + + c_start = ( + datetime.fromisoformat(contract_start).date() if contract_start else None + ) + c_end = datetime.fromisoformat(contract_end).date() if contract_end else None + + enc_pwd = encrypt_secret(mgmt_password) if mgmt_password else None + + # Only allow http:// or https:// URLs to avoid javascript: schemes etc. + mgmt_url_clean = mgmt_url.strip() + if mgmt_url_clean and not ( + mgmt_url_clean.lower().startswith("http://") + or mgmt_url_clean.lower().startswith("https://") + ): + mgmt_url_clean = "" + + server = Server( + owner_id=current_user.id, + name=name, + provider=provider, + hostname=hostname or None, + type=type, + location=location or None, + ipv4=ipv4 or None, + ipv6=ipv6 or None, + billing_period=billing_period, + price=price, + currency=currency, + contract_start=c_start, + contract_end=c_end, + cpu_model=cpu_model or None, + cpu_cores=cpu_cores or None, + ram_mb=ram_mb or None, + storage_gb=storage_gb or None, + storage_type=storage_type or None, + tags=tags or None, + mgmt_url=mgmt_url_clean or None, + mgmt_user=mgmt_user or None, + mgmt_password_encrypted=enc_pwd, + ssh_user=ssh_user or None, + ssh_key_hint=ssh_key_hint or None, + notes=notes or None, + updated_at=datetime.utcnow(), + ) + session.add(server) + session.commit() + session.refresh(server) + return RedirectResponse(url="/", status_code=303) + + +@app.get("/servers/{server_id}", response_class=HTMLResponse) +def server_detail( + server_id: int, + request: Request, + session: Session = Depends(get_session), + current_user: User = Depends(require_current_user), +): + """Show details for a single server.""" + server = session.get(Server, server_id) + if not server or server.owner_id != current_user.id: + return RedirectResponse("/", status_code=303) + + decrypted_pwd = ( + decrypt_secret(server.mgmt_password_encrypted) + if server.mgmt_password_encrypted + else None + ) + csrf_token = ensure_csrf_token(request) + + return templates.TemplateResponse( + "server_detail.html", + { + "request": request, + "server": server, + "mgmt_password": decrypted_pwd, + "can_encrypt": can_encrypt(), + "current_user": current_user, + "csrf_token": csrf_token, + }, + ) + + +@app.get("/servers/{server_id}/edit", response_class=HTMLResponse) +def edit_server_form( + server_id: int, + request: Request, + session: Session = Depends(get_session), + current_user: User = Depends(require_current_user), +): + """Render a pre-filled form for editing an existing server.""" + server = session.get(Server, server_id) + if not server or server.owner_id != current_user.id: + return RedirectResponse("/", status_code=303) + + csrf_token = ensure_csrf_token(request) + + return templates.TemplateResponse( + "server_form.html", + { + "request": request, + "server": server, + "can_encrypt": can_encrypt(), + "current_user": current_user, + "csrf_token": csrf_token, + }, + ) + + +@app.post("/servers/{server_id}/edit") +def update_server( + server_id: int, + request: Request, + name: str = Form(...), + provider: str = Form(...), + hostname: str = Form(""), + type: str = Form("vps"), + location: str = Form(""), + ipv4: str = Form(""), + ipv6: str = Form(""), + billing_period: str = Form("monthly"), + price: float = Form(0.0), + currency: str = Form("EUR"), + contract_start: Optional[str] = Form(None), + contract_end: Optional[str] = Form(None), + cpu_model: str = Form(""), + cpu_cores: int = Form(0), + ram_mb: int = Form(0), + storage_gb: int = Form(0), + storage_type: str = Form(""), + tags: str = Form(""), + mgmt_url: str = Form(""), + mgmt_user: str = Form(""), + mgmt_password: str = Form(""), + ssh_user: str = Form(""), + ssh_key_hint: str = Form(""), + notes: str = Form(""), + csrf_token: str = Form(...), + session: Session = Depends(get_session), + current_user: User = Depends(require_current_user), +): + """Update an existing server entry (only owner).""" + server = session.get(Server, server_id) + if not server or server.owner_id != current_user.id: + return RedirectResponse("/", status_code=303) + + if not validate_csrf(request, csrf_token): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid CSRF token." + ) + + c_start = ( + datetime.fromisoformat(contract_start).date() if contract_start else None + ) + c_end = datetime.fromisoformat(contract_end).date() if contract_end else None + + # Only update mgmt password if a non-empty value was submitted. + if mgmt_password: + enc_pwd = encrypt_secret(mgmt_password) + server.mgmt_password_encrypted = enc_pwd + + mgmt_url_clean = mgmt_url.strip() + if mgmt_url_clean and not ( + mgmt_url_clean.lower().startswith("http://") + or mgmt_url_clean.lower().startswith("https://") + ): + mgmt_url_clean = "" + + server.name = name + server.provider = provider + server.hostname = hostname or None + server.type = type + server.location = location or None + server.ipv4 = ipv4 or None + server.ipv6 = ipv6 or None + server.billing_period = billing_period + server.price = price + server.currency = currency + server.contract_start = c_start + server.contract_end = c_end + server.cpu_model = cpu_model or None + server.cpu_cores = cpu_cores or None + server.ram_mb = ram_mb or None + server.storage_gb = storage_gb or None + server.storage_type = storage_type or None + server.tags = tags or None + server.mgmt_url = mgmt_url_clean or None + server.mgmt_user = mgmt_user or None + server.ssh_user = ssh_user or None + server.ssh_key_hint = ssh_key_hint or None + server.notes = notes or None + server.updated_at = datetime.utcnow() + + session.add(server) + session.commit() + return RedirectResponse(f"/servers/{server_id}", status_code=303) + + +@app.post("/servers/{server_id}/archive") +def archive_server( + server_id: int, + request: Request, + csrf_token: str = Form(...), + session: Session = Depends(get_session), + current_user: User = Depends(require_current_user), +): + """ + Soft-delete (archive) a server. + + The record is kept but not shown in the main list. + """ + if not validate_csrf(request, csrf_token): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid CSRF token." + ) + + server = session.get(Server, server_id) + if not server or server.owner_id != current_user.id: + return RedirectResponse("/", status_code=303) + + server.archived = True + server.updated_at = datetime.utcnow() + session.add(server) + session.commit() + return RedirectResponse("/", status_code=303) + + +# ------------- Per-user map view ------------- + + +@app.get("/map", response_class=HTMLResponse) +def server_map( + request: Request, + session: Session = Depends(get_session), + current_user: User = Depends(require_current_user), +): + """ + Show a per-user map view with all non-archived servers. + + Coordinates are not stored in the database. Instead, the frontend derives + approximate positions from the location string (city/datacenter name) + using a built-in city map and a deterministic fallback. + """ + servers = session.exec( + select(Server) + .where(Server.archived == False) + .where(Server.owner_id == current_user.id) + .order_by(Server.provider, Server.name) + ).all() + + return templates.TemplateResponse( + "servers_map.html", + { + "request": request, + "servers": servers, + "current_user": current_user, + }, + ) diff --git a/fleetledger/app/models.py b/fleetledger/app/models.py new file mode 100644 index 0000000..b9b8bca --- /dev/null +++ b/fleetledger/app/models.py @@ -0,0 +1,99 @@ +from datetime import date, datetime +from typing import Optional, List + +from sqlmodel import SQLModel, Field, Relationship + + +class User(SQLModel, table=True): + """Application user model.""" + + id: Optional[int] = Field(default=None, primary_key=True) + + username: str = Field(index=True, unique=True) + email: Optional[str] = Field(default=None, index=True) + password_hash: str + + is_active: bool = Field(default=True) + is_admin: bool = Field(default=False) + + servers: List["Server"] = Relationship(back_populates="owner") + + +class Server(SQLModel, table=True): + """Server/VPS entry owned by a user.""" + + id: Optional[int] = Field(default=None, primary_key=True) + + # Owner + owner_id: int = Field(foreign_key="user.id") + owner: Optional[User] = Relationship(back_populates="servers") + + # General info + name: str + hostname: Optional[str] = None + type: str = "vps" # vps, dedicated, storage, managed, other + provider: str + location: Optional[str] = None + + # Network + ipv4: Optional[str] = None + ipv6: Optional[str] = None + + # Cost / billing + billing_period: str = "monthly" # monthly, yearly, other + price: float = 0.0 + currency: str = "EUR" + contract_start: Optional[date] = None + contract_end: Optional[date] = None + tags: Optional[str] = None # e.g. "prod,critical,backup" + + # Hardware + cpu_model: Optional[str] = None + cpu_cores: Optional[int] = None + ram_mb: Optional[int] = None + storage_gb: Optional[int] = None + storage_type: Optional[str] = None # nvme, ssd, hdd, ceph, ... + + # Access (no private SSH keys, only hints) + mgmt_url: Optional[str] = None + mgmt_user: Optional[str] = None + mgmt_password_encrypted: Optional[str] = None + ssh_user: Optional[str] = None + ssh_key_hint: Optional[str] = None # e.g. "id_ed25519_ovh" + + notes: Optional[str] = None + + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + archived: bool = Field(default=False) + + # ----- Convenience properties for badges / UI ----- + + @property + def days_until_contract_end(self) -> Optional[int]: + """ + Number of days until the contract_end date. + + Returns: + int: positive or zero if in the future, + negative if already past, + None if no contract_end is set. + """ + if not self.contract_end: + return None + return (self.contract_end - date.today()).days + + @property + def is_expired(self) -> bool: + """Return True if the contract_end date lies in the past.""" + return self.contract_end is not None and self.contract_end < date.today() + + @property + def is_expiring_soon(self) -> bool: + """ + Return True if the contract will end within the next 30 days. + + This is used for "expiring soon" badges in the UI. + """ + days = self.days_until_contract_end + return days is not None and 0 <= days <= 30 diff --git a/fleetledger/app/static/icon-192.png b/fleetledger/app/static/icon-192.png new file mode 100644 index 0000000..bfeade6 Binary files /dev/null and b/fleetledger/app/static/icon-192.png differ diff --git a/fleetledger/app/static/icon-512.png b/fleetledger/app/static/icon-512.png new file mode 100644 index 0000000..aa731fa Binary files /dev/null and b/fleetledger/app/static/icon-512.png differ diff --git a/fleetledger/app/static/manifest.webmanifest b/fleetledger/app/static/manifest.webmanifest new file mode 100644 index 0000000..158b24e --- /dev/null +++ b/fleetledger/app/static/manifest.webmanifest @@ -0,0 +1,20 @@ +{ + "name": "FleetLedger", + "short_name": "FleetLedger", + "start_url": "/", + "display": "standalone", + "background_color": "#020617", + "theme_color": "#020617", + "icons": [ + { + "src": "/static/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/static/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/fleetledger/app/static/service-worker.js b/fleetledger/app/static/service-worker.js new file mode 100644 index 0000000..bbd314b --- /dev/null +++ b/fleetledger/app/static/service-worker.js @@ -0,0 +1,74 @@ +const CACHE_VERSION = "v2"; +const CACHE_NAME = `fleetledger-${CACHE_VERSION}`; +const ASSETS = [ + "/", + "/static/style.css", + "/static/icon-192.png", + "/static/icon-512.png", + "/static/manifest.webmanifest", +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(ASSETS); + }) + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys + .filter((key) => key.startsWith("fleetledger-") && key !== CACHE_NAME) + .map((key) => caches.delete(key)) + ) + ) + ); +}); + +async function networkFirst(request) { + try { + const response = await fetch(request); + const cache = await caches.open(CACHE_NAME); + cache.put(request, response.clone()); + return response; + } catch (err) { + const cached = await caches.match(request); + if (cached) { + return cached; + } + return caches.match("/"); + } +} + +async function cacheFirst(request) { + const cached = await caches.match(request); + if (cached) { + return cached; + } + const response = await fetch(request); + const cache = await caches.open(CACHE_NAME); + cache.put(request, response.clone()); + return response; +} + +self.addEventListener("fetch", (event) => { + if (event.request.method !== "GET") { + return; + } + + const url = new URL(event.request.url); + + // Navigation requests: network first, fallback to cache + if (event.request.mode === "navigate") { + event.respondWith(networkFirst(event.request)); + return; + } + + // Same-origin static assets: cache first + if (url.origin === self.location.origin) { + event.respondWith(cacheFirst(event.request)); + } +}); diff --git a/fleetledger/app/static/style.css b/fleetledger/app/static/style.css new file mode 100644 index 0000000..5bf82eb --- /dev/null +++ b/fleetledger/app/static/style.css @@ -0,0 +1,5 @@ +/* Simple extra styles on top of Tailwind. */ +body { + font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", + "Segoe UI", sans-serif; +} diff --git a/fleetledger/app/templates/admin_dashboard.html b/fleetledger/app/templates/admin_dashboard.html new file mode 100644 index 0000000..18394db --- /dev/null +++ b/fleetledger/app/templates/admin_dashboard.html @@ -0,0 +1,154 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+

Admin-Dashboard

+

+ Globale Übersicht über alle nicht archivierten Server und Benutzer. +

+
+
+ + {% if stats %} +
+
+ Benutzer + + {{ stats.total_users }} + + Accounts +
+
+ Server + + {{ stats.total_servers }} + + nicht archiviert +
+
+ Laufende Kosten + + {{ "%.2f"|format(stats.monthly_total) }} + {% if stats.monthly_currency %} {{ stats.monthly_currency }}{% endif %} + + + pro Monat{% if stats.mixed_currencies %} (gemischte Währungen){% endif %} + +
+
+ Vertragstatus + + Bald auslaufend: {{ stats.expiring_soon }} + + + Abgelaufen: {{ stats.expired }} + +
+
+ {% endif %} + + +
+

Nach Provider

+
+ + + + + + + + + + + + {% if provider_stats %} + {% for provider, ps in provider_stats.items() %} + + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
ProviderServerMonatskostenLaufen bald ausAbgelaufen
{{ provider }}{{ ps.count }} + {{ "%.2f"|format(ps.monthly_total) }} + {% if ps.currency_set|length == 1 %} + {{ (ps.currency_set|list)[0] }} + {% elif ps.currency_set|length > 1 %} + (mixed) + {% endif %} + {{ ps.expiring_soon }}{{ ps.expired }}
+ Keine Server erfasst. +
+
+
+ + +
+
+

Laufen bald aus (≤ 30 Tage)

+
+ {% if expiring_soon_list %} +
    + {% for s in expiring_soon_list %} +
  • +
    + + {{ s.name }} + +
    + {{ s.provider }} + {% if s.location %} · {{ s.location }}{% endif %} +
    +
    +
    + endet {{ s.contract_end }} +
    +
  • + {% endfor %} +
+ {% else %} +
Keine Verträge laufen in den nächsten 30 Tagen aus.
+ {% endif %} +
+
+ +
+

Abgelaufene Verträge

+
+ {% if expired_list %} +
    + {% for s in expired_list[:10] %} +
  • +
    + + {{ s.name }} + +
    + {{ s.provider }} + {% if s.location %} · {{ s.location }}{% endif %} +
    +
    +
    + endete {{ s.contract_end }} +
    +
  • + {% endfor %} +
+ {% else %} +
Keine abgelaufenen Verträge gefunden.
+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/fleetledger/app/templates/base.html b/fleetledger/app/templates/base.html new file mode 100644 index 0000000..c77ce91 --- /dev/null +++ b/fleetledger/app/templates/base.html @@ -0,0 +1,129 @@ + + + + + FleetLedger + + + + + + + + + + + + {% block extra_head %}{% endblock %} + + +
+
+
+
+
+ FL +
+
+
FleetLedger
+
Deine gemieteten Server im Blick
+
+
+
+ {% if current_user %} + + Eingeloggt als {{ current_user.username }} + {% if current_user.is_admin %} + Admin + {% endif %} + + {% if current_user.is_admin %} + + Admin-Dashboard + + + User + + {% endif %} + + Übersicht + + + Karte + + + + Server anlegen + + + Logout + + {% else %} + + Login + + + Registrieren + + {% endif %} + +
+
+
+ +
+
+ {% block content %}{% endblock %} +
+
+ + +
+ + + + {% block extra_scripts %}{% endblock %} + + diff --git a/fleetledger/app/templates/login.html b/fleetledger/app/templates/login.html new file mode 100644 index 0000000..70dce5c --- /dev/null +++ b/fleetledger/app/templates/login.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block content %} +
+

Login

+

Melde dich an, um deine Server zu verwalten.

+ + {% if error %} +
+ {{ error }} +
+ {% endif %} + +
+ +
+ + +
+
+ + +
+
+ Noch kein Account? Registrieren +
+ +
+
+{% endblock %} diff --git a/fleetledger/app/templates/register.html b/fleetledger/app/templates/register.html new file mode 100644 index 0000000..09df005 --- /dev/null +++ b/fleetledger/app/templates/register.html @@ -0,0 +1,62 @@ +{% extends "base.html" %} +{% block content %} +
+

Registrieren

+

+ Erstelle einen neuen Account. Der erste Benutzer wird automatisch Admin. +

+ + {% if error %} +
+ {{ error }} +
+ {% endif %} + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+{% endblock %} diff --git a/fleetledger/app/templates/server_detail.html b/fleetledger/app/templates/server_detail.html new file mode 100644 index 0000000..70fa106 --- /dev/null +++ b/fleetledger/app/templates/server_detail.html @@ -0,0 +1,196 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+
Server
+

+ {{ server.name }} + {% if server.is_expired %} + + abgelaufen + + {% elif server.is_expiring_soon %} + + läuft bald aus + + {% endif %} +

+ {% if server.hostname %} +

{{ server.hostname }}

+ {% endif %} + {% if server.contract_end %} +

+ Vertragsende: {{ server.contract_end }} + {% if server.days_until_contract_end is not none %} + {% if server.days_until_contract_end < 0 %} + (vor {{ (server.days_until_contract_end * -1) }} Tagen) + {% elif server.days_until_contract_end == 0 %} + (heute) + {% else %} + (in {{ server.days_until_contract_end }} Tagen) + {% endif %} + {% endif %} +

+ {% endif %} +
+
+ + {{ server.provider }} + + + {{ server.type }} + + {% if server.location %} + + {{ server.location }} + + {% endif %} + {% if server.tags %} + + {{ server.tags }} + + {% endif %} + + Bearbeiten + +
+ + +
+
+
+ +
+ +
+

Netz & Kosten

+
+
+
IPv4
+
{% if server.ipv4 %}{{ server.ipv4 }}{% else %}–{% endif %}
+
+
+
IPv6
+
{% if server.ipv6 %}{{ server.ipv6 }}{% else %}–{% endif %}
+
+
+
Kosten
+
+ {% if server.price %} + {{ "%.2f"|format(server.price) }} {{ server.currency }} / + {{ "Monat" if server.billing_period == "monthly" else "Jahr" }} + {% else %}–{% endif %} +
+
+
+
Vertrag
+
+ {% if server.contract_start %}ab {{ server.contract_start }}{% endif %} + {% if server.contract_end %} bis {{ server.contract_end }}{% endif %} + {% if not server.contract_start and not server.contract_end %}–{% endif %} +
+
+
+
+ + +
+

Hardware

+
+
+
CPU
+
+ {% if server.cpu_model %}{{ server.cpu_model }}{% else %}–{% endif %} + {% if server.cpu_cores %} ({{ server.cpu_cores }} Cores){% endif %} +
+
+
+
RAM
+
+ {% if server.ram_mb %}{{ server.ram_mb }} MB{% else %}–{% endif %} +
+
+
+
Storage
+
+ {% if server.storage_gb %}{{ server.storage_gb }} GB{% else %}–{% endif %} + {% if server.storage_type %} ({{ server.storage_type }}){% endif %} +
+
+
+
+ + +
+

Zugänge

+
+
+
Management
+
+ {% if server.mgmt_url %} + + Console öffnen + + {% else %}–{% endif %} +
+
+
+
Mgmt-User
+
{% if server.mgmt_user %}{{ server.mgmt_user }}{% else %}–{% endif %}
+
+
+
Mgmt-Passwort
+
+ {% if server.mgmt_password_encrypted %} + {% if mgmt_password %} + {{ mgmt_password }} + {% else %} + verschlüsselt gespeichert (Key fehlt?) + {% endif %} + {% else %} + – + {% endif %} +
+
+
+
SSH User
+
{% if server.ssh_user %}{{ server.ssh_user }}{% else %}–{% endif %}
+
+
+
SSH Key Hint
+
+ {% if server.ssh_key_hint %} + {{ server.ssh_key_hint }} + {% else %}–{% endif %} +
+
+
+

+ Hinweis: Es werden nur Key-Namen gespeichert, keine privaten SSH-Schlüssel. +

+
+ + +
+

Notizen

+
+ {% if server.notes %}{{ server.notes }}{% else %}Keine Notizen.{% endif %} +
+
+
+ +
+ Zurück zur Übersicht + Zuletzt aktualisiert: {{ server.updated_at }} +
+
+{% endblock %} diff --git a/fleetledger/app/templates/server_form.html b/fleetledger/app/templates/server_form.html new file mode 100644 index 0000000..13a4c9e --- /dev/null +++ b/fleetledger/app/templates/server_form.html @@ -0,0 +1,315 @@ +{% extends "base.html" %} +{% block content %} +
+

+ {% if server %}Server bearbeiten{% else %}Neuen Server anlegen{% endif %} +

+

+ Trage alle relevanten Infos zu deinem VPS / Server ein. +

+ + {% if not can_encrypt %} +
+ ENCRYPTION_KEY ist nicht gesetzt – eingegebene Management-Passwörter werden nicht gespeichert. +
+ {% endif %} + +
+ + +
+

Allgemein

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

Netzwerk

+
+
+ + +
+
+ + +
+
+
+ + +
+

Kosten

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

Hardware

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

Zugänge

+

+ SSH: hier nur Key-Namen oder Hints eintragen, keine privaten Keys. +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

Notizen

+ +
+ +
+ Abbrechen + +
+
+
+{% endblock %} +s diff --git a/fleetledger/app/templates/servers_list.html b/fleetledger/app/templates/servers_list.html new file mode 100644 index 0000000..87504d0 --- /dev/null +++ b/fleetledger/app/templates/servers_list.html @@ -0,0 +1,125 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+

Deine Server

+

+ Übersicht aller gemieteten VPS, dedizierten Server und Storage-Systeme. +

+
+
+ {% if not can_encrypt %} + + Hinweis: ENCRYPTION_KEY nicht gesetzt – Passwörter werden nicht gespeichert. + + {% endif %} +
+
+ + {% if stats %} + +
+
+ Gesamt + + {{ stats.total_servers }} + + aktive Server +
+
+ Laufende Kosten + + {{ "%.2f"|format(stats.monthly_total) }} + {% if stats.monthly_currency %} {{ stats.monthly_currency }}{% endif %} + + + pro Monat{% if stats.mixed_currencies %} (gemischte Währungen){% endif %} + +
+
+ Laufen bald aus + + {{ stats.expiring_soon }} + + ≤ 30 Tage +
+
+ Abgelaufen + + {{ stats.expired }} + + Vertrag beendet +
+
+ {% endif %} + + {% if servers %} +
+ + + + + + + + + + + + + {% for s in servers %} + + + + + + + + + {% endfor %} + +
NameProviderTypeLocationIPv4Kosten
+
+
+ + {{ s.name }} + + {% if s.hostname %} +
{{ s.hostname }}
+ {% endif %} +
+ {% if s.is_expired %} + + abgelaufen + + {% elif s.is_expiring_soon %} + + läuft bald aus + + {% endif %} +
+
{{ s.provider }} + + {{ s.type }} + + + {% if s.location %}{{ s.location }}{% else %}{% endif %} + + {% if s.ipv4 %}{{ s.ipv4 }}{% else %}{% endif %} + + {% if s.price %} + {{ "%.2f"|format(s.price) }} {{ s.currency }} + / {{ "Monat" if s.billing_period == "monthly" else "Jahr" }} + {% else %} + + {% endif %} +
+
+ {% else %} +
+ Noch keine Server erfasst. Leg den ersten mit „Server anlegen“ oben rechts an. +
+ {% endif %} +
+{% endblock %} diff --git a/fleetledger/app/templates/servers_map.html b/fleetledger/app/templates/servers_map.html new file mode 100644 index 0000000..0d670ec --- /dev/null +++ b/fleetledger/app/templates/servers_map.html @@ -0,0 +1,230 @@ +{% extends "base.html" %} + +{% block extra_head %} + + +{% endblock %} + +{% block content %} +
+
+
+

Server-Karte

+

+ Zeigt alle nicht archivierten Server mit gesetzter Location auf einer Karte. + Für grobe Übersicht reicht die Stadt – keine exakten GPS-Daten notwendig. +

+
+
+ + + + +
+ +

+ Hinweis: Marker werden anhand der Location-Namen grob auf der Weltkarte platziert. Mehrere Server + in derselben Stadt werden leicht versetzt dargestellt, damit sie klickbar bleiben. +

+
+{% endblock %} + +{% block extra_scripts %} + + +{% endblock %} diff --git a/fleetledger/app/templates/users_list.html b/fleetledger/app/templates/users_list.html new file mode 100644 index 0000000..35bdaa6 --- /dev/null +++ b/fleetledger/app/templates/users_list.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+

Benutzerverwaltung

+

+ Admins können Benutzer aktivieren/deaktivieren. Der eigene Account kann nicht deaktiviert werden. +

+
+
+ +
+ + + + + + + + + + + + {% for u in users %} + + + + + + + + {% endfor %} + +
BenutzernameE-MailRolleStatusAktion
+ {{ u.username }} + + {% if u.email %}{{ u.email }}{% else %}{% endif %} + + {% if u.is_admin %}Admin{% else %}User{% endif %} + + {% if u.is_active %} + + aktiv + + {% else %} + + deaktiviert + + {% endif %} + + {% if u.id != current_user.id %} +
+ + +
+ {% else %} + Eigener Account + {% endif %} +
+
+
+{% endblock %} diff --git a/fleetledger/app/utils.py b/fleetledger/app/utils.py new file mode 100644 index 0000000..774905e --- /dev/null +++ b/fleetledger/app/utils.py @@ -0,0 +1,66 @@ +import os +import secrets +from typing import Optional + +from cryptography.fernet import Fernet, InvalidToken +from starlette.requests import Request + +# Optional symmetric encryption for management passwords +_ENC_KEY = os.getenv("ENCRYPTION_KEY") +_f = None + +if _ENC_KEY: + # If the key is already a valid Fernet key string, use it directly. + # Otherwise you could do more validation/derivation, but for now we assume a proper key. + _f = Fernet( + _ENC_KEY.encode() if not _ENC_KEY.strip().endswith("=") else _ENC_KEY + ) + + +def can_encrypt() -> bool: + """Return True if an encryption key has been configured.""" + return _f is not None + + +def encrypt_secret(plaintext: str) -> Optional[str]: + """Encrypt a secret string using Fernet, if configured.""" + if not plaintext or not _f: + return None + token = _f.encrypt(plaintext.encode("utf-8")) + return token.decode("utf-8") + + +def decrypt_secret(token: str) -> Optional[str]: + """Decrypt a Fernet-encrypted token, returning a string or None.""" + if not token or not _f: + return None + try: + plaintext = _f.decrypt(token.encode("utf-8")) + return plaintext.decode("utf-8") + except (InvalidToken, ValueError): + return None + + +# ----- CSRF helpers ----- +_CSRF_SESSION_KEY = "csrf_token" + + +def ensure_csrf_token(request: Request) -> str: + """ + Ensure the current session has a CSRF token and return it. + """ + token = request.session.get(_CSRF_SESSION_KEY) + if not token: + token = secrets.token_urlsafe(32) + request.session[_CSRF_SESSION_KEY] = token + return token + + +def validate_csrf(request: Request, token: str) -> bool: + """ + Compare provided token with the one stored in the session. + """ + stored = request.session.get(_CSRF_SESSION_KEY) + if not stored or not token: + return False + return secrets.compare_digest(str(stored), str(token)) diff --git a/fleetledger/docker-compose.yml b/fleetledger/docker-compose.yml new file mode 100644 index 0000000..ab53976 --- /dev/null +++ b/fleetledger/docker-compose.yml @@ -0,0 +1,21 @@ +version: "3.9" +services: + fleetledger: + build: . + container_name: fleetledger + ports: + - "8000:8000" + environment: + - DATABASE_PATH=/data/fleetledger.db + # SESSION_SECRET must be provided (e.g. via .env) and should be long and random + - SESSION_SECRET=${SESSION_SECRET:?Set SESSION_SECRET in your environment} + # Set to 0 only for local HTTP testing; keep secure (default) in production + - SESSION_COOKIE_SECURE=${SESSION_COOKIE_SECURE:-1} + # Optional: encryption key for management passwords (Fernet key) + # - ENCRYPTION_KEY=your_fernet_key_here + volumes: + - fleetledger_data:/data + restart: unless-stopped + +volumes: + fleetledger_data: diff --git a/fleetledger/requirements.txt b/fleetledger/requirements.txt new file mode 100644 index 0000000..ba49b04 --- /dev/null +++ b/fleetledger/requirements.txt @@ -0,0 +1,7 @@ +fastapi +uvicorn[standard] +sqlmodel +jinja2 +python-multipart +cryptography +passlib[bcrypt]