refactor: 更新项目引用路径从perfect-panel/ppanel-server到perfect-panel/server
Build docker and publish / build (20.15.1) (push) Failing after 6m27s
Build docker and publish / build (20.15.1) (push) Failing after 6m27s
feat: 添加版本和构建时间变量 fix: 修正短信队列类型注释错误 style: 清理未使用的代码和测试文件 docs: 更新安装文档中的下载链接 chore: 迁移数据库脚本添加日志和订阅配置
This commit is contained in:
@@ -1,71 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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)
|
||||
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
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:
|
||||
`
|
||||
@@ -1,131 +0,0 @@
|
||||
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"`
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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"
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
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"
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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"
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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"
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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"
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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"
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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"
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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)))
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
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
|
||||
}
|
||||
]
|
||||
}
|
||||
`
|
||||
@@ -1,76 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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"`
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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"`
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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
|
||||
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
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
|
||||
@@ -1,44 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
#!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 }}
|
||||
@@ -1,12 +0,0 @@
|
||||
package surfboard
|
||||
|
||||
import "time"
|
||||
|
||||
type UserInfo struct {
|
||||
UUID string
|
||||
Upload int64
|
||||
Download int64
|
||||
TotalTraffic int64
|
||||
ExpiredDate time.Time
|
||||
SubscribeURL string
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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"
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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"
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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"
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
#!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
|
||||
@@ -1,43 +0,0 @@
|
||||
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"
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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"
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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"
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
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"
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
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
|
||||
}
|
||||
Vendored
+49
@@ -0,0 +1,49 @@
|
||||
package cache
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
defaultExpiry = time.Hour * 24 * 7
|
||||
defaultNotFoundExpiry = time.Minute
|
||||
)
|
||||
|
||||
type (
|
||||
// Options is used to store the cache options.
|
||||
Options struct {
|
||||
Expiry time.Duration
|
||||
NotFoundExpiry time.Duration
|
||||
}
|
||||
|
||||
// Option defines the method to customize an Options.
|
||||
Option func(o *Options)
|
||||
)
|
||||
|
||||
func newOptions(opts ...Option) Options {
|
||||
var o Options
|
||||
for _, opt := range opts {
|
||||
opt(&o)
|
||||
}
|
||||
|
||||
if o.Expiry <= 0 {
|
||||
o.Expiry = defaultExpiry
|
||||
}
|
||||
if o.NotFoundExpiry <= 0 {
|
||||
o.NotFoundExpiry = defaultNotFoundExpiry
|
||||
}
|
||||
|
||||
return o
|
||||
}
|
||||
|
||||
// WithExpiry returns a func to customize an Options with given expiry.
|
||||
func WithExpiry(expiry time.Duration) Option {
|
||||
return func(o *Options) {
|
||||
o.Expiry = expiry
|
||||
}
|
||||
}
|
||||
|
||||
// WithNotFoundExpiry returns a func to customize an Options with given not found expiry.
|
||||
func WithNotFoundExpiry(expiry time.Duration) Option {
|
||||
return func(o *Options) {
|
||||
o.NotFoundExpiry = expiry
|
||||
}
|
||||
}
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCacheOptions(t *testing.T) {
|
||||
t.Run("default options", func(t *testing.T) {
|
||||
o := newOptions()
|
||||
assert.Equal(t, defaultExpiry, o.Expiry)
|
||||
assert.Equal(t, defaultNotFoundExpiry, o.NotFoundExpiry)
|
||||
})
|
||||
|
||||
t.Run("with expiry", func(t *testing.T) {
|
||||
o := newOptions(WithExpiry(time.Second))
|
||||
assert.Equal(t, time.Second, o.Expiry)
|
||||
assert.Equal(t, defaultNotFoundExpiry, o.NotFoundExpiry)
|
||||
})
|
||||
|
||||
t.Run("with not found expiry", func(t *testing.T) {
|
||||
o := newOptions(WithNotFoundExpiry(time.Second))
|
||||
assert.Equal(t, defaultExpiry, o.Expiry)
|
||||
assert.Equal(t, time.Second, o.NotFoundExpiry)
|
||||
})
|
||||
}
|
||||
Vendored
+16
-7
@@ -5,12 +5,16 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var ErrNotFound = redis.Nil
|
||||
var (
|
||||
// ErrNotFound is the error when cache not found.
|
||||
ErrNotFound = redis.Nil
|
||||
)
|
||||
|
||||
type (
|
||||
// ExecCtxFn defines the sql exec method.
|
||||
@@ -23,16 +27,21 @@ type (
|
||||
QueryCtxFn func(conn *gorm.DB, v interface{}) error
|
||||
|
||||
CachedConn struct {
|
||||
db *gorm.DB
|
||||
cache *redis.Client
|
||||
db *gorm.DB
|
||||
cache *redis.Client
|
||||
expiry time.Duration
|
||||
notFoundExpiry time.Duration
|
||||
}
|
||||
)
|
||||
|
||||
// NewConn returns a CachedConn with a redis cluster cache.
|
||||
func NewConn(db *gorm.DB, c *redis.Client) CachedConn {
|
||||
func NewConn(db *gorm.DB, c *redis.Client, opts ...Option) CachedConn {
|
||||
o := newOptions(opts...)
|
||||
return CachedConn{
|
||||
db: db,
|
||||
cache: c,
|
||||
db: db,
|
||||
cache: c,
|
||||
expiry: o.Expiry,
|
||||
notFoundExpiry: o.NotFoundExpiry,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +74,7 @@ func (cc CachedConn) SetCache(key string, v interface{}) error {
|
||||
return err
|
||||
}
|
||||
// set redis key
|
||||
return cc.cache.Set(context.Background(), key, val, 0).Err()
|
||||
return cc.cache.Set(context.Background(), key, val, cc.expiry).Err()
|
||||
}
|
||||
|
||||
// ExecCtx runs given exec on given keys, and returns execution result.
|
||||
|
||||
Vendored
+1
-1
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/ppanel-server/pkg/orm"
|
||||
"github.com/perfect-panel/server/pkg/orm"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/plugin/soft_delete"
|
||||
|
||||
@@ -8,4 +8,5 @@ const (
|
||||
CtxKeyRequestHost CtxKey = "requestHost"
|
||||
CtxKeyPlatform CtxKey = "platform"
|
||||
CtxKeyPayment CtxKey = "payment"
|
||||
LoginType CtxKey = "loginType"
|
||||
)
|
||||
|
||||
+15
-6
@@ -1,8 +1,6 @@
|
||||
package constant
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
import "encoding/json"
|
||||
|
||||
// Used for type cloning conversion
|
||||
const (
|
||||
@@ -43,9 +41,20 @@ type TemporaryOrderInfo struct {
|
||||
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)
|
||||
func (t *TemporaryOrderInfo) Unmarshal(data []byte) error {
|
||||
type Alias TemporaryOrderInfo
|
||||
aux := (*Alias)(t)
|
||||
return json.Unmarshal(data, aux)
|
||||
}
|
||||
|
||||
func (t *TemporaryOrderInfo) Marshal() ([]byte, error) {
|
||||
type Alias TemporaryOrderInfo
|
||||
return json.Marshal(&struct {
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(t),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
package constant
|
||||
|
||||
// Version PPanel version
|
||||
const Version = "0.3.0(3002)"
|
||||
var (
|
||||
Version = "unknown version"
|
||||
BuildTime = "unknown time"
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
||||
package countryCenter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetCountryCenter(t *testing.T) {
|
||||
lat, lon, found := GetCountryCenterByCountryOrCity("SG", "Singapore")
|
||||
if !found {
|
||||
t.Error("GetCountryCenter('HK') should return found = true")
|
||||
}
|
||||
t.Logf("lat = %v, lon = %v", lat, lon)
|
||||
|
||||
}
|
||||
+301
-61
@@ -1,95 +1,302 @@
|
||||
// Package deduction provides functionality for calculating remaining amounts
|
||||
// in subscription billing systems, supporting various time units and traffic-based calculations.
|
||||
package deduction
|
||||
|
||||
import (
|
||||
"log"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/ppanel-server/pkg/tool"
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
)
|
||||
|
||||
const (
|
||||
UnitTimeNoLimit = "NoLimit"
|
||||
UnitTimeYear = "Year"
|
||||
UnitTimeMonth = "Month"
|
||||
UnitTimeDay = "Day"
|
||||
UintTimeHour = "Hour"
|
||||
UintTimeMinute = "Minute"
|
||||
// Time unit constants for subscription billing
|
||||
UnitTimeNoLimit = "NoLimit" // Unlimited time subscription
|
||||
UnitTimeYear = "Year" // Annual subscription
|
||||
UnitTimeMonth = "Month" // Monthly subscription
|
||||
UnitTimeDay = "Day" // Daily subscription
|
||||
UnitTimeHour = "Hour" // Hourly subscription
|
||||
UnitTimeMinute = "Minute" // Per-minute subscription
|
||||
|
||||
ResetCycleNone = 0
|
||||
ResetCycle1st = 1
|
||||
ResetCycleMonthly = 2
|
||||
ResetCycleYear = 3
|
||||
// Reset cycle constants for traffic resets
|
||||
ResetCycleNone = 0 // No reset cycle
|
||||
ResetCycle1st = 1 // Reset on 1st of each month
|
||||
ResetCycleMonthly = 2 // Reset monthly based on start date
|
||||
ResetCycleYear = 3 // Reset yearly based on start date
|
||||
|
||||
// Safety limits for overflow protection
|
||||
maxInt64 = math.MaxInt64
|
||||
minInt64 = math.MinInt64
|
||||
)
|
||||
|
||||
// Error definitions for validation and calculation failures
|
||||
var (
|
||||
ErrInvalidQuantity = errors.New("order quantity cannot be zero or negative")
|
||||
ErrInvalidAmount = errors.New("order amount cannot be negative")
|
||||
ErrInvalidTraffic = errors.New("traffic values cannot be negative")
|
||||
ErrInvalidTimeRange = errors.New("expire time must be after start time")
|
||||
ErrInvalidUnitTime = errors.New("invalid unit time")
|
||||
ErrInvalidDeductionRatio = errors.New("deduction ratio must be between 0 and 100")
|
||||
ErrOverflow = errors.New("calculation overflow")
|
||||
)
|
||||
|
||||
// Subscribe represents a subscription with time and traffic limits
|
||||
type Subscribe struct {
|
||||
StartTime time.Time
|
||||
ExpireTime time.Time
|
||||
Traffic int64
|
||||
Download int64
|
||||
Upload int64
|
||||
UnitTime string
|
||||
UnitPrice int64
|
||||
ResetCycle int64
|
||||
DeductionRatio int64
|
||||
StartTime time.Time // Subscription start time
|
||||
ExpireTime time.Time // Subscription expiration time
|
||||
Traffic int64 // Total traffic allowance in bytes
|
||||
Download int64 // Downloaded traffic in bytes
|
||||
Upload int64 // Uploaded traffic in bytes
|
||||
UnitTime string // Time unit for billing (Year, Month, Day, etc.)
|
||||
UnitPrice int64 // Price per unit time
|
||||
ResetCycle int64 // Traffic reset cycle
|
||||
DeductionRatio int64 // Deduction ratio for weighted calculations (0-100)
|
||||
}
|
||||
|
||||
// Order represents a purchase order for subscription calculation
|
||||
type Order struct {
|
||||
Amount int64
|
||||
Quantity int64
|
||||
Amount int64 // Total order amount
|
||||
Quantity int64 // Order quantity
|
||||
}
|
||||
|
||||
func CalculateRemainingAmount(sub Subscribe, order Order) int64 {
|
||||
if sub.UnitTime == UnitTimeNoLimit && sub.ResetCycle != 0 {
|
||||
return 0
|
||||
// Validate checks if the Subscribe struct contains valid data
|
||||
func (s *Subscribe) Validate() error {
|
||||
if s.Traffic < 0 || s.Download < 0 || s.Upload < 0 {
|
||||
return ErrInvalidTraffic
|
||||
}
|
||||
log.Printf("开始计算订单剩余价值")
|
||||
// 实际单价
|
||||
sub.UnitPrice = order.Amount / order.Quantity
|
||||
log.Printf("订阅实际单价: %d", sub.UnitPrice)
|
||||
now := time.Now()
|
||||
|
||||
if s.Download+s.Upload > s.Traffic {
|
||||
return fmt.Errorf("download + upload (%d) cannot exceed total traffic (%d)", s.Download+s.Upload, s.Traffic)
|
||||
}
|
||||
|
||||
if !s.ExpireTime.After(s.StartTime) {
|
||||
return ErrInvalidTimeRange
|
||||
}
|
||||
|
||||
if s.DeductionRatio < 0 || s.DeductionRatio > 100 {
|
||||
return ErrInvalidDeductionRatio
|
||||
}
|
||||
|
||||
validUnitTimes := []string{UnitTimeNoLimit, UnitTimeYear, UnitTimeMonth, UnitTimeDay, UnitTimeHour, UnitTimeMinute}
|
||||
valid := false
|
||||
for _, ut := range validUnitTimes {
|
||||
if s.UnitTime == ut {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
return ErrInvalidUnitTime
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks if the Order struct contains valid data
|
||||
func (o *Order) Validate() error {
|
||||
if o.Quantity <= 0 {
|
||||
return ErrInvalidQuantity
|
||||
}
|
||||
if o.Amount < 0 {
|
||||
return ErrInvalidAmount
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// safeMultiply performs multiplication with overflow protection
|
||||
func safeMultiply(a, b int64) (int64, error) {
|
||||
if a == 0 || b == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if a > 0 && b > 0 {
|
||||
if a > maxInt64/b {
|
||||
return 0, ErrOverflow
|
||||
}
|
||||
} else if a < 0 && b < 0 {
|
||||
if a < maxInt64/b {
|
||||
return 0, ErrOverflow
|
||||
}
|
||||
} else {
|
||||
if (a > 0 && b < minInt64/a) || (a < 0 && b > minInt64/a) {
|
||||
return 0, ErrOverflow
|
||||
}
|
||||
}
|
||||
|
||||
return a * b, nil
|
||||
}
|
||||
|
||||
// safeAdd performs addition with overflow protection
|
||||
func safeAdd(a, b int64) (int64, error) {
|
||||
if (b > 0 && a > maxInt64-b) || (b < 0 && a < minInt64-b) {
|
||||
return 0, ErrOverflow
|
||||
}
|
||||
return a + b, nil
|
||||
}
|
||||
|
||||
// safeDivide performs division with zero-division protection
|
||||
func safeDivide(a, b int64) (int64, error) {
|
||||
if b == 0 {
|
||||
return 0, errors.New("division by zero")
|
||||
}
|
||||
return a / b, nil
|
||||
}
|
||||
|
||||
// CalculateRemainingAmount calculates the remaining refund amount for a subscription
|
||||
// based on unused time and traffic. Returns the amount and any calculation errors.
|
||||
func CalculateRemainingAmount(sub Subscribe, order Order) (int64, error) {
|
||||
if err := sub.Validate(); err != nil {
|
||||
return 0, fmt.Errorf("invalid subscription: %w", err)
|
||||
}
|
||||
|
||||
if err := order.Validate(); err != nil {
|
||||
return 0, fmt.Errorf("invalid order: %w", err)
|
||||
}
|
||||
|
||||
if sub.UnitTime == UnitTimeNoLimit && sub.ResetCycle != 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
unitPrice, err := safeDivide(order.Amount, order.Quantity)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to calculate unit price: %w", err)
|
||||
}
|
||||
sub.UnitPrice = unitPrice
|
||||
|
||||
loc, err := time.LoadLocation(sub.StartTime.Location().String())
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
now := time.Now().In(loc)
|
||||
|
||||
switch sub.UnitTime {
|
||||
case UnitTimeNoLimit:
|
||||
log.Printf("订阅不限时长")
|
||||
usedTraffic := sub.Traffic - sub.Download - sub.Upload
|
||||
unitPrice := float64(order.Amount) / float64(sub.Traffic)
|
||||
return int64(float64(usedTraffic) * unitPrice)
|
||||
return calculateNoLimitAmount(sub, order)
|
||||
|
||||
case UnitTimeYear:
|
||||
log.Printf("订阅时长为年")
|
||||
remainingYears := tool.YearDiff(now, sub.ExpireTime)
|
||||
remainingUnitTimeAmount := calculateRemainingUnitTimeAmount(sub)
|
||||
return int64(remainingYears)*sub.UnitPrice + remainingUnitTimeAmount
|
||||
remainingUnitTimeAmount, err := calculateRemainingUnitTimeAmount(sub)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
yearAmount, err := safeMultiply(int64(remainingYears), sub.UnitPrice)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("year calculation overflow: %w", err)
|
||||
}
|
||||
|
||||
total, err := safeAdd(yearAmount, remainingUnitTimeAmount)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("total calculation overflow: %w", err)
|
||||
}
|
||||
|
||||
return total, nil
|
||||
|
||||
case UnitTimeMonth:
|
||||
log.Printf("订阅时长为月")
|
||||
remainingMonths := tool.MonthDiff(now, sub.ExpireTime)
|
||||
remainingUnitTimeAmount := calculateRemainingUnitTimeAmount(sub)
|
||||
return int64(remainingMonths)*sub.UnitPrice + remainingUnitTimeAmount
|
||||
remainingUnitTimeAmount, err := calculateRemainingUnitTimeAmount(sub)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
monthAmount, err := safeMultiply(int64(remainingMonths), sub.UnitPrice)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("month calculation overflow: %w", err)
|
||||
}
|
||||
|
||||
total, err := safeAdd(monthAmount, remainingUnitTimeAmount)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("total calculation overflow: %w", err)
|
||||
}
|
||||
|
||||
return total, nil
|
||||
|
||||
case UnitTimeDay:
|
||||
remainingDays := tool.DayDiff(now, sub.ExpireTime)
|
||||
remainingUnitTimeAmount, err := calculateRemainingUnitTimeAmount(sub)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
dayAmount, err := safeMultiply(remainingDays, sub.UnitPrice)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("day calculation overflow: %w", err)
|
||||
}
|
||||
|
||||
total, err := safeAdd(dayAmount, remainingUnitTimeAmount)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("total calculation overflow: %w", err)
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
||||
return 0
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func calculateRemainingUnitTimeAmount(sub Subscribe) int64 {
|
||||
// calculateNoLimitAmount calculates refund amount for unlimited time subscriptions
|
||||
// based on unused traffic only
|
||||
func calculateNoLimitAmount(sub Subscribe, order Order) (int64, error) {
|
||||
if sub.Traffic == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
usedTraffic := sub.Traffic - sub.Download - sub.Upload
|
||||
if usedTraffic < 0 {
|
||||
usedTraffic = 0
|
||||
}
|
||||
|
||||
unitPrice := float64(order.Amount) / float64(sub.Traffic)
|
||||
result := float64(usedTraffic) * unitPrice
|
||||
|
||||
if result > float64(maxInt64) || result < float64(minInt64) {
|
||||
return 0, ErrOverflow
|
||||
}
|
||||
|
||||
return int64(result), nil
|
||||
}
|
||||
|
||||
// calculateRemainingUnitTimeAmount calculates the remaining amount based on
|
||||
// both time and traffic usage, applying deduction ratios when specified
|
||||
func calculateRemainingUnitTimeAmount(sub Subscribe) (int64, error) {
|
||||
now := time.Now()
|
||||
log.Printf("开始计算订阅剩余时长价值")
|
||||
log.Printf("订阅开始时间: %s, 订阅到期时间: %s,订阅流量: %d", sub.StartTime.Format("2006-01-02 15:04:05"), sub.ExpireTime.Format("2006-01-02 15:04:05"), sub.Traffic)
|
||||
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)
|
||||
log.Printf("订阅剩余天数: %d, 总天数: %d, 剩余流量: %d, 剩余时间价值: %d, 剩余流量价值: %d", remainingDays, totalDays, remainingTraffic, remainingTimeAmount, remainingTrafficAmount)
|
||||
if sub.Traffic == 0 {
|
||||
return remainingTimeAmount
|
||||
|
||||
if totalDays == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
remainingTraffic := sub.Traffic - sub.Download - sub.Upload
|
||||
if remainingTraffic < 0 {
|
||||
remainingTraffic = 0
|
||||
}
|
||||
|
||||
remainingTimeAmount, err := calculateProportionalAmount(sub.UnitPrice, remainingDays, totalDays)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("time amount calculation failed: %w", err)
|
||||
}
|
||||
|
||||
if sub.Traffic == 0 {
|
||||
return remainingTimeAmount, nil
|
||||
}
|
||||
|
||||
remainingTrafficAmount, err := calculateProportionalAmount(sub.UnitPrice, remainingTraffic, sub.Traffic)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("traffic amount calculation failed: %w", err)
|
||||
}
|
||||
|
||||
if sub.DeductionRatio != 0 {
|
||||
return calculateWeightedAmount(sub.UnitPrice, remainingTraffic, sub.Traffic, remainingDays, totalDays, trafficWeight, timeWeight)
|
||||
}
|
||||
|
||||
return min(remainingTimeAmount, remainingTrafficAmount)
|
||||
return min(remainingTimeAmount, remainingTrafficAmount), nil
|
||||
}
|
||||
|
||||
// calculateWeights converts deduction ratio to traffic and time weights
|
||||
// for weighted calculations
|
||||
func calculateWeights(deductionRatio int64) (float64, float64) {
|
||||
if deductionRatio == 0 {
|
||||
return 0, 0
|
||||
@@ -99,22 +306,32 @@ func calculateWeights(deductionRatio int64) (float64, float64) {
|
||||
return trafficWeight, timeWeight
|
||||
}
|
||||
|
||||
// getRemainingAndTotalDays calculates remaining and total days based on
|
||||
// the subscription's reset cycle configuration
|
||||
func getRemainingAndTotalDays(sub Subscribe, now time.Time) (int64, int64) {
|
||||
log.Printf("开始计算订阅剩余天数")
|
||||
log.Printf("重置周期: %d", sub.ResetCycle)
|
||||
switch sub.ResetCycle {
|
||||
case ResetCycleNone:
|
||||
|
||||
remaining := sub.ExpireTime.Sub(now).Hours() / 24
|
||||
total := sub.ExpireTime.Sub(sub.StartTime).Hours() / 24
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
if total < 0 {
|
||||
total = 0
|
||||
}
|
||||
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())
|
||||
remaining := tool.DaysToMonthDay(now, sub.StartTime.Day()) - 1
|
||||
total := tool.DaysToMonthDay(now, sub.StartTime.Day())
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
return remaining, total
|
||||
|
||||
case ResetCycleYear:
|
||||
return tool.DaysToYearDay(now, int(sub.StartTime.Month()), sub.StartTime.Day()),
|
||||
tool.GetYearDays(now, int(sub.StartTime.Month()), sub.StartTime.Day())
|
||||
@@ -122,13 +339,36 @@ func getRemainingAndTotalDays(sub Subscribe, now time.Time) (int64, int64) {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
func calculateWeightedAmount(unitPrice, remainingTraffic, totalTraffic, remainingDays, totalDays int64, trafficWeight, timeWeight float64) int64 {
|
||||
// calculateWeightedAmount applies weighted calculation combining both time and traffic
|
||||
// remaining ratios based on the specified weights
|
||||
func calculateWeightedAmount(unitPrice, remainingTraffic, totalTraffic, remainingDays, totalDays int64, trafficWeight, timeWeight float64) (int64, error) {
|
||||
if totalDays == 0 || totalTraffic == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
remainingTimeRatio := float64(remainingDays) / float64(totalDays)
|
||||
remainingTrafficRatio := float64(remainingTraffic) / float64(totalTraffic)
|
||||
weightedRemainingRatio := (timeWeight * remainingTimeRatio) + (trafficWeight * remainingTrafficRatio)
|
||||
return int64(float64(unitPrice) * weightedRemainingRatio)
|
||||
|
||||
result := float64(unitPrice) * weightedRemainingRatio
|
||||
if result > float64(maxInt64) || result < float64(minInt64) {
|
||||
return 0, ErrOverflow
|
||||
}
|
||||
|
||||
return int64(result), nil
|
||||
}
|
||||
|
||||
func calculateProportionalAmount(unitPrice, remaining, total int64) int64 {
|
||||
return int64(float64(unitPrice) * (float64(remaining) / float64(total)))
|
||||
// calculateProportionalAmount calculates proportional amount based on
|
||||
// remaining vs total ratio with overflow protection
|
||||
func calculateProportionalAmount(unitPrice, remaining, total int64) (int64, error) {
|
||||
if total == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
result := float64(unitPrice) * (float64(remaining) / float64(total))
|
||||
if result > float64(maxInt64) || result < float64(minInt64) {
|
||||
return 0, ErrOverflow
|
||||
}
|
||||
|
||||
return int64(result), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,665 @@
|
||||
package deduction
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSubscribe_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sub Subscribe
|
||||
wantErr bool
|
||||
errType error
|
||||
}{
|
||||
{
|
||||
name: "valid subscription",
|
||||
sub: Subscribe{
|
||||
StartTime: time.Now(),
|
||||
ExpireTime: time.Now().Add(24 * time.Hour),
|
||||
Traffic: 1000,
|
||||
Download: 100,
|
||||
Upload: 200,
|
||||
UnitTime: UnitTimeMonth,
|
||||
DeductionRatio: 50,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "negative traffic",
|
||||
sub: Subscribe{
|
||||
StartTime: time.Now(),
|
||||
ExpireTime: time.Now().Add(24 * time.Hour),
|
||||
Traffic: -1000,
|
||||
Download: 100,
|
||||
Upload: 200,
|
||||
UnitTime: UnitTimeMonth,
|
||||
DeductionRatio: 50,
|
||||
},
|
||||
wantErr: true,
|
||||
errType: ErrInvalidTraffic,
|
||||
},
|
||||
{
|
||||
name: "negative download",
|
||||
sub: Subscribe{
|
||||
StartTime: time.Now(),
|
||||
ExpireTime: time.Now().Add(24 * time.Hour),
|
||||
Traffic: 1000,
|
||||
Download: -100,
|
||||
Upload: 200,
|
||||
UnitTime: UnitTimeMonth,
|
||||
DeductionRatio: 50,
|
||||
},
|
||||
wantErr: true,
|
||||
errType: ErrInvalidTraffic,
|
||||
},
|
||||
{
|
||||
name: "download + upload exceeds traffic",
|
||||
sub: Subscribe{
|
||||
StartTime: time.Now(),
|
||||
ExpireTime: time.Now().Add(24 * time.Hour),
|
||||
Traffic: 1000,
|
||||
Download: 600,
|
||||
Upload: 500,
|
||||
UnitTime: UnitTimeMonth,
|
||||
DeductionRatio: 50,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "expire time before start time",
|
||||
sub: Subscribe{
|
||||
StartTime: time.Now(),
|
||||
ExpireTime: time.Now().Add(-24 * time.Hour),
|
||||
Traffic: 1000,
|
||||
Download: 100,
|
||||
Upload: 200,
|
||||
UnitTime: UnitTimeMonth,
|
||||
DeductionRatio: 50,
|
||||
},
|
||||
wantErr: true,
|
||||
errType: ErrInvalidTimeRange,
|
||||
},
|
||||
{
|
||||
name: "invalid deduction ratio - negative",
|
||||
sub: Subscribe{
|
||||
StartTime: time.Now(),
|
||||
ExpireTime: time.Now().Add(24 * time.Hour),
|
||||
Traffic: 1000,
|
||||
Download: 100,
|
||||
Upload: 200,
|
||||
UnitTime: UnitTimeMonth,
|
||||
DeductionRatio: -10,
|
||||
},
|
||||
wantErr: true,
|
||||
errType: ErrInvalidDeductionRatio,
|
||||
},
|
||||
{
|
||||
name: "invalid deduction ratio - over 100",
|
||||
sub: Subscribe{
|
||||
StartTime: time.Now(),
|
||||
ExpireTime: time.Now().Add(24 * time.Hour),
|
||||
Traffic: 1000,
|
||||
Download: 100,
|
||||
Upload: 200,
|
||||
UnitTime: UnitTimeMonth,
|
||||
DeductionRatio: 150,
|
||||
},
|
||||
wantErr: true,
|
||||
errType: ErrInvalidDeductionRatio,
|
||||
},
|
||||
{
|
||||
name: "invalid unit time",
|
||||
sub: Subscribe{
|
||||
StartTime: time.Now(),
|
||||
ExpireTime: time.Now().Add(24 * time.Hour),
|
||||
Traffic: 1000,
|
||||
Download: 100,
|
||||
Upload: 200,
|
||||
UnitTime: "InvalidUnit",
|
||||
DeductionRatio: 50,
|
||||
},
|
||||
wantErr: true,
|
||||
errType: ErrInvalidUnitTime,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.sub.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Subscribe.Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if tt.errType != nil && err != tt.errType {
|
||||
t.Errorf("Subscribe.Validate() error = %v, want %v", err, tt.errType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrder_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
order Order
|
||||
wantErr bool
|
||||
errType error
|
||||
}{
|
||||
{
|
||||
name: "valid order",
|
||||
order: Order{Amount: 1000, Quantity: 2},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "zero quantity",
|
||||
order: Order{Amount: 1000, Quantity: 0},
|
||||
wantErr: true,
|
||||
errType: ErrInvalidQuantity,
|
||||
},
|
||||
{
|
||||
name: "negative quantity",
|
||||
order: Order{Amount: 1000, Quantity: -1},
|
||||
wantErr: true,
|
||||
errType: ErrInvalidQuantity,
|
||||
},
|
||||
{
|
||||
name: "negative amount",
|
||||
order: Order{Amount: -1000, Quantity: 2},
|
||||
wantErr: true,
|
||||
errType: ErrInvalidAmount,
|
||||
},
|
||||
{
|
||||
name: "zero amount is valid",
|
||||
order: Order{Amount: 0, Quantity: 1},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.order.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Order.Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if tt.errType != nil && err != tt.errType {
|
||||
t.Errorf("Order.Validate() error = %v, want %v", err, tt.errType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeMultiply(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b int64
|
||||
want int64
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "normal multiplication",
|
||||
a: 10,
|
||||
b: 20,
|
||||
want: 200,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "zero multiplication",
|
||||
a: 10,
|
||||
b: 0,
|
||||
want: 0,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "negative multiplication",
|
||||
a: -10,
|
||||
b: 20,
|
||||
want: -200,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "overflow case",
|
||||
a: math.MaxInt64,
|
||||
b: 2,
|
||||
want: 0,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "large numbers no overflow",
|
||||
a: 1000000,
|
||||
b: 1000000,
|
||||
want: 1000000000000,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := safeMultiply(tt.a, tt.b)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("safeMultiply() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("safeMultiply() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeAdd(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b int64
|
||||
want int64
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "normal addition",
|
||||
a: 10,
|
||||
b: 20,
|
||||
want: 30,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "negative addition",
|
||||
a: -10,
|
||||
b: 5,
|
||||
want: -5,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "overflow case",
|
||||
a: math.MaxInt64,
|
||||
b: 1,
|
||||
want: 0,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "underflow case",
|
||||
a: math.MinInt64,
|
||||
b: -1,
|
||||
want: 0,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := safeAdd(tt.a, tt.b)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("safeAdd() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("safeAdd() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeDivide(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
a, b int64
|
||||
want int64
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "normal division",
|
||||
a: 20,
|
||||
b: 10,
|
||||
want: 2,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "division by zero",
|
||||
a: 20,
|
||||
b: 0,
|
||||
want: 0,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "negative division",
|
||||
a: -20,
|
||||
b: 10,
|
||||
want: -2,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "zero dividend",
|
||||
a: 0,
|
||||
b: 10,
|
||||
want: 0,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := safeDivide(tt.a, tt.b)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("safeDivide() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("safeDivide() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateWeights(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
deductionRatio int64
|
||||
wantTrafficWeight float64
|
||||
wantTimeWeight float64
|
||||
}{
|
||||
{
|
||||
name: "zero ratio",
|
||||
deductionRatio: 0,
|
||||
wantTrafficWeight: 0,
|
||||
wantTimeWeight: 0,
|
||||
},
|
||||
{
|
||||
name: "50% ratio",
|
||||
deductionRatio: 50,
|
||||
wantTrafficWeight: 0.5,
|
||||
wantTimeWeight: 0.5,
|
||||
},
|
||||
{
|
||||
name: "75% ratio",
|
||||
deductionRatio: 75,
|
||||
wantTrafficWeight: 0.75,
|
||||
wantTimeWeight: 0.25,
|
||||
},
|
||||
{
|
||||
name: "100% ratio",
|
||||
deductionRatio: 100,
|
||||
wantTrafficWeight: 1.0,
|
||||
wantTimeWeight: 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotTrafficWeight, gotTimeWeight := calculateWeights(tt.deductionRatio)
|
||||
if gotTrafficWeight != tt.wantTrafficWeight {
|
||||
t.Errorf("calculateWeights() trafficWeight = %v, want %v", gotTrafficWeight, tt.wantTrafficWeight)
|
||||
}
|
||||
if gotTimeWeight != tt.wantTimeWeight {
|
||||
t.Errorf("calculateWeights() timeWeight = %v, want %v", gotTimeWeight, tt.wantTimeWeight)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateProportionalAmount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
unitPrice int64
|
||||
remaining int64
|
||||
total int64
|
||||
want int64
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "normal calculation",
|
||||
unitPrice: 100,
|
||||
remaining: 50,
|
||||
total: 100,
|
||||
want: 50,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "zero total",
|
||||
unitPrice: 100,
|
||||
remaining: 50,
|
||||
total: 0,
|
||||
want: 0,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "zero remaining",
|
||||
unitPrice: 100,
|
||||
remaining: 0,
|
||||
total: 100,
|
||||
want: 0,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "quarter remaining",
|
||||
unitPrice: 200,
|
||||
remaining: 25,
|
||||
total: 100,
|
||||
want: 50,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := calculateProportionalAmount(tt.unitPrice, tt.remaining, tt.total)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("calculateProportionalAmount() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("calculateProportionalAmount() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateNoLimitAmount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sub Subscribe
|
||||
order Order
|
||||
want int64
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "normal no limit calculation",
|
||||
sub: Subscribe{
|
||||
Traffic: 1000,
|
||||
Download: 300,
|
||||
Upload: 200,
|
||||
},
|
||||
order: Order{
|
||||
Amount: 1000,
|
||||
},
|
||||
want: 500, // (1000 - 300 - 200) / 1000 * 1000 = 500
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "zero traffic",
|
||||
sub: Subscribe{
|
||||
Traffic: 0,
|
||||
Download: 0,
|
||||
Upload: 0,
|
||||
},
|
||||
order: Order{
|
||||
Amount: 1000,
|
||||
},
|
||||
want: 0,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "overused traffic",
|
||||
sub: Subscribe{
|
||||
Traffic: 1000,
|
||||
Download: 600,
|
||||
Upload: 500,
|
||||
},
|
||||
order: Order{
|
||||
Amount: 1000,
|
||||
},
|
||||
want: 0, // usedTraffic would be negative, clamped to 0
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := calculateNoLimitAmount(tt.sub, tt.order)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("calculateNoLimitAmount() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("calculateNoLimitAmount() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateRemainingAmount(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sub Subscribe
|
||||
order Order
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid no limit subscription",
|
||||
sub: Subscribe{
|
||||
StartTime: now.Add(-24 * time.Hour),
|
||||
ExpireTime: now.Add(24 * time.Hour),
|
||||
Traffic: 1000,
|
||||
Download: 300,
|
||||
Upload: 200,
|
||||
UnitTime: UnitTimeNoLimit,
|
||||
ResetCycle: ResetCycleNone,
|
||||
DeductionRatio: 0,
|
||||
},
|
||||
order: Order{
|
||||
Amount: 1000,
|
||||
Quantity: 1,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid subscription",
|
||||
sub: Subscribe{
|
||||
StartTime: now,
|
||||
ExpireTime: now.Add(-24 * time.Hour), // Invalid: expire before start
|
||||
Traffic: 1000,
|
||||
Download: 300,
|
||||
Upload: 200,
|
||||
UnitTime: UnitTimeMonth,
|
||||
DeductionRatio: 0,
|
||||
},
|
||||
order: Order{
|
||||
Amount: 1000,
|
||||
Quantity: 1,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid order",
|
||||
sub: Subscribe{
|
||||
StartTime: now.Add(-24 * time.Hour),
|
||||
ExpireTime: now.Add(24 * time.Hour),
|
||||
Traffic: 1000,
|
||||
Download: 300,
|
||||
Upload: 200,
|
||||
UnitTime: UnitTimeMonth,
|
||||
DeductionRatio: 0,
|
||||
},
|
||||
order: Order{
|
||||
Amount: 1000,
|
||||
Quantity: 0, // Invalid: zero quantity
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "no limit with reset cycle",
|
||||
sub: Subscribe{
|
||||
StartTime: now.Add(-24 * time.Hour),
|
||||
ExpireTime: now.Add(24 * time.Hour),
|
||||
Traffic: 1000,
|
||||
Download: 300,
|
||||
Upload: 200,
|
||||
UnitTime: UnitTimeNoLimit,
|
||||
ResetCycle: ResetCycleMonthly, // Should return 0
|
||||
DeductionRatio: 0,
|
||||
},
|
||||
order: Order{
|
||||
Amount: 1000,
|
||||
Quantity: 1,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := CalculateRemainingAmount(tt.sub, tt.order)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("CalculateRemainingAmount() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateRemainingAmount_NoLimitWithResetCycle(t *testing.T) {
|
||||
now := time.Now()
|
||||
sub := Subscribe{
|
||||
StartTime: now.Add(-24 * time.Hour),
|
||||
ExpireTime: now.Add(24 * time.Hour),
|
||||
Traffic: 1000,
|
||||
Download: 300,
|
||||
Upload: 200,
|
||||
UnitTime: UnitTimeNoLimit,
|
||||
ResetCycle: ResetCycleMonthly,
|
||||
DeductionRatio: 0,
|
||||
}
|
||||
order := Order{
|
||||
Amount: 1000,
|
||||
Quantity: 1,
|
||||
}
|
||||
|
||||
got, err := CalculateRemainingAmount(sub, order)
|
||||
if err != nil {
|
||||
t.Errorf("CalculateRemainingAmount() error = %v", err)
|
||||
return
|
||||
}
|
||||
if got != 0 {
|
||||
t.Errorf("CalculateRemainingAmount() = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkCalculateRemainingAmount(b *testing.B) {
|
||||
now := time.Now()
|
||||
sub := Subscribe{
|
||||
StartTime: now.Add(-24 * time.Hour),
|
||||
ExpireTime: now.Add(24 * time.Hour),
|
||||
Traffic: 1000,
|
||||
Download: 300,
|
||||
Upload: 200,
|
||||
UnitTime: UnitTimeMonth,
|
||||
ResetCycle: ResetCycleNone,
|
||||
DeductionRatio: 50,
|
||||
}
|
||||
order := Order{
|
||||
Amount: 1000,
|
||||
Quantity: 1,
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = CalculateRemainingAmount(sub, order)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSafeMultiply(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = safeMultiply(12345, 67890)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
Manager *WorkerManager // 全局调度器实例
|
||||
once sync.Once // 确保 Scheduler 只被初始化一次
|
||||
limit sync.RWMutex // 控制并发限制
|
||||
)
|
||||
|
||||
type WorkerManager struct {
|
||||
db *gorm.DB // 数据库连接
|
||||
sender Sender // 邮件发送器接口
|
||||
mutex sync.RWMutex // 读写互斥锁,确保线程安全
|
||||
workers map[int64]*Worker // 存储所有 Worker 实例
|
||||
cancels map[int64]context.CancelFunc // 存储每个 Worker 的取消函数
|
||||
}
|
||||
|
||||
func NewWorkerManager(db *gorm.DB, sender Sender) *WorkerManager {
|
||||
if Manager != nil {
|
||||
return Manager
|
||||
}
|
||||
once.Do(func() {
|
||||
Manager = &WorkerManager{
|
||||
db: db,
|
||||
workers: make(map[int64]*Worker),
|
||||
cancels: make(map[int64]context.CancelFunc),
|
||||
sender: sender,
|
||||
}
|
||||
})
|
||||
// 设置定时检查任务
|
||||
go func() {
|
||||
for {
|
||||
// 每隔5分钟检查一次
|
||||
select {
|
||||
case <-time.After(1 * time.Minute):
|
||||
checkWorker()
|
||||
continue
|
||||
}
|
||||
}
|
||||
}()
|
||||
return Manager
|
||||
}
|
||||
|
||||
// AddWorker 添加一个新的 Worker 实例
|
||||
func (m *WorkerManager) AddWorker(id int64) {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
if _, exists := m.workers[id]; !exists {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
worker := NewWorker(ctx, id, m.db, m.sender)
|
||||
m.workers[id] = worker
|
||||
m.cancels[id] = cancel
|
||||
go worker.Start()
|
||||
logger.Info("Batch Send Email",
|
||||
logger.Field("message", "Added new worker"),
|
||||
logger.Field("task_id", id),
|
||||
)
|
||||
} else {
|
||||
logger.Info("Batch Send Email",
|
||||
logger.Field("message", "Worker already exists"),
|
||||
logger.Field("task_id", id),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// GetWorker 获取指定任务的 Worker 实例
|
||||
func (m *WorkerManager) GetWorker(id int64) *Worker {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
if worker, exists := m.workers[id]; exists {
|
||||
return worker
|
||||
} else {
|
||||
logger.Error("Batch Send Email",
|
||||
logger.Field("message", "Worker not found"),
|
||||
logger.Field("task_id", id),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveWorker 移除指定任务的 Worker 实例
|
||||
func (m *WorkerManager) RemoveWorker(id int64) {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
if _, exists := m.workers[id]; exists {
|
||||
delete(m.workers, id)
|
||||
if cancelFunc, ok := m.cancels[id]; ok {
|
||||
cancelFunc() // 调用取消函数
|
||||
delete(m.cancels, id)
|
||||
}
|
||||
logger.Info("Batch Send Email",
|
||||
logger.Field("message", "Removed worker"),
|
||||
logger.Field("task_id", id),
|
||||
)
|
||||
} else {
|
||||
logger.Error("Batch Send Email",
|
||||
logger.Field("message", "Worker not found for removal"),
|
||||
logger.Field("task_id", id),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func checkWorker() {
|
||||
if Manager == nil {
|
||||
// 如果 Manager 未初始化,直接返回
|
||||
return
|
||||
}
|
||||
Manager.mutex.Lock()
|
||||
defer Manager.mutex.Unlock()
|
||||
for id, worker := range Manager.workers {
|
||||
if worker.IsRunning() == 2 {
|
||||
// 如果Worker已完成,移除它
|
||||
delete(Manager.workers, id)
|
||||
if cancelFunc, ok := Manager.cancels[id]; ok {
|
||||
cancelFunc() // 调用取消函数
|
||||
delete(Manager.cancels, id)
|
||||
}
|
||||
logger.Info("Batch Send Email",
|
||||
logger.Field("message", "Removed completed worker"),
|
||||
logger.Field("task_id", id),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package email
|
||||
|
||||
import "github.com/perfect-panel/ppanel-server/internal/types"
|
||||
import "github.com/perfect-panel/server/internal/types"
|
||||
|
||||
type Platform int
|
||||
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/perfect-panel/ppanel-server/pkg/email/smtp"
|
||||
"github.com/perfect-panel/ppanel-server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/email/smtp"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
)
|
||||
|
||||
type Sender interface {
|
||||
|
||||
@@ -282,7 +282,6 @@ const (
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
DefaultTrafficExceedEmailTemplate = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/task"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ErrorInfo struct {
|
||||
Error string `json:"error"`
|
||||
Email string `json:"email"`
|
||||
Time int64 `json:"time"`
|
||||
}
|
||||
|
||||
type Worker struct {
|
||||
id int64 // 任务ID
|
||||
db *gorm.DB // 数据库连接
|
||||
ctx context.Context // 上下文
|
||||
sender Sender // 邮件发送器接口
|
||||
status uint8 // 任务状态,0 表示未运行,1 表示运行中 2 表示已完成
|
||||
}
|
||||
|
||||
func NewWorker(ctx context.Context, id int64, db *gorm.DB, sender Sender) *Worker {
|
||||
return &Worker{
|
||||
id: id,
|
||||
db: db,
|
||||
ctx: ctx,
|
||||
sender: sender,
|
||||
}
|
||||
}
|
||||
|
||||
// GetID 获取Worker的任务ID
|
||||
func (w *Worker) GetID() int64 {
|
||||
return w.id
|
||||
}
|
||||
|
||||
// IsRunning 检查Worker是否正在运行
|
||||
func (w *Worker) IsRunning() uint8 {
|
||||
return w.status
|
||||
}
|
||||
|
||||
// Start 启动Worker,开始处理任务
|
||||
func (w *Worker) Start() {
|
||||
// 检查并发限制
|
||||
limit.Lock()
|
||||
defer limit.Unlock()
|
||||
tx := w.db.WithContext(w.ctx)
|
||||
var taskInfo task.Task
|
||||
if err := tx.Model(&task.Task{}).Where("id = ?", w.id).First(&taskInfo).Error; err != nil {
|
||||
logger.Error("Batch Send Email",
|
||||
logger.Field("message", "Failed to find task"),
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("task_id", w.id),
|
||||
)
|
||||
return
|
||||
}
|
||||
if taskInfo.Status != 0 {
|
||||
logger.Error("Batch Send Email",
|
||||
logger.Field("message", "Task already completed or in progress"),
|
||||
logger.Field("task_id", w.id),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
var scope task.EmailScope
|
||||
if err := json.Unmarshal([]byte(taskInfo.Scope), &scope); err != nil {
|
||||
logger.Error("Batch Send Email",
|
||||
logger.Field("message", "Failed to parse task scope"),
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("task_id", w.id),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if len(scope.Recipients) == 0 && len(scope.Additional) == 0 {
|
||||
logger.Error("Batch Send Email",
|
||||
logger.Field("message", "No recipients or additional emails provided"),
|
||||
logger.Field("task_id", w.id),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
var content task.EmailContent
|
||||
if err := json.Unmarshal([]byte(taskInfo.Content), &content); err != nil {
|
||||
logger.Error("Batch Send Email",
|
||||
logger.Field("message", "Failed to parse task content"),
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("task_id", w.id),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
w.status = 1 // 设置状态为运行中
|
||||
var recipients []string
|
||||
// 解析收件人
|
||||
if len(scope.Recipients) > 0 {
|
||||
recipients = append(recipients, scope.Recipients...)
|
||||
}
|
||||
// 解析附加收件人
|
||||
if len(scope.Additional) > 0 {
|
||||
recipients = append(recipients, scope.Additional...)
|
||||
}
|
||||
// 去重和清理空字符串
|
||||
recipients = tool.RemoveDuplicateElements(recipients...)
|
||||
|
||||
if len(recipients) == 0 {
|
||||
logger.Error("Batch Send Email",
|
||||
logger.Field("message", "No valid recipients found"),
|
||||
logger.Field("task_id", w.id),
|
||||
)
|
||||
w.status = 2 // 设置状态为已完成
|
||||
return
|
||||
}
|
||||
|
||||
// 设置发送间隔时间
|
||||
var intervalTime time.Duration
|
||||
if scope.Interval == 0 {
|
||||
intervalTime = 1 * time.Second
|
||||
} else {
|
||||
intervalTime = time.Duration(scope.Interval) * time.Second
|
||||
}
|
||||
|
||||
var errors []ErrorInfo
|
||||
var count uint64
|
||||
for _, recipient := range recipients {
|
||||
select {
|
||||
case <-w.ctx.Done():
|
||||
logger.Info("Batch Send Email",
|
||||
logger.Field("message", "Worker stopped by context cancellation"),
|
||||
logger.Field("task_id", w.id),
|
||||
)
|
||||
return
|
||||
default:
|
||||
}
|
||||
if taskInfo.Status == 0 {
|
||||
taskInfo.Status = 1 // 1 表示任务进行中
|
||||
}
|
||||
|
||||
if err := w.sender.Send([]string{recipient}, content.Subject, content.Content); err != nil {
|
||||
logger.Error("Batch Send Email",
|
||||
logger.Field("message", "Failed to send email"),
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("recipient", recipient),
|
||||
logger.Field("task_id", w.id),
|
||||
)
|
||||
errors = append(errors, ErrorInfo{
|
||||
Error: err.Error(),
|
||||
Email: recipient,
|
||||
Time: time.Now().Unix(),
|
||||
})
|
||||
text, _ := json.Marshal(errors)
|
||||
taskInfo.Errors = string(text)
|
||||
}
|
||||
count++
|
||||
taskInfo.Current = count
|
||||
if err := tx.Model(&task.Task{}).Where("`id` = ?", taskInfo.Id).Save(&taskInfo).Error; err != nil {
|
||||
logger.Error("Batch Send Email",
|
||||
logger.Field("message", "Failed to update task progress"),
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("task_id", w.id),
|
||||
)
|
||||
errors = append(errors, ErrorInfo{
|
||||
Error: err.Error(),
|
||||
Email: recipient,
|
||||
Time: time.Now().Unix(),
|
||||
})
|
||||
w.status = 2 // 设置状态为已完成
|
||||
}
|
||||
time.Sleep(intervalTime)
|
||||
}
|
||||
taskInfo.Status = 2 // 2 表示任务已完成
|
||||
w.status = 2 // 设置状态为已完成
|
||||
|
||||
if err := tx.Model(&task.Task{}).Where("`id` = ?", taskInfo.Id).Save(&taskInfo).Error; err != nil {
|
||||
logger.Error("Batch Send Email",
|
||||
logger.Field("message", "Failed to finalize task"),
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("task_id", w.id),
|
||||
)
|
||||
} else {
|
||||
logger.Info("Batch Send Email",
|
||||
logger.Field("message", "Task completed successfully"),
|
||||
logger.Field("task_id", w.id),
|
||||
logger.Field("total_sent", count),
|
||||
)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -3,7 +3,7 @@ package fs
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/perfect-panel/ppanel-server/pkg/hash"
|
||||
"github.com/perfect-panel/server/pkg/hash"
|
||||
)
|
||||
|
||||
// TempFileWithText creates the temporary file with the given content,
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/perfect-panel/ppanel-server/pkg/lang"
|
||||
"github.com/perfect-panel/server/pkg/lang"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/ppanel-server/pkg/errorx"
|
||||
"github.com/perfect-panel/ppanel-server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/errorx"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/redis/go-redis/v9"
|
||||
xrate "golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ package logger
|
||||
import (
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/perfect-panel/ppanel-server/pkg/color"
|
||||
"github.com/perfect-panel/server/pkg/color"
|
||||
)
|
||||
|
||||
// WithColor is a helper function to add color to a string, only in plain encoding.
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/perfect-panel/ppanel-server/pkg/color"
|
||||
"github.com/perfect-panel/server/pkg/color"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@ type LogConf struct {
|
||||
// console: log to console.
|
||||
// file: log to file.
|
||||
// volume: used in k8s, prepend the hostname to the log file name.
|
||||
Mode string `yaml:"Mode" default:"console"`
|
||||
Mode string `yaml:"Mode" default:"file"`
|
||||
// Encoding represents the encoding type, default is `json`.
|
||||
// json: json encoding.
|
||||
// plain: plain text encoding, typically used in development.
|
||||
Encoding string `yaml:"Encoding" default:"json"`
|
||||
// TimeFormat represents the time format, default is `2006-01-02T15:04:05.000Z07:00`.
|
||||
TimeFormat string `yaml:"TimeFormat" default:"2006-01-02T15:04:05.000Z07:00"`
|
||||
TimeFormat string `yaml:"TimeFormat" default:"2006-01-02 15:04:05.000"`
|
||||
// Path represents the log file path, default is `logs`.
|
||||
Path string `yaml:"Path" default:"logs"`
|
||||
// Level represents the log level, default is `info`.
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/ppanel-server/pkg/syncx"
|
||||
"github.com/perfect-panel/ppanel-server/pkg/timex"
|
||||
"github.com/perfect-panel/server/pkg/syncx"
|
||||
"github.com/perfect-panel/server/pkg/timex"
|
||||
)
|
||||
|
||||
type limitedExecutor struct {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/ppanel-server/pkg/timex"
|
||||
"github.com/perfect-panel/server/pkg/timex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/perfect-panel/ppanel-server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
)
|
||||
|
||||
type Buffer struct {
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/perfect-panel/ppanel-server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
func ReadLastNLines(path string, n int) ([]string, error) {
|
||||
// Open the file
|
||||
file, err := os.Open(fmt.Sprintf("%s/%s", path, accessFilename))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Get file size
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fileSize := fileInfo.Size()
|
||||
|
||||
// If file is empty, return empty slice
|
||||
if fileSize == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
// Buffer for reading
|
||||
bufferSize := int64(4096)
|
||||
if bufferSize > fileSize {
|
||||
bufferSize = fileSize
|
||||
}
|
||||
buffer := make([]byte, bufferSize)
|
||||
|
||||
// Start reading from the end
|
||||
position := fileSize
|
||||
lines := make([]string, 0, n)
|
||||
lineCount := 0
|
||||
|
||||
for lineCount < n && position > 0 {
|
||||
// How much to read
|
||||
readSize := bufferSize
|
||||
if position < bufferSize {
|
||||
readSize = position
|
||||
}
|
||||
position -= readSize
|
||||
|
||||
// Read chunk from position
|
||||
_, err := file.Seek(position, io.SeekStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = file.Read(buffer[:readSize])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Count newlines in reverse
|
||||
for i := readSize - 1; i >= 0; i-- {
|
||||
if buffer[i] == '\n' {
|
||||
lineCount++
|
||||
if lineCount > n {
|
||||
// We found more than n lines
|
||||
// Need to adjust position to read only last n lines
|
||||
position += int64(i) + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we couldn't find n lines, start from beginning
|
||||
if position < 0 {
|
||||
position = 0
|
||||
}
|
||||
|
||||
// Seek to the position where we want to start reading
|
||||
_, err = file.Seek(position, io.SeekStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read lines from position to end
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
|
||||
// Check if we need to trim
|
||||
if len(lines) > n {
|
||||
lines = lines[len(lines)-n:]
|
||||
}
|
||||
|
||||
return lines, scanner.Err()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadLastNLines(t *testing.T) {
|
||||
t.Skipf("skip this test until this test fails")
|
||||
lines, err := ReadLastNLines("/Users/tension/code/ppanel/server/logs", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Error reading last N lines: %v", err)
|
||||
}
|
||||
for i, line := range lines {
|
||||
t.Logf("Line %d: %s", i, line)
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/ppanel-server/internal/trace"
|
||||
"github.com/perfect-panel/server/internal/trace"
|
||||
|
||||
"github.com/perfect-panel/ppanel-server/pkg/timex"
|
||||
"github.com/perfect-panel/server/pkg/timex"
|
||||
)
|
||||
|
||||
// WithCallerSkip returns a Logger with given caller skip.
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/ppanel-server/pkg/fs"
|
||||
"github.com/perfect-panel/ppanel-server/pkg/lang"
|
||||
"github.com/perfect-panel/server/pkg/fs"
|
||||
"github.com/perfect-panel/server/pkg/lang"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/ppanel-server/pkg/random"
|
||||
"github.com/perfect-panel/server/pkg/random"
|
||||
|
||||
"github.com/perfect-panel/ppanel-server/pkg/fs"
|
||||
"github.com/perfect-panel/server/pkg/fs"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ package logger
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/perfect-panel/ppanel-server/pkg/syncx"
|
||||
"github.com/perfect-panel/server/pkg/syncx"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"sync/atomic"
|
||||
|
||||
fatihcolor "github.com/fatih/color"
|
||||
"github.com/perfect-panel/ppanel-server/pkg/color"
|
||||
"github.com/perfect-panel/ppanel-server/pkg/errorx"
|
||||
"github.com/perfect-panel/server/pkg/color"
|
||||
"github.com/perfect-panel/server/pkg/errorx"
|
||||
)
|
||||
|
||||
type (
|
||||
|
||||
@@ -8,10 +8,15 @@ import (
|
||||
func TestNewNodeMultiplierManager(t *testing.T) {
|
||||
periods := []TimePeriod{
|
||||
{
|
||||
StartTime: "23:00",
|
||||
EndTime: "1:59",
|
||||
StartTime: "23:00.000",
|
||||
EndTime: "1:59.000",
|
||||
Multiplier: 1.2,
|
||||
},
|
||||
{
|
||||
StartTime: "12:00.000",
|
||||
EndTime: "13:59.000",
|
||||
Multiplier: 0.5,
|
||||
},
|
||||
}
|
||||
m := NewNodeMultiplierManager(periods)
|
||||
if len(m.Periods) != 1 {
|
||||
|
||||
@@ -28,8 +28,8 @@ func (m *Manager) GetMultiplier(current time.Time) float32 {
|
||||
}
|
||||
|
||||
func (m *Manager) isInTimePeriod(current time.Time, start, end string) bool {
|
||||
startTime, _ := time.Parse("15:04", start)
|
||||
endTime, _ := time.Parse("15:04", end)
|
||||
startTime, _ := time.Parse("15:04.000", start)
|
||||
endTime, _ := time.Parse("15:04.000", end)
|
||||
|
||||
currentTime := time.Date(0, 1, 1, current.Hour(), current.Minute(), 0, 0, time.UTC)
|
||||
startTimeFormatted := time.Date(0, 1, 1, startTime.Hour(), startTime.Minute(), 0, 0, time.UTC)
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
AppleID.auth.init({
|
||||
clientId: 'web.jiashus.com', // 替换为你的服务 ID
|
||||
scope: 'name email', // 可选,授权范围
|
||||
redirectURI: 'https://test.muran.org:8443/auth/apple/callback', // 替换为你的回调 URL
|
||||
redirectURI: 'https://test.ppanel.dev:8443/auth/apple/callback', // 替换为你的回调 URL
|
||||
state: 'optional-csrf-token', // 可选
|
||||
usePopup: false // 可选,是否使用弹窗方式
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ func handleAppleCallBack(ctx context.Context, request CallbackRequest) {
|
||||
ClientID: ClientID,
|
||||
KeyID: KeyID,
|
||||
ClientSecret: ClientSecret,
|
||||
RedirectURI: "https://test.muran.org:8443/auth/apple/callback",
|
||||
RedirectURI: "https://test.ppanel.dev:8443/auth/apple/callback",
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println("error creating apple client: " + err.Error())
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/perfect-panel/ppanel-server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
)
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/ppanel-server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user