package nym import ( "encoding/binary" "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 recipient []byte // parsed 96-byte wire form of journalistAddr wsPort int dryRun bool process *exec.Cmd ws *websocket.Conn binFS fs.FS configDir string } func New(journalistAddr string, binFS fs.FS) (*Client, error) { recipient, err := ParseRecipient(journalistAddr) if err != nil { return nil, fmt.Errorf("parse journalist nym address: %w", err) } return &Client{journalistAddr: journalistAddr, recipient: recipient, wsPort: 1978, binFS: binFS}, nil } 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") } // Send delivers payload to the journalist's Nym address through the mixnet // using nym-client's binary websocket protocol (tag || recipient || conn_id || // data_len || data). This avoids the JSON+base64 wrapping of the old text // protocol, which inflated every payload by ~4/3 before it ever reached the // nym-client's own 16MB hardcoded websocket message limit. 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 } req := make([]byte, 0, 1+len(c.recipient)+8+8+len(payload)) req = append(req, ReqSend) req = append(req, c.recipient...) req = binary.BigEndian.AppendUint64(req, 0) // connection_id: none req = binary.BigEndian.AppendUint64(req, uint64(len(payload))) req = append(req, payload...) if err := websocket.Message.Send(c.ws, req); 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