diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..8aea8bb --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,35 @@ +name: Tests + +on: + push: + branches: [ main, feature/* ] + pull_request: + branches: [ main ] + +jobs: + test: + name: Test Suite + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: Download Dependencies + run: go mod download + + - name: Run Tests + run: go test -v ./... -v -race -coverprofile=coverage.out + + - name: Upload Coverage Report + uses: codecov/codecov-action@v5 + with: + files: coverage.out + flags: unittests + name: codecov-umbrella + diff --git a/.gitignore b/.gitignore index cb1fa35..93aa848 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,5 @@ # Binaries -# ignore the top-level binary named `expose` (anchor to repo root so we don't -# accidentally ignore files or directories named `expose` elsewhere, e.g. `cmd/expose`) -/expose +expose *.exe *.so *.dylib @@ -16,6 +14,7 @@ vendor/ # Config .expose/ .env* +.expose.yml # IDEs .vscode/ diff --git a/README.md b/README.md index 9064a35..40bc40f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # expose +[![Tests](https://github.com/kernelshard/expose/actions/workflows/test.yml/badge.svg)](https://github.com/kernelshard/expose/actions/workflows/test.yml) +[![Go Report Card](https://goreportcard.com/badge/github.com/kernelshard/expose)](https://goreportcard.com/report/github.com/kernelshard/expose) + > Expose localhost to the internet. Minimal. Fast. Open Source. **expose** is a lightweight Golang CLI that makes sharing your local development server effortless. @@ -15,14 +18,14 @@ ``` # Clone the repository -git clone https://github.com/yourusername/expose.git +git clone https://github.com/kernelshard/expose.git cd expose # Build go build -o expose cmd/expose/main.go # Optional: Install globally -go install github.com/yourusername/expose/cmd/expose@latest +go install github.com/kernelshard/expose/cmd/expose@latest ``` ## 🚀 Quick Start @@ -81,7 +84,12 @@ expose/ ├── cmd/expose/ # CLI entry point └── internal/ ├── cli/ # Command implementations - └── config/ # Configuration management + ├── config/ # Configuration management + ├── env/ # Environment handling + ├── git/ # Git integration + ├── preview/ # Preview functionality + ├── state/ # State management + └── tunnel/ # Tunnel management ``` **Design Principles:** diff --git a/cmd/expose/main.go b/cmd/expose/main.go index 676498d..7431a28 100644 --- a/cmd/expose/main.go +++ b/cmd/expose/main.go @@ -1,4 +1,4 @@ -package expose +package main import ( "fmt" diff --git a/internal/config/config.go b/internal/config/config.go index c35fa67..d0495ff 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,7 +8,7 @@ import ( "gopkg.in/yaml.v3" ) -const DefaultConfigFile = "expose.yaml" +const DefaultConfigFile = ".expose.yml" // Config represents the structure of the configuration file. type Config struct { diff --git a/internal/tunnel/manager.go b/internal/tunnel/manager.go new file mode 100644 index 0000000..100c2ef --- /dev/null +++ b/internal/tunnel/manager.go @@ -0,0 +1,173 @@ +package tunnel + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "sync" + "time" +) + +// Tunneler represents a tunnel that can be started and stopped, and +// provides a public URL once ready. +type Tunneler interface { + Start(ctx context.Context) error + Close() error + Ready() <-chan struct{} + PublicURL() string +} + +// Manager manages the lifecycle of a tunneler. +type Manager struct { + localPort int + publicURL string + listener net.Listener + server *http.Server + ready chan struct{} + mu sync.RWMutex +} + +// Ensure Manager implements Tunneler +var _ Tunneler = (*Manager)(nil) + +// NewManager creates a new Manager instance. +func NewManager(port int) *Manager { + return &Manager{ + localPort: port, + ready: make(chan struct{}), + } +} + +// Start initializes the tunnel and begins listening for incoming connections. +func (m *Manager) Start(ctx context.Context) error { + // respect context cancellation; exit early if already cancelled + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + // Create a Listener + listener, err := net.Listen("tcp", ":0") // Listen on any random available port + if err != nil { + return fmt.Errorf("failed to create listener: %w", err) + } + + // Set the public URL and listener (concurrency-safe) + port := listener.Addr().(*net.TCPAddr).Port + m.mu.Lock() + m.listener = listener + m.publicURL = fmt.Sprintf("http://localhost:%d", port) + m.mu.Unlock() + + // Signal that the tunnel is ready + // closing the channel indicates readiness as per Go idioms + // concurrency-safe + // we can do heavy operations here before signaling readiness if needed + // e.g., establishing connections to remote servers + close(m.ready) + + // Create HTTP server to handle incoming requests + server := &http.Server{ + Handler: http.HandlerFunc(m.proxyHandler), + } + + // Set server (concurrency-safe) + m.mu.Lock() + m.server = server + m.mu.Unlock() + + // Auto-close & clean up on context cancellation + go func() { + <-ctx.Done() + m.Close() + }() + + // Serve incoming connections(blocking call) + // ends when closed from outside (e.g., via m.Close()) or context cancellation + if err := m.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("server error: %w", err) + } + + return nil +} + +// Ready returns a channel that is closed when the tunnel is ready. +// before the tunnel is ready it will block on reads. +func (m *Manager) Ready() <-chan struct{} { + return m.ready +} + +// Close shuts down the tunnel and cleans up resources. +func (m *Manager) Close() error { + m.mu.Lock() + defer m.mu.Unlock() + + var err error + + // Shutdown the http server if it's running + if m.server != nil { + err = m.server.Close() + } else if m.listener != nil { + err = m.listener.Close() + } + + return err + +} + +// PublicURL returns the public URL of the tunnel. +// for concurrency safety we read under a lock. +func (m *Manager) PublicURL() string { + m.mu.RLock() + defer m.mu.RUnlock() + return m.publicURL +} + +// proxyHandler forwards incoming HTTP requests to the local server. +// It dials the local server, forwards the request, and writes back the response. +// If any step fails, it responds with an appropriate HTTP error. +func (m *Manager) proxyHandler(w http.ResponseWriter, r *http.Request) { + + // create connection to local server + target := fmt.Sprintf("localhost:%d", m.localPort) + conn, err := net.DialTimeout("tcp", target, 5*time.Second) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to connect localhost:%d - is your server running?", m.localPort), http.StatusBadGateway) + return + } + + defer conn.Close() + + // Send request to local server + if err := r.Write(conn); err != nil { + http.Error(w, "Failed to forward request", http.StatusBadGateway) + return + } + + // Read response from local server + resp, err := http.ReadResponse(bufio.NewReader(conn), r) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to read response from local server: %v", err), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + // Copy response headers + for key, values := range resp.Header { + for _, value := range values { + w.Header().Add(key, value) + } + } + + // Copy response status code and body + w.WriteHeader(resp.StatusCode) + + // partial response sent anyway as headers are already written + io.Copy(w, resp.Body) // nolint:errcheck + +} diff --git a/internal/tunnel/manager_test.go b/internal/tunnel/manager_test.go new file mode 100644 index 0000000..d6c2b2c --- /dev/null +++ b/internal/tunnel/manager_test.go @@ -0,0 +1,311 @@ +package tunnel + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" +) + +func TestManager(t *testing.T) { + port := 3000 + m := NewManager(port) + + if m == nil { + t.Fatal("Expected Manager instance, got nil") + } + + if m.localPort != port { + t.Fatalf("Expected localPort to be %d, got %d", port, m.localPort) + } + + if m.ready == nil { + t.Errorf("ready channel not initialized") + } + + // PublicURL should be empty initially + if m.PublicURL() != "" { + t.Errorf("Expected empty PublicURL, got %s", m.PublicURL()) + } +} + +// TestManager_PublicURL verifies thread-safe access to PublicURL. +func TestManager_PublicURL(t *testing.T) { + m := NewManager(3000) + + // initially empty + if url := m.PublicURL(); url != "" { + t.Errorf("expected empty URL, got %s", url) + } + + // Check URL (simulate Start behavior) + m.mu.Lock() + m.publicURL = "http://localhost:8080" + m.mu.Unlock() + + if url := m.PublicURL(); url != "http://localhost:8080" { + t.Errorf("expected URL to be http://localhost:8080, got %s", url) + } +} + +// TestManager_Ready verifies the Ready channel behavior. +func TestManager_Ready(t *testing.T) { + m := NewManager(3000) + + // Initially should block + select { + case <-m.Ready(): + t.Error("read channel closed before Start()") + default: + // expected: channel is open + } + + // Close it (simulate Start behavior) + close(m.ready) + + // Now it should be closed & non-blocking + select { + case <-m.Ready(): + // expected: channel is closed + case <-time.After(110 * time.Millisecond): + t.Error("read channel did not close after Start()") + } + +} + +// TestManager_Start_Success tests successful Start of the Manager. +func TestManager_Start_Success(t *testing.T) { + m := NewManager(3000) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + errCh := make(chan error, 1) + // Start in goroutine to avoid blocking + go func() { + errCh <- m.Start(ctx) + }() + + // Wait for Ready + select { + case <-m.Ready(): + // expected + case <-time.After(500 * time.Millisecond): + t.Fatal("timeout waiting for Ready signal from Start()") + } + + // Verify publicURL is set + publicURL := m.PublicURL() + if publicURL == "" { + t.Error("expected PublicURL to be set after Start()") + } + + t.Logf("Tunnel URL: %s", publicURL) + + // cancel & wait for graceful shutdown + cancel() + + select { + case err := <-errCh: + // Expect no error on normal shutdown + if err != nil && !errors.Is(err, http.ErrServerClosed) { + t.Errorf("expected nil error on shutdown, got %v", err) + } + case <-time.After(700 * time.Millisecond): + t.Error("timeout waiting for Start() to return after context cancellation") + } +} + +// TestManager_Start_PreCancelledContext veries early exit on cancelled context. +func TestManager_Start_PreCancelledContext(t *testing.T) { + m := NewManager(3000) + // Pre-cancel context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + // Start should return immediately with context.Canceled error + err := m.Start(ctx) + if err == nil { + t.Error("expected error from pre-cancelled context, got nil") + } + if !errors.Is(err, context.Canceled) { + t.Errorf("expected context.Canceled error, got %v", err) + } +} + +// TestManager_Close verifies resource cleanup on Close. +func TestManager_Close(t *testing.T) { + m := NewManager(3000) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start the manager + go m.Start(ctx) + + // Wait for Ready + <-m.Ready() + + // Close should not return on first call + if err := m.Close(); err != nil { + t.Errorf("expected nil error on Close(), got %v", err) + } + + // Subsequent close should be safe + _ = m.Close() + +} + +// TestManager_Close_BeforeStart verifies close is safe before Start is called. +func TestManager_Close_BeforeStart(t *testing.T) { + m := NewManager(3000) + + // Close before start should not panic or error + if err := m.Close(); err != nil { + t.Errorf("expected nil error on Close() before Start(), got %v", err) + } +} + +// TestManager_InterfaceCompliance +func TestManager_InterfaceCompliance(t *testing.T) { + var _ Tunneler = (*Manager)(nil) +} + +// TestManager_ConcurrentURLAccess verifies thread safety +func TestManager_ConcurrentURLAccess(t *testing.T) { + m := NewManager(3000) + + // Concurrent writest + var wg sync.WaitGroup + for i := range 10 { + wg.Add(1) + go func(i int) { + defer wg.Done() + m.mu.Lock() + m.publicURL = fmt.Sprintf("http://localhost:%d", 8000+i) + m.mu.Unlock() + }(i) + } + + // Concurrent reads + for range 10 { + wg.Go(func() { + _ = m.PublicURL() + }) + } + + wg.Wait() +} + +// TestManager_ProxyHandler_NoLocalServer verifies error handling when local server is down. +func TestManager_ProxyHandler_NoLocalServer(t *testing.T) { + m := NewManager(65000) // assuming nothing is running on this high port + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + m.proxyHandler(w, req) + + // It should return 502 Bad Gateway if local server is unreachable + if w.Code != http.StatusBadGateway { + t.Errorf("expected status 502 Bad Gateway, got %d", w.Code) + } + + body := w.Body.String() + if body == "" { + t.Error("expected error message in response body, got empty body") + } + t.Logf("Error response: %s", body) +} + +// TestManager_ProxyHandler_WithLocalServer tests the proxy handler when the local server is running. +func TestManager_ProxyHandler_WithLocalServer(t *testing.T) { + // values for verification + testBodyStr := "Hello from local server" + testHeaderStr := "X-Test-Header" + testHeaderValue := "test-value" + + // Create a test local server + localServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set(testHeaderStr, testHeaderValue) + w.WriteHeader(http.StatusOK) + w.Write([]byte(testBodyStr)) + })) + + // closing is must to avoid resource leaks + defer localServer.Close() + + // Extract port from test server + _, portStr, _ := net.SplitHostPort(localServer.Listener.Addr().String()) + var port int + fmt.Sscanf(portStr, "%d", &port) + + // Create manager pointing to the test local server port to simulate proxying + m := NewManager(port) + + // Test the proxy handler + req := httptest.NewRequest("GET", "/test", nil) + w := httptest.NewRecorder() + + m.proxyHandler(w, req) + + // It should return 200 OK + if w.Code != http.StatusOK { + t.Errorf("expected status 200 OK, got %d", w.Code) + } + + if w.Header().Get(testHeaderStr) != testHeaderValue { + t.Errorf( + "expected %s to be '%s', got '%s'", testHeaderStr, testHeaderValue, + w.Header().Get(testHeaderStr), + ) + } + + body := w.Body.String() + if body != testBodyStr { + t.Errorf("expected body '%s', got '%s'", testBodyStr, body) + } + +} + +func TestManager_FullLifeCycle(t *testing.T) { + m := NewManager(3000) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + // Start the manager + go m.Start(ctx) + + // Wait for Ready + + select { + case <-m.Ready(): + // expected + case <-time.After(1 * time.Second): + t.Fatal("timeout waiting for Ready signal from Start()") + } + + // Verify URL is set + if m.PublicURL() == "" { + t.Error("publicURL not set after Start()") + } + + // // Close explicitly + // if err := m.Close(); err != nil { + // t.Errorf("error on Close(): %v", err) + // } + err := m.Close() + + // Note: Close() may return "use of closed network connection" + // because server.Close() already closed the listener. + // This is expected behavior in errors.Join() + if err != nil { + t.Errorf("unexpected error on Close(): %v", err) + } +}