cloud/bmaas/server: init

This adds the BMaaS server alongside its first functionality: serving an
Agent heartbeat API.

This allows (untrusted) Agents to communicate with the rest of the
system by submitting heartbeats which may include a hardware report.

The BMaaS server will likely grow to implement further functionality as
described in its README.

Change-Id: I1ede02121b3700079cbb11295525f4c167ee1e7d
Reviewed-on: https://review.monogon.dev/c/monogon/+/988
Reviewed-by: Lorenz Brun <lorenz@monogon.tech>
Tested-by: Jenkins CI
diff --git a/cloud/bmaas/server/agent_callback_service.go b/cloud/bmaas/server/agent_callback_service.go
new file mode 100644
index 0000000..f058213
--- /dev/null
+++ b/cloud/bmaas/server/agent_callback_service.go
@@ -0,0 +1,106 @@
+package server
+
+import (
+	"context"
+	"crypto/ed25519"
+	"errors"
+	"fmt"
+	"time"
+
+	"github.com/google/uuid"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+	"google.golang.org/protobuf/proto"
+	"k8s.io/klog"
+
+	"source.monogon.dev/cloud/bmaas/bmdb/model"
+	apb "source.monogon.dev/cloud/bmaas/server/api"
+	"source.monogon.dev/metropolis/node/core/rpc"
+)
+
+type agentCallbackService struct {
+	s *Server
+}
+
+var (
+	errAgentUnauthenticated = errors.New("machine id or public key unknown")
+)
+
+func (a *agentCallbackService) Heartbeat(ctx context.Context, req *apb.AgentHeartbeatRequest) (*apb.AgentHeartbeatResponse, error) {
+	// Extract ED25519 self-signed certificate from client connection.
+	cert, err := rpc.GetPeerCertificate(ctx)
+	if err != nil {
+		return nil, err
+	}
+	pk := cert.PublicKey.(ed25519.PublicKey)
+	machineId, err := uuid.Parse(req.MachineId)
+	if err != nil {
+		return nil, status.Error(codes.InvalidArgument, "machine_id invalid")
+	}
+
+	// TODO(q3k): don't start a session for every RPC.
+	session, err := a.s.bmdb.StartSession(ctx)
+	if err != nil {
+		klog.Errorf("Could not start session: %v", err)
+		return nil, status.Error(codes.Unavailable, "could not start session")
+	}
+
+	// Verify that machine ID and connection public key match up to a machine in the
+	// BMDB. Prevent leaking information about a machine's existence to unauthorized
+	// agents.
+	err = session.Transact(ctx, func(q *model.Queries) error {
+		agents, err := q.AuthenticateAgentConnection(ctx, model.AuthenticateAgentConnectionParams{
+			MachineID:      machineId,
+			AgentPublicKey: pk,
+		})
+		if err != nil {
+			return fmt.Errorf("AuthenticateAgentConnection: %w", err)
+		}
+		if len(agents) < 1 {
+			return errAgentUnauthenticated
+		}
+		return nil
+	})
+	if err != nil {
+		if errors.Is(err, errAgentUnauthenticated) {
+			return nil, status.Error(codes.Unauthenticated, err.Error())
+		}
+		klog.Errorf("Could not authenticate agent: %v", err)
+		return nil, status.Error(codes.Unavailable, "could not authenticate agent")
+	}
+
+	// Request is now authenticated.
+
+	// Serialize hardware report if submitted alongside heartbeat.
+	var hwraw []byte
+	if req.HardwareReport != nil {
+		hwraw, err = proto.Marshal(req.HardwareReport)
+		if err != nil {
+			return nil, status.Errorf(codes.InvalidArgument, "could not serialize harcware report: %v", err)
+		}
+	}
+
+	// Upsert heartbeat time and hardware report.
+	err = session.Transact(ctx, func(q *model.Queries) error {
+		// Upsert hardware report if submitted.
+		if hwraw != nil {
+			err = q.MachineSetHardwareReport(ctx, model.MachineSetHardwareReportParams{
+				MachineID:         machineId,
+				HardwareReportRaw: hwraw,
+			})
+			if err != nil {
+				return fmt.Errorf("hardware report upsert: %w", err)
+			}
+		}
+		return q.MachineSetAgentHeartbeat(ctx, model.MachineSetAgentHeartbeatParams{
+			MachineID:        machineId,
+			AgentHeartbeatAt: time.Now(),
+		})
+	})
+	if err != nil {
+		klog.Errorf("Could not submit heartbeat: %v", err)
+		return nil, status.Error(codes.Unavailable, "could not submit heartbeat")
+	}
+
+	return &apb.AgentHeartbeatResponse{}, nil
+}