diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/handler/static.go | 19 | ||||
| -rw-r--r-- | internal/handler/submit.go | 33 | ||||
| -rw-r--r-- | internal/nolog/middleware.go | 15 | ||||
| -rw-r--r-- | internal/nym/client.go | 141 | ||||
| -rw-r--r-- | internal/relay/relay.go | 47 |
5 files changed, 255 insertions, 0 deletions
diff --git a/internal/handler/static.go b/internal/handler/static.go new file mode 100644 index 0000000..bf2aaec --- /dev/null +++ b/internal/handler/static.go @@ -0,0 +1,19 @@ +package handler + +import ( + "net/http" +) + +func Static(dir string) http.Handler { + fs := http.FileServer(http.Dir(dir)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Security headers on every response. + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Cache-Control", "no-store") + fs.ServeHTTP(w, r) + }) +} diff --git a/internal/handler/submit.go b/internal/handler/submit.go new file mode 100644 index 0000000..15251cf --- /dev/null +++ b/internal/handler/submit.go @@ -0,0 +1,33 @@ +package handler + +import ( + "net/http" + "nymdrop/internal/relay" +) + +type Submit struct { + relay *relay.Relay +} + +func NewSubmit(r *relay.Relay) *Submit { + return &Submit{relay: r} +} + +func (s *Submit) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024) + defer r.Body.Close() + + if err := s.relay.Forward(r.Body); err != nil { + // Generic error — reveal nothing about internals. + http.Error(w, "error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) +} diff --git a/internal/nolog/middleware.go b/internal/nolog/middleware.go new file mode 100644 index 0000000..c11ed55 --- /dev/null +++ b/internal/nolog/middleware.go @@ -0,0 +1,15 @@ +package nolog + +import "net/http" + +// Strip all identifying information from every request before any handler sees it. +func Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Header.Del("X-Forwarded-For") + r.Header.Del("X-Real-IP") + r.Header.Del("User-Agent") + r.Header.Del("Referer") + r.RemoteAddr = "0.0.0.0:0" + next.ServeHTTP(w, r) + }) +} diff --git a/internal/nym/client.go b/internal/nym/client.go new file mode 100644 index 0000000..89fcf52 --- /dev/null +++ b/internal/nym/client.go @@ -0,0 +1,141 @@ +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 diff --git a/internal/relay/relay.go b/internal/relay/relay.go new file mode 100644 index 0000000..9cd0366 --- /dev/null +++ b/internal/relay/relay.go @@ -0,0 +1,47 @@ +package relay + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "nymdrop/internal/nym" +) + +const maxPayloadBytes = 10 * 1024 * 1024 // 10 MB + +// Relay receives ciphertext and forwards it through Nym. Nothing is written to disk. +type Relay struct { + nym *nym.Client +} + +func New(client *nym.Client) *Relay { + return &Relay{nym: client} +} + +// Forward reads ciphertext from r, sends it over Nym, then zeros the buffer. +func (r *Relay) Forward(body io.Reader) error { + buf := make([]byte, maxPayloadBytes) + n, err := io.ReadFull(body, buf) + if err != nil && err != io.ErrUnexpectedEOF { + return fmt.Errorf("read payload: %w", err) + } + payload := buf[:n] + defer zeroBytes(payload) + + // Prepend a random nonce so identical submissions look different on the wire. + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + return fmt.Errorf("nonce: %w", err) + } + packet := append([]byte(hex.EncodeToString(nonce)+":"), payload...) + defer zeroBytes(packet) + + return r.nym.Send(packet) +} + +func zeroBytes(b []byte) { + for i := range b { + b[i] = 0 + } +} |
