From b5f6f42311ed8f8f403d7f2d2e50f8a542be1e68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20Luis=20Monta=C3=B1es=20Ojados?= Date: Tue, 27 Jan 2026 03:03:21 +0100 Subject: [PATCH] feat: Implement initial client application with TUI for SSH tunnel management and monitoring. --- cmd/client/main.go | 3 +- internal/tui/model.go | 4 +-- internal/tunnel/client.go | 74 +++++++++++++++++++++++++++++++++------ 3 files changed, 67 insertions(+), 14 deletions(-) diff --git a/cmd/client/main.go b/cmd/client/main.go index e81afc8..e621247 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -35,6 +35,7 @@ func main() { localPort := flag.String("local", "8080", "Local port to expose") serverAddrFlag := flag.String("server", "", "Grokway server address") tokenFlag := flag.String("token", "", "Authentication token (overrides config)") + hostHeaderFlag := flag.String("host-header", "", "Custom Host header to send to local service") flag.Parse() // Load config @@ -56,7 +57,7 @@ func main() { serverAddr = "localhost:2222" } - m := tui.InitialModel(*localPort, serverAddr, authToken) + m := tui.InitialModel(*localPort, serverAddr, authToken, *hostHeaderFlag) p := tea.NewProgram(m, tea.WithAltScreen()) if _, err := p.Run(); err != nil { diff --git a/internal/tui/model.go b/internal/tui/model.go index d91df5c..c18ff35 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -68,8 +68,8 @@ type LogMsg string type MetricMsg int64 type ClearCopiedMsg struct{} -func InitialModel(localPort, serverAddr, authToken string) Model { - c := tunnel.NewClient(serverAddr, localPort, authToken) +func InitialModel(localPort, serverAddr, authToken, hostHeader string) Model { + c := tunnel.NewClient(serverAddr, localPort, authToken, hostHeader) return Model{ Client: c, LogLines: []string{}, diff --git a/internal/tunnel/client.go b/internal/tunnel/client.go index 996af36..4b846a5 100644 --- a/internal/tunnel/client.go +++ b/internal/tunnel/client.go @@ -23,6 +23,7 @@ type Client struct { ServerAddr string LocalPort string AuthToken string + HostHeader string // New field for custom Host header SSHClient *ssh.Client Listener net.Listener Events chan string // Channel to send logs/events to TUI @@ -30,11 +31,12 @@ type Client struct { PublicURL string // PublicURL is the URL accessible from the internet } -func NewClient(serverAddr, localPort, authToken string) *Client { +func NewClient(serverAddr, localPort, authToken, hostHeader string) *Client { return &Client{ ServerAddr: serverAddr, LocalPort: localPort, AuthToken: authToken, + HostHeader: hostHeader, Events: make(chan string, 10), Metrics: make(chan int64, 10), } @@ -129,18 +131,73 @@ func (c *Client) handleConnection(remoteConn net.Conn) { // or use a bufio reader if we weren't doing a raw Copy. // But io.Copy needs a reader. - // Create a buffer to read the first few bytes - buf := make([]byte, 1024) + // Read first chunk to inspect headers + buf := make([]byte, 8192) // Increased buffer for headers + remoteConn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) n, err := remoteConn.Read(buf) - if err != nil { + remoteConn.SetReadDeadline(time.Time{}) // Reset deadline + + if err != nil && err != io.EOF && !os.IsTimeout(err) { logToFile(fmt.Sprintf("Error reading from remote: %v", err)) return } + if n == 0 { + return + } payload := string(buf[:n]) - // Try to parse rudimentary HTTP + // If it looks like HTTP, rewrite Host and add Proto headers + if strings.Contains(payload, "HTTP/") { + lines := strings.Split(payload, "\r\n") + var newLines []string + + for _, line := range lines { + if strings.HasPrefix(strings.ToLower(line), "host:") { + if c.HostHeader != "" { + newLines = append(newLines, "Host: "+c.HostHeader) + } else { + // Default to localhost handling: keep original or set to localhost? + // Usually localhost:port is safest if user didn't specify. + newLines = append(newLines, "Host: localhost:"+c.LocalPort) + } + continue + } + newLines = append(newLines, line) + } + + // Insert X-Forwarded-Proto if missing (prevents redirects loop) + if !strings.Contains(strings.ToLower(payload), "x-forwarded-proto:") { + // Find end of headers (empty line) + for i, line := range newLines { + if line == "" { // End of headers + // Insert before the empty line + finalHeaders := append(newLines[:i], "X-Forwarded-Proto: https") + finalHeaders = append(finalHeaders, newLines[i:]...) + newLines = finalHeaders + break + } + } + } + + modifiedPayload := strings.Join(newLines, "\r\n") + + // Log for debug + logToFile("Rewritten Headers:\n" + modifiedPayload) + + // Send modified headers + localConn.Write([]byte(modifiedPayload)) + } else { + // Not HTTP or couldn't parse, just forward as comes + localConn.Write(buf[:n]) + } + + // Try to parse rudimentary HTTP for TUI logs // Format: METHOD PATH PROTOCOL + + // We use the payload we just processed (or the original if not modified? No, logs should show original request usually, + // but here we have the payload string variable already.) + lines := strings.Split(payload, "\n") if len(lines) > 0 { firstLine := lines[0] @@ -149,19 +206,14 @@ func (c *Client) handleConnection(remoteConn net.Conn) { method := parts[0] path := parts[1] // Send structured log - c.Events <- fmt.Sprintf("HTTP|%s|%s|%d", method, path, 200) // Status is fake for now, acts as connection open + c.Events <- fmt.Sprintf("HTTP|%s|%s|%d", method, path, 200) } else { c.Events <- "TCP|||Connection" } } - // We need to write the bytes we read to the local connection first - localConn.Write(buf[:n]) - // Bidirectional copy // Calculate bytes? - - // We can use a custom copy to track metrics go func() { n, _ := io.Copy(remoteConn, localConn) c.Metrics <- n