|
@@ -0,0 +1,185 @@
|
|
|
|
|
+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")
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+}
|