diff --git a/CHANGELOG.md b/CHANGELOG.md index c7902f5..bf561a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- ## [Unreleased] +### Planned for v0.2.0 ### Planned for v0.2.0 - ngrok provider support - Custom subdomains - HTTPS support +## [v0.2.0] - 2025-11-29 + +### Added +- **Cloudflare Tunnel** support (`expose tunnel -P cloudflare`) [#21] +- `--provider/-P` flag (localtunnel, cloudflare) +- test coverage for provider + service layers + +### Changed +- Bump version to v0.2.0 +- Update README with providers table + examples +## [v0.2.0] - 2025-11-29 + +### Added +- **Cloudflare Tunnel** support (`expose tunnel -P cloudflare`) [#12] +- `--provider/-P` flag (localtunnel, cloudflare) +- Full test coverage for provider + service layers + +### Changed +- Bump version to v0.2.0 +- Update README with providers table + examples + --- ## [0.1.2] - 2025-11-10 @@ -58,6 +80,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- -[0.1.2]: https://github.com/kernelshard/expose/releases/tag/v0.1.2 +[v0.1.2]: https://github.com/kernelshard/expose/compare/v0.2.0...v0.1.2 [0.1.1]: https://github.com/kernelshard/expose/releases/tag/v0.1.1 [0.1.0]: https://github.com/kernelshard/expose/releases/tag/v0.1.0 diff --git a/README.md b/README.md index cfc3029..16e654d 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,10 @@ > Minimal CLI tool to expose your local dev server to the internet -**Expose** lets you share your `localhost` with the world — perfect for testing webhooks, demoing work, or debugging on mobile devices. Built as a lightweight alternative to ngrok, powered by LocalTunnel. - +**Expose** lets you share your `localhost` with the world — perfect for testing webhooks, demoing work, or debugging on mobile devices. Supports LocalTunnel and Cloudflare Tunnel. ## ✨ Features -- 🌐 **Instant public URLs** — Share localhost with one command + 🌐 **Multiple providers** — LocalTunnel + Cloudflare Tunnel - ⚡ **Zero signup** — No accounts, no registration required - � **Config management** — Save port settings per project - 📦 **Single binary** — No Node.js, Python, or runtime dependencies @@ -31,6 +30,10 @@ expose init expose tunnel ``` ++ # Or use Cloudflare Tunnel +```bash +expose tunnel -P cloudflare -p 3000 +``` --- ## 📦 Installation @@ -150,8 +153,6 @@ expose/ --- ## ⚠️ Known Limitations - -- **LocalTunnel only** — ngrok/Cloudflare support planned for v0.2.0 - **One tunnel per process** — Each `expose tunnel` command runs independently (can run multiple on different ports) - **No persistence** — Public URLs change on restart - **CLI-only** — No web UI or dashboard yet @@ -183,6 +184,7 @@ go test ./... -v -race -cover # Check coverage for specific packages go test ./internal/config -cover go test ./internal/tunnel -cover +go test ./internal/provider -cover ``` ### Build diff --git a/internal/cli/tunnel.go b/internal/cli/tunnel.go index 381cb4e..b2740e2 100644 --- a/internal/cli/tunnel.go +++ b/internal/cli/tunnel.go @@ -21,10 +21,15 @@ func newTunnelCmd() *cobra.Command { //RunE: runTunnelCmd, cmd := &cobra.Command{ Use: "tunnel", - Short: "Start a tunnel to expose local server", + Short: "Expose local server via tunnel", RunE: runTunnelCmd, } + // Define flags + // provider flag to specify provider e.g. expose tunnel --provider cloudflare + cmd.Flags().StringP("provider", "P", "localtunnel", "Tunnel provider: localtunnel, cloudflare, etc. defaults to localtunnel") + + // port flag to specify local port e.g. expose tunnel --port 8080 cmd.Flags().IntP("port", "p", 0, "Local port to expose (overrides config)") return cmd } @@ -53,17 +58,27 @@ func runTunnelCmd(cmd *cobra.Command, _ []string) error { return fmt.Errorf("invalid port %d (must be 1-65535)", port) } - return runTunnel(port) + // use provider flag shorthand -P to select provider + providerName, err := cmd.Flags().GetString("provider") + if err != nil { + return fmt.Errorf("invalid port %d (must be 1-65535)", port) + } + + return runTunnel(port, providerName) } // runTunnel sets up a reverse proxy to expose the local server // on the specified port. -func runTunnel(port int) error { - // - Create LocalTunnel provider - lt := provider.NewLocalTunnel(nil) +func runTunnel(port int, providerName string) error { + var svc *tunnel.Service + + switch providerName { + case "cloudflare": + svc = tunnel.NewService(provider.NewCloudFlare()) + default: + svc = tunnel.NewService(provider.NewLocalTunnel(nil)) - // - Wrap in service - svc := tunnel.NewService(lt) + } // Setup ctx & signal handling ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/provider/cloudflare.go b/internal/provider/cloudflare.go new file mode 100644 index 0000000..2279303 --- /dev/null +++ b/internal/provider/cloudflare.go @@ -0,0 +1,143 @@ +package provider + +import ( + "bufio" + "context" + "fmt" + "os/exec" + "regexp" + "sync" + "time" +) + +// Cloudflare implements the Provider interface for Cloudflare Tunnel +type Cloudflare struct { + cmd *exec.Cmd + mu sync.RWMutex + publicURL string + + // RequestTunnel is exported for test mocking + RequestTunnel func(ctx context.Context, port int, timeout time.Duration) (string, *exec.Cmd, error) +} + +// NewCloudFlare creates a new instance of Cloudflare provider +func NewCloudFlare() *Cloudflare { + return &Cloudflare{ + RequestTunnel: requestTunnel, // Use real implementation by default + } +} + +// Connect establishes a Cloudflare Tunnel to the specified local port +func (c *Cloudflare) Connect(ctx context.Context, localPort int) (string, error) { + timeout := 30 * time.Second + url, cmd, err := c.RequestTunnel(ctx, localPort, timeout) + if err != nil { + return "", err + } + + c.mu.Lock() + c.cmd = cmd + c.publicURL = url + c.mu.Unlock() + + return url, nil +} + +// Close terminates the Cloudflare Tunnel +func (c *Cloudflare) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + + // if cmd is running, kill the process + if c.cmd != nil && c.cmd.Process != nil { + err := c.cmd.Process.Kill() + // clear fields safely under write lock + c.cmd = nil + c.publicURL = "" + return err + } + return nil +} + +// PublicURL returns the public URL of the Cloudflare Tunnel +func (c *Cloudflare) PublicURL() string { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.publicURL +} + +// IsConnected checks if the Cloudflare Tunnel is active +func (c *Cloudflare) IsConnected() bool { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.cmd != nil && c.cmd.ProcessState == nil +} + +// Name returns the name of the provider +func (c *Cloudflare) Name() string { + return "Cloudflare" +} + +// requestTunnel starts the cloudflared process and retrieves the public URL +func requestTunnel(ctx context.Context, port int, timeout time.Duration) (string, *exec.Cmd, error) { + urlRegex := regexp.MustCompile(`https://[a-z0-9-]+\.trycloudflare\.com`) + + cmd := exec.CommandContext(ctx, "cloudflared", "tunnel", "--url", fmt.Sprintf("http://localhost:%d", port)) + + stderr, err := cmd.StderrPipe() + if err != nil { + return "", nil, fmt.Errorf("get stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return "", nil, fmt.Errorf("start cloudflared: %w", err) + } + + urlCh := make(chan string, 1) + errCh := make(chan error, 1) + + // Read stderr for URL + go func() { + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + line := scanner.Text() + fmt.Println(line) // logs + + if url := urlRegex.FindString(line); url != "" { + urlCh <- url + return + } + } + + // Handle scanner error or no URL found + if err := scanner.Err(); err != nil { + errCh <- fmt.Errorf("read stderr: %w", err) + } else { + errCh <- fmt.Errorf("cloudflared exited without providing URL") + } + }() + + // Wait for result with timeout + select { + case url := <-urlCh: + // Success - return cmd so caller can manage it + return url, cmd, nil + + case err := <-errCh: + _ = cmd.Process.Kill() + _ = cmd.Wait() + return "", nil, err + + case <-time.After(timeout): + _ = cmd.Process.Kill() + _ = cmd.Wait() + return "", nil, fmt.Errorf("timeout waiting for tunnel URL") + + case <-ctx.Done(): + _ = cmd.Process.Kill() + _ = cmd.Wait() + return "", nil, ctx.Err() + } +} diff --git a/internal/provider/cloudflare_test.go b/internal/provider/cloudflare_test.go new file mode 100644 index 0000000..8fdd548 --- /dev/null +++ b/internal/provider/cloudflare_test.go @@ -0,0 +1,84 @@ +package provider + +import ( + "context" + "errors" + "os/exec" + "testing" + "time" +) + +// TestCloudflare_Connect tests the Connect method of Cloudflare provider +func TestCloudflare_Connect(t *testing.T) { + cf := NewCloudFlare() + + // Mock RequestTunnel + cf.RequestTunnel = func(ctx context.Context, port int, timeout time.Duration) (string, *exec.Cmd, error) { + return "https://test-tunnel.trycloudflare.com", nil, nil + } + + url, err := cf.Connect(context.Background(), 3000) + if err != nil { + t.Fatalf("Connect() failed: %v", err) + } + + if url != "https://test-tunnel.trycloudflare.com" { + t.Errorf("got %s, want test URL", url) + } + + if cf.PublicURL() != url { + t.Errorf("PublicURL() = %s, want %s", cf.PublicURL(), url) + } +} + +// TestCloudflare_ConnectError tests the Connect method when RequestTunnel returns an error +func TestCloudflare_ConnectError(t *testing.T) { + cf := NewCloudFlare() + + cf.RequestTunnel = func(ctx context.Context, port int, timeout time.Duration) (string, *exec.Cmd, error) { + return "", nil, errors.New("tunnel creation failed") + } + + _, err := cf.Connect(context.Background(), 3000) + if err == nil { + t.Fatal("Expected error, got nil") + } + + if cf.PublicURL() != "" { + t.Errorf("PublicURL() should be empty, got %s", cf.PublicURL()) + } +} + +// TestCloudflare_Name tests the Name method of Cloudflare provider +func TestCloudflare_Name(t *testing.T) { + cf := NewCloudFlare() + if got := cf.Name(); got != "Cloudflare" { + t.Errorf("Name() = %s, want Cloudflare", got) + } +} + +// TestCloudflare_CloseBeforeConnect tests that Close can be called before Connect without error +func TestCloudflare_CloseBeforeConnect(t *testing.T) { + cf := NewCloudFlare() + if err := cf.Close(); err != nil { + t.Errorf("Close() before Connect error: %v", err) + } +} + +// TestCloudflare_ConnectTimeout tests the Connect method with a context timeout +func TestCloudflare_ConnectTimeout(t *testing.T) { + cf := NewCloudFlare() + // Mock RequestTunnel to simulate delay + cf.RequestTunnel = func(ctx context.Context, port int, timeout time.Duration) (string, *exec.Cmd, error) { + <-ctx.Done() + return "", nil, ctx.Err() + } + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + _, err := cf.Connect(ctx, 3000) + if err == nil { + t.Fatal("Expected timeout error, got nil") + } +} diff --git a/internal/tunnel/service_test.go b/internal/tunnel/service_test.go index 3aa8cc3..6558b81 100644 --- a/internal/tunnel/service_test.go +++ b/internal/tunnel/service_test.go @@ -159,3 +159,12 @@ func TestService_StartTwice(t *testing.T) { t.Error("already started error shall be returned") } } + +func TestService_Providername(t *testing.T) { + mock := &MockProvider{} + svc := NewService(mock) + + if got := svc.ProviderName(); got != "MockProvider" { + t.Errorf("ProviderName() = %s, want MockProvider", got) + } +} diff --git a/internal/version/version.go b/internal/version/version.go index 8763d1b..2e05502 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,9 +1,9 @@ package version var ( - Version = "v0.1.2" - GitCommit = "d30c483" - BuildDate = "2025-11-10" + Version = "v0.2.0" + GitCommit = "8fdff9e" + BuildDate = "2025-11-29" ) // GetVersion returns just the version string