Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,6 +30,10 @@ expose init
expose tunnel
```

+ # Or use Cloudflare Tunnel
```bash
expose tunnel -P cloudflare -p 3000
```
---

## 📦 Installation
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
29 changes: 22 additions & 7 deletions internal/cli/tunnel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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())
Expand Down
143 changes: 143 additions & 0 deletions internal/provider/cloudflare.go
Original file line number Diff line number Diff line change
@@ -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()
}
}
84 changes: 84 additions & 0 deletions internal/provider/cloudflare_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
9 changes: 9 additions & 0 deletions internal/tunnel/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
6 changes: 3 additions & 3 deletions internal/version/version.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down