mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 00:24:19 +00:00
41645255f1
* 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.
712 lines
21 KiB
Go
712 lines
21 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"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/util/common"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
|
)
|
|
|
|
type HeartbeatPatch struct {
|
|
Status string
|
|
LastHeartbeat int64
|
|
LatencyMs int
|
|
XrayVersion string
|
|
PanelVersion string
|
|
Guid string
|
|
CpuPct float64
|
|
MemPct float64
|
|
UptimeSecs uint64
|
|
LastError string
|
|
// XrayState and XrayError come from the remote /panel/api/server/status when the
|
|
// panel API is reachable. They allow distinguishing panel connectivity from
|
|
// Xray core health on the node.
|
|
XrayState string
|
|
XrayError string
|
|
}
|
|
|
|
type NodeService struct{}
|
|
|
|
var nodeHTTPClient = &http.Client{
|
|
Transport: &http.Transport{
|
|
MaxIdleConns: 64,
|
|
MaxIdleConnsPerHost: 4,
|
|
IdleConnTimeout: 60 * time.Second,
|
|
DialContext: netsafe.SSRFGuardedDialContext,
|
|
},
|
|
}
|
|
|
|
// nodeHTTPClientFor returns the HTTP client used to reach a node, honoring its
|
|
// per-node TLS verification mode. "verify" (or any http node) uses the shared
|
|
// client with default certificate validation. "skip" disables validation.
|
|
// "pin" disables the default chain check but verifies the leaf certificate's
|
|
// SHA-256 against the stored pin, keeping MITM protection for self-signed certs.
|
|
func nodeHTTPClientFor(n *model.Node) (*http.Client, error) {
|
|
mode := n.TlsVerifyMode
|
|
if mode == "" {
|
|
mode = "verify"
|
|
}
|
|
if mode == "verify" || n.Scheme == "http" {
|
|
return nodeHTTPClient, nil
|
|
}
|
|
tlsCfg := &tls.Config{InsecureSkipVerify: true}
|
|
if mode == "pin" {
|
|
want, err := decodeCertPin(n.PinnedCertSha256)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tlsCfg.VerifyConnection = func(cs tls.ConnectionState) error {
|
|
if len(cs.PeerCertificates) == 0 {
|
|
return common.NewError("node presented no certificate")
|
|
}
|
|
sum := sha256.Sum256(cs.PeerCertificates[0].Raw)
|
|
if subtle.ConstantTimeCompare(sum[:], want) != 1 {
|
|
return common.NewError("node certificate does not match pinned SHA-256")
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
return &http.Client{
|
|
Transport: &http.Transport{
|
|
MaxIdleConns: 64,
|
|
MaxIdleConnsPerHost: 4,
|
|
IdleConnTimeout: 60 * time.Second,
|
|
DialContext: netsafe.SSRFGuardedDialContext,
|
|
TLSClientConfig: tlsCfg,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// decodeCertPin accepts a SHA-256 certificate hash as base64 (the format used
|
|
// by Xray's pinnedPeerCertSha256) or hex with optional colons (the openssl
|
|
// -fingerprint style) and returns the 32 raw bytes.
|
|
func decodeCertPin(s string) ([]byte, error) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return nil, common.NewError("certificate pin is empty")
|
|
}
|
|
if b, err := hex.DecodeString(strings.ReplaceAll(s, ":", "")); err == nil && len(b) == sha256.Size {
|
|
return b, nil
|
|
}
|
|
for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
|
|
if b, err := enc.DecodeString(s); err == nil && len(b) == sha256.Size {
|
|
return b, nil
|
|
}
|
|
}
|
|
return nil, common.NewError("certificate pin must be a SHA-256 hash (base64 or hex)")
|
|
}
|
|
|
|
// FetchCertFingerprint connects to the node over HTTPS without verifying the
|
|
// certificate and returns the leaf certificate's SHA-256 as base64, so the UI
|
|
// can offer a "fetch and pin current certificate" action.
|
|
func (s *NodeService) FetchCertFingerprint(ctx context.Context, n *model.Node) (string, error) {
|
|
addr, err := netsafe.NormalizeHost(n.Address)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
scheme := n.Scheme
|
|
if scheme != "http" && scheme != "https" {
|
|
scheme = "https"
|
|
}
|
|
if scheme != "https" {
|
|
return "", common.NewError("certificate pinning is only available for https nodes")
|
|
}
|
|
if n.Port <= 0 || n.Port > 65535 {
|
|
return "", common.NewError("node port must be 1-65535")
|
|
}
|
|
probeURL := &url.URL{
|
|
Scheme: scheme,
|
|
Host: net.JoinHostPort(addr, strconv.Itoa(n.Port)),
|
|
Path: normalizeBasePath(n.BasePath) + "panel/api/server/status",
|
|
}
|
|
req, err := http.NewRequestWithContext(
|
|
netsafe.ContextWithAllowPrivate(ctx, n.AllowPrivateAddress),
|
|
http.MethodGet, probeURL.String(), nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
client := &http.Client{
|
|
Transport: &http.Transport{
|
|
DialContext: netsafe.SSRFGuardedDialContext,
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // lgtm[go/disabled-certificate-check]
|
|
},
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.TLS == nil || len(resp.TLS.PeerCertificates) == 0 {
|
|
return "", common.NewError("node did not present a TLS certificate")
|
|
}
|
|
sum := sha256.Sum256(resp.TLS.PeerCertificates[0].Raw)
|
|
return base64.StdEncoding.EncodeToString(sum[:]), nil
|
|
}
|
|
|
|
func (s *NodeService) GetAll() ([]*model.Node, error) {
|
|
db := database.GetDB()
|
|
var nodes []*model.Node
|
|
err := db.Model(model.Node{}).Order("id asc").Find(&nodes).Error
|
|
if err != nil || len(nodes) == 0 {
|
|
return nodes, err
|
|
}
|
|
|
|
type inboundRow struct {
|
|
Id int
|
|
NodeID int `gorm:"column:node_id"`
|
|
}
|
|
var inboundRows []inboundRow
|
|
if err := db.Table("inbounds").
|
|
Select("id, node_id").
|
|
Where("node_id IS NOT NULL").
|
|
Scan(&inboundRows).Error; err != nil {
|
|
return nodes, nil
|
|
}
|
|
if len(inboundRows) == 0 {
|
|
return nodes, nil
|
|
}
|
|
inboundsByNode := make(map[int][]int, len(nodes))
|
|
nodeByInbound := make(map[int]int, len(inboundRows))
|
|
for _, row := range inboundRows {
|
|
inboundsByNode[row.NodeID] = append(inboundsByNode[row.NodeID], row.Id)
|
|
nodeByInbound[row.Id] = row.NodeID
|
|
}
|
|
|
|
type clientCountRow struct {
|
|
NodeID int `gorm:"column:node_id"`
|
|
Count int `gorm:"column:count"`
|
|
}
|
|
var clientCounts []clientCountRow
|
|
if err := db.Raw(`
|
|
SELECT inbounds.node_id AS node_id, COUNT(DISTINCT client_inbounds.client_id) AS count
|
|
FROM inbounds
|
|
JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
|
|
WHERE inbounds.node_id IS NOT NULL
|
|
GROUP BY inbounds.node_id
|
|
`).Scan(&clientCounts).Error; err == nil {
|
|
for _, row := range clientCounts {
|
|
for _, n := range nodes {
|
|
if n.Id == row.NodeID {
|
|
n.ClientCount = row.Count
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
now := time.Now().UnixMilli()
|
|
type trafficRow struct {
|
|
InboundID int `gorm:"column:inbound_id"`
|
|
Email string
|
|
Enable bool
|
|
Total int64
|
|
Up int64
|
|
Down int64
|
|
ExpiryTime int64 `gorm:"column:expiry_time"`
|
|
}
|
|
var trafficRows []trafficRow
|
|
inboundIDs := make([]int, 0, len(nodeByInbound))
|
|
for id := range nodeByInbound {
|
|
inboundIDs = append(inboundIDs, id)
|
|
}
|
|
// Chunk the IN clause to avoid "too many SQL variables" on SQLite
|
|
// when there are many node-owned inbounds (common with many nodes).
|
|
// sqliteMaxVars is defined in this package (inbound.go).
|
|
for _, batch := range chunkInts(inboundIDs, sqliteMaxVars) {
|
|
var page []trafficRow
|
|
if err := db.Table("client_traffics").
|
|
Select("inbound_id, email, enable, total, up, down, expiry_time").
|
|
Where("inbound_id IN ?", batch).
|
|
Scan(&page).Error; err == nil {
|
|
trafficRows = append(trafficRows, page...)
|
|
}
|
|
}
|
|
depletedByNode := make(map[int]int)
|
|
if len(trafficRows) > 0 {
|
|
for _, row := range trafficRows {
|
|
nodeID, ok := nodeByInbound[row.InboundID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
expired := row.ExpiryTime > 0 && row.ExpiryTime <= now
|
|
exhausted := row.Total > 0 && row.Up+row.Down >= row.Total
|
|
if expired || exhausted || !row.Enable {
|
|
depletedByNode[nodeID]++
|
|
}
|
|
}
|
|
}
|
|
onlineByGuid := s.onlineEmailsByGuid()
|
|
for _, n := range nodes {
|
|
n.InboundCount = len(inboundsByNode[n.Id])
|
|
n.DepletedCount = depletedByNode[n.Id]
|
|
// Online is attributed to the node that physically hosts the client
|
|
// (by GUID): a client on a sub-node counts under the sub-node, not
|
|
// the intermediate node it syncs through (#4983).
|
|
n.OnlineCount = len(onlineByGuid[effectiveNodeGuid(n)])
|
|
}
|
|
|
|
return nodes, nil
|
|
}
|
|
|
|
func (s *NodeService) onlineEmailsByGuid() map[string]map[string]struct{} {
|
|
svc := InboundService{}
|
|
byGuid := svc.GetOnlineClientsByGuid()
|
|
out := make(map[string]map[string]struct{}, len(byGuid))
|
|
for guid, emails := range byGuid {
|
|
set := make(map[string]struct{}, len(emails))
|
|
for _, email := range emails {
|
|
set[email] = struct{}{}
|
|
}
|
|
out[guid] = set
|
|
}
|
|
return out
|
|
}
|
|
|
|
// effectiveNodeGuid is a node's stable online-attribution key: its reported
|
|
// panelGuid, or a master-local synthetic id when the node is an old build that
|
|
// hasn't reported one yet (#4983).
|
|
func effectiveNodeGuid(n *model.Node) string {
|
|
if n.Guid != "" {
|
|
return n.Guid
|
|
}
|
|
return synthNodeGuid(n.Id)
|
|
}
|
|
|
|
func (s *NodeService) GetById(id int) (*model.Node, error) {
|
|
db := database.GetDB()
|
|
n := &model.Node{}
|
|
if err := db.Model(model.Node{}).Where("id = ?", id).First(n).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// NodeExists reports whether a node with the given id exists on this panel.
|
|
// Used to drop stale, cross-panel node references on inbound import. A Count
|
|
// query distinguishes "no such node" (count 0, no error) from a real DB error.
|
|
func (s *NodeService) NodeExists(id int) (bool, error) {
|
|
if id <= 0 {
|
|
return false, nil
|
|
}
|
|
var count int64
|
|
if err := database.GetDB().Model(model.Node{}).Where("id = ?", id).Count(&count).Error; err != nil {
|
|
return false, err
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
func normalizeBasePath(p string) string {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
return "/"
|
|
}
|
|
if !strings.HasPrefix(p, "/") {
|
|
p = "/" + p
|
|
}
|
|
if !strings.HasSuffix(p, "/") {
|
|
p = p + "/"
|
|
}
|
|
return p
|
|
}
|
|
|
|
func (s *NodeService) normalize(n *model.Node) error {
|
|
n.Name = strings.TrimSpace(n.Name)
|
|
n.ApiToken = strings.TrimSpace(n.ApiToken)
|
|
if n.Name == "" {
|
|
return common.NewError("node name is required")
|
|
}
|
|
addr, err := netsafe.NormalizeHost(n.Address)
|
|
if err != nil {
|
|
return common.NewError(err.Error())
|
|
}
|
|
n.Address = addr
|
|
if n.Port <= 0 || n.Port > 65535 {
|
|
return common.NewError("node port must be 1-65535")
|
|
}
|
|
if n.Scheme != "http" && n.Scheme != "https" {
|
|
n.Scheme = "https"
|
|
}
|
|
if n.TlsVerifyMode != "skip" && n.TlsVerifyMode != "pin" {
|
|
n.TlsVerifyMode = "verify"
|
|
}
|
|
n.PinnedCertSha256 = strings.TrimSpace(n.PinnedCertSha256)
|
|
if n.TlsVerifyMode == "pin" {
|
|
if _, err := decodeCertPin(n.PinnedCertSha256); err != nil {
|
|
return common.NewError(err.Error())
|
|
}
|
|
}
|
|
n.BasePath = normalizeBasePath(n.BasePath)
|
|
return nil
|
|
}
|
|
|
|
func (s *NodeService) Create(n *model.Node) error {
|
|
if err := s.normalize(n); err != nil {
|
|
return err
|
|
}
|
|
db := database.GetDB()
|
|
return db.Create(n).Error
|
|
}
|
|
|
|
func (s *NodeService) Update(id int, in *model.Node) error {
|
|
if err := s.normalize(in); err != nil {
|
|
return err
|
|
}
|
|
db := database.GetDB()
|
|
existing := &model.Node{}
|
|
if err := db.Where("id = ?", id).First(existing).Error; err != nil {
|
|
return err
|
|
}
|
|
updates := map[string]any{
|
|
"name": in.Name,
|
|
"remark": in.Remark,
|
|
"scheme": in.Scheme,
|
|
"address": in.Address,
|
|
"port": in.Port,
|
|
"base_path": in.BasePath,
|
|
"api_token": in.ApiToken,
|
|
"enable": in.Enable,
|
|
"allow_private_address": in.AllowPrivateAddress,
|
|
"tls_verify_mode": in.TlsVerifyMode,
|
|
"pinned_cert_sha256": in.PinnedCertSha256,
|
|
}
|
|
if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
|
return err
|
|
}
|
|
if mgr := runtime.GetManager(); mgr != nil {
|
|
mgr.InvalidateNode(id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *NodeService) Delete(id int) error {
|
|
db := database.GetDB()
|
|
if err := db.Where("id = ?", id).Delete(model.Node{}).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := db.Where("node_id = ?", id).Delete(&model.NodeClientTraffic{}).Error; err != nil {
|
|
return err
|
|
}
|
|
if mgr := runtime.GetManager(); mgr != nil {
|
|
mgr.InvalidateNode(id)
|
|
}
|
|
nodeMetrics.drop(nodeMetricKey(id, "cpu"))
|
|
nodeMetrics.drop(nodeMetricKey(id, "mem"))
|
|
return nil
|
|
}
|
|
|
|
func (s *NodeService) SetEnable(id int, enable bool) error {
|
|
db := database.GetDB()
|
|
return db.Model(model.Node{}).Where("id = ?", id).Update("enable", enable).Error
|
|
}
|
|
|
|
// GetWebCertFiles asks a node for its own web TLS certificate/key file paths,
|
|
// used by "Set Cert from Panel" so a node-assigned inbound gets paths that
|
|
// exist on the node rather than the central panel. See issue #4854.
|
|
func (s *NodeService) GetWebCertFiles(id int) (*runtime.WebCertFiles, error) {
|
|
n, err := s.GetById(id)
|
|
if err != nil || n == nil {
|
|
return nil, fmt.Errorf("node not found")
|
|
}
|
|
if !n.Enable {
|
|
return nil, fmt.Errorf("node is disabled")
|
|
}
|
|
mgr := runtime.GetManager()
|
|
if mgr == nil {
|
|
return nil, fmt.Errorf("runtime manager unavailable")
|
|
}
|
|
remote, err := mgr.RemoteFor(n)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
return remote.GetWebCertFiles(ctx)
|
|
}
|
|
|
|
// NodeUpdateResult reports the outcome of triggering a panel self-update on one
|
|
// node so the UI can show per-node success/failure for a bulk request.
|
|
type NodeUpdateResult struct {
|
|
Id int `json:"id"`
|
|
Name string `json:"name"`
|
|
OK bool `json:"ok"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// UpdatePanels triggers the official self-updater on each given node. Only
|
|
// enabled, online nodes are eligible — an offline node can't be reached, so it
|
|
// is reported as skipped rather than silently dropped.
|
|
func (s *NodeService) UpdatePanels(ids []int) ([]NodeUpdateResult, error) {
|
|
mgr := runtime.GetManager()
|
|
if mgr == nil {
|
|
return nil, fmt.Errorf("runtime manager unavailable")
|
|
}
|
|
results := make([]NodeUpdateResult, 0, len(ids))
|
|
for _, id := range ids {
|
|
n, err := s.GetById(id)
|
|
if err != nil || n == nil {
|
|
results = append(results, NodeUpdateResult{Id: id, OK: false, Error: "node not found"})
|
|
continue
|
|
}
|
|
res := NodeUpdateResult{Id: id, Name: n.Name}
|
|
switch {
|
|
case !n.Enable:
|
|
res.Error = "node is disabled"
|
|
case n.Status != "online":
|
|
res.Error = "node is offline"
|
|
default:
|
|
remote, remoteErr := mgr.RemoteFor(n)
|
|
if remoteErr != nil {
|
|
res.Error = remoteErr.Error()
|
|
break
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
updErr := remote.UpdatePanel(ctx)
|
|
cancel()
|
|
if updErr != nil {
|
|
res.Error = updErr.Error()
|
|
} else {
|
|
res.OK = true
|
|
}
|
|
}
|
|
results = append(results, res)
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
|
|
db := database.GetDB()
|
|
updates := map[string]any{
|
|
"status": p.Status,
|
|
"last_heartbeat": p.LastHeartbeat,
|
|
"latency_ms": p.LatencyMs,
|
|
"xray_version": p.XrayVersion,
|
|
"panel_version": p.PanelVersion,
|
|
"cpu_pct": p.CpuPct,
|
|
"mem_pct": p.MemPct,
|
|
"uptime_secs": p.UptimeSecs,
|
|
"last_error": p.LastError,
|
|
"xray_state": p.XrayState,
|
|
"xray_error": p.XrayError,
|
|
}
|
|
// Only learn the GUID; never clear a known one if an old-build node (or a
|
|
// failed probe) reports none, so the stable identity survives blips.
|
|
if p.Guid != "" {
|
|
updates["guid"] = p.Guid
|
|
}
|
|
if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
|
return err
|
|
}
|
|
if p.Status == "online" {
|
|
now := time.Unix(p.LastHeartbeat, 0)
|
|
nodeMetrics.append(nodeMetricKey(id, "cpu"), now, p.CpuPct)
|
|
nodeMetrics.append(nodeMetricKey(id, "mem"), now, p.MemPct)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *NodeService) MarkNodeDirty(id int) error {
|
|
if id <= 0 {
|
|
return nil
|
|
}
|
|
return database.GetDB().Model(model.Node{}).
|
|
Where("id = ?", id).
|
|
Updates(map[string]any{
|
|
"config_dirty": true,
|
|
"config_dirty_at": time.Now().UnixMilli(),
|
|
}).Error
|
|
}
|
|
|
|
func (s *NodeService) ClearNodeDirty(id int, dirtyAt int64) error {
|
|
if id <= 0 {
|
|
return nil
|
|
}
|
|
return database.GetDB().Model(model.Node{}).
|
|
Where("id = ? AND config_dirty_at = ?", id, dirtyAt).
|
|
Update("config_dirty", false).Error
|
|
}
|
|
|
|
func (s *NodeService) NodeSyncState(id int) (enabled bool, status string, dirty bool, dirtyAt int64, err error) {
|
|
if id <= 0 {
|
|
return false, "", false, 0, errors.New("invalid node id")
|
|
}
|
|
var row model.Node
|
|
err = database.GetDB().Model(model.Node{}).
|
|
Select("enable", "status", "config_dirty", "config_dirty_at").
|
|
Where("id = ?", id).
|
|
First(&row).Error
|
|
if err != nil {
|
|
return false, "", false, 0, err
|
|
}
|
|
return row.Enable, row.Status, row.ConfigDirty, row.ConfigDirtyAt, nil
|
|
}
|
|
|
|
func (s *NodeService) IsNodePending(id int) bool {
|
|
enabled, status, dirty, _, err := s.NodeSyncState(id)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return !enabled || status != "online" || dirty
|
|
}
|
|
|
|
func nodeMetricKey(id int, metric string) string {
|
|
return "node:" + strconv.Itoa(id) + ":" + metric
|
|
}
|
|
|
|
func (s *NodeService) AggregateNodeMetric(id int, metric string, bucketSeconds int, maxPoints int) []map[string]any {
|
|
return nodeMetrics.aggregate(nodeMetricKey(id, metric), bucketSeconds, maxPoints)
|
|
}
|
|
|
|
func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch, error) {
|
|
patch := HeartbeatPatch{LastHeartbeat: time.Now().Unix()}
|
|
|
|
addr, err := netsafe.NormalizeHost(n.Address)
|
|
if err != nil {
|
|
patch.LastError = err.Error()
|
|
return patch, err
|
|
}
|
|
scheme := n.Scheme
|
|
if scheme != "http" && scheme != "https" {
|
|
scheme = "https"
|
|
}
|
|
if n.Port <= 0 || n.Port > 65535 {
|
|
patch.LastError = "node port must be 1-65535"
|
|
return patch, errors.New(patch.LastError)
|
|
}
|
|
probeURL := &url.URL{
|
|
Scheme: scheme,
|
|
Host: net.JoinHostPort(addr, strconv.Itoa(n.Port)),
|
|
Path: normalizeBasePath(n.BasePath) + "panel/api/server/status",
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(
|
|
netsafe.ContextWithAllowPrivate(ctx, n.AllowPrivateAddress),
|
|
http.MethodGet, probeURL.String(), nil)
|
|
if err != nil {
|
|
patch.LastError = err.Error()
|
|
return patch, err
|
|
}
|
|
if n.ApiToken != "" {
|
|
req.Header.Set("Authorization", "Bearer "+n.ApiToken)
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
client, err := nodeHTTPClientFor(n)
|
|
if err != nil {
|
|
patch.LastError = err.Error()
|
|
return patch, err
|
|
}
|
|
|
|
start := time.Now()
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
patch.LastError = err.Error()
|
|
return patch, err
|
|
}
|
|
defer resp.Body.Close()
|
|
patch.LatencyMs = int(time.Since(start) / time.Millisecond)
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
patch.LastError = fmt.Sprintf("HTTP %d from remote panel", resp.StatusCode)
|
|
return patch, errors.New(patch.LastError)
|
|
}
|
|
|
|
var envelope struct {
|
|
Success bool `json:"success"`
|
|
Msg string `json:"msg"`
|
|
Obj *struct {
|
|
CpuPct float64 `json:"cpu"`
|
|
Mem struct {
|
|
Current uint64 `json:"current"`
|
|
Total uint64 `json:"total"`
|
|
} `json:"mem"`
|
|
Xray struct {
|
|
Version string `json:"version"`
|
|
State string `json:"state"`
|
|
ErrorMsg string `json:"errorMsg"`
|
|
} `json:"xray"`
|
|
PanelVersion string `json:"panelVersion"`
|
|
PanelGuid string `json:"panelGuid"`
|
|
Uptime uint64 `json:"uptime"`
|
|
} `json:"obj"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
|
|
patch.LastError = "decode response: " + err.Error()
|
|
return patch, err
|
|
}
|
|
if !envelope.Success || envelope.Obj == nil {
|
|
patch.LastError = "remote returned success=false: " + envelope.Msg
|
|
return patch, errors.New(patch.LastError)
|
|
}
|
|
o := envelope.Obj
|
|
patch.CpuPct = o.CpuPct
|
|
if o.Mem.Total > 0 {
|
|
patch.MemPct = float64(o.Mem.Current) * 100.0 / float64(o.Mem.Total)
|
|
}
|
|
patch.XrayVersion = o.Xray.Version
|
|
patch.XrayState = o.Xray.State
|
|
patch.XrayError = o.Xray.ErrorMsg
|
|
patch.PanelVersion = o.PanelVersion
|
|
patch.Guid = o.PanelGuid
|
|
patch.UptimeSecs = o.Uptime
|
|
return patch, nil
|
|
}
|
|
|
|
type ProbeResultUI struct {
|
|
Status string `json:"status" example:"online"`
|
|
LatencyMs int `json:"latencyMs" example:"42"`
|
|
XrayVersion string `json:"xrayVersion" example:"25.10.31"`
|
|
PanelVersion string `json:"panelVersion" example:"v3.x.x"`
|
|
CpuPct float64 `json:"cpuPct" example:"12.5"`
|
|
MemPct float64 `json:"memPct" example:"45.2"`
|
|
UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
|
|
Error string `json:"error"`
|
|
// XrayState/XrayError are populated on successful probes even when the node's
|
|
// Xray core is not healthy. The UI uses them for a distinct "panel ok, xray failed" indicator.
|
|
XrayState string `json:"xrayState"`
|
|
XrayError string `json:"xrayError"`
|
|
}
|
|
|
|
func (p HeartbeatPatch) ToUI(ok bool) ProbeResultUI {
|
|
r := ProbeResultUI{
|
|
LatencyMs: p.LatencyMs,
|
|
XrayVersion: p.XrayVersion,
|
|
PanelVersion: p.PanelVersion,
|
|
CpuPct: p.CpuPct,
|
|
MemPct: p.MemPct,
|
|
UptimeSecs: p.UptimeSecs,
|
|
Error: FriendlyProbeError(p.LastError),
|
|
XrayState: p.XrayState,
|
|
XrayError: p.XrayError,
|
|
}
|
|
if ok {
|
|
r.Status = "online"
|
|
} else {
|
|
r.Status = "offline"
|
|
}
|
|
return r
|
|
}
|
|
|
|
func FriendlyProbeError(msg string) string {
|
|
if strings.Contains(msg, "server gave HTTP response to HTTPS client") {
|
|
return "the server speaks HTTP, not HTTPS; set the node scheme to http"
|
|
}
|
|
return msg
|
|
}
|