SMTP: Add host, port, username, sender, and password fields to the Authelia configuration. Uses modern address format (submission:// for STARTTLS on 587, smtps:// for implicit TLS on 465) with explicit TLS server name. Falls back to filesystem notifier when SMTP is not configured. Fixes: Config changes now persist before attempting service restart, so a restart failure (e.g. bad SMTP credentials) no longer prevents saving. The specific Authelia error is extracted from the journal and shown in the UI. Frontend: SMTP fields added to the Authentication config card. Form no longer continuously resets from server state while user is editing. OIDC client edit UI added (pencil icon).
166 lines
6.4 KiB
Go
166 lines
6.4 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
|
|
SMTP struct {
|
|
Host string `yaml:"host,omitempty" json:"host,omitempty"` // e.g. smtp.gmail.com
|
|
Port int `yaml:"port,omitempty" json:"port,omitempty"` // e.g. 587
|
|
Username string `yaml:"username,omitempty" json:"username,omitempty"` // SMTP login username
|
|
Sender string `yaml:"sender,omitempty" json:"sender,omitempty"` // From address (e.g. auth@payne.io)
|
|
} `yaml:"smtp,omitempty" json:"smtp,omitempty"`
|
|
} `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)
|
|
}
|
|
|
|
|