mirror of
https://github.com/jsiebens/ionscale.git
synced 2026-03-31 15:07:49 +01:00
433 lines
10 KiB
Go
433 lines
10 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"github.com/caddyserver/certmagic"
|
|
"github.com/jsiebens/ionscale/internal/domain"
|
|
"github.com/jsiebens/ionscale/internal/key"
|
|
"github.com/jsiebens/ionscale/internal/util"
|
|
"github.com/mitchellh/go-homedir"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sigs.k8s.io/yaml"
|
|
"strings"
|
|
"tailscale.com/tailcfg"
|
|
tkey "tailscale.com/types/key"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultKeepAliveInterval = 1 * time.Minute
|
|
defaultMagicDNSSuffix = "ionscale.net"
|
|
)
|
|
|
|
var (
|
|
keepAliveInterval = defaultKeepAliveInterval
|
|
magicDNSSuffix = defaultMagicDNSSuffix
|
|
dnsProviderConfigured = false
|
|
)
|
|
|
|
func KeepAliveInterval() time.Duration {
|
|
return keepAliveInterval
|
|
}
|
|
|
|
func MagicDNSSuffix() string {
|
|
return magicDNSSuffix
|
|
}
|
|
|
|
func DNSProviderConfigured() bool {
|
|
return dnsProviderConfigured
|
|
}
|
|
|
|
func LoadConfig(path string) (*Config, error) {
|
|
cfg := defaultConfig()
|
|
|
|
if len(path) != 0 {
|
|
expandedPath, err := homedir.Expand(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
absPath, err := filepath.Abs(expandedPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
b, err := os.ReadFile(absPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
b, err = expandEnvVars(b)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := yaml.Unmarshal(b, cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
envCfgB64 := os.Getenv("IONSCALE_CONFIG_BASE64")
|
|
if len(envCfgB64) != 0 {
|
|
b, err := base64.StdEncoding.DecodeString(envCfgB64)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
b, err = expandEnvVars(b)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := yaml.Unmarshal(b, cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
keepAliveInterval = time.Duration(cfg.PollNet.KeepAliveInterval)
|
|
magicDNSSuffix = cfg.DNS.MagicDNSSuffix
|
|
|
|
if cfg.DNS.Provider.Zone != "" {
|
|
dnsProviderConfigured = true
|
|
}
|
|
|
|
return cfg.Validate()
|
|
}
|
|
|
|
func defaultConfig() *Config {
|
|
return &Config{
|
|
ListenAddr: ":8080",
|
|
MetricsListenAddr: ":9091",
|
|
StunListenAddr: ":3478",
|
|
Database: Database{
|
|
Type: "sqlite",
|
|
Url: "./ionscale.db?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)",
|
|
MaxOpenConns: 0,
|
|
MaxIdleConns: 2,
|
|
},
|
|
Tls: Tls{
|
|
Disable: false,
|
|
ForceHttps: true,
|
|
AcmeEnabled: false,
|
|
AcmeCA: certmagic.LetsEncryptProductionCA,
|
|
},
|
|
PollNet: PollNet{
|
|
KeepAliveInterval: Duration(defaultKeepAliveInterval),
|
|
},
|
|
DNS: DNS{
|
|
MagicDNSSuffix: defaultMagicDNSSuffix,
|
|
},
|
|
DERP: DERP{
|
|
Server: DERPServer{
|
|
Disabled: false,
|
|
RegionID: 1000,
|
|
RegionCode: "ionscale",
|
|
RegionName: "ionscale Embedded DERP",
|
|
},
|
|
},
|
|
Logging: Logging{
|
|
Level: "info",
|
|
},
|
|
}
|
|
}
|
|
|
|
type ServerKeys struct {
|
|
SystemAdminKey *key.ServerPrivate
|
|
ControlKey tkey.MachinePrivate
|
|
LegacyControlKey tkey.MachinePrivate
|
|
}
|
|
|
|
type Config struct {
|
|
ListenAddr string `json:"listen_addr,omitempty"`
|
|
StunListenAddr string `json:"stun_listen_addr,omitempty"`
|
|
MetricsListenAddr string `json:"metrics_listen_addr,omitempty"`
|
|
PublicAddr string `json:"public_addr,omitempty"`
|
|
StunPublicAddr string `json:"stun_public_addr,omitempty"`
|
|
Tls Tls `json:"tls,omitempty"`
|
|
PollNet PollNet `json:"poll_net,omitempty"`
|
|
Keys Keys `json:"keys,omitempty"`
|
|
Database Database `json:"database,omitempty"`
|
|
Auth Auth `json:"auth,omitempty"`
|
|
DNS DNS `json:"dns,omitempty"`
|
|
DERP DERP `json:"derp,omitempty"`
|
|
Logging Logging `json:"logging,omitempty"`
|
|
|
|
PublicUrl *url.URL `json:"-"`
|
|
|
|
stunHost string
|
|
stunPort int
|
|
derpHost string
|
|
derpPort int
|
|
}
|
|
|
|
type Tls struct {
|
|
Disable bool `json:"disable"`
|
|
ForceHttps bool `json:"force_https"`
|
|
CertFile string `json:"cert_file,omitempty"`
|
|
KeyFile string `json:"key_file,omitempty"`
|
|
AcmeEnabled bool `json:"acme,omitempty"`
|
|
AcmeEmail string `json:"acme_email,omitempty"`
|
|
AcmeCA string `json:"acme_ca,omitempty"`
|
|
}
|
|
|
|
type PollNet struct {
|
|
KeepAliveInterval Duration `json:"keep_alive_interval"`
|
|
}
|
|
|
|
type Logging struct {
|
|
Level string `json:"level,omitempty"`
|
|
Format string `json:"format,omitempty"`
|
|
File string `json:"file,omitempty"`
|
|
}
|
|
|
|
type Database struct {
|
|
Type string `json:"type,omitempty"`
|
|
Url string `json:"url,omitempty"`
|
|
MaxOpenConns int `json:"max_open_conns,omitempty"`
|
|
MaxIdleConns int `json:"max_idle_conns,omitempty"`
|
|
ConnMaxLifetime Duration `json:"conn_max_life_time,omitempty"`
|
|
ConnMaxIdleTime Duration `json:"conn_max_idle_time,omitempty"`
|
|
}
|
|
|
|
type Keys struct {
|
|
ControlKey string `json:"control_key,omitempty"`
|
|
LegacyControlKey string `json:"legacy_control_key,omitempty"`
|
|
SystemAdminKey string `json:"system_admin_key,omitempty"`
|
|
}
|
|
|
|
type Auth struct {
|
|
Provider AuthProvider `json:"provider,omitempty"`
|
|
SystemAdminPolicy SystemAdminPolicy `json:"system_admins"`
|
|
}
|
|
|
|
type AuthProvider struct {
|
|
Issuer string `json:"issuer"`
|
|
ClientID string `json:"client_id"`
|
|
ClientSecret string `json:"client_secret"`
|
|
Scopes []string `json:"additional_scopes" `
|
|
}
|
|
|
|
type DNS struct {
|
|
MagicDNSSuffix string `json:"magic_dns_suffix"`
|
|
Provider DNSProvider `json:"provider,omitempty"`
|
|
}
|
|
|
|
type DNSProvider struct {
|
|
Name string `json:"name"`
|
|
PluginPath string `json:"plugin_path"`
|
|
Zone string `json:"zone"`
|
|
Configuration json.RawMessage `json:"config"`
|
|
}
|
|
|
|
type SystemAdminPolicy struct {
|
|
Subs []string `json:"subs,omitempty"`
|
|
Emails []string `json:"emails,omitempty"`
|
|
Filters []string `json:"filters,omitempty"`
|
|
}
|
|
|
|
type DERP struct {
|
|
Server DERPServer `json:"server,omitempty"`
|
|
Sources []string `json:"sources,omitempty"`
|
|
}
|
|
|
|
type DERPServer struct {
|
|
Disabled bool `json:"disabled,omitempty"`
|
|
RegionID int `json:"region_id,omitempty"`
|
|
RegionCode string `json:"region_code,omitempty"`
|
|
RegionName string `json:"region_name,omitempty"`
|
|
}
|
|
|
|
func (c *Config) Validate() (*Config, error) {
|
|
publicWebUrl, webHost, webPort, err := validatePublicAddr(c.PublicAddr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("web public addr: %w", err)
|
|
}
|
|
|
|
c.PublicUrl = publicWebUrl
|
|
c.derpHost = webHost
|
|
c.derpPort = webPort
|
|
|
|
if !c.DERP.Server.Disabled {
|
|
_, stunHost, stunPort, err := validatePublicAddr(c.StunPublicAddr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stun public addr: %w", err)
|
|
}
|
|
|
|
c.stunHost = stunHost
|
|
c.stunPort = stunPort
|
|
}
|
|
|
|
return c, nil
|
|
}
|
|
|
|
func (c *Config) CreateUrl(format string, a ...interface{}) string {
|
|
path := fmt.Sprintf(format, a...)
|
|
u := url.URL{
|
|
Scheme: c.PublicUrl.Scheme,
|
|
Host: c.PublicUrl.Host,
|
|
Path: path,
|
|
}
|
|
return u.String()
|
|
}
|
|
|
|
func (c *Config) ReadServerKeys(defaultKeys *domain.ControlKeys) (*ServerKeys, error) {
|
|
keys := &ServerKeys{
|
|
ControlKey: defaultKeys.ControlKey,
|
|
LegacyControlKey: defaultKeys.LegacyControlKey,
|
|
}
|
|
|
|
if len(c.Keys.SystemAdminKey) != 0 {
|
|
systemAdminKey, err := key.ParsePrivateKey(c.Keys.SystemAdminKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error reading system admin key: %v", err)
|
|
}
|
|
|
|
keys.SystemAdminKey = systemAdminKey
|
|
}
|
|
|
|
if len(c.Keys.ControlKey) != 0 {
|
|
controlKey, err := util.ParseMachinePrivateKey(c.Keys.ControlKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error reading control key: %v", err)
|
|
}
|
|
keys.ControlKey = *controlKey
|
|
}
|
|
|
|
if len(c.Keys.LegacyControlKey) != 0 {
|
|
legacyControlKey, err := util.ParseMachinePrivateKey(c.Keys.LegacyControlKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error reading legacy control key: %v", err)
|
|
}
|
|
keys.LegacyControlKey = *legacyControlKey
|
|
}
|
|
|
|
return keys, nil
|
|
}
|
|
|
|
func (c *Config) DefaultDERPMap() *tailcfg.DERPMap {
|
|
if c.derpHost == c.stunHost {
|
|
return &tailcfg.DERPMap{
|
|
Regions: map[int]*tailcfg.DERPRegion{
|
|
c.DERP.Server.RegionID: {
|
|
RegionID: c.DERP.Server.RegionID,
|
|
RegionCode: c.DERP.Server.RegionCode,
|
|
RegionName: c.DERP.Server.RegionName,
|
|
Nodes: []*tailcfg.DERPNode{
|
|
{
|
|
RegionID: c.DERP.Server.RegionID,
|
|
Name: "ionscale",
|
|
HostName: c.derpHost,
|
|
DERPPort: c.derpPort,
|
|
STUNPort: c.stunPort,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
return &tailcfg.DERPMap{
|
|
Regions: map[int]*tailcfg.DERPRegion{
|
|
c.DERP.Server.RegionID: {
|
|
RegionID: c.DERP.Server.RegionID,
|
|
RegionCode: c.DERP.Server.RegionCode,
|
|
RegionName: c.DERP.Server.RegionName,
|
|
Nodes: []*tailcfg.DERPNode{
|
|
{
|
|
RegionID: c.DERP.Server.RegionID,
|
|
Name: "stun",
|
|
HostName: c.stunHost,
|
|
STUNOnly: true,
|
|
STUNPort: c.stunPort,
|
|
},
|
|
{
|
|
RegionID: c.DERP.Server.RegionID,
|
|
Name: "derp",
|
|
HostName: c.derpHost,
|
|
DERPPort: c.derpPort,
|
|
STUNPort: -1,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
type Duration time.Duration
|
|
|
|
func (d Duration) Std() time.Duration {
|
|
return time.Duration(d)
|
|
}
|
|
|
|
func (d Duration) MarshalJSON() ([]byte, error) {
|
|
return json.Marshal(time.Duration(d).String())
|
|
}
|
|
|
|
func (d *Duration) UnmarshalJSON(b []byte) error {
|
|
var v interface{}
|
|
if err := json.Unmarshal(b, &v); err != nil {
|
|
return err
|
|
}
|
|
switch value := v.(type) {
|
|
case float64:
|
|
*d = Duration(value)
|
|
return nil
|
|
case string:
|
|
tmp, err := time.ParseDuration(value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*d = Duration(tmp)
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("invalid duration")
|
|
}
|
|
}
|
|
|
|
// Match ${VAR:default} syntax for variables with default values
|
|
var optionalEnvRegex = regexp.MustCompile(`\${([a-zA-Z0-9_]+):([^}]*)}`)
|
|
|
|
// Match ${VAR} syntax (without default) - these are required
|
|
var requiredEnvRegex = regexp.MustCompile(`\${([a-zA-Z0-9_]+)}`)
|
|
|
|
func expandEnvVars(config []byte) ([]byte, error) {
|
|
var result = config
|
|
var missingVars []string
|
|
|
|
result = optionalEnvRegex.ReplaceAllFunc(result, func(match []byte) []byte {
|
|
parts := optionalEnvRegex.FindSubmatch(match)
|
|
envVar := string(parts[1])
|
|
defaultValue := parts[2]
|
|
|
|
envValue := os.Getenv(envVar)
|
|
if envValue != "" {
|
|
return []byte(envValue)
|
|
}
|
|
return defaultValue
|
|
})
|
|
|
|
result = requiredEnvRegex.ReplaceAllFunc(result, func(match []byte) []byte {
|
|
parts := requiredEnvRegex.FindSubmatch(match)
|
|
envVar := string(parts[1])
|
|
envValue := os.Getenv(envVar)
|
|
|
|
if envValue == "" {
|
|
missingVars = append(missingVars, envVar)
|
|
return match
|
|
}
|
|
|
|
return []byte(envValue)
|
|
})
|
|
|
|
if len(missingVars) > 0 {
|
|
return nil, fmt.Errorf("missing required environment variables: %s", strings.Join(missingVars, ", "))
|
|
}
|
|
|
|
return result, nil
|
|
}
|