Files
3x-ui/internal/web/service/setting_mtls_bundle_test.go
T
n0ctal f8db7f6c29 fix(nodes): say which half of node mTLS failed, and say it as an error (#6565)
* fix(nodes): say which half of node mTLS failed, and say it as an error

A configured client CA bundle that will not parse produced the same
warning as a settings read that failed, and both read as though mTLS
were merely unavailable. It is not: the node API silently stops
accepting client certificates, callers fall back to a bearer token or
lose their only credential, and the one line saying so is a warning at
boot.

Report it at error level, and distinguish the two causes rather than
attributing a storage fault to the operator's certificate bundle.
NodeMtlsClientCAPool now tags the parse failure with
ErrNodeMtlsTrustBundleInvalid; its message text is unchanged, so
anything matching on the existing string still matches.

Startup is deliberately left alone. Refusing to boot was considered and
rejected: the bundle is one of two equal credentials here, a panel that
will not start takes the proxies and the subscription server with it,
and bundles written before the stricter validation landed in #6188 are
already stored, editable only through the panel that would no longer
come up.

The tests pin the tag on an unusable bundle and its absence on an unset
one; without the tag the first goes red.

* test(nodes): drop a duplicate node mTLS trust-bundle test

TestNodeMtlsClientCAPoolLeavesUnsetBundleUntagged asserted only that an
unset nodeMtlsClientCAPem yields (nil, nil). That path returns before the
line the sentinel change touched, so the test was green with and without
ErrNodeMtlsTrustBundleInvalid, and TestNodeMtlsClientCAPool already pins
the same two assertions on the same fixture. A test that passes either way
certifies nothing and then gets cited as coverage for the sentinel.

TestNodeMtlsClientCAPoolTagsAnInvalidBundle, which does go red without the
sentinel, stays as the regression guard.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-16 12:30:44 +02:00

94 lines
3.5 KiB
Go

package service
import (
"errors"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
)
func mustNodeCAPEM(t *testing.T, name string) string {
t.Helper()
ca, err := crypto.GenerateNodeCA(name)
if err != nil {
t.Fatalf("GenerateNodeCA(%q): %v", name, err)
}
return string(ca.CertPEM)
}
func TestParseCertificateBundlePEM(t *testing.T) {
first := mustNodeCAPEM(t, "bundle test CA one")
second := mustNodeCAPEM(t, "bundle test CA two")
corrupt := strings.Replace(second, "-----BEGIN CERTIFICATE-----\n", "-----BEGIN CERTIFICATE-----\nAA", 1)
tests := []struct {
name string
bundle string
wantCerts int
wantErr string
}{
{name: "single certificate", bundle: first, wantCerts: 1},
{name: "two certificates", bundle: first + second, wantCerts: 2},
{name: "empty", bundle: "", wantErr: "certificate bundle is empty"},
{name: "whitespace only", bundle: "\n\t \n", wantErr: "certificate bundle is empty"},
{name: "leading non-PEM data", bundle: "junk\n" + first, wantErr: "certificate bundle contains malformed or non-PEM data"},
{name: "interstitial non-PEM data", bundle: first + "junk\n" + second, wantErr: "certificate bundle contains malformed or non-PEM data"},
{name: "second certificate corrupt", bundle: first + corrupt, wantErr: "certificate bundle contains malformed or non-PEM data"},
{name: "trailing non-PEM data", bundle: first + "not a certificate\n", wantErr: "certificate bundle contains malformed or non-PEM data"},
{name: "non-certificate block", bundle: first + "-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----\n", wantErr: "certificate bundle contains malformed or non-PEM data"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
certs, err := parseCertificateBundlePEM([]byte(tt.bundle))
if tt.wantErr != "" {
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("parseCertificateBundlePEM() error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("parseCertificateBundlePEM(): %v", err)
}
if len(certs) != tt.wantCerts {
t.Fatalf("parseCertificateBundlePEM() = %d certs, want %d", len(certs), tt.wantCerts)
}
})
}
}
func TestNodeMtlsClientCAPoolRejectsPartiallyValidBundle(t *testing.T) {
s := setupSettingMtlsDB(t)
valid := mustNodeCAPEM(t, "pool test CA")
if err := s.setString("nodeMtlsClientCAPem", valid+"-----BEGIN CERTIFICATE-----\nnot base64\n-----END CERTIFICATE-----\n"); err != nil {
t.Fatalf("setString: %v", err)
}
pool, err := s.NodeMtlsClientCAPool()
want := "nodeMtlsClientCAPem is not a valid certificate bundle: certificate bundle contains malformed or non-PEM data"
if err == nil || err.Error() != want {
t.Fatalf("NodeMtlsClientCAPool() = %v, error = %v, want %q", pool, err, want)
}
}
// The boot path tells the operator whether the bundle itself is unusable or the
// settings read failed, so the parse failure has to carry a matchable cause.
func TestNodeMtlsClientCAPoolTagsAnInvalidBundle(t *testing.T) {
s := setupSettingMtlsDB(t)
if err := s.setString("nodeMtlsClientCAPem", "-----BEGIN CERTIFICATE-----\nnot base64\n-----END CERTIFICATE-----\n"); err != nil {
t.Fatalf("setString: %v", err)
}
pool, err := s.NodeMtlsClientCAPool()
if pool != nil {
t.Fatalf("NodeMtlsClientCAPool() returned a pool built from an unusable bundle")
}
if !errors.Is(err, ErrNodeMtlsTrustBundleInvalid) {
t.Fatalf("NodeMtlsClientCAPool() error = %v, want it to wrap ErrNodeMtlsTrustBundleInvalid", err)
}
}