Initial Commit
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..045c22e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+arista-proxy
+*swp
diff --git a/arista.proto b/arista.proto
new file mode 100644
index 0000000..75fc1f3
--- /dev/null
+++ b/arista.proto
@@ -0,0 +1,24 @@
+syntax = "proto3";
+
+package proto;
+
+message ShowVersionRequest {
+};
+
+message ShowVersionResponse {
+    string model_name = 1;
+    string internal_version = 2;
+    string system_mac_address = 3;
+    string serial_number = 4;
+    int64 mem_total = 5;
+    double bootup_timestamp = 6;
+    int64 mem_free = 7;
+    string version = 8;
+    string architecture = 9;
+    string internal_build_id = 10;
+    string hardware_revision = 11;
+};
+
+service AristaProxy {
+    rpc ShowVersion(ShowVersionRequest) returns (ShowVersionResponse);
+};
diff --git a/grpc.go b/grpc.go
new file mode 100644
index 0000000..ddc978f
--- /dev/null
+++ b/grpc.go
@@ -0,0 +1,143 @@
+package main
+
+import (
+	"context"
+	"crypto/tls"
+	"crypto/x509"
+	"fmt"
+	"io/ioutil"
+	"net"
+	"net/http"
+
+	"github.com/golang/glog"
+	"github.com/q3k/statusz"
+	"golang.org/x/net/trace"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/credentials"
+	"google.golang.org/grpc/reflection"
+
+	pb "code.hackerspace.pl/q3k/arista-proxy/proto"
+)
+
+type serverOpts struct {
+	listenAddress      string
+	debugAddress       string
+	tlsCAPath          string
+	tlsCertificatePath string
+	tlsKeyPath         string
+	pkiRealm           string
+}
+
+type server struct {
+	arista *aristaClient
+	opts   *serverOpts
+
+	grpc struct {
+		listen net.Listener
+		server *grpc.Server
+	}
+	http struct {
+		listen net.Listener
+		server *http.Server
+	}
+}
+
+func newServer(opts *serverOpts, arista *aristaClient) (*server, error) {
+	return &server{
+		opts:   opts,
+		arista: arista,
+	}, nil
+}
+
+func (s *server) trace(ctx context.Context, f string, args ...interface{}) {
+	tr, ok := trace.FromContext(ctx)
+	if !ok {
+		fmtd := fmt.Sprintf(f, args...)
+		glog.Warningf("No trace in %v: %s", ctx, fmtd)
+		return
+	}
+	tr.LazyPrintf(f, args...)
+}
+
+func (s *server) setupGRPC(options ...grpc.ServerOption) error {
+	serverCert, err := tls.LoadX509KeyPair(s.opts.tlsCertificatePath, s.opts.tlsKeyPath)
+	if err != nil {
+		return fmt.Errorf("while loading keypair: %v", err)
+	}
+
+	certPool := x509.NewCertPool()
+	ca, err := ioutil.ReadFile(s.opts.tlsCAPath)
+	if err != nil {
+		return fmt.Errorf("while loading ca certificate: %v", err)
+	}
+	if ok := certPool.AppendCertsFromPEM(ca); !ok {
+		return fmt.Errorf("while appending ca certificate to pool: %v", err)
+	}
+
+	lis, err := net.Listen("tcp", s.opts.listenAddress)
+	if err != nil {
+		return fmt.Errorf("while listening on main port: %v", err)
+	}
+
+	creds := credentials.NewTLS(&tls.Config{
+		ClientAuth:   tls.RequireAndVerifyClientCert,
+		Certificates: []tls.Certificate{serverCert},
+		ClientCAs:    certPool,
+	})
+
+	s.grpc.listen = lis
+	options = append([]grpc.ServerOption{grpc.Creds(creds)}, options...)
+	s.grpc.server = grpc.NewServer(options...)
+
+	return nil
+}
+
+func (s *server) setupDebugHTTP(mux http.Handler) error {
+	lis, err := net.Listen("tcp", s.opts.debugAddress)
+	if err != nil {
+		return fmt.Errorf("while listening on main port: %v", err)
+	}
+
+	s.http.listen = lis
+	s.http.server = &http.Server{
+		Addr:    s.opts.debugAddress,
+		Handler: mux,
+	}
+
+	return nil
+}
+
+func (s *server) serveForever() {
+	grpc.EnableTracing = true
+
+	if err := s.setupGRPC(grpc.UnaryInterceptor(s.unaryInterceptor)); err != nil {
+		glog.Exitf("Could not setup GRPC server: %v", err)
+	}
+	pb.RegisterAristaProxyServer(s.grpc.server, s)
+	reflection.Register(s.grpc.server)
+
+	go func() {
+		if err := s.grpc.server.Serve(s.grpc.listen); err != nil {
+			glog.Exitf("Could not start GRPC server: %v", err)
+		}
+	}()
+	glog.Infof("Listening for GRPC on %v", s.opts.listenAddress)
+
+	httpMux := http.NewServeMux()
+	httpMux.HandleFunc("/debug/status", statusz.StatusHandler)
+	httpMux.HandleFunc("/debug/requests", trace.Traces)
+	httpMux.HandleFunc("/", statusz.StatusHandler)
+
+	if err := s.setupDebugHTTP(httpMux); err != nil {
+		glog.Exitf("Could not setup HTTP server: %v", err)
+	}
+
+	go func() {
+		if err := s.http.server.Serve(s.http.listen); err != nil {
+			glog.Exitf("Could not start HTTP server: %v", err)
+		}
+	}()
+	glog.Infof("Listening for HTTP on %v", s.opts.debugAddress)
+
+	select {}
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..6c68977
--- /dev/null
+++ b/main.go
@@ -0,0 +1,73 @@
+package main
+
+import (
+	"flag"
+	"fmt"
+
+	"github.com/golang/glog"
+	"github.com/ybbus/jsonrpc"
+)
+
+var (
+	flagAristaAPI       string
+	flagListenAddress   string
+	flagDebugAddress    string
+	flagCAPath          string
+	flagCertificatePath string
+	flagKeyPath         string
+	flagPKIRealm        string
+)
+
+type aristaClient struct {
+	rpc jsonrpc.RPCClient
+}
+
+func (c *aristaClient) structuredCall(res interface{}, command ...string) error {
+	cmd := struct {
+		Version int      `json:"version"`
+		Cmds    []string `json:"cmds"`
+		Format  string   `json:"format"`
+	}{
+		Version: 1,
+		Cmds:    command,
+		Format:  "json",
+	}
+
+	err := c.rpc.CallFor(res, "runCmds", cmd)
+	if err != nil {
+		return fmt.Errorf("could not execute structured call: %v", err)
+	}
+	return nil
+}
+
+func main() {
+	flag.StringVar(&flagAristaAPI, "arista_api", "http://admin:password@1.2.3.4:80/command-api", "Arista remote endpoint")
+	flag.StringVar(&flagListenAddress, "listen_address", "127.0.0.1:8080", "gRPC listen address")
+	flag.StringVar(&flagDebugAddress, "debug_address", "127.0.0.1:8081", "Debug HTTP listen address, or empty to disable")
+	flag.StringVar(&flagCAPath, "tls_ca_path", "pki/ca.pem", "Path to PKI CA certificate")
+	flag.StringVar(&flagCertificatePath, "tls_certificate_path", "pki/service.pem", "Path to PKI service certificate")
+	flag.StringVar(&flagKeyPath, "tls_key_path", "pki/service-key.pem", "Path to PKI service private key")
+	flag.StringVar(&flagPKIRealm, "pki_realm", "svc.cluster.local", "PKI realm")
+	flag.Set("logtostderr", "true")
+	flag.Parse()
+
+	arista := &aristaClient{
+		rpc: jsonrpc.NewClient(flagAristaAPI),
+	}
+
+	opts := &serverOpts{
+		listenAddress:      flagListenAddress,
+		debugAddress:       flagDebugAddress,
+		tlsCAPath:          flagCAPath,
+		tlsCertificatePath: flagCertificatePath,
+		tlsKeyPath:         flagKeyPath,
+		pkiRealm:           flagPKIRealm,
+	}
+	server, err := newServer(opts, arista)
+	if err != nil {
+		glog.Errorf("Could not create server: %v", err)
+	}
+
+	glog.Info("Starting up...")
+	server.serveForever()
+}
diff --git a/pki.go b/pki.go
new file mode 100644
index 0000000..fdb4e34
--- /dev/null
+++ b/pki.go
@@ -0,0 +1,88 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"golang.org/x/net/trace"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/credentials"
+	"google.golang.org/grpc/peer"
+	"google.golang.org/grpc/status"
+)
+
+type clientPKIInfo struct {
+	realm     string
+	principal string
+	job       string
+}
+
+func (c *clientPKIInfo) String() string {
+	return fmt.Sprintf("job=%q, principal=%q, realm=%q", c.job, c.principal, c.realm)
+}
+
+func parseClientName(realm, name string) (*clientPKIInfo, error) {
+	if !strings.HasSuffix(name, "."+realm) {
+		return nil, fmt.Errorf("invalid realm")
+	}
+	service := strings.TrimSuffix(name, "."+realm)
+	parts := strings.Split(service, ".")
+	if len(parts) != 2 {
+		return nil, fmt.Errorf("invalid service")
+	}
+	return &clientPKIInfo{
+		realm:     realm,
+		principal: parts[1],
+		job:       parts[0],
+	}, nil
+}
+
+const (
+	ctxKeyPKIInfo = "hscloud-pki-info"
+)
+
+func withPKIInfo(ctx context.Context, c *clientPKIInfo) context.Context {
+	tr, ok := trace.FromContext(ctx)
+	if ok {
+		tr.LazyPrintf("PKI Peer: %s", c.String())
+	}
+	return context.WithValue(ctx, ctxKeyPKIInfo, c)
+}
+
+func (s *server) unaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
+	peer, ok := peer.FromContext(ctx)
+	if !ok {
+		s.trace(ctx, "Could not establish identity of peer.")
+		return nil, status.Error(codes.InvalidArgument, "no peer info")
+	}
+
+	authInfo, ok := peer.AuthInfo.(credentials.TLSInfo)
+	if !ok {
+		s.trace(ctx, "Could not establish TLS identity of peer.")
+		return nil, status.Error(codes.InvalidArgument, "no TLS peer info")
+	}
+
+	chains := authInfo.State.VerifiedChains
+	if len(chains) != 1 {
+		s.trace(ctx, "No trusted chain found.")
+		return nil, status.Error(codes.InvalidArgument, "invalid TLS certificate")
+	}
+	chain := chains[0]
+
+	certDNs := make([]string, len(chain))
+	for i, cert := range chain {
+		certDNs[i] = cert.Subject.String()
+	}
+	s.trace(ctx, "TLS chain: %s", strings.Join(certDNs, ", "))
+
+	clientInfo, err := parseClientName(s.opts.pkiRealm, chain[0].Subject.CommonName)
+	if err != nil {
+		s.trace(ctx, "Could not parse certificate DN: %v", err)
+		return nil, status.Error(codes.InvalidArgument, "invalid TLS CommonName")
+	}
+	ctx = withPKIInfo(ctx, clientInfo)
+
+	return handler(ctx, req)
+}
diff --git a/pki/.gitignore b/pki/.gitignore
new file mode 100644
index 0000000..6d26d49
--- /dev/null
+++ b/pki/.gitignore
@@ -0,0 +1,3 @@
+*csr
+*pem
+*json
diff --git a/pki/clean.sh b/pki/clean.sh
new file mode 100755
index 0000000..490223d
--- /dev/null
+++ b/pki/clean.sh
@@ -0,0 +1,6 @@
+#!/bin/sh
+
+set -e -x
+
+rm *pem
+rm *csr
diff --git a/pki/gen.sh b/pki/gen.sh
new file mode 100755
index 0000000..e09e9f3
--- /dev/null
+++ b/pki/gen.sh
@@ -0,0 +1,7 @@
+#!/bin/sh
+
+set -e -x
+
+test -f ca.pem || ( cfssl gencert -initca ca_csr.json | cfssljson -bare ca )
+test -f service.pem || ( cfssl gencert -ca=ca.pem -ca-key=ca-key.pem -config=ca_config.json -profile=test service_csr.json | cfssljson -bare service )
+test -f client.pem || ( cfssl gencert -ca=ca.pem -ca-key=ca-key.pem -config=ca_config.json -profile=test client_csr.json | cfssljson -bare client )
diff --git a/proto/.gitignore b/proto/.gitignore
new file mode 100644
index 0000000..46ddcab
--- /dev/null
+++ b/proto/.gitignore
@@ -0,0 +1 @@
+arista.pb.go
diff --git a/proto/generate.go b/proto/generate.go
new file mode 100644
index 0000000..92f2720
--- /dev/null
+++ b/proto/generate.go
@@ -0,0 +1,3 @@
+//go:generate protoc -I.. ../arista.proto --go_out=plugins=grpc:.
+
+package proto
diff --git a/service.go b/service.go
new file mode 100644
index 0000000..0010ff9
--- /dev/null
+++ b/service.go
@@ -0,0 +1,54 @@
+package main
+
+import (
+	"context"
+
+	"github.com/golang/glog"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+
+	pb "code.hackerspace.pl/q3k/arista-proxy/proto"
+)
+
+func (s *server) ShowVersion(ctx context.Context, req *pb.ShowVersionRequest) (*pb.ShowVersionResponse, error) {
+	var version []struct {
+		ModelName        string  `json:"modelName"`
+		InternalVersion  string  `json:"internalVersion"`
+		SystemMacAddress string  `json:"systemMacAddress"`
+		SerialNumber     string  `json:"serialNumber"`
+		MemTotal         int64   `json:"memTotal"`
+		BootupTimestamp  float64 `json:"bootupTimestamp"`
+		MemFree          int64   `json:"memFree"`
+		Version          string  `json:"version"`
+		Architecture     string  `json:"architecture"`
+		InternalBuildId  string  `json:"internalBuildId"`
+		HardwareRevision string  `json:"hardwareRevision"`
+	}
+
+	err := s.arista.structuredCall(&version, "show version")
+	if err != nil {
+		glog.Errorf("EOS Capi: show version: %v", err)
+		return nil, status.Error(codes.Unavailable, "EOS Capi call failed")
+	}
+
+	if len(version) != 1 {
+		glog.Errorf("Expected 1-length result, got %d", len(version))
+		return nil, status.Error(codes.Internal, "Internal error")
+	}
+
+	d := version[0]
+
+	return &pb.ShowVersionResponse{
+		ModelName:        d.ModelName,
+		InternalVersion:  d.InternalVersion,
+		SystemMacAddress: d.SystemMacAddress,
+		SerialNumber:     d.SerialNumber,
+		MemTotal:         d.MemTotal,
+		BootupTimestamp:  d.BootupTimestamp,
+		MemFree:          d.MemFree,
+		Version:          d.Version,
+		Architecture:     d.Architecture,
+		InternalBuildId:  d.InternalBuildId,
+		HardwareRevision: d.HardwareRevision,
+	}, nil
+}