diff --git a/claude-funnel/nginx.conf b/claude-funnel/nginx.conf new file mode 100644 index 00000000..590eb21f --- /dev/null +++ b/claude-funnel/nginx.conf @@ -0,0 +1,88 @@ +# Claude AI IP Filter for Tailscale Funnel +# +# Routes MCP transport to Claude AI IPs only, while allowing +# OAuth/auth endpoints from any IP (needed for user login flow). +# +# Pattern: homelab-argocd/atlantis/templates/nginx-webhook-config.yaml + +worker_processes auto; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log warn; + + sendfile on; + keepalive_timeout 65; + + # Map Claude AI outbound IPs (must be at http level) + geo $remote_addr $is_claude_ai { + default 0; + + # Anthropic Claude AI Outbound IP Range + # Source: https://docs.claude.com/en/api/ip-addresses + # Last updated: 2026-03-29 + + # IPv4 range + 160.79.104.0/21 1; # Claude AI + } + + server { + listen 8080; + server_name _; + + # Trust Tailscale proxy for real IP extraction + real_ip_header X-Forwarded-For; + set_real_ip_from 100.64.0.0/10; # Tailscale CGNAT range + set_real_ip_from 10.0.0.0/8; # Docker internal networks + set_real_ip_from 172.16.0.0/12; # Docker bridge networks + real_ip_recursive on; + + # OAuth/auth endpoints + favicon - allow ALL IPs (user browser needs access for login flow) + location ~ ^/(oauth|\.well-known|app|favicon\.ico)(/|$) { + proxy_pass http://mcp-login-flow:8004; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + } + + # MCP transport endpoint - Claude AI IPs ONLY + # login-flow uses streamable-http transport (no /sse needed) + location /mcp { + if ($is_claude_ai = 0) { + return 403 '{"error": "Access denied - IP not in Claude AI range", "source_ip": "$remote_addr"}\n'; + } + + proxy_pass http://mcp-login-flow:8004; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + # Streamable HTTP support (long-lived connections) + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + proxy_http_version 1.1; + proxy_set_header Connection ''; + } + + # Default - deny everything else + location / { + return 404 '{"error": "Not found"}\n'; + } + } +} diff --git a/claude-funnel/serve-config.json b/claude-funnel/serve-config.json new file mode 100644 index 00000000..bca8418a --- /dev/null +++ b/claude-funnel/serve-config.json @@ -0,0 +1,19 @@ +{ + "TCP": { + "443": { + "HTTPS": true + } + }, + "Web": { + "${TS_CERT_DOMAIN}:443": { + "Handlers": { + "/": { + "Proxy": "http://nginx-claude-filter:8080" + } + } + } + }, + "AllowFunnel": { + "${TS_CERT_DOMAIN}:443": true + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 7c8c73da..fd3ff9fd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -303,6 +303,7 @@ services: environment: - NEXTCLOUD_HOST=http://app:80 - NEXTCLOUD_MCP_SERVER_URL=http://localhost:8004 + #- NEXTCLOUD_MCP_SERVER_URL=https://nextcloud-mcp-dev.tail148d5.ts.net - NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080 # Login Flow v2 (ADR-022) @@ -324,6 +325,38 @@ services: profiles: - login-flow + # Tailscale Funnel for Claude AI connector testing + # Usage: docker compose --profile login-flow --profile claude-funnel up --build -d + # Requires: TS_AUTHKEY in .env file (see env.sample.claude-funnel) + tailscale-mcp: + image: tailscale/tailscale:latest + hostname: nextcloud-mcp-dev + restart: always + environment: + - TS_AUTHKEY=${TS_AUTHKEY} + - TS_STATE_DIR=/var/lib/tailscale + - TS_SERVE_CONFIG=/config/serve-config.json + - TS_HOSTNAME=nextcloud-mcp-dev + - TS_EXTRA_ARGS=--advertise-tags=tag:container + volumes: + - tailscale-state:/var/lib/tailscale + - ./claude-funnel/serve-config.json:/config/serve-config.json:ro + cap_add: + - NET_ADMIN + - SYS_MODULE + profiles: + - claude-funnel + + nginx-claude-filter: + image: docker.io/library/nginx:alpine + restart: always + depends_on: + - mcp-login-flow + volumes: + - ./claude-funnel/nginx.conf:/etc/nginx/nginx.conf:ro + profiles: + - claude-funnel + qdrant: image: docker.io/qdrant/qdrant:v1.17.1@sha256:94728574965d17c6485dd361aa3c0818b325b9016dac5ea6afec7b4b2700865f restart: always @@ -354,3 +387,4 @@ volumes: qdrant-data: mcp-data: multi-user-basic-data: + tailscale-state: diff --git a/env.sample.claude-funnel b/env.sample.claude-funnel new file mode 100644 index 00000000..b123eb20 --- /dev/null +++ b/env.sample.claude-funnel @@ -0,0 +1,22 @@ +# Claude AI Connector - Tailscale Funnel Configuration +# +# This profile exposes the MCP server (login-flow mode) via Tailscale Funnel +# for testing Claude AI connector integration. +# +# Usage: +# 1. Copy this file to .env and fill in TS_AUTHKEY +# 2. Run: docker compose --profile login-flow --profile claude-funnel up --build -d +# 3. Configure Claude.ai connector with: https://nextcloud-mcp-dev..ts.net/mcp +# +# Prerequisites: +# - Generate auth key at https://login.tailscale.com/admin/settings/keys +# with tag:container and reusable=yes +# - Ensure Tailscale ACL allows funnel for tag:container: +# "nodeAttrs": [{ "target": ["tag:container"], "attr": ["funnel"] }] +# +# Security: +# - /mcp endpoint restricted to Claude AI IPs (160.79.104.0/21) +# - /oauth/* and /.well-known/* open to all IPs (needed for user login flow) +# - All other paths return 404 + +TS_AUTHKEY=tskey-auth-REPLACE_ME diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index e7f2f0c3..2ed855d3 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -24,7 +24,7 @@ from pydantic import AnyHttpUrl from starlette.applications import Starlette from starlette.middleware.authentication import AuthenticationMiddleware from starlette.middleware.cors import CORSMiddleware -from starlette.responses import JSONResponse, RedirectResponse +from starlette.responses import FileResponse, JSONResponse, RedirectResponse from starlette.routing import Mount, Route from starlette.staticfiles import StaticFiles from starlette.types import ASGIApp, Receive, Send @@ -2342,6 +2342,18 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = routes.append(Mount("/app", app=browser_app)) logger.info("App routes with session auth: /app, /app/webhooks, /app/revoke") + # Favicon for connector directory discovery (Google favicon service) + favicon_path = os.path.join( + os.path.dirname(__file__), "auth", "static", "favicon.png" + ) + if os.path.isfile(favicon_path): + routes.append( + Route( + "/favicon.ico", + lambda request: FileResponse(favicon_path, media_type="image/png"), + ) + ) + # Mount FastMCP at root last (catch-all, handles OAuth via token_verifier) routes.append(Mount("/", app=mcp_app)) diff --git a/nextcloud_mcp_server/auth/static/favicon.png b/nextcloud_mcp_server/auth/static/favicon.png new file mode 100644 index 00000000..219fbbdf Binary files /dev/null and b/nextcloud_mcp_server/auth/static/favicon.png differ diff --git a/third_party/astrolabe b/third_party/astrolabe index d245ded7..829db07a 160000 --- a/third_party/astrolabe +++ b/third_party/astrolabe @@ -1 +1 @@ -Subproject commit d245ded7f8dc57f9add06f5d7d5ee90c19d38bc3 +Subproject commit 829db07a8c9cb750cdcb20e1f5238e24594b03b3