From 01501f4c60d1835f280b47d4f8716fc1f80370e8 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Thu, 30 Apr 2026 23:42:19 -0600 Subject: [PATCH] feat: authenticate via HTTP/1.1 before establishing SSH tunnel Add --http-auth and --auth-token flags to perform HTTP authentication over TLS (with http/1.1 ALPN) before establishing the SSH tunnel. Add --http-upgrade flag to use HTTP Upgrade header mechanism, allowing the proxy to hijack/switch the connection to SSH on the same TLS connection (similar to WebSocket upgrade but without websockets). Two modes: - Without --http-upgrade: Auth connection is closed after auth, then a second TLS+SSH connection is established (two connections) - With --http-upgrade : After auth, if proxy responds with 101 Switching Protocols + Upgrade: , the same connection is used for SSH (single connection, protocol switch) No changes to existing behavior when --http-auth is not used. --- cmd/sclient/main.go | 37 +++++++++-- sclient.go | 149 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 161 insertions(+), 25 deletions(-) diff --git a/cmd/sclient/main.go b/cmd/sclient/main.go index e3161f4..ad9244a 100644 --- a/cmd/sclient/main.go +++ b/cmd/sclient/main.go @@ -51,6 +51,9 @@ func main() { var insecure bool var servername string var silent bool + var httpAuthURL string + var authToken string + var httpUpgradeProtocol string flag.Usage = usage @@ -60,6 +63,9 @@ func main() { flag.StringVar(&servername, "servername", "", "specify a servername different from (to disable SNI use an IP as and do not use this option)") flag.BoolVar(&insecure, "insecure", false, "ignore bad TLS/SSL/HTTPS certificates") flag.BoolVar(&silent, "silent", false, "less verbose output") + flag.StringVar(&httpAuthURL, "http-auth", "", "authenticate via HTTP/1.1 before establishing tunnel (ex: 'proxy.example.com/auth')") + flag.StringVar(&authToken, "auth-token", "", "authentication token (sent as Bearer token in Authorization header)") + flag.StringVar(&httpUpgradeProtocol, "http-upgrade", "", "protocol to upgrade to after authentication (e.g., 'ssh') - uses HTTP Upgrade header to switch protocols on the same connection") flag.Parse() @@ -78,13 +84,32 @@ func main() { } } + authTokenFromURL := "" + if httpAuthURL != "" { + if idx := strings.Index(httpAuthURL, "token="); idx != -1 { + authTokenFromURL = httpAuthURL[idx+6:] + if idx2 := strings.Index(authTokenFromURL, "&"); idx2 != -1 { + authTokenFromURL = authTokenFromURL[:idx2] + } else if idx2 := strings.Index(authTokenFromURL, " "); idx2 != -1 { + authTokenFromURL = authTokenFromURL[:idx2] + } + } + } + sclient := &sclient.Tunnel{ - RemotePort: 443, - LocalAddress: "localhost", - InsecureSkipVerify: insecure, - ServerName: servername, - Silent: silent, - NextProtos: alpns, + RemotePort: 443, + LocalAddress: "localhost", + InsecureSkipVerify: insecure, + ServerName: servername, + Silent: silent, + NextProtos: alpns, + HttpAuthURL: httpAuthURL, + AuthToken: authToken, + HttpUpgradeProtocol: httpUpgradeProtocol, + } + + if sclient.AuthToken == "" { + sclient.AuthToken = authTokenFromURL } remote := strings.Split(remotestr, ":") diff --git a/sclient.go b/sclient.go index 94da38d..e65a05a 100644 --- a/sclient.go +++ b/sclient.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net" + "net/http" "os" "strconv" "strings" @@ -12,24 +13,84 @@ import ( // Tunnel specifies which remote encrypted connection to make available as a plain connection locally. type Tunnel struct { - RemoteAddress string - RemotePort int - LocalAddress string - LocalPort int - InsecureSkipVerify bool - NextProtos []string - ServerName string - Silent bool + RemoteAddress string + RemotePort int + LocalAddress string + LocalPort int + InsecureSkipVerify bool + NextProtos []string + ServerName string + Silent bool + HttpAuthURL string + AuthToken string + HttpUpgradeProtocol string +} + +func (t *Tunnel) httpAuth(conn net.Conn) (bool, error) { + url := t.HttpAuthURL + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + url = "http://" + url + } + parts := strings.SplitN(url, "//", 2) + path := "/" + if idx := strings.Index(parts[1], "/"); idx != -1 { + path = parts[1][idx:] + } + host := parts[0] + + req, _ := http.NewRequest("GET", path, nil) + req.Host = host + if t.AuthToken != "" { + req.Header.Set("Authorization", "Bearer "+t.AuthToken) + } + if t.HttpUpgradeProtocol != "" { + req.Header.Set("Upgrade", t.HttpUpgradeProtocol) + } + req.ContentLength = 0 + req.Close = true + + if err := req.Write(conn); nil != err { + return false, err + } + + buf := make([]byte, 4096) + n, err := conn.Read(buf) + if nil != err { + return false, err + } + + resp := string(buf[:n]) + + if t.HttpUpgradeProtocol != "" { + if strings.Contains(resp, "101") && strings.Contains(strings.ToLower(resp), "upgrade: "+strings.ToLower(t.HttpUpgradeProtocol)) { + return true, nil + } + if !strings.Contains(resp, "200") && !strings.Contains(resp, "201") && !strings.Contains(resp, "202") && !strings.Contains(resp, "204") { + return false, fmt.Errorf("authentication failed: %s", resp) + } + } + + if !strings.Contains(resp, "200") && !strings.Contains(resp, "201") && !strings.Contains(resp, "202") && !strings.Contains(resp, "204") { + return false, fmt.Errorf("authentication failed: %s", resp) + } + + return false, nil } // DialAndListen will create a test TLS connection to the remote address and then // begin listening locally. Each local connection will result in a separate remote connection. func (t *Tunnel) DialAndListen() error { remote := t.RemoteAddress + ":" + strconv.Itoa(t.RemotePort) + + var nextProtos []string = t.NextProtos + if t.HttpAuthURL != "" { + nextProtos = []string{"http/1.1"} + } + conn, err := tls.Dial("tcp", remote, &tls.Config{ ServerName: t.ServerName, - NextProtos: t.NextProtos, + NextProtos: nextProtos, InsecureSkipVerify: t.InsecureSkipVerify, }) @@ -142,17 +203,67 @@ func pipe(r netReadWriteCloser, w netReadWriteCloser, t string) { } func (t *Tunnel) handleConnection(remote string, conn netReadWriteCloser) { - sclient, err := tls.Dial("tcp", remote, - &tls.Config{ - ServerName: t.ServerName, - NextProtos: t.NextProtos, - InsecureSkipVerify: t.InsecureSkipVerify, - }) + var sclient net.Conn - if err != nil { - fmt.Fprintf(os.Stderr, "[error] (remote) %s\n", err) - _ = conn.Close() - return + if t.HttpAuthURL != "" { + authConn, err := tls.Dial("tcp", remote, + &tls.Config{ + ServerName: t.ServerName, + NextProtos: []string{"http/1.1"}, + InsecureSkipVerify: t.InsecureSkipVerify, + }) + + if err != nil { + fmt.Fprintf(os.Stderr, "[error] (auth connection) %s\n", err) + _ = conn.Close() + return + } + + upgraded, err := t.httpAuth(authConn) + if nil != err { + fmt.Fprintf(os.Stderr, "[error] (authentication) %s\n", err) + _ = conn.Close() + _ = authConn.Close() + return + } + + if t.HttpUpgradeProtocol != "" && upgraded { + sclient = authConn + } else { + _ = authConn.Close() + + var nextProtos []string = t.NextProtos + if len(nextProtos) == 0 { + nextProtos = []string{"ssh"} + } + + sclient, err = tls.Dial("tcp", remote, + &tls.Config{ + ServerName: t.ServerName, + NextProtos: nextProtos, + InsecureSkipVerify: t.InsecureSkipVerify, + }) + + if err != nil { + fmt.Fprintf(os.Stderr, "[error] (remote) %s\n", err) + _ = conn.Close() + return + } + } + } else { + var dialErr error + sclient, dialErr = tls.Dial("tcp", remote, + &tls.Config{ + ServerName: t.ServerName, + NextProtos: t.NextProtos, + InsecureSkipVerify: t.InsecureSkipVerify, + }) + + if dialErr != nil { + fmt.Fprintf(os.Stderr, "[error] (remote) %s\n", dialErr) + _ = conn.Close() + return + } } if !t.Silent {