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
+6 -1
View File
@@ -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
}
+145
View File
@@ -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}
}
+261
View File
@@ -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
}
+164
View File
@@ -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")
}
}
+32 -9
View File
@@ -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)
}
+95
View File
@@ -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)
}
}
+3 -1
View File
@@ -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 {