mirror of
https://github.com/Nezumi-2711/9router-config-generate.git
synced 2026-09-22 05:31:47 +00:00
fix: remove the proxy url
This commit is contained in:
@@ -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.
@@ -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
@@ -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 }))
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
@@ -10,5 +10,5 @@
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force"
|
||||
},
|
||||
"include": ["index.ts", "modelProxy.ts"]
|
||||
"include": ["index.ts"]
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,6 +7,6 @@
|
||||
"directory": "./dist",
|
||||
"binding": "ASSETS",
|
||||
"not_found_handling": "single-page-application",
|
||||
"run_worker_first": ["/i/*", "/api/*"]
|
||||
"run_worker_first": ["/i/*"]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user