mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-04-14 19:45:47 +00:00
Merge 18557116e9 into 169b216d7e
This commit is contained in:
commit
75866a69cb
14 changed files with 726 additions and 34 deletions
74
AGENTS.md
Normal file
74
AGENTS.md
Normal file
|
|
@ -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
|
||||||
110
CLAUDE.md
Normal file
110
CLAUDE.md
Normal file
|
|
@ -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`.
|
||||||
13
sub/sub.go
13
sub/sub.go
|
|
@ -91,12 +91,21 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
return nil, err
|
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()
|
subJsonEnable, err := s.settingService.GetSubJsonEnable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
subClashEnable, err := s.settingService.GetSubClashEnable()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Set base_path based on LinksPath for template rendering
|
// Set base_path based on LinksPath for template rendering
|
||||||
// Ensure LinksPath ends with "/" for proper asset URL generation
|
// Ensure LinksPath ends with "/" for proper asset URL generation
|
||||||
basePath := LinksPath
|
basePath := LinksPath
|
||||||
|
|
@ -255,7 +264,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
g := engine.Group("/")
|
g := engine.Group("/")
|
||||||
|
|
||||||
s.sub = NewSUBController(
|
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,
|
SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl,
|
||||||
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
|
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
|
||||||
|
|
||||||
|
|
|
||||||
385
sub/subClashService.go
Normal file
385
sub/subClashService.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -21,12 +21,15 @@ type SUBController struct {
|
||||||
subRoutingRules string
|
subRoutingRules string
|
||||||
subPath string
|
subPath string
|
||||||
subJsonPath string
|
subJsonPath string
|
||||||
|
subClashPath string
|
||||||
jsonEnabled bool
|
jsonEnabled bool
|
||||||
|
clashEnabled bool
|
||||||
subEncrypt bool
|
subEncrypt bool
|
||||||
updateInterval string
|
updateInterval string
|
||||||
|
|
||||||
subService *SubService
|
subService *SubService
|
||||||
subJsonService *SubJsonService
|
subJsonService *SubJsonService
|
||||||
|
subClashService *SubClashService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSUBController creates a new subscription controller with the given configuration.
|
// NewSUBController creates a new subscription controller with the given configuration.
|
||||||
|
|
@ -34,7 +37,9 @@ func NewSUBController(
|
||||||
g *gin.RouterGroup,
|
g *gin.RouterGroup,
|
||||||
subPath string,
|
subPath string,
|
||||||
jsonPath string,
|
jsonPath string,
|
||||||
|
clashPath string,
|
||||||
jsonEnabled bool,
|
jsonEnabled bool,
|
||||||
|
clashEnabled bool,
|
||||||
encrypt bool,
|
encrypt bool,
|
||||||
showInfo bool,
|
showInfo bool,
|
||||||
rModel string,
|
rModel string,
|
||||||
|
|
@ -60,12 +65,15 @@ func NewSUBController(
|
||||||
subRoutingRules: subRoutingRules,
|
subRoutingRules: subRoutingRules,
|
||||||
subPath: subPath,
|
subPath: subPath,
|
||||||
subJsonPath: jsonPath,
|
subJsonPath: jsonPath,
|
||||||
|
subClashPath: clashPath,
|
||||||
jsonEnabled: jsonEnabled,
|
jsonEnabled: jsonEnabled,
|
||||||
|
clashEnabled: clashEnabled,
|
||||||
subEncrypt: encrypt,
|
subEncrypt: encrypt,
|
||||||
updateInterval: update,
|
updateInterval: update,
|
||||||
|
|
||||||
subService: sub,
|
subService: sub,
|
||||||
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
|
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
|
||||||
|
subClashService: NewSubClashService(sub),
|
||||||
}
|
}
|
||||||
a.initRouter(g)
|
a.initRouter(g)
|
||||||
return a
|
return a
|
||||||
|
|
@ -80,6 +88,10 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
|
||||||
gJson := g.Group(a.subJsonPath)
|
gJson := g.Group(a.subJsonPath)
|
||||||
gJson.GET(":subid", a.subJsons)
|
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.
|
// 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")
|
accept := c.GetHeader("Accept")
|
||||||
if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
|
if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
|
||||||
// Build page data in service
|
// 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 {
|
if !a.jsonEnabled {
|
||||||
subJsonURL = ""
|
subJsonURL = ""
|
||||||
}
|
}
|
||||||
|
if !a.clashEnabled {
|
||||||
|
subClashURL = ""
|
||||||
|
}
|
||||||
// Get base_path from context (set by middleware)
|
// Get base_path from context (set by middleware)
|
||||||
basePath, exists := c.Get("base_path")
|
basePath, exists := c.Get("base_path")
|
||||||
if !exists {
|
if !exists {
|
||||||
|
|
@ -116,7 +131,7 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||||
// Remove trailing slash if exists, add subId, then add trailing slash
|
// Remove trailing slash if exists, add subId, then add trailing slash
|
||||||
basePathStr = strings.TrimRight(basePathStr, "/") + "/" + subId + "/"
|
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{
|
c.HTML(200, "subpage.html", gin.H{
|
||||||
"title": "subscription.title",
|
"title": "subscription.title",
|
||||||
"cur_ver": config.GetVersion(),
|
"cur_ver": config.GetVersion(),
|
||||||
|
|
@ -136,6 +151,7 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||||
"totalByte": page.TotalByte,
|
"totalByte": page.TotalByte,
|
||||||
"subUrl": page.SubUrl,
|
"subUrl": page.SubUrl,
|
||||||
"subJsonUrl": page.SubJsonUrl,
|
"subJsonUrl": page.SubJsonUrl,
|
||||||
|
"subClashUrl": page.SubClashUrl,
|
||||||
"result": page.Result,
|
"result": page.Result,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -165,7 +181,6 @@ func (a *SUBController) subJsons(c *gin.Context) {
|
||||||
if err != nil || len(jsonSub) == 0 {
|
if err != nil || len(jsonSub) == 0 {
|
||||||
c.String(400, "Error!")
|
c.String(400, "Error!")
|
||||||
} else {
|
} else {
|
||||||
// Add headers
|
|
||||||
profileUrl := a.subProfileUrl
|
profileUrl := a.subProfileUrl
|
||||||
if profileUrl == "" {
|
if profileUrl == "" {
|
||||||
profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
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.
|
// ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
|
||||||
func (a *SUBController) ApplyCommonHeaders(
|
func (a *SUBController) ApplyCommonHeaders(
|
||||||
c *gin.Context,
|
c *gin.Context,
|
||||||
|
|
|
||||||
|
|
@ -1031,6 +1031,7 @@ type PageData struct {
|
||||||
TotalByte int64
|
TotalByte int64
|
||||||
SubUrl string
|
SubUrl string
|
||||||
SubJsonUrl string
|
SubJsonUrl string
|
||||||
|
SubClashUrl string
|
||||||
Result []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.
|
// 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.
|
// 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) {
|
func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subClashPath, subId string) (subURL, subJsonURL, subClashURL string) {
|
||||||
// Input validation
|
|
||||||
if subId == "" {
|
if subId == "" {
|
||||||
return "", ""
|
return "", "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get configured URIs first (highest priority)
|
|
||||||
configuredSubURI, _ := s.settingService.GetSubURI()
|
configuredSubURI, _ := s.settingService.GetSubURI()
|
||||||
configuredSubJsonURI, _ := s.settingService.GetSubJsonURI()
|
configuredSubJsonURI, _ := s.settingService.GetSubJsonURI()
|
||||||
|
configuredSubClashURI, _ := s.settingService.GetSubClashURI()
|
||||||
|
|
||||||
// Determine base scheme and host (cached to avoid duplicate calls)
|
|
||||||
var baseScheme, baseHostWithPort string
|
var baseScheme, baseHostWithPort string
|
||||||
if configuredSubURI == "" || configuredSubJsonURI == "" {
|
if configuredSubURI == "" || configuredSubJsonURI == "" || configuredSubClashURI == "" {
|
||||||
baseScheme, baseHostWithPort = s.getBaseSchemeAndHost(scheme, hostWithPort)
|
baseScheme, baseHostWithPort = s.getBaseSchemeAndHost(scheme, hostWithPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build subscription URL
|
|
||||||
subURL = s.buildSingleURL(configuredSubURI, baseScheme, baseHostWithPort, subPath, subId)
|
subURL = s.buildSingleURL(configuredSubURI, baseScheme, baseHostWithPort, subPath, subId)
|
||||||
|
|
||||||
// Build JSON subscription URL
|
|
||||||
subJsonURL = s.buildSingleURL(configuredSubJsonURI, baseScheme, baseHostWithPort, subJsonPath, subId)
|
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
|
// 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 parses header and prepares the template view model.
|
||||||
// BuildPageData constructs page data for rendering the subscription information page.
|
// 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)
|
download := common.FormatTraffic(traffic.Down)
|
||||||
upload := common.FormatTraffic(traffic.Up)
|
upload := common.FormatTraffic(traffic.Up)
|
||||||
total := "∞"
|
total := "∞"
|
||||||
|
|
@ -1183,6 +1180,7 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
|
||||||
TotalByte: traffic.Total,
|
TotalByte: traffic.Total,
|
||||||
SubUrl: subURL,
|
SubUrl: subURL,
|
||||||
SubJsonUrl: subJsonURL,
|
SubJsonUrl: subJsonURL,
|
||||||
|
SubClashUrl: subClashURL,
|
||||||
Result: subs,
|
Result: subs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ class AllSetting {
|
||||||
this.subPort = 2096;
|
this.subPort = 2096;
|
||||||
this.subPath = "/sub/";
|
this.subPath = "/sub/";
|
||||||
this.subJsonPath = "/json/";
|
this.subJsonPath = "/json/";
|
||||||
|
this.subClashEnable = true;
|
||||||
|
this.subClashPath = "/clash/";
|
||||||
this.subDomain = "";
|
this.subDomain = "";
|
||||||
this.externalTrafficInformEnable = false;
|
this.externalTrafficInformEnable = false;
|
||||||
this.externalTrafficInformURI = "";
|
this.externalTrafficInformURI = "";
|
||||||
|
|
@ -48,6 +50,7 @@ class AllSetting {
|
||||||
this.subShowInfo = true;
|
this.subShowInfo = true;
|
||||||
this.subURI = "";
|
this.subURI = "";
|
||||||
this.subJsonURI = "";
|
this.subJsonURI = "";
|
||||||
|
this.subClashURI = "";
|
||||||
this.subJsonFragment = "";
|
this.subJsonFragment = "";
|
||||||
this.subJsonNoises = "";
|
this.subJsonNoises = "";
|
||||||
this.subJsonMux = "";
|
this.subJsonMux = "";
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
sId: el.getAttribute('data-sid') || '',
|
sId: el.getAttribute('data-sid') || '',
|
||||||
subUrl: el.getAttribute('data-sub-url') || '',
|
subUrl: el.getAttribute('data-sub-url') || '',
|
||||||
subJsonUrl: el.getAttribute('data-subjson-url') || '',
|
subJsonUrl: el.getAttribute('data-subjson-url') || '',
|
||||||
|
subClashUrl: el.getAttribute('data-subclash-url') || '',
|
||||||
download: el.getAttribute('data-download') || '',
|
download: el.getAttribute('data-download') || '',
|
||||||
upload: el.getAttribute('data-upload') || '',
|
upload: el.getAttribute('data-upload') || '',
|
||||||
used: el.getAttribute('data-used') || '',
|
used: el.getAttribute('data-used') || '',
|
||||||
|
|
@ -98,13 +99,19 @@
|
||||||
this.lang = LanguageManager.getLanguage();
|
this.lang = LanguageManager.getLanguage();
|
||||||
const tpl = document.getElementById('subscription-data');
|
const tpl = document.getElementById('subscription-data');
|
||||||
const sj = tpl ? tpl.getAttribute('data-subjson-url') : '';
|
const sj = tpl ? tpl.getAttribute('data-subjson-url') : '';
|
||||||
|
const sc = tpl ? tpl.getAttribute('data-subclash-url') : '';
|
||||||
if (sj) this.app.subJsonUrl = sj;
|
if (sj) this.app.subJsonUrl = sj;
|
||||||
|
if (sc) this.app.subClashUrl = sc;
|
||||||
drawQR(this.app.subUrl);
|
drawQR(this.app.subUrl);
|
||||||
try {
|
try {
|
||||||
const elJson = document.getElementById('qrcode-subjson');
|
const elJson = document.getElementById('qrcode-subjson');
|
||||||
if (elJson && this.app.subJsonUrl) {
|
if (elJson && this.app.subJsonUrl) {
|
||||||
new QRious({ element: elJson, value: this.app.subJsonUrl, size: 220 });
|
new QRious({ element: elJson, value: this.app.subJsonUrl, size: 220 });
|
||||||
}
|
}
|
||||||
|
const elClash = document.getElementById('qrcode-subclash');
|
||||||
|
if (elClash && this.app.subClashUrl) {
|
||||||
|
new QRious({ element: elClash, value: this.app.subClashUrl, size: 220 });
|
||||||
|
}
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
this._onResize = () => { this.viewportWidth = window.innerWidth; };
|
this._onResize = () => { this.viewportWidth = window.innerWidth; };
|
||||||
window.addEventListener('resize', this._onResize);
|
window.addEventListener('resize', this._onResize);
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,9 @@ type AllSetting struct {
|
||||||
SubURI string `json:"subURI" form:"subURI"` // Subscription server URI
|
SubURI string `json:"subURI" form:"subURI"` // Subscription server URI
|
||||||
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` // Path for JSON subscription endpoint
|
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` // Path for JSON subscription endpoint
|
||||||
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` // JSON subscription server URI
|
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
|
SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"` // JSON subscription fragment configuration
|
||||||
SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration
|
SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration
|
||||||
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
|
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
|
||||||
|
|
@ -168,6 +171,13 @@ func (s *AllSetting) CheckValid() error {
|
||||||
s.SubJsonPath += "/"
|
s.SubJsonPath += "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(s.SubClashPath, "/") {
|
||||||
|
s.SubClashPath = "/" + s.SubClashPath
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(s.SubClashPath, "/") {
|
||||||
|
s.SubClashPath += "/"
|
||||||
|
}
|
||||||
|
|
||||||
_, err := time.LoadLocation(s.TimeLocation)
|
_, err := time.LoadLocation(s.TimeLocation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return common.NewError("time location not exist:", s.TimeLocation)
|
return common.NewError("time location not exist:", s.TimeLocation)
|
||||||
|
|
|
||||||
|
|
@ -82,10 +82,10 @@
|
||||||
</template>
|
</template>
|
||||||
{{ template "settings/panel/subscription/general" . }}
|
{{ template "settings/panel/subscription/general" . }}
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
<a-tab-pane key="5" v-if="allSetting.subJsonEnable" :style="{ paddingTop: '20px' }">
|
<a-tab-pane key="5" v-if="allSetting.subJsonEnable || allSetting.subClashEnable" :style="{ paddingTop: '20px' }">
|
||||||
<template #tab>
|
<template #tab>
|
||||||
<a-icon type="code"></a-icon>
|
<a-icon type="code"></a-icon>
|
||||||
<span>{{ i18n "pages.settings.subSettings" }} (JSON)</span>
|
<span>{{ i18n "pages.settings.subSettings" }} (Formats)</span>
|
||||||
</template>
|
</template>
|
||||||
{{ template "settings/panel/subscription/json" . }}
|
{{ template "settings/panel/subscription/json" . }}
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,13 @@
|
||||||
<a-switch v-model="allSetting.subJsonEnable"></a-switch>
|
<a-switch v-model="allSetting.subJsonEnable"></a-switch>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
|
<a-setting-list-item paddings="small">
|
||||||
|
<template #title>Clash / Mihomo Subscription</template>
|
||||||
|
<template #description>Enable direct Clash Party and Mihomo YAML subscriptions.</template>
|
||||||
|
<template #control>
|
||||||
|
<a-switch v-model="allSetting.subClashEnable"></a-switch>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
<template #title>{{ i18n "pages.settings.subListen"}}</template>
|
<template #title>{{ i18n "pages.settings.subListen"}}</template>
|
||||||
<template #description>{{ i18n "pages.settings.subListenDesc"}}</template>
|
<template #description>{{ i18n "pages.settings.subListenDesc"}}</template>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{{define "settings/panel/subscription/json"}}
|
{{define "settings/panel/subscription/json"}}
|
||||||
<a-collapse default-active-key="1">
|
<a-collapse default-active-key="1">
|
||||||
<a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
|
<a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small" v-if="allSetting.subJsonEnable">
|
||||||
<template #title>{{ i18n "pages.settings.subPath"}}</template>
|
<template #title>{{ i18n "pages.settings.subPath"}} (JSON)</template>
|
||||||
<template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
|
<template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input type="text" v-model="allSetting.subJsonPath"
|
<a-input type="text" v-model="allSetting.subJsonPath"
|
||||||
|
|
@ -11,14 +11,32 @@
|
||||||
placeholder="/json/"></a-input>
|
placeholder="/json/"></a-input>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small" v-if="allSetting.subJsonEnable">
|
||||||
<template #title>{{ i18n "pages.settings.subURI"}}</template>
|
<template #title>{{ i18n "pages.settings.subURI"}} (JSON)</template>
|
||||||
<template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
|
<template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
|
||||||
<template #control>
|
<template #control>
|
||||||
<a-input type="text" placeholder="(http|https)://domain[:port]/path/"
|
<a-input type="text" placeholder="(http|https)://domain[:port]/path/"
|
||||||
v-model="allSetting.subJsonURI"></a-input>
|
v-model="allSetting.subJsonURI"></a-input>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
|
<a-setting-list-item paddings="small" v-if="allSetting.subClashEnable">
|
||||||
|
<template #title>{{ i18n "pages.settings.subPath"}} (Clash)</template>
|
||||||
|
<template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
|
||||||
|
<template #control>
|
||||||
|
<a-input type="text" v-model="allSetting.subClashPath"
|
||||||
|
@input="allSetting.subClashPath = ((typeof $event === 'string' ? $event : ($event && $event.target ? $event.target.value : '')) || '').replace(/[:*]/g, '')"
|
||||||
|
@blur="allSetting.subClashPath = (p => { p = p || '/'; if (!p.startsWith('/')) p='/' + p; if (!p.endsWith('/')) p += '/'; return p.replace(/\/+/g,'/'); })(allSetting.subClashPath)"
|
||||||
|
placeholder="/clash/"></a-input>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
|
<a-setting-list-item paddings="small" v-if="allSetting.subClashEnable">
|
||||||
|
<template #title>{{ i18n "pages.settings.subURI"}} (Clash)</template>
|
||||||
|
<template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
|
||||||
|
<template #control>
|
||||||
|
<a-input type="text" placeholder="(http|https)://domain[:port]/path/"
|
||||||
|
v-model="allSetting.subClashURI"></a-input>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
</a-collapse-panel>
|
</a-collapse-panel>
|
||||||
<a-collapse-panel key="2" header='{{ i18n "pages.settings.fragment"}}'>
|
<a-collapse-panel key="2" header='{{ i18n "pages.settings.fragment"}}'>
|
||||||
<a-setting-list-item paddings="small">
|
<a-setting-list-item paddings="small">
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@
|
||||||
<a-form-item>
|
<a-form-item>
|
||||||
<a-space direction="vertical" align="center">
|
<a-space direction="vertical" align="center">
|
||||||
<a-row type="flex" :gutter="[8,8]" justify="center" style="width:100%">
|
<a-row type="flex" :gutter="[8,8]" justify="center" style="width:100%">
|
||||||
<a-col :xs="24" :sm="app.subJsonUrl ? 12 : 24" style="text-align:center;">
|
<a-col :xs="24" :sm="app.subJsonUrl || app.subClashUrl ? 12 : 24" style="text-align:center;">
|
||||||
<tr-qr-box class="qr-box">
|
<tr-qr-box class="qr-box">
|
||||||
<a-tag color="purple" class="qr-tag">
|
<a-tag color="purple" class="qr-tag">
|
||||||
<span>{{ i18n
|
<span>{{ i18n
|
||||||
|
|
@ -112,6 +112,19 @@
|
||||||
</tr-qr-bg>
|
</tr-qr-bg>
|
||||||
</tr-qr-box>
|
</tr-qr-box>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
<a-col v-if="app.subClashUrl" :xs="24" :sm="12" style="text-align:center;">
|
||||||
|
<tr-qr-box class="qr-box">
|
||||||
|
<a-tag color="purple" class="qr-tag">
|
||||||
|
<span>Clash / Mihomo</span>
|
||||||
|
</a-tag>
|
||||||
|
<tr-qr-bg class="qr-bg-sub">
|
||||||
|
<tr-qr-bg-inner class="qr-bg-sub-inner">
|
||||||
|
<canvas id="qrcode-subclash" class="qr-cv" title='{{ i18n "copy" }}'
|
||||||
|
@click="copy(app.subClashUrl)"></canvas>
|
||||||
|
</tr-qr-bg-inner>
|
||||||
|
</tr-qr-bg>
|
||||||
|
</tr-qr-box>
|
||||||
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
</a-space>
|
</a-space>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
@ -242,7 +255,7 @@
|
||||||
</a-layout>
|
</a-layout>
|
||||||
|
|
||||||
<!-- Bootstrap data for external JS -->
|
<!-- Bootstrap data for external JS -->
|
||||||
<template id="subscription-data" data-sid="{{ .sId }}" data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}"
|
<template id="subscription-data" data-sid="{{ .sId }}" data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}" data-subclash-url="{{ .subClashUrl }}"
|
||||||
data-download="{{ .download }}" data-upload="{{ .upload }}" data-used="{{ .used }}" data-total="{{ .total }}"
|
data-download="{{ .download }}" data-upload="{{ .upload }}" data-used="{{ .used }}" data-total="{{ .total }}"
|
||||||
data-remained="{{ .remained }}" data-expire="{{ .expire }}" data-lastonline="{{ .lastOnline }}"
|
data-remained="{{ .remained }}" data-expire="{{ .expire }}" data-lastonline="{{ .lastOnline }}"
|
||||||
data-downloadbyte="{{ .downloadByte }}" data-uploadbyte="{{ .uploadByte }}" data-totalbyte="{{ .totalByte }}"
|
data-downloadbyte="{{ .downloadByte }}" data-uploadbyte="{{ .uploadByte }}" data-totalbyte="{{ .totalByte }}"
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,9 @@ var defaultValueMap = map[string]string{
|
||||||
"subURI": "",
|
"subURI": "",
|
||||||
"subJsonPath": "/json/",
|
"subJsonPath": "/json/",
|
||||||
"subJsonURI": "",
|
"subJsonURI": "",
|
||||||
|
"subClashEnable": "true",
|
||||||
|
"subClashPath": "/clash/",
|
||||||
|
"subClashURI": "",
|
||||||
"subJsonFragment": "",
|
"subJsonFragment": "",
|
||||||
"subJsonNoises": "",
|
"subJsonNoises": "",
|
||||||
"subJsonMux": "",
|
"subJsonMux": "",
|
||||||
|
|
@ -555,6 +558,18 @@ func (s *SettingService) GetSubJsonURI() (string, error) {
|
||||||
return s.getString("subJsonURI")
|
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) {
|
func (s *SettingService) GetSubJsonFragment() (string, error) {
|
||||||
return s.getString("subJsonFragment")
|
return s.getString("subJsonFragment")
|
||||||
}
|
}
|
||||||
|
|
@ -750,11 +765,13 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
||||||
"defaultKey": func() (any, error) { return s.GetKeyFile() },
|
"defaultKey": func() (any, error) { return s.GetKeyFile() },
|
||||||
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
|
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
|
||||||
"subEnable": func() (any, error) { return s.GetSubEnable() },
|
"subEnable": func() (any, error) { return s.GetSubEnable() },
|
||||||
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
|
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
|
||||||
"subTitle": func() (any, error) { return s.GetSubTitle() },
|
"subClashEnable": func() (any, error) { return s.GetSubClashEnable() },
|
||||||
"subURI": func() (any, error) { return s.GetSubURI() },
|
"subTitle": func() (any, error) { return s.GetSubTitle() },
|
||||||
"subJsonURI": func() (any, error) { return s.GetSubJsonURI() },
|
"subURI": func() (any, error) { return s.GetSubURI() },
|
||||||
"remarkModel": func() (any, error) { return s.GetRemarkModel() },
|
"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() },
|
"datepicker": func() (any, error) { return s.GetDatepicker() },
|
||||||
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
|
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
|
||||||
}
|
}
|
||||||
|
|
@ -776,12 +793,19 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
||||||
subJsonEnable = b
|
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 := ""
|
subURI := ""
|
||||||
subTitle, _ := s.GetSubTitle()
|
subTitle, _ := s.GetSubTitle()
|
||||||
subPort, _ := s.GetSubPort()
|
subPort, _ := s.GetSubPort()
|
||||||
subPath, _ := s.GetSubPath()
|
subPath, _ := s.GetSubPath()
|
||||||
subJsonPath, _ := s.GetSubJsonPath()
|
subJsonPath, _ := s.GetSubJsonPath()
|
||||||
|
subClashPath, _ := s.GetSubClashPath()
|
||||||
subDomain, _ := s.GetSubDomain()
|
subDomain, _ := s.GetSubDomain()
|
||||||
subKeyFile, _ := s.GetSubKeyFile()
|
subKeyFile, _ := s.GetSubKeyFile()
|
||||||
subCertFile, _ := s.GetSubCertFile()
|
subCertFile, _ := s.GetSubCertFile()
|
||||||
|
|
@ -811,6 +835,9 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
||||||
if subJsonEnable && result["subJsonURI"].(string) == "" {
|
if subJsonEnable && result["subJsonURI"].(string) == "" {
|
||||||
result["subJsonURI"] = subURI + subJsonPath
|
result["subJsonURI"] = subURI + subJsonPath
|
||||||
}
|
}
|
||||||
|
if subClashEnable && result["subClashURI"].(string) == "" {
|
||||||
|
result["subClashURI"] = subURI + subClashPath
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue