mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-13 08:59:08 +02:00
[client] Add nblink, an unprivileged forwarder into the network (#7955)
Reaching an internal service meant installing the agent, which needs root for a TUN device. nblink runs the embedded client in netstack mode and forwards local HTTP listeners to upstreams inside the tunnel, so it works on managed machines and in rootless containers. Upstream names resolve in-process. Forwards bind loopback unless --allow-public-bind is given. The listener refuses rebound and cross-site browser requests and bounds stalled request bodies. It ships as release archives and a distroless image that runs under an arbitrary UID. An embedded client given no StatePath now keeps its state in memory rather than falling back to the installed agent's state file, which every embedded client in a proxy process also shared. The state manager gets an explicit in-memory store for it.
This commit is contained in:
@@ -72,6 +72,9 @@ type ConnectClient struct {
|
||||
updateManager *updater.Manager
|
||||
|
||||
persistSyncResponse bool
|
||||
// inMemoryState keeps the state manager off disk, for clients that change
|
||||
// nothing on the host that a later run would have to restore.
|
||||
inMemoryState bool
|
||||
|
||||
// netMgr gates every reconnection loop on OS-reported network
|
||||
// availability and sweeps connections on network change.
|
||||
@@ -84,6 +87,12 @@ type ConnectClient struct {
|
||||
// ConnectClientOption configures optional ConnectClient behavior.
|
||||
type ConnectClientOption func(*ConnectClient)
|
||||
|
||||
// WithInMemoryState keeps client state in memory instead of the default state
|
||||
// file, so the client neither reads nor writes another installation's state.
|
||||
func WithInMemoryState() ConnectClientOption {
|
||||
return func(c *ConnectClient) { c.inMemoryState = true }
|
||||
}
|
||||
|
||||
// WithNetEvents injects the OS network event handling.
|
||||
func WithNetEvents(events *netevents.Manager) ConnectClientOption {
|
||||
return func(c *ConnectClient) { c.netMgr = events }
|
||||
@@ -272,8 +281,15 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
return err
|
||||
}
|
||||
|
||||
// path stays alongside the manager because the engine derives StateDir
|
||||
// from it; an in-memory client leaves it empty.
|
||||
var path string
|
||||
if runtime.GOOS == "ios" || runtime.GOOS == "android" {
|
||||
var stateManager *statemanager.Manager
|
||||
switch {
|
||||
case c.inMemoryState:
|
||||
log.Debugf("client state is kept in memory")
|
||||
stateManager = statemanager.NewInMemory()
|
||||
case runtime.GOOS == "ios" || runtime.GOOS == "android":
|
||||
// On mobile, use the provided state file path directly
|
||||
if !fileExists(mobileDependency.StateFilePath) {
|
||||
if err := createFile(mobileDependency.StateFilePath); err != nil {
|
||||
@@ -282,11 +298,12 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
}
|
||||
}
|
||||
path = mobileDependency.StateFilePath
|
||||
} else {
|
||||
stateManager = statemanager.New(path)
|
||||
default:
|
||||
sm := profilemanager.NewServiceManager("")
|
||||
path = sm.GetStatePath()
|
||||
stateManager = statemanager.New(path)
|
||||
}
|
||||
stateManager := statemanager.New(path)
|
||||
stateManager.RegisterState(&sshconfig.ShutdownState{})
|
||||
|
||||
if c.updateManager != nil {
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
package statemanager
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"reflect"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -17,7 +13,6 @@ import (
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -56,7 +51,7 @@ type Manager struct {
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
|
||||
filePath string
|
||||
store store
|
||||
// holds the states that are registered with the manager and that are to be persisted
|
||||
states map[string]State
|
||||
// holds the state names that have been updated and need to be persisted with the next save
|
||||
@@ -65,10 +60,20 @@ type Manager struct {
|
||||
stateTypes map[string]reflect.Type
|
||||
}
|
||||
|
||||
// New creates a new Manager instance
|
||||
// New creates a Manager that persists its states to filePath.
|
||||
func New(filePath string) *Manager {
|
||||
return newManager(&fileStore{path: filePath})
|
||||
}
|
||||
|
||||
// NewInMemory creates a Manager that persists nothing, for a client that
|
||||
// changes nothing on the host and so has no state a later run must restore.
|
||||
func NewInMemory() *Manager {
|
||||
return newManager(memoryStore{})
|
||||
}
|
||||
|
||||
func newManager(s store) *Manager {
|
||||
return &Manager{
|
||||
filePath: filePath,
|
||||
store: s,
|
||||
states: make(map[string]State),
|
||||
dirty: make(map[string]struct{}),
|
||||
stateTypes: make(map[string]reflect.Type),
|
||||
@@ -274,7 +279,7 @@ func (m *Manager) PersistState(ctx context.Context) error {
|
||||
done := make(chan error, 1)
|
||||
start := time.Now()
|
||||
go func() {
|
||||
done <- util.WriteBytesWithRestrictedPermission(ctx, m.filePath, bs)
|
||||
done <- m.store.save(ctx, bs)
|
||||
}()
|
||||
|
||||
select {
|
||||
@@ -293,45 +298,9 @@ func (m *Manager) PersistState(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadStateFile reads and unmarshals the state file into a map of raw JSON messages
|
||||
// loadStateFile returns the states an earlier run persisted.
|
||||
func (m *Manager) loadStateFile(deleteCorrupt bool) (map[string]json.RawMessage, error) {
|
||||
data, err := os.ReadFile(m.filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
log.Debugf("state file %s does not exist", m.filePath)
|
||||
return nil, nil // nolint:nilnil
|
||||
}
|
||||
return nil, fmt.Errorf("read state file: %w", err)
|
||||
}
|
||||
|
||||
var rawStates map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &rawStates); err != nil {
|
||||
if len(bytes.TrimSpace(data)) == 0 {
|
||||
log.Warnf("state file %s is empty (%d bytes)", m.filePath, len(data))
|
||||
} else {
|
||||
log.Warnf("state file %s has malformed content (%d bytes)", m.filePath, len(data))
|
||||
}
|
||||
m.handleCorruptedState(deleteCorrupt)
|
||||
return nil, fmt.Errorf("unmarshal states: %w", err)
|
||||
}
|
||||
|
||||
return rawStates, nil
|
||||
}
|
||||
|
||||
// handleCorruptedState creates a backup of a corrupted state file by moving it
|
||||
func (m *Manager) handleCorruptedState(deleteCorrupt bool) {
|
||||
if !deleteCorrupt {
|
||||
return
|
||||
}
|
||||
log.Warn("State file appears to be corrupted, attempting to back it up")
|
||||
|
||||
backupPath := fmt.Sprintf("%s.corrupted.%d", m.filePath, time.Now().UnixNano())
|
||||
if err := os.Rename(m.filePath, backupPath); err != nil {
|
||||
log.Errorf("Failed to backup corrupted state file: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Infof("Created backup of corrupted state file at: %s", backupPath)
|
||||
return m.store.load(deleteCorrupt)
|
||||
}
|
||||
|
||||
// loadSingleRawState unmarshals a raw state into a concrete state object
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package statemanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testState struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func (s *testState) Name() string { return "test_state" }
|
||||
|
||||
func TestInMemoryManagerNeverTouchesDisk(t *testing.T) {
|
||||
// Run from an empty directory, so a write to a relative or empty path
|
||||
// would leave a file behind here.
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
|
||||
m := NewInMemory()
|
||||
m.RegisterState(&testState{})
|
||||
require.NoError(t, m.UpdateState(&testState{Value: "kept"}))
|
||||
require.NoError(t, m.PersistState(context.Background()), "persisting in memory must succeed")
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, entries, "an in-memory manager must not write a state file")
|
||||
|
||||
assert.Equal(t, &testState{Value: "kept"}, m.GetState(&testState{}), "the state must still be held in memory")
|
||||
assert.NoError(t, m.LoadState(&testState{}), "loading with nothing on disk must succeed")
|
||||
|
||||
names, err := m.GetSavedStateNames()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, names, "an in-memory manager has no saved states")
|
||||
}
|
||||
|
||||
// An empty path is a misconfiguration rather than a request for memory: the
|
||||
// in-memory manager has its own constructor, so a path that resolved to
|
||||
// nothing must surface instead of silently discarding every write.
|
||||
func TestManagerWithoutAPathFailsToPersist(t *testing.T) {
|
||||
m := New("")
|
||||
m.RegisterState(&testState{})
|
||||
require.NoError(t, m.UpdateState(&testState{Value: "kept"}))
|
||||
|
||||
assert.Error(t, m.PersistState(context.Background()),
|
||||
"an empty path must report that it cannot persist, not quietly drop the state")
|
||||
}
|
||||
|
||||
func TestManagerPersistsToItsFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
|
||||
m := New(path)
|
||||
m.RegisterState(&testState{})
|
||||
require.NoError(t, m.UpdateState(&testState{Value: "kept"}))
|
||||
require.NoError(t, m.PersistState(context.Background()))
|
||||
|
||||
names, err := New(path).GetSavedStateNames()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"test_state"}, names, "a manager with a path must write its state there")
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package statemanager
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
// store is where a Manager keeps its states between runs.
|
||||
type store interface {
|
||||
// load returns the states persisted by an earlier run, or an empty map
|
||||
// when there are none. deleteCorrupt asks the store to move a state it
|
||||
// cannot parse out of the way.
|
||||
load(deleteCorrupt bool) (map[string]json.RawMessage, error)
|
||||
// save replaces the persisted states with data.
|
||||
save(ctx context.Context, data []byte) error
|
||||
}
|
||||
|
||||
// fileStore persists states to a file, which is what an installed agent needs:
|
||||
// it changes routing, DNS and firewall rules on the host, and a later run has
|
||||
// to know what to put back.
|
||||
type fileStore struct {
|
||||
path string
|
||||
}
|
||||
|
||||
// memoryStore keeps nothing. An embedded client in netstack mode changes
|
||||
// nothing on the host, so there is no state a later run has to restore, and
|
||||
// writing one would mean reading and overwriting another installation's file.
|
||||
type memoryStore struct{}
|
||||
|
||||
func (f *fileStore) load(deleteCorrupt bool) (map[string]json.RawMessage, error) {
|
||||
data, err := os.ReadFile(f.path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
log.Debugf("state file %s does not exist", f.path)
|
||||
return nil, nil //nolint:nilnil // no file is not a failure, there is simply nothing to load
|
||||
}
|
||||
return nil, fmt.Errorf("read state file: %w", err)
|
||||
}
|
||||
|
||||
var rawStates map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &rawStates); err != nil {
|
||||
if len(bytes.TrimSpace(data)) == 0 {
|
||||
log.Warnf("state file %s is empty (%d bytes)", f.path, len(data))
|
||||
} else {
|
||||
log.Warnf("state file %s has malformed content (%d bytes)", f.path, len(data))
|
||||
}
|
||||
f.handleCorrupted(deleteCorrupt)
|
||||
return nil, fmt.Errorf("unmarshal states: %w", err)
|
||||
}
|
||||
|
||||
return rawStates, nil
|
||||
}
|
||||
|
||||
func (f *fileStore) save(ctx context.Context, data []byte) error {
|
||||
return util.WriteBytesWithRestrictedPermission(ctx, f.path, data)
|
||||
}
|
||||
|
||||
// handleCorrupted moves a state file that cannot be parsed aside, so the next
|
||||
// run starts from nothing rather than failing on the same content forever.
|
||||
func (f *fileStore) handleCorrupted(deleteCorrupt bool) {
|
||||
if !deleteCorrupt {
|
||||
return
|
||||
}
|
||||
log.Warn("State file appears to be corrupted, attempting to back it up")
|
||||
|
||||
backupPath := fmt.Sprintf("%s.corrupted.%d", f.path, time.Now().UnixNano())
|
||||
if err := os.Rename(f.path, backupPath); err != nil {
|
||||
log.Errorf("Failed to backup corrupted state file: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Infof("Created backup of corrupted state file at: %s", backupPath)
|
||||
}
|
||||
|
||||
func (memoryStore) load(bool) (map[string]json.RawMessage, error) {
|
||||
return map[string]json.RawMessage{}, nil
|
||||
}
|
||||
|
||||
func (memoryStore) save(context.Context, []byte) error {
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user