diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8d2bfa6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,71 @@ +# Conduit — Agent Guidelines + +## Project Overview + +Conduit is a self-hosted tunneling service (Go, single binary). A **server** (`conduit serve`) runs on a public host and routes incoming HTTP requests by subdomain to registered **tunnels**. A **client** (`conduit tunnel`) connects via WebSocket, receives forwarded traffic, and relays it to a local target using either an HTTP reverse-proxy or raw TCP dial. + +## Build & Test + +```bash +# Build all platforms +make build_local + +# Run tests +make tests # includes coverage + +# Lint +golangci-lint run +``` + +Go 1.25+ is required (`go.mod`). Nix devshell provides Go, gopls, golangci-lint. + +## Architecture at a Glance + +``` +server/server.go — Raw TCP listener, reads Host header, routes to tunnel or control API +tunnel/tunnel.go — Core Tunnel struct, WebSocket message loop, stream management +tunnel/forwarder.go — Forwarder interface; factory selects HTTP or TCP forwarder +tunnel/http_forwarder.go — httputil.ReverseProxy served over net.Pipe via multiConnListener +tunnel/tcp_forwarder.go — Direct net.Dial TCP forwarding +tunnel/stream.go — Stream interface (io.ReadWriteCloser + Source/Target) +store/store.go — In-memory request/response recorder with pub/sub (SSE) +web/web.go — Local tunnel monitor (port 8181), SSE endpoint +config/config.go — Reflection-based config from struct tags → flags + env vars +pkg/maps/map.go — Generic sync.RWMutex-guarded map +types/message.go — WebSocket message envelope (data | close) +``` + +## Code Conventions + +- **Go style**: standard `gofmt`, golangci-lint with `.golangci.toml` +- **Comment style**: Title Case heading above logical blocks (see root `AGENTS.md`) +- **Config**: add struct tags (`json`, `default`, `description`) to `ServerConfig` or `ClientConfig` — flags and env vars are auto-derived +- **Logging**: use `logrus` (`log` alias); structured fields preferred +- **Concurrency**: use `pkg/maps.Map` for shared maps; protect other shared state with `sync.Mutex` +- **Error handling**: return errors up; log at command/entry-point level. Use `fmt.Errorf` with `%w` for wrapping + +## Key Patterns + +1. **Raw TCP → HTTP upgrade**: The server reads from a raw TCP connection to inspect the Host header before deciding whether to handle as a control API request or tunnel the connection via WebSocket. +2. **Reconstructed connection**: After reading the HTTP request from the raw conn, `reconstructedConn` replays the consumed bytes so the full TCP stream can be forwarded. +3. **Forwarder abstraction**: `Forwarder` interface decouples tunnel transport from protocol handling. HTTP forwarder uses `net.Pipe` + `multiConnListener` to feed connections into a standard `http.Server`. +4. **Context-threaded records**: Request records are attached to context in `RecordRequest` and retrieved in `RecordResponse` via the `ModifyResponse` hook. + +## Adding a New Forwarder + +1. Implement `tunnel.Forwarder` interface (`Type()`, `Initialize()`, `Start()`) +2. Add a case in `tunnel.NewForwarder()` factory +3. Add corresponding `ForwarderType` const + +## File Locations + +| Concern | Files | +|---------|-------| +| CLI entry | `main.go`, `cmd/` | +| Server | `server/` | +| Tunneling | `tunnel/` | +| Config | `config/` | +| Storage | `store/` | +| Web UI | `web/`, `web/pages/` | +| Shared types | `types/` | +| Utilities | `pkg/maps/` | diff --git a/README.md b/README.md index d2be5bb..0d17fe9 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,143 @@ # Conduit -A lightweight tunneling service that enables secure connection forwarding through a remote server. +A lightweight tunneling service that exposes local services to the internet through a public server — similar to ngrok, but self-hosted. -**How:** Deploy Conduit on a public server (e.g., `https://conduit.example.com`) to create tunnels from local services to the internet. Simply point a tunnel to your local endpoint (such as `localhost:8000`) and assign it a custom subdomain identifier like `black-fox-123`. Your local service becomes instantly accessible at `https://black-fox-123.conduit.example.com`. - -**Key Benefits:** - -- Expose local development servers to the internet -- Share work-in-progress applications with clients or teammates -- Test webhooks and external integrations -- Bypass firewall restrictions for remote access - -Perfect for developers who need quick, temporary public access to local services without complex networking setup. - -### Example +Deploy Conduit on a public server (e.g., `https://conduit.example.com`), then create tunnels from your local machine. Point a tunnel at a local endpoint (e.g., `localhost:8000`) and it becomes accessible at a subdomain like `https://black-fox-123.conduit.example.com`. ![Example](https://gitea.va.reichard.io/evan/conduit/raw/branch/main/assets/example.gif) + +## Features + +- **HTTP & TCP tunneling** — automatically detected based on the target scheme +- **Subdomain routing** — each tunnel gets a unique subdomain on the server +- **Auto-generated tunnel names** — random `color-animal-number` names when none is provided +- **Local tunnel monitor** — web UI on `:8181` with SSE-based live request/response inspection +- **API key authentication** — simple shared-key auth between client and server +- **Minimal footprint** — single binary, no external dependencies + +## Architecture + +``` +┌──────────────┐ WebSocket ┌──────────────────┐ +│ conduit │◄──────────────────────────► │ conduit │ +│ tunnel │ (control + streams) │ serve │ +│ (client) │ │ (public server) │ +├──────────────┤ ├──────────────────┤ +│ HTTP Fwd or │ │ Raw TCP listener │ +│ TCP Fwd │ │ Subdomain router │ +├──────────────┤ │ WS upgrade │ +│ Tunnel │ └──────────────────┘ +│ Monitor :8181│ +└──────────────┘ +``` + +The server accepts raw TCP connections, reads the HTTP `Host` header to determine routing. Requests to the base domain hit the control API; requests to subdomains are forwarded over WebSocket to the matching tunnel client. The client then forwards traffic to the local target via either a reverse-proxy (HTTP) or direct TCP dial. + +## Quick Start + +### Prerequisites + +- Go 1.25+ (or Docker) + +### Build + +```bash +# Local build (all platforms) +make build_local + +# Docker +make docker_build_local +``` + +### Server + +Run the server on a publicly accessible host: + +```bash +conduit serve \ + --server https://conduit.example.com \ + --bind 0.0.0.0:8080 \ + --api_key your-secret-key +``` + +Or with Docker: + +```bash +docker run -p 8080:8080 \ + -e CONDUIT_SERVER=https://conduit.example.com \ + -e CONDUIT_API_KEY=your-secret-key \ + conduit:latest +``` + +### Client + +Create a tunnel to expose a local service: + +```bash +# HTTP tunnel (auto-generates name) +conduit tunnel \ + --server https://conduit.example.com \ + --api_key your-secret-key \ + --target http://localhost:8000 + +# Named TCP tunnel +conduit tunnel \ + --server https://conduit.example.com \ + --api_key your-secret-key \ + --name my-service \ + --target localhost:5432 +``` + +The local tunnel monitor is available at `http://localhost:8181` for HTTP tunnels. + +## Configuration + +All options can be set via CLI flags or environment variables (`CONDUIT_` prefix): + +### Server (`conduit serve`) + +| Flag | Env Var | Default | Description | +|------|---------|---------|-------------| +| `--server` | `CONDUIT_SERVER` | `http://localhost:8080` | Public server address | +| `--api_key` | `CONDUIT_API_KEY` | — | API key (required) | +| `--bind` | `CONDUIT_BIND` | `0.0.0.0:8080` | Listen address | +| `--log_level` | `CONDUIT_LOG_LEVEL` | `info` | Log level | +| `--log_format` | `CONDUIT_LOG_FORMAT` | `text` | Log format (`text` or `json`) | + +### Client (`conduit tunnel`) + +| Flag | Env Var | Default | Description | +|------|---------|---------|-------------| +| `--server` | `CONDUIT_SERVER` | `http://localhost:8080` | Conduit server address | +| `--api_key` | `CONDUIT_API_KEY` | — | API key (required) | +| `--name` | `CONDUIT_NAME` | (auto-generated) | Tunnel subdomain name | +| `--target` | `CONDUIT_TARGET` | — | Local target address (required) | +| `--log_level` | `CONDUIT_LOG_LEVEL` | `info` | Log level | +| `--log_format` | `CONDUIT_LOG_FORMAT` | `text` | Log format (`text` or `json`) | + +## Server API + +| Endpoint | Description | +|----------|-------------| +| `/_conduit/tunnel?tunnelName=&apiKey=` | WebSocket tunnel registration | +| `/_conduit/info?apiKey=` | JSON list of active tunnels | + +## Project Structure + +``` +├── cmd/ # Cobra CLI commands (root, serve, tunnel) +├── config/ # Configuration parsing & logging setup +├── server/ # TCP listener, subdomain routing, WebSocket upgrade +├── tunnel/ # Tunnel, Stream, and Forwarder abstractions +├── store/ # In-memory request/response recording for the monitor +├── web/ # Local tunnel monitor HTTP server & SSE streaming +├── types/ # Shared message types +├── pkg/maps/ # Generic concurrent map +├── build/ # Compiled binaries (gitignored in practice) +├── Dockerfile # Single-stage Docker build +└── Makefile # Build & release targets +``` + +## License + +See repository for license details. diff --git a/e2e_test.go b/e2e_test.go new file mode 100644 index 0000000..8a948e6 --- /dev/null +++ b/e2e_test.go @@ -0,0 +1,507 @@ +package main + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "strings" + "sync" + "testing" + "time" + + "reichard.io/conduit/config" + "reichard.io/conduit/server" + "reichard.io/conduit/store" + "reichard.io/conduit/tunnel" + "reichard.io/conduit/web" +) + +// ---------- Helpers ---------- + +// startConduitServer creates and starts a conduit server on a random port. +// Returns the server address (host:port) and a cancel func for teardown. +func startConduitServer(t *testing.T, apiKey string) (string, context.CancelFunc) { + t.Helper() + + // Find Free Port + port := getFreePort(t) + bindAddr := fmt.Sprintf("127.0.0.1:%d", port) + serverAddr := fmt.Sprintf("http://%s", bindAddr) + + cfg := &config.ServerConfig{ + BaseConfig: config.BaseConfig{ + ServerAddress: serverAddr, + APIKey: apiKey, + LogLevel: "error", + LogFormat: "text", + }, + BindAddress: bindAddr, + } + + ctx, cancel := context.WithCancel(context.Background()) + srv, err := server.NewServer(ctx, cfg) + if err != nil { + cancel() + t.Fatalf("failed to create server: %v", err) + } + + // Start Server in Background + errCh := make(chan error, 1) + go func() { errCh <- srv.Start() }() + + // Wait for Server to Accept + waitForPort(t, bindAddr, 3*time.Second) + + // Check Early Errors + select { + case err := <-errCh: + cancel() + t.Fatalf("server exited early: %v", err) + default: + } + + return bindAddr, cancel +} + +// startHTTPTarget creates a simple HTTP server that echoes request info. +func startHTTPTarget(t *testing.T) (string, context.CancelFunc) { + t.Helper() + + port := getFreePort(t) + addr := fmt.Sprintf("127.0.0.1:%d", port) + + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Test-Header", "present") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "echo: %s %s", r.Method, r.URL.Path) + }) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }) + mux.HandleFunc("/post", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "received: %s", string(body)) + }) + + srv := &http.Server{Addr: addr, Handler: mux} + ctx, cancel := context.WithCancel(context.Background()) + + go func() { srv.ListenAndServe() }() + go func() { <-ctx.Done(); srv.Close() }() + + waitForPort(t, addr, 3*time.Second) + + return addr, cancel +} + +// startTCPEchoTarget creates a TCP server that echoes back whatever it receives. +func startTCPEchoTarget(t *testing.T) (string, context.CancelFunc) { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to start tcp echo: %v", err) + } + addr := listener.Addr().String() + + ctx, cancel := context.WithCancel(context.Background()) + + go func() { + <-ctx.Done() + listener.Close() + }() + + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + io.Copy(c, c) + }(conn) + } + }() + + return addr, cancel +} + +// connectTunnel creates a conduit tunnel client and starts it. +func connectTunnel(t *testing.T, serverAddr, targetAddr, tunnelName, apiKey string) context.CancelFunc { + t.Helper() + + cfg := &config.ClientConfig{ + BaseConfig: config.BaseConfig{ + ServerAddress: fmt.Sprintf("http://%s", serverAddr), + APIKey: apiKey, + LogLevel: "error", + LogFormat: "text", + }, + TunnelName: tunnelName, + TunnelTarget: targetAddr, + } + + // Create Tunnel Store + tunnelStore := store.NewTunnelStore(100) + + // Create Forwarder + forwarder, err := tunnel.NewForwarder(cfg.TunnelTarget, tunnelStore) + if err != nil { + t.Fatalf("failed to create forwarder: %v", err) + } + + var wg sync.WaitGroup + ctx, cancel := context.WithCancel(context.Background()) + + // Start Forwarder + wg.Add(1) + go func() { + defer wg.Done() + forwarder.Start(ctx) + }() + + // Create & Start Tunnel + tun, err := tunnel.NewClientTunnel(cfg, forwarder) + if err != nil { + cancel() + t.Fatalf("failed to create tunnel: %v", err) + } + + wg.Add(1) + go func() { + defer wg.Done() + tun.Start(ctx) + }() + + // Start Web Server + webServer := web.NewWebServer(tunnelStore) + wg.Add(1) + go func() { + defer wg.Done() + webServer.Start(ctx) + }() + + // Brief Settle Time + time.Sleep(100 * time.Millisecond) + + cleanup := func() { + cancel() + wg.Wait() + } + return cleanup +} + +// sendHTTPViaTunnel sends an HTTP request through the conduit server to a tunnel. +func sendHTTPViaTunnel(t *testing.T, serverAddr, tunnelName, method, path, body string) *http.Response { + t.Helper() + + url := fmt.Sprintf("http://%s%s", serverAddr, path) + var bodyReader io.Reader + if body != "" { + bodyReader = strings.NewReader(body) + } + + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + // Route via Subdomain + req.Host = fmt.Sprintf("%s.%s", tunnelName, serverAddr) + + client := &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{DisableKeepAlives: true}, + } + resp, err := client.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + return resp +} + +func readBody(t *testing.T, resp *http.Response) string { + t.Helper() + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + return string(b) +} + +func getFreePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to get free port: %v", err) + } + port := l.Addr().(*net.TCPAddr).Port + l.Close() + return port +} + +func waitForPort(t *testing.T, addr string, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", addr, 100*time.Millisecond) + if err == nil { + conn.Close() + return + } + time.Sleep(25 * time.Millisecond) + } + t.Fatalf("port %s not ready after %s", addr, timeout) +} + +// ---------- Tests ---------- + +func TestHTTPTunnelRoundTrip(t *testing.T) { + apiKey := "test-key-http" + + // Start Target HTTP Server + targetAddr, stopTarget := startHTTPTarget(t) + defer stopTarget() + + // Start Conduit Server + serverAddr, stopServer := startConduitServer(t, apiKey) + defer stopServer() + + // Connect Tunnel + stopTunnel := connectTunnel(t, serverAddr, fmt.Sprintf("http://%s", targetAddr), "http-test", apiKey) + defer stopTunnel() + + // GET / + resp := sendHTTPViaTunnel(t, serverAddr, "http-test", "GET", "/", "") + body := readBody(t, resp) + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + if !strings.Contains(body, "echo: GET /") { + t.Errorf("unexpected body: %s", body) + } + + // GET /health + resp = sendHTTPViaTunnel(t, serverAddr, "http-test", "GET", "/health", "") + body = readBody(t, resp) + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + if body != "ok" { + t.Errorf("expected 'ok', got %q", body) + } +} + +func TestHTTPTunnelPOST(t *testing.T) { + apiKey := "test-key-post" + + // Start Target HTTP Server + targetAddr, stopTarget := startHTTPTarget(t) + defer stopTarget() + + // Start Conduit Server + serverAddr, stopServer := startConduitServer(t, apiKey) + defer stopServer() + + // Connect Tunnel + stopTunnel := connectTunnel(t, serverAddr, fmt.Sprintf("http://%s", targetAddr), "post-test", apiKey) + defer stopTunnel() + + // POST /post + resp := sendHTTPViaTunnel(t, serverAddr, "post-test", "POST", "/post", "hello world") + body := readBody(t, resp) + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + if !strings.Contains(body, "received: hello world") { + t.Errorf("unexpected body: %s", body) + } +} + +func TestUnknownTunnelReturns404(t *testing.T) { + apiKey := "test-key-404" + + // Start Conduit Server + serverAddr, stopServer := startConduitServer(t, apiKey) + defer stopServer() + + // Request to Non-Existent Tunnel + resp := sendHTTPViaTunnel(t, serverAddr, "no-such-tunnel", "GET", "/", "") + body := readBody(t, resp) + + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected 404, got %d", resp.StatusCode) + } + if !strings.Contains(body, "unknown tunnel") { + t.Errorf("expected 'unknown tunnel' error, got: %s", body) + } +} + +func TestDuplicateTunnelNameRejected(t *testing.T) { + apiKey := "test-key-dup" + + // Start Target HTTP Server + targetAddr, stopTarget := startHTTPTarget(t) + defer stopTarget() + + // Start Conduit Server + serverAddr, stopServer := startConduitServer(t, apiKey) + defer stopServer() + + // Connect First Tunnel + stopTunnel1 := connectTunnel(t, serverAddr, fmt.Sprintf("http://%s", targetAddr), "dup-test", apiKey) + defer stopTunnel1() + + // Attempt Duplicate — this should fail at WebSocket dial + cfg := &config.ClientConfig{ + BaseConfig: config.BaseConfig{ + ServerAddress: fmt.Sprintf("http://%s", serverAddr), + APIKey: apiKey, + LogLevel: "error", + LogFormat: "text", + }, + TunnelName: "dup-test", + TunnelTarget: fmt.Sprintf("http://%s", targetAddr), + } + + tunnelStore := store.NewTunnelStore(100) + forwarder, err := tunnel.NewForwarder(cfg.TunnelTarget, tunnelStore) + if err != nil { + t.Fatalf("failed to create forwarder: %v", err) + } + + _, err = tunnel.NewClientTunnel(cfg, forwarder) + if err == nil { + t.Error("expected error for duplicate tunnel name, got nil") + } +} + +func TestUnauthorizedControlAccess(t *testing.T) { + apiKey := "test-key-auth" + + // Start Conduit Server + serverAddr, stopServer := startConduitServer(t, apiKey) + defer stopServer() + + // Request Info with Wrong API Key + url := fmt.Sprintf("http://%s/_conduit/info?apiKey=wrong-key", serverAddr) + req, _ := http.NewRequest("GET", url, nil) + req.Host = serverAddr + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", resp.StatusCode) + } +} + +func TestInfoEndpointListsTunnels(t *testing.T) { + apiKey := "test-key-info" + + // Start Target HTTP Server + targetAddr, stopTarget := startHTTPTarget(t) + defer stopTarget() + + // Start Conduit Server + serverAddr, stopServer := startConduitServer(t, apiKey) + defer stopServer() + + // Connect Tunnel + stopTunnel := connectTunnel(t, serverAddr, fmt.Sprintf("http://%s", targetAddr), "info-test", apiKey) + defer stopTunnel() + + // Query Info Endpoint + url := fmt.Sprintf("http://%s/_conduit/info?apiKey=%s", serverAddr, apiKey) + req, _ := http.NewRequest("GET", url, nil) + req.Host = serverAddr + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + body := readBody(t, resp) + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + if !strings.Contains(body, "info-test") { + t.Errorf("expected tunnel 'info-test' in response: %s", body) + } +} + +func TestMultipleTunnelsRouteCorrectly(t *testing.T) { + apiKey := "test-key-multi" + + // Start Two Separate Target Servers + target1Addr, stopTarget1 := startHTTPTarget(t) + defer stopTarget1() + + port2 := getFreePort(t) + addr2 := fmt.Sprintf("127.0.0.1:%d", port2) + mux2 := http.NewServeMux() + mux2.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "target-two") + }) + srv2 := &http.Server{Addr: addr2, Handler: mux2} + go srv2.ListenAndServe() + defer srv2.Close() + waitForPort(t, addr2, 3*time.Second) + + // Start Conduit Server + serverAddr, stopServer := startConduitServer(t, apiKey) + defer stopServer() + + // Connect Two Tunnels + stopTunnel1 := connectTunnel(t, serverAddr, fmt.Sprintf("http://%s", target1Addr), "multi-one", apiKey) + defer stopTunnel1() + + stopTunnel2 := connectTunnel(t, serverAddr, fmt.Sprintf("http://%s", addr2), "multi-two", apiKey) + defer stopTunnel2() + + // Request to First Tunnel + resp1 := sendHTTPViaTunnel(t, serverAddr, "multi-one", "GET", "/", "") + body1 := readBody(t, resp1) + if !strings.Contains(body1, "echo: GET /") { + t.Errorf("tunnel one unexpected body: %s", body1) + } + + // Request to Second Tunnel + resp2 := sendHTTPViaTunnel(t, serverAddr, "multi-two", "GET", "/", "") + body2 := readBody(t, resp2) + if body2 != "target-two" { + t.Errorf("tunnel two expected 'target-two', got: %s", body2) + } +} + +func TestServerGracefulShutdown(t *testing.T) { + apiKey := "test-key-shutdown" + + // Start Conduit Server + serverAddr, stopServer := startConduitServer(t, apiKey) + + // Cancel Server + stopServer() + + // Verify Port Is Closed + time.Sleep(200 * time.Millisecond) + conn, err := net.DialTimeout("tcp", serverAddr, 500*time.Millisecond) + if err == nil { + conn.Close() + t.Error("expected server port to be closed after shutdown") + } +} diff --git a/flake.lock b/flake.lock index 9aa7f11..dedd329 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1760038930, - "narHash": "sha256-Oncbh0UmHjSlxO7ErQDM3KM0A5/Znfofj2BSzlHLeVw=", + "lastModified": 1777578337, + "narHash": "sha256-Ad49moKWeXtKBJNy2ebiTQUEgdLyvGmTeykAQ9xM+Z4=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "0b4defa2584313f3b781240b29d61f6f9f7e0df3", + "rev": "15f4ee454b1dce334612fa6843b3e05cf546efab", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 7ce2df7..caeec10 100644 --- a/flake.nix +++ b/flake.nix @@ -6,8 +6,14 @@ flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { self, nixpkgs, flake-utils }: - flake-utils.lib.eachDefaultSystem (system: + outputs = + { self + , nixpkgs + , flake-utils + , + }: + flake-utils.lib.eachDefaultSystem ( + system: let pkgs = nixpkgs.legacyPackages.${system}; in @@ -15,6 +21,7 @@ devShells.default = pkgs.mkShell { packages = with pkgs; [ go + gopls golangci-lint ]; shellHook = '' diff --git a/pkg/maps/map.go b/pkg/maps/map.go index 3cbedb7..c18be59 100644 --- a/pkg/maps/map.go +++ b/pkg/maps/map.go @@ -42,6 +42,8 @@ func (m *Map[K, V]) HasKey(key K) bool { func (m *Map[K, V]) Entries() iter.Seq2[K, V] { return func(yield func(K, V) bool) { + m.mu.RLock() + defer m.mu.RUnlock() for k, v := range m.items { if !yield(k, v) { return diff --git a/server/server.go b/server/server.go index b1ef1ce..38a057c 100644 --- a/server/server.go +++ b/server/server.go @@ -70,11 +70,23 @@ func (s *Server) Start() error { } defer listener.Close() + // Context Cancellation - Close the listener when the context is cancelled + // so that Accept() unblocks and the loop exits cleanly. + go func() { + <-s.ctx.Done() + listener.Close() + }() + // Start Listening log.Infof("conduit server listening on %s", s.cfg.BindAddress) for { conn, err := listener.Accept() if err != nil { + // Expected Error on Shutdown + if s.ctx.Err() != nil { + log.Info("conduit server shutting down") + return nil + } log.WithError(err).Error("error accepting connection") continue }