package nym import ( "encoding/base64" "encoding/json" "fmt" "io" "io/fs" "os" "os/exec" "path/filepath" "runtime" "time" "golang.org/x/net/websocket" ) // Client sends payloads through the Nym mixnet via an embedded nym-client subprocess. type Client struct { journalistAddr string wsPort int dryRun bool process *exec.Cmd ws *websocket.Conn binFS fs.FS configDir string } func New(journalistAddr string, binFS fs.FS) *Client { return &Client{journalistAddr: journalistAddr, wsPort: 1978, binFS: binFS} } func NewDryRun(journalistAddr string) *Client { return &Client{journalistAddr: journalistAddr, dryRun: true} } // Start extracts the nym-client binary, inits config, starts the subprocess, // and connects to its WebSocket. No-op in dry-run mode. func (c *Client) Start() error { if c.dryRun { fmt.Printf("[dry-run] Nym client skipped — journalist: %s\n", c.journalistAddr) return nil } binPath, tmpDir, err := extractBin(c.binFS) if err != nil { return fmt.Errorf("extract nym-client: %w", err) } homeDir, _ := os.UserHomeDir() c.configDir = filepath.Join(homeDir, ".nymdrop-server") // Init (idempotent — ignore error if already initialised). exec.Command(binPath, "init", "--id", "nymdrop-server").Run() cmd := exec.Command(binPath, "run", "--id", "nymdrop-server", "--port", fmt.Sprintf("%d", c.wsPort)) cmd.Stdout = os.Stderr // route nym-client noise to our stderr cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { os.RemoveAll(tmpDir) return fmt.Errorf("nym-client start: %w", err) } c.process = cmd wsURL := fmt.Sprintf("ws://127.0.0.1:%d", c.wsPort) for i := 0; i < 120; i++ { ws, err := websocket.Dial(wsURL, "", "http://localhost/") if err == nil { c.ws = ws return nil } time.Sleep(500 * time.Millisecond) } return fmt.Errorf("nym-client websocket did not start in time") } type sendMsg struct { Type string `json:"type"` Message string `json:"message"` Recipient string `json:"recipient"` WithReplySurb bool `json:"withReplySurb"` } // Send delivers payload to the journalist's Nym address through the mixnet. func (c *Client) Send(payload []byte) error { if c.dryRun { fmt.Printf("[dry-run] payload received: %d bytes — would send to %s\n", len(payload), c.journalistAddr) return nil } msg := sendMsg{ Type: "send", Message: base64.StdEncoding.EncodeToString(payload), Recipient: c.journalistAddr, WithReplySurb: false, } raw, err := json.Marshal(msg) if err != nil { return fmt.Errorf("marshal: %w", err) } if err := websocket.Message.Send(c.ws, string(raw)); err != nil { return fmt.Errorf("ws send: %w", err) } return nil } // Stop terminates the subprocess. func (c *Client) Stop() { if c.ws != nil { c.ws.Close() } if c.process != nil && c.process.Process != nil { c.process.Process.Kill() } } func extractBin(binFS fs.FS) (string, string, error) { tmpDir, err := os.MkdirTemp("", "nymdrop-server-*") if err != nil { return "", "", err } name := "nym-client" if runtime.GOOS == "windows" { name += ".exe" } binPath := filepath.Join(tmpDir, name) data, err := fs.ReadFile(binFS, "bin/nym-client-linux-amd64") if err != nil { os.RemoveAll(tmpDir) return "", "", err } if err := os.WriteFile(binPath, data, 0700); err != nil { os.RemoveAll(tmpDir) return "", "", err } return binPath, tmpDir, nil } // Ensure unused import io is not flagged. var _ = io.EOF