diff --git a/docker-compose/deploy/.env.example b/docker-compose/deploy/.env.example index 8f7665cc60..64f4e14f94 100644 --- a/docker-compose/deploy/.env.example +++ b/docker-compose/deploy/.env.example @@ -19,9 +19,10 @@ LOBE_PORT=3210 RUSTFS_PORT=9000 RUSTFS_ADMIN_PORT=9001 APP_URL=http://localhost:3210 -# INTERNAL_APP_URL is optional, used for server-to-server calls -# to bypass CDN/proxy. If not set, defaults to APP_URL. -# Example: INTERNAL_APP_URL=http://localhost:3210 +# INTERNAL_APP_URL is used for internal server-to-server communication. +# Required for Docker Compose deployments, otherwise features like +# AI image generation will fail when APP_URL is a host/LAN IP. +INTERNAL_APP_URL=http://localhost:3210 # Secrets (auto-generated by setup.sh) KEY_VAULTS_SECRET=YOUR_KEY_VAULTS_SECRET diff --git a/docker-compose/deploy/.env.zh-CN.example b/docker-compose/deploy/.env.zh-CN.example index 2c78c74c61..9075193526 100644 --- a/docker-compose/deploy/.env.zh-CN.example +++ b/docker-compose/deploy/.env.zh-CN.example @@ -17,9 +17,9 @@ # 如没有特殊需要不用更改 LOBE_PORT=3210 APP_URL=http://localhost:3210 -# 内部应用URL是可选的,用于服务器内部调用 -# 如果没有设置,默认使用 APP_URL -# INTERNAL_APP_URL=http://localhost:3210 +# 内部应用URL,用于容器内部服务间通信 +# Docker Compose 部署时必须配置,否则当 APP_URL 为宿主机 IP 时 AI 生图等功能会失败 +INTERNAL_APP_URL=http://localhost:3210 # 密钥配置(由 setup.sh 自动生成) KEY_VAULTS_SECRET=YOUR_KEY_VAULTS_SECRET diff --git a/docker-compose/deploy/docker-compose.yml b/docker-compose/deploy/docker-compose.yml index ad4c90307c..3d994c52f7 100644 --- a/docker-compose/deploy/docker-compose.yml +++ b/docker-compose/deploy/docker-compose.yml @@ -18,6 +18,7 @@ services: - 'KEY_VAULTS_SECRET=${KEY_VAULTS_SECRET}' - 'AUTH_SECRET=${AUTH_SECRET}' - 'DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgresql:5432/${LOBE_DB_NAME}' + - 'INTERNAL_APP_URL=http://localhost:3210' - 'S3_ENDPOINT=${S3_ENDPOINT}' - 'S3_BUCKET=${RUSTFS_LOBE_BUCKET}' - 'S3_ENABLE_PATH_STYLE=1' diff --git a/docs/self-hosting/platform/docker-compose.mdx b/docs/self-hosting/platform/docker-compose.mdx index f1052e4dde..d246d41256 100644 --- a/docs/self-hosting/platform/docker-compose.mdx +++ b/docs/self-hosting/platform/docker-compose.mdx @@ -308,6 +308,23 @@ services: - 'DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgresql:5432/${LOBE_DB_NAME}' ``` +3. Internal Communication URL (`INTERNAL_APP_URL`) + + + `INTERNAL_APP_URL` is **required** for Docker Compose deployments. LobeHub's async features (e.g., AI image generation) require the container to make HTTP requests to itself internally. If `INTERNAL_APP_URL` is not set, the system falls back to `APP_URL`. When `APP_URL` is a host IP (e.g., `http://10.1.7.146:8080`), the container cannot reach that address internally, causing async features like image generation to silently fail. + + +```env +INTERNAL_APP_URL=http://localhost:3210 +``` + +- `APP_URL`: Used for browser/client access, OAuth callbacks, webhooks, etc. +- `INTERNAL_APP_URL`: Used for internal server-to-server communication within the container (bypasses CDN/proxy/host network) + + + For Docker Compose deployments, we recommend using `http://localhost:3210` (container calling itself) or `http://lobe:3210` (using service name for container-to-container communication). + + ## FAQ #### Database Migration Issues @@ -330,37 +347,6 @@ sudo rm -rf ./data # Remove mounted database data docker compose up -d # Restart ``` -#### Using `INTERNAL_APP_URL` for Internal Server Communication - - - If you're deploying LobeHub behind a CDN (like Cloudflare) or reverse proxy, you may want to configure internal server-to-server communication to bypass the CDN/proxy layer for better performance. - - -You can configure the `INTERNAL_APP_URL` environment variable: - -```yaml -environment: - - 'APP_URL=https://lobe.example.com' # Public URL for browser access - - 'INTERNAL_APP_URL=http://localhost:3210' # Internal URL for server-to-server calls -``` - -**How it works:** - -- `APP_URL`: Used for browser/client access, OAuth callbacks, webhooks, etc. (goes through CDN/proxy) -- `INTERNAL_APP_URL`: Used for internal server-to-server communication (bypasses CDN/proxy) - -If `INTERNAL_APP_URL` is not set, it defaults to `APP_URL`. - -**Configuration options:** - -- `http://localhost:3210` - If using Docker with host network mode -- `http://lobe:3210` - If using Docker network with service name -- `http://127.0.0.1:3210` - Alternative localhost address - - - For Docker Compose deployments, we recommend using `http://lobe:3210` as the `INTERNAL_APP_URL` (using service name for container-to-container communication). - - ## Reverse Proxy Configuration For production deployments with a custom domain and HTTPS, configure a reverse proxy in front of LobeHub. diff --git a/docs/self-hosting/platform/docker-compose.zh-CN.mdx b/docs/self-hosting/platform/docker-compose.zh-CN.mdx index 08130f8da1..1e7c5e8dfe 100644 --- a/docs/self-hosting/platform/docker-compose.zh-CN.mdx +++ b/docs/self-hosting/platform/docker-compose.zh-CN.mdx @@ -302,6 +302,23 @@ services: - 'DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgresql:5432/${LOBE_DB_NAME}' ``` +3. 内部通信地址 (`INTERNAL_APP_URL`) + + + Docker Compose 部署时**必须配置** `INTERNAL_APP_URL`。LobeHub 的异步功能(如 AI 生图)需要容器内部发起 HTTP 请求调用自身。如果未设置 `INTERNAL_APP_URL`,系统将使用 `APP_URL` 作为回退。当 `APP_URL` 是宿主机 IP(如 `http://10.1.7.146:8080`)时,容器内部无法访问该地址,导致生图等异步功能静默失败。 + + +```env +INTERNAL_APP_URL=http://localhost:3210 +``` + +- `APP_URL`:用于浏览器 / 客户端访问、OAuth 回调、webhook 等 +- `INTERNAL_APP_URL`:用于容器内部服务间通信(绕过 CDN / 代理 / 宿主机网络) + + + 对于 Docker Compose 部署,推荐使用 `http://localhost:3210`(容器调用自身)或 `http://lobe:3210`(使用服务名称进行容器间通信)。 + + ## 常见问题 #### 数据库迁移问题 @@ -324,37 +341,6 @@ sudo rm -rf ./data # 移除挂载的数据库数据 docker compose up -d # 重新启动 ``` -#### 使用 `INTERNAL_APP_URL` 配置内部服务器通信 - - - 如果你在 CDN(如 Cloudflare)或反向代理后部署 LobeHub,你可以配置内部服务器到服务器通信以绕过 CDN / 代理层,以获得更好的性能。 - - -你可以配置 `INTERNAL_APP_URL` 环境变量: - -```yaml -environment: - - 'APP_URL=https://lobe.example.com' # 浏览器访问的公开 URL - - 'INTERNAL_APP_URL=http://localhost:3210' # 服务器到服务器调用的内部 URL -``` - -**工作原理:** - -- `APP_URL`:用于浏览器 / 客户端访问、OAuth 回调、webhook 等(通过 CDN / 代理) -- `INTERNAL_APP_URL`:用于内部服务器到服务器通信(绕过 CDN / 代理) - -如果未设置 `INTERNAL_APP_URL`,它将默认为 `APP_URL`。 - -**配置选项:** - -- `http://localhost:3210` - 如果使用 Docker 主机网络模式 -- `http://lobe:3210` - 如果使用 Docker 网络与服务名称 -- `http://127.0.0.1:3210` - 备用本地主机地址 - - - 对于 Docker Compose 部署,推荐使用 `http://lobe:3210` 作为 `INTERNAL_APP_URL`(使用服务名称进行容器间通信)。 - - ## 反向代理配置 在生产环境中,如需使用自定义域名和 HTTPS,需在 LobeHub 前面配置反向代理。 diff --git a/packages/ssrf-safe-fetch/index.test.ts b/packages/ssrf-safe-fetch/index.test.ts index 21d8828b69..50bea302cf 100644 --- a/packages/ssrf-safe-fetch/index.test.ts +++ b/packages/ssrf-safe-fetch/index.test.ts @@ -96,9 +96,9 @@ describe('ssrfSafeFetch', () => { throw new Error('getaddrinfo ENOTFOUND'); }); - await expect(ssrfSafeFetch(url)).rejects.toThrow(/SSRF-safe fetch failed/); + await expect(ssrfSafeFetch(url)).rejects.toThrow(/Fetch failed/); - expect(console.error).toHaveBeenCalledWith('SSRF-safe fetch error:', expect.any(Error)); + expect(console.error).toHaveBeenCalledWith('Fetch error:', expect.any(Error)); }); }); @@ -128,21 +128,36 @@ describe('ssrfSafeFetch', () => { }); describe('SSRF protection for malicious URLs', () => { - const maliciousUrls = [ + const privateHttpUrls = [ 'http://169.254.169.254/latest/meta-data/', // AWS metadata service 'http://169.254.169.254:80/computeMetadata/v1/', // GCP metadata 'http://metadata.google.internal/computeMetadata/v1/', + ]; + + privateHttpUrls.forEach((url) => { + it(`should SSRF-block private HTTP URL: ${url}`, async () => { + mockFetch.mockImplementation(() => { + throw new Error( + 'DNS lookup 169.254.169.254 is not allowed. Because, It is private IP address.', + ); + }); + + await expect(ssrfSafeFetch(url)).rejects.toThrow(/SSRF blocked/); + }); + }); + + const unsupportedSchemeUrls = [ 'file:///etc/passwd', // File protocol 'ftp://internal.company.com/secrets', // FTP protocol ]; - maliciousUrls.forEach((url) => { - it(`should block malicious URL: ${url}`, async () => { + unsupportedSchemeUrls.forEach((url) => { + it(`should reject unsupported scheme: ${url}`, async () => { mockFetch.mockImplementation(() => { - throw new Error('Request blocked by SSRF protection'); + throw new TypeError('Only HTTP(S) protocols are supported'); }); - await expect(ssrfSafeFetch(url)).rejects.toThrow(/SSRF-safe fetch failed/); + await expect(ssrfSafeFetch(url)).rejects.toThrow(/Fetch failed/); }); }); }); @@ -155,9 +170,7 @@ describe('ssrfSafeFetch', () => { throw new Error('getaddrinfo ENOTFOUND'); }); - await expect(ssrfSafeFetch('http://127.0.0.1:8080')).rejects.toThrow( - /SSRF-safe fetch failed/, - ); + await expect(ssrfSafeFetch('http://127.0.0.1:8080')).rejects.toThrow(/Fetch failed/); }); it('should handle invalid environment variable values gracefully', async () => { @@ -168,9 +181,7 @@ describe('ssrfSafeFetch', () => { }); // Should default to false when env var is not 'true' - await expect(ssrfSafeFetch('http://127.0.0.1:8080')).rejects.toThrow( - /SSRF-safe fetch failed/, - ); + await expect(ssrfSafeFetch('http://127.0.0.1:8080')).rejects.toThrow(/Fetch failed/); }); }); @@ -180,10 +191,21 @@ describe('ssrfSafeFetch', () => { mockFetch.mockRejectedValue(originalError); await expect(ssrfSafeFetch('https://example.com')).rejects.toThrow( - 'SSRF-safe fetch failed: Network error', + 'Fetch failed: Network error', ); - expect(console.error).toHaveBeenCalledWith('SSRF-safe fetch error:', originalError); + expect(console.error).toHaveBeenCalledWith('Fetch error:', originalError); + }); + + it('should throw SSRF blocked error when request-filtering-agent blocks', async () => { + const ssrfError = new Error( + 'DNS lookup 10.0.0.1(family:4, host:10.0.0.1) is not allowed. Because, It is private IP address.', + ); + mockFetch.mockRejectedValue(ssrfError); + + await expect(ssrfSafeFetch('http://10.0.0.1/internal')).rejects.toThrow(/SSRF blocked/); + + expect(console.error).toHaveBeenCalledWith('SSRF protection blocked request:', ssrfError); }); it('should handle non-Error thrown values', async () => { @@ -191,16 +213,14 @@ describe('ssrfSafeFetch', () => { mockFetch.mockRejectedValue(nonErrorValue); await expect(ssrfSafeFetch('https://example.com')).rejects.toThrow( - 'SSRF-safe fetch failed: String error', + 'Fetch failed: String error', ); }); it('should handle null/undefined error values', async () => { mockFetch.mockRejectedValue(null); - await expect(ssrfSafeFetch('https://example.com')).rejects.toThrow( - 'SSRF-safe fetch failed: null', - ); + await expect(ssrfSafeFetch('https://example.com')).rejects.toThrow('Fetch failed: null'); }); }); diff --git a/packages/ssrf-safe-fetch/index.ts b/packages/ssrf-safe-fetch/index.ts index 5a9871acfc..9e30836c7a 100644 --- a/packages/ssrf-safe-fetch/index.ts +++ b/packages/ssrf-safe-fetch/index.ts @@ -61,10 +61,20 @@ export const ssrfSafeFetch = async ( statusText: response.statusText, }); } catch (error) { - console.error('SSRF-safe fetch error:', error); - throw new Error( - `SSRF-safe fetch failed: ${error instanceof Error ? error.message : String(error)}. ` + - 'See: https://lobehub.com/docs/self-hosting/environment-variables/basic#ssrf-allow-private-ip-address', - ); + const errorMessage = error instanceof Error ? error.message : String(error); + // request-filtering-agent errors contain "is not allowed" when blocking private/denied IPs + const isSSRFBlock = errorMessage.includes('is not allowed'); + + if (isSSRFBlock) { + console.error('SSRF protection blocked request:', error); + throw new Error( + `SSRF blocked: ${errorMessage}. ` + + 'See: https://lobehub.com/docs/self-hosting/environment-variables/basic#ssrf-allow-private-ip-address', + { cause: error }, + ); + } + + console.error('Fetch error:', error); + throw new Error(`Fetch failed: ${errorMessage}`, { cause: error }); } };