Merge branch 'master' of /home/q3k/Projects/hscloud/go/src/code.hackerspace.pl/q3k/topo
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d9568ca
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+*swp
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..bbb4580
--- /dev/null
+++ b/README.md
@@ -0,0 +1,13 @@
+hscloud monorepo
+================
+
+This is the main git repository for all HSWAW cloud related code.
+
+Building stuff
+--------------
+
+No bazel yet :^).
+
+    go get -d code.hackerspace.pl/hscloud
+    go generate code.hackerspace.pl/hscloud/...
+    go build code.hackerspace.pl/hcloud/go/svc/arista-proxy
diff --git a/go/mirko/README b/go/mirko/README
new file mode 100644
index 0000000..ba8be71
--- /dev/null
+++ b/go/mirko/README
@@ -0,0 +1,54 @@
+Mirko, the HSWAW microservice helper library
+============================================
+
+Wanna write a Go microservice for HSWAW? Can't be arsed to copy paste code? This is the library for you!
+
+Usage (dev)
+-----------
+
+    package main
+
+    import (
+        "code.hackerspace.pl/hscloud/go/mirko"
+    )
+
+    func main() {
+        m := mirko.New()
+
+        // setup/checks before TCP ports are opened...
+        // ...
+
+        if err := m.Listen(); err != nil {
+            glog.Exitf("Listen(): %v", err)
+        }
+
+        // register your gRPC and http handlers...
+        // (relfection and basic debug http is automatically registered)
+        // pb.RegisterFooServer(m.GRPC(), s)
+        // m.HTTPMux().HandleFunc("/debug/foo", fooHandler)
+
+        if err := m.Serve(); err != nil {
+            glog.Exitf("Serve(): %v", err)
+        }
+
+        // start any other background processing...
+        // (you can use m.Context() to get a context that will get
+        // canceled when the service is about to shut down)
+
+        <-m.Done()
+    }
+
+Usage (running)
+---------------
+
+The following flags are automatically registered:
+
+ - `-listen_address` (default: `127.0.0.1:4200`): where to listen for gRPC requests
+ - `-debug_address` (default: `127.0.0.1:4201`): where to listen for debug HTTP requests
+ - `-debug_allow_all` (default: false): whether to allow all IP address (vs. localhost) to connect to debug endpoint
+
+The following debug HTTP handlers are installed:
+
+ - `/debug/status`: show the [statusz](https://github.com/q3k/statusz) page
+ - `/debug/requests`: show the [net/trace](https://godoc.org/golang.org/x/net/trace) page (including gRPC traces)
+
diff --git a/go/mirko/mirko.go b/go/mirko/mirko.go
new file mode 100644
index 0000000..46c2987
--- /dev/null
+++ b/go/mirko/mirko.go
@@ -0,0 +1,206 @@
+package mirko
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"net"
+	"net/http"
+	"os"
+	"os/signal"
+	"time"
+
+	"code.hackerspace.pl/hscloud/go/pki"
+	"github.com/golang/glog"
+	"github.com/q3k/statusz"
+	"golang.org/x/net/trace"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/reflection"
+)
+
+var (
+	flagListenAddress string
+	flagDebugAddress  string
+	flagDebugAllowAll bool
+)
+
+func init() {
+	flag.StringVar(&flagListenAddress, "listen_address", "127.0.0.1:4200", "gRPC listen address")
+	flag.StringVar(&flagDebugAddress, "debug_address", "127.0.0.1:4201", "HTTP debug/status listen address")
+	flag.BoolVar(&flagDebugAllowAll, "debug_allow_all", false, "HTTP debug/status available to everyone")
+	flag.Set("logtostderr", "true")
+}
+
+type Mirko struct {
+	grpcListen net.Listener
+	grpcServer *grpc.Server
+	httpListen net.Listener
+	httpServer *http.Server
+	httpMux    *http.ServeMux
+
+	ctx     context.Context
+	cancel  context.CancelFunc
+	waiters []chan bool
+}
+
+func New() *Mirko {
+	ctx, cancel := context.WithCancel(context.Background())
+	return &Mirko{
+		ctx:     ctx,
+		cancel:  cancel,
+		waiters: []chan bool{},
+	}
+}
+
+func authRequest(req *http.Request) (any, sensitive bool) {
+	host, _, err := net.SplitHostPort(req.RemoteAddr)
+	if err != nil {
+		host = req.RemoteAddr
+	}
+
+	if flagDebugAllowAll {
+		return true, true
+	}
+
+	switch host {
+	case "localhost", "127.0.0.1", "::1":
+		return true, true
+	default:
+		return false, false
+	}
+}
+
+func (m *Mirko) Listen() error {
+	grpc.EnableTracing = true
+	trace.AuthRequest = authRequest
+
+	grpcLis, err := net.Listen("tcp", flagListenAddress)
+	if err != nil {
+		return fmt.Errorf("net.Listen: %v", err)
+	}
+	m.grpcListen = grpcLis
+	m.grpcServer = grpc.NewServer(pki.WithServerHSPKI()...)
+	reflection.Register(m.grpcServer)
+
+	httpLis, err := net.Listen("tcp", flagDebugAddress)
+	if err != nil {
+		return fmt.Errorf("net.Listen: %v", err)
+	}
+
+	m.httpMux = http.NewServeMux()
+	// Canonical URLs
+	m.httpMux.HandleFunc("/debug/status", func(w http.ResponseWriter, r *http.Request) {
+		any, _ := authRequest(r)
+		if !any {
+			http.Error(w, "not allowed", http.StatusUnauthorized)
+			return
+		}
+		statusz.StatusHandler(w, r)
+	})
+	m.httpMux.HandleFunc("/debug/requests", trace.Traces)
+
+	// -z legacy URLs
+	m.httpMux.HandleFunc("/statusz", func(w http.ResponseWriter, r *http.Request) {
+		http.Redirect(w, r, "/debug/status", http.StatusSeeOther)
+	})
+	m.httpMux.HandleFunc("/rpcz", func(w http.ResponseWriter, r *http.Request) {
+		http.Redirect(w, r, "/debug/requests", http.StatusSeeOther)
+	})
+	m.httpMux.HandleFunc("/requestz", func(w http.ResponseWriter, r *http.Request) {
+		http.Redirect(w, r, "/debug/requests", http.StatusSeeOther)
+	})
+
+	// root redirect
+	m.httpMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		http.Redirect(w, r, "/debug/status", http.StatusSeeOther)
+	})
+
+	m.httpListen = httpLis
+	m.httpServer = &http.Server{
+		Addr:    flagDebugAddress,
+		Handler: m.httpMux,
+	}
+
+	return nil
+}
+
+// Trace logs debug information to either a context trace (if present)
+// or stderr (if not)
+func 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...)
+}
+
+// GRPC returns the microservice's grpc.Server object
+func (m *Mirko) GRPC() *grpc.Server {
+	if m.grpcServer == nil {
+		panic("GRPC() called before Listen()")
+	}
+	return m.grpcServer
+}
+
+// HTTPMux returns the microservice's debug HTTP mux
+func (m *Mirko) HTTPMux() *http.ServeMux {
+	if m.httpMux == nil {
+		panic("HTTPMux() called before Listen()")
+	}
+	return m.httpMux
+}
+
+// Context returns a background microservice context that will be canceled
+// when the service is shut down
+func (m *Mirko) Context() context.Context {
+	return m.ctx
+}
+
+// Done() returns a channel that will emit a value when the service is
+// shut down. This should be used in the main() function instead of a select{}
+// call, to allow the background context to be canceled fully.
+func (m *Mirko) Done() chan bool {
+	c := make(chan bool, 1)
+	m.waiters = append(m.waiters, c)
+	return c
+}
+
+// Serve starts serving HTTP and gRPC requests
+func (m *Mirko) Serve() error {
+	errs := make(chan error, 1)
+	go func() {
+		if err := m.grpcServer.Serve(m.grpcListen); err != nil {
+			errs <- err
+		}
+	}()
+	go func() {
+		if err := m.httpServer.Serve(m.httpListen); err != nil {
+			errs <- err
+		}
+	}()
+
+	signalCh := make(chan os.Signal, 1)
+	signal.Notify(signalCh, os.Interrupt)
+	go func() {
+		select {
+		case <-signalCh:
+			m.cancel()
+			time.Sleep(time.Second)
+			for _, w := range m.waiters {
+				w <- true
+			}
+		}
+	}()
+
+	ticker := time.NewTicker(1 * time.Second)
+	select {
+	case <-ticker.C:
+		glog.Infof("gRPC listening on %s", flagListenAddress)
+		glog.Infof("HTTP listening on %s", flagDebugAddress)
+		return nil
+	case err := <-errs:
+		return err
+	}
+}
diff --git a/go/pki/README.md b/go/pki/README.md
new file mode 100644
index 0000000..b84c32d
--- /dev/null
+++ b/go/pki/README.md
@@ -0,0 +1,96 @@
+HSCloud PKI
+===========
+
+a.k.a. API tokens are so 2012
+
+Introduction
+------------
+
+The HSCloud Public Key Infrastructure system is a lightweight specification on how microservices within the HSCloud ecosystem authenticate themselves.
+
+The driving force behind this being standardized is to make it very easy for developers to write new microservices and other tools that can mutually authenticate themselves without having to use public TLS certificates, API tokens or passwords.
+
+Each microservice or tool has a key/certificate pair that it uses to both serve incoming requests and to use as a client certificate when performing outgoing requests.
+
+We currently support gRPC as a first-class transport. Other transports (HTTPS for debug pages, HTTPS for JSON(-RPC)) are not yet implemented.
+
+Where do I get certificates from?
+---------------------------------
+
+The distribution of HSPKI certificates to production services is currently being designed (and will likely be based on Hashicorp Vault or a similar NIH tool). For development purposes, the `gen.sh` script in `dev-certs/` can be used to generate a temporary CA, service keypair and developer keypair.
+
+Concepts
+--------
+
+All certs for mutual auth have the following CN/SAN format:
+
+    <job>.<principal>.<realm>
+
+For example, if principal maps into a 'group' and job into a 'user':
+
+    arista-proxy-dcr01u23.cluster-management-prod.c.example.com
+
+    job = arista-proxy-dcr01u23
+    principal = cluster-management-prod
+    realm = c.example.com
+
+The Realm is a DNS name that is global to all jobs that need mutual authentication.
+
+The Principal is any name that carries significance for logical grouping of jobs.
+It can, but doesn't need to, group jobs by similar permissions.
+
+The Job is any name that identifies uniquely (within the principal) a security
+endpoint that describes a single security policy for a gRPC endpoint.
+
+The entire CN should be DNS resolvable into an IP address that would respond to
+gRPC requests on port 42000 (with a server TLS certificate that represents this CN) if the
+job represents a service.
+
+This maps nicely to the Kubernetes Cluster DNS format if you set `realm` to `svc.cluster.local`.
+Then, `principal` maps to a Kubernetes namespace, and `job` maps into a Kubernetes service.
+
+    arista-proxy-dcr01u23.infrastructure-prod.svc.cluster.local
+
+    job/service = arista-proxy-dcr01u23
+    principal/namespace = infrastructure-prod
+    realm = svc.cluster.local
+
+ACL, or How do I restrict access to my service?
+-----------------------------------------------
+
+Currently you'll have to manually check the PKI information via your language's library and reject unauthorized access within your handler. A unified ACL system with an external RBAC store is currently being designed.
+
+Go Library
+==========
+
+We provide a Go library that all microservices should use to interact with HSPKI.
+
+Usage with gRPC
+---------------
+
+In lieu of a godoc (soon (TM)), here's a quick usage example:
+
+
+    import (
+        "code.hackerspace.pl/hscloud/go/pki"
+    )
+    ...
+    g := grpc.NewServer(pki.WithServerHSPKI()...)
+    pb.RegiserXXXServer(g, service)
+    ...
+
+Flags
+-----
+
+Once linked into your program, the following flags will be automatically present:
+
+    -hspki_realm string
+        PKI realm (default "svc.cluster.local")
+    -hspki_tls_ca_path string
+        Path to PKI CA certificate (default "pki/ca.pem")
+    -hspki_tls_certificate_path string
+        Path to PKI service certificate (default "pki/service.pem")
+    -hspki_tls_key_path string
+        Path to PKI service private key (default "pki/service-key.pem")
+
+These should be set accordingly in your development environment.
diff --git a/go/pki/dev-certs/.gitignore b/go/pki/dev-certs/.gitignore
new file mode 100644
index 0000000..e24607d
--- /dev/null
+++ b/go/pki/dev-certs/.gitignore
@@ -0,0 +1,2 @@
+*csr
+*pem
diff --git a/go/pki/dev-certs/ca_config.json b/go/pki/dev-certs/ca_config.json
new file mode 100644
index 0000000..113a08f
--- /dev/null
+++ b/go/pki/dev-certs/ca_config.json
@@ -0,0 +1,13 @@
+{
+  "signing": {
+    "default": {
+      "expiry": "8760h"
+    },
+    "profiles": {
+      "test": {
+        "usages": ["signing", "key encipherment", "server auth", "client auth"],
+        "expiry": "8760h"
+      }
+    }
+  }
+}
diff --git a/go/pki/dev-certs/ca_csr.json b/go/pki/dev-certs/ca_csr.json
new file mode 100644
index 0000000..b24c638
--- /dev/null
+++ b/go/pki/dev-certs/ca_csr.json
@@ -0,0 +1,11 @@
+{
+    "names": [
+        {
+            "C":  "US",
+            "L":  "San Francisco",
+            "O":  "Internet Widgets, Inc.",
+            "OU": "WWW",
+            "ST": "California"
+        }
+    ]
+}
diff --git a/go/pki/dev-certs/clean.sh b/go/pki/dev-certs/clean.sh
new file mode 100755
index 0000000..490223d
--- /dev/null
+++ b/go/pki/dev-certs/clean.sh
@@ -0,0 +1,6 @@
+#!/bin/sh
+
+set -e -x
+
+rm *pem
+rm *csr
diff --git a/go/pki/dev-certs/client_csr.json b/go/pki/dev-certs/client_csr.json
new file mode 100644
index 0000000..26fc041
--- /dev/null
+++ b/go/pki/dev-certs/client_csr.json
@@ -0,0 +1,12 @@
+{
+    "CN": "developer.humans.svc.cluster.local",
+    "names": [
+        {
+            "C":  "US",
+            "L":  "San Francisco",
+            "O":  "Internet Widgets, Inc.",
+            "OU": "WWW",
+            "ST": "California"
+        }
+    ]
+}
diff --git a/go/pki/dev-certs/gen.sh b/go/pki/dev-certs/gen.sh
new file mode 100755
index 0000000..e09e9f3
--- /dev/null
+++ b/go/pki/dev-certs/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/go/pki/dev-certs/service_csr.json b/go/pki/dev-certs/service_csr.json
new file mode 100644
index 0000000..72c910e
--- /dev/null
+++ b/go/pki/dev-certs/service_csr.json
@@ -0,0 +1,12 @@
+{
+    "CN": "test.arista-proxy.svc.cluster.local",
+    "names": [
+        {
+            "C":  "US",
+            "L":  "San Francisco",
+            "O":  "Internet Widgets, Inc.",
+            "OU": "WWW",
+            "ST": "California"
+        }
+    ]
+}
diff --git a/go/pki/grpc.go b/go/pki/grpc.go
new file mode 100644
index 0000000..f014a34
--- /dev/null
+++ b/go/pki/grpc.go
@@ -0,0 +1,216 @@
+package pki
+
+// Copyright 2018 Sergiusz Bazanski <q3k@hackerspace.pl>
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
+// IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+import (
+	"context"
+	"crypto/tls"
+	"crypto/x509"
+	"flag"
+	"fmt"
+	"io/ioutil"
+	"strings"
+
+	"github.com/golang/glog"
+	"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"
+)
+
+var (
+	flagCAPath          string
+	flagCertificatePath string
+	flagKeyPath         string
+	flagPKIRealm        string
+
+	// Enable logging HSPKI info into traces
+	Trace = true
+	// Enable logging HSPKI info into glog
+	Log = false
+)
+
+const (
+	ctxKeyClientInfo = "hspki-client-info"
+)
+
+func init() {
+	flag.StringVar(&flagCAPath, "hspki_tls_ca_path", "pki/ca.pem", "Path to PKI CA certificate")
+	flag.StringVar(&flagCertificatePath, "hspki_tls_certificate_path", "pki/service.pem", "Path to PKI service certificate")
+	flag.StringVar(&flagKeyPath, "hspki_tls_key_path", "pki/service-key.pem", "Path to PKI service private key")
+	flag.StringVar(&flagPKIRealm, "hspki_realm", "svc.cluster.local", "PKI realm")
+}
+
+func maybeTrace(ctx context.Context, f string, args ...interface{}) {
+	if Log {
+		glog.Infof(f, args...)
+	}
+
+	if !Trace {
+		return
+	}
+
+	tr, ok := trace.FromContext(ctx)
+	if !ok {
+		if !Log {
+			fmtd := fmt.Sprintf(f, args...)
+			glog.Info("[no trace] %v", fmtd)
+		}
+		return
+	}
+	tr.LazyPrintf(f, args...)
+}
+
+func parseClientName(name string) (*ClientInfo, error) {
+	if !strings.HasSuffix(name, "."+flagPKIRealm) {
+		return nil, fmt.Errorf("invalid realm")
+	}
+	service := strings.TrimSuffix(name, "."+flagPKIRealm)
+	parts := strings.Split(service, ".")
+	if len(parts) != 2 {
+		return nil, fmt.Errorf("invalid job/principal format")
+	}
+	return &ClientInfo{
+		Realm:     flagPKIRealm,
+		Principal: parts[1],
+		Job:       parts[0],
+	}, nil
+}
+
+func withPKIInfo(ctx context.Context, c *ClientInfo) context.Context {
+	maybeTrace(ctx, "HSPKI: Applying ClientInfo: %s", c.String())
+	return context.WithValue(ctx, ctxKeyClientInfo, c)
+}
+
+func grpcInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
+	peer, ok := peer.FromContext(ctx)
+	if !ok {
+		maybeTrace(ctx, "HSPKI: Could not establish identity of peer.")
+		return nil, status.Errorf(codes.PermissionDenied, "no peer info")
+	}
+
+	authInfo, ok := peer.AuthInfo.(credentials.TLSInfo)
+	if !ok {
+		maybeTrace(ctx, "HSPKI: Could not establish TLS identity of peer.")
+		return nil, status.Errorf(codes.PermissionDenied, "no TLS certificate presented")
+	}
+
+	chains := authInfo.State.VerifiedChains
+	if len(chains) != 1 {
+		maybeTrace(ctx, "HSPKI: No trusted chains found.")
+		return nil, status.Errorf(codes.PermissionDenied, "no trusted TLS certificate presented")
+	}
+
+	chain := chains[0]
+
+	certDNs := make([]string, len(chain))
+	for i, cert := range chain {
+		certDNs[i] = cert.Subject.String()
+	}
+	maybeTrace(ctx, "HSPKI: Trust chain: %s", strings.Join(certDNs, ", "))
+
+	clientInfo, err := parseClientName(chain[0].Subject.CommonName)
+	if err != nil {
+		maybeTrace(ctx, "HSPKI: Invalid CN %q: %v", chain[0].Subject.CommonName, err)
+		return nil, status.Errorf(codes.PermissionDenied, "invalid TLS CN format")
+	}
+	ctx = withPKIInfo(ctx, clientInfo)
+	return handler(ctx, req)
+}
+
+// ClientInfo contains information about the HSPKI authentication data of the
+// gRPC client that has made the request.
+type ClientInfo struct {
+	Realm     string
+	Principal string
+	Job       string
+}
+
+// String returns a human-readable representation of the ClientInfo in the
+// form "job=foo, principal=bar, realm=baz".
+func (c *ClientInfo) String() string {
+	return fmt.Sprintf("job=%q, principal=%q, realm=%q", c.Job, c.Principal, c.Realm)
+}
+
+// ClientInfoFromContext returns ClientInfo from a gRPC service context.
+func ClientInfoFromContext(ctx context.Context) *ClientInfo {
+	v := ctx.Value(ctxKeyClientInfo)
+	if v == nil {
+		return nil
+	}
+	ci, ok := v.(*ClientInfo)
+	if !ok {
+		return nil
+	}
+	return ci
+}
+
+// WithServerHSPKI is a grpc.ServerOptions array that ensures that the gRPC server:
+// - runs with HSPKI TLS Service Certificate
+// - rejects all non_HSPKI compatible requests
+// - injects ClientInfo into the service context, which can be later retrieved
+//   using ClientInfoFromContext
+func WithServerHSPKI() []grpc.ServerOption {
+	if !flag.Parsed() {
+		glog.Exitf("WithServerHSPKI called before flag.Parse!")
+	}
+	serverCert, err := tls.LoadX509KeyPair(flagCertificatePath, flagKeyPath)
+	if err != nil {
+		glog.Exitf("WithServerHSPKI: cannot load service certificate/key: %v", err)
+	}
+
+	certPool := x509.NewCertPool()
+	ca, err := ioutil.ReadFile(flagCAPath)
+	if err != nil {
+		glog.Exitf("WithServerHSPKI: cannot load CA certificate: %v", err)
+	}
+	if ok := certPool.AppendCertsFromPEM(ca); !ok {
+		glog.Exitf("WithServerHSPKI: cannot use CA certificate: %v", err)
+	}
+
+	creds := grpc.Creds(credentials.NewTLS(&tls.Config{
+		ClientAuth:   tls.RequireAndVerifyClientCert,
+		Certificates: []tls.Certificate{serverCert},
+		ClientCAs:    certPool,
+	}))
+
+	interceptor := grpc.UnaryInterceptor(grpcInterceptor)
+
+	return []grpc.ServerOption{creds, interceptor}
+}
+
+func WithClientHSPKI() grpc.DialOption {
+	certPool := x509.NewCertPool()
+	ca, err := ioutil.ReadFile(flagCAPath)
+	if err != nil {
+		glog.Exitf("WithClientHSPKI: cannot load CA certificate: %v", err)
+	}
+	if ok := certPool.AppendCertsFromPEM(ca); !ok {
+		glog.Exitf("WithClientHSPKI: cannot use CA certificate: %v", err)
+	}
+
+	clientCert, err := tls.LoadX509KeyPair(flagCertificatePath, flagKeyPath)
+	if err != nil {
+		glog.Exitf("WithClientHSPKI: cannot load service certificate/key: %v", err)
+	}
+
+	creds := credentials.NewTLS(&tls.Config{
+		Certificates: []tls.Certificate{clientCert},
+		RootCAs:      certPool,
+	})
+	return grpc.WithTransportCredentials(creds)
+}
diff --git a/go/svc/arista-proxy/README.md b/go/svc/arista-proxy/README.md
new file mode 100644
index 0000000..60368dc
--- /dev/null
+++ b/go/svc/arista-proxy/README.md
@@ -0,0 +1,44 @@
+Old Shitty Arista eAPI/Capi <-> gRPC proxy
+==========================================
+
+Our Arista 7148S does not support gRPC/OpenConfig, so we have to make our own damn gRPC proxy.
+
+The schema is supposed to be 1:1 mapped to the JSON-RPC EAPI. This is just a dumb proxy.
+
+Getting and Building
+--------------------
+
+    go get -d -u code.hackerspace.pl/q3k/arista-proxy
+    go generate code.hackerspace.pl/q3k/arista-proxy/proto
+    go build code.hackerspace.pl/q3k/arista-proxy
+
+Debug Status Page
+-----------------
+
+The `debug_address` flag controls spawning an HTTP server useful for debugging. You can use it to inspect gRPC request and view general status information of the proxy.
+
+Flags
+-----
+
+    ./arista-proxy -help
+    Usage of ./arista-proxy:
+      -alsologtostderr
+        	log to standard error as well as files
+      -arista_api string
+        	Arista remote endpoint (default "http://admin:password@1.2.3.4:80/command-api")
+      -debug_address string
+        	Debug HTTP listen address, or empty to disable (default "127.0.0.1:42000")
+      -listen_address string
+        	gRPC listen address (default "127.0.0.1:43001")
+      -log_backtrace_at value
+        	when logging hits line file:N, emit a stack trace
+      -log_dir string
+        	If non-empty, write log files in this directory
+      -logtostderr
+        	log to standard error instead of files
+      -stderrthreshold value
+        	logs at or above this threshold go to stderr
+      -v value
+        	log level for V logs
+      -vmodule value
+        	comma-separated list of pattern=N settings for file-filtered logging
diff --git a/go/svc/arista-proxy/arista.proto b/go/svc/arista-proxy/arista.proto
new file mode 100644
index 0000000..d6bf105
--- /dev/null
+++ b/go/svc/arista-proxy/arista.proto
@@ -0,0 +1,31 @@
+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;
+};
+
+message ShowEnvironmentTemperatureRequest {
+};
+
+message ShowEnvironmentTemperatureResponse {
+};
+
+service AristaProxy {
+    rpc ShowVersion(ShowVersionRequest) returns (ShowVersionResponse);
+    rpc ShowEnvironmentTemperature(ShowEnvironmentTemperatureRequest) returns (ShowEnvironmentTemperatureResponse);
+};
diff --git a/go/svc/arista-proxy/main.go b/go/svc/arista-proxy/main.go
new file mode 100644
index 0000000..1227cb1
--- /dev/null
+++ b/go/svc/arista-proxy/main.go
@@ -0,0 +1,67 @@
+package main
+
+import (
+	"flag"
+	"fmt"
+
+	"code.hackerspace.pl/hscloud/go/mirko"
+	"github.com/golang/glog"
+	"github.com/ybbus/jsonrpc"
+
+	pb "code.hackerspace.pl/hscloud/go/svc/arista-proxy/proto"
+)
+
+var (
+	flagAristaAPI 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
+}
+
+type server struct {
+	arista *aristaClient
+}
+
+func main() {
+	flag.StringVar(&flagAristaAPI, "arista_api", "http://admin:password@1.2.3.4:80/command-api", "Arista remote endpoint")
+	flag.Parse()
+
+	arista := &aristaClient{
+		rpc: jsonrpc.NewClient(flagAristaAPI),
+	}
+
+	m := mirko.New()
+	if err := m.Listen(); err != nil {
+		glog.Exitf("Listen(): %v", err)
+	}
+
+	s := &server{
+		arista: arista,
+	}
+	pb.RegisterAristaProxyServer(m.GRPC(), s)
+
+	if err := m.Serve(); err != nil {
+		glog.Exitf("Serve(): %v", err)
+	}
+
+	select {}
+}
diff --git a/go/svc/arista-proxy/proto/.gitignore b/go/svc/arista-proxy/proto/.gitignore
new file mode 100644
index 0000000..46ddcab
--- /dev/null
+++ b/go/svc/arista-proxy/proto/.gitignore
@@ -0,0 +1 @@
+arista.pb.go
diff --git a/go/svc/arista-proxy/proto/generate.go b/go/svc/arista-proxy/proto/generate.go
new file mode 100644
index 0000000..92f2720
--- /dev/null
+++ b/go/svc/arista-proxy/proto/generate.go
@@ -0,0 +1,3 @@
+//go:generate protoc -I.. ../arista.proto --go_out=plugins=grpc:.
+
+package proto
diff --git a/go/svc/arista-proxy/service.go b/go/svc/arista-proxy/service.go
new file mode 100644
index 0000000..d7e2a29
--- /dev/null
+++ b/go/svc/arista-proxy/service.go
@@ -0,0 +1,97 @@
+package main
+
+import (
+	"context"
+
+	"github.com/golang/glog"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+
+	pb "code.hackerspace.pl/hscloud/go/svc/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
+}
+
+type temperatureSensor struct {
+	InAlertState       bool    `json:"inAlertState"`
+	MaxTemperature     float64 `json:"maxTemperature"`
+	RelPos             int64   `json:"relPos"`
+	Description        string  `json:"description"`
+	Name               string  `json:"name"`
+	AlertCount         int64   `json:"alertCount"`
+	CurrentTemperature float64 `json:"currentTemperature"`
+	OverheatThreshold  float64 `json:"overheatThreshold"`
+	CriticalThreshold  float64 `json:"criticalThreshold"`
+	HwStatus           string  `json:"hwStatus"`
+}
+
+func (s *server) ShowEnvironmentTemperature(ctx context.Context, req *pb.ShowEnvironmentTemperatureRequest) (*pb.ShowEnvironmentTemperatureResponse, error) {
+	var response []struct {
+		PowerSuppplySlots []struct {
+			TempSensors      []temperatureSensor `json:"tempSensors"`
+			EntPhysicalClass string              `json:"entPhysicalClass"`
+			RelPos           int64               `json:"relPos"`
+		} `json:"powerSupplySlots"`
+
+		ShutdownOnOverheat bool                `json:"shutdownOnOverheat"`
+		TempSensors        []temperatureSensor `json:"tempSensors"`
+		SystemStatus       string              `json:"systemStatus"`
+	}
+
+	err := s.arista.structuredCall(&response, "show environment temperature")
+	if err != nil {
+		glog.Errorf("EOS Capi: show environment temperature: %v", err)
+		return nil, status.Error(codes.Unavailable, "EOS Capi call failed")
+	}
+
+	if len(response) != 1 {
+		glog.Errorf("Expected 1-length result, got %d", len(response))
+		return nil, status.Error(codes.Internal, "Internal error")
+	}
+
+	d := response[0]
+	glog.Infof("%+v", d)
+
+	return &pb.ShowEnvironmentTemperatureResponse{}, nil
+}
diff --git a/go/svc/cmc-proxy/README.md b/go/svc/cmc-proxy/README.md
new file mode 100644
index 0000000..6e063f2
--- /dev/null
+++ b/go/svc/cmc-proxy/README.md
@@ -0,0 +1,39 @@
+Dell M1000e gRPC Proxy
+======================
+
+Cursedness level: 6.5/10 (regexp XML parsing, JSONP scraping, limited sessions).
+
+This is a small gRPC proxy to allow programmatic access to a Dell M1000e Chassis Management Controller. It's based on scraping the web interface, as the alternative (WSMAN) is even more ridiculous.
+
+Functionality
+-------------
+
+The only feature supported so far is getting information for an iDRAC KVM console. This can be used to run a iDRAC KVM proxy (to be implemented), or the original client.
+
+Usage
+-----
+
+    ./cmc-proxy -h
+
+Flags are self-explanatory. The proxy listens on gRPC and a status HTTP debug server.
+
+Example
+-------
+
+    $ grpc-dev -d '{"blade_num": 6}' cmc.q3k.svc.cluster.local:4200 proto.CMCProxy.GetKVMData
+    {
+      "arguments": [
+        "10.10.10.16:443",
+        "5901",
+        "oojo2obohhaWiu3A",
+        "1",
+        "0",
+        "3668",
+        "3669",
+        "511",
+        "5900",
+        "1",
+        "EN"
+      ]
+    }
+
diff --git a/go/svc/cmc-proxy/client.go b/go/svc/cmc-proxy/client.go
new file mode 100644
index 0000000..2735dbb
--- /dev/null
+++ b/go/svc/cmc-proxy/client.go
@@ -0,0 +1,346 @@
+package main
+
+import (
+	"context"
+	"crypto/tls"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"regexp"
+	"strings"
+
+	"code.hackerspace.pl/hscloud/go/mirko"
+	"github.com/cenkalti/backoff"
+	"github.com/golang/glog"
+)
+
+var (
+	reSessionCookie = regexp.MustCompile("'SESSION_COOKIE' : '([^']*)'")
+	reIpmiPriv      = regexp.MustCompile("'IPMI_PRIV' : ([^,]*)")
+	reExtPriv       = regexp.MustCompile("'EXT_PRIV' : ([^,]*)")
+	reSystemModel   = regexp.MustCompile("'SYSTEM_MODEL' : '([^']*)'")
+	reArgument      = regexp.MustCompile("<argument>([^<]*)</argument>")
+)
+
+var (
+	ErrorNoFreeSlot = fmt.Errorf("iDRAC reports no free slot")
+)
+
+type cmcRequestType int
+
+const (
+	cmcRequestKVMDetails cmcRequestType = iota
+)
+
+type cmcResponse struct {
+	data interface{}
+	err  error
+}
+
+type cmcRequest struct {
+	t        cmcRequestType
+	req      interface{}
+	res      chan cmcResponse
+	canceled bool
+}
+
+type KVMDetails struct {
+	arguments []string
+}
+
+type cmcClient struct {
+	session string
+	req     chan *cmcRequest
+}
+
+func (c *cmcClient) RequestKVMDetails(ctx context.Context, slot int) (*KVMDetails, error) {
+	r := &cmcRequest{
+		t:   cmcRequestKVMDetails,
+		req: slot,
+		res: make(chan cmcResponse, 1),
+	}
+	mirko.Trace(ctx, "cmcRequestKVMDetails: requesting...")
+	c.req <- r
+	mirko.Trace(ctx, "cmcRequestKVMDetails: requested.")
+
+	select {
+	case <-ctx.Done():
+		r.canceled = true
+		return nil, context.Canceled
+	case res := <-r.res:
+		mirko.Trace(ctx, "cmcRequestKVMDetails: got response")
+		if res.err != nil {
+			return nil, res.err
+		}
+		return res.data.(*KVMDetails), nil
+	}
+}
+
+func NewCMCClient() *cmcClient {
+	return &cmcClient{
+		req: make(chan *cmcRequest, 4),
+	}
+}
+
+func (c *cmcClient) Run(ctx context.Context) {
+	for {
+		select {
+		case <-ctx.Done():
+			c.logout()
+			return
+		case msg := <-c.req:
+			c.handle(msg)
+		}
+	}
+}
+
+func (c *cmcClient) handle(r *cmcRequest) {
+	switch {
+	case r.t == cmcRequestKVMDetails:
+		var details *KVMDetails
+		slot := r.req.(int)
+		err := backoff.Retry(func() error {
+			if err := c.login(); err != nil {
+				return err
+			}
+			url, err := c.getiDRACURL(slot)
+			if err != nil {
+				return err
+			}
+			details, err = c.getiDRACJNLP(url)
+			return err
+		}, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), 2))
+
+		if err != nil {
+			r.res <- cmcResponse{err: err}
+		}
+
+		r.res <- cmcResponse{data: details}
+	default:
+		panic("invalid cmcRequestType")
+	}
+}
+
+func makeUrl(path string) string {
+	if strings.HasSuffix(flagCMCAddress, "/") {
+		return flagCMCAddress + path
+	}
+	return flagCMCAddress + "/" + path
+}
+
+func (c *cmcClient) transport() *http.Transport {
+	return &http.Transport{
+		TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+	}
+}
+
+func (c *cmcClient) addCookies(req *http.Request) {
+	req.AddCookie(&http.Cookie{Name: "custom_domain", Value: ""})
+	req.AddCookie(&http.Cookie{Name: "domain_selected", Value: "This Chassis"})
+	if c.session != "" {
+		glog.Infof("Session cookie: %v", c.session)
+		req.AddCookie(&http.Cookie{Name: "sid", Value: c.session})
+	}
+}
+
+func (c *cmcClient) getiDRACURL(slot int) (string, error) {
+	if c.session == "" {
+		return "", fmt.Errorf("not logged in")
+	}
+
+	url := makeUrl(pathiDRACURL) + fmt.Sprintf("?vKVM=1&serverSlot=%d", slot)
+
+	req, err := http.NewRequest("GET", url, nil)
+	if err != nil {
+		return "", fmt.Errorf("GET prepare to %s failed: %v", pathLogin, err)
+	}
+	c.addCookies(req)
+
+	cl := &http.Client{
+		Transport: c.transport(),
+		CheckRedirect: func(req *http.Request, via []*http.Request) error {
+			return http.ErrUseLastResponse
+		},
+	}
+	resp, err := cl.Do(req)
+	if err != nil {
+		return "", fmt.Errorf("GET to %s failed: %v", pathLogin, err)
+	}
+
+	if resp.StatusCode != 302 {
+		return "", fmt.Errorf("expected 302 on iDRAC URL redirect, got %v instead", resp.Status)
+	}
+
+	loc, _ := resp.Location()
+
+	if !strings.Contains(loc.String(), "cmc_sess_id") {
+		c.session = ""
+		return "", fmt.Errorf("redirect URL contains no session ID - session timed out?")
+	}
+
+	return loc.String(), nil
+}
+
+func (c *cmcClient) getiDRACJNLP(loginUrl string) (*KVMDetails, error) {
+	lurl, err := url.Parse(loginUrl)
+	if err != nil {
+		return nil, err
+	}
+
+	sessid := lurl.Query().Get("cmc_sess_id")
+	if sessid == "" {
+		return nil, fmt.Errorf("no cmc_sess_id in iDRAC login URL")
+	}
+
+	createURL := *lurl
+	createURL.Path = "/Applications/dellUI/RPC/WEBSES/create.asp"
+	createURL.RawQuery = ""
+
+	values := url.Values{}
+	values.Set("WEBVAR_USERNAME", "cmc")
+	values.Set("WEBVAR_PASSWORD", sessid)
+	values.Set("WEBVAR_ISCMCLOGIN", "1")
+	valuesString := values.Encode()
+	req, err := http.NewRequest("POST", createURL.String(), strings.NewReader(valuesString))
+
+	cl := &http.Client{
+		Transport: c.transport(),
+		CheckRedirect: func(req *http.Request, via []*http.Request) error {
+			return http.ErrUseLastResponse
+		},
+	}
+	resp, err := cl.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	data, _ := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	first := func(v [][]byte) string {
+		if len(v) < 1 {
+			return ""
+		}
+		return string(v[1])
+	}
+
+	sessionCookie := first(reSessionCookie.FindSubmatch(data))
+	ipmiPriv := first(reIpmiPriv.FindSubmatch(data))
+	extPriv := first(reExtPriv.FindSubmatch(data))
+	systemModel := first(reSystemModel.FindSubmatch(data))
+
+	if sessionCookie == "Failure_No_Free_Slot" {
+		return nil, ErrorNoFreeSlot
+	}
+
+	jnlpURL := *lurl
+	jnlpURL.Path = "/Applications/dellUI/Java/jviewer.jnlp"
+	jnlpURL.RawQuery = ""
+
+	req, err = http.NewRequest("GET", jnlpURL.String(), nil)
+	for _, cookie := range resp.Cookies() {
+		req.AddCookie(cookie)
+	}
+	req.AddCookie(&http.Cookie{Name: "SessionCookie", Value: sessionCookie})
+	req.AddCookie(&http.Cookie{Name: "SessionCookieUser", Value: "cmc"})
+	req.AddCookie(&http.Cookie{Name: "IPMIPriv", Value: ipmiPriv})
+	req.AddCookie(&http.Cookie{Name: "ExtPriv", Value: extPriv})
+	req.AddCookie(&http.Cookie{Name: "SystemModel", Value: systemModel})
+
+	resp, err = cl.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	data, err = ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	// yes we do parse xml with regex why are you asking
+	matches := reArgument.FindAllSubmatch(data, -1)
+
+	res := &KVMDetails{
+		arguments: []string{},
+	}
+	for _, match := range matches {
+		res.arguments = append(res.arguments, string(match[1]))
+	}
+
+	return res, nil
+}
+
+func (c *cmcClient) login() error {
+	if c.session != "" {
+		return nil
+	}
+
+	values := url.Values{}
+	values.Set("ST2", "NOTSET")
+	values.Set("user", flagCMCUsername)
+	values.Set("user_id", flagCMCUsername)
+	values.Set("password", flagCMCPassword)
+	values.Set("WEBSERVER_timeout", "1800")
+	values.Set("WEBSERVER_timeout_select", "1800")
+	valuesString := values.Encode()
+	req, err := http.NewRequest("POST", makeUrl(pathLogin), strings.NewReader(valuesString))
+	if err != nil {
+		return fmt.Errorf("POST prepare to %s failed: %v", pathLogin, err)
+	}
+	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+	c.addCookies(req)
+
+	cl := &http.Client{
+		Transport: c.transport(),
+		CheckRedirect: func(req *http.Request, via []*http.Request) error {
+			return http.ErrUseLastResponse
+		},
+	}
+	resp, err := cl.Do(req)
+	if err != nil {
+		return fmt.Errorf("POST to %s failed: %v", pathLogin, err)
+	}
+	glog.Infof("Login response: %s", resp.Status)
+	defer resp.Body.Close()
+	for _, cookie := range resp.Cookies() {
+		if cookie.Name == "sid" {
+			c.session = cookie.Value
+			break
+		}
+	}
+	if c.session == "" {
+		return fmt.Errorf("login unsuccesful")
+	}
+	return nil
+}
+
+func (c *cmcClient) logout() {
+	glog.Infof("Killing session..")
+	if c.session == "" {
+		return
+	}
+
+	req, err := http.NewRequest("GET", makeUrl(pathLogout), nil)
+	if err != nil {
+		glog.Errorf("GET prepare to %s failed: %v", pathLogin, err)
+	}
+	c.addCookies(req)
+
+	cl := &http.Client{
+		Transport: c.transport(),
+		CheckRedirect: func(req *http.Request, via []*http.Request) error {
+			return http.ErrUseLastResponse
+		},
+	}
+	resp, err := cl.Do(req)
+	if err != nil {
+		glog.Errorf("GET to %s failed: %v", pathLogin, err)
+	}
+	glog.Infof("Logout response: %s", resp.Status)
+	return
+}
diff --git a/go/svc/cmc-proxy/main.go b/go/svc/cmc-proxy/main.go
new file mode 100644
index 0000000..5ae09d0
--- /dev/null
+++ b/go/svc/cmc-proxy/main.go
@@ -0,0 +1,75 @@
+package main
+
+import (
+	"context"
+	"flag"
+
+	"code.hackerspace.pl/hscloud/go/mirko"
+	"github.com/golang/glog"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+
+	pb "code.hackerspace.pl/hscloud/go/svc/cmc-proxy/proto"
+)
+
+var (
+	flagCMCAddress  string
+	flagCMCUsername string
+	flagCMCPassword string
+)
+
+const (
+	pathLogin    = "cgi-bin/webcgi/login"
+	pathLogout   = "cgi-bin/webcgi/logout"
+	pathiDRACURL = "cgi-bin/webcgi/blade_iDRAC_url"
+)
+
+func init() {
+	flag.Set("logtostderr", "true")
+}
+
+type service struct {
+	cmc *cmcClient
+}
+
+func (s *service) GetKVMData(ctx context.Context, req *pb.GetKVMDataRequest) (*pb.GetKVMDataResponse, error) {
+	if req.BladeNum < 1 || req.BladeNum > 16 {
+		return nil, status.Error(codes.InvalidArgument, "blade_num must be [1,16]")
+	}
+
+	details, err := s.cmc.RequestKVMDetails(ctx, int(req.BladeNum))
+	if err != nil {
+		glog.Errorf("RequestKVMDetails(_, %d): %v", req.BladeNum, err)
+		return nil, status.Error(codes.Unavailable, "CMC unavailable")
+	}
+
+	return &pb.GetKVMDataResponse{
+		Arguments: details.arguments,
+	}, nil
+}
+
+func main() {
+	flag.StringVar(&flagCMCAddress, "cmc_address", "https://10.10.10.10", "URL of Dell M1000e CMC")
+	flag.StringVar(&flagCMCUsername, "cmc_username", "root", "Login username for CMC")
+	flag.StringVar(&flagCMCPassword, "cmc_password", "", "Login password for CMC")
+	flag.Parse()
+
+	m := mirko.New()
+	if err := m.Listen(); err != nil {
+		glog.Exitf("Could not listen: %v", err)
+	}
+
+	s := &service{
+		cmc: NewCMCClient(),
+	}
+	pb.RegisterCMCProxyServer(m.GRPC(), s)
+
+	if err := m.Serve(); err != nil {
+		glog.Exitf("Could not run: %v", err)
+	}
+
+	go s.cmc.Run(m.Context())
+	glog.Info("Running.")
+
+	<-m.Done()
+}
diff --git a/go/svc/cmc-proxy/proto/generate.go b/go/svc/cmc-proxy/proto/generate.go
new file mode 100644
index 0000000..fc6193d
--- /dev/null
+++ b/go/svc/cmc-proxy/proto/generate.go
@@ -0,0 +1,3 @@
+//go:generate protoc -I.. ../proxy.proto --go_out=plugins=grpc:.
+
+package proto
diff --git a/go/svc/cmc-proxy/proto/proxy.pb.go b/go/svc/cmc-proxy/proto/proxy.pb.go
new file mode 100644
index 0000000..ff1d00b
--- /dev/null
+++ b/go/svc/cmc-proxy/proto/proxy.pb.go
@@ -0,0 +1,194 @@
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// source: proxy.proto
+
+package proto
+
+import (
+	fmt "fmt"
+	proto "github.com/golang/protobuf/proto"
+	context "golang.org/x/net/context"
+	grpc "google.golang.org/grpc"
+	math "math"
+)
+
+// Reference imports to suppress errors if they are not otherwise used.
+var _ = proto.Marshal
+var _ = fmt.Errorf
+var _ = math.Inf
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the proto package it is being compiled against.
+// A compilation error at this line likely means your copy of the
+// proto package needs to be updated.
+const _ = proto.ProtoPackageIsVersion2 // please upgrade the proto package
+
+type GetKVMDataRequest struct {
+	BladeNum             int64    `protobuf:"varint,1,opt,name=blade_num,json=bladeNum,proto3" json:"blade_num,omitempty"`
+	XXX_NoUnkeyedLiteral struct{} `json:"-"`
+	XXX_unrecognized     []byte   `json:"-"`
+	XXX_sizecache        int32    `json:"-"`
+}
+
+func (m *GetKVMDataRequest) Reset()         { *m = GetKVMDataRequest{} }
+func (m *GetKVMDataRequest) String() string { return proto.CompactTextString(m) }
+func (*GetKVMDataRequest) ProtoMessage()    {}
+func (*GetKVMDataRequest) Descriptor() ([]byte, []int) {
+	return fileDescriptor_700b50b08ed8dbaf, []int{0}
+}
+
+func (m *GetKVMDataRequest) XXX_Unmarshal(b []byte) error {
+	return xxx_messageInfo_GetKVMDataRequest.Unmarshal(m, b)
+}
+func (m *GetKVMDataRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
+	return xxx_messageInfo_GetKVMDataRequest.Marshal(b, m, deterministic)
+}
+func (m *GetKVMDataRequest) XXX_Merge(src proto.Message) {
+	xxx_messageInfo_GetKVMDataRequest.Merge(m, src)
+}
+func (m *GetKVMDataRequest) XXX_Size() int {
+	return xxx_messageInfo_GetKVMDataRequest.Size(m)
+}
+func (m *GetKVMDataRequest) XXX_DiscardUnknown() {
+	xxx_messageInfo_GetKVMDataRequest.DiscardUnknown(m)
+}
+
+var xxx_messageInfo_GetKVMDataRequest proto.InternalMessageInfo
+
+func (m *GetKVMDataRequest) GetBladeNum() int64 {
+	if m != nil {
+		return m.BladeNum
+	}
+	return 0
+}
+
+type GetKVMDataResponse struct {
+	Arguments            []string `protobuf:"bytes,1,rep,name=arguments,proto3" json:"arguments,omitempty"`
+	XXX_NoUnkeyedLiteral struct{} `json:"-"`
+	XXX_unrecognized     []byte   `json:"-"`
+	XXX_sizecache        int32    `json:"-"`
+}
+
+func (m *GetKVMDataResponse) Reset()         { *m = GetKVMDataResponse{} }
+func (m *GetKVMDataResponse) String() string { return proto.CompactTextString(m) }
+func (*GetKVMDataResponse) ProtoMessage()    {}
+func (*GetKVMDataResponse) Descriptor() ([]byte, []int) {
+	return fileDescriptor_700b50b08ed8dbaf, []int{1}
+}
+
+func (m *GetKVMDataResponse) XXX_Unmarshal(b []byte) error {
+	return xxx_messageInfo_GetKVMDataResponse.Unmarshal(m, b)
+}
+func (m *GetKVMDataResponse) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
+	return xxx_messageInfo_GetKVMDataResponse.Marshal(b, m, deterministic)
+}
+func (m *GetKVMDataResponse) XXX_Merge(src proto.Message) {
+	xxx_messageInfo_GetKVMDataResponse.Merge(m, src)
+}
+func (m *GetKVMDataResponse) XXX_Size() int {
+	return xxx_messageInfo_GetKVMDataResponse.Size(m)
+}
+func (m *GetKVMDataResponse) XXX_DiscardUnknown() {
+	xxx_messageInfo_GetKVMDataResponse.DiscardUnknown(m)
+}
+
+var xxx_messageInfo_GetKVMDataResponse proto.InternalMessageInfo
+
+func (m *GetKVMDataResponse) GetArguments() []string {
+	if m != nil {
+		return m.Arguments
+	}
+	return nil
+}
+
+func init() {
+	proto.RegisterType((*GetKVMDataRequest)(nil), "proto.GetKVMDataRequest")
+	proto.RegisterType((*GetKVMDataResponse)(nil), "proto.GetKVMDataResponse")
+}
+
+func init() { proto.RegisterFile("proxy.proto", fileDescriptor_700b50b08ed8dbaf) }
+
+var fileDescriptor_700b50b08ed8dbaf = []byte{
+	// 156 bytes of a gzipped FileDescriptorProto
+	0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0xe2, 0x2e, 0x28, 0xca, 0xaf,
+	0xa8, 0xd4, 0x2b, 0x28, 0xca, 0x2f, 0xc9, 0x17, 0x62, 0x05, 0x53, 0x4a, 0x06, 0x5c, 0x82, 0xee,
+	0xa9, 0x25, 0xde, 0x61, 0xbe, 0x2e, 0x89, 0x25, 0x89, 0x41, 0xa9, 0x85, 0xa5, 0xa9, 0xc5, 0x25,
+	0x42, 0xd2, 0x5c, 0x9c, 0x49, 0x39, 0x89, 0x29, 0xa9, 0xf1, 0x79, 0xa5, 0xb9, 0x12, 0x8c, 0x0a,
+	0x8c, 0x1a, 0xcc, 0x41, 0x1c, 0x60, 0x01, 0xbf, 0xd2, 0x5c, 0x25, 0x23, 0x2e, 0x21, 0x64, 0x1d,
+	0xc5, 0x05, 0xf9, 0x79, 0xc5, 0xa9, 0x42, 0x32, 0x5c, 0x9c, 0x89, 0x45, 0xe9, 0xa5, 0xb9, 0xa9,
+	0x79, 0x25, 0xc5, 0x12, 0x8c, 0x0a, 0xcc, 0x1a, 0x9c, 0x41, 0x08, 0x01, 0x23, 0x5f, 0x2e, 0x0e,
+	0x67, 0x5f, 0xe7, 0x00, 0x90, 0xf5, 0x42, 0x8e, 0x5c, 0x5c, 0x08, 0xfd, 0x42, 0x12, 0x10, 0xe7,
+	0xe8, 0x61, 0x38, 0x42, 0x4a, 0x12, 0x8b, 0x0c, 0xc4, 0xb2, 0x24, 0x36, 0xb0, 0x8c, 0x31, 0x20,
+	0x00, 0x00, 0xff, 0xff, 0x09, 0x00, 0x2b, 0x54, 0xd1, 0x00, 0x00, 0x00,
+}
+
+// Reference imports to suppress errors if they are not otherwise used.
+var _ context.Context
+var _ grpc.ClientConn
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+const _ = grpc.SupportPackageIsVersion4
+
+// CMCProxyClient is the client API for CMCProxy service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream.
+type CMCProxyClient interface {
+	GetKVMData(ctx context.Context, in *GetKVMDataRequest, opts ...grpc.CallOption) (*GetKVMDataResponse, error)
+}
+
+type cMCProxyClient struct {
+	cc *grpc.ClientConn
+}
+
+func NewCMCProxyClient(cc *grpc.ClientConn) CMCProxyClient {
+	return &cMCProxyClient{cc}
+}
+
+func (c *cMCProxyClient) GetKVMData(ctx context.Context, in *GetKVMDataRequest, opts ...grpc.CallOption) (*GetKVMDataResponse, error) {
+	out := new(GetKVMDataResponse)
+	err := c.cc.Invoke(ctx, "/proto.CMCProxy/GetKVMData", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// CMCProxyServer is the server API for CMCProxy service.
+type CMCProxyServer interface {
+	GetKVMData(context.Context, *GetKVMDataRequest) (*GetKVMDataResponse, error)
+}
+
+func RegisterCMCProxyServer(s *grpc.Server, srv CMCProxyServer) {
+	s.RegisterService(&_CMCProxy_serviceDesc, srv)
+}
+
+func _CMCProxy_GetKVMData_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(GetKVMDataRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(CMCProxyServer).GetKVMData(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/proto.CMCProxy/GetKVMData",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(CMCProxyServer).GetKVMData(ctx, req.(*GetKVMDataRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+var _CMCProxy_serviceDesc = grpc.ServiceDesc{
+	ServiceName: "proto.CMCProxy",
+	HandlerType: (*CMCProxyServer)(nil),
+	Methods: []grpc.MethodDesc{
+		{
+			MethodName: "GetKVMData",
+			Handler:    _CMCProxy_GetKVMData_Handler,
+		},
+	},
+	Streams:  []grpc.StreamDesc{},
+	Metadata: "proxy.proto",
+}
diff --git a/go/svc/cmc-proxy/proxy.proto b/go/svc/cmc-proxy/proxy.proto
new file mode 100644
index 0000000..f34b905
--- /dev/null
+++ b/go/svc/cmc-proxy/proxy.proto
@@ -0,0 +1,15 @@
+syntax = "proto3";
+
+package proto;
+
+message GetKVMDataRequest {
+    int64 blade_num = 1;
+}
+
+message GetKVMDataResponse {
+    repeated string arguments = 1;
+}
+
+service CMCProxy {
+    rpc GetKVMData(GetKVMDataRequest) returns (GetKVMDataResponse);
+}
diff --git a/go/svc/m6220-proxy/cli.go b/go/svc/m6220-proxy/cli.go
new file mode 100644
index 0000000..b9642cf
--- /dev/null
+++ b/go/svc/m6220-proxy/cli.go
@@ -0,0 +1,243 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/golang/glog"
+	"github.com/ziutek/telnet"
+	"golang.org/x/net/trace"
+)
+
+type cliClient struct {
+	conn *telnet.Conn
+
+	username string
+	password string
+
+	loggedIn       bool
+	promptHostname string
+}
+
+func newCliClient(c *telnet.Conn, username, password string) *cliClient {
+	return &cliClient{
+		conn:     c,
+		username: username,
+		password: password,
+	}
+}
+
+func (c *cliClient) readUntil(ctx context.Context, delims ...string) (string, error) {
+	chStr := make(chan string, 1)
+	chErr := make(chan error, 1)
+	go func() {
+		s, err := c.conn.ReadUntil(delims...)
+		if err != nil {
+			chErr <- err
+			return
+		}
+		chStr <- string(s)
+	}()
+
+	select {
+	case <-ctx.Done():
+		return "", fmt.Errorf("context done")
+	case err := <-chErr:
+		c.trace(ctx, "readUntil failed: %v", err)
+		return "", err
+	case s := <-chStr:
+		c.trace(ctx, "readUntil <- %q", s)
+		return s, nil
+
+	}
+}
+
+func (c *cliClient) readString(ctx context.Context, delim byte) (string, error) {
+	chStr := make(chan string, 1)
+	chErr := make(chan error, 1)
+	go func() {
+		s, err := c.conn.ReadString(delim)
+		if err != nil {
+			chErr <- err
+			return
+		}
+		chStr <- s
+	}()
+
+	select {
+	case <-ctx.Done():
+		return "", fmt.Errorf("context done")
+	case err := <-chErr:
+		c.trace(ctx, "readString failed: %v", err)
+		return "", err
+	case s := <-chStr:
+		c.trace(ctx, "readString <- %q", s)
+		return s, nil
+
+	}
+}
+
+func (c *cliClient) writeLine(ctx context.Context, s string) error {
+	n, err := c.conn.Write([]byte(s + "\n"))
+	if got, want := n, len(s)+1; got != want {
+		err = fmt.Errorf("wrote %d bytes out of %d", got, want)
+	}
+	if err != nil {
+		c.trace(ctx, "writeLine failed: %v", err)
+		return err
+	}
+	c.trace(ctx, "writeLine -> %q", s)
+	return nil
+}
+
+func (c *cliClient) trace(ctx context.Context, f string, parts ...interface{}) {
+	tr, ok := trace.FromContext(ctx)
+	if !ok {
+		fmted := fmt.Sprintf(f, parts...)
+		glog.Infof("[no trace] %s", fmted)
+		return
+	}
+	tr.LazyPrintf(f, parts...)
+}
+
+func (c *cliClient) logIn(ctx context.Context) error {
+	if c.loggedIn {
+		return nil
+	}
+
+	// Provide username.
+	prompt, err := c.readString(ctx, ':')
+	if err != nil {
+		return fmt.Errorf("could not read username prompt: %v", err)
+	}
+	if !strings.HasSuffix(prompt, "User:") {
+		return fmt.Errorf("invalid username prompt: %v", err)
+	}
+	if err := c.writeLine(ctx, c.username); err != nil {
+		return fmt.Errorf("could not write username: %v")
+	}
+
+	// Provide password.
+	prompt, err = c.readString(ctx, ':')
+	if err != nil {
+		return fmt.Errorf("could not read password prompt: %v", err)
+	}
+	if !strings.HasSuffix(prompt, "Password:") {
+		return fmt.Errorf("invalid password prompt: %v", err)
+	}
+	if err := c.writeLine(ctx, c.password); err != nil {
+		return fmt.Errorf("could not write password: %v")
+	}
+
+	// Get unprivileged prompt.
+	prompt, err = c.readString(ctx, '>')
+	if err != nil {
+		return fmt.Errorf("could not read unprivileged prompt: %v", err)
+	}
+
+	parts := strings.Split(prompt, "\r\n")
+	c.promptHostname = strings.TrimSuffix(parts[len(parts)-1], ">")
+
+	// Enable privileged mode.
+
+	if err := c.writeLine(ctx, "enable"); err != nil {
+		return fmt.Errorf("could not write enable: %v")
+	}
+
+	// Provide password (again)
+	prompt, err = c.readString(ctx, ':')
+	if err != nil {
+		return fmt.Errorf("could not read password prompt: %v", err)
+	}
+	if !strings.HasSuffix(prompt, "Password:") {
+		return fmt.Errorf("invalid password prompt: %v", err)
+	}
+	if err := c.writeLine(ctx, c.password); err != nil {
+		return fmt.Errorf("could not write password: %v")
+	}
+
+	// Get privileged prompt.
+	prompt, err = c.readString(ctx, '#')
+	if err != nil {
+		return fmt.Errorf("could not read privileged prompt: %v", err)
+	}
+
+	if !strings.HasSuffix(prompt, c.promptHostname+"#") {
+		return fmt.Errorf("unexpected privileged prompt: %v", prompt)
+	}
+
+	// Disable pager.
+	if err := c.writeLine(ctx, "terminal length 0"); err != nil {
+		return fmt.Errorf("could not diable pager: %v", err)
+	}
+	prompt, err = c.readString(ctx, '#')
+	if err != nil {
+		return fmt.Errorf("could not disable pager: %v", err)
+	}
+	if !strings.HasSuffix(prompt, c.promptHostname+"#") {
+		return fmt.Errorf("unexpected privileged prompt: %v", prompt)
+	}
+
+	// Success!
+	c.loggedIn = true
+	c.trace(ctx, "logged into %v", c.promptHostname)
+	return nil
+}
+
+func (c *cliClient) runCommand(ctx context.Context, command string) ([]string, string, error) {
+	if err := c.logIn(ctx); err != nil {
+		return nil, "", fmt.Errorf("could not log in: %v", err)
+	}
+
+	// First, synchronize to prompt.
+	attempts := 3
+	for {
+		c.writeLine(ctx, "")
+		line, err := c.readString(ctx, '\n')
+		if err != nil {
+			return nil, "", fmt.Errorf("while synchronizing to prompt: %v", err)
+		}
+		line = strings.Trim(line, "\r\n")
+		if strings.HasSuffix(line, c.promptHostname+"#") {
+			break
+		}
+
+		attempts -= 1
+		if attempts == 0 {
+			return nil, "", fmt.Errorf("could not find prompt, last result %q", line)
+		}
+	}
+
+	// Send comand.
+	c.writeLine(ctx, command)
+
+	// First, read until prompt again.
+	if _, err := c.readUntil(ctx, c.promptHostname+"#"); err != nil {
+		return nil, "", fmt.Errorf("could not get command hostname echo: %v", err)
+	}
+
+	loopback, err := c.readUntil(ctx, "\r\n")
+	if err != nil {
+		return nil, "", fmt.Errorf("could not get command loopback: %v", err)
+	}
+	loopback = strings.Trim(loopback, "\r\n")
+	c.trace(ctx, "effective command: %q", loopback)
+
+	// Read until we have a standalone prompt with no newline afterwards.
+	data, err := c.readUntil(ctx, c.promptHostname+"#")
+	if err != nil {
+		return nil, "", fmt.Errorf("could not get command results: %v", err)
+	}
+
+	lines := []string{}
+	for _, line := range strings.Split(data, "\r\n") {
+		if line == c.promptHostname+"#" {
+			break
+		}
+		lines = append(lines, line)
+	}
+	c.trace(ctx, "command %q returned lines: %v", command, lines)
+
+	return lines, loopback, nil
+}
diff --git a/go/svc/m6220-proxy/main.go b/go/svc/m6220-proxy/main.go
new file mode 100644
index 0000000..5e92ac1
--- /dev/null
+++ b/go/svc/m6220-proxy/main.go
@@ -0,0 +1,277 @@
+package main
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"reflect"
+	"strconv"
+	"strings"
+
+	"code.hackerspace.pl/hscloud/go/mirko"
+	"github.com/golang/glog"
+	"github.com/ziutek/telnet"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+
+	tpb "code.hackerspace.pl/hscloud/go/proto/control"
+	pb "code.hackerspace.pl/hscloud/go/svc/m6220-proxy/proto"
+)
+
+var (
+	flagSwitchAddress  string
+	flagSwitchUsername string
+	flagSwitchPassword string
+)
+
+func init() {
+	flag.Set("logtostderr", "true")
+}
+
+type service struct {
+	connectionSemaphore chan int
+}
+
+func (s *service) connect() (*cliClient, error) {
+	s.connectionSemaphore <- 1
+	conn, err := telnet.Dial("tcp", flagSwitchAddress)
+	if err != nil {
+		<-s.connectionSemaphore
+		return nil, err
+	}
+
+	cli := newCliClient(conn, flagSwitchUsername, flagSwitchPassword)
+	return cli, nil
+}
+
+func (s *service) disconnect() {
+	<-s.connectionSemaphore
+}
+
+func (s *service) RunCommand(ctx context.Context, req *pb.RunCommandRequest) (*pb.RunCommandResponse, error) {
+	if req.Command == "" {
+		return nil, status.Error(codes.InvalidArgument, "command cannot be null")
+	}
+
+	cli, err := s.connect()
+	if err != nil {
+		return nil, status.Error(codes.Unavailable, "could not connect to switch")
+	}
+	defer s.disconnect()
+
+	lines, effective, err := cli.runCommand(ctx, req.Command)
+	if err != nil {
+		return nil, err
+	}
+	res := &pb.RunCommandResponse{
+		EffectiveCommand: effective,
+		Lines:            lines,
+	}
+	return res, nil
+}
+
+func (s *service) parseInterfaceStatus(res *tpb.GetPortsResponse, lines []string) error {
+	if len(lines) < 4 {
+		return fmt.Errorf("need at least 4 lines of output, got %d", len(lines))
+	}
+	if lines[0] != "" {
+		return fmt.Errorf("expected first line to be empty, is %q", lines[0])
+	}
+	header1parts := strings.Fields(lines[1])
+	if want := []string{"Port", "Description", "Duplex", "Speed", "Neg", "Link", "Flow", "Control"}; !reflect.DeepEqual(want, header1parts) {
+		return fmt.Errorf("expected header1 to be %v, got %v", want, header1parts)
+	}
+
+	header2parts := strings.Fields(lines[2])
+	if want := []string{"State", "Status"}; !reflect.DeepEqual(want, header2parts) {
+		return fmt.Errorf("expected header2 to be %v, got %v", want, header2parts)
+	}
+
+	if lines[3][0] != '-' {
+		return fmt.Errorf("expected header3 to start with -, got %q", lines[3])
+	}
+
+	for _, line := range lines[4:] {
+		parts := strings.Fields(line)
+		if len(parts) < 6 {
+			break
+		}
+		portName := parts[0]
+		if strings.HasPrefix(portName, "Gi") && strings.HasPrefix(portName, "Ti") {
+			break
+		}
+
+		speedStr := parts[len(parts)-4]
+		stateStr := parts[len(parts)-2]
+
+		port := &tpb.SwitchPort{
+			Name: portName,
+		}
+		if speedStr == "100" {
+			port.Speed = tpb.SwitchPort_SPEED_100M
+		} else if speedStr == "1000" {
+			port.Speed = tpb.SwitchPort_SPEED_1G
+		} else if speedStr == "10000" {
+			port.Speed = tpb.SwitchPort_SPEED_10G
+		}
+		if stateStr == "Up" {
+			port.LinkState = tpb.SwitchPort_LINKSTATE_UP
+		} else if stateStr == "Down" {
+			port.LinkState = tpb.SwitchPort_LINKSTATE_DOWN
+		}
+
+		res.Ports = append(res.Ports, port)
+	}
+
+	return nil
+}
+
+func (s *service) parseInterfaceConfig(port *tpb.SwitchPort, lines []string) error {
+	glog.Infof("%+v", port)
+	for _, line := range lines {
+		glog.Infof("%s: %q", port.Name, line)
+		parts := strings.Fields(line)
+		if len(parts) < 1 {
+			continue
+		}
+
+		if len(parts) >= 2 && parts[0] == "switchport" {
+			if parts[1] == "mode" {
+				if port.PortMode != tpb.SwitchPort_PORTMODE_INVALID {
+					return fmt.Errorf("redefinition of switchport mode")
+				}
+				if parts[2] == "access" {
+					port.PortMode = tpb.SwitchPort_PORTMODE_SWITCHPORT_UNTAGGED
+				} else if parts[2] == "trunk" {
+					port.PortMode = tpb.SwitchPort_PORTMODE_SWITCHPORT_TAGGED
+				} else if parts[2] == "general" {
+					port.PortMode = tpb.SwitchPort_PORTMODE_SWITCHPORT_GENERIC
+				} else {
+					port.PortMode = tpb.SwitchPort_PORTMODE_MANGLED
+				}
+			}
+
+			if parts[1] == "access" {
+				if port.PortMode == tpb.SwitchPort_PORTMODE_INVALID {
+					port.PortMode = tpb.SwitchPort_PORTMODE_SWITCHPORT_UNTAGGED
+				}
+				if len(parts) > 3 && parts[2] == "vlan" {
+					vlan, err := strconv.Atoi(parts[3])
+					if err != nil {
+						return fmt.Errorf("invalid vlan: %q", parts[3])
+					}
+					port.VlanNative = int32(vlan)
+				}
+			}
+
+			if parts[1] == "trunk" {
+				if len(parts) >= 5 && parts[2] == "allowed" && parts[3] == "vlan" {
+					vlans := strings.Split(parts[4], ",")
+					for _, vlan := range vlans {
+						vlanNum, err := strconv.Atoi(vlan)
+						if err != nil {
+							return fmt.Errorf("invalid vlan: %q", parts[3])
+						}
+						port.VlanTagged = append(port.VlanTagged, int32(vlanNum))
+					}
+				}
+			}
+		} else if len(parts) >= 2 && parts[0] == "mtu" {
+			mtu, err := strconv.Atoi(parts[1])
+			if err != nil {
+				return fmt.Errorf("invalid mtu: %q", parts[3])
+			}
+			port.Mtu = int32(mtu)
+		} else if len(parts) >= 2 && parts[0] == "spanning-tree" && parts[1] == "portfast" {
+			port.SpanningTreeMode = tpb.SwitchPort_SPANNING_TREE_MODE_PORTFAST
+		}
+	}
+
+	// no mode -> access
+	if port.PortMode == tpb.SwitchPort_PORTMODE_INVALID {
+		port.PortMode = tpb.SwitchPort_PORTMODE_SWITCHPORT_UNTAGGED
+	}
+
+	// apply defaults
+	if port.Mtu == 0 {
+		port.Mtu = 1500
+	}
+	if port.SpanningTreeMode == tpb.SwitchPort_SPANNING_TREE_MODE_INVALID {
+		port.SpanningTreeMode = tpb.SwitchPort_SPANNING_TREE_MODE_AUTO_PORTFAST
+	}
+
+	// sanitize
+	if port.PortMode == tpb.SwitchPort_PORTMODE_SWITCHPORT_UNTAGGED {
+		port.VlanTagged = []int32{}
+		port.Prefixes = []string{}
+		if port.VlanNative == 0 {
+			port.VlanNative = 1
+		}
+	} else if port.PortMode == tpb.SwitchPort_PORTMODE_SWITCHPORT_TAGGED {
+		port.VlanNative = 0
+		port.Prefixes = []string{}
+	} else if port.PortMode == tpb.SwitchPort_PORTMODE_SWITCHPORT_GENERIC {
+		port.Prefixes = []string{}
+		if port.VlanNative == 0 {
+			port.VlanNative = 1
+		}
+	}
+	return nil
+}
+
+func (s *service) GetPorts(ctx context.Context, req *tpb.GetPortsRequest) (*tpb.GetPortsResponse, error) {
+	cli, err := s.connect()
+	if err != nil {
+		return nil, status.Error(codes.Unavailable, "could not connect to switch")
+	}
+	defer s.disconnect()
+	res := &tpb.GetPortsResponse{}
+
+	statusLines, _, err := cli.runCommand(ctx, "show interface status")
+	if err != nil {
+		return nil, status.Error(codes.Unavailable, "could not get interface status from switch")
+	}
+
+	err = s.parseInterfaceStatus(res, statusLines)
+	if err != nil {
+		return nil, status.Errorf(codes.Unavailable, "could not parse interface status from switch: %v", err)
+	}
+
+	for _, port := range res.Ports {
+		configLines, _, err := cli.runCommand(ctx, "show run interface "+port.Name)
+		if err != nil {
+			return nil, status.Error(codes.Unavailable, "could not get interface config from switch")
+		}
+		err = s.parseInterfaceConfig(port, configLines)
+		if err != nil {
+			return nil, status.Errorf(codes.Unavailable, "could not parse interface config from switch: %v", err)
+		}
+	}
+
+	return res, nil
+}
+
+func main() {
+	flag.StringVar(&flagSwitchAddress, "switch_address", "127.0.0.1:23", "Telnet address of M6220")
+	flag.StringVar(&flagSwitchUsername, "switch_username", "admin", "Switch login username")
+	flag.StringVar(&flagSwitchPassword, "switch_password", "admin", "Switch login password")
+	flag.Parse()
+
+	s := &service{
+		connectionSemaphore: make(chan int, 1),
+	}
+
+	m := mirko.New()
+	if err := m.Listen(); err != nil {
+		glog.Exitf("Listen(): %v", err)
+	}
+
+	pb.RegisterM6220ProxyServer(m.GRPC(), s)
+	tpb.RegisterSwitchControlServer(m.GRPC(), s)
+
+	if err := m.Serve(); err != nil {
+		glog.Exitf("Serve(): %v", err)
+	}
+
+	select {}
+}
diff --git a/go/svc/m6220-proxy/proto/generate.go b/go/svc/m6220-proxy/proto/generate.go
new file mode 100644
index 0000000..fc6193d
--- /dev/null
+++ b/go/svc/m6220-proxy/proto/generate.go
@@ -0,0 +1,3 @@
+//go:generate protoc -I.. ../proxy.proto --go_out=plugins=grpc:.
+
+package proto
diff --git a/go/svc/m6220-proxy/proto/proxy.pb.go b/go/svc/m6220-proxy/proto/proxy.pb.go
new file mode 100644
index 0000000..148b878
--- /dev/null
+++ b/go/svc/m6220-proxy/proto/proxy.pb.go
@@ -0,0 +1,203 @@
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// source: proxy.proto
+
+package proto
+
+import (
+	fmt "fmt"
+	proto "github.com/golang/protobuf/proto"
+	context "golang.org/x/net/context"
+	grpc "google.golang.org/grpc"
+	math "math"
+)
+
+// Reference imports to suppress errors if they are not otherwise used.
+var _ = proto.Marshal
+var _ = fmt.Errorf
+var _ = math.Inf
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the proto package it is being compiled against.
+// A compilation error at this line likely means your copy of the
+// proto package needs to be updated.
+const _ = proto.ProtoPackageIsVersion2 // please upgrade the proto package
+
+type RunCommandRequest struct {
+	Command              string   `protobuf:"bytes,1,opt,name=command,proto3" json:"command,omitempty"`
+	XXX_NoUnkeyedLiteral struct{} `json:"-"`
+	XXX_unrecognized     []byte   `json:"-"`
+	XXX_sizecache        int32    `json:"-"`
+}
+
+func (m *RunCommandRequest) Reset()         { *m = RunCommandRequest{} }
+func (m *RunCommandRequest) String() string { return proto.CompactTextString(m) }
+func (*RunCommandRequest) ProtoMessage()    {}
+func (*RunCommandRequest) Descriptor() ([]byte, []int) {
+	return fileDescriptor_700b50b08ed8dbaf, []int{0}
+}
+
+func (m *RunCommandRequest) XXX_Unmarshal(b []byte) error {
+	return xxx_messageInfo_RunCommandRequest.Unmarshal(m, b)
+}
+func (m *RunCommandRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
+	return xxx_messageInfo_RunCommandRequest.Marshal(b, m, deterministic)
+}
+func (m *RunCommandRequest) XXX_Merge(src proto.Message) {
+	xxx_messageInfo_RunCommandRequest.Merge(m, src)
+}
+func (m *RunCommandRequest) XXX_Size() int {
+	return xxx_messageInfo_RunCommandRequest.Size(m)
+}
+func (m *RunCommandRequest) XXX_DiscardUnknown() {
+	xxx_messageInfo_RunCommandRequest.DiscardUnknown(m)
+}
+
+var xxx_messageInfo_RunCommandRequest proto.InternalMessageInfo
+
+func (m *RunCommandRequest) GetCommand() string {
+	if m != nil {
+		return m.Command
+	}
+	return ""
+}
+
+type RunCommandResponse struct {
+	EffectiveCommand     string   `protobuf:"bytes,1,opt,name=effective_command,json=effectiveCommand,proto3" json:"effective_command,omitempty"`
+	Lines                []string `protobuf:"bytes,2,rep,name=lines,proto3" json:"lines,omitempty"`
+	XXX_NoUnkeyedLiteral struct{} `json:"-"`
+	XXX_unrecognized     []byte   `json:"-"`
+	XXX_sizecache        int32    `json:"-"`
+}
+
+func (m *RunCommandResponse) Reset()         { *m = RunCommandResponse{} }
+func (m *RunCommandResponse) String() string { return proto.CompactTextString(m) }
+func (*RunCommandResponse) ProtoMessage()    {}
+func (*RunCommandResponse) Descriptor() ([]byte, []int) {
+	return fileDescriptor_700b50b08ed8dbaf, []int{1}
+}
+
+func (m *RunCommandResponse) XXX_Unmarshal(b []byte) error {
+	return xxx_messageInfo_RunCommandResponse.Unmarshal(m, b)
+}
+func (m *RunCommandResponse) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
+	return xxx_messageInfo_RunCommandResponse.Marshal(b, m, deterministic)
+}
+func (m *RunCommandResponse) XXX_Merge(src proto.Message) {
+	xxx_messageInfo_RunCommandResponse.Merge(m, src)
+}
+func (m *RunCommandResponse) XXX_Size() int {
+	return xxx_messageInfo_RunCommandResponse.Size(m)
+}
+func (m *RunCommandResponse) XXX_DiscardUnknown() {
+	xxx_messageInfo_RunCommandResponse.DiscardUnknown(m)
+}
+
+var xxx_messageInfo_RunCommandResponse proto.InternalMessageInfo
+
+func (m *RunCommandResponse) GetEffectiveCommand() string {
+	if m != nil {
+		return m.EffectiveCommand
+	}
+	return ""
+}
+
+func (m *RunCommandResponse) GetLines() []string {
+	if m != nil {
+		return m.Lines
+	}
+	return nil
+}
+
+func init() {
+	proto.RegisterType((*RunCommandRequest)(nil), "proto.RunCommandRequest")
+	proto.RegisterType((*RunCommandResponse)(nil), "proto.RunCommandResponse")
+}
+
+func init() { proto.RegisterFile("proxy.proto", fileDescriptor_700b50b08ed8dbaf) }
+
+var fileDescriptor_700b50b08ed8dbaf = []byte{
+	// 165 bytes of a gzipped FileDescriptorProto
+	0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0xe2, 0x2e, 0x28, 0xca, 0xaf,
+	0xa8, 0xd4, 0x2b, 0x28, 0xca, 0x2f, 0xc9, 0x17, 0x62, 0x05, 0x53, 0x4a, 0xba, 0x5c, 0x82, 0x41,
+	0xa5, 0x79, 0xce, 0xf9, 0xb9, 0xb9, 0x89, 0x79, 0x29, 0x41, 0xa9, 0x85, 0xa5, 0xa9, 0xc5, 0x25,
+	0x42, 0x12, 0x5c, 0xec, 0xc9, 0x10, 0x11, 0x09, 0x46, 0x05, 0x46, 0x0d, 0xce, 0x20, 0x18, 0x57,
+	0x29, 0x9c, 0x4b, 0x08, 0x59, 0x79, 0x71, 0x41, 0x7e, 0x5e, 0x71, 0xaa, 0x90, 0x36, 0x97, 0x60,
+	0x6a, 0x5a, 0x5a, 0x6a, 0x72, 0x49, 0x66, 0x59, 0x6a, 0x3c, 0xaa, 0x4e, 0x01, 0xb8, 0x04, 0x54,
+	0x93, 0x90, 0x08, 0x17, 0x6b, 0x4e, 0x66, 0x5e, 0x6a, 0xb1, 0x04, 0x93, 0x02, 0xb3, 0x06, 0x67,
+	0x10, 0x84, 0x63, 0xe4, 0xcf, 0xc5, 0xe5, 0x6b, 0x66, 0x64, 0x64, 0x10, 0x00, 0x72, 0xa2, 0x90,
+	0x23, 0x17, 0x17, 0xc2, 0x1a, 0x21, 0x09, 0x88, 0x93, 0xf5, 0x30, 0x1c, 0x2a, 0x25, 0x89, 0x45,
+	0x06, 0xe2, 0xa6, 0x24, 0x36, 0xb0, 0x8c, 0x31, 0x20, 0x00, 0x00, 0xff, 0xff, 0x39, 0x6d, 0xab,
+	0xdd, 0xf5, 0x00, 0x00, 0x00,
+}
+
+// Reference imports to suppress errors if they are not otherwise used.
+var _ context.Context
+var _ grpc.ClientConn
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+const _ = grpc.SupportPackageIsVersion4
+
+// M6220ProxyClient is the client API for M6220Proxy service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream.
+type M6220ProxyClient interface {
+	RunCommand(ctx context.Context, in *RunCommandRequest, opts ...grpc.CallOption) (*RunCommandResponse, error)
+}
+
+type m6220ProxyClient struct {
+	cc *grpc.ClientConn
+}
+
+func NewM6220ProxyClient(cc *grpc.ClientConn) M6220ProxyClient {
+	return &m6220ProxyClient{cc}
+}
+
+func (c *m6220ProxyClient) RunCommand(ctx context.Context, in *RunCommandRequest, opts ...grpc.CallOption) (*RunCommandResponse, error) {
+	out := new(RunCommandResponse)
+	err := c.cc.Invoke(ctx, "/proto.M6220Proxy/RunCommand", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// M6220ProxyServer is the server API for M6220Proxy service.
+type M6220ProxyServer interface {
+	RunCommand(context.Context, *RunCommandRequest) (*RunCommandResponse, error)
+}
+
+func RegisterM6220ProxyServer(s *grpc.Server, srv M6220ProxyServer) {
+	s.RegisterService(&_M6220Proxy_serviceDesc, srv)
+}
+
+func _M6220Proxy_RunCommand_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(RunCommandRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(M6220ProxyServer).RunCommand(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/proto.M6220Proxy/RunCommand",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(M6220ProxyServer).RunCommand(ctx, req.(*RunCommandRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+var _M6220Proxy_serviceDesc = grpc.ServiceDesc{
+	ServiceName: "proto.M6220Proxy",
+	HandlerType: (*M6220ProxyServer)(nil),
+	Methods: []grpc.MethodDesc{
+		{
+			MethodName: "RunCommand",
+			Handler:    _M6220Proxy_RunCommand_Handler,
+		},
+	},
+	Streams:  []grpc.StreamDesc{},
+	Metadata: "proxy.proto",
+}
diff --git a/go/svc/m6220-proxy/proxy.proto b/go/svc/m6220-proxy/proxy.proto
new file mode 100644
index 0000000..d35a2c3
--- /dev/null
+++ b/go/svc/m6220-proxy/proxy.proto
@@ -0,0 +1,15 @@
+syntax = "proto3";
+package proto;
+
+message RunCommandRequest {
+    string command = 1;
+};
+
+message RunCommandResponse {
+    string effective_command = 1;
+    repeated string lines = 2;
+};
+
+service M6220Proxy {
+    rpc RunCommand(RunCommandRequest) returns (RunCommandResponse);
+};