mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-16 00:01:02 +00:00
d7698ec7aa
* feat(xray): browse geosite/geoip categories from routing rules Routing rules made you type category names from memory: nothing showed which categories a database actually contains, what is inside one, or whether a name resolves at all — a typo only surfaced when Xray refused the config. The panel now reads Xray's .dat databases itself and exposes them over four endpoints: databases in the asset folder, a database's categories, one page of a category's rules, and validation of the tokens already in a rule. The reader walks the protobuf wire format directly rather than decoding into Go structs, because a 10 MB geosite.dat holds well over a million domains and materialising them costs ~284 MB where streaming costs ~19 MB. Only the category index is cached, entry pages are scanned on demand, and scans are serialised, so twenty concurrent requests peak at 87 MB instead of 1 GB. A database's type is decided by its contents, not its file name, since custom .dat files are named freely. In the rule form, the source-IP, IP and domain fields gain a database button opening the browser: search over categories, a preview of what a category holds, and a multi-select that merges into the field. Plain domains, CIDRs and categories the panel does not know are left untouched; categories already present come back ticked, and unticking one removes it from the rule. * fix(xray): read geo databases through os.Root and match codes verbatim CodeQL flagged the database read as a path built from a user-supplied value, and it was right about the shape of it. The file name arrives in a request; resolve() rejects traversal and stats the file through an os.Root, but the read itself went through a joined path with os.ReadFile. That left the symlink defence incomplete: the stat could pass while the read followed a link planted — or swapped in — afterwards. Reads now go through the same root, so a request-supplied name never becomes a path this code resolves on its own, and the size limit is applied to the opened file rather than to a separate stat of it. Lookup no longer trims the category code either. It backs the routing-token validator, and the core matches codes verbatim: "geosite: cn" will not start Xray, so repairing that space here hid exactly the typo the validator exists to report. * fix(xray): address review findings on the geo category browser Asset folder. The browser read config.GetBinFolderPath() unconditionally, but the core honours a preset XRAY_LOCATION_ASSET and only falls back to the bin folder (ensureXrayAssetLocation). On an install pointing at a shared asset directory the panel listed an empty folder and reported perfectly valid geosite:/geoip: tokens as missing — the validator warning about a correct config. The directory is now resolved with the core's precedence. Paging. Serving one page read and rescanned the whole database, so walking category-ads-all re-read it per page. The index now records each category's byte range and a page reads only that record through the os.Root handle, with the current category's records held for the duration of a paging session. Profiling that also showed the real cost was not the read but the slice of payload pointers built per call — a category holds a hundred thousand of them — so records are now walked with a callback instead. Ten pages over category-ads-all: 239 MB allocated, now 4.3 MB. Cached failures. Any error from reading a file was latched under the file's size+mtime, so a transient ENOMEM or EMFILE marked a healthy database as damaged until it changed on disk. Only deterministic failures are cached. Wrong kind. A geoip: token typed into a domain field parsed as a plain domain and was waved through, though the core cannot resolve it as one. It is now reported, with its own reason and wording. Frontend. The category filter fed the query key on every keystroke, so each character triggered a request that re-scanned the database; it is debounced now. GeoTokenInput accepts and forwards a ref, so React Hook Form can focus these three fields on a validation error again. A failed validation shows that it failed instead of rendering the same empty state as "no issues". Also drops an unreachable branch in the token-count guard and corrects the categories endpoint docs, where limit is unbounded by default. --------- Co-authored-by: STRENCH0 <17428017+STRENCH0@users.noreply.github.com>
615 lines
21 KiB
Go
615 lines
21 KiB
Go
package controller
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service/integration"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service/outbound"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// XraySettingController handles Xray configuration and settings operations.
|
|
type XraySettingController struct {
|
|
XraySettingService service.XraySettingService
|
|
SettingService service.SettingService
|
|
InboundService service.InboundService
|
|
OutboundService outbound.OutboundService
|
|
XrayService service.XrayService
|
|
WarpService integration.WarpService
|
|
NordService integration.NordService
|
|
OutboundSubscriptionService service.OutboundSubscriptionService
|
|
GeodataService service.GeodataService
|
|
}
|
|
|
|
// NewXraySettingController creates a new XraySettingController and initializes its routes.
|
|
func NewXraySettingController(g *gin.RouterGroup) *XraySettingController {
|
|
a := &XraySettingController{}
|
|
a.initRouter(g)
|
|
return a
|
|
}
|
|
|
|
// initRouter sets up the routes for Xray settings management.
|
|
func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
|
|
g = g.Group("/xray")
|
|
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
|
|
g.GET("/getOutboundsTraffic", a.getOutboundsTraffic)
|
|
g.GET("/getXrayResult", a.getXrayResult)
|
|
|
|
g.POST("/", a.getXraySetting)
|
|
g.POST("/warp/:action", a.warp)
|
|
g.POST("/nord/:action", a.nord)
|
|
g.POST("/update", a.updateSetting)
|
|
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
|
|
g.POST("/testOutbound", a.testOutbound)
|
|
g.POST("/testOutbounds", a.testOutbounds)
|
|
g.POST("/balancerStatus", a.balancerStatus)
|
|
g.POST("/balancerOverride", a.balancerOverride)
|
|
g.POST("/routeTest", a.routeTest)
|
|
|
|
g.GET("/geodata/files", a.geodataFiles)
|
|
g.GET("/geodata/categories", a.geodataCategories)
|
|
g.GET("/geodata/entries", a.geodataEntries)
|
|
g.POST("/geodata/validate", a.geodataValidate)
|
|
|
|
// Outbound subscription (remote outbound lists)
|
|
g.GET("/outbound-subs", a.listOutboundSubs)
|
|
g.POST("/outbound-subs", a.createOutboundSub)
|
|
g.POST("/outbound-subs/:id/refresh", a.refreshOutboundSub)
|
|
g.POST("/outbound-subs/:id/move", a.moveOutboundSub)
|
|
g.POST("/outbound-subs/:id", a.updateOutboundSub)
|
|
g.DELETE("/outbound-subs/:id", a.deleteOutboundSub)
|
|
g.POST("/outbound-subs/:id/del", a.deleteOutboundSub) // POST alias for clients that can't send DELETE
|
|
g.POST("/outbound-subs/parse", a.parseOutboundSubURL) // preview without saving
|
|
}
|
|
|
|
// getXraySetting retrieves the Xray configuration template, inbound tags, and outbound test URL.
|
|
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
|
|
}
|
|
// 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
|
|
}
|
|
}
|
|
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,
|
|
}
|
|
|
|
// Surface subscription outbounds (and their tags) so the frontend can:
|
|
// - show them as read-only items in the Outbounds tab
|
|
// - let users pick them in balancers and routing rules
|
|
// These are not part of the editable template; they are injected at runtime.
|
|
if subObs, err := a.OutboundSubscriptionService.AllActiveOutbounds(); err == nil && len(subObs) > 0 {
|
|
xrayResponse["subscriptionOutbounds"] = subObs
|
|
}
|
|
if subTags, err := a.OutboundSubscriptionService.AllActiveOutboundTags(); err == nil && len(subTags) > 0 {
|
|
xrayResponse["subscriptionOutboundTags"] = subTags
|
|
}
|
|
result, err := json.Marshal(xrayResponse)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
|
|
return
|
|
}
|
|
jsonObj(c, string(result), nil)
|
|
}
|
|
|
|
// updateSetting updates the Xray configuration settings and applies them to
|
|
// the running core right away — through the gRPC API when only inbounds,
|
|
// outbounds or routing rules changed, with a process restart otherwise.
|
|
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
|
|
}
|
|
// Only reconcile a running core; a manually stopped xray stays stopped.
|
|
if a.XrayService.IsXrayRunning() {
|
|
if err := a.XrayService.RestartXray(false); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
|
return
|
|
}
|
|
}
|
|
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), nil)
|
|
}
|
|
|
|
// getDefaultXrayConfig retrieves the default Xray configuration.
|
|
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)
|
|
}
|
|
|
|
// getXrayResult retrieves the current Xray service result.
|
|
func (a *XraySettingController) getXrayResult(c *gin.Context) {
|
|
jsonObj(c, a.XrayService.GetXrayResult(), nil)
|
|
}
|
|
|
|
// 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 "changeIp":
|
|
resp, err = a.WarpService.ChangeWarpIP()
|
|
if err == nil {
|
|
a.XrayService.SetToNeedRestart()
|
|
// Restart the auto-update clock so a scheduled rotation
|
|
// doesn't fire right after this manual one.
|
|
err = a.SettingService.SetWarpLastUpdate(time.Now().Unix())
|
|
}
|
|
case "license":
|
|
license := c.PostForm("license")
|
|
resp, err = a.WarpService.SetWarpLicense(license)
|
|
case "interval":
|
|
interval, convErr := strconv.Atoi(c.PostForm("interval"))
|
|
if convErr != nil || interval < 0 {
|
|
err = common.NewError("invalid warp update interval")
|
|
} else if err = a.SettingService.SetWarpUpdateInterval(interval); err == nil && interval > 0 {
|
|
// Count the interval from now rather than from epoch 0,
|
|
// otherwise the job would rotate on its next tick.
|
|
err = a.SettingService.SetWarpLastUpdate(time.Now().Unix())
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// getOutboundsTraffic retrieves the traffic statistics for outbounds.
|
|
func (a *XraySettingController) getOutboundsTraffic(c *gin.Context) {
|
|
outboundsTraffic, err := a.OutboundService.GetOutboundsTraffic()
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getOutboundTrafficError"), err)
|
|
return
|
|
}
|
|
jsonObj(c, outboundsTraffic, nil)
|
|
}
|
|
|
|
// resetOutboundsTraffic resets the traffic statistics for the specified outbound tag.
|
|
func (a *XraySettingController) resetOutboundsTraffic(c *gin.Context) {
|
|
tag := c.PostForm("tag")
|
|
err := a.OutboundService.ResetOutboundTraffic(tag)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.resetOutboundTrafficError"), err)
|
|
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.
|
|
// Optional form "mode": "tcp" for a fast dial-only probe, "real" for the cold
|
|
// full-request delay, 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")
|
|
mode := c.PostForm("mode")
|
|
|
|
if outboundJSON == "" {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("outbound parameter is required"))
|
|
return
|
|
}
|
|
|
|
// Load the test URL from server settings to prevent SSRF via user-controlled URLs
|
|
testURL, _ := a.SettingService.GetXrayOutboundTestUrl()
|
|
testURL, err := service.SanitizePublicHTTPURL(testURL, false)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
|
|
result, err := a.OutboundService.TestOutbound(outboundJSON, testURL, allOutboundsJSON, mode)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
|
|
jsonObj(c, result, nil)
|
|
}
|
|
|
|
// testOutbounds tests a batch of outbound configurations through one shared
|
|
// temp xray instance and returns an array of results in input order.
|
|
// Form "outbounds": JSON array of outbound configs (required).
|
|
// Optional form "allOutbounds": JSON array of all outbounds; used to resolve sockopt.dialerProxy dependencies.
|
|
// Optional form "mode": "tcp" for fast dial-only probes, "real" for the cold
|
|
// full-request delay, anything else (default) for real HTTP requests routed through each outbound.
|
|
func (a *XraySettingController) testOutbounds(c *gin.Context) {
|
|
outboundsJSON := c.PostForm("outbounds")
|
|
allOutboundsJSON := c.PostForm("allOutbounds")
|
|
mode := c.PostForm("mode")
|
|
|
|
if outboundsJSON == "" {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("outbounds parameter is required"))
|
|
return
|
|
}
|
|
|
|
// Load the test URL from server settings to prevent SSRF via user-controlled URLs
|
|
testURL, _ := a.SettingService.GetXrayOutboundTestUrl()
|
|
testURL, err := service.SanitizePublicHTTPURL(testURL, false)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
|
|
results, err := a.OutboundService.TestOutbounds(outboundsJSON, testURL, allOutboundsJSON, mode)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
|
|
jsonObj(c, results, nil)
|
|
}
|
|
|
|
// balancerStatus reports the live state (override + strategy picks) of the
|
|
// balancer tags given as a comma-separated "tags" form field.
|
|
func (a *XraySettingController) balancerStatus(c *gin.Context) {
|
|
raw := c.PostForm("tags")
|
|
var tags []string
|
|
for tag := range strings.SplitSeq(raw, ",") {
|
|
if tag = strings.TrimSpace(tag); tag != "" {
|
|
tags = append(tags, tag)
|
|
}
|
|
}
|
|
statuses, err := a.XrayService.GetBalancersStatus(tags)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
byTag := make(map[string]service.BalancerStatus, len(statuses))
|
|
for _, status := range statuses {
|
|
byTag[status.Tag] = status
|
|
}
|
|
jsonObj(c, byTag, nil)
|
|
}
|
|
|
|
// balancerOverride forces a balancer to a specific outbound tag; an empty
|
|
// "target" clears the override.
|
|
func (a *XraySettingController) balancerOverride(c *gin.Context) {
|
|
tag := c.PostForm("tag")
|
|
if tag == "" {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("tag is required"))
|
|
return
|
|
}
|
|
target := c.PostForm("target")
|
|
if err := a.XrayService.OverrideBalancer(tag, target); err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
jsonObj(c, "", nil)
|
|
}
|
|
|
|
// routeTest asks the running core which outbound it would route a synthetic
|
|
// connection to.
|
|
func (a *XraySettingController) routeTest(c *gin.Context) {
|
|
port := 0
|
|
if portStr := c.PostForm("port"); portStr != "" {
|
|
parsed, err := strconv.Atoi(portStr)
|
|
if err != nil || parsed < 0 || parsed > 65535 {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("invalid port"))
|
|
return
|
|
}
|
|
port = parsed
|
|
}
|
|
req := xray.RouteTestRequest{
|
|
InboundTag: c.PostForm("inboundTag"),
|
|
Domain: c.PostForm("domain"),
|
|
IP: c.PostForm("ip"),
|
|
Port: port,
|
|
Network: c.PostForm("network"),
|
|
Protocol: c.PostForm("protocol"),
|
|
Email: c.PostForm("email"),
|
|
}
|
|
if req.Domain == "" && req.IP == "" {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("domain or ip is required"))
|
|
return
|
|
}
|
|
result, err := a.XrayService.TestRoute(req)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
jsonObj(c, result, nil)
|
|
}
|
|
|
|
// maxGeodataTokens bounds one validation request; a routing rule listing more
|
|
// categories than this is not something the panel needs to answer for.
|
|
const maxGeodataTokens = 500
|
|
|
|
// geodataFiles lists the geo databases Xray resolves geosite:/geoip: tokens
|
|
// against, including ones that failed to parse.
|
|
func (a *XraySettingController) geodataFiles(c *gin.Context) {
|
|
files, err := a.GeodataService.Files()
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
jsonObj(c, files, nil)
|
|
}
|
|
|
|
// geodataCategories returns one page of a database's categories.
|
|
func (a *XraySettingController) geodataCategories(c *gin.Context) {
|
|
offset, limit := geodataPaging(c)
|
|
page, err := a.GeodataService.Categories(c.Query("file"), c.Query("q"), offset, limit)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
jsonObj(c, page, nil)
|
|
}
|
|
|
|
// geodataEntries returns one page of the domains or CIDRs inside a category.
|
|
func (a *XraySettingController) geodataEntries(c *gin.Context) {
|
|
code := c.Query("code")
|
|
if code == "" {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("code is required"))
|
|
return
|
|
}
|
|
offset, limit := geodataPaging(c)
|
|
page, err := a.GeodataService.Entries(c.Query("file"), code, c.Query("q"), offset, limit)
|
|
if err != nil {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
|
return
|
|
}
|
|
jsonObj(c, page, nil)
|
|
}
|
|
|
|
// geodataValidate reports which routing tokens do not resolve against the
|
|
// databases on disk.
|
|
func (a *XraySettingController) geodataValidate(c *gin.Context) {
|
|
// Split with a bound rather than splitting first: a 10 MB body of commas
|
|
// would otherwise allocate millions of strings before the limit is checked.
|
|
tokens := strings.SplitN(c.PostForm("tokens"), ",", maxGeodataTokens+1)
|
|
if len(tokens) > maxGeodataTokens {
|
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewErrorf("too many tokens: over %d", maxGeodataTokens))
|
|
return
|
|
}
|
|
jsonObj(c, a.GeodataService.Validate(c.PostForm("kind") == "ip", tokens), nil)
|
|
}
|
|
|
|
func geodataPaging(c *gin.Context) (int, int) {
|
|
offset, err := strconv.Atoi(c.Query("offset"))
|
|
if err != nil {
|
|
offset = 0
|
|
}
|
|
limit, err := strconv.Atoi(c.Query("limit"))
|
|
if err != nil {
|
|
limit = 0
|
|
}
|
|
return offset, limit
|
|
}
|
|
|
|
// --- Outbound Subscription handlers ---
|
|
|
|
func (a *XraySettingController) listOutboundSubs(c *gin.Context) {
|
|
list, err := a.OutboundSubscriptionService.List()
|
|
if err != nil {
|
|
jsonMsg(c, "Failed to list outbound subscriptions", err)
|
|
return
|
|
}
|
|
jsonObj(c, list, nil)
|
|
}
|
|
|
|
func (a *XraySettingController) createOutboundSub(c *gin.Context) {
|
|
remark := c.PostForm("remark")
|
|
rawURL := c.PostForm("url")
|
|
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
|
|
if intervalStr != "" {
|
|
if v, err := parseIntSafe(intervalStr); err == nil && v > 0 {
|
|
interval = v
|
|
}
|
|
}
|
|
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
|
|
}
|
|
jsonObj(c, sub, nil)
|
|
}
|
|
|
|
func (a *XraySettingController) updateOutboundSub(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var subID int
|
|
if _, err := fmt.Sscanf(id, "%d", &subID); err != nil {
|
|
jsonMsg(c, "Invalid id", err)
|
|
return
|
|
}
|
|
remark := c.PostForm("remark")
|
|
rawURL := c.PostForm("url")
|
|
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
|
|
if intervalStr != "" {
|
|
if v, err := parseIntSafe(intervalStr); err == nil && v > 0 {
|
|
interval = v
|
|
}
|
|
}
|
|
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
|
|
}
|
|
jsonObj(c, "", nil)
|
|
}
|
|
|
|
func (a *XraySettingController) deleteOutboundSub(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var subID int
|
|
if _, err := fmt.Sscanf(id, "%d", &subID); err != nil {
|
|
jsonMsg(c, "Invalid id", err)
|
|
return
|
|
}
|
|
if err := a.OutboundSubscriptionService.Delete(subID); err != nil {
|
|
jsonMsg(c, "Failed to delete outbound subscription", err)
|
|
return
|
|
}
|
|
// Signal that xray should drop this subscription's outbounds on next reload.
|
|
a.XrayService.SetToNeedRestart()
|
|
jsonObj(c, "", nil)
|
|
}
|
|
|
|
func (a *XraySettingController) refreshOutboundSub(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var subID int
|
|
if _, err := fmt.Sscanf(id, "%d", &subID); err != nil {
|
|
jsonMsg(c, "Invalid id", err)
|
|
return
|
|
}
|
|
obs, err := a.OutboundSubscriptionService.Refresh(subID)
|
|
if err != nil {
|
|
jsonMsg(c, "Refresh failed", err)
|
|
return
|
|
}
|
|
// Signal that xray should pick up the new outbounds on next restart/reload
|
|
a.XrayService.SetToNeedRestart()
|
|
jsonObj(c, obs, nil)
|
|
}
|
|
|
|
func (a *XraySettingController) moveOutboundSub(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var subID int
|
|
if _, err := fmt.Sscanf(id, "%d", &subID); err != nil {
|
|
jsonMsg(c, "Invalid id", err)
|
|
return
|
|
}
|
|
up := c.PostForm("dir") == "up"
|
|
if err := a.OutboundSubscriptionService.Move(subID, up); err != nil {
|
|
jsonMsg(c, "Failed to reorder outbound subscription", err)
|
|
return
|
|
}
|
|
// Order affects the merged outbounds, so xray needs a reload.
|
|
a.XrayService.SetToNeedRestart()
|
|
jsonObj(c, "", nil)
|
|
}
|
|
|
|
// parseOutboundSubURL is a preview endpoint: it fetches + parses the provided
|
|
// URL but does not persist anything. Useful for the "add subscription" flow
|
|
// so the user can see the resulting outbounds (and assigned tags) before saving.
|
|
func (a *XraySettingController) parseOutboundSubURL(c *gin.Context) {
|
|
rawURL := c.PostForm("url")
|
|
if rawURL == "" {
|
|
jsonMsg(c, "url is required", common.NewError("missing url"))
|
|
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, allowInsecure)
|
|
if err != nil {
|
|
jsonMsg(c, "Failed to preview subscription", err)
|
|
return
|
|
}
|
|
obs, err := svc.Refresh(tmp.Id)
|
|
// best-effort cleanup
|
|
_ = svc.Delete(tmp.Id)
|
|
if err != nil {
|
|
jsonMsg(c, "Failed to fetch/parse subscription", err)
|
|
return
|
|
}
|
|
jsonObj(c, obs, nil)
|
|
}
|
|
|
|
func parseIntSafe(s string) (int, error) {
|
|
var v int
|
|
_, err := fmt.Sscanf(s, "%d", &v)
|
|
return v, err
|
|
}
|