From 533ecee2522739f38ec0df5a627483ed45c73805 Mon Sep 17 00:00:00 2001
From: Kristoffer Dalby <kradalby@kradalby.no>
Date: Fri, 3 Jun 2022 09:05:41 +0200
Subject: [PATCH 01/11] Move config struct to its own file

---
 app.go    | 89 --------------------------------------------------
 config.go | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 97 insertions(+), 89 deletions(-)
 create mode 100644 config.go

diff --git a/app.go b/app.go
index 054fd17..9b92fd3 100644
--- a/app.go
+++ b/app.go
@@ -6,10 +6,8 @@ import (
 	"errors"
 	"fmt"
 	"io"
-	"io/fs"
 	"net"
 	"net/http"
-	"net/url"
 	"os"
 	"os/signal"
 	"sort"
@@ -42,7 +40,6 @@ import (
 	"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/key"
@@ -72,92 +69,6 @@ const (
 	EnforcedClientAuth = "enforced"
 )
 
-// Config contains the initial Headscale configuration.
-type Config struct {
-	ServerURL                      string
-	Addr                           string
-	MetricsAddr                    string
-	GRPCAddr                       string
-	GRPCAllowInsecure              bool
-	EphemeralNodeInactivityTimeout time.Duration
-	IPPrefixes                     []netaddr.IPPrefix
-	PrivateKeyPath                 string
-	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
-	TLSClientAuthMode tls.ClientAuthType
-
-	ACMEURL   string
-	ACMEEmail string
-
-	DNSConfig *tailcfg.DNSConfig
-
-	UnixSocket           string
-	UnixSocketPermission fs.FileMode
-
-	OIDC OIDCConfig
-
-	LogTail LogTailConfig
-
-	CLI CLIConfig
-
-	ACL ACLConfig
-}
-
-type OIDCConfig struct {
-	Issuer           string
-	ClientID         string
-	ClientSecret     string
-	Scope            []string
-	ExtraParams      map[string]string
-	AllowedDomains   []string
-	AllowedUsers     []string
-	StripEmaildomain bool
-}
-
-type DERPConfig struct {
-	ServerEnabled    bool
-	ServerRegionID   int
-	ServerRegionCode string
-	ServerRegionName string
-	STUNAddr         string
-	URLs             []url.URL
-	Paths            []string
-	AutoUpdate       bool
-	UpdateFrequency  time.Duration
-}
-
-type LogTailConfig struct {
-	Enabled bool
-}
-
-type CLIConfig struct {
-	Address  string
-	APIKey   string
-	Timeout  time.Duration
-	Insecure bool
-}
-
-type ACLConfig struct {
-	PolicyPath string
-}
-
 // Headscale represents the base app of the service.
 type Headscale struct {
 	cfg        Config
diff --git a/config.go b/config.go
new file mode 100644
index 0000000..29337fd
--- /dev/null
+++ b/config.go
@@ -0,0 +1,97 @@
+package headscale
+
+import (
+	"crypto/tls"
+	"io/fs"
+	"net/url"
+	"time"
+
+	"inet.af/netaddr"
+	"tailscale.com/tailcfg"
+)
+
+// Config contains the initial Headscale configuration.
+type Config struct {
+	ServerURL                      string
+	Addr                           string
+	MetricsAddr                    string
+	GRPCAddr                       string
+	GRPCAllowInsecure              bool
+	EphemeralNodeInactivityTimeout time.Duration
+	IPPrefixes                     []netaddr.IPPrefix
+	PrivateKeyPath                 string
+	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
+	TLSClientAuthMode tls.ClientAuthType
+
+	ACMEURL   string
+	ACMEEmail string
+
+	DNSConfig *tailcfg.DNSConfig
+
+	UnixSocket           string
+	UnixSocketPermission fs.FileMode
+
+	OIDC OIDCConfig
+
+	LogTail LogTailConfig
+
+	CLI CLIConfig
+
+	ACL ACLConfig
+}
+
+type OIDCConfig struct {
+	Issuer           string
+	ClientID         string
+	ClientSecret     string
+	Scope            []string
+	ExtraParams      map[string]string
+	AllowedDomains   []string
+	AllowedUsers     []string
+	StripEmaildomain bool
+}
+
+type DERPConfig struct {
+	ServerEnabled    bool
+	ServerRegionID   int
+	ServerRegionCode string
+	ServerRegionName string
+	STUNAddr         string
+	URLs             []url.URL
+	Paths            []string
+	AutoUpdate       bool
+	UpdateFrequency  time.Duration
+}
+
+type LogTailConfig struct {
+	Enabled bool
+}
+
+type CLIConfig struct {
+	Address  string
+	APIKey   string
+	Timeout  time.Duration
+	Insecure bool
+}
+
+type ACLConfig struct {
+	PolicyPath string
+}

From 35722cd5aabbb270d2545e8ce16874cfab5b1388 Mon Sep 17 00:00:00 2001
From: Kristoffer Dalby <kradalby@kradalby.no>
Date: Fri, 3 Jun 2022 09:24:36 +0200
Subject: [PATCH 02/11] Move FilePerm function from cli to headscale

---
 cmd/headscale/cli/utils.go | 12 ------------
 utils.go                   | 15 +++++++++++++++
 2 files changed, 15 insertions(+), 12 deletions(-)

diff --git a/cmd/headscale/cli/utils.go b/cmd/headscale/cli/utils.go
index af4391a..7d9ba98 100644
--- a/cmd/headscale/cli/utils.go
+++ b/cmd/headscale/cli/utils.go
@@ -29,7 +29,6 @@ import (
 )
 
 const (
-	PermissionFallback      = 0o700
 	HeadscaleDateTimeFormat = "2006-01-02 15:04:05"
 )
 
@@ -570,17 +569,6 @@ func (tokenAuth) RequireTransportSecurity() bool {
 	return true
 }
 
-func GetFileMode(key string) fs.FileMode {
-	modeStr := viper.GetString(key)
-
-	mode, err := strconv.ParseUint(modeStr, headscale.Base8, headscale.BitSize64)
-	if err != nil {
-		return PermissionFallback
-	}
-
-	return fs.FileMode(mode)
-}
-
 func contains[T string](ts []T, t T) bool {
 	for _, v := range ts {
 		if reflect.DeepEqual(v, t) {
diff --git a/utils.go b/utils.go
index 6dddf4c..8d9dec5 100644
--- a/utils.go
+++ b/utils.go
@@ -11,10 +11,12 @@ import (
 	"encoding/base64"
 	"encoding/json"
 	"fmt"
+	"io/fs"
 	"net"
 	"os"
 	"path/filepath"
 	"reflect"
+	"strconv"
 	"strings"
 
 	"github.com/rs/zerolog/log"
@@ -55,6 +57,8 @@ const (
 
 	// privateKey prefix.
 	privateHexPrefix = "privkey:"
+
+	PermissionFallback = 0o700
 )
 
 func MachinePublicKeyStripPrefix(machineKey key.MachinePublic) string {
@@ -350,3 +354,14 @@ func AbsolutePathFromConfigPath(path string) string {
 
 	return path
 }
+
+func GetFileMode(key string) fs.FileMode {
+	modeStr := viper.GetString(key)
+
+	mode, err := strconv.ParseUint(modeStr, Base8, BitSize64)
+	if err != nil {
+		return PermissionFallback
+	}
+
+	return fs.FileMode(mode)
+}

From 1ea8bb782c4854fae4aab44d46ce6f9e4fc0e348 Mon Sep 17 00:00:00 2001
From: Kristoffer Dalby <kradalby@kradalby.no>
Date: Fri, 3 Jun 2022 09:26:36 +0200
Subject: [PATCH 03/11] Move all read config logic to config.go

---
 cmd/headscale/cli/utils.go | 376 +------------------------------------
 config.go                  | 376 +++++++++++++++++++++++++++++++++++++
 2 files changed, 378 insertions(+), 374 deletions(-)

diff --git a/cmd/headscale/cli/utils.go b/cmd/headscale/cli/utils.go
index 7d9ba98..0b447ab 100644
--- a/cmd/headscale/cli/utils.go
+++ b/cmd/headscale/cli/utils.go
@@ -4,17 +4,11 @@ import (
 	"context"
 	"crypto/tls"
 	"encoding/json"
-	"errors"
 	"fmt"
-	"io/fs"
-	"net/url"
 	"os"
 	"reflect"
-	"strconv"
-	"strings"
 	"time"
 
-	"github.com/coreos/go-oidc/v3/oidc"
 	"github.com/juanfont/headscale"
 	v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
 	"github.com/rs/zerolog/log"
@@ -23,378 +17,12 @@ import (
 	"google.golang.org/grpc/credentials"
 	"google.golang.org/grpc/credentials/insecure"
 	"gopkg.in/yaml.v2"
-	"inet.af/netaddr"
-	"tailscale.com/tailcfg"
-	"tailscale.com/types/dnstype"
 )
 
 const (
 	HeadscaleDateTimeFormat = "2006-01-02 15:04:05"
 )
 
-func LoadConfig(path string) error {
-	viper.SetConfigName("config")
-	if path == "" {
-		viper.AddConfigPath("/etc/headscale/")
-		viper.AddConfigPath("$HOME/.headscale")
-		viper.AddConfigPath(".")
-	} else {
-		// For testing
-		viper.AddConfigPath(path)
-	}
-
-	viper.SetEnvPrefix("headscale")
-	viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
-	viper.AutomaticEnv()
-
-	viper.SetDefault("tls_letsencrypt_cache_dir", "/var/www/.cache")
-	viper.SetDefault("tls_letsencrypt_challenge_type", "HTTP-01")
-	viper.SetDefault("tls_client_auth_mode", "relaxed")
-
-	viper.SetDefault("log_level", "info")
-
-	viper.SetDefault("dns_config", nil)
-
-	viper.SetDefault("derp.server.enabled", false)
-	viper.SetDefault("derp.server.stun.enabled", true)
-
-	viper.SetDefault("unix_socket", "/var/run/headscale.sock")
-	viper.SetDefault("unix_socket_permission", "0o770")
-
-	viper.SetDefault("grpc_listen_addr", ":50443")
-	viper.SetDefault("grpc_allow_insecure", false)
-
-	viper.SetDefault("cli.timeout", "5s")
-	viper.SetDefault("cli.insecure", false)
-
-	viper.SetDefault("oidc.scope", []string{oidc.ScopeOpenID, "profile", "email"})
-	viper.SetDefault("oidc.strip_email_domain", true)
-
-	viper.SetDefault("logtail.enabled", false)
-
-	if err := viper.ReadInConfig(); err != nil {
-		return fmt.Errorf("fatal error reading config file: %w", err)
-	}
-
-	// Collect any validation errors and return them all at once
-	var errorText string
-	if (viper.GetString("tls_letsencrypt_hostname") != "") &&
-		((viper.GetString("tls_cert_path") != "") || (viper.GetString("tls_key_path") != "")) {
-		errorText += "Fatal config error: set either tls_letsencrypt_hostname or tls_cert_path/tls_key_path, not both\n"
-	}
-
-	if (viper.GetString("tls_letsencrypt_hostname") != "") &&
-		(viper.GetString("tls_letsencrypt_challenge_type") == "TLS-ALPN-01") &&
-		(!strings.HasSuffix(viper.GetString("listen_addr"), ":443")) {
-		// this is only a warning because there could be something sitting in front of headscale that redirects the traffic (e.g. an iptables rule)
-		log.Warn().
-			Msg("Warning: when using tls_letsencrypt_hostname with TLS-ALPN-01 as challenge type, headscale must be reachable on port 443, i.e. listen_addr should probably end in :443")
-	}
-
-	if (viper.GetString("tls_letsencrypt_challenge_type") != "HTTP-01") &&
-		(viper.GetString("tls_letsencrypt_challenge_type") != "TLS-ALPN-01") {
-		errorText += "Fatal config error: the only supported values for tls_letsencrypt_challenge_type are HTTP-01 and TLS-ALPN-01\n"
-	}
-
-	if !strings.HasPrefix(viper.GetString("server_url"), "http://") &&
-		!strings.HasPrefix(viper.GetString("server_url"), "https://") {
-		errorText += "Fatal config error: server_url must start with https:// or http://\n"
-	}
-
-	_, authModeValid := headscale.LookupTLSClientAuthMode(
-		viper.GetString("tls_client_auth_mode"),
-	)
-
-	if !authModeValid {
-		errorText += fmt.Sprintf(
-			"Invalid tls_client_auth_mode supplied: %s. Accepted values: %s, %s, %s.",
-			viper.GetString("tls_client_auth_mode"),
-			headscale.DisabledClientAuth,
-			headscale.RelaxedClientAuth,
-			headscale.EnforcedClientAuth)
-	}
-
-	if errorText != "" {
-		//nolint
-		return errors.New(strings.TrimSuffix(errorText, "\n"))
-	} else {
-		return nil
-	}
-}
-
-func GetDERPConfig() headscale.DERPConfig {
-	serverEnabled := viper.GetBool("derp.server.enabled")
-	serverRegionID := viper.GetInt("derp.server.region_id")
-	serverRegionCode := viper.GetString("derp.server.region_code")
-	serverRegionName := viper.GetString("derp.server.region_name")
-	stunAddr := viper.GetString("derp.server.stun_listen_addr")
-
-	if serverEnabled && stunAddr == "" {
-		log.Fatal().
-			Msg("derp.server.stun_listen_addr must be set if derp.server.enabled is true")
-	}
-
-	urlStrs := viper.GetStringSlice("derp.urls")
-
-	urls := make([]url.URL, len(urlStrs))
-	for index, urlStr := range urlStrs {
-		urlAddr, err := url.Parse(urlStr)
-		if err != nil {
-			log.Error().
-				Str("url", urlStr).
-				Err(err).
-				Msg("Failed to parse url, ignoring...")
-		}
-
-		urls[index] = *urlAddr
-	}
-
-	paths := viper.GetStringSlice("derp.paths")
-
-	autoUpdate := viper.GetBool("derp.auto_update_enabled")
-	updateFrequency := viper.GetDuration("derp.update_frequency")
-
-	return headscale.DERPConfig{
-		ServerEnabled:    serverEnabled,
-		ServerRegionID:   serverRegionID,
-		ServerRegionCode: serverRegionCode,
-		ServerRegionName: serverRegionName,
-		STUNAddr:         stunAddr,
-		URLs:             urls,
-		Paths:            paths,
-		AutoUpdate:       autoUpdate,
-		UpdateFrequency:  updateFrequency,
-	}
-}
-
-func GetLogTailConfig() headscale.LogTailConfig {
-	enabled := viper.GetBool("logtail.enabled")
-
-	return headscale.LogTailConfig{
-		Enabled: enabled,
-	}
-}
-
-func GetACLConfig() headscale.ACLConfig {
-	policyPath := viper.GetString("acl_policy_path")
-
-	return headscale.ACLConfig{
-		PolicyPath: policyPath,
-	}
-}
-
-func GetDNSConfig() (*tailcfg.DNSConfig, string) {
-	if viper.IsSet("dns_config") {
-		dnsConfig := &tailcfg.DNSConfig{}
-
-		if viper.IsSet("dns_config.nameservers") {
-			nameserversStr := viper.GetStringSlice("dns_config.nameservers")
-
-			nameservers := make([]netaddr.IP, len(nameserversStr))
-			resolvers := make([]dnstype.Resolver, len(nameserversStr))
-
-			for index, nameserverStr := range nameserversStr {
-				nameserver, err := netaddr.ParseIP(nameserverStr)
-				if err != nil {
-					log.Error().
-						Str("func", "getDNSConfig").
-						Err(err).
-						Msgf("Could not parse nameserver IP: %s", nameserverStr)
-				}
-
-				nameservers[index] = nameserver
-				resolvers[index] = dnstype.Resolver{
-					Addr: nameserver.String(),
-				}
-			}
-
-			dnsConfig.Nameservers = nameservers
-			dnsConfig.Resolvers = resolvers
-		}
-
-		if viper.IsSet("dns_config.restricted_nameservers") {
-			if len(dnsConfig.Nameservers) > 0 {
-				dnsConfig.Routes = make(map[string][]dnstype.Resolver)
-				restrictedDNS := viper.GetStringMapStringSlice(
-					"dns_config.restricted_nameservers",
-				)
-				for domain, restrictedNameservers := range restrictedDNS {
-					restrictedResolvers := make(
-						[]dnstype.Resolver,
-						len(restrictedNameservers),
-					)
-					for index, nameserverStr := range restrictedNameservers {
-						nameserver, err := netaddr.ParseIP(nameserverStr)
-						if err != nil {
-							log.Error().
-								Str("func", "getDNSConfig").
-								Err(err).
-								Msgf("Could not parse restricted nameserver IP: %s", nameserverStr)
-						}
-						restrictedResolvers[index] = dnstype.Resolver{
-							Addr: nameserver.String(),
-						}
-					}
-					dnsConfig.Routes[domain] = restrictedResolvers
-				}
-			} else {
-				log.Warn().
-					Msg("Warning: dns_config.restricted_nameservers is set, but no nameservers are configured. Ignoring restricted_nameservers.")
-			}
-		}
-
-		if viper.IsSet("dns_config.domains") {
-			dnsConfig.Domains = viper.GetStringSlice("dns_config.domains")
-		}
-
-		if viper.IsSet("dns_config.magic_dns") {
-			magicDNS := viper.GetBool("dns_config.magic_dns")
-			if len(dnsConfig.Nameservers) > 0 {
-				dnsConfig.Proxied = magicDNS
-			} else if magicDNS {
-				log.Warn().
-					Msg("Warning: dns_config.magic_dns is set, but no nameservers are configured. Ignoring magic_dns.")
-			}
-		}
-
-		var baseDomain string
-		if viper.IsSet("dns_config.base_domain") {
-			baseDomain = viper.GetString("dns_config.base_domain")
-		} else {
-			baseDomain = "headscale.net" // does not really matter when MagicDNS is not enabled
-		}
-
-		return dnsConfig, baseDomain
-	}
-
-	return nil, ""
-}
-
-func GetHeadscaleConfig() headscale.Config {
-	dnsConfig, baseDomain := GetDNSConfig()
-	derpConfig := GetDERPConfig()
-	logConfig := GetLogTailConfig()
-
-	configuredPrefixes := viper.GetStringSlice("ip_prefixes")
-	parsedPrefixes := make([]netaddr.IPPrefix, 0, len(configuredPrefixes)+1)
-
-	legacyPrefixField := viper.GetString("ip_prefix")
-	if len(legacyPrefixField) > 0 {
-		log.
-			Warn().
-			Msgf(
-				"%s, %s",
-				"use of 'ip_prefix' for configuration is deprecated",
-				"please see 'ip_prefixes' in the shipped example.",
-			)
-		legacyPrefix, err := netaddr.ParseIPPrefix(legacyPrefixField)
-		if err != nil {
-			panic(fmt.Errorf("failed to parse ip_prefix: %w", err))
-		}
-		parsedPrefixes = append(parsedPrefixes, legacyPrefix)
-	}
-
-	for i, prefixInConfig := range configuredPrefixes {
-		prefix, err := netaddr.ParseIPPrefix(prefixInConfig)
-		if err != nil {
-			panic(fmt.Errorf("failed to parse ip_prefixes[%d]: %w", i, err))
-		}
-		parsedPrefixes = append(parsedPrefixes, prefix)
-	}
-
-	prefixes := make([]netaddr.IPPrefix, 0, len(parsedPrefixes))
-	{
-		// dedup
-		normalizedPrefixes := make(map[string]int, len(parsedPrefixes))
-		for i, p := range parsedPrefixes {
-			normalized, _ := p.Range().Prefix()
-			normalizedPrefixes[normalized.String()] = i
-		}
-
-		// convert back to list
-		for _, i := range normalizedPrefixes {
-			prefixes = append(prefixes, parsedPrefixes[i])
-		}
-	}
-
-	if len(prefixes) < 1 {
-		prefixes = append(prefixes, netaddr.MustParseIPPrefix("100.64.0.0/10"))
-		log.Warn().
-			Msgf("'ip_prefixes' not configured, falling back to default: %v", prefixes)
-	}
-
-	tlsClientAuthMode, _ := headscale.LookupTLSClientAuthMode(
-		viper.GetString("tls_client_auth_mode"),
-	)
-
-	return headscale.Config{
-		ServerURL:         viper.GetString("server_url"),
-		Addr:              viper.GetString("listen_addr"),
-		MetricsAddr:       viper.GetString("metrics_listen_addr"),
-		GRPCAddr:          viper.GetString("grpc_listen_addr"),
-		GRPCAllowInsecure: viper.GetBool("grpc_allow_insecure"),
-
-		IPPrefixes:     prefixes,
-		PrivateKeyPath: headscale.AbsolutePathFromConfigPath(viper.GetString("private_key_path")),
-		BaseDomain:     baseDomain,
-
-		DERP: derpConfig,
-
-		EphemeralNodeInactivityTimeout: viper.GetDuration(
-			"ephemeral_node_inactivity_timeout",
-		),
-
-		DBtype: viper.GetString("db_type"),
-		DBpath: headscale.AbsolutePathFromConfigPath(viper.GetString("db_path")),
-		DBhost: viper.GetString("db_host"),
-		DBport: viper.GetInt("db_port"),
-		DBname: viper.GetString("db_name"),
-		DBuser: viper.GetString("db_user"),
-		DBpass: viper.GetString("db_pass"),
-
-		TLSLetsEncryptHostname: viper.GetString("tls_letsencrypt_hostname"),
-		TLSLetsEncryptListen:   viper.GetString("tls_letsencrypt_listen"),
-		TLSLetsEncryptCacheDir: headscale.AbsolutePathFromConfigPath(
-			viper.GetString("tls_letsencrypt_cache_dir"),
-		),
-		TLSLetsEncryptChallengeType: viper.GetString("tls_letsencrypt_challenge_type"),
-
-		TLSCertPath:       headscale.AbsolutePathFromConfigPath(viper.GetString("tls_cert_path")),
-		TLSKeyPath:        headscale.AbsolutePathFromConfigPath(viper.GetString("tls_key_path")),
-		TLSClientAuthMode: tlsClientAuthMode,
-
-		DNSConfig: dnsConfig,
-
-		ACMEEmail: viper.GetString("acme_email"),
-		ACMEURL:   viper.GetString("acme_url"),
-
-		UnixSocket:           viper.GetString("unix_socket"),
-		UnixSocketPermission: GetFileMode("unix_socket_permission"),
-
-		OIDC: headscale.OIDCConfig{
-			Issuer:           viper.GetString("oidc.issuer"),
-			ClientID:         viper.GetString("oidc.client_id"),
-			ClientSecret:     viper.GetString("oidc.client_secret"),
-			Scope:            viper.GetStringSlice("oidc.scope"),
-			ExtraParams:      viper.GetStringMapString("oidc.extra_params"),
-			AllowedDomains:   viper.GetStringSlice("oidc.allowed_domains"),
-			AllowedUsers:     viper.GetStringSlice("oidc.allowed_users"),
-			StripEmaildomain: viper.GetBool("oidc.strip_email_domain"),
-		},
-
-		LogTail: logConfig,
-
-		CLI: headscale.CLIConfig{
-			Address:  viper.GetString("cli.address"),
-			APIKey:   viper.GetString("cli.api_key"),
-			Timeout:  viper.GetDuration("cli.timeout"),
-			Insecure: viper.GetBool("cli.insecure"),
-		},
-
-		ACL: GetACLConfig(),
-	}
-}
-
 func getHeadscaleApp() (*headscale.Headscale, error) {
 	// Minimum inactivity time out is keepalive timeout (60s) plus a few seconds
 	// to avoid races
@@ -411,7 +39,7 @@ func getHeadscaleApp() (*headscale.Headscale, error) {
 		return nil, err
 	}
 
-	cfg := GetHeadscaleConfig()
+	cfg := headscale.GetHeadscaleConfig()
 
 	app, err := headscale.NewHeadscale(cfg)
 	if err != nil {
@@ -435,7 +63,7 @@ func getHeadscaleApp() (*headscale.Headscale, error) {
 }
 
 func getHeadscaleCLIClient() (context.Context, v1.HeadscaleServiceClient, *grpc.ClientConn, context.CancelFunc) {
-	cfg := GetHeadscaleConfig()
+	cfg := headscale.GetHeadscaleConfig()
 
 	log.Debug().
 		Dur("timeout", cfg.CLI.Timeout).
diff --git a/config.go b/config.go
index 29337fd..070a90b 100644
--- a/config.go
+++ b/config.go
@@ -2,12 +2,19 @@ package headscale
 
 import (
 	"crypto/tls"
+	"errors"
+	"fmt"
 	"io/fs"
 	"net/url"
+	"strings"
 	"time"
 
+	"github.com/coreos/go-oidc/v3/oidc"
+	"github.com/rs/zerolog/log"
+	"github.com/spf13/viper"
 	"inet.af/netaddr"
 	"tailscale.com/tailcfg"
+	"tailscale.com/types/dnstype"
 )
 
 // Config contains the initial Headscale configuration.
@@ -95,3 +102,372 @@ type CLIConfig struct {
 type ACLConfig struct {
 	PolicyPath string
 }
+
+func LoadConfig(path string) error {
+	viper.SetConfigName("config")
+	if path == "" {
+		viper.AddConfigPath("/etc/headscale/")
+		viper.AddConfigPath("$HOME/.headscale")
+		viper.AddConfigPath(".")
+	} else {
+		// For testing
+		viper.AddConfigPath(path)
+	}
+
+	viper.SetEnvPrefix("headscale")
+	viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
+	viper.AutomaticEnv()
+
+	viper.SetDefault("tls_letsencrypt_cache_dir", "/var/www/.cache")
+	viper.SetDefault("tls_letsencrypt_challenge_type", "HTTP-01")
+	viper.SetDefault("tls_client_auth_mode", "relaxed")
+
+	viper.SetDefault("log_level", "info")
+
+	viper.SetDefault("dns_config", nil)
+
+	viper.SetDefault("derp.server.enabled", false)
+	viper.SetDefault("derp.server.stun.enabled", true)
+
+	viper.SetDefault("unix_socket", "/var/run/headscale.sock")
+	viper.SetDefault("unix_socket_permission", "0o770")
+
+	viper.SetDefault("grpc_listen_addr", ":50443")
+	viper.SetDefault("grpc_allow_insecure", false)
+
+	viper.SetDefault("cli.timeout", "5s")
+	viper.SetDefault("cli.insecure", false)
+
+	viper.SetDefault("oidc.scope", []string{oidc.ScopeOpenID, "profile", "email"})
+	viper.SetDefault("oidc.strip_email_domain", true)
+
+	viper.SetDefault("logtail.enabled", false)
+
+	if err := viper.ReadInConfig(); err != nil {
+		return fmt.Errorf("fatal error reading config file: %w", err)
+	}
+
+	// Collect any validation errors and return them all at once
+	var errorText string
+	if (viper.GetString("tls_letsencrypt_hostname") != "") &&
+		((viper.GetString("tls_cert_path") != "") || (viper.GetString("tls_key_path") != "")) {
+		errorText += "Fatal config error: set either tls_letsencrypt_hostname or tls_cert_path/tls_key_path, not both\n"
+	}
+
+	if (viper.GetString("tls_letsencrypt_hostname") != "") &&
+		(viper.GetString("tls_letsencrypt_challenge_type") == "TLS-ALPN-01") &&
+		(!strings.HasSuffix(viper.GetString("listen_addr"), ":443")) {
+		// this is only a warning because there could be something sitting in front of headscale that redirects the traffic (e.g. an iptables rule)
+		log.Warn().
+			Msg("Warning: when using tls_letsencrypt_hostname with TLS-ALPN-01 as challenge type, headscale must be reachable on port 443, i.e. listen_addr should probably end in :443")
+	}
+
+	if (viper.GetString("tls_letsencrypt_challenge_type") != "HTTP-01") &&
+		(viper.GetString("tls_letsencrypt_challenge_type") != "TLS-ALPN-01") {
+		errorText += "Fatal config error: the only supported values for tls_letsencrypt_challenge_type are HTTP-01 and TLS-ALPN-01\n"
+	}
+
+	if !strings.HasPrefix(viper.GetString("server_url"), "http://") &&
+		!strings.HasPrefix(viper.GetString("server_url"), "https://") {
+		errorText += "Fatal config error: server_url must start with https:// or http://\n"
+	}
+
+	_, authModeValid := LookupTLSClientAuthMode(
+		viper.GetString("tls_client_auth_mode"),
+	)
+
+	if !authModeValid {
+		errorText += fmt.Sprintf(
+			"Invalid tls_client_auth_mode supplied: %s. Accepted values: %s, %s, %s.",
+			viper.GetString("tls_client_auth_mode"),
+			DisabledClientAuth,
+			RelaxedClientAuth,
+			EnforcedClientAuth)
+	}
+
+	if errorText != "" {
+		//nolint
+		return errors.New(strings.TrimSuffix(errorText, "\n"))
+	} else {
+		return nil
+	}
+}
+
+func GetDERPConfig() DERPConfig {
+	serverEnabled := viper.GetBool("derp.server.enabled")
+	serverRegionID := viper.GetInt("derp.server.region_id")
+	serverRegionCode := viper.GetString("derp.server.region_code")
+	serverRegionName := viper.GetString("derp.server.region_name")
+	stunAddr := viper.GetString("derp.server.stun_listen_addr")
+
+	if serverEnabled && stunAddr == "" {
+		log.Fatal().
+			Msg("derp.server.stun_listen_addr must be set if derp.server.enabled is true")
+	}
+
+	urlStrs := viper.GetStringSlice("derp.urls")
+
+	urls := make([]url.URL, len(urlStrs))
+	for index, urlStr := range urlStrs {
+		urlAddr, err := url.Parse(urlStr)
+		if err != nil {
+			log.Error().
+				Str("url", urlStr).
+				Err(err).
+				Msg("Failed to parse url, ignoring...")
+		}
+
+		urls[index] = *urlAddr
+	}
+
+	paths := viper.GetStringSlice("derp.paths")
+
+	autoUpdate := viper.GetBool("derp.auto_update_enabled")
+	updateFrequency := viper.GetDuration("derp.update_frequency")
+
+	return DERPConfig{
+		ServerEnabled:    serverEnabled,
+		ServerRegionID:   serverRegionID,
+		ServerRegionCode: serverRegionCode,
+		ServerRegionName: serverRegionName,
+		STUNAddr:         stunAddr,
+		URLs:             urls,
+		Paths:            paths,
+		AutoUpdate:       autoUpdate,
+		UpdateFrequency:  updateFrequency,
+	}
+}
+
+func GetLogTailConfig() LogTailConfig {
+	enabled := viper.GetBool("logtail.enabled")
+
+	return LogTailConfig{
+		Enabled: enabled,
+	}
+}
+
+func GetACLConfig() ACLConfig {
+	policyPath := viper.GetString("acl_policy_path")
+
+	return ACLConfig{
+		PolicyPath: policyPath,
+	}
+}
+
+func GetDNSConfig() (*tailcfg.DNSConfig, string) {
+	if viper.IsSet("dns_config") {
+		dnsConfig := &tailcfg.DNSConfig{}
+
+		if viper.IsSet("dns_config.nameservers") {
+			nameserversStr := viper.GetStringSlice("dns_config.nameservers")
+
+			nameservers := make([]netaddr.IP, len(nameserversStr))
+			resolvers := make([]dnstype.Resolver, len(nameserversStr))
+
+			for index, nameserverStr := range nameserversStr {
+				nameserver, err := netaddr.ParseIP(nameserverStr)
+				if err != nil {
+					log.Error().
+						Str("func", "getDNSConfig").
+						Err(err).
+						Msgf("Could not parse nameserver IP: %s", nameserverStr)
+				}
+
+				nameservers[index] = nameserver
+				resolvers[index] = dnstype.Resolver{
+					Addr: nameserver.String(),
+				}
+			}
+
+			dnsConfig.Nameservers = nameservers
+			dnsConfig.Resolvers = resolvers
+		}
+
+		if viper.IsSet("dns_config.restricted_nameservers") {
+			if len(dnsConfig.Nameservers) > 0 {
+				dnsConfig.Routes = make(map[string][]dnstype.Resolver)
+				restrictedDNS := viper.GetStringMapStringSlice(
+					"dns_config.restricted_nameservers",
+				)
+				for domain, restrictedNameservers := range restrictedDNS {
+					restrictedResolvers := make(
+						[]dnstype.Resolver,
+						len(restrictedNameservers),
+					)
+					for index, nameserverStr := range restrictedNameservers {
+						nameserver, err := netaddr.ParseIP(nameserverStr)
+						if err != nil {
+							log.Error().
+								Str("func", "getDNSConfig").
+								Err(err).
+								Msgf("Could not parse restricted nameserver IP: %s", nameserverStr)
+						}
+						restrictedResolvers[index] = dnstype.Resolver{
+							Addr: nameserver.String(),
+						}
+					}
+					dnsConfig.Routes[domain] = restrictedResolvers
+				}
+			} else {
+				log.Warn().
+					Msg("Warning: dns_config.restricted_nameservers is set, but no nameservers are configured. Ignoring restricted_nameservers.")
+			}
+		}
+
+		if viper.IsSet("dns_config.domains") {
+			dnsConfig.Domains = viper.GetStringSlice("dns_config.domains")
+		}
+
+		if viper.IsSet("dns_config.magic_dns") {
+			magicDNS := viper.GetBool("dns_config.magic_dns")
+			if len(dnsConfig.Nameservers) > 0 {
+				dnsConfig.Proxied = magicDNS
+			} else if magicDNS {
+				log.Warn().
+					Msg("Warning: dns_config.magic_dns is set, but no nameservers are configured. Ignoring magic_dns.")
+			}
+		}
+
+		var baseDomain string
+		if viper.IsSet("dns_config.base_domain") {
+			baseDomain = viper.GetString("dns_config.base_domain")
+		} else {
+			baseDomain = "headscale.net" // does not really matter when MagicDNS is not enabled
+		}
+
+		return dnsConfig, baseDomain
+	}
+
+	return nil, ""
+}
+
+func GetHeadscaleConfig() Config {
+	dnsConfig, baseDomain := GetDNSConfig()
+	derpConfig := GetDERPConfig()
+	logConfig := GetLogTailConfig()
+
+	configuredPrefixes := viper.GetStringSlice("ip_prefixes")
+	parsedPrefixes := make([]netaddr.IPPrefix, 0, len(configuredPrefixes)+1)
+
+	legacyPrefixField := viper.GetString("ip_prefix")
+	if len(legacyPrefixField) > 0 {
+		log.
+			Warn().
+			Msgf(
+				"%s, %s",
+				"use of 'ip_prefix' for configuration is deprecated",
+				"please see 'ip_prefixes' in the shipped example.",
+			)
+		legacyPrefix, err := netaddr.ParseIPPrefix(legacyPrefixField)
+		if err != nil {
+			panic(fmt.Errorf("failed to parse ip_prefix: %w", err))
+		}
+		parsedPrefixes = append(parsedPrefixes, legacyPrefix)
+	}
+
+	for i, prefixInConfig := range configuredPrefixes {
+		prefix, err := netaddr.ParseIPPrefix(prefixInConfig)
+		if err != nil {
+			panic(fmt.Errorf("failed to parse ip_prefixes[%d]: %w", i, err))
+		}
+		parsedPrefixes = append(parsedPrefixes, prefix)
+	}
+
+	prefixes := make([]netaddr.IPPrefix, 0, len(parsedPrefixes))
+	{
+		// dedup
+		normalizedPrefixes := make(map[string]int, len(parsedPrefixes))
+		for i, p := range parsedPrefixes {
+			normalized, _ := p.Range().Prefix()
+			normalizedPrefixes[normalized.String()] = i
+		}
+
+		// convert back to list
+		for _, i := range normalizedPrefixes {
+			prefixes = append(prefixes, parsedPrefixes[i])
+		}
+	}
+
+	if len(prefixes) < 1 {
+		prefixes = append(prefixes, netaddr.MustParseIPPrefix("100.64.0.0/10"))
+		log.Warn().
+			Msgf("'ip_prefixes' not configured, falling back to default: %v", prefixes)
+	}
+
+	tlsClientAuthMode, _ := LookupTLSClientAuthMode(
+		viper.GetString("tls_client_auth_mode"),
+	)
+
+	return Config{
+		ServerURL:         viper.GetString("server_url"),
+		Addr:              viper.GetString("listen_addr"),
+		MetricsAddr:       viper.GetString("metrics_listen_addr"),
+		GRPCAddr:          viper.GetString("grpc_listen_addr"),
+		GRPCAllowInsecure: viper.GetBool("grpc_allow_insecure"),
+
+		IPPrefixes: prefixes,
+		PrivateKeyPath: AbsolutePathFromConfigPath(
+			viper.GetString("private_key_path"),
+		),
+		BaseDomain: baseDomain,
+
+		DERP: derpConfig,
+
+		EphemeralNodeInactivityTimeout: viper.GetDuration(
+			"ephemeral_node_inactivity_timeout",
+		),
+
+		DBtype: viper.GetString("db_type"),
+		DBpath: AbsolutePathFromConfigPath(viper.GetString("db_path")),
+		DBhost: viper.GetString("db_host"),
+		DBport: viper.GetInt("db_port"),
+		DBname: viper.GetString("db_name"),
+		DBuser: viper.GetString("db_user"),
+		DBpass: viper.GetString("db_pass"),
+
+		TLSLetsEncryptHostname: viper.GetString("tls_letsencrypt_hostname"),
+		TLSLetsEncryptListen:   viper.GetString("tls_letsencrypt_listen"),
+		TLSLetsEncryptCacheDir: AbsolutePathFromConfigPath(
+			viper.GetString("tls_letsencrypt_cache_dir"),
+		),
+		TLSLetsEncryptChallengeType: viper.GetString("tls_letsencrypt_challenge_type"),
+
+		TLSCertPath: AbsolutePathFromConfigPath(
+			viper.GetString("tls_cert_path"),
+		),
+		TLSKeyPath: AbsolutePathFromConfigPath(
+			viper.GetString("tls_key_path"),
+		),
+		TLSClientAuthMode: tlsClientAuthMode,
+
+		DNSConfig: dnsConfig,
+
+		ACMEEmail: viper.GetString("acme_email"),
+		ACMEURL:   viper.GetString("acme_url"),
+
+		UnixSocket:           viper.GetString("unix_socket"),
+		UnixSocketPermission: GetFileMode("unix_socket_permission"),
+
+		OIDC: OIDCConfig{
+			Issuer:           viper.GetString("oidc.issuer"),
+			ClientID:         viper.GetString("oidc.client_id"),
+			ClientSecret:     viper.GetString("oidc.client_secret"),
+			Scope:            viper.GetStringSlice("oidc.scope"),
+			ExtraParams:      viper.GetStringMapString("oidc.extra_params"),
+			AllowedDomains:   viper.GetStringSlice("oidc.allowed_domains"),
+			AllowedUsers:     viper.GetStringSlice("oidc.allowed_users"),
+			StripEmaildomain: viper.GetBool("oidc.strip_email_domain"),
+		},
+
+		LogTail: logConfig,
+
+		CLI: CLIConfig{
+			Address:  viper.GetString("cli.address"),
+			APIKey:   viper.GetString("cli.api_key"),
+			Timeout:  viper.GetDuration("cli.timeout"),
+			Insecure: viper.GetBool("cli.insecure"),
+		},
+
+		ACL: GetACLConfig(),
+	}
+}

From 5514a862dccbc8145216abed642ea34301259b16 Mon Sep 17 00:00:00 2001
From: Kristoffer Dalby <kradalby@kradalby.no>
Date: Fri, 3 Jun 2022 09:26:46 +0200
Subject: [PATCH 04/11] Update headscale read config tests

---
 cmd/headscale/headscale.go      |  3 ++-
 cmd/headscale/headscale_test.go | 14 +++++++-------
 2 files changed, 9 insertions(+), 8 deletions(-)

diff --git a/cmd/headscale/headscale.go b/cmd/headscale/headscale.go
index 600b186..dde1c2e 100644
--- a/cmd/headscale/headscale.go
+++ b/cmd/headscale/headscale.go
@@ -7,6 +7,7 @@ import (
 	"time"
 
 	"github.com/efekarakus/termcolor"
+	"github.com/juanfont/headscale"
 	"github.com/juanfont/headscale/cmd/headscale/cli"
 	"github.com/rs/zerolog"
 	"github.com/rs/zerolog/log"
@@ -43,7 +44,7 @@ func main() {
 		NoColor:    !colors,
 	})
 
-	if err := cli.LoadConfig(""); err != nil {
+	if err := headscale.LoadConfig(""); err != nil {
 		log.Fatal().Caller().Err(err)
 	}
 
diff --git a/cmd/headscale/headscale_test.go b/cmd/headscale/headscale_test.go
index faf55f4..9ca4a2c 100644
--- a/cmd/headscale/headscale_test.go
+++ b/cmd/headscale/headscale_test.go
@@ -8,7 +8,7 @@ import (
 	"strings"
 	"testing"
 
-	"github.com/juanfont/headscale/cmd/headscale/cli"
+	"github.com/juanfont/headscale"
 	"github.com/spf13/viper"
 	"gopkg.in/check.v1"
 )
@@ -49,7 +49,7 @@ func (*Suite) TestConfigLoading(c *check.C) {
 	}
 
 	// Load example config, it should load without validation errors
-	err = cli.LoadConfig(tmpDir)
+	err = headscale.LoadConfig(tmpDir)
 	c.Assert(err, check.IsNil)
 
 	// Test that config file was interpreted correctly
@@ -63,7 +63,7 @@ func (*Suite) TestConfigLoading(c *check.C) {
 	c.Assert(viper.GetString("tls_letsencrypt_challenge_type"), check.Equals, "HTTP-01")
 	c.Assert(viper.GetStringSlice("dns_config.nameservers")[0], check.Equals, "1.1.1.1")
 	c.Assert(
-		cli.GetFileMode("unix_socket_permission"),
+		headscale.GetFileMode("unix_socket_permission"),
 		check.Equals,
 		fs.FileMode(0o770),
 	)
@@ -92,10 +92,10 @@ func (*Suite) TestDNSConfigLoading(c *check.C) {
 	}
 
 	// Load example config, it should load without validation errors
-	err = cli.LoadConfig(tmpDir)
+	err = headscale.LoadConfig(tmpDir)
 	c.Assert(err, check.IsNil)
 
-	dnsConfig, baseDomain := cli.GetDNSConfig()
+	dnsConfig, baseDomain := headscale.GetDNSConfig()
 
 	c.Assert(dnsConfig.Nameservers[0].String(), check.Equals, "1.1.1.1")
 	c.Assert(dnsConfig.Resolvers[0].Addr, check.Equals, "1.1.1.1")
@@ -125,7 +125,7 @@ func (*Suite) TestTLSConfigValidation(c *check.C) {
 	writeConfig(c, tmpDir, configYaml)
 
 	// Check configuration validation errors (1)
-	err = cli.LoadConfig(tmpDir)
+	err = headscale.LoadConfig(tmpDir)
 	c.Assert(err, check.NotNil)
 	// check.Matches can not handle multiline strings
 	tmp := strings.ReplaceAll(err.Error(), "\n", "***")
@@ -150,6 +150,6 @@ func (*Suite) TestTLSConfigValidation(c *check.C) {
 		"---\nserver_url: \"http://127.0.0.1:8080\"\ntls_letsencrypt_hostname: \"example.com\"\ntls_letsencrypt_challenge_type: \"TLS-ALPN-01\"",
 	)
 	writeConfig(c, tmpDir, configYaml)
-	err = cli.LoadConfig(tmpDir)
+	err = headscale.LoadConfig(tmpDir)
 	c.Assert(err, check.IsNil)
 }

From aee8aa1c61b727865e682a07417466cf92e0629e Mon Sep 17 00:00:00 2001
From: Kristoffer Dalby <kradalby@kradalby.no>
Date: Fri, 3 Jun 2022 10:14:14 +0200
Subject: [PATCH 05/11] Move TLS config into its own struct

---
 app.go    | 18 +++++++--------
 config.go | 67 ++++++++++++++++++++++++++++++++++---------------------
 2 files changed, 50 insertions(+), 35 deletions(-)

diff --git a/app.go b/app.go
index 9b92fd3..c835013 100644
--- a/app.go
+++ b/app.go
@@ -689,7 +689,7 @@ func (h *Headscale) Serve() error {
 
 func (h *Headscale) getTLSSettings() (*tls.Config, error) {
 	var err error
-	if h.cfg.TLSLetsEncryptHostname != "" {
+	if h.cfg.TLS.LetsEncrypt.Hostname != "" {
 		if !strings.HasPrefix(h.cfg.ServerURL, "https://") {
 			log.Warn().
 				Msg("Listening with TLS but ServerURL does not start with https://")
@@ -697,15 +697,15 @@ func (h *Headscale) getTLSSettings() (*tls.Config, error) {
 
 		certManager := autocert.Manager{
 			Prompt:     autocert.AcceptTOS,
-			HostPolicy: autocert.HostWhitelist(h.cfg.TLSLetsEncryptHostname),
-			Cache:      autocert.DirCache(h.cfg.TLSLetsEncryptCacheDir),
+			HostPolicy: autocert.HostWhitelist(h.cfg.TLS.LetsEncrypt.Hostname),
+			Cache:      autocert.DirCache(h.cfg.TLS.LetsEncrypt.CacheDir),
 			Client: &acme.Client{
 				DirectoryURL: h.cfg.ACMEURL,
 			},
 			Email: h.cfg.ACMEEmail,
 		}
 
-		switch h.cfg.TLSLetsEncryptChallengeType {
+		switch h.cfg.TLS.LetsEncrypt.ChallengeType {
 		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
@@ -719,7 +719,7 @@ func (h *Headscale) getTLSSettings() (*tls.Config, error) {
 			go func() {
 				log.Fatal().
 					Caller().
-					Err(http.ListenAndServe(h.cfg.TLSLetsEncryptListen, certManager.HTTPHandler(http.HandlerFunc(h.redirect)))).
+					Err(http.ListenAndServe(h.cfg.TLS.LetsEncrypt.Listen, certManager.HTTPHandler(http.HandlerFunc(h.redirect)))).
 					Msg("failed to set up a HTTP server")
 			}()
 
@@ -728,7 +728,7 @@ func (h *Headscale) getTLSSettings() (*tls.Config, error) {
 		default:
 			return nil, errUnsupportedLetsEncryptChallengeType
 		}
-	} else if h.cfg.TLSCertPath == "" {
+	} else if h.cfg.TLS.CertPath == "" {
 		if !strings.HasPrefix(h.cfg.ServerURL, "http://") {
 			log.Warn().Msg("Listening without TLS but ServerURL does not start with http://")
 		}
@@ -741,16 +741,16 @@ func (h *Headscale) getTLSSettings() (*tls.Config, error) {
 
 		log.Info().Msg(fmt.Sprintf(
 			"Client authentication (mTLS) is \"%s\". See the docs to learn about configuring this setting.",
-			h.cfg.TLSClientAuthMode))
+			h.cfg.TLS.ClientAuthMode))
 
 		tlsConfig := &tls.Config{
-			ClientAuth:   h.cfg.TLSClientAuthMode,
+			ClientAuth:   h.cfg.TLS.ClientAuthMode,
 			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)
+		tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(h.cfg.TLS.CertPath, h.cfg.TLS.KeyPath)
 
 		return tlsConfig, err
 	}
diff --git a/config.go b/config.go
index 070a90b..23f0c82 100644
--- a/config.go
+++ b/config.go
@@ -39,14 +39,7 @@ type Config struct {
 	DBuser string
 	DBpass string
 
-	TLSLetsEncryptListen        string
-	TLSLetsEncryptHostname      string
-	TLSLetsEncryptCacheDir      string
-	TLSLetsEncryptChallengeType string
-
-	TLSCertPath       string
-	TLSKeyPath        string
-	TLSClientAuthMode tls.ClientAuthType
+	TLS TLSConfig
 
 	ACMEURL   string
 	ACMEEmail string
@@ -65,6 +58,21 @@ type Config struct {
 	ACL ACLConfig
 }
 
+type TLSConfig struct {
+	CertPath       string
+	KeyPath        string
+	ClientAuthMode tls.ClientAuthType
+
+	LetsEncrypt LetsEncryptConfig
+}
+
+type LetsEncryptConfig struct {
+	Listen        string
+	Hostname      string
+	CacheDir      string
+	ChallengeType string
+}
+
 type OIDCConfig struct {
 	Issuer           string
 	ClientID         string
@@ -193,6 +201,30 @@ func LoadConfig(path string) error {
 	}
 }
 
+func GetTLSConfig() TLSConfig {
+	tlsClientAuthMode, _ := LookupTLSClientAuthMode(
+		viper.GetString("tls_client_auth_mode"),
+	)
+
+	return TLSConfig{
+		LetsEncrypt: LetsEncryptConfig{
+			Hostname: viper.GetString("tls_letsencrypt_hostname"),
+			Listen:   viper.GetString("tls_letsencrypt_listen"),
+			CacheDir: AbsolutePathFromConfigPath(
+				viper.GetString("tls_letsencrypt_cache_dir"),
+			),
+			ChallengeType: viper.GetString("tls_letsencrypt_challenge_type"),
+		},
+		CertPath: AbsolutePathFromConfigPath(
+			viper.GetString("tls_cert_path"),
+		),
+		KeyPath: AbsolutePathFromConfigPath(
+			viper.GetString("tls_key_path"),
+		),
+		ClientAuthMode: tlsClientAuthMode,
+	}
+}
+
 func GetDERPConfig() DERPConfig {
 	serverEnabled := viper.GetBool("derp.server.enabled")
 	serverRegionID := viper.GetInt("derp.server.region_id")
@@ -394,10 +426,6 @@ func GetHeadscaleConfig() Config {
 			Msgf("'ip_prefixes' not configured, falling back to default: %v", prefixes)
 	}
 
-	tlsClientAuthMode, _ := LookupTLSClientAuthMode(
-		viper.GetString("tls_client_auth_mode"),
-	)
-
 	return Config{
 		ServerURL:         viper.GetString("server_url"),
 		Addr:              viper.GetString("listen_addr"),
@@ -425,20 +453,7 @@ func GetHeadscaleConfig() Config {
 		DBuser: viper.GetString("db_user"),
 		DBpass: viper.GetString("db_pass"),
 
-		TLSLetsEncryptHostname: viper.GetString("tls_letsencrypt_hostname"),
-		TLSLetsEncryptListen:   viper.GetString("tls_letsencrypt_listen"),
-		TLSLetsEncryptCacheDir: AbsolutePathFromConfigPath(
-			viper.GetString("tls_letsencrypt_cache_dir"),
-		),
-		TLSLetsEncryptChallengeType: viper.GetString("tls_letsencrypt_challenge_type"),
-
-		TLSCertPath: AbsolutePathFromConfigPath(
-			viper.GetString("tls_cert_path"),
-		),
-		TLSKeyPath: AbsolutePathFromConfigPath(
-			viper.GetString("tls_key_path"),
-		),
-		TLSClientAuthMode: tlsClientAuthMode,
+		TLS: GetTLSConfig(),
 
 		DNSConfig: dnsConfig,
 

From 24c9530eee4e730856a5ed987f1bf2e448224247 Mon Sep 17 00:00:00 2001
From: Kristoffer Dalby <kradalby@kradalby.no>
Date: Fri, 3 Jun 2022 10:37:45 +0200
Subject: [PATCH 06/11] Add loglevel and disable update to config struct

---
 config.go | 21 ++++++++++++++++-----
 1 file changed, 16 insertions(+), 5 deletions(-)

diff --git a/config.go b/config.go
index 23f0c82..045ce02 100644
--- a/config.go
+++ b/config.go
@@ -10,6 +10,7 @@ import (
 	"time"
 
 	"github.com/coreos/go-oidc/v3/oidc"
+	"github.com/rs/zerolog"
 	"github.com/rs/zerolog/log"
 	"github.com/spf13/viper"
 	"inet.af/netaddr"
@@ -28,6 +29,8 @@ type Config struct {
 	IPPrefixes                     []netaddr.IPPrefix
 	PrivateKeyPath                 string
 	BaseDomain                     string
+	LogLevel                       zerolog.Level
+	DisableUpdateCheck             bool
 
 	DERP DERPConfig
 
@@ -381,6 +384,12 @@ func GetHeadscaleConfig() Config {
 	configuredPrefixes := viper.GetStringSlice("ip_prefixes")
 	parsedPrefixes := make([]netaddr.IPPrefix, 0, len(configuredPrefixes)+1)
 
+	logLevelStr := viper.GetString("log_level")
+	logLevel, err := zerolog.ParseLevel(logLevelStr)
+	if err != nil {
+		logLevel = zerolog.DebugLevel
+	}
+
 	legacyPrefixField := viper.GetString("ip_prefix")
 	if len(legacyPrefixField) > 0 {
 		log.
@@ -427,11 +436,13 @@ func GetHeadscaleConfig() Config {
 	}
 
 	return Config{
-		ServerURL:         viper.GetString("server_url"),
-		Addr:              viper.GetString("listen_addr"),
-		MetricsAddr:       viper.GetString("metrics_listen_addr"),
-		GRPCAddr:          viper.GetString("grpc_listen_addr"),
-		GRPCAllowInsecure: viper.GetBool("grpc_allow_insecure"),
+		ServerURL:          viper.GetString("server_url"),
+		Addr:               viper.GetString("listen_addr"),
+		MetricsAddr:        viper.GetString("metrics_listen_addr"),
+		GRPCAddr:           viper.GetString("grpc_listen_addr"),
+		GRPCAllowInsecure:  viper.GetBool("grpc_allow_insecure"),
+		DisableUpdateCheck: viper.GetBool("disable_check_updates"),
+		LogLevel:           logLevel,
 
 		IPPrefixes: prefixes,
 		PrivateKeyPath: AbsolutePathFromConfigPath(

From af891808f6451f3cdbd3ed3a88f7d4b76852f08e Mon Sep 17 00:00:00 2001
From: Kristoffer Dalby <kradalby@kradalby.no>
Date: Sun, 5 Jun 2022 17:47:12 +0200
Subject: [PATCH 07/11] Make get config load the config, use config in main
 method

---
 cmd/headscale/cli/utils.go | 15 ++++++++++++---
 cmd/headscale/headscale.go | 14 ++++----------
 config.go                  | 11 ++++++++---
 3 files changed, 24 insertions(+), 16 deletions(-)

diff --git a/cmd/headscale/cli/utils.go b/cmd/headscale/cli/utils.go
index 0b447ab..f5c679c 100644
--- a/cmd/headscale/cli/utils.go
+++ b/cmd/headscale/cli/utils.go
@@ -24,6 +24,11 @@ const (
 )
 
 func getHeadscaleApp() (*headscale.Headscale, error) {
+	cfg, err := headscale.GetHeadscaleConfig()
+	if err != nil {
+		return nil, fmt.Errorf("failed to load configuration while creating headscale instance: %w", err)
+	}
+
 	// Minimum inactivity time out is keepalive timeout (60s) plus a few seconds
 	// to avoid races
 	minInactivityTimeout, _ := time.ParseDuration("65s")
@@ -39,8 +44,6 @@ func getHeadscaleApp() (*headscale.Headscale, error) {
 		return nil, err
 	}
 
-	cfg := headscale.GetHeadscaleConfig()
-
 	app, err := headscale.NewHeadscale(cfg)
 	if err != nil {
 		return nil, err
@@ -63,7 +66,13 @@ func getHeadscaleApp() (*headscale.Headscale, error) {
 }
 
 func getHeadscaleCLIClient() (context.Context, v1.HeadscaleServiceClient, *grpc.ClientConn, context.CancelFunc) {
-	cfg := headscale.GetHeadscaleConfig()
+	cfg, err := headscale.GetHeadscaleConfig()
+	if err != nil {
+		log.Fatal().
+			Err(err).
+			Caller().
+			Msgf("Failed to load configuration")
+	}
 
 	log.Debug().
 		Dur("timeout", cfg.CLI.Timeout).
diff --git a/cmd/headscale/headscale.go b/cmd/headscale/headscale.go
index dde1c2e..f5e2866 100644
--- a/cmd/headscale/headscale.go
+++ b/cmd/headscale/headscale.go
@@ -11,7 +11,6 @@ import (
 	"github.com/juanfont/headscale/cmd/headscale/cli"
 	"github.com/rs/zerolog"
 	"github.com/rs/zerolog/log"
-	"github.com/spf13/viper"
 	"github.com/tcnksm/go-latest"
 )
 
@@ -44,19 +43,14 @@ func main() {
 		NoColor:    !colors,
 	})
 
-	if err := headscale.LoadConfig(""); err != nil {
+	cfg, err := headscale.GetHeadscaleConfig()
+	if err != nil {
 		log.Fatal().Caller().Err(err)
 	}
 
 	machineOutput := cli.HasMachineOutputFlag()
 
-	logLevel := viper.GetString("log_level")
-	level, err := zerolog.ParseLevel(logLevel)
-	if err != nil {
-		zerolog.SetGlobalLevel(zerolog.DebugLevel)
-	} else {
-		zerolog.SetGlobalLevel(level)
-	}
+	zerolog.SetGlobalLevel(cfg.LogLevel)
 
 	// If the user has requested a "machine" readable format,
 	// then disable login so the output remains valid.
@@ -64,7 +58,7 @@ func main() {
 		zerolog.SetGlobalLevel(zerolog.Disabled)
 	}
 
-	if !viper.GetBool("disable_check_updates") && !machineOutput {
+	if !cfg.DisableUpdateCheck && !machineOutput {
 		if (runtime.GOOS == "linux" || runtime.GOOS == "darwin") &&
 			cli.Version != "dev" {
 			githubTag := &latest.GithubTag{
diff --git a/config.go b/config.go
index 045ce02..909a48c 100644
--- a/config.go
+++ b/config.go
@@ -376,7 +376,12 @@ func GetDNSConfig() (*tailcfg.DNSConfig, string) {
 	return nil, ""
 }
 
-func GetHeadscaleConfig() Config {
+func GetHeadscaleConfig() (*Config, error) {
+	err := LoadConfig("")
+	if err != nil {
+		return nil, err
+	}
+
 	dnsConfig, baseDomain := GetDNSConfig()
 	derpConfig := GetDERPConfig()
 	logConfig := GetLogTailConfig()
@@ -435,7 +440,7 @@ func GetHeadscaleConfig() Config {
 			Msgf("'ip_prefixes' not configured, falling back to default: %v", prefixes)
 	}
 
-	return Config{
+	return &Config{
 		ServerURL:          viper.GetString("server_url"),
 		Addr:               viper.GetString("listen_addr"),
 		MetricsAddr:        viper.GetString("metrics_listen_addr"),
@@ -495,5 +500,5 @@ func GetHeadscaleConfig() Config {
 		},
 
 		ACL: GetACLConfig(),
-	}
+	}, nil
 }

From 78ed610b50fa85cb1201d78624c3c5e6b7620f84 Mon Sep 17 00:00:00 2001
From: Kristoffer Dalby <kradalby@kradalby.no>
Date: Sun, 5 Jun 2022 17:47:26 +0200
Subject: [PATCH 08/11] Switch config to pointer

---
 app.go          |  4 ++--
 app_test.go     |  2 +-
 machine_test.go | 12 ++++++------
 3 files changed, 9 insertions(+), 9 deletions(-)

diff --git a/app.go b/app.go
index c835013..01528fb 100644
--- a/app.go
+++ b/app.go
@@ -71,7 +71,7 @@ const (
 
 // Headscale represents the base app of the service.
 type Headscale struct {
-	cfg        Config
+	cfg        *Config
 	db         *gorm.DB
 	dbString   string
 	dbType     string
@@ -115,7 +115,7 @@ func LookupTLSClientAuthMode(mode string) (tls.ClientAuthType, bool) {
 	}
 }
 
-func NewHeadscale(cfg Config) (*Headscale, error) {
+func NewHeadscale(cfg *Config) (*Headscale, error) {
 	privKey, err := readOrCreatePrivateKey(cfg.PrivateKeyPath)
 	if err != nil {
 		return nil, fmt.Errorf("failed to read or create private key: %w", err)
diff --git a/app_test.go b/app_test.go
index 96036a1..170db48 100644
--- a/app_test.go
+++ b/app_test.go
@@ -46,7 +46,7 @@ func (s *Suite) ResetDB(c *check.C) {
 	}
 
 	app = Headscale{
-		cfg:      cfg,
+		cfg:      &cfg,
 		dbType:   "sqlite3",
 		dbString: tmpDir + "/headscale_test.db",
 	}
diff --git a/machine_test.go b/machine_test.go
index 0fb3ed7..bde9605 100644
--- a/machine_test.go
+++ b/machine_test.go
@@ -821,7 +821,7 @@ func TestHeadscale_GenerateGivenName(t *testing.T) {
 		{
 			name: "simple machine name generation",
 			h: &Headscale{
-				cfg: Config{
+				cfg: &Config{
 					OIDC: OIDCConfig{
 						StripEmaildomain: true,
 					},
@@ -836,7 +836,7 @@ func TestHeadscale_GenerateGivenName(t *testing.T) {
 		{
 			name: "machine name with 53 chars",
 			h: &Headscale{
-				cfg: Config{
+				cfg: &Config{
 					OIDC: OIDCConfig{
 						StripEmaildomain: true,
 					},
@@ -851,7 +851,7 @@ func TestHeadscale_GenerateGivenName(t *testing.T) {
 		{
 			name: "machine name with 60 chars",
 			h: &Headscale{
-				cfg: Config{
+				cfg: &Config{
 					OIDC: OIDCConfig{
 						StripEmaildomain: true,
 					},
@@ -866,7 +866,7 @@ func TestHeadscale_GenerateGivenName(t *testing.T) {
 		{
 			name: "machine name with 63 chars",
 			h: &Headscale{
-				cfg: Config{
+				cfg: &Config{
 					OIDC: OIDCConfig{
 						StripEmaildomain: true,
 					},
@@ -881,7 +881,7 @@ func TestHeadscale_GenerateGivenName(t *testing.T) {
 		{
 			name: "machine name with 64 chars",
 			h: &Headscale{
-				cfg: Config{
+				cfg: &Config{
 					OIDC: OIDCConfig{
 						StripEmaildomain: true,
 					},
@@ -896,7 +896,7 @@ func TestHeadscale_GenerateGivenName(t *testing.T) {
 		{
 			name: "machine name with 73 chars",
 			h: &Headscale{
-				cfg: Config{
+				cfg: &Config{
 					OIDC: OIDCConfig{
 						StripEmaildomain: true,
 					},

From 90f6be0c98584eb7b9be7662d43d0d4413db5191 Mon Sep 17 00:00:00 2001
From: Kristoffer Dalby <kradalby@kradalby.no>
Date: Sun, 5 Jun 2022 17:52:28 +0200
Subject: [PATCH 09/11] Rename one char var

---
 cmd/headscale/cli/server.go | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/cmd/headscale/cli/server.go b/cmd/headscale/cli/server.go
index c19580b..a1d1960 100644
--- a/cmd/headscale/cli/server.go
+++ b/cmd/headscale/cli/server.go
@@ -16,12 +16,12 @@ var serveCmd = &cobra.Command{
 		return nil
 	},
 	Run: func(cmd *cobra.Command, args []string) {
-		h, err := getHeadscaleApp()
+		app, err := getHeadscaleApp()
 		if err != nil {
 			log.Fatal().Caller().Err(err).Msg("Error initializing")
 		}
 
-		err = h.Serve()
+		err = app.Serve()
 		if err != nil {
 			log.Fatal().Caller().Err(err).Msg("Error starting server")
 		}

From a4e05d4db31b0e4cacd6b71cd7f7faea7ab28856 Mon Sep 17 00:00:00 2001
From: Darrell Kundel <darrell.kundel@gmail.com>
Date: Sat, 28 May 2022 23:18:34 +0800
Subject: [PATCH 10/11] fix typo for GGO->CGO

---
 Dockerfile        | 2 +-
 Dockerfile.alpine | 2 +-
 Dockerfile.debug  | 2 +-
 3 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/Dockerfile b/Dockerfile
index 8d53f6d..ac80779 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,7 +8,7 @@ RUN go mod download
 
 COPY . .
 
-RUN GGO_ENABLED=0 GOOS=linux go install -a ./cmd/headscale
+RUN CGO_ENABLED=0 GOOS=linux go install -a ./cmd/headscale
 RUN strip /go/bin/headscale
 RUN test -e /go/bin/headscale
 
diff --git a/Dockerfile.alpine b/Dockerfile.alpine
index 45fa171..24d4e6f 100644
--- a/Dockerfile.alpine
+++ b/Dockerfile.alpine
@@ -9,7 +9,7 @@ RUN go mod download
 
 COPY . .
 
-RUN GGO_ENABLED=0 GOOS=linux go install -a ./cmd/headscale
+RUN CGO_ENABLED=0 GOOS=linux go install -a ./cmd/headscale
 RUN strip /go/bin/headscale
 RUN test -e /go/bin/headscale
 
diff --git a/Dockerfile.debug b/Dockerfile.debug
index 91fe289..f053d72 100644
--- a/Dockerfile.debug
+++ b/Dockerfile.debug
@@ -8,7 +8,7 @@ RUN go mod download
 
 COPY . .
 
-RUN GGO_ENABLED=0 GOOS=linux go install -a ./cmd/headscale
+RUN CGO_ENABLED=0 GOOS=linux go install -a ./cmd/headscale
 RUN test -e /go/bin/headscale
 
 # Debug image

From 17d4968425699fb789f4c6519f861344b02f7144 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
 <41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 7 Jun 2022 06:16:00 +0000
Subject: [PATCH 11/11] docs(README): update contributors

---
 README.md | 32 ++++++++++++++++++++++++--------
 1 file changed, 24 insertions(+), 8 deletions(-)

diff --git a/README.md b/README.md
index 98738bc..a007120 100644
--- a/README.md
+++ b/README.md
@@ -415,6 +415,15 @@ make build
             <sub style="font-size:14px"><b> Carson Yang</b></sub>
         </a>
     </td>
+    <td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
+        <a href=https://github.com/kundel>
+            <img src=https://avatars.githubusercontent.com/u/10158899?v=4 width="100;"  style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=kundel/>
+            <br />
+            <sub style="font-size:14px"><b>kundel</b></sub>
+        </a>
+    </td>
+</tr>
+<tr>
     <td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
         <a href=https://github.com/fkr>
             <img src=https://avatars.githubusercontent.com/u/51063?v=4 width="100;"  style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Felix Kronlage-Dammers/>
@@ -422,8 +431,6 @@ make build
             <sub style="font-size:14px"><b>Felix Kronlage-Dammers</b></sub>
         </a>
     </td>
-</tr>
-<tr>
     <td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
         <a href=https://github.com/felixonmars>
             <img src=https://avatars.githubusercontent.com/u/1006477?v=4 width="100;"  style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Felix Yan/>
@@ -445,6 +452,13 @@ make build
             <sub style="font-size:14px"><b>Jamie Greeff</b></sub>
         </a>
     </td>
+    <td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
+        <a href=https://github.com/huskyii>
+            <img src=https://avatars.githubusercontent.com/u/5499746?v=4 width="100;"  style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Jiang Zhu/>
+            <br />
+            <sub style="font-size:14px"><b>Jiang Zhu</b></sub>
+        </a>
+    </td>
     <td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
         <a href=https://github.com/jimt>
             <img src=https://avatars.githubusercontent.com/u/180326?v=4 width="100;"  style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Jim Tittsler/>
@@ -452,6 +466,8 @@ make build
             <sub style="font-size:14px"><b>Jim Tittsler</b></sub>
         </a>
     </td>
+</tr>
+<tr>
     <td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
         <a href=https://github.com/piec>
             <img src=https://avatars.githubusercontent.com/u/781471?v=4 width="100;"  style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Pierre Carru/>
@@ -466,8 +482,6 @@ make build
             <sub style="font-size:14px"><b>rcursaru</b></sub>
         </a>
     </td>
-</tr>
-<tr>
     <td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
         <a href=https://github.com/renovate-bot>
             <img src=https://avatars.githubusercontent.com/u/25180681?v=4 width="100;"  style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=WhiteSource Renovate/>
@@ -496,6 +510,8 @@ make build
             <sub style="font-size:14px"><b>Tanner</b></sub>
         </a>
     </td>
+</tr>
+<tr>
     <td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
         <a href=https://github.com/Teteros>
             <img src=https://avatars.githubusercontent.com/u/5067989?v=4 width="100;"  style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Teteros/>
@@ -510,8 +526,6 @@ make build
             <sub style="font-size:14px"><b>The Gitter Badger</b></sub>
         </a>
     </td>
-</tr>
-<tr>
     <td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
         <a href=https://github.com/tianon>
             <img src=https://avatars.githubusercontent.com/u/161631?v=4 width="100;"  style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Tianon Gravi/>
@@ -540,6 +554,8 @@ make build
             <sub style="font-size:14px"><b>Zakhar Bessarab</b></sub>
         </a>
     </td>
+</tr>
+<tr>
     <td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
         <a href=https://github.com/Bpazy>
             <img src=https://avatars.githubusercontent.com/u/9838749?v=4 width="100;"  style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=ZiYuan/>
@@ -554,8 +570,6 @@ make build
             <sub style="font-size:14px"><b>derelm</b></sub>
         </a>
     </td>
-</tr>
-<tr>
     <td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
         <a href=https://github.com/nning>
             <img src=https://avatars.githubusercontent.com/u/557430?v=4 width="100;"  style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=henning mueller/>
@@ -584,6 +598,8 @@ make build
             <sub style="font-size:14px"><b>pernila</b></sub>
         </a>
     </td>
+</tr>
+<tr>
     <td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
         <a href=https://github.com/Wakeful-Cloud>
             <img src=https://avatars.githubusercontent.com/u/38930607?v=4 width="100;"  style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Wakeful-Cloud/>