diff --git a/internal/agent/client.go b/internal/agent/client.go index 55fdd97..15ff871 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -301,6 +301,9 @@ func (c *Client) connectAndRun(ctx context.Context) error { log.Printf("[Agent] Failed to send initial heartbeat: %v", err) } + // Send scan result to master for auto-sync + go c.sendScanResult(conn) + return c.runMessageLoop(ctx, conn) } @@ -950,6 +953,7 @@ func (e *AuthError) IsTokenInvalid() bool { const ( WSMsgTypeCertDeploy = "cert_deploy" WSMsgTypeTokenUpdate = "token_update" + WSMsgTypeScanResult = "scan_result" ) // WSCertDeployPayload represents a certificate deploy command from master @@ -1030,11 +1034,11 @@ func deployCert(certPEM, keyPEM, certPath, keyPath, reloadTarget string) error { switch reloadTarget { case "nginx": - return runCmd("nginx", "-s", "reload") + return reloadNginxCmd() case "xray": return runCmd("systemctl", "restart", "xray") case "both": - if err := runCmd("nginx", "-s", "reload"); err != nil { + if err := reloadNginxCmd(); err != nil { return err } return runCmd("systemctl", "restart", "xray") @@ -1042,6 +1046,15 @@ func deployCert(certPEM, keyPEM, certPath, keyPath, reloadTarget string) error { return nil } +func reloadNginxCmd() error { + for _, bin := range []string{"/usr/local/nginx/sbin/nginx", "nginx"} { + if path, err := exec.LookPath(bin); err == nil { + return runCmd(path, "-s", "reload") + } + } + return runCmd("systemctl", "reload", "nginx") +} + func runCmd(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) @@ -1058,3 +1071,66 @@ func (c *Client) handleTokenUpdate(payload WSTokenUpdatePayload) { log.Printf("[Agent] Token updated successfully in memory") } + +// sendScanResult scans local xray status and sends results to master +func (c *Client) sendScanResult(conn *websocket.Conn) { + // Check xray running status + xrayRunning := false + xrayVersion := "" + cmd := exec.Command("xray", "version") + if out, err := cmd.Output(); err == nil { + xrayVersion = strings.TrimSpace(strings.Split(string(out), "\n")[0]) + } + if exec.Command("systemctl", "is-active", "--quiet", "xray").Run() == nil { + xrayRunning = true + } + + // Read inbounds from config + var inbounds []map[string]interface{} + configPaths := []string{ + "/usr/local/etc/xray/config.json", + "/etc/xray/config.json", + } + for _, cfgPath := range configPaths { + data, err := os.ReadFile(cfgPath) + if err != nil { + continue + } + var config map[string]interface{} + if json.Unmarshal(data, &config) != nil { + continue + } + if ibs, ok := config["inbounds"].([]interface{}); ok { + for _, ib := range ibs { + if m, ok := ib.(map[string]interface{}); ok { + if tag, _ := m["tag"].(string); tag == "api" { + continue + } + inbounds = append(inbounds, m) + } + } + } + break + } + + payload, _ := json.Marshal(map[string]interface{}{ + "xray_running": xrayRunning, + "xray_version": xrayVersion, + "inbounds": inbounds, + }) + + msg := map[string]interface{}{ + "type": WSMsgTypeScanResult, + "payload": json.RawMessage(payload), + } + + c.wsMu.Lock() + err := conn.WriteJSON(msg) + c.wsMu.Unlock() + + if err != nil { + log.Printf("[Agent] Failed to send scan_result: %v", err) + return + } + log.Printf("[Agent] Sent scan_result: xray_running=%v, inbounds=%d", xrayRunning, len(inbounds)) +} diff --git a/internal/handler/embed.go b/internal/handler/embed.go new file mode 100644 index 0000000..63deb58 --- /dev/null +++ b/internal/handler/embed.go @@ -0,0 +1,6 @@ +package handler + +import _ "embed" + +//go:embed xray_default_config.json +var defaultXrayConfig []byte diff --git a/internal/handler/manage.go b/internal/handler/manage.go index 2a26fd1..1b710a7 100644 --- a/internal/handler/manage.go +++ b/internal/handler/manage.go @@ -297,6 +297,9 @@ func (h *ManageHandler) HandleXrayInstall(w http.ResponseWriter, r *http.Request log.Printf("[Manage] Xray installed successfully") + // Deploy default config if no config exists + h.deployDefaultXrayConfig() + writeJSON(w, http.StatusOK, map[string]interface{}{ "success": true, "message": "Xray installed successfully", @@ -2511,6 +2514,31 @@ func runCommand(name string, args ...string) error { return nil } +// deployDefaultXrayConfig deploys the embedded default xray config if no config exists. +func (h *ManageHandler) deployDefaultXrayConfig() { + configPath := "/usr/local/etc/xray/config.json" + if _, err := os.Stat(configPath); err == nil { + // Config already exists — run EnsureXrayConfig to add missing sections + result := h.EnsureXrayConfig() + if result.Modified { + log.Printf("[Manage] Xray config updated after install: added %v", result.AddedSections) + exec.Command("systemctl", "restart", "xray").Run() + } + return + } + + if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { + log.Printf("[Manage] Failed to create xray config dir: %v", err) + return + } + if err := os.WriteFile(configPath, defaultXrayConfig, 0644); err != nil { + log.Printf("[Manage] Failed to write default xray config: %v", err) + return + } + log.Printf("[Manage] Deployed default xray config to %s", configPath) + exec.Command("systemctl", "restart", "xray").Run() +} + // ================== SSE Streaming Install/Remove ================== func sseStreamCmd(w http.ResponseWriter, r *http.Request, cmd *exec.Cmd, completeMsg string) { @@ -2600,6 +2628,9 @@ func (h *ManageHandler) HandleXrayInstallStream(w http.ResponseWriter, r *http.R `bash -c "$(curl -L https://github.com/XTLS/Xray-install/raw/main/install-release.sh)" @ install`) cmd.Env = os.Environ() sseStreamCmd(w, r, cmd, "Xray installed successfully") + + // Deploy default config after install + h.deployDefaultXrayConfig() } func (h *ManageHandler) HandleXrayRemoveStream(w http.ResponseWriter, r *http.Request) { diff --git a/internal/handler/xray_default_config.json b/internal/handler/xray_default_config.json new file mode 100644 index 0000000..25c5ea1 --- /dev/null +++ b/internal/handler/xray_default_config.json @@ -0,0 +1,104 @@ +{ + "log": { + "access": "/var/log/xray/access.log", + "error": "/var/log/xray/error.log", + "loglevel": "error" + }, + "dns": {}, + "api": { + "tag": "api", + "services": [ + "HandlerService", + "LoggerService", + "StatsService", + "RoutingService" + ] + }, + "stats": {}, + "policy": { + "levels": { + "0": { + "handshake": 5, + "connIdle": 300, + "uplinkOnly": 2, + "downlinkOnly": 2, + "statsUserUplink": true, + "statsUserDownlink": true + } + }, + "system": { + "statsInboundUplink": true, + "statsInboundDownlink": true, + "statsOutboundUplink": true, + "statsOutboundDownlink": true + } + }, + "routing": { + "domainStrategy": "IPIfNonMatch", + "rules": [ + { + "type": "field", + "inboundTag": [ + "api" + ], + "outboundTag": "api" + }, + { + "type": "field", + "protocol": [ + "bittorrent" + ], + "marktag": "ban_bt", + "outboundTag": "block" + }, + { + "type": "field", + "ip": [ + "geoip:cn" + ], + "marktag": "ban_geoip_cn", + "outboundTag": "block" + }, + { + "type": "field", + "domain": [ + "geosite:openai" + ], + "marktag": "fix_openai", + "outboundTag": "direct" + }, + { + "type": "field", + "ip": [ + "geoip:private" + ], + "outboundTag": "block" + } + ] + }, + "inbounds": [ + { + "tag": "api", + "port": 46736, + "listen": "127.0.0.1", + "protocol": "dokodemo-door", + "settings": { + "address": "127.0.0.1" + } + } + ], + "outbounds": [ + { + "tag": "direct", + "protocol": "freedom" + }, + { + "tag": "block", + "protocol": "blackhole" + } + ], + "metrics": { + "tag": "Metrics", + "listen": "127.0.0.1:38889" + } +} \ No newline at end of file diff --git a/templates/config.json b/templates/config.json new file mode 100644 index 0000000..25c5ea1 --- /dev/null +++ b/templates/config.json @@ -0,0 +1,104 @@ +{ + "log": { + "access": "/var/log/xray/access.log", + "error": "/var/log/xray/error.log", + "loglevel": "error" + }, + "dns": {}, + "api": { + "tag": "api", + "services": [ + "HandlerService", + "LoggerService", + "StatsService", + "RoutingService" + ] + }, + "stats": {}, + "policy": { + "levels": { + "0": { + "handshake": 5, + "connIdle": 300, + "uplinkOnly": 2, + "downlinkOnly": 2, + "statsUserUplink": true, + "statsUserDownlink": true + } + }, + "system": { + "statsInboundUplink": true, + "statsInboundDownlink": true, + "statsOutboundUplink": true, + "statsOutboundDownlink": true + } + }, + "routing": { + "domainStrategy": "IPIfNonMatch", + "rules": [ + { + "type": "field", + "inboundTag": [ + "api" + ], + "outboundTag": "api" + }, + { + "type": "field", + "protocol": [ + "bittorrent" + ], + "marktag": "ban_bt", + "outboundTag": "block" + }, + { + "type": "field", + "ip": [ + "geoip:cn" + ], + "marktag": "ban_geoip_cn", + "outboundTag": "block" + }, + { + "type": "field", + "domain": [ + "geosite:openai" + ], + "marktag": "fix_openai", + "outboundTag": "direct" + }, + { + "type": "field", + "ip": [ + "geoip:private" + ], + "outboundTag": "block" + } + ] + }, + "inbounds": [ + { + "tag": "api", + "port": 46736, + "listen": "127.0.0.1", + "protocol": "dokodemo-door", + "settings": { + "address": "127.0.0.1" + } + } + ], + "outbounds": [ + { + "tag": "direct", + "protocol": "freedom" + }, + { + "tag": "block", + "protocol": "blackhole" + } + ], + "metrics": { + "tag": "Metrics", + "listen": "127.0.0.1:38889" + } +} \ No newline at end of file