Skip to content

Commit 4a35bf9

Browse files
committed
feat: add cloudflare wireguard outbound support
1 parent 5374c28 commit 4a35bf9

22 files changed

Lines changed: 2127 additions & 24 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
<img src="./logo.svg" alt="SingBox Proxy Manager Logo" width="96" />
66

7-
![Version](https://img.shields.io/badge/version-1.3.12-blue.svg)
7+
![Version](https://img.shields.io/badge/version-1.3.16-blue.svg)
88
![License](https://img.shields.io/badge/license-MIT-green.svg)
99
![SingBox](https://img.shields.io/badge/sing--box-1.12.12-orange.svg)
1010

backend/api/handlers_test.go

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,252 @@ func TestBatchImportNodesAutoAssignSkipsUsedInboundPorts(t *testing.T) {
481481
}
482482
}
483483

484+
func TestCreateNodeWireGuardPersistsConfig(t *testing.T) {
485+
gin.SetMode(gin.TestMode)
486+
handler := newTestHandler(t, func(proxyAddr, username, password string) (*services.IPInfo, error) {
487+
return nil, fmt.Errorf("not used")
488+
})
489+
490+
payload := map[string]interface{}{
491+
"name": "warp-node",
492+
"remark": "cf",
493+
"type": "wireguard",
494+
"enabled": true,
495+
"config": `{
496+
"server":"engage.cloudflareclient.com",
497+
"server_port":2408,
498+
"local_address":["172.16.0.2/32","2606:4700:110:8765::2/128"],
499+
"private_key":"private-key",
500+
"peer_public_key":"peer-public-key",
501+
"allowed_ips":["0.0.0.0/0","::/0"],
502+
"reserved":[162,104,222],
503+
"detour":"warp-selector",
504+
"domain_resolver":"local",
505+
"domain_resolver_strategy":"prefer_ipv4",
506+
"udp_fragment":true,
507+
"connect_timeout":"5s"
508+
}`,
509+
}
510+
body, _ := json.Marshal(payload)
511+
512+
rec := httptest.NewRecorder()
513+
ctx, _ := gin.CreateTestContext(rec)
514+
req, _ := http.NewRequest(http.MethodPost, "/api/nodes", bytes.NewReader(body))
515+
req.Header.Set("Content-Type", "application/json")
516+
ctx.Request = req
517+
518+
handler.CreateNode(ctx)
519+
if rec.Code != http.StatusCreated {
520+
t.Fatalf("unexpected status %d body=%s", rec.Code, rec.Body.String())
521+
}
522+
523+
var (
524+
nodeType string
525+
configJSON string
526+
)
527+
if err := handler.db.QueryRow(
528+
"SELECT type, config FROM proxy_nodes WHERE name = ?",
529+
"warp-node",
530+
).Scan(&nodeType, &configJSON); err != nil {
531+
t.Fatalf("query node: %v", err)
532+
}
533+
if nodeType != "wireguard" {
534+
t.Fatalf("expected wireguard type, got %q", nodeType)
535+
}
536+
537+
var cfg models.WireGuardConfig
538+
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
539+
t.Fatalf("unmarshal config: %v", err)
540+
}
541+
if cfg.Server != "engage.cloudflareclient.com" || cfg.ServerPort != 2408 {
542+
t.Fatalf("unexpected endpoint: %+v", cfg)
543+
}
544+
if len(cfg.LocalAddress) != 2 || cfg.LocalAddress[1] != "2606:4700:110:8765::2/128" {
545+
t.Fatalf("unexpected local_address: %+v", cfg.LocalAddress)
546+
}
547+
if cfg.PeerPublicKey != "peer-public-key" || cfg.Detour != "warp-selector" {
548+
t.Fatalf("unexpected wireguard config: %+v", cfg)
549+
}
550+
if cfg.UDPFragment == nil || !*cfg.UDPFragment {
551+
t.Fatalf("expected udp_fragment=true, got %+v", cfg.UDPFragment)
552+
}
553+
}
554+
555+
func TestParseShareLinkWireGuard(t *testing.T) {
556+
gin.SetMode(gin.TestMode)
557+
handler := newTestHandler(t, func(proxyAddr, username, password string) (*services.IPInfo, error) {
558+
return nil, fmt.Errorf("not used")
559+
})
560+
561+
link := "wireguard://private-key@engage.cloudflareclient.com:2408?publickey=peer-public-key&ip=172.16.0.2/32&ipv6=2606:4700:110:8765::2/128&allowedips=0.0.0.0/0,::/0&reserved=162,104,222&mtu=1280&workers=2&detour=warp-selector&domain_resolver=local&domain_resolver_strategy=prefer_ipv4&udp_fragment=1#WARP"
562+
body := bytes.NewBufferString(fmt.Sprintf(`{"link":%q}`, link))
563+
564+
rec := httptest.NewRecorder()
565+
ctx, _ := gin.CreateTestContext(rec)
566+
req, _ := http.NewRequest(http.MethodPost, "/api/parse-link", body)
567+
req.Header.Set("Content-Type", "application/json")
568+
ctx.Request = req
569+
570+
handler.ParseShareLink(ctx)
571+
if rec.Code != http.StatusOK {
572+
t.Fatalf("unexpected status %d body=%s", rec.Code, rec.Body.String())
573+
}
574+
575+
var resp struct {
576+
Type string `json:"type"`
577+
Name string `json:"name"`
578+
Config string `json:"config"`
579+
}
580+
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
581+
t.Fatalf("unmarshal response: %v", err)
582+
}
583+
if resp.Type != "wireguard" || resp.Name != "WARP" {
584+
t.Fatalf("unexpected response: %+v", resp)
585+
}
586+
587+
var cfg models.WireGuardConfig
588+
if err := json.Unmarshal([]byte(resp.Config), &cfg); err != nil {
589+
t.Fatalf("unmarshal config: %v", err)
590+
}
591+
if cfg.PrivateKey != "private-key" || cfg.Workers != 2 || cfg.MTU != 1280 {
592+
t.Fatalf("unexpected parsed config: %+v", cfg)
593+
}
594+
if len(cfg.Reserved) != 3 || cfg.Reserved[0] != 162 {
595+
t.Fatalf("unexpected reserved bytes: %+v", cfg.Reserved)
596+
}
597+
}
598+
599+
func TestReplaceNodeWithWireGuardLinkClearsIPAndPreservesNameWhenRequested(t *testing.T) {
600+
gin.SetMode(gin.TestMode)
601+
handler := newTestHandler(t, func(proxyAddr, username, password string) (*services.IPInfo, error) {
602+
return nil, fmt.Errorf("not used")
603+
})
604+
605+
nodeID := insertTestNode(t, handler.db)
606+
if _, err := handler.db.Exec(`
607+
UPDATE proxy_nodes
608+
SET name = 'old-name', node_ip = '8.8.8.8', location = 'Old', country_code = 'US', latency = 99
609+
WHERE id = ?
610+
`, nodeID); err != nil {
611+
t.Fatalf("seed node: %v", err)
612+
}
613+
614+
link := "wireguard://private-key@engage.cloudflareclient.com:2408?publickey=peer-public-key&ip=172.16.0.2/32&ipv6=2606:4700:110:8765::2/128&allowedips=0.0.0.0/0,::/0&reserved=162,104,222#WARP"
615+
body := bytes.NewBufferString(fmt.Sprintf(`{"link":%q,"update_name":false}`, link))
616+
617+
rec := httptest.NewRecorder()
618+
ctx, _ := gin.CreateTestContext(rec)
619+
ctx.Params = gin.Params{gin.Param{Key: "id", Value: strconv.Itoa(nodeID)}}
620+
req, _ := http.NewRequest(http.MethodPut, "/api/nodes/"+strconv.Itoa(nodeID)+"/replace", body)
621+
req.Header.Set("Content-Type", "application/json")
622+
ctx.Request = req
623+
624+
handler.ReplaceNode(ctx)
625+
if rec.Code != http.StatusOK {
626+
t.Fatalf("unexpected status %d body=%s", rec.Code, rec.Body.String())
627+
}
628+
629+
var (
630+
name string
631+
nodeType string
632+
configJSON string
633+
nodeIP string
634+
location string
635+
countryCode string
636+
latency int
637+
)
638+
if err := handler.db.QueryRow(`
639+
SELECT name, type, config, node_ip, location, country_code, latency
640+
FROM proxy_nodes WHERE id = ?
641+
`, nodeID).Scan(&name, &nodeType, &configJSON, &nodeIP, &location, &countryCode, &latency); err != nil {
642+
t.Fatalf("query node: %v", err)
643+
}
644+
if name != "old-name" || nodeType != "wireguard" {
645+
t.Fatalf("unexpected node identity: name=%q type=%q", name, nodeType)
646+
}
647+
if nodeIP != "" || location != "" || countryCode != "" || latency != 0 {
648+
t.Fatalf("expected IP status cleared, got ip=%q location=%q country=%q latency=%d", nodeIP, location, countryCode, latency)
649+
}
650+
651+
var cfg models.WireGuardConfig
652+
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
653+
t.Fatalf("unmarshal config: %v", err)
654+
}
655+
if cfg.Server != "engage.cloudflareclient.com" || cfg.PeerPublicKey != "peer-public-key" {
656+
t.Fatalf("unexpected replaced config: %+v", cfg)
657+
}
658+
}
659+
660+
func TestBatchImportNodesAcceptsWireGuardYAMLContent(t *testing.T) {
661+
gin.SetMode(gin.TestMode)
662+
handler := newTestHandler(t, func(proxyAddr, username, password string) (*services.IPInfo, error) {
663+
return nil, fmt.Errorf("not used")
664+
})
665+
666+
yaml := `
667+
proxies:
668+
- name: "WARP"
669+
type: wireguard
670+
server: engage.cloudflareclient.com
671+
port: 2408
672+
ip: 172.16.0.2
673+
ipv6: "2606:4700:110:8765::2"
674+
private-key: private-key
675+
public-key: peer-public-key
676+
allowed-ips: ["0.0.0.0/0", "::/0"]
677+
reserved: [162, 104, 222]
678+
mtu: 1280
679+
udp: true
680+
dialer-proxy: warp-selector
681+
`
682+
payload := map[string]interface{}{
683+
"content": yaml,
684+
"enabled": true,
685+
}
686+
body, _ := json.Marshal(payload)
687+
688+
rec := httptest.NewRecorder()
689+
ctx, _ := gin.CreateTestContext(rec)
690+
req, _ := http.NewRequest(http.MethodPost, "/api/nodes/batch-import", bytes.NewReader(body))
691+
req.Header.Set("Content-Type", "application/json")
692+
ctx.Request = req
693+
694+
handler.BatchImportNodes(ctx)
695+
if rec.Code != http.StatusOK {
696+
t.Fatalf("unexpected status %d body=%s", rec.Code, rec.Body.String())
697+
}
698+
699+
var resp struct {
700+
Success int `json:"success"`
701+
Failed int `json:"failed"`
702+
}
703+
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
704+
t.Fatalf("unmarshal response: %v", err)
705+
}
706+
if resp.Success != 1 || resp.Failed != 0 {
707+
t.Fatalf("unexpected import response: %+v", resp)
708+
}
709+
710+
var configJSON string
711+
if err := handler.db.QueryRow(
712+
"SELECT config FROM proxy_nodes WHERE name = ?",
713+
"WARP",
714+
).Scan(&configJSON); err != nil {
715+
t.Fatalf("query node: %v", err)
716+
}
717+
718+
var cfg models.WireGuardConfig
719+
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
720+
t.Fatalf("unmarshal config: %v", err)
721+
}
722+
if cfg.Network != "udp" || cfg.Detour != "warp-selector" {
723+
t.Fatalf("unexpected batch-import config: %+v", cfg)
724+
}
725+
if len(cfg.LocalAddress) != 2 || cfg.LocalAddress[0] != "172.16.0.2/32" {
726+
t.Fatalf("unexpected local_address: %+v", cfg.LocalAddress)
727+
}
728+
}
729+
484730
func TestReorderNodesPreserveInboundPortsKeepsPorts(t *testing.T) {
485731
gin.SetMode(gin.TestMode)
486732
handler := newTestHandler(t, func(proxyAddr, username, password string) (*services.IPInfo, error) {

backend/models/proxy.go

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ type ProxyNode struct {
1717
ID int `json:"id"`
1818
Name string `json:"name"`
1919
Remark string `json:"remark"`
20-
Type string `json:"type"` // ss, vless, vmess, hy2, tuic, trojan, anytls, socks5, http, direct
20+
Type string `json:"type"` // ss, vless, vmess, hy2, tuic, trojan, anytls, socks5, http, wireguard, direct
2121
Config string `json:"config"` // JSON string of protocol-specific config
2222
InboundPort int `json:"inbound_port"`
2323
Username string `json:"username"`
@@ -221,6 +221,43 @@ type HTTPProxyConfig struct {
221221
SNI string `json:"sni,omitempty"`
222222
}
223223

224+
// WireGuardPeerConfig represents a single peer configuration for WireGuard.
225+
type WireGuardPeerConfig struct {
226+
Server string `json:"server,omitempty"`
227+
ServerPort int `json:"server_port,omitempty"`
228+
PublicKey string `json:"public_key"`
229+
PreSharedKey string `json:"pre_shared_key,omitempty"`
230+
AllowedIPs []string `json:"allowed_ips,omitempty"`
231+
Reserved []uint8 `json:"reserved,omitempty"`
232+
}
233+
234+
// WireGuardConfig represents sing-box wireguard outbound configuration.
235+
// The structure keeps a small set of app-level compatibility fields
236+
// (allowed_ips, domain_resolver_strategy) that are converted when generating
237+
// the final sing-box config.
238+
type WireGuardConfig struct {
239+
Server string `json:"server,omitempty"`
240+
ServerPort int `json:"server_port,omitempty"`
241+
SystemInterface bool `json:"system_interface,omitempty"`
242+
InterfaceName string `json:"interface_name,omitempty"`
243+
LocalAddress []string `json:"local_address"`
244+
PrivateKey string `json:"private_key"`
245+
PeerPublicKey string `json:"peer_public_key,omitempty"`
246+
PreSharedKey string `json:"pre_shared_key,omitempty"`
247+
AllowedIPs []string `json:"allowed_ips,omitempty"`
248+
Reserved []uint8 `json:"reserved,omitempty"`
249+
Workers int `json:"workers,omitempty"`
250+
MTU int `json:"mtu,omitempty"`
251+
Network string `json:"network,omitempty"`
252+
Detour string `json:"detour,omitempty"`
253+
DomainResolver string `json:"domain_resolver,omitempty"`
254+
DomainResolverStrategy string `json:"domain_resolver_strategy,omitempty"`
255+
RoutingMark string `json:"routing_mark,omitempty"`
256+
UDPFragment *bool `json:"udp_fragment,omitempty"`
257+
ConnectTimeout string `json:"connect_timeout,omitempty"`
258+
Peers []WireGuardPeerConfig `json:"peers,omitempty"`
259+
}
260+
224261
// Settings represents global settings
225262
type Settings struct {
226263
ID int `json:"id"`
@@ -466,6 +503,8 @@ func (p *ProxyNode) ParseConfig() (interface{}, error) {
466503
config = &SOCKS5Config{}
467504
case "http":
468505
config = &HTTPProxyConfig{}
506+
case "wireguard":
507+
config = &WireGuardConfig{}
469508
default:
470509
return nil, nil
471510
}

backend/models/proxy_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package models
22

33
import (
44
"database/sql"
5+
"encoding/json"
56
"testing"
67
"time"
78

@@ -134,3 +135,44 @@ func TestInitDBProxyNodeTCPReuseEnabledDefaultsToTrue(t *testing.T) {
134135
t.Fatalf("expected tcp_reuse_enabled default to 1, got %d", tcpReuseEnabled)
135136
}
136137
}
138+
139+
func TestProxyNodeParseConfigWireGuard(t *testing.T) {
140+
rawConfig := WireGuardConfig{
141+
Server: "engage.cloudflareclient.com",
142+
ServerPort: 2408,
143+
LocalAddress: []string{"172.16.0.2/32", "2606:4700:110:8765::2/128"},
144+
PrivateKey: "private-key",
145+
PeerPublicKey: "peer-public-key",
146+
AllowedIPs: []string{"0.0.0.0/0", "::/0"},
147+
Reserved: []uint8{162, 104, 222},
148+
DomainResolver: "local",
149+
}
150+
configJSON, err := json.Marshal(rawConfig)
151+
if err != nil {
152+
t.Fatalf("marshal config: %v", err)
153+
}
154+
155+
node := ProxyNode{
156+
Type: "wireguard",
157+
Config: string(configJSON),
158+
}
159+
160+
parsed, err := node.ParseConfig()
161+
if err != nil {
162+
t.Fatalf("ParseConfig failed: %v", err)
163+
}
164+
165+
cfg, ok := parsed.(*WireGuardConfig)
166+
if !ok {
167+
t.Fatalf("unexpected config type: %T", parsed)
168+
}
169+
if cfg.Server != rawConfig.Server || cfg.ServerPort != rawConfig.ServerPort {
170+
t.Fatalf("unexpected endpoint: %+v", cfg)
171+
}
172+
if len(cfg.LocalAddress) != 2 || cfg.LocalAddress[0] != "172.16.0.2/32" {
173+
t.Fatalf("unexpected local addresses: %+v", cfg.LocalAddress)
174+
}
175+
if len(cfg.Reserved) != 3 || cfg.Reserved[0] != 162 || cfg.Reserved[2] != 222 {
176+
t.Fatalf("unexpected reserved: %+v", cfg.Reserved)
177+
}
178+
}

0 commit comments

Comments
 (0)