Files
wild-central/internal/config/config.go
Paul Payne d796a79f79 Add Authelia as centralized authentication and OIDC provider
Authelia runs as a managed native service on Wild Central, providing
two integration patterns for network services:

- Forward-auth via HAProxy for apps without native SSO (Lua auth-request
  script intercepts requests, redirects unauthenticated users to login portal)
- OIDC provider for apps with native support (Gitea, Grafana, etc.)

Backend: new internal/authelia/ package with service manager, config
generation, file-based user management (argon2id), and OIDC client
management. API endpoints for status, config, users CRUD, and OIDC
clients CRUD.

HAProxy: config generator extended with lua-load, auth-request
directives, and Authelia backend. Auth directives scoped to session
cookie domain — only subdomains of the auth portal's parent are
eligible for forward-auth.

Frontend: Authentication page with enable/disable, user management,
OIDC client management (add/edit/delete), and protected domains
toggles. Advanced subsystem page for raw config view. Dashboard
service card and sidebar entries.
2026-07-12 11:47:37 +00:00

160 lines
5.9 KiB
Go

package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// ExtraPort is a TCP or UDP port that the firewall should allow on the WAN interface.
type ExtraPort struct {
Port int `yaml:"port" json:"port"`
Protocol string `yaml:"protocol,omitempty" json:"protocol,omitempty"` // "tcp" (default) or "udp"
Label string `yaml:"label,omitempty" json:"label,omitempty"`
}
// UnmarshalYAML handles both plain integers (legacy: extraPorts: [22]) and structured entries
// (extraPorts: [{port: 22, protocol: tcp, label: SSH}]).
func (p *ExtraPort) UnmarshalYAML(value *yaml.Node) error {
if value.Kind == yaml.ScalarNode {
var port int
if err := value.Decode(&port); err != nil {
return fmt.Errorf("extraPort: expected integer, got %q", value.Value)
}
p.Port = port
return nil
}
type alias ExtraPort
return value.Decode((*alias)(p))
}
// UnmarshalJSON handles both plain integers (legacy: extraPorts: [22]) and structured entries.
func (p *ExtraPort) UnmarshalJSON(data []byte) error {
if len(data) > 0 && data[0] != '{' {
var port int
if err := json.Unmarshal(data, &port); err != nil {
return fmt.Errorf("extraPort: expected integer or object, got: %s", data)
}
p.Port = port
return nil
}
type alias ExtraPort
var a alias
if err := json.Unmarshal(data, &a); err != nil {
return err
}
*p = ExtraPort(a)
return nil
}
// SplitExtraPorts separates ExtraPorts into TCP and UDP slices.
// Ports with no Protocol or Protocol "tcp" are TCP; Protocol "udp" are UDP.
func SplitExtraPorts(ports []ExtraPort) (tcp []int, udp []int) {
for _, p := range ports {
if strings.EqualFold(p.Protocol, "udp") {
udp = append(udp, p.Port)
} else {
tcp = append(tcp, p.Port)
}
}
return
}
// HAProxyCustomRoute defines a custom TCP proxy rule for non-Wild Cloud services
type HAProxyCustomRoute struct {
Name string `yaml:"name" json:"name"`
Port int `yaml:"port" json:"port"`
Backend string `yaml:"backend" json:"backend"`
}
// DHCPStaticLease defines a static DHCP address assignment
type DHCPStaticLease struct {
MAC string `yaml:"mac" json:"mac"`
IP string `yaml:"ip" json:"ip"`
Hostname string `yaml:"hostname,omitempty" json:"hostname,omitempty"`
}
// State represents Wild Central's runtime state — operator settings, networking
// configuration, DDNS, DHCP, etc. Persisted in {dataDir}/state.yaml and mutated
// via the API. This is NOT boot-time config (which comes from env vars).
type State struct {
Operator struct {
Email string `yaml:"email,omitempty" json:"email,omitempty"`
} `yaml:"operator,omitempty" json:"operator,omitempty"`
Cloud struct {
Central struct {
Domain string `yaml:"domain,omitempty" json:"domain,omitempty"` // e.g. "central.payne.io"
} `yaml:"central,omitempty" json:"central,omitempty"`
Dnsmasq struct {
IP string `yaml:"ip,omitempty" json:"ip,omitempty"`
Interface string `yaml:"interface,omitempty" json:"interface,omitempty"`
DHCP struct {
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
RangeStart string `yaml:"rangeStart,omitempty" json:"rangeStart,omitempty"`
RangeEnd string `yaml:"rangeEnd,omitempty" json:"rangeEnd,omitempty"`
LeaseTime string `yaml:"leaseTime,omitempty" json:"leaseTime,omitempty"`
Gateway string `yaml:"gateway,omitempty" json:"gateway,omitempty"`
StaticLeases []DHCPStaticLease `yaml:"staticLeases,omitempty" json:"staticLeases,omitempty"`
} `yaml:"dhcp,omitempty" json:"dhcp,omitempty"`
} `yaml:"dnsmasq,omitempty" json:"dnsmasq,omitempty"`
HAProxy struct {
CustomRoutes []HAProxyCustomRoute `yaml:"customRoutes,omitempty" json:"customRoutes,omitempty"`
} `yaml:"haproxy,omitempty" json:"haproxy,omitempty"`
Nftables struct {
Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
WANInterface string `yaml:"wanInterface,omitempty" json:"wanInterface,omitempty"`
ExtraPorts []ExtraPort `yaml:"extraPorts,omitempty" json:"extraPorts,omitempty"`
} `yaml:"nftables,omitempty" json:"nftables,omitempty"`
DDNS struct {
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"`
IntervalMinutes int `yaml:"intervalMinutes,omitempty" json:"intervalMinutes,omitempty"`
} `yaml:"ddns,omitempty" json:"ddns,omitempty"`
Authelia struct {
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
Domain string `yaml:"domain,omitempty" json:"domain,omitempty"` // login portal domain (e.g. auth.payne.io)
DefaultPolicy string `yaml:"defaultPolicy,omitempty" json:"defaultPolicy,omitempty"` // one_factor or two_factor
} `yaml:"authelia,omitempty" json:"authelia,omitempty"`
DNSFilter struct {
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
IntervalHours int `yaml:"intervalHours,omitempty" json:"intervalHours,omitempty"` // update interval, default 24
} `yaml:"dnsFilter,omitempty" json:"dnsFilter,omitempty"`
} `yaml:"cloud,omitempty" json:"cloud,omitempty"`
}
// LoadState loads state from the specified path
func LoadState(configPath string) (*State, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("reading config file %s: %w", configPath, err)
}
config := &State{}
if err := yaml.Unmarshal(data, config); err != nil {
return nil, fmt.Errorf("parsing config file: %w", err)
}
return config, nil
}
// SaveState saves the state to the specified path
func SaveState(config *State, configPath string) error {
// Ensure the directory exists
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
return fmt.Errorf("creating config directory: %w", err)
}
data, err := yaml.Marshal(config)
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
return os.WriteFile(configPath, data, 0644)
}