From 229690f4503ecd158b122616dca4193a87e50132 Mon Sep 17 00:00:00 2001 From: somethingwentwell Date: Mon, 16 Mar 2026 14:41:42 +0800 Subject: [PATCH 1/3] Add HTTPS support with Certbot Made-with: Cursor --- .env.example | 8 +++++ backend/app/api/feishu.py | 5 ++- backend/app/config.py | 15 +++++++++ backend/app/main.py | 23 ++++++++++++- certbot-entrypoint.sh | 42 ++++++++++++++++++++++++ docker-compose.yml | 23 +++++++++++++ frontend/Dockerfile | 8 +++-- frontend/entrypoint-nginx.sh | 36 ++++++++++++++++++++ frontend/nginx.ssl.conf.template | 56 ++++++++++++++++++++++++++++++++ 9 files changed, 212 insertions(+), 4 deletions(-) create mode 100755 certbot-entrypoint.sh create mode 100755 frontend/entrypoint-nginx.sh create mode 100644 frontend/nginx.ssl.conf.template diff --git a/.env.example b/.env.example index c27b9b06..8a412639 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,14 @@ SECRET_KEY=change-me-in-production JWT_SECRET_KEY=change-me-jwt-secret +# Public domain (optional). When set, the app redirects HTTP to HTTPS and uses https://DOMAIN for public URLs. +# Example: app.example.com (host only, no scheme). Set PUBLIC_BASE_URL if you need a different base URL. +# DOMAIN= + +# HTTPS with Certbot (Let's Encrypt). Set all three and run: docker compose --profile ssl up -d +# ENABLE_SSL=true +# LETSENCRYPT_EMAIL=admin@example.com + # Database (auto-configured by setup.sh; override for custom setups) # For local dev, ssl=disable is required to prevent asyncpg SSL negotiation hang # DATABASE_URL=postgresql+asyncpg://clawith:clawith@localhost:5432/clawith?ssl=disable diff --git a/backend/app/api/feishu.py b/backend/app/api/feishu.py index dee151df..d86ffd95 100644 --- a/backend/app/api/feishu.py +++ b/backend/app/api/feishu.py @@ -127,8 +127,9 @@ async def get_channel_config( async def get_webhook_url(agent_id: uuid.UUID, request: Request, db: AsyncSession = Depends(get_db)): """Get the webhook URL for this agent's Feishu bot.""" import os + from app.config import get_settings from app.models.system_settings import SystemSetting - # Priority: system_settings > env var > request.base_url + # Priority: system_settings > env PUBLIC_BASE_URL > config public_base_url (DOMAIN) > request.base_url public_base = "" result = await db.execute(select(SystemSetting).where(SystemSetting.key == "platform")) setting = result.scalar_one_or_none() @@ -136,6 +137,8 @@ async def get_webhook_url(agent_id: uuid.UUID, request: Request, db: AsyncSessio public_base = setting.value["public_base_url"].rstrip("/") if not public_base: public_base = os.environ.get("PUBLIC_BASE_URL", "").rstrip("/") + if not public_base: + public_base = get_settings().public_base_url if not public_base: public_base = str(request.base_url).rstrip("/") return {"webhook_url": f"{public_base}/api/channel/feishu/{agent_id}/webhook"} diff --git a/backend/app/config.py b/backend/app/config.py index 685464a6..45a0346a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -68,6 +68,10 @@ class Settings(BaseSettings): # CORS CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:5173"] + # Public domain (optional). When set, used for HTTPS redirect and building public base URL. + # Example: app.example.com (no scheme) + DOMAIN: str = "" + # Jina AI (Reader + Search APIs) JINA_API_KEY: str = "" @@ -78,6 +82,17 @@ class Settings(BaseSettings): "extra": "ignore", } + @property + def public_base_url(self) -> str: + """Public base URL for callbacks and links. Prefer env PUBLIC_BASE_URL; else https://DOMAIN when DOMAIN is set.""" + import os + base = os.environ.get("PUBLIC_BASE_URL", "").strip().rstrip("/") + if base: + return base + if self.DOMAIN: + return f"https://{self.DOMAIN.strip().rstrip('/')}" + return "" + @lru_cache def get_settings() -> Settings: diff --git a/backend/app/main.py b/backend/app/main.py index 789b5df6..97ed08bf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,8 +2,10 @@ from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import RedirectResponse from app.config import get_settings from app.core.events import close_redis @@ -12,6 +14,23 @@ settings = get_settings() +class HTTPSRedirectMiddleware(BaseHTTPMiddleware): + """When DOMAIN is set, redirect HTTP to HTTPS (using X-Forwarded-Proto when behind a proxy).""" + + async def dispatch(self, request: Request, call_next): + if not settings.DOMAIN: + return await call_next(request) + proto = request.headers.get("X-Forwarded-Proto", request.url.scheme) + if proto and proto.lower() == "https": + return await call_next(request) + url = request.url + base = f"https://{settings.DOMAIN.strip().rstrip('/')}" + path = url.path or "/" + if url.query: + path = f"{path}?{url.query}" + return RedirectResponse(url=f"{base}{path}", status_code=301) + + async def _start_ss_local() -> None: """Start ss-local SOCKS5 proxy for Discord API calls. Tries nodes in priority order.""" import asyncio, json, os, shutil, tempfile @@ -208,6 +227,8 @@ def _bg_task_error(t): allow_methods=["*"], allow_headers=["*"], ) +# HTTPS redirect when DOMAIN is set (runs first: redirect HTTP -> https://DOMAIN) +app.add_middleware(HTTPSRedirectMiddleware) # Register API routes from app.api.auth import router as auth_router diff --git a/certbot-entrypoint.sh b/certbot-entrypoint.sh new file mode 100755 index 00000000..4ce349d9 --- /dev/null +++ b/certbot-entrypoint.sh @@ -0,0 +1,42 @@ +#!/bin/sh +# Obtain certificate if missing, then renew periodically. On renewal, touch reload-requested so nginx reloads. + +set -e +DOMAIN="${DOMAIN:-}" +EMAIL="${LETSENCRYPT_EMAIL:-}" +WEBROOT="/var/www/certbot" + +if [ -z "$DOMAIN" ] || [ -z "$EMAIL" ]; then + echo "[certbot] DOMAIN and LETSENCRYPT_EMAIL must be set. Exiting." + exit 0 +fi + +obtain() { + certbot certonly --webroot -w "$WEBROOT" \ + -d "$DOMAIN" \ + --email "$EMAIL" \ + --agree-tos \ + --non-interactive \ + --keep-until-expiring 2>/dev/null || true +} + +renew() { + certbot renew --webroot -w "$WEBROOT" \ + --deploy-hook "touch /etc/letsencrypt/reload-requested" +} + +# Wait for nginx to be serving ACME challenge +echo "[certbot] Waiting for nginx..." +sleep 10 + +# Initial certificate (or refresh if self-signed was used) +echo "[certbot] Obtaining certificate for $DOMAIN..." +obtain +touch /etc/letsencrypt/reload-requested + +# Renew every 12 hours; deploy-hook triggers nginx reload +echo "[certbot] Starting renewal loop (every 12h)..." +while true; do + sleep 43200 + renew +done diff --git a/docker-compose.yml b/docker-compose.yml index 38a570d7..72dcbb73 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,17 +62,40 @@ services: build: ./frontend ports: - "${FRONTEND_PORT:-3008}:3000" + - "80:80" + - "443:443" environment: VITE_API_URL: http://localhost:8000 + DOMAIN: ${DOMAIN:-} + ENABLE_SSL: ${ENABLE_SSL:-false} volumes: - ./frontend/src:/app/src - ./frontend/public:/app/public + - certbot_www:/var/www/certbot + - certbot_conf:/etc/letsencrypt depends_on: - backend + certbot: + image: certbot/certbot:latest + entrypoint: ["/bin/sh", "/scripts/certbot-entrypoint.sh"] + environment: + DOMAIN: ${DOMAIN:-} + LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL:-} + volumes: + - certbot_www:/var/www/certbot + - certbot_conf:/etc/letsencrypt + - ./certbot-entrypoint.sh:/scripts/certbot-entrypoint.sh:ro + depends_on: + - frontend + profiles: + - ssl + volumes: pgdata: redisdata: + certbot_www: + certbot_conf: networks: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 7d8b64e1..b28cc823 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -6,7 +6,11 @@ COPY . . RUN npm run build FROM nginx:alpine +RUN apk add --no-cache openssl COPY --from=build /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf -EXPOSE 3000 -CMD ["nginx", "-g", "daemon off;"] +COPY nginx.ssl.conf.template /etc/nginx/templates/nginx.ssl.conf.template +COPY entrypoint-nginx.sh /entrypoint-nginx.sh +RUN chmod +x /entrypoint-nginx.sh +EXPOSE 3000 80 443 +ENTRYPOINT ["/entrypoint-nginx.sh"] diff --git a/frontend/entrypoint-nginx.sh b/frontend/entrypoint-nginx.sh new file mode 100755 index 00000000..b55b6cd2 --- /dev/null +++ b/frontend/entrypoint-nginx.sh @@ -0,0 +1,36 @@ +#!/bin/sh +set -e + +# When DOMAIN and SSL are set: use SSL template and ensure certs exist (self-signed until certbot runs). +# Otherwise: use default HTTP-only config. + +if [ -n "$DOMAIN" ] && [ "$ENABLE_SSL" = "true" ]; then + CERT_DIR="/etc/letsencrypt/live/$DOMAIN" + if [ ! -f "$CERT_DIR/fullchain.pem" ] || [ ! -f "$CERT_DIR/privkey.pem" ]; then + echo "[nginx] Creating self-signed cert for $DOMAIN (replace with Certbot for production)" + mkdir -p "$CERT_DIR" + openssl req -x509 -nodes -days 7 -newkey rsa:2048 \ + -keyout "$CERT_DIR/privkey.pem" \ + -out "$CERT_DIR/fullchain.pem" \ + -subj "/CN=$DOMAIN" + fi + sed "s/__DOMAIN__/$DOMAIN/g" /etc/nginx/templates/nginx.ssl.conf.template > /etc/nginx/conf.d/default.conf +# else: default.conf is already the HTTP-only config from the image +fi + +# Start nginx in background so we can run reload watcher +nginx -g "daemon off;" & +NGINX_PID=$! + +# When using SSL, watch for certbot renewal reload signal +if [ -n "$DOMAIN" ] && [ "$ENABLE_SSL" = "true" ]; then + while true; do + sleep 60 + if [ -f "/etc/letsencrypt/reload-requested" ]; then + echo "[nginx] Reloading after certificate renewal" + nginx -s reload 2>/dev/null || true + rm -f /etc/letsencrypt/reload-requested + fi + done +fi +wait $NGINX_PID diff --git a/frontend/nginx.ssl.conf.template b/frontend/nginx.ssl.conf.template new file mode 100644 index 00000000..bea7ee19 --- /dev/null +++ b/frontend/nginx.ssl.conf.template @@ -0,0 +1,56 @@ +# HTTPS + Certbot (when DOMAIN is set). Generated at runtime from nginx.ssl.conf.template. +# __DOMAIN__ is replaced by entrypoint with the actual domain. + +server { + listen 80; + server_name __DOMAIN__; + root /usr/share/nginx/html; + index index.html; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + try_files $uri =404; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + server_name __DOMAIN__; + root /usr/share/nginx/html; + index index.html; + + ssl_certificate /etc/letsencrypt/live/__DOMAIN__/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/__DOMAIN__/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + + client_max_body_size 500m; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + client_max_body_size 500m; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + } + + location /ws/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + } +} From 3b2985af2a2b27608fa7df0866f137ba05887dce Mon Sep 17 00:00:00 2001 From: somethingwentwell Date: Mon, 16 Mar 2026 15:47:55 +0800 Subject: [PATCH 2/3] Make SSL auto-enable based on env Made-with: Cursor --- .env.example | 6 +++--- docker-compose.yml | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 8a412639..4104f4cb 100644 --- a/.env.example +++ b/.env.example @@ -9,9 +9,9 @@ JWT_SECRET_KEY=change-me-jwt-secret # Example: app.example.com (host only, no scheme). Set PUBLIC_BASE_URL if you need a different base URL. # DOMAIN= -# HTTPS with Certbot (Let's Encrypt). Set all three and run: docker compose --profile ssl up -d -# ENABLE_SSL=true -# LETSENCRYPT_EMAIL=admin@example.com +# HTTPS with Certbot (Let's Encrypt). If ENABLE_SSL=true and DOMAIN + LETSENCRYPT_EMAIL are set, HTTPS is enabled automatically. +ENABLE_SSL=false +LETSENCRYPT_EMAIL= # Database (auto-configured by setup.sh; override for custom setups) # For local dev, ssl=disable is required to prevent asyncpg SSL negotiation hang diff --git a/docker-compose.yml b/docker-compose.yml index 72dcbb73..1b761c90 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -88,8 +88,7 @@ services: - ./certbot-entrypoint.sh:/scripts/certbot-entrypoint.sh:ro depends_on: - frontend - profiles: - - ssl + # Certbot reads DOMAIN / LETSENCRYPT_EMAIL from .env; if they are empty it exits quickly without error. volumes: pgdata: From 4879566e7c4a49d527efb76128639e38de21296b Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 16 Mar 2026 08:02:44 +0000 Subject: [PATCH 3/3] fix: make https docker build and runtime robust Made-with: Cursor --- docker-compose.yml | 1 - frontend/vite.config.ts | 9 +++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 1b761c90..6b4a9d23 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -62,7 +62,6 @@ services: build: ./frontend ports: - "${FRONTEND_PORT:-3008}:3000" - - "80:80" - "443:443" environment: VITE_API_URL: http://localhost:8000 diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 1687a696..50b7824c 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -3,8 +3,13 @@ import react from '@vitejs/plugin-react' import path from 'path' import fs from 'fs' -// Read major version from root VERSION file, generate build timestamp automatically -const majorVersion = fs.readFileSync(path.resolve(__dirname, '../VERSION'), 'utf-8').trim() +// Read major version from root VERSION file when available, fall back gracefully in containers +let majorVersion = '0.0.0' +try { + majorVersion = fs.readFileSync(path.resolve(__dirname, '../VERSION'), 'utf-8').trim() +} catch { + // In Docker build context the root VERSION file may not be present; keep default +} const now = new Date() const buildTimestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}.${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}` const version = `${majorVersion}+${buildTimestamp}`