mirror of
https://codeberg.org/cunicu/cunicu.git
synced 2025-09-26 21:01:14 +08:00
556 lines
15 KiB
Go
556 lines
15 KiB
Go
// SPDX-FileCopyrightText: 2023-2025 Steffen Vogel <post@steffenvogel.de>
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package config_test
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/knadh/koanf/parsers/yaml"
|
|
"github.com/knadh/koanf/providers/rawbytes"
|
|
"github.com/onsi/gomega/ghttp"
|
|
"github.com/pion/ice/v4"
|
|
"github.com/spf13/pflag"
|
|
|
|
"cunicu.li/cunicu/pkg/config"
|
|
"cunicu.li/cunicu/pkg/crypto"
|
|
"cunicu.li/cunicu/test"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
func TestSuite(t *testing.T) {
|
|
test.SetupLogging()
|
|
RegisterFailHandler(Fail)
|
|
RunSpecs(t, "Config Suite")
|
|
}
|
|
|
|
var _ = Context("config", func() {
|
|
mkTempFile := func(contents string) string {
|
|
dir := GinkgoT().TempDir()
|
|
fn := filepath.Join(dir, "cunicu.yaml")
|
|
|
|
err := os.WriteFile(fn, []byte(contents), 0o600)
|
|
Expect(err).To(Succeed())
|
|
|
|
return fn
|
|
}
|
|
|
|
Describe("parse command line arguments", func() {
|
|
It("can parse a boolean argument like --wg-userspace", func() {
|
|
cfg, err := parseArgs("--wg-userspace")
|
|
Expect(err).To(Succeed())
|
|
|
|
Expect(cfg.DefaultInterfaceSettings.UserSpace).To(BeTrue())
|
|
})
|
|
|
|
It("can parse multiple backends", func() {
|
|
cfg, err := parseArgs("--backend", "grpc", "--backend", "inprocess")
|
|
|
|
Expect(err).To(Succeed())
|
|
Expect(cfg.Backends).To(HaveLen(2))
|
|
Expect(cfg.Backends[0].Scheme).To(Equal("grpc"))
|
|
Expect(cfg.Backends[1].Scheme).To(Equal("inprocess"))
|
|
})
|
|
|
|
It("parse an interface list", func() {
|
|
cfg, err := parseArgs("wg0", "wg1")
|
|
Expect(err).To(Succeed())
|
|
|
|
Expect(cfg.InterfaceFilter("wg0")).To(BeTrue())
|
|
Expect(cfg.InterfaceFilter("wg1")).To(BeTrue())
|
|
Expect(cfg.InterfaceFilter("wg2")).To(BeFalse())
|
|
})
|
|
|
|
It("parse an interface list with patterns", func() {
|
|
cfg, err := parseArgs("wg0", "wg1", "wg-work-*")
|
|
Expect(err).To(Succeed())
|
|
|
|
Expect(cfg.InterfaceFilter("wg0")).To(BeTrue())
|
|
Expect(cfg.InterfaceFilter("wg1")).To(BeTrue())
|
|
Expect(cfg.InterfaceFilter("wg-work-0")).To(BeTrue())
|
|
Expect(cfg.InterfaceFilter("wg2")).To(BeFalse())
|
|
Expect(cfg.InterfaceSettings("wg2")).To(BeNil())
|
|
})
|
|
|
|
It("parses arbitrary options", func() {
|
|
cfg, err := parseArgs(
|
|
"-o", "watch_interval=1m",
|
|
"-o", "log.rules=info:watcher",
|
|
"-o", "rpc.socket=/some/other/cunicu.sock",
|
|
"-o", "log.rules=debug:epdisc.*",
|
|
"-o", "log.rules=error",
|
|
)
|
|
Expect(err).To(Succeed())
|
|
|
|
Expect(cfg.WatchInterval).To(Equal(1 * time.Minute))
|
|
Expect(cfg.RPC.Socket).To(Equal("/some/other/cunicu.sock"))
|
|
Expect(cfg.Log.Rules).To(HaveExactElements("info:watcher", "debug:epdisc.*", "error"))
|
|
})
|
|
|
|
It("fails on invalid arguments", func() {
|
|
_, err := parseArgs("--wrong")
|
|
|
|
Expect(err).To(MatchError("failed to parse command line flags: unknown flag: --wrong"))
|
|
})
|
|
|
|
It("fails on invalid arguments values", func() {
|
|
_, err := parseArgs("--backend", ":_")
|
|
|
|
Expect(err).To(MatchError(HaveSuffix("missing protocol scheme")))
|
|
})
|
|
|
|
Describe("parse configuration files", func() {
|
|
Context("with a local file", func() {
|
|
var cfgFile string
|
|
|
|
BeforeEach(func() {
|
|
cfgFile = mkTempFile("watch_interval: 1337s\n")
|
|
})
|
|
|
|
Context("file with explicit path", func() {
|
|
It("can read a single valid local file", func() {
|
|
cfg, err := parseArgs("--config", cfgFile)
|
|
|
|
Expect(err).To(Succeed())
|
|
Expect(cfg.WatchInterval).To(Equal(1337 * time.Second))
|
|
})
|
|
|
|
Specify("that command line arguments take precedence over settings provided by configuration files", func() {
|
|
cfg, err := parseArgs("--config", cfgFile, "--watch-interval", "1m")
|
|
|
|
Expect(err).To(Succeed())
|
|
Expect(cfg.WatchInterval).To(Equal(time.Minute))
|
|
})
|
|
})
|
|
|
|
Context("in search path", func() {
|
|
BeforeEach(func() {
|
|
// Move config file into XDG config directory
|
|
configDir := filepath.Dir(cfgFile)
|
|
|
|
os.Setenv("CUNICU_CONFIG_DIR", configDir)
|
|
})
|
|
|
|
AfterEach(func() {
|
|
os.Unsetenv("CUNICU_CONFIG_DIR")
|
|
})
|
|
|
|
It("can read a single valid local file", func() {
|
|
cfg, err := parseArgs()
|
|
|
|
Expect(err).To(Succeed())
|
|
Expect(cfg.WatchInterval).To(Equal(1337 * time.Second))
|
|
})
|
|
})
|
|
})
|
|
|
|
Context("with a remote URL", func() {
|
|
var server *ghttp.Server
|
|
|
|
BeforeEach(func() {
|
|
server = ghttp.NewServer()
|
|
server.AppendHandlers(
|
|
ghttp.VerifyRequest("HEAD", "/cunicu.yaml"),
|
|
ghttp.CombineHandlers(
|
|
ghttp.VerifyRequest("GET", "/cunicu.yaml"),
|
|
ghttp.RespondWith(http.StatusOK,
|
|
"watch_interval: 1337s\n",
|
|
http.Header{
|
|
"Content-type": []string{"text/yaml"},
|
|
}),
|
|
),
|
|
)
|
|
})
|
|
|
|
It("can fetch a valid remote configuration file", func() {
|
|
cfg, err := parseArgs("--config", server.URL()+"/cunicu.yaml")
|
|
|
|
Expect(err).To(Succeed())
|
|
Expect(cfg.WatchInterval).To(BeNumerically("==", 1337*time.Second))
|
|
})
|
|
|
|
AfterEach(func() {
|
|
// shut down the server between tests
|
|
server.Close()
|
|
})
|
|
|
|
It("fails on loading an non-existent remote file", func() {
|
|
_, err := parseArgs("--config", "http://example.com/doesnotexist.yaml")
|
|
|
|
Expect(err).To(HaveOccurred())
|
|
})
|
|
})
|
|
|
|
Describe("non-existing files", func() {
|
|
It("fails on loading an non-existing local file paths", func() {
|
|
var errPattern string
|
|
if runtime.GOOS == "windows" {
|
|
errPattern = `The system cannot find the (path|file) specified.$`
|
|
} else {
|
|
errPattern = `no such file or directory$`
|
|
}
|
|
|
|
_, err := parseArgs("--config", "/does-not-exist.yaml")
|
|
|
|
Expect(err).To(MatchError(MatchRegexp(errPattern)))
|
|
})
|
|
|
|
It("fails on loading an non-existing remote file paths", func() {
|
|
_, err := parseArgs("--config", "https://domain.invalid/config.yaml")
|
|
|
|
Expect(err).To(MatchError(MatchRegexp(`^failed to load config: failed to fetch https://domain\.invalid/config\.yaml`)))
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("use environment variables", func() {
|
|
AfterEach(func() {
|
|
os.Unsetenv("CUNICU_ICE_CANDIDATE_TYPES")
|
|
os.Unsetenv("CUNICU_WATCH_INTERVAL")
|
|
})
|
|
|
|
It("accepts settings via environment variables", func() {
|
|
os.Setenv("CUNICU_ICE_CANDIDATE_TYPES", "srflx")
|
|
|
|
cfg, err := parseArgs()
|
|
Expect(err).To(Succeed())
|
|
|
|
icfg := cfg.DefaultInterfaceSettings
|
|
|
|
Expect(icfg.ICE.CandidateTypes).To(ConsistOf(
|
|
ice.CandidateTypeServerReflexive,
|
|
))
|
|
})
|
|
|
|
It("accepts multiple settings via environment variables", func() {
|
|
os.Setenv("CUNICU_ICE_CANDIDATE_TYPES", "srflx,relay")
|
|
|
|
cfg, err := parseArgs()
|
|
Expect(err).To(Succeed())
|
|
|
|
icfg := cfg.DefaultInterfaceSettings
|
|
|
|
Expect(icfg.ICE.CandidateTypes).To(ConsistOf(
|
|
ice.CandidateTypeServerReflexive,
|
|
ice.CandidateTypeRelay,
|
|
))
|
|
})
|
|
|
|
It("environment variables are overwritten by command line arguments", func() {
|
|
os.Setenv("CUNICU_WATCH_INTERVAL", "10s")
|
|
|
|
cfg, err := parseArgs("--watch-interval", "5s")
|
|
Expect(err).To(Succeed())
|
|
Expect(cfg.WatchInterval).To(Equal(5 * time.Second))
|
|
})
|
|
})
|
|
|
|
Describe("use proper default settings", func() {
|
|
var err error
|
|
var cfg *config.Config
|
|
var icfg *config.InterfaceSettings
|
|
|
|
BeforeEach(func() {
|
|
cfg, err = parseArgs()
|
|
Expect(err).To(Succeed())
|
|
|
|
icfg = &cfg.DefaultInterfaceSettings
|
|
})
|
|
|
|
It("should use the standard cunicu signaling backend", func() {
|
|
Expect(cfg.Backends).To(HaveLen(1))
|
|
Expect(cfg.Backends[0].String()).To(Equal("grpc://signal.cunicu.li:443"))
|
|
})
|
|
|
|
It("should have a default STUN URL", func() {
|
|
Expect(icfg.ICE.URLs).To(HaveLen(1))
|
|
Expect(icfg.ICE.URLs[0].String()).To(Equal("grpc://relay.cunicu.li:443"))
|
|
})
|
|
})
|
|
|
|
Describe("dump", func() {
|
|
var cfg1, cfg2 *config.Config
|
|
var icfg1, icfg2 *config.InterfaceSettings
|
|
|
|
BeforeEach(func() {
|
|
var err error
|
|
|
|
args := []string{"--backend", "grpc://server1,grpc://server2", "--watch-interval", "10s", "wg0"}
|
|
|
|
cfg1, err = parseArgs(args...)
|
|
Expect(err).To(Succeed())
|
|
|
|
buf := &bytes.Buffer{}
|
|
|
|
Expect(cfg1.Marshal(buf)).To(Succeed())
|
|
|
|
cfg2, err = parseArgs(args...)
|
|
Expect(err).To(Succeed())
|
|
|
|
err = cfg2.Koanf.Load(rawbytes.Provider(buf.Bytes()), yaml.Parser())
|
|
Expect(err).To(Succeed())
|
|
|
|
err = cfg2.Init(nil)
|
|
Expect(err).To(Succeed())
|
|
|
|
icfg1 = &cfg1.DefaultInterfaceSettings
|
|
icfg2 = &cfg2.DefaultInterfaceSettings
|
|
})
|
|
|
|
It("have equal WireGuard interface lists", func() {
|
|
Expect(cfg1.Interfaces).To(Equal(cfg2.Interfaces))
|
|
})
|
|
|
|
It("have equal ICE network types", func() {
|
|
Expect(icfg1.ICE.NetworkTypes).To(Equal(icfg2.ICE.NetworkTypes))
|
|
})
|
|
|
|
It("have equal ICE URLs", func() {
|
|
Expect(icfg1.ICE.URLs).To(Equal(icfg2.ICE.URLs))
|
|
})
|
|
})
|
|
|
|
Context("allow insecure configs", func() {
|
|
BeforeEach(func() {
|
|
os.Setenv("CUNICU_CONFIG_ALLOW_INSECURE", "true")
|
|
})
|
|
|
|
AfterEach(func() {
|
|
os.Unsetenv("CUNICU_CONFIG_ALLOW_INSECURE")
|
|
})
|
|
|
|
It("can parse the example config file", func() {
|
|
cfg, err := parseArgs("--config", "../../etc/cunicu.advanced.yaml")
|
|
|
|
Expect(err).To(Succeed())
|
|
|
|
Expect(cfg.Files).To(Equal([]string{"../../etc/cunicu.advanced.yaml"}))
|
|
Expect(cfg.InterfaceOrder).To(Equal([]string{"wg0", "wg1", "wg2", "wg-work-*", "wg-work-external-*"}))
|
|
Expect(cfg.InterfaceSettings("wg-work-laptop").Community).To(BeEquivalentTo(crypto.GenerateKeyFromPassword("mysecret-pass")))
|
|
Expect(cfg.DefaultInterfaceSettings.Hooks).To(HaveLen(2))
|
|
|
|
h := cfg.DefaultInterfaceSettings.Hooks[0]
|
|
hh, ok := h.(*config.ExecHookSetting)
|
|
Expect(ok).To(BeTrue(), "Found invalid hook %+#v", hh)
|
|
})
|
|
})
|
|
|
|
It("throws an error on an invalid config file path", func() {
|
|
_, err := parseArgs("--config", "_:")
|
|
Expect(err).To(MatchError(HavePrefix("ignoring config file with invalid name")))
|
|
})
|
|
|
|
It("throws an error on an invalid config file URL schema", func() {
|
|
_, err := parseArgs("--config", "smb://is-not-supported")
|
|
Expect(err).To(MatchError("unsupported scheme 'smb' for config file"))
|
|
})
|
|
|
|
Describe("runtime", func() {
|
|
BeforeEach(func() {
|
|
config.RuntimeConfigFile = filepath.Join(GinkgoT().TempDir(), "cunicu.runtime.yaml")
|
|
})
|
|
|
|
It("can update multiple settings", func() {
|
|
cfg, err := parseArgs()
|
|
Expect(err).To(Succeed())
|
|
|
|
_, err = cfg.Update(map[string]any{
|
|
"watch_interval": 100 * time.Second,
|
|
"listen_port_range.min": 100,
|
|
"listen_port_range.max": 200,
|
|
})
|
|
Expect(err).To(Succeed())
|
|
|
|
Expect(cfg.WatchInterval).To(Equal(100 * time.Second))
|
|
Expect(cfg.DefaultInterfaceSettings.ListenPortRange.Min).To(Equal(100))
|
|
Expect(cfg.DefaultInterfaceSettings.ListenPortRange.Max).To(Equal(200))
|
|
})
|
|
|
|
It("fails to update multiple settings which are incorrect", func() {
|
|
cfg, err := parseArgs()
|
|
Expect(err).To(Succeed())
|
|
|
|
orig := cfg.DefaultInterfaceSettings.ListenPortRange
|
|
|
|
_, err = cfg.Update(map[string]any{
|
|
"listen_port_range.min": 200,
|
|
"listen_port_range.max": 100,
|
|
})
|
|
Expect(err).To(MatchError(
|
|
MatchRegexp(`invalid settings: WireGuard minimal listen port \(\d+\) must be smaller or equal than maximal port \(\d+\)`),
|
|
))
|
|
|
|
Expect(cfg.DefaultInterfaceSettings.ListenPortRange).To(Equal(orig), "Failed update has changed settings")
|
|
})
|
|
|
|
It("can save runtime settings", func() {
|
|
cfg, err := parseArgs()
|
|
Expect(err).To(Succeed())
|
|
|
|
_, err = cfg.Update(map[string]any{
|
|
"watch_interval": "100s",
|
|
})
|
|
Expect(err).To(Succeed())
|
|
|
|
buf := &bytes.Buffer{}
|
|
Expect(cfg.Runtime.Marshal(buf)).To(Succeed())
|
|
Expect(buf.Bytes()).To(MatchYAML("watch_interval: 100s"))
|
|
})
|
|
})
|
|
|
|
Describe("reload", func() {
|
|
})
|
|
|
|
Describe("interface overwrites", func() {
|
|
It("should accept all interfaces", func() {
|
|
cfg, err := parseArgs()
|
|
Expect(err).To(Succeed())
|
|
|
|
Expect(cfg.InterfaceOrder).To(Equal([]string{"*"}))
|
|
|
|
icfg := cfg.InterfaceSettings("wg12345")
|
|
Expect(icfg).NotTo(BeNil())
|
|
|
|
Expect(*icfg).To(Equal(cfg.DefaultInterfaceSettings))
|
|
})
|
|
|
|
It("single interface as argument", func() {
|
|
cfg, err := parseArgs("wg0")
|
|
Expect(err).To(Succeed())
|
|
|
|
Expect(cfg.InterfaceOrder).To(Equal([]string{"wg0"}))
|
|
|
|
Expect(cfg.InterfaceFilter("wg0")).To(BeTrue())
|
|
Expect(cfg.InterfaceFilter("wg1")).To(BeFalse())
|
|
|
|
icfg := cfg.InterfaceSettings("wg0")
|
|
Expect(icfg).NotTo(BeNil())
|
|
|
|
Expect(*icfg).To(Equal(cfg.DefaultInterfaceSettings))
|
|
})
|
|
|
|
It("single interface pattern as argument", func() {
|
|
cfg, err := parseArgs("wg*")
|
|
Expect(err).To(Succeed())
|
|
|
|
Expect(cfg.InterfaceOrder).To(Equal([]string{"wg*"}))
|
|
|
|
Expect(cfg.InterfaceFilter("wg0")).To(BeTrue())
|
|
Expect(cfg.InterfaceFilter("eth0")).To(BeFalse())
|
|
|
|
icfg := cfg.InterfaceSettings("wg0")
|
|
Expect(icfg).NotTo(BeNil())
|
|
|
|
Expect(*icfg).To(Equal(cfg.DefaultInterfaceSettings))
|
|
})
|
|
|
|
It("single interface", func() {
|
|
cfgFile := mkTempFile(`---
|
|
ice:
|
|
restart_timeout: 5s
|
|
disconnected_timeout: 22s
|
|
|
|
interfaces:
|
|
wg0:
|
|
ice:
|
|
restart_timeout: 10s
|
|
`)
|
|
|
|
cfg, err := parseArgs("--config", cfgFile)
|
|
Expect(err).To(Succeed())
|
|
|
|
Expect(cfg.InterfaceFilter("wg0")).To(BeTrue())
|
|
Expect(cfg.InterfaceFilter("wg1")).To(BeFalse())
|
|
|
|
Expect(cfg.InterfaceOrder).To(Equal([]string{"wg0"}))
|
|
|
|
icfg := cfg.InterfaceSettings("wg0")
|
|
Expect(icfg).NotTo(BeNil())
|
|
|
|
Expect(icfg.ICE.RestartTimeout).To(Equal(10 * time.Second))
|
|
Expect(icfg.ICE.DisconnectedTimeout).To(Equal(22 * time.Second))
|
|
|
|
Expect(cfg.DefaultInterfaceSettings.ICE.RestartTimeout).To(Equal(5 * time.Second))
|
|
})
|
|
|
|
It("two interface names and two patterns", func() {
|
|
cfgFile := mkTempFile(`---
|
|
ice:
|
|
keepalive_interval: 7s
|
|
|
|
interfaces:
|
|
wg0:
|
|
ice:
|
|
restart_timeout: 10s
|
|
|
|
wg-work-*:
|
|
ice:
|
|
keepalive_interval: 123s
|
|
restart_timeout: 20s
|
|
|
|
wg-*:
|
|
ice:
|
|
restart_timeout: 30s
|
|
`)
|
|
|
|
cfg, err := parseArgs("--config", cfgFile, "wg1")
|
|
Expect(err).To(Succeed())
|
|
|
|
Expect(cfg.InterfaceOrder).To(Equal([]string{"wg1", "wg0", "wg-work-*", "wg-*"}))
|
|
|
|
Expect(cfg.InterfaceFilter("wg0")).To(BeTrue())
|
|
Expect(cfg.InterfaceFilter("wg1")).To(BeTrue())
|
|
Expect(cfg.InterfaceFilter("wg-work-0")).To(BeTrue())
|
|
Expect(cfg.InterfaceFilter("wg-0")).To(BeTrue())
|
|
Expect(cfg.InterfaceFilter("eth0")).To(BeFalse())
|
|
|
|
icfg1 := cfg.InterfaceSettings("wg-work-seattle")
|
|
Expect(icfg1).NotTo(BeNil())
|
|
|
|
Expect(icfg1.ICE.RestartTimeout).To(Equal(30 * time.Second))
|
|
Expect(icfg1.ICE.KeepaliveInterval).To(Equal(123 * time.Second))
|
|
|
|
icfg2 := cfg.InterfaceSettings("wg-mobile")
|
|
Expect(icfg2).NotTo(BeNil())
|
|
|
|
Expect(icfg2.ICE.RestartTimeout).To(Equal(30 * time.Second))
|
|
Expect(icfg2.ICE.KeepaliveInterval).To(Equal(7 * time.Second))
|
|
|
|
icfg3 := cfg.InterfaceSettings("wg0")
|
|
Expect(icfg3).NotTo(BeNil())
|
|
|
|
Expect(icfg3.ICE.RestartTimeout).To(Equal(10 * time.Second))
|
|
Expect(icfg3.ICE.KeepaliveInterval).To(Equal(7 * time.Second))
|
|
|
|
icfg4 := cfg.InterfaceSettings("wg1")
|
|
Expect(icfg4).NotTo(BeNil())
|
|
|
|
Expect(*icfg4).To(Equal(cfg.DefaultInterfaceSettings))
|
|
})
|
|
})
|
|
})
|
|
|
|
// parseArgs creates a new configuration instance and loads all configuration
|
|
//
|
|
// Only used for testing.
|
|
func parseArgs(args ...string) (*config.Config, error) {
|
|
flags := pflag.NewFlagSet("", pflag.ContinueOnError)
|
|
cfg := config.New(flags)
|
|
|
|
if err := flags.Parse(args); err != nil {
|
|
return nil, fmt.Errorf("failed to parse command line flags: %w", err)
|
|
}
|
|
|
|
return cfg, cfg.Init(flags.Args())
|
|
}
|