Compare commits

...

4 Commits

Author SHA1 Message Date
Logan 9fdcad1c46 deploy via Gitea CI registry; provision GCP infra with Terraform
- Terraform: e2-micro VM (us-east1-b, free tier), static IP, SSH/web
  firewall rules, IAM bindings for Firestore + GCS; imports existing
  drb-calls bucket and c2-server Firestore database into state
- Gitea CI: build c2-core, discord-bot, frontend images and push to
  git.vpn.cusano.net registry; SSH deploy pulls pre-built images (no
  build on VM)
- Ansible: first-time setup only — git clone, env files from vault,
  Caddyfile, docker login + compose pull + up; no rsync or on-VM builds
- docker-compose: add image: ${REGISTRY}/name:latest alongside build:
  so local dev and CI registry both work
- gitignore: add Terraform state, lock, tfvars, ansible secrets
2026-06-22 02:31:28 -04:00
Logan 33700448bf add Terraform + Ansible infrastructure for GCP deployment
Provisions e2-micro VM (us-east1-b, free tier) with static IP, SSH and
web firewall rules, Docker + Caddy startup script, and IAM bindings for
Firestore and GCS access via ADC. Imports existing drb-calls bucket and
c2-server Firestore database into state. Ansible roles handle first-time
setup (swap, docker group) and all subsequent deploys via rsync + docker
compose, with secrets managed via Ansible Vault. DNS stays on AWS Route 53.
2026-06-22 02:03:36 -04:00
Logan 4295bdf4d2 Merge remote-tracking branch 'origin/main' into build-infrastructure 2026-06-21 13:51:58 -04:00
Logan 18d96193ab Security fixes
auth.py

secrets.compare_digest replaces == for service key comparison (timing-safe)
Added require_service_key — bot-only endpoints (trip/event join/leave)
Added require_service_key_or_admin — node commands/config (bot via service key OR dashboard admin via Firebase)
Added _RateLimiter with three shared instances: trip_chat_limiter (20/5min per user), summarize_limiter (5/10min per incident), bootstrap_limiter (2/hr per system)
nodes.py

send_command and assign_system now require require_service_key_or_admin — the Discord bot can still call them via service key, but regular Firebase users are blocked
tokens.py

add_token, flush_tokens, set_preferred_system, delete_token all require require_admin_token
Token masking changed from token[:10] + "…" + token[-4:] to "•••" + token[-4:]
systems.py

All write endpoints (create, update, delete, ai-flags, ten-codes, vocabulary writes, bootstrap) now require require_admin_token
bootstrap_vocabulary also calls bootstrap_limiter.check(system_id)
incidents.py

POST /incidents/summarize (bulk) now requires require_admin_token
POST /incidents/{id}/summarize now calls summarize_limiter.check(incident_id)
trips.py

join_trip, leave_trip, join_event, leave_event require require_service_key — only the Discord bot can set Discord attendee identity
delete_trip, delete_event require require_service_key_or_admin
trip_chat rate-limited per caller UID, history stripped to user/assistant roles only, user message truncated to 2000 chars, Maps query strings capped at 200 chars
upload.py

Rejects files larger than settings.upload_max_bytes (default 100MB) with 413
storage.py

_safe_audio_filename() derives GCS object name from call_id + allowlisted extension, completely ignoring the client-supplied filename
config.py

Added upload_max_bytes: int = 100 * 1024 * 1024
Both Dockerfiles — python:3.14-slim → python:3.12-slim
2026-06-21 13:40:08 -04:00
30 changed files with 763 additions and 83 deletions
+58 -15
View File
@@ -1,16 +1,61 @@
name: Deploy
name: Build & Deploy
on:
push:
branches: [main]
env:
SERVER_IP: ${{ secrets.SERVER_IP }}
SSH_USER: drb
# REGISTRY secret = "git.vpn.cusano.net/logan" (full image prefix)
REGISTRY: ${{ secrets.REGISTRY }}
jobs:
build:
name: Build & push images
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
registry: git.vpn.cusano.net
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.BUILD_TOKEN }}
- name: Build & push c2-core
uses: docker/build-push-action@v5
with:
context: ./drb-c2-core
push: true
tags: |
${{ env.REGISTRY }}/c2-core:latest
${{ env.REGISTRY }}/c2-core:${{ gitea.sha }}
- name: Build & push discord-bot
uses: docker/build-push-action@v5
with:
context: ./drb-server-discord-bot
push: true
tags: |
${{ env.REGISTRY }}/discord-bot:latest
${{ env.REGISTRY }}/discord-bot:${{ gitea.sha }}
- name: Build & push frontend
uses: docker/build-push-action@v5
with:
context: ./drb-frontend
push: true
tags: |
${{ env.REGISTRY }}/frontend:latest
${{ env.REGISTRY }}/frontend:${{ gitea.sha }}
deploy:
name: Deploy to VM
needs: build
runs-on: ubuntu-latest
steps:
@@ -21,26 +66,24 @@ jobs:
- name: Deploy
run: |
ssh -o StrictHostKeyChecking=no -i /tmp/deploy_key \
${{ env.SSH_USER }}@${{ env.SERVER_IP }} << 'ENDSSH'
ssh -o StrictHostKeyChecking=no \
-o HostKeyAlgorithms=ssh-ed25519,rsa-sha2-256,rsa-sha2-512 \
-i /tmp/deploy_key \
drb@${{ secrets.SERVER_IP }} << 'ENDSSH'
set -e
cd /opt/drb
# Pull latest code
# Update compose files + mosquitto config
git pull origin main
# Rebuild and restart changed services
docker compose up -d --build --remove-orphans
# Reload Caddy if Caddyfile changed
sudo systemctl reload caddy
# Clean up old images
# Pull pre-built images and restart (no build on the VM)
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --remove-orphans
docker image prune -f
ENDSSH
- name: Verify health
- name: Health check
run: |
sleep 15
sleep 20
curl -f https://api.${{ secrets.DRB_DOMAIN }}/health || \
(echo "Health check failed" && exit 1)
+12
View File
@@ -5,6 +5,18 @@ drb-server-discord-bot/.env
drb-frontend/.env
drb-c2-core/gcp-key.json
# Terraform
infra/.terraform/
infra/terraform.tfstate
infra/terraform.tfstate.backup
infra/terraform.tfstate.*.backup
infra/.terraform.lock.hcl
infra/terraform.tfvars
infra/tf.log
infra/ansible/inventory.ini
infra/ansible/group_vars/all.yml
infra/ansible/vault.yml
# Python
__pycache__/
*.py[cod]
+3 -2
View File
@@ -17,17 +17,17 @@ services:
- mosquitto_data:/mosquitto/data
c2-core:
image: ${REGISTRY}/c2-core:${TAG:-latest}
build: ./drb-c2-core
restart: unless-stopped
ports:
- "8888:8000"
env_file: ./drb-c2-core/.env
volumes:
- ./drb-c2-core/gcp-key.json:/app/gcp-key.json:ro
depends_on:
- mosquitto
discord-bot:
image: ${REGISTRY}/discord-bot:${TAG:-latest}
build: ./drb-server-discord-bot
restart: unless-stopped
env_file: ./drb-server-discord-bot/.env
@@ -35,6 +35,7 @@ services:
- c2-core
frontend:
image: ${REGISTRY}/frontend:${TAG:-latest}
build: ./drb-frontend
restart: unless-stopped
ports:
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.14-slim
FROM python:3.12-slim
WORKDIR /app
+5 -1
View File
@@ -51,7 +51,11 @@ class Settings(BaseSettings):
# Internal service key — allows server-side services (discord bot) to call C2 without Firebase
service_key: Optional[str] = None
# CORS — comma-separated list of allowed origins, or "*" for all
# Upload size limit — reject audio files larger than this (bytes). Default 100 MB.
upload_max_bytes: int = 100 * 1024 * 1024
# CORS — set to your frontend origin(s) in production, e.g. ["https://app.example.com"]
# Defaults to "*" for local development only.
cors_origins: list[str] = ["*"]
class Config:
+73 -1
View File
@@ -1,3 +1,6 @@
import secrets
import time
from collections import defaultdict, deque
from typing import Optional
from fastapi import HTTPException, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
@@ -26,7 +29,7 @@ async def require_service_or_firebase_token(
if not credentials:
raise HTTPException(status_code=401, detail="Missing authorization token")
token = credentials.credentials
if settings.service_key and token == settings.service_key:
if settings.service_key and secrets.compare_digest(token, settings.service_key):
return {"service": True}
try:
return firebase_auth.verify_id_token(token)
@@ -42,3 +45,72 @@ async def require_admin_token(
if not decoded.get("admin"):
raise HTTPException(status_code=403, detail="Admin access required")
return decoded
async def require_service_key(
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
) -> dict:
"""Accept only the internal service key — used for bot-only endpoints."""
if not credentials:
raise HTTPException(status_code=401, detail="Missing authorization token")
if not settings.service_key:
raise HTTPException(status_code=503, detail="Service key not configured")
if not secrets.compare_digest(credentials.credentials, settings.service_key):
raise HTTPException(status_code=403, detail="Service key required")
return {"service": True}
async def require_service_key_or_admin(
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
) -> dict:
"""Accept either the internal service key or a Firebase admin token.
Used for endpoints that the Discord bot (service key) and dashboard admins
(Firebase + admin claim) both need to call, but regular Firebase users must not.
"""
if not credentials:
raise HTTPException(status_code=401, detail="Missing authorization token")
token = credentials.credentials
if settings.service_key and secrets.compare_digest(token, settings.service_key):
return {"service": True}
try:
decoded = firebase_auth.verify_id_token(token)
except Exception:
raise HTTPException(status_code=401, detail="Invalid or expired token")
if not decoded.get("admin"):
raise HTTPException(status_code=403, detail="Admin access required")
return decoded
# ---------------------------------------------------------------------------
# Simple in-memory sliding-window rate limiter
# ---------------------------------------------------------------------------
# Not persistent across restarts; good enough for a single-instance deployment.
# Key format is caller-defined (e.g. "{uid}:{endpoint}").
class _RateLimiter:
def __init__(self, max_calls: int, window_seconds: int):
self.max_calls = max_calls
self.window = window_seconds
self._log: dict[str, deque] = defaultdict(deque)
def check(self, key: str) -> None:
now = time.monotonic()
q = self._log[key]
while q and now - q[0] > self.window:
q.popleft()
if len(q) >= self.max_calls:
raise HTTPException(
status_code=429,
detail="Rate limit exceeded. Please wait before trying again.",
)
q.append(now)
# Shared limiter instances
# trip chat: 20 requests per user per 5 minutes
trip_chat_limiter = _RateLimiter(max_calls=20, window_seconds=300)
# per-incident summarize: 5 per incident per 10 minutes
summarize_limiter = _RateLimiter(max_calls=5, window_seconds=600)
# vocabulary bootstrap: 2 per system per hour
bootstrap_limiter = _RateLimiter(max_calls=2, window_seconds=3600)
+17 -2
View File
@@ -5,7 +5,21 @@ from app.config import settings
from app.internal.logger import logger
async def upload_audio(data: bytes, filename: str) -> Optional[str]:
def _safe_audio_filename(filename: str, call_id: str) -> str:
"""Return a safe GCS object name derived from the call_id.
We ignore the client-supplied filename entirely and derive the name from the
call_id (which we control) to prevent path traversal via crafted filenames.
The original extension is preserved only if it's a known audio type.
"""
import os
ext = os.path.splitext(filename)[-1].lower() if filename else ""
if ext not in (".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"):
ext = ".mp3"
return f"{call_id}{ext}"
async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optional[str]:
"""Upload audio bytes to GCS and return a signed URL, or None if disabled."""
if not settings.gcs_bucket:
logger.info("GCS_BUCKET not configured — skipping audio upload.")
@@ -21,7 +35,8 @@ async def upload_audio(data: bytes, filename: str) -> Optional[str]:
client = storage.Client()
signing_creds = None
bucket = client.bucket(settings.gcs_bucket)
blob = bucket.blob(f"calls/{filename}")
safe_name = _safe_audio_filename(filename, call_id)
blob = bucket.blob(f"calls/{safe_name}")
blob.upload_from_string(data, content_type="audio/mpeg")
if signing_creds:
return blob.generate_signed_url(
+12 -3
View File
@@ -4,7 +4,7 @@ from typing import Optional
from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends
from app.models import IncidentCreate, IncidentUpdate
from app.internal import firestore as fstore
from app.internal.auth import require_admin_token
from app.internal.auth import require_admin_token, require_service_or_firebase_token, summarize_limiter
router = APIRouter(prefix="/incidents", tags=["incidents"])
@@ -20,7 +20,10 @@ async def list_incidents(status: Optional[str] = None, type: Optional[str] = Non
@router.post("/summarize")
async def summarize_all_stale(background_tasks: BackgroundTasks):
async def summarize_all_stale(
background_tasks: BackgroundTasks,
_: dict = Depends(require_admin_token),
):
"""Immediately run the summarizer pass on all stale incidents (don't wait for the next interval)."""
from app.internal.summarizer import _run_summary_pass
background_tasks.add_task(_run_summary_pass)
@@ -76,12 +79,18 @@ async def delete_incident(incident_id: str, _: dict = Depends(require_admin_toke
@router.post("/{incident_id}/summarize")
async def summarize_incident(incident_id: str, background_tasks: BackgroundTasks):
async def summarize_incident(
incident_id: str,
background_tasks: BackgroundTasks,
decoded: dict = Depends(require_service_or_firebase_token),
):
"""Immediately run the summarizer for a specific incident."""
from app.internal.summarizer import _summarize_incident
inc = await fstore.doc_get("incidents", incident_id)
if not inc:
raise HTTPException(404, f"Incident '{incident_id}' not found.")
# Rate limit by incident ID to prevent repeated expensive LLM calls
summarize_limiter.check(incident_id)
background_tasks.add_task(_summarize_incident, inc)
return {"ok": True, "incident_id": incident_id}
+7 -2
View File
@@ -4,7 +4,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query
from app.models import CommandPayload
from app.internal import firestore as fstore
from app.internal.mqtt_handler import mqtt_handler
from app.internal.auth import require_admin_token
from app.internal.auth import require_admin_token, require_service_key_or_admin
from app.routers.tokens import assign_token, release_token
router = APIRouter(prefix="/nodes", tags=["nodes"])
@@ -55,7 +55,11 @@ async def reject_node(node_id: str, _: dict = Depends(require_admin_token)):
@router.post("/{node_id}/command")
async def send_command(node_id: str, cmd: CommandPayload):
async def send_command(
node_id: str,
cmd: CommandPayload,
_: dict = Depends(require_service_key_or_admin),
):
node = await fstore.doc_get("nodes", node_id)
if not node:
raise HTTPException(404, f"Node '{node_id}' not found.")
@@ -108,6 +112,7 @@ async def assign_system(
system_id: str,
hardware_preset: str = Query("rtl-sdr-v3"),
ppm_override: Optional[float] = Query(None),
_: dict = Depends(require_service_key_or_admin),
):
"""
Assign a system to a node. Fetches the system config from Firestore
+40 -11
View File
@@ -1,9 +1,10 @@
import uuid
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Dict, Optional
from app.models import SystemCreate, SystemRecord
from app.internal import firestore as fstore
from app.internal.auth import require_admin_token, bootstrap_limiter
router = APIRouter(prefix="/systems", tags=["systems"])
@@ -35,7 +36,7 @@ async def get_system(system_id: str):
@router.post("", status_code=201)
async def create_system(body: SystemCreate):
async def create_system(body: SystemCreate, _: dict = Depends(require_admin_token)):
system_id = str(uuid.uuid4())
doc = SystemRecord(system_id=system_id, **body.model_dump())
await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False)
@@ -43,7 +44,7 @@ async def create_system(body: SystemCreate):
@router.put("/{system_id}")
async def update_system(system_id: str, body: SystemCreate):
async def update_system(system_id: str, body: SystemCreate, _: dict = Depends(require_admin_token)):
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
@@ -52,7 +53,7 @@ async def update_system(system_id: str, body: SystemCreate):
@router.delete("/{system_id}", status_code=204)
async def delete_system(system_id: str):
async def delete_system(system_id: str, _: dict = Depends(require_admin_token)):
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
@@ -62,7 +63,11 @@ async def delete_system(system_id: str):
# ── Per-system AI flag overrides ──────────────────────────────────────────────
@router.put("/{system_id}/ai-flags")
async def update_system_ai_flags(system_id: str, body: AiFlagsBody):
async def update_system_ai_flags(
system_id: str,
body: AiFlagsBody,
_: dict = Depends(require_admin_token),
):
"""
Set per-system AI flag overrides. Only fields included in the body are
written; omitted fields remain unchanged (or absent, meaning inherit global).
@@ -95,7 +100,11 @@ async def get_ten_codes(system_id: str):
@router.put("/{system_id}/ten-codes")
async def update_ten_codes(system_id: str, body: TenCodesBody):
async def update_ten_codes(
system_id: str,
body: TenCodesBody,
_: dict = Depends(require_admin_token),
):
"""Replace the ten-code dictionary for a system."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
@@ -117,18 +126,26 @@ async def get_vocabulary(system_id: str):
@router.post("/{system_id}/vocabulary/bootstrap", status_code=202)
async def bootstrap_vocabulary(system_id: str):
async def bootstrap_vocabulary(
system_id: str,
decoded: dict = Depends(require_admin_token),
):
"""Trigger a one-shot GPT-4o bootstrap to seed the vocabulary from local knowledge."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
raise HTTPException(404, f"System '{system_id}' not found.")
bootstrap_limiter.check(system_id)
from app.internal.vocabulary_learner import bootstrap_system_vocabulary
terms = await bootstrap_system_vocabulary(system_id)
return {"added": len(terms), "terms": terms}
@router.post("/{system_id}/vocabulary/terms")
async def add_vocabulary_term(system_id: str, body: VocabularyTermBody):
async def add_vocabulary_term(
system_id: str,
body: VocabularyTermBody,
_: dict = Depends(require_admin_token),
):
"""Manually add a term to the approved vocabulary."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
@@ -139,7 +156,11 @@ async def add_vocabulary_term(system_id: str, body: VocabularyTermBody):
@router.delete("/{system_id}/vocabulary/terms")
async def remove_vocabulary_term(system_id: str, body: VocabularyTermBody):
async def remove_vocabulary_term(
system_id: str,
body: VocabularyTermBody,
_: dict = Depends(require_admin_token),
):
"""Remove a term from the approved vocabulary."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
@@ -150,7 +171,11 @@ async def remove_vocabulary_term(system_id: str, body: VocabularyTermBody):
@router.post("/{system_id}/vocabulary/pending/approve")
async def approve_pending(system_id: str, body: VocabularyTermBody):
async def approve_pending(
system_id: str,
body: VocabularyTermBody,
_: dict = Depends(require_admin_token),
):
"""Move a pending induction suggestion into the approved vocabulary."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
@@ -161,7 +186,11 @@ async def approve_pending(system_id: str, body: VocabularyTermBody):
@router.post("/{system_id}/vocabulary/pending/dismiss")
async def dismiss_pending(system_id: str, body: VocabularyTermBody):
async def dismiss_pending(
system_id: str,
body: VocabularyTermBody,
_: dict = Depends(require_admin_token),
):
"""Dismiss a pending induction suggestion without adding it."""
existing = await fstore.doc_get("systems", system_id)
if not existing:
+11 -6
View File
@@ -1,9 +1,10 @@
import uuid
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional
from datetime import datetime, timezone
from app.internal import firestore as fstore
from app.internal.auth import require_admin_token
router = APIRouter(prefix="/tokens", tags=["tokens"])
@@ -22,13 +23,13 @@ async def list_tokens():
"""List all tokens. The actual token string is masked for safety."""
tokens = await fstore.collection_list("bot_tokens")
return [
{**t, "token": t["token"][:10] + "" + t["token"][-4:]}
{**t, "token": "•••" + t["token"][-4:]}
for t in tokens
]
@router.post("", status_code=201)
async def add_token(body: TokenCreate):
async def add_token(body: TokenCreate, _: dict = Depends(require_admin_token)):
token_id = str(uuid.uuid4())
doc = {
"token_id": token_id,
@@ -43,7 +44,7 @@ async def add_token(body: TokenCreate):
@router.post("/flush", status_code=200)
async def flush_tokens():
async def flush_tokens(_: dict = Depends(require_admin_token)):
"""Force-release all in-use tokens (admin utility — use when tokens get orphaned)."""
def _find():
from app.internal.firestore import db
@@ -61,7 +62,11 @@ async def flush_tokens():
@router.put("/{token_id}/prefer/{system_id}", status_code=200)
async def set_preferred_system(token_id: str, system_id: str):
async def set_preferred_system(
token_id: str,
system_id: str,
_: dict = Depends(require_admin_token),
):
"""
Mark this token as the preferred bot for a system.
When a discord_join is issued for any node in that system, this token
@@ -89,7 +94,7 @@ async def set_preferred_system(token_id: str, system_id: str):
@router.delete("/{token_id}", status_code=204)
async def delete_token(token_id: str):
async def delete_token(token_id: str, _: dict = Depends(require_admin_token)):
existing = await fstore.doc_get("bot_tokens", token_id)
if not existing:
raise HTTPException(404, "Token not found.")
+64 -11
View File
@@ -3,12 +3,18 @@ import json
import httpx
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from app.models import TripCreate, TripEventCreate, AttendeeAction
from app.internal import firestore as fstore
from app.config import settings
from app.internal.logger import logger
from app.internal.auth import (
require_service_or_firebase_token,
require_service_key,
require_service_key_or_admin,
trip_chat_limiter,
)
router = APIRouter(prefix="/trips", tags=["trips"])
@@ -180,7 +186,7 @@ async def get_trip(trip_id: str):
@router.delete("/{trip_id}")
async def delete_trip(trip_id: str):
async def delete_trip(trip_id: str, _: dict = Depends(require_service_key_or_admin)):
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
@@ -192,7 +198,12 @@ async def delete_trip(trip_id: str):
@router.post("/{trip_id}/join")
async def join_trip(trip_id: str, body: AttendeeAction):
async def join_trip(
trip_id: str,
body: AttendeeAction,
_: dict = Depends(require_service_key),
):
"""Join a trip as an attendee. Only the Discord bot (service key) may call this."""
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
@@ -203,7 +214,12 @@ async def join_trip(trip_id: str, body: AttendeeAction):
@router.post("/{trip_id}/leave")
async def leave_trip(trip_id: str, body: AttendeeAction):
async def leave_trip(
trip_id: str,
body: AttendeeAction,
_: dict = Depends(require_service_key),
):
"""Leave a trip. Only the Discord bot (service key) may call this."""
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
@@ -253,7 +269,11 @@ async def create_event(trip_id: str, body: TripEventCreate):
@router.delete("/{trip_id}/events/{event_id}")
async def delete_event(trip_id: str, event_id: str):
async def delete_event(
trip_id: str,
event_id: str,
_: dict = Depends(require_service_key_or_admin),
):
event = await fstore.doc_get("trip_events", event_id)
if not event or event.get("trip_id") != trip_id:
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
@@ -262,7 +282,13 @@ async def delete_event(trip_id: str, event_id: str):
@router.post("/{trip_id}/events/{event_id}/join")
async def join_event(trip_id: str, event_id: str, body: AttendeeAction):
async def join_event(
trip_id: str,
event_id: str,
body: AttendeeAction,
_: dict = Depends(require_service_key),
):
"""Join an event. Only the Discord bot (service key) may call this."""
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
@@ -278,7 +304,13 @@ async def join_event(trip_id: str, event_id: str, body: AttendeeAction):
@router.post("/{trip_id}/events/{event_id}/leave")
async def leave_event(trip_id: str, event_id: str, body: AttendeeAction):
async def leave_event(
trip_id: str,
event_id: str,
body: AttendeeAction,
_: dict = Depends(require_service_key),
):
"""Leave an event. Only the Discord bot (service key) may call this."""
event = await fstore.doc_get("trip_events", event_id)
if not event or event.get("trip_id") != trip_id:
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
@@ -293,10 +325,18 @@ async def leave_event(trip_id: str, event_id: str, body: AttendeeAction):
# ---------------------------------------------------------------------------
@router.post("/{trip_id}/chat")
async def trip_chat(trip_id: str, body: ChatRequest):
async def trip_chat(
trip_id: str,
body: ChatRequest,
decoded: dict = Depends(require_service_or_firebase_token),
):
if not settings.openai_api_key:
raise HTTPException(503, "OpenAI not configured.")
# Rate limit by caller identity
caller_key = decoded.get("uid") or ("service" if decoded.get("service") else "unknown")
trip_chat_limiter.check(f"{caller_key}:{trip_id}")
trip = await fstore.doc_get("trips", trip_id)
if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.")
@@ -306,10 +346,20 @@ async def trip_chat(trip_id: str, body: ChatRequest):
from openai import AsyncOpenAI
oai = AsyncOpenAI(api_key=settings.openai_api_key)
# Strip history to only user/assistant roles to prevent prompt injection
safe_history = [
{"role": m.role, "content": m.content}
for m in body.history[-20:]
if m.role in ("user", "assistant")
]
# Truncate message to prevent oversized single requests
user_message = body.message[:2000]
messages: list[dict] = [
{"role": "system", "content": _build_system_prompt(trip, events)},
*[{"role": m.role, "content": m.content} for m in body.history[-20:]],
{"role": "user", "content": body.message},
*safe_history,
{"role": "user", "content": user_message},
]
suggestions: list[dict] = []
@@ -340,7 +390,10 @@ async def trip_chat(trip_id: str, body: ChatRequest):
args = json.loads(tc.function.arguments)
if tc.function.name == "search_places":
results = await _places_search(args.get("query", ""), args.get("near", ""))
# Limit query string lengths before hitting the Maps API
query = str(args.get("query", ""))[:200]
near = str(args.get("near", ""))[:200]
results = await _places_search(query, near)
messages.append({
"role": "tool",
"tool_call_id": tc.id,
+4 -2
View File
@@ -4,6 +4,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.internal.storage import upload_audio
from app.internal import firestore as fstore
from app.internal.logger import logger
from app.config import settings
router = APIRouter(tags=["upload"])
@@ -43,9 +44,10 @@ async def upload_call_audio(
data = await file.read()
if not data:
raise HTTPException(400, "Empty file.")
if len(data) > settings.upload_max_bytes:
raise HTTPException(413, f"File too large (max {settings.upload_max_bytes // (1024*1024)} MB).")
filename = file.filename
audio_url = await upload_audio(data, filename)
audio_url = await upload_audio(data, file.filename or "", call_id=call_id)
if audio_url:
try:
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.14-slim
FROM python:3.12-slim
WORKDIR /app
+40
View File
@@ -0,0 +1,40 @@
.PHONY: tf-init tf-plan tf-apply tf-destroy deploy setup-ansible
ANSIBLE_DIR = ansible
INVENTORY = $(ANSIBLE_DIR)/inventory.ini
# ── Terraform ─────────────────────────────────────────────────────────────────
tf-init:
terraform init
tf-plan:
terraform plan
tf-apply:
terraform apply
@echo ""
@echo "Server IP: $$(terraform output -raw server_ip)"
@echo "Update $(INVENTORY) with this IP, then run: make deploy"
tf-destroy:
@echo "WARNING: This will destroy the VM and all data on it."
@read -p "Type 'yes' to confirm: " confirm && [ "$$confirm" = "yes" ] && terraform destroy
# ── Ansible ───────────────────────────────────────────────────────────────────
# First-time setup: waits for Docker, clones repo, starts stack.
setup:
ansible-playbook -i $(INVENTORY) $(ANSIBLE_DIR)/site.yml --ask-vault-pass
# Update deploy: sync code + restart changed containers. Run this after every push.
deploy:
ansible-playbook -i $(INVENTORY) $(ANSIBLE_DIR)/deploy.yml --ask-vault-pass
# ── Helpers ───────────────────────────────────────────────────────────────────
ip:
@terraform output -raw server_ip
ssh:
ssh drb@$$(terraform output -raw server_ip)
+15
View File
@@ -0,0 +1,15 @@
---
# Lightweight update deploy — runs in ~60s.
# Use this for every code push after the initial site.yml run.
#
# Usage:
# ansible-playbook -i inventory.ini deploy.yml --ask-vault-pass
- name: Deploy DRB update
hosts: drb
become: true
vars_files:
- vault.yml
roles:
- deploy
+9
View File
@@ -0,0 +1,9 @@
# Copy to group_vars/all.yml — safe to commit (no secrets here).
domain: example.com # must match Terraform var.domain
app_dir: /opt/drb
ssh_user: drb
# Path to the local repo root on your machine (used for rsync).
# Trailing slash is intentional — rsync copies contents, not the folder itself.
local_repo_path: "/path/to/Version 5C/Server/"
+8
View File
@@ -0,0 +1,8 @@
# Copy to inventory.ini and replace SERVER_IP with the Terraform output.
# Get it with: cd ../terraform && terraform output server_ip
[drb]
SERVER_IP ansible_user=drb ansible_ssh_private_key_file=~/.ssh/id_ed25519
[drb:vars]
ansible_python_interpreter=/usr/bin/python3
+77
View File
@@ -0,0 +1,77 @@
---
# First-time setup: clone repo, write secrets, pull pre-built images and start stack.
# Images are built and pushed by Gitea CI — this role never builds on the VM.
- name: Clone repo (skipped if already present)
git:
repo: "{{ repo_url }}"
dest: "{{ app_dir }}"
version: main
update: false
become: false
- name: Set ownership of app directory
file:
path: "{{ app_dir }}"
state: directory
owner: "{{ ssh_user }}"
group: "{{ ssh_user }}"
recurse: true
- name: Template top-level .env (docker-compose MQTT creds + registry)
template:
src: root.env.j2
dest: "{{ app_dir }}/.env"
owner: "{{ ssh_user }}"
group: "{{ ssh_user }}"
mode: "0600"
- name: Template c2-core .env
template:
src: c2-core.env.j2
dest: "{{ app_dir }}/drb-c2-core/.env"
owner: "{{ ssh_user }}"
group: "{{ ssh_user }}"
mode: "0600"
- name: Template discord-bot .env
template:
src: discord-bot.env.j2
dest: "{{ app_dir }}/drb-server-discord-bot/.env"
owner: "{{ ssh_user }}"
group: "{{ ssh_user }}"
mode: "0600"
- name: Template frontend .env
template:
src: frontend.env.j2
dest: "{{ app_dir }}/drb-frontend/.env"
owner: "{{ ssh_user }}"
group: "{{ ssh_user }}"
mode: "0600"
- name: Deploy Caddyfile
template:
src: Caddyfile.j2
dest: /etc/caddy/Caddyfile
owner: root
group: root
mode: "0644"
notify: Reload Caddy
- name: Log in to container registry
command: >
docker login {{ vault_registry_host }}
-u {{ vault_registry_user }}
-p {{ vault_registry_token }}
no_log: true
- name: Pull pre-built images and start stack
community.docker.docker_compose_v2:
project_src: "{{ app_dir }}"
files:
- docker-compose.yml
- docker-compose.prod.yml
pull: always
build: never
state: present
@@ -0,0 +1,13 @@
# Managed by Ansible — do not edit manually on the server.
api.{{ domain }} {
reverse_proxy localhost:8888 {
header_up X-Forwarded-For {remote_host}
}
}
app.{{ domain }} {
reverse_proxy localhost:3000 {
header_up X-Forwarded-For {remote_host}
}
}
@@ -0,0 +1,20 @@
# drb-c2-core environment — Managed by Ansible. Do not edit manually.
MQTT_BROKER=mosquitto
MQTT_PORT=1883
MQTT_USER={{ vault_mqtt_c2_user }}
MQTT_PASS={{ vault_mqtt_c2_pass }}
# No GCP_CREDENTIALS_PATH — the VM uses Application Default Credentials
# via the GCE metadata server. The Terraform IAM bindings grant the required roles.
FIRESTORE_DATABASE={{ vault_firestore_database }}
GCS_BUCKET={{ vault_gcs_bucket }}
OPENAI_API_KEY={{ vault_openai_api_key }}
GOOGLE_MAPS_API_KEY={{ vault_google_maps_api_key }}
GEMINI_API_KEY={{ vault_gemini_api_key }}
SERVICE_KEY={{ vault_service_key }}
NODE_API_KEY={{ vault_node_api_key }}
CORS_ORIGINS=["https://app.{{ domain }}"]
@@ -0,0 +1,5 @@
# drb-server-discord-bot environment — Managed by Ansible. Do not edit manually.
DISCORD_TOKEN={{ vault_discord_token }}
C2_URL=http://c2-core:8000
C2_SERVICE_KEY={{ vault_service_key }}
@@ -0,0 +1,11 @@
# drb-frontend environment — Managed by Ansible. Do not edit manually.
NEXT_PUBLIC_C2_URL=https://api.{{ domain }}
NEXT_PUBLIC_FIREBASE_API_KEY={{ vault_firebase_api_key }}
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN={{ vault_firebase_auth_domain }}
NEXT_PUBLIC_FIREBASE_PROJECT_ID={{ vault_firebase_project_id }}
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET={{ vault_firebase_storage_bucket }}
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID={{ vault_firebase_messaging_sender_id }}
NEXT_PUBLIC_FIREBASE_APP_ID={{ vault_firebase_app_id }}
NEXT_PUBLIC_FIRESTORE_DATABASE={{ vault_firestore_database }}
@@ -0,0 +1,10 @@
# Top-level docker-compose environment — MQTT credentials and registry prefix.
# Managed by Ansible. Do not edit manually.
MQTT_C2_USER={{ vault_mqtt_c2_user }}
MQTT_C2_PASS={{ vault_mqtt_c2_pass }}
MQTT_NODE_USER={{ vault_mqtt_node_user }}
MQTT_NODE_PASS={{ vault_mqtt_node_pass }}
# Container registry prefix — docker compose uses this for image: ${REGISTRY}/name:latest
REGISTRY={{ vault_registry }}
+79
View File
@@ -0,0 +1,79 @@
---
# Full first-time setup: waits for the VM's startup.sh to finish installing
# Docker, then deploys the stack. Safe to re-run — all tasks are idempotent.
#
# Usage:
# ansible-playbook -i inventory.ini site.yml --ask-vault-pass
- name: Bootstrap + deploy DRB server
hosts: drb
become: true
vars_files:
- vault.yml
pre_tasks:
- name: Install rsync
apt:
name: rsync
state: present
update_cache: false
- name: Wait for Docker (startup.sh runs async on first boot)
command: docker info
register: _docker
until: _docker.rc == 0
retries: 30
delay: 10
changed_when: false
- name: Create 2 GB swap file
command: fallocate -l 2G /swapfile
args:
creates: /swapfile
- name: Set swap file permissions
file:
path: /swapfile
mode: "0600"
- name: Format swap file
command: mkswap /swapfile
register: _mkswap
changed_when: _mkswap.rc == 0
- name: Enable swap
command: swapon /swapfile
register: _swapon
failed_when: _swapon.rc != 0 and 'already' not in _swapon.stderr
changed_when: _swapon.rc == 0
- name: Persist swap in fstab
lineinfile:
path: /etc/fstab
line: "/swapfile none swap sw 0 0"
state: present
- name: Set swappiness to 10 (use swap only under pressure)
sysctl:
name: vm.swappiness
value: "10"
sysctl_set: true
state: present
reload: true
- name: Add deploy user to docker group
user:
name: "{{ ssh_user }}"
groups: docker
append: true
- name: Create app directory
file:
path: "{{ app_dir }}"
state: directory
owner: "{{ ssh_user }}"
group: "{{ ssh_user }}"
mode: "0755"
roles:
- deploy
+40
View File
@@ -0,0 +1,40 @@
# Template for your Ansible Vault secrets file.
# Copy to vault.yml, fill in values, then encrypt:
# ansible-vault encrypt vault.yml
# Edit later with:
# ansible-vault edit vault.yml
# ── MQTT ─────────────────────────────────────────────────────────────────────
vault_mqtt_c2_user: drb-c2-core
vault_mqtt_c2_pass: "CHANGE_ME"
vault_mqtt_node_user: drb-node
vault_mqtt_node_pass: "CHANGE_ME"
# ── C2 Core ───────────────────────────────────────────────────────────────────
vault_service_key: "" # openssl rand -hex 32
vault_node_api_key: "" # openssl rand -hex 32
vault_openai_api_key: ""
vault_google_maps_api_key: ""
vault_gemini_api_key: ""
vault_gcs_bucket: "your-gcs-bucket-name"
vault_firestore_database: "c2-server"
# ── Gitea Container Registry ──────────────────────────────────────────────────
vault_registry_host: "git.vpn.cusano.net"
vault_registry_user: "logan"
vault_registry_token: "" # Gitea access token with package:write scope
vault_registry: "git.vpn.cusano.net/logan" # full image prefix
# ── Discord Bot ───────────────────────────────────────────────────────────────
vault_discord_token: ""
# ── Frontend (Firebase) ───────────────────────────────────────────────────────
vault_firebase_api_key: ""
vault_firebase_auth_domain: ""
vault_firebase_project_id: ""
vault_firebase_storage_bucket: ""
vault_firebase_messaging_sender_id: ""
vault_firebase_app_id: ""
# No GCP key needed — the VM uses Application Default Credentials via the
# GCE metadata server. Terraform grants the required IAM roles at apply time.
+71 -20
View File
@@ -8,10 +8,11 @@ terraform {
}
# Store state in GCS — create the bucket manually once before first apply
backend "gcs" {
bucket = "drb-tf-state"
prefix = "drb/state"
}
# Uncomment once GCS bucket permissions are confirmed working.
# backend "gcs" {
# bucket = "drb-tf-state"
# prefix = "drb/state"
# }
}
provider "google" {
@@ -19,6 +20,10 @@ provider "google" {
region = var.region
}
# Pull live project metadata (number, name) without hardcoding them.
data "google_project" "current" {}
# ---------------------------------------------------------------------------
# Static external IP
# ---------------------------------------------------------------------------
@@ -87,8 +92,8 @@ resource "google_compute_instance" "drb_server" {
boot_disk {
initialize_params {
image = "debian-cloud/debian-12"
size = 30 # GB — enough for Docker images + mosquitto data
type = "pd-balanced"
size = 30 # GB — free tier covers 30GB pd-standard on e2-micro
type = "pd-standard"
}
}
@@ -106,7 +111,8 @@ resource "google_compute_instance" "drb_server" {
# Startup script runs once on first boot to install Docker + Caddy
metadata_startup_script = file("${path.module}/startup.sh")
# Allow the VM to pull from Artifact Registry using its service account
# The default compute service account with cloud-platform scope gives the VM
# full access to GCS and Firestore in the same project — no key file needed.
service_account {
scopes = ["cloud-platform"]
}
@@ -118,21 +124,66 @@ resource "google_compute_instance" "drb_server" {
}
# ---------------------------------------------------------------------------
# Cloud DNS records
# IAM — grant the VM's default compute SA access to Firestore and GCS.
# Since Firebase/GCS already live in the same project, no key file is needed —
# the VM authenticates via the metadata server (ADC).
# ---------------------------------------------------------------------------
resource "google_dns_record_set" "app" {
name = "app.${var.domain}."
type = "A"
ttl = 300
managed_zone = var.dns_zone_name
rrdatas = [google_compute_address.drb.address]
locals {
compute_sa = "serviceAccount:${data.google_project.current.number}-compute@developer.gserviceaccount.com"
}
resource "google_dns_record_set" "api" {
name = "api.${var.domain}."
type = "A"
ttl = 300
managed_zone = var.dns_zone_name
rrdatas = [google_compute_address.drb.address]
resource "google_project_iam_member" "drb_firestore" {
project = var.project_id
role = "roles/datastore.user"
member = local.compute_sa
}
resource "google_project_iam_member" "drb_gcs" {
project = var.project_id
role = "roles/storage.objectAdmin"
member = local.compute_sa
}
# ---------------------------------------------------------------------------
# Firestore database — import existing, manages schema/settings going forward
# ---------------------------------------------------------------------------
import {
id = "projects/${var.project_id}/databases/${var.firestore_database}"
to = google_firestore_database.c2
}
resource "google_firestore_database" "c2" {
project = var.project_id
name = var.firestore_database
location_id = var.firestore_location
type = "FIRESTORE_NATIVE"
# Prevent accidental deletion of the live database
deletion_policy = "DELETE"
}
# ---------------------------------------------------------------------------
# GCS bucket — audio recordings. Import existing bucket.
# ---------------------------------------------------------------------------
import {
id = var.audio_bucket_name
to = google_storage_bucket.audio
}
resource "google_storage_bucket" "audio" {
project = var.project_id
name = var.audio_bucket_name
location = var.audio_bucket_location
uniform_bucket_level_access = true
}
# ---------------------------------------------------------------------------
# DNS — managed in AWS Route 53 (cusano.net is there).
# After terraform apply, add these A records in Route 53:
# app.drb.cusano.net → server_ip output
# api.drb.cusano.net → server_ip output
# Or use a single wildcard: *.drb.cusano.net → server_ip
# ---------------------------------------------------------------------------
+10
View File
@@ -10,3 +10,13 @@ output "app_url" {
output "api_url" {
value = "https://api.${var.domain}"
}
output "project_number" {
value = data.google_project.current.number
description = "GCP project number (useful for service account references)"
}
output "ssh_command" {
value = "ssh ${var.ssh_user}@${google_compute_address.drb.address}"
description = "SSH command to reach the server (should rarely be needed)"
}
+24
View File
@@ -0,0 +1,24 @@
# Copy to terraform.tfvars and fill in values.
# terraform.tfvars is gitignored — never commit it.
project_id = "your-gcp-project-id" # gcloud config get-value project
region = "us-central1"
zone = "us-central1-a"
domain = "drb.cusano.net" # DNS is on AWS Route 53 — add A records manually after apply
machine_type = "e2-standard-2" # 2 vCPU / 8 GB — adjust if needed
ssh_user = "drb"
ssh_public_key = "ssh-ed25519 AAAA... user@host" # cat ~/.ssh/id_ed25519.pub
# Your IP + any CI runner IPs that need SSH access
allowed_ssh_cidrs = ["YOUR_IP/32"]
# Existing GCS bucket for audio recordings (bucket must already exist — imported into state)
audio_bucket_name = "your-audio-bucket-name"
audio_bucket_location = "US-CENTRAL1" # must match existing bucket location exactly — check GCP console
# Existing Firestore database ID and location (imported into state)
firestore_database = "c2-server"
firestore_location = "nam5" # nam5 = us-central, eur3 = europe, us-east1 = us-east
+23 -5
View File
@@ -20,11 +20,6 @@ variable "domain" {
type = string
}
variable "dns_zone_name" {
description = "Cloud DNS managed zone name"
type = string
}
variable "machine_type" {
description = "Compute Engine machine type"
type = string
@@ -46,3 +41,26 @@ variable "allowed_ssh_cidrs" {
description = "CIDR ranges allowed to SSH to the VM (your IP + Gitea runner)"
type = list(string)
}
variable "audio_bucket_name" {
description = "Existing GCS bucket name for call audio recordings"
type = string
}
variable "audio_bucket_location" {
description = "GCS bucket location — must match the existing bucket's location exactly"
type = string
default = "US-CENTRAL1"
}
variable "firestore_database" {
description = "Firestore database ID (e.g. c2-server)"
type = string
default = "c2-server"
}
variable "firestore_location" {
description = "Firestore multi-region location (nam5 = us-central, eur3 = europe)"
type = string
default = "nam5"
}