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
5 changes: 3 additions & 2 deletions internal/cli/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ package cli
import (
"fmt"

"github.com/kernelshard/expose/internal/config"
"github.com/spf13/cobra"

"github.com/kernelshard/expose/internal/config"
)

// newInitCmd creates the 'init' command for initializing configuration.
Expand All @@ -20,7 +21,7 @@ func newInitCmd() *cobra.Command {

fmt.Printf("✓ Created .expose.yml\n")
fmt.Printf("✓ Project: %s\n", cfg.Project)
fmt.Printf("✓ Port: %d\n", cfg.DefaultPort)
fmt.Printf("✓ Port: %d\n", cfg.Port)
return nil

},
Expand Down
11 changes: 6 additions & 5 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import (
"github.com/spf13/cobra"
)

var rootCmd = &cobra.Command{
Use: "expose",
Short: "Expose localhost to the internet",
Long: "Minimal CLI to expose your local dev server",
}

func Execute() error {
rootCmd := &cobra.Command{
Use: "expose",
Short: "Expose localhost to the internet",
Long: "Minimal CLI to expose your local dev server",
}

// Add commands
rootCmd.AddCommand(newInitCmd())
Expand Down
97 changes: 66 additions & 31 deletions internal/cli/tunnel.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,48 +10,66 @@ import (
"github.com/spf13/cobra"

"github.com/kernelshard/expose/internal/config"
"github.com/kernelshard/expose/internal/provider"
"github.com/kernelshard/expose/internal/tunnel"
)

// tunnelCmd represents the 'tunnel' command in the CLI application.
// tunnelCmd represents the tunnel command
func newTunnelCmd() *cobra.Command {
var port int

// Define the command structure and behavior
//Use: "tunnel",
//Short: "Start a tunnel to expose local server",
//RunE: runTunnelCmd,
cmd := &cobra.Command{
Use: "tunnel",
Short: "Start a tunnel to expose local server",
RunE: func(cmd *cobra.Command, args []string) error {
// Load config
cfg, err := config.Load("")
if err != nil {
return fmt.Errorf("run 'expose' init first")
}

if port == 0 {
port = cfg.DefaultPort
}

return runTunnel(port)
},
RunE: runTunnelCmd,
}

cmd.Flags().IntVarP(&port, "port", "p", 0, "local port to expose")
cmd.Flags().IntP("port", "p", 0, "Local port to expose (overrides config)")
return cmd
}

// runTunnelCmd represents the 'tunnel' command in the CLI application.
func runTunnelCmd(cmd *cobra.Command, args []string) error {

// Load config
cfg, err := config.Load("")
if err != nil {
return fmt.Errorf("config not found (run 'expose init' first): %w", err)
}

// Get port from flag
port, err := cmd.Flags().GetInt("port")
if err != nil {
return fmt.Errorf("invalid port flag %w", err)
}

// use config port if flag not set
if port == 0 {
port = cfg.Port
}

if port <= 0 || port > 65535 {
return fmt.Errorf("invalid port %d (must be 1-65535)", port)
}

return runTunnel(port)
}

// 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)

// Create manager
mgr := tunnel.NewManager(port)
// - Wrap in service
svc := tunnel.NewService(lt)

// context with signal handling
// Setup ctx & signal handling
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// handle Ctrl+C, kill pid etc
// handle Ctrl+C, kill pid etc.
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)

Expand All @@ -62,20 +80,37 @@ func runTunnel(port int) error {
cancel()
}()

// start in background
// - Start tunnel in background
errChan := make(chan error, 1)
go func() {
errChan <- mgr.Start(ctx)
errChan <- svc.Start(ctx, port)
}()

// wait for ready
<-mgr.Ready()
select {
case <-svc.Ready():
// Show info
fmt.Printf("🚀 Tunnel[%s] started for localhost:%d\n", svc.ProviderName(), port)
fmt.Printf("✓ Public URL: %s\n", svc.PublicURL())
fmt.Printf("✓ Forwarding to: http://localhost:%d\n", port)
fmt.Printf("✓ Provider: %s\n", svc.ProviderName())
fmt.Println("Press Ctrl+C to stop")

case err := <-errChan:
if err != nil {
return err
}

// Show info
fmt.Printf("🚀 Starting tunnel for localhost:%d\n\n", port)
fmt.Printf("✓ Public URL: %s\n", mgr.PublicURL())
fmt.Printf("✓ Forwarding to: http://localhost:%d\n\n", port)
fmt.Println("Press Ctrl+C to stop")
}

// - Wait for shutdown
<-ctx.Done()

// - Cleanup
if err := svc.Close(); err != nil {
return fmt.Errorf("close failed %w", err)
}

return <-errChan
fmt.Println("✓ Tunnel closed")
return nil
}
8 changes: 4 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ const DefaultConfigFile = ".expose.yml"

// Config represents the structure of the configuration file.
type Config struct {
Project string `yaml:"project"`
DefaultPort int `yaml:"default_port"`
Project string `yaml:"project"`
Port int `yaml:"port"`
}

// Load reads the configuration from the specified or default file path.
Expand Down Expand Up @@ -50,8 +50,8 @@ func Init() (*Config, error) {
projectName := filepath.Base(dir)

cfg := &Config{
Project: projectName,
DefaultPort: 3000,
Project: projectName,
Port: 3000,
}

// Write config file
Expand Down
2 changes: 1 addition & 1 deletion internal/provider/localtunnel.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ func (lt *localTunnel) handleConnection(tunnelConn net.Conn) {
// proxyRequest forwards data between the tunnel connection and the local server.
func (lt *localTunnel) proxyRequest(tunnelConn net.Conn) error {
// connect to local server
localAddr := fmt.Sprintf("localhost:%d", lt.localPort)
localAddr := fmt.Sprintf("127.0.0.1:%d", lt.localPort)
localConn, err := net.DialTimeout("tcp", localAddr, 5*time.Second)
if err != nil {
return fmt.Errorf("local dial failed: %w", err)
Expand Down
105 changes: 105 additions & 0 deletions internal/tunnel/service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package tunnel

import (
"context"
"fmt"
"sync"
"time"
)

// Service wraps a tunnel Provider and manages its lifecycle.
// It provides a uniform interface for all tunnel providers(localtunnel, ngrok etc.)
type Service struct {
provider Provider
ready chan struct{}
mu sync.RWMutex
started bool
closed bool
}

// NewService creates a new Service instance with the given Provider.
func NewService(p Provider) *Service {
return &Service{
provider: p,
ready: make(chan struct{}),
}
}

// Start initializes the tunnel provider and signals when ready.
func (s *Service) Start(ctx context.Context, localPort int) error {
s.mu.Lock()
if s.started {
s.mu.Unlock()
return fmt.Errorf("tunnel already started")
}

if s.closed {
s.mu.Unlock()
return fmt.Errorf("service is closed")
}
s.started = true
s.mu.Unlock()

_, err := s.provider.Connect(ctx, localPort)
if err != nil {
return fmt.Errorf("failed to connect %s provider tunnel: %w", s.provider.Name(), err)
}

// signal that tunnel is ready to use
close(s.ready)
return nil

}

// Ready returns a channel that closes when the tunnel is ready.
// Useful for waiting in CLI: <-service.Ready()
func (s *Service) Ready() <-chan struct{} {
return s.ready
}

// PublicURL returns the tunnel's public URL.
// Returns empty string if not connected.
func (s *Service) PublicURL() string {
return s.provider.PublicURL()
}

// ProviderName returns the name of the tunnel provider.
func (s *Service) ProviderName() string {
return s.provider.Name()
}

// IsConnected returns true if tunnel is active
func (s *Service) IsConnected() bool {
return s.provider.IsConnected()
}

// Close terminates the tunnel and cleans up resources.
func (s *Service) Close() error {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil
}
s.closed = true
s.mu.Unlock()

return s.provider.Close()
}

// WaitReady waits for the tunnel to be ready with a timeout.
// Returns error if timeout exceeded or service closes
func (s *Service) WaitReady(timeout time.Duration) error {
if s.provider.IsConnected() {
return nil
}

ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()

select {
case <-s.ready:
return nil
case <-ctx.Done():
return fmt.Errorf("tunnel readiness timeout: %w", ctx.Err())
}
}
Loading