mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-26 13:07:14 +00:00
refactor: focused service files, leaf subpackages, and an internal/ layout (#5167)
* refactor(service): split client.go into focused files
client.go had grown to 4455 lines mixing ~10 responsibilities. Split it
verbatim into cohesive same-package files (no behavior change):
client.go foundation: ClientService, ClientWithAttachments,
ClientCreatePayload, ErrClientNotInInbound, sqlInChunk
client_locks.go inbound mutation locks, delete tombstones, compactOrphans
client_lookup.go read-only lookups (GetByID, List, EffectiveFlow, ...)
client_link.go inbound association sync (SyncInbound, DetachInbound, ...)
client_crud.go single-client CRUD + validation + protocol defaults
client_inbound_apply.go low-level inbound-settings mutators + by-email setters
client_bulk.go bulk attach/detach/adjust/delete/create + DelDepleted
client_traffic.go traffic-reset paths
client_groups.go client group management
client_paging.go paged listing, filtering, sorting, summary
Every declaration moved unchanged (verified: identical func/type/const/var
signature set before vs after). Imports redistributed per file via goimports.
go build ./..., go vet, and go test ./web/service/... all pass.
* refactor(service): split inbound.go into focused files
inbound.go was 4100 lines. Split it verbatim into cohesive same-package
files (no behavior change):
inbound.go core inbound CRUD + InboundService (keeps pkg doc)
inbound_protocol.go protocol / stream capability helpers
inbound_node.go node/runtime/remote coordination + online tracking
inbound_traffic.go traffic accounting, reset, client stats
inbound_client_ips.go per-client IP tracking
inbound_clients.go client lookups within inbounds + copy-clients
inbound_disable.go auto-disable invalid inbounds/clients
inbound_migration.go DB migrations
inbound_sublink.go subscription link providers
inbound_util.go generic slice/string helpers
Identical func/type/const/var signature set before vs after; package doc
comment preserved on inbound.go. Imports redistributed via goimports.
Build, vet, and go test ./web/service/... all pass.
* refactor(service): split tgbot.go into focused files
tgbot.go was 3738 lines dominated by a 1246-line answerCallback. Split it
verbatim into cohesive same-package files (no behavior change):
tgbot.go lifecycle, bot setup, caches, small utils
tgbot_router.go incoming update / command / callback dispatch
tgbot_send.go outbound messaging primitives
tgbot_client.go client views, actions, subscription links
tgbot_inbound.go inbound listing / pickers
tgbot_report.go server usage, exhausted, online, backups, notifications
Identical func/type/const/var signature set before vs after. Imports
redistributed via goimports. Build, vet, and go test ./web/service/... pass.
* refactor(client): dedupe single-field by-email setters
ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, and
ResetClientTrafficLimitByEmail shared an identical ~50-line body that
resolves the inbound by email, confirms the client exists, rewrites a
single-client settings payload, and delegates to UpdateInboundClient.
Extract that into applyClientFieldByEmail(inboundSvc, email, mutate) and
reduce each setter to a 3-line wrapper. Behavior is unchanged: same checks
and error strings, same single-client payload contract, same totalGB guard.
SetClientTelegramUserID (resolves by traffic id, different error text) and
ToggleClientEnableByEmail/SetClientEnableByEmail (different return shape and
a pre-read of the old state) intentionally keep their own bodies.
* refactor(service): extract panel/ subpackage
Move the panel-administration leaf services out of the flat service
package into web/service/panel/ (package panel):
user.go UserService (auth / 2FA / LDAP)
panel.go PanelService (restart / self-update) + version helpers
panel_other.go non-unix RestartPanel
panel_unix.go unix RestartPanel
api_token.go ApiTokenService
websocket.go WebSocketService
panel_test.go version/shellQuote unit tests
These are leaves: they depend on core (SettingService, Release) but no
core file references them, so the extraction creates no import cycle.
Core references are now qualified (service.SettingService, service.Release);
callers in main.go, web/web.go, and web/controller/* updated to panel.*.
Build, vet, and go test ./web/... pass.
* refactor(service): extract integration/ subpackage
Move the external-provider integration leaves into web/service/integration/
(package integration):
warp.go WarpService (Cloudflare WARP)
nord.go NordService (NordVPN)
custom_geo.go CustomGeoService (custom geo asset management)
*_test.go custom_geo / panel-proxy tests
These depend on core (SettingService, ServerService, XraySettingService) but
no core file references them. xray_setting.go stays in core because it calls
the unexported SettingService.saveSetting. The shared isBlockedIP SSRF helper
(used by core url_safety.go and by custom_geo) now has a small copy in each
package rather than being exported. Core references qualified; callers in
web/web.go, web/job/*, and web/controller/* updated to integration.*.
Build, vet, and go test ./web/... pass.
* refactor(service): extract tgbot/ subpackage
Move the Telegram bot (6 files + test) into web/service/tgbot/ (package
tgbot). It is a leaf: it embeds five core services (Inbound/Client/Setting/
Server/Xray) and the core never references it, so no import cycle.
To support the package boundary without changing behavior:
- core exposes XrayProcess() *xray.Process so tgbot keeps calling the
exact same running-process methods it used via the package-level `p`;
- three core methods tgbot calls are exported: ClientService.checkIs-
EnabledByEmail -> CheckIsEnabledByEmail, InboundService.getAllEmails ->
GetAllEmails (callers updated in-package);
- tgbot's embedded-field types and the few core type refs (Status,
ClientCreatePayload, SanitizePublicHTTPURL) are now service-qualified.
Callers in main.go, web/web.go, web/job/*, and web/controller/* updated to
tgbot.*. Build, vet, and go test ./web/... pass.
* refactor(service): extract outbound/ subpackage
OutboundService (outbound.go) imports only neutral packages (config,
database, model, xray) and its production code is referenced by no core or
sibling service file — only by web/controller/xray_setting.go and
web/job/xray_traffic_job.go. Move it to web/service/outbound/ (package
outbound); no core qualification needed inside. Callers updated to outbound.*.
The one coupling was a tiny pure test helper, outboundsContainTag, used by
both outbound.go and the core outbound_subscription_test.go; it now has a
small copy in that test file rather than being shared across the boundary.
Build, vet, and go test ./web/... pass.
* refactor(util): move wireguard into its own subpackage
util/wireguard.go was the lone file of the root `util` package (24 lines,
one exported func GenerateWireguardKeypair), while every other util concern
lives in a focused subpackage (util/common, util/crypto, util/netsafe, ...).
Move it to util/wireguard/ (package wireguard) for consistency; its only
importer, web/service/integration/warp.go, is updated. The root `util`
package no longer exists.
* refactor(sub): drop redundant sub prefix from filenames
Inside package sub the subXxx.go prefix just repeats the package name
(like client_*.go did inside service). Rename for consistency; content and
type names are unchanged:
subController.go -> controller.go
subService.go -> service.go
subClashService.go -> clash_service.go
subJsonService.go -> json_service.go
(+ matching _test.go files)
* refactor(controller): rename xui.go -> spa.go
XUIController serves the panel's single-page-app shell; spa.go names that
role plainly (the other controller files are domain-named). File rename only
— the type stays XUIController. api_docs_test.go keys route base paths by
filename, so its "xui.go" case is updated to "spa.go".
* refactor: move backend packages under internal/
Adopt the idiomatic Go application layout: the backend packages now live
under internal/ (a boundary the toolchain enforces), signalling private
implementation instead of a library-style flat root. No runtime behavior
changes — only import paths and a few build/config paths move.
Moved: config, database, logger, mtproto, sub, util, web, xray -> internal/.
main.go stays at the repo root and tools/openapigen stays under tools/ (both
still import internal/* because the internal rule keys off the module root).
The module path github.com/mhsanaei/3x-ui/v3 is unchanged; 149 .go files had
their import prefix rewritten to .../internal/<pkg>.
Couplings the Go compiler can't see, updated to the new layout:
- frontend i18n imports of web/translation (react.ts, setup.components.ts)
- vite outDir + eslint/tsconfig ignore globs -> internal/web/dist
- Dockerfile COPY paths for web/dist and web/translation
- locale.go os.DirFS("web") disk fallback -> "internal/web"
- .gitignore and ci.yml go:embed stub for internal/web/dist
- api_docs_test.go repo-root relative walk (one level deeper)
- tools/openapigen filesystem package paths; ApiTokenView repointed to the
web/service/panel subpackage and codegen regenerated (clears a stale
type the ci.yml codegen check was failing on)
Verified: go build/vet/test (all packages), and frontend typecheck, lint,
vitest (478 tests), and production build into internal/web/dist.
* fix(config): keep test runs from writing logs into the source tree
GetLogFolder() returns a CWD-relative "./log" on Windows. Under `go test`
the working directory is each package's own folder, so InitLogger (called by
tests in web/job, web/service, xray, web/websocket) created stray log/
directories scattered through the source tree (e.g. internal/web/job/log/).
Redirect to a shared temp folder when testing.Testing() reports a test run.
Production behavior is unchanged: Windows still uses ./log next to the binary
and Linux /var/log/x-ui. The log files were always gitignored (*.log) and
never committed; this just stops the noise at the source.
* docs: move subscription-template guide out of root into docs/
sub_templates/ was a top-level folder holding only a README and no actual
templates (3x-ui ships none by design), referenced nowhere and unlinked from
any doc — it read like an empty placeholder cluttering the repo root.
Move the guide to docs/custom-subscription-templates.md (a proper docs home),
reword its intro to read as documentation rather than a folder note, link it
from the Features list in README.md, and drop the empty sub_templates/ folder.
* fix: update stale web/ path references after the internal/ move
The internal/ migration rewrote Go import paths but left some references to
the old top-level layout in docs, comments, and a few runtime disk paths.
Functional (dev-mode only): the disk-serving fallbacks that read the Vite
build from disk when running from source still pointed at web/dist/, which
moved to internal/web/dist/ — so `os.DirFS`/`os.Stat`/`os.ReadFile` in
internal/web/web.go and internal/sub/{sub,controller}.go are corrected.
Production was unaffected (it serves the embedded FS; verified by the Docker
build), but `go run` with a live frontend build silently fell back to embed.
Docs/comments: frontend/README.md, CONTRIBUTING.md, the claude-issue-bot and
release workflows, the openapigen -root help text, and assorted Go comments
now reference internal/web, internal/database, internal/sub, internal/xray,
etc. Package-name mentions (the "web" package), root paths (main.go,
frontend/, install scripts, /etc/x-ui), routes (/panel/api/xray), and the
historical "web/assets no longer exists" note were intentionally left as-is.
* refactor(web): remove the legacy /xui -> /panel redirect middleware
RedirectMiddleware existed only for backward compatibility with the old
`/xui` URL scheme (301-redirecting /xui and /xui/API to /panel and
/panel/api). That cutover was long ago, so drop the middleware, its
registration in initRouter, and the now-inaccurate "URL redirection"
mention in the middleware package doc. Old /xui URLs now 404 like any other
unknown path. HTTPS auto-redirect and auth redirects are unrelated and stay.
* build: fix .dockerignore for internal/ layout and exclude runtime dir
- web/dist -> internal/web/dist: the embedded frontend moved under internal/,
so the stale exclude no longer matched and the locally-built dist could be
sent to the build context (the frontend stage rebuilds it fresh anyway).
- exclude x-ui/: the local runtime directory (SQLite db, geo .dat files, xray
binaries, certs — ~150MB) was being shipped into the build context for no
reason. Verified the pattern excludes only the directory and still keeps
x-ui.sh, which the Dockerfile copies to /usr/bin/x-ui.
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
)
|
||||
|
||||
func initSubDB(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
// Close the handle before t.TempDir cleanup so Windows doesn't refuse to
|
||||
// remove the still-open sqlite file.
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
}
|
||||
|
||||
// The subscription page's Copy URL must be built from the same host the
|
||||
// subscriber reached the page on (after PrepareForRequest normalizes away a
|
||||
// loopback/bind address) — never the raw listen IP. A subscriber that hit a
|
||||
// loopback bind should see "localhost", not "127.0.0.1".
|
||||
func TestBuildURLs_NormalizesListenIP(t *testing.T) {
|
||||
initSubDB(t)
|
||||
s := &SubService{}
|
||||
s.PrepareForRequest("127.0.0.1")
|
||||
|
||||
subURL, _, _ := s.BuildURLs("/sub/", "/json/", "/clash/", "ABC")
|
||||
|
||||
if strings.Contains(subURL, "127.0.0.1") {
|
||||
t.Fatalf("listen IP leaked into Copy URL: %q", subURL)
|
||||
}
|
||||
if !strings.Contains(subURL, "localhost") {
|
||||
t.Fatalf("Copy URL = %q, want a localhost host", subURL)
|
||||
}
|
||||
if !strings.HasSuffix(subURL, "/sub/ABC") {
|
||||
t.Fatalf("Copy URL = %q, want it to end with /sub/ABC", subURL)
|
||||
}
|
||||
}
|
||||
|
||||
// A subscriber arriving on a real domain gets that exact domain in the Copy
|
||||
// URL, with the configured sub port — matching the Client Information page.
|
||||
func TestBuildURLs_UsesSubscriberDomain(t *testing.T) {
|
||||
initSubDB(t)
|
||||
s := &SubService{}
|
||||
s.PrepareForRequest("sub.example.com")
|
||||
|
||||
subURL, jsonURL, clashURL := s.BuildURLs("/sub/", "/json/", "/clash/", "ABC")
|
||||
|
||||
if subURL != "http://sub.example.com:2096/sub/ABC" {
|
||||
t.Fatalf("subURL = %q", subURL)
|
||||
}
|
||||
if jsonURL != "http://sub.example.com:2096/json/ABC" {
|
||||
t.Fatalf("jsonURL = %q", jsonURL)
|
||||
}
|
||||
if clashURL != "http://sub.example.com:2096/clash/ABC" {
|
||||
t.Fatalf("clashURL = %q", clashURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildURLs_EmptySubId(t *testing.T) {
|
||||
initSubDB(t)
|
||||
s := &SubService{}
|
||||
s.PrepareForRequest("sub.example.com")
|
||||
a, b, c := s.BuildURLs("/sub/", "/json/", "/clash/", "")
|
||||
if a != "" || b != "" || c != "" {
|
||||
t.Fatalf("empty subId must yield empty URLs, got %q %q %q", a, b, c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
yaml "github.com/goccy/go-yaml"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
type SubClashService struct {
|
||||
inboundService service.InboundService
|
||||
enableRouting bool
|
||||
clashRules string
|
||||
SubService *SubService
|
||||
}
|
||||
|
||||
func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService {
|
||||
return &SubClashService{enableRouting: enableRouting, clashRules: clashRules, SubService: subService}
|
||||
}
|
||||
|
||||
func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
|
||||
// Set per-request state so resolveInboundAddress sees the node map.
|
||||
s.SubService.PrepareForRequest(host)
|
||||
inbounds, err := s.SubService.getInboundsBySubId(subId)
|
||||
if err != nil || len(inbounds) == 0 {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
var proxies []map[string]any
|
||||
|
||||
seenEmails := make(map[string]struct{})
|
||||
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
|
||||
}
|
||||
s.SubService.projectThroughFallbackMaster(inbound)
|
||||
for _, client := range clients {
|
||||
if client.SubID == subId {
|
||||
seenEmails[client.Email] = struct{}{}
|
||||
proxies = append(proxies, s.getProxies(inbound, client, host)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(proxies) == 0 {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
ensureUniqueProxyNames(proxies)
|
||||
|
||||
emails := make([]string, 0, len(seenEmails))
|
||||
for e := range seenEmails {
|
||||
emails = append(emails, e)
|
||||
}
|
||||
traffic, _ := s.SubService.AggregateTrafficByEmails(emails)
|
||||
|
||||
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 := map[string]any{
|
||||
"proxies": proxies,
|
||||
"proxy-groups": []map[string]any{{
|
||||
"name": "PROXY",
|
||||
"type": "select",
|
||||
"proxies": proxyNames,
|
||||
}},
|
||||
"rules": []string{"MATCH,PROXY"},
|
||||
}
|
||||
|
||||
if s.enableRouting {
|
||||
if err := mergeClashRulesYAML(config, s.clashRules); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ensureUniqueProxyNames keeps every proxy "name" non-empty and unique:
|
||||
// mihomo rejects the whole config on a duplicate name (the empty string
|
||||
// genRemark returns for a remark-less inbound counts), vanishing the Clash
|
||||
// profile on refresh. See issue #4641.
|
||||
func ensureUniqueProxyNames(proxies []map[string]any) {
|
||||
seen := make(map[string]struct{}, len(proxies))
|
||||
for i, proxy := range proxies {
|
||||
base, _ := proxy["name"].(string)
|
||||
if base == "" {
|
||||
base = fallbackProxyName(proxy, i)
|
||||
}
|
||||
name := base
|
||||
for n := 2; ; n++ {
|
||||
if _, dup := seen[name]; !dup {
|
||||
break
|
||||
}
|
||||
name = fmt.Sprintf("%s-%d", base, n)
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
proxy["name"] = name
|
||||
}
|
||||
}
|
||||
|
||||
func fallbackProxyName(proxy map[string]any, idx int) string {
|
||||
typ, _ := proxy["type"].(string)
|
||||
server, _ := proxy["server"].(string)
|
||||
if typ != "" && server != "" {
|
||||
return fmt.Sprintf("%s-%s-%v", typ, server, proxy["port"])
|
||||
}
|
||||
return fmt.Sprintf("proxy-%d", idx+1)
|
||||
}
|
||||
|
||||
func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client, host string) []map[string]any {
|
||||
stream := s.streamData(inbound.StreamSettings)
|
||||
// For node-managed inbounds the Clash proxy "server" must be the
|
||||
// node's address, not the request host. resolveInboundAddress handles
|
||||
// the node→subscriber-host fallback chain.
|
||||
defaultDest := s.SubService.resolveInboundAddress(inbound)
|
||||
if defaultDest == "" {
|
||||
defaultDest = host
|
||||
}
|
||||
externalProxies, ok := stream["externalProxy"].([]any)
|
||||
hasExternalProxy := ok && len(externalProxies) > 0
|
||||
if !hasExternalProxy {
|
||||
externalProxies = []any{map[string]any{
|
||||
"forceTls": "same",
|
||||
"dest": defaultDest,
|
||||
"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 := cloneStreamForExternalProxy(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")
|
||||
}
|
||||
}
|
||||
security, _ := workingStream["security"].(string)
|
||||
if hasExternalProxy {
|
||||
applyExternalProxyTLSToStream(extPrxy, workingStream, security)
|
||||
}
|
||||
|
||||
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 {
|
||||
// Hysteria has its own transport + TLS model, applyTransport /
|
||||
// applySecurity don't fit.
|
||||
if inbound.Protocol == model.Hysteria {
|
||||
return s.buildHysteriaProxy(inbound, client, extraRemark)
|
||||
}
|
||||
|
||||
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 = strings.TrimSpace(encryption)
|
||||
if encryption != "" && encryption != "none" {
|
||||
proxy["encryption"] = 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
|
||||
}
|
||||
|
||||
// buildHysteriaProxy produces a mihomo-compatible Clash entry for a
|
||||
// Hysteria (v1) or Hysteria2 inbound. It reads `inbound.StreamSettings`
|
||||
// directly instead of going through streamData/tlsData, because those
|
||||
// helpers prune fields (like `allowInsecure` / the salamander obfs
|
||||
// block) that the hysteria proxy wants preserved.
|
||||
func (s *SubClashService) buildHysteriaProxy(inbound *model.Inbound, client model.Client, extraRemark string) map[string]any {
|
||||
var inboundSettings map[string]any
|
||||
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
||||
|
||||
proxyType := "hysteria2"
|
||||
authKey := "password"
|
||||
if v, ok := inboundSettings["version"].(float64); ok && int(v) == 1 {
|
||||
proxyType = "hysteria"
|
||||
authKey = "auth-str"
|
||||
}
|
||||
|
||||
proxy := map[string]any{
|
||||
"name": s.SubService.genRemark(inbound, client.Email, extraRemark),
|
||||
"type": proxyType,
|
||||
"server": inbound.Listen,
|
||||
"port": inbound.Port,
|
||||
"udp": true,
|
||||
authKey: client.Auth,
|
||||
}
|
||||
|
||||
var rawStream map[string]any
|
||||
_ = json.Unmarshal([]byte(inbound.StreamSettings), &rawStream)
|
||||
|
||||
// TLS details — hysteria always uses TLS.
|
||||
if tlsSettings, ok := rawStream["tlsSettings"].(map[string]any); ok {
|
||||
if serverName, ok := tlsSettings["serverName"].(string); ok && serverName != "" {
|
||||
proxy["sni"] = serverName
|
||||
}
|
||||
if alpnList, ok := tlsSettings["alpn"].([]any); ok && len(alpnList) > 0 {
|
||||
out := make([]string, 0, len(alpnList))
|
||||
for _, a := range alpnList {
|
||||
if s, ok := a.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
proxy["alpn"] = out
|
||||
}
|
||||
}
|
||||
if inner, ok := tlsSettings["settings"].(map[string]any); ok {
|
||||
if insecure, ok := inner["allowInsecure"].(bool); ok && insecure {
|
||||
proxy["skip-cert-verify"] = true
|
||||
}
|
||||
if fp, ok := inner["fingerprint"].(string); ok && fp != "" {
|
||||
proxy["client-fingerprint"] = fp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Salamander obfs (Hysteria2). Read the same finalmask.udp[salamander]
|
||||
// block the subscription link generator uses.
|
||||
if finalmask, ok := rawStream["finalmask"].(map[string]any); ok {
|
||||
if udpMasks, ok := finalmask["udp"].([]any); ok {
|
||||
for _, m := range udpMasks {
|
||||
mask, _ := m.(map[string]any)
|
||||
if mask == nil || mask["type"] != "salamander" {
|
||||
continue
|
||||
}
|
||||
settings, _ := mask["settings"].(map[string]any)
|
||||
if pw, ok := settings["password"].(string); ok && pw != "" {
|
||||
proxy["obfs"] = "salamander"
|
||||
proxy["obfs-password"] = pw
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UDP port hopping. mihomo reads the range from a dedicated `ports`
|
||||
// field (the base `port` stays as the redirect target).
|
||||
if hopPorts := hysteriaHopPorts(rawStream); hopPorts != "" {
|
||||
proxy["ports"] = hopPorts
|
||||
}
|
||||
|
||||
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
|
||||
case "httpupgrade":
|
||||
proxy["network"] = "httpupgrade"
|
||||
hu, _ := stream["httpupgradeSettings"].(map[string]any)
|
||||
opts := map[string]any{}
|
||||
if hu != nil {
|
||||
if path, ok := hu["path"].(string); ok && path != "" {
|
||||
opts["path"] = path
|
||||
}
|
||||
host := ""
|
||||
if v, ok := hu["host"].(string); ok && v != "" {
|
||||
host = v
|
||||
} else if headers, ok := hu["headers"].(map[string]any); ok {
|
||||
host = searchHost(headers)
|
||||
}
|
||||
if host != "" {
|
||||
opts["headers"] = map[string]any{"Host": host}
|
||||
}
|
||||
}
|
||||
if len(opts) > 0 {
|
||||
proxy["http-upgrade-opts"] = opts
|
||||
}
|
||||
return true
|
||||
case "xhttp":
|
||||
proxy["network"] = "xhttp"
|
||||
xhttp, _ := stream["xhttpSettings"].(map[string]any)
|
||||
opts := map[string]any{}
|
||||
if xhttp != nil {
|
||||
if path, ok := xhttp["path"].(string); ok && path != "" {
|
||||
opts["path"] = path
|
||||
}
|
||||
host := ""
|
||||
if v, ok := xhttp["host"].(string); ok && v != "" {
|
||||
host = v
|
||||
} else if headers, ok := xhttp["headers"].(map[string]any); ok {
|
||||
host = searchHost(headers)
|
||||
}
|
||||
if host != "" {
|
||||
opts["host"] = host
|
||||
}
|
||||
if mode, ok := xhttp["mode"].(string); ok && mode != "" {
|
||||
opts["mode"] = mode
|
||||
}
|
||||
}
|
||||
if len(opts) > 0 {
|
||||
proxy["xhttp-opts"] = opts
|
||||
}
|
||||
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
|
||||
}
|
||||
if alpn, ok := externalProxyALPNList(tlsSettings["alpn"]); ok {
|
||||
out := make([]string, 0, len(alpn))
|
||||
for _, item := range alpn {
|
||||
if s, ok := item.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
proxy["alpn"] = out
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
if pins, ok := tlsClientSettings["pinnedPeerCertSha256"].([]any); ok && len(pins) > 0 {
|
||||
tlsData["pin-sha256"] = pins
|
||||
}
|
||||
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))
|
||||
maps.Copy(dst, src)
|
||||
return dst
|
||||
}
|
||||
|
||||
func mergeClashRulesYAML(base map[string]any, raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var custom any
|
||||
if err := yaml.Unmarshal([]byte(raw), &custom); err != nil {
|
||||
mergeClashRules(base, linesToClashRules(raw))
|
||||
return nil
|
||||
}
|
||||
|
||||
switch typed := custom.(type) {
|
||||
case []any:
|
||||
mergeClashRules(base, typed)
|
||||
case map[string]any:
|
||||
for key, value := range typed {
|
||||
if key == "rules" {
|
||||
if ruleList, ok := asAnySlice(value); ok {
|
||||
mergeClashRules(base, ruleList)
|
||||
}
|
||||
continue
|
||||
}
|
||||
base[key] = value
|
||||
}
|
||||
default:
|
||||
mergeClashRules(base, linesToClashRules(raw))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeClashRules(base map[string]any, customRules []any) {
|
||||
if len(customRules) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
baseRules, _ := asAnySlice(base["rules"])
|
||||
if hasClashMatchRule(customRules) {
|
||||
base["rules"] = customRules
|
||||
return
|
||||
}
|
||||
|
||||
merged := make([]any, 0, len(customRules)+len(baseRules))
|
||||
merged = append(merged, customRules...)
|
||||
merged = append(merged, baseRules...)
|
||||
base["rules"] = merged
|
||||
}
|
||||
|
||||
func asAnySlice(value any) ([]any, bool) {
|
||||
switch typed := value.(type) {
|
||||
case []any:
|
||||
return typed, true
|
||||
case []string:
|
||||
out := make([]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, true
|
||||
case []map[string]any:
|
||||
out := make([]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func hasClashMatchRule(rules []any) bool {
|
||||
for _, rule := range rules {
|
||||
ruleText, ok := rule.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(ruleText, ",", 2)
|
||||
if strings.EqualFold(strings.TrimSpace(parts[0]), "MATCH") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func linesToClashRules(raw string) []any {
|
||||
lines := strings.Split(raw, "\n")
|
||||
rules := make([]any, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
rules = append(rules, line)
|
||||
}
|
||||
return rules
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestEnsureUniqueProxyNames(t *testing.T) {
|
||||
proxies := []map[string]any{
|
||||
{"name": "", "type": "vless", "server": "a.com", "port": 443},
|
||||
{"name": "", "type": "vmess", "server": "b.com", "port": 8443},
|
||||
{"name": "node"},
|
||||
{"name": "node"},
|
||||
{"name": ""},
|
||||
}
|
||||
|
||||
ensureUniqueProxyNames(proxies)
|
||||
|
||||
seen := map[string]bool{}
|
||||
for i, p := range proxies {
|
||||
name, _ := p["name"].(string)
|
||||
if name == "" {
|
||||
t.Fatalf("proxy %d still has an empty name (mihomo would reject the config, #4641)", i)
|
||||
}
|
||||
if seen[name] {
|
||||
t.Fatalf("proxy %d has duplicate name %q (mihomo rejects the whole config, #4641)", i, name)
|
||||
}
|
||||
seen[name] = true
|
||||
}
|
||||
|
||||
if got := proxies[0]["name"]; got != "vless-a.com-443" {
|
||||
t.Errorf("empty name fallback = %q, want vless-a.com-443", got)
|
||||
}
|
||||
if proxies[2]["name"] == proxies[3]["name"] {
|
||||
t.Errorf("duplicate %q was not disambiguated", proxies[2]["name"])
|
||||
}
|
||||
if got := proxies[4]["name"]; got != "proxy-5" {
|
||||
t.Errorf("typeless empty name fallback = %q, want proxy-5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTransport_XHTTP(t *testing.T) {
|
||||
svc := &SubClashService{}
|
||||
proxy := map[string]any{}
|
||||
stream := map[string]any{
|
||||
"xhttpSettings": map[string]any{
|
||||
"path": "/xh",
|
||||
"host": "example.com",
|
||||
"mode": "auto",
|
||||
},
|
||||
}
|
||||
|
||||
if !svc.applyTransport(proxy, "xhttp", stream) {
|
||||
t.Fatalf("applyTransport returned false for xhttp (#4531: would drop the inbound and yield an empty Clash YAML)")
|
||||
}
|
||||
if proxy["network"] != "xhttp" {
|
||||
t.Fatalf("network = %v, want xhttp", proxy["network"])
|
||||
}
|
||||
opts, ok := proxy["xhttp-opts"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("xhttp-opts missing or wrong type: %#v", proxy["xhttp-opts"])
|
||||
}
|
||||
want := map[string]any{"path": "/xh", "host": "example.com", "mode": "auto"}
|
||||
if !reflect.DeepEqual(opts, want) {
|
||||
t.Fatalf("xhttp-opts = %#v, want %#v", opts, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTransport_XHTTP_HostFromHeaders(t *testing.T) {
|
||||
svc := &SubClashService{}
|
||||
proxy := map[string]any{}
|
||||
stream := map[string]any{
|
||||
"xhttpSettings": map[string]any{
|
||||
"path": "/xh",
|
||||
"headers": map[string]any{"Host": "via-header.example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
if !svc.applyTransport(proxy, "xhttp", stream) {
|
||||
t.Fatalf("applyTransport returned false for xhttp")
|
||||
}
|
||||
opts, _ := proxy["xhttp-opts"].(map[string]any)
|
||||
if opts["host"] != "via-header.example.com" {
|
||||
t.Fatalf("host should fall back to headers.Host, got %v", opts["host"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTransport_HTTPUpgrade(t *testing.T) {
|
||||
svc := &SubClashService{}
|
||||
proxy := map[string]any{}
|
||||
stream := map[string]any{
|
||||
"httpupgradeSettings": map[string]any{
|
||||
"path": "/hu",
|
||||
"host": "example.com",
|
||||
},
|
||||
}
|
||||
|
||||
if !svc.applyTransport(proxy, "httpupgrade", stream) {
|
||||
t.Fatalf("applyTransport returned false for httpupgrade")
|
||||
}
|
||||
if proxy["network"] != "httpupgrade" {
|
||||
t.Fatalf("network = %v, want httpupgrade", proxy["network"])
|
||||
}
|
||||
opts, ok := proxy["http-upgrade-opts"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("http-upgrade-opts missing: %#v", proxy["http-upgrade-opts"])
|
||||
}
|
||||
if opts["path"] != "/hu" {
|
||||
t.Fatalf("path = %v, want /hu", opts["path"])
|
||||
}
|
||||
headers, _ := opts["headers"].(map[string]any)
|
||||
if headers["Host"] != "example.com" {
|
||||
t.Fatalf("headers.Host = %v, want example.com", headers["Host"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProxy_VLESSPostQuantumEncryptionUsesMihomoEncryptionField(t *testing.T) {
|
||||
svc := &SubClashService{SubService: &SubService{remarkModel: "-i"}}
|
||||
encryption := "mlkem768x25519plus.native.0rtt.client"
|
||||
inbound := &model.Inbound{
|
||||
Listen: "203.0.113.1",
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
Remark: "pq",
|
||||
Settings: `{"encryption":"` + encryption + `"}`,
|
||||
}
|
||||
client := model.Client{ID: "11111111-2222-4333-8444-555555555555"}
|
||||
stream := map[string]any{
|
||||
"network": "xhttp",
|
||||
"xhttpSettings": map[string]any{
|
||||
"path": "/",
|
||||
"mode": "auto",
|
||||
},
|
||||
"security": "reality",
|
||||
"realitySettings": map[string]any{
|
||||
"publicKey": "pub",
|
||||
"serverName": "example.com",
|
||||
"shortId": "abcd",
|
||||
},
|
||||
}
|
||||
|
||||
proxy := svc.buildProxy(inbound, client, stream, "")
|
||||
|
||||
if proxy["encryption"] != encryption {
|
||||
t.Fatalf("encryption = %v, want %q", proxy["encryption"], encryption)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProxy_VLESSNoneEncryptionOmittedForClash(t *testing.T) {
|
||||
svc := &SubClashService{SubService: &SubService{remarkModel: "-i"}}
|
||||
inbound := &model.Inbound{
|
||||
Listen: "203.0.113.1",
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
Remark: "plain",
|
||||
Settings: `{"encryption":"none"}`,
|
||||
}
|
||||
client := model.Client{ID: "11111111-2222-4333-8444-555555555555"}
|
||||
stream := map[string]any{
|
||||
"network": "tcp",
|
||||
"security": "none",
|
||||
"tcpSettings": map[string]any{
|
||||
"header": map[string]any{"type": "none"},
|
||||
},
|
||||
}
|
||||
|
||||
proxy := svc.buildProxy(inbound, client, stream, "")
|
||||
|
||||
if _, ok := proxy["encryption"]; ok {
|
||||
t.Fatalf("plain vless encryption should be omitted for mihomo: %#v", proxy)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
// writeSubError translates a service-layer result into an HTTP response.
|
||||
// A nil error with no rows means the subId doesn't match anything (deleted
|
||||
// client, never-existed id) and becomes 404. A real error becomes 500. No
|
||||
// body — VPN clients only look at the status.
|
||||
func writeSubError(c *gin.Context, err error) {
|
||||
if err == nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// cachedSubTemplate holds a parsed custom subscription template together with
|
||||
// the modification time of the file it was parsed from, so the cache can be
|
||||
// invalidated when an admin edits the template on disk.
|
||||
type cachedSubTemplate struct {
|
||||
tmpl *template.Template
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
// SUBController handles HTTP requests for subscription links and JSON configurations.
|
||||
type SUBController struct {
|
||||
subTitle string
|
||||
subSupportUrl string
|
||||
subProfileUrl string
|
||||
subAnnounce string
|
||||
subEnableRouting bool
|
||||
subRoutingRules string
|
||||
subPath string
|
||||
subJsonPath string
|
||||
subClashPath string
|
||||
jsonEnabled bool
|
||||
clashEnabled bool
|
||||
subEncrypt bool
|
||||
updateInterval string
|
||||
|
||||
subService *SubService
|
||||
subJsonService *SubJsonService
|
||||
subClashService *SubClashService
|
||||
settingService service.SettingService
|
||||
|
||||
subTemplateMu sync.RWMutex
|
||||
subTemplateCache map[string]*cachedSubTemplate
|
||||
}
|
||||
|
||||
// NewSUBController creates a new subscription controller with the given configuration.
|
||||
func NewSUBController(
|
||||
g *gin.RouterGroup,
|
||||
subPath string,
|
||||
jsonPath string,
|
||||
clashPath string,
|
||||
jsonEnabled bool,
|
||||
clashEnabled bool,
|
||||
encrypt bool,
|
||||
showInfo bool,
|
||||
rModel string,
|
||||
update string,
|
||||
jsonMux string,
|
||||
jsonRules string,
|
||||
jsonFinalMask string,
|
||||
clashEnableRouting bool,
|
||||
clashRules string,
|
||||
subTitle string,
|
||||
subSupportUrl string,
|
||||
subProfileUrl string,
|
||||
subAnnounce string,
|
||||
subEnableRouting bool,
|
||||
subRoutingRules string,
|
||||
) *SUBController {
|
||||
sub := NewSubService(showInfo, rModel)
|
||||
a := &SUBController{
|
||||
subTitle: subTitle,
|
||||
subSupportUrl: subSupportUrl,
|
||||
subProfileUrl: subProfileUrl,
|
||||
subAnnounce: subAnnounce,
|
||||
subEnableRouting: subEnableRouting,
|
||||
subRoutingRules: subRoutingRules,
|
||||
subPath: subPath,
|
||||
subJsonPath: jsonPath,
|
||||
subClashPath: clashPath,
|
||||
jsonEnabled: jsonEnabled,
|
||||
clashEnabled: clashEnabled,
|
||||
subEncrypt: encrypt,
|
||||
updateInterval: update,
|
||||
|
||||
subService: sub,
|
||||
subJsonService: NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
|
||||
subClashService: NewSubClashService(clashEnableRouting, clashRules, sub),
|
||||
|
||||
subTemplateCache: map[string]*cachedSubTemplate{},
|
||||
}
|
||||
a.initRouter(g)
|
||||
return a
|
||||
}
|
||||
|
||||
// initRouter registers HTTP routes for subscription links and JSON endpoints
|
||||
// on the provided router group.
|
||||
func (a *SUBController) initRouter(g *gin.RouterGroup) {
|
||||
gLink := g.Group(a.subPath)
|
||||
gLink.GET(":subid", a.subs)
|
||||
gLink.HEAD(":subid", a.subs)
|
||||
if a.jsonEnabled {
|
||||
gJson := g.Group(a.subJsonPath)
|
||||
gJson.GET(":subid", a.subJsons)
|
||||
gJson.HEAD(":subid", a.subJsons)
|
||||
}
|
||||
if a.clashEnabled {
|
||||
gClash := g.Group(a.subClashPath)
|
||||
gClash.GET(":subid", a.subClashs)
|
||||
gClash.HEAD(":subid", a.subClashs)
|
||||
}
|
||||
}
|
||||
|
||||
// subs handles HTTP requests for subscription links, returning either HTML page or base64-encoded subscription data.
|
||||
func (a *SUBController) subs(c *gin.Context) {
|
||||
subId := c.Param("subid")
|
||||
scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c)
|
||||
subs, emails, lastOnline, traffic, err := a.subService.GetSubs(subId, host)
|
||||
if err != nil || len(subs) == 0 {
|
||||
writeSubError(c, err)
|
||||
} else {
|
||||
result := ""
|
||||
for _, sub := range subs {
|
||||
result += sub + "\n"
|
||||
}
|
||||
|
||||
// If the request expects HTML (e.g., browser) or explicitly asked (?html=1 or ?view=html), render the info page here
|
||||
accept := c.GetHeader("Accept")
|
||||
if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
|
||||
subURL, subJsonURL, subClashURL := a.subService.BuildURLs(a.subPath, a.subJsonPath, a.subClashPath, subId)
|
||||
if !a.jsonEnabled {
|
||||
subJsonURL = ""
|
||||
}
|
||||
if !a.clashEnabled {
|
||||
subClashURL = ""
|
||||
}
|
||||
basePath, exists := c.Get("base_path")
|
||||
if !exists {
|
||||
basePath = "/"
|
||||
}
|
||||
basePathStr := basePath.(string)
|
||||
page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, emails, subURL, subJsonURL, subClashURL, basePathStr, a.subTitle, a.subSupportUrl)
|
||||
a.serveSubPage(c, basePathStr, page)
|
||||
return
|
||||
}
|
||||
|
||||
// Add headers
|
||||
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
||||
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)
|
||||
|
||||
if a.subEncrypt {
|
||||
c.String(200, base64.StdEncoding.EncodeToString([]byte(result)))
|
||||
} else {
|
||||
c.String(200, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serveSubPage renders internal/web/dist/subpage.html for the current subscription
|
||||
// request. The Vite-built SPA reads window.__SUB_PAGE_DATA__ on mount —
|
||||
// we inject that here, along with window.X_UI_BASE_PATH so the
|
||||
// page's static asset references resolve correctly when the panel runs
|
||||
// behind a URL prefix.
|
||||
func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageData) {
|
||||
var body []byte
|
||||
if diskBody, diskErr := os.ReadFile("internal/web/dist/subpage.html"); diskErr == nil {
|
||||
body = diskBody
|
||||
} else {
|
||||
readBody, err := distFS.ReadFile("dist/subpage.html")
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "missing embedded subpage")
|
||||
return
|
||||
}
|
||||
body = readBody
|
||||
}
|
||||
|
||||
// Vite emits absolute asset URLs (`/assets/...`); when the panel is
|
||||
// installed under a custom URL prefix, rewrite them so the bundle
|
||||
// loads from `<basePath>assets/...` where the static handler is
|
||||
// actually mounted.
|
||||
if basePath != "/" && basePath != "" {
|
||||
body = bytes.ReplaceAll(body, []byte(`src="/assets/`), []byte(`src="`+basePath+`assets/`))
|
||||
body = bytes.ReplaceAll(body, []byte(`href="/assets/`), []byte(`href="`+basePath+`assets/`))
|
||||
}
|
||||
|
||||
// JSON-marshal the view-model so the SPA can read it as a plain
|
||||
// The panel's "Calendar Type" setting decides whether the SubPage
|
||||
// renders dates in Gregorian or Jalali — surface it here so the SPA
|
||||
// can match the rest of the panel without a round-trip.
|
||||
datepicker, _ := a.settingService.GetDatepicker()
|
||||
if datepicker == "" {
|
||||
datepicker = "gregorian"
|
||||
}
|
||||
|
||||
subData := map[string]any{
|
||||
"sId": page.SId,
|
||||
"enabled": page.Enabled,
|
||||
"download": page.Download,
|
||||
"upload": page.Upload,
|
||||
"total": page.Total,
|
||||
"used": page.Used,
|
||||
"remained": page.Remained,
|
||||
"expire": page.Expire,
|
||||
"lastOnline": page.LastOnline,
|
||||
"downloadByte": page.DownloadByte,
|
||||
"uploadByte": page.UploadByte,
|
||||
"totalByte": page.TotalByte,
|
||||
"subUrl": page.SubUrl,
|
||||
"subJsonUrl": page.SubJsonUrl,
|
||||
"subClashUrl": page.SubClashUrl,
|
||||
"subTitle": page.SubTitle,
|
||||
"subSupportUrl": page.SubSupportUrl,
|
||||
"links": page.Result,
|
||||
"emails": page.Emails,
|
||||
"datepicker": datepicker,
|
||||
}
|
||||
|
||||
// When an admin has configured a custom subscription theme, render it
|
||||
// instead of the default SPA. We render into a buffer first so a template
|
||||
// that fails mid-execution can't leave a partially-written (corrupt)
|
||||
// response — on any error we log and fall through to the default page.
|
||||
if themeDir, _ := a.settingService.GetSubThemeDir(); themeDir != "" {
|
||||
if tmpl, err := a.loadSubTemplate(themeDir); err != nil {
|
||||
logger.Error("sub: custom template parse failed, using default page:", err)
|
||||
} else if tmpl == nil {
|
||||
logger.Warning("sub: subThemeDir set but no usable template found, using default page:", themeDir)
|
||||
} else {
|
||||
var buf bytes.Buffer
|
||||
if execErr := tmpl.Execute(&buf, subData); execErr != nil {
|
||||
logger.Error("sub: custom template execution failed, using default page:", execErr)
|
||||
} else {
|
||||
setNoCacheHeaders(c)
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subDataJSON, err := json.Marshal(subData)
|
||||
if err != nil {
|
||||
subDataJSON = []byte("{}")
|
||||
}
|
||||
|
||||
// Defense-in-depth string-escape for the basePath embed — admin-
|
||||
// controlled but cheap to harden.
|
||||
jsEscape := strings.NewReplacer(
|
||||
`\`, `\\`,
|
||||
`"`, `\"`,
|
||||
"\n", `\n`,
|
||||
"\r", `\r`,
|
||||
"<", `<`,
|
||||
">", `>`,
|
||||
"&", `&`,
|
||||
)
|
||||
escapedBase := jsEscape.Replace(basePath)
|
||||
|
||||
inject := []byte(`<script>window.X_UI_BASE_PATH="` + escapedBase + `";` +
|
||||
`window.__SUB_PAGE_DATA__=` + string(subDataJSON) + `;</script></head>`)
|
||||
out := bytes.Replace(body, []byte("</head>"), inject, 1)
|
||||
|
||||
setNoCacheHeaders(c)
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", out)
|
||||
}
|
||||
|
||||
// setNoCacheHeaders marks a subscription page response as non-cacheable so VPN
|
||||
// clients and browsers always fetch fresh traffic/expiry data.
|
||||
func setNoCacheHeaders(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
c.Header("Pragma", "no-cache")
|
||||
c.Header("Expires", "0")
|
||||
}
|
||||
|
||||
// loadSubTemplate returns the parsed custom subscription template located in
|
||||
// themeDir, preferring sub.html over index.html. Parsed templates are cached and
|
||||
// only re-parsed when the underlying file's modification time changes, so admin
|
||||
// edits are picked up without paying a disk read + HTML parse on every request.
|
||||
//
|
||||
// It returns (nil, nil) when themeDir is not a usable directory or contains no
|
||||
// template file — the caller should fall back to the default page. A non-nil
|
||||
// error means a template file exists but failed to parse.
|
||||
func (a *SUBController) loadSubTemplate(themeDir string) (*template.Template, error) {
|
||||
info, err := os.Stat(themeDir)
|
||||
if err != nil || !info.IsDir() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
templatePath := filepath.Join(themeDir, "index.html")
|
||||
if _, err := os.Stat(filepath.Join(themeDir, "sub.html")); err == nil {
|
||||
templatePath = filepath.Join(themeDir, "sub.html")
|
||||
}
|
||||
|
||||
fi, err := os.Stat(templatePath)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
modTime := fi.ModTime()
|
||||
|
||||
a.subTemplateMu.RLock()
|
||||
cached := a.subTemplateCache[templatePath]
|
||||
a.subTemplateMu.RUnlock()
|
||||
if cached != nil && cached.modTime.Equal(modTime) {
|
||||
return cached.tmpl, nil
|
||||
}
|
||||
|
||||
tmpl, err := template.ParseFiles(templatePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
a.subTemplateMu.Lock()
|
||||
a.subTemplateCache[templatePath] = &cachedSubTemplate{tmpl: tmpl, modTime: modTime}
|
||||
a.subTemplateMu.Unlock()
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
// subJsons handles HTTP requests for JSON subscription configurations.
|
||||
func (a *SUBController) subJsons(c *gin.Context) {
|
||||
subId := c.Param("subid")
|
||||
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
|
||||
jsonSub, header, err := a.subJsonService.GetJson(subId, host)
|
||||
if err != nil || len(jsonSub) == 0 {
|
||||
writeSubError(c, err)
|
||||
} 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.String(200, jsonSub)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
writeSubError(c, err)
|
||||
} 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)
|
||||
if a.subTitle != "" {
|
||||
// Clash clients commonly use Content-Disposition to choose the imported profile name.
|
||||
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename*=UTF-8''%s`, url.PathEscape(a.subTitle)))
|
||||
}
|
||||
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,
|
||||
header,
|
||||
updateInterval,
|
||||
profileTitle string,
|
||||
profileSupportUrl string,
|
||||
profileUrl string,
|
||||
profileAnnounce string,
|
||||
profileEnableRouting bool,
|
||||
profileRoutingRules string,
|
||||
) {
|
||||
c.Writer.Header().Set("Subscription-Userinfo", header)
|
||||
c.Writer.Header().Set("Profile-Update-Interval", updateInterval)
|
||||
|
||||
//Basics
|
||||
if profileTitle != "" {
|
||||
c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle)))
|
||||
}
|
||||
if profileSupportUrl != "" {
|
||||
c.Writer.Header().Set("Support-Url", profileSupportUrl)
|
||||
}
|
||||
if profileUrl != "" {
|
||||
c.Writer.Header().Set("Profile-Web-Page-Url", profileUrl)
|
||||
}
|
||||
if profileAnnounce != "" {
|
||||
c.Writer.Header().Set("Announce", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileAnnounce)))
|
||||
}
|
||||
|
||||
//Advanced (Happ)
|
||||
c.Writer.Header().Set("Routing-Enable", strconv.FormatBool(profileEnableRouting))
|
||||
if profileRoutingRules != "" {
|
||||
c.Writer.Header().Set("Routing", profileRoutingRules)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newTestSUBController builds a controller with just the bits loadSubTemplate
|
||||
// needs, so the template tests don't require a database.
|
||||
func newTestSUBController() *SUBController {
|
||||
return &SUBController{subTemplateCache: map[string]*cachedSubTemplate{}}
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func renderTemplate(t *testing.T, a *SUBController, dir string, data map[string]any) string {
|
||||
t.Helper()
|
||||
tmpl, err := a.loadSubTemplate(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("loadSubTemplate: unexpected error: %v", err)
|
||||
}
|
||||
if tmpl == nil {
|
||||
t.Fatal("loadSubTemplate: expected a template, got nil")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestLoadSubTemplate_RendersIndex(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFile(t, filepath.Join(dir, "index.html"), `<h1>{{ .sId }}</h1>`)
|
||||
|
||||
got := renderTemplate(t, newTestSUBController(), dir, map[string]any{"sId": "abc-123"})
|
||||
if want := `<h1>abc-123</h1>`; got != want {
|
||||
t.Fatalf("rendered = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSubTemplate_PrefersSubHTML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFile(t, filepath.Join(dir, "index.html"), `from-index`)
|
||||
writeFile(t, filepath.Join(dir, "sub.html"), `from-sub`)
|
||||
|
||||
got := renderTemplate(t, newTestSUBController(), dir, nil)
|
||||
if got != "from-sub" {
|
||||
t.Fatalf("rendered = %q, want %q (sub.html should take precedence)", got, "from-sub")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSubTemplate_FallbackCases(t *testing.T) {
|
||||
a := newTestSUBController()
|
||||
|
||||
t.Run("missing dir", func(t *testing.T) {
|
||||
tmpl, err := a.loadSubTemplate(filepath.Join(t.TempDir(), "does-not-exist"))
|
||||
if tmpl != nil || err != nil {
|
||||
t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("path is a file not a dir", func(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "index.html")
|
||||
writeFile(t, file, `whatever`)
|
||||
tmpl, err := a.loadSubTemplate(file)
|
||||
if tmpl != nil || err != nil {
|
||||
t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dir without template file", func(t *testing.T) {
|
||||
tmpl, err := a.loadSubTemplate(t.TempDir())
|
||||
if tmpl != nil || err != nil {
|
||||
t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadSubTemplate_MalformedTemplate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Unterminated action — html/template fails to parse this.
|
||||
writeFile(t, filepath.Join(dir, "index.html"), `<h1>{{ .sId </h1>`)
|
||||
|
||||
tmpl, err := newTestSUBController().loadSubTemplate(dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected a parse error for a malformed template, got nil")
|
||||
}
|
||||
if tmpl != nil {
|
||||
t.Fatalf("expected nil template on parse error, got %v", tmpl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSubTemplate_CacheHitAndInvalidation(t *testing.T) {
|
||||
a := newTestSUBController()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "index.html")
|
||||
|
||||
// v1 with a fixed mtime.
|
||||
writeFile(t, path, `v1`)
|
||||
t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
if err := os.Chtimes(path, t1, t1); err != nil {
|
||||
t.Fatalf("chtimes: %v", err)
|
||||
}
|
||||
|
||||
first, err := a.loadSubTemplate(dir)
|
||||
if err != nil || first == nil {
|
||||
t.Fatalf("first load: (%v, %v)", first, err)
|
||||
}
|
||||
|
||||
// Same mtime → cache hit returns the identical parsed template.
|
||||
second, err := a.loadSubTemplate(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("second load: %v", err)
|
||||
}
|
||||
if second != first {
|
||||
t.Fatal("expected cache hit to return the same *template.Template pointer")
|
||||
}
|
||||
|
||||
// New content + newer mtime → cache invalidated, fresh content served.
|
||||
writeFile(t, path, `v2`)
|
||||
t2 := t1.Add(time.Hour)
|
||||
if err := os.Chtimes(path, t2, t2); err != nil {
|
||||
t.Fatalf("chtimes: %v", err)
|
||||
}
|
||||
|
||||
third, err := a.loadSubTemplate(dir)
|
||||
if err != nil || third == nil {
|
||||
t.Fatalf("third load: (%v, %v)", third, err)
|
||||
}
|
||||
if third == first {
|
||||
t.Fatal("expected cache invalidation to re-parse the template after mtime change")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := third.Execute(&buf, nil); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if buf.String() != "v2" {
|
||||
t.Fatalf("rendered = %q, want %q after edit", buf.String(), "v2")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"remarks": "",
|
||||
"dns": {
|
||||
"tag": "dns_out",
|
||||
"queryStrategy": "UseIP",
|
||||
"servers": [
|
||||
{
|
||||
"address": "8.8.8.8",
|
||||
"skipFallback": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"inbounds": [
|
||||
{
|
||||
"port": 10808,
|
||||
"protocol": "mixed",
|
||||
"settings": {
|
||||
"auth": "noauth",
|
||||
"udp": true,
|
||||
"userLevel": 8
|
||||
},
|
||||
"sniffing": {
|
||||
"destOverride": [
|
||||
"http",
|
||||
"tls",
|
||||
"quic",
|
||||
"fakedns"
|
||||
],
|
||||
"enabled": true
|
||||
},
|
||||
"tag": "mixed"
|
||||
},
|
||||
{
|
||||
"port": 10809,
|
||||
"protocol": "http",
|
||||
"settings": {
|
||||
"userLevel": 8
|
||||
},
|
||||
"tag": "http"
|
||||
}
|
||||
],
|
||||
"log": {
|
||||
"loglevel": "warning"
|
||||
},
|
||||
"outbounds": [
|
||||
{
|
||||
"tag": "direct",
|
||||
"protocol": "freedom",
|
||||
"settings": {
|
||||
"domainStrategy": "AsIs",
|
||||
"redirect": "",
|
||||
"noises": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "block",
|
||||
"protocol": "blackhole",
|
||||
"settings": {
|
||||
"response": {
|
||||
"type": "http"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"policy": {
|
||||
"levels": {
|
||||
"8": {
|
||||
"connIdle": 300,
|
||||
"downlinkOnly": 1,
|
||||
"handshake": 4,
|
||||
"uplinkOnly": 1
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"statsOutboundUplink": true,
|
||||
"statsOutboundDownlink": true
|
||||
}
|
||||
},
|
||||
"routing": {
|
||||
"domainStrategy": "AsIs",
|
||||
"rules": [
|
||||
{
|
||||
"type": "field",
|
||||
"network": "tcp,udp",
|
||||
"outboundTag": "proxy"
|
||||
}
|
||||
]
|
||||
},
|
||||
"stats": {}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package sub
|
||||
|
||||
import "embed"
|
||||
|
||||
// distFS holds the Vite-built frontend filesystem, injected from main at
|
||||
// startup. The `web` package owns the //go:embed directive (because dist/
|
||||
// is at internal/web/dist/), and hands the FS over via SetDistFS so the sub package
|
||||
// doesn't import web — that would create an import cycle once any
|
||||
// internal/web/controller handler reuses sub's link-building service.
|
||||
var distFS embed.FS
|
||||
|
||||
// SetDistFS installs the embedded frontend filesystem the sub server uses
|
||||
// for its info page assets. Must be called before NewServer().Start().
|
||||
func SetDistFS(fs embed.FS) {
|
||||
distFS = fs
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
//go:embed default.json
|
||||
var defaultJson string
|
||||
|
||||
// SubJsonService handles JSON subscription configuration generation and management.
|
||||
type SubJsonService struct {
|
||||
configJson map[string]any
|
||||
defaultOutbounds []json_util.RawMessage
|
||||
finalMask string
|
||||
mux string
|
||||
|
||||
inboundService service.InboundService
|
||||
SubService *SubService
|
||||
}
|
||||
|
||||
// NewSubJsonService creates a new JSON subscription service with the given configuration.
|
||||
func NewSubJsonService(mux string, rules string, finalMask string, subService *SubService) *SubJsonService {
|
||||
var configJson map[string]any
|
||||
var defaultOutbounds []json_util.RawMessage
|
||||
json.Unmarshal([]byte(defaultJson), &configJson)
|
||||
if outboundSlices, ok := configJson["outbounds"].([]any); ok {
|
||||
for _, defaultOutbound := range outboundSlices {
|
||||
jsonBytes, _ := json.Marshal(defaultOutbound)
|
||||
defaultOutbounds = append(defaultOutbounds, jsonBytes)
|
||||
}
|
||||
}
|
||||
|
||||
if rules != "" {
|
||||
var newRules []any
|
||||
routing, _ := configJson["routing"].(map[string]any)
|
||||
defaultRules, _ := routing["rules"].([]any)
|
||||
json.Unmarshal([]byte(rules), &newRules)
|
||||
defaultRules = append(newRules, defaultRules...)
|
||||
routing["rules"] = defaultRules
|
||||
configJson["routing"] = routing
|
||||
}
|
||||
|
||||
return &SubJsonService{
|
||||
configJson: configJson,
|
||||
defaultOutbounds: defaultOutbounds,
|
||||
finalMask: finalMask,
|
||||
mux: mux,
|
||||
SubService: subService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetJson generates a JSON subscription configuration for the given subscription ID and host.
|
||||
func (s *SubJsonService) GetJson(subId string, host string) (string, string, error) {
|
||||
// Set per-request state on the shared SubService so any
|
||||
// resolveInboundAddress call inside picks node-aware host values.
|
||||
s.SubService.PrepareForRequest(host)
|
||||
inbounds, err := s.SubService.getInboundsBySubId(subId)
|
||||
if err != nil || len(inbounds) == 0 {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
var header string
|
||||
var configArray []json_util.RawMessage
|
||||
|
||||
seenEmails := make(map[string]struct{})
|
||||
// Prepare Inbounds
|
||||
for _, inbound := range inbounds {
|
||||
clients, err := s.inboundService.GetClients(inbound)
|
||||
if err != nil {
|
||||
logger.Error("SubJsonService - GetClients: Unable to get clients from inbound")
|
||||
}
|
||||
if clients == nil {
|
||||
continue
|
||||
}
|
||||
s.SubService.projectThroughFallbackMaster(inbound)
|
||||
|
||||
for _, client := range clients {
|
||||
if client.SubID == subId {
|
||||
seenEmails[client.Email] = struct{}{}
|
||||
configArray = append(configArray, s.getConfig(inbound, client, host)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(configArray) == 0 {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
emails := make([]string, 0, len(seenEmails))
|
||||
for e := range seenEmails {
|
||||
emails = append(emails, e)
|
||||
}
|
||||
traffic, _ := s.SubService.AggregateTrafficByEmails(emails)
|
||||
|
||||
// Combile outbounds
|
||||
var finalJson []byte
|
||||
if len(configArray) == 1 {
|
||||
finalJson, _ = json.MarshalIndent(configArray[0], "", " ")
|
||||
} else {
|
||||
finalJson, _ = json.MarshalIndent(configArray, "", " ")
|
||||
}
|
||||
|
||||
header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
||||
return string(finalJson), header, nil
|
||||
}
|
||||
|
||||
func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client, host string) []json_util.RawMessage {
|
||||
var newJsonArray []json_util.RawMessage
|
||||
stream := s.streamData(inbound.StreamSettings)
|
||||
|
||||
// When externalProxy is empty the JSON config falls back to a
|
||||
// synthetic one whose `dest` is the host the client connects to.
|
||||
// For node-managed inbounds we want the node's address — request
|
||||
// host won't reach the right xray. resolveInboundAddress already
|
||||
// implements the node→subscriber-host fallback chain.
|
||||
defaultDest := s.SubService.resolveInboundAddress(inbound)
|
||||
if defaultDest == "" {
|
||||
defaultDest = host
|
||||
}
|
||||
|
||||
externalProxies, ok := stream["externalProxy"].([]any)
|
||||
hasExternalProxy := ok && len(externalProxies) > 0
|
||||
if !hasExternalProxy {
|
||||
externalProxies = []any{
|
||||
map[string]any{
|
||||
"forceTls": "same",
|
||||
"dest": defaultDest,
|
||||
"port": float64(inbound.Port),
|
||||
"remark": "",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
delete(stream, "externalProxy")
|
||||
|
||||
for _, ep := range externalProxies {
|
||||
extPrxy := ep.(map[string]any)
|
||||
inbound.Listen = extPrxy["dest"].(string)
|
||||
inbound.Port = int(extPrxy["port"].(float64))
|
||||
newStream := cloneStreamForExternalProxy(stream)
|
||||
switch extPrxy["forceTls"].(string) {
|
||||
case "tls":
|
||||
if newStream["security"] != "tls" {
|
||||
newStream["security"] = "tls"
|
||||
newStream["tlsSettings"] = map[string]any{}
|
||||
}
|
||||
case "none":
|
||||
if newStream["security"] != "none" {
|
||||
newStream["security"] = "none"
|
||||
delete(newStream, "tlsSettings")
|
||||
}
|
||||
}
|
||||
security, _ := newStream["security"].(string)
|
||||
if hasExternalProxy {
|
||||
applyExternalProxyTLSToStream(extPrxy, newStream, security)
|
||||
}
|
||||
streamSettings, _ := json.MarshalIndent(newStream, "", " ")
|
||||
|
||||
var newOutbounds []json_util.RawMessage
|
||||
|
||||
switch inbound.Protocol {
|
||||
case "vmess":
|
||||
newOutbounds = append(newOutbounds, s.genVnext(inbound, streamSettings, client))
|
||||
case "vless":
|
||||
newOutbounds = append(newOutbounds, s.genVless(inbound, streamSettings, client))
|
||||
case "trojan", "shadowsocks":
|
||||
newOutbounds = append(newOutbounds, s.genServer(inbound, streamSettings, client))
|
||||
case "hysteria":
|
||||
newOutbounds = append(newOutbounds, s.genHy(inbound, newStream, client))
|
||||
}
|
||||
|
||||
newOutbounds = append(newOutbounds, s.defaultOutbounds...)
|
||||
newConfigJson := make(map[string]any)
|
||||
maps.Copy(newConfigJson, s.configJson)
|
||||
|
||||
newConfigJson["outbounds"] = newOutbounds
|
||||
newConfigJson["remarks"] = s.SubService.genRemark(inbound, client.Email, extPrxy["remark"].(string))
|
||||
|
||||
newConfig, _ := json.MarshalIndent(newConfigJson, "", " ")
|
||||
newJsonArray = append(newJsonArray, newConfig)
|
||||
}
|
||||
|
||||
return newJsonArray
|
||||
}
|
||||
|
||||
func (s *SubJsonService) streamData(stream string) map[string]any {
|
||||
var streamSettings map[string]any
|
||||
json.Unmarshal([]byte(stream), &streamSettings)
|
||||
security, _ := streamSettings["security"].(string)
|
||||
switch security {
|
||||
case "tls":
|
||||
streamSettings["tlsSettings"] = s.tlsData(streamSettings["tlsSettings"].(map[string]any))
|
||||
case "reality":
|
||||
streamSettings["realitySettings"] = s.realityData(streamSettings["realitySettings"].(map[string]any))
|
||||
}
|
||||
delete(streamSettings, "sockopt")
|
||||
|
||||
if s.finalMask != "" {
|
||||
s.applyGlobalFinalMask(streamSettings)
|
||||
}
|
||||
|
||||
// remove proxy protocol
|
||||
network, _ := streamSettings["network"].(string)
|
||||
switch network {
|
||||
case "tcp":
|
||||
streamSettings["tcpSettings"] = s.removeAcceptProxy(streamSettings["tcpSettings"])
|
||||
case "ws":
|
||||
streamSettings["wsSettings"] = s.removeAcceptProxy(streamSettings["wsSettings"])
|
||||
case "httpupgrade":
|
||||
streamSettings["httpupgradeSettings"] = s.removeAcceptProxy(streamSettings["httpupgradeSettings"])
|
||||
case "xhttp":
|
||||
streamSettings["xhttpSettings"] = s.removeAcceptProxy(streamSettings["xhttpSettings"])
|
||||
if xhttp, ok := streamSettings["xhttpSettings"].(map[string]any); ok {
|
||||
delete(xhttp, "noSSEHeader")
|
||||
delete(xhttp, "scMaxBufferedPosts")
|
||||
delete(xhttp, "scStreamUpServerSecs")
|
||||
delete(xhttp, "serverMaxHeaderBytes")
|
||||
}
|
||||
}
|
||||
return streamSettings
|
||||
}
|
||||
|
||||
func (s *SubJsonService) applyGlobalFinalMask(streamSettings map[string]any) {
|
||||
var fm map[string]any
|
||||
if err := json.Unmarshal([]byte(s.finalMask), &fm); err != nil || len(fm) == 0 {
|
||||
return
|
||||
}
|
||||
merged := mergeFinalMask(streamSettings["finalmask"], fm)
|
||||
if len(merged) > 0 {
|
||||
streamSettings["finalmask"] = merged
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SubJsonService) removeAcceptProxy(setting any) map[string]any {
|
||||
netSettings, ok := setting.(map[string]any)
|
||||
if ok {
|
||||
delete(netSettings, "acceptProxyProtocol")
|
||||
}
|
||||
return netSettings
|
||||
}
|
||||
|
||||
func (s *SubJsonService) 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
|
||||
}
|
||||
if ech, ok := tlsClientSettings["echConfigList"].(string); ok && ech != "" {
|
||||
tlsData["echConfigList"] = ech
|
||||
}
|
||||
if pins, ok := tlsClientSettings["pinnedPeerCertSha256"].([]any); ok && len(pins) > 0 {
|
||||
tlsData["pinnedPeerCertSha256"] = pins
|
||||
}
|
||||
return tlsData
|
||||
}
|
||||
|
||||
func (s *SubJsonService) realityData(rData map[string]any) map[string]any {
|
||||
rltyData := make(map[string]any, 1)
|
||||
rltyClientSettings, _ := rData["settings"].(map[string]any)
|
||||
|
||||
rltyData["show"] = false
|
||||
rltyData["publicKey"] = rltyClientSettings["publicKey"]
|
||||
rltyData["fingerprint"] = rltyClientSettings["fingerprint"]
|
||||
rltyData["mldsa65Verify"] = rltyClientSettings["mldsa65Verify"]
|
||||
|
||||
// Set random data
|
||||
rltyData["spiderX"] = "/" + random.Seq(15)
|
||||
shortIds, ok := rData["shortIds"].([]any)
|
||||
if ok && len(shortIds) > 0 {
|
||||
rltyData["shortId"] = shortIds[random.Num(len(shortIds))].(string)
|
||||
} else {
|
||||
rltyData["shortId"] = ""
|
||||
}
|
||||
serverNames, ok := rData["serverNames"].([]any)
|
||||
if ok && len(serverNames) > 0 {
|
||||
rltyData["serverName"] = serverNames[random.Num(len(serverNames))].(string)
|
||||
} else {
|
||||
rltyData["serverName"] = ""
|
||||
}
|
||||
|
||||
return rltyData
|
||||
}
|
||||
|
||||
func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client) json_util.RawMessage {
|
||||
outbound := Outbound{}
|
||||
|
||||
outbound.Protocol = string(inbound.Protocol)
|
||||
outbound.Tag = "proxy"
|
||||
if s.mux != "" {
|
||||
outbound.Mux = json_util.RawMessage(s.mux)
|
||||
}
|
||||
outbound.StreamSettings = streamSettings
|
||||
|
||||
security := client.Security
|
||||
if security == "" {
|
||||
security = "auto"
|
||||
}
|
||||
outbound.Settings = map[string]any{
|
||||
"address": inbound.Listen,
|
||||
"port": inbound.Port,
|
||||
"id": client.ID,
|
||||
"security": security,
|
||||
"level": 8,
|
||||
}
|
||||
|
||||
result, _ := json.MarshalIndent(outbound, "", " ")
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client) json_util.RawMessage {
|
||||
outbound := Outbound{}
|
||||
outbound.Protocol = string(inbound.Protocol)
|
||||
outbound.Tag = "proxy"
|
||||
if s.mux != "" {
|
||||
outbound.Mux = json_util.RawMessage(s.mux)
|
||||
}
|
||||
outbound.StreamSettings = streamSettings
|
||||
|
||||
// Add encryption for VLESS outbound from inbound settings
|
||||
var inboundSettings map[string]any
|
||||
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
||||
encryption, _ := inboundSettings["encryption"].(string)
|
||||
|
||||
settings := map[string]any{
|
||||
"address": inbound.Listen,
|
||||
"port": inbound.Port,
|
||||
"id": client.ID,
|
||||
"encryption": encryption,
|
||||
"level": 8,
|
||||
}
|
||||
if client.Flow != "" {
|
||||
settings["flow"] = client.Flow
|
||||
}
|
||||
outbound.Settings = settings
|
||||
result, _ := json.MarshalIndent(outbound, "", " ")
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client) json_util.RawMessage {
|
||||
outbound := Outbound{}
|
||||
|
||||
serverData := make([]ServerSetting, 1)
|
||||
serverData[0] = ServerSetting{
|
||||
Address: inbound.Listen,
|
||||
Port: inbound.Port,
|
||||
Level: 8,
|
||||
Password: client.Password,
|
||||
}
|
||||
|
||||
if inbound.Protocol == model.Shadowsocks {
|
||||
var inboundSettings map[string]any
|
||||
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
||||
method, _ := inboundSettings["method"].(string)
|
||||
serverData[0].Method = method
|
||||
|
||||
// server password in multi-user 2022 protocols
|
||||
if strings.HasPrefix(method, "2022") {
|
||||
if serverPassword, ok := inboundSettings["password"].(string); ok {
|
||||
serverData[0].Password = fmt.Sprintf("%s:%s", serverPassword, client.Password)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outbound.Protocol = string(inbound.Protocol)
|
||||
outbound.Tag = "proxy"
|
||||
if s.mux != "" {
|
||||
outbound.Mux = json_util.RawMessage(s.mux)
|
||||
}
|
||||
outbound.StreamSettings = streamSettings
|
||||
|
||||
settings := map[string]any{
|
||||
"address": serverData[0].Address,
|
||||
"port": serverData[0].Port,
|
||||
"password": serverData[0].Password,
|
||||
"level": 8,
|
||||
}
|
||||
if inbound.Protocol == model.Shadowsocks {
|
||||
settings["method"] = serverData[0].Method
|
||||
}
|
||||
outbound.Settings = settings
|
||||
|
||||
result, _ := json.MarshalIndent(outbound, "", " ")
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, client model.Client) json_util.RawMessage {
|
||||
outbound := Outbound{}
|
||||
|
||||
outbound.Protocol = string(inbound.Protocol)
|
||||
outbound.Tag = "proxy"
|
||||
|
||||
if s.mux != "" {
|
||||
outbound.Mux = json_util.RawMessage(s.mux)
|
||||
}
|
||||
|
||||
var settings, stream map[string]any
|
||||
json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||
version, _ := settings["version"].(float64)
|
||||
outbound.Settings = map[string]any{
|
||||
"version": int(version),
|
||||
"address": inbound.Listen,
|
||||
"port": inbound.Port,
|
||||
}
|
||||
|
||||
json.Unmarshal([]byte(inbound.StreamSettings), &stream)
|
||||
hyStream := stream["hysteriaSettings"].(map[string]any)
|
||||
outHyStream := map[string]any{
|
||||
"version": int(version),
|
||||
"auth": client.Auth,
|
||||
}
|
||||
if udpIdleTimeout, ok := hyStream["udpIdleTimeout"].(float64); ok {
|
||||
outHyStream["udpIdleTimeout"] = int(udpIdleTimeout)
|
||||
}
|
||||
if masquerade, ok := hyStream["masquerade"].(map[string]any); ok {
|
||||
outHyStream["masquerade"] = masquerade
|
||||
}
|
||||
newStream["hysteriaSettings"] = outHyStream
|
||||
|
||||
if finalmask, ok := hyStream["finalmask"].(map[string]any); ok {
|
||||
newStream["finalmask"] = mergeFinalMask(newStream["finalmask"], finalmask)
|
||||
}
|
||||
|
||||
newStream["network"] = "hysteria"
|
||||
newStream["security"] = "tls"
|
||||
|
||||
outbound.StreamSettings, _ = json.MarshalIndent(newStream, "", " ")
|
||||
|
||||
result, _ := json.MarshalIndent(outbound, "", " ")
|
||||
return result
|
||||
}
|
||||
|
||||
func mergeFinalMask(base any, extra map[string]any) map[string]any {
|
||||
merged := map[string]any{}
|
||||
if baseMap, ok := base.(map[string]any); ok {
|
||||
for key, value := range baseMap {
|
||||
switch key {
|
||||
case "tcp", "udp":
|
||||
if masks, ok := value.([]any); ok {
|
||||
merged[key] = append([]any(nil), masks...)
|
||||
}
|
||||
default:
|
||||
merged[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for key, value := range extra {
|
||||
switch key {
|
||||
case "tcp", "udp":
|
||||
baseMasks, _ := merged[key].([]any)
|
||||
extraMasks, _ := value.([]any)
|
||||
if len(extraMasks) > 0 {
|
||||
merged[key] = append(baseMasks, extraMasks...)
|
||||
}
|
||||
case "quicParams":
|
||||
if _, exists := merged[key]; !exists {
|
||||
merged[key] = value
|
||||
}
|
||||
default:
|
||||
merged[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
type Outbound struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Tag string `json:"tag"`
|
||||
StreamSettings json_util.RawMessage `json:"streamSettings"`
|
||||
Mux json_util.RawMessage `json:"mux,omitempty"`
|
||||
Settings map[string]any `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
type ServerSetting struct {
|
||||
Password string `json:"password"`
|
||||
Level int `json:"level"`
|
||||
Address string `json:"address"`
|
||||
Port int `json:"port"`
|
||||
Flow string `json:"flow,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func hasDirectOutOutbound(svc *SubJsonService) bool {
|
||||
for _, raw := range svc.defaultOutbounds {
|
||||
var outbound map[string]any
|
||||
if err := json.Unmarshal(raw, &outbound); err != nil {
|
||||
continue
|
||||
}
|
||||
if outbound["tag"] == "direct_out" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func outboundSettings(t *testing.T, raw []byte) map[string]any {
|
||||
t.Helper()
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
t.Fatalf("failed to unmarshal outbound: %v", err)
|
||||
}
|
||||
settings, _ := parsed["settings"].(map[string]any)
|
||||
if settings == nil {
|
||||
t.Fatal("outbound has no settings")
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
|
||||
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","length":"100-200","delay":"10-20"}}],"udp":[{"type":"noise","settings":{"noise":[{"type":"base64","packet":"SGVsbG8="}]}}],"quicParams":{"congestion":"bbr"}}`
|
||||
svc := NewSubJsonService("", "", finalMask, nil)
|
||||
|
||||
if hasDirectOutOutbound(svc) {
|
||||
t.Fatal("direct_out outbound must never be emitted")
|
||||
}
|
||||
|
||||
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
|
||||
if _, ok := stream["sockopt"]; ok {
|
||||
t.Fatal("legacy direct_out dialerProxy sockopt must never be set")
|
||||
}
|
||||
|
||||
finalmask, _ := stream["finalmask"].(map[string]any)
|
||||
if finalmask == nil {
|
||||
t.Fatal("streamSettings is missing finalmask")
|
||||
}
|
||||
|
||||
tcp, _ := finalmask["tcp"].([]any)
|
||||
if len(tcp) != 1 {
|
||||
t.Fatalf("tcp masks len = %d, want 1", len(tcp))
|
||||
}
|
||||
if first, _ := tcp[0].(map[string]any); first["type"] != "fragment" {
|
||||
t.Fatalf("tcp[0] type = %v, want fragment", first["type"])
|
||||
}
|
||||
|
||||
udp, _ := finalmask["udp"].([]any)
|
||||
if len(udp) != 1 {
|
||||
t.Fatalf("udp masks len = %d, want 1", len(udp))
|
||||
}
|
||||
|
||||
quic, _ := finalmask["quicParams"].(map[string]any)
|
||||
if quic == nil || quic["congestion"] != "bbr" {
|
||||
t.Fatalf("quicParams missing/wrong: %#v", finalmask["quicParams"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
|
||||
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}`
|
||||
svc := NewSubJsonService("", "", finalMask, nil)
|
||||
|
||||
stream := svc.streamData(`{
|
||||
"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}},
|
||||
"finalmask":{"tcp":[{"type":"sudoku"}]}
|
||||
}`)
|
||||
|
||||
finalmask, _ := stream["finalmask"].(map[string]any)
|
||||
tcp, _ := finalmask["tcp"].([]any)
|
||||
if len(tcp) != 2 {
|
||||
t.Fatalf("tcp masks len = %d, want 2 (existing + global)", len(tcp))
|
||||
}
|
||||
a, _ := tcp[0].(map[string]any)
|
||||
b, _ := tcp[1].(map[string]any)
|
||||
if a["type"] != "sudoku" || b["type"] != "fragment" {
|
||||
t.Fatalf("tcp masks = %#v, want existing sudoku then global fragment", tcp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
|
||||
svc := NewSubJsonService("", "", "", nil)
|
||||
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
|
||||
if _, ok := stream["finalmask"]; ok {
|
||||
t.Fatal("no finalmask should be emitted when subJsonFinalMask is empty")
|
||||
}
|
||||
if _, ok := stream["sockopt"]; ok {
|
||||
t.Fatal("legacy direct_out sockopt must never be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubJsonServiceVlessFlattened(t *testing.T) {
|
||||
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
|
||||
client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
|
||||
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(inbound, nil, client))
|
||||
if _, ok := settings["vnext"]; ok {
|
||||
t.Fatal("vless outbound must not use vnext")
|
||||
}
|
||||
if settings["address"] != "1.2.3.4" || settings["id"] != "uuid-1" || settings["encryption"] != "none" || settings["flow"] != "xtls-rprx-vision" {
|
||||
t.Fatalf("flat vless settings wrong: %#v", settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubJsonServiceVmessFlattened(t *testing.T) {
|
||||
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VMESS, Settings: `{}`}
|
||||
client := model.Client{ID: "uuid-2"}
|
||||
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVnext(inbound, nil, client))
|
||||
if _, ok := settings["vnext"]; ok {
|
||||
t.Fatal("vmess outbound must not use vnext")
|
||||
}
|
||||
if settings["id"] != "uuid-2" || settings["security"] != "auto" {
|
||||
t.Fatalf("flat vmess settings wrong: %#v", settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubJsonServiceServerFlattened(t *testing.T) {
|
||||
trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`}
|
||||
client := model.Client{Password: "p4ss"}
|
||||
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(trojan, nil, client))
|
||||
if _, ok := settings["servers"]; ok {
|
||||
t.Fatal("trojan outbound must not use servers array")
|
||||
}
|
||||
if settings["password"] != "p4ss" || settings["address"] != "1.2.3.4" {
|
||||
t.Fatalf("flat trojan settings wrong: %#v", settings)
|
||||
}
|
||||
|
||||
ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`}
|
||||
ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(ss, nil, client))
|
||||
if ssSettings["method"] != "aes-256-gcm" {
|
||||
t.Fatalf("flat shadowsocks must carry method: %#v", ssSettings)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
type LinkProvider struct {
|
||||
settingService service.SettingService
|
||||
}
|
||||
|
||||
func NewLinkProvider() *LinkProvider {
|
||||
return &LinkProvider{}
|
||||
}
|
||||
|
||||
func (p *LinkProvider) build(host string) *SubService {
|
||||
showInfo, _ := p.settingService.GetSubShowInfo()
|
||||
rModel, err := p.settingService.GetRemarkModel()
|
||||
if err != nil {
|
||||
rModel = "-io"
|
||||
}
|
||||
svc := NewSubService(showInfo, rModel)
|
||||
svc.PrepareForRequest(host)
|
||||
return svc
|
||||
}
|
||||
|
||||
func (p *LinkProvider) SubLinksForSubId(host, subId string) ([]string, error) {
|
||||
svc := p.build(host)
|
||||
links, _, _, _, err := svc.GetSubs(subId, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]string, 0, len(links))
|
||||
for _, l := range links {
|
||||
out = append(out, splitLinkLines(l)...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *LinkProvider) LinksForClient(host string, inbound *model.Inbound, email string) []string {
|
||||
svc := p.build(host)
|
||||
svc.projectThroughFallbackMaster(inbound)
|
||||
return splitLinkLines(svc.GetLink(inbound, email))
|
||||
}
|
||||
|
||||
func splitLinkLines(raw string) []string {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, "\n")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSplitLinkLines(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"single_line", "vless://abc", []string{"vless://abc"}},
|
||||
{"two_lines", "vless://abc\nvmess://xyz", []string{"vless://abc", "vmess://xyz"}},
|
||||
{"trims_each_line", " vless://abc \n\tvmess://xyz\t", []string{"vless://abc", "vmess://xyz"}},
|
||||
{"skips_blank_lines", "vless://abc\n\n\nvmess://xyz\n", []string{"vless://abc", "vmess://xyz"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := splitLinkLines(c.in)
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Fatalf("splitLinkLines(%q) = %#v, want %#v", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLinkLines_EmptyInputIsNil(t *testing.T) {
|
||||
if got := splitLinkLines(""); got != nil {
|
||||
t.Fatalf("splitLinkLines(\"\") = %#v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLinkLines_WhitespaceOnlyHasNoEntries(t *testing.T) {
|
||||
got := splitLinkLines(" \n\t \n")
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("splitLinkLines(whitespace) = %#v, want empty slice", got)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,999 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestSubscriptionExpiryFromClient(t *testing.T) {
|
||||
const now = int64(1_700_000_000_000)
|
||||
const oneDayMs = int64(86_400_000)
|
||||
if got := subscriptionExpiryFromClient(now, 0); got != 0 {
|
||||
t.Fatalf("zero expiry should stay zero, got %d", got)
|
||||
}
|
||||
if got := subscriptionExpiryFromClient(now, 1_700_000_000_000); got != 1_700_000_000_000 {
|
||||
t.Fatalf("positive expiry should pass through, got %d", got)
|
||||
}
|
||||
if got := subscriptionExpiryFromClient(now, -oneDayMs); got != now+oneDayMs {
|
||||
t.Fatalf("delayed-start expiry should be now+|value|, got %d, want %d", got, now+oneDayMs)
|
||||
}
|
||||
if a, b := subscriptionExpiryFromClient(now, -oneDayMs), subscriptionExpiryFromClient(now, -oneDayMs); a != b {
|
||||
t.Fatalf("same now+value should be deterministic across calls, got %d vs %d (#4545 review)", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindClientIndex(t *testing.T) {
|
||||
clients := []model.Client{
|
||||
{Email: "a@example.com"},
|
||||
{Email: "b@example.com"},
|
||||
{Email: "c@example.com"},
|
||||
}
|
||||
if got := findClientIndex(clients, "b@example.com"); got != 1 {
|
||||
t.Fatalf("findClientIndex middle = %d, want 1", got)
|
||||
}
|
||||
if got := findClientIndex(clients, "a@example.com"); got != 0 {
|
||||
t.Fatalf("findClientIndex first = %d, want 0", got)
|
||||
}
|
||||
if got := findClientIndex(clients, "missing@example.com"); got != -1 {
|
||||
t.Fatalf("findClientIndex missing = %d, want -1", got)
|
||||
}
|
||||
if got := findClientIndex(nil, "x"); got != -1 {
|
||||
t.Fatalf("findClientIndex on nil slice = %d, want -1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRoutableHost(t *testing.T) {
|
||||
routable := []string{"example.com", "sub.example.com", "10.0.0.1", "192.168.1.5", "1.2.3.4", "2001:db8::1"}
|
||||
for _, v := range routable {
|
||||
if !isRoutableHost(v) {
|
||||
t.Fatalf("isRoutableHost(%q) = false, want true", v)
|
||||
}
|
||||
}
|
||||
notRoutable := []string{"", "0.0.0.0", "::", "::0", "127.0.0.1", "127.0.0.2", "::1", "[::1]"}
|
||||
for _, v := range notRoutable {
|
||||
if isRoutableHost(v) {
|
||||
t.Fatalf("isRoutableHost(%q) = true, want false", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenIsInternalOnly(t *testing.T) {
|
||||
// Reachable only from the same host -> a fallback child here must be
|
||||
// projected through its master.
|
||||
internalOnly := []string{"127.0.0.1", "127.0.0.2", "::1", "[::1]", "@fallback", "/run/x.sock"}
|
||||
for _, v := range internalOnly {
|
||||
if !listenIsInternalOnly(v) {
|
||||
t.Fatalf("listenIsInternalOnly(%q) = false, want true", v)
|
||||
}
|
||||
}
|
||||
// Directly reachable on its own port -> never projected, even if a stale
|
||||
// fallback rule names it as a child (#4987).
|
||||
reachable := []string{"", "0.0.0.0", "::", "::0", "1.2.3.4", "10.0.0.5", "192.168.1.10", "vpn.example.com"}
|
||||
for _, v := range reachable {
|
||||
if listenIsInternalOnly(v) {
|
||||
t.Fatalf("listenIsInternalOnly(%q) = true, want false", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInboundAddress(t *testing.T) {
|
||||
const reqHost = "sub.example.com"
|
||||
|
||||
// A routable bind Listen (a real IP or hostname the operator set as the
|
||||
// inbound's advertised endpoint) becomes the link's connect host.
|
||||
t.Run("routable listen is advertised as the link host", func(t *testing.T) {
|
||||
s := &SubService{address: reqHost}
|
||||
for _, listen := range []string{"1.2.3.4", "10.0.0.5", "192.168.1.10", "203.0.113.7", "vpn.example.com"} {
|
||||
ib := &model.Inbound{Listen: listen}
|
||||
if got := s.resolveInboundAddress(ib); got != listen {
|
||||
t.Fatalf("listen %q: address = %q, want %q (advertised listen)", listen, got, listen)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// A loopback/wildcard bind or a unix-domain-socket listen is a
|
||||
// server-side detail and must never leak into the link host.
|
||||
t.Run("non-routable listen falls back to subscriber host", func(t *testing.T) {
|
||||
s := &SubService{address: reqHost}
|
||||
for _, listen := range []string{"", "0.0.0.0", "::", "::0", "127.0.0.1", "::1", "@fallback", "/run/x.sock"} {
|
||||
ib := &model.Inbound{Listen: listen}
|
||||
if got := s.resolveInboundAddress(ib); got != reqHost {
|
||||
t.Fatalf("listen %q: address = %q, want %q (subscriber host, not bind detail)", listen, got, reqHost)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("node-managed inbound uses the node address", func(t *testing.T) {
|
||||
id := 7
|
||||
s := &SubService{
|
||||
address: reqHost,
|
||||
nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
|
||||
}
|
||||
ib := &model.Inbound{NodeID: &id, Listen: "1.2.3.4"}
|
||||
if got := s.resolveInboundAddress(ib); got != "node7.example.com" {
|
||||
t.Fatalf("node-managed address = %q, want node7.example.com", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("node id with no known node falls back to subscriber host", func(t *testing.T) {
|
||||
id := 9
|
||||
s := &SubService{address: reqHost, nodesByID: map[int]*model.Node{}}
|
||||
ib := &model.Inbound{NodeID: &id, Listen: "0.0.0.0"}
|
||||
if got := s.resolveInboundAddress(ib); got != reqHost {
|
||||
t.Fatalf("unknown-node address = %q, want subscriber host %q", got, reqHost)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUnmarshalStreamSettings(t *testing.T) {
|
||||
got := unmarshalStreamSettings(`{"network":"ws","wsSettings":{"path":"/api"}}`)
|
||||
if got["network"] != "ws" {
|
||||
t.Fatalf("network = %v, want ws", got["network"])
|
||||
}
|
||||
ws, ok := got["wsSettings"].(map[string]any)
|
||||
if !ok || ws["path"] != "/api" {
|
||||
t.Fatalf("wsSettings = %v, want map with path=/api", got["wsSettings"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalStreamSettings_InvalidJSON(t *testing.T) {
|
||||
if got := unmarshalStreamSettings("not json"); got != nil {
|
||||
t.Fatalf("invalid JSON should produce nil map, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchHost_StringValue(t *testing.T) {
|
||||
headers := map[string]any{"Host": "example.com"}
|
||||
if got := searchHost(headers); got != "example.com" {
|
||||
t.Fatalf("searchHost = %q, want example.com", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchHost_CaseInsensitiveKey(t *testing.T) {
|
||||
headers := map[string]any{"host": "example.com"}
|
||||
if got := searchHost(headers); got != "example.com" {
|
||||
t.Fatalf("searchHost = %q, want example.com", got)
|
||||
}
|
||||
headers2 := map[string]any{"HOST": "example.com"}
|
||||
if got := searchHost(headers2); got != "example.com" {
|
||||
t.Fatalf("searchHost uppercase = %q, want example.com", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchHost_ArrayValue(t *testing.T) {
|
||||
headers := map[string]any{"Host": []any{"first.example.com", "second.example.com"}}
|
||||
if got := searchHost(headers); got != "first.example.com" {
|
||||
t.Fatalf("searchHost array = %q, want first.example.com", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchHost_EmptyArray(t *testing.T) {
|
||||
headers := map[string]any{"Host": []any{}}
|
||||
if got := searchHost(headers); got != "" {
|
||||
t.Fatalf("searchHost empty array = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchHost_NoHostKey(t *testing.T) {
|
||||
headers := map[string]any{"X-Other": "value"}
|
||||
if got := searchHost(headers); got != "" {
|
||||
t.Fatalf("searchHost no host = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchHost_NotAMap(t *testing.T) {
|
||||
if got := searchHost("not a map"); got != "" {
|
||||
t.Fatalf("searchHost non-map = %q, want empty", got)
|
||||
}
|
||||
if got := searchHost(nil); got != "" {
|
||||
t.Fatalf("searchHost nil = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchKey_FoundAtTopLevel(t *testing.T) {
|
||||
data := map[string]any{"foo": 42, "bar": "x"}
|
||||
got, ok := searchKey(data, "foo")
|
||||
if !ok {
|
||||
t.Fatal("expected to find foo")
|
||||
}
|
||||
if got != 42 {
|
||||
t.Fatalf("got %v, want 42", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchKey_FoundInNested(t *testing.T) {
|
||||
data := map[string]any{
|
||||
"outer": map[string]any{
|
||||
"inner": map[string]any{
|
||||
"target": "hit",
|
||||
},
|
||||
},
|
||||
}
|
||||
got, ok := searchKey(data, "target")
|
||||
if !ok {
|
||||
t.Fatal("expected to find target in nested map")
|
||||
}
|
||||
if got != "hit" {
|
||||
t.Fatalf("got %v, want hit", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchKey_FoundInsideArray(t *testing.T) {
|
||||
data := map[string]any{
|
||||
"list": []any{
|
||||
map[string]any{"other": 1},
|
||||
map[string]any{"needle": "found"},
|
||||
},
|
||||
}
|
||||
got, ok := searchKey(data, "needle")
|
||||
if !ok {
|
||||
t.Fatal("expected to find needle in array element")
|
||||
}
|
||||
if got != "found" {
|
||||
t.Fatalf("got %v, want found", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchKey_NotFound(t *testing.T) {
|
||||
data := map[string]any{"foo": "bar"}
|
||||
if _, ok := searchKey(data, "missing"); ok {
|
||||
t.Fatal("expected ok=false for missing key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchKey_OnScalar(t *testing.T) {
|
||||
if _, ok := searchKey(42, "anything"); ok {
|
||||
t.Fatal("expected ok=false searching on a scalar")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildXhttpExtra_IncludesClientSideFieldsWhenPresent(t *testing.T) {
|
||||
extra := buildXhttpExtra(map[string]any{
|
||||
"path": "/xhttp",
|
||||
"host": "example.com",
|
||||
"mode": "packet-up",
|
||||
"xPaddingBytes": "100-1000",
|
||||
"uplinkHTTPMethod": "GET",
|
||||
"uplinkChunkSize": float64(4096),
|
||||
"noGRPCHeader": true,
|
||||
"scMinPostsIntervalMs": "20-40",
|
||||
"xmux": map[string]any{
|
||||
"maxConcurrency": "16-32",
|
||||
"hMaxRequestTimes": "600-900",
|
||||
"hMaxReusableSecs": "1800-3000",
|
||||
"hKeepAlivePeriod": float64(15),
|
||||
},
|
||||
"downloadSettings": map[string]any{
|
||||
"network": "xhttp",
|
||||
},
|
||||
"headers": map[string]any{
|
||||
"Host": "ignored.example.com",
|
||||
"X-Forwarded": "1",
|
||||
"X-Test-Empty": "",
|
||||
},
|
||||
})
|
||||
|
||||
if extra["path"] != nil || extra["host"] != nil {
|
||||
t.Fatalf("path/host should stay top-level, got extra %#v", extra)
|
||||
}
|
||||
for _, key := range []string{
|
||||
"xPaddingBytes",
|
||||
"uplinkHTTPMethod",
|
||||
"uplinkChunkSize",
|
||||
"noGRPCHeader",
|
||||
"scMinPostsIntervalMs",
|
||||
"xmux",
|
||||
"downloadSettings",
|
||||
} {
|
||||
if _, ok := extra[key]; !ok {
|
||||
t.Fatalf("extra missing %q: %#v", key, extra)
|
||||
}
|
||||
}
|
||||
if _, ok := extra["mode"]; ok {
|
||||
t.Fatalf("mode should stay as a top-level query parameter, got extra %#v", extra)
|
||||
}
|
||||
|
||||
headers, ok := extra["headers"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("headers = %#v, want map", extra["headers"])
|
||||
}
|
||||
if _, ok := headers["Host"]; ok {
|
||||
t.Fatalf("headers should not include Host: %#v", headers)
|
||||
}
|
||||
if headers["X-Forwarded"] != "1" {
|
||||
t.Fatalf("headers[X-Forwarded] = %#v, want 1", headers["X-Forwarded"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildXhttpExtra_LeavesDefaultClientSideFieldsOut(t *testing.T) {
|
||||
extra := buildXhttpExtra(map[string]any{
|
||||
"uplinkHTTPMethod": "",
|
||||
"uplinkChunkSize": float64(0),
|
||||
"noGRPCHeader": false,
|
||||
"xmux": map[string]any{},
|
||||
"downloadSettings": map[string]any{},
|
||||
})
|
||||
if extra != nil {
|
||||
t.Fatalf("default-only xhttp extra = %#v, want nil", extra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneStringMap(t *testing.T) {
|
||||
src := map[string]string{"a": "1", "b": "2"}
|
||||
dst := cloneStringMap(src)
|
||||
if len(dst) != len(src) {
|
||||
t.Fatalf("clone length = %d, want %d", len(dst), len(src))
|
||||
}
|
||||
for k, v := range src {
|
||||
if dst[k] != v {
|
||||
t.Fatalf("clone[%q] = %q, want %q", k, dst[k], v)
|
||||
}
|
||||
}
|
||||
dst["a"] = "changed"
|
||||
if src["a"] == "changed" {
|
||||
t.Fatal("modifying clone leaked into source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneStringMap_Empty(t *testing.T) {
|
||||
dst := cloneStringMap(map[string]string{})
|
||||
if dst == nil {
|
||||
t.Fatal("clone of empty map should not be nil")
|
||||
}
|
||||
if len(dst) != 0 {
|
||||
t.Fatalf("clone of empty map should be empty, got %v", dst)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHostFromXFH_HostOnly(t *testing.T) {
|
||||
got, err := getHostFromXFH("example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "example.com" {
|
||||
t.Fatalf("got %q, want example.com", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHostFromXFH_HostWithPort(t *testing.T) {
|
||||
got, err := getHostFromXFH("example.com:8443")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "example.com" {
|
||||
t.Fatalf("got %q, want example.com", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHostFromXFH_IPv6WithPort(t *testing.T) {
|
||||
got, err := getHostFromXFH("[2606:4700::1111]:443")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "2606:4700::1111" {
|
||||
t.Fatalf("got %q, want 2606:4700::1111", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHostFromXFH_BadHostPort(t *testing.T) {
|
||||
if _, err := getHostFromXFH("example.com:8443:9999"); err == nil {
|
||||
t.Fatal("expected error for malformed host:port")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPositiveInt(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in any
|
||||
wantVal int
|
||||
wantOk bool
|
||||
}{
|
||||
{"int_positive", int(5), 5, true},
|
||||
{"int_zero", int(0), 0, false},
|
||||
{"int_negative", int(-3), -3, false},
|
||||
{"int32_positive", int32(7), 7, true},
|
||||
{"int64_positive", int64(99), 99, true},
|
||||
{"float64_positive", float64(12), 12, true},
|
||||
{"float64_zero", float64(0.0), 0, false},
|
||||
{"float64_negative", float64(-1.5), -1, false},
|
||||
{"float32_positive", float32(3), 3, true},
|
||||
{"string", "not a number", 0, false},
|
||||
{"nil", nil, 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
gotVal, gotOk := readPositiveInt(c.in)
|
||||
if gotVal != c.wantVal || gotOk != c.wantOk {
|
||||
t.Fatalf("readPositiveInt(%v) = (%d, %v), want (%d, %v)", c.in, gotVal, gotOk, c.wantVal, c.wantOk)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetStringParam(t *testing.T) {
|
||||
p := map[string]string{"existing": "value"}
|
||||
|
||||
setStringParam(p, "new", "hello")
|
||||
if p["new"] != "hello" {
|
||||
t.Fatalf("missing key after set: %v", p)
|
||||
}
|
||||
|
||||
setStringParam(p, "existing", "")
|
||||
if _, ok := p["existing"]; ok {
|
||||
t.Fatalf("empty value should delete the key, got %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetIntParam(t *testing.T) {
|
||||
p := map[string]string{"existing": "10"}
|
||||
|
||||
setIntParam(p, "n", 42)
|
||||
if p["n"] != "42" {
|
||||
t.Fatalf("set positive int: got %v", p)
|
||||
}
|
||||
|
||||
setIntParam(p, "existing", 0)
|
||||
if _, ok := p["existing"]; ok {
|
||||
t.Fatalf("zero value should delete the key, got %v", p)
|
||||
}
|
||||
|
||||
p["other"] = "5"
|
||||
setIntParam(p, "other", -1)
|
||||
if _, ok := p["other"]; ok {
|
||||
t.Fatalf("negative value should delete the key, got %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetStringField(t *testing.T) {
|
||||
f := map[string]any{"existing": "value"}
|
||||
|
||||
setStringField(f, "new", "hello")
|
||||
if f["new"] != "hello" {
|
||||
t.Fatalf("missing key after set: %v", f)
|
||||
}
|
||||
|
||||
setStringField(f, "existing", "")
|
||||
if _, ok := f["existing"]; ok {
|
||||
t.Fatalf("empty value should delete the key, got %v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetIntField(t *testing.T) {
|
||||
f := map[string]any{"existing": 10}
|
||||
|
||||
setIntField(f, "n", 7)
|
||||
if f["n"] != 7 {
|
||||
t.Fatalf("set positive int: got %v", f)
|
||||
}
|
||||
|
||||
setIntField(f, "existing", 0)
|
||||
if _, ok := f["existing"]; ok {
|
||||
t.Fatalf("zero value should delete the key, got %v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVmessLink(t *testing.T) {
|
||||
obj := map[string]any{
|
||||
"v": "2",
|
||||
"ps": "remark",
|
||||
"add": "example.com",
|
||||
"port": 443,
|
||||
"net": "tcp",
|
||||
}
|
||||
link := buildVmessLink(obj)
|
||||
if !strings.HasPrefix(link, "vmess://") {
|
||||
t.Fatalf("missing vmess:// prefix: %q", link)
|
||||
}
|
||||
payload := strings.TrimPrefix(link, "vmess://")
|
||||
decoded, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("base64 decode failed: %v", err)
|
||||
}
|
||||
var roundTrip map[string]any
|
||||
if err := json.Unmarshal(decoded, &roundTrip); err != nil {
|
||||
t.Fatalf("decoded payload is not JSON: %v\n%s", err, decoded)
|
||||
}
|
||||
if roundTrip["add"] != "example.com" {
|
||||
t.Fatalf("round-trip add = %v, want example.com", roundTrip["add"])
|
||||
}
|
||||
if roundTrip["ps"] != "remark" {
|
||||
t.Fatalf("round-trip ps = %v, want remark", roundTrip["ps"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneVmessShareObj_CopiesEverythingByDefault(t *testing.T) {
|
||||
base := map[string]any{
|
||||
"v": "2",
|
||||
"sni": "example.com",
|
||||
"alpn": "h2",
|
||||
"fp": "chrome",
|
||||
"net": "tcp",
|
||||
}
|
||||
out := cloneVmessShareObj(base, "tls")
|
||||
for _, key := range []string{"sni", "alpn", "fp", "net", "v"} {
|
||||
if _, ok := out[key]; !ok {
|
||||
t.Fatalf("expected key %q to be preserved when security=tls, got %v", key, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneVmessShareObj_NoneStripsTLSOnlyKeys(t *testing.T) {
|
||||
base := map[string]any{
|
||||
"v": "2",
|
||||
"sni": "example.com",
|
||||
"alpn": "h2",
|
||||
"fp": "chrome",
|
||||
"net": "tcp",
|
||||
}
|
||||
out := cloneVmessShareObj(base, "none")
|
||||
for _, key := range []string{"sni", "alpn", "fp"} {
|
||||
if _, ok := out[key]; ok {
|
||||
t.Fatalf("security=none should strip %q, got %v", key, out)
|
||||
}
|
||||
}
|
||||
if out["v"] != "2" || out["net"] != "tcp" {
|
||||
t.Fatalf("non-TLS keys should remain, got %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExternalProxyTLSParams_UsesProxyDomainAndOverrides(t *testing.T) {
|
||||
params := map[string]string{
|
||||
"security": "tls",
|
||||
"sni": "origin.example.com",
|
||||
"fp": "firefox",
|
||||
"alpn": "h2",
|
||||
}
|
||||
ep := map[string]any{
|
||||
"dest": "proxy.example.com",
|
||||
"sni": "tls.example.com",
|
||||
"fingerprint": "chrome",
|
||||
"alpn": []any{"h3", "h2"},
|
||||
}
|
||||
|
||||
applyExternalProxyTLSParams(ep, params, "tls")
|
||||
|
||||
if params["sni"] != "tls.example.com" {
|
||||
t.Fatalf("sni = %q, want tls.example.com", params["sni"])
|
||||
}
|
||||
if params["fp"] != "chrome" {
|
||||
t.Fatalf("fp = %q, want chrome", params["fp"])
|
||||
}
|
||||
if params["alpn"] != "h3,h2" {
|
||||
t.Fatalf("alpn = %q, want h3,h2", params["alpn"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExternalProxyTLSParams_PreservesUpstreamSNI(t *testing.T) {
|
||||
// External-proxy entry has no SNI of its own; its dest must not
|
||||
// clobber the upstream tlsSettings.serverName already written into
|
||||
// params. Regression: the dest fallback used to overwrite "222" with
|
||||
// "111" whenever an operator set forceTls=same and left the proxy's
|
||||
// SNI field blank.
|
||||
params := map[string]string{"security": "tls", "sni": "real.example.com"}
|
||||
ep := map[string]any{"dest": "proxy.example.com"}
|
||||
|
||||
applyExternalProxyTLSParams(ep, params, "tls")
|
||||
|
||||
if params["sni"] != "real.example.com" {
|
||||
t.Fatalf("sni = %q, want upstream sni preserved (real.example.com)", params["sni"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExternalProxyTLSParams_ExplicitSNIOverridesUpstream(t *testing.T) {
|
||||
params := map[string]string{"security": "tls", "sni": "real.example.com"}
|
||||
ep := map[string]any{"dest": "proxy.example.com", "sni": "edge.example.com"}
|
||||
|
||||
applyExternalProxyTLSParams(ep, params, "tls")
|
||||
|
||||
if params["sni"] != "edge.example.com" {
|
||||
t.Fatalf("sni = %q, want edge.example.com", params["sni"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExternalProxy_ECHPropagates(t *testing.T) {
|
||||
const ech = "ech-config-base64"
|
||||
|
||||
t.Run("url params", func(t *testing.T) {
|
||||
params := map[string]string{"security": "tls"}
|
||||
ep := map[string]any{"dest": "proxy.example.com", "echConfigList": ech}
|
||||
applyExternalProxyTLSParams(ep, params, "tls")
|
||||
if params["ech"] != ech {
|
||||
t.Fatalf("ech param = %q, want %q", params["ech"], ech)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("vmess obj", func(t *testing.T) {
|
||||
obj := map[string]any{}
|
||||
ep := map[string]any{"dest": "proxy.example.com", "echConfigList": ech}
|
||||
applyExternalProxyTLSObj(ep, obj, "tls")
|
||||
if obj["ech"] != ech {
|
||||
t.Fatalf("ech obj = %v, want %q", obj["ech"], ech)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("json stream settings", func(t *testing.T) {
|
||||
stream := map[string]any{"security": "tls", "tlsSettings": map[string]any{}}
|
||||
ep := map[string]any{"dest": "proxy.example.com", "echConfigList": ech}
|
||||
applyExternalProxyTLSToStream(ep, stream, "tls")
|
||||
settings, _ := stream["tlsSettings"].(map[string]any)["settings"].(map[string]any)
|
||||
if settings["echConfigList"] != ech {
|
||||
t.Fatalf("echConfigList = %v, want %q", settings["echConfigList"], ech)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-tls security drops ech", func(t *testing.T) {
|
||||
params := map[string]string{}
|
||||
ep := map[string]any{"echConfigList": ech}
|
||||
applyExternalProxyTLSParams(ep, params, "none")
|
||||
if _, ok := params["ech"]; ok {
|
||||
t.Fatalf("ech must not be set when security != tls")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyExternalProxyTLSToStream_DoesNotLeakAcrossProxies(t *testing.T) {
|
||||
stream := map[string]any{
|
||||
"security": "tls",
|
||||
"tlsSettings": map[string]any{
|
||||
"serverName": "upstream.example.com",
|
||||
},
|
||||
}
|
||||
proxies := []map[string]any{
|
||||
{"dest": "a.example.com", "sni": "a-sni.example.com", "fingerprint": "chrome", "alpn": []any{"h3"}},
|
||||
{"dest": "b.example.com"},
|
||||
}
|
||||
|
||||
results := make([]map[string]any, 0, len(proxies))
|
||||
for _, ep := range proxies {
|
||||
working := cloneStreamForExternalProxy(stream)
|
||||
applyExternalProxyTLSToStream(ep, working, "tls")
|
||||
ts := working["tlsSettings"].(map[string]any)
|
||||
snapshot := map[string]any{
|
||||
"serverName": ts["serverName"],
|
||||
"fingerprint": ts["fingerprint"],
|
||||
"alpn": ts["alpn"],
|
||||
}
|
||||
results = append(results, snapshot)
|
||||
}
|
||||
|
||||
if results[0]["serverName"] != "a-sni.example.com" || results[0]["fingerprint"] != "chrome" {
|
||||
t.Fatalf("proxy A snapshot = %v", results[0])
|
||||
}
|
||||
// Proxy B has no SNI of its own — the upstream tlsSettings serverName
|
||||
// must remain in place (no dest fallback) and no fingerprint/alpn
|
||||
// must leak from proxy A.
|
||||
if results[1]["serverName"] != "upstream.example.com" {
|
||||
t.Fatalf("proxy B serverName = %v, want upstream.example.com preserved", results[1]["serverName"])
|
||||
}
|
||||
if results[1]["fingerprint"] != nil {
|
||||
t.Fatalf("proxy B should inherit no fingerprint, got %v (leaked from A)", results[1]["fingerprint"])
|
||||
}
|
||||
if results[1]["alpn"] != nil {
|
||||
t.Fatalf("proxy B should inherit no alpn, got %v (leaked from A)", results[1]["alpn"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExternalProxyTLSParams_SetsPinnedPeerCert(t *testing.T) {
|
||||
params := map[string]string{"security": "tls"}
|
||||
ep := map[string]any{
|
||||
"dest": "proxy.example.com",
|
||||
"pinnedPeerCertSha256": []any{"aa11", "bb22"},
|
||||
}
|
||||
|
||||
applyExternalProxyTLSParams(ep, params, "tls")
|
||||
|
||||
if params["pcs"] != "aa11,bb22" {
|
||||
t.Fatalf("pcs = %q, want aa11,bb22", params["pcs"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExternalProxyTLSObj_SetsPinnedPeerCert(t *testing.T) {
|
||||
obj := map[string]any{"tls": "tls"}
|
||||
ep := map[string]any{
|
||||
"dest": "proxy.example.com",
|
||||
"pinnedPeerCertSha256": []any{"aa11"},
|
||||
}
|
||||
|
||||
applyExternalProxyTLSObj(ep, obj, "tls")
|
||||
|
||||
if obj["pcs"] != "aa11" {
|
||||
t.Fatalf("pcs = %v, want aa11", obj["pcs"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExternalProxyTLSToStream_SetsPinnedPeerCert(t *testing.T) {
|
||||
stream := map[string]any{
|
||||
"security": "tls",
|
||||
"tlsSettings": map[string]any{"serverName": "upstream.example.com"},
|
||||
}
|
||||
ep := map[string]any{"dest": "edge.example.com", "pinnedPeerCertSha256": []any{"aa11", "bb22"}}
|
||||
|
||||
working := cloneStreamForExternalProxy(stream)
|
||||
applyExternalProxyTLSToStream(ep, working, "tls")
|
||||
|
||||
ts := working["tlsSettings"].(map[string]any)
|
||||
settings, _ := ts["settings"].(map[string]any)
|
||||
pins, ok := settings["pinnedPeerCertSha256"].([]any)
|
||||
if !ok || len(pins) != 2 || pins[0] != "aa11" || pins[1] != "bb22" {
|
||||
t.Fatalf("pinnedPeerCertSha256 = %v, want [aa11 bb22]", settings["pinnedPeerCertSha256"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExternalProxyHysteriaParams_PinIsHexNormalized(t *testing.T) {
|
||||
// base64 SHA-256 pin must come out as bare lowercase hex for Hysteria's
|
||||
// pinSHA256, which other (pcs) protocols leave untouched.
|
||||
params := map[string]string{"security": "tls", "sni": "server.example.com"}
|
||||
ep := map[string]any{
|
||||
"dest": "edge.example.com",
|
||||
"pinnedPeerCertSha256": []any{"yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ="},
|
||||
}
|
||||
|
||||
applyExternalProxyHysteriaParams(ep, params)
|
||||
|
||||
if params["pinSHA256"] != "c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4" {
|
||||
t.Fatalf("pinSHA256 = %q, want hex-normalized pin", params["pinSHA256"])
|
||||
}
|
||||
if _, ok := params["pcs"]; ok {
|
||||
t.Fatalf("pcs must not be set for Hysteria, got %v", params)
|
||||
}
|
||||
if params["sni"] != "server.example.com" {
|
||||
t.Fatalf("sni = %q, want inbound sni preserved (no override for Hysteria)", params["sni"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExternalProxyHysteriaParams_NoPinLeavesMainPin(t *testing.T) {
|
||||
params := map[string]string{"security": "tls", "pinSHA256": "deadbeef"}
|
||||
ep := map[string]any{"dest": "edge.example.com"}
|
||||
|
||||
applyExternalProxyHysteriaParams(ep, params)
|
||||
|
||||
if params["pinSHA256"] != "deadbeef" {
|
||||
t.Fatalf("pinSHA256 = %q, want main pin preserved when proxy has none", params["pinSHA256"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExternalProxyTLSParams_DoesNotApplyForNone(t *testing.T) {
|
||||
params := map[string]string{
|
||||
"security": "none",
|
||||
"sni": "origin.example.com",
|
||||
}
|
||||
ep := map[string]any{
|
||||
"dest": "proxy.example.com",
|
||||
"fingerprint": "chrome",
|
||||
"alpn": []any{"h3"},
|
||||
}
|
||||
|
||||
applyExternalProxyTLSParams(ep, params, "none")
|
||||
|
||||
if params["sni"] != "origin.example.com" {
|
||||
t.Fatalf("sni should not change for security=none, got %q", params["sni"])
|
||||
}
|
||||
if _, ok := params["fp"]; ok {
|
||||
t.Fatalf("fp should not be set for security=none, got %v", params)
|
||||
}
|
||||
if _, ok := params["alpn"]; ok {
|
||||
t.Fatalf("alpn should not be set for security=none, got %v", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractKcpShareFields_Defaults(t *testing.T) {
|
||||
stream := map[string]any{}
|
||||
got := extractKcpShareFields(stream)
|
||||
if got.headerType != "none" {
|
||||
t.Fatalf("default headerType = %q, want none", got.headerType)
|
||||
}
|
||||
if got.seed != "" || got.mtu != 0 || got.tti != 0 {
|
||||
t.Fatalf("default kcpShareFields should be zero except headerType, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractKcpShareFields_ReadsAllFields(t *testing.T) {
|
||||
stream := map[string]any{
|
||||
"kcpSettings": map[string]any{
|
||||
"header": map[string]any{"type": "wechat-video"},
|
||||
"seed": "secret-seed",
|
||||
"mtu": float64(1350),
|
||||
"tti": float64(50),
|
||||
},
|
||||
}
|
||||
got := extractKcpShareFields(stream)
|
||||
if got.headerType != "wechat-video" {
|
||||
t.Fatalf("headerType = %q, want wechat-video", got.headerType)
|
||||
}
|
||||
if got.seed != "secret-seed" {
|
||||
t.Fatalf("seed = %q, want secret-seed", got.seed)
|
||||
}
|
||||
if got.mtu != 1350 {
|
||||
t.Fatalf("mtu = %d, want 1350", got.mtu)
|
||||
}
|
||||
if got.tti != 50 {
|
||||
t.Fatalf("tti = %d, want 50", got.tti)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractKcpShareFields_FinalMaskLegacyHeader(t *testing.T) {
|
||||
stream := map[string]any{
|
||||
"finalmask": map[string]any{
|
||||
"udp": []any{
|
||||
map[string]any{
|
||||
"type": "mkcp-legacy",
|
||||
"settings": map[string]any{"header": "wechat", "value": ""},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := extractKcpShareFields(stream)
|
||||
if got.headerType != "wechat-video" {
|
||||
t.Fatalf("headerType = %q, want wechat-video", got.headerType)
|
||||
}
|
||||
if got.seed != "" {
|
||||
t.Fatalf("seed = %q, want empty for header mask", got.seed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractKcpShareFields_FinalMaskLegacySeed(t *testing.T) {
|
||||
stream := map[string]any{
|
||||
"finalmask": map[string]any{
|
||||
"udp": []any{
|
||||
map[string]any{
|
||||
"type": "mkcp-legacy",
|
||||
"settings": map[string]any{"header": "", "value": "obfs-pass"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := extractKcpShareFields(stream)
|
||||
if got.headerType != "none" {
|
||||
t.Fatalf("headerType = %q, want none for empty-header legacy mask", got.headerType)
|
||||
}
|
||||
if got.seed != "obfs-pass" {
|
||||
t.Fatalf("seed = %q, want obfs-pass", got.seed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKcpShareFields_ApplyToParams(t *testing.T) {
|
||||
params := map[string]string{}
|
||||
kcpShareFields{headerType: "wechat-video", seed: "s", mtu: 1350, tti: 50}.applyToParams(params)
|
||||
if params["headerType"] != "wechat-video" {
|
||||
t.Fatalf("headerType param = %q", params["headerType"])
|
||||
}
|
||||
if params["seed"] != "s" {
|
||||
t.Fatalf("seed param = %q", params["seed"])
|
||||
}
|
||||
if params["mtu"] != "1350" {
|
||||
t.Fatalf("mtu param = %q", params["mtu"])
|
||||
}
|
||||
if params["tti"] != "50" {
|
||||
t.Fatalf("tti param = %q", params["tti"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestKcpShareFields_ApplyToParams_NoneHeaderNotAdded(t *testing.T) {
|
||||
params := map[string]string{}
|
||||
kcpShareFields{headerType: "none"}.applyToParams(params)
|
||||
if _, ok := params["headerType"]; ok {
|
||||
t.Fatalf("headerType=none should not be added, got %v", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalFinalMask_EmptyReturnsFalse(t *testing.T) {
|
||||
if _, ok := marshalFinalMask(map[string]any{}); ok {
|
||||
t.Fatal("expected ok=false for empty finalmask")
|
||||
}
|
||||
if _, ok := marshalFinalMask(nil); ok {
|
||||
t.Fatal("expected ok=false for nil finalmask")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalFinalMask_WithContent(t *testing.T) {
|
||||
fm := map[string]any{
|
||||
"tcp": []any{
|
||||
map[string]any{"type": "fragment"},
|
||||
},
|
||||
}
|
||||
out, ok := marshalFinalMask(fm)
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true for finalmask with valid tcp mask")
|
||||
}
|
||||
if !strings.Contains(out, `"tcp"`) {
|
||||
t.Fatalf("marshaled finalmask missing tcp key: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "fragment") {
|
||||
t.Fatalf("marshaled finalmask missing mask type: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalFinalMask_UnknownTypeIsDropped(t *testing.T) {
|
||||
fm := map[string]any{
|
||||
"tcp": []any{
|
||||
map[string]any{"type": "not-a-real-mask"},
|
||||
},
|
||||
}
|
||||
if _, ok := marshalFinalMask(fm); ok {
|
||||
t.Fatal("unknown mask types should be dropped, leaving nothing to marshal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasFinalMaskContent(t *testing.T) {
|
||||
if hasFinalMaskContent(nil) {
|
||||
t.Fatal("nil should not count as content")
|
||||
}
|
||||
if hasFinalMaskContent(map[string]any{}) {
|
||||
t.Fatal("empty map should not count as content")
|
||||
}
|
||||
if !hasFinalMaskContent(map[string]any{"x": 1}) {
|
||||
t.Fatal("non-empty map should count as content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHysteriaPinHex(t *testing.T) {
|
||||
const hexPin = "c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
// Std base64 (xray-core's native TLS format / the panel generate button)
|
||||
// must be re-encoded to the hex form Hysteria2 clients expect (#4818).
|
||||
{"std base64", "yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ=", hexPin},
|
||||
// A manually pasted hex fingerprint passes through (lowercased).
|
||||
{"hex passthrough", hexPin, hexPin},
|
||||
{"uppercase hex lowercased", strings.ToUpper(hexPin), hexPin},
|
||||
// openssl x509 -fingerprint -sha256 emits colon-separated hex.
|
||||
{"colon hex stripped", "C8:47:DD:23:95:D0:97:8C:07:80:B8:20:1C:4B:28:9A:8B:28:15:97:D4:7C:27:5F:2D:77:D3:F9:6D:8D:E9:C4", hexPin},
|
||||
{"surrounding whitespace trimmed", " " + hexPin + " ", hexPin},
|
||||
// URL-safe base64 with the same 32 bytes decodes identically.
|
||||
{"url-safe base64", "yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT-W2N6cQ=", hexPin},
|
||||
// Garbage that is neither valid hex nor a 32-byte base64 is left as-is
|
||||
// rather than silently dropped.
|
||||
{"unrecognized passthrough", "not-a-pin", "not-a-pin"},
|
||||
{"empty", "", ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := hysteriaPinHex(tc.in); got != tc.want {
|
||||
t.Fatalf("hysteriaPinHex(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHysteriaHopPorts(t *testing.T) {
|
||||
withHop := func(ports any) map[string]any {
|
||||
return map[string]any{
|
||||
"finalmask": map[string]any{
|
||||
"quicParams": map[string]any{
|
||||
"udpHop": map[string]any{"ports": ports, "interval": "5-10"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
stream map[string]any
|
||||
want string
|
||||
}{
|
||||
{"range", withHop("20000-50000"), "20000-50000"},
|
||||
{"trimmed", withHop(" 443,20000-50000 "), "443,20000-50000"},
|
||||
{"empty string", withHop(""), ""},
|
||||
{"non-string", withHop(float64(443)), ""},
|
||||
{"no udpHop", map[string]any{"finalmask": map[string]any{"quicParams": map[string]any{}}}, ""},
|
||||
{"no finalmask", map[string]any{}, ""},
|
||||
{"nil stream", nil, ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := hysteriaHopPorts(tc.stream); got != tc.want {
|
||||
t.Fatalf("hysteriaHopPorts() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
func TestAggregateTrafficByEmails_FallsBackToClientLimits(t *testing.T) {
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
const email = "node-client@example.com"
|
||||
const totalBytes = int64(300) * 1024 * 1024 * 1024
|
||||
const expiry = int64(1893456000000)
|
||||
|
||||
db := database.GetDB()
|
||||
if err := db.Create(&model.ClientRecord{
|
||||
Email: email,
|
||||
TotalGB: totalBytes,
|
||||
ExpiryTime: expiry,
|
||||
Enable: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed client record: %v", err)
|
||||
}
|
||||
if err := db.Create(&xray.ClientTraffic{
|
||||
Email: email,
|
||||
Up: 111,
|
||||
Down: 222,
|
||||
Total: 0,
|
||||
ExpiryTime: 0,
|
||||
Enable: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed client traffic: %v", err)
|
||||
}
|
||||
|
||||
var s SubService
|
||||
agg, _ := s.AggregateTrafficByEmails([]string{email})
|
||||
|
||||
if agg.Up != 111 || agg.Down != 222 {
|
||||
t.Errorf("usage = up %d/down %d, want 111/222", agg.Up, agg.Down)
|
||||
}
|
||||
if agg.Total != totalBytes {
|
||||
t.Errorf("total = %d, want %d (fallback to clients table)", agg.Total, totalBytes)
|
||||
}
|
||||
if agg.ExpiryTime != expiry {
|
||||
t.Errorf("expiry = %d, want %d (fallback to clients table)", agg.ExpiryTime, expiry)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
// Package sub provides subscription server functionality for the 3x-ui panel,
|
||||
// including HTTP/HTTPS servers for serving subscription links and JSON configurations.
|
||||
package sub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/network"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Server represents the subscription server that serves subscription links and JSON configurations.
|
||||
type Server struct {
|
||||
httpServer *http.Server
|
||||
listener net.Listener
|
||||
|
||||
sub *SUBController
|
||||
settingService service.SettingService
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewServer creates a new subscription server instance with a cancellable context.
|
||||
func NewServer() *Server {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Server{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// initRouter configures the subscription server's Gin engine, middleware,
|
||||
// templates and static assets and returns the ready-to-use engine.
|
||||
func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
// Always run in release mode for the subscription server
|
||||
gin.DefaultWriter = io.Discard
|
||||
gin.DefaultErrorWriter = io.Discard
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
engine := gin.Default()
|
||||
|
||||
subDomain, err := s.settingService.GetSubDomain()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if subDomain != "" {
|
||||
engine.Use(middleware.DomainValidatorMiddleware(subDomain))
|
||||
}
|
||||
|
||||
LinksPath, err := s.settingService.GetSubPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
JsonPath, err := s.settingService.GetSubJsonPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
if basePath != "/" && !strings.HasSuffix(basePath, "/") {
|
||||
basePath += "/"
|
||||
}
|
||||
// logger.Debug("sub: Setting base_path to:", basePath)
|
||||
engine.Use(func(c *gin.Context) {
|
||||
c.Set("base_path", basePath)
|
||||
})
|
||||
|
||||
Encrypt, err := s.settingService.GetSubEncrypt()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ShowInfo, err := s.settingService.GetSubShowInfo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
RemarkModel, err := s.settingService.GetRemarkModel()
|
||||
if err != nil {
|
||||
RemarkModel = "-io"
|
||||
}
|
||||
|
||||
SubUpdates, err := s.settingService.GetSubUpdates()
|
||||
if err != nil {
|
||||
SubUpdates = "10"
|
||||
}
|
||||
|
||||
SubJsonMux, err := s.settingService.GetSubJsonMux()
|
||||
if err != nil {
|
||||
SubJsonMux = ""
|
||||
}
|
||||
|
||||
SubJsonRules, err := s.settingService.GetSubJsonRules()
|
||||
if err != nil {
|
||||
SubJsonRules = ""
|
||||
}
|
||||
|
||||
SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask()
|
||||
if err != nil {
|
||||
SubJsonFinalMask = ""
|
||||
}
|
||||
|
||||
SubClashEnableRouting, err := s.settingService.GetSubClashEnableRouting()
|
||||
if err != nil {
|
||||
SubClashEnableRouting = false
|
||||
}
|
||||
|
||||
SubClashRules, err := s.settingService.GetSubClashRules()
|
||||
if err != nil {
|
||||
SubClashRules = ""
|
||||
}
|
||||
|
||||
SubTitle, err := s.settingService.GetSubTitle()
|
||||
if err != nil {
|
||||
SubTitle = ""
|
||||
}
|
||||
|
||||
SubSupportUrl, err := s.settingService.GetSubSupportUrl()
|
||||
if err != nil {
|
||||
SubSupportUrl = ""
|
||||
}
|
||||
|
||||
SubProfileUrl, err := s.settingService.GetSubProfileUrl()
|
||||
if err != nil {
|
||||
SubProfileUrl = ""
|
||||
}
|
||||
|
||||
SubAnnounce, err := s.settingService.GetSubAnnounce()
|
||||
if err != nil {
|
||||
SubAnnounce = ""
|
||||
}
|
||||
|
||||
SubEnableRouting, err := s.settingService.GetSubEnableRouting()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
SubRoutingRules, err := s.settingService.GetSubRoutingRules()
|
||||
if err != nil {
|
||||
SubRoutingRules = ""
|
||||
}
|
||||
|
||||
// set per-request localizer from headers/cookies
|
||||
engine.Use(locale.LocalizerMiddleware())
|
||||
|
||||
// Mount the Vite-built dist/assets/ so the subscription page's JS/CSS
|
||||
// bundles load from `/assets/...`. Also mount the same FS under the
|
||||
// subscription path prefix (LinksPath + "assets") so reverse proxies
|
||||
// running the panel under a URI prefix can resolve those URLs too.
|
||||
// Note: LinksPath always starts and ends with "/" (validated in settings).
|
||||
var linksPathForAssets string
|
||||
if LinksPath == "/" {
|
||||
linksPathForAssets = "/assets"
|
||||
} else {
|
||||
linksPathForAssets = strings.TrimRight(LinksPath, "/") + "/assets"
|
||||
}
|
||||
|
||||
var assetsFS http.FileSystem
|
||||
if _, err := os.Stat("internal/web/dist/assets"); err == nil {
|
||||
assetsFS = http.FS(os.DirFS("internal/web/dist/assets"))
|
||||
} else if subFS, err := fs.Sub(distFS, "dist/assets"); err == nil {
|
||||
assetsFS = http.FS(subFS)
|
||||
} else {
|
||||
logger.Error("sub: failed to mount embedded dist assets:", err)
|
||||
}
|
||||
|
||||
if assetsFS != nil {
|
||||
engine.StaticFS("/assets", assetsFS)
|
||||
if linksPathForAssets != "/assets" {
|
||||
engine.StaticFS(linksPathForAssets, assetsFS)
|
||||
}
|
||||
|
||||
// Browser may resolve subpage assets relative to the request URL —
|
||||
// /sub/<basePath>/<subId>/assets/... — so route those to the same FS.
|
||||
if LinksPath != "/" {
|
||||
engine.Use(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
pathPrefix := strings.TrimRight(LinksPath, "/") + "/"
|
||||
if strings.HasPrefix(path, pathPrefix) && strings.Contains(path, "/assets/") {
|
||||
_, after, ok := strings.Cut(path, "/assets/")
|
||||
if ok {
|
||||
assetPath := after // +8 to skip "/assets/"
|
||||
if assetPath != "" {
|
||||
c.FileFromFS(assetPath, assetsFS)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
g := engine.Group("/")
|
||||
|
||||
s.sub = NewSUBController(
|
||||
g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
|
||||
SubJsonMux, SubJsonRules, SubJsonFinalMask, SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl,
|
||||
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
|
||||
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
// Start initializes and starts the subscription server with configured settings.
|
||||
func (s *Server) Start() (err error) {
|
||||
// This is an anonymous function, no function name
|
||||
defer func() {
|
||||
if err != nil {
|
||||
s.Stop()
|
||||
}
|
||||
}()
|
||||
|
||||
subEnable, err := s.settingService.GetSubEnable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !subEnable {
|
||||
return nil
|
||||
}
|
||||
|
||||
engine, err := s.initRouter()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
certFile, err := s.settingService.GetSubCertFile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keyFile, err := s.settingService.GetSubKeyFile()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
listen, err := s.settingService.GetSubListen()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
port, err := s.settingService.GetSubPort()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
listenAddr := net.JoinHostPort(listen, strconv.Itoa(port))
|
||||
listener, err := net.Listen("tcp", listenAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if certFile != "" || keyFile != "" {
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err == nil {
|
||||
c := &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
}
|
||||
listener = network.NewAutoHttpsListener(listener)
|
||||
listener = tls.NewListener(listener, c)
|
||||
logger.Info("Sub server running HTTPS on", listener.Addr())
|
||||
} else {
|
||||
logger.Error("Error loading certificates:", err)
|
||||
logger.Info("Sub server running HTTP on", listener.Addr())
|
||||
}
|
||||
} else {
|
||||
logger.Info("Sub server running HTTP on", listener.Addr())
|
||||
}
|
||||
s.listener = listener
|
||||
|
||||
s.httpServer = &http.Server{
|
||||
Handler: engine,
|
||||
}
|
||||
|
||||
go func() {
|
||||
s.httpServer.Serve(listener)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the subscription server and closes the listener.
|
||||
func (s *Server) Stop() error {
|
||||
s.cancel()
|
||||
|
||||
var err1 error
|
||||
var err2 error
|
||||
if s.httpServer != nil {
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer shutdownCancel()
|
||||
err1 = s.httpServer.Shutdown(shutdownCtx)
|
||||
}
|
||||
if s.listener != nil {
|
||||
err2 = s.listener.Close()
|
||||
}
|
||||
return common.Combine(err1, err2)
|
||||
}
|
||||
|
||||
// GetCtx returns the server's context for cancellation and deadline management.
|
||||
func (s *Server) GetCtx() context.Context {
|
||||
return s.ctx
|
||||
}
|
||||
Reference in New Issue
Block a user