mirror of
https://github.com/safing/portmaster
synced 2025-09-02 02:29:12 +00:00
Add export and import for profiles
This commit is contained in:
parent
beed574fa3
commit
602db080c5
13 changed files with 668 additions and 85 deletions
|
@ -1,10 +1,14 @@
|
||||||
package profile
|
package profile
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/safing/portbase/api"
|
"github.com/safing/portbase/api"
|
||||||
"github.com/safing/portbase/formats/dsd"
|
"github.com/safing/portbase/formats/dsd"
|
||||||
|
"github.com/safing/portbase/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func registerAPIEndpoints() error {
|
func registerAPIEndpoints() error {
|
||||||
|
@ -19,6 +23,28 @@ func registerAPIEndpoints() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := api.RegisterEndpoint(api.Endpoint{
|
||||||
|
Name: "Get Profile Icon",
|
||||||
|
Description: "Returns the requested profile icon.",
|
||||||
|
Path: "profile/icon/{id:[0-9a-f]{40-80}}.{ext:[a-z]{3-4}}",
|
||||||
|
Read: api.PermitUser,
|
||||||
|
BelongsTo: module,
|
||||||
|
DataFunc: handleGetProfileIcon,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := api.RegisterEndpoint(api.Endpoint{
|
||||||
|
Name: "Update Profile Icon",
|
||||||
|
Description: "Merge multiple profiles into a new one.",
|
||||||
|
Path: "profile/icon/update",
|
||||||
|
Write: api.PermitUser,
|
||||||
|
BelongsTo: module,
|
||||||
|
StructFunc: handleUpdateProfileIcon,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,3 +90,68 @@ func handleMergeProfiles(ar *api.Request) (i interface{}, err error) {
|
||||||
New: newProfile.ScopedID(),
|
New: newProfile.ScopedID(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleGetProfileIcon(ar *api.Request) (data []byte, err error) {
|
||||||
|
// Get profile icon.
|
||||||
|
data, err = GetProfileIcon(ar.URLVars["id"], ar.URLVars["ext"])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set content type for icon.
|
||||||
|
contentType, ok := utils.MimeTypeByExtension(ar.URLVars["ext"])
|
||||||
|
if ok {
|
||||||
|
ar.ResponseHeader.Set("Content-Type", contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateProfileIconResponse struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUpdateProfileIcon(ar *api.Request) (any, error) {
|
||||||
|
// Check input.
|
||||||
|
if len(ar.InputData) == 0 {
|
||||||
|
return nil, api.ErrorWithStatus(errors.New("no content"), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
mimeType := ar.Header.Get("Content-Type")
|
||||||
|
if mimeType == "" {
|
||||||
|
return nil, api.ErrorWithStatus(errors.New("no content type"), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive image format from content type.
|
||||||
|
mimeType = strings.TrimSpace(mimeType)
|
||||||
|
mimeType = strings.ToLower(mimeType)
|
||||||
|
mimeType, _, _ = strings.Cut(mimeType, ";")
|
||||||
|
var ext string
|
||||||
|
switch mimeType {
|
||||||
|
case "image/gif":
|
||||||
|
ext = "gif"
|
||||||
|
case "image/jpeg":
|
||||||
|
ext = "jpg"
|
||||||
|
case "image/jpg":
|
||||||
|
ext = "jpg"
|
||||||
|
case "image/png":
|
||||||
|
ext = "png"
|
||||||
|
case "image/svg+xml":
|
||||||
|
ext = "svg"
|
||||||
|
case "image/tiff":
|
||||||
|
ext = "tiff"
|
||||||
|
case "image/webp":
|
||||||
|
ext = "webp"
|
||||||
|
default:
|
||||||
|
return "", api.ErrorWithStatus(errors.New("unsupported image format"), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update profile icon.
|
||||||
|
filename, err := UpdateProfileIcon(ar.InputData, ext)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &updateProfileIconResponse{
|
||||||
|
Filename: filename,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
|
@ -24,11 +24,13 @@ var profileDB = database.NewInterface(&database.Options{
|
||||||
Internal: true,
|
Internal: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
func makeScopedID(source profileSource, id string) string {
|
// MakeScopedID returns a scoped profile ID.
|
||||||
|
func MakeScopedID(source ProfileSource, id string) string {
|
||||||
return string(source) + "/" + id
|
return string(source) + "/" + id
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeProfileKey(source profileSource, id string) string {
|
// MakeProfileKey returns a profile key.
|
||||||
|
func MakeProfileKey(source ProfileSource, id string) string {
|
||||||
return ProfilesDBPath + string(source) + "/" + id
|
return ProfilesDBPath + string(source) + "/" + id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -67,7 +67,7 @@ type (
|
||||||
// merged from. The merged profile should create a new profile ID derived
|
// merged from. The merged profile should create a new profile ID derived
|
||||||
// from the new fingerprints and add all fingerprints with this field set
|
// from the new fingerprints and add all fingerprints with this field set
|
||||||
// to the originating profile ID
|
// to the originating profile ID
|
||||||
MergedFrom string
|
MergedFrom string // `json:"mergedFrom,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tag represents a simple key/value kind of tag used in process metadata
|
// Tag represents a simple key/value kind of tag used in process metadata
|
||||||
|
@ -170,7 +170,8 @@ type parsedFingerprints struct {
|
||||||
cmdlinePrints []matchingFingerprint
|
cmdlinePrints []matchingFingerprint
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseFingerprints(raw []Fingerprint, deprecatedLinkedPath string) (parsed *parsedFingerprints, firstErr error) {
|
// ParseFingerprints parses the fingerprints to make them ready for matching.
|
||||||
|
func ParseFingerprints(raw []Fingerprint, deprecatedLinkedPath string) (parsed *parsedFingerprints, firstErr error) {
|
||||||
parsed = &parsedFingerprints{}
|
parsed = &parsedFingerprints{}
|
||||||
|
|
||||||
// Add deprecated LinkedPath to fingerprints, if they are empty.
|
// Add deprecated LinkedPath to fingerprints, if they are empty.
|
||||||
|
@ -230,7 +231,7 @@ func parseFingerprints(raw []Fingerprint, deprecatedLinkedPath string) (parsed *
|
||||||
|
|
||||||
default:
|
default:
|
||||||
if firstErr == nil {
|
if firstErr == nil {
|
||||||
firstErr = fmt.Errorf("unknown fingerprint operation: %q", entry.Type)
|
firstErr = fmt.Errorf("unknown fingerprint operation: %q", entry.Operation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -367,7 +368,8 @@ const (
|
||||||
deriveFPKeyIDForValue
|
deriveFPKeyIDForValue
|
||||||
)
|
)
|
||||||
|
|
||||||
func deriveProfileID(fps []Fingerprint) string {
|
// DeriveProfileID derives a profile ID from the given fingerprints.
|
||||||
|
func DeriveProfileID(fps []Fingerprint) string {
|
||||||
// Sort the fingerprints.
|
// Sort the fingerprints.
|
||||||
sortAndCompactFingerprints(fps)
|
sortAndCompactFingerprints(fps)
|
||||||
|
|
||||||
|
|
|
@ -47,7 +47,7 @@ func TestDeriveProfileID(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Check if fingerprint matches.
|
// Check if fingerprint matches.
|
||||||
id := deriveProfileID(fps)
|
id := DeriveProfileID(fps)
|
||||||
assert.Equal(t, "PTSRP7rdCnmvdjRoPMTrtjj7qk7PxR1a9YdBWUGwnZXJh2", id)
|
assert.Equal(t, "PTSRP7rdCnmvdjRoPMTrtjj7qk7PxR1a9YdBWUGwnZXJh2", id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,7 +35,7 @@ func GetLocalProfile(id string, md MatchingData, createProfileCallback func() *P
|
||||||
// Get active profile based on the ID, if available.
|
// Get active profile based on the ID, if available.
|
||||||
if id != "" {
|
if id != "" {
|
||||||
// Check if there already is an active profile.
|
// Check if there already is an active profile.
|
||||||
profile = getActiveProfile(makeScopedID(SourceLocal, id))
|
profile = getActiveProfile(MakeScopedID(SourceLocal, id))
|
||||||
if profile != nil {
|
if profile != nil {
|
||||||
// Mark active and return if not outdated.
|
// Mark active and return if not outdated.
|
||||||
if profile.outdated.IsNotSet() {
|
if profile.outdated.IsNotSet() {
|
||||||
|
@ -57,9 +57,9 @@ func GetLocalProfile(id string, md MatchingData, createProfileCallback func() *P
|
||||||
return nil, errors.New("cannot get local profiles without ID and matching data")
|
return nil, errors.New("cannot get local profiles without ID and matching data")
|
||||||
}
|
}
|
||||||
|
|
||||||
profile, err = getProfile(makeScopedID(SourceLocal, id))
|
profile, err = getProfile(MakeScopedID(SourceLocal, id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to load profile %s by ID: %w", makeScopedID(SourceLocal, id), err)
|
return nil, fmt.Errorf("failed to load profile %s by ID: %w", MakeScopedID(SourceLocal, id), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -70,7 +70,7 @@ func GetLocalProfile(id string, md MatchingData, createProfileCallback func() *P
|
||||||
|
|
||||||
// Get special profile from DB.
|
// Get special profile from DB.
|
||||||
if profile == nil {
|
if profile == nil {
|
||||||
profile, err = getProfile(makeScopedID(SourceLocal, id))
|
profile, err = getProfile(MakeScopedID(SourceLocal, id))
|
||||||
if err != nil && !errors.Is(err, database.ErrNotFound) {
|
if err != nil && !errors.Is(err, database.ErrNotFound) {
|
||||||
log.Warningf("profile: failed to get special profile %s: %s", id, err)
|
log.Warningf("profile: failed to get special profile %s: %s", id, err)
|
||||||
}
|
}
|
||||||
|
@ -188,12 +188,12 @@ func getProfile(scopedID string) (profile *Profile, err error) {
|
||||||
|
|
||||||
// findProfile searches for a profile with the given linked path. If it cannot
|
// findProfile searches for a profile with the given linked path. If it cannot
|
||||||
// find one, it will create a new profile for the given linked path.
|
// find one, it will create a new profile for the given linked path.
|
||||||
func findProfile(source profileSource, md MatchingData) (profile *Profile, err error) {
|
func findProfile(source ProfileSource, md MatchingData) (profile *Profile, err error) {
|
||||||
// TODO: Loading every profile from database and parsing it for every new
|
// TODO: Loading every profile from database and parsing it for every new
|
||||||
// process might be quite expensive. Measure impact and possibly improve.
|
// process might be quite expensive. Measure impact and possibly improve.
|
||||||
|
|
||||||
// Get iterator over all profiles.
|
// Get iterator over all profiles.
|
||||||
it, err := profileDB.Query(query.New(ProfilesDBPath + makeScopedID(source, "")))
|
it, err := profileDB.Query(query.New(ProfilesDBPath + MakeScopedID(source, "")))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to query for profiles: %w", err)
|
return nil, fmt.Errorf("failed to query for profiles: %w", err)
|
||||||
}
|
}
|
||||||
|
@ -265,7 +265,7 @@ func loadProfileFingerprints(r record.Record) (parsed *parsedFingerprints, err e
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and return fingerprints.
|
// Parse and return fingerprints.
|
||||||
return parseFingerprints(profile.Fingerprints, profile.LinkedPath)
|
return ParseFingerprints(profile.Fingerprints, profile.LinkedPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadProfile(r record.Record) (*Profile, error) {
|
func loadProfile(r record.Record) (*Profile, error) {
|
||||||
|
|
|
@ -19,14 +19,17 @@ type IconType string
|
||||||
const (
|
const (
|
||||||
IconTypeFile IconType = "path"
|
IconTypeFile IconType = "path"
|
||||||
IconTypeDatabase IconType = "database"
|
IconTypeDatabase IconType = "database"
|
||||||
|
IconTypeAPI IconType = "api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (t IconType) sortOrder() int {
|
func (t IconType) sortOrder() int {
|
||||||
switch t {
|
switch t {
|
||||||
case IconTypeDatabase:
|
case IconTypeAPI:
|
||||||
return 1
|
return 1
|
||||||
case IconTypeFile:
|
case IconTypeDatabase:
|
||||||
return 2
|
return 2
|
||||||
|
case IconTypeFile:
|
||||||
|
return 3
|
||||||
default:
|
default:
|
||||||
return 100
|
return 100
|
||||||
}
|
}
|
||||||
|
|
69
profile/icons.go
Normal file
69
profile/icons.go
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
package profile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/safing/portbase/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
var profileIconStoragePath = ""
|
||||||
|
|
||||||
|
// GetProfileIcon returns the profile icon with the given ID and extension.
|
||||||
|
func GetProfileIcon(id, ext string) (data []byte, err error) {
|
||||||
|
// Build storage path.
|
||||||
|
iconPath := filepath.Join(profileIconStoragePath, id+"."+ext)
|
||||||
|
iconPath, err = filepath.Abs(iconPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check icon path: %w", err)
|
||||||
|
}
|
||||||
|
// Do a quick check if we are still within the right directory.
|
||||||
|
// This check is not entirely correct, but is sufficient for this use case.
|
||||||
|
if !strings.HasPrefix(iconPath, profileIconStoragePath) {
|
||||||
|
return nil, api.ErrorWithStatus(errors.New("invalid icon"), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.ReadFile(iconPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProfileIcon creates or updates the given icon.
|
||||||
|
func UpdateProfileIcon(data []byte, ext string) (filename string, err error) {
|
||||||
|
// Check icon size.
|
||||||
|
if len(data) > 1_000_000 {
|
||||||
|
return "", errors.New("icon too big")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate sha1 sum of icon.
|
||||||
|
h := crypto.SHA1.New()
|
||||||
|
if _, err := h.Write(data); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sum := hex.EncodeToString(h.Sum(nil))
|
||||||
|
|
||||||
|
// Check ext.
|
||||||
|
ext = strings.ToLower(ext)
|
||||||
|
switch ext {
|
||||||
|
case "gif":
|
||||||
|
case "jpeg":
|
||||||
|
ext = "jpg"
|
||||||
|
case "jpg":
|
||||||
|
case "png":
|
||||||
|
case "svg":
|
||||||
|
case "tiff":
|
||||||
|
case "webp":
|
||||||
|
default:
|
||||||
|
return "", errors.New("unsupported icon format")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to disk.
|
||||||
|
filename = sum + "." + ext
|
||||||
|
return filename, os.WriteFile(filepath.Join(profileIconStoragePath, filename), data, 0o0644) //nolint:gosec
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Clean up icons regularly.
|
|
@ -208,7 +208,7 @@ func migrateToDerivedIDs(ctx context.Context, _, to *version.Version, db *databa
|
||||||
|
|
||||||
// Generate new ID.
|
// Generate new ID.
|
||||||
oldScopedID := profile.ScopedID()
|
oldScopedID := profile.ScopedID()
|
||||||
newID := deriveProfileID(profile.Fingerprints)
|
newID := DeriveProfileID(profile.Fingerprints)
|
||||||
|
|
||||||
// If they match, skip migration for this profile.
|
// If they match, skip migration for this profile.
|
||||||
if profile.ID == newID {
|
if profile.ID == newID {
|
||||||
|
|
|
@ -2,10 +2,12 @@ package profile
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/safing/portbase/database"
|
"github.com/safing/portbase/database"
|
||||||
"github.com/safing/portbase/database/migration"
|
"github.com/safing/portbase/database/migration"
|
||||||
|
"github.com/safing/portbase/dataroot"
|
||||||
"github.com/safing/portbase/log"
|
"github.com/safing/portbase/log"
|
||||||
"github.com/safing/portbase/modules"
|
"github.com/safing/portbase/modules"
|
||||||
_ "github.com/safing/portmaster/core/base"
|
_ "github.com/safing/portmaster/core/base"
|
||||||
|
@ -45,6 +47,13 @@ func prep() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup icon storage location.
|
||||||
|
iconsDir := dataroot.Root().ChildDir("databases", 0o0700).ChildDir("icons", 0o0700)
|
||||||
|
if err := iconsDir.Ensure(); err != nil {
|
||||||
|
return fmt.Errorf("failed to create/check icons directory: %w", err)
|
||||||
|
}
|
||||||
|
profileIconStoragePath = iconsDir.Path
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,13 +20,13 @@ import (
|
||||||
"github.com/safing/portmaster/profile/endpoints"
|
"github.com/safing/portmaster/profile/endpoints"
|
||||||
)
|
)
|
||||||
|
|
||||||
// profileSource is the source of the profile.
|
// ProfileSource is the source of the profile.
|
||||||
type profileSource string
|
type ProfileSource string
|
||||||
|
|
||||||
// Profile Sources.
|
// Profile Sources.
|
||||||
const (
|
const (
|
||||||
SourceLocal profileSource = "local" // local, editable
|
SourceLocal ProfileSource = "local" // local, editable
|
||||||
SourceSpecial profileSource = "special" // specials (read-only)
|
SourceSpecial ProfileSource = "special" // specials (read-only)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Default Action IDs.
|
// Default Action IDs.
|
||||||
|
@ -45,7 +45,7 @@ type Profile struct { //nolint:maligned // not worth the effort
|
||||||
// ID is a unique identifier for the profile.
|
// ID is a unique identifier for the profile.
|
||||||
ID string // constant
|
ID string // constant
|
||||||
// Source describes the source of the profile.
|
// Source describes the source of the profile.
|
||||||
Source profileSource // constant
|
Source ProfileSource // constant
|
||||||
// Name is a human readable name of the profile. It
|
// Name is a human readable name of the profile. It
|
||||||
// defaults to the basename of the application.
|
// defaults to the basename of the application.
|
||||||
Name string
|
Name string
|
||||||
|
@ -262,7 +262,7 @@ func New(profile *Profile) *Profile {
|
||||||
if profile.ID == "" {
|
if profile.ID == "" {
|
||||||
if len(profile.Fingerprints) > 0 {
|
if len(profile.Fingerprints) > 0 {
|
||||||
// Derive from fingerprints.
|
// Derive from fingerprints.
|
||||||
profile.ID = deriveProfileID(profile.Fingerprints)
|
profile.ID = DeriveProfileID(profile.Fingerprints)
|
||||||
} else {
|
} else {
|
||||||
// Generate random ID as fallback.
|
// Generate random ID as fallback.
|
||||||
log.Warningf("profile: creating new profile without fingerprints to derive ID from")
|
log.Warningf("profile: creating new profile without fingerprints to derive ID from")
|
||||||
|
@ -284,12 +284,12 @@ func New(profile *Profile) *Profile {
|
||||||
|
|
||||||
// ScopedID returns the scoped ID (Source + ID) of the profile.
|
// ScopedID returns the scoped ID (Source + ID) of the profile.
|
||||||
func (profile *Profile) ScopedID() string {
|
func (profile *Profile) ScopedID() string {
|
||||||
return makeScopedID(profile.Source, profile.ID)
|
return MakeScopedID(profile.Source, profile.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// makeKey derives and sets the record Key from the profile attributes.
|
// makeKey derives and sets the record Key from the profile attributes.
|
||||||
func (profile *Profile) makeKey() {
|
func (profile *Profile) makeKey() {
|
||||||
profile.SetKey(makeProfileKey(profile.Source, profile.ID))
|
profile.SetKey(MakeProfileKey(profile.Source, profile.ID))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save saves the profile to the database.
|
// Save saves the profile to the database.
|
||||||
|
|
|
@ -25,5 +25,8 @@ func prep() error {
|
||||||
if err := registerSingleSettingAPI(); err != nil {
|
if err := registerSingleSettingAPI(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := registerProfileAPI(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
435
sync/profile.go
435
sync/profile.go
|
@ -1,45 +1,440 @@
|
||||||
package sync
|
package sync
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/safing/portbase/api"
|
||||||
|
"github.com/safing/portbase/config"
|
||||||
|
"github.com/safing/portbase/log"
|
||||||
"github.com/safing/portmaster/profile"
|
"github.com/safing/portmaster/profile"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProfileExport holds an export of a profile.
|
// ProfileExport holds an export of a profile.
|
||||||
type ProfileExport struct { //nolint:maligned
|
type ProfileExport struct { //nolint:maligned
|
||||||
Type Type
|
Type Type `json:"type"`
|
||||||
|
|
||||||
// Identification (sync or import as new only)
|
// Identification
|
||||||
ID string
|
ID string `json:"id,omitempty"`
|
||||||
Source string
|
Source profile.ProfileSource `json:"source,omitempty"`
|
||||||
|
|
||||||
// Human Metadata
|
// Human Metadata
|
||||||
Name string
|
Name string `json:"name"`
|
||||||
Description string
|
Description string `json:"description,omitempty"`
|
||||||
Homepage string
|
Homepage string `json:"homepage,omitempty"`
|
||||||
Icons []profile.Icon
|
Icons []ProfileIcon `json:"icons,omitempty"`
|
||||||
PresentationPath string
|
PresentationPath string `json:"presPath,omitempty"`
|
||||||
UsePresentationPath bool
|
UsePresentationPath bool `json:"usePresPath,omitempty"`
|
||||||
|
|
||||||
// Process matching
|
// Process matching
|
||||||
Fingerprints []profile.Fingerprint
|
Fingerprints []ProfileFingerprint `json:"fingerprints"`
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
Config map[string]any
|
Config map[string]any `json:"config,omitempty"`
|
||||||
|
|
||||||
// Metadata (sync only)
|
// Metadata
|
||||||
LastEdited time.Time
|
LastEdited *time.Time `json:"lastEdited,omitempty"`
|
||||||
Created time.Time
|
Created *time.Time `json:"created,omitempty"`
|
||||||
Internal bool
|
Internal bool `json:"internal,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfileIcon represents a profile icon.
|
||||||
|
type ProfileIcon struct {
|
||||||
|
Type profile.IconType `json:"type"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfileIcon represents a profile fingerprint.
|
||||||
|
type ProfileFingerprint struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
Operation string `json:"operation"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
MergedFrom string `json:"mergedFrom,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfileExportRequest is a request for a profile export.
|
||||||
|
type ProfileExportRequest struct {
|
||||||
|
ID string `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProfileImportRequest is a request to import Profile.
|
// ProfileImportRequest is a request to import Profile.
|
||||||
type ProfileImportRequest struct {
|
type ProfileImportRequest struct {
|
||||||
ImportRequest
|
ImportRequest `json:",inline"`
|
||||||
|
|
||||||
// Reset all settings and fingerprints of target before import.
|
// AllowUnknown allows the import of unknown settings.
|
||||||
Reset bool
|
// Otherwise, attempting to import an unknown setting will result in an error.
|
||||||
|
AllowUnknown bool `json:"allowUnknown"`
|
||||||
|
|
||||||
Export *ProfileExport
|
// AllowReplace allows the import to replace other existing profiles.
|
||||||
|
AllowReplace bool `json:"allowReplaceProfiles"`
|
||||||
|
|
||||||
|
Export *ProfileExport `json:"export"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfileImportResult is returned by successful import operations.
|
||||||
|
type ProfileImportResult struct {
|
||||||
|
ImportResult `json:",inline"`
|
||||||
|
|
||||||
|
ReplacesProfiles []string `json:"replacesProfiles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerProfileAPI() error {
|
||||||
|
if err := api.RegisterEndpoint(api.Endpoint{
|
||||||
|
Name: "Export App Profile",
|
||||||
|
Description: "Exports app fingerprints, settings and metadata in a share-able format.",
|
||||||
|
Path: "sync/profile/export",
|
||||||
|
Read: api.PermitAdmin,
|
||||||
|
Write: api.PermitAdmin,
|
||||||
|
Parameters: []api.Parameter{{
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Field: "id",
|
||||||
|
Description: "Specify scoped profile ID to export.",
|
||||||
|
}},
|
||||||
|
BelongsTo: module,
|
||||||
|
DataFunc: handleExportProfile,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := api.RegisterEndpoint(api.Endpoint{
|
||||||
|
Name: "Import App Profile",
|
||||||
|
Description: "Imports full app profiles, including fingerprints, setting and metadata from the share-able format.",
|
||||||
|
Path: "sync/profile/import",
|
||||||
|
Read: api.PermitAdmin,
|
||||||
|
Write: api.PermitAdmin,
|
||||||
|
Parameters: []api.Parameter{
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Field: "allowReplace",
|
||||||
|
Description: "Allow replacing existing profiles.",
|
||||||
|
}, {
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Field: "validate",
|
||||||
|
Description: "Validate only.",
|
||||||
|
}, {
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Field: "reset",
|
||||||
|
Description: "Replace all existing settings.",
|
||||||
|
}, {
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Field: "allowUnknown",
|
||||||
|
Description: "Allow importing of unknown values.",
|
||||||
|
}},
|
||||||
|
BelongsTo: module,
|
||||||
|
StructFunc: handleImportProfile,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleExportProfile(ar *api.Request) (data []byte, err error) {
|
||||||
|
var request *ProfileExportRequest
|
||||||
|
|
||||||
|
// Get parameters.
|
||||||
|
q := ar.URL.Query()
|
||||||
|
if len(q) > 0 {
|
||||||
|
request = &ProfileExportRequest{
|
||||||
|
ID: q.Get("id"),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
request = &ProfileExportRequest{}
|
||||||
|
if err := json.Unmarshal(ar.InputData, request); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: failed to parse export request: %w", ErrExportFailed, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check parameters.
|
||||||
|
if request.ID == "" {
|
||||||
|
return nil, errors.New("missing parameters")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export.
|
||||||
|
export, err := ExportProfile(request.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return serializeExport(export, ar)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleImportProfile(ar *api.Request) (any, error) {
|
||||||
|
var request *ProfileImportRequest
|
||||||
|
|
||||||
|
// Get parameters.
|
||||||
|
q := ar.URL.Query()
|
||||||
|
if len(q) > 0 {
|
||||||
|
request = &ProfileImportRequest{
|
||||||
|
ImportRequest: ImportRequest{
|
||||||
|
ValidateOnly: q.Has("validate"),
|
||||||
|
RawExport: string(ar.InputData),
|
||||||
|
RawMime: ar.Header.Get("Content-Type"),
|
||||||
|
},
|
||||||
|
AllowUnknown: q.Has("allowUnknown"),
|
||||||
|
AllowReplace: q.Has("allowReplace"),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
request = &ProfileImportRequest{}
|
||||||
|
if err := json.Unmarshal(ar.InputData, request); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: failed to parse import request: %w", ErrInvalidImportRequest, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we need to parse the export.
|
||||||
|
switch {
|
||||||
|
case request.Export != nil && request.RawExport != "":
|
||||||
|
return nil, fmt.Errorf("%w: both Export and RawExport are defined", ErrInvalidImportRequest)
|
||||||
|
case request.RawExport != "":
|
||||||
|
// Parse export.
|
||||||
|
export := &ProfileExport{}
|
||||||
|
if err := parseExport(&request.ImportRequest, export); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
request.Export = export
|
||||||
|
case request.Export != nil:
|
||||||
|
// Export is aleady parsed.
|
||||||
|
default:
|
||||||
|
return nil, ErrInvalidImportRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import.
|
||||||
|
return ImportProfile(request, profile.SourceLocal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportProfile exports a profile.
|
||||||
|
func ExportProfile(scopedID string) (*ProfileExport, error) {
|
||||||
|
// Get Profile.
|
||||||
|
r, err := db.Get(profile.ProfilesDBPath + scopedID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: failed to find profile: %w", ErrTargetNotFound, err)
|
||||||
|
}
|
||||||
|
p, err := profile.EnsureProfile(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: failed to load profile: %w", ErrExportFailed, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy exportable profile data.
|
||||||
|
export := &ProfileExport{
|
||||||
|
Type: TypeProfile,
|
||||||
|
|
||||||
|
// Identification
|
||||||
|
ID: p.ID,
|
||||||
|
Source: p.Source,
|
||||||
|
|
||||||
|
// Human Metadata
|
||||||
|
Name: p.Name,
|
||||||
|
Description: p.Description,
|
||||||
|
Homepage: p.Homepage,
|
||||||
|
Icons: convertIconsToExport(p.Icons),
|
||||||
|
PresentationPath: p.PresentationPath,
|
||||||
|
UsePresentationPath: p.UsePresentationPath,
|
||||||
|
|
||||||
|
// Process matching
|
||||||
|
Fingerprints: convertFingerprintsToExport(p.Fingerprints),
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
Config: p.Config,
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
Internal: p.Internal,
|
||||||
|
}
|
||||||
|
// Add optional timestamps.
|
||||||
|
if p.LastEdited > 0 {
|
||||||
|
lastEdited := time.Unix(p.LastEdited, 0)
|
||||||
|
export.LastEdited = &lastEdited
|
||||||
|
}
|
||||||
|
if p.Created > 0 {
|
||||||
|
created := time.Unix(p.Created, 0)
|
||||||
|
export.Created = &created
|
||||||
|
}
|
||||||
|
|
||||||
|
return export, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportProfile imports a profile.
|
||||||
|
func ImportProfile(r *ProfileImportRequest, requiredProfileSource profile.ProfileSource) (*ProfileImportResult, error) {
|
||||||
|
// Check import.
|
||||||
|
if r.Export.Type != TypeProfile {
|
||||||
|
return nil, ErrMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Source.
|
||||||
|
if r.Export.Source != "" && r.Export.Source != requiredProfileSource {
|
||||||
|
return nil, ErrMismatch
|
||||||
|
}
|
||||||
|
// Check ID.
|
||||||
|
fingerprints := convertFingerprintsToInternal(r.Export.Fingerprints)
|
||||||
|
profileID := profile.DeriveProfileID(fingerprints)
|
||||||
|
if r.Export.ID != "" && r.Export.ID != profileID {
|
||||||
|
return nil, ErrMismatch
|
||||||
|
} else {
|
||||||
|
r.Export.ID = profileID
|
||||||
|
}
|
||||||
|
// Check Fingerprints.
|
||||||
|
_, err := profile.ParseFingerprints(fingerprints, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: the export contains invalid fingerprints: %w", ErrInvalidProfileData, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten config.
|
||||||
|
settings := config.Flatten(r.Export.Config)
|
||||||
|
|
||||||
|
// Check settings.
|
||||||
|
settingsResult, globalOnlySettingFound, err := checkSettings(settings)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if settingsResult.ContainsUnknown && !r.AllowUnknown && !r.ValidateOnly {
|
||||||
|
return nil, fmt.Errorf("%w: the export contains unknown settings", ErrInvalidImportRequest)
|
||||||
|
}
|
||||||
|
// Check if a setting is settable per app.
|
||||||
|
if globalOnlySettingFound {
|
||||||
|
return nil, fmt.Errorf("%w: export contains settings that cannot be set per app", ErrNotSettablePerApp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create result based on settings result.
|
||||||
|
result := &ProfileImportResult{
|
||||||
|
ImportResult: *settingsResult,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the profile already exists.
|
||||||
|
exists, err := db.Exists(profile.MakeProfileKey(r.Export.Source, r.Export.ID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("internal import error: %w", err)
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
result.ReplacesExisting = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if import will delete any profiles.
|
||||||
|
requiredSourcePrefix := string(r.Export.Source) + "/"
|
||||||
|
result.ReplacesProfiles = make([]string, 0, len(r.Export.Fingerprints))
|
||||||
|
for _, fp := range r.Export.Fingerprints {
|
||||||
|
if fp.MergedFrom != "" {
|
||||||
|
if !strings.HasPrefix(fp.MergedFrom, requiredSourcePrefix) {
|
||||||
|
return nil, fmt.Errorf("%w: exported profile was merged from different profile source", ErrInvalidImportRequest)
|
||||||
|
}
|
||||||
|
exists, err := db.Exists(profile.ProfilesDBPath + fp.MergedFrom)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("internal import error: %w", err)
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
result.ReplacesProfiles = append(result.ReplacesProfiles, fp.MergedFrom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop here if we are only validating.
|
||||||
|
if r.ValidateOnly {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if result.ReplacesExisting && !r.AllowReplace {
|
||||||
|
return nil, fmt.Errorf("%w: import would replace existing profile", ErrImportFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create profile from export.
|
||||||
|
// Note: Don't use profile.New(), as this will not trigger a profile refresh if active.
|
||||||
|
in := r.Export
|
||||||
|
p := &profile.Profile{
|
||||||
|
// Identification
|
||||||
|
ID: in.ID,
|
||||||
|
Source: requiredProfileSource,
|
||||||
|
|
||||||
|
// Human Metadata
|
||||||
|
Name: in.Name,
|
||||||
|
Description: in.Description,
|
||||||
|
Homepage: in.Homepage,
|
||||||
|
Icons: convertIconsToInternal(in.Icons),
|
||||||
|
PresentationPath: in.PresentationPath,
|
||||||
|
UsePresentationPath: in.UsePresentationPath,
|
||||||
|
|
||||||
|
// Process matching
|
||||||
|
Fingerprints: fingerprints,
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
Config: in.Config,
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
Internal: in.Internal,
|
||||||
|
}
|
||||||
|
// Add optional timestamps.
|
||||||
|
if in.LastEdited != nil {
|
||||||
|
p.LastEdited = in.LastEdited.Unix()
|
||||||
|
}
|
||||||
|
if in.Created != nil {
|
||||||
|
p.Created = in.Created.Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save profile to db.
|
||||||
|
p.SetKey(profile.MakeProfileKey(p.Source, p.ID))
|
||||||
|
err = p.Save()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: failed to save profile: %w", ErrImportFailed, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete profiles that were merged into the imported profile.
|
||||||
|
for _, profileID := range result.ReplacesProfiles {
|
||||||
|
err := db.Delete(profile.ProfilesDBPath + profileID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("sync: failed to delete merged profile %s on import: %s", profileID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertIconsToExport(icons []profile.Icon) []ProfileIcon {
|
||||||
|
converted := make([]ProfileIcon, 0, len(icons))
|
||||||
|
for _, icon := range icons {
|
||||||
|
converted = append(converted, ProfileIcon{
|
||||||
|
Type: icon.Type,
|
||||||
|
Value: icon.Value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return converted
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertIconsToInternal(icons []ProfileIcon) []profile.Icon {
|
||||||
|
converted := make([]profile.Icon, 0, len(icons))
|
||||||
|
for _, icon := range icons {
|
||||||
|
converted = append(converted, profile.Icon{
|
||||||
|
Type: icon.Type,
|
||||||
|
Value: icon.Value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return converted
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertFingerprintsToExport(fingerprints []profile.Fingerprint) []ProfileFingerprint {
|
||||||
|
converted := make([]ProfileFingerprint, 0, len(fingerprints))
|
||||||
|
for _, fp := range fingerprints {
|
||||||
|
converted = append(converted, ProfileFingerprint{
|
||||||
|
Type: fp.Type,
|
||||||
|
Key: fp.Key,
|
||||||
|
Operation: fp.Operation,
|
||||||
|
Value: fp.Value,
|
||||||
|
MergedFrom: fp.MergedFrom,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return converted
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertFingerprintsToInternal(fingerprints []ProfileFingerprint) []profile.Fingerprint {
|
||||||
|
converted := make([]profile.Fingerprint, 0, len(fingerprints))
|
||||||
|
for _, fp := range fingerprints {
|
||||||
|
converted = append(converted, profile.Fingerprint{
|
||||||
|
Type: fp.Type,
|
||||||
|
Key: fp.Key,
|
||||||
|
Operation: fp.Operation,
|
||||||
|
Value: fp.Value,
|
||||||
|
MergedFrom: fp.MergedFrom,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return converted
|
||||||
}
|
}
|
||||||
|
|
|
@ -221,52 +221,16 @@ func ImportSettings(r *SettingsImportRequest) (*ImportResult, error) {
|
||||||
if r.Export.Type != TypeSettings {
|
if r.Export.Type != TypeSettings {
|
||||||
return nil, ErrMismatch
|
return nil, ErrMismatch
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flatten config.
|
// Flatten config.
|
||||||
settings := config.Flatten(r.Export.Config)
|
settings := config.Flatten(r.Export.Config)
|
||||||
|
|
||||||
// Validate config and gather some metadata.
|
// Check settings.
|
||||||
var (
|
result, globalOnlySettingFound, err := checkSettings(settings)
|
||||||
result = &ImportResult{}
|
|
||||||
checked int
|
|
||||||
globalOnlySettingFound bool
|
|
||||||
)
|
|
||||||
err := config.ForEachOption(func(option *config.Option) error {
|
|
||||||
// Check if any setting is set.
|
|
||||||
if r.Reset && option.IsSetByUser() {
|
|
||||||
result.ReplacesExisting = true
|
|
||||||
}
|
|
||||||
|
|
||||||
newValue, ok := settings[option.Key]
|
|
||||||
if ok {
|
|
||||||
checked++
|
|
||||||
|
|
||||||
// Validate the new value.
|
|
||||||
if err := option.ValidateValue(newValue); err != nil {
|
|
||||||
return fmt.Errorf("%w: configuration value for %s is invalid: %w", ErrInvalidSettingValue, option.Key, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect metadata.
|
|
||||||
if option.RequiresRestart {
|
|
||||||
result.RestartRequired = true
|
|
||||||
}
|
|
||||||
if !r.Reset && option.IsSetByUser() {
|
|
||||||
result.ReplacesExisting = true
|
|
||||||
}
|
|
||||||
if !option.AnnotationEquals(config.SettablePerAppAnnotation, true) {
|
|
||||||
globalOnlySettingFound = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if checked < len(settings) {
|
if result.ContainsUnknown && !r.AllowUnknown && !r.ValidateOnly {
|
||||||
result.ContainsUnknown = true
|
return nil, fmt.Errorf("%w: the export contains unknown settings", ErrInvalidImportRequest)
|
||||||
if !r.AllowUnknown && !r.ValidateOnly {
|
|
||||||
return nil, fmt.Errorf("%w: the export contains unknown settings", ErrInvalidImportRequest)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import global settings.
|
// Import global settings.
|
||||||
|
@ -334,3 +298,48 @@ func ImportSettings(r *SettingsImportRequest) (*ImportResult, error) {
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkSettings(settings map[string]any) (result *ImportResult, globalOnlySettingFound bool, err error) {
|
||||||
|
result = &ImportResult{}
|
||||||
|
|
||||||
|
// Validate config and gather some metadata.
|
||||||
|
var checked int
|
||||||
|
err = config.ForEachOption(func(option *config.Option) error {
|
||||||
|
// Check if any setting is set.
|
||||||
|
// TODO: Fix this - it only checks for global settings.
|
||||||
|
// if r.Reset && option.IsSetByUser() {
|
||||||
|
// result.ReplacesExisting = true
|
||||||
|
// }
|
||||||
|
|
||||||
|
newValue, ok := settings[option.Key]
|
||||||
|
if ok {
|
||||||
|
checked++
|
||||||
|
|
||||||
|
// Validate the new value.
|
||||||
|
if err := option.ValidateValue(newValue); err != nil {
|
||||||
|
return fmt.Errorf("%w: configuration value for %s is invalid: %w", ErrInvalidSettingValue, option.Key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect metadata.
|
||||||
|
if option.RequiresRestart {
|
||||||
|
result.RestartRequired = true
|
||||||
|
}
|
||||||
|
// TODO: Fix this - it only checks for global settings.
|
||||||
|
// if !r.Reset && option.IsSetByUser() {
|
||||||
|
// result.ReplacesExisting = true
|
||||||
|
// }
|
||||||
|
if !option.AnnotationEquals(config.SettablePerAppAnnotation, true) {
|
||||||
|
globalOnlySettingFound = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if checked < len(settings) {
|
||||||
|
result.ContainsUnknown = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, globalOnlySettingFound, nil
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue