Files
nadir-agent/internal/config/config.go
T
urania 0e041fac5e
build-and-release / release (push) Failing after 2m1s
fix: .minisign for signed releases
2026-06-22 20:03:27 +02:00

189 lines
5.9 KiB
Go

package config
import (
"fmt"
"net/url"
"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)
}
// release_repo, when set, is downloaded over the wire and (for /api/update)
// executed. Reject http:// at the boundary so /install.sh and the updater
// never have to re-check.
if f.Server.ReleaseRepo != "" {
u, err := url.Parse(f.Server.ReleaseRepo)
if err != nil || u.Scheme != "https" {
return nil, fmt.Errorf("server.release_repo must use https:// (got %q)", f.Server.ReleaseRepo)
}
}
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]
}