feat: machine mode, PEM-only certs, ECH support, multi-instance config, batch ops, xbctl
This commit is contained in:
@@ -2,6 +2,7 @@ package controlplane
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/cedar2025/xboard-node/internal/model"
|
||||
@@ -23,10 +24,14 @@ func (l *LocalControlPlane) Initial(ctx context.Context, _ func() map[string]int
|
||||
return Bootstrap{}, ctx.Err()
|
||||
default:
|
||||
}
|
||||
nodeSpec, err := model.NodeSpecFromStandaloneValidated(l.cfg)
|
||||
if err != nil {
|
||||
return Bootstrap{}, fmt.Errorf("load standalone node config: %w", err)
|
||||
}
|
||||
return Bootstrap{
|
||||
PushInterval: l.cfg.Node.PushInterval,
|
||||
PullInterval: l.cfg.Node.PullInterval,
|
||||
Config: model.NodeSpecFromStandalone(l.cfg),
|
||||
Config: nodeSpec,
|
||||
Users: model.UserSpecsFromStandalone(l.cfg),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package controlplane
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/cedar2025/xboard-node/internal/model"
|
||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
)
|
||||
|
||||
// MachinePanelControlPlane implements ControlPlane for a single node running
|
||||
// under the machine orchestrator. REST calls go through a per-node panel.Client
|
||||
// with machine-level auth; WS events are delivered via an externally-managed
|
||||
// PushClient supplied by the machine WS mux.
|
||||
type MachinePanelControlPlane struct {
|
||||
client *panel.Client
|
||||
kcfg config.KernelConfig
|
||||
push PushClient // virtual push client from WS mux (may be nil)
|
||||
|
||||
registerFn func(statuses chan<- StatusChange) *NodeMailbox
|
||||
}
|
||||
|
||||
// NewMachinePanelControlPlane creates a per-node control plane for machine mode.
|
||||
// push may be nil if WS is unavailable.
|
||||
// registerFn is called during Initial() to wire machine mailbox access and status
|
||||
// updates into the service loop. It may be nil when WS is not used.
|
||||
func NewMachinePanelControlPlane(
|
||||
client *panel.Client,
|
||||
kcfg config.KernelConfig,
|
||||
push PushClient,
|
||||
registerFn func(statuses chan<- StatusChange) *NodeMailbox,
|
||||
) *MachinePanelControlPlane {
|
||||
return &MachinePanelControlPlane{
|
||||
client: client,
|
||||
kcfg: kcfg,
|
||||
push: push,
|
||||
registerFn: registerFn,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MachinePanelControlPlane) SupportsPolling() bool { return true }
|
||||
func (p *MachinePanelControlPlane) SupportsDiscovery() bool { return false }
|
||||
func (p *MachinePanelControlPlane) SupportsReporting() bool { return true }
|
||||
func (p *MachinePanelControlPlane) SupportsDeviceReports() bool { return p.push != nil }
|
||||
|
||||
func (p *MachinePanelControlPlane) Initial(
|
||||
ctx context.Context,
|
||||
metricsFn func() map[string]interface{},
|
||||
events chan<- Event,
|
||||
statuses chan<- StatusChange,
|
||||
) (Bootstrap, error) {
|
||||
// In machine mode the orchestrator handles the shared WS handshake, but each
|
||||
// node service still needs one initial REST snapshot so newly discovered nodes
|
||||
// can start immediately instead of waiting for a subsequent WS full-sync.
|
||||
hs, err := p.client.Handshake()
|
||||
if err != nil {
|
||||
nlog.Core().Debug("machine node handshake skipped, using REST", "error", err)
|
||||
}
|
||||
|
||||
bootstrap := Bootstrap{}
|
||||
if hs != nil {
|
||||
bootstrap.PushInterval = hs.Settings.PushInterval
|
||||
bootstrap.PullInterval = hs.Settings.PullInterval
|
||||
}
|
||||
|
||||
configSnapshot, err := p.client.GetConfig()
|
||||
if err != nil {
|
||||
return Bootstrap{}, fmt.Errorf("machine initial config: %w", err)
|
||||
}
|
||||
if configSnapshot == nil {
|
||||
return Bootstrap{}, fmt.Errorf("machine initial config is nil")
|
||||
}
|
||||
users, err := p.client.GetUsers()
|
||||
if err != nil {
|
||||
return Bootstrap{}, fmt.Errorf("machine initial users: %w", err)
|
||||
}
|
||||
bootstrap.Config, err = model.NodeSpecFromPanelValidated(configSnapshot, p.kcfg)
|
||||
if err != nil {
|
||||
return Bootstrap{}, fmt.Errorf("machine initial config normalize: %w", err)
|
||||
}
|
||||
bootstrap.Users = model.UserSpecsFromPanel(users)
|
||||
|
||||
// Register mailbox access after the initial snapshot is prepared, so the
|
||||
// Service can start the kernel immediately and only then drain coalesced WS
|
||||
// state from the machine mailbox.
|
||||
if p.push != nil {
|
||||
if p.registerFn != nil {
|
||||
bootstrap.Mailbox = p.registerFn(statuses)
|
||||
}
|
||||
bootstrap.Push = p.push
|
||||
}
|
||||
|
||||
return bootstrap, nil
|
||||
}
|
||||
|
||||
func (p *MachinePanelControlPlane) Poll(ctx context.Context) (Snapshot, error) {
|
||||
configSnapshot, err := p.client.GetConfig()
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("machine poll config: %w", err)
|
||||
}
|
||||
users, err := p.client.GetUsers()
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("machine poll users: %w", err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Snapshot{}, ctx.Err()
|
||||
default:
|
||||
}
|
||||
nodeSpec, err := model.NodeSpecFromPanelValidated(configSnapshot, p.kcfg)
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("machine poll normalize: %w", err)
|
||||
}
|
||||
return Snapshot{Config: nodeSpec, Users: model.UserSpecsFromPanel(users)}, nil
|
||||
}
|
||||
|
||||
func (p *MachinePanelControlPlane) Discover(
|
||||
ctx context.Context,
|
||||
metricsFn func() map[string]interface{},
|
||||
events chan<- Event,
|
||||
statuses chan<- StatusChange,
|
||||
) (PushClient, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *MachinePanelControlPlane) Report(payload ReportPayload) error {
|
||||
return p.client.Report(
|
||||
payload.Traffic, payload.Alive, payload.Online,
|
||||
payload.CPU, payload.Mem, payload.Swap, payload.Disk,
|
||||
payload.Metrics,
|
||||
)
|
||||
}
|
||||
|
||||
func (p *MachinePanelControlPlane) ReportDevices(push PushClient, devices map[int][]string) {
|
||||
if push != nil {
|
||||
push.SendDeviceReport(devices)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MachinePanelControlPlane) Metrics() APIMetrics {
|
||||
api := p.client.SnapshotMetrics()
|
||||
return APIMetrics{Success: api.Success, Failure: api.Failure}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package controlplane
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/model"
|
||||
)
|
||||
|
||||
// MailboxState is the coalesced snapshot drained from a NodeMailbox.
|
||||
type MailboxState struct {
|
||||
Config *model.NodeSpec
|
||||
Users []model.UserSpec
|
||||
DeviceUsers map[int][]string
|
||||
HasConfig bool
|
||||
HasUsers bool
|
||||
HasDevices bool
|
||||
NeedsReconcile bool
|
||||
}
|
||||
|
||||
// NodeMailbox buffers WS events for a machine-mode node service that is not
|
||||
// yet ready to process them. Events are coalesced (latest-wins) and drained
|
||||
// as a single snapshot once the service marks the mailbox as ready.
|
||||
type NodeMailbox struct {
|
||||
mu sync.Mutex
|
||||
ready bool
|
||||
config *model.NodeSpec
|
||||
users []model.UserSpec
|
||||
deviceUsers map[int][]string
|
||||
dirtyConfig bool
|
||||
dirtyUsers bool
|
||||
dirtyDevices bool
|
||||
needsReconcile bool
|
||||
hasFullUserState bool
|
||||
notifyCh chan struct{}
|
||||
}
|
||||
|
||||
func NewNodeMailbox() *NodeMailbox {
|
||||
return &NodeMailbox{notifyCh: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
func (m *NodeMailbox) MarkReady() {
|
||||
m.mu.Lock()
|
||||
m.ready = true
|
||||
m.mu.Unlock()
|
||||
m.notify()
|
||||
}
|
||||
|
||||
// SeedBaseline initializes the mailbox with the REST-derived baseline so that
|
||||
// subsequent delta events can be applied incrementally instead of triggering
|
||||
// a full REST reconciliation.
|
||||
func (m *NodeMailbox) SeedBaseline(users []model.UserSpec, config *model.NodeSpec) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if users != nil && !m.hasFullUserState {
|
||||
m.users = cloneUsers(users)
|
||||
m.hasFullUserState = true
|
||||
}
|
||||
if config != nil && m.config == nil {
|
||||
m.config = cloneNodeSpec(config)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *NodeMailbox) Apply(event Event) {
|
||||
m.mu.Lock()
|
||||
changed := false
|
||||
|
||||
switch event.Type {
|
||||
case EventSyncConfig:
|
||||
if event.Config != nil {
|
||||
m.config = cloneNodeSpec(event.Config)
|
||||
m.dirtyConfig = true
|
||||
changed = true
|
||||
}
|
||||
case EventSyncUsers:
|
||||
if event.Users != nil {
|
||||
m.users = cloneUsers(event.Users)
|
||||
m.dirtyUsers = true
|
||||
m.hasFullUserState = true
|
||||
changed = true
|
||||
}
|
||||
case EventSyncDevices:
|
||||
if event.DeviceUsers != nil {
|
||||
m.deviceUsers = cloneDeviceUsers(event.DeviceUsers)
|
||||
m.dirtyDevices = true
|
||||
changed = true
|
||||
}
|
||||
case EventSyncUserDelta:
|
||||
if len(event.DeltaUsers) > 0 {
|
||||
if !m.hasFullUserState {
|
||||
m.needsReconcile = true
|
||||
changed = true
|
||||
} else if !applyUserDelta(&m.users, event.DeltaAction, event.DeltaUsers) {
|
||||
m.needsReconcile = true
|
||||
changed = true
|
||||
} else {
|
||||
m.dirtyUsers = true
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if changed {
|
||||
m.notify()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *NodeMailbox) notify() {
|
||||
select {
|
||||
case m.notifyCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (m *NodeMailbox) NotifyCh() <-chan struct{} { return m.notifyCh }
|
||||
|
||||
func (m *NodeMailbox) DrainIfReady() MailboxState {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if !m.ready {
|
||||
return MailboxState{}
|
||||
}
|
||||
state := MailboxState{NeedsReconcile: m.needsReconcile}
|
||||
if m.dirtyConfig && m.config != nil {
|
||||
state.Config = cloneNodeSpec(m.config)
|
||||
state.HasConfig = true
|
||||
m.dirtyConfig = false
|
||||
}
|
||||
if m.dirtyUsers {
|
||||
state.Users = cloneUsers(m.users)
|
||||
state.HasUsers = true
|
||||
m.dirtyUsers = false
|
||||
}
|
||||
if m.dirtyDevices && m.deviceUsers != nil {
|
||||
state.DeviceUsers = cloneDeviceUsers(m.deviceUsers)
|
||||
state.HasDevices = true
|
||||
m.dirtyDevices = false
|
||||
}
|
||||
m.needsReconcile = false
|
||||
return state
|
||||
}
|
||||
|
||||
func cloneNodeSpec(spec *model.NodeSpec) *model.NodeSpec {
|
||||
if spec == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *spec
|
||||
if spec.NetworkSettings != nil {
|
||||
clone.NetworkSettings = make(map[string]interface{}, len(spec.NetworkSettings))
|
||||
for k, v := range spec.NetworkSettings {
|
||||
clone.NetworkSettings[k] = v
|
||||
}
|
||||
}
|
||||
if spec.TLSSettings != nil {
|
||||
clone.TLSSettings = make(map[string]interface{}, len(spec.TLSSettings))
|
||||
for k, v := range spec.TLSSettings {
|
||||
clone.TLSSettings[k] = v
|
||||
}
|
||||
}
|
||||
if spec.Routes != nil {
|
||||
clone.Routes = append([]model.RouteRule(nil), spec.Routes...)
|
||||
}
|
||||
if spec.CustomOutbounds != nil {
|
||||
clone.CustomOutbounds = append([]model.OutboundConfig(nil), spec.CustomOutbounds...)
|
||||
}
|
||||
if spec.CustomRoutes != nil {
|
||||
clone.CustomRoutes = make([]map[string]any, len(spec.CustomRoutes))
|
||||
for i, route := range spec.CustomRoutes {
|
||||
if route == nil {
|
||||
continue
|
||||
}
|
||||
copied := make(map[string]any, len(route))
|
||||
for k, v := range route {
|
||||
copied[k] = v
|
||||
}
|
||||
clone.CustomRoutes[i] = copied
|
||||
}
|
||||
}
|
||||
if spec.CustomRouteRules != nil {
|
||||
clone.CustomRouteRules = append([]model.CustomRouteRule(nil), spec.CustomRouteRules...)
|
||||
}
|
||||
if spec.CertConfig != nil {
|
||||
certCopy := *spec.CertConfig
|
||||
if spec.CertConfig.DNSEnv != nil {
|
||||
certCopy.DNSEnv = make(map[string]string, len(spec.CertConfig.DNSEnv))
|
||||
for k, v := range spec.CertConfig.DNSEnv {
|
||||
certCopy.DNSEnv[k] = v
|
||||
}
|
||||
}
|
||||
clone.CertConfig = &certCopy
|
||||
}
|
||||
if spec.Multiplex != nil {
|
||||
muxCopy := *spec.Multiplex
|
||||
if spec.Multiplex.Brutal != nil {
|
||||
brutalCopy := *spec.Multiplex.Brutal
|
||||
muxCopy.Brutal = &brutalCopy
|
||||
}
|
||||
clone.Multiplex = &muxCopy
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
func cloneUsers(users []model.UserSpec) []model.UserSpec {
|
||||
if users == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]model.UserSpec, len(users))
|
||||
copy(cloned, users)
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneDeviceUsers(devices map[int][]string) map[int][]string {
|
||||
if devices == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make(map[int][]string, len(devices))
|
||||
for uid, ips := range devices {
|
||||
dup := make([]string, len(ips))
|
||||
copy(dup, ips)
|
||||
cloned[uid] = dup
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func applyUserDelta(users *[]model.UserSpec, action string, delta []model.UserSpec) bool {
|
||||
current := cloneUsers(*users)
|
||||
index := make(map[int]int, len(current))
|
||||
for i, user := range current {
|
||||
index[user.ID] = i
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "add":
|
||||
for _, user := range delta {
|
||||
if idx, ok := index[user.ID]; ok {
|
||||
current[idx] = user
|
||||
continue
|
||||
}
|
||||
index[user.ID] = len(current)
|
||||
current = append(current, user)
|
||||
}
|
||||
case "remove":
|
||||
removeIDs := make(map[int]struct{}, len(delta))
|
||||
for _, user := range delta {
|
||||
removeIDs[user.ID] = struct{}{}
|
||||
}
|
||||
filtered := current[:0]
|
||||
for _, user := range current {
|
||||
if _, ok := removeIDs[user.ID]; ok {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, user)
|
||||
}
|
||||
current = append([]model.UserSpec(nil), filtered...)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
*users = current
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package controlplane
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/model"
|
||||
)
|
||||
|
||||
func TestNodeMailboxLatestStateAndNotifyCollapse(t *testing.T) {
|
||||
mb := NewNodeMailbox()
|
||||
cfg1 := &model.NodeSpec{Protocol: "vmess", ServerPort: 10010}
|
||||
cfg2 := &model.NodeSpec{Protocol: "vless", ServerPort: 10020}
|
||||
|
||||
mb.Apply(Event{Type: EventSyncConfig, Config: cfg1})
|
||||
mb.Apply(Event{Type: EventSyncConfig, Config: cfg2})
|
||||
|
||||
select {
|
||||
case <-mb.NotifyCh():
|
||||
default:
|
||||
t.Fatal("expected collapsed notify signal")
|
||||
}
|
||||
|
||||
mb.MarkReady()
|
||||
state := mb.DrainIfReady()
|
||||
if !state.HasConfig || state.Config == nil {
|
||||
t.Fatal("expected config state")
|
||||
}
|
||||
if state.Config.Protocol != "vless" || state.Config.ServerPort != 10020 {
|
||||
t.Fatalf("unexpected config: %+v", state.Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeMailboxFullUsersOverwriteAndDeltaBeforeBaseline(t *testing.T) {
|
||||
mb := NewNodeMailbox()
|
||||
mb.Apply(Event{
|
||||
Type: EventSyncUserDelta,
|
||||
DeltaAction: "add",
|
||||
DeltaUsers: []model.UserSpec{{ID: 1, UUID: "u1"}},
|
||||
})
|
||||
mb.MarkReady()
|
||||
state := mb.DrainIfReady()
|
||||
if !state.NeedsReconcile {
|
||||
t.Fatal("expected reconcile when delta arrives before baseline")
|
||||
}
|
||||
|
||||
mb.Apply(Event{Type: EventSyncUsers, Users: []model.UserSpec{{ID: 1, UUID: "u1"}}})
|
||||
mb.Apply(Event{Type: EventSyncUsers, Users: []model.UserSpec{{ID: 2, UUID: "u2"}}})
|
||||
state = mb.DrainIfReady()
|
||||
if !state.HasUsers {
|
||||
t.Fatal("expected users snapshot")
|
||||
}
|
||||
if len(state.Users) != 1 || state.Users[0].ID != 2 {
|
||||
t.Fatalf("unexpected users: %+v", state.Users)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeMailboxDevicesLatestWins(t *testing.T) {
|
||||
mb := NewNodeMailbox()
|
||||
mb.Apply(Event{Type: EventSyncDevices, DeviceUsers: map[int][]string{1: {"1.1.1.1"}}})
|
||||
mb.Apply(Event{Type: EventSyncDevices, DeviceUsers: map[int][]string{1: {"2.2.2.2"}}})
|
||||
mb.MarkReady()
|
||||
state := mb.DrainIfReady()
|
||||
if !state.HasDevices {
|
||||
t.Fatal("expected devices state")
|
||||
}
|
||||
if got := state.DeviceUsers[1][0]; got != "2.2.2.2" {
|
||||
t.Fatalf("unexpected device state: %v", state.DeviceUsers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeMailboxReconcileNotifies(t *testing.T) {
|
||||
mb := NewNodeMailbox()
|
||||
mb.MarkReady()
|
||||
|
||||
// Drain the MarkReady notification
|
||||
select {
|
||||
case <-mb.NotifyCh():
|
||||
default:
|
||||
}
|
||||
|
||||
// Delta without baseline should trigger reconcile AND notify
|
||||
mb.Apply(Event{
|
||||
Type: EventSyncUserDelta,
|
||||
DeltaAction: "add",
|
||||
DeltaUsers: []model.UserSpec{{ID: 1, UUID: "u1"}},
|
||||
})
|
||||
|
||||
select {
|
||||
case <-mb.NotifyCh():
|
||||
default:
|
||||
t.Fatal("expected notify on reconcile path")
|
||||
}
|
||||
|
||||
state := mb.DrainIfReady()
|
||||
if !state.NeedsReconcile {
|
||||
t.Fatal("expected NeedsReconcile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeMailboxSeedBaselineEnablesDelta(t *testing.T) {
|
||||
mb := NewNodeMailbox()
|
||||
mb.SeedBaseline([]model.UserSpec{{ID: 1, UUID: "u1"}, {ID: 2, UUID: "u2"}}, nil)
|
||||
mb.MarkReady()
|
||||
|
||||
// Drain MarkReady notify
|
||||
select {
|
||||
case <-mb.NotifyCh():
|
||||
default:
|
||||
}
|
||||
|
||||
// Delta should now apply incrementally, not trigger reconcile
|
||||
mb.Apply(Event{
|
||||
Type: EventSyncUserDelta,
|
||||
DeltaAction: "add",
|
||||
DeltaUsers: []model.UserSpec{{ID: 3, UUID: "u3"}},
|
||||
})
|
||||
|
||||
select {
|
||||
case <-mb.NotifyCh():
|
||||
default:
|
||||
t.Fatal("expected notify after delta")
|
||||
}
|
||||
|
||||
state := mb.DrainIfReady()
|
||||
if state.NeedsReconcile {
|
||||
t.Fatal("should not need reconcile after seeded baseline")
|
||||
}
|
||||
if !state.HasUsers {
|
||||
t.Fatal("expected users")
|
||||
}
|
||||
if len(state.Users) != 3 {
|
||||
t.Fatalf("expected 3 users, got %d: %+v", len(state.Users), state.Users)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeMailboxDeltaFailedApplyNotifies(t *testing.T) {
|
||||
mb := NewNodeMailbox()
|
||||
mb.SeedBaseline([]model.UserSpec{{ID: 1, UUID: "u1"}}, nil)
|
||||
mb.MarkReady()
|
||||
|
||||
// Drain MarkReady notify
|
||||
select {
|
||||
case <-mb.NotifyCh():
|
||||
default:
|
||||
}
|
||||
|
||||
// Invalid action should trigger reconcile and still notify
|
||||
mb.Apply(Event{
|
||||
Type: EventSyncUserDelta,
|
||||
DeltaAction: "invalid_action",
|
||||
DeltaUsers: []model.UserSpec{{ID: 2, UUID: "u2"}},
|
||||
})
|
||||
|
||||
select {
|
||||
case <-mb.NotifyCh():
|
||||
default:
|
||||
t.Fatal("expected notify on failed delta apply")
|
||||
}
|
||||
|
||||
state := mb.DrainIfReady()
|
||||
if !state.NeedsReconcile {
|
||||
t.Fatal("expected NeedsReconcile for invalid delta action")
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
type PanelControlPlane struct {
|
||||
cfg config.PanelConfig
|
||||
wsCfg config.WSConfig
|
||||
kcfg config.KernelConfig
|
||||
client *panel.Client
|
||||
}
|
||||
|
||||
@@ -21,10 +22,11 @@ type panelPushClient struct {
|
||||
inner *panel.WSClient
|
||||
}
|
||||
|
||||
func NewPanelControlPlane(panelCfg config.PanelConfig, wsCfg config.WSConfig) *PanelControlPlane {
|
||||
func NewPanelControlPlane(panelCfg config.PanelConfig, wsCfg config.WSConfig, kcfg config.KernelConfig) *PanelControlPlane {
|
||||
return &PanelControlPlane{
|
||||
cfg: panelCfg,
|
||||
wsCfg: wsCfg,
|
||||
kcfg: kcfg,
|
||||
client: panel.NewClient(panelCfg),
|
||||
}
|
||||
}
|
||||
@@ -58,7 +60,10 @@ func (p *PanelControlPlane) Initial(ctx context.Context, metricsFn func() map[st
|
||||
if err != nil {
|
||||
return Bootstrap{}, fmt.Errorf("initial user fetch: %w", err)
|
||||
}
|
||||
bootstrap.Config = model.NodeSpecFromPanel(configSnapshot)
|
||||
bootstrap.Config, err = model.NodeSpecFromPanelValidated(configSnapshot, p.kcfg)
|
||||
if err != nil {
|
||||
return Bootstrap{}, fmt.Errorf("initial config normalize: %w", err)
|
||||
}
|
||||
bootstrap.Users = model.UserSpecsFromPanel(users)
|
||||
return bootstrap, nil
|
||||
}
|
||||
@@ -77,7 +82,11 @@ func (p *PanelControlPlane) Poll(ctx context.Context) (Snapshot, error) {
|
||||
return Snapshot{}, ctx.Err()
|
||||
default:
|
||||
}
|
||||
return Snapshot{Config: model.NodeSpecFromPanel(configSnapshot), Users: model.UserSpecsFromPanel(users)}, nil
|
||||
nodeSpec, err := model.NodeSpecFromPanelValidated(configSnapshot, p.kcfg)
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("poll config normalize: %w", err)
|
||||
}
|
||||
return Snapshot{Config: nodeSpec, Users: model.UserSpecsFromPanel(users)}, nil
|
||||
}
|
||||
|
||||
func (p *PanelControlPlane) Discover(ctx context.Context, metricsFn func() map[string]interface{}, events chan<- Event, statuses chan<- StatusChange) (PushClient, error) {
|
||||
@@ -124,11 +133,19 @@ func (p *PanelControlPlane) newPushClient(metricsFn func() map[string]interface{
|
||||
p.cfg.NodeID,
|
||||
cfg,
|
||||
func(event panel.WSEvent) {
|
||||
translated := translateWSEvent(event)
|
||||
translated, err := TranslateWSEvent(event, p.kcfg)
|
||||
if err != nil {
|
||||
nlog.Core().Warn("invalid ws event config, dropping event", "type", event.Type, "error", err)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case events <- translated:
|
||||
default:
|
||||
nlog.Core().Warn("ws event channel full, dropping event", "type", translated.Type)
|
||||
select {
|
||||
case statuses <- StatusChange{Connected: true, NeedsResync: true}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
},
|
||||
func(status panel.WSStatusChange) {
|
||||
@@ -142,10 +159,14 @@ func (p *PanelControlPlane) newPushClient(metricsFn func() map[string]interface{
|
||||
return &panelPushClient{inner: inner}
|
||||
}
|
||||
|
||||
func translateWSEvent(event panel.WSEvent) Event {
|
||||
func TranslateWSEvent(event panel.WSEvent, kcfg config.KernelConfig) (Event, error) {
|
||||
translated := Event{Type: EventType(event.Type), DeltaAction: event.DeltaAction, DeviceUsers: event.DeviceUsers}
|
||||
if event.Config != nil {
|
||||
translated.Config = model.NodeSpecFromPanel(event.Config)
|
||||
var err error
|
||||
translated.Config, err = model.NodeSpecFromPanelValidated(event.Config, kcfg)
|
||||
if err != nil {
|
||||
return Event{}, fmt.Errorf("translate node config: %w", err)
|
||||
}
|
||||
}
|
||||
if event.Users != nil {
|
||||
translated.Users = model.UserSpecsFromPanel(event.Users)
|
||||
@@ -153,9 +174,11 @@ func translateWSEvent(event panel.WSEvent) Event {
|
||||
if event.DeltaUsers != nil {
|
||||
translated.DeltaUsers = model.UserSpecsFromPanel(event.DeltaUsers)
|
||||
}
|
||||
return translated
|
||||
return translated, nil
|
||||
}
|
||||
|
||||
func (p *panelPushClient) Run(ctx context.Context) { p.inner.Run(ctx) }
|
||||
func (p *panelPushClient) IsConnected() bool { return p.inner.IsConnected() }
|
||||
func (p *panelPushClient) SendDeviceReport(devices map[int][]string) { p.inner.SendDeviceReport(devices) }
|
||||
func (p *panelPushClient) IsConnected() bool { return p.inner.IsConnected() }
|
||||
func (p *panelPushClient) SendDeviceReport(devices map[int][]string) {
|
||||
p.inner.SendDeviceReport(devices)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package controlplane
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
panelapi "github.com/cedar2025/xboard-node/internal/panel"
|
||||
)
|
||||
|
||||
func TestPanelControlPlaneInitialRejectsInvalidCustomOutbounds(t *testing.T) {
|
||||
server := newPanelTestServer(`{"protocol":"shadowsocks","server_port":8388,"custom_outbounds":[{"tag":"proxy","protocol":"socks","proxy_tag":"missing","settings":{"server":"2.2.2.2","server_port":1080}}]}`)
|
||||
defer server.Close()
|
||||
|
||||
cp := NewPanelControlPlane(config.PanelConfig{URL: server.URL, Token: "token", NodeID: 1}, config.WSConfig{}, config.KernelConfig{Type: "singbox"})
|
||||
_, err := cp.Initial(context.Background(), nil, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `initial config normalize: validate custom outbounds: custom_outbounds[0].proxy_tag references unknown outbound "missing"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanelControlPlanePollRejectsInvalidCustomOutbounds(t *testing.T) {
|
||||
server := newPanelTestServer(`{"protocol":"shadowsocks","server_port":8388,"custom_outbounds":[{"tag":"proxy","protocol":"socks","proxy_tag":"missing","settings":{"server":"2.2.2.2","server_port":1080}}]}`)
|
||||
defer server.Close()
|
||||
|
||||
cp := NewPanelControlPlane(config.PanelConfig{URL: server.URL, Token: "token", NodeID: 1}, config.WSConfig{}, config.KernelConfig{Type: "singbox"})
|
||||
_, err := cp.Poll(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `poll config normalize: validate custom outbounds: custom_outbounds[0].proxy_tag references unknown outbound "missing"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateWSEventRejectsInvalidCustomOutbounds(t *testing.T) {
|
||||
_, err := TranslateWSEvent(panelapi.WSEvent{
|
||||
Type: panelapi.WSEventSyncConfig,
|
||||
Config: &panelapi.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 8388,
|
||||
CustomOutbounds: []panelapi.OutboundConfig{
|
||||
{Tag: "proxy", Protocol: "socks", ProxyTag: "missing", Settings: map[string]any{"server": "2.2.2.2", "server_port": 1080}},
|
||||
},
|
||||
},
|
||||
}, config.KernelConfig{Type: "singbox"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `translate node config: validate custom outbounds: custom_outbounds[0].proxy_tag references unknown outbound "missing"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newPanelTestServer(configBody string) *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v2/server/handshake", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"websocket":{"enabled":false},"settings":{"push_interval":60,"pull_interval":60}}`))
|
||||
})
|
||||
mux.HandleFunc("/api/v1/server/UniProxy/config", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(configBody))
|
||||
})
|
||||
mux.HandleFunc("/api/v1/server/UniProxy/user", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"users":[{"id":1,"uuid":"11111111-1111-1111-1111-111111111111"}]}`))
|
||||
})
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
func TestTranslateWSEventRejectsUnsupportedProtocolForKernel(t *testing.T) {
|
||||
_, err := TranslateWSEvent(panelapi.WSEvent{
|
||||
Type: panelapi.WSEventSyncConfig,
|
||||
Config: &panelapi.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 8388,
|
||||
CustomOutbounds: []panelapi.OutboundConfig{
|
||||
{Tag: "hy2", Protocol: "hysteria2", Settings: map[string]any{"server": "2.2.2.2", "server_port": 8443}},
|
||||
},
|
||||
},
|
||||
}, config.KernelConfig{Type: "xray"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `translate node config: validate custom outbounds: custom_outbounds[0].protocol "hysteria2" is not supported by kernel "xray"`) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,8 @@ type Event struct {
|
||||
}
|
||||
|
||||
type StatusChange struct {
|
||||
Connected bool
|
||||
Connected bool
|
||||
NeedsResync bool
|
||||
}
|
||||
|
||||
type APIMetrics struct {
|
||||
@@ -39,6 +40,7 @@ type Bootstrap struct {
|
||||
Push PushClient
|
||||
Config *model.NodeSpec
|
||||
Users []model.UserSpec
|
||||
Mailbox *NodeMailbox
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
|
||||
Reference in New Issue
Block a user