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
35 changes: 35 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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

5 changes: 2 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -16,6 +14,7 @@ vendor/
# Config
.expose/
.env*
.expose.yml

# IDEs
.vscode/
Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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:**
Expand Down
2 changes: 1 addition & 1 deletion cmd/expose/main.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package expose
package main

import (
"fmt"
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
173 changes: 173 additions & 0 deletions internal/tunnel/manager.go
Original file line number Diff line number Diff line change
@@ -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

}
Loading