mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-16 01:26:07 +00:00
refactor: focused service files, leaf subpackages, and an internal/ layout (#5167)
* refactor(service): split client.go into focused files
client.go had grown to 4455 lines mixing ~10 responsibilities. Split it
verbatim into cohesive same-package files (no behavior change):
client.go foundation: ClientService, ClientWithAttachments,
ClientCreatePayload, ErrClientNotInInbound, sqlInChunk
client_locks.go inbound mutation locks, delete tombstones, compactOrphans
client_lookup.go read-only lookups (GetByID, List, EffectiveFlow, ...)
client_link.go inbound association sync (SyncInbound, DetachInbound, ...)
client_crud.go single-client CRUD + validation + protocol defaults
client_inbound_apply.go low-level inbound-settings mutators + by-email setters
client_bulk.go bulk attach/detach/adjust/delete/create + DelDepleted
client_traffic.go traffic-reset paths
client_groups.go client group management
client_paging.go paged listing, filtering, sorting, summary
Every declaration moved unchanged (verified: identical func/type/const/var
signature set before vs after). Imports redistributed per file via goimports.
go build ./..., go vet, and go test ./web/service/... all pass.
* refactor(service): split inbound.go into focused files
inbound.go was 4100 lines. Split it verbatim into cohesive same-package
files (no behavior change):
inbound.go core inbound CRUD + InboundService (keeps pkg doc)
inbound_protocol.go protocol / stream capability helpers
inbound_node.go node/runtime/remote coordination + online tracking
inbound_traffic.go traffic accounting, reset, client stats
inbound_client_ips.go per-client IP tracking
inbound_clients.go client lookups within inbounds + copy-clients
inbound_disable.go auto-disable invalid inbounds/clients
inbound_migration.go DB migrations
inbound_sublink.go subscription link providers
inbound_util.go generic slice/string helpers
Identical func/type/const/var signature set before vs after; package doc
comment preserved on inbound.go. Imports redistributed via goimports.
Build, vet, and go test ./web/service/... all pass.
* refactor(service): split tgbot.go into focused files
tgbot.go was 3738 lines dominated by a 1246-line answerCallback. Split it
verbatim into cohesive same-package files (no behavior change):
tgbot.go lifecycle, bot setup, caches, small utils
tgbot_router.go incoming update / command / callback dispatch
tgbot_send.go outbound messaging primitives
tgbot_client.go client views, actions, subscription links
tgbot_inbound.go inbound listing / pickers
tgbot_report.go server usage, exhausted, online, backups, notifications
Identical func/type/const/var signature set before vs after. Imports
redistributed via goimports. Build, vet, and go test ./web/service/... pass.
* refactor(client): dedupe single-field by-email setters
ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, and
ResetClientTrafficLimitByEmail shared an identical ~50-line body that
resolves the inbound by email, confirms the client exists, rewrites a
single-client settings payload, and delegates to UpdateInboundClient.
Extract that into applyClientFieldByEmail(inboundSvc, email, mutate) and
reduce each setter to a 3-line wrapper. Behavior is unchanged: same checks
and error strings, same single-client payload contract, same totalGB guard.
SetClientTelegramUserID (resolves by traffic id, different error text) and
ToggleClientEnableByEmail/SetClientEnableByEmail (different return shape and
a pre-read of the old state) intentionally keep their own bodies.
* refactor(service): extract panel/ subpackage
Move the panel-administration leaf services out of the flat service
package into web/service/panel/ (package panel):
user.go UserService (auth / 2FA / LDAP)
panel.go PanelService (restart / self-update) + version helpers
panel_other.go non-unix RestartPanel
panel_unix.go unix RestartPanel
api_token.go ApiTokenService
websocket.go WebSocketService
panel_test.go version/shellQuote unit tests
These are leaves: they depend on core (SettingService, Release) but no
core file references them, so the extraction creates no import cycle.
Core references are now qualified (service.SettingService, service.Release);
callers in main.go, web/web.go, and web/controller/* updated to panel.*.
Build, vet, and go test ./web/... pass.
* refactor(service): extract integration/ subpackage
Move the external-provider integration leaves into web/service/integration/
(package integration):
warp.go WarpService (Cloudflare WARP)
nord.go NordService (NordVPN)
custom_geo.go CustomGeoService (custom geo asset management)
*_test.go custom_geo / panel-proxy tests
These depend on core (SettingService, ServerService, XraySettingService) but
no core file references them. xray_setting.go stays in core because it calls
the unexported SettingService.saveSetting. The shared isBlockedIP SSRF helper
(used by core url_safety.go and by custom_geo) now has a small copy in each
package rather than being exported. Core references qualified; callers in
web/web.go, web/job/*, and web/controller/* updated to integration.*.
Build, vet, and go test ./web/... pass.
* refactor(service): extract tgbot/ subpackage
Move the Telegram bot (6 files + test) into web/service/tgbot/ (package
tgbot). It is a leaf: it embeds five core services (Inbound/Client/Setting/
Server/Xray) and the core never references it, so no import cycle.
To support the package boundary without changing behavior:
- core exposes XrayProcess() *xray.Process so tgbot keeps calling the
exact same running-process methods it used via the package-level `p`;
- three core methods tgbot calls are exported: ClientService.checkIs-
EnabledByEmail -> CheckIsEnabledByEmail, InboundService.getAllEmails ->
GetAllEmails (callers updated in-package);
- tgbot's embedded-field types and the few core type refs (Status,
ClientCreatePayload, SanitizePublicHTTPURL) are now service-qualified.
Callers in main.go, web/web.go, web/job/*, and web/controller/* updated to
tgbot.*. Build, vet, and go test ./web/... pass.
* refactor(service): extract outbound/ subpackage
OutboundService (outbound.go) imports only neutral packages (config,
database, model, xray) and its production code is referenced by no core or
sibling service file — only by web/controller/xray_setting.go and
web/job/xray_traffic_job.go. Move it to web/service/outbound/ (package
outbound); no core qualification needed inside. Callers updated to outbound.*.
The one coupling was a tiny pure test helper, outboundsContainTag, used by
both outbound.go and the core outbound_subscription_test.go; it now has a
small copy in that test file rather than being shared across the boundary.
Build, vet, and go test ./web/... pass.
* refactor(util): move wireguard into its own subpackage
util/wireguard.go was the lone file of the root `util` package (24 lines,
one exported func GenerateWireguardKeypair), while every other util concern
lives in a focused subpackage (util/common, util/crypto, util/netsafe, ...).
Move it to util/wireguard/ (package wireguard) for consistency; its only
importer, web/service/integration/warp.go, is updated. The root `util`
package no longer exists.
* refactor(sub): drop redundant sub prefix from filenames
Inside package sub the subXxx.go prefix just repeats the package name
(like client_*.go did inside service). Rename for consistency; content and
type names are unchanged:
subController.go -> controller.go
subService.go -> service.go
subClashService.go -> clash_service.go
subJsonService.go -> json_service.go
(+ matching _test.go files)
* refactor(controller): rename xui.go -> spa.go
XUIController serves the panel's single-page-app shell; spa.go names that
role plainly (the other controller files are domain-named). File rename only
— the type stays XUIController. api_docs_test.go keys route base paths by
filename, so its "xui.go" case is updated to "spa.go".
* refactor: move backend packages under internal/
Adopt the idiomatic Go application layout: the backend packages now live
under internal/ (a boundary the toolchain enforces), signalling private
implementation instead of a library-style flat root. No runtime behavior
changes — only import paths and a few build/config paths move.
Moved: config, database, logger, mtproto, sub, util, web, xray -> internal/.
main.go stays at the repo root and tools/openapigen stays under tools/ (both
still import internal/* because the internal rule keys off the module root).
The module path github.com/mhsanaei/3x-ui/v3 is unchanged; 149 .go files had
their import prefix rewritten to .../internal/<pkg>.
Couplings the Go compiler can't see, updated to the new layout:
- frontend i18n imports of web/translation (react.ts, setup.components.ts)
- vite outDir + eslint/tsconfig ignore globs -> internal/web/dist
- Dockerfile COPY paths for web/dist and web/translation
- locale.go os.DirFS("web") disk fallback -> "internal/web"
- .gitignore and ci.yml go:embed stub for internal/web/dist
- api_docs_test.go repo-root relative walk (one level deeper)
- tools/openapigen filesystem package paths; ApiTokenView repointed to the
web/service/panel subpackage and codegen regenerated (clears a stale
type the ci.yml codegen check was failing on)
Verified: go build/vet/test (all packages), and frontend typecheck, lint,
vitest (478 tests), and production build into internal/web/dist.
* fix(config): keep test runs from writing logs into the source tree
GetLogFolder() returns a CWD-relative "./log" on Windows. Under `go test`
the working directory is each package's own folder, so InitLogger (called by
tests in web/job, web/service, xray, web/websocket) created stray log/
directories scattered through the source tree (e.g. internal/web/job/log/).
Redirect to a shared temp folder when testing.Testing() reports a test run.
Production behavior is unchanged: Windows still uses ./log next to the binary
and Linux /var/log/x-ui. The log files were always gitignored (*.log) and
never committed; this just stops the noise at the source.
* docs: move subscription-template guide out of root into docs/
sub_templates/ was a top-level folder holding only a README and no actual
templates (3x-ui ships none by design), referenced nowhere and unlinked from
any doc — it read like an empty placeholder cluttering the repo root.
Move the guide to docs/custom-subscription-templates.md (a proper docs home),
reword its intro to read as documentation rather than a folder note, link it
from the Features list in README.md, and drop the empty sub_templates/ folder.
* fix: update stale web/ path references after the internal/ move
The internal/ migration rewrote Go import paths but left some references to
the old top-level layout in docs, comments, and a few runtime disk paths.
Functional (dev-mode only): the disk-serving fallbacks that read the Vite
build from disk when running from source still pointed at web/dist/, which
moved to internal/web/dist/ — so `os.DirFS`/`os.Stat`/`os.ReadFile` in
internal/web/web.go and internal/sub/{sub,controller}.go are corrected.
Production was unaffected (it serves the embedded FS; verified by the Docker
build), but `go run` with a live frontend build silently fell back to embed.
Docs/comments: frontend/README.md, CONTRIBUTING.md, the claude-issue-bot and
release workflows, the openapigen -root help text, and assorted Go comments
now reference internal/web, internal/database, internal/sub, internal/xray,
etc. Package-name mentions (the "web" package), root paths (main.go,
frontend/, install scripts, /etc/x-ui), routes (/panel/api/xray), and the
historical "web/assets no longer exists" note were intentionally left as-is.
* refactor(web): remove the legacy /xui -> /panel redirect middleware
RedirectMiddleware existed only for backward compatibility with the old
`/xui` URL scheme (301-redirecting /xui and /xui/API to /panel and
/panel/api). That cutover was long ago, so drop the middleware, its
registration in initRouter, and the now-inaccurate "URL redirection"
mention in the middleware package doc. Old /xui URLs now 404 like any other
unknown path. HTTPS auto-redirect and auth redirects are unrelated and stay.
* build: fix .dockerignore for internal/ layout and exclude runtime dir
- web/dist -> internal/web/dist: the embedded frontend moved under internal/,
so the stale exclude no longer matched and the locally-built dist could be
sent to the build context (the frontend stage rebuilds it fresh anyway).
- exclude x-ui/: the local runtime directory (SQLite db, geo .dat files, xray
binaries, certs — ~150MB) was being shipped into the build context for no
reason. Verified the pattern excludes only the directory and still keeps
x-ui.sh, which the Dockerfile copies to /usr/bin/x-ui.
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
||||
"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/panel"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// APIController handles the main API routes for the 3x-ui panel, including inbounds and server management.
|
||||
type APIController struct {
|
||||
BaseController
|
||||
inboundController *InboundController
|
||||
serverController *ServerController
|
||||
nodeController *NodeController
|
||||
settingController *SettingController
|
||||
xraySettingController *XraySettingController
|
||||
settingService service.SettingService
|
||||
userService panel.UserService
|
||||
apiTokenService panel.ApiTokenService
|
||||
Tgbot tgbot.Tgbot
|
||||
}
|
||||
|
||||
// NewAPIController creates a new APIController instance and initializes its routes.
|
||||
func NewAPIController(g *gin.RouterGroup, customGeo *integration.CustomGeoService) *APIController {
|
||||
a := &APIController{}
|
||||
a.initRouter(g, customGeo)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *APIController) checkAPIAuth(c *gin.Context) {
|
||||
auth := c.GetHeader("Authorization")
|
||||
if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
|
||||
tok := after
|
||||
if a.apiTokenService.Match(tok) {
|
||||
if u, err := a.userService.GetFirstUser(); err == nil {
|
||||
session.SetAPIAuthUser(c, u)
|
||||
}
|
||||
c.Set("api_authed", true)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
if !session.IsLogin(c) {
|
||||
if c.GetHeader("X-Requested-With") == "XMLHttpRequest" {
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
} else {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
}
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// initRouter sets up the API routes for inbounds, server, and other endpoints.
|
||||
func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *integration.CustomGeoService) {
|
||||
// Main API group
|
||||
api := g.Group("/panel/api")
|
||||
api.Use(a.checkAPIAuth)
|
||||
api.Use(middleware.CSRFMiddleware())
|
||||
|
||||
// Inbounds API
|
||||
inbounds := api.Group("/inbounds")
|
||||
a.inboundController = NewInboundController(inbounds)
|
||||
|
||||
clients := api.Group("/clients")
|
||||
NewClientController(clients)
|
||||
NewGroupController(clients)
|
||||
|
||||
// Server API
|
||||
server := api.Group("/server")
|
||||
a.serverController = NewServerController(server)
|
||||
|
||||
// Nodes API — multi-panel management
|
||||
nodes := api.Group("/nodes")
|
||||
a.nodeController = NewNodeController(nodes)
|
||||
|
||||
NewCustomGeoController(api.Group("/custom-geo"), customGeo)
|
||||
|
||||
// Settings + Xray config management live under the API surface too, so the
|
||||
// same API token drives them. Paths are /panel/api/setting/* and
|
||||
// /panel/api/xray/*.
|
||||
a.settingController = NewSettingController(api)
|
||||
a.xraySettingController = NewXraySettingController(api)
|
||||
|
||||
// Extra routes
|
||||
api.POST("/backuptotgbot", a.BackuptoTgbot)
|
||||
}
|
||||
|
||||
// BackuptoTgbot sends a backup of the panel data to Telegram bot admins.
|
||||
func (a *APIController) BackuptoTgbot(c *gin.Context) {
|
||||
a.Tgbot.SendBackupToAdmins()
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type routeDef struct {
|
||||
Method string
|
||||
Path string
|
||||
}
|
||||
|
||||
// routePattern matches route registrations like g.GET("/path", handler) or api.GET("/path", handler)
|
||||
var routePattern = regexp.MustCompile(`\b(g|api)\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\("([^"]+)"`)
|
||||
|
||||
// docRoutePattern matches { method: 'X', path: 'Y' ... } entries in endpoints.ts.
|
||||
var docRoutePattern = regexp.MustCompile(`method:\s*'([A-Z]+)'\s*,\s*path:\s*'([^']+)'`)
|
||||
|
||||
// buildDocSet parses frontend/src/pages/api-docs/endpoints.ts and returns the
|
||||
// set of documented "METHOD PATH" keys. WS pseudo-routes and subscription
|
||||
// placeholders (paths starting with /{...}) are skipped because they aren't
|
||||
// registered on the main Gin engine.
|
||||
func buildDocSet(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
controllerDir, err := filepath.Abs(".")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get current dir: %v", err)
|
||||
}
|
||||
endpointsPath := filepath.Join(controllerDir, "..", "..", "..", "frontend", "src", "pages", "api-docs", "endpoints.ts")
|
||||
data, err := os.ReadFile(endpointsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read endpoints.ts at %s: %v", endpointsPath, err)
|
||||
}
|
||||
docSet := make(map[string]bool)
|
||||
for _, m := range docRoutePattern.FindAllStringSubmatch(string(data), -1) {
|
||||
method, path := m[1], m[2]
|
||||
if method == "WS" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "/{") {
|
||||
continue
|
||||
}
|
||||
docSet[method+" "+path] = true
|
||||
}
|
||||
if len(docSet) == 0 {
|
||||
t.Fatalf("no documented routes parsed from %s — regex or file format may have changed", endpointsPath)
|
||||
}
|
||||
return docSet
|
||||
}
|
||||
|
||||
func TestAPIRoutesDocumented(t *testing.T) {
|
||||
docSet := buildDocSet(t)
|
||||
|
||||
controllerDir, err := filepath.Abs(".")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get current dir: %v", err)
|
||||
}
|
||||
|
||||
var allRoutes []routeDef
|
||||
|
||||
entries, err := os.ReadDir(controllerDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read controller dir: %v", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(controllerDir, entry.Name()))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read %s: %v", entry.Name(), err)
|
||||
}
|
||||
src := string(data)
|
||||
|
||||
// Determine the base path for this file based on its initRouter patterns
|
||||
basePath := ""
|
||||
switch entry.Name() {
|
||||
case "index.go":
|
||||
basePath = ""
|
||||
case "spa.go":
|
||||
basePath = "/panel"
|
||||
case "api.go":
|
||||
basePath = "/panel/api"
|
||||
case "inbound.go":
|
||||
basePath = "/panel/api/inbounds"
|
||||
case "client.go":
|
||||
basePath = "/panel/api/clients"
|
||||
case "group.go":
|
||||
basePath = "/panel/api/clients"
|
||||
case "server.go":
|
||||
basePath = "/panel/api/server"
|
||||
case "node.go":
|
||||
basePath = "/panel/api/nodes"
|
||||
case "setting.go":
|
||||
basePath = "/panel/api/setting"
|
||||
case "xray_setting.go":
|
||||
basePath = "/panel/api/xray"
|
||||
case "custom_geo.go":
|
||||
basePath = "/panel/api/custom-geo"
|
||||
case "websocket.go":
|
||||
basePath = ""
|
||||
}
|
||||
|
||||
// Find all route registrations
|
||||
matches := routePattern.FindAllStringSubmatch(src, -1)
|
||||
for _, m := range matches {
|
||||
method := m[2]
|
||||
path := strings.TrimSpace(m[3])
|
||||
if basePath == "" {
|
||||
allRoutes = append(allRoutes, routeDef{Method: method, Path: path})
|
||||
} else {
|
||||
fullPath := basePath + path
|
||||
allRoutes = append(allRoutes, routeDef{Method: method, Path: fullPath})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The WebSocket route /ws is registered in web/web.go (not a controller file)
|
||||
allRoutes = append(allRoutes, routeDef{Method: "GET", Path: "/ws"})
|
||||
|
||||
missingFromDocs := 0
|
||||
foundInDoc := 0
|
||||
sourceSet := make(map[string]bool)
|
||||
|
||||
for _, r := range allRoutes {
|
||||
key := r.Method + " " + r.Path
|
||||
// Skip SPA page routes (these are UI pages, not API endpoints)
|
||||
spaPages := map[string]bool{
|
||||
"/": true, "/panel/": true, "/panel/inbounds": true,
|
||||
"/panel/clients": true, "/panel/groups": true,
|
||||
"/panel/nodes": true, "/panel/settings": true,
|
||||
"/panel/xray": true, "/panel/api-docs": true,
|
||||
}
|
||||
if spaPages[r.Path] {
|
||||
continue
|
||||
}
|
||||
// Skip /panel/csrf-token (documented under auth as /csrf-token)
|
||||
if r.Path == "/panel/csrf-token" {
|
||||
continue
|
||||
}
|
||||
// Skip Chrome DevTools route
|
||||
if strings.Contains(r.Path, ".well-known") {
|
||||
continue
|
||||
}
|
||||
|
||||
sourceSet[key] = true
|
||||
if docSet[key] {
|
||||
foundInDoc++
|
||||
} else {
|
||||
missingFromDocs++
|
||||
t.Errorf("Route not documented in endpoints.ts: %s %s", r.Method, r.Path)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Routes found in source: %d, documented: %d, matching: %d, missing: %d",
|
||||
len(sourceSet), len(docSet), foundInDoc, missingFromDocs)
|
||||
|
||||
if missingFromDocs > 0 {
|
||||
t.Errorf("Found %d undocumented route(s). Update endpoints.ts to match.", missingFromDocs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Package controller provides HTTP request handlers and controllers for the 3x-ui web management panel.
|
||||
// It handles routing, authentication, and API endpoints for managing Xray inbounds, settings, and more.
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// BaseController provides common functionality for all controllers, including authentication checks.
|
||||
type BaseController struct{}
|
||||
|
||||
// checkLogin is a middleware that verifies user authentication and handles unauthorized access.
|
||||
func (a *BaseController) checkLogin(c *gin.Context) {
|
||||
if !session.IsLogin(c) {
|
||||
if isAjax(c) {
|
||||
pureJsonMsg(c, http.StatusUnauthorized, false, I18nWeb(c, "pages.login.loginAgain"))
|
||||
} else {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
|
||||
}
|
||||
c.Abort()
|
||||
} else {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// I18nWeb retrieves an internationalized message for the web interface based on the current locale.
|
||||
func I18nWeb(c *gin.Context, name string, params ...string) string {
|
||||
anyfunc, funcExists := c.Get("I18n")
|
||||
if !funcExists {
|
||||
logger.Warning("I18n function not exists in gin context!")
|
||||
return ""
|
||||
}
|
||||
i18nFunc, _ := anyfunc.(func(i18nType locale.I18nType, key string, keyParams ...string) string)
|
||||
msg := i18nFunc(locale.Web, name, params...)
|
||||
return msg
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func notifyClientsChanged() {
|
||||
websocket.BroadcastInvalidate(websocket.MessageTypeClients)
|
||||
}
|
||||
|
||||
func parseInboundIdsQuery(raw string) []int {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
ids := make([]int, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if id, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
type ClientController struct {
|
||||
clientService service.ClientService
|
||||
inboundService service.InboundService
|
||||
xrayService service.XrayService
|
||||
settingService service.SettingService
|
||||
}
|
||||
|
||||
func NewClientController(g *gin.RouterGroup) *ClientController {
|
||||
a := &ClientController{}
|
||||
a.initRouter(g)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/list", a.list)
|
||||
g.GET("/list/paged", a.listPaged)
|
||||
g.GET("/get/:email", a.get)
|
||||
g.GET("/traffic/:email", a.getTrafficByEmail)
|
||||
g.GET("/subLinks/:subId", a.getSubLinks)
|
||||
g.GET("/links/:email", a.getClientLinks)
|
||||
|
||||
g.POST("/add", a.create)
|
||||
g.POST("/update/:email", a.update)
|
||||
g.POST("/del/:email", a.delete)
|
||||
g.POST("/:email/attach", a.attach)
|
||||
g.POST("/:email/detach", a.detach)
|
||||
g.POST("/resetAllTraffics", a.resetAllTraffics)
|
||||
g.POST("/delDepleted", a.delDepleted)
|
||||
g.POST("/bulkAdjust", a.bulkAdjust)
|
||||
g.POST("/bulkDel", a.bulkDelete)
|
||||
g.POST("/bulkCreate", a.bulkCreate)
|
||||
g.POST("/bulkAttach", a.bulkAttach)
|
||||
g.POST("/bulkDetach", a.bulkDetach)
|
||||
g.POST("/bulkResetTraffic", a.bulkResetTraffic)
|
||||
g.POST("/resetTraffic/:email", a.resetTrafficByEmail)
|
||||
g.POST("/updateTraffic/:email", a.updateTrafficByEmail)
|
||||
g.POST("/ips/:email", a.getIps)
|
||||
g.POST("/clearIps/:email", a.clearIps)
|
||||
g.POST("/onlines", a.onlines)
|
||||
g.POST("/onlinesByGuid", a.onlinesByGuid)
|
||||
g.POST("/activeInbounds", a.activeInbounds)
|
||||
g.POST("/lastOnline", a.lastOnline)
|
||||
}
|
||||
|
||||
func (a *ClientController) list(c *gin.Context) {
|
||||
rows, err := a.clientService.List()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, rows, nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) listPaged(c *gin.Context) {
|
||||
var params service.ClientPageParams
|
||||
if err := c.ShouldBindQuery(¶ms); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
resp, err := a.clientService.ListPaged(&a.inboundService, &a.settingService, params)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, resp, nil)
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
flow, err := a.clientService.EffectiveFlow(nil, rec.Id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
rec.Flow = flow
|
||||
jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds}, nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) create(c *gin.Context) {
|
||||
var payload service.ClientCreatePayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
needRestart, err := a.clientService.Create(&a.inboundService, &payload)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(payload.InboundIds)), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
func (a *ClientController) update(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
var updated model.Client
|
||||
if err := c.ShouldBindJSON(&updated); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
inboundFilter := parseInboundIdsQuery(c.Query("inboundIds"))
|
||||
needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated, inboundFilter...)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), pendingNodeObj(a.clientService.HasPendingNode(&a.inboundService, email)), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
func (a *ClientController) delete(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
keepTraffic := c.Query("keepTraffic") == "1"
|
||||
needRestart, err := a.clientService.DeleteByEmail(&a.inboundService, email, keepTraffic)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
type attachDetachBody struct {
|
||||
InboundIds []int `json:"inboundIds"`
|
||||
}
|
||||
|
||||
func (a *ClientController) attach(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
var body attachDetachBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
needRestart, err := a.clientService.AttachByEmail(&a.inboundService, email, body.InboundIds)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(body.InboundIds)), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
func (a *ClientController) resetAllTraffics(c *gin.Context) {
|
||||
needRestart, err := a.clientService.ResetAllTraffics()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllClientTrafficSuccess"), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
type bulkAdjustRequest struct {
|
||||
Emails []string `json:"emails"`
|
||||
AddDays int `json:"addDays"`
|
||||
AddBytes int64 `json:"addBytes"`
|
||||
}
|
||||
|
||||
func (a *ClientController) bulkAdjust(c *gin.Context) {
|
||||
var req bulkAdjustRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
result, needRestart, err := a.clientService.BulkAdjust(&a.inboundService, req.Emails, req.AddDays, req.AddBytes)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, result, nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
type bulkDeleteRequest struct {
|
||||
Emails []string `json:"emails"`
|
||||
KeepTraffic bool `json:"keepTraffic"`
|
||||
}
|
||||
|
||||
type bulkAttachRequest struct {
|
||||
Emails []string `json:"emails"`
|
||||
InboundIds []int `json:"inboundIds"`
|
||||
}
|
||||
|
||||
func (a *ClientController) bulkAttach(c *gin.Context) {
|
||||
var req bulkAttachRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
result, needRestart, err := a.clientService.BulkAttach(&a.inboundService, req.Emails, req.InboundIds)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, result, nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
type bulkDetachRequest struct {
|
||||
Emails []string `json:"emails"`
|
||||
InboundIds []int `json:"inboundIds"`
|
||||
}
|
||||
|
||||
func (a *ClientController) bulkDetach(c *gin.Context) {
|
||||
var req bulkDetachRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
result, needRestart, err := a.clientService.BulkDetach(&a.inboundService, req.Emails, req.InboundIds)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, result, nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
func (a *ClientController) bulkDelete(c *gin.Context) {
|
||||
var req bulkDeleteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
result, needRestart, err := a.clientService.BulkDelete(&a.inboundService, req.Emails, req.KeepTraffic)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, result, nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
func (a *ClientController) bulkCreate(c *gin.Context) {
|
||||
var payloads []service.ClientCreatePayload
|
||||
if err := c.ShouldBindJSON(&payloads); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
result, needRestart, err := a.clientService.BulkCreate(&a.inboundService, payloads)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, result, nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
func (a *ClientController) delDepleted(c *gin.Context) {
|
||||
deleted, needRestart, err := a.clientService.DelDepleted(&a.inboundService)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, gin.H{"deleted": deleted}, nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
func (a *ClientController) resetTrafficByEmail(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
needRestart, err := a.clientService.ResetTrafficByEmail(&a.inboundService, email)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundClientTrafficSuccess"), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
type trafficUpdateRequest struct {
|
||||
Upload int64 `json:"upload"`
|
||||
Download int64 `json:"download"`
|
||||
}
|
||||
|
||||
func (a *ClientController) updateTrafficByEmail(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
var req trafficUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
if err := a.inboundService.UpdateClientTrafficByEmail(email, req.Upload, req.Download); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
func (a *ClientController) getIps(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
ips, err := a.inboundService.GetInboundClientIps(email)
|
||||
if err != nil || ips == "" {
|
||||
jsonObj(c, "No IP Record", nil)
|
||||
return
|
||||
}
|
||||
type ipWithTimestamp struct {
|
||||
IP string `json:"ip"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
var ipsWithTime []ipWithTimestamp
|
||||
if err := json.Unmarshal([]byte(ips), &ipsWithTime); err == nil && len(ipsWithTime) > 0 {
|
||||
formatted := make([]string, 0, len(ipsWithTime))
|
||||
for _, item := range ipsWithTime {
|
||||
if item.IP == "" {
|
||||
continue
|
||||
}
|
||||
if item.Timestamp > 0 {
|
||||
ts := time.Unix(item.Timestamp, 0).Local().Format("2006-01-02 15:04:05")
|
||||
formatted = append(formatted, fmt.Sprintf("%s (%s)", item.IP, ts))
|
||||
continue
|
||||
}
|
||||
formatted = append(formatted, item.IP)
|
||||
}
|
||||
jsonObj(c, formatted, nil)
|
||||
return
|
||||
}
|
||||
var oldIps []string
|
||||
if err := json.Unmarshal([]byte(ips), &oldIps); err == nil && len(oldIps) > 0 {
|
||||
jsonObj(c, oldIps, nil)
|
||||
return
|
||||
}
|
||||
jsonObj(c, ips, nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) clearIps(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
if err := a.inboundService.ClearClientIps(email); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.updateSuccess"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) onlines(c *gin.Context) {
|
||||
jsonObj(c, a.inboundService.GetOnlineClients(), nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) onlinesByGuid(c *gin.Context) {
|
||||
jsonObj(c, a.inboundService.GetOnlineClientsByGuid(), nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) activeInbounds(c *gin.Context) {
|
||||
jsonObj(c, a.inboundService.GetActiveInboundsByGuid(), nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) lastOnline(c *gin.Context) {
|
||||
data, err := a.inboundService.GetClientsLastOnline()
|
||||
jsonObj(c, data, err)
|
||||
}
|
||||
|
||||
func (a *ClientController) getTrafficByEmail(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
traffic, err := a.inboundService.GetClientTrafficByEmail(email)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, traffic, nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) getSubLinks(c *gin.Context) {
|
||||
links, err := a.inboundService.GetSubLinks(resolveHost(c), c.Param("subId"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, links, nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) getClientLinks(c *gin.Context) {
|
||||
links, err := a.inboundService.GetAllClientLinks(resolveHost(c), c.Param("email"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, links, nil)
|
||||
}
|
||||
|
||||
func (a *ClientController) detach(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
var body attachDetachBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
needRestart, err := a.clientService.DetachByEmailMany(&a.inboundService, email, body.InboundIds)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(body.InboundIds)), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
type bulkResetRequest struct {
|
||||
Emails []string `json:"emails"`
|
||||
}
|
||||
|
||||
func (a *ClientController) bulkResetTraffic(c *gin.Context) {
|
||||
var req bulkResetRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
affected, err := a.clientService.BulkResetTraffic(&a.inboundService, req.Emails)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, gin.H{"affected": affected}, nil)
|
||||
a.xrayService.SetToNeedRestart()
|
||||
notifyClientsChanged()
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/integration"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type CustomGeoController struct {
|
||||
BaseController
|
||||
customGeoService *integration.CustomGeoService
|
||||
}
|
||||
|
||||
func NewCustomGeoController(g *gin.RouterGroup, customGeo *integration.CustomGeoService) *CustomGeoController {
|
||||
a := &CustomGeoController{customGeoService: customGeo}
|
||||
a.initRouter(g)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *CustomGeoController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/list", a.list)
|
||||
g.GET("/aliases", a.aliases)
|
||||
g.POST("/add", a.add)
|
||||
g.POST("/update/:id", a.update)
|
||||
g.POST("/delete/:id", a.delete)
|
||||
g.POST("/download/:id", a.download)
|
||||
g.POST("/update-all", a.updateAll)
|
||||
}
|
||||
|
||||
func mapCustomGeoErr(c *gin.Context, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, integration.ErrCustomGeoInvalidType):
|
||||
return errors.New(I18nWeb(c, "pages.index.customGeoErrInvalidType"))
|
||||
case errors.Is(err, integration.ErrCustomGeoAliasRequired):
|
||||
return errors.New(I18nWeb(c, "pages.index.customGeoErrAliasRequired"))
|
||||
case errors.Is(err, integration.ErrCustomGeoAliasPattern):
|
||||
return errors.New(I18nWeb(c, "pages.index.customGeoErrAliasPattern"))
|
||||
case errors.Is(err, integration.ErrCustomGeoAliasReserved):
|
||||
return errors.New(I18nWeb(c, "pages.index.customGeoErrAliasReserved"))
|
||||
case errors.Is(err, integration.ErrCustomGeoURLRequired):
|
||||
return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlRequired"))
|
||||
case errors.Is(err, integration.ErrCustomGeoInvalidURL):
|
||||
return errors.New(I18nWeb(c, "pages.index.customGeoErrInvalidUrl"))
|
||||
case errors.Is(err, integration.ErrCustomGeoURLScheme):
|
||||
return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlScheme"))
|
||||
case errors.Is(err, integration.ErrCustomGeoURLHost):
|
||||
return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlHost"))
|
||||
case errors.Is(err, integration.ErrCustomGeoDuplicateAlias):
|
||||
return errors.New(I18nWeb(c, "pages.index.customGeoErrDuplicateAlias"))
|
||||
case errors.Is(err, integration.ErrCustomGeoNotFound):
|
||||
return errors.New(I18nWeb(c, "pages.index.customGeoErrNotFound"))
|
||||
case errors.Is(err, integration.ErrCustomGeoDownload):
|
||||
logger.Warning("custom geo download:", err)
|
||||
return errors.New(I18nWeb(c, "pages.index.customGeoErrDownload"))
|
||||
case errors.Is(err, integration.ErrCustomGeoSSRFBlocked):
|
||||
logger.Warning("custom geo SSRF blocked:", err)
|
||||
return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlHost"))
|
||||
case errors.Is(err, integration.ErrCustomGeoPathTraversal):
|
||||
logger.Warning("custom geo path traversal blocked:", err)
|
||||
return errors.New(I18nWeb(c, "pages.index.customGeoErrDownload"))
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (a *CustomGeoController) list(c *gin.Context) {
|
||||
list, err := a.customGeoService.GetAll()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastList"), mapCustomGeoErr(c, err))
|
||||
return
|
||||
}
|
||||
jsonObj(c, list, nil)
|
||||
}
|
||||
|
||||
func (a *CustomGeoController) aliases(c *gin.Context) {
|
||||
out, err := a.customGeoService.GetAliasesForUI()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.customGeoAliasesError"), mapCustomGeoErr(c, err))
|
||||
return
|
||||
}
|
||||
jsonObj(c, out, nil)
|
||||
}
|
||||
|
||||
type customGeoForm struct {
|
||||
Type string `json:"type" form:"type"`
|
||||
Alias string `json:"alias" form:"alias"`
|
||||
Url string `json:"url" form:"url"`
|
||||
}
|
||||
|
||||
func (a *CustomGeoController) add(c *gin.Context) {
|
||||
var form customGeoForm
|
||||
if err := c.ShouldBind(&form); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastAdd"), err)
|
||||
return
|
||||
}
|
||||
r := &model.CustomGeoResource{
|
||||
Type: form.Type,
|
||||
Alias: form.Alias,
|
||||
Url: form.Url,
|
||||
}
|
||||
err := a.customGeoService.Create(r)
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastAdd"), mapCustomGeoErr(c, err))
|
||||
}
|
||||
|
||||
func parseCustomGeoID(c *gin.Context, idStr string) (int, bool) {
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.customGeoInvalidId"), err)
|
||||
return 0, false
|
||||
}
|
||||
if id <= 0 {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.customGeoInvalidId"), errors.New(""))
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func (a *CustomGeoController) update(c *gin.Context) {
|
||||
id, ok := parseCustomGeoID(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var form customGeoForm
|
||||
if bindErr := c.ShouldBind(&form); bindErr != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastUpdate"), bindErr)
|
||||
return
|
||||
}
|
||||
r := &model.CustomGeoResource{
|
||||
Type: form.Type,
|
||||
Alias: form.Alias,
|
||||
Url: form.Url,
|
||||
}
|
||||
err := a.customGeoService.Update(id, r)
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastUpdate"), mapCustomGeoErr(c, err))
|
||||
}
|
||||
|
||||
func (a *CustomGeoController) delete(c *gin.Context) {
|
||||
id, ok := parseCustomGeoID(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
name, err := a.customGeoService.Delete(id)
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastDelete", "fileName=="+name), mapCustomGeoErr(c, err))
|
||||
}
|
||||
|
||||
func (a *CustomGeoController) download(c *gin.Context) {
|
||||
id, ok := parseCustomGeoID(c, c.Param("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
name, err := a.customGeoService.TriggerUpdate(id)
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastDownload", "fileName=="+name), mapCustomGeoErr(c, err))
|
||||
}
|
||||
|
||||
func (a *CustomGeoController) updateAll(c *gin.Context) {
|
||||
res, err := a.customGeoService.TriggerUpdateAll()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastUpdateAll"), mapCustomGeoErr(c, err))
|
||||
return
|
||||
}
|
||||
if len(res.Failed) > 0 {
|
||||
c.JSON(http.StatusOK, entity.Msg{
|
||||
Success: false,
|
||||
Msg: I18nWeb(c, "pages.index.customGeoErrUpdateAllIncomplete"),
|
||||
Obj: res,
|
||||
})
|
||||
return
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.index.customGeoToastUpdateAll"), res, nil)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
htmlpkg "html"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
||||
)
|
||||
|
||||
var distFS embed.FS
|
||||
|
||||
func SetDistFS(fs embed.FS) {
|
||||
distFS = fs
|
||||
}
|
||||
|
||||
var distPageBuildTime = time.Now()
|
||||
|
||||
// ServeOpenAPISpec returns the generated OpenAPI 3.0 description of the
|
||||
// panel API. Postman / Insomnia / openapi-generator consume this URL
|
||||
// directly; the in-panel Swagger UI page also fetches it. The spec is
|
||||
// produced at frontend build time by scripts/build-openapi.mjs and
|
||||
// embedded into the binary via the dist FS.
|
||||
func ServeOpenAPISpec(c *gin.Context) {
|
||||
body, err := distFS.ReadFile("dist/openapi.json")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "msg": "openapi.json not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// The embedded spec ships with `servers: [{url: "/"}]`. When the panel runs
|
||||
// under a non-root web base path, Swagger UI "Try it out" and external
|
||||
// generators must target that prefix, so rewrite the single server entry to
|
||||
// the runtime base path before serving.
|
||||
if basePath := c.GetString("base_path"); basePath != "" && basePath != "/" {
|
||||
if rebuilt, err := withServerBasePath(body, basePath); err != nil {
|
||||
logger.Warning("openapi.json: could not inject base path:", err)
|
||||
} else {
|
||||
body = rebuilt
|
||||
}
|
||||
}
|
||||
|
||||
c.Header("Cache-Control", "public, max-age=300")
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
|
||||
}
|
||||
|
||||
// withServerBasePath rewrites the spec's `servers` entry so requests target the
|
||||
// panel's configured web base path. Only the top-level `servers` field is
|
||||
// replaced; every other field is preserved verbatim via json.RawMessage.
|
||||
func withServerBasePath(spec []byte, basePath string) ([]byte, error) {
|
||||
var doc map[string]json.RawMessage
|
||||
if err := json.Unmarshal(spec, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
servers, err := json.Marshal([]map[string]string{{
|
||||
"url": strings.TrimSuffix(basePath, "/"),
|
||||
"description": "Current panel",
|
||||
}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc["servers"] = servers
|
||||
return json.Marshal(doc)
|
||||
}
|
||||
|
||||
func serveDistPage(c *gin.Context, name string) {
|
||||
body, err := distFS.ReadFile("dist/" + name)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "missing embedded page: %s", name)
|
||||
return
|
||||
}
|
||||
|
||||
basePath := c.GetString("base_path")
|
||||
if basePath == "" {
|
||||
basePath = "/"
|
||||
}
|
||||
|
||||
if basePath != "/" {
|
||||
body = bytes.ReplaceAll(body, []byte(`src="/assets/`), []byte(`src="`+basePath+`assets/`))
|
||||
body = bytes.ReplaceAll(body, []byte(`href="/assets/`), []byte(`href="`+basePath+`assets/`))
|
||||
}
|
||||
|
||||
jsEscape := strings.NewReplacer(
|
||||
`\`, `\\`,
|
||||
`"`, `\"`,
|
||||
"\n", `\n`,
|
||||
"\r", `\r`,
|
||||
"<", `<`,
|
||||
">", `>`,
|
||||
"&", `&`,
|
||||
)
|
||||
escapedBase := jsEscape.Replace(basePath)
|
||||
csrfToken, err := session.EnsureCSRFToken(c)
|
||||
if err != nil {
|
||||
logger.Warning("Unable to mint CSRF token for", name+":", err)
|
||||
csrfToken = ""
|
||||
}
|
||||
csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`)
|
||||
basePathMeta := []byte(`<meta name="base-path" content="` + htmlpkg.EscapeString(basePath) + `">`)
|
||||
|
||||
nonceAttr := ""
|
||||
if nonce := c.GetString("csp_nonce"); nonce != "" {
|
||||
nonceAttr = ` nonce="` + htmlpkg.EscapeString(nonce) + `"`
|
||||
}
|
||||
script := `<script` + nonceAttr + `>window.X_UI_BASE_PATH="` + escapedBase + `"`
|
||||
if name != "login.html" {
|
||||
escapedVer := jsEscape.Replace(config.GetVersion())
|
||||
script += `;window.X_UI_CUR_VER="` + escapedVer + `"`
|
||||
script += `;window.X_UI_DB_TYPE="` + config.GetDBKind() + `"`
|
||||
}
|
||||
script += `;</script>`
|
||||
inject := []byte(script)
|
||||
inject = append(inject, csrfMeta...)
|
||||
inject = append(inject, basePathMeta...)
|
||||
inject = append(inject, []byte(`</head>`)...)
|
||||
out := bytes.Replace(body, []byte("</head>"), inject, 1)
|
||||
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
c.Header("Pragma", "no-cache")
|
||||
c.Header("Expires", "0")
|
||||
c.Header("Last-Modified", distPageBuildTime.UTC().Format(http.TimeFormat))
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", out)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWithServerBasePath(t *testing.T) {
|
||||
spec := []byte(`{"openapi":"3.0.3","info":{"title":"x"},"servers":[{"url":"/","description":"old"}],"paths":{"/p":{"get":{"summary":"s"}}}}`)
|
||||
|
||||
out, err := withServerBasePath(spec, "/test/")
|
||||
if err != nil {
|
||||
t.Fatalf("withServerBasePath: %v", err)
|
||||
}
|
||||
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(out, &doc); err != nil {
|
||||
t.Fatalf("unmarshal result: %v", err)
|
||||
}
|
||||
|
||||
servers, ok := doc["servers"].([]any)
|
||||
if !ok || len(servers) != 1 {
|
||||
t.Fatalf("servers = %v, want one entry", doc["servers"])
|
||||
}
|
||||
srv, _ := servers[0].(map[string]any)
|
||||
if srv["url"] != "/test" {
|
||||
t.Errorf("server url = %v, want /test (trailing slash trimmed)", srv["url"])
|
||||
}
|
||||
|
||||
if doc["openapi"] != "3.0.3" {
|
||||
t.Errorf("openapi field not preserved: %v", doc["openapi"])
|
||||
}
|
||||
if _, ok := doc["paths"].(map[string]any)["/p"]; !ok {
|
||||
t.Errorf("paths content not preserved verbatim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithServerBasePathInvalidJSON(t *testing.T) {
|
||||
if _, err := withServerBasePath([]byte("not json"), "/test/"); err == nil {
|
||||
t.Errorf("expected error on invalid spec, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type GroupController struct {
|
||||
clientService service.ClientService
|
||||
xrayService service.XrayService
|
||||
}
|
||||
|
||||
func NewGroupController(g *gin.RouterGroup) *GroupController {
|
||||
a := &GroupController{}
|
||||
a.initRouter(g)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *GroupController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/groups", a.list)
|
||||
g.GET("/groups/:name/emails", a.emails)
|
||||
g.POST("/groups/create", a.create)
|
||||
g.POST("/groups/rename", a.rename)
|
||||
g.POST("/groups/delete", a.delete)
|
||||
g.POST("/groups/bulkAdd", a.bulkAdd)
|
||||
g.POST("/groups/bulkRemove", a.bulkRemove)
|
||||
}
|
||||
|
||||
func (a *GroupController) list(c *gin.Context) {
|
||||
rows, err := a.clientService.ListGroups()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, rows, nil)
|
||||
}
|
||||
|
||||
func (a *GroupController) emails(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
emails, err := a.clientService.EmailsByGroup(name)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, emails, nil)
|
||||
}
|
||||
|
||||
type groupCreateBody struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (a *GroupController) create(c *gin.Context) {
|
||||
var body groupCreateBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
if err := a.clientService.CreateGroup(body.Name); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, gin.H{"name": body.Name}, nil)
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
type groupRenameBody struct {
|
||||
OldName string `json:"oldName"`
|
||||
NewName string `json:"newName"`
|
||||
}
|
||||
|
||||
func (a *GroupController) rename(c *gin.Context) {
|
||||
var body groupRenameBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
affected, err := a.clientService.RenameGroup(body.OldName, body.NewName)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
a.xrayService.SetToNeedRestart()
|
||||
jsonObj(c, gin.H{"affected": affected}, nil)
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
type groupDeleteBody struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (a *GroupController) delete(c *gin.Context) {
|
||||
var body groupDeleteBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
affected, err := a.clientService.DeleteGroup(body.Name)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
a.xrayService.SetToNeedRestart()
|
||||
jsonObj(c, gin.H{"affected": affected}, nil)
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
type bulkAddToGroupRequest struct {
|
||||
Emails []string `json:"emails"`
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
func (a *GroupController) bulkAdd(c *gin.Context) {
|
||||
var req bulkAddToGroupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Group) == "" {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("group name is required"))
|
||||
return
|
||||
}
|
||||
affected, err := a.clientService.AddToGroup(req.Emails, req.Group)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, gin.H{"affected": affected}, nil)
|
||||
a.xrayService.SetToNeedRestart()
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
type bulkRemoveFromGroupRequest struct {
|
||||
Emails []string `json:"emails"`
|
||||
}
|
||||
|
||||
func (a *GroupController) bulkRemove(c *gin.Context) {
|
||||
var req bulkRemoveFromGroupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
affected, err := a.clientService.RemoveFromGroup(req.Emails)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, gin.H{"affected": affected}, nil)
|
||||
a.xrayService.SetToNeedRestart()
|
||||
notifyClientsChanged()
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// InboundController handles HTTP requests related to Xray inbounds management.
|
||||
type InboundController struct {
|
||||
inboundService service.InboundService
|
||||
clientService service.ClientService
|
||||
xrayService service.XrayService
|
||||
fallbackService service.FallbackService
|
||||
}
|
||||
|
||||
// NewInboundController creates a new InboundController and sets up its routes.
|
||||
func NewInboundController(g *gin.RouterGroup) *InboundController {
|
||||
a := &InboundController{}
|
||||
a.initRouter(g)
|
||||
return a
|
||||
}
|
||||
|
||||
// broadcastInboundsUpdateClientLimit is the threshold past which we skip the
|
||||
// full-list push over WebSocket and signal the frontend to re-fetch via REST.
|
||||
// Mirrors the same heuristic used by the periodic traffic job.
|
||||
const broadcastInboundsUpdateClientLimit = 5000
|
||||
|
||||
// broadcastInboundsUpdate fetches and broadcasts the inbound list for userId.
|
||||
// At scale (10k+ clients) the marshaled JSON exceeds the WS payload ceiling,
|
||||
// so we send an invalidate signal instead — frontend re-fetches via REST.
|
||||
// Skipped entirely when no WebSocket clients are connected.
|
||||
func (a *InboundController) broadcastInboundsUpdate(userId int) {
|
||||
if !websocket.HasClients() {
|
||||
return
|
||||
}
|
||||
inbounds, err := a.inboundService.GetInbounds(userId)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
totalClients := 0
|
||||
for _, ib := range inbounds {
|
||||
totalClients += len(ib.ClientStats)
|
||||
}
|
||||
if totalClients > broadcastInboundsUpdateClientLimit {
|
||||
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
|
||||
return
|
||||
}
|
||||
websocket.BroadcastInbounds(inbounds)
|
||||
}
|
||||
|
||||
// initRouter initializes the routes for inbound-related operations.
|
||||
func (a *InboundController) initRouter(g *gin.RouterGroup) {
|
||||
|
||||
g.GET("/list", a.getInbounds)
|
||||
g.GET("/list/slim", a.getInboundsSlim)
|
||||
g.GET("/options", a.getInboundOptions)
|
||||
g.GET("/get/:id", a.getInbound)
|
||||
g.GET("/:id/fallbacks", a.getFallbacks)
|
||||
|
||||
g.POST("/add", a.addInbound)
|
||||
g.POST("/del/:id", a.delInbound)
|
||||
g.POST("/bulkDel", a.bulkDelInbounds)
|
||||
g.POST("/update/:id", a.updateInbound)
|
||||
g.POST("/setEnable/:id", a.setInboundEnable)
|
||||
g.POST("/:id/resetTraffic", a.resetInboundTraffic)
|
||||
g.POST("/:id/delAllClients", a.delAllInboundClients)
|
||||
g.POST("/resetAllTraffics", a.resetAllTraffics)
|
||||
g.POST("/import", a.importInbound)
|
||||
g.POST("/:id/fallbacks", a.setFallbacks)
|
||||
}
|
||||
|
||||
// getInbounds retrieves the list of inbounds for the logged-in user.
|
||||
func (a *InboundController) getInbounds(c *gin.Context) {
|
||||
user := session.GetLoginUser(c)
|
||||
inbounds, err := a.inboundService.GetInbounds(user.Id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, inbounds, nil)
|
||||
}
|
||||
|
||||
// getInboundsSlim is the list-page variant that strips full client
|
||||
// payloads from settings.clients[]. Detail-view flows still use /get/:id.
|
||||
func (a *InboundController) getInboundsSlim(c *gin.Context) {
|
||||
user := session.GetLoginUser(c)
|
||||
inbounds, err := a.inboundService.GetInboundsSlim(user.Id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, inbounds, nil)
|
||||
}
|
||||
|
||||
// getInboundOptions returns a lightweight projection of the user's inbounds
|
||||
// (id, remark, protocol, port, tlsFlowCapable) for pickers in the clients UI.
|
||||
// Avoids shipping per-client settings and traffic stats just to fill a dropdown.
|
||||
func (a *InboundController) getInboundOptions(c *gin.Context) {
|
||||
user := session.GetLoginUser(c)
|
||||
options, err := a.inboundService.GetInboundOptions(user.Id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, options, nil)
|
||||
}
|
||||
|
||||
// getInbound retrieves a specific inbound by its ID.
|
||||
func (a *InboundController) getInbound(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
inbound, err := a.inboundService.GetInboundDetail(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, inbound, nil)
|
||||
}
|
||||
|
||||
// addInbound creates a new inbound configuration.
|
||||
func (a *InboundController) addInbound(c *gin.Context) {
|
||||
inbound, ok := middleware.BindAndValidate[model.Inbound](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user := session.GetLoginUser(c)
|
||||
inbound.UserId = user.Id
|
||||
// Treat NodeID=0 as "no node" — gin's *int form binding can land on
|
||||
// 0 when the field is absent or empty, and 0 is never a valid Node
|
||||
// row id. Without this normalization the runtime layer would try to
|
||||
// load Node id=0 and surface "record not found".
|
||||
if inbound.NodeID != nil && *inbound.NodeID == 0 {
|
||||
inbound.NodeID = nil
|
||||
}
|
||||
|
||||
inbound, needRestart, err := a.inboundService.AddInbound(inbound)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), inbound, nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
a.broadcastInboundsUpdate(user.Id)
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
// delInbound deletes an inbound configuration by its ID.
|
||||
func (a *InboundController) delInbound(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundDeleteSuccess"), err)
|
||||
return
|
||||
}
|
||||
needRestart, err := a.inboundService.DelInbound(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundDeleteSuccess"), id, nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
user := session.GetLoginUser(c)
|
||||
a.broadcastInboundsUpdate(user.Id)
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
type bulkDelInboundsRequest struct {
|
||||
Ids []int `json:"ids"`
|
||||
}
|
||||
|
||||
// bulkDelInbounds deletes several inbounds in one call. Failures are
|
||||
// reported per id and the rest still proceed; xray restarts at most once.
|
||||
func (a *InboundController) bulkDelInbounds(c *gin.Context) {
|
||||
var req bulkDelInboundsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
result, needRestart, err := a.inboundService.DelInbounds(req.Ids)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, result, nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
user := session.GetLoginUser(c)
|
||||
a.broadcastInboundsUpdate(user.Id)
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
// updateInbound updates an existing inbound configuration.
|
||||
func (a *InboundController) updateInbound(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
||||
return
|
||||
}
|
||||
inbound := &model.Inbound{
|
||||
Id: id,
|
||||
}
|
||||
if !middleware.BindAndValidateInto(c, inbound) {
|
||||
return
|
||||
}
|
||||
// Same NodeID=0 → nil normalisation as addInbound. UpdateInbound
|
||||
// loads the existing row's NodeID from DB anyway (Phase 1 doesn't
|
||||
// support migrating an inbound between nodes), but normalising here
|
||||
// keeps the wire shape consistent.
|
||||
if inbound.NodeID != nil && *inbound.NodeID == 0 {
|
||||
inbound.NodeID = nil
|
||||
}
|
||||
inbound, needRestart, err := a.inboundService.UpdateInbound(inbound)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), inbound, nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
user := session.GetLoginUser(c)
|
||||
a.broadcastInboundsUpdate(user.Id)
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
// setInboundEnable flips only the enable flag of an inbound. This is a
|
||||
// dedicated endpoint because the regular update path serialises the entire
|
||||
// settings JSON (every client) — far too heavy for an interactive switch
|
||||
// on inbounds with thousands of clients. Frontend optimistically updates
|
||||
// the UI; we just persist + sync xray + nudge other open admin sessions.
|
||||
func (a *InboundController) setInboundEnable(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
||||
return
|
||||
}
|
||||
type form struct {
|
||||
Enable bool `json:"enable" form:"enable"`
|
||||
}
|
||||
var f form
|
||||
if err := c.ShouldBind(&f); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
needRestart, err := a.inboundService.SetInboundEnable(id, f.Enable)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
// Cross-admin sync: lightweight invalidate signal (a few hundred bytes)
|
||||
// instead of fetching + serialising the whole inbound list. Other open
|
||||
// sessions re-fetch via REST. The toggling admin's own UI already
|
||||
// updated optimistically.
|
||||
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
|
||||
}
|
||||
|
||||
// resetInboundTraffic resets traffic counters for a specific inbound.
|
||||
func (a *InboundController) resetInboundTraffic(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
|
||||
return
|
||||
}
|
||||
|
||||
err = a.inboundService.ResetInboundTraffic(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
} else {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundTrafficSuccess"), nil)
|
||||
}
|
||||
|
||||
// delAllInboundClients removes every client attached to a specific inbound
|
||||
// while keeping the inbound itself. Internally collects the current email
|
||||
// list from settings.clients[] and feeds it into ClientService.BulkDelete,
|
||||
// which handles per-inbound JSON rewriting, runtime user removal, traffic
|
||||
// row cleanup, and the SyncInbound mapping pass in one optimized cycle.
|
||||
func (a *InboundController) delAllInboundClients(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
emails, err := a.inboundService.EmailsByInbound(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
if len(emails) == 0 {
|
||||
jsonObj(c, service.BulkDeleteResult{}, nil)
|
||||
return
|
||||
}
|
||||
result, needRestart, err := a.clientService.BulkDelete(&a.inboundService, emails, false)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, result, nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
user := session.GetLoginUser(c)
|
||||
a.broadcastInboundsUpdate(user.Id)
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
// resetAllTraffics resets all traffic counters across all inbounds.
|
||||
func (a *InboundController) resetAllTraffics(c *gin.Context) {
|
||||
err := a.inboundService.ResetAllTraffics()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
} else {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllTrafficSuccess"), nil)
|
||||
}
|
||||
|
||||
// importInbound imports an inbound configuration from provided data.
|
||||
func (a *InboundController) importInbound(c *gin.Context) {
|
||||
inbound := &model.Inbound{}
|
||||
err := json.Unmarshal([]byte(c.PostForm("data")), inbound)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
user := session.GetLoginUser(c)
|
||||
inbound.Id = 0
|
||||
inbound.UserId = user.Id
|
||||
// Node IDs are panel-local and not portable across panels. Drop a node
|
||||
// reference that is zero or that points to a node which doesn't exist on
|
||||
// this panel, so a cross-panel export imports as a local inbound instead of
|
||||
// failing with "record not found" when nodePushPlan looks the node up.
|
||||
if inbound.NodeID != nil {
|
||||
if *inbound.NodeID == 0 {
|
||||
inbound.NodeID = nil
|
||||
} else if exists, err := (&service.NodeService{}).NodeExists(*inbound.NodeID); err == nil && !exists {
|
||||
inbound.NodeID = nil
|
||||
}
|
||||
}
|
||||
|
||||
for index := range inbound.ClientStats {
|
||||
inbound.ClientStats[index].Id = 0
|
||||
inbound.ClientStats[index].Enable = true
|
||||
}
|
||||
|
||||
inbound, needRestart, err := a.inboundService.AddInbound(inbound)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), inbound, nil)
|
||||
if needRestart {
|
||||
a.xrayService.SetToNeedRestart()
|
||||
}
|
||||
a.broadcastInboundsUpdate(user.Id)
|
||||
notifyClientsChanged()
|
||||
}
|
||||
|
||||
// resolveHost mirrors what sub.SubService.ResolveRequest does for the host
|
||||
// field: prefers X-Forwarded-Host (first entry of any list, port stripped),
|
||||
// then X-Real-IP, then the host portion of c.Request.Host. Keeping it in the
|
||||
// controller layer means the service interface stays HTTP-agnostic — service
|
||||
// methods receive a plain host string instead of a *gin.Context.
|
||||
func resolveHost(c *gin.Context) string {
|
||||
if isTrustedForwardedRequest(c) {
|
||||
if h := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); h != "" {
|
||||
if i := strings.Index(h, ","); i >= 0 {
|
||||
h = strings.TrimSpace(h[:i])
|
||||
}
|
||||
if hp, _, err := net.SplitHostPort(h); err == nil {
|
||||
return hp
|
||||
}
|
||||
return h
|
||||
}
|
||||
if h := c.GetHeader("X-Real-IP"); h != "" {
|
||||
return h
|
||||
}
|
||||
}
|
||||
if h, _, err := net.SplitHostPort(c.Request.Host); err == nil {
|
||||
return h
|
||||
}
|
||||
return c.Request.Host
|
||||
}
|
||||
|
||||
// getFallbacks returns the fallback rules attached to the master inbound.
|
||||
func (a *InboundController) getFallbacks(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
rows, err := a.fallbackService.GetByMaster(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, rows, nil)
|
||||
}
|
||||
|
||||
// setFallbacks atomically replaces the master inbound's fallback list
|
||||
// and triggers an Xray restart so the new settings.fallbacks take effect.
|
||||
func (a *InboundController) setFallbacks(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
type body struct {
|
||||
Fallbacks []service.FallbackInput `json:"fallbacks"`
|
||||
}
|
||||
var b body
|
||||
if err := c.ShouldBindJSON(&b); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
if err := a.fallbackService.SetByMaster(id, b.Fallbacks); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
a.xrayService.SetToNeedRestart()
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// LoginForm represents the login request structure.
|
||||
type LoginForm struct {
|
||||
Username string `json:"username" form:"username"`
|
||||
Password string `json:"password" form:"password"`
|
||||
TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
|
||||
}
|
||||
|
||||
// IndexController handles the main index and login-related routes.
|
||||
type IndexController struct {
|
||||
BaseController
|
||||
|
||||
settingService service.SettingService
|
||||
userService panel.UserService
|
||||
tgbot tgbot.Tgbot
|
||||
}
|
||||
|
||||
// NewIndexController creates a new IndexController and initializes its routes.
|
||||
func NewIndexController(g *gin.RouterGroup) *IndexController {
|
||||
a := &IndexController{}
|
||||
a.initRouter(g)
|
||||
return a
|
||||
}
|
||||
|
||||
// initRouter sets up the routes for index, login, logout, and two-factor authentication.
|
||||
func (a *IndexController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/", a.index)
|
||||
g.GET("/csrf-token", a.csrfToken)
|
||||
|
||||
g.POST("/login", middleware.CSRFMiddleware(), a.login)
|
||||
g.POST("/logout", middleware.CSRFMiddleware(), a.logout)
|
||||
g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(), a.getTwoFactorEnable)
|
||||
}
|
||||
|
||||
// index handles the root route, redirecting logged-in users to the panel or showing the login page.
|
||||
func (a *IndexController) index(c *gin.Context) {
|
||||
if session.IsLogin(c) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path")+"panel/")
|
||||
return
|
||||
}
|
||||
serveDistPage(c, "login.html")
|
||||
}
|
||||
|
||||
// login handles user authentication and session creation.
|
||||
func (a *IndexController) login(c *gin.Context) {
|
||||
var form LoginForm
|
||||
|
||||
if err := c.ShouldBind(&form); err != nil {
|
||||
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.invalidFormData"))
|
||||
return
|
||||
}
|
||||
if form.Username == "" {
|
||||
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.emptyUsername"))
|
||||
return
|
||||
}
|
||||
if form.Password == "" {
|
||||
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.emptyPassword"))
|
||||
return
|
||||
}
|
||||
|
||||
remoteIP := getRemoteIp(c)
|
||||
safeUser := template.HTMLEscapeString(form.Username)
|
||||
timeStr := time.Now().Format("2006-01-02 15:04:05")
|
||||
if blockedUntil, ok := defaultLoginLimiter.allow(remoteIP, form.Username); !ok {
|
||||
reason := "too many failed attempts"
|
||||
logger.Warningf("failed login: username=%q, IP=%q, reason=%q, blocked_until=%s", safeUser, remoteIP, reason, blockedUntil.Format(time.RFC3339))
|
||||
a.tgbot.UserLoginNotify(tgbot.LoginAttempt{
|
||||
Username: safeUser,
|
||||
IP: remoteIP,
|
||||
Time: timeStr,
|
||||
Status: tgbot.LoginFail,
|
||||
Reason: reason,
|
||||
})
|
||||
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
|
||||
return
|
||||
}
|
||||
|
||||
user, checkErr := a.userService.CheckUser(form.Username, form.Password, form.TwoFactorCode)
|
||||
|
||||
if user == nil {
|
||||
reason := loginFailureReason(checkErr)
|
||||
if blockedUntil, blocked := defaultLoginLimiter.registerFailure(remoteIP, form.Username); blocked {
|
||||
logger.Warningf("failed login: username=%q, IP=%q, reason=%q, blocked_until=%s", safeUser, remoteIP, reason, blockedUntil.Format(time.RFC3339))
|
||||
} else {
|
||||
logger.Warningf("failed login: username=%q, IP=%q, reason=%q", safeUser, remoteIP, reason)
|
||||
}
|
||||
a.tgbot.UserLoginNotify(tgbot.LoginAttempt{
|
||||
Username: safeUser,
|
||||
IP: remoteIP,
|
||||
Time: timeStr,
|
||||
Status: tgbot.LoginFail,
|
||||
Reason: reason,
|
||||
})
|
||||
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
|
||||
return
|
||||
}
|
||||
|
||||
defaultLoginLimiter.registerSuccess(remoteIP, form.Username)
|
||||
logger.Infof("%s logged in successfully, Ip Address: %s\n", safeUser, remoteIP)
|
||||
a.tgbot.UserLoginNotify(tgbot.LoginAttempt{
|
||||
Username: safeUser,
|
||||
IP: remoteIP,
|
||||
Time: timeStr,
|
||||
Status: tgbot.LoginSuccess,
|
||||
})
|
||||
|
||||
if err := session.SetLoginUser(c, user); err != nil {
|
||||
logger.Warning("Unable to save session:", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("%s logged in successfully", safeUser)
|
||||
jsonMsg(c, I18nWeb(c, "pages.login.toasts.successLogin"), nil)
|
||||
}
|
||||
|
||||
func loginFailureReason(err error) string {
|
||||
if err != nil && err.Error() == "invalid 2fa code" {
|
||||
return "invalid 2FA code"
|
||||
}
|
||||
return "invalid credentials"
|
||||
}
|
||||
|
||||
func (a *IndexController) logout(c *gin.Context) {
|
||||
user := session.GetLoginUser(c)
|
||||
if user != nil {
|
||||
logger.Infof("%s logged out successfully", user.Username)
|
||||
}
|
||||
if err := session.ClearSession(c); err != nil {
|
||||
logger.Warning("Unable to clear session on logout:", err)
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// csrfToken returns the session CSRF token. Public — the login page
|
||||
// needs a token before authenticating.
|
||||
func (a *IndexController) csrfToken(c *gin.Context) {
|
||||
token, err := session.EnsureCSRFToken(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "msg": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "obj": token})
|
||||
}
|
||||
|
||||
// getTwoFactorEnable retrieves the current status of two-factor authentication.
|
||||
func (a *IndexController) getTwoFactorEnable(c *gin.Context) {
|
||||
status, err := a.settingService.GetTwoFactorEnable()
|
||||
if err == nil {
|
||||
jsonObj(c, status, nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
loginLimitMaxFailures = 5
|
||||
loginLimitWindow = 5 * time.Minute
|
||||
loginLimitCooldown = 15 * time.Minute
|
||||
)
|
||||
|
||||
var defaultLoginLimiter = newLoginLimiter(loginLimitMaxFailures, loginLimitWindow, loginLimitCooldown)
|
||||
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
maxFailures int
|
||||
window time.Duration
|
||||
cooldown time.Duration
|
||||
attempts map[string]*loginLimitRecord
|
||||
}
|
||||
|
||||
type loginLimitRecord struct {
|
||||
failures []time.Time
|
||||
blockedUntil time.Time
|
||||
}
|
||||
|
||||
func newLoginLimiter(maxFailures int, window, cooldown time.Duration) *loginLimiter {
|
||||
return &loginLimiter{
|
||||
now: time.Now,
|
||||
maxFailures: maxFailures,
|
||||
window: window,
|
||||
cooldown: cooldown,
|
||||
attempts: make(map[string]*loginLimitRecord),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *loginLimiter) allow(ip, username string) (time.Time, bool) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
key := loginLimitKey(ip, username)
|
||||
record := l.attempts[key]
|
||||
if record == nil {
|
||||
return time.Time{}, true
|
||||
}
|
||||
now := l.now()
|
||||
if now.Before(record.blockedUntil) {
|
||||
return record.blockedUntil, false
|
||||
}
|
||||
record.blockedUntil = time.Time{}
|
||||
record.failures = pruneLoginFailures(record.failures, now.Add(-l.window))
|
||||
if len(record.failures) == 0 {
|
||||
delete(l.attempts, key)
|
||||
}
|
||||
return time.Time{}, true
|
||||
}
|
||||
|
||||
func (l *loginLimiter) registerFailure(ip, username string) (time.Time, bool) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
key := loginLimitKey(ip, username)
|
||||
record := l.attempts[key]
|
||||
if record == nil {
|
||||
record = &loginLimitRecord{}
|
||||
l.attempts[key] = record
|
||||
}
|
||||
now := l.now()
|
||||
record.failures = pruneLoginFailures(record.failures, now.Add(-l.window))
|
||||
record.failures = append(record.failures, now)
|
||||
if len(record.failures) >= l.maxFailures {
|
||||
record.failures = nil
|
||||
record.blockedUntil = now.Add(l.cooldown)
|
||||
return record.blockedUntil, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func (l *loginLimiter) registerSuccess(ip, username string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.attempts, loginLimitKey(ip, username))
|
||||
}
|
||||
|
||||
func loginLimitKey(ip, username string) string {
|
||||
return strings.TrimSpace(ip) + "\x00" + strings.ToLower(strings.TrimSpace(username))
|
||||
}
|
||||
|
||||
func pruneLoginFailures(failures []time.Time, cutoff time.Time) []time.Time {
|
||||
keepFrom := 0
|
||||
for keepFrom < len(failures) && failures[keepFrom].Before(cutoff) {
|
||||
keepFrom++
|
||||
}
|
||||
return failures[keepFrom:]
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoginLimiterBlocksAfterConfiguredFailures(t *testing.T) {
|
||||
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
|
||||
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
|
||||
limiter.now = func() time.Time { return now }
|
||||
|
||||
for i := range 4 {
|
||||
if _, blocked := limiter.registerFailure("192.0.2.10", "Admin"); blocked {
|
||||
t.Fatalf("failure %d should not block yet", i+1)
|
||||
}
|
||||
if _, ok := limiter.allow("192.0.2.10", "admin"); !ok {
|
||||
t.Fatalf("failure %d should still allow login attempts", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
blockedUntil, blocked := limiter.registerFailure("192.0.2.10", "ADMIN")
|
||||
if !blocked {
|
||||
t.Fatal("fifth failure should start cooldown")
|
||||
}
|
||||
if want := now.Add(15 * time.Minute); !blockedUntil.Equal(want) {
|
||||
t.Fatalf("blocked until %s, want %s", blockedUntil, want)
|
||||
}
|
||||
if _, ok := limiter.allow("192.0.2.10", "admin"); ok {
|
||||
t.Fatal("login should be blocked during cooldown")
|
||||
}
|
||||
|
||||
now = blockedUntil
|
||||
if _, ok := limiter.allow("192.0.2.10", "admin"); !ok {
|
||||
t.Fatal("login should be allowed after cooldown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterPrunesOldFailuresAndResetsOnSuccess(t *testing.T) {
|
||||
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
|
||||
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
|
||||
limiter.now = func() time.Time { return now }
|
||||
|
||||
for range 4 {
|
||||
limiter.registerFailure("192.0.2.10", "admin")
|
||||
}
|
||||
now = now.Add(6 * time.Minute)
|
||||
if _, blocked := limiter.registerFailure("192.0.2.10", "admin"); blocked {
|
||||
t.Fatal("old failures should be pruned outside the rolling window")
|
||||
}
|
||||
|
||||
limiter.registerSuccess("192.0.2.10", "admin")
|
||||
for i := range 4 {
|
||||
if _, blocked := limiter.registerFailure("192.0.2.10", "admin"); blocked {
|
||||
t.Fatalf("success should reset previous failures; failure %d blocked", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterSeparatesIPAndUsername(t *testing.T) {
|
||||
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
|
||||
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
|
||||
limiter.now = func() time.Time { return now }
|
||||
|
||||
for range 5 {
|
||||
limiter.registerFailure("192.0.2.10", "admin")
|
||||
}
|
||||
if _, ok := limiter.allow("192.0.2.11", "admin"); !ok {
|
||||
t.Fatal("different IP should not be blocked")
|
||||
}
|
||||
if _, ok := limiter.allow("192.0.2.10", "other-admin"); !ok {
|
||||
t.Fatal("different username should not be blocked")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type NodeController struct {
|
||||
nodeService service.NodeService
|
||||
}
|
||||
|
||||
func NewNodeController(g *gin.RouterGroup) *NodeController {
|
||||
a := &NodeController{}
|
||||
a.initRouter(g)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *NodeController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/list", a.list)
|
||||
g.GET("/get/:id", a.get)
|
||||
g.GET("/webCert/:id", a.webCert)
|
||||
|
||||
g.POST("/add", a.add)
|
||||
g.POST("/update/:id", a.update)
|
||||
g.POST("/del/:id", a.del)
|
||||
g.POST("/setEnable/:id", a.setEnable)
|
||||
|
||||
g.POST("/test", a.test)
|
||||
g.POST("/certFingerprint", a.certFingerprint)
|
||||
g.POST("/probe/:id", a.probe)
|
||||
g.POST("/updatePanel", a.updatePanel)
|
||||
g.GET("/history/:id/:metric/:bucket", a.history)
|
||||
}
|
||||
|
||||
func (a *NodeController) list(c *gin.Context) {
|
||||
nodes, err := a.nodeService.GetNodeTree()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.list"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, nodes, nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) get(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
n, err := a.nodeService.GetById(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, n, nil)
|
||||
}
|
||||
|
||||
// webCert returns the node's own web TLS certificate/key file paths so the
|
||||
// inbound form's "Set Cert from Panel" can fill paths that exist on the node.
|
||||
func (a *NodeController) webCert(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
files, err := a.nodeService.GetWebCertFiles(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, files, nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) ensureReachable(c *gin.Context, n *model.Node) error {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
|
||||
defer cancel()
|
||||
if _, err := a.nodeService.Probe(ctx, n); err != nil {
|
||||
return errors.New(service.FriendlyProbeError(err.Error()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *NodeController) add(c *gin.Context) {
|
||||
n, ok := middleware.BindAndValidate[model.Node](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := a.ensureReachable(c, n); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
||||
return
|
||||
}
|
||||
if err := a.nodeService.Create(n); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
||||
return
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), n, nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) update(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
n, ok := middleware.BindAndValidate[model.Node](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := a.ensureReachable(c, n); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
if err := a.nodeService.Update(id, n); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) del(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
if err := a.nodeService.Delete(id); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.delete"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.delete"), nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) setEnable(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
body := struct {
|
||||
Enable bool `json:"enable" form:"enable"`
|
||||
}{}
|
||||
if err := c.ShouldBind(&body); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
if err := a.nodeService.SetEnable(id, body.Enable); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) test(c *gin.Context) {
|
||||
n := &model.Node{}
|
||||
if err := c.ShouldBind(n); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
|
||||
return
|
||||
}
|
||||
if n.Scheme == "" {
|
||||
n.Scheme = "https"
|
||||
}
|
||||
if n.BasePath == "" {
|
||||
n.BasePath = "/"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
|
||||
defer cancel()
|
||||
patch, err := a.nodeService.Probe(ctx, n)
|
||||
jsonObj(c, patch.ToUI(err == nil), nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) certFingerprint(c *gin.Context) {
|
||||
n := &model.Node{}
|
||||
if err := c.ShouldBind(n); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
|
||||
return
|
||||
}
|
||||
if n.Scheme == "" {
|
||||
n.Scheme = "https"
|
||||
}
|
||||
if n.BasePath == "" {
|
||||
n.BasePath = "/"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
|
||||
defer cancel()
|
||||
fp, err := a.nodeService.FetchCertFingerprint(ctx, n)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, fp, nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) probe(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
n, err := a.nodeService.GetById(id)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
|
||||
defer cancel()
|
||||
patch, probeErr := a.nodeService.Probe(ctx, n)
|
||||
if probeErr != nil {
|
||||
patch.Status = "offline"
|
||||
} else {
|
||||
patch.Status = "online"
|
||||
}
|
||||
_ = a.nodeService.UpdateHeartbeat(id, patch)
|
||||
jsonObj(c, patch.ToUI(probeErr == nil), nil)
|
||||
}
|
||||
|
||||
func (a *NodeController) updatePanel(c *gin.Context) {
|
||||
var req struct {
|
||||
Ids []int `json:"ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
if len(req.Ids) == 0 {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), fmt.Errorf("no nodes selected"))
|
||||
return
|
||||
}
|
||||
results, err := a.nodeService.UpdatePanels(req.Ids)
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.updateStarted"), results, err)
|
||||
}
|
||||
|
||||
func (a *NodeController) history(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
metric := c.Param("metric")
|
||||
if !slices.Contains(service.NodeMetricKeys, metric) {
|
||||
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
|
||||
return
|
||||
}
|
||||
bucket, err := strconv.Atoi(c.Param("bucket"))
|
||||
if err != nil || bucket <= 0 || !service.IsAllowedHistoryBucket(bucket) {
|
||||
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
|
||||
return
|
||||
}
|
||||
jsonObj(c, a.nodeService.AggregateNodeMetric(id, metric, bucket, 60), nil)
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var filenameRegex = regexp.MustCompile(`^[a-zA-Z0-9_\-.]+$`)
|
||||
|
||||
// ServerController handles server management and status-related operations.
|
||||
type ServerController struct {
|
||||
BaseController
|
||||
|
||||
serverService service.ServerService
|
||||
settingService service.SettingService
|
||||
panelService panel.PanelService
|
||||
xrayMetricsService service.XrayMetricsService
|
||||
}
|
||||
|
||||
// NewServerController creates a new ServerController, initializes routes, and starts background tasks.
|
||||
func NewServerController(g *gin.RouterGroup) *ServerController {
|
||||
a := &ServerController{}
|
||||
service.RestoreSystemMetrics()
|
||||
a.initRouter(g)
|
||||
a.startTask()
|
||||
return a
|
||||
}
|
||||
|
||||
// initRouter sets up the routes for server status, Xray management, and utility endpoints.
|
||||
func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
||||
|
||||
g.GET("/status", a.status)
|
||||
g.GET("/cpuHistory/:bucket", a.getCpuHistoryBucket)
|
||||
g.GET("/history/:metric/:bucket", a.getMetricHistoryBucket)
|
||||
g.GET("/xrayMetricsState", a.getXrayMetricsState)
|
||||
g.GET("/xrayMetricsHistory/:metric/:bucket", a.getXrayMetricsHistoryBucket)
|
||||
g.GET("/xrayObservatory", a.getXrayObservatory)
|
||||
g.GET("/xrayObservatoryHistory/:tag/:bucket", a.getXrayObservatoryHistoryBucket)
|
||||
g.GET("/getXrayVersion", a.getXrayVersion)
|
||||
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
|
||||
g.GET("/getConfigJson", a.getConfigJson)
|
||||
g.GET("/getDb", a.getDb)
|
||||
g.GET("/getMigration", a.getMigration)
|
||||
g.GET("/getNewUUID", a.getNewUUID)
|
||||
g.GET("/getWebCertFiles", a.getWebCertFiles)
|
||||
g.GET("/descendants", a.descendants)
|
||||
g.GET("/getNewX25519Cert", a.getNewX25519Cert)
|
||||
g.GET("/getNewmldsa65", a.getNewmldsa65)
|
||||
g.GET("/getNewmlkem768", a.getNewmlkem768)
|
||||
g.GET("/getNewVlessEnc", a.getNewVlessEnc)
|
||||
g.GET("/clientIps", a.getClientIps)
|
||||
|
||||
g.POST("/stopXrayService", a.stopXrayService)
|
||||
g.POST("/restartXrayService", a.restartXrayService)
|
||||
g.POST("/installXray/:version", a.installXray)
|
||||
g.POST("/updatePanel", a.updatePanel)
|
||||
g.POST("/updateGeofile", a.updateGeofile)
|
||||
g.POST("/updateGeofile/:fileName", a.updateGeofile)
|
||||
g.POST("/logs/:count", a.getLogs)
|
||||
g.POST("/xraylogs/:count", a.getXrayLogs)
|
||||
g.POST("/importDB", a.importDB)
|
||||
g.POST("/getNewEchCert", a.getNewEchCert)
|
||||
g.POST("/clientIps", a.setClientIps)
|
||||
}
|
||||
|
||||
// startTask registers the @2s ticker that refreshes server status, samples
|
||||
// xray metrics, and pushes the new snapshot to all websocket subscribers.
|
||||
// State + sampling live in ServerService; the controller only orchestrates
|
||||
// the cross-service side effects (xrayMetrics sample + websocket broadcast).
|
||||
func (a *ServerController) startTask() {
|
||||
c := global.GetWebServer().GetCron()
|
||||
c.AddFunc("@every 2s", func() {
|
||||
status := a.serverService.RefreshStatus()
|
||||
if status == nil {
|
||||
return
|
||||
}
|
||||
a.xrayMetricsService.Sample(time.Now())
|
||||
websocket.BroadcastStatus(status)
|
||||
})
|
||||
c.AddFunc("@every 1m", func() {
|
||||
if err := service.PersistSystemMetrics(); err != nil {
|
||||
logger.Warning("persist system metrics failed:", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// status returns the current server status information.
|
||||
func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.serverService.LastStatus(), nil) }
|
||||
|
||||
func parseHistoryBucket(c *gin.Context) (int, bool) {
|
||||
bucket, err := strconv.Atoi(c.Param("bucket"))
|
||||
if err != nil || bucket <= 0 || !service.IsAllowedHistoryBucket(bucket) {
|
||||
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
|
||||
return 0, false
|
||||
}
|
||||
return bucket, true
|
||||
}
|
||||
|
||||
// getCpuHistoryBucket retrieves aggregated CPU usage history based on the specified time bucket.
|
||||
// Kept for back-compat; new callers should use /history/cpu/:bucket which
|
||||
// returns {"t","v"} (uniform across all metrics) instead of {"t","cpu"}.
|
||||
func (a *ServerController) getCpuHistoryBucket(c *gin.Context) {
|
||||
bucket, ok := parseHistoryBucket(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
jsonObj(c, a.serverService.AggregateCpuHistory(bucket, 60), nil)
|
||||
}
|
||||
|
||||
// getMetricHistoryBucket returns up to 60 buckets of history for a single
|
||||
// system metric (cpu, mem, netUp, netDown, online, load1/5/15). The
|
||||
// SystemHistoryModal calls one endpoint per active tab.
|
||||
func (a *ServerController) getMetricHistoryBucket(c *gin.Context) {
|
||||
metric := c.Param("metric")
|
||||
if !slices.Contains(service.SystemMetricKeys, metric) {
|
||||
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
|
||||
return
|
||||
}
|
||||
bucket, ok := parseHistoryBucket(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
jsonObj(c, a.serverService.AggregateSystemMetric(metric, bucket, 60), nil)
|
||||
}
|
||||
|
||||
func (a *ServerController) getXrayMetricsState(c *gin.Context) {
|
||||
jsonObj(c, a.xrayMetricsService.State(), nil)
|
||||
}
|
||||
|
||||
func (a *ServerController) getXrayMetricsHistoryBucket(c *gin.Context) {
|
||||
metric := c.Param("metric")
|
||||
if !slices.Contains(service.XrayMetricKeys, metric) {
|
||||
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
|
||||
return
|
||||
}
|
||||
bucket, ok := parseHistoryBucket(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
jsonObj(c, a.xrayMetricsService.AggregateMetric(metric, bucket, 60), nil)
|
||||
}
|
||||
|
||||
func (a *ServerController) getXrayObservatory(c *gin.Context) {
|
||||
jsonObj(c, a.xrayMetricsService.ObservatorySnapshot(), nil)
|
||||
}
|
||||
|
||||
func (a *ServerController) getXrayObservatoryHistoryBucket(c *gin.Context) {
|
||||
tag := c.Param("tag")
|
||||
if !a.xrayMetricsService.HasObservatoryTag(tag) {
|
||||
jsonMsg(c, "invalid tag", fmt.Errorf("unknown observatory tag"))
|
||||
return
|
||||
}
|
||||
bucket, ok := parseHistoryBucket(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
jsonObj(c, a.xrayMetricsService.AggregateObservatory(tag, bucket, 60), nil)
|
||||
}
|
||||
|
||||
func (a *ServerController) getXrayVersion(c *gin.Context) {
|
||||
versions, err := a.serverService.GetXrayVersionsCached()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "getVersion"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, versions, nil)
|
||||
}
|
||||
|
||||
// getPanelUpdateInfo retrieves the current and latest panel version.
|
||||
func (a *ServerController) getPanelUpdateInfo(c *gin.Context) {
|
||||
info, err := a.panelService.GetUpdateInfo()
|
||||
if err != nil {
|
||||
logger.Debug("panel update check failed:", err)
|
||||
c.JSON(http.StatusOK, entity.Msg{Success: false})
|
||||
return
|
||||
}
|
||||
jsonObj(c, info, nil)
|
||||
}
|
||||
|
||||
// installXray installs or updates Xray to the specified version.
|
||||
func (a *ServerController) installXray(c *gin.Context) {
|
||||
version := c.Param("version")
|
||||
err := a.serverService.UpdateXray(version)
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.xraySwitchVersionPopover"), err)
|
||||
}
|
||||
|
||||
// updatePanel starts a panel self-update to the latest release.
|
||||
func (a *ServerController) updatePanel(c *gin.Context) {
|
||||
err := a.panelService.StartUpdate()
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.panelUpdateStartedPopover"), err)
|
||||
}
|
||||
|
||||
// updateGeofile updates the specified geo file for Xray.
|
||||
func (a *ServerController) updateGeofile(c *gin.Context) {
|
||||
fileName := c.Param("fileName")
|
||||
|
||||
if fileName != "" && !a.serverService.IsValidGeofileName(fileName) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"),
|
||||
fmt.Errorf("invalid filename: contains unsafe characters or path traversal patterns"))
|
||||
return
|
||||
}
|
||||
|
||||
err := a.serverService.UpdateGeofile(fileName)
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"), err)
|
||||
}
|
||||
|
||||
// stopXrayService stops the Xray service.
|
||||
func (a *ServerController) stopXrayService(c *gin.Context) {
|
||||
err := a.serverService.StopXrayService()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.xray.stopError"), err)
|
||||
websocket.BroadcastXrayState("error", err.Error())
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.xray.stopSuccess"), err)
|
||||
websocket.BroadcastXrayState("stop", "")
|
||||
websocket.BroadcastNotification(
|
||||
I18nWeb(c, "pages.xray.stopSuccess"),
|
||||
"Xray service has been stopped",
|
||||
"warning",
|
||||
)
|
||||
}
|
||||
|
||||
// restartXrayService restarts the Xray service.
|
||||
func (a *ServerController) restartXrayService(c *gin.Context) {
|
||||
err := a.serverService.RestartXrayService()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.xray.restartError"), err)
|
||||
websocket.BroadcastXrayState("error", err.Error())
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.xray.restartSuccess"), err)
|
||||
websocket.BroadcastXrayState("running", "")
|
||||
websocket.BroadcastNotification(
|
||||
I18nWeb(c, "pages.xray.restartSuccess"),
|
||||
"Xray service has been restarted successfully",
|
||||
"success",
|
||||
)
|
||||
}
|
||||
|
||||
// getLogs retrieves the application logs based on count, level, and syslog filters.
|
||||
func (a *ServerController) getLogs(c *gin.Context) {
|
||||
logs := a.serverService.GetLogs(c.Param("count"), c.PostForm("level"), c.PostForm("syslog"))
|
||||
jsonObj(c, logs, nil)
|
||||
}
|
||||
|
||||
// getXrayLogs retrieves Xray logs with filtering options for direct, blocked, and proxy traffic.
|
||||
func (a *ServerController) getXrayLogs(c *gin.Context) {
|
||||
freedoms, blackholes := a.serverService.GetDefaultLogOutboundTags()
|
||||
logs := a.serverService.GetXrayLogs(
|
||||
c.Param("count"),
|
||||
c.PostForm("filter"),
|
||||
c.PostForm("showDirect"),
|
||||
c.PostForm("showBlocked"),
|
||||
c.PostForm("showProxy"),
|
||||
freedoms,
|
||||
blackholes,
|
||||
)
|
||||
jsonObj(c, logs, nil)
|
||||
}
|
||||
|
||||
// getConfigJson retrieves the Xray configuration as JSON.
|
||||
func (a *ServerController) getConfigJson(c *gin.Context) {
|
||||
configJson, err := a.serverService.GetConfigJson()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.getConfigError"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, configJson, nil)
|
||||
}
|
||||
|
||||
// getDb downloads the database file.
|
||||
func (a *ServerController) getDb(c *gin.Context) {
|
||||
db, err := a.serverService.GetDb()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.getDatabaseError"), err)
|
||||
return
|
||||
}
|
||||
|
||||
filename := "x-ui.db"
|
||||
if database.IsPostgres() {
|
||||
filename = "x-ui.dump"
|
||||
}
|
||||
if !filenameRegex.MatchString(filename) {
|
||||
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Disposition", "attachment; filename="+filename)
|
||||
c.Writer.Write(db)
|
||||
}
|
||||
|
||||
// getMigration downloads a cross-engine migration file: a .dump on SQLite or a
|
||||
// .db SQLite database on PostgreSQL, so the data can seed the other backend.
|
||||
func (a *ServerController) getMigration(c *gin.Context) {
|
||||
data, filename, err := a.serverService.GetMigration()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.getDatabaseError"), err)
|
||||
return
|
||||
}
|
||||
if !filenameRegex.MatchString(filename) {
|
||||
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Disposition", "attachment; filename="+filename)
|
||||
c.Writer.Write(data)
|
||||
}
|
||||
|
||||
// importDB imports a database file and restarts the Xray service.
|
||||
func (a *ServerController) importDB(c *gin.Context) {
|
||||
file, _, err := c.Request.FormFile("db")
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.readDatabaseError"), err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
if err := a.serverService.ImportDB(file); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, I18nWeb(c, "pages.index.importDatabaseSuccess"), nil)
|
||||
}
|
||||
|
||||
// descendants publishes read-only summaries of the nodes this panel manages so
|
||||
// a parent panel can surface them as transitive sub-nodes in a chained
|
||||
// topology. Called by the parent via the node's API token (#4983).
|
||||
func (a *ServerController) descendants(c *gin.Context) {
|
||||
data, err := (&service.NodeService{}).LocalDescendants()
|
||||
jsonObj(c, data, err)
|
||||
}
|
||||
|
||||
// getWebCertFiles returns this panel's own web TLS certificate and key file
|
||||
// paths. The central panel calls it on a node (via the node's API token) so
|
||||
// "Set Cert from Panel" can fill a node-assigned inbound with paths that exist
|
||||
// on the node's filesystem instead of the central panel's — see issue #4854.
|
||||
func (a *ServerController) getWebCertFiles(c *gin.Context) {
|
||||
certFile, err := a.settingService.GetCertFile()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
keyFile, err := a.settingService.GetKeyFile()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, gin.H{"webCertFile": certFile, "webKeyFile": keyFile}, nil)
|
||||
}
|
||||
|
||||
// getNewX25519Cert generates a new X25519 certificate.
|
||||
func (a *ServerController) getNewX25519Cert(c *gin.Context) {
|
||||
cert, err := a.serverService.GetNewX25519Cert()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.getNewX25519CertError"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, cert, nil)
|
||||
}
|
||||
|
||||
// getNewmldsa65 generates a new ML-DSA-65 key.
|
||||
func (a *ServerController) getNewmldsa65(c *gin.Context) {
|
||||
cert, err := a.serverService.GetNewmldsa65()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.getNewmldsa65Error"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, cert, nil)
|
||||
}
|
||||
|
||||
// getNewEchCert generates a new ECH certificate for the given SNI.
|
||||
func (a *ServerController) getNewEchCert(c *gin.Context) {
|
||||
cert, err := a.serverService.GetNewEchCert(c.PostForm("sni"))
|
||||
if err != nil {
|
||||
jsonMsg(c, "get ech certificate", err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, cert, nil)
|
||||
}
|
||||
|
||||
// getNewVlessEnc generates a new VLESS encryption key.
|
||||
func (a *ServerController) getNewVlessEnc(c *gin.Context) {
|
||||
out, err := a.serverService.GetNewVlessEnc()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.getNewVlessEncError"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, out, nil)
|
||||
}
|
||||
|
||||
// getNewUUID generates a new UUID.
|
||||
func (a *ServerController) getNewUUID(c *gin.Context) {
|
||||
uuidResp, err := a.serverService.GetNewUUID()
|
||||
if err != nil {
|
||||
jsonMsg(c, "Failed to generate UUID", err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, uuidResp, nil)
|
||||
}
|
||||
|
||||
// getNewmlkem768 generates a new ML-KEM-768 key.
|
||||
func (a *ServerController) getNewmlkem768(c *gin.Context) {
|
||||
out, err := a.serverService.GetNewmlkem768()
|
||||
if err != nil {
|
||||
jsonMsg(c, "Failed to generate mlkem768 keys", err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, out, nil)
|
||||
}
|
||||
|
||||
func (a *ServerController) getClientIps(c *gin.Context) {
|
||||
ips, err := (&service.InboundService{}).GetAllInboundClientIps()
|
||||
jsonObj(c, ips, err)
|
||||
}
|
||||
|
||||
func (a *ServerController) setClientIps(c *gin.Context) {
|
||||
var ips []model.InboundClientIps
|
||||
if err := c.ShouldBindJSON(&ips); err != nil {
|
||||
jsonMsg(c, "invalid data", err)
|
||||
return
|
||||
}
|
||||
err := (&service.InboundService{}).MergeInboundClientIps(ips)
|
||||
jsonMsg(c, "Client IPs merged", err)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// updateUserForm represents the form for updating user credentials.
|
||||
type updateUserForm struct {
|
||||
OldUsername string `json:"oldUsername" form:"oldUsername"`
|
||||
OldPassword string `json:"oldPassword" form:"oldPassword"`
|
||||
NewUsername string `json:"newUsername" form:"newUsername"`
|
||||
NewPassword string `json:"newPassword" form:"newPassword"`
|
||||
}
|
||||
|
||||
// SettingController handles settings and user management operations.
|
||||
type SettingController struct {
|
||||
settingService service.SettingService
|
||||
userService panel.UserService
|
||||
panelService panel.PanelService
|
||||
apiTokenService panel.ApiTokenService
|
||||
}
|
||||
|
||||
// NewSettingController creates a new SettingController and initializes its routes.
|
||||
func NewSettingController(g *gin.RouterGroup) *SettingController {
|
||||
a := &SettingController{}
|
||||
a.initRouter(g)
|
||||
return a
|
||||
}
|
||||
|
||||
// initRouter sets up the routes for settings management.
|
||||
func (a *SettingController) initRouter(g *gin.RouterGroup) {
|
||||
g = g.Group("/setting")
|
||||
|
||||
g.POST("/all", a.getAllSetting)
|
||||
g.POST("/defaultSettings", a.getDefaultSettings)
|
||||
g.POST("/update", a.updateSetting)
|
||||
g.POST("/updateUser", a.updateUser)
|
||||
g.POST("/restartPanel", a.restartPanel)
|
||||
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
|
||||
g.GET("/apiTokens", a.listApiTokens)
|
||||
g.POST("/apiTokens/create", a.createApiToken)
|
||||
g.POST("/apiTokens/delete/:id", a.deleteApiToken)
|
||||
g.POST("/apiTokens/setEnabled/:id", a.setApiTokenEnabled)
|
||||
}
|
||||
|
||||
// getAllSetting retrieves all current settings.
|
||||
func (a *SettingController) getAllSetting(c *gin.Context) {
|
||||
allSetting, err := a.settingService.GetAllSetting()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, allSetting, nil)
|
||||
}
|
||||
|
||||
// getDefaultSettings retrieves the default settings based on the host.
|
||||
func (a *SettingController) getDefaultSettings(c *gin.Context) {
|
||||
result, err := a.settingService.GetDefaultSettings(c.Request.Host)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, result, nil)
|
||||
}
|
||||
|
||||
// updateSetting updates all settings with the provided data.
|
||||
func (a *SettingController) updateSetting(c *gin.Context) {
|
||||
allSetting, ok := middleware.BindAndValidate[entity.AllSetting](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
oldTwoFactor, twoFactorErr := a.settingService.GetTwoFactorEnable()
|
||||
err := a.settingService.UpdateAllSetting(allSetting)
|
||||
if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
|
||||
if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
|
||||
err = bumpErr
|
||||
}
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
}
|
||||
|
||||
// updateUser updates the current user's username and password.
|
||||
func (a *SettingController) updateUser(c *gin.Context) {
|
||||
form := &updateUserForm{}
|
||||
err := c.ShouldBind(form)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
return
|
||||
}
|
||||
user := session.GetLoginUser(c)
|
||||
if user.Username != form.OldUsername || !crypto.CheckPasswordHash(user.Password, form.OldPassword) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUserError"), errors.New(I18nWeb(c, "pages.settings.toasts.originalUserPassIncorrect")))
|
||||
return
|
||||
}
|
||||
if form.NewUsername == "" || form.NewPassword == "" {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUserError"), errors.New(I18nWeb(c, "pages.settings.toasts.userPassMustBeNotEmpty")))
|
||||
return
|
||||
}
|
||||
err = a.userService.UpdateUser(user.Id, form.NewUsername, form.NewPassword)
|
||||
if err == nil {
|
||||
user.Username = form.NewUsername
|
||||
user.Password, _ = crypto.HashPasswordAsBcrypt(form.NewPassword)
|
||||
if saveErr := session.SetLoginUser(c, user); saveErr != nil {
|
||||
err = saveErr
|
||||
}
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUser"), err)
|
||||
}
|
||||
|
||||
// restartPanel restarts the panel service after a delay.
|
||||
func (a *SettingController) restartPanel(c *gin.Context) {
|
||||
err := a.panelService.RestartPanel(time.Second * 3)
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.restartPanelSuccess"), err)
|
||||
}
|
||||
|
||||
// getDefaultXrayConfig retrieves the default Xray configuration.
|
||||
func (a *SettingController) 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)
|
||||
}
|
||||
|
||||
type apiTokenCreateForm struct {
|
||||
Name string `json:"name" form:"name"`
|
||||
}
|
||||
|
||||
type apiTokenEnabledForm struct {
|
||||
Enabled bool `json:"enabled" form:"enabled"`
|
||||
}
|
||||
|
||||
func (a *SettingController) listApiTokens(c *gin.Context) {
|
||||
rows, err := a.apiTokenService.List()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, rows, nil)
|
||||
}
|
||||
|
||||
func (a *SettingController) createApiToken(c *gin.Context) {
|
||||
form := &apiTokenCreateForm{}
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
return
|
||||
}
|
||||
row, err := a.apiTokenService.Create(form.Name)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, row, nil)
|
||||
}
|
||||
|
||||
func (a *SettingController) deleteApiToken(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.Delete(id))
|
||||
}
|
||||
|
||||
func (a *SettingController) setApiTokenEnabled(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
return
|
||||
}
|
||||
form := &apiTokenEnabledForm{}
|
||||
if bindErr := c.ShouldBind(form); bindErr != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), bindErr)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.SetEnabled(id, form.Enabled))
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// XUIController is the main controller for the X-UI panel, serving the SPA shell.
|
||||
type XUIController struct {
|
||||
BaseController
|
||||
}
|
||||
|
||||
// NewXUIController creates a new XUIController and initializes its routes.
|
||||
func NewXUIController(g *gin.RouterGroup) *XUIController {
|
||||
a := &XUIController{}
|
||||
a.initRouter(g)
|
||||
return a
|
||||
}
|
||||
|
||||
// initRouter sets up the main panel routes and initializes sub-controllers.
|
||||
//
|
||||
// The HTML routes all hand the same single-page-app shell (index.html) to the
|
||||
// browser; React Router takes over and renders the correct page from the URL.
|
||||
// The /panel/api, /panel/setting, /panel/xray sub-routers register POST/JSON
|
||||
// endpoints on different paths and stay untouched by the shell handler.
|
||||
func (a *XUIController) initRouter(g *gin.RouterGroup) {
|
||||
g = g.Group("/panel")
|
||||
g.Use(a.checkLogin)
|
||||
g.Use(middleware.CSRFMiddleware())
|
||||
|
||||
g.GET("/", a.panelSPA)
|
||||
g.GET("/inbounds", a.panelSPA)
|
||||
g.GET("/clients", a.panelSPA)
|
||||
g.GET("/groups", a.panelSPA)
|
||||
g.GET("/nodes", a.panelSPA)
|
||||
g.GET("/settings", a.panelSPA)
|
||||
g.GET("/xray", a.panelSPA)
|
||||
g.GET("/api-docs", a.panelSPA)
|
||||
|
||||
// SPA pages built by Vite don't have a server-rendered <meta name="csrf-token">,
|
||||
// so they fetch the session token via this endpoint at startup and replay it
|
||||
// on subsequent unsafe requests through axios.
|
||||
g.GET("/csrf-token", a.csrfToken)
|
||||
}
|
||||
|
||||
// panelSPA serves the React SPA shell. Every GET under /panel/ that isn't an
|
||||
// API endpoint returns the same index.html — React Router reads the URL and
|
||||
// mounts the matching page on the client.
|
||||
func (a *XUIController) panelSPA(c *gin.Context) {
|
||||
serveDistPage(c, "index.html")
|
||||
}
|
||||
|
||||
// csrfToken returns the session CSRF token to authenticated SPA clients.
|
||||
// The endpoint is GET (a safe method) so it bypasses CSRFMiddleware itself,
|
||||
// but checkLogin still gates the response — anonymous callers get 401/redirect.
|
||||
func (a *XUIController) csrfToken(c *gin.Context) {
|
||||
token, err := session.EnsureCSRFToken(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, entity.Msg{Success: false, Msg: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, entity.Msg{Success: true, Obj: token})
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// getRemoteIp extracts the real IP address from the request headers or remote address.
|
||||
func getRemoteIp(c *gin.Context) string {
|
||||
remoteIP, ok := extractTrustedIP(c.Request.RemoteAddr)
|
||||
if !ok {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
if isTrustedProxy(remoteIP) {
|
||||
if ip, ok := extractTrustedIP(c.GetHeader("X-Real-IP")); ok {
|
||||
return ip
|
||||
}
|
||||
|
||||
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
||||
for part := range strings.SplitSeq(xff, ",") {
|
||||
if ip, ok := extractTrustedIP(part); ok {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return remoteIP
|
||||
}
|
||||
|
||||
func isTrustedForwardedRequest(c *gin.Context) bool {
|
||||
remoteIP, ok := extractTrustedIP(c.Request.RemoteAddr)
|
||||
return ok && isTrustedProxy(remoteIP)
|
||||
}
|
||||
|
||||
func isTrustedProxy(ip string) bool {
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
trusted := trustedProxyCIDRs()
|
||||
for value := range strings.SplitSeq(trusted, ",") {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if prefix, err := netip.ParsePrefix(value); err == nil {
|
||||
if prefix.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if proxyIP, err := netip.ParseAddr(value); err == nil && proxyIP.Unmap() == addr.Unmap() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func trustedProxyCIDRs() (trusted string) {
|
||||
trusted = "127.0.0.1/32,::1/128"
|
||||
defer func() {
|
||||
_ = recover()
|
||||
}()
|
||||
settingService := service.SettingService{}
|
||||
if value, err := settingService.GetTrustedProxyCIDRs(); err == nil && strings.TrimSpace(value) != "" {
|
||||
trusted = value
|
||||
}
|
||||
return trusted
|
||||
}
|
||||
|
||||
func extractTrustedIP(value string) (string, bool) {
|
||||
candidate := strings.TrimSpace(value)
|
||||
if candidate == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if ip, ok := parseIPCandidate(candidate); ok {
|
||||
return ip.String(), true
|
||||
}
|
||||
|
||||
if host, _, err := net.SplitHostPort(candidate); err == nil {
|
||||
if ip, ok := parseIPCandidate(host); ok {
|
||||
return ip.String(), true
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Count(candidate, ":") == 1 {
|
||||
if host, _, err := net.SplitHostPort(fmt.Sprintf("[%s]", candidate)); err == nil {
|
||||
if ip, ok := parseIPCandidate(host); ok {
|
||||
return ip.String(), true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func parseIPCandidate(value string) (netip.Addr, bool) {
|
||||
ip, err := netip.ParseAddr(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
return ip.Unmap(), true
|
||||
}
|
||||
|
||||
// jsonMsg sends a JSON response with a message and error status.
|
||||
func jsonMsg(c *gin.Context, msg string, err error) {
|
||||
jsonMsgObj(c, msg, nil, err)
|
||||
}
|
||||
|
||||
// jsonObj sends a JSON response with an object and error status.
|
||||
func jsonObj(c *gin.Context, obj any, err error) {
|
||||
jsonMsgObj(c, "", obj, err)
|
||||
}
|
||||
|
||||
func requestErrorContext(c *gin.Context) string {
|
||||
handler, loc := callerOutsideUtil()
|
||||
return fmt.Sprintf("[%s %s handler=%s %s]", c.Request.Method, c.Request.URL.Path, handler, loc)
|
||||
}
|
||||
|
||||
func callerOutsideUtil() (string, string) {
|
||||
var pcs [12]uintptr
|
||||
n := runtime.Callers(2, pcs[:])
|
||||
frames := runtime.CallersFrames(pcs[:n])
|
||||
for {
|
||||
frame, more := frames.Next()
|
||||
base := filepath.Base(frame.File)
|
||||
if base != "util.go" {
|
||||
name := frame.Function
|
||||
if idx := strings.LastIndex(name, "/"); idx >= 0 {
|
||||
name = name[idx+1:]
|
||||
}
|
||||
return name, fmt.Sprintf("%s:%d", base, frame.Line)
|
||||
}
|
||||
if !more {
|
||||
break
|
||||
}
|
||||
}
|
||||
return "unknown", "unknown"
|
||||
}
|
||||
|
||||
// jsonMsgObj sends a JSON response with a message, object, and error status.
|
||||
func jsonMsgObj(c *gin.Context, msg string, obj any, err error) {
|
||||
m := entity.Msg{
|
||||
Obj: obj,
|
||||
}
|
||||
if err == nil {
|
||||
m.Success = true
|
||||
if msg != "" {
|
||||
m.Msg = msg
|
||||
}
|
||||
} else {
|
||||
m.Success = false
|
||||
ctx := requestErrorContext(c)
|
||||
fail := I18nWeb(c, "fail")
|
||||
errStr := err.Error()
|
||||
if errStr != "" {
|
||||
m.Msg = msg + " (" + errStr + ")"
|
||||
logger.Warningf("%s %s %s: %v", ctx, msg, fail, err)
|
||||
} else if msg != "" {
|
||||
m.Msg = msg
|
||||
logger.Warningf("%s %s %s", ctx, msg, fail)
|
||||
} else {
|
||||
m.Msg = I18nWeb(c, "somethingWentWrong")
|
||||
logger.Warningf("%s %s %s", ctx, m.Msg, fail)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, m)
|
||||
}
|
||||
|
||||
// pendingNodeObj returns a response object flagging that the save committed
|
||||
// locally but a backing node was offline/disabled, so the change will be
|
||||
// mirrored to the node once it reconnects. Returns nil when nothing is pending.
|
||||
func pendingNodeObj(pending bool) any {
|
||||
if pending {
|
||||
return gin.H{"nodePending": true}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pureJsonMsg sends a pure JSON message response with custom status code.
|
||||
func pureJsonMsg(c *gin.Context, statusCode int, success bool, msg string) {
|
||||
c.JSON(statusCode, entity.Msg{
|
||||
Success: success,
|
||||
Msg: msg,
|
||||
})
|
||||
}
|
||||
|
||||
// isAjax checks if the request is an AJAX request.
|
||||
func isAjax(c *gin.Context) bool {
|
||||
return c.GetHeader("X-Requested-With") == "XMLHttpRequest"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestGetRemoteIpIgnoresForwardedHeadersFromUntrustedRemote(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c.Request.RemoteAddr = "203.0.113.10:12345"
|
||||
c.Request.Header.Set("X-Real-IP", "198.51.100.9")
|
||||
c.Request.Header.Set("X-Forwarded-For", "198.51.100.8")
|
||||
|
||||
if got := getRemoteIp(c); got != "203.0.113.10" {
|
||||
t.Fatalf("remote IP = %q, want request remote address", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRemoteIpHonorsForwardedHeadersFromTrustedLoopbackProxy(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c.Request.RemoteAddr = "127.0.0.1:12345"
|
||||
c.Request.Header.Set("X-Forwarded-For", "198.51.100.8, 127.0.0.1")
|
||||
|
||||
if got := getRemoteIp(c); got != "198.51.100.8" {
|
||||
t.Fatalf("remote IP = %q, want forwarded client IP", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
ws "github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = ws.Upgrader{
|
||||
ReadBufferSize: 32768,
|
||||
WriteBufferSize: 32768,
|
||||
EnableCompression: true,
|
||||
CheckOrigin: checkSameOrigin,
|
||||
}
|
||||
|
||||
// checkSameOrigin allows requests with no Origin header (same-origin or non-browser
|
||||
// clients) and otherwise requires the Origin hostname to match the request hostname.
|
||||
// Comparison is case-insensitive (RFC 7230 §2.7.3) and ignores port differences
|
||||
// (the panel often sits behind a reverse proxy on a different port).
|
||||
func checkSameOrigin(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
return false
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.Host)
|
||||
if err != nil {
|
||||
// IPv6 literals without a port arrive as "[::1]"; net.SplitHostPort
|
||||
// fails in that case while url.Hostname() returns the address without
|
||||
// brackets. Strip them so same-origin checks pass for bare IPv6 hosts.
|
||||
host = r.Host
|
||||
if len(host) >= 2 && host[0] == '[' && host[len(host)-1] == ']' {
|
||||
host = host[1 : len(host)-1]
|
||||
}
|
||||
}
|
||||
return strings.EqualFold(u.Hostname(), host)
|
||||
}
|
||||
|
||||
// WebSocketController handles the HTTP→WebSocket upgrade for real-time updates.
|
||||
// All per-connection lifecycle (pumps, hub registration) lives in
|
||||
// panel.WebSocketService — this controller is HTTP-layer only.
|
||||
type WebSocketController struct {
|
||||
BaseController
|
||||
service *panel.WebSocketService
|
||||
}
|
||||
|
||||
// NewWebSocketController creates a controller wired to the given service.
|
||||
func NewWebSocketController(svc *panel.WebSocketService) *WebSocketController {
|
||||
return &WebSocketController{service: svc}
|
||||
}
|
||||
|
||||
// HandleWebSocket authenticates the request, upgrades the HTTP connection, and
|
||||
// hands ownership of the connection off to the service.
|
||||
func (w *WebSocketController) HandleWebSocket(c *gin.Context) {
|
||||
if !session.IsLogin(c) {
|
||||
logger.Warningf("Unauthorized WebSocket connection attempt from %s", getRemoteIp(c))
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
logger.Error("Failed to upgrade WebSocket connection:", err)
|
||||
return
|
||||
}
|
||||
|
||||
w.service.HandleConnection(conn, getRemoteIp(c))
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"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/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
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// 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) // axios-friendly alias
|
||||
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.
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
_ = 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.
|
||||
_ = 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 (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")
|
||||
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)
|
||||
}
|
||||
|
||||
// --- 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"
|
||||
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)
|
||||
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"
|
||||
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); 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"
|
||||
// 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)
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user