feat: add standalone controlplane support
This commit is contained in:
+21
-13
@@ -21,6 +21,8 @@ type Config struct {
|
||||
Log LogConfig `yaml:"log"`
|
||||
Runtime RuntimeConfig `yaml:"runtime"`
|
||||
WS WSConfig `yaml:"ws"`
|
||||
// Standalone enables a local-only node that never contacts the panel.
|
||||
Standalone *StandaloneConfig `yaml:"standalone,omitempty"`
|
||||
// HealthPort enables a lightweight HTTP health-check endpoint on the
|
||||
// given port (e.g. 65530). 0 = disabled (default).
|
||||
HealthPort int `yaml:"health_port"`
|
||||
@@ -278,19 +280,25 @@ func (c *Config) setDefaults() {
|
||||
}
|
||||
|
||||
func (c *Config) validate() error {
|
||||
if c.Panel.URL == "" {
|
||||
return fmt.Errorf("panel.url is required")
|
||||
}
|
||||
if c.Panel.Token == "" {
|
||||
return fmt.Errorf("panel.token is required")
|
||||
}
|
||||
// In multi-node mode panel.node_id is optional; validate each NodeEntry instead.
|
||||
if len(c.Nodes) == 0 && c.Panel.NodeID <= 0 {
|
||||
return fmt.Errorf("panel.node_id must be positive (or use 'nodes:' for multi-node)")
|
||||
}
|
||||
for i, n := range c.Nodes {
|
||||
if n.NodeID <= 0 {
|
||||
return fmt.Errorf("nodes[%d].node_id must be positive", i)
|
||||
if c.IsStandalone() {
|
||||
if err := c.validateStandalone(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if c.Panel.URL == "" {
|
||||
return fmt.Errorf("panel.url is required")
|
||||
}
|
||||
if c.Panel.Token == "" {
|
||||
return fmt.Errorf("panel.token is required")
|
||||
}
|
||||
// In multi-node mode panel.node_id is optional; validate each NodeEntry instead.
|
||||
if len(c.Nodes) == 0 && c.Panel.NodeID <= 0 {
|
||||
return fmt.Errorf("panel.node_id must be positive (or use 'nodes:' for multi-node)")
|
||||
}
|
||||
for i, n := range c.Nodes {
|
||||
if n.NodeID <= 0 {
|
||||
return fmt.Errorf("nodes[%d].node_id must be positive", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
switch c.Kernel.Type {
|
||||
|
||||
@@ -267,3 +267,59 @@ node:
|
||||
t.Errorf("pull_interval: got %d", cfg.Node.PullInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_StandaloneConfig(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
standalone:
|
||||
enabled: true
|
||||
node:
|
||||
protocol: "vless"
|
||||
server_port: 8443
|
||||
network: "ws"
|
||||
tls: 1
|
||||
network_settings:
|
||||
path: "/ws"
|
||||
users:
|
||||
- id: 1
|
||||
uuid: "11111111-1111-1111-1111-111111111111"
|
||||
kernel:
|
||||
type: xray
|
||||
cert:
|
||||
cert_mode: none
|
||||
`)
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if !cfg.IsStandalone() {
|
||||
t.Fatal("expected standalone mode to be enabled")
|
||||
}
|
||||
if cfg.Standalone.Node.Protocol != "vless" {
|
||||
t.Errorf("standalone.node.protocol: got %q", cfg.Standalone.Node.Protocol)
|
||||
}
|
||||
if cfg.Standalone.Node.ServerPort != 8443 {
|
||||
t.Errorf("standalone.node.server_port: got %d", cfg.Standalone.Node.ServerPort)
|
||||
}
|
||||
if len(cfg.Standalone.Users) != 1 {
|
||||
t.Fatalf("standalone.users: got %d", len(cfg.Standalone.Users))
|
||||
}
|
||||
if cfg.Standalone.Users[0].UUID != "11111111-1111-1111-1111-111111111111" {
|
||||
t.Errorf("standalone.users[0].uuid: got %q", cfg.Standalone.Users[0].UUID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_StandaloneRequiresUsers(t *testing.T) {
|
||||
path := writeTemp(t, `
|
||||
standalone:
|
||||
enabled: true
|
||||
node:
|
||||
protocol: "trojan"
|
||||
server_port: 443
|
||||
kernel:
|
||||
type: singbox
|
||||
`)
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for standalone mode without users")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package config
|
||||
|
||||
import "fmt"
|
||||
|
||||
// StandaloneConfig describes a locally-managed node that runs without panel
|
||||
// handshake, polling, reporting, or WebSocket sync.
|
||||
type StandaloneConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Node StandaloneNodeConfig `yaml:"node"`
|
||||
Users []StandaloneUser `yaml:"users"`
|
||||
}
|
||||
|
||||
// StandaloneNodeConfig captures the subset of panel node config that is needed
|
||||
// to generate a working local inbound.
|
||||
type StandaloneNodeConfig struct {
|
||||
Protocol string `yaml:"protocol"`
|
||||
ListenIP string `yaml:"listen_ip,omitempty"`
|
||||
|
||||
ServerPort int `yaml:"server_port"`
|
||||
Network string `yaml:"network,omitempty"`
|
||||
|
||||
NetworkSettings map[string]any `yaml:"network_settings,omitempty"`
|
||||
Routes []StandaloneRouteRule `yaml:"routes,omitempty"`
|
||||
|
||||
Cipher string `yaml:"cipher,omitempty"`
|
||||
Plugin string `yaml:"plugin,omitempty"`
|
||||
PluginOpt string `yaml:"plugin_opts,omitempty"`
|
||||
ServerKey string `yaml:"server_key,omitempty"`
|
||||
|
||||
TLS int `yaml:"tls,omitempty"`
|
||||
Flow string `yaml:"flow,omitempty"`
|
||||
Decryption string `yaml:"decryption,omitempty"`
|
||||
TLSSettings map[string]any `yaml:"tls_settings,omitempty"`
|
||||
|
||||
Host string `yaml:"host,omitempty"`
|
||||
ServerName string `yaml:"server_name,omitempty"`
|
||||
|
||||
Version int `yaml:"version,omitempty"`
|
||||
UpMbps int `yaml:"up_mbps,omitempty"`
|
||||
DownMbps int `yaml:"down_mbps,omitempty"`
|
||||
Obfs string `yaml:"obfs,omitempty"`
|
||||
ObfsPassword string `yaml:"obfs_password,omitempty"`
|
||||
|
||||
CongestionControl string `yaml:"congestion_control,omitempty"`
|
||||
PaddingScheme string `yaml:"padding_scheme,omitempty"`
|
||||
Transport string `yaml:"transport,omitempty"`
|
||||
TrafficPattern string `yaml:"traffic_pattern,omitempty"`
|
||||
|
||||
Multiplex *StandaloneMultiplexConfig `yaml:"multiplex,omitempty"`
|
||||
AcceptProxyProtocol bool `yaml:"accept_proxy_protocol,omitempty"`
|
||||
}
|
||||
|
||||
type StandaloneRouteRule struct {
|
||||
ID int `yaml:"id,omitempty"`
|
||||
Match []string `yaml:"match,omitempty"`
|
||||
Action string `yaml:"action,omitempty"`
|
||||
ActionValue string `yaml:"action_value,omitempty"`
|
||||
}
|
||||
|
||||
type StandaloneMultiplexConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Protocol string `yaml:"protocol,omitempty"`
|
||||
MaxConnections int `yaml:"max_connections,omitempty"`
|
||||
MinStreams int `yaml:"min_streams,omitempty"`
|
||||
MaxStreams int `yaml:"max_streams,omitempty"`
|
||||
Padding bool `yaml:"padding,omitempty"`
|
||||
Brutal *StandaloneBrutalConfig `yaml:"brutal,omitempty"`
|
||||
}
|
||||
|
||||
type StandaloneBrutalConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
UpMbps int `yaml:"up_mbps,omitempty"`
|
||||
DownMbps int `yaml:"down_mbps,omitempty"`
|
||||
}
|
||||
|
||||
type StandaloneUser struct {
|
||||
ID int `yaml:"id"`
|
||||
UUID string `yaml:"uuid"`
|
||||
SpeedLimit int `yaml:"speed_limit,omitempty"`
|
||||
DeviceLimit int `yaml:"device_limit,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Config) IsStandalone() bool {
|
||||
return c.Standalone != nil && c.Standalone.Enabled
|
||||
}
|
||||
|
||||
func (c *Config) validateStandalone() error {
|
||||
if c.Standalone == nil {
|
||||
return fmt.Errorf("standalone config is required when standalone mode is enabled")
|
||||
}
|
||||
if len(c.Nodes) > 0 {
|
||||
return fmt.Errorf("standalone mode does not support top-level 'nodes'")
|
||||
}
|
||||
|
||||
node := c.Standalone.Node
|
||||
if node.Protocol == "" {
|
||||
return fmt.Errorf("standalone.node.protocol is required")
|
||||
}
|
||||
if node.ServerPort <= 0 || node.ServerPort > 65535 {
|
||||
return fmt.Errorf("standalone.node.server_port must be between 1 and 65535")
|
||||
}
|
||||
if node.TLS < 0 || node.TLS > 2 {
|
||||
return fmt.Errorf("standalone.node.tls must be 0 (off), 1 (tls), or 2 (reality)")
|
||||
}
|
||||
if len(c.Standalone.Users) == 0 {
|
||||
return fmt.Errorf("standalone.users must not be empty")
|
||||
}
|
||||
|
||||
ids := make(map[int]struct{}, len(c.Standalone.Users))
|
||||
uuids := make(map[string]struct{}, len(c.Standalone.Users))
|
||||
for i, user := range c.Standalone.Users {
|
||||
if user.ID <= 0 {
|
||||
return fmt.Errorf("standalone.users[%d].id must be positive", i)
|
||||
}
|
||||
if user.UUID == "" {
|
||||
return fmt.Errorf("standalone.users[%d].uuid is required", i)
|
||||
}
|
||||
if _, exists := ids[user.ID]; exists {
|
||||
return fmt.Errorf("standalone.users[%d].id duplicates %d", i, user.ID)
|
||||
}
|
||||
if _, exists := uuids[user.UUID]; exists {
|
||||
return fmt.Errorf("standalone.users[%d].uuid duplicates %q", i, user.UUID)
|
||||
}
|
||||
ids[user.ID] = struct{}{}
|
||||
uuids[user.UUID] = struct{}{}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user