diff --git a/internal/cli/init.go b/internal/cli/init.go index 771d1d9..8eea9f2 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -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. @@ -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 }, diff --git a/internal/cli/root.go b/internal/cli/root.go index f2fed3e..a0dfae2 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -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()) diff --git a/internal/cli/tunnel.go b/internal/cli/tunnel.go index 6d88995..4c60531 100644 --- a/internal/cli/tunnel.go +++ b/internal/cli/tunnel.go @@ -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) @@ -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 } diff --git a/internal/config/config.go b/internal/config/config.go index d0495ff..a6fd6b4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. @@ -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 diff --git a/internal/provider/localtunnel.go b/internal/provider/localtunnel.go index edef3b5..ae78635 100644 --- a/internal/provider/localtunnel.go +++ b/internal/provider/localtunnel.go @@ -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) diff --git a/internal/tunnel/service.go b/internal/tunnel/service.go new file mode 100644 index 0000000..a769d97 --- /dev/null +++ b/internal/tunnel/service.go @@ -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()) + } +} diff --git a/internal/tunnel/service_test.go b/internal/tunnel/service_test.go new file mode 100644 index 0000000..3aa8cc3 --- /dev/null +++ b/internal/tunnel/service_test.go @@ -0,0 +1,161 @@ +package tunnel + +import ( + "context" + "strings" + "testing" +) + +// MockProvider implements Provider interface for testing purposes. +type MockProvider struct { + connectedCalled bool + connectPort int + closeCalled bool +} + +// implement Provider interface +func (m *MockProvider) Connect(ctx context.Context, localPort int) (string, error) { + m.connectedCalled = true + m.connectPort = localPort + return "https://abc123.example.com", nil +} + +func (m *MockProvider) Close() error { + m.closeCalled = true + return nil +} + +func (m *MockProvider) IsConnected() bool { + return m.connectedCalled && !m.closeCalled +} + +func (m *MockProvider) PublicURL() string { + return "https://abc123.example.com" +} + +func (m *MockProvider) Name() string { + return "MockProvider" +} + +// NewService creates a service with the MockProvider for testing. +func TestNewService(t *testing.T) { + mockProvider := &MockProvider{} + svc := NewService(mockProvider) + + if svc == nil { + t.Fatal("Service should not be nil") + } +} + +func TestService_Start(t *testing.T) { + mock := &MockProvider{} + + svc := NewService(mock) + ctx := context.Background() + port := 3000 + err := svc.Start(ctx, port) + if err != nil { + t.Fatalf("Start() error = %v, want nil", err) + } + + // check mock.Connect was called + if !mock.connectedCalled { + t.Error("provider.Connect was not called") + } + + // check port is correct + if mock.connectPort != port { + t.Errorf("connectPort = %d, want %d", port, mock.connectPort) + } + + // Check Ready() channel is closed + select { + case <-svc.Ready(): + // Ok! channel is closed + default: + t.Error("ready channel should be closed after Start()") + } + +} + +func TestService_PublicURL(t *testing.T) { + mock := &MockProvider{} + svc := NewService(mock) + + url := svc.PublicURL() + if url != mock.PublicURL() { + t.Errorf("Expected %s, got %s", mock.PublicURL(), svc.PublicURL()) + } +} + +func TestService_Ready(t *testing.T) { + mock := &MockProvider{} + svc := NewService(mock) + + // Test before Start (Read channel not closed) + select { + case <-svc.Ready(): + t.Error("ready before even `Start` is called!") + default: + // alright + } + + ctx := context.Background() + + // Test after Start (Ready channel closed) + err := svc.Start(ctx, 3000) + if err != nil { + t.Errorf("expected nil got %v", err) + } + + select { + case <-svc.Ready(): + // closed as expected + default: + // error if channel is not closed to send ready signal + t.Error("ready channel should have been closed") + } +} + +func TestService_Close(t *testing.T) { + mock := &MockProvider{} + + svc := NewService(mock) + + // before calling Close + if mock.closeCalled { + t.Error("provider should not be closed yet") + } + + err := svc.Close() + if err != nil { + t.Errorf("Close() error = %v, want nil", err) + } + + if !mock.closeCalled { + t.Error("provider.Close() was not called") + } +} + +func TestService_StartTwice(t *testing.T) { + mock := &MockProvider{} + svc := NewService(mock) + + ctx := context.Background() + + // First Start - should succeed + err := svc.Start(ctx, 3000) + + if err != nil { + t.Fatalf("First Start() error = %v, want nil", err) + } + + err = svc.Start(ctx, 3000) + if err == nil { + t.Fatalf("Second start shall cause error") + } + + if !strings.Contains(err.Error(), "already started") { + t.Error("already started error shall be returned") + } +}