Skip to content

Commit 71c53e1

Browse files
author
xboard
committed
feat: add xhttp transport, network monitoring and kernel auto-switch
1 parent 65d97f3 commit 71c53e1

5 files changed

Lines changed: 175 additions & 1 deletion

File tree

internal/kernel/xray/config.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,31 @@ func applyStreamSettings(base M, nc *model.NodeSpec, tc kernel.TLSCert) {
529529
}
530530
ss["httpSettings"] = h2Settings
531531

532+
case "xhttp", "splithttp":
533+
ss["network"] = "xhttp"
534+
xhttpSettings := M{}
535+
if nc.NetworkSettings != nil {
536+
if v, ok := nc.NetworkSettings["path"]; ok {
537+
xhttpSettings["path"] = v
538+
}
539+
if v, ok := nc.NetworkSettings["host"]; ok {
540+
xhttpSettings["host"] = v
541+
}
542+
if v, ok := nc.NetworkSettings["mode"]; ok {
543+
xhttpSettings["mode"] = v
544+
}
545+
if v, ok := nc.NetworkSettings["extra"]; ok {
546+
// PHP sends empty arrays [] instead of {} for empty objects;
547+
// xray rejects [] for fields that expect objects (e.g. sockopt, tlsSettings).
548+
// Recursively strip empty arrays from the extra map before passing to xray.
549+
if m, ok := v.(map[string]interface{}); ok && len(m) > 0 {
550+
sanitizeEmptyArrays(m)
551+
xhttpSettings["extra"] = m
552+
}
553+
}
554+
}
555+
ss["xhttpSettings"] = xhttpSettings
556+
532557
case "tcp":
533558
// default, no extra settings
534559
}
@@ -784,6 +809,22 @@ func copyStrings(src []string) []string {
784809
return out
785810
}
786811

812+
// sanitizeEmptyArrays recursively removes empty slices from a map.
813+
// PHP encodes empty objects as [] instead of {}; xray-core rejects []
814+
// for fields that expect struct types (e.g. sockopt, tlsSettings).
815+
func sanitizeEmptyArrays(m map[string]interface{}) {
816+
for k, v := range m {
817+
switch val := v.(type) {
818+
case []interface{}:
819+
if len(val) == 0 {
820+
delete(m, k)
821+
}
822+
case map[string]interface{}:
823+
sanitizeEmptyArrays(val)
824+
}
825+
}
826+
}
827+
787828
// extractECHServerKeys extracts the ECH private key from tls_settings.ech
788829
// and converts it from PEM to base64 for xray-core's echServerKeys field.
789830
func extractECHServerKeys(tlsSettings map[string]interface{}) string {

internal/machine/machine.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"github.com/cedar2025/xboard-node/internal/config"
1313
"github.com/cedar2025/xboard-node/internal/controlplane"
14+
"github.com/cedar2025/xboard-node/internal/model"
1415
"github.com/cedar2025/xboard-node/internal/monitor"
1516
"github.com/cedar2025/xboard-node/internal/nlog"
1617
"github.com/cedar2025/xboard-node/internal/panel"
@@ -134,6 +135,19 @@ func (o *Orchestrator) startNode(ctx context.Context, mn panel.MachineNode) {
134135

135136
perNodeClient := o.client.ForNode(mn.ID)
136137

138+
// Pre-fetch node config to detect transport-based kernel requirements.
139+
// If the transport (e.g. xhttp) is incompatible with the configured kernel
140+
// (e.g. singbox), auto-switch to the required kernel for this node.
141+
if cfgSnapshot, err := perNodeClient.GetConfig(); err == nil && cfgSnapshot != nil {
142+
if resolved := model.ResolveKernelForTransport(cfgSnapshot.Network, nodeCfg.Kernel.Type); resolved != nodeCfg.Kernel.Type {
143+
nlog.Core().Info(fmt.Sprintf("machine: auto-switching kernel for node %d (%s→%s, transport=%s)",
144+
mn.ID, nodeCfg.Kernel.Type, resolved, cfgSnapshot.Network))
145+
nodeCfg.Kernel.Type = resolved
146+
}
147+
}
148+
// Reset cached ETag so the subsequent GetConfig in Initial() gets a full response.
149+
perNodeClient.ResetConfigETag()
150+
137151
var push controlplane.PushClient
138152
if o.ws != nil {
139153
push = &machineNodePush{
@@ -247,6 +261,7 @@ func (o *Orchestrator) reportMachineStatus() {
247261
[2]uint64{s.MemTotal, s.MemUsed},
248262
[2]uint64{s.SwapTotal, s.SwapUsed},
249263
[2]uint64{s.DiskTotal, s.DiskUsed},
264+
s.NetInSpeed, s.NetOutSpeed,
250265
); err != nil {
251266
nlog.Core().Warn("machine status report failed", "error", err)
252267
}

internal/model/validate.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,38 @@ func ValidateNodeSpec(n *NodeSpec, kcfg config.KernelConfig) error {
3636
if err := ValidateCustomRouteRules(n.CustomRouteRules, kernelType, availableTags); err != nil {
3737
return fmt.Errorf("validate custom route rules: %w", err)
3838
}
39+
if err := validateTransportKernel(n.Network, kernelType); err != nil {
40+
return err
41+
}
42+
return nil
43+
}
44+
45+
// singboxUnsupportedTransports lists transport types that sing-box does not support.
46+
var singboxUnsupportedTransports = map[string]bool{
47+
"xhttp": true,
48+
"splithttp": true,
49+
}
50+
51+
func validateTransportKernel(network, kernelType string) error {
52+
net := strings.ToLower(strings.TrimSpace(network))
53+
if kernelType == "singbox" && singboxUnsupportedTransports[net] {
54+
return fmt.Errorf("transport %q is not supported by sing-box kernel; use xray kernel instead", net)
55+
}
3956
return nil
4057
}
4158

59+
// ResolveKernelForTransport returns the kernel type required by the given
60+
// transport. If the configured kernel cannot handle the transport, it returns
61+
// the kernel that can. Otherwise it returns configuredKernel unchanged.
62+
// This is used in machine mode to auto-switch kernel per node.
63+
func ResolveKernelForTransport(network, configuredKernel string) string {
64+
net := strings.ToLower(strings.TrimSpace(network))
65+
if configuredKernel == "singbox" && singboxUnsupportedTransports[net] {
66+
return "xray"
67+
}
68+
return configuredKernel
69+
}
70+
4271
func normalizeKernelType(value string) (string, error) {
4372
switch strings.ToLower(strings.TrimSpace(value)) {
4473
case "singbox", "sing-box":

internal/monitor/monitor.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@ package monitor
22

33
import (
44
"runtime"
5+
"strings"
6+
"sync"
57
"time"
68

79
"github.com/cedar2025/xboard-node/internal/nlog"
810
"github.com/shirou/gopsutil/v4/cpu"
911
"github.com/shirou/gopsutil/v4/disk"
1012
"github.com/shirou/gopsutil/v4/load"
1113
"github.com/shirou/gopsutil/v4/mem"
14+
"github.com/shirou/gopsutil/v4/net"
1215
)
1316

1417
var startTime = time.Now()
@@ -18,6 +21,9 @@ func init() {
1821
// always returns 0% because it has no prior sample. This throwaway call
1922
// seeds the baseline so subsequent Collect() calls return real values.
2023
cpu.Percent(500*time.Millisecond, false)
24+
25+
// Seed the network baseline so the first Collect() can compute rates.
26+
collectNetSpeed()
2127
}
2228

2329
// Status holds system resource metrics
@@ -36,11 +42,81 @@ type Status struct {
3642
DiskUsed uint64
3743
Goroutines int
3844

45+
// Network speed (bytes/sec), -1 means unavailable (first sample).
46+
NetInSpeed float64
47+
NetOutSpeed float64
48+
3949
// GC metrics (process-wide)
4050
NumGC uint32
4151
LastPauseMS float64
4252
}
4353

54+
// netBaseline tracks the previous network counters for rate calculation.
55+
var (
56+
netMu sync.Mutex
57+
netPrevRecv uint64
58+
netPrevSent uint64
59+
netPrevTime time.Time
60+
netHasBase bool
61+
)
62+
63+
// skipInterface returns true for loopback and common virtual interfaces.
64+
func skipInterface(name string) bool {
65+
lower := strings.ToLower(name)
66+
for _, prefix := range []string{"lo", "docker", "veth", "br-", "virbr", "vnet", "tun", "tap"} {
67+
if strings.HasPrefix(lower, prefix) {
68+
return true
69+
}
70+
}
71+
return false
72+
}
73+
74+
// collectNetSpeed calculates network in/out bytes per second since last call.
75+
// Returns -1, -1 on first call or if counters decreased (reboot).
76+
func collectNetSpeed() (inSpeed, outSpeed float64) {
77+
counters, err := net.IOCounters(true) // per-interface
78+
if err != nil {
79+
nlog.Core().Debug("failed to get network counters", "error", err)
80+
return -1, -1
81+
}
82+
83+
var totalRecv, totalSent uint64
84+
for _, c := range counters {
85+
if skipInterface(c.Name) {
86+
continue
87+
}
88+
totalRecv += c.BytesRecv
89+
totalSent += c.BytesSent
90+
}
91+
92+
now := time.Now()
93+
94+
netMu.Lock()
95+
defer netMu.Unlock()
96+
97+
if !netHasBase {
98+
netPrevRecv, netPrevSent, netPrevTime, netHasBase = totalRecv, totalSent, now, true
99+
return -1, -1
100+
}
101+
102+
elapsed := now.Sub(netPrevTime).Seconds()
103+
if elapsed <= 0 {
104+
return -1, -1
105+
}
106+
107+
// Counter decreased → system reboot or interface reset; reset baseline.
108+
if totalRecv < netPrevRecv || totalSent < netPrevSent {
109+
netPrevRecv, netPrevSent, netPrevTime = totalRecv, totalSent, now
110+
return -1, -1
111+
}
112+
113+
inSpeed = float64(totalRecv-netPrevRecv) / elapsed
114+
outSpeed = float64(totalSent-netPrevSent) / elapsed
115+
116+
netPrevRecv, netPrevSent, netPrevTime = totalRecv, totalSent, now
117+
return inSpeed, outSpeed
118+
}
119+
44120
// Collect gathers current system metrics
45121
func Collect() Status {
46122
var s Status
@@ -79,6 +155,8 @@ func Collect() Status {
79155
s.DiskUsed = diskStat.Used
80156
}
81157

158+
s.NetInSpeed, s.NetOutSpeed = collectNetSpeed()
159+
82160
// GC metrics
83161
var ms runtime.MemStats
84162
runtime.ReadMemStats(&ms)

internal/panel/client.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,13 @@ func (c *Client) ForNode(nodeID int) *Client {
7575
}
7676
}
7777

78+
// ResetConfigETag clears the cached config ETag so the next GetConfig call
79+
// returns a full response instead of 304. Used by machine mode after a
80+
// pre-fetch to probe the transport type.
81+
func (c *Client) ResetConfigETag() {
82+
c.configETag = ""
83+
}
84+
7885
// Handshake calls the new v2 API to get WS config + initial data in one shot.
7986
func (c *Client) Handshake() (*HandshakeResponse, error) {
8087
resp, err := c.doRequest("POST", "/api/v2/server/handshake", nil, "")
@@ -335,13 +342,17 @@ func (c *Client) GetMachineNodes() (*MachineNodesResponse, error) {
335342
}
336343

337344
// ReportMachineStatus sends machine-level load metrics to the panel.
338-
func (c *Client) ReportMachineStatus(cpu float64, mem, swap, disk [2]uint64) error {
345+
// netIn/netOut are bytes/sec; negative values mean "unavailable" (first sample).
346+
func (c *Client) ReportMachineStatus(cpu float64, mem, swap, disk [2]uint64, netIn, netOut float64) error {
339347
payload := map[string]interface{}{
340348
"cpu": cpu,
341349
"mem": map[string]interface{}{"total": mem[0], "used": mem[1]},
342350
"swap": map[string]interface{}{"total": swap[0], "used": swap[1]},
343351
"disk": map[string]interface{}{"total": disk[0], "used": disk[1]},
344352
}
353+
if netIn >= 0 && netOut >= 0 {
354+
payload["net"] = map[string]interface{}{"in_speed": netIn, "out_speed": netOut}
355+
}
345356
return c.postJSON("/api/v2/server/machine/status", payload)
346357
}
347358

0 commit comments

Comments
 (0)