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.
|
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 each gateway to allow the deployed app origin and request headers:
|
||||||
|
|
||||||
Configure it in **Workers & Pages → 9router-config-generate → Settings → Variables and Secrets** as a plaintext variable:
|
|
||||||
|
|
||||||
```txt
|
```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.
|
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.
|
||||||
|
|
||||||
> 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.
|
|
||||||
|
|
||||||
## Cloudflare resources used
|
## Cloudflare resources used
|
||||||
|
|
||||||
- Cloudflare Workers
|
- Cloudflare Workers
|
||||||
- Workers Static Assets (`ASSETS` binding)
|
- Workers Static Assets (`ASSETS` binding)
|
||||||
- Optional custom domain
|
- Optional custom domain
|
||||||
- Optional plaintext variable: `MODEL_PROXY_ALLOWED_ORIGINS`
|
|
||||||
|
|
||||||
No KV, D1, R2, Queue, Durable Object, or Worker secret is required.
|
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[]> {
|
export async function fetchRemoteModels(connection: ConnectionConfig): Promise<Model[]> {
|
||||||
const trimmedBase = connection.baseUrl.replace(/\/+$/, '')
|
const trimmedBase = connection.baseUrl.replace(/\/+$/, '')
|
||||||
const isHttpUrl = trimmedBase.startsWith('http://') || trimmedBase.startsWith('https://')
|
let data: RawModelsResponse
|
||||||
|
|
||||||
let data: RawModelsResponse | null = null
|
// Fetch the user-provided gateway directly. The gateway must permit this app's origin with CORS.
|
||||||
|
|
||||||
// 1. Direct fetch with the standard Authorization header. Never put an API key in a URL.
|
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
@@ -77,43 +75,16 @@ export async function fetchRemoteModels(connection: ConnectionConfig): Promise<M
|
|||||||
headers,
|
headers,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (directRes.ok) {
|
if (!directRes.ok) {
|
||||||
data = await directRes.json()
|
throw new Error(`Gateway responded with ${directRes.status} ${directRes.statusText}`)
|
||||||
}
|
}
|
||||||
} catch {
|
data = await directRes.json()
|
||||||
// If direct CORS/network fails, fallback to standard Authorization header or proxy
|
} catch (err: unknown) {
|
||||||
}
|
const reason = err instanceof Error ? err.message : 'Unknown network error'
|
||||||
|
throw new Error(
|
||||||
// 2. Fallback to the local Vite / allowlisted Worker proxy when direct CORS fails.
|
`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}`,
|
||||||
if (!data && isHttpUrl) {
|
{ cause: err }
|
||||||
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 rawList = Array.isArray(data) ? data : (data.data || [])
|
const rawList = Array.isArray(data) ? data : (data.data || [])
|
||||||
|
|||||||
+1
-49
@@ -12,7 +12,7 @@ export default defineConfig({
|
|||||||
react(),
|
react(),
|
||||||
babel({ presets: [reactCompilerPreset()] }),
|
babel({ presets: [reactCompilerPreset()] }),
|
||||||
{
|
{
|
||||||
name: 'router-proxy-middleware',
|
name: 'install-script-middleware',
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
// Dev endpoint for 1-click install scripts: /i/:token.(sh|ps1)
|
// Dev endpoint for 1-click install scripts: /i/:token.(sh|ps1)
|
||||||
server.middlewares.use((req: IncomingMessage, res: ServerResponse, next: () => void) => {
|
server.middlewares.use((req: IncomingMessage, res: ServerResponse, next: () => void) => {
|
||||||
@@ -32,54 +32,6 @@ export default defineConfig({
|
|||||||
next()
|
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 { renderInstallScript } from '../src/services/installEndpoint.ts'
|
||||||
import { proxyModelRequest } from './modelProxy.ts'
|
|
||||||
|
|
||||||
interface WorkerEnv {
|
interface WorkerEnv {
|
||||||
ASSETS: {
|
ASSETS: {
|
||||||
fetch(request: Request): Promise<Response>
|
fetch(request: Request): Promise<Response>
|
||||||
}
|
}
|
||||||
MODEL_PROXY_ALLOWED_ORIGINS?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -26,10 +24,6 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === '/api/fetch-models') {
|
|
||||||
return proxyModelRequest(request, env)
|
|
||||||
}
|
|
||||||
|
|
||||||
return env.ASSETS.fetch(request)
|
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,
|
"verbatimModuleSyntax": true,
|
||||||
"moduleDetection": "force"
|
"moduleDetection": "force"
|
||||||
},
|
},
|
||||||
"include": ["index.ts", "modelProxy.ts"]
|
"include": ["index.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -7,6 +7,6 @@
|
|||||||
"directory": "./dist",
|
"directory": "./dist",
|
||||||
"binding": "ASSETS",
|
"binding": "ASSETS",
|
||||||
"not_found_handling": "single-page-application",
|
"not_found_handling": "single-page-application",
|
||||||
"run_worker_first": ["/i/*", "/api/*"]
|
"run_worker_first": ["/i/*"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user