mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-24 21:46:07 +00:00
892c06c8bc
* fix(xray): block private-range egress in default freedom finalRules (#6037)
With domainStrategy AsIs the router never resolves domains, so a domain
with a private A record (e.g. 127-0-0-1.nip.io) sails past the
geoip:private routing block and freedom's allow-all finalRules let it
reach loopback services such as the xray gRPC API and metrics listener.
Prepend a block rule for geoip:private to the default template and add
the FreedomFinalRulesPrivateEgressBlock seeder so existing installs
still carrying the stock allow-only (or legacy private-only-allow)
finalRules are upgraded in place; customized rules are left untouched.
* fix(sub): version-gate unencrypted-outbound drops in outbound subscriptions (#6033)
Commit d38c912d taught CheckXrayConfig to keep unencrypted vless/trojan
outbounds when the running core predates the v26.7.11 rejection, but
filterOutboundsRejectedByCore still consulted the embedded validator
unconditionally, so outbound subscriptions kept silently dropping those
outbounds even on downgraded cores.
Apply the same shouldSkipLegacyUnencryptedOutboundRejection gate when
filtering fetched subscription outbounds.
* fix(xray): resolve geodata assets before building outbound configs (#5928)
Saving routing or template settings validates each outbound through the
embedded config loader, and a freedom outbound whose finalRules
reference geoip:private opens geoip.dat during that build. Unlike
ApplyRoutingConfig, ValidateOutboundConfig and AddOutbound never pointed
the in-process loader at the bin folder, so xray-core resolved the file
relative to the panel executable and saving failed with
'stat /usr/local/x-ui/geoip.dat: no such file or directory'.
Call ensureXrayAssetLocation before both build paths.
* fix(api): use a real i18n key in the client get handler (#5911)
The client fetch endpoint localized its error prefix with the bare key
'get', which exists in no translation file, so every lookup of a deleted
client's email logged 'message "get" not found in language ...' noise
alongside the expected record-not-found warning. Reuse the same
pages.inbounds.toasts.obtain key the sibling list handler uses.
* fix(sub): carry host record Host header and path into Clash/JSON output (#5944)
The raw-link path overrides the host/path share params from a Host
record via applyEndpointHostPath, but the Clash and JSON renderers read
the transport settings object, which applyHostStreamOverrides never
touched — so a Host record's WebSocket Host header (and path) silently
vanished from Clash/Mihomo and JSON subscriptions whenever the inbound's
own ws settings left them empty.
Inject hostHeader/path into the ws/httpupgrade/xhttp settings of the
per-host stream, mirroring the raw-link override.
* fix(metrics): accept Unicode outbound tags in the observatory (#5972)
The observatory validator whitelisted ASCII word characters, so any
outbound whose tag carries a flag emoji or other non-ASCII text was
silently dropped from the metrics snapshot, delay history, and health
notifications. The history store is an in-process map, so the strict
charset bought nothing.
Validate tags as non-empty, bounded, control-character-free UTF-8
instead, keeping spaces and emoji while still rejecting garbage input on
the query path.
* fix(database): default sqlite to WAL to stop background-job lock storms (#6057, #6068)
With journal_mode=DELETE every write serializes the whole database and
blocks readers, so under normal multi-job load (traffic sampling, node
sync, mtproto reconcile) transactions regularly outwaited the 10s busy
timeout and jobs failed with 'database is locked'.
Move to WAL by default: readers no longer block writers and vice versa,
which removes the observed contention while writer-writer access still
serializes safely. The single-file-at-rest property is preserved where
it matters — Checkpoint() now issues wal_checkpoint(TRUNCATE), so panel
and Telegram backups read a complete main file, and sqlite folds the WAL
back into the db on clean shutdown. XUI_DB_JOURNAL_MODE=DELETE restores
the previous behavior for setups that copy the live file directly.
* fix(database): strip finalmask.tcp from REALITY inbounds on upgrade (#6038)
validateFinalMaskRealityCombo blocks saving finalmask.tcp together with
REALITY because that combination crashes Xray-core 26.7.11 on the first
connection (XTLS/Xray-core#6453), but it only runs on add/update. An
inbound saved before the validator existed sailed through the upgrade
untouched and took the core down at boot.
Add the InboundRealityFinalmaskTcpStrip seeder: one-time scan that
removes finalmask.tcp from REALITY inbounds (other finalmask transports
survive), so upgraded panels start cleanly.
* fix(xray): stop deleting hand-written direct routing rules on save (#6056)
The DNS allow-rule sync recognized 'its' rules purely by shape
(type=field, ip, port, outboundTag=direct, nothing else), so any manual
rule of that shape — e.g. routing a LAN CIDR to a NAS port over direct —
was silently stripped on every settings save.
Mark managed rules with ruleTag=xui-dns-allow (round-tripped untouched
by both xray-core and the Routing tab editor) and only strip rules that
carry the tag. Untagged legacy managed rules are adopted when their
exact ip-set/port matches a currently configured private DNS endpoint;
anything else is left alone. A stale pre-tag managed rule whose DNS
server was removed now lingers until deleted manually — the safe side of
the trade against eating user rules.
* fix(clients): resolve email lookups through client_inbounds after a move (#6059)
GetClientInboundByEmail trusted the client_traffics.inbound_id pointer
whenever that inbound still existed, but a client moved between inbounds
leaves the row pointing at its old (still existing) inbound. The lookup
then searched the wrong inbound's clients and failed with 'Client Not
Found In Inbound For Email', which broke the Telegram bot's link and QR
generation for moved clients.
When the pointed-at inbound no longer carries the email, re-resolve
through the authoritative client_inbounds link to the inbound that
actually hosts the client.
* fix(nodes): replicate inbound fallbacks to nodes (#5963)
Fallbacks live in the inbound_fallbacks table and were only merged into
settings by the master's local config builder; the runtime inbound
pushed to nodes rebuilt settings without them, and the reconcile job
additionally fingerprinted the raw DB row, so fallback edits neither
reached nodes nor triggered a re-push.
Inject settings.fallbacks in buildRuntimeInboundForAPI (mirroring the
local builder, gated on inboundCanHostFallbacks) and make ReconcileNode
push and fingerprint that same runtime-built payload, aligning the
interactive and reconcile paths.
* fix(database): survive PostgreSQL outages without a runaway restart loop (#6023)
A PostgreSQL that was down or still starting made InitDB fail instantly;
the process exited with a generic startup error and systemd restarted it
every 5s forever, flooding the journal.
Retry the initial postgres connection with backoff (~70s total) and log
the real driver error on every attempt, and cap the systemd units with
StartLimitIntervalSec/StartLimitBurst so a persistently unreachable
database stops the unit instead of looping indefinitely.
* fix(xray): force a full restart when REALITY stream settings change (#6010)
A changed inbound is normally hot-swapped over gRPC as RemoveInbound +
AddInbound, but xray-core does not reliably rebuild a REALITY listener's
authenticator on a runtime re-add — key or shortId edits appeared
applied yet clients kept authenticating against the old parameters until
someone restarted the core manually, on nodes in particular.
Treat any non-client change to an inbound that uses (or starts using)
REALITY as not hot-appliable so the panel restarts the core instead.
Client-only edits on REALITY inbounds keep flowing through the per-user
AlterInbound path and still avoid restarts.
* feat(sub): allow insecure TLS for outbound subscription fetches (#6067)
An outbound subscription served over HTTPS with a self-signed or
private-CA certificate could never be fetched: the fetch client had no
TLS options, so refreshes died with 'x509: certificate signed by unknown
authority' and there was nothing the admin could toggle.
Add a per-subscription 'Allow insecure' switch (persisted as
allow_insecure, default off) that sets InsecureSkipVerify on the fetch
transport — including when the fetch is routed through the panel egress
proxy. The SSRF-guarded dialer and redirect re-validation stay in force
either way.
* fix(reality): send PROXY protocol header in the target scanner when xver is set (#6082)
The REALITY target scanner always probed with a plain TLS handshake, so
a target fronted by an Nginx listener that requires the PROXY protocol
(matching the inbound's xver>=1) reset the connection and the panel
reported a false 'TLS handshake failed'.
Thread the inbound's xver into the scan request and, when it is >=1,
lead with the matching PROXY protocol header (v1 for xver 1, binary v2
for xver 2) built from the dialed connection's own address pair. Batch
candidate scans against public sites are unaffected (xver 0).
* fix(frontend): default sockopt fields when editing a stored inbound (#5956)
Opening an existing inbound ran rawInboundToFormValues over the raw DB
row, and only xhttpSettings was re-parsed through its Zod schema to fill
defaults. A sockopt object saved before the TProxy control existed has
no tproxy key, so the Select rendered blank; picking Off didn't help
because the wire normalizer drops tproxy=off, recreating the missing
key on the next edit.
Re-parse streamSettings.sockopt through SockoptStreamSettingsSchema on
load, mirroring the xhttpSettings handling, so absent keys (tproxy,
tcpcongestion, …) get their schema defaults every time the form opens.
421 lines
17 KiB
Go
421 lines
17 KiB
Go
package sub
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
)
|
|
|
|
func seedSubDB(t *testing.T) {
|
|
t.Helper()
|
|
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() })
|
|
}
|
|
|
|
// seedSubInbound creates a VLESS inbound with one client wired into the
|
|
// normalized clients/client_inbounds tables so getInboundsBySubId resolves it.
|
|
func seedSubInbound(t *testing.T, subId, tag string, port, subSortIndex int, stream string) *model.Inbound {
|
|
t.Helper()
|
|
db := database.GetDB()
|
|
uuid := "11111111-2222-4333-8444-" + fmt.Sprintf("%012d", port)
|
|
email := tag + "@e"
|
|
settings := fmt.Sprintf(`{"clients":[{"id":%q,"email":%q,"subId":%q,"enable":true}],"decryption":"none"}`, uuid, email, subId)
|
|
ib := &model.Inbound{
|
|
UserId: 1, Tag: tag, Enable: true, Listen: "203.0.113.5", Port: port,
|
|
Protocol: model.VLESS, Remark: tag, Settings: settings, StreamSettings: stream,
|
|
SubSortIndex: subSortIndex,
|
|
}
|
|
if err := db.Create(ib).Error; err != nil {
|
|
t.Fatalf("seed inbound %s: %v", tag, err)
|
|
}
|
|
client := &model.ClientRecord{Email: email, SubID: subId, UUID: uuid, Enable: true}
|
|
if err := db.Create(client).Error; err != nil {
|
|
t.Fatalf("seed client %s: %v", email, err)
|
|
}
|
|
if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
|
|
t.Fatalf("seed client_inbound %s: %v", email, err)
|
|
}
|
|
return ib
|
|
}
|
|
|
|
func seedHost(t *testing.T, h *model.Host) *model.Host {
|
|
t.Helper()
|
|
if err := database.GetDB().Create(h).Error; err != nil {
|
|
t.Fatalf("seed host: %v", err)
|
|
}
|
|
return h
|
|
}
|
|
|
|
const wsTLSStream = `{"network":"ws","security":"tls","wsSettings":{"path":"/base","host":"base.host"},"tlsSettings":{"serverName":"base.sni"}}`
|
|
|
|
// #1 — an inbound with no hosts renders identically to the legacy path: a single
|
|
// link from the inbound's own address. Mutation-checks the zero-hosts fallback.
|
|
func TestSub_ZeroHosts_IdenticalOutput(t *testing.T) {
|
|
seedSubDB(t)
|
|
seedSubInbound(t, "s1", "z", 4431, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
if len(links) != 1 {
|
|
t.Fatalf("links = %d, want 1", len(links))
|
|
}
|
|
if !strings.Contains(links[0], "203.0.113.5:4431") {
|
|
t.Fatalf("zero-hosts link should use the inbound address: %s", links[0])
|
|
}
|
|
if strings.Contains(links[0], "\n") {
|
|
t.Fatalf("zero-hosts must be a single link: %s", links[0])
|
|
}
|
|
}
|
|
|
|
// #2 — N enabled hosts render N links, ordered by sort_order, each carrying its
|
|
// own address/port/sni and host-header/path override.
|
|
func TestSub_NHosts_EmitsNLinksOrdered(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "n", 4432, 1, wsTLSStream)
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "B", Address: "b.cdn.com", Port: 8443, Security: "tls", Sni: "b.sni", HostHeader: "b.host", Path: "/b"})
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "A", Address: "a.cdn.com", Port: 2096, Security: "tls", Sni: "a.sni", HostHeader: "a.host", Path: "/a"})
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
parts := strings.Split(strings.Join(links, "\n"), "\n")
|
|
if len(parts) != 2 {
|
|
t.Fatalf("want 2 host links, got %d: %v", len(parts), parts)
|
|
}
|
|
if !strings.Contains(parts[0], "a.cdn.com:2096") || !strings.Contains(parts[0], "sni=a.sni") ||
|
|
!strings.Contains(parts[0], "host=a.host") || !strings.Contains(parts[0], "path=%2Fa") {
|
|
t.Fatalf("host A link (sort_order 1) wrong: %s", parts[0])
|
|
}
|
|
if !strings.Contains(parts[1], "b.cdn.com:8443") || !strings.Contains(parts[1], "sni=b.sni") ||
|
|
!strings.Contains(parts[1], "host=b.host") || !strings.Contains(parts[1], "path=%2Fb") {
|
|
t.Fatalf("host B link (sort_order 2) wrong: %s", parts[1])
|
|
}
|
|
}
|
|
|
|
// #3 — a disabled host is omitted; the inbound falls back to its legacy link.
|
|
func TestSub_DisabledHostSkipped(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "d", 4433, 1, wsTLSStream)
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "OFF", Address: "off.cdn.com", Port: 8443, IsDisabled: true})
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
joined := strings.Join(links, "\n")
|
|
if strings.Contains(joined, "off.cdn.com") {
|
|
t.Fatalf("disabled host must not render: %s", joined)
|
|
}
|
|
if !strings.Contains(joined, "203.0.113.5:4433") {
|
|
t.Fatalf("with only a disabled host, the inbound's own link should render: %s", joined)
|
|
}
|
|
}
|
|
|
|
// #4 — when both hosts and a legacy externalProxy are set, hosts win and the
|
|
// externalProxy entry is ignored.
|
|
func TestSub_HostAndExternalProxy_Precedence(t *testing.T) {
|
|
seedSubDB(t)
|
|
stream := `{"network":"ws","security":"tls","wsSettings":{"path":"/base","host":"base.host"},"tlsSettings":{"serverName":"base.sni"},"externalProxy":[{"forceTls":"tls","dest":"legacy.cdn.com","port":7443,"remark":"L"}]}`
|
|
ib := seedSubInbound(t, "s1", "p", 4434, 1, stream)
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "H", Address: "host.cdn.com", Port: 8443, Security: "tls", Sni: "host.sni"})
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
joined := strings.Join(links, "\n")
|
|
if !strings.Contains(joined, "host.cdn.com:8443") {
|
|
t.Fatalf("host should win: %s", joined)
|
|
}
|
|
if strings.Contains(joined, "legacy.cdn.com") {
|
|
t.Fatalf("externalProxy must be ignored when hosts exist: %s", joined)
|
|
}
|
|
}
|
|
|
|
// #5 — hosts that share a remark but differ in address/port are NOT deduped:
|
|
// distinct hosts produce distinct links. Mutation-checks the (absent) dedup.
|
|
func TestSub_NHosts_NoDedup(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "dd", 4435, 1, wsTLSStream)
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "SAME", Address: "one.cdn.com", Port: 8443, Security: "tls"})
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "SAME", Address: "two.cdn.com", Port: 8443, Security: "tls"})
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
joined := strings.Join(links, "\n")
|
|
parts := strings.Split(joined, "\n")
|
|
if len(parts) != 2 {
|
|
t.Fatalf("two distinct hosts must yield two links, got %d: %v", len(parts), parts)
|
|
}
|
|
if !strings.Contains(joined, "one.cdn.com") || !strings.Contains(joined, "two.cdn.com") {
|
|
t.Fatalf("both distinct host addresses must appear: %s", joined)
|
|
}
|
|
}
|
|
|
|
// #6 — host sort_order composes with inbound SubSortIndex: inbounds order by
|
|
// SubSortIndex, hosts within an inbound by sort_order.
|
|
func TestSub_HostSortComposesWithSubSortIndex(t *testing.T) {
|
|
seedSubDB(t)
|
|
// inbound "second" has a higher SubSortIndex so it must come after "first".
|
|
ibFirst := seedSubInbound(t, "s1", "first", 4436, 1, wsTLSStream)
|
|
ibSecond := seedSubInbound(t, "s1", "second", 4437, 2, wsTLSStream)
|
|
seedHost(t, &model.Host{InboundId: ibSecond.Id, SortOrder: 1, Remark: "S", Address: "second-host.com", Port: 8443, Security: "tls"})
|
|
seedHost(t, &model.Host{InboundId: ibFirst.Id, SortOrder: 1, Remark: "F", Address: "first-host.com", Port: 8443, Security: "tls"})
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
joined := strings.Join(links, "\n")
|
|
firstAt := strings.Index(joined, "first-host.com")
|
|
secondAt := strings.Index(joined, "second-host.com")
|
|
if firstAt < 0 || secondAt < 0 {
|
|
t.Fatalf("both inbound hosts should render: %s", joined)
|
|
}
|
|
if firstAt > secondAt {
|
|
t.Fatalf("inbound order must follow SubSortIndex (first before second): %s", joined)
|
|
}
|
|
}
|
|
|
|
// #7 — host overrides apply AFTER projectThroughFallbackMaster: the host's
|
|
// address/sni win over the projected master stream.
|
|
func TestSub_HostOverFallback(t *testing.T) {
|
|
seedSubDB(t)
|
|
db := database.GetDB()
|
|
master := &model.Inbound{
|
|
UserId: 1, Tag: "master", Enable: true, Listen: "203.0.113.9", Port: 9443,
|
|
Protocol: model.VLESS, Remark: "master",
|
|
Settings: `{"clients":[],"decryption":"none"}`,
|
|
StreamSettings: `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"master.sni"}}`,
|
|
}
|
|
if err := db.Create(master).Error; err != nil {
|
|
t.Fatalf("seed master: %v", err)
|
|
}
|
|
// child listens internal-only so projection triggers.
|
|
child := seedSubInbound(t, "s1", "child", 4438, 1, `{"network":"tcp","security":"none"}`)
|
|
child.Listen = "127.0.0.1"
|
|
if err := db.Model(&model.Inbound{}).Where("id = ?", child.Id).Update("listen", "127.0.0.1").Error; err != nil {
|
|
t.Fatalf("set child listen: %v", err)
|
|
}
|
|
if err := db.Create(&model.InboundFallback{MasterId: master.Id, ChildId: child.Id}).Error; err != nil {
|
|
t.Fatalf("seed fallback: %v", err)
|
|
}
|
|
seedHost(t, &model.Host{InboundId: child.Id, SortOrder: 1, Remark: "H", Address: "host.cdn.com", Port: 8443, Security: "tls", Sni: "host.sni"})
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
joined := strings.Join(links, "\n")
|
|
if !strings.Contains(joined, "host.cdn.com:8443") || !strings.Contains(joined, "sni=host.sni") {
|
|
t.Fatalf("host override must win over fallback master: %s", joined)
|
|
}
|
|
if strings.Contains(joined, "203.0.113.9") || strings.Contains(joined, "sni=master.sni") {
|
|
t.Fatalf("master endpoint/sni must be overridden by the host: %s", joined)
|
|
}
|
|
}
|
|
|
|
// #8 — a client only gets hosts for inbounds it is actually on (the
|
|
// clients ⋈ client_inbounds ⋈ inbounds join), never arbitrary inbounds.
|
|
func TestSub_HostsResolveViaClientInbounds(t *testing.T) {
|
|
seedSubDB(t)
|
|
seedSubInbound(t, "s1", "mine", 4439, 1, wsTLSStream) // client on s1
|
|
other := seedSubInbound(t, "s2", "other", 4440, 1, wsTLSStream) // client on s2 only
|
|
seedHost(t, &model.Host{InboundId: other.Id, SortOrder: 1, Remark: "X", Address: "other-host.com", Port: 8443, Security: "tls"})
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
joined := strings.Join(links, "\n")
|
|
if strings.Contains(joined, "other-host.com") {
|
|
t.Fatalf("host on an inbound the client is not on must not appear: %s", joined)
|
|
}
|
|
}
|
|
|
|
// allowInsecure renders as allowInsecure=1 in the raw link and
|
|
// skip-cert-verify: true in the Clash proxy.
|
|
func TestSub_HostAllowInsecure(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "ai", 4450, 1, wsTLSStream)
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 0, Remark: "AI", Address: "ai.cdn.com", Port: 8443, Security: "tls", AllowInsecure: true})
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
if !strings.Contains(strings.Join(links, "\n"), "allowInsecure=1") {
|
|
t.Fatalf("raw link should carry allowInsecure=1: %s", strings.Join(links, "\n"))
|
|
}
|
|
|
|
clash := NewSubClashService(false, "", NewSubService(""))
|
|
yaml, _, err := clash.GetClash("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetClash: %v", err)
|
|
}
|
|
if !strings.Contains(yaml, "skip-cert-verify: true") {
|
|
t.Fatalf("clash proxy should carry skip-cert-verify: true:\n%s", yaml)
|
|
}
|
|
}
|
|
|
|
// A host's Host header and path reach the Clash and JSON renderers even when
|
|
// the inbound's own ws settings leave them empty (#5944).
|
|
func TestSub_HostHeaderReachesClashAndJson(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "hh", 4457, 1,
|
|
`{"network":"ws","security":"tls","wsSettings":{"path":"/"},"tlsSettings":{"serverName":"base.sni"}}`)
|
|
seedHost(t, &model.Host{
|
|
InboundId: ib.Id, SortOrder: 0, Remark: "HH", Address: "hh.cdn.com", Port: 8443, Security: "tls",
|
|
HostHeader: "cdn.example.com", Path: "/ws-path",
|
|
})
|
|
|
|
clash := NewSubClashService(false, "", NewSubService(""))
|
|
yaml, _, err := clash.GetClash("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetClash: %v", err)
|
|
}
|
|
if !strings.Contains(yaml, "Host: cdn.example.com") {
|
|
t.Fatalf("clash ws-opts should carry the host record's Host header:\n%s", yaml)
|
|
}
|
|
if !strings.Contains(yaml, "path: /ws-path") {
|
|
t.Fatalf("clash ws-opts should carry the host record's path:\n%s", yaml)
|
|
}
|
|
|
|
js := NewSubJsonService("", "", "", NewSubService(""))
|
|
out, _, err := js.GetJson("s1", "req.example.com", false)
|
|
if err != nil {
|
|
t.Fatalf("GetJson: %v", err)
|
|
}
|
|
if !strings.Contains(out, `"host": "cdn.example.com"`) && !strings.Contains(out, `"host":"cdn.example.com"`) {
|
|
t.Fatalf("json wsSettings should carry the host record's Host header:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// A host's Final Mask reaches the raw share link as the fm param, merged with
|
|
// any inbound-level mask (#5831).
|
|
func TestSub_HostFinalMask_RawLink(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "fmh", 4455, 1,
|
|
`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"},"finalmask":{"tcp":[{"type":"sudoku"}]}}`)
|
|
seedHost(t, &model.Host{
|
|
InboundId: ib.Id, SortOrder: 0, Remark: "FM", Address: "fm.cdn.com", Port: 8443, Security: "tls",
|
|
FinalMask: `{"tcp":[{"type":"fragment"}]}`,
|
|
})
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
joined := strings.Join(links, "\n")
|
|
wantFm := "fm=" + url.QueryEscape(`{"tcp":[{"type":"sudoku"},{"type":"fragment"}]}`)
|
|
if !strings.Contains(joined, wantFm) {
|
|
t.Fatalf("raw link should merge the host Final Mask into fm.\n got: %s\nwant substring: %s", joined, wantFm)
|
|
}
|
|
}
|
|
|
|
// A host's sockoptParams is injected into the JSON output stream (sockopt is
|
|
// stripped from the base stream, re-added per host).
|
|
func TestSub_HostSockoptJSON(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "so", 4460, 1,
|
|
`{"network":"xhttp","security":"tls","xhttpSettings":{"path":"/x","mode":"auto"},"tlsSettings":{"serverName":"base.sni"}}`)
|
|
seedHost(t, &model.Host{
|
|
InboundId: ib.Id, SortOrder: 0, Remark: "SO", Address: "so.cdn.com", Port: 8443, Security: "tls",
|
|
SockoptParams: `{"tcpFastOpen":true}`,
|
|
})
|
|
js := NewSubJsonService("", "", "", NewSubService(""))
|
|
out, _, err := js.GetJson("s1", "req.example.com", false)
|
|
if err != nil {
|
|
t.Fatalf("GetJson: %v", err)
|
|
}
|
|
if !strings.Contains(out, "sockopt") || !strings.Contains(out, "tcpFastOpen") {
|
|
t.Fatalf("json should include the host sockopt:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// A host's muxParams override the JSON outbound's mux.
|
|
func TestSub_HostMuxJSON(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "mx", 4470, 1, wsTLSStream)
|
|
seedHost(t, &model.Host{
|
|
InboundId: ib.Id, SortOrder: 0, Remark: "MX", Address: "mx.cdn.com", Port: 8443, Security: "tls",
|
|
MuxParams: `{"enabled":true,"concurrency":8}`,
|
|
})
|
|
js := NewSubJsonService("", "", "", NewSubService(""))
|
|
out, _, err := js.GetJson("s1", "req.example.com", false)
|
|
if err != nil {
|
|
t.Fatalf("GetJson: %v", err)
|
|
}
|
|
if !strings.Contains(out, "concurrency") {
|
|
t.Fatalf("json should include the host mux override:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// A reality host overrides SNI + fingerprint while inheriting pbk/sid from the
|
|
// inbound (reality keys can't be host-supplied).
|
|
func TestSub_HostRealitySniOverride(t *testing.T) {
|
|
seedSubDB(t)
|
|
realityStream := `{"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},"realitySettings":{"serverNames":["base.reality.com"],"shortIds":["abcd"],"settings":{"publicKey":"PBK","fingerprint":"chrome"}}}`
|
|
ib := seedSubInbound(t, "s1", "rl", 4490, 1, realityStream)
|
|
seedHost(t, &model.Host{
|
|
InboundId: ib.Id, SortOrder: 0, Remark: "RL", Address: "rl.cdn.com", Port: 8443,
|
|
Security: "reality", Sni: "host.reality.com", Fingerprint: "firefox",
|
|
})
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
joined := strings.Join(links, "\n")
|
|
if !strings.Contains(joined, "rl.cdn.com:8443") || !strings.Contains(joined, "security=reality") {
|
|
t.Fatalf("reality host base wrong: %s", joined)
|
|
}
|
|
if !strings.Contains(joined, "sni=host.reality.com") || !strings.Contains(joined, "fp=firefox") {
|
|
t.Fatalf("reality host sni/fp override not applied: %s", joined)
|
|
}
|
|
if strings.Contains(joined, "sni=base.reality.com") {
|
|
t.Fatalf("base reality sni must be overridden: %s", joined)
|
|
}
|
|
if !strings.Contains(joined, "pbk=PBK") || !strings.Contains(joined, "sid=abcd") {
|
|
t.Fatalf("reality pbk/sid must be inherited from the inbound: %s", joined)
|
|
}
|
|
}
|
|
|
|
// #9 — ExcludeFromSubTypes is honored per format: a host excluded from clash is
|
|
// absent from GetClash but present in the raw GetSubs output.
|
|
func TestSub_ExcludeFromSubTypes(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "x", 4441, 1, wsTLSStream)
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "H", Address: "clashless.cdn.com", Port: 8443, Security: "tls", ExcludeFromSubTypes: []string{"clash"}})
|
|
|
|
sub := NewSubService("")
|
|
links, _, _, _, err := sub.GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
if !strings.Contains(strings.Join(links, "\n"), "clashless.cdn.com") {
|
|
t.Fatalf("host not excluded from raw should appear in GetSubs")
|
|
}
|
|
|
|
clash := NewSubClashService(false, "", NewSubService(""))
|
|
yaml, _, err := clash.GetClash("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetClash: %v", err)
|
|
}
|
|
if strings.Contains(yaml, "clashless.cdn.com") {
|
|
t.Fatalf("host excluded from clash must not appear in GetClash:\n%s", yaml)
|
|
}
|
|
}
|