init: 1.0.0

This commit is contained in:
Chang lue Tsen
2025-04-25 12:08:29 +09:00
commit 8addcc584b
1031 changed files with 76472 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
package adapter
import (
"github.com/perfect-panel/ppanel-server/internal/model/server"
"github.com/perfect-panel/ppanel-server/pkg/adapter/clash"
"github.com/perfect-panel/ppanel-server/pkg/adapter/general"
"github.com/perfect-panel/ppanel-server/pkg/adapter/loon"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
"github.com/perfect-panel/ppanel-server/pkg/adapter/quantumultx"
"github.com/perfect-panel/ppanel-server/pkg/adapter/shadowrocket"
"github.com/perfect-panel/ppanel-server/pkg/adapter/singbox"
"github.com/perfect-panel/ppanel-server/pkg/adapter/surfboard"
)
type Adapter struct {
proxy.Adapter
}
func NewAdapter(nodes []*server.Server, rules []*server.RuleGroup) *Adapter {
// 转换服务器列表
proxies := adapterProxies(nodes)
// 生成代理组
proxyGroup, region := generateProxyGroup(proxies)
// 转换规则组
g, r := adapterRules(rules)
// 加入兜底节点
for i, group := range g {
if len(group.Proxies) == 0 {
g[i].Proxies = append([]string{"DIRECT"}, region...)
}
}
// 合并代理组
proxyGroup = append(proxyGroup, g...)
return &Adapter{
Adapter: proxy.Adapter{
Proxies: proxies,
Group: proxyGroup,
Rules: r,
Region: region,
},
}
}
func (m *Adapter) BuildClash(uuid string) ([]byte, error) {
client := clash.NewClash(m.Adapter)
return client.Build(uuid)
}
func (m *Adapter) BuildGeneral(uuid string) []byte {
return general.GenerateBase64General(m.Proxies, uuid)
}
func (m *Adapter) BuildLoon(uuid string) []byte {
return loon.BuildLoon(m.Proxies, uuid)
}
func (m *Adapter) BuildQuantumultX(uuid string) string {
return quantumultx.BuildQuantumultX(m.Proxies, uuid)
}
func (m *Adapter) BuildSingbox(uuid string) ([]byte, error) {
return singbox.BuildSingbox(m.Adapter, uuid)
}
func (m *Adapter) BuildShadowrocket(uuid string, userInfo shadowrocket.UserInfo) []byte {
return shadowrocket.BuildShadowrocket(m.Proxies, uuid, userInfo)
}
func (m *Adapter) BuildSurfboard(siteName string, user surfboard.UserInfo) []byte {
return surfboard.BuildSurfboard(m.Adapter, siteName, user)
}
+138
View File
@@ -0,0 +1,138 @@
package adapter
import (
"encoding/json"
"fmt"
"testing"
"time"
"github.com/perfect-panel/ppanel-server/internal/model/server"
"github.com/perfect-panel/ppanel-server/pkg/adapter/surfboard"
)
func createTestServer() []*server.Server {
c := server.Shadowsocks{
Method: "aes-256-gcm",
Port: 10301,
ServerKey: "",
}
data, _ := json.Marshal(c)
relays := creatRelayNode()
relay, _ := json.Marshal(relays)
enable := true
// 创建一个测试用的服务器列表
return []*server.Server{
{
Id: 1,
Name: "Test Server 1",
Tags: "",
Country: "CN",
City: "",
Latitude: "",
Longitude: "",
ServerAddr: "test1.example.com",
RelayMode: "random",
RelayNode: string(relay),
SpeedLimit: 0,
TrafficRatio: 0,
GroupId: 0,
Protocol: "shadowsocks",
Config: string(data),
Enable: &enable,
Sort: 0,
},
}
}
func creatRelayNode() []*server.NodeRelay {
var nodes []*server.NodeRelay
for i := 0; i < 10; i++ {
port := 10301 + i
c := server.NodeRelay{
Host: fmt.Sprintf("192.168.1.%d", i),
Port: port,
Prefix: fmt.Sprintf("relay-%d", i),
}
nodes = append(nodes, &c)
}
return nodes
}
func TestNewAdapter(t *testing.T) {
nodes := createTestServer()
rules := []*server.RuleGroup{
{
Name: "Test Rule Group 1",
Tags: "",
Rules: "DOMAIN-SUFFIX,example.com,Test Rule Group 1",
},
}
adapter := NewAdapter(nodes, rules)
bytes, err := adapter.BuildClash("some-uuid")
if err != nil {
t.Errorf("Failed to build adapter: %v", err)
return
}
t.Logf("Adapter built successfully: %s", string(bytes))
}
func TestAdapter_BuildSingbox(t *testing.T) {
nodes := createTestServer()
rules := []*server.RuleGroup{
{
Name: "Test Rule Group 1",
Tags: "",
Rules: "DOMAIN-SUFFIX,example.com,Test Rule Group 1",
},
}
adapter := NewAdapter(nodes, rules)
bytes, err := adapter.BuildSingbox("some-uuid")
if err != nil {
t.Errorf("Failed to build adapter: %v", err)
return
}
var pretty map[string]interface{}
_ = json.Unmarshal(bytes, &pretty)
if pretty == nil {
t.Errorf("Failed to parse Singbox config")
return
}
prettyStr, err := json.MarshalIndent(pretty, "", " ")
if err != nil {
t.Errorf("Failed to format Singbox config: %v", err)
return
}
t.Logf("Adapter built successfully: \n %s", string(prettyStr))
}
func TestAdapter_BuildSurfboard(t *testing.T) {
nodes := createTestServer()
rules := []*server.RuleGroup{
{
Name: "Test Rule Group 1",
Tags: "",
Rules: "DOMAIN-SUFFIX,example.com,Test Rule Group 1",
},
}
adapter := NewAdapter(nodes, rules)
user := surfboard.UserInfo{
UUID: "some-uuid",
Upload: 200,
Download: 13012,
TotalTraffic: 1024000,
ExpiredDate: time.Now().Add(24 * time.Hour),
SubscribeURL: "",
}
bytes := adapter.BuildSurfboard("test-site", user)
if bytes == nil {
t.Errorf("Failed to build adapter")
return
}
t.Logf("Adapter built successfully: %s", string(bytes))
}
+68
View File
@@ -0,0 +1,68 @@
package clash
import (
"fmt"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
"github.com/perfect-panel/ppanel-server/pkg/logger"
"gopkg.in/yaml.v3"
)
type Clash struct {
proxy.Adapter
}
func NewClash(adapter proxy.Adapter) *Clash {
return &Clash{
Adapter: adapter,
}
}
func (c *Clash) Build(uuid string) ([]byte, error) {
var proxies []Proxy
for _, v := range c.Proxies {
p, err := c.parseProxy(v, uuid)
if err != nil {
logger.Errorf("Failed to parse proxy for %s: %s", v.Name, err.Error())
continue
}
proxies = append(proxies, *p)
}
var rawConfig RawConfig
if err := yaml.Unmarshal([]byte(DefaultTemplate), &rawConfig); err != nil {
return nil, fmt.Errorf("failed to unmarshal template: %w", err)
}
rawConfig.Proxies = proxies
// generate proxy groups
var groups []ProxyGroup
for _, group := range c.Group {
groups = append(groups, ProxyGroup{
Name: group.Name,
Type: string(group.Type),
Proxies: group.Proxies,
Url: group.URL,
Interval: group.Interval,
})
}
rawConfig.ProxyGroups = groups
rawConfig.Rules = append(c.Rules, "# 最终规则", "MATCH,手动选择")
return yaml.Marshal(&rawConfig)
}
func (c *Clash) parseProxy(p proxy.Proxy, uuid string) (*Proxy, error) {
parseFuncs := map[string]func(proxy.Proxy, string) (*Proxy, error){
"shadowsocks": parseShadowsocks,
"trojan": parseTrojan,
"vless": parseVless,
"vmess": parseVmess,
"hysteria2": parseHysteria2,
"tuic": parseTuic,
}
if parseFunc, exists := parseFuncs[p.Protocol]; exists {
return parseFunc(p, uuid)
}
logger.Errorw("Unknown protocol", logger.Field("protocol", p.Protocol), logger.Field("server", p.Name))
return nil, fmt.Errorf("unknown protocol: %s", p.Protocol)
}
+41
View File
@@ -0,0 +1,41 @@
package clash
import (
"testing"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
"github.com/stretchr/testify/assert"
)
func TestClash_Build(t *testing.T) {
adapter := proxy.Adapter{
Proxies: []proxy.Proxy{
{
Name: "test-proxy",
Protocol: "shadowsocks",
Server: "1.2.3.4",
Port: 8388,
Option: proxy.Shadowsocks{
Method: "aes-256-gcm",
},
},
},
Group: []proxy.Group{
{
Name: "test-group",
Type: "select",
Proxies: []string{"test-proxy"},
},
},
Rules: []string{
"DOMAIN-SUFFIX,example.com,DIRECT",
"GEOIP,CN,DIRECT",
"MATCH,DIRECT",
},
}
clash := NewClash(adapter)
result, err := clash.Build("test-uuid")
assert.NoError(t, err)
assert.NotNil(t, result)
}
+35
View File
@@ -0,0 +1,35 @@
package clash
const DefaultTemplate = `
mixed-port: 7890
allow-lan: true
bind-address: "*"
mode: rule
log-level: info
external-controller: 127.0.0.1:9090
global-client-fingerprint: chrome
unified-delay: true
geox-url:
mmdb: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geoip.metadb"
dns:
enable: true
ipv6: true
enhanced-mode: fake-ip
fake-ip-range: 198.18.0.1/16
use-hosts: true
default-nameserver:
- 120.53.53.53
- 1.12.12.12
nameserver:
- https://120.53.53.53/dns-query#skip-cert-verify=true
- tls://1.12.12.12#skip-cert-verify=true
proxy-server-nameserver:
- https://120.53.53.53/dns-query#skip-cert-verify=true
- tls://1.12.12.12#skip-cert-verify=true
proxies:
proxy-groups:
rules:
`
+131
View File
@@ -0,0 +1,131 @@
package clash
type RawConfig struct {
Port int `yaml:"port" json:"port"`
SocksPort int `yaml:"socks-port" json:"socks-port"`
RedirPort int `yaml:"redir-port" json:"redir-port"`
TProxyPort int `yaml:"tproxy-port" json:"tproxy-port"`
MixedPort int `yaml:"mixed-port" json:"mixed-port"`
AllowLan bool `yaml:"allow-lan" json:"allow-lan"`
Mode string `yaml:"mode" json:"mode"`
LogLevel string `yaml:"log-level" json:"log-level"`
ExternalController string `yaml:"external-controller" json:"external-controller"`
Secret string `yaml:"secret" json:"secret"`
Proxies []Proxy `yaml:"proxies" json:"proxies"`
ProxyGroups []ProxyGroup `yaml:"proxy-groups" json:"proxy-groups"`
Rules []string `yaml:"rules" json:"rule"`
}
type Proxy struct {
// 基础数据
Name string `yaml:"name"`
Type string `yaml:"type"`
Server string `yaml:"server"`
Port int `yaml:"port,omitempty"`
// Shadowsocks
Password string `yaml:"password,omitempty"`
Cipher string `yaml:"cipher,omitempty"`
UDP bool `yaml:"udp,omitempty"`
Plugin string `yaml:"plugin,omitempty"`
PluginOpts map[string]any `yaml:"plugin-opts,omitempty"`
UDPOverTCP bool `yaml:"udp-over-tcp,omitempty"`
UDPOverTCPVersion int `yaml:"udp-over-tcp-version,omitempty"`
ClientFingerprint string `yaml:"client-fingerprint,omitempty"`
// Vmess
UUID string `yaml:"uuid,omitempty"`
AlterID *int `yaml:"alterId,omitempty"`
Network string `yaml:"network,omitempty"`
TLS bool `yaml:"tls,omitempty"`
ALPN []string `yaml:"alpn,omitempty"`
SkipCertVerify bool `yaml:"skip-cert-verify,omitempty"`
Fingerprint string `yaml:"fingerprint,omitempty"`
ServerName string `yaml:"servername,omitempty"`
RealityOpts RealityOptions `yaml:"reality-opts,omitempty"`
HTTPOpts HTTPOptions `yaml:"http-opts,omitempty"`
HTTP2Opts HTTP2Options `yaml:"h2-opts,omitempty"`
GrpcOpts GrpcOptions `yaml:"grpc-opts,omitempty"`
WSOpts WSOptions `yaml:"ws-opts,omitempty"`
PacketAddr bool `yaml:"packet-addr,omitempty"`
XUDP bool `yaml:"xudp,omitempty"`
PacketEncoding string `yaml:"packet-encoding,omitempty"`
GlobalPadding bool `yaml:"global-padding,omitempty"`
AuthenticatedLength bool `yaml:"authenticated-length,omitempty"`
// Vless
Flow string `yaml:"flow,omitempty"`
WSPath string `yaml:"ws-path,omitempty"`
WSHeaders map[string]string `yaml:"ws-headers,omitempty"`
// Trojan
SNI string `yaml:"sni,omitempty"`
SSOpts TrojanSSOption `yaml:"ss-opts,omitempty"`
// Hysteria2
Ports string `yaml:"ports,omitempty"`
HopInterval int `yaml:"hop-interval,omitempty"`
Up string `yaml:"up,omitempty"`
Down string `yaml:"down,omitempty"`
Obfs string `yaml:"obfs,omitempty"`
ObfsPassword string `yaml:"obfs-password,omitempty"`
CustomCA string `yaml:"ca,omitempty"`
CustomCAString string `yaml:"ca-str,omitempty"`
CWND int `yaml:"cwnd,omitempty"`
UdpMTU int `yaml:"udp-mtu,omitempty"`
// Tuic
Token string `yaml:"token,omitempty"`
Ip string `yaml:"ip,omitempty"`
HeartbeatInterval int `yaml:"heartbeat-interval,omitempty"`
ReduceRtt bool `yaml:"reduce-rtt,omitempty"`
RequestTimeout int `yaml:"request-timeout,omitempty"`
UdpRelayMode string `yaml:"udp-relay-mode,omitempty"`
CongestionController string `yaml:"congestion-controller,omitempty"`
DisableSni bool `yaml:"disable-sni,omitempty"`
MaxUdpRelayPacketSize int `yaml:"max-udp-relay-packet-size,omitempty"`
FastOpen bool `yaml:"fast-open,omitempty"`
MaxOpenStreams int `yaml:"max-open-streams,omitempty"`
ReceiveWindowConn int `yaml:"recv-window-conn,omitempty"`
ReceiveWindow int `yaml:"recv-window,omitempty"`
DisableMTUDiscovery bool `yaml:"disable-mtu-discovery,omitempty"`
MaxDatagramFrameSize int `yaml:"max-datagram-frame-size,omitempty"`
UDPOverStream bool `yaml:"udp-over-stream,omitempty"`
UDPOverStreamVersion int `yaml:"udp-over-stream-version,omitempty"`
}
type ProxyGroup struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
Proxies []string `yaml:"proxies"`
Url string `yaml:"url,omitempty"`
Interval int `yaml:"interval,omitempty"`
}
type TrojanSSOption struct {
Enabled bool `yaml:"enabled,omitempty"`
Method string `yaml:"method,omitempty"`
Password string `yaml:"password,omitempty"`
}
type RealityOptions struct {
PublicKey string `yaml:"public-key"`
ShortID string `yaml:"short-id"`
}
type HTTPOptions struct {
Method string `yaml:"method,omitempty"`
Path []string `yaml:"path,omitempty"`
Headers map[string][]string `yaml:"headers,omitempty"`
}
type HTTP2Options struct {
Host []string `yaml:"host,omitempty"`
Path string `yaml:"path,omitempty"`
}
type GrpcOptions struct {
GrpcServiceName string `yaml:"grpc-service-name,omitempty"`
}
type WSOptions struct {
Path string `yaml:"path,omitempty"`
Headers map[string]string `yaml:"headers,omitempty"`
MaxEarlyData int `yaml:"max-early-data,omitempty"`
EarlyDataHeaderName string `yaml:"early-data-header-name,omitempty"`
V2rayHttpUpgrade bool `yaml:"v2ray-http-upgrade,omitempty"`
V2rayHttpUpgradeFastOpen bool `yaml:"v2ray-http-upgrade-fast-open,omitempty"`
}
+165
View File
@@ -0,0 +1,165 @@
package clash
import (
"fmt"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
func parseShadowsocks(s proxy.Proxy, uuid string) (*Proxy, error) {
config, ok := s.Option.(proxy.Shadowsocks)
if !ok {
return nil, fmt.Errorf("invalid type for Shadowsocks")
}
p := &Proxy{
Name: s.Name,
Type: "ss",
Server: s.Server,
Port: s.Port,
Cipher: config.Method,
Password: uuid,
UDP: true,
}
return p, nil
}
func parseTrojan(data proxy.Proxy, password string) (*Proxy, error) {
trojan, ok := data.Option.(proxy.Trojan)
if !ok {
return nil, fmt.Errorf("invalid type for Trojan")
}
p := &Proxy{
Name: data.Name,
Type: "trojan",
Server: data.Server,
Port: data.Port,
Password: password,
SNI: trojan.SecurityConfig.SNI,
SkipCertVerify: trojan.SecurityConfig.AllowInsecure,
}
setTransportOptions(p, trojan.Transport, trojan.TransportConfig)
return p, nil
}
func parseVless(data proxy.Proxy, uuid string) (*Proxy, error) {
vless, ok := data.Option.(proxy.Vless)
if !ok {
return nil, fmt.Errorf("invalid type for Vless")
}
p := &Proxy{
Name: data.Name,
Type: "vless",
Server: data.Server,
Port: data.Port,
UUID: uuid,
Flow: vless.Flow,
}
setSecurityOptions(p, vless.Security, vless.SecurityConfig)
clashTransport(p, vless.Transport, vless.TransportConfig)
return p, nil
}
func parseVmess(data proxy.Proxy, uuid string) (*Proxy, error) {
vmess, ok := data.Option.(proxy.Vmess)
if !ok {
return nil, fmt.Errorf("invalid type for Vmess")
}
alterID := 0
p := &Proxy{
Name: data.Name,
Type: "vmess",
Server: data.Server,
Port: data.Port,
UUID: uuid,
AlterID: &alterID,
Cipher: "auto",
}
setSecurityOptions(p, vmess.Security, vmess.SecurityConfig)
clashTransport(p, vmess.Transport, vmess.TransportConfig)
return p, nil
}
func parseHysteria2(data proxy.Proxy, uuid string) (*Proxy, error) {
hysteria2, ok := data.Option.(proxy.Hysteria2)
if !ok {
return nil, fmt.Errorf("invalid type for Hysteria2")
}
p := &Proxy{
Name: data.Name,
Type: "hysteria2",
Server: data.Server,
Port: data.Port,
Ports: hysteria2.HopPorts,
Password: uuid,
HeartbeatInterval: hysteria2.HopInterval,
SkipCertVerify: hysteria2.SecurityConfig.AllowInsecure,
SNI: hysteria2.SecurityConfig.SNI,
}
if hysteria2.ObfsPassword != "" {
p.Obfs = "salamander"
p.ObfsPassword = hysteria2.ObfsPassword
}
return p, nil
}
func parseTuic(data proxy.Proxy, uuid string) (*Proxy, error) {
tuic, ok := data.Option.(proxy.Tuic)
if !ok {
return nil, fmt.Errorf("invalid type for Tuic")
}
p := &Proxy{
Name: data.Name,
Type: "tuic",
Server: data.Server,
Port: data.Port,
UUID: uuid,
Password: uuid,
SNI: tuic.SecurityConfig.SNI,
SkipCertVerify: tuic.SecurityConfig.AllowInsecure,
}
return p, nil
}
func setSecurityOptions(p *Proxy, security string, config proxy.SecurityConfig) {
switch security {
case "tls":
p.TLS = true
p.ServerName = config.SNI
p.ClientFingerprint = config.Fingerprint
p.SkipCertVerify = config.AllowInsecure
case "reality":
p.TLS = true
p.ServerName = config.SNI
p.ClientFingerprint = config.Fingerprint
p.RealityOpts = RealityOptions{
PublicKey: config.RealityPublicKey,
ShortID: config.RealityShortId,
}
p.SkipCertVerify = config.AllowInsecure
default:
p.TLS = false
}
}
func setTransportOptions(p *Proxy, transport string, config proxy.TransportConfig) {
switch transport {
case "websocket":
p.Network = "ws"
p.WSOpts = WSOptions{
Path: config.Path,
Headers: map[string]string{
"Host": config.Host,
},
}
case "grpc":
p.Network = "grpc"
p.GrpcOpts = GrpcOptions{
GrpcServiceName: config.ServiceName,
}
default:
p.Network = "tcp"
}
}
+33
View File
@@ -0,0 +1,33 @@
package clash
import "github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
func clashTransport(c *Proxy, transportType string, transportConfig proxy.TransportConfig) {
switch transportType {
case "websocket", "httpupgrade":
if transportType == "websocket" {
c.Network = "ws"
} else {
c.Network = transportType
}
c.WSOpts = WSOptions{
Path: transportConfig.Path,
Headers: map[string]string{},
}
if transportConfig.Host != "" {
c.WSOpts.Headers["host"] = transportConfig.Host
}
if transportType == "httpupgrade" {
c.WSOpts.V2rayHttpUpgrade = true
}
case "grpc":
c.Network = "grpc"
c.GrpcOpts = GrpcOptions{
GrpcServiceName: transportConfig.ServiceName,
}
case "tcp":
c.Network = "tcp"
}
}
+245
View File
@@ -0,0 +1,245 @@
package general
import (
"encoding/base64"
"encoding/json"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
type v2rayShareLink struct {
Ps string `json:"ps"`
Add string `json:"add"`
Port string `json:"port"`
ID string `json:"id"`
Aid string `json:"aid"`
Net string `json:"net"`
Type string `json:"type"`
Host string `json:"host"`
SNI string `json:"sni"`
Path string `json:"path"`
TLS string `json:"tls"`
Flow string `json:"flow,omitempty"`
Alpn string `json:"alpn,omitempty"`
AllowInsecure bool `json:"allowInsecure"`
Fingerprint string `json:"fp,omitempty"`
PublicKey string `json:"pbk,omitempty"`
ShortId string `json:"sid,omitempty"`
SpiderX string `json:"spx,omitempty"`
V string `json:"v"`
}
// GenerateBase64General will output node URLs split by '\n' and then encode into base64
func GenerateBase64General(data []proxy.Proxy, uuid string) []byte {
var links []string
for _, v := range data {
p := buildProxy(v, uuid)
if p == "" {
continue
}
links = append(links, p)
}
var rsp []byte
rsp = base64.RawStdEncoding.AppendEncode(rsp, []byte(strings.Join(links, "\n")))
return rsp
}
func buildProxy(data proxy.Proxy, uuid string) string {
switch data.Protocol {
case "shadowsocks":
return ShadowsocksUri(data, uuid)
case "vmess":
return VmessUri(data, uuid)
case "vless":
return VlessUri(data, uuid)
case "trojan":
return TrojanUri(data, uuid)
case "hysteria2":
return Hysteria2Uri(data, uuid)
case "tuic":
return TuicUri(data, uuid)
default:
return ""
}
}
func ShadowsocksUri(data proxy.Proxy, uuid string) string {
ss := data.Option.(proxy.Shadowsocks)
// sip002
u := &url.URL{
Scheme: "ss",
// 还没有写 2022 的
User: url.User(strings.TrimSuffix(base64.URLEncoding.EncodeToString([]byte(ss.Method+":"+uuid)), "=")),
Host: net.JoinHostPort(data.Server, strconv.Itoa(data.Port)),
Fragment: data.Name,
}
return u.String()
}
func VmessUri(data proxy.Proxy, uuid string) string {
vmess := data.Option.(proxy.Vmess)
transport := vmess.TransportConfig
securityConfig := vmess.SecurityConfig
var s = v2rayShareLink{
V: "2",
Add: data.Server,
Port: fmt.Sprint(data.Port),
ID: uuid,
Aid: "0",
Net: vmess.Transport,
// Type: "?",
Host: transport.Host,
Path: transport.Path,
}
if vmess.Security == "tls" {
s.TLS = "tls"
s.SNI = securityConfig.SNI
s.AllowInsecure = securityConfig.AllowInsecure
s.Fingerprint = securityConfig.Fingerprint
}
b, _ := json.Marshal(s)
return "vmess://" + strings.TrimSuffix(base64.StdEncoding.EncodeToString(b), "=")
}
func VlessUri(data proxy.Proxy, uuid string) string {
vless := data.Option.(proxy.Vless)
transportConfig := vless.TransportConfig
securityConfig := vless.SecurityConfig
var query = make(url.Values)
setQuery(&query, "flow", vless.Flow)
setQuery(&query, "type", vless.Transport)
setQuery(&query, "security", vless.Security)
switch vless.Transport {
case "ws", "http", "httpupgrade":
setQuery(&query, "path", transportConfig.Path)
setQuery(&query, "host", transportConfig.Host)
case "grpc":
setQuery(&query, "serviceName", transportConfig.ServiceName)
case "meek":
setQuery(&query, "url", transportConfig.Host)
}
setQuery(&query, "sni", securityConfig.SNI)
setQuery(&query, "fp", securityConfig.Fingerprint)
setQuery(&query, "pbk", securityConfig.RealityPublicKey)
setQuery(&query, "sid", securityConfig.RealityShortId)
u := url.URL{
Scheme: "vless",
User: url.User(uuid),
Host: net.JoinHostPort(data.Server, fmt.Sprint(data.Port)),
RawQuery: query.Encode(),
Fragment: data.Name,
}
return u.String()
}
func TrojanUri(data proxy.Proxy, uuid string) string {
trojan := data.Option.(proxy.Trojan)
transportConfig := trojan.TransportConfig
securityConfig := trojan.SecurityConfig
var query = make(url.Values)
setQuery(&query, "type", trojan.Transport)
setQuery(&query, "security", trojan.Security)
switch trojan.Transport {
case "ws", "http", "httpupgrade":
setQuery(&query, "path", transportConfig.Path)
setQuery(&query, "host", transportConfig.Host)
case "grpc":
setQuery(&query, "serviceName", transportConfig.ServiceName)
case "meek":
setQuery(&query, "url", transportConfig.Host)
}
setQuery(&query, "sni", securityConfig.SNI)
setQuery(&query, "fp", securityConfig.Fingerprint)
setQuery(&query, "pbk", securityConfig.RealityPublicKey)
setQuery(&query, "sid", securityConfig.RealityShortId)
if securityConfig.AllowInsecure {
setQuery(&query, "allowInsecure", "1")
}
u := &url.URL{
Scheme: "trojan",
User: url.User(uuid),
Host: net.JoinHostPort(data.Server, strconv.Itoa(data.Port)),
RawQuery: query.Encode(),
Fragment: data.Name,
}
return u.String()
}
func Hysteria2Uri(data proxy.Proxy, uuid string) string {
hysteria2 := data.Option.(proxy.Hysteria2)
var query = make(url.Values)
setQuery(&query, "sni", hysteria2.SecurityConfig.SNI)
if hysteria2.SecurityConfig.AllowInsecure {
setQuery(&query, "insecure", "1")
}
if hp := strings.TrimSpace(hysteria2.HopPorts); hp != "" {
setQuery(&query, "mport", hp)
}
if hysteria2.ObfsPassword != "" {
setQuery(&query, "obfs", "salamander")
setQuery(&query, "obfs-password", hysteria2.ObfsPassword)
}
u := &url.URL{
Scheme: "hysteria2",
User: url.User(uuid),
Host: net.JoinHostPort(data.Server, strconv.Itoa(data.Port)),
RawQuery: query.Encode(),
Fragment: data.Name,
}
return u.String()
}
func TuicUri(data proxy.Proxy, uuid string) string {
tuic := data.Option.(proxy.Tuic)
var query = make(url.Values)
setQuery(&query, "congestion_control", "bbr")
if tuic.SecurityConfig.SNI == "" {
setQuery(&query, "sni", tuic.SecurityConfig.SNI)
} else {
setQuery(&query, "disable_sni", "1")
}
if tuic.SecurityConfig.AllowInsecure {
setQuery(&query, "allow_insecure", "1")
}
u := &url.URL{
Scheme: "tuic",
User: url.User(uuid + ":" + uuid),
Host: net.JoinHostPort(data.Server, strconv.Itoa(data.Port)),
RawQuery: query.Encode(),
Fragment: data.Name,
}
return u.String()
}
func setQuery(q *url.Values, k, v string) {
if v != "" {
q.Set(k, v)
}
}
+26
View File
@@ -0,0 +1,26 @@
package general
import (
"testing"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
func createServer() proxy.Proxy {
return proxy.Proxy{
Name: "Meta",
Server: "127.0.0.1",
Port: 13092,
Protocol: "shadowsocks",
Option: proxy.Shadowsocks{
Method: "aes-256-gcm",
ServerKey: "",
},
}
}
func TestGenerateBase64General(t *testing.T) {
s := createServer()
p := buildProxy(s, "935b33c7-e128-49f2-816b-71070469cac2")
t.Log(p)
}
+27
View File
@@ -0,0 +1,27 @@
package loon
import (
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
func BuildLoon(servers []proxy.Proxy, uuid string) []byte {
uri := ""
for _, s := range servers {
switch s.Protocol {
case "vmess":
uri += buildVMess(s, uuid)
case "shadowsocks":
uri += buildShadowsocks(s, uuid)
case "trojan":
uri += buildTrojan(s, uuid)
case "vless":
uri += buildVless(s, uuid)
case "hysteria2":
uri += buildHysteria2(s, uuid)
default:
continue
}
}
return []byte(uri)
}
+34
View File
@@ -0,0 +1,34 @@
package loon
import (
"fmt"
"strconv"
"strings"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
func buildHysteria2(data proxy.Proxy, password string) string {
hysteria2 := data.Option.(proxy.Hysteria2)
configs := []string{
fmt.Sprintf("%s=Hysteria2", data.Name),
data.Server,
strconv.Itoa(data.Port),
password,
"udp=true",
}
if hysteria2.ObfsPassword != "" {
configs = append(configs, "obfs=salamander", fmt.Sprintf("salamander-password=%s", hysteria2.ObfsPassword))
}
if hysteria2.SecurityConfig.SNI != "" {
configs = append(configs, fmt.Sprintf("sni=%s", hysteria2.SecurityConfig.SNI))
if hysteria2.SecurityConfig.AllowInsecure {
configs = append(configs, "skip-cert-verify=true")
} else {
configs = append(configs, "skip-cert-verify=false")
}
}
uri := strings.Join(configs, ",")
return uri + "\r\n"
}
+29
View File
@@ -0,0 +1,29 @@
package loon
import (
"testing"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
func createSS() proxy.Proxy {
return proxy.Proxy{
Name: "Shadowsocks",
Server: "127.0.0.1",
Port: 10301,
Protocol: "shadowsocks",
Option: proxy.Shadowsocks{
Method: "aes-256-gcm",
ServerKey: "",
},
}
}
func TestBuildSS(t *testing.T) {
s := createSS()
password := "f0d0237d-193a-4cf5-99dd-b02207beaea6"
uri := buildShadowsocks(s, password)
t.Log(uri)
}
+49
View File
@@ -0,0 +1,49 @@
package loon
import (
"fmt"
"strconv"
"strings"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
"github.com/perfect-panel/ppanel-server/pkg/tool"
"github.com/perfect-panel/ppanel-server/pkg/uuidx"
)
func buildShadowsocks(data proxy.Proxy, password string) string {
shadowsocks := data.Option.(proxy.Shadowsocks)
// If the method is 2022-blake3-chacha20-poly1305, it means that the server is a relay server
if shadowsocks.Method == "2022-blake3-chacha20-poly1305" {
return ""
}
if strings.Contains(shadowsocks.Method, "2022") {
serverKey, userKey := generateShadowsocks2022Password(shadowsocks, password)
password = fmt.Sprintf("%s:%s", serverKey, userKey)
}
configs := []string{
fmt.Sprintf("%s=Shadowsocks", data.Name),
data.Server,
strconv.Itoa(data.Port),
shadowsocks.Method,
password,
"fast-open=false",
"udp=true",
}
uri := strings.Join(configs, ",")
return uri + "\r\n"
}
func generateShadowsocks2022Password(ss proxy.Shadowsocks, password string) (string, string) {
// server key
var serverKey string
if ss.Method == "2022-blake3-aes-128-gcm" {
serverKey = tool.GenerateCipher(ss.ServerKey, 16)
password = uuidx.UUIDToBase64(password, 16)
} else {
serverKey = tool.GenerateCipher(ss.ServerKey, 32)
password = uuidx.UUIDToBase64(password, 32)
}
return serverKey, password
}
+44
View File
@@ -0,0 +1,44 @@
package loon
import (
"fmt"
"strings"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
func buildTrojan(data proxy.Proxy, password string) string {
trojan := data.Option.(proxy.Trojan)
configs := []string{
fmt.Sprintf("%s=trojan", data.Name),
data.Server,
fmt.Sprintf("%d", data.Port),
"auto",
password,
"fast-open=false",
"udp=true",
}
if trojan.SecurityConfig.SNI != "" {
configs = append(configs, fmt.Sprintf("sni=%s", trojan.SecurityConfig.SNI))
}
if trojan.SecurityConfig.AllowInsecure {
configs = append(configs, "skip-cert-verify=true")
} else {
configs = append(configs, "skip-cert-verify=false")
}
if trojan.Transport == "websocket" {
configs = append(configs, "transport=ws")
if trojan.TransportConfig.Path != "" {
configs = append(configs, fmt.Sprintf("path=%s", trojan.TransportConfig.Path))
}
if trojan.TransportConfig.Host != "" {
configs = append(configs, fmt.Sprintf("host=%s", trojan.TransportConfig.Host))
}
}
uri := strings.Join(configs, ",")
return uri + "\r\n"
}
+62
View File
@@ -0,0 +1,62 @@
package loon
import (
"fmt"
"strconv"
"strings"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
"github.com/perfect-panel/ppanel-server/pkg/logger"
)
func buildVless(data proxy.Proxy, password string) string {
vless := data.Option.(proxy.Vless)
// If flow is not empty, it means that the server is a relay server
if vless.Flow != "" {
return ""
}
configs := []string{
fmt.Sprintf("%s=vless", data.Name),
data.Server,
strconv.Itoa(data.Port),
"auto",
password,
"fast-open=false",
"udp=true",
"alterId=0",
}
switch vless.Transport {
case "tcp":
configs = append(configs, "transport=tcp")
case "websocket":
configs = append(configs, "transport=ws")
if vless.TransportConfig.Path != "" {
configs = append(configs, fmt.Sprintf("path=%s", vless.TransportConfig.Path))
}
if vless.TransportConfig.Host != "" {
configs = append(configs, fmt.Sprintf("host=%s", vless.TransportConfig.Host))
}
default:
logger.Info("Loon Unknown transport type: ", logger.Field("transport", vless.Transport))
return ""
}
if vless.Security == "tls" {
configs = append(configs, "over-tls=true", fmt.Sprintf("tls-name=%s", vless.SecurityConfig.SNI))
if vless.SecurityConfig.AllowInsecure {
configs = append(configs, "skip-cert-verify=true")
} else {
configs = append(configs, "skip-cert-verify=false")
}
} else if vless.Security == "reality" {
// Loon does not support reality security
logger.Info("Loon Unknown security type: ", logger.Field("security", vless.Security))
return ""
}
uri := strings.Join(configs, ",")
return uri + "\r\n"
}
+53
View File
@@ -0,0 +1,53 @@
package loon
import (
"fmt"
"strings"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
"github.com/perfect-panel/ppanel-server/pkg/logger"
)
func buildVMess(data proxy.Proxy, password string) string {
vmess := data.Option.(proxy.Vmess)
configs := []string{
fmt.Sprintf("%s=vmess", data.Name),
data.Server,
fmt.Sprintf("%d", data.Port),
"auto",
password,
"fast-open=false",
"udp=true",
"alterId=0",
}
switch vmess.Transport {
case "tcp":
configs = append(configs, "transport=tcp")
case "websocket":
configs = append(configs, "transport=ws")
if vmess.TransportConfig.Path != "" {
configs = append(configs, fmt.Sprintf("path=%s", vmess.TransportConfig.Path))
}
if vmess.TransportConfig.Host != "" {
configs = append(configs, fmt.Sprintf("host=%s", vmess.TransportConfig.Host))
}
default:
logger.Info("Loon Unknown transport type: ", logger.Field("transport", vmess.Transport))
return ""
}
if vmess.Security == "tls" {
configs = append(configs, "over-tls=true", fmt.Sprintf("tls-name=%s", vmess.SecurityConfig.SNI))
if vmess.SecurityConfig.AllowInsecure {
configs = append(configs, "skip-cert-verify=true")
} else {
configs = append(configs, "skip-cert-verify=false")
}
}
uri := strings.Join(configs, ",")
return uri + "\r\n"
}
+114
View File
@@ -0,0 +1,114 @@
package proxy
// Adapter represents a proxy adapter
type Adapter struct {
Proxies []Proxy
Group []Group
Rules []string
Region []string
}
// Proxy represents a proxy server
type Proxy struct {
Name string
Server string
Port int
Protocol string
Country string
Option any
}
// Group represents a group of proxies
type Group struct {
Name string
Type GroupType
Proxies []string
URL string
Interval int
}
type GroupType string
const (
GroupTypeSelect GroupType = "select"
GroupTypeURLTest GroupType = "url-test"
GroupTypeFallback GroupType = "fallback"
)
// Shadowsocks represents a Shadowsocks proxy configuration
type Shadowsocks struct {
Port int `json:"port"`
Method string `json:"method"`
ServerKey string `json:"server_key"`
}
// Vless represents a Vless proxy configuration
type Vless struct {
Port int `json:"port"`
Flow string `json:"flow"`
Transport string `json:"transport"`
TransportConfig TransportConfig `json:"transport_config"`
Security string `json:"security"`
SecurityConfig SecurityConfig `json:"security_config"`
}
// Vmess represents a Vmess proxy configuration
type Vmess struct {
Port int `json:"port"`
Flow string `json:"flow"`
Transport string `json:"transport"`
TransportConfig TransportConfig `json:"transport_config"`
Security string `json:"security"`
SecurityConfig SecurityConfig `json:"security_config"`
}
// Trojan represents a Trojan proxy configuration
type Trojan struct {
Port int `json:"port"`
Flow string `json:"flow"`
Transport string `json:"transport"`
TransportConfig TransportConfig `json:"transport_config"`
Security string `json:"security"`
SecurityConfig SecurityConfig `json:"security_config"`
}
// Hysteria2 represents a Hysteria2 proxy configuration
type Hysteria2 struct {
Port int `json:"port"`
HopPorts string `json:"hop_ports"`
HopInterval int `json:"hop_interval"`
ObfsPassword string `json:"obfs_password"`
SecurityConfig SecurityConfig `json:"security_config"`
}
// Tuic represents a Tuic proxy configuration
type Tuic struct {
Port int `json:"port"`
SecurityConfig SecurityConfig `json:"security_config"`
}
// TransportConfig represents the transport configuration for a proxy
type TransportConfig struct {
Path string `json:"path,omitempty"` // ws/httpupgrade
Host string `json:"host,omitempty"`
ServiceName string `json:"service_name"` // grpc
}
// SecurityConfig represents the security configuration for a proxy
type SecurityConfig struct {
SNI string `json:"sni"`
AllowInsecure bool `json:"allow_insecure"`
Fingerprint string `json:"fingerprint"`
RealityServerAddr string `json:"reality_server_addr"`
RealityServerPort int `json:"reality_server_port"`
RealityPrivateKey string `json:"reality_private_key"`
RealityPublicKey string `json:"reality_public_key"`
RealityShortId string `json:"reality_short_id"`
}
// Relay represents a relay configuration
type Relay struct {
RelayHost string
DispatchMode string
Prefix string
}
+22
View File
@@ -0,0 +1,22 @@
package quantumultx
import (
"encoding/base64"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
func BuildQuantumultX(servers []proxy.Proxy, uuid string) string {
var uri string
for _, s := range servers {
switch s.Protocol {
case "vmess":
uri += buildVmess(s, uuid)
case "shadowsocks":
uri += buildShadowsocks(s, uuid)
case "trojan":
uri += buildTrojan(s, uuid)
}
}
return base64.StdEncoding.EncodeToString([]byte(uri))
}
+94
View File
@@ -0,0 +1,94 @@
package quantumultx
import (
"testing"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
func createVMess() proxy.Proxy {
return proxy.Proxy{
Name: "Vmess",
Server: "test.xxxx.com",
Port: 13002,
Protocol: "vmess",
Option: proxy.Vmess{
Port: 13002,
Transport: "websocket",
TransportConfig: proxy.TransportConfig{
Path: "/ws",
Host: "test.xx.com",
},
Security: "none",
},
}
}
func createSS() proxy.Proxy {
return proxy.Proxy{
Name: "Shadowsocks",
Server: "test.xxxx.com",
Port: 10301,
Protocol: "shadowsocks",
Option: proxy.Shadowsocks{
Port: 10301,
Method: "aes-256-gcm",
ServerKey: "123456",
},
}
}
func createTrojan() proxy.Proxy {
return proxy.Proxy{
Name: "Trojan",
Server: "test.xxxx.com",
Port: 13002,
Protocol: "trojan",
Option: proxy.Trojan{
Port: 13002,
Transport: "websocket",
TransportConfig: proxy.TransportConfig{
Path: "/ws",
Host: "baidu.com",
},
SecurityConfig: proxy.SecurityConfig{
SNI: "baidu.com",
AllowInsecure: true,
},
},
}
}
func TestVmess(t *testing.T) {
s := createVMess()
vmess := buildVmess(s, "uuid")
t.Log(vmess)
// output:
// vmess=127.0.0.1:13002,method=chacha20-poly1305,password=uuid,fast-open=true,udp-relay=true,tag=Vmess,tls-verification=true,obfs-uri=/ws,obfs-host=baidu.com
}
func TestShadowsocks(t *testing.T) {
s := createSS()
shadowsocks := buildShadowsocks(s, "uuid")
t.Log(shadowsocks)
// output:
// shadowsocks=127.0.0.1:10301,method=aes-256-gcm,password=uuid,fast-open=true,udp-relay=true,tag=Shadowsocks
}
func TestTrojan(t *testing.T) {
s := createTrojan()
trojan := buildTrojan(s, "password")
t.Log(trojan)
// output:
// trojan=192.168.0.1:13002,password=password,fast-open=true,udp-relay=true,tag=Trojan,obfs=wss,obfs-uri=ws,obfs-host=baidu.com
}
func TestBuildQuantumultX(t *testing.T) {
var servers []proxy.Proxy
uri := BuildQuantumultX(servers, "uuid")
t.Log(uri)
// output:
// c2hhZG93c29ja3M9MTI3LjAuMC4xOjEwMzAxLG1ldGhvZD1hZXMtMjU2LWdjbSxwYXNzd29yZD11dWlkLGZhc3Qtb3Blbj10cnVlLHVkcC1yZWxheT10cnVlLHRhZz1TaGFkb3dzb2Nrcw0KdHJvamFuPTE5Mi4xNjguMC4xOjEzMDAyLHBhc3N3b3JkPXV1aWQsZmFzdC1vcGVuPXRydWUsdWRwLXJlbGF5PXRydWUsdGFnPVRyb2phbixvYmZzPXdzcyxvYmZzLXVyaT13cyxvYmZzLWhvc3Q9YmFpZHUuY29tDQp2bWVzcz0xMjcuMC4wLjE6MTMwMDIsbWV0aG9kPWNoYWNoYTIwLXBvbHkxMzA1LHBhc3N3b3JkPXV1aWQsZmFzdC1vcGVuPXRydWUsdWRwLXJlbGF5PXRydWUsdGFnPVZtZXNzLHRscy12ZXJpZmljYXRpb249dHJ1ZSxvYmZzLXVyaT0vd3Msb2Jmcy1ob3N0PWJhaWR1LmNvbQ0K
}
+23
View File
@@ -0,0 +1,23 @@
package quantumultx
import (
"fmt"
"strings"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
func buildShadowsocks(data proxy.Proxy, uuid string) string {
ss := data.Option.(proxy.Shadowsocks)
addr := fmt.Sprintf("%s:%d", data.Server, data.Port)
config := []string{
addr,
fmt.Sprintf("method=%s", ss.Method),
fmt.Sprintf("password=%s", uuid),
"fast-open=true",
"udp-relay=true",
fmt.Sprintf("tag=%s", data.Name),
}
return strings.Join(config, ",") + "\r\n"
}
+39
View File
@@ -0,0 +1,39 @@
package quantumultx
import (
"fmt"
"strings"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
// 生成 Trojan 配置
func buildTrojan(data proxy.Proxy, password string) string {
trojan := data.Option.(proxy.Trojan)
addr := fmt.Sprintf("trojan=%s:%d", data.Server, data.Port)
config := []string{
addr,
fmt.Sprintf("password=%s", password),
"fast-open=true",
"udp-relay=true",
fmt.Sprintf("tag=%s", data.Name),
}
if trojan.Transport == "websocket" {
config = append(config, "obfs=wss")
if trojan.TransportConfig.Path != "" {
config = append(config, fmt.Sprintf("obfs-uri=%s", trojan.TransportConfig.Path))
}
if trojan.TransportConfig.Host != "" {
config = append(config, fmt.Sprintf("obfs-host=%s", trojan.TransportConfig.Host))
}
} else {
config = append(config, "over-tls=true")
if trojan.SecurityConfig.SNI != "" {
config = append(config, fmt.Sprintf("tls-host=%s", trojan.SecurityConfig.SNI))
}
}
return strings.Join(config, ",") + "\r\n"
}
+45
View File
@@ -0,0 +1,45 @@
package quantumultx
import (
"fmt"
"strings"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
func buildVmess(data proxy.Proxy, uuid string) string {
vmess := data.Option.(proxy.Vmess)
addr := fmt.Sprintf("vmess=%s:%d", data.Server, data.Port)
var host string
uriConfig := []string{
addr,
"method=chacha20-poly1305",
fmt.Sprintf("password=%s", uuid),
"fast-open=true",
"udp-relay=true",
fmt.Sprintf("tag=%s", data.Name),
}
if vmess.Security == "tls" {
if vmess.Transport == "tcp" {
uriConfig = append(uriConfig, "obfs=over-tls")
}
if vmess.SecurityConfig.AllowInsecure {
uriConfig = append(uriConfig, "tls-verification=true")
} else {
uriConfig = append(uriConfig, "tls-verification=false")
}
if vmess.SecurityConfig.SNI != "" {
host = vmess.SecurityConfig.SNI
}
}
if vmess.Transport == "websocket" {
uriConfig = append(uriConfig, fmt.Sprintf("obfs-uri=%s", vmess.TransportConfig.Path))
host = vmess.TransportConfig.Host
}
if host != "" {
uriConfig = append(uriConfig, fmt.Sprintf("obfs-host=%s", host))
}
return strings.Join(uriConfig, ",") + "\r\n"
}
+48
View File
@@ -0,0 +1,48 @@
package shadowrocket
import (
"fmt"
"time"
"github.com/perfect-panel/ppanel-server/pkg/adapter/general"
"encoding/base64"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
"github.com/perfect-panel/ppanel-server/pkg/traffic"
)
type UserInfo struct {
Upload int64
Download int64
TotalTraffic int64
ExpiredDate time.Time
}
func BuildShadowrocket(servers []proxy.Proxy, uuid string, userinfo UserInfo) []byte {
upload := traffic.AutoConvert(userinfo.Upload, false)
download := traffic.AutoConvert(userinfo.Download, false)
total := traffic.AutoConvert(userinfo.TotalTraffic, false)
expiredAt := userinfo.ExpiredDate.Format("2006-01-02 15:04:05")
uri := fmt.Sprintf("STATUS=🚀↑:%s,↓:%s,TOT:%s💡Expires:%s\r\n", upload, download, total, expiredAt)
for _, s := range servers {
switch s.Protocol {
case "vmess":
uri += buildVmess(s, uuid)
case "shadowsocks":
uri += general.ShadowsocksUri(s, uuid) + "\r\n"
case "trojan":
uri += general.TrojanUri(s, uuid) + "\r\n"
case "vless":
uri += general.VlessUri(s, uuid) + "\r\n"
case "hysteria2":
uri += general.Hysteria2Uri(s, uuid) + "\r\n"
case "tuic":
uri += general.TuicUri(s, uuid) + "\r\n"
default:
continue
}
}
return []byte(base64.StdEncoding.EncodeToString([]byte(uri)))
}
@@ -0,0 +1,76 @@
package shadowrocket
import (
"testing"
"time"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
func createVMess() proxy.Proxy {
return proxy.Proxy{
Name: "Vmess",
Server: "test.xxxx.com",
Port: 13002,
Protocol: "vmess",
Option: proxy.Vmess{
Port: 13002,
Transport: "websocket",
TransportConfig: proxy.TransportConfig{
Path: "/ws",
Host: "test.xx.com",
},
Security: "none",
},
}
}
func createSS() proxy.Proxy {
return proxy.Proxy{
Name: "Shadowsocks",
Server: "test.xxxx.com",
Port: 10301,
Protocol: "shadowsocks",
Option: proxy.Shadowsocks{
Port: 10301,
Method: "aes-256-gcm",
ServerKey: "123456",
},
}
}
func createTrojan() proxy.Proxy {
return proxy.Proxy{
Name: "Trojan",
Server: "test.xxxx.com",
Port: 13002,
Protocol: "trojan",
Option: proxy.Trojan{
Port: 13002,
Transport: "websocket",
TransportConfig: proxy.TransportConfig{
Path: "/ws",
Host: "baidu.com",
},
SecurityConfig: proxy.SecurityConfig{
SNI: "baidu.com",
AllowInsecure: true,
},
},
}
}
func TestBuildShadowrocket(t *testing.T) {
s := []proxy.Proxy{
createVMess(),
createSS(),
createTrojan(),
}
uri := BuildShadowrocket(s, "uuid", UserInfo{
Upload: 1024,
Download: 1024,
TotalTraffic: 2048,
ExpiredDate: time.Now().AddDate(0, 0, 1),
})
t.Log(string(uri))
}
+57
View File
@@ -0,0 +1,57 @@
package shadowrocket
import (
"fmt"
"strings"
"encoding/base64"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
func buildVmess(data proxy.Proxy, uuid string) string {
vmess := data.Option.(proxy.Vmess)
userinfo := fmt.Sprintf("auto:%s@%s:%d", uuid, data.Server, data.Port)
// 准备 config,使用默认值
config := map[string]interface{}{
"tfo": 1,
"remark": data.Name,
"alterId": 0,
}
// tls 配置
if vmess.Security == "tls" {
config["tls"] = 1
if vmess.SecurityConfig.AllowInsecure {
config["allowInsecure"] = 1
}
if vmess.SecurityConfig.SNI != "" {
config["peer"] = vmess.SecurityConfig.SNI
}
}
// transport 配置
switch vmess.Transport {
case "websocket":
config["obfs"] = "websocket"
if vmess.TransportConfig.Path != "" {
config["path"] = vmess.TransportConfig.Path
}
if vmess.TransportConfig.Host != "" {
config["obfsParam"] = vmess.TransportConfig.Host
}
case "grpc":
config["obfs"] = "grpc"
if vmess.TransportConfig.ServiceName != "" {
config["path"] = vmess.TransportConfig.ServiceName
}
}
query := make([]string, 0)
for k, v := range config {
query = append(query, fmt.Sprintf("%s=%v", k, v))
}
queryStr := strings.Join(query, "&")
uri := fmt.Sprintf("vmess://%s?%s\r\n", base64.StdEncoding.EncodeToString([]byte(userinfo)), queryStr)
return uri
}
+201
View File
@@ -0,0 +1,201 @@
package singbox
import (
"encoding/json"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
"github.com/perfect-panel/ppanel-server/pkg/logger"
)
func BuildSingbox(adapter proxy.Adapter, uuid string) ([]byte, error) {
// build outbounds type is Proxy
var proxies []Proxy
// build outbound group
for _, group := range adapter.Group {
if group.Type == proxy.GroupTypeSelect {
selector := Proxy{
Type: Selector,
Tag: group.Name,
SelectorOptions: &SelectorOutboundOptions{
OutboundOptions: OutboundOptions{
Tag: group.Name,
Type: Selector,
},
Outbounds: group.Proxies,
Default: group.Proxies[0],
InterruptExistConnections: false,
},
}
proxies = append(proxies, selector)
} else if group.Type == proxy.GroupTypeURLTest {
selector := Proxy{
Type: URLTest,
Tag: group.Name,
URLTestOptions: &URLTestOutboundOptions{
OutboundOptions: OutboundOptions{
Tag: group.Name,
Type: URLTest,
},
Outbounds: group.Proxies,
URL: group.URL,
},
}
proxies = append(proxies, selector)
} else {
logger.Errorf("[sing-box] Unknown group type: %s, group name: %s", group.Type, group.Name)
}
}
// build outbounds
for _, data := range adapter.Proxies {
p := buildProxy(data, uuid)
if p == nil {
continue
}
proxies = append(proxies, *p)
}
// add direct outbound
direct := Proxy{
Type: Direct,
Tag: "DIRECT",
}
// add block outbound
block := Proxy{
Type: Block,
Tag: "block",
}
// add dns outbound
dns := Proxy{
Type: DNS,
Tag: "dns-out",
}
proxies = append(proxies, direct, block, dns)
var rawConfig map[string]any
if err := json.Unmarshal([]byte(DefaultTemplate), &rawConfig); err != nil {
return nil, err
}
rawConfig["outbounds"] = proxies
route := RouteOptions{
Final: "手动选择",
Rules: []Rule{
{
Inbound: []string{
"tun-in",
"mixed-in",
},
Action: "sniff",
},
{
Type: "logical",
Mode: "or",
Rules: []Rule{
{
Port: []uint16{53},
},
{
Protocol: []string{"dns"},
},
},
Action: "hijack-dns",
},
{
RuleSet: []string{
"geosite-category-ads-all",
},
ClashMode: "rule",
Action: "reject",
},
{
ClashMode: "direct",
Outbound: "DIRECT",
},
{
ClashMode: "global",
Outbound: "手动选择",
},
{
IPIsPrivate: true,
Outbound: "DIRECT",
},
{
RuleSet: []string{
"geosite-private",
},
Outbound: "DIRECT",
},
},
RuleSet: []RuleSet{
{
Tag: "geoip-cn",
Type: "remote",
Format: "binary",
URL: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@sing/geo/geoip/cn.srs",
DownloadDetour: "DIRECT",
},
{
Tag: "geosite-cn",
Type: "remote",
Format: "binary",
URL: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@sing/geo/geosite/cn.srs",
DownloadDetour: "DIRECT",
},
{
Tag: "geosite-private",
Type: "remote",
Format: "binary",
URL: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@sing/geo/geosite/private.srs",
DownloadDetour: "DIRECT",
},
{
Tag: "geosite-category-ads-all",
Type: "remote",
Format: "binary",
URL: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@sing/geo/geosite/category-ads-all.srs",
DownloadDetour: "DIRECT",
},
{
Tag: "geosite-geolocation-!cn",
Type: "remote",
Format: "binary",
URL: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@sing/geo/geosite/geolocation-!cn.srs",
DownloadDetour: "DIRECT",
},
},
AutoDetectInterface: true,
}
route.Rules = append(route.Rules, adapterToSingboxRule(adapter.Rules)...)
rawConfig["route"] = route
return json.Marshal(rawConfig)
}
func buildProxy(data proxy.Proxy, uuid string) *Proxy {
var p *Proxy
var err error
switch data.Protocol {
case VLESS:
p, err = ParseVless(data, uuid)
case Shadowsocks:
p, err = ParseShadowsocks(data, uuid)
case Trojan:
p, err = ParseTrojan(data, uuid)
case VMess:
p, err = ParseVMess(data, uuid)
case Hysteria2:
p, err = ParseHysteria2(data, uuid)
case TUIC:
p, err = ParseTUIC(data, uuid)
default:
logger.Error("Unknown protocol", logger.Field("protocol", data.Protocol), logger.Field("server", data.Name))
}
if err != nil {
logger.Error("ParseVless", logger.Field("error", err.Error()), logger.Field("server", data.Name), logger.Field("protocol", data.Protocol))
return nil
}
return p
}
+100
View File
@@ -0,0 +1,100 @@
package singbox
const DefaultTemplate = `
{
"log": {
"level": "info",
"timestamp": true
},
"experimental": {
"clash_api": {
"external_controller": "127.0.0.1:9090",
"external_ui": "ui",
"secret": "",
"external_ui_download_url": "https://mirror.ghproxy.com/https://github.com/MetaCubeX/Yacd-meta/archive/gh-pages.zip",
"external_ui_download_detour": "direct",
"default_mode": "rule"
},
"cache_file": {
"enabled": true,
"store_fakeip": false
}
},
"dns": {
"servers": [
{
"tag": "dns_proxy",
"address": "tls://8.8.8.8",
"detour": "手动选择"
},
{
"tag": "dns_direct",
"address": "https://223.5.5.5/dns-query",
"detour": "DIRECT"
}
],
"rules": [
{
"outbound": "any",
"server": "dns_direct",
"disable_cache": true
},
{
"rule_set": "geosite-cn",
"server": "dns_direct"
},
{
"clash_mode": "direct",
"server": "dns_direct"
},
{
"clash_mode": "global",
"server": "dns_proxy"
},
{
"rule_set": "geosite-geolocation-!cn",
"server": "dns_proxy"
}
],
"final": "dns_direct",
"strategy": "ipv4_only"
},
"route": {
"rules": [
{
"action": "sniff"
},
{
"protocol": "dns",
"action": "hijack-dns"
}
]
},
"inbounds": [
{
"tag": "tun-in",
"type": "tun",
"address": [
"172.18.0.1/30",
"fdfe:dcba:9876::1/126"
],
"auto_route": true,
"strict_route": true,
"stack": "system",
"platform": {
"http_proxy": {
"enabled": true,
"server": "127.0.0.1",
"server_port": 7890
}
}
},
{
"tag": "mixed-in",
"type": "mixed",
"listen": "127.0.0.1",
"listen_port": 7890
}
]
}
`
+76
View File
@@ -0,0 +1,76 @@
package singbox
import (
"strings"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
type Hysteria2Obfs struct {
Type string `json:"type,omitempty"`
Password string `json:"password,omitempty"`
}
type Hysteria2OutboundOptions struct {
ServerOptions
ServerPorts []string `json:"server_ports,omitempty"`
HopInterval int `json:"hop_interval,omitempty"`
UpMbps int `json:"up_mbps,omitempty"`
DownMbps int `json:"down_mbps,omitempty"`
Obfs *Hysteria2Obfs `json:"obfs,omitempty"`
Password string `json:"password,omitempty"`
Network string `json:"network,omitempty"`
OutboundTLSOptionsContainer
Multiplex *OutboundMultiplexOptions `json:"multiplex,omitempty"`
Transport *V2RayTransportOptions `json:"transport,omitempty"`
}
func ParseHysteria2(data proxy.Proxy, password string) (*Proxy, error) {
hysteria2 := data.Option.(proxy.Hysteria2)
p := &Proxy{
Tag: data.Name,
Type: Hysteria2,
Hysteria2Options: &Hysteria2OutboundOptions{
ServerOptions: ServerOptions{
Tag: data.Name,
Type: Hysteria2,
Server: data.Server,
},
Password: password,
},
}
var ports []string
if hysteria2.HopPorts != "" {
ps := strings.Split(hysteria2.HopPorts, ",")
for _, port := range ps {
// 舍弃单个端口,只保留端口范围
if len(strings.Split(port, "-")) > 1 {
tmp := strings.Split(port, "-")
ports = append(ports, strings.Join(tmp, ":"))
}
}
}
if len(ports) > 0 {
p.Hysteria2Options.ServerPorts = ports
p.Hysteria2Options.HopInterval = hysteria2.HopInterval
} else {
p.Hysteria2Options.ServerPort = data.Port
}
if hysteria2.ObfsPassword != "" {
p.Hysteria2Options.Obfs = &Hysteria2Obfs{
Type: "salamander",
Password: hysteria2.ObfsPassword,
}
}
var tls *OutboundTLSOptions
if hysteria2.SecurityConfig.SNI != "" {
tls = NewOutboundTLSOptions("tls", hysteria2.SecurityConfig)
}
p.Hysteria2Options.TLS = tls
return p, nil
}
+17
View File
@@ -0,0 +1,17 @@
package singbox
type OutboundMultiplexOptions struct {
Enabled bool `json:"enabled,omitempty"`
Protocol string `json:"protocol,omitempty"`
MaxConnections int `json:"max_connections,omitempty"`
MinStreams int `json:"min_streams,omitempty"`
MaxStreams int `json:"max_streams,omitempty"`
Padding bool `json:"padding,omitempty"`
Brutal *BrutalOptions `json:"brutal,omitempty"`
}
type BrutalOptions struct {
Enabled bool `json:"enabled,omitempty"`
UpMbps int `json:"up_mbps,omitempty"`
DownMbps int `json:"down_mbps,omitempty"`
}
+130
View File
@@ -0,0 +1,130 @@
package singbox
import (
"strconv"
"github.com/perfect-panel/ppanel-server/pkg/logger"
"github.com/perfect-panel/ppanel-server/pkg/rules"
)
type Rule struct {
Outbound string `json:"outbound,omitempty"`
ClashMode string `json:"clash_mode,omitempty"`
RuleSet []string `json:"rule_set,omitempty"`
Domain []string `json:"domain,omitempty"`
DomainSuffix []string `json:"domain_suffix,omitempty"`
DomainKeyword []string `json:"domain_keyword,omitempty"`
DomainRegex []string `json:"domain_regex,omitempty"`
GeoIP []string `json:"geoip,omitempty"`
IPCIDR []string `json:"ip_cidr,omitempty"`
IPIsPrivate bool `json:"ip_is_private,omitempty"`
SourceIPCIDR []string `json:"source_ip_cidr,omitempty"`
ProcessName []string `json:"process_name,omitempty"`
ProcessPath []string `json:"process_path,omitempty"`
SourcePort []uint16 `json:"source_port,omitempty"`
Protocol []string `json:"protocol,omitempty"`
Port []uint16 `json:"port,omitempty"`
Action string `json:"action,omitempty"`
Inbound []string `json:"inbound,omitempty"`
Rules []Rule `json:"rules,omitempty"`
Type string `json:"type,omitempty"`
Mode string `json:"mode,omitempty"`
}
type RuleSet struct {
Tag string `json:"tag,omitempty"`
Type string `json:"type,omitempty"`
Format string `json:"format,omitempty"`
URL string `json:"url,omitempty"`
DownloadDetour string `json:"download_detour,omitempty"`
}
func adapterToSingboxRule(texts []string) []Rule {
var rulesList []Rule
for _, rule := range texts {
r := rules.NewRule(rule, "")
if r == nil {
continue
}
rulesList = addRuleToItem(rulesList, r.Target, *r)
}
return rulesList
}
func addRuleToItem(group []Rule, outbound string, rule rules.Rule) []Rule {
for i := range group {
if group[i].Outbound == outbound {
switch rules.ParseRuleType(rule.Type) {
case rules.Domain:
group[i].Domain = append(group[i].Domain, rule.Payload)
return group
case rules.DomainSuffix:
group[i].DomainSuffix = append(group[i].DomainSuffix, rule.Payload)
return group
case rules.DomainKeyword:
group[i].DomainKeyword = append(group[i].DomainKeyword, rule.Payload)
return group
case rules.IPCIDR:
group[i].IPCIDR = append(group[i].IPCIDR, rule.Payload)
return group
case rules.SrcIPCIDR:
group[i].SourceIPCIDR = append(group[i].SourceIPCIDR, rule.Payload)
return group
case rules.SrcPort:
port, err := strconv.ParseUint(rule.Payload, 10, 16)
if err != nil {
logger.Errorf("[adapterToSingboxRule] failed to parse port %s to uint16", rule.Payload)
return group
}
group[i].SourcePort = append(group[i].SourcePort, uint16(port))
return group
case rules.GEOIP:
group[i].GeoIP = append(group[i].GeoIP, rule.Payload)
return group
case rules.Process:
group[i].ProcessName = append(group[i].ProcessName, rule.Payload)
return group
case rules.ProcessPath:
group[i].ProcessPath = append(group[i].ProcessPath, rule.Payload)
return group
default:
logger.Errorf("[adapterToSingboxRule] unknown rule type %s", rule.Type)
return group
}
}
}
newRule := Rule{
Outbound: outbound,
}
switch rules.ParseRuleType(rule.Type) {
case rules.Domain:
newRule.Domain = []string{rule.Payload}
case rules.DomainSuffix:
newRule.DomainSuffix = []string{rule.Payload}
case rules.DomainKeyword:
newRule.DomainKeyword = []string{rule.Payload}
case rules.IPCIDR:
newRule.IPCIDR = []string{rule.Payload}
case rules.SrcIPCIDR:
newRule.SourceIPCIDR = []string{rule.Payload}
case rules.SrcPort:
port, err := strconv.ParseUint(rule.Payload, 10, 16)
if err != nil {
logger.Errorf("[adapterToSingboxRule] failed to parse port %s to uint16", rule.Payload)
return group
}
newRule.SourcePort = []uint16{uint16(port)}
case rules.GEOIP:
newRule.GeoIP = []string{rule.Payload}
case rules.Process:
newRule.ProcessName = []string{rule.Payload}
case rules.ProcessPath:
newRule.ProcessPath = []string{rule.Payload}
default:
logger.Errorf("[adapterToSingboxRule] unknown rule type %s", rule.Type)
return group
}
group = append(group, newRule)
return group
}
+15
View File
@@ -0,0 +1,15 @@
package singbox
import (
"fmt"
"testing"
)
func TestAdapterToSingboxRule(t *testing.T) {
rules := []string{
"DOMAIN,example.com,DIRECT",
"DOMAIN-SUFFIX,google.com,智能线路",
}
result := adapterToSingboxRule(rules)
fmt.Printf("TestAdapterToSingboxRule: result: %+v\n", result)
}
+34
View File
@@ -0,0 +1,34 @@
package singbox
import (
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
type ShadowsocksOptions struct {
ServerOptions
Method string `json:"method,omitempty"`
Password string `json:"password,omitempty"`
Plugin string `json:"plugin,omitempty"`
PluginOptions string `json:"plugin_opts,omitempty"`
Network string `json:"network,omitempty"`
}
func ParseShadowsocks(data proxy.Proxy, uuid string) (*Proxy, error) {
config := data.Option.(proxy.Shadowsocks)
p := &Proxy{
Tag: data.Name,
Type: Shadowsocks,
ShadowsocksOptions: &ShadowsocksOptions{
ServerOptions: ServerOptions{
Tag: data.Name,
Type: Shadowsocks,
Server: data.Server,
ServerPort: data.Port,
},
Method: config.Method,
Password: uuid,
Network: "tcp",
},
}
return p, nil
}
+98
View File
@@ -0,0 +1,98 @@
package singbox
import (
"encoding/json"
"fmt"
)
const (
Trojan = "trojan"
VLESS = "vless"
VMess = "vmess"
TUIC = "tuic"
Hysteria2 = "hysteria2"
Shadowsocks = "shadowsocks"
Selector = "selector"
URLTest = "urltest"
Direct = "direct"
Block = "block"
DNS = "dns"
)
type Proxy struct {
Tag string `json:"tag,omitempty"`
Type string `json:"type"`
ShadowsocksOptions *ShadowsocksOptions `json:"-"`
TUICOptions *TUICOutboundOptions `json:"-"`
TrojanOptions *TrojanOutboundOptions `json:"-"`
VLESSOptions *VLESSOutboundOptions `json:"-"`
VMessOptions *VMessOutboundOptions `json:"-"`
Hysteria2Options *Hysteria2OutboundOptions `json:"-"`
SelectorOptions *SelectorOutboundOptions `json:"-"`
URLTestOptions *URLTestOutboundOptions `json:"-"`
}
type ServerOptions struct {
Tag string `json:"tag"`
Type string `json:"type"`
Server string `json:"server"`
ServerPort int `json:"server_port,omitempty"`
}
type OutboundOptions struct {
Tag string `json:"tag"`
Type string `json:"type"`
}
type SelectorOutboundOptions struct {
OutboundOptions
Outbounds []string `json:"outbounds"`
Default string `json:"default,omitempty"`
InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"`
}
type URLTestOutboundOptions struct {
OutboundOptions
Outbounds []string `json:"outbounds"`
URL string `json:"url,omitempty"`
Interval Duration `json:"interval,omitempty"`
Tolerance uint16 `json:"tolerance,omitempty"`
IdleTimeout Duration `json:"idle_timeout,omitempty"`
InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"`
}
type RouteOptions struct {
Rules []Rule `json:"rules,omitempty"`
Final string `json:"final,omitempty"`
RuleSet []RuleSet `json:"rule_set,omitempty"`
AutoDetectInterface bool `json:"auto_detect_interface,omitempty"`
}
func (p Proxy) MarshalJSON() ([]byte, error) {
type Alias Proxy
aux := struct {
Alias
}{
Alias: (Alias)(p),
}
switch p.Type {
case Shadowsocks:
return json.Marshal(p.ShadowsocksOptions)
case TUIC:
return json.Marshal(p.TUICOptions)
case Trojan:
return json.Marshal(p.TrojanOptions)
case VLESS:
return json.Marshal(p.VLESSOptions)
case VMess:
return json.Marshal(p.VMessOptions)
case Hysteria2:
return json.Marshal(p.Hysteria2Options)
case Selector:
return json.Marshal(p.SelectorOptions)
case URLTest:
return json.Marshal(p.URLTestOptions)
case Direct, Block, DNS:
return json.Marshal(aux.Alias)
default:
return nil, fmt.Errorf("[sing-box] MarshalJSON unknown type: %s", p.Type)
}
}
+80
View File
@@ -0,0 +1,80 @@
package singbox
import (
"testing"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
"github.com/stretchr/testify/assert"
)
func createSS() proxy.Proxy {
c := proxy.Shadowsocks{
Method: "aes-256-gcm",
Port: 10301,
ServerKey: "",
}
return proxy.Proxy{
Name: "Shadowsocks",
Server: "127.0.0.1",
Port: 10301,
Protocol: "shadowsocks",
Option: c,
}
}
func createVLESS() proxy.Proxy {
c := proxy.Vless{
Port: 10301,
Flow: "xtls-rprx-direct",
Transport: "websocket",
TransportConfig: proxy.TransportConfig{
Path: "/ws",
Host: "baidu.com",
},
Security: "tls",
SecurityConfig: proxy.SecurityConfig{
SNI: "baidu.com",
Fingerprint: "chrome",
AllowInsecure: true,
},
}
s := proxy.Proxy{
Name: "VLESS",
Server: "test.xxx.com",
Port: 10301,
Protocol: "vless",
Option: c,
}
return s
}
func TestSingboxShadowsocks(t *testing.T) {
s := createSS()
p, err := ParseShadowsocks(s, "uuid")
if err != nil {
t.Fatal(err)
}
data, err := p.MarshalJSON()
if err != nil {
t.Fatal(err)
}
assert.NotEqual(t, 0, len(data))
// Output:
// proxy: proxy: {"tag":"Shadowsocks","type":"shadowsocks","server":"127.0.0.1","server_port":10301,"method":"aes-256-gcm","password":"uuid","network":"tcp"}
}
func TestSingboxVless(t *testing.T) {
s := createVLESS()
p, err := ParseVless(s, "uuid")
if err != nil {
t.Fatal(err)
}
data, err := p.MarshalJSON()
if err != nil {
t.Fatal(err)
}
assert.NotEqual(t, 0, len(data))
}
+87
View File
@@ -0,0 +1,87 @@
package singbox
import (
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
type OutboundTLSOptions struct {
Enabled bool `json:"enabled,omitempty"`
DisableSNI bool `json:"disable_sni,omitempty"`
ServerName string `json:"server_name,omitempty"`
Insecure bool `json:"insecure,omitempty"`
ALPN Listable[string] `json:"alpn,omitempty"`
MinVersion string `json:"min_version,omitempty"`
MaxVersion string `json:"max_version,omitempty"`
CipherSuites Listable[string] `json:"cipher_suites,omitempty"`
Certificate Listable[string] `json:"certificate,omitempty"`
CertificatePath string `json:"certificate_path,omitempty"`
ECH *OutboundECHOptions `json:"ech,omitempty"`
UTLS *OutboundUTLSOptions `json:"utls,omitempty"`
Reality *OutboundRealityOptions `json:"reality,omitempty"`
}
func NewOutboundTLSOptions(security string, cfg proxy.SecurityConfig) *OutboundTLSOptions {
var tls = &OutboundTLSOptions{}
switch security {
case "none":
return nil
case "tls":
tls.Enabled = true
if cfg.SNI != "" {
tls.ServerName = cfg.SNI
} else {
tls.DisableSNI = true
}
tls.Insecure = cfg.AllowInsecure
if cfg.Fingerprint != "" {
tls.UTLS = &OutboundUTLSOptions{
Enabled: true,
Fingerprint: cfg.Fingerprint,
}
}
case "reality":
tls.Enabled = true
if cfg.SNI != "" {
tls.ServerName = cfg.SNI
} else {
tls.DisableSNI = true
}
tls.Insecure = cfg.AllowInsecure
if cfg.Fingerprint != "" {
tls.UTLS = &OutboundUTLSOptions{
Enabled: true,
Fingerprint: cfg.Fingerprint,
}
}
tls.Reality = &OutboundRealityOptions{
Enabled: true,
PublicKey: cfg.RealityPublicKey,
ShortID: cfg.RealityShortId,
}
}
return tls
}
type OutboundECHOptions struct {
Enabled bool `json:"enabled,omitempty"`
PQSignatureSchemesEnabled bool `json:"pq_signature_schemes_enabled,omitempty"`
DynamicRecordSizingDisabled bool `json:"dynamic_record_sizing_disabled,omitempty"`
Config Listable[string] `json:"config,omitempty"`
ConfigPath string `json:"config_path,omitempty"`
}
type OutboundRealityOptions struct {
Enabled bool `json:"enabled,omitempty"`
PublicKey string `json:"public_key,omitempty"`
ShortID string `json:"short_id,omitempty"`
}
type OutboundUTLSOptions struct {
Enabled bool `json:"enabled,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
}
type Listable[T any] []T
type OutboundTLSOptionsContainer struct {
TLS *OutboundTLSOptions `json:"tls,omitempty"`
}
+11
View File
@@ -0,0 +1,11 @@
package singbox
import "encoding/json"
func mergeOptions(target map[string]any, options any) error {
optionsJSON, err := json.Marshal(options)
if err != nil {
return err
}
return json.Unmarshal(optionsJSON, &target)
}
+39
View File
@@ -0,0 +1,39 @@
package singbox
import (
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
type TrojanOutboundOptions struct {
ServerOptions
Password string `json:"password"`
Network string `json:"network,omitempty"`
OutboundTLSOptionsContainer
Multiplex *OutboundMultiplexOptions `json:"multiplex,omitempty"`
Transport *V2RayTransportOptions `json:"transport,omitempty"`
}
func ParseTrojan(data proxy.Proxy, uuid string) (*Proxy, error) {
trojan := data.Option.(proxy.Trojan)
p := &Proxy{
Tag: data.Name,
Type: Trojan,
TrojanOptions: &TrojanOutboundOptions{
ServerOptions: ServerOptions{
Tag: data.Name,
Type: Trojan,
Server: data.Server,
ServerPort: data.Port,
},
Password: uuid,
},
}
// Transport options
transport := NewV2RayTransportOptions(trojan.Transport, trojan.TransportConfig)
p.TrojanOptions.Transport = transport
// Security options
p.TrojanOptions.TLS = NewOutboundTLSOptions(trojan.Security, trojan.SecurityConfig)
return p, nil
}
+40
View File
@@ -0,0 +1,40 @@
package singbox
import (
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
type TUICOutboundOptions struct {
ServerOptions
UUID string `json:"uuid,omitempty"`
Password string `json:"password,omitempty"`
CongestionControl string `json:"congestion_control,omitempty"`
UDPRelayMode string `json:"udp_relay_mode,omitempty"`
UDPOverStream bool `json:"udp_over_stream,omitempty"`
ZeroRTTHandshake bool `json:"zero_rtt_handshake,omitempty"`
Heartbeat string `json:"heartbeat,omitempty"`
Network string `json:"network,omitempty"`
OutboundTLSOptionsContainer
}
func ParseTUIC(data proxy.Proxy, uuid string) (*Proxy, error) {
tuic := data.Option.(proxy.Tuic)
p := &Proxy{
Tag: data.Name,
Type: TUIC,
TUICOptions: &TUICOutboundOptions{
ServerOptions: ServerOptions{
Tag: data.Name,
Type: TUIC,
Server: data.Server,
ServerPort: data.Port,
},
UUID: uuid,
Password: uuid,
CongestionControl: "bbr",
},
}
// Security options
p.TUICOptions.TLS = NewOutboundTLSOptions("tls", tuic.SecurityConfig)
return p, nil
}
+114
View File
@@ -0,0 +1,114 @@
package singbox
import (
"encoding/json"
"time"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
type V2RayTransportOptions struct {
Type string `json:"type"`
HTTPOptions V2RayHTTPOptions `json:"-"`
WebsocketOptions V2RayWebsocketOptions `json:"-"`
QUICOptions V2RayQUICOptions `json:"-"`
GRPCOptions V2RayGRPCOptions `json:"-"`
HTTPUpgradeOptions V2RayHTTPUpgradeOptions `json:"-"`
}
func (v V2RayTransportOptions) MarshalJSON() ([]byte, error) {
var v2rayTransportOptions any
data := map[string]any{
"type": v.Type,
}
switch v.Type {
case "http":
v2rayTransportOptions = v.HTTPOptions
case "ws":
v2rayTransportOptions = v.WebsocketOptions
case "quic":
v2rayTransportOptions = v.QUICOptions
case "grpc":
v2rayTransportOptions = v.GRPCOptions
case "httpupgrade":
v2rayTransportOptions = v.HTTPUpgradeOptions
}
if err := mergeOptions(data, v2rayTransportOptions); err != nil {
return nil, err
}
return json.Marshal(data)
}
func NewV2RayTransportOptions(network string, transport proxy.TransportConfig) *V2RayTransportOptions {
var t *V2RayTransportOptions = nil
switch network {
case "websocket":
t = &V2RayTransportOptions{
Type: "ws",
WebsocketOptions: V2RayWebsocketOptions{
Path: transport.Path,
Headers: map[string]Listable[string]{
"Host": []string{transport.Host},
},
MaxEarlyData: 2048,
EarlyDataHeaderName: "Sec-WebSocket-Protocol",
},
}
case "httpupgrade":
t = &V2RayTransportOptions{
Type: "httpupgrade",
HTTPOptions: V2RayHTTPOptions{
Path: transport.Path,
Host: []string{transport.Host},
Headers: map[string]Listable[string]{
"Host": []string{transport.Host},
},
},
}
case "grpc":
t = &V2RayTransportOptions{
Type: "grpc",
GRPCOptions: V2RayGRPCOptions{
ServiceName: transport.ServiceName,
},
}
}
return t
}
type V2RayHTTPOptions struct {
Host Listable[string] `json:"host,omitempty"`
Path string `json:"path,omitempty"`
Method string `json:"method,omitempty"`
Headers HTTPHeader `json:"headers,omitempty"`
IdleTimeout Duration `json:"idle_timeout,omitempty"`
PingTimeout Duration `json:"ping_timeout,omitempty"`
}
type V2RayWebsocketOptions struct {
Path string `json:"path,omitempty"`
Headers HTTPHeader `json:"headers,omitempty"`
MaxEarlyData uint32 `json:"max_early_data,omitempty"`
EarlyDataHeaderName string `json:"early_data_header_name,omitempty"`
}
type V2RayQUICOptions struct{}
type V2RayGRPCOptions struct {
ServiceName string `json:"service_name,omitempty"`
IdleTimeout string `json:"idle_timeout,omitempty"`
PingTimeout string `json:"ping_timeout,omitempty"`
PermitWithoutStream bool `json:"permit_without_stream,omitempty"`
ForceLite bool `json:"-"` // for test
}
type V2RayHTTPUpgradeOptions struct {
Host string `json:"host,omitempty"`
Path string `json:"path,omitempty"`
Headers HTTPHeader `json:"headers,omitempty"`
}
type HTTPHeader map[string]Listable[string]
type Duration time.Duration
+44
View File
@@ -0,0 +1,44 @@
package singbox
import (
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
type VLESSOutboundOptions struct {
ServerOptions
OutboundTLSOptionsContainer
UUID string `json:"uuid"`
Flow string `json:"flow,omitempty"`
Network string `json:"network,omitempty"`
Multiplex *OutboundMultiplexOptions `json:"multiplex,omitempty"`
Transport *V2RayTransportOptions `json:"transport,omitempty"`
PacketEncoding *string `json:"packet_encoding,omitempty"`
}
func ParseVless(data proxy.Proxy, uuid string) (*Proxy, error) {
vless := data.Option.(proxy.Vless)
packetEncoding := "xudp"
p := &Proxy{
Tag: data.Name,
Type: VLESS,
VLESSOptions: &VLESSOutboundOptions{
ServerOptions: ServerOptions{
Tag: data.Name,
Type: VLESS,
Server: data.Server,
ServerPort: data.Port,
},
UUID: uuid,
Flow: vless.Flow,
PacketEncoding: &packetEncoding,
},
}
// Transport options
transport := NewV2RayTransportOptions(vless.Transport, vless.TransportConfig)
p.VLESSOptions.Transport = transport
// Security options
p.VLESSOptions.TLS = NewOutboundTLSOptions(vless.Security, vless.SecurityConfig)
return p, nil
}
+43
View File
@@ -0,0 +1,43 @@
package singbox
import (
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
type VMessOutboundOptions struct {
ServerOptions
UUID string `json:"uuid"`
Security string `json:"security"`
AlterId int `json:"alter_id,omitempty"`
GlobalPadding bool `json:"global_padding,omitempty"`
AuthenticatedLength bool `json:"authenticated_length,omitempty"`
Network string `json:"network,omitempty"`
PacketEncoding string `json:"packet_encoding,omitempty"`
Multiplex *OutboundMultiplexOptions `json:"multiplex,omitempty"`
Transport *V2RayTransportOptions `json:"transport,omitempty"`
OutboundTLSOptionsContainer
}
func ParseVMess(data proxy.Proxy, uuid string) (*Proxy, error) {
vmess := data.Option.(proxy.Vmess)
p := &Proxy{
Type: VMess,
VMessOptions: &VMessOutboundOptions{
ServerOptions: ServerOptions{
Tag: data.Name,
Type: VMess,
Server: data.Server,
ServerPort: data.Port,
},
UUID: uuid,
Security: "auto",
AlterId: 0,
},
}
// Transport options
p.VMessOptions.Transport = NewV2RayTransportOptions(vmess.Transport, vmess.TransportConfig)
// Security options
p.VMessOptions.TLS = NewOutboundTLSOptions(vmess.Security, vmess.SecurityConfig)
return p, nil
}
+111
View File
@@ -0,0 +1,111 @@
package surfboard
import (
"bytes"
"embed"
"fmt"
"net/url"
"strings"
"text/template"
"time"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
"github.com/perfect-panel/ppanel-server/pkg/logger"
"github.com/perfect-panel/ppanel-server/pkg/tool"
"github.com/perfect-panel/ppanel-server/pkg/traffic"
)
//go:embed *.tpl
var configFiles embed.FS
var shadowsocksSupportMethod = []string{"aes-128-gcm", "aes-192-gcm", "aes-256-gcm", "chacha20-ietf-poly1305"}
func BuildSurfboard(servers proxy.Adapter, siteName string, user UserInfo) []byte {
var proxies, proxyGroup string
for _, node := range servers.Proxies {
if uri := buildProxy(node, user.UUID); uri != "" {
proxies += uri
}
}
for _, group := range servers.Group {
if group.Type == proxy.GroupTypeSelect {
proxyGroup += fmt.Sprintf("%s = select, %s", group.Name, strings.Join(group.Proxies, ", ")) + "\r\n"
} else if group.Type == proxy.GroupTypeURLTest {
proxyGroup += fmt.Sprintf("%s = url-test, %s, url=%s, interval=%d", group.Name, strings.Join(group.Proxies, ", "), group.URL, group.Interval) + "\r\n"
} else if group.Type == proxy.GroupTypeFallback {
proxyGroup += fmt.Sprintf("%s = fallback, %s, url=%s, interval=%d", group.Name, strings.Join(group.Proxies, ", "), group.URL, group.Interval) + "\r\n"
} else {
logger.Errorf("[BuildSurfboard] unknown group type: %s", group.Type)
}
}
var rules string
for _, rule := range servers.Rules {
if rule == "" {
continue
}
rules += rule + "\r\n"
}
//final rule
rules += "# 最终规则" + "\r\n" + "FINAL, 手动选择"
file, err := configFiles.ReadFile("default.tpl")
if err != nil {
logger.Errorf("read default surfboard config error: %v", err.Error())
return nil
}
// replace template
tpl, err := template.New("default").Parse(string(file))
if err != nil {
logger.Errorf("read default surfboard config error: %v", err.Error())
return nil
}
var buf bytes.Buffer
var expiredAt string
if user.ExpiredDate.Before(time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)) {
expiredAt = "长期有效"
} else {
expiredAt = user.ExpiredDate.Format("2006-01-02 15:04:05")
}
// convert traffic
upload := traffic.AutoConvert(user.Upload, false)
download := traffic.AutoConvert(user.Download, false)
total := traffic.AutoConvert(user.TotalTraffic, false)
unusedTraffic := traffic.AutoConvert(user.TotalTraffic-user.Upload-user.Download, false)
// query Host
urlParse, err := url.Parse(user.SubscribeURL)
if err != nil {
return nil
}
if err := tpl.Execute(&buf, map[string]interface{}{
"Proxies": proxies,
"ProxyGroup": proxyGroup,
"SubscribeURL": user.SubscribeURL,
"SubscribeInfo": fmt.Sprintf("title=%s订阅信息, content=上传流量:%s\\n下载流量:%s\\n剩余流量: %s\\n套餐流量:%s\\n到期时间:%s", siteName, upload, download, unusedTraffic, total, expiredAt),
"SubscribeDomain": urlParse.Host,
"Rules": rules,
}); err != nil {
logger.Errorf("build surfboard config error: %v", err.Error())
return nil
}
return buf.Bytes()
}
func buildProxy(data proxy.Proxy, uuid string) string {
var p string
switch data.Protocol {
case "vmess":
p = buildVMess(data, uuid)
case "shadowsocks":
if !tool.Contains(shadowsocksSupportMethod, data.Option.(proxy.Shadowsocks).Method) {
return ""
}
p = buildShadowsocks(data, uuid)
case "trojan":
p = buildTrojan(data, uuid)
}
return p
}
+24
View File
@@ -0,0 +1,24 @@
package surfboard
import (
"testing"
"time"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
"github.com/perfect-panel/ppanel-server/pkg/uuidx"
)
func TestBuildSurfboard(t *testing.T) {
siteName := "test"
user := UserInfo{
UUID: uuidx.NewUUID().String(),
Upload: 0,
Download: 0,
TotalTraffic: 0,
ExpiredDate: time.Now().AddDate(0, 1, 1),
SubscribeURL: "https://test.com",
}
conf := BuildSurfboard(proxy.Adapter{}, siteName, user)
t.Log(string(conf))
}
+29
View File
@@ -0,0 +1,29 @@
#!MANAGED-CONFIG {{ .SubscribeURL }} interval=43200 strict=true
[General]
loglevel = notify
ipv6 = false
skip-proxy = localhost, *.local, injections.adguard.org, local.adguard.org, 0.0.0.0/8, 10.0.0.0/8, 17.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.168.0.0/16, 192.88.99.0/24, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4, 255.255.255.255/32
tls-provider = default
show-error-page-for-reject = true
dns-server = 223.6.6.6, 119.29.29.29, 119.28.28.28
test-timeout = 5
internet-test-url = http://bing.com
proxy-test-url = http://bing.com
[Panel]
SubscribeInfo = {{ .SubscribeInfo }}, style=info
# Surfboard 配置文档:https://manual.getsurfboard.com/
[Proxy]
# 代理列表
{{ .Proxies }}
[Proxy Group]
# 代理组列表
{{ .ProxyGroup }}
[Rule]
# 规则列表
{{ .Rules }}
+12
View File
@@ -0,0 +1,12 @@
package surfboard
import "time"
type UserInfo struct {
UUID string
Upload int64
Download int64
TotalTraffic int64
ExpiredDate time.Time
SubscribeURL string
}
+24
View File
@@ -0,0 +1,24 @@
package surfboard
import (
"fmt"
"strings"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
func buildShadowsocks(data proxy.Proxy, uuid string) string {
ss, ok := data.Option.(proxy.Shadowsocks)
if !ok {
return ""
}
addr := fmt.Sprintf("%s=ss, %s, %d", data.Name, data.Server, data.Port)
config := []string{
addr,
fmt.Sprintf("encrypt-method=%s", ss.Method),
fmt.Sprintf("password=%s", uuid),
"tfo=true",
"udp-relay=true",
}
return strings.Join(config, ",") + "\r\n"
}
+28
View File
@@ -0,0 +1,28 @@
package surfboard
import (
"testing"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
func createSS() proxy.Proxy {
return proxy.Proxy{
Name: "Shadowsocks",
Server: "test.xxxx.com",
Port: 10301,
Protocol: "shadowsocks",
Option: proxy.Shadowsocks{
Port: 10301,
Method: "aes-256-gcm",
ServerKey: "123456",
},
}
}
func TestShadowsocks(t *testing.T) {
node := createSS()
uuid := "123456"
shadowsocks := buildShadowsocks(node, uuid)
t.Log(shadowsocks)
}
+41
View File
@@ -0,0 +1,41 @@
package surfboard
import (
"strconv"
"strings"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
func buildTrojan(data proxy.Proxy, uuid string) string {
// $config = [
// "{$server['name']}=trojan",
// "{$server['host']}",
// "{$server['port']}",
// "password={$password}",
// $protocol_settings['server_name'] ? "sni={$protocol_settings['server_name']}" : "",
// 'tfo=true',
// 'udp-relay=true'
//];
trojan, ok := data.Option.(proxy.Trojan)
if !ok {
return ""
}
config := []string{
data.Name + "=trojan",
data.Server,
strconv.Itoa(data.Port),
"password=" + uuid,
"tfo=true",
"udp-relay=true",
}
if trojan.SecurityConfig.SNI != "" {
config = append(config, "sni="+trojan.SecurityConfig.SNI)
}
if trojan.SecurityConfig.AllowInsecure {
config = append(config, "skip-cert-verify=true")
} else {
config = append(config, "skip-cert-verify=false")
}
return strings.Join(config, ",") + "\r\n"
}
+36
View File
@@ -0,0 +1,36 @@
package surfboard
import (
"testing"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
func createTrojan() proxy.Proxy {
return proxy.Proxy{
Name: "Trojan",
Server: "test.xxxx.com",
Port: 13002,
Protocol: "trojan",
Option: proxy.Trojan{
Port: 13002,
Transport: "websocket",
TransportConfig: proxy.TransportConfig{
Path: "/ws",
Host: "baidu.com",
},
SecurityConfig: proxy.SecurityConfig{
SNI: "baidu.com",
AllowInsecure: true,
},
},
}
}
func TestTrojan(t *testing.T) {
node := createTrojan()
uuid := "123456"
trojan := buildTrojan(node, uuid)
t.Log(trojan)
}
+45
View File
@@ -0,0 +1,45 @@
package surfboard
import (
"fmt"
"strings"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
func buildVMess(data proxy.Proxy, uuid string) string {
vmess, ok := data.Option.(proxy.Vmess)
if !ok {
return ""
}
addr := fmt.Sprintf("%s=vmess, %s, %d", data.Name, data.Server, data.Port)
uriConfig := []string{
addr,
fmt.Sprintf("username=%s", uuid),
"vmess-aead=true",
"tfo=true",
"udp-relay=true",
}
if vmess.Security == "tls" {
uriConfig = append(uriConfig, "tls=true")
if vmess.SecurityConfig.AllowInsecure {
uriConfig = append(uriConfig, "skip-cert-verify=true")
} else {
uriConfig = append(uriConfig, "skip-cert-verify=false")
}
if vmess.SecurityConfig.SNI != "" {
uriConfig = append(uriConfig, fmt.Sprintf("sni=%s", vmess.SecurityConfig.SNI))
}
}
if vmess.Transport == "websocket" {
uriConfig = append(uriConfig, "ws=true")
if vmess.TransportConfig.Path != "" {
uriConfig = append(uriConfig, fmt.Sprintf("ws-path=%s", vmess.TransportConfig.Path))
}
if vmess.TransportConfig.Host != "" {
uriConfig = append(uriConfig, fmt.Sprintf("ws-headers=Host:%s", vmess.TransportConfig.Host))
}
}
return strings.Join(uriConfig, ",") + "\r\n"
}
+33
View File
@@ -0,0 +1,33 @@
package surfboard
import (
"testing"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
func createVMess() proxy.Proxy {
return proxy.Proxy{
Name: "Vmess",
Server: "test.xxxx.com",
Port: 13002,
Protocol: "vmess",
Option: proxy.Vmess{
Port: 13002,
Transport: "websocket",
TransportConfig: proxy.TransportConfig{
Path: "/ws",
Host: "test.xx.com",
},
Security: "none",
},
}
}
func TestVMess(t *testing.T) {
node := createVMess()
uuid := "123456"
p := buildVMess(node, uuid)
t.Log(p)
}
+61
View File
@@ -0,0 +1,61 @@
#!MANAGED-CONFIG {{ .SubscribeURL }} interval=43200 strict=true
# Surge 的规则配置手册: https://manual.nssurge.com/
[General]
loglevel = notify
# 从 Surge iOS 4 / Surge Mac 3.3.0 起,工具开始支持 DoH
doh-server = https://doh.pub/dns-query
# https://dns.alidns.com/dns-query, https://13800000000.rubyfish.cn/, https://dns.google/dns-query
dns-server = 223.5.5.5, 114.114.114.114
tun-excluded-routes = 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.168.0.0/16, 192.88.99.0/24, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 255.255.255.255/32
skip-proxy = localhost, *.local, injections.adguard.org, local.adguard.org, captive.apple.com, guzzoni.apple.com, 0.0.0.0/8, 10.0.0.0/8, 17.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.168.0.0/16, 192.88.99.0/24, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4, 255.255.255.255/32
wifi-assist = true
allow-wifi-access = true
wifi-access-http-port = 6152
wifi-access-socks5-port = 6153
http-listen = 0.0.0.0:6152
socks5-listen = 0.0.0.0:6153
external-controller-access = surgepasswd@0.0.0.0:6170
replica = false
tls-provider = openssl
network-framework = false
exclude-simple-hostnames = true
ipv6 = true
test-timeout = 4
proxy-test-url = http://www.gstatic.com/generate_204
geoip-maxmind-url = https://unpkg.zhimg.com/rulestatic@1.0.1/Country.mmdb
[Replica]
hide-apple-request = true
hide-crashlytics-request = true
use-keyword-filter = false
hide-udp = false
[Panel]
SubscribeInfo = {{ .SubscribeInfo }}, style=info
# -----------------------------
# Surge 的几种策略配置规范,请参考 https://manual.nssurge.com/policy/proxy.html
# 不同的代理策略有*很多*可选参数,请参考上方连接的 Parameters 一段,根据需求自行添加参数。
#
# Surge 现已支持 UDP 转发功能,请参考: https://trello.com/c/ugOMxD3u/53-udp-%E8%BD%AC%E5%8F%91
# Surge 现已支持 TCP-Fast-Open 技术,请参考: https://trello.com/c/ij65BU6Q/48-tcp-fast-open-troubleshooting-guide
# Surge 现已支持 ss-libev 的全部加密方式和混淆,请参考: https://trello.com/c/BTr0vG1O/47-ss-libev-%E7%9A%84%E6%94%AF%E6%8C%81%E6%83%85%E5%86%B5
# -----------------------------
[Proxy]
{{ .Proxies }}
[Proxy Group]
# 代理组列表
{{ .ProxyGroup }}
[Rule]
{{ .Rules }}
[URL Rewrite]
^https?://(www.)?(g|google).cn https://www.google.com 302
+43
View File
@@ -0,0 +1,43 @@
package surge
import (
"fmt"
"strconv"
"strings"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
func buildHysteria2(data proxy.Proxy, uuid string) string {
hysteria2, ok := data.Option.(proxy.Hysteria2)
if !ok {
return ""
}
var port int
if hysteria2.HopPorts != "" {
ports := strings.Split(hysteria2.HopPorts, ",")
p := ports[0]
if len(strings.Split(p, "-")) > 1 {
p = strings.Split(p, "-")[0]
}
port, _ = strconv.Atoi(p)
} else {
port = data.Port
}
config := []string{
fmt.Sprintf("%s=hysteria2,%s,%d", data.Name, data.Server, port),
"password=" + uuid,
"udp-relay=true",
}
if hysteria2.SecurityConfig.SNI != "" {
config = append(config, "sni="+hysteria2.SecurityConfig.SNI)
}
if hysteria2.SecurityConfig.AllowInsecure {
config = append(config, "skip-cert-verify=true")
} else {
config = append(config, "skip-cert-verify=false")
}
return strings.Join(config, ",") + "\r\n"
}
+70
View File
@@ -0,0 +1,70 @@
package surge
import (
"testing"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
func TestBuildHysteria2(t *testing.T) {
tests := []struct {
name string
data proxy.Proxy
uuid string
expected string
}{
{
name: "Valid Hysteria2 with HopPorts",
data: proxy.Proxy{
Name: "test",
Server: "server.com",
Port: 443,
Option: proxy.Hysteria2{
HopPorts: "1000-2000",
SecurityConfig: proxy.SecurityConfig{
SNI: "example.com",
AllowInsecure: true,
},
},
},
uuid: "test-uuid",
expected: "test=hysteria2,server.com,1000,password=test-uuid,udp-relay=true,sni=example.com,skip-cert-verify=true\r\n",
},
{
name: "Valid Hysteria2 without HopPorts",
data: proxy.Proxy{
Name: "test",
Server: "server.com",
Port: 443,
Option: proxy.Hysteria2{
SecurityConfig: proxy.SecurityConfig{
SNI: "example.com",
AllowInsecure: false,
},
},
},
uuid: "test-uuid",
expected: "test=hysteria2,server.com,443,password=test-uuid,udp-relay=true,sni=example.com,skip-cert-verify=false\r\n",
},
{
name: "Invalid Hysteria2 Option",
data: proxy.Proxy{
Name: "test",
Server: "server.com",
Port: 443,
Option: nil,
},
uuid: "test-uuid",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := buildHysteria2(tt.data, tt.uuid)
if result != tt.expected {
t.Errorf("expected %s, got %s", tt.expected, result)
}
})
}
}
+24
View File
@@ -0,0 +1,24 @@
package surge
import (
"fmt"
"strings"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
func buildShadowsocks(data proxy.Proxy, uuid string) string {
ss, ok := data.Option.(proxy.Shadowsocks)
if !ok {
return ""
}
addr := fmt.Sprintf("%s=ss, %s, %d", data.Name, data.Server, data.Port)
config := []string{
addr,
fmt.Sprintf("encrypt-method=%s", ss.Method),
fmt.Sprintf("password=%s", uuid),
"tfo=true",
"udp-relay=true",
}
return strings.Join(config, ",") + "\r\n"
}
+117
View File
@@ -0,0 +1,117 @@
package surge
import (
"bytes"
"embed"
"fmt"
"net/url"
"strings"
"text/template"
"time"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
"github.com/perfect-panel/ppanel-server/pkg/logger"
"github.com/perfect-panel/ppanel-server/pkg/traffic"
)
//go:embed *.tpl
var configFiles embed.FS
type UserInfo struct {
UUID string
Upload int64
Download int64
TotalTraffic int64
ExpiredDate time.Time
SubscribeURL string
}
type Surge struct {
Adapter proxy.Adapter
UUID string
User UserInfo
}
func NewSurge(adapter proxy.Adapter) *Surge {
return &Surge{
Adapter: adapter,
}
}
func (m *Surge) Build(uuid, siteName string, user UserInfo) []byte {
var proxies, proxyGroup, rules string
for _, p := range m.Adapter.Proxies {
switch p.Protocol {
case "shadowsocks":
proxies += buildShadowsocks(p, uuid)
case "trojan":
proxies += buildTrojan(p, uuid)
case "hysteria2":
proxies += buildHysteria2(p, uuid)
case "vmess":
proxies += buildVMess(p, uuid)
}
}
for _, group := range m.Adapter.Group {
if group.Type == proxy.GroupTypeSelect {
proxyGroup += fmt.Sprintf("%s = select, %s", group.Name, strings.Join(group.Proxies, ", ")) + "\r\n"
} else if group.Type == proxy.GroupTypeURLTest {
proxyGroup += fmt.Sprintf("%s = url-test, %s, url=%s, interval=%d", group.Name, strings.Join(group.Proxies, ", "), group.URL, group.Interval) + "\r\n"
} else if group.Type == proxy.GroupTypeFallback {
proxyGroup += fmt.Sprintf("%s = fallback, %s, url=%s, interval=%d", group.Name, strings.Join(group.Proxies, ", "), group.URL, group.Interval) + "\r\n"
} else {
logger.Errorf("[BuildSurfboard] unknown group type: %s", group.Type)
}
}
for _, rule := range m.Adapter.Rules {
if rule == "" {
continue
}
rules += rule + "\r\n"
}
//final rule
rules += "# 最终规则" + "\r\n" + "FINAL,手动选择,dns-failed"
file, err := configFiles.ReadFile("default.tpl")
if err != nil {
logger.Errorf("read default surfboard config error: %v", err.Error())
return nil
}
// replace template
tpl, err := template.New("default").Parse(string(file))
if err != nil {
logger.Errorf("read default surfboard config error: %v", err.Error())
return nil
}
var buf bytes.Buffer
var expiredAt string
if user.ExpiredDate.Before(time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)) {
expiredAt = "长期有效"
} else {
expiredAt = user.ExpiredDate.Format("2006-01-02 15:04:05")
}
// convert traffic
upload := traffic.AutoConvert(user.Upload, false)
download := traffic.AutoConvert(user.Download, false)
total := traffic.AutoConvert(user.TotalTraffic, false)
unusedTraffic := traffic.AutoConvert(user.TotalTraffic-user.Upload-user.Download, false)
// query Host
urlParse, err := url.Parse(user.SubscribeURL)
if err != nil {
return nil
}
if err := tpl.Execute(&buf, map[string]interface{}{
"Proxies": proxies,
"ProxyGroup": proxyGroup,
"SubscribeURL": user.SubscribeURL,
"SubscribeInfo": fmt.Sprintf("title=%s订阅信息, content=上传流量:%s\\n下载流量:%s\\n剩余流量: %s\\n套餐流量:%s\\n到期时间:%s", siteName, upload, download, unusedTraffic, total, expiredAt),
"SubscribeDomain": urlParse.Host,
"Rules": rules,
}); err != nil {
logger.Errorf("build Surge config error: %v", err.Error())
return nil
}
return buf.Bytes()
}
+97
View File
@@ -0,0 +1,97 @@
package surge
import (
"strings"
"testing"
"time"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
func TestSurgeBuild(t *testing.T) {
adapter := proxy.Adapter{
Proxies: []proxy.Proxy{
{
Name: "test-shadowsocks",
Protocol: "shadowsocks",
Server: "1.2.3.4",
Port: 8388,
Option: proxy.Shadowsocks{
Method: "aes-256-gcm",
},
},
{
Name: "test-trojan",
Protocol: "trojan",
Server: "5.6.7.8",
Port: 443,
Option: proxy.Trojan{
SecurityConfig: proxy.SecurityConfig{
SNI: "example.com",
AllowInsecure: true,
},
},
},
{
Name: "test-hysteria",
Protocol: "hysteria2",
Server: "1.1.1.1",
Port: 443,
Option: proxy.Hysteria2{
HopPorts: "8080-8090",
HopInterval: 320,
SecurityConfig: proxy.SecurityConfig{
SNI: "example.com",
AllowInsecure: true,
},
},
},
},
Group: []proxy.Group{
{
Name: "test-group",
Type: proxy.GroupTypeSelect,
Proxies: []string{"test-shadowsocks", "test-trojan", "test-hysteria"},
},
{
Name: "手动选择",
Type: proxy.GroupTypeSelect,
Proxies: []string{"test-shadowsocks", "test-trojan", "test-hysteria"},
},
},
Rules: []string{
"DOMAIN-SUFFIX,example.com,DIRECT",
},
}
user := UserInfo{
UUID: "test-uuid",
Upload: 1024,
Download: 2048,
TotalTraffic: 4096,
ExpiredDate: time.Now().Add(24 * time.Hour),
SubscribeURL: "http://example.com/subscribe",
}
surge := NewSurge(adapter)
config := surge.Build("test-uuid", "TestSite", user)
if config == nil {
t.Fatal("Expected non-nil config")
}
configStr := string(config)
t.Logf("configStr: %v", configStr)
if !strings.Contains(configStr, "test-shadowsocks=ss") {
t.Errorf("Expected config to contain test-shadowsocks proxy")
}
if !strings.Contains(configStr, "test-trojan=trojan") {
t.Errorf("Expected config to contain test-trojan proxy")
}
if !strings.Contains(configStr, "test-group = select") {
t.Errorf("Expected config to contain test-group proxy group")
}
if !strings.Contains(configStr, "DOMAIN-SUFFIX,example.com,DIRECT") {
t.Errorf("Expected config to contain rule for example.com")
}
}
+32
View File
@@ -0,0 +1,32 @@
package surge
import (
"strconv"
"strings"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
func buildTrojan(data proxy.Proxy, uuid string) string {
trojan, ok := data.Option.(proxy.Trojan)
if !ok {
return ""
}
config := []string{
data.Name + "=trojan",
data.Server,
strconv.Itoa(data.Port),
"password=" + uuid,
"tfo=true",
"udp-relay=true",
}
if trojan.SecurityConfig.SNI != "" {
config = append(config, "sni="+trojan.SecurityConfig.SNI)
}
if trojan.SecurityConfig.AllowInsecure {
config = append(config, "skip-cert-verify=true")
} else {
config = append(config, "skip-cert-verify=false")
}
return strings.Join(config, ",") + "\r\n"
}
+44
View File
@@ -0,0 +1,44 @@
package surge
import (
"fmt"
"strings"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
)
func buildVMess(data proxy.Proxy, uuid string) string {
vmess, ok := data.Option.(proxy.Vmess)
if !ok {
return ""
}
addr := fmt.Sprintf("%s=vmess, %s, %d", data.Name, data.Server, data.Port)
uriConfig := []string{
addr,
fmt.Sprintf("username=%s", uuid),
"vmess-aead=true",
"tfo=true",
"udp-relay=true",
}
if vmess.Security == "tls" {
uriConfig = append(uriConfig, "tls=true")
if vmess.SecurityConfig.AllowInsecure {
uriConfig = append(uriConfig, "skip-cert-verify=true")
} else {
uriConfig = append(uriConfig, "skip-cert-verify=false")
}
if vmess.SecurityConfig.SNI != "" {
uriConfig = append(uriConfig, fmt.Sprintf("sni=%s", vmess.SecurityConfig.SNI))
}
}
if vmess.Transport == "websocket" {
uriConfig = append(uriConfig, "ws=true")
if vmess.TransportConfig.Path != "" {
uriConfig = append(uriConfig, fmt.Sprintf("ws-path=%s", vmess.TransportConfig.Path))
}
if vmess.TransportConfig.Host != "" {
uriConfig = append(uriConfig, fmt.Sprintf("ws-headers=Host:%s", vmess.TransportConfig.Host))
}
}
return strings.Join(uriConfig, ",") + "\r\n"
}
+197
View File
@@ -0,0 +1,197 @@
package adapter
import (
"encoding/json"
"strings"
"github.com/perfect-panel/ppanel-server/internal/model/server"
"github.com/perfect-panel/ppanel-server/pkg/adapter/proxy"
"github.com/perfect-panel/ppanel-server/pkg/logger"
"github.com/perfect-panel/ppanel-server/pkg/random"
"github.com/perfect-panel/ppanel-server/pkg/tool"
)
func addNode(data *server.Server, host string, port int) *proxy.Proxy {
var option any
node := proxy.Proxy{
Name: data.Name,
Server: host,
Port: port,
Country: data.Country,
Protocol: data.Protocol,
}
switch data.Protocol {
case "shadowsocks":
var ss proxy.Shadowsocks
if err := json.Unmarshal([]byte(data.Config), &ss); err != nil {
return nil
}
if port == 0 {
node.Port = ss.Port
}
option = ss
case "vless":
var vless proxy.Vless
if err := json.Unmarshal([]byte(data.Config), &vless); err != nil {
return nil
}
if port == 0 {
node.Port = vless.Port
}
option = vless
case "vmess":
var vmess proxy.Vmess
if err := json.Unmarshal([]byte(data.Config), &vmess); err != nil {
return nil
}
if port == 0 {
node.Port = vmess.Port
}
option = vmess
case "trojan":
var trojan proxy.Trojan
if err := json.Unmarshal([]byte(data.Config), &trojan); err != nil {
return nil
}
if port == 0 {
node.Port = trojan.Port
}
option = trojan
case "hysteria2":
var hysteria2 proxy.Hysteria2
if err := json.Unmarshal([]byte(data.Config), &hysteria2); err != nil {
return nil
}
if port == 0 {
node.Port = hysteria2.Port
}
option = hysteria2
case "tuic":
var tuic proxy.Tuic
if err := json.Unmarshal([]byte(data.Config), &tuic); err != nil {
return nil
}
if port == 0 {
node.Port = tuic.Port
}
option = tuic
default:
return nil
}
node.Option = option
return &node
}
func addProxyToGroup(proxyName, groupName string, groups []proxy.Group) []proxy.Group {
for i, group := range groups {
if group.Name == groupName {
groups[i].Proxies = tool.RemoveDuplicateElements(append(group.Proxies, proxyName)...)
return groups
}
}
groups = append(groups, proxy.Group{
Name: groupName,
Type: "select",
Proxies: []string{proxyName},
})
return groups
}
func adapterRules(groups []*server.RuleGroup) (proxyGroup []proxy.Group, rules []string) {
for _, group := range groups {
proxyGroup = append(proxyGroup, proxy.Group{
Name: group.Name,
Type: "select",
Proxies: RemoveEmptyString(strings.Split(group.Tags, ",")),
})
rules = append(rules, strings.Split(group.Rules, "/n")...)
}
return
}
func generateProxyGroup(servers []proxy.Proxy) (proxyGroup []proxy.Group, region []string) {
// 设置手动选择分组
proxyGroup = append(proxyGroup, []proxy.Group{
{
Name: "智能线路",
Type: "url-test",
Proxies: make([]string, 0),
URL: "https://www.gstatic.com/generate_204",
Interval: 300,
},
{
Name: "手动选择",
Type: "select",
Proxies: []string{"智能线路"},
},
}...)
for _, node := range servers {
if node.Country != "" {
proxyGroup = addProxyToGroup(node.Name, node.Country, proxyGroup)
region = append(region, node.Country)
proxyGroup = addProxyToGroup(node.Country, "智能线路", proxyGroup)
}
proxyGroup = addProxyToGroup(node.Name, "手动选择", proxyGroup)
}
proxyGroup = addProxyToGroup("DIRECT", "手动选择", proxyGroup)
return proxyGroup, tool.RemoveDuplicateElements(region...)
}
func adapterProxies(servers []*server.Server) []proxy.Proxy {
var proxies []proxy.Proxy
for _, node := range servers {
switch node.RelayMode {
case server.RelayModeAll:
var relays []server.NodeRelay
if err := json.Unmarshal([]byte(node.RelayNode), &relays); err != nil {
logger.Errorw("Unmarshal RelayNode", logger.Field("error", err.Error()), logger.Field("node", node.Name), logger.Field("relayNode", node.RelayNode))
continue
}
for _, relay := range relays {
n := addNode(node, relay.Host, relay.Port)
if n == nil {
continue
}
if relay.Prefix != "" {
n.Name = relay.Prefix + "-" + n.Name
}
proxies = append(proxies, *n)
}
case server.RelayModeRandom:
var relays []server.NodeRelay
if err := json.Unmarshal([]byte(node.RelayNode), &relays); err != nil {
logger.Errorw("Unmarshal RelayNode", logger.Field("error", err.Error()), logger.Field("node", node.Name), logger.Field("relayNode", node.RelayNode))
continue
}
randNum := random.RandomInRange(0, len(relays)-1)
relay := relays[randNum]
n := addNode(node, relay.Host, relay.Port)
if n == nil {
continue
}
if relay.Prefix != "" {
n.Name = relay.Prefix + " - " + node.Name
}
proxies = append(proxies, *n)
default:
logger.Info("Not Relay Mode", logger.Field("node", node.Name), logger.Field("relayMode", node.RelayMode))
n := addNode(node, node.ServerAddr, 0)
if n != nil {
proxies = append(proxies, *n)
}
}
}
return proxies
}
// RemoveEmptyString 切片去除空值
func RemoveEmptyString(arr []string) []string {
var result []string
for _, str := range arr {
if str != "" {
result = append(result, str)
}
}
return result
}
+47
View File
@@ -0,0 +1,47 @@
package pkgaes
import (
"crypto/md5"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"time"
"github.com/forgoer/openssl"
)
// Encrypt 传入 []byte,返回 []byte 类型的加密数据
func Encrypt(plainText []byte, keyStr string) (string, string, error) {
//get time
nonce := fmt.Sprintf("%x", time.Now().UnixNano())
key := generateKey(keyStr)
iv := generateIv(nonce, keyStr)
dst, err := openssl.AesCBCEncrypt(plainText, key, iv, openssl.PKCS7_PADDING)
// 返回加密后的数据(包括 IV
return base64.StdEncoding.EncodeToString(dst), nonce, err
}
// Decrypt 传入 []byte 类型的加密数据,返回解密后的 []byte 明文数据
func Decrypt(cipherText string, keyStr string, ivStr string) (string, error) {
decode, err := base64.StdEncoding.DecodeString(cipherText)
if err != nil {
return "", err
}
key := generateKey(keyStr)
iv := generateIv(ivStr, keyStr)
dst, err := openssl.AesCBCDecrypt(decode, key, iv, openssl.PKCS7_PADDING)
return string(dst), err
}
// 生成密钥(哈希处理后保持为固定大小)
func generateKey(key string) []byte {
hash := sha256.Sum256([]byte(key))
return hash[:32] // AES-256 需要 32 字节密钥
}
func generateIv(iv, key string) []byte {
h := md5.New()
h.Write([]byte(iv))
return generateKey(hex.EncodeToString(h.Sum(nil)) + key)
}
+29
View File
@@ -0,0 +1,29 @@
package pkgaes
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
)
func TestAes(t *testing.T) {
params := map[string]interface{}{
"method": "email",
"account": "admin@ppanel.dev",
"password": "password",
}
marshal, _ := json.Marshal(params)
jsonStr := string(marshal)
encrypt, iv, err := Encrypt([]byte(jsonStr), "123456")
if err != nil {
t.Fatalf("encrypt failed: %v", err)
}
decrypt, err := Decrypt(encrypt, "123456", iv)
if err != nil {
t.Fatalf("decrypt failed: %v", err)
}
assert.Equal(t, jsonStr, decrypt, "decrypt failed")
}
+8
View File
@@ -0,0 +1,8 @@
package authmethod
const (
Email = "email" //邮箱
Mobile = "mobile" //手机
Device = "device" //设备
)
+27
View File
@@ -0,0 +1,27 @@
package cache
import (
"context"
"time"
)
type (
Cache interface {
// Del deletes cached values with keys.
Del(keys ...string) error
// DelCtx deletes cached values with keys.
DelCtx(ctx context.Context, keys ...string) error
// Get gets the cache with key and fills into v.
Get(key string, val any) error
// GetCtx gets the cache with key and fills into v.
GetCtx(ctx context.Context, key string, val any) error
// Set sets the cache with key and value.
Set(key string, val any) error
// SetCtx sets the cache with key and value.
SetCtx(ctx context.Context, key string, val any) error
// SetWithExpire sets the cache with key and v, using given expire.
SetWithExpire(key string, val any, expire time.Duration) error
// SetWithExpireCtx sets the cache with key and v, using given expire.
SetWithExpireCtx(ctx context.Context, key string, val any, expire time.Duration) error
}
)
+120
View File
@@ -0,0 +1,120 @@
package cache
import (
"context"
"database/sql"
"encoding/json"
"errors"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
var ErrNotFound = redis.Nil
type (
// ExecCtxFn defines the sql exec method.
ExecCtxFn func(conn *gorm.DB) error
// IndexQueryCtxFn defines the query method that based on unique indexes.
IndexQueryCtxFn func(conn *gorm.DB, v interface{}) (interface{}, error)
// PrimaryQueryCtxFn defines the query method that based on primary keys.
PrimaryQueryCtxFn func(conn *gorm.DB, v, primary interface{}) error
// QueryCtxFn defines the query method.
QueryCtxFn func(conn *gorm.DB, v interface{}) error
CachedConn struct {
db *gorm.DB
cache *redis.Client
}
)
// NewConn returns a CachedConn with a redis cluster cache.
func NewConn(db *gorm.DB, c *redis.Client) CachedConn {
return CachedConn{
db: db,
cache: c,
}
}
// DelCache deletes cache with keys.
func (cc CachedConn) DelCache(keys ...string) error {
return cc.cache.Del(context.Background(), keys...).Err()
}
// DelCacheCtx deletes cache with keys.
func (cc CachedConn) DelCacheCtx(ctx context.Context, keys ...string) error {
return cc.cache.Del(ctx, keys...).Err()
}
// GetCache unmarshals cache with given key into v.
func (cc CachedConn) GetCache(key string, v interface{}) error {
// query redis key
val, err := cc.cache.Get(context.Background(), key).Result()
if err != nil {
return err
}
// unmarshal value
return json.Unmarshal([]byte(val), v)
}
// SetCache sets cache with key and v.
func (cc CachedConn) SetCache(key string, v interface{}) error {
// marshal value
val, err := json.Marshal(v)
if err != nil {
return err
}
// set redis key
return cc.cache.Set(context.Background(), key, val, 0).Err()
}
// ExecCtx runs given exec on given keys, and returns execution result.
func (cc CachedConn) ExecCtx(ctx context.Context, execCtx ExecCtxFn, keys ...string) error {
err := execCtx(cc.db.WithContext(ctx))
if err != nil {
return err
}
if err := cc.DelCacheCtx(ctx, keys...); err != nil {
return err
}
return nil
}
// ExecNoCache runs exec with given sql statement, without affecting cache.
func (cc CachedConn) ExecNoCache(exec ExecCtxFn) error {
return cc.ExecNoCacheCtx(context.Background(), exec)
}
// ExecNoCacheCtx runs exec with given sql statement, without affecting cache.
func (cc CachedConn) ExecNoCacheCtx(ctx context.Context, execCtx ExecCtxFn) (err error) {
return execCtx(cc.db.WithContext(ctx))
}
func (cc CachedConn) QueryCtx(ctx context.Context, v interface{}, key string, query QueryCtxFn) (err error) {
err = cc.GetCache(key, v)
if err != nil {
if errors.Is(err, ErrNotFound) {
err = query(cc.db.WithContext(ctx), v)
if err != nil {
return err
}
return cc.SetCache(key, v)
}
}
return
}
// QueryNoCacheCtx runs query with given sql statement, without affecting cache.
func (cc CachedConn) QueryNoCacheCtx(ctx context.Context, v interface{}, query QueryCtxFn) (err error) {
return query(cc.db.WithContext(ctx), v)
}
// TransactCtx runs given fn in transaction mode.
func (cc CachedConn) TransactCtx(ctx context.Context, fn func(db *gorm.DB) error, opts ...*sql.TxOptions) error {
return cc.db.WithContext(ctx).Transaction(fn, opts...)
}
// Transact runs given fn in transaction mode.
func (cc CachedConn) Transact(fn func(db *gorm.DB) error, opts ...*sql.TxOptions) error {
return cc.TransactCtx(context.Background(), fn, opts...)
}
+66
View File
@@ -0,0 +1,66 @@
package cache
import (
"context"
"testing"
"time"
"github.com/perfect-panel/ppanel-server/pkg/orm"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"gorm.io/plugin/soft_delete"
)
type User struct {
Id int64 `gorm:"primarykey"`
Email string `gorm:"index:idx_email;type:varchar(100);unique;not null;comment:电子邮箱"`
Password string `gorm:"type:varchar(100);comment:用户密码;not null"`
Avatar string `gorm:"type:varchar(200);default:'';comment:用户头像"`
Balance int64 `gorm:"default:0;comment:用户余额"`
Telegram int64 `gorm:"default:null;comment:Telegram账号"`
ReferCode string `gorm:"type:varchar(20);default:'';comment:推荐码"`
RefererId int64 `gorm:"comment:推荐人ID"`
Enable bool `gorm:"default:true;not null;comment:账户是否可用"`
IsAdmin bool `gorm:"default:false;not null;comment:是否管理员"`
ValidEmail bool `gorm:"default:false;not null;comment:是否验证邮箱"`
EnableEmailNotify bool `gorm:"default:false;not null;comment:是否启用邮件通知"`
EnableTelegramNotify bool `gorm:"default:false;not null;comment:是否启用Telegram通知"`
EnableBalanceNotify bool `gorm:"default:false;not null;comment:是否启用余额变动通知"`
EnableLoginNotify bool `gorm:"default:false;not null;comment:是否启用登录通知"`
EnableSubscribeNotify bool `gorm:"default:false;not null;comment:是否启用订阅通知"`
EnableTradeNotify bool `gorm:"default:false;not null;comment:是否启用交易通知"`
CreatedAt time.Time `gorm:"<-:create;comment:创建时间"`
UpdatedAt time.Time `gorm:"comment:更新时间"`
DeletedAt gorm.DeletedAt `gorm:"default:null;comment:删除时间"`
IsDel soft_delete.DeletedAt `gorm:"softDelete:flag,DeletedAtField:DeletedAt;comment:1:正常 0:删除"` // Use `1` `0` to identify
}
func TestGormCacheCtx(t *testing.T) {
t.Skipf("skip TestGormCacheCtx test")
db, err := orm.ConnectMysql(orm.Mysql{
Config: orm.Config{
Addr: "localhost:3306",
Config: "charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai",
Dbname: "vpnboard",
Username: "root",
Password: "mylove520",
},
})
if err != nil {
t.Error(err)
}
rds := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
conn := NewConn(db, rds)
var u User
key := "user:id"
err = conn.QueryCtx(context.Background(), &u, key, func(conn *gorm.DB, v interface{}) error {
return conn.Where("id = ?", 1).First(v).Error
})
if err != nil {
t.Error(err)
return
}
t.Logf("get cache success %+v", u)
}
+17
View File
@@ -0,0 +1,17 @@
package calculateMonths
import "time"
// CalculateMonths calculates the number of months between startTime and endTime.
// It rounds up to the next month if there are remaining days.
func CalculateMonths(startTime, endTime time.Time) int8 {
// Calculate the year and month difference
years := endTime.Year() - startTime.Year()
months := int8(years*12) + int8(endTime.Month()) - int8(startTime.Month())
// Always round up if endTime is not on the same or earlier day of the month
if endTime.Day() > startTime.Day() || (endTime.Day() < startTime.Day() && endTime.After(startTime)) {
months++
}
return months
}
@@ -0,0 +1,13 @@
package calculateMonths
import (
"testing"
"time"
)
func TestCalculateMonths(t *testing.T) {
startTime, _ := time.Parse(time.DateTime, "2025-01-15 00:00:00")
EndTime, _ := time.Parse(time.DateTime, "2025-05-15 00:00:00")
months := CalculateMonths(startTime, EndTime)
t.Log(months)
}
+73
View File
@@ -0,0 +1,73 @@
package color
import "github.com/fatih/color"
const (
// NoColor is no color for both foreground and background.
NoColor Color = iota
// FgBlack is the foreground color black.
FgBlack
// FgRed is the foreground color red.
FgRed
// FgGreen is the foreground color green.
FgGreen
// FgYellow is the foreground color yellow.
FgYellow
// FgBlue is the foreground color blue.
FgBlue
// FgMagenta is the foreground color magenta.
FgMagenta
// FgCyan is the foreground color cyan.
FgCyan
// FgWhite is the foreground color white.
FgWhite
// BgBlack is the background color black.
BgBlack
// BgRed is the background color red.
BgRed
// BgGreen is the background color green.
BgGreen
// BgYellow is the background color yellow.
BgYellow
// BgBlue is the background color blue.
BgBlue
// BgMagenta is the background color magenta.
BgMagenta
// BgCyan is the background color cyan.
BgCyan
// BgWhite is the background color white.
BgWhite
)
var colors = map[Color][]color.Attribute{
FgBlack: {color.FgBlack, color.Bold},
FgRed: {color.FgRed, color.Bold},
FgGreen: {color.FgGreen, color.Bold},
FgYellow: {color.FgYellow, color.Bold},
FgBlue: {color.FgBlue, color.Bold},
FgMagenta: {color.FgMagenta, color.Bold},
FgCyan: {color.FgCyan, color.Bold},
FgWhite: {color.FgWhite, color.Bold},
BgBlack: {color.BgBlack, color.FgHiWhite, color.Bold},
BgRed: {color.BgRed, color.FgHiWhite, color.Bold},
BgGreen: {color.BgGreen, color.FgHiWhite, color.Bold},
BgYellow: {color.BgHiYellow, color.FgHiBlack, color.Bold},
BgBlue: {color.BgBlue, color.FgHiWhite, color.Bold},
BgMagenta: {color.BgMagenta, color.FgHiWhite, color.Bold},
BgCyan: {color.BgCyan, color.FgHiWhite, color.Bold},
BgWhite: {color.BgHiWhite, color.FgHiBlack, color.Bold},
}
type Color uint32
// WithColor returns a string with the given color applied.
func WithColor(text string, colour Color) string {
c := color.New(colors[colour]...)
return c.Sprint(text)
}
// WithColorPadding returns a string with the given color applied with leading and trailing spaces.
func WithColorPadding(text string, colour Color) string {
return WithColor(" "+text+" ", colour)
}
+17
View File
@@ -0,0 +1,17 @@
package color
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestWithColor(t *testing.T) {
output := WithColor("Hello", BgRed)
assert.Equal(t, "Hello", output)
}
func TestWithColorPadding(t *testing.T) {
output := WithColorPadding("Hello", BgRed)
assert.Equal(t, " Hello ", output)
}
+28
View File
@@ -0,0 +1,28 @@
package conf
import (
"log"
"os"
"gopkg.in/yaml.v3"
)
func MustLoad(file string, v any) {
if err := Load(file, v); err != nil {
log.Fatalf("error: config file %s, %s", file, err.Error())
}
}
func Load(file string, v any) error {
setDefaults(v)
content, err := os.ReadFile(file)
if err != nil {
return err
}
// Unmarshal the YAML content directly into the target structure
if err := yaml.Unmarshal(content, v); err != nil {
return err
}
return nil
}
+18
View File
@@ -0,0 +1,18 @@
package conf
import "testing"
type Server struct {
Host string `yaml:"Host" default:"localhost"`
Port int `yaml:"Port" default:"8080"`
}
type Config struct {
Server Server `yaml:"Server"`
}
func TestConfigLoad(t *testing.T) {
var c Config
MustLoad("./config_test.yaml", &c)
t.Logf("config: %+v", c)
}
+3
View File
@@ -0,0 +1,3 @@
Server:
Port: 9999
Host: 0.0.0.0
+63
View File
@@ -0,0 +1,63 @@
package conf
import (
"fmt"
"reflect"
)
func setDefaults(v any) {
// Get the element of the pointer
val := reflect.ValueOf(v).Elem()
setDefaultsRecursive(val)
}
func setDefaultsRecursive(v reflect.Value) {
if v.Kind() != reflect.Struct {
return
}
typ := v.Type()
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
fieldType := typ.Field(i)
// if the field is a struct, set recursively
if field.Kind() == reflect.Struct {
setDefaultsRecursive(field)
}
// if the field is zero value and has default tag, set the default value
if isZero(field) {
defaultValue := fieldType.Tag.Get("default")
if defaultValue != "" {
// set the value for the field using reflection
field.Set(reflect.ValueOf(parseDefaultValue(field.Kind(), defaultValue)))
}
}
}
}
func isZero(v reflect.Value) bool {
return reflect.DeepEqual(v.Interface(), reflect.Zero(v.Type()).Interface())
}
func parseDefaultValue(kind reflect.Kind, defaultValue string) any {
switch kind {
case reflect.String:
return defaultValue
case reflect.Int:
var i int
_, _ = fmt.Sscanf(defaultValue, "%d", &i)
return i
case reflect.Int64:
var i int64
_, _ = fmt.Sscanf(defaultValue, "%d", &i)
return i
case reflect.Bool:
var b bool
_, _ = fmt.Sscanf(defaultValue, "%t", &b)
return b
case reflect.Uint32:
var i uint32
_, _ = fmt.Sscanf(defaultValue, "%d", &i)
return i
default:
fmt.Printf("类型 %v 没有处理, 值为: %v \n", kind, defaultValue)
panic("unhandled default case")
}
}
+11
View File
@@ -0,0 +1,11 @@
package constant
type CtxKey string
const (
CtxKeyUser CtxKey = "user"
CtxKeySessionID CtxKey = "sessionId"
CtxKeyRequestHost CtxKey = "requestHost"
CtxKeyPlatform CtxKey = "platform"
CtxKeyPayment CtxKey = "payment"
)
+52
View File
@@ -0,0 +1,52 @@
package constant
import (
"encoding/json"
)
// Used for type cloning conversion
const (
Int64 int64 = 0
Uint32 uint32 = 0
DevMode = "dev"
)
// VerifyType is the type of verification code
type VerifyType uint8
const (
Register VerifyType = iota + 1
Security
)
func ParseVerifyType(i uint8) VerifyType {
return VerifyType(i)
}
func (v VerifyType) String() string {
switch v {
case Register:
return "register"
case Security:
return "security"
default:
return "unknown"
}
}
// TempOrderCacheKey Cache to Redis Key
// eg: temp_order:order_no
const TempOrderCacheKey = "temp_order:%s"
type TemporaryOrderInfo struct {
OrderNo string `json:"order_no"`
Identifier string `json:"identifier"`
AuthType string `json:"auth_type"`
Password string `json:"password"`
InviteCode string `json:"invite_code,omitempty"`
}
func (t TemporaryOrderInfo) Marshal() string {
value, _ := json.Marshal(t)
return string(value)
}
+7
View File
@@ -0,0 +1,7 @@
package constant
// Version PPanel version
const (
Version = "unknown"
BuildNumber = "unknown"
)
+127
View File
@@ -0,0 +1,127 @@
package deduction
import (
"time"
"github.com/perfect-panel/ppanel-server/pkg/tool"
)
const (
UnitTimeNoLimit = "NoLimit"
UnitTimeYear = "Year"
UnitTimeMonth = "Month"
UnitTimeDay = "Day"
UintTimeHour = "Hour"
UintTimeMinute = "Minute"
ResetCycleNone = 0
ResetCycle1st = 1
ResetCycleMonthly = 2
ResetCycleYear = 3
)
type Subscribe struct {
StartTime time.Time
ExpireTime time.Time
Traffic int64
Download int64
Upload int64
UnitTime string
UnitPrice int64
ResetCycle int64
DeductionRatio int64
}
type Order struct {
Amount int64
Quantity int64
}
func CalculateRemainingAmount(sub Subscribe, order Order) int64 {
if sub.UnitTime == UnitTimeNoLimit && sub.ResetCycle != 0 {
return 0
}
// 实际单价
sub.UnitPrice = order.Amount / order.Quantity
now := time.Now()
switch sub.UnitTime {
case UnitTimeNoLimit:
usedTraffic := sub.Traffic - sub.Download - sub.Upload
unitPrice := float64(order.Amount) / float64(sub.Traffic)
return int64(float64(usedTraffic) * unitPrice)
case UnitTimeYear:
remainingYears := tool.YearDiff(now, sub.ExpireTime)
remainingUnitTimeAmount := calculateRemainingUnitTimeAmount(sub)
return int64(remainingYears)*sub.UnitPrice + remainingUnitTimeAmount
case UnitTimeMonth:
remainingMonths := tool.MonthDiff(now, sub.ExpireTime)
remainingUnitTimeAmount := calculateRemainingUnitTimeAmount(sub)
return int64(remainingMonths)*sub.UnitPrice + remainingUnitTimeAmount
case UnitTimeDay:
remainingDays := tool.DayDiff(now, sub.ExpireTime)
remainingUnitTimeAmount := calculateRemainingUnitTimeAmount(sub)
return remainingDays*sub.UnitPrice + remainingUnitTimeAmount
}
return 0
}
func calculateRemainingUnitTimeAmount(sub Subscribe) int64 {
now := time.Now()
trafficWeight, timeWeight := calculateWeights(sub.DeductionRatio)
remainingDays, totalDays := getRemainingAndTotalDays(sub, now)
remainingTraffic := sub.Traffic - sub.Download - sub.Upload
remainingTimeAmount := calculateProportionalAmount(sub.UnitPrice, remainingDays, totalDays)
remainingTrafficAmount := calculateProportionalAmount(sub.UnitPrice, remainingTraffic, sub.Traffic)
if sub.Traffic == 0 {
return remainingTimeAmount
}
if sub.DeductionRatio != 0 {
return calculateWeightedAmount(sub.UnitPrice, remainingTraffic, sub.Traffic, remainingDays, totalDays, trafficWeight, timeWeight)
}
return min(remainingTimeAmount, remainingTrafficAmount)
}
func calculateWeights(deductionRatio int64) (float64, float64) {
if deductionRatio == 0 {
return 0, 0
}
trafficWeight := float64(deductionRatio) / 100
timeWeight := 1 - trafficWeight
return trafficWeight, timeWeight
}
func getRemainingAndTotalDays(sub Subscribe, now time.Time) (int64, int64) {
switch sub.ResetCycle {
case ResetCycleNone:
remaining := sub.ExpireTime.Sub(now).Hours() / 24
total := sub.ExpireTime.Sub(sub.StartTime).Hours() / 24
return int64(remaining), int64(total)
case ResetCycle1st:
return tool.DaysToNextMonth(now), tool.GetLastDayOfMonth(now)
case ResetCycleMonthly:
// -1 to include the current day
return tool.DaysToMonthDay(now, sub.StartTime.Day()) - 1, tool.DaysToMonthDay(now, sub.StartTime.Day())
case ResetCycleYear:
return tool.DaysToYearDay(now, int(sub.StartTime.Month()), sub.StartTime.Day()),
tool.GetYearDays(now, int(sub.StartTime.Month()), sub.StartTime.Day())
}
return 0, 0
}
func calculateWeightedAmount(unitPrice, remainingTraffic, totalTraffic, remainingDays, totalDays int64, trafficWeight, timeWeight float64) int64 {
remainingTimeRatio := float64(remainingDays) / float64(totalDays)
remainingTrafficRatio := float64(remainingTraffic) / float64(totalTraffic)
weightedRemainingRatio := (timeWeight * remainingTimeRatio) + (trafficWeight * remainingTrafficRatio)
return int64(float64(unitPrice) * weightedRemainingRatio)
}
func calculateProportionalAmount(unitPrice, remaining, total int64) int64 {
return int64(float64(unitPrice) * (float64(remaining) / float64(total)))
}
+359
View File
@@ -0,0 +1,359 @@
package device
import (
"context"
"fmt"
"net/http"
"sync"
"sync/atomic"
"time"
"go.uber.org/zap"
"github.com/gorilla/websocket"
)
type Operator int
const (
MaxDevices Operator = iota
Admin
SubscribeUpdate = "subscribe_update"
)
// Device represents a device structure
type Device struct {
Session string
DeviceID string
Conn *websocket.Conn
CreatedAt time.Time
LastPingTime time.Time
}
// WebSocket upgrader
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
// DeviceManager manages devices
type DeviceManager struct {
userDevices sync.Map // userID -> []*Device
totalOnline int32 // total online devices
userMutexes sync.Map // userID level locks
heartbeatTimeout int // heartbeat timeout (seconds)
checkInterval int // heartbeat check interval (seconds)
// event callbacks
OnDeviceOnline func(userID int64, deviceID, session string)
OnDeviceOffline func(userID int64, deviceID, session string, createAt time.Time)
OnDeviceKicked func(userID int64, deviceID, session string, operator Operator)
OnMessage func(userID int64, deviceID, session string, message string)
}
// Get user-level mutex
func (dm *DeviceManager) getUserMutex(userID int64) *sync.Mutex {
mu, _ := dm.userMutexes.LoadOrStore(userID, &sync.Mutex{})
return mu.(*sync.Mutex)
}
// Listen to WebSocket data
func (dm *DeviceManager) listenToDevice(userID int64, device *Device) {
defer func() {
dm.removeDevice(userID, device.DeviceID) // remove device when disconnected
}()
for {
_, msg, err := device.Conn.ReadMessage()
if err != nil {
zap.S().Infof("Device %s (User %d) disconnected: %v", device.DeviceID, userID, err)
break
}
message := string(msg)
if message == "ping" || message == "heartbeat" {
dm.UpdateHeartbeat(userID, device.DeviceID)
continue
}
// Trigger message callback
if dm.OnMessage != nil {
go dm.OnMessage(userID, device.DeviceID, device.Session, message)
}
}
}
// UpdateHeartbeat updates device heartbeat
func (dm *DeviceManager) UpdateHeartbeat(userID int64, deviceID string) {
mu := dm.getUserMutex(userID)
mu.Lock()
defer mu.Unlock()
if val, ok := dm.userDevices.Load(userID); ok {
devices := val.([]*Device)
for _, d := range devices {
if d.DeviceID == deviceID {
d.LastPingTime = time.Now()
if err := d.Conn.WriteMessage(websocket.TextMessage, []byte("ping")); err != nil {
zap.S().Infof("✅ Heartbeat updated: Device %s (User %d) err: %s", deviceID, userID, err.Error())
}
break
}
}
}
}
// AddDevice **Add: Device connects WebSocket and is added to the manager**
func (dm *DeviceManager) AddDevice(w http.ResponseWriter, r *http.Request, session string, userID int64, deviceID string, maxDevices int) {
// **Upgrade WebSocket connection**
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
zap.S().Infof("WebSocket upgrade failed: %v", err)
return
}
mu := dm.getUserMutex(userID)
mu.Lock()
defer mu.Unlock()
newDevice := &Device{
Session: session,
DeviceID: deviceID,
Conn: conn,
CreatedAt: time.Now(),
LastPingTime: time.Now(),
}
//不限制设备数量
if maxDevices < 1 {
maxDevices = 99
}
// Get user's device list
var restConnection bool
var devices []*Device
if val, ok := dm.userDevices.Load(userID); ok {
devices = val.([]*Device)
var tempDevice []*Device
for _, d := range devices {
if d.DeviceID == deviceID {
restConnection = true
} else {
tempDevice = append(tempDevice, d)
}
}
devices = tempDevice
}
// **If exceeding the limit, kick out the earliest device**
if !restConnection && len(devices) >= maxDevices {
oldestDevice := devices[0]
devices = devices[1:]
if dm.OnDeviceKicked != nil {
done := make(chan struct{})
go func() {
defer close(done)
dm.OnDeviceKicked(userID, oldestDevice.DeviceID, oldestDevice.Session, MaxDevices)
}()
<-done // block and wait for callback to complete
}
oldestDevice.Conn.Close()
atomic.AddInt32(&dm.totalOnline, -1)
}
// Add new device
devices = append(devices, newDevice)
dm.userDevices.Store(userID, devices)
atomic.AddInt32(&dm.totalOnline, 1)
// Trigger online event
if dm.OnDeviceOnline != nil {
go dm.OnDeviceOnline(userID, deviceID, session)
}
// Start listening
go dm.listenToDevice(userID, newDevice)
}
// removeDevice removes a device
func (dm *DeviceManager) removeDevice(userID int64, deviceID string) {
mu := dm.getUserMutex(userID)
mu.Lock()
defer mu.Unlock()
if val, ok := dm.userDevices.Load(userID); ok {
devices := val.([]*Device)
for i, d := range devices {
if d.DeviceID == deviceID {
devices = append(devices[:i], devices[i+1:]...)
d.Conn.Close()
atomic.AddInt32(&dm.totalOnline, -1)
if dm.OnDeviceOffline != nil {
go dm.OnDeviceOffline(userID, deviceID, d.Session, d.CreatedAt)
}
break
}
}
if len(devices) == 0 {
dm.userDevices.Delete(userID)
} else {
dm.userDevices.Store(userID, devices)
}
}
}
// KickDevice kicks a device (supports individual device or entire user)
func (dm *DeviceManager) KickDevice(userID int64, deviceID string) {
mu := dm.getUserMutex(userID)
mu.Lock()
defer mu.Unlock()
// Get user's device list
val, ok := dm.userDevices.Load(userID)
if !ok {
zap.S().Infof("⚠️ User %d has no online devices, unable to kick out", userID)
return
}
devices := val.([]*Device)
var activeDevices []*Device
for _, d := range devices {
if deviceID == "" || d.DeviceID == deviceID {
// Trigger kick event callback
if dm.OnDeviceKicked != nil {
done := make(chan struct{})
go func() {
defer close(done)
dm.OnDeviceKicked(userID, d.DeviceID, d.Session, Admin)
}()
<-done // block and wait for callback to complete
}
// Close WebSocket connection
d.Conn.Close()
atomic.AddInt32(&dm.totalOnline, -1)
zap.S().Infof("❌ Device %s (User %d) kicked out", d.DeviceID, userID)
} else {
activeDevices = append(activeDevices, d)
}
}
// Update user's device mapping
if len(activeDevices) == 0 {
dm.userDevices.Delete(userID)
} else {
dm.userDevices.Store(userID, activeDevices)
}
}
// StartHeartbeatCheck periodically checks for heartbeat timeout devices
func (dm *DeviceManager) StartHeartbeatCheck() {
ticker := time.NewTicker(time.Duration(dm.checkInterval) * time.Second)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
dm.userDevices.Range(func(userID, val interface{}) bool {
uid := userID.(int64)
devices := val.([]*Device)
mu := dm.getUserMutex(uid)
mu.Lock()
defer mu.Unlock()
var activeDevices []*Device
for _, d := range devices {
if now.Sub(d.LastPingTime) > time.Duration(dm.heartbeatTimeout)*time.Second {
zap.S().Infof("⚠️ Device %s (User %d) heartbeat timeout, removed", d.DeviceID, uid)
d.Conn.Close()
atomic.AddInt32(&dm.totalOnline, -1)
if dm.OnDeviceOffline != nil {
go dm.OnDeviceOffline(uid, d.DeviceID, d.Session, d.CreatedAt)
}
} else {
activeDevices = append(activeDevices, d)
}
}
if len(activeDevices) == 0 {
dm.userDevices.Delete(uid)
} else {
dm.userDevices.Store(uid, activeDevices)
}
return true
})
//zap.S().Infof("Total online devices: %d\n", dm.totalOnline)
}
}
// NewDeviceManager creates a new device manager
func NewDeviceManager(heartbeatTimeout, checkInterval int) *DeviceManager {
dm := &DeviceManager{
heartbeatTimeout: heartbeatTimeout,
checkInterval: checkInterval,
}
go dm.StartHeartbeatCheck()
return dm
}
// SendToDevice sends a message to a specific device
func (dm *DeviceManager) SendToDevice(userID int64, deviceID string, message string) error {
if val, ok := dm.userDevices.Load(userID); ok {
devices := val.([]*Device)
if deviceID == "" {
for _, d := range devices {
err := d.Conn.WriteMessage(websocket.TextMessage, []byte(message))
if err != nil {
return err
}
continue
}
} else {
for _, d := range devices {
if d.DeviceID == deviceID {
return d.Conn.WriteMessage(websocket.TextMessage, []byte(message))
}
}
}
}
return fmt.Errorf("device %s (User %d) is offline", deviceID, userID)
}
// Broadcast sends a message to all devices
func (dm *DeviceManager) Broadcast(message string) {
go func(message string) {
dm.userDevices.Range(func(_, val interface{}) bool {
devices := val.([]*Device)
for _, d := range devices {
_ = d.Conn.WriteMessage(websocket.TextMessage, []byte(message))
}
return true
})
}(message)
}
// Gracefully shut down all WebSocket connections
func (dm *DeviceManager) Shutdown(ctx context.Context) {
<-ctx.Done()
zap.S().Infof("🔴 Shutting down all WebSocket connections...")
dm.userDevices.Range(func(userID, val interface{}) bool {
uid := userID.(int64)
devices := val.([]*Device)
for _, d := range devices {
d.Conn.Close()
zap.S().Infof("✅ Closed device %s (User %d)", d.DeviceID, uid)
}
dm.userDevices.Delete(uid)
return true
})
}
+123
View File
@@ -0,0 +1,123 @@
package device
import (
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"strings"
"sync"
"testing"
"time"
"github.com/pkg/errors"
"github.com/gorilla/websocket"
)
func TestDevice(t *testing.T) {
t.Skip("skip test")
/* deviceManager := NewDeviceManager(10, 3)
deviceManager.OnDeviceOnline = func(userID int64, deviceID, session string) {
fmt.Printf("✅ 设备 %s (用户 %d) 上线\n", deviceID, userID)
}
deviceManager.OnDeviceOffline = func(userID int64, deviceID, session string) {
fmt.Printf("❌ 设备 %s (用户 %d) 下线\n", deviceID, userID)
}
deviceManager.OnDeviceKicked = func(userID int64, deviceID, session string, operator Operator) {
fmt.Printf("⚠️ 设备 %s (用户 %d) 被踢下线\n", deviceID, userID)
}
deviceManager.OnMessage = func(userID int64, deviceID, session string, message string) {
log.Printf("✅收到消息: 设备 %s (用户 %d) 内容: %s,sesion: %s\n", deviceID, userID, message, session)
}
engine := gin.Default()
engine.GET("/ws/:userid/:device_number", func(c *gin.Context) {
//根据Authorization获取session
authorization := c.GetHeader("Authorization")
userid, err := strconv.ParseInt(c.Param("userid"), 10, 64)
if err != nil {
t.Errorf("get user id err:%v", err)
return
}
deviceNumber := c.Param("device_number")
deviceManager.AddDevice(c, authorization, userid, deviceNumber, 3)
return
})
go func() {
err := http.ListenAndServe(":8081", engine)
if err != nil {
t.Fatalf("engine start failed: %v", err)
}
}()
*/
h := http.Header{}
h.Add("Authorization", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJTZXNzaW9uSWQiOiIwMTk0Y2ZiNy1hYjY0LTdjYjMtODUzYi03ZGU5YTAzNWRlZTgiLCJVc2VySWQiOjI5LCJleHAiOjE3MzkyNTY1MDgsImlhdCI6MTczODY1MTcwOH0.BGKT5-hongJPZrA_yAb6cf6go5iDR8T9uu1ZxUg8HDw")
mutex := sync.Mutex{}
serverURL := fmt.Sprintf("ws://localhost:8080/v1/app/ws/%d/%s", 29, "15502502051") // 假设 userID 为 1001,设备ID 为 deviceA
// 建立 WebSocket 连接
conn, resp, err := websocket.DefaultDialer.Dial(serverURL, h)
if err != nil {
all, err := io.ReadAll(resp.Body)
t.Fatalf("websocket dial failed: %v:%s", err, string(all))
}
// 启动一个 goroutine 来读取服务器消息
go func() {
for {
_, msg, err := conn.ReadMessage()
if err != nil {
if errors.Is(err, net.ErrClosed) || strings.Contains(err.Error(), "use of closed network connection") {
log.Println("连接已关闭")
return
}
log.Printf("接收消息失败: %v", err)
return
}
fmt.Printf("收到来自服务器的消息: %s\n", msg)
}
}()
//发送心跳
go func() {
ticker := time.NewTicker(time.Second * 5)
defer ticker.Stop()
for range ticker.C {
mutex.Lock()
err := conn.WriteMessage(websocket.TextMessage, []byte("ping"))
mutex.Unlock()
if err != nil {
if strings.Contains(err.Error(), "use of closed network connection") {
log.Println("连接已关闭")
return
}
t.Errorf("websocket 写入失败: %v", err)
return
}
}
}()
updateSubscribe, _ := json.Marshal(map[string]interface{}{
"method": "test_method",
})
//发送一条消息
mutex.Lock()
err = conn.WriteMessage(websocket.TextMessage, updateSubscribe)
mutex.Unlock()
if err != nil {
t.Errorf("websocket write failed: %v", err)
}
time.Sleep(time.Second * 20)
conn.Close()
time.Sleep(time.Second * 5)
}
+48
View File
@@ -0,0 +1,48 @@
package email
import "github.com/perfect-panel/ppanel-server/internal/types"
type Platform int
const (
SMTP Platform = iota
unsupported
)
var platformNames = map[string]Platform{
"smtp": SMTP,
"unsupported": unsupported,
}
func (p Platform) String() string {
for k, v := range platformNames {
if v == p {
return k
}
}
return "unsupported"
}
func parsePlatform(s string) Platform {
if p, ok := platformNames[s]; ok {
return p
}
return unsupported
}
func GetSupportedPlatforms() []types.PlatformInfo {
return []types.PlatformInfo{
{
Platform: SMTP.String(),
PlatformUrl: "",
PlatformFieldDescription: map[string]string{
"host": "host",
"port": "port",
"user": "user",
"pass": "pass",
"from": "from",
"ssl": "ssl",
},
},
}
}
+28
View File
@@ -0,0 +1,28 @@
package email
import (
"encoding/json"
"fmt"
"github.com/perfect-panel/ppanel-server/pkg/email/smtp"
"github.com/perfect-panel/ppanel-server/pkg/logger"
)
type Sender interface {
Send(to []string, subject, body string) error
}
func NewSender(platform, config, siteName string) (Sender, error) {
switch parsePlatform(platform) {
case SMTP:
cfg := smtp.Config{}
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
logger.Error("unmarshal email config failed", logger.Field("error", err.Error()), logger.Field("config", config))
return nil, err
}
cfg.SiteName = siteName
return smtp.NewClient(&cfg), nil
default:
return nil, fmt.Errorf("unsupported platform: %s", platform)
}
}
+44
View File
@@ -0,0 +1,44 @@
package smtp
import (
"crypto/tls"
"gopkg.in/gomail.v2"
)
type Client struct {
conf Config
dailer *gomail.Dialer
}
type Config struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Pass string `json:"pass"`
From string `json:"from"`
SSL bool `json:"ssl"`
SiteName string `json:"siteName"`
}
func NewClient(conf *Config) *Client {
if conf == nil {
return nil
}
dailer := gomail.NewDialer(conf.Host, conf.Port, conf.User, conf.Pass)
dailer.TLSConfig = &tls.Config{
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS12,
ServerName: conf.Host,
}
return &Client{conf: *conf, dailer: dailer}
}
func (m *Client) Send(to []string, subject, body string) error {
msg := gomail.NewMessage()
msg.SetAddressHeader("From", m.conf.From, m.conf.SiteName)
msg.SetHeader("To", to...)
msg.SetHeader("Subject", subject)
msg.SetBody("text/html", body)
return m.dailer.DialAndSend(msg)
}
+24
View File
@@ -0,0 +1,24 @@
package smtp
import "testing"
func TestEmailSend(t *testing.T) {
t.Skipf("Skip TestEmailSend")
config := &Config{
Host: "smtp.mail.me.com",
Port: 587,
User: "support@ppanel.dev",
Pass: "password",
From: "support@ppanel.dev",
SSL: true,
SiteName: "",
}
address := []string{"tension@sparkdance.dev"}
subject := "test"
body := "test"
email := NewClient(config)
err := email.Send(address, subject, body)
if err != nil {
t.Errorf("send email error: %v", err)
}
}
+364
View File
@@ -0,0 +1,364 @@
package email
const (
DefaultEmailVerifyTemplate = `<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>
{{if eq .Type 1}}注册验证码 / Registration Verification Code{{else}}重置密码验证码 / Password
Reset Verification Code{{end}}
</title>
<style>
body {
color: black;
}
.container {
border-radius: 5px;
width: 500px;
margin: 20px auto 0;
border: 1px solid #cce7ff;
background-color: #f0f8ff;
padding: 25px 30px;
}
.header {
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.logo {
width: 56px;
height: 56px;
object-fit: cover;
margin-right: 10px;
}
.site-name {
font-size: 18px;
font-weight: bold;
margin: 0;
}
.content {
margin: 10px 0;
font-size: 14px;
}
.greeting {
font-weight: 700;
margin: 5px 0;
}
.highlight {
margin: 0 2px;
font-weight: 700;
color: #007bff;
}
.code-container {
margin: 25px 0;
width: 100%;
background-color: #e6f2ff;
height: 60px;
line-height: 60px;
text-align: center;
font-size: 32px;
font-weight: 700;
color: #007bff;
}
.code {
letter-spacing: 5pt;
}
.footer {
border-top: #99ccff 1px solid;
margin-top: 20px;
padding-top: 5px;
font-size: 12px;
font-weight: 700;
color: #777;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="{{.SiteLogo}}" class="logo" />
<p class="site-name">{{.SiteName}}</p>
</div>
<div class="content">
<p class="greeting">Hi, 尊敬的用户 / Dear User</p>
<p>
{{if eq .Type 1}} 感谢您注册!您的验证码是(请于<span class="highlight">{{.Expire}}</span
>分钟内使用):
<br />
Thank you for registering! Your verification code is (please use it within
<span class="highlight">{{.Expire}}</span> minutes): {{else}}
您正在重置密码。您的验证码是(请于<span class="highlight">{{.Expire}}</span>分钟内使用):
<br />
You are resetting your password. Your verification code is (please use it within
<span class="highlight">{{.Expire}}</span> minutes): {{end}}
</p>
<div class="code-container">
<span class="code">{{.Code}}</span>
</div>
<p>
如果您未请求此验证码,请忽略此邮件。<br />If you did not request this code, please ignore
this email.
</p>
</div>
<div class="footer">此为系统邮件,请勿回复 / This is a system email, please do not reply</div>
</div>
</body>
</html>
`
DefaultMaintenanceEmailTemplate = `
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>系统维护通知 / System Maintenance Notice</title>
<style>
body {
color: black;
}
.container {
border-radius: 5px;
width: 500px;
margin: 20px auto 0;
border: 1px solid #cce7ff;
background-color: #f0f8ff;
padding: 25px 30px;
}
.header {
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.logo {
width: 56px;
height: 56px;
object-fit: cover;
margin-right: 10px;
}
.site-name {
font-size: 18px;
font-weight: bold;
margin: 0;
}
.content {
margin: 20px 0;
font-size: 14px;
}
.greeting {
font-weight: 700;
margin: 5px 0;
}
.highlight {
margin: 0 2px;
font-weight: 700;
color: #007bff;
}
.footer {
border-top: #99ccff 1px solid;
margin-top: 20px;
padding-top: 5px;
font-size: 12px;
font-weight: 700;
color: #777;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="{{.SiteLogo}}" class="logo" />
<p class="site-name">{{.SiteName}}</p>
</div>
<div class="content">
<p class="greeting">Hi, 尊敬的用户 / Dear User</p>
<p>
我们计划在<span class="highlight">{{.MaintenanceDate}}</span
>进行系统维护,预计维护时间为<span class="highlight">{{.MaintenanceTime}}</span
>。在此期间,您可能会遇到服务中断或无法访问的情况。
<br />
We will be performing system maintenance on
<span class="highlight">{{.MaintenanceDate}}</span>, and the expected maintenance period
is <span class="highlight">{{.MaintenanceTime}}</span>. During this time, you may
experience service interruptions or unavailability.
</p>
<p>
维护完成后,系统将自动恢复。如果您有任何问题,请随时联系我们的支持团队。
<br />
The system will resume automatically once the maintenance is completed. If you have any
questions, please feel free to contact our support team.
</p>
</div>
<div class="footer">此为系统邮件,请勿回复 / This is a system email, please do not reply</div>
</div>
</body>
</html>
`
DefaultExpirationEmailTemplate = `<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>服务到期通知 / Service Expiration Notice</title>
<style>
body {
color: black;
}
.container {
border-radius: 5px;
width: 500px;
margin: 20px auto 0;
border: 1px solid #cce7ff;
background-color: #f0f8ff;
padding: 25px 30px;
}
.header {
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.logo {
width: 56px;
height: 56px;
object-fit: cover;
margin-right: 10px;
}
.site-name {
font-size: 18px;
font-weight: bold;
margin: 0;
}
.content {
margin: 20px 0;
font-size: 14px;
}
.greeting {
font-weight: 700;
margin: 5px 0;
}
.highlight {
margin: 0 2px;
font-weight: 700;
color: #007bff;
}
.footer {
border-top: #99ccff 1px solid;
margin-top: 20px;
padding-top: 5px;
font-size: 12px;
font-weight: 700;
color: #777;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="{{.SiteLogo}}" class="logo" />
<p class="site-name">{{.SiteName}}</p>
</div>
<div class="content">
<p class="greeting">Hi, 尊敬的用户 / Dear User</p>
<p>
您的服务即将在<span class="highlight">{{.ExpireDate}}</span
>到期,请及时续费以保证服务不间断。
<br />
Your service is set to expire on <span class="highlight">{{.ExpireDate}}</span>. Please
renew your subscription to avoid service interruptions.
</p>
<p>
如需帮助,请联系客服团队。感谢您的支持!
<br />
If you need assistance, please contact our support team. Thank you for your continued
support!
</p>
</div>
<div class="footer">此为系统邮件,请勿回复 / This is a system email, please do not reply</div>
</div>
</body>
</html>
`
DefaultTrafficExceedEmailTemplate = `<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>流量用尽通知 / Traffic Exhausted Notice</title>
<style>
.container {
border-radius: 5px;
width: 500px;
margin: 20px auto 0;
border: 1px solid #cce7ff;
background-color: #f0f8ff;
padding: 25px 30px;
}
.header {
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.logo {
width: 56px;
height: 56px;
object-fit: cover;
margin-right: 10px;
}
.site-name {
font-size: 18px;
font-weight: bold;
margin: 0;
}
.content {
margin: 20px 0;
font-size: 14px;
}
.greeting {
font-weight: 700;
margin: 5px 0;
}
.highlight {
color: #007bff;
}
.footer {
border-top: #99ccff 1px solid;
margin-top: 20px;
padding-top: 5px;
font-size: 12px;
font-weight: 700;
color: #777;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="{{.SiteLogo}}" class="logo" />
<p class="site-name">{{.SiteName}}</p>
</div>
<div class="content">
<p class="greeting">Hi, 尊敬的用户 / Dear User</p>
<p>
您的流量已经用尽,请及时购买流量以继续使用我们的服务。
<br />
Your traffic has been exhausted. Please purchase additional traffic to continue using our
service.
</p>
<p>
如需帮助,请联系客服团队。感谢您的支持!
<br />
If you need assistance, please contact our support team. Thank you for your continued
support!
</p>
</div>
<div class="footer">此为系统邮件,请勿回复 / This is a system email, please do not reply</div>
</div>
</body>
</html>`
)
+36
View File
@@ -0,0 +1,36 @@
package email
import (
"bytes"
"html/template"
"testing"
)
type VerifyTemplate struct {
Type uint8
SiteLogo string
SiteName string
Expire uint8
Code string
}
func TestVerifyEmail(t *testing.T) {
t.Skipf("Skip TestVerifyEmail test")
data := VerifyTemplate{
Type: 1,
SiteLogo: "https://www.google.com",
SiteName: "Google",
Expire: 5,
Code: "123456",
}
tpl, err := template.New("email").Parse(DefaultEmailVerifyTemplate)
if err != nil {
t.Error(err)
}
var result bytes.Buffer
err = tpl.Execute(&result, data)
if err != nil {
t.Error(err)
}
t.Log(result.String())
}
+23
View File
@@ -0,0 +1,23 @@
package errorx
import "sync/atomic"
// AtomicError defines an atomic error.
type AtomicError struct {
err atomic.Value // error
}
// Set sets the error.
func (ae *AtomicError) Set(err error) {
if err != nil {
ae.err.Store(err)
}
}
// Load returns the error.
func (ae *AtomicError) Load() error {
if v := ae.err.Load(); v != nil {
return v.(error)
}
return nil
}
+82
View File
@@ -0,0 +1,82 @@
package errorx
import (
"errors"
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
)
var errDummy = errors.New("hello")
func TestAtomicError(t *testing.T) {
var err AtomicError
err.Set(errDummy)
assert.Equal(t, errDummy, err.Load())
}
func TestAtomicErrorSetNil(t *testing.T) {
var (
errNil error
err AtomicError
)
err.Set(errNil)
assert.Equal(t, errNil, err.Load())
}
func TestAtomicErrorNil(t *testing.T) {
var err AtomicError
assert.Nil(t, err.Load())
}
func BenchmarkAtomicError(b *testing.B) {
var aerr AtomicError
wg := sync.WaitGroup{}
b.Run("Load", func(b *testing.B) {
var done uint32
go func() {
for {
if atomic.LoadUint32(&done) != 0 {
break
}
wg.Add(1)
go func() {
aerr.Set(errDummy)
wg.Done()
}()
}
}()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = aerr.Load()
}
b.StopTimer()
atomic.StoreUint32(&done, 1)
wg.Wait()
})
b.Run("Set", func(b *testing.B) {
var done uint32
go func() {
for {
if atomic.LoadUint32(&done) != 0 {
break
}
wg.Add(1)
go func() {
_ = aerr.Load()
wg.Done()
}()
}
}()
b.ResetTimer()
for i := 0; i < b.N; i++ {
aerr.Set(errDummy)
}
b.StopTimer()
atomic.StoreUint32(&done, 1)
wg.Wait()
})
}
+42
View File
@@ -0,0 +1,42 @@
package errorx
import (
"errors"
"sync"
)
// BatchError is an error that can hold multiple errors.
type BatchError struct {
errs []error
lock sync.RWMutex
}
// Add adds one or more non-nil errors to the BatchError instance.
func (be *BatchError) Add(errs ...error) {
be.lock.Lock()
defer be.lock.Unlock()
for _, err := range errs {
if err != nil {
be.errs = append(be.errs, err)
}
}
}
// Err returns an error that represents all accumulated errors.
// It returns nil if there are no errors.
func (be *BatchError) Err() error {
be.lock.RLock()
defer be.lock.RUnlock()
// If there are no non-nil errors, errors.Join(...) returns nil.
return errors.Join(be.errs...)
}
// NotNil checks if there is at least one error inside the BatchError.
func (be *BatchError) NotNil() bool {
be.lock.RLock()
defer be.lock.RUnlock()
return len(be.errs) > 0
}
+147
View File
@@ -0,0 +1,147 @@
package errorx
import (
"errors"
"fmt"
"sync"
"testing"
"github.com/stretchr/testify/assert"
)
const (
err1 = "first error"
err2 = "second error"
)
func TestBatchErrorNil(t *testing.T) {
var batch BatchError
assert.Nil(t, batch.Err())
assert.False(t, batch.NotNil())
batch.Add(nil)
assert.Nil(t, batch.Err())
assert.False(t, batch.NotNil())
}
func TestBatchErrorNilFromFunc(t *testing.T) {
err := func() error {
var be BatchError
return be.Err()
}()
assert.True(t, err == nil)
}
func TestBatchErrorOneError(t *testing.T) {
var batch BatchError
batch.Add(errors.New(err1))
assert.NotNil(t, batch.Err())
assert.Equal(t, err1, batch.Err().Error())
assert.True(t, batch.NotNil())
}
func TestBatchErrorWithErrors(t *testing.T) {
var batch BatchError
batch.Add(errors.New(err1))
batch.Add(errors.New(err2))
assert.NotNil(t, batch.Err())
assert.Equal(t, fmt.Sprintf("%s\n%s", err1, err2), batch.Err().Error())
assert.True(t, batch.NotNil())
}
func TestBatchErrorConcurrentAdd(t *testing.T) {
const count = 10000
var batch BatchError
var wg sync.WaitGroup
wg.Add(count)
for i := 0; i < count; i++ {
go func() {
defer wg.Done()
batch.Add(errors.New(err1))
}()
}
wg.Wait()
assert.NotNil(t, batch.Err())
assert.Equal(t, count, len(batch.errs))
assert.True(t, batch.NotNil())
}
func TestBatchError_Unwrap(t *testing.T) {
t.Run("nil", func(t *testing.T) {
var be BatchError
assert.Nil(t, be.Err())
assert.True(t, errors.Is(be.Err(), nil))
})
t.Run("one error", func(t *testing.T) {
var errFoo = errors.New("foo")
var errBar = errors.New("bar")
var be BatchError
be.Add(errFoo)
assert.True(t, errors.Is(be.Err(), errFoo))
assert.False(t, errors.Is(be.Err(), errBar))
})
t.Run("two errors", func(t *testing.T) {
var errFoo = errors.New("foo")
var errBar = errors.New("bar")
var errBaz = errors.New("baz")
var be BatchError
be.Add(errFoo)
be.Add(errBar)
assert.True(t, errors.Is(be.Err(), errFoo))
assert.True(t, errors.Is(be.Err(), errBar))
assert.False(t, errors.Is(be.Err(), errBaz))
})
}
func TestBatchError_Add(t *testing.T) {
var be BatchError
// Test adding nil errors
be.Add(nil, nil)
assert.False(t, be.NotNil(), "Expected BatchError to be empty after adding nil errors")
// Test adding non-nil errors
err1 := errors.New("error 1")
err2 := errors.New("error 2")
be.Add(err1, err2)
assert.True(t, be.NotNil(), "Expected BatchError to be non-empty after adding errors")
// Test adding a mix of nil and non-nil errors
err3 := errors.New("error 3")
be.Add(nil, err3, nil)
assert.True(t, be.NotNil(), "Expected BatchError to be non-empty after adding a mix of nil and non-nil errors")
}
func TestBatchError_Err(t *testing.T) {
var be BatchError
// Test Err() on empty BatchError
assert.Nil(t, be.Err(), "Expected nil error for empty BatchError")
// Test Err() with multiple errors
err1 := errors.New("error 1")
err2 := errors.New("error 2")
be.Add(err1, err2)
combinedErr := be.Err()
assert.NotNil(t, combinedErr, "Expected nil error for BatchError with multiple errors")
// Check if the combined error contains both error messages
errString := combinedErr.Error()
assert.Truef(t, errors.Is(combinedErr, err1), "Combined error doesn't contain first error: %s", errString)
assert.Truef(t, errors.Is(combinedErr, err2), "Combined error doesn't contain second error: %s", errString)
}
func TestBatchError_NotNil(t *testing.T) {
var be BatchError
// Test NotNil() on empty BatchError
assert.Nil(t, be.Err(), "Expected nil error for empty BatchError")
// Test NotNil() after adding an error
be.Add(errors.New("test error"))
assert.NotNil(t, be.Err(), "Expected non-nil error after adding an error")
}
+12
View File
@@ -0,0 +1,12 @@
package errorx
// Chain runs funs one by one until an error occurred.
func Chain(fns ...func() error) error {
for _, fn := range fns {
if err := fn(); err != nil {
return err
}
}
return nil
}
+27
View File
@@ -0,0 +1,27 @@
package errorx
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
func TestChain(t *testing.T) {
errDummy := errors.New("dummy")
assert.Nil(t, Chain(func() error {
return nil
}, func() error {
return nil
}))
assert.Equal(t, errDummy, Chain(func() error {
return errDummy
}, func() error {
return nil
}))
assert.Equal(t, errDummy, Chain(func() error {
return nil
}, func() error {
return errDummy
}))
}
+14
View File
@@ -0,0 +1,14 @@
package errorx
import "errors"
// In checks if the given err is one of errs.
func In(err error, errs ...error) bool {
for _, each := range errs {
if errors.Is(err, each) {
return true
}
}
return false
}
+70
View File
@@ -0,0 +1,70 @@
package errorx
import (
"errors"
"testing"
)
func TestIn(t *testing.T) {
err1 := errors.New("error 1")
err2 := errors.New("error 2")
err3 := errors.New("error 3")
tests := []struct {
name string
err error
errs []error
want bool
}{
{
name: "Error matches one of the errors in the list",
err: err1,
errs: []error{err1, err2},
want: true,
},
{
name: "Error does not match any errors in the list",
err: err3,
errs: []error{err1, err2},
want: false,
},
{
name: "Empty error list",
err: err1,
errs: []error{},
want: false,
},
{
name: "Nil error with non-nil list",
err: nil,
errs: []error{err1, err2},
want: false,
},
{
name: "Non-nil error with nil in list",
err: err1,
errs: []error{nil, err2},
want: false,
},
{
name: "Error matches nil error in the list",
err: nil,
errs: []error{nil, err2},
want: true,
},
{
name: "Nil error with empty list",
err: nil,
errs: []error{},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := In(tt.err, tt.errs...); got != tt.want {
t.Errorf("In() = %v, want %v", got, tt.want)
}
})
}
}
+21
View File
@@ -0,0 +1,21 @@
package errorx
import "fmt"
// Wrap returns an error that wraps err with given message.
func Wrap(err error, message string) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", message, err)
}
// Wrapf returns an error that wraps err with given format and args.
func Wrapf(err error, format string, args ...any) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), err)
}
+24
View File
@@ -0,0 +1,24 @@
package errorx
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
func TestWrap(t *testing.T) {
assert.Nil(t, Wrap(nil, "test"))
assert.Equal(t, "foo: bar", Wrap(errors.New("bar"), "foo").Error())
err := errors.New("foo")
assert.True(t, errors.Is(Wrap(err, "bar"), err))
}
func TestWrapf(t *testing.T) {
assert.Nil(t, Wrapf(nil, "%s", "test"))
assert.Equal(t, "foo bar: quz", Wrapf(errors.New("quz"), "foo %s", "bar").Error())
err := errors.New("foo")
assert.True(t, errors.Is(Wrapf(err, "foo %s", "bar"), err))
}
+54
View File
@@ -0,0 +1,54 @@
package exchangeRate
import (
"errors"
"strconv"
"time"
"github.com/go-resty/resty/v2"
)
const (
Url = "https://api.exchangerate.host"
)
type Response struct {
Success bool `json:"success"`
Terms string `json:"terms"`
Privacy string `json:"privacy"`
Query struct {
From string `json:"from"`
To string `json:"to"`
Amount float64 `json:"amount"`
} `json:"query"`
Info struct {
Timestamp int64 `json:"timestamp"`
Quote float64 `json:"quote"`
} `json:"info"`
Result float64 `json:"result"`
}
func GetExchangeRete(form, to, access string, amount float64) (float64, error) {
client := resty.New()
client.SetRetryCount(3)
client.SetTimeout(5 * time.Second)
client.SetBaseURL(Url)
// amount to string
amountStr := strconv.FormatFloat(amount, 'f', -1, 64)
client.SetQueryParams(map[string]string{
"from": form,
"to": to,
"amount": amountStr,
"access_key": access,
})
resp := new(Response)
_, err := client.R().SetResult(resp).Get("/convert")
if err != nil {
return 0, err
}
if !resp.Success {
return 0, errors.New("exchange rate failed")
}
return resp.Result, nil
}
+12
View File
@@ -0,0 +1,12 @@
package exchangeRate
import "testing"
func TestGetExchangeRete(t *testing.T) {
t.Skip("skip TestGetExchangeRete")
result, err := GetExchangeRete("USD", "CNY", "90734e5af4f5353114cdaf3bb9c3f2e3", 1)
if err != nil {
t.Fatal(err)
}
t.Log(result)
}
+8
View File
@@ -0,0 +1,8 @@
//go:build windows
package fs
import "os"
func CloseOnExec(*os.File) {
}

Some files were not shown because too many files have changed in this diff Show More