189 lines
5.9 KiB
Go
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]
|
|
}
|