package config import ( "fmt" "os" "path/filepath" "strings" "gopkg.in/yaml.v3" "nadir/internal/module" "nadir/internal/rbac" ) // File is the on-disk YAML shape. Module keys and permission values may be // "*" (must be quoted in YAML to avoid alias syntax). // // Example: // // server: // secure_tls: false // roles: // admin: // "*": ["*"] // auditor: // "*": [read] // assignments: // urania: [admin] // // LogFiles is the allowlist of log files readable per unit via the file log // source. The path query parameter is matched against this set, so an admin // (not the caller) decides which files are exposable. Example: // // log_files: // nginx.service: // - /var/log/nginx/access.log // - /var/log/nginx/error.log type File struct { Server Server `yaml:"server"` Roles map[string]map[string][]string `yaml:"roles"` Assignments map[string][]string `yaml:"assignments"` LogFiles map[string][]string `yaml:"log_files"` } // Server holds process-level settings. type Server struct { // SecureTLS controls the Secure attribute on the session cookie. A pointer // so an omitted key means "unset" (defaults to true / production-safe) // rather than the zero value false. Set false for local HTTP development. SecureTLS *bool `yaml:"secure_tls"` Hostname string `yaml:"hostname"` Port string `yaml:"port"` // TLS is provided one of three ways, in priority order: // 1. TrustProxy true - a reverse proxy (e.g. https://example.com) does // TLS; nadir serves plaintext HTTP and trusts X-Forwarded-For. nadir // must then be reachable ONLY by the proxy (bind localhost). // 2. TLSCert+TLSKey - nadir terminates TLS with this PEM pair. // 3. neither - nadir self-signs in memory (dev only). // Keep secure_tls true in modes 1 and 2 so the session cookie stays Secure. TrustProxy bool `yaml:"trust_proxy"` TLSCert string `yaml:"tls_cert"` TLSKey string `yaml:"tls_key"` // ReleaseRepo is the Gitea repo URL used by /install.sh to fetch the // latest binary. Example: https://gitea.example.com/urania/nadir. // Empty disables the install.sh endpoint. ReleaseRepo string `yaml:"release_repo"` } // SecureCookie reports whether the session cookie should carry the Secure // attribute, defaulting to true when server.secure_tls is omitted. func (f *File) SecureCookie() bool { if f.Server.SecureTLS == nil { return true } return *f.Server.SecureTLS } // DefaultPath returns the default configuration path at // ~/.config/nadir/config.yaml (honoring XDG_CONFIG_HOME via os.UserConfigDir). func DefaultPath() (string, error) { dir, err := os.UserConfigDir() if err != nil { return "", fmt.Errorf("detect config dir: %w", err) } return filepath.Join(dir, "nadir", "config.yaml"), nil } // ExpandPath expands leading ~ to the current user's home directory. func ExpandPath(path string) (string, error) { if strings.HasPrefix(path, "~/") { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("detect home dir: %w", err) } return filepath.Join(home, path[2:]), nil } return path, nil } // Load reads and parses the YAML file at path. func Load(path string) (*File, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read config %s: %w", path, err) } var f File if err := yaml.Unmarshal(data, &f); err != nil { return nil, fmt.Errorf("parse config %s: %w", path, err) } return &f, nil } // Apply validates the config against the loaded modules and installs roles // + assignments into the RBAC store. Any reference to an unknown module or // unknown permission causes a startup error. func Apply(f *File, roles *rbac.RBAC, mods []module.Module) error { // Build a lookup: module ID -> the permissions it exposes. knownPerms := map[string]map[rbac.Permission]bool{} for _, m := range mods { set := map[rbac.Permission]bool{} for _, p := range m.Permissions() { set[p] = true } knownPerms[m.ID()] = set } // 1. Define roles, validating every named module + permission. for roleName, grants := range f.Roles { converted := map[string][]rbac.Permission{} for modKey, perms := range grants { if modKey != rbac.Wildcard { if _, ok := knownPerms[modKey]; !ok { return fmt.Errorf("role %q references unknown module %q", roleName, modKey) } } permList := make([]rbac.Permission, 0, len(perms)) for _, raw := range perms { p := rbac.Permission(raw) if p == rbac.All { permList = append(permList, p) continue } if !permissionExists(p, modKey, knownPerms) { return fmt.Errorf("role %q grants %q on module %q, but that permission is not exported by any matching module", roleName, raw, modKey) } permList = append(permList, p) } converted[modKey] = permList } roles.DefineRole(rbac.Role{Name: roleName, ModuleGrants: converted}) } // 2. Apply assignments, validating role names. for user, assigned := range f.Assignments { for _, roleName := range assigned { if !roles.RoleExists(roleName) { return fmt.Errorf("user %q assigned to unknown role %q", user, roleName) } roles.AssignRole(user, roleName) } } return nil } // permissionExists returns true if perm is exported by the named module, // or by any module when modKey is the wildcard. func permissionExists(perm rbac.Permission, modKey string, known map[string]map[rbac.Permission]bool) bool { if modKey == rbac.Wildcard { for _, perms := range known { if perms[perm] { return true } } return false } return known[modKey][perm] }