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:
Daniel Hååvi
2024-08-09 17:15:48 +02:00
committed by GitHub
parent 10a77498f4
commit 80664d1a27
647 changed files with 37690 additions and 3366 deletions

106
base/config/basic_config.go Normal file
View 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
View 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
View File

@@ -0,0 +1,2 @@
// Package config provides a versatile configuration management system.
package config

104
base/config/expertise.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

View 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
View 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
View 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
}

View 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
View 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
View 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
View 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
View 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
View 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
}