1
0

6 Revīzijas b770287995 ... 1c0fdb4527

Autors SHA1 Ziņojums Datums
  MHSanaei 1c0fdb4527 fix(outbounds): test subscriptions in Test All, skip direct/dns 8 stundas atpakaļ
  MHSanaei 2d6dea4bf6 fix(settings): rename remark model 'Other' to 'External Proxy' (#5265) 9 stundas atpakaļ
  MHSanaei 4c8d3cb625 fix(nodes): honor TLS verify mode skip/pin for remote node operations (#5264) 9 stundas atpakaļ
  MHSanaei 9a8247fa78 fix(tgbot): clear legacy panelProxy/tgBotProxy settings on upgrade 9 stundas atpakaļ
  MHSanaei 355262e632 fix(clients): keep the client list live with a background poll (#5262) 10 stundas atpakaļ
  MHSanaei 8f556fe2db fix(clients): centre the online dot inside the Online tag (#5238) 19 stundas atpakaļ

+ 7 - 0
frontend/src/hooks/useClients.ts

@@ -183,6 +183,9 @@ export function useClients() {
     queryKey: keys.clients.list(query),
     queryFn: () => fetchClientPage(query),
     staleTime: Infinity,
+    // List is sorted/paged server-side, so the WS patch can't add new or
+    // re-sort rows; poll the current page to keep it live (pauses when hidden).
+    refetchInterval: 5000,
     placeholderData: keepPreviousData,
   });
 
@@ -216,6 +219,9 @@ export function useClients() {
   const fetched = listQuery.data !== undefined || listQuery.isError;
   const fetchError = listQuery.error ? (listQuery.error as Error).message : '';
   const loading = listQuery.isFetching;
+  // Showing kept-previous data for a new key (filter/sort/page) — drives the
+  // table overlay so the 5s background poll doesn't flash it.
+  const transitioning = listQuery.isPlaceholderData;
 
   const inbounds = inboundOptionsQuery.data ?? [];
   const onlines = useMemo(() => onlinesQuery.data ?? [], [onlinesQuery.data]);
@@ -528,6 +534,7 @@ export function useClients() {
     inbounds,
     onlines,
     loading,
+    transitioning,
     fetched,
     fetchError,
     subSettings,

+ 67 - 18
frontend/src/hooks/useXraySetting.ts

@@ -142,10 +142,12 @@ export function useXraySetting(): UseXraySettingResult {
   const xraySettingRef = useRef('');
   const outboundTestUrlRef = useRef(outboundTestUrl);
   const templateSettingsRef = useRef<XraySettingsValue | null>(null);
+  const subscriptionOutboundsRef = useRef<unknown[]>([]);
 
   xraySettingRef.current = xraySetting;
   outboundTestUrlRef.current = outboundTestUrl;
   templateSettingsRef.current = templateSettings;
+  subscriptionOutboundsRef.current = subscriptionOutbounds;
 
   // Seed local editor state from the config query. Runs on first fetch and
   // every time the query refetches (e.g. after a successful save).
@@ -316,41 +318,61 @@ export function useXraySetting(): UseXraySettingResult {
   );
 
   const testAllOutbounds = useCallback(async (mode = 'tcp') => {
-    const list = templateSettingsRef.current?.outbounds || [];
-    if (list.length === 0 || testingAll) return;
+    // Template outbounds key their results by index (outboundTestStates);
+    // subscription outbounds aren't in the template, so they key by tag
+    // (subscriptionTestStates). Both go through the same probe endpoint.
+    const templateList = templateSettingsRef.current?.outbounds || [];
+    const subList = (subscriptionOutboundsRef.current || []) as Array<{ tag?: string; protocol?: string }>;
+    if ((templateList.length === 0 && subList.length === 0) || testingAll) return;
     setTestingAll(true);
     try {
-      const tcpQueue: { index: number; outbound: unknown }[] = [];
-      const httpQueue: { index: number; outbound: unknown }[] = [];
-      list.forEach((ob, i) => {
-        const tag = ob?.tag;
+      type TcpEntry =
+        | { kind: 'tpl'; index: number; outbound: unknown }
+        | { kind: 'sub'; tag: string; outbound: unknown };
+      const tcpQueue: TcpEntry[] = [];
+      // HTTP batches stay homogeneous (all template or all subscription) so a
+      // tag shared between a template and a subscription outbound can't collide
+      // inside one batch, and each batch's results route to one state map.
+      const httpTplQueue: { index: number; outbound: unknown }[] = [];
+      const httpSubQueue: { tag: string; outbound: unknown }[] = [];
+      const enqueue = (ob: { tag?: string; protocol?: string }, kind: 'tpl' | 'sub', index: number, tag: string) => {
         const proto = ob?.protocol;
-        if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return;
-        if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return;
-        if (mode === 'http' || isUdpOutbound(ob)) {
-          httpQueue.push({ index: i, outbound: ob });
+        if (proto === 'blackhole' || proto === 'loopback' || ob?.tag === 'blocked') return;
+        // freedom ("direct") and dns aren't proxies — skip them in every mode.
+        if (proto === 'freedom' || proto === 'dns') return;
+        if (kind === 'sub' && !tag) return;
+        const toHttp = mode === 'http' || isUdpOutbound(ob);
+        if (kind === 'tpl') {
+          if (toHttp) httpTplQueue.push({ index, outbound: ob });
+          else tcpQueue.push({ kind: 'tpl', index, outbound: ob });
+        } else if (toHttp) {
+          httpSubQueue.push({ tag, outbound: ob });
         } else {
-          tcpQueue.push({ index: i, outbound: ob });
+          tcpQueue.push({ kind: 'sub', tag, outbound: ob });
         }
-      });
+      };
+      templateList.forEach((ob, i) => enqueue(ob, 'tpl', i, ''));
+      subList.forEach((ob) => enqueue(ob, 'sub', -1, typeof ob?.tag === 'string' ? ob.tag : ''));
+
       // TCP probes are dial-only and cheap server-side; per-item requests
-      // keep results landing one by one.
+      // keep results landing one by one, each routed to its own state map.
       const runTcpLane = async () => {
         const queue = [...tcpQueue];
         const worker = async () => {
           while (queue.length > 0) {
             const item = queue.shift();
             if (!item) break;
-            await testOutbound(item.index, item.outbound, mode);
+            if (item.kind === 'sub') await testSubscriptionOutbound(item.tag, item.outbound, mode);
+            else await testOutbound(item.index, item.outbound, mode);
           }
         };
         await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker()));
       };
       // HTTP probes go out as chunked batches — one temp xray spawn per
       // chunk instead of one per outbound, with results landing per chunk.
-      const runHttpLane = async () => {
-        for (let at = 0; at < httpQueue.length; at += HTTP_BATCH_CHUNK) {
-          const chunk = httpQueue.slice(at, at + HTTP_BATCH_CHUNK);
+      const runTplHttpLane = async () => {
+        for (let at = 0; at < httpTplQueue.length; at += HTTP_BATCH_CHUNK) {
+          const chunk = httpTplQueue.slice(at, at + HTTP_BATCH_CHUNK);
           setOutboundTestStates((prev) => {
             const next = { ...prev };
             for (const item of chunk) next[item.index] = { testing: true, result: null, mode: 'http' };
@@ -366,11 +388,38 @@ export function useXraySetting(): UseXraySettingResult {
           });
         }
       };
+      const runSubHttpLane = async () => {
+        for (let at = 0; at < httpSubQueue.length; at += HTTP_BATCH_CHUNK) {
+          const chunk = httpSubQueue.slice(at, at + HTTP_BATCH_CHUNK);
+          setSubscriptionTestStates((prev) => {
+            const next = { ...prev };
+            for (const item of chunk) next[item.tag] = { testing: true, result: null, mode: 'http' };
+            return next;
+          });
+          const results = await postOutboundTestBatch(chunk.map((c) => c.outbound), 'http');
+          setSubscriptionTestStates((prev) => {
+            const next = { ...prev };
+            chunk.forEach((item, i) => {
+              next[item.tag] = { testing: false, result: results[i] };
+            });
+            return next;
+          });
+        }
+      };
+      // HTTP batches must not overlap: the backend serialises them with a
+      // non-blocking lock and rejects a second concurrent batch ("Another
+      // outbound test is already running"). Run the template and subscription
+      // HTTP lanes one after the other; TCP probes don't take that lock, so
+      // they still run alongside.
+      const runHttpLane = async () => {
+        await runTplHttpLane();
+        await runSubHttpLane();
+      };
       await Promise.all([runTcpLane(), runHttpLane()]);
     } finally {
       setTestingAll(false);
     }
-  }, [testingAll, testOutbound, postOutboundTestBatch]);
+  }, [testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch]);
 
   useEffect(() => {
     const timer = window.setInterval(() => {

+ 4 - 4
frontend/src/pages/clients/ClientsPage.tsx

@@ -197,7 +197,7 @@ export default function ClientsPage() {
     summary: serverSummary,
     allGroups,
     setQuery,
-    inbounds, onlines, loading, fetched, fetchError, subSettings,
+    inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
     tgBotEnable, expireDiff, trafficDiff, pageSize,
     create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, bulkAttach, detach, bulkDetach,
     resetTraffic, resetAllTraffics, delDepleted, setEnable,
@@ -649,7 +649,7 @@ export default function ClientsPage() {
           </Tooltip>
         );
         if (record.enable && isOnline(record.email)) return (
-          <Tag color="green"><span className="online-dot" />{t('pages.clients.online')}</Tag>
+          <Tag color="green" className="dot-tag"><span className="online-dot" />{t('pages.clients.online')}</Tag>
         );
         if (!record.enable) return <Tag>{t('disabled')}</Tag>;
         if (bucket === 'expiring') return <Tag color="orange">{t('depletingSoon')}</Tag>;
@@ -1100,7 +1100,7 @@ export default function ClientsPage() {
                         <Table<ClientRecord>
                           columns={columns}
                           dataSource={sortedClients}
-                          loading={loading}
+                          loading={transitioning}
                           rowKey="email"
                           rowSelection={rowSelection}
                           pagination={tablePagination}
@@ -1117,7 +1117,7 @@ export default function ClientsPage() {
                           }}
                         />
                       ) : (
-                        <Spin spinning={loading}>
+                        <Spin spinning={transitioning}>
                           <div className="client-cards">
                             {filteredClients.length > 0 && (
                               <div className="card-bulk-bar">

+ 1 - 1
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -8,7 +8,7 @@ import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from './catTabLabel';
 import { sanitizePath, normalizePath } from './uriPath';
 
-const REMARK_MODELS: Record<string, string> = { i: 'Inbound', e: 'Email', o: 'Other' };
+const REMARK_MODELS: Record<string, string> = { i: 'Inbound', e: 'Email', o: 'External Proxy' };
 const REMARK_SAMPLES: Record<string, string> = { i: 'Germany', e: 'john', o: 'Relay' };
 const REMARK_SEPARATORS = [' ', '-', '_', '@', ':', '~', '|', ',', '.', '/'];
 

+ 1 - 1
frontend/src/pages/xray/outbounds/OutboundCardList.tsx

@@ -110,7 +110,7 @@ export default function OutboundCardList({
                 shape="circle"
                 size="small"
                 loading={isTesting(outboundTestStates, index)}
-                disabled={isUntestable(record, testMode) || isTesting(outboundTestStates, index)}
+                disabled={isUntestable(record) || isTesting(outboundTestStates, index)}
                 icon={<ThunderboltOutlined />}
                 onClick={() => onTest(index, testMode)}
               />

+ 1 - 1
frontend/src/pages/xray/outbounds/SubscriptionOutbounds.tsx

@@ -110,7 +110,7 @@ export default function SubscriptionOutbounds({
           shape="circle"
           size={isMobile ? 'small' : undefined}
           loading={isTesting(subscriptionTestStates, key)}
-          disabled={!record.tag || isUntestable(record, testMode) || isTesting(subscriptionTestStates, key)}
+          disabled={!record.tag || isUntestable(record) || isTesting(subscriptionTestStates, key)}
           icon={<ThunderboltOutlined />}
           onClick={() => onTestSubscription(record as unknown as Record<string, unknown>, testMode)}
         />

+ 5 - 2
frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts

@@ -31,10 +31,13 @@ export function outboundAddresses(o: OutboundRow): string[] {
   }
 }
 
-export function isUntestable(o: OutboundRow, mode: string): boolean {
+export function isUntestable(o: OutboundRow): boolean {
   if (!o) return true;
   if (o.protocol === Protocols.Blackhole || o.protocol === Protocols.Loopback || o.tag === 'blocked') return true;
-  if (mode === 'tcp' && (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS)) return true;
+  // freedom ("direct") and dns aren't proxies — a TCP dial has no endpoint and
+  // an HTTP probe would only measure the host's own direct reachability, so
+  // they're untestable in every mode.
+  if (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS) return true;
   return false;
 }
 

+ 1 - 1
frontend/src/pages/xray/outbounds/useOutboundColumns.tsx

@@ -172,7 +172,7 @@ export function useOutboundColumns({
               type="primary"
               shape="circle"
               loading={isTesting(outboundTestStates, index)}
-              disabled={isUntestable(record, testMode) || isTesting(outboundTestStates, index)}
+              disabled={isUntestable(record) || isTesting(outboundTestStates, index)}
               icon={<ThunderboltOutlined />}
               onClick={() => onTest(index, testMode)}
             />

+ 8 - 0
frontend/src/styles/utils.css

@@ -33,6 +33,14 @@
   animation: online-blink 1.1s ease-in-out infinite;
 }
 
+/* For Tags that carry a status dot: inline layout aligns the dot to
+   baseline + half x-height (vertical-align: middle), which sits visibly
+   off-centre next to the label; flex centring is exact. */
+.dot-tag {
+  display: inline-flex;
+  align-items: center;
+}
+
 @keyframes online-blink {
   0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(82, 196, 26, 0.55); }
   50% { opacity: 0.35; box-shadow: 0 0 0 4px rgba(82, 196, 26, 0); }

+ 19 - 1
internal/database/db.go

@@ -200,7 +200,7 @@ func runSeeders(isUsersEmpty bool) error {
 	}
 
 	if empty && isUsersEmpty {
-		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash"}
+		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash", "LegacyProxySettingsCleanup"}
 		for _, name := range seeders {
 			if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
 				return err
@@ -286,9 +286,27 @@ func runSeeders(isUsersEmpty bool) error {
 			return err
 		}
 	}
+
+	if !slices.Contains(seedersHistory, "LegacyProxySettingsCleanup") {
+		if err := clearLegacyProxySettings(); err != nil {
+			return err
+		}
+	}
 	return nil
 }
 
+// clearLegacyProxySettings drops the deprecated panelProxy/tgBotProxy rows so a
+// stale tgBotProxy no longer masks the panelOutbound egress fallback.
+func clearLegacyProxySettings() error {
+	return db.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Where("key IN ?", []string{"panelProxy", "tgBotProxy"}).
+			Delete(&model.Setting{}).Error; err != nil {
+			return err
+		}
+		return tx.Create(&model.HistoryOfSeeders{SeederName: "LegacyProxySettingsCleanup"}).Error
+	})
+}
+
 func normalizeInboundClientTgId() error {
 	var inbounds []model.Inbound
 	if err := db.Find(&inbounds).Error; err != nil {

+ 20 - 10
internal/web/runtime/remote.go

@@ -23,15 +23,6 @@ import (
 
 const remoteHTTPTimeout = 10 * time.Second
 
-var remoteHTTPClient = &http.Client{
-	Transport: &http.Transport{
-		MaxIdleConns:        64,
-		MaxIdleConnsPerHost: 4,
-		IdleConnTimeout:     60 * time.Second,
-		DialContext:         netsafe.SSRFGuardedDialContext,
-	},
-}
-
 type envelope struct {
 	Success bool            `json:"success"`
 	Msg     string          `json:"msg"`
@@ -43,6 +34,12 @@ type Remote struct {
 
 	mu            sync.RWMutex
 	remoteIDByTag map[string]int
+
+	// Per-node client honoring the TLS verify mode, built once and reused; a
+	// node config change drops the cached Remote so the next one rebuilds it.
+	clientOnce sync.Once
+	client     *http.Client
+	clientErr  error
 }
 
 type RemoteInboundOption struct {
@@ -61,6 +58,15 @@ func NewRemote(n *model.Node) *Remote {
 
 func (r *Remote) Name() string { return "node:" + r.node.Name }
 
+// httpClient lazily builds and caches the per-node client honoring the TLS
+// verify mode, so Remote ops don't fall back to system CA on skip/pin (#5264).
+func (r *Remote) httpClient() (*http.Client, error) {
+	r.clientOnce.Do(func() {
+		r.client, r.clientErr = HTTPClientForNode(r.node)
+	})
+	return r.client, r.clientErr
+}
+
 func (r *Remote) baseURL() (string, error) {
 	addr, err := netsafe.NormalizeHost(r.node.Address)
 	if err != nil {
@@ -129,7 +135,11 @@ func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelo
 		req.Header.Set("Content-Type", contentType)
 	}
 
-	resp, err := remoteHTTPClient.Do(req)
+	client, err := r.httpClient()
+	if err != nil {
+		return nil, err
+	}
+	resp, err := client.Do(req)
 	if err != nil {
 		return nil, fmt.Errorf("%s %s: %w", method, path, err)
 	}

+ 84 - 0
internal/web/runtime/tls_client.go

@@ -0,0 +1,84 @@
+package runtime
+
+import (
+	"crypto/sha256"
+	"crypto/subtle"
+	"crypto/tls"
+	"encoding/base64"
+	"encoding/hex"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
+)
+
+// defaultNodeHTTPClient reaches nodes trusting the system CA store ("verify"
+// mode or plain http); shared so connections pool across nodes.
+var defaultNodeHTTPClient = &http.Client{
+	Transport: &http.Transport{
+		MaxIdleConns:        64,
+		MaxIdleConnsPerHost: 4,
+		IdleConnTimeout:     60 * time.Second,
+		DialContext:         netsafe.SSRFGuardedDialContext,
+	},
+}
+
+// HTTPClientForNode returns the node's HTTP client honoring its TLS verify mode
+// (verify→system CA, skip→no check, pin→leaf SHA-256). Used by both the probe
+// and every Remote op so they can't disagree on a self-signed node (#5264).
+func HTTPClientForNode(n *model.Node) (*http.Client, error) {
+	mode := n.TlsVerifyMode
+	if mode == "" {
+		mode = "verify"
+	}
+	if mode == "verify" || n.Scheme == "http" {
+		return defaultNodeHTTPClient, nil
+	}
+	tlsCfg := &tls.Config{InsecureSkipVerify: true} // lgtm[go/disabled-certificate-check]
+	if mode == "pin" {
+		want, err := DecodeCertPin(n.PinnedCertSha256)
+		if err != nil {
+			return nil, err
+		}
+		tlsCfg.VerifyConnection = func(cs tls.ConnectionState) error {
+			if len(cs.PeerCertificates) == 0 {
+				return common.NewError("node presented no certificate")
+			}
+			sum := sha256.Sum256(cs.PeerCertificates[0].Raw)
+			if subtle.ConstantTimeCompare(sum[:], want) != 1 {
+				return common.NewError("node certificate does not match pinned SHA-256")
+			}
+			return nil
+		}
+	}
+	return &http.Client{
+		Transport: &http.Transport{
+			MaxIdleConns:        64,
+			MaxIdleConnsPerHost: 4,
+			IdleConnTimeout:     60 * time.Second,
+			DialContext:         netsafe.SSRFGuardedDialContext,
+			TLSClientConfig:     tlsCfg,
+		},
+	}, nil
+}
+
+// DecodeCertPin decodes a SHA-256 cert pin given as base64 (Xray's
+// pinnedPeerCertSha256 form) or hex with optional colons into 32 raw bytes.
+func DecodeCertPin(s string) ([]byte, error) {
+	s = strings.TrimSpace(s)
+	if s == "" {
+		return nil, common.NewError("certificate pin is empty")
+	}
+	if b, err := hex.DecodeString(strings.ReplaceAll(s, ":", "")); err == nil && len(b) == sha256.Size {
+		return b, nil
+	}
+	for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
+		if b, err := enc.DecodeString(s); err == nil && len(b) == sha256.Size {
+			return b, nil
+		}
+	}
+	return nil, common.NewError("certificate pin must be a SHA-256 hash (base64 or hex)")
+}

+ 165 - 0
internal/web/runtime/tls_client_test.go

@@ -0,0 +1,165 @@
+package runtime
+
+import (
+	"context"
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/hex"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"strconv"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// nodeForServer builds a node pointing at a loopback test server (loopback is
+// SSRF-blocked, so AllowPrivateAddress is set for the guarded dialer).
+func nodeForServer(t *testing.T, srv *httptest.Server, mode, pin string) *model.Node {
+	t.Helper()
+	u, err := url.Parse(srv.URL)
+	if err != nil {
+		t.Fatalf("parse server url: %v", err)
+	}
+	port, err := strconv.Atoi(u.Port())
+	if err != nil {
+		t.Fatalf("parse server port: %v", err)
+	}
+	return &model.Node{
+		Id:                  1,
+		Name:                "n1",
+		Scheme:              "https",
+		Address:             u.Hostname(),
+		Port:                port,
+		BasePath:            "/",
+		ApiToken:            "token",
+		Enable:              true,
+		AllowPrivateAddress: true,
+		TlsVerifyMode:       mode,
+		PinnedCertSha256:    pin,
+	}
+}
+
+func leafPinBase64(srv *httptest.Server) string {
+	sum := sha256.Sum256(srv.Certificate().Raw)
+	return base64.StdEncoding.EncodeToString(sum[:])
+}
+
+// A self-signed node must be reachable by Remote ops under skip/pin and
+// rejected under verify — the split issue #5264 reported.
+func TestRemoteHonorsTLSVerifyMode(t *testing.T) {
+	srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write([]byte(`{"success":true,"obj":[]}`))
+	}))
+	defer srv.Close()
+
+	goodPin := leafPinBase64(srv)
+	wrongPin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size))
+
+	cases := []struct {
+		name    string
+		mode    string
+		pin     string
+		wantErr bool
+	}{
+		{"verify rejects self-signed", "verify", "", true},
+		{"skip accepts self-signed", "skip", "", false},
+		{"pin accepts matching cert", "pin", goodPin, false},
+		{"pin rejects mismatched cert", "pin", wrongPin, true},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			r := NewRemote(nodeForServer(t, srv, c.mode, c.pin))
+			_, err := r.ListInboundOptions(context.Background())
+			if c.wantErr && err == nil {
+				t.Fatalf("mode %q: expected error, got nil", c.mode)
+			}
+			if !c.wantErr && err != nil {
+				t.Fatalf("mode %q: unexpected error: %v", c.mode, err)
+			}
+		})
+	}
+}
+
+// The lazily-built client is cached for the Remote's lifetime so repeated
+// operations reuse one pooled transport rather than rebuilding TLS each call.
+func TestRemoteClientCached(t *testing.T) {
+	r := NewRemote(&model.Node{Scheme: "https", TlsVerifyMode: "skip"})
+	c1, err1 := r.httpClient()
+	c2, err2 := r.httpClient()
+	if err1 != nil || err2 != nil {
+		t.Fatalf("httpClient errors: %v %v", err1, err2)
+	}
+	if c1 != c2 {
+		t.Fatal("expected the same cached client across calls")
+	}
+}
+
+func TestHTTPClientForNodeVerifyShared(t *testing.T) {
+	// verify mode and plain http both reuse the shared default client.
+	for _, n := range []*model.Node{
+		{Scheme: "https", TlsVerifyMode: "verify"},
+		{Scheme: "https", TlsVerifyMode: ""},
+		{Scheme: "http", TlsVerifyMode: "skip"},
+	} {
+		c, err := HTTPClientForNode(n)
+		if err != nil {
+			t.Fatalf("HTTPClientForNode(%+v): %v", n, err)
+		}
+		if c != defaultNodeHTTPClient {
+			t.Fatalf("HTTPClientForNode(%+v) = %p, want shared default %p", n, c, defaultNodeHTTPClient)
+		}
+	}
+}
+
+func TestHTTPClientForNodePinInvalid(t *testing.T) {
+	if _, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: "not-a-pin"}); err == nil {
+		t.Fatal("expected error for invalid pin")
+	}
+}
+
+func TestDecodeCertPin(t *testing.T) {
+	raw := sha256.Sum256([]byte("cert"))
+	hexColon := strings.ToUpper(hex.EncodeToString(raw[:]))
+	// reinsert colons in openssl -fingerprint style
+	var withColons strings.Builder
+	for i := 0; i < len(hexColon); i += 2 {
+		if i > 0 {
+			withColons.WriteByte(':')
+		}
+		withColons.WriteString(hexColon[i : i+2])
+	}
+
+	cases := []struct {
+		name    string
+		in      string
+		wantErr bool
+	}{
+		{"base64 std", base64.StdEncoding.EncodeToString(raw[:]), false},
+		{"base64 raw url", base64.RawURLEncoding.EncodeToString(raw[:]), false},
+		{"hex bare", hex.EncodeToString(raw[:]), false},
+		{"hex colon openssl", withColons.String(), false},
+		{"empty", "", true},
+		{"garbage", "not-a-pin", true},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			got, err := DecodeCertPin(c.in)
+			if c.wantErr {
+				if err == nil {
+					t.Fatalf("expected error for %q", c.in)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatalf("unexpected error for %q: %v", c.in, err)
+			}
+			if string(got) != string(raw[:]) {
+				t.Fatalf("decoded bytes mismatch for %q", c.in)
+			}
+		})
+	}
+}

+ 2 - 73
internal/web/service/node.go

@@ -3,10 +3,8 @@ package service
 import (
 	"context"
 	"crypto/sha256"
-	"crypto/subtle"
 	"crypto/tls"
 	"encoding/base64"
-	"encoding/hex"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -44,75 +42,6 @@ type HeartbeatPatch struct {
 
 type NodeService struct{}
 
-var nodeHTTPClient = &http.Client{
-	Transport: &http.Transport{
-		MaxIdleConns:        64,
-		MaxIdleConnsPerHost: 4,
-		IdleConnTimeout:     60 * time.Second,
-		DialContext:         netsafe.SSRFGuardedDialContext,
-	},
-}
-
-// nodeHTTPClientFor returns the HTTP client used to reach a node, honoring its
-// per-node TLS verification mode. "verify" (or any http node) uses the shared
-// client with default certificate validation. "skip" disables validation.
-// "pin" disables the default chain check but verifies the leaf certificate's
-// SHA-256 against the stored pin, keeping MITM protection for self-signed certs.
-func nodeHTTPClientFor(n *model.Node) (*http.Client, error) {
-	mode := n.TlsVerifyMode
-	if mode == "" {
-		mode = "verify"
-	}
-	if mode == "verify" || n.Scheme == "http" {
-		return nodeHTTPClient, nil
-	}
-	tlsCfg := &tls.Config{InsecureSkipVerify: true}
-	if mode == "pin" {
-		want, err := decodeCertPin(n.PinnedCertSha256)
-		if err != nil {
-			return nil, err
-		}
-		tlsCfg.VerifyConnection = func(cs tls.ConnectionState) error {
-			if len(cs.PeerCertificates) == 0 {
-				return common.NewError("node presented no certificate")
-			}
-			sum := sha256.Sum256(cs.PeerCertificates[0].Raw)
-			if subtle.ConstantTimeCompare(sum[:], want) != 1 {
-				return common.NewError("node certificate does not match pinned SHA-256")
-			}
-			return nil
-		}
-	}
-	return &http.Client{
-		Transport: &http.Transport{
-			MaxIdleConns:        64,
-			MaxIdleConnsPerHost: 4,
-			IdleConnTimeout:     60 * time.Second,
-			DialContext:         netsafe.SSRFGuardedDialContext,
-			TLSClientConfig:     tlsCfg,
-		},
-	}, nil
-}
-
-// decodeCertPin accepts a SHA-256 certificate hash as base64 (the format used
-// by Xray's pinnedPeerCertSha256) or hex with optional colons (the openssl
-// -fingerprint style) and returns the 32 raw bytes.
-func decodeCertPin(s string) ([]byte, error) {
-	s = strings.TrimSpace(s)
-	if s == "" {
-		return nil, common.NewError("certificate pin is empty")
-	}
-	if b, err := hex.DecodeString(strings.ReplaceAll(s, ":", "")); err == nil && len(b) == sha256.Size {
-		return b, nil
-	}
-	for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
-		if b, err := enc.DecodeString(s); err == nil && len(b) == sha256.Size {
-			return b, nil
-		}
-	}
-	return nil, common.NewError("certificate pin must be a SHA-256 hash (base64 or hex)")
-}
-
 // FetchCertFingerprint connects to the node over HTTPS without verifying the
 // certificate and returns the leaf certificate's SHA-256 as base64, so the UI
 // can offer a "fetch and pin current certificate" action.
@@ -367,7 +296,7 @@ func (s *NodeService) normalize(n *model.Node) error {
 		n.InboundTags = tags
 	}
 	if n.TlsVerifyMode == "pin" {
-		if _, err := decodeCertPin(n.PinnedCertSha256); err != nil {
+		if _, err := runtime.DecodeCertPin(n.PinnedCertSha256); err != nil {
 			return common.NewError(err.Error())
 		}
 	}
@@ -692,7 +621,7 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
 	}
 	req.Header.Set("Accept", "application/json")
 
-	client, err := nodeHTTPClientFor(n)
+	client, err := runtime.HTTPClientForNode(n)
 	if err != nil {
 		patch.LastError = err.Error()
 		return patch, err

+ 4 - 0
internal/web/service/outbound/probe_http.go

@@ -160,6 +160,10 @@ func (s *OutboundService) testOutboundsParsed(items []map[string]any, testURL st
 			r.Error = "Blocked/blackhole outbound cannot be tested"
 		case protocol == "loopback":
 			r.Error = "Loopback outbound cannot be tested"
+		case protocol == "freedom" || protocol == "dns":
+			// Direct/DNS outbounds aren't proxies — an HTTP probe through them
+			// would only measure the host's own reachability, not a tunnel.
+			r.Error = "Direct/DNS outbound cannot be tested"
 		case seenTags[tag]:
 			r.Error = fmt.Sprintf("Duplicate outbound tag in batch: %s", tag)
 		default: