mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-10 20:27:15 +00:00
47d2303334
An inbound could be saved with security "tls" and a certificate row carrying neither a file path nor inline content. Nothing rejected it, so the row reached xray-core, whose readFileOrString fails with "both file and bytes are empty" and takes the whole config build down with it — every other inbound included. Validate the credentials on both sides of the wire. validateInboundTLSCertificates follows xray's file-over-inline precedence, requires a private key for every non-verify certificate and insists on at least one server certificate, so a verify-only CA list no longer passes as a server config. The inbound form's Zod schema enforces the same rules per field and serializes only the editor mode the operator actually used, and a failed save jumps to the Security tab naming the certificate row that broke. On update the guard is scoped to a real TLS edit. A row already stored incomplete is grandfathered: it stays editable, and only a save that breaks a previously valid block is refused. A sub-node stores whatever the master pushes, and Remote.UpdateInbound falls back to AddInbound when the node does not yet hold the tag, so a grandfathered row could otherwise never be deployed or re-seeded — the rejection is swallowed to a logger.Debug line and the node stays on a stale config while the panel shows the client as cut off. The controller now marks a node-sync request (mTLS or a node-sync token) on a per-request copy of InboundService, and the guard steps aside for it on both add and update: the row was judged where the operator acted, and a node that refuses it only falls out of sync. Operator and admin-token saves are held to the guard as before. The security union is parameterised on its tlsSettings branch instead of copied, and tlsCertUsesFiles is the one file-vs-inline inference shared by the form schema and the adapter, so the mode the editor opens in and the pair of fields the save serializes cannot drift apart.
186 lines
10 KiB
Go
186 lines
10 KiB
Go
package service
|
|
|
|
import (
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
)
|
|
|
|
func TestValidateInboundTLSCertificates(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
streamSettings string
|
|
wantErr bool
|
|
}{
|
|
{"empty stream", "", false},
|
|
{"whitespace stream", " \t\n", false},
|
|
{"none ignores stale TLS settings", `{"security":"none","tlsSettings":{"certificates":[{}]}}`, false},
|
|
{"reality needs no TLS certificate", `{"security":"reality","realitySettings":{}}`, false},
|
|
{"missing TLS settings", `{"security":"tls"}`, true},
|
|
{"uppercase TLS security", `{"security":"TLS","tlsSettings":{}}`, true},
|
|
{"mixed-case TLS security", `{"security":"Tls","tlsSettings":{}}`, true},
|
|
{"null TLS settings", `{"security":"tls","tlsSettings":null}`, true},
|
|
{"missing certificates", `{"security":"tls","tlsSettings":{}}`, true},
|
|
{"null certificates", `{"security":"tls","tlsSettings":{"certificates":null}}`, true},
|
|
{"empty certificates", `{"security":"tls","tlsSettings":{"certificates":[]}}`, true},
|
|
{"null certificate row", `{"security":"tls","tlsSettings":{"certificates":[null]}}`, true},
|
|
{"empty default file fields", `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":"","keyFile":""}]}}`, true},
|
|
{"empty default inline fields", `{"security":"tls","tlsSettings":{"certificates":[{"certificate":[],"key":[]}]}}`, true},
|
|
{"whitespace file fields", `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":" \t","keyFile":" \n"}]}}`, true},
|
|
{"whitespace inline certificate", `{"security":"tls","tlsSettings":{"certificates":[{"certificate":[" ","\t"],"key":["private key"]}]}}`, true},
|
|
{"whitespace inline key", `{"security":"tls","tlsSettings":{"certificates":[{"certificate":["certificate"],"key":[" ","\n"]}]}}`, true},
|
|
{"missing private key", `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem"}]}}`, true},
|
|
{"missing certificate", `{"security":"tls","tlsSettings":{"certificates":[{"keyFile":"/node/key.pem"}]}}`, true},
|
|
{"verify only", `{"security":"tls","tlsSettings":{"certificates":[{"usage":"verify","certificateFile":"/node/ca.pem"}]}}`, true},
|
|
{"verify with private key still needs server certificate", `{"security":"tls","tlsSettings":{"certificates":[{"usage":"verify","certificateFile":"/node/ca.pem","keyFile":"/node/key.pem"}]}}`, true},
|
|
{"issue needs private key", `{"security":"tls","tlsSettings":{"certificates":[{"usage":"issue","certificateFile":"/node/ca.pem"}]}}`, true},
|
|
{"file credentials with default usage", `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"}]}}`, false},
|
|
{"inline credentials", `{"security":"tls","tlsSettings":{"certificates":[{"certificate":["certificate"],"key":["private key"]}]}}`, false},
|
|
{"certificate file and inline key", `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem","key":["private key"]}]}}`, false},
|
|
{"inline certificate and key file", `{"security":"tls","tlsSettings":{"certificates":[{"certificate":["certificate"],"keyFile":"/node/key.pem"}]}}`, false},
|
|
{"encipherment usage", `{"security":"tls","tlsSettings":{"certificates":[{"usage":"encipherment","certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"}]}}`, false},
|
|
{"issue usage", `{"security":"tls","tlsSettings":{"certificates":[{"usage":"issue","certificateFile":"/node/ca.pem","keyFile":"/node/ca-key.pem"}]}}`, false},
|
|
{"unknown usage defaults to encipherment like Xray", `{"security":"tls","tlsSettings":{"certificates":[{"usage":"custom","certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"}]}}`, false},
|
|
{"verify and server certificates", `{"security":"tls","tlsSettings":{"certificates":[{"usage":"verify","certificateFile":"/node/ca.pem"},{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"}]}}`, false},
|
|
{"verify usage is case insensitive", `{"security":"tls","tlsSettings":{"certificates":[{"usage":"VERIFY","certificateFile":"/node/ca.pem"},{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"}]}}`, false},
|
|
{"empty extra certificate row", `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"},{}]}}`, true},
|
|
{"empty extra verify certificate", `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"},{"usage":"verify"}]}}`, true},
|
|
{"whitespace certificate file overrides inline content", `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":" ","certificate":["certificate"],"key":["private key"]}]}}`, true},
|
|
{"whitespace key file overrides inline content", `{"security":"tls","tlsSettings":{"certificates":[{"certificate":["certificate"],"keyFile":" ","key":["private key"]}]}}`, true},
|
|
{"malformed stream", `{"security":"tls"`, true},
|
|
{"malformed TLS settings", `{"security":"tls","tlsSettings":"invalid"}`, true},
|
|
{"malformed certificate list", `{"security":"tls","tlsSettings":{"certificates":{}}}`, true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validateInboundTLSCertificates(tt.streamSettings)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Fatalf("validateInboundTLSCertificates() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateInboundTLSCertificatesIdentifiesIncompleteRow(t *testing.T) {
|
|
err := validateInboundTLSCertificates(`{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"},{"certificateFile":"/node/other.pem"}]}}`)
|
|
if err == nil || !strings.Contains(err.Error(), "TLS certificate 2") || !strings.Contains(err.Error(), "private key") {
|
|
t.Fatalf("expected actionable error for the second certificate's private key, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAddInboundRejectsMissingTLSCertificates(t *testing.T) {
|
|
setupConflictDB(t)
|
|
mgr := useTestRuntimeManager(t)
|
|
fake := &fakeNodeRuntime{}
|
|
mgr.SetLocalRuntimeOverride(fake)
|
|
|
|
inbound := &model.Inbound{
|
|
Tag: "tls-missing-44310",
|
|
Enable: true,
|
|
Listen: "0.0.0.0",
|
|
Port: 44310,
|
|
Protocol: model.VLESS,
|
|
StreamSettings: `{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"certificateFile":"","keyFile":""}]}}`,
|
|
Settings: `{"clients":[]}`,
|
|
}
|
|
_, needRestart, err := (&InboundService{}).AddInbound(inbound)
|
|
if err == nil || !strings.Contains(err.Error(), "TLS") {
|
|
t.Fatalf("AddInbound: expected TLS validation error, got %v", err)
|
|
}
|
|
if needRestart {
|
|
t.Fatal("AddInbound: rejected TLS configuration requested a restart")
|
|
}
|
|
var count int64
|
|
if err := database.GetDB().Model(&model.Inbound{}).Count(&count).Error; err != nil {
|
|
t.Fatalf("count inbounds: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("AddInbound: rejected TLS configuration created %d rows", count)
|
|
}
|
|
if fake.addInbound.Load() != 0 || fake.updateInbound.Load() != 0 || fake.delInbound.Load() != 0 {
|
|
t.Fatal("AddInbound: rejected TLS configuration reached the runtime")
|
|
}
|
|
}
|
|
|
|
func TestUpdateInboundRejectsMissingTLSCertificates(t *testing.T) {
|
|
setupConflictDB(t)
|
|
mgr := useTestRuntimeManager(t)
|
|
fake := &fakeNodeRuntime{}
|
|
mgr.SetLocalRuntimeOverride(fake)
|
|
|
|
seedInboundConflict(t, "tls-existing-44311", "0.0.0.0", 44311, model.VLESS,
|
|
`{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"}]}}`, `{"clients":[]}`)
|
|
var existing model.Inbound
|
|
if err := database.GetDB().Where("tag = ?", "tls-existing-44311").First(&existing).Error; err != nil {
|
|
t.Fatalf("load existing inbound: %v", err)
|
|
}
|
|
update := existing
|
|
update.Remark = "must not be saved"
|
|
update.Port = 44312
|
|
update.StreamSettings = `{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem"}]}}`
|
|
_, needRestart, err := (&InboundService{}).UpdateInbound(&update)
|
|
if err == nil || !strings.Contains(err.Error(), "TLS") {
|
|
t.Fatalf("UpdateInbound: expected TLS validation error, got %v", err)
|
|
}
|
|
if needRestart {
|
|
t.Fatal("UpdateInbound: rejected TLS configuration requested a restart")
|
|
}
|
|
var reloaded model.Inbound
|
|
if err := database.GetDB().First(&reloaded, existing.Id).Error; err != nil {
|
|
t.Fatalf("reload existing inbound: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(reloaded, existing) {
|
|
t.Fatal("UpdateInbound: rejected TLS configuration changed the stored inbound")
|
|
}
|
|
if fake.addInbound.Load() != 0 || fake.updateInbound.Load() != 0 || fake.delInbound.Load() != 0 {
|
|
t.Fatal("UpdateInbound: rejected TLS configuration reached the runtime")
|
|
}
|
|
}
|
|
|
|
// The panel used to seed a TLS inbound with an all-empty certificate, so rows in
|
|
// that shape predate the guard and must stay editable — see UpdateInbound.
|
|
func TestUpdateInboundAllowsUntouchedLegacyTLSCertificates(t *testing.T) {
|
|
const legacyStream = `{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"certificateFile":"","keyFile":"","certificate":[],"key":[]}]}}`
|
|
|
|
tests := []struct {
|
|
name string
|
|
streamSettings string
|
|
}{
|
|
{"remark-only edit resends the stored block", legacyStream},
|
|
{"node push re-encodes the same block", `{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"key":[],"certificate":[],"keyFile":"","certificateFile":""}]}}`},
|
|
{"a partial fix to the stored credentials is tolerated", `{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem"}]}}`},
|
|
{"completing the credentials is accepted", `{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"}]}}`},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
setupConflictDB(t)
|
|
mgr := useTestRuntimeManager(t)
|
|
mgr.SetLocalRuntimeOverride(&fakeNodeRuntime{})
|
|
|
|
seedInboundConflict(t, "tls-legacy-44321", "0.0.0.0", 44321, model.VLESS, legacyStream, `{"clients":[]}`)
|
|
var existing model.Inbound
|
|
if err := database.GetDB().Where("tag = ?", "tls-legacy-44321").First(&existing).Error; err != nil {
|
|
t.Fatalf("load legacy inbound: %v", err)
|
|
}
|
|
|
|
update := existing
|
|
update.Remark = "renamed"
|
|
update.StreamSettings = tt.streamSettings
|
|
if _, _, err := (&InboundService{}).UpdateInbound(&update); err != nil {
|
|
t.Fatalf("UpdateInbound: %v", err)
|
|
}
|
|
var reloaded model.Inbound
|
|
if err := database.GetDB().First(&reloaded, existing.Id).Error; err != nil {
|
|
t.Fatalf("reload inbound: %v", err)
|
|
}
|
|
if reloaded.Remark != "renamed" {
|
|
t.Fatalf("UpdateInbound: remark = %q, want %q", reloaded.Remark, "renamed")
|
|
}
|
|
})
|
|
}
|
|
}
|