diff --git a/api.md b/api.md index 50167aaa..3f72f411 100644 --- a/api.md +++ b/api.md @@ -331,6 +331,26 @@ Same endpoints as V1 but under `/api/v2/passport/` prefix. - **Purpose**: Get server configuration - **Returns**: Server configuration - **Data**: Server settings and parameters + - **Example Response**: +```json +{ + "protocol": "vless", + "listen_ip": "0.0.0.0", + "server_port": 18443, + "network": "tcp", + "tls": 2, + "server_name": "git.example.com", + "dest": "www.cloudflare.com:443", + "private_key": "YOUR_REALITY_PRIVATE_KEY", + "short_id": "01234567", + "accept_proxy_protocol": true, + "base_config": { + "push_interval": 60, + "pull_interval": 60 + } +} +``` + - **Proxy Protocol Note**: Set `accept_proxy_protocol` to `true` only when this node is behind an L4 proxy or load balancer that really sends PROXY protocol headers. Direct client connections will fail if this is enabled without an upstream PROXY sender. - **GET** `/api/v1/server/UniProxy/user` - **Purpose**: Get user data for server @@ -952,4 +972,4 @@ Same endpoints as V1 but under `/api/v2/passport/` prefix. 6. **Filtering**: Admin endpoints often support filtering and sorting parameters. -This documentation provides a comprehensive overview of all available API endpoints in the Xboard system. Each endpoint serves specific functionality within the VPN service management platform. \ No newline at end of file +This documentation provides a comprehensive overview of all available API endpoints in the Xboard system. Each endpoint serves specific functionality within the VPN service management platform. diff --git a/common/listener/listener_tcp.go b/common/listener/listener_tcp.go index 54d84a6b..8fa2948e 100644 --- a/common/listener/listener_tcp.go +++ b/common/listener/listener_tcp.go @@ -21,10 +21,6 @@ import ( ) func (l *Listener) ListenTCP() (net.Listener, error) { - //nolint:staticcheck - if l.listenOptions.ProxyProtocol || l.listenOptions.ProxyProtocolAcceptNoHeader { - return nil, E.New("Proxy Protocol is deprecated and removed in sing-box 1.6.0") - } var err error bindAddr := M.SocksaddrFrom(l.listenOptions.Listen.Build(netip.AddrFrom4([4]byte{127, 0, 0, 1})), l.listenOptions.ListenPort) var listenConfig net.ListenConfig @@ -100,6 +96,18 @@ func (l *Listener) loopTCPIn() { l.logger.Error("tcp listener closed: ", err) continue } + remoteAddr := conn.RemoteAddr() + //nolint:staticcheck + if l.listenOptions.ProxyProtocol || l.listenOptions.ProxyProtocolAcceptNoHeader { + //nolint:staticcheck + wrappedConn, wrapErr := wrapProxyProtocolConn(conn, l.listenOptions.ProxyProtocolAcceptNoHeader) + if wrapErr != nil { + conn.Close() + l.logger.Error("process connection from ", remoteAddr, ": PROXY protocol: ", wrapErr) + continue + } + conn = wrappedConn + } //nolint:staticcheck metadata.InboundDetour = l.listenOptions.Detour metadata.Source = M.SocksaddrFromNet(conn.RemoteAddr()).Unwrap() diff --git a/common/listener/proxy_protocol.go b/common/listener/proxy_protocol.go new file mode 100644 index 00000000..01ec1da4 --- /dev/null +++ b/common/listener/proxy_protocol.go @@ -0,0 +1,186 @@ +package listener + +import ( + "bufio" + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "net" +) + +var errProxyProtocolHeaderNotPresent = errors.New("proxy protocol header not present") + +var proxyProtocolV2Signature = []byte{ + 0x0d, 0x0a, 0x0d, 0x0a, + 0x00, 0x0d, 0x0a, 0x51, + 0x55, 0x49, 0x54, 0x0a, +} + +type proxyProtocolConn struct { + net.Conn + reader *bufio.Reader + remoteAddr net.Addr +} + +func (c *proxyProtocolConn) Read(p []byte) (int, error) { + return c.reader.Read(p) +} + +func (c *proxyProtocolConn) RemoteAddr() net.Addr { + return c.remoteAddr +} + +func wrapProxyProtocolConn(conn net.Conn, allowNoHeader bool) (net.Conn, error) { + reader := bufio.NewReader(conn) + remoteAddr, err := readProxyProtocolRemoteAddr(reader) + if err != nil { + if allowNoHeader && errors.Is(err, errProxyProtocolHeaderNotPresent) { + return &proxyProtocolConn{ + Conn: conn, + reader: reader, + remoteAddr: conn.RemoteAddr(), + }, nil + } + return nil, err + } + if remoteAddr == nil { + remoteAddr = conn.RemoteAddr() + } + return &proxyProtocolConn{ + Conn: conn, + reader: reader, + remoteAddr: remoteAddr, + }, nil +} + +func readProxyProtocolRemoteAddr(reader *bufio.Reader) (net.Addr, error) { + firstByte, err := reader.Peek(1) + if err != nil { + return nil, err + } + switch firstByte[0] { + case 'P': + return readProxyProtocolV1RemoteAddr(reader) + case '\r': + signature, err := reader.Peek(len(proxyProtocolV2Signature)) + if err != nil { + return nil, err + } + if !bytes.Equal(signature, proxyProtocolV2Signature) { + return nil, errProxyProtocolHeaderNotPresent + } + return readProxyProtocolV2RemoteAddr(reader) + default: + return nil, errProxyProtocolHeaderNotPresent + } +} + +func readProxyProtocolV1RemoteAddr(reader *bufio.Reader) (net.Addr, error) { + prefix, err := reader.Peek(6) + if err != nil { + return nil, err + } + if !bytes.Equal(prefix, []byte("PROXY ")) { + return nil, errProxyProtocolHeaderNotPresent + } + + line, err := reader.ReadString('\n') + if err != nil { + return nil, err + } + if len(line) < 2 || line[len(line)-2:] != "\r\n" { + return nil, fmt.Errorf("invalid PROXY protocol v1 line ending") + } + + fields := bytes.Fields([]byte(line[:len(line)-2])) + if len(fields) < 2 { + return nil, fmt.Errorf("invalid PROXY protocol v1 header") + } + if string(fields[1]) == "UNKNOWN" { + return nil, nil + } + if len(fields) != 6 { + return nil, fmt.Errorf("invalid PROXY protocol v1 field count") + } + + sourceIP := net.ParseIP(string(fields[2])) + if sourceIP == nil { + return nil, fmt.Errorf("invalid PROXY protocol source ip") + } + sourcePort, err := parseProxyProtocolPort(fields[4]) + if err != nil { + return nil, err + } + return &net.TCPAddr{ + IP: sourceIP, + Port: sourcePort, + }, nil +} + +func readProxyProtocolV2RemoteAddr(reader *bufio.Reader) (net.Addr, error) { + header := make([]byte, 16) + if _, err := io.ReadFull(reader, header); err != nil { + return nil, err + } + if !bytes.Equal(header[:12], proxyProtocolV2Signature) { + return nil, errProxyProtocolHeaderNotPresent + } + + version := header[12] >> 4 + command := header[12] & 0x0f + if version != 0x2 { + return nil, fmt.Errorf("invalid PROXY protocol v2 version") + } + + addressDataLen := int(binary.BigEndian.Uint16(header[14:16])) + addressData := make([]byte, addressDataLen) + if _, err := io.ReadFull(reader, addressData); err != nil { + return nil, err + } + + if command == 0x0 { + return nil, nil + } + if command != 0x1 { + return nil, fmt.Errorf("unsupported PROXY protocol v2 command") + } + + switch header[13] { + case 0x11, 0x12: + if len(addressData) < 12 { + return nil, fmt.Errorf("short PROXY protocol v2 ipv4 header") + } + return &net.TCPAddr{ + IP: net.IP(addressData[:4]), + Port: int(binary.BigEndian.Uint16(addressData[8:10])), + }, nil + case 0x21, 0x22: + if len(addressData) < 36 { + return nil, fmt.Errorf("short PROXY protocol v2 ipv6 header") + } + return &net.TCPAddr{ + IP: net.IP(addressData[:16]), + Port: int(binary.BigEndian.Uint16(addressData[32:34])), + }, nil + case 0x31, 0x32: + return nil, nil + default: + return nil, fmt.Errorf("unsupported PROXY protocol v2 family") + } +} + +func parseProxyProtocolPort(raw []byte) (int, error) { + port := 0 + for _, ch := range raw { + if ch < '0' || ch > '9' { + return 0, fmt.Errorf("invalid PROXY protocol port") + } + port = port*10 + int(ch-'0') + if port > 65535 { + return 0, fmt.Errorf("invalid PROXY protocol port") + } + } + return port, nil +} diff --git a/common/listener/proxy_protocol_test.go b/common/listener/proxy_protocol_test.go new file mode 100644 index 00000000..61134d29 --- /dev/null +++ b/common/listener/proxy_protocol_test.go @@ -0,0 +1,114 @@ +package listener + +import ( + "bufio" + "encoding/binary" + "net" + "strings" + "testing" + "time" +) + +func TestReadProxyProtocolV1RemoteAddr(t *testing.T) { + reader := bufio.NewReaderSize(newStaticConn("PROXY TCP4 203.0.113.10 192.0.2.1 45678 443\r\npayload"), 128) + remoteAddr, err := readProxyProtocolRemoteAddr(reader) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + tcpAddr, ok := remoteAddr.(*net.TCPAddr) + if !ok { + t.Fatalf("unexpected addr type: %T", remoteAddr) + } + if got := tcpAddr.IP.String(); got != "203.0.113.10" { + t.Fatalf("unexpected ip: %s", got) + } + if tcpAddr.Port != 45678 { + t.Fatalf("unexpected port: %d", tcpAddr.Port) + } +} + +func TestReadProxyProtocolV2RemoteAddr(t *testing.T) { + header := make([]byte, 28) + copy(header[:12], proxyProtocolV2Signature) + header[12] = 0x21 + header[13] = 0x11 + binary.BigEndian.PutUint16(header[14:16], 12) + copy(header[16:20], net.ParseIP("198.51.100.12").To4()) + copy(header[20:24], net.ParseIP("192.0.2.8").To4()) + binary.BigEndian.PutUint16(header[24:26], 50000) + binary.BigEndian.PutUint16(header[26:28], 443) + + reader := bufio.NewReaderSize(newStaticConn(string(header)+"payload"), 128) + remoteAddr, err := readProxyProtocolRemoteAddr(reader) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + tcpAddr, ok := remoteAddr.(*net.TCPAddr) + if !ok { + t.Fatalf("unexpected addr type: %T", remoteAddr) + } + if got := tcpAddr.IP.String(); got != "198.51.100.12" { + t.Fatalf("unexpected ip: %s", got) + } + if tcpAddr.Port != 50000 { + t.Fatalf("unexpected port: %d", tcpAddr.Port) + } +} + +func TestWrapProxyProtocolConnAllowNoHeader(t *testing.T) { + rawConn := newStaticConn("hello") + conn, err := wrapProxyProtocolConn(rawConn, true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if conn.RemoteAddr().String() != rawConn.RemoteAddr().String() { + t.Fatalf("remote addr changed unexpectedly: %s", conn.RemoteAddr()) + } +} + +type staticConn struct { + net.Conn + reader *bufio.Reader + local net.Addr + remote net.Addr +} + +func newStaticConn(payload string) *staticConn { + return &staticConn{ + reader: bufio.NewReaderSize(strings.NewReader(payload), len(payload)+16), + local: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 443}, + remote: &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 12345}, + } +} + +func (c *staticConn) Read(p []byte) (int, error) { + return c.reader.Read(p) +} + +func (c *staticConn) Write(p []byte) (int, error) { + return len(p), nil +} + +func (c *staticConn) Close() error { + return nil +} + +func (c *staticConn) LocalAddr() net.Addr { + return c.local +} + +func (c *staticConn) RemoteAddr() net.Addr { + return c.remote +} + +func (c *staticConn) SetDeadline(t time.Time) error { + return nil +} + +func (c *staticConn) SetReadDeadline(t time.Time) error { + return nil +} + +func (c *staticConn) SetWriteDeadline(t time.Time) error { + return nil +} diff --git a/install.sh b/install.sh index e3dbcc38..f8dacda2 100644 --- a/install.sh +++ b/install.sh @@ -145,6 +145,9 @@ PANEL_URL=${INPUT_URL:-$PANEL_URL} read -p "Enter Panel Token (Node Key) [${PANEL_TOKEN}]: " INPUT_TOKEN PANEL_TOKEN=${INPUT_TOKEN:-$PANEL_TOKEN} +read -p "This node is behind an L4 proxy/LB that sends PROXY protocol? [${ENABLE_PROXY_PROTOCOL_HINT:-n}]: " INPUT_PROXY_PROTOCOL +ENABLE_PROXY_PROTOCOL_HINT=${INPUT_PROXY_PROTOCOL:-${ENABLE_PROXY_PROTOCOL_HINT:-n}} + read -p "Enter Node Count [${NODE_COUNT:-1}]: " INPUT_COUNT NODE_COUNT=${INPUT_COUNT:-${NODE_COUNT:-1}} @@ -284,6 +287,17 @@ EOF echo -e "${GREEN}Configuration written to $CONFIG_FILE${NC}" +if [[ "$ENABLE_PROXY_PROTOCOL_HINT" =~ ^([yY][eE][sS]|[yY]|1|true|TRUE)$ ]]; then + echo -e "${YELLOW}Proxy Protocol deployment hint enabled.${NC}" + echo -e "${YELLOW}To make real client IP reporting work, your panel node config response must include:${NC}" + echo -e "${YELLOW} \"accept_proxy_protocol\": true${NC}" + echo -e "${YELLOW}Only enable this when the upstream L4 proxy or load balancer actually sends PROXY protocol headers.${NC}" + echo -e "${YELLOW}If clients connect directly without a PROXY header, connections will fail after enabling it on the panel.${NC}" +else + echo -e "${YELLOW}Proxy Protocol is not expected for this deployment.${NC}" + echo -e "${YELLOW}Keep panel field \"accept_proxy_protocol\" disabled or absent unless you are using an L4 proxy/LB that sends it.${NC}" +fi + # Create Systemd Service echo -e "${YELLOW}Creating systemd service...${NC}" cat > "$SERVICE_FILE" <= len(h.users) { + h.ssmMutex.RUnlock() + return os.ErrInvalid + } + userEntry := h.users[userIndex] + tracker := h.tracker + h.ssmMutex.RUnlock() + user := userEntry.Name if user == "" { user = F.ToString(userIndex) } else { @@ -175,8 +189,8 @@ func (h *MultiInbound) newConnection(ctx context.Context, conn net.Conn, metadat //nolint:staticcheck metadata.InboundDetour = h.listener.ListenOptions().Detour //nolint:staticcheck - if h.tracker != nil { - conn = h.tracker.TrackConnection(conn, metadata) + if tracker != nil { + conn = tracker.TrackConnection(conn, metadata) } return h.router.RouteConnection(ctx, conn, metadata) } @@ -186,7 +200,15 @@ func (h *MultiInbound) newPacketConnection(ctx context.Context, conn N.PacketCon if !loaded { return os.ErrInvalid } - user := h.users[userIndex].Name + h.ssmMutex.RLock() + if userIndex < 0 || userIndex >= len(h.users) { + h.ssmMutex.RUnlock() + return os.ErrInvalid + } + userEntry := h.users[userIndex] + tracker := h.tracker + h.ssmMutex.RUnlock() + user := userEntry.Name if user == "" { user = F.ToString(userIndex) } else { @@ -200,8 +222,8 @@ func (h *MultiInbound) newPacketConnection(ctx context.Context, conn N.PacketCon //nolint:staticcheck metadata.InboundDetour = h.listener.ListenOptions().Detour //nolint:staticcheck - if h.tracker != nil { - conn = h.tracker.TrackPacketConnection(conn, metadata) + if tracker != nil { + conn = tracker.TrackPacketConnection(conn, metadata) } return h.router.RoutePacketConnection(ctx, conn, metadata) } diff --git a/protocol/vless/inbound.go b/protocol/vless/inbound.go index 19d40724..6635abe2 100644 --- a/protocol/vless/inbound.go +++ b/protocol/vless/inbound.go @@ -209,16 +209,22 @@ func (h *Inbound) newConnectionEx(ctx context.Context, conn net.Conn, metadata a N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) return } - user := h.users[userIndex].Name + h.ssmMutex.RLock() + if userIndex < 0 || userIndex >= len(h.users) { + h.ssmMutex.RUnlock() + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) + return + } + userEntry := h.users[userIndex] + tracker := h.tracker + h.ssmMutex.RUnlock() + user := userEntry.Name if user == "" { user = F.ToString(userIndex) } else { metadata.User = user } h.logger.InfoContext(ctx, "[", user, "] inbound connection to ", metadata.Destination) - h.ssmMutex.RLock() - tracker := h.tracker - h.ssmMutex.RUnlock() if tracker != nil { conn = tracker.TrackConnection(conn, metadata) } @@ -233,7 +239,16 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn, N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) return } - user := h.users[userIndex].Name + h.ssmMutex.RLock() + if userIndex < 0 || userIndex >= len(h.users) { + h.ssmMutex.RUnlock() + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) + return + } + userEntry := h.users[userIndex] + tracker := h.tracker + h.ssmMutex.RUnlock() + user := userEntry.Name if user == "" { user = F.ToString(userIndex) } else { @@ -246,9 +261,6 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn, } else { h.logger.InfoContext(ctx, "[", user, "] inbound packet connection to ", metadata.Destination) } - h.ssmMutex.RLock() - tracker := h.tracker - h.ssmMutex.RUnlock() if tracker != nil { conn = tracker.TrackPacketConnection(conn, metadata) } diff --git a/protocol/vmess/inbound.go b/protocol/vmess/inbound.go index e3f4a388..12125d7b 100644 --- a/protocol/vmess/inbound.go +++ b/protocol/vmess/inbound.go @@ -215,7 +215,15 @@ func (h *Inbound) newConnectionEx(ctx context.Context, conn net.Conn, metadata a N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) return } - user := h.users[userIndex].Name + h.ssmMutex.RLock() + if userIndex < 0 || userIndex >= len(h.users) { + h.ssmMutex.RUnlock() + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) + return + } + userEntry := h.users[userIndex] + h.ssmMutex.RUnlock() + user := userEntry.Name if user == "" { user = F.ToString(userIndex) } else { @@ -233,7 +241,16 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn, N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) return } - user := h.users[userIndex].Name + h.ssmMutex.RLock() + if userIndex < 0 || userIndex >= len(h.users) { + h.ssmMutex.RUnlock() + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) + return + } + userEntry := h.users[userIndex] + tracker := h.tracker + h.ssmMutex.RUnlock() + user := userEntry.Name if user == "" { user = F.ToString(userIndex) } else { @@ -246,9 +263,6 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn, } else { h.logger.InfoContext(ctx, "[", user, "] inbound packet connection to ", metadata.Destination) } - h.ssmMutex.RLock() - tracker := h.tracker - h.ssmMutex.RUnlock() if tracker != nil { conn = tracker.TrackPacketConnection(conn, metadata) } diff --git a/service/xboard/service.go b/service/xboard/service.go index 5c35f265..5d695074 100644 --- a/service/xboard/service.go +++ b/service/xboard/service.go @@ -106,6 +106,8 @@ type XNodeConfig struct { ServerName string `json:"server_name,omitempty"` ServerPortText string `json:"server_port_text,omitempty"` Network string `json:"network"` + AcceptProxyProtocol bool `json:"accept_proxy_protocol,omitempty"` + AcceptProxyProtocol_ bool `json:"acceptProxyProtocol,omitempty"` Multiplex *XMultiplexConfig `json:"multiplex,omitempty"` NetworkSettings json.RawMessage `json:"network_settings"` NetworkSettings_ json.RawMessage `json:"networkSettings"` @@ -162,6 +164,8 @@ type XInnerConfig struct { Dest string `json:"dest,omitempty"` ServerName string `json:"server_name,omitempty"` Network string `json:"network"` + AcceptProxyProtocol bool `json:"accept_proxy_protocol,omitempty"` + AcceptProxyProtocol_ bool `json:"acceptProxyProtocol,omitempty"` Multiplex *XMultiplexConfig `json:"multiplex,omitempty"` NetworkSettings json.RawMessage `json:"network_settings"` NetworkSettings_ json.RawMessage `json:"networkSettings"` @@ -212,6 +216,67 @@ type GrpcNetworkConfig struct { ServiceName string `json:"serviceName"` } +func unmarshalNetworkSettings(settings json.RawMessage) map[string]any { + if len(settings) == 0 { + return nil + } + var raw map[string]any + if err := json.Unmarshal(settings, &raw); err != nil { + return nil + } + return raw +} + +func readNetworkString(raw map[string]any, keys ...string) string { + for _, key := range keys { + value, exists := raw[key] + if !exists { + continue + } + if stringValue, ok := value.(string); ok && stringValue != "" { + return stringValue + } + } + return "" +} + +func readNetworkBool(raw map[string]any, keys ...string) (bool, bool) { + for _, key := range keys { + value, exists := raw[key] + if !exists { + continue + } + if boolValue, ok := value.(bool); ok { + return boolValue, true + } + } + return false, false +} + +func readNetworkDuration(raw map[string]any, keys ...string) (badoption.Duration, bool) { + for _, key := range keys { + value, exists := raw[key] + if !exists { + continue + } + switch typedValue := value.(type) { + case float64: + return badoption.Duration(time.Duration(typedValue) * time.Second), true + case string: + if typedValue == "" { + continue + } + if durationValue, err := time.ParseDuration(typedValue); err == nil { + return badoption.Duration(durationValue), true + } + if secondsValue, err := strconv.ParseInt(typedValue, 10, 64); err == nil { + return badoption.Duration(time.Duration(secondsValue) * time.Second), true + } + } + } + return 0, false +} + type HttpupgradeNetworkConfig struct { Path string `json:"path"` Host string `json:"host"` @@ -466,6 +531,7 @@ func getInboundTransport(network string, settings json.RawMessage) (*option.V2Ra t := &option.V2RayTransportOptions{ Type: network, } + rawSettings := unmarshalNetworkSettings(settings) switch network { case "tcp": if len(settings) != 0 { @@ -535,6 +601,18 @@ func getInboundTransport(network string, settings json.RawMessage) (*option.V2Ra t.GRPCOptions = option.V2RayGRPCOptions{ ServiceName: networkConfig.ServiceName, } + if serviceName := readNetworkString(rawSettings, "service_name"); serviceName != "" && t.GRPCOptions.ServiceName == "" { + t.GRPCOptions.ServiceName = serviceName + } + if idleTimeout, ok := readNetworkDuration(rawSettings, "idle_timeout", "idleTimeout"); ok { + t.GRPCOptions.IdleTimeout = idleTimeout + } + if pingTimeout, ok := readNetworkDuration(rawSettings, "ping_timeout", "pingTimeout"); ok { + t.GRPCOptions.PingTimeout = pingTimeout + } + if permitWithoutStream, ok := readNetworkBool(rawSettings, "permit_without_stream", "permitWithoutStream"); ok { + t.GRPCOptions.PermitWithoutStream = permitWithoutStream + } } case "httpupgrade": if len(settings) != 0 { @@ -548,10 +626,106 @@ func getInboundTransport(network string, settings json.RawMessage) (*option.V2Ra Host: networkConfig.Host, } } + case "h2", "http": + t.Type = "http" + if rawSettings != nil { + t.HTTPOptions = option.V2RayHTTPOptions{ + Path: readNetworkString(rawSettings, "path"), + Method: readNetworkString(rawSettings, "method"), + Headers: nil, + } + if idleTimeout, ok := readNetworkDuration(rawSettings, "idle_timeout", "idleTimeout"); ok { + t.HTTPOptions.IdleTimeout = idleTimeout + } + if pingTimeout, ok := readNetworkDuration(rawSettings, "ping_timeout", "pingTimeout"); ok { + t.HTTPOptions.PingTimeout = pingTimeout + } + if hostValue, exists := rawSettings["host"]; exists { + switch typedHost := hostValue.(type) { + case string: + if typedHost != "" { + t.HTTPOptions.Host = badoption.Listable[string]{typedHost} + } + case []any: + hostList := make(badoption.Listable[string], 0, len(typedHost)) + for _, item := range typedHost { + if host, ok := item.(string); ok && host != "" { + hostList = append(hostList, host) + } + } + if len(hostList) > 0 { + t.HTTPOptions.Host = hostList + } + } + } + if headersValue, exists := rawSettings["headers"]; exists { + if headersMap, ok := headersValue.(map[string]any); ok { + headerOptions := make(badoption.HTTPHeader) + for headerKey, headerValue := range headersMap { + switch typedHeader := headerValue.(type) { + case string: + if typedHeader != "" { + headerOptions[headerKey] = badoption.Listable[string]{typedHeader} + } + case []any: + values := make(badoption.Listable[string], 0, len(typedHeader)) + for _, item := range typedHeader { + if headerString, ok := item.(string); ok && headerString != "" { + values = append(values, headerString) + } + } + if len(values) > 0 { + headerOptions[headerKey] = values + } + } + } + if len(headerOptions) > 0 { + t.HTTPOptions.Headers = headerOptions + } + } + } + } } return t, nil } +func networkAcceptProxyProtocol(settings json.RawMessage) bool { + if len(settings) == 0 { + return false + } + var raw map[string]any + if err := json.Unmarshal(settings, &raw); err != nil { + return false + } + for _, key := range []string{"acceptProxyProtocol", "accept_proxy_protocol"} { + value, exists := raw[key] + if !exists { + continue + } + enabled, ok := value.(bool) + if ok && enabled { + return true + } + } + return false +} + +func acceptProxyProtocolEnabled(inner XInnerConfig, config *XNodeConfig) bool { + if inner.AcceptProxyProtocol || inner.AcceptProxyProtocol_ { + return true + } + if config != nil && (config.AcceptProxyProtocol || config.AcceptProxyProtocol_) { + return true + } + if networkAcceptProxyProtocol(inner.NetworkSettings) || networkAcceptProxyProtocol(inner.NetworkSettings_) { + return true + } + if config != nil && (networkAcceptProxyProtocol(config.NetworkSettings) || networkAcceptProxyProtocol(config.NetworkSettings_)) { + return true + } + return false +} + func (s *Service) setupNode() error { s.logger.Info("Xboard fetching node config...") config, err := s.fetchConfig() @@ -675,6 +849,10 @@ func (s *Service) setupNode() error { Listen: &listenAddr, ListenPort: uint16(inner.Port), } + if acceptProxyProtocolEnabled(inner, config) { + listen.ProxyProtocol = true + s.logger.Info("Xboard PROXY protocol enabled for inbound on ", inner.ListenIP, ":", inner.Port) + } // ── TLS / Reality handling (matching V2bX panel.Security constants) ── // V2bX: 0=None, 1=TLS, 2=Reality @@ -1264,6 +1442,11 @@ func (s *Service) syncUsers() { } } + if sameUserSet(s.localUsers, newUsers) { + s.logger.Trace("Xboard sync skipped: users unchanged") + return + } + for tag, server := range s.servers { // Update users in each manager users := make([]string, 0, len(newUsers)) @@ -1286,6 +1469,22 @@ func (s *Service) syncUsers() { s.logger.Info("Xboard sync completed, total users: ", len(users)) } +func sameUserSet(current map[string]userData, next map[string]userData) bool { + if len(current) != len(next) { + return false + } + for userName, currentUser := range current { + nextUser, exists := next[userName] + if !exists { + return false + } + if currentUser != nextUser { + return false + } + } + return true +} + func (s *Service) reportTraffic() { s.logger.Trace("Xboard reporting traffic...")