diff --git a/cli/src/auth/hosts.test.ts b/cli/src/auth/hosts.test.ts index 112538ccbb9..337b785a4c4 100644 --- a/cli/src/auth/hosts.test.ts +++ b/cli/src/auth/hosts.test.ts @@ -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() diff --git a/cli/src/auth/hosts.ts b/cli/src/auth/hosts.ts index 29305db951e..5df8cdabdbe 100644 --- a/cli/src/auth/hosts.ts +++ b/cli/src/auth/hosts.ts @@ -41,6 +41,7 @@ export type AccountContext = z.infer 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) diff --git a/cli/src/commands/_shared/authed-command.ts b/cli/src/commands/_shared/authed-command.ts index 1b0d6803180..d40b9342041 100644 --- a/cli/src/commands/_shared/authed-command.ts +++ b/cli/src/commands/_shared/authed-command.ts @@ -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, 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 { 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), diff --git a/cli/src/commands/auth/login/index.ts b/cli/src/commands/auth/login/index.ts index 214f38c3697..0b702262932 100644 --- a/cli/src/commands/auth/login/index.ts +++ b/cli/src/commands/auth/login/index.ts @@ -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 }) }, }) } diff --git a/cli/src/commands/auth/login/login.ts b/cli/src/commands/auth/login/login.ts index f0eb14dc8e1..7777f8c7c18 100644 --- a/cli/src/commands/auth/login/login.ts +++ b/cli/src/commands/auth/login/login.ts @@ -44,7 +44,7 @@ export async function runLogin(opts: LoginOptions): Promise { 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 { 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) diff --git a/cli/src/commands/auth/logout/index.ts b/cli/src/commands/auth/logout/index.ts index 6476b1726e8..786d525c748 100644 --- a/cli/src/commands/auth/logout/index.ts +++ b/cli/src/commands/auth/logout/index.ts @@ -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 }) } } diff --git a/cli/src/http/client-tls.test.ts b/cli/src/http/client-tls.test.ts new file mode 100644 index 00000000000..089b677fa97 --- /dev/null +++ b/cli/src/http/client-tls.test.ts @@ -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(resolve => server.listen(0, resolve)) + const port = (server.address() as AddressInfo).port + baseURL = `https://localhost:${port}/` + }) + + afterAll(async () => { + await new Promise(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) + }) +}) diff --git a/cli/src/http/client.ts b/cli/src/http/client.ts index 940aee9152b..30f8ca27441 100644 --- a/cli/src/http/client.ts +++ b/cli/src/http/client.ts @@ -38,6 +38,7 @@ type ClientState = { readonly logger: HttpLogger | undefined readonly originalOptions: ClientOptions readonly dispatcher: ReturnType + readonly insecure: boolean } function toArray(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 diff --git a/cli/src/http/proxy.test.ts b/cli/src/http/proxy.test.ts index 80b36d0f295..2c1f386f320 100644 --- a/cli/src/http/proxy.test.ts +++ b/cli/src/http/proxy.test.ts @@ -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() + }) }) diff --git a/cli/src/http/proxy.ts b/cli/src/http/proxy.ts index 5e58936f91a..bd9a745c922 100644 --- a/cli/src/http/proxy.ts +++ b/cli/src/http/proxy.ts @@ -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 } diff --git a/cli/src/http/types.ts b/cli/src/http/types.ts index d209e97460c..a022a253296 100644 --- a/cli/src/http/types.ts +++ b/cli/src/http/types.ts @@ -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 = { diff --git a/cli/src/util/host.ts b/cli/src/util/host.ts index 1042b0875ea..453a68149ad 100644 --- a/cli/src/util/host.ts +++ b/cli/src/util/host.ts @@ -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): { 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) diff --git a/cli/src/version/enforce.ts b/cli/src/version/enforce.ts index d1b9fe87da9..8c995a912d1 100644 --- a/cli/src/version/enforce.ts +++ b/cli/src/version/enforce.ts @@ -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) diff --git a/cli/src/version/probe.ts b/cli/src/version/probe.ts index 266910e7c05..05ab3ea84e3 100644 --- a/cli/src/version/probe.ts +++ b/cli/src/version/probe.ts @@ -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 => { 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