RPC Over SSH and Domain Sockets

I really like using SSH for authentication and authorization when possible – it is very configurable, well understood, and more secure then anything I am likely to design. It is also generally pretty easy to have applications communicate over SSH. A nice model is to have the server listen on a domain socket in a directory with appropriate permissions, and clients connect over ssh and netcat to talk to it.

Logically, on the client it is:

$ ssh server.example.com /usr/bin/nc -U /tmp/foo

And voila, your client (or shell in this case) is connected to the remote domain socket. After finding Jeff Hodges’s wonderful writeup on go.crypto/ssh I sat down to make Go do this internally. It was fun, and pretty straightforward.

The server is just a net/rpc server which listens on a domain socket and responds with a greeting:

package main

import (
	"fmt"
	"log"
	"net"
	"net/rpc"
	"os"
	"os/signal"
	"syscall"
)

// rpc response
type Response struct {
	Greeting string
}

// rpc request
type Request struct {
	Name string
}

// rpc host struct thing
type Greeter struct{}

// our remotely invocable function
func (g *Greeter) Greet(req Request, res *Response) (err error) {
	res.Greeting = fmt.Sprintf("Hello %s", req.Name)
	return
}

// start up rpc listener at path
func ServeAt(path string) (err error) {
	rpc.Register(&Greeter{})

	listener, err := net.Listen("unix", path)
	if err != nil {
		return fmt.Errorf("unable to listen at %s: %s", path, err)
	}

	go rpc.Accept(listener)
	return
}

// ./server /tmp/foo
func main() {
	path := os.Args[1]

	err := ServeAt(path)
	if err != nil {
		log.Fatalf("failed: %s", err)
	}
	defer os.Remove(path)

	// block until we are signalled to quit
	wait()
}

func wait() {
	signals := make(chan os.Signal)
	signal.Notify(signals, syscall.SIGINT, syscall.SIGKILL, syscall.SIGHUP)
	<-signals
}

The client is the fun part. It establishes an SSH connection to the server host, then fires off a Session against netcat, attaches an RPC client to that session, and does its stuff!

package main

import (
	"code.google.com/p/go.crypto/ssh"
	"fmt"
	"io"
	"log"
	"net"
	"net/rpc"
	"os"
	"strings"
)

// RPC response container
type Response struct {
	Greeting string
}

// RPC request container
type Request struct {
	Name string
}

// It would be nice if ssh.Session was an io.ReaderWriter
// proposal submitted :-)
type NetCatSession struct {
	*ssh.Session // define Close()
	writer io.Writer
	reader io.Reader
}

// io.Reader
func (s NetCatSession) Read(p []byte) (n int, err error) {
	return s.reader.Read(p)
}

// io.Writer
func (s NetCatSession) Write(p []byte) (n int, err error) {
	return s.writer.Write(p)
}

// given the established ssh connection, start a session against netcat and
// return a io.ReaderWriterCloser appropriate for rpc.NewClient(...)
func StartNetCat(client *ssh.ClientConn, path string) (rwc *NetCatSession, err error) {
	session, err := client.NewSession()
	if err != nil {
		return
	}

	cmd := fmt.Sprintf("/usr/bin/nc -U %s", path)
	in, err := session.StdinPipe()
	if err != nil {
		return nil, fmt.Errorf("unable to get stdin: %s", err)
	}

	out, err := session.StdoutPipe()
	if err != nil {
		return nil, fmt.Errorf("unable to get stdout: %s", err)
	}

	err = session.Start(cmd)
	if err != nil {
		return nil, fmt.Errorf("unable to start '%s': %s", cmd, err)
	}

	return &NetCatSession{session, in, out}, nil
}


// ./client localhost:/tmp/foo Brian
func main() {
	parts := strings.Split(os.Args[1], ":")
	host := parts[0]
	path := parts[1]
	name := os.Args[2]


	// SSH setup, we assume current username and use the ssh agent
	// for auth
	agent_sock, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK"))
	if err != nil {
		log.Fatalf("sorry, this example requires the ssh agent: %s", err)
	}
	defer agent_sock.Close()

	config := &ssh.ClientConfig{
		User: os.Getenv("USER"),
		Auth: []ssh.ClientAuth{
			ssh.ClientAuthAgent(ssh.NewAgentClient(agent_sock)),
		},
	}
	ssh_client, err := ssh.Dial("tcp", fmt.Sprintf("%s:22", host), config)
	if err != nil {
		log.Fatalf("Failed to dial: %s", err)
	}
	defer ssh_client.Close()


	// Establish sesstion to netcat talking to the domain socket
	s, err := StartNetCat(ssh_client, path)
	if err != nil {
		log.Fatalf("unable to start netcat session: %s", err)
	}


	// now comes the RPC!
	client := rpc.NewClient(s)
	defer client.Close()

	req := &Request{name}
	var res Response

	err = client.Call("Greeter.Greet", req, &res)
	if err != nil {
		log.Fatalf("error in rpc: %s", err)
	}
	fmt.Println(res.Greeting)
}

And there it is! This isn’t exactly library code, but it nicely bundles up how to do it.

I really like using domain sockets and SSH for “operational” stuff. The slight overhead of firing up extra processes on the server, and hopping between tcp and unix sockets doesn’t usually matter, and you get lots of nice well understood and configurable security for your sessions.

In this case, I’m using SSH-as-a-library, in the past I have shelled out to SSH in order to take advantage of client side SSH configuration as well. Which makes the most sense varies, of course :-)

$ ./server /tmp/foo &
[1] 46206
$ ./client localhost:/tmp/foo "brave SSH world"
Hello brave SSH world
$ 

I put the project into a gist you can clone and noodle with if you like :-)