1
0
Эх сурвалжийг харах

feat(nodes): add per-node TLS verification mode for self-signed certs (#4757)

Adds a per-node TLS verification mode to the Add/Edit Node dialog so the panel can reach nodes that serve HTTPS with a self-signed certificate:

- verify (default): normal CA validation.
- skip: InsecureSkipVerify, with a clear UI warning that it drops MITM protection.
- pin: validates the leaf certificate's SHA-256 (base64 or hex) via VerifyConnection while bypassing the default chain/name check — keeps MITM protection for self-signed certs, the secure alternative to skip.

New Node model fields tlsVerifyMode + pinnedCertSha256 (gorm auto-migrated). Probe() selects the HTTP client per node via nodeHTTPClientFor, keeping the SSRF-guarded dialer. A new POST /panel/api/nodes/certFingerprint endpoint (FetchCertFingerprint) lets the UI fetch and pin the node's current certificate in one click. Endpoint documented in api-docs/openapi; i18n added across all locales. Verified end-to-end in Docker (verify rejects, skip bypasses, fetch matches, pin accepts correct / rejects wrong).
MHSanaei 1 өдөр өмнө
parent
commit
56ec359041

+ 2 - 0
database/model/model.go

@@ -379,6 +379,8 @@ type Node struct {
 	ApiToken            string `json:"apiToken" form:"apiToken" validate:"required"`
 	Enable              bool   `json:"enable" form:"enable" gorm:"default:true"`
 	AllowPrivateAddress bool   `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
+	TlsVerifyMode       string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"`
+	PinnedCertSha256    string `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
 
 	// Heartbeat-updated fields. UpdatedAt advances on every probe even when
 	// the row is otherwise unchanged so the UI's "last seen" tooltip is

+ 50 - 0
frontend/public/openapi.json

@@ -4203,6 +4203,56 @@
         }
       }
     },
+    "/panel/api/nodes/certFingerprint": {
+      "post": {
+        "tags": [
+          "Nodes"
+        ],
+        "summary": "Connect to the node over HTTPS without verifying its certificate and return the leaf certificate's SHA-256 (base64). Used by the Add/Edit Node dialog to fetch and pin a self-signed certificate. Uses the same body as /test.",
+        "operationId": "post_panel_api_nodes_certFingerprint",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "scheme": "https",
+                "address": "node1.example.com",
+                "port": 2053,
+                "basePath": "/"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": "k3b1...base64-sha256...="
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/nodes/probe/{id}": {
       "post": {
         "tags": [

+ 2 - 0
frontend/src/api/queries/useNodeMutations.ts

@@ -70,5 +70,7 @@ export function useNodeMutations() {
       const raw = await HttpUtil.post('/panel/api/nodes/test', payload);
       return parseMsg(raw, ProbeResultSchema, 'nodes/test');
     },
+    fetchFingerprint: (payload: Partial<NodeRecord>): Promise<Msg<string>> =>
+      HttpUtil.post<string>('/panel/api/nodes/certFingerprint', payload),
   };
 }

+ 7 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -769,6 +769,13 @@ export const sections: readonly Section[] = [
         body: '{\n  "scheme": "https",\n  "address": "node1.example.com",\n  "port": 2053,\n  "basePath": "/",\n  "apiToken": "abcdef..."\n}',
         response: '{\n  "success": true,\n  "obj": {\n    "status": "online",\n    "latencyMs": 42,\n    "xrayVersion": "25.x.x",\n    "panelVersion": "v3.x.x",\n    "cpuPct": 12.5,\n    "memPct": 45.2,\n    "uptimeSecs": 86400,\n    "error": ""\n  }\n}',
       },
+      {
+        method: 'POST',
+        path: '/panel/api/nodes/certFingerprint',
+        summary: "Connect to the node over HTTPS without verifying its certificate and return the leaf certificate's SHA-256 (base64). Used by the Add/Edit Node dialog to fetch and pin a self-signed certificate. Uses the same body as /test.",
+        body: '{\n  "scheme": "https",\n  "address": "node1.example.com",\n  "port": 2053,\n  "basePath": "/"\n}',
+        response: '{\n  "success": true,\n  "obj": "k3b1...base64-sha256...="\n}',
+      },
       {
         method: 'POST',
         path: '/panel/api/nodes/probe/:id',

+ 67 - 0
frontend/src/pages/nodes/NodeFormModal.tsx

@@ -26,6 +26,7 @@ interface NodeFormModalProps {
   mode: Mode;
   node: NodeRecord | null;
   testConnection: (payload: Partial<NodeRecord>) => Promise<Msg<ProbeResult>>;
+  fetchFingerprint: (payload: Partial<NodeRecord>) => Promise<Msg<string>>;
   save: (payload: Partial<NodeRecord>) => Promise<Msg<unknown>>;
   onOpenChange: (open: boolean) => void;
 }
@@ -42,6 +43,8 @@ function defaultValues(): NodeFormValues {
     apiToken: '',
     enable: true,
     allowPrivateAddress: false,
+    tlsVerifyMode: 'verify',
+    pinnedCertSha256: '',
   };
 }
 
@@ -50,6 +53,7 @@ export default function NodeFormModal({
   mode,
   node,
   testConnection,
+  fetchFingerprint,
   save,
   onOpenChange,
 }: NodeFormModalProps) {
@@ -59,7 +63,9 @@ export default function NodeFormModal({
 
   const [submitting, setSubmitting] = useState(false);
   const [testing, setTesting] = useState(false);
+  const [fetchingPin, setFetchingPin] = useState(false);
   const [testResult, setTestResult] = useState<ProbeResult | null>(null);
+  const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify';
 
   useEffect(() => {
     if (!open) return;
@@ -94,6 +100,8 @@ export default function NodeFormModal({
       apiToken: values.apiToken.trim(),
       enable: values.enable,
       allowPrivateAddress: values.allowPrivateAddress,
+      tlsVerifyMode: values.tlsVerifyMode,
+      pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '',
     };
   }
 
@@ -118,6 +126,27 @@ export default function NodeFormModal({
     }
   }
 
+  async function onFetchPin() {
+    try {
+      await form.validateFields(['address', 'port']);
+    } catch {
+      return;
+    }
+    setFetchingPin(true);
+    try {
+      const payload = buildPayload(form.getFieldsValue(true));
+      const msg = await fetchFingerprint(payload);
+      if (msg?.success && msg.obj) {
+        form.setFieldValue('pinnedCertSha256', msg.obj);
+        messageApi.success(t('pages.nodes.pinFetched'));
+      } else {
+        messageApi.error(msg?.msg || t('pages.nodes.pinFetchFailed'));
+      }
+    } finally {
+      setFetchingPin(false);
+    }
+  }
+
   async function onFinish(values: NodeFormValues) {
     const result = NodeFormSchema.safeParse(values);
     if (!result.success) {
@@ -233,6 +262,44 @@ export default function NodeFormModal({
             <Switch />
           </Form.Item>
 
+          <Form.Item
+            label={t('pages.nodes.tlsVerifyMode')}
+            name="tlsVerifyMode"
+            extra={t('pages.nodes.tlsVerifyModeHint')}
+          >
+            <Select
+              options={[
+                { value: 'verify', label: t('pages.nodes.tlsVerify') },
+                { value: 'pin', label: t('pages.nodes.tlsPin') },
+                { value: 'skip', label: t('pages.nodes.tlsSkip') },
+              ]}
+            />
+          </Form.Item>
+
+          {tlsVerifyMode === 'skip' && (
+            <Alert
+              type="warning"
+              showIcon
+              style={{ marginBottom: 16 }}
+              title={t('pages.nodes.tlsSkipWarning')}
+            />
+          )}
+
+          {tlsVerifyMode === 'pin' && (
+            <Form.Item
+              label={t('pages.nodes.pinnedCert')}
+              name="pinnedCertSha256"
+              extra={t('pages.nodes.pinnedCertHint')}
+            >
+              <Input.Search
+                placeholder={t('pages.nodes.pinnedCertPlaceholder')}
+                enterButton={t('pages.nodes.fetchPin')}
+                loading={fetchingPin}
+                onSearch={onFetchPin}
+              />
+            </Form.Item>
+          )}
+
           <Form.Item
             label={t('pages.nodes.apiToken')}
             name="apiToken"

+ 2 - 1
frontend/src/pages/nodes/NodesPage.tsx

@@ -30,7 +30,7 @@ export default function NodesPage() {
   useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
 
   const { nodes, loading, fetched, fetchError, refetch, totals } = useNodesQuery();
-  const { create, update, remove, setEnable, testConnection, probe, updatePanels } = useNodeMutations();
+  const { create, update, remove, setEnable, testConnection, fetchFingerprint, probe, updatePanels } = useNodeMutations();
 
   const { data: latestVersion = '' } = useQuery({
     queryKey: ['server', 'panelUpdateInfo'],
@@ -231,6 +231,7 @@ export default function NodesPage() {
           mode={formMode}
           node={formNode}
           testConnection={testConnection}
+          fetchFingerprint={fetchFingerprint}
           save={onSave}
           onOpenChange={setFormOpen}
         />

+ 4 - 0
frontend/src/schemas/node.ts

@@ -24,6 +24,8 @@ export const NodeRecordSchema = z.object({
   lastHeartbeat: z.number().optional(),
   lastError: z.string().optional(),
   allowPrivateAddress: z.boolean().optional(),
+  tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
+  pinnedCertSha256: z.string().optional(),
 }).loose();
 
 export const NodeListSchema = z.array(NodeRecordSchema);
@@ -46,6 +48,8 @@ export const NodeFormSchema = z.object({
   apiToken: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
   enable: z.boolean(),
   allowPrivateAddress: z.boolean(),
+  tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
+  pinnedCertSha256: z.string(),
 });
 
 export type NodeRecord = z.infer<typeof NodeRecordSchema>;

+ 24 - 0
web/controller/node.go

@@ -34,6 +34,7 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) {
 	g.POST("/setEnable/:id", a.setEnable)
 
 	g.POST("/test", a.test)
+	g.POST("/certFingerprint", a.certFingerprint)
 	g.POST("/probe/:id", a.probe)
 	g.POST("/updatePanel", a.updatePanel)
 	g.GET("/history/:id/:metric/:bucket", a.history)
@@ -143,6 +144,29 @@ func (a *NodeController) test(c *gin.Context) {
 	jsonObj(c, patch.ToUI(err == nil), nil)
 }
 
+func (a *NodeController) certFingerprint(c *gin.Context) {
+	n := &model.Node{}
+	if err := c.ShouldBind(n); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
+		return
+	}
+	if n.Scheme == "" {
+		n.Scheme = "https"
+	}
+	if n.BasePath == "" {
+		n.BasePath = "/"
+	}
+
+	ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
+	defer cancel()
+	fp, err := a.nodeService.FetchCertFingerprint(ctx, n)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
+		return
+	}
+	jsonObj(c, fp, nil)
+}
+
 func (a *NodeController) probe(c *gin.Context) {
 	id, err := strconv.Atoi(c.Param("id"))
 	if err != nil {

+ 130 - 1
web/service/node.go

@@ -2,6 +2,11 @@ package service
 
 import (
 	"context"
+	"crypto/sha256"
+	"crypto/subtle"
+	"crypto/tls"
+	"encoding/base64"
+	"encoding/hex"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -42,6 +47,113 @@ var nodeHTTPClient = &http.Client{
 	},
 }
 
+// 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.
+func (s *NodeService) FetchCertFingerprint(ctx context.Context, n *model.Node) (string, error) {
+	addr, err := netsafe.NormalizeHost(n.Address)
+	if err != nil {
+		return "", err
+	}
+	scheme := n.Scheme
+	if scheme != "http" && scheme != "https" {
+		scheme = "https"
+	}
+	if scheme != "https" {
+		return "", common.NewError("certificate pinning is only available for https nodes")
+	}
+	if n.Port <= 0 || n.Port > 65535 {
+		return "", common.NewError("node port must be 1-65535")
+	}
+	probeURL := &url.URL{
+		Scheme: scheme,
+		Host:   net.JoinHostPort(addr, strconv.Itoa(n.Port)),
+		Path:   normalizeBasePath(n.BasePath) + "panel/api/server/status",
+	}
+	req, err := http.NewRequestWithContext(
+		netsafe.ContextWithAllowPrivate(ctx, n.AllowPrivateAddress),
+		http.MethodGet, probeURL.String(), nil)
+	if err != nil {
+		return "", err
+	}
+	client := &http.Client{
+		Transport: &http.Transport{
+			DialContext:     netsafe.SSRFGuardedDialContext,
+			TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+		},
+	}
+	resp, err := client.Do(req)
+	if err != nil {
+		return "", err
+	}
+	defer resp.Body.Close()
+	if resp.TLS == nil || len(resp.TLS.PeerCertificates) == 0 {
+		return "", common.NewError("node did not present a TLS certificate")
+	}
+	sum := sha256.Sum256(resp.TLS.PeerCertificates[0].Raw)
+	return base64.StdEncoding.EncodeToString(sum[:]), nil
+}
+
 func (s *NodeService) GetAll() ([]*model.Node, error) {
 	db := database.GetDB()
 	var nodes []*model.Node
@@ -187,6 +299,15 @@ func (s *NodeService) normalize(n *model.Node) error {
 	if n.Scheme != "http" && n.Scheme != "https" {
 		n.Scheme = "https"
 	}
+	if n.TlsVerifyMode != "skip" && n.TlsVerifyMode != "pin" {
+		n.TlsVerifyMode = "verify"
+	}
+	n.PinnedCertSha256 = strings.TrimSpace(n.PinnedCertSha256)
+	if n.TlsVerifyMode == "pin" {
+		if _, err := decodeCertPin(n.PinnedCertSha256); err != nil {
+			return common.NewError(err.Error())
+		}
+	}
 	n.BasePath = normalizeBasePath(n.BasePath)
 	return nil
 }
@@ -218,6 +339,8 @@ func (s *NodeService) Update(id int, in *model.Node) error {
 		"api_token":             in.ApiToken,
 		"enable":                in.Enable,
 		"allow_private_address": in.AllowPrivateAddress,
+		"tls_verify_mode":       in.TlsVerifyMode,
+		"pinned_cert_sha256":    in.PinnedCertSha256,
 	}
 	if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
 		return err
@@ -365,8 +488,14 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
 	}
 	req.Header.Set("Accept", "application/json")
 
+	client, err := nodeHTTPClientFor(n)
+	if err != nil {
+		patch.LastError = err.Error()
+		return patch, err
+	}
+
 	start := time.Now()
-	resp, err := nodeHTTPClient.Do(req)
+	resp, err := client.Do(req)
 	if err != nil {
 		patch.LastError = err.Error()
 		return patch, err

+ 13 - 1
web/translation/ar-EG.json

@@ -869,7 +869,19 @@
         "updateStarted": "بدأ تحديث اللوحة",
         "updateResult": "تم بدء التحديث على {ok} عقدة، فشل {failed}",
         "updateNoneEligible": "اختر عقدة واحدة على الأقل متصلة ومفعّلة"
-      }
+      },
+      "tlsVerifyMode": "التحقق من TLS",
+      "tlsVerifyModeHint": "كيف يتحقق اللوحة من شهادة HTTPS الخاصة بالعقدة. التثبيت أو التخطّي مخصّصان للشهادات الموقّعة ذاتيًا (عُقد https فقط).",
+      "tlsVerify": "تحقّق (CA الافتراضية)",
+      "tlsPin": "تثبيت الشهادة (SHA-256)",
+      "tlsSkip": "تخطّي التحقق",
+      "tlsSkipWarning": "تخطّي التحقق يزيل الحماية من هجمات الوسيط — قد يُعترض رمز الـ API. يُفضَّل تثبيت الشهادة بدلاً من ذلك.",
+      "pinnedCert": "SHA-256 للشهادة المثبّتة",
+      "pinnedCertHint": "SHA-256 لشهادة العقدة بصيغة base64 أو hex. استخدم \"جلب\" لقراءتها من العقدة الآن.",
+      "pinnedCertPlaceholder": "SHA-256 بصيغة base64 أو hex",
+      "fetchPin": "جلب",
+      "pinFetched": "تم جلب شهادة العقدة الحالية",
+      "pinFetchFailed": "تعذّر جلب الشهادة"
     },
     "settings": {
       "title": "إعدادات البانل",

+ 13 - 1
web/translation/en-US.json

@@ -869,7 +869,19 @@
         "updateStarted": "Panel update started",
         "updateResult": "Update triggered on {ok} node(s), {failed} failed",
         "updateNoneEligible": "Select at least one online, enabled node"
-      }
+      },
+      "tlsVerifyMode": "TLS verification",
+      "tlsVerifyModeHint": "How the panel validates the node's HTTPS certificate. Pin or Skip are for self-signed certs (https nodes only).",
+      "tlsVerify": "Verify (default CA)",
+      "tlsPin": "Pin certificate (SHA-256)",
+      "tlsSkip": "Skip verification",
+      "tlsSkipWarning": "Skipping verification removes protection against man-in-the-middle attacks — the API token could be intercepted. Prefer pinning the certificate.",
+      "pinnedCert": "Pinned certificate SHA-256",
+      "pinnedCertHint": "Base64 or hex SHA-256 of the node's certificate. Use Fetch to read it from the node now.",
+      "pinnedCertPlaceholder": "base64 or hex SHA-256",
+      "fetchPin": "Fetch",
+      "pinFetched": "Fetched the node's current certificate",
+      "pinFetchFailed": "Could not fetch the certificate"
     },
     "settings": {
       "title": "Panel Settings",

+ 13 - 1
web/translation/es-ES.json

@@ -869,7 +869,19 @@
         "updateStarted": "Actualización del panel iniciada",
         "updateResult": "Actualización iniciada en {ok} nodo(s), {failed} fallaron",
         "updateNoneEligible": "Selecciona al menos un nodo en línea y habilitado"
-      }
+      },
+      "tlsVerifyMode": "Verificación TLS",
+      "tlsVerifyModeHint": "Cómo valida el panel el certificado HTTPS del nodo. Fijar u Omitir son para certificados autofirmados (solo nodos https).",
+      "tlsVerify": "Verificar (CA predeterminada)",
+      "tlsPin": "Fijar certificado (SHA-256)",
+      "tlsSkip": "Omitir verificación",
+      "tlsSkipWarning": "Omitir la verificación elimina la protección contra ataques de intermediario; el token de API podría ser interceptado. Es preferible fijar el certificado.",
+      "pinnedCert": "SHA-256 del certificado fijado",
+      "pinnedCertHint": "SHA-256 del certificado del nodo en base64 o hex. Usa Obtener para leerlo del nodo ahora.",
+      "pinnedCertPlaceholder": "SHA-256 en base64 o hex",
+      "fetchPin": "Obtener",
+      "pinFetched": "Se obtuvo el certificado actual del nodo",
+      "pinFetchFailed": "No se pudo obtener el certificado"
     },
     "settings": {
       "title": "Configuraciones",

+ 13 - 1
web/translation/fa-IR.json

@@ -869,7 +869,19 @@
         "updateStarted": "به‌روزرسانی پنل آغاز شد",
         "updateResult": "به‌روزرسانی روی {ok} نود آغاز شد، {failed} ناموفق",
         "updateNoneEligible": "حداقل یک نود آنلاین و فعال انتخاب کنید"
-      }
+      },
+      "tlsVerifyMode": "اعتبارسنجی TLS",
+      "tlsVerifyModeHint": "اینکه پنل گواهی HTTPS نود را چطور بررسی کند. Pin یا Skip برای گواهی‌های self-signed است (فقط نودهای https).",
+      "tlsVerify": "اعتبارسنجی (CA پیش‌فرض)",
+      "tlsPin": "Pin گواهی (SHA-256)",
+      "tlsSkip": "رد کردن اعتبارسنجی",
+      "tlsSkipWarning": "رد کردن اعتبارسنجی محافظت در برابر حملهٔ مرد میانی را از بین می‌برد و توکن API ممکن است شنود شود. ترجیحاً به‌جای آن گواهی را Pin کنید.",
+      "pinnedCert": "SHA-256 گواهیِ Pin‌شده",
+      "pinnedCertHint": "SHA-256 گواهیِ نود به‌صورت base64 یا hex. برای خواندنِ همین حالا از نود، از دکمهٔ Fetch استفاده کنید.",
+      "pinnedCertPlaceholder": "SHA-256 به‌صورت base64 یا hex",
+      "fetchPin": "دریافت",
+      "pinFetched": "گواهیِ فعلیِ نود دریافت شد",
+      "pinFetchFailed": "دریافت گواهی ممکن نشد"
     },
     "settings": {
       "title": "تنظیمات پنل",

+ 13 - 1
web/translation/id-ID.json

@@ -869,7 +869,19 @@
         "updateStarted": "Pembaruan panel dimulai",
         "updateResult": "Pembaruan dipicu pada {ok} node, {failed} gagal",
         "updateNoneEligible": "Pilih minimal satu node online dan aktif"
-      }
+      },
+      "tlsVerifyMode": "Verifikasi TLS",
+      "tlsVerifyModeHint": "Cara panel memvalidasi sertifikat HTTPS node. Pin atau Lewati untuk sertifikat self-signed (hanya node https).",
+      "tlsVerify": "Verifikasi (CA bawaan)",
+      "tlsPin": "Pin sertifikat (SHA-256)",
+      "tlsSkip": "Lewati verifikasi",
+      "tlsSkipWarning": "Melewati verifikasi menghilangkan perlindungan terhadap serangan man-in-the-middle — token API bisa disadap. Lebih baik pin sertifikat.",
+      "pinnedCert": "SHA-256 sertifikat yang dipin",
+      "pinnedCertHint": "SHA-256 sertifikat node dalam base64 atau hex. Gunakan Ambil untuk membacanya dari node sekarang.",
+      "pinnedCertPlaceholder": "SHA-256 base64 atau hex",
+      "fetchPin": "Ambil",
+      "pinFetched": "Berhasil mengambil sertifikat node saat ini",
+      "pinFetchFailed": "Tidak dapat mengambil sertifikat"
     },
     "settings": {
       "title": "Pengaturan Panel",

+ 13 - 1
web/translation/ja-JP.json

@@ -869,7 +869,19 @@
         "updateStarted": "パネルの更新を開始しました",
         "updateResult": "{ok} 個のノードで更新を開始、{failed} 個失敗",
         "updateNoneEligible": "オンラインで有効なノードを少なくとも1つ選択してください"
-      }
+      },
+      "tlsVerifyMode": "TLS 検証",
+      "tlsVerifyModeHint": "パネルがノードの HTTPS 証明書を検証する方法。ピン留めやスキップは自己署名証明書向け(https ノードのみ)。",
+      "tlsVerify": "検証(既定の CA)",
+      "tlsPin": "証明書をピン留め(SHA-256)",
+      "tlsSkip": "検証をスキップ",
+      "tlsSkipWarning": "検証をスキップすると中間者攻撃への保護がなくなり、API トークンが傍受される恐れがあります。証明書のピン留めを推奨します。",
+      "pinnedCert": "ピン留め証明書の SHA-256",
+      "pinnedCertHint": "ノード証明書の SHA-256(base64 または hex)。「取得」でノードから今すぐ読み取れます。",
+      "pinnedCertPlaceholder": "base64 または hex の SHA-256",
+      "fetchPin": "取得",
+      "pinFetched": "ノードの現在の証明書を取得しました",
+      "pinFetchFailed": "証明書を取得できませんでした"
     },
     "settings": {
       "title": "パネル設定",

+ 13 - 1
web/translation/pt-BR.json

@@ -869,7 +869,19 @@
         "updateStarted": "Atualização do painel iniciada",
         "updateResult": "Atualização iniciada em {ok} nó(s), {failed} falharam",
         "updateNoneEligible": "Selecione pelo menos um nó online e ativo"
-      }
+      },
+      "tlsVerifyMode": "Verificação TLS",
+      "tlsVerifyModeHint": "Como o painel valida o certificado HTTPS do nó. Fixar ou Ignorar são para certificados autoassinados (apenas nós https).",
+      "tlsVerify": "Verificar (CA padrão)",
+      "tlsPin": "Fixar certificado (SHA-256)",
+      "tlsSkip": "Ignorar verificação",
+      "tlsSkipWarning": "Ignorar a verificação remove a proteção contra ataques man-in-the-middle — o token de API pode ser interceptado. Prefira fixar o certificado.",
+      "pinnedCert": "SHA-256 do certificado fixado",
+      "pinnedCertHint": "SHA-256 do certificado do nó em base64 ou hex. Use Obter para lê-lo do nó agora.",
+      "pinnedCertPlaceholder": "SHA-256 em base64 ou hex",
+      "fetchPin": "Obter",
+      "pinFetched": "Certificado atual do nó obtido",
+      "pinFetchFailed": "Não foi possível obter o certificado"
     },
     "settings": {
       "title": "Configurações do Painel",

+ 13 - 1
web/translation/ru-RU.json

@@ -869,7 +869,19 @@
         "updateStarted": "Обновление панели запущено",
         "updateResult": "Обновление запущено на {ok} узлах, {failed} не удалось",
         "updateNoneEligible": "Выберите хотя бы один включённый узел в сети"
-      }
+      },
+      "tlsVerifyMode": "Проверка TLS",
+      "tlsVerifyModeHint": "Как панель проверяет HTTPS-сертификат узла. Закрепление или Пропуск — для самоподписанных сертификатов (только https-узлы).",
+      "tlsVerify": "Проверять (стандартный CA)",
+      "tlsPin": "Закрепить сертификат (SHA-256)",
+      "tlsSkip": "Пропустить проверку",
+      "tlsSkipWarning": "Пропуск проверки убирает защиту от атак «человек посередине» — токен API может быть перехвачен. Лучше закрепить сертификат.",
+      "pinnedCert": "SHA-256 закреплённого сертификата",
+      "pinnedCertHint": "SHA-256 сертификата узла в base64 или hex. Нажмите «Получить», чтобы считать его с узла сейчас.",
+      "pinnedCertPlaceholder": "SHA-256 в base64 или hex",
+      "fetchPin": "Получить",
+      "pinFetched": "Текущий сертификат узла получен",
+      "pinFetchFailed": "Не удалось получить сертификат"
     },
     "settings": {
       "title": "Настройки",

+ 13 - 1
web/translation/tr-TR.json

@@ -869,7 +869,19 @@
         "updateStarted": "Panel güncellemesi başlatıldı",
         "updateResult": "{ok} düğümde güncelleme başlatıldı, {failed} başarısız",
         "updateNoneEligible": "En az bir çevrimiçi ve etkin düğüm seçin"
-      }
+      },
+      "tlsVerifyMode": "TLS doğrulaması",
+      "tlsVerifyModeHint": "Panelin düğümün HTTPS sertifikasını nasıl doğrulayacağı. Sabitle veya Atla, kendinden imzalı sertifikalar içindir (yalnızca https düğümleri).",
+      "tlsVerify": "Doğrula (varsayılan CA)",
+      "tlsPin": "Sertifikayı sabitle (SHA-256)",
+      "tlsSkip": "Doğrulamayı atla",
+      "tlsSkipWarning": "Doğrulamayı atlamak, ortadaki adam saldırılarına karşı korumayı kaldırır — API anahtarı ele geçirilebilir. Bunun yerine sertifikayı sabitlemeniz önerilir.",
+      "pinnedCert": "Sabitlenen sertifika SHA-256",
+      "pinnedCertHint": "Düğüm sertifikasının base64 veya hex biçiminde SHA-256 değeri. Şimdi düğümden okumak için Getir'i kullanın.",
+      "pinnedCertPlaceholder": "base64 veya hex SHA-256",
+      "fetchPin": "Getir",
+      "pinFetched": "Düğümün geçerli sertifikası alındı",
+      "pinFetchFailed": "Sertifika alınamadı"
     },
     "settings": {
       "title": "Panel Ayarları",

+ 13 - 1
web/translation/uk-UA.json

@@ -869,7 +869,19 @@
         "updateStarted": "Оновлення панелі розпочато",
         "updateResult": "Оновлення запущено на {ok} вузлах, {failed} не вдалося",
         "updateNoneEligible": "Виберіть принаймні один увімкнений вузол у мережі"
-      }
+      },
+      "tlsVerifyMode": "Перевірка TLS",
+      "tlsVerifyModeHint": "Як панель перевіряє HTTPS-сертифікат вузла. Закріплення або Пропуск — для самопідписаних сертифікатів (лише https-вузли).",
+      "tlsVerify": "Перевіряти (стандартний CA)",
+      "tlsPin": "Закріпити сертифікат (SHA-256)",
+      "tlsSkip": "Пропустити перевірку",
+      "tlsSkipWarning": "Пропуск перевірки прибирає захист від атак «людина посередині» — токен API можуть перехопити. Краще закріпити сертифікат.",
+      "pinnedCert": "SHA-256 закріпленого сертифіката",
+      "pinnedCertHint": "SHA-256 сертифіката вузла у base64 або hex. Натисніть «Отримати», щоб зчитати його з вузла зараз.",
+      "pinnedCertPlaceholder": "SHA-256 у base64 або hex",
+      "fetchPin": "Отримати",
+      "pinFetched": "Поточний сертифікат вузла отримано",
+      "pinFetchFailed": "Не вдалося отримати сертифікат"
     },
     "settings": {
       "title": "Параметри панелі",

+ 13 - 1
web/translation/vi-VN.json

@@ -869,7 +869,19 @@
         "updateStarted": "Đã bắt đầu cập nhật bảng điều khiển",
         "updateResult": "Đã kích hoạt cập nhật trên {ok} node, {failed} thất bại",
         "updateNoneEligible": "Chọn ít nhất một node trực tuyến và đang bật"
-      }
+      },
+      "tlsVerifyMode": "Xác minh TLS",
+      "tlsVerifyModeHint": "Cách panel xác thực chứng chỉ HTTPS của node. Ghim hoặc Bỏ qua dành cho chứng chỉ tự ký (chỉ node https).",
+      "tlsVerify": "Xác minh (CA mặc định)",
+      "tlsPin": "Ghim chứng chỉ (SHA-256)",
+      "tlsSkip": "Bỏ qua xác minh",
+      "tlsSkipWarning": "Bỏ qua xác minh sẽ loại bỏ bảo vệ trước tấn công xen giữa — token API có thể bị chặn bắt. Nên ghim chứng chỉ thay vì vậy.",
+      "pinnedCert": "SHA-256 của chứng chỉ đã ghim",
+      "pinnedCertHint": "SHA-256 của chứng chỉ node ở dạng base64 hoặc hex. Dùng Lấy để đọc trực tiếp từ node.",
+      "pinnedCertPlaceholder": "SHA-256 base64 hoặc hex",
+      "fetchPin": "Lấy",
+      "pinFetched": "Đã lấy chứng chỉ hiện tại của node",
+      "pinFetchFailed": "Không thể lấy chứng chỉ"
     },
     "settings": {
       "title": "Cài đặt",

+ 13 - 1
web/translation/zh-CN.json

@@ -869,7 +869,19 @@
         "updateStarted": "已开始更新面板",
         "updateResult": "已在 {ok} 个节点上触发更新,{failed} 个失败",
         "updateNoneEligible": "请至少选择一个在线且已启用的节点"
-      }
+      },
+      "tlsVerifyMode": "TLS 校验",
+      "tlsVerifyModeHint": "面板如何校验节点的 HTTPS 证书。固定或跳过用于自签名证书(仅 https 节点)。",
+      "tlsVerify": "校验(默认 CA)",
+      "tlsPin": "固定证书(SHA-256)",
+      "tlsSkip": "跳过校验",
+      "tlsSkipWarning": "跳过校验会失去对中间人攻击的防护,API 令牌可能被截获。建议改用固定证书。",
+      "pinnedCert": "固定证书的 SHA-256",
+      "pinnedCertHint": "节点证书的 SHA-256(base64 或 hex)。点击“获取”可立即从节点读取。",
+      "pinnedCertPlaceholder": "base64 或 hex 的 SHA-256",
+      "fetchPin": "获取",
+      "pinFetched": "已获取节点当前证书",
+      "pinFetchFailed": "无法获取证书"
     },
     "settings": {
       "title": "面板设置",

+ 13 - 1
web/translation/zh-TW.json

@@ -869,7 +869,19 @@
         "updateStarted": "已開始更新面板",
         "updateResult": "已在 {ok} 個節點上觸發更新,{failed} 個失敗",
         "updateNoneEligible": "請至少選擇一個在線且已啟用的節點"
-      }
+      },
+      "tlsVerifyMode": "TLS 驗證",
+      "tlsVerifyModeHint": "面板如何驗證節點的 HTTPS 憑證。釘選或略過用於自簽憑證(僅 https 節點)。",
+      "tlsVerify": "驗證(預設 CA)",
+      "tlsPin": "釘選憑證(SHA-256)",
+      "tlsSkip": "略過驗證",
+      "tlsSkipWarning": "略過驗證會失去對中間人攻擊的防護,API 權杖可能被攔截。建議改用釘選憑證。",
+      "pinnedCert": "釘選憑證的 SHA-256",
+      "pinnedCertHint": "節點憑證的 SHA-256(base64 或 hex)。點選「取得」可立即從節點讀取。",
+      "pinnedCertPlaceholder": "base64 或 hex 的 SHA-256",
+      "fetchPin": "取得",
+      "pinFetched": "已取得節點目前憑證",
+      "pinFetchFailed": "無法取得憑證"
     },
     "settings": {
       "title": "面板設定",