mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-03 10:59:34 +00:00
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).
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "إعدادات البانل",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "تنظیمات پنل",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "パネル設定",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Настройки",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -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": "Параметри панелі",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "面板设置",
|
||||
|
||||
@@ -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": "面板設定",
|
||||
|
||||
Reference in New Issue
Block a user