282 lines
8.1 KiB
Go
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
|
|
}
|