mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 10:38:32 +08:00
fix(cli): --insecure also skips TLS certificate verification (#38531)
This commit is contained in:
parent
5d61318860
commit
ae1e180b54
@ -113,6 +113,21 @@ describe('Registry (pure)', () => {
|
||||
expect(active?.ctx.account.email).toBe('a@x')
|
||||
})
|
||||
|
||||
it('resolveActive returns the active context with insecureTls', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setInsecureTls('h1', true)
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
expect(reg.resolveActive()?.insecureTls).toBe(true)
|
||||
})
|
||||
|
||||
it('setInsecureTls is a no-op for an unknown host', () => {
|
||||
const reg = baseReg()
|
||||
reg.setInsecureTls('missing', true)
|
||||
expect(reg.hosts.missing).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolveActive returns undefined for each missing pointer', () => {
|
||||
const reg = baseReg()
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
|
||||
@ -41,6 +41,7 @@ export type AccountContext = z.infer<typeof AccountContextSchema>
|
||||
|
||||
export const HostEntrySchema = z.object({
|
||||
scheme: z.string().optional(),
|
||||
insecure_tls: z.boolean().optional(),
|
||||
current_account: z.string().optional(),
|
||||
accounts: z.record(z.string(), AccountContextSchema).default({}),
|
||||
})
|
||||
@ -58,6 +59,7 @@ export type ActiveContext = {
|
||||
readonly email: string
|
||||
readonly ctx: AccountContext
|
||||
readonly scheme?: string
|
||||
readonly insecureTls?: boolean
|
||||
}
|
||||
|
||||
export function notLoggedInError(hint = 'run \'difyctl auth login\''): BaseError {
|
||||
@ -104,7 +106,7 @@ export class Registry {
|
||||
const ctx = entry.accounts[email]
|
||||
if (ctx === undefined)
|
||||
return undefined
|
||||
return { host, email, ctx, scheme: entry.scheme }
|
||||
return { host, email, ctx, scheme: entry.scheme, insecureTls: entry.insecure_tls }
|
||||
}
|
||||
|
||||
requireActive(hint?: string): ActiveContext {
|
||||
@ -157,6 +159,12 @@ export class Registry {
|
||||
entry.scheme = scheme
|
||||
}
|
||||
|
||||
setInsecureTls(host: string, insecure: boolean): void {
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry !== undefined)
|
||||
entry.insecure_tls = insecure
|
||||
}
|
||||
|
||||
activate(host: string, email: string, ctx: AccountContext): void {
|
||||
this.upsert(host, email, ctx)
|
||||
this.setHost(host)
|
||||
|
||||
@ -13,7 +13,7 @@ import { formatErrorForCli } from '@/errors/format'
|
||||
import { createHttpClient } from '@/http/client'
|
||||
import { getTokenStore } from '@/store/manager'
|
||||
import { realStreams } from '@/sys/io/streams'
|
||||
import { hostWithScheme, openAPIBase } from '@/util/host'
|
||||
import { activeHostInfo, openAPIBase } from '@/util/host'
|
||||
import { enforceDifyVersion } from '@/version/enforce'
|
||||
import { versionInfo } from '@/version/info'
|
||||
import { maybeNudgeCompat } from '@/version/nudge'
|
||||
@ -50,17 +50,17 @@ export async function buildAuthedContext(
|
||||
if (bearer === '')
|
||||
fail(cmd, opts, io)
|
||||
|
||||
const host = hostWithScheme(active.host, active.scheme)
|
||||
const { host, insecure } = activeHostInfo(active)
|
||||
const retryAttempts = resolveRetryAttempts({ flag: opts.retryFlag, env: getEnv })
|
||||
const http = createHttpClient({ baseURL: openAPIBase(host), bearer, retryAttempts })
|
||||
const http = createHttpClient({ baseURL: openAPIBase(host), bearer, retryAttempts, insecure })
|
||||
|
||||
const cache = opts.withCache === true ? await loadAppInfoCache() : undefined
|
||||
|
||||
// Hard gate: refuse a server too old for this difyctl (throws → exit 6).
|
||||
// Cached per host (1h) so most commands don't re-probe. Then the soft nudge
|
||||
// handles the "server too new" direction.
|
||||
await enforceDifyVersion(host)
|
||||
await runCompatNudge({ host, io })
|
||||
await enforceDifyVersion(host, { insecure })
|
||||
await runCompatNudge({ host, insecure, io })
|
||||
|
||||
return { reg, active, store, http, host, io, cache }
|
||||
}
|
||||
@ -74,6 +74,7 @@ function fail(cmd: Pick<Command, 'error'>, opts: AuthedContextOptions, io: IOStr
|
||||
// command flows through it without per-command wiring.
|
||||
async function runCompatNudge(opts: {
|
||||
readonly host: string
|
||||
readonly insecure: boolean
|
||||
readonly io: IOStreams
|
||||
}): Promise<void> {
|
||||
try {
|
||||
@ -81,7 +82,7 @@ async function runCompatNudge(opts: {
|
||||
await maybeNudgeCompat(opts.host, {
|
||||
store,
|
||||
probe: async (host) => {
|
||||
const http = createHttpClient({ baseURL: openAPIBase(host), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })
|
||||
const http = createHttpClient({ baseURL: openAPIBase(host), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0, insecure: opts.insecure })
|
||||
return new MetaClient(http).serverVersion()
|
||||
},
|
||||
emit: line => opts.io.err.write(line),
|
||||
|
||||
@ -27,7 +27,7 @@ export default class Login extends DifyCommand {
|
||||
default: false,
|
||||
}),
|
||||
'insecure': Flags.boolean({
|
||||
description: 'allow http:// hosts (local-dev only)',
|
||||
description: 'allow http:// hosts and skip TLS certificate verification (local-dev only)',
|
||||
default: false,
|
||||
}),
|
||||
}
|
||||
@ -40,7 +40,7 @@ export default class Login extends DifyCommand {
|
||||
noBrowser: flags['no-browser'],
|
||||
insecure: flags.insecure,
|
||||
verifyServer: async (host) => {
|
||||
await enforceDifyVersion(host, { forceFresh: true })
|
||||
await enforceDifyVersion(host, { forceFresh: true, insecure: flags.insecure })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -44,7 +44,7 @@ export async function runLogin(opts: LoginOptions): Promise<Registry> {
|
||||
const host = await resolveLoginHost(opts, insecure)
|
||||
const label = opts.deviceLabel ?? defaultDeviceLabel()
|
||||
|
||||
const api = opts.api ?? new DeviceFlowApi(createHttpClient({ baseURL: openAPIBase(host) }))
|
||||
const api = opts.api ?? new DeviceFlowApi(createHttpClient({ baseURL: openAPIBase(host), insecure }))
|
||||
const code = await api.requestCode({ device_label: label })
|
||||
|
||||
renderCodePrompt(opts.io.err, cs, code)
|
||||
@ -88,6 +88,7 @@ export async function runLogin(opts: LoginOptions): Promise<Registry> {
|
||||
reg.token_storage = storeBundle.mode
|
||||
reg.activate(display, email, ctx)
|
||||
applyScheme(reg, display, host)
|
||||
reg.setInsecureTls(display, insecure)
|
||||
await reg.save()
|
||||
|
||||
renderLoggedIn(opts.io.out, cs, host, success)
|
||||
|
||||
@ -6,7 +6,7 @@ import { createHttpClient } from '@/http/client'
|
||||
import { getTokenStore } from '@/store/manager'
|
||||
import { runWithSpinner } from '@/sys/io/spinner'
|
||||
import { realStreams } from '@/sys/io/streams'
|
||||
import { hostWithScheme, openAPIBase } from '@/util/host'
|
||||
import { activeHostInfo, openAPIBase } from '@/util/host'
|
||||
import { runLogout } from './logout.js'
|
||||
|
||||
export default class Logout extends DifyCommand {
|
||||
@ -32,7 +32,8 @@ export default class Logout extends DifyCommand {
|
||||
}
|
||||
catch { /* keyring locked — skip remote revocation, local cleanup still runs */ }
|
||||
if (bearer !== '') {
|
||||
http = createHttpClient({ baseURL: openAPIBase(hostWithScheme(active.host, active.scheme)), bearer, retryAttempts: 0 })
|
||||
const { host, insecure } = activeHostInfo(active)
|
||||
http = createHttpClient({ baseURL: openAPIBase(host), bearer, retryAttempts: 0, insecure })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
66
cli/src/http/client-tls.test.ts
Normal file
66
cli/src/http/client-tls.test.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import type { Buffer } from 'node:buffer'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import * as https from 'node:https'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createHttpClient } from './client.js'
|
||||
|
||||
function generateSelfSignedCert(dir: string): { key: Buffer, cert: Buffer } {
|
||||
const keyPath = join(dir, 'key.pem')
|
||||
const certPath = join(dir, 'cert.pem')
|
||||
execFileSync('openssl', [
|
||||
'req',
|
||||
'-x509',
|
||||
'-newkey',
|
||||
'rsa:2048',
|
||||
'-nodes',
|
||||
'-keyout',
|
||||
keyPath,
|
||||
'-out',
|
||||
certPath,
|
||||
'-days',
|
||||
'1',
|
||||
'-subj',
|
||||
'/CN=localhost',
|
||||
], { stdio: ['ignore', 'ignore', 'pipe'] })
|
||||
return { key: readFileSync(keyPath), cert: readFileSync(certPath) }
|
||||
}
|
||||
|
||||
// A real server, not a fetch mock, so this also covers Bun's native `tls`
|
||||
// fetch option (ignored by Node, which only reads undici's `dispatcher`).
|
||||
describe('createHttpClient against a real self-signed TLS server', () => {
|
||||
let server: https.Server
|
||||
let baseURL: string
|
||||
let certDir: string
|
||||
|
||||
beforeAll(async () => {
|
||||
certDir = mkdtempSync(join(tmpdir(), 'difyctl-tls-test-'))
|
||||
const { key, cert } = generateSelfSignedCert(certDir)
|
||||
server = https.createServer({ key, cert }, (_req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify({ ok: true }))
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, resolve))
|
||||
const port = (server.address() as AddressInfo).port
|
||||
baseURL = `https://localhost:${port}/`
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>(resolve => server.close(() => resolve()))
|
||||
rmSync(certDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('rejects the self-signed cert by default', async () => {
|
||||
const http = createHttpClient({ baseURL, retryAttempts: 0 })
|
||||
await expect(http.get('')).rejects.toBeDefined()
|
||||
})
|
||||
|
||||
it('accepts the self-signed cert when insecure: true', async () => {
|
||||
const http = createHttpClient({ baseURL, retryAttempts: 0, insecure: true })
|
||||
const res = await http.get<{ ok: boolean }>('')
|
||||
expect(res.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
@ -38,6 +38,7 @@ type ClientState = {
|
||||
readonly logger: HttpLogger | undefined
|
||||
readonly originalOptions: ClientOptions
|
||||
readonly dispatcher: ReturnType<typeof proxyDispatcher>
|
||||
readonly insecure: boolean
|
||||
}
|
||||
|
||||
function toArray<T>(value: T | T[] | undefined): T[] {
|
||||
@ -74,7 +75,8 @@ function compileState(opts: ClientOptions): ClientState {
|
||||
hooks: { onRequest, onResponse, onRequestError, onResponseError },
|
||||
logger: opts.logger,
|
||||
originalOptions: opts,
|
||||
dispatcher: proxyDispatcher(),
|
||||
dispatcher: proxyDispatcher({ insecure: opts.insecure }),
|
||||
insecure: opts.insecure ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
@ -171,9 +173,16 @@ async function execute(
|
||||
|
||||
await runHooks(state.hooks.onRequest, ctx)
|
||||
|
||||
const init: RequestInit & { dispatcher?: unknown, verbose?: boolean } = { signal }
|
||||
// Two runtimes, two options: Node's fetch reads undici's `dispatcher` (used
|
||||
// below for TLS-skip + proxy routing); Bun's native fetch — what the compiled
|
||||
// difyctl binary actually runs on — ignores `dispatcher` entirely and instead
|
||||
// needs its own `tls` option. Set both; each runtime ignores the one it
|
||||
// doesn't understand.
|
||||
const init: RequestInit & { dispatcher?: unknown, tls?: { rejectUnauthorized: boolean }, verbose?: boolean } = { signal }
|
||||
if (state.dispatcher !== undefined)
|
||||
init.dispatcher = state.dispatcher
|
||||
if (state.insecure)
|
||||
init.tls = { rejectUnauthorized: false }
|
||||
if (isVerbose())
|
||||
init.verbose = true
|
||||
|
||||
|
||||
@ -52,4 +52,28 @@ describe('proxyDispatcher', () => {
|
||||
expect(proxyDispatcher()).toBe(first)
|
||||
await first?.close()
|
||||
})
|
||||
|
||||
it('builds a plain Agent with TLS verification disabled when insecure is set and no proxy env', async () => {
|
||||
const { proxyDispatcher } = await import('./proxy.js')
|
||||
const d = proxyDispatcher({ insecure: true })
|
||||
expect(d?.constructor.name).toBe('Agent')
|
||||
await d?.close()
|
||||
})
|
||||
|
||||
it('builds an EnvHttpProxyAgent with TLS verification disabled when insecure and a proxy are both set', async () => {
|
||||
process.env.HTTP_PROXY = 'http://127.0.0.1:8888'
|
||||
const { proxyDispatcher } = await import('./proxy.js')
|
||||
const d = proxyDispatcher({ insecure: true })
|
||||
expect(d?.constructor.name).toBe('EnvHttpProxyAgent')
|
||||
await d?.close()
|
||||
})
|
||||
|
||||
it('re-resolves when the insecure flag changes for the same call site', async () => {
|
||||
const { proxyDispatcher } = await import('./proxy.js')
|
||||
const secure = proxyDispatcher({ insecure: false })
|
||||
expect(secure).toBeUndefined()
|
||||
const insecure = proxyDispatcher({ insecure: true })
|
||||
expect(insecure?.constructor.name).toBe('Agent')
|
||||
await insecure?.close()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { EnvHttpProxyAgent } from 'undici'
|
||||
import type { Dispatcher } from 'undici'
|
||||
import { Agent, EnvHttpProxyAgent } from 'undici'
|
||||
|
||||
const PROXY_ENV_KEYS = ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy'] as const
|
||||
|
||||
@ -6,18 +7,30 @@ export function hasProxyEnv(): boolean {
|
||||
return PROXY_ENV_KEYS.some(k => (process.env[k] ?? '') !== '')
|
||||
}
|
||||
|
||||
let resolved = false
|
||||
let agent: EnvHttpProxyAgent | undefined
|
||||
export type ProxyDispatcherOptions = {
|
||||
// --insecure on a self-signed https:// host: skip certificate verification
|
||||
// (local-dev only, same flag that allows plain http:// hosts).
|
||||
readonly insecure?: boolean
|
||||
}
|
||||
|
||||
let resolvedKey: string | undefined
|
||||
let agent: Dispatcher | undefined
|
||||
|
||||
// Node's global fetch ignores HTTP_PROXY / HTTPS_PROXY / NO_PROXY. When a proxy
|
||||
// var is set, route requests through an EnvHttpProxyAgent (it also reads the
|
||||
// lowercase variants and honours NO_PROXY); when none is set, return undefined so
|
||||
// fetch keeps Node's default global dispatcher untouched. Resolved once per
|
||||
// process — proxy env vars are fixed for a single CLI invocation.
|
||||
export function proxyDispatcher(): EnvHttpProxyAgent | undefined {
|
||||
if (!resolved) {
|
||||
agent = hasProxyEnv() ? new EnvHttpProxyAgent() : undefined
|
||||
resolved = true
|
||||
// lowercase variants and honours NO_PROXY); when none is set and TLS verification
|
||||
// isn't being skipped, return undefined so fetch keeps Node's default global
|
||||
// dispatcher untouched. Resolved once per (proxy env, insecure) combination —
|
||||
// both are fixed for a single CLI invocation.
|
||||
export function proxyDispatcher(opts: ProxyDispatcherOptions = {}): Dispatcher | undefined {
|
||||
const insecure = opts.insecure ?? false
|
||||
const key = `${hasProxyEnv()}:${insecure}`
|
||||
if (resolvedKey !== key) {
|
||||
const tls = insecure ? { rejectUnauthorized: false } : undefined
|
||||
agent = hasProxyEnv()
|
||||
? new EnvHttpProxyAgent(tls !== undefined ? { connect: tls, requestTls: tls, proxyTls: tls } : undefined)
|
||||
: (tls !== undefined ? new Agent({ connect: tls }) : undefined)
|
||||
resolvedKey = key
|
||||
}
|
||||
return agent
|
||||
}
|
||||
|
||||
@ -75,6 +75,8 @@ export type ClientOptions = {
|
||||
readonly retryAttempts?: number
|
||||
readonly logger?: HttpLogger
|
||||
readonly hooks?: Hooks
|
||||
// Skip TLS certificate verification (local-dev only, self-signed hosts).
|
||||
readonly insecure?: boolean
|
||||
}
|
||||
|
||||
export type HttpClient = {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
|
||||
@ -44,6 +45,16 @@ export function hostWithScheme(host: string, scheme: string | undefined): string
|
||||
return `${proto}://${host}`
|
||||
}
|
||||
|
||||
// Every call site that builds an HTTP client for the active host needs both its
|
||||
// scheme-qualified host and whether TLS verification is disabled for it — derive
|
||||
// them together so neither is forgotten independently.
|
||||
export function activeHostInfo(active: Pick<ActiveContext, 'host' | 'scheme' | 'insecureTls'>): { host: string, insecure: boolean } {
|
||||
return {
|
||||
host: hostWithScheme(active.host, active.scheme),
|
||||
insecure: active.insecureTls === true,
|
||||
}
|
||||
}
|
||||
|
||||
export function bareHost(raw: string): string {
|
||||
try {
|
||||
const u = new URL(raw)
|
||||
|
||||
@ -16,15 +16,18 @@ const UPGRADE_HINT
|
||||
+ '(https://docs.dify.ai/en/getting-started/install-self-hosted)'
|
||||
|
||||
// /_version is unauthenticated; same timeout/no-retry budget as the auto-nudge probe.
|
||||
const defaultProbe: ServerVersionProbe = async (host) => {
|
||||
const http = createHttpClient({ baseURL: openAPIBase(host), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })
|
||||
return new MetaClient(http).serverVersion()
|
||||
function buildDefaultProbe(insecure: boolean): ServerVersionProbe {
|
||||
return async (host) => {
|
||||
const http = createHttpClient({ baseURL: openAPIBase(host), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0, insecure })
|
||||
return new MetaClient(http).serverVersion()
|
||||
}
|
||||
}
|
||||
|
||||
export type EnforceOptions = {
|
||||
readonly probe?: ServerVersionProbe
|
||||
readonly store?: CompatStore
|
||||
readonly forceFresh?: boolean
|
||||
readonly insecure?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@ -45,7 +48,7 @@ export async function enforceDifyVersion(
|
||||
if (opts.forceFresh !== true && store.isFreshCompatible(host))
|
||||
return undefined
|
||||
|
||||
const probe = opts.probe ?? defaultProbe
|
||||
const probe = opts.probe ?? buildDefaultProbe(opts.insecure === true)
|
||||
let server: ServerVersionResponse
|
||||
try {
|
||||
server = await probe(host)
|
||||
|
||||
@ -6,7 +6,7 @@ import { META_PROBE_TIMEOUT_MS, MetaClient } from '@/api/meta'
|
||||
import { Registry } from '@/auth/hosts'
|
||||
import { createHttpClient } from '@/http/client'
|
||||
import { arch, platform } from '@/sys/index'
|
||||
import { hostWithScheme, openAPIBase } from '@/util/host'
|
||||
import { activeHostInfo, openAPIBase } from '@/util/host'
|
||||
import { difyCompat, evaluateCompat } from './compat.js'
|
||||
import { versionInfo } from './info.js'
|
||||
|
||||
@ -51,9 +51,11 @@ const defaultLoadActive = async (): Promise<ActiveContext | undefined> => {
|
||||
return (await Registry.load()).resolveActive()
|
||||
}
|
||||
|
||||
const defaultProbe: MetaProbe = async (endpoint) => {
|
||||
const http = createHttpClient({ baseURL: openAPIBase(endpoint), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })
|
||||
return new MetaClient(http).serverVersion()
|
||||
function buildDefaultProbe(insecure: boolean): MetaProbe {
|
||||
return async (endpoint) => {
|
||||
const http = createHttpClient({ baseURL: openAPIBase(endpoint), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0, insecure })
|
||||
return new MetaClient(http).serverVersion()
|
||||
}
|
||||
}
|
||||
|
||||
function buildClientBlock(): ClientBlock {
|
||||
@ -92,7 +94,6 @@ export async function runVersionProbe(opts: RunVersionProbeOptions): Promise<Ver
|
||||
}
|
||||
|
||||
const loadActive = opts.loadActive ?? defaultLoadActive
|
||||
const probe = opts.probe ?? defaultProbe
|
||||
|
||||
let active: ActiveContext | undefined
|
||||
let loadFailed = false
|
||||
@ -112,7 +113,8 @@ export async function runVersionProbe(opts: RunVersionProbeOptions): Promise<Ver
|
||||
}
|
||||
}
|
||||
|
||||
const endpoint = hostWithScheme(active.host, active.scheme)
|
||||
const { host: endpoint, insecure } = activeHostInfo(active)
|
||||
const probe = opts.probe ?? buildDefaultProbe(insecure)
|
||||
|
||||
let serverInfo: ServerVersionResponse | undefined
|
||||
try {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user