Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions api/panel/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package panel

import (
"fmt"
"path"
"time"
)

Expand Down Expand Up @@ -38,7 +37,7 @@ func (c *ClientV1) ReportNodeStatus(nodeStatus *NodeStatus) (err error) {
UpdatedAt: time.Now().UnixMilli(),
}
if _, err = c.Client.R().SetBody(status).ForceContentType("application/json").Post(p); err != nil {
return fmt.Errorf("访问 %s 失败: %v", path.Join(c.APIHost+p), err.Error())
return fmt.Errorf("访问 %s 失败: %s", endpointURL(c.APIHost, p), sanitizeError(err, c.SecretKey))
}
return nil
}
38 changes: 22 additions & 16 deletions api/panel/panel.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
package panel

import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"

"github.com/sirupsen/logrus"

"github.com/go-resty/resty/v2"
"github.com/perfect-panel/ppanel-node/conf"
)

var secretKeyPattern = regexp.MustCompile(`secret_key=[^&\s"]+`)

type ClientV1 struct {
Client *resty.Client
APIHost string
Expand All @@ -30,7 +30,25 @@ type ClientV2 struct {
SecretKey string
ServerId int
ServerConfigEtag string
responseBodyHash string
serverConfigHash string
}

func endpointURL(base, p string) string {
return strings.TrimRight(base, "/") + p
}

func redactSecret(s, secret string) string {
if secret != "" {
s = strings.ReplaceAll(s, secret, "<redacted>")
}
return secretKeyPattern.ReplaceAllString(s, "secret_key=<redacted>")
}

func sanitizeError(err error, secret string) string {
if err == nil {
return ""
}
return redactSecret(err.Error(), secret)
}

func NewClientV1(c *conf.NodeApiConfig) (*ClientV1, error) {
Expand All @@ -41,12 +59,6 @@ func NewClientV1(c *conf.NodeApiConfig) (*ClientV1, error) {
} else {
client.SetTimeout(30 * time.Second)
}
client.OnError(func(req *resty.Request, err error) {
var v *resty.ResponseError
if errors.As(err, &v) {
logrus.Error(v.Err)
}
})
client.SetBaseURL(c.APIHost)
// Check node type
c.NodeType = strings.ToLower(c.NodeType)
Expand Down Expand Up @@ -88,12 +100,6 @@ func NewClientV2(c *conf.ServerApiConfig) *ClientV2 {
} else {
client.SetTimeout(30 * time.Second)
}
client.OnError(func(req *resty.Request, err error) {
var v *resty.ResponseError
if errors.As(err, &v) {
logrus.Error(v.Err)
}
})
client.SetBaseURL(c.ApiHost)
client.SetQueryParams(map[string]string{
"secret_key": c.SecretKey,
Expand Down
148 changes: 127 additions & 21 deletions api/panel/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"sort"
)

type ServerConfigResponse struct {
Expand All @@ -26,6 +27,18 @@ type Data struct {
Total int `json:"total"`
}

type semanticServerConfigData struct {
TrafficReportThreshold int `json:"traffic_report_threshold"`
PushInterval int `json:"push_interval"`
PullInterval int `json:"pull_interval"`
IPStrategy string `json:"ip_strategy"`
DNS []DNSItem `json:"dns"`
Block []string `json:"block"`
Outbound []Outbound `json:"outbound"`
Protocols []Protocol `json:"protocols"`
Total int `json:"total"`
}

type DNSItem struct {
Proto string `json:"proto"`
Address string `json:"address"`
Expand Down Expand Up @@ -102,42 +115,135 @@ func GetServerConfig(ctx context.Context, c *ClientV2) (*ServerConfigResponse, e

// 优先检查错误,避免处理无效响应
if err != nil {
return nil, fmt.Errorf("访问 %s 失败: %v", client.BaseURL+path, err.Error())
return nil, fmt.Errorf("访问 %s 失败: %s", endpointURL(client.BaseURL, path), sanitizeError(err, c.SecretKey))
}

if r == nil {
return nil, fmt.Errorf("服务端返回为空")
}

// 检查 HTTP 状态码
if r.StatusCode() == 304 {
return nil, nil
}
if r.StatusCode() >= 400 {
body := r.Body()
return nil, fmt.Errorf("访问 %s 失败: %s", client.BaseURL+path, string(body))
}

// 只有在成功响应时才检查 hash
hash := sha256.Sum256(r.Body())
newBodyHash := hex.EncodeToString(hash[:])
if c.responseBodyHash == newBodyHash {
return nil, nil
return nil, fmt.Errorf("访问 %s 失败: %s", endpointURL(client.BaseURL, path), redactSecret(string(body), c.SecretKey))
}
c.responseBodyHash = newBodyHash
c.ServerConfigEtag = r.Header().Get("ETag")
if r != nil {
defer func() {
if r.RawBody() != nil {
r.RawBody().Close()
}
}()
} else {
return nil, fmt.Errorf("服务端返回为空")
}
resp := &ServerConfigResponse{}
err = json.Unmarshal(r.Body(), resp)
if err != nil {
return nil, fmt.Errorf("解码响应体失败: %s", err)
}
if resp.Data.Protocols == nil {
if resp.Data == nil || resp.Data.Protocols == nil {
return nil, fmt.Errorf("协议配置为空")
}
newConfigHash, err := semanticServerConfigHash(resp)
if err != nil {
return nil, err
}
if c.serverConfigHash == newConfigHash {
return nil, nil
}
c.serverConfigHash = newConfigHash
return resp, nil
}

func semanticServerConfigHash(resp *ServerConfigResponse) (string, error) {
normalized := normalizeServerConfigData(resp.Data)
body, err := json.Marshal(normalized)
if err != nil {
return "", fmt.Errorf("编码服务端配置指纹失败: %s", err)
}
hash := sha256.Sum256(body)
return hex.EncodeToString(hash[:]), nil
}

func normalizeServerConfigData(data *Data) semanticServerConfigData {
if data == nil {
return semanticServerConfigData{
DNS: []DNSItem{},
Block: []string{},
Outbound: []Outbound{},
Protocols: []Protocol{},
}
}

dnsItems := cloneDNSItems(data.DNS)
blockItems := cloneStringSlice(data.Block)
outboundItems := cloneOutboundItems(data.Outbound)
protocolItems := cloneProtocolItems(data.Protocols)

sort.Strings(blockItems)
sort.SliceStable(protocolItems, func(i, j int) bool {
if protocolItems[i].Type != protocolItems[j].Type {
return protocolItems[i].Type < protocolItems[j].Type
}
if protocolItems[i].Port != protocolItems[j].Port {
return protocolItems[i].Port < protocolItems[j].Port
}
if protocolItems[i].Transport != protocolItems[j].Transport {
return protocolItems[i].Transport < protocolItems[j].Transport
}
return protocolItems[i].Security < protocolItems[j].Security
})

return semanticServerConfigData{
TrafficReportThreshold: data.TrafficReportThreshold,
PushInterval: data.PushInterval,
PullInterval: data.PullInterval,
IPStrategy: data.IPStrategy,
DNS: dnsItems,
Block: blockItems,
Outbound: outboundItems,
Protocols: protocolItems,
Total: data.Total,
}
}

func cloneStringSlice(items *[]string) []string {
if items == nil {
return []string{}
}
clone := make([]string, len(*items))
copy(clone, *items)
return clone
}

func cloneDNSItems(items *[]DNSItem) []DNSItem {
if items == nil {
return []DNSItem{}
}
clone := make([]DNSItem, len(*items))
for i, item := range *items {
clone[i] = item
clone[i].Domains = make([]string, len(item.Domains))
copy(clone[i].Domains, item.Domains)
sort.Strings(clone[i].Domains)
}
return clone
}

func cloneOutboundItems(items *[]Outbound) []Outbound {
if items == nil {
return []Outbound{}
}
clone := make([]Outbound, len(*items))
for i, item := range *items {
clone[i] = item
clone[i].Rules = make([]string, len(item.Rules))
copy(clone[i].Rules, item.Rules)
sort.Strings(clone[i].Rules)
}
return clone
}

func cloneProtocolItems(items *[]Protocol) []Protocol {
if items == nil {
return []Protocol{}
}
clone := make([]Protocol, len(*items))
copy(clone, *items)
return clone
}
98 changes: 98 additions & 0 deletions api/panel/server_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package panel

import "testing"

func TestSemanticServerConfigHashNormalizesNonSemanticOrder(t *testing.T) {
first := &ServerConfigResponse{Data: &Data{
PushInterval: 60,
PullInterval: 90,
DNS: &[]DNSItem{
{Proto: "udp", Address: "1.1.1.1", Domains: []string{"suffix:example.com", "keyword:google"}},
},
Block: &[]string{"suffix:b.example", "suffix:a.example"},
Outbound: &[]Outbound{
{Name: "proxy", Protocol: "socks", Address: "127.0.0.1", Port: 1080, Rules: []string{"suffix:b.example", "suffix:a.example"}},
},
Protocols: &[]Protocol{
{Type: "hysteria", Port: 443, Security: "tls"},
{Type: "vless", Port: 8443, Security: "reality", Transport: "tcp"},
},
Total: 2,
}}
second := &ServerConfigResponse{Data: &Data{
PushInterval: 60,
PullInterval: 90,
DNS: &[]DNSItem{
{Proto: "udp", Address: "1.1.1.1", Domains: []string{"keyword:google", "suffix:example.com"}},
},
Block: &[]string{"suffix:a.example", "suffix:b.example"},
Outbound: &[]Outbound{
{Name: "proxy", Protocol: "socks", Address: "127.0.0.1", Port: 1080, Rules: []string{"suffix:a.example", "suffix:b.example"}},
},
Protocols: &[]Protocol{
{Type: "vless", Port: 8443, Security: "reality", Transport: "tcp"},
{Type: "hysteria", Port: 443, Security: "tls"},
},
Total: 2,
}}

firstHash, err := semanticServerConfigHash(first)
if err != nil {
t.Fatal(err)
}
secondHash, err := semanticServerConfigHash(second)
if err != nil {
t.Fatal(err)
}
if firstHash != secondHash {
t.Fatalf("expected semantically equal configs to match: %s != %s", firstHash, secondHash)
}
}

func TestSemanticServerConfigHashKeepsOutboundOrder(t *testing.T) {
first := &ServerConfigResponse{Data: &Data{
Outbound: &[]Outbound{
{Name: "first", Protocol: "socks", Address: "127.0.0.1", Port: 1080},
{Name: "second", Protocol: "socks", Address: "127.0.0.2", Port: 1081},
},
Protocols: &[]Protocol{},
}}
second := &ServerConfigResponse{Data: &Data{
Outbound: &[]Outbound{
{Name: "second", Protocol: "socks", Address: "127.0.0.2", Port: 1081},
{Name: "first", Protocol: "socks", Address: "127.0.0.1", Port: 1080},
},
Protocols: &[]Protocol{},
}}

firstHash, err := semanticServerConfigHash(first)
if err != nil {
t.Fatal(err)
}
secondHash, err := semanticServerConfigHash(second)
if err != nil {
t.Fatal(err)
}
if firstHash == secondHash {
t.Fatal("expected outbound order changes to remain significant")
}
}

func TestSemanticServerConfigHashTreatsNilSlicesAsEmpty(t *testing.T) {
firstHash, err := semanticServerConfigHash(&ServerConfigResponse{Data: &Data{Protocols: &[]Protocol{}}})
if err != nil {
t.Fatal(err)
}
secondHash, err := semanticServerConfigHash(&ServerConfigResponse{Data: &Data{
DNS: &[]DNSItem{},
Block: &[]string{},
Outbound: &[]Outbound{},
Protocols: &[]Protocol{},
}})
if err != nil {
t.Fatal(err)
}
if firstHash != secondHash {
t.Fatalf("expected nil and empty slices to match: %s != %s", firstHash, secondHash)
}
}
Loading
Loading