diff --git a/CHANGELOG.md b/CHANGELOG.md index e991f139..dd25e317 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - This change disables the logs by default - Use [Prometheus]'s duration parser, supporting days (`d`), weeks (`w`) and years (`y`) [#598](https://github.com/juanfont/headscale/pull/598) - Add support for reloading ACLs with SIGHUP [#601](https://github.com/juanfont/headscale/pull/601) +- Add -c option to specify config file from command line [#285](https://github.com/juanfont/headscale/issues/285) [#612](https://github.com/juanfont/headscale/pull/601) ## 0.15.0 (2022-03-20) diff --git a/api_key.go b/api_key.go index 41c83857..c1bbce2d 100644 --- a/api_key.go +++ b/api_key.go @@ -13,7 +13,6 @@ import ( const ( apiPrefixLength = 7 apiKeyLength = 32 - apiKeyParts = 2 errAPIKeyFailedToParse = Error("Failed to parse ApiKey") ) @@ -115,9 +114,9 @@ func (h *Headscale) ExpireAPIKey(key *APIKey) error { } func (h *Headscale) ValidateAPIKey(keyStr string) (bool, error) { - prefix, hash, err := splitAPIKey(keyStr) - if err != nil { - return false, fmt.Errorf("failed to validate api key: %w", err) + prefix, hash, found := strings.Cut(keyStr, ".") + if !found { + return false, errAPIKeyFailedToParse } key, err := h.GetAPIKey(prefix) @@ -136,15 +135,6 @@ func (h *Headscale) ValidateAPIKey(keyStr string) (bool, error) { return true, nil } -func splitAPIKey(key string) (string, string, error) { - parts := strings.Split(key, ".") - if len(parts) != apiKeyParts { - return "", "", errAPIKeyFailedToParse - } - - return parts[0], parts[1], nil -} - func (key *APIKey) toProto() *v1.ApiKey { protoKey := v1.ApiKey{ Id: key.ID, diff --git a/cmd/headscale/cli/dump_config.go b/cmd/headscale/cli/dump_config.go new file mode 100644 index 00000000..374690ed --- /dev/null +++ b/cmd/headscale/cli/dump_config.go @@ -0,0 +1,28 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func init() { + rootCmd.AddCommand(dumpConfigCmd) +} + +var dumpConfigCmd = &cobra.Command{ + Use: "dumpConfig", + Short: "dump current config to /etc/headscale/config.dump.yaml, integration test only", + Hidden: true, + Args: func(cmd *cobra.Command, args []string) error { + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + err := viper.WriteConfigAs("/etc/headscale/config.dump.yaml") + if err != nil { + //nolint + fmt.Println("Failed to dump config") + } + }, +} diff --git a/cmd/headscale/cli/root.go b/cmd/headscale/cli/root.go index 99b15140..270ca555 100644 --- a/cmd/headscale/cli/root.go +++ b/cmd/headscale/cli/root.go @@ -3,17 +3,75 @@ package cli import ( "fmt" "os" + "runtime" + "github.com/juanfont/headscale" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" "github.com/spf13/cobra" + "github.com/tcnksm/go-latest" ) +var cfgFile string = "" + func init() { + cobra.OnInitialize(initConfig) + rootCmd.PersistentFlags(). + StringVarP(&cfgFile, "config", "c", "", "config file (default is /etc/headscale/config.yaml)") rootCmd.PersistentFlags(). StringP("output", "o", "", "Output format. Empty for human-readable, 'json', 'json-line' or 'yaml'") rootCmd.PersistentFlags(). Bool("force", false, "Disable prompts and forces the execution") } +func initConfig() { + if cfgFile != "" { + err := headscale.LoadConfig(cfgFile, true) + if err != nil { + log.Fatal().Caller().Err(err) + } + } else { + err := headscale.LoadConfig("", false) + if err != nil { + log.Fatal().Caller().Err(err) + } + } + + cfg, err := headscale.GetHeadscaleConfig() + if err != nil { + log.Fatal().Caller().Err(err) + } + + machineOutput := HasMachineOutputFlag() + + zerolog.SetGlobalLevel(cfg.LogLevel) + + // If the user has requested a "machine" readable format, + // then disable login so the output remains valid. + if machineOutput { + zerolog.SetGlobalLevel(zerolog.Disabled) + } + + if !cfg.DisableUpdateCheck && !machineOutput { + if (runtime.GOOS == "linux" || runtime.GOOS == "darwin") && + Version != "dev" { + githubTag := &latest.GithubTag{ + Owner: "juanfont", + Repository: "headscale", + } + res, err := latest.Check(githubTag, Version) + if err == nil && res.Outdated { + //nolint + fmt.Printf( + "An updated version of Headscale has been found (%s vs. your current %s). Check it out https://github.com/juanfont/headscale/releases\n", + res.Current, + Version, + ) + } + } + } +} + var rootCmd = &cobra.Command{ Use: "headscale", Short: "headscale - a Tailscale control server", diff --git a/cmd/headscale/headscale.go b/cmd/headscale/headscale.go index f5e28661..40772a93 100644 --- a/cmd/headscale/headscale.go +++ b/cmd/headscale/headscale.go @@ -1,17 +1,13 @@ package main import ( - "fmt" "os" - "runtime" "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" - "github.com/tcnksm/go-latest" ) func main() { @@ -43,39 +39,5 @@ func main() { NoColor: !colors, }) - cfg, err := headscale.GetHeadscaleConfig() - if err != nil { - log.Fatal().Caller().Err(err) - } - - machineOutput := cli.HasMachineOutputFlag() - - zerolog.SetGlobalLevel(cfg.LogLevel) - - // If the user has requested a "machine" readable format, - // then disable login so the output remains valid. - if machineOutput { - zerolog.SetGlobalLevel(zerolog.Disabled) - } - - if !cfg.DisableUpdateCheck && !machineOutput { - if (runtime.GOOS == "linux" || runtime.GOOS == "darwin") && - cli.Version != "dev" { - githubTag := &latest.GithubTag{ - Owner: "juanfont", - Repository: "headscale", - } - res, err := latest.Check(githubTag, cli.Version) - if err == nil && res.Outdated { - //nolint - fmt.Printf( - "An updated version of Headscale has been found (%s vs. your current %s). Check it out https://github.com/juanfont/headscale/releases\n", - res.Current, - cli.Version, - ) - } - } - } - cli.Execute() } diff --git a/cmd/headscale/headscale_test.go b/cmd/headscale/headscale_test.go index 9ca4a2c3..555cab32 100644 --- a/cmd/headscale/headscale_test.go +++ b/cmd/headscale/headscale_test.go @@ -27,6 +27,51 @@ func (s *Suite) SetUpSuite(c *check.C) { func (s *Suite) TearDownSuite(c *check.C) { } +func (*Suite) TestConfigFileLoading(c *check.C) { + tmpDir, err := ioutil.TempDir("", "headscale") + if err != nil { + c.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + path, err := os.Getwd() + if err != nil { + c.Fatal(err) + } + + cfgFile := filepath.Join(tmpDir, "config.yaml") + + // Symlink the example config file + err = os.Symlink( + filepath.Clean(path+"/../../config-example.yaml"), + cfgFile, + ) + if err != nil { + c.Fatal(err) + } + + // Load example config, it should load without validation errors + err = headscale.LoadConfig(cfgFile, true) + c.Assert(err, check.IsNil) + + // Test that config file was interpreted correctly + c.Assert(viper.GetString("server_url"), check.Equals, "http://127.0.0.1:8080") + c.Assert(viper.GetString("listen_addr"), check.Equals, "0.0.0.0:8080") + c.Assert(viper.GetString("metrics_listen_addr"), check.Equals, "127.0.0.1:9090") + c.Assert(viper.GetString("db_type"), check.Equals, "sqlite3") + c.Assert(viper.GetString("db_path"), check.Equals, "/var/lib/headscale/db.sqlite") + c.Assert(viper.GetString("tls_letsencrypt_hostname"), check.Equals, "") + c.Assert(viper.GetString("tls_letsencrypt_listen"), check.Equals, ":http") + 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( + headscale.GetFileMode("unix_socket_permission"), + check.Equals, + fs.FileMode(0o770), + ) + c.Assert(viper.GetBool("logtail.enabled"), check.Equals, false) +} + func (*Suite) TestConfigLoading(c *check.C) { tmpDir, err := ioutil.TempDir("", "headscale") if err != nil { @@ -49,7 +94,7 @@ func (*Suite) TestConfigLoading(c *check.C) { } // Load example config, it should load without validation errors - err = headscale.LoadConfig(tmpDir) + err = headscale.LoadConfig(tmpDir, false) c.Assert(err, check.IsNil) // Test that config file was interpreted correctly @@ -92,7 +137,7 @@ func (*Suite) TestDNSConfigLoading(c *check.C) { } // Load example config, it should load without validation errors - err = headscale.LoadConfig(tmpDir) + err = headscale.LoadConfig(tmpDir, false) c.Assert(err, check.IsNil) dnsConfig, baseDomain := headscale.GetDNSConfig() @@ -125,7 +170,7 @@ func (*Suite) TestTLSConfigValidation(c *check.C) { writeConfig(c, tmpDir, configYaml) // Check configuration validation errors (1) - err = headscale.LoadConfig(tmpDir) + err = headscale.LoadConfig(tmpDir, false) c.Assert(err, check.NotNil) // check.Matches can not handle multiline strings tmp := strings.ReplaceAll(err.Error(), "\n", "***") @@ -150,6 +195,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 = headscale.LoadConfig(tmpDir) + err = headscale.LoadConfig(tmpDir, false) c.Assert(err, check.IsNil) } diff --git a/config.go b/config.go index 909a48c4..576b35d0 100644 --- a/config.go +++ b/config.go @@ -114,15 +114,19 @@ 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(".") +func LoadConfig(path string, isFile bool) error { + if isFile { + viper.SetConfigFile(path) } else { - // For testing - viper.AddConfigPath(path) + viper.SetConfigName("config") + if path == "" { + viper.AddConfigPath("/etc/headscale/") + viper.AddConfigPath("$HOME/.headscale") + viper.AddConfigPath(".") + } else { + // For testing + viper.AddConfigPath(path) + } } viper.SetEnvPrefix("headscale") @@ -377,11 +381,6 @@ func GetDNSConfig() (*tailcfg.DNSConfig, string) { } func GetHeadscaleConfig() (*Config, error) { - err := LoadConfig("") - if err != nil { - return nil, err - } - dnsConfig, baseDomain := GetDNSConfig() derpConfig := GetDERPConfig() logConfig := GetLogTailConfig() diff --git a/integration_cli_test.go b/integration_cli_test.go index 075c38ac..8ac6ee4d 100644 --- a/integration_cli_test.go +++ b/integration_cli_test.go @@ -1721,3 +1721,43 @@ func (s *IntegrationCLITestSuite) TestNodeMoveCommand() { assert.Equal(s.T(), machine.Namespace, oldNamespace) } + +func (s *IntegrationCLITestSuite) TestLoadConfigFromCommand() { + // TODO: make sure defaultConfig is not same as altConfig + defaultConfig, err := os.ReadFile("integration_test/etc/config.dump.gold.yaml") + assert.Nil(s.T(), err) + altConfig, err := os.ReadFile("integration_test/etc/alt-config.dump.gold.yaml") + assert.Nil(s.T(), err) + + _, err = ExecuteCommand( + &s.headscale, + []string{ + "headscale", + "dumpConfig", + }, + []string{}, + ) + assert.Nil(s.T(), err) + + defaultDumpConfig, err := os.ReadFile("integration_test/etc/config.dump.yaml") + assert.Nil(s.T(), err) + + assert.YAMLEq(s.T(), string(defaultConfig), string(defaultDumpConfig)) + + _, err = ExecuteCommand( + &s.headscale, + []string{ + "headscale", + "-c", + "/etc/headscale/alt-config.yaml", + "dumpConfig", + }, + []string{}, + ) + assert.Nil(s.T(), err) + + altDumpConfig, err := os.ReadFile("integration_test/etc/config.dump.yaml") + assert.Nil(s.T(), err) + + assert.YAMLEq(s.T(), string(altConfig), string(altDumpConfig)) +} diff --git a/integration_test/etc/alt-config.dump.gold.yaml b/integration_test/etc/alt-config.dump.gold.yaml new file mode 100644 index 00000000..5cc025cc --- /dev/null +++ b/integration_test/etc/alt-config.dump.gold.yaml @@ -0,0 +1,46 @@ +acl_policy_path: "" +cli: + insecure: false + timeout: 5s +db_path: /tmp/integration_test_db.sqlite3 +db_type: sqlite3 +derp: + auto_update_enabled: false + server: + enabled: false + stun: + enabled: true + update_frequency: 1m + urls: + - https://controlplane.tailscale.com/derpmap/default +dns_config: + base_domain: headscale.net + domains: [] + magic_dns: true + nameservers: + - 1.1.1.1 +ephemeral_node_inactivity_timeout: 30m +grpc_allow_insecure: false +grpc_listen_addr: :50443 +ip_prefixes: + - fd7a:115c:a1e0::/48 + - 100.64.0.0/10 +listen_addr: 0.0.0.0:18080 +log_level: disabled +logtail: + enabled: false +metrics_listen_addr: 127.0.0.1:19090 +oidc: + scope: + - openid + - profile + - email + strip_email_domain: true +private_key_path: private.key +server_url: http://headscale:18080 +tls_client_auth_mode: relaxed +tls_letsencrypt_cache_dir: /var/www/.cache +tls_letsencrypt_challenge_type: HTTP-01 +unix_socket: /var/run/headscale.sock +unix_socket_permission: "0o770" + diff --git a/integration_test/etc/alt-config.yaml b/integration_test/etc/alt-config.yaml new file mode 100644 index 00000000..8de9a828 --- /dev/null +++ b/integration_test/etc/alt-config.yaml @@ -0,0 +1,24 @@ +log_level: trace +acl_policy_path: "" +db_type: sqlite3 +ephemeral_node_inactivity_timeout: 30m +ip_prefixes: + - fd7a:115c:a1e0::/48 + - 100.64.0.0/10 +dns_config: + base_domain: headscale.net + magic_dns: true + domains: [] + nameservers: + - 1.1.1.1 +db_path: /tmp/integration_test_db.sqlite3 +private_key_path: private.key +listen_addr: 0.0.0.0:18080 +metrics_listen_addr: 127.0.0.1:19090 +server_url: http://headscale:18080 + +derp: + urls: + - https://controlplane.tailscale.com/derpmap/default + auto_update_enabled: false + update_frequency: 1m diff --git a/integration_test/etc/config.dump.gold.yaml b/integration_test/etc/config.dump.gold.yaml new file mode 100644 index 00000000..0df651ed --- /dev/null +++ b/integration_test/etc/config.dump.gold.yaml @@ -0,0 +1,46 @@ +acl_policy_path: "" +cli: + insecure: false + timeout: 5s +db_path: /tmp/integration_test_db.sqlite3 +db_type: sqlite3 +derp: + auto_update_enabled: false + server: + enabled: false + stun: + enabled: true + update_frequency: 1m + urls: + - https://controlplane.tailscale.com/derpmap/default +dns_config: + base_domain: headscale.net + domains: [] + magic_dns: true + nameservers: + - 1.1.1.1 +ephemeral_node_inactivity_timeout: 30m +grpc_allow_insecure: false +grpc_listen_addr: :50443 +ip_prefixes: + - fd7a:115c:a1e0::/48 + - 100.64.0.0/10 +listen_addr: 0.0.0.0:8080 +log_level: disabled +logtail: + enabled: false +metrics_listen_addr: 127.0.0.1:9090 +oidc: + scope: + - openid + - profile + - email + strip_email_domain: true +private_key_path: private.key +server_url: http://headscale:8080 +tls_client_auth_mode: relaxed +tls_letsencrypt_cache_dir: /var/www/.cache +tls_letsencrypt_challenge_type: HTTP-01 +unix_socket: /var/run/headscale.sock +unix_socket_permission: "0o770" +