From 570657a6415724df7341bc63bedb050b751c1d30 Mon Sep 17 00:00:00 2001 From: haimu0427 Date: Thu, 12 Mar 2026 14:46:51 +0800 Subject: [PATCH 1/6] docs(agents): add AI agent guidance documentation --- AGENTS.md | 74 ++++++++++++++++++++++++++++++++++++ CLAUDE.md | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..1786680c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,74 @@ +# AGENTS.md + +This file provides guidance to agents when working with code in this repository. + +## Critical Architecture Patterns + +**Telegram Bot Restart Pattern**: MUST call `service.StopBot()` before any server restart (SIGHUP or shutdown) to prevent Telegram bot 409 conflicts. This is critical in `main.go` signal handlers (lines 82-84, 120-122). + +**Embedded Assets**: All web resources (HTML, CSS, JS, translations in `web/translation/`) are embedded at compile time using `//go:embed`. Changes to these files require full recompilation - no hot-reload available. + +**Dual Server Design**: Main web panel and subscription server run concurrently, both managed by `web/global` package. Subscription server uses separate port. + +**Database Seeder System**: Uses `HistoryOfSeeders` model to track one-time migrations (e.g., password bcrypt migration). Check this table before running migrations to prevent re-execution. + +**Xray Integration**: Panel dynamically generates `config.json` from inbound/outbound settings and communicates via gRPC API (`xray/api.go`) for real-time traffic stats. Xray binary is platform-specific (`xray-{os}-{arch}`) and managed by installer scripts. + +**Signal-Based Restart**: SIGHUP triggers graceful restart. Always stop Telegram bot first via `service.StopBot()`, then restart both web and sub servers. + +## Build & Development Commands + +```bash +# Build (creates bin/3x-ui.exe) +go build -o bin/3x-ui.exe ./main.go + +# Run with debug logging +XUI_DEBUG=true go run ./main.go + +# Test all packages +go test ./... + +# Vet code +go vet ./... +``` + +**Production Build**: Uses CGO_ENABLED=1 with static linking via Bootlin musl toolchains for cross-platform builds (see `.github/workflows/release.yml`). + +## Configuration & Environment + +**Environment Variables**: +- `XUI_DEBUG=true` - Enable detailed debug logging +- `XUI_LOG_LEVEL` - Set log level (debug/info/notice/warning/error) +- `XUI_MAIN_FOLDER` - Override default installation folder +- `XUI_BIN_FOLDER` - Override binary folder (default: "bin") +- `XUI_DB_FOLDER` - Override database folder (default: `/etc/x-ui` on Linux) +- `XUI_LOG_FOLDER` - Override log folder (default: `/var/log/x-ui` on Linux) + +**Database Path**: `config.GetDBPath()` returns `/etc/x-ui/x-ui.db` on Linux, current directory on Windows. GORM models auto-migrate on startup. + +**Listen Address**: If inbound listen field is empty, defaults to `0.0.0.0` for proper dual-stack IPv4/IPv6 binding (see `database/model/model.go` lines 85-87). + +## Project-Specific Patterns + +**IP Limitation**: Implements "last IP wins" strategy. When client exceeds LimitIP, oldest connections are automatically disconnected via Xray API to allow newest IPs. + +**Session Management**: Uses `gin-contrib/sessions` with cookie-based store for authentication. + +**Internationalization**: Translation files in `web/translation/translate.*.toml`. Access via `I18nWeb(c, "key")` in controllers using `locale.I18nType` enum. + +**Job Scheduling**: Uses `robfig/cron/v3` for periodic tasks (traffic monitoring, CPU checks, LDAP sync, IP tracking). Jobs registered in `web/web.go` during server initialization. + +**Service Layer Pattern**: Services inject dependencies (like `xray.XrayAPI`) and operate on GORM models. Example: `InboundService` in `web/service/inbound.go`. + +**Controller Pattern**: Controllers use Gin context (`*gin.Context`) and inherit from `BaseController`. Check auth via `checkLogin` middleware. + +**Xray Binary Management**: Download platform-specific Xray binary to bin folder during installation. GeoIP/GeoSite rules downloaded from external repositories (Loyalsoldier, chocolate4u, runetfreedom). + +## Gotchas + +1. **Bot Restart**: Always stop Telegram bot before server restart to avoid 409 conflict +2. **Embedded Assets**: Changes to HTML/CSS/JS require recompilation +3. **Password Migration**: Seeder system tracks bcrypt migration - check `HistoryOfSeeders` table +4. **Port Binding**: Subscription server uses different port from main panel +5. **Xray Binary**: Must match OS/arch exactly - managed by installer scripts +6. **No Test Files**: Project currently has no `_test.go` files, though `go test ./...` is available diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..3050e09b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,110 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development commands + +- Build the app: `go build -o bin/3x-ui.exe ./main.go` +- Run locally with debug logging: `XUI_DEBUG=true go run ./main.go` +- Run tests: `go test ./...` +- Run vet: `go vet ./...` +- Run a single package test suite: `go test ./path/to/package` +- Run a single test: `go test ./path/to/package -run TestName` +- Show CLI help / subcommands: `go run ./main.go --help` +- Show version: `go run ./main.go -v` + +VS Code tasks mirror the common Go workflows: +- `go: build` +- `go: run` +- `go: test` +- `go: vet` + +## Runtime shape + +This is a Go monolith for managing Xray-core, with two Gin-based HTTP servers started from `main.go`: +- the main panel server in `web/` +- the subscription server in `sub/` + +`main.go` initializes the SQLite database, starts both servers, and handles process signals: +- `SIGHUP` restarts the panel + subscription servers +- `SIGUSR1` restarts xray-core only + +Important: before full shutdown or SIGHUP restart, the Telegram bot is stopped explicitly via `service.StopBot()` to avoid Telegram 409 conflicts. + +## High-level architecture + +### Database and settings + +- `database/db.go` initializes GORM with SQLite, runs auto-migrations, seeds the default admin user, and runs one-time seeders. +- Models live in `database/model/model.go`. +- App configuration is heavily database-backed through the `settings` table rather than static config files. +- `HistoryOfSeeders` is used to track one-time migrations such as password hashing changes. + +### Web panel + +- `web/web.go` builds the main Gin engine, session middleware, gzip, i18n, static asset serving, template loading, websocket hub setup, and background cron jobs. +- Controllers are in `web/controller/`. +- Business logic lives in `web/service/`. +- Background tasks live in `web/job/`. +- The websocket hub is in `web/websocket/` and is wired from `web/web.go`. + +### Subscription server + +- `sub/sub.go` starts a separate Gin server for subscription links and JSON subscriptions. +- It has its own listen/port/cert settings and can run independently of the main panel routes. +- It reuses embedded templates/assets from `web/` and applies subscription-specific path/domain settings from the database. + +### Xray integration + +- `xray/` is the bridge to xray-core. +- `xray/process.go` writes `config.json`, launches the platform-specific xray binary, tracks process state, and handles stop/restart behavior. +- `xray/api.go`, `xray/traffic.go`, and related files handle API access and traffic/stat collection. +- The panel treats xray-core as a managed subprocess and periodically monitors/restarts it from cron jobs in `web/web.go`. + +### Frontend delivery model + +- The UI is server-rendered HTML templates plus embedded static assets under `web/html/` and `web/assets/`. +- In production, templates/assets are embedded with `go:embed` in `web/web.go`. +- In debug mode (`XUI_DEBUG=true`), templates and assets are loaded from disk, so edits under `web/html/` and `web/assets/` are reflected without rebuilding embedded resources. +- Internationalization files live in `web/translation/*.toml` and are initialized by `web/locale`. + +## Background jobs and long-running behavior + +`web/web.go` registers the operational jobs that keep the panel in sync with xray-core. These include: +- xray process health checks +- deferred/statistical traffic collection +- client IP checks / log maintenance +- periodic traffic reset jobs +- optional LDAP sync +- optional Telegram notification and CPU alert jobs + +When changing settings or services that affect runtime behavior, check whether a cron job, websocket update, or xray restart path also needs to change. + +## Repo-specific conventions and gotchas + +- Default credentials are seeded as `admin` / `admin`, but stored hashed in the DB. +- The app uses DB settings extensively; many behavior changes require updating `SettingService`, not just editing route/controller code. +- The `Inbound` model stores much of the Xray config as JSON strings (`Settings`, `StreamSettings`, `Sniffing`), then converts those into xray config structs. +- The main panel and subscription server have separate listen/port/cert/base-path concepts. Keep them distinct when changing routing or TLS behavior. +- Session handling uses `gin-contrib/sessions` with a cookie store and secret loaded from settings. +- The subscription server intentionally runs Gin in release mode and discards Gin default writers. +- There are currently no `*_test.go` files in the repo, so `go test ./...` mainly validates buildability of packages. + +## Important files to orient quickly + +- `main.go` — process entrypoint, CLI subcommands, signal handling +- `web/web.go` — main server wiring, embedded assets/templates, cron jobs +- `sub/sub.go` — subscription server wiring +- `database/db.go` — DB init, migrations, seeders +- `database/model/model.go` — core persistent models +- `web/service/setting.go` — central behavior/settings access point +- `web/service/inbound.go` and `web/service/xray.go` — panel logic tied to xray config/runtime +- `xray/process.go` — xray subprocess management + +## Existing repo guidance carried forward + +From `.github/copilot-instructions.md` and current code structure: +- Treat the project as a Go + Gin + SQLite application with embedded web assets. +- Remember the dual-server design: main panel plus subscription server. +- Preserve the Telegram bot shutdown-before-restart behavior. +- If working on deployment or container behavior, note that Docker support exists via `Dockerfile`, `DockerInit.sh`, `DockerEntrypoint.sh`, and `docker-compose.yml`. From 9478e1a3e49cbbf5e6ec9174a23fa6b4844e6075 Mon Sep 17 00:00:00 2001 From: haimu0427 Date: Thu, 12 Mar 2026 15:14:51 +0800 Subject: [PATCH 2/6] feat(sub): add Clash/Mihomo YAML subscription service Add SubClashService to convert subscription links to Clash/Mihomo YAML format for direct client compatibility. Co-Authored-By: Claude Sonnet 4.6 --- sub/subClashService.go | 385 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 385 insertions(+) create mode 100644 sub/subClashService.go diff --git a/sub/subClashService.go b/sub/subClashService.go new file mode 100644 index 00000000..ea095919 --- /dev/null +++ b/sub/subClashService.go @@ -0,0 +1,385 @@ +package sub + +import ( + "fmt" + "strings" + + "github.com/goccy/go-json" + yaml "github.com/goccy/go-yaml" + + "github.com/mhsanaei/3x-ui/v2/database/model" + "github.com/mhsanaei/3x-ui/v2/logger" + "github.com/mhsanaei/3x-ui/v2/web/service" + "github.com/mhsanaei/3x-ui/v2/xray" +) + +type SubClashService struct { + inboundService service.InboundService + SubService *SubService +} + +type ClashConfig struct { + Proxies []map[string]any `yaml:"proxies"` + ProxyGroups []map[string]any `yaml:"proxy-groups"` + Rules []string `yaml:"rules"` +} + +func NewSubClashService(subService *SubService) *SubClashService { + return &SubClashService{SubService: subService} +} + +func (s *SubClashService) GetClash(subId string, host string) (string, string, error) { + inbounds, err := s.SubService.getInboundsBySubId(subId) + if err != nil || len(inbounds) == 0 { + return "", "", err + } + + var traffic xray.ClientTraffic + var clientTraffics []xray.ClientTraffic + var proxies []map[string]any + + for _, inbound := range inbounds { + clients, err := s.inboundService.GetClients(inbound) + if err != nil { + logger.Error("SubClashService - GetClients: Unable to get clients from inbound") + } + if clients == nil { + continue + } + if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' { + listen, port, streamSettings, err := s.SubService.getFallbackMaster(inbound.Listen, inbound.StreamSettings) + if err == nil { + inbound.Listen = listen + inbound.Port = port + inbound.StreamSettings = streamSettings + } + } + for _, client := range clients { + if client.Enable && client.SubID == subId { + clientTraffics = append(clientTraffics, s.SubService.getClientTraffics(inbound.ClientStats, client.Email)) + proxies = append(proxies, s.getProxies(inbound, client, host)...) + } + } + } + + if len(proxies) == 0 { + return "", "", nil + } + + for index, clientTraffic := range clientTraffics { + if index == 0 { + traffic.Up = clientTraffic.Up + traffic.Down = clientTraffic.Down + traffic.Total = clientTraffic.Total + if clientTraffic.ExpiryTime > 0 { + traffic.ExpiryTime = clientTraffic.ExpiryTime + } + } else { + traffic.Up += clientTraffic.Up + traffic.Down += clientTraffic.Down + if traffic.Total == 0 || clientTraffic.Total == 0 { + traffic.Total = 0 + } else { + traffic.Total += clientTraffic.Total + } + if clientTraffic.ExpiryTime != traffic.ExpiryTime { + traffic.ExpiryTime = 0 + } + } + } + + proxyNames := make([]string, 0, len(proxies)+1) + for _, proxy := range proxies { + if name, ok := proxy["name"].(string); ok && name != "" { + proxyNames = append(proxyNames, name) + } + } + proxyNames = append(proxyNames, "DIRECT") + + config := ClashConfig{ + Proxies: proxies, + ProxyGroups: []map[string]any{{ + "name": "PROXY", + "type": "select", + "proxies": proxyNames, + }}, + Rules: []string{"MATCH,PROXY"}, + } + + finalYAML, err := yaml.Marshal(config) + if err != nil { + return "", "", err + } + + header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000) + return string(finalYAML), header, nil +} + +func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client, host string) []map[string]any { + stream := s.streamData(inbound.StreamSettings) + externalProxies, ok := stream["externalProxy"].([]any) + if !ok || len(externalProxies) == 0 { + externalProxies = []any{map[string]any{ + "forceTls": "same", + "dest": host, + "port": float64(inbound.Port), + "remark": "", + }} + } + delete(stream, "externalProxy") + + proxies := make([]map[string]any, 0, len(externalProxies)) + for _, ep := range externalProxies { + extPrxy := ep.(map[string]any) + workingInbound := *inbound + workingInbound.Listen = extPrxy["dest"].(string) + workingInbound.Port = int(extPrxy["port"].(float64)) + workingStream := cloneMap(stream) + + switch extPrxy["forceTls"].(string) { + case "tls": + if workingStream["security"] != "tls" { + workingStream["security"] = "tls" + workingStream["tlsSettings"] = map[string]any{} + } + case "none": + if workingStream["security"] != "none" { + workingStream["security"] = "none" + delete(workingStream, "tlsSettings") + delete(workingStream, "realitySettings") + } + } + + proxy := s.buildProxy(&workingInbound, client, workingStream, extPrxy["remark"].(string)) + if len(proxy) > 0 { + proxies = append(proxies, proxy) + } + } + return proxies +} + +func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client, stream map[string]any, extraRemark string) map[string]any { + proxy := map[string]any{ + "name": s.SubService.genRemark(inbound, client.Email, extraRemark), + "server": inbound.Listen, + "port": inbound.Port, + "udp": true, + } + + network, _ := stream["network"].(string) + if !s.applyTransport(proxy, network, stream) { + return nil + } + + switch inbound.Protocol { + case model.VMESS: + proxy["type"] = "vmess" + proxy["uuid"] = client.ID + proxy["alterId"] = 0 + cipher := client.Security + if cipher == "" { + cipher = "auto" + } + proxy["cipher"] = cipher + case model.VLESS: + proxy["type"] = "vless" + proxy["uuid"] = client.ID + if client.Flow != "" && network == "tcp" { + proxy["flow"] = client.Flow + } + var inboundSettings map[string]any + json.Unmarshal([]byte(inbound.Settings), &inboundSettings) + if encryption, ok := inboundSettings["encryption"].(string); ok && encryption != "" { + proxy["packet-encoding"] = encryption + } + case model.Trojan: + proxy["type"] = "trojan" + proxy["password"] = client.Password + case model.Shadowsocks: + proxy["type"] = "ss" + proxy["password"] = client.Password + var inboundSettings map[string]any + json.Unmarshal([]byte(inbound.Settings), &inboundSettings) + method, _ := inboundSettings["method"].(string) + if method == "" { + return nil + } + proxy["cipher"] = method + if strings.HasPrefix(method, "2022") { + if serverPassword, ok := inboundSettings["password"].(string); ok && serverPassword != "" { + proxy["password"] = fmt.Sprintf("%s:%s", serverPassword, client.Password) + } + } + default: + return nil + } + + security, _ := stream["security"].(string) + if !s.applySecurity(proxy, security, stream) { + return nil + } + + return proxy +} + +func (s *SubClashService) applyTransport(proxy map[string]any, network string, stream map[string]any) bool { + switch network { + case "", "tcp": + proxy["network"] = "tcp" + tcp, _ := stream["tcpSettings"].(map[string]any) + if tcp != nil { + header, _ := tcp["header"].(map[string]any) + if header != nil { + typeStr, _ := header["type"].(string) + if typeStr != "" && typeStr != "none" { + return false + } + } + } + return true + case "ws": + proxy["network"] = "ws" + ws, _ := stream["wsSettings"].(map[string]any) + wsOpts := map[string]any{} + if ws != nil { + if path, ok := ws["path"].(string); ok && path != "" { + wsOpts["path"] = path + } + host := "" + if v, ok := ws["host"].(string); ok && v != "" { + host = v + } else if headers, ok := ws["headers"].(map[string]any); ok { + host = searchHost(headers) + } + if host != "" { + wsOpts["headers"] = map[string]any{"Host": host} + } + } + if len(wsOpts) > 0 { + proxy["ws-opts"] = wsOpts + } + return true + case "grpc": + proxy["network"] = "grpc" + grpc, _ := stream["grpcSettings"].(map[string]any) + grpcOpts := map[string]any{} + if grpc != nil { + if serviceName, ok := grpc["serviceName"].(string); ok && serviceName != "" { + grpcOpts["grpc-service-name"] = serviceName + } + } + if len(grpcOpts) > 0 { + proxy["grpc-opts"] = grpcOpts + } + return true + default: + return false + } +} + +func (s *SubClashService) applySecurity(proxy map[string]any, security string, stream map[string]any) bool { + switch security { + case "", "none": + proxy["tls"] = false + return true + case "tls": + proxy["tls"] = true + tlsSettings, _ := stream["tlsSettings"].(map[string]any) + if tlsSettings != nil { + if serverName, ok := tlsSettings["serverName"].(string); ok && serverName != "" { + proxy["servername"] = serverName + switch proxy["type"] { + case "trojan": + proxy["sni"] = serverName + } + } + if fingerprint, ok := tlsSettings["fingerprint"].(string); ok && fingerprint != "" { + proxy["client-fingerprint"] = fingerprint + } + } + return true + case "reality": + proxy["tls"] = true + realitySettings, _ := stream["realitySettings"].(map[string]any) + if realitySettings == nil { + return false + } + if serverName, ok := realitySettings["serverName"].(string); ok && serverName != "" { + proxy["servername"] = serverName + } + realityOpts := map[string]any{} + if publicKey, ok := realitySettings["publicKey"].(string); ok && publicKey != "" { + realityOpts["public-key"] = publicKey + } + if shortID, ok := realitySettings["shortId"].(string); ok && shortID != "" { + realityOpts["short-id"] = shortID + } + if len(realityOpts) > 0 { + proxy["reality-opts"] = realityOpts + } + if fingerprint, ok := realitySettings["fingerprint"].(string); ok && fingerprint != "" { + proxy["client-fingerprint"] = fingerprint + } + return true + default: + return false + } +} + +func (s *SubClashService) streamData(stream string) map[string]any { + var streamSettings map[string]any + json.Unmarshal([]byte(stream), &streamSettings) + security, _ := streamSettings["security"].(string) + switch security { + case "tls": + if tlsSettings, ok := streamSettings["tlsSettings"].(map[string]any); ok { + streamSettings["tlsSettings"] = s.tlsData(tlsSettings) + } + case "reality": + if realitySettings, ok := streamSettings["realitySettings"].(map[string]any); ok { + streamSettings["realitySettings"] = s.realityData(realitySettings) + } + } + delete(streamSettings, "sockopt") + return streamSettings +} + +func (s *SubClashService) tlsData(tData map[string]any) map[string]any { + tlsData := make(map[string]any, 1) + tlsClientSettings, _ := tData["settings"].(map[string]any) + tlsData["serverName"] = tData["serverName"] + tlsData["alpn"] = tData["alpn"] + if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok { + tlsData["fingerprint"] = fingerprint + } + return tlsData +} + +func (s *SubClashService) realityData(rData map[string]any) map[string]any { + rDataOut := make(map[string]any, 1) + realityClientSettings, _ := rData["settings"].(map[string]any) + if publicKey, ok := realityClientSettings["publicKey"].(string); ok { + rDataOut["publicKey"] = publicKey + } + if fingerprint, ok := realityClientSettings["fingerprint"].(string); ok { + rDataOut["fingerprint"] = fingerprint + } + if serverNames, ok := rData["serverNames"].([]any); ok && len(serverNames) > 0 { + rDataOut["serverName"] = fmt.Sprint(serverNames[0]) + } + if shortIDs, ok := rData["shortIds"].([]any); ok && len(shortIDs) > 0 { + rDataOut["shortId"] = fmt.Sprint(shortIDs[0]) + } + return rDataOut +} + +func cloneMap(src map[string]any) map[string]any { + if src == nil { + return nil + } + dst := make(map[string]any, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} From 9127fda70b6958962f8ed3b2ff65a78321427fea Mon Sep 17 00:00:00 2001 From: haimu0427 Date: Thu, 12 Mar 2026 15:14:57 +0800 Subject: [PATCH 3/6] feat(sub): integrate Clash YAML endpoint into subscription system - Add Clash route handler in SUBController - Update BuildURLs to include Clash URL - Pass Clash settings through subscription pipeline Co-Authored-By: Claude Sonnet 4.6 --- sub/sub.go | 13 +++++++++++-- sub/subController.go | 45 +++++++++++++++++++++++++++++++++++++------- sub/subService.go | 20 +++++++++----------- 3 files changed, 58 insertions(+), 20 deletions(-) diff --git a/sub/sub.go b/sub/sub.go index 1dcd9601..b940cc95 100644 --- a/sub/sub.go +++ b/sub/sub.go @@ -91,12 +91,21 @@ func (s *Server) initRouter() (*gin.Engine, error) { return nil, err } - // Determine if JSON subscription endpoint is enabled + ClashPath, err := s.settingService.GetSubClashPath() + if err != nil { + return nil, err + } + subJsonEnable, err := s.settingService.GetSubJsonEnable() if err != nil { return nil, err } + subClashEnable, err := s.settingService.GetSubClashEnable() + if err != nil { + return nil, err + } + // Set base_path based on LinksPath for template rendering // Ensure LinksPath ends with "/" for proper asset URL generation basePath := LinksPath @@ -255,7 +264,7 @@ func (s *Server) initRouter() (*gin.Engine, error) { g := engine.Group("/") s.sub = NewSUBController( - g, LinksPath, JsonPath, subJsonEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates, + g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates, SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl, SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules) diff --git a/sub/subController.go b/sub/subController.go index 79ea755d..0e9e2c97 100644 --- a/sub/subController.go +++ b/sub/subController.go @@ -21,12 +21,15 @@ type SUBController struct { subRoutingRules string subPath string subJsonPath string + subClashPath string jsonEnabled bool + clashEnabled bool subEncrypt bool updateInterval string - subService *SubService - subJsonService *SubJsonService + subService *SubService + subJsonService *SubJsonService + subClashService *SubClashService } // NewSUBController creates a new subscription controller with the given configuration. @@ -34,7 +37,9 @@ func NewSUBController( g *gin.RouterGroup, subPath string, jsonPath string, + clashPath string, jsonEnabled bool, + clashEnabled bool, encrypt bool, showInfo bool, rModel string, @@ -60,12 +65,15 @@ func NewSUBController( subRoutingRules: subRoutingRules, subPath: subPath, subJsonPath: jsonPath, + subClashPath: clashPath, jsonEnabled: jsonEnabled, + clashEnabled: clashEnabled, subEncrypt: encrypt, updateInterval: update, - subService: sub, - subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub), + subService: sub, + subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub), + subClashService: NewSubClashService(sub), } a.initRouter(g) return a @@ -80,6 +88,10 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) { gJson := g.Group(a.subJsonPath) gJson.GET(":subid", a.subJsons) } + if a.clashEnabled { + gClash := g.Group(a.subClashPath) + gClash.GET(":subid", a.subClashs) + } } // subs handles HTTP requests for subscription links, returning either HTML page or base64-encoded subscription data. @@ -99,10 +111,13 @@ func (a *SUBController) subs(c *gin.Context) { accept := c.GetHeader("Accept") if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") { // Build page data in service - subURL, subJsonURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, subId) + subURL, subJsonURL, subClashURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, a.subClashPath, subId) if !a.jsonEnabled { subJsonURL = "" } + if !a.clashEnabled { + subClashURL = "" + } // Get base_path from context (set by middleware) basePath, exists := c.Get("base_path") if !exists { @@ -116,7 +131,7 @@ func (a *SUBController) subs(c *gin.Context) { // Remove trailing slash if exists, add subId, then add trailing slash basePathStr = strings.TrimRight(basePathStr, "/") + "/" + subId + "/" } - page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, basePathStr) + page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, subClashURL, basePathStr) c.HTML(200, "subpage.html", gin.H{ "title": "subscription.title", "cur_ver": config.GetVersion(), @@ -136,6 +151,7 @@ func (a *SUBController) subs(c *gin.Context) { "totalByte": page.TotalByte, "subUrl": page.SubUrl, "subJsonUrl": page.SubJsonUrl, + "subClashUrl": page.SubClashUrl, "result": page.Result, }) return @@ -165,7 +181,6 @@ func (a *SUBController) subJsons(c *gin.Context) { if err != nil || len(jsonSub) == 0 { c.String(400, "Error!") } else { - // Add headers profileUrl := a.subProfileUrl if profileUrl == "" { profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI) @@ -176,6 +191,22 @@ func (a *SUBController) subJsons(c *gin.Context) { } } +func (a *SUBController) subClashs(c *gin.Context) { + subId := c.Param("subid") + scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c) + clashSub, header, err := a.subClashService.GetClash(subId, host) + if err != nil || len(clashSub) == 0 { + c.String(400, "Error!") + } else { + profileUrl := a.subProfileUrl + if profileUrl == "" { + profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI) + } + a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules) + c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub)) + } +} + // ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title. func (a *SUBController) ApplyCommonHeaders( c *gin.Context, diff --git a/sub/subService.go b/sub/subService.go index 818f193b..19c644c7 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -1031,6 +1031,7 @@ type PageData struct { TotalByte int64 SubUrl string SubJsonUrl string + SubClashUrl string Result []string } @@ -1080,29 +1081,25 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string, // BuildURLs constructs absolute subscription and JSON subscription URLs for a given subscription ID. // It prioritizes configured URIs, then individual settings, and finally falls back to request-derived components. -func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subId string) (subURL, subJsonURL string) { - // Input validation +func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subClashPath, subId string) (subURL, subJsonURL, subClashURL string) { if subId == "" { - return "", "" + return "", "", "" } - // Get configured URIs first (highest priority) configuredSubURI, _ := s.settingService.GetSubURI() configuredSubJsonURI, _ := s.settingService.GetSubJsonURI() + configuredSubClashURI, _ := s.settingService.GetSubClashURI() - // Determine base scheme and host (cached to avoid duplicate calls) var baseScheme, baseHostWithPort string - if configuredSubURI == "" || configuredSubJsonURI == "" { + if configuredSubURI == "" || configuredSubJsonURI == "" || configuredSubClashURI == "" { baseScheme, baseHostWithPort = s.getBaseSchemeAndHost(scheme, hostWithPort) } - // Build subscription URL subURL = s.buildSingleURL(configuredSubURI, baseScheme, baseHostWithPort, subPath, subId) - - // Build JSON subscription URL subJsonURL = s.buildSingleURL(configuredSubJsonURI, baseScheme, baseHostWithPort, subJsonPath, subId) + subClashURL = s.buildSingleURL(configuredSubClashURI, baseScheme, baseHostWithPort, subClashPath, subId) - return subURL, subJsonURL + return subURL, subJsonURL, subClashURL } // getBaseSchemeAndHost determines the base scheme and host from settings or falls back to request values @@ -1149,7 +1146,7 @@ func (s *SubService) joinPathWithID(basePath, subId string) string { // BuildPageData parses header and prepares the template view model. // BuildPageData constructs page data for rendering the subscription information page. -func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL string, basePath string) PageData { +func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL, subClashURL string, basePath string) PageData { download := common.FormatTraffic(traffic.Down) upload := common.FormatTraffic(traffic.Up) total := "∞" @@ -1183,6 +1180,7 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray TotalByte: traffic.Total, SubUrl: subURL, SubJsonUrl: subJsonURL, + SubClashUrl: subClashURL, Result: subs, } } From 6f5caefb00034f7a674113fb978cff277b5b5261 Mon Sep 17 00:00:00 2001 From: haimu0427 Date: Thu, 12 Mar 2026 15:15:01 +0800 Subject: [PATCH 4/6] feat(web): add Clash settings to entity and service - Add SubClashEnable, SubClashPath, SubClashURI fields - Add getter methods for Clash configuration - Set default Clash path to /clash/ and enable by default Co-Authored-By: Claude Sonnet 4.6 --- web/entity/entity.go | 10 ++++++++++ web/service/setting.go | 39 +++++++++++++++++++++++++++++++++------ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/web/entity/entity.go b/web/entity/entity.go index 40294925..14353cf0 100644 --- a/web/entity/entity.go +++ b/web/entity/entity.go @@ -76,6 +76,9 @@ type AllSetting struct { SubURI string `json:"subURI" form:"subURI"` // Subscription server URI SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` // Path for JSON subscription endpoint SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` // JSON subscription server URI + SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash/Mihomo subscription endpoint + SubClashPath string `json:"subClashPath" form:"subClashPath"` // Path for Clash/Mihomo subscription endpoint + SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash/Mihomo subscription server URI SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"` // JSON subscription fragment configuration SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration @@ -168,6 +171,13 @@ func (s *AllSetting) CheckValid() error { s.SubJsonPath += "/" } + if !strings.HasPrefix(s.SubClashPath, "/") { + s.SubClashPath = "/" + s.SubClashPath + } + if !strings.HasSuffix(s.SubClashPath, "/") { + s.SubClashPath += "/" + } + _, err := time.LoadLocation(s.TimeLocation) if err != nil { return common.NewError("time location not exist:", s.TimeLocation) diff --git a/web/service/setting.go b/web/service/setting.go index 5c93e9fd..7027d466 100644 --- a/web/service/setting.go +++ b/web/service/setting.go @@ -71,6 +71,9 @@ var defaultValueMap = map[string]string{ "subURI": "", "subJsonPath": "/json/", "subJsonURI": "", + "subClashEnable": "true", + "subClashPath": "/clash/", + "subClashURI": "", "subJsonFragment": "", "subJsonNoises": "", "subJsonMux": "", @@ -555,6 +558,18 @@ func (s *SettingService) GetSubJsonURI() (string, error) { return s.getString("subJsonURI") } +func (s *SettingService) GetSubClashEnable() (bool, error) { + return s.getBool("subClashEnable") +} + +func (s *SettingService) GetSubClashPath() (string, error) { + return s.getString("subClashPath") +} + +func (s *SettingService) GetSubClashURI() (string, error) { + return s.getString("subClashURI") +} + func (s *SettingService) GetSubJsonFragment() (string, error) { return s.getString("subJsonFragment") } @@ -750,11 +765,13 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) { "defaultKey": func() (any, error) { return s.GetKeyFile() }, "tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() }, "subEnable": func() (any, error) { return s.GetSubEnable() }, - "subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() }, - "subTitle": func() (any, error) { return s.GetSubTitle() }, - "subURI": func() (any, error) { return s.GetSubURI() }, - "subJsonURI": func() (any, error) { return s.GetSubJsonURI() }, - "remarkModel": func() (any, error) { return s.GetRemarkModel() }, + "subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() }, + "subClashEnable": func() (any, error) { return s.GetSubClashEnable() }, + "subTitle": func() (any, error) { return s.GetSubTitle() }, + "subURI": func() (any, error) { return s.GetSubURI() }, + "subJsonURI": func() (any, error) { return s.GetSubJsonURI() }, + "subClashURI": func() (any, error) { return s.GetSubClashURI() }, + "remarkModel": func() (any, error) { return s.GetRemarkModel() }, "datepicker": func() (any, error) { return s.GetDatepicker() }, "ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() }, } @@ -776,12 +793,19 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) { subJsonEnable = b } } - if (subEnable && result["subURI"].(string) == "") || (subJsonEnable && result["subJsonURI"].(string) == "") { + subClashEnable := false + if v, ok := result["subClashEnable"]; ok { + if b, ok2 := v.(bool); ok2 { + subClashEnable = b + } + } + if (subEnable && result["subURI"].(string) == "") || (subJsonEnable && result["subJsonURI"].(string) == "") || (subClashEnable && result["subClashURI"].(string) == "") { subURI := "" subTitle, _ := s.GetSubTitle() subPort, _ := s.GetSubPort() subPath, _ := s.GetSubPath() subJsonPath, _ := s.GetSubJsonPath() + subClashPath, _ := s.GetSubClashPath() subDomain, _ := s.GetSubDomain() subKeyFile, _ := s.GetSubKeyFile() subCertFile, _ := s.GetSubCertFile() @@ -811,6 +835,9 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) { if subJsonEnable && result["subJsonURI"].(string) == "" { result["subJsonURI"] = subURI + subJsonPath } + if subClashEnable && result["subClashURI"].(string) == "" { + result["subClashURI"] = subURI + subClashPath + } } return result, nil From 9d130286539ea6eb4b572b216fe81393a7b3afb4 Mon Sep 17 00:00:00 2001 From: haimu0427 Date: Thu, 12 Mar 2026 15:15:04 +0800 Subject: [PATCH 5/6] feat(ui): add Clash settings to subscription panels - Add Clash enable switch in general subscription settings - Add Clash path/URI configuration in formats panel - Display Clash QR code on subscription page - Rename JSON tab to "Formats" for clarity Co-Authored-By: Claude Sonnet 4.6 --- web/html/settings.html | 4 +-- .../settings/panel/subscription/general.html | 7 +++++ .../settings/panel/subscription/json.html | 26 ++++++++++++++++--- .../settings/panel/subscription/subpage.html | 17 ++++++++++-- 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/web/html/settings.html b/web/html/settings.html index 21294da7..a34ff6d0 100644 --- a/web/html/settings.html +++ b/web/html/settings.html @@ -82,10 +82,10 @@ {{ template "settings/panel/subscription/general" . }} - + {{ template "settings/panel/subscription/json" . }} diff --git a/web/html/settings/panel/subscription/general.html b/web/html/settings/panel/subscription/general.html index 5d83aa37..4f10a716 100644 --- a/web/html/settings/panel/subscription/general.html +++ b/web/html/settings/panel/subscription/general.html @@ -15,6 +15,13 @@ + + + + + diff --git a/web/html/settings/panel/subscription/json.html b/web/html/settings/panel/subscription/json.html index e8642305..9b83571a 100644 --- a/web/html/settings/panel/subscription/json.html +++ b/web/html/settings/panel/subscription/json.html @@ -1,8 +1,8 @@ {{define "settings/panel/subscription/json"}} - - + + - - + + + + + + + + + + + + diff --git a/web/html/settings/panel/subscription/subpage.html b/web/html/settings/panel/subscription/subpage.html index 794c67c3..64c1224d 100644 --- a/web/html/settings/panel/subscription/subpage.html +++ b/web/html/settings/panel/subscription/subpage.html @@ -83,7 +83,7 @@ - + {{ i18n @@ -112,6 +112,19 @@ + + + + Clash / Mihomo + + + + + + + + @@ -242,7 +255,7 @@ -