Skip to content

Commit

Permalink
MEP-9: Implement VPN keys utilizing headscale (#313)
Browse files Browse the repository at this point in the history
  • Loading branch information
GrigoriyMikhalkin authored Sep 9, 2022
1 parent f31fbec commit 1336fd8
Show file tree
Hide file tree
Showing 13 changed files with 1,384 additions and 54 deletions.
8 changes: 4 additions & 4 deletions .github/workflows/docker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ jobs:
- name: Checkout
uses: actions/checkout@v2

- name: Set up Go 1.18
- name: Set up Go 1.19
uses: actions/setup-go@v2
with:
go-version: '1.18.x'
go-version: '1.19.x'

- name: setup buf
uses: bufbuild/buf-setup-action@v1
Expand Down Expand Up @@ -70,10 +70,10 @@ jobs:
- name: Checkout
uses: actions/checkout@v2

- name: Set up Go 1.18
- name: Set up Go 1.19
uses: actions/setup-go@v2
with:
go-version: '1.18.x'
go-version: '1.19.x'

- name: Run integration tests
run: |
Expand Down
23 changes: 23 additions & 0 deletions cmd/metal-api/internal/headscale/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package headscale

import (
"context"
)

// Implements google.golang.org/grpc/credentials.PerRPCCredentials interface
type tokenAuth struct {
token string
}

func (t tokenAuth) GetRequestMetadata(
ctx context.Context,
_ ...string,
) (map[string]string, error) {
return map[string]string{
"authorization": "Bearer " + t.token,
}, nil
}

func (tokenAuth) RequireTransportSecurity() bool {
return false
}
124 changes: 124 additions & 0 deletions cmd/metal-api/internal/headscale/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package headscale

import (
"context"
"errors"
"fmt"
"time"

"go.uber.org/zap"

headscalecore "github.com/juanfont/headscale"
headscalev1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
)

type HeadscaleClient struct {
client headscalev1.HeadscaleServiceClient

address string
controlPlaneAddress string

ctx context.Context
conn *grpc.ClientConn
cancelFunc context.CancelFunc
logger *zap.SugaredLogger
}

func NewHeadscaleClient(addr, controlPlaneAddr, apiKey string, logger *zap.SugaredLogger) (client *HeadscaleClient, err error) {
if addr != "" || apiKey != "" {
if addr == "" {
return nil, fmt.Errorf("headscale address should be set with api key")
}
if apiKey == "" {
return nil, fmt.Errorf("headscale api key should be set with address")
}
} else {
return
}

h := &HeadscaleClient{
address: addr,
controlPlaneAddress: controlPlaneAddr,

logger: logger,
}
h.ctx, h.cancelFunc = context.WithCancel(context.Background())

if err = h.connect(apiKey); err != nil {
return nil, fmt.Errorf("failed to connect to Headscale server: %w", err)
}

return h, nil
}

// Connect or reconnect to Headscale server
func (h *HeadscaleClient) connect(apiKey string) (err error) {
ctx, cancel := context.WithTimeout(h.ctx, 5*time.Second)
defer cancel()

grpcOptions := []grpc.DialOption{
grpc.WithBlock(),
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithPerRPCCredentials(tokenAuth{
token: apiKey,
}),
}

h.conn, err = grpc.DialContext(ctx, h.address, grpcOptions...)
if err != nil {
return fmt.Errorf("failed to connect to headscale server %s: %w", h.address, err)
}

h.client = headscalev1.NewHeadscaleServiceClient(h.conn)

return
}

func (h *HeadscaleClient) GetControlPlaneAddress() string {
return h.controlPlaneAddress
}

func (h *HeadscaleClient) NamespaceExists(name string) bool {
getNSRequest := &headscalev1.GetNamespaceRequest{
Name: name,
}
if _, err := h.client.GetNamespace(h.ctx, getNSRequest); err != nil {
return false
}

return true
}

func (h *HeadscaleClient) CreateNamespace(name string) error {
req := &headscalev1.CreateNamespaceRequest{
Name: name,
}
_, err := h.client.CreateNamespace(h.ctx, req)
if err != nil && !errors.Is(headscalecore.ErrNamespaceExists, err) {
return fmt.Errorf("failed to create new VPN namespace: %w", err)
}

return nil
}

func (h *HeadscaleClient) CreatePreAuthKey(namespace string, expiration time.Time) (key string, err error) {
req := &headscalev1.CreatePreAuthKeyRequest{
Namespace: namespace,
Expiration: timestamppb.New(expiration),
}
resp, err := h.client.CreatePreAuthKey(h.ctx, req)
if err != nil || resp == nil || resp.PreAuthKey == nil {
return "", fmt.Errorf("failed to create new Auth Key: %w", err)
}

return resp.PreAuthKey.Key, nil
}

// Close client
func (h *HeadscaleClient) Close() error {
h.cancelFunc()
return h.conn.Close()
}
6 changes: 6 additions & 0 deletions cmd/metal-api/internal/metal/machine.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ type MachineAllocation struct {
Reinstall bool `rethinkdb:"reinstall" json:"reinstall"`
MachineSetup *MachineSetup `rethinkdb:"setup" json:"setup"`
Role Role `rethinkdb:"role" json:"role"`
VPN *MachineVPN `rethinkdb:"vpn" json:"vpn"`
}

// A MachineSetup stores the data used for machine reinstallations.
Expand Down Expand Up @@ -414,3 +415,8 @@ type FirmwareUpdate struct {
Kind FirmwareKind `json:"kind"`
URL string `json:"url"`
}

type MachineVPN struct {
ControlPlaneAddress string `rethinkdb:"address" json:"address"`
AuthKey string `rethinkdb:"auth_key" json:"auth_key"`
}
57 changes: 48 additions & 9 deletions cmd/metal-api/internal/service/firewall-service.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@ package service
import (
"errors"
"fmt"
"time"

"net/http"

"github.com/metal-stack/metal-api/cmd/metal-api/internal/headscale"

"github.com/metal-stack/security"

"github.com/metal-stack/metal-lib/httperrors"
"go.uber.org/zap"

"github.com/metal-stack/metal-lib/httperrors"

mdm "github.com/metal-stack/masterdata-api/pkg/client"

"github.com/metal-stack/metal-api/cmd/metal-api/internal/datastore"
Expand All @@ -19,16 +24,18 @@ import (

restfulspec "github.com/emicklei/go-restful-openapi/v2"
restful "github.com/emicklei/go-restful/v3"

"github.com/metal-stack/metal-lib/bus"
)

type firewallResource struct {
webResource
bus.Publisher
ipamer ipam.IPAMer
mdc mdm.Client
userGetter security.UserGetter
actor *asyncActor
ipamer ipam.IPAMer
mdc mdm.Client
userGetter security.UserGetter
actor *asyncActor
headscaleClient *headscale.HeadscaleClient
}

// NewFirewall returns a webservice for firewall specific endpoints.
Expand All @@ -40,16 +47,18 @@ func NewFirewall(
ep *bus.Endpoints,
mdc mdm.Client,
userGetter security.UserGetter,
headscaleClient *headscale.HeadscaleClient,
) (*restful.WebService, error) {
r := firewallResource{
webResource: webResource{
log: log,
ds: ds,
},
Publisher: pub,
ipamer: ipamer,
mdc: mdc,
userGetter: userGetter,
Publisher: pub,
ipamer: ipamer,
mdc: mdc,
userGetter: userGetter,
headscaleClient: headscaleClient,
}

var err error
Expand Down Expand Up @@ -200,6 +209,11 @@ func (r *firewallResource) allocateFirewall(request *restful.Request, response *
return
}

if err := r.setVPNConfigInSpec(spec); err != nil {
r.sendError(request, response, defaultError(err))
return
}

m, err := allocateMachine(r.logger(request), r.ds, r.ipamer, spec, r.mdc, r.actor, r.Publisher)
if err != nil {
r.sendError(request, response, defaultError(err))
Expand All @@ -215,6 +229,31 @@ func (r *firewallResource) allocateFirewall(request *restful.Request, response *
r.send(request, response, http.StatusOK, resp)
}

func (r firewallResource) setVPNConfigInSpec(allocationSpec *machineAllocationSpec) error {
if r.headscaleClient == nil {
return nil
}

// Try to create namespace in Headscale DB
projectID := allocationSpec.ProjectID
if err := r.headscaleClient.CreateNamespace(projectID); err != nil {
return fmt.Errorf("failed to create new VPN namespace for the project: %w", err)
}

expiration := time.Now().Add(2 * time.Hour)
key, err := r.headscaleClient.CreatePreAuthKey(projectID, expiration)
if err != nil {
return fmt.Errorf("failed to create new auth key for the firewall: %w", err)
}

allocationSpec.VPN = &metal.MachineVPN{
ControlPlaneAddress: r.headscaleClient.GetControlPlaneAddress(),
AuthKey: key,
}

return nil
}

func makeFirewallResponse(fw *metal.Machine, ds *datastore.RethinkStore) (*v1.FirewallResponse, error) {
ms, err := makeMachineResponse(fw, ds)
if err != nil {
Expand Down
2 changes: 2 additions & 0 deletions cmd/metal-api/internal/service/machine-service.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ type machineAllocationSpec struct {
Networks v1.MachineAllocationNetworks
IPs []string
Role metal.Role
VPN *metal.MachineVPN
}

// allocationNetwork is intermediate struct to create machine networks from regular networks during machine allocation
Expand Down Expand Up @@ -1263,6 +1264,7 @@ func allocateMachine(logger *zap.SugaredLogger, ds *datastore.RethinkStore, ipam
SSHPubKeys: allocationSpec.SSHPubKeys,
MachineNetworks: []*metal.MachineNetwork{},
Role: allocationSpec.Role,
VPN: allocationSpec.VPN,
}
rollbackOnError := func(err error) error {
if err != nil {
Expand Down
18 changes: 18 additions & 0 deletions cmd/metal-api/internal/service/v1/machine.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type MachineAllocation struct {
Reinstall bool `json:"reinstall" description:"indicates whether to reinstall the machine"`
BootInfo *BootInfo `json:"boot_info" description:"information required for booting the machine from HD" optional:"true"`
Role string `json:"role" enum:"machine|firewall" description:"the role of the machine"`
VPN *MachineVPN `json:"vpn" description:"vpn connection info for machine" optional:"true"`
}

type BootInfo struct {
Expand Down Expand Up @@ -262,6 +263,11 @@ type MachineAbortReinstallRequest struct {
PrimaryDiskWiped bool `json:"primary_disk_wiped" description:"indicates whether the primary disk is already wiped"`
}

type MachineVPN struct {
ControlPlaneAddress string `json:"address" description:"address of VPN control plane"`
AuthKey string `json:"auth_key" description:"auth key used to connect to VPN"`
}

func NewMetalMachineHardware(r *MachineHardware) metal.MachineHardware {
nics := metal.Nics{}
for i := range r.Nics {
Expand Down Expand Up @@ -456,6 +462,7 @@ func NewMachineResponse(m *metal.Machine, s *metal.Size, p *metal.Partition, i *
Succeeded: m.Allocation.Succeeded,
FilesystemLayout: NewFilesystemLayoutResponse(m.Allocation.FilesystemLayout),
Role: string(m.Allocation.Role),
VPN: NewMachineVPN(m.Allocation.VPN),
}

allocation.Reinstall = m.Allocation.Reinstall
Expand Down Expand Up @@ -564,3 +571,14 @@ func NewMachineRecentProvisioningEvents(ec *metal.ProvisioningEventContainer) *M
IncompleteProvisioningCycles: "0", // TODO: remove in next minor release
}
}

func NewMachineVPN(m *metal.MachineVPN) *MachineVPN {
if m == nil {
return nil
}

return &MachineVPN{
ControlPlaneAddress: m.ControlPlaneAddress,
AuthKey: m.AuthKey,
}
}
13 changes: 13 additions & 0 deletions cmd/metal-api/internal/service/v1/vpn.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package v1

import "time"

type VPNResponse struct {
Address string `json:"address" description:"address of VPN's control plane"`
AuthKey string `json:"auth_key" description:"auth key to connect to the VPN"`
}

type VPNRequest struct {
Pid string `json:"pid" description:"project ID"`
Expiration *time.Duration `json:"expiration" description:"expiration time" optional:"true"`
}
Loading

0 comments on commit 1336fd8

Please sign in to comment.