m/c/metroctl: implement multi-node installation

This enables metroctl to include the ClusterRegister part of
NodeParameters in generated installer images, making it possible for
newly installed nodes to join an existing cluster.

Change-Id: I648207d70a4bec2ed7acf42e02f2b2c93319f559
Reviewed-on: https://review.monogon.dev/c/monogon/+/822
Tested-by: Jenkins CI
Reviewed-by: Lorenz Brun <lorenz@monogon.tech>
diff --git a/metropolis/cli/metroctl/rpc.go b/metropolis/cli/metroctl/rpc.go
new file mode 100644
index 0000000..b307d06
--- /dev/null
+++ b/metropolis/cli/metroctl/rpc.go
@@ -0,0 +1,72 @@
+package main
+
+import (
+	"context"
+	"crypto/ed25519"
+	"crypto/tls"
+	"crypto/x509"
+	"fmt"
+	"net"
+
+	"golang.org/x/net/proxy"
+	"google.golang.org/grpc"
+
+	"source.monogon.dev/metropolis/node"
+	"source.monogon.dev/metropolis/node/core/rpc"
+	"source.monogon.dev/metropolis/node/core/rpc/resolver"
+)
+
+// dialCluster dials the cluster control address. The owner certificate, and
+// proxy address parameters are optional and can be left nil, and empty,
+// respectively. At least one cluster endpoint must be provided. A missing
+// owner certificate will result in a connection that is authenticated with
+// ephemeral credentials, restricting the available API surface. proxyAddr
+// must point at a SOCKS5 endpoint.
+func dialCluster(ctx context.Context, opkey ed25519.PrivateKey, ocert *x509.Certificate, proxyAddr string, clusterEndpoints []string) (*grpc.ClientConn, error) {
+	var dialOpts []grpc.DialOption
+
+	if opkey == nil {
+		return nil, fmt.Errorf("an owner's private key must be provided")
+	}
+	if len(clusterEndpoints) == 0 {
+		return nil, fmt.Errorf("at least one cluster endpoint must be provided")
+	}
+
+	if proxyAddr != "" {
+		socksDialer, err := proxy.SOCKS5("tcp", proxyAddr, nil, proxy.Direct)
+		if err != nil {
+			return nil, fmt.Errorf("failed to build a SOCKS dialer: %v", err)
+		}
+		grpcd := func(_ context.Context, addr string) (net.Conn, error) {
+			return socksDialer.Dial("tcp", addr)
+		}
+		dialOpts = append(dialOpts, grpc.WithContextDialer(grpcd))
+	}
+
+	if ocert == nil {
+		creds, err := rpc.NewEphemeralCredentials(opkey, nil)
+		if err != nil {
+			return nil, fmt.Errorf("while building ephemeral credentials: %v", err)
+		}
+		dialOpts = append(dialOpts, grpc.WithTransportCredentials(creds))
+	} else {
+		tlsc := tls.Certificate{
+			Certificate: [][]byte{ocert.Raw},
+			PrivateKey:  opkey,
+		}
+		creds := rpc.NewAuthenticatedCredentials(tlsc, nil)
+		dialOpts = append(dialOpts, grpc.WithTransportCredentials(creds))
+	}
+
+	r := resolver.New(ctx)
+	for _, ep := range clusterEndpoints {
+		r.AddEndpoint(resolver.NodeByHostPort(ep, uint16(node.CuratorServicePort)))
+	}
+	dialOpts = append(dialOpts, grpc.WithResolvers(r))
+
+	c, err := grpc.Dial(resolver.MetropolisControlAddress, dialOpts...)
+	if err != nil {
+		return nil, fmt.Errorf("could not dial: %v", err)
+	}
+	return c, nil
+}