Files
3x-ui/web/controller/xray_setting.go

230 lines
7.6 KiB
Go
Raw Normal View History

2023-12-04 19:20:46 +01:00
package controller
import (
"encoding/json"
2026-05-10 02:13:42 +02:00
"github.com/mhsanaei/3x-ui/v3/util/common"
"github.com/mhsanaei/3x-ui/v3/web/service"
2023-12-04 19:20:46 +01:00
"github.com/gin-gonic/gin"
)
2025-09-20 09:35:50 +02:00
// XraySettingController handles Xray configuration and settings operations.
2023-12-04 19:20:46 +01:00
type XraySettingController struct {
XraySettingService service.XraySettingService
SettingService service.SettingService
2023-12-05 18:13:36 +01:00
InboundService service.InboundService
OutboundService service.OutboundService
2023-12-10 12:57:39 +01:00
XrayService service.XrayService
WarpService service.WarpService
NordService service.NordService
2023-12-04 19:20:46 +01:00
}
2025-09-20 09:35:50 +02:00
// NewXraySettingController creates a new XraySettingController and initializes its routes.
2023-12-04 19:20:46 +01:00
func NewXraySettingController(g *gin.RouterGroup) *XraySettingController {
a := &XraySettingController{}
a.initRouter(g)
return a
}
2025-09-20 09:35:50 +02:00
// initRouter sets up the routes for Xray settings management.
2023-12-04 19:20:46 +01:00
func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
g = g.Group("/xray")
2025-09-17 01:08:59 +02:00
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
g.GET("/getOutboundsTraffic", a.getOutboundsTraffic)
g.GET("/getXrayResult", a.getXrayResult)
2023-12-04 19:20:46 +01:00
g.POST("/", a.getXraySetting)
g.POST("/warp/:action", a.warp)
g.POST("/nord/:action", a.nord)
2025-09-17 01:08:59 +02:00
g.POST("/update", a.updateSetting)
2024-02-07 11:25:31 +03:30
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
g.POST("/testOutbound", a.testOutbound)
2023-12-04 19:20:46 +01:00
}
// getXraySetting retrieves the Xray configuration template, inbound tags, and outbound test URL.
2023-12-04 19:20:46 +01:00
func (a *XraySettingController) getXraySetting(c *gin.Context) {
xraySetting, err := a.SettingService.GetXrayConfigTemplate()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
return
}
Fix blank Xray Settings page from wrapped xrayTemplateConfig (#4059) (#4069) `getXraySetting` builds its response as { "xraySetting": <db value>, "inboundTags": ..., "outboundTestUrl": ... } and embeds the raw DB value as the `xraySetting` field without checking whether the stored value already has that exact shape. The frontend pulls the textarea content from `result.xraySetting` and saves it back verbatim. If the DB ever ends up holding the response-shaped wrapper instead of a real xray config (older installs where this happened at least once, users who imported a copy-pasted response into the textarea, a botched migration, etc.), the next save nests another layer, the one after that nests a third, and the Vue-side JSON.parse of the resulting blob silently fails — the Xray Settings page goes blank. Fix both ends of the round-trip: * Add `service.UnwrapXrayTemplateConfig`. It peels off any number of `xraySetting`-keyed layers, leaving a real xray config behind. The check is conservative: if the outer object already contains any top-level xray key (`inbounds`, `outbounds`, `routing`, `api`, `dns`, `log`, `policy`, `stats`), it is returned unchanged, and there is a depth cap to avoid pathological inputs. * `SaveXraySetting` unwraps before validation so a round-tripped wrapper from an already-corrupted page can no longer re-poison the DB on save. * `getXraySetting` unwraps on read and, when it finds a wrapper, rewrites the DB with the corrected value. Existing broken installs heal themselves on the next visit to the page. Includes unit tests for the passthrough, single-wrap, multi-wrap, string-encoded-inner, and false-positive cases. Co-authored-by: pwnnex <eternxles@gmail.com>
2026-04-21 21:30:02 +03:00
// Older versions of this handler embedded the raw DB value as
// `xraySetting` in the response without checking if the value
// already had that wrapper shape. When the frontend saved it
// back through the textarea verbatim, the wrapper got persisted
// and every subsequent save nested another layer, which is what
// eventually produced the blank Xray Settings page in #4059.
// Strip any such wrapper here, and heal the DB if we found one so
// the next read is O(1) instead of climbing the same pile again.
if unwrapped := service.UnwrapXrayTemplateConfig(xraySetting); unwrapped != xraySetting {
if saveErr := a.XraySettingService.SaveXraySetting(unwrapped); saveErr == nil {
xraySetting = unwrapped
} else {
// Don't fail the read — just serve the unwrapped value
// and leave the DB healing for a later save.
xraySetting = unwrapped
}
}
2023-12-05 18:13:36 +01:00
inboundTags, err := a.InboundService.GetInboundTags()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
return
}
clientReverseTags, err := a.InboundService.GetClientReverseTags()
if err != nil {
clientReverseTags = "[]"
}
outboundTestUrl, _ := a.SettingService.GetXrayOutboundTestUrl()
if outboundTestUrl == "" {
outboundTestUrl = "https://www.google.com/generate_204"
}
xrayResponse := map[string]any{
"xraySetting": json.RawMessage(xraySetting),
"inboundTags": json.RawMessage(inboundTags),
"clientReverseTags": json.RawMessage(clientReverseTags),
"outboundTestUrl": outboundTestUrl,
2026-02-09 22:56:21 +01:00
}
result, err := json.Marshal(xrayResponse)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
return
}
jsonObj(c, string(result), nil)
2023-12-04 19:20:46 +01:00
}
2025-09-20 09:35:50 +02:00
// updateSetting updates the Xray configuration settings.
2023-12-04 19:20:46 +01:00
func (a *XraySettingController) updateSetting(c *gin.Context) {
xraySetting := c.PostForm("xraySetting")
if err := a.XraySettingService.SaveXraySetting(xraySetting); err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
outboundTestUrl := c.PostForm("outboundTestUrl")
if outboundTestUrl == "" {
outboundTestUrl = "https://www.google.com/generate_204"
}
if err := a.SettingService.SetXrayOutboundTestUrl(outboundTestUrl); err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), nil)
2023-12-04 19:20:46 +01:00
}
2025-09-20 09:35:50 +02:00
// getDefaultXrayConfig retrieves the default Xray configuration.
2023-12-04 19:20:46 +01:00
func (a *XraySettingController) getDefaultXrayConfig(c *gin.Context) {
defaultJsonConfig, err := a.SettingService.GetDefaultXrayConfig()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
return
}
jsonObj(c, defaultJsonConfig, nil)
}
2023-12-10 12:57:39 +01:00
2025-09-20 09:35:50 +02:00
// getXrayResult retrieves the current Xray service result.
2023-12-10 12:57:39 +01:00
func (a *XraySettingController) getXrayResult(c *gin.Context) {
jsonObj(c, a.XrayService.GetXrayResult(), nil)
}
2025-09-20 09:35:50 +02:00
// warp handles Warp-related operations based on the action parameter.
func (a *XraySettingController) warp(c *gin.Context) {
action := c.Param("action")
var resp string
var err error
switch action {
case "data":
resp, err = a.WarpService.GetWarpData()
case "del":
err = a.WarpService.DelWarpData()
case "config":
resp, err = a.WarpService.GetWarpConfig()
case "reg":
skey := c.PostForm("privateKey")
pkey := c.PostForm("publicKey")
resp, err = a.WarpService.RegWarp(skey, pkey)
case "license":
license := c.PostForm("license")
resp, err = a.WarpService.SetWarpLicense(license)
}
jsonObj(c, resp, err)
}
// nord handles NordVPN-related operations based on the action parameter.
func (a *XraySettingController) nord(c *gin.Context) {
action := c.Param("action")
var resp string
var err error
switch action {
case "countries":
resp, err = a.NordService.GetCountries()
case "servers":
countryId := c.PostForm("countryId")
resp, err = a.NordService.GetServers(countryId)
case "reg":
token := c.PostForm("token")
resp, err = a.NordService.GetCredentials(token)
case "setKey":
key := c.PostForm("key")
resp, err = a.NordService.SetKey(key)
case "data":
resp, err = a.NordService.GetNordData()
case "del":
err = a.NordService.DelNordData()
}
jsonObj(c, resp, err)
}
2025-09-20 09:35:50 +02:00
// getOutboundsTraffic retrieves the traffic statistics for outbounds.
func (a *XraySettingController) getOutboundsTraffic(c *gin.Context) {
outboundsTraffic, err := a.OutboundService.GetOutboundsTraffic()
if err != nil {
2025-05-09 10:46:29 +07:00
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getOutboundTrafficError"), err)
return
}
jsonObj(c, outboundsTraffic, nil)
}
2024-02-07 11:25:31 +03:30
2025-09-20 09:35:50 +02:00
// resetOutboundsTraffic resets the traffic statistics for the specified outbound tag.
2024-02-07 11:25:31 +03:30
func (a *XraySettingController) resetOutboundsTraffic(c *gin.Context) {
tag := c.PostForm("tag")
err := a.OutboundService.ResetOutboundTraffic(tag)
if err != nil {
2025-05-09 10:46:29 +07:00
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.resetOutboundTrafficError"), err)
2024-02-07 11:25:31 +03:30
return
}
jsonObj(c, "", nil)
}
// testOutbound tests an outbound configuration and returns the delay/response time.
// Optional form "allOutbounds": JSON array of all outbounds; used to resolve sockopt.dialerProxy dependencies.
feat(xray/outbounds): TCP probe mode + Test All + timing breakdown - service.TestOutbound now dispatches on `mode`: - "tcp": parallel net.DialTimeout to every server/peer endpoint (vmess/vless/trojan/ss/socks/http/wireguard). No xray spin-up, no semaphore — safe to run concurrently across outbounds. - "http" (default): existing temp-xray + SOCKS path, now with an httptrace.ClientTrace breakdown (DNS / Connect / TLS / TTFB) alongside the total delay and status code. - testSemaphore renamed to httpTestSemaphore — only HTTP probes serialise, TCP runs free. - TestOutboundResult carries the per-mode extras: timing fields for HTTP, per-endpoint dial list for TCP, plus a `mode` echo. - Controller reads `mode` from the form and passes it through. - useXraySetting: testOutbound accepts mode (default "tcp"); new testAllOutbounds(mode) runs a worker pool (concurrency 8 for TCP, 1 for HTTP) and skips blackhole / loopback / blocked outbounds — also skips freedom / dns under TCP since they have no endpoint. - OutboundsTab: TCP/HTTP radio toggle and a Test All button land in the toolbar; the per-row ⚡ now uses the selected mode. Results surface in a popover with the full timing breakdown plus the endpoint list for TCP probes. Latency header replaces the duplicate "check" column title. Practical effect: testing ten outbounds in TCP mode drops from ~50–100s (serial HTTP) to ~1–2s (parallel dial × 8). HTTP mode stays as the authoritative probe and now shows where the latency actually lives.
2026-05-11 04:17:23 +02:00
// Optional form "mode": "tcp" for a fast dial-only probe (parallel-safe),
// anything else (default) for a full HTTP probe through a temp xray instance.
func (a *XraySettingController) testOutbound(c *gin.Context) {
outboundJSON := c.PostForm("outbound")
allOutboundsJSON := c.PostForm("allOutbounds")
feat(xray/outbounds): TCP probe mode + Test All + timing breakdown - service.TestOutbound now dispatches on `mode`: - "tcp": parallel net.DialTimeout to every server/peer endpoint (vmess/vless/trojan/ss/socks/http/wireguard). No xray spin-up, no semaphore — safe to run concurrently across outbounds. - "http" (default): existing temp-xray + SOCKS path, now with an httptrace.ClientTrace breakdown (DNS / Connect / TLS / TTFB) alongside the total delay and status code. - testSemaphore renamed to httpTestSemaphore — only HTTP probes serialise, TCP runs free. - TestOutboundResult carries the per-mode extras: timing fields for HTTP, per-endpoint dial list for TCP, plus a `mode` echo. - Controller reads `mode` from the form and passes it through. - useXraySetting: testOutbound accepts mode (default "tcp"); new testAllOutbounds(mode) runs a worker pool (concurrency 8 for TCP, 1 for HTTP) and skips blackhole / loopback / blocked outbounds — also skips freedom / dns under TCP since they have no endpoint. - OutboundsTab: TCP/HTTP radio toggle and a Test All button land in the toolbar; the per-row ⚡ now uses the selected mode. Results surface in a popover with the full timing breakdown plus the endpoint list for TCP probes. Latency header replaces the duplicate "check" column title. Practical effect: testing ten outbounds in TCP mode drops from ~50–100s (serial HTTP) to ~1–2s (parallel dial × 8). HTTP mode stays as the authoritative probe and now shows where the latency actually lives.
2026-05-11 04:17:23 +02:00
mode := c.PostForm("mode")
if outboundJSON == "" {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("outbound parameter is required"))
return
}
2026-02-09 22:56:21 +01:00
// Load the test URL from server settings to prevent SSRF via user-controlled URLs
testURL, _ := a.SettingService.GetXrayOutboundTestUrl()
Security hardening: sessions, SSRF, CSP nonce, CSRF logout, trusted proxies (#4275) * refactor(session): store user ID in session instead of full struct Replaces storing the full User object in the session cookie with just the user ID. GetLoginUser now re-fetches the user from the database on every request so credential/permission changes take effect immediately without requiring a re-login. Includes a backward-compatible migration path for existing sessions that still carry the old struct payload. * feat(auth): block panel with default admin/admin credentials and guide credential change checkLogin middleware now detects default admin/admin credentials and redirects every panel route to /panel/settings until they are changed. The settings page auto-opens the Authentication tab, shows a non-dismissible error banner, and lists 'Default credentials' first in the security checklist. Login response includes mustChangeCredentials so the login page can redirect directly. Logout is now POST-only. Password must be at least 10 characters and cannot be admin/admin. * feat(settings): redact secrets in AllSettingView and add TrustedProxyCIDRs Introduces AllSettingView which strips tgBotToken, twoFactorToken, ldapPassword, apiToken and warp/nord secrets before sending them to the browser, replacing them with boolean hasFoo presence flags. A new /panel/setting/secret endpoint allows updating individual secrets by key. Secrets that arrive blank on a save are preserved from the DB rather than overwritten. Adds TrustedProxyCIDRs as a configurable setting (defaults to localhost CIDRs). URL fields are validated before save. * fix(security): SSRF prevention, trusted-proxy header gating, CSP nonce, HTTP timeouts Adds SanitizeHTTPURL / SanitizePublicHTTPURL to reject private-range and loopback targets before any outbound HTTP request (node probe, xray download, outbound test, external traffic inform, tgbot API server, panel updater). Forwarded headers (X-Real-IP, X-Forwarded-For, X-Forwarded-Host) are now only trusted when the direct connection arrives from a CIDR in TrustedProxyCIDRs. CSP policy is tightened with a per-request nonce. HTTP server gains read/write/idle timeouts. Panel updater downloads the script to a temp file instead of piping curl into shell. Xray archive download adds a size cap and response-code check. backuptotgbot is changed from GET to POST. * feat(nodes): add allow-private-address toggle per node Adds AllowPrivateAddress to the Node model (DB default false). When enabled it bypasses the SSRF private-range check for that node's probe URL, allowing nodes hosted on RFC-1918 or loopback addresses (e.g. a private VPN or LAN setup). * chore: frontend UX improvements, CI pipeline, and dev tooling - AppSidebar: logout via POST /logout instead of navigating to GET - InboundList: persist filter state (search, protocol, node) to localStorage across page reloads; add protocol and node filter dropdowns - IndexPage: add health status strip (Xray, CPU, Memory, Update) with quick-action buttons - dependabot: weekly go mod and npm update schedule - ci.yml: add GitHub Actions workflow for build and vet - .nvmrc: pin Node 22 for local development - frontend: bump package.json and package-lock.json - SubPage, DnsPresetsModal, api-docs: minor fixes * fix(ci): stub web/dist before go list to satisfy go:embed at compile time * chore(ui): remove health-strip bar from dashboard top * Revert "feat(auth): block panel with default admin/admin credentials and guide credential change" This reverts commit 56ce6073ce09f08147f989858e0e88b3a4359546. * fix(auth): make logout POST+CSRF and propagate session loss to other tabs - Switch /logout from GET to POST with CSRFMiddleware so it matches the SPA's existing HttpUtil.post('/logout') call (previously 404'd silently) and blocks GET-based logout via image tags or link prefetchers. Handler now returns JSON; the SPA already navigates client-side. - Return 401 (instead of 404) from /panel/api/* when the caller is a browser XHR (X-Requested-With: XMLHttpRequest) so the axios interceptor redirects to the login page on logout-in-another-tab, cookie expiry, and server restart. Anonymous callers still get 404 to keep endpoints hidden from casual scanners. - One-shot the 401 redirect in axios-init.js and hang the rejected promise so queued polls don't stack reloads or surface error toasts while the browser is navigating away. - Add the CSP nonce to the runtime-injected <script> in dist.go so the panel loads under the existing script-src 'nonce-...' policy. - Update api-docs endpoints.js: GET /logout doc entry was missing. * fix(settings): POST /logout after credential change * fix(auth): invalidate other sessions when credentials change When the admin changes username/password from one machine, sessions on every other machine kept working until they manually logged out because session storage is a signed client-side cookie — there is no server-side session list to revoke. Add a per-user LoginEpoch counter stamped into the session at login and re-verified on every authenticated request. UpdateUser and UpdateFirstUser bump the epoch (UpdateUser via gorm.Expr so a single update statement is atomic), so any cookie issued before the change no longer matches the user's current epoch and GetLoginUser returns nil — the SPA's 401 interceptor then redirects to the login page. Backward compatible: the column defaults to 0 and missing cookie values are treated as 0, so sessions issued before this change remain valid until the first credential update. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-05-13 12:52:52 +02:00
testURL, err := service.SanitizePublicHTTPURL(testURL, false)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
2026-02-09 22:56:21 +01:00
feat(xray/outbounds): TCP probe mode + Test All + timing breakdown - service.TestOutbound now dispatches on `mode`: - "tcp": parallel net.DialTimeout to every server/peer endpoint (vmess/vless/trojan/ss/socks/http/wireguard). No xray spin-up, no semaphore — safe to run concurrently across outbounds. - "http" (default): existing temp-xray + SOCKS path, now with an httptrace.ClientTrace breakdown (DNS / Connect / TLS / TTFB) alongside the total delay and status code. - testSemaphore renamed to httpTestSemaphore — only HTTP probes serialise, TCP runs free. - TestOutboundResult carries the per-mode extras: timing fields for HTTP, per-endpoint dial list for TCP, plus a `mode` echo. - Controller reads `mode` from the form and passes it through. - useXraySetting: testOutbound accepts mode (default "tcp"); new testAllOutbounds(mode) runs a worker pool (concurrency 8 for TCP, 1 for HTTP) and skips blackhole / loopback / blocked outbounds — also skips freedom / dns under TCP since they have no endpoint. - OutboundsTab: TCP/HTTP radio toggle and a Test All button land in the toolbar; the per-row ⚡ now uses the selected mode. Results surface in a popover with the full timing breakdown plus the endpoint list for TCP probes. Latency header replaces the duplicate "check" column title. Practical effect: testing ten outbounds in TCP mode drops from ~50–100s (serial HTTP) to ~1–2s (parallel dial × 8). HTTP mode stays as the authoritative probe and now shows where the latency actually lives.
2026-05-11 04:17:23 +02:00
result, err := a.OutboundService.TestOutbound(outboundJSON, testURL, allOutboundsJSON, mode)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, result, nil)
}