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
+}