mirror of
https://github.com/go-gost/gost.git
synced 2026-10-08 03:55:45 +00:00
chore: bump x to v0.15.1, add e2e tests for chainGroup and sniffing
- Bump github.com/go-gost/x v0.15.0 → v0.15.1 - ChainGroupSuite: matcher routing by host, probe dead-chain pre-marking - SniffingSuite: SOCKS5+HTTPS w/o SNI, debug-only log on empty SNI - Add RunHTTPSEchoContainer and https_echo.py test helper
This commit is contained in:
@@ -4,7 +4,7 @@ go 1.26.3
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-gost/core v0.6.0
|
github.com/go-gost/core v0.6.0
|
||||||
github.com/go-gost/x v0.15.0
|
github.com/go-gost/x v0.15.1
|
||||||
github.com/judwhite/go-svc v1.2.1
|
github.com/judwhite/go-svc v1.2.1
|
||||||
github.com/moby/moby/client v0.4.0
|
github.com/moby/moby/client v0.4.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
|
|||||||
@@ -97,8 +97,8 @@ github.com/go-gost/relay v0.7.0 h1:J8e3Sba6DtBJQotXY5j5EaZNWwtv6Z9CoCFQsnrHNcE=
|
|||||||
github.com/go-gost/relay v0.7.0/go.mod h1:Dku0f5sfjOClrZFiDmQUrYYJ4uof7rnkCUBfsl0PSAI=
|
github.com/go-gost/relay v0.7.0/go.mod h1:Dku0f5sfjOClrZFiDmQUrYYJ4uof7rnkCUBfsl0PSAI=
|
||||||
github.com/go-gost/tls-dissector v0.3.1 h1:gvOteWog5pjY/HCpc8l+gngmSi8Q6zl5rRrfK8gwRKA=
|
github.com/go-gost/tls-dissector v0.3.1 h1:gvOteWog5pjY/HCpc8l+gngmSi8Q6zl5rRrfK8gwRKA=
|
||||||
github.com/go-gost/tls-dissector v0.3.1/go.mod h1:vGfog053fIm93iXBtvmVzMQqEJo5YwbbNaPNVDjbiOc=
|
github.com/go-gost/tls-dissector v0.3.1/go.mod h1:vGfog053fIm93iXBtvmVzMQqEJo5YwbbNaPNVDjbiOc=
|
||||||
github.com/go-gost/x v0.15.0 h1:ugklRGp3fg8lveJ5JOp0wpnF2xxmP3qzb1TyWkEGDGc=
|
github.com/go-gost/x v0.15.1 h1:S8rP1HsU943GracSrok81SlsbTkHXJxkhXncXpxxDqU=
|
||||||
github.com/go-gost/x v0.15.0/go.mod h1:YQdy2hZ9nZ1w5GDacGKWrC4jIYXzthAKO/Er/K65+c4=
|
github.com/go-gost/x v0.15.1/go.mod h1:jQE7dktg2aMxfOiNmN3hqnbQb2K6KecMVBtglL+/H14=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
"github.com/testcontainers/testcontainers-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChainGroupSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
ctx context.Context
|
||||||
|
echoC testcontainers.Container
|
||||||
|
echoIP string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChainGroupSuite) SetupSuite() {
|
||||||
|
s.ctx = context.Background()
|
||||||
|
|
||||||
|
echoC, err := RunEchoContainer(s.ctx, SharedNetworkName)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.echoC = echoC
|
||||||
|
|
||||||
|
echoIP, err := echoC.ContainerIP(s.ctx)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.echoIP = echoIP
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChainGroupSuite) TearDownSuite() {
|
||||||
|
if s.echoC != nil {
|
||||||
|
s.echoC.Terminate(s.ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxyRequest sends a request through the gost proxy, using the given
|
||||||
|
// target hostname (not IP) so that Host() matchers at the chain-group level
|
||||||
|
// can match against it.
|
||||||
|
func (s *ChainGroupSuite) proxyRequestHost(gostC testcontainers.Container, proxyPort, targetHost string) (int, string) {
|
||||||
|
cmd := []string{
|
||||||
|
"curl", "-s",
|
||||||
|
"-x", fmt.Sprintf("http://127.0.0.1:%s", proxyPort),
|
||||||
|
fmt.Sprintf("http://%s:5678", targetHost),
|
||||||
|
}
|
||||||
|
code, out, err := gostC.Exec(s.ctx, cmd)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
|
||||||
|
body, err := io.ReadAll(out)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
return code, string(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxyRequestIP sends a request via IP (like existing tests do).
|
||||||
|
func (s *ChainGroupSuite) proxyRequestIP(gostC testcontainers.Container, port string) (int, string) {
|
||||||
|
return s.proxyRequestHost(gostC, port, s.echoIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMatcherRoutesByHost verifies that a chainGroup with per-entry Host() matchers
|
||||||
|
// routes traffic to the chain whose matcher matches the target hostname. The
|
||||||
|
// non-matching chain has a dead relay, so if the matcher fails to filter, requests
|
||||||
|
// would fail.
|
||||||
|
func (s *ChainGroupSuite) TestMatcherRoutesByHost() {
|
||||||
|
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, "testdata/chaingroup/matcher.yaml", "8080/tcp")
|
||||||
|
s.Require().NoError(err)
|
||||||
|
defer gostC.Terminate(s.ctx)
|
||||||
|
|
||||||
|
// Requests with hostname "tcp-echo" (the echo container's network alias)
|
||||||
|
// must match Host("tcp-echo") → chain-target → live relay → echo server.
|
||||||
|
for range 10 {
|
||||||
|
code, body := s.proxyRequestHost(gostC, "8080", "tcp-echo")
|
||||||
|
s.Require().Equal(0, code, "Host('tcp-echo') must match the target chain")
|
||||||
|
s.Require().Contains(body, "hello-gost")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProbeMarksDeadChain verifies that a TCP probe detects a dead chain entry
|
||||||
|
// and marks it before real traffic, so the FailFilter excludes it. With the dead
|
||||||
|
// entry pre-marked, every request succeeds.
|
||||||
|
func (s *ChainGroupSuite) TestProbeMarksDeadChain() {
|
||||||
|
gostC, err := RunGostContainerWithPorts(s.ctx, SharedNetworkName, "testdata/chaingroup/probe.yaml", "8080/tcp")
|
||||||
|
s.Require().NoError(err)
|
||||||
|
defer gostC.Terminate(s.ctx)
|
||||||
|
|
||||||
|
// The probe fires immediately at startup; by the time we send requests
|
||||||
|
// the dead chain entry is already marked. All requests converge to the
|
||||||
|
// live chain.
|
||||||
|
failures := 0
|
||||||
|
for range 10 {
|
||||||
|
code, body := s.proxyRequestIP(gostC, "8080")
|
||||||
|
if code != 0 || !strings.Contains(body, "hello-gost") {
|
||||||
|
failures++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// At most one failure is acceptable (the first request might race with
|
||||||
|
// the probe's first Mark). Every request after must succeed.
|
||||||
|
s.Require().LessOrEqual(failures, 0,
|
||||||
|
"probe must pre-mark the dead chain entry; all requests must succeed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChainGroupSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(ChainGroupSuite))
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import ssl
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
|
||||||
|
PORT = int(os.environ.get("PORT", "8443"))
|
||||||
|
|
||||||
|
cert_dir = "/tmp/certs"
|
||||||
|
os.makedirs(cert_dir, exist_ok=True)
|
||||||
|
cert_file = os.path.join(cert_dir, "cert.pem")
|
||||||
|
key_file = os.path.join(cert_dir, "key.pem")
|
||||||
|
|
||||||
|
if not os.path.exists(cert_file) or not os.path.exists(key_file):
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"openssl", "req", "-x509", "-newkey", "rsa:2048",
|
||||||
|
"-keyout", key_file, "-out", cert_file,
|
||||||
|
"-days", "365", "-nodes",
|
||||||
|
"-subj", "/CN=localhost",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
body = b"hello-gost"
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
server = HTTPServer(("0.0.0.0", PORT), Handler)
|
||||||
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
|
ctx.load_cert_chain(cert_file, key_file)
|
||||||
|
server.socket = ctx.wrap_socket(server.socket, server_side=True)
|
||||||
|
server.serve_forever()
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
"github.com/testcontainers/testcontainers-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SniffingSuite covers protocol sniffing behavior.
|
||||||
|
type SniffingSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
ctx context.Context
|
||||||
|
httpsEchoC testcontainers.Container
|
||||||
|
httpsIP string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SniffingSuite) SetupSuite() {
|
||||||
|
s.ctx = context.Background()
|
||||||
|
|
||||||
|
c, err := RunHTTPSEchoContainer(s.ctx, SharedNetworkName)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.httpsEchoC = c
|
||||||
|
|
||||||
|
ip, err := c.ContainerIP(s.ctx)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.httpsIP = ip
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SniffingSuite) TearDownSuite() {
|
||||||
|
if s.httpsEchoC != nil {
|
||||||
|
s.httpsEchoC.Terminate(s.ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// curlThroughSOCKS5 runs curl through the GOST SOCKS5 proxy on the given
|
||||||
|
// container address and port, connecting to the HTTPS echo server by IP.
|
||||||
|
// Connecting by IP makes curl omit the TLS SNI extension.
|
||||||
|
func (s *SniffingSuite) curlThroughSOCKS5(c testcontainers.Container, proxyPort string) string {
|
||||||
|
s.T().Helper()
|
||||||
|
// curl -k: don't verify the self-signed cert on the echo server
|
||||||
|
// curl -x socks5://...: route through GOST SOCKS5 proxy
|
||||||
|
// Using the container IP as the host makes curl skip SNI entirely.
|
||||||
|
cmd := []string{"curl", "-k", "-s",
|
||||||
|
"-x", "socks5://127.0.0.1:" + proxyPort,
|
||||||
|
"https://" + s.httpsIP + ":8443",
|
||||||
|
}
|
||||||
|
code, out, err := c.Exec(s.ctx, cmd)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.Require().Zero(code, "curl should exit 0")
|
||||||
|
b, _ := io.ReadAll(out)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSOCKS5NoSNI verifies that a SOCKS5 proxy with sniffing enabled can
|
||||||
|
// successfully forward TLS connections that lack an SNI extension — such as
|
||||||
|
// when a client connects to an HTTPS server by IP address.
|
||||||
|
func (s *SniffingSuite) TestSOCKS5NoSNI() {
|
||||||
|
gostC, err := RunGostContainer(s.ctx, SharedNetworkName,
|
||||||
|
"testdata/sniffing/no_sni.yaml",
|
||||||
|
)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
defer func() {
|
||||||
|
DumpLogs(s.T(), s.ctx, "gost-no-sni", gostC)
|
||||||
|
gostC.Terminate(s.ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
body := s.curlThroughSOCKS5(gostC, "8080")
|
||||||
|
s.Require().Contains(body, "hello-gost",
|
||||||
|
"HTTPS response should pass through when SNI is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSOCKS5NoSNI_Callback verifies the same behavior a second time, serving
|
||||||
|
// as a sanity check that state doesn't leak between connections.
|
||||||
|
func (s *SniffingSuite) TestSOCKS5NoSNI_Callback() {
|
||||||
|
s.TestSOCKS5NoSNI()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSOCKS5NoSNI_LogsOnlyDebug verifies the proxy logs a debug message
|
||||||
|
// rather than an error when SNI is missing.
|
||||||
|
func (s *SniffingSuite) TestSOCKS5NoSNI_LogsOnlyDebug() {
|
||||||
|
gostC, err := RunGostContainer(s.ctx, SharedNetworkName,
|
||||||
|
"testdata/sniffing/no_sni.yaml",
|
||||||
|
)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
defer func() {
|
||||||
|
// Do NOT dump logs before the grep — DumpLogs consumes the reader
|
||||||
|
gostC.Terminate(s.ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
body := s.curlThroughSOCKS5(gostC, "8080")
|
||||||
|
s.Require().Contains(body, "hello-gost")
|
||||||
|
|
||||||
|
// Verify the debug log message exists and no error-level SNI message.
|
||||||
|
logs, err := gostC.Logs(s.ctx)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
defer logs.Close()
|
||||||
|
logBody, _ := io.ReadAll(logs)
|
||||||
|
logStr := string(logBody)
|
||||||
|
|
||||||
|
s.Require().Contains(logStr, "no sni in clienthello",
|
||||||
|
"should log a debug message when SNI is empty")
|
||||||
|
s.Require().NotContains(logStr, "tls: sni is empty",
|
||||||
|
"must not error on empty SNI")
|
||||||
|
// The log level for "no sni" should be debug, not error.
|
||||||
|
s.Require().False(strings.Contains(logStr, `"level":"error"`),
|
||||||
|
"should not log any error-level message")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSniffingSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(SniffingSuite))
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
services:
|
||||||
|
- name: proxy
|
||||||
|
addr: :8080
|
||||||
|
handler:
|
||||||
|
type: http
|
||||||
|
chainGroup:
|
||||||
|
chains:
|
||||||
|
- chain: chain-target
|
||||||
|
matcher:
|
||||||
|
rule: Host(`tcp-echo`)
|
||||||
|
- chain: chain-other
|
||||||
|
matcher:
|
||||||
|
rule: Host(`no-match`)
|
||||||
|
selector:
|
||||||
|
strategy: round
|
||||||
|
maxFails: 1
|
||||||
|
failTimeout: 10s
|
||||||
|
listener:
|
||||||
|
type: tcp
|
||||||
|
|
||||||
|
# Live relay: forwards to echo server.
|
||||||
|
- name: relay
|
||||||
|
addr: 127.0.0.1:18081
|
||||||
|
handler:
|
||||||
|
type: http
|
||||||
|
listener:
|
||||||
|
type: tcp
|
||||||
|
|
||||||
|
chains:
|
||||||
|
- name: chain-target
|
||||||
|
hops:
|
||||||
|
- name: hop-target
|
||||||
|
nodes:
|
||||||
|
- name: n-live
|
||||||
|
addr: 127.0.0.1:18081
|
||||||
|
connector:
|
||||||
|
type: http
|
||||||
|
|
||||||
|
# This chain has a dead relay — but the matcher Host("no-match") ensures it is
|
||||||
|
# never selected for requests targeting tcp-echo.
|
||||||
|
- name: chain-other
|
||||||
|
hops:
|
||||||
|
- name: hop-other
|
||||||
|
nodes:
|
||||||
|
- name: n-dead
|
||||||
|
addr: 127.0.0.1:18082
|
||||||
|
connector:
|
||||||
|
type: http
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
services:
|
||||||
|
- name: proxy
|
||||||
|
addr: :8080
|
||||||
|
handler:
|
||||||
|
type: http
|
||||||
|
chainGroup:
|
||||||
|
chains:
|
||||||
|
- chain: chain-live
|
||||||
|
probe:
|
||||||
|
type: tcp
|
||||||
|
addr: 127.0.0.1:18081
|
||||||
|
interval: 3s
|
||||||
|
- chain: chain-dead
|
||||||
|
probe:
|
||||||
|
type: tcp
|
||||||
|
addr: 127.0.0.1:18082
|
||||||
|
interval: 3s
|
||||||
|
selector:
|
||||||
|
strategy: round
|
||||||
|
maxFails: 1
|
||||||
|
failTimeout: 10s
|
||||||
|
listener:
|
||||||
|
type: tcp
|
||||||
|
|
||||||
|
# Live relay: forwards to echo server.
|
||||||
|
- name: relay
|
||||||
|
addr: 127.0.0.1:18081
|
||||||
|
handler:
|
||||||
|
type: http
|
||||||
|
listener:
|
||||||
|
type: tcp
|
||||||
|
|
||||||
|
chains:
|
||||||
|
- name: chain-live
|
||||||
|
hops:
|
||||||
|
- name: hop-live
|
||||||
|
nodes:
|
||||||
|
- name: n-live
|
||||||
|
addr: 127.0.0.1:18081
|
||||||
|
connector:
|
||||||
|
type: http
|
||||||
|
|
||||||
|
# Dead chain: relay never listens here. TCP probe marks the chain entry so
|
||||||
|
# FailFilter excludes it before real traffic reaches it.
|
||||||
|
- name: chain-dead
|
||||||
|
hops:
|
||||||
|
- name: hop-dead
|
||||||
|
nodes:
|
||||||
|
- name: n-dead
|
||||||
|
addr: 127.0.0.1:18082
|
||||||
|
connector:
|
||||||
|
type: http
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
services:
|
||||||
|
- name: service-0
|
||||||
|
addr: ":8080"
|
||||||
|
handler:
|
||||||
|
type: socks5
|
||||||
|
metadata:
|
||||||
|
sniffing: true
|
||||||
|
listener:
|
||||||
|
type: tcp
|
||||||
@@ -227,6 +227,37 @@ func RunResolverResponderContainer(ctx context.Context, networkName, targetIP st
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunHTTPSEchoContainer starts an HTTPS echo server (self-signed cert) that
|
||||||
|
// responds with "hello-gost" on port 8443. The container is registered with
|
||||||
|
// the network alias "https-echo".
|
||||||
|
func RunHTTPSEchoContainer(ctx context.Context, networkName string) (testcontainers.Container, error) {
|
||||||
|
return testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||||||
|
ContainerRequest: testcontainers.ContainerRequest{
|
||||||
|
FromDockerfile: testcontainers.FromDockerfile{
|
||||||
|
Context: ".",
|
||||||
|
Dockerfile: "Dockerfile",
|
||||||
|
Repo: "gost-e2e",
|
||||||
|
Tag: "latest",
|
||||||
|
KeepImage: true,
|
||||||
|
BuildOptionsModifier: func(opts *client.ImageBuildOptions) {
|
||||||
|
opts.NetworkMode = "host"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Networks: []string{networkName},
|
||||||
|
NetworkAliases: map[string][]string{
|
||||||
|
networkName: {"https-echo"},
|
||||||
|
},
|
||||||
|
Files: []testcontainers.ContainerFile{
|
||||||
|
{HostFilePath: "scripts/https_echo.py", ContainerFilePath: "/scripts/https_echo.py", FileMode: 0644},
|
||||||
|
},
|
||||||
|
ExposedPorts: []string{"8443/tcp"},
|
||||||
|
Cmd: []string{"python3", "/scripts/https_echo.py"},
|
||||||
|
WaitingFor: wait.ForExposedPort(),
|
||||||
|
},
|
||||||
|
Started: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func RunGostContainer(ctx context.Context, networkName, yamlPath string) (testcontainers.Container, error) {
|
func RunGostContainer(ctx context.Context, networkName, yamlPath string) (testcontainers.Container, error) {
|
||||||
return runGostContainer(ctx, networkName, yamlPath, nil, nil, nil)
|
return runGostContainer(ctx, networkName, yamlPath, nil, nil, nil)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user