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:
MHSanaei
2026-06-10 23:01:33 +02:00
parent 3092326d9e
commit 6b16d8c37a
32 changed files with 2209 additions and 109 deletions
+2 -1
View File
@@ -3,7 +3,8 @@
"services": [
"HandlerService",
"LoggerService",
"StatsService"
"StatsService",
"RoutingService"
],
"tag": "api"
},
+229 -2
View File
@@ -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)
}
}