Files
mmw-agent/internal/handler/manage.go

2399 lines
62 KiB
Go
Raw Normal View History

2026-01-28 13:13:58 +08:00
package handler
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"mmw-agent/internal/xrpc"
"github.com/xtls/xray-core/app/proxyman/command"
"github.com/xtls/xray-core/infra/conf"
)
// ManageHandler handles management API requests for child servers
type ManageHandler struct {
configToken string
}
// NewManageHandler creates a new management handler
func NewManageHandler(configToken string) *ManageHandler {
return &ManageHandler{
configToken: configToken,
}
}
// authenticate checks if the request is authorized
func (h *ManageHandler) authenticate(r *http.Request) bool {
if h.configToken == "" {
return true
}
auth := r.Header.Get("Authorization")
if auth == "" {
auth = r.Header.Get("MM-Remote-Token")
}
if auth == "" {
return false
}
if strings.HasPrefix(auth, "Bearer ") {
token := strings.TrimPrefix(auth, "Bearer ")
return token == h.configToken
}
return auth == h.configToken
}
// writeJSON writes JSON response
func writeJSON(w http.ResponseWriter, statusCode int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(data)
}
// writeError writes error response
func writeError(w http.ResponseWriter, statusCode int, message string) {
writeJSON(w, statusCode, map[string]interface{}{
"success": false,
"error": message,
})
}
// ================== System Services Status ==================
// ServicesStatusResponse represents the response for services status
type ServicesStatusResponse struct {
Success bool `json:"success"`
Xray *ServiceStatus `json:"xray,omitempty"`
Nginx *ServiceStatus `json:"nginx,omitempty"`
}
// ServiceStatus represents a service status
type ServiceStatus struct {
Installed bool `json:"installed"`
Running bool `json:"running"`
Version string `json:"version,omitempty"`
}
// HandleServicesStatus handles GET /api/child/services/status
func (h *ManageHandler) HandleServicesStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !h.authenticate(r) {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
response := ServicesStatusResponse{
Success: true,
Xray: h.getXrayStatus(),
Nginx: h.getNginxStatus(),
}
writeJSON(w, http.StatusOK, response)
}
func (h *ManageHandler) getXrayStatus() *ServiceStatus {
status := &ServiceStatus{}
xrayPath, err := exec.LookPath("xray")
if err != nil {
commonPaths := []string{"/usr/local/bin/xray", "/usr/bin/xray", "/opt/xray/xray"}
for _, p := range commonPaths {
if _, err := os.Stat(p); err == nil {
xrayPath = p
break
}
}
}
if xrayPath != "" {
status.Installed = true
cmd := exec.Command(xrayPath, "version")
output, err := cmd.Output()
if err == nil {
lines := strings.Split(string(output), "\n")
if len(lines) > 0 {
status.Version = strings.TrimSpace(lines[0])
}
}
}
// Check systemctl first
cmd := exec.Command("systemctl", "is-active", "xray")
output, _ := cmd.Output()
if strings.TrimSpace(string(output)) == "active" {
status.Running = true
return status
}
// Fallback: check if xray process is running via pgrep
pgrepCmd := exec.Command("pgrep", "-x", "xray")
if err := pgrepCmd.Run(); err == nil {
status.Running = true
return status
}
// Fallback: check via ps for processes containing "xray"
psCmd := exec.Command("bash", "-c", "ps aux | grep -v grep | grep -E '[x]ray' | head -1")
if output, err := psCmd.Output(); err == nil && len(strings.TrimSpace(string(output))) > 0 {
status.Running = true
}
return status
}
func (h *ManageHandler) getNginxStatus() *ServiceStatus {
status := &ServiceStatus{}
nginxPath, err := exec.LookPath("nginx")
if err == nil {
status.Installed = true
cmd := exec.Command(nginxPath, "-v")
output, err := cmd.CombinedOutput()
if err == nil {
status.Version = strings.TrimSpace(string(output))
}
}
// Check systemctl first
cmd := exec.Command("systemctl", "is-active", "nginx")
output, _ := cmd.Output()
if strings.TrimSpace(string(output)) == "active" {
status.Running = true
return status
}
// Fallback: check if nginx process is running via pgrep
pgrepCmd := exec.Command("pgrep", "-x", "nginx")
if err := pgrepCmd.Run(); err == nil {
status.Running = true
return status
}
// Fallback: check via ps for nginx master process
psCmd := exec.Command("bash", "-c", "ps aux | grep -v grep | grep -E 'nginx: master' | head -1")
if output, err := psCmd.Output(); err == nil && len(strings.TrimSpace(string(output))) > 0 {
status.Running = true
}
return status
}
// ================== Service Control ==================
// ServiceControlRequest represents a service control request
type ServiceControlRequest struct {
Service string `json:"service"`
Action string `json:"action"`
}
// HandleServiceControl handles POST /api/child/services/control
func (h *ManageHandler) HandleServiceControl(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !h.authenticate(r) {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
var req ServiceControlRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid request body")
return
}
if req.Service != "xray" && req.Service != "nginx" {
writeError(w, http.StatusBadRequest, "Invalid service. Must be 'xray' or 'nginx'")
return
}
if req.Action != "start" && req.Action != "stop" && req.Action != "restart" {
writeError(w, http.StatusBadRequest, "Invalid action. Must be 'start', 'stop', or 'restart'")
return
}
cmd := exec.Command("systemctl", req.Action, req.Service)
output, err := cmd.CombinedOutput()
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to %s %s: %v - %s", req.Action, req.Service, err, string(output)))
return
}
log.Printf("[Manage] Service %s: %s", req.Service, req.Action)
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": fmt.Sprintf("Service %s %sed successfully", req.Service, req.Action),
})
}
// ================== Xray Installation ==================
// HandleXrayInstall handles POST /api/child/xray/install
func (h *ManageHandler) HandleXrayInstall(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !h.authenticate(r) {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
log.Printf("[Manage] Installing Xray...")
cmd := exec.Command("bash", "-c", "bash -c \"$(curl -L https://github.com/XTLS/Xray-install/raw/main/install-release.sh)\" @ install")
cmd.Env = os.Environ()
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
log.Printf("[Manage] Xray installation failed: %v, stderr: %s", err, stderr.String())
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Installation failed: %v", err))
return
}
log.Printf("[Manage] Xray installed successfully")
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "Xray installed successfully",
"output": stdout.String(),
})
}
// HandleXrayRemove handles POST /api/child/xray/remove
func (h *ManageHandler) HandleXrayRemove(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !h.authenticate(r) {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
log.Printf("[Manage] Removing Xray...")
cmd := exec.Command("bash", "-c", "bash -c \"$(curl -L https://github.com/XTLS/Xray-install/raw/main/install-release.sh)\" @ remove")
cmd.Env = os.Environ()
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
log.Printf("[Manage] Xray removal failed: %v, stderr: %s", err, stderr.String())
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Removal failed: %v", err))
return
}
log.Printf("[Manage] Xray removed successfully")
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "Xray removed successfully",
"output": stdout.String(),
})
}
// ================== Xray Configuration ==================
// HandleXrayConfig handles GET/POST /api/child/xray/config
func (h *ManageHandler) HandleXrayConfig(w http.ResponseWriter, r *http.Request) {
if !h.authenticate(r) {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
switch r.Method {
case http.MethodGet:
h.getXrayConfig(w, r)
case http.MethodPost:
h.setXrayConfig(w, r)
default:
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
}
func (h *ManageHandler) getXrayConfig(w http.ResponseWriter, r *http.Request) {
configPaths := []string{
"/usr/local/etc/xray/config.json",
"/etc/xray/config.json",
"/opt/xray/config.json",
}
var configPath string
var content []byte
var err error
for _, p := range configPaths {
content, err = os.ReadFile(p)
if err == nil {
configPath = p
break
}
}
if configPath == "" {
writeError(w, http.StatusNotFound, "Xray config not found")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"path": configPath,
"config": string(content),
})
}
func (h *ManageHandler) setXrayConfig(w http.ResponseWriter, r *http.Request) {
var req struct {
Config string `json:"config"`
Path string `json:"path,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid request body")
return
}
var js json.RawMessage
if err := json.Unmarshal([]byte(req.Config), &js); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON config")
return
}
configPath := req.Path
if configPath == "" {
configPath = "/usr/local/etc/xray/config.json"
}
dir := filepath.Dir(configPath)
if err := os.MkdirAll(dir, 0755); err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create directory: %v", err))
return
}
if err := os.WriteFile(configPath, []byte(req.Config), 0644); err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to write config: %v", err))
return
}
log.Printf("[Manage] Xray config saved to %s", configPath)
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "Config saved successfully",
"path": configPath,
})
}
// ================== Xray System Configuration ==================
// XraySystemConfig represents the system configuration state
type XraySystemConfig struct {
MetricsEnabled bool `json:"metrics_enabled"`
MetricsListen string `json:"metrics_listen"`
StatsEnabled bool `json:"stats_enabled"`
GrpcEnabled bool `json:"grpc_enabled"`
GrpcPort int `json:"grpc_port"`
}
// HandleXraySystemConfig handles GET/POST /api/child/xray/system-config
func (h *ManageHandler) HandleXraySystemConfig(w http.ResponseWriter, r *http.Request) {
if !h.authenticate(r) {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
switch r.Method {
case http.MethodGet:
h.getXraySystemConfig(w, r)
case http.MethodPost:
h.updateXraySystemConfig(w, r)
default:
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
}
func (h *ManageHandler) getXraySystemConfig(w http.ResponseWriter, r *http.Request) {
configPath := h.findXrayConfigPath()
if configPath == "" {
writeError(w, http.StatusNotFound, "Xray config not found")
return
}
content, err := os.ReadFile(configPath)
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read config: %v", err))
return
}
var config map[string]interface{}
if err := json.Unmarshal(content, &config); err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Invalid JSON: %v", err))
return
}
sysConfig := &XraySystemConfig{
MetricsListen: "127.0.0.1:38889",
GrpcPort: 46736,
}
if metrics, ok := config["metrics"].(map[string]interface{}); ok {
sysConfig.MetricsEnabled = true
if listen, ok := metrics["listen"].(string); ok {
sysConfig.MetricsListen = listen
}
}
if _, ok := config["stats"]; ok {
sysConfig.StatsEnabled = true
}
if api, ok := config["api"].(map[string]interface{}); ok {
if _, hasTag := api["tag"]; hasTag {
sysConfig.GrpcEnabled = true
}
}
if inbounds, ok := config["inbounds"].([]interface{}); ok {
for _, ib := range inbounds {
if inbound, ok := ib.(map[string]interface{}); ok {
if tag, _ := inbound["tag"].(string); tag == "api" {
if port, ok := inbound["port"].(float64); ok {
sysConfig.GrpcPort = int(port)
}
break
}
}
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"config": sysConfig,
})
}
func (h *ManageHandler) updateXraySystemConfig(w http.ResponseWriter, r *http.Request) {
var req XraySystemConfig
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid request body")
return
}
configPath := h.findXrayConfigPath()
if configPath == "" {
writeError(w, http.StatusNotFound, "Xray config not found")
return
}
content, err := os.ReadFile(configPath)
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read config: %v", err))
return
}
var config map[string]interface{}
if err := json.Unmarshal(content, &config); err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Invalid JSON: %v", err))
return
}
if req.MetricsEnabled {
config["metrics"] = map[string]interface{}{
"tag": "Metrics",
"listen": req.MetricsListen,
}
} else {
delete(config, "metrics")
}
if req.StatsEnabled {
config["stats"] = map[string]interface{}{}
config["policy"] = map[string]interface{}{
"levels": map[string]interface{}{
"0": map[string]interface{}{
"handshake": float64(5),
"connIdle": float64(300),
"uplinkOnly": float64(2),
"downlinkOnly": float64(2),
"statsUserUplink": true,
"statsUserDownlink": true,
},
},
"system": map[string]interface{}{
"statsInboundUplink": true,
"statsInboundDownlink": true,
"statsOutboundUplink": true,
"statsOutboundDownlink": true,
},
}
} else {
delete(config, "stats")
delete(config, "policy")
}
if req.GrpcEnabled {
config["api"] = map[string]interface{}{
"tag": "api",
"services": []interface{}{"HandlerService", "LoggerService", "StatsService", "RoutingService"},
}
hasAPIInbound := false
if inbounds, ok := config["inbounds"].([]interface{}); ok {
for i, ib := range inbounds {
if inbound, ok := ib.(map[string]interface{}); ok {
if tag, _ := inbound["tag"].(string); tag == "api" {
inbound["port"] = float64(req.GrpcPort)
inbounds[i] = inbound
hasAPIInbound = true
break
}
}
}
if !hasAPIInbound {
apiInbound := map[string]interface{}{
"tag": "api",
"port": float64(req.GrpcPort),
"listen": "127.0.0.1",
"protocol": "dokodemo-door",
"settings": map[string]interface{}{
"address": "127.0.0.1",
},
}
config["inbounds"] = append([]interface{}{apiInbound}, inbounds...)
}
} else {
config["inbounds"] = []interface{}{
map[string]interface{}{
"tag": "api",
"port": float64(req.GrpcPort),
"listen": "127.0.0.1",
"protocol": "dokodemo-door",
"settings": map[string]interface{}{
"address": "127.0.0.1",
},
},
}
}
h.ensureAPIRoutingRule(config)
} else {
delete(config, "api")
if inbounds, ok := config["inbounds"].([]interface{}); ok {
newInbounds := make([]interface{}, 0)
for _, ib := range inbounds {
if inbound, ok := ib.(map[string]interface{}); ok {
if tag, _ := inbound["tag"].(string); tag != "api" {
newInbounds = append(newInbounds, inbound)
}
}
}
config["inbounds"] = newInbounds
}
h.removeAPIRoutingRule(config)
}
backupPath := configPath + ".backup"
if err := os.WriteFile(backupPath, content, 0644); err != nil {
log.Printf("[Manage] Warning: failed to backup config: %v", err)
}
newContent, _ := json.MarshalIndent(config, "", " ")
if err := os.WriteFile(configPath, newContent, 0644); err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to write config: %v", err))
return
}
cmd := exec.Command("systemctl", "restart", "xray")
if err := cmd.Run(); err != nil {
log.Printf("[Manage] Warning: failed to restart xray: %v", err)
}
log.Printf("[Manage] Xray system config updated: metrics=%v, stats=%v, grpc=%v",
req.MetricsEnabled, req.StatsEnabled, req.GrpcEnabled)
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "System config updated, Xray restarted",
})
}
func (h *ManageHandler) ensureAPIRoutingRule(config map[string]interface{}) {
routing, ok := config["routing"].(map[string]interface{})
if !ok {
routing = map[string]interface{}{
"domainStrategy": "IPIfNonMatch",
"rules": []interface{}{},
}
config["routing"] = routing
}
rules, ok := routing["rules"].([]interface{})
if !ok {
rules = []interface{}{}
}
for _, r := range rules {
if rule, ok := r.(map[string]interface{}); ok {
if outboundTag, _ := rule["outboundTag"].(string); outboundTag == "api" {
return
}
}
}
apiRule := map[string]interface{}{
"type": "field",
"inboundTag": []interface{}{"api"},
"outboundTag": "api",
}
routing["rules"] = append([]interface{}{apiRule}, rules...)
}
func (h *ManageHandler) removeAPIRoutingRule(config map[string]interface{}) {
routing, ok := config["routing"].(map[string]interface{})
if !ok {
return
}
rules, ok := routing["rules"].([]interface{})
if !ok {
return
}
newRules := make([]interface{}, 0)
for _, r := range rules {
if rule, ok := r.(map[string]interface{}); ok {
if outboundTag, _ := rule["outboundTag"].(string); outboundTag != "api" {
newRules = append(newRules, rule)
}
}
}
routing["rules"] = newRules
}
// ================== Nginx Installation ==================
// HandleNginxInstall handles POST /api/child/nginx/install
func (h *ManageHandler) HandleNginxInstall(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !h.authenticate(r) {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
log.Printf("[Manage] Installing Nginx...")
var cmd *exec.Cmd
if _, err := exec.LookPath("apt-get"); err == nil {
cmd = exec.Command("bash", "-c", "apt-get update && apt-get install -y nginx")
} else if _, err := exec.LookPath("yum"); err == nil {
cmd = exec.Command("bash", "-c", "yum install -y nginx")
} else if _, err := exec.LookPath("dnf"); err == nil {
cmd = exec.Command("bash", "-c", "dnf install -y nginx")
} else {
writeError(w, http.StatusInternalServerError, "No supported package manager found")
return
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
log.Printf("[Manage] Nginx installation failed: %v, stderr: %s", err, stderr.String())
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Installation failed: %v", err))
return
}
log.Printf("[Manage] Nginx installed successfully")
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "Nginx installed successfully",
"output": stdout.String(),
})
}
// HandleNginxRemove handles POST /api/child/nginx/remove
func (h *ManageHandler) HandleNginxRemove(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !h.authenticate(r) {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
log.Printf("[Manage] Removing Nginx...")
exec.Command("systemctl", "stop", "nginx").Run()
var cmd *exec.Cmd
if _, err := exec.LookPath("apt-get"); err == nil {
cmd = exec.Command("bash", "-c", "apt-get remove -y nginx nginx-common")
} else if _, err := exec.LookPath("yum"); err == nil {
cmd = exec.Command("bash", "-c", "yum remove -y nginx")
} else if _, err := exec.LookPath("dnf"); err == nil {
cmd = exec.Command("bash", "-c", "dnf remove -y nginx")
} else {
writeError(w, http.StatusInternalServerError, "No supported package manager found")
return
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
log.Printf("[Manage] Nginx removal failed: %v, stderr: %s", err, stderr.String())
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Removal failed: %v", err))
return
}
log.Printf("[Manage] Nginx removed successfully")
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "Nginx removed successfully",
"output": stdout.String(),
})
}
// ================== Nginx Configuration ==================
// HandleNginxConfig handles GET/POST /api/child/nginx/config
func (h *ManageHandler) HandleNginxConfig(w http.ResponseWriter, r *http.Request) {
if !h.authenticate(r) {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
switch r.Method {
case http.MethodGet:
h.getNginxConfig(w, r)
case http.MethodPost:
h.setNginxConfig(w, r)
default:
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
}
func (h *ManageHandler) getNginxConfig(w http.ResponseWriter, r *http.Request) {
configPaths := []string{
"/etc/nginx/nginx.conf",
"/usr/local/nginx/conf/nginx.conf",
}
var configPath string
var content []byte
var err error
for _, p := range configPaths {
content, err = os.ReadFile(p)
if err == nil {
configPath = p
break
}
}
if configPath == "" {
writeError(w, http.StatusNotFound, "Nginx config not found")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"path": configPath,
"config": string(content),
})
}
func (h *ManageHandler) setNginxConfig(w http.ResponseWriter, r *http.Request) {
var req struct {
Config string `json:"config"`
Path string `json:"path,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid request body")
return
}
configPath := req.Path
if configPath == "" {
configPath = "/etc/nginx/nginx.conf"
}
backupPath := configPath + ".bak." + time.Now().Format("20060102150405")
if content, err := os.ReadFile(configPath); err == nil {
os.WriteFile(backupPath, content, 0644)
}
if err := os.WriteFile(configPath, []byte(req.Config), 0644); err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to write config: %v", err))
return
}
cmd := exec.Command("nginx", "-t")
output, err := cmd.CombinedOutput()
if err != nil {
if backup, err := os.ReadFile(backupPath); err == nil {
os.WriteFile(configPath, backup, 0644)
}
writeError(w, http.StatusBadRequest, fmt.Sprintf("Invalid nginx config: %s", string(output)))
return
}
log.Printf("[Manage] Nginx config saved to %s", configPath)
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "Config saved successfully",
"path": configPath,
})
}
// ================== System Info ==================
// HandleSystemInfo handles GET /api/child/system/info
func (h *ManageHandler) HandleSystemInfo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !h.authenticate(r) {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
info := map[string]interface{}{
"success": true,
}
if hostname, err := os.Hostname(); err == nil {
info["hostname"] = hostname
}
if data, err := os.ReadFile("/proc/uptime"); err == nil {
parts := strings.Fields(string(data))
if len(parts) > 0 {
info["uptime"] = parts[0]
}
}
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
memInfo := make(map[string]string)
lines := strings.Split(string(data), "\n")
for _, line := range lines {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
if key == "MemTotal" || key == "MemFree" || key == "MemAvailable" {
memInfo[key] = value
}
}
}
info["memory"] = memInfo
}
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
info["loadavg"] = strings.TrimSpace(string(data))
}
writeJSON(w, http.StatusOK, info)
}
// ================== Config Files Management ==================
// ConfigFileInfo represents a config file entry
type ConfigFileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
ModTime string `json:"mod_time"`
}
// HandleXrayConfigFiles handles listing and managing xray config files
func (h *ManageHandler) HandleXrayConfigFiles(w http.ResponseWriter, r *http.Request) {
if !h.authenticate(r) {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
switch r.Method {
case http.MethodGet:
file := r.URL.Query().Get("file")
if file != "" {
h.getXrayConfigFile(w, r, file)
} else {
h.listXrayConfigFiles(w, r)
}
case http.MethodPut, http.MethodPost:
h.saveXrayConfigFile(w, r)
default:
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
}
func (h *ManageHandler) listXrayConfigFiles(w http.ResponseWriter, r *http.Request) {
configDirs := []string{
"/usr/local/etc/xray",
"/etc/xray",
"/opt/xray",
}
var files []ConfigFileInfo
var baseDir string
for _, dir := range configDirs {
if _, err := os.Stat(dir); err == nil {
baseDir = dir
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
if !strings.HasSuffix(entry.Name(), ".json") {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
files = append(files, ConfigFileInfo{
Name: entry.Name(),
Path: filepath.Join(dir, entry.Name()),
Size: info.Size(),
ModTime: info.ModTime().Format(time.RFC3339),
})
}
break
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"base_dir": baseDir,
"files": files,
})
}
func (h *ManageHandler) getXrayConfigFile(w http.ResponseWriter, r *http.Request, file string) {
file = filepath.Clean(file)
configDirs := []string{
"/usr/local/etc/xray",
"/etc/xray",
"/opt/xray",
}
var filePath string
for _, dir := range configDirs {
candidate := filepath.Join(dir, file)
if !strings.HasPrefix(candidate, dir) {
continue
}
if _, err := os.Stat(candidate); err == nil {
filePath = candidate
break
}
}
if filePath == "" {
writeError(w, http.StatusNotFound, "File not found")
return
}
content, err := os.ReadFile(filePath)
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read file: %v", err))
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"path": filePath,
"content": string(content),
})
}
func (h *ManageHandler) saveXrayConfigFile(w http.ResponseWriter, r *http.Request) {
var req struct {
File string `json:"file"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid request body")
return
}
if req.File == "" {
writeError(w, http.StatusBadRequest, "File name required")
return
}
req.File = filepath.Base(req.File)
if !strings.HasSuffix(req.File, ".json") {
req.File += ".json"
}
configDirs := []string{
"/usr/local/etc/xray",
"/etc/xray",
"/opt/xray",
}
var configDir string
for _, dir := range configDirs {
if _, err := os.Stat(dir); err == nil {
configDir = dir
break
}
}
if configDir == "" {
configDir = "/usr/local/etc/xray"
if err := os.MkdirAll(configDir, 0755); err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create config directory: %v", err))
return
}
}
filePath := filepath.Join(configDir, req.File)
if strings.HasSuffix(req.File, ".json") {
var js json.RawMessage
if err := json.Unmarshal([]byte(req.Content), &js); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON content")
return
}
}
if err := os.WriteFile(filePath, []byte(req.Content), 0644); err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to write file: %v", err))
return
}
log.Printf("[Manage] Xray config file saved: %s", filePath)
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "File saved successfully",
"path": filePath,
})
}
// HandleNginxConfigFiles handles listing and managing nginx config files
func (h *ManageHandler) HandleNginxConfigFiles(w http.ResponseWriter, r *http.Request) {
if !h.authenticate(r) {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
switch r.Method {
case http.MethodGet:
file := r.URL.Query().Get("file")
if file != "" {
h.getNginxConfigFile(w, r, file)
} else {
h.listNginxConfigFiles(w, r)
}
case http.MethodPut, http.MethodPost:
h.saveNginxConfigFile(w, r)
default:
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
}
func (h *ManageHandler) listNginxConfigFiles(w http.ResponseWriter, r *http.Request) {
configDirs := []struct {
dir string
description string
}{
{"/etc/nginx", "main"},
{"/etc/nginx/sites-available", "sites-available"},
{"/etc/nginx/sites-enabled", "sites-enabled"},
{"/etc/nginx/conf.d", "conf.d"},
}
result := make(map[string][]ConfigFileInfo)
for _, cd := range configDirs {
if _, err := os.Stat(cd.dir); err != nil {
continue
}
entries, err := os.ReadDir(cd.dir)
if err != nil {
continue
}
var files []ConfigFileInfo
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
files = append(files, ConfigFileInfo{
Name: entry.Name(),
Path: filepath.Join(cd.dir, entry.Name()),
Size: info.Size(),
ModTime: info.ModTime().Format(time.RFC3339),
})
}
result[cd.description] = files
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"files": result,
})
}
func (h *ManageHandler) getNginxConfigFile(w http.ResponseWriter, r *http.Request, file string) {
file = filepath.Clean(file)
allowedDirs := []string{
"/etc/nginx",
"/etc/nginx/sites-available",
"/etc/nginx/sites-enabled",
"/etc/nginx/conf.d",
"/usr/local/nginx/conf",
}
var filePath string
if filepath.IsAbs(file) {
for _, dir := range allowedDirs {
if strings.HasPrefix(file, dir) {
if _, err := os.Stat(file); err == nil {
filePath = file
break
}
}
}
} else {
for _, dir := range allowedDirs {
candidate := filepath.Join(dir, file)
if _, err := os.Stat(candidate); err == nil {
filePath = candidate
break
}
}
}
if filePath == "" {
writeError(w, http.StatusNotFound, "File not found")
return
}
content, err := os.ReadFile(filePath)
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read file: %v", err))
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"path": filePath,
"content": string(content),
})
}
func (h *ManageHandler) saveNginxConfigFile(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid request body")
return
}
if req.Path == "" {
writeError(w, http.StatusBadRequest, "File path required")
return
}
req.Path = filepath.Clean(req.Path)
allowedDirs := []string{
"/etc/nginx",
"/usr/local/nginx/conf",
}
allowed := false
for _, dir := range allowedDirs {
if strings.HasPrefix(req.Path, dir) {
allowed = true
break
}
}
if !allowed {
writeError(w, http.StatusForbidden, "Path not allowed")
return
}
if _, err := os.Stat(req.Path); err == nil {
backupPath := req.Path + ".bak." + time.Now().Format("20060102150405")
if content, err := os.ReadFile(req.Path); err == nil {
os.WriteFile(backupPath, content, 0644)
}
}
dir := filepath.Dir(req.Path)
if err := os.MkdirAll(dir, 0755); err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create directory: %v", err))
return
}
if err := os.WriteFile(req.Path, []byte(req.Content), 0644); err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to write file: %v", err))
return
}
cmd := exec.Command("nginx", "-t")
output, err := cmd.CombinedOutput()
if err != nil {
backupPath := req.Path + ".bak." + time.Now().Format("20060102150405")[:14]
if backup, err := os.ReadFile(backupPath); err == nil {
os.WriteFile(req.Path, backup, 0644)
}
writeError(w, http.StatusBadRequest, fmt.Sprintf("Invalid nginx config: %s", string(output)))
return
}
log.Printf("[Manage] Nginx config file saved: %s", req.Path)
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "File saved successfully",
"path": req.Path,
})
}
// ================== Xray Inbounds Management ==================
// InboundRequest represents inbound management request
type InboundRequest struct {
Action string `json:"action"`
Inbound map[string]interface{} `json:"inbound,omitempty"`
Tag string `json:"tag,omitempty"`
}
// HandleInbounds handles inbound management
func (h *ManageHandler) HandleInbounds(w http.ResponseWriter, r *http.Request) {
if !h.authenticate(r) {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
switch r.Method {
case http.MethodGet:
h.listInbounds(w, r)
case http.MethodPost:
h.manageInbound(w, r)
default:
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
}
func (h *ManageHandler) listInbounds(w http.ResponseWriter, r *http.Request) {
configInbounds := h.getInboundsFromConfig()
runtimeTags := h.getInboundTagsFromGRPC()
mergedInbounds := h.mergeInbounds(configInbounds, runtimeTags)
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"inbounds": mergedInbounds,
})
}
func (h *ManageHandler) getInboundsFromConfig() []map[string]interface{} {
configPath := h.findXrayConfigPath()
if configPath == "" {
return nil
}
content, err := os.ReadFile(configPath)
if err != nil {
log.Printf("[Manage] Failed to read config file: %v", err)
return nil
}
var config map[string]interface{}
if err := json.Unmarshal(content, &config); err != nil {
log.Printf("[Manage] Failed to parse config: %v", err)
return nil
}
rawInbounds, _ := config["inbounds"].([]interface{})
inbounds := make([]map[string]interface{}, 0, len(rawInbounds))
for _, ib := range rawInbounds {
if ibMap, ok := ib.(map[string]interface{}); ok {
inbounds = append(inbounds, ibMap)
}
}
return inbounds
}
func (h *ManageHandler) getInboundTagsFromGRPC() []string {
apiPort := h.findXrayAPIPort()
if apiPort == 0 {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
clients, err := xrpc.New(ctx, "127.0.0.1", uint16(apiPort))
if err != nil {
log.Printf("[Manage] Failed to connect to Xray gRPC: %v", err)
return nil
}
defer clients.Connection.Close()
resp, err := clients.Handler.ListInbounds(ctx, &command.ListInboundsRequest{IsOnlyTags: true})
if err != nil {
log.Printf("[Manage] Failed to list inbounds via gRPC: %v", err)
return nil
}
tags := make([]string, 0, len(resp.Inbounds))
for _, ib := range resp.Inbounds {
2026-01-29 18:01:08 +08:00
// 过滤掉 tag="api" 和空 tagXray 内部入站)
if ib.Tag != "" && ib.Tag != "api" {
tags = append(tags, ib.Tag)
}
2026-01-28 13:13:58 +08:00
}
return tags
}
func (h *ManageHandler) mergeInbounds(configInbounds []map[string]interface{}, runtimeTags []string) []map[string]interface{} {
runtimeTagSet := make(map[string]bool)
for _, tag := range runtimeTags {
runtimeTagSet[tag] = true
}
configTagSet := make(map[string]bool)
for _, ib := range configInbounds {
if tag, ok := ib["tag"].(string); ok {
configTagSet[tag] = true
}
}
result := make([]map[string]interface{}, 0, len(configInbounds)+len(runtimeTags))
for _, ib := range configInbounds {
tag, _ := ib["tag"].(string)
2026-01-29 18:01:08 +08:00
// 跳过 tag="api" 的入站Xray 内部 API 入站)
if tag == "api" {
continue
}
2026-01-28 13:13:58 +08:00
ibCopy := make(map[string]interface{})
for k, v := range ib {
ibCopy[k] = v
}
2026-01-29 18:01:08 +08:00
// 如果 tag 为空,根据协议和端口生成名称
if tag == "" {
protocol, _ := ib["protocol"].(string)
port := 0
if p, ok := ib["port"].(float64); ok {
port = int(p)
} else if p, ok := ib["port"].(int); ok {
port = p
}
if protocol != "" && port > 0 {
ibCopy["tag"] = fmt.Sprintf("%s-%d", protocol, port)
ibCopy["_generated_tag"] = true
}
}
2026-01-28 13:13:58 +08:00
if runtimeTagSet[tag] {
ibCopy["_runtime_status"] = "running"
} else {
ibCopy["_runtime_status"] = "not_running"
}
ibCopy["_source"] = "config"
result = append(result, ibCopy)
}
for _, tag := range runtimeTags {
if !configTagSet[tag] {
result = append(result, map[string]interface{}{
"tag": tag,
"_runtime_status": "running",
"_source": "runtime_only",
"_warning": "This inbound is not persisted and will be lost on restart",
})
}
}
return result
}
func (h *ManageHandler) manageInbound(w http.ResponseWriter, r *http.Request) {
var req InboundRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid request body")
return
}
action := strings.ToLower(strings.TrimSpace(req.Action))
if action == "" {
action = "add"
}
apiPort := h.findXrayAPIPort()
if apiPort == 0 {
writeError(w, http.StatusInternalServerError, "Xray API not available")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
clients, err := xrpc.New(ctx, "127.0.0.1", uint16(apiPort))
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to connect to Xray: %v", err))
return
}
defer clients.Connection.Close()
switch action {
case "add":
if req.Inbound == nil {
writeError(w, http.StatusBadRequest, "Inbound payload is required")
return
}
if err := h.addInbound(ctx, clients.Handler, req.Inbound); err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add inbound: %v", err))
return
}
if err := h.persistInbound(req.Inbound); err != nil {
log.Printf("[Manage] Error: Failed to persist inbound to config: %v", err)
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "Inbound added to runtime, but failed to persist to config: " + err.Error(),
"warning": "persist_failed",
})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "Inbound added successfully",
})
case "remove":
if req.Tag == "" {
writeError(w, http.StatusBadRequest, "Tag is required for remove action")
return
}
2026-02-06 19:55:00 +08:00
// Try to remove from runtime (ignore error if not running)
runtimeErr := h.removeInbound(ctx, clients.Handler, req.Tag)
if runtimeErr != nil {
log.Printf("[Manage] Warning: Failed to remove inbound from runtime: %v", runtimeErr)
}
// Remove from config file (this is the primary operation)
configErr := h.removeInboundFromConfig(req.Tag)
if configErr != nil {
log.Printf("[Manage] Warning: Failed to remove inbound from config: %v", configErr)
2026-01-28 13:13:58 +08:00
}
2026-02-06 19:55:00 +08:00
// Success if at least one operation succeeded
if runtimeErr != nil && configErr != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to remove inbound: runtime=%v, config=%v", runtimeErr, configErr))
return
2026-01-28 13:13:58 +08:00
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "Inbound removed successfully",
})
default:
writeError(w, http.StatusBadRequest, "Invalid action. Must be 'add' or 'remove'")
}
}
// ================== Xray Outbounds Management ==================
// OutboundRequest represents outbound management request
type OutboundRequest struct {
Action string `json:"action"`
Outbound map[string]interface{} `json:"outbound,omitempty"`
Tag string `json:"tag,omitempty"`
}
// HandleOutbounds handles outbound management
func (h *ManageHandler) HandleOutbounds(w http.ResponseWriter, r *http.Request) {
if !h.authenticate(r) {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
switch r.Method {
case http.MethodGet:
h.listOutbounds(w, r)
case http.MethodPost:
h.manageOutbound(w, r)
default:
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
}
func (h *ManageHandler) listOutbounds(w http.ResponseWriter, r *http.Request) {
configPath := h.findXrayConfigPath()
if configPath == "" {
writeError(w, http.StatusNotFound, "Xray config not found")
return
}
content, err := os.ReadFile(configPath)
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read config: %v", err))
return
}
var config map[string]interface{}
if err := json.Unmarshal(content, &config); err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to parse config: %v", err))
return
}
outbounds, _ := config["outbounds"].([]interface{})
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"outbounds": outbounds,
})
}
func (h *ManageHandler) manageOutbound(w http.ResponseWriter, r *http.Request) {
var req OutboundRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid request body")
return
}
action := strings.ToLower(strings.TrimSpace(req.Action))
if action == "" {
action = "add"
}
apiPort := h.findXrayAPIPort()
if apiPort == 0 {
writeError(w, http.StatusInternalServerError, "Xray API not available")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
clients, err := xrpc.New(ctx, "127.0.0.1", uint16(apiPort))
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to connect to Xray: %v", err))
return
}
defer clients.Connection.Close()
switch action {
case "add":
if req.Outbound == nil {
writeError(w, http.StatusBadRequest, "Outbound payload is required")
return
}
if err := h.addOutbound(ctx, clients.Handler, req.Outbound); err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add outbound: %v", err))
return
}
if err := h.persistOutbound(req.Outbound); err != nil {
log.Printf("[Manage] Warning: Failed to persist outbound to config: %v", err)
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "Outbound added successfully",
})
case "remove":
if req.Tag == "" {
writeError(w, http.StatusBadRequest, "Tag is required for remove action")
return
}
if err := h.removeOutbound(ctx, clients.Handler, req.Tag); err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to remove outbound: %v", err))
return
}
if err := h.removeOutboundFromConfig(req.Tag); err != nil {
log.Printf("[Manage] Warning: Failed to remove outbound from config: %v", err)
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "Outbound removed successfully",
})
default:
writeError(w, http.StatusBadRequest, "Invalid action. Must be 'add' or 'remove'")
}
}
// ================== Xray Routing Management ==================
// RoutingRequest represents routing management request
type RoutingRequest struct {
Action string `json:"action"`
Routing map[string]interface{} `json:"routing,omitempty"`
Rule map[string]interface{} `json:"rule,omitempty"`
Index int `json:"index,omitempty"`
}
// HandleRouting handles routing management
func (h *ManageHandler) HandleRouting(w http.ResponseWriter, r *http.Request) {
if !h.authenticate(r) {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
switch r.Method {
case http.MethodGet:
h.getRouting(w, r)
case http.MethodPost:
h.manageRouting(w, r)
default:
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
}
func (h *ManageHandler) getRouting(w http.ResponseWriter, r *http.Request) {
configPath := h.findXrayConfigPath()
if configPath == "" {
writeError(w, http.StatusNotFound, "Xray config not found")
return
}
content, err := os.ReadFile(configPath)
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read config: %v", err))
return
}
var config map[string]interface{}
if err := json.Unmarshal(content, &config); err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to parse config: %v", err))
return
}
routing, _ := config["routing"].(map[string]interface{})
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"routing": routing,
})
}
func (h *ManageHandler) manageRouting(w http.ResponseWriter, r *http.Request) {
var req RoutingRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid request body")
return
}
action := strings.ToLower(strings.TrimSpace(req.Action))
if action == "" {
action = "set"
}
configPath := h.findXrayConfigPath()
if configPath == "" {
writeError(w, http.StatusNotFound, "Xray config not found")
return
}
content, err := os.ReadFile(configPath)
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read config: %v", err))
return
}
var config map[string]interface{}
if err := json.Unmarshal(content, &config); err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to parse config: %v", err))
return
}
switch action {
case "set":
if req.Routing == nil {
writeError(w, http.StatusBadRequest, "Routing config is required")
return
}
config["routing"] = req.Routing
case "add_rule":
if req.Rule == nil {
writeError(w, http.StatusBadRequest, "Rule is required")
return
}
routing, _ := config["routing"].(map[string]interface{})
if routing == nil {
routing = map[string]interface{}{}
}
rules, _ := routing["rules"].([]interface{})
rules = append(rules, req.Rule)
routing["rules"] = rules
config["routing"] = routing
case "remove_rule":
routing, _ := config["routing"].(map[string]interface{})
if routing == nil {
writeError(w, http.StatusBadRequest, "No routing config found")
return
}
rules, _ := routing["rules"].([]interface{})
if req.Index < 0 || req.Index >= len(rules) {
writeError(w, http.StatusBadRequest, "Invalid rule index")
return
}
rules = append(rules[:req.Index], rules[req.Index+1:]...)
routing["rules"] = rules
config["routing"] = routing
default:
writeError(w, http.StatusBadRequest, "Invalid action")
return
}
newContent, err := json.MarshalIndent(config, "", " ")
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to marshal config: %v", err))
return
}
if err := os.WriteFile(configPath, newContent, 0644); err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to write config: %v", err))
return
}
log.Printf("[Manage] Routing config updated")
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "Routing updated successfully. Restart Xray to apply changes.",
})
}
// ================== Helper Functions ==================
func (h *ManageHandler) findXrayConfigPath() string {
configPaths := []string{
"/usr/local/etc/xray/config.json",
"/etc/xray/config.json",
"/opt/xray/config.json",
}
for _, p := range configPaths {
if _, err := os.Stat(p); err == nil {
return p
}
}
return ""
}
func (h *ManageHandler) findXrayAPIPort() int {
configPath := h.findXrayConfigPath()
if configPath == "" {
return 0
}
content, err := os.ReadFile(configPath)
if err != nil {
return 0
}
var config map[string]interface{}
if err := json.Unmarshal(content, &config); err != nil {
return 0
}
inbounds, ok := config["inbounds"].([]interface{})
if !ok {
return 0
}
for _, ib := range inbounds {
inbound, ok := ib.(map[string]interface{})
if !ok {
continue
}
tag, _ := inbound["tag"].(string)
if tag == "api" {
port, ok := inbound["port"].(float64)
if ok {
return int(port)
}
}
}
return 10085
}
func (h *ManageHandler) addInbound(ctx context.Context, handlerClient command.HandlerServiceClient, inbound map[string]interface{}) error {
inboundJSON, err := json.Marshal(inbound)
if err != nil {
return fmt.Errorf("failed to marshal inbound: %w", err)
}
inboundConfig := &conf.InboundDetourConfig{}
if err := json.Unmarshal(inboundJSON, inboundConfig); err != nil {
return fmt.Errorf("failed to unmarshal inbound config: %w", err)
}
rawConfig, err := inboundConfig.Build()
if err != nil {
return fmt.Errorf("failed to build inbound config: %w", err)
}
if tag, ok := inbound["tag"].(string); ok && tag != "" {
_, _ = handlerClient.RemoveInbound(ctx, &command.RemoveInboundRequest{
Tag: tag,
})
}
_, err = handlerClient.AddInbound(ctx, &command.AddInboundRequest{
Inbound: rawConfig,
})
return err
}
func (h *ManageHandler) removeInbound(ctx context.Context, handlerClient command.HandlerServiceClient, tag string) error {
_, err := handlerClient.RemoveInbound(ctx, &command.RemoveInboundRequest{
Tag: tag,
})
return err
}
func (h *ManageHandler) addOutbound(ctx context.Context, handlerClient command.HandlerServiceClient, outbound map[string]interface{}) error {
outboundJSON, err := json.Marshal(outbound)
if err != nil {
return fmt.Errorf("failed to marshal outbound: %w", err)
}
outboundConfig := &conf.OutboundDetourConfig{}
if err := json.Unmarshal(outboundJSON, outboundConfig); err != nil {
return fmt.Errorf("failed to unmarshal outbound config: %w", err)
}
rawConfig, err := outboundConfig.Build()
if err != nil {
return fmt.Errorf("failed to build outbound config: %w", err)
}
_, err = handlerClient.AddOutbound(ctx, &command.AddOutboundRequest{
Outbound: rawConfig,
})
return err
}
func (h *ManageHandler) removeOutbound(ctx context.Context, handlerClient command.HandlerServiceClient, tag string) error {
_, err := handlerClient.RemoveOutbound(ctx, &command.RemoveOutboundRequest{
Tag: tag,
})
return err
}
func (h *ManageHandler) persistInbound(inbound map[string]interface{}) error {
configPath := h.findXrayConfigPath()
if configPath == "" {
return fmt.Errorf("config file not found")
}
content, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("failed to read config: %w", err)
}
var config map[string]interface{}
if err := json.Unmarshal(content, &config); err != nil {
return fmt.Errorf("failed to parse config: %w", err)
}
inbounds, _ := config["inbounds"].([]interface{})
inbounds = append(inbounds, inbound)
config["inbounds"] = inbounds
newContent, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
return os.WriteFile(configPath, newContent, 0644)
}
func (h *ManageHandler) removeInboundFromConfig(tag string) error {
configPath := h.findXrayConfigPath()
if configPath == "" {
return fmt.Errorf("config file not found")
}
content, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("failed to read config: %w", err)
}
var config map[string]interface{}
if err := json.Unmarshal(content, &config); err != nil {
return fmt.Errorf("failed to parse config: %w", err)
}
inbounds, _ := config["inbounds"].([]interface{})
var newInbounds []interface{}
for _, ib := range inbounds {
inbound, ok := ib.(map[string]interface{})
if !ok {
newInbounds = append(newInbounds, ib)
continue
}
ibTag, _ := inbound["tag"].(string)
if ibTag != tag {
newInbounds = append(newInbounds, ib)
}
}
config["inbounds"] = newInbounds
newContent, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
return os.WriteFile(configPath, newContent, 0644)
}
func (h *ManageHandler) persistOutbound(outbound map[string]interface{}) error {
configPath := h.findXrayConfigPath()
if configPath == "" {
return fmt.Errorf("config file not found")
}
content, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("failed to read config: %w", err)
}
var config map[string]interface{}
if err := json.Unmarshal(content, &config); err != nil {
return fmt.Errorf("failed to parse config: %w", err)
}
outbounds, _ := config["outbounds"].([]interface{})
outbounds = append(outbounds, outbound)
config["outbounds"] = outbounds
newContent, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
return os.WriteFile(configPath, newContent, 0644)
}
func (h *ManageHandler) removeOutboundFromConfig(tag string) error {
configPath := h.findXrayConfigPath()
if configPath == "" {
return fmt.Errorf("config file not found")
}
content, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("failed to read config: %w", err)
}
var config map[string]interface{}
if err := json.Unmarshal(content, &config); err != nil {
return fmt.Errorf("failed to parse config: %w", err)
}
outbounds, _ := config["outbounds"].([]interface{})
var newOutbounds []interface{}
for _, ob := range outbounds {
outbound, ok := ob.(map[string]interface{})
if !ok {
newOutbounds = append(newOutbounds, ob)
continue
}
obTag, _ := outbound["tag"].(string)
if obTag != tag {
newOutbounds = append(newOutbounds, ob)
}
}
config["outbounds"] = newOutbounds
newContent, err := json.MarshalIndent(config, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
return os.WriteFile(configPath, newContent, 0644)
}
// ================== Scan ==================
// ScanResponse represents the response for scan operation
type ScanResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
XrayRunning bool `json:"xray_running"`
XrayVersion string `json:"xray_version,omitempty"`
APIPort int `json:"api_port,omitempty"`
ConfigPath string `json:"config_path,omitempty"`
Inbounds []map[string]interface{} `json:"inbounds,omitempty"`
ConfigModified bool `json:"config_modified,omitempty"`
ConfigAddedSections []string `json:"config_added_sections,omitempty"`
}
// HandleScan handles POST /api/child/scan
func (h *ManageHandler) HandleScan(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
if !h.authenticate(r) {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
log.Printf("[Manage] Scanning for Xray process...")
configResult := h.EnsureXrayConfig()
response := ScanResponse{
Success: true,
Message: "Scan completed",
}
if configResult.Modified {
response.ConfigModified = true
response.ConfigAddedSections = configResult.AddedSections
log.Printf("[Manage] Xray config auto-completed, added sections: %v", configResult.AddedSections)
cmd := exec.Command("systemctl", "restart", "xray")
if err := cmd.Run(); err != nil {
log.Printf("[Manage] Failed to restart xray after config update: %v", err)
} else {
log.Printf("[Manage] Xray restarted after config update")
time.Sleep(1 * time.Second)
}
} else if configResult.Error != "" {
log.Printf("[Manage] Xray config check warning: %s", configResult.Error)
}
xrayStatus := h.getXrayStatus()
if xrayStatus != nil {
response.XrayRunning = xrayStatus.Running
response.XrayVersion = xrayStatus.Version
}
configPath := h.findXrayConfigPath()
if configPath != "" {
response.ConfigPath = configPath
response.APIPort = h.findXrayAPIPort()
content, err := os.ReadFile(configPath)
if err == nil {
var config map[string]interface{}
if json.Unmarshal(content, &config) == nil {
if inbounds, ok := config["inbounds"].([]interface{}); ok {
for _, ib := range inbounds {
if inbound, ok := ib.(map[string]interface{}); ok {
if tag, _ := inbound["tag"].(string); tag == "api" {
continue
}
response.Inbounds = append(response.Inbounds, inbound)
}
}
}
}
}
}
if response.XrayRunning {
response.Message = fmt.Sprintf("Xray is running, found %d inbound(s)", len(response.Inbounds))
if response.ConfigModified {
response.Message += fmt.Sprintf(", config updated: added %v", response.ConfigAddedSections)
}
} else if xrayStatus != nil && xrayStatus.Installed {
response.Message = "Xray is installed but not running"
} else {
response.Message = "Xray is not installed"
}
log.Printf("[Manage] Scan result: %s", response.Message)
writeJSON(w, http.StatusOK, response)
}
// ================== Xray Config Auto-Complete ==================
// EnsureXrayConfigResult holds the result of config check
type EnsureXrayConfigResult struct {
ConfigPath string `json:"config_path"`
Modified bool `json:"modified"`
AddedSections []string `json:"added_sections,omitempty"`
Error string `json:"error,omitempty"`
}
// EnsureXrayConfig checks and completes Xray configuration
func (h *ManageHandler) EnsureXrayConfig() *EnsureXrayConfigResult {
result := &EnsureXrayConfigResult{}
configPath := h.findXrayConfigPath()
if configPath == "" {
result.Error = "Xray config not found"
return result
}
result.ConfigPath = configPath
content, err := os.ReadFile(configPath)
if err != nil {
result.Error = fmt.Sprintf("Failed to read config: %v", err)
return result
}
var config map[string]interface{}
if err := json.Unmarshal(content, &config); err != nil {
result.Error = fmt.Sprintf("Invalid JSON: %v", err)
return result
}
modified := false
if _, ok := config["api"]; !ok {
config["api"] = map[string]interface{}{
"tag": "api",
"services": []interface{}{"HandlerService", "LoggerService", "StatsService", "RoutingService"},
}
result.AddedSections = append(result.AddedSections, "api")
modified = true
}
if _, ok := config["stats"]; !ok {
config["stats"] = map[string]interface{}{}
result.AddedSections = append(result.AddedSections, "stats")
modified = true
}
if !h.hasValidPolicy(config) {
config["policy"] = h.getTemplatePolicy()
result.AddedSections = append(result.AddedSections, "policy")
modified = true
}
if _, ok := config["metrics"]; !ok {
config["metrics"] = map[string]interface{}{
"tag": "Metrics",
"listen": "127.0.0.1:38889",
}
result.AddedSections = append(result.AddedSections, "metrics")
modified = true
}
if !h.hasAPIInbound(config) {
h.addAPIInbound(config)
result.AddedSections = append(result.AddedSections, "api_inbound")
modified = true
}
if !h.hasAPIRoutingRule(config) {
h.addAPIRoutingRule(config)
result.AddedSections = append(result.AddedSections, "api_routing_rule")
modified = true
}
if modified {
backupPath := configPath + ".backup"
if err := os.WriteFile(backupPath, content, 0644); err != nil {
log.Printf("[Manage] Warning: failed to backup config: %v", err)
}
newContent, _ := json.MarshalIndent(config, "", " ")
if err := os.WriteFile(configPath, newContent, 0644); err != nil {
result.Error = fmt.Sprintf("Failed to write config: %v", err)
return result
}
result.Modified = true
log.Printf("[Manage] Xray config updated, added: %v", result.AddedSections)
}
return result
}
func (h *ManageHandler) hasValidPolicy(config map[string]interface{}) bool {
policy, ok := config["policy"].(map[string]interface{})
if !ok {
return false
}
levels, ok := policy["levels"].(map[string]interface{})
if !ok {
return false
}
level0, ok := levels["0"].(map[string]interface{})
if !ok {
return false
}
statsUplink, _ := level0["statsUserUplink"].(bool)
statsDownlink, _ := level0["statsUserDownlink"].(bool)
return statsUplink && statsDownlink
}
func (h *ManageHandler) getTemplatePolicy() map[string]interface{} {
return map[string]interface{}{
"levels": map[string]interface{}{
"0": map[string]interface{}{
"handshake": float64(5),
"connIdle": float64(300),
"uplinkOnly": float64(2),
"downlinkOnly": float64(2),
"statsUserUplink": true,
"statsUserDownlink": true,
},
},
"system": map[string]interface{}{
"statsInboundUplink": true,
"statsInboundDownlink": true,
"statsOutboundUplink": true,
"statsOutboundDownlink": true,
},
}
}
func (h *ManageHandler) hasAPIInbound(config map[string]interface{}) bool {
inbounds, ok := config["inbounds"].([]interface{})
if !ok {
return false
}
for _, ib := range inbounds {
if inbound, ok := ib.(map[string]interface{}); ok {
if tag, _ := inbound["tag"].(string); tag == "api" {
return true
}
}
}
return false
}
func (h *ManageHandler) addAPIInbound(config map[string]interface{}) {
apiInbound := map[string]interface{}{
"tag": "api",
"port": float64(46736),
"listen": "127.0.0.1",
"protocol": "dokodemo-door",
"settings": map[string]interface{}{
"address": "127.0.0.1",
},
}
inbounds, ok := config["inbounds"].([]interface{})
if !ok {
inbounds = []interface{}{}
}
config["inbounds"] = append([]interface{}{apiInbound}, inbounds...)
}
func (h *ManageHandler) hasAPIRoutingRule(config map[string]interface{}) bool {
routing, ok := config["routing"].(map[string]interface{})
if !ok {
return false
}
rules, ok := routing["rules"].([]interface{})
if !ok {
return false
}
for _, r := range rules {
if rule, ok := r.(map[string]interface{}); ok {
if outboundTag, _ := rule["outboundTag"].(string); outboundTag == "api" {
return true
}
}
}
return false
}
func (h *ManageHandler) addAPIRoutingRule(config map[string]interface{}) {
apiRule := map[string]interface{}{
"type": "field",
"inboundTag": []interface{}{"api"},
"outboundTag": "api",
}
routing, ok := config["routing"].(map[string]interface{})
if !ok {
routing = map[string]interface{}{
"domainStrategy": "IPIfNonMatch",
"rules": []interface{}{},
}
config["routing"] = routing
}
rules, ok := routing["rules"].([]interface{})
if !ok {
rules = []interface{}{}
}
routing["rules"] = append([]interface{}{apiRule}, rules...)
}