From b8dc1d7f52a3e5d8acbe5a558aa1c0b1a8c04c22 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sun, 29 Mar 2026 19:25:58 +0200 Subject: [PATCH] feat: add Tailscale Funnel config for Claude AI connector testing Add docker compose services (tailscale-mcp + nginx-claude-filter) behind a claude-funnel profile that expose the login-flow MCP server via Tailscale Funnel with IP-based access control: - /mcp endpoint restricted to Claude AI outbound IPs (160.79.104.0/21) - /oauth/*, /.well-known/*, /app paths open to all IPs (user login flow) - All other paths return 404 Also add favicon.png served at /favicon.ico for connector directory discovery (Google favicon service). Usage: docker compose --profile login-flow --profile claude-funnel up -d Co-Authored-By: Claude Opus 4.6 (1M context) --- claude-funnel/nginx.conf | 88 +++++++++++++++++++ claude-funnel/serve-config.json | 19 ++++ docker-compose.yml | 34 +++++++ env.sample.claude-funnel | 22 +++++ nextcloud_mcp_server/app.py | 14 ++- nextcloud_mcp_server/auth/static/favicon.png | Bin 0 -> 10210 bytes third_party/astrolabe | 2 +- 7 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 claude-funnel/nginx.conf create mode 100644 claude-funnel/serve-config.json create mode 100644 env.sample.claude-funnel create mode 100644 nextcloud_mcp_server/auth/static/favicon.png 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 0000000000000000000000000000000000000000..219fbbdf78546adb60dc4172342a485b87fd6056 GIT binary patch literal 10210 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 T3*~^9_CPX{iV`*d83z9kI`|Z^ literal 0 HcmV?d00001 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