feat: add Mieru protocol and major system performance optimizations
This commit is contained in:
@@ -2,6 +2,10 @@ package kernel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
"golang.org/x/time/rate"
|
||||
@@ -60,6 +64,21 @@ type Kernel interface {
|
||||
SetSpeedLimitFunc(fn func(uuid string) *rate.Limiter)
|
||||
}
|
||||
|
||||
// ComputeHash returns a hash of config + user identities that would
|
||||
// require a kernel restart/reconstruction if changed.
|
||||
func ComputeHash(nc *panel.NodeConfig, users []panel.User) string {
|
||||
h := sha256.New()
|
||||
configData, _ := json.Marshal(nc)
|
||||
h.Write(configData)
|
||||
sorted := make([]panel.User, len(users))
|
||||
copy(sorted, users)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID })
|
||||
for _, u := range sorted {
|
||||
fmt.Fprintf(h, "%d:%s,", u.ID, u.UUID)
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
// Connection represents an active proxy connection reported by the kernel.
|
||||
type Connection struct {
|
||||
ID string
|
||||
|
||||
@@ -351,11 +351,33 @@ func buildInbound(nc *panel.NodeConfig, users []panel.User, certFile, keyFile st
|
||||
return buildSocks(base, users)
|
||||
case "http":
|
||||
return buildHTTP(base, nc, users, certFile, keyFile)
|
||||
case "mieru":
|
||||
return buildMieru(base, nc, users)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildMieru(base M, nc *panel.NodeConfig, users []panel.User) M {
|
||||
base["type"] = "mieru"
|
||||
if nc.Transport != "" {
|
||||
base["transport"] = nc.Transport
|
||||
}
|
||||
if nc.TrafficPattern != "" {
|
||||
base["traffic_pattern"] = nc.TrafficPattern
|
||||
}
|
||||
|
||||
userList := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
userList = append(userList, M{
|
||||
"name": u.UUID,
|
||||
"password": u.UUID,
|
||||
})
|
||||
}
|
||||
base["users"] = userList
|
||||
return base
|
||||
}
|
||||
|
||||
func buildShadowsocks(base M, nc *panel.NodeConfig, users []panel.User) M {
|
||||
base["type"] = "shadowsocks"
|
||||
base["method"] = nc.Cipher
|
||||
@@ -394,6 +416,8 @@ func buildVMess(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyF
|
||||
base["users"] = userList
|
||||
|
||||
applyTransport(base, nc)
|
||||
applyMultiplex(base, nc)
|
||||
|
||||
if nc.TLS == 1 {
|
||||
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
|
||||
}
|
||||
@@ -418,6 +442,7 @@ func buildVLESS(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyF
|
||||
base["users"] = userList
|
||||
|
||||
applyTransport(base, nc)
|
||||
applyMultiplex(base, nc)
|
||||
|
||||
if nc.TLS == 1 {
|
||||
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
|
||||
@@ -441,6 +466,8 @@ func buildTrojan(base M, nc *panel.NodeConfig, users []panel.User, certFile, key
|
||||
base["users"] = userList
|
||||
|
||||
applyTransport(base, nc)
|
||||
applyMultiplex(base, nc)
|
||||
|
||||
if nc.TLS == 1 {
|
||||
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
|
||||
} else if nc.TLS == 2 {
|
||||
@@ -742,3 +769,31 @@ func buildRealityConfig(nc *panel.NodeConfig) M {
|
||||
tls["reality"] = reality
|
||||
return tls
|
||||
}
|
||||
|
||||
func applyMultiplex(base M, nc *panel.NodeConfig) {
|
||||
if nc.Multiplex == nil || !nc.Multiplex.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
mux := M{
|
||||
"enabled": true,
|
||||
}
|
||||
if nc.Multiplex.Padding {
|
||||
mux["padding"] = true
|
||||
}
|
||||
|
||||
if nc.Multiplex.Brutal != nil && nc.Multiplex.Brutal.Enabled {
|
||||
brutal := M{
|
||||
"enabled": true,
|
||||
}
|
||||
if nc.Multiplex.Brutal.UpMbps > 0 {
|
||||
brutal["up_mbps"] = nc.Multiplex.Brutal.UpMbps
|
||||
}
|
||||
if nc.Multiplex.Brutal.DownMbps > 0 {
|
||||
brutal["down_mbps"] = nc.Multiplex.Brutal.DownMbps
|
||||
}
|
||||
mux["brutal"] = brutal
|
||||
}
|
||||
|
||||
base["multiplex"] = mux
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func (s *SingBox) Name() string { return "sing-box" }
|
||||
func (s *SingBox) Protocols() []string {
|
||||
return []string{
|
||||
"vmess", "vless", "trojan", "shadowsocks",
|
||||
"hysteria2", "tuic", "naive", "socks", "http", "anytls",
|
||||
"hysteria2", "tuic", "naive", "socks", "http", "anytls", "mieru",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,55 +175,61 @@ func (s *SingBox) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certF
|
||||
|
||||
nopFactory := singLog.NewNOPFactory()
|
||||
|
||||
// Configuration hash check for inbound reconstruction
|
||||
configChanged := s.nodeConfig == nil || kernel.ComputeHash(nodeConfig, users) != kernel.ComputeHash(s.nodeConfig, s.users)
|
||||
|
||||
for _, inb := range opts.Inbounds {
|
||||
tag := inb.Tag
|
||||
if existing, ok := im.Get(tag); ok && existing.Type() == inb.Type {
|
||||
var err error
|
||||
switch v := existing.(type) {
|
||||
case adapter.UpdatableInbound[option.VMessUser]:
|
||||
if opts, ok := inb.Options.(*option.VMessInboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
if !configChanged {
|
||||
if existing, ok := im.Get(tag); ok && existing.Type() == inb.Type {
|
||||
var err error
|
||||
switch v := existing.(type) {
|
||||
case adapter.UpdatableInbound[option.VMessUser]:
|
||||
if opts, ok := inb.Options.(*option.VMessInboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[option.VLESSUser]:
|
||||
if opts, ok := inb.Options.(*option.VLESSInboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[option.TrojanUser]:
|
||||
if opts, ok := inb.Options.(*option.TrojanInboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[option.Hysteria2User]:
|
||||
if opts, ok := inb.Options.(*option.Hysteria2InboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableShadowsocksInbound:
|
||||
if opts, ok := inb.Options.(*option.ShadowsocksInboundOptions); ok {
|
||||
err = v.UpdateUsersByOptions(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[option.TUICUser]:
|
||||
if opts, ok := inb.Options.(*option.TUICInboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[option.AnyTLSUser]:
|
||||
if opts, ok := inb.Options.(*option.AnyTLSInboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[auth.User]:
|
||||
switch opts := inb.Options.(type) {
|
||||
case *option.NaiveInboundOptions:
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
case *option.SocksInboundOptions:
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
case *option.HTTPMixedInboundOptions:
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
}
|
||||
case adapter.UpdatableInbound[option.VLESSUser]:
|
||||
if opts, ok := inb.Options.(*option.VLESSInboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[option.TrojanUser]:
|
||||
if opts, ok := inb.Options.(*option.TrojanInboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[option.Hysteria2User]:
|
||||
if opts, ok := inb.Options.(*option.Hysteria2InboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableShadowsocksInbound:
|
||||
if opts, ok := inb.Options.(*option.ShadowsocksInboundOptions); ok {
|
||||
err = v.UpdateUsersByOptions(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[option.TUICUser]:
|
||||
if opts, ok := inb.Options.(*option.TUICInboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[option.AnyTLSUser]:
|
||||
if opts, ok := inb.Options.(*option.AnyTLSInboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[auth.User]:
|
||||
switch opts := inb.Options.(type) {
|
||||
case *option.NaiveInboundOptions:
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
case *option.SocksInboundOptions:
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
case *option.HTTPMixedInboundOptions:
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
slog.Warn("incremental update failed, falling back to recreate", "tag", tag, "error", err)
|
||||
}
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
slog.Warn("incremental update failed, falling back to recreate", "tag", tag, "error", err)
|
||||
}
|
||||
|
||||
// Config mismatch or incremental update failure: Reconstruct Inbound.
|
||||
// Remove the existing inbound first so im.Create() can bind the same port.
|
||||
// im.Create() cannot atomically swap a TCP listener — it tries to start the
|
||||
// new socket before the old one is closed, causing "address already in use".
|
||||
|
||||
@@ -460,6 +460,16 @@ func applyStreamSettings(base M, nc *panel.NodeConfig, certFile, keyFile string)
|
||||
ss["realitySettings"] = buildRealitySettings(nc)
|
||||
}
|
||||
|
||||
// Proxy Protocol
|
||||
if nc.AcceptProxyProtocol {
|
||||
sockopt, ok := base["streamSettings"].(M)["sockopt"].(M)
|
||||
if !ok {
|
||||
sockopt = M{}
|
||||
}
|
||||
sockopt["acceptProxyProtocol"] = true
|
||||
ss["sockopt"] = sockopt
|
||||
}
|
||||
|
||||
base["streamSettings"] = ss
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -152,7 +151,7 @@ func (x *Xray) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile,
|
||||
x.protocol = nodeConfig.Protocol
|
||||
x.inboundTag = nodeConfig.Protocol + "-in"
|
||||
x.cumTraffic = make(map[int][2]int64)
|
||||
x.lastKernelHash = computeKernelHash(nodeConfig, users)
|
||||
x.lastKernelHash = kernel.ComputeHash(nodeConfig, users)
|
||||
x.running.Store(true)
|
||||
x.mu.Unlock()
|
||||
|
||||
@@ -165,23 +164,26 @@ func (x *Xray) Start(nodeConfig *panel.NodeConfig, users []panel.User, certFile,
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload hot-swaps dispatcher limits and, only when kernel-affecting fields
|
||||
// have changed (user identities, routes, protocol), triggers a full restart.
|
||||
// Reload updates dispatcher limits and handles configuration changes.
|
||||
// Since Xray does not support granular inbound reconstruction without an
|
||||
// instance restart for most transport/TLS settings, it triggers a full restart
|
||||
// if any kernel-affecting fields (hash mismatch) have changed.
|
||||
func (x *Xray) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error {
|
||||
x.updateDispatcherLimits(users)
|
||||
|
||||
newHash := computeKernelHash(nodeConfig, users)
|
||||
newHash := kernel.ComputeHash(nodeConfig, users)
|
||||
|
||||
x.mu.Lock()
|
||||
same := x.lastKernelHash == newHash
|
||||
if same {
|
||||
x.users = users
|
||||
x.mu.Unlock()
|
||||
slog.Info("xray: limits updated, kernel unchanged")
|
||||
slog.Debug("xray: limits updated, kernel configuration unchanged")
|
||||
return nil
|
||||
}
|
||||
x.mu.Unlock()
|
||||
|
||||
slog.Info("xray: configuration changed, performing full restart")
|
||||
return x.Start(nodeConfig, users, certFile, keyFile)
|
||||
}
|
||||
|
||||
@@ -745,18 +747,3 @@ func (x *Xray) updateDispatcherLimits(users []panel.User) {
|
||||
// so that concurrent Xray instances in multi-node mode each capture their
|
||||
// own LimitDispatcher.
|
||||
var xrayCreationMu sync.Mutex
|
||||
|
||||
// computeKernelHash returns a hash of config + user identities that would
|
||||
// require a kernel restart if changed. Limit-only changes are excluded.
|
||||
func computeKernelHash(nc *panel.NodeConfig, users []panel.User) string {
|
||||
h := sha256.New()
|
||||
configData, _ := json.Marshal(nc)
|
||||
h.Write(configData)
|
||||
sorted := make([]panel.User, len(users))
|
||||
copy(sorted, users)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID })
|
||||
for _, u := range sorted {
|
||||
fmt.Fprintf(h, "%d:%s,", u.ID, u.UUID)
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/shirou/gopsutil/v4/cpu"
|
||||
"github.com/shirou/gopsutil/v4/disk"
|
||||
"github.com/shirou/gopsutil/v4/load"
|
||||
"github.com/shirou/gopsutil/v4/mem"
|
||||
)
|
||||
|
||||
@@ -24,12 +25,16 @@ type Status struct {
|
||||
Uptime uint64
|
||||
CPU float64
|
||||
CPUPerCore []float64
|
||||
Load1 float64
|
||||
Load5 float64
|
||||
Load15 float64
|
||||
MemTotal uint64
|
||||
MemUsed uint64
|
||||
SwapTotal uint64
|
||||
SwapUsed uint64
|
||||
DiskTotal uint64
|
||||
DiskUsed uint64
|
||||
Goroutines int
|
||||
|
||||
// GC metrics (process-wide)
|
||||
NumGC uint32
|
||||
@@ -53,6 +58,12 @@ func Collect() Status {
|
||||
s.CPUPerCore = perCore
|
||||
}
|
||||
|
||||
if loadAvg, err := load.Avg(); err == nil {
|
||||
s.Load1 = loadAvg.Load1
|
||||
s.Load5 = loadAvg.Load5
|
||||
s.Load15 = loadAvg.Load15
|
||||
}
|
||||
|
||||
if vmStat, err := mem.VirtualMemory(); err == nil {
|
||||
s.MemTotal = vmStat.Total
|
||||
s.MemUsed = vmStat.Used
|
||||
@@ -71,6 +82,7 @@ func Collect() Status {
|
||||
// GC metrics
|
||||
var ms runtime.MemStats
|
||||
runtime.ReadMemStats(&ms)
|
||||
s.Goroutines = runtime.NumGoroutine()
|
||||
s.NumGC = ms.NumGC
|
||||
if ms.NumGC > 0 {
|
||||
// PauseNs is a ring buffer of the most recent GC pause times.
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +18,12 @@ import (
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
trafficMapPool = sync.Pool{New: func() interface{} { return make(map[string][2]int64) }}
|
||||
aliveMapPool = sync.Pool{New: func() interface{} { return make(map[string][]string) }}
|
||||
onlineMapPool = sync.Pool{New: func() interface{} { return make(map[string]int) }}
|
||||
)
|
||||
|
||||
// Client communicates with the Xboard panel API
|
||||
type Client struct {
|
||||
baseURL string
|
||||
@@ -78,30 +85,48 @@ func (c *Client) Report(traffic map[int][2]int64, alive map[int][]string, online
|
||||
cpu float64, mem, swap, disk [2]uint64,
|
||||
metrics map[string]interface{},
|
||||
) error {
|
||||
payload := map[string]interface{}{}
|
||||
payload := make(map[string]interface{})
|
||||
|
||||
if len(traffic) > 0 {
|
||||
t := make(map[string][2]int64, len(traffic))
|
||||
t := trafficMapPool.Get().(map[string][2]int64)
|
||||
for uid, d := range traffic {
|
||||
t[strconv.Itoa(uid)] = d
|
||||
}
|
||||
payload["traffic"] = t
|
||||
defer func() {
|
||||
for k := range t {
|
||||
delete(t, k)
|
||||
}
|
||||
trafficMapPool.Put(t)
|
||||
}()
|
||||
}
|
||||
|
||||
if len(alive) > 0 {
|
||||
a := make(map[string][]string, len(alive))
|
||||
a := aliveMapPool.Get().(map[string][]string)
|
||||
for uid, ips := range alive {
|
||||
a[strconv.Itoa(uid)] = ips
|
||||
}
|
||||
payload["alive"] = a
|
||||
defer func() {
|
||||
for k := range a {
|
||||
delete(a, k)
|
||||
}
|
||||
aliveMapPool.Put(a)
|
||||
}()
|
||||
}
|
||||
|
||||
if len(online) > 0 {
|
||||
o := make(map[string]int, len(online))
|
||||
o := onlineMapPool.Get().(map[string]int)
|
||||
for uid, count := range online {
|
||||
o[strconv.Itoa(uid)] = count
|
||||
}
|
||||
payload["online"] = o
|
||||
defer func() {
|
||||
for k := range o {
|
||||
delete(o, k)
|
||||
}
|
||||
onlineMapPool.Put(o)
|
||||
}()
|
||||
}
|
||||
|
||||
status := map[string]interface{}{
|
||||
|
||||
@@ -110,6 +110,32 @@ type NodeConfig struct {
|
||||
|
||||
// AnyTLS
|
||||
PaddingScheme StringOrArray `json:"padding_scheme,omitempty"`
|
||||
|
||||
// Mieru
|
||||
Transport string `json:"transport,omitempty"`
|
||||
TrafficPattern string `json:"traffic_pattern,omitempty"`
|
||||
|
||||
// Multiplex
|
||||
Multiplex *MultiplexConfig `json:"multiplex,omitempty"`
|
||||
|
||||
// Proxy Protocol
|
||||
AcceptProxyProtocol bool `json:"accept_proxy_protocol,omitempty"`
|
||||
}
|
||||
|
||||
type MultiplexConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Protocol string `json:"protocol"`
|
||||
MaxConnections int `json:"max_connections"`
|
||||
MinStreams int `json:"min_streams"`
|
||||
MaxStreams int `json:"max_streams"`
|
||||
Padding bool `json:"padding"`
|
||||
Brutal *BrutalConfig `json:"brutal,omitempty"`
|
||||
}
|
||||
|
||||
type BrutalConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
UpMbps int `json:"up_mbps"`
|
||||
DownMbps int `json:"down_mbps"`
|
||||
}
|
||||
|
||||
// CertConfig holds certificate automation settings from the panel
|
||||
|
||||
+55
-22
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
@@ -67,6 +68,7 @@ type WSClient struct {
|
||||
nodeID int
|
||||
onEvent func(WSEvent)
|
||||
onStatus func(WSStatusChange)
|
||||
onPing func() map[string]interface{}
|
||||
|
||||
connected atomic.Bool
|
||||
}
|
||||
@@ -74,13 +76,14 @@ type WSClient struct {
|
||||
// NewWSClient creates a new WebSocket client.
|
||||
// wsURL is the base WebSocket URL (e.g. "ws://panel.example.com:8076").
|
||||
// token and nodeID are used for authentication via query parameters.
|
||||
func NewWSClient(wsURL string, token string, nodeID int, onEvent func(WSEvent), onStatus func(WSStatusChange)) *WSClient {
|
||||
func NewWSClient(wsURL string, token string, nodeID int, onEvent func(WSEvent), onStatus func(WSStatusChange), onPing func() map[string]interface{}) *WSClient {
|
||||
return &WSClient{
|
||||
wsURL: wsURL,
|
||||
token: token,
|
||||
nodeID: nodeID,
|
||||
onEvent: onEvent,
|
||||
onStatus: onStatus,
|
||||
onPing: onPing,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,15 +125,19 @@ func (w *WSClient) Run(ctx context.Context) {
|
||||
backoff = time.Second
|
||||
}
|
||||
|
||||
timer := time.NewTimer(backoff)
|
||||
// Apply exponential backoff with jitter to prevent thundering herd.
|
||||
jitter := time.Duration(rand.Int63n(int64(backoff / 5)))
|
||||
wait := backoff + jitter
|
||||
|
||||
timer := time.NewTimer(wait)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
if backoff < 30*time.Second {
|
||||
backoff = min(backoff*2, 30*time.Second)
|
||||
if backoff < 60*time.Second {
|
||||
backoff = min(backoff*2, 60*time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,8 +193,9 @@ func (w *WSClient) connect(ctx context.Context) error {
|
||||
slog.Info("ws connected and authenticated")
|
||||
}
|
||||
|
||||
// Ping interval: send pong responses to server pings
|
||||
const pingInterval = 50 * time.Second
|
||||
// Ping interval: send pong responses to server pings.
|
||||
// We also use this timer to trigger periodic status pushes (10s interval).
|
||||
const reportInterval = 10 * time.Second
|
||||
|
||||
msgCh := make(chan wsMessage, 16)
|
||||
errCh := make(chan error, 1)
|
||||
@@ -211,8 +219,11 @@ func (w *WSClient) connect(ctx context.Context) error {
|
||||
}
|
||||
}()
|
||||
|
||||
pingTimer := time.NewTimer(pingInterval)
|
||||
defer pingTimer.Stop()
|
||||
reportTicker := time.NewTicker(reportInterval)
|
||||
defer reportTicker.Stop()
|
||||
|
||||
// writeCh decouples data collection from network I/O.
|
||||
writeCh := make(chan wsMessage, 8)
|
||||
|
||||
for {
|
||||
select {
|
||||
@@ -227,16 +238,38 @@ func (w *WSClient) connect(ctx context.Context) error {
|
||||
return fmt.Errorf("read: %w", err)
|
||||
|
||||
case msg := <-msgCh:
|
||||
pingTimer.Reset(pingInterval)
|
||||
w.handleMessage(msg)
|
||||
|
||||
case <-pingTimer.C:
|
||||
// Send pong to keep connection alive
|
||||
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := conn.WriteJSON(wsMessage{Event: "pong"}); err != nil {
|
||||
return fmt.Errorf("pong: %w", err)
|
||||
if msg.Event == "ping" {
|
||||
// Queue an immediate pong
|
||||
select {
|
||||
case writeCh <- wsMessage{Event: "pong"}:
|
||||
default:
|
||||
slog.Warn("ws write channel full, skipping pong")
|
||||
}
|
||||
}
|
||||
|
||||
case <-reportTicker.C:
|
||||
// Send periodic node.status via WebSocket
|
||||
if w.onPing != nil {
|
||||
msg := wsMessage{Event: "node.status"}
|
||||
if stats := w.onPing(); stats != nil {
|
||||
data, _ := json.Marshal(stats)
|
||||
msg.Data = data
|
||||
msg.Timestamp = time.Now().Unix()
|
||||
}
|
||||
select {
|
||||
case writeCh <- msg:
|
||||
default:
|
||||
slog.Warn("ws write channel full, skipping status push (network slow?)")
|
||||
}
|
||||
}
|
||||
|
||||
case msg := <-writeCh:
|
||||
// Perform the actual network write asynchronously in this loop.
|
||||
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := conn.WriteJSON(msg); err != nil {
|
||||
return fmt.Errorf("write: %w", err)
|
||||
}
|
||||
pingTimer.Reset(pingInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,7 +303,7 @@ func (w *WSClient) handleDataEvent(msg wsMessage) {
|
||||
|
||||
// Helper to unmarshal and decode with weak Typing
|
||||
decodeData := func(data []byte, target interface{}) error {
|
||||
var raw map[string]interface{}
|
||||
var raw any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -279,25 +312,25 @@ func (w *WSClient) handleDataEvent(msg wsMessage) {
|
||||
|
||||
switch msg.Event {
|
||||
case WSEventSyncConfig:
|
||||
slog.Info("ws sync config event received")
|
||||
slog.Debug("ws sync config event received")
|
||||
var p syncConfigPayload
|
||||
if err := decodeData(msg.Data, &p); err != nil {
|
||||
if err := decodeData(msg.Data, &p); err != nil && p.Config.Protocol != "" {
|
||||
slog.Warn("ws: cannot decode config payload", "error", err)
|
||||
return
|
||||
}
|
||||
event.Config = &p.Config
|
||||
|
||||
case WSEventSyncUsers:
|
||||
slog.Info("ws sync users event received")
|
||||
slog.Debug("ws sync users event received")
|
||||
var p syncUsersPayload
|
||||
if err := decodeData(msg.Data, &p); err != nil {
|
||||
if err := decodeData(msg.Data, &p); err != nil && len(p.Users) > 0 {
|
||||
slog.Warn("ws: cannot decode users payload", "error", err)
|
||||
return
|
||||
}
|
||||
event.Users = p.Users
|
||||
|
||||
case WSEventSyncUserDelta:
|
||||
slog.Info("ws sync user delta event received")
|
||||
slog.Debug("ws sync user delta event received")
|
||||
var p syncUserDeltaPayload
|
||||
if err := decodeData(msg.Data, &p); err != nil {
|
||||
slog.Warn("ws: cannot decode user delta payload", "error", err)
|
||||
|
||||
@@ -90,7 +90,7 @@ func TestWSClient_ConnectAndReceiveDataEvents(t *testing.T) {
|
||||
mu.Lock()
|
||||
received = append(received, event)
|
||||
mu.Unlock()
|
||||
}, nil)
|
||||
}, nil, func() map[string]interface{} { return nil })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
@@ -187,7 +187,7 @@ func TestWSClient_ReconnectOnDisconnect(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
host := strings.TrimPrefix(server.URL, "http://")
|
||||
ws := NewWSClient("ws://"+host, "reconnect-token", 1, func(WSEvent) {}, nil)
|
||||
ws := NewWSClient("ws://"+host, "reconnect-token", 1, func(WSEvent) {}, nil, func() map[string]interface{} { return nil })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
@@ -218,7 +218,7 @@ func TestWSClient_ReconnectOnDisconnect(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWSClient_FallbackWhenNoServer(t *testing.T) {
|
||||
ws := NewWSClient("ws://127.0.0.1:19999", "fallback-token", 1, func(WSEvent) {}, nil)
|
||||
ws := NewWSClient("ws://127.0.0.1:19999", "fallback-token", 1, func(WSEvent) {}, nil, func() map[string]interface{} { return nil })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
@@ -254,7 +254,7 @@ func TestWSClient_UserDeltaEvent(t *testing.T) {
|
||||
mu.Lock()
|
||||
received = append(received, event)
|
||||
mu.Unlock()
|
||||
}, nil)
|
||||
}, nil, func() map[string]interface{} { return nil })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
+68
-59
@@ -3,8 +3,10 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math"
|
||||
"sort"
|
||||
@@ -33,8 +35,12 @@ type Service struct {
|
||||
lastConfig *panel.NodeConfig
|
||||
lastUsers []panel.User
|
||||
|
||||
lastAppliedPort int // the port currently bound by the kernel
|
||||
lastAppliedIP string // the listen IP currently bound by the kernel
|
||||
// appliedState tracks the configuration and users that are currently
|
||||
// successfully running in the kernel.
|
||||
appliedState struct {
|
||||
Config *panel.NodeConfig
|
||||
Users []panel.User
|
||||
}
|
||||
|
||||
pushInterval int // seconds
|
||||
pullInterval int // seconds
|
||||
@@ -282,13 +288,14 @@ func (s *Service) initialSetup(ctx context.Context) error {
|
||||
// Update service-level config overrides from remote NodeConfig if present
|
||||
s.applyRemoteOverrides(ctx, nodeConfig)
|
||||
|
||||
s.lastAppliedPort = nodeConfig.ServerPort
|
||||
s.lastAppliedIP = nodeConfig.ListenIP
|
||||
|
||||
certFile, keyFile := s.cert.CertFile(), s.cert.KeyFile()
|
||||
if err := s.kernel.Start(nodeConfig, users, certFile, keyFile); err != nil {
|
||||
if err := s.kernel.Start(nodeConfig, users, s.cert.CertFile(), s.cert.KeyFile()); err != nil {
|
||||
return fmt.Errorf("start kernel: %w", err)
|
||||
}
|
||||
|
||||
// record applied state on success
|
||||
s.appliedState.Config = nodeConfig
|
||||
s.appliedState.Users = users
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -381,6 +388,12 @@ func (s *Service) newWSClient(wsURL string) *panel.WSClient {
|
||||
default:
|
||||
}
|
||||
},
|
||||
func() map[string]interface{} {
|
||||
status := monitor.Collect()
|
||||
m := s.buildMetrics(status)
|
||||
m["kernel_status"] = s.kernel.IsRunning()
|
||||
return m
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -576,15 +589,22 @@ func (s *Service) updateUserState(users []panel.User) {
|
||||
}
|
||||
|
||||
// startKernel starts (or restarts) the kernel with the given config/users and
|
||||
// records the bound port/IP. Returns false on error (already logged).
|
||||
// records the successfully applied state. Returns false on error.
|
||||
func (s *Service) startKernel(nc *panel.NodeConfig, users []panel.User) bool {
|
||||
certFile, keyFile := s.cert.CertFile(), s.cert.KeyFile()
|
||||
if err := s.kernel.Start(nc, users, certFile, keyFile); err != nil {
|
||||
if err := s.kernel.Start(nc, users, s.cert.CertFile(), s.cert.KeyFile()); err != nil {
|
||||
slog.Error("failed to start kernel", "error", err)
|
||||
return false
|
||||
}
|
||||
s.lastAppliedPort = nc.ServerPort
|
||||
s.lastAppliedIP = nc.ListenIP
|
||||
|
||||
s.appliedState.Config = nc
|
||||
s.appliedState.Users = users
|
||||
|
||||
slog.Info("kernel started successfully",
|
||||
"node_id", s.cfg.Panel.NodeID,
|
||||
"protocol", nc.Protocol,
|
||||
"port", nc.ServerPort,
|
||||
"users", len(users),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -710,27 +730,28 @@ func (s *Service) applyChanges(ctx context.Context, configChanged, usersChanged
|
||||
if len(s.lastUsers) == 0 {
|
||||
slog.Warn("no users, stopping kernel")
|
||||
s.kernel.Stop()
|
||||
s.appliedState.Users = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// If port or listen IP changed, must do full restart.
|
||||
needsRestart := s.kernel.IsRunning() &&
|
||||
(s.lastConfig.ServerPort != s.lastAppliedPort || s.lastConfig.ListenIP != s.lastAppliedIP)
|
||||
|
||||
if !needsRestart && s.kernel.IsRunning() {
|
||||
certFile, keyFile := s.cert.CertFile(), s.cert.KeyFile()
|
||||
if err := s.kernel.Reload(s.lastConfig, s.lastUsers, certFile, keyFile); err != nil {
|
||||
// If config changed, delegate to kernel.Reload. The kernel implementation
|
||||
// decides whether to hot-swap users, reconstruct inbounds, or restart itself.
|
||||
if configChanged && s.kernel.IsRunning() {
|
||||
if err := s.kernel.Reload(s.lastConfig, s.lastUsers, s.cert.CertFile(), s.cert.KeyFile()); err != nil {
|
||||
slog.Warn("reload failed, falling back to full restart", "error", err)
|
||||
needsRestart = true
|
||||
s.startKernel(s.lastConfig, s.lastUsers)
|
||||
} else {
|
||||
s.appliedState.Config = s.lastConfig
|
||||
s.appliedState.Users = s.lastUsers
|
||||
}
|
||||
}
|
||||
|
||||
if needsRestart || !s.kernel.IsRunning() {
|
||||
} else if !s.kernel.IsRunning() {
|
||||
s.startKernel(s.lastConfig, s.lastUsers)
|
||||
}
|
||||
}
|
||||
|
||||
// updateUserState is the single point that refreshes limiter, speedTracker,
|
||||
|
||||
func (s *Service) trackAndEnforce(ctx context.Context) {
|
||||
if !s.kernel.IsRunning() {
|
||||
return
|
||||
@@ -776,6 +797,7 @@ func (s *Service) pushReport() {
|
||||
status := monitor.Collect()
|
||||
|
||||
metrics := s.buildMetrics(status)
|
||||
metrics["kernel_status"] = s.kernel.IsRunning()
|
||||
|
||||
// Always report even if no traffic, to maintain heartbeats and status.
|
||||
if err := s.panel.Report(
|
||||
@@ -806,12 +828,16 @@ func (s *Service) pushReport() {
|
||||
// WebSocket status, and limiter hit counts.
|
||||
func (s *Service) buildMetrics(status monitor.Status) map[string]interface{} {
|
||||
m := make(map[string]interface{})
|
||||
online := s.tracker.CurrentOnline()
|
||||
|
||||
m["uptime"] = status.Uptime
|
||||
m["goroutines"] = status.Goroutines
|
||||
|
||||
// Active connections (last measured during tracker.Process()).
|
||||
m["active_connections"] = s.tracker.ActiveConnections()
|
||||
m["total_connections"] = s.tracker.TotalConnections()
|
||||
m["active_users"] = len(online)
|
||||
m["total_users"] = len(s.lastUsers)
|
||||
|
||||
// Speed
|
||||
m["inbound_speed"] = s.tracker.InboundSpeed()
|
||||
@@ -822,6 +848,12 @@ func (s *Service) buildMetrics(status monitor.Status) map[string]interface{} {
|
||||
m["cpu_per_core"] = status.CPUPerCore
|
||||
}
|
||||
|
||||
m["load"] = map[string]interface{}{
|
||||
"load1": status.Load1,
|
||||
"load5": status.Load5,
|
||||
"load15": status.Load15,
|
||||
}
|
||||
|
||||
// Speed Limiter metrics
|
||||
m["speed_limiter"] = map[string]interface{}{
|
||||
"has_limits": s.speedTracker.HasLimits(),
|
||||
@@ -859,21 +891,19 @@ func (s *Service) buildMetrics(status monitor.Status) map[string]interface{} {
|
||||
return m
|
||||
}
|
||||
|
||||
// computeConfigHash returns a SHA-256 hash of the kernel-relevant node config
|
||||
// fields. BaseConfig (PushInterval/PullInterval) is excluded because it only
|
||||
// affects service-layer polling intervals and must not trigger a kernel restart.
|
||||
// computeConfigHash returns a deterministic hash of the node config.
|
||||
// It uses JSON marshaling to ensure all fields are captured, ensuring that
|
||||
// any configuration change correctly triggers a kernel reload.
|
||||
func computeConfigHash(cfg *panel.NodeConfig) string {
|
||||
if cfg == nil {
|
||||
return ""
|
||||
}
|
||||
tmp := *cfg
|
||||
tmp.BaseConfig = panel.BaseConfig{}
|
||||
data, err := json.Marshal(&tmp)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
h := sha256.Sum256(data)
|
||||
return fmt.Sprintf("%x", h)
|
||||
h := sha256.New()
|
||||
// We marshal the entire config to be safe. Node config updates are low-frequency,
|
||||
// so the robustness of capturing all fields outweighs the micro-performance of manual hashing.
|
||||
data, _ := json.Marshal(cfg)
|
||||
h.Write(data)
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
// computeUserHash returns a deterministic hash of the user list for change detection.
|
||||
@@ -886,33 +916,12 @@ func computeUserHash(users []panel.User) string {
|
||||
h := sha256.New()
|
||||
var buf [8]byte
|
||||
for _, u := range sorted {
|
||||
buf[0] = byte(u.ID)
|
||||
buf[1] = byte(u.ID >> 8)
|
||||
buf[2] = byte(u.ID >> 16)
|
||||
buf[3] = byte(u.ID >> 24)
|
||||
buf[4] = byte(u.ID >> 32)
|
||||
buf[5] = byte(u.ID >> 40)
|
||||
buf[6] = byte(u.ID >> 48)
|
||||
buf[7] = byte(u.ID >> 56)
|
||||
binary.LittleEndian.PutUint64(buf[:], uint64(u.ID))
|
||||
h.Write(buf[:])
|
||||
h.Write([]byte(u.UUID))
|
||||
buf[0] = byte(u.SpeedLimit)
|
||||
buf[1] = byte(u.SpeedLimit >> 8)
|
||||
buf[2] = byte(u.SpeedLimit >> 16)
|
||||
buf[3] = byte(u.SpeedLimit >> 24)
|
||||
buf[4] = byte(u.SpeedLimit >> 32)
|
||||
buf[5] = byte(u.SpeedLimit >> 40)
|
||||
buf[6] = byte(u.SpeedLimit >> 48)
|
||||
buf[7] = byte(u.SpeedLimit >> 56)
|
||||
io.WriteString(h, u.UUID)
|
||||
binary.LittleEndian.PutUint64(buf[:], uint64(u.SpeedLimit))
|
||||
h.Write(buf[:])
|
||||
buf[0] = byte(u.DeviceLimit)
|
||||
buf[1] = byte(u.DeviceLimit >> 8)
|
||||
buf[2] = byte(u.DeviceLimit >> 16)
|
||||
buf[3] = byte(u.DeviceLimit >> 24)
|
||||
buf[4] = byte(u.DeviceLimit >> 32)
|
||||
buf[5] = byte(u.DeviceLimit >> 40)
|
||||
buf[6] = byte(u.DeviceLimit >> 48)
|
||||
buf[7] = byte(u.DeviceLimit >> 56)
|
||||
binary.LittleEndian.PutUint64(buf[:], uint64(u.DeviceLimit))
|
||||
h.Write(buf[:])
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
|
||||
Reference in New Issue
Block a user