Restructure modules (#1572)
* Move portbase into monorepo * Add new simple module mgr * [WIP] Switch to new simple module mgr * Add StateMgr and more worker variants * [WIP] Switch more modules * [WIP] Switch more modules * [WIP] swtich more modules * [WIP] switch all SPN modules * [WIP] switch all service modules * [WIP] Convert all workers to the new module system * [WIP] add new task system to module manager * [WIP] Add second take for scheduling workers * [WIP] Add FIXME for bugs in new scheduler * [WIP] Add minor improvements to scheduler * [WIP] Add new worker scheduler * [WIP] Fix more bug related to new module system * [WIP] Fix start handing of the new module system * [WIP] Improve startup process * [WIP] Fix minor issues * [WIP] Fix missing subsystem in settings * [WIP] Initialize managers in constructor * [WIP] Move module event initialization to constrictors * [WIP] Fix setting for enabling and disabling the SPN module * [WIP] Move API registeration into module construction * [WIP] Update states mgr for all modules * [WIP] Add CmdLine operation support * Add state helper methods to module group and instance * Add notification and module status handling to status package * Fix starting issues * Remove pilot widget and update security lock to new status data * Remove debug logs * Improve http server shutdown * Add workaround for cleanly shutting down firewall+netquery * Improve logging * Add syncing states with notifications for new module system * Improve starting, stopping, shutdown; resolve FIXMEs/TODOs * [WIP] Fix most unit tests * Review new module system and fix minor issues * Push shutdown and restart events again via API * Set sleep mode via interface * Update example/template module * [WIP] Fix spn/cabin unit test * Remove deprecated UI elements * Make log output more similar for the logging transition phase * Switch spn hub and observer cmds to new module system * Fix log sources * Make worker mgr less error prone * Fix tests and minor issues * Fix observation hub * Improve shutdown and restart handling * Split up big connection.go source file * Move varint and dsd packages to structures repo * Improve expansion test * Fix linter warnings * Fix interception module on windows * Fix linter errors --------- Co-authored-by: Vladimir Stoilov <vladimir@safing.io>
This commit is contained in:
106
base/config/basic_config.go
Normal file
106
base/config/basic_config.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
|
||||
"github.com/safing/portmaster/base/log"
|
||||
"github.com/safing/portmaster/service/mgr"
|
||||
)
|
||||
|
||||
// Configuration Keys.
|
||||
var (
|
||||
CfgDevModeKey = "core/devMode"
|
||||
defaultDevMode bool
|
||||
|
||||
CfgLogLevel = "core/log/level"
|
||||
defaultLogLevel = log.InfoLevel.String()
|
||||
logLevel StringOption
|
||||
)
|
||||
|
||||
func init() {
|
||||
flag.BoolVar(&defaultDevMode, "devmode", false, "enable development mode; configuration is stronger")
|
||||
}
|
||||
|
||||
func registerBasicOptions() error {
|
||||
// Get the default log level from the log package.
|
||||
defaultLogLevel = log.GetLogLevel().Name()
|
||||
|
||||
// Register logging setting.
|
||||
// The log package cannot do that, as it would trigger and import loop.
|
||||
if err := Register(&Option{
|
||||
Name: "Log Level",
|
||||
Key: CfgLogLevel,
|
||||
Description: "Configure the logging level.",
|
||||
OptType: OptTypeString,
|
||||
ExpertiseLevel: ExpertiseLevelDeveloper,
|
||||
ReleaseLevel: ReleaseLevelStable,
|
||||
DefaultValue: defaultLogLevel,
|
||||
Annotations: Annotations{
|
||||
DisplayOrderAnnotation: 513,
|
||||
DisplayHintAnnotation: DisplayHintOneOf,
|
||||
CategoryAnnotation: "Development",
|
||||
},
|
||||
PossibleValues: []PossibleValue{
|
||||
{
|
||||
Name: "Critical",
|
||||
Value: "critical",
|
||||
Description: "The critical level only logs errors that lead to a partial, but imminent failure.",
|
||||
},
|
||||
{
|
||||
Name: "Error",
|
||||
Value: "error",
|
||||
Description: "The error level logs errors that potentially break functionality. Everything logged by the critical level is included here too.",
|
||||
},
|
||||
{
|
||||
Name: "Warning",
|
||||
Value: "warning",
|
||||
Description: "The warning level logs minor errors and worse. Everything logged by the error level is included here too.",
|
||||
},
|
||||
{
|
||||
Name: "Info",
|
||||
Value: "info",
|
||||
Description: "The info level logs the main events that are going on and are interesting to the user. Everything logged by the warning level is included here too.",
|
||||
},
|
||||
{
|
||||
Name: "Debug",
|
||||
Value: "debug",
|
||||
Description: "The debug level logs some additional debugging details. Everything logged by the info level is included here too.",
|
||||
},
|
||||
{
|
||||
Name: "Trace",
|
||||
Value: "trace",
|
||||
Description: "The trace level logs loads of detailed information as well as operation and request traces. Everything logged by the debug level is included here too.",
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
logLevel = GetAsString(CfgLogLevel, defaultLogLevel)
|
||||
|
||||
// Register to hook to update the log level.
|
||||
module.EventConfigChange.AddCallback("update log level", setLogLevel)
|
||||
|
||||
return Register(&Option{
|
||||
Name: "Development Mode",
|
||||
Key: CfgDevModeKey,
|
||||
Description: "In Development Mode, security restrictions are lifted/softened to enable unrestricted access for debugging and testing purposes.",
|
||||
OptType: OptTypeBool,
|
||||
ExpertiseLevel: ExpertiseLevelDeveloper,
|
||||
ReleaseLevel: ReleaseLevelStable,
|
||||
DefaultValue: defaultDevMode,
|
||||
Annotations: Annotations{
|
||||
DisplayOrderAnnotation: 512,
|
||||
CategoryAnnotation: "Development",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func loadLogLevel() error {
|
||||
return setDefaultConfigOption(CfgLogLevel, log.GetLogLevel().Name(), false)
|
||||
}
|
||||
|
||||
func setLogLevel(_ *mgr.WorkerCtx, _ struct{}) (cancel bool, err error) {
|
||||
log.SetLogLevel(log.ParseLevel(logLevel()))
|
||||
|
||||
return false, nil
|
||||
}
|
||||
169
base/config/database.go
Normal file
169
base/config/database.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/safing/portmaster/base/database"
|
||||
"github.com/safing/portmaster/base/database/iterator"
|
||||
"github.com/safing/portmaster/base/database/query"
|
||||
"github.com/safing/portmaster/base/database/record"
|
||||
"github.com/safing/portmaster/base/database/storage"
|
||||
"github.com/safing/portmaster/base/log"
|
||||
)
|
||||
|
||||
var dbController *database.Controller
|
||||
|
||||
// StorageInterface provices a storage.Interface to the configuration manager.
|
||||
type StorageInterface struct {
|
||||
storage.InjectBase
|
||||
}
|
||||
|
||||
// Get returns a database record.
|
||||
func (s *StorageInterface) Get(key string) (record.Record, error) {
|
||||
opt, err := GetOption(key)
|
||||
if err != nil {
|
||||
return nil, storage.ErrNotFound
|
||||
}
|
||||
|
||||
return opt.Export()
|
||||
}
|
||||
|
||||
// Put stores a record in the database.
|
||||
func (s *StorageInterface) Put(r record.Record) (record.Record, error) {
|
||||
if r.Meta().Deleted > 0 {
|
||||
return r, setConfigOption(r.DatabaseKey(), nil, false)
|
||||
}
|
||||
|
||||
acc := r.GetAccessor(r)
|
||||
if acc == nil {
|
||||
return nil, errors.New("invalid data")
|
||||
}
|
||||
|
||||
val, ok := acc.Get("Value")
|
||||
if !ok || val == nil {
|
||||
err := setConfigOption(r.DatabaseKey(), nil, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Get(r.DatabaseKey())
|
||||
}
|
||||
|
||||
option, err := GetOption(r.DatabaseKey())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var value interface{}
|
||||
switch option.OptType {
|
||||
case OptTypeString:
|
||||
value, ok = acc.GetString("Value")
|
||||
case OptTypeStringArray:
|
||||
value, ok = acc.GetStringArray("Value")
|
||||
case OptTypeInt:
|
||||
value, ok = acc.GetInt("Value")
|
||||
case OptTypeBool:
|
||||
value, ok = acc.GetBool("Value")
|
||||
case optTypeAny:
|
||||
ok = false
|
||||
}
|
||||
if !ok {
|
||||
return nil, errors.New("received invalid value in \"Value\"")
|
||||
}
|
||||
|
||||
if err := setConfigOption(r.DatabaseKey(), value, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return option.Export()
|
||||
}
|
||||
|
||||
// Delete deletes a record from the database.
|
||||
func (s *StorageInterface) Delete(key string) error {
|
||||
return setConfigOption(key, nil, false)
|
||||
}
|
||||
|
||||
// Query returns a an iterator for the supplied query.
|
||||
func (s *StorageInterface) Query(q *query.Query, local, internal bool) (*iterator.Iterator, error) {
|
||||
optionsLock.RLock()
|
||||
defer optionsLock.RUnlock()
|
||||
|
||||
it := iterator.New()
|
||||
var opts []*Option
|
||||
for _, opt := range options {
|
||||
if strings.HasPrefix(opt.Key, q.DatabaseKeyPrefix()) {
|
||||
opts = append(opts, opt)
|
||||
}
|
||||
}
|
||||
|
||||
go s.processQuery(it, opts)
|
||||
|
||||
return it, nil
|
||||
}
|
||||
|
||||
func (s *StorageInterface) processQuery(it *iterator.Iterator, opts []*Option) {
|
||||
sort.Sort(sortByKey(opts))
|
||||
|
||||
for _, opt := range opts {
|
||||
r, err := opt.Export()
|
||||
if err != nil {
|
||||
it.Finish(err)
|
||||
return
|
||||
}
|
||||
it.Next <- r
|
||||
}
|
||||
|
||||
it.Finish(nil)
|
||||
}
|
||||
|
||||
// ReadOnly returns whether the database is read only.
|
||||
func (s *StorageInterface) ReadOnly() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func registerAsDatabase() error {
|
||||
_, err := database.Register(&database.Database{
|
||||
Name: "config",
|
||||
Description: "Configuration Manager",
|
||||
StorageType: "injected",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
controller, err := database.InjectDatabase("config", &StorageInterface{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbController = controller
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleOptionUpdate updates the expertise and release level options,
|
||||
// if required, and eventually pushes a update for the option.
|
||||
// The caller must hold the option lock.
|
||||
func handleOptionUpdate(option *Option, push bool) {
|
||||
if expertiseLevelOptionFlag.IsSet() && option == expertiseLevelOption {
|
||||
updateExpertiseLevel()
|
||||
}
|
||||
|
||||
if releaseLevelOptionFlag.IsSet() && option == releaseLevelOption {
|
||||
updateReleaseLevel()
|
||||
}
|
||||
|
||||
if push {
|
||||
pushUpdate(option)
|
||||
}
|
||||
}
|
||||
|
||||
// pushUpdate pushes an database update notification for option.
|
||||
// The caller must hold the option lock.
|
||||
func pushUpdate(option *Option) {
|
||||
r, err := option.export()
|
||||
if err != nil {
|
||||
log.Errorf("failed to export option to push update: %s", err)
|
||||
} else {
|
||||
dbController.PushUpdate(r)
|
||||
}
|
||||
}
|
||||
2
base/config/doc.go
Normal file
2
base/config/doc.go
Normal file
@@ -0,0 +1,2 @@
|
||||
// Package config provides a versatile configuration management system.
|
||||
package config
|
||||
104
base/config/expertise.go
Normal file
104
base/config/expertise.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/tevino/abool"
|
||||
)
|
||||
|
||||
// ExpertiseLevel allows to group settings by user expertise.
|
||||
// It's useful if complex or technical settings should be hidden
|
||||
// from the average user while still allowing experts and developers
|
||||
// to change deep configuration settings.
|
||||
type ExpertiseLevel uint8
|
||||
|
||||
// Expertise Level constants.
|
||||
const (
|
||||
ExpertiseLevelUser ExpertiseLevel = 0
|
||||
ExpertiseLevelExpert ExpertiseLevel = 1
|
||||
ExpertiseLevelDeveloper ExpertiseLevel = 2
|
||||
|
||||
ExpertiseLevelNameUser = "user"
|
||||
ExpertiseLevelNameExpert = "expert"
|
||||
ExpertiseLevelNameDeveloper = "developer"
|
||||
|
||||
expertiseLevelKey = "core/expertiseLevel"
|
||||
)
|
||||
|
||||
var (
|
||||
expertiseLevelOption *Option
|
||||
expertiseLevel = new(int32)
|
||||
expertiseLevelOptionFlag = abool.New()
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerExpertiseLevelOption()
|
||||
}
|
||||
|
||||
func registerExpertiseLevelOption() {
|
||||
expertiseLevelOption = &Option{
|
||||
Name: "UI Mode",
|
||||
Key: expertiseLevelKey,
|
||||
Description: "Control the default amount of settings and information shown. Hidden settings are still in effect. Can be changed temporarily in the top right corner.",
|
||||
OptType: OptTypeString,
|
||||
ExpertiseLevel: ExpertiseLevelUser,
|
||||
ReleaseLevel: ReleaseLevelStable,
|
||||
DefaultValue: ExpertiseLevelNameUser,
|
||||
Annotations: Annotations{
|
||||
DisplayOrderAnnotation: -16,
|
||||
DisplayHintAnnotation: DisplayHintOneOf,
|
||||
CategoryAnnotation: "User Interface",
|
||||
},
|
||||
PossibleValues: []PossibleValue{
|
||||
{
|
||||
Name: "Simple Interface",
|
||||
Value: ExpertiseLevelNameUser,
|
||||
Description: "Hide complex settings and information.",
|
||||
},
|
||||
{
|
||||
Name: "Advanced Interface",
|
||||
Value: ExpertiseLevelNameExpert,
|
||||
Description: "Show technical details.",
|
||||
},
|
||||
{
|
||||
Name: "Developer Interface",
|
||||
Value: ExpertiseLevelNameDeveloper,
|
||||
Description: "Developer mode. Please be careful!",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := Register(expertiseLevelOption)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
expertiseLevelOptionFlag.Set()
|
||||
}
|
||||
|
||||
func updateExpertiseLevel() {
|
||||
// get value
|
||||
value := expertiseLevelOption.activeFallbackValue
|
||||
if expertiseLevelOption.activeValue != nil {
|
||||
value = expertiseLevelOption.activeValue
|
||||
}
|
||||
if expertiseLevelOption.activeDefaultValue != nil {
|
||||
value = expertiseLevelOption.activeDefaultValue
|
||||
}
|
||||
// set atomic value
|
||||
switch value.stringVal {
|
||||
case ExpertiseLevelNameUser:
|
||||
atomic.StoreInt32(expertiseLevel, int32(ExpertiseLevelUser))
|
||||
case ExpertiseLevelNameExpert:
|
||||
atomic.StoreInt32(expertiseLevel, int32(ExpertiseLevelExpert))
|
||||
case ExpertiseLevelNameDeveloper:
|
||||
atomic.StoreInt32(expertiseLevel, int32(ExpertiseLevelDeveloper))
|
||||
default:
|
||||
atomic.StoreInt32(expertiseLevel, int32(ExpertiseLevelUser))
|
||||
}
|
||||
}
|
||||
|
||||
// GetExpertiseLevel returns the current active expertise level.
|
||||
func GetExpertiseLevel() uint8 {
|
||||
return uint8(atomic.LoadInt32(expertiseLevel))
|
||||
}
|
||||
112
base/config/get-safe.go
Normal file
112
base/config/get-safe.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package config
|
||||
|
||||
import "sync"
|
||||
|
||||
type safe struct{}
|
||||
|
||||
// Concurrent makes concurrency safe get methods available.
|
||||
var Concurrent = &safe{}
|
||||
|
||||
// GetAsString returns a function that returns the wanted string with high performance.
|
||||
func (cs *safe) GetAsString(name string, fallback string) StringOption {
|
||||
valid := getValidityFlag()
|
||||
option, valueCache := getValueCache(name, nil, OptTypeString)
|
||||
value := fallback
|
||||
if valueCache != nil {
|
||||
value = valueCache.stringVal
|
||||
}
|
||||
var lock sync.Mutex
|
||||
|
||||
return func() string {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
if !valid.IsSet() {
|
||||
valid = getValidityFlag()
|
||||
option, valueCache = getValueCache(name, option, OptTypeString)
|
||||
if valueCache != nil {
|
||||
value = valueCache.stringVal
|
||||
} else {
|
||||
value = fallback
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// GetAsStringArray returns a function that returns the wanted string with high performance.
|
||||
func (cs *safe) GetAsStringArray(name string, fallback []string) StringArrayOption {
|
||||
valid := getValidityFlag()
|
||||
option, valueCache := getValueCache(name, nil, OptTypeStringArray)
|
||||
value := fallback
|
||||
if valueCache != nil {
|
||||
value = valueCache.stringArrayVal
|
||||
}
|
||||
var lock sync.Mutex
|
||||
|
||||
return func() []string {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
if !valid.IsSet() {
|
||||
valid = getValidityFlag()
|
||||
option, valueCache = getValueCache(name, option, OptTypeStringArray)
|
||||
if valueCache != nil {
|
||||
value = valueCache.stringArrayVal
|
||||
} else {
|
||||
value = fallback
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// GetAsInt returns a function that returns the wanted int with high performance.
|
||||
func (cs *safe) GetAsInt(name string, fallback int64) IntOption {
|
||||
valid := getValidityFlag()
|
||||
option, valueCache := getValueCache(name, nil, OptTypeInt)
|
||||
value := fallback
|
||||
if valueCache != nil {
|
||||
value = valueCache.intVal
|
||||
}
|
||||
var lock sync.Mutex
|
||||
|
||||
return func() int64 {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
if !valid.IsSet() {
|
||||
valid = getValidityFlag()
|
||||
option, valueCache = getValueCache(name, option, OptTypeInt)
|
||||
if valueCache != nil {
|
||||
value = valueCache.intVal
|
||||
} else {
|
||||
value = fallback
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// GetAsBool returns a function that returns the wanted int with high performance.
|
||||
func (cs *safe) GetAsBool(name string, fallback bool) BoolOption {
|
||||
valid := getValidityFlag()
|
||||
option, valueCache := getValueCache(name, nil, OptTypeBool)
|
||||
value := fallback
|
||||
if valueCache != nil {
|
||||
value = valueCache.boolVal
|
||||
}
|
||||
var lock sync.Mutex
|
||||
|
||||
return func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
if !valid.IsSet() {
|
||||
valid = getValidityFlag()
|
||||
option, valueCache = getValueCache(name, option, OptTypeBool)
|
||||
if valueCache != nil {
|
||||
value = valueCache.boolVal
|
||||
} else {
|
||||
value = fallback
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
174
base/config/get.go
Normal file
174
base/config/get.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/safing/portmaster/base/log"
|
||||
)
|
||||
|
||||
type (
|
||||
// StringOption defines the returned function by GetAsString.
|
||||
StringOption func() string
|
||||
// StringArrayOption defines the returned function by GetAsStringArray.
|
||||
StringArrayOption func() []string
|
||||
// IntOption defines the returned function by GetAsInt.
|
||||
IntOption func() int64
|
||||
// BoolOption defines the returned function by GetAsBool.
|
||||
BoolOption func() bool
|
||||
)
|
||||
|
||||
func getValueCache(name string, option *Option, requestedType OptionType) (*Option, *valueCache) {
|
||||
// get option
|
||||
if option == nil {
|
||||
var err error
|
||||
option, err = GetOption(name)
|
||||
if err != nil {
|
||||
log.Errorf("config: request for unregistered option: %s", name)
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check the option type, no locking required as
|
||||
// OptType is immutable once it is set
|
||||
if requestedType != option.OptType {
|
||||
log.Errorf("config: bad type: requested %s as %s, but is %s", name, getTypeName(requestedType), getTypeName(option.OptType))
|
||||
return option, nil
|
||||
}
|
||||
|
||||
option.Lock()
|
||||
defer option.Unlock()
|
||||
|
||||
// check release level
|
||||
if option.ReleaseLevel <= getReleaseLevel() && option.activeValue != nil {
|
||||
return option, option.activeValue
|
||||
}
|
||||
|
||||
if option.activeDefaultValue != nil {
|
||||
return option, option.activeDefaultValue
|
||||
}
|
||||
|
||||
return option, option.activeFallbackValue
|
||||
}
|
||||
|
||||
// GetAsString returns a function that returns the wanted string with high performance.
|
||||
func GetAsString(name string, fallback string) StringOption {
|
||||
valid := getValidityFlag()
|
||||
option, valueCache := getValueCache(name, nil, OptTypeString)
|
||||
value := fallback
|
||||
if valueCache != nil {
|
||||
value = valueCache.stringVal
|
||||
}
|
||||
|
||||
return func() string {
|
||||
if !valid.IsSet() {
|
||||
valid = getValidityFlag()
|
||||
option, valueCache = getValueCache(name, option, OptTypeString)
|
||||
if valueCache != nil {
|
||||
value = valueCache.stringVal
|
||||
} else {
|
||||
value = fallback
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// GetAsStringArray returns a function that returns the wanted string with high performance.
|
||||
func GetAsStringArray(name string, fallback []string) StringArrayOption {
|
||||
valid := getValidityFlag()
|
||||
option, valueCache := getValueCache(name, nil, OptTypeStringArray)
|
||||
value := fallback
|
||||
if valueCache != nil {
|
||||
value = valueCache.stringArrayVal
|
||||
}
|
||||
|
||||
return func() []string {
|
||||
if !valid.IsSet() {
|
||||
valid = getValidityFlag()
|
||||
option, valueCache = getValueCache(name, option, OptTypeStringArray)
|
||||
if valueCache != nil {
|
||||
value = valueCache.stringArrayVal
|
||||
} else {
|
||||
value = fallback
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// GetAsInt returns a function that returns the wanted int with high performance.
|
||||
func GetAsInt(name string, fallback int64) IntOption {
|
||||
valid := getValidityFlag()
|
||||
option, valueCache := getValueCache(name, nil, OptTypeInt)
|
||||
value := fallback
|
||||
if valueCache != nil {
|
||||
value = valueCache.intVal
|
||||
}
|
||||
|
||||
return func() int64 {
|
||||
if !valid.IsSet() {
|
||||
valid = getValidityFlag()
|
||||
option, valueCache = getValueCache(name, option, OptTypeInt)
|
||||
if valueCache != nil {
|
||||
value = valueCache.intVal
|
||||
} else {
|
||||
value = fallback
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// GetAsBool returns a function that returns the wanted int with high performance.
|
||||
func GetAsBool(name string, fallback bool) BoolOption {
|
||||
valid := getValidityFlag()
|
||||
option, valueCache := getValueCache(name, nil, OptTypeBool)
|
||||
value := fallback
|
||||
if valueCache != nil {
|
||||
value = valueCache.boolVal
|
||||
}
|
||||
|
||||
return func() bool {
|
||||
if !valid.IsSet() {
|
||||
valid = getValidityFlag()
|
||||
option, valueCache = getValueCache(name, option, OptTypeBool)
|
||||
if valueCache != nil {
|
||||
value = valueCache.boolVal
|
||||
} else {
|
||||
value = fallback
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
func getAndFindValue(key string) interface{} {
|
||||
optionsLock.RLock()
|
||||
option, ok := options[key]
|
||||
optionsLock.RUnlock()
|
||||
if !ok {
|
||||
log.Errorf("config: request for unregistered option: %s", key)
|
||||
return nil
|
||||
}
|
||||
|
||||
return option.findValue()
|
||||
}
|
||||
*/
|
||||
|
||||
/*
|
||||
// findValue finds the preferred value in the user or default config.
|
||||
func (option *Option) findValue() interface{} {
|
||||
// lock option
|
||||
option.Lock()
|
||||
defer option.Unlock()
|
||||
|
||||
if option.ReleaseLevel <= getReleaseLevel() && option.activeValue != nil {
|
||||
return option.activeValue
|
||||
}
|
||||
|
||||
if option.activeDefaultValue != nil {
|
||||
return option.activeDefaultValue
|
||||
}
|
||||
|
||||
return option.DefaultValue
|
||||
}
|
||||
*/
|
||||
368
base/config/get_test.go
Normal file
368
base/config/get_test.go
Normal file
@@ -0,0 +1,368 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/safing/portmaster/base/log"
|
||||
)
|
||||
|
||||
func parseAndReplaceConfig(jsonData string) error {
|
||||
m, err := JSONToMap([]byte(jsonData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
validationErrors, _ := ReplaceConfig(m)
|
||||
if len(validationErrors) > 0 {
|
||||
return fmt.Errorf("%d errors, first: %w", len(validationErrors), validationErrors[0])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseAndReplaceDefaultConfig(jsonData string) error {
|
||||
m, err := JSONToMap([]byte(jsonData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
validationErrors, _ := ReplaceDefaultConfig(m)
|
||||
if len(validationErrors) > 0 {
|
||||
return fmt.Errorf("%d errors, first: %w", len(validationErrors), validationErrors[0])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func quickRegister(t *testing.T, key string, optType OptionType, defaultValue interface{}) {
|
||||
t.Helper()
|
||||
|
||||
err := Register(&Option{
|
||||
Name: key,
|
||||
Key: key,
|
||||
Description: "test config",
|
||||
ReleaseLevel: ReleaseLevelStable,
|
||||
ExpertiseLevel: ExpertiseLevelUser,
|
||||
OptType: optType,
|
||||
DefaultValue: defaultValue,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet(t *testing.T) { //nolint:paralleltest
|
||||
// reset
|
||||
options = make(map[string]*Option)
|
||||
|
||||
err := log.Start()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
quickRegister(t, "monkey", OptTypeString, "c")
|
||||
quickRegister(t, "zebras/zebra", OptTypeStringArray, []string{"a", "b"})
|
||||
quickRegister(t, "elephant", OptTypeInt, -1)
|
||||
quickRegister(t, "hot", OptTypeBool, false)
|
||||
quickRegister(t, "cold", OptTypeBool, true)
|
||||
|
||||
err = parseAndReplaceConfig(`
|
||||
{
|
||||
"monkey": "a",
|
||||
"zebras": {
|
||||
"zebra": ["black", "white"]
|
||||
},
|
||||
"elephant": 2,
|
||||
"hot": true,
|
||||
"cold": false
|
||||
}
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = parseAndReplaceDefaultConfig(`
|
||||
{
|
||||
"monkey": "b",
|
||||
"snake": "0",
|
||||
"elephant": 0
|
||||
}
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
monkey := GetAsString("monkey", "none")
|
||||
if monkey() != "a" {
|
||||
t.Errorf("monkey should be a, is %s", monkey())
|
||||
}
|
||||
|
||||
zebra := GetAsStringArray("zebras/zebra", []string{})
|
||||
if len(zebra()) != 2 || zebra()[0] != "black" || zebra()[1] != "white" {
|
||||
t.Errorf("zebra should be [\"black\", \"white\"], is %v", zebra())
|
||||
}
|
||||
|
||||
elephant := GetAsInt("elephant", -1)
|
||||
if elephant() != 2 {
|
||||
t.Errorf("elephant should be 2, is %d", elephant())
|
||||
}
|
||||
|
||||
hot := GetAsBool("hot", false)
|
||||
if !hot() {
|
||||
t.Errorf("hot should be true, is %v", hot())
|
||||
}
|
||||
|
||||
cold := GetAsBool("cold", true)
|
||||
if cold() {
|
||||
t.Errorf("cold should be false, is %v", cold())
|
||||
}
|
||||
|
||||
err = parseAndReplaceConfig(`
|
||||
{
|
||||
"monkey": "3"
|
||||
}
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if monkey() != "3" {
|
||||
t.Errorf("monkey should be 0, is %s", monkey())
|
||||
}
|
||||
|
||||
if elephant() != 0 {
|
||||
t.Errorf("elephant should be 0, is %d", elephant())
|
||||
}
|
||||
|
||||
zebra()
|
||||
hot()
|
||||
|
||||
// concurrent
|
||||
GetAsString("monkey", "none")()
|
||||
GetAsStringArray("zebras/zebra", []string{})()
|
||||
GetAsInt("elephant", -1)()
|
||||
GetAsBool("hot", false)()
|
||||
|
||||
// perspective
|
||||
|
||||
// load data
|
||||
pLoaded := make(map[string]interface{})
|
||||
err = json.Unmarshal([]byte(`{
|
||||
"monkey": "a",
|
||||
"zebras": {
|
||||
"zebra": ["black", "white"]
|
||||
},
|
||||
"elephant": 2,
|
||||
"hot": true,
|
||||
"cold": false
|
||||
}`), &pLoaded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// create
|
||||
p, err := NewPerspective(pLoaded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
monkeyVal, ok := p.GetAsString("monkey")
|
||||
if !ok || monkeyVal != "a" {
|
||||
t.Errorf("[perspective] monkey should be a, is %+v", monkeyVal)
|
||||
}
|
||||
|
||||
zebraVal, ok := p.GetAsStringArray("zebras/zebra")
|
||||
if !ok || len(zebraVal) != 2 || zebraVal[0] != "black" || zebraVal[1] != "white" {
|
||||
t.Errorf("[perspective] zebra should be [\"black\", \"white\"], is %+v", zebraVal)
|
||||
}
|
||||
|
||||
elephantVal, ok := p.GetAsInt("elephant")
|
||||
if !ok || elephantVal != 2 {
|
||||
t.Errorf("[perspective] elephant should be 2, is %+v", elephantVal)
|
||||
}
|
||||
|
||||
hotVal, ok := p.GetAsBool("hot")
|
||||
if !ok || !hotVal {
|
||||
t.Errorf("[perspective] hot should be true, is %+v", hotVal)
|
||||
}
|
||||
|
||||
coldVal, ok := p.GetAsBool("cold")
|
||||
if !ok || coldVal {
|
||||
t.Errorf("[perspective] cold should be false, is %+v", coldVal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseLevel(t *testing.T) { //nolint:paralleltest
|
||||
// reset
|
||||
options = make(map[string]*Option)
|
||||
registerReleaseLevelOption()
|
||||
|
||||
// setup
|
||||
subsystemOption := &Option{
|
||||
Name: "test subsystem",
|
||||
Key: "subsystem/test",
|
||||
Description: "test config",
|
||||
ReleaseLevel: ReleaseLevelStable,
|
||||
ExpertiseLevel: ExpertiseLevelUser,
|
||||
OptType: OptTypeBool,
|
||||
DefaultValue: false,
|
||||
}
|
||||
err := Register(subsystemOption)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = SetConfigOption("subsystem/test", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testSubsystem := GetAsBool("subsystem/test", false)
|
||||
|
||||
// test option level stable
|
||||
subsystemOption.ReleaseLevel = ReleaseLevelStable
|
||||
err = SetConfigOption(releaseLevelKey, ReleaseLevelNameStable)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !testSubsystem() {
|
||||
t.Error("should be active")
|
||||
}
|
||||
err = SetConfigOption(releaseLevelKey, ReleaseLevelNameBeta)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !testSubsystem() {
|
||||
t.Error("should be active")
|
||||
}
|
||||
err = SetConfigOption(releaseLevelKey, ReleaseLevelNameExperimental)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !testSubsystem() {
|
||||
t.Error("should be active")
|
||||
}
|
||||
|
||||
// test option level beta
|
||||
subsystemOption.ReleaseLevel = ReleaseLevelBeta
|
||||
err = SetConfigOption(releaseLevelKey, ReleaseLevelNameStable)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if testSubsystem() {
|
||||
t.Errorf("should be inactive: opt=%d system=%d", subsystemOption.ReleaseLevel, getReleaseLevel())
|
||||
}
|
||||
err = SetConfigOption(releaseLevelKey, ReleaseLevelNameBeta)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !testSubsystem() {
|
||||
t.Error("should be active")
|
||||
}
|
||||
err = SetConfigOption(releaseLevelKey, ReleaseLevelNameExperimental)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !testSubsystem() {
|
||||
t.Error("should be active")
|
||||
}
|
||||
|
||||
// test option level experimental
|
||||
subsystemOption.ReleaseLevel = ReleaseLevelExperimental
|
||||
err = SetConfigOption(releaseLevelKey, ReleaseLevelNameStable)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if testSubsystem() {
|
||||
t.Error("should be inactive")
|
||||
}
|
||||
err = SetConfigOption(releaseLevelKey, ReleaseLevelNameBeta)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if testSubsystem() {
|
||||
t.Error("should be inactive")
|
||||
}
|
||||
err = SetConfigOption(releaseLevelKey, ReleaseLevelNameExperimental)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !testSubsystem() {
|
||||
t.Error("should be active")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGetAsStringCached(b *testing.B) {
|
||||
// reset
|
||||
options = make(map[string]*Option)
|
||||
|
||||
// Setup
|
||||
err := parseAndReplaceConfig(`{
|
||||
"monkey": "banana"
|
||||
}`)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
monkey := GetAsString("monkey", "no banana")
|
||||
|
||||
// Reset timer for precise results
|
||||
b.ResetTimer()
|
||||
|
||||
// Start benchmark
|
||||
for range b.N {
|
||||
monkey()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGetAsStringRefetch(b *testing.B) {
|
||||
// Setup
|
||||
err := parseAndReplaceConfig(`{
|
||||
"monkey": "banana"
|
||||
}`)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
// Reset timer for precise results
|
||||
b.ResetTimer()
|
||||
|
||||
// Start benchmark
|
||||
for range b.N {
|
||||
getValueCache("monkey", nil, OptTypeString)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGetAsIntCached(b *testing.B) {
|
||||
// Setup
|
||||
err := parseAndReplaceConfig(`{
|
||||
"elephant": 1
|
||||
}`)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
elephant := GetAsInt("elephant", -1)
|
||||
|
||||
// Reset timer for precise results
|
||||
b.ResetTimer()
|
||||
|
||||
// Start benchmark
|
||||
for range b.N {
|
||||
elephant()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGetAsIntRefetch(b *testing.B) {
|
||||
// Setup
|
||||
err := parseAndReplaceConfig(`{
|
||||
"elephant": 1
|
||||
}`)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
// Reset timer for precise results
|
||||
b.ResetTimer()
|
||||
|
||||
// Start benchmark
|
||||
for range b.N {
|
||||
getValueCache("elephant", nil, OptTypeInt)
|
||||
}
|
||||
}
|
||||
35
base/config/init_test.go
Normal file
35
base/config/init_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type testInstance struct{}
|
||||
|
||||
var _ instance = testInstance{}
|
||||
|
||||
func (stub testInstance) SetCmdLineOperation(f func() error) {}
|
||||
|
||||
func runTest(m *testing.M) error {
|
||||
ds, err := InitializeUnitTestDataroot("test-config")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize dataroot: %w", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(ds) }()
|
||||
module, err = New(&testInstance{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize module: %w", err)
|
||||
}
|
||||
|
||||
m.Run()
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if err := runTest(m); err != nil {
|
||||
fmt.Printf("%s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
155
base/config/main.go
Normal file
155
base/config/main.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"github.com/safing/portmaster/base/dataroot"
|
||||
"github.com/safing/portmaster/base/utils"
|
||||
"github.com/safing/portmaster/base/utils/debug"
|
||||
"github.com/safing/portmaster/service/mgr"
|
||||
)
|
||||
|
||||
// ChangeEvent is the name of the config change event.
|
||||
const ChangeEvent = "config change"
|
||||
|
||||
var (
|
||||
dataRoot *utils.DirStructure
|
||||
|
||||
exportConfig bool
|
||||
)
|
||||
|
||||
// SetDataRoot sets the data root from which the updates module derives its paths.
|
||||
func SetDataRoot(root *utils.DirStructure) {
|
||||
if dataRoot == nil {
|
||||
dataRoot = root
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
flag.BoolVar(&exportConfig, "export-config-options", false, "export configuration registry and exit")
|
||||
}
|
||||
|
||||
func prep() error {
|
||||
SetDataRoot(dataroot.Root())
|
||||
if dataRoot == nil {
|
||||
return errors.New("data root is not set")
|
||||
}
|
||||
|
||||
if exportConfig {
|
||||
module.instance.SetCmdLineOperation(exportConfigCmd)
|
||||
return mgr.ErrExecuteCmdLineOp
|
||||
}
|
||||
|
||||
return registerBasicOptions()
|
||||
}
|
||||
|
||||
func start() error {
|
||||
configFilePath = filepath.Join(dataRoot.Path, "config.json")
|
||||
|
||||
// Load log level from log package after it started.
|
||||
err := loadLogLevel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = registerAsDatabase()
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
err = loadConfig(false)
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("failed to load config file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func exportConfigCmd() error {
|
||||
// Reset the metrics instance name option, as the default
|
||||
// is set to the current hostname.
|
||||
// Config key copied from metrics.CfgOptionInstanceKey.
|
||||
option, err := GetOption("core/metrics/instance")
|
||||
if err == nil {
|
||||
option.DefaultValue = ""
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(ExportOptions(), "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = os.Stdout.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
// AddToDebugInfo adds all changed global config options to the given debug.Info.
|
||||
func AddToDebugInfo(di *debug.Info) {
|
||||
var lines []string
|
||||
|
||||
// Collect all changed settings.
|
||||
_ = ForEachOption(func(opt *Option) error {
|
||||
opt.Lock()
|
||||
defer opt.Unlock()
|
||||
|
||||
if opt.ReleaseLevel <= getReleaseLevel() && opt.activeValue != nil {
|
||||
if opt.Sensitive {
|
||||
lines = append(lines, fmt.Sprintf("%s: [redacted]", opt.Key))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("%s: %v", opt.Key, opt.activeValue.getData(opt)))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
sort.Strings(lines)
|
||||
|
||||
// Add data as section.
|
||||
di.AddSection(
|
||||
fmt.Sprintf("Config: %d", len(lines)),
|
||||
debug.UseCodeSection|debug.AddContentLineBreaks,
|
||||
lines...,
|
||||
)
|
||||
}
|
||||
|
||||
// GetActiveConfigValues returns a map with the active config values.
|
||||
func GetActiveConfigValues() map[string]interface{} {
|
||||
values := make(map[string]interface{})
|
||||
|
||||
// Collect active values from options.
|
||||
_ = ForEachOption(func(opt *Option) error {
|
||||
opt.Lock()
|
||||
defer opt.Unlock()
|
||||
|
||||
if opt.ReleaseLevel <= getReleaseLevel() && opt.activeValue != nil {
|
||||
values[opt.Key] = opt.activeValue.getData(opt)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
// InitializeUnitTestDataroot initializes a new random tmp directory for running tests.
|
||||
func InitializeUnitTestDataroot(testName string) (string, error) {
|
||||
basePath, err := os.MkdirTemp("", fmt.Sprintf("portmaster-%s", testName))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to make tmp dir: %w", err)
|
||||
}
|
||||
|
||||
ds := utils.NewDirStructure(basePath, 0o0755)
|
||||
SetDataRoot(ds)
|
||||
err = dataroot.Initialize(basePath, 0o0755)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to initialize dataroot: %w", err)
|
||||
}
|
||||
|
||||
return basePath, nil
|
||||
}
|
||||
60
base/config/module.go
Normal file
60
base/config/module.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/safing/portmaster/service/mgr"
|
||||
)
|
||||
|
||||
// Config provides configuration mgmt.
|
||||
type Config struct {
|
||||
mgr *mgr.Manager
|
||||
|
||||
instance instance
|
||||
|
||||
EventConfigChange *mgr.EventMgr[struct{}]
|
||||
}
|
||||
|
||||
// Manager returns the module's manager.
|
||||
func (u *Config) Manager() *mgr.Manager {
|
||||
return u.mgr
|
||||
}
|
||||
|
||||
// Start starts the module.
|
||||
func (u *Config) Start() error {
|
||||
return start()
|
||||
}
|
||||
|
||||
// Stop stops the module.
|
||||
func (u *Config) Stop() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
module *Config
|
||||
shimLoaded atomic.Bool
|
||||
)
|
||||
|
||||
// New returns a new Config module.
|
||||
func New(instance instance) (*Config, error) {
|
||||
if !shimLoaded.CompareAndSwap(false, true) {
|
||||
return nil, errors.New("only one instance allowed")
|
||||
}
|
||||
m := mgr.New("Config")
|
||||
module = &Config{
|
||||
mgr: m,
|
||||
instance: instance,
|
||||
EventConfigChange: mgr.NewEventMgr[struct{}](ChangeEvent, m),
|
||||
}
|
||||
|
||||
if err := prep(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return module, nil
|
||||
}
|
||||
|
||||
type instance interface {
|
||||
SetCmdLineOperation(f func() error)
|
||||
}
|
||||
418
base/config/option.go
Normal file
418
base/config/option.go
Normal file
@@ -0,0 +1,418 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sync"
|
||||
|
||||
"github.com/mitchellh/copystructure"
|
||||
"github.com/tidwall/sjson"
|
||||
|
||||
"github.com/safing/portmaster/base/database/record"
|
||||
"github.com/safing/structures/dsd"
|
||||
)
|
||||
|
||||
// OptionType defines the value type of an option.
|
||||
type OptionType uint8
|
||||
|
||||
// Various attribute options. Use ExternalOptType for extended types in the frontend.
|
||||
const (
|
||||
optTypeAny OptionType = 0
|
||||
OptTypeString OptionType = 1
|
||||
OptTypeStringArray OptionType = 2
|
||||
OptTypeInt OptionType = 3
|
||||
OptTypeBool OptionType = 4
|
||||
)
|
||||
|
||||
func getTypeName(t OptionType) string {
|
||||
switch t {
|
||||
case optTypeAny:
|
||||
return "any"
|
||||
case OptTypeString:
|
||||
return "string"
|
||||
case OptTypeStringArray:
|
||||
return "[]string"
|
||||
case OptTypeInt:
|
||||
return "int"
|
||||
case OptTypeBool:
|
||||
return "bool"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// PossibleValue defines a value that is possible for
|
||||
// a configuration setting.
|
||||
type PossibleValue struct {
|
||||
// Name is a human readable name of the option.
|
||||
Name string
|
||||
// Description is a human readable description of
|
||||
// this value.
|
||||
Description string
|
||||
// Value is the actual value of the option. The type
|
||||
// must match the option's value type.
|
||||
Value interface{}
|
||||
}
|
||||
|
||||
// Annotations can be attached to configuration options to
|
||||
// provide hints for user interfaces or other systems working
|
||||
// or setting configuration options.
|
||||
// Annotation keys should follow the below format to ensure
|
||||
// future well-known annotation additions do not conflict
|
||||
// with vendor/product/package specific annoations.
|
||||
//
|
||||
// Format: <vendor/package>:<scope>:<identifier> //.
|
||||
type Annotations map[string]interface{}
|
||||
|
||||
// MigrationFunc is a function that migrates a config option value.
|
||||
type MigrationFunc func(option *Option, value any) any
|
||||
|
||||
// Well known annotations defined by this package.
|
||||
const (
|
||||
// DisplayHintAnnotation provides a hint for the user
|
||||
// interface on how to render an option.
|
||||
// The value of DisplayHintAnnotation is expected to
|
||||
// be a string. See DisplayHintXXXX constants below
|
||||
// for a list of well-known display hint annotations.
|
||||
DisplayHintAnnotation = "safing/portbase:ui:display-hint"
|
||||
// DisplayOrderAnnotation provides a hint for the user
|
||||
// interface in which order settings should be displayed.
|
||||
// The value of DisplayOrderAnnotations is expected to be
|
||||
// an number (int).
|
||||
DisplayOrderAnnotation = "safing/portbase:ui:order"
|
||||
// UnitAnnotations defines the SI unit of an option (if any).
|
||||
UnitAnnotation = "safing/portbase:ui:unit"
|
||||
// CategoryAnnotations can provide an additional category
|
||||
// to each settings. This category can be used by a user
|
||||
// interface to group certain options together.
|
||||
// User interfaces should treat a CategoryAnnotation, if
|
||||
// supported, with higher priority as a DisplayOrderAnnotation.
|
||||
CategoryAnnotation = "safing/portbase:ui:category"
|
||||
// SubsystemAnnotation can be used to mark an option as part
|
||||
// of a module subsystem.
|
||||
SubsystemAnnotation = "safing/portbase:module:subsystem"
|
||||
// StackableAnnotation can be set on configuration options that
|
||||
// stack on top of the default (or otherwise related) options.
|
||||
// The value of StackableAnnotaiton is expected to be a boolean but
|
||||
// may be extended to hold references to other options in the
|
||||
// future.
|
||||
StackableAnnotation = "safing/portbase:options:stackable"
|
||||
// RestartPendingAnnotation is automatically set on a configuration option
|
||||
// that requires a restart and has been changed.
|
||||
// The value must always be a boolean with value "true".
|
||||
RestartPendingAnnotation = "safing/portbase:options:restart-pending"
|
||||
// QuickSettingAnnotation can be used to add quick settings to
|
||||
// a configuration option. A quick setting can support the user
|
||||
// by switching between pre-configured values.
|
||||
// The type of a quick-setting annotation is []QuickSetting or QuickSetting.
|
||||
QuickSettingsAnnotation = "safing/portbase:ui:quick-setting"
|
||||
// RequiresAnnotation can be used to mark another option as a
|
||||
// requirement. The type of RequiresAnnotation is []ValueRequirement
|
||||
// or ValueRequirement.
|
||||
RequiresAnnotation = "safing/portbase:config:requires"
|
||||
// RequiresFeatureIDAnnotation can be used to mark a setting as only available
|
||||
// when the user has a certain feature ID in the subscription plan.
|
||||
// The type is []string or string.
|
||||
RequiresFeatureIDAnnotation = "safing/portmaster:ui:config:requires-feature"
|
||||
// SettablePerAppAnnotation can be used to mark a setting as settable per-app and
|
||||
// is a boolean.
|
||||
SettablePerAppAnnotation = "safing/portmaster:settable-per-app"
|
||||
// RequiresUIReloadAnnotation can be used to inform the UI that changing the value
|
||||
// of the annotated setting requires a full reload of the user interface.
|
||||
// The value of this annotation does not matter as the sole presence of
|
||||
// the annotation key is enough. Though, users are advised to set the value
|
||||
// of this annotation to true.
|
||||
RequiresUIReloadAnnotation = "safing/portmaster:ui:requires-reload"
|
||||
)
|
||||
|
||||
// QuickSettingsAction defines the action of a quick setting.
|
||||
type QuickSettingsAction string
|
||||
|
||||
const (
|
||||
// QuickReplace replaces the current setting with the one from
|
||||
// the quick setting.
|
||||
QuickReplace = QuickSettingsAction("replace")
|
||||
// QuickMergeTop merges the value of the quick setting with the
|
||||
// already configured one adding new values on the top. Merging
|
||||
// is only supported for OptTypeStringArray.
|
||||
QuickMergeTop = QuickSettingsAction("merge-top")
|
||||
// QuickMergeBottom merges the value of the quick setting with the
|
||||
// already configured one adding new values at the bottom. Merging
|
||||
// is only supported for OptTypeStringArray.
|
||||
QuickMergeBottom = QuickSettingsAction("merge-bottom")
|
||||
)
|
||||
|
||||
// QuickSetting defines a quick setting for a configuration option and
|
||||
// should be used together with the QuickSettingsAnnotation.
|
||||
type QuickSetting struct {
|
||||
// Name is the name of the quick setting.
|
||||
Name string
|
||||
|
||||
// Value is the value that the quick-setting configures. It must match
|
||||
// the expected value type of the annotated option.
|
||||
Value interface{}
|
||||
|
||||
// Action defines the action of the quick setting.
|
||||
Action QuickSettingsAction
|
||||
}
|
||||
|
||||
// ValueRequirement defines a requirement on another configuration option.
|
||||
type ValueRequirement struct {
|
||||
// Key is the key of the configuration option that is required.
|
||||
Key string
|
||||
|
||||
// Value that is required.
|
||||
Value interface{}
|
||||
}
|
||||
|
||||
// Values for the DisplayHintAnnotation.
|
||||
const (
|
||||
// DisplayHintOneOf is used to mark an option
|
||||
// as a "select"-style option. That is, only one of
|
||||
// the supported values may be set. This option makes
|
||||
// only sense together with the PossibleValues property
|
||||
// of Option.
|
||||
DisplayHintOneOf = "one-of"
|
||||
// DisplayHintOrdered is used to mark a list option as ordered.
|
||||
// That is, the order of items is important and a user interface
|
||||
// is encouraged to provide the user with re-ordering support
|
||||
// (like drag'n'drop).
|
||||
DisplayHintOrdered = "ordered"
|
||||
// DisplayHintFilePicker is used to mark the option as being a file, which
|
||||
// should give the option to use a file picker to select a local file from disk.
|
||||
DisplayHintFilePicker = "file-picker"
|
||||
)
|
||||
|
||||
// Option describes a configuration option.
|
||||
type Option struct {
|
||||
sync.Mutex
|
||||
// Name holds the name of the configuration options.
|
||||
// It should be human readable and is mainly used for
|
||||
// presentation purposes.
|
||||
// Name is considered immutable after the option has
|
||||
// been created.
|
||||
Name string
|
||||
// Key holds the database path for the option. It should
|
||||
// follow the path format `category/sub/key`.
|
||||
// Key is considered immutable after the option has
|
||||
// been created.
|
||||
Key string
|
||||
// Description holds a human readable description of the
|
||||
// option and what is does. The description should be short.
|
||||
// Use the Help property for a longer support text.
|
||||
// Description is considered immutable after the option has
|
||||
// been created.
|
||||
Description string
|
||||
// Help may hold a long version of the description providing
|
||||
// assistance with the configuration option.
|
||||
// Help is considered immutable after the option has
|
||||
// been created.
|
||||
Help string
|
||||
// Sensitive signifies that the configuration values may contain sensitive
|
||||
// content, such as authentication keys.
|
||||
Sensitive bool
|
||||
// OptType defines the type of the option.
|
||||
// OptType is considered immutable after the option has
|
||||
// been created.
|
||||
OptType OptionType
|
||||
// ExpertiseLevel can be used to set the required expertise
|
||||
// level for the option to be displayed to a user.
|
||||
// ExpertiseLevel is considered immutable after the option has
|
||||
// been created.
|
||||
ExpertiseLevel ExpertiseLevel
|
||||
// ReleaseLevel is used to mark the stability of the option.
|
||||
// ReleaseLevel is considered immutable after the option has
|
||||
// been created.
|
||||
ReleaseLevel ReleaseLevel
|
||||
// RequiresRestart should be set to true if a modification of
|
||||
// the options value requires a restart of the whole application
|
||||
// to take effect.
|
||||
// RequiresRestart is considered immutable after the option has
|
||||
// been created.
|
||||
RequiresRestart bool
|
||||
// DefaultValue holds the default value of the option. Note that
|
||||
// this value can be overwritten during runtime (see activeDefaultValue
|
||||
// and activeFallbackValue).
|
||||
// DefaultValue is considered immutable after the option has
|
||||
// been created.
|
||||
DefaultValue interface{}
|
||||
// ValidationRegex may contain a regular expression used to validate
|
||||
// the value of option. If the option type is set to OptTypeStringArray
|
||||
// the validation regex is applied to all entries of the string slice.
|
||||
// Note that it is recommended to keep the validation regex simple so
|
||||
// it can also be used in other languages (mainly JavaScript) to provide
|
||||
// a better user-experience by pre-validating the expression.
|
||||
// ValidationRegex is considered immutable after the option has
|
||||
// been created.
|
||||
ValidationRegex string
|
||||
// ValidationFunc may contain a function to validate more complex values.
|
||||
// The error is returned beyond the scope of this package and may be
|
||||
// displayed to a user.
|
||||
ValidationFunc func(value interface{}) error `json:"-"`
|
||||
// PossibleValues may be set to a slice of values that are allowed
|
||||
// for this configuration setting. Note that PossibleValues makes most
|
||||
// sense when ExternalOptType is set to HintOneOf
|
||||
// PossibleValues is considered immutable after the option has
|
||||
// been created.
|
||||
PossibleValues []PossibleValue `json:",omitempty"`
|
||||
// Annotations adds additional annotations to the configuration options.
|
||||
// See documentation of Annotations for more information.
|
||||
// Annotations is considered mutable and setting/reading annotation keys
|
||||
// must be performed while the option is locked.
|
||||
Annotations Annotations
|
||||
// Migrations holds migration functions that are given the raw option value
|
||||
// before any validation is run. The returned value is then used.
|
||||
Migrations []MigrationFunc `json:"-"`
|
||||
|
||||
activeValue *valueCache // runtime value (loaded from config file or set by user)
|
||||
activeDefaultValue *valueCache // runtime default value (may be set internally)
|
||||
activeFallbackValue *valueCache // default value from option registration
|
||||
compiledRegex *regexp.Regexp
|
||||
}
|
||||
|
||||
// AddAnnotation adds the annotation key to option if it's not already set.
|
||||
func (option *Option) AddAnnotation(key string, value interface{}) {
|
||||
option.Lock()
|
||||
defer option.Unlock()
|
||||
|
||||
if option.Annotations == nil {
|
||||
option.Annotations = make(Annotations)
|
||||
}
|
||||
|
||||
if _, ok := option.Annotations[key]; ok {
|
||||
return
|
||||
}
|
||||
option.Annotations[key] = value
|
||||
}
|
||||
|
||||
// SetAnnotation sets the value of the annotation key overwritting an
|
||||
// existing value if required.
|
||||
func (option *Option) SetAnnotation(key string, value interface{}) {
|
||||
option.Lock()
|
||||
defer option.Unlock()
|
||||
|
||||
option.setAnnotation(key, value)
|
||||
}
|
||||
|
||||
// setAnnotation sets the value of the annotation key overwritting an
|
||||
// existing value if required. Does not lock the Option.
|
||||
func (option *Option) setAnnotation(key string, value interface{}) {
|
||||
if option.Annotations == nil {
|
||||
option.Annotations = make(Annotations)
|
||||
}
|
||||
option.Annotations[key] = value
|
||||
}
|
||||
|
||||
// GetAnnotation returns the value of the annotation key.
|
||||
func (option *Option) GetAnnotation(key string) (interface{}, bool) {
|
||||
option.Lock()
|
||||
defer option.Unlock()
|
||||
|
||||
if option.Annotations == nil {
|
||||
return nil, false
|
||||
}
|
||||
val, ok := option.Annotations[key]
|
||||
return val, ok
|
||||
}
|
||||
|
||||
// AnnotationEquals returns whether the annotation of the given key matches the
|
||||
// given value.
|
||||
func (option *Option) AnnotationEquals(key string, value any) bool {
|
||||
option.Lock()
|
||||
defer option.Unlock()
|
||||
|
||||
if option.Annotations == nil {
|
||||
return false
|
||||
}
|
||||
setValue, ok := option.Annotations[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return reflect.DeepEqual(value, setValue)
|
||||
}
|
||||
|
||||
// copyOrNil returns a copy of the option, or nil if copying failed.
|
||||
func (option *Option) copyOrNil() *Option {
|
||||
copied, err := copystructure.Copy(option)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return copied.(*Option) //nolint:forcetypeassert
|
||||
}
|
||||
|
||||
// IsSetByUser returns whether the option has been set by the user.
|
||||
func (option *Option) IsSetByUser() bool {
|
||||
option.Lock()
|
||||
defer option.Unlock()
|
||||
|
||||
return option.activeValue != nil
|
||||
}
|
||||
|
||||
// UserValue returns the value set by the user or nil if the value has not
|
||||
// been changed from the default.
|
||||
func (option *Option) UserValue() any {
|
||||
option.Lock()
|
||||
defer option.Unlock()
|
||||
|
||||
if option.activeValue == nil {
|
||||
return nil
|
||||
}
|
||||
return option.activeValue.getData(option)
|
||||
}
|
||||
|
||||
// ValidateValue checks if the given value is valid for the option.
|
||||
func (option *Option) ValidateValue(value any) error {
|
||||
option.Lock()
|
||||
defer option.Unlock()
|
||||
|
||||
value = migrateValue(option, value)
|
||||
if _, err := validateValue(option, value); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Export expors an option to a Record.
|
||||
func (option *Option) Export() (record.Record, error) {
|
||||
option.Lock()
|
||||
defer option.Unlock()
|
||||
|
||||
return option.export()
|
||||
}
|
||||
|
||||
func (option *Option) export() (record.Record, error) {
|
||||
data, err := json.Marshal(option)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if option.activeValue != nil {
|
||||
data, err = sjson.SetBytes(data, "Value", option.activeValue.getData(option))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if option.activeDefaultValue != nil {
|
||||
data, err = sjson.SetBytes(data, "DefaultValue", option.activeDefaultValue.getData(option))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
r, err := record.NewWrapper(fmt.Sprintf("config:%s", option.Key), nil, dsd.JSON, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.SetMeta(&record.Meta{})
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
type sortByKey []*Option
|
||||
|
||||
func (opts sortByKey) Len() int { return len(opts) }
|
||||
func (opts sortByKey) Less(i, j int) bool { return opts[i].Key < opts[j].Key }
|
||||
func (opts sortByKey) Swap(i, j int) { opts[i], opts[j] = opts[j], opts[i] }
|
||||
234
base/config/persistence.go
Normal file
234
base/config/persistence.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/safing/portmaster/base/log"
|
||||
)
|
||||
|
||||
var (
|
||||
configFilePath string
|
||||
|
||||
loadedConfigValidationErrors []*ValidationError
|
||||
loadedConfigValidationErrorsLock sync.Mutex
|
||||
)
|
||||
|
||||
// GetLoadedConfigValidationErrors returns the encountered validation errors
|
||||
// from the last time loading config from disk.
|
||||
func GetLoadedConfigValidationErrors() []*ValidationError {
|
||||
loadedConfigValidationErrorsLock.Lock()
|
||||
defer loadedConfigValidationErrorsLock.Unlock()
|
||||
|
||||
return loadedConfigValidationErrors
|
||||
}
|
||||
|
||||
func loadConfig(requireValidConfig bool) error {
|
||||
// check if persistence is configured
|
||||
if configFilePath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// read config file
|
||||
data, err := os.ReadFile(configFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// convert to map
|
||||
newValues, err := JSONToMap(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
validationErrors, _ := ReplaceConfig(newValues)
|
||||
if requireValidConfig && len(validationErrors) > 0 {
|
||||
return fmt.Errorf("encountered %d validation errors during config loading", len(validationErrors))
|
||||
}
|
||||
|
||||
// Save validation errors.
|
||||
loadedConfigValidationErrorsLock.Lock()
|
||||
defer loadedConfigValidationErrorsLock.Unlock()
|
||||
loadedConfigValidationErrors = validationErrors
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveConfig saves the current configuration to file.
|
||||
// It will acquire a read-lock on the global options registry
|
||||
// lock and must lock each option!
|
||||
func SaveConfig() error {
|
||||
optionsLock.RLock()
|
||||
defer optionsLock.RUnlock()
|
||||
|
||||
// check if persistence is configured
|
||||
if configFilePath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// extract values
|
||||
activeValues := make(map[string]interface{})
|
||||
for key, option := range options {
|
||||
// we cannot immedately unlock the option afger
|
||||
// getData() because someone could lock and change it
|
||||
// while we are marshaling the value (i.e. for string slices).
|
||||
// We NEED to keep the option locks until we finsihed.
|
||||
option.Lock()
|
||||
defer option.Unlock()
|
||||
|
||||
if option.activeValue != nil {
|
||||
activeValues[key] = option.activeValue.getData(option)
|
||||
}
|
||||
}
|
||||
|
||||
// convert to JSON
|
||||
data, err := MapToJSON(activeValues)
|
||||
if err != nil {
|
||||
log.Errorf("config: failed to save config: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// write file
|
||||
return os.WriteFile(configFilePath, data, 0o0600)
|
||||
}
|
||||
|
||||
// JSONToMap parses and flattens a hierarchical json object.
|
||||
func JSONToMap(jsonData []byte) (map[string]interface{}, error) {
|
||||
loaded := make(map[string]interface{})
|
||||
err := json.Unmarshal(jsonData, &loaded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return Flatten(loaded), nil
|
||||
}
|
||||
|
||||
// Flatten returns a flattened copy of the given hierarchical config.
|
||||
func Flatten(config map[string]interface{}) (flattenedConfig map[string]interface{}) {
|
||||
flattenedConfig = make(map[string]interface{})
|
||||
flattenMap(flattenedConfig, config, "")
|
||||
return flattenedConfig
|
||||
}
|
||||
|
||||
func flattenMap(rootMap, subMap map[string]interface{}, subKey string) {
|
||||
for key, entry := range subMap {
|
||||
|
||||
// get next level key
|
||||
subbedKey := path.Join(subKey, key)
|
||||
|
||||
// check for next subMap
|
||||
nextSub, ok := entry.(map[string]interface{})
|
||||
if ok {
|
||||
flattenMap(rootMap, nextSub, subbedKey)
|
||||
} else {
|
||||
// only set if not on root level
|
||||
rootMap[subbedKey] = entry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MapToJSON expands a flattened map and returns it as json.
|
||||
func MapToJSON(config map[string]interface{}) ([]byte, error) {
|
||||
return json.MarshalIndent(Expand(config), "", " ")
|
||||
}
|
||||
|
||||
// Expand returns a hierarchical copy of the given flattened config.
|
||||
func Expand(flattenedConfig map[string]interface{}) (config map[string]interface{}) {
|
||||
config = make(map[string]interface{})
|
||||
for key, entry := range flattenedConfig {
|
||||
PutValueIntoHierarchicalConfig(config, key, entry)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// PutValueIntoHierarchicalConfig injects a configuration entry into an hierarchical config map. Conflicting entries will be replaced.
|
||||
func PutValueIntoHierarchicalConfig(config map[string]interface{}, key string, value interface{}) {
|
||||
parts := strings.Split(key, "/")
|
||||
|
||||
// create/check maps for all parts except the last one
|
||||
subMap := config
|
||||
for i, part := range parts {
|
||||
if i == len(parts)-1 {
|
||||
// do not process the last part,
|
||||
// which is not a map, but the value key itself
|
||||
break
|
||||
}
|
||||
|
||||
var nextSubMap map[string]interface{}
|
||||
// get value
|
||||
value, ok := subMap[part]
|
||||
if !ok {
|
||||
// create new map and assign it
|
||||
nextSubMap = make(map[string]interface{})
|
||||
subMap[part] = nextSubMap
|
||||
} else {
|
||||
nextSubMap, ok = value.(map[string]interface{})
|
||||
if !ok {
|
||||
// create new map and assign it
|
||||
nextSubMap = make(map[string]interface{})
|
||||
subMap[part] = nextSubMap
|
||||
}
|
||||
}
|
||||
|
||||
// assign for next parts loop
|
||||
subMap = nextSubMap
|
||||
}
|
||||
|
||||
// assign value to last submap
|
||||
subMap[parts[len(parts)-1]] = value
|
||||
}
|
||||
|
||||
// CleanFlattenedConfig removes all inexistent configuration options from the given flattened config map.
|
||||
func CleanFlattenedConfig(flattenedConfig map[string]interface{}) {
|
||||
optionsLock.RLock()
|
||||
defer optionsLock.RUnlock()
|
||||
|
||||
for key := range flattenedConfig {
|
||||
_, ok := options[key]
|
||||
if !ok {
|
||||
delete(flattenedConfig, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CleanHierarchicalConfig removes all inexistent configuration options from the given hierarchical config map.
|
||||
func CleanHierarchicalConfig(config map[string]interface{}) {
|
||||
optionsLock.RLock()
|
||||
defer optionsLock.RUnlock()
|
||||
|
||||
cleanSubMap(config, "")
|
||||
}
|
||||
|
||||
func cleanSubMap(subMap map[string]interface{}, subKey string) (empty bool) {
|
||||
var foundValid int
|
||||
for key, value := range subMap {
|
||||
value, ok := value.(map[string]interface{})
|
||||
if ok {
|
||||
// we found another section
|
||||
isEmpty := cleanSubMap(value, path.Join(subKey, key))
|
||||
if isEmpty {
|
||||
delete(subMap, key)
|
||||
} else {
|
||||
foundValid++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// we found an option value
|
||||
if strings.Contains(key, "/") {
|
||||
delete(subMap, key)
|
||||
} else {
|
||||
_, ok := options[path.Join(subKey, key)]
|
||||
if ok {
|
||||
foundValid++
|
||||
} else {
|
||||
delete(subMap, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return foundValid == 0
|
||||
}
|
||||
97
base/config/persistence_test.go
Normal file
97
base/config/persistence_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var (
|
||||
jsonData = `{
|
||||
"a": "b",
|
||||
"c": {
|
||||
"d": "e",
|
||||
"f": "g",
|
||||
"h": {
|
||||
"i": "j",
|
||||
"k": "l",
|
||||
"m": {
|
||||
"n": "o"
|
||||
}
|
||||
}
|
||||
},
|
||||
"p": "q"
|
||||
}`
|
||||
jsonBytes = []byte(jsonData)
|
||||
|
||||
mapData = map[string]interface{}{
|
||||
"a": "b",
|
||||
"p": "q",
|
||||
"c/d": "e",
|
||||
"c/f": "g",
|
||||
"c/h/i": "j",
|
||||
"c/h/k": "l",
|
||||
"c/h/m/n": "o",
|
||||
}
|
||||
)
|
||||
|
||||
func TestJSONMapConversion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// convert to json
|
||||
j, err := MapToJSON(mapData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// check if to json matches
|
||||
if !bytes.Equal(jsonBytes, j) {
|
||||
t.Errorf("json does not match, got %s", j)
|
||||
}
|
||||
|
||||
// convert to map
|
||||
m, err := JSONToMap(jsonBytes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// and back
|
||||
j2, err := MapToJSON(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// check if double convert matches
|
||||
if !bytes.Equal(jsonBytes, j2) {
|
||||
t.Errorf("json does not match, got %s", j)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigCleaning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// load
|
||||
configFlat, err := JSONToMap(jsonBytes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// clean everything
|
||||
CleanFlattenedConfig(configFlat)
|
||||
if len(configFlat) != 0 {
|
||||
t.Errorf("should be empty: %+v", configFlat)
|
||||
}
|
||||
|
||||
// load manuall for hierarchical config
|
||||
configHier := make(map[string]interface{})
|
||||
err = json.Unmarshal(jsonBytes, &configHier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// clean everything
|
||||
CleanHierarchicalConfig(configHier)
|
||||
if len(configHier) != 0 {
|
||||
t.Errorf("should be empty: %+v", configHier)
|
||||
}
|
||||
}
|
||||
133
base/config/perspective.go
Normal file
133
base/config/perspective.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/safing/portmaster/base/log"
|
||||
)
|
||||
|
||||
// Perspective is a view on configuration data without interfering with the configuration system.
|
||||
type Perspective struct {
|
||||
config map[string]*perspectiveOption
|
||||
}
|
||||
|
||||
type perspectiveOption struct {
|
||||
option *Option
|
||||
valueCache *valueCache
|
||||
}
|
||||
|
||||
// NewPerspective parses the given config and returns it as a new perspective.
|
||||
func NewPerspective(config map[string]interface{}) (*Perspective, error) {
|
||||
// flatten config structure
|
||||
config = Flatten(config)
|
||||
|
||||
perspective := &Perspective{
|
||||
config: make(map[string]*perspectiveOption),
|
||||
}
|
||||
var firstErr error
|
||||
var errCnt int
|
||||
|
||||
optionsLock.RLock()
|
||||
optionsLoop:
|
||||
for key, option := range options {
|
||||
// get option key from config
|
||||
configValue, ok := config[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// migrate value
|
||||
configValue = migrateValue(option, configValue)
|
||||
// validate value
|
||||
valueCache, err := validateValue(option, configValue)
|
||||
if err != nil {
|
||||
errCnt++
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue optionsLoop
|
||||
}
|
||||
|
||||
// add to perspective
|
||||
perspective.config[key] = &perspectiveOption{
|
||||
option: option,
|
||||
valueCache: valueCache,
|
||||
}
|
||||
}
|
||||
optionsLock.RUnlock()
|
||||
|
||||
if firstErr != nil {
|
||||
if errCnt > 0 {
|
||||
return perspective, fmt.Errorf("encountered %d errors, first was: %w", errCnt, firstErr)
|
||||
}
|
||||
return perspective, firstErr
|
||||
}
|
||||
|
||||
return perspective, nil
|
||||
}
|
||||
|
||||
func (p *Perspective) getPerspectiveValueCache(name string, requestedType OptionType) *valueCache {
|
||||
// get option
|
||||
pOption, ok := p.config[name]
|
||||
if !ok {
|
||||
// check if option exists at all
|
||||
if _, err := GetOption(name); err != nil {
|
||||
log.Errorf("config: request for unregistered option: %s", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// check type
|
||||
if requestedType != pOption.option.OptType && requestedType != optTypeAny {
|
||||
log.Errorf("config: bad type: requested %s as %s, but is %s", name, getTypeName(requestedType), getTypeName(pOption.option.OptType))
|
||||
return nil
|
||||
}
|
||||
|
||||
// check release level
|
||||
if pOption.option.ReleaseLevel > getReleaseLevel() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return pOption.valueCache
|
||||
}
|
||||
|
||||
// Has returns whether the given option is set in the perspective.
|
||||
func (p *Perspective) Has(name string) bool {
|
||||
valueCache := p.getPerspectiveValueCache(name, optTypeAny)
|
||||
return valueCache != nil
|
||||
}
|
||||
|
||||
// GetAsString returns a function that returns the wanted string with high performance.
|
||||
func (p *Perspective) GetAsString(name string) (value string, ok bool) {
|
||||
valueCache := p.getPerspectiveValueCache(name, OptTypeString)
|
||||
if valueCache != nil {
|
||||
return valueCache.stringVal, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// GetAsStringArray returns a function that returns the wanted string with high performance.
|
||||
func (p *Perspective) GetAsStringArray(name string) (value []string, ok bool) {
|
||||
valueCache := p.getPerspectiveValueCache(name, OptTypeStringArray)
|
||||
if valueCache != nil {
|
||||
return valueCache.stringArrayVal, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// GetAsInt returns a function that returns the wanted int with high performance.
|
||||
func (p *Perspective) GetAsInt(name string) (value int64, ok bool) {
|
||||
valueCache := p.getPerspectiveValueCache(name, OptTypeInt)
|
||||
if valueCache != nil {
|
||||
return valueCache.intVal, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// GetAsBool returns a function that returns the wanted int with high performance.
|
||||
func (p *Perspective) GetAsBool(name string) (value bool, ok bool) {
|
||||
valueCache := p.getPerspectiveValueCache(name, OptTypeBool)
|
||||
if valueCache != nil {
|
||||
return valueCache.boolVal, true
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
106
base/config/registry.go
Normal file
106
base/config/registry.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
optionsLock sync.RWMutex
|
||||
options = make(map[string]*Option)
|
||||
)
|
||||
|
||||
// ForEachOption calls fn for each defined option. If fn returns
|
||||
// and error the iteration is stopped and the error is returned.
|
||||
// Note that ForEachOption does not guarantee a stable order of
|
||||
// iteration between multiple calles. ForEachOption does NOT lock
|
||||
// opt when calling fn.
|
||||
func ForEachOption(fn func(opt *Option) error) error {
|
||||
optionsLock.RLock()
|
||||
defer optionsLock.RUnlock()
|
||||
|
||||
for _, opt := range options {
|
||||
if err := fn(opt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExportOptions exports the registered options. The returned data must be
|
||||
// treated as immutable.
|
||||
// The data does not include the current active or default settings.
|
||||
func ExportOptions() []*Option {
|
||||
optionsLock.RLock()
|
||||
defer optionsLock.RUnlock()
|
||||
|
||||
// Copy the map into a slice.
|
||||
opts := make([]*Option, 0, len(options))
|
||||
for _, opt := range options {
|
||||
opts = append(opts, opt)
|
||||
}
|
||||
|
||||
sort.Sort(sortByKey(opts))
|
||||
return opts
|
||||
}
|
||||
|
||||
// GetOption returns the option with name or an error
|
||||
// if the option does not exist. The caller should lock
|
||||
// the returned option itself for further processing.
|
||||
func GetOption(name string) (*Option, error) {
|
||||
optionsLock.RLock()
|
||||
defer optionsLock.RUnlock()
|
||||
|
||||
opt, ok := options[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("option %q does not exist", name)
|
||||
}
|
||||
return opt, nil
|
||||
}
|
||||
|
||||
// Register registers a new configuration option.
|
||||
func Register(option *Option) error {
|
||||
if option.Name == "" {
|
||||
return fmt.Errorf("failed to register option: please set option.Name")
|
||||
}
|
||||
if option.Key == "" {
|
||||
return fmt.Errorf("failed to register option: please set option.Key")
|
||||
}
|
||||
if option.Description == "" {
|
||||
return fmt.Errorf("failed to register option: please set option.Description")
|
||||
}
|
||||
if option.OptType == 0 {
|
||||
return fmt.Errorf("failed to register option: please set option.OptType")
|
||||
}
|
||||
|
||||
if option.ValidationRegex == "" && option.PossibleValues != nil {
|
||||
values := make([]string, len(option.PossibleValues))
|
||||
for idx, val := range option.PossibleValues {
|
||||
values[idx] = fmt.Sprintf("%v", val.Value)
|
||||
}
|
||||
option.ValidationRegex = fmt.Sprintf("^(%s)$", strings.Join(values, "|"))
|
||||
}
|
||||
|
||||
var err error
|
||||
if option.ValidationRegex != "" {
|
||||
option.compiledRegex, err = regexp.Compile(option.ValidationRegex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: could not compile option.ValidationRegex: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var vErr *ValidationError
|
||||
option.activeFallbackValue, vErr = validateValue(option, option.DefaultValue)
|
||||
if vErr != nil {
|
||||
return fmt.Errorf("config: invalid default value: %w", vErr)
|
||||
}
|
||||
|
||||
optionsLock.Lock()
|
||||
defer optionsLock.Unlock()
|
||||
options[option.Key] = option
|
||||
|
||||
return nil
|
||||
}
|
||||
49
base/config/registry_test.go
Normal file
49
base/config/registry_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegistry(t *testing.T) { //nolint:paralleltest
|
||||
// reset
|
||||
options = make(map[string]*Option)
|
||||
|
||||
if err := Register(&Option{
|
||||
Name: "name",
|
||||
Key: "key",
|
||||
Description: "description",
|
||||
ReleaseLevel: ReleaseLevelStable,
|
||||
ExpertiseLevel: ExpertiseLevelUser,
|
||||
OptType: OptTypeString,
|
||||
DefaultValue: "water",
|
||||
ValidationRegex: "^(banana|water)$",
|
||||
}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if err := Register(&Option{
|
||||
Name: "name",
|
||||
Key: "key",
|
||||
Description: "description",
|
||||
ReleaseLevel: ReleaseLevelStable,
|
||||
ExpertiseLevel: ExpertiseLevelUser,
|
||||
OptType: 0,
|
||||
DefaultValue: "default",
|
||||
ValidationRegex: "^[A-Z][a-z]+$",
|
||||
}); err == nil {
|
||||
t.Error("should fail")
|
||||
}
|
||||
|
||||
if err := Register(&Option{
|
||||
Name: "name",
|
||||
Key: "key",
|
||||
Description: "description",
|
||||
ReleaseLevel: ReleaseLevelStable,
|
||||
ExpertiseLevel: ExpertiseLevelUser,
|
||||
OptType: OptTypeString,
|
||||
DefaultValue: "default",
|
||||
ValidationRegex: "[",
|
||||
}); err == nil {
|
||||
t.Error("should fail")
|
||||
}
|
||||
}
|
||||
101
base/config/release.go
Normal file
101
base/config/release.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/tevino/abool"
|
||||
)
|
||||
|
||||
// ReleaseLevel is used to define the maturity of a
|
||||
// configuration setting.
|
||||
type ReleaseLevel uint8
|
||||
|
||||
// Release Level constants.
|
||||
const (
|
||||
ReleaseLevelStable ReleaseLevel = 0
|
||||
ReleaseLevelBeta ReleaseLevel = 1
|
||||
ReleaseLevelExperimental ReleaseLevel = 2
|
||||
|
||||
ReleaseLevelNameStable = "stable"
|
||||
ReleaseLevelNameBeta = "beta"
|
||||
ReleaseLevelNameExperimental = "experimental"
|
||||
|
||||
releaseLevelKey = "core/releaseLevel"
|
||||
)
|
||||
|
||||
var (
|
||||
releaseLevel = new(int32)
|
||||
releaseLevelOption *Option
|
||||
releaseLevelOptionFlag = abool.New()
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerReleaseLevelOption()
|
||||
}
|
||||
|
||||
func registerReleaseLevelOption() {
|
||||
releaseLevelOption = &Option{
|
||||
Name: "Feature Stability",
|
||||
Key: releaseLevelKey,
|
||||
Description: `May break things. Decide if you want to experiment with unstable features. "Beta" has been tested roughly by the Safing team while "Experimental" is really raw. When "Beta" or "Experimental" are disabled, their settings use the default again.`,
|
||||
OptType: OptTypeString,
|
||||
ExpertiseLevel: ExpertiseLevelDeveloper,
|
||||
ReleaseLevel: ReleaseLevelStable,
|
||||
DefaultValue: ReleaseLevelNameStable,
|
||||
Annotations: Annotations{
|
||||
DisplayOrderAnnotation: -8,
|
||||
DisplayHintAnnotation: DisplayHintOneOf,
|
||||
CategoryAnnotation: "Updates",
|
||||
},
|
||||
PossibleValues: []PossibleValue{
|
||||
{
|
||||
Name: "Stable",
|
||||
Value: ReleaseLevelNameStable,
|
||||
Description: "Only show stable features.",
|
||||
},
|
||||
{
|
||||
Name: "Beta",
|
||||
Value: ReleaseLevelNameBeta,
|
||||
Description: "Show stable and beta features.",
|
||||
},
|
||||
{
|
||||
Name: "Experimental",
|
||||
Value: ReleaseLevelNameExperimental,
|
||||
Description: "Show all features",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := Register(releaseLevelOption)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
releaseLevelOptionFlag.Set()
|
||||
}
|
||||
|
||||
func updateReleaseLevel() {
|
||||
// get value
|
||||
value := releaseLevelOption.activeFallbackValue
|
||||
if releaseLevelOption.activeValue != nil {
|
||||
value = releaseLevelOption.activeValue
|
||||
}
|
||||
if releaseLevelOption.activeDefaultValue != nil {
|
||||
value = releaseLevelOption.activeDefaultValue
|
||||
}
|
||||
// set atomic value
|
||||
switch value.stringVal {
|
||||
case ReleaseLevelNameStable:
|
||||
atomic.StoreInt32(releaseLevel, int32(ReleaseLevelStable))
|
||||
case ReleaseLevelNameBeta:
|
||||
atomic.StoreInt32(releaseLevel, int32(ReleaseLevelBeta))
|
||||
case ReleaseLevelNameExperimental:
|
||||
atomic.StoreInt32(releaseLevel, int32(ReleaseLevelExperimental))
|
||||
default:
|
||||
atomic.StoreInt32(releaseLevel, int32(ReleaseLevelStable))
|
||||
}
|
||||
}
|
||||
|
||||
func getReleaseLevel() ReleaseLevel {
|
||||
return ReleaseLevel(atomic.LoadInt32(releaseLevel))
|
||||
}
|
||||
235
base/config/set.go
Normal file
235
base/config/set.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/tevino/abool"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidJSON is returned by SetConfig and SetDefaultConfig if they receive invalid json.
|
||||
ErrInvalidJSON = errors.New("json string invalid")
|
||||
|
||||
// ErrInvalidOptionType is returned by SetConfigOption and SetDefaultConfigOption if given an unsupported option type.
|
||||
ErrInvalidOptionType = errors.New("invalid option value type")
|
||||
|
||||
validityFlag = abool.NewBool(true)
|
||||
validityFlagLock sync.RWMutex
|
||||
)
|
||||
|
||||
// getValidityFlag returns a flag that signifies if the configuration has been changed. This flag must not be changed, only read.
|
||||
func getValidityFlag() *abool.AtomicBool {
|
||||
validityFlagLock.RLock()
|
||||
defer validityFlagLock.RUnlock()
|
||||
return validityFlag
|
||||
}
|
||||
|
||||
// signalChanges marks the configs validtityFlag as dirty and eventually
|
||||
// triggers a config change event.
|
||||
func signalChanges() {
|
||||
// reset validity flag
|
||||
validityFlagLock.Lock()
|
||||
validityFlag.SetTo(false)
|
||||
validityFlag = abool.NewBool(true)
|
||||
validityFlagLock.Unlock()
|
||||
|
||||
module.EventConfigChange.Submit(struct{}{})
|
||||
}
|
||||
|
||||
// ValidateConfig validates the given configuration and returns all validation
|
||||
// errors as well as whether the given configuration contains unknown keys.
|
||||
func ValidateConfig(newValues map[string]interface{}) (validationErrors []*ValidationError, requiresRestart bool, containsUnknown bool) {
|
||||
// RLock the options because we are not adding or removing
|
||||
// options from the registration but rather only checking the
|
||||
// options value which is guarded by the option's lock itself.
|
||||
optionsLock.RLock()
|
||||
defer optionsLock.RUnlock()
|
||||
|
||||
var checked int
|
||||
for key, option := range options {
|
||||
newValue, ok := newValues[key]
|
||||
if ok {
|
||||
checked++
|
||||
|
||||
func() {
|
||||
option.Lock()
|
||||
defer option.Unlock()
|
||||
|
||||
newValue = migrateValue(option, newValue)
|
||||
_, err := validateValue(option, newValue)
|
||||
if err != nil {
|
||||
validationErrors = append(validationErrors, err)
|
||||
}
|
||||
|
||||
if option.RequiresRestart {
|
||||
requiresRestart = true
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
return validationErrors, requiresRestart, checked < len(newValues)
|
||||
}
|
||||
|
||||
// ReplaceConfig sets the (prioritized) user defined config.
|
||||
func ReplaceConfig(newValues map[string]interface{}) (validationErrors []*ValidationError, requiresRestart bool) {
|
||||
// RLock the options because we are not adding or removing
|
||||
// options from the registration but rather only update the
|
||||
// options value which is guarded by the option's lock itself.
|
||||
optionsLock.RLock()
|
||||
defer optionsLock.RUnlock()
|
||||
|
||||
for key, option := range options {
|
||||
newValue, ok := newValues[key]
|
||||
|
||||
func() {
|
||||
option.Lock()
|
||||
defer option.Unlock()
|
||||
|
||||
option.activeValue = nil
|
||||
if ok {
|
||||
newValue = migrateValue(option, newValue)
|
||||
valueCache, err := validateValue(option, newValue)
|
||||
if err == nil {
|
||||
option.activeValue = valueCache
|
||||
} else {
|
||||
validationErrors = append(validationErrors, err)
|
||||
}
|
||||
}
|
||||
handleOptionUpdate(option, true)
|
||||
|
||||
if option.RequiresRestart {
|
||||
requiresRestart = true
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
signalChanges()
|
||||
|
||||
return validationErrors, requiresRestart
|
||||
}
|
||||
|
||||
// ReplaceDefaultConfig sets the (fallback) default config.
|
||||
func ReplaceDefaultConfig(newValues map[string]interface{}) (validationErrors []*ValidationError, requiresRestart bool) {
|
||||
// RLock the options because we are not adding or removing
|
||||
// options from the registration but rather only update the
|
||||
// options value which is guarded by the option's lock itself.
|
||||
optionsLock.RLock()
|
||||
defer optionsLock.RUnlock()
|
||||
|
||||
for key, option := range options {
|
||||
newValue, ok := newValues[key]
|
||||
|
||||
func() {
|
||||
option.Lock()
|
||||
defer option.Unlock()
|
||||
|
||||
option.activeDefaultValue = nil
|
||||
if ok {
|
||||
newValue = migrateValue(option, newValue)
|
||||
valueCache, err := validateValue(option, newValue)
|
||||
if err == nil {
|
||||
option.activeDefaultValue = valueCache
|
||||
} else {
|
||||
validationErrors = append(validationErrors, err)
|
||||
}
|
||||
}
|
||||
handleOptionUpdate(option, true)
|
||||
|
||||
if option.RequiresRestart {
|
||||
requiresRestart = true
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
signalChanges()
|
||||
|
||||
return validationErrors, requiresRestart
|
||||
}
|
||||
|
||||
// SetConfigOption sets a single value in the (prioritized) user defined config.
|
||||
func SetConfigOption(key string, value any) error {
|
||||
return setConfigOption(key, value, true)
|
||||
}
|
||||
|
||||
func setConfigOption(key string, value any, push bool) (err error) {
|
||||
option, err := GetOption(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
option.Lock()
|
||||
if value == nil {
|
||||
option.activeValue = nil
|
||||
} else {
|
||||
value = migrateValue(option, value)
|
||||
valueCache, vErr := validateValue(option, value)
|
||||
if vErr == nil {
|
||||
option.activeValue = valueCache
|
||||
} else {
|
||||
err = vErr
|
||||
}
|
||||
}
|
||||
|
||||
// Add the "restart pending" annotation if the settings requires a restart.
|
||||
if option.RequiresRestart {
|
||||
option.setAnnotation(RestartPendingAnnotation, true)
|
||||
}
|
||||
|
||||
handleOptionUpdate(option, push)
|
||||
option.Unlock()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// finalize change, activate triggers
|
||||
signalChanges()
|
||||
|
||||
return SaveConfig()
|
||||
}
|
||||
|
||||
// SetDefaultConfigOption sets a single value in the (fallback) default config.
|
||||
func SetDefaultConfigOption(key string, value interface{}) error {
|
||||
return setDefaultConfigOption(key, value, true)
|
||||
}
|
||||
|
||||
func setDefaultConfigOption(key string, value interface{}, push bool) (err error) {
|
||||
option, err := GetOption(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
option.Lock()
|
||||
if value == nil {
|
||||
option.activeDefaultValue = nil
|
||||
} else {
|
||||
value = migrateValue(option, value)
|
||||
valueCache, vErr := validateValue(option, value)
|
||||
if vErr == nil {
|
||||
option.activeDefaultValue = valueCache
|
||||
} else {
|
||||
err = vErr
|
||||
}
|
||||
}
|
||||
|
||||
// Add the "restart pending" annotation if the settings requires a restart.
|
||||
if option.RequiresRestart {
|
||||
option.setAnnotation(RestartPendingAnnotation, true)
|
||||
}
|
||||
|
||||
handleOptionUpdate(option, push)
|
||||
option.Unlock()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// finalize change, activate triggers
|
||||
signalChanges()
|
||||
|
||||
// Do not save the configuration, as it only saves the active values, not the
|
||||
// active default value.
|
||||
return nil
|
||||
}
|
||||
193
base/config/set_test.go
Normal file
193
base/config/set_test.go
Normal file
@@ -0,0 +1,193 @@
|
||||
//nolint:goconst
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLayersGetters(t *testing.T) { //nolint:paralleltest
|
||||
// reset
|
||||
options = make(map[string]*Option)
|
||||
|
||||
mapData, err := JSONToMap([]byte(`
|
||||
{
|
||||
"monkey": "1",
|
||||
"elephant": 2,
|
||||
"zebras": {
|
||||
"zebra": ["black", "white"],
|
||||
"weird_zebra": ["black", -1]
|
||||
},
|
||||
"env": {
|
||||
"hot": true
|
||||
}
|
||||
}
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
validationErrors, _ := ReplaceConfig(mapData)
|
||||
if len(validationErrors) > 0 {
|
||||
t.Fatalf("%d errors, first: %s", len(validationErrors), validationErrors[0].Error())
|
||||
}
|
||||
|
||||
// Test missing values
|
||||
|
||||
missingString := GetAsString("missing", "fallback")
|
||||
if missingString() != "fallback" {
|
||||
t.Error("expected fallback value: fallback")
|
||||
}
|
||||
|
||||
missingStringArray := GetAsStringArray("missing", []string{"fallback"})
|
||||
if len(missingStringArray()) != 1 || missingStringArray()[0] != "fallback" {
|
||||
t.Error("expected fallback value: [fallback]")
|
||||
}
|
||||
|
||||
missingInt := GetAsInt("missing", -1)
|
||||
if missingInt() != -1 {
|
||||
t.Error("expected fallback value: -1")
|
||||
}
|
||||
|
||||
missingBool := GetAsBool("missing", false)
|
||||
if missingBool() {
|
||||
t.Error("expected fallback value: false")
|
||||
}
|
||||
|
||||
// Test value mismatch
|
||||
|
||||
notString := GetAsString("elephant", "fallback")
|
||||
if notString() != "fallback" {
|
||||
t.Error("expected fallback value: fallback")
|
||||
}
|
||||
|
||||
notStringArray := GetAsStringArray("elephant", []string{"fallback"})
|
||||
if len(notStringArray()) != 1 || notStringArray()[0] != "fallback" {
|
||||
t.Error("expected fallback value: [fallback]")
|
||||
}
|
||||
|
||||
mixedStringArray := GetAsStringArray("zebras/weird_zebra", []string{"fallback"})
|
||||
if len(mixedStringArray()) != 1 || mixedStringArray()[0] != "fallback" {
|
||||
t.Error("expected fallback value: [fallback]")
|
||||
}
|
||||
|
||||
notInt := GetAsInt("monkey", -1)
|
||||
if notInt() != -1 {
|
||||
t.Error("expected fallback value: -1")
|
||||
}
|
||||
|
||||
notBool := GetAsBool("monkey", false)
|
||||
if notBool() {
|
||||
t.Error("expected fallback value: false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayersSetters(t *testing.T) { //nolint:paralleltest
|
||||
// reset
|
||||
options = make(map[string]*Option)
|
||||
|
||||
_ = Register(&Option{
|
||||
Name: "name",
|
||||
Key: "monkey",
|
||||
Description: "description",
|
||||
ReleaseLevel: ReleaseLevelStable,
|
||||
ExpertiseLevel: ExpertiseLevelUser,
|
||||
OptType: OptTypeString,
|
||||
DefaultValue: "banana",
|
||||
ValidationRegex: "^(banana|water)$",
|
||||
})
|
||||
_ = Register(&Option{
|
||||
Name: "name",
|
||||
Key: "zebras/zebra",
|
||||
Description: "description",
|
||||
ReleaseLevel: ReleaseLevelStable,
|
||||
ExpertiseLevel: ExpertiseLevelUser,
|
||||
OptType: OptTypeStringArray,
|
||||
DefaultValue: []string{"black", "white"},
|
||||
ValidationRegex: "^[a-z]+$",
|
||||
})
|
||||
_ = Register(&Option{
|
||||
Name: "name",
|
||||
Key: "elephant",
|
||||
Description: "description",
|
||||
ReleaseLevel: ReleaseLevelStable,
|
||||
ExpertiseLevel: ExpertiseLevelUser,
|
||||
OptType: OptTypeInt,
|
||||
DefaultValue: 2,
|
||||
ValidationRegex: "",
|
||||
})
|
||||
_ = Register(&Option{
|
||||
Name: "name",
|
||||
Key: "hot",
|
||||
Description: "description",
|
||||
ReleaseLevel: ReleaseLevelStable,
|
||||
ExpertiseLevel: ExpertiseLevelUser,
|
||||
OptType: OptTypeBool,
|
||||
DefaultValue: true,
|
||||
ValidationRegex: "",
|
||||
})
|
||||
|
||||
// correct types
|
||||
if err := SetConfigOption("monkey", "banana"); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := SetConfigOption("zebras/zebra", []string{"black", "white"}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := SetDefaultConfigOption("elephant", 2); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := SetDefaultConfigOption("hot", true); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// incorrect types
|
||||
if err := SetConfigOption("monkey", []string{"black", "white"}); err == nil {
|
||||
t.Error("should fail")
|
||||
}
|
||||
if err := SetConfigOption("zebras/zebra", 2); err == nil {
|
||||
t.Error("should fail")
|
||||
}
|
||||
if err := SetDefaultConfigOption("elephant", true); err == nil {
|
||||
t.Error("should fail")
|
||||
}
|
||||
if err := SetDefaultConfigOption("hot", "banana"); err == nil {
|
||||
t.Error("should fail")
|
||||
}
|
||||
if err := SetDefaultConfigOption("hot", []byte{0}); err == nil {
|
||||
t.Error("should fail")
|
||||
}
|
||||
|
||||
// validation fail
|
||||
if err := SetConfigOption("monkey", "dirt"); err == nil {
|
||||
t.Error("should fail")
|
||||
}
|
||||
if err := SetConfigOption("zebras/zebra", []string{"Element649"}); err == nil {
|
||||
t.Error("should fail")
|
||||
}
|
||||
|
||||
// unregistered checking
|
||||
if err := SetConfigOption("invalid", "banana"); err == nil {
|
||||
t.Error("should fail")
|
||||
}
|
||||
if err := SetConfigOption("invalid", []string{"black", "white"}); err == nil {
|
||||
t.Error("should fail")
|
||||
}
|
||||
if err := SetConfigOption("invalid", 2); err == nil {
|
||||
t.Error("should fail")
|
||||
}
|
||||
if err := SetConfigOption("invalid", true); err == nil {
|
||||
t.Error("should fail")
|
||||
}
|
||||
if err := SetConfigOption("invalid", []byte{0}); err == nil {
|
||||
t.Error("should fail")
|
||||
}
|
||||
|
||||
// delete
|
||||
if err := SetConfigOption("monkey", nil); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := SetDefaultConfigOption("elephant", nil); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := SetDefaultConfigOption("invalid_delete", nil); err == nil {
|
||||
t.Error("should fail")
|
||||
}
|
||||
}
|
||||
239
base/config/validate.go
Normal file
239
base/config/validate.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
|
||||
"github.com/safing/portmaster/base/log"
|
||||
)
|
||||
|
||||
type valueCache struct {
|
||||
stringVal string
|
||||
stringArrayVal []string
|
||||
intVal int64
|
||||
boolVal bool
|
||||
}
|
||||
|
||||
func (vc *valueCache) getData(opt *Option) interface{} {
|
||||
switch opt.OptType {
|
||||
case OptTypeBool:
|
||||
return vc.boolVal
|
||||
case OptTypeInt:
|
||||
return vc.intVal
|
||||
case OptTypeString:
|
||||
return vc.stringVal
|
||||
case OptTypeStringArray:
|
||||
return vc.stringArrayVal
|
||||
case optTypeAny:
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// isAllowedPossibleValue checks if value is defined as a PossibleValue
|
||||
// in opt. If there are not possible values defined value is considered
|
||||
// allowed and nil is returned. isAllowedPossibleValue ensure the actual
|
||||
// value is an allowed primitiv value by using reflection to convert
|
||||
// value and each PossibleValue to a comparable primitiv if possible.
|
||||
// In case of complex value types isAllowedPossibleValue uses
|
||||
// reflect.DeepEqual as a fallback.
|
||||
func isAllowedPossibleValue(opt *Option, value interface{}) error {
|
||||
if opt.PossibleValues == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, val := range opt.PossibleValues {
|
||||
compareAgainst := val.Value
|
||||
valueType := reflect.TypeOf(value)
|
||||
|
||||
// loading int's from the configuration JSON does not preserve the correct type
|
||||
// as we get float64 instead. Make sure to convert them before.
|
||||
if reflect.TypeOf(val.Value).ConvertibleTo(valueType) {
|
||||
compareAgainst = reflect.ValueOf(val.Value).Convert(valueType).Interface()
|
||||
}
|
||||
if compareAgainst == value {
|
||||
return nil
|
||||
}
|
||||
|
||||
if reflect.DeepEqual(val.Value, value) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return errors.New("value is not allowed")
|
||||
}
|
||||
|
||||
// migrateValue runs all value migrations.
|
||||
func migrateValue(option *Option, value any) any {
|
||||
for _, migration := range option.Migrations {
|
||||
newValue := migration(option, value)
|
||||
if newValue != value {
|
||||
log.Debugf("config: migrated %s value from %v to %v", option.Key, value, newValue)
|
||||
}
|
||||
value = newValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// validateValue ensures that value matches the expected type of option.
|
||||
// It does not create a copy of the value!
|
||||
func validateValue(option *Option, value interface{}) (*valueCache, *ValidationError) { //nolint:gocyclo
|
||||
if option.OptType != OptTypeStringArray {
|
||||
if err := isAllowedPossibleValue(option, value); err != nil {
|
||||
return nil, &ValidationError{
|
||||
Option: option.copyOrNil(),
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var validated *valueCache
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if option.OptType != OptTypeString {
|
||||
return nil, invalid(option, "expected type %s, got type %T", getTypeName(option.OptType), v)
|
||||
}
|
||||
if option.compiledRegex != nil {
|
||||
if !option.compiledRegex.MatchString(v) {
|
||||
return nil, invalid(option, "did not match validation regex")
|
||||
}
|
||||
}
|
||||
validated = &valueCache{stringVal: v}
|
||||
case []interface{}:
|
||||
vConverted := make([]string, len(v))
|
||||
for pos, entry := range v {
|
||||
s, ok := entry.(string)
|
||||
if !ok {
|
||||
return nil, invalid(option, "entry #%d is not a string", pos+1)
|
||||
}
|
||||
vConverted[pos] = s
|
||||
}
|
||||
// Call validation function again with converted value.
|
||||
var vErr *ValidationError
|
||||
validated, vErr = validateValue(option, vConverted)
|
||||
if vErr != nil {
|
||||
return nil, vErr
|
||||
}
|
||||
case []string:
|
||||
if option.OptType != OptTypeStringArray {
|
||||
return nil, invalid(option, "expected type %s, got type %T", getTypeName(option.OptType), v)
|
||||
}
|
||||
if option.compiledRegex != nil {
|
||||
for pos, entry := range v {
|
||||
if !option.compiledRegex.MatchString(entry) {
|
||||
return nil, invalid(option, "entry #%d did not match validation regex", pos+1)
|
||||
}
|
||||
|
||||
if err := isAllowedPossibleValue(option, entry); err != nil {
|
||||
return nil, invalid(option, "entry #%d is not allowed", pos+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
validated = &valueCache{stringArrayVal: v}
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, float32, float64:
|
||||
// uint64 is omitted, as it does not fit in a int64
|
||||
if option.OptType != OptTypeInt {
|
||||
return nil, invalid(option, "expected type %s, got type %T", getTypeName(option.OptType), v)
|
||||
}
|
||||
if option.compiledRegex != nil {
|
||||
// we need to use %v here so we handle float and int correctly.
|
||||
if !option.compiledRegex.MatchString(fmt.Sprintf("%v", v)) {
|
||||
return nil, invalid(option, "did not match validation regex")
|
||||
}
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
validated = &valueCache{intVal: int64(v)}
|
||||
case int8:
|
||||
validated = &valueCache{intVal: int64(v)}
|
||||
case int16:
|
||||
validated = &valueCache{intVal: int64(v)}
|
||||
case int32:
|
||||
validated = &valueCache{intVal: int64(v)}
|
||||
case int64:
|
||||
validated = &valueCache{intVal: v}
|
||||
case uint:
|
||||
validated = &valueCache{intVal: int64(v)}
|
||||
case uint8:
|
||||
validated = &valueCache{intVal: int64(v)}
|
||||
case uint16:
|
||||
validated = &valueCache{intVal: int64(v)}
|
||||
case uint32:
|
||||
validated = &valueCache{intVal: int64(v)}
|
||||
case float32:
|
||||
// convert if float has no decimals
|
||||
if math.Remainder(float64(v), 1) == 0 {
|
||||
validated = &valueCache{intVal: int64(v)}
|
||||
} else {
|
||||
return nil, invalid(option, "failed to convert float32 to int64")
|
||||
}
|
||||
case float64:
|
||||
// convert if float has no decimals
|
||||
if math.Remainder(v, 1) == 0 {
|
||||
validated = &valueCache{intVal: int64(v)}
|
||||
} else {
|
||||
return nil, invalid(option, "failed to convert float64 to int64")
|
||||
}
|
||||
default:
|
||||
return nil, invalid(option, "internal error")
|
||||
}
|
||||
case bool:
|
||||
if option.OptType != OptTypeBool {
|
||||
return nil, invalid(option, "expected type %s, got type %T", getTypeName(option.OptType), v)
|
||||
}
|
||||
validated = &valueCache{boolVal: v}
|
||||
default:
|
||||
return nil, invalid(option, "invalid option value type: %T", value)
|
||||
}
|
||||
|
||||
// Check if there is an additional function to validate the value.
|
||||
if option.ValidationFunc != nil {
|
||||
var err error
|
||||
switch option.OptType {
|
||||
case optTypeAny:
|
||||
err = errors.New("internal error")
|
||||
case OptTypeString:
|
||||
err = option.ValidationFunc(validated.stringVal)
|
||||
case OptTypeStringArray:
|
||||
err = option.ValidationFunc(validated.stringArrayVal)
|
||||
case OptTypeInt:
|
||||
err = option.ValidationFunc(validated.intVal)
|
||||
case OptTypeBool:
|
||||
err = option.ValidationFunc(validated.boolVal)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, &ValidationError{
|
||||
Option: option.copyOrNil(),
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return validated, nil
|
||||
}
|
||||
|
||||
// ValidationError error holds details about a config option value validation error.
|
||||
type ValidationError struct {
|
||||
Option *Option
|
||||
Err error
|
||||
}
|
||||
|
||||
// Error returns the formatted error.
|
||||
func (ve *ValidationError) Error() string {
|
||||
return fmt.Sprintf("validation of %s failed: %s", ve.Option.Key, ve.Err)
|
||||
}
|
||||
|
||||
// Unwrap returns the wrapped error.
|
||||
func (ve *ValidationError) Unwrap() error {
|
||||
return ve.Err
|
||||
}
|
||||
|
||||
func invalid(option *Option, format string, a ...interface{}) *ValidationError {
|
||||
return &ValidationError{
|
||||
Option: option.copyOrNil(),
|
||||
Err: fmt.Errorf(format, a...),
|
||||
}
|
||||
}
|
||||
32
base/config/validity.go
Normal file
32
base/config/validity.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/tevino/abool"
|
||||
)
|
||||
|
||||
// ValidityFlag is a flag that signifies if the configuration has been changed. It is not safe for concurrent use.
|
||||
type ValidityFlag struct {
|
||||
flag *abool.AtomicBool
|
||||
}
|
||||
|
||||
// NewValidityFlag returns a flag that signifies if the configuration has been changed.
|
||||
// It always starts out as invalid. Refresh to start with the current value.
|
||||
func NewValidityFlag() *ValidityFlag {
|
||||
vf := &ValidityFlag{
|
||||
flag: abool.New(),
|
||||
}
|
||||
return vf
|
||||
}
|
||||
|
||||
// IsValid returns if the configuration is still valid.
|
||||
func (vf *ValidityFlag) IsValid() bool {
|
||||
return vf.flag.IsSet()
|
||||
}
|
||||
|
||||
// Refresh refreshes the flag and makes it reusable.
|
||||
func (vf *ValidityFlag) Refresh() {
|
||||
validityFlagLock.RLock()
|
||||
defer validityFlagLock.RUnlock()
|
||||
|
||||
vf.flag = validityFlag
|
||||
}
|
||||
Reference in New Issue
Block a user