feat: machine mode, PEM-only certs, ECH support, multi-instance config, batch ops, xbctl

This commit is contained in:
xboard
2026-04-16 23:06:58 +08:00
parent 598b6b531b
commit c5bcbc3299
52 changed files with 6676 additions and 1348 deletions
+92 -32
View File
@@ -20,6 +20,7 @@ const (
WSEventSyncUsers = "sync.users"
WSEventSyncUserDelta = "sync.user.delta"
WSEventSyncDevices = "sync.devices" // panel → node: global device state
WSEventSyncNodes = "sync.nodes" // panel → machine: node list changed
WSEventReportDevices = "report.devices" // node → panel: report device snapshot
)
@@ -33,6 +34,10 @@ type WSEvent struct {
// Device sync fields
DeviceUsers map[int][]string // userID -> IPs (for sync.devices)
NodeID int
// Machine node discovery fields (for sync.nodes)
Nodes []MachineNode
}
// WSStatusChange notifies the service when WS connectivity changes.
@@ -47,27 +52,36 @@ type wsMessage struct {
Timestamp int64 `json:"timestamp,omitempty"`
}
// Payload structures for data events
// Payload structures for data events.
type syncConfigPayload struct {
Config NodeConfig `json:"config"`
Timestamp int64 `json:"timestamp"`
NodeID int `json:"node_id"`
}
type syncUsersPayload struct {
Users []User `json:"users"`
Timestamp int64 `json:"timestamp"`
NodeID int `json:"node_id"`
}
type syncUserDeltaPayload struct {
Action string `json:"action"`
Users []User `json:"users"`
Timestamp int64 `json:"timestamp"`
NodeID int `json:"node_id"`
}
// syncDevicesPayload carries global device state from panel.
type syncDevicesPayload struct {
Users map[int][]string `json:"users"`
Timestamp int64 `json:"timestamp"`
NodeID int `json:"node_id"`
}
// syncNodesPayload carries the updated node list for a machine.
type syncNodesPayload struct {
Nodes []MachineNode `json:"nodes"`
}
// WSClientConfig holds WebSocket client tuning options.
@@ -76,6 +90,7 @@ type WSClientConfig struct {
HandshakeTimeout time.Duration
BackoffInitial time.Duration
BackoffMax time.Duration
MachineID int
}
// WSClient connects to the panel's Workerman WS server using native WebSocket.
@@ -182,14 +197,17 @@ func (w *WSClient) Run(ctx context.Context) {
}
func (w *WSClient) connect(ctx context.Context) error {
// Build WS URL with auth query params
u, err := url.Parse(w.wsURL)
if err != nil {
return fmt.Errorf("parse ws url: %w", err)
}
q := u.Query()
q.Set("token", w.token)
q.Set("node_id", strconv.Itoa(w.nodeID))
if w.cfg.MachineID > 0 {
q.Set("machine_id", strconv.Itoa(w.cfg.MachineID))
} else {
q.Set("node_id", strconv.Itoa(w.nodeID))
}
u.RawQuery = q.Encode()
nlog.Core().Debug("ws connecting", "url", u.String())
@@ -238,7 +256,11 @@ func (w *WSClient) connect(ctx context.Context) error {
reportTicker := time.NewTicker(w.cfg.StatusInterval)
defer reportTicker.Stop()
msgCh := make(chan wsMessage, 16)
// writeCh decouples data collection from network I/O.
writeCh := make(chan wsMessage, 16)
w.writeCh = writeCh
defer func() { w.writeCh = nil }()
errCh := make(chan error, 1)
done := make(chan struct{})
go func() {
@@ -253,21 +275,17 @@ func (w *WSClient) connect(ctx context.Context) error {
return
}
nlog.Core().Debug("ws recv", "event", msg.Event, "data", string(msg.Data))
select {
case msgCh <- msg:
case <-ctx.Done():
return
default:
nlog.Core().Warn("ws: message channel full, dropping message", "event", msg.Event)
w.handleMessage(msg)
if msg.Event == "ping" {
select {
case writeCh <- wsMessage{Event: "pong"}:
default:
nlog.Core().Warn("ws write channel full, skipping pong")
}
}
}
}()
// writeCh decouples data collection from network I/O.
writeCh := make(chan wsMessage, 16)
w.writeCh = writeCh
defer func() { w.writeCh = nil }()
for {
select {
case <-ctx.Done():
@@ -280,17 +298,6 @@ func (w *WSClient) connect(ctx context.Context) error {
case err := <-errCh:
return fmt.Errorf("read: %w", err)
case msg := <-msgCh:
w.handleMessage(msg)
if msg.Event == "ping" {
// Queue an immediate pong
select {
case writeCh <- wsMessage{Event: "pong"}:
default:
nlog.Core().Warn("ws write channel full, skipping pong")
}
}
case <-reportTicker.C:
// Send periodic node.status via WebSocket
if w.onPing != nil {
@@ -339,6 +346,9 @@ func (w *WSClient) handleMessage(msg wsMessage) {
case WSEventSyncDevices:
w.handleDataEvent(msg)
case WSEventSyncNodes:
w.handleDataEvent(msg)
default:
nlog.Core().Debug("ws unknown event", "event", msg.Event)
}
@@ -370,6 +380,11 @@ func (w *WSClient) handleDataEvent(msg wsMessage) {
return
}
event.Config = &p.Config
if p.NodeID > 0 {
event.NodeID = p.NodeID
} else if p.Config.NodeID > 0 {
event.NodeID = p.Config.NodeID
}
case WSEventSyncUsers:
nlog.Core().Debug("ws sync users event received")
@@ -383,6 +398,7 @@ func (w *WSClient) handleDataEvent(msg wsMessage) {
return
}
event.Users = p.Users
event.NodeID = p.NodeID
case WSEventSyncUserDelta:
nlog.Core().Debug("ws sync user delta event received")
@@ -401,6 +417,7 @@ func (w *WSClient) handleDataEvent(msg wsMessage) {
}
event.DeltaAction = p.Action
event.DeltaUsers = p.Users
event.NodeID = p.NodeID
case WSEventSyncDevices:
nlog.Core().Debug("ws sync devices event received")
@@ -410,27 +427,49 @@ func (w *WSClient) handleDataEvent(msg wsMessage) {
return
}
event.DeviceUsers = p.Users
event.NodeID = p.NodeID
case WSEventSyncNodes:
nlog.Core().Info("ws sync nodes event received (machine node list changed)")
var p syncNodesPayload
if err := decodeData(msg.Data, &p); err != nil {
nlog.Core().Warn("ws: cannot decode nodes payload", "error", err)
return
}
event.Nodes = p.Nodes
}
w.onEvent(event)
}
// SendDeviceReport sends local device snapshot to panel via WS.
// Format: {"123": ["1.1.1.1", "2.2.2.2"], "456": ["3.3.3.3"]}
func (w *WSClient) SendDeviceReport(devices map[int][]string) {
w.SendDeviceReportForNode(0, devices)
}
// SendDeviceReportForNode sends a device report tagged with a specific node_id.
// nodeID == 0 omits the field (legacy single-node mode).
func (w *WSClient) SendDeviceReportForNode(nodeID int, devices map[int][]string) {
if !w.connected.Load() {
return
}
msg := wsMessage{
Event: WSEventReportDevices,
Timestamp: time.Now().Unix(),
var payload interface{}
if nodeID > 0 {
payload = map[string]interface{}{"node_id": nodeID, "devices": devices}
} else {
payload = devices
}
d, err := json.Marshal(devices)
d, err := json.Marshal(payload)
if err != nil {
return
}
msg.Data = d
msg := wsMessage{
Event: WSEventReportDevices,
Data: d,
Timestamp: time.Now().Unix(),
}
select {
case w.writeCh <- msg:
@@ -439,6 +478,27 @@ func (w *WSClient) SendDeviceReport(devices map[int][]string) {
}
}
// SendNodeStatus sends a node.status event with the given node_id (machine mode).
func (w *WSClient) SendNodeStatus(nodeID int, stats map[string]interface{}) {
if !w.connected.Load() || stats == nil {
return
}
if nodeID > 0 {
stats["node_id"] = nodeID
}
data, _ := json.Marshal(stats)
msg := wsMessage{
Event: "node.status",
Data: data,
Timestamp: time.Now().Unix(),
}
select {
case w.writeCh <- msg:
default:
nlog.Core().Warn("ws write channel full, skipping node status")
}
}
// SendRaw sends a raw message to the panel via WebSocket.
func (w *WSClient) SendRaw(event string, data json.RawMessage) {
if !w.connected.Load() {