feat(channel): add a per-channel switch to skip upstream TLS verification

Until now only the global TLS_INSECURE_SKIP_VERIFY setting could accept a
self-signed upstream certificate. A channel's tls_insecure_skip_verify
setting now does it for that channel's API requests: relay and the Responses
and Realtime WebSocket dialer, task submission and polling, plugin fetches,
balance, usage and model-list queries, and media proxied through the channel
proxy. Credential exchanges (Codex OAuth refresh, Vertex and plugin
service-account tokens) keep verifying certificates. The channel drawer shows
the switch under the HTTP protocol settings.
This commit is contained in:
CaIon committed 2026-10-10 14:02:06 +08:00
1 parent c8d234baa1
commit c6741c36a9
29 files changed
+259 -71

No files matched your search

+4 -2
View File
@@ -157,7 +157,8 @@ func GetResponseBody(method, url string, channel *model.Channel, headers http.He
for k := range headers {
req.Header.Add(k, headers.Get(k))
}
client, err := service.GetHttpClientWithProxy(channel.GetSetting().Proxy)
setting := channel.GetSetting()
client, err := service.GetHttpClientWithProxySettings(setting.Proxy, setting)
if err != nil {
return nil, err
}
@@ -453,7 +454,8 @@ func fetchAdvancedCustomBalance(channel *model.Channel) (channelBalanceResult, e
request.Host = headers.Get(name)
}
}
client, err := service.GetHttpClientWithProxy(channel.GetSetting().Proxy)
setting := channel.GetSetting()
client, err := service.GetHttpClientWithProxySettings(setting.Proxy, setting)
if err != nil {
return channelBalanceResult{}, sanitizeFetchModelsError(err, key)
}
+3 -2
View File
@@ -347,7 +347,8 @@ func getFetchModelsResponseBody(method string, requestURL string, channel *model
request.Host = headers.Get(name)
}
}
client, err := service.NewProxyHttpClient(channel.GetSetting().Proxy)
setting := channel.GetSetting()
client, err := service.GetHttpClientWithProxySettings(setting.Proxy, setting)
if err != nil {
return nil, err
}
@@ -392,7 +393,7 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
return nil, fmt.Errorf("failed to get channel key: %w", apiErr)
}
key = strings.TrimSpace(key)
models, err := gemini.FetchGeminiModels(baseURL, key, channel.GetSetting().Proxy)
models, err := gemini.FetchGeminiModels(baseURL, key, channel.GetSetting())
if err != nil {
return nil, err
}
+3 -2
View File
@@ -99,7 +99,8 @@ func fetchCodexChannelWhamData(
return
}
client, err := service.GetHttpClientWithProxy(ch.GetSetting().Proxy)
setting := ch.GetSetting()
client, err := service.GetHttpClientWithProxySettings(setting.Proxy, setting)
if err != nil {
common.ApiError(c, err)
return
@@ -119,7 +120,7 @@ func fetchCodexChannelWhamData(
refreshCtx, refreshCancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer refreshCancel()
res, refreshErr := service.RefreshCodexOAuthTokenWithProxy(refreshCtx, oauthKey.RefreshToken, ch.GetSetting().Proxy)
res, refreshErr := service.RefreshCodexOAuthTokenWithProxy(refreshCtx, oauthKey.RefreshToken, setting.Proxy)
if refreshErr == nil {
oauthKey.AccessToken = res.AccessToken
oauthKey.RefreshToken = res.RefreshToken
+3 -2
View File
@@ -215,7 +215,8 @@ func proxyTaskMedia(c *gin.Context, task *model.Task, descriptor *relaychannel.T
message: "Artifact channel is unavailable", err: err,
}
}
proxy := strings.TrimSpace(channel.GetSetting().Proxy)
setting := channel.GetSetting()
proxy := strings.TrimSpace(setting.Proxy)
if err := validateTaskMediaURL(rawURL, proxy); err != nil {
return &taskMediaProxyError{
status: http.StatusBadGateway, code: "artifact_request_rejected",
@@ -225,7 +226,7 @@ func proxyTaskMedia(c *gin.Context, task *model.Task, descriptor *relaychannel.T
client := service.GetSSRFProtectedHTTPClient()
if proxy != "" {
client, err = service.GetHttpClientWithProxy(proxy)
client, err = service.GetHttpClientWithProxySettings(proxy, setting)
if err != nil {
return &taskMediaProxyError{
status: http.StatusInternalServerError, code: "artifact_internal_error",
+3
View File
@@ -405,6 +405,9 @@ func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
}
dialer.Proxy = http.ProxyURL(proxyURL)
}
if info.ChannelSetting.TLSInsecureSkipVerify {
dialer.TLSClientConfig = common2.InsecureTLSConfig
}
targetConn, resp, err := dialer.DialContext(c.Request.Context(), fullRequestURL, targetHeader)
if err != nil {
statusCode := http.StatusInternalServerError
+2 -2
View File
@@ -597,8 +597,8 @@ type GeminiModelsResponse struct {
NextPageToken string `json:"nextPageToken"`
}
func FetchGeminiModels(baseURL, apiKey, proxyURL string) ([]string, error) {
client, err := service.GetHttpClientWithProxy(proxyURL)
func FetchGeminiModels(baseURL, apiKey string, setting dto.ChannelSettings) ([]string, error) {
client, err := service.GetHttpClientWithProxySettings(setting.Proxy, setting)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP client: %v", err)
}
+11 -2
View File
@@ -670,7 +670,7 @@ func (a *TaskAdaptor) hookFetcher(baseURL, proxy string) pluginruntime.Fetcher {
if err := pluginruntime.ValidateRequestURL(request.URL, baseURL, a.plugin.Meta.AllowedHosts); err != nil {
return pluginruntime.FetchResponse{}, err
}
client, err := service.GetHttpClientWithProxy(proxy)
client, err := service.GetHttpClientWithProxySettings(proxy, a.channelSetting())
if err != nil {
return pluginruntime.FetchResponse{}, err
}
@@ -738,7 +738,7 @@ func (a *TaskAdaptor) doFetchDescriptor(baseURL, proxy string, descriptor reques
for name, value := range descriptor.Headers {
req.Header.Set(name, value)
}
client, err := service.GetHttpClientWithProxy(proxy)
client, err := service.GetHttpClientWithProxySettings(proxy, a.channelSetting())
if err != nil {
return nil, err
}
@@ -1178,6 +1178,15 @@ func (a *TaskAdaptor) channelType() int {
return a.info.ChannelType
}
// channelSetting is the executing channel's settings when the adaptor runs
// with channel metadata; zero otherwise.
func (a *TaskAdaptor) channelSetting() kitdto.ChannelSettings {
if a.info == nil || !a.info.HasChannelMeta() {
return kitdto.ChannelSettings{}
}
return a.info.ChannelSetting
}
// applyUpstreamCredentials sets ctx.upstream together with the credentials
// every driver hook reads. A New API channel (type 60) is another gateway:
// the plugin must address its own prefixed native routes there, and the
+14 -48
View File
@@ -6,7 +6,6 @@ import (
"encoding/json"
"encoding/pem"
"errors"
"net/http"
"net/url"
"strings"
@@ -53,7 +52,7 @@ func getAccessToken(a *Adaptor, info *relaycommon.RelayInfo) (string, error) {
if err != nil {
return "", fmt.Errorf("failed to create signed JWT: %w", err)
}
newToken, err := exchangeJwtForAccessToken(signedJWT, info)
newToken, err := exchangeJwtForAccessToken(signedJWT, info.ChannelSetting.Proxy)
if err != nil {
return "", fmt.Errorf("failed to exchange JWT for access token: %w", err)
}
@@ -104,14 +103,24 @@ func createSignedJWT(email, privateKeyPEM string) (string, error) {
return signedToken, nil
}
func exchangeJwtForAccessToken(signedJWT string, info *relaycommon.RelayInfo) (string, error) {
func AcquireAccessToken(creds Credentials, proxy string) (string, error) {
signedJWT, err := createSignedJWT(creds.ClientEmail, creds.PrivateKey)
if err != nil {
return "", fmt.Errorf("failed to create signed JWT: %w", err)
}
return exchangeJwtForAccessToken(signedJWT, proxy)
}
// exchangeJwtForAccessToken trades a signed service-account JWT for an access
// token at Google's OAuth endpoint. It goes through the channel proxy only and
// always verifies the certificate; the channel's TLS setting does not apply.
func exchangeJwtForAccessToken(signedJWT string, proxy string) (string, error) {
authURL := "https://www.googleapis.com/oauth2/v4/token"
data := url.Values{}
data.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
data.Set("assertion", signedJWT)
client, err := service.GetHttpClientWithProxySettings(info.ChannelSetting.Proxy, info.ChannelSetting)
client, err := service.GetHttpClientWithProxy(proxy)
if err != nil {
return "", fmt.Errorf("new proxy http client failed: %w", err)
}
@@ -122,50 +131,7 @@ func exchangeJwtForAccessToken(signedJWT string, info *relaycommon.RelayInfo) (s
}
defer resp.Body.Close()
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if accessToken, ok := result["access_token"].(string); ok {
return accessToken, nil
}
return "", fmt.Errorf("failed to get access token: %v", result)
}
func AcquireAccessToken(creds Credentials, proxy string) (string, error) {
signedJWT, err := createSignedJWT(creds.ClientEmail, creds.PrivateKey)
if err != nil {
return "", fmt.Errorf("failed to create signed JWT: %w", err)
}
return exchangeJwtForAccessTokenWithProxy(signedJWT, proxy)
}
func exchangeJwtForAccessTokenWithProxy(signedJWT string, proxy string) (string, error) {
authURL := "https://www.googleapis.com/oauth2/v4/token"
data := url.Values{}
data.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
data.Set("assertion", signedJWT)
var client *http.Client
var err error
if proxy != "" {
client, err = service.GetHttpClientWithProxy(proxy)
if err != nil {
return "", fmt.Errorf("new proxy http client failed: %w", err)
}
} else {
client = service.GetHttpClient()
}
resp, err := client.PostForm(authURL, data)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result map[string]interface{}
var result map[string]any
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
+3 -2
View File
@@ -38,9 +38,10 @@ func RelayMidjourneyImage(c *gin.Context) {
var httpClient *http.Client
var proxy string
if channel, err := model.CacheGetChannel(midjourneyTask.ChannelId); err == nil {
proxy = channel.GetSetting().Proxy
channelSetting := channel.GetSetting()
proxy = channelSetting.Proxy
if proxy != "" {
if httpClient, err = service.GetHttpClientWithProxy(proxy); err != nil {
if httpClient, err = service.GetHttpClientWithProxySettings(proxy, channelSetting); err != nil {
c.JSON(400, gin.H{
"error": "proxy_url_invalid",
})
+3
View File
@@ -32,6 +32,9 @@ type ChannelSettings struct {
// HTTP2ConnectionShards spreads HTTP/2 traffic across N independent transports
// (1-8). Zero/unset means 1. Ignored when HTTPProtocol is "http1".
HTTP2ConnectionShards int `json:"http2_connection_shards,omitempty"`
// TLSInsecureSkipVerify accepts upstream certificates that fail
// verification, such as self-signed ones, for this channel only.
TLSInsecureSkipVerify bool `json:"tls_insecure_skip_verify,omitempty"`
}
// BindsTaskPlugin reports whether the channel is bound to the task plugin,
+4
View File
@@ -676,20 +676,24 @@ func TestChannelSettingsHTTPTransportJSONRoundTrip(t *testing.T) {
require.NoError(t, err)
assert.NotContains(t, string(encoded), "http_protocol")
assert.NotContains(t, string(encoded), "http2_connection_shards")
assert.NotContains(t, string(encoded), "tls_insecure_skip_verify")
explicit := ChannelSettings{
Proxy: "socks5://127.0.0.1:1080",
HTTPProtocol: HTTPProtocolHTTP1,
HTTP2ConnectionShards: 1,
TLSInsecureSkipVerify: true,
}
encoded, err = json.Marshal(explicit)
require.NoError(t, err)
assert.Contains(t, string(encoded), `"http_protocol":"http1"`)
assert.Contains(t, string(encoded), `"tls_insecure_skip_verify":true`)
var decoded ChannelSettings
require.NoError(t, json.Unmarshal(encoded, &decoded))
assert.Equal(t, explicit.HTTPProtocol, decoded.HTTPProtocol)
assert.Equal(t, 1, decoded.HTTP2ConnectionShards)
assert.True(t, decoded.TLSInsecureSkipVerify)
sharded := ChannelSettings{HTTP2ConnectionShards: 4}
encoded, err = json.Marshal(sharded)
+13 -5
View File
@@ -19,18 +19,26 @@ func FetchCodexChannelModels(channel *model.Channel) ([]string, error) {
return nil, fmt.Errorf("codex channel does not support multi-key model discovery")
}
client, err := NewProxyHttpClient(channel.GetSetting().Proxy)
if err != nil {
return nil, err
}
setting := channel.GetSetting()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
clientVersion, err := GetLatestCodexClientVersion(ctx, client)
// The GitHub release lookup is not an upstream request; it always verifies
// certificates, whatever the channel's TLS setting says.
releaseClient, err := GetHttpClientWithProxy(setting.Proxy)
if err != nil {
return nil, err
}
clientVersion, err := GetLatestCodexClientVersion(ctx, releaseClient)
if err != nil {
return nil, fmt.Errorf("failed to get Codex client version: %w", err)
}
client, err := GetHttpClientWithProxySettings(setting.Proxy, setting)
if err != nil {
return nil, err
}
baseURL := channel.GetBaseURL()
if baseURL == "" {
baseURL = constant.GetChannelBaseURL(constant.ChannelTypeCodex)
+3
View File
@@ -333,6 +333,9 @@ func newTransportFactory(proxyURL *url.URL, tlsConfig *tls.Config) (func() *http
}
func newHTTPClientFromPolicy(policy HTTPTransportPolicy, proxyURL *url.URL, tlsConfig *tls.Config) (*http.Client, error) {
if policy.TLSInsecureSkipVerify {
tlsConfig = common.InsecureTLSConfig
}
factory, err := newTransportFactory(proxyURL, tlsConfig)
if err != nil {
return nil, err
+80
View File
@@ -6,6 +6,7 @@ import (
"encoding/pem"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"sync"
@@ -297,6 +298,75 @@ func TestHTTPClientCachePolicyAndCompatibility(t *testing.T) {
proxyHTTP1, err := GetHttpClientWithProxySettings(proxyA, dto.ChannelSettings{HTTPProtocol: dto.HTTPProtocolHTTP1})
require.NoError(t, err)
assert.NotSame(t, clientA, proxyHTTP1)
insecure, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{TLSInsecureSkipVerify: true})
require.NoError(t, err)
assert.NotSame(t, aware, insecure, "skip-verify channels must not share the verifying client")
insecureHTTP1, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{HTTPProtocol: dto.HTTPProtocolHTTP1, TLSInsecureSkipVerify: true})
require.NoError(t, err)
assert.NotSame(t, http1, insecureHTTP1)
proxyInsecure, err := GetHttpClientWithProxySettings(proxyA, dto.ChannelSettings{TLSInsecureSkipVerify: true})
require.NoError(t, err)
assert.NotSame(t, clientA, proxyInsecure)
}
func TestChannelTLSInsecureSkipVerifyAcceptsUntrustedUpstream(t *testing.T) {
initDefaultHTTPClientFixture(t)
upstream := startHTTP2TLSServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
connectProxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodConnect {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
target, err := net.Dial("tcp", r.Host)
if err != nil {
w.WriteHeader(http.StatusBadGateway)
return
}
defer target.Close()
conn, _, err := http.NewResponseController(w).Hijack()
if err != nil {
return
}
defer conn.Close()
_, _ = conn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
go func() { _, _ = io.Copy(target, conn) }()
_, _ = io.Copy(conn, target)
}))
t.Cleanup(connectProxy.Close)
skip := dto.ChannelSettings{TLSInsecureSkipVerify: true}
testCases := []struct {
name string
proxy string
settings dto.ChannelSettings
wantProto int // zero means certificate verification must fail
}{
{name: "default direct verifies", settings: dto.ChannelSettings{}},
{name: "default proxy verifies", proxy: connectProxy.URL, settings: dto.ChannelSettings{}},
{name: "http1 verifies", settings: dto.ChannelSettings{HTTPProtocol: dto.HTTPProtocolHTTP1}},
{name: "skip direct", settings: skip, wantProto: 2},
{name: "skip proxy", proxy: connectProxy.URL, settings: skip, wantProto: 2},
{name: "skip http1", settings: dto.ChannelSettings{HTTPProtocol: dto.HTTPProtocolHTTP1, TLSInsecureSkipVerify: true}, wantProto: 1},
{name: "skip sharded", settings: dto.ChannelSettings{HTTP2ConnectionShards: 3, TLSInsecureSkipVerify: true}, wantProto: 2},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
client, err := GetHttpClientWithProxySettings(tc.proxy, tc.settings)
require.NoError(t, err)
resp, err := client.Get(upstream.URL)
if tc.wantProto == 0 {
var verifyErr *tls.CertificateVerificationError
require.ErrorAs(t, err, &verifyErr)
return
}
require.NoError(t, err)
assert.Equal(t, tc.wantProto, resp.ProtoMajor)
drainClose(t, resp)
})
}
}
func TestHTTPClientCacheConcurrentGetOrCreate(t *testing.T) {
@@ -366,6 +436,8 @@ func TestInvalidateProxyClientClosesAllPolicyVariants(t *testing.T) {
require.NoError(t, err)
shardedClient, err := GetHttpClientWithProxySettings(proxyURL, dto.ChannelSettings{HTTP2ConnectionShards: 2})
require.NoError(t, err)
insecureClient, err := GetHttpClientWithProxySettings(proxyURL, dto.ChannelSettings{TLSInsecureSkipVerify: true})
require.NoError(t, err)
InvalidateProxyClient(proxyURL)
@@ -375,10 +447,13 @@ func TestInvalidateProxyClientClosesAllPolicyVariants(t *testing.T) {
require.NoError(t, err)
afterSharded, err := GetHttpClientWithProxySettings(proxyURL, dto.ChannelSettings{HTTP2ConnectionShards: 2})
require.NoError(t, err)
afterInsecure, err := GetHttpClientWithProxySettings(proxyURL, dto.ChannelSettings{TLSInsecureSkipVerify: true})
require.NoError(t, err)
assert.NotSame(t, defaultClient, afterDefault)
assert.NotSame(t, http1Client, afterHTTP1)
assert.NotSame(t, shardedClient, afterSharded)
assert.NotSame(t, insecureClient, afterInsecure)
}
func TestResetProxyClientCacheKeepsDefaultPointerAndRecreatesVariants(t *testing.T) {
@@ -390,6 +465,8 @@ func TestResetProxyClientCacheKeepsDefaultPointerAndRecreatesVariants(t *testing
require.NoError(t, err)
proxyClient, err := GetHttpClientWithProxy("http://reset-proxy.example:8080")
require.NoError(t, err)
insecureClient, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{TLSInsecureSkipVerify: true})
require.NoError(t, err)
ResetProxyClientCache()
@@ -404,9 +481,12 @@ func TestResetProxyClientCacheKeepsDefaultPointerAndRecreatesVariants(t *testing
require.NoError(t, err)
afterProxy, err := GetHttpClientWithProxy("http://reset-proxy.example:8080")
require.NoError(t, err)
afterInsecure, err := GetHttpClientWithProxySettings("", dto.ChannelSettings{TLSInsecureSkipVerify: true})
require.NoError(t, err)
assert.NotSame(t, http1Client, afterHTTP1)
assert.NotSame(t, shardedClient, afterSharded)
assert.NotSame(t, proxyClient, afterProxy)
assert.NotSame(t, insecureClient, afterInsecure)
}
func TestResetProxyClientCacheClosesDefaultIdlePool(t *testing.T) {
+5 -3
View File
@@ -14,8 +14,9 @@ import (
// HTTPTransportPolicy is the runtime-normalized outbound HTTP transport policy
// for a channel. Unknown or out-of-range stored values are clamped safely.
type HTTPTransportPolicy struct {
Protocol string // dto.HTTPProtocolAuto or dto.HTTPProtocolHTTP1
Shards int // 1..dto.MaxHTTP2ConnectionShards
Protocol string // dto.HTTPProtocolAuto or dto.HTTPProtocolHTTP1
Shards int // 1..dto.MaxHTTP2ConnectionShards
TLSInsecureSkipVerify bool
}
var httpTransportPolicyWarnings sync.Map
@@ -31,6 +32,7 @@ func defaultHTTPTransportPolicy() HTTPTransportPolicy {
// Invalid stored values never panic; they clamp to defaults and warn once per bad value.
func NormalizeHTTPTransportPolicy(settings dto.ChannelSettings) HTTPTransportPolicy {
policy := defaultHTTPTransportPolicy()
policy.TLSInsecureSkipVerify = settings.TLSInsecureSkipVerify
protocol := strings.ToLower(strings.TrimSpace(settings.HTTPProtocol))
switch protocol {
@@ -84,7 +86,7 @@ func warnHTTPTransportPolicyOnce(field, value string) {
}
func (p HTTPTransportPolicy) cacheKeyPart() string {
return fmt.Sprintf("%s|%d", p.Protocol, p.Shards)
return fmt.Sprintf("%s|%d|%t", p.Protocol, p.Shards, p.TLSInsecureSkipVerify)
}
func (p HTTPTransportPolicy) String() string {
+2 -1
View File
@@ -277,7 +277,7 @@ func updateBatchTasks(ctx context.Context, adaptor BatchTaskPollingAdaptor, chan
// The channel type tells plugin adaptors whether the upstream is another
// New API gateway, the same signal submission derives from the request.
info := &relaycommon.RelayInfo{}
info.ChannelMeta = &relaycommon.ChannelMeta{ChannelType: ch.Type, ChannelId: ch.Id, ChannelBaseUrl: baseURL}
info.ChannelMeta = &relaycommon.ChannelMeta{ChannelType: ch.Type, ChannelId: ch.Id, ChannelBaseUrl: baseURL, ChannelSetting: ch.GetSetting()}
info.ApiKey = ch.Key
adaptor.Init(info)
resp, err := adaptor.FetchBatchTasks(baseURL, ch.Key, tasks, proxy)
@@ -452,6 +452,7 @@ func updateVideoTasks(ctx context.Context, platform constant.TaskPlatform, chann
ChannelType: cacheGetChannel.Type,
ChannelId: cacheGetChannel.Id,
ChannelBaseUrl: cacheGetChannel.GetBaseURL(),
ChannelSetting: cacheGetChannel.GetSetting(),
}
info.ApiKey = cacheGetChannel.Key
adaptor.Init(info)
@@ -1611,6 +1611,56 @@ test('an Ollama channel marks a saved OpenAI-compatible chat setting in Request
})
})
test('a saved upstream TLS verification skip marks Other Settings and turning it off saves the setting without it', async () => {
editingChannel = {
...editingChannel,
setting: '{"tls_insecure_skip_verify":true}',
}
const put = vi
.spyOn(api, 'put')
.mockResolvedValue({ data: { success: true } })
const user = userEvent.setup()
render(<ConfigurationHarness currentRow={editingChannel} />)
await screen.findByDisplayValue('Existing channel')
const otherTab = screen.getByRole('tab', { name: /Other Settings/ })
expect(otherTab).toHaveAccessibleName(/Configured/)
await user.click(otherTab)
const toggle = screen.getByRole('switch', {
name: 'Skip Upstream TLS Verification',
})
expect(toggle).toBeChecked()
await user.click(toggle)
expect(toggle).not.toBeChecked()
expect(otherTab).not.toHaveAccessibleName(/Configured/)
await user.click(screen.getByRole('button', { name: 'Update Channel' }))
await waitFor(() => expect(put).toHaveBeenCalled())
const payload = put.mock.calls[0]?.[1] as { setting: string }
expect(JSON.parse(payload.setting)).not.toHaveProperty(
'tls_insecure_skip_verify'
)
})
test('turning on the upstream TLS verification skip saves it in the channel setting', async () => {
const put = vi
.spyOn(api, 'put')
.mockResolvedValue({ data: { success: true } })
const user = userEvent.setup()
render(<ConfigurationHarness currentRow={editingChannel} />)
await screen.findByDisplayValue('Existing channel')
await user.click(screen.getByRole('tab', { name: /Other Settings/ }))
const toggle = screen.getByRole('switch', {
name: 'Skip Upstream TLS Verification',
})
expect(toggle).not.toBeChecked()
await user.click(toggle)
await user.click(screen.getByRole('button', { name: 'Update Channel' }))
await waitFor(() => expect(put).toHaveBeenCalled())
const payload = put.mock.calls[0]?.[1] as { setting: string }
expect(JSON.parse(payload.setting)).toMatchObject({
tls_insecure_skip_verify: true,
})
})
test('an invalid edit switches categories and replaces configured styling with the field error', async () => {
const user = userEvent.setup()
render(<ConfigurationHarness currentRow={editingChannel} />)
@@ -299,6 +299,7 @@ const SENSITIVE_FORM_FIELDS = [
'proxy',
'http_protocol',
'http2_connection_shards',
'tls_insecure_skip_verify',
'pass_through_body_enabled',
'responses_websocket_enabled',
'system_prompt',
@@ -2159,6 +2160,32 @@ export function ChannelMutateDrawer({
/>
)
const tlsInsecureSkipVerifyFields = (
<FormField
control={form.control}
name='tls_insecure_skip_verify'
render={({ field }) => (
<FormItem className='flex items-center justify-between px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel>{t('Skip Upstream TLS Verification')}</FormLabel>
<FormDescription>
{t(
'Accept upstream certificates that fail validation, such as self-signed ones. Only enable for trusted internal upstreams.'
)}
</FormDescription>
</div>
<FormControl>
<Switch
disabled={sensitiveLocked}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)
const routingFields = (
<div
role='group'
@@ -4690,6 +4717,7 @@ export function ChannelMutateDrawer({
{proxyFields}
{httpProtocolFields}
{httpShardsFields}
{tlsInsecureSkipVerifyFields}
</fieldset>
</div>
{upstreamModelDetectionFields}
@@ -84,6 +84,7 @@ const CONFIGURATION_BLOCKS = {
'proxy',
'http_protocol',
'http2_connection_shards',
'tls_insecure_skip_verify',
'disable_task_polling_sleep',
],
},
@@ -175,6 +176,7 @@ export function getChannelConfigurationState(
values.proxy?.trim() ||
(values.http_protocol && values.http_protocol !== 'auto') ||
(values.http2_connection_shards ?? 1) > 1 ||
values.tls_insecure_skip_verify ||
values.disable_task_polling_sleep
),
upstreamModelDetection:
@@ -42,6 +42,7 @@ const ADVANCED_SETTINGS_FIELDS = new Set<FieldPath<ChannelFormValues>>([
'proxy',
'http_protocol',
'http2_connection_shards',
'tls_insecure_skip_verify',
'system_prompt',
'system_prompt_override',
'allow_service_tier',
@@ -269,6 +269,7 @@ export const channelFormSchema = z
.refine(isOptionalProxyURL, ERROR_MESSAGES.INVALID_PROXY),
http_protocol: z.enum(['auto', 'http1']).optional(),
http2_connection_shards: z.number().int().optional(),
tls_insecure_skip_verify: z.boolean().optional(),
pass_through_body_enabled: z.boolean().optional(),
responses_websocket_enabled: z.boolean().optional(),
system_prompt: z.string().optional(),
@@ -460,6 +461,7 @@ export const CHANNEL_FORM_DEFAULT_VALUES: ChannelFormValues = {
proxy: '',
http_protocol: HTTP_PROTOCOL_AUTO,
http2_connection_shards: 1,
tls_insecure_skip_verify: false,
pass_through_body_enabled: false,
responses_websocket_enabled: false,
system_prompt: '',
@@ -504,6 +506,7 @@ export function transformChannelToFormDefaults(
proxy: '',
http_protocol: HTTP_PROTOCOL_AUTO as 'auto' | 'http1',
http2_connection_shards: 1,
tls_insecure_skip_verify: false,
pass_through_body_enabled: false,
responses_websocket_enabled: false,
system_prompt: '',
@@ -525,6 +528,7 @@ export function transformChannelToFormDefaults(
proxy: parsed.proxy || '',
http_protocol: protocol,
http2_connection_shards: protocol === HTTP_PROTOCOL_HTTP1 ? 1 : shards,
tls_insecure_skip_verify: parsed.tls_insecure_skip_verify === true,
pass_through_body_enabled: parsed.pass_through_body_enabled || false,
responses_websocket_enabled:
parsed.responses_websocket_enabled === true,
@@ -678,6 +682,9 @@ export function buildSettingJSON(formData: ChannelFormValues): string {
} else if (shards > 1) {
settingObj.http2_connection_shards = shards
}
if (formData.tls_insecure_skip_verify === true) {
settingObj.tls_insecure_skip_verify = true
}
return JSON.stringify(settingObj)
}
+1
View File
@@ -91,6 +91,7 @@ export interface ChannelSettings {
system_prompt_override?: boolean
http_protocol?: 'auto' | 'http1' | string
http2_connection_shards?: number
tls_insecure_skip_verify?: boolean
}
export interface ChannelOtherSettings {
+2
View File
@@ -187,6 +187,7 @@
"About {{days}} days left": "About {{days}} days left",
"About pricing currency": "About pricing currency",
"Accept Unpriced Models": "Accept Unpriced Models",
"Accept upstream certificates that fail validation, such as self-signed ones. Only enable for trusted internal upstreams.": "Accept upstream certificates that fail validation, such as self-signed ones. Only enable for trusted internal upstreams.",
"Accepts a JSON array of model identifiers that support the Imagine API.": "Accepts a JSON array of model identifiers that support the Imagine API.",
"Accepts comma-separated status codes and inclusive ranges.": "Accepts comma-separated status codes and inclusive ranges.",
"Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.": "Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.",
@@ -5773,6 +5774,7 @@
"Skip retry on failure": "Skip retry on failure",
"Skip SMTP TLS certificate verification": "Skip SMTP TLS certificate verification",
"Skip to Main": "Skip to Main",
"Skip Upstream TLS Verification": "Skip Upstream TLS Verification",
"Slug": "Slug",
"Slug can only contain letters, numbers, hyphens, and underscores": "Slug can only contain letters, numbers, hyphens, and underscores",
"Slug is required": "Slug is required",
+2
View File
@@ -187,6 +187,7 @@
"About {{days}} days left": "Environ {{days}} jours restants",
"About pricing currency": "À propos de la devise de tarification",
"Accept Unpriced Models": "Accepter les modèles non tarifés",
"Accept upstream certificates that fail validation, such as self-signed ones. Only enable for trusted internal upstreams.": "Accepte les certificats amont qui échouent à la validation, comme les certificats auto-signés. À n'activer que pour des serveurs amont internes de confiance.",
"Accepts a JSON array of model identifiers that support the Imagine API.": "Accepte un tableau JSON d'identifiants de modèles qui prennent en charge l'API Imagine.",
"Accepts comma-separated status codes and inclusive ranges.": "Accepte les codes de statut séparés par des virgules et les plages inclusives.",
"Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.": "Accédez à une vaste sélection de modèles via un protocole API standard et unifié. Propulsez les applications d'IA, gérez les actifs numériques et connectez le futur.",
@@ -5773,6 +5774,7 @@
"Skip retry on failure": "Ne pas réessayer en cas d'échec",
"Skip SMTP TLS certificate verification": "Ignorer la vérification du certificat TLS SMTP",
"Skip to Main": "Aller au contenu principal",
"Skip Upstream TLS Verification": "Ignorer la vérification TLS du serveur amont",
"Slug": "Slug",
"Slug can only contain letters, numbers, hyphens, and underscores": "Le slug ne peut contenir que des lettres, des chiffres, des tirets et des underscores",
"Slug is required": "Le slug est requis",
+2
View File
@@ -187,6 +187,7 @@
"About {{days}} days left": "約 {{days}} 日分",
"About pricing currency": "価格設定の通貨について",
"Accept Unpriced Models": "価格設定されていないモデルを許可",
"Accept upstream certificates that fail validation, such as self-signed ones. Only enable for trusted internal upstreams.": "自己署名証明書など、検証に通らない上流の証明書を受け入れます。信頼できる社内の上流にのみ有効にしてください。",
"Accepts a JSON array of model identifiers that support the Imagine API.": "Imagine APIをサポートするモデル識別子のJSON配列を受け入れます。",
"Accepts comma-separated status codes and inclusive ranges.": "カンマ区切りのステータスコードと包含範囲を受け入れます。",
"Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.": "標準的で統一されたAPIプロトコルを介して、膨大なモデルにアクセス。AIアプリケーションを強化し、デジタル資産を管理し、未来へと繋げます。",
@@ -5773,6 +5774,7 @@
"Skip retry on failure": "失敗時にリトライしない",
"Skip SMTP TLS certificate verification": "SMTP TLS証明書の検証をスキップ",
"Skip to Main": "メインコンテンツへスキップ",
"Skip Upstream TLS Verification": "上流の TLS 証明書の検証をスキップ",
"Slug": "スラッグ",
"Slug can only contain letters, numbers, hyphens, and underscores": "スラッグには英数字、ハイフン、アンダースコアのみ使用できます",
"Slug is required": "スラッグは必須です",
+2
View File
@@ -187,6 +187,7 @@
"About {{days}} days left": "Примерно {{days}} дней",
"About pricing currency": "О валюте цен",
"Accept Unpriced Models": "Принимать модели без цены",
"Accept upstream certificates that fail validation, such as self-signed ones. Only enable for trusted internal upstreams.": "Принимать сертификаты upstream-сервера, не прошедшие проверку, например самоподписанные. Включайте только для доверенных внутренних upstream-серверов.",
"Accepts a JSON array of model identifiers that support the Imagine API.": "Принимает JSON-массив идентификаторов моделей, поддерживающих Imagine API.",
"Accepts comma-separated status codes and inclusive ranges.": "Принимает коды статуса, разделенные запятыми, и включающие диапазоны.",
"Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.": "Получите доступ к огромному выбору моделей через стандартный, единый протокол API. Развивайте приложения ИИ, управляйте цифровыми активами и соединяйте будущее.",
@@ -5773,6 +5774,7 @@
"Skip retry on failure": "Не повторять при ошибке",
"Skip SMTP TLS certificate verification": "Пропустить проверку TLS-сертификата SMTP",
"Skip to Main": "Перейти к основному содержимому",
"Skip Upstream TLS Verification": "Пропустить проверку TLS-сертификата upstream-сервера",
"Slug": "Идентификатор",
"Slug can only contain letters, numbers, hyphens, and underscores": "Slug может содержать только буквы, цифры, дефисы и подчёркивания",
"Slug is required": "Slug обязателен",
+2
View File
@@ -187,6 +187,7 @@
"About {{days}} days left": "Còn khoảng {{days}} ngày",
"About pricing currency": "Về tiền tệ định giá",
"Accept Unpriced Models": "Chấp nhận các Mô hình chưa định giá",
"Accept upstream certificates that fail validation, such as self-signed ones. Only enable for trusted internal upstreams.": "Chấp nhận chứng chỉ upstream không vượt qua xác minh, chẳng hạn chứng chỉ tự ký. Chỉ bật cho upstream nội bộ đáng tin cậy.",
"Accepts a JSON array of model identifiers that support the Imagine API.": "Chấp nhận một mảng JSON gồm các mã định danh mô hình hỗ trợ API Imagine.",
"Accepts comma-separated status codes and inclusive ranges.": "Chấp nhận mã trạng thái phân cách bằng dấu phẩy và phạm vi bao gồm.",
"Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.": "Truy cập số lượng lớn các mô hình thông qua giao thức API chuẩn hóa và thống nhất. Thúc đẩy các ứng dụng AI, quản lý tài sản kỹ thuật số và kết nối tương lai.",
@@ -5773,6 +5774,7 @@
"Skip retry on failure": "Không thử lại khi thất bại",
"Skip SMTP TLS certificate verification": "Bỏ qua xác minh chứng chỉ TLS SMTP",
"Skip to Main": "Bỏ qua đến nội dung chính",
"Skip Upstream TLS Verification": "Bỏ qua xác minh chứng chỉ TLS của upstream",
"Slug": "Slug",
"Slug can only contain letters, numbers, hyphens, and underscores": "Slug chỉ có thể chứa chữ cái, số, dấu gạch ngang và dấu gạch dưới",
"Slug is required": "Slug là bắt buộc",
+2
View File
@@ -187,6 +187,7 @@
"About {{days}} days left": "約剩 {{days}} 日",
"About pricing currency": "關於定價貨幣",
"Accept Unpriced Models": "接受未定價模型",
"Accept upstream certificates that fail validation, such as self-signed ones. Only enable for trusted internal upstreams.": "接受無法通過驗證的上游證書,例如自簽章證書。僅對可信的內網上游開啟。",
"Accepts a JSON array of model identifiers that support the Imagine API.": "接受支援 Imagine API 的模型標識符的 JSON 陣列。",
"Accepts comma-separated status codes and inclusive ranges.": "接受逗號分隔的狀態碼和包含性範圍。",
"Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.": "透過統一、標準的介面協定接入海量模型。承載 AI 套用,高效管理數位資產,連接未來。",
@@ -5773,6 +5774,7 @@
"Skip retry on failure": "失敗後不重試",
"Skip SMTP TLS certificate verification": "跳過 SMTP TLS 證書驗證",
"Skip to Main": "跳到主內容",
"Skip Upstream TLS Verification": "忽略上游證書驗證",
"Slug": "標識符",
"Slug can only contain letters, numbers, hyphens, and underscores": "Slug 只能包含字母、數字、連字符和底線",
"Slug is required": "Slug 不能為空",
+2
View File
@@ -187,6 +187,7 @@
"About {{days}} days left": "约剩 {{days}} 天",
"About pricing currency": "关于定价货币",
"Accept Unpriced Models": "接受未定价模型",
"Accept upstream certificates that fail validation, such as self-signed ones. Only enable for trusted internal upstreams.": "接受无法通过校验的上游证书,例如自签名证书。仅对可信的内网上游开启。",
"Accepts a JSON array of model identifiers that support the Imagine API.": "接受支持 Imagine API 的模型标识符的 JSON 数组。",
"Accepts comma-separated status codes and inclusive ranges.": "接受逗号分隔的状态码和包含性范围。",
"Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.": "通过统一、标准的接口协议接入海量模型。承载 AI 应用,高效管理数字资产,连接未来。",
@@ -5773,6 +5774,7 @@
"Skip retry on failure": "失败后不重试",
"Skip SMTP TLS certificate verification": "跳过 SMTP TLS 证书验证",
"Skip to Main": "跳到主内容",
"Skip Upstream TLS Verification": "忽略上游证书校验",
"Slug": "标识符",
"Slug can only contain letters, numbers, hyphens, and underscores": "Slug 只能包含字母、数字、连字符和下划线",
"Slug is required": "Slug 不能为空",