From abfb0252f2a54aff64ab1ee9523c67d450e5fff8 Mon Sep 17 00:00:00 2001 From: BugMaster Date: Wed, 15 Jul 2026 15:07:30 +0800 Subject: [PATCH] feat(channel): support insecure TLS verification Allow certificate verification bypass per channel for model discovery and upstream requests without affecting other channels. --- .../components/channels-action-dialog.tsx | 25 +++++++- .../src/features/channels/data/channels.ts | 15 ++++- frontend/src/features/channels/data/schema.ts | 1 + frontend/src/features/channels/utils/merge.ts | 1 + frontend/src/locales/en/channels.json | 2 + frontend/src/locales/zh-CN/channels.json | 2 + internal/objects/channel.go | 4 ++ internal/server/biz/channel_llm.go | 17 ++++-- internal/server/biz/channel_llm_test.go | 34 +++++++++++ internal/server/biz/model_fetcher.go | 16 +++-- internal/server/biz/model_fetcher_test.go | 32 ++++++++++ internal/server/gql/axonhub.graphql | 3 + internal/server/gql/generated.go | 58 ++++++++++++++++++- llm/httpclient/client.go | 47 ++++++++++++++- llm/httpclient/client_test.go | 43 ++++++++++++++ 15 files changed, 286 insertions(+), 14 deletions(-) diff --git a/frontend/src/features/channels/components/channels-action-dialog.tsx b/frontend/src/features/channels/components/channels-action-dialog.tsx index 10b9001fb..b4faaedc0 100644 --- a/frontend/src/features/channels/components/channels-action-dialog.tsx +++ b/frontend/src/features/channels/components/channels-action-dialog.tsx @@ -381,6 +381,7 @@ export function ChannelsActionDialog({ currentRow, duplicateFromRow, open, onOpe const [proxyUrl, setProxyUrl] = useState(() => initialRow?.settings?.proxy?.url || ''); const [proxyUsername, setProxyUsername] = useState(() => initialRow?.settings?.proxy?.username || ''); const [proxyPassword, setProxyPassword] = useState(() => initialRow?.settings?.proxy?.password || ''); + const [insecureSkipVerify, setInsecureSkipVerify] = useState(() => initialRow?.settings?.insecureSkipVerify ?? false); const [passThroughUserAgent, setPassThroughUserAgent] = useState(() => { return initialRow?.settings?.passThroughUserAgent ?? null; }); @@ -1216,6 +1217,7 @@ export function ChannelsActionDialog({ currentRow, duplicateFromRow, open, onOpe if (isEdit && currentRow) { const nextSettings = mergeChannelSettingsForUpdate(settingsForSubmit, { + insecureSkipVerify, passThroughUserAgent, passThroughBody, retryableStatusCodes, @@ -1260,6 +1262,7 @@ export function ChannelsActionDialog({ currentRow, duplicateFromRow, open, onOpe const nextSettings = mergeChannelSettingsForUpdate(settingsForSubmit, { proxy: proxyConfig, + insecureSkipVerify, passThroughUserAgent, passThroughBody, retryableStatusCodes, @@ -1422,6 +1425,7 @@ export function ChannelsActionDialog({ currentRow, duplicateFromRow, open, onOpe baseURL, apiKey: firstApiKey || undefined, channelID: isEdit ? currentRow?.id : undefined, + insecureSkipVerify, }); if (result.error) { @@ -1444,7 +1448,7 @@ export function ChannelsActionDialog({ currentRow, duplicateFromRow, open, onOpe } catch (_error) { // Error is already handled by the mutation } - }, [fetchModels, form, isEdit, currentRow]); + }, [fetchModels, form, insecureSkipVerify, isEdit, currentRow]); const handleSyncNow = useCallback(async () => { if (!currentRow) return []; @@ -1691,6 +1695,7 @@ export function ChannelsActionDialog({ currentRow, duplicateFromRow, open, onOpe setProxyUrl(initialRow?.settings?.proxy?.url || ''); setProxyUsername(initialRow?.settings?.proxy?.username || ''); setProxyPassword(initialRow?.settings?.proxy?.password || ''); + setInsecureSkipVerify(initialRow?.settings?.insecureSkipVerify ?? false); setPassThroughUserAgent(initialRow?.settings?.passThroughUserAgent ?? null); setPassThroughBody(initialRow?.settings?.passThroughBody ?? null); setRetryableStatusCodesText(formatRetryableStatusCodes(initialRow?.settings?.retryableStatusCodes)); @@ -2173,6 +2178,24 @@ export function ChannelsActionDialog({ currentRow, duplicateFromRow, open, onOpe )} /> + + + {t('channels.dialogs.fields.insecureSkipVerify.label')} + +
+
+ setInsecureSkipVerify(checked === true)} + /> +

+ {t('channels.dialogs.fields.insecureSkipVerify.warning')} +

+
+
+
+ {(!(isCodexType || isClaudeCodeType || isCopilotType) || authMode === 'third-party') && selectedProvider !== 'antigravity' && selectedType !== 'anthropic_gcp' && ( diff --git a/frontend/src/features/channels/data/channels.ts b/frontend/src/features/channels/data/channels.ts index 4fbd60522..e2737653a 100644 --- a/frontend/src/features/channels/data/channels.ts +++ b/frontend/src/features/channels/data/channels.ts @@ -99,6 +99,7 @@ const CREATE_CHANNEL_MUTATION = ` username password } + insecureSkipVerify transformOptions { forceArrayInstructions forceArrayInputs @@ -172,6 +173,7 @@ const DUPLICATE_CHANNEL_MUTATION = ` username password } + insecureSkipVerify transformOptions { forceArrayInstructions forceArrayInputs @@ -245,6 +247,7 @@ const BULK_CREATE_CHANNELS_MUTATION = ` username password } + insecureSkipVerify transformOptions { forceArrayInstructions forceArrayInputs @@ -318,6 +321,7 @@ const UPDATE_CHANNEL_MUTATION = ` username password } + insecureSkipVerify transformOptions { forceArrayInstructions forceArrayInputs @@ -508,6 +512,7 @@ const BULK_IMPORT_CHANNELS_MUTATION = ` hideOriginalModels hideMappedModels lowercaseModelId + insecureSkipVerify transformOptions { forceArrayInstructions forceArrayInputs @@ -706,6 +711,7 @@ const BULK_UPDATE_CHANNEL_ORDERING_MUTATION = ` hideOriginalModels hideMappedModels lowercaseModelId + insecureSkipVerify transformOptions { forceArrayInstructions forceArrayInputs @@ -856,6 +862,7 @@ const QUERY_CHANNELS_QUERY = ` username password } + insecureSkipVerify transformOptions { forceArrayInstructions forceArrayInputs @@ -1617,7 +1624,13 @@ export function useFetchModels() { const { handleError } = useErrorHandler(); return useMutation({ - mutationFn: async (input: { channelType: string; baseURL: string; apiKey?: string; channelID?: string }) => { + mutationFn: async (input: { + channelType: string; + baseURL: string; + apiKey?: string; + channelID?: string; + insecureSkipVerify?: boolean; + }) => { try { const data = await graphqlRequest<{ fetchModels: { diff --git a/frontend/src/features/channels/data/schema.ts b/frontend/src/features/channels/data/schema.ts index b15f67da7..6cedf9d6b 100644 --- a/frontend/src/features/channels/data/schema.ts +++ b/frontend/src/features/channels/data/schema.ts @@ -254,6 +254,7 @@ export const channelSettingsSchema = z.object({ bodyOverrideOperations: z.array(overrideOperationSchema).optional(), headerOverrideOperations: z.array(overrideOperationSchema).optional(), proxy: proxyConfigSchema.optional().nullable(), + insecureSkipVerify: z.boolean().optional(), transformOptions: transformOptionsSchema.optional(), passThroughUserAgent: z.boolean().optional().nullable(), passThroughBody: z.boolean().optional().nullable(), diff --git a/frontend/src/features/channels/utils/merge.ts b/frontend/src/features/channels/utils/merge.ts index 88937ece9..ac3a9a32c 100644 --- a/frontend/src/features/channels/utils/merge.ts +++ b/frontend/src/features/channels/utils/merge.ts @@ -107,6 +107,7 @@ export function mergeChannelSettingsForUpdate( bodyOverrideOperations: pick('bodyOverrideOperations', existing?.bodyOverrideOperations ?? []), headerOverrideOperations: pick('headerOverrideOperations', existing?.headerOverrideOperations ?? []), proxy: pick('proxy', existing?.proxy ?? null), + insecureSkipVerify: pick('insecureSkipVerify', existing?.insecureSkipVerify ?? false), transformOptions: pick('transformOptions', existing?.transformOptions ?? undefined), passThroughUserAgent: pick('passThroughUserAgent', existing?.passThroughUserAgent ?? null), passThroughBody: pick('passThroughBody', existing?.passThroughBody ?? null), diff --git a/frontend/src/locales/en/channels.json b/frontend/src/locales/en/channels.json index 788c4d5ab..cb8acf6c4 100644 --- a/frontend/src/locales/en/channels.json +++ b/frontend/src/locales/en/channels.json @@ -303,6 +303,8 @@ "channels.dialogs.fields.baseURL.placeholder": "https://api.openai.com/v1", "channels.dialogs.fields.baseURL.errors.httpScheme": "HTTP transport requires an http:// or https:// Base URL", "channels.dialogs.fields.baseURL.errors.websocketScheme": "WebSocket transport requires a ws:// or wss:// Base URL", + "channels.dialogs.fields.insecureSkipVerify.label": "Ignore HTTPS Certificate", + "channels.dialogs.fields.insecureSkipVerify.warning": "Disables certificate verification for this channel. Use only with trusted self-signed endpoints; this makes man-in-the-middle attacks possible.", "channels.dialogs.fields.streamPolicy.label": "Stream Policy", "channels.dialogs.fields.streamPolicy.placeholder": "Select policy", "channels.dialogs.fields.streamPolicy.options.unlimited": "Unlimited (follow request)", diff --git a/frontend/src/locales/zh-CN/channels.json b/frontend/src/locales/zh-CN/channels.json index 470404078..7e11a69ab 100644 --- a/frontend/src/locales/zh-CN/channels.json +++ b/frontend/src/locales/zh-CN/channels.json @@ -300,6 +300,8 @@ "channels.dialogs.fields.baseURL.placeholder": "https://api.openai.com/v1", "channels.dialogs.fields.baseURL.errors.httpScheme": "HTTP 传输要求 Base URL 使用 http:// 或 https://", "channels.dialogs.fields.baseURL.errors.websocketScheme": "WebSocket 传输要求 Base URL 使用 ws:// 或 wss://", + "channels.dialogs.fields.insecureSkipVerify.label": "忽略 HTTPS 证书", + "channels.dialogs.fields.insecureSkipVerify.warning": "仅用于可信的自签名 HTTPS 端点。启用后此渠道不再校验证书,可能遭受中间人攻击。", "channels.dialogs.fields.streamPolicy.label": "流式策略", "channels.dialogs.fields.streamPolicy.placeholder": "选择策略", "channels.dialogs.fields.streamPolicy.options.unlimited": "没有限制 (遵循请求)", diff --git a/internal/objects/channel.go b/internal/objects/channel.go index e2be8e9d8..57ec41f3c 100644 --- a/internal/objects/channel.go +++ b/internal/objects/channel.go @@ -179,6 +179,10 @@ type ChannelSettings struct { // Proxy configuration for the channel. If not set, defaults to environment proxy type. Proxy *httpclient.ProxyConfig `json:"proxy,omitempty"` + // InsecureSkipVerify disables TLS certificate verification for this channel. + // Keep this disabled unless the upstream uses a trusted self-signed certificate. + InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"` + // TransformOptions configures the transform options for the channel. TransformOptions TransformOptions `json:"transformOptions"` diff --git a/internal/server/biz/channel_llm.go b/internal/server/biz/channel_llm.go index 076e29a74..bd0f877cf 100644 --- a/internal/server/biz/channel_llm.go +++ b/internal/server/biz/channel_llm.go @@ -103,14 +103,21 @@ func getProxyConfig(channelSettings *objects.ChannelSettings) *httpclient.ProxyC return channelSettings.Proxy } -// getHttpClient returns the injected default HTTP client when no custom proxy is configured, -// or creates a new one with proxy support (inheriting TLS settings from the default client). +// getHttpClient returns an HTTP client configured for this channel. func (svc *ChannelService) getHttpClient(channelSettings *objects.ChannelSettings) *httpclient.HttpClient { - if channelSettings == nil || channelSettings.Proxy == nil { - return svc.httpClient + httpClient := svc.httpClient + if channelSettings == nil { + return httpClient + } + + if channelSettings.Proxy != nil { + httpClient = httpClient.WithProxy(channelSettings.Proxy) + } + if channelSettings.InsecureSkipVerify { + httpClient = httpClient.WithInsecureSkipVerify(true) } - return svc.httpClient.WithProxy(channelSettings.Proxy) + return httpClient } // buildChannel creates a Channel with precomputed caches (transformer is set separately). diff --git a/internal/server/biz/channel_llm_test.go b/internal/server/biz/channel_llm_test.go index 55855d9df..2b9d88db4 100644 --- a/internal/server/biz/channel_llm_test.go +++ b/internal/server/biz/channel_llm_test.go @@ -1,14 +1,48 @@ package biz import ( + "net/http" "testing" "github.com/stretchr/testify/require" "github.com/looplj/axonhub/internal/ent" "github.com/looplj/axonhub/internal/objects" + "github.com/looplj/axonhub/llm/httpclient" ) +func TestChannelServiceGetHTTPClient_PerChannelTLSIsolation(t *testing.T) { + defaultClient := httpclient.NewHttpClient() + svc := &ChannelService{httpClient: defaultClient} + + secureClient := svc.getHttpClient(&objects.ChannelSettings{}) + require.Same(t, defaultClient, secureClient) + require.Nil(t, secureClient.GetNativeClient().Transport) + + insecureClient := svc.getHttpClient(&objects.ChannelSettings{InsecureSkipVerify: true}) + transport, ok := insecureClient.GetNativeClient().Transport.(*http.Transport) + require.True(t, ok) + require.True(t, transport.TLSClientConfig.InsecureSkipVerify) + require.Nil(t, defaultClient.GetNativeClient().Transport) +} + +func TestChannelServiceGetHTTPClient_PreservesProxyAndGlobalTLS(t *testing.T) { + defaultClient := httpclient.NewHttpClient(httpclient.WithInsecureSkipVerify(true)) + svc := &ChannelService{httpClient: defaultClient} + settings := &objects.ChannelSettings{ + Proxy: &httpclient.ProxyConfig{Type: httpclient.ProxyTypeDisabled}, + } + + channelClient := svc.getHttpClient(settings) + transport, ok := channelClient.GetNativeClient().Transport.(*http.Transport) + require.True(t, ok) + require.True(t, transport.TLSClientConfig.InsecureSkipVerify) + + proxyURL, err := transport.Proxy(&http.Request{}) + require.NoError(t, err) + require.Nil(t, proxyURL) +} + func TestChannel_IsModelSupported_WithExtraModelPrefix(t *testing.T) { tests := []struct { name string diff --git a/internal/server/biz/model_fetcher.go b/internal/server/biz/model_fetcher.go index 14aefffc1..78565cf49 100644 --- a/internal/server/biz/model_fetcher.go +++ b/internal/server/biz/model_fetcher.go @@ -159,8 +159,9 @@ type FetchModelsInput struct { ChannelType string BaseURL string //nolint:gosec // G117: Field name contains "APIKey" but this is input data, not a hardcoded secret - APIKey *string - ChannelID *int + APIKey *string + ChannelID *int + InsecureSkipVerify *bool } // FetchModelsResult represents the result of fetching models. @@ -246,8 +247,9 @@ func (f *ModelFetcher) FetchModels(ctx context.Context, input FetchModelsInput) } var ( - apiKey string - proxyConfig *httpclient.ProxyConfig + apiKey string + proxyConfig *httpclient.ProxyConfig + insecureSkipVerify = lo.FromPtrOr(input.InsecureSkipVerify, false) ) if input.APIKey != nil && *input.APIKey != "" { @@ -287,6 +289,9 @@ func (f *ModelFetcher) FetchModels(ctx context.Context, input FetchModelsInput) if ch.Settings != nil { proxyConfig = ch.Settings.Proxy + if input.InsecureSkipVerify == nil { + insecureSkipVerify = ch.Settings.InsecureSkipVerify + } } } @@ -359,6 +364,9 @@ func (f *ModelFetcher) FetchModels(ctx context.Context, input FetchModelsInput) if proxyConfig != nil { httpClient = f.httpClient.WithProxy(proxyConfig) } + if insecureSkipVerify { + httpClient = httpClient.WithInsecureSkipVerify(true) + } if channelType.IsGemini() { models, err := f.fetchGeminiModels(ctx, httpClient, req) diff --git a/internal/server/biz/model_fetcher_test.go b/internal/server/biz/model_fetcher_test.go index 753c04a57..18d9bd604 100644 --- a/internal/server/biz/model_fetcher_test.go +++ b/internal/server/biz/model_fetcher_test.go @@ -10,6 +10,8 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" + "github.com/looplj/axonhub/internal/authz" "github.com/looplj/axonhub/internal/ent/channel" "github.com/looplj/axonhub/internal/ent/enttest" @@ -511,6 +513,36 @@ func TestFetchModelsGeminiPagination(t *testing.T) { } } +func TestFetchModelsInsecureSkipVerify(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"self-signed-model"}]}`)) + })) + defer server.Close() + + fetcher := NewModelFetcher(httpclient.NewHttpClient(), nil) + apiKey := "test-key" + + secureResult, err := fetcher.FetchModels(t.Context(), FetchModelsInput{ + ChannelType: channel.TypeOpenai.String(), + BaseURL: server.URL, + APIKey: &apiKey, + }) + require.NoError(t, err) + require.NotNil(t, secureResult.Error) + + insecure := true + insecureResult, err := fetcher.FetchModels(t.Context(), FetchModelsInput{ + ChannelType: channel.TypeOpenai.String(), + BaseURL: server.URL, + APIKey: &apiKey, + InsecureSkipVerify: &insecure, + }) + require.NoError(t, err) + require.Nil(t, insecureResult.Error) + require.Equal(t, []ModelIdentify{{ID: "self-signed-model"}}, insecureResult.Models) +} + func TestFetchModelsWithChannelIDUsesStoredCredentialsOnlyForStoredEndpoint(t *testing.T) { var gotAuth string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/gql/axonhub.graphql b/internal/server/gql/axonhub.graphql index 0618fe9a3..42ba717e2 100644 --- a/internal/server/gql/axonhub.graphql +++ b/internal/server/gql/axonhub.graphql @@ -156,6 +156,7 @@ type ChannelSettings { hideMappedModels: Boolean lowercaseModelId: Boolean proxy: ProxyConfig + insecureSkipVerify: Boolean transformOptions: TransformOptions headerOverrideOperations: [OverrideOperation!]! @goField(forceResolver: true) bodyOverrideOperations: [OverrideOperation!]! @goField(forceResolver: true) @@ -194,6 +195,7 @@ input ChannelSettingsInput { hideMappedModels: Boolean lowercaseModelId: Boolean proxy: ProxyConfigInput + insecureSkipVerify: Boolean transformOptions: TransformOptionsInput headerOverrideOperations: [OverrideOperationInput!] bodyOverrideOperations: [OverrideOperationInput!] @@ -522,6 +524,7 @@ input FetchModelsInput { baseURL: String! apiKey: String channelID: ID + insecureSkipVerify: Boolean } type FetchModelsPayload { diff --git a/internal/server/gql/generated.go b/internal/server/gql/generated.go index 260d4a9cb..688ab8e5a 100644 --- a/internal/server/gql/generated.go +++ b/internal/server/gql/generated.go @@ -543,6 +543,7 @@ type ComplexityRoot struct { HeaderOverrideOperations func(childComplexity int) int HideMappedModels func(childComplexity int) int HideOriginalModels func(childComplexity int) int + InsecureSkipVerify func(childComplexity int) int LowercaseModelID func(childComplexity int) int ModelMappings func(childComplexity int) int PassThroughBody func(childComplexity int) int @@ -4110,6 +4111,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.ChannelSettings.HideOriginalModels(childComplexity), true + case "ChannelSettings.insecureSkipVerify": + if e.complexity.ChannelSettings.InsecureSkipVerify == nil { + break + } + + return e.complexity.ChannelSettings.InsecureSkipVerify(childComplexity), true case "ChannelSettings.lowercaseModelId": if e.complexity.ChannelSettings.LowercaseModelID == nil { break @@ -19371,6 +19378,8 @@ func (ec *executionContext) fieldContext_Channel_settings(_ context.Context, fie return ec.fieldContext_ChannelSettings_lowercaseModelId(ctx, field) case "proxy": return ec.fieldContext_ChannelSettings_proxy(ctx, field) + case "insecureSkipVerify": + return ec.fieldContext_ChannelSettings_insecureSkipVerify(ctx, field) case "transformOptions": return ec.fieldContext_ChannelSettings_transformOptions(ctx, field) case "headerOverrideOperations": @@ -23686,6 +23695,35 @@ func (ec *executionContext) fieldContext_ChannelSettings_proxy(_ context.Context return fc, nil } +func (ec *executionContext) _ChannelSettings_insecureSkipVerify(ctx context.Context, field graphql.CollectedField, obj *objects.ChannelSettings) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ChannelSettings_insecureSkipVerify, + func(ctx context.Context) (any, error) { + return obj.InsecureSkipVerify, nil + }, + nil, + ec.marshalOBoolean2bool, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_ChannelSettings_insecureSkipVerify(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ChannelSettings", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _ChannelSettings_transformOptions(ctx context.Context, field graphql.CollectedField, obj *objects.ChannelSettings) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -65093,7 +65131,7 @@ func (ec *executionContext) unmarshalInputChannelSettingsInput(ctx context.Conte asMap[k] = v } - fieldsInOrder := [...]string{"extraModelPrefix", "modelMappings", "autoTrimedModelPrefixes", "hideOriginalModels", "hideMappedModels", "lowercaseModelId", "proxy", "transformOptions", "headerOverrideOperations", "bodyOverrideOperations", "passThroughUserAgent", "passThroughBody", "rateLimit", "retryableStatusCodes", "retryableErrorPatterns", "providerQuota"} + fieldsInOrder := [...]string{"extraModelPrefix", "modelMappings", "autoTrimedModelPrefixes", "hideOriginalModels", "hideMappedModels", "lowercaseModelId", "proxy", "insecureSkipVerify", "transformOptions", "headerOverrideOperations", "bodyOverrideOperations", "passThroughUserAgent", "passThroughBody", "rateLimit", "retryableStatusCodes", "retryableErrorPatterns", "providerQuota"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -65149,6 +65187,13 @@ func (ec *executionContext) unmarshalInputChannelSettingsInput(ctx context.Conte return it, err } it.Proxy = data + case "insecureSkipVerify": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("insecureSkipVerify")) + data, err := ec.unmarshalOBoolean2bool(ctx, v) + if err != nil { + return it, err + } + it.InsecureSkipVerify = data case "transformOptions": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("transformOptions")) data, err := ec.unmarshalOTransformOptionsInput2githubᚗcomᚋloopljᚋaxonhubᚋinternalᚋobjectsᚐTransformOptions(ctx, v) @@ -68650,7 +68695,7 @@ func (ec *executionContext) unmarshalInputFetchModelsInput(ctx context.Context, asMap[k] = v } - fieldsInOrder := [...]string{"channelType", "baseURL", "apiKey", "channelID"} + fieldsInOrder := [...]string{"channelType", "baseURL", "apiKey", "channelID", "insecureSkipVerify"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -68689,6 +68734,13 @@ func (ec *executionContext) unmarshalInputFetchModelsInput(ctx context.Context, return it, graphql.ErrorOnPath(ctx, err) } it.ChannelID = converted + case "insecureSkipVerify": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("insecureSkipVerify")) + data, err := ec.unmarshalOBoolean2ᚖbool(ctx, v) + if err != nil { + return it, err + } + it.InsecureSkipVerify = data } } @@ -90466,6 +90518,8 @@ func (ec *executionContext) _ChannelSettings(ctx context.Context, sel ast.Select out.Values[i] = ec._ChannelSettings_lowercaseModelId(ctx, field, obj) case "proxy": out.Values[i] = ec._ChannelSettings_proxy(ctx, field, obj) + case "insecureSkipVerify": + out.Values[i] = ec._ChannelSettings_insecureSkipVerify(ctx, field, obj) case "transformOptions": out.Values[i] = ec._ChannelSettings_transformOptions(ctx, field, obj) case "headerOverrideOperations": diff --git a/llm/httpclient/client.go b/llm/httpclient/client.go index a595498e1..5d93a1bba 100644 --- a/llm/httpclient/client.go +++ b/llm/httpclient/client.go @@ -17,13 +17,13 @@ import ( "github.com/looplj/axonhub/llm/streams" ) + // MaxErrorBodySize is the maximum number of bytes read from an upstream error // response body. Error bodies beyond this size are truncated to prevent OOM // from pathological upstream responses that echo large request payloads in // validation error messages, producing response bodies of 1+ GB. const MaxErrorBodySize = 1 << 20 // 1 MB - // HttpClient implements the HttpClient interface. type HttpClient struct { client *http.Client @@ -86,6 +86,51 @@ func (hc *HttpClient) WithProxy(proxyConfig *ProxyConfig) *HttpClient { return NewHttpClientWithProxy(proxyConfig, hc.opts...) } +func cloneHTTPTransport(roundTripper http.RoundTripper, proxy func(*http.Request) (*url.URL, error)) *http.Transport { + if transport, ok := roundTripper.(*http.Transport); ok { + return transport.Clone() + } + if roundTripper == nil { + if transport, ok := http.DefaultTransport.(*http.Transport); ok { + return transport.Clone() + } + } + + return &http.Transport{ + Proxy: proxy, + DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + } +} + +// WithInsecureSkipVerify returns an isolated client with the requested TLS setting. +// The underlying client and transport are cloned so the setting cannot leak to other channels. +func (hc *HttpClient) WithInsecureSkipVerify(skip bool) *HttpClient { + client := *hc.client + transport := cloneHTTPTransport(client.Transport, getProxyFunc(hc.proxyConfig)) + + if transport.TLSClientConfig == nil { + transport.TLSClientConfig = &tls.Config{} + } else { + transport.TLSClientConfig = transport.TLSClientConfig.Clone() + } + transport.TLSClientConfig.InsecureSkipVerify = skip //nolint:gosec // Explicit per-channel setting for self-signed certificates + client.Transport = transport + + opts := append([]ClientOption(nil), hc.opts...) + opts = append(opts, WithInsecureSkipVerify(skip)) + + return &HttpClient{ + client: &client, + proxyConfig: hc.proxyConfig, + opts: opts, + } +} + // GetNativeClient returns the underlying *http.Client for advanced use cases. func (hc *HttpClient) GetNativeClient() *http.Client { return hc.client diff --git a/llm/httpclient/client_test.go b/llm/httpclient/client_test.go index 28586cb16..46edf0211 100644 --- a/llm/httpclient/client_test.go +++ b/llm/httpclient/client_test.go @@ -281,6 +281,49 @@ func TestNewHttpClient_WithInsecureSkipVerify_PreservesDefaultTransportSettings( require.True(t, tr.TLSClientConfig.InsecureSkipVerify) } +func TestHttpClient_WithInsecureSkipVerify_Isolated(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + secureClient := NewHttpClient() + request := &Request{Method: http.MethodGet, URL: server.URL} + + _, err := secureClient.Do(t.Context(), request) + require.Error(t, err) + + insecureClient := secureClient.WithInsecureSkipVerify(true) + response, err := insecureClient.Do(t.Context(), request) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + require.Nil(t, secureClient.GetNativeClient().Transport) + + transport, ok := insecureClient.GetNativeClient().Transport.(*http.Transport) + require.True(t, ok) + require.True(t, transport.TLSClientConfig.InsecureSkipVerify) +} + +func TestHttpClient_WithInsecureSkipVerify_PreservesProxyAndTransport(t *testing.T) { + proxyConfig := &ProxyConfig{Type: ProxyTypeURL, URL: "http://proxy.example:8080"} + original := NewHttpClientWithProxy(proxyConfig) + derived := original.WithInsecureSkipVerify(true) + + originalTransport := original.GetNativeClient().Transport.(*http.Transport) + derivedTransport := derived.GetNativeClient().Transport.(*http.Transport) + require.NotSame(t, originalTransport, derivedTransport) + require.False(t, originalTransport.TLSClientConfig != nil && originalTransport.TLSClientConfig.InsecureSkipVerify) + require.True(t, derivedTransport.TLSClientConfig.InsecureSkipVerify) + require.Equal(t, originalTransport.MaxIdleConns, derivedTransport.MaxIdleConns) + require.Equal(t, originalTransport.IdleConnTimeout, derivedTransport.IdleConnTimeout) + + request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://api.example.com", nil) + require.NoError(t, err) + proxyURL, err := derivedTransport.Proxy(request) + require.NoError(t, err) + require.Equal(t, proxyConfig.URL, proxyURL.String()) +} + func TestHttpClientImpl_buildHttpRequest(t *testing.T) { client := &HttpClient{ client: &http.Client{Timeout: 5 * time.Second},