Files
NexusGate/agent-go/singbox.go
T

282 lines
8.1 KiB
Go

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
// "<cmd> <args> failed: <stderr||stdout, redacted, 1200 chars>".
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>:<startTick>"
// (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/<pid>/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
}