add persistent proxy/visitor store with CRUD API and web UI (#5188)

This commit is contained in:
fatedier
2026-03-02 01:09:59 +08:00
committed by GitHub
parent d0347325fc
commit 01997deb98
89 changed files with 13960 additions and 3864 deletions

View File

@@ -0,0 +1,117 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package source
import (
"errors"
"fmt"
"sort"
"sync"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
type Aggregator struct {
mu sync.RWMutex
configSource *ConfigSource
storeSource *StoreSource
}
func NewAggregator(configSource *ConfigSource) *Aggregator {
if configSource == nil {
configSource = NewConfigSource()
}
return &Aggregator{
configSource: configSource,
}
}
func (a *Aggregator) SetStoreSource(storeSource *StoreSource) {
a.mu.Lock()
defer a.mu.Unlock()
a.storeSource = storeSource
}
func (a *Aggregator) ConfigSource() *ConfigSource {
return a.configSource
}
func (a *Aggregator) StoreSource() *StoreSource {
return a.storeSource
}
func (a *Aggregator) getSourcesLocked() []Source {
sources := make([]Source, 0, 2)
if a.configSource != nil {
sources = append(sources, a.configSource)
}
if a.storeSource != nil {
sources = append(sources, a.storeSource)
}
return sources
}
func (a *Aggregator) Load() ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) {
a.mu.RLock()
entries := a.getSourcesLocked()
a.mu.RUnlock()
if len(entries) == 0 {
return nil, nil, errors.New("no sources configured")
}
proxyMap := make(map[string]v1.ProxyConfigurer)
visitorMap := make(map[string]v1.VisitorConfigurer)
for _, src := range entries {
proxies, visitors, err := src.Load()
if err != nil {
return nil, nil, fmt.Errorf("load source: %w", err)
}
for _, p := range proxies {
proxyMap[p.GetBaseConfig().Name] = p
}
for _, v := range visitors {
visitorMap[v.GetBaseConfig().Name] = v
}
}
proxies, visitors := a.mapsToSortedSlices(proxyMap, visitorMap)
return proxies, visitors, nil
}
func (a *Aggregator) mapsToSortedSlices(
proxyMap map[string]v1.ProxyConfigurer,
visitorMap map[string]v1.VisitorConfigurer,
) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer) {
proxies := make([]v1.ProxyConfigurer, 0, len(proxyMap))
for _, p := range proxyMap {
proxies = append(proxies, p)
}
sort.Slice(proxies, func(i, j int) bool {
return proxies[i].GetBaseConfig().Name < proxies[j].GetBaseConfig().Name
})
visitors := make([]v1.VisitorConfigurer, 0, len(visitorMap))
for _, v := range visitorMap {
visitors = append(visitors, v)
}
sort.Slice(visitors, func(i, j int) bool {
return visitors[i].GetBaseConfig().Name < visitors[j].GetBaseConfig().Name
})
return proxies, visitors
}

View File

@@ -0,0 +1,217 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package source
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
// mockProxy creates a TCP proxy config for testing
func mockProxy(name string) v1.ProxyConfigurer {
cfg := &v1.TCPProxyConfig{}
cfg.Name = name
cfg.Type = "tcp"
cfg.LocalPort = 8080
cfg.RemotePort = 9090
return cfg
}
// mockVisitor creates a STCP visitor config for testing
func mockVisitor(name string) v1.VisitorConfigurer {
cfg := &v1.STCPVisitorConfig{}
cfg.Name = name
cfg.Type = "stcp"
cfg.ServerName = "test-server"
return cfg
}
func newTestStoreSource(t *testing.T) *StoreSource {
t.Helper()
path := filepath.Join(t.TempDir(), "store.json")
storeSource, err := NewStoreSource(StoreSourceConfig{Path: path})
require.NoError(t, err)
return storeSource
}
func newTestAggregator(t *testing.T, storeSource *StoreSource) *Aggregator {
t.Helper()
configSource := NewConfigSource()
agg := NewAggregator(configSource)
if storeSource != nil {
agg.SetStoreSource(storeSource)
}
return agg
}
func TestNewAggregator_CreatesConfigSourceWhenNil(t *testing.T) {
require := require.New(t)
agg := NewAggregator(nil)
require.NotNil(agg)
require.NotNil(agg.ConfigSource())
require.Nil(agg.StoreSource())
}
func TestNewAggregator_WithoutStore(t *testing.T) {
require := require.New(t)
configSource := NewConfigSource()
agg := NewAggregator(configSource)
require.NotNil(agg)
require.Same(configSource, agg.ConfigSource())
require.Nil(agg.StoreSource())
}
func TestNewAggregator_WithStore(t *testing.T) {
require := require.New(t)
storeSource := newTestStoreSource(t)
configSource := NewConfigSource()
agg := NewAggregator(configSource)
agg.SetStoreSource(storeSource)
require.Same(configSource, agg.ConfigSource())
require.Same(storeSource, agg.StoreSource())
}
func TestAggregator_SetStoreSource_Overwrite(t *testing.T) {
require := require.New(t)
agg := newTestAggregator(t, nil)
first := newTestStoreSource(t)
second := newTestStoreSource(t)
agg.SetStoreSource(first)
require.Same(first, agg.StoreSource())
agg.SetStoreSource(second)
require.Same(second, agg.StoreSource())
agg.SetStoreSource(nil)
require.Nil(agg.StoreSource())
}
func TestAggregator_MergeBySourceOrder(t *testing.T) {
require := require.New(t)
storeSource := newTestStoreSource(t)
agg := newTestAggregator(t, storeSource)
configSource := agg.ConfigSource()
configShared := mockProxy("shared").(*v1.TCPProxyConfig)
configShared.LocalPort = 1111
configOnly := mockProxy("only-in-config").(*v1.TCPProxyConfig)
configOnly.LocalPort = 1112
err := configSource.ReplaceAll([]v1.ProxyConfigurer{configShared, configOnly}, nil)
require.NoError(err)
storeShared := mockProxy("shared").(*v1.TCPProxyConfig)
storeShared.LocalPort = 2222
storeOnly := mockProxy("only-in-store").(*v1.TCPProxyConfig)
storeOnly.LocalPort = 2223
err = storeSource.AddProxy(storeShared)
require.NoError(err)
err = storeSource.AddProxy(storeOnly)
require.NoError(err)
proxies, visitors, err := agg.Load()
require.NoError(err)
require.Len(visitors, 0)
require.Len(proxies, 3)
var sharedProxy *v1.TCPProxyConfig
for _, p := range proxies {
if p.GetBaseConfig().Name == "shared" {
sharedProxy = p.(*v1.TCPProxyConfig)
break
}
}
require.NotNil(sharedProxy)
require.Equal(2222, sharedProxy.LocalPort)
}
func TestAggregator_DisabledEntryIsSourceLocalFilter(t *testing.T) {
require := require.New(t)
storeSource := newTestStoreSource(t)
agg := newTestAggregator(t, storeSource)
configSource := agg.ConfigSource()
lowProxy := mockProxy("shared-proxy").(*v1.TCPProxyConfig)
lowProxy.LocalPort = 1111
err := configSource.ReplaceAll([]v1.ProxyConfigurer{lowProxy}, nil)
require.NoError(err)
disabled := false
highProxy := mockProxy("shared-proxy").(*v1.TCPProxyConfig)
highProxy.LocalPort = 2222
highProxy.Enabled = &disabled
err = storeSource.AddProxy(highProxy)
require.NoError(err)
proxies, visitors, err := agg.Load()
require.NoError(err)
require.Len(proxies, 1)
require.Len(visitors, 0)
proxy := proxies[0].(*v1.TCPProxyConfig)
require.Equal("shared-proxy", proxy.Name)
require.Equal(1111, proxy.LocalPort)
}
func TestAggregator_VisitorMerge(t *testing.T) {
require := require.New(t)
storeSource := newTestStoreSource(t)
agg := newTestAggregator(t, storeSource)
err := agg.ConfigSource().ReplaceAll(nil, []v1.VisitorConfigurer{mockVisitor("visitor1")})
require.NoError(err)
err = storeSource.AddVisitor(mockVisitor("visitor2"))
require.NoError(err)
_, visitors, err := agg.Load()
require.NoError(err)
require.Len(visitors, 2)
}
func TestAggregator_Load_ReturnsDefensiveCopies(t *testing.T) {
require := require.New(t)
agg := newTestAggregator(t, nil)
err := agg.ConfigSource().ReplaceAll([]v1.ProxyConfigurer{mockProxy("ssh")}, nil)
require.NoError(err)
proxies, _, err := agg.Load()
require.NoError(err)
require.Len(proxies, 1)
require.Equal("ssh", proxies[0].GetBaseConfig().Name)
proxies[0].GetBaseConfig().Name = "alice.ssh"
proxies2, _, err := agg.Load()
require.NoError(err)
require.Len(proxies2, 1)
require.Equal("ssh", proxies2[0].GetBaseConfig().Name)
}

View File

@@ -0,0 +1,65 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package source
import (
"sync"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
// baseSource provides shared state and behavior for Source implementations.
// It manages proxy/visitor storage.
// Concrete types (ConfigSource, StoreSource) embed this struct.
type baseSource struct {
mu sync.RWMutex
proxies map[string]v1.ProxyConfigurer
visitors map[string]v1.VisitorConfigurer
}
func newBaseSource() baseSource {
return baseSource{
proxies: make(map[string]v1.ProxyConfigurer),
visitors: make(map[string]v1.VisitorConfigurer),
}
}
// Load returns all enabled proxy and visitor configurations.
// Configurations with Enabled explicitly set to false are filtered out.
func (s *baseSource) Load() ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) {
s.mu.RLock()
defer s.mu.RUnlock()
proxies := make([]v1.ProxyConfigurer, 0, len(s.proxies))
for _, p := range s.proxies {
// Filter out disabled proxies (nil or true means enabled)
if enabled := p.GetBaseConfig().Enabled; enabled != nil && !*enabled {
continue
}
proxies = append(proxies, p)
}
visitors := make([]v1.VisitorConfigurer, 0, len(s.visitors))
for _, v := range s.visitors {
// Filter out disabled visitors (nil or true means enabled)
if enabled := v.GetBaseConfig().Enabled; enabled != nil && !*enabled {
continue
}
visitors = append(visitors, v)
}
return cloneConfigurers(proxies, visitors)
}

View File

@@ -0,0 +1,48 @@
package source
import (
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func TestBaseSourceLoadReturnsClonedConfigurers(t *testing.T) {
require := require.New(t)
src := NewConfigSource()
proxyCfg := &v1.TCPProxyConfig{
ProxyBaseConfig: v1.ProxyBaseConfig{
Name: "proxy1",
Type: "tcp",
},
}
visitorCfg := &v1.STCPVisitorConfig{
VisitorBaseConfig: v1.VisitorBaseConfig{
Name: "visitor1",
Type: "stcp",
},
}
err := src.ReplaceAll([]v1.ProxyConfigurer{proxyCfg}, []v1.VisitorConfigurer{visitorCfg})
require.NoError(err)
firstProxies, firstVisitors, err := src.Load()
require.NoError(err)
require.Len(firstProxies, 1)
require.Len(firstVisitors, 1)
// Mutate loaded objects as runtime completion would do.
firstProxies[0].Complete()
firstVisitors[0].Complete()
secondProxies, secondVisitors, err := src.Load()
require.NoError(err)
require.Len(secondProxies, 1)
require.Len(secondVisitors, 1)
require.Empty(secondProxies[0].GetBaseConfig().LocalIP)
require.Empty(secondVisitors[0].GetBaseConfig().BindAddr)
}

View File

@@ -0,0 +1,43 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package source
import (
"fmt"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func cloneConfigurers(
proxies []v1.ProxyConfigurer,
visitors []v1.VisitorConfigurer,
) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) {
clonedProxies := make([]v1.ProxyConfigurer, 0, len(proxies))
clonedVisitors := make([]v1.VisitorConfigurer, 0, len(visitors))
for _, cfg := range proxies {
if cfg == nil {
return nil, nil, fmt.Errorf("proxy cannot be nil")
}
clonedProxies = append(clonedProxies, cfg.Clone())
}
for _, cfg := range visitors {
if cfg == nil {
return nil, nil, fmt.Errorf("visitor cannot be nil")
}
clonedVisitors = append(clonedVisitors, cfg.Clone())
}
return clonedProxies, clonedVisitors, nil
}

View File

@@ -0,0 +1,65 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package source
import (
"fmt"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
// ConfigSource implements Source for in-memory configuration.
// All operations are thread-safe.
type ConfigSource struct {
baseSource
}
func NewConfigSource() *ConfigSource {
return &ConfigSource{
baseSource: newBaseSource(),
}
}
// ReplaceAll replaces all proxy and visitor configurations atomically.
func (s *ConfigSource) ReplaceAll(proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer) error {
s.mu.Lock()
defer s.mu.Unlock()
nextProxies := make(map[string]v1.ProxyConfigurer, len(proxies))
for _, p := range proxies {
if p == nil {
return fmt.Errorf("proxy cannot be nil")
}
name := p.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("proxy name cannot be empty")
}
nextProxies[name] = p
}
nextVisitors := make(map[string]v1.VisitorConfigurer, len(visitors))
for _, v := range visitors {
if v == nil {
return fmt.Errorf("visitor cannot be nil")
}
name := v.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("visitor name cannot be empty")
}
nextVisitors[name] = v
}
s.proxies = nextProxies
s.visitors = nextVisitors
return nil
}

View File

@@ -0,0 +1,173 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package source
import (
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func TestNewConfigSource(t *testing.T) {
require := require.New(t)
src := NewConfigSource()
require.NotNil(src)
}
func TestConfigSource_ReplaceAll(t *testing.T) {
require := require.New(t)
src := NewConfigSource()
err := src.ReplaceAll(
[]v1.ProxyConfigurer{mockProxy("proxy1"), mockProxy("proxy2")},
[]v1.VisitorConfigurer{mockVisitor("visitor1")},
)
require.NoError(err)
proxies, visitors, err := src.Load()
require.NoError(err)
require.Len(proxies, 2)
require.Len(visitors, 1)
// ReplaceAll again should replace everything
err = src.ReplaceAll(
[]v1.ProxyConfigurer{mockProxy("proxy3")},
nil,
)
require.NoError(err)
proxies, visitors, err = src.Load()
require.NoError(err)
require.Len(proxies, 1)
require.Len(visitors, 0)
require.Equal("proxy3", proxies[0].GetBaseConfig().Name)
// ReplaceAll with nil proxy should fail
err = src.ReplaceAll([]v1.ProxyConfigurer{nil}, nil)
require.Error(err)
// ReplaceAll with empty name proxy should fail
err = src.ReplaceAll([]v1.ProxyConfigurer{&v1.TCPProxyConfig{}}, nil)
require.Error(err)
}
func TestConfigSource_Load(t *testing.T) {
require := require.New(t)
src := NewConfigSource()
err := src.ReplaceAll(
[]v1.ProxyConfigurer{mockProxy("proxy1"), mockProxy("proxy2")},
[]v1.VisitorConfigurer{mockVisitor("visitor1")},
)
require.NoError(err)
proxies, visitors, err := src.Load()
require.NoError(err)
require.Len(proxies, 2)
require.Len(visitors, 1)
}
// TestConfigSource_Load_FiltersDisabled verifies that Load() filters out
// proxies and visitors with Enabled explicitly set to false.
func TestConfigSource_Load_FiltersDisabled(t *testing.T) {
require := require.New(t)
src := NewConfigSource()
disabled := false
enabled := true
// Create enabled proxy (nil Enabled = enabled by default)
enabledProxy := mockProxy("enabled-proxy")
// Create disabled proxy
disabledProxy := &v1.TCPProxyConfig{}
disabledProxy.Name = "disabled-proxy"
disabledProxy.Type = "tcp"
disabledProxy.Enabled = &disabled
// Create explicitly enabled proxy
explicitEnabledProxy := &v1.TCPProxyConfig{}
explicitEnabledProxy.Name = "explicit-enabled-proxy"
explicitEnabledProxy.Type = "tcp"
explicitEnabledProxy.Enabled = &enabled
// Create enabled visitor (nil Enabled = enabled by default)
enabledVisitor := mockVisitor("enabled-visitor")
// Create disabled visitor
disabledVisitor := &v1.STCPVisitorConfig{}
disabledVisitor.Name = "disabled-visitor"
disabledVisitor.Type = "stcp"
disabledVisitor.Enabled = &disabled
err := src.ReplaceAll(
[]v1.ProxyConfigurer{enabledProxy, disabledProxy, explicitEnabledProxy},
[]v1.VisitorConfigurer{enabledVisitor, disabledVisitor},
)
require.NoError(err)
// Load should filter out disabled configs
proxies, visitors, err := src.Load()
require.NoError(err)
require.Len(proxies, 2, "Should have 2 enabled proxies")
require.Len(visitors, 1, "Should have 1 enabled visitor")
// Verify the correct proxies are returned
proxyNames := make([]string, 0, len(proxies))
for _, p := range proxies {
proxyNames = append(proxyNames, p.GetBaseConfig().Name)
}
require.Contains(proxyNames, "enabled-proxy")
require.Contains(proxyNames, "explicit-enabled-proxy")
require.NotContains(proxyNames, "disabled-proxy")
// Verify the correct visitor is returned
require.Equal("enabled-visitor", visitors[0].GetBaseConfig().Name)
}
func TestConfigSource_ReplaceAll_DoesNotApplyRuntimeDefaults(t *testing.T) {
require := require.New(t)
src := NewConfigSource()
proxyCfg := &v1.TCPProxyConfig{}
proxyCfg.Name = "proxy1"
proxyCfg.Type = "tcp"
proxyCfg.LocalPort = 10080
visitorCfg := &v1.XTCPVisitorConfig{}
visitorCfg.Name = "visitor1"
visitorCfg.Type = "xtcp"
visitorCfg.ServerName = "server1"
visitorCfg.SecretKey = "secret"
visitorCfg.BindPort = 10081
err := src.ReplaceAll([]v1.ProxyConfigurer{proxyCfg}, []v1.VisitorConfigurer{visitorCfg})
require.NoError(err)
proxies, visitors, err := src.Load()
require.NoError(err)
require.Len(proxies, 1)
require.Len(visitors, 1)
require.Empty(proxies[0].GetBaseConfig().LocalIP)
require.Empty(visitors[0].GetBaseConfig().BindAddr)
require.Empty(visitors[0].(*v1.XTCPVisitorConfig).Protocol)
}

View File

@@ -0,0 +1,37 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package source
import (
v1 "github.com/fatedier/frp/pkg/config/v1"
)
// Source is the interface for configuration sources.
// A Source provides proxy and visitor configurations from various backends.
// Aggregator currently uses the built-in config source as base and an optional
// store source as higher-priority overlay.
type Source interface {
// Load loads the proxy and visitor configurations from this source.
// Returns the loaded configurations and any error encountered.
// A disabled entry in one source is source-local filtering, not a cross-source
// tombstone for entries from lower-priority sources.
//
// Error handling contract with Aggregator:
// - When err is nil, returned slices are consumed.
// - When err is non-nil, Aggregator aborts the merge and returns the error.
// - To publish best-effort or partial results, return those results with
// err set to nil.
Load() (proxies []v1.ProxyConfigurer, visitors []v1.VisitorConfigurer, err error)
}

359
pkg/config/source/store.go Normal file
View File

@@ -0,0 +1,359 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package source
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
type StoreSourceConfig struct {
Path string `json:"path"`
}
type storeData struct {
Proxies []v1.TypedProxyConfig `json:"proxies,omitempty"`
Visitors []v1.TypedVisitorConfig `json:"visitors,omitempty"`
}
type StoreSource struct {
baseSource
config StoreSourceConfig
}
var (
ErrAlreadyExists = errors.New("already exists")
ErrNotFound = errors.New("not found")
)
func NewStoreSource(cfg StoreSourceConfig) (*StoreSource, error) {
if cfg.Path == "" {
return nil, fmt.Errorf("path is required")
}
s := &StoreSource{
baseSource: newBaseSource(),
config: cfg,
}
if err := s.loadFromFile(); err != nil {
if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to load existing data: %w", err)
}
}
return s, nil
}
func (s *StoreSource) loadFromFile() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.loadFromFileUnlocked()
}
func (s *StoreSource) loadFromFileUnlocked() error {
data, err := os.ReadFile(s.config.Path)
if err != nil {
return err
}
var stored storeData
if err := v1.WithDisallowUnknownFields(false, func() error {
return json.Unmarshal(data, &stored)
}); err != nil {
return fmt.Errorf("failed to parse JSON: %w", err)
}
s.proxies = make(map[string]v1.ProxyConfigurer)
s.visitors = make(map[string]v1.VisitorConfigurer)
for _, tp := range stored.Proxies {
if tp.ProxyConfigurer != nil {
proxyCfg := tp.ProxyConfigurer
name := proxyCfg.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("proxy name cannot be empty")
}
s.proxies[name] = proxyCfg
}
}
for _, tv := range stored.Visitors {
if tv.VisitorConfigurer != nil {
visitorCfg := tv.VisitorConfigurer
name := visitorCfg.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("visitor name cannot be empty")
}
s.visitors[name] = visitorCfg
}
}
return nil
}
func (s *StoreSource) saveToFileUnlocked() error {
stored := storeData{
Proxies: make([]v1.TypedProxyConfig, 0, len(s.proxies)),
Visitors: make([]v1.TypedVisitorConfig, 0, len(s.visitors)),
}
for _, p := range s.proxies {
stored.Proxies = append(stored.Proxies, v1.TypedProxyConfig{ProxyConfigurer: p})
}
for _, v := range s.visitors {
stored.Visitors = append(stored.Visitors, v1.TypedVisitorConfig{VisitorConfigurer: v})
}
data, err := json.MarshalIndent(stored, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal JSON: %w", err)
}
dir := filepath.Dir(s.config.Path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
tmpPath := s.config.Path + ".tmp"
f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
if _, err := f.Write(data); err != nil {
f.Close()
os.Remove(tmpPath)
return fmt.Errorf("failed to write temp file: %w", err)
}
if err := f.Sync(); err != nil {
f.Close()
os.Remove(tmpPath)
return fmt.Errorf("failed to sync temp file: %w", err)
}
if err := f.Close(); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("failed to close temp file: %w", err)
}
if err := os.Rename(tmpPath, s.config.Path); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("failed to rename temp file: %w", err)
}
return nil
}
func (s *StoreSource) AddProxy(proxy v1.ProxyConfigurer) error {
if proxy == nil {
return fmt.Errorf("proxy cannot be nil")
}
name := proxy.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("proxy name cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.proxies[name]; exists {
return fmt.Errorf("%w: proxy %q", ErrAlreadyExists, name)
}
s.proxies[name] = proxy
if err := s.saveToFileUnlocked(); err != nil {
delete(s.proxies, name)
return fmt.Errorf("failed to persist: %w", err)
}
return nil
}
func (s *StoreSource) UpdateProxy(proxy v1.ProxyConfigurer) error {
if proxy == nil {
return fmt.Errorf("proxy cannot be nil")
}
name := proxy.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("proxy name cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
oldProxy, exists := s.proxies[name]
if !exists {
return fmt.Errorf("%w: proxy %q", ErrNotFound, name)
}
s.proxies[name] = proxy
if err := s.saveToFileUnlocked(); err != nil {
s.proxies[name] = oldProxy
return fmt.Errorf("failed to persist: %w", err)
}
return nil
}
func (s *StoreSource) RemoveProxy(name string) error {
if name == "" {
return fmt.Errorf("proxy name cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
oldProxy, exists := s.proxies[name]
if !exists {
return fmt.Errorf("%w: proxy %q", ErrNotFound, name)
}
delete(s.proxies, name)
if err := s.saveToFileUnlocked(); err != nil {
s.proxies[name] = oldProxy
return fmt.Errorf("failed to persist: %w", err)
}
return nil
}
func (s *StoreSource) GetProxy(name string) v1.ProxyConfigurer {
s.mu.RLock()
defer s.mu.RUnlock()
p, exists := s.proxies[name]
if !exists {
return nil
}
return p
}
func (s *StoreSource) AddVisitor(visitor v1.VisitorConfigurer) error {
if visitor == nil {
return fmt.Errorf("visitor cannot be nil")
}
name := visitor.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("visitor name cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.visitors[name]; exists {
return fmt.Errorf("%w: visitor %q", ErrAlreadyExists, name)
}
s.visitors[name] = visitor
if err := s.saveToFileUnlocked(); err != nil {
delete(s.visitors, name)
return fmt.Errorf("failed to persist: %w", err)
}
return nil
}
func (s *StoreSource) UpdateVisitor(visitor v1.VisitorConfigurer) error {
if visitor == nil {
return fmt.Errorf("visitor cannot be nil")
}
name := visitor.GetBaseConfig().Name
if name == "" {
return fmt.Errorf("visitor name cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
oldVisitor, exists := s.visitors[name]
if !exists {
return fmt.Errorf("%w: visitor %q", ErrNotFound, name)
}
s.visitors[name] = visitor
if err := s.saveToFileUnlocked(); err != nil {
s.visitors[name] = oldVisitor
return fmt.Errorf("failed to persist: %w", err)
}
return nil
}
func (s *StoreSource) RemoveVisitor(name string) error {
if name == "" {
return fmt.Errorf("visitor name cannot be empty")
}
s.mu.Lock()
defer s.mu.Unlock()
oldVisitor, exists := s.visitors[name]
if !exists {
return fmt.Errorf("%w: visitor %q", ErrNotFound, name)
}
delete(s.visitors, name)
if err := s.saveToFileUnlocked(); err != nil {
s.visitors[name] = oldVisitor
return fmt.Errorf("failed to persist: %w", err)
}
return nil
}
func (s *StoreSource) GetVisitor(name string) v1.VisitorConfigurer {
s.mu.RLock()
defer s.mu.RUnlock()
v, exists := s.visitors[name]
if !exists {
return nil
}
return v
}
func (s *StoreSource) GetAllProxies() ([]v1.ProxyConfigurer, error) {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]v1.ProxyConfigurer, 0, len(s.proxies))
for _, p := range s.proxies {
result = append(result, p)
}
return result, nil
}
func (s *StoreSource) GetAllVisitors() ([]v1.VisitorConfigurer, error) {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]v1.VisitorConfigurer, 0, len(s.visitors))
for _, v := range s.visitors {
result = append(result, v)
}
return result, nil
}

View File

@@ -0,0 +1,144 @@
// Copyright 2026 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package source
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
v1 "github.com/fatedier/frp/pkg/config/v1"
)
func setDisallowUnknownFieldsForStoreTest(t *testing.T, value bool) func() {
t.Helper()
v1.DisallowUnknownFieldsMu.Lock()
prev := v1.DisallowUnknownFields
v1.DisallowUnknownFields = value
v1.DisallowUnknownFieldsMu.Unlock()
return func() {
v1.DisallowUnknownFieldsMu.Lock()
v1.DisallowUnknownFields = prev
v1.DisallowUnknownFieldsMu.Unlock()
}
}
func getDisallowUnknownFieldsForStoreTest() bool {
v1.DisallowUnknownFieldsMu.Lock()
defer v1.DisallowUnknownFieldsMu.Unlock()
return v1.DisallowUnknownFields
}
func TestStoreSource_AddProxyAndVisitor_DoesNotApplyRuntimeDefaults(t *testing.T) {
require := require.New(t)
path := filepath.Join(t.TempDir(), "store.json")
storeSource, err := NewStoreSource(StoreSourceConfig{Path: path})
require.NoError(err)
proxyCfg := &v1.TCPProxyConfig{}
proxyCfg.Name = "proxy1"
proxyCfg.Type = "tcp"
proxyCfg.LocalPort = 10080
visitorCfg := &v1.XTCPVisitorConfig{}
visitorCfg.Name = "visitor1"
visitorCfg.Type = "xtcp"
visitorCfg.ServerName = "server1"
visitorCfg.SecretKey = "secret"
visitorCfg.BindPort = 10081
err = storeSource.AddProxy(proxyCfg)
require.NoError(err)
err = storeSource.AddVisitor(visitorCfg)
require.NoError(err)
gotProxy := storeSource.GetProxy("proxy1")
require.NotNil(gotProxy)
require.Empty(gotProxy.GetBaseConfig().LocalIP)
gotVisitor := storeSource.GetVisitor("visitor1")
require.NotNil(gotVisitor)
require.Empty(gotVisitor.GetBaseConfig().BindAddr)
require.Empty(gotVisitor.(*v1.XTCPVisitorConfig).Protocol)
}
func TestStoreSource_LoadFromFile_DoesNotApplyRuntimeDefaults(t *testing.T) {
require := require.New(t)
path := filepath.Join(t.TempDir(), "store.json")
proxyCfg := &v1.TCPProxyConfig{}
proxyCfg.Name = "proxy1"
proxyCfg.Type = "tcp"
proxyCfg.LocalPort = 10080
visitorCfg := &v1.XTCPVisitorConfig{}
visitorCfg.Name = "visitor1"
visitorCfg.Type = "xtcp"
visitorCfg.ServerName = "server1"
visitorCfg.SecretKey = "secret"
visitorCfg.BindPort = 10081
stored := storeData{
Proxies: []v1.TypedProxyConfig{{ProxyConfigurer: proxyCfg}},
Visitors: []v1.TypedVisitorConfig{{VisitorConfigurer: visitorCfg}},
}
data, err := json.Marshal(stored)
require.NoError(err)
err = os.WriteFile(path, data, 0o600)
require.NoError(err)
storeSource, err := NewStoreSource(StoreSourceConfig{Path: path})
require.NoError(err)
gotProxy := storeSource.GetProxy("proxy1")
require.NotNil(gotProxy)
require.Empty(gotProxy.GetBaseConfig().LocalIP)
gotVisitor := storeSource.GetVisitor("visitor1")
require.NotNil(gotVisitor)
require.Empty(gotVisitor.GetBaseConfig().BindAddr)
require.Empty(gotVisitor.(*v1.XTCPVisitorConfig).Protocol)
}
func TestStoreSource_LoadFromFile_UnknownFieldsNotAffectedByAmbientStrictness(t *testing.T) {
require := require.New(t)
restore := setDisallowUnknownFieldsForStoreTest(t, true)
t.Cleanup(restore)
path := filepath.Join(t.TempDir(), "store.json")
raw := []byte(`{
"proxies": [
{"name":"proxy1","type":"tcp","localPort":10080,"unexpected":"value"}
],
"visitors": [
{"name":"visitor1","type":"xtcp","serverName":"server1","secretKey":"secret","bindPort":10081,"unexpected":"value"}
]
}`)
err := os.WriteFile(path, raw, 0o600)
require.NoError(err)
storeSource, err := NewStoreSource(StoreSourceConfig{Path: path})
require.NoError(err)
require.NotNil(storeSource.GetProxy("proxy1"))
require.NotNil(storeSource.GetVisitor("visitor1"))
require.True(getDisallowUnknownFieldsForStoreTest())
}