diff --git a/.gitignore b/.gitignore index 3f5609d..24f0cd2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,12 @@ /frps/frps.toml /.remediation-backup/ /up.sh + +# Dev environment (mirror of prod ignores; keep secrets/data/volumes out of git) +/dev/web/ +/dev/.dev-secrets.env +/dev/frps/frps.toml +/dev/supabase/volumes/ +/dev/redis/data/ +/dev/stripe-stub/.env +/dev/bandwidth-worker/.env diff --git a/caddy/Caddyfile b/caddy/Caddyfile index ba59a26..77c683f 100644 --- a/caddy/Caddyfile +++ b/caddy/Caddyfile @@ -68,3 +68,41 @@ api.linumiq.net { header Strict-Transport-Security "max-age=31536000; includeSubDomains" reverse_proxy frps:7080 } + +# ============================================================================ +# DEV environment (served by this same shared Caddy instance). +# Dev upstreams live on the external "dev_edge" network; Caddy is dual-homed on +# both "edge" (prod) and "dev_edge" (dev). On-demand TLS for these hosts is +# authorized by the global ask endpoint, which recognises the dev hostnames. +# ============================================================================ + +# Dev dashboard (Next.js, dev build) +app-dev.linumiq.net { + tls { + on_demand + } + import security_headers + reverse_proxy web-dev:3000 +} + +# Dev Supabase API (dev Kong) +api-dev.linumiq.net { + tls { + on_demand + } + import security_headers + @blocked_webhooks path /functions/v1/auth-webhook* /functions/v1/stripe-webhook* + respond @blocked_webhooks 403 + reverse_proxy supabase-dev-kong:8000 +} + +# Dev wildcard tunnel subdomains -> dev frps vhost HTTP. More specific than +# *.linumiq.net, so dev tunnels match here. Only HSTS injected (HA sets its own +# framing/CSP headers). +*.dev.linumiq.net { + tls { + on_demand + } + header Strict-Transport-Security "max-age=31536000; includeSubDomains" + reverse_proxy frps-dev:7080 +} diff --git a/caddy/docker-compose.yml b/caddy/docker-compose.yml index 0592307..acb6f27 100644 --- a/caddy/docker-compose.yml +++ b/caddy/docker-compose.yml @@ -15,6 +15,9 @@ services: - ./config:/config networks: - edge + - dev_edge networks: edge: external: true + dev_edge: + external: true diff --git a/dev/README.md b/dev/README.md new file mode 100644 index 0000000..ded46ca --- /dev/null +++ b/dev/README.md @@ -0,0 +1,57 @@ +# Dev environment (`/docker/dev`) + +A near-1:1 clone of the production remote-access stack, isolated on its own +Docker network (`dev_edge`) and fronted by the **same shared Caddy** instance as +production. Dev is **manual-start** (not started on boot) to save resources. + +## Hostnames + +| Purpose | Production | Dev | +| -------------- | --------------------- | ------------------------- | +| Dashboard | `app.linumiq.net` | `app-dev.linumiq.net` | +| Supabase API | `api.linumiq.net` | `api-dev.linumiq.net` | +| Tunnels | `*.linumiq.net` | `*.dev.linumiq.net` | +| Tunnel ingress | `linumiq.net:7000` | `linumiq.net:7001` | + +## Topology + +- All dev services run on the external `dev_edge` network with `*-dev` + container names and their own internal `supabase-dev_default` network. +- The shared Caddy is dual-homed on `edge` (prod) and `dev_edge` (dev) and + routes the `*-dev` / `*.dev` hostnames to the dev upstreams. +- On-demand TLS for every hostname is authorized by Caddy's single global + `ask` endpoint (the prod `check-subdomain` edge function). It returns 200 for + `app-dev`/`api-dev`, and for `*.dev.linumiq.net` it delegates to the dev + authorizer (`supabase-dev-edge-functions:9000`), which checks the dev + `tunnels` table. To reach it, the prod `functions` container is also attached + to `dev_edge`. +- Dev frps publishes **7001** (tunnel ingress); its dashboard/API (7500) and all + Supabase/web ports stay internal. + +## Secrets & data (gitignored, never committed) + +- `*/.env`, `supabase/.env`, `redis/.env`, `stripe-stub/.env`, + `bandwidth-worker/.env` +- `frps/frps.toml` (frps dashboard password) +- `.dev-secrets.env` (generated record of all dev secrets) +- `supabase/volumes/` (edge functions, incl. dev `check-subdomain`) +- `web/` (its own git worktree on the `dev` branch of the web-app repo) +- data dirs (`redis/data`, `supabase/volumes/db/data`, ...) + +Dev secrets are independent of production and are generated fresh. + +## Start / stop + +```sh +/docker/dev/dev.sh up # build + start the whole dev stack +/docker/dev/dev.sh down # stop the whole dev stack +/docker/dev/dev.sh ps # status of dev containers +``` + +The dev database starts empty; apply migrations once after first `up`: + +```sh +for m in /docker/dev/supabase/migrations/000*.sql; do + docker exec -i supabase-dev-db psql -v ON_ERROR_STOP=1 -U postgres -d postgres < "$m" +done +``` diff --git a/dev/bandwidth-worker/Dockerfile b/dev/bandwidth-worker/Dockerfile new file mode 100644 index 0000000..9a132ee --- /dev/null +++ b/dev/bandwidth-worker/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY worker.py . + +CMD ["python", "-u", "worker.py"] diff --git a/dev/bandwidth-worker/docker-compose.yml b/dev/bandwidth-worker/docker-compose.yml new file mode 100644 index 0000000..637697e --- /dev/null +++ b/dev/bandwidth-worker/docker-compose.yml @@ -0,0 +1,16 @@ +name: bandwidth-worker-dev +services: + bandwidth-worker: + build: . + image: bandwidth-worker-dev:1.0.0 + container_name: bandwidth-worker-dev + restart: unless-stopped + security_opt: + - no-new-privileges:true + env_file: .env + networks: + - dev_edge + +networks: + dev_edge: + external: true diff --git a/dev/bandwidth-worker/requirements.txt b/dev/bandwidth-worker/requirements.txt new file mode 100644 index 0000000..07ece12 --- /dev/null +++ b/dev/bandwidth-worker/requirements.txt @@ -0,0 +1,2 @@ +httpx==0.27.2 +redis==5.1.1 diff --git a/dev/bandwidth-worker/worker.py b/dev/bandwidth-worker/worker.py new file mode 100644 index 0000000..8cde8b7 --- /dev/null +++ b/dev/bandwidth-worker/worker.py @@ -0,0 +1,186 @@ +"""bandwidth-worker: polls frps dashboard, writes deltas to Postgres via PostgREST. + +Also enforces per-tunnel quota: when a tunnel crosses its quota it is marked +inactive and its auth-cache entry is invalidated so the next frps Ping/NewProxy +is rejected, terminating the live session (frps OSS exposes no kill endpoint). +""" + +from __future__ import annotations + +import logging +import os +import sys +import time +from datetime import datetime, timezone +from typing import Any + +import httpx +import redis + +POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL_SECONDS", "60")) +FRPS_API = os.environ.get("FRPS_API_URL", "http://frps:7500/api/proxy/http") +FRPS_USER = os.environ["FRPS_DASHBOARD_USER"] +FRPS_PASS = os.environ["FRPS_DASHBOARD_PASS"] +SUPABASE_URL = os.environ.get("SUPABASE_URL", "http://supabase-kong:8000") +SERVICE_ROLE = os.environ["SUPABASE_SERVICE_ROLE_KEY"] +REDIS_URL = os.environ["REDIS_URL"] + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + stream=sys.stdout, +) +log = logging.getLogger("bandwidth-worker") + +rds = redis.from_url(REDIS_URL, decode_responses=True) +last_seen: dict[str, int] = {} + + +def _headers(extra: dict[str, str] | None = None) -> dict[str, str]: + h = { + "apikey": SERVICE_ROLE, + "authorization": f"Bearer {SERVICE_ROLE}", + "content-type": "application/json", + } + if extra: + h.update(extra) + return h + + +def fetch_proxies(client: httpx.Client) -> list[dict[str, Any]]: + resp = client.get(FRPS_API, auth=(FRPS_USER, FRPS_PASS), timeout=10.0) + resp.raise_for_status() + return resp.json().get("proxies") or [] + + +def previous_total(subdomain: str) -> int: + if subdomain in last_seen: + return last_seen[subdomain] + try: + v = rds.get(f"tunnel:bytes_total:{subdomain}") + if v is not None: + return int(v) + except Exception as e: + log.warning("redis get failed for %s: %s", subdomain, e) + return 0 + + +def persist_total(subdomain: str, total: int) -> None: + last_seen[subdomain] = total + try: + rds.set(f"tunnel:bytes_total:{subdomain}", total) + except Exception as e: + log.warning("redis set failed for %s: %s", subdomain, e) + + +def enforce_quota(client: httpx.Client, subdomain: str, row: dict[str, Any], new_used: int) -> None: + """Deactivate a tunnel that has exhausted its quota and invalidate the + auth cache so the next frps Ping/NewProxy is rejected.""" + quota = int(row.get("quota_bytes") or 0) + is_active = bool(row.get("is_active")) + token = row.get("token") or "" + if quota <= 0 or not is_active or new_used < quota: + return + + upd = client.patch( + f"{SUPABASE_URL}/rest/v1/tunnels?subdomain=eq.{subdomain}", + headers=_headers({"prefer": "return=minimal"}), + json={"is_active": False}, + timeout=10.0, + ) + if upd.status_code >= 300: + log.error("quota deactivate failed %s: %s", upd.status_code, upd.text) + return + + if token: + try: + rds.delete(f"tunnel:token:{token}") + except Exception as e: + log.warning("redis auth-cache invalidate failed for %s: %s", subdomain, e) + + log.warning( + "QUOTA EXCEEDED: subdomain=%s used=%d quota=%d -> deactivated", + subdomain, new_used, quota, + ) + + +def record_delta(client: httpx.Client, subdomain: str, delta: int) -> None: + r = client.post( + f"{SUPABASE_URL}/rest/v1/usage_samples", + headers=_headers({"prefer": "return=minimal"}), + json={"subdomain": subdomain, "bytes_delta": delta}, + timeout=10.0, + ) + if r.status_code >= 300: + log.error("usage_samples insert failed %s: %s", r.status_code, r.text) + return + + cur = client.get( + f"{SUPABASE_URL}/rest/v1/tunnels?select=bytes_used,quota_bytes,is_active,token&subdomain=eq.{subdomain}", + headers=_headers(), + timeout=10.0, + ) + if cur.status_code >= 300: + log.error("tunnels select failed %s: %s", cur.status_code, cur.text) + return + rows = cur.json() + if not rows: + log.warning("tunnel row missing for subdomain=%s; sample retained", subdomain) + return + row = rows[0] + current = int(row.get("bytes_used") or 0) + new_used = current + delta + upd = client.patch( + f"{SUPABASE_URL}/rest/v1/tunnels?subdomain=eq.{subdomain}", + headers=_headers({"prefer": "return=minimal"}), + json={ + "bytes_used": new_used, + "last_seen_at": datetime.now(timezone.utc).isoformat(), + }, + timeout=10.0, + ) + if upd.status_code >= 300: + log.error("tunnels update failed %s: %s", upd.status_code, upd.text) + return + + enforce_quota(client, subdomain, row, new_used) + + +def poll_once(client: httpx.Client) -> None: + proxies = fetch_proxies(client) + log.info("poll: %d proxies", len(proxies)) + for p in proxies: + name = p.get("name") or "" + if not name: + continue + traffic_in = int(p.get("today_traffic_in") or 0) + traffic_out = int(p.get("today_traffic_out") or 0) + total = traffic_in + traffic_out + prev = previous_total(name) + delta = total - prev + if delta < 0: + delta = total # daily counter reset + if delta == 0: + persist_total(name, total) + continue + try: + record_delta(client, name, delta) + persist_total(name, total) + log.info("recorded delta=%d for %s (total=%d)", delta, name, total) + except Exception as e: + log.error("record_delta failed for %s: %s", name, e) + + +def main() -> None: + log.info("bandwidth-worker starting; interval=%ss api=%s", POLL_INTERVAL, FRPS_API) + with httpx.Client() as client: + while True: + try: + poll_once(client) + except Exception as e: + log.error("poll failed: %s", e) + time.sleep(POLL_INTERVAL) + + +if __name__ == "__main__": + main() diff --git a/dev/dev.sh b/dev/dev.sh new file mode 100755 index 0000000..8d5b7d6 --- /dev/null +++ b/dev/dev.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# Manual start/stop helper for the dev stack (/docker/dev). +# Dev is intentionally NOT started on boot; bring it up explicitly when needed. +set -uo pipefail + +DEV_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Start order matters: redis + supabase first (web/worker depend on them). +ORDER=(redis supabase frps stripe-stub bandwidth-worker web) + +ensure_network() { + docker network inspect dev_edge >/dev/null 2>&1 || docker network create dev_edge +} + +cmd="${1:-}" +case "$cmd" in + up) + ensure_network + for d in "${ORDER[@]}"; do + echo "==> up: $d" + if [ -f "$DEV_DIR/$d/Dockerfile" ]; then + (cd "$DEV_DIR/$d" && docker compose up -d --build) + else + (cd "$DEV_DIR/$d" && docker compose up -d) + fi + done + ;; + down) + for ((i=${#ORDER[@]}-1; i>=0; i--)); do + d="${ORDER[$i]}" + echo "==> down: $d" + (cd "$DEV_DIR/$d" && docker compose down) + done + ;; + ps) + docker ps --filter "name=-dev" --format "{{.Names}}\t{{.Status}}" | sort + ;; + *) + echo "usage: $0 {up|down|ps}" >&2 + exit 2 + ;; +esac diff --git a/dev/frps/docker-compose.yml b/dev/frps/docker-compose.yml new file mode 100644 index 0000000..1e6fd0d --- /dev/null +++ b/dev/frps/docker-compose.yml @@ -0,0 +1,21 @@ +name: frps-dev +services: + frps: + image: snowdreamtech/frps:0.65.0 + container_name: frps-dev + restart: unless-stopped + security_opt: + - no-new-privileges:true + ports: + # Dev tunnel ingress (dev frpc clients connect here). Public port 7001. + - "7001:7001" + # Dashboard/API port 7500 unpublished. bandwidth-worker-dev reaches it + # internally via frps-dev:7500 on the dev_edge network. + volumes: + - ./frps.toml:/etc/frp/frps.toml:ro + command: ["frps", "-c", "/etc/frp/frps.toml"] + networks: + - dev_edge +networks: + dev_edge: + external: true diff --git a/dev/redis/docker-compose.yml b/dev/redis/docker-compose.yml new file mode 100644 index 0000000..6963532 --- /dev/null +++ b/dev/redis/docker-compose.yml @@ -0,0 +1,44 @@ +name: redis-dev +services: + redis: + image: redis:7.2-alpine + container_name: redis-dev + restart: unless-stopped + # SECURITY (R4/I7): drop privileges and disable destructive admin commands. + security_opt: + - no-new-privileges:true + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD} + command: + - redis-server + - --requirepass + - ${REDIS_PASSWORD} + - --appendonly + - "yes" + - --appendfsync + - everysec + - --rename-command + - FLUSHALL + - "" + - --rename-command + - FLUSHDB + - "" + - --rename-command + - KEYS + - "" + - --rename-command + - DEBUG + - "" + volumes: + - ./data:/data + healthcheck: + test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" PING | grep -q PONG"] + interval: 10s + timeout: 3s + retries: 5 + networks: + - dev_edge + +networks: + dev_edge: + external: true diff --git a/dev/stripe-stub/.env.template b/dev/stripe-stub/.env.template new file mode 100644 index 0000000..21696a8 --- /dev/null +++ b/dev/stripe-stub/.env.template @@ -0,0 +1,3 @@ +DOMAIN=linumiq.net +EDGE_STRIPE_WEBHOOK_URL=http://supabase-edge-functions:9000/stripe-webhook +STRIPE_STUB_WEBHOOK_SECRET=__SECRET__ diff --git a/dev/stripe-stub/Dockerfile b/dev/stripe-stub/Dockerfile new file mode 100644 index 0000000..c1a99e0 --- /dev/null +++ b/dev/stripe-stub/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app.py . + +EXPOSE 4242 +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "4242"] diff --git a/dev/stripe-stub/app.py b/dev/stripe-stub/app.py new file mode 100644 index 0000000..e731b81 --- /dev/null +++ b/dev/stripe-stub/app.py @@ -0,0 +1,63 @@ +"""stripe-stub: pretends every checkout succeeds and forwards test webhooks. + +Forwarded webhooks are signed with an HMAC-SHA256 signature so the edge +function can reject unsigned/forged/replayed events. +""" + +import hashlib +import hmac +import os +import time +import uuid +from typing import Any + +import httpx +from fastapi import FastAPI, Request + +DOMAIN = os.environ.get("DOMAIN", "linumiq.net") +EDGE_URL = os.environ.get( + "EDGE_STRIPE_WEBHOOK_URL", + "http://supabase-edge-functions:9000/stripe-webhook", +) +WEBHOOK_SECRET = os.environ.get("STRIPE_STUB_WEBHOOK_SECRET", "") + +app = FastAPI() + + +def sign(body: bytes) -> str: + ts = int(time.time()) + mac = hmac.new( + WEBHOOK_SECRET.encode(), + f"{ts}.".encode() + body, + hashlib.sha256, + ).hexdigest() + return f"t={ts},v1={mac}" + + +@app.post("/v1/checkout/sessions") +async def create_session(_request: Request) -> dict[str, str]: + session_id = f"stub_{uuid.uuid4().hex}" + return { + "id": session_id, + "url": f"https://app.{DOMAIN}/billing/success?session={session_id}", + } + + +@app.post("/v1/webhooks/test") +async def forward_test(request: Request) -> Any: + body = await request.body() + headers = { + "content-type": request.headers.get("content-type", "application/json"), + "x-stub-signature": sign(body), + } + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post(EDGE_URL, content=body, headers=headers) + try: + return resp.json() + except Exception: + return {"upstream_status": resp.status_code, "body": resp.text} + + +@app.get("/health") +def health() -> dict[str, str]: + return {"status": "ok"} diff --git a/dev/stripe-stub/docker-compose.yml b/dev/stripe-stub/docker-compose.yml new file mode 100644 index 0000000..e2e1a54 --- /dev/null +++ b/dev/stripe-stub/docker-compose.yml @@ -0,0 +1,21 @@ +name: stripe-stub-dev +services: + stripe-stub: + build: . + image: stripe-stub-dev:1.0.0 + container_name: stripe-stub-dev + restart: unless-stopped + security_opt: + - no-new-privileges:true + env_file: .env + networks: + - dev_edge + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:4242/health').status==200 else 1)"] + interval: 30s + timeout: 5s + retries: 3 + +networks: + dev_edge: + external: true diff --git a/dev/stripe-stub/requirements.txt b/dev/stripe-stub/requirements.txt new file mode 100644 index 0000000..48a7f46 --- /dev/null +++ b/dev/stripe-stub/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.4 +uvicorn[standard]==0.32.0 +httpx==0.27.2 diff --git a/dev/supabase/.env.example b/dev/supabase/.env.example new file mode 100644 index 0000000..fb00d7d --- /dev/null +++ b/dev/supabase/.env.example @@ -0,0 +1,113 @@ +############ +# Secrets +# YOU MUST CHANGE THESE BEFORE GOING INTO PRODUCTION +############ + +POSTGRES_PASSWORD=your-super-secret-and-long-postgres-password +JWT_SECRET=your-super-secret-jwt-token-with-at-least-32-characters-long +ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE +SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q +DASHBOARD_USERNAME=supabase +DASHBOARD_PASSWORD=this_password_is_insecure_and_should_be_updated + +############ +# Database - You can change these to any PostgreSQL database that has logical replication enabled. +############ + +POSTGRES_HOST=db +POSTGRES_DB=postgres +POSTGRES_PORT=5432 +# default user is postgres + +############ +# Supavisor -- Database pooler +############ +POOLER_PROXY_PORT_TRANSACTION=6543 +POOLER_DEFAULT_POOL_SIZE=20 +POOLER_MAX_CLIENT_CONN=100 + + +############ +# API Proxy - Configuration for the Kong Reverse proxy. +############ + +KONG_HTTP_PORT=8000 +KONG_HTTPS_PORT=8443 + + +############ +# API - Configuration for PostgREST. +############ + +PGRST_DB_SCHEMAS=public,storage,graphql_public + + +############ +# Auth - Configuration for the GoTrue authentication server. +############ + +## General +SITE_URL=http://localhost:3000 +ADDITIONAL_REDIRECT_URLS= +JWT_EXPIRY=3600 +DISABLE_SIGNUP=false +API_EXTERNAL_URL=http://localhost:8000 + +## Mailer Config +MAILER_URLPATHS_CONFIRMATION="/auth/v1/verify" +MAILER_URLPATHS_INVITE="/auth/v1/verify" +MAILER_URLPATHS_RECOVERY="/auth/v1/verify" +MAILER_URLPATHS_EMAIL_CHANGE="/auth/v1/verify" + +## Email auth +ENABLE_EMAIL_SIGNUP=true +ENABLE_EMAIL_AUTOCONFIRM=false +SMTP_ADMIN_EMAIL=admin@example.com +SMTP_HOST=supabase-mail +SMTP_PORT=2500 +SMTP_USER=fake_mail_user +SMTP_PASS=fake_mail_password +SMTP_SENDER_NAME=fake_sender +ENABLE_ANONYMOUS_USERS=false + +## Phone auth +ENABLE_PHONE_SIGNUP=true +ENABLE_PHONE_AUTOCONFIRM=true + + +############ +# Studio - Configuration for the Dashboard +############ + +STUDIO_DEFAULT_ORGANIZATION=Default Organization +STUDIO_DEFAULT_PROJECT=Default Project + +STUDIO_PORT=3000 +# replace if you intend to use Studio outside of localhost +SUPABASE_PUBLIC_URL=http://localhost:8000 + +# Enable webp support +IMGPROXY_ENABLE_WEBP_DETECTION=true + +############ +# Functions - Configuration for Functions +############ +# NOTE: VERIFY_JWT applies to all functions. Per-function VERIFY_JWT is not supported yet. +FUNCTIONS_VERIFY_JWT=false + +############ +# Logs - Configuration for Logflare +# Please refer to https://supabase.com/docs/reference/self-hosting-analytics/introduction +############ + +LOGFLARE_LOGGER_BACKEND_API_KEY=your-super-secret-and-long-logflare-key + +# Change vector.toml sinks to reflect this change +LOGFLARE_API_KEY=your-super-secret-and-long-logflare-key + +# Docker socket location - this value will differ depending on your OS +DOCKER_SOCKET_LOCATION=/var/run/docker.sock + +# Google Cloud Project details +GOOGLE_PROJECT_ID=GOOGLE_PROJECT_ID +GOOGLE_PROJECT_NUMBER=GOOGLE_PROJECT_NUMBER diff --git a/dev/supabase/.gitignore b/dev/supabase/.gitignore new file mode 100644 index 0000000..a1e9dc6 --- /dev/null +++ b/dev/supabase/.gitignore @@ -0,0 +1,5 @@ +volumes/db/data +volumes/storage +.env +test.http +docker-compose.override.yml diff --git a/dev/supabase/README.md b/dev/supabase/README.md new file mode 100644 index 0000000..9ab215b --- /dev/null +++ b/dev/supabase/README.md @@ -0,0 +1,3 @@ +# Supabase Docker + +This is a minimal Docker Compose setup for self-hosting Supabase. Follow the steps [here](https://supabase.com/docs/guides/hosting/docker) to get started. diff --git a/dev/supabase/docker-compose.override.yml b/dev/supabase/docker-compose.override.yml new file mode 100644 index 0000000..4321294 --- /dev/null +++ b/dev/supabase/docker-compose.override.yml @@ -0,0 +1,13 @@ +# Dev override: attach kong + edge functions to the external "dev_edge" network +# so the shared Caddy and the dev frps can reach them by DNS name, and set the +# dev tunnel base domain for the on-demand-TLS authorizer + frps auth plugin. +services: + kong: + networks: [default, dev_edge] + functions: + networks: [default, dev_edge] + environment: + TUNNEL_BASE_DOMAIN: dev.linumiq.net +networks: + dev_edge: + external: true diff --git a/dev/supabase/docker-compose.s3.yml b/dev/supabase/docker-compose.s3.yml new file mode 100644 index 0000000..bb8be62 --- /dev/null +++ b/dev/supabase/docker-compose.s3.yml @@ -0,0 +1,96 @@ +version: "3.8" + +services: + + minio: + image: minio/minio + ports: + - '9000:9000' + - '9001:9001' + environment: + MINIO_ROOT_USER: supa-storage + MINIO_ROOT_PASSWORD: secret1234 + command: server --console-address ":9001" /data + healthcheck: + test: [ "CMD", "curl", "-f", "http://minio:9000/minio/health/live" ] + interval: 2s + timeout: 10s + retries: 5 + volumes: + - ./volumes/storage:/data:z + + minio-createbucket: + image: minio/mc + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + /usr/bin/mc alias set supa-minio http://minio:9000 supa-storage secret1234; + /usr/bin/mc mb supa-minio/stub; + exit 0; + " + + storage: + container_name: supabase-storage + image: supabase/storage-api:v0.43.11 + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + rest: + condition: service_started + imgproxy: + condition: service_started + minio: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://localhost:5000/status" + ] + timeout: 5s + interval: 5s + retries: 3 + restart: unless-stopped + environment: + ANON_KEY: ${ANON_KEY} + SERVICE_KEY: ${SERVICE_ROLE_KEY} + POSTGREST_URL: http://rest:3000 + PGRST_JWT_SECRET: ${JWT_SECRET} + DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + FILE_SIZE_LIMIT: 52428800 + STORAGE_BACKEND: s3 + GLOBAL_S3_BUCKET: stub + GLOBAL_S3_ENDPOINT: http://minio:9000 + GLOBAL_S3_PROTOCOL: http + GLOBAL_S3_FORCE_PATH_STYLE: true + AWS_ACCESS_KEY_ID: supa-storage + AWS_SECRET_ACCESS_KEY: secret1234 + AWS_DEFAULT_REGION: stub + FILE_STORAGE_BACKEND_PATH: /var/lib/storage + TENANT_ID: stub + # TODO: https://github.com/supabase/storage-api/issues/55 + REGION: stub + ENABLE_IMAGE_TRANSFORMATION: "true" + IMGPROXY_URL: http://imgproxy:5001 + volumes: + - ./volumes/storage:/var/lib/storage:z + + imgproxy: + container_name: supabase-imgproxy + image: darthsim/imgproxy:v3.8.0 + healthcheck: + test: [ "CMD", "imgproxy", "health" ] + timeout: 5s + interval: 5s + retries: 3 + environment: + IMGPROXY_BIND: ":5001" + IMGPROXY_USE_ETAG: "true" + IMGPROXY_ENABLE_WEBP_DETECTION: ${IMGPROXY_ENABLE_WEBP_DETECTION} diff --git a/dev/supabase/docker-compose.yml b/dev/supabase/docker-compose.yml new file mode 100644 index 0000000..fcd0f26 --- /dev/null +++ b/dev/supabase/docker-compose.yml @@ -0,0 +1,516 @@ +# Usage +# Start: docker compose up +# With helpers: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml up +# Stop: docker compose down +# Destroy: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml down -v --remove-orphans + +name: supabase-dev + +services: + studio: + container_name: supabase-dev-studio + image: supabase/studio:20240923-2e3e90c + restart: unless-stopped + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "require('http').get('http://localhost:3000/api/profile', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})" + ] + timeout: 5s + interval: 5s + retries: 3 + depends_on: + analytics: + condition: service_healthy + environment: + STUDIO_PG_META_URL: http://meta:8080 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + + DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION} + DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT} + + SUPABASE_URL: http://kong:8000 + SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL} + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} + AUTH_JWT_SECRET: ${JWT_SECRET} + + LOGFLARE_API_KEY: ${LOGFLARE_API_KEY} + LOGFLARE_URL: http://analytics:4000 + NEXT_PUBLIC_ENABLE_LOGS: true + # Comment to use Big Query backend for analytics + NEXT_ANALYTICS_BACKEND_PROVIDER: postgres + # Uncomment to use Big Query backend for analytics + # NEXT_ANALYTICS_BACKEND_PROVIDER: bigquery + + kong: + container_name: supabase-dev-kong + image: kong:2.8.1 + restart: unless-stopped + # https://unix.stackexchange.com/a/294837 + entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start' + # SECURITY (R2/F2/I3): host ports unpublished. Kong is reached only via Caddy + # over the internal docker network (api.linumiq.net -> supabase-kong:8000). + depends_on: + analytics: + condition: service_healthy + environment: + KONG_DATABASE: "off" + KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml + # https://github.com/supabase/cli/issues/14 + KONG_DNS_ORDER: LAST,A,CNAME + KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth + KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k + KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} + DASHBOARD_USERNAME: ${DASHBOARD_USERNAME} + DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD} + volumes: + # https://github.com/supabase/supabase/issues/12661 + - ./volumes/api/kong.yml:/home/kong/temp.yml:ro + + auth: + container_name: supabase-dev-auth + image: supabase/gotrue:v2.158.1 + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + analytics: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://localhost:9999/health" + ] + timeout: 5s + interval: 5s + retries: 3 + restart: unless-stopped + environment: + GOTRUE_API_HOST: 0.0.0.0 + GOTRUE_API_PORT: 9999 + API_EXTERNAL_URL: ${API_EXTERNAL_URL} + + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + + GOTRUE_SITE_URL: ${SITE_URL} + GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS} + GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP} + + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_EXP: ${JWT_EXPIRY} + GOTRUE_PASSWORD_MIN_LENGTH: ${GOTRUE_PASSWORD_MIN_LENGTH} + GOTRUE_JWT_SECRET: ${JWT_SECRET} + + GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP} + GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS} + GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM} + # GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: true + # GOTRUE_SMTP_MAX_FREQUENCY: 1s + GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL} + GOTRUE_SMTP_HOST: ${SMTP_HOST} + GOTRUE_SMTP_PORT: ${SMTP_PORT} + GOTRUE_SMTP_USER: ${SMTP_USER} + GOTRUE_SMTP_PASS: ${SMTP_PASS} + GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME} + GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE} + GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION} + GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY} + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE} + + GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP} + GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM} + # Uncomment to enable custom access token hook. Please see: https://supabase.com/docs/guides/auth/auth-hooks for full list of hooks and additional details about custom_access_token_hook + + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: "true" + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI: "pg-functions://postgres/public/custom_access_token_hook" + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS: "" + + # GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_ENABLED: "true" + # GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/mfa_verification_attempt" + + # GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_ENABLED: "true" + # GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/password_verification_attempt" + + # GOTRUE_HOOK_SEND_SMS_ENABLED: "false" + # GOTRUE_HOOK_SEND_SMS_URI: "pg-functions://postgres/public/custom_access_token_hook" + # GOTRUE_HOOK_SEND_SMS_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n" + + # GOTRUE_HOOK_SEND_EMAIL_ENABLED: "false" + # GOTRUE_HOOK_SEND_EMAIL_URI: "http://host.docker.internal:54321/functions/v1/email_sender" + # GOTRUE_HOOK_SEND_EMAIL_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n" + + + + + + rest: + container_name: supabase-dev-rest + image: postgrest/postgrest:v12.2.0 + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + analytics: + condition: service_healthy + restart: unless-stopped + environment: + PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS} + PGRST_DB_ANON_ROLE: anon + PGRST_JWT_SECRET: ${JWT_SECRET} + PGRST_DB_USE_LEGACY_GUCS: "false" + PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET} + PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY} + command: "postgrest" + + realtime: + # This container name looks inconsistent but is correct because realtime constructs tenant id by parsing the subdomain + container_name: realtime-dev.supabase-dev-realtime + image: supabase/realtime:v2.30.34 + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + analytics: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "curl", + "-sSfL", + "--head", + "-o", + "/dev/null", + "-H", + "Authorization: Bearer ${ANON_KEY}", + "http://localhost:4000/api/tenants/realtime-dev/health" + ] + timeout: 5s + interval: 5s + retries: 3 + restart: unless-stopped + environment: + PORT: 4000 + DB_HOST: ${POSTGRES_HOST} + DB_PORT: ${POSTGRES_PORT} + DB_USER: supabase_admin + DB_PASSWORD: ${POSTGRES_PASSWORD} + DB_NAME: ${POSTGRES_DB} + DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' + DB_ENC_KEY: supabaserealtime + API_JWT_SECRET: ${JWT_SECRET} + SECRET_KEY_BASE: UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq + ERL_AFLAGS: -proto_dist inet_tcp + DNS_NODES: "''" + RLIMIT_NOFILE: "10000" + APP_NAME: realtime + SEED_SELF_HOST: true + + # To use S3 backed storage: docker compose -f docker-compose.yml -f docker-compose.s3.yml up + storage: + container_name: supabase-dev-storage + image: supabase/storage-api:v1.10.1 + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + rest: + condition: service_started + imgproxy: + condition: service_started + healthcheck: + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://localhost:5000/status" + ] + timeout: 5s + interval: 5s + retries: 3 + restart: unless-stopped + environment: + ANON_KEY: ${ANON_KEY} + SERVICE_KEY: ${SERVICE_ROLE_KEY} + POSTGREST_URL: http://rest:3000 + PGRST_JWT_SECRET: ${JWT_SECRET} + DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + FILE_SIZE_LIMIT: 52428800 + STORAGE_BACKEND: file + FILE_STORAGE_BACKEND_PATH: /var/lib/storage + TENANT_ID: stub + # TODO: https://github.com/supabase/storage-api/issues/55 + REGION: stub + GLOBAL_S3_BUCKET: stub + ENABLE_IMAGE_TRANSFORMATION: "true" + IMGPROXY_URL: http://imgproxy:5001 + volumes: + - ./volumes/storage:/var/lib/storage:z + + imgproxy: + container_name: supabase-dev-imgproxy + image: darthsim/imgproxy:v3.8.0 + healthcheck: + test: [ "CMD", "imgproxy", "health" ] + timeout: 5s + interval: 5s + retries: 3 + environment: + IMGPROXY_BIND: ":5001" + IMGPROXY_LOCAL_FILESYSTEM_ROOT: / + IMGPROXY_USE_ETAG: "true" + IMGPROXY_ENABLE_WEBP_DETECTION: ${IMGPROXY_ENABLE_WEBP_DETECTION} + volumes: + - ./volumes/storage:/var/lib/storage:z + + meta: + container_name: supabase-dev-meta + image: supabase/postgres-meta:v0.83.2 + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + analytics: + condition: service_healthy + restart: unless-stopped + environment: + PG_META_PORT: 8080 + PG_META_DB_HOST: ${POSTGRES_HOST} + PG_META_DB_PORT: ${POSTGRES_PORT} + PG_META_DB_NAME: ${POSTGRES_DB} + PG_META_DB_USER: supabase_admin + PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD} + + functions: + container_name: supabase-dev-edge-functions + image: supabase/edge-runtime:v1.58.3 + restart: unless-stopped + depends_on: + analytics: + condition: service_healthy + environment: + JWT_SECRET: ${JWT_SECRET} + SUPABASE_URL: http://kong:8000 + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} + SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + # TODO: Allow configuring VERIFY_JWT per function. This PR might help: https://github.com/supabase/cli/pull/786 + VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}" + REDIS_URL: ${REDIS_URL} + STRIPE_STUB_WEBHOOK_SECRET: ${STRIPE_STUB_WEBHOOK_SECRET} + volumes: + - ./volumes/functions:/home/deno/functions:Z + command: + - start + - --main-service + - /home/deno/functions/main + + analytics: + container_name: supabase-dev-analytics + image: supabase/logflare:1.4.0 + healthcheck: + test: [ "CMD", "curl", "http://localhost:4000/health" ] + timeout: 5s + interval: 5s + retries: 10 + restart: unless-stopped + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + # Uncomment to use Big Query backend for analytics + # volumes: + # - type: bind + # source: ${PWD}/gcloud.json + # target: /opt/app/rel/logflare/bin/gcloud.json + # read_only: true + environment: + LOGFLARE_NODE_HOST: 127.0.0.1 + DB_USERNAME: supabase_admin + DB_DATABASE: _supabase + DB_HOSTNAME: ${POSTGRES_HOST} + DB_PORT: ${POSTGRES_PORT} + DB_PASSWORD: ${POSTGRES_PASSWORD} + DB_SCHEMA: _analytics + LOGFLARE_API_KEY: ${LOGFLARE_API_KEY} + LOGFLARE_SINGLE_TENANT: true + LOGFLARE_SUPABASE_MODE: true + LOGFLARE_MIN_CLUSTER_SIZE: 1 + + # Comment variables to use Big Query backend for analytics + POSTGRES_BACKEND_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase + POSTGRES_BACKEND_SCHEMA: _analytics + LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true + # Uncomment to use Big Query backend for analytics + # GOOGLE_PROJECT_ID: ${GOOGLE_PROJECT_ID} + # GOOGLE_PROJECT_NUMBER: ${GOOGLE_PROJECT_NUMBER} + # SECURITY (R2/F2/I3): host port 4000 unpublished. Reached internally as analytics:4000. + + # Comment out everything below this point if you are using an external Postgres database + db: + container_name: supabase-dev-db + image: supabase/postgres:15.1.1.78 + healthcheck: + test: pg_isready -U postgres -h localhost + interval: 5s + timeout: 5s + retries: 10 + depends_on: + vector: + condition: service_healthy + command: + - postgres + - -c + - config_file=/etc/postgresql/postgresql.conf + - -c + - log_min_messages=fatal # prevents Realtime polling queries from appearing in logs + restart: unless-stopped + environment: + POSTGRES_HOST: /var/run/postgresql + PGPORT: ${POSTGRES_PORT} + POSTGRES_PORT: ${POSTGRES_PORT} + PGPASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + PGDATABASE: ${POSTGRES_DB} + POSTGRES_DB: ${POSTGRES_DB} + JWT_SECRET: ${JWT_SECRET} + JWT_EXP: ${JWT_EXPIRY} + volumes: + - ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z + # Must be superuser to create event trigger + - ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z + # Must be superuser to alter reserved role + - ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z + # Initialize the database settings with JWT_SECRET and JWT_EXP + - ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z + # PGDATA directory is persisted between restarts + - ./volumes/db/data:/var/lib/postgresql/data:Z + # Changes required for internal supabase data such as _analytics + - ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:Z + # Changes required for Analytics support + - ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z + # Changes required for Pooler support + - ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z + # Volume to persist pgsodium decryption key between restarts + - ./volumes/db-config:/etc/postgresql-custom + + # SECURITY (R2/I1): read-only Docker API gateway. Vector no longer mounts the + # raw docker.sock (a `:ro` bind on a unix socket does NOT restrict the Docker + # API, so a compromised vector could create privileged containers = host + # escape). This haproxy-based proxy exposes ONLY read endpoints (GET + # containers/events/ping); all POST/exec/create/start are denied. + docker-socket-proxy: + container_name: supabase-dev-docker-socket-proxy + image: tecnativa/docker-socket-proxy:0.3.0 + restart: unless-stopped + environment: + CONTAINERS: 1 + EVENTS: 1 + PING: 1 + INFO: 0 + POST: 0 + EXEC: 0 + IMAGES: 0 + NETWORKS: 0 + VOLUMES: 0 + BUILD: 0 + COMMIT: 0 + CONFIGS: 0 + DISTRIBUTION: 0 + NODES: 0 + PLUGINS: 0 + SECRETS: 0 + SERVICES: 0 + SESSION: 0 + SWARM: 0 + SYSTEM: 0 + TASKS: 0 + AUTH: 0 + volumes: + - ${DOCKER_SOCKET_LOCATION}:/var/run/docker.sock:ro + + vector: + container_name: supabase-dev-vector + image: timberio/vector:0.28.1-alpine + healthcheck: + test: + [ + + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://vector:9001/health" + ] + timeout: 5s + interval: 5s + retries: 3 + depends_on: + docker-socket-proxy: + condition: service_started + volumes: + - ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro + environment: + LOGFLARE_API_KEY: ${LOGFLARE_API_KEY} + command: [ "--config", "etc/vector/vector.yml" ] + + # Update the DATABASE_URL if you are using an external Postgres database + supavisor: + container_name: supabase-dev-pooler + image: supabase/supavisor:1.1.56 + healthcheck: + test: curl -sSfL --head -o /dev/null "http://127.0.0.1:4000/api/health" + interval: 10s + timeout: 5s + retries: 5 + depends_on: + db: + condition: service_healthy + analytics: + condition: service_healthy + command: + - /bin/sh + - -c + - /app/bin/migrate && /app/bin/supavisor eval "$$(cat /etc/pooler/pooler.exs)" && /app/bin/server + restart: unless-stopped + # SECURITY (R2/F1/I5): host ports 5432/6543 unpublished. Postgres/pooler are + # reached only over the internal docker network; no public DB exposure. + environment: + - PORT=4000 + - POSTGRES_PORT=${POSTGRES_PORT} + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - DATABASE_URL=ecto://postgres:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/_supabase + - CLUSTER_POSTGRES=true + # SECURITY (R3): replaced predictable upstream-default supavisor secrets. + - SECRET_KEY_BASE=${SUPAVISOR_SECRET_KEY_BASE} + - VAULT_ENC_KEY=${SUPAVISOR_VAULT_ENC_KEY} + - API_JWT_SECRET=${JWT_SECRET} + - METRICS_JWT_SECRET=${JWT_SECRET} + - REGION=local + - ERL_AFLAGS=-proto_dist inet_tcp + - POOLER_TENANT_ID=${POOLER_TENANT_ID} + - POOLER_DEFAULT_POOL_SIZE=${POOLER_DEFAULT_POOL_SIZE} + - POOLER_MAX_CLIENT_CONN=${POOLER_MAX_CLIENT_CONN} + - POOLER_POOL_MODE=transaction + volumes: + - ./volumes/pooler/pooler.exs:/etc/pooler/pooler.exs:ro diff --git a/dev/supabase/migrations/0001_init.sql b/dev/supabase/migrations/0001_init.sql new file mode 100644 index 0000000..442e9f2 --- /dev/null +++ b/dev/supabase/migrations/0001_init.sql @@ -0,0 +1,128 @@ +-- v1 initial schema for linumiq tunnel platform +-- Phase 2 / Wave A backend + +BEGIN; + +CREATE EXTENSION IF NOT EXISTS citext; + +-- --------------------------------------------------------------------------- +-- Tables +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS public.tunnels ( + user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + subdomain citext UNIQUE NOT NULL + CHECK (subdomain ~ '^[a-z0-9-]{3,32}$' + AND subdomain NOT IN ('app','api','www','admin','auth','mail','static')), + token text UNIQUE NOT NULL, + is_active boolean NOT NULL DEFAULT true, + bytes_used bigint NOT NULL DEFAULT 0, + quota_bytes bigint NOT NULL DEFAULT 1099511627776, + created_at timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz +); + +CREATE TABLE IF NOT EXISTS public.subscriptions ( + user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + plan text NOT NULL DEFAULT 'free', + status text NOT NULL DEFAULT 'active', + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS public.usage_samples ( + id bigserial PRIMARY KEY, + subdomain citext NOT NULL, + ts timestamptz NOT NULL DEFAULT now(), + bytes_delta bigint NOT NULL +); +CREATE INDEX IF NOT EXISTS usage_samples_subdomain_ts_idx + ON public.usage_samples (subdomain, ts DESC); + +CREATE TABLE IF NOT EXISTS public.users_profile ( + user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + email text, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- --------------------------------------------------------------------------- +-- Trigger: auto-create users_profile row on auth.users insert +-- --------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + INSERT INTO public.users_profile (user_id, email) + VALUES (NEW.id, NEW.email) + ON CONFLICT (user_id) DO NOTHING; + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION public.handle_new_user() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.handle_new_user() TO supabase_auth_admin; + +DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users; +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); + +-- --------------------------------------------------------------------------- +-- Row-level security +-- --------------------------------------------------------------------------- + +ALTER TABLE public.tunnels ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.usage_samples ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.users_profile ENABLE ROW LEVEL SECURITY; + +-- tunnels: owner can SELECT and UPDATE. No INSERT/DELETE for users. +DROP POLICY IF EXISTS tunnels_select_own ON public.tunnels; +CREATE POLICY tunnels_select_own ON public.tunnels + FOR SELECT TO authenticated + USING (auth.uid() = user_id); + +DROP POLICY IF EXISTS tunnels_update_own ON public.tunnels; +CREATE POLICY tunnels_update_own ON public.tunnels + FOR UPDATE TO authenticated + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + +-- subscriptions: owner can SELECT. +DROP POLICY IF EXISTS subscriptions_select_own ON public.subscriptions; +CREATE POLICY subscriptions_select_own ON public.subscriptions + FOR SELECT TO authenticated + USING (auth.uid() = user_id); + +-- usage_samples: no anon/authenticated access (service_role bypasses RLS). +-- (No policies created → all access denied for non-service roles.) + +-- users_profile: owner can SELECT and UPDATE. +DROP POLICY IF EXISTS users_profile_select_own ON public.users_profile; +CREATE POLICY users_profile_select_own ON public.users_profile + FOR SELECT TO authenticated + USING (auth.uid() = user_id); + +DROP POLICY IF EXISTS users_profile_update_own ON public.users_profile; +CREATE POLICY users_profile_update_own ON public.users_profile + FOR UPDATE TO authenticated + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + +-- --------------------------------------------------------------------------- +-- Grants: let PostgREST roles see the tables; RLS still gates access. +-- --------------------------------------------------------------------------- + +GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role; + +GRANT SELECT, UPDATE ON public.tunnels TO authenticated; +GRANT SELECT ON public.subscriptions TO authenticated; +GRANT SELECT, UPDATE ON public.users_profile TO authenticated; + +GRANT ALL ON public.tunnels, public.subscriptions, public.usage_samples, public.users_profile TO service_role; +GRANT USAGE, SELECT ON SEQUENCE public.usage_samples_id_seq TO service_role; + +COMMIT; diff --git a/dev/supabase/migrations/0002_hardening.sql b/dev/supabase/migrations/0002_hardening.sql new file mode 100644 index 0000000..dfca002 --- /dev/null +++ b/dev/supabase/migrations/0002_hardening.sql @@ -0,0 +1,54 @@ +-- 0002: pentest remediation (A4/W3 privilege reduction, A3 quota default, W1 subscription rows) +BEGIN; + +-- --------------------------------------------------------------------------- +-- A4 / W3: authenticated users must NOT be able to UPDATE tunnels directly. +-- All legitimate writes (claim, token rotation, quota/usage, activation) go +-- through service-role server routes / workers. A direct grant let an owner +-- tamper with bytes_used, quota_bytes, is_active, token and subdomain. +-- --------------------------------------------------------------------------- +REVOKE UPDATE ON public.tunnels FROM authenticated; +DROP POLICY IF EXISTS tunnels_update_own ON public.tunnels; +-- SELECT (read-only dashboard) stays intact via tunnels_select_own. + +-- --------------------------------------------------------------------------- +-- A3: free-tier quota default 1 TiB -> 2 GiB, and shrink existing rows that +-- still carry the old default so the kill-switch is meaningful. +-- --------------------------------------------------------------------------- +ALTER TABLE public.tunnels ALTER COLUMN quota_bytes SET DEFAULT 2147483648; +UPDATE public.tunnels + SET quota_bytes = 2147483648 + WHERE quota_bytes = 1099511627776; + +-- --------------------------------------------------------------------------- +-- W1 (enablement): ensure every user has a subscriptions row so the billing +-- webhook activation (PATCH by user) actually targets a row, and backfill +-- existing users. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + INSERT INTO public.users_profile (user_id, email) + VALUES (NEW.id, NEW.email) + ON CONFLICT (user_id) DO NOTHING; + + INSERT INTO public.subscriptions (user_id, plan, status) + VALUES (NEW.id, 'free', 'active') + ON CONFLICT (user_id) DO NOTHING; + + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION public.handle_new_user() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.handle_new_user() TO supabase_auth_admin; + +INSERT INTO public.subscriptions (user_id, plan, status) +SELECT id, 'free', 'active' FROM auth.users +ON CONFLICT (user_id) DO NOTHING; + +COMMIT; diff --git a/dev/supabase/migrations/0003_least_privilege.sql b/dev/supabase/migrations/0003_least_privilege.sql new file mode 100644 index 0000000..3485bf6 --- /dev/null +++ b/dev/supabase/migrations/0003_least_privilege.sql @@ -0,0 +1,20 @@ +-- 0003: least-privilege table grants (A4/W3 hardening). +-- Supabase's default privileges grant ALL on public tables to anon & +-- authenticated. RLS gates DML, but TRUNCATE bypasses RLS and unauthenticated +-- (anon) should have no direct table rights at all. Reduce to the minimum the +-- app actually needs; service_role (which bypasses RLS) keeps full access. +BEGIN; + +REVOKE ALL ON public.tunnels FROM anon, authenticated; +REVOKE ALL ON public.subscriptions FROM anon, authenticated; +REVOKE ALL ON public.usage_samples FROM anon, authenticated; +REVOKE ALL ON public.users_profile FROM anon, authenticated; + +-- Authenticated users get read-only dashboard access (still gated by RLS +-- owner policies). users_profile also needs UPDATE (it has an owner policy). +GRANT SELECT ON public.tunnels TO authenticated; +GRANT SELECT ON public.subscriptions TO authenticated; +GRANT SELECT, UPDATE ON public.users_profile TO authenticated; +-- usage_samples: service_role only (no anon/authenticated access). + +COMMIT;