mirror of
https://github.com/juanfont/headscale.git
synced 2024-11-23 18:15:26 +00:00
Setup API and prepare for API keys
This commit sets up the API and gRPC endpoints and adds authentication to them. Currently there is no actual authentication implemented but it has been prepared for API keys. In addition, there is a allow put in place for gRPC traffic over localhost. This has two purposes: 1. grpc-gateway, which is the base of the API, connects to the gRPC service over localhost. 2. We do not want to break current "on server" behaviour which allows users to use the cli on the server without any fuzz
This commit is contained in:
parent
c7fa9b6e4a
commit
a23d82e33a
175
app.go
175
app.go
@ -5,6 +5,7 @@ import (
|
|||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@ -16,7 +17,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
||||||
apiV1 "github.com/juanfont/headscale/gen/go/v1"
|
apiV1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/soheilhy/cmux"
|
"github.com/soheilhy/cmux"
|
||||||
ginprometheus "github.com/zsais/go-gin-prometheus"
|
ginprometheus "github.com/zsais/go-gin-prometheus"
|
||||||
@ -24,6 +25,12 @@ import (
|
|||||||
"golang.org/x/crypto/acme/autocert"
|
"golang.org/x/crypto/acme/autocert"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/credentials"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
"google.golang.org/grpc/peer"
|
||||||
|
"google.golang.org/grpc/reflection"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"inet.af/netaddr"
|
"inet.af/netaddr"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
@ -31,6 +38,12 @@ import (
|
|||||||
"tailscale.com/types/wgkey"
|
"tailscale.com/types/wgkey"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
LOCALHOST_V4 = "127.0.0.1"
|
||||||
|
LOCALHOST_V6 = "[::1]"
|
||||||
|
AUTH_PREFIX = "Bearer "
|
||||||
|
)
|
||||||
|
|
||||||
// Config contains the initial Headscale configuration.
|
// Config contains the initial Headscale configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ServerURL string
|
ServerURL string
|
||||||
@ -208,6 +221,110 @@ func (h *Headscale) watchForKVUpdatesWorker() {
|
|||||||
// more functions will come here in the future
|
// more functions will come here in the future
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IsLocalhost(host string) bool {
|
||||||
|
if strings.Contains(host, LOCALHOST_V4) || strings.Contains(host, LOCALHOST_V6) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Headscale) grpcAuthenticationInterceptor(ctx context.Context,
|
||||||
|
req interface{},
|
||||||
|
info *grpc.UnaryServerInfo,
|
||||||
|
handler grpc.UnaryHandler) (interface{}, error) {
|
||||||
|
|
||||||
|
// Check if the request is coming from the on-server client.
|
||||||
|
// This is not secure, but it is to maintain maintainability
|
||||||
|
// with the "legacy" database-based client
|
||||||
|
// It is also neede for grpc-gateway to be able to connect to
|
||||||
|
// the server
|
||||||
|
p, _ := peer.FromContext(ctx)
|
||||||
|
|
||||||
|
if IsLocalhost(p.Addr.String()) {
|
||||||
|
log.Trace().Caller().Str("client_address", p.Addr.String()).Msg("Client connected from localhost")
|
||||||
|
|
||||||
|
return handler(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Trace().Caller().Str("client_address", p.Addr.String()).Msg("Client is trying to authenticate")
|
||||||
|
|
||||||
|
md, ok := metadata.FromIncomingContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
log.Error().Caller().Str("client_address", p.Addr.String()).Msg("Retrieving metadata is failed")
|
||||||
|
return ctx, status.Errorf(codes.InvalidArgument, "Retrieving metadata is failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
authHeader, ok := md["authorization"]
|
||||||
|
if !ok {
|
||||||
|
log.Error().Caller().Str("client_address", p.Addr.String()).Msg("Authorization token is not supplied")
|
||||||
|
return ctx, status.Errorf(codes.Unauthenticated, "Authorization token is not supplied")
|
||||||
|
}
|
||||||
|
|
||||||
|
token := authHeader[0]
|
||||||
|
|
||||||
|
if !strings.HasPrefix(token, AUTH_PREFIX) {
|
||||||
|
log.Error().
|
||||||
|
Caller().
|
||||||
|
Str("client_address", p.Addr.String()).
|
||||||
|
Msg(`missing "Bearer " prefix in "Authorization" header`)
|
||||||
|
return ctx, status.Error(codes.Unauthenticated, `missing "Bearer " prefix in "Authorization" header`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(kradalby): Implement API key backend:
|
||||||
|
// - Table in the DB
|
||||||
|
// - Key name
|
||||||
|
// - Encrypted
|
||||||
|
// - Expiry
|
||||||
|
//
|
||||||
|
// Currently all other than localhost traffic is unauthorized, this is intentional to allow
|
||||||
|
// us to make use of gRPC for our CLI, but not having to implement any of the remote capabilities
|
||||||
|
// and API key auth
|
||||||
|
return ctx, status.Error(codes.Unauthenticated, "Authentication is not implemented yet")
|
||||||
|
|
||||||
|
//if strings.TrimPrefix(token, AUTH_PREFIX) != a.Token {
|
||||||
|
// log.Error().Caller().Str("client_address", p.Addr.String()).Msg("invalid token")
|
||||||
|
// return ctx, status.Error(codes.Unauthenticated, "invalid token")
|
||||||
|
//}
|
||||||
|
|
||||||
|
// return handler(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Headscale) httpAuthenticationMiddleware(c *gin.Context) {
|
||||||
|
log.Trace().
|
||||||
|
Caller().
|
||||||
|
Str("client_address", c.ClientIP()).
|
||||||
|
Msg("HTTP authentication invoked")
|
||||||
|
|
||||||
|
authHeader := c.GetHeader("authorization")
|
||||||
|
|
||||||
|
if !strings.HasPrefix(authHeader, AUTH_PREFIX) {
|
||||||
|
log.Error().
|
||||||
|
Caller().
|
||||||
|
Str("client_address", c.ClientIP()).
|
||||||
|
Msg(`missing "Bearer " prefix in "Authorization" header`)
|
||||||
|
c.AbortWithStatus(http.StatusUnauthorized)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.AbortWithStatus(http.StatusUnauthorized)
|
||||||
|
|
||||||
|
// TODO(kradalby): Implement API key backend
|
||||||
|
// Currently all traffic is unauthorized, this is intentional to allow
|
||||||
|
// us to make use of gRPC for our CLI, but not having to implement any of the remote capabilities
|
||||||
|
// and API key auth
|
||||||
|
//
|
||||||
|
// if strings.TrimPrefix(authHeader, AUTH_PREFIX) != a.Token {
|
||||||
|
// log.Error().Caller().Str("client_address", c.ClientIP()).Msg("invalid token")
|
||||||
|
// c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error", "unauthorized"})
|
||||||
|
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
|
||||||
|
// c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
// Serve launches a GIN server with the Headscale API.
|
// Serve launches a GIN server with the Headscale API.
|
||||||
func (h *Headscale) Serve() error {
|
func (h *Headscale) Serve() error {
|
||||||
var err error
|
var err error
|
||||||
@ -226,21 +343,25 @@ func (h *Headscale) Serve() error {
|
|||||||
// The two following listeners will be served on the same port below gracefully.
|
// The two following listeners will be served on the same port below gracefully.
|
||||||
m := cmux.New(l)
|
m := cmux.New(l)
|
||||||
// Match gRPC requests here
|
// Match gRPC requests here
|
||||||
grpcListener := m.MatchWithWriters(cmux.HTTP2MatchHeaderFieldSendSettings("content-type", "application/grpc"))
|
grpcListener := m.MatchWithWriters(
|
||||||
|
cmux.HTTP2MatchHeaderFieldSendSettings("content-type", "application/grpc"),
|
||||||
|
cmux.HTTP2MatchHeaderFieldSendSettings("content-type", "application/grpc+proto"),
|
||||||
|
)
|
||||||
// Otherwise match regular http requests.
|
// Otherwise match regular http requests.
|
||||||
httpListener := m.Match(cmux.Any())
|
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()
|
grpcGatewayMux := runtime.NewServeMux()
|
||||||
|
|
||||||
opts := []grpc.DialOption{grpc.WithInsecure()}
|
grpcDialOptions := []grpc.DialOption{grpc.WithInsecure()}
|
||||||
|
|
||||||
err = apiV1.RegisterHeadscaleServiceHandlerFromEndpoint(ctx, grpcGatewayMux, h.cfg.Addr, opts)
|
_, port, err := net.SplitHostPort(h.cfg.Addr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to the gRPC server over localhost to skip
|
||||||
|
// the authentication.
|
||||||
|
err = apiV1.RegisterHeadscaleServiceHandlerFromEndpoint(ctx, grpcGatewayMux, LOCALHOST_V4+":"+port, grpcDialOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -258,10 +379,15 @@ func (h *Headscale) Serve() error {
|
|||||||
r.GET("/apple", h.AppleMobileConfig)
|
r.GET("/apple", h.AppleMobileConfig)
|
||||||
r.GET("/apple/:platform", h.ApplePlatformConfig)
|
r.GET("/apple/:platform", h.ApplePlatformConfig)
|
||||||
|
|
||||||
r.Any("/api/v1/*any", gin.WrapF(grpcGatewayMux.ServeHTTP))
|
|
||||||
r.StaticFile("/swagger/swagger.json", "gen/openapiv2/v1/headscale.swagger.json")
|
r.StaticFile("/swagger/swagger.json", "gen/openapiv2/v1/headscale.swagger.json")
|
||||||
|
|
||||||
updateMillisecondsWait := int64(5000)
|
api := r.Group("/api")
|
||||||
|
api.Use(h.httpAuthenticationMiddleware)
|
||||||
|
{
|
||||||
|
api.Any("/v1/*any", gin.WrapF(grpcGatewayMux.ServeHTTP))
|
||||||
|
}
|
||||||
|
|
||||||
|
r.NoRoute(stdoutHandler)
|
||||||
|
|
||||||
// Fetch an initial DERP Map before we start serving
|
// Fetch an initial DERP Map before we start serving
|
||||||
h.DERPMap = GetDERPMap(h.cfg.DERP)
|
h.DERPMap = GetDERPMap(h.cfg.DERP)
|
||||||
@ -273,6 +399,7 @@ func (h *Headscale) Serve() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// I HATE THIS
|
// I HATE THIS
|
||||||
|
updateMillisecondsWait := int64(5000)
|
||||||
go h.watchForKVUpdates(updateMillisecondsWait)
|
go h.watchForKVUpdates(updateMillisecondsWait)
|
||||||
go h.expireEphemeralNodes(updateMillisecondsWait)
|
go h.expireEphemeralNodes(updateMillisecondsWait)
|
||||||
|
|
||||||
@ -287,6 +414,12 @@ func (h *Headscale) Serve() error {
|
|||||||
WriteTimeout: 0,
|
WriteTimeout: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
grpcOptions := []grpc.ServerOption{
|
||||||
|
grpc.UnaryInterceptor(
|
||||||
|
h.grpcAuthenticationInterceptor,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
tlsConfig, err := h.getTLSSettings()
|
tlsConfig, err := h.getTLSSettings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to set up TLS configuration")
|
log.Error().Err(err).Msg("Failed to set up TLS configuration")
|
||||||
@ -296,8 +429,15 @@ func (h *Headscale) Serve() error {
|
|||||||
|
|
||||||
if tlsConfig != nil {
|
if tlsConfig != nil {
|
||||||
httpServer.TLSConfig = tlsConfig
|
httpServer.TLSConfig = tlsConfig
|
||||||
|
|
||||||
|
grpcOptions = append(grpcOptions, grpc.Creds(credentials.NewTLS(tlsConfig)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
grpcServer := grpc.NewServer(grpcOptions...)
|
||||||
|
|
||||||
|
apiV1.RegisterHeadscaleServiceServer(grpcServer, newHeadscaleV1APIServer(h))
|
||||||
|
reflection.Register(grpcServer)
|
||||||
|
|
||||||
g := new(errgroup.Group)
|
g := new(errgroup.Group)
|
||||||
|
|
||||||
g.Go(func() error { return grpcServer.Serve(grpcListener) })
|
g.Go(func() error { return grpcServer.Serve(grpcListener) })
|
||||||
@ -394,3 +534,14 @@ func (h *Headscale) getLastStateChange(namespaces ...string) time.Time {
|
|||||||
return times[0]
|
return times[0]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func stdoutHandler(c *gin.Context) {
|
||||||
|
b, _ := io.ReadAll(c.Request.Body)
|
||||||
|
|
||||||
|
log.Trace().
|
||||||
|
Interface("header", c.Request.Header).
|
||||||
|
Interface("proto", c.Request.Proto).
|
||||||
|
Interface("url", c.Request.URL).
|
||||||
|
Bytes("body", b).
|
||||||
|
Msg("Request did not match")
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user