mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-03 02:54:20 +00:00
4c8d3cb625
The node probe honored the per-node TlsVerifyMode (skip/pin) but runtime.Remote used a shared client with no TLSClientConfig, so traffic sync and every other remote op fell back to system-CA verification and failed against self-signed nodes even after the operator set skip/pin. Move the TLS client builder into the runtime layer (HTTPClientForNode / DecodeCertPin) as the single source of truth, have Remote build and cache its per-node client through it, and delegate the service probe to the same builder so the two paths can no longer diverge.
166 lines
4.6 KiB
Go
166 lines
4.6 KiB
Go
package runtime
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
)
|
|
|
|
// nodeForServer builds a node pointing at a loopback test server (loopback is
|
|
// SSRF-blocked, so AllowPrivateAddress is set for the guarded dialer).
|
|
func nodeForServer(t *testing.T, srv *httptest.Server, mode, pin string) *model.Node {
|
|
t.Helper()
|
|
u, err := url.Parse(srv.URL)
|
|
if err != nil {
|
|
t.Fatalf("parse server url: %v", err)
|
|
}
|
|
port, err := strconv.Atoi(u.Port())
|
|
if err != nil {
|
|
t.Fatalf("parse server port: %v", err)
|
|
}
|
|
return &model.Node{
|
|
Id: 1,
|
|
Name: "n1",
|
|
Scheme: "https",
|
|
Address: u.Hostname(),
|
|
Port: port,
|
|
BasePath: "/",
|
|
ApiToken: "token",
|
|
Enable: true,
|
|
AllowPrivateAddress: true,
|
|
TlsVerifyMode: mode,
|
|
PinnedCertSha256: pin,
|
|
}
|
|
}
|
|
|
|
func leafPinBase64(srv *httptest.Server) string {
|
|
sum := sha256.Sum256(srv.Certificate().Raw)
|
|
return base64.StdEncoding.EncodeToString(sum[:])
|
|
}
|
|
|
|
// A self-signed node must be reachable by Remote ops under skip/pin and
|
|
// rejected under verify — the split issue #5264 reported.
|
|
func TestRemoteHonorsTLSVerifyMode(t *testing.T) {
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"success":true,"obj":[]}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
goodPin := leafPinBase64(srv)
|
|
wrongPin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size))
|
|
|
|
cases := []struct {
|
|
name string
|
|
mode string
|
|
pin string
|
|
wantErr bool
|
|
}{
|
|
{"verify rejects self-signed", "verify", "", true},
|
|
{"skip accepts self-signed", "skip", "", false},
|
|
{"pin accepts matching cert", "pin", goodPin, false},
|
|
{"pin rejects mismatched cert", "pin", wrongPin, true},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
r := NewRemote(nodeForServer(t, srv, c.mode, c.pin))
|
|
_, err := r.ListInboundOptions(context.Background())
|
|
if c.wantErr && err == nil {
|
|
t.Fatalf("mode %q: expected error, got nil", c.mode)
|
|
}
|
|
if !c.wantErr && err != nil {
|
|
t.Fatalf("mode %q: unexpected error: %v", c.mode, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The lazily-built client is cached for the Remote's lifetime so repeated
|
|
// operations reuse one pooled transport rather than rebuilding TLS each call.
|
|
func TestRemoteClientCached(t *testing.T) {
|
|
r := NewRemote(&model.Node{Scheme: "https", TlsVerifyMode: "skip"})
|
|
c1, err1 := r.httpClient()
|
|
c2, err2 := r.httpClient()
|
|
if err1 != nil || err2 != nil {
|
|
t.Fatalf("httpClient errors: %v %v", err1, err2)
|
|
}
|
|
if c1 != c2 {
|
|
t.Fatal("expected the same cached client across calls")
|
|
}
|
|
}
|
|
|
|
func TestHTTPClientForNodeVerifyShared(t *testing.T) {
|
|
// verify mode and plain http both reuse the shared default client.
|
|
for _, n := range []*model.Node{
|
|
{Scheme: "https", TlsVerifyMode: "verify"},
|
|
{Scheme: "https", TlsVerifyMode: ""},
|
|
{Scheme: "http", TlsVerifyMode: "skip"},
|
|
} {
|
|
c, err := HTTPClientForNode(n)
|
|
if err != nil {
|
|
t.Fatalf("HTTPClientForNode(%+v): %v", n, err)
|
|
}
|
|
if c != defaultNodeHTTPClient {
|
|
t.Fatalf("HTTPClientForNode(%+v) = %p, want shared default %p", n, c, defaultNodeHTTPClient)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHTTPClientForNodePinInvalid(t *testing.T) {
|
|
if _, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: "not-a-pin"}); err == nil {
|
|
t.Fatal("expected error for invalid pin")
|
|
}
|
|
}
|
|
|
|
func TestDecodeCertPin(t *testing.T) {
|
|
raw := sha256.Sum256([]byte("cert"))
|
|
hexColon := strings.ToUpper(hex.EncodeToString(raw[:]))
|
|
// reinsert colons in openssl -fingerprint style
|
|
var withColons strings.Builder
|
|
for i := 0; i < len(hexColon); i += 2 {
|
|
if i > 0 {
|
|
withColons.WriteByte(':')
|
|
}
|
|
withColons.WriteString(hexColon[i : i+2])
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
wantErr bool
|
|
}{
|
|
{"base64 std", base64.StdEncoding.EncodeToString(raw[:]), false},
|
|
{"base64 raw url", base64.RawURLEncoding.EncodeToString(raw[:]), false},
|
|
{"hex bare", hex.EncodeToString(raw[:]), false},
|
|
{"hex colon openssl", withColons.String(), false},
|
|
{"empty", "", true},
|
|
{"garbage", "not-a-pin", true},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got, err := DecodeCertPin(c.in)
|
|
if c.wantErr {
|
|
if err == nil {
|
|
t.Fatalf("expected error for %q", c.in)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error for %q: %v", c.in, err)
|
|
}
|
|
if string(got) != string(raw[:]) {
|
|
t.Fatalf("decoded bytes mismatch for %q", c.in)
|
|
}
|
|
})
|
|
}
|
|
}
|