feat(node): node hardening — mTLS, hashed+zstd reconcile transport, per-node net metrics (#5382)

* fix(api-docs): document clientIpsByGuid route

Restores a green `go test ./...` baseline: TestAPIRoutesDocumented
flagged POST /panel/api/clients/clientIpsByGuid (added in 9385b6c6)
as undocumented in endpoints.ts.

* test(node): characterize current node TLS + API auth behavior

Phase 0 regression net for the mTLS work. These pass on unchanged
production code and lock the pre-mTLS contracts so later phases can be
proven additive:

- tlsConfigForNode: skip -> InsecureSkipVerify (no VerifyConnection);
  pin -> VerifyConnection installed.
- checkAPIAuth: bearer match -> Next + api_authed; unauthenticated ->
  401 (XHR) / 404; valid session -> Next.
- panel HTTPS listener with no ClientAuth accepts a client that presents
  no client certificate (the browsers-keep-working invariant).

* feat(crypto): node-auth CA + client-cert minting (TDD)

Stdlib-only ECDSA P-256 helpers for the node mTLS work:
- GenerateNodeCA: self-signed CA (IsCA, CertSign, path len 0)
- IssueClientCert: client-auth leaf (ExtKeyUsageClientAuth) signed by CA
- LoadCAFromPEM: parse a CA cert+key for issuing / trust-pool building

Tests assert the contract (leaf verifies against the issuing CA with
ExtKeyUsageClientAuth), seen failing on the assertion before impl.

* feat(node): lazy node mTLS CA + client cert in settings (TDD)

SettingService gains opt-in mTLS material, all stored as Setting rows
with empty defaults and kept out of entity.AllSetting (so private keys
never reach the settings UI/export):
- EnsureNodeMtlsCA: mint+persist the node-auth CA once, reuse thereafter
- EnsureMasterClientCert: issue the master client cert from the CA, idempotent
- NodeMtlsClientCAPool: ClientCAs trust pool for the listener; nil when
  unconfigured so the no-mTLS path is unchanged

Tests assert idempotency and that the client cert verifies against the CA
for client auth; seen failing on the assertion before impl.

* feat(node): mtls client TLS config + master-cert provider (TDD)

tlsConfigForNode gains an 'mtls' branch that presents the master client
certificate and verifies the node server against system roots (no
InsecureSkipVerify, no custom RootCAs). The cert is supplied via an
injected MasterClientCertProvider so runtime need not import service;
it fails closed when unconfigured. skip/pin contracts unchanged.

* feat(node): allow tokenless mtls nodes in remote do() (TDD)

mtls nodes authenticate with a client certificate, so the bearer token
becomes optional for them: do() no longer rejects an empty ApiToken when
TlsVerifyMode is mtls, and the Authorization header is omitted when no
token is set. Every other mode still requires a token (regression kept).

* feat(node): authenticate verified client certs in checkAPIAuth (TDD)

A completed mTLS handshake (non-empty r.TLS.VerifiedChains) now
authenticates an API request, equivalent to a valid bearer token, and
sets api_authed so the CSRF middleware lets cert-authed mutations
through. Bearer/session/reject paths unchanged. The accept-path assert
was mutation-checked (guard flipped -> test red -> reverted).

* feat(node): opt-in mTLS on the panel listener (TDD; mutation-checked)

web.go now applies VerifyClientCertIfGiven + ClientCAs to the HTTPS
listener when a node trust CA is configured, and wires the master client
cert provider for outbound mtls calls. With no CA the listener is
byte-identical to before (browsers unaffected).

applyNodeMtls is covered end-to-end: no-cert client handshakes (browsers
keep working), a CA-signed client cert verifies, a foreign-CA cert is
rejected at the handshake. Mutation-checked:
- RequireAndVerifyClientCert -> no-cert client rejected (red) -> reverted
- drop ClientCAs -> master cert no longer trusted (red) -> reverted

* feat(node): accept mtls verify-mode + CA reveal endpoint (TDD)

- model.Node.TlsVerifyMode validator now accepts 'mtls'
- normalize() preserves mtls and requires the node scheme to be https
  (fail closed), instead of clamping mtls back to verify
- NodeService.NodeMtlsCaCert + POST /panel/api/nodes/mtls/ca return this
  panel's node-auth CA cert (public) to paste into a node, minting the CA
  + master client cert on first call
- endpoints.ts documents the new route (doc-sync test)

No model column added (enum is a string), so no migration/codegen.

* feat(node): node mTLS UI + trust-CA setter (TDD)

Backend:
- NodeService.SetNodeMtlsTrustCA + POST /panel/api/nodes/mtls/trustCA
  store the CA this panel trusts for incoming node-API client certs
  (validates PEM, empty clears); applied on next restart
- endpoints.ts + regenerated openapi.json document both mtls routes

Frontend:
- node form: 'mtls' TLS-verify option + setup hint (zod enum updated)
- Nodes page 'Node mTLS' card: copy this panel's CA, and paste/save the
  trusted parent CA
- en-US i18n keys (other locales fall back to en-US)

Gates green: go build (native+windows), vet, go test ./...; frontend
typecheck, lint, vitest (541).

* style(node): gofmt web_mtls_test doc comment

* feat(node): hashed+zstd reconcile transport (TDD, negotiated, mixed-version safe)

Adds an integrity + compression envelope to node config pushes:
- internal/util/wirecodec: shared zstd codec (bomb-capped decode) +
  SHA-256 hashing + the header/capability constants
- Remote.do(): always attaches X-Config-Sha256 of the uncompressed body;
  zstd-compresses only when the node advertised support (learned from its
  X-3x-Node-Caps response header) and the body is >=1KiB
- ConfigEnvelopeMiddleware on /panel/api: advertises the cap, decompresses
  and verifies the hash (handler not invoked on mismatch) before binding

Mixed-version safe: old nodes never advertise the cap -> plain bodies;
the hash header is verify-if-present so any panel/node mix interoperates
(existing reconcile tests stay green). klauspost/compress promoted to a
direct dep. Hash-mismatch reject was mutation-checked (compare defeated
-> test red -> reverted).

* feat(node): per-node network throughput metrics (TDD)

The node status response already carries gopsutil netIO.up/down (summed
non-virtual interfaces), so no node-side change is needed:
- probe() parses netIO.up/down into HeartbeatPatch.NetUp/NetDown
- Node gains net_up/net_down columns (AutoMigrate); UpdateHeartbeat
  persists them and appends netUp/netDown to the per-node metric history
- NodeMetricKeys whitelists netUp/netDown so the history endpoint serves them
- NodeHistoryPanel renders Net Up/Down sparklines (KB/s, no 0-100 clamp)
- regenerated frontend types + openapi.json for the new Node fields

* feat(node): move node mTLS controls into a toolbar button + modal

The Node mTLS panel was an always-visible card cluttering the nodes
page. Replace it with a 'Node mTLS' button beside 'Add node' that opens
a modal with the same copy-CA + trusted-parent-CA controls; the modal
closes on a successful save. No backend/i18n changes.

* i18n(node): translate mTLS + net-metrics keys for all locales

Adds the node mTLS strings (tlsMtls, mtlsFormHint, mtls.* dialog + the
saveMtls toast) and the netUp/netDown chart labels to all 12 non-English
catalogs (ar, es, fa, id, ja, pt, ru, tr, uk, vi, zh-CN, zh-TW), matching
each catalog's existing terminology. Technical tokens (mTLS/TLS/CA/API/
KB/s) kept verbatim.

* fix(node): address Copilot review on node-hardening PR

- setting_mtls: fail closed on a half-present CA/master-cert pair instead of
  silently regenerating (which would rotate the CA and break fleet trust).
- config_envelope: reject non-zstd Content-Encoding on the envelope path
  rather than hashing/forwarding a still-encoded body to the handler.
- node mTLS: support tokenless mTLS end-to-end — apiToken is now
  required_unless tlsVerifyMode=mtls (model) with matching conditional
  validation in NodeFormSchema, so the runtime allowance is actually reachable.
- NodesPage: add a catch block to onSaveTrustCa so save failures surface.
This commit is contained in:
Sanaei
2026-06-16 12:19:33 +02:00
committed by GitHub
parent f3eba04ed8
commit 37c5e0bfd2
51 changed files with 3073 additions and 1014 deletions
+87 -88
View File
@@ -446,6 +446,7 @@
"inboundClientAddSuccess": "Cliente(s) de entrada añadido(s)",
"inboundClientDeleteSuccess": "Cliente de entrada eliminado",
"inboundClientUpdateSuccess": "Cliente de entrada actualizado",
"savedNodeOfflineWillSync": "Guardado localmente. Un nodo de respaldo está desconectado o deshabilitado: el cambio se sincronizará cuando vuelva a conectarse.",
"delDepletedClientsSuccess": "Todos los clientes con tráfico agotado fueron eliminados",
"resetAllClientTrafficSuccess": "Todo el tráfico del cliente ha sido reiniciado",
"resetAllTrafficSuccess": "Todo el tráfico ha sido reiniciado",
@@ -912,6 +913,8 @@
"status": "Estado",
"cpu": "CPU",
"mem": "Memoria",
"netUp": "Subida de red (KB/s)",
"netDown": "Bajada de red (KB/s)",
"uptime": "Tiempo activo",
"latency": "Latencia",
"lastHeartbeat": "Último latido",
@@ -953,13 +956,29 @@
"probeFailed": "Sondeo fallido",
"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"
"updateNoneEligible": "Selecciona al menos un nodo en línea y habilitado",
"saveMtls": "Guardar mTLS del nodo"
},
"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",
"tlsMtls": "TLS mutuo (certificado de cliente)",
"mtlsFormHint": "Este nodo autentica al panel con un certificado de cliente. Copia el CA de este panel desde la sección mTLS del nodo al nodo, configura su CA de confianza y luego reinícialo.",
"mtls": {
"title": "mTLS del nodo",
"intro": "TLS mutuo añade un factor de certificado de cliente además del token de API para las llamadas entre nodos. Es opcional: déjalo vacío para mantener solo la autenticación por token.",
"copyCa": "Copiar el CA de este panel",
"copyCaHint": "Entrega este CA a los nodos que gestiona este panel y luego configura su verificación TLS como TLS mutuo.",
"caCopied": "Certificado CA copiado al portapapeles",
"caFailed": "No se pudo obtener el certificado CA",
"trustLabel": "CA de confianza (panel superior)",
"trustHint": "Cuando este panel es a su vez un nodo, pega aquí el CA del panel que lo gestiona para exigir su certificado de cliente. Reinicia el panel para aplicar.",
"trustPlaceholder": "-----BEGIN CERTIFICATE-----",
"save": "Guardar CA de confianza",
"saved": "CA de confianza guardado — reinicia el panel para aplicar"
},
"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.",
@@ -1210,55 +1229,60 @@
"getOutboundTrafficError": "Error al obtener el tráfico saliente",
"resetOutboundTrafficError": "Error al reiniciar el tráfico saliente"
},
"emailNotifications": "Notificaciones",
"smtpSettings": "Configuración de SMTP",
"smtpEnable": "Activar notificaciones por correo",
"smtpEnableDesc": "Activar notificaciones por correo mediante SMTP",
"smtpHost": "Servidor SMTP",
"smtpHostDesc": "Nombre del servidor SMTP (p. ej. smtp.gmail.com)",
"smtpPort": "Puerto SMTP",
"smtpPortDesc": "Puerto del servidor SMTP (predeterminado: 587)",
"smtpUsername": "Usuario SMTP",
"smtpUsernameDesc": "Usuario de autenticación SMTP",
"smtpPassword": "Contraseña SMTP",
"smtpPasswordDesc": "Contraseña de autenticación SMTP",
"smtpTo": "Destinatarios",
"smtpToDesc": "Direcciones de correo de los destinatarios separadas por comas",
"emailSettings": "Correo",
"eventCPUHigh": "CPU alta (%)",
"emailNotifications": "Notificaciones",
"smtpEventBusNotify": "Notificaciones por correo de eventos",
"smtpEventBusNotifyDesc": "Seleccione qué eventos generan notificaciones por correo",
"tgEventBusNotify": "Notificaciones de Telegram de eventos",
"tgEventBusNotifyDesc": "Seleccione qué eventos generan notificaciones de Telegram",
"testSmtp": "Enviar correo de prueba",
"testTgBot": "Enviar mensaje de prueba",
"eventGroupOutbound": "Saliente",
"eventGroupSecurity": "Seguridad",
"eventGroupSystem": "Sistema",
"eventGroupXray": "Núcleo de Xray",
"eventLoginAttempt": "Intento de inicio de sesión",
"eventGroupSystem": "Sistema",
"eventGroupSecurity": "Seguridad",
"eventGroupNode": "Nodos",
"eventOutboundDown": "Caído",
"eventOutboundUp": "Activo",
"eventXrayCrash": "Caída",
"eventNodeDown": "Caído",
"eventNodeUp": "Activo",
"eventCPUHigh": "CPU alta (%)",
"requestFailed": "La solicitud falló",
"smtpEnable": "Activar notificaciones por correo",
"smtpEnableDesc": "Activar notificaciones por correo mediante SMTP",
"smtpEncryption": "Cifrado",
"smtpEncryptionDesc": "Método de cifrado de la conexión SMTP",
"smtpEncryptionNone": "Ninguno (texto sin cifrar)",
"smtpEncryptionStartTLS": "STARTTLS",
"smtpEncryptionTLS": "TLS (implícito)",
"smtpEventBusNotify": "Notificaciones por correo de eventos",
"smtpEventBusNotifyDesc": "Seleccione qué eventos generan notificaciones por correo",
"smtpHost": "Servidor SMTP",
"smtpHostDesc": "Nombre del servidor SMTP (p. ej. smtp.gmail.com)",
"smtpHostNotConfigured": "Servidor SMTP no configurado",
"smtpNoRecipients": "No hay destinatarios configurados",
"smtpNotInitialized": "SMTP no inicializado",
"smtpPassword": "Contraseña SMTP",
"smtpPasswordDesc": "Contraseña de autenticación SMTP",
"smtpPort": "Puerto SMTP",
"smtpPortDesc": "Puerto del servidor SMTP (predeterminado: 587)",
"smtpSettings": "Configuración de SMTP",
"smtpStageAuth": "Autenticación",
"smtpStageConnect": "Conexión",
"smtpStageAuth": "Autenticación",
"smtpStageSend": "Envío",
"smtpTestSuccess": "Correo de prueba enviado correctamente",
"smtpTo": "Destinatarios",
"smtpToDesc": "Direcciones de correo de los destinatarios separadas por comas",
"smtpUsername": "Usuario SMTP",
"smtpUsernameDesc": "Usuario de autenticación SMTP",
"smtpHostNotConfigured": "Servidor SMTP no configurado",
"smtpNoRecipients": "No hay destinatarios configurados",
"eventLoginAttempt": "Intento de inicio de sesión",
"telegramTokenConfigured": "Configurado; deje en blanco para mantener el token actual.",
"telegramTokenPlaceholder": "Configurado: introduzca un nuevo token para reemplazarlo",
"testSmtp": "Enviar correo de prueba",
"testTgBot": "Enviar mensaje de prueba",
"smtpPasswordConfigured": "Configurada; deje en blanco para mantener la contraseña actual.",
"smtpPasswordPlaceholder": "Configurada: introduzca una nueva contraseña para reemplazarla",
"smtpNotInitialized": "SMTP no inicializado",
"tgBotNotEnabled": "El bot de Telegram no está activado",
"tgBotNotRunning": "El bot de Telegram no está en ejecución",
"tgEventBusNotify": "Notificaciones de Telegram de eventos",
"tgEventBusNotifyDesc": "Seleccione qué eventos generan notificaciones de Telegram",
"tgTestFailed": "La prueba de Telegram falló",
"tgTestSuccess": "Mensaje de prueba enviado a Telegram",
"tgBotNotRunning": "El bot de Telegram no está en ejecución",
"smtpErrorAuth": "Error de autenticación: compruebe el usuario y la contraseña",
"smtpErrorStarttls": "El servidor requiere STARTTLS: cambie el tipo de cifrado",
"smtpErrorTls": "El servidor requiere TLS: cambie el tipo de cifrado",
@@ -1266,12 +1290,7 @@
"smtpErrorTimeout": "Tiempo de conexión agotado: servidor inaccesible",
"smtpErrorRelay": "El servidor rechaza el envío desde esta dirección",
"smtpErrorEof": "Conexión cerrada por el servidor",
"smtpErrorUnknown": "Error de SMTP: {{ .Error }}",
"eventGroupNode": "Nodos",
"eventNodeDown": "Caído",
"eventNodeUp": "Activo",
"smtpPasswordConfigured": "Configurada; deje en blanco para mantener la contraseña actual.",
"smtpPasswordPlaceholder": "Configurada: introduzca una nueva contraseña para reemplazarla"
"smtpErrorUnknown": "Error de SMTP: {{ .Error }}"
},
"xray": {
"title": "Xray Configuración",
@@ -1319,6 +1338,8 @@
"Inbounds": "Entradas",
"InboundsDesc": "Cambia la plantilla de configuración para aceptar clientes específicos.",
"Outbounds": "Salidas",
"OutboundSubscriptions": "Suscripciones de salida",
"OutboundSubscriptionsDesc": "Importa salidas desde URLs de suscripción remotas (vmess/vless/trojan/ss/...). Las etiquetas se mantienen estables para usarlas en balanceadores y reglas de enrutamiento. Las actualizaciones son automáticas.",
"Balancers": "Equilibradores",
"balancerTagRequired": "La etiqueta es obligatoria",
"balancerSelectorRequired": "Elige al menos una salida",
@@ -1496,8 +1517,6 @@
"privateKey": "Clave privada",
"load": "Carga"
},
"OutboundSubscriptions": "Suscripciones de salida",
"OutboundSubscriptionsDesc": "Importa salidas desde URLs de suscripción remotas (vmess/vless/trojan/ss/...). Las etiquetas se mantienen estables para usarlas en balanceadores y reglas de enrutamiento. Las actualizaciones son automáticas.",
"outboundSub": {
"manage": "Suscripciones",
"title": "Suscripciones de salida",
@@ -1775,17 +1794,17 @@
"SuccessResetTraffic": "📧 Correo: {{ .ClientEmail }}\n🏁 Resultado: ✅ Éxito",
"FailedResetTraffic": "📧 Correo: {{ .ClientEmail }}\n🏁 Resultado: ❌ Fallido \n\n🛠️ Error: [ {{ .ErrorMessage }} ]",
"FinishProcess": "🔚 Proceso de reinicio de tráfico finalizado para todos los clientes.",
"eventCPUHigh": "CPU alta",
"eventCPUHighDetail": "CPU: {{ .Detail }}",
"eventDelayDetail": "Retardo: {{ .Delay }} ms",
"eventErrorDetail": "Error: {{ .Error }}",
"eventLoginFallback": "Inicio de sesión fallido desde {{ .Source }}",
"eventOutboundDown": "El saliente {{ .Tag }} está CAÍDO",
"eventOutboundUp": "El saliente {{ .Tag }} está ACTIVO",
"eventErrorDetail": "Error: {{ .Error }}",
"eventDelayDetail": "Retardo: {{ .Delay }} ms",
"eventXrayCrash": "Xray se ha BLOQUEADO",
"eventXrayCrashError": "Error: {{ .Error }}",
"eventNodeDown": "El nodo {{ .Name }} está CAÍDO",
"eventNodeUp": "El nodo {{ .Name }} está ACTIVO"
"eventNodeUp": "El nodo {{ .Name }} está ACTIVO",
"eventCPUHigh": "CPU alta",
"eventCPUHighDetail": "CPU: {{ .Detail }}",
"eventLoginFallback": "Inicio de sesión fallido desde {{ .Source }}"
},
"buttons": {
"closeKeyboard": "❌ Cerrar Teclado",
@@ -1857,55 +1876,35 @@
}
},
"email": {
"labelDelay": "Retardo",
"labelDetail": "Detalle",
"labelError": "Error",
"labelIP": "IP",
"labelOutbound": "Saliente",
"labelReason": "Motivo",
"labelSource": "Origen",
"labelStatus": "Estado",
"labelTime": "Hora",
"labelUsername": "Usuario",
"statusBanned": "BANNED",
"statusCrashed": "BLOQUEADO",
"statusDown": "CAÍDO",
"statusFailed": "FALLIDO",
"statusFull": "FULL",
"statusHigh": "ALTA",
"statusOffline": "OFFLINE",
"statusOnline": "ONLINE",
"statusRunning": "En ejecución",
"statusSuccess": "CORRECTO",
"statusUp": "ACTIVO",
"statusXrayDown": "Xray DOWN",
"statusXrayUp": "Xray UP",
"subjectCPUHigh": "CPU alta",
"subjectDiskFull": "Disk full",
"subjectIPBanned": "IP banned: {{ .IP }}",
"subjectLoginFailed": "Inicio de sesión fallido",
"subjectLoginSuccess": "Inicio de sesión correcto",
"subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
"subjectNodeOnline": "Node {{ .Node }} is ONLINE",
"subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
"subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
"subjectOutboundDown": "El saliente {{ .Tag }} está CAÍDO",
"subjectOutboundUp": "El saliente {{ .Tag }} está ACTIVO",
"subjectXrayCrash": "Xray se ha BLOQUEADO",
"subjectXrayUp": "Xray is UP",
"titleCPUHigh": "CPU alta",
"titleDiskFull": "Disk full",
"titleIPBanned": "IP banned",
"titleLoginFailed": "Inicio de sesión fallido",
"titleLoginSuccess": "Inicio de sesión correcto",
"titleNodeOffline": "Node OFFLINE",
"titleNodeOnline": "Node ONLINE",
"titleNodeXrayDown": "Node Xray DOWN",
"titleNodeXrayUp": "Node Xray UP",
"subjectCPUHigh": "CPU alta",
"subjectLoginSuccess": "Inicio de sesión correcto",
"subjectLoginFailed": "Inicio de sesión fallido",
"titleOutboundDown": "Saliente CAÍDO",
"titleOutboundUp": "Saliente ACTIVO",
"titleXrayCrash": "Xray se ha BLOQUEADO",
"titleXrayUp": "Xray UP",
"labelNode": "Nodo"
"titleCPUHigh": "CPU alta",
"titleLoginSuccess": "Inicio de sesión correcto",
"titleLoginFailed": "Inicio de sesión fallido",
"labelStatus": "Estado",
"labelOutbound": "Saliente",
"labelNode": "Nodo",
"labelError": "Error",
"labelDelay": "Retardo",
"labelDetail": "Detalle",
"labelUsername": "Usuario",
"labelIP": "IP",
"labelReason": "Motivo",
"labelSource": "Origen",
"labelTime": "Hora",
"statusCrashed": "BLOQUEADO",
"statusRunning": "En ejecución",
"statusHigh": "ALTA",
"statusSuccess": "CORRECTO",
"statusFailed": "FALLIDO",
"statusDown": "CAÍDO",
"statusUp": "ACTIVO"
}
}