From 892c06c8bc869a4053bfdfdb4806cb65d318e487 Mon Sep 17 00:00:00 2001 From: Sanaei Date: Thu, 23 Jul 2026 15:34:42 +0200 Subject: [PATCH] Bug-label issue sweep: 16 fixes (#6083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- frontend/src/lib/xray/inbound-form-adapter.ts | 8 + .../pages/inbounds/form/useSecurityActions.ts | 3 +- .../src/pages/xray/outbounds/OutboundsTab.tsx | 15 +- .../src/test/inbound-form-adapter.test.ts | 16 ++ internal/database/db.go | 198 +++++++++++++++++- .../freedom_finalrules_migration_test.go | 87 ++++++++ internal/database/journal_mode_test.go | 82 ++++++++ internal/database/model/model.go | 1 + .../reality_finalmask_migration_test.go | 93 ++++++++ internal/sub/host_sub.go | 14 ++ internal/sub/host_sub_test.go | 33 +++ internal/web/controller/client.go | 8 +- internal/web/controller/server.go | 3 +- internal/web/controller/xray_setting.go | 9 +- internal/web/service/config.json | 1 + internal/web/service/inbound.go | 91 ++++---- .../web/service/inbound_client_move_test.go | 60 ++++++ internal/web/service/inbound_clients.go | 30 +++ .../service/inbound_fallback_runtime_test.go | 78 +++++++ internal/web/service/inbound_node.go | 9 +- internal/web/service/outbound_subscription.go | 42 +++- .../outbound_subscription_ssrf_test.go | 2 +- internal/web/service/reality_scan.go | 75 ++++++- internal/web/service/reality_scan_test.go | 59 +++++- internal/web/service/xray_metrics.go | 23 +- internal/web/service/xray_metrics_test.go | 48 +++++ .../web/service/xray_setting_dns_routing.go | 44 +++- .../service/xray_setting_dns_routing_test.go | 70 ++++++- internal/xray/api.go | 4 + internal/xray/hot_diff.go | 17 ++ internal/xray/hot_diff_test.go | 42 ++++ x-ui.service.arch | 2 + x-ui.service.debian | 2 + x-ui.service.rhel | 2 + 34 files changed, 1179 insertions(+), 92 deletions(-) create mode 100644 internal/database/freedom_finalrules_migration_test.go create mode 100644 internal/database/journal_mode_test.go create mode 100644 internal/database/reality_finalmask_migration_test.go create mode 100644 internal/web/service/inbound_client_move_test.go create mode 100644 internal/web/service/inbound_fallback_runtime_test.go diff --git a/frontend/src/lib/xray/inbound-form-adapter.ts b/frontend/src/lib/xray/inbound-form-adapter.ts index c34499c70..4362655c3 100644 --- a/frontend/src/lib/xray/inbound-form-adapter.ts +++ b/frontend/src/lib/xray/inbound-form-adapter.ts @@ -14,6 +14,7 @@ import type { Sniffing } from '@/schemas/primitives'; import type { z } from 'zod'; import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize'; import { canEnableSniffing } from '@/lib/xray/protocol-capabilities'; +import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt'; import { XHttpStreamSettingsSchema, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp'; const XMUX_DEFAULTS = XHttpXmuxSchema.parse({}); @@ -178,6 +179,13 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues { xhttp.xmux = { ...XMUX_DEFAULTS, ...(xmux as Record) }; } } + const so = streamRecord.sockopt; + if (so && typeof so === 'object' && !Array.isArray(so)) { + const parsed = SockoptStreamSettingsSchema.safeParse(so); + if (parsed.success) { + streamRecord.sockopt = { ...(so as Record), ...parsed.data }; + } + } } const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing; diff --git a/frontend/src/pages/inbounds/form/useSecurityActions.ts b/frontend/src/pages/inbounds/form/useSecurityActions.ts index a5dc5e387..a869bb943 100644 --- a/frontend/src/pages/inbounds/form/useSecurityActions.ts +++ b/frontend/src/pages/inbounds/form/useSecurityActions.ts @@ -86,11 +86,12 @@ export function useSecurityActions({ methods, setSaving, messageApi, nodeId, set messageApi.warning(t('pages.inbounds.form.realityTargetRequired')); return; } + const xver = Number(getValues('streamSettings.realitySettings.xver')) || 0; setScanning(true); try { const msg = await HttpUtil.post( '/panel/api/server/scanRealityTarget', - { target }, + { target, xver }, { silent: true }, ); if (!msg?.success || !msg.obj) { diff --git a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx index 662b9187b..f4e15bcb0 100644 --- a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx +++ b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx @@ -61,6 +61,7 @@ interface OutboundSub { url?: string; enabled?: boolean; allowPrivate?: boolean; + allowInsecure?: boolean; prepend?: boolean; priority?: number; tagPrefix?: string; @@ -122,7 +123,7 @@ export default function OutboundsTab({ const [subDrawerOpen, setSubDrawerOpen] = useState(false); const [subs, setSubs] = useState([]); const [subsLoading, setSubsLoading] = useState(false); - const [newSub, setNewSub] = useState({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, prepend: false }); + const [newSub, setNewSub] = useState({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, allowInsecure: false, prepend: false }); const [editingSubId, setEditingSubId] = useState(null); const [savingSub, setSavingSub] = useState(false); const [refreshingId, setRefreshingId] = useState(null); @@ -303,7 +304,7 @@ export default function OutboundsTab({ setSubsLoading(false); } } - function subBody(src: { remark?: string; url?: string; tagPrefix?: string; updateInterval?: number; enabled?: boolean; allowPrivate?: boolean; prepend?: boolean }) { + function subBody(src: { remark?: string; url?: string; tagPrefix?: string; updateInterval?: number; enabled?: boolean; allowPrivate?: boolean; allowInsecure?: boolean; prepend?: boolean }) { return { remark: src.remark ?? '', url: src.url ?? '', @@ -311,11 +312,12 @@ export default function OutboundsTab({ updateInterval: src.updateInterval ?? 600, enabled: src.enabled ?? true, allowPrivate: src.allowPrivate ?? false, + allowInsecure: src.allowInsecure ?? false, prepend: src.prepend ?? false, }; } function resetSubForm() { - setNewSub({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, prepend: false }); + setNewSub({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, allowInsecure: false, prepend: false }); setEditingSubId(null); setPreviewData(null); } @@ -327,6 +329,7 @@ export default function OutboundsTab({ updateInterval: sub.updateInterval ?? 600, enabled: sub.enabled ?? true, allowPrivate: sub.allowPrivate ?? false, + allowInsecure: sub.allowInsecure ?? false, prepend: sub.prepend ?? false, }); setEditingSubId(sub.id); @@ -647,6 +650,12 @@ export default function OutboundsTab({ {t('pages.xray.outboundSub.allowPrivateHint')} + + setNewSub({ ...newSub, allowInsecure: v })} /> +
+ {t('pages.hosts.hints.allowInsecure')} +
+
setNewSub({ ...newSub, prepend: v })} />
diff --git a/frontend/src/test/inbound-form-adapter.test.ts b/frontend/src/test/inbound-form-adapter.test.ts index 360e82431..234f13e2c 100644 --- a/frontend/src/test/inbound-form-adapter.test.ts +++ b/frontend/src/test/inbound-form-adapter.test.ts @@ -155,6 +155,22 @@ describe('transportless streamSettings (wireguard / tunnel)', () => { } }); + it('fills sockopt schema defaults for a stored inbound missing tproxy (#5956)', () => { + const values = rawInboundToFormValues({ + port: 443, + protocol: 'vless', + settings: { clients: [] }, + streamSettings: JSON.stringify({ + network: 'tcp', + security: 'none', + sockopt: { tcpFastOpen: true }, + }), + }); + const stream = values.streamSettings as { sockopt?: { tproxy?: string; tcpFastOpen?: boolean } }; + expect(stream.sockopt?.tproxy).toBe('off'); + expect(stream.sockopt?.tcpFastOpen).toBe(true); + }); + it('still rejects a present-but-invalid network value', () => { const result = InboundFormSchema.safeParse({ port: 12345, diff --git a/internal/database/db.go b/internal/database/db.go index faab8f95b..d080cfcc4 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -1057,7 +1057,7 @@ func runSeeders(isUsersEmpty bool) error { } if empty && isUsersEmpty { - seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash", "LegacyProxySettingsCleanup", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"} + seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"} for _, name := range seeders { if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil { return err @@ -1144,6 +1144,18 @@ func runSeeders(isUsersEmpty bool) error { } } + if !slices.Contains(seedersHistory, "FreedomFinalRulesPrivateEgressBlock") { + if err := hardenFreedomFinalRules(); err != nil { + return err + } + } + + if !slices.Contains(seedersHistory, "InboundRealityFinalmaskTcpStrip") { + if err := stripRealityFinalmaskTcp(); err != nil { + return err + } + } + if !slices.Contains(seedersHistory, "LegacyProxySettingsCleanup") { if err := clearLegacyProxySettings(); err != nil { return err @@ -1592,6 +1604,147 @@ func isLegacyPrivateOnlyFinalRules(v any) bool { return true } +func hardenFreedomFinalRules() error { + var setting model.Setting + err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(&setting).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return db.Create(&model.HistoryOfSeeders{SeederName: "FreedomFinalRulesPrivateEgressBlock"}).Error + } + if err != nil { + return err + } + + updated, changed, rErr := rewriteFreedomFinalRulesPrivateEgress(setting.Value) + if rErr != nil { + log.Printf("FreedomFinalRulesPrivateEgressBlock: skip (invalid xrayTemplateConfig json): %v", rErr) + return db.Create(&model.HistoryOfSeeders{SeederName: "FreedomFinalRulesPrivateEgressBlock"}).Error + } + + return db.Transaction(func(tx *gorm.DB) error { + if changed { + if err := tx.Model(&model.Setting{}).Where("key = ?", "xrayTemplateConfig"). + Update("value", updated).Error; err != nil { + return err + } + } + return tx.Create(&model.HistoryOfSeeders{SeederName: "FreedomFinalRulesPrivateEgressBlock"}).Error + }) +} + +func rewriteFreedomFinalRulesPrivateEgress(raw string) (string, bool, error) { + if strings.TrimSpace(raw) == "" { + return raw, false, nil + } + var cfg map[string]any + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + return raw, false, err + } + outbounds, ok := cfg["outbounds"].([]any) + if !ok { + return raw, false, nil + } + changed := false + for _, ob := range outbounds { + obj, ok := ob.(map[string]any) + if !ok { + continue + } + if proto, _ := obj["protocol"].(string); proto != "freedom" { + continue + } + settings, ok := obj["settings"].(map[string]any) + if !ok { + continue + } + if !isAllowOnlyFinalRules(settings["finalRules"]) && !isLegacyPrivateOnlyFinalRules(settings["finalRules"]) { + continue + } + settings["finalRules"] = []any{ + map[string]any{"action": "block", "ip": []any{"geoip:private"}}, + map[string]any{"action": "allow"}, + } + changed = true + } + if !changed { + return raw, false, nil + } + out, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return raw, false, err + } + return string(out), true, nil +} + +func stripRealityFinalmaskTcp() error { + var inbounds []model.Inbound + if err := db.Find(&inbounds).Error; err != nil { + return err + } + return db.Transaction(func(tx *gorm.DB) error { + for i := range inbounds { + updated, changed := stripRealityFinalmaskTcpFromStream(inbounds[i].StreamSettings) + if !changed { + continue + } + if err := tx.Model(&model.Inbound{}).Where("id = ?", inbounds[i].Id). + Update("stream_settings", updated).Error; err != nil { + return err + } + log.Printf("InboundRealityFinalmaskTcpStrip: removed finalmask.tcp from REALITY inbound %d (%s)", inbounds[i].Id, inbounds[i].Tag) + } + return tx.Create(&model.HistoryOfSeeders{SeederName: "InboundRealityFinalmaskTcpStrip"}).Error + }) +} + +func stripRealityFinalmaskTcpFromStream(raw string) (string, bool) { + if strings.TrimSpace(raw) == "" { + return raw, false + } + var stream map[string]any + if err := json.Unmarshal([]byte(raw), &stream); err != nil { + return raw, false + } + if sec, _ := stream["security"].(string); sec != "reality" { + return raw, false + } + finalmask, ok := stream["finalmask"].(map[string]any) + if !ok { + return raw, false + } + if tcp, _ := finalmask["tcp"].([]any); len(tcp) == 0 { + return raw, false + } + delete(finalmask, "tcp") + if len(finalmask) == 0 { + delete(stream, "finalmask") + } + out, err := json.Marshal(stream) + if err != nil { + return raw, false + } + return string(out), true +} + +func isAllowOnlyFinalRules(v any) bool { + rules, ok := v.([]any) + if !ok || len(rules) != 1 { + return false + } + rule, ok := rules[0].(map[string]any) + if !ok { + return false + } + if action, _ := rule["action"].(string); action != "allow" { + return false + } + for k := range rule { + if k != "action" { + return false + } + } + return true +} + func normalizeClientJSONFields(obj map[string]any) { normalizeInt := func(key string) { raw, exists := obj[key] @@ -1776,7 +1929,7 @@ func InitDB(dbPath string) error { if dsn == "" { return errors.New("XUI_DB_TYPE=postgres but XUI_DB_DSN is empty") } - db, err = gorm.Open(postgres.Open(dsn), c) + db, err = openPostgresWithRetry(dsn, c) if err != nil { return err } @@ -1787,7 +1940,8 @@ func InitDB(dbPath string) error { } sync := sqliteSynchronous() - dsn := dbPath + "?_journal_mode=DELETE&_busy_timeout=10000&_synchronous=" + sync + "&_txlock=immediate" + journal := sqliteJournalMode() + dsn := dbPath + "?_journal_mode=" + journal + "&_busy_timeout=10000&_synchronous=" + sync + "&_txlock=immediate" db, err = gorm.Open(sqlite.Open(dsn), c) if err != nil { return err @@ -1798,7 +1952,7 @@ func InitDB(dbPath string) error { } pragmas := []string{ - "PRAGMA journal_mode=DELETE", + "PRAGMA journal_mode=" + journal, "PRAGMA busy_timeout=10000", "PRAGMA synchronous=" + sync, fmt.Sprintf("PRAGMA cache_size=-%d", envInt("XUI_DB_CACHE_MB", 32)*1024), @@ -1851,6 +2005,40 @@ func normalizeApiTokenCreatedAtSeconds() error { UpdateColumn("created_at", gorm.Expr("created_at / ?", 1000)).Error } +// openPostgresWithRetry retries the initial PostgreSQL connection with +// backoff so a database that starts slower than the panel (or drops out +// briefly) does not immediately kill the process and trip systemd's +// restart loop. Every failed attempt logs the real driver error, which +// used to be buried behind a generic startup failure. +func openPostgresWithRetry(dsn string, c *gorm.Config) (*gorm.DB, error) { + delays := []time.Duration{0, 2 * time.Second, 5 * time.Second, 10 * time.Second, 20 * time.Second, 30 * time.Second} + var lastErr error + for i, delay := range delays { + if delay > 0 { + time.Sleep(delay) + } + conn, err := gorm.Open(postgres.Open(dsn), c) + if err == nil { + if i > 0 { + log.Printf("postgres connection established on attempt %d/%d", i+1, len(delays)) + } + return conn, nil + } + lastErr = err + log.Printf("postgres connection attempt %d/%d failed: %v", i+1, len(delays), err) + } + return nil, fmt.Errorf("postgres unreachable after %d attempts: %w", len(delays), lastErr) +} + +func sqliteJournalMode() string { + switch strings.ToUpper(strings.TrimSpace(os.Getenv("XUI_DB_JOURNAL_MODE"))) { + case "DELETE": + return "DELETE" + default: + return "WAL" + } +} + func sqliteSynchronous() string { switch strings.ToUpper(strings.TrimSpace(os.Getenv("XUI_DB_SYNCHRONOUS"))) { case "OFF": @@ -1909,7 +2097,7 @@ func Checkpoint() error { if IsPostgres() { return nil } - return db.Exec("PRAGMA wal_checkpoint;").Error + return db.Exec("PRAGMA wal_checkpoint(TRUNCATE);").Error } func ValidateSQLiteDB(dbPath string) error { diff --git a/internal/database/freedom_finalrules_migration_test.go b/internal/database/freedom_finalrules_migration_test.go new file mode 100644 index 000000000..201ef9f94 --- /dev/null +++ b/internal/database/freedom_finalrules_migration_test.go @@ -0,0 +1,87 @@ +package database + +import ( + "encoding/json" + "testing" +) + +func TestRewriteFreedomFinalRulesPrivateEgress(t *testing.T) { + hardened := []any{ + map[string]any{"action": "block", "ip": []any{"geoip:private"}}, + map[string]any{"action": "allow"}, + } + + tests := []struct { + name string + raw string + wantChanged bool + wantRules []any + }{ + { + name: "allow-only default is hardened", + raw: `{"outbounds":[{"protocol":"freedom","settings":{"domainStrategy":"AsIs","finalRules":[{"action":"allow"}]},"tag":"direct"}]}`, + wantChanged: true, + wantRules: hardened, + }, + { + name: "legacy private-only allow is hardened", + raw: `{"outbounds":[{"protocol":"freedom","settings":{"finalRules":[{"action":"allow","ip":["geoip:private"]}]},"tag":"direct"}]}`, + wantChanged: true, + wantRules: hardened, + }, + { + name: "customized rules are preserved", + raw: `{"outbounds":[{"protocol":"freedom","settings":{"finalRules":[{"action":"block","ip":["1.2.3.4"]},{"action":"allow"}]},"tag":"direct"}]}`, + wantChanged: false, + }, + { + name: "non-freedom outbounds are ignored", + raw: `{"outbounds":[{"protocol":"blackhole","settings":{},"tag":"blocked"}]}`, + wantChanged: false, + }, + { + name: "empty config is untouched", + raw: "", + wantChanged: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + updated, changed, err := rewriteFreedomFinalRulesPrivateEgress(tc.raw) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if changed != tc.wantChanged { + t.Fatalf("changed = %v, want %v", changed, tc.wantChanged) + } + if !tc.wantChanged { + if updated != tc.raw { + t.Fatalf("raw config mutated without change flag:\n%s", updated) + } + return + } + var cfg map[string]any + if err := json.Unmarshal([]byte(updated), &cfg); err != nil { + t.Fatalf("updated config is not valid json: %v", err) + } + outbounds := cfg["outbounds"].([]any) + settings := outbounds[0].(map[string]any)["settings"].(map[string]any) + gotRules, _ := json.Marshal(settings["finalRules"]) + wantRules, _ := json.Marshal(tc.wantRules) + if string(gotRules) != string(wantRules) { + t.Fatalf("finalRules = %s, want %s", gotRules, wantRules) + } + }) + } +} + +func TestRewriteFreedomFinalRulesPrivateEgressInvalidJSON(t *testing.T) { + _, changed, err := rewriteFreedomFinalRulesPrivateEgress("{not json") + if err == nil { + t.Fatal("expected a json error for malformed config") + } + if changed { + t.Fatal("malformed config must not report changed") + } +} diff --git a/internal/database/journal_mode_test.go b/internal/database/journal_mode_test.go new file mode 100644 index 000000000..f08c56350 --- /dev/null +++ b/internal/database/journal_mode_test.go @@ -0,0 +1,82 @@ +package database + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +func journalModeOf(t *testing.T) string { + t.Helper() + var mode string + if err := db.Raw("PRAGMA journal_mode;").Scan(&mode).Error; err != nil { + t.Fatalf("read journal_mode: %v", err) + } + return mode +} + +func TestSqliteJournalModeDefaultsToWal(t *testing.T) { + t.Setenv("XUI_DB_JOURNAL_MODE", "") + dbDir := t.TempDir() + if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = CloseDB() }) + + if got := journalModeOf(t); got != "wal" { + t.Fatalf("journal_mode = %q, want wal", got) + } +} + +func TestSqliteJournalModeEnvOverrideDelete(t *testing.T) { + t.Setenv("XUI_DB_JOURNAL_MODE", "delete") + dbDir := t.TempDir() + if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = CloseDB() }) + + if got := journalModeOf(t); got != "delete" { + t.Fatalf("journal_mode = %q, want delete", got) + } +} + +func TestWalCheckpointMakesRawFileBackupComplete(t *testing.T) { + t.Setenv("XUI_DB_JOURNAL_MODE", "") + dbDir := t.TempDir() + dbPath := filepath.Join(dbDir, "x-ui.db") + if err := InitDB(dbPath); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = CloseDB() }) + + if err := db.Create(&model.Setting{Key: "walBackupProbe", Value: "42"}).Error; err != nil { + t.Fatalf("write setting: %v", err) + } + if err := Checkpoint(); err != nil { + t.Fatalf("Checkpoint: %v", err) + } + + raw, err := os.ReadFile(dbPath) + if err != nil { + t.Fatalf("read db file: %v", err) + } + copyPath := filepath.Join(t.TempDir(), "copy.db") + if err := os.WriteFile(copyPath, raw, 0o600); err != nil { + t.Fatalf("write copy: %v", err) + } + if err := ValidateSQLiteDB(copyPath); err != nil { + t.Fatalf("checkpointed raw copy must be a valid sqlite db: %v", err) + } + + dump, err := DumpSQLiteToBytes(copyPath) + if err != nil { + t.Fatalf("dump copy: %v", err) + } + if !bytes.Contains(dump, []byte("walBackupProbe")) { + t.Fatal("raw-file backup taken after Checkpoint must contain the latest write") + } +} diff --git a/internal/database/model/model.go b/internal/database/model/model.go index 773d0c214..4ad891c32 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -1098,6 +1098,7 @@ type OutboundSubscription struct { Url string `json:"url" form:"url"` Enabled bool `json:"enabled" form:"enabled" gorm:"default:true"` AllowPrivate bool `json:"allowPrivate" form:"allowPrivate" gorm:"default:false"` + AllowInsecure bool `json:"allowInsecure" form:"allowInsecure" gorm:"default:false"` TagPrefix string `json:"tagPrefix" form:"tagPrefix"` UpdateInterval int `json:"updateInterval" form:"updateInterval" gorm:"default:600"` // seconds between refreshes Priority int `json:"priority" form:"priority" gorm:"default:0"` // order among subscriptions in the merged outbounds (lower = earlier) diff --git a/internal/database/reality_finalmask_migration_test.go b/internal/database/reality_finalmask_migration_test.go new file mode 100644 index 000000000..64a73ad68 --- /dev/null +++ b/internal/database/reality_finalmask_migration_test.go @@ -0,0 +1,93 @@ +package database + +import ( + "encoding/json" + "testing" +) + +func TestStripRealityFinalmaskTcpFromStream(t *testing.T) { + tests := []struct { + name string + raw string + wantChanged bool + wantAbsent []string + wantPresent []string + }{ + { + name: "reality with tcp masks is stripped", + raw: `{"network":"tcp","security":"reality","realitySettings":{"privateKey":"k"},"finalmask":{"tcp":[{"type":"sudoku"}]}}`, + wantChanged: true, + wantAbsent: []string{"finalmask"}, + wantPresent: []string{"realitySettings"}, + }, + { + name: "reality with tcp and udp masks keeps udp", + raw: `{"security":"reality","finalmask":{"tcp":[{"type":"sudoku"}],"udp":[{"type":"salt"}]}}`, + wantChanged: true, + wantAbsent: []string{"tcp"}, + wantPresent: []string{"udp"}, + }, + { + name: "tls with tcp masks is untouched", + raw: `{"security":"tls","finalmask":{"tcp":[{"type":"sudoku"}]}}`, + wantChanged: false, + }, + { + name: "reality without finalmask is untouched", + raw: `{"security":"reality","realitySettings":{}}`, + wantChanged: false, + }, + { + name: "empty stream is untouched", + raw: "", + wantChanged: false, + }, + { + name: "invalid json is untouched", + raw: "{not json", + wantChanged: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + updated, changed := stripRealityFinalmaskTcpFromStream(tc.raw) + if changed != tc.wantChanged { + t.Fatalf("changed = %v, want %v (updated: %s)", changed, tc.wantChanged, updated) + } + if !tc.wantChanged { + if updated != tc.raw { + t.Fatalf("stream mutated without change flag: %s", updated) + } + return + } + var stream map[string]any + if err := json.Unmarshal([]byte(updated), &stream); err != nil { + t.Fatalf("updated stream is not valid json: %v", err) + } + flat, _ := json.Marshal(stream) + for _, key := range tc.wantAbsent { + if containsJSONKey(stream, key) { + t.Fatalf("key %q should be gone: %s", key, flat) + } + } + for _, key := range tc.wantPresent { + if !containsJSONKey(stream, key) { + t.Fatalf("key %q should survive: %s", key, flat) + } + } + }) + } +} + +func containsJSONKey(m map[string]any, key string) bool { + if _, ok := m[key]; ok { + return true + } + for _, v := range m { + if nested, ok := v.(map[string]any); ok && containsJSONKey(nested, key) { + return true + } + } + return false +} diff --git a/internal/sub/host_sub.go b/internal/sub/host_sub.go index 16ca6647d..d5a4fc5e1 100644 --- a/internal/sub/host_sub.go +++ b/internal/sub/host_sub.go @@ -125,6 +125,20 @@ func hostMuxOverride(ep map[string]any) string { // the base stream strips sockopt) and finalMask. No-op for legacy externalProxy // entries (which never carry these keys), so existing output is unchanged. func applyHostStreamOverrides(ep map[string]any, stream map[string]any) { + if hh, ok := ep["hostHeader"].(string); ok && hh != "" { + for _, key := range []string{"wsSettings", "httpupgradeSettings", "xhttpSettings"} { + if ts, ok := stream[key].(map[string]any); ok && ts != nil { + ts["host"] = hh + } + } + } + if p, ok := ep["path"].(string); ok && p != "" { + for _, key := range []string{"wsSettings", "httpupgradeSettings", "xhttpSettings"} { + if ts, ok := stream[key].(map[string]any); ok && ts != nil { + ts["path"] = p + } + } + } if sp, ok := ep["sockoptParams"].(string); ok && sp != "" { var sockopt map[string]any if json.Unmarshal([]byte(sp), &sockopt) == nil && len(sockopt) > 0 { diff --git a/internal/sub/host_sub_test.go b/internal/sub/host_sub_test.go index 7a0befc83..a57b85ef5 100644 --- a/internal/sub/host_sub_test.go +++ b/internal/sub/host_sub_test.go @@ -271,6 +271,39 @@ func TestSub_HostAllowInsecure(t *testing.T) { } } +// 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) { diff --git a/internal/web/controller/client.go b/internal/web/controller/client.go index 093217ea8..8c88a0922 100644 --- a/internal/web/controller/client.go +++ b/internal/web/controller/client.go @@ -109,22 +109,22 @@ func (a *ClientController) get(c *gin.Context) { email := c.Param("email") rec, err := a.clientService.GetRecordByEmail(nil, email) if err != nil { - jsonMsg(c, I18nWeb(c, "get"), err) + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) return } inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id) if err != nil { - jsonMsg(c, I18nWeb(c, "get"), err) + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) return } externalLinks, err := a.clientService.GetExternalLinksForRecord(rec.Id) if err != nil { - jsonMsg(c, I18nWeb(c, "get"), err) + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) return } flow, err := a.clientService.EffectiveFlow(nil, rec.Id) if err != nil { - jsonMsg(c, I18nWeb(c, "get"), err) + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) return } rec.Flow = flow diff --git a/internal/web/controller/server.go b/internal/web/controller/server.go index 11c512a1b..bd1078d60 100644 --- a/internal/web/controller/server.go +++ b/internal/web/controller/server.go @@ -463,7 +463,8 @@ func (a *ServerController) getRemoteCertHash(c *gin.Context) { // scanRealityTarget runs a live TLS 1.3 probe against the candidate REALITY // target and returns a structured feasibility verdict plus the cert SAN names. func (a *ServerController) scanRealityTarget(c *gin.Context) { - res, err := a.serverService.ScanRealityTarget(c.PostForm("target")) + xver, _ := strconv.Atoi(c.PostForm("xver")) + res, err := a.serverService.ScanRealityTarget(c.PostForm("target"), xver) if err != nil { jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.scanRealityTargetError"), err) return diff --git a/internal/web/controller/xray_setting.go b/internal/web/controller/xray_setting.go index 446259820..9f1d006d5 100644 --- a/internal/web/controller/xray_setting.go +++ b/internal/web/controller/xray_setting.go @@ -408,6 +408,7 @@ func (a *XraySettingController) createOutboundSub(c *gin.Context) { prefix := c.PostForm("tagPrefix") enabled := c.PostForm("enabled") != "false" allowPrivate := c.PostForm("allowPrivate") == "true" + allowInsecure := c.PostForm("allowInsecure") == "true" prepend := c.PostForm("prepend") == "true" intervalStr := c.PostForm("updateInterval") interval := 600 @@ -416,7 +417,7 @@ func (a *XraySettingController) createOutboundSub(c *gin.Context) { interval = v } } - sub, err := a.OutboundSubscriptionService.Create(remark, rawURL, prefix, enabled, interval, allowPrivate, prepend) + sub, err := a.OutboundSubscriptionService.Create(remark, rawURL, prefix, enabled, interval, allowPrivate, prepend, allowInsecure) if err != nil { jsonMsg(c, "Failed to create outbound subscription", err) return @@ -436,6 +437,7 @@ func (a *XraySettingController) updateOutboundSub(c *gin.Context) { prefix := c.PostForm("tagPrefix") enabled := c.PostForm("enabled") != "false" allowPrivate := c.PostForm("allowPrivate") == "true" + allowInsecure := c.PostForm("allowInsecure") == "true" prepend := c.PostForm("prepend") == "true" intervalStr := c.PostForm("updateInterval") interval := 600 @@ -444,7 +446,7 @@ func (a *XraySettingController) updateOutboundSub(c *gin.Context) { interval = v } } - if err := a.OutboundSubscriptionService.Update(subID, remark, rawURL, prefix, enabled, interval, allowPrivate, prepend); err != nil { + if err := a.OutboundSubscriptionService.Update(subID, remark, rawURL, prefix, enabled, interval, allowPrivate, prepend, allowInsecure); err != nil { jsonMsg(c, "Failed to update outbound subscription", err) return } @@ -511,12 +513,13 @@ func (a *XraySettingController) parseOutboundSubURL(c *gin.Context) { return } allowPrivate := c.PostForm("allowPrivate") == "true" + allowInsecure := c.PostForm("allowInsecure") == "true" // Use a throw-away service instance; it only needs the settingService for proxy. svc := service.OutboundSubscriptionService{} // We don't have a direct "fetch once" that returns without storing, so we // temporarily create a disabled row, refresh it, then delete. Cleaner would // be to expose a pure ParseURL on the service, but this keeps the surface small. - tmp, err := svc.Create("preview", rawURL, "", false, 600, allowPrivate, false) + tmp, err := svc.Create("preview", rawURL, "", false, 600, allowPrivate, false, allowInsecure) if err != nil { jsonMsg(c, "Failed to preview subscription", err) return diff --git a/internal/web/service/config.json b/internal/web/service/config.json index d67412bab..29077fa1a 100644 --- a/internal/web/service/config.json +++ b/internal/web/service/config.json @@ -33,6 +33,7 @@ "settings": { "domainStrategy": "AsIs", "finalRules": [ + { "action": "block", "ip": ["geoip:private"] }, { "action": "allow" } ] }, diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 2c17665bc..902490b8b 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -1418,45 +1418,64 @@ func (s *InboundService) buildRuntimeInboundForAPI(tx *gorm.DB, inbound *model.I return nil, err } - clients, ok := settings["clients"].([]any) - if !ok { + mutated := false + if clients, ok := settings["clients"].([]any); ok { + var clientStats []xray.ClientTraffic + err := tx.Model(xray.ClientTraffic{}). + Where("inbound_id = ?", inbound.Id). + Select("email", "enable"). + Find(&clientStats).Error + if err != nil { + return nil, err + } + + enableMap := make(map[string]bool, len(clientStats)) + for _, clientTraffic := range clientStats { + enableMap[clientTraffic.Email] = clientTraffic.Enable + } + + finalClients := make([]any, 0, len(clients)) + for _, client := range clients { + c, ok := client.(map[string]any) + if !ok { + continue + } + + email, _ := c["email"].(string) + if enable, exists := enableMap[email]; exists && !enable { + continue + } + + if manualEnable, ok := c["enable"].(bool); ok && !manualEnable { + continue + } + + finalClients = append(finalClients, c) + } + + settings["clients"] = finalClients + mutated = true + } + + if inboundCanHostFallbacks(inbound) { + fallbacks, fbErr := s.fallbackService.BuildFallbacksJSON(tx, inbound.Id) + if fbErr != nil { + return nil, fbErr + } + if len(fallbacks) > 0 { + generic := make([]any, 0, len(fallbacks)) + for _, f := range fallbacks { + generic = append(generic, f) + } + settings["fallbacks"] = generic + mutated = true + } + } + + if !mutated { return &runtimeInbound, nil } - var clientStats []xray.ClientTraffic - err := tx.Model(xray.ClientTraffic{}). - Where("inbound_id = ?", inbound.Id). - Select("email", "enable"). - Find(&clientStats).Error - if err != nil { - return nil, err - } - - enableMap := make(map[string]bool, len(clientStats)) - for _, clientTraffic := range clientStats { - enableMap[clientTraffic.Email] = clientTraffic.Enable - } - - finalClients := make([]any, 0, len(clients)) - for _, client := range clients { - c, ok := client.(map[string]any) - if !ok { - continue - } - - email, _ := c["email"].(string) - if enable, exists := enableMap[email]; exists && !enable { - continue - } - - if manualEnable, ok := c["enable"].(bool); ok && !manualEnable { - continue - } - - finalClients = append(finalClients, c) - } - - settings["clients"] = finalClients modifiedSettings, err := json.MarshalIndent(settings, "", " ") if err != nil { return nil, err diff --git a/internal/web/service/inbound_client_move_test.go b/internal/web/service/inbound_client_move_test.go new file mode 100644 index 000000000..b0c6e9d02 --- /dev/null +++ b/internal/web/service/inbound_client_move_test.go @@ -0,0 +1,60 @@ +package service + +import ( + "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" +) + +// TestGetClientByEmail_AfterMoveBetweenInbounds is the #6059 regression: a +// client moved to another inbound keeps a client_traffics row pointing at the +// old inbound (which still exists), so email lookups used to fail with +// "Client Not Found In Inbound For Email" and the Telegram bot could not build +// links or QR codes for the client anymore. +func TestGetClientByEmail_AfterMoveBetweenInbounds(t *testing.T) { + setupBulkDB(t) + svc := &InboundService{} + db := database.GetDB() + + oldClients := []model.Client{ + {Email: "stay@x", ID: "11111111-1111-1111-1111-111111111111", Enable: true}, + } + movedClients := []model.Client{ + {Email: "moved@x", ID: "22222222-2222-2222-2222-222222222222", Enable: true}, + } + + oldIb := mkInbound(t, 30101, model.VLESS, clientsSettings(t, oldClients)) + newIb := mkInbound(t, 30102, model.VLESS, clientsSettings(t, movedClients)) + if err := svc.clientService.SyncInbound(nil, oldIb.Id, oldClients); err != nil { + t.Fatalf("SyncInbound old: %v", err) + } + if err := svc.clientService.SyncInbound(nil, newIb.Id, movedClients); err != nil { + t.Fatalf("SyncInbound new: %v", err) + } + + stale := xray.ClientTraffic{InboundId: oldIb.Id, Email: "moved@x", Enable: true, Up: 5, Down: 7} + if err := db.Create(&stale).Error; err != nil { + t.Fatalf("seed stale traffic row: %v", err) + } + + traffic, inbound, err := svc.GetClientInboundByEmail("moved@x") + if err != nil { + t.Fatalf("GetClientInboundByEmail: %v", err) + } + if traffic == nil || traffic.Email != "moved@x" { + t.Fatalf("traffic = %+v, want the moved@x row", traffic) + } + if inbound == nil || inbound.Id != newIb.Id { + t.Fatalf("inbound = %+v, want the new inbound %d", inbound, newIb.Id) + } + + _, client, err := svc.GetClientByEmail("moved@x") + if err != nil { + t.Fatalf("GetClientByEmail: %v", err) + } + if client == nil || client.Email != "moved@x" { + t.Fatalf("client = %+v, want moved@x", client) + } +} diff --git a/internal/web/service/inbound_clients.go b/internal/web/service/inbound_clients.go index d9cd33707..7e6a1e511 100644 --- a/internal/web/service/inbound_clients.go +++ b/internal/web/service/inbound_clients.go @@ -413,9 +413,39 @@ func (s *InboundService) GetClientInboundByEmail(email string) (traffic *xray.Cl inbound, err = s.GetInbound(ids[0]) } } + if err == nil && inbound != nil && !s.inboundHasClientEmail(inbound, email) { + // The pointed-at inbound still exists but no longer carries the client — + // the client was moved to another inbound (#6059). Resolve through the + // client_inbounds link to the inbound that actually hosts it now. + ids, idErr := s.clientService.GetInboundIdsForEmail(db, email) + if idErr == nil { + for _, id := range ids { + if id == inbound.Id { + continue + } + if other, oErr := s.GetInbound(id); oErr == nil && s.inboundHasClientEmail(other, email) { + inbound = other + break + } + } + } + } return traffic, inbound, err } +func (s *InboundService) inboundHasClientEmail(inbound *model.Inbound, email string) bool { + clients, err := s.GetClients(inbound) + if err != nil { + return false + } + for _, client := range clients { + if client.Email == email { + return true + } + } + return false +} + func (s *InboundService) GetClientByEmail(clientEmail string) (*xray.ClientTraffic, *model.Client, error) { traffic, inbound, err := s.GetClientInboundByEmail(clientEmail) if err != nil { diff --git a/internal/web/service/inbound_fallback_runtime_test.go b/internal/web/service/inbound_fallback_runtime_test.go new file mode 100644 index 000000000..68798e890 --- /dev/null +++ b/internal/web/service/inbound_fallback_runtime_test.go @@ -0,0 +1,78 @@ +package service + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// TestBuildRuntimeInboundForAPI_InjectsFallbacks is the #5963 regression: the +// runtime inbound sent to nodes never carried the inbound_fallbacks rows, so +// fallbacks configured on the master silently vanished from every node. +func TestBuildRuntimeInboundForAPI_InjectsFallbacks(t *testing.T) { + setupBulkDB(t) + svc := &InboundService{} + db := database.GetDB() + + clients := []model.Client{ + {Email: "fb@x", ID: "11111111-1111-1111-1111-111111111111", Enable: true}, + } + master := mkInbound(t, 30201, model.VLESS, clientsSettings(t, clients)) + master.StreamSettings = `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"s"}}` + if err := db.Save(master).Error; err != nil { + t.Fatalf("save master stream: %v", err) + } + + fb := model.InboundFallback{MasterId: master.Id, ChildId: 0, Dest: "8081", Alpn: "h2", Path: "/fb", Xver: 1} + if err := db.Create(&fb).Error; err != nil { + t.Fatalf("seed fallback: %v", err) + } + + runtimeIb, err := svc.buildRuntimeInboundForAPI(db, master) + if err != nil { + t.Fatalf("buildRuntimeInboundForAPI: %v", err) + } + + var settings map[string]any + if err := json.Unmarshal([]byte(runtimeIb.Settings), &settings); err != nil { + t.Fatalf("runtime settings not valid json: %v", err) + } + fallbacks, ok := settings["fallbacks"].([]any) + if !ok || len(fallbacks) != 1 { + t.Fatalf("runtime settings must carry the fallback, got: %s", runtimeIb.Settings) + } + if !strings.Contains(runtimeIb.Settings, `"dest"`) || !strings.Contains(runtimeIb.Settings, "8081") { + t.Fatalf("fallback dest missing from runtime settings: %s", runtimeIb.Settings) + } +} + +// A non-fallback-capable inbound (ws transport) must stay untouched. +func TestBuildRuntimeInboundForAPI_NoFallbacksOnWsInbound(t *testing.T) { + setupBulkDB(t) + svc := &InboundService{} + db := database.GetDB() + + clients := []model.Client{ + {Email: "ws@x", ID: "22222222-2222-2222-2222-222222222222", Enable: true}, + } + ib := mkInbound(t, 30202, model.VLESS, clientsSettings(t, clients)) + ib.StreamSettings = `{"network":"ws","security":"none"}` + if err := db.Save(ib).Error; err != nil { + t.Fatalf("save stream: %v", err) + } + fb := model.InboundFallback{MasterId: ib.Id, Dest: "8082"} + if err := db.Create(&fb).Error; err != nil { + t.Fatalf("seed fallback: %v", err) + } + + runtimeIb, err := svc.buildRuntimeInboundForAPI(db, ib) + if err != nil { + t.Fatalf("buildRuntimeInboundForAPI: %v", err) + } + if strings.Contains(runtimeIb.Settings, "fallbacks") { + t.Fatalf("ws inbound must not receive fallbacks: %s", runtimeIb.Settings) + } +} diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index d80cfccc7..eb9b646bb 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -125,7 +125,14 @@ func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote, } } } - if _, err := rt.ReconcileInbound(ctx, ib, existsOnNode); err != nil { + // Reconcile with the same runtime-built payload interactive pushes + // send (disabled clients filtered, settings.fallbacks injected) so + // fingerprints line up and fallback edits actually reach the node. + runtimeIb := ib + if built, bErr := s.buildRuntimeInboundForAPI(db, ib); bErr == nil { + runtimeIb = built + } + if _, err := rt.ReconcileInbound(ctx, runtimeIb, existsOnNode); err != nil { errs = append(errs, fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err)) } } diff --git a/internal/web/service/outbound_subscription.go b/internal/web/service/outbound_subscription.go index 489bff8ac..6f5e92d5b 100644 --- a/internal/web/service/outbound_subscription.go +++ b/internal/web/service/outbound_subscription.go @@ -2,6 +2,7 @@ package service import ( "context" + "crypto/tls" "encoding/json" "errors" "fmt" @@ -24,14 +25,20 @@ import ( // filterOutboundsRejectedByCore drops outbounds the vendored xray-core config // loader refuses to build — since v26.7.11 that includes unencrypted // vless/trojan outbounds to public addresses — because one such outbound in -// the merged config would keep the whole core from starting. +// the merged config would keep the whole core from starting. When the running +// core predates that rejection, unencrypted outbounds are kept, mirroring +// CheckXrayConfig's version gate. func filterOutboundsRejectedByCore(label string, outbounds []any) ([]any, []string) { + coreVersion := "Unknown" + if p != nil { + coreVersion = p.GetXrayVersion() + } kept := make([]any, 0, len(outbounds)) var dropped []string for _, ob := range outbounds { raw, err := json.Marshal(ob) if err == nil { - if buildErr := xray.ValidateOutboundConfig(raw); buildErr != nil { + if buildErr := xray.ValidateOutboundConfig(raw); buildErr != nil && !shouldSkipLegacyUnencryptedOutboundRejection(coreVersion, buildErr) { tag := "" if m, ok := ob.(map[string]any); ok { tag, _ = m["tag"].(string) @@ -155,7 +162,7 @@ func (s *OutboundSubscriptionService) nextDefaultSubPrefix(excludeId int) string return fmt.Sprintf("sub%d-", defaultPrefixNumber(subs, excludeId)) } -func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend bool) (*model.OutboundSubscription, error) { +func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend, allowInsecure bool) (*model.OutboundSubscription, error) { cleanURL, err := SanitizePublicHTTPURL(rawURL, allowPrivate) if err != nil { return nil, common.NewError("invalid subscription URL:", err) @@ -178,6 +185,7 @@ func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, e Url: cleanURL, Enabled: enabled, AllowPrivate: allowPrivate, + AllowInsecure: allowInsecure, Prepend: prepend, Priority: int(count), TagPrefix: prefix, @@ -190,7 +198,7 @@ func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, e } // Update updates editable fields. -func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend bool) error { +func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend, allowInsecure bool) error { sub, err := s.Get(id) if err != nil { return err @@ -213,6 +221,7 @@ func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix s sub.Url = cleanURL sub.Enabled = enabled sub.AllowPrivate = allowPrivate + sub.AllowInsecure = allowInsecure sub.Prepend = prepend sub.TagPrefix = prefix sub.UpdateInterval = updateInterval @@ -284,14 +293,27 @@ func (s *OutboundSubscriptionService) RefreshAllEnabled() (int, error) { // goes through the SSRF-guarded dialer, which resolves, checks and dials the same // IP atomically — closing the DNS-rebinding gap left by validating the hostname // separately from the dial. -func (s *OutboundSubscriptionService) subscriptionFetchClient(timeout time.Duration) *http.Client { +func (s *OutboundSubscriptionService) subscriptionFetchClient(timeout time.Duration, allowInsecure bool) *http.Client { + var client *http.Client if s.settingService.PanelEgressProxyURL() != "" { - return s.settingService.NewProxiedHTTPClient(timeout) + client = s.settingService.NewProxiedHTTPClient(timeout) + } else { + client = &http.Client{ + Timeout: timeout, + Transport: &http.Transport{DialContext: netsafe.SSRFGuardedDialContext}, + } } - return &http.Client{ - Timeout: timeout, - Transport: &http.Transport{DialContext: netsafe.SSRFGuardedDialContext}, + if allowInsecure { + if tr, ok := client.Transport.(*http.Transport); ok && tr != nil { + cloned := tr.Clone() + if cloned.TLSClientConfig == nil { + cloned.TLSClientConfig = &tls.Config{} + } + cloned.TLSClientConfig.InsecureSkipVerify = true + client.Transport = cloned + } } + return client } // fetchAndStore does the actual network + parse + stability + persist work. @@ -309,7 +331,7 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript } sub.Url = cleanURL // persist the cleaned version - client := s.subscriptionFetchClient(30 * time.Second) + client := s.subscriptionFetchClient(30*time.Second, sub.AllowInsecure) // Re-validate every redirect hop: the initial host is checked above, but a // redirect could still point at a private/internal address (SSRF). Cap the // redirect chain as well. diff --git a/internal/web/service/outbound_subscription_ssrf_test.go b/internal/web/service/outbound_subscription_ssrf_test.go index ddf646489..979efe0fe 100644 --- a/internal/web/service/outbound_subscription_ssrf_test.go +++ b/internal/web/service/outbound_subscription_ssrf_test.go @@ -12,7 +12,7 @@ import ( func TestSubscriptionFetchClientBlocksPrivateDial(t *testing.T) { setupSettingTestDB(t) - client := (&OutboundSubscriptionService{}).subscriptionFetchClient(5 * time.Second) + client := (&OutboundSubscriptionService{}).subscriptionFetchClient(5*time.Second, false) ctx := netsafe.ContextWithAllowPrivate(context.Background(), false) req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:1/", nil) diff --git a/internal/web/service/reality_scan.go b/internal/web/service/reality_scan.go index 2b4b99854..b17c9fd37 100644 --- a/internal/web/service/reality_scan.go +++ b/internal/web/service/reality_scan.go @@ -170,7 +170,7 @@ func enumerateCIDR(cidr string, max int) ([]string, error) { return ips, nil } -func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string, timeout time.Duration) *RealityScanResult { +func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string, timeout time.Duration, xver int) *RealityScanResult { addr := net.JoinHostPort(dialHost, strconv.Itoa(port)) res := &RealityScanResult{Port: port} if net.ParseIP(dialHost) != nil { @@ -196,6 +196,17 @@ func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string, defer conn.Close() _ = conn.SetDeadline(time.Now().Add(timeout)) + // A REALITY inbound with xver>=1 fronts a target that speaks the PROXY + // protocol (e.g. an Nginx listener with `proxy_protocol`), so the probe + // must lead with a PROXY header or the target resets the connection and + // the scan reports a spurious handshake failure (#6082). + if xver >= 1 { + if err := writeProxyProtocolHeader(conn, xver); err != nil { + res.Reason = "proxy protocol write failed: " + err.Error() + return res + } + } + cfg := &tls.Config{ ServerName: sni, InsecureSkipVerify: true, @@ -272,16 +283,16 @@ func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string, return res } -func (s *ServerService) probeRealityTarget(host string, port int) *RealityScanResult { - return s.probeRealityAddr(host, port, host, realityScanTimeout) +func (s *ServerService) probeRealityTarget(host string, port int, xver int) *RealityScanResult { + return s.probeRealityAddr(host, port, host, realityScanTimeout, xver) } -func (s *ServerService) ScanRealityTarget(target string) (*RealityScanResult, error) { +func (s *ServerService) ScanRealityTarget(target string, xver int) (*RealityScanResult, error) { host, port, err := splitRealityTarget(target) if err != nil { return nil, err } - return s.probeRealityTarget(host, port), nil + return s.probeRealityTarget(host, port, xver), nil } func (s *ServerService) ScanRealityTargets(targetsCSV string) ([]*RealityScanResult, error) { @@ -336,7 +347,7 @@ func (s *ServerService) ScanRealityTargets(targetsCSV string) ([]*RealityScanRes go func(idx int, tk realityProbeTask) { defer wg.Done() defer func() { <-sem }() - r := s.probeRealityAddr(tk.dialHost, tk.port, tk.sni, tk.timeout) + r := s.probeRealityAddr(tk.dialHost, tk.port, tk.sni, tk.timeout, 0) if tk.bulk && r.TLSVersion == "" { return } @@ -389,3 +400,55 @@ func sortRealityResults(results []*RealityScanResult) { return a.LatencyMs - b.LatencyMs }) } + +// writeProxyProtocolHeader emits a PROXY protocol header describing the local +// connection so a target that requires it (Nginx `proxy_protocol`, matching a +// REALITY inbound's xver) accepts the probe instead of resetting it. xver 1 +// sends the human-readable v1 header; xver 2 sends the binary v2 header. The +// addresses come from the already-dialed connection, so they are always a +// consistent, real (src, dst) pair. +func writeProxyProtocolHeader(conn net.Conn, xver int) error { + local, lok := conn.LocalAddr().(*net.TCPAddr) + remote, rok := conn.RemoteAddr().(*net.TCPAddr) + if !lok || !rok { + return fmt.Errorf("connection has no TCP addresses") + } + if xver >= 2 { + return writeProxyProtocolV2(conn, local, remote) + } + return writeProxyProtocolV1(conn, local, remote) +} + +func writeProxyProtocolV1(conn net.Conn, local, remote *net.TCPAddr) error { + fam := "TCP4" + if local.IP.To4() == nil || remote.IP.To4() == nil { + fam = "TCP6" + } + header := fmt.Sprintf("PROXY %s %s %s %d %d\r\n", fam, local.IP.String(), remote.IP.String(), local.Port, remote.Port) + _, err := conn.Write([]byte(header)) + return err +} + +func writeProxyProtocolV2(conn net.Conn, local, remote *net.TCPAddr) error { + buf := []byte{0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A} + buf = append(buf, 0x21) + + src4, dst4 := local.IP.To4(), remote.IP.To4() + if src4 != nil && dst4 != nil { + buf = append(buf, 0x11) + buf = append(buf, 0x00, 12) + buf = append(buf, src4...) + buf = append(buf, dst4...) + buf = append(buf, byte(local.Port>>8), byte(local.Port)) + buf = append(buf, byte(remote.Port>>8), byte(remote.Port)) + } else { + buf = append(buf, 0x21) + buf = append(buf, 0x00, 36) + buf = append(buf, local.IP.To16()...) + buf = append(buf, remote.IP.To16()...) + buf = append(buf, byte(local.Port>>8), byte(local.Port)) + buf = append(buf, byte(remote.Port>>8), byte(remote.Port)) + } + _, err := conn.Write(buf) + return err +} diff --git a/internal/web/service/reality_scan_test.go b/internal/web/service/reality_scan_test.go index e93c99fea..c365bf9a7 100644 --- a/internal/web/service/reality_scan_test.go +++ b/internal/web/service/reality_scan_test.go @@ -2,6 +2,7 @@ package service import ( "crypto/tls" + "net" "testing" ) @@ -77,13 +78,13 @@ func TestSplitRealityTarget(t *testing.T) { } func TestScanRealityTargetInputValidation(t *testing.T) { - if _, err := (&ServerService{}).ScanRealityTarget(""); err == nil { + if _, err := (&ServerService{}).ScanRealityTarget("", 0); err == nil { t.Error("ScanRealityTarget(empty) expected error, got nil") } } func TestScanRealityTargetBlocksPrivate(t *testing.T) { - res, err := (&ServerService{}).ScanRealityTarget("127.0.0.1:443") + res, err := (&ServerService{}).ScanRealityTarget("127.0.0.1:443", 0) if err != nil { t.Fatalf("ScanRealityTarget(loopback) unexpected error: %v", err) } @@ -109,3 +110,57 @@ func TestScanRealityTargetsHandlesPrivateAndBadInput(t *testing.T) { } } } + +func TestWriteProxyProtocolV1(t *testing.T) { + server, client := net.Pipe() + defer client.Close() + + local := &net.TCPAddr{IP: net.ParseIP("192.0.2.10"), Port: 51234} + remote := &net.TCPAddr{IP: net.ParseIP("203.0.113.5"), Port: 443} + + got := make(chan string, 1) + go func() { + buf := make([]byte, 128) + n, _ := server.Read(buf) + got <- string(buf[:n]) + }() + + if err := writeProxyProtocolV1(client, local, remote); err != nil { + t.Fatalf("writeProxyProtocolV1: %v", err) + } + line := <-got + want := "PROXY TCP4 192.0.2.10 203.0.113.5 51234 443\r\n" + if line != want { + t.Fatalf("v1 header = %q, want %q", line, want) + } +} + +func TestWriteProxyProtocolV2Signature(t *testing.T) { + server, client := net.Pipe() + defer client.Close() + + local := &net.TCPAddr{IP: net.ParseIP("192.0.2.10"), Port: 51234} + remote := &net.TCPAddr{IP: net.ParseIP("203.0.113.5"), Port: 443} + + got := make(chan []byte, 1) + go func() { + buf := make([]byte, 128) + n, _ := server.Read(buf) + got <- append([]byte(nil), buf[:n]...) + }() + + if err := writeProxyProtocolV2(client, local, remote); err != nil { + t.Fatalf("writeProxyProtocolV2: %v", err) + } + hdr := <-got + sig := []byte{0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A} + if len(hdr) < 16 || string(hdr[:12]) != string(sig) { + t.Fatalf("v2 header missing the protocol signature: %v", hdr) + } + if hdr[12] != 0x21 { + t.Fatalf("v2 version/command byte = 0x%02x, want 0x21", hdr[12]) + } + if hdr[13] != 0x11 { + t.Fatalf("v2 family/protocol byte = 0x%02x, want 0x11 (TCP over IPv4)", hdr[13]) + } +} diff --git a/internal/web/service/xray_metrics.go b/internal/web/service/xray_metrics.go index 28ef95593..3633e065d 100644 --- a/internal/web/service/xray_metrics.go +++ b/internal/web/service/xray_metrics.go @@ -5,11 +5,12 @@ import ( "encoding/json" "fmt" "net/http" - "regexp" "sort" "strings" "sync" "time" + "unicode" + "unicode/utf8" "github.com/mhsanaei/3x-ui/v3/internal/eventbus" "github.com/mhsanaei/3x-ui/v3/internal/logger" @@ -61,7 +62,19 @@ type outboundHealth struct { notified bool } -var validObsTag = regexp.MustCompile(`^[a-zA-Z0-9._\-]+$`) +const maxObsTagLength = 128 + +func validObsTag(tag string) bool { + if tag == "" || len(tag) > maxObsTagLength || !utf8.ValidString(tag) { + return false + } + for _, r := range tag { + if unicode.IsControl(r) { + return false + } + } + return true +} func obsHistoryKey(tag string) string { return "xrObs." + tag + ".delay" @@ -102,7 +115,7 @@ func (s *XrayMetricsService) ObservatorySnapshot() []ObsTagSnapshot { } func (s *XrayMetricsService) HasObservatoryTag(tag string) bool { - if !validObsTag.MatchString(tag) { + if !validObsTag(tag) { return false } s.mu.RLock() @@ -112,7 +125,7 @@ func (s *XrayMetricsService) HasObservatoryTag(tag string) bool { } func (s *XrayMetricsService) AggregateObservatory(tag string, bucketSeconds, maxPoints int) []map[string]any { - if !validObsTag.MatchString(tag) { + if !validObsTag(tag) { return []map[string]any{} } return xrayMetrics.aggregate(obsHistoryKey(tag), bucketSeconds, maxPoints) @@ -212,7 +225,7 @@ func (s *XrayMetricsService) applyObservatory(t time.Time, entries map[string]ra if tag == "" { tag = key } - if !validObsTag.MatchString(tag) { + if !validObsTag(tag) { continue } snap := ObsTagSnapshot{ diff --git a/internal/web/service/xray_metrics_test.go b/internal/web/service/xray_metrics_test.go index c95749b96..afecf1d5f 100644 --- a/internal/web/service/xray_metrics_test.go +++ b/internal/web/service/xray_metrics_test.go @@ -2,6 +2,7 @@ package service import ( "path/filepath" + "strings" "testing" "time" @@ -120,3 +121,50 @@ func TestApplyObservatoryDebounce(t *testing.T) { }) } } + +func TestValidObsTag(t *testing.T) { + tests := []struct { + name string + tag string + want bool + }{ + {"plain ascii", "proxy-1", true}, + {"dots and underscores", "warp_us.east", true}, + {"flag emoji", "🇩🇪 Germany", true}, + {"cyrillic", "Германия", true}, + {"spaces allowed", "US proxy 2", true}, + {"empty rejected", "", false}, + {"control char rejected", "bad\x00tag", false}, + {"newline rejected", "bad\ntag", false}, + {"invalid utf8 rejected", string([]byte{0xff, 0xfe}), false}, + {"overlong rejected", strings.Repeat("a", maxObsTagLength+1), false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := validObsTag(tc.tag); got != tc.want { + t.Fatalf("validObsTag(%q) = %v, want %v", tc.tag, got, tc.want) + } + }) + } +} + +func TestApplyObservatoryKeepsUnicodeTags(t *testing.T) { + dbDir := t.TempDir() + if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + s := &XrayMetricsService{settingService: SettingService{}} + s.applyObservatory(time.Unix(1000, 0), map[string]rawObsEntry{ + "🇩🇪 Berlin": {Alive: true, Delay: 42, LastTryTime: 1}, + }) + + if !s.HasObservatoryTag("🇩🇪 Berlin") { + t.Fatal("emoji-tagged outbound must appear in the observatory") + } + snaps := s.ObservatorySnapshot() + if len(snaps) != 1 || snaps[0].Tag != "🇩🇪 Berlin" { + t.Fatalf("snapshot = %+v, want the emoji tag", snaps) + } +} diff --git a/internal/web/service/xray_setting_dns_routing.go b/internal/web/service/xray_setting_dns_routing.go index 16eff08a4..1ded733d1 100644 --- a/internal/web/service/xray_setting_dns_routing.go +++ b/internal/web/service/xray_setting_dns_routing.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net" "reflect" + "slices" "sort" "strconv" "strings" @@ -11,7 +12,39 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/util/netsafe" ) -// dnsAllowRuleShape identifies routing rules this file manages: a plain +// dnsAllowRuleTag marks the routing rules this file manages. Both +// xray-core and the Routing tab's rule editor round-trip ruleTag +// untouched, so it is a stable provenance marker: only rules carrying it +// (or legacy managed rules recognized by exact content, see +// managedDnsAllowRule) are ever stripped and rebuilt. +const dnsAllowRuleTag = "xui-dns-allow" + +// managedDnsAllowRule reports whether a routing rule was created by this +// file. Current managed rules carry dnsAllowRuleTag. Rules written before +// the tag existed are adopted only when their shape matches AND their +// exact ip-set/port content equals a currently configured private DNS +// endpoint group — a hand-written rule that merely resembles the managed +// shape (e.g. a CIDR allow for a NAS on port 5000, #6056) never matches +// and is left untouched. +func managedDnsAllowRule(rule map[string]any, groups []dnsAllowPortGroup) bool { + if tag, _ := rule["ruleTag"].(string); tag == dnsAllowRuleTag { + return true + } + if !dnsAllowRuleShape(rule) { + return false + } + port, _ := rule["port"].(string) + ips := append([]string(nil), readRuleIPs(rule["ip"])...) + sort.Strings(ips) + for _, g := range groups { + if strconv.Itoa(g.port) == port && slices.Equal(ips, g.ips) { + return true + } + } + return false +} + +// dnsAllowRuleShape identifies the legacy managed-rule shape: a plain // "type=field, ip=[...], port=..., outboundTag=direct" rule with no other // matchers. An "enabled" key is tolerated as long as it's true — the // Routing tab's rule editor (RuleFormModal.tsx submit()) and its enabled @@ -21,10 +54,6 @@ import ( // toggled off (enabled=false) is treated as no longer ours: the admin // explicitly turned it off, and re-enabling it on the next save would // silently override that choice. -// -// Rules shaped like this are kept in sync with the current dns.servers -// config on every save; anything else (including rules an admin wrote by -// hand that happen to also allow-list an IP) is left untouched. func dnsAllowRuleShape(rule map[string]any) bool { if t, _ := rule["type"].(string); t != "field" { return false @@ -270,7 +299,7 @@ func collectPrivateDnsAllowGroups(dnsRaw json.RawMessage) []dnsAllowPortGroup { // otherwise silently reintroduce the stall with nothing to notice or fix // it). The rebuilt result is only written back if it actually differs // from the input, so well-formed configs aren't churned on every save. -// Manually-authored rules are never touched — see dnsAllowRuleShape. +// Manually-authored rules are never touched — see managedDnsAllowRule. func EnsureDnsServerRouting(raw string) (string, error) { var cfg map[string]json.RawMessage if err := json.Unmarshal([]byte(raw), &cfg); err != nil { @@ -339,7 +368,7 @@ func EnsureDnsServerRouting(raw string) (string, error) { func rebuildDnsAllowRules(rules []map[string]any, groups []dnsAllowPortGroup) []map[string]any { clean := make([]map[string]any, 0, len(rules)) for _, rule := range rules { - if !dnsAllowRuleShape(rule) { + if !managedDnsAllowRule(rule, groups) { clean = append(clean, rule) } } @@ -353,6 +382,7 @@ func rebuildDnsAllowRules(rules []map[string]any, groups []dnsAllowPortGroup) [] for _, g := range groups { managed = append(managed, map[string]any{ "type": "field", + "ruleTag": dnsAllowRuleTag, "ip": g.ips, "port": strconv.Itoa(g.port), "outboundTag": "direct", diff --git a/internal/web/service/xray_setting_dns_routing_test.go b/internal/web/service/xray_setting_dns_routing_test.go index 2e7c5c860..466028a14 100644 --- a/internal/web/service/xray_setting_dns_routing_test.go +++ b/internal/web/service/xray_setting_dns_routing_test.go @@ -216,6 +216,8 @@ func TestEnsureDnsServerRouting_IdempotentOnSecondSave(t *testing.T) { } func TestEnsureDnsServerRouting_UpdatesOwnedRuleWhenServersChange(t *testing.T) { + // A legacy (untagged) managed rule matching the current dns.servers is + // adopted: rebuilt once with the ruleTag marker, then stable. in := `{ "dns": {"servers": ["172.20.0.53"]}, "routing": { @@ -229,8 +231,19 @@ func TestEnsureDnsServerRouting_UpdatesOwnedRuleWhenServersChange(t *testing.T) if err != nil { t.Fatalf("unexpected err: %v", err) } - if out != in { - t.Fatalf("dns servers unchanged, expected no-op, got: %s", out) + rules := rulesFromRaw(t, out) + if len(rules) != 2 { + t.Fatalf("rules len = %d, want 2 (legacy rule adopted in place): %s", len(rules), out) + } + if tag, _ := rules[0]["ruleTag"].(string); tag != dnsAllowRuleTag { + t.Fatalf("adopted rule should carry %q, got %v", dnsAllowRuleTag, rules[0]) + } + stable, err := EnsureDnsServerRouting(out) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if stable != out { + t.Fatalf("expected no further change once tagged\nfirst: %s\nsecond: %s", out, stable) } // Admin adds a second internal resolver on the same port. @@ -238,7 +251,7 @@ func TestEnsureDnsServerRouting_UpdatesOwnedRuleWhenServersChange(t *testing.T) "dns": {"servers": ["172.20.0.53", "10.0.0.53"]}, "routing": { "rules": [ - {"type":"field","ip":["172.20.0.53"],"port":"53","outboundTag":"direct"}, + {"type":"field","ruleTag":"xui-dns-allow","ip":["172.20.0.53"],"port":"53","outboundTag":"direct"}, {"type":"field","outboundTag":"blocked","ip":["geoip:private"]} ] } @@ -247,7 +260,7 @@ func TestEnsureDnsServerRouting_UpdatesOwnedRuleWhenServersChange(t *testing.T) if err != nil { t.Fatalf("unexpected err: %v", err) } - rules := rulesFromRaw(t, out2) + rules = rulesFromRaw(t, out2) if len(rules) != 2 { t.Fatalf("rules len = %d, want 2 (existing rule updated in place): %s", len(rules), out2) } @@ -258,13 +271,13 @@ func TestEnsureDnsServerRouting_UpdatesOwnedRuleWhenServersChange(t *testing.T) } func TestEnsureDnsServerRouting_RemovesOwnedRuleWhenNoLongerNeeded(t *testing.T) { - // Admin switches dns.servers to a public resolver — our previously - // inserted allow-rule is now dead weight and should be dropped. + // Admin switches dns.servers to a public resolver — the tagged managed + // rule is now dead weight and should be dropped. in := `{ "dns": {"servers": ["1.1.1.1"]}, "routing": { "rules": [ - {"type":"field","ip":["172.20.0.53"],"port":"53","outboundTag":"direct"}, + {"type":"field","ruleTag":"xui-dns-allow","ip":["172.20.0.53"],"port":"53","outboundTag":"direct"}, {"type":"field","outboundTag":"blocked","ip":["geoip:private"]} ] } @@ -282,6 +295,49 @@ func TestEnsureDnsServerRouting_RemovesOwnedRuleWhenNoLongerNeeded(t *testing.T) } } +func TestEnsureDnsServerRouting_KeepsManualDirectRuleOnSave(t *testing.T) { + // The #6056 regression: a hand-written "LAN service over direct" rule + // (CIDR ip + custom port, no ruleTag) matches nothing the panel + // manages and must survive a save untouched, even when dns.servers has + // no private entries at all. + in := `{ + "dns": {"servers": ["1.1.1.1"]}, + "routing": { + "rules": [ + {"type":"field","ip":["192.168.178.0/24"],"port":"5000","outboundTag":"direct","enabled":true}, + {"type":"field","outboundTag":"blocked","ip":["geoip:private"]} + ] + } + }` + out, err := EnsureDnsServerRouting(in) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if out != in { + t.Fatalf("manual direct rule must survive the save untouched, got: %s", out) + } + + // Same shape with a single literal private IP — indistinguishable from + // a legacy managed rule only if it matches a configured dns server, + // which it doesn't here, so it must survive too. + in2 := `{ + "dns": {"servers": ["1.1.1.1"]}, + "routing": { + "rules": [ + {"type":"field","ip":["192.168.178.4"],"port":"5000","outboundTag":"direct"}, + {"type":"field","outboundTag":"blocked","ip":["geoip:private"]} + ] + } + }` + out2, err := EnsureDnsServerRouting(in2) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if out2 != in2 { + t.Fatalf("manual single-IP direct rule must survive the save untouched, got: %s", out2) + } +} + func TestEnsureDnsServerRouting_DoesNotTouchManualRuleWithExtraMatchers(t *testing.T) { // A hand-written rule that also allows the DNS IP but carries an extra // matcher isn't recognized as "ours" and must be left alone; a fresh diff --git a/internal/xray/api.go b/internal/xray/api.go index 29332212c..98ae9c07e 100644 --- a/internal/xray/api.go +++ b/internal/xray/api.go @@ -176,6 +176,8 @@ func (x *XrayAPI) DelInbound(tag string) error { // startup — notably v26.7.11's refusal of unencrypted vless/trojan outbounds // whose server address is a public IP or domain. func ValidateOutboundConfig(outbound []byte) error { + ensureXrayAssetLocation() + detour := new(conf.OutboundDetourConfig) if err := json.Unmarshal(outbound, detour); err != nil { return err @@ -191,6 +193,8 @@ func (x *XrayAPI) AddOutbound(outbound []byte) error { } client := *x.HandlerServiceClient + ensureXrayAssetLocation() + conf := new(conf.OutboundDetourConfig) if err := json.Unmarshal(outbound, conf); err != nil { logger.Debug("Failed to unmarshal outbound:", err) diff --git a/internal/xray/hot_diff.go b/internal/xray/hot_diff.go index 0bb0b0ef1..d43898aee 100644 --- a/internal/xray/hot_diff.go +++ b/internal/xray/hot_diff.go @@ -128,6 +128,10 @@ func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool { if exists && diffInboundUsers(oldIb, newIb, diff) { continue } + if exists && (inboundUsesReality(oldIb) || inboundUsesReality(newIb)) { + logger.Debug("hot diff: inbound [", oldIb.Tag, "] REALITY configuration changed; a gRPC remove+add does not reliably rebuild the REALITY authenticator, forcing a full restart") + return false + } diff.RemovedInboundTags = append(diff.RemovedInboundTags, oldIb.Tag) if exists { raw, err := json.Marshal(newIb) @@ -247,6 +251,19 @@ func splitSettingsClients(raw json_util.RawMessage) (map[string]clientEntry, []b return clients, rest, true } +func inboundUsesReality(ib *InboundConfig) bool { + if ib == nil || len(ib.StreamSettings) == 0 { + return false + } + var stream struct { + Security string `json:"security"` + } + if err := json.Unmarshal(ib.StreamSettings, &stream); err != nil { + return false + } + return stream.Security == "reality" +} + func inboundHasReverseClient(ib *InboundConfig) bool { if ib == nil { return false diff --git a/internal/xray/hot_diff_test.go b/internal/xray/hot_diff_test.go index 03c32fd4a..2d464492b 100644 --- a/internal/xray/hot_diff_test.go +++ b/internal/xray/hot_diff_test.go @@ -343,3 +343,45 @@ func TestComputeHotDiff_RoutingStrategyChangeNeedsRestart(t *testing.T) { t.Fatal("domainStrategy change must force a restart") } } + +func TestComputeHotDiff_RealityStreamChangeNeedsRestart(t *testing.T) { + oldCfg := makeHotConfig() + oldCfg.InboundConfigs[1].StreamSettings = json_util.RawMessage(`{"network":"tcp","security":"reality","realitySettings":{"privateKey":"old-key","serverNames":["a.example"]}}`) + newCfg := makeHotConfig() + newCfg.InboundConfigs[1].StreamSettings = json_util.RawMessage(`{"network":"tcp","security":"reality","realitySettings":{"privateKey":"new-key","serverNames":["a.example"]}}`) + + if _, ok := ComputeHotDiff(oldCfg, newCfg); ok { + t.Fatal("a REALITY stream-settings change must force a full restart, not a gRPC hot swap") + } +} + +func TestComputeHotDiff_SecuritySwitchToRealityNeedsRestart(t *testing.T) { + oldCfg := makeHotConfig() + oldCfg.InboundConfigs[1].StreamSettings = json_util.RawMessage(`{"network":"tcp","security":"none"}`) + newCfg := makeHotConfig() + newCfg.InboundConfigs[1].StreamSettings = json_util.RawMessage(`{"network":"tcp","security":"reality","realitySettings":{"privateKey":"k"}}`) + + if _, ok := ComputeHotDiff(oldCfg, newCfg); ok { + t.Fatal("switching security to REALITY must force a full restart") + } +} + +func TestComputeHotDiff_RealityClientOnlyChangeStaysHot(t *testing.T) { + oldCfg := makeHotConfig() + oldCfg.InboundConfigs[1].StreamSettings = json_util.RawMessage(`{"network":"tcp","security":"reality","realitySettings":{"privateKey":"k"}}`) + oldCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","id":"uuid-a"}],"decryption":"none"}`) + newCfg := makeHotConfig() + newCfg.InboundConfigs[1].StreamSettings = json_util.RawMessage(`{"network":"tcp","security":"reality","realitySettings":{"privateKey":"k"}}`) + newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","id":"uuid-a"},{"email":"b","id":"uuid-b"}],"decryption":"none"}`) + + diff, ok := ComputeHotDiff(oldCfg, newCfg) + if !ok { + t.Fatal("client-only change on a REALITY inbound must stay hot-appliable") + } + if len(diff.RemovedInboundTags) != 0 || len(diff.AddedInbounds) != 0 { + t.Fatalf("client-only change must not replace the handler, got %+v", diff) + } + if len(diff.AddedUsers) != 1 || diff.AddedUsers[0].Email != "b" { + t.Fatalf("expected user b added via AlterInbound, got %+v", diff.AddedUsers) + } +} diff --git a/x-ui.service.arch b/x-ui.service.arch index b001db92d..c8134b01a 100644 --- a/x-ui.service.arch +++ b/x-ui.service.arch @@ -2,6 +2,8 @@ Description=x-ui Service After=network.target Wants=network.target +StartLimitIntervalSec=180 +StartLimitBurst=10 [Service] EnvironmentFile=-/etc/conf.d/x-ui diff --git a/x-ui.service.debian b/x-ui.service.debian index d101ce663..dead90993 100644 --- a/x-ui.service.debian +++ b/x-ui.service.debian @@ -2,6 +2,8 @@ Description=x-ui Service After=network.target Wants=network.target +StartLimitIntervalSec=180 +StartLimitBurst=10 [Service] EnvironmentFile=-/etc/default/x-ui diff --git a/x-ui.service.rhel b/x-ui.service.rhel index 70912bc9a..b7ae77764 100644 --- a/x-ui.service.rhel +++ b/x-ui.service.rhel @@ -2,6 +2,8 @@ Description=x-ui Service After=network.target Wants=network.target +StartLimitIntervalSec=180 +StartLimitBurst=10 [Service] EnvironmentFile=-/etc/sysconfig/x-ui