Files
brianmcgee 3040e8c758 pkg: implement signed namespaces
It's a variation of the ADR described here: #310.

Namespaces allow peers to publish files under paths like ``<namespace>/<pubkey>`
if they hold a certificate signed by the network key. This enables dynamic,
per-peer file publishing without requiring each file to be pre-configured.

Also cleans up some testing patterns.
2026-03-30 15:42:43 +01:00

1344 lines
32 KiB
Go

package config_test
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.clan.lol/clan/data-mesher/pkg/config"
"git.clan.lol/clan/data-mesher/pkg/crypto"
"git.clan.lol/clan/data-mesher/test"
"github.com/libp2p/go-libp2p/core/peer"
"github.com/stretchr/testify/require"
)
// clusterKeyPaths holds all file paths needed for a valid cluster TOML config.
type clusterKeyPaths struct {
IdentityKeyPath string
CertPath string
BootstrapPeerAddr string
}
// generateClusterKeys generates an identity key, a certificate signed by the
// given network key, and a bootstrap peer for use in TOML configs.
func generateClusterKeys(t *testing.T, networkKey *crypto.PrivateKey) clusterKeyPaths {
t.Helper()
as := require.New(t)
key, keyPath := test.GenerateIdentityKey(t)
now := time.Now()
cert, err := crypto.SignCertificate(
"test-machine", key.Public, networkKey, now.Add(-time.Minute), now.Add(365*24*time.Hour),
)
as.NoError(err)
dir := t.TempDir()
certPath := filepath.Join(dir, "identity.cert")
as.NoError(crypto.WriteCertificate(cert, certPath))
_, bpAddr := test.GenerateBootstrapPeer(t)
return clusterKeyPaths{
IdentityKeyPath: keyPath,
CertPath: certPath,
BootstrapPeerAddr: bpAddr,
}
}
func validHTTP() config.HTTP {
return config.HTTP{
Port: 7331,
Interfaces: []string{"lo"},
}
}
func validCluster(t *testing.T, networkKey *crypto.PrivateKey) config.Cluster {
t.Helper()
as := require.New(t)
key, _ := test.GenerateIdentityKey(t)
bp, _ := test.GenerateBootstrapPeer(t)
now := time.Now()
cert, err := crypto.SignCertificate(
"test-machine", key.Public, networkKey, now.Add(-time.Minute), now.Add(365*24*time.Hour),
)
as.NoError(err)
return config.Cluster{
BootstrapPeers: []peer.AddrInfo{bp},
Port: 7946,
PushPullInterval: 30 * time.Second,
IdentityKey: key,
IdentityCert: cert,
}
}
func validNetwork(t *testing.T, id *crypto.PublicKey) config.Network {
t.Helper()
return config.Network{
ID: id,
Files: validFiles(t),
}
}
func validFiles(t *testing.T) config.Files {
t.Helper()
return config.Files{
"test_file": nil,
}
}
func TestConfig_Validate(t *testing.T) {
t.Parallel()
keys := test.GenerateKeys(t, 2)
t.Run("valid config", func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
StateDirectory: "/var/lib/data-mesher",
HTTP: validHTTP(),
Cluster: validCluster(t, keys[0]),
Network: validNetwork(t, keys[0].Public),
}
err := cfg.Validate()
require.NoError(t, err)
})
t.Run("valid config with explicit file signer", func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
StateDirectory: "/var/lib/data-mesher",
HTTP: validHTTP(),
Cluster: validCluster(t, keys[0]),
Network: config.Network{
ID: keys[0].Public,
Files: config.Files{
"test_file": {keys[1].Public},
},
},
}
err := cfg.Validate()
require.NoError(t, err)
})
t.Run("missing state directory", func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
StateDirectory: "",
HTTP: validHTTP(),
Cluster: validCluster(t, keys[0]),
Network: validNetwork(t, keys[0].Public),
}
err := cfg.Validate()
require.ErrorIs(t, err, config.ErrInvalidConfig)
require.ErrorContains(t, err, "state directory")
})
t.Run("missing files", func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
StateDirectory: "/var/lib/data-mesher",
HTTP: validHTTP(),
Cluster: validCluster(t, keys[0]),
Network: config.Network{
ID: keys[0].Public,
},
}
err := cfg.Validate()
require.ErrorIs(t, err, config.ErrInvalidConfig)
require.ErrorContains(t, err, "at least one file should be configured")
})
t.Run("nil files", func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
StateDirectory: "/var/lib/data-mesher",
HTTP: validHTTP(),
Cluster: validCluster(t, keys[0]),
Network: config.Network{
ID: keys[0].Public,
},
}
err := cfg.Validate()
require.ErrorIs(t, err, config.ErrInvalidConfig)
require.ErrorContains(t, err, "at least one file should be configured")
})
t.Run("max_file_size defaults when not set", func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
StateDirectory: "/var/lib/data-mesher",
HTTP: validHTTP(),
Cluster: validCluster(t, keys[0]),
Network: validNetwork(t, keys[0].Public),
MaxFileSize: 0, // not set
}
err := cfg.Validate()
require.NoError(t, err)
require.Equal(t, int64(config.DefaultMaxFileSize), cfg.MaxFileSize)
})
t.Run("max_file_size custom value", func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
StateDirectory: "/var/lib/data-mesher",
HTTP: validHTTP(),
Cluster: validCluster(t, keys[0]),
Network: validNetwork(t, keys[0].Public),
MaxFileSize: 1024 * 1024, // 1 MiB
}
err := cfg.Validate()
require.NoError(t, err)
require.Equal(t, int64(1024*1024), cfg.MaxFileSize)
})
t.Run("max_file_size negative rejected", func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
StateDirectory: "/var/lib/data-mesher",
HTTP: validHTTP(),
Cluster: validCluster(t, keys[0]),
Network: validNetwork(t, keys[0].Public),
MaxFileSize: -1,
}
err := cfg.Validate()
require.ErrorIs(t, err, config.ErrInvalidConfig)
require.ErrorContains(t, err, "max_file_size must be positive")
})
t.Run("valid file names", func(t *testing.T) {
t.Parallel()
validNames := []string{
"test",
"test_file",
"test123",
"123",
"a",
"test_file_123",
"test/file",
"foo/bar",
"nested/path/name",
strings.Repeat("a", 255), // max length single segment
strings.Repeat("a", 255) + "/" + strings.Repeat("b", 255), // max length multi-segment
}
for _, name := range validNames {
cfg := &config.Config{
StateDirectory: "/var/lib/data-mesher",
HTTP: validHTTP(),
Cluster: validCluster(t, keys[0]),
Network: config.Network{
ID: keys[0].Public,
Files: config.Files{
name: nil,
},
},
}
err := cfg.Validate()
require.NoError(t, err, "expected %q to be a valid file name", name)
}
})
t.Run("invalid file names", func(t *testing.T) {
t.Parallel()
invalidNames := []string{
"", // empty
"Test", // uppercase
"TEST", // all uppercase
"test-file", // hyphen
"test.txt", // dot
"test file", // space
"test@file", // special char
"dns:foo", // colon not allowed
"/leading_slash", // leading slash not allowed
"trailing/", // trailing slash not allowed
"double//slash", // empty segments not allowed
strings.Repeat("a", 256), // segment too long
}
for _, name := range invalidNames {
cfg := &config.Config{
StateDirectory: "/var/lib/data-mesher",
HTTP: validHTTP(),
Cluster: validCluster(t, keys[0]),
Network: config.Network{
ID: keys[0].Public,
Files: config.Files{
name: nil,
},
},
}
err := cfg.Validate()
require.ErrorIs(t, err, config.ErrInvalidConfig, "expected %q to be an invalid file name", name)
require.ErrorContains(t, err, "file names must match", "expected %q to be an invalid file name", name)
}
})
}
func TestHTTP_Validate(t *testing.T) {
t.Parallel()
t.Run("valid config", func(t *testing.T) {
t.Parallel()
http := config.HTTP{
Port: 7331,
Interfaces: []string{"lo"},
}
err := http.Validate()
require.NoError(t, err)
require.Equal(t, []string{"[::1]:7331", "127.0.0.1:7331"}, http.ListenAddresses)
})
t.Run("missing port", func(t *testing.T) {
t.Parallel()
http := config.HTTP{
Port: 0,
Interfaces: []string{"lo"},
}
err := http.Validate()
require.ErrorIs(t, err, config.ErrInvalidConfig)
require.ErrorContains(t, err, "http port must be between 1 and 65535")
})
t.Run("missing interface", func(t *testing.T) {
t.Parallel()
http := config.HTTP{
Port: 7331,
Interfaces: []string{},
}
err := http.Validate()
require.ErrorIs(t, err, config.ErrInvalidConfig)
require.ErrorContains(t, err, "at least one http interface is required")
})
t.Run("invalid interface", func(t *testing.T) {
t.Parallel()
http := config.HTTP{
Port: 7331,
Interfaces: []string{"nonexistent-interface-12345"},
}
err := http.Validate()
require.ErrorIs(t, err, config.ErrInvalidConfig)
require.ErrorContains(t, err, "failed to resolve ip addresses for interface")
})
}
func TestCluster_Validate(t *testing.T) {
t.Parallel()
networkKey := test.GenerateKeys(t, 1)[0]
t.Run("valid config", func(t *testing.T) {
t.Parallel()
cluster := validCluster(t, networkKey)
err := cluster.Validate(networkKey.Public)
require.NoError(t, err)
})
t.Run("missing bootstrap peers", func(t *testing.T) {
t.Parallel()
cluster := config.Cluster{
Port: 7946,
PushPullInterval: 30 * time.Second,
}
err := cluster.Validate(networkKey.Public)
require.ErrorIs(t, err, config.ErrInvalidConfig)
require.ErrorContains(t, err, "at least one bootstrap peer is required")
})
t.Run("missing identity key", func(t *testing.T) {
t.Parallel()
bp, _ := test.GenerateBootstrapPeer(t)
cluster := config.Cluster{
Port: 7946,
BootstrapPeers: []peer.AddrInfo{bp},
PushPullInterval: 30 * time.Second,
}
err := cluster.Validate(networkKey.Public)
require.ErrorIs(t, err, config.ErrInvalidConfig)
require.ErrorContains(t, err, "identity key is required")
})
t.Run("push pull interval too short", func(t *testing.T) {
t.Parallel()
bp, _ := test.GenerateBootstrapPeer(t)
key, _ := test.GenerateIdentityKey(t)
cluster := config.Cluster{
Port: 7946,
BootstrapPeers: []peer.AddrInfo{bp},
IdentityKey: key,
PushPullInterval: 1 * time.Second,
}
err := cluster.Validate(networkKey.Public)
require.ErrorIs(t, err, config.ErrInvalidConfig)
require.ErrorContains(t, err, "cluster push pull interval must be >= 5s")
})
t.Run("missing port", func(t *testing.T) {
t.Parallel()
bp, _ := test.GenerateBootstrapPeer(t)
key, _ := test.GenerateIdentityKey(t)
cluster := config.Cluster{
Port: 0,
BootstrapPeers: []peer.AddrInfo{bp},
IdentityKey: key,
PushPullInterval: 30 * time.Second,
}
err := cluster.Validate(networkKey.Public)
require.ErrorIs(t, err, config.ErrInvalidConfig)
require.ErrorContains(t, err, "cluster port must be between 1 and 65535")
})
t.Run("push pull interval at minimum", func(t *testing.T) {
t.Parallel()
as := require.New(t)
bp, _ := test.GenerateBootstrapPeer(t)
key, _ := test.GenerateIdentityKey(t)
now := time.Now()
cert, err := crypto.SignCertificate(
"test-machine", key.Public, networkKey, now.Add(-time.Minute), now.Add(365*24*time.Hour),
)
as.NoError(err)
cluster := config.Cluster{
BootstrapPeers: []peer.AddrInfo{bp},
IdentityKey: key,
IdentityCert: cert,
Port: 7946,
PushPullInterval: config.MinPushPullInterval,
}
err = cluster.Validate(networkKey.Public)
as.NoError(err)
})
t.Run("missing identity cert", func(t *testing.T) {
t.Parallel()
bp, _ := test.GenerateBootstrapPeer(t)
key, _ := test.GenerateIdentityKey(t)
cluster := config.Cluster{
Port: 7946,
BootstrapPeers: []peer.AddrInfo{bp},
IdentityKey: key,
PushPullInterval: 30 * time.Second,
}
err := cluster.Validate(networkKey.Public)
require.ErrorIs(t, err, config.ErrInvalidConfig)
require.ErrorContains(t, err, "identity certificate is required")
})
t.Run("cert key mismatch", func(t *testing.T) {
t.Parallel()
as := require.New(t)
bp, _ := test.GenerateBootstrapPeer(t)
key, _ := test.GenerateIdentityKey(t)
otherKey, _ := test.GenerateIdentityKey(t)
// Sign cert for otherKey's public key, not for key's
now := time.Now()
cert, err := crypto.SignCertificate(
"test-machine", otherKey.Public, networkKey, now.Add(-time.Minute), now.Add(365*24*time.Hour),
)
as.NoError(err)
cluster := config.Cluster{
Port: 7946,
BootstrapPeers: []peer.AddrInfo{bp},
IdentityKey: key,
IdentityCert: cert,
PushPullInterval: 30 * time.Second,
}
err = cluster.Validate(networkKey.Public)
require.ErrorIs(t, err, config.ErrInvalidConfig)
require.ErrorContains(t, err, "does not match identity key")
})
t.Run("cert not signed by network", func(t *testing.T) {
t.Parallel()
as := require.New(t)
bp, _ := test.GenerateBootstrapPeer(t)
key, _ := test.GenerateIdentityKey(t)
// Sign cert with a different key than the network key
wrongSigner := test.GenerateKeys(t, 1)[0]
now := time.Now()
cert, err := crypto.SignCertificate(
"test-machine", key.Public, wrongSigner, now.Add(-time.Minute), now.Add(365*24*time.Hour),
)
as.NoError(err)
cluster := config.Cluster{
Port: 7946,
BootstrapPeers: []peer.AddrInfo{bp},
IdentityKey: key,
IdentityCert: cert,
PushPullInterval: 30 * time.Second,
}
err = cluster.Validate(networkKey.Public)
require.ErrorIs(t, err, config.ErrInvalidConfig)
require.ErrorContains(t, err, "not signed by network certificate authority")
})
}
func readConfigFromTempFile(t *testing.T, toml string) *config.Config {
t.Helper()
as := require.New(t)
// Create a new viper instance
v, err := config.NewViper()
as.NoError(err)
// force viper to read the config from our temp file instead of the usual locations
as.NoError(v.ReadConfig(strings.NewReader(toml)), "failed to read config")
// Unmarshal viper config into a Config object
cfg, err := config.FromViper(v)
as.NoError(err)
return cfg
}
func TestFromViper_TOML(t *testing.T) {
t.Parallel()
keys := test.GenerateKeys(t, 2)
t.Run("parse public key from file path", func(t *testing.T) {
t.Parallel()
as := require.New(t)
// Write key to a temp file
keyFilePath := t.TempDir() + "/pubkey.pem"
as.NoError(keys[0].Public.WriteToFile(keyFilePath))
ck := generateClusterKeys(t, keys[1])
toml := `
state_directory = "/var/lib/data-mesher"
[http]
port = 8080
interfaces = ["lo"]
[cluster]
port = 7946
interfaces = ["lo"]
bootstrap_peers = ["` + ck.BootstrapPeerAddr + `"]
push_pull_interval = "15s"
identity_key = "` + ck.IdentityKeyPath + `"
identity_cert = "` + ck.CertPath + `"
[network]
id = "` + keys[1].Public.String() + `"
[network.files]
"dns_zone" = ["` + keyFilePath + `"]
`
cfg := readConfigFromTempFile(t, toml)
as.Len(cfg.Network.Files, 1)
as.Len(cfg.Network.Files["dns_zone"], 1)
as.Equal(keys[0].Public.String(), cfg.Network.Files["dns_zone"][0].String())
})
t.Run("parse mixed file paths and inline keys", func(t *testing.T) {
t.Parallel()
as := require.New(t)
// Write one key to a file, use the other inline
keyFilePath := t.TempDir() + "/pubkey.pem"
as.NoError(keys[0].Public.WriteToFile(keyFilePath))
ck := generateClusterKeys(t, keys[1])
toml := `
state_directory = "/var/lib/data-mesher"
[http]
port = 8080
interfaces = ["lo"]
[cluster]
port = 7946
interfaces = ["lo"]
bootstrap_peers = ["` + ck.BootstrapPeerAddr + `"]
push_pull_interval = "15s"
identity_key = "` + ck.IdentityKeyPath + `"
identity_cert = "` + ck.CertPath + `"
[network]
id = "` + keys[1].Public.String() + `"
[network.files]
"dns_zone" = ["` + keyFilePath + `", "` + keys[1].Public.String() + `"]
`
cfg := readConfigFromTempFile(t, toml)
as.Len(cfg.Network.Files, 1)
as.Len(cfg.Network.Files["dns_zone"], 2)
as.Equal(keys[0].Public.String(), cfg.Network.Files["dns_zone"][0].String())
as.Equal(keys[1].Public.String(), cfg.Network.Files["dns_zone"][1].String())
})
t.Run("parse complete toml config", func(t *testing.T) {
t.Parallel()
ck := generateClusterKeys(t, keys[1])
// Note: viper treats dots in map keys as path separators, so we use
// colon-style names like "dns_zone" instead of "file.txt" to avoid issues
toml := `
state_directory = "/var/lib/data-mesher"
[http]
port = 8080
interfaces = ["lo"]
[cluster]
port = 7946
interfaces = ["lo"]
bootstrap_peers = ["` + ck.BootstrapPeerAddr + `"]
push_pull_interval = "15s"
identity_key = "` + ck.IdentityKeyPath + `"
identity_cert = "` + ck.CertPath + `"
[network]
id = "` + keys[1].Public.String() + `"
[network.files]
"dns_zone1" = ["` + keys[0].Public.String() + `"]
"dns_zone2" = ["` + keys[0].Public.String() + `", "` + keys[1].Public.String() + `"]
`
as := require.New(t)
cfg := readConfigFromTempFile(t, toml)
// Check the config values
as.Equal("/var/lib/data-mesher", cfg.StateDirectory)
as.Equal(uint16(8080), cfg.HTTP.Port)
as.Equal([]string{"lo"}, cfg.HTTP.Interfaces)
as.Equal(uint16(7946), cfg.Cluster.Port)
as.Equal([]string{"lo"}, cfg.Cluster.Interfaces)
as.Equal(15*time.Second, cfg.Cluster.PushPullInterval)
as.Len(cfg.Network.Files, 2)
as.Len(cfg.Network.Files["dns_zone1"], 1)
as.Equal(keys[0].Public.String(), cfg.Network.Files["dns_zone1"][0].String())
as.Len(cfg.Network.Files["dns_zone2"], 2)
})
t.Run("parse duration formats", func(t *testing.T) {
t.Parallel()
ck := generateClusterKeys(t, keys[1])
toml := `
state_directory = "/var/lib/data-mesher"
[http]
port = 8080
interfaces = ["lo"]
[cluster]
port = 7946
interfaces = ["lo"]
bootstrap_peers = ["` + ck.BootstrapPeerAddr + `"]
push_pull_interval = "1h"
identity_key = "` + ck.IdentityKeyPath + `"
identity_cert = "` + ck.CertPath + `"
[network]
id = "` + keys[1].Public.String() + `"
[network.files]
"dns_test" = ["` + keys[0].Public.String() + `"]
`
as := require.New(t)
cfg := readConfigFromTempFile(t, toml)
as.Equal(1*time.Hour, cfg.Cluster.PushPullInterval)
})
t.Run("parse max_file_size", func(t *testing.T) {
t.Parallel()
ck := generateClusterKeys(t, keys[1])
toml := `
state_directory = "/var/lib/data-mesher"
max_file_size = 5242880
[http]
port = 8080
interfaces = ["lo"]
[cluster]
port = 7946
interfaces = ["lo"]
bootstrap_peers = ["` + ck.BootstrapPeerAddr + `"]
push_pull_interval = "15s"
identity_key = "` + ck.IdentityKeyPath + `"
identity_cert = "` + ck.CertPath + `"
[network]
id = "` + keys[1].Public.String() + `"
[network.files]
"dns_test" = ["` + keys[0].Public.String() + `"]
`
as := require.New(t)
cfg := readConfigFromTempFile(t, toml)
as.Equal(int64(5242880), cfg.MaxFileSize) // 5 MiB
})
t.Run("max_file_size defaults in toml", func(t *testing.T) {
t.Parallel()
ck := generateClusterKeys(t, keys[1])
toml := `
state_directory = "/var/lib/data-mesher"
[http]
port = 8080
interfaces = ["lo"]
[cluster]
port = 7946
interfaces = ["lo"]
bootstrap_peers = ["` + ck.BootstrapPeerAddr + `"]
push_pull_interval = "15s"
identity_key = "` + ck.IdentityKeyPath + `"
identity_cert = "` + ck.CertPath + `"
[network]
id = "` + keys[1].Public.String() + `"
[network.files]
"dns_test" = ["` + keys[0].Public.String() + `"]
`
as := require.New(t)
cfg := readConfigFromTempFile(t, toml)
as.Equal(int64(config.DefaultMaxFileSize), cfg.MaxFileSize)
})
}
func TestExtraNetworks_Validate(t *testing.T) {
t.Parallel()
keys := test.GenerateKeys(t, 3)
t.Run("nil extra networks is valid", func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
StateDirectory: "/var/lib/data-mesher",
HTTP: validHTTP(),
Cluster: validCluster(t, keys[0]),
Network: validNetwork(t, keys[0].Public),
}
err := cfg.Validate()
require.NoError(t, err)
})
t.Run("empty extra networks is valid", func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
StateDirectory: "/var/lib/data-mesher",
HTTP: validHTTP(),
Cluster: validCluster(t, keys[0]),
Network: validNetwork(t, keys[0].Public),
ExtraNetworks: config.ExtraNetworks{},
}
err := cfg.Validate()
require.NoError(t, err)
})
t.Run("valid extra network", func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
StateDirectory: "/var/lib/data-mesher",
HTTP: validHTTP(),
Cluster: validCluster(t, keys[0]),
Network: validNetwork(t, keys[0].Public),
ExtraNetworks: config.ExtraNetworks{
"other": {
ID: keys[1].Public,
Files: config.Files{"shared_file": nil},
},
},
}
err := cfg.Validate()
require.NoError(t, err)
})
t.Run("valid extra network with exports_to", func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
StateDirectory: "/var/lib/data-mesher",
HTTP: validHTTP(),
Cluster: validCluster(t, keys[0]),
Network: validNetwork(t, keys[0].Public),
ExtraNetworks: config.ExtraNetworks{
"other": {
ID: keys[1].Public,
Files: config.Files{"shared_file": nil},
},
},
}
err := cfg.Validate()
require.NoError(t, err)
})
t.Run("multiple extra networks", func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
StateDirectory: "/var/lib/data-mesher",
HTTP: validHTTP(),
Cluster: validCluster(t, keys[0]),
Network: validNetwork(t, keys[0].Public),
ExtraNetworks: config.ExtraNetworks{
"net_a": {
ID: keys[1].Public,
Files: config.Files{"file_a": nil},
},
"net_b": {
ID: keys[2].Public,
Files: config.Files{"file_b": nil},
},
},
}
err := cfg.Validate()
require.NoError(t, err)
})
t.Run("extra network missing ID", func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
StateDirectory: "/var/lib/data-mesher",
HTTP: validHTTP(),
Cluster: validCluster(t, keys[0]),
Network: validNetwork(t, keys[0].Public),
ExtraNetworks: config.ExtraNetworks{
"broken": {
Files: config.Files{"some_file": nil},
},
},
}
err := cfg.Validate()
require.ErrorIs(t, err, config.ErrInvalidConfig)
require.ErrorContains(t, err, "extra network broken")
require.ErrorContains(t, err, "network ID is required")
})
t.Run("extra network missing files", func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
StateDirectory: "/var/lib/data-mesher",
HTTP: validHTTP(),
Cluster: validCluster(t, keys[0]),
Network: validNetwork(t, keys[0].Public),
ExtraNetworks: config.ExtraNetworks{
"broken": {
ID: keys[1].Public,
},
},
}
err := cfg.Validate()
require.ErrorIs(t, err, config.ErrInvalidConfig)
require.ErrorContains(t, err, "extra network broken")
require.ErrorContains(t, err, "at least one file should be configured")
})
t.Run("extra network invalid file name", func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
StateDirectory: "/var/lib/data-mesher",
HTTP: validHTTP(),
Cluster: validCluster(t, keys[0]),
Network: validNetwork(t, keys[0].Public),
ExtraNetworks: config.ExtraNetworks{
"broken": {
ID: keys[1].Public,
Files: config.Files{"INVALID-NAME": nil},
},
},
}
err := cfg.Validate()
require.ErrorIs(t, err, config.ErrInvalidConfig)
require.ErrorContains(t, err, "extra network broken")
require.ErrorContains(t, err, "file names must match")
})
t.Run("extra network cannot use reserved 'home' name", func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
StateDirectory: "/var/lib/data-mesher",
HTTP: validHTTP(),
Cluster: validCluster(t, keys[0]),
Network: validNetwork(t, keys[0].Public),
ExtraNetworks: config.ExtraNetworks{
"home": {
ID: keys[1].Public,
Files: config.Files{"test": nil},
},
},
}
err := cfg.Validate()
require.ErrorIs(t, err, config.ErrInvalidConfig)
require.ErrorContains(t, err, "network name 'home' is reserved")
})
}
func TestConfig_NetworkNames(t *testing.T) {
t.Parallel()
keys := test.GenerateKeys(t, 3)
t.Run("default network gets 'home' name", func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
StateDirectory: "/var/lib/data-mesher",
HTTP: validHTTP(),
Cluster: validCluster(t, keys[0]),
Network: validNetwork(t, keys[0].Public),
}
err := cfg.Validate()
require.NoError(t, err)
require.Equal(t, "home", cfg.Network.Name)
})
t.Run("extra networks get names from map keys", func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
StateDirectory: "/var/lib/data-mesher",
HTTP: validHTTP(),
Cluster: validCluster(t, keys[0]),
Network: validNetwork(t, keys[0].Public),
ExtraNetworks: config.ExtraNetworks{
"other_net": {
ID: keys[1].Public,
Files: config.Files{"shared_file": nil},
},
"third_net": {
ID: keys[2].Public,
Files: config.Files{"another_file": nil},
},
},
}
err := cfg.Validate()
require.NoError(t, err)
require.Equal(t, "other_net", cfg.ExtraNetworks["other_net"].Name)
require.Equal(t, "third_net", cfg.ExtraNetworks["third_net"].Name)
})
}
func TestConfig_NetworksByID(t *testing.T) {
t.Parallel()
keys := test.GenerateKeys(t, 3)
t.Run("NetworksByID includes main network", func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
StateDirectory: "/var/lib/data-mesher",
HTTP: validHTTP(),
Cluster: validCluster(t, keys[0]),
Network: validNetwork(t, keys[0].Public),
}
err := cfg.Validate()
require.NoError(t, err)
require.NotNil(t, cfg.NetworksByID)
require.Len(t, cfg.NetworksByID, 1)
network, ok := cfg.NetworksByID[keys[0].Public.String()]
require.True(t, ok, "main network should be in NetworksByID")
require.Equal(t, "home", network.Name)
require.True(t, network.ID.Equal(keys[0].Public))
})
t.Run("NetworksByID includes extra networks", func(t *testing.T) {
t.Parallel()
cfg := &config.Config{
StateDirectory: "/var/lib/data-mesher",
HTTP: validHTTP(),
Cluster: validCluster(t, keys[0]),
Network: validNetwork(t, keys[0].Public),
ExtraNetworks: config.ExtraNetworks{
"other_net": {
ID: keys[1].Public,
Files: config.Files{"shared_file": nil},
},
"third_net": {
ID: keys[2].Public,
Files: config.Files{"another_file": nil},
},
},
}
err := cfg.Validate()
require.NoError(t, err)
require.NotNil(t, cfg.NetworksByID)
require.Len(t, cfg.NetworksByID, 3)
// Check main network
network0, ok := cfg.NetworksByID[keys[0].Public.String()]
require.True(t, ok, "main network should be in NetworksByID")
require.Equal(t, "home", network0.Name)
// Check extra networks
network1, ok := cfg.NetworksByID[keys[1].Public.String()]
require.True(t, ok, "other_net should be in NetworksByID")
require.Equal(t, "other_net", network1.Name)
network2, ok := cfg.NetworksByID[keys[2].Public.String()]
require.True(t, ok, "third_net should be in NetworksByID")
require.Equal(t, "third_net", network2.Name)
})
}
func TestFromViper_ExtraNetworks(t *testing.T) {
t.Parallel()
keys := test.GenerateKeys(t, 4)
t.Run("parse extra networks from TOML", func(t *testing.T) {
t.Parallel()
as := require.New(t)
ck := generateClusterKeys(t, keys[0])
toml := `
state_directory = "/var/lib/data-mesher"
[http]
port = 8080
interfaces = ["lo"]
[cluster]
port = 7946
interfaces = ["lo"]
bootstrap_peers = ["` + ck.BootstrapPeerAddr + `"]
push_pull_interval = "15s"
identity_key = "` + ck.IdentityKeyPath + `"
identity_cert = "` + ck.CertPath + `"
[network]
id = "` + keys[0].Public.String() + `"
[network.files]
"dns_zone" = ["` + keys[1].Public.String() + `"]
[extra_networks.other_net]
id = "` + keys[2].Public.String() + `"
exports_to = ["` + keys[3].Public.String() + `"]
[extra_networks.other_net.files]
"shared_data" = ["` + keys[1].Public.String() + `"]
`
cfg := readConfigFromTempFile(t, toml)
as.Len(cfg.ExtraNetworks, 1)
otherNet, ok := cfg.ExtraNetworks["other_net"]
as.True(ok, "expected extra network 'other_net'")
as.True(keys[2].Public.Equal(otherNet.ID))
as.Len(otherNet.Files, 1)
as.Len(otherNet.Files["shared_data"], 1)
as.Equal(keys[1].Public.String(), otherNet.Files["shared_data"][0].String())
})
t.Run("parse multiple extra networks from TOML", func(t *testing.T) {
t.Parallel()
as := require.New(t)
ck := generateClusterKeys(t, keys[0])
toml := `
state_directory = "/var/lib/data-mesher"
[http]
port = 8080
interfaces = ["lo"]
[cluster]
port = 7946
interfaces = ["lo"]
bootstrap_peers = ["` + ck.BootstrapPeerAddr + `"]
push_pull_interval = "15s"
identity_key = "` + ck.IdentityKeyPath + `"
identity_cert = "` + ck.CertPath + `"
[network]
id = "` + keys[0].Public.String() + `"
[network.files]
"dns_zone" = ["` + keys[1].Public.String() + `"]
[extra_networks.net_a]
id = "` + keys[2].Public.String() + `"
[extra_networks.net_a.files]
"file_a" = ["` + keys[1].Public.String() + `"]
[extra_networks.net_b]
id = "` + keys[3].Public.String() + `"
[extra_networks.net_b.files]
"file_b" = ["` + keys[1].Public.String() + `"]
`
cfg := readConfigFromTempFile(t, toml)
as.Len(cfg.ExtraNetworks, 2)
netA, ok := cfg.ExtraNetworks["net_a"]
as.True(ok, "expected extra network 'net_a'")
as.True(keys[2].Public.Equal(netA.ID))
as.Len(netA.Files, 1)
netB, ok := cfg.ExtraNetworks["net_b"]
as.True(ok, "expected extra network 'net_b'")
as.True(keys[3].Public.Equal(netB.ID))
as.Len(netB.Files, 1)
})
t.Run("no extra networks is valid", func(t *testing.T) {
t.Parallel()
as := require.New(t)
ck := generateClusterKeys(t, keys[0])
toml := `
state_directory = "/var/lib/data-mesher"
[http]
port = 8080
interfaces = ["lo"]
[cluster]
port = 7946
interfaces = ["lo"]
bootstrap_peers = ["` + ck.BootstrapPeerAddr + `"]
push_pull_interval = "15s"
identity_key = "` + ck.IdentityKeyPath + `"
identity_cert = "` + ck.CertPath + `"
[network]
id = "` + keys[0].Public.String() + `"
[network.files]
"dns_zone" = ["` + keys[1].Public.String() + `"]
`
cfg := readConfigFromTempFile(t, toml)
as.Nil(cfg.ExtraNetworks)
})
}
// TestFromViper_ConfigurationDirectory tests that CONFIGURATION_DIRECTORY env var
// is used to discover config files. Note: STATE_DIRECTORY and other env var tests
// are in env_mapping_test.go.
func TestFromViper_ConfigurationDirectory(t *testing.T) {
keys := test.GenerateKeys(t, 2)
ck := generateClusterKeys(t, keys[1])
toml := `
state_directory = "/var/lib/data-mesher"
[http]
port = 8080
interfaces = ["lo"]
[cluster]
port = 7946
interfaces = ["lo"]
bootstrap_peers = ["` + ck.BootstrapPeerAddr + `"]
push_pull_interval = "15s"
identity_key = "` + ck.IdentityKeyPath + `"
identity_cert = "` + ck.CertPath + `"
[network]
id = "` + keys[1].Public.String() + `"
[network.files]
"dns_test" = ["` + keys[0].Public.String() + `"]
`
as := require.New(t)
// Create a temp directory to act as the configuration directory
tempDir := t.TempDir()
configPath := tempDir + "/dm.toml"
as.NoError(os.WriteFile(configPath, []byte(toml), 0o600))
// Set CONFIGURATION_DIRECTORY to our temp directory
t.Setenv("CONFIGURATION_DIRECTORY", tempDir)
// Create a new viper instance and read config
v, err := config.NewViper()
as.NoError(err)
// ReadInConfig should find dm.toml in CONFIGURATION_DIRECTORY
as.NoError(v.ReadInConfig())
// Verify it read from our temp directory
as.Equal(configPath, v.ConfigFileUsed())
}
func TestNetwork_NamespacesValidation(t *testing.T) {
t.Parallel()
keys := test.GenerateKeys(t, 1)
t.Run("valid namespaces", func(t *testing.T) {
t.Parallel()
n := config.Network{
ID: keys[0].Public,
Files: config.Files{"test_file": nil},
Namespaces: []string{"dns", "nss"},
}
require.NoError(t, n.Validate())
})
t.Run("empty namespaces is valid", func(t *testing.T) {
t.Parallel()
n := config.Network{
ID: keys[0].Public,
Files: config.Files{"test_file": nil},
}
require.NoError(t, n.Validate())
})
t.Run("invalid namespace name", func(t *testing.T) {
t.Parallel()
for _, name := range []string{"DNS", "my-ns", "a/b", ""} {
n := config.Network{
ID: keys[0].Public,
Files: config.Files{"test_file": nil},
Namespaces: []string{name},
}
require.ErrorIs(t, n.Validate(), config.ErrInvalidConfig, "expected %q to be invalid", name)
}
})
t.Run("duplicate namespace", func(t *testing.T) {
t.Parallel()
n := config.Network{
ID: keys[0].Public,
Files: config.Files{"test_file": nil},
Namespaces: []string{"dns", "dns"},
}
require.ErrorIs(t, n.Validate(), config.ErrInvalidConfig)
require.ErrorContains(t, n.Validate(), "duplicate namespace")
})
}