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..2a85868 --- /dev/null +++ b/fleetledger/README.md @@ -0,0 +1,82 @@ +# 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) + +### 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 %} +
+ Globale Übersicht über alle nicht archivierten Server und Benutzer. +
+| Provider | +Server | +Monatskosten | +Laufen bald aus | +Abgelaufen | +
|---|---|---|---|---|
| {{ 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. + | +||||
Melde dich an, um deine Server zu verwalten.
+ + {% if error %} ++ Erstelle einen neuen Account. Der erste Benutzer wird automatisch Admin. +
+ + {% if error %} +{{ 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 %} ++ Hinweis: Es werden nur Key-Namen gespeichert, keine privaten SSH-Schlüssel. +
++ Trage alle relevanten Infos zu deinem VPS / Server ein. +
+ + {% if not can_encrypt %} ++ Übersicht aller gemieteten VPS, dedizierten Server und Storage-Systeme. +
+| Name | +Provider | +Type | +Location | +IPv4 | +Kosten | +
|---|---|---|---|---|---|
|
+
+
+
+
+ {{ s.name }}
+
+ {% if s.hostname %}
+
+ {% if s.is_expired %}
+
+ abgelaufen
+
+ {% elif s.is_expiring_soon %}
+
+ läuft bald aus
+
+ {% endif %}
+ {{ s.hostname }}
+ {% 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 %} + | +
+ 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. +
++ Admins können Benutzer aktivieren/deaktivieren. Der eigene Account kann nicht deaktiviert werden. +
+| Benutzername | +Rolle | +Status | +Aktion | +|
|---|---|---|---|---|
| + {{ 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 %} + | +