import os, sqlite3, datetime, secrets, hashlib, subprocess, json from fastapi import FastAPI, Request, Depends, HTTPException, Form from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse, JSONResponse, PlainTextResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from jose import jwt from jinja2 import Environment, FileSystemLoader, select_autoescape from config import * from models import get_db, init_db, hash_password, verify_password from auth import create_access_token, get_current_user from signing import build_leaf_cert from cryptography.hazmat.primitives import serialization app = FastAPI(title="CertAuth Key Vault") app.mount("/static", StaticFiles(directory="/opt/certauth/api/static"), name="static") def get_user_from_cookie(request: Request): token = request.cookies.get("token") if not token: return None try: return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) except: return None jinja_env = Environment( loader=FileSystemLoader("/opt/certauth/api/templates"), autoescape=select_autoescape(["html"]) ) @app.on_event("startup") def startup(): init_db() try: with open(ROOT_CA_PATH) as f: root = f.read() with open(INT_CA_PATH) as f: inter = f.read() with open(CA_CHAIN_PATH, "w") as f: f.write(inter + "\n" + root) except: pass def render(name, ctx): return HTMLResponse(jinja_env.get_template(name).render(**ctx)) class LoginRequest(BaseModel): username: str password: str @app.post("/api/token") async def login(req: LoginRequest): conn = get_db() row = conn.execute("SELECT * FROM users WHERE username = ?", (req.username,)).fetchone() conn.close() if not row or not verify_password(req.password, row["password_hash"]): raise HTTPException(401, "Invalid credentials") token = create_access_token({"sub": req.username}) return {"access_token": token, "token_type": "bearer"} @app.get("/api/me") async def me(user: str = Depends(get_current_user)): return {"username": user} @app.get("/api/domains") async def list_domains(user: str = Depends(get_current_user)): conn = get_db() rows = conn.execute("SELECT * FROM domains ORDER BY created_at DESC").fetchall() conn.close() return [dict(r) for r in rows] @app.post("/api/domains") async def create_domain(name: str = Form(...), description: str = Form(""), user: str = Depends(get_current_user)): conn = get_db() cur = conn.cursor() cur.execute("INSERT INTO domains (name, description, created_by) VALUES (?,?,?)", (name, description, 1)) conn.commit() conn.close() return {"status": "ok"} @app.get("/api/certs") async def list_certs(user: str = Depends(get_current_user)): conn = get_db() rows = conn.execute("SELECT c.*, d.name as domain_name FROM certificates c LEFT JOIN domains d ON c.domain_id = d.id ORDER BY c.created_at DESC").fetchall() conn.close() return [dict(r) for r in rows] @app.post("/api/certs/request") async def request_cert(cn: str = Form(...), sans: str = Form(""), days: int = Form(365), domain_id: int = Form(0), user: str = Depends(get_current_user)): conn = get_db() cur = conn.cursor() cur.execute("INSERT INTO certificates (domain_id, subject, san, status, created_by) VALUES (?,?,?,?,?)", (domain_id, cn, sans, "pending", 1)) conn.commit() cid = cur.lastrowid conn.close() return {"status": "ok", "id": cid} @app.post("/api/certs/{cert_id}/sign") async def sign_cert(cert_id: int, user: str = Depends(get_current_user)): conn = get_db() row = conn.execute("SELECT * FROM certificates WHERE id = ?", (cert_id,)).fetchone() if not row or row["status"] != "pending": conn.close() raise HTTPException(400, "Not found or already signed") conn.close() result, err = build_leaf_cert(row["subject"], row["san"], 365) if err: raise HTTPException(500, f"Signing failed: {err}") cf = f"/etc/ssl/ca/issued/cert-{result['serial']}.crt" kf = f"/etc/ssl/ca/issued/cert-{result['serial']}.key" open(cf, "w").write(result["cert_pem"]) open(kf, "w").write(result["key_pem"]) os.chmod(cf, 0o640); os.chmod(kf, 0o600) conn = get_db() conn.execute("UPDATE certificates SET status=?, serial=?, cert_path=?, issued_at=?, expires_at=? WHERE id=?", ("issued", result["serial"], cf, datetime.datetime.now().isoformat(), result["expires_at"], cert_id)) conn.commit(); conn.close() return {"status": "ok", "serial": result["serial"]} @app.get("/api/certs/{cert_id}/pem") async def download_pem(cert_id: int, request: Request = None): """Download cert + chain as bundled PEM.""" user = get_user_from_cookie(request) if not user: raise HTTPException(401, "Login required") conn = get_db() row = conn.execute("SELECT * FROM certificates WHERE id = ?", (cert_id,)).fetchone() conn.close() if not row or row["status"] != "issued": raise HTTPException(404) pem_path = f"/var/lib/certauth/tmp/cert-{row['serial']}.pem" with open(row["cert_path"]) as f: cert_pem = f.read() with open(CA_CHAIN_PATH) as f: chain_pem = f.read() with open(pem_path, "w") as f: f.write(cert_pem.rstrip() + "\n" + chain_pem) return FileResponse(pem_path, media_type="application/x-pem-file", filename=f"cert-{row['serial']}.pem") @app.get("/api/certs/{cert_id}/pfx") async def download_pfx(cert_id: int, password: str = "certauth", request: Request = None): """Download cert + key + chain as PKCS12/PFX.""" user = get_user_from_cookie(request) if not user: raise HTTPException(401, "Login required") conn = get_db() row = conn.execute("SELECT * FROM certificates WHERE id = ?", (cert_id,)).fetchone() conn.close() if not row or row["status"] != "issued": raise HTTPException(404) kf = row["cert_path"].replace(".crt", ".key") if not os.path.exists(kf): raise HTTPException(404) from cryptography.hazmat.primitives.serialization import pkcs12, BestAvailableEncryption from cryptography import x509 with open(row["cert_path"], "rb") as f: leaf = x509.load_pem_x509_certificate(f.read()) with open(kf, "rb") as f: key = serialization.load_pem_private_key(f.read(), password=None) chain_certs = [] with open(CA_CHAIN_PATH, "rb") as f: for cert_pem in f.read().split(b"-----END CERTIFICATE-----"): cert_pem = cert_pem.strip() if cert_pem: chain_certs.append(x509.load_pem_x509_certificate(cert_pem + b"\n-----END CERTIFICATE-----")) pfx_data = pkcs12.serialize_key_and_certificates( name=row["subject"].encode(), key=key, cert=leaf, cas=chain_certs or None, encryption_algorithm=BestAvailableEncryption(password.encode()) ) pfx_path = f"/var/lib/certauth/tmp/cert-{row['serial']}.pfx" with open(pfx_path, "wb") as f: f.write(pfx_data) return FileResponse(pfx_path, media_type="application/x-pkcs12", filename=f"cert-{row['serial']}.pfx") @app.get("/api/health") async def health(): return {"status": "ok"} @app.get("/api/ca-chain") async def ca_chain(): return FileResponse(CA_CHAIN_PATH, filename="ca-chain.crt") def get_user_from_cookie(request: Request): token = request.cookies.get("token") if not token: return None try: return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) except: return None @app.get("/", response_class=HTMLResponse) async def dashboard(request: Request): user = get_user_from_cookie(request) if not user: return RedirectResponse("/login", status_code=302) conn = get_db() certs = conn.execute("SELECT c.*, d.name as domain_name FROM certificates c LEFT JOIN domains d ON c.domain_id = d.id ORDER BY c.created_at DESC LIMIT 20").fetchall() domains = conn.execute("SELECT * FROM domains").fetchall() p = conn.execute("SELECT COUNT(*) as c FROM certificates WHERE status = ?", ("pending",)).fetchone()["c"] i = conn.execute("SELECT COUNT(*) as c FROM certificates WHERE status = ?", ("issued",)).fetchone()["c"] conn.close() return render("dashboard.html", {"request": request, "user": user, "certs": [dict(r) for r in certs], "domains": [dict(r) for r in domains], "pending": p, "issued": i}) @app.get("/login", response_class=HTMLResponse) async def login_page(request: Request): return render("login.html", {"request": request, "error": None}) @app.post("/login") async def login_post(username: str = Form(...), password: str = Form(...)): conn = get_db() row = conn.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone() conn.close() if not row or not verify_password(password, row["password_hash"]): return render("login.html", {"request": None, "error": "Invalid credentials"}) token = create_access_token({"sub": username}) resp = RedirectResponse("/", status_code=302) resp.set_cookie("token", token, httponly=True, samesite="lax", path="/") return resp @app.post("/api/certs/{cert_id}/sign/web") async def sign_cert_web(cert_id: int, request: Request = None): user = get_user_from_cookie(request) if not user: return RedirectResponse("/login", status_code=302) conn = get_db() row = conn.execute("SELECT * FROM certificates WHERE id = ?", (cert_id,)).fetchone() if not row or row["status"] != "pending": conn.close() raise HTTPException(400, "Not found or already issued") conn.close() try: result, err = build_leaf_cert(row["subject"], row["san"], 365) if err: return HTMLResponse(f'Issue failed: {err}') cf = f"/etc/ssl/ca/issued/cert-{result['serial']}.crt" kf = f"/etc/ssl/ca/issued/cert-{result['serial']}.key" open(cf, "w").write(result["cert_pem"]) open(kf, "w").write(result["key_pem"]) os.chmod(cf, 0o640) os.chmod(kf, 0o600) conn2 = get_db() conn2.execute("UPDATE certificates SET status=?, serial=?, cert_path=?, issued_at=?, expires_at=? WHERE id=?", ("issued", result["serial"], cf, datetime.datetime.now().isoformat(), result["expires_at"], cert_id)) conn2.commit() conn2.close() return HTMLResponse(f'Issued! PEM | PFX | Refresh') except Exception as ex: return HTMLResponse(f'Issue failed: {str(ex)}') @app.get("/logout") async def logout(): resp = RedirectResponse("/login", status_code=302) resp.delete_cookie("token", path="/") return resp # --- Web API (cookie auth) --- @app.post("/api/domains/web") async def create_domain_web(name: str = Form(...), description: str = Form(""), request: Request = None): user = get_user_from_cookie(request) if not user: return RedirectResponse("/login", status_code=302) conn = get_db() cur = conn.cursor() cur.execute("INSERT INTO domains (name, description, created_by) VALUES (?,?,?)", (name, description, 1)) conn.commit() conn.close() return HTMLResponse('Domain registered! Refresh') @app.post("/api/certs/web/request") async def request_cert_web(cn: str = Form(...), sans: str = Form(""), days: int = Form(365), domain_id: int = Form(0), request: Request = None): user = get_user_from_cookie(request) if not user: return RedirectResponse("/login", status_code=302) conn = get_db() cur = conn.cursor() # Look up domain by CN if domain_id not provided if domain_id == 0: cur.execute("SELECT id FROM domains WHERE name=?", (cn,)) row = cur.fetchone() domain_id = row[0] if row else None cur.execute("INSERT INTO certificates (domain_id, subject, san, status, created_by) VALUES (?,?,?,?,?)", (domain_id, cn, sans, "pending", 1)) conn.commit() conn.close() return HTMLResponse('Certificate requested! Click Issue below. Refresh') @app.get("/domains", response_class=HTMLResponse) async def domains_page(request: Request): user = get_user_from_cookie(request) if not user: return RedirectResponse("/login", status_code=302) conn = get_db() rows = conn.execute("SELECT * FROM domains ORDER BY created_at DESC").fetchall() conn.close() return render("domains.html", {"request": request, "user": user, "domains": [dict(r) for r in rows]}) @app.get("/certs", response_class=HTMLResponse) async def certs_page(request: Request): user = get_user_from_cookie(request) if not user: return RedirectResponse("/login", status_code=302) conn = get_db() rows = conn.execute("SELECT c.*, d.name as domain_name FROM certificates c LEFT JOIN domains d ON c.domain_id = d.id ORDER BY c.created_at DESC").fetchall() domains = conn.execute("SELECT * FROM domains").fetchall() conn.close() return render("certs.html", {"request": request, "user": user, "certs": [dict(r) for r in rows], "domains": [dict(r) for r in domains]}) @app.get("/history", response_class=HTMLResponse) async def history_page(request: Request): user = get_user_from_cookie(request) if not user: return RedirectResponse("/login", status_code=302) conn = get_db() rows = conn.execute("SELECT c.*, d.name as domain_name FROM certificates c LEFT JOIN domains d ON c.domain_id = d.id ORDER BY c.created_at DESC").fetchall() conn.close() return render("history.html", {"request": request, "user": user, "certs": [dict(r) for r in rows]}) @app.get("/setup", response_class=HTMLResponse) async def setup_page(request: Request): user = get_user_from_cookie(request) if not user: return RedirectResponse("/login", status_code=302) return render("setup.html", {"request": request, "user": user}) @app.get("/setup.sh") async def setup_sh(): """One-liner bash setup script for Linux/macOS.""" script = r'''#!/bin/bash set -e # CertAuth CA Chain Installer # Usage: curl -sL http:///setup.sh | bash # curl -sL http:///setup.sh | sudo bash # Auto-detect CertAuth server IP DETECTED_IP="" if [[ -n "$1" ]]; then DETECTED_IP="$1" elif [[ -n "$CERTAUTH_IP" ]]; then DETECTED_IP="$CERTAUTH_IP" else # Try Linux hostname -I first DETECTED_IP=$(hostname -I 2>/dev/null | awk '{print $1}') || true # Fallback: ip route (Linux) [[ -z "$DETECTED_IP" ]] && DETECTED_IP=$(ip route get 1 2>/dev/null | awk '{print $7}' | head -1) || true # Fallback: ifconfig (macOS/BSD) [[ -z "$DETECTED_IP" ]] && DETECTED_IP=$(ifconfig 2>/dev/null | grep -E '^\s+(inet )' | awk '{print $2}' | grep -v '127.0.0.1' | head -1) || true # Fallback: networksetup (macOS only) [[ -z "$DETECTED_IP" ]] && DETECTED_IP=$(networksetup -getinfo $(networksetup -listallhardwareports 2>/dev/null | awk '/Hardware Port:/ {getline; gsub(/^[ \t]+/, ""); print}') 2>/dev/null | grep 'IP address:' | awk '{print $3}') || true fi # Prompt if auto-detection failed if [[ -z "$DETECTED_IP" ]]; then read -r -p "Enter CertAuth server IP (e.g., 192.168.8.248): " DETECTED_IP fi CHAIN_URL="http://$DETECTED_IP/api/ca-chain" echo "Downloading CA chain..." curl -sLk "$CHAIN_URL" -o /tmp/ca-chain.crt || { echo "Failed to download CA chain from $CHAIN_URL"; exit 1; } # Detect OS and install if [[ -f /etc/os-release ]]; then . /etc/os-release if [[ "$ID" == "debian" || "$ID" == "ubuntu" || "$ID" == "linuxmint" ]]; then sudo cp /tmp/ca-chain.crt /usr/local/share/ca-certificates/certauth.crt sudo update-ca-certificates echo "✅ CA chain installed (Debian/Ubuntu)" elif [[ "$ID" == "centos" || "$ID" == "rhel" || "$ID" == "fedora" ]]; then # Install to system trust store (curl, openssl, etc.) sudo cp /tmp/ca-chain.crt /etc/pki/ca-trust/source/anchors/certauth.crt sudo update-ca-trust # Install root CA to NSS database (Firefox, Thunderbird, etc.) # The root CA is the self-signed cert (second cert in chain) if command -v certutil &>/dev/null; then sudo certutil -D -n "CertAuth Root CA" -d sql:/etc/pki/nssdb/ 2>/dev/null || true python3 -c " import re, subprocess with open('/tmp/ca-chain.crt') as f: content = f.read() certs = re.findall(r'-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----', content, re.DOTALL) # Find the self-signed root cert (subject == issuer) for cert in certs: subj = subprocess.run(['openssl', 'x509', '-noout', '-subject'], input=cert, capture_output=True, text=True).stdout iss = subprocess.run(['openssl', 'x509', '-noout', '-issuer'], input=cert, capture_output=True, text=True).stdout if subj.replace('subject=', '') == iss.replace('issuer=', ''): with open('/tmp/certauth-root.crt', 'w') as rf: rf.write(cert + '\n') break " 2>/dev/null if [[ -f /tmp/certauth-root.crt ]]; then sudo certutil -A -n "CertAuth Root CA" -t "CT,Cu,Tu" -d sql:/etc/pki/nssdb/ -i /tmp/certauth-root.crt 2>/dev/null echo "✅ Root CA imported to NSS database" rm -f /tmp/certauth-root.crt fi fi echo "✅ CA chain installed (RHEL/CentOS/Fedora)" elif [[ "$ID" == "arch" ]]; then sudo cp /tmp/ca-chain.crt /etc/ca-certificates/trust-source/anchors/certauth.crt sudo update-ca-trust echo "✅ CA chain installed (Arch)" elif [[ "$ID" == "alpine" ]]; then sudo cp /tmp/ca-chain.crt /usr/local/share/ca-certificates/certauth.crt sudo update-ca-certificates echo "✅ CA chain installed (Alpine)" else echo "❌ Unsupported Linux distribution: $ID" echo " Download /tmp/ca-chain.crt and install manually" exit 1 fi elif [[ "$(uname)" == "Darwin" ]]; then sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain /tmp/ca-chain.crt echo "✅ CA chain installed (macOS)" else echo "❌ Unsupported OS: $(uname -s)" echo " Download /tmp/ca-chain.crt and install manually" exit 1 fi # Verify VERIFY_URL="${CHAIN_URL%/api/ca-chain}" if curl -sL "$VERIFY_URL" &>/dev/null; then echo "🌐 Server at $VERIFY_URL is reachable" else echo "⚠️ Server at $VERIFY_URL is not reachable (expected if not on same network)" fi rm -f /tmp/ca-chain.crt echo "Done!" ''' return PlainTextResponse(script, media_type="text/x-shellscript") @app.get("/setup.ps1") async def setup_ps1(): """PowerShell setup script for Windows.""" script = r'''# CertAuth CA Chain Installer for Windows # Usage: iex (New-Object Net.WebClient).DownloadString("http:///setup.ps1") # iwr http:///setup.ps1 -UseBasicParsing | iex param([string]$CertAuthIP = "") if (-not $CertAuthIP) { # Try to detect from environment or prompt $CertAuthIP = Read-Host "Enter CertAuth server IP (e.g., 192.168.8.248)" } $ChainUrl = "http://$CertAuthIP/api/ca-chain" $ChainPath = "$env:TEMP\ca-chain.crt" Write-Host "Downloading CA chain from $ChainUrl ..." -ForegroundColor Cyan try { (New-Object Net.WebClient).DownloadFile($ChainUrl, $ChainPath) } catch { Write-Host "Failed to download CA chain: $_" -ForegroundColor Red exit 1 } # Install to Local Machine Trusted Root store Write-Host "Installing to Trusted Root Certification Authorities..." -ForegroundColor Cyan try { $store = New-Object System.Security.Cryptography.X509Certificates.X509Store( [System.Security.Cryptography.X509Certificates.StoreName]::Root, [System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine) $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($ChainPath) $store.Add($cert) $store.Close() Write-Host "CA chain installed successfully!" -ForegroundColor Green } catch { Write-Host "Failed to install: $_" -ForegroundColor Red Write-Host "Run as Administrator and try again." -ForegroundColor Yellow exit 1 } # Verify try { $response = Invoke-WebRequest -Uri "http://$CertAuthIP/api/health" -TimeoutSec 3 -ErrorAction Stop Write-Host "Server at http://$CertAuthIP is reachable." -ForegroundColor Green } catch { Write-Host "Server at http://$CertAuthIP is not reachable (expected if not on same network)." -ForegroundColor Yellow } Remove-Item $ChainPath -Force -ErrorAction SilentlyContinue Write-Host "Done!" -ForegroundColor Green ''' return PlainTextResponse(script, media_type="text/plain")