软件功能基本开发完成,内测BUG等待修复
All checks were successful
build / build (api, amd64, linux) (push) Successful in -15s
build / build (api, arm64, linux) (push) Successful in -29s
build / build (api.exe, amd64, windows) (push) Successful in -27s

This commit is contained in:
CN-JS-HuiBai
2026-04-17 22:31:09 +08:00
parent b3435e5ef8
commit e8e7664aff
19 changed files with 5274 additions and 1151 deletions

View File

@@ -157,6 +157,7 @@ func getAllConfigMappings() gin.H {
"default_remind_traffic": service.MustGetBool("default_remind_traffic", true),
"subscribe_path": service.MustGetString("subscribe_path", "s"),
},
"subscribe_template": service.GetAllSubscribeTemplates(),
"frontend": gin.H{
"frontend_theme": service.MustGetString("frontend_theme", "Xboard"),
"frontend_theme_sidebar": service.MustGetString("frontend_theme_sidebar", "light"),
@@ -202,7 +203,7 @@ func getAllConfigMappings() gin.H {
"nebula_welcome_target": service.MustGetString("nebula_welcome_target", ""),
"nebula_register_title": service.MustGetString("nebula_register_title", ""),
"nebula_background_url": service.MustGetString("nebula_background_url", ""),
"nebula_metrics_base_url": service.MustGetString("nebula_metrics_base_url", ""),
"nebula_metrics_base_url": service.MustGetString("nebula_metrics_base_url", ""),
"nebula_default_theme_mode": service.MustGetString("nebula_default_theme_mode", "system"),
"nebula_light_logo_url": service.MustGetString("nebula_light_logo_url", ""),
"nebula_dark_logo_url": service.MustGetString("nebula_dark_logo_url", ""),
@@ -320,6 +321,9 @@ func settingGroupName(name string) string {
"renew_order_event_id", "change_order_event_id", "show_info_to_server_enable",
"show_protocol_to_server_enable", "default_remind_expire", "default_remind_traffic", "subscribe_path":
return "subscribe"
case "subscribe_template_singbox", "subscribe_template_clash", "subscribe_template_clashmeta",
"subscribe_template_stash", "subscribe_template_surge", "subscribe_template_surfboard":
return "subscribe_template"
case "frontend_theme", "frontend_theme_sidebar", "frontend_theme_header", "frontend_theme_color", "frontend_background_url":
return "frontend"
case "server_token", "server_pull_interval", "server_push_interval", "device_limit_mode", "server_ws_enable", "server_ws_url":

View File

@@ -579,8 +579,13 @@ func AdminUsersFetch(c *gin.Context) {
page := parsePositiveInt(firstString(params["page"], params["current"]), 1)
perPage := parsePositiveInt(firstString(params["per_page"], params["pageSize"]), 50)
keyword := strings.TrimSpace(params["keyword"])
suffix := service.GetPluginConfigString(service.PluginUserAddIPv6, "email_suffix", "-ipv6")
shadowPattern := "%" + suffix + "@%"
query := database.DB.Model(&model.User{}).Preload("Plan").Order("id DESC")
query := database.DB.Model(&model.User{}).
Preload("Plan").
Where("email NOT LIKE ?", shadowPattern).
Order("id DESC")
if keyword != "" {
query = query.Where("email LIKE ? OR CAST(id AS CHAR) = ?", "%"+keyword+"%", keyword)
}

View File

@@ -39,16 +39,37 @@ func FulfillSubscription(c *gin.Context, user *model.User) {
ua := c.GetHeader("User-Agent")
flag := c.Query("flag")
uaLower := strings.ToLower(ua)
// 2. Handle specialized configs
if strings.Contains(ua, "Clash") || flag == "clash" {
config, _ := protocol.GenerateClash(servers, *user)
if strings.Contains(uaLower, "stash") || flag == "stash" {
config, _ := protocol.GenerateClashWithTemplate("stash", servers, *user)
c.Header("Content-Type", "application/octet-stream")
c.String(http.StatusOK, config)
return
}
if strings.Contains(strings.ToLower(ua), "sing-box") || flag == "sing-box" {
if strings.Contains(uaLower, "clashmeta") ||
strings.Contains(uaLower, "clash meta") ||
strings.Contains(uaLower, "metacubex") ||
strings.Contains(uaLower, "verge") ||
strings.Contains(uaLower, "flclash") ||
strings.Contains(uaLower, "nekobox") ||
flag == "clashmeta" || flag == "clash-meta" {
config, _ := protocol.GenerateClashWithTemplate("clashmeta", servers, *user)
c.Header("Content-Type", "application/octet-stream")
c.String(http.StatusOK, config)
return
}
if strings.Contains(ua, "Clash") || flag == "clash" {
config, _ := protocol.GenerateClashWithTemplate("clash", servers, *user)
c.Header("Content-Type", "application/octet-stream")
c.String(http.StatusOK, config)
return
}
if strings.Contains(uaLower, "sing-box") || flag == "sing-box" {
config, _ := protocol.GenerateSingBox(servers, *user)
c.Header("Content-Type", "application/json; charset=utf-8")
c.String(http.StatusOK, config)
@@ -99,7 +120,7 @@ func filterServers(servers []model.Server, types, filter string) []model.Server
func generateInfoNodes(user *model.User) []string {
var nodes []string
// Expire Info
expireDate := "长期有效"
if user.ExpiredAt != nil {

View File

@@ -1,6 +1,8 @@
package handler
import (
"encoding/json"
"net/http"
"strings"
"time"
"xboard-go/internal/database"
@@ -11,6 +13,31 @@ import (
"github.com/gin-gonic/gin"
)
func ipv6StatusPresentation(status string, allowed bool) (string, string, string) {
status = strings.TrimSpace(strings.ToLower(status))
switch status {
case "active":
return "active", "IPv6 enabled", ""
case "eligible":
return "eligible", "Ready to enable", ""
case "", "not_allowed", "not_eligible", "disallowed":
if allowed {
return "eligible", "Ready to enable", ""
}
return "not_allowed", "Not eligible", "Current plan or group is not allowed to enable IPv6"
default:
label := strings.ReplaceAll(strings.Title(strings.ReplaceAll(status, "_", " ")), "Ipv6", "IPv6")
if strings.EqualFold(label, "Not Allowed") {
label = "Not eligible"
}
reason := ""
if !allowed && (status == "not_allowed" || status == "not_eligible") {
reason = "Current plan or group is not allowed to enable IPv6"
}
return status, label, reason
}
}
func PluginUserOnlineDevicesUsers(c *gin.Context) {
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
@@ -165,7 +192,7 @@ func AdminIPv6SubscriptionUsers(c *gin.Context) {
shadowByParentID := make(map[int]model.User, len(userIDs))
if len(userIDs) > 0 {
var shadowUsers []model.User
if err := database.DB.Where("parent_id IN ?", userIDs).Find(&shadowUsers).Error; err == nil {
if err := database.DB.Preload("Plan").Where("parent_id IN ?", userIDs).Find(&shadowUsers).Error; err == nil {
for _, shadow := range shadowUsers {
if shadow.ParentID != nil {
shadowByParentID[*shadow.ParentID] = shadow
@@ -173,6 +200,17 @@ func AdminIPv6SubscriptionUsers(c *gin.Context) {
}
}
}
shadowPlanID := parsePositiveInt(
service.GetPluginConfigString(service.PluginUserAddIPv6, "ipv6_plan_id", "0"),
0,
)
planNames := make(map[int]string)
var plans []model.Plan
if err := database.DB.Select("id", "name").Find(&plans).Error; err == nil {
for _, plan := range plans {
planNames[plan.ID] = plan.Name
}
}
list := make([]gin.H, 0, len(users))
for _, user := range users {
@@ -191,32 +229,30 @@ func AdminIPv6SubscriptionUsers(c *gin.Context) {
}
allowed := service.PluginUserAllowed(&user, user.Plan)
status := "not_allowed"
statusLabel := "Not eligible"
if allowed {
status = "eligible"
statusLabel = "Ready to enable"
planID := shadowPlanID
planNameValue := planNames[shadowPlanID]
if hasShadowUser {
planID = intFromPointer(shadowUser.PlanID)
planNameValue = planName(shadowUser.Plan)
}
shadowUserID := 0
shadowUpdatedAt := int64(0)
if hasSubscription {
status = firstString(subscription.Status, status)
statusLabel = "IPv6 enabled"
if subscription.Status != "active" && subscription.Status != "" {
statusLabel = strings.ReplaceAll(strings.Title(strings.ReplaceAll(subscription.Status, "_", " ")), "Ipv6", "IPv6")
}
if subscription.ShadowUserID != nil {
shadowUserID = *subscription.ShadowUserID
}
shadowUpdatedAt = subscription.UpdatedAt
} else if allowed {
statusLabel = "Ready to enable"
}
effectiveAllowed := allowed || hasSubscription && subscription.Allowed
status, statusLabel, _ := ipv6StatusPresentation(status, effectiveAllowed)
list = append(list, gin.H{
"id": user.ID,
"email": user.Email,
"plan_name": planName(user.Plan),
"allowed": allowed || hasSubscription && subscription.Allowed,
"plan_id": planID,
"plan_name": firstString(planNameValue, "-"),
"allowed": effectiveAllowed,
"is_active": hasSubscription && subscription.Status == "active",
"status": status,
"status_label": statusLabel,
@@ -238,6 +274,71 @@ func AdminIPv6SubscriptionUsers(c *gin.Context) {
})
}
func AdminIPv6SubscriptionConfigFetch(c *gin.Context) {
cfg := service.GetPluginConfig(service.PluginUserAddIPv6)
Success(c, gin.H{
"ipv6_plan_id": parsePositiveInt(service.GetPluginConfigString(service.PluginUserAddIPv6, "ipv6_plan_id", "0"), 0),
"allowed_plans": service.GetPluginConfigIntList(service.PluginUserAddIPv6, "allowed_plans"),
"allowed_groups": service.GetPluginConfigIntList(service.PluginUserAddIPv6, "allowed_groups"),
"email_suffix": firstString(stringFromAny(cfg["email_suffix"]), "-ipv6"),
"reference_flag": firstString(stringFromAny(cfg["reference_flag"]), "ipv6"),
})
}
func AdminIPv6SubscriptionConfigSave(c *gin.Context) {
var payload struct {
IPv6PlanID int `json:"ipv6_plan_id"`
}
if err := c.ShouldBindJSON(&payload); err != nil {
Fail(c, http.StatusBadRequest, "invalid request body")
return
}
var plugin model.Plugin
err := database.DB.Where("code = ?", service.PluginUserAddIPv6).First(&plugin).Error
if err != nil {
plugin = model.Plugin{
Code: service.PluginUserAddIPv6,
Name: "IPv6 Subscription",
IsEnabled: true,
}
}
cfg := service.GetPluginConfig(service.PluginUserAddIPv6)
if payload.IPv6PlanID > 0 {
cfg["ipv6_plan_id"] = payload.IPv6PlanID
} else {
delete(cfg, "ipv6_plan_id")
}
raw, marshalErr := json.Marshal(cfg)
if marshalErr != nil {
Fail(c, http.StatusInternalServerError, "failed to encode plugin config")
return
}
configText := string(raw)
plugin.Config = &configText
if plugin.ID > 0 {
if updateErr := database.DB.Model(&model.Plugin{}).Where("id = ?", plugin.ID).Updates(map[string]any{
"config": plugin.Config,
"is_enabled": plugin.IsEnabled,
}).Error; updateErr != nil {
Fail(c, http.StatusInternalServerError, "failed to save plugin config")
return
}
} else {
if createErr := database.DB.Create(&plugin).Error; createErr != nil {
Fail(c, http.StatusInternalServerError, "failed to create plugin config")
return
}
}
Success(c, gin.H{
"ipv6_plan_id": payload.IPv6PlanID,
})
}
func AdminIPv6SubscriptionEnable(c *gin.Context) {
userID := parsePositiveInt(c.Param("userId"), 0)
if userID == 0 {
@@ -346,24 +447,14 @@ func PluginUserAddIPv6Check(c *gin.Context) {
hasSubscription := database.DB.Where("user_id = ?", user.ID).First(&subscription).Error == nil
allowed := service.PluginUserAllowed(user, plan)
status := "not_allowed"
statusLabel := "Not eligible"
reason := "Current plan or group is not allowed to enable IPv6"
if allowed {
status = "eligible"
statusLabel = "Ready to enable"
reason = ""
}
if hasSubscription {
status = firstString(subscription.Status, "active")
statusLabel = "IPv6 enabled"
reason = ""
if subscription.Status != "active" && subscription.Status != "" {
statusLabel = strings.ReplaceAll(strings.Title(strings.ReplaceAll(subscription.Status, "_", " ")), "Ipv6", "IPv6")
}
}
effectiveAllowed := allowed || hasSubscription && subscription.Allowed
status, statusLabel, reason := ipv6StatusPresentation(status, effectiveAllowed)
Success(c, gin.H{
"allowed": allowed || hasSubscription && subscription.Allowed,
"allowed": effectiveAllowed,
"is_active": hasSubscription && subscription.Status == "active",
"status": status,
"status_label": statusLabel,

View File

@@ -5,37 +5,195 @@ import (
"strings"
"xboard-go/internal/model"
"xboard-go/internal/service"
"github.com/goccy/go-yaml"
)
func GenerateClash(servers []model.Server, user model.User) (string, error) {
return GenerateClashWithTemplate("clash", servers, user)
}
func GenerateClashWithTemplate(templateName string, servers []model.Server, user model.User) (string, error) {
template := strings.TrimSpace(service.GetSubscribeTemplate(templateName))
if template == "" {
return generateClashFallback(servers, user), nil
}
var config map[string]any
if err := yaml.Unmarshal([]byte(template), &config); err != nil {
return generateClashFallback(servers, user), nil
}
proxies := make([]any, 0, len(servers))
proxyNames := make([]string, 0, len(servers))
for _, s := range servers {
conf := service.BuildNodeConfig(&s)
proxy := buildClashProxy(conf, user)
if proxy == nil {
continue
}
proxies = append(proxies, proxy)
proxyNames = append(proxyNames, conf.Name)
}
config["proxies"] = append(anySlice(config["proxies"]), proxies...)
config["proxy-groups"] = mergeClashProxyGroups(anySlice(config["proxy-groups"]), proxyNames)
if _, ok := config["rules"]; !ok {
config["rules"] = []any{"MATCH,Proxy"}
}
data, err := yaml.Marshal(config)
if err != nil {
return "", err
}
output := strings.ReplaceAll(string(data), "$app_name", service.MustGetString("app_name", "XBoard"))
return output, nil
}
func buildClashProxy(conf service.NodeServerConfig, user model.User) map[string]any {
switch conf.Protocol {
case "shadowsocks":
cipher, _ := conf.Cipher.(string)
return map[string]any{
"name": conf.Name,
"type": "ss",
"server": conf.RawHost,
"port": conf.ServerPort,
"cipher": cipher,
"password": user.UUID,
}
case "vmess":
return map[string]any{
"name": conf.Name,
"type": "vmess",
"server": conf.RawHost,
"port": conf.ServerPort,
"uuid": user.UUID,
"alterId": 0,
"cipher": "auto",
"udp": true,
}
case "trojan":
return map[string]any{
"name": conf.Name,
"type": "trojan",
"server": conf.RawHost,
"port": conf.ServerPort,
"password": user.UUID,
"udp": true,
}
default:
return nil
}
}
func mergeClashProxyGroups(groups []any, proxyNames []string) []any {
if len(groups) == 0 {
return []any{
map[string]any{
"name": "Proxy",
"type": "select",
"proxies": append([]any{"DIRECT"}, stringsToAny(proxyNames)...),
},
}
}
for index, item := range groups {
group, ok := item.(map[string]any)
if !ok {
continue
}
group["proxies"] = appendUniqueAny(anySlice(group["proxies"]), stringsToAny(proxyNames)...)
groups[index] = group
}
return groups
}
func anySlice(value any) []any {
switch typed := value.(type) {
case nil:
return []any{}
case []any:
return append([]any{}, typed...)
case []string:
result := make([]any, 0, len(typed))
for _, item := range typed {
result = append(result, item)
}
return result
default:
return []any{}
}
}
func stringsToAny(values []string) []any {
result := make([]any, 0, len(values))
for _, value := range values {
result = append(result, value)
}
return result
}
func appendUniqueAny(base []any, values ...any) []any {
existing := make(map[string]struct{}, len(base))
for _, item := range base {
existing[fmt.Sprint(item)] = struct{}{}
}
for _, item := range values {
key := fmt.Sprint(item)
if _, ok := existing[key]; ok {
continue
}
base = append(base, item)
existing[key] = struct{}{}
}
return base
}
func generateClashFallback(servers []model.Server, user model.User) string {
var builder strings.Builder
builder.WriteString("proxies:\n")
var proxyNames []string
for _, s := range servers {
conf := service.BuildNodeConfig(&s)
proxy := ""
switch conf.Protocol {
case "shadowsocks":
cipher := ""
if c, ok := conf.Cipher.(string); ok { cipher = c }
proxy = fmt.Sprintf(" - name: \"%s\"\n type: ss\n server: %s\n port: %d\n cipher: %s\n password: %s\n",
conf.Name, conf.RawHost, conf.ServerPort, cipher, user.UUID)
case "vmess":
proxy = fmt.Sprintf(" - name: \"%s\"\n type: vmess\n server: %s\n port: %d\n uuid: %s\n alterId: 0\n cipher: auto\n udp: true\n",
conf.Name, conf.RawHost, conf.ServerPort, user.UUID)
case "trojan":
proxy = fmt.Sprintf(" - name: \"%s\"\n type: trojan\n server: %s\n port: %d\n password: %s\n udp: true\n",
conf.Name, conf.RawHost, conf.ServerPort, user.UUID)
default:
proxy := buildClashProxy(conf, user)
if proxy == nil {
continue
}
if proxy != "" {
builder.WriteString(proxy)
proxyNames = append(proxyNames, fmt.Sprintf("\"%s\"", conf.Name))
switch proxy["type"] {
case "ss":
builder.WriteString(fmt.Sprintf(
" - name: \"%s\"\n type: ss\n server: %s\n port: %d\n cipher: %s\n password: %s\n",
conf.Name,
conf.RawHost,
conf.ServerPort,
fmt.Sprint(proxy["cipher"]),
user.UUID,
))
case "vmess":
builder.WriteString(fmt.Sprintf(
" - name: \"%s\"\n type: vmess\n server: %s\n port: %d\n uuid: %s\n alterId: 0\n cipher: auto\n udp: true\n",
conf.Name,
conf.RawHost,
conf.ServerPort,
user.UUID,
))
case "trojan":
builder.WriteString(fmt.Sprintf(
" - name: \"%s\"\n type: trojan\n server: %s\n port: %d\n password: %s\n udp: true\n",
conf.Name,
conf.RawHost,
conf.ServerPort,
user.UUID,
))
}
proxyNames = append(proxyNames, fmt.Sprintf("\"%s\"", conf.Name))
}
builder.WriteString("\nproxy-groups:\n")
@@ -47,5 +205,5 @@ func GenerateClash(servers []model.Server, user model.User) (string, error) {
builder.WriteString("\nrules:\n")
builder.WriteString(" - MATCH,Proxy\n")
return builder.String(), nil
return builder.String()
}

View File

@@ -11,6 +11,81 @@ type SingBoxConfig struct {
}
func GenerateSingBox(servers []model.Server, user model.User) (string, error) {
template := service.GetSubscribeTemplate("singbox")
if template == "" {
return generateSingBoxFallback(servers, user)
}
var config map[string]any
if err := json.Unmarshal([]byte(template), &config); err != nil {
return generateSingBoxFallback(servers, user)
}
outbounds := make([]any, 0, len(servers))
proxyTags := make([]string, 0, len(servers))
for _, s := range servers {
conf := service.BuildNodeConfig(&s)
outbound := buildSingBoxOutbound(conf, user)
if outbound == nil {
continue
}
outbounds = append(outbounds, outbound)
proxyTags = append(proxyTags, conf.Name)
}
existingOutbounds := anySlice(config["outbounds"])
for index, item := range existingOutbounds {
outbound, ok := item.(map[string]any)
if !ok {
continue
}
outboundType, _ := outbound["type"].(string)
if outboundType != "selector" && outboundType != "urltest" {
continue
}
outbound["outbounds"] = appendUniqueAny(anySlice(outbound["outbounds"]), stringsToAny(proxyTags)...)
existingOutbounds[index] = outbound
}
config["outbounds"] = append(existingOutbounds, outbounds...)
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return "", err
}
return string(data), nil
}
func buildSingBoxOutbound(conf service.NodeServerConfig, user model.User) map[string]any {
outbound := map[string]any{
"tag": conf.Name,
"server": conf.RawHost,
"server_port": conf.ServerPort,
}
switch conf.Protocol {
case "shadowsocks":
outbound["type"] = "shadowsocks"
outbound["method"] = conf.Cipher
outbound["password"] = user.UUID
case "vmess":
outbound["type"] = "vmess"
outbound["uuid"] = user.UUID
outbound["security"] = "auto"
case "trojan":
outbound["type"] = "trojan"
outbound["password"] = user.UUID
default:
return nil
}
return outbound
}
func generateSingBoxFallback(servers []model.Server, user model.User) (string, error) {
outbounds := []map[string]interface{}{}
proxyTags := []string{}
@@ -42,7 +117,6 @@ func GenerateSingBox(servers []model.Server, user model.User) (string, error) {
proxyTags = append(proxyTags, conf.Name)
}
// Add selector
selector := map[string]interface{}{
"type": "selector",
"tag": "Proxy",

View File

@@ -0,0 +1,56 @@
package service
import (
"os"
"path/filepath"
"sort"
"strings"
)
var subscribeTemplateFiles = map[string]string{
"singbox": filepath.Join("submodule", "singbox.json"),
"clash": filepath.Join("submodule", "clash.yaml"),
"clashmeta": filepath.Join("submodule", "clash-meta.yaml"),
"stash": filepath.Join("submodule", "stash.yaml"),
"surge": filepath.Join("submodule", "surge.toml"),
"surfboard": filepath.Join("submodule", "surfboard.toml"),
}
func SubscribeTemplateSettingKey(name string) string {
return "subscribe_template_" + strings.TrimSpace(strings.ToLower(name))
}
func DefaultSubscribeTemplate(name string) string {
path, ok := subscribeTemplateFiles[strings.TrimSpace(strings.ToLower(name))]
if !ok {
return ""
}
content, err := os.ReadFile(path)
if err != nil {
return ""
}
return string(content)
}
func GetSubscribeTemplate(name string) string {
key := SubscribeTemplateSettingKey(name)
if value, ok := GetSetting(key); ok && strings.TrimSpace(value) != "" {
return value
}
return DefaultSubscribeTemplate(name)
}
func GetAllSubscribeTemplates() map[string]string {
keys := make([]string, 0, len(subscribeTemplateFiles))
for key := range subscribeTemplateFiles {
keys = append(keys, key)
}
sort.Strings(keys)
result := make(map[string]string, len(keys))
for _, key := range keys {
result[SubscribeTemplateSettingKey(key)] = GetSubscribeTemplate(key)
}
return result
}