package main import ( "bytes" "context" "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" "time" ) // runCapture executes a command with a timeout and reports its exit code // (-1 when the process could not be started or was killed by timeout, // mirroring spawnSync's null status in agent.js). func runCapture(timeout time.Duration, name string, args ...string) (stdout, stderr string, exitCode int) { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() cmd := exec.CommandContext(ctx, name, args...) var outBuf, errBuf bytes.Buffer cmd.Stdout = &outBuf cmd.Stderr = &errBuf err := cmd.Run() stdout, stderr = outBuf.String(), errBuf.String() if err == nil { return stdout, stderr, 0 } if exitErr, ok := err.(*exec.ExitError); ok { return stdout, stderr, exitErr.ExitCode() } return stdout, stderr, -1 } // runThrow mirrors agent.js run(): returns stdout, or throws // " failed: ". func runThrow(timeout time.Duration, name string, args ...string) (string, error) { stdout, stderr, code := runCapture(timeout, name, args...) if code != 0 { detail := stderr if detail == "" { detail = stdout } return "", fmt.Errorf("%s %s failed: %s", name, strings.Join(args, " "), truncateRunes(safeError(detail), 1200)) } return stdout, nil } func systemdPresent() bool { return fileExists("/run/systemd/system") } // serviceCommand mirrors agent.js: systemctl under systemd, rc-service under // OpenRC, selected by the presence of /run/systemd/system. func serviceCommand(action, service string) error { if systemdPresent() { _, err := runThrow(30*time.Second, "systemctl", action, service+".service") return err } _, err := runThrow(30*time.Second, "rc-service", service, action) return err } type serviceStatus struct { Status string Detail string } func serviceHealth(service string) serviceStatus { var stdout, stderr string var code int if systemdPresent() { stdout, stderr, code = runCapture(3*time.Second, "systemctl", "is-active", service+".service") } else { stdout, stderr, code = runCapture(3*time.Second, "rc-service", service, "status") } status := "error" detail := stdout if code == 0 { status = "ready" } else { detail = getLastEngineError() if detail == "" { detail = stderr } if detail == "" { detail = stdout } } return serviceStatus{Status: status, Detail: truncateRunes(strings.TrimSpace(detail), 400)} } func copyFilePreserve(src, dst string) error { data, err := os.ReadFile(src) if err != nil { return err } // JS copyFileSync would create dst at 0666&^umask; the config contains // Reality private keys, so the Go agent deliberately keeps 0600 // (functional parity, stricter permissions — see PARITY.md). return os.WriteFile(dst, data, 0o600) } // activateSingBoxLocked regenerates the combined sing-box config from all // resources, validates it with `sing-box check`, swaps it in and restarts // the service, rolling back on failure — agent.js activateSingBox(). // Callers must hold stateMu. func activateSingBoxLocked() error { resources, err := readResources() if err != nil { return err } if len(resources) == 0 { // No resources: the engine must be stopped (and any stale config // removed), otherwise a fresh install loops restarts. _ = serviceCommand("stop", "nexusgate-sing-box") if fileExists(cfg.SingBoxConfig) { _ = os.Remove(cfg.SingBoxConfig) } return nil } if !fileExists(cfg.SingBoxBin) || !fileExists(cfg.SingBoxStatsBin) { return fmt.Errorf("sing-box 及统计组件未安装;请在入口机器运行 ng-agent engine install") } candidate := cfg.SingBoxConfig + ".candidate.json" previous := cfg.SingBoxConfig + ".previous" next, err := marshalIndentJSON(combinedSingBoxConfig(resources)) if err != nil { return err } next += "\n" if current, err := os.ReadFile(cfg.SingBoxConfig); err == nil && string(current) == next && serviceHealth("nexusgate-sing-box").Status == "ready" { return nil // config unchanged and engine healthy: no restart } if err := os.MkdirAll(filepath.Dir(cfg.SingBoxConfig), 0o700); err != nil { return err } if err := os.WriteFile(candidate, []byte(next), 0o600); err != nil { return err } if _, err := runThrow(30*time.Second, cfg.SingBoxBin, "check", "-c", candidate); err != nil { _ = os.Remove(candidate) return err } if fileExists(cfg.SingBoxConfig) { if err := copyFilePreserve(cfg.SingBoxConfig, previous); err != nil { return err } } if err := os.Rename(candidate, cfg.SingBoxConfig); err != nil { return err } if err := serviceCommand("restart", "nexusgate-sing-box"); err != nil { if fileExists(previous) { _ = copyFilePreserve(previous, cfg.SingBoxConfig) } else { _ = os.Remove(cfg.SingBoxConfig) } if fileExists(cfg.SingBoxConfig) { _ = serviceCommand("restart", "nexusgate-sing-box") // preserve original error } return err } return nil } // serviceEpoch identifies one sing-box process instance as ":" // (PID alone can be reused) — agent.js serviceEpoch(). "" when unknown. func serviceEpoch() string { const service = "nexusgate-sing-box" pid := 0 if systemdPresent() { stdout, _, code := runCapture(3*time.Second, "systemctl", "show", "-p", "MainPID", "--value", service+".service") if code == 0 { pid, _ = strconv.Atoi(strings.TrimSpace(stdout)) } } else { if data, err := os.ReadFile("/run/" + service + ".pid"); err == nil { pid, _ = strconv.Atoi(strings.TrimSpace(string(data))) } } if pid <= 0 { return "" } stat, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) if err != nil { return "" } text := string(stat) idx := strings.LastIndex(text, ") ") if idx < 0 { return "" } fields := strings.Fields(strings.TrimSpace(text[idx+2:])) if len(fields) <= 19 || fields[19] == "" { return "" } return fmt.Sprintf("%d:%s", pid, fields[19]) } // udpPortsForPid returns the set of UDP ports a process listens on, resolved // through /proc//fd socket inodes and /proc/net/udp{,6} — agent.js // udpPortsForPid(). func udpPortsForPid(pid int) (map[int]bool, error) { sockets := map[string]bool{} fds, err := os.ReadDir(fmt.Sprintf("/proc/%d/fd", pid)) if err != nil { return nil, err } for _, fd := range fds { target, err := os.Readlink(filepath.Join(fmt.Sprintf("/proc/%d/fd", pid), fd.Name())) if err != nil { continue // descriptor closed during inspection } if strings.HasPrefix(target, "socket:[") && strings.HasSuffix(target, "]") { sockets[target[len("socket:["):len(target)-1]] = true } } ports := map[int]bool{} for _, table := range []string{"/proc/net/udp", "/proc/net/udp6"} { data, err := os.ReadFile(table) if err != nil { return nil, err } lines := strings.Split(strings.TrimSpace(string(data)), "\n") for _, row := range lines[1:] { fields := strings.Fields(row) if len(fields) > 9 && fields[3] == "07" && sockets[fields[9]] { local := fields[1] if idx := strings.LastIndex(local, ":"); idx >= 0 { if port, err := strconv.ParseInt(local[idx+1:], 16, 32); err == nil { ports[int(port)] = true } } } } } return ports, nil } // missingHysteriaListeners lists hysteria2 listen ports the running sing-box // has no UDP socket for — agent.js missingHysteriaListeners(). func missingHysteriaListeners(resources []map[string]interface{}) ([]int, error) { expected := []int{} seen := map[int]bool{} for _, res := range resources { for _, inbound := range asSlice(res["inbounds"]) { im, ok := asMap(inbound) if !ok || asString(im["type"]) != "hysteria2" { continue } port := jsonNumberToInt(im["listen_port"]) if !seen[port] { seen[port] = true expected = append(expected, port) } } } if len(expected) == 0 { return []int{}, nil } epoch := serviceEpoch() if epoch == "" { return nil, fmt.Errorf("无法确认 sing-box 进程,无法验证 Hysteria 2 UDP 监听") } pid, _ := strconv.Atoi(strings.SplitN(epoch, ":", 2)[0]) actual, err := udpPortsForPid(pid) if err != nil { return nil, err } missing := []int{} for _, port := range expected { if !actual[port] { missing = append(missing, port) } } return missing, nil }