From 7cf77838d4fe6eebbd9521883e9fd471f43934a3 Mon Sep 17 00:00:00 2001 From: Samiul Sk <9149283+kernelshard@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:35:52 +0530 Subject: [PATCH 1/2] feat(security): add auth token flag to protect self-hosted server (#63) --- internal/cli/server.go | 7 +++- internal/cli/server_test.go | 38 +++++++++++++++++++ internal/cli/tunnel.go | 9 ++++- internal/cli/tunnel_test.go | 8 ++++ internal/provider/selfhosted.go | 9 +++++ internal/provider/selfhosted_test.go | 46 +++++++++++++++++++++++ internal/server/protocol/protocol.go | 8 ++-- internal/server/server.go | 23 ++++++++++-- internal/server/server_test.go | 55 ++++++++++++++++++++++++++++ 9 files changed, 195 insertions(+), 8 deletions(-) create mode 100644 internal/cli/server_test.go diff --git a/internal/cli/server.go b/internal/cli/server.go index bc523a3..dcf1590 100644 --- a/internal/cli/server.go +++ b/internal/cli/server.go @@ -31,6 +31,7 @@ func newServerCmd() *cobra.Command { cmd.Flags().IntP("control-port", "c", 7890, "Port for client connections") cmd.Flags().IntP("public-port", "p", 8080, "Port for public HTTP traffic") cmd.Flags().StringP("domain", "d", "localhost", "Domain for public URLs") + cmd.Flags().StringP("token", "t", "", "Auth token to protect the server (or EXPOSE_SERVER_TOKEN env)") return cmd } @@ -39,8 +40,12 @@ func runServerCmd(cmd *cobra.Command, _ []string) error { domain, _ := cmd.Flags().GetString("domain") controlPort, _ := cmd.Flags().GetInt("control-port") publicPort, _ := cmd.Flags().GetInt("public-port") + token, _ := cmd.Flags().GetString("token") + if token == "" { + token = os.Getenv("EXPOSE_SERVER_TOKEN") + } - srv := server.NewServer(domain, controlPort, publicPort) + srv := server.NewServer(domain, controlPort, publicPort).WithToken(token) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/internal/cli/server_test.go b/internal/cli/server_test.go new file mode 100644 index 0000000..65b0144 --- /dev/null +++ b/internal/cli/server_test.go @@ -0,0 +1,38 @@ +package cli + +import ( + "testing" +) + +func TestServerCmd(t *testing.T) { + cmd := newServerCmd() + + if cmd == nil { + t.Fatal("newServerCmd returned nil") + } + + if cmd.Name() != "server" { + t.Errorf("expected command name 'server', got '%s'", cmd.Name()) + } + + flags := []struct { + name string + shorthand string + }{ + {"control-port", "c"}, + {"public-port", "p"}, + {"domain", "d"}, + {"token", "t"}, + } + + for _, f := range flags { + flag := cmd.Flags().Lookup(f.name) + if flag == nil { + t.Errorf("flag %q not defined", f.name) + continue + } + if flag.Shorthand != f.shorthand { + t.Errorf("flag %q: expected shorthand %q, got %q", f.name, f.shorthand, flag.Shorthand) + } + } +} diff --git a/internal/cli/tunnel.go b/internal/cli/tunnel.go index aabca65..03a2fb6 100644 --- a/internal/cli/tunnel.go +++ b/internal/cli/tunnel.go @@ -43,6 +43,9 @@ func newTunnelCmd() *cobra.Command { // subdomain flag for custom subdomain request cmd.Flags().StringP("subdomain", "d", "", "Request a custom subdomain (e.g. --subdomain my-app)") + // token flag for self-hosted server auth + cmd.Flags().StringP("token", "t", "", "Auth token for self-hosted server (or EXPOSE_SERVER_TOKEN env)") + return cmd } @@ -109,13 +112,17 @@ func runTunnelCmd(cmd *cobra.Command, args []string) error { } subdomain, _ := cmd.Flags().GetString("subdomain") + token, _ := cmd.Flags().GetString("token") + if token == "" { + token = os.Getenv("EXPOSE_SERVER_TOKEN") + } var svc *tunnel.Service switch providerName { case "cloudflare": svc = tunnel.NewService(provider.NewCloudFlare()) case "selfhosted": - svc = tunnel.NewService(provider.NewSelfHosted(serverAddr, subdomain)) + svc = tunnel.NewService(provider.NewSelfHosted(serverAddr, subdomain).WithToken(token)) default: svc = tunnel.NewService(provider.NewLocalTunnel(nil)) } diff --git a/internal/cli/tunnel_test.go b/internal/cli/tunnel_test.go index a5ab491..9219a93 100644 --- a/internal/cli/tunnel_test.go +++ b/internal/cli/tunnel_test.go @@ -33,6 +33,14 @@ func TestTunnelCmd(t *testing.T) { if subdomainFlag.Shorthand != "d" { t.Errorf("expected shorthand 'd', got %s", subdomainFlag.Shorthand) } + + tokenFlag := cmd.Flags().Lookup("token") + if tokenFlag == nil { + t.Error("token flag not defined") + } + if tokenFlag.Shorthand != "t" { + t.Errorf("expected shorthand 't', got %s", tokenFlag.Shorthand) + } } func TestResolvePort(t *testing.T) { diff --git a/internal/provider/selfhosted.go b/internal/provider/selfhosted.go index 937acca..1b7aba4 100644 --- a/internal/provider/selfhosted.go +++ b/internal/provider/selfhosted.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net" + "strings" "sync" "time" @@ -16,6 +17,7 @@ import ( type SelfHosted struct { serverAddr string // e.g: tunnel.mysite.com:7890 subdomain string + token string conn net.Conn publicURL string connected bool @@ -33,6 +35,11 @@ func NewSelfHosted(serverAddr string, subdomain string) *SelfHosted { } } +func (s *SelfHosted) WithToken(token string) *SelfHosted { + s.token = strings.TrimSpace(token) + return s +} + // Name returns the name of the provider. func (s *SelfHosted) Name() string { return "selfhosted" @@ -78,7 +85,9 @@ func (s *SelfHosted) Connect(ctx context.Context, localPort int) (string, error) req := protocol.TunnelRequest{ Type: protocol.TypeControl, Subdomain: s.subdomain, + Token: s.token, } + if err := json.NewEncoder(conn).Encode(req); err != nil { conn.Close() return "", err diff --git a/internal/provider/selfhosted_test.go b/internal/provider/selfhosted_test.go index 3ce3e8a..ae1d9e8 100644 --- a/internal/provider/selfhosted_test.go +++ b/internal/provider/selfhosted_test.go @@ -255,3 +255,49 @@ func TestSelfHosted_Connect_CustomSubdomain(t *testing.T) { t.Errorf("expected non-empty URL for random subdomain") } } + +func TestSelfHosted_Connect_TokenAuth_Success(t *testing.T) { + const SecretToken = "secret123" + srv := server.NewServer("localhost", 0, 0).WithToken(SecretToken) + ctx := t.Context() + go func() { + _ = srv.Start(ctx) + }() + <-srv.Ready() + + controlAddr := srv.ControlAddr() + + client := NewSelfHosted(controlAddr, "example").WithToken(SecretToken) + url, err := client.Connect(ctx, 3000) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + defer client.Close() + + if url == "" { + t.Fatal("expected public URL, got empty string") + } + if !client.IsConnected() { + t.Fatal("expected IsConnected to be true") + } +} + +func TestSelfHosted_Connect_TokenAuth_Rejected(t *testing.T) { + srv := server.NewServer("localhost", 0, 0).WithToken("secret123") + ctx := t.Context() + go func() { + _ = srv.Start(ctx) + }() + <-srv.Ready() + + controlAddr := srv.ControlAddr() + + client := NewSelfHosted(controlAddr, "example").WithToken("wrong") + _, err := client.Connect(ctx, 3000) + if err == nil { + t.Fatalf("expected error from Connect() with invalid token, got nil") + } + if !strings.Contains(err.Error(), protocol.ErrUnauthorized) { + t.Errorf("expected '%s' error, got: %v", protocol.ErrUnauthorized, err) + } +} diff --git a/internal/server/protocol/protocol.go b/internal/server/protocol/protocol.go index fa9a38b..80fe43d 100644 --- a/internal/server/protocol/protocol.go +++ b/internal/server/protocol/protocol.go @@ -1,8 +1,9 @@ package protocol const ( - TypeControl = "control" - TypeData = "data" + TypeControl = "control" + TypeData = "data" + ErrUnauthorized = "unauthorized: invalid or missing auth token" ) // TunnelRequest is sent by the client when connecting to the server. @@ -10,7 +11,8 @@ const ( type TunnelRequest struct { // Subdomain is the desired subdomain. If empty, server will assign a random one. Subdomain string `json:"subdomain,omitempty"` - Type string `json:"type,omitempty"` // "control" or "data" + Type string `json:"type"` // "control" or "data" + Token string `json:"token,omitempty"` // optional auth token for self-hosted server } // TunnelResponse is sent by the server after registration. diff --git a/internal/server/server.go b/internal/server/server.go index b470322..b1ecf33 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2,6 +2,7 @@ package server import ( "context" + "crypto/subtle" "encoding/json" "errors" "fmt" @@ -20,12 +21,13 @@ import ( // holds the state of the server and handles incoming connections. type Server struct { domain string - controlPort int // port for client connections - publicPort int // port for public http connections - tunnels sync.Map + controlPort int // port for client connections + publicPort int // port for public http connections + token string // auth token required for tunnel registry(optional) controlTimeout time.Duration // after this duration of no client activity, connection is closed handshakeTimeout time.Duration maxHandshakeBytes int + tunnels sync.Map controlListener net.Listener publicListener net.Listener ready chan struct{} @@ -58,6 +60,11 @@ func NewServer(domain string, controlPort, publicPort int) *Server { } } +func (s *Server) WithToken(token string) *Server { + s.token = strings.TrimSpace(token) + return s +} + // startControlPlane accepts incoming client connections (registrations and data sockets) // and dispatches each to handleControlConnection in a separate goroutine. func (s *Server) startControlPlane() error { @@ -165,6 +172,16 @@ func (s *Server) handleControlConnection(conn net.Conn) { } } + if s.token != "" { + matchedVal := subtle.ConstantTimeCompare([]byte(req.Token), []byte(s.token)) + if matchedVal != 1 { + errUnauthorizedResp := protocol.TunnelResponse{Error: protocol.ErrUnauthorized} + _ = json.NewEncoder(conn).Encode(&errUnauthorizedResp) + conn.Close() + return + } + } + // Phase 2: Control socket - register new tunnel subdomain, err := s.registerSubdomain(conn, req.Subdomain) if err != nil { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 8493606..ad1e397 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -713,3 +713,58 @@ func TestServer_ServeHTTP_SymmetricTeardown_VisitorDisconnect(t *testing.T) { t.Fatal("hung: laptop socket was not closed when visitor disconnected (socket leak!)") } } + +func TestServer_HandleControl_TokenAuth_Rejected(t *testing.T) { + srv := NewServer("localhost", 7890, 8080).WithToken("secret123") + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + + go srv.handleControlConnection(serverConn) + + req := protocol.TunnelRequest{ + Type: protocol.TypeControl, + Subdomain: "example", + } + + if err := json.NewEncoder(clientConn).Encode(req); err != nil { + t.Fatalf("failed to encode request %v", err) + } + + var unAuthorizedResp protocol.TunnelResponse + if err := json.NewDecoder(clientConn).Decode(&unAuthorizedResp); err != nil { + t.Fatalf("failed to decode response %v", err) + } + + if unAuthorizedResp.Error != protocol.ErrUnauthorized { + t.Errorf("expected error unauthorized, got %s", unAuthorizedResp.Error) + } +} + +func TestServer_HandleControl_TokenAuth_Success(t *testing.T) { + const SecretToken = "secret123" + srv := NewServer("localhost", 7890, 8080).WithToken(SecretToken) + + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + + go srv.handleControlConnection(serverConn) + + req := protocol.TunnelRequest{ + Type: protocol.TypeControl, + Subdomain: "example", + Token: SecretToken, + } + + if err := json.NewEncoder(clientConn).Encode(req); err != nil { + t.Fatalf("failed to decode response %v", err) + } + + var resp protocol.TunnelResponse + if err := json.NewDecoder(clientConn).Decode(&resp); err != nil { + t.Fatalf("failed to decode response %v", err) + } + + if resp.Error != "" { + t.Fatalf("unexpected error %s", resp.Error) + } +} From 76454fa4a87c5243966881dfe1bfb5913c4edd10 Mon Sep 17 00:00:00 2001 From: Samiul Sk <9149283+kernelshard@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:36:25 +0530 Subject: [PATCH 2/2] test: add unit tests for cli commands and tunnel service --- internal/cli/config_test.go | 91 ++++++++++++++++++++++++++++++++ internal/cli/init_test.go | 36 +++++++++++++ internal/cli/root_test.go | 13 +++++ internal/cli/tunnel_test.go | 19 +++++++ internal/tunnel/service_test.go | 38 +++++++++++++ internal/version/version_test.go | 9 ++++ 6 files changed, 206 insertions(+) create mode 100644 internal/cli/init_test.go create mode 100644 internal/cli/root_test.go diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go index 7f1e458..0ce1699 100644 --- a/internal/cli/config_test.go +++ b/internal/cli/config_test.go @@ -1 +1,92 @@ package cli + +import ( + "os" + "strings" + "testing" + + "github.com/kernelshard/expose/internal/config" +) + +func TestConfigCmd_Structure(t *testing.T) { + cmd := newConfigCmd() + if cmd.Name() != "config" { + t.Errorf("expected command name 'config', got %q", cmd.Name()) + } + + subCommands := cmd.Commands() + if len(subCommands) != 2 { + t.Fatalf("expected 2 subcommands, got %d", len(subCommands)) + } +} + +func TestConfigListCmd(t *testing.T) { + t.Run("fails when config does not exist", func(t *testing.T) { + tmpDir := t.TempDir() + prevDir, _ := os.Getwd() + _ = os.Chdir(tmpDir) + defer os.Chdir(prevDir) + + cmd := newConfigListCmd() + err := cmd.RunE(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "config not found") { + t.Fatalf("expected 'config not found' error, got: %v", err) + } + }) + + t.Run("succeeds when config exists", func(t *testing.T) { + tmpDir := t.TempDir() + prevDir, _ := os.Getwd() + _ = os.Chdir(tmpDir) + defer os.Chdir(prevDir) + + _, err := config.Init() + if err != nil { + t.Fatalf("failed to init config: %v", err) + } + + cmd := newConfigListCmd() + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("expected config list to succeed, got: %v", err) + } + }) +} + +func TestConfigGetCmd(t *testing.T) { + t.Run("fails when config does not exist", func(t *testing.T) { + tmpDir := t.TempDir() + prevDir, _ := os.Getwd() + _ = os.Chdir(tmpDir) + defer os.Chdir(prevDir) + + cmd := newConfigGetCmd() + err := cmd.RunE(cmd, []string{"port"}) + if err == nil || !strings.Contains(err.Error(), "config not found") { + t.Fatalf("expected 'config not found' error, got: %v", err) + } + }) + + t.Run("succeeds for valid key and fails for invalid key", func(t *testing.T) { + tmpDir := t.TempDir() + prevDir, _ := os.Getwd() + _ = os.Chdir(tmpDir) + defer os.Chdir(prevDir) + + _, err := config.Init() + if err != nil { + t.Fatalf("failed to init config: %v", err) + } + + cmd := newConfigGetCmd() + + // Valid key + if err := cmd.RunE(cmd, []string{"port"}); err != nil { + t.Fatalf("expected get 'port' to succeed, got: %v", err) + } + + // Invalid key + if err := cmd.RunE(cmd, []string{"nonexistent"}); err == nil { + t.Fatal("expected get 'nonexistent' to fail, got nil") + } + }) +} diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go new file mode 100644 index 0000000..4fc26b3 --- /dev/null +++ b/internal/cli/init_test.go @@ -0,0 +1,36 @@ +package cli + +import ( + "os" + "testing" +) + +func TestInitCmd(t *testing.T) { + cmd := newInitCmd() + if cmd == nil { + t.Fatal("expected newInitCmd to return non-nil command") + } + if cmd.Name() != "init" { + t.Errorf("expected command name 'init', got %q", cmd.Name()) + } + + t.Run("successfully creates config and rejects duplicate", func(t *testing.T) { + tmpDir := t.TempDir() + prevDir, _ := os.Getwd() + _ = os.Chdir(tmpDir) + defer os.Chdir(prevDir) + + // First execution creates .expose.yml + err := cmd.RunE(cmd, nil) + if err != nil { + t.Fatalf("expected init to succeed, got: %v", err) + } + + // Second execution should fail because config already exists + err = cmd.RunE(cmd, nil) + if err == nil { + t.Fatal("expected second init to fail with 'config already exists', got nil") + } + }) +} + diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go new file mode 100644 index 0000000..8c01c39 --- /dev/null +++ b/internal/cli/root_test.go @@ -0,0 +1,13 @@ +package cli + +import ( + "testing" +) + +func TestExecute(t *testing.T) { + rootCmd.SetArgs([]string{"--help"}) + if err := Execute(); err != nil { + t.Fatalf("expected Execute() with --help to succeed, got: %v", err) + } +} + diff --git a/internal/cli/tunnel_test.go b/internal/cli/tunnel_test.go index 9219a93..80bfe6c 100644 --- a/internal/cli/tunnel_test.go +++ b/internal/cli/tunnel_test.go @@ -1,6 +1,7 @@ package cli import ( + "net" "os" "testing" ) @@ -110,3 +111,21 @@ func TestResolvePort(t *testing.T) { } }) } + +func TestIsPortListening(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + defer ln.Close() + + port := ln.Addr().(*net.TCPAddr).Port + if !isPortListening(port) { + t.Errorf("expected port %d to be listening", port) + } + + _ = ln.Close() + if isPortListening(port) { + t.Errorf("expected closed port %d to not be listening", port) + } +} diff --git a/internal/tunnel/service_test.go b/internal/tunnel/service_test.go index 6558b81..cda2ddb 100644 --- a/internal/tunnel/service_test.go +++ b/internal/tunnel/service_test.go @@ -4,6 +4,7 @@ import ( "context" "strings" "testing" + "time" ) // MockProvider implements Provider interface for testing purposes. @@ -168,3 +169,40 @@ func TestService_Providername(t *testing.T) { t.Errorf("ProviderName() = %s, want MockProvider", got) } } + +func TestService_IsConnected(t *testing.T) { + mock := &MockProvider{} + svc := NewService(mock) + + if svc.IsConnected() { + t.Error("expected IsConnected to be false before Start()") + } + + _ = svc.Start(context.Background(), 3000) + if !svc.IsConnected() { + t.Error("expected IsConnected to be true after Start()") + } + + _ = svc.Close() + if svc.IsConnected() { + t.Error("expected IsConnected to be false after Close()") + } +} + +func TestService_WaitReady(t *testing.T) { + mock := &MockProvider{} + svc := NewService(mock) + + // Before start, WaitReady with short timeout should timeout + err := svc.WaitReady(50 * time.Millisecond) + if err == nil { + t.Error("expected timeout error from WaitReady before start") + } + + // After start, WaitReady returns nil immediately + _ = svc.Start(context.Background(), 3000) + err = svc.WaitReady(time.Second) + if err != nil { + t.Errorf("expected WaitReady to return nil after start, got: %v", err) + } +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go index f46be4f..caf5762 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -12,6 +12,15 @@ func TestGetVersion(t *testing.T) { } } +func TestGetVersion_Dev(t *testing.T) { + orig := Version + t.Cleanup(func() { Version = orig }) + Version = "dev" + if got := GetVersion(); got != "dev (unreleased)" { + t.Errorf("expected 'dev (unreleased)', got: %s", got) + } +} + func TestGetFullVersion(t *testing.T) { fullVersion := GetFullVersion() if !strings.Contains(fullVersion, GitCommit) {