mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-29 06:27:14 +00:00
feat: apply inbound/outbound/routing changes live via Xray gRPC API
Add a hot-apply layer that computes a diff between the old and new generated config and applies only the changed parts through the Xray gRPC HandlerService and RoutingService, avoiding a full process restart whenever possible. A restart is still performed when sections that have no reload API (log, dns, policy, observatory, ...) actually change. Key additions: - internal/xray/hot_diff.go: ComputeHotDiff with canonical-JSON comparison (sorted keys, null=absent, full number precision) so UI reformatting never triggers a spurious restart - internal/xray/api.go: AddOutbound/DelOutbound, ApplyRoutingConfig, GetBalancerInfo, SetBalancerTarget, TestRoute gRPC wrappers - internal/web/service/xray.go: tryHotApply, ensureAPIServices, GetBalancersStatus, OverrideBalancer, TestRoute service methods - internal/web/controller/xray_setting.go: balancerStatus, balancerOverride, routeTest API endpoints - frontend: BalancersTab live-status/override columns, RouteTester component, Restart button removed (Save now hot-applies) - balancer-helpers.ts: syncObservatories never creates observatory sections for random/roundRobin balancers (no reload API → restart) - i18n: balancerLive/Override/routeTester keys added to all 13 locales
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
"services": [
|
||||
"HandlerService",
|
||||
"LoggerService",
|
||||
"StatsService"
|
||||
"StatsService",
|
||||
"RoutingService"
|
||||
],
|
||||
"tag": "api"
|
||||
},
|
||||
|
||||
@@ -115,6 +115,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
xrayConfig.LogConfig = resolveXrayLogPaths(xrayConfig.LogConfig)
|
||||
xrayConfig.API = ensureAPIServices(xrayConfig.API)
|
||||
|
||||
_, _, _ = s.inboundService.AddTraffic(nil, nil)
|
||||
|
||||
@@ -306,6 +307,47 @@ func mergeSubscriptionOutbounds(cfg *xray.Config, prepend, appendList []any) {
|
||||
cfg.OutboundConfigs = json_util.RawMessage(combined)
|
||||
}
|
||||
|
||||
// ensureAPIServices guarantees the gRPC services the panel depends on are
|
||||
// listed in the generated config's api block: HandlerService and StatsService
|
||||
// have always been required for inbound/user management and traffic polling,
|
||||
// and RoutingService enables hot routing reload on templates saved before it
|
||||
// was added to the default template. The stored template itself is not
|
||||
// modified — only the generated runtime config.
|
||||
func ensureAPIServices(api json_util.RawMessage) json_util.RawMessage {
|
||||
if len(api) == 0 {
|
||||
// No api block means the panel's API integration is deliberately
|
||||
// disabled; don't resurrect it behind the user's back.
|
||||
return api
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(api, &parsed); err != nil {
|
||||
return api
|
||||
}
|
||||
services, _ := parsed["services"].([]any)
|
||||
have := make(map[string]bool, len(services))
|
||||
for _, svc := range services {
|
||||
if name, ok := svc.(string); ok {
|
||||
have[name] = true
|
||||
}
|
||||
}
|
||||
added := false
|
||||
for _, name := range []string{"HandlerService", "StatsService", "RoutingService"} {
|
||||
if !have[name] {
|
||||
services = append(services, name)
|
||||
added = true
|
||||
}
|
||||
}
|
||||
if !added {
|
||||
return api
|
||||
}
|
||||
parsed["services"] = services
|
||||
out, err := json.Marshal(parsed)
|
||||
if err != nil {
|
||||
return api
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveXrayLogPaths rewrites relative `log.access` / `log.error` values to
|
||||
// absolute paths under config.GetLogFolder(), so Xray writes those files
|
||||
// alongside the panel's other logs regardless of the working directory the
|
||||
@@ -378,7 +420,81 @@ func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic,
|
||||
return traffic, clientTraffic, nil
|
||||
}
|
||||
|
||||
// RestartXray restarts the Xray process, optionally forcing a restart even if config unchanged.
|
||||
// BalancerStatus is the live view of one balancer for the panel UI. Running
|
||||
// is false when the balancer isn't present in the running core (e.g. xray is
|
||||
// stopped or the balancer hasn't been saved/applied yet).
|
||||
type BalancerStatus struct {
|
||||
Tag string `json:"tag"`
|
||||
Running bool `json:"running"`
|
||||
Override string `json:"override"`
|
||||
Selected []string `json:"selected"`
|
||||
}
|
||||
|
||||
// GetBalancersStatus queries the running core for the live state of the
|
||||
// given balancer tags. Per-tag failures are reported as Running=false rather
|
||||
// than failing the whole call, so the UI can render saved-but-not-applied
|
||||
// balancers alongside live ones.
|
||||
func (s *XrayService) GetBalancersStatus(tags []string) ([]BalancerStatus, error) {
|
||||
statuses := make([]BalancerStatus, 0, len(tags))
|
||||
if !s.IsXrayRunning() {
|
||||
for _, tag := range tags {
|
||||
statuses = append(statuses, BalancerStatus{Tag: tag})
|
||||
}
|
||||
return statuses, nil
|
||||
}
|
||||
if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer s.xrayAPI.Close()
|
||||
|
||||
for _, tag := range tags {
|
||||
info, err := s.xrayAPI.GetBalancerInfo(tag)
|
||||
if err != nil {
|
||||
logger.Debug("get balancer info [", tag, "] failed:", err)
|
||||
statuses = append(statuses, BalancerStatus{Tag: tag})
|
||||
continue
|
||||
}
|
||||
statuses = append(statuses, BalancerStatus{
|
||||
Tag: tag,
|
||||
Running: true,
|
||||
Override: info.Override,
|
||||
Selected: info.Selected,
|
||||
})
|
||||
}
|
||||
return statuses, nil
|
||||
}
|
||||
|
||||
// OverrideBalancer forces a balancer in the running core to use the given
|
||||
// outbound tag; an empty target clears the override.
|
||||
func (s *XrayService) OverrideBalancer(tag, target string) error {
|
||||
if !s.IsXrayRunning() {
|
||||
return errors.New("xray is not running")
|
||||
}
|
||||
if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.xrayAPI.Close()
|
||||
return s.xrayAPI.SetBalancerTarget(tag, target)
|
||||
}
|
||||
|
||||
// TestRoute asks the running core which outbound its router picks for the
|
||||
// described connection.
|
||||
func (s *XrayService) TestRoute(req xray.RouteTestRequest) (*xray.RouteTestResult, error) {
|
||||
if !s.IsXrayRunning() {
|
||||
return nil, errors.New("xray is not running")
|
||||
}
|
||||
if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer s.xrayAPI.Close()
|
||||
return s.xrayAPI.TestRoute(req)
|
||||
}
|
||||
|
||||
// RestartXray reconciles the running Xray process with the current desired
|
||||
// config. When isForce is false it first tries to apply the changes through
|
||||
// the Xray gRPC API without restarting the process (inbounds, outbounds and
|
||||
// routing rules/balancers are hot-reloadable); only changes the core cannot
|
||||
// take at runtime — or a force request — stop and restart the process.
|
||||
func (s *XrayService) RestartXray(isForce bool) error {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
@@ -391,10 +507,15 @@ func (s *XrayService) RestartXray(isForce bool) error {
|
||||
}
|
||||
|
||||
if s.IsXrayRunning() {
|
||||
if !isForce && p.GetConfig().Equals(xrayConfig) && !isNeedXrayRestart.Load() {
|
||||
configUnchanged := p.GetConfig().Equals(xrayConfig)
|
||||
if !isForce && configUnchanged && !isNeedXrayRestart.Load() {
|
||||
logger.Debug("It does not need to restart Xray")
|
||||
return nil
|
||||
}
|
||||
if !isForce && !configUnchanged && s.tryHotApply(xrayConfig) {
|
||||
logger.Info("Xray config changes applied through the core API, no restart needed")
|
||||
return nil
|
||||
}
|
||||
p.Stop()
|
||||
}
|
||||
|
||||
@@ -409,6 +530,112 @@ func (s *XrayService) RestartXray(isForce bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// tryHotApply attempts to reconcile the running Xray instance with newCfg
|
||||
// through the core gRPC API (HandlerService for inbounds/outbounds,
|
||||
// RoutingService for rules/balancers). It returns true when the running
|
||||
// instance now matches newCfg; on any failure it returns false and the
|
||||
// caller falls back to a full process restart, which cleans up whatever was
|
||||
// partially applied. Callers must hold the package-level lock.
|
||||
func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
|
||||
oldCfg := p.GetConfig()
|
||||
diff, ok := xray.ComputeHotDiff(oldCfg, newCfg)
|
||||
if !ok {
|
||||
logger.Debug("hot apply: config change is not API-applicable, falling back to restart")
|
||||
return false
|
||||
}
|
||||
if diff.Empty() {
|
||||
p.SetConfig(newCfg)
|
||||
return true
|
||||
}
|
||||
|
||||
apiPort := p.GetAPIPort()
|
||||
if apiPort <= 0 {
|
||||
return false
|
||||
}
|
||||
// A dedicated client: s.xrayAPI may be in use by traffic polling on other
|
||||
// service instances and is reset around restarts.
|
||||
hotAPI := xray.XrayAPI{}
|
||||
if err := hotAPI.Init(apiPort); err != nil {
|
||||
logger.Debug("hot apply: failed to init xray api:", err)
|
||||
return false
|
||||
}
|
||||
defer hotAPI.Close()
|
||||
|
||||
// Removals first so changed handlers and port swaps never collide with
|
||||
// the additions that follow.
|
||||
for _, tag := range diff.RemovedInboundTags {
|
||||
if err := hotAPI.DelInbound(tag); err != nil && !xray.IsMissingHandlerErr(err) {
|
||||
logger.Info("hot apply: remove inbound [", tag, "] failed:", err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, tag := range diff.RemovedOutboundTags {
|
||||
if err := hotAPI.DelOutbound(tag); err != nil && !xray.IsMissingHandlerErr(err) {
|
||||
logger.Info("hot apply: remove outbound [", tag, "] failed:", err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, ob := range diff.AddedOutbounds {
|
||||
if err := addOutboundReconciling(&hotAPI, ob); err != nil {
|
||||
logger.Info("hot apply: add outbound failed:", err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, ib := range diff.AddedInbounds {
|
||||
if err := addInboundReconciling(&hotAPI, ib); err != nil {
|
||||
logger.Info("hot apply: add inbound failed:", err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
if diff.RoutingConfig != nil {
|
||||
if err := hotAPI.ApplyRoutingConfig(diff.RoutingConfig); err != nil {
|
||||
logger.Info("hot apply: apply routing config failed:", err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
p.SetConfig(newCfg)
|
||||
return true
|
||||
}
|
||||
|
||||
// addInboundReconciling adds an inbound, and on a tag conflict (the handler
|
||||
// was already created through the runtime API while the stored snapshot was
|
||||
// stale) replaces the existing handler instead.
|
||||
func addInboundReconciling(api *xray.XrayAPI, inbound []byte) error {
|
||||
err := api.AddInbound(inbound)
|
||||
if err == nil || !xray.IsExistingTagErr(err) {
|
||||
return err
|
||||
}
|
||||
var meta struct {
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
if jsonErr := json.Unmarshal(inbound, &meta); jsonErr != nil || meta.Tag == "" {
|
||||
return err
|
||||
}
|
||||
if delErr := api.DelInbound(meta.Tag); delErr != nil && !xray.IsMissingHandlerErr(delErr) {
|
||||
return delErr
|
||||
}
|
||||
return api.AddInbound(inbound)
|
||||
}
|
||||
|
||||
// addOutboundReconciling mirrors addInboundReconciling for outbounds.
|
||||
func addOutboundReconciling(api *xray.XrayAPI, outbound []byte) error {
|
||||
err := api.AddOutbound(outbound)
|
||||
if err == nil || !xray.IsExistingTagErr(err) {
|
||||
return err
|
||||
}
|
||||
var meta struct {
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
if jsonErr := json.Unmarshal(outbound, &meta); jsonErr != nil || meta.Tag == "" {
|
||||
return err
|
||||
}
|
||||
if delErr := api.DelOutbound(meta.Tag); delErr != nil && !xray.IsMissingHandlerErr(delErr) {
|
||||
return delErr
|
||||
}
|
||||
return api.AddOutbound(outbound)
|
||||
}
|
||||
|
||||
// StopXray stops the running Xray process.
|
||||
func (s *XrayService) StopXray() error {
|
||||
lock.Lock()
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||
)
|
||||
|
||||
func TestEnsureAPIServices(t *testing.T) {
|
||||
// legacy template without RoutingService gets it injected
|
||||
out := ensureAPIServices(json_util.RawMessage(`{"services":["HandlerService","LoggerService","StatsService"],"tag":"api"}`))
|
||||
var parsed struct {
|
||||
Services []string `json:"services"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]bool{"HandlerService": true, "StatsService": true, "RoutingService": true, "LoggerService": true}
|
||||
if len(parsed.Services) != 4 {
|
||||
t.Fatalf("expected 4 services, got %v", parsed.Services)
|
||||
}
|
||||
for _, svc := range parsed.Services {
|
||||
if !want[svc] {
|
||||
t.Fatalf("unexpected service %q", svc)
|
||||
}
|
||||
}
|
||||
if parsed.Tag != "api" {
|
||||
t.Fatalf("tag must be preserved, got %q", parsed.Tag)
|
||||
}
|
||||
|
||||
// complete api block is returned unchanged (no marshal churn)
|
||||
full := json_util.RawMessage(`{"services":["HandlerService","StatsService","RoutingService"],"tag":"api"}`)
|
||||
if got := ensureAPIServices(full); string(got) != string(full) {
|
||||
t.Fatalf("complete api block must pass through untouched, got %s", got)
|
||||
}
|
||||
|
||||
// absent api block stays absent
|
||||
if got := ensureAPIServices(nil); got != nil {
|
||||
t.Fatalf("nil api block must stay nil, got %s", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user