Files
lolcathost/internal/config/config.go
T
lukaszraczylo 29263dc8a2 gosec govulncheck runs (#1)
* gosec govulncheck runs

* Fix flaky TestRateLimiter_Matrix test

The test was failing due to two issues:
1. Test name generation used invalid character conversion (string(rune('0'+limit)))
   which produced non-printable characters for limits >= 10
2. Using 10ms windows with 100 requests caused race conditions - early requests
   would expire before all 100 were made, allowing the 101st request

Changed to use struct-based test cases with proper fmt.Sprintf naming and
a consistent 1-second window that won't expire during rapid test execution.
2025-12-09 01:07:16 +00:00

475 lines
10 KiB
Go

// Package config handles YAML configuration parsing and hot-reload.
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"github.com/fsnotify/fsnotify"
"gopkg.in/yaml.v3"
)
// SystemConfigDir is the system-wide config directory for the daemon.
const SystemConfigDir = "/etc/lolcathost"
// SystemConfigPath is the system-wide config file path for the daemon.
const SystemConfigPath = "/etc/lolcathost/config.yaml"
// DefaultConfigDir returns the default config directory path for users.
func DefaultConfigDir() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".config", "lolcathost")
}
// DefaultConfigPath returns the default config file path for users.
func DefaultConfigPath() string {
return filepath.Join(DefaultConfigDir(), "config.yaml")
}
// FlushMethod defines DNS cache flush methods.
type FlushMethod string
const (
FlushMethodAuto FlushMethod = "auto"
FlushMethodDscacheutil FlushMethod = "dscacheutil"
FlushMethodKillall FlushMethod = "killall"
FlushMethodBoth FlushMethod = "both"
)
// Settings holds global configuration settings.
type Settings struct {
AutoApply bool `yaml:"autoApply"`
FlushMethod FlushMethod `yaml:"flushMethod"`
}
// Host represents a single host entry in configuration.
type Host struct {
Domain string `yaml:"domain"`
IP string `yaml:"ip"`
Alias string `yaml:"alias"`
Enabled bool `yaml:"enabled"`
}
// Group represents a group of host entries.
type Group struct {
Name string `yaml:"name"`
Hosts []Host `yaml:"hosts"`
}
// Preset defines a named preset that enables/disables specific aliases.
type Preset struct {
Name string `yaml:"name"`
Enable []string `yaml:"enable,omitempty"`
Disable []string `yaml:"disable,omitempty"`
}
// Config represents the complete configuration.
type Config struct {
Settings Settings `yaml:"settings"`
Groups []Group `yaml:"groups"`
Presets []Preset `yaml:"presets"`
}
// Manager handles configuration loading and watching.
type Manager struct {
path string
config *Config
mu sync.RWMutex
watcher *fsnotify.Watcher
onChange func(*Config)
stopCh chan struct{}
}
// NewManager creates a new config manager.
func NewManager(path string) *Manager {
return &Manager{
path: path,
stopCh: make(chan struct{}),
}
}
// Load reads and parses the configuration file.
func (m *Manager) Load() error {
data, err := os.ReadFile(m.path)
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("failed to parse config file: %w", err)
}
if err := ValidateConfig(&cfg); err != nil {
return fmt.Errorf("invalid config: %w", err)
}
m.mu.Lock()
m.config = &cfg
m.mu.Unlock()
return nil
}
// Get returns the current configuration.
func (m *Manager) Get() *Config {
m.mu.RLock()
defer m.mu.RUnlock()
return m.config
}
// Watch starts watching the config file for changes.
func (m *Manager) Watch(onChange func(*Config)) error {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return fmt.Errorf("failed to create watcher: %w", err)
}
m.watcher = watcher
m.onChange = onChange
go m.watchLoop()
if err := watcher.Add(m.path); err != nil {
return fmt.Errorf("failed to watch config file: %w", err)
}
return nil
}
func (m *Manager) watchLoop() {
for {
select {
case event, ok := <-m.watcher.Events:
if !ok {
return
}
if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
if err := m.Load(); err == nil && m.onChange != nil {
m.onChange(m.Get())
}
}
case <-m.watcher.Errors:
// Ignore watcher errors
case <-m.stopCh:
return
}
}
}
// Stop stops watching the config file.
func (m *Manager) Stop() {
close(m.stopCh)
if m.watcher != nil {
_ = m.watcher.Close()
}
}
// GetAllHosts returns all hosts from all groups.
func (c *Config) GetAllHosts() []Host {
var hosts []Host
for _, g := range c.Groups {
hosts = append(hosts, g.Hosts...)
}
return hosts
}
// FindHostByAlias finds a host by its alias.
func (c *Config) FindHostByAlias(alias string) (*Host, *Group) {
for i := range c.Groups {
for j := range c.Groups[i].Hosts {
if c.Groups[i].Hosts[j].Alias == alias {
return &c.Groups[i].Hosts[j], &c.Groups[i]
}
}
}
return nil, nil
}
// FindPreset finds a preset by name.
func (c *Config) FindPreset(name string) *Preset {
for i := range c.Presets {
if c.Presets[i].Name == name {
return &c.Presets[i]
}
}
return nil
}
// SetHostEnabled sets the enabled state of a host by alias.
func (c *Config) SetHostEnabled(alias string, enabled bool) bool {
for i := range c.Groups {
for j := range c.Groups[i].Hosts {
if c.Groups[i].Hosts[j].Alias == alias {
c.Groups[i].Hosts[j].Enabled = enabled
return true
}
}
}
return false
}
// GenerateAlias creates a unique alias from a domain name.
func (c *Config) GenerateAlias(domain string) string {
// Convert domain to alias format: example.com -> example-com
alias := strings.ReplaceAll(domain, ".", "-")
alias = strings.ReplaceAll(alias, "_", "-")
alias = strings.ToLower(alias)
// Check if alias exists, if so append a number
baseAlias := alias
counter := 1
for {
if existing, _ := c.FindHostByAlias(alias); existing == nil {
break
}
counter++
alias = fmt.Sprintf("%s-%d", baseAlias, counter)
}
return alias
}
// AddHost adds a new host to the configuration.
func (c *Config) AddHost(domain, ip, alias, groupName string, enabled bool) error {
// Auto-generate alias if empty
if alias == "" {
alias = c.GenerateAlias(domain)
} else {
// Check for duplicate alias
if existing, _ := c.FindHostByAlias(alias); existing != nil {
return fmt.Errorf("alias already exists: %s", alias)
}
}
host := Host{
Domain: domain,
IP: ip,
Alias: alias,
Enabled: enabled,
}
// Find or create group
for i := range c.Groups {
if c.Groups[i].Name == groupName {
c.Groups[i].Hosts = append(c.Groups[i].Hosts, host)
return nil
}
}
// Create new group
c.Groups = append(c.Groups, Group{
Name: groupName,
Hosts: []Host{host},
})
return nil
}
// AddGroup adds a new empty group.
func (c *Config) AddGroup(name string) error {
// Check if group already exists
for _, g := range c.Groups {
if g.Name == name {
return fmt.Errorf("group already exists: %s", name)
}
}
c.Groups = append(c.Groups, Group{
Name: name,
Hosts: []Host{},
})
return nil
}
// DeleteGroup removes a group and all its hosts.
func (c *Config) DeleteGroup(name string) error {
for i, g := range c.Groups {
if g.Name == name {
c.Groups = append(c.Groups[:i], c.Groups[i+1:]...)
return nil
}
}
return fmt.Errorf("group not found: %s", name)
}
// RenameGroup renames an existing group.
func (c *Config) RenameGroup(oldName, newName string) error {
// Check if new name already exists
for _, g := range c.Groups {
if g.Name == newName {
return fmt.Errorf("group already exists: %s", newName)
}
}
for i := range c.Groups {
if c.Groups[i].Name == oldName {
c.Groups[i].Name = newName
return nil
}
}
return fmt.Errorf("group not found: %s", oldName)
}
// GetGroups returns all group names.
func (c *Config) GetGroups() []string {
names := make([]string, len(c.Groups))
for i, g := range c.Groups {
names[i] = g.Name
}
return names
}
// DeleteHost removes a host by alias.
func (c *Config) DeleteHost(alias string) bool {
for i := range c.Groups {
for j := range c.Groups[i].Hosts {
if c.Groups[i].Hosts[j].Alias == alias {
c.Groups[i].Hosts = append(c.Groups[i].Hosts[:j], c.Groups[i].Hosts[j+1:]...)
return true
}
}
}
return false
}
// ApplyPreset applies a preset to the configuration.
func (c *Config) ApplyPreset(name string) error {
preset := c.FindPreset(name)
if preset == nil {
return fmt.Errorf("preset not found: %s", name)
}
for _, alias := range preset.Enable {
c.SetHostEnabled(alias, true)
}
for _, alias := range preset.Disable {
c.SetHostEnabled(alias, false)
}
return nil
}
// AddPreset adds a new preset.
func (c *Config) AddPreset(name string, enable, disable []string) error {
// Check if preset already exists
for _, p := range c.Presets {
if p.Name == name {
return fmt.Errorf("preset already exists: %s", name)
}
}
c.Presets = append(c.Presets, Preset{
Name: name,
Enable: enable,
Disable: disable,
})
return nil
}
// DeletePreset removes a preset by name.
func (c *Config) DeletePreset(name string) error {
for i, p := range c.Presets {
if p.Name == name {
c.Presets = append(c.Presets[:i], c.Presets[i+1:]...)
return nil
}
}
return fmt.Errorf("preset not found: %s", name)
}
// GetPresets returns all presets.
func (c *Config) GetPresets() []Preset {
return c.Presets
}
// EnsureDefaultGroup ensures at least one group exists, creating "default" if needed.
func (c *Config) EnsureDefaultGroup() {
if len(c.Groups) == 0 {
c.Groups = append(c.Groups, Group{
Name: "default",
Hosts: []Host{},
})
}
}
// Save writes the configuration to the file.
func (m *Manager) Save() error {
m.mu.RLock()
cfg := m.config
m.mu.RUnlock()
if cfg == nil {
return fmt.Errorf("no config loaded")
}
data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
// #nosec G306 -- config file should be world-readable
if err := os.WriteFile(m.path, data, 0644); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
return nil
}
// CreateDefault creates a default configuration file.
func CreateDefault(path string) error {
dir := filepath.Dir(path)
// #nosec G301 -- config directory should be world-readable
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
cfg := &Config{
Settings: Settings{
AutoApply: true,
FlushMethod: FlushMethodAuto,
},
Groups: []Group{
{
Name: "development",
Hosts: []Host{
{
Domain: "example.local",
IP: "127.0.0.1",
Alias: "example-local",
Enabled: false,
},
},
},
},
Presets: []Preset{
{
Name: "local",
Enable: []string{"example-local"},
Disable: []string{},
},
{
Name: "clear",
Enable: []string{},
Disable: []string{"example-local"},
},
},
}
data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("failed to marshal default config: %w", err)
}
// #nosec G306 -- config file should be world-readable
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("failed to write default config: %w", err)
}
return nil
}