diff --git a/cmd/secubox/internal/profile/merger.go b/cmd/secubox/internal/profile/merger.go new file mode 100644 index 00000000..ffc90a6e --- /dev/null +++ b/cmd/secubox/internal/profile/merger.go @@ -0,0 +1,143 @@ +// cmd/secubox/internal/profile/merger.go +package profile + +import ( + "fmt" + "path/filepath" +) + +// Merger resolves profile inheritance +type Merger struct { + profilesDir string + cache map[string]*Profile +} + +// NewMerger creates a new profile merger +func NewMerger(profilesDir string) *Merger { + return &Merger{ + profilesDir: profilesDir, + cache: make(map[string]*Profile), + } +} + +// Resolve loads a profile and merges all inherited profiles +func (m *Merger) Resolve(name string) (*Profile, error) { + // Check cache + if p, ok := m.cache[name]; ok { + return p, nil + } + + // Load profile + path := filepath.Join(m.profilesDir, name+".yaml") + p, err := Load(path) + if err != nil { + return nil, fmt.Errorf("load profile %s: %w", name, err) + } + + // If no inheritance, return as-is + if p.Inherits == "" { + m.cache[name] = p + return p, nil + } + + // Resolve parent first + parent, err := m.Resolve(p.Inherits) + if err != nil { + return nil, fmt.Errorf("resolve parent %s: %w", p.Inherits, err) + } + + // Merge parent into child + merged := m.merge(parent, p) + m.cache[name] = merged + return merged, nil +} + +// merge combines parent and child profiles (child overrides parent) +func (m *Merger) merge(parent, child *Profile) *Profile { + result := &Profile{ + Name: child.Name, + Description: child.Description, + } + + // Merge packages (combine required, child excluded wins) + result.Packages.Required = append([]string{}, parent.Packages.Required...) + result.Packages.Required = append(result.Packages.Required, child.Packages.Required...) + result.Packages.Required = unique(result.Packages.Required) + result.Packages.Excluded = child.Packages.Excluded + + // Remove excluded packages from required + result.Packages.Required = subtract(result.Packages.Required, result.Packages.Excluded) + + // Kernel: child overrides or inherits + result.Kernel = parent.Kernel + if child.Kernel.Version != "" { + result.Kernel.Version = child.Kernel.Version + } + if len(child.Kernel.Modules.Enable) > 0 { + result.Kernel.Modules.Enable = append(result.Kernel.Modules.Enable, child.Kernel.Modules.Enable...) + } + if len(child.Kernel.Modules.Blacklist) > 0 { + result.Kernel.Modules.Blacklist = child.Kernel.Modules.Blacklist + } + + // Services: merge + result.Services.Enable = append(parent.Services.Enable, child.Services.Enable...) + result.Services.Enable = unique(result.Services.Enable) + result.Services.Disable = child.Services.Disable + + // Sysctl: merge maps + result.Sysctl = make(map[string]interface{}) + for k, v := range parent.Sysctl { + result.Sysctl[k] = v + } + for k, v := range child.Sysctl { + result.Sysctl[k] = v + } + + // Features: child overrides + result.Features = parent.Features + if child.Features.DPI != nil { + result.Features.DPI = child.Features.DPI + } + if child.Features.Swap != "" { + result.Features.Swap = child.Features.Swap + } + // LXC: explicit check since false is valid + result.Features.LXC = child.Features.LXC || parent.Features.LXC + + // Constraints: child overrides + result.Constraints = child.Constraints + if result.Constraints.MinRAM == "" { + result.Constraints.MinRAM = parent.Constraints.MinRAM + } + + return result +} + +// unique removes duplicates from a slice +func unique(s []string) []string { + seen := make(map[string]bool) + result := []string{} + for _, v := range s { + if !seen[v] { + seen[v] = true + result = append(result, v) + } + } + return result +} + +// subtract removes items in b from a +func subtract(a, b []string) []string { + exclude := make(map[string]bool) + for _, v := range b { + exclude[v] = true + } + result := []string{} + for _, v := range a { + if !exclude[v] { + result = append(result, v) + } + } + return result +} diff --git a/cmd/secubox/internal/profile/profile_test.go b/cmd/secubox/internal/profile/profile_test.go index 4f2ae149..10a98156 100644 --- a/cmd/secubox/internal/profile/profile_test.go +++ b/cmd/secubox/internal/profile/profile_test.go @@ -34,3 +34,48 @@ packages: t.Errorf("Packages.Required = %d, want 2", len(p.Packages.Required)) } } + +func TestMergeProfiles(t *testing.T) { + dir := t.TempDir() + + // Create base profile + base := ` +name: base +packages: + required: + - secubox-core +kernel: + version: "6.6" +` + basePath := filepath.Join(dir, "base.yaml") + os.WriteFile(basePath, []byte(base), 0644) + + // Create child profile + child := ` +name: child +inherits: base +packages: + required: + - secubox-crowdsec + excluded: + - secubox-dpi +` + childPath := filepath.Join(dir, "child.yaml") + os.WriteFile(childPath, []byte(child), 0644) + + merger := NewMerger(dir) + result, err := merger.Resolve("child") + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + + // Should have both base and child packages + if len(result.Packages.Required) != 2 { + t.Errorf("Packages.Required = %v, want 2 items", result.Packages.Required) + } + + // Should inherit kernel version + if result.Kernel.Version != "6.6" { + t.Errorf("Kernel.Version = %q, want %q", result.Kernel.Version, "6.6") + } +} diff --git a/profiles/tier-lite.yaml b/profiles/tier-lite.yaml new file mode 100644 index 00000000..a45b42a9 --- /dev/null +++ b/profiles/tier-lite.yaml @@ -0,0 +1,27 @@ +# profiles/tier-lite.yaml +name: tier-lite +inherits: base +description: For 1-2GB RAM devices (ESPRESSObin) + +constraints: + min_ram: 1G + max_ram: 2G + +packages: + required: + - secubox-crowdsec + - secubox-wireguard + - secubox-netmodes + - secubox-nac + excluded: + - secubox-dpi + - secubox-ollama + - secubox-jellyfin + - secubox-matrix + - secubox-nextcloud + - secubox-gitea + +features: + dpi: false + lxc: false + swap: 512M diff --git a/profiles/tier-pro.yaml b/profiles/tier-pro.yaml new file mode 100644 index 00000000..d45124c8 --- /dev/null +++ b/profiles/tier-pro.yaml @@ -0,0 +1,16 @@ +# profiles/tier-pro.yaml +name: tier-pro +inherits: base +description: For 8GB+ RAM devices (MOCHAbin) + +constraints: + min_ram: 8G + +packages: + required: + - secubox-full + +features: + dpi: inline + lxc: true + swap: "0" diff --git a/profiles/tier-standard.yaml b/profiles/tier-standard.yaml new file mode 100644 index 00000000..c0f65d4c --- /dev/null +++ b/profiles/tier-standard.yaml @@ -0,0 +1,25 @@ +# profiles/tier-standard.yaml +name: tier-standard +inherits: base +description: For 4GB RAM devices + +constraints: + min_ram: 4G + max_ram: 8G + +packages: + required: + - secubox-crowdsec + - secubox-wireguard + - secubox-netmodes + - secubox-nac + - secubox-dpi + - secubox-qos + - secubox-waf + - secubox-haproxy + - secubox-vhost + +features: + dpi: mirror + lxc: true + swap: "0"