mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-24 09:21:29 +02:00
Compare commits
42 Commits
fix/cli-up
...
grpc-acl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f84aa3d20 | ||
|
|
4de39a80f8 | ||
|
|
5d9ef0123f | ||
|
|
27afbd7952 | ||
|
|
a12a3e4603 | ||
|
|
dc89b471fa | ||
|
|
0e520ee9f5 | ||
|
|
9620890b65 | ||
|
|
69c35e31b4 | ||
|
|
b6cd8944b1 | ||
|
|
6fc05efa6c | ||
|
|
3cda14d7f2 | ||
|
|
d9392fdbb8 | ||
|
|
82fdfa84b8 | ||
|
|
ca80e49aa0 | ||
|
|
51f17bf919 | ||
|
|
724c6a06e6 | ||
|
|
d64e9542eb | ||
|
|
3fb26d458e | ||
|
|
a411fd300c | ||
|
|
92a5ed19d3 | ||
|
|
be6777427d | ||
|
|
a1c9427d80 | ||
|
|
a59d7fba95 | ||
|
|
41d7bf4bbd | ||
|
|
b7b0d5796e | ||
|
|
21fc5b81f6 | ||
|
|
9906b9b1a1 | ||
|
|
3f8c447378 | ||
|
|
6e3f4d8722 | ||
|
|
877e889250 | ||
|
|
099ae4bc6c | ||
|
|
63d60ba490 | ||
|
|
62fc8d254e | ||
|
|
3a2f773d65 | ||
|
|
f0eed7564f | ||
|
|
277d8e4c53 | ||
|
|
3d87547d95 | ||
|
|
4d4cc551fd | ||
|
|
08e46aa62f | ||
|
|
7cd5c1732b | ||
|
|
816d80602f |
@@ -17,7 +17,8 @@ ENV \
|
||||
NETBIRD_BIN="/usr/local/bin/netbird" \
|
||||
NB_LOG_FILE="console,/var/log/netbird/client.log" \
|
||||
NB_DAEMON_ADDR="unix:///var/run/netbird.sock" \
|
||||
NB_ENABLE_CAPTURE="false"
|
||||
NB_ENABLE_CAPTURE="false" \
|
||||
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
@@ -23,7 +23,8 @@ ENV \
|
||||
NB_DAEMON_ADDR="unix:///var/lib/netbird/netbird.sock" \
|
||||
NB_LOG_FILE="console,/var/lib/netbird/client.log" \
|
||||
NB_DISABLE_DNS="true" \
|
||||
NB_ENABLE_CAPTURE="false"
|
||||
NB_ENABLE_CAPTURE="false" \
|
||||
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
@@ -247,6 +247,9 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
|
||||
deps.SyncResponse = resp
|
||||
|
||||
if e := cc.Engine(); e != nil {
|
||||
deps.RefreshStatus = func() {
|
||||
e.RunHealthProbes(context.Background(), true)
|
||||
}
|
||||
if cm := e.GetClientMetrics(); cm != nil {
|
||||
deps.ClientMetrics = cm
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ func (pm *ProfileManager) SwitchProfile(id string) error {
|
||||
// AddProfile creates a new profile
|
||||
func (pm *ProfileManager) AddProfile(profileName string) error {
|
||||
// Use ServiceManager (creates profile in profiles/ directory)
|
||||
profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername)
|
||||
profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add profile: %w", err)
|
||||
}
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/connectivity"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// startUnixGRPCServer starts a bare gRPC server listening on a unix socket at path
|
||||
// and returns a stop function. No services are registered; the connectivity-state
|
||||
// wait only cares about the transport becoming READY.
|
||||
func startUnixGRPCServer(t *testing.T, path string) func() {
|
||||
t.Helper()
|
||||
lis, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
t.Fatalf("listen unix %s: %v", path, err)
|
||||
}
|
||||
srv := grpc.NewServer()
|
||||
go func() { _ = srv.Serve(lis) }()
|
||||
return srv.Stop
|
||||
}
|
||||
|
||||
func TestDialClientGRPCServer_ConnectsWhenServing(t *testing.T) {
|
||||
sock := filepath.Join(t.TempDir(), "nb.sock")
|
||||
stop := startUnixGRPCServer(t, sock)
|
||||
defer stop()
|
||||
|
||||
conn, err := dialClientGRPCServer(context.Background(), "unix://"+sock, 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("expected connection, got error: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if state := conn.GetState(); state != connectivity.Ready {
|
||||
t.Fatalf("expected READY, got %s", state)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDialClientGRPCServer_WaitsForLateServer is the core regression test: the
|
||||
// daemon socket appears only after the dial has already started, mirroring
|
||||
// "netbird service start" immediately followed by "netbird up".
|
||||
func TestDialClientGRPCServer_WaitsForLateServer(t *testing.T) {
|
||||
sock := filepath.Join(t.TempDir(), "nb.sock")
|
||||
|
||||
var stop func()
|
||||
timer := time.AfterFunc(1*time.Second, func() {
|
||||
stop = startUnixGRPCServer(t, sock)
|
||||
})
|
||||
defer timer.Stop()
|
||||
defer func() {
|
||||
if stop != nil {
|
||||
stop()
|
||||
}
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
conn, err := dialClientGRPCServer(context.Background(), "unix://"+sock, 10*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("expected connection after late server start, got error: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if elapsed := time.Since(start); elapsed < 500*time.Millisecond {
|
||||
t.Fatalf("connected too fast (%s); server should not have been up yet", elapsed)
|
||||
}
|
||||
if state := conn.GetState(); state != connectivity.Ready {
|
||||
t.Fatalf("expected READY, got %s", state)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeStatusServer serves the Status RPC with a programmable response so we can
|
||||
// exercise waitForDaemonStatus without spinning up a real engine.
|
||||
type fakeStatusServer struct {
|
||||
proto.UnimplementedDaemonServiceServer
|
||||
resp func() *proto.StatusResponse
|
||||
}
|
||||
|
||||
func (f *fakeStatusServer) Status(context.Context, *proto.StatusRequest) (*proto.StatusResponse, error) {
|
||||
return f.resp(), nil
|
||||
}
|
||||
|
||||
func startFakeStatusServer(t *testing.T, sock string, resp func() *proto.StatusResponse) func() {
|
||||
t.Helper()
|
||||
lis, err := net.Listen("unix", sock)
|
||||
if err != nil {
|
||||
t.Fatalf("listen unix %s: %v", sock, err)
|
||||
}
|
||||
srv := grpc.NewServer()
|
||||
proto.RegisterDaemonServiceServer(srv, &fakeStatusServer{resp: resp})
|
||||
go func() { _ = srv.Serve(lis) }()
|
||||
return srv.Stop
|
||||
}
|
||||
|
||||
func dialFake(t *testing.T, sock string) proto.DaemonServiceClient {
|
||||
t.Helper()
|
||||
conn, err := dialClientGRPCServer(context.Background(), "unix://"+sock, 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("dial fake daemon: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
return proto.NewDaemonServiceClient(conn)
|
||||
}
|
||||
|
||||
// New daemon that flips DaemonReady=true after a couple of polls: waitForDaemonStatus
|
||||
// must block until the flag is set, then return.
|
||||
func TestWaitForDaemonStatus_WaitsForDaemonReady(t *testing.T) {
|
||||
sock := filepath.Join(t.TempDir(), "nb.sock")
|
||||
var polls int32
|
||||
stop := startFakeStatusServer(t, sock, func() *proto.StatusResponse {
|
||||
n := atomic.AddInt32(&polls, 1)
|
||||
return &proto.StatusResponse{
|
||||
Status: string(internal.StatusConnecting),
|
||||
DaemonReady: n >= 3, // ready only from the 3rd poll on
|
||||
}
|
||||
})
|
||||
defer stop()
|
||||
|
||||
client := dialFake(t, sock)
|
||||
status, err := waitForDaemonStatus(context.Background(), client)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !status.GetDaemonReady() {
|
||||
t.Fatalf("expected DaemonReady=true, got false")
|
||||
}
|
||||
if got := atomic.LoadInt32(&polls); got < 3 {
|
||||
t.Fatalf("expected at least 3 polls before ready, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Older daemon that never sets DaemonReady but reports a healthy (Connected)
|
||||
// status: waitForDaemonStatus must return promptly via the readiness fallback,
|
||||
// not block for the whole grace window.
|
||||
func TestWaitForDaemonStatus_OlderDaemonHealthyStatus(t *testing.T) {
|
||||
sock := filepath.Join(t.TempDir(), "nb.sock")
|
||||
stop := startFakeStatusServer(t, sock, func() *proto.StatusResponse {
|
||||
return &proto.StatusResponse{Status: string(internal.StatusConnected)} // DaemonReady unset
|
||||
})
|
||||
defer stop()
|
||||
|
||||
client := dialFake(t, sock)
|
||||
start := time.Now()
|
||||
status, err := waitForDaemonStatus(context.Background(), client)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if status.GetDaemonReady() {
|
||||
t.Fatalf("expected DaemonReady=false from older daemon")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||||
t.Fatalf("returned too slowly (%s); healthy status should short-circuit the grace", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialClientGRPCServer_TimesOutWhenAbsent(t *testing.T) {
|
||||
sock := filepath.Join(t.TempDir(), "never.sock")
|
||||
|
||||
start := time.Now()
|
||||
conn, err := dialClientGRPCServer(context.Background(), "unix://"+sock, 1*time.Second)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
t.Fatal("expected timeout error, got nil")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed < 900*time.Millisecond {
|
||||
t.Fatalf("returned too early (%s); should have waited ~timeout", elapsed)
|
||||
}
|
||||
}
|
||||
115
client/cmd/owner.go
Normal file
115
client/cmd/owner.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
var ownerCmd = &cobra.Command{
|
||||
Use: "owner",
|
||||
Short: "Manage who may control the active NetBird profile",
|
||||
Long: `Manage the owners of the active profile's daemon control channel.
|
||||
|
||||
Ownership is enforced per profile: an isolated profile can only be controlled by
|
||||
its owner principals (plus root/administrator). A new profile is automatically
|
||||
owned by its creator; an unowned profile is claimed by the first caller.`,
|
||||
}
|
||||
|
||||
var ownerAddCmd = &cobra.Command{
|
||||
Use: "add <principal>",
|
||||
Short: "Add an owner principal to the active profile",
|
||||
Long: `Add an owner principal to the active profile. Principals are typed:
|
||||
uid:1000 a Unix user ID
|
||||
gid:1000 a Unix group ID
|
||||
group:netbird-admins a Unix group name (resolved via NSS/getent)
|
||||
sid:S-1-5-21-... a Windows user or group SID
|
||||
|
||||
Requires root/administrator or an existing owner.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return withDaemon(cmd, func(ctx context.Context, c proto.DaemonServiceClient) error {
|
||||
if _, err := c.AddOwner(ctx, &proto.AddOwnerRequest{Principal: args[0]}); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.Printf("Added owner %q to the active profile\n", args[0])
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var ownerResetCmd = &cobra.Command{
|
||||
Use: "reset",
|
||||
Short: "Clear the active profile's owner list (root/administrator only)",
|
||||
Long: `Clear the active profile's owner list, returning it to the unowned
|
||||
state. The next caller then claims ownership (trust-on-first-use). Requires
|
||||
root/administrator.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return withDaemon(cmd, func(ctx context.Context, c proto.DaemonServiceClient) error {
|
||||
if _, err := c.ResetOwner(ctx, &proto.ResetOwnerRequest{}); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.Println("Owner list cleared; the next caller will claim ownership")
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var ownerShareCmd = &cobra.Command{
|
||||
Use: "share",
|
||||
Short: "Mark the active profile shared (any local user may control it)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return withDaemon(cmd, func(ctx context.Context, c proto.DaemonServiceClient) error {
|
||||
if _, err := c.ShareProfile(ctx, &proto.ShareProfileRequest{Shared: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.Println("Active profile is now shared with all local users")
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var ownerUnshareCmd = &cobra.Command{
|
||||
Use: "unshare",
|
||||
Short: "Stop sharing the active profile (restrict to its owners)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return withDaemon(cmd, func(ctx context.Context, c proto.DaemonServiceClient) error {
|
||||
if _, err := c.ShareProfile(ctx, &proto.ShareProfileRequest{Shared: false}); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.Println("Active profile is no longer shared")
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// withDaemon runs fn with a connected daemon client, handling setup and teardown.
|
||||
func withDaemon(cmd *cobra.Command, fn func(context.Context, proto.DaemonServiceClient) error) error {
|
||||
SetFlagsFromEnvVars(rootCmd)
|
||||
cmd.SetOut(cmd.OutOrStdout())
|
||||
if err := util.InitLog(logLevel, util.LogConsole); err != nil {
|
||||
log.Errorf("failed initializing log %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
||||
if err != nil {
|
||||
log.Errorf("failed to connect to service CLI interface %v", err)
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if cerr := conn.Close(); cerr != nil {
|
||||
log.Debugf("close daemon connection: %v", cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
return fn(ctx, proto.NewDaemonServiceClient(conn))
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path"
|
||||
@@ -20,8 +21,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
"google.golang.org/grpc"
|
||||
gbackoff "google.golang.org/grpc/backoff"
|
||||
"google.golang.org/grpc/connectivity"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
|
||||
@@ -145,10 +144,10 @@ func init() {
|
||||
|
||||
defaultDaemonAddr := "unix:///var/run/netbird.sock"
|
||||
if runtime.GOOS == "windows" {
|
||||
defaultDaemonAddr = "tcp://127.0.0.1:41731"
|
||||
defaultDaemonAddr = "npipe://netbird"
|
||||
}
|
||||
|
||||
rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]")
|
||||
rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp|npipe]://[path|host:port|name]")
|
||||
rootCmd.PersistentFlags().StringVarP(&managementURL, "management-url", "m", "", fmt.Sprintf("Management Service URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultManagementURL))
|
||||
rootCmd.PersistentFlags().StringVar(&adminURL, "admin-url", "", fmt.Sprintf("Admin Panel URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultAdminURL))
|
||||
rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "info", "sets NetBird log level")
|
||||
@@ -174,6 +173,9 @@ func init() {
|
||||
rootCmd.AddCommand(profileCmd)
|
||||
rootCmd.AddCommand(exposeCmd)
|
||||
|
||||
rootCmd.AddCommand(ownerCmd)
|
||||
ownerCmd.AddCommand(ownerAddCmd, ownerResetCmd, ownerShareCmd, ownerUnshareCmd)
|
||||
|
||||
networksCMD.AddCommand(routesListCmd)
|
||||
networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd)
|
||||
|
||||
@@ -266,70 +268,29 @@ func FlagNameToEnvVar(cmdFlag string, prefix string) string {
|
||||
return prefix + upper
|
||||
}
|
||||
|
||||
// defaultDaemonDialTimeout is how long DialClientGRPCServer waits for the daemon
|
||||
// to become reachable. It is intentionally generous so that invoking the CLI
|
||||
// right after "netbird service start" (e.g. from a container entrypoint) tolerates
|
||||
// the window where the daemon has created its socket but is not yet serving.
|
||||
const defaultDaemonDialTimeout = 30 * time.Second
|
||||
|
||||
// DialClientGRPCServer returns a client connection to the daemon server. It waits
|
||||
// for the daemon to become reachable, retrying with backoff until the connection
|
||||
// reports READY or defaultDaemonDialTimeout elapses. This handles the startup race
|
||||
// where the daemon socket exists (or is about to) before the gRPC server is serving.
|
||||
func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, error) {
|
||||
return dialClientGRPCServer(ctx, addr, defaultDaemonDialTimeout)
|
||||
}
|
||||
|
||||
func dialClientGRPCServer(ctx context.Context, addr string, timeout time.Duration) (*grpc.ClientConn, error) {
|
||||
conn, err := grpc.NewClient(
|
||||
strings.TrimPrefix(addr, "tcp://"),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
// Cap reconnect backoff at 5s; gRPC's default 120s MaxDelay would leave the
|
||||
// CLI waiting far too long to notice a freshly-started daemon. Mirrors the GUI.
|
||||
grpc.WithConnectParams(grpc.ConnectParams{
|
||||
Backoff: gbackoff.Config{
|
||||
BaseDelay: 1 * time.Second,
|
||||
Multiplier: 1.6,
|
||||
Jitter: 0.2,
|
||||
MaxDelay: 5 * time.Second,
|
||||
},
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create daemon gRPC client: %w", err)
|
||||
}
|
||||
|
||||
// grpc.NewClient is lazy: it does not connect until the first RPC or until we
|
||||
// nudge it. Trigger connection attempts and wait until the channel reaches READY.
|
||||
if err := waitForConnReady(ctx, conn, timeout); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// waitForConnReady drives the gRPC channel out of IDLE and blocks until it becomes
|
||||
// READY, or until timeout/ctx expires. TRANSIENT_FAILURE (daemon not yet serving)
|
||||
// is treated as retryable so the caller keeps waiting within the deadline.
|
||||
func waitForConnReady(ctx context.Context, conn *grpc.ClientConn, timeout time.Duration) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
// DialClientGRPCServer returns client connection to the daemon server.
|
||||
func DialClientGRPCServer(ctx context.Context, addr string, opts ...grpc.DialOption) (*grpc.ClientConn, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Second*10)
|
||||
defer cancel()
|
||||
|
||||
for {
|
||||
state := conn.GetState()
|
||||
switch state {
|
||||
case connectivity.Ready:
|
||||
return nil
|
||||
case connectivity.Idle:
|
||||
// Kick the lazy channel into connecting.
|
||||
conn.Connect()
|
||||
}
|
||||
opts = append([]grpc.DialOption{
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithBlock()}, opts...)
|
||||
|
||||
if !conn.WaitForStateChange(ctx, state) {
|
||||
// ctx expired while in `state`.
|
||||
return fmt.Errorf("timed out after %s waiting for daemon to become ready (last state: %s)", timeout, state)
|
||||
}
|
||||
// The daemon reads the caller's kernel identity from the transport
|
||||
// (SO_PEERCRED on a Unix socket, the client token on a Windows named pipe),
|
||||
// so the client stays insecure. For npipe we install a context dialer since
|
||||
// gRPC's resolver does not understand Windows named pipes.
|
||||
target := strings.TrimPrefix(addr, "tcp://")
|
||||
if strings.HasPrefix(addr, "npipe://") {
|
||||
path := pipePath(strings.TrimPrefix(addr, "npipe://"))
|
||||
opts = append(opts, grpc.WithContextDialer(func(dialCtx context.Context, _ string) (net.Conn, error) {
|
||||
return dialNamedPipe(dialCtx, path)
|
||||
}))
|
||||
target = "passthrough:///netbird-daemon-pipe"
|
||||
}
|
||||
|
||||
return grpc.DialContext(ctx, target, opts...)
|
||||
}
|
||||
|
||||
// WithBackOff execute function in backoff cycle.
|
||||
|
||||
@@ -5,6 +5,7 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
@@ -13,12 +14,36 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/server"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
// daemonServerOptions installs peer-identity transport credentials and the
|
||||
// authorization interceptor on the daemon ipc. Identity is only available
|
||||
// over a Unix socket (SO_PEERCRED) or a Windows named pipe (client token).
|
||||
// Over TCP, or on platforms without a peer-credential primitive, the daemon
|
||||
// runs without per-caller authorization and warns (no interceptor, so it does
|
||||
// not deny everyone).
|
||||
func daemonServerOptions(network string, interceptor *ipcauth.Interceptor) []grpc.ServerOption {
|
||||
creds := ipcauth.NewTransportCredentials()
|
||||
if creds == nil {
|
||||
log.Warnf("daemon ipc has no peer-identity primitive on %s, per-caller authorization is disabled", runtime.GOOS)
|
||||
return nil
|
||||
}
|
||||
if network == "tcp" {
|
||||
log.Warnf("daemon is listening on TCP (%s), peer identity cannot be authenticated over TCP, per-caller authorization is disabled", daemonAddr)
|
||||
return nil
|
||||
}
|
||||
return []grpc.ServerOption{
|
||||
grpc.Creds(creds),
|
||||
grpc.ChainUnaryInterceptor(interceptor.UnaryServerInterceptor()),
|
||||
grpc.ChainStreamInterceptor(interceptor.StreamServerInterceptor()),
|
||||
}
|
||||
}
|
||||
|
||||
func validateJSONSocketFlags() error {
|
||||
if serviceCmd.PersistentFlags().Changed("json-socket") && !enableJSONSocket {
|
||||
return fmt.Errorf("--json-socket requires --enable-json-socket to configure the daemon JSON gateway")
|
||||
@@ -37,8 +62,19 @@ func (p *program) Start(svc service.Service) error {
|
||||
// Collect static system and platform information
|
||||
system.UpdateStaticInfoAsync()
|
||||
|
||||
// in any case, even if configuration does not exists we run daemon to serve CLI gRPC API.
|
||||
p.serv = grpc.NewServer()
|
||||
network, _, err := parseListenAddress(daemonAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse daemon address: %w", err)
|
||||
}
|
||||
|
||||
// Owner-authorization interceptor. The ConfigAdapter is a lazy bridge: the
|
||||
// gRPC server is built before the daemon server instance exists, so we set
|
||||
// the real policy backend below once serverInstance is created.
|
||||
ownerAdapter := &ipcauth.ConfigAdapter{}
|
||||
authInterceptor := ipcauth.NewInterceptor(ownerAdapter, ipcauth.NewDefaultGroupResolver())
|
||||
|
||||
// in any case, even if configuration does not exist we run daemon to serve the CLI gRPC API.
|
||||
p.serv = grpc.NewServer(daemonServerOptions(network, authInterceptor)...)
|
||||
|
||||
daemonListener, err := listenOnAddress(daemonAddr)
|
||||
if err != nil {
|
||||
@@ -77,17 +113,15 @@ func (p *program) Start(svc service.Service) error {
|
||||
if err := serverInstance.Start(); err != nil {
|
||||
log.Fatalf("failed to start daemon: %v", err)
|
||||
}
|
||||
ownerAdapter.SetBackend(serverInstance)
|
||||
proto.RegisterDaemonServiceServer(p.serv, serverInstance)
|
||||
// The engine is started and the service is registered: from here on the
|
||||
// daemon serves RPCs backed by a running engine. Report readiness so
|
||||
// clients (e.g. netbird up) can wait deterministically.
|
||||
serverInstance.SetReady()
|
||||
|
||||
p.serverInstanceMu.Lock()
|
||||
p.serverInstance = serverInstance
|
||||
p.serverInstanceMu.Unlock()
|
||||
|
||||
if jsonListener != nil {
|
||||
log.Warnf("JSON gateway (--enable-json-socket) re-dials the daemon locally; the HTTP client's identity is forwarded so per-caller authorization still applies, but restrict access to %s appropriately", jsonSocket)
|
||||
if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil {
|
||||
log.Fatalf("failed to start daemon JSON server: %v", err)
|
||||
}
|
||||
|
||||
@@ -14,16 +14,45 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/metadata"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// jsonPeerCtxKey keys the HTTP client's kernel identity in the request context.
|
||||
type jsonPeerCtxKey struct{}
|
||||
|
||||
// jsonConnContext reads the connecting HTTP client's identity from the JSON
|
||||
// socket (peercred) and stashes it so it can be forwarded to the daemon. The
|
||||
// gateway re-dials the daemon as the daemon's own identity, so without this the
|
||||
// daemon would see every JSON request as privileged.
|
||||
func jsonConnContext(ctx context.Context, c net.Conn) context.Context {
|
||||
id, err := ipcauth.PeerIdentity(c)
|
||||
if err != nil {
|
||||
log.Debugf("json gateway: cannot read HTTP client identity, requests won't carry it: %v", err)
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, jsonPeerCtxKey{}, id)
|
||||
}
|
||||
|
||||
// jsonForwardIdentity injects the stashed HTTP client identity as gRPC metadata
|
||||
// on the gateway's re-dial to the daemon. The daemon trusts it only because the
|
||||
// dial arrives as the daemon's own (self/privileged) identity.
|
||||
func jsonForwardIdentity(ctx context.Context, _ *http.Request) metadata.MD {
|
||||
id, ok := ctx.Value(jsonPeerCtxKey{}).(ipcauth.Identity)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return ipcauth.ForwardIdentityMetadata(id)
|
||||
}
|
||||
|
||||
func grpcGatewayEndpoint(addr string) string {
|
||||
return strings.TrimPrefix(addr, "tcp://")
|
||||
}
|
||||
|
||||
func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint string) error {
|
||||
mux := runtime.NewServeMux()
|
||||
mux := runtime.NewServeMux(runtime.WithMetadata(jsonForwardIdentity))
|
||||
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
|
||||
if err := proto.RegisterDaemonServiceHandlerFromEndpoint(p.ctx, mux, grpcGatewayEndpoint(daemonEndpoint), opts); err != nil {
|
||||
return err
|
||||
@@ -35,6 +64,7 @@ func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint
|
||||
BaseContext: func(net.Listener) context.Context {
|
||||
return p.ctx
|
||||
},
|
||||
ConnContext: jsonConnContext,
|
||||
}
|
||||
|
||||
p.jsonServMu.Lock()
|
||||
|
||||
20
client/cmd/service_pipe_other.go
Normal file
20
client/cmd/service_pipe_other.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build !windows
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// listenNamedPipe is unsupported off Windows; named pipes are a Windows-only transport.
|
||||
func listenNamedPipe(string) (net.Listener, error) {
|
||||
return nil, fmt.Errorf("named pipe daemon socket is only supported on Windows, not %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
// dialNamedPipe is unsupported off Windows.
|
||||
func dialNamedPipe(context.Context, string) (net.Conn, error) {
|
||||
return nil, fmt.Errorf("named pipe daemon socket is only supported on Windows, not %s", runtime.GOOS)
|
||||
}
|
||||
33
client/cmd/service_pipe_windows.go
Normal file
33
client/cmd/service_pipe_windows.go
Normal file
@@ -0,0 +1,33 @@
|
||||
//go:build windows
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
"github.com/Microsoft/go-winio"
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// listenNamedPipe creates the daemon control named pipe with a permissive,
|
||||
// local-only SDDL. Any local caller may connect, on par with the Unix
|
||||
// socket's 0666, and the per-RPC interceptor authorizes. ListenPipe fails
|
||||
// if the pipe already exists (first-instance semantics), which prevents a
|
||||
// squatting process from pre-creating it.
|
||||
func listenNamedPipe(path string) (net.Listener, error) {
|
||||
return winio.ListenPipe(path, &winio.PipeConfig{
|
||||
SecurityDescriptor: ipcauth.DefaultPipeSDDL(),
|
||||
})
|
||||
}
|
||||
|
||||
// dialNamedPipe connects to the daemon ipc named pipe at SECURITY_IDENTIFICATION.
|
||||
func dialNamedPipe(ctx context.Context, path string) (net.Conn, error) {
|
||||
access := uint32(windows.GENERIC_READ | windows.GENERIC_WRITE)
|
||||
// winio's plain DialPipe connects at SECURITY_ANONYMOUS, under which the
|
||||
// daemon cannot read the caller's token. Identification lets the daemon
|
||||
// read its SID/groups without granting it the ability to act as the caller.
|
||||
return winio.DialPipeAccessImpLevel(ctx, path, access, winio.PipeImpLevelIdentification)
|
||||
}
|
||||
@@ -26,6 +26,15 @@ func listenOnAddress(addr string) (*socketListener, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if network == "npipe" {
|
||||
path := pipePath(address)
|
||||
listener, err := listenNamedPipe(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &socketListener{Listener: listener, network: network, address: path}, nil
|
||||
}
|
||||
|
||||
if network == "unix" {
|
||||
removeStaleUnixSocket(address)
|
||||
}
|
||||
@@ -45,13 +54,22 @@ func parseListenAddress(addr string) (string, string, error) {
|
||||
}
|
||||
|
||||
switch network {
|
||||
case "unix", "tcp":
|
||||
case "unix", "tcp", "npipe":
|
||||
return network, address, nil
|
||||
default:
|
||||
return "", "", fmt.Errorf("unsupported daemon address protocol: %v", network)
|
||||
}
|
||||
}
|
||||
|
||||
// pipePath maps a daemon-addr npipe name ("npipe://netbird") to a Windows
|
||||
// named-pipe path (\\.\pipe\netbird).
|
||||
func pipePath(name string) string {
|
||||
if strings.HasPrefix(name, `\\`) {
|
||||
return name
|
||||
}
|
||||
return `\\.\pipe\` + name
|
||||
}
|
||||
|
||||
func removeStaleUnixSocket(path string) {
|
||||
stat, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -56,6 +56,7 @@ var (
|
||||
showQR bool
|
||||
profileName string
|
||||
configPath string
|
||||
claimOwner bool
|
||||
|
||||
upCmd = &cobra.Command{
|
||||
Use: "up",
|
||||
@@ -67,6 +68,7 @@ var (
|
||||
|
||||
func init() {
|
||||
upCmd.PersistentFlags().BoolVarP(&foregroundMode, "foreground-mode", "F", false, "start service in foreground")
|
||||
upCmd.PersistentFlags().BoolVar(&claimOwner, "owner", false, "claim ownership of this profile for the current user, restricting daemon control of it to you and root/administrator")
|
||||
upCmd.PersistentFlags().StringVar(&interfaceName, interfaceNameFlag, iface.WgInterfaceDefault, "WireGuard interface name")
|
||||
upCmd.PersistentFlags().Uint16Var(&wireguardPort, wireguardPortFlag, iface.DefaultWgPort, "WireGuard interface listening port")
|
||||
upCmd.PersistentFlags().Uint16Var(&mtu, mtuFlag, iface.DefaultMTU, "Set MTU (Maximum Transmission Unit) for the WireGuard interface")
|
||||
@@ -295,7 +297,9 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
|
||||
|
||||
client := proto.NewDaemonServiceClient(conn)
|
||||
|
||||
status, err := waitForDaemonStatus(ctx, client)
|
||||
status, err := client.Status(ctx, &proto.StatusRequest{
|
||||
WaitForReady: func() *bool { b := true; return &b }(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to get daemon status: %v", err)
|
||||
}
|
||||
@@ -334,79 +338,6 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
|
||||
return nil
|
||||
}
|
||||
|
||||
// daemonStatusPollTimeout bounds how long we poll the Status RPC waiting for the
|
||||
// daemon to answer coherently. The transport is already READY at this point (see
|
||||
// DialClientGRPCServer), so this only covers the brief window where the gRPC server
|
||||
// is serving but the daemon engine is still starting up and the Status RPC races
|
||||
// against server.Start().
|
||||
const daemonStatusPollTimeout = 15 * time.Second
|
||||
|
||||
// daemonReadyGrace bounds how long we keep polling once the daemon answers but
|
||||
// still reports DaemonReady=false. A freshly-started daemon flips it to true
|
||||
// within this window; an older daemon that never sets the field simply falls
|
||||
// through after the grace elapses, preserving backward compatibility.
|
||||
const daemonReadyGrace = 10 * time.Second
|
||||
|
||||
// waitForDaemonStatus fetches the daemon status, waiting for the daemon to become
|
||||
// ready. It handles two startup races:
|
||||
//
|
||||
// 1. The gRPC server is not yet serving: Status fails with Unavailable — retry.
|
||||
// 2. The server serves but the engine is still starting: a DaemonReady-aware
|
||||
// daemon reports DaemonReady=false until Start finishes; poll until it flips
|
||||
// true (bounded by daemonReadyGrace). Older daemons never set DaemonReady, so
|
||||
// we stop waiting on it after the grace and use the status as-is.
|
||||
//
|
||||
// It gives up after daemonStatusPollTimeout.
|
||||
func waitForDaemonStatus(ctx context.Context, client proto.DaemonServiceClient) (*proto.StatusResponse, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, daemonStatusPollTimeout)
|
||||
defer cancel()
|
||||
|
||||
waitForReady := true
|
||||
req := &proto.StatusRequest{WaitForReady: &waitForReady}
|
||||
|
||||
var lastErr error
|
||||
var firstAnswer time.Time
|
||||
for {
|
||||
status, err := client.Status(ctx, req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
// Only retry while the daemon is not yet answering; surface real errors.
|
||||
if s, ok := gstatus.FromError(err); !ok || s.Code() != codes.Unavailable {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// Daemon answered. Explicitly ready (DaemonReady-aware daemon), or
|
||||
// already fully connected — either way, done. Connected is the only
|
||||
// status unambiguous enough to short-circuit on: a DaemonReady-aware
|
||||
// daemon sets the flag at startup, so trusting Connected here can only
|
||||
// help an older daemon that never sets the flag, without overriding a
|
||||
// new daemon that legitimately reports DaemonReady=false while starting.
|
||||
if status.GetDaemonReady() || internal.StatusType(status.GetStatus()) == internal.StatusConnected {
|
||||
return status, nil
|
||||
}
|
||||
// Answered but neither ready-flagged nor connected yet: give a
|
||||
// DaemonReady-aware daemon a bounded window to finish starting, then
|
||||
// fall through so an older daemon that never sets the flag isn't
|
||||
// blocked here.
|
||||
if firstAnswer.IsZero() {
|
||||
firstAnswer = time.Now()
|
||||
} else if time.Since(firstAnswer) >= daemonReadyGrace {
|
||||
return status, nil
|
||||
}
|
||||
lastErr = nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if lastErr != nil {
|
||||
return nil, fmt.Errorf("daemon did not become ready: %w", lastErr)
|
||||
}
|
||||
return nil, fmt.Errorf("daemon did not become ready within %s", daemonStatusPollTimeout)
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServiceClient, pm *profilemanager.ProfileManager, activeProf *profilemanager.Profile, customDNSAddressConverted []byte, username string) error {
|
||||
|
||||
providedSetupKey, err := getSetupKey()
|
||||
@@ -462,6 +393,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
|
||||
if _, err := client.Up(ctx, &proto.UpRequest{
|
||||
ProfileName: &profileID,
|
||||
Username: &username,
|
||||
ClaimOwner: claimOwner,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("call service up method: %v", err)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestUpDaemon(t *testing.T) {
|
||||
}
|
||||
|
||||
sm := profilemanager.ServiceManager{}
|
||||
created, err := sm.AddProfile("test1", currUser.Username)
|
||||
created, err := sm.AddProfile("test1", currUser.Username, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to add profile: %v", err)
|
||||
return
|
||||
|
||||
@@ -121,6 +121,7 @@ type Manager struct {
|
||||
udpTracker *conntrack.UDPTracker
|
||||
icmpTracker *conntrack.ICMPTracker
|
||||
tcpTracker *conntrack.TCPTracker
|
||||
fragments *fragmentTracker
|
||||
forwarder atomic.Pointer[forwarder.Forwarder]
|
||||
pendingCapture atomic.Pointer[forwarder.PacketCapture]
|
||||
logger *nblog.Logger
|
||||
@@ -183,6 +184,41 @@ func (d *decoder) decodePacket(data []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
// decodeTransport decodes the transport header of a first fragment (which
|
||||
// gopacket leaves undecoded) into the decoder and appends its layer type to
|
||||
// decoded, so the ACL pipeline can evaluate it like a normal packet. It returns
|
||||
// false if the protocol is unsupported or the header is truncated.
|
||||
func (d *decoder) decodeTransport(proto layers.IPProtocol, payload []byte) bool {
|
||||
var l4 gopacket.DecodingLayer
|
||||
var layerType gopacket.LayerType
|
||||
var minLen int
|
||||
switch proto {
|
||||
case layers.IPProtocolTCP:
|
||||
l4, layerType, minLen = &d.tcp, layers.LayerTypeTCP, 20
|
||||
case layers.IPProtocolUDP:
|
||||
l4, layerType, minLen = &d.udp, layers.LayerTypeUDP, 8
|
||||
case layers.IPProtocolICMPv4:
|
||||
l4, layerType, minLen = &d.icmp4, layers.LayerTypeICMPv4, 8
|
||||
case layers.IPProtocolICMPv6:
|
||||
l4, layerType, minLen = &d.icmp6, layers.LayerTypeICMPv6, 8
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
// Reject a fragment too small to hold the full transport header before
|
||||
// decoding: it can't be ACL-evaluated (tiny-fragment attack), and skipping
|
||||
// the decode avoids gopacket allocating an error on the drop path.
|
||||
if len(payload) < minLen {
|
||||
return false
|
||||
}
|
||||
|
||||
if err := l4.DecodeFromBytes(payload, gopacket.NilDecodeFeedback); err != nil {
|
||||
return false
|
||||
}
|
||||
d.decoded = append(d.decoded, layerType)
|
||||
return true
|
||||
}
|
||||
|
||||
// Create userspace firewall manager constructor
|
||||
func Create(iface common.IFaceMapper, disableServerRoutes bool, flowLogger nftypes.FlowLogger, mtu uint16) (*Manager, error) {
|
||||
return create(iface, nil, disableServerRoutes, flowLogger, mtu)
|
||||
@@ -286,6 +322,8 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe
|
||||
if err := m.localipmanager.UpdateLocalIPs(iface); err != nil {
|
||||
return nil, fmt.Errorf("update local IPs: %w", err)
|
||||
}
|
||||
m.fragments = newFragmentTracker(m.logger)
|
||||
|
||||
if disableConntrack {
|
||||
log.Info("conntrack is disabled")
|
||||
} else {
|
||||
@@ -299,6 +337,7 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe
|
||||
}
|
||||
}
|
||||
if err := iface.SetFilter(m); err != nil {
|
||||
m.fragments.Close()
|
||||
return nil, fmt.Errorf("set filter: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
@@ -694,6 +733,10 @@ func (m *Manager) resetState() {
|
||||
m.tcpTracker.Close()
|
||||
}
|
||||
|
||||
if m.fragments != nil {
|
||||
m.fragments.Close()
|
||||
}
|
||||
|
||||
if fwder := m.forwarder.Load(); fwder != nil {
|
||||
fwder.SetCapture(nil)
|
||||
fwder.Stop()
|
||||
@@ -1046,19 +1089,20 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// TODO: pass fragments of routed packets to forwarder
|
||||
// gopacket does not decode the transport header of any IP fragment, so
|
||||
// fragments take a dedicated path: the first fragment's header is decoded
|
||||
// and ACL-evaluated here, and the remaining fragments inherit its verdict.
|
||||
if fragment {
|
||||
if m.logger.Enabled(nblog.LevelTrace) {
|
||||
if d.decoded[0] == layers.LayerTypeIPv4 {
|
||||
m.logger.Trace4("packet is a fragment: src=%v dst=%v id=%v flags=%v",
|
||||
srcIP, dstIP, d.ip4.Id, d.ip4.Flags)
|
||||
} else {
|
||||
m.logger.Trace2("packet is an IPv6 fragment: src=%v dst=%v", srcIP, dstIP)
|
||||
}
|
||||
}
|
||||
return false
|
||||
return m.filterInboundFragment(d, srcIP, dstIP, size)
|
||||
}
|
||||
|
||||
return m.filterInboundDecoded(d, srcIP, dstIP, packetData, size)
|
||||
}
|
||||
|
||||
// filterInboundDecoded runs the ACL, DNAT and conntrack pipeline on a fully
|
||||
// decoded (non-fragment) inbound packet. It returns true if the packet should
|
||||
// be dropped.
|
||||
func (m *Manager) filterInboundDecoded(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool {
|
||||
// TODO: optimize port DNAT by caching matched rules in conntrack
|
||||
if translated := m.translateInboundPortDNAT(packetData, d, srcIP, dstIP); translated {
|
||||
// Re-decode after port DNAT translation to update port information
|
||||
@@ -1089,33 +1133,226 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool {
|
||||
return m.handleRoutedTraffic(d, srcIP, dstIP, packetData, size)
|
||||
}
|
||||
|
||||
// fragmentMeta holds the reassembly identity and layout of an IP fragment,
|
||||
// extracted uniformly for IPv4 and IPv6.
|
||||
type fragmentMeta struct {
|
||||
key fragmentKey
|
||||
// offset is the fragment offset in 8-byte units (zero for the first
|
||||
// fragment).
|
||||
offset uint16
|
||||
// moreFragments is the More Fragments bit. A first fragment with it unset is
|
||||
// an IPv6 atomic fragment (a complete datagram, RFC 6946): it has no trailing
|
||||
// fragments to inherit a verdict, so it must not be recorded.
|
||||
moreFragments bool
|
||||
proto layers.IPProtocol
|
||||
// l4payload is the fragmentable payload of this fragment. For the first
|
||||
// fragment it starts with the transport header.
|
||||
l4payload []byte
|
||||
// headerEndOctets is the first fragment's payload length in 8-byte units:
|
||||
// the smallest offset a trailing fragment may start at without overlapping
|
||||
// the inspected transport header.
|
||||
headerEndOctets uint16
|
||||
}
|
||||
|
||||
// fragmentMetadata extracts the fragment identity and layout from a decoded IP
|
||||
// fragment. It returns false for fragments it can't interpret (e.g. an IPv6
|
||||
// fragment header shorter than 8 bytes), which are then dropped.
|
||||
func fragmentMetadata(d *decoder, srcIP, dstIP netip.Addr) (fragmentMeta, bool) {
|
||||
switch d.decoded[0] {
|
||||
case layers.LayerTypeIPv4:
|
||||
payload := d.ip4.Payload
|
||||
return fragmentMeta{
|
||||
key: fragmentKey{srcIP: srcIP, dstIP: dstIP, id: uint32(d.ip4.Id), proto: uint8(d.ip4.Protocol)},
|
||||
offset: d.ip4.FragOffset,
|
||||
moreFragments: d.ip4.Flags&layers.IPv4MoreFragments != 0,
|
||||
proto: d.ip4.Protocol,
|
||||
l4payload: payload,
|
||||
headerEndOctets: octets(len(payload)),
|
||||
}, true
|
||||
|
||||
case layers.LayerTypeIPv6:
|
||||
// IPv6 fragment extension header: 8 bytes, followed by the fragmentable
|
||||
// payload. Layout: next header (1), reserved (1), offset+flags (2), id (4).
|
||||
payload := d.ip6.Payload
|
||||
if len(payload) < 8 {
|
||||
return fragmentMeta{}, false
|
||||
}
|
||||
nextHeader := layers.IPProtocol(payload[0])
|
||||
offsetFlags := binary.BigEndian.Uint16(payload[2:4])
|
||||
id := binary.BigEndian.Uint32(payload[4:8])
|
||||
l4 := payload[8:]
|
||||
return fragmentMeta{
|
||||
key: fragmentKey{srcIP: srcIP, dstIP: dstIP, id: id, proto: uint8(nextHeader)},
|
||||
offset: offsetFlags >> 3,
|
||||
moreFragments: offsetFlags&1 != 0,
|
||||
proto: nextHeader,
|
||||
l4payload: l4,
|
||||
headerEndOctets: octets(len(l4)),
|
||||
}, true
|
||||
|
||||
default:
|
||||
return fragmentMeta{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// octets rounds a byte length up to whole 8-byte units, the granularity of the
|
||||
// IP fragment offset field.
|
||||
func octets(nbytes int) uint16 {
|
||||
return uint16((nbytes + 7) / 8)
|
||||
}
|
||||
|
||||
// filterInboundFragment decides the fate of an IP fragment. gopacket stops
|
||||
// decoding at the network layer for every fragment, so the first fragment's
|
||||
// transport header is decoded and ACL-evaluated here and its verdict recorded;
|
||||
// the remaining (headerless) fragments inherit that verdict. Anything that
|
||||
// cannot be tied to an allowed, non-overlapping first fragment is dropped.
|
||||
func (m *Manager) filterInboundFragment(d *decoder, srcIP, dstIP netip.Addr, size int) bool {
|
||||
meta, ok := fragmentMetadata(d, srcIP, dstIP)
|
||||
if !ok {
|
||||
if m.logger.Enabled(nblog.LevelTrace) {
|
||||
m.logger.Trace2("dropping unsupported fragment: src=%v dst=%v", srcIP, dstIP)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if meta.offset != 0 {
|
||||
return m.filterTrailingFragment(meta, srcIP, dstIP)
|
||||
}
|
||||
|
||||
// A new first fragment supersedes any recorded verdict for this datagram, so
|
||||
// a re-sent or overlapping offset-zero fragment can't inherit the old one.
|
||||
m.fragments.poison(meta.key)
|
||||
|
||||
// First fragment: decode its transport header so the ACL can evaluate it. A
|
||||
// decode failure means the fragment is too small to hold the full transport
|
||||
// header (RFC 1858 §3 tiny-fragment attack); it can't be evaluated, so drop it.
|
||||
if !d.decodeTransport(meta.proto, meta.l4payload) {
|
||||
if m.logger.Enabled(nblog.LevelTrace) {
|
||||
m.logger.Trace3("dropping first fragment without full L4 header: src=%v dst=%v id=%v",
|
||||
srcIP, dstIP, meta.key.id)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return m.filterFirstFragment(d, meta, srcIP, dstIP, size)
|
||||
}
|
||||
|
||||
// filterTrailingFragment applies a recorded first-fragment verdict to a
|
||||
// non-first fragment.
|
||||
func (m *Manager) filterTrailingFragment(meta fragmentMeta, srcIP, dstIP netip.Addr) bool {
|
||||
switch m.fragments.verdict(meta.key, meta.offset) {
|
||||
case fragmentAllow:
|
||||
return false
|
||||
case fragmentOverlap:
|
||||
if m.logger.Enabled(nblog.LevelTrace) {
|
||||
m.logger.Trace3("dropping overlapping fragment rewriting inspected header: src=%v dst=%v id=%v",
|
||||
srcIP, dstIP, meta.key.id)
|
||||
}
|
||||
return true
|
||||
default:
|
||||
if m.logger.Enabled(nblog.LevelTrace) {
|
||||
m.logger.Trace3("dropping fragment with no allowed first fragment: src=%v dst=%v id=%v",
|
||||
srcIP, dstIP, meta.key.id)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// filterFirstFragment runs the verdict part of the inbound pipeline on a first
|
||||
// fragment with its transport header decoded. It mirrors filterInboundDecoded
|
||||
// but skips DNAT (port rewriting on fragments is unsupported) and forwarder
|
||||
// injection (fragments are left to the stack to reassemble, not forwarded).
|
||||
// Allowed fragments have their verdict recorded so the datagram's trailing
|
||||
// fragments inherit it.
|
||||
func (m *Manager) filterFirstFragment(d *decoder, meta fragmentMeta, srcIP, dstIP netip.Addr, size int) bool {
|
||||
if m.stateful && m.isValidTrackedConnection(d, srcIP, dstIP, size) {
|
||||
m.recordFirstFragment(meta)
|
||||
return false
|
||||
}
|
||||
|
||||
if m.localipmanager.IsLocalIP(dstIP) {
|
||||
ruleID, blocked := m.peerACLsBlock(srcIP, d, nil)
|
||||
if blocked {
|
||||
m.storeDropFlow("Dropping local first fragment (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
||||
d, srcIP, dstIP, ruleID, size)
|
||||
return true
|
||||
}
|
||||
m.trackInbound(d, srcIP, dstIP, ruleID, size)
|
||||
m.recordFirstFragment(meta)
|
||||
return false
|
||||
}
|
||||
|
||||
if !m.routingEnabled.Load() {
|
||||
if m.logger.Enabled(nblog.LevelTrace) {
|
||||
m.logger.Trace2("Dropping routed fragment (routing disabled): src=%s dst=%s", srcIP, dstIP)
|
||||
}
|
||||
return true
|
||||
}
|
||||
if m.nativeRouter.Load() {
|
||||
m.trackInbound(d, srcIP, dstIP, nil, size)
|
||||
m.recordFirstFragment(meta)
|
||||
return false
|
||||
}
|
||||
|
||||
// TODO: pass fragments of routed packets to the forwarder; until then
|
||||
// allowed routed fragments go to the native stack.
|
||||
srcPort, dstPort := getPortsFromPacket(d)
|
||||
ruleID, pass := m.routeACLsPass(srcIP, dstIP, d.decoded[1], srcPort, dstPort)
|
||||
if !pass {
|
||||
m.storeDropFlow("Dropping routed first fragment (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
||||
d, srcIP, dstIP, ruleID, size)
|
||||
return true
|
||||
}
|
||||
|
||||
m.recordFirstFragment(meta)
|
||||
return false
|
||||
}
|
||||
|
||||
// recordFirstFragment caches an allowed first fragment's verdict for its
|
||||
// trailing fragments to inherit. Atomic fragments (no More Fragments bit) are
|
||||
// complete datagrams with no trailing fragments, so they are not cached and
|
||||
// cannot exhaust the verdict table.
|
||||
func (m *Manager) recordFirstFragment(meta fragmentMeta) {
|
||||
if !meta.moreFragments {
|
||||
return
|
||||
}
|
||||
m.fragments.recordAllowed(meta.key, meta.headerEndOctets)
|
||||
}
|
||||
|
||||
// storeDropFlow logs and records a netflow drop event for an inbound packet
|
||||
// denied by the ACLs. msg is the trace format taking rule id, protocol, source
|
||||
// and destination.
|
||||
func (m *Manager) storeDropFlow(msg string, d *decoder, srcIP, dstIP netip.Addr, ruleID []byte, size int) {
|
||||
pnum := getProtocolFromPacket(d)
|
||||
srcPort, dstPort := getPortsFromPacket(d)
|
||||
|
||||
if m.logger.Enabled(nblog.LevelTrace) {
|
||||
m.logger.Trace6(msg, ruleID, pnum, srcIP, srcPort, dstIP, dstPort)
|
||||
}
|
||||
|
||||
m.flowLogger.StoreEvent(nftypes.EventFields{
|
||||
FlowID: uuid.New(),
|
||||
Type: nftypes.TypeDrop,
|
||||
RuleID: ruleID,
|
||||
Direction: nftypes.Ingress,
|
||||
Protocol: pnum,
|
||||
SourceIP: srcIP,
|
||||
DestIP: dstIP,
|
||||
SourcePort: srcPort,
|
||||
DestPort: dstPort,
|
||||
// TODO: icmp type/code
|
||||
RxPackets: 1,
|
||||
RxBytes: uint64(size),
|
||||
})
|
||||
}
|
||||
|
||||
// handleLocalTraffic handles local traffic.
|
||||
// If it returns true, the packet should be dropped.
|
||||
func (m *Manager) handleLocalTraffic(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool {
|
||||
ruleID, blocked := m.peerACLsBlock(srcIP, d, packetData)
|
||||
if blocked {
|
||||
pnum := getProtocolFromPacket(d)
|
||||
srcPort, dstPort := getPortsFromPacket(d)
|
||||
|
||||
if m.logger.Enabled(nblog.LevelTrace) {
|
||||
m.logger.Trace6("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
||||
ruleID, pnum, srcIP, srcPort, dstIP, dstPort)
|
||||
}
|
||||
|
||||
m.flowLogger.StoreEvent(nftypes.EventFields{
|
||||
FlowID: uuid.New(),
|
||||
Type: nftypes.TypeDrop,
|
||||
RuleID: ruleID,
|
||||
Direction: nftypes.Ingress,
|
||||
Protocol: pnum,
|
||||
SourceIP: srcIP,
|
||||
DestIP: dstIP,
|
||||
SourcePort: srcPort,
|
||||
DestPort: dstPort,
|
||||
// TODO: icmp type/code
|
||||
RxPackets: 1,
|
||||
RxBytes: uint64(size),
|
||||
})
|
||||
m.storeDropFlow("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
||||
d, srcIP, dstIP, ruleID, size)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1168,27 +1405,8 @@ func (m *Manager) handleRoutedTraffic(d *decoder, srcIP, dstIP netip.Addr, packe
|
||||
|
||||
ruleID, pass := m.routeACLsPass(srcIP, dstIP, protoLayer, srcPort, dstPort)
|
||||
if !pass {
|
||||
proto := getProtocolFromPacket(d)
|
||||
|
||||
if m.logger.Enabled(nblog.LevelTrace) {
|
||||
m.logger.Trace6("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
||||
ruleID, proto, srcIP, srcPort, dstIP, dstPort)
|
||||
}
|
||||
|
||||
m.flowLogger.StoreEvent(nftypes.EventFields{
|
||||
FlowID: uuid.New(),
|
||||
Type: nftypes.TypeDrop,
|
||||
RuleID: ruleID,
|
||||
Direction: nftypes.Ingress,
|
||||
Protocol: proto,
|
||||
SourceIP: srcIP,
|
||||
DestIP: dstIP,
|
||||
SourcePort: srcPort,
|
||||
DestPort: dstPort,
|
||||
// TODO: icmp type/code
|
||||
RxPackets: 1,
|
||||
RxBytes: uint64(size),
|
||||
})
|
||||
m.storeDropFlow("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
||||
d, srcIP, dstIP, ruleID, size)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -31,6 +33,11 @@ const (
|
||||
defaultMaxInFlight = 1024
|
||||
iosReceiveWindow = 16384
|
||||
iosMaxInFlight = 256
|
||||
|
||||
// envForceTCPRACK overrides the platform default for gVisor's RACK loss
|
||||
// detection. Set to a truthy value to force RACK on, or a falsy value to
|
||||
// force it off, on any platform.
|
||||
envForceTCPRACK = "NB_FORCE_TCP_RACK"
|
||||
)
|
||||
|
||||
type Forwarder struct {
|
||||
@@ -152,6 +159,8 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow
|
||||
maxInFlight = iosMaxInFlight
|
||||
}
|
||||
|
||||
configureTCPRecovery(s)
|
||||
|
||||
tcpForwarder := tcp.NewForwarder(s, receiveWindow, maxInFlight, f.handleTCP)
|
||||
s.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpForwarder.HandlePacket)
|
||||
|
||||
@@ -466,3 +475,31 @@ func probeRawICMP(network, addr string, logger *nblog.Logger) bool {
|
||||
logger.Debug1("forwarder: raw %s socket access available", network)
|
||||
return true
|
||||
}
|
||||
|
||||
// configureTCPRecovery disables gVisor's RACK loss detection on Windows, where
|
||||
// it interacts poorly with the host and collapses throughput on routed TCP
|
||||
// connections (gVisor issue #9778). Other platforms keep the default. The
|
||||
// EnvForceTCPRACK environment variable overrides the platform default.
|
||||
func configureTCPRecovery(s *stack.Stack) {
|
||||
disableRACK := runtime.GOOS == "windows"
|
||||
|
||||
if val := os.Getenv(envForceTCPRACK); val != "" {
|
||||
force, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
log.Warnf("parse %s: %v", envForceTCPRACK, err)
|
||||
} else {
|
||||
disableRACK = !force
|
||||
}
|
||||
}
|
||||
|
||||
if !disableRACK {
|
||||
return
|
||||
}
|
||||
|
||||
opt := tcpip.TCPRecovery(0)
|
||||
if err := s.SetTransportProtocolOption(tcp.ProtocolNumber, &opt); err != nil {
|
||||
log.Warnf("disable TCP RACK loss detection: %v", err)
|
||||
return
|
||||
}
|
||||
log.Info("forwarder: TCP RACK loss detection disabled")
|
||||
}
|
||||
|
||||
204
client/firewall/uspfilter/fragment.go
Normal file
204
client/firewall/uspfilter/fragment.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package uspfilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultFragmentTimeout bounds how long a first-fragment verdict is kept
|
||||
// while the remaining fragments arrive. It mirrors the Linux IP reassembly
|
||||
// timeout (net.ipv4.ipfrag_time).
|
||||
defaultFragmentTimeout = 30 * time.Second
|
||||
// fragmentCleanupInterval is how often expired verdicts are purged.
|
||||
fragmentCleanupInterval = 10 * time.Second
|
||||
// defaultMaxFragmentEntries caps the number of concurrently tracked
|
||||
// fragmented datagrams. The table stays bounded because each datagram is a
|
||||
// single small entry regardless of how many fragments it is split into, and
|
||||
// the 13-bit IPv4 fragment-offset field limits any datagram to 64 KiB.
|
||||
defaultMaxFragmentEntries = 16384
|
||||
|
||||
// EnvFragmentMaxEntries overrides defaultMaxFragmentEntries.
|
||||
EnvFragmentMaxEntries = "NB_FRAGMENT_MAX_ENTRIES"
|
||||
)
|
||||
|
||||
// fragmentVerdict is the decision for a trailing (headerless) fragment.
|
||||
type fragmentVerdict int
|
||||
|
||||
const (
|
||||
// fragmentDeny drops the fragment: no allowed first fragment is on record.
|
||||
fragmentDeny fragmentVerdict = iota
|
||||
// fragmentAllow passes the fragment: it belongs to an allowed datagram and
|
||||
// does not overlap the already-inspected transport header.
|
||||
fragmentAllow
|
||||
// fragmentOverlap drops the fragment and poisons its datagram: it overlaps
|
||||
// the transport header the ACL inspected (RFC 1858 §4, RFC 3128; RFC 5722
|
||||
// requires discarding the whole datagram on overlap for IPv6).
|
||||
fragmentOverlap
|
||||
)
|
||||
|
||||
// fragmentKey identifies a fragmented datagram. It matches the RFC 791 / RFC
|
||||
// 8200 reassembly key: source, destination, protocol and identification. The id
|
||||
// is 32-bit to hold both the IPv4 (16-bit) and IPv6 (32-bit) identification.
|
||||
type fragmentKey struct {
|
||||
srcIP netip.Addr
|
||||
dstIP netip.Addr
|
||||
id uint32
|
||||
proto uint8
|
||||
}
|
||||
|
||||
// fragmentEntry records the verdict of an allowed first fragment.
|
||||
type fragmentEntry struct {
|
||||
// headerEndOctets is the offset, in 8-byte units, at which the first
|
||||
// fragment's payload ended. A trailing fragment starting before this
|
||||
// overlaps bytes the ACL already inspected and is rejected.
|
||||
headerEndOctets uint16
|
||||
// recordedAt is when the first fragment was accepted. The verdict expires a
|
||||
// fixed timeout later and is not refreshed, mirroring the kernel reassembly
|
||||
// timer so a trailing-fragment flood can't keep a datagram alive.
|
||||
recordedAt time.Time
|
||||
}
|
||||
|
||||
// fragmentTracker records the ACL verdict of a datagram's first fragment so the
|
||||
// remaining fragments, which carry no L4 header, can inherit the decision
|
||||
// without reassembling the datagram. Only allowed first fragments are stored;
|
||||
// anything that cannot be tied to an allowed, non-overlapping first fragment is
|
||||
// dropped (fail closed).
|
||||
type fragmentTracker struct {
|
||||
logger *nblog.Logger
|
||||
mutex sync.Mutex
|
||||
entries map[fragmentKey]fragmentEntry
|
||||
timeout time.Duration
|
||||
// maxEntries caps the table; atCapacity dedups the capacity warning until
|
||||
// the table drains below the cap again.
|
||||
maxEntries int
|
||||
atCapacity bool
|
||||
cleanupTicker *time.Ticker
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func newFragmentTracker(logger *nblog.Logger) *fragmentTracker {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t := &fragmentTracker{
|
||||
logger: logger,
|
||||
entries: make(map[fragmentKey]fragmentEntry),
|
||||
timeout: defaultFragmentTimeout,
|
||||
maxEntries: fragmentMaxEntries(logger),
|
||||
cleanupTicker: time.NewTicker(fragmentCleanupInterval),
|
||||
cancel: cancel,
|
||||
}
|
||||
go t.cleanupRoutine(ctx)
|
||||
return t
|
||||
}
|
||||
|
||||
func fragmentMaxEntries(logger *nblog.Logger) int {
|
||||
v := os.Getenv(EnvFragmentMaxEntries)
|
||||
if v == "" {
|
||||
return defaultMaxFragmentEntries
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n <= 0 {
|
||||
logger.Warn2("invalid %s=%q, using default", EnvFragmentMaxEntries, v)
|
||||
return defaultMaxFragmentEntries
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// recordAllowed stores the verdict of an allowed first fragment. headerEndOctets
|
||||
// is the first fragment's payload length in 8-byte units. When the table is full
|
||||
// the record is dropped, which fails closed: the datagram's trailing fragments
|
||||
// will be denied.
|
||||
func (t *fragmentTracker) recordAllowed(key fragmentKey, headerEndOctets uint16) {
|
||||
t.mutex.Lock()
|
||||
defer t.mutex.Unlock()
|
||||
|
||||
if t.entries == nil {
|
||||
return
|
||||
}
|
||||
if _, ok := t.entries[key]; !ok && len(t.entries) >= t.maxEntries {
|
||||
if !t.atCapacity {
|
||||
t.atCapacity = true
|
||||
t.logger.Warn2("fragment verdict table at capacity (%d/%d): trailing fragments of new datagrams will be dropped",
|
||||
len(t.entries), t.maxEntries)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.entries[key] = fragmentEntry{
|
||||
headerEndOctets: headerEndOctets,
|
||||
recordedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// poison drops any recorded verdict for a datagram, so its later fragments are
|
||||
// denied until a new allowed first fragment is recorded. Called on every
|
||||
// offset-zero fragment to defeat offset-zero overlap rewrites (RFC 3128).
|
||||
func (t *fragmentTracker) poison(key fragmentKey) {
|
||||
t.mutex.Lock()
|
||||
defer t.mutex.Unlock()
|
||||
delete(t.entries, key)
|
||||
}
|
||||
|
||||
// verdict decides the fate of a trailing fragment at fragOffsetOctets (the IPv4
|
||||
// fragment offset, in 8-byte units). A fragment overlapping the inspected
|
||||
// header poisons the datagram: the entry is removed so all further fragments of
|
||||
// that datagram are denied too.
|
||||
func (t *fragmentTracker) verdict(key fragmentKey, fragOffsetOctets uint16) fragmentVerdict {
|
||||
t.mutex.Lock()
|
||||
defer t.mutex.Unlock()
|
||||
|
||||
entry, ok := t.entries[key]
|
||||
if !ok {
|
||||
return fragmentDeny
|
||||
}
|
||||
if time.Since(entry.recordedAt) > t.timeout {
|
||||
delete(t.entries, key)
|
||||
return fragmentDeny
|
||||
}
|
||||
if fragOffsetOctets < entry.headerEndOctets {
|
||||
delete(t.entries, key)
|
||||
return fragmentOverlap
|
||||
}
|
||||
return fragmentAllow
|
||||
}
|
||||
|
||||
func (t *fragmentTracker) cleanupRoutine(ctx context.Context) {
|
||||
defer t.cleanupTicker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-t.cleanupTicker.C:
|
||||
t.cleanup()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *fragmentTracker) cleanup() {
|
||||
t.mutex.Lock()
|
||||
defer t.mutex.Unlock()
|
||||
|
||||
for key, entry := range t.entries {
|
||||
if time.Since(entry.recordedAt) > t.timeout {
|
||||
delete(t.entries, key)
|
||||
}
|
||||
}
|
||||
|
||||
if len(t.entries) < t.maxEntries {
|
||||
t.atCapacity = false
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops the cleanup routine and releases resources.
|
||||
func (t *fragmentTracker) Close() {
|
||||
t.cancel()
|
||||
|
||||
t.mutex.Lock()
|
||||
t.entries = nil
|
||||
t.mutex.Unlock()
|
||||
}
|
||||
115
client/firewall/uspfilter/fragment_bench_test.go
Normal file
115
client/firewall/uspfilter/fragment_bench_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package uspfilter
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// benchFilterInbound drives filterInbound over a fixed packet in a tight loop.
|
||||
// Packets are built once, outside the timed region, so the benchmark measures
|
||||
// only pipeline cost, which is what an attacker can amplify.
|
||||
func benchFilterInbound(b *testing.B, pkt []byte) {
|
||||
b.Helper()
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(pkt)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
m := benchManager
|
||||
m.filterInbound(pkt, len(pkt))
|
||||
}
|
||||
}
|
||||
|
||||
// benchManager is a package-level manager reused across fragment benchmarks so
|
||||
// setup cost stays out of the timed region.
|
||||
var benchManager *Manager
|
||||
|
||||
func setupBenchManager(b *testing.B) *Manager {
|
||||
b.Helper()
|
||||
m := newFragmentTestManager(b)
|
||||
allowUDP(b, m, 8080)
|
||||
// Disable conntrack so the allowed-first-fragment path measures transport
|
||||
// decode + ACL every iteration instead of matching the connection tracked
|
||||
// on the first iteration.
|
||||
m.stateful = false
|
||||
benchManager = m
|
||||
return m
|
||||
}
|
||||
|
||||
// BenchmarkInbound_NormalPacket is the baseline: a full, non-fragmented UDP
|
||||
// packet that passes the ACL. Fragment paths should stay comparable to this.
|
||||
func BenchmarkInbound_NormalPacket(b *testing.B) {
|
||||
setupBenchManager(b)
|
||||
pkt := normalUDPPacket(b, 8080, 32)
|
||||
benchFilterInbound(b, pkt)
|
||||
}
|
||||
|
||||
// BenchmarkInbound_FirstFragmentAllowed measures the first-fragment path:
|
||||
// transport decode + ACL evaluation + verdict record.
|
||||
func BenchmarkInbound_FirstFragmentAllowed(b *testing.B) {
|
||||
setupBenchManager(b)
|
||||
pkt := firstFragmentUDP(b, 0x2000, 8080, 32)
|
||||
benchFilterInbound(b, pkt)
|
||||
}
|
||||
|
||||
// BenchmarkInbound_TrailingFragmentAllowed measures the common trailing-fragment
|
||||
// path: a single map lookup after the first fragment is on record.
|
||||
func BenchmarkInbound_TrailingFragmentAllowed(b *testing.B) {
|
||||
m := setupBenchManager(b)
|
||||
first := firstFragmentUDP(b, 0x3000, 8080, 32)
|
||||
m.filterInbound(first, len(first))
|
||||
pkt := trailingFragment(b, 0x3000, 5, false, 24)
|
||||
benchFilterInbound(b, pkt)
|
||||
}
|
||||
|
||||
// BenchmarkInbound_TrailingFragmentNoFirst is the primary DoS vector: an
|
||||
// attacker floods trailing fragments with no first fragment on record. Each is
|
||||
// a map miss and must be cheap.
|
||||
func BenchmarkInbound_TrailingFragmentNoFirst(b *testing.B) {
|
||||
setupBenchManager(b)
|
||||
pkt := trailingFragment(b, 0x4000, 185, false, 40)
|
||||
benchFilterInbound(b, pkt)
|
||||
}
|
||||
|
||||
// BenchmarkInbound_TinyFirstFragment measures the tiny-fragment drop path: a
|
||||
// first fragment too small to decode a transport header.
|
||||
func BenchmarkInbound_TinyFirstFragment(b *testing.B) {
|
||||
setupBenchManager(b)
|
||||
pkt := trailingFragment(b, 0x5000, 0, true, 4)
|
||||
benchFilterInbound(b, pkt)
|
||||
}
|
||||
|
||||
// BenchmarkInbound_TrailingFragmentDistinctIDs is the worst case for the
|
||||
// verdict table: an attacker varies the datagram id on every packet so no first
|
||||
// fragment ever matches. Verdict lookups always miss and nothing is recorded,
|
||||
// so the table cannot grow. Each iteration rewrites the id field in place.
|
||||
func BenchmarkInbound_TrailingFragmentDistinctIDs(b *testing.B) {
|
||||
setupBenchManager(b)
|
||||
pkt := trailingFragment(b, 0x6000, 185, false, 40)
|
||||
m := benchManager
|
||||
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(pkt)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
// IPv4 identification field is at bytes 4:6.
|
||||
binary.BigEndian.PutUint16(pkt[4:6], uint16(i))
|
||||
m.filterInbound(pkt, len(pkt))
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkInbound_FirstFragmentDistinctIDs measures sustained first-fragment
|
||||
// pressure with distinct ids: transport decode + ACL + verdict insert until the
|
||||
// table caps, exercising the map growth and capacity guard.
|
||||
func BenchmarkInbound_FirstFragmentDistinctIDs(b *testing.B) {
|
||||
setupBenchManager(b)
|
||||
pkt := firstFragmentUDP(b, 0x7000, 8080, 32)
|
||||
m := benchManager
|
||||
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(pkt)))
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
binary.BigEndian.PutUint16(pkt[4:6], uint16(i))
|
||||
m.filterInbound(pkt, len(pkt))
|
||||
}
|
||||
}
|
||||
554
client/firewall/uspfilter/fragment_test.go
Normal file
554
client/firewall/uspfilter/fragment_test.go
Normal file
@@ -0,0 +1,554 @@
|
||||
package uspfilter
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/gopacket"
|
||||
"github.com/google/gopacket/layers"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
fw "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
nbiface "github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
)
|
||||
|
||||
const (
|
||||
fragTestSrc = "100.10.0.1"
|
||||
fragTestDst = "100.10.0.100"
|
||||
fragTestSrcV6 = "fd00::1"
|
||||
fragTestDstV6 = "fd00::100"
|
||||
)
|
||||
|
||||
func newFragmentTestManager(tb testing.TB) *Manager {
|
||||
tb.Helper()
|
||||
|
||||
ifaceMock := &IFaceMock{
|
||||
SetFilterFunc: func(device.PacketFilter) error { return nil },
|
||||
AddressFunc: func() wgaddr.Address {
|
||||
return wgaddr.Address{
|
||||
IP: netip.MustParseAddr(fragTestDst),
|
||||
Network: netip.MustParsePrefix("100.10.0.0/16"),
|
||||
IPv6: netip.MustParseAddr(fragTestDstV6),
|
||||
IPv6Net: netip.MustParsePrefix("fd00::/64"),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU)
|
||||
require.NoError(tb, err)
|
||||
require.NoError(tb, m.UpdateLocalIPs())
|
||||
tb.Cleanup(func() { require.NoError(tb, m.Close(nil)) })
|
||||
return m
|
||||
}
|
||||
|
||||
// firstFragmentUDPTo builds the first fragment of a fragmented UDP datagram to
|
||||
// the given destination: it carries the full UDP header plus payloadLen bytes
|
||||
// of data, with the More Fragments flag set and offset zero.
|
||||
func firstFragmentUDPTo(tb testing.TB, dst string, id uint16, dstPort uint16, payloadLen int) []byte {
|
||||
tb.Helper()
|
||||
|
||||
ip := &layers.IPv4{
|
||||
Version: 4,
|
||||
TTL: 64,
|
||||
Id: id,
|
||||
Protocol: layers.IPProtocolUDP,
|
||||
SrcIP: net.ParseIP(fragTestSrc),
|
||||
DstIP: net.ParseIP(dst),
|
||||
Flags: layers.IPv4MoreFragments,
|
||||
}
|
||||
udp := &layers.UDP{SrcPort: 40000, DstPort: layers.UDPPort(dstPort)}
|
||||
require.NoError(tb, udp.SetNetworkLayerForChecksum(ip))
|
||||
|
||||
buf := gopacket.NewSerializeBuffer()
|
||||
opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
|
||||
require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, payloadLen))))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func firstFragmentUDP(tb testing.TB, id uint16, dstPort uint16, payloadLen int) []byte {
|
||||
tb.Helper()
|
||||
return firstFragmentUDPTo(tb, fragTestDst, id, dstPort, payloadLen)
|
||||
}
|
||||
|
||||
// firstFragmentTCP builds the first fragment of a fragmented TCP datagram: the
|
||||
// full 20-byte TCP header plus 12 bytes of data, with the More Fragments flag
|
||||
// set and offset zero.
|
||||
func firstFragmentTCP(tb testing.TB, id uint16, dstPort uint16) []byte {
|
||||
tb.Helper()
|
||||
|
||||
ip := &layers.IPv4{
|
||||
Version: 4,
|
||||
TTL: 64,
|
||||
Id: id,
|
||||
Protocol: layers.IPProtocolTCP,
|
||||
SrcIP: net.ParseIP(fragTestSrc),
|
||||
DstIP: net.ParseIP(fragTestDst),
|
||||
Flags: layers.IPv4MoreFragments,
|
||||
}
|
||||
tcp := &layers.TCP{SrcPort: 40000, DstPort: layers.TCPPort(dstPort), SYN: true, Window: 64240}
|
||||
require.NoError(tb, tcp.SetNetworkLayerForChecksum(ip))
|
||||
|
||||
buf := gopacket.NewSerializeBuffer()
|
||||
opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
|
||||
require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, tcp, gopacket.Payload(make([]byte, 12))))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// trailingFragmentTo builds a non-first fragment to the given destination: an
|
||||
// IPv4 header at the given fragment offset (in 8-byte units) carrying raw
|
||||
// payload and no L4 header.
|
||||
func trailingFragmentTo(tb testing.TB, dst string, proto layers.IPProtocol, id uint16, fragOffsetOctets uint16, moreFragments bool, payloadLen int) []byte {
|
||||
tb.Helper()
|
||||
|
||||
ip := &layers.IPv4{
|
||||
Version: 4,
|
||||
TTL: 64,
|
||||
Id: id,
|
||||
Protocol: proto,
|
||||
SrcIP: net.ParseIP(fragTestSrc),
|
||||
DstIP: net.ParseIP(dst),
|
||||
FragOffset: fragOffsetOctets,
|
||||
}
|
||||
if moreFragments {
|
||||
ip.Flags = layers.IPv4MoreFragments
|
||||
}
|
||||
|
||||
buf := gopacket.NewSerializeBuffer()
|
||||
opts := gopacket.SerializeOptions{FixLengths: true}
|
||||
require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, gopacket.Payload(make([]byte, payloadLen))))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func trailingFragment(tb testing.TB, id uint16, fragOffsetOctets uint16, moreFragments bool, payloadLen int) []byte {
|
||||
tb.Helper()
|
||||
return trailingFragmentTo(tb, fragTestDst, layers.IPProtocolUDP, id, fragOffsetOctets, moreFragments, payloadLen)
|
||||
}
|
||||
|
||||
// outboundUDPPacket builds a complete outbound UDP packet from the local
|
||||
// address, used to establish conntrack state for reply-direction tests.
|
||||
func outboundUDPPacket(tb testing.TB, srcPort, dstPort uint16) []byte {
|
||||
tb.Helper()
|
||||
|
||||
ip := &layers.IPv4{
|
||||
Version: 4,
|
||||
TTL: 64,
|
||||
Id: 1,
|
||||
Protocol: layers.IPProtocolUDP,
|
||||
SrcIP: net.ParseIP(fragTestDst),
|
||||
DstIP: net.ParseIP(fragTestSrc),
|
||||
}
|
||||
udp := &layers.UDP{SrcPort: layers.UDPPort(srcPort), DstPort: layers.UDPPort(dstPort)}
|
||||
require.NoError(tb, udp.SetNetworkLayerForChecksum(ip))
|
||||
|
||||
buf := gopacket.NewSerializeBuffer()
|
||||
opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
|
||||
require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, 16))))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// normalUDPPacket builds a complete, non-fragmented UDP packet for baseline
|
||||
// comparisons against the fragment paths.
|
||||
func normalUDPPacket(tb testing.TB, dstPort uint16, payloadLen int) []byte {
|
||||
tb.Helper()
|
||||
|
||||
ip := &layers.IPv4{
|
||||
Version: 4,
|
||||
TTL: 64,
|
||||
Id: 1,
|
||||
Protocol: layers.IPProtocolUDP,
|
||||
SrcIP: net.ParseIP(fragTestSrc),
|
||||
DstIP: net.ParseIP(fragTestDst),
|
||||
}
|
||||
udp := &layers.UDP{SrcPort: 40000, DstPort: layers.UDPPort(dstPort)}
|
||||
require.NoError(tb, udp.SetNetworkLayerForChecksum(ip))
|
||||
|
||||
buf := gopacket.NewSerializeBuffer()
|
||||
opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
|
||||
require.NoError(tb, gopacket.SerializeLayers(buf, opts, ip, udp, gopacket.Payload(make([]byte, payloadLen))))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func allowUDP(tb testing.TB, m *Manager, dstPort uint16) {
|
||||
tb.Helper()
|
||||
_, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolUDP, nil,
|
||||
&fw.Port{Values: []uint16{dstPort}}, fw.ActionAccept, "")
|
||||
require.NoError(tb, err)
|
||||
}
|
||||
|
||||
// TestFragment_TrailingWithoutFirstDropped is the core bypass repro: a trailing
|
||||
// fragment with no allowed first fragment on record must be dropped. Before the
|
||||
// fix, filterInbound returned false (allow) for any fragment.
|
||||
func TestFragment_TrailingWithoutFirstDropped(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
|
||||
frag := trailingFragment(t, 0x1234, 185, false, 40)
|
||||
require.True(t, m.filterInbound(frag, len(frag)),
|
||||
"trailing fragment without an allowed first fragment must be dropped")
|
||||
}
|
||||
|
||||
// TestFragment_AllowedFirstPassesTrailing verifies that once a first fragment
|
||||
// passes the ACL, its trailing fragments inherit the allow verdict.
|
||||
func TestFragment_AllowedFirstPassesTrailing(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
allowUDP(t, m, 8080)
|
||||
|
||||
// First fragment: UDP header (8) + 32 payload = 40 octets -> headerEnd = 5.
|
||||
first := firstFragmentUDP(t, 0x2222, 8080, 32)
|
||||
require.False(t, m.filterInbound(first, len(first)),
|
||||
"allowed first fragment should pass and be recorded")
|
||||
|
||||
trailing := trailingFragment(t, 0x2222, 5, false, 24)
|
||||
require.False(t, m.filterInbound(trailing, len(trailing)),
|
||||
"trailing fragment of an allowed datagram should pass")
|
||||
}
|
||||
|
||||
// TestFragment_DeniedFirstDropsTrailing verifies that a first fragment blocked
|
||||
// by the ACL leaves no verdict, so its trailing fragments are dropped.
|
||||
func TestFragment_DeniedFirstDropsTrailing(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
// No accept rule: local traffic defaults to deny.
|
||||
|
||||
first := firstFragmentUDP(t, 0x3333, 9999, 32)
|
||||
require.True(t, m.filterInbound(first, len(first)),
|
||||
"first fragment to a blocked port should be dropped by the ACL")
|
||||
|
||||
trailing := trailingFragment(t, 0x3333, 5, false, 24)
|
||||
require.True(t, m.filterInbound(trailing, len(trailing)),
|
||||
"trailing fragment of a denied datagram must be dropped")
|
||||
}
|
||||
|
||||
// TestFragment_OverlappingHeaderDropped covers the RFC 1858 §4 / RFC 3128
|
||||
// overlapping-fragment rewrite: a trailing fragment starting inside the range
|
||||
// the ACL already inspected is dropped and poisons the datagram. TCP is used so
|
||||
// the overlap lands on real header bytes (the flags at byte 13).
|
||||
func TestFragment_OverlappingHeaderDropped(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
_, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolTCP, nil,
|
||||
&fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// First fragment: TCP header (20) + 12 data = 32 bytes -> headerEnd = 4 octets.
|
||||
first := firstFragmentTCP(t, 0x4444, 8080)
|
||||
require.False(t, m.filterInbound(first, len(first)))
|
||||
|
||||
// Overlapping fragment at offset 1 (byte 8) falls inside the inspected TCP
|
||||
// header, so it could rewrite the flags or port on reassembly.
|
||||
overlap := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x4444, 1, true, 32)
|
||||
require.True(t, m.filterInbound(overlap, len(overlap)),
|
||||
"fragment overlapping the inspected header must be dropped")
|
||||
|
||||
// The datagram is now poisoned: a later, non-overlapping fragment is also
|
||||
// dropped because the verdict was removed.
|
||||
later := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x4444, 4, false, 24)
|
||||
require.True(t, m.filterInbound(later, len(later)),
|
||||
"fragments after an overlap must be dropped (datagram poisoned)")
|
||||
}
|
||||
|
||||
// TestFragment_OffsetZeroOverlapPoisons covers the RFC 3128 offset-zero rewrite:
|
||||
// an allowed first fragment followed by a denied offset-zero fragment for the
|
||||
// same datagram must not leave the earlier allow verdict in place.
|
||||
func TestFragment_OffsetZeroOverlapPoisons(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
allowUDP(t, m, 8080)
|
||||
|
||||
allowed := firstFragmentUDP(t, 0x5A5A, 8080, 32)
|
||||
require.False(t, m.filterInbound(allowed, len(allowed)),
|
||||
"allowed first fragment should pass and be recorded")
|
||||
|
||||
// A second offset-zero fragment to a denied port supersedes the datagram's
|
||||
// verdict; it is dropped and must not leave the allow in place.
|
||||
denied := firstFragmentUDP(t, 0x5A5A, 9999, 32)
|
||||
require.True(t, m.filterInbound(denied, len(denied)),
|
||||
"denied offset-zero fragment must be dropped")
|
||||
|
||||
trailing := trailingFragment(t, 0x5A5A, 5, false, 24)
|
||||
require.True(t, m.filterInbound(trailing, len(trailing)),
|
||||
"trailing fragment must be denied after the datagram was poisoned")
|
||||
}
|
||||
|
||||
// TestFragment_TinyFirstDropped covers the tiny-fragment attack: a first
|
||||
// fragment too small to contain the full transport header can't be
|
||||
// ACL-evaluated and must be dropped.
|
||||
func TestFragment_TinyFirstDropped(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
allowUDP(t, m, 8080)
|
||||
|
||||
// IPv4 header + 4 raw bytes, MF set, offset 0: too small for the 8-byte UDP
|
||||
// header, so it decodes to L3 only.
|
||||
tiny := trailingFragment(t, 0x5555, 0, true, 4)
|
||||
require.True(t, m.filterInbound(tiny, len(tiny)),
|
||||
"tiny first fragment without a full L4 header must be dropped")
|
||||
}
|
||||
|
||||
// TestFragment_TCPFirstFragment verifies the TCP arm of the transport decode: a
|
||||
// first fragment carrying the full 20-byte TCP header is ACL-evaluated and its
|
||||
// trailing fragments inherit the verdict.
|
||||
func TestFragment_TCPFirstFragment(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
_, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolTCP, nil,
|
||||
&fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// TCP header (20) + 12 data = 32 bytes -> headerEnd = 4 octets.
|
||||
first := firstFragmentTCP(t, 0x6666, 8080)
|
||||
require.False(t, m.filterInbound(first, len(first)),
|
||||
"allowed TCP first fragment should pass and be recorded")
|
||||
|
||||
trailing := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x6666, 4, false, 24)
|
||||
require.False(t, m.filterInbound(trailing, len(trailing)),
|
||||
"trailing fragment of an allowed TCP datagram should pass")
|
||||
}
|
||||
|
||||
// TestFragment_TCPTinyFirstDropped verifies the TCP minimum header length: 12
|
||||
// bytes would satisfy a UDP header but falls short of the 20-byte TCP header.
|
||||
func TestFragment_TCPTinyFirstDropped(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
_, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), fw.ProtocolTCP, nil,
|
||||
&fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
tiny := trailingFragmentTo(t, fragTestDst, layers.IPProtocolTCP, 0x7777, 0, true, 12)
|
||||
require.True(t, m.filterInbound(tiny, len(tiny)),
|
||||
"first fragment shorter than the TCP header must be dropped")
|
||||
}
|
||||
|
||||
// TestFragment_ConntrackAllowsFirstFragment verifies the conntrack branch: reply
|
||||
// fragments of an outbound-established UDP flow pass without any inbound rule.
|
||||
func TestFragment_ConntrackAllowsFirstFragment(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
|
||||
out := outboundUDPPacket(t, 12345, 40000)
|
||||
require.False(t, m.filterOutbound(out, len(out)))
|
||||
|
||||
first := firstFragmentUDP(t, 0x8888, 12345, 32)
|
||||
require.False(t, m.filterInbound(first, len(first)),
|
||||
"reply first fragment should pass via conntrack")
|
||||
|
||||
trailing := trailingFragment(t, 0x8888, 5, false, 24)
|
||||
require.False(t, m.filterInbound(trailing, len(trailing)),
|
||||
"trailing fragment of a tracked flow should pass")
|
||||
}
|
||||
|
||||
// TestFragment_RoutingDisabledDropsFragment verifies routed first fragments are
|
||||
// dropped when routing is disabled.
|
||||
func TestFragment_RoutingDisabledDropsFragment(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
m.routingEnabled.Store(false)
|
||||
|
||||
first := firstFragmentUDPTo(t, "198.51.100.10", 0x9999, 8080, 32)
|
||||
require.True(t, m.filterInbound(first, len(first)),
|
||||
"routed first fragment must be dropped when routing is disabled")
|
||||
}
|
||||
|
||||
// TestFragment_RouteACL verifies the route-ACL branch: fragments to a non-local
|
||||
// destination follow the route rules, allowed datagrams pass their trailing
|
||||
// fragments and denied ones don't.
|
||||
func TestFragment_RouteACL(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
m.routingEnabled.Store(true)
|
||||
m.nativeRouter.Store(false)
|
||||
|
||||
_, err := m.AddRouteFiltering(
|
||||
[]byte("rt-1"),
|
||||
[]netip.Prefix{netip.MustParsePrefix("100.10.0.0/16")},
|
||||
fw.Network{Prefix: netip.MustParsePrefix("198.51.100.0/24")},
|
||||
fw.ProtocolUDP,
|
||||
nil,
|
||||
&fw.Port{Values: []uint16{8080}},
|
||||
fw.ActionAccept,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
first := firstFragmentUDPTo(t, "198.51.100.10", 0xAAAA, 8080, 32)
|
||||
require.False(t, m.filterInbound(first, len(first)),
|
||||
"route-ACL-allowed first fragment should pass")
|
||||
trailing := trailingFragmentTo(t, "198.51.100.10", layers.IPProtocolUDP, 0xAAAA, 5, false, 24)
|
||||
require.False(t, m.filterInbound(trailing, len(trailing)),
|
||||
"trailing fragment of an allowed routed datagram should pass")
|
||||
|
||||
denied := firstFragmentUDPTo(t, "198.51.100.10", 0xBBBB, 9999, 32)
|
||||
require.True(t, m.filterInbound(denied, len(denied)),
|
||||
"route-ACL-denied first fragment must be dropped")
|
||||
deniedTrailing := trailingFragmentTo(t, "198.51.100.10", layers.IPProtocolUDP, 0xBBBB, 5, false, 24)
|
||||
require.True(t, m.filterInbound(deniedTrailing, len(deniedTrailing)),
|
||||
"trailing fragment of a denied routed datagram must be dropped")
|
||||
}
|
||||
|
||||
// TestFragment_ExpiredVerdictDropsTrailing verifies a verdict older than the
|
||||
// tracker timeout no longer admits trailing fragments.
|
||||
func TestFragment_ExpiredVerdictDropsTrailing(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
allowUDP(t, m, 8080)
|
||||
|
||||
first := firstFragmentUDP(t, 0xCCCC, 8080, 32)
|
||||
require.False(t, m.filterInbound(first, len(first)))
|
||||
|
||||
m.fragments.mutex.Lock()
|
||||
for key, entry := range m.fragments.entries {
|
||||
entry.recordedAt = time.Now().Add(-defaultFragmentTimeout - time.Second)
|
||||
m.fragments.entries[key] = entry
|
||||
}
|
||||
m.fragments.mutex.Unlock()
|
||||
|
||||
trailing := trailingFragment(t, 0xCCCC, 5, false, 24)
|
||||
require.True(t, m.filterInbound(trailing, len(trailing)),
|
||||
"trailing fragment after verdict expiry must be dropped")
|
||||
}
|
||||
|
||||
// TestFragment_CapacityFailsClosed verifies the table cap: at capacity, new
|
||||
// datagram verdicts are not recorded (their trailing fragments are dropped)
|
||||
// while already-recorded datagrams keep working.
|
||||
func TestFragment_CapacityFailsClosed(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
allowUDP(t, m, 8080)
|
||||
|
||||
m.fragments.mutex.Lock()
|
||||
m.fragments.maxEntries = 1
|
||||
m.fragments.mutex.Unlock()
|
||||
|
||||
first1 := firstFragmentUDP(t, 0x0101, 8080, 32)
|
||||
require.False(t, m.filterInbound(first1, len(first1)))
|
||||
|
||||
first2 := firstFragmentUDP(t, 0x0202, 8080, 32)
|
||||
require.False(t, m.filterInbound(first2, len(first2)),
|
||||
"first fragment itself still passes at capacity")
|
||||
|
||||
trailing2 := trailingFragment(t, 0x0202, 5, false, 24)
|
||||
require.True(t, m.filterInbound(trailing2, len(trailing2)),
|
||||
"trailing fragment of an unrecorded datagram must be dropped at capacity")
|
||||
|
||||
trailing1 := trailingFragment(t, 0x0101, 5, false, 24)
|
||||
require.False(t, m.filterInbound(trailing1, len(trailing1)),
|
||||
"already-recorded datagram should keep passing at capacity")
|
||||
}
|
||||
|
||||
// v6FragmentHeader builds the 8-byte IPv6 fragment extension header for the
|
||||
// given inner protocol, offset (8-byte units), More Fragments bit and id.
|
||||
func v6FragmentHeader(proto layers.IPProtocol, offsetOctets uint16, moreFragments bool, id uint32) []byte {
|
||||
offsetFlags := offsetOctets << 3
|
||||
if moreFragments {
|
||||
offsetFlags |= 1
|
||||
}
|
||||
hdr := make([]byte, 8)
|
||||
hdr[0] = uint8(proto)
|
||||
binary.BigEndian.PutUint16(hdr[2:4], offsetFlags)
|
||||
binary.BigEndian.PutUint32(hdr[4:8], id)
|
||||
return hdr
|
||||
}
|
||||
|
||||
func v6UDPHeader(dstPort uint16, dataLen int) []byte {
|
||||
hdr := make([]byte, 8)
|
||||
binary.BigEndian.PutUint16(hdr[0:2], 40000)
|
||||
binary.BigEndian.PutUint16(hdr[2:4], dstPort)
|
||||
binary.BigEndian.PutUint16(hdr[4:6], uint16(8+dataLen))
|
||||
return hdr
|
||||
}
|
||||
|
||||
// firstFragmentUDPv6 builds the first fragment of a fragmented IPv6 UDP
|
||||
// datagram: fragment header (offset 0, More Fragments set) + full UDP header +
|
||||
// data.
|
||||
func firstFragmentUDPv6(tb testing.TB, id uint32, dstPort uint16, dataLen int) []byte {
|
||||
tb.Helper()
|
||||
return fragmentUDPv6(tb, id, dstPort, dataLen, true)
|
||||
}
|
||||
|
||||
// fragmentUDPv6 builds an offset-zero IPv6 UDP fragment. With moreFragments
|
||||
// false it is an atomic fragment (a complete datagram, RFC 6946).
|
||||
func fragmentUDPv6(tb testing.TB, id uint32, dstPort uint16, dataLen int, moreFragments bool) []byte {
|
||||
tb.Helper()
|
||||
|
||||
ip := &layers.IPv6{
|
||||
Version: 6,
|
||||
NextHeader: layers.IPProtocolIPv6Fragment,
|
||||
HopLimit: 64,
|
||||
SrcIP: net.ParseIP(fragTestSrcV6),
|
||||
DstIP: net.ParseIP(fragTestDstV6),
|
||||
}
|
||||
payload := append(v6FragmentHeader(layers.IPProtocolUDP, 0, moreFragments, id), v6UDPHeader(dstPort, dataLen)...)
|
||||
payload = append(payload, make([]byte, dataLen)...)
|
||||
|
||||
buf := gopacket.NewSerializeBuffer()
|
||||
require.NoError(tb, gopacket.SerializeLayers(buf, gopacket.SerializeOptions{FixLengths: true}, ip, gopacket.Payload(payload)))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// trailingFragmentV6 builds a non-first IPv6 fragment: fragment header at the
|
||||
// given offset carrying raw data and no transport header.
|
||||
func trailingFragmentV6(tb testing.TB, id uint32, offsetOctets uint16, moreFragments bool, dataLen int) []byte {
|
||||
tb.Helper()
|
||||
|
||||
ip := &layers.IPv6{
|
||||
Version: 6,
|
||||
NextHeader: layers.IPProtocolIPv6Fragment,
|
||||
HopLimit: 64,
|
||||
SrcIP: net.ParseIP(fragTestSrcV6),
|
||||
DstIP: net.ParseIP(fragTestDstV6),
|
||||
}
|
||||
payload := append(v6FragmentHeader(layers.IPProtocolUDP, offsetOctets, moreFragments, id), make([]byte, dataLen)...)
|
||||
|
||||
buf := gopacket.NewSerializeBuffer()
|
||||
require.NoError(tb, gopacket.SerializeLayers(buf, gopacket.SerializeOptions{FixLengths: true}, ip, gopacket.Payload(payload)))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// TestFragmentV6_TrailingWithoutFirstDropped verifies the IPv6 bypass is closed:
|
||||
// a trailing fragment with no allowed first fragment is dropped.
|
||||
func TestFragmentV6_TrailingWithoutFirstDropped(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
|
||||
frag := trailingFragmentV6(t, 0xAABBCCDD, 100, false, 40)
|
||||
require.True(t, m.filterInbound(frag, len(frag)),
|
||||
"IPv6 trailing fragment without an allowed first fragment must be dropped")
|
||||
}
|
||||
|
||||
// TestFragmentV6_AllowedFirstPassesTrailing verifies IPv6 fragments are
|
||||
// evaluated like IPv4: an allowed first fragment lets its trailing fragments
|
||||
// through.
|
||||
func TestFragmentV6_AllowedFirstPassesTrailing(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
_, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrcV6), fw.ProtocolUDP, nil,
|
||||
&fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// First fragment: UDP header (8) + 32 data = 40 octets -> headerEnd = 5.
|
||||
first := firstFragmentUDPv6(t, 0xAABBCCDD, 8080, 32)
|
||||
require.False(t, m.filterInbound(first, len(first)),
|
||||
"allowed IPv6 first fragment should pass and be recorded")
|
||||
|
||||
trailing := trailingFragmentV6(t, 0xAABBCCDD, 5, false, 24)
|
||||
require.False(t, m.filterInbound(trailing, len(trailing)),
|
||||
"trailing fragment of an allowed IPv6 datagram should pass")
|
||||
}
|
||||
|
||||
// TestFragmentV6_AtomicNotCached verifies an IPv6 atomic fragment (fragment
|
||||
// header with offset 0 and no More Fragments, a complete datagram per RFC 6946)
|
||||
// is evaluated but not recorded, so a flood of allowed atomic fragments can't
|
||||
// exhaust the verdict table.
|
||||
func TestFragmentV6_AtomicNotCached(t *testing.T) {
|
||||
m := newFragmentTestManager(t)
|
||||
_, err := m.AddPeerFiltering(nil, net.ParseIP(fragTestSrcV6), fw.ProtocolUDP, nil,
|
||||
&fw.Port{Values: []uint16{8080}}, fw.ActionAccept, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
atomic := fragmentUDPv6(t, 0xA70301C, 8080, 16, false)
|
||||
require.False(t, m.filterInbound(atomic, len(atomic)),
|
||||
"allowed IPv6 atomic fragment should pass")
|
||||
|
||||
m.fragments.mutex.Lock()
|
||||
n := len(m.fragments.entries)
|
||||
m.fragments.mutex.Unlock()
|
||||
require.Zero(t, n, "atomic fragment must not create a verdict entry")
|
||||
|
||||
// A genuine fragmented datagram (More Fragments set) is still recorded.
|
||||
first := fragmentUDPv6(t, 0xBEEF, 8080, 32, true)
|
||||
require.False(t, m.filterInbound(first, len(first)))
|
||||
m.fragments.mutex.Lock()
|
||||
n = len(m.fragments.entries)
|
||||
m.fragments.mutex.Unlock()
|
||||
require.Equal(t, 1, n, "genuine first fragment must record a verdict")
|
||||
}
|
||||
@@ -3,14 +3,31 @@
|
||||
package netstack
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const EnvUseNetstackMode = "NB_USE_NETSTACK_MODE"
|
||||
const (
|
||||
EnvUseNetstackMode = "NB_USE_NETSTACK_MODE"
|
||||
|
||||
// EnvSocks5ListenerPort overrides the port the SOCKS5 proxy listens on.
|
||||
EnvSocks5ListenerPort = "NB_SOCKS5_LISTENER_PORT"
|
||||
|
||||
// EnvSocks5ListenerAddress overrides the host/IP the SOCKS5 proxy binds to.
|
||||
// The proxy is a bridge for local host applications into the userspace
|
||||
// WireGuard netstack, so it binds to loopback by default. Override this only
|
||||
// when the proxy must be reachable from other hosts (e.g. a container
|
||||
// gateway); doing so exposes an unauthenticated SOCKS5 proxy on that
|
||||
// address.
|
||||
EnvSocks5ListenerAddress = "NB_SOCKS5_LISTENER_ADDRESS"
|
||||
|
||||
// defaultSocks5Host is the loopback address the SOCKS5 proxy binds to unless
|
||||
// overridden via EnvSocks5ListenerAddress.
|
||||
defaultSocks5Host = "127.0.0.1"
|
||||
)
|
||||
|
||||
// IsEnabled todo: move these function to cmd layer
|
||||
func IsEnabled() bool {
|
||||
@@ -18,24 +35,40 @@ func IsEnabled() bool {
|
||||
}
|
||||
|
||||
func ListenAddr() string {
|
||||
sPort := os.Getenv("NB_SOCKS5_LISTENER_PORT")
|
||||
return net.JoinHostPort(listenHost(), strconv.Itoa(listenPort()))
|
||||
}
|
||||
|
||||
// listenHost returns the host/IP the SOCKS5 proxy binds to. It defaults to
|
||||
// loopback and only honors EnvSocks5ListenerAddress when it holds a valid IP.
|
||||
func listenHost() string {
|
||||
addr := os.Getenv(EnvSocks5ListenerAddress)
|
||||
if addr == "" {
|
||||
return defaultSocks5Host
|
||||
}
|
||||
if net.ParseIP(addr) == nil {
|
||||
log.Warnf("invalid socks5 listener address %q, falling back to default: %s", addr, defaultSocks5Host)
|
||||
return defaultSocks5Host
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
// listenPort returns the port the SOCKS5 proxy binds to, defaulting to
|
||||
// DefaultSocks5Port when EnvSocks5ListenerPort is unset or invalid.
|
||||
func listenPort() int {
|
||||
sPort := os.Getenv(EnvSocks5ListenerPort)
|
||||
if sPort == "" {
|
||||
return listenAddr(DefaultSocks5Port)
|
||||
return DefaultSocks5Port
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(sPort)
|
||||
if err != nil {
|
||||
log.Warnf("invalid socks5 listener port, unable to convert it to int, falling back to default: %d", DefaultSocks5Port)
|
||||
return listenAddr(DefaultSocks5Port)
|
||||
return DefaultSocks5Port
|
||||
}
|
||||
if port < 1 || port > 65535 {
|
||||
log.Warnf("invalid socks5 listener port, it should be in the range 1-65535, falling back to default: %d", DefaultSocks5Port)
|
||||
return listenAddr(DefaultSocks5Port)
|
||||
return DefaultSocks5Port
|
||||
}
|
||||
|
||||
return listenAddr(port)
|
||||
}
|
||||
|
||||
func listenAddr(port int) string {
|
||||
return fmt.Sprintf("0.0.0.0:%d", port)
|
||||
return port
|
||||
}
|
||||
|
||||
63
client/iface/netstack/env_test.go
Normal file
63
client/iface/netstack/env_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
//go:build !js
|
||||
|
||||
package netstack
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListenAddr_DefaultsToLoopback(t *testing.T) {
|
||||
// No env overrides: must bind loopback, never all interfaces.
|
||||
got := ListenAddr()
|
||||
want := net.JoinHostPort("127.0.0.1", strconv.Itoa(DefaultSocks5Port))
|
||||
if got != want {
|
||||
t.Fatalf("ListenAddr() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenAddr_AddressOverride(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
want string
|
||||
}{
|
||||
{name: "valid override honored", env: "0.0.0.0", want: "0.0.0.0"},
|
||||
{name: "valid specific ip honored", env: "10.0.0.5", want: "10.0.0.5"},
|
||||
{name: "ipv6 loopback bracketed", env: "::1", want: "::1"},
|
||||
{name: "invalid falls back to loopback", env: "not-an-ip", want: "127.0.0.1"},
|
||||
{name: "empty falls back to loopback", env: "", want: "127.0.0.1"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv(EnvSocks5ListenerAddress, tc.env)
|
||||
want := net.JoinHostPort(tc.want, strconv.Itoa(DefaultSocks5Port))
|
||||
if got := ListenAddr(); got != want {
|
||||
t.Fatalf("ListenAddr() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenAddr_PortOverride(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env string
|
||||
want int
|
||||
}{
|
||||
{name: "valid port honored", env: "1081", want: 1081},
|
||||
{name: "non-numeric falls back", env: "abc", want: DefaultSocks5Port},
|
||||
{name: "out of range falls back", env: "70000", want: DefaultSocks5Port},
|
||||
{name: "zero falls back", env: "0", want: DefaultSocks5Port},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv(EnvSocks5ListenerPort, tc.env)
|
||||
want := net.JoinHostPort("127.0.0.1", strconv.Itoa(tc.want))
|
||||
if got := ListenAddr(); got != want {
|
||||
t.Fatalf("ListenAddr() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -299,7 +299,7 @@ func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowIn
|
||||
UseIDToken: d.providerConfig.UseIDToken,
|
||||
}
|
||||
|
||||
err = isValidAccessToken(tokenInfo.GetTokenToUse(), d.providerConfig.Audience)
|
||||
err = validateTokenAudience(tokenInfo.GetTokenToUse(), d.providerConfig.Audience)
|
||||
if err != nil {
|
||||
return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err)
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo,
|
||||
audience = p.providerConfig.ClientID
|
||||
}
|
||||
|
||||
if err := isValidAccessToken(tokenInfo.GetTokenToUse(), audience); err != nil {
|
||||
if err := validateTokenAudience(tokenInfo.GetTokenToUse(), audience); err != nil {
|
||||
return TokenInfo{}, fmt.Errorf("authentication failed: invalid access token - %w", err)
|
||||
}
|
||||
|
||||
@@ -320,6 +320,11 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo,
|
||||
return tokenInfo, nil
|
||||
}
|
||||
|
||||
// parseEmailFromIDToken extracts the email (or name) claim from an ID token
|
||||
// without verifying its signature. The value is best-effort and used only as a
|
||||
// UX convenience (login hint prefill and display); it never drives an
|
||||
// authorization decision. The authoritative identity is established server-side
|
||||
// from the signature-verified token.
|
||||
func parseEmailFromIDToken(token string) (string, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) < 2 {
|
||||
|
||||
@@ -24,11 +24,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// Skew tolerates a small clock difference between the management
|
||||
// server and this peer before treating a deadline as "in the past".
|
||||
// Slightly above typical NTP drift; tight enough that the UI doesn't
|
||||
// paint a stale expiry as if it were valid.
|
||||
Skew = 30 * time.Second
|
||||
maxPastHorizon = 30 * 24 * time.Hour
|
||||
|
||||
// maxDeadlineHorizon caps how far in the future an accepted deadline
|
||||
// can sit. A timestamp beyond this is almost certainly a protocol
|
||||
@@ -57,7 +53,7 @@ var (
|
||||
ErrDeadlineTooFarFuture = errors.New("session deadline too far in the future")
|
||||
|
||||
// ErrDeadlineInPast is returned by Update when the supplied deadline
|
||||
// is more than Skew in the past.
|
||||
// is more than maxPastHorizon in the past.
|
||||
ErrDeadlineInPast = errors.New("session deadline in the past")
|
||||
)
|
||||
|
||||
@@ -66,15 +62,14 @@ var (
|
||||
// for deadline change/clear, PublishEvent for the two warnings); tests pass
|
||||
// a fake recorder so the same surface is observable without an engine.
|
||||
//
|
||||
// The watcher is the single owner of the deadline propagated to the
|
||||
// recorder: every set, clear, sanity-check rejection and Close routes the
|
||||
// value through SetSessionExpiresAt, so the SubscribeStatus snapshot the UI
|
||||
// reads can never drift from the watcher's timer state. (SetSessionExpiresAt
|
||||
// fans out its own state-change notification, so no separate notify is
|
||||
// needed.) The recorder is server-scoped and outlives this engine-scoped
|
||||
// watcher — without the Close-time clear a teardown (Down, or the Down+Up of
|
||||
// a profile switch) would leave the next session showing the previous one's
|
||||
// stale "expires in" value.
|
||||
// While the watcher runs, it owns the deadline propagated to the recorder:
|
||||
// every set, clear and sanity-check rejection routes the value through
|
||||
// SetSessionExpiresAt, so the SubscribeStatus snapshot the UI reads can
|
||||
// never drift from the watcher's timer state. (SetSessionExpiresAt fans
|
||||
// out its own state-change notification, so no separate notify is needed.)
|
||||
// The recorder is server-scoped and outlives this engine-scoped watcher;
|
||||
// Close deliberately leaves the recorder value in place so transient engine
|
||||
// restarts don't blank it — the client run loop clears it on real teardown.
|
||||
//
|
||||
// PublishEvent's signature mirrors peer.Status.PublishEvent: the watcher
|
||||
// composes the metadata internally so the wire format (MetaSession*) is
|
||||
@@ -135,10 +130,13 @@ func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher {
|
||||
// was disabled).
|
||||
//
|
||||
// Same-value updates are no-ops. A different non-zero value cancels any
|
||||
// pending timer, resets the "already fired" guard, and arms a new one.
|
||||
// pending timer, resets the "already fired" guards, and — when the
|
||||
// deadline lies in the future — arms fresh warning timers. A deadline
|
||||
// already in the past (within maxPastHorizon) is recorded as-is with no
|
||||
// timers: the session has expired and consumers render it that way.
|
||||
//
|
||||
// Returns one of the sentinel Err* values when the deadline fails the
|
||||
// sanity checks (pre-epoch, far future, or in the past beyond Skew).
|
||||
// sanity checks (pre-epoch, far future, or past beyond maxPastHorizon).
|
||||
// In every error case the watcher first clears its state so it stays
|
||||
// consistent with what the caller will push into its other sinks (e.g.
|
||||
// applySessionDeadline forces a zero deadline into the status recorder
|
||||
@@ -163,7 +161,7 @@ func (w *Watcher) Update(deadline time.Time) error {
|
||||
case deadline.After(now.Add(maxDeadlineHorizon)):
|
||||
w.clearLocked()
|
||||
return fmt.Errorf("%w: %v", ErrDeadlineTooFarFuture, deadline)
|
||||
case deadline.Before(now.Add(-Skew)):
|
||||
case deadline.Before(now.Add(-maxPastHorizon)):
|
||||
w.clearLocked()
|
||||
return fmt.Errorf("%w: %v (now=%v)", ErrDeadlineInPast, deadline, now)
|
||||
}
|
||||
@@ -183,7 +181,9 @@ func (w *Watcher) Update(deadline time.Time) error {
|
||||
w.finalFiredAt = time.Time{}
|
||||
w.dismissedAt = time.Time{}
|
||||
|
||||
w.armTimerLocked(deadline)
|
||||
if deadline.After(now) {
|
||||
w.armTimerLocked(deadline)
|
||||
}
|
||||
recorder := w.recorder
|
||||
w.mu.Unlock()
|
||||
if recorder != nil {
|
||||
@@ -227,30 +227,25 @@ func (w *Watcher) Dismiss() {
|
||||
log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// Close stops any pending timer and drops the deadline on the status
|
||||
// recorder. Update calls after Close are ignored. Clearing the recorder
|
||||
// here is what keeps a teardown (Down, or the Down+Up of a profile switch)
|
||||
// from leaving the next session showing this one's stale "expires in"
|
||||
// value — the recorder is server-scoped and outlives this engine-scoped
|
||||
// watcher, so nothing else drops the anchor on teardown.
|
||||
// Close stops any pending timer. Update calls after Close are ignored.
|
||||
// The recorder keeps its deadline: the watcher is engine-scoped and closes
|
||||
// on every engine restart (network change, sleep/wake, stream errors)
|
||||
// while the SSO deadline stays valid across those, so clearing here would
|
||||
// blank the UI's "expires in" row on every transient reconnect. The
|
||||
// client run loop clears the server-scoped recorder when it exits for
|
||||
// real (Down, profile switch, permanent login failure).
|
||||
func (w *Watcher) Close() {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.closed {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.closed = true
|
||||
w.stopTimerLocked()
|
||||
hadDeadline := !w.current.IsZero()
|
||||
w.current = time.Time{}
|
||||
w.firedAt = time.Time{}
|
||||
w.finalFiredAt = time.Time{}
|
||||
w.dismissedAt = time.Time{}
|
||||
recorder := w.recorder
|
||||
w.mu.Unlock()
|
||||
if recorder != nil && hadDeadline {
|
||||
recorder.SetSessionExpiresAt(time.Time{})
|
||||
}
|
||||
}
|
||||
|
||||
// clearLocked drops the tracked deadline and notifies the recorder so
|
||||
|
||||
@@ -224,11 +224,13 @@ func TestNewDeadlineCancelsPriorTimer(t *testing.T) {
|
||||
|
||||
func TestRefreshAfterFireArmsNewWarning(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
lead := 30 * time.Millisecond
|
||||
lead := 150 * time.Millisecond
|
||||
w := newWatcher(lead, r)
|
||||
defer w.Close()
|
||||
|
||||
first := time.Now().Add(50 * time.Millisecond)
|
||||
// Warning fires ~20ms in; the deadline itself stays 150ms away so the
|
||||
// replacement below lands well before it.
|
||||
first := time.Now().Add(170 * time.Millisecond)
|
||||
_ = w.Update(first)
|
||||
|
||||
// Wait for stateChange + warning of the first cycle.
|
||||
@@ -306,7 +308,29 @@ func TestUpdateRejectsTooFarFuture(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateInPastClearsDeadline(t *testing.T) {
|
||||
func TestUpdateRecentPastRecordedAsExpired(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
d := time.Now().Add(-1 * time.Hour)
|
||||
if err := w.Update(d); err != nil {
|
||||
t.Fatalf("recent-past Update should succeed, got %v", err)
|
||||
}
|
||||
if !w.Deadline().Equal(d) {
|
||||
t.Fatalf("expected deadline to be recorded, got %v want %v", w.Deadline(), d)
|
||||
}
|
||||
if got := r.deadline(); !got.Equal(d) {
|
||||
t.Fatalf("recorder deadline = %v, want %v", got, d)
|
||||
}
|
||||
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 {
|
||||
t.Fatalf("no warning events may fire for an already-past deadline, got %+v", r.snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAncientPastRejected(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
@@ -318,12 +342,12 @@ func TestUpdateInPastClearsDeadline(t *testing.T) {
|
||||
// Drain the stateChange from the seed.
|
||||
waitForEvents(t, r, 1)
|
||||
|
||||
err := w.Update(time.Now().Add(-1 * time.Hour))
|
||||
err := w.Update(time.Now().Add(-31 * 24 * time.Hour))
|
||||
if !errors.Is(err, ErrDeadlineInPast) {
|
||||
t.Fatalf("want ErrDeadlineInPast, got %v", err)
|
||||
}
|
||||
if !w.Deadline().IsZero() {
|
||||
t.Fatalf("in-past update must clear the deadline, got %v", w.Deadline())
|
||||
t.Fatalf("rejected ancient-past update must clear the deadline, got %v", w.Deadline())
|
||||
}
|
||||
events := waitForEvents(t, r, 2)
|
||||
if events[1].kind != stateChange {
|
||||
@@ -331,39 +355,25 @@ func TestUpdateInPastClearsDeadline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWithinSkewAccepted(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
defer w.Close()
|
||||
|
||||
// 5 seconds in the past is within the 30s Skew tolerance — accept it.
|
||||
d := time.Now().Add(-5 * time.Second)
|
||||
if err := w.Update(d); err != nil {
|
||||
t.Fatalf("within-skew Update should succeed, got %v", err)
|
||||
}
|
||||
if !w.Deadline().Equal(d) {
|
||||
t.Fatalf("expected deadline to be applied, got %v want %v", w.Deadline(), d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseSilencesUpdates(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(50*time.Millisecond, r)
|
||||
w.Close()
|
||||
|
||||
_ = w.Update(time.Now().Add(time.Hour))
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if err := w.Update(time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatalf("Update after Close: want nil, got %v", err)
|
||||
}
|
||||
if got := r.snapshot(); len(got) != 0 {
|
||||
t.Fatalf("expected no events after Close, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseClearsRecorderDeadline pins the profile-switch fix: a watcher
|
||||
// holding a live deadline must zero the recorder on Close so the next
|
||||
// engine's watcher (and the UI reading the shared server-scoped recorder)
|
||||
// doesn't start out showing the previous session's stale "expires in".
|
||||
func TestCloseClearsRecorderDeadline(t *testing.T) {
|
||||
// TestCloseKeepsRecorderDeadline pins the reconnect-flap fix: the watcher
|
||||
// closes on every engine restart (network change, sleep/wake) while the
|
||||
// SSO deadline stays valid across those, so Close must leave the
|
||||
// server-scoped recorder's value in place. The client run loop clears the
|
||||
// recorder when it exits for real.
|
||||
func TestCloseKeepsRecorderDeadline(t *testing.T) {
|
||||
r := &fakeRecorder{}
|
||||
w := newWatcher(time.Hour, r)
|
||||
|
||||
@@ -377,8 +387,8 @@ func TestCloseClearsRecorderDeadline(t *testing.T) {
|
||||
|
||||
w.Close()
|
||||
|
||||
if got := r.deadline(); !got.IsZero() {
|
||||
t.Fatalf("recorder deadline after Close = %v, want zero", got)
|
||||
if got := r.deadline(); !got.Equal(d) {
|
||||
t.Fatalf("recorder deadline after Close = %v, want %v", got, d)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,14 +20,26 @@ func randomBytesInHex(count int) (string, error) {
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// isValidAccessToken is a simple validation of the access token
|
||||
func isValidAccessToken(token string, audience string) error {
|
||||
// validateTokenAudience checks that the token is a well-formed JWT whose
|
||||
// audience claim matches the expected audience.
|
||||
//
|
||||
// It does NOT verify the token's cryptographic signature and therefore must not
|
||||
// be treated as an authenticity check. The token is obtained by the client
|
||||
// directly from the IdP token endpoint over TLS, and its signature is verified
|
||||
// server-side by the management server against the IdP's JWKS
|
||||
// (see shared/auth/jwt/validator.go). This function is only a client-side
|
||||
// sanity check that the returned token targets the expected audience.
|
||||
func validateTokenAudience(token string, audience string) error {
|
||||
if token == "" {
|
||||
return fmt.Errorf("token received is empty")
|
||||
}
|
||||
|
||||
encodedClaims := strings.Split(token, ".")[1]
|
||||
claimsString, err := base64.RawURLEncoding.DecodeString(encodedClaims)
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return fmt.Errorf("token is not a well-formed JWT")
|
||||
}
|
||||
|
||||
claimsString, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
108
client/internal/auth/util_test.go
Normal file
108
client/internal/auth/util_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// makeJWT builds an unsigned JWT-shaped string (header.payload.signature) with
|
||||
// the given claims payload. The signature part is arbitrary because
|
||||
// validateTokenAudience intentionally does not verify it.
|
||||
func makeJWT(t *testing.T, claims map[string]interface{}) string {
|
||||
t.Helper()
|
||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`))
|
||||
payloadBytes, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal claims: %v", err)
|
||||
}
|
||||
payload := base64.RawURLEncoding.EncodeToString(payloadBytes)
|
||||
return header + "." + payload + ".unverified-signature"
|
||||
}
|
||||
|
||||
func TestValidateTokenAudience(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
audience string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty token",
|
||||
token: "",
|
||||
audience: "netbird",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "not a JWT - no dots",
|
||||
token: "notajwt",
|
||||
audience: "netbird",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "not a JWT - two parts only",
|
||||
token: "header.payload",
|
||||
audience: "netbird",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "matching string audience",
|
||||
token: makeJWT(t, map[string]interface{}{"aud": "netbird"}),
|
||||
audience: "netbird",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "mismatching string audience",
|
||||
token: makeJWT(t, map[string]interface{}{"aud": "other"}),
|
||||
audience: "netbird",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "matching audience in array",
|
||||
token: makeJWT(t, map[string]interface{}{"aud": []interface{}{"other", "netbird"}}),
|
||||
audience: "netbird",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "mismatching audience array",
|
||||
token: makeJWT(t, map[string]interface{}{"aud": []interface{}{"a", "b"}}),
|
||||
audience: "netbird",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing audience claim",
|
||||
token: makeJWT(t, map[string]interface{}{"sub": "user"}),
|
||||
audience: "netbird",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid base64 payload",
|
||||
token: "header.!!!not-base64!!!.sig",
|
||||
audience: "netbird",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateTokenAudience(tc.token, tc.audience)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateTokenAudienceNoPanic guards the regression where a non-empty
|
||||
// token without the JWT dot structure caused an index-out-of-range panic.
|
||||
func TestValidateTokenAudienceNoPanic(t *testing.T) {
|
||||
inputs := []string{"a", ".", "a.", "aaaa", "no-dots-here"}
|
||||
for _, in := range inputs {
|
||||
if err := validateTokenAudience(in, "netbird"); err == nil {
|
||||
t.Fatalf("expected error for malformed token %q, got nil", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,7 +257,10 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
|
||||
log.Errorf("failed to clean up temporary installer file: %v", err)
|
||||
}
|
||||
|
||||
defer c.statusRecorder.ClientStop()
|
||||
defer func() {
|
||||
c.statusRecorder.SetSessionExpiresAt(time.Time{})
|
||||
c.statusRecorder.ClientStop()
|
||||
}()
|
||||
operation := func() error {
|
||||
// if context cancelled we not start new backoff cycle
|
||||
if c.ctx.Err() != nil {
|
||||
|
||||
@@ -75,4 +75,14 @@ func TestApplySessionDeadline_ThreeState(t *testing.T) {
|
||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
|
||||
"invalid timestamp must clear the deadline")
|
||||
})
|
||||
|
||||
t.Run("recently expired timestamp stays visible as expired", func(t *testing.T) {
|
||||
e := newEngine()
|
||||
expired := time.Now().Add(-5 * time.Minute).UTC().Truncate(time.Second)
|
||||
|
||||
e.ApplySessionDeadline(timestamppb.New(expired))
|
||||
|
||||
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(expired),
|
||||
"recently-expired deadline must stay on the recorder so consumers render it as expired")
|
||||
})
|
||||
}
|
||||
|
||||
90
client/internal/ipcauth/authorize.go
Normal file
90
client/internal/ipcauth/authorize.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Ownership is a profile's access policy: the typed owner principals plus the
|
||||
// opt-in shared flag.
|
||||
type Ownership struct {
|
||||
Owners []string
|
||||
Shared bool
|
||||
}
|
||||
|
||||
// GroupResolver resolves a Unix caller's effective group IDs (primary +
|
||||
// supplementary, NSS-aware) and owner group names to GIDs. It is only consulted
|
||||
// for Unix `gid:`/`group:` owners; Windows uses the SIDs carried in the Identity.
|
||||
// A nil resolver disables group matching.
|
||||
type GroupResolver interface {
|
||||
// CallerGIDs returns the set of group IDs the caller belongs to.
|
||||
CallerGIDs(id Identity) map[uint32]struct{}
|
||||
// GroupNameGID resolves a group name to its GID.
|
||||
GroupNameGID(name string) (uint32, bool)
|
||||
}
|
||||
|
||||
// Authorize reports whether the identity may control a profile with the given
|
||||
// ownership. Privileged callers (root / elevated-admin / LocalSystem) and shared
|
||||
// profiles are always allowed; otherwise the identity must match one of the
|
||||
// owner principals.
|
||||
func Authorize(o Ownership, id Identity, r GroupResolver) bool {
|
||||
if id.IsPrivileged() {
|
||||
return true
|
||||
}
|
||||
if o.Shared {
|
||||
return true
|
||||
}
|
||||
for _, raw := range o.Owners {
|
||||
p, ok := ParsePrincipal(raw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if principalMatches(p, id, r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func principalMatches(p Principal, id Identity, r GroupResolver) bool {
|
||||
switch p.Kind {
|
||||
case KindUID:
|
||||
if id.IsWindows() {
|
||||
return false
|
||||
}
|
||||
uid, err := strconv.ParseUint(p.Value, 10, 32)
|
||||
return err == nil && uint32(uid) == id.UID
|
||||
case KindGID:
|
||||
if id.IsWindows() {
|
||||
return false
|
||||
}
|
||||
gid, err := strconv.ParseUint(p.Value, 10, 32)
|
||||
return err == nil && callerHasGID(uint32(gid), id, r)
|
||||
case KindGroup:
|
||||
if id.IsWindows() || r == nil {
|
||||
return false
|
||||
}
|
||||
gid, ok := r.GroupNameGID(p.Value)
|
||||
return ok && callerHasGID(gid, id, r)
|
||||
case KindSID:
|
||||
if !id.IsWindows() {
|
||||
return false
|
||||
}
|
||||
return id.SID == p.Value || slices.Contains(id.Groups, p.Value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// callerHasGID reports whether gid is the caller's primary GID (from peercred,
|
||||
// no lookup) or one of their supplementary groups (NSS-resolved via r).
|
||||
func callerHasGID(gid uint32, id Identity, r GroupResolver) bool {
|
||||
if id.GID == gid {
|
||||
return true
|
||||
}
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := r.CallerGIDs(id)[gid]
|
||||
return ok
|
||||
}
|
||||
12
client/internal/ipcauth/creds_stub.go
Normal file
12
client/internal/ipcauth/creds_stub.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build !linux && !darwin && !freebsd && !windows
|
||||
|
||||
package ipcauth
|
||||
|
||||
import "google.golang.org/grpc/credentials"
|
||||
|
||||
// NewTransportCredentials returns nil on platforms without a peer-identity
|
||||
// primitive. The daemon falls back to insecure credentials and skips per-RPC
|
||||
// authorization (logging a warning), preserving pre-hardening behavior.
|
||||
func NewTransportCredentials() credentials.TransportCredentials {
|
||||
return nil
|
||||
}
|
||||
48
client/internal/ipcauth/creds_unix.go
Normal file
48
client/internal/ipcauth/creds_unix.go
Normal file
@@ -0,0 +1,48 @@
|
||||
//go:build linux || darwin || freebsd
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
// NewTransportCredentials returns gRPC transport credentials that extract the
|
||||
// caller's kernel-authenticated identity from a Unix-socket connection and
|
||||
// expose it via IdentityFromContext. Non-nil on platforms with a
|
||||
// peer-credential primitive.
|
||||
func NewTransportCredentials() credentials.TransportCredentials {
|
||||
return unixCreds{}
|
||||
}
|
||||
|
||||
// unixCreds implements credentials.TransportCredentials over a Unix socket.
|
||||
// The server side reads SO_PEERCRED/LOCAL_PEERCRED during the handshake; the
|
||||
// client side is a no-op (the kernel supplies the peer identity to the server
|
||||
// without any client cooperation), so an ordinary insecure client still works.
|
||||
type unixCreds struct{}
|
||||
|
||||
func (unixCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
return conn, AuthInfo{}, nil
|
||||
}
|
||||
|
||||
// ServerHandshake extracts the peer identity and fails closed if it cannot be read.
|
||||
func (unixCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
id, err := PeerIdentity(conn)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return conn, AuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
|
||||
Identity: id,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (unixCreds) Info() credentials.ProtocolInfo {
|
||||
return credentials.ProtocolInfo{SecurityProtocol: "netbird-ipc-peercred"}
|
||||
}
|
||||
|
||||
func (unixCreds) Clone() credentials.TransportCredentials { return unixCreds{} }
|
||||
|
||||
func (unixCreds) OverrideServerName(string) error { return nil }
|
||||
129
client/internal/ipcauth/creds_windows.go
Normal file
129
client/internal/ipcauth/creds_windows.go
Normal file
@@ -0,0 +1,129 @@
|
||||
//go:build windows
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"runtime"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
var (
|
||||
modadvapi32 = windows.NewLazySystemDLL("advapi32.dll")
|
||||
procImpersonateNamedPipeClient = modadvapi32.NewProc("ImpersonateNamedPipeClient")
|
||||
)
|
||||
|
||||
// DefaultPipeSDDL keeps the daemon control pipe open to any LOCAL caller, on par
|
||||
// with the Unix socket's 0666 mode.
|
||||
//
|
||||
// D:P protected DACL, no inheritance
|
||||
// (D;;GA;;;NU) deny GENERIC_ALL to NETWORK (remote/SMB)
|
||||
// (A;;GA;;;SY) allow GENERIC_ALL to LocalSystem (the daemon itself)
|
||||
// (A;;GA;;;WD) allow GENERIC_ALL to Everyone (local, per-RPC ACL gates)
|
||||
func DefaultPipeSDDL() string {
|
||||
return "D:P(D;;GA;;;NU)(A;;GA;;;SY)(A;;GA;;;WD)"
|
||||
}
|
||||
|
||||
// NewTransportCredentials returns gRPC transport credentials that derive the
|
||||
// caller's identity from the named-pipe client token.
|
||||
//
|
||||
// This requires the client to dial at SECURITY_IDENTIFICATION (see dialNamedPipe).
|
||||
func NewTransportCredentials() credentials.TransportCredentials {
|
||||
return winpipeCreds{}
|
||||
}
|
||||
|
||||
type winpipeCreds struct{}
|
||||
|
||||
func (winpipeCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
return conn, AuthInfo{}, nil
|
||||
}
|
||||
|
||||
// ServerHandshake extracts the connecting client's identity from the pipe. Fails
|
||||
// closed if the handle or token cannot be read.
|
||||
func (winpipeCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
|
||||
// go-winio's pipe connection embeds *win32File, which exposes Fd().
|
||||
fdConn, ok := conn.(interface{ Fd() uintptr })
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("connection %T does not expose a pipe handle", conn)
|
||||
}
|
||||
handle := windows.Handle(fdConn.Fd())
|
||||
|
||||
id, err := pipeClientIdentity(handle)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return conn, AuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
|
||||
Identity: id,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (winpipeCreds) Info() credentials.ProtocolInfo {
|
||||
return credentials.ProtocolInfo{SecurityProtocol: "netbird-ipc-peercred"}
|
||||
}
|
||||
|
||||
func (winpipeCreds) Clone() credentials.TransportCredentials { return winpipeCreds{} }
|
||||
|
||||
func (winpipeCreds) OverrideServerName(string) error { return nil }
|
||||
|
||||
// pipeClientIdentity reads the connecting client's user SID, enabled group SIDs,
|
||||
// and elevation by impersonating the pipe client on this thread and reading the
|
||||
// impersonation token.
|
||||
func pipeClientIdentity(handle windows.Handle) (id Identity, err error) {
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
|
||||
if err = impersonateNamedPipeClient(handle); err != nil {
|
||||
return Identity{}, fmt.Errorf("impersonate named pipe client: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
// Surface revert error if there are no other errors.
|
||||
revErr := windows.RevertToSelf()
|
||||
if err == nil {
|
||||
err = revErr
|
||||
}
|
||||
}()
|
||||
|
||||
// openAsSelf=true: the token is opened using the daemon's process context
|
||||
// (LocalSystem), not the impersonated client's, so the open always succeeds.
|
||||
var token windows.Token
|
||||
if err = windows.OpenThreadToken(windows.CurrentThread(), windows.TOKEN_QUERY, true, &token); err != nil {
|
||||
return Identity{}, fmt.Errorf("open thread token: %w", err)
|
||||
}
|
||||
defer token.Close()
|
||||
|
||||
tu, err := token.GetTokenUser()
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("get token user: %w", err)
|
||||
}
|
||||
|
||||
tg, err := token.GetTokenGroups()
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("get token groups: %w", err)
|
||||
}
|
||||
var groups []string
|
||||
for _, g := range tg.AllGroups() {
|
||||
if g.Attributes&windows.SE_GROUP_ENABLED == 0 || g.Attributes&windows.SE_GROUP_USE_FOR_DENY_ONLY != 0 {
|
||||
continue
|
||||
}
|
||||
groups = append(groups, g.Sid.String())
|
||||
}
|
||||
|
||||
return Identity{
|
||||
SID: tu.User.Sid.String(),
|
||||
Groups: groups,
|
||||
Elevated: token.IsElevated(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func impersonateNamedPipeClient(h windows.Handle) error {
|
||||
r, _, e := procImpersonateNamedPipeClient.Call(uintptr(h))
|
||||
if r == 0 {
|
||||
return e
|
||||
}
|
||||
return nil
|
||||
}
|
||||
61
client/internal/ipcauth/forward.go
Normal file
61
client/internal/ipcauth/forward.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
// Metadata keys used by the local JSON gateway to forward the HTTP client's
|
||||
// identity to the daemon. Trusted by the interceptor ONLY when the gRPC peer is
|
||||
// itself the daemon (self/privileged) — i.e. the loopback gateway — so a direct
|
||||
// gRPC caller cannot forge them.
|
||||
const (
|
||||
mdFwdUID = "x-netbird-fwd-uid"
|
||||
mdFwdGID = "x-netbird-fwd-gid"
|
||||
)
|
||||
|
||||
// ForwardIdentityMetadata encodes a Unix identity for the gateway to forward to
|
||||
// the daemon. Windows identities are not forwarded (the gateway cannot read a
|
||||
// pipe token for an HTTP client); nil is returned in that case.
|
||||
func ForwardIdentityMetadata(id Identity) metadata.MD {
|
||||
if id.IsWindows() {
|
||||
return nil
|
||||
}
|
||||
return metadata.Pairs(
|
||||
mdFwdUID, strconv.FormatUint(uint64(id.UID), 10),
|
||||
mdFwdGID, strconv.FormatUint(uint64(id.GID), 10),
|
||||
)
|
||||
}
|
||||
|
||||
// forwardedIdentity extracts a forwarded Unix identity from incoming gRPC
|
||||
// metadata, if present and well-formed.
|
||||
func forwardedIdentity(ctx context.Context) (Identity, bool) {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return Identity{}, false
|
||||
}
|
||||
uidStr := mdFirst(md, mdFwdUID)
|
||||
if uidStr == "" {
|
||||
return Identity{}, false
|
||||
}
|
||||
uid, err := strconv.ParseUint(uidStr, 10, 32)
|
||||
if err != nil {
|
||||
return Identity{}, false
|
||||
}
|
||||
id := Identity{UID: uint32(uid)}
|
||||
if g := mdFirst(md, mdFwdGID); g != "" {
|
||||
if v, err := strconv.ParseUint(g, 10, 32); err == nil {
|
||||
id.GID = uint32(v)
|
||||
}
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func mdFirst(md metadata.MD, key string) string {
|
||||
if v := md.Get(key); len(v) > 0 {
|
||||
return v[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
95
client/internal/ipcauth/identity.go
Normal file
95
client/internal/ipcauth/identity.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Package ipcauth provides the kernel-authenticated identity of a local IPC
|
||||
// (gRPC) caller and the transport credentials that surface it into the gRPC
|
||||
// context, so the daemon can authorize each RPC by caller identity.
|
||||
//
|
||||
// On Unix the identity is read from the kernel via SO_PEERCRED (Linux) or
|
||||
// LOCAL_PEERCRED (Darwin/FreeBSD). On Windows it is derived from the named-pipe
|
||||
// client token. Platforms without a peer-identity primitive get no credentials
|
||||
// and therefore no enforcement (the daemon logs a warning and stays open,
|
||||
// preserving today's behavior until the transport gains an identity primitive).
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/peer"
|
||||
)
|
||||
|
||||
// sidLocalSystem is the well-known Windows SID for the LocalSystem account.
|
||||
const sidLocalSystem = "S-1-5-18"
|
||||
|
||||
// Identity is the kernel-authenticated identity of a local IPC caller. The zero
|
||||
// value is not a valid identity; obtain one via IdentityFromContext (which
|
||||
// reports presence) or PeerIdentity.
|
||||
type Identity struct {
|
||||
// UID and GID are the caller's Unix user ID and primary group ID.
|
||||
// Zero on Windows, where SID is authoritative instead.
|
||||
UID uint32
|
||||
GID uint32
|
||||
|
||||
// SID is the caller's Windows security identifier (empty on Unix).
|
||||
SID string
|
||||
|
||||
// Groups holds the caller's Windows group SIDs, captured from the client
|
||||
// token at handshake (empty on Unix, where supplementary group membership is
|
||||
// resolved on demand via NSS/getent by the authorizer).
|
||||
Groups []string
|
||||
|
||||
// Elevated reports whether the Windows client token is elevated (run as
|
||||
// administrator). Always false on Unix, where privilege is uid==0.
|
||||
Elevated bool
|
||||
}
|
||||
|
||||
// IsWindows reports whether this identity is a Windows principal (SID-based)
|
||||
// rather than a Unix uid/gid principal.
|
||||
func (i Identity) IsWindows() bool {
|
||||
return i.SID != ""
|
||||
}
|
||||
|
||||
// IsPrivileged reports whether the caller is the platform's administrative
|
||||
// principal — Unix root (uid 0), or on Windows an elevated token or LocalSystem.
|
||||
// It deliberately requires actual elevation on Windows (a non-elevated member of
|
||||
// Administrators has a filtered token and is NOT privileged), mirroring "must
|
||||
// really be root" on Unix.
|
||||
func (i Identity) IsPrivileged() bool {
|
||||
if i.IsWindows() {
|
||||
return i.Elevated || i.SID == sidLocalSystem
|
||||
}
|
||||
return i.UID == 0
|
||||
}
|
||||
|
||||
// String renders the identity for audit logs.
|
||||
func (i Identity) String() string {
|
||||
if i.IsWindows() {
|
||||
return fmt.Sprintf("sid=%s elevated=%t", i.SID, i.Elevated)
|
||||
}
|
||||
return fmt.Sprintf("uid=%d gid=%d", i.UID, i.GID)
|
||||
}
|
||||
|
||||
// AuthInfo carries the peer Identity as a gRPC credentials.AuthInfo so the
|
||||
// interceptor can retrieve it from the request context via IdentityFromContext.
|
||||
type AuthInfo struct {
|
||||
credentials.CommonAuthInfo
|
||||
Identity Identity
|
||||
}
|
||||
|
||||
// AuthType identifies the authentication scheme.
|
||||
func (AuthInfo) AuthType() string { return "netbird-ipc-peercred" }
|
||||
|
||||
// IdentityFromContext extracts the caller's kernel-authenticated identity from
|
||||
// the gRPC peer context. The second return value is false when no IPC transport
|
||||
// credentials were negotiated (e.g. an unsupported platform, or a caller that
|
||||
// did not come through the daemon socket) — callers MUST fail closed in that case.
|
||||
func IdentityFromContext(ctx context.Context) (Identity, bool) {
|
||||
p, ok := peer.FromContext(ctx)
|
||||
if !ok {
|
||||
return Identity{}, false
|
||||
}
|
||||
info, ok := p.AuthInfo.(AuthInfo)
|
||||
if !ok {
|
||||
return Identity{}, false
|
||||
}
|
||||
return info.Identity, true
|
||||
}
|
||||
50
client/internal/ipcauth/identity_test.go
Normal file
50
client/internal/ipcauth/identity_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/peer"
|
||||
)
|
||||
|
||||
func TestIdentityFromContext_NoPeer(t *testing.T) {
|
||||
_, ok := IdentityFromContext(context.Background())
|
||||
assert.False(t, ok, "bare context must report no identity (fail closed)")
|
||||
}
|
||||
|
||||
func TestIdentityFromContext_WrongAuthInfo(t *testing.T) {
|
||||
ctx := peer.NewContext(context.Background(), &peer.Peer{})
|
||||
_, ok := IdentityFromContext(ctx)
|
||||
assert.False(t, ok, "peer without our AuthInfo must report no identity")
|
||||
}
|
||||
|
||||
func TestIdentityFromContext_Present(t *testing.T) {
|
||||
want := Identity{UID: 1000, GID: 1000}
|
||||
ctx := peer.NewContext(context.Background(), &peer.Peer{
|
||||
AuthInfo: AuthInfo{
|
||||
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
|
||||
Identity: want,
|
||||
},
|
||||
})
|
||||
|
||||
got, ok := IdentityFromContext(ctx)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func TestIdentity_IsPrivileged(t *testing.T) {
|
||||
// Unix
|
||||
assert.True(t, Identity{UID: 0}.IsPrivileged(), "root is privileged")
|
||||
assert.False(t, Identity{UID: 1000}.IsPrivileged(), "non-root is not privileged")
|
||||
// Windows
|
||||
assert.True(t, Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true}.IsPrivileged(), "elevated admin is privileged")
|
||||
assert.True(t, Identity{SID: "S-1-5-18"}.IsPrivileged(), "LocalSystem is privileged")
|
||||
assert.False(t, Identity{SID: "S-1-5-21-1-2-3-1001"}.IsPrivileged(), "non-elevated admin is NOT privileged")
|
||||
}
|
||||
|
||||
func TestIdentity_IsWindows(t *testing.T) {
|
||||
assert.True(t, Identity{SID: "S-1-5-18"}.IsWindows())
|
||||
assert.False(t, Identity{UID: 0}.IsWindows())
|
||||
}
|
||||
124
client/internal/ipcauth/interceptor.go
Normal file
124
client/internal/ipcauth/interceptor.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// Interceptor enforces per-RPC authorization on the daemon control channel,
|
||||
// keyed to the caller's kernel-authenticated identity. It is safe-by-default:
|
||||
// any RPC without a matching bypass is gated by the active profile's ownership,
|
||||
// and a caller without a readable identity is denied.
|
||||
type Interceptor struct {
|
||||
policy ProfilePolicy
|
||||
resolver GroupResolver
|
||||
// selfUID is the daemon's own effective UID. A caller whose UID matches it
|
||||
// (rootless container / foreground daemon running as the invoking user) is
|
||||
// allowed: it already has full control of the daemon process. -1 on Windows.
|
||||
selfUID int
|
||||
}
|
||||
|
||||
// NewInterceptor builds an interceptor over the given policy and group resolver.
|
||||
func NewInterceptor(policy ProfilePolicy, resolver GroupResolver) *Interceptor {
|
||||
return &Interceptor{policy: policy, resolver: resolver, selfUID: os.Geteuid()}
|
||||
}
|
||||
|
||||
// UnaryServerInterceptor authorizes each unary RPC before the handler runs.
|
||||
func (i *Interceptor) UnaryServerInterceptor() grpc.UnaryServerInterceptor {
|
||||
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
if err := i.authorize(ctx, info.FullMethod); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return handler(ctx, req)
|
||||
}
|
||||
}
|
||||
|
||||
// StreamServerInterceptor authorizes each streaming RPC before the handler runs.
|
||||
func (i *Interceptor) StreamServerInterceptor() grpc.StreamServerInterceptor {
|
||||
return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
|
||||
if err := i.authorize(ss.Context(), info.FullMethod); err != nil {
|
||||
return err
|
||||
}
|
||||
return handler(srv, ss)
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Interceptor) authorize(ctx context.Context, fullMethod string) error {
|
||||
id, ok := IdentityFromContext(ctx)
|
||||
if !ok {
|
||||
log.Warnf("ipc authz: DENY %s — caller identity unavailable", fullMethod)
|
||||
return status.Error(codes.PermissionDenied, "caller identity could not be verified on the daemon control channel")
|
||||
}
|
||||
|
||||
if i.isSelfOrPrivileged(id) {
|
||||
// The local JSON gateway connects as the daemon itself (self/privileged)
|
||||
// and forwards the real HTTP client's identity. Trust it here — and only
|
||||
// here, where the transport peer is already the daemon — then authorize
|
||||
// as the forwarded client. A direct non-privileged caller never reaches
|
||||
// this branch, so it cannot forge the forwarding metadata.
|
||||
fwd, hasFwd := forwardedIdentity(ctx)
|
||||
if !hasFwd {
|
||||
i.auditAllow(id, fullMethod)
|
||||
return nil
|
||||
}
|
||||
log.Infof("ipc authz: honoring gateway-forwarded identity %s", fwd)
|
||||
id = fwd
|
||||
if i.isSelfOrPrivileged(id) {
|
||||
i.auditAllow(id, fullMethod)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Per-user / per-target-profile RPCs authorize themselves in the handler.
|
||||
if handlerAuthorizedMethods[fullMethod] {
|
||||
return nil
|
||||
}
|
||||
|
||||
o := i.policy.ActiveProfileOwnership()
|
||||
|
||||
// Trust-on-first-use: an unowned, non-shared profile is claimed by the first
|
||||
// caller. The claim is atomic; if we lose the race we re-read and authorize.
|
||||
if len(o.Owners) == 0 && !o.Shared {
|
||||
claimed, err := i.policy.ClaimActiveProfileOwnerIfUnowned(id)
|
||||
if err != nil {
|
||||
log.Errorf("ipc authz: claim active profile for %s: %v", id, err)
|
||||
return status.Error(codes.Internal, "failed to claim profile ownership")
|
||||
}
|
||||
if claimed {
|
||||
log.Infof("ipc authz: %s claimed ownership of the active profile (trust-on-first-use)", id)
|
||||
i.auditAllow(id, fullMethod)
|
||||
return nil
|
||||
}
|
||||
o = i.policy.ActiveProfileOwnership()
|
||||
}
|
||||
|
||||
if Authorize(o, id, i.resolver) {
|
||||
i.auditAllow(id, fullMethod)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Warnf("ipc authz: DENY %s for %s — active profile owned by another principal", fullMethod, id)
|
||||
return status.Errorf(codes.PermissionDenied,
|
||||
"not authorized to control the active profile (caller %s); ask an owner or run as root/administrator", id)
|
||||
}
|
||||
|
||||
// isSelfOrPrivileged reports whether the caller is the platform administrator
|
||||
// (root / elevated-admin / LocalSystem) or the daemon's own user.
|
||||
func (i *Interceptor) isSelfOrPrivileged(id Identity) bool {
|
||||
if id.IsPrivileged() {
|
||||
return true
|
||||
}
|
||||
// Daemon-self: only meaningful on Unix (Windows privilege is covered above).
|
||||
return !id.IsWindows() && i.selfUID >= 0 && int(id.UID) == i.selfUID
|
||||
}
|
||||
|
||||
func (i *Interceptor) auditAllow(id Identity, fullMethod string) {
|
||||
if auditMethods[fullMethod] {
|
||||
log.Infof("ipc authz: allow %s for %s", fullMethod, id)
|
||||
}
|
||||
}
|
||||
139
client/internal/ipcauth/interceptor_test.go
Normal file
139
client/internal/ipcauth/interceptor_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/peer"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type mockPolicy struct {
|
||||
o Ownership
|
||||
claimed bool
|
||||
}
|
||||
|
||||
func (m *mockPolicy) ActiveProfileOwnership() Ownership { return m.o }
|
||||
|
||||
// ClaimActiveProfileOwnerIfUnowned records a claim and marks the profile owned.
|
||||
func (m *mockPolicy) ClaimActiveProfileOwnerIfUnowned(id Identity) (bool, error) {
|
||||
if len(m.o.Owners) == 0 && !m.o.Shared {
|
||||
m.o.Owners = []string{OwnerPrincipalForIdentity(id)}
|
||||
m.claimed = true
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
type mockResolver struct {
|
||||
gids map[uint32]struct{}
|
||||
names map[string]uint32
|
||||
}
|
||||
|
||||
func (m mockResolver) CallerGIDs(Identity) map[uint32]struct{} { return m.gids }
|
||||
func (m mockResolver) GroupNameGID(n string) (uint32, bool) { g, ok := m.names[n]; return g, ok }
|
||||
|
||||
func ctxWith(id Identity) context.Context {
|
||||
return peer.NewContext(context.Background(), &peer.Peer{AuthInfo: AuthInfo{Identity: id}})
|
||||
}
|
||||
|
||||
const (
|
||||
up = servicePath + "Up"
|
||||
list = servicePath + "ListProfiles"
|
||||
unkwn = servicePath + "SomeFutureMethod"
|
||||
)
|
||||
|
||||
func TestInterceptorAuthorize(t *testing.T) {
|
||||
const selfUID = 4000
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
own Ownership
|
||||
resolver GroupResolver
|
||||
ctx context.Context
|
||||
method string
|
||||
wantErr bool
|
||||
}{
|
||||
{"no identity denies", Ownership{}, nil, context.Background(), up, true},
|
||||
{"root allowed", Ownership{}, nil, ctxWith(Identity{UID: 0}), up, false},
|
||||
{"daemon-self allowed", Ownership{}, nil, ctxWith(Identity{UID: selfUID}), up, false},
|
||||
{"shared allows any", Ownership{Shared: true}, nil, ctxWith(Identity{UID: 1234}), up, false},
|
||||
{"uid owner allowed", Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 1000}), up, false},
|
||||
{"non-owner denied", Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), up, true},
|
||||
{"handler-authorized bypass", Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), list, false},
|
||||
{"unknown method gated", Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), unkwn, true},
|
||||
{"primary gid owner", Ownership{Owners: []string{"gid:5000"}}, nil, ctxWith(Identity{UID: 2000, GID: 5000}), up, false},
|
||||
{"group-name owner via resolver", Ownership{Owners: []string{"group:admins"}},
|
||||
mockResolver{names: map[string]uint32{"admins": 5000}, gids: map[uint32]struct{}{5000: {}}},
|
||||
ctxWith(Identity{UID: 2000, GID: 42}), up, false},
|
||||
{"windows sid owner", Ownership{Owners: []string{"sid:S-1-5-21-9"}}, nil,
|
||||
ctxWith(Identity{SID: "S-1-5-21-9"}), up, false},
|
||||
{"windows group-sid owner", Ownership{Owners: []string{"sid:S-1-5-32-544"}}, nil,
|
||||
ctxWith(Identity{SID: "S-1-5-21-1", Groups: []string{"S-1-5-32-544"}}), up, false},
|
||||
{"windows elevated privileged", Ownership{}, nil,
|
||||
ctxWith(Identity{SID: "S-1-5-21-1", Elevated: true}), up, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
i := &Interceptor{policy: &mockPolicy{o: tt.own}, resolver: tt.resolver, selfUID: selfUID}
|
||||
err := i.authorize(tt.ctx, tt.method)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, codes.PermissionDenied, status.Code(err))
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterceptorForwardedIdentity verifies the JSON-gateway trust model: a
|
||||
// self/privileged transport peer (the loopback gateway) may forward a real
|
||||
// client identity, but a non-privileged caller cannot forge it.
|
||||
func TestInterceptorForwardedIdentity(t *testing.T) {
|
||||
const selfUID = 4000
|
||||
owners := Ownership{Owners: []string{"uid:1000"}}
|
||||
|
||||
withFwd := func(peerUID, fwdUID uint32) context.Context {
|
||||
ctx := ctxWith(Identity{UID: peerUID})
|
||||
return metadata.NewIncomingContext(ctx, metadata.Pairs(mdFwdUID, itoa(fwdUID)))
|
||||
}
|
||||
|
||||
// Gateway (peer == daemon-self) forwards a non-owner client → denied as that client.
|
||||
i := &Interceptor{policy: &mockPolicy{o: owners}, selfUID: selfUID}
|
||||
assert.Error(t, i.authorize(withFwd(selfUID, 2000), up))
|
||||
|
||||
// Gateway forwards the owner → allowed.
|
||||
assert.NoError(t, i.authorize(withFwd(selfUID, 1000), up))
|
||||
|
||||
// A non-privileged direct caller's forwarded metadata is IGNORED (can't forge):
|
||||
// caller uid 2000 forwarding uid:1000 is still treated as 2000 → denied.
|
||||
assert.Error(t, i.authorize(withFwd(2000, 1000), up))
|
||||
}
|
||||
|
||||
func itoa(u uint32) string {
|
||||
return strconv.FormatUint(uint64(u), 10)
|
||||
}
|
||||
|
||||
// TestInterceptorTOFU verifies an unowned, non-shared profile is claimed by the
|
||||
// first non-privileged caller, and a different caller is then denied.
|
||||
func TestInterceptorTOFU(t *testing.T) {
|
||||
policy := &mockPolicy{o: Ownership{}} // unowned
|
||||
i := &Interceptor{policy: policy, resolver: nil, selfUID: 4000}
|
||||
|
||||
// First caller (uid 1000) claims via TOFU.
|
||||
err := i.authorize(ctxWith(Identity{UID: 1000}), up)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, policy.claimed, "first caller should claim ownership")
|
||||
assert.Equal(t, []string{"uid:1000"}, policy.o.Owners)
|
||||
|
||||
// A different caller is now denied (profile owned by uid 1000).
|
||||
err = i.authorize(ctxWith(Identity{UID: 2000}), up)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, codes.PermissionDenied, status.Code(err))
|
||||
}
|
||||
42
client/internal/ipcauth/peercred_bsd.go
Normal file
42
client/internal/ipcauth/peercred_bsd.go
Normal file
@@ -0,0 +1,42 @@
|
||||
//go:build darwin || freebsd
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// PeerIdentity reads the kernel-authenticated identity of the process on the
|
||||
// other end of a Unix socket connection via LOCAL_PEERCRED (xucred). xucred
|
||||
// carries the uid and primary group.
|
||||
func PeerIdentity(c net.Conn) (Identity, error) {
|
||||
uc, ok := c.(*net.UnixConn)
|
||||
if !ok {
|
||||
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", c)
|
||||
}
|
||||
raw, err := uc.SyscallConn()
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("raw conn: %w", err)
|
||||
}
|
||||
|
||||
var cred *unix.Xucred
|
||||
var credErr error
|
||||
if err := raw.Control(func(fd uintptr) {
|
||||
cred, credErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED)
|
||||
}); err != nil {
|
||||
return Identity{}, fmt.Errorf("getsockopt control: %w", err)
|
||||
}
|
||||
if credErr != nil {
|
||||
return Identity{}, fmt.Errorf("LOCAL_PEERCRED: %w", credErr)
|
||||
}
|
||||
|
||||
id := Identity{UID: cred.Uid}
|
||||
// Groups[0] is the effective (primary) GID; guard against an empty list.
|
||||
if cred.Ngroups > 0 {
|
||||
id.GID = cred.Groups[0]
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
41
client/internal/ipcauth/peercred_linux.go
Normal file
41
client/internal/ipcauth/peercred_linux.go
Normal file
@@ -0,0 +1,41 @@
|
||||
//go:build linux
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// PeerIdentity reads the kernel-authenticated identity of the process on the
|
||||
// other end of a Unix socket connection via SO_PEERCRED. The credentials are
|
||||
// captured by the kernel at connect() time and cannot be spoofed or changed for
|
||||
// the life of the connection.
|
||||
func PeerIdentity(c net.Conn) (Identity, error) {
|
||||
uc, ok := c.(*net.UnixConn)
|
||||
if !ok {
|
||||
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", c)
|
||||
}
|
||||
raw, err := uc.SyscallConn()
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("raw conn: %w", err)
|
||||
}
|
||||
|
||||
var cred *unix.Ucred
|
||||
var credErr error
|
||||
if err := raw.Control(func(fd uintptr) {
|
||||
cred, credErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
|
||||
}); err != nil {
|
||||
return Identity{}, fmt.Errorf("getsockopt control: %w", err)
|
||||
}
|
||||
if credErr != nil {
|
||||
return Identity{}, fmt.Errorf("SO_PEERCRED: %w", credErr)
|
||||
}
|
||||
|
||||
return Identity{
|
||||
UID: cred.Uid,
|
||||
GID: cred.Gid,
|
||||
}, nil
|
||||
}
|
||||
16
client/internal/ipcauth/peercred_stub.go
Normal file
16
client/internal/ipcauth/peercred_stub.go
Normal file
@@ -0,0 +1,16 @@
|
||||
//go:build !linux && !darwin && !freebsd
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// PeerIdentity is unimplemented on platforms without a Unix-socket peer-credential
|
||||
// primitive. Windows derives identity from the named-pipe client token instead
|
||||
// (see the Windows transport credentials), so it never calls this.
|
||||
func PeerIdentity(net.Conn) (Identity, error) {
|
||||
return Identity{}, fmt.Errorf("peer credential check not supported on %s", runtime.GOOS)
|
||||
}
|
||||
114
client/internal/ipcauth/peercred_unix_test.go
Normal file
114
client/internal/ipcauth/peercred_unix_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
//go:build linux || darwin || freebsd
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/health"
|
||||
healthpb "google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// TestPeerIdentity_MatchesCurrentProcess connects to a real Unix socket and
|
||||
// verifies the extracted UID/GID match the running process (both ends are us).
|
||||
func TestPeerIdentity_MatchesCurrentProcess(t *testing.T) {
|
||||
sock := filepath.Join(t.TempDir(), "peer.sock")
|
||||
ln, err := net.Listen("unix", sock)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
|
||||
type result struct {
|
||||
id Identity
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
c, aerr := ln.Accept()
|
||||
if aerr != nil {
|
||||
done <- result{err: aerr}
|
||||
return
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
id, ierr := PeerIdentity(c)
|
||||
done <- result{id: id, err: ierr}
|
||||
}()
|
||||
|
||||
client, err := net.Dial("unix", sock)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
res := <-done
|
||||
require.NoError(t, res.err)
|
||||
assert.Equal(t, uint32(os.Getuid()), res.id.UID, "UID should match current process")
|
||||
assert.Equal(t, uint32(os.Getgid()), res.id.GID, "primary GID should match current process")
|
||||
}
|
||||
|
||||
// TestPeerIdentity_NonUnixConn rejects non-Unix connections (fail closed).
|
||||
func TestPeerIdentity_NonUnixConn(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
c, aerr := ln.Accept()
|
||||
if aerr != nil {
|
||||
done <- aerr
|
||||
return
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
_, ierr := PeerIdentity(c)
|
||||
done <- ierr
|
||||
}()
|
||||
|
||||
client, err := net.Dial("tcp", ln.Addr().String())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
assert.Error(t, <-done, "PeerIdentity must reject a non-Unix connection")
|
||||
}
|
||||
|
||||
// TestGRPCRoundTrip_ServerCredsClientInsecure proves the transport contract end
|
||||
// to end: a gRPC server using the peercred transport credentials still serves a
|
||||
// plain insecure client (the CLI never changed), and the caller's kernel
|
||||
// identity reaches the handler via IdentityFromContext.
|
||||
func TestGRPCRoundTrip_ServerCredsClientInsecure(t *testing.T) {
|
||||
sock := filepath.Join(t.TempDir(), "rt.sock")
|
||||
ln, err := net.Listen("unix", sock)
|
||||
require.NoError(t, err)
|
||||
|
||||
var gotUID uint32
|
||||
var gotOK bool
|
||||
srv := grpc.NewServer(
|
||||
grpc.Creds(NewTransportCredentials()),
|
||||
grpc.UnaryInterceptor(func(ctx context.Context, req any, _ *grpc.UnaryServerInfo, h grpc.UnaryHandler) (any, error) {
|
||||
id, ok := IdentityFromContext(ctx)
|
||||
gotUID, gotOK = id.UID, ok
|
||||
return h(ctx, req)
|
||||
}),
|
||||
)
|
||||
healthpb.RegisterHealthServer(srv, health.NewServer())
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
t.Cleanup(srv.Stop)
|
||||
|
||||
conn, err := grpc.NewClient("unix://"+sock, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err = healthpb.NewHealthClient(conn).Check(ctx, &healthpb.HealthCheckRequest{})
|
||||
require.NoError(t, err, "insecure client must reach the peercred server")
|
||||
|
||||
assert.True(t, gotOK, "handler must see a peer identity")
|
||||
assert.Equal(t, uint32(os.Getuid()), gotUID, "handler must see the caller's UID")
|
||||
}
|
||||
92
client/internal/ipcauth/policy.go
Normal file
92
client/internal/ipcauth/policy.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package ipcauth
|
||||
|
||||
import "sync"
|
||||
|
||||
const servicePath = "/daemon.DaemonService/"
|
||||
|
||||
// ProfilePolicy exposes the active profile's ownership to the interceptor. The
|
||||
// daemon server implements it; ConfigAdapter bridges the gap because the gRPC
|
||||
// server (and its interceptor) is constructed before the server instance exists.
|
||||
type ProfilePolicy interface {
|
||||
// ActiveProfileOwnership returns the active profile's ownership policy.
|
||||
ActiveProfileOwnership() Ownership
|
||||
|
||||
// ClaimActiveProfileOwnerIfUnowned atomically claims the active profile for
|
||||
// id when it has no owners and is not shared (trust-on-first-use), and
|
||||
// reports whether id is now an owner. A false return means the profile was
|
||||
// already owned/shared or another caller won the claim — the caller must
|
||||
// re-read ownership and authorize normally.
|
||||
ClaimActiveProfileOwnerIfUnowned(id Identity) (bool, error)
|
||||
}
|
||||
|
||||
// handlerAuthorizedMethods bypass the active-profile gate: they are per-user or
|
||||
// per-target-profile operations whose handler does its own authorization (bound
|
||||
// to the caller identity). Peer identity is still required to reach them.
|
||||
var handlerAuthorizedMethods = map[string]bool{
|
||||
servicePath + "AddProfile": true,
|
||||
servicePath + "ListProfiles": true,
|
||||
servicePath + "GetActiveProfile": true,
|
||||
servicePath + "RemoveProfile": true,
|
||||
servicePath + "RenameProfile": true,
|
||||
}
|
||||
|
||||
// auditMethods are the Tier-C/H RPCs (threat model §3) whose successful
|
||||
// authorization is worth an audit log line. Denials are always logged.
|
||||
var auditMethods = map[string]bool{
|
||||
servicePath + "GetConfig": true,
|
||||
servicePath + "SetConfig": true,
|
||||
servicePath + "Login": true,
|
||||
servicePath + "WaitSSOLogin": true,
|
||||
servicePath + "RequestJWTAuth": true,
|
||||
servicePath + "WaitJWTToken": true,
|
||||
servicePath + "StartCapture": true,
|
||||
servicePath + "StartBundleCapture": true,
|
||||
servicePath + "DebugBundle": true,
|
||||
servicePath + "ExposeService": true,
|
||||
servicePath + "Up": true,
|
||||
servicePath + "Down": true,
|
||||
servicePath + "SelectNetworks": true,
|
||||
servicePath + "DeselectNetworks": true,
|
||||
servicePath + "SwitchProfile": true,
|
||||
servicePath + "TriggerUpdate": true,
|
||||
servicePath + "Logout": true,
|
||||
servicePath + "CleanState": true,
|
||||
servicePath + "DeleteState": true,
|
||||
}
|
||||
|
||||
// ConfigAdapter is a ProfilePolicy whose backend is set lazily, once the daemon
|
||||
// server instance is created. Until then it reports an unowned profile
|
||||
// (Ownership zero value), so non-privileged callers are denied — fail closed.
|
||||
type ConfigAdapter struct {
|
||||
mu sync.RWMutex
|
||||
backend ProfilePolicy
|
||||
}
|
||||
|
||||
// SetBackend installs the real policy. Must be called before serving RPCs.
|
||||
func (a *ConfigAdapter) SetBackend(backend ProfilePolicy) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.backend = backend
|
||||
}
|
||||
|
||||
// ActiveProfileOwnership delegates to the backend, or reports an unowned profile
|
||||
// when no backend is set yet.
|
||||
func (a *ConfigAdapter) ActiveProfileOwnership() Ownership {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.backend == nil {
|
||||
return Ownership{}
|
||||
}
|
||||
return a.backend.ActiveProfileOwnership()
|
||||
}
|
||||
|
||||
// ClaimActiveProfileOwnerIfUnowned delegates to the backend. Before the backend
|
||||
// is set it cannot claim, so it reports not-owned (fail closed).
|
||||
func (a *ConfigAdapter) ClaimActiveProfileOwnerIfUnowned(id Identity) (bool, error) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.backend == nil {
|
||||
return false, nil
|
||||
}
|
||||
return a.backend.ClaimActiveProfileOwnerIfUnowned(id)
|
||||
}
|
||||
55
client/internal/ipcauth/principal.go
Normal file
55
client/internal/ipcauth/principal.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PrincipalKind is the type of an owner principal.
|
||||
type PrincipalKind string
|
||||
|
||||
const (
|
||||
KindUID PrincipalKind = "uid" // Unix user ID
|
||||
KindGID PrincipalKind = "gid" // Unix group ID
|
||||
KindGroup PrincipalKind = "group" // Unix group name (NSS-resolved)
|
||||
KindSID PrincipalKind = "sid" // Windows user or group SID
|
||||
)
|
||||
|
||||
// Principal is a parsed owner entry from a profile's Owners list.
|
||||
type Principal struct {
|
||||
Kind PrincipalKind
|
||||
Value string
|
||||
}
|
||||
|
||||
// ParsePrincipal parses a "kind:value" owner string. Returns false for empty
|
||||
// values or unknown kinds so malformed entries are ignored rather than trusted.
|
||||
func ParsePrincipal(s string) (Principal, bool) {
|
||||
kind, value, ok := strings.Cut(s, ":")
|
||||
if !ok || value == "" {
|
||||
return Principal{}, false
|
||||
}
|
||||
switch PrincipalKind(kind) {
|
||||
case KindUID, KindGID, KindGroup, KindSID:
|
||||
return Principal{Kind: PrincipalKind(kind), Value: value}, true
|
||||
default:
|
||||
return Principal{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// UIDPrincipal builds the owner string for a Unix user ID.
|
||||
func UIDPrincipal(uid uint32) string {
|
||||
return string(KindUID) + ":" + strconv.FormatUint(uint64(uid), 10)
|
||||
}
|
||||
|
||||
// SIDPrincipal builds the owner string for a Windows SID.
|
||||
func SIDPrincipal(sid string) string { return string(KindSID) + ":" + sid }
|
||||
|
||||
// OwnerPrincipalForIdentity returns the self-ownership principal for an identity:
|
||||
// the user's UID on Unix, or the user's SID on Windows. Used to auto-isolate a
|
||||
// new profile to its creator.
|
||||
func OwnerPrincipalForIdentity(id Identity) string {
|
||||
if id.IsWindows() {
|
||||
return SIDPrincipal(id.SID)
|
||||
}
|
||||
return UIDPrincipal(id.UID)
|
||||
}
|
||||
72
client/internal/ipcauth/resolver_unix.go
Normal file
72
client/internal/ipcauth/resolver_unix.go
Normal file
@@ -0,0 +1,72 @@
|
||||
//go:build !windows
|
||||
|
||||
package ipcauth
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/shell"
|
||||
)
|
||||
|
||||
const groupCacheTTL = 30 * time.Second
|
||||
|
||||
// NewDefaultGroupResolver returns an NSS-aware group resolver backed by
|
||||
// getent/`id -G` (via client/internal/shell), so `gid:`/`group:` owners resolve
|
||||
// correctly for LDAP/AD users under CGO_ENABLED=0. Results are cached briefly.
|
||||
func NewDefaultGroupResolver() GroupResolver {
|
||||
return &nssResolver{byUID: make(map[uint32]gidCacheEntry)}
|
||||
}
|
||||
|
||||
type gidCacheEntry struct {
|
||||
gids map[uint32]struct{}
|
||||
at time.Time
|
||||
}
|
||||
|
||||
type nssResolver struct {
|
||||
mu sync.Mutex
|
||||
byUID map[uint32]gidCacheEntry
|
||||
}
|
||||
|
||||
func (r *nssResolver) CallerGIDs(id Identity) map[uint32]struct{} {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if e, ok := r.byUID[id.UID]; ok && time.Since(e.at) < groupCacheTTL {
|
||||
return e.gids
|
||||
}
|
||||
gids := resolveGIDs(id.UID)
|
||||
r.byUID[id.UID] = gidCacheEntry{gids: gids, at: time.Now()}
|
||||
return gids
|
||||
}
|
||||
|
||||
func resolveGIDs(uid uint32) map[uint32]struct{} {
|
||||
out := make(map[uint32]struct{})
|
||||
u, err := shell.GetUserFromGetent(strconv.FormatUint(uint64(uid), 10))
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
ids, err := shell.GroupIdsWithFallback(u)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for _, s := range ids {
|
||||
if g, err := strconv.ParseUint(s, 10, 32); err == nil {
|
||||
out[uint32(g)] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *nssResolver) GroupNameGID(name string) (uint32, bool) {
|
||||
g, err := shell.LookupGroupWithGetent(name)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
gid, err := strconv.ParseUint(g.Gid, 10, 32)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return uint32(gid), true
|
||||
}
|
||||
10
client/internal/ipcauth/resolver_windows.go
Normal file
10
client/internal/ipcauth/resolver_windows.go
Normal file
@@ -0,0 +1,10 @@
|
||||
//go:build windows
|
||||
|
||||
package ipcauth
|
||||
|
||||
// NewDefaultGroupResolver returns nil on Windows: group authorization uses the
|
||||
// group SIDs carried in the client token (see the Windows transport
|
||||
// credentials), not NSS/getent.
|
||||
func NewDefaultGroupResolver() GroupResolver {
|
||||
return nil
|
||||
}
|
||||
@@ -813,19 +813,14 @@ func (d *Status) SetSessionExpiresAt(deadline time.Time) {
|
||||
}
|
||||
|
||||
// GetSessionExpiresAt returns the most recently recorded SSO session deadline,
|
||||
// or the zero value when no deadline is tracked. A deadline that has already
|
||||
// slipped into the past reports as "none": once the session has expired it is
|
||||
// no longer a meaningful countdown, and the sessionwatch.Watcher does not
|
||||
// arm a timer at the deadline itself to clear it (only the two pre-expiry
|
||||
// warnings). Without this guard the UI would keep painting a stale
|
||||
// "expires in …" against a moment that has passed until the next login,
|
||||
// extend, or teardown rewrote the value.
|
||||
// or the zero value when no deadline is tracked. A deadline in the past is
|
||||
// returned as-is: it means the session has expired, and consumers (tray row,
|
||||
// CLI status) render it as "expired" rather than hiding it — masking it as
|
||||
// "none" would blank the UI at the exact moment it should say the session
|
||||
// ended.
|
||||
func (d *Status) GetSessionExpiresAt() time.Time {
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
if !d.sessionExpiresAt.IsZero() && d.sessionExpiresAt.Before(time.Now()) {
|
||||
return time.Time{}
|
||||
}
|
||||
return d.sessionExpiresAt
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,11 @@ type ConfigInput struct {
|
||||
DNSLabels domain.List
|
||||
|
||||
MTU *uint16
|
||||
|
||||
// Owners replaces the profile's owner principal list when non-nil.
|
||||
// Shared replaces the profile's shared flag when non-nil.
|
||||
Owners []string
|
||||
Shared *bool
|
||||
}
|
||||
|
||||
// Config Configuration type
|
||||
@@ -184,6 +189,17 @@ type Config struct {
|
||||
|
||||
MTU uint16
|
||||
|
||||
// Owners lists the principals allowed to control this profile over the local
|
||||
// IPC, as typed strings: "uid:1000", "gid:1000", "group:netbird-admins"
|
||||
// (Unix, NSS-resolved) or "sid:S-1-5-..." (Windows user or group SID). Empty
|
||||
// with Shared=false means the profile is owned by nobody yet (privileged +
|
||||
// daemon-self only, until claimed). Interpreted by client/internal/ipcauth.
|
||||
Owners []string `json:"Owners,omitempty"`
|
||||
|
||||
// Shared, when true, lets any authenticated local caller control this profile
|
||||
// (opt-in). Takes precedence over Owners.
|
||||
Shared bool `json:"Shared,omitempty"`
|
||||
|
||||
// policy is the MDM policy that produced the currently-set values for
|
||||
// any MDM-enforced fields. Set by applyMDMPolicy at the tail of apply()
|
||||
// and reset on every apply() invocation. Never persisted to disk.
|
||||
@@ -642,6 +658,18 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.Owners != nil && !slices.Equal(config.Owners, input.Owners) {
|
||||
log.Infof("updating profile owners to %v", input.Owners)
|
||||
config.Owners = input.Owners
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.Shared != nil && *input.Shared != config.Shared {
|
||||
log.Infof("updating profile shared flag to %t", *input.Shared)
|
||||
config.Shared = *input.Shared
|
||||
updated = true
|
||||
}
|
||||
|
||||
// MDM is the last override layer: any key present in the policy
|
||||
// supersedes defaults, on-disk config, env vars and CLI input.
|
||||
config.applyMDMPolicy(loadMDMPolicy())
|
||||
|
||||
@@ -296,7 +296,7 @@ func (s *ServiceManager) DefaultProfilePath() string {
|
||||
// The returned Profile carries the freshly-generated ID so callers can
|
||||
// show it to the user (and so the gRPC AddProfileResponse can include
|
||||
// it).
|
||||
func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, error) {
|
||||
func (s *ServiceManager) AddProfile(displayName, username string, owners []string) (*Profile, error) {
|
||||
configDir, err := s.getConfigDir(username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get config directory: %w", err)
|
||||
@@ -318,6 +318,9 @@ func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, err
|
||||
return nil, fmt.Errorf("failed to create new config: %w", err)
|
||||
}
|
||||
cfg.Name = displayName
|
||||
// owners auto-isolates the new profile to its creator (nil = unowned, so the
|
||||
// eventual first caller claims it via trust-on-first-use).
|
||||
cfg.Owners = owners
|
||||
|
||||
if err := util.WriteJson(context.Background(), profPath, cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to write profile config: %w", err)
|
||||
|
||||
@@ -32,7 +32,7 @@ func withTestSM(t *testing.T, fn func(sm *ServiceManager, username string)) {
|
||||
|
||||
func TestServiceProfile_ExactID(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
created, err := sm.AddProfile("work", username)
|
||||
created, err := sm.AddProfile("work", username, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := sm.ResolveProfile(created.ID.String(), username)
|
||||
@@ -44,7 +44,7 @@ func TestServiceProfile_ExactID(t *testing.T) {
|
||||
|
||||
func TestServiceProfile_IDPrefix(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
created, err := sm.AddProfile("work", username)
|
||||
created, err := sm.AddProfile("work", username, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
prefix := created.ID[:4]
|
||||
@@ -75,7 +75,7 @@ func TestServiceProfile_AmbiguousPrefix(t *testing.T) {
|
||||
|
||||
func TestServiceProfile_ExactNameUnique(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
_, err := sm.AddProfile("work", username)
|
||||
_, err := sm.AddProfile("work", username, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := sm.ResolveProfile("work", username)
|
||||
@@ -86,9 +86,9 @@ func TestServiceProfile_ExactNameUnique(t *testing.T) {
|
||||
|
||||
func TestServiceProfile_AmbiguousName(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
_, err := sm.AddProfile("work", username)
|
||||
_, err := sm.AddProfile("work", username, nil)
|
||||
require.NoError(t, err)
|
||||
_, err = sm.AddProfile("work", username)
|
||||
_, err = sm.AddProfile("work", username, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = sm.ResolveProfile("work", username)
|
||||
@@ -133,10 +133,10 @@ func TestServiceProfile_LegacyFilenameCoexists(t *testing.T) {
|
||||
|
||||
func TestAddProfile_AllowsDuplicateWithFlag(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
first, err := sm.AddProfile("work", username)
|
||||
first, err := sm.AddProfile("work", username, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
second, err := sm.AddProfile("work", username)
|
||||
second, err := sm.AddProfile("work", username, nil)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, first.ID, second.ID)
|
||||
assert.Equal(t, "work", second.Name)
|
||||
@@ -151,7 +151,7 @@ func TestAddProfile_RejectsInvalidNames(t *testing.T) {
|
||||
strings.Repeat("a", maxProfileNameLen+1), // too long
|
||||
}
|
||||
for _, name := range cases {
|
||||
_, err := sm.AddProfile(name, username)
|
||||
_, err := sm.AddProfile(name, username, nil)
|
||||
assert.Error(t, err, "expected error for %q", name)
|
||||
}
|
||||
})
|
||||
@@ -215,7 +215,7 @@ func TestIsValidProfileFilenameStem(t *testing.T) {
|
||||
|
||||
func TestRemoveProfile_DeletesStateFile(t *testing.T) {
|
||||
withTestSM(t, func(sm *ServiceManager, username string) {
|
||||
created, err := sm.AddProfile("work", username)
|
||||
created, err := sm.AddProfile("work", username, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
configDir, err := sm.getConfigDir(username)
|
||||
|
||||
@@ -185,7 +185,7 @@ func (r *Route) startResolver(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (r *Route) update(ctx context.Context) error {
|
||||
resolved, err := r.resolveDomains()
|
||||
resolved, err := r.resolveDomains(ctx)
|
||||
if err != nil {
|
||||
if len(resolved) == 0 {
|
||||
return fmt.Errorf("resolve domains: %w", err)
|
||||
@@ -199,9 +199,9 @@ func (r *Route) update(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Route) resolveDomains() (domainMap, error) {
|
||||
func (r *Route) resolveDomains(ctx context.Context) (domainMap, error) {
|
||||
results := make(chan resolveResult)
|
||||
go r.resolve(results)
|
||||
go r.resolve(ctx, results)
|
||||
|
||||
resolved := domainMap{}
|
||||
var merr *multierror.Error
|
||||
@@ -217,7 +217,7 @@ func (r *Route) resolveDomains() (domainMap, error) {
|
||||
return resolved, nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
|
||||
func (r *Route) resolve(results chan resolveResult) {
|
||||
func (r *Route) resolve(ctx context.Context, results chan resolveResult) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, d := range r.route.Domains {
|
||||
@@ -225,10 +225,10 @@ func (r *Route) resolve(results chan resolveResult) {
|
||||
go func(domain domain.Domain) {
|
||||
defer wg.Done()
|
||||
|
||||
ips, err := r.getIPsFromResolver(domain)
|
||||
ips, err := r.getIPsFromResolver(ctx, domain)
|
||||
if err != nil {
|
||||
log.Tracef("Failed to resolve domain %s with private resolver: %v", domain.SafeString(), err)
|
||||
ips, err = net.LookupIP(domain.PunycodeString())
|
||||
ips, err = lookupHostIPs(ctx, domain)
|
||||
if err != nil {
|
||||
results <- resolveResult{domain: domain, err: fmt.Errorf("resolve d %s: %w", domain.SafeString(), err)}
|
||||
return
|
||||
@@ -364,6 +364,20 @@ func determinePrefixChanges(oldPrefixes, newPrefixes []netip.Prefix) (toAdd, toR
|
||||
return
|
||||
}
|
||||
|
||||
// lookupHostIPs resolves d via the system resolver, honoring ctx cancellation.
|
||||
func lookupHostIPs(ctx context.Context, d domain.Domain) ([]net.IP, error) {
|
||||
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, d.PunycodeString())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ips := make([]net.IP, 0, len(addrs))
|
||||
for _, addr := range addrs {
|
||||
ips = append(ips, addr.IP)
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
func combinePrefixes(oldPrefixes, removedPrefixes, addedPrefixes []netip.Prefix) []netip.Prefix {
|
||||
prefixSet := make(map[netip.Prefix]struct{})
|
||||
for _, prefix := range oldPrefixes {
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
package dynamic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
|
||||
return net.LookupIP(domain.PunycodeString())
|
||||
func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) {
|
||||
return lookupHostIPs(ctx, domain)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package dynamic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
@@ -16,7 +17,7 @@ import (
|
||||
|
||||
const dialTimeout = 10 * time.Second
|
||||
|
||||
func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
|
||||
func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) {
|
||||
privateClient, err := nbdns.GetClientPrivate(r.wgInterface, r.resolverAddr.Addr(), dialTimeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error while creating private client: %s", err)
|
||||
@@ -32,7 +33,7 @@ func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(fqdn, qtype)
|
||||
|
||||
response, _, err := nbdns.ExchangeWithFallback(nil, privateClient, msg, r.resolverAddr.String())
|
||||
response, _, err := nbdns.ExchangeWithFallback(ctx, privateClient, msg, r.resolverAddr.String())
|
||||
if err != nil {
|
||||
if queryErr == nil {
|
||||
queryErr = fmt.Errorf("DNS query for %s (type %d) after %s: %w", domain.SafeString(), qtype, time.Since(startTime), err)
|
||||
|
||||
29
client/internal/shell/getent_cgo_unix.go
Normal file
29
client/internal/shell/getent_cgo_unix.go
Normal file
@@ -0,0 +1,29 @@
|
||||
//go:build cgo && !osusergo && !windows
|
||||
|
||||
package shell
|
||||
|
||||
import "os/user"
|
||||
|
||||
// LookupWithGetent with CGO delegates directly to os/user.Lookup.
|
||||
// When CGO is enabled, os/user uses libc (getpwnam_r) which goes through
|
||||
// the NSS stack natively. If it fails, the user truly doesn't exist and
|
||||
// getent would also fail.
|
||||
func LookupWithGetent(username string) (*user.User, error) {
|
||||
return user.Lookup(username)
|
||||
}
|
||||
|
||||
// CurrentUserWithGetent with CGO delegates directly to os/user.Current.
|
||||
func CurrentUserWithGetent() (*user.User, error) {
|
||||
return user.Current()
|
||||
}
|
||||
|
||||
// LookupGroupWithGetent returns the resolved group from either a gid or groupname.
|
||||
func LookupGroupWithGetent(name string) (*user.Group, error) {
|
||||
return user.LookupGroup(name)
|
||||
}
|
||||
|
||||
// GroupIdsWithFallback with CGO delegates directly to user.GroupIds.
|
||||
// libc's getgrouplist handles NSS groups natively.
|
||||
func GroupIdsWithFallback(u *user.User) ([]string, error) {
|
||||
return u.GroupIds()
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build (!cgo || osusergo) && !windows
|
||||
|
||||
package server
|
||||
package shell
|
||||
|
||||
import (
|
||||
"os"
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// lookupWithGetent looks up a user by name, falling back to getent if os/user fails.
|
||||
// LookupWithGetent looks up a user by name, falling back to getent if os/user fails.
|
||||
// Without CGO, os/user only reads /etc/passwd and misses NSS-provided users.
|
||||
// getent goes through the host's NSS stack.
|
||||
func lookupWithGetent(username string) (*user.User, error) {
|
||||
func LookupWithGetent(username string) (*user.User, error) {
|
||||
u, err := user.Lookup(username)
|
||||
if err == nil {
|
||||
return u, nil
|
||||
@@ -22,7 +22,7 @@ func lookupWithGetent(username string) (*user.User, error) {
|
||||
stdErr := err
|
||||
log.Debugf("os/user.Lookup(%q) failed, trying getent: %v", username, err)
|
||||
|
||||
u, _, getentErr := runGetent(username)
|
||||
u, _, getentErr := runGetentPasswd(username)
|
||||
if getentErr != nil {
|
||||
log.Debugf("getent fallback for %q also failed: %v", username, getentErr)
|
||||
return nil, stdErr
|
||||
@@ -31,8 +31,26 @@ func lookupWithGetent(username string) (*user.User, error) {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// currentUserWithGetent gets the current user, falling back to getent if os/user fails.
|
||||
func currentUserWithGetent() (*user.User, error) {
|
||||
// LookupGroupWithGetent returns the resolved group from either a gid or groupname,
|
||||
// falling back to getent if os/user fails (NSS groups under nocgo).
|
||||
func LookupGroupWithGetent(name string) (*user.Group, error) {
|
||||
g, err := user.LookupGroup(name)
|
||||
if err == nil {
|
||||
return g, nil
|
||||
}
|
||||
|
||||
stdErr := err
|
||||
log.Debugf("os/user.LookupGroup(%q) failed, trying getent: %v", name, err)
|
||||
g, getentErr := runGetentGroup(name)
|
||||
if getentErr != nil {
|
||||
log.Debugf("getent fallback for %q also failed: %v", name, getentErr)
|
||||
return nil, stdErr
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// CurrentUserWithGetent gets the current user, falling back to getent if os/user fails.
|
||||
func CurrentUserWithGetent() (*user.User, error) {
|
||||
u, err := user.Current()
|
||||
if err == nil {
|
||||
return u, nil
|
||||
@@ -42,7 +60,7 @@ func currentUserWithGetent() (*user.User, error) {
|
||||
uid := strconv.Itoa(os.Getuid())
|
||||
log.Debugf("os/user.Current() failed, trying getent with UID %s: %v", uid, err)
|
||||
|
||||
u, _, getentErr := runGetent(uid)
|
||||
u, _, getentErr := runGetentPasswd(uid)
|
||||
if getentErr != nil {
|
||||
return nil, stdErr
|
||||
}
|
||||
@@ -50,14 +68,14 @@ func currentUserWithGetent() (*user.User, error) {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// groupIdsWithFallback gets group IDs for a user via the id command first,
|
||||
// GroupIdsWithFallback gets group IDs for a user via the id command first,
|
||||
// falling back to user.GroupIds().
|
||||
// NOTE: unlike lookupWithGetent/currentUserWithGetent which try stdlib first,
|
||||
// NOTE: unlike LookupWithGetent/CurrentUserWithGetent which try stdlib first,
|
||||
// this intentionally tries `id -G` first because without CGO, user.GroupIds()
|
||||
// only reads /etc/group and silently returns incomplete results for NSS users
|
||||
// (no error, just missing groups). The id command goes through NSS and returns
|
||||
// the full set.
|
||||
func groupIdsWithFallback(u *user.User) ([]string, error) {
|
||||
func GroupIdsWithFallback(u *user.User) ([]string, error) {
|
||||
ids, err := runIdGroups(u.Username)
|
||||
if err == nil {
|
||||
return ids, nil
|
||||
@@ -1,4 +1,4 @@
|
||||
package server
|
||||
package shell
|
||||
|
||||
import (
|
||||
"os/user"
|
||||
@@ -15,7 +15,7 @@ func TestLookupWithGetent_CurrentUser(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
u, err := lookupWithGetent(current.Username)
|
||||
u, err := LookupWithGetent(current.Username)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, current.Username, u.Username)
|
||||
assert.Equal(t, current.Uid, u.Uid)
|
||||
@@ -23,7 +23,7 @@ func TestLookupWithGetent_CurrentUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLookupWithGetent_NonexistentUser(t *testing.T) {
|
||||
_, err := lookupWithGetent("nonexistent_user_xyzzy_12345")
|
||||
_, err := LookupWithGetent("nonexistent_user_xyzzy_12345")
|
||||
require.Error(t, err, "should fail for nonexistent user")
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestCurrentUserWithGetent(t *testing.T) {
|
||||
stdUser, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
u, err := currentUserWithGetent()
|
||||
u, err := CurrentUserWithGetent()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, stdUser.Uid, u.Uid)
|
||||
assert.Equal(t, stdUser.Username, u.Username)
|
||||
@@ -41,7 +41,7 @@ func TestGroupIdsWithFallback_CurrentUser(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
groups, err := groupIdsWithFallback(current)
|
||||
groups, err := GroupIdsWithFallback(current)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, groups, "current user should have at least one group")
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestGroupIdsWithFallback_CurrentUser(t *testing.T) {
|
||||
func TestGetShellFromGetent_CurrentUser(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Windows stub always returns empty, which is correct
|
||||
shell := getShellFromGetent("1000")
|
||||
shell := GetShellFromGetent("1000")
|
||||
assert.Empty(t, shell, "Windows stub should return empty")
|
||||
return
|
||||
}
|
||||
@@ -65,9 +65,9 @@ func TestGetShellFromGetent_CurrentUser(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// getent may not be available on all systems (e.g., macOS without Homebrew getent)
|
||||
shell := getShellFromGetent(current.Uid)
|
||||
shell := GetShellFromGetent(current.Uid)
|
||||
if shell == "" {
|
||||
t.Log("getShellFromGetent returned empty, getent may not be available")
|
||||
t.Log("GetShellFromGetent returned empty, getent may not be available")
|
||||
return
|
||||
}
|
||||
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
|
||||
@@ -78,7 +78,7 @@ func TestLookupWithGetent_RootUser(t *testing.T) {
|
||||
t.Skip("no root user on Windows")
|
||||
}
|
||||
|
||||
u, err := lookupWithGetent("root")
|
||||
u, err := LookupWithGetent("root")
|
||||
if err != nil {
|
||||
t.Skip("root user not available on this system")
|
||||
}
|
||||
@@ -86,25 +86,25 @@ func TestLookupWithGetent_RootUser(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestIntegration_FullLookupChain exercises the complete user lookup chain
|
||||
// against the real system, testing that all wrappers (lookupWithGetent,
|
||||
// currentUserWithGetent, groupIdsWithFallback, getShellFromGetent) produce
|
||||
// against the real system, testing that all wrappers (LookupWithGetent,
|
||||
// CurrentUserWithGetent, GroupIdsWithFallback, GetShellFromGetent) produce
|
||||
// consistent and correct results when composed together.
|
||||
func TestIntegration_FullLookupChain(t *testing.T) {
|
||||
// Step 1: currentUserWithGetent must resolve the running user.
|
||||
current, err := currentUserWithGetent()
|
||||
require.NoError(t, err, "currentUserWithGetent must resolve the running user")
|
||||
// Step 1: CurrentUserWithGetent must resolve the running user.
|
||||
current, err := CurrentUserWithGetent()
|
||||
require.NoError(t, err, "CurrentUserWithGetent must resolve the running user")
|
||||
require.NotEmpty(t, current.Uid)
|
||||
require.NotEmpty(t, current.Username)
|
||||
|
||||
// Step 2: lookupWithGetent by the same username must return matching identity.
|
||||
byName, err := lookupWithGetent(current.Username)
|
||||
// Step 2: LookupWithGetent by the same username must return matching identity.
|
||||
byName, err := LookupWithGetent(current.Username)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, current.Uid, byName.Uid, "lookup by name should return same UID")
|
||||
assert.Equal(t, current.Gid, byName.Gid, "lookup by name should return same GID")
|
||||
assert.Equal(t, current.HomeDir, byName.HomeDir, "lookup by name should return same home")
|
||||
|
||||
// Step 3: groupIdsWithFallback must return at least the primary GID.
|
||||
groups, err := groupIdsWithFallback(current)
|
||||
// Step 3: GroupIdsWithFallback must return at least the primary GID.
|
||||
groups, err := GroupIdsWithFallback(current)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, groups, "user must have at least one group")
|
||||
|
||||
@@ -120,10 +120,10 @@ func TestIntegration_FullLookupChain(t *testing.T) {
|
||||
}
|
||||
assert.True(t, foundPrimary, "primary GID %s should appear in supplementary groups", current.Gid)
|
||||
|
||||
// Step 4: getShellFromGetent should either return a valid shell path or empty
|
||||
// Step 4: GetShellFromGetent should either return a valid shell path or empty
|
||||
// (empty is OK when getent is not available, e.g. macOS without Homebrew getent).
|
||||
if runtime.GOOS != "windows" {
|
||||
shell := getShellFromGetent(current.Uid)
|
||||
shell := GetShellFromGetent(current.Uid)
|
||||
if shell != "" {
|
||||
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
|
||||
}
|
||||
@@ -131,17 +131,17 @@ func TestIntegration_FullLookupChain(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestIntegration_LookupAndGroupsConsistency verifies that a user resolved via
|
||||
// lookupWithGetent can have their groups resolved via groupIdsWithFallback,
|
||||
// LookupWithGetent can have their groups resolved via GroupIdsWithFallback,
|
||||
// testing the handoff between the two functions as used by the SSH server.
|
||||
func TestIntegration_LookupAndGroupsConsistency(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Simulate the SSH server flow: lookup user, then get their groups.
|
||||
resolved, err := lookupWithGetent(current.Username)
|
||||
resolved, err := LookupWithGetent(current.Username)
|
||||
require.NoError(t, err)
|
||||
|
||||
groups, err := groupIdsWithFallback(resolved)
|
||||
groups, err := GroupIdsWithFallback(resolved)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, groups, "resolved user must have groups")
|
||||
|
||||
@@ -156,7 +156,7 @@ func TestIntegration_LookupAndGroupsConsistency(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestIntegration_ShellLookupChain tests the full shell resolution chain
|
||||
// (getShellFromPasswd -> getShellFromGetent -> $SHELL -> default) on Unix.
|
||||
// (getShellFromPasswd -> GetShellFromGetent -> $SHELL -> default) on Unix.
|
||||
func TestIntegration_ShellLookupChain(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Unix shell lookup not applicable on Windows")
|
||||
@@ -165,8 +165,8 @@ func TestIntegration_ShellLookupChain(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
// getUserShell is the top-level function used by the SSH server.
|
||||
shell := getUserShell(current.Uid)
|
||||
require.NotEmpty(t, shell, "getUserShell must always return a shell")
|
||||
// GetUserShell is the top-level function used by the SSH server.
|
||||
shell := GetUserShell(current.Uid)
|
||||
require.NotEmpty(t, shell, "GetUserShell must always return a shell")
|
||||
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build !windows
|
||||
|
||||
package server
|
||||
package shell
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -14,19 +14,26 @@ import (
|
||||
|
||||
const getentTimeout = 5 * time.Second
|
||||
|
||||
// getShellFromGetent gets a user's login shell via getent by UID.
|
||||
// GetShellFromGetent gets a user's login shell via getent by UID.
|
||||
// This is needed even with CGO because getShellFromPasswd reads /etc/passwd
|
||||
// directly and won't find NSS-provided users there.
|
||||
func getShellFromGetent(userID string) string {
|
||||
_, shell, err := runGetent(userID)
|
||||
func GetShellFromGetent(userID string) string {
|
||||
_, shell, err := runGetentPasswd(userID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return shell
|
||||
}
|
||||
|
||||
// runGetent executes `getent passwd <query>` and returns the user and login shell.
|
||||
func runGetent(query string) (*user.User, string, error) {
|
||||
// GetUserFromGetent returns the resolved user from either a uid or username,
|
||||
// going through the host's NSS stack.
|
||||
func GetUserFromGetent(query string) (*user.User, error) {
|
||||
u, _, err := runGetentPasswd(query)
|
||||
return u, err
|
||||
}
|
||||
|
||||
// runGetentPasswd executes `getent passwd <query>` and returns the user and login shell.
|
||||
func runGetentPasswd(query string) (*user.User, string, error) {
|
||||
if !validateGetentInput(query) {
|
||||
return nil, "", fmt.Errorf("invalid getent input: %q", query)
|
||||
}
|
||||
@@ -42,7 +49,24 @@ func runGetent(query string) (*user.User, string, error) {
|
||||
return parseGetentPasswd(string(out))
|
||||
}
|
||||
|
||||
// parseGetentPasswd parses getent passwd output: "name:x:uid:gid:gecos:home:shell"
|
||||
// runGetentGroup executes `getent group <query>` and returns the group.
|
||||
func runGetentGroup(query string) (*user.Group, error) {
|
||||
if !validateGetentInput(query) {
|
||||
return nil, fmt.Errorf("invalid getent input: %q", query)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), getentTimeout)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx, "getent", "group", query).Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getent group %s: %w", query, err)
|
||||
}
|
||||
|
||||
return parseGetentGroup(string(out))
|
||||
}
|
||||
|
||||
// parseGetentPasswd parses getent passwd output: "name:x:uid:gid:gecos:home:shell".
|
||||
func parseGetentPasswd(output string) (*user.User, string, error) {
|
||||
fields := strings.SplitN(strings.TrimSpace(output), ":", 8)
|
||||
if len(fields) < 6 {
|
||||
@@ -67,9 +91,24 @@ func parseGetentPasswd(output string) (*user.User, string, error) {
|
||||
}, shell, nil
|
||||
}
|
||||
|
||||
// parseGetentGroup parses getent group output: "group:x:gid:members".
|
||||
func parseGetentGroup(output string) (*user.Group, error) {
|
||||
fields := strings.SplitN(strings.TrimSpace(output), ":", 8)
|
||||
if len(fields) < 4 {
|
||||
return nil, fmt.Errorf("unexpected getent output (need 4+ fields): %q", output)
|
||||
}
|
||||
|
||||
if fields[0] == "" || fields[2] == "" {
|
||||
return nil, fmt.Errorf("missing required fields in getent output: %q", output)
|
||||
}
|
||||
|
||||
return &user.Group{Gid: fields[2], Name: fields[0]}, nil
|
||||
}
|
||||
|
||||
// validateGetentInput checks that the input is safe to pass to getent or id.
|
||||
// Allows POSIX usernames, numeric UIDs, and common NSS extensions
|
||||
// (@ for Kerberos, $ for Samba, + for NIS compat).
|
||||
// (@ for Kerberos, $ for Samba, + for NIS compat). A leading hyphen is
|
||||
// rejected so the input can never be parsed as a command-line flag.
|
||||
func validateGetentInput(input string) bool {
|
||||
maxLen := 32
|
||||
if runtime.GOOS == "linux" {
|
||||
@@ -80,6 +119,10 @@ func validateGetentInput(input string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
if input[0] == '-' {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, r := range input {
|
||||
if isAllowedGetentChar(r) {
|
||||
continue
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build !windows
|
||||
|
||||
package server
|
||||
package shell
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
@@ -157,6 +157,9 @@ func TestValidateGetentInput(t *testing.T) {
|
||||
{"numeric UID", "1001", true},
|
||||
{"dots and underscores", "alice.bob_test", true},
|
||||
{"hyphen", "alice-bob", true},
|
||||
{"leading hyphen rejected", "-i", false},
|
||||
{"leading double hyphen rejected", "--no-idn", false},
|
||||
{"lone hyphen rejected", "-", false},
|
||||
{"kerberos principal", "user@REALM", true},
|
||||
{"samba machine account", "MACHINE$", true},
|
||||
{"NIS compat", "+user", true},
|
||||
@@ -195,7 +198,7 @@ func TestRunGetent_RootUser(t *testing.T) {
|
||||
t.Skip("getent not available on this system")
|
||||
}
|
||||
|
||||
u, shell, err := runGetent("root")
|
||||
u, shell, err := runGetentPasswd("root")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "root", u.Username)
|
||||
assert.Equal(t, "0", u.Uid)
|
||||
@@ -208,7 +211,7 @@ func TestRunGetent_ByUID(t *testing.T) {
|
||||
t.Skip("getent not available on this system")
|
||||
}
|
||||
|
||||
u, _, err := runGetent("0")
|
||||
u, _, err := runGetentPasswd("0")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "root", u.Username)
|
||||
assert.Equal(t, "0", u.Uid)
|
||||
@@ -219,15 +222,15 @@ func TestRunGetent_NonexistentUser(t *testing.T) {
|
||||
t.Skip("getent not available on this system")
|
||||
}
|
||||
|
||||
_, _, err := runGetent("nonexistent_user_xyzzy_12345")
|
||||
_, _, err := runGetentPasswd("nonexistent_user_xyzzy_12345")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRunGetent_InvalidInput(t *testing.T) {
|
||||
_, _, err := runGetent("")
|
||||
_, _, err := runGetentPasswd("")
|
||||
assert.Error(t, err)
|
||||
|
||||
_, _, err = runGetent("user\x00name")
|
||||
_, _, err = runGetentPasswd("user\x00name")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -236,7 +239,7 @@ func TestRunGetent_NotAvailable(t *testing.T) {
|
||||
t.Skip("getent is available, can't test missing case")
|
||||
}
|
||||
|
||||
_, _, err := runGetent("root")
|
||||
_, _, err := runGetentPasswd("root")
|
||||
assert.Error(t, err, "should fail when getent is not installed")
|
||||
}
|
||||
|
||||
@@ -283,7 +286,7 @@ func TestGetentResultsMatchStdlib(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
getentUser, _, err := runGetent(current.Username)
|
||||
getentUser, _, err := runGetentPasswd(current.Username)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, current.Username, getentUser.Username, "username should match")
|
||||
@@ -300,7 +303,7 @@ func TestGetentResultsMatchStdlib_ByUID(t *testing.T) {
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
|
||||
getentUser, _, err := runGetent(current.Uid)
|
||||
getentUser, _, err := runGetentPasswd(current.Uid)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, current.Username, getentUser.Username, "username should match when looked up by UID")
|
||||
@@ -356,7 +359,7 @@ func TestGetShellFromPasswd_CurrentUser(t *testing.T) {
|
||||
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
|
||||
|
||||
if _, err := exec.LookPath("getent"); err == nil {
|
||||
_, getentShell, getentErr := runGetent(current.Uid)
|
||||
_, getentShell, getentErr := runGetentPasswd(current.Uid)
|
||||
if getentErr == nil && getentShell != "" {
|
||||
assert.Equal(t, getentShell, shell, "shell from /etc/passwd should match getent")
|
||||
}
|
||||
@@ -400,7 +403,7 @@ func TestGetShellFromPasswd_MatchesGetentForKnownUsers(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
_, getentShell, err := runGetent(uid)
|
||||
_, getentShell, err := runGetentPasswd(uid)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
31
client/internal/shell/getent_windows.go
Normal file
31
client/internal/shell/getent_windows.go
Normal file
@@ -0,0 +1,31 @@
|
||||
//go:build windows
|
||||
|
||||
package shell
|
||||
|
||||
import "os/user"
|
||||
|
||||
// LookupWithGetent on Windows just delegates to os/user.Lookup.
|
||||
// Windows does not use NSS/getent; its user lookup works without CGO.
|
||||
func LookupWithGetent(username string) (*user.User, error) {
|
||||
return user.Lookup(username)
|
||||
}
|
||||
|
||||
// CurrentUserWithGetent on Windows just delegates to os/user.Current.
|
||||
func CurrentUserWithGetent() (*user.User, error) {
|
||||
return user.Current()
|
||||
}
|
||||
|
||||
// LookupGroupWithGetent on Windows just delegates to os/user.LookupGroup.
|
||||
func LookupGroupWithGetent(name string) (*user.Group, error) {
|
||||
return user.LookupGroup(name)
|
||||
}
|
||||
|
||||
// GetShellFromGetent is a no-op on Windows; shell resolution uses PowerShell detection.
|
||||
func GetShellFromGetent(_ string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// GroupIdsWithFallback on Windows just delegates to u.GroupIds().
|
||||
func GroupIdsWithFallback(u *user.User) ([]string, error) {
|
||||
return u.GroupIds()
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
package server
|
||||
package shell
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -22,9 +19,9 @@ const (
|
||||
powershellExe = "powershell.exe"
|
||||
)
|
||||
|
||||
// getUserShell returns the appropriate shell for the given user ID
|
||||
// Handles all platform-specific logic and fallbacks consistently
|
||||
func getUserShell(userID string) string {
|
||||
// GetUserShell returns the appropriate shell for the given user ID.
|
||||
// Handles all platform-specific logic and fallbacks consistently.
|
||||
func GetUserShell(userID string) string {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return getWindowsUserShell()
|
||||
@@ -56,7 +53,7 @@ func getUnixUserShell(userID string) string {
|
||||
return shell
|
||||
}
|
||||
|
||||
if shell := getShellFromGetent(userID); shell != "" {
|
||||
if shell := GetShellFromGetent(userID); shell != "" {
|
||||
return shell
|
||||
}
|
||||
|
||||
@@ -67,7 +64,7 @@ func getUnixUserShell(userID string) string {
|
||||
return defaultUnixShell
|
||||
}
|
||||
|
||||
// getShellFromPasswd reads the shell from /etc/passwd for the given user ID
|
||||
// getShellFromPasswd reads the shell from /etc/passwd for the given user ID.
|
||||
func getShellFromPasswd(userID string) string {
|
||||
file, err := os.Open("/etc/passwd")
|
||||
if err != nil {
|
||||
@@ -101,8 +98,8 @@ func getShellFromPasswd(userID string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// prepareUserEnv prepares environment variables for user execution
|
||||
func prepareUserEnv(user *user.User, shell string) []string {
|
||||
// PrepareUserEnv prepares environment variables for user execution.
|
||||
func PrepareUserEnv(user *user.User, shell string) []string {
|
||||
pathValue := "/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games"
|
||||
if runtime.GOOS == "windows" {
|
||||
pathValue = `C:\Windows\System32;C:\Windows;C:\Windows\System32\Wbem;C:\Windows\System32\WindowsPowerShell\v1.0`
|
||||
@@ -117,9 +114,9 @@ func prepareUserEnv(user *user.User, shell string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
// acceptEnv checks if environment variable from SSH client should be accepted
|
||||
// This is a whitelist of variables that SSH clients can send to the server
|
||||
func acceptEnv(envVar string) bool {
|
||||
// AcceptEnv checks if an environment variable from an SSH client should be accepted.
|
||||
// This is a whitelist of variables that SSH clients can send to the server.
|
||||
func AcceptEnv(envVar string) bool {
|
||||
varName := envVar
|
||||
if idx := strings.Index(envVar, "="); idx != -1 {
|
||||
varName = envVar[:idx]
|
||||
@@ -156,29 +153,3 @@ func acceptEnv(envVar string) bool {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// prepareSSHEnv prepares SSH protocol-specific environment variables
|
||||
// These variables provide information about the SSH connection itself
|
||||
func prepareSSHEnv(session ssh.Session) []string {
|
||||
remoteAddr := session.RemoteAddr()
|
||||
localAddr := session.LocalAddr()
|
||||
|
||||
remoteHost, remotePort, err := net.SplitHostPort(remoteAddr.String())
|
||||
if err != nil {
|
||||
remoteHost = remoteAddr.String()
|
||||
remotePort = "0"
|
||||
}
|
||||
|
||||
localHost, localPort, err := net.SplitHostPort(localAddr.String())
|
||||
if err != nil {
|
||||
localHost = localAddr.String()
|
||||
localPort = strconv.Itoa(InternalSSHPort)
|
||||
}
|
||||
|
||||
return []string{
|
||||
// SSH_CLIENT format: "client_ip client_port server_port"
|
||||
fmt.Sprintf("SSH_CLIENT=%s %s %s", remoteHost, remotePort, localPort),
|
||||
// SSH_CONNECTION format: "client_ip client_port server_ip server_port"
|
||||
fmt.Sprintf("SSH_CONNECTION=%s %s %s %s", remoteHost, remotePort, localHost, localPort),
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package statemanager
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -305,6 +306,11 @@ func (m *Manager) loadStateFile(deleteCorrupt bool) (map[string]json.RawMessage,
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -233,6 +233,9 @@ func (c *Client) DebugBundle(anonymize bool) (string, error) {
|
||||
deps.SyncResponse = resp
|
||||
|
||||
if e := cc.Engine(); e != nil {
|
||||
deps.RefreshStatus = func() {
|
||||
e.RunHealthProbes(context.Background(), true)
|
||||
}
|
||||
if cm := e.GetClientMetrics(); cm != nil {
|
||||
deps.ClientMetrics = cm
|
||||
}
|
||||
|
||||
@@ -1,27 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs the NetBird daemon and brings the connection up in one container process.
|
||||
#
|
||||
# A thin wrapper is needed (rather than a one-line ENTRYPOINT) for two reasons:
|
||||
# 1. Two processes must run: the daemon (`service run`, long-lived) and a
|
||||
# one-shot `up` that brings the connection up.
|
||||
# 2. Signal handling: as PID 1 the wrapper must forward SIGTERM/SIGINT to the
|
||||
# daemon so it tears down WireGuard and deregisters ephemeral peers on
|
||||
# `docker stop`. Without this the daemon would be killed uncleanly.
|
||||
#
|
||||
# `netbird up` waits for the daemon to become ready on its own, so no readiness
|
||||
# poll is needed here.
|
||||
set -eEuo pipefail
|
||||
|
||||
: ${NB_ENTRYPOINT_SERVICE_TIMEOUT:="30"}
|
||||
NETBIRD_BIN="${NETBIRD_BIN:-"netbird"}"
|
||||
export NB_LOG_FILE="${NB_LOG_FILE:-"console,/var/log/netbird/client.log"}"
|
||||
service_pids=()
|
||||
|
||||
daemon=""
|
||||
cleanup() { [[ -n "${daemon}" ]] && kill -TERM "${daemon}" 2>/dev/null || true; }
|
||||
trap cleanup SIGTERM SIGINT EXIT
|
||||
_log() {
|
||||
# mimic Go logger's output for easier parsing
|
||||
# 2025-04-15T21:32:00+08:00 INFO client/internal/config.go:495: setting notifications to disabled by default
|
||||
printf "$(date -Isec) ${1} ${BASH_SOURCE[1]}:${BASH_LINENO[1]}: ${2}\n" "${@:3}" >&2
|
||||
}
|
||||
|
||||
"${NETBIRD_BIN}" service run &
|
||||
daemon=$!
|
||||
info() {
|
||||
_log INFO "$@"
|
||||
}
|
||||
|
||||
"${NETBIRD_BIN}" up
|
||||
warn() {
|
||||
_log WARN "$@"
|
||||
}
|
||||
|
||||
wait "${daemon}"
|
||||
on_exit() {
|
||||
info "Shutting down NetBird daemon..."
|
||||
if test "${#service_pids[@]}" -gt 0; then
|
||||
info "terminating service process IDs: ${service_pids[@]@Q}"
|
||||
kill -TERM "${service_pids[@]}" 2>/dev/null || true
|
||||
wait "${service_pids[@]}" 2>/dev/null || true
|
||||
else
|
||||
info "there are no service processes to terminate"
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_daemon_startup() {
|
||||
local timeout="${1}"
|
||||
if [[ "${timeout}" -eq 0 ]]; then
|
||||
info "not waiting for daemon startup due to zero timeout."
|
||||
return
|
||||
fi
|
||||
|
||||
local deadline=$((SECONDS + timeout))
|
||||
while [[ "${SECONDS}" -lt "${deadline}" ]]; do
|
||||
if "${NETBIRD_BIN}" status --check live 2>/dev/null; then
|
||||
return
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
warn "daemon did not become responsive after ${timeout} seconds, exiting..."
|
||||
exit 1
|
||||
}
|
||||
|
||||
connect() {
|
||||
info "running 'netbird up'..."
|
||||
"${NETBIRD_BIN}" up
|
||||
return $?
|
||||
}
|
||||
|
||||
main() {
|
||||
trap 'on_exit' SIGTERM SIGINT EXIT
|
||||
"${NETBIRD_BIN}" service run &
|
||||
service_pids+=("$!")
|
||||
info "registered new service process 'netbird service run', currently running: ${service_pids[@]@Q}"
|
||||
|
||||
wait_for_daemon_startup "${NB_ENTRYPOINT_SERVICE_TIMEOUT}"
|
||||
connect
|
||||
|
||||
wait "${service_pids[@]}"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -791,6 +791,78 @@ func local_request_DaemonService_GetActiveProfile_0(ctx context.Context, marshal
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_DaemonService_AddOwner_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq AddOwnerRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := client.AddOwner(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_DaemonService_AddOwner_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq AddOwnerRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := server.AddOwner(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_DaemonService_ResetOwner_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq ResetOwnerRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := client.ResetOwner(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_DaemonService_ResetOwner_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq ResetOwnerRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := server.ResetOwner(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_DaemonService_ShareProfile_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq ShareProfileRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := client.ShareProfile(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func local_request_DaemonService_ShareProfile_0(ctx context.Context, marshaler runtime.Marshaler, server DaemonServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq ShareProfileRequest
|
||||
metadata runtime.ServerMetadata
|
||||
)
|
||||
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
msg, err := server.ShareProfile(ctx, &protoReq)
|
||||
return msg, metadata, err
|
||||
}
|
||||
|
||||
func request_DaemonService_Logout_0(ctx context.Context, marshaler runtime.Marshaler, client DaemonServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
|
||||
var (
|
||||
protoReq LogoutRequest
|
||||
@@ -1730,6 +1802,66 @@ func RegisterDaemonServiceHandlerServer(ctx context.Context, mux *runtime.ServeM
|
||||
}
|
||||
forward_DaemonService_GetActiveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_AddOwner_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/AddOwner", runtime.WithHTTPPathPattern("/daemon.DaemonService/AddOwner"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_DaemonService_AddOwner_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_AddOwner_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_ResetOwner_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/ResetOwner", runtime.WithHTTPPathPattern("/daemon.DaemonService/ResetOwner"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_DaemonService_ResetOwner_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_ResetOwner_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_ShareProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
var stream runtime.ServerTransportStream
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/daemon.DaemonService/ShareProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/ShareProfile"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := local_request_DaemonService_ShareProfile_0(annotatedContext, inboundMarshaler, server, req, pathParams)
|
||||
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_ShareProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_Logout_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
@@ -2581,6 +2713,57 @@ func RegisterDaemonServiceHandlerClient(ctx context.Context, mux *runtime.ServeM
|
||||
}
|
||||
forward_DaemonService_GetActiveProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_AddOwner_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/AddOwner", runtime.WithHTTPPathPattern("/daemon.DaemonService/AddOwner"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_DaemonService_AddOwner_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_AddOwner_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_ResetOwner_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ResetOwner", runtime.WithHTTPPathPattern("/daemon.DaemonService/ResetOwner"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_DaemonService_ResetOwner_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_ResetOwner_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_ShareProfile_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
|
||||
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/daemon.DaemonService/ShareProfile", runtime.WithHTTPPathPattern("/daemon.DaemonService/ShareProfile"))
|
||||
if err != nil {
|
||||
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
resp, md, err := request_DaemonService_ShareProfile_0(annotatedContext, inboundMarshaler, client, req, pathParams)
|
||||
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
|
||||
if err != nil {
|
||||
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
|
||||
return
|
||||
}
|
||||
forward_DaemonService_ShareProfile_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
|
||||
})
|
||||
mux.Handle(http.MethodPost, pattern_DaemonService_Logout_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
defer cancel()
|
||||
@@ -2855,6 +3038,9 @@ var (
|
||||
pattern_DaemonService_RemoveProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "RemoveProfile"}, ""))
|
||||
pattern_DaemonService_ListProfiles_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ListProfiles"}, ""))
|
||||
pattern_DaemonService_GetActiveProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetActiveProfile"}, ""))
|
||||
pattern_DaemonService_AddOwner_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "AddOwner"}, ""))
|
||||
pattern_DaemonService_ResetOwner_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ResetOwner"}, ""))
|
||||
pattern_DaemonService_ShareProfile_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "ShareProfile"}, ""))
|
||||
pattern_DaemonService_Logout_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "Logout"}, ""))
|
||||
pattern_DaemonService_GetFeatures_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "GetFeatures"}, ""))
|
||||
pattern_DaemonService_TriggerUpdate_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"daemon.DaemonService", "TriggerUpdate"}, ""))
|
||||
@@ -2904,6 +3090,9 @@ var (
|
||||
forward_DaemonService_RemoveProfile_0 = runtime.ForwardResponseMessage
|
||||
forward_DaemonService_ListProfiles_0 = runtime.ForwardResponseMessage
|
||||
forward_DaemonService_GetActiveProfile_0 = runtime.ForwardResponseMessage
|
||||
forward_DaemonService_AddOwner_0 = runtime.ForwardResponseMessage
|
||||
forward_DaemonService_ResetOwner_0 = runtime.ForwardResponseMessage
|
||||
forward_DaemonService_ShareProfile_0 = runtime.ForwardResponseMessage
|
||||
forward_DaemonService_Logout_0 = runtime.ForwardResponseMessage
|
||||
forward_DaemonService_GetFeatures_0 = runtime.ForwardResponseMessage
|
||||
forward_DaemonService_TriggerUpdate_0 = runtime.ForwardResponseMessage
|
||||
|
||||
@@ -104,6 +104,18 @@ service DaemonService {
|
||||
|
||||
rpc GetActiveProfile(GetActiveProfileRequest) returns (GetActiveProfileResponse) {}
|
||||
|
||||
// AddOwner adds a principal to the active profile's owner list. Requires the
|
||||
// caller to be privileged (root/administrator) or an existing owner.
|
||||
rpc AddOwner(AddOwnerRequest) returns (AddOwnerResponse) {}
|
||||
|
||||
// ResetOwner clears the active profile's owner list, returning it to the
|
||||
// unowned state (next caller re-claims via trust-on-first-use). Privileged only.
|
||||
rpc ResetOwner(ResetOwnerRequest) returns (ResetOwnerResponse) {}
|
||||
|
||||
// ShareProfile marks the active profile shared (any local caller) or unshared.
|
||||
// Requires the caller to be an owner or privileged.
|
||||
rpc ShareProfile(ShareProfileRequest) returns (ShareProfileResponse) {}
|
||||
|
||||
// Logout disconnects from the network and deletes the peer from the management server
|
||||
rpc Logout(LogoutRequest) returns (LogoutResponse) {}
|
||||
|
||||
@@ -270,6 +282,10 @@ message UpRequest {
|
||||
// RPC blocks until the engine is running or gives up, which is the behaviour
|
||||
// needed by the CLI.
|
||||
bool async = 4;
|
||||
|
||||
// claimOwner, when true, claims ownership of the active profile for the
|
||||
// calling user (adds their kernel principal to the profile's owner list).
|
||||
bool claimOwner = 5;
|
||||
}
|
||||
|
||||
message UpResponse {}
|
||||
@@ -291,11 +307,6 @@ message StatusResponse{
|
||||
// Unset when the peer is not SSO-registered or login expiration is disabled.
|
||||
// The UI derives "warning active" from this value and its own clock.
|
||||
google.protobuf.Timestamp sessionExpiresAt = 4;
|
||||
// daemonReady reports whether the daemon has finished starting up and is
|
||||
// serving RPCs backed by a running engine. Older daemons never set this
|
||||
// (it defaults to false); clients must treat an unset value as "unknown"
|
||||
// and fall back to their previous readiness heuristics for compatibility.
|
||||
bool daemonReady = 5;
|
||||
}
|
||||
|
||||
message DownRequest {}
|
||||
@@ -779,6 +790,24 @@ message AddProfileResponse {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
message AddOwnerRequest {
|
||||
// principal is a typed owner string: "uid:1000", "gid:1000",
|
||||
// "group:netbird-admins" (Unix, NSS-resolved), or "sid:S-1-5-..." (Windows).
|
||||
string principal = 1;
|
||||
}
|
||||
|
||||
message AddOwnerResponse {}
|
||||
|
||||
message ResetOwnerRequest {}
|
||||
|
||||
message ResetOwnerResponse {}
|
||||
|
||||
message ShareProfileRequest {
|
||||
bool shared = 1;
|
||||
}
|
||||
|
||||
message ShareProfileResponse {}
|
||||
|
||||
message RenameProfileRequest {
|
||||
string username = 1;
|
||||
// handle: an exact ID, a unique ID prefix, or a unique display name.
|
||||
|
||||
@@ -51,6 +51,9 @@ const (
|
||||
DaemonService_RemoveProfile_FullMethodName = "/daemon.DaemonService/RemoveProfile"
|
||||
DaemonService_ListProfiles_FullMethodName = "/daemon.DaemonService/ListProfiles"
|
||||
DaemonService_GetActiveProfile_FullMethodName = "/daemon.DaemonService/GetActiveProfile"
|
||||
DaemonService_AddOwner_FullMethodName = "/daemon.DaemonService/AddOwner"
|
||||
DaemonService_ResetOwner_FullMethodName = "/daemon.DaemonService/ResetOwner"
|
||||
DaemonService_ShareProfile_FullMethodName = "/daemon.DaemonService/ShareProfile"
|
||||
DaemonService_Logout_FullMethodName = "/daemon.DaemonService/Logout"
|
||||
DaemonService_GetFeatures_FullMethodName = "/daemon.DaemonService/GetFeatures"
|
||||
DaemonService_TriggerUpdate_FullMethodName = "/daemon.DaemonService/TriggerUpdate"
|
||||
@@ -132,6 +135,15 @@ type DaemonServiceClient interface {
|
||||
RemoveProfile(ctx context.Context, in *RemoveProfileRequest, opts ...grpc.CallOption) (*RemoveProfileResponse, error)
|
||||
ListProfiles(ctx context.Context, in *ListProfilesRequest, opts ...grpc.CallOption) (*ListProfilesResponse, error)
|
||||
GetActiveProfile(ctx context.Context, in *GetActiveProfileRequest, opts ...grpc.CallOption) (*GetActiveProfileResponse, error)
|
||||
// AddOwner adds a principal to the active profile's owner list. Requires the
|
||||
// caller to be privileged (root/administrator) or an existing owner.
|
||||
AddOwner(ctx context.Context, in *AddOwnerRequest, opts ...grpc.CallOption) (*AddOwnerResponse, error)
|
||||
// ResetOwner clears the active profile's owner list, returning it to the
|
||||
// unowned state (next caller re-claims via trust-on-first-use). Privileged only.
|
||||
ResetOwner(ctx context.Context, in *ResetOwnerRequest, opts ...grpc.CallOption) (*ResetOwnerResponse, error)
|
||||
// ShareProfile marks the active profile shared (any local caller) or unshared.
|
||||
// Requires the caller to be an owner or privileged.
|
||||
ShareProfile(ctx context.Context, in *ShareProfileRequest, opts ...grpc.CallOption) (*ShareProfileResponse, error)
|
||||
// Logout disconnects from the network and deletes the peer from the management server
|
||||
Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error)
|
||||
GetFeatures(ctx context.Context, in *GetFeaturesRequest, opts ...grpc.CallOption) (*GetFeaturesResponse, error)
|
||||
@@ -528,6 +540,36 @@ func (c *daemonServiceClient) GetActiveProfile(ctx context.Context, in *GetActiv
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *daemonServiceClient) AddOwner(ctx context.Context, in *AddOwnerRequest, opts ...grpc.CallOption) (*AddOwnerResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AddOwnerResponse)
|
||||
err := c.cc.Invoke(ctx, DaemonService_AddOwner_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *daemonServiceClient) ResetOwner(ctx context.Context, in *ResetOwnerRequest, opts ...grpc.CallOption) (*ResetOwnerResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ResetOwnerResponse)
|
||||
err := c.cc.Invoke(ctx, DaemonService_ResetOwner_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *daemonServiceClient) ShareProfile(ctx context.Context, in *ShareProfileRequest, opts ...grpc.CallOption) (*ShareProfileResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ShareProfileResponse)
|
||||
err := c.cc.Invoke(ctx, DaemonService_ShareProfile_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *daemonServiceClient) Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(LogoutResponse)
|
||||
@@ -742,6 +784,15 @@ type DaemonServiceServer interface {
|
||||
RemoveProfile(context.Context, *RemoveProfileRequest) (*RemoveProfileResponse, error)
|
||||
ListProfiles(context.Context, *ListProfilesRequest) (*ListProfilesResponse, error)
|
||||
GetActiveProfile(context.Context, *GetActiveProfileRequest) (*GetActiveProfileResponse, error)
|
||||
// AddOwner adds a principal to the active profile's owner list. Requires the
|
||||
// caller to be privileged (root/administrator) or an existing owner.
|
||||
AddOwner(context.Context, *AddOwnerRequest) (*AddOwnerResponse, error)
|
||||
// ResetOwner clears the active profile's owner list, returning it to the
|
||||
// unowned state (next caller re-claims via trust-on-first-use). Privileged only.
|
||||
ResetOwner(context.Context, *ResetOwnerRequest) (*ResetOwnerResponse, error)
|
||||
// ShareProfile marks the active profile shared (any local caller) or unshared.
|
||||
// Requires the caller to be an owner or privileged.
|
||||
ShareProfile(context.Context, *ShareProfileRequest) (*ShareProfileResponse, error)
|
||||
// Logout disconnects from the network and deletes the peer from the management server
|
||||
Logout(context.Context, *LogoutRequest) (*LogoutResponse, error)
|
||||
GetFeatures(context.Context, *GetFeaturesRequest) (*GetFeaturesResponse, error)
|
||||
@@ -887,6 +938,15 @@ func (UnimplementedDaemonServiceServer) ListProfiles(context.Context, *ListProfi
|
||||
func (UnimplementedDaemonServiceServer) GetActiveProfile(context.Context, *GetActiveProfileRequest) (*GetActiveProfileResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetActiveProfile not implemented")
|
||||
}
|
||||
func (UnimplementedDaemonServiceServer) AddOwner(context.Context, *AddOwnerRequest) (*AddOwnerResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AddOwner not implemented")
|
||||
}
|
||||
func (UnimplementedDaemonServiceServer) ResetOwner(context.Context, *ResetOwnerRequest) (*ResetOwnerResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ResetOwner not implemented")
|
||||
}
|
||||
func (UnimplementedDaemonServiceServer) ShareProfile(context.Context, *ShareProfileRequest) (*ShareProfileResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ShareProfile not implemented")
|
||||
}
|
||||
func (UnimplementedDaemonServiceServer) Logout(context.Context, *LogoutRequest) (*LogoutResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method Logout not implemented")
|
||||
}
|
||||
@@ -1505,6 +1565,60 @@ func _DaemonService_GetActiveProfile_Handler(srv interface{}, ctx context.Contex
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _DaemonService_AddOwner_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AddOwnerRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DaemonServiceServer).AddOwner(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: DaemonService_AddOwner_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DaemonServiceServer).AddOwner(ctx, req.(*AddOwnerRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _DaemonService_ResetOwner_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ResetOwnerRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DaemonServiceServer).ResetOwner(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: DaemonService_ResetOwner_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DaemonServiceServer).ResetOwner(ctx, req.(*ResetOwnerRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _DaemonService_ShareProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ShareProfileRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DaemonServiceServer).ShareProfile(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: DaemonService_ShareProfile_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DaemonServiceServer).ShareProfile(ctx, req.(*ShareProfileRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _DaemonService_Logout_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(LogoutRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@@ -1873,6 +1987,18 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "GetActiveProfile",
|
||||
Handler: _DaemonService_GetActiveProfile_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AddOwner",
|
||||
Handler: _DaemonService_AddOwner_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ResetOwner",
|
||||
Handler: _DaemonService_ResetOwner_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ShareProfile",
|
||||
Handler: _DaemonService_ShareProfile_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Logout",
|
||||
Handler: _DaemonService_Logout_Handler,
|
||||
|
||||
204
client/server/ownership.go
Normal file
204
client/server/ownership.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
// The daemon Server implements ipcauth.ProfilePolicy so the gRPC interceptor can
|
||||
// authorize each RPC against the active profile's ownership.
|
||||
var _ ipcauth.ProfilePolicy = (*Server)(nil)
|
||||
|
||||
// ActiveProfileOwnership returns the active profile's ownership policy. Reads
|
||||
// the in-memory active config (kept current by the handlers), falling back to
|
||||
// the on-disk active profile when the daemon hasn't loaded one yet.
|
||||
func (s *Server) ActiveProfileOwnership() ipcauth.Ownership {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
cfg := s.config
|
||||
if cfg == nil {
|
||||
loaded, err := s.loadActiveProfileConfigLocked()
|
||||
if err != nil {
|
||||
log.Warnf("ownership: cannot load active profile config, treating as unowned: %v", err)
|
||||
return ipcauth.Ownership{}
|
||||
}
|
||||
cfg = loaded
|
||||
}
|
||||
return ipcauth.Ownership{Owners: cfg.Owners, Shared: cfg.Shared}
|
||||
}
|
||||
|
||||
// ClaimActiveProfileOwnerIfUnowned atomically claims the active profile for id
|
||||
// when it has no owners and is not shared (trust-on-first-use). Returns whether
|
||||
// id is now an owner. Concurrent first-callers are serialized by s.mutex, so
|
||||
// exactly one wins the claim; the others get false and are authorized normally.
|
||||
func (s *Server) ClaimActiveProfileOwnerIfUnowned(id ipcauth.Identity) (bool, error) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
cfg := s.config
|
||||
if cfg == nil {
|
||||
loaded, err := s.loadActiveProfileConfigLocked()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("load active profile config: %w", err)
|
||||
}
|
||||
cfg = loaded
|
||||
}
|
||||
|
||||
if len(cfg.Owners) > 0 || cfg.Shared {
|
||||
return false, nil // already owned or shared — someone won the race
|
||||
}
|
||||
|
||||
cfg.Owners = []string{ipcauth.OwnerPrincipalForIdentity(id)}
|
||||
if err := s.persistActiveProfileConfigLocked(cfg); err != nil {
|
||||
cfg.Owners = nil // revert in-memory on persistence failure
|
||||
return false, fmt.Errorf("persist claimed ownership: %w", err)
|
||||
}
|
||||
s.config = cfg
|
||||
log.Infof("profile ownership claimed by %s (trust-on-first-use)", id)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// activeProfileConfigPathLocked resolves the active profile's config file path.
|
||||
func (s *Server) activeProfileConfigPathLocked() (string, error) {
|
||||
activeProf, err := s.profileManager.GetActiveProfileState()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get active profile: %w", err)
|
||||
}
|
||||
path, err := activeProf.FilePath()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve active profile path: %w", err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// loadActiveProfileConfigLocked reads the active profile's config from disk.
|
||||
func (s *Server) loadActiveProfileConfigLocked() (*profilemanager.Config, error) {
|
||||
path, err := s.activeProfileConfigPathLocked()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return profilemanager.GetConfig(path)
|
||||
}
|
||||
|
||||
// persistActiveProfileConfigLocked writes cfg to the active profile's config file.
|
||||
func (s *Server) persistActiveProfileConfigLocked(cfg *profilemanager.Config) error {
|
||||
path, err := s.activeProfileConfigPathLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return util.WriteJson(context.Background(), path, cfg)
|
||||
}
|
||||
|
||||
// activeConfigLocked returns the in-memory active config, loading it from disk
|
||||
// if the daemon hasn't cached one. Caller must hold s.mutex.
|
||||
func (s *Server) activeConfigLocked() (*profilemanager.Config, error) {
|
||||
if s.config != nil {
|
||||
return s.config, nil
|
||||
}
|
||||
return s.loadActiveProfileConfigLocked()
|
||||
}
|
||||
|
||||
// claimForCallerLocked adds the caller's principal to cfg (if absent) and
|
||||
// persists. No-op for privileged callers (they need no ownership entry). Caller
|
||||
// must hold s.mutex.
|
||||
func (s *Server) claimForCallerLocked(id ipcauth.Identity, cfg *profilemanager.Config) error {
|
||||
if id.IsPrivileged() {
|
||||
return nil
|
||||
}
|
||||
principal := ipcauth.OwnerPrincipalForIdentity(id)
|
||||
if slices.Contains(cfg.Owners, principal) {
|
||||
return nil
|
||||
}
|
||||
cfg.Owners = append(cfg.Owners, principal)
|
||||
if err := s.persistActiveProfileConfigLocked(cfg); err != nil {
|
||||
cfg.Owners = cfg.Owners[:len(cfg.Owners)-1] // revert on failure
|
||||
return err
|
||||
}
|
||||
s.config = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddOwner adds a principal to the active profile's owner list. The interceptor
|
||||
// has already confirmed the caller is an owner or privileged; the handler just
|
||||
// validates and persists.
|
||||
func (s *Server) AddOwner(_ context.Context, msg *proto.AddOwnerRequest) (*proto.AddOwnerResponse, error) {
|
||||
principal := msg.GetPrincipal()
|
||||
if _, ok := ipcauth.ParsePrincipal(principal); !ok {
|
||||
return nil, gstatus.Errorf(codes.InvalidArgument, "invalid owner principal %q (expected uid:/gid:/group:/sid:)", principal)
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
cfg, err := s.activeConfigLocked()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load active profile config: %w", err)
|
||||
}
|
||||
if slices.Contains(cfg.Owners, principal) {
|
||||
return &proto.AddOwnerResponse{}, nil
|
||||
}
|
||||
cfg.Owners = append(cfg.Owners, principal)
|
||||
if err := s.persistActiveProfileConfigLocked(cfg); err != nil {
|
||||
cfg.Owners = cfg.Owners[:len(cfg.Owners)-1]
|
||||
return nil, fmt.Errorf("persist owner: %w", err)
|
||||
}
|
||||
s.config = cfg
|
||||
log.Infof("added owner %q to the active profile", principal)
|
||||
return &proto.AddOwnerResponse{}, nil
|
||||
}
|
||||
|
||||
// ResetOwner clears the active profile's owner list (and shared flag), returning
|
||||
// it to the unowned state so the next caller re-claims via trust-on-first-use.
|
||||
// Privileged-only, so co-owners cannot evict each other.
|
||||
func (s *Server) ResetOwner(ctx context.Context, _ *proto.ResetOwnerRequest) (*proto.ResetOwnerResponse, error) {
|
||||
id, ok := ipcauth.IdentityFromContext(ctx)
|
||||
if !ok || !id.IsPrivileged() {
|
||||
return nil, gstatus.Error(codes.PermissionDenied, "reset-owner requires root/administrator")
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
cfg, err := s.activeConfigLocked()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load active profile config: %w", err)
|
||||
}
|
||||
cfg.Owners = nil
|
||||
cfg.Shared = false
|
||||
if err := s.persistActiveProfileConfigLocked(cfg); err != nil {
|
||||
return nil, fmt.Errorf("persist owner reset: %w", err)
|
||||
}
|
||||
s.config = cfg
|
||||
log.Infof("active profile owner list reset; next caller will re-claim (trust-on-first-use)")
|
||||
return &proto.ResetOwnerResponse{}, nil
|
||||
}
|
||||
|
||||
// ShareProfile marks the active profile shared or unshared. The interceptor has
|
||||
// already confirmed the caller is an owner or privileged.
|
||||
func (s *Server) ShareProfile(_ context.Context, msg *proto.ShareProfileRequest) (*proto.ShareProfileResponse, error) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
cfg, err := s.activeConfigLocked()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load active profile config: %w", err)
|
||||
}
|
||||
cfg.Shared = msg.GetShared()
|
||||
if err := s.persistActiveProfileConfigLocked(cfg); err != nil {
|
||||
return nil, fmt.Errorf("persist shared flag: %w", err)
|
||||
}
|
||||
s.config = cfg
|
||||
log.Infof("active profile shared flag set to %t", msg.GetShared())
|
||||
return &proto.ShareProfileResponse{}, nil
|
||||
}
|
||||
53
client/server/profile_authz.go
Normal file
53
client/server/profile_authz.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// bindCallerUsername enforces that a non-privileged caller may only operate on
|
||||
// its OWN user's profiles. Profiles live in per-username directories, but the
|
||||
// username is a client-supplied gRPC field the server historically never
|
||||
// checked; binding it to the caller's kernel identity closes the "pass another
|
||||
// user's username" hole. Privileged callers (root / elevated-admin) may manage
|
||||
// any user's profiles.
|
||||
func (s *Server) bindCallerUsername(ctx context.Context, requested string) error {
|
||||
if requested == "" {
|
||||
return nil // handlers validate emptiness themselves; nothing to bind
|
||||
}
|
||||
|
||||
id, ok := ipcauth.IdentityFromContext(ctx)
|
||||
if !ok {
|
||||
return gstatus.Error(codes.PermissionDenied, "caller identity could not be verified")
|
||||
}
|
||||
if id.IsPrivileged() {
|
||||
return nil
|
||||
}
|
||||
|
||||
caller, err := usernameForIdentity(id)
|
||||
if err != nil {
|
||||
log.Warnf("profile authz: resolve caller username for %s: %v", id, err)
|
||||
return gstatus.Error(codes.PermissionDenied, "could not resolve caller identity to a username")
|
||||
}
|
||||
if !usernamesEqual(requested, caller) {
|
||||
return gstatus.Errorf(codes.PermissionDenied,
|
||||
"not authorized to operate on another user's profiles (caller %q requested %q)", caller, requested)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// usernamesEqual compares usernames case-insensitively on Windows (domain
|
||||
// accounts) and exactly on Unix.
|
||||
func usernamesEqual(a, b string) bool {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.EqualFold(a, b)
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
20
client/server/profile_authz_unix.go
Normal file
20
client/server/profile_authz_unix.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build !windows
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/internal/shell"
|
||||
)
|
||||
|
||||
// usernameForIdentity resolves a Unix caller's UID to its username via NSS
|
||||
// (getent), so LDAP/AD users resolve correctly under CGO_ENABLED=0.
|
||||
func usernameForIdentity(id ipcauth.Identity) (string, error) {
|
||||
u, err := shell.GetUserFromGetent(strconv.FormatUint(uint64(id.UID), 10))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.Username, nil
|
||||
}
|
||||
18
client/server/profile_authz_windows.go
Normal file
18
client/server/profile_authz_windows.go
Normal file
@@ -0,0 +1,18 @@
|
||||
//go:build windows
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"os/user"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// usernameForIdentity resolves a Windows caller's SID to its account name.
|
||||
func usernameForIdentity(id ipcauth.Identity) (string, error) {
|
||||
u, err := user.LookupId(id.SID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.Username, nil
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/auth"
|
||||
"github.com/netbirdio/netbird/client/internal/expose"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
@@ -104,12 +105,6 @@ type Server struct {
|
||||
persistSyncResponse bool
|
||||
isSessionActive atomic.Bool
|
||||
|
||||
// ready is set once the daemon has finished startup and is serving RPCs
|
||||
// backed by a running engine (see SetReady, called after Start succeeds and
|
||||
// the service is registered). Reported via StatusResponse.DaemonReady so
|
||||
// clients can wait deterministically instead of polling heuristically.
|
||||
ready atomic.Bool
|
||||
|
||||
cpuProfileBuf *bytes.Buffer
|
||||
cpuProfiling bool
|
||||
|
||||
@@ -164,14 +159,6 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable
|
||||
return s
|
||||
}
|
||||
|
||||
// SetReady marks the daemon as fully started and serving RPCs. It is called by
|
||||
// the service controller once Start has succeeded and the DaemonService is
|
||||
// registered, so a subsequent Status RPC reports DaemonReady=true. Safe for
|
||||
// concurrent use.
|
||||
func (s *Server) SetReady() {
|
||||
s.ready.Store(true)
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
@@ -416,6 +403,11 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
|
||||
}
|
||||
}
|
||||
|
||||
// SSH root login / disabling SSH auth requires a privileged caller.
|
||||
if err := requirePrivilegedForDangerousSSH(callerCtx, msg.EnableSSHRoot, msg.DisableSSHAuth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// MDM gate: refuse the whole request if any of its fields is enforced
|
||||
// by the active MDM policy. The error carries an MDMManagedFields-
|
||||
// Violation detail listing the offending key names. Non-conflicting
|
||||
@@ -551,6 +543,11 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
|
||||
}
|
||||
}
|
||||
|
||||
// SSH root login / disabling SSH auth requires a privileged caller.
|
||||
if err := requirePrivilegedForDangerousSSH(callerCtx, msg.EnableSSHRoot, msg.DisableSSHAuth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
if s.actCancel != nil {
|
||||
s.actCancel()
|
||||
@@ -957,6 +954,17 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
|
||||
}
|
||||
s.config = config
|
||||
|
||||
// --owner: explicitly claim ownership of the active profile for the caller.
|
||||
if msg != nil && msg.GetClaimOwner() {
|
||||
if id, ok := ipcauth.IdentityFromContext(callerCtx); ok {
|
||||
if err := s.claimForCallerLocked(id, s.config); err != nil {
|
||||
s.mutex.Unlock()
|
||||
log.Errorf("failed to claim profile ownership: %v", err)
|
||||
return nil, fmt.Errorf("claim owner: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String())
|
||||
s.statusRecorder.UpdateRosenpass(s.config.RosenpassEnabled, s.config.RosenpassPermissive)
|
||||
|
||||
@@ -1062,6 +1070,16 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi
|
||||
}
|
||||
|
||||
if msg != nil && msg.ProfileName != nil {
|
||||
targetUsername := ""
|
||||
if msg.Username != nil {
|
||||
targetUsername = *msg.Username
|
||||
}
|
||||
// The interceptor already gated this against the CURRENT active profile;
|
||||
// also bind the TARGET profile's username so a caller can't switch into
|
||||
// another user's profile.
|
||||
if err := s.bindCallerUsername(callerCtx, targetUsername); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil {
|
||||
log.Errorf("failed to switch profile: %v", err)
|
||||
return nil, err
|
||||
@@ -1095,7 +1113,10 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes
|
||||
|
||||
if err := s.cleanupConnection(); err != nil {
|
||||
s.mutex.Unlock()
|
||||
// todo review to update the status in case any type of error
|
||||
if errors.Is(err, ErrServiceNotUp) {
|
||||
log.Debugf("Down called while service not up: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
log.Errorf("failed to shut down properly: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
@@ -1168,7 +1189,7 @@ func (s *Server) cleanupConnection() error {
|
||||
// making the run loop the sole owner of engine shutdown.
|
||||
if engine != nil {
|
||||
if err := engine.Stop(); err != nil {
|
||||
return err
|
||||
log.Errorf("failed to stop engine during cleanup: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1436,7 +1457,7 @@ func (s *Server) buildStatusResponse(ctx context.Context, msg *proto.StatusReque
|
||||
s.isSessionActive.Store(false)
|
||||
}
|
||||
|
||||
statusResponse := proto.StatusResponse{Status: string(status), DaemonVersion: version.NetbirdVersion(), DaemonReady: s.ready.Load()}
|
||||
statusResponse := proto.StatusResponse{Status: string(status), DaemonVersion: version.NetbirdVersion()}
|
||||
|
||||
if deadline := s.statusRecorder.GetSessionExpiresAt(); !deadline.IsZero() {
|
||||
statusResponse.SessionExpiresAt = timestamppb.New(deadline)
|
||||
@@ -2016,7 +2037,18 @@ func (s *Server) AddProfile(ctx context.Context, msg *proto.AddProfileRequest) (
|
||||
return nil, gstatus.Errorf(codes.InvalidArgument, "profile name and username must be provided")
|
||||
}
|
||||
|
||||
created, err := s.profileManager.AddProfile(msg.ProfileName, msg.Username)
|
||||
if err := s.bindCallerUsername(ctx, msg.Username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Auto-isolate the new profile to its creator. When root/admin creates it we
|
||||
// leave it unowned so the intended user claims it via trust-on-first-use.
|
||||
var initialOwners []string
|
||||
if id, ok := ipcauth.IdentityFromContext(ctx); ok && !id.IsPrivileged() {
|
||||
initialOwners = []string{ipcauth.OwnerPrincipalForIdentity(id)}
|
||||
}
|
||||
|
||||
created, err := s.profileManager.AddProfile(msg.ProfileName, msg.Username, initialOwners)
|
||||
if err != nil {
|
||||
log.Errorf("failed to create profile: %v", err)
|
||||
return nil, fmt.Errorf("failed to create profile: %w", err)
|
||||
@@ -2039,6 +2071,10 @@ func (s *Server) RenameProfile(ctx context.Context, msg *proto.RenameProfileRequ
|
||||
return nil, gstatus.Errorf(codes.InvalidArgument, "profile name, username and new profile name must be provided")
|
||||
}
|
||||
|
||||
if err := s.bindCallerUsername(ctx, msg.Username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resolved, err := s.resolveProfileHandle(msg.Handle, msg.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -2068,6 +2104,10 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ
|
||||
return nil, gstatus.Errorf(codes.InvalidArgument, "profile name must be provided")
|
||||
}
|
||||
|
||||
if err := s.bindCallerUsername(ctx, msg.Username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resolved, err := s.resolveProfileHandle(msg.ProfileName, msg.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -2136,6 +2176,10 @@ func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesReques
|
||||
return nil, gstatus.Errorf(codes.InvalidArgument, "username must be provided")
|
||||
}
|
||||
|
||||
if err := s.bindCallerUsername(ctx, msg.Username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
profiles, err := s.profileManager.ListProfiles(msg.Username)
|
||||
if err != nil {
|
||||
log.Errorf("failed to list profiles: %v", err)
|
||||
|
||||
36
client/server/ssh_gate.go
Normal file
36
client/server/ssh_gate.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// requirePrivilegedForDangerousSSH enforces admin permissions for SSH config.
|
||||
// Enabling SSH root login or disabling SSH authentication turns the
|
||||
// root/LocalSystem daemon's SSH server into an unauthenticated root shell
|
||||
// (local-to-remote-root escalation), so only a privileged caller may set
|
||||
// these flags to true over the local IPC.
|
||||
func requirePrivilegedForDangerousSSH(ctx context.Context, enableSSHRoot, disableSSHAuth *bool) error {
|
||||
dangerous := (enableSSHRoot != nil && *enableSSHRoot) || (disableSSHAuth != nil && *disableSSHAuth)
|
||||
if !dangerous {
|
||||
return nil
|
||||
}
|
||||
|
||||
id, ok := ipcauth.IdentityFromContext(ctx)
|
||||
if !ok {
|
||||
log.Warnf("denying SSH root/no-auth config change: caller identity unavailable on this control channel")
|
||||
return gstatus.Error(codes.PermissionDenied,
|
||||
"enabling SSH root login or disabling SSH authentication requires root/administrator, but the caller identity could not be verified on this daemon control channel")
|
||||
}
|
||||
if !id.IsPrivileged() {
|
||||
log.Warnf("denying SSH root/no-auth config change from non-privileged caller %s", id)
|
||||
return gstatus.Errorf(codes.PermissionDenied,
|
||||
"enabling SSH root login or disabling SSH authentication requires root/administrator (caller %s is not privileged); rerun as root/administrator", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
52
client/server/ssh_gate_test.go
Normal file
52
client/server/ssh_gate_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/peer"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
func ctxWithIdentity(id ipcauth.Identity) context.Context {
|
||||
return peer.NewContext(context.Background(), &peer.Peer{AuthInfo: ipcauth.AuthInfo{Identity: id}})
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
|
||||
func TestRequirePrivilegedForDangerousSSH(t *testing.T) {
|
||||
root := ipcauth.Identity{UID: 0}
|
||||
user := ipcauth.Identity{UID: 1000}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
enableSSHRoot *bool
|
||||
disableSSHAuth *bool
|
||||
wantDenied bool
|
||||
}{
|
||||
{"no flags, no identity", context.Background(), nil, nil, false},
|
||||
{"flags false, non-priv", ctxWithIdentity(user), boolPtr(false), boolPtr(false), false},
|
||||
{"enableSSHRoot by root", ctxWithIdentity(root), boolPtr(true), nil, false},
|
||||
{"enableSSHRoot by non-priv", ctxWithIdentity(user), boolPtr(true), nil, true},
|
||||
{"disableSSHAuth by non-priv", ctxWithIdentity(user), nil, boolPtr(true), true},
|
||||
{"enableSSHRoot no identity (fail closed)", context.Background(), boolPtr(true), nil, true},
|
||||
{"both by root", ctxWithIdentity(root), boolPtr(true), boolPtr(true), false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := requirePrivilegedForDangerousSSH(tt.ctx, tt.enableSSHRoot, tt.disableSSHAuth)
|
||||
if tt.wantDenied {
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, codes.PermissionDenied, gstatus.Code(err))
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nbssh "github.com/netbirdio/netbird/client/ssh"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -218,11 +219,20 @@ func (m *Manager) buildHostPatterns(peer PeerSSHInfo) []string {
|
||||
if peer.IPv6.IsValid() {
|
||||
hostPatterns = append(hostPatterns, peer.IPv6.String())
|
||||
}
|
||||
if peer.FQDN != "" {
|
||||
// Peer FQDNs and hostnames originate from remote peers, so they must be
|
||||
// validated as plain DNS names before being embedded in the ssh_config
|
||||
// "Match host" pattern list. This prevents injection of arbitrary
|
||||
// ssh_config directives via embedded quotes, whitespace, newlines, the
|
||||
// comma pattern separator, or the "*"/"?" pattern metacharacters.
|
||||
if domain.IsValidDomainNoWildcard(peer.FQDN) {
|
||||
hostPatterns = append(hostPatterns, peer.FQDN)
|
||||
} else if peer.FQDN != "" {
|
||||
log.Warnf("skipping peer FQDN with invalid characters in SSH config: %q", peer.FQDN)
|
||||
}
|
||||
if peer.Hostname != "" && peer.Hostname != peer.FQDN {
|
||||
if peer.Hostname != peer.FQDN && domain.IsValidDomainNoWildcard(peer.Hostname) {
|
||||
hostPatterns = append(hostPatterns, peer.Hostname)
|
||||
} else if peer.Hostname != "" && peer.Hostname != peer.FQDN {
|
||||
log.Warnf("skipping peer hostname with invalid characters in SSH config: %q", peer.Hostname)
|
||||
}
|
||||
return hostPatterns
|
||||
}
|
||||
|
||||
@@ -148,6 +148,45 @@ func TestManager_MatchHostFormat(t *testing.T) {
|
||||
"should use Match host with comma-separated patterns")
|
||||
}
|
||||
|
||||
func TestManager_HostPatternInjection(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "netbird-ssh-config-test")
|
||||
require.NoError(t, err)
|
||||
defer func() { assert.NoError(t, os.RemoveAll(tempDir)) }()
|
||||
|
||||
manager := &Manager{
|
||||
sshConfigDir: filepath.Join(tempDir, "ssh_config.d"),
|
||||
sshConfigFile: "99-netbird.conf",
|
||||
}
|
||||
|
||||
// A malicious peer FQDN/hostname attempts to break out of the Match host
|
||||
// directive and inject arbitrary ssh_config (a ProxyCommand executing a
|
||||
// command). It must be rejected, not written to the config.
|
||||
peers := []PeerSSHInfo{
|
||||
{
|
||||
Hostname: "evil\"\n ProxyCommand touch /tmp/pwned\nHost x",
|
||||
IP: netip.MustParseAddr("100.125.1.1"),
|
||||
FQDN: "evil\"\n ProxyCommand touch /tmp/pwned\nHost x.nb.internal",
|
||||
},
|
||||
{Hostname: "peer2", IP: netip.MustParseAddr("100.125.1.2"), FQDN: "peer2.nb.internal"},
|
||||
}
|
||||
|
||||
err = manager.SetupSSHClientConfig(peers)
|
||||
require.NoError(t, err)
|
||||
|
||||
configPath := filepath.Join(manager.sshConfigDir, manager.sshConfigFile)
|
||||
content, err := os.ReadFile(configPath)
|
||||
require.NoError(t, err)
|
||||
configStr := string(content)
|
||||
|
||||
assert.NotContains(t, configStr, "ProxyCommand touch /tmp/pwned",
|
||||
"injected directive must not appear in generated config")
|
||||
assert.NotContains(t, configStr, "evil",
|
||||
"malicious pattern must be dropped entirely")
|
||||
// The valid peer must still be present, on a single Match host line.
|
||||
assert.Contains(t, configStr, "Match host \"100.125.1.1,100.125.1.2,peer2.nb.internal,peer2\"",
|
||||
"valid peers must survive, injected patterns dropped")
|
||||
}
|
||||
|
||||
func TestManager_ForcedSSHConfig(t *testing.T) {
|
||||
// Set force environment variable
|
||||
t.Setenv(EnvForceSSHConfig, "true")
|
||||
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
"github.com/creack/pty"
|
||||
"github.com/gliderlabs/ssh"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
shellutil "github.com/netbirdio/netbird/client/internal/shell"
|
||||
)
|
||||
|
||||
// ptyManager manages Pty file operations with thread safety
|
||||
@@ -146,10 +148,10 @@ func (s *Server) createShellCommand(ctx context.Context, shell string, args []st
|
||||
|
||||
// prepareCommandEnv prepares environment variables for command execution on Unix
|
||||
func (s *Server) prepareCommandEnv(_ *log.Entry, localUser *user.User, session ssh.Session) []string {
|
||||
env := prepareUserEnv(localUser, getUserShell(localUser.Uid))
|
||||
env := shellutil.PrepareUserEnv(localUser, shellutil.GetUserShell(localUser.Uid))
|
||||
env = append(env, prepareSSHEnv(session)...)
|
||||
for _, v := range session.Environ() {
|
||||
if acceptEnv(v) {
|
||||
if shellutil.AcceptEnv(v) {
|
||||
env = append(env, v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
|
||||
shellutil "github.com/netbirdio/netbird/client/internal/shell"
|
||||
"github.com/netbirdio/netbird/client/ssh/server/winpty"
|
||||
)
|
||||
|
||||
@@ -247,10 +248,10 @@ func (s *Server) prepareCommandEnv(logger *log.Entry, localUser *user.User, sess
|
||||
userEnv, err := s.getUserEnvironment(logger, username, domain)
|
||||
if err != nil {
|
||||
log.Debugf("failed to get user environment for %s\\%s, using fallback: %v", domain, username, err)
|
||||
env := prepareUserEnv(localUser, getUserShell(localUser.Uid))
|
||||
env := shellutil.PrepareUserEnv(localUser, shellutil.GetUserShell(localUser.Uid))
|
||||
env = append(env, prepareSSHEnv(session)...)
|
||||
for _, v := range session.Environ() {
|
||||
if acceptEnv(v) {
|
||||
if shellutil.AcceptEnv(v) {
|
||||
env = append(env, v)
|
||||
}
|
||||
}
|
||||
@@ -260,7 +261,7 @@ func (s *Server) prepareCommandEnv(logger *log.Entry, localUser *user.User, sess
|
||||
env := userEnv
|
||||
env = append(env, prepareSSHEnv(session)...)
|
||||
for _, v := range session.Environ() {
|
||||
if acceptEnv(v) {
|
||||
if shellutil.AcceptEnv(v) {
|
||||
env = append(env, v)
|
||||
}
|
||||
}
|
||||
@@ -273,7 +274,7 @@ func (s *Server) handlePtyLogin(logger *log.Entry, session ssh.Session, privileg
|
||||
return false
|
||||
}
|
||||
|
||||
shell := getUserShell(privilegeResult.User.Uid)
|
||||
shell := shellutil.GetUserShell(privilegeResult.User.Uid)
|
||||
logger.Infof("starting interactive shell: %s", shell)
|
||||
|
||||
s.executeCommandWithPty(logger, session, nil, privilegeResult, ptyReq, nil)
|
||||
@@ -384,7 +385,7 @@ func (s *Server) executeCommandWithPty(logger *log.Entry, session ssh.Session, _
|
||||
}
|
||||
|
||||
username, domain := s.parseUsername(localUser.Username)
|
||||
shell := getUserShell(localUser.Uid)
|
||||
shell := shellutil.GetUserShell(localUser.Uid)
|
||||
|
||||
req := PtyExecutionRequest{
|
||||
Shell: shell,
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
//go:build cgo && !osusergo && !windows
|
||||
|
||||
package server
|
||||
|
||||
import "os/user"
|
||||
|
||||
// lookupWithGetent with CGO delegates directly to os/user.Lookup.
|
||||
// When CGO is enabled, os/user uses libc (getpwnam_r) which goes through
|
||||
// the NSS stack natively. If it fails, the user truly doesn't exist and
|
||||
// getent would also fail.
|
||||
func lookupWithGetent(username string) (*user.User, error) {
|
||||
return user.Lookup(username)
|
||||
}
|
||||
|
||||
// currentUserWithGetent with CGO delegates directly to os/user.Current.
|
||||
func currentUserWithGetent() (*user.User, error) {
|
||||
return user.Current()
|
||||
}
|
||||
|
||||
// groupIdsWithFallback with CGO delegates directly to user.GroupIds.
|
||||
// libc's getgrouplist handles NSS groups natively.
|
||||
func groupIdsWithFallback(u *user.User) ([]string, error) {
|
||||
return u.GroupIds()
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package server
|
||||
|
||||
import "os/user"
|
||||
|
||||
// lookupWithGetent on Windows just delegates to os/user.Lookup.
|
||||
// Windows does not use NSS/getent; its user lookup works without CGO.
|
||||
func lookupWithGetent(username string) (*user.User, error) {
|
||||
return user.Lookup(username)
|
||||
}
|
||||
|
||||
// currentUserWithGetent on Windows just delegates to os/user.Current.
|
||||
func currentUserWithGetent() (*user.User, error) {
|
||||
return user.Current()
|
||||
}
|
||||
|
||||
// getShellFromGetent is a no-op on Windows; shell resolution uses PowerShell detection.
|
||||
func getShellFromGetent(_ string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// groupIdsWithFallback on Windows just delegates to u.GroupIds().
|
||||
func groupIdsWithFallback(u *user.User) ([]string, error) {
|
||||
return u.GroupIds()
|
||||
}
|
||||
35
client/ssh/server/ssh_env.go
Normal file
35
client/ssh/server/ssh_env.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
)
|
||||
|
||||
// prepareSSHEnv prepares SSH protocol-specific environment variables
|
||||
// These variables provide information about the SSH connection itself
|
||||
func prepareSSHEnv(session ssh.Session) []string {
|
||||
remoteAddr := session.RemoteAddr()
|
||||
localAddr := session.LocalAddr()
|
||||
|
||||
remoteHost, remotePort, err := net.SplitHostPort(remoteAddr.String())
|
||||
if err != nil {
|
||||
remoteHost = remoteAddr.String()
|
||||
remotePort = "0"
|
||||
}
|
||||
|
||||
localHost, localPort, err := net.SplitHostPort(localAddr.String())
|
||||
if err != nil {
|
||||
localHost = localAddr.String()
|
||||
localPort = strconv.Itoa(InternalSSHPort)
|
||||
}
|
||||
|
||||
return []string{
|
||||
// SSH_CLIENT format: "client_ip client_port server_port"
|
||||
fmt.Sprintf("SSH_CLIENT=%s %s %s", remoteHost, remotePort, localPort),
|
||||
// SSH_CONNECTION format: "client_ip client_port server_ip server_port"
|
||||
fmt.Sprintf("SSH_CONNECTION=%s %s %s %s", remoteHost, remotePort, localHost, localPort),
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
shellutil "github.com/netbirdio/netbird/client/internal/shell"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -23,8 +25,8 @@ func isPlatformUnix() bool {
|
||||
|
||||
// Dependency injection variables for testing - allows mocking dynamic runtime checks
|
||||
var (
|
||||
getCurrentUser = currentUserWithGetent
|
||||
lookupUser = lookupWithGetent
|
||||
getCurrentUser = shellutil.CurrentUserWithGetent
|
||||
lookupUser = shellutil.LookupWithGetent
|
||||
getCurrentOS = func() string { return runtime.GOOS }
|
||||
getIsProcessPrivileged = isCurrentProcessPrivileged
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
shellutil "github.com/netbirdio/netbird/client/internal/shell"
|
||||
)
|
||||
|
||||
// POSIX portable filename character set regex: [a-zA-Z0-9._-]
|
||||
@@ -160,7 +162,7 @@ func (s *Server) parseUserCredentials(localUser *user.User) (uint32, uint32, []u
|
||||
// getSupplementaryGroups retrieves supplementary group IDs for a user.
|
||||
// Uses id/getent fallback for NSS users in CGO_ENABLED=0 builds.
|
||||
func (s *Server) getSupplementaryGroups(u *user.User) ([]uint32, error) {
|
||||
groupIDStrings, err := groupIdsWithFallback(u)
|
||||
groupIDStrings, err := shellutil.GroupIdsWithFallback(u)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get group IDs for user %s: %w", u.Username, err)
|
||||
}
|
||||
@@ -196,7 +198,7 @@ func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, l
|
||||
GID: gid,
|
||||
Groups: groups,
|
||||
WorkingDir: localUser.HomeDir,
|
||||
Shell: getUserShell(localUser.Uid),
|
||||
Shell: shellutil.GetUserShell(localUser.Uid),
|
||||
Command: session.RawCommand(),
|
||||
PTY: hasPty,
|
||||
}
|
||||
@@ -228,7 +230,7 @@ func (s *Server) createPtyCommand(privilegeResult PrivilegeCheckResult, ptyReq s
|
||||
func (s *Server) createDirectPtyCommand(session ssh.Session, localUser *user.User, ptyReq ssh.Pty) *exec.Cmd {
|
||||
log.Debugf("creating direct Pty command for user %s (no user switching needed)", localUser.Username)
|
||||
|
||||
shell := getUserShell(localUser.Uid)
|
||||
shell := shellutil.GetUserShell(localUser.Uid)
|
||||
args := s.getShellCommandArgs(shell, session.RawCommand())
|
||||
|
||||
cmd := s.createShellCommand(session.Context(), shell, args)
|
||||
@@ -245,12 +247,12 @@ func (s *Server) preparePtyEnv(localUser *user.User, ptyReq ssh.Pty, session ssh
|
||||
termType = "xterm-256color"
|
||||
}
|
||||
|
||||
env := prepareUserEnv(localUser, getUserShell(localUser.Uid))
|
||||
env := shellutil.PrepareUserEnv(localUser, shellutil.GetUserShell(localUser.Uid))
|
||||
env = append(env, prepareSSHEnv(session)...)
|
||||
env = append(env, fmt.Sprintf("TERM=%s", termType))
|
||||
|
||||
for _, v := range session.Environ() {
|
||||
if acceptEnv(v) {
|
||||
if shellutil.AcceptEnv(v) {
|
||||
env = append(env, v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"github.com/gliderlabs/ssh"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
shellutil "github.com/netbirdio/netbird/client/internal/shell"
|
||||
)
|
||||
|
||||
// validateUsername validates Windows usernames according to SAM Account Name rules
|
||||
@@ -104,7 +106,7 @@ func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, l
|
||||
func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session, localUser *user.User) (*exec.Cmd, func(), error) {
|
||||
username, domain := s.parseUsername(localUser.Username)
|
||||
|
||||
shell := getUserShell(localUser.Uid)
|
||||
shell := shellutil.GetUserShell(localUser.Uid)
|
||||
|
||||
rawCmd := session.RawCommand()
|
||||
var command string
|
||||
|
||||
1
client/ui/.gitignore
vendored
1
client/ui/.gitignore
vendored
@@ -6,3 +6,4 @@ frontend/bindings
|
||||
frontend/.vite
|
||||
build/linux/appimage/build
|
||||
build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
|
||||
build/windows/frontend
|
||||
|
||||
@@ -51,7 +51,7 @@ func autostartDisabledByMDM(policy *mdm.Policy) bool {
|
||||
// netbirdFootprintExists reports whether the machine already carries NetBird
|
||||
// daemon config or state, meaning this is not a genuinely fresh install. It is
|
||||
// the update-safety gate for the autostart default: upgrading users always
|
||||
// have a footprint, so an update can never trigger a login-item write.
|
||||
// have a footprint, so an update can never trigger a autostart entry write.
|
||||
func netbirdFootprintExists() bool {
|
||||
candidates := []string{
|
||||
profilemanager.DefaultConfigPath,
|
||||
@@ -69,9 +69,23 @@ func netbirdFootprintExists() bool {
|
||||
// applyAutostartDefault runs the one-time launch-on-login default for genuinely
|
||||
// fresh installs. The autostartInitialized marker is persisted before any
|
||||
// enable attempt so a crash mid-flow degrades to "never enabled" instead of
|
||||
// retrying login-item writes on every launch. A user's later disable in
|
||||
// retrying autostart entry writes on every launch. A user's later disable in
|
||||
// Settings is never overridden: the marker guarantees at-most-once, ever.
|
||||
func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, prefs *preferences.Store, prefsFileExisted bool) {
|
||||
mdmDisabled := autostartDisabledByMDM(mdm.LoadPolicy())
|
||||
|
||||
if mdmDisabled {
|
||||
if enabled, err := autostart.IsEnabled(ctx); err != nil {
|
||||
log.Warnf("MDM disableAutostart: read autostart state: %v", err)
|
||||
} else if enabled {
|
||||
if err := autostart.SetEnabled(ctx, false); err != nil {
|
||||
log.Warnf("MDM disableAutostart: force off failed: %v", err)
|
||||
} else {
|
||||
log.Info("MDM disableAutostart enforced: autostart turned off")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
priorFootprint := netbirdFootprintExists() || prefsFileExisted
|
||||
|
||||
if prefs.Get().AutostartInitialized {
|
||||
@@ -84,7 +98,7 @@ func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, p
|
||||
|
||||
state := autostartDefaultState{
|
||||
supported: autostart.Supported(ctx),
|
||||
mdmDisabled: autostartDisabledByMDM(mdm.LoadPolicy()),
|
||||
mdmDisabled: mdmDisabled,
|
||||
priorInstall: priorFootprint,
|
||||
}
|
||||
enable, reason := shouldEnableAutostartDefault(state)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangleIcon, DownloadIcon } from "lucide-react";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import { Version } from "@bindings/services";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { useStatus } from "@/contexts/StatusContext.tsx";
|
||||
|
||||
const RELEASES_URL = "https://github.com/netbirdio/netbird/releases/latest";
|
||||
const RC_RELEASES_URL = "https://pkgs.netbird.io/releases/rc";
|
||||
|
||||
function openUrl(url: string) {
|
||||
Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank"));
|
||||
@@ -12,7 +15,26 @@ function openUrl(url: string) {
|
||||
|
||||
export const DaemonOutdatedOverlay = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isDaemonOutdated } = useStatus();
|
||||
const { status, isDaemonOutdated } = useStatus();
|
||||
|
||||
const [guiVersion, setGuiVersion] = useState<string>("-");
|
||||
const clientVersion = status?.daemonVersion ?? "—";
|
||||
|
||||
const isRc = /-rc/i.test(guiVersion) || /-rc/i.test(clientVersion);
|
||||
const downloadUrl = isRc ? RC_RELEASES_URL : RELEASES_URL;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDaemonOutdated) return;
|
||||
let cancelled = false;
|
||||
Version.GUI()
|
||||
.then((v) => {
|
||||
if (!cancelled) setGuiVersion(v);
|
||||
})
|
||||
.catch((err) => console.error("[DaemonOutdatedOverlay] GUI version error", err));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isDaemonOutdated]);
|
||||
|
||||
if (!isDaemonOutdated) return null;
|
||||
|
||||
@@ -38,10 +60,37 @@ export const DaemonOutdatedOverlay = () => {
|
||||
<p className={"text-sm text-nb-gray-300"}>{t("daemon.outdated.description")}</p>
|
||||
</div>
|
||||
|
||||
<div className={"flex flex-col items-center gap-0.5 text-center"}>
|
||||
<p className={"text-sm font-semibold text-nb-gray-100"}>
|
||||
{clientVersion === "development" ? (
|
||||
<span>
|
||||
{t("settings.about.clientName")}{" "}
|
||||
<span className={"font-mono text-yellow-400"}>
|
||||
{t("settings.about.development")}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
t("settings.about.client", { version: clientVersion })
|
||||
)}
|
||||
</p>
|
||||
<p className={"text-sm font-medium text-nb-gray-250"}>
|
||||
{guiVersion === "development" ? (
|
||||
<span>
|
||||
{t("settings.about.guiName")}{" "}
|
||||
<span className={"font-mono text-yellow-400"}>
|
||||
{t("settings.about.development")}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
t("settings.about.gui", { version: guiVersion })
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={"wails-no-draggable"}>
|
||||
<Button variant={"primary"} size={"xs"} onClick={() => openUrl(RELEASES_URL)}>
|
||||
<Button variant={"primary"} size={"xs"} onClick={() => openUrl(downloadUrl)}>
|
||||
<DownloadIcon size={14} />
|
||||
{t("update.card.getInstaller")}
|
||||
{t("daemon.outdated.download")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,7 @@ type ProfileContextValue = {
|
||||
loaded: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
switchProfile: (id: string) => Promise<void>;
|
||||
switchProfileNoConnect: (id: string) => Promise<void>;
|
||||
addProfile: (name: string) => Promise<string>;
|
||||
removeProfile: (id: string) => Promise<void>;
|
||||
renameProfile: (id: string, newName: string) => Promise<void>;
|
||||
@@ -112,6 +113,16 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
[username, refresh],
|
||||
);
|
||||
|
||||
// Manage-profiles variant: switches without connecting, so the user can
|
||||
// still adjust the management URL before bringing the connection up.
|
||||
const switchProfileNoConnect = useCallback(
|
||||
async (id: string) => {
|
||||
await ProfileSwitcher.SwitchActiveNoConnect({ profileName: id, username });
|
||||
await refresh();
|
||||
},
|
||||
[username, refresh],
|
||||
);
|
||||
|
||||
// addProfile creates a profile by display name and returns the
|
||||
// daemon-generated ID, so the caller can immediately address it by ID.
|
||||
const addProfile = useCallback(
|
||||
@@ -158,6 +169,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
loaded,
|
||||
refresh,
|
||||
switchProfile,
|
||||
switchProfileNoConnect,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
renameProfile,
|
||||
@@ -171,6 +183,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
loaded,
|
||||
refresh,
|
||||
switchProfile,
|
||||
switchProfileNoConnect,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
renameProfile,
|
||||
|
||||
@@ -45,7 +45,7 @@ export function ProfilesTab() {
|
||||
activeProfileId,
|
||||
loaded,
|
||||
username,
|
||||
switchProfile,
|
||||
switchProfileNoConnect,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
renameProfile,
|
||||
@@ -100,7 +100,7 @@ export function ProfilesTab() {
|
||||
confirmLabel: t("profile.switch.confirm"),
|
||||
});
|
||||
if (!ok) return;
|
||||
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(id));
|
||||
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfileNoConnect(id));
|
||||
};
|
||||
|
||||
const handleDeregister = async (id: string, name: string) => {
|
||||
@@ -129,14 +129,13 @@ export function ProfilesTab() {
|
||||
await guarded(i18next.t("profile.error.createTitle"), async () => {
|
||||
const id = await addProfile(name);
|
||||
// SetConfig is keyed by the new profile's ID, so it writes the
|
||||
// not-yet-active profile. Write before switching so any reconnect
|
||||
// targets the right deployment.
|
||||
// not-yet-active profile before the switch makes it current.
|
||||
if (!isNetbirdCloud(managementUrl)) {
|
||||
await SettingsSvc.SetConfig(
|
||||
new SetConfigParams({ profileName: id, username, managementUrl }),
|
||||
);
|
||||
}
|
||||
await switchProfile(id);
|
||||
await switchProfileNoConnect(id);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -73,6 +73,13 @@ export default function SessionExpirationDialog() {
|
||||
|
||||
let offCancel: (() => void) | undefined;
|
||||
|
||||
// Return the dialog to its interactive state and dismiss the browser popup
|
||||
const resetDialog = () => {
|
||||
offCancel?.();
|
||||
WindowManager.CloseBrowserLogin().catch(console.error);
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
try {
|
||||
const start = await Session.RequestExtend({ hint: "" });
|
||||
const uri = start.verificationUriComplete || start.verificationUri;
|
||||
@@ -105,25 +112,22 @@ export default function SessionExpirationDialog() {
|
||||
if (outcome.kind === "cancel") {
|
||||
waitPromise.cancel?.();
|
||||
waitPromise.catch(() => {});
|
||||
resetDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
// Another surface owns this flow; keep the dialog open to retry.
|
||||
if (outcome.result.preempted) {
|
||||
resetDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
// Close before the popup so the restore can't flash this window back.
|
||||
WindowManager.CloseSessionExpiration().catch(console.error);
|
||||
WindowManager.CloseRenewFlow().catch(console.error);
|
||||
} catch (e) {
|
||||
resetDialog();
|
||||
await errorDialog({
|
||||
Title: t("sessionExpiration.extendFailedTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
offCancel?.();
|
||||
WindowManager.CloseBrowserLogin().catch(console.error);
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, t]);
|
||||
|
||||
@@ -139,12 +143,11 @@ export default function SessionExpirationDialog() {
|
||||
});
|
||||
WindowManager.CloseSessionExpiration().catch(console.error);
|
||||
} catch (e) {
|
||||
setBusy(false);
|
||||
await errorDialog({
|
||||
Title: t("sessionExpiration.logoutFailedTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, t]);
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ type WelcomeStepTrayProps = {
|
||||
export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>) {
|
||||
const { t } = useTranslation();
|
||||
const trayScreenshot = trayScreenshotForOS();
|
||||
// macOS has no tray — the icon sits in the menu bar, so the copy says so.
|
||||
const titleKey = isMacOS() ? "welcome.titleMac" : "welcome.title";
|
||||
const descriptionKey = isMacOS() ? "welcome.descriptionMac" : "welcome.description";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -36,9 +39,9 @@ export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>)
|
||||
|
||||
<div className={"flex w-full flex-col gap-1"}>
|
||||
<DialogHeading id={"nb-welcome-title"} align={"left"}>
|
||||
{t("welcome.title")}
|
||||
{t(titleKey)}
|
||||
</DialogHeading>
|
||||
<DialogDescription align={"left"}>{t("welcome.description")}</DialogDescription>
|
||||
<DialogDescription align={"left"}>{t(descriptionKey)}</DialogDescription>
|
||||
</div>
|
||||
|
||||
<DialogActions>
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/backoff"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"github.com/netbirdio/netbird/client/cmd"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ui/desktop"
|
||||
)
|
||||
@@ -36,9 +36,7 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) {
|
||||
return c.client, nil
|
||||
}
|
||||
|
||||
cc, err := grpc.NewClient(
|
||||
strings.TrimPrefix(c.addr, "tcp://"),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
opts := []grpc.DialOption{
|
||||
grpc.WithUserAgent(desktop.GetUIUserAgent()),
|
||||
// Cap reconnect backoff at 5s; gRPC's default 120s MaxDelay would
|
||||
// leave the UI waiting 30-60s to notice a freshly-started daemon.
|
||||
@@ -50,6 +48,12 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) {
|
||||
MaxDelay: 5 * time.Second,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
cc, err := cmd.DialClientGRPCServer(
|
||||
context.Background(),
|
||||
c.addr,
|
||||
opts...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial daemon: %w", err)
|
||||
@@ -61,7 +65,7 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) {
|
||||
// DaemonAddr returns the default daemon gRPC address: a Unix socket on Linux/macOS, TCP loopback on Windows.
|
||||
func DaemonAddr() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "tcp://127.0.0.1:41731"
|
||||
return "npipe://netbird"
|
||||
}
|
||||
return "unix:///var/run/netbird.sock"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
{"code": "fr", "displayName": "Français", "englishName": "French"},
|
||||
{"code": "it", "displayName": "Italiano", "englishName": "Italian"},
|
||||
{"code": "pt", "displayName": "Português", "englishName": "Portuguese"},
|
||||
{"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"}
|
||||
{"code": "zh-CN", "displayName": "简体中文", "englishName": "Simplified Chinese"},
|
||||
{"code": "ja", "displayName": "日本語", "englishName": "Japanese"}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1034,9 +1034,15 @@
|
||||
"welcome.title": {
|
||||
"message": "Suchen Sie NetBird in der Taskleiste"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Suchen Sie NetBird in der Menüleiste"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird läuft in Ihrer Taskleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird läuft in Ihrer Menüleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Weiter"
|
||||
},
|
||||
@@ -1293,10 +1299,13 @@
|
||||
"message": "Dokumentation"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "NetBird-Dienst ist veraltet"
|
||||
"message": "NetBird Client ist veraltet"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "Aktualisieren Sie den NetBird-Dienst, um diese App zu verwenden."
|
||||
"message": "Die neue GUI ist nicht mit Ihrem älteren Client kompatibel. Aktualisieren Sie Ihren Client, um die neue Anwendung zu verwenden."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Neueste Version herunterladen"
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Anmeldung fehlgeschlagen: Die Uhr dieses Geräts ist nicht mit dem Server synchron. Bitte synchronisieren Sie die Systemuhr und versuchen Sie es erneut."
|
||||
|
||||
@@ -1377,11 +1377,19 @@
|
||||
},
|
||||
"welcome.title": {
|
||||
"message": "Look for NetBird in your tray",
|
||||
"description": "Heading on the first onboarding step, pointing the user to the tray icon. 'tray' = system tray / menu bar."
|
||||
"description": "Heading on the first onboarding step, pointing the user to the tray icon. Shown on Windows and Linux; macOS uses welcome.titleMac."
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Look for NetBird in your menu bar",
|
||||
"description": "Heading on the first onboarding step on macOS, pointing the user to the menu bar icon. Use your language's Apple term for the macOS menu bar."
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird lives in your tray. Click the icon to connect, switch profiles, or open settings.",
|
||||
"description": "Body of the first onboarding step explaining the tray icon."
|
||||
"description": "Body of the first onboarding step explaining the tray icon. Shown on Windows and Linux; macOS uses welcome.descriptionMac."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird lives in your menu bar. Click the icon to connect, switch profiles, or open settings.",
|
||||
"description": "Body of the first onboarding step on macOS explaining the menu bar icon. Use your language's Apple term for the macOS menu bar."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Continue",
|
||||
@@ -1724,12 +1732,16 @@
|
||||
"description": "Documentation link on the daemon-unavailable overlay."
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "NetBird Service Is Outdated",
|
||||
"description": "Title of the overlay shown when the NetBird background service is too old to drive this UI."
|
||||
"message": "NetBird Client Is Outdated",
|
||||
"description": "Title of the overlay shown when the NetBird client (daemon) is too old to drive this UI."
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "Update the NetBird service to use this app.",
|
||||
"description": "Body of the daemon-outdated overlay telling the user to upgrade the service."
|
||||
"message": "The new GUI isn't compatible with the older NetBird client. Update your client to use the new application.",
|
||||
"description": "Body of the daemon-outdated overlay explaining that the GUI is newer than the client and the client must be updated."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Download Latest",
|
||||
"description": "Button on the daemon-outdated overlay that opens the download page for the latest release."
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Sign-in failed: this device's clock is out of sync with the server. Please sync your system clock and try again.",
|
||||
|
||||
@@ -1034,9 +1034,15 @@
|
||||
"welcome.title": {
|
||||
"message": "Busque NetBird en su bandeja del sistema"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Busque NetBird en su barra de menús"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird reside en su bandeja del sistema. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird reside en su barra de menús. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Continuar"
|
||||
},
|
||||
@@ -1293,10 +1299,13 @@
|
||||
"message": "Documentación"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "El servicio de NetBird está desactualizado"
|
||||
"message": "NetBird Client está desactualizado"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "Actualice el servicio de NetBird para usar esta aplicación."
|
||||
"message": "La nueva GUI no es compatible con su cliente anterior. Actualice su cliente para usar la nueva aplicación."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Descargar la última versión"
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Error al iniciar sesión: el reloj de este dispositivo no está sincronizado con el servidor. Sincronice el reloj del sistema e inténtelo de nuevo."
|
||||
|
||||
@@ -1034,9 +1034,15 @@
|
||||
"welcome.title": {
|
||||
"message": "Cherchez NetBird dans votre barre d’état système"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Cherchez NetBird dans votre barre des menus"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird se trouve dans votre barre d’état système. Cliquez sur l’icône pour vous connecter, changer de profil ou ouvrir les paramètres."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird se trouve dans votre barre des menus. Cliquez sur l’icône pour vous connecter, changer de profil ou ouvrir les paramètres."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Continuer"
|
||||
},
|
||||
@@ -1293,10 +1299,13 @@
|
||||
"message": "Documentation"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "Le service NetBird est obsolète"
|
||||
"message": "Le Client NetBird est obsolète"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "Mettez à jour le service NetBird pour utiliser cette application."
|
||||
"message": "La nouvelle GUI n'est pas compatible avec votre ancien client. Mettez à jour votre client pour utiliser la nouvelle application."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Télécharger la dernière version"
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Échec de la connexion : l’horloge de cet appareil n’est pas synchronisée avec le serveur. Veuillez synchroniser l’horloge de votre système et réessayer."
|
||||
|
||||
@@ -1034,9 +1034,15 @@
|
||||
"welcome.title": {
|
||||
"message": "Keresse a NetBirdöt a tálcán"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Keresse a NetBirdöt a menüsorban"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "A NetBird a tálcán fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "A NetBird a menüsorban fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Folytatás"
|
||||
},
|
||||
@@ -1293,10 +1299,13 @@
|
||||
"message": "Dokumentáció"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "A NetBird szolgáltatás elavult"
|
||||
"message": "A NetBird Kliens elavult"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "Frissítsd a NetBird szolgáltatást az alkalmazás használatához."
|
||||
"message": "Az új GUI nem kompatibilis a régebbi klienseddel. Frissítsd a klienst az új alkalmazás használatához."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Legújabb letöltése"
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "A bejelentkezés sikertelen: az eszköz órája eltér a szerverétől. Kérjük, szinkronizálja a rendszer óráját, majd próbálja újra."
|
||||
|
||||
@@ -1034,9 +1034,15 @@
|
||||
"welcome.title": {
|
||||
"message": "Cerchi NetBird nella tray"
|
||||
},
|
||||
"welcome.titleMac": {
|
||||
"message": "Cerchi NetBird nella barra dei menu"
|
||||
},
|
||||
"welcome.description": {
|
||||
"message": "NetBird risiede nella tray. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni."
|
||||
},
|
||||
"welcome.descriptionMac": {
|
||||
"message": "NetBird risiede nella barra dei menu. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni."
|
||||
},
|
||||
"welcome.continue": {
|
||||
"message": "Continua"
|
||||
},
|
||||
@@ -1293,10 +1299,13 @@
|
||||
"message": "Documentazione"
|
||||
},
|
||||
"daemon.outdated.title": {
|
||||
"message": "Il servizio NetBird è obsoleto"
|
||||
"message": "NetBird Client è obsoleto"
|
||||
},
|
||||
"daemon.outdated.description": {
|
||||
"message": "Aggiorna il servizio NetBird per usare questa app."
|
||||
"message": "La nuova GUI non è compatibile con il tuo client precedente. Aggiorna il client per usare la nuova applicazione."
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "Scarica l'ultima versione"
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "Accesso non riuscito: l'orologio di questo dispositivo non è sincronizzato con il server. Sincronizzi l'orologio di sistema e riprovi."
|
||||
|
||||
1331
client/ui/i18n/locales/ja/common.json
Normal file
1331
client/ui/i18n/locales/ja/common.json
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user