Reapply SingboxForPanel integration on upstream stable
This commit is contained in:
@@ -29,7 +29,7 @@ func (m *UserManager) postUpdate(updated bool) error {
|
||||
users = append(users, username)
|
||||
uPSKs = append(uPSKs, password)
|
||||
}
|
||||
err := m.server.UpdateUsers(users, uPSKs)
|
||||
err := m.server.UpdateUsers(users, uPSKs, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
112
service/xboard/multi_service.go
Normal file
112
service/xboard/multi_service.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package xboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
boxService "github.com/sagernet/sing-box/adapter/service"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
)
|
||||
|
||||
type multiNodeService struct {
|
||||
boxService.Adapter
|
||||
services []adapter.Service
|
||||
}
|
||||
|
||||
func newMultiNodeService(ctx context.Context, logger log.ContextLogger, tag string, options option.XBoardServiceOptions) (adapter.Service, error) {
|
||||
expanded := expandNodeOptions(options)
|
||||
services := make([]adapter.Service, 0, len(expanded))
|
||||
for i, node := range expanded {
|
||||
nodeTag := expandedNodeTag(tag, i, options.Nodes[i], node)
|
||||
service, err := newSingleService(ctx, logger, nodeTag, node)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create xboard node service %s: %w", nodeTag, err)
|
||||
}
|
||||
services = append(services, service)
|
||||
}
|
||||
return &multiNodeService{
|
||||
Adapter: boxService.NewAdapter(C.TypeXBoard, tag),
|
||||
services: services,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func expandNodeOptions(base option.XBoardServiceOptions) []option.XBoardServiceOptions {
|
||||
if len(base.Nodes) == 0 {
|
||||
return []option.XBoardServiceOptions{base}
|
||||
}
|
||||
result := make([]option.XBoardServiceOptions, 0, len(base.Nodes))
|
||||
for _, node := range base.Nodes {
|
||||
child := base
|
||||
child.Nodes = nil
|
||||
if node.PanelURL != "" {
|
||||
child.PanelURL = node.PanelURL
|
||||
}
|
||||
if node.ConfigPanelURL != "" {
|
||||
child.ConfigPanelURL = node.ConfigPanelURL
|
||||
}
|
||||
if node.UserPanelURL != "" {
|
||||
child.UserPanelURL = node.UserPanelURL
|
||||
}
|
||||
if node.Key != "" {
|
||||
child.Key = node.Key
|
||||
}
|
||||
if node.NodeID != 0 {
|
||||
child.NodeID = node.NodeID
|
||||
}
|
||||
if node.ConfigNodeID != 0 {
|
||||
child.ConfigNodeID = node.ConfigNodeID
|
||||
}
|
||||
if node.UserNodeID != 0 {
|
||||
child.UserNodeID = node.UserNodeID
|
||||
}
|
||||
if node.NodeType != "" {
|
||||
child.NodeType = node.NodeType
|
||||
}
|
||||
if node.SyncInterval != 0 {
|
||||
child.SyncInterval = node.SyncInterval
|
||||
}
|
||||
if node.ReportInterval != 0 {
|
||||
child.ReportInterval = node.ReportInterval
|
||||
}
|
||||
result = append(result, child)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func expandedNodeTag(baseTag string, index int, entry option.XBoardNodeOptions, node option.XBoardServiceOptions) string {
|
||||
nodeTag := ""
|
||||
if entry.Tag != "" {
|
||||
nodeTag = entry.Tag
|
||||
}
|
||||
if nodeTag == "" && node.NodeID != 0 {
|
||||
nodeTag = fmt.Sprintf("%d", node.NodeID)
|
||||
}
|
||||
if nodeTag == "" {
|
||||
nodeTag = fmt.Sprintf("%d", index+1)
|
||||
}
|
||||
if baseTag == "" {
|
||||
return "xboard-" + nodeTag
|
||||
}
|
||||
return fmt.Sprintf("%s-%s", baseTag, nodeTag)
|
||||
}
|
||||
|
||||
func (s *multiNodeService) Start(stage adapter.StartStage) error {
|
||||
for _, service := range s.services {
|
||||
if err := service.Start(stage); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *multiNodeService) Close() error {
|
||||
for i := len(s.services) - 1; i >= 0; i-- {
|
||||
if err := s.services[i].Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
1963
service/xboard/service.go
Normal file
1963
service/xboard/service.go
Normal file
File diff suppressed because it is too large
Load Diff
317
service/xboard/service_test.go
Normal file
317
service/xboard/service_test.go
Normal file
@@ -0,0 +1,317 @@
|
||||
package xboard
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common/json/badoption"
|
||||
)
|
||||
|
||||
func TestXUserResolveKeyPrefersPasswordFields(t *testing.T) {
|
||||
user := XUser{
|
||||
UUID: "uuid-value",
|
||||
Passwd: "passwd-value",
|
||||
Password: "password-value",
|
||||
Token: "token-value",
|
||||
}
|
||||
|
||||
if got := user.ResolveKey(); got != "passwd-value" {
|
||||
t.Fatalf("ResolveKey() = %q, want %q", got, "passwd-value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestXUserIdentifierPrefersUUID(t *testing.T) {
|
||||
user := XUser{
|
||||
ID: 7,
|
||||
UUID: "uuid-value",
|
||||
Email: "user@example.com",
|
||||
}
|
||||
|
||||
if got := user.Identifier(); got != "uuid-value" {
|
||||
t.Fatalf("Identifier() = %q, want %q", got, "uuid-value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUserKeyForSS2022CombinedPassword(t *testing.T) {
|
||||
service := &Service{ssServerKey: "master-key"}
|
||||
user := XUser{
|
||||
ID: 1,
|
||||
Password: "master-key:user-key",
|
||||
UUID: "uuid-value",
|
||||
}
|
||||
|
||||
if got := service.resolveUserKey(user, true); got != "user-key" {
|
||||
t.Fatalf("resolveUserKey() = %q, want %q", got, "user-key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUserKeyForNonSS2022UsesResolvedKey(t *testing.T) {
|
||||
service := &Service{}
|
||||
user := XUser{
|
||||
UUID: "uuid-value",
|
||||
Passwd: "passwd-value",
|
||||
}
|
||||
|
||||
if got := service.resolveUserKey(user, false); got != "passwd-value" {
|
||||
t.Fatalf("resolveUserKey() = %q, want %q", got, "passwd-value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestXUserIdentifierFallsBackToEmailThenID(t *testing.T) {
|
||||
userWithEmail := XUser{
|
||||
ID: 8,
|
||||
Email: "user@example.com",
|
||||
}
|
||||
if got := userWithEmail.Identifier(); got != "user@example.com" {
|
||||
t.Fatalf("Identifier() = %q, want %q", got, "user@example.com")
|
||||
}
|
||||
|
||||
userWithID := XUser{ID: 9}
|
||||
if got := userWithID.Identifier(); got != "9" {
|
||||
t.Fatalf("Identifier() = %q, want %q", got, "9")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandNodeOptions(t *testing.T) {
|
||||
base := option.XBoardServiceOptions{
|
||||
PanelURL: "https://panel.example",
|
||||
Key: "shared-token",
|
||||
NodeType: "vless",
|
||||
Nodes: []option.XBoardNodeOptions{
|
||||
{NodeID: 1},
|
||||
{NodeID: 2, NodeType: "anytls"},
|
||||
},
|
||||
}
|
||||
|
||||
nodes := expandNodeOptions(base)
|
||||
if len(nodes) != 2 {
|
||||
t.Fatalf("expandNodeOptions() len = %d, want 2", len(nodes))
|
||||
}
|
||||
if nodes[0].NodeID != 1 || nodes[0].NodeType != "vless" {
|
||||
t.Fatalf("first node = %+v", nodes[0])
|
||||
}
|
||||
if nodes[1].NodeID != 2 || nodes[1].NodeType != "anytls" {
|
||||
t.Fatalf("second node = %+v", nodes[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandNodeOptionsMinimalInstallConfig(t *testing.T) {
|
||||
base := option.XBoardServiceOptions{
|
||||
PanelURL: "https://panel.example",
|
||||
Key: "shared-token",
|
||||
SyncInterval: 60,
|
||||
ReportInterval: 60,
|
||||
Nodes: []option.XBoardNodeOptions{
|
||||
{NodeID: 286},
|
||||
{NodeID: 774},
|
||||
},
|
||||
}
|
||||
|
||||
nodes := expandNodeOptions(base)
|
||||
if len(nodes) != 2 {
|
||||
t.Fatalf("expandNodeOptions() len = %d, want 2", len(nodes))
|
||||
}
|
||||
for index, node := range nodes {
|
||||
if node.PanelURL != base.PanelURL {
|
||||
t.Fatalf("node %d PanelURL = %q, want %q", index, node.PanelURL, base.PanelURL)
|
||||
}
|
||||
if node.Key != base.Key {
|
||||
t.Fatalf("node %d Key = %q, want %q", index, node.Key, base.Key)
|
||||
}
|
||||
if node.SyncInterval != base.SyncInterval {
|
||||
t.Fatalf("node %d SyncInterval = %v, want %v", index, node.SyncInterval, base.SyncInterval)
|
||||
}
|
||||
if node.ReportInterval != base.ReportInterval {
|
||||
t.Fatalf("node %d ReportInterval = %v, want %v", index, node.ReportInterval, base.ReportInterval)
|
||||
}
|
||||
if node.ConfigPanelURL != "" || node.UserPanelURL != "" {
|
||||
t.Fatalf("node %d unexpected panel overrides: config=%q user=%q", index, node.ConfigPanelURL, node.UserPanelURL)
|
||||
}
|
||||
if node.ConfigNodeID != 0 || node.UserNodeID != 0 {
|
||||
t.Fatalf("node %d unexpected node overrides: config=%d user=%d", index, node.ConfigNodeID, node.UserNodeID)
|
||||
}
|
||||
}
|
||||
if nodes[0].NodeID != 286 {
|
||||
t.Fatalf("first node NodeID = %d, want 286", nodes[0].NodeID)
|
||||
}
|
||||
if nodes[1].NodeID != 774 {
|
||||
t.Fatalf("second node NodeID = %d, want 774", nodes[1].NodeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandedNodeTagFallsBackToNodeID(t *testing.T) {
|
||||
tag := expandedNodeTag("", 0, option.XBoardNodeOptions{NodeID: 286}, option.XBoardServiceOptions{NodeID: 286})
|
||||
if tag != "xboard-286" {
|
||||
t.Fatalf("expandedNodeTag() = %q, want %q", tag, "xboard-286")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyACMEConfigFromAutoTLS(t *testing.T) {
|
||||
var tlsOptions option.InboundTLSOptions
|
||||
ok := applyACMEConfig(&tlsOptions, nil, true, "example.com", 8443)
|
||||
if !ok {
|
||||
t.Fatal("applyACMEConfig() returned false")
|
||||
}
|
||||
if tlsOptions.ACME == nil {
|
||||
t.Fatal("ACME options not configured")
|
||||
}
|
||||
if len(tlsOptions.ACME.Domain) != 1 || tlsOptions.ACME.Domain[0] != "example.com" {
|
||||
t.Fatalf("ACME domains = %+v", tlsOptions.ACME.Domain)
|
||||
}
|
||||
if tlsOptions.ACME.DisableHTTPChallenge {
|
||||
t.Fatal("DisableHTTPChallenge should be false for auto_tls/http mode")
|
||||
}
|
||||
if !tlsOptions.ACME.DisableTLSALPNChallenge {
|
||||
t.Fatal("DisableTLSALPNChallenge should be true for auto_tls/http mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyACMEConfigFromDNSCertMode(t *testing.T) {
|
||||
var tlsOptions option.InboundTLSOptions
|
||||
ok := applyACMEConfig(&tlsOptions, &XCertConfig{
|
||||
CertMode: "dns",
|
||||
Domain: "example.com",
|
||||
DNSProvider: "cloudflare",
|
||||
DNSEnv: map[string]string{
|
||||
"CF_API_TOKEN": "token",
|
||||
},
|
||||
}, false, "example.com", 443)
|
||||
if !ok {
|
||||
t.Fatal("applyACMEConfig() returned false")
|
||||
}
|
||||
if tlsOptions.ACME == nil || tlsOptions.ACME.DNS01Challenge == nil {
|
||||
t.Fatal("DNS01Challenge not configured")
|
||||
}
|
||||
if tlsOptions.ACME.DNS01Challenge.Provider != "cloudflare" {
|
||||
t.Fatalf("DNS provider = %q", tlsOptions.ACME.DNS01Challenge.Provider)
|
||||
}
|
||||
if tlsOptions.ACME.DNS01Challenge.CloudflareOptions.APIToken != "token" {
|
||||
t.Fatalf("Cloudflare API token = %q", tlsOptions.ACME.DNS01Challenge.CloudflareOptions.APIToken)
|
||||
}
|
||||
if !tlsOptions.ACME.DisableTLSALPNChallenge {
|
||||
t.Fatal("DisableTLSALPNChallenge should be true for dns mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyACMEConfigFromTencentCloudDNSCertMode(t *testing.T) {
|
||||
var tlsOptions option.InboundTLSOptions
|
||||
ok := applyACMEConfig(&tlsOptions, &XCertConfig{
|
||||
CertMode: "dns",
|
||||
Domain: "code.littlediary.cn",
|
||||
DNSProvider: "tencentcloud",
|
||||
DNSEnv: map[string]string{
|
||||
"TENCENTCLOUD_SECRET_ID": "sid",
|
||||
"TENCENTCLOUD_SECRET_KEY": "skey",
|
||||
},
|
||||
}, false, "code.littlediary.cn", 45365)
|
||||
if !ok {
|
||||
t.Fatal("applyACMEConfig() returned false")
|
||||
}
|
||||
if tlsOptions.ACME == nil || tlsOptions.ACME.DNS01Challenge == nil {
|
||||
t.Fatal("DNS01Challenge not configured")
|
||||
}
|
||||
if tlsOptions.ACME.DNS01Challenge.Provider != C.DNSProviderTencentCloud {
|
||||
t.Fatalf("DNS provider = %q", tlsOptions.ACME.DNS01Challenge.Provider)
|
||||
}
|
||||
if tlsOptions.ACME.DNS01Challenge.TencentCloudOptions.SecretID != "sid" {
|
||||
t.Fatalf("TencentCloud SecretID = %q", tlsOptions.ACME.DNS01Challenge.TencentCloudOptions.SecretID)
|
||||
}
|
||||
if tlsOptions.ACME.DNS01Challenge.TencentCloudOptions.SecretKey != "skey" {
|
||||
t.Fatalf("TencentCloud SecretKey = %q", tlsOptions.ACME.DNS01Challenge.TencentCloudOptions.SecretKey)
|
||||
}
|
||||
if tlsOptions.ACME.AlternativeTLSPort != 45365 {
|
||||
t.Fatalf("AlternativeTLSPort = %d, want 45365", tlsOptions.ACME.AlternativeTLSPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyACMEConfigFromDNSPodAliasWithTencentCredentials(t *testing.T) {
|
||||
var tlsOptions option.InboundTLSOptions
|
||||
ok := applyACMEConfig(&tlsOptions, &XCertConfig{
|
||||
CertMode: "dns",
|
||||
Domain: "code.littlediary.cn",
|
||||
DNSProvider: "dnspod",
|
||||
DNSEnv: map[string]string{
|
||||
"TENCENTCLOUD_SECRET_ID": "sid",
|
||||
"TENCENTCLOUD_SECRET_KEY": "skey",
|
||||
},
|
||||
}, false, "code.littlediary.cn", 443)
|
||||
if !ok {
|
||||
t.Fatal("applyACMEConfig() returned false")
|
||||
}
|
||||
if tlsOptions.ACME == nil || tlsOptions.ACME.DNS01Challenge == nil {
|
||||
t.Fatal("DNS01Challenge not configured")
|
||||
}
|
||||
if tlsOptions.ACME.DNS01Challenge.Provider != C.DNSProviderTencentCloud {
|
||||
t.Fatalf("DNS provider = %q, want %q", tlsOptions.ACME.DNS01Challenge.Provider, C.DNSProviderTencentCloud)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergedTLSSettingsUsesTopLevelServerName(t *testing.T) {
|
||||
tlsSettings := mergedTLSSettings(XInnerConfig{}, &XNodeConfig{
|
||||
ServerName: "code.littlediary.cn",
|
||||
})
|
||||
if tlsSettings == nil {
|
||||
t.Fatal("mergedTLSSettings() returned nil")
|
||||
}
|
||||
if tlsSettings.ServerName != "code.littlediary.cn" {
|
||||
t.Fatalf("ServerName = %q, want %q", tlsSettings.ServerName, "code.littlediary.cn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasUsableServerTLS(t *testing.T) {
|
||||
if hasUsableServerTLS(option.InboundTLSOptions{}) {
|
||||
t.Fatal("empty TLS options should not be usable")
|
||||
}
|
||||
if !hasUsableServerTLS(option.InboundTLSOptions{
|
||||
CertificatePath: "cert.pem",
|
||||
KeyPath: "key.pem",
|
||||
}) {
|
||||
t.Fatal("file certificate should be usable")
|
||||
}
|
||||
if !hasUsableServerTLS(option.InboundTLSOptions{
|
||||
ACME: &option.InboundACMEOptions{
|
||||
Domain: badoption.Listable[string]{"example.com"},
|
||||
},
|
||||
}) {
|
||||
t.Fatal("ACME certificate should be usable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInboundMultiplex(t *testing.T) {
|
||||
config := &XMultiplexConfig{
|
||||
Enabled: true,
|
||||
Padding: true,
|
||||
Brutal: &XBrutalConfig{
|
||||
Enabled: true,
|
||||
UpMbps: 100,
|
||||
DownMbps: 200,
|
||||
},
|
||||
}
|
||||
|
||||
got := buildInboundMultiplex(config)
|
||||
if got == nil {
|
||||
t.Fatal("buildInboundMultiplex() returned nil")
|
||||
}
|
||||
if !got.Enabled || !got.Padding {
|
||||
t.Fatalf("buildInboundMultiplex() = %+v", got)
|
||||
}
|
||||
if got.Brutal == nil || !got.Brutal.Enabled || got.Brutal.UpMbps != 100 || got.Brutal.DownMbps != 200 {
|
||||
t.Fatalf("buildInboundMultiplex() brutal = %+v", got.Brutal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePanelNodeType(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"v2ray": "vmess",
|
||||
"hysteria2": "hysteria",
|
||||
"vless": "vless",
|
||||
"": "",
|
||||
}
|
||||
|
||||
for input, want := range tests {
|
||||
if got := normalizePanelNodeType(input); got != want {
|
||||
t.Fatalf("normalizePanelNodeType(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
99
service/xboard/tracker.go
Normal file
99
service/xboard/tracker.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package xboard
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/service/ssmapi"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
)
|
||||
|
||||
const aliveIPRetention = 5 * time.Minute
|
||||
|
||||
var _ adapter.SSMTracker = (*xboardTracker)(nil)
|
||||
|
||||
type xboardTracker struct {
|
||||
service *Service
|
||||
traffic *ssmapi.TrafficManager
|
||||
}
|
||||
|
||||
func newXboardTracker(service *Service) *xboardTracker {
|
||||
return &xboardTracker{
|
||||
service: service,
|
||||
traffic: ssmapi.NewTrafficManager(),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *xboardTracker) TrafficManager() *ssmapi.TrafficManager {
|
||||
return t.traffic
|
||||
}
|
||||
|
||||
func (t *xboardTracker) TrackConnection(conn net.Conn, metadata adapter.InboundContext) net.Conn {
|
||||
t.service.recordAliveIP(metadata)
|
||||
return t.traffic.TrackConnection(conn, metadata)
|
||||
}
|
||||
|
||||
func (t *xboardTracker) TrackPacketConnection(conn N.PacketConn, metadata adapter.InboundContext) N.PacketConn {
|
||||
t.service.recordAliveIP(metadata)
|
||||
return t.traffic.TrackPacketConnection(conn, metadata)
|
||||
}
|
||||
|
||||
func (s *Service) recordAliveIP(metadata adapter.InboundContext) {
|
||||
if metadata.User == "" || !metadata.Source.IsValid() || !metadata.Source.Addr.IsValid() {
|
||||
return
|
||||
}
|
||||
|
||||
clientIP := metadata.Source.Addr.Unmap().String()
|
||||
if clientIP == "" {
|
||||
return
|
||||
}
|
||||
|
||||
s.access.Lock()
|
||||
defer s.access.Unlock()
|
||||
|
||||
ipSet, exists := s.aliveUsers[metadata.User]
|
||||
if !exists {
|
||||
ipSet = make(map[string]time.Time)
|
||||
s.aliveUsers[metadata.User] = ipSet
|
||||
}
|
||||
ipSet[clientIP] = time.Now()
|
||||
}
|
||||
|
||||
func (s *Service) buildAlivePayload() map[string][]string {
|
||||
now := time.Now()
|
||||
|
||||
s.access.Lock()
|
||||
defer s.access.Unlock()
|
||||
|
||||
payload := make(map[string][]string)
|
||||
for userName, ipSet := range s.aliveUsers {
|
||||
userMeta, exists := s.localUsers[userName]
|
||||
if !exists || userMeta.ID == 0 {
|
||||
delete(s.aliveUsers, userName)
|
||||
continue
|
||||
}
|
||||
|
||||
activeIPs := make([]string, 0, len(ipSet))
|
||||
for ip, lastSeen := range ipSet {
|
||||
if now.Sub(lastSeen) > aliveIPRetention {
|
||||
delete(ipSet, ip)
|
||||
continue
|
||||
}
|
||||
activeIPs = append(activeIPs, ip)
|
||||
}
|
||||
if len(ipSet) == 0 {
|
||||
delete(s.aliveUsers, userName)
|
||||
}
|
||||
if len(activeIPs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
sort.Strings(activeIPs)
|
||||
payload[strconv.Itoa(userMeta.ID)] = activeIPs
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
Reference in New Issue
Block a user