"... and SSH connections can thus be routed through a web server"

The veracity of this claim depends on the skill of the reader; it is undefined for those with no knowledge of software, or the language this text is written in. A low skill user may accept it as true; they may have heard of "SSH" and "web"; the statement could be true. Grammatical, at least, even if the meaning escapes them, much as someone unfamiliar with sportsball may accept "leg before wicket" during a baseball game; "legs" and "wickets" sound reasonable, and maybe they heard the term used elsewhere, so, yeah, sure, could be true. At higher skill levels the statement is false. There is no way you could route SSH through a web server, those are totally different things that run on different ports and anyways web servers don't do that. This, perhaps, is the realm of the power user who knows a thing or two about computers. At higher skill levels the statement returns to being possibly true; they may recognize that SSH and HTTP use the same underlying sockets. You certainly could have a SSH daemon listen at port 80 or 443, maybe that's what "through a web server" means?

Another skill: supposing "SSH connections can be routed through a web server" is true, how would one go about doing that? Could the statement be proven false? Unlike philosophy, "supposing a white horse is not horse", computers are pretty good at providing a testable environment.

白馬非馬

A first step may be to read something: "Beej's Guide to Network Programming", "Network Programming with Go", etc. Another option is to look at the source code for a HTTP or SSH daemon. These can be complex things, so try simple or minimal implementations--httpd on OpenBSD, for example. This is why networking guides trot out the usual "echo server" implementation: the salient points aren't buried behind 100,000 lines of boilerplate, bug reports, and business logic. On the other hand, the ability to plow through heaps of code could be a useful skill.

    func TestChunkedResponseHeaders_h1(t *testing.T) {
    testChunkedResponseHeaders(t, h1Mode) }
    func TestChunkedResponseHeaders_h2(t *testing.T) {
    testChunkedResponseHeaders(t, h2Mode) }

    func testChunkedResponseHeaders(t *testing.T, h2 bool) {
            defer afterTest(t)
            log.SetOutput(io.Discard) // is noisy otherwise

Various lines from a Go src/net/http directory. This part is probably less valuable than others.

Another step is to read through the fine manual for ssh(1), top to bottom, and to chase any related material, such as ssh_config(5), etc. This search may reveal the -L flag, which is the ability to open ports and tunnel them elsewhere. Webservers may not have this feature, but may support proxying requests. Documentation quality varies; for some projects you might be better off just looking at the code.

    -L [bind_address:]port:host:hostport
    -L [bind_address:]port:remote_socket
    -L local_socket:host:hostport
    -L local_socket:remote_socket
       Specifies that connections to the given TCP port or
       Unix socket on the local (client) host are to be
       forwarded to the given host and port, or Unix socket,
       on the remote side. This works by ...

One could also try to write a HTTP daemon or SSH-like communications system; it need not be very featureful nor complete nor even secure. Or to extend the usual "echo server" with additional functionality, maybe into some sort of talk or chat system? These days, at least, there are programmable web servers that handle much of the boilerplate for you.

    StatusExpectationFailed        = 417 // RFC 7231, 6.5.14
    StatusTeapot                   = 418 // RFC 7168, 2.3.3
    StatusMisdirectedRequest       = 421 // RFC 7540, 9.1.2
    StatusUnprocessableEntity      = 422 // RFC 4918, 11.2
    StatusLocked                   = 423 // RFC 4918, 11.3
    StatusFailedDependency         = 424 // RFC 4918, 11.4
    StatusTooEarly                 = 425 // RFC 8470, 5.2.

More code from that Go directory. The teapot caused some trouble.

Another option is to use tcpdump and look at the protocol "on the wire" even if the wire is fake. Sniffing is easier with unencrypted text-based protocols, or sometimes the private keys are available. Encrypted protocols do look different on the wire and will show different packet sizes and timing patterns. This could be useful information.

Now, HTTP and SSH are obviously different; SSH being perhaps closer to HTTPS, but ssh developed from rsh and telnet before it, while HTTP belongs more to the FTP or SMTP protocol school, now with TLS wrapped around it. Both use TCP sockets. HTTP may close the connection. SSH keeps it open. HTTPS usually keeps the connection open to avoid the overhead of standing up TCP and then TLS over and over again. But, SSH and HTTP are different protocols. Point a web client at a SSH server and the connection will fail; the SSH server may log something about a bogus request.

    $ doas rcctl -f start sshd
    sshd(ok)
    $ nc localhost 22
    SSH-2.0-OpenSSH_9.1
    hi there ssh
    Invalid SSH identification string.
    hello?
    $ doas rcctl -f start httpd
    httpd(ok)
    $ printf 'HEAD / HTTP/1.0\r\n\r\n' | nc ::1 80 | sed 2q
    HTTP/1.0 200 OK
    Connection: close
    $ doas rcctl stop sshd httpd

Protocol switching is not unknown; SMTP and other protocols support STARTTLS to change a connection over to TLS--RFC 2487, RFC 2595. The web moved to a distinct HTTPS port instead of switching protocols on port 80, but, regardless, "change the protocol used" is not unknown. Could we apply this to a web connection? This need not be anything as formal as an RFC, but does need to work.

One idea would be to accept the packets from a SSH client, encode them somehow, pass that to a webserver, the have something server-side decode and feed a SSH server. Responses from the SSH server are encoded, returned to the HTTP client, decoded, and sent to the SSH client. This approach is not considered here.

Instead, what we want is the socket associated with a web request, and to appropriate that socket for a SSH connection. A small HTTP server:

    #!/usr/bin/env tclsh8.6
    proc handling {fh address port} {
      puts  $fh "HTTP/1.0 200 OK\nConnection: close\n\nOK\n"
      close $fh
    }
    socket -server handling 8080
    vwait godot

$fh in this TCL code has the socket we want SSH traffic to flow over. The socket is available in webservers, though there may be too much code in the way to easily access it. Some servers do allow the socket to be hijacked...

In brief, in Go,

    package main

    import (
      "bufio"
      "net"
      "net/http"
    )

    func main() {
      http.HandleFunc("/", nope)
      http.HandleFunc("/ssh", tunnel)
      http.ListenAndServe(":8888", nil)
    }

    func nope(rw http.ResponseWriter, req *http.Request) {
      rw.WriteHeader(http.StatusInternalServerError)
    }

    func tunnel(rw http.ResponseWriter, req *http.Request) {
      hj, _ := rw.(http.Hijacker)
      conn, buf, _ := hj.Hijack()
      sshd, _ := net.Dial("tcp", "127.0.0.1:22")
      bridge(conn, buf, sshd)
    }

    func bridge(conn net.Conn, buf *bufio.ReadWriter, sshd net.Conn) {
      writerServer := bufio.NewWriter(sshd)
      readerServer := bufio.NewReader(sshd)

      go func(c net.Conn, reader *bufio.Reader, writer *bufio.Writer) {
        defer c.Close()
        reader.WriteTo(writer)
      }(conn, buf.Reader, writerServer)

      go func(c net.Conn, reader *bufio.Reader, writer *bufio.Writer) {
        defer c.Close()
        reader.WriteTo(writer)
      }(sshd, readerServer, buf.Writer)
    }

/ssh requests are hijacked, a connection to SSH on the local system is made, and all traffic is bridged between the HTTP client and SSH server. Real code may not want to ignore the various errors not handled by the above, and certainly more logging will help with various problems.

A custom client is required; this will typically be code that makes a raw HTTP request so that the socket is not hidden behind the complexity of a HTTP client library. Following a /ssh request, the custom HTTP client will act like the -L flag to ssh does, and open some port. Traffic between that port and the HTTP socket will be bridged exactly as done on the server. A SSH client then connects to the port opened by the custom HTTP client, and if all goes well will begin to communicate with the SSH server through a web server.

"... and SSH connections can thus be routed through a web server"

Back to tech index

tags #legacyweb #ssh #go