package nymclient import ( "context" "fmt" "io" "net" "os" "os/exec" "path/filepath" "strings" "sync" "time" ) type Config struct { Binary string Provider string HomeDir string ClientID string SocksAddr string AnonymousReplies bool StartupTimeout time.Duration LogOutput io.Writer } type Manager struct { cfg Config cmd *exec.Cmd mu sync.Mutex } func New(cfg Config) (*Manager, error) { if cfg.Binary == "" { cfg.Binary = "nym-socks5-client" } if cfg.HomeDir == "" { return nil, fmt.Errorf("nym home dir is required") } if cfg.ClientID == "" { cfg.ClientID = "n2usenet" } if cfg.SocksAddr == "" { cfg.SocksAddr = "127.0.0.1:11080" } if cfg.Provider == "" { return nil, fmt.Errorf("nym provider is required") } if cfg.StartupTimeout <= 0 { cfg.StartupTimeout = 120 * time.Second } if cfg.LogOutput == nil { cfg.LogOutput = os.Stderr } return &Manager{cfg: cfg}, nil } func (m *Manager) Init(ctx context.Context) error { binary, err := exec.LookPath(m.cfg.Binary) if err != nil { if _, statErr := os.Stat(m.cfg.Binary); statErr != nil { return fmt.Errorf("find nym-socks5-client: %w", err) } binary = m.cfg.Binary } m.cfg.Binary = binary if err := os.MkdirAll(m.cfg.HomeDir, 0700); err != nil { return fmt.Errorf("create nym home: %w", err) } configFile := filepath.Join(m.cfg.HomeDir, ".nym", "socks5-clients", m.cfg.ClientID, "config", "config.toml") if data, err := os.ReadFile(configFile); err == nil { if !strings.Contains(string(data), m.cfg.Provider) { return fmt.Errorf("existing nym client config uses a different provider; remove %s to reinitialize intentionally", filepath.Dir(filepath.Dir(configFile))) } return nil } host, port, err := net.SplitHostPort(m.cfg.SocksAddr) if err != nil { return fmt.Errorf("split socks addr: %w", err) } args := []string{ "init", "--id", m.cfg.ClientID, "--provider", m.cfg.Provider, "--host", host, "--port", port, } if m.cfg.AnonymousReplies { args = append(args, "--use-reply-surbs", "true") } cmd := exec.CommandContext(ctx, m.cfg.Binary, args...) cmd.Env = append(os.Environ(), "HOME="+m.cfg.HomeDir) cmd.Stdout = m.cfg.LogOutput cmd.Stderr = m.cfg.LogOutput if err := cmd.Run(); err != nil { return fmt.Errorf("nym-socks5-client init: %w", err) } return nil } func (m *Manager) Start(ctx context.Context) error { m.mu.Lock() defer m.mu.Unlock() if m.cmd != nil { return nil } host, port, err := net.SplitHostPort(m.cfg.SocksAddr) if err != nil { return fmt.Errorf("split socks addr: %w", err) } args := []string{"run", "--id", m.cfg.ClientID, "--host", host, "--port", port} if m.cfg.AnonymousReplies { args = append(args, "--use-anonymous-replies", "true") } cmd := exec.CommandContext(ctx, m.cfg.Binary, args...) cmd.Env = append(os.Environ(), "HOME="+m.cfg.HomeDir) cmd.Stdout = m.cfg.LogOutput cmd.Stderr = m.cfg.LogOutput if err := cmd.Start(); err != nil { return fmt.Errorf("start nym-socks5-client: %w", err) } m.cmd = cmd go func() { _ = cmd.Wait() }() deadline := time.Now().Add(m.cfg.StartupTimeout) for { dialCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) conn, err := (&net.Dialer{}).DialContext(dialCtx, "tcp", m.cfg.SocksAddr) cancel() if err == nil { _ = conn.Close() return nil } if time.Now().After(deadline) { m.Stop() return fmt.Errorf("nym-socks5-client did not open %s within %s", m.cfg.SocksAddr, m.cfg.StartupTimeout) } select { case <-ctx.Done(): m.Stop() return ctx.Err() case <-time.After(500 * time.Millisecond): } } } func (m *Manager) Stop() { m.mu.Lock() defer m.mu.Unlock() if m.cmd == nil || m.cmd.Process == nil { return } _ = m.cmd.Process.Signal(os.Interrupt) done := make(chan struct{}) go func() { _ = m.cmd.Wait() close(done) }() select { case <-done: case <-time.After(5 * time.Second): _ = m.cmd.Process.Kill() } m.cmd = nil }