commit e08e6da775f56a0c8eff33a3e6bbedaa48182abc Author: Sergiusz Bazanski Date: Mon Aug 27 20:40:10 2018 +0100 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..045c22e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +arista-proxy +*swp diff --git a/arista.proto b/arista.proto new file mode 100644 index 00000000..75fc1f3a --- /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 00000000..ddc978fe --- /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 00000000..6c689770 --- /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 00000000..fdb4e346 --- /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 00000000..6d26d494 --- /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 00000000..490223d1 --- /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 00000000..e09e9f3b --- /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 00000000..46ddcabf --- /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 00000000..92f2720a --- /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 00000000..0010ff98 --- /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 +}