2500 lines
66 KiB
Go
2500 lines
66 KiB
Go
package handler
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"mmw-agent/internal/config"
|
||
"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 (token + User-Agent)
|
||
func (h *ManageHandler) authenticate(r *http.Request) bool {
|
||
if r.Header.Get("User-Agent") != config.AgentUserAgent {
|
||
return false
|
||
}
|
||
|
||
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 {
|
||
// 过滤掉 tag="api" 和空 tag(Xray 内部入站)
|
||
if ib.Tag != "" && ib.Tag != "api" {
|
||
tags = append(tags, ib.Tag)
|
||
}
|
||
}
|
||
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)
|
||
// 跳过 tag="api" 的入站(Xray 内部 API 入站)
|
||
if tag == "api" {
|
||
continue
|
||
}
|
||
ibCopy := make(map[string]interface{})
|
||
for k, v := range ib {
|
||
ibCopy[k] = v
|
||
}
|
||
// 如果 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
|
||
}
|
||
}
|
||
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
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// Success if config operation succeeded (runtime removal is optional)
|
||
// The inbound might not exist in runtime if Xray wasn't restarted after config change
|
||
if configErr != nil {
|
||
// Config file operation failed
|
||
if runtimeErr != nil {
|
||
// Both failed - check if it's just "not found" errors
|
||
if strings.Contains(runtimeErr.Error(), "not enough information") {
|
||
// Xray says the inbound doesn't exist in runtime, which is fine
|
||
// Just report config error
|
||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to remove inbound from config: %v", configErr))
|
||
} else {
|
||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to remove inbound: runtime=%v, config=%v", runtimeErr, configErr))
|
||
}
|
||
} else {
|
||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to remove inbound from config: %v", configErr))
|
||
}
|
||
return
|
||
}
|
||
|
||
// Config succeeded, runtime error is acceptable (inbound might not be loaded)
|
||
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...)
|
||
}
|
||
|
||
// ================== Certificate Deploy ==================
|
||
|
||
// CertDeployRequest represents a certificate deploy request from master
|
||
type CertDeployRequest struct {
|
||
Domain string `json:"domain"`
|
||
CertPEM string `json:"cert_pem"`
|
||
KeyPEM string `json:"key_pem"`
|
||
CertPath string `json:"cert_path"`
|
||
KeyPath string `json:"key_path"`
|
||
Reload string `json:"reload"` // nginx, xray, both, none
|
||
}
|
||
|
||
// HandleCertDeploy handles POST /api/child/cert/deploy
|
||
func (h *ManageHandler) HandleCertDeploy(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 CertDeployRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
|
||
if req.CertPEM == "" || req.KeyPEM == "" || req.CertPath == "" || req.KeyPath == "" {
|
||
writeError(w, http.StatusBadRequest, "cert_pem, key_pem, cert_path, key_path are required")
|
||
return
|
||
}
|
||
|
||
if err := deployCertFiles(req.CertPEM, req.KeyPEM, req.CertPath, req.KeyPath, req.Reload); err != nil {
|
||
log.Printf("[CertDeploy] Failed to deploy cert for %s: %v", req.Domain, err)
|
||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("deploy failed: %v", err))
|
||
return
|
||
}
|
||
|
||
log.Printf("[CertDeploy] Successfully deployed cert for %s to %s", req.Domain, req.CertPath)
|
||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||
"success": true,
|
||
"message": fmt.Sprintf("certificate for %s deployed", req.Domain),
|
||
})
|
||
}
|
||
|
||
func deployCertFiles(certPEM, keyPEM, certPath, keyPath, reloadTarget string) error {
|
||
if err := os.MkdirAll(filepath.Dir(certPath), 0755); err != nil {
|
||
return fmt.Errorf("create cert dir: %w", err)
|
||
}
|
||
if err := os.MkdirAll(filepath.Dir(keyPath), 0755); err != nil {
|
||
return fmt.Errorf("create key dir: %w", err)
|
||
}
|
||
if err := os.WriteFile(certPath, []byte(certPEM), 0644); err != nil {
|
||
return fmt.Errorf("write cert: %w", err)
|
||
}
|
||
if err := os.WriteFile(keyPath, []byte(keyPEM), 0600); err != nil {
|
||
return fmt.Errorf("write key: %w", err)
|
||
}
|
||
|
||
switch reloadTarget {
|
||
case "nginx":
|
||
return runCommand("nginx", "-s", "reload")
|
||
case "xray":
|
||
return runCommand("systemctl", "restart", "xray")
|
||
case "both":
|
||
if err := runCommand("nginx", "-s", "reload"); err != nil {
|
||
return err
|
||
}
|
||
return runCommand("systemctl", "restart", "xray")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func runCommand(name string, args ...string) error {
|
||
if output, err := exec.Command(name, args...).CombinedOutput(); err != nil {
|
||
return fmt.Errorf("%s: %s: %w", name, string(output), err)
|
||
}
|
||
return nil
|
||
}
|