mirror of
https://github.com/juanfont/headscale.git
synced 2024-12-22 07:57:34 +00:00
021c464148
This commit adds a sentral cache to keep track of clients whom has requested an expiry time, but were we need to keep hold of it until the second request comes in.
706 lines
18 KiB
Go
706 lines
18 KiB
Go
package headscale
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
"github.com/gin-gonic/gin"
|
|
grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware"
|
|
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
|
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
|
"github.com/patrickmn/go-cache"
|
|
zerolog "github.com/philip-bui/grpc-zerolog"
|
|
zl "github.com/rs/zerolog"
|
|
"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/oauth2"
|
|
"golang.org/x/sync/errgroup"
|
|
"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"
|
|
"inet.af/netaddr"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/types/dnstype"
|
|
"tailscale.com/types/wgkey"
|
|
)
|
|
|
|
const (
|
|
AuthPrefix = "Bearer "
|
|
Postgres = "postgresql"
|
|
Sqlite = "sqlite3"
|
|
updateInterval = 5000
|
|
HTTPReadTimeout = 30 * time.Second
|
|
|
|
requestedExpiryCacheExpiration = time.Minute * 5
|
|
requestedExpiryCacheCleanupInterval = time.Minute * 10
|
|
|
|
errUnsupportedDatabase = Error("unsupported DB")
|
|
errUnsupportedLetsEncryptChallengeType = Error(
|
|
"unknown value for Lets Encrypt challenge type",
|
|
)
|
|
)
|
|
|
|
// Config contains the initial Headscale configuration.
|
|
type Config struct {
|
|
ServerURL string
|
|
Addr string
|
|
PrivateKeyPath string
|
|
EphemeralNodeInactivityTimeout time.Duration
|
|
IPPrefix netaddr.IPPrefix
|
|
BaseDomain string
|
|
|
|
DERP DERPConfig
|
|
|
|
DBtype string
|
|
DBpath string
|
|
DBhost string
|
|
DBport int
|
|
DBname string
|
|
DBuser string
|
|
DBpass string
|
|
|
|
TLSLetsEncryptListen string
|
|
TLSLetsEncryptHostname string
|
|
TLSLetsEncryptCacheDir string
|
|
TLSLetsEncryptChallengeType string
|
|
|
|
TLSCertPath string
|
|
TLSKeyPath string
|
|
|
|
ACMEURL string
|
|
ACMEEmail string
|
|
|
|
DNSConfig *tailcfg.DNSConfig
|
|
|
|
UnixSocket string
|
|
|
|
OIDC OIDCConfig
|
|
|
|
CLI CLIConfig
|
|
}
|
|
|
|
type OIDCConfig struct {
|
|
Issuer string
|
|
ClientID string
|
|
ClientSecret string
|
|
MatchMap map[string]string
|
|
}
|
|
|
|
type DERPConfig struct {
|
|
URLs []url.URL
|
|
Paths []string
|
|
AutoUpdate bool
|
|
UpdateFrequency time.Duration
|
|
}
|
|
|
|
type CLIConfig struct {
|
|
Address string
|
|
APIKey string
|
|
Insecure bool
|
|
Timeout time.Duration
|
|
}
|
|
|
|
// Headscale represents the base app of the service.
|
|
type Headscale struct {
|
|
cfg Config
|
|
db *gorm.DB
|
|
dbString string
|
|
dbType string
|
|
dbDebug bool
|
|
publicKey *wgkey.Key
|
|
privateKey *wgkey.Private
|
|
|
|
DERPMap *tailcfg.DERPMap
|
|
|
|
aclPolicy *ACLPolicy
|
|
aclRules []tailcfg.FilterRule
|
|
|
|
lastStateChange sync.Map
|
|
|
|
oidcProvider *oidc.Provider
|
|
oauth2Config *oauth2.Config
|
|
oidcStateCache *cache.Cache
|
|
|
|
requestedExpiryCache *cache.Cache
|
|
}
|
|
|
|
// 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
|
|
}
|
|
pubKey := privKey.Public()
|
|
|
|
var dbString string
|
|
switch cfg.DBtype {
|
|
case Postgres:
|
|
dbString = fmt.Sprintf(
|
|
"host=%s port=%d dbname=%s user=%s password=%s sslmode=disable",
|
|
cfg.DBhost,
|
|
cfg.DBport,
|
|
cfg.DBname,
|
|
cfg.DBuser,
|
|
cfg.DBpass,
|
|
)
|
|
case Sqlite:
|
|
dbString = cfg.DBpath
|
|
default:
|
|
return nil, errUnsupportedDatabase
|
|
}
|
|
|
|
requestedExpiryCache := cache.New(
|
|
requestedExpiryCacheExpiration,
|
|
requestedExpiryCacheCleanupInterval,
|
|
)
|
|
|
|
app := Headscale{
|
|
cfg: cfg,
|
|
dbType: cfg.DBtype,
|
|
dbString: dbString,
|
|
privateKey: privKey,
|
|
publicKey: &pubKey,
|
|
aclRules: tailcfg.FilterAllowAll, // default allowall
|
|
requestedExpiryCache: requestedExpiryCache,
|
|
}
|
|
|
|
err = app.initDB()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if cfg.OIDC.Issuer != "" {
|
|
err = app.initOIDC()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if app.cfg.DNSConfig != nil && app.cfg.DNSConfig.Proxied { // if MagicDNS
|
|
magicDNSDomains := generateMagicDNSRootDomains(
|
|
app.cfg.IPPrefix,
|
|
)
|
|
// we might have routes already from Split DNS
|
|
if app.cfg.DNSConfig.Routes == nil {
|
|
app.cfg.DNSConfig.Routes = make(map[string][]dnstype.Resolver)
|
|
}
|
|
for _, d := range magicDNSDomains {
|
|
app.cfg.DNSConfig.Routes[d.WithoutTrailingDot()] = nil
|
|
}
|
|
}
|
|
|
|
return &app, nil
|
|
}
|
|
|
|
// 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.
|
|
func (h *Headscale) expireEphemeralNodes(milliSeconds int64) {
|
|
ticker := time.NewTicker(time.Duration(milliSeconds) * time.Millisecond)
|
|
for range ticker.C {
|
|
h.expireEphemeralNodesWorker()
|
|
}
|
|
}
|
|
|
|
func (h *Headscale) expireEphemeralNodesWorker() {
|
|
namespaces, err := h.ListNamespaces()
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Error listing namespaces")
|
|
|
|
return
|
|
}
|
|
|
|
for _, namespace := range namespaces {
|
|
machines, err := h.ListMachinesInNamespace(namespace.Name)
|
|
if err != nil {
|
|
log.Error().
|
|
Err(err).
|
|
Str("namespace", namespace.Name).
|
|
Msg("Error listing machines in namespace")
|
|
|
|
return
|
|
}
|
|
|
|
for _, machine := range machines {
|
|
if machine.AuthKey != nil && machine.LastSeen != nil &&
|
|
machine.AuthKey.Ephemeral &&
|
|
time.Now().
|
|
After(machine.LastSeen.Add(h.cfg.EphemeralNodeInactivityTimeout)) {
|
|
log.Info().
|
|
Str("machine", machine.Name).
|
|
Msg("Ephemeral client removed from database")
|
|
|
|
err = h.db.Unscoped().Delete(machine).Error
|
|
if err != nil {
|
|
log.Error().
|
|
Err(err).
|
|
Str("machine", machine.Name).
|
|
Msg("🤮 Cannot delete ephemeral machine from the database")
|
|
}
|
|
}
|
|
}
|
|
|
|
h.setLastStateChangeToNow(namespace.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.
|
|
func (h *Headscale) watchForKVUpdates(milliSeconds int64) {
|
|
ticker := time.NewTicker(time.Duration(milliSeconds) * time.Millisecond)
|
|
for range ticker.C {
|
|
h.watchForKVUpdatesWorker()
|
|
}
|
|
}
|
|
|
|
func (h *Headscale) watchForKVUpdatesWorker() {
|
|
h.checkForNamespacesPendingUpdates()
|
|
// more functions will come here in the future
|
|
}
|
|
|
|
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
|
|
client, _ := peer.FromContext(ctx)
|
|
|
|
log.Trace().
|
|
Caller().
|
|
Str("client_address", client.Addr.String()).
|
|
Msg("Client is trying to authenticate")
|
|
|
|
meta, ok := metadata.FromIncomingContext(ctx)
|
|
if !ok {
|
|
log.Error().
|
|
Caller().
|
|
Str("client_address", client.Addr.String()).
|
|
Msg("Retrieving metadata is failed")
|
|
|
|
return ctx, status.Errorf(
|
|
codes.InvalidArgument,
|
|
"Retrieving metadata is failed",
|
|
)
|
|
}
|
|
|
|
authHeader, ok := meta["authorization"]
|
|
if !ok {
|
|
log.Error().
|
|
Caller().
|
|
Str("client_address", client.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, AuthPrefix) {
|
|
log.Error().
|
|
Caller().
|
|
Str("client_address", client.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(ctx *gin.Context) {
|
|
log.Trace().
|
|
Caller().
|
|
Str("client_address", ctx.ClientIP()).
|
|
Msg("HTTP authentication invoked")
|
|
|
|
authHeader := ctx.GetHeader("authorization")
|
|
|
|
if !strings.HasPrefix(authHeader, AuthPrefix) {
|
|
log.Error().
|
|
Caller().
|
|
Str("client_address", ctx.ClientIP()).
|
|
Msg(`missing "Bearer " prefix in "Authorization" header`)
|
|
ctx.AbortWithStatus(http.StatusUnauthorized)
|
|
|
|
return
|
|
}
|
|
|
|
ctx.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()
|
|
}
|
|
|
|
// ensureUnixSocketIsAbsent will check if the given path for headscales unix socket is clear
|
|
// and will remove it if it is not.
|
|
func (h *Headscale) ensureUnixSocketIsAbsent() error {
|
|
// File does not exist, all fine
|
|
if _, err := os.Stat(h.cfg.UnixSocket); errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
|
|
return os.Remove(h.cfg.UnixSocket)
|
|
}
|
|
|
|
// 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()
|
|
|
|
err = h.ensureUnixSocketIsAbsent()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
socketListener, err := net.Listen("unix", h.cfg.UnixSocket)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
// Handle common process-killing signals so we can gracefully shut down:
|
|
sigc := make(chan os.Signal, 1)
|
|
signal.Notify(sigc, os.Interrupt, syscall.SIGTERM)
|
|
go func(c chan os.Signal) {
|
|
// Wait for a SIGINT or SIGKILL:
|
|
sig := <-c
|
|
log.Printf("Caught signal %s: shutting down.", sig)
|
|
// Stop listening (and unlink the socket if unix type):
|
|
socketListener.Close()
|
|
// And we're done:
|
|
os.Exit(0)
|
|
}(sigc)
|
|
|
|
networkListener, 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.
|
|
networkMutex := cmux.New(networkListener)
|
|
// Match gRPC requests here
|
|
grpcListener := networkMutex.MatchWithWriters(
|
|
cmux.HTTP2MatchHeaderFieldSendSettings("content-type", "application/grpc"),
|
|
cmux.HTTP2MatchHeaderFieldSendSettings(
|
|
"content-type",
|
|
"application/grpc+proto",
|
|
),
|
|
)
|
|
// Otherwise match regular http requests.
|
|
httpListener := networkMutex.Match(cmux.Any())
|
|
|
|
grpcGatewayMux := runtime.NewServeMux()
|
|
|
|
// Make the grpc-gateway connect to grpc over socket
|
|
grpcGatewayConn, err := grpc.Dial(
|
|
h.cfg.UnixSocket,
|
|
[]grpc.DialOption{
|
|
grpc.WithInsecure(),
|
|
grpc.WithContextDialer(GrpcSocketDialer),
|
|
}...,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Connect to the gRPC server over localhost to skip
|
|
// the authentication.
|
|
err = v1.RegisterHeadscaleServiceHandler(ctx, grpcGatewayMux, grpcGatewayConn)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
router := gin.Default()
|
|
|
|
prometheus := ginprometheus.NewPrometheus("gin")
|
|
prometheus.Use(router)
|
|
|
|
router.GET(
|
|
"/health",
|
|
func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"healthy": "ok"}) },
|
|
)
|
|
router.GET("/key", h.KeyHandler)
|
|
router.GET("/register", h.RegisterWebAPI)
|
|
router.POST("/machine/:id/map", h.PollNetMapHandler)
|
|
router.POST("/machine/:id", h.RegistrationHandler)
|
|
router.GET("/oidc/register/:mkey", h.RegisterOIDC)
|
|
router.GET("/oidc/callback", h.OIDCCallback)
|
|
router.GET("/apple", h.AppleMobileConfig)
|
|
router.GET("/apple/:platform", h.ApplePlatformConfig)
|
|
router.GET("/swagger", SwaggerUI)
|
|
router.GET("/swagger/v1/openapiv2.json", SwaggerAPIv1)
|
|
|
|
api := router.Group("/api")
|
|
api.Use(h.httpAuthenticationMiddleware)
|
|
{
|
|
api.Any("/v1/*any", gin.WrapF(grpcGatewayMux.ServeHTTP))
|
|
}
|
|
|
|
router.NoRoute(stdoutHandler)
|
|
|
|
// Fetch an initial DERP Map before we start serving
|
|
h.DERPMap = GetDERPMap(h.cfg.DERP)
|
|
|
|
if h.cfg.DERP.AutoUpdate {
|
|
derpMapCancelChannel := make(chan struct{})
|
|
defer func() { derpMapCancelChannel <- struct{}{} }()
|
|
go h.scheduledDERPMapUpdateWorker(derpMapCancelChannel)
|
|
}
|
|
|
|
// I HATE THIS
|
|
go h.watchForKVUpdates(updateInterval)
|
|
go h.expireEphemeralNodes(updateInterval)
|
|
|
|
httpServer := &http.Server{
|
|
Addr: h.cfg.Addr,
|
|
Handler: router,
|
|
ReadTimeout: HTTPReadTimeout,
|
|
// Go does not handle timeouts in HTTP very well, and there is
|
|
// no good way to handle streaming timeouts, therefore we need to
|
|
// keep this at unlimited and be careful to clean up connections
|
|
// https://blog.cloudflare.com/the-complete-guide-to-golang-net-http-timeouts/#aboutstreaming
|
|
WriteTimeout: 0,
|
|
}
|
|
|
|
if zl.GlobalLevel() == zl.TraceLevel {
|
|
zerolog.RespLog = true
|
|
} else {
|
|
zerolog.RespLog = false
|
|
}
|
|
|
|
grpcOptions := []grpc.ServerOption{
|
|
grpc.UnaryInterceptor(
|
|
grpc_middleware.ChainUnaryServer(
|
|
h.grpcAuthenticationInterceptor,
|
|
zerolog.NewUnaryServerInterceptor(),
|
|
),
|
|
),
|
|
}
|
|
|
|
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
|
|
|
|
grpcOptions = append(grpcOptions, grpc.Creds(credentials.NewTLS(tlsConfig)))
|
|
}
|
|
|
|
grpcServer := grpc.NewServer(grpcOptions...)
|
|
|
|
// Start the local gRPC server without TLS and without authentication
|
|
grpcSocket := grpc.NewServer(zerolog.UnaryInterceptor())
|
|
|
|
v1.RegisterHeadscaleServiceServer(grpcServer, newHeadscaleV1APIServer(h))
|
|
v1.RegisterHeadscaleServiceServer(grpcSocket, newHeadscaleV1APIServer(h))
|
|
reflection.Register(grpcServer)
|
|
reflection.Register(grpcSocket)
|
|
|
|
errorGroup := new(errgroup.Group)
|
|
|
|
errorGroup.Go(func() error { return grpcSocket.Serve(socketListener) })
|
|
|
|
// TODO(kradalby): Verify if we need the same TLS setup for gRPC as HTTP
|
|
errorGroup.Go(func() error { return grpcServer.Serve(grpcListener) })
|
|
|
|
if tlsConfig != nil {
|
|
errorGroup.Go(func() error {
|
|
tlsl := tls.NewListener(httpListener, tlsConfig)
|
|
|
|
return httpServer.Serve(tlsl)
|
|
})
|
|
} else {
|
|
errorGroup.Go(func() error { return httpServer.Serve(httpListener) })
|
|
}
|
|
|
|
errorGroup.Go(func() error { return networkMutex.Serve() })
|
|
|
|
log.Info().
|
|
Msgf("listening and serving (multiplexed HTTP and gRPC) on: %s", h.cfg.Addr)
|
|
|
|
return errorGroup.Wait()
|
|
}
|
|
|
|
func (h *Headscale) getTLSSettings() (*tls.Config, error) {
|
|
var err 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://")
|
|
}
|
|
|
|
certManager := autocert.Manager{
|
|
Prompt: autocert.AcceptTOS,
|
|
HostPolicy: autocert.HostWhitelist(h.cfg.TLSLetsEncryptHostname),
|
|
Cache: autocert.DirCache(h.cfg.TLSLetsEncryptCacheDir),
|
|
Client: &acme.Client{
|
|
DirectoryURL: h.cfg.ACMEURL,
|
|
},
|
|
Email: h.cfg.ACMEEmail,
|
|
}
|
|
|
|
switch h.cfg.TLSLetsEncryptChallengeType {
|
|
case "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.
|
|
return certManager.TLSConfig(), nil
|
|
|
|
case "HTTP-01":
|
|
// Configuration via autocert with HTTP-01. This requires listening on
|
|
// port 80 for the certificate validation in addition to the headscale
|
|
// service, which can be configured to run on any other port.
|
|
go func() {
|
|
log.Fatal().
|
|
Err(http.ListenAndServe(h.cfg.TLSLetsEncryptListen, certManager.HTTPHandler(http.HandlerFunc(h.redirect)))).
|
|
Msg("failed to set up a HTTP server")
|
|
}()
|
|
|
|
return certManager.TLSConfig(), nil
|
|
|
|
default:
|
|
return nil, errUnsupportedLetsEncryptChallengeType
|
|
}
|
|
} 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://")
|
|
}
|
|
|
|
return nil, err
|
|
} else {
|
|
if !strings.HasPrefix(h.cfg.ServerURL, "https://") {
|
|
log.Warn().Msg("Listening with TLS but ServerURL does not start with https://")
|
|
}
|
|
tlsConfig := &tls.Config{
|
|
ClientAuth: tls.RequireAnyClientCert,
|
|
NextProtos: []string{"http/1.1"},
|
|
Certificates: make([]tls.Certificate, 1),
|
|
MinVersion: tls.VersionTLS12,
|
|
}
|
|
tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(h.cfg.TLSCertPath, h.cfg.TLSKeyPath)
|
|
|
|
return tlsConfig, err
|
|
}
|
|
}
|
|
|
|
func (h *Headscale) setLastStateChangeToNow(namespace string) {
|
|
now := time.Now().UTC()
|
|
lastStateUpdate.WithLabelValues("", "headscale").Set(float64(now.Unix()))
|
|
h.lastStateChange.Store(namespace, now)
|
|
}
|
|
|
|
func (h *Headscale) getLastStateChange(namespaces ...string) time.Time {
|
|
times := []time.Time{}
|
|
|
|
for _, namespace := range namespaces {
|
|
if wrapped, ok := h.lastStateChange.Load(namespace); ok {
|
|
lastChange, _ := wrapped.(time.Time)
|
|
|
|
times = append(times, lastChange)
|
|
}
|
|
}
|
|
|
|
sort.Slice(times, func(i, j int) bool {
|
|
return times[i].After(times[j])
|
|
})
|
|
|
|
log.Trace().Msgf("Latest times %#v", times)
|
|
|
|
if len(times) == 0 {
|
|
return time.Now().UTC()
|
|
} else {
|
|
return times[0]
|
|
}
|
|
}
|
|
|
|
func stdoutHandler(ctx *gin.Context) {
|
|
body, _ := io.ReadAll(ctx.Request.Body)
|
|
|
|
log.Trace().
|
|
Interface("header", ctx.Request.Header).
|
|
Interface("proto", ctx.Request.Proto).
|
|
Interface("url", ctx.Request.URL).
|
|
Bytes("body", body).
|
|
Msg("Request did not match")
|
|
}
|