diff --git a/packages/go-plugin/README.md b/packages/go-plugin/README.md new file mode 100644 index 00000000..a015a83d --- /dev/null +++ b/packages/go-plugin/README.md @@ -0,0 +1,263 @@ +# Outray Go Client + +Expose your localhost Go server to the internet using [Outray](https://outray.dev). + +## Installation + +```bash +go get github.com/outray-tunnel/outray-go +``` + +## Quick Start + +### Basic Usage + +```go +package main + +import ( + "fmt" + "log" + "net/http" + + outray "github.com/outray-tunnel/outray-go" +) + +func main() { + // Start your local server + go func() { + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "Hello from Go!") + }) + http.ListenAndServe(":8080", nil) + }() + + // Create tunnel + client := outray.New(outray.Options{ + LocalPort: 8080, + OnTunnelReady: func(url string, port int) { + fmt.Printf("šŸš€ Tunnel ready at: %s\n", url) + }, + }) + + if err := client.Start(); err != nil { + log.Fatal(err) + } + defer client.Stop() + + // Keep running + select {} +} +``` + +### With Authentication + +```go +client := outray.New(outray.Options{ + LocalPort: 8080, + APIKey: "your-api-key", // or set OUTRAY_API_KEY env var + Subdomain: "my-go-app", // or set OUTRAY_SUBDOMAIN env var + OnTunnelReady: func(url string, port int) { + fmt.Printf("Tunnel ready at: %s\n", url) + }, + OnError: func(err error, code string) { + fmt.Printf("Error [%s]: %v\n", code, err) + }, +}) +``` + +### With Gin + +```go +package main + +import ( + "fmt" + "log" + + "github.com/gin-gonic/gin" + outray "github.com/outray-tunnel/outray-go" +) + +func main() { + r := gin.Default() + + r.GET("/", func(c *gin.Context) { + c.JSON(200, gin.H{"message": "Hello from Gin!"}) + }) + + // Start tunnel + client := outray.New(outray.Options{ + LocalPort: 8080, + OnTunnelReady: func(url string, port int) { + fmt.Printf("šŸš€ Tunnel: %s\n", url) + }, + }) + + if err := client.Start(); err != nil { + log.Fatal(err) + } + defer client.Stop() + + // Start Gin server + r.Run(":8080") +} +``` + +### With Echo + +```go +package main + +import ( + "fmt" + "log" + "net/http" + + "github.com/labstack/echo/v4" + outray "github.com/outray-tunnel/outray-go" +) + +func main() { + e := echo.New() + + e.GET("/", func(c echo.Context) error { + return c.JSON(http.StatusOK, map[string]string{"message": "Hello from Echo!"}) + }) + + // Start tunnel + client := outray.New(outray.Options{ + LocalPort: 8080, + OnTunnelReady: func(url string, port int) { + fmt.Printf("šŸš€ Tunnel: %s\n", url) + }, + }) + + if err := client.Start(); err != nil { + log.Fatal(err) + } + defer client.Stop() + + // Start Echo server + e.Start(":8080") +} +``` + +### With Fiber + +```go +package main + +import ( + "fmt" + "log" + + "github.com/gofiber/fiber/v2" + outray "github.com/outray-tunnel/outray-go" +) + +func main() { + app := fiber.New() + + app.Get("/", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{"message": "Hello from Fiber!"}) + }) + + // Start tunnel + client := outray.New(outray.Options{ + LocalPort: 3000, + OnTunnelReady: func(url string, port int) { + fmt.Printf("šŸš€ Tunnel: %s\n", url) + }, + }) + + if err := client.Start(); err != nil { + log.Fatal(err) + } + defer client.Stop() + + // Start Fiber server + app.Listen(":3000") +} +``` + +## Options + +| Option | Type | Description | +| ---------------- | ---------------------------------------- | ---------------------------------------------------------------- | +| `LocalPort` | `int` | **Required.** The local port to tunnel | +| `ServerURL` | `string` | Outray server URL (default: `wss://api.outray.dev/`) | +| `APIKey` | `string` | API key for authentication (or use `OUTRAY_API_KEY` env var) | +| `Subdomain` | `string` | Request a specific subdomain (or use `OUTRAY_SUBDOMAIN` env var) | +| `CustomDomain` | `string` | Use a custom domain instead of subdomain | +| `Protocol` | `Protocol` | Tunnel protocol: `ProtocolHTTP`, `ProtocolTCP`, `ProtocolUDP` | +| `RemotePort` | `int` | Port to expose on server (for TCP/UDP tunnels) | +| `OnTunnelReady` | `func(url string, port int)` | Called when tunnel is established | +| `OnRequest` | `func(info RequestInfo)` | Called for each proxied request | +| `OnError` | `func(err error, code string)` | Called when an error occurs | +| `OnReconnecting` | `func(attempt int, delay time.Duration)` | Called when reconnecting | +| `OnClose` | `func(reason string)` | Called when tunnel is closed | +| `Silent` | `bool` | Suppress log output | +| `Logger` | `*log.Logger` | Custom logger | + +## TCP Tunnels + +```go +client := outray.New(outray.Options{ + LocalPort: 5432, // PostgreSQL + Protocol: outray.ProtocolTCP, + RemotePort: 5432, // Optional: request specific port + OnTunnelReady: func(url string, port int) { + fmt.Printf("TCP tunnel ready on port %d\n", port) + }, +}) +``` + +## UDP Tunnels + +```go +client := outray.New(outray.Options{ + LocalPort: 53, // DNS + Protocol: outray.ProtocolUDP, + OnTunnelReady: func(url string, port int) { + fmt.Printf("UDP tunnel ready on port %d\n", port) + }, +}) +``` + +## Request Logging + +```go +client := outray.New(outray.Options{ + LocalPort: 8080, + OnRequest: func(info outray.RequestInfo) { + fmt.Printf("%s %s -> %d (%v)\n", + info.Method, + info.Path, + info.StatusCode, + info.Duration, + ) + }, +}) +``` + +## Environment Variables + +| Variable | Description | +| ------------------- | -------------------------- | +| `OUTRAY_API_KEY` | API key for authentication | +| `OUTRAY_SUBDOMAIN` | Requested subdomain | +| `OUTRAY_SERVER_URL` | Custom server URL | + +## Error Codes + +| Code | Description | +| -------------------- | ------------------------------------- | +| `AUTH_FAILED` | Invalid API key | +| `LIMIT_EXCEEDED` | Tunnel limit exceeded for your plan | +| `SUBDOMAIN_IN_USE` | Requested subdomain is already in use | +| `BANDWIDTH_EXCEEDED` | Monthly bandwidth limit exceeded | + +## License + +MIT diff --git a/packages/go-plugin/example/example b/packages/go-plugin/example/example new file mode 100755 index 00000000..3b440ff3 Binary files /dev/null and b/packages/go-plugin/example/example differ diff --git a/packages/go-plugin/example/go.mod b/packages/go-plugin/example/go.mod new file mode 100644 index 00000000..4a013014 --- /dev/null +++ b/packages/go-plugin/example/go.mod @@ -0,0 +1,12 @@ +module example + +go 1.21 + +require github.com/outray-tunnel/outray-go v0.0.0 + +require ( + github.com/gorilla/websocket v1.5.1 // indirect + golang.org/x/net v0.17.0 // indirect +) + +replace github.com/outray-tunnel/outray-go => ../ diff --git a/packages/go-plugin/example/go.sum b/packages/go-plugin/example/go.sum new file mode 100644 index 00000000..272772f0 --- /dev/null +++ b/packages/go-plugin/example/go.sum @@ -0,0 +1,4 @@ +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= diff --git a/packages/go-plugin/example/main.go b/packages/go-plugin/example/main.go new file mode 100644 index 00000000..f571c735 --- /dev/null +++ b/packages/go-plugin/example/main.go @@ -0,0 +1,83 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + outray "github.com/outray-tunnel/outray-go" +) + +func main() { + // Start a simple HTTP server + go func() { + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"message": "Hello from Go!", "path": "%s", "method": "%s"}`, r.URL.Path, r.Method) + }) + + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"status": "ok", "timestamp": "%s"}`, time.Now().Format(time.RFC3339)) + }) + + log.Println("Starting local server on :8080...") + if err := http.ListenAndServe(":8080", nil); err != nil { + log.Fatal(err) + } + }() + + // Wait for server to start + time.Sleep(100 * time.Millisecond) + + // Create and start the tunnel + client := outray.New(outray.Options{ + LocalPort: 8080, + // Subdomain: "my-go-app", // Uncomment to request specific subdomain + // APIKey: "your-api-key", // Or set OUTRAY_API_KEY env var + + OnTunnelReady: func(url string, port int) { + fmt.Printf("\nšŸš€ Tunnel ready!\n") + fmt.Printf(" Public URL: %s\n", url) + fmt.Printf(" Local: http://localhost:8080\n\n") + }, + + OnRequest: func(info outray.RequestInfo) { + statusEmoji := "āœ…" + if info.StatusCode >= 400 { + statusEmoji = "āŒ" + } + fmt.Printf("%s %s %s -> %d (%v)\n", + statusEmoji, + info.Method, + info.Path, + info.StatusCode, + info.Duration.Round(time.Millisecond), + ) + }, + + OnError: func(err error, code string) { + fmt.Printf("āŒ Error [%s]: %v\n", code, err) + }, + + OnReconnecting: func(attempt int, delay time.Duration) { + fmt.Printf("šŸ”„ Reconnecting (attempt %d) in %v...\n", attempt, delay) + }, + }) + + if err := client.Start(); err != nil { + log.Fatalf("Failed to start tunnel: %v", err) + } + defer client.Stop() + + // Wait for interrupt signal + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + fmt.Println("\n\nShutting down...") +} diff --git a/packages/go-plugin/go.mod b/packages/go-plugin/go.mod new file mode 100644 index 00000000..500c0e54 --- /dev/null +++ b/packages/go-plugin/go.mod @@ -0,0 +1,7 @@ +module github.com/outray-tunnel/outray-go + +go 1.21 + +require github.com/gorilla/websocket v1.5.1 + +require golang.org/x/net v0.17.0 // indirect diff --git a/packages/go-plugin/go.sum b/packages/go-plugin/go.sum new file mode 100644 index 00000000..272772f0 --- /dev/null +++ b/packages/go-plugin/go.sum @@ -0,0 +1,4 @@ +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= diff --git a/packages/go-plugin/outray.go b/packages/go-plugin/outray.go new file mode 100644 index 00000000..a22bed59 --- /dev/null +++ b/packages/go-plugin/outray.go @@ -0,0 +1,687 @@ +// Package outray provides a Go client for creating Outray tunnels. +// +// Outray exposes your localhost server to the internet, similar to ngrok. +// This package allows you to programmatically create HTTP, TCP, and UDP tunnels. +// +// Basic usage: +// +// client := outray.New(outray.Options{ +// LocalPort: 8080, +// OnTunnelReady: func(url string) { +// fmt.Printf("Tunnel ready at: %s\n", url) +// }, +// }) +// +// if err := client.Start(); err != nil { +// log.Fatal(err) +// } +// defer client.Stop() +// +// // Keep running... +// select {} +package outray + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "math" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +const ( + // DefaultServerURL is the default Outray server URL + DefaultServerURL = "wss://api.outray.dev/" + + // pingInterval is how often we send pings to keep the connection alive + pingInterval = 25 * time.Second + + // pongTimeout is how long we wait for a pong before considering connection dead + pongTimeout = 10 * time.Second +) + +// Protocol specifies the tunnel protocol type +type Protocol string + +const ( + ProtocolHTTP Protocol = "http" + ProtocolTCP Protocol = "tcp" + ProtocolUDP Protocol = "udp" +) + +// RequestInfo contains information about a proxied request +type RequestInfo struct { + Method string + Path string + StatusCode int + Duration time.Duration + Error string +} + +// Options configures the Outray client +type Options struct { + // LocalPort is the local port to tunnel (required) + LocalPort int + + // ServerURL is the Outray server URL (optional, defaults to wss://api.outray.dev/) + ServerURL string + + // APIKey is the API key for authentication (optional, can also use OUTRAY_API_KEY env var) + APIKey string + + // Subdomain requests a specific subdomain (optional, can also use OUTRAY_SUBDOMAIN env var) + Subdomain string + + // CustomDomain uses a custom domain instead of subdomain (optional) + CustomDomain string + + // Protocol specifies the tunnel protocol (http, tcp, udp). Defaults to http. + Protocol Protocol + + // RemotePort is the port to expose on the server (for TCP/UDP tunnels) + RemotePort int + + // OnTunnelReady is called when the tunnel is established + OnTunnelReady func(url string, port int) + + // OnRequest is called for each proxied request + OnRequest func(info RequestInfo) + + // OnError is called when an error occurs + OnError func(err error, code string) + + // OnReconnecting is called when attempting to reconnect + OnReconnecting func(attempt int, delay time.Duration) + + // OnClose is called when the tunnel is closed + OnClose func(reason string) + + // Silent suppresses log output when true + Silent bool + + // Logger is the logger to use (optional, defaults to standard logger) + Logger *log.Logger +} + +// Client is the Outray tunnel client +type Client struct { + options Options + conn *websocket.Conn + mu sync.RWMutex + + shouldReconnect bool + assignedURL string + subdomain string + forceTakeover bool + reconnectAttempts int + lastPongReceived time.Time + + stopChan chan struct{} + doneChan chan struct{} + pingTicker *time.Ticker + + logger *log.Logger +} + +// New creates a new Outray client with the given options +func New(opts Options) *Client { + // Apply defaults + if opts.ServerURL == "" { + opts.ServerURL = os.Getenv("OUTRAY_SERVER_URL") + if opts.ServerURL == "" { + opts.ServerURL = DefaultServerURL + } + } + + if opts.APIKey == "" { + opts.APIKey = os.Getenv("OUTRAY_API_KEY") + } + + if opts.Subdomain == "" { + opts.Subdomain = os.Getenv("OUTRAY_SUBDOMAIN") + } + + if opts.Protocol == "" { + opts.Protocol = ProtocolHTTP + } + + logger := opts.Logger + if logger == nil { + if opts.Silent { + logger = log.New(io.Discard, "", 0) + } else { + logger = log.New(os.Stdout, "[outray] ", log.LstdFlags) + } + } + + return &Client{ + options: opts, + subdomain: opts.Subdomain, + shouldReconnect: true, + lastPongReceived: time.Now(), + logger: logger, + } +} + +// Start initiates the tunnel connection +func (c *Client) Start() error { + if c.options.LocalPort == 0 { + return fmt.Errorf("LocalPort is required") + } + + c.mu.Lock() + c.shouldReconnect = true + c.stopChan = make(chan struct{}) + c.doneChan = make(chan struct{}) + c.mu.Unlock() + + return c.connect() +} + +// Stop closes the tunnel connection +func (c *Client) Stop() { + c.mu.Lock() + c.shouldReconnect = false + if c.stopChan != nil { + close(c.stopChan) + } + if c.pingTicker != nil { + c.pingTicker.Stop() + } + if c.conn != nil { + c.conn.Close() + } + c.mu.Unlock() + + // Wait for message loop to finish + if c.doneChan != nil { + <-c.doneChan + } +} + +// URL returns the assigned tunnel URL, or empty string if not connected +func (c *Client) URL() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.assignedURL +} + +// IsConnected returns true if the client is currently connected +func (c *Client) IsConnected() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.conn != nil +} + +func (c *Client) connect() error { + c.logger.Printf("Connecting to %s...", c.options.ServerURL) + + dialer := websocket.Dialer{ + HandshakeTimeout: 10 * time.Second, + } + + conn, _, err := dialer.Dial(c.options.ServerURL, nil) + if err != nil { + return fmt.Errorf("failed to connect: %w", err) + } + + c.mu.Lock() + c.conn = conn + c.lastPongReceived = time.Now() + c.mu.Unlock() + + // Set up pong handler + conn.SetPongHandler(func(string) error { + c.mu.Lock() + c.lastPongReceived = time.Now() + c.mu.Unlock() + return nil + }) + + // Send handshake + if err := c.sendHandshake(); err != nil { + conn.Close() + return err + } + + // Start ping loop + c.startPing() + + // Start message loop + go c.messageLoop() + + return nil +} + +func (c *Client) sendHandshake() error { + msg := openTunnelMessage{ + Type: "open_tunnel", + APIKey: c.options.APIKey, + Subdomain: c.subdomain, + CustomDomain: c.options.CustomDomain, + ForceTakeover: c.forceTakeover, + Protocol: string(c.options.Protocol), + RemotePort: c.options.RemotePort, + } + + data, err := json.Marshal(msg) + if err != nil { + return err + } + + c.mu.RLock() + conn := c.conn + c.mu.RUnlock() + + return conn.WriteMessage(websocket.TextMessage, data) +} + +func (c *Client) messageLoop() { + defer func() { + if c.doneChan != nil { + close(c.doneChan) + } + }() + + for { + select { + case <-c.stopChan: + return + default: + } + + c.mu.RLock() + conn := c.conn + c.mu.RUnlock() + + if conn == nil { + return + } + + _, data, err := conn.ReadMessage() + if err != nil { + c.handleDisconnect(err) + return + } + + c.handleMessage(data) + } +} + +func (c *Client) handleMessage(data []byte) { + var msg message + if err := json.Unmarshal(data, &msg); err != nil { + c.logger.Printf("Failed to parse message: %v", err) + return + } + + switch msg.Type { + case "tunnel_opened": + var tunnelMsg tunnelOpenedMessage + if err := json.Unmarshal(data, &tunnelMsg); err != nil { + c.logger.Printf("Failed to parse tunnel_opened: %v", err) + return + } + + c.mu.Lock() + c.assignedURL = tunnelMsg.URL + c.forceTakeover = false + c.reconnectAttempts = 0 + + // Extract subdomain from URL + if subdomain := c.extractSubdomain(tunnelMsg.URL); subdomain != "" { + c.subdomain = subdomain + } + c.mu.Unlock() + + c.logger.Printf("Tunnel ready at: %s", tunnelMsg.URL) + + if c.options.OnTunnelReady != nil { + c.options.OnTunnelReady(tunnelMsg.URL, tunnelMsg.Port) + } + + case "error": + var errMsg errorMessage + if err := json.Unmarshal(data, &errMsg); err != nil { + c.logger.Printf("Failed to parse error: %v", err) + return + } + + c.handleError(errMsg.Code, errMsg.Message) + + case "request": + var reqMsg requestMessage + if err := json.Unmarshal(data, &reqMsg); err != nil { + c.logger.Printf("Failed to parse request: %v", err) + return + } + + go c.handleRequest(reqMsg) + } +} + +func (c *Client) handleError(code, message string) { + c.logger.Printf("Error [%s]: %s", code, message) + + // If reconnecting and subdomain is in use, try to take over + c.mu.RLock() + assignedURL := c.assignedURL + forceTakeover := c.forceTakeover + c.mu.RUnlock() + + if code == "SUBDOMAIN_IN_USE" && assignedURL != "" && !forceTakeover { + c.mu.Lock() + c.forceTakeover = true + c.mu.Unlock() + + c.mu.RLock() + conn := c.conn + c.mu.RUnlock() + if conn != nil { + conn.Close() + } + return + } + + if c.options.OnError != nil { + c.options.OnError(fmt.Errorf(message), code) + } + + // Fatal errors - stop reconnecting + if code == "AUTH_FAILED" || code == "LIMIT_EXCEEDED" { + c.mu.Lock() + c.shouldReconnect = false + c.mu.Unlock() + c.Stop() + } +} + +func (c *Client) handleRequest(req requestMessage) { + startTime := time.Now() + + // Build the local URL + localURL := fmt.Sprintf("http://localhost:%d%s", c.options.LocalPort, req.Path) + + // Decode body if present + var body io.Reader + if req.Body != "" { + decoded, err := base64.StdEncoding.DecodeString(req.Body) + if err != nil { + c.sendErrorResponse(req.RequestID, 502, fmt.Sprintf("Failed to decode body: %v", err)) + return + } + body = bytes.NewReader(decoded) + } + + // Create the request + httpReq, err := http.NewRequest(req.Method, localURL, body) + if err != nil { + c.sendErrorResponse(req.RequestID, 502, fmt.Sprintf("Failed to create request: %v", err)) + return + } + + // Set headers + for key, value := range req.Headers { + switch v := value.(type) { + case string: + httpReq.Header.Set(key, v) + case []interface{}: + for _, hv := range v { + if s, ok := hv.(string); ok { + httpReq.Header.Add(key, s) + } + } + } + } + + // Make the request + client := &http.Client{ + Timeout: 60 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + resp, err := client.Do(httpReq) + if err != nil { + duration := time.Since(startTime) + + if c.options.OnRequest != nil { + c.options.OnRequest(RequestInfo{ + Method: req.Method, + Path: req.Path, + StatusCode: 502, + Duration: duration, + Error: err.Error(), + }) + } + + c.sendErrorResponse(req.RequestID, 502, fmt.Sprintf("Bad Gateway: %v", err)) + return + } + defer resp.Body.Close() + + // Read response body + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + c.sendErrorResponse(req.RequestID, 502, fmt.Sprintf("Failed to read response: %v", err)) + return + } + + duration := time.Since(startTime) + + if c.options.OnRequest != nil { + c.options.OnRequest(RequestInfo{ + Method: req.Method, + Path: req.Path, + StatusCode: resp.StatusCode, + Duration: duration, + }) + } + + // Build response headers + headers := make(map[string]interface{}) + for key, values := range resp.Header { + if len(values) == 1 { + headers[strings.ToLower(key)] = values[0] + } else { + headers[strings.ToLower(key)] = values + } + } + + // Send response + respMsg := responseMessage{ + Type: "response", + RequestID: req.RequestID, + StatusCode: resp.StatusCode, + Headers: headers, + } + + if len(bodyBytes) > 0 { + respMsg.Body = base64.StdEncoding.EncodeToString(bodyBytes) + } + + c.sendMessage(respMsg) +} + +func (c *Client) sendErrorResponse(requestID string, statusCode int, message string) { + respMsg := responseMessage{ + Type: "response", + RequestID: requestID, + StatusCode: statusCode, + Headers: map[string]interface{}{"content-type": "text/plain"}, + Body: base64.StdEncoding.EncodeToString([]byte(message)), + } + c.sendMessage(respMsg) +} + +func (c *Client) sendMessage(msg interface{}) error { + data, err := json.Marshal(msg) + if err != nil { + return err + } + + c.mu.RLock() + conn := c.conn + c.mu.RUnlock() + + if conn == nil { + return fmt.Errorf("not connected") + } + + return conn.WriteMessage(websocket.TextMessage, data) +} + +func (c *Client) startPing() { + c.mu.Lock() + if c.pingTicker != nil { + c.pingTicker.Stop() + } + c.pingTicker = time.NewTicker(pingInterval) + c.mu.Unlock() + + go func() { + for { + select { + case <-c.stopChan: + return + case <-c.pingTicker.C: + c.mu.RLock() + conn := c.conn + lastPong := c.lastPongReceived + c.mu.RUnlock() + + if conn == nil { + return + } + + // Check if pong timeout exceeded + if time.Since(lastPong) > pingInterval+pongTimeout { + c.logger.Println("Pong timeout - connection may be dead") + conn.Close() + return + } + + if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(time.Second)); err != nil { + c.logger.Printf("Failed to send ping: %v", err) + } + } + } + }() +} + +func (c *Client) handleDisconnect(err error) { + c.mu.Lock() + if c.pingTicker != nil { + c.pingTicker.Stop() + } + c.conn = nil + + if !c.shouldReconnect { + c.mu.Unlock() + return + } + + // If we previously had a tunnel URL, force takeover on reconnect + if c.assignedURL != "" { + c.forceTakeover = true + } + + attempts := c.reconnectAttempts + c.reconnectAttempts++ + c.mu.Unlock() + + // Exponential backoff + delay := time.Duration(math.Min(float64(2*time.Second)*math.Pow(2, float64(attempts)), float64(30*time.Second))) + + c.logger.Printf("Connection lost, reconnecting in %v (attempt %d)...", delay, attempts+1) + + if c.options.OnReconnecting != nil { + c.options.OnReconnecting(attempts+1, delay) + } + + time.Sleep(delay) + + // Check if we should still reconnect + c.mu.RLock() + shouldReconnect := c.shouldReconnect + c.mu.RUnlock() + + if shouldReconnect { + c.doneChan = make(chan struct{}) + if err := c.connect(); err != nil { + c.logger.Printf("Reconnect failed: %v", err) + c.handleDisconnect(err) + } + } +} + +func (c *Client) extractSubdomain(tunnelURL string) string { + parsed, err := url.Parse(tunnelURL) + if err != nil { + return "" + } + + parts := strings.Split(parsed.Hostname(), ".") + if len(parts) > 0 { + return parts[0] + } + return "" +} + +// Message types for JSON serialization +type message struct { + Type string `json:"type"` +} + +type openTunnelMessage struct { + Type string `json:"type"` + APIKey string `json:"apiKey,omitempty"` + Subdomain string `json:"subdomain,omitempty"` + CustomDomain string `json:"customDomain,omitempty"` + ForceTakeover bool `json:"forceTakeover,omitempty"` + Protocol string `json:"protocol,omitempty"` + RemotePort int `json:"remotePort,omitempty"` +} + +type tunnelOpenedMessage struct { + Type string `json:"type"` + TunnelID string `json:"tunnelId"` + URL string `json:"url"` + Plan string `json:"plan,omitempty"` + Protocol string `json:"protocol,omitempty"` + Port int `json:"port,omitempty"` +} + +type errorMessage struct { + Type string `json:"type"` + Code string `json:"code"` + Message string `json:"message"` +} + +type requestMessage struct { + Type string `json:"type"` + RequestID string `json:"requestId"` + Method string `json:"method"` + Path string `json:"path"` + Headers map[string]interface{} `json:"headers"` + Body string `json:"body,omitempty"` +} + +type responseMessage struct { + Type string `json:"type"` + RequestID string `json:"requestId"` + StatusCode int `json:"statusCode"` + Headers map[string]interface{} `json:"headers"` + Body string `json:"body,omitempty"` +}