feat: add Mieru protocol and major system performance optimizations

This commit is contained in:
xboard
2026-03-16 22:22:04 +08:00
parent 7a37c15fdc
commit 3e457e8ac6
13 changed files with 343 additions and 337 deletions
+19
View File
@@ -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
+55
View File
@@ -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
}
+49 -43
View File
@@ -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".
+10
View File
@@ -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 -21
View File
@@ -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))
}
+12
View File
@@ -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.
+29 -4
View File
@@ -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{}{
+26
View File
@@ -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
View File
@@ -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)
+4 -4
View File
@@ -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
View File
@@ -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))