mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-11-10 13:14:18 +08:00
fix: guard AI endpoint probes and discovery (#14347)
This commit is contained in:
8 files changed
+372
-110
No files matched your search
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/aihttp"
|
||||
)
|
||||
|
||||
type APIConfig struct {
|
||||
@@ -373,7 +375,7 @@ func ResolveBaseURL(key, apiType, requested string) (string, error) {
|
||||
return "", fmt.Errorf("base url is required")
|
||||
}
|
||||
parsed, err := url.Parse(baseURL)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
if err != nil || aihttp.ValidateURL(baseURL) != nil {
|
||||
return "", fmt.Errorf("invalid base url")
|
||||
}
|
||||
if key == "custom" && (IsImageAPIType(config.APIType) || IsEmbeddingAPIType(config.APIType)) {
|
||||
|
||||
@@ -6,34 +6,46 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/aihttp"
|
||||
)
|
||||
|
||||
func DiscoverModels(baseURL, apiKey string) ([]string, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, buildModelDiscoveryURL(baseURL), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, aihttp.ErrTarget
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
resp, err := (&http.Client{Timeout: defaultVerifyTimeout}).Do(req)
|
||||
resp, err := aihttp.NewClient(defaultVerifyTimeout).Do(req)
|
||||
if err != nil {
|
||||
return nil, aihttp.SafeError(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := aihttp.ReadResponseBody(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
return nil, fmt.Errorf("request failed: %s", resp.Status)
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, fmt.Errorf("request failed: status %d", resp.StatusCode)
|
||||
}
|
||||
var payload struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return nil, err
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, aihttp.ErrResponse
|
||||
}
|
||||
if len(payload.Data) > aihttp.MaxDiscoveredModels {
|
||||
return nil, fmt.Errorf("model discovery exceeded the model limit")
|
||||
}
|
||||
models := make([]string, 0, len(payload.Data))
|
||||
seen := make(map[string]struct{}, len(payload.Data))
|
||||
for _, item := range payload.Data {
|
||||
id := strings.TrimSpace(item.ID)
|
||||
if len(id) > 512 {
|
||||
return nil, aihttp.ErrResponse
|
||||
}
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/aihttp"
|
||||
)
|
||||
|
||||
type VerifyRequest struct {
|
||||
@@ -20,13 +20,6 @@ type VerifyRequest struct {
|
||||
Body []byte
|
||||
}
|
||||
|
||||
type verifyErrorResponse struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
const (
|
||||
defaultVerifyTimeout = 30 * time.Second
|
||||
defaultVerifyMaxTokens = 16
|
||||
@@ -36,22 +29,22 @@ func VerifyAccount(provider, apiType, authMode, baseURL, apiKey, model string) e
|
||||
req := BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model)
|
||||
httpReq, err := http.NewRequest(req.Method, req.URL, bytes.NewReader(req.Body))
|
||||
if err != nil {
|
||||
return err
|
||||
return buserr.WithErr("ErrAgentAccountUnavailable", aihttp.ErrTarget)
|
||||
}
|
||||
for key, value := range req.Headers {
|
||||
httpReq.Header.Set(key, value)
|
||||
}
|
||||
httpReq.Header.Set("Accept", "application/json")
|
||||
resp, err := (&http.Client{Timeout: defaultVerifyTimeout}).Do(httpReq)
|
||||
resp, err := aihttp.NewClient(defaultVerifyTimeout).Do(httpReq)
|
||||
if err != nil {
|
||||
return buserr.WithErr("ErrAgentAccountUnavailable", err)
|
||||
return buserr.WithErr("ErrAgentAccountUnavailable", aihttp.SafeError(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, readErr := aihttp.ReadResponseBody(resp.Body)
|
||||
if readErr != nil {
|
||||
return buserr.WithErr("ErrAgentAccountUnavailable", readErr)
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
|
||||
if readErr != nil {
|
||||
return buserr.WithErr("ErrAgentAccountUnavailable", readErr)
|
||||
}
|
||||
return buserr.WithErr("ErrAgentAccountUnavailable", errors.New(verifyHTTPError(resp.StatusCode, body)))
|
||||
}
|
||||
return nil
|
||||
@@ -156,20 +149,8 @@ func imageVerifyURL(provider, baseURL, endpoint string) string {
|
||||
return baseURL + endpoint
|
||||
}
|
||||
|
||||
func verifyHTTPError(statusCode int, body []byte) string {
|
||||
message := strings.TrimSpace(string(body))
|
||||
var payload verifyErrorResponse
|
||||
if err := json.Unmarshal(body, &payload); err == nil {
|
||||
if value := strings.TrimSpace(payload.Error.Message); value != "" {
|
||||
message = value
|
||||
} else if value := strings.TrimSpace(payload.Message); value != "" {
|
||||
message = value
|
||||
}
|
||||
}
|
||||
if message == "" {
|
||||
return fmt.Sprintf("validation request returned status %d", statusCode)
|
||||
}
|
||||
return message
|
||||
func verifyHTTPError(statusCode int, _ []byte) string {
|
||||
return fmt.Sprintf("validation request returned status %d", statusCode)
|
||||
}
|
||||
|
||||
func mustJSON(value interface{}) []byte {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/aihttp"
|
||||
)
|
||||
|
||||
const (
|
||||
mcpOutputTransportSSE = "sse"
|
||||
mcpOutputTransportStreamableHTTP = "streamableHttp"
|
||||
)
|
||||
|
||||
func probeMcpConnection(endpoint, outputTransport, protocolVersion string) (bool, string) {
|
||||
client := aihttp.NewClient(10 * time.Second)
|
||||
if outputTransport == mcpOutputTransportSSE {
|
||||
request, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return false, aihttp.ErrTarget.Error()
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return false, aihttp.SafeError(err).Error()
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode >= 200 && response.StatusCode < 300 && strings.Contains(response.Header.Get("Content-Type"), "text/event-stream") {
|
||||
return true, "ok"
|
||||
}
|
||||
if _, err := aihttp.ReadResponseBody(response.Body); err != nil {
|
||||
return false, aihttp.SafeError(err).Error()
|
||||
}
|
||||
return false, fmt.Sprintf("unexpected response: status=%d", response.StatusCode)
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": map[string]interface{}{
|
||||
"protocolVersion": protocolVersion,
|
||||
"capabilities": map[string]interface{}{},
|
||||
"clientInfo": map[string]string{"name": "1Panel", "version": "1.0.0"},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
request, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return false, aihttp.ErrTarget.Error()
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json, text/event-stream")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return false, aihttp.SafeError(err).Error()
|
||||
}
|
||||
defer response.Body.Close()
|
||||
return handleMcpConnectionHTTPResponse(response)
|
||||
}
|
||||
|
||||
func handleMcpConnectionHTTPResponse(response *http.Response) (bool, string) {
|
||||
if response.StatusCode >= 200 && response.StatusCode < 300 && strings.Contains(response.Header.Get("Content-Type"), "text/event-stream") {
|
||||
return true, "ok"
|
||||
}
|
||||
if _, err := aihttp.ReadResponseBody(response.Body); err != nil {
|
||||
return false, aihttp.SafeError(err).Error()
|
||||
}
|
||||
if response.StatusCode >= 200 && response.StatusCode < 300 {
|
||||
return true, "ok"
|
||||
}
|
||||
return false, fmt.Sprintf("endpoint returned status %d", response.StatusCode)
|
||||
}
|
||||
@@ -1,17 +1,12 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
|
||||
@@ -40,11 +35,9 @@ import (
|
||||
type McpServerService struct{}
|
||||
|
||||
const (
|
||||
defaultMcpGatewayImageNpx = "supercorp/supergateway:3.4.3"
|
||||
defaultMcpGatewayImageUvx = "supercorp/supergateway:uvx"
|
||||
defaultMcpProtocolVersion = "2025-06-18"
|
||||
mcpOutputTransportSSE = "sse"
|
||||
mcpOutputTransportStreamableHTTP = "streamableHttp"
|
||||
defaultMcpGatewayImageNpx = "supercorp/supergateway:3.4.3"
|
||||
defaultMcpGatewayImageUvx = "supercorp/supergateway:uvx"
|
||||
defaultMcpProtocolVersion = "2025-06-18"
|
||||
)
|
||||
|
||||
type IMcpServerService interface {
|
||||
@@ -352,72 +345,10 @@ func (m McpServerService) TestConnection(req request.McpServerConnectionTest) (r
|
||||
return res, nil
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
if mcpServer.OutputTransport == mcpOutputTransportSSE {
|
||||
httpReq, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
res.Message = err.Error()
|
||||
return res, nil
|
||||
}
|
||||
httpRes, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
res.Message = err.Error()
|
||||
return res, nil
|
||||
}
|
||||
defer httpRes.Body.Close()
|
||||
contentType := httpRes.Header.Get("Content-Type")
|
||||
if httpRes.StatusCode >= 200 && httpRes.StatusCode < 300 && strings.Contains(contentType, "text/event-stream") {
|
||||
res.Success = true
|
||||
res.Message = "ok"
|
||||
return res, nil
|
||||
}
|
||||
res.Message = fmt.Sprintf("unexpected response: status=%d content-type=%s", httpRes.StatusCode, contentType)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": map[string]interface{}{
|
||||
"protocolVersion": mcpServer.ProtocolVersion,
|
||||
"capabilities": map[string]interface{}{},
|
||||
"clientInfo": map[string]string{
|
||||
"name": "1Panel",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
httpReq, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
res.Message = err.Error()
|
||||
return res, nil
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Accept", "application/json, text/event-stream")
|
||||
httpRes, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
res.Message = err.Error()
|
||||
return res, nil
|
||||
}
|
||||
defer httpRes.Body.Close()
|
||||
res.Success, res.Message = handleMcpConnectionHTTPResponse(httpRes)
|
||||
res.Success, res.Message = probeMcpConnection(endpoint, mcpServer.OutputTransport, mcpServer.ProtocolVersion)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func handleMcpConnectionHTTPResponse(httpRes *http.Response) (bool, string) {
|
||||
if httpRes.StatusCode >= 200 && httpRes.StatusCode < 300 {
|
||||
return true, "ok"
|
||||
}
|
||||
respBody, _ := io.ReadAll(httpRes.Body)
|
||||
message := strings.TrimSpace(string(respBody))
|
||||
if message == "" {
|
||||
message = httpRes.Status
|
||||
}
|
||||
return false, message
|
||||
}
|
||||
|
||||
func (m McpServerService) GetBindDomain() (response.McpBindDomainRes, error) {
|
||||
var res response.McpBindDomainRes
|
||||
websiteID := GetWebsiteID()
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package aihttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/webhook_sender"
|
||||
"golang.org/x/net/idna"
|
||||
)
|
||||
|
||||
const MaxResponseBytes = 1024 * 1024
|
||||
const MaxDiscoveredModels = 4096
|
||||
|
||||
var (
|
||||
ErrTarget = errors.New("AI endpoint is invalid or blocked by outbound policy")
|
||||
ErrRequest = errors.New("AI endpoint request failed")
|
||||
ErrResponse = errors.New("AI endpoint returned an invalid response")
|
||||
ErrResponseTooLarge = errors.New("AI endpoint response exceeded the size limit")
|
||||
)
|
||||
|
||||
type ipResolver interface {
|
||||
LookupIPAddr(context.Context, string) ([]net.IPAddr, error)
|
||||
}
|
||||
|
||||
type guardedTransport struct {
|
||||
resolver ipResolver
|
||||
transport *http.Transport
|
||||
}
|
||||
|
||||
func NewClient(timeout time.Duration) *http.Client {
|
||||
return newClient(timeout, nil, nil)
|
||||
}
|
||||
|
||||
func newClient(timeout time.Duration, resolver ipResolver, transport *http.Transport) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &guardedTransport{resolver: resolver, transport: transport},
|
||||
CheckRedirect: func(next *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 3 || len(via) == 0 || !sameOrigin(via[0].URL, next.URL) {
|
||||
return ErrTarget
|
||||
}
|
||||
return ValidateURL(next.URL.String())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (guard *guardedTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
if request == nil || request.URL == nil {
|
||||
return nil, ErrTarget
|
||||
}
|
||||
canonical, err := normalizeTargetURL(request.URL.String())
|
||||
if err != nil {
|
||||
return nil, ErrTarget
|
||||
}
|
||||
hostname := canonical.Hostname()
|
||||
var addresses []net.IP
|
||||
if literal := net.ParseIP(hostname); literal != nil {
|
||||
addresses = append(addresses, literal)
|
||||
} else {
|
||||
resolver := guard.resolver
|
||||
if resolver == nil {
|
||||
resolver = net.DefaultResolver
|
||||
}
|
||||
resolved, err := resolver.LookupIPAddr(request.Context(), hostname)
|
||||
if err != nil {
|
||||
return nil, ErrTarget
|
||||
}
|
||||
for _, item := range resolved {
|
||||
if item.Zone != "" || !allowedIP(item.IP) {
|
||||
return nil, ErrTarget
|
||||
}
|
||||
addresses = append(addresses, item.IP)
|
||||
}
|
||||
}
|
||||
if len(addresses) == 0 {
|
||||
return nil, ErrTarget
|
||||
}
|
||||
for _, address := range addresses {
|
||||
if !allowedIP(address) {
|
||||
return nil, ErrTarget
|
||||
}
|
||||
}
|
||||
normalized := request.Clone(request.Context())
|
||||
normalized.URL = canonical
|
||||
normalized.Host = canonical.Host
|
||||
response, err := webhook_sender.RoundTripPinned(normalized, addresses, guard.transport)
|
||||
if err != nil {
|
||||
return nil, ErrRequest
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func ValidateURL(rawURL string) error {
|
||||
_, err := normalizeTargetURL(rawURL)
|
||||
return err
|
||||
}
|
||||
|
||||
func normalizeTargetURL(rawURL string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" ||
|
||||
(parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return nil, ErrTarget
|
||||
}
|
||||
hostname := strings.TrimSuffix(parsed.Hostname(), ".")
|
||||
if hostname == "" || strings.Contains(hostname, "%") {
|
||||
return nil, ErrTarget
|
||||
}
|
||||
if net.ParseIP(hostname) == nil {
|
||||
hostname, err = idna.Lookup.ToASCII(hostname)
|
||||
if err != nil || hostname == "" {
|
||||
return nil, ErrTarget
|
||||
}
|
||||
}
|
||||
hostname = strings.ToLower(hostname)
|
||||
if address := net.ParseIP(hostname); address != nil {
|
||||
if !allowedIP(address) {
|
||||
return nil, ErrTarget
|
||||
}
|
||||
hostname = address.String()
|
||||
}
|
||||
if port := parsed.Port(); port != "" {
|
||||
parsed.Host = net.JoinHostPort(hostname, port)
|
||||
} else if strings.Contains(hostname, ":") {
|
||||
parsed.Host = "[" + hostname + "]"
|
||||
} else {
|
||||
parsed.Host = hostname
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
var blockedPrefixes = []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/8"),
|
||||
netip.MustParsePrefix("240.0.0.0/4"),
|
||||
netip.MustParsePrefix("fd00:ec2::/64"),
|
||||
netip.MustParsePrefix("64:ff9b:1::/48"),
|
||||
netip.MustParsePrefix("2001::/32"),
|
||||
}
|
||||
|
||||
func allowedIP(ip net.IP) bool {
|
||||
address, ok := netip.AddrFromSlice(ip)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
address = address.Unmap()
|
||||
if address.IsUnspecified() || address.IsMulticast() || address.IsLinkLocalUnicast() || address.IsLinkLocalMulticast() ||
|
||||
address == netip.MustParseAddr("100.100.100.200") {
|
||||
return false
|
||||
}
|
||||
if netip.MustParsePrefix("::/96").Contains(address) && address != netip.IPv6Loopback() {
|
||||
return false
|
||||
}
|
||||
if netip.MustParsePrefix("64:ff9b::/96").Contains(address) || netip.MustParsePrefix("::ffff:0:0:0/96").Contains(address) {
|
||||
value := address.As16()
|
||||
return allowedIP(net.IPv4(value[12], value[13], value[14], value[15]))
|
||||
}
|
||||
if netip.MustParsePrefix("2002::/16").Contains(address) {
|
||||
value := address.As16()
|
||||
return allowedIP(net.IPv4(value[2], value[3], value[4], value[5]))
|
||||
}
|
||||
for _, prefix := range blockedPrefixes {
|
||||
if prefix.Contains(address) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func sameOrigin(first, second *url.URL) bool {
|
||||
if first == nil || second == nil {
|
||||
return false
|
||||
}
|
||||
first, firstErr := normalizeTargetURL(first.String())
|
||||
second, secondErr := normalizeTargetURL(second.String())
|
||||
if firstErr != nil || secondErr != nil || first.Scheme != second.Scheme || first.Hostname() != second.Hostname() {
|
||||
return false
|
||||
}
|
||||
port := func(value *url.URL) string {
|
||||
if value.Port() != "" {
|
||||
return value.Port()
|
||||
}
|
||||
if value.Scheme == "https" {
|
||||
return "443"
|
||||
}
|
||||
return "80"
|
||||
}
|
||||
return port(first) == port(second)
|
||||
}
|
||||
|
||||
func SafeError(err error) error {
|
||||
if errors.Is(err, ErrTarget) {
|
||||
return ErrTarget
|
||||
}
|
||||
if errors.Is(err, ErrResponseTooLarge) {
|
||||
return ErrResponseTooLarge
|
||||
}
|
||||
if errors.Is(err, ErrResponse) {
|
||||
return ErrResponse
|
||||
}
|
||||
return ErrRequest
|
||||
}
|
||||
|
||||
func ReadResponseBody(body io.Reader) ([]byte, error) {
|
||||
content, err := io.ReadAll(io.LimitReader(body, MaxResponseBytes+1))
|
||||
if err != nil {
|
||||
return nil, ErrResponse
|
||||
}
|
||||
if len(content) > MaxResponseBytes {
|
||||
return nil, ErrResponseTooLarge
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package webhook_sender
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func RoundTripPinned(request *http.Request, addresses []net.IP, input *http.Transport) (*http.Response, error) {
|
||||
if request == nil || request.URL == nil || len(addresses) == 0 || addresses[0] == nil {
|
||||
return nil, errors.New("invalid pinned HTTP target")
|
||||
}
|
||||
originalURL := cloneURL(request.URL)
|
||||
pinned := request.Clone(request.Context())
|
||||
pinned.URL = cloneURL(originalURL)
|
||||
pinned.URL.Host = pinnedHost(addresses[0], originalURL.Port())
|
||||
pinned.Host = originalURL.Host
|
||||
transport := systemTLSTransport(input, originalURL.Hostname(), originalURL, addresses)
|
||||
transport.DisableKeepAlives = true
|
||||
var roundTripper http.RoundTripper = transport
|
||||
if originalURL.Scheme == "http" {
|
||||
roundTripper = &pinnedHTTPProxyRoundTripper{transport: transport, originalHost: originalURL.Host}
|
||||
}
|
||||
response, err := roundTripper.RoundTrip(pinned)
|
||||
if err != nil {
|
||||
transport.CloseIdleConnections()
|
||||
return nil, err
|
||||
}
|
||||
response.Request = request
|
||||
response.Body = &pinnedResponseBody{ReadCloser: response.Body, transport: transport}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
type pinnedResponseBody struct {
|
||||
io.ReadCloser
|
||||
transport *http.Transport
|
||||
}
|
||||
|
||||
func (body *pinnedResponseBody) Close() error {
|
||||
err := body.ReadCloser.Close()
|
||||
body.transport.CloseIdleConnections()
|
||||
return err
|
||||
}
|
||||
@@ -147,6 +147,7 @@
|
||||
</el-radio-group>
|
||||
<el-button
|
||||
v-if="supportsModelDiscovery && modelConfigMode === 'discover'"
|
||||
v-permission="'ai_model_manage'"
|
||||
type="primary"
|
||||
plain
|
||||
:loading="discovering"
|
||||
|
||||
Reference in new issue
Block a user