cloud: init with apigw
This adds a first component to the cloud project, the apigw (API
Gateway), which listens on a public gRPC-Web socket.
It's not truly a gateway - it will actually contain most of the
IAM/Project logic for the cloud system. A better name should be picked
later.
We implement a minimum internal/public gRPC(-Web) listener and some
boilerplate for the parts that are gonna pop up again. Notably, we add
some automation around generating developer TLS certificates for the
internal gRPC listener.
Currently the apigw serves a single, demo RPC which returns
'unimplemented'.
Change-Id: I9164ddbd9a20172154ae5a3ffad676de5fe4927d
Reviewed-on: https://review.monogon.dev/c/monogon/+/906
Reviewed-by: Leopold Schabel <leo@monogon.tech>
Tested-by: Jenkins CI
diff --git a/cloud/apigw/server/BUILD.bazel b/cloud/apigw/server/BUILD.bazel
new file mode 100644
index 0000000..2444267
--- /dev/null
+++ b/cloud/apigw/server/BUILD.bazel
@@ -0,0 +1,31 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
+
+go_library(
+ name = "server",
+ srcs = ["server.go"],
+ importpath = "source.monogon.dev/cloud/apigw/server",
+ visibility = ["//visibility:public"],
+ deps = [
+ "//cloud/api",
+ "//cloud/lib/component",
+ "@com_github_improbable_eng_grpc_web//go/grpcweb",
+ "@io_k8s_klog_v2//:klog",
+ "@org_golang_google_grpc//:go_default_library",
+ "@org_golang_google_grpc//codes",
+ "@org_golang_google_grpc//credentials/insecure",
+ "@org_golang_google_grpc//reflection",
+ "@org_golang_google_grpc//status",
+ ],
+)
+
+go_test(
+ name = "server_test",
+ srcs = ["server_test.go"],
+ embed = [":server"],
+ deps = [
+ "//cloud/api",
+ "//cloud/lib/component",
+ "@org_golang_google_grpc//codes",
+ "@org_golang_google_protobuf//proto",
+ ],
+)
diff --git a/cloud/apigw/server/server.go b/cloud/apigw/server/server.go
new file mode 100644
index 0000000..b3b80bf
--- /dev/null
+++ b/cloud/apigw/server/server.go
@@ -0,0 +1,107 @@
+package server
+
+import (
+ "context"
+ "flag"
+ "net"
+ "net/http"
+
+ "github.com/improbable-eng/grpc-web/go/grpcweb"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/credentials/insecure"
+ "google.golang.org/grpc/reflection"
+ "google.golang.org/grpc/status"
+ "k8s.io/klog/v2"
+
+ apb "source.monogon.dev/cloud/api"
+ "source.monogon.dev/cloud/lib/component"
+)
+
+// Config is the main configuration of the apigw server. It's usually populated
+// from flags via RegisterFlags, but can also be set manually (eg. in tests).
+type Config struct {
+ component.Configuration
+
+ PublicListenAddress string
+}
+
+// RegisterFlags registers the component configuration to be provided by flags.
+// This must be called exactly once before then calling flags.Parse().
+func (c *Config) RegisterFlags() {
+ c.Configuration.RegisterFlags("apigw")
+ flag.StringVar(&c.PublicListenAddress, "apigw_public_grpc_listen_address", ":8080", "Address to listen at for public/user gRPC connections for apigw")
+}
+
+// Server runs the apigw server. It listens on two interfaces:
+// - Internal gRPC, which is authenticated using TLS and authorized by CA. This
+// is to be used for internal RPCs, eg. management/debug.
+// - Public gRPC-Web, which is currently unauthenticated.
+type Server struct {
+ Config Config
+
+ // ListenGRPC will contain the address at which the internal gRPC server is
+ // listening after .Start() has been called. This can differ from the configured
+ // value if the configuration requests any port (via :0).
+ ListenGRPC string
+ // ListenPublic will contain the address at which the public API server is
+ // listening after .Start() has been called. This can differ from the configured
+ // value if the configuration requests any port (via :0).
+ ListenPublic string
+}
+
+func (s *Server) startInternalGRPC(ctx context.Context) {
+ g := grpc.NewServer(s.Config.GRPCServerOptions()...)
+ lis, err := net.Listen("tcp", s.Config.GRPCListenAddress)
+ if err != nil {
+ klog.Exitf("Could not listen: %v", err)
+ }
+ s.ListenGRPC = lis.Addr().String()
+
+ reflection.Register(g)
+
+ klog.Infof("Internal gRPC listening on %s", s.ListenGRPC)
+ go func() {
+ err := g.Serve(lis)
+ if err != ctx.Err() {
+ klog.Exitf("Internal gRPC serve failed: %v", err)
+ }
+ }()
+}
+
+func (s *Server) startPublic(ctx context.Context) {
+ g := grpc.NewServer(grpc.Creds(insecure.NewCredentials()))
+ lis, err := net.Listen("tcp", s.Config.PublicListenAddress)
+ if err != nil {
+ klog.Exitf("Could not listen: %v", err)
+ }
+ s.ListenPublic = lis.Addr().String()
+
+ reflection.Register(g)
+ apb.RegisterIAMServer(g, s)
+
+ wrapped := grpcweb.WrapServer(g)
+ server := http.Server{
+ Addr: s.Config.PublicListenAddress,
+ Handler: http.HandlerFunc(wrapped.ServeHTTP),
+ }
+ klog.Infof("Public API listening on %s", s.ListenPublic)
+ go func() {
+ err := server.Serve(lis)
+ if err != ctx.Err() {
+ klog.Exitf("Public API serve failed: %v", err)
+ }
+ }()
+}
+
+// Start runs the two listeners of the server. The process will fail (via
+// klog.Exit) if any of the listeners/servers fail to start.
+func (s *Server) Start(ctx context.Context) {
+ s.startInternalGRPC(ctx)
+ s.startPublic(ctx)
+}
+
+func (s *Server) WhoAmI(ctx context.Context, req *apb.WhoAmIRequest) (*apb.WhoAmIResponse, error) {
+ klog.Infof("req: %+v", req)
+ return nil, status.Error(codes.Unimplemented, "unimplemented")
+}
diff --git a/cloud/apigw/server/server_test.go b/cloud/apigw/server/server_test.go
new file mode 100644
index 0000000..16cdc07
--- /dev/null
+++ b/cloud/apigw/server/server_test.go
@@ -0,0 +1,74 @@
+package server
+
+import (
+ "bytes"
+ "context"
+ "encoding/binary"
+ "fmt"
+ "net/http"
+ "strconv"
+ "testing"
+
+ "google.golang.org/grpc/codes"
+ "google.golang.org/protobuf/proto"
+
+ apb "source.monogon.dev/cloud/api"
+ "source.monogon.dev/cloud/lib/component"
+)
+
+// TestPublicSimple ensures the public grpc-web listener is working.
+func TestPublicSimple(t *testing.T) {
+ s := Server{
+ Config: Config{
+ Configuration: component.Configuration{
+ GRPCListenAddress: ":0",
+ DevCerts: true,
+ DevCertsPath: "/tmp/foo",
+ },
+ PublicListenAddress: ":0",
+ },
+ }
+
+ ctx := context.Background()
+ s.Start(ctx)
+
+ // Craft a gRPC-Web request from scratch. There doesn't seem to be a
+ // well-supported library to do this.
+
+ // The request is \0 ++ uint32be(len(req)) ++ req.
+ msgBytes, err := proto.Marshal(&apb.WhoAmIRequest{})
+ if err != nil {
+ t.Fatalf("Could not marshal request body: %v", err)
+ }
+ buf := bytes.NewBuffer(nil)
+ binary.Write(buf, binary.BigEndian, byte(0))
+ binary.Write(buf, binary.BigEndian, uint32(len(msgBytes)))
+ buf.Write(msgBytes)
+
+ // Perform the request. Set minimum headers required for gRPC-Web to recognize
+ // this as a gRPC-Web request.
+ req, err := http.NewRequest("POST", fmt.Sprintf("http://%s/cloud.api.IAM/WhoAmI", s.ListenPublic), buf)
+ if err != nil {
+ t.Fatalf("Could not create request: %v", err)
+ }
+ req.Header.Set("Content-Type", "application/grpc-web+proto")
+ req.Header.Set("X-Grpc-Web", "1")
+
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("Could not perform request: %v", err)
+ }
+ // Regardless for RPC status, 200 should always be returned.
+ if want, got := 200, res.StatusCode; want != got {
+ t.Errorf("Wanted code %d, got %d", want, got)
+ }
+
+ // Expect endpoint to return 'unimplemented'.
+ code, _ := strconv.Atoi(res.Header.Get("Grpc-Status"))
+ if want, got := uint32(codes.Unimplemented), uint32(code); want != got {
+ t.Errorf("Wanted code %d, got %d", want, got)
+ }
+ if want, got := "unimplemented", res.Header.Get("Grpc-Message"); want != got {
+ t.Errorf("Wanted message %q, got %q", want, got)
+ }
+}