fix: remove the proxy url

This commit is contained in:
2026-08-15 12:17:45 +07:00
parent 96759eb34c
commit efe18629b1
9 changed files with 21 additions and 347 deletions
+7 -12
View File
@@ -37,29 +37,24 @@ After the first deployment, add a hostname in **Workers & Pages → 9router-conf
Do not protect the app or `/i/*` with Cloudflare Access if users must download install scripts via `curl` or PowerShell without signing in.
## Production model proxy
## Gateway CORS requirement
The browser first requests `<gateway>/models` directly. If that gateway does not allow CORS, the app falls back to `/api/fetch-models` on the Worker.
Models are requested directly from the Base URL entered by the user at `<gateway>/models`. This app does not proxy gateway requests through Cloudflare, so it works with any reachable gateway that permits cross-origin requests.
The Worker proxy is disabled by default and only accepts HTTPS gateway origins explicitly allowlisted through `MODEL_PROXY_ALLOWED_ORIGINS`. This prevents the public endpoint from becoming an open proxy.
Configure it in **Workers & Pages → 9router-config-generate → Settings → Variables and Secrets** as a plaintext variable:
Configure each gateway to allow the deployed app origin and request headers:
```txt
MODEL_PROXY_ALLOWED_ORIGINS=https://router.example.com,https://backup-router.example.com
Access-Control-Allow-Origin: https://<your-app-domain>
Access-Control-Allow-Methods: GET, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
```
Each value must be an HTTPS origin only: no path, query string, credentials, or wildcard. Deploy again after changing the variable.
> A Cloudflare Worker cannot reach `localhost` on a visitor's computer. For a local 9router gateway, configure that gateway's CORS policy to allow the deployed app origin instead. Use the Worker proxy only for publicly reachable HTTPS gateways.
The proxy forwards the API key supplied by the user for the single `/models` request. It does not store, log, or cache the key. Do not configure a shared 9router API key as a Worker variable or secret.
For a local 9router gateway, allow the deployed app domain in its CORS configuration. Cloudflare cannot access `localhost` on a visitor's computer, but the visitor's browser can when the gateway permits that origin.
## Cloudflare resources used
- Cloudflare Workers
- Workers Static Assets (`ASSETS` binding)
- Optional custom domain
- Optional plaintext variable: `MODEL_PROXY_ALLOWED_ORIGINS`
No KV, D1, R2, Queue, Durable Object, or Worker secret is required.
Binary file not shown.
+10 -39
View File
@@ -59,11 +59,9 @@ function formatModelName(id: string, rawName?: string): string {
export async function fetchRemoteModels(connection: ConnectionConfig): Promise<Model[]> {
const trimmedBase = connection.baseUrl.replace(/\/+$/, '')
const isHttpUrl = trimmedBase.startsWith('http://') || trimmedBase.startsWith('https://')
let data: RawModelsResponse
let data: RawModelsResponse | null = null
// 1. Direct fetch with the standard Authorization header. Never put an API key in a URL.
// Fetch the user-provided gateway directly. The gateway must permit this app's origin with CORS.
try {
const headers: Record<string, string> = {
Accept: 'application/json',
@@ -77,43 +75,16 @@ export async function fetchRemoteModels(connection: ConnectionConfig): Promise<M
headers,
})
if (directRes.ok) {
if (!directRes.ok) {
throw new Error(`Gateway responded with ${directRes.status} ${directRes.statusText}`)
}
data = await directRes.json()
}
} catch {
// If direct CORS/network fails, fallback to standard Authorization header or proxy
}
// 2. Fallback to the local Vite / allowlisted Worker proxy when direct CORS fails.
if (!data && isHttpUrl) {
try {
const proxyRes = await fetch('/api/fetch-models', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
baseUrl: connection.baseUrl,
apiKey: connection.apiKey,
}),
})
if (proxyRes.ok) {
data = await proxyRes.json()
} else {
const errJson = await proxyRes.json().catch(() => null)
const errMsg = errJson?.error?.message || errJson?.error || proxyRes.statusText
throw new Error(`Failed to fetch models: ${errMsg}`)
}
} catch (err: unknown) {
if (err instanceof Error) {
throw err
}
}
}
if (!data) {
throw new Error('Unable to connect to gateway models endpoint. Check your Base URL and API Key.')
const reason = err instanceof Error ? err.message : 'Unknown network error'
throw new Error(
`Unable to fetch models directly from the gateway. Check the Base URL, API key, and that the gateway allows this app origin with CORS. ${reason}`,
{ cause: err }
)
}
const rawList = Array.isArray(data) ? data : (data.data || [])
+1 -49
View File
@@ -12,7 +12,7 @@ export default defineConfig({
react(),
babel({ presets: [reactCompilerPreset()] }),
{
name: 'router-proxy-middleware',
name: 'install-script-middleware',
configureServer(server) {
// Dev endpoint for 1-click install scripts: /i/:token.(sh|ps1)
server.middlewares.use((req: IncomingMessage, res: ServerResponse, next: () => void) => {
@@ -32,54 +32,6 @@ export default defineConfig({
next()
})
server.middlewares.use('/api/fetch-models', async (req: IncomingMessage, res: ServerResponse) => {
if (req.method !== 'POST') {
res.statusCode = 405
res.end(JSON.stringify({ error: 'Method not allowed' }))
return
}
let body = ''
req.on('data', (chunk: Buffer) => {
body += chunk.toString()
})
req.on('end', async () => {
try {
const { baseUrl, apiKey } = JSON.parse(body || '{}')
if (!baseUrl) {
res.statusCode = 400
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: 'baseUrl is required' }))
return
}
const targetUrl = `${baseUrl.replace(/\/+$/, '')}/models`
const headers: Record<string, string> = {
'Accept': 'application/json',
}
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey.trim()}`
}
const remoteRes = await fetch(targetUrl, {
method: 'GET',
headers,
})
const remoteData = await remoteRes.json().catch(() => null)
res.statusCode = remoteRes.status
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(remoteData || {}))
} catch (err: unknown) {
res.statusCode = 500
res.setHeader('Content-Type', 'application/json')
const msg = err instanceof Error ? err.message : 'Internal error'
res.end(JSON.stringify({ error: msg }))
}
})
})
},
},
],
-6
View File
@@ -1,11 +1,9 @@
import { renderInstallScript } from '../src/services/installEndpoint.ts'
import { proxyModelRequest } from './modelProxy.ts'
interface WorkerEnv {
ASSETS: {
fetch(request: Request): Promise<Response>
}
MODEL_PROXY_ALLOWED_ORIGINS?: string
}
export default {
@@ -26,10 +24,6 @@ export default {
})
}
if (url.pathname === '/api/fetch-models') {
return proxyModelRequest(request, env)
}
return env.ASSETS.fetch(request)
},
}
-91
View File
@@ -1,91 +0,0 @@
import { afterEach, describe, expect, it, mock } from 'bun:test'
import { proxyModelRequest } from './modelProxy.ts'
const originalFetch = globalThis.fetch
function createRequest(body: Record<string, unknown>, method = 'POST'): Request {
return new Request('https://config.example.com/api/fetch-models', {
method,
headers: { 'content-type': 'application/json' },
body: method === 'POST' ? JSON.stringify(body) : undefined,
})
}
afterEach(() => {
globalThis.fetch = originalFetch
})
describe('model proxy', () => {
it('does not enable the proxy without an allowlist', async () => {
const fetchMock = mock()
globalThis.fetch = fetchMock
const response = await proxyModelRequest(
createRequest({ baseUrl: 'https://router.example.com/v1' }),
{}
)
expect(response.status).toBe(503)
expect(fetchMock).not.toHaveBeenCalled()
})
it('ignores allowlist entries that are not plain HTTPS origins', async () => {
const fetchMock = mock()
globalThis.fetch = fetchMock
const response = await proxyModelRequest(
createRequest({ baseUrl: 'https://router.example.com/v1' }),
{ MODEL_PROXY_ALLOWED_ORIGINS: 'https://router.example.com/v1' }
)
expect(response.status).toBe(503)
expect(fetchMock).not.toHaveBeenCalled()
})
it('proxies an allowlisted gateway request with a bearer token', async () => {
const fetchMock = mock(() =>
Promise.resolve(Response.json({ data: [{ id: 'example-model' }] }))
)
globalThis.fetch = fetchMock
const response = await proxyModelRequest(
createRequest({
baseUrl: 'https://router.example.com/v1/',
apiKey: 'test-key',
}),
{ MODEL_PROXY_ALLOWED_ORIGINS: 'https://router.example.com' }
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ data: [{ id: 'example-model' }] })
expect(fetchMock).toHaveBeenCalledWith(
'https://router.example.com/v1/models',
expect.objectContaining({
headers: expect.any(Headers),
redirect: 'error',
})
)
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]
expect((requestInit.headers as Headers).get('Authorization')).toBe('Bearer test-key')
})
it('rejects unallowlisted and non-HTTPS gateways without fetching them', async () => {
const fetchMock = mock()
globalThis.fetch = fetchMock
const env = { MODEL_PROXY_ALLOWED_ORIGINS: 'https://router.example.com' }
const unallowlisted = await proxyModelRequest(
createRequest({ baseUrl: 'https://other.example.com/v1' }),
env
)
const nonHttps = await proxyModelRequest(
createRequest({ baseUrl: 'http://router.example.com/v1' }),
env
)
expect(unallowlisted.status).toBe(403)
expect(nonHttps.status).toBe(400)
expect(fetchMock).not.toHaveBeenCalled()
})
})
-147
View File
@@ -1,147 +0,0 @@
const MAX_REQUEST_BODY_BYTES = 10_240
const MAX_BASE_URL_LENGTH = 2_048
const MAX_API_KEY_LENGTH = 4_096
interface ModelProxyEnvironment {
MODEL_PROXY_ALLOWED_ORIGINS?: string
}
interface ModelProxyRequestBody {
baseUrl?: unknown
apiKey?: unknown
}
function jsonError(status: number, error: string): Response {
return Response.json(
{ error },
{
status,
headers: { 'cache-control': 'no-store' },
}
)
}
function getAllowedOrigins(value: string | undefined): Set<string> {
const origins = new Set<string>()
for (const candidate of value?.split(',') ?? []) {
try {
const url = new URL(candidate.trim())
if (
url.protocol === 'https:' &&
url.pathname === '/' &&
!url.search &&
!url.hash &&
!url.username &&
!url.password
) {
origins.add(url.origin)
}
} catch {
// Ignore malformed configuration entries rather than widening access.
}
}
return origins
}
export async function proxyModelRequest(
request: Request,
env: ModelProxyEnvironment
): Promise<Response> {
if (request.method !== 'POST') {
return jsonError(405, 'Method not allowed')
}
const contentLength = Number(request.headers.get('content-length'))
if (Number.isFinite(contentLength) && contentLength > MAX_REQUEST_BODY_BYTES) {
return jsonError(413, 'Request body is too large')
}
if (!request.headers.get('content-type')?.includes('application/json')) {
return jsonError(415, 'Content-Type must be application/json')
}
const requestText = await request.text()
if (requestText.length > MAX_REQUEST_BODY_BYTES) {
return jsonError(413, 'Request body is too large')
}
let body: ModelProxyRequestBody
try {
body = JSON.parse(requestText) as ModelProxyRequestBody
} catch {
return jsonError(400, 'Request body must be valid JSON')
}
if (
typeof body.baseUrl !== 'string' ||
body.baseUrl.length === 0 ||
body.baseUrl.length > MAX_BASE_URL_LENGTH
) {
return jsonError(400, 'baseUrl must be a valid HTTPS URL')
}
if (typeof body.apiKey !== 'undefined' && typeof body.apiKey !== 'string') {
return jsonError(400, 'apiKey must be a string')
}
if (body.apiKey && body.apiKey.length > MAX_API_KEY_LENGTH) {
return jsonError(400, 'apiKey is too long')
}
const normalizedBaseUrl = body.baseUrl.trim().replace(/\/+$/, '')
let baseUrl: URL
try {
baseUrl = new URL(normalizedBaseUrl)
} catch {
return jsonError(400, 'baseUrl must be a valid HTTPS URL')
}
if (
baseUrl.protocol !== 'https:' ||
baseUrl.username ||
baseUrl.password ||
baseUrl.search ||
baseUrl.hash
) {
return jsonError(400, 'baseUrl must be a valid HTTPS URL')
}
const allowedOrigins = getAllowedOrigins(env.MODEL_PROXY_ALLOWED_ORIGINS)
if (allowedOrigins.size === 0) {
return jsonError(
503,
'Model proxy is not configured. Ask the site administrator to configure MODEL_PROXY_ALLOWED_ORIGINS.'
)
}
if (!allowedOrigins.has(baseUrl.origin)) {
return jsonError(403, 'This gateway origin is not allowed by the model proxy')
}
const headers = new Headers({ Accept: 'application/json' })
if (body.apiKey?.trim()) {
headers.set('Authorization', `Bearer ${body.apiKey.trim()}`)
}
let upstreamResponse: Response
try {
upstreamResponse = await fetch(`${normalizedBaseUrl}/models`, {
method: 'GET',
headers,
redirect: 'error',
})
} catch {
return jsonError(502, 'Unable to contact the configured gateway')
}
const upstreamBody = await upstreamResponse.json().catch(() => ({
error: 'Gateway returned an invalid JSON response',
}))
return Response.json(upstreamBody, {
status: upstreamResponse.status,
headers: { 'cache-control': 'no-store' },
})
}
+1 -1
View File
@@ -10,5 +10,5 @@
"verbatimModuleSyntax": true,
"moduleDetection": "force"
},
"include": ["index.ts", "modelProxy.ts"]
"include": ["index.ts"]
}
+1 -1
View File
@@ -7,6 +7,6 @@
"directory": "./dist",
"binding": "ASSETS",
"not_found_handling": "single-page-application",
"run_worker_first": ["/i/*", "/api/*"]
"run_worker_first": ["/i/*"]
}
}