590 lines
16 KiB
Go
590 lines
16 KiB
Go
package netclient
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/binary"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestCustomProxyBuildsSocks5URL(t *testing.T) {
|
|
pf, err := proxyFunc(ProxySpec{
|
|
Mode: "custom",
|
|
Type: "socks5",
|
|
Server: "127.0.0.1",
|
|
Port: 7890,
|
|
Username: "user",
|
|
Password: "secret",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("proxyFunc: %v", err)
|
|
}
|
|
got, err := pf(&http.Request{URL: mustURL("https://api.deepseek.com/chat/completions")})
|
|
if err != nil {
|
|
t.Fatalf("proxy lookup: %v", err)
|
|
}
|
|
if got.Scheme != "socks5" || got.Host != "127.0.0.1:7890" {
|
|
t.Fatalf("proxy URL = %s, want socks5://127.0.0.1:7890", got)
|
|
}
|
|
if pass, ok := got.User.Password(); !ok || pass != "secret" {
|
|
t.Fatalf("proxy password not preserved")
|
|
}
|
|
}
|
|
|
|
func TestCustomProxyHonorsNoProxy(t *testing.T) {
|
|
pf, err := proxyFunc(ProxySpec{
|
|
Mode: "custom",
|
|
URL: "http://proxy.example.com:8080",
|
|
NoProxy: "api.deepseek.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("proxyFunc: %v", err)
|
|
}
|
|
got, err := pf(&http.Request{URL: mustURL("https://api.deepseek.com/chat/completions")})
|
|
if err != nil {
|
|
t.Fatalf("proxy lookup: %v", err)
|
|
}
|
|
if got != nil {
|
|
t.Fatalf("NoProxy host should bypass proxy, got %s", got)
|
|
}
|
|
}
|
|
|
|
func TestDirectHostsBypassProxy(t *testing.T) {
|
|
t.Setenv("HTTPS_PROXY", "http://proxy.example.com:8080")
|
|
t.Setenv("NO_PROXY", "")
|
|
pf, err := proxyFunc(ProxySpec{Mode: "auto", DirectHosts: []string{"token-plan-cn.xiaomimimo.com"}})
|
|
if err != nil {
|
|
t.Fatalf("proxyFunc: %v", err)
|
|
}
|
|
|
|
got, err := pf(&http.Request{URL: mustURL("https://token-plan-cn.xiaomimimo.com/v1/chat")})
|
|
if err != nil {
|
|
t.Fatalf("direct-host lookup: %v", err)
|
|
}
|
|
if got != nil {
|
|
t.Fatalf("a direct host should bypass the proxy, got %s", got)
|
|
}
|
|
|
|
other, err := pf(&http.Request{URL: mustURL("https://example.com/x")})
|
|
if err != nil {
|
|
t.Fatalf("other lookup: %v", err)
|
|
}
|
|
if other == nil || other.Host != "proxy.example.com:8080" {
|
|
t.Fatalf("non-direct host should still use the env proxy, got %v", other)
|
|
}
|
|
}
|
|
|
|
func TestNoDirectHostsKeepsEveryoneProxied(t *testing.T) {
|
|
t.Setenv("HTTPS_PROXY", "http://proxy.example.com:8080")
|
|
t.Setenv("NO_PROXY", "")
|
|
pf, err := proxyFunc(ProxySpec{Mode: "env"}) // no DirectHosts → nothing special-cased
|
|
if err != nil {
|
|
t.Fatalf("proxyFunc: %v", err)
|
|
}
|
|
got, err := pf(&http.Request{URL: mustURL("https://token-plan-cn.xiaomimimo.com/v1/chat")})
|
|
if err != nil {
|
|
t.Fatalf("lookup: %v", err)
|
|
}
|
|
if got == nil || got.Host != "proxy.example.com:8080" {
|
|
t.Fatalf("without DirectHosts the host must go through the proxy, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestOffProxyDisablesProxy(t *testing.T) {
|
|
pf, err := proxyFunc(ProxySpec{Mode: "off"})
|
|
if err != nil {
|
|
t.Fatalf("proxyFunc: %v", err)
|
|
}
|
|
if pf != nil {
|
|
t.Fatal("off mode should return nil proxy func")
|
|
}
|
|
}
|
|
|
|
func TestSummaryRedactsPassword(t *testing.T) {
|
|
got := Summary(ProxySpec{
|
|
Mode: "custom",
|
|
Type: "socks5",
|
|
Server: "proxy.example.com",
|
|
Port: 1080,
|
|
Username: "user",
|
|
Password: "secret",
|
|
})
|
|
if got != "custom (socks5://user@proxy.example.com:1080)" {
|
|
t.Fatalf("Summary = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestHTTPClientProxyModesAffectRequests(t *testing.T) {
|
|
var targetHits int32
|
|
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt32(&targetHits, 1)
|
|
_, _ = io.WriteString(w, "target")
|
|
}))
|
|
t.Cleanup(target.Close)
|
|
targetAddr := strings.TrimPrefix(target.URL, "http://")
|
|
|
|
var envProxyHits int32
|
|
envProxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt32(&envProxyHits, 1)
|
|
if got, want := r.URL.String(), "http://service.test/resource"; got != want {
|
|
t.Errorf("env proxy request URL = %q, want %q", got, want)
|
|
}
|
|
_, _ = io.WriteString(w, "env-proxy")
|
|
}))
|
|
t.Cleanup(envProxy.Close)
|
|
|
|
var customProxyHits int32
|
|
customProxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt32(&customProxyHits, 1)
|
|
if got, want := r.URL.String(), "http://service.test/resource"; got != want {
|
|
t.Errorf("custom proxy request URL = %q, want %q", got, want)
|
|
}
|
|
_, _ = io.WriteString(w, "custom-proxy")
|
|
}))
|
|
t.Cleanup(customProxy.Close)
|
|
|
|
// Windows env vars are case-insensitive, so HTTP_PROXY and http_proxy are the
|
|
// same var — set the intended value last or the empty clear wipes it.
|
|
t.Setenv("http_proxy", "")
|
|
t.Setenv("HTTPS_PROXY", "")
|
|
t.Setenv("https_proxy", "")
|
|
t.Setenv("NO_PROXY", "")
|
|
t.Setenv("no_proxy", "")
|
|
t.Setenv("HTTP_PROXY", envProxy.URL)
|
|
|
|
tests := []struct {
|
|
name string
|
|
spec ProxySpec
|
|
wantBody string
|
|
wantTargetHits int32
|
|
wantEnvHits int32
|
|
wantCustomHits int32
|
|
}{
|
|
{
|
|
name: "auto uses environment proxy",
|
|
spec: ProxySpec{Mode: ModeAuto},
|
|
wantBody: "env-proxy",
|
|
wantEnvHits: 1,
|
|
},
|
|
{
|
|
name: "env uses environment proxy",
|
|
spec: ProxySpec{Mode: ModeEnv},
|
|
wantBody: "env-proxy",
|
|
wantEnvHits: 1,
|
|
},
|
|
{
|
|
name: "custom ignores environment proxy",
|
|
spec: ProxySpec{Mode: ModeCustom, URL: customProxy.URL},
|
|
wantBody: "custom-proxy",
|
|
wantCustomHits: 1,
|
|
},
|
|
{
|
|
name: "custom no_proxy bypasses proxy",
|
|
spec: ProxySpec{Mode: ModeCustom, URL: customProxy.URL, NoProxy: "service.test"},
|
|
wantBody: "target",
|
|
wantTargetHits: 1,
|
|
},
|
|
{
|
|
name: "off bypasses environment proxy",
|
|
spec: ProxySpec{Mode: ModeOff},
|
|
wantBody: "target",
|
|
wantTargetHits: 1,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
atomic.StoreInt32(&targetHits, 0)
|
|
atomic.StoreInt32(&envProxyHits, 0)
|
|
atomic.StoreInt32(&customProxyHits, 0)
|
|
|
|
client := mappedClient(t, tt.spec, "service.test:80", targetAddr)
|
|
resp, err := client.Get("http://service.test/resource")
|
|
if err != nil {
|
|
t.Fatalf("GET: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("read body: %v", err)
|
|
}
|
|
if string(body) != tt.wantBody {
|
|
t.Fatalf("body = %q, want %q", body, tt.wantBody)
|
|
}
|
|
if got := atomic.LoadInt32(&targetHits); got != tt.wantTargetHits {
|
|
t.Fatalf("target hits = %d, want %d", got, tt.wantTargetHits)
|
|
}
|
|
if got := atomic.LoadInt32(&envProxyHits); got != tt.wantEnvHits {
|
|
t.Fatalf("env proxy hits = %d, want %d", got, tt.wantEnvHits)
|
|
}
|
|
if got := atomic.LoadInt32(&customProxyHits); got != tt.wantCustomHits {
|
|
t.Fatalf("custom proxy hits = %d, want %d", got, tt.wantCustomHits)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStructuredProxyTypesAffectRequests(t *testing.T) {
|
|
httpProxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if got, want := r.URL.String(), "http://service.test/resource"; got != want {
|
|
t.Errorf("HTTP proxy request URL = %q, want %q", got, want)
|
|
}
|
|
_, _ = io.WriteString(w, "http-proxy")
|
|
}))
|
|
t.Cleanup(httpProxy.Close)
|
|
|
|
httpsProxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if got, want := r.URL.String(), "http://service.test/resource"; got != want {
|
|
t.Errorf("HTTPS proxy request URL = %q, want %q", got, want)
|
|
}
|
|
_, _ = io.WriteString(w, "https-proxy")
|
|
}))
|
|
t.Cleanup(httpsProxy.Close)
|
|
|
|
socks5Proxy := newSocksHTTPProxy(t)
|
|
socks5hProxy := newSocksHTTPProxy(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
spec ProxySpec
|
|
tlsProxy *httptest.Server
|
|
wantBody string
|
|
}{
|
|
{
|
|
name: "http",
|
|
spec: structuredProxySpec(t, "http", httpProxy.URL),
|
|
wantBody: "http-proxy",
|
|
},
|
|
{
|
|
name: "https",
|
|
spec: structuredProxySpec(t, "https", httpsProxy.URL),
|
|
tlsProxy: httpsProxy,
|
|
wantBody: "https-proxy",
|
|
},
|
|
{
|
|
name: "socks5",
|
|
spec: structuredProxySpec(t, "socks5", "http://"+socks5Proxy.addr),
|
|
wantBody: "socks-proxy",
|
|
},
|
|
{
|
|
name: "socks5h",
|
|
spec: structuredProxySpec(t, "socks5h", "http://"+socks5hProxy.addr),
|
|
wantBody: "socks-proxy",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tr := mappedTransport(t, tt.spec, "service.test:80", "127.0.0.1:1")
|
|
if tt.tlsProxy != nil {
|
|
proxyTransport := tt.tlsProxy.Client().Transport.(*http.Transport)
|
|
tr.TLSClientConfig = proxyTransport.TLSClientConfig
|
|
}
|
|
client := &http.Client{Transport: tr, Timeout: 2 * time.Second}
|
|
resp, err := client.Get("http://service.test/resource")
|
|
if err != nil {
|
|
t.Fatalf("GET: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("read body: %v", err)
|
|
}
|
|
if string(body) != tt.wantBody {
|
|
t.Fatalf("body = %q, want %q", body, tt.wantBody)
|
|
}
|
|
})
|
|
}
|
|
|
|
if got := atomic.LoadInt32(&socks5Proxy.hits); got != 1 {
|
|
t.Fatalf("socks5 proxy hits = %d, want 1", got)
|
|
}
|
|
if got := atomic.LoadInt32(&socks5hProxy.hits); got != 1 {
|
|
t.Fatalf("socks5h proxy hits = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestHTTPSRequestsRespectProxyModes(t *testing.T) {
|
|
var targetHits int32
|
|
target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt32(&targetHits, 1)
|
|
w.Header().Set("Connection", "close")
|
|
_, _ = io.WriteString(w, "https-target")
|
|
}))
|
|
t.Cleanup(target.Close)
|
|
targetAddr := strings.TrimPrefix(target.URL, "https://")
|
|
|
|
var proxyHits int32
|
|
proxy := newConnectProxy(t, targetAddr, &proxyHits)
|
|
t.Cleanup(proxy.Close)
|
|
|
|
// Set HTTPS_PROXY last: on Windows it and https_proxy are the same var.
|
|
t.Setenv("HTTP_PROXY", "")
|
|
t.Setenv("http_proxy", "")
|
|
t.Setenv("https_proxy", "")
|
|
t.Setenv("NO_PROXY", "")
|
|
t.Setenv("no_proxy", "")
|
|
t.Setenv("HTTPS_PROXY", proxy.URL)
|
|
|
|
tests := []struct {
|
|
name string
|
|
spec ProxySpec
|
|
wantProxyHits int32
|
|
}{
|
|
{
|
|
name: "auto uses HTTPS_PROXY",
|
|
spec: ProxySpec{Mode: ModeAuto},
|
|
wantProxyHits: 1,
|
|
},
|
|
{
|
|
name: "custom proxies HTTPS requests",
|
|
spec: ProxySpec{Mode: ModeCustom, URL: proxy.URL},
|
|
wantProxyHits: 1,
|
|
},
|
|
{
|
|
name: "custom no_proxy bypasses HTTPS proxy",
|
|
spec: ProxySpec{Mode: ModeCustom, URL: proxy.URL, NoProxy: "service.test"},
|
|
},
|
|
{
|
|
name: "off bypasses HTTPS_PROXY",
|
|
spec: ProxySpec{Mode: ModeOff},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
atomic.StoreInt32(&targetHits, 0)
|
|
atomic.StoreInt32(&proxyHits, 0)
|
|
|
|
tr := mappedTransport(t, tt.spec, "service.test:443", targetAddr)
|
|
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
|
client := &http.Client{Transport: tr, Timeout: 2 * time.Second}
|
|
resp, err := client.Get("https://service.test/resource")
|
|
if err != nil {
|
|
t.Fatalf("GET: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("read body: %v", err)
|
|
}
|
|
if string(body) != "https-target" {
|
|
t.Fatalf("body = %q, want https-target", body)
|
|
}
|
|
if got := atomic.LoadInt32(&targetHits); got != 1 {
|
|
t.Fatalf("target hits = %d, want 1", got)
|
|
}
|
|
if got := atomic.LoadInt32(&proxyHits); got != tt.wantProxyHits {
|
|
t.Fatalf("CONNECT proxy hits = %d, want %d", got, tt.wantProxyHits)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func structuredProxySpec(t *testing.T, typ, rawURL string) ProxySpec {
|
|
t.Helper()
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
t.Fatalf("parse proxy URL: %v", err)
|
|
}
|
|
host, portText, err := net.SplitHostPort(u.Host)
|
|
if err != nil {
|
|
t.Fatalf("split proxy host: %v", err)
|
|
}
|
|
port, err := strconv.Atoi(portText)
|
|
if err != nil {
|
|
t.Fatalf("parse proxy port: %v", err)
|
|
}
|
|
return ProxySpec{Mode: ModeCustom, Type: typ, Server: host, Port: port}
|
|
}
|
|
|
|
func mappedClient(t *testing.T, spec ProxySpec, fromAddr, toAddr string) *http.Client {
|
|
t.Helper()
|
|
tr := mappedTransport(t, spec, fromAddr, toAddr)
|
|
return &http.Client{Transport: tr, Timeout: 2 * time.Second}
|
|
}
|
|
|
|
func mappedTransport(t *testing.T, spec ProxySpec, fromAddr, toAddr string) *http.Transport {
|
|
t.Helper()
|
|
tr, err := NewTransport(spec, TransportOptions{})
|
|
if err != nil {
|
|
t.Fatalf("NewTransport: %v", err)
|
|
}
|
|
t.Cleanup(tr.CloseIdleConnections)
|
|
dialer := &net.Dialer{Timeout: time.Second}
|
|
tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
if addr == fromAddr {
|
|
addr = toAddr
|
|
}
|
|
return dialer.DialContext(ctx, network, addr)
|
|
}
|
|
return tr
|
|
}
|
|
|
|
type socksHTTPProxy struct {
|
|
addr string
|
|
hits int32
|
|
}
|
|
|
|
func newSocksHTTPProxy(t *testing.T) *socksHTTPProxy {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen socks proxy: %v", err)
|
|
}
|
|
p := &socksHTTPProxy{addr: ln.Addr().String()}
|
|
t.Cleanup(func() { _ = ln.Close() })
|
|
go func() {
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go p.handle(conn)
|
|
}
|
|
}()
|
|
return p
|
|
}
|
|
|
|
func (p *socksHTTPProxy) handle(conn net.Conn) {
|
|
defer conn.Close()
|
|
_ = conn.SetDeadline(time.Now().Add(2 * time.Second))
|
|
r := bufio.NewReader(conn)
|
|
|
|
header := make([]byte, 2)
|
|
if _, err := io.ReadFull(r, header); err != nil || header[0] != 5 {
|
|
return
|
|
}
|
|
methods := make([]byte, int(header[1]))
|
|
if _, err := io.ReadFull(r, methods); err != nil {
|
|
return
|
|
}
|
|
if _, err := conn.Write([]byte{5, 0}); err != nil {
|
|
return
|
|
}
|
|
|
|
req := make([]byte, 4)
|
|
if _, err := io.ReadFull(r, req); err != nil || req[0] != 5 || req[1] != 1 {
|
|
return
|
|
}
|
|
if !p.readSocksAddr(r, req[3]) {
|
|
return
|
|
}
|
|
atomic.AddInt32(&p.hits, 1)
|
|
if _, err := conn.Write([]byte{5, 0, 0, 1, 0, 0, 0, 0, 0, 0}); err != nil {
|
|
return
|
|
}
|
|
|
|
httpReq, err := http.ReadRequest(r)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_ = httpReq.Body.Close()
|
|
_, _ = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Length: 11\r\nConnection: close\r\n\r\nsocks-proxy"))
|
|
}
|
|
|
|
func (p *socksHTTPProxy) readSocksAddr(r *bufio.Reader, atyp byte) bool {
|
|
switch atyp {
|
|
case 1:
|
|
if _, err := io.ReadFull(r, make([]byte, net.IPv4len)); err != nil {
|
|
return false
|
|
}
|
|
case 3:
|
|
size, err := r.ReadByte()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if _, err := io.ReadFull(r, make([]byte, int(size))); err != nil {
|
|
return false
|
|
}
|
|
case 4:
|
|
if _, err := io.ReadFull(r, make([]byte, net.IPv6len)); err != nil {
|
|
return false
|
|
}
|
|
default:
|
|
return false
|
|
}
|
|
port := make([]byte, 2)
|
|
if _, err := io.ReadFull(r, port); err != nil {
|
|
return false
|
|
}
|
|
return binary.BigEndian.Uint16(port) != 0
|
|
}
|
|
|
|
func newConnectProxy(t *testing.T, targetAddr string, hits *int32) *httptest.Server {
|
|
t.Helper()
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodConnect {
|
|
t.Errorf("proxy method = %s, want CONNECT", r.Method)
|
|
http.Error(w, "CONNECT required", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
atomic.AddInt32(hits, 1)
|
|
clientConn, _, err := http.NewResponseController(w).Hijack()
|
|
if err != nil {
|
|
t.Errorf("hijack CONNECT: %v", err)
|
|
return
|
|
}
|
|
defer clientConn.Close()
|
|
targetConn, err := net.DialTimeout("tcp", targetAddr, time.Second)
|
|
if err != nil {
|
|
t.Errorf("dial CONNECT target: %v", err)
|
|
return
|
|
}
|
|
defer targetConn.Close()
|
|
if _, err := clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil {
|
|
return
|
|
}
|
|
go func() {
|
|
_, _ = io.Copy(targetConn, clientConn)
|
|
}()
|
|
_, _ = io.Copy(clientConn, targetConn)
|
|
}))
|
|
}
|
|
|
|
func mustURL(s string) *url.URL {
|
|
u, err := url.Parse(s)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return u
|
|
}
|
|
|
|
func TestForceIPv4Dials(t *testing.T) {
|
|
tr, err := NewTransport(ProxySpec{Mode: ModeOff}, TransportOptions{ForceIPv4: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tr.DialContext == nil {
|
|
t.Fatal("ForceIPv4 should install a DialContext")
|
|
}
|
|
ln, err := net.Listen("tcp4", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer ln.Close()
|
|
|
|
conn, err := tr.DialContext(context.Background(), "tcp", ln.Addr().String())
|
|
if err != nil {
|
|
t.Fatalf("forced-IPv4 dial to a v4 listener failed: %v", err)
|
|
}
|
|
conn.Close()
|
|
|
|
// A tcp4 dialer rejects an IPv6 literal outright (address-family mismatch),
|
|
// which is exactly the IPv6 route the fallback is meant to skip.
|
|
if c, err := tr.DialContext(context.Background(), "tcp", "[::1]:9"); err == nil {
|
|
c.Close()
|
|
t.Error("forced-IPv4 dial should reject an IPv6 address")
|
|
}
|
|
}
|