mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-16 16:20:59 +00:00
7ecd88b9e3
* fix(mtls): invalidate pooled clients after credential rotation * fix(mtls): make connection reload read-only --------- Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
538 lines
17 KiB
Go
538 lines
17 KiB
Go
package runtime
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
|
|
)
|
|
|
|
type generationProbeTransport struct {
|
|
id string
|
|
closed atomic.Int32
|
|
}
|
|
|
|
func (t *generationProbeTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: http.NoBody,
|
|
Header: make(http.Header),
|
|
Request: &http.Request{},
|
|
}, nil
|
|
}
|
|
|
|
func (t *generationProbeTransport) CloseIdleConnections() {
|
|
t.closed.Add(1)
|
|
}
|
|
|
|
func TestCredentialRotatingTransportDropsOldPoolBeforeNextRequest(t *testing.T) {
|
|
var selected atomic.Pointer[generationProbeTransport]
|
|
oldTransport := &generationProbeTransport{id: "old"}
|
|
newTransport := &generationProbeTransport{id: "new"}
|
|
selected.Store(oldTransport)
|
|
|
|
rotating, err := newCredentialRotatingTransport(func() (idleClosingRoundTripper, error) {
|
|
return selected.Load(), nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("newCredentialRotatingTransport: %v", err)
|
|
}
|
|
rotating.mu.Lock()
|
|
initial := rotating.current
|
|
rotating.mu.Unlock()
|
|
if initial != oldTransport {
|
|
t.Fatalf("initial transport = %p, want old %p", initial, oldTransport)
|
|
}
|
|
|
|
selected.Store(newTransport)
|
|
InvalidateMasterClientConnections()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "https://node.example.test/panel/api/server/status", nil)
|
|
resp, err := rotating.RoundTrip(req)
|
|
if err != nil {
|
|
t.Fatalf("RoundTrip after credential rotation: %v", err)
|
|
}
|
|
_ = resp.Body.Close()
|
|
|
|
rotating.mu.Lock()
|
|
current := rotating.current
|
|
rotating.mu.Unlock()
|
|
if current != newTransport {
|
|
t.Fatalf("transport after invalidation = %p, want new %p", current, newTransport)
|
|
}
|
|
if got := oldTransport.closed.Load(); got != 1 {
|
|
t.Fatalf("old transport CloseIdleConnections calls = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestReloadMasterClientConnectionsValidatesProviderBeforeInvalidation(t *testing.T) {
|
|
before := masterCertEpoch.Load()
|
|
SetMasterClientCertProvider(func() (tls.Certificate, error) {
|
|
return tls.Certificate{}, context.Canceled
|
|
})
|
|
if err := ReloadMasterClientConnections(); err == nil {
|
|
t.Fatal("reload with an invalid provider unexpectedly succeeded")
|
|
}
|
|
if got := masterCertEpoch.Load(); got != before {
|
|
t.Fatalf("failed reload changed generation from %d to %d", before, got)
|
|
}
|
|
|
|
SetMasterClientCertProvider(func() (tls.Certificate, error) {
|
|
return masterCertForTest(t), nil
|
|
})
|
|
t.Cleanup(func() { SetMasterClientCertProvider(nil) })
|
|
if err := ReloadMasterClientConnections(); err != nil {
|
|
t.Fatalf("ReloadMasterClientConnections: %v", err)
|
|
}
|
|
if got := masterCertEpoch.Load(); got != before+1 {
|
|
t.Fatalf("successful reload generation = %d, want %d", got, before+1)
|
|
}
|
|
}
|
|
|
|
func TestCredentialRotatingTransportRejectsBuildAcrossInvalidation(t *testing.T) {
|
|
oldTransport := &generationProbeTransport{id: "old"}
|
|
newTransport := &generationProbeTransport{id: "new"}
|
|
var selected atomic.Pointer[generationProbeTransport]
|
|
selected.Store(oldTransport)
|
|
|
|
firstBuildCaptured := make(chan struct{})
|
|
releaseFirstBuild := make(chan struct{})
|
|
var once sync.Once
|
|
build := func() (idleClosingRoundTripper, error) {
|
|
captured := selected.Load()
|
|
once.Do(func() {
|
|
close(firstBuildCaptured)
|
|
<-releaseFirstBuild
|
|
})
|
|
return captured, nil
|
|
}
|
|
|
|
type result struct {
|
|
transport *credentialRotatingTransport
|
|
err error
|
|
}
|
|
resultCh := make(chan result, 1)
|
|
go func() {
|
|
transport, err := newCredentialRotatingTransport(build)
|
|
resultCh <- result{transport: transport, err: err}
|
|
}()
|
|
|
|
<-firstBuildCaptured
|
|
selected.Store(newTransport)
|
|
InvalidateMasterClientConnections()
|
|
close(releaseFirstBuild)
|
|
|
|
got := <-resultCh
|
|
if got.err != nil {
|
|
t.Fatalf("newCredentialRotatingTransport: %v", got.err)
|
|
}
|
|
got.transport.mu.Lock()
|
|
current := got.transport.current
|
|
got.transport.mu.Unlock()
|
|
if current != newTransport {
|
|
t.Fatalf("transport built across invalidation = %p, want new %p", current, newTransport)
|
|
}
|
|
if calls := oldTransport.closed.Load(); calls != 1 {
|
|
t.Fatalf("stale transport CloseIdleConnections calls = %d, want 1", calls)
|
|
}
|
|
}
|
|
|
|
func TestHTTPClientForNodeMTLSRebuildsTLSConfigAfterCredentialInvalidation(t *testing.T) {
|
|
oldCert := masterCertForTest(t)
|
|
newCert := masterCertForTest(t)
|
|
selected := oldCert
|
|
SetMasterClientCertProvider(func() (tls.Certificate, error) { return selected, nil })
|
|
t.Cleanup(func() { SetMasterClientCertProvider(nil) })
|
|
|
|
client, err := HTTPClientForNode(&model.Node{
|
|
Scheme: "https",
|
|
Address: "node.example.test",
|
|
Port: 443,
|
|
TlsVerifyMode: "mtls",
|
|
}, "")
|
|
if err != nil {
|
|
t.Fatalf("HTTPClientForNode: %v", err)
|
|
}
|
|
rotating, ok := client.Transport.(*credentialRotatingTransport)
|
|
if !ok {
|
|
t.Fatalf("transport = %T, want *credentialRotatingTransport", client.Transport)
|
|
}
|
|
leaf := func() []byte {
|
|
rotating.mu.Lock()
|
|
defer rotating.mu.Unlock()
|
|
transport, ok := rotating.current.(*http.Transport)
|
|
if !ok {
|
|
t.Fatalf("current transport = %T, want *http.Transport", rotating.current)
|
|
}
|
|
return transport.TLSClientConfig.Certificates[0].Certificate[0]
|
|
}
|
|
if got := leaf(); string(got) != string(oldCert.Certificate[0]) {
|
|
t.Fatal("initial TLS config does not contain the old credential")
|
|
}
|
|
|
|
selected = newCert
|
|
InvalidateMasterClientConnections()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://node.example.test/", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequestWithContext: %v", err)
|
|
}
|
|
if _, err := client.Do(req); err == nil {
|
|
t.Fatal("canceled request unexpectedly succeeded")
|
|
}
|
|
if got := leaf(); string(got) != string(newCert.Certificate[0]) {
|
|
t.Fatal("TLS config retained the old credential after invalidation")
|
|
}
|
|
}
|
|
|
|
func TestHTTPClientForNodeProxyMTLSRebuildKeepsProxyAndNewCredential(t *testing.T) {
|
|
oldCert := masterCertForTest(t)
|
|
newCert := masterCertForTest(t)
|
|
selected := oldCert
|
|
SetMasterClientCertProvider(func() (tls.Certificate, error) { return selected, nil })
|
|
t.Cleanup(func() { SetMasterClientCertProvider(nil) })
|
|
|
|
const proxyURL = "http://127.0.0.1:18080"
|
|
client, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "mtls"}, proxyURL)
|
|
if err != nil {
|
|
t.Fatalf("HTTPClientForNode: %v", err)
|
|
}
|
|
rotating, ok := client.Transport.(*credentialRotatingTransport)
|
|
if !ok {
|
|
t.Fatalf("transport = %T, want rotating transport", client.Transport)
|
|
}
|
|
current := func() *http.Transport {
|
|
rotating.mu.Lock()
|
|
defer rotating.mu.Unlock()
|
|
transport, ok := rotating.current.(*http.Transport)
|
|
if !ok {
|
|
t.Fatalf("current transport = %T, want *http.Transport", rotating.current)
|
|
}
|
|
return transport
|
|
}
|
|
assertProxy := func(transport *http.Transport) {
|
|
t.Helper()
|
|
if transport.Proxy == nil {
|
|
t.Fatalf("proxy function is nil, want %s", proxyURL)
|
|
}
|
|
req, _ := http.NewRequest(http.MethodGet, "https://node.example.test/", nil)
|
|
got, err := transport.Proxy(req)
|
|
if err != nil || got == nil || got.String() != proxyURL {
|
|
t.Fatalf("proxy = %v, error = %v, want %s", got, err, proxyURL)
|
|
}
|
|
}
|
|
assertProxy(current())
|
|
|
|
selected = newCert
|
|
InvalidateMasterClientConnections()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://node.example.test/", nil)
|
|
_, _ = client.Do(req)
|
|
rebuilt := current()
|
|
assertProxy(rebuilt)
|
|
if got := rebuilt.TLSClientConfig.Certificates[0].Certificate[0]; string(got) != string(newCert.Certificate[0]) {
|
|
t.Fatal("proxy mTLS rebuild retained the old credential")
|
|
}
|
|
}
|
|
|
|
// masterCertForTest builds a real CA-signed client certificate for mtls tests.
|
|
func masterCertForTest(t *testing.T) tls.Certificate {
|
|
t.Helper()
|
|
ca, err := crypto.GenerateNodeCA("test ca")
|
|
if err != nil {
|
|
t.Fatalf("GenerateNodeCA: %v", err)
|
|
}
|
|
client, err := crypto.IssueClientCert(ca, "master")
|
|
if err != nil {
|
|
t.Fatalf("IssueClientCert: %v", err)
|
|
}
|
|
cert, err := tls.X509KeyPair(client.CertPEM, client.KeyPEM)
|
|
if err != nil {
|
|
t.Fatalf("X509KeyPair: %v", err)
|
|
}
|
|
return cert
|
|
}
|
|
|
|
// TestTLSConfigForNode_MTLS_PresentsClientCert asserts the mtls branch presents
|
|
// the master client cert and verifies the node's server cert against system
|
|
// roots (no InsecureSkipVerify, no custom RootCAs).
|
|
func TestTLSConfigForNode_MTLS_PresentsClientCert(t *testing.T) {
|
|
cert := masterCertForTest(t)
|
|
SetMasterClientCertProvider(func() (tls.Certificate, error) { return cert, nil })
|
|
t.Cleanup(func() { SetMasterClientCertProvider(nil) })
|
|
|
|
cfg, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "mtls"})
|
|
if err != nil {
|
|
t.Fatalf("tlsConfigForNode(mtls): %v", err)
|
|
}
|
|
if len(cfg.Certificates) != 1 {
|
|
t.Fatalf("mtls config must present exactly one client certificate, got %d", len(cfg.Certificates))
|
|
}
|
|
if cfg.InsecureSkipVerify {
|
|
t.Fatal("mtls must NOT skip server verification")
|
|
}
|
|
if cfg.RootCAs != nil {
|
|
t.Fatal("mtls verifies the node server against system roots (RootCAs must be nil)")
|
|
}
|
|
}
|
|
|
|
// TestTLSConfigForNode_MTLS_NoProviderFailsClosed asserts mtls fails closed when
|
|
// no master client certificate is available, rather than silently dropping auth.
|
|
func TestTLSConfigForNode_MTLS_NoProviderFailsClosed(t *testing.T) {
|
|
SetMasterClientCertProvider(nil)
|
|
if _, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "mtls"}); err == nil {
|
|
t.Fatal("mtls without a configured client cert provider must fail closed")
|
|
}
|
|
}
|
|
|
|
// 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), nil)
|
|
_, 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"}, nil)
|
|
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) {
|
|
// pin mode must fail closed, and with a specific error per cause — not merely
|
|
// "some error" (which a bug anywhere in the build path would also satisfy).
|
|
cases := []struct {
|
|
name string
|
|
pin string
|
|
wantErr string
|
|
}{
|
|
{"garbage pin", "not-a-pin", "must be a SHA-256 hash"},
|
|
{"empty pin", "", "certificate pin is empty"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
_, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: c.pin}, "")
|
|
if err == nil {
|
|
t.Fatalf("expected error for pin %q", c.pin)
|
|
}
|
|
if !strings.Contains(err.Error(), c.wantErr) {
|
|
t.Fatalf("error = %q, want it to contain %q", err.Error(), c.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHTTPClientForNode_ProxyPinPreservesPinEnforcement covers the proxy+pin branch
|
|
// (tls_client.go:43-52): when a node uses a proxy AND pin mode, the proxy client's
|
|
// transport must carry the pinning tls.Config (the `transport.TLSClientConfig = tlsCfg`
|
|
// line). Dropping it would silently disable certificate pinning whenever a proxy is set.
|
|
func TestHTTPClientForNode_ProxyPinPreservesPinEnforcement(t *testing.T) {
|
|
pin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size))
|
|
n := &model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: pin}
|
|
|
|
c, err := HTTPClientForNode(n, "socks5://127.0.0.1:1080")
|
|
if err != nil {
|
|
t.Fatalf("HTTPClientForNode: %v", err)
|
|
}
|
|
if c == defaultNodeHTTPClient {
|
|
t.Fatal("proxy client must not be the shared default client")
|
|
}
|
|
tr, ok := c.Transport.(*http.Transport)
|
|
if !ok {
|
|
t.Fatalf("transport is %T, want *http.Transport", c.Transport)
|
|
}
|
|
if tr.TLSClientConfig == nil || tr.TLSClientConfig.VerifyConnection == nil {
|
|
t.Fatal("pin mode over a proxy must install a pinning tls.Config (VerifyConnection); pin enforcement was dropped")
|
|
}
|
|
}
|
|
|
|
// TestHTTPClientForNode_ProxyVerifyNoPin covers the proxy+verify branch
|
|
// (tls_client.go:40-42): verify mode over a proxy returns the proxy client as-is,
|
|
// using system-CA verification and NOT a pin VerifyConnection.
|
|
func TestHTTPClientForNode_ProxyVerifyNoPin(t *testing.T) {
|
|
n := &model.Node{Scheme: "https", TlsVerifyMode: "verify"}
|
|
c, err := HTTPClientForNode(n, "socks5://127.0.0.1:1080")
|
|
if err != nil {
|
|
t.Fatalf("HTTPClientForNode: %v", err)
|
|
}
|
|
if c == defaultNodeHTTPClient {
|
|
t.Fatal("proxy client must not be the shared default client")
|
|
}
|
|
if tr, ok := c.Transport.(*http.Transport); ok && tr.TLSClientConfig != nil && tr.TLSClientConfig.VerifyConnection != nil {
|
|
t.Fatal("verify mode must not install a pin VerifyConnection")
|
|
}
|
|
}
|
|
|
|
// TestTLSConfigForNode_CurrentContract locks the pre-mTLS behavior of
|
|
// tlsConfigForNode so the "mtls" branch added later cannot silently regress the
|
|
// existing skip/pin modes (characterization — passes on unchanged code).
|
|
func TestTLSConfigForNode_CurrentContract(t *testing.T) {
|
|
t.Run("skip disables verification with no VerifyConnection", func(t *testing.T) {
|
|
cfg, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "skip"})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !cfg.InsecureSkipVerify {
|
|
t.Fatal("skip mode must set InsecureSkipVerify")
|
|
}
|
|
if cfg.VerifyConnection != nil {
|
|
t.Fatal("skip mode must not install a VerifyConnection")
|
|
}
|
|
})
|
|
t.Run("pin installs a VerifyConnection", func(t *testing.T) {
|
|
pin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size))
|
|
cfg, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "pin", PinnedCertSha256: pin})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if cfg.VerifyConnection == nil {
|
|
t.Fatal("pin mode must install a VerifyConnection")
|
|
}
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|