Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial work preparing for API/gRPC #204

Merged
merged 16 commits into from
Oct 28, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,3 @@ docker-compose*
README.md
LICENSE
.vscode

19 changes: 0 additions & 19 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,3 @@ jobs:
# below, but it's still much faster in the end than installing
# golangci-lint manually in the `Run lint` step.
- uses: golangci/golangci-lint-action@v2
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noice, an omnibus PR

with:
args: --timeout 5m

# Setup Go
- name: Setup Go
uses: actions/setup-go@v2
with:
go-version: "1.16.3" # The Go version to download (if necessary) and use.

# Install all the dependencies
- name: Install dependencies
run: |
go version
go install golang.org/x/lint/golint@latest
sudo apt update
sudo apt install -y make

- name: Run lint
run: make lint
7 changes: 7 additions & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
run:
timeout: 5m

issues:
skip-dirs:
- gen
6 changes: 4 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
FROM bufbuild/buf:1.0.0-rc6 as buf

FROM golang:1.17.1-bullseye AS build
ENV GOPATH /go
WORKDIR /go/src/headscale

COPY go.mod go.sum /go/src/headscale/
WORKDIR /go/src/headscale
RUN go mod download

COPY . /go/src/headscale
COPY . .

RUN go install -a -ldflags="-extldflags=-static" -tags netgo,sqlite_omit_load_extension ./cmd/headscale
RUN test -e /go/bin/headscale
Expand Down
13 changes: 11 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,18 @@ coverprofile_html:
go tool cover -html=coverage.out

lint:
golint
golangci-lint run --timeout 5m
golangci-lint run --fix

compress: build
upx --brute headscale

generate:
rm -rf gen
buf generate proto

install-protobuf-plugins:
go install \
github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway \
github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2 \
google.golang.org/protobuf/cmd/protoc-gen-go \
google.golang.org/grpc/cmd/protoc-gen-go-grpc
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,40 @@ Please have a look at the documentation under [`docs/`](docs/).
1. We have nothing to do with Tailscale, or Tailscale Inc.
2. The purpose of writing this was to learn how Tailscale works.

## Contributing

To contribute to Headscale you would need the lastest version of [Go](golang.org) and [Buf](https://buf.build)(Protobuf generator).

### Install development tools

- Go
- Buf
- Protobuf tools:

```shell
make install-protobuf-plugins
```

### Testing and building

Some parts of the project requires the generation of Go code from Protobuf (if changes is made in `proto/`) and it must be (re-)generated with:

```shell
make generate
```
**Note**: Please check in changes from `gen/` in a separate commit to make it easier to review.

To run the tests:

```shell
make test
```

To build the program:

```shell
make build
```

## Contributors

Expand Down
127 changes: 105 additions & 22 deletions app.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package headscale

import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
Expand All @@ -11,20 +14,24 @@ import (
"sync"
"time"

"github.com/rs/zerolog/log"

"github.com/gin-gonic/gin"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
apiV1 "github.com/juanfont/headscale/gen/go/v1"
"github.com/rs/zerolog/log"
"github.com/soheilhy/cmux"
ginprometheus "github.com/zsais/go-gin-prometheus"
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
"golang.org/x/sync/errgroup"
"google.golang.org/grpc"
"gorm.io/gorm"
"inet.af/netaddr"
"tailscale.com/tailcfg"
"tailscale.com/types/dnstype"
"tailscale.com/types/wgkey"
)

// Config contains the initial Headscale configuration
// Config contains the initial Headscale configuration.
type Config struct {
ServerURL string
Addr string
Expand Down Expand Up @@ -64,7 +71,7 @@ type DERPConfig struct {
UpdateFrequency time.Duration
}

// Headscale represents the base app of the service
// Headscale represents the base app of the service.
type Headscale struct {
cfg Config
db *gorm.DB
Expand All @@ -82,12 +89,13 @@ type Headscale struct {
lastStateChange sync.Map
}

// NewHeadscale returns the Headscale app
// NewHeadscale returns the Headscale app.
func NewHeadscale(cfg Config) (*Headscale, error) {
content, err := os.ReadFile(cfg.PrivateKeyPath)
if err != nil {
return nil, err
}

privKey, err := wgkey.ParsePrivate(string(content))
if err != nil {
return nil, err
Expand Down Expand Up @@ -136,14 +144,14 @@ func NewHeadscale(cfg Config) (*Headscale, error) {
return &h, nil
}

// Redirect to our TLS url
// Redirect to our TLS url.
func (h *Headscale) redirect(w http.ResponseWriter, req *http.Request) {
target := h.cfg.ServerURL + req.URL.RequestURI()
http.Redirect(w, req, target, http.StatusFound)
}

// expireEphemeralNodes deletes ephemeral machine records that have not been
// seen for longer than h.cfg.EphemeralNodeInactivityTimeout
// seen for longer than h.cfg.EphemeralNodeInactivityTimeout.
func (h *Headscale) expireEphemeralNodes(milliSeconds int64) {
ticker := time.NewTicker(time.Duration(milliSeconds) * time.Millisecond)
for range ticker.C {
Expand All @@ -155,18 +163,23 @@ func (h *Headscale) expireEphemeralNodesWorker() {
namespaces, err := h.ListNamespaces()
if err != nil {
log.Error().Err(err).Msg("Error listing namespaces")

return
}

for _, ns := range *namespaces {
machines, err := h.ListMachinesInNamespace(ns.Name)
if err != nil {
log.Error().Err(err).Str("namespace", ns.Name).Msg("Error listing machines in namespace")

return
}

for _, m := range *machines {
if m.AuthKey != nil && m.LastSeen != nil && m.AuthKey.Ephemeral &&
time.Now().After(m.LastSeen.Add(h.cfg.EphemeralNodeInactivityTimeout)) {
log.Info().Str("machine", m.Name).Msg("Ephemeral client removed from database")

err = h.db.Unscoped().Delete(m).Error
if err != nil {
log.Error().
Expand All @@ -176,12 +189,13 @@ func (h *Headscale) expireEphemeralNodesWorker() {
}
}
}

h.setLastStateChangeToNow(ns.Name)
}
}

// WatchForKVUpdates checks the KV DB table for requests to perform tailnet upgrades
// This is a way to communitate the CLI with the headscale server
// This is a way to communitate the CLI with the headscale server.
func (h *Headscale) watchForKVUpdates(milliSeconds int64) {
ticker := time.NewTicker(time.Duration(milliSeconds) * time.Millisecond)
for range ticker.C {
Expand All @@ -194,24 +208,60 @@ func (h *Headscale) watchForKVUpdatesWorker() {
// more functions will come here in the future
}

// Serve launches a GIN server with the Headscale API
// Serve launches a GIN server with the Headscale API.
func (h *Headscale) Serve() error {
var err error

ctx := context.Background()
ctx, cancel := context.WithCancel(ctx)

defer cancel()

l, err := net.Listen("tcp", h.cfg.Addr)
if err != nil {
panic(err)
}

// Create the cmux object that will multiplex 2 protocols on the same port.
// The two following listeners will be served on the same port below gracefully.
m := cmux.New(l)
// Match gRPC requests here
grpcListener := m.MatchWithWriters(cmux.HTTP2MatchHeaderFieldSendSettings("content-type", "application/grpc"))
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noice. And clever.

// Otherwise match regular http requests.
httpListener := m.Match(cmux.Any())

// Now create the grpc server with those options.
grpcServer := grpc.NewServer()

// TODO(kradalby): register the new server when we have authentication ready
// apiV1.RegisterHeadscaleServiceServer(grpcServer, newHeadscaleV1APIServer(h))

grpcGatewayMux := runtime.NewServeMux()

opts := []grpc.DialOption{grpc.WithInsecure()}

err = apiV1.RegisterHeadscaleServiceHandlerFromEndpoint(ctx, grpcGatewayMux, h.cfg.Addr, opts)
if err != nil {
return err
}

r := gin.Default()

p := ginprometheus.NewPrometheus("gin")
p.Use(r)

r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"healthy": "ok"}) })
r.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"healthy": "ok"}) })
r.GET("/key", h.KeyHandler)
r.GET("/register", h.RegisterWebAPI)
r.POST("/machine/:id/map", h.PollNetMapHandler)
r.POST("/machine/:id", h.RegistrationHandler)
r.GET("/apple", h.AppleMobileConfig)
r.GET("/apple/:platform", h.ApplePlatformConfig)
var err error

go h.watchForKVUpdates(5000)
go h.expireEphemeralNodes(5000)
r.Any("/api/v1/*any", gin.WrapF(grpcGatewayMux.ServeHTTP))
r.StaticFile("/swagger/swagger.json", "gen/openapiv2/v1/headscale.swagger.json")

updateMillisecondsWait := int64(5000)

// Fetch an initial DERP Map before we start serving
h.DERPMap = GetDERPMap(h.cfg.DERP)
Expand All @@ -222,7 +272,11 @@ func (h *Headscale) Serve() error {
go h.scheduledDERPMapUpdateWorker(derpMapCancelChannel)
}

s := &http.Server{
// I HATE THIS
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Me too.

go h.watchForKVUpdates(updateMillisecondsWait)
go h.expireEphemeralNodes(updateMillisecondsWait)

httpServer := &http.Server{
Addr: h.cfg.Addr,
Handler: r,
ReadTimeout: 30 * time.Second,
Expand All @@ -233,6 +287,29 @@ func (h *Headscale) Serve() error {
WriteTimeout: 0,
}

tlsConfig, err := h.getTLSSettings()
if err != nil {
log.Error().Err(err).Msg("Failed to set up TLS configuration")

return err
}

if tlsConfig != nil {
httpServer.TLSConfig = tlsConfig
}

g := new(errgroup.Group)

g.Go(func() error { return grpcServer.Serve(grpcListener) })
g.Go(func() error { return httpServer.Serve(httpListener) })
g.Go(func() error { return m.Serve() })

log.Info().Msgf("listening and serving (multiplexed HTTP and gRPC) on: %s", h.cfg.Addr)

return g.Wait()
}

func (h *Headscale) getTLSSettings() (*tls.Config, error) {
if h.cfg.TLSLetsEncryptHostname != "" {
if !strings.HasPrefix(h.cfg.ServerURL, "https://") {
log.Warn().Msg("Listening with TLS but ServerURL does not start with https://")
Expand All @@ -248,13 +325,11 @@ func (h *Headscale) Serve() error {
Email: h.cfg.ACMEEmail,
}

s.TLSConfig = m.TLSConfig()

if h.cfg.TLSLetsEncryptChallengeType == "TLS-ALPN-01" {
// Configuration via autocert with TLS-ALPN-01 (https://tools.ietf.org/html/rfc8737)
// The RFC requires that the validation is done on port 443; in other words, headscale
// must be reachable on port 443.
err = s.ListenAndServeTLS("", "")
return m.TLSConfig(), nil
} else if h.cfg.TLSLetsEncryptChallengeType == "HTTP-01" {
// Configuration via autocert with HTTP-01. This requires listening on
// port 80 for the certificate validation in addition to the headscale
Expand All @@ -264,22 +339,30 @@ func (h *Headscale) Serve() error {
Err(http.ListenAndServe(h.cfg.TLSLetsEncryptListen, m.HTTPHandler(http.HandlerFunc(h.redirect)))).
Msg("failed to set up a HTTP server")
}()
err = s.ListenAndServeTLS("", "")

return m.TLSConfig(), nil
} else {
return errors.New("unknown value for TLSLetsEncryptChallengeType")
return nil, errors.New("unknown value for TLSLetsEncryptChallengeType")
}
} else if h.cfg.TLSCertPath == "" {
if !strings.HasPrefix(h.cfg.ServerURL, "http://") {
log.Warn().Msg("Listening without TLS but ServerURL does not start with http://")
}
err = s.ListenAndServe()

return nil, nil
} else {
if !strings.HasPrefix(h.cfg.ServerURL, "https://") {
log.Warn().Msg("Listening with TLS but ServerURL does not start with https://")
}
err = s.ListenAndServeTLS(h.cfg.TLSCertPath, h.cfg.TLSKeyPath)
var err error
tlsConfig := &tls.Config{}
tlsConfig.ClientAuth = tls.RequireAnyClientCert
tlsConfig.NextProtos = []string{"http/1.1"}
tlsConfig.Certificates = make([]tls.Certificate, 1)
tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(h.cfg.TLSCertPath, h.cfg.TLSKeyPath)

return tlsConfig, err
}
return err
}

func (h *Headscale) setLastStateChangeToNow(namespace string) {
Expand Down
Loading