mirror of
https://github.com/netbirdio/netbird.git
synced 2026-06-12 19:09:54 +00:00
Compare commits
12 Commits
fix/ios-de
...
socket-grp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d65927275d | ||
|
|
064f7bf0fd | ||
|
|
644615fed6 | ||
|
|
d3b63c6be9 | ||
|
|
60d2fa08b0 | ||
|
|
1e7b16db0a | ||
|
|
b377d99933 | ||
|
|
512899d82d | ||
|
|
5993ec6e43 | ||
|
|
eac6d501c3 | ||
|
|
deeae30612 | ||
|
|
f3cdf163e1 |
9
.github/workflows/golang-test-darwin.yml
vendored
9
.github/workflows/golang-test-darwin.yml
vendored
@@ -45,4 +45,11 @@ jobs:
|
||||
run: git --no-pager diff --exit-code
|
||||
|
||||
- name: Test
|
||||
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
|
||||
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: netbirdio/netbird
|
||||
flags: unit,client
|
||||
|
||||
61
.github/workflows/golang-test-linux.yml
vendored
61
.github/workflows/golang-test-linux.yml
vendored
@@ -158,7 +158,16 @@ jobs:
|
||||
run: git --no-pager diff --exit-code
|
||||
|
||||
- name: Test
|
||||
run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
|
||||
run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -coverprofile=coverage.txt -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.arch == 'amd64'
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: netbirdio/netbird
|
||||
flags: unit,client
|
||||
|
||||
|
||||
test_client_on_docker:
|
||||
name: "Client (Docker) / Unit"
|
||||
@@ -276,9 +285,17 @@ jobs:
|
||||
run: |
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
go test ${{ matrix.raceFlag }} \
|
||||
-exec 'sudo' \
|
||||
-exec 'sudo' -coverprofile=coverage.txt \
|
||||
-timeout 10m -p 1 ./relay/... ./shared/relay/...
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.arch == 'amd64'
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: netbirdio/netbird
|
||||
flags: unit,relay
|
||||
|
||||
test_proxy:
|
||||
name: "Proxy / Unit"
|
||||
needs: [build-cache]
|
||||
@@ -326,7 +343,15 @@ jobs:
|
||||
- name: Test
|
||||
run: |
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
go test -timeout 10m -p 1 ./proxy/...
|
||||
go test -timeout 10m -p 1 -coverprofile=coverage.txt ./proxy/...
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.arch == 'amd64'
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: netbirdio/netbird
|
||||
flags: unit,proxy
|
||||
|
||||
test_signal:
|
||||
name: "Signal / Unit"
|
||||
@@ -377,9 +402,17 @@ jobs:
|
||||
run: |
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
go test \
|
||||
-exec 'sudo' \
|
||||
-exec 'sudo' -coverprofile=coverage.txt \
|
||||
-timeout 10m ./signal/... ./shared/signal/...
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.arch == 'amd64'
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: netbirdio/netbird
|
||||
flags: unit,signal
|
||||
|
||||
test_management:
|
||||
name: "Management / Unit"
|
||||
needs: [build-cache]
|
||||
@@ -445,10 +478,18 @@ jobs:
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
|
||||
CI=true \
|
||||
go test -tags=devcert \
|
||||
go test -tags=devcert -coverprofile=coverage.txt \
|
||||
-exec "sudo --preserve-env=CI,NETBIRD_STORE_ENGINE" \
|
||||
-timeout 20m ./management/... ./shared/management/...
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.arch == 'amd64'
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: netbirdio/netbird
|
||||
flags: unit,management
|
||||
|
||||
benchmark:
|
||||
name: "Management / Benchmark"
|
||||
needs: [build-cache]
|
||||
@@ -687,6 +728,14 @@ jobs:
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
|
||||
CI=true \
|
||||
go test -tags=integration \
|
||||
go test -tags=integration -coverprofile=coverage.txt \
|
||||
-exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' \
|
||||
-timeout 20m ./management/server/http/...
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.arch == 'amd64'
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 #v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: netbirdio/netbird
|
||||
flags: integration,management
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -29,10 +29,10 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Generate FreeBSD port diff
|
||||
run: bash release_files/freebsd-port-diff.sh
|
||||
run: bash -x release_files/freebsd-port-diff.sh
|
||||
|
||||
- name: Generate FreeBSD port issue body
|
||||
run: bash release_files/freebsd-port-issue-body.sh
|
||||
run: bash -x release_files/freebsd-port-issue-body.sh
|
||||
|
||||
- name: Check if diff was generated
|
||||
id: check_diff
|
||||
|
||||
4
.github/workflows/wasm-build-validation.yml
vendored
4
.github/workflows/wasm-build-validation.yml
vendored
@@ -65,7 +65,7 @@ jobs:
|
||||
|
||||
echo "Size: ${SIZE} bytes (${SIZE_MB} MB)"
|
||||
|
||||
if [ ${SIZE} -gt 58720256 ]; then
|
||||
echo "Wasm binary size (${SIZE_MB}MB) exceeds 56MB limit!"
|
||||
if [ ${SIZE} -gt 62914560 ]; then
|
||||
echo "Wasm binary size (${SIZE_MB}MB) exceeds 60MB limit!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/server"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/upload-server/types"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
const errCloseConnection = "Failed to close connection: %v"
|
||||
@@ -100,6 +101,7 @@ func debugBundle(cmd *cobra.Command, _ []string) error {
|
||||
Anonymize: anonymizeFlag,
|
||||
SystemInfo: systemInfoFlag,
|
||||
LogFileCount: logFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
}
|
||||
if uploadBundleFlag {
|
||||
request.UploadURL = uploadBundleURLFlag
|
||||
@@ -298,6 +300,7 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
||||
Anonymize: anonymizeFlag,
|
||||
SystemInfo: systemInfoFlag,
|
||||
LogFileCount: logFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
}
|
||||
if uploadBundleFlag {
|
||||
request.UploadURL = uploadBundleURLFlag
|
||||
@@ -432,6 +435,7 @@ func generateDebugBundle(config *profilemanager.Config, recorder *peer.Status, c
|
||||
SyncResponse: syncResponse,
|
||||
LogPath: logFilePath,
|
||||
CPUProfile: nil,
|
||||
DaemonVersion: version.NetbirdVersion(), // acting as daemon
|
||||
},
|
||||
debug.BundleConfig{
|
||||
IncludeSystemInfo: true,
|
||||
|
||||
36
client/cmd/peercred_bsd.go
Normal file
36
client/cmd/peercred_bsd.go
Normal file
@@ -0,0 +1,36 @@
|
||||
//go:build darwin || freebsd
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// peerUID returns the uid of the process on the other end of a unix socket
|
||||
// connection, read via LOCAL_PEERCRED (xucred). Note: xucred carries the uid
|
||||
// and group list but no pid, so audit on these platforms is uid-based.
|
||||
func peerUID(c net.Conn) (int, error) {
|
||||
uc, ok := c.(*net.UnixConn)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("connection is not a unix socket: %T", c)
|
||||
}
|
||||
raw, err := uc.SyscallConn()
|
||||
if err != nil {
|
||||
return 0, 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 0, fmt.Errorf("getsockopt control: %w", err)
|
||||
}
|
||||
if credErr != nil {
|
||||
return 0, fmt.Errorf("LOCAL_PEERCRED: %w", credErr)
|
||||
}
|
||||
return int(cred.Uid), nil
|
||||
}
|
||||
35
client/cmd/peercred_linux.go
Normal file
35
client/cmd/peercred_linux.go
Normal file
@@ -0,0 +1,35 @@
|
||||
//go:build linux
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// peerUID returns the uid of the process on the other end of a unix socket
|
||||
// connection, read from the kernel via SO_PEERCRED.
|
||||
func peerUID(c net.Conn) (int, error) {
|
||||
uc, ok := c.(*net.UnixConn)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("connection is not a unix socket: %T", c)
|
||||
}
|
||||
raw, err := uc.SyscallConn()
|
||||
if err != nil {
|
||||
return 0, 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 0, fmt.Errorf("getsockopt control: %w", err)
|
||||
}
|
||||
if credErr != nil {
|
||||
return 0, fmt.Errorf("SO_PEERCRED: %w", credErr)
|
||||
}
|
||||
return int(cred.Uid), nil
|
||||
}
|
||||
16
client/cmd/peercred_unsupported.go
Normal file
16
client/cmd/peercred_unsupported.go
Normal file
@@ -0,0 +1,16 @@
|
||||
//go:build !linux && !darwin && !freebsd
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// peerUID is unimplemented on this platform, so the trust-on-first-use socket
|
||||
// migration cannot run here. Configure --socket-owner explicitly, or use
|
||||
// --disable-strict-socket. (Windows uses a TCP socket and never reaches this.)
|
||||
func peerUID(net.Conn) (int, error) {
|
||||
return 0, fmt.Errorf("peer credential check not supported on %s", runtime.GOOS)
|
||||
}
|
||||
@@ -77,6 +77,8 @@ var (
|
||||
updateSettingsDisabled bool
|
||||
captureEnabled bool
|
||||
networksDisabled bool
|
||||
socketOwner string
|
||||
strictSocketDisabled bool
|
||||
|
||||
rootCmd = &cobra.Command{
|
||||
Use: "netbird",
|
||||
|
||||
@@ -57,6 +57,9 @@ func init() {
|
||||
installCmd.Flags().StringSliceVar(&serviceEnvVars, "service-env", nil, serviceEnvDesc)
|
||||
reconfigureCmd.Flags().StringSliceVar(&serviceEnvVars, "service-env", nil, serviceEnvDesc)
|
||||
|
||||
serviceCmd.PersistentFlags().StringVar(&socketOwner, "socket-owner", "", "user to own the daemon control socket; restricts it to that user plus the netbird group (0660). If unset, the first client to connect claims ownership (trust-on-first-use)")
|
||||
serviceCmd.PersistentFlags().BoolVar(&strictSocketDisabled, "disable-strict-socket", false, "leave the daemon control socket world-writable (0666) instead of restricting it; set via the (root-only) service command")
|
||||
|
||||
rootCmd.AddCommand(serviceCmd)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,15 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kardianos/service"
|
||||
@@ -16,6 +21,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/shell"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/server"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
@@ -54,10 +60,36 @@ func (p *program) Start(svc service.Service) error {
|
||||
go func() {
|
||||
defer listen.Close()
|
||||
|
||||
srvListener := listen
|
||||
if split[0] == "unix" {
|
||||
if err := os.Chmod(split[1], 0666); err != nil {
|
||||
log.Errorf("failed setting daemon permissions: %v", split[1])
|
||||
return
|
||||
owner := effectiveSocketOwner()
|
||||
switch {
|
||||
case strictSocketDisabled:
|
||||
// Opt-out (root-only, via service.json): leave it world-writable.
|
||||
if err := os.Chmod(split[1], 0666); err != nil {
|
||||
log.Errorf("failed setting daemon permissions: %v", split[1])
|
||||
return
|
||||
}
|
||||
case owner != "":
|
||||
// Seeded owner (flag, MDM, or persisted TOFU result): restrict
|
||||
// before serving so there is no open window.
|
||||
uid, err := lookupUser(owner)
|
||||
if err != nil {
|
||||
log.Errorf("lookup socket owner %q: %v", owner, err)
|
||||
return
|
||||
}
|
||||
if err := restrictSocket(split[1], uid); err != nil {
|
||||
log.Errorf("restrict socket to %q: %v", owner, err)
|
||||
return
|
||||
}
|
||||
default:
|
||||
// Trust-on-first-use: open the socket now; tofuListener locks it
|
||||
// to the first caller's uid on the first connection.
|
||||
if err := os.Chmod(split[1], 0666); err != nil {
|
||||
log.Errorf("failed setting daemon permissions: %v", split[1])
|
||||
return
|
||||
}
|
||||
srvListener = &tofuListener{Listener: listen, path: split[1], owner: -1}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,13 +104,180 @@ func (p *program) Start(svc service.Service) error {
|
||||
p.serverInstanceMu.Unlock()
|
||||
|
||||
log.Printf("started daemon server: %v", split[1])
|
||||
if err := p.serv.Serve(listen); err != nil {
|
||||
if err := p.serv.Serve(srvListener); err != nil {
|
||||
log.Errorf("failed to serve daemon requests: %v", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func lookupUser(username string) (int, error) {
|
||||
u, err := shell.LookupWithGetent(username)
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("lookup user %s: %w", username, err)
|
||||
}
|
||||
uid, err := strconv.Atoi(u.Uid)
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("parse uid %s: %w", u.Uid, err)
|
||||
}
|
||||
return uid, nil
|
||||
}
|
||||
|
||||
// addGroup creates a system group if it doesn't already exist and returns the gid.
|
||||
// Must run as root.
|
||||
func addGroup(name string) (int, error) {
|
||||
group, err := shell.LookupGroupWithGetent(name)
|
||||
if err == nil {
|
||||
gid, err := strconv.ParseInt(group.Gid, 10, 64)
|
||||
return int(gid), err
|
||||
}
|
||||
|
||||
// looup failed, create the group
|
||||
groupadd, err := exec.LookPath("groupadd")
|
||||
if err != nil {
|
||||
// Fallback for Alpine/BusyBox systems.
|
||||
if groupadd, err = exec.LookPath("addgroup"); err != nil {
|
||||
return -1, errors.New("neither groupadd nor addgroup found")
|
||||
}
|
||||
}
|
||||
|
||||
// Use --system for a service/daemon group (no login, low GID).
|
||||
out, err := exec.Command(groupadd, "--system", name).CombinedOutput()
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("create group %q: %w: %s", name, err, out)
|
||||
}
|
||||
if group, err := shell.LookupWithGetent(name); err == nil {
|
||||
gid, err := strconv.ParseInt(group.Gid, 10, 64)
|
||||
return int(gid), err
|
||||
}
|
||||
return -1, fmt.Errorf("lookup group %q: %w", name, err)
|
||||
}
|
||||
|
||||
// restrictSocket locks the unix socket down to the owner uid plus the netbird
|
||||
// group (0660). If the group cannot be created or applied, it fails closed to
|
||||
// owner-only 0600 — it never leaves the socket world-writable.
|
||||
func restrictSocket(path string, uid int) error {
|
||||
// TODO: introduce flag to configure this (LDAP/AD usecase)
|
||||
gid, err := addGroup("netbird")
|
||||
if err != nil {
|
||||
log.Errorf("create netbird group, failing closed to owner-only 0600: %v", err)
|
||||
return chownChmod(path, uid, -1, 0600)
|
||||
}
|
||||
if err := chownChmod(path, uid, gid, 0660); err != nil {
|
||||
log.Errorf("apply netbird group to socket, failing closed to owner-only 0600: %v", err)
|
||||
return chownChmod(path, uid, -1, 0600)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// chownChmod sets ownership and mode on the socket. A gid of -1 leaves the
|
||||
// group unchanged.
|
||||
func chownChmod(path string, uid, gid int, mode os.FileMode) error {
|
||||
if err := os.Chown(path, uid, gid); err != nil {
|
||||
return fmt.Errorf("chown socket %s: %w", path, err)
|
||||
}
|
||||
if err := os.Chmod(path, mode); err != nil {
|
||||
return fmt.Errorf("chmod socket %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tofuListener implements trust-on-first-use for the daemon control socket.
|
||||
// The socket starts world-writable; the first caller's uid (read via the
|
||||
// platform peer-credential mechanism) becomes the owner. On that first
|
||||
// connection the socket is restricted (see restrictSocket) and the owner is
|
||||
// persisted so the open window never reopens on later starts. Connections that
|
||||
// raced in during the open window and are neither the owner nor root are
|
||||
// dropped. Changing the socket mode does not disturb the already-open
|
||||
// connection, so the first caller's request is served normally.
|
||||
type tofuListener struct {
|
||||
net.Listener
|
||||
path string
|
||||
mu sync.Mutex
|
||||
owner int // -1 until claimed
|
||||
}
|
||||
|
||||
func (l *tofuListener) Accept() (net.Conn, error) {
|
||||
for {
|
||||
c, err := l.Listener.Accept()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
uid, err := peerUID(c)
|
||||
if err != nil {
|
||||
log.Errorf("read peer credentials, dropping connection: %v", err)
|
||||
_ = c.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
if l.owner == -1 {
|
||||
if err := restrictSocket(l.path, uid); err != nil {
|
||||
l.mu.Unlock()
|
||||
_ = c.Close()
|
||||
// Refuse to serve on a socket we could not lock down.
|
||||
return nil, fmt.Errorf("restrict socket on first connection: %w", err)
|
||||
}
|
||||
l.owner = uid
|
||||
persistSocketOwner(uid)
|
||||
log.Infof("control socket restricted to first caller (uid %d)", uid)
|
||||
l.mu.Unlock()
|
||||
return c, nil
|
||||
}
|
||||
owner := l.owner
|
||||
l.mu.Unlock()
|
||||
|
||||
// New connects are already gated by the 0660 perms set above; this only
|
||||
// drops anything that slipped in during the brief open window.
|
||||
if uid != owner && uid != 0 {
|
||||
log.Warnf("dropping non-owner connection (uid %d) during socket bootstrap", uid)
|
||||
_ = c.Close()
|
||||
continue
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
|
||||
// effectiveSocketOwner returns the configured socket owner: the --socket-owner
|
||||
// flag when set, otherwise the owner persisted by a previous TOFU migration.
|
||||
func effectiveSocketOwner() string {
|
||||
if socketOwner != "" {
|
||||
return socketOwner
|
||||
}
|
||||
params, err := loadServiceParams()
|
||||
if err != nil {
|
||||
log.Errorf("load service params for socket owner: %v", err)
|
||||
return ""
|
||||
}
|
||||
if params != nil {
|
||||
return params.SocketOwner
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// persistSocketOwner records the TOFU-selected owner (by username) so the next
|
||||
// daemon start restricts the socket immediately, with no open window.
|
||||
func persistSocketOwner(uid int) {
|
||||
u, err := user.LookupId(strconv.Itoa(uid))
|
||||
if err != nil {
|
||||
log.Errorf("resolve uid %d to username for persistence: %v", uid, err)
|
||||
return
|
||||
}
|
||||
params, err := loadServiceParams()
|
||||
if err != nil {
|
||||
log.Errorf("load service params to persist socket owner: %v", err)
|
||||
return
|
||||
}
|
||||
if params == nil {
|
||||
params = currentServiceParams()
|
||||
}
|
||||
params.SocketOwner = u.Username
|
||||
if err := saveServiceParams(params); err != nil {
|
||||
log.Errorf("persist socket owner: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *program) Stop(srv service.Service) error {
|
||||
p.serverInstanceMu.Lock()
|
||||
if p.serverInstance != nil {
|
||||
@@ -102,7 +301,7 @@ func (p *program) Stop(srv service.Service) error {
|
||||
}
|
||||
|
||||
// Common setup for service control commands
|
||||
func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc) (service.Service, error) {
|
||||
func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc, consoleLog bool) (service.Service, error) {
|
||||
// rootCmd env vars are already applied by PersistentPreRunE.
|
||||
SetFlagsFromEnvVars(serviceCmd)
|
||||
|
||||
@@ -112,8 +311,14 @@ func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := util.InitLog(logLevel, logFiles...); err != nil {
|
||||
return nil, fmt.Errorf("init log: %w", err)
|
||||
if consoleLog {
|
||||
if err := util.InitLog(logLevel, util.LogConsole); err != nil {
|
||||
return nil, fmt.Errorf("init log: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := util.InitLog(logLevel, logFiles...); err != nil {
|
||||
return nil, fmt.Errorf("init log: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := newSVCConfig()
|
||||
@@ -138,7 +343,7 @@ var runCmd = &cobra.Command{
|
||||
SetupCloseHandler(ctx, cancel)
|
||||
SetupDebugHandler(ctx, nil, nil, nil, util.FindFirstLogPath(logFiles))
|
||||
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel)
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -152,7 +357,7 @@ var startCmd = &cobra.Command{
|
||||
Short: "starts NetBird service",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel)
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -170,7 +375,7 @@ var stopCmd = &cobra.Command{
|
||||
Short: "stops NetBird service",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel)
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -188,7 +393,7 @@ var restartCmd = &cobra.Command{
|
||||
Short: "restarts NetBird service",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel)
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -206,7 +411,7 @@ var svcStatusCmd = &cobra.Command{
|
||||
Short: "shows NetBird service status",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel)
|
||||
s, err := setupServiceControlCommand(cmd, ctx, cancel, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -67,6 +67,14 @@ func buildServiceArguments() []string {
|
||||
args = append(args, "--disable-networks")
|
||||
}
|
||||
|
||||
if socketOwner != "" {
|
||||
args = append(args, "--socket-owner", socketOwner)
|
||||
}
|
||||
|
||||
if strictSocketDisabled {
|
||||
args = append(args, "--disable-strict-socket")
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -127,6 +135,8 @@ var installCmd = &cobra.Command{
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Printf("SUDO_UID: %s\n", os.Getenv("SUDO_UID"))
|
||||
|
||||
if err := loadAndApplyServiceParams(cmd); err != nil {
|
||||
cmd.PrintErrf("Warning: failed to load saved service params: %v\n", err)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ type serviceParams struct {
|
||||
DisableUpdateSettings bool `json:"disable_update_settings,omitempty"`
|
||||
EnableCapture bool `json:"enable_capture,omitempty"`
|
||||
DisableNetworks bool `json:"disable_networks,omitempty"`
|
||||
SocketOwner string `json:"socket_owner,omitempty"`
|
||||
DisableStrictSocket bool `json:"disable_strict_socket,omitempty"`
|
||||
ServiceEnvVars map[string]string `json:"service_env_vars,omitempty"`
|
||||
}
|
||||
|
||||
@@ -82,6 +84,8 @@ func currentServiceParams() *serviceParams {
|
||||
DisableUpdateSettings: updateSettingsDisabled,
|
||||
EnableCapture: captureEnabled,
|
||||
DisableNetworks: networksDisabled,
|
||||
SocketOwner: socketOwner,
|
||||
DisableStrictSocket: strictSocketDisabled,
|
||||
}
|
||||
|
||||
if len(serviceEnvVars) > 0 {
|
||||
@@ -154,6 +158,14 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
|
||||
networksDisabled = params.DisableNetworks
|
||||
}
|
||||
|
||||
if !serviceCmd.PersistentFlags().Changed("socket-owner") {
|
||||
socketOwner = params.SocketOwner
|
||||
}
|
||||
|
||||
if !serviceCmd.PersistentFlags().Changed("disable-strict-socket") {
|
||||
strictSocketDisabled = params.DisableStrictSocket
|
||||
}
|
||||
|
||||
applyServiceEnvParams(cmd, params)
|
||||
}
|
||||
|
||||
|
||||
@@ -254,6 +254,8 @@ type BundleGenerator struct {
|
||||
capturePath string
|
||||
refreshStatus func() // Optional callback to refresh status before bundle generation
|
||||
clientMetrics MetricsExporter
|
||||
daemonVersion string
|
||||
cliVersion string
|
||||
|
||||
anonymize bool
|
||||
includeSystemInfo bool
|
||||
@@ -278,6 +280,8 @@ type GeneratorDependencies struct {
|
||||
CapturePath string
|
||||
RefreshStatus func()
|
||||
ClientMetrics MetricsExporter
|
||||
DaemonVersion string
|
||||
CliVersion string
|
||||
}
|
||||
|
||||
func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGenerator {
|
||||
@@ -299,6 +303,8 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
|
||||
capturePath: deps.CapturePath,
|
||||
refreshStatus: deps.RefreshStatus,
|
||||
clientMetrics: deps.ClientMetrics,
|
||||
daemonVersion: deps.DaemonVersion,
|
||||
cliVersion: deps.CliVersion,
|
||||
|
||||
anonymize: cfg.Anonymize,
|
||||
includeSystemInfo: cfg.IncludeSystemInfo,
|
||||
@@ -459,9 +465,11 @@ func (g *BundleGenerator) addStatus() error {
|
||||
protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus)
|
||||
protoFullStatus.Events = g.statusRecorder.GetEventHistory()
|
||||
overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{
|
||||
Anonymize: g.anonymize,
|
||||
ProfileName: profName,
|
||||
Anonymize: g.anonymize,
|
||||
ProfileName: profName,
|
||||
DaemonVersion: g.daemonVersion,
|
||||
})
|
||||
overview.CliVersion = g.cliVersion
|
||||
statusOutput := overview.FullDetailSummary()
|
||||
|
||||
statusReader := strings.NewReader(statusOutput)
|
||||
@@ -798,6 +806,8 @@ func (g *BundleGenerator) addSyncResponse() error {
|
||||
AllowPartial: true,
|
||||
}
|
||||
|
||||
g.maskSecrets()
|
||||
|
||||
jsonBytes, err := options.Marshal(g.syncResponse)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate json: %w", err)
|
||||
@@ -810,6 +820,27 @@ func (g *BundleGenerator) addSyncResponse() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *BundleGenerator) maskSecrets() {
|
||||
if g.syncResponse == nil || g.syncResponse.NetbirdConfig == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if g.syncResponse.NetbirdConfig.Flow != nil {
|
||||
g.syncResponse.NetbirdConfig.Flow.TokenPayload = maskedValue
|
||||
|
||||
}
|
||||
|
||||
if g.syncResponse.NetbirdConfig.Relay != nil {
|
||||
g.syncResponse.NetbirdConfig.Relay.TokenPayload = maskedValue
|
||||
}
|
||||
|
||||
for i := range g.syncResponse.NetbirdConfig.Turns {
|
||||
if g.syncResponse.NetbirdConfig.Turns[i] != nil {
|
||||
g.syncResponse.NetbirdConfig.Turns[i].Password = maskedValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *BundleGenerator) addStateFile() error {
|
||||
sm := profilemanager.NewServiceManager("")
|
||||
path := sm.GetStatePath()
|
||||
@@ -1039,7 +1070,8 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
|
||||
return
|
||||
}
|
||||
|
||||
pattern := filepath.Join(logDir, "client-*.log.gz")
|
||||
// This regex will match both logs rotated by us and logrotate on linux
|
||||
pattern := filepath.Join(logDir, "client*.log.*")
|
||||
files, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
log.Warnf("failed to glob rotated logs: %v", err)
|
||||
@@ -1072,7 +1104,12 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir string) {
|
||||
|
||||
for i := 0; i < maxFiles; i++ {
|
||||
name := filepath.Base(files[i])
|
||||
if err := g.addSingleLogFileGz(files[i], name); err != nil {
|
||||
if strings.HasSuffix(name, ".gz") {
|
||||
err = g.addSingleLogFileGz(files[i], name)
|
||||
} else {
|
||||
err = g.addSingleLogfile(files[i], name)
|
||||
}
|
||||
if err != nil {
|
||||
log.Warnf("failed to add rotated log %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
103
client/internal/debug/debug_logfiles_test.go
Normal file
103
client/internal/debug/debug_logfiles_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestAddRotatedLogFiles_PicksUpAllVariants asserts that the rotated-log
|
||||
// glob picks up logs rotated by timberjack (gzipped) and by logrotate (plain
|
||||
// and gzipped), and skips unrelated files.
|
||||
func TestAddRotatedLogFiles_PicksUpAllVariants(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
writeFile(t, filepath.Join(dir, "client.log"), "active log\n")
|
||||
writeFile(t, filepath.Join(dir, "other.log"), "unrelated\n")
|
||||
|
||||
timberjackRotated := "client-2026-05-21T10-30-45.000.log.gz"
|
||||
writeGzFile(t, filepath.Join(dir, timberjackRotated), "timberjack rotated content\n")
|
||||
|
||||
logrotatePlain := "client.log.1"
|
||||
writeFile(t, filepath.Join(dir, logrotatePlain), "logrotate plain content\n")
|
||||
|
||||
logrotateGz := "client.log.2.gz"
|
||||
writeGzFile(t, filepath.Join(dir, logrotateGz), "logrotate gz content\n")
|
||||
|
||||
names := runAddRotatedLogFiles(t, dir, 10)
|
||||
|
||||
require.Contains(t, names, timberjackRotated, "timberjack rotated file should be in bundle")
|
||||
require.Contains(t, names, logrotatePlain, "logrotate plain rotated file should be in bundle")
|
||||
require.Contains(t, names, logrotateGz, "logrotate gzipped rotated file should be in bundle")
|
||||
require.NotContains(t, names, "client.log", "active log should not be added by addRotatedLogFiles")
|
||||
require.NotContains(t, names, "other.log", "unrelated files should not be in bundle")
|
||||
}
|
||||
|
||||
// TestAddRotatedLogFiles_RespectsLogFileCount asserts that only the newest
|
||||
// logFileCount rotated files are bundled, ordered by mtime.
|
||||
func TestAddRotatedLogFiles_RespectsLogFileCount(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
oldest := filepath.Join(dir, "client.log.3")
|
||||
middle := filepath.Join(dir, "client.log.2")
|
||||
newest := filepath.Join(dir, "client.log.1")
|
||||
writeFile(t, oldest, "old\n")
|
||||
writeFile(t, middle, "mid\n")
|
||||
writeFile(t, newest, "new\n")
|
||||
|
||||
now := time.Now()
|
||||
require.NoError(t, os.Chtimes(oldest, now.Add(-2*time.Hour), now.Add(-2*time.Hour)))
|
||||
require.NoError(t, os.Chtimes(middle, now.Add(-1*time.Hour), now.Add(-1*time.Hour)))
|
||||
require.NoError(t, os.Chtimes(newest, now, now))
|
||||
|
||||
names := runAddRotatedLogFiles(t, dir, 2)
|
||||
|
||||
require.Contains(t, names, "client.log.1")
|
||||
require.Contains(t, names, "client.log.2")
|
||||
require.NotContains(t, names, "client.log.3", "oldest file should be dropped when logFileCount=2")
|
||||
}
|
||||
|
||||
// runAddRotatedLogFiles calls addRotatedLogFiles against a fresh in-memory
|
||||
// zip writer and returns the set of entry names that ended up in the archive.
|
||||
func runAddRotatedLogFiles(t *testing.T, dir string, logFileCount uint32) map[string]struct{} {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
g := &BundleGenerator{
|
||||
archive: zip.NewWriter(&buf),
|
||||
logFileCount: logFileCount,
|
||||
}
|
||||
g.addRotatedLogFiles(dir)
|
||||
require.NoError(t, g.archive.Close())
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
||||
require.NoError(t, err)
|
||||
|
||||
names := make(map[string]struct{}, len(zr.File))
|
||||
for _, f := range zr.File {
|
||||
names[f.Name] = struct{}{}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
|
||||
}
|
||||
|
||||
func writeGzFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
gw := gzip.NewWriter(&buf)
|
||||
_, err := io.WriteString(gw, content)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, gw.Close())
|
||||
require.NoError(t, os.WriteFile(path, buf.Bytes(), 0o644))
|
||||
}
|
||||
@@ -72,6 +72,7 @@ import (
|
||||
sProto "github.com/netbirdio/netbird/shared/signal/proto"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
"github.com/netbirdio/netbird/util/capture"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
// PeerConnectionTimeoutMax is a timeout of an initial connection attempt to a remote peer.
|
||||
@@ -1072,6 +1073,7 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
|
||||
state.PubKey = e.config.WgPrivateKey.PublicKey().String()
|
||||
state.KernelInterface = !e.wgInterface.IsUserspaceBind()
|
||||
state.FQDN = conf.GetFqdn()
|
||||
state.WgPort = e.config.WgPort
|
||||
|
||||
e.statusRecorder.UpdateLocalPeerState(state)
|
||||
|
||||
@@ -1150,6 +1152,7 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
|
||||
LogPath: e.config.LogPath,
|
||||
TempDir: e.config.TempDir,
|
||||
ClientMetrics: e.clientMetrics,
|
||||
DaemonVersion: version.NetbirdVersion(),
|
||||
RefreshStatus: func() {
|
||||
e.RunHealthProbes(true)
|
||||
},
|
||||
|
||||
@@ -111,6 +111,7 @@ type LocalPeerState struct {
|
||||
PubKey string
|
||||
KernelInterface bool
|
||||
FQDN string
|
||||
WgPort int
|
||||
Routes map[string]struct{}
|
||||
}
|
||||
|
||||
@@ -1357,6 +1358,7 @@ func (fs FullStatus) ToProto() *proto.FullStatus {
|
||||
pbFullStatus.LocalPeerState.PubKey = fs.LocalPeerState.PubKey
|
||||
pbFullStatus.LocalPeerState.KernelInterface = fs.LocalPeerState.KernelInterface
|
||||
pbFullStatus.LocalPeerState.Fqdn = fs.LocalPeerState.FQDN
|
||||
pbFullStatus.LocalPeerState.WgPort = int32(fs.LocalPeerState.WgPort)
|
||||
pbFullStatus.LocalPeerState.RosenpassPermissive = fs.RosenpassState.Permissive
|
||||
pbFullStatus.LocalPeerState.RosenpassEnabled = fs.RosenpassState.Enabled
|
||||
pbFullStatus.NumberOfForwardingRules = int32(fs.NumOfForwardingRules)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build cgo && !osusergo && !windows
|
||||
|
||||
package server
|
||||
package shell
|
||||
|
||||
import "os/user"
|
||||
|
||||
@@ -8,17 +8,22 @@ import "os/user"
|
||||
// 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) {
|
||||
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) {
|
||||
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) {
|
||||
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"
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
// 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,25 @@ func lookupWithGetent(username string) (*user.User, error) {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// LookupGroupWithGetent returns the resolved group from either a gid or groupname
|
||||
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) {
|
||||
func CurrentUserWithGetent() (*user.User, error) {
|
||||
u, err := user.Current()
|
||||
if err == nil {
|
||||
return u, nil
|
||||
@@ -42,7 +59,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
|
||||
}
|
||||
@@ -57,7 +74,7 @@ func currentUserWithGetent() (*user.User, error) {
|
||||
// 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,7 +65,7 @@ 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")
|
||||
return
|
||||
@@ -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")
|
||||
}
|
||||
@@ -91,20 +91,20 @@ func TestLookupWithGetent_RootUser(t *testing.T) {
|
||||
// consistent and correct results when composed together.
|
||||
func TestIntegration_FullLookupChain(t *testing.T) {
|
||||
// Step 1: currentUserWithGetent must resolve the running user.
|
||||
current, err := currentUserWithGetent()
|
||||
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)
|
||||
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)
|
||||
groups, err := GroupIdsWithFallback(current)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, groups, "user must have at least one group")
|
||||
|
||||
@@ -123,7 +123,7 @@ func TestIntegration_FullLookupChain(t *testing.T) {
|
||||
// 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)
|
||||
}
|
||||
@@ -138,10 +138,10 @@ func TestIntegration_LookupAndGroupsConsistency(t *testing.T) {
|
||||
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")
|
||||
|
||||
@@ -154,19 +154,3 @@ func TestIntegration_LookupAndGroupsConsistency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegration_ShellLookupChain tests the full shell resolution chain
|
||||
// (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")
|
||||
}
|
||||
|
||||
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")
|
||||
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,25 @@ 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 group from either a uid or username
|
||||
func GetUserFromGetent(user string) (*user.User, error) {
|
||||
u, _, err := runGetentPasswd(user)
|
||||
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,6 +48,23 @@ func runGetent(query string) (*user.User, string, error) {
|
||||
return parseGetentPasswd(string(out))
|
||||
}
|
||||
|
||||
// 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 passwd%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)
|
||||
@@ -67,6 +90,20 @@ func parseGetentPasswd(output string) (*user.User, string, error) {
|
||||
}, shell, nil
|
||||
}
|
||||
|
||||
// parseGetentGroup parses getent group output: "group:x:gid:user"
|
||||
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).
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build !windows
|
||||
|
||||
package server
|
||||
package shell
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
@@ -195,7 +195,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 +208,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 +219,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 +236,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 +283,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 +300,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 +356,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 +400,7 @@ func TestGetShellFromPasswd_MatchesGetentForKnownUsers(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
_, getentShell, err := runGetent(uid)
|
||||
_, getentShell, err := runGetentPasswd(uid)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -1,26 +1,26 @@
|
||||
//go:build windows
|
||||
|
||||
package server
|
||||
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) {
|
||||
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) {
|
||||
func CurrentUserWithGetent() (*user.User, error) {
|
||||
return user.Current()
|
||||
}
|
||||
|
||||
// getShellFromGetent is a no-op on Windows; shell resolution uses PowerShell detection.
|
||||
func getShellFromGetent(_ string) string {
|
||||
func GetShellFromGetent(_ string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// groupIdsWithFallback on Windows just delegates to u.GroupIds().
|
||||
func groupIdsWithFallback(u *user.User) ([]string, error) {
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -24,7 +21,7 @@ const (
|
||||
|
||||
// getUserShell returns the appropriate shell for the given user ID
|
||||
// Handles all platform-specific logic and fallbacks consistently
|
||||
func getUserShell(userID string) string {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
@@ -119,7 +116,7 @@ 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 {
|
||||
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),
|
||||
}
|
||||
}
|
||||
26
client/internal/shell/shell_test.go
Normal file
26
client/internal/shell/shell_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"os/user"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestIntegration_ShellLookupChain tests the full shell resolution chain
|
||||
// (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")
|
||||
}
|
||||
|
||||
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")
|
||||
assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell)
|
||||
}
|
||||
@@ -1614,6 +1614,7 @@ type LocalPeerState struct {
|
||||
RosenpassPermissive bool `protobuf:"varint,6,opt,name=rosenpassPermissive,proto3" json:"rosenpassPermissive,omitempty"`
|
||||
Networks []string `protobuf:"bytes,7,rep,name=networks,proto3" json:"networks,omitempty"`
|
||||
Ipv6 string `protobuf:"bytes,8,opt,name=ipv6,proto3" json:"ipv6,omitempty"`
|
||||
WgPort int32 `protobuf:"varint,9,opt,name=wgPort,proto3" json:"wgPort,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -1704,6 +1705,13 @@ func (x *LocalPeerState) GetIpv6() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LocalPeerState) GetWgPort() int32 {
|
||||
if x != nil {
|
||||
return x.WgPort
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// SignalState contains the latest state of a signal connection
|
||||
type SignalState struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@@ -2709,6 +2717,7 @@ type DebugBundleRequest struct {
|
||||
SystemInfo bool `protobuf:"varint,3,opt,name=systemInfo,proto3" json:"systemInfo,omitempty"`
|
||||
UploadURL string `protobuf:"bytes,4,opt,name=uploadURL,proto3" json:"uploadURL,omitempty"`
|
||||
LogFileCount uint32 `protobuf:"varint,5,opt,name=logFileCount,proto3" json:"logFileCount,omitempty"`
|
||||
CliVersion string `protobuf:"bytes,6,opt,name=cliVersion,proto3" json:"cliVersion,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -2771,6 +2780,13 @@ func (x *DebugBundleRequest) GetLogFileCount() uint32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *DebugBundleRequest) GetCliVersion() string {
|
||||
if x != nil {
|
||||
return x.CliVersion
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type DebugBundleResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
|
||||
@@ -6389,7 +6405,7 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"sshHostKey\x18\x13 \x01(\fR\n" +
|
||||
"sshHostKey\x12\x12\n" +
|
||||
"\x04ipv6\x18\x14 \x01(\tR\x04ipv6\"\x84\x02\n" +
|
||||
"\x04ipv6\x18\x14 \x01(\tR\x04ipv6\"\x9c\x02\n" +
|
||||
"\x0eLocalPeerState\x12\x0e\n" +
|
||||
"\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" +
|
||||
"\x06pubKey\x18\x02 \x01(\tR\x06pubKey\x12(\n" +
|
||||
@@ -6398,7 +6414,8 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\x10rosenpassEnabled\x18\x05 \x01(\bR\x10rosenpassEnabled\x120\n" +
|
||||
"\x13rosenpassPermissive\x18\x06 \x01(\bR\x13rosenpassPermissive\x12\x1a\n" +
|
||||
"\bnetworks\x18\a \x03(\tR\bnetworks\x12\x12\n" +
|
||||
"\x04ipv6\x18\b \x01(\tR\x04ipv6\"S\n" +
|
||||
"\x04ipv6\x18\b \x01(\tR\x04ipv6\x12\x16\n" +
|
||||
"\x06wgPort\x18\t \x01(\x05R\x06wgPort\"S\n" +
|
||||
"\vSignalState\x12\x10\n" +
|
||||
"\x03URL\x18\x01 \x01(\tR\x03URL\x12\x1c\n" +
|
||||
"\tconnected\x18\x02 \x01(\bR\tconnected\x12\x14\n" +
|
||||
@@ -6475,14 +6492,17 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\x12translatedHostname\x18\x04 \x01(\tR\x12translatedHostname\x128\n" +
|
||||
"\x0etranslatedPort\x18\x05 \x01(\v2\x10.daemon.PortInfoR\x0etranslatedPort\"G\n" +
|
||||
"\x17ForwardingRulesResponse\x12,\n" +
|
||||
"\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\x94\x01\n" +
|
||||
"\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\xb4\x01\n" +
|
||||
"\x12DebugBundleRequest\x12\x1c\n" +
|
||||
"\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x1e\n" +
|
||||
"\n" +
|
||||
"systemInfo\x18\x03 \x01(\bR\n" +
|
||||
"systemInfo\x12\x1c\n" +
|
||||
"\tuploadURL\x18\x04 \x01(\tR\tuploadURL\x12\"\n" +
|
||||
"\flogFileCount\x18\x05 \x01(\rR\flogFileCount\"}\n" +
|
||||
"\flogFileCount\x18\x05 \x01(\rR\flogFileCount\x12\x1e\n" +
|
||||
"\n" +
|
||||
"cliVersion\x18\x06 \x01(\tR\n" +
|
||||
"cliVersion\"}\n" +
|
||||
"\x13DebugBundleResponse\x12\x12\n" +
|
||||
"\x04path\x18\x01 \x01(\tR\x04path\x12 \n" +
|
||||
"\vuploadedKey\x18\x02 \x01(\tR\vuploadedKey\x120\n" +
|
||||
|
||||
@@ -349,6 +349,7 @@ message LocalPeerState {
|
||||
bool rosenpassPermissive = 6;
|
||||
repeated string networks = 7;
|
||||
string ipv6 = 8;
|
||||
int32 wgPort = 9;
|
||||
}
|
||||
|
||||
// SignalState contains the latest state of a signal connection
|
||||
@@ -471,6 +472,7 @@ message DebugBundleRequest {
|
||||
bool systemInfo = 3;
|
||||
string uploadURL = 4;
|
||||
uint32 logFileCount = 5;
|
||||
string cliVersion = 6;
|
||||
}
|
||||
|
||||
message DebugBundleResponse {
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if ! which realpath > /dev/null 2>&1
|
||||
then
|
||||
echo realpath is not installed
|
||||
echo run: brew install coreutils
|
||||
exit 1
|
||||
if ! which realpath >/dev/null 2>&1; then
|
||||
echo realpath is not installed
|
||||
echo run: brew install coreutils
|
||||
exit 1
|
||||
fi
|
||||
|
||||
old_pwd=$(pwd)
|
||||
script_path=$(dirname $(realpath "$0"))
|
||||
script_path=$(dirname "$(realpath "$0")")
|
||||
cd "$script_path"
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.6
|
||||
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1
|
||||
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.1
|
||||
protoc -I ./ ./daemon.proto --go_out=../ --go-grpc_out=../ --experimental_allow_proto3_optional
|
||||
cd "$old_pwd"
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/debug"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
// DebugBundle creates a debug bundle and returns the location.
|
||||
@@ -67,6 +68,8 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
|
||||
CapturePath: capturePath,
|
||||
RefreshStatus: refreshStatus,
|
||||
ClientMetrics: clientMetrics,
|
||||
DaemonVersion: version.NetbirdVersion(),
|
||||
CliVersion: req.CliVersion,
|
||||
},
|
||||
debug.BundleConfig{
|
||||
Anonymize: req.GetAnonymize(),
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"github.com/creack/pty"
|
||||
"github.com/gliderlabs/ssh"
|
||||
"github.com/netbirdio/netbird/client/internal/shell"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -146,10 +147,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 := shell.PrepareUserEnv(localUser, shell.GetUserShell(localUser.Uid))
|
||||
env = append(env, prepareSSHEnv(session)...)
|
||||
for _, v := range session.Environ() {
|
||||
if acceptEnv(v) {
|
||||
if shell.AcceptEnv(v) {
|
||||
env = append(env, v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,10 +247,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 := shell.PrepareUserEnv(localUser, shell.GetUserShell(localUser.Uid))
|
||||
env = append(env, prepareSSHEnv(session)...)
|
||||
for _, v := range session.Environ() {
|
||||
if acceptEnv(v) {
|
||||
if shell.AcceptEnv(v) {
|
||||
env = append(env, v)
|
||||
}
|
||||
}
|
||||
@@ -260,7 +260,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 shell.AcceptEnv(v) {
|
||||
env = append(env, v)
|
||||
}
|
||||
}
|
||||
@@ -273,7 +273,7 @@ func (s *Server) handlePtyLogin(logger *log.Entry, session ssh.Session, privileg
|
||||
return false
|
||||
}
|
||||
|
||||
shell := getUserShell(privilegeResult.User.Uid)
|
||||
shell := shell.GetUserShell(privilegeResult.User.Uid)
|
||||
logger.Infof("starting interactive shell: %s", shell)
|
||||
|
||||
s.executeCommandWithPty(logger, session, nil, privilegeResult, ptyReq, nil)
|
||||
@@ -384,7 +384,7 @@ func (s *Server) executeCommandWithPty(logger *log.Entry, session ssh.Session, _
|
||||
}
|
||||
|
||||
username, domain := s.parseUsername(localUser.Username)
|
||||
shell := getUserShell(localUser.Uid)
|
||||
shell := shell.GetUserShell(localUser.Uid)
|
||||
|
||||
req := PtyExecutionRequest{
|
||||
Shell: shell,
|
||||
|
||||
@@ -3,11 +3,15 @@ package server
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
"github.com/netbirdio/netbird/client/internal/shell"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -23,8 +27,8 @@ func isPlatformUnix() bool {
|
||||
|
||||
// Dependency injection variables for testing - allows mocking dynamic runtime checks
|
||||
var (
|
||||
getCurrentUser = currentUserWithGetent
|
||||
lookupUser = lookupWithGetent
|
||||
getCurrentUser = shell.CurrentUserWithGetent
|
||||
lookupUser = shell.LookupWithGetent
|
||||
getCurrentOS = func() string { return runtime.GOOS }
|
||||
getIsProcessPrivileged = isCurrentProcessPrivileged
|
||||
|
||||
@@ -409,3 +413,29 @@ func isWindowsElevated() bool {
|
||||
log.Debugf("Windows user switching not supported: not running as privileged user (current: %s)", currentUser.Uid)
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
"github.com/netbirdio/netbird/client/internal/shell"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -160,7 +161,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 := shell.GroupIdsWithFallback(u)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get group IDs for user %s: %w", u.Username, err)
|
||||
}
|
||||
@@ -196,7 +197,7 @@ func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, l
|
||||
GID: gid,
|
||||
Groups: groups,
|
||||
WorkingDir: localUser.HomeDir,
|
||||
Shell: getUserShell(localUser.Uid),
|
||||
Shell: shell.GetUserShell(localUser.Uid),
|
||||
Command: session.RawCommand(),
|
||||
PTY: hasPty,
|
||||
}
|
||||
@@ -228,7 +229,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 := shell.GetUserShell(localUser.Uid)
|
||||
args := s.getShellCommandArgs(shell, session.RawCommand())
|
||||
|
||||
cmd := s.createShellCommand(session.Context(), shell, args)
|
||||
@@ -245,12 +246,12 @@ func (s *Server) preparePtyEnv(localUser *user.User, ptyReq ssh.Pty, session ssh
|
||||
termType = "xterm-256color"
|
||||
}
|
||||
|
||||
env := prepareUserEnv(localUser, getUserShell(localUser.Uid))
|
||||
env := shell.PrepareUserEnv(localUser, shell.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 shell.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"
|
||||
|
||||
"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)
|
||||
sh := shell.GetUserShell(localUser.Uid)
|
||||
|
||||
rawCmd := session.RawCommand()
|
||||
var command string
|
||||
@@ -116,7 +118,7 @@ func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session,
|
||||
Username: username,
|
||||
Domain: domain,
|
||||
WorkingDir: localUser.HomeDir,
|
||||
Shell: shell,
|
||||
Shell: sh,
|
||||
Command: command,
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ type OutputOverview struct {
|
||||
IPv6 string `json:"netbirdIpv6,omitempty" yaml:"netbirdIpv6,omitempty"`
|
||||
PubKey string `json:"publicKey" yaml:"publicKey"`
|
||||
KernelInterface bool `json:"usesKernelInterface" yaml:"usesKernelInterface"`
|
||||
WgPort int `json:"wireguardPort" yaml:"wireguardPort"`
|
||||
FQDN string `json:"fqdn" yaml:"fqdn"`
|
||||
RosenpassEnabled bool `json:"quantumResistance" yaml:"quantumResistance"`
|
||||
RosenpassPermissive bool `json:"quantumResistancePermissive" yaml:"quantumResistancePermissive"`
|
||||
@@ -187,6 +188,7 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
|
||||
IPv6: pbFullStatus.GetLocalPeerState().GetIpv6(),
|
||||
PubKey: pbFullStatus.GetLocalPeerState().GetPubKey(),
|
||||
KernelInterface: pbFullStatus.GetLocalPeerState().GetKernelInterface(),
|
||||
WgPort: int(pbFullStatus.GetLocalPeerState().GetWgPort()),
|
||||
FQDN: pbFullStatus.GetLocalPeerState().GetFqdn(),
|
||||
RosenpassEnabled: pbFullStatus.GetLocalPeerState().GetRosenpassEnabled(),
|
||||
RosenpassPermissive: pbFullStatus.GetLocalPeerState().GetRosenpassPermissive(),
|
||||
@@ -547,6 +549,21 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
||||
goarm = fmt.Sprintf(" (ARMv%s)", os.Getenv("GOARM"))
|
||||
}
|
||||
|
||||
daemonVersion := "N/A"
|
||||
if o.DaemonVersion != "" {
|
||||
daemonVersion = o.DaemonVersion
|
||||
}
|
||||
|
||||
cliVersion := version.NetbirdVersion()
|
||||
if o.CliVersion != "" {
|
||||
cliVersion = o.CliVersion
|
||||
}
|
||||
|
||||
wgPortString := "N/A"
|
||||
if o.WgPort > 0 {
|
||||
wgPortString = fmt.Sprintf("%d", o.WgPort)
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf(
|
||||
"OS: %s\n"+
|
||||
"Daemon version: %s\n"+
|
||||
@@ -560,6 +577,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
||||
"NetBird IP: %s\n"+
|
||||
"%s"+
|
||||
"Interface type: %s\n"+
|
||||
"Wireguard port: %s\n"+
|
||||
"Quantum resistance: %s\n"+
|
||||
"Lazy connection: %s\n"+
|
||||
"SSH Server: %s\n"+
|
||||
@@ -567,8 +585,8 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
||||
"%s"+
|
||||
"Peers count: %s\n",
|
||||
fmt.Sprintf("%s/%s%s", goos, goarch, goarm),
|
||||
o.DaemonVersion,
|
||||
version.NetbirdVersion(),
|
||||
daemonVersion,
|
||||
cliVersion,
|
||||
o.ProfileName,
|
||||
managementConnString,
|
||||
signalConnString,
|
||||
@@ -578,6 +596,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
||||
interfaceIP,
|
||||
ipv6Line,
|
||||
interfaceTypeString,
|
||||
wgPortString,
|
||||
rosenpassEnabledStatus,
|
||||
lazyConnectionEnabledStatus,
|
||||
sshServerStatus,
|
||||
|
||||
@@ -94,6 +94,7 @@ var resp = &proto.StatusResponse{
|
||||
Ipv6: "fd00::100",
|
||||
PubKey: "Some-Pub-Key",
|
||||
KernelInterface: true,
|
||||
WgPort: 51820,
|
||||
Fqdn: "some-localhost.awesome-domain.com",
|
||||
Networks: []string{
|
||||
"10.10.0.0/24",
|
||||
@@ -210,6 +211,7 @@ var overview = OutputOverview{
|
||||
IPv6: "fd00::100",
|
||||
PubKey: "Some-Pub-Key",
|
||||
KernelInterface: true,
|
||||
WgPort: 51820,
|
||||
FQDN: "some-localhost.awesome-domain.com",
|
||||
NSServerGroups: []NsServerGroupStateOutput{
|
||||
{
|
||||
@@ -369,6 +371,7 @@ func TestParsingToJSON(t *testing.T) {
|
||||
"netbirdIpv6": "fd00::100",
|
||||
"publicKey": "Some-Pub-Key",
|
||||
"usesKernelInterface": true,
|
||||
"wireguardPort": 51820,
|
||||
"fqdn": "some-localhost.awesome-domain.com",
|
||||
"quantumResistance": false,
|
||||
"quantumResistancePermissive": false,
|
||||
@@ -487,6 +490,7 @@ netbirdIp: 192.168.178.100/16
|
||||
netbirdIpv6: fd00::100
|
||||
publicKey: Some-Pub-Key
|
||||
usesKernelInterface: true
|
||||
wireguardPort: 51820
|
||||
fqdn: some-localhost.awesome-domain.com
|
||||
quantumResistance: false
|
||||
quantumResistancePermissive: false
|
||||
@@ -579,12 +583,13 @@ FQDN: some-localhost.awesome-domain.com
|
||||
NetBird IP: 192.168.178.100/16
|
||||
NetBird IPv6: fd00::100
|
||||
Interface type: Kernel
|
||||
Wireguard port: %d
|
||||
Quantum resistance: false
|
||||
Lazy connection: false
|
||||
SSH Server: Disabled
|
||||
Networks: 10.10.0.0/24
|
||||
Peers count: 2/2 Connected
|
||||
`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion)
|
||||
`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion, overview.WgPort)
|
||||
|
||||
assert.Equal(t, expectedDetail, detail)
|
||||
}
|
||||
@@ -604,6 +609,7 @@ FQDN: some-localhost.awesome-domain.com
|
||||
NetBird IP: 192.168.178.100/16
|
||||
NetBird IPv6: fd00::100
|
||||
Interface type: Kernel
|
||||
Wireguard port: 51820
|
||||
Quantum resistance: false
|
||||
Lazy connection: false
|
||||
SSH Server: Disabled
|
||||
|
||||
@@ -502,7 +502,7 @@ func (s *serviceClient) getConnectionForm() *widget.Form {
|
||||
{Text: "Pre-shared Key", Widget: s.iPreSharedKey},
|
||||
{Text: "Quantum-Resistance", Widget: s.sRosenpassPermissive},
|
||||
{Text: "Interface Name", Widget: s.iInterfaceName},
|
||||
{Text: "Interface Port", Widget: s.iInterfacePort},
|
||||
{Text: "Interface Port", Widget: s.iInterfacePort, HintText: "If set to 0, a random free port will be used"},
|
||||
{Text: "MTU", Widget: s.iMTU},
|
||||
{Text: "Log File", Widget: s.iLogFile},
|
||||
},
|
||||
@@ -558,8 +558,8 @@ func (s *serviceClient) parseNumericSettings() (int64, int64, error) {
|
||||
if err != nil {
|
||||
return 0, 0, errors.New("invalid interface port")
|
||||
}
|
||||
if port < 1 || port > 65535 {
|
||||
return 0, 0, errors.New("invalid interface port: out of range 1-65535")
|
||||
if port < 0 || port > 65535 {
|
||||
return 0, 0, errors.New("invalid interface port: out of range 0-65535")
|
||||
}
|
||||
|
||||
var mtu int64
|
||||
@@ -1438,7 +1438,7 @@ func protoConfigToConfig(cfg *proto.GetConfigResponse) *profilemanager.Config {
|
||||
}
|
||||
|
||||
config.WgIface = cfg.InterfaceName
|
||||
if cfg.WireguardPort != 0 {
|
||||
if cfg.WireguardPort >= 0 && cfg.WireguardPort <= 65535 {
|
||||
config.WgPort = int(cfg.WireguardPort)
|
||||
} else {
|
||||
config.WgPort = iface.DefaultWgPort
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
uptypes "github.com/netbirdio/netbird/upload-server/types"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
// Initial state for the debug collection
|
||||
@@ -462,6 +463,7 @@ func (s *serviceClient) createDebugBundleFromCollection(
|
||||
request := &proto.DebugBundleRequest{
|
||||
Anonymize: params.anonymize,
|
||||
SystemInfo: params.systemInfo,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
}
|
||||
|
||||
if params.upload {
|
||||
@@ -593,6 +595,7 @@ func (s *serviceClient) createDebugBundle(anonymize bool, systemInfo bool, uploa
|
||||
request := &proto.DebugBundleRequest{
|
||||
Anonymize: anonymize,
|
||||
SystemInfo: systemInfo,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
}
|
||||
|
||||
if uploadURL != "" {
|
||||
|
||||
2
go.mod
2
go.mod
@@ -24,13 +24,13 @@ require (
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3
|
||||
google.golang.org/grpc v1.80.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
)
|
||||
|
||||
require (
|
||||
fyne.io/fyne/v2 v2.7.0
|
||||
fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3
|
||||
github.com/DeRuina/timberjack v1.4.2
|
||||
github.com/awnumar/memguard v0.23.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.38.3
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.6
|
||||
|
||||
4
go.sum
4
go.sum
@@ -29,6 +29,8 @@ github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+
|
||||
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/DeRuina/timberjack v1.4.2 h1:4bKlzhKdsR+2oNkgef9mqb4n11ICow8VK88RfzJPzN8=
|
||||
github.com/DeRuina/timberjack v1.4.2/go.mod h1:RLoeQrwrCGIEF8gO5nV5b/gMD0QIy7bzQhBUgpp1EqE=
|
||||
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
|
||||
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
|
||||
github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0=
|
||||
@@ -940,8 +942,6 @@ gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8
|
||||
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
|
||||
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
|
||||
@@ -19,6 +19,46 @@ readonly MSG_SEPARATOR="=========================================="
|
||||
# Utility Functions
|
||||
############################################
|
||||
|
||||
check_docker_sock_perms() {
|
||||
local sock="${DOCKER_HOST:-unix:///var/run/docker.sock}"
|
||||
sock="${sock#unix://}"
|
||||
|
||||
if [[ ! -S "$sock" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ ! -r "$sock" ]] || [[ ! -w "$sock" ]]; then
|
||||
local group
|
||||
if [[ "${OSTYPE}" == "darwin"* ]]; then
|
||||
group="$(stat -f '%Sg' "$sock")"
|
||||
else
|
||||
group="$(stat -c '%G' "$sock")"
|
||||
fi
|
||||
|
||||
echo "Cannot access Docker socket: $sock" > /dev/stderr
|
||||
echo "" > /dev/stderr
|
||||
echo "Socket permissions:" > /dev/stderr
|
||||
ls -l "$sock" > /dev/stderr
|
||||
echo "" > /dev/stderr
|
||||
|
||||
if [[ "$group" == "docker" ]]; then
|
||||
echo "Your user may need to be added to the '$group' group:" > /dev/stderr
|
||||
echo " sudo usermod -aG $group \"$USER\"" > /dev/stderr
|
||||
echo "Then log out and back in, or run this for the current shell:" > /dev/stderr
|
||||
echo " newgrp $group" > /dev/stderr
|
||||
echo "Note: newgrp is temporary; usermod is the permanent group change." > /dev/stderr
|
||||
else
|
||||
echo "The Docker socket is owned by the '$group' group, which is not the standard 'docker' group." > /dev/stderr
|
||||
echo "For safety, this script will not suggest adding your user to '$group'." > /dev/stderr
|
||||
echo "Instead, either run this script with appropriate privileges (for example, via sudo) or follow Docker's post-install steps to configure access via the 'docker' group:" > /dev/stderr
|
||||
echo " https://docs.docker.com/engine/install/linux-postinstall/" > /dev/stderr
|
||||
fi
|
||||
|
||||
exit 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
check_docker_compose() {
|
||||
if command -v docker-compose &> /dev/null
|
||||
then
|
||||
@@ -311,11 +351,12 @@ initialize_default_values() {
|
||||
NETBIRD_STUN_PORT=3478
|
||||
|
||||
# Docker images
|
||||
DASHBOARD_IMAGE="netbirdio/dashboard:latest"
|
||||
DASHBOARD_IMAGE=${DASHBOARD_IMAGE:-"netbirdio/dashboard:latest"}
|
||||
# Combined server replaces separate signal, relay, and management containers
|
||||
NETBIRD_SERVER_IMAGE="netbirdio/netbird-server:latest"
|
||||
NETBIRD_PROXY_IMAGE="netbirdio/reverse-proxy:latest"
|
||||
|
||||
NETBIRD_SERVER_IMAGE=${NETBIRD_SERVER_IMAGE:-"netbirdio/netbird-server:latest"}
|
||||
NETBIRD_PROXY_IMAGE=${NETBIRD_PROXY_IMAGE:-"netbirdio/reverse-proxy:latest"}
|
||||
TRAEFIK_IMAGE=${TRAEFIK_IMAGE:-"traefik:v3.6"}
|
||||
CROWDSEC_IMAGE=${CROWDSEC_IMAGE:-"crowdsecurity/crowdsec:v1.7.7"}
|
||||
# Reverse proxy configuration
|
||||
REVERSE_PROXY_TYPE="0"
|
||||
TRAEFIK_EXTERNAL_NETWORK=""
|
||||
@@ -580,12 +621,15 @@ start_services_and_show_instructions() {
|
||||
}
|
||||
|
||||
init_environment() {
|
||||
# Check if docker compose is installed using check_docker_compose function
|
||||
DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
|
||||
check_docker_sock_perms
|
||||
|
||||
initialize_default_values
|
||||
configure_domain
|
||||
configure_reverse_proxy
|
||||
|
||||
check_jq
|
||||
DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
|
||||
|
||||
check_existing_installation
|
||||
generate_configuration_files
|
||||
@@ -656,7 +700,7 @@ render_docker_compose_traefik_builtin() {
|
||||
if [[ "$ENABLE_CROWDSEC" == "true" ]]; then
|
||||
crowdsec_service="
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.7.7
|
||||
image: $CROWDSEC_IMAGE
|
||||
container_name: netbird-crowdsec
|
||||
restart: unless-stopped
|
||||
networks: [netbird]
|
||||
@@ -687,7 +731,7 @@ render_docker_compose_traefik_builtin() {
|
||||
services:
|
||||
# Traefik reverse proxy (automatic TLS via Let's Encrypt)
|
||||
traefik:
|
||||
image: traefik:v3.6
|
||||
image: $TRAEFIK_IMAGE
|
||||
container_name: netbird-traefik
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
@@ -771,7 +815,7 @@ $traefik_dynamic_volume
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# gRPC router (needs h2c backend for HTTP/2 cleartext)
|
||||
- traefik.http.routers.netbird-grpc.rule=Host(\`$NETBIRD_DOMAIN\`) && (PathPrefix(\`/signalexchange.SignalExchange/\`) || PathPrefix(\`/management.ManagementService/\`))
|
||||
- traefik.http.routers.netbird-grpc.rule=Host(\`$NETBIRD_DOMAIN\`) && (PathPrefix(\`/signalexchange.SignalExchange/\`) || PathPrefix(\`/management.ManagementService/\`) || PathPrefix(\`/management.ProxyService/\`))
|
||||
- traefik.http.routers.netbird-grpc.entrypoints=websecure
|
||||
- traefik.http.routers.netbird-grpc.tls=true
|
||||
- traefik.http.routers.netbird-grpc.tls.certresolver=letsencrypt
|
||||
|
||||
@@ -122,7 +122,7 @@ func (s *BaseServer) Start(ctx context.Context) error {
|
||||
s.errCh = make(chan error, 4)
|
||||
|
||||
if s.autoResolveDomains {
|
||||
s.resolveDomains(srvCtx)
|
||||
s.ResolveDomains(srvCtx)
|
||||
}
|
||||
|
||||
s.PeersManager()
|
||||
@@ -398,10 +398,10 @@ func (s *BaseServer) serveGRPCWithHTTP(ctx context.Context, listener net.Listene
|
||||
}()
|
||||
}
|
||||
|
||||
// resolveDomains determines dnsDomain and mgmtSingleAccModeDomain based on store state.
|
||||
// ResolveDomains determines dnsDomain and mgmtSingleAccModeDomain based on store state.
|
||||
// Fresh installs use the default self-hosted domain, while existing installs reuse the
|
||||
// persisted account domain to keep addressing stable across config changes.
|
||||
func (s *BaseServer) resolveDomains(ctx context.Context) {
|
||||
func (s *BaseServer) ResolveDomains(ctx context.Context) {
|
||||
st := s.Store()
|
||||
|
||||
setDefault := func(logMsg string, args ...any) {
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestResolveDomains_FreshInstallUsesDefault(t *testing.T) {
|
||||
srv := NewServer(&Config{NbConfig: &nbconfig.Config{}})
|
||||
Inject[store.Store](srv, mockStore)
|
||||
|
||||
srv.resolveDomains(context.Background())
|
||||
srv.ResolveDomains(context.Background())
|
||||
|
||||
require.Equal(t, DefaultSelfHostedDomain, srv.dnsDomain)
|
||||
require.Equal(t, DefaultSelfHostedDomain, srv.mgmtSingleAccModeDomain)
|
||||
@@ -40,7 +40,7 @@ func TestResolveDomains_ExistingInstallUsesPersistedDomain(t *testing.T) {
|
||||
srv := NewServer(&Config{NbConfig: &nbconfig.Config{}})
|
||||
Inject[store.Store](srv, mockStore)
|
||||
|
||||
srv.resolveDomains(context.Background())
|
||||
srv.ResolveDomains(context.Background())
|
||||
|
||||
require.Equal(t, "vpn.mycompany.com", srv.dnsDomain)
|
||||
require.Equal(t, "vpn.mycompany.com", srv.mgmtSingleAccModeDomain)
|
||||
@@ -56,7 +56,7 @@ func TestResolveDomains_StoreErrorFallsBackToDefault(t *testing.T) {
|
||||
srv := NewServer(&Config{NbConfig: &nbconfig.Config{}})
|
||||
Inject[store.Store](srv, mockStore)
|
||||
|
||||
srv.resolveDomains(context.Background())
|
||||
srv.ResolveDomains(context.Background())
|
||||
|
||||
require.Equal(t, DefaultSelfHostedDomain, srv.dnsDomain)
|
||||
require.Equal(t, DefaultSelfHostedDomain, srv.mgmtSingleAccModeDomain)
|
||||
|
||||
@@ -978,6 +978,7 @@ func shallowCloneMapping(m *proto.ProxyMapping) *proto.ProxyMapping {
|
||||
Mode: m.Mode,
|
||||
ListenPort: m.ListenPort,
|
||||
AccessRestrictions: m.AccessRestrictions,
|
||||
Private: m.Private,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
88
management/internals/shared/grpc/proxy_clone_test.go
Normal file
88
management/internals/shared/grpc/proxy_clone_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// authTokenField is the only per-proxy field that shallowCloneMapping must NOT
|
||||
// copy from the source, since callers assign it individually after cloning.
|
||||
const authTokenField = "AuthToken"
|
||||
|
||||
// TestShallowCloneMapping_ClonesAllFields populates every exported field of
|
||||
// ProxyMapping with a non-zero value and verifies the clone carries each one
|
||||
// (except AuthToken). It uses reflection so adding a new field to ProxyMapping
|
||||
// without updating shallowCloneMapping fails this test.
|
||||
func TestShallowCloneMapping_ClonesAllFields(t *testing.T) {
|
||||
src := &proto.ProxyMapping{}
|
||||
populated := populateExportedFields(t, reflect.ValueOf(src).Elem())
|
||||
require.NotEmpty(t, populated, "ProxyMapping should expose fields to populate")
|
||||
|
||||
clone := shallowCloneMapping(src)
|
||||
require.NotNil(t, clone, "clone must not be nil")
|
||||
|
||||
srcVal := reflect.ValueOf(src).Elem()
|
||||
cloneVal := reflect.ValueOf(clone).Elem()
|
||||
|
||||
for _, name := range populated {
|
||||
srcField := srcVal.FieldByName(name).Interface()
|
||||
cloneField := cloneVal.FieldByName(name).Interface()
|
||||
|
||||
if name == authTokenField {
|
||||
assert.Zero(t, cloneField, "AuthToken must not be cloned; it is set per proxy after cloning")
|
||||
continue
|
||||
}
|
||||
|
||||
assert.Equal(t, srcField, cloneField, "field %s must be carried over by shallowCloneMapping", name)
|
||||
}
|
||||
}
|
||||
|
||||
// populateExportedFields sets a non-zero value on every settable exported field
|
||||
// of the struct and returns their names.
|
||||
func populateExportedFields(t *testing.T, v reflect.Value) []string {
|
||||
t.Helper()
|
||||
|
||||
var names []string
|
||||
typ := v.Type()
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
field := v.Field(i)
|
||||
structField := typ.Field(i)
|
||||
|
||||
if structField.PkgPath != "" || !field.CanSet() {
|
||||
continue
|
||||
}
|
||||
|
||||
setNonZero(t, field, structField.Name)
|
||||
names = append(names, structField.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// setNonZero assigns a deterministic non-zero value based on the field kind.
|
||||
func setNonZero(t *testing.T, field reflect.Value, name string) {
|
||||
t.Helper()
|
||||
|
||||
switch field.Kind() {
|
||||
case reflect.String:
|
||||
field.SetString("non-zero-" + name)
|
||||
case reflect.Bool:
|
||||
field.SetBool(true)
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
field.SetInt(7)
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
field.SetUint(7)
|
||||
case reflect.Ptr:
|
||||
field.Set(reflect.New(field.Type().Elem()))
|
||||
case reflect.Slice:
|
||||
field.Set(reflect.MakeSlice(field.Type(), 1, 1))
|
||||
case reflect.Map:
|
||||
field.Set(reflect.MakeMapWithSize(field.Type(), 0))
|
||||
default:
|
||||
t.Fatalf("unhandled field kind %s for field %s; extend setNonZero", field.Kind(), name)
|
||||
}
|
||||
}
|
||||
@@ -1216,6 +1216,7 @@ func (s *SqlStore) getAccountGorm(ctx context.Context, accountID string) (*types
|
||||
Preload("NetworkResources").
|
||||
Preload("Onboarding").
|
||||
Preload("Services.Targets").
|
||||
Preload("Domains").
|
||||
Take(&account, idQueryCondition, accountID)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("error when getting account %s from the store: %s", accountID, result.Error)
|
||||
@@ -1302,7 +1303,7 @@ func (s *SqlStore) getAccountPgx(ctx context.Context, accountID string) (*types.
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errChan := make(chan error, 12)
|
||||
errChan := make(chan error, 16)
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
@@ -1403,6 +1404,17 @@ func (s *SqlStore) getAccountPgx(ctx context.Context, accountID string) (*types.
|
||||
account.Services = services
|
||||
}()
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
domains, err := s.ListCustomDomains(ctx, accountID)
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
account.Domains = domains
|
||||
}()
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -21,6 +23,63 @@ import (
|
||||
"github.com/netbirdio/netbird/route"
|
||||
)
|
||||
|
||||
// TestGetAccount_LoadsCustomDomains verifies GetAccount populates account.Domains.
|
||||
// SynthesizePrivateServiceZones depends on this relation to anchor a custom-domain
|
||||
// private service's DNS zone; without the preload the relation is empty and the
|
||||
// service is silently skipped, so a custom domain never resolves on clients.
|
||||
func TestGetAccount_LoadsCustomDomains(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("The SQLite store is not properly supported by Windows yet")
|
||||
}
|
||||
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer cleanup()
|
||||
|
||||
assertGetAccountLoadsCustomDomains(t, store)
|
||||
}
|
||||
|
||||
func TestPostgresql_GetAccount_LoadsCustomDomains(t *testing.T) {
|
||||
if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" {
|
||||
t.Skip("skip CI tests on darwin and windows")
|
||||
}
|
||||
|
||||
t.Setenv("NETBIRD_STORE_ENGINE", string(types.PostgresStoreEngine))
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
assertGetAccountLoadsCustomDomains(t, store)
|
||||
}
|
||||
|
||||
// assertGetAccountLoadsCustomDomains exercises both the gorm and pgx GetAccount
|
||||
// paths: it persists two custom domains and asserts the relation comes back
|
||||
// populated, which SynthesizePrivateServiceZones relies on.
|
||||
func assertGetAccountLoadsCustomDomains(t *testing.T, store Store) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
accountID := "acct-custom-domains"
|
||||
require.NoError(t, store.SaveAccount(ctx, newAccountWithId(ctx, accountID, "user-1", "")))
|
||||
|
||||
_, err := store.CreateCustomDomain(ctx, accountID, "example.com", "eu.proxy.netbird.io", true)
|
||||
require.NoError(t, err, "creating the first custom domain must succeed")
|
||||
_, err = store.CreateCustomDomain(ctx, accountID, "apps.acme.io", "us.proxy.netbird.io", false)
|
||||
require.NoError(t, err, "creating the second custom domain must succeed")
|
||||
|
||||
account, err := store.GetAccount(ctx, accountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, account.Domains, 2, "GetAccount must preload the account's custom domains")
|
||||
|
||||
byDomain := map[string]string{}
|
||||
for _, d := range account.Domains {
|
||||
require.NotNil(t, d)
|
||||
byDomain[d.Domain] = d.TargetCluster
|
||||
}
|
||||
assert.Equal(t, "eu.proxy.netbird.io", byDomain["example.com"], "custom domain must carry its target cluster")
|
||||
assert.Equal(t, "us.proxy.netbird.io", byDomain["apps.acme.io"], "custom domain must carry its target cluster")
|
||||
}
|
||||
|
||||
// TestGetAccount_ComprehensiveFieldValidation validates that GetAccount properly loads
|
||||
// all fields and nested objects from the database, including deeply nested structures.
|
||||
func TestGetAccount_ComprehensiveFieldValidation(t *testing.T) {
|
||||
|
||||
@@ -273,7 +273,7 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
|
||||
}
|
||||
|
||||
peerGroups := a.GetPeerGroups(peerID)
|
||||
zonesByCluster := map[string]*nbdns.CustomZone{}
|
||||
zonesByApex := map[string]*nbdns.CustomZone{}
|
||||
|
||||
for _, svc := range a.Services {
|
||||
if svc == nil || !svc.Enabled || !svc.Private {
|
||||
@@ -290,19 +290,24 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
|
||||
continue
|
||||
}
|
||||
|
||||
zone, exists := zonesByCluster[svc.ProxyCluster]
|
||||
serviceDomainZone := a.privateServiceDomainZone(svc)
|
||||
if serviceDomainZone == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
zone, exists := zonesByApex[serviceDomainZone]
|
||||
if !exists {
|
||||
// NonAuthoritative makes this a match-only zone: queries for
|
||||
// names without an explicit record fall through to the
|
||||
// upstream resolver instead of returning NXDOMAIN. Without
|
||||
// it, adding a single private service would black-hole every
|
||||
// other name under the cluster apex.
|
||||
// other name under the zone apex.
|
||||
zone = &nbdns.CustomZone{
|
||||
Domain: dns.Fqdn(svc.ProxyCluster),
|
||||
Domain: dns.Fqdn(serviceDomainZone),
|
||||
Records: []nbdns.SimpleRecord{},
|
||||
NonAuthoritative: true,
|
||||
}
|
||||
zonesByCluster[svc.ProxyCluster] = zone
|
||||
zonesByApex[serviceDomainZone] = zone
|
||||
}
|
||||
|
||||
emitted := 0
|
||||
@@ -340,8 +345,8 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]nbdns.CustomZone, 0, len(zonesByCluster))
|
||||
for _, zone := range zonesByCluster {
|
||||
out := make([]nbdns.CustomZone, 0, len(zonesByApex))
|
||||
for _, zone := range zonesByApex {
|
||||
if len(zone.Records) == 0 {
|
||||
continue
|
||||
}
|
||||
@@ -357,6 +362,33 @@ func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZon
|
||||
return out
|
||||
}
|
||||
|
||||
// privateServiceDomainZone returns the DNS zone name for the given private service domain by
|
||||
// looking at the proxy cluster domain then the custom domains.
|
||||
func (a *Account) privateServiceDomainZone(svc *service.Service) string {
|
||||
if domainFromSuffix(svc.Domain, svc.ProxyCluster) {
|
||||
return svc.ProxyCluster
|
||||
}
|
||||
|
||||
// Longest matching custom domain wins
|
||||
zoneName := ""
|
||||
for _, d := range a.Domains {
|
||||
if d == nil || d.TargetCluster != svc.ProxyCluster {
|
||||
continue
|
||||
}
|
||||
if domainFromSuffix(svc.Domain, d.Domain) && len(d.Domain) > len(zoneName) {
|
||||
zoneName = d.Domain
|
||||
}
|
||||
}
|
||||
return zoneName
|
||||
}
|
||||
|
||||
func domainFromSuffix(domain, suffix string) bool {
|
||||
if suffix == "" {
|
||||
return false
|
||||
}
|
||||
return domain == suffix || strings.HasSuffix(domain, "."+suffix)
|
||||
}
|
||||
|
||||
// peerInDistributionGroups reports whether any of the peer's groups
|
||||
// matches the service's bearer-auth distribution_groups.
|
||||
func peerInDistributionGroups(peerGroups LookupMap, distributionGroups []string) bool {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
proxydomain "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
)
|
||||
@@ -234,6 +235,113 @@ func TestPrivateZone_GetPeerNetworkMap_PeerOutsideGroups_OmitsSynthZone(t *testi
|
||||
assert.False(t, ok, "peer outside the distribution_groups must not see the synth zone")
|
||||
}
|
||||
|
||||
func TestSynthesizePrivateServiceZones_CustomDomain_ZoneApexIsRegisteredDomain(t *testing.T) {
|
||||
account := privateZoneTestAccount(t)
|
||||
// A custom-domain service: Domain is the custom FQDN, ProxyCluster
|
||||
// is the cluster serving it, and account.Domains holds the registered
|
||||
// custom domain. The synth zone apex must be the registered domain,
|
||||
// not the cluster, or the client's match-only zone never intercepts
|
||||
// the query.
|
||||
account.Services[0].Domain = "app.example.com"
|
||||
account.Domains = []*proxydomain.Domain{
|
||||
{Domain: "example.com", AccountID: "acct-1", TargetCluster: "eu.proxy.netbird.io", Validated: true},
|
||||
}
|
||||
|
||||
zones := account.SynthesizePrivateServiceZones("user-peer")
|
||||
require.Len(t, zones, 1, "custom-domain service must still produce one zone")
|
||||
zone := zones[0]
|
||||
assert.Equal(t, "example.com.", zone.Domain, "zone apex must be the registered custom domain, not the cluster or the service FQDN")
|
||||
assert.True(t, zone.NonAuthoritative, "synth zone must remain match-only")
|
||||
require.Len(t, zone.Records, 1, "custom-domain service yields one A record")
|
||||
rec := zone.Records[0]
|
||||
assert.Equal(t, "app.example.com.", rec.Name, "record name is the custom service FQDN")
|
||||
assert.Equal(t, "100.64.0.99", rec.RData, "record points at the embedded proxy peer's tunnel IP")
|
||||
}
|
||||
|
||||
func TestSynthesizePrivateServiceZones_CustomAndFreeDomain_SeparateZones(t *testing.T) {
|
||||
account := privateZoneTestAccount(t)
|
||||
account.Domains = []*proxydomain.Domain{
|
||||
{Domain: "example.com", AccountID: "acct-1", TargetCluster: "eu.proxy.netbird.io", Validated: true},
|
||||
}
|
||||
account.Services = append(account.Services, &service.Service{
|
||||
ID: "svc-2",
|
||||
AccountID: "acct-1",
|
||||
Name: "custom",
|
||||
Domain: "app.example.com",
|
||||
ProxyCluster: "eu.proxy.netbird.io",
|
||||
Enabled: true,
|
||||
Private: true,
|
||||
Mode: service.ModeHTTP,
|
||||
AccessGroups: []string{"grp-admins"},
|
||||
})
|
||||
|
||||
zones := account.SynthesizePrivateServiceZones("user-peer")
|
||||
require.Len(t, zones, 2, "a free-domain and a custom-domain service must not collapse into one zone")
|
||||
|
||||
free, ok := findCustomZone(zones, "eu.proxy.netbird.io")
|
||||
require.True(t, ok, "free-domain service keeps the shared cluster-apex zone")
|
||||
require.Len(t, free.Records, 1, "cluster zone carries only the free-domain record")
|
||||
assert.Equal(t, "myapp.eu.proxy.netbird.io.", free.Records[0].Name, "cluster zone record is the free-domain FQDN")
|
||||
|
||||
custom, ok := findCustomZone(zones, "example.com")
|
||||
require.True(t, ok, "custom-domain service gets its own zone at the registered custom domain apex")
|
||||
require.Len(t, custom.Records, 1, "custom zone carries only the custom-domain record")
|
||||
assert.Equal(t, "app.example.com.", custom.Records[0].Name, "custom zone record is the custom-domain FQDN")
|
||||
}
|
||||
|
||||
func TestSynthesizePrivateServiceZones_TwoServicesSameCustomDomain_OneZone(t *testing.T) {
|
||||
account := privateZoneTestAccount(t)
|
||||
account.Domains = []*proxydomain.Domain{
|
||||
{Domain: "example.com", AccountID: "acct-1", TargetCluster: "eu.proxy.netbird.io", Validated: true},
|
||||
}
|
||||
account.Services[0].Domain = "a.example.com"
|
||||
account.Services = append(account.Services, &service.Service{
|
||||
ID: "svc-2",
|
||||
AccountID: "acct-1",
|
||||
Name: "bapp",
|
||||
Domain: "b.example.com",
|
||||
ProxyCluster: "eu.proxy.netbird.io",
|
||||
Enabled: true,
|
||||
Private: true,
|
||||
Mode: service.ModeHTTP,
|
||||
AccessGroups: []string{"grp-admins"},
|
||||
})
|
||||
|
||||
zones := account.SynthesizePrivateServiceZones("user-peer")
|
||||
require.Len(t, zones, 1, "two services under the same registered custom domain must share one zone")
|
||||
assert.Equal(t, "example.com.", zones[0].Domain, "shared zone apex is the registered custom domain")
|
||||
require.Len(t, zones[0].Records, 2, "both services surface as records in the shared custom-domain zone")
|
||||
names := []string{zones[0].Records[0].Name, zones[0].Records[1].Name}
|
||||
assert.ElementsMatch(t, []string{"a.example.com.", "b.example.com."}, names, "both custom-domain service FQDNs must surface")
|
||||
}
|
||||
|
||||
func TestSynthesizePrivateServiceZones_CustomDomainNotRegistered_NoZone(t *testing.T) {
|
||||
account := privateZoneTestAccount(t)
|
||||
// Service domain is outside the cluster and no account.Domains entry
|
||||
// covers it: there is no apex that would intercept the query, so the
|
||||
// service must be skipped rather than emit an unmatchable record.
|
||||
account.Services[0].Domain = "app.example.com"
|
||||
|
||||
zones := account.SynthesizePrivateServiceZones("user-peer")
|
||||
assert.Empty(t, zones, "a custom-domain service with no registered domain apex must not produce a zone")
|
||||
}
|
||||
|
||||
func TestSynthesizePrivateServiceZones_CustomDomainClusterMismatch_NoZone(t *testing.T) {
|
||||
account := privateZoneTestAccount(t)
|
||||
// The registered custom domain matches the service FQDN by suffix but
|
||||
// targets a different cluster than the service's ProxyCluster. It must
|
||||
// be ignored, leaving no apex to intercept the query — otherwise the
|
||||
// zone would point at this cluster's proxy peers under a domain owned
|
||||
// by a different cluster.
|
||||
account.Services[0].Domain = "app.example.com"
|
||||
account.Domains = []*proxydomain.Domain{
|
||||
{Domain: "example.com", AccountID: "acct-1", TargetCluster: "us.proxy.netbird.io", Validated: true},
|
||||
}
|
||||
|
||||
zones := account.SynthesizePrivateServiceZones("user-peer")
|
||||
assert.Empty(t, zones, "a custom domain targeting a different cluster must not anchor the service zone")
|
||||
}
|
||||
|
||||
func TestSynthesizePrivateServiceZones_TwoServicesSameCluster_OneZone(t *testing.T) {
|
||||
account := privateZoneTestAccount(t)
|
||||
account.Services = append(account.Services, &service.Service{
|
||||
@@ -254,3 +362,72 @@ func TestSynthesizePrivateServiceZones_TwoServicesSameCluster_OneZone(t *testing
|
||||
names := []string{zones[0].Records[0].Name, zones[0].Records[1].Name}
|
||||
assert.ElementsMatch(t, []string{"myapp.eu.proxy.netbird.io.", "anotherapp.eu.proxy.netbird.io."}, names, "both service domains must surface")
|
||||
}
|
||||
|
||||
func TestSynthesizePrivateServiceZones_MixedClusterCustomAndPublic(t *testing.T) {
|
||||
account := privateZoneTestAccount(t)
|
||||
account.Domains = []*proxydomain.Domain{
|
||||
{Domain: "example.com", AccountID: "acct-1", TargetCluster: "eu.proxy.netbird.io", Validated: true},
|
||||
}
|
||||
|
||||
privateService := func(id, domain string) *service.Service {
|
||||
return &service.Service{
|
||||
ID: id,
|
||||
AccountID: "acct-1",
|
||||
Name: id,
|
||||
Domain: domain,
|
||||
ProxyCluster: "eu.proxy.netbird.io",
|
||||
Enabled: true,
|
||||
Private: true,
|
||||
Mode: service.ModeHTTP,
|
||||
AccessGroups: []string{"grp-admins"},
|
||||
}
|
||||
}
|
||||
publicService := func(id, domain string) *service.Service {
|
||||
s := privateService(id, domain)
|
||||
s.Private = false
|
||||
return s
|
||||
}
|
||||
|
||||
account.Services = []*service.Service{
|
||||
// 3 private services under the cluster suffix.
|
||||
privateService("cluster-1", "cluster1.eu.proxy.netbird.io"),
|
||||
privateService("cluster-2", "cluster2.eu.proxy.netbird.io"),
|
||||
privateService("cluster-3", "cluster3.eu.proxy.netbird.io"),
|
||||
// 4 private services under the custom domain suffix.
|
||||
privateService("custom-1", "custom1.example.com"),
|
||||
privateService("custom-2", "custom2.example.com"),
|
||||
privateService("custom-3", "custom3.example.com"),
|
||||
privateService("custom-4", "custom4.example.com"),
|
||||
// 2 public services, one per suffix, must not surface.
|
||||
publicService("public-cluster", "public.eu.proxy.netbird.io"),
|
||||
publicService("public-custom", "public.example.com"),
|
||||
}
|
||||
|
||||
zones := account.SynthesizePrivateServiceZones("user-peer")
|
||||
require.Len(t, zones, 2, "one zone per apex: the cluster apex and the custom domain apex")
|
||||
|
||||
cluster, ok := findCustomZone(zones, "eu.proxy.netbird.io")
|
||||
require.True(t, ok, "cluster-suffix services collapse into the cluster-apex zone")
|
||||
clusterNames := recordNames(cluster)
|
||||
assert.ElementsMatch(t,
|
||||
[]string{"cluster1.eu.proxy.netbird.io.", "cluster2.eu.proxy.netbird.io.", "cluster3.eu.proxy.netbird.io."},
|
||||
clusterNames,
|
||||
"only the 3 private cluster services surface in the cluster zone (public one excluded)")
|
||||
|
||||
custom, ok := findCustomZone(zones, "example.com")
|
||||
require.True(t, ok, "custom-suffix services collapse into the custom-domain-apex zone")
|
||||
customNames := recordNames(custom)
|
||||
assert.ElementsMatch(t,
|
||||
[]string{"custom1.example.com.", "custom2.example.com.", "custom3.example.com.", "custom4.example.com."},
|
||||
customNames,
|
||||
"only the 4 private custom services surface in the custom zone (public one excluded)")
|
||||
}
|
||||
|
||||
// recordNames returns the record names of a zone for order-independent assertions.
|
||||
func recordNames(zone nbdns.CustomZone) []string {
|
||||
names := make([]string, 0, len(zone.Records))
|
||||
for _, r := range zone.Records {
|
||||
names = append(names, r.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
@@ -417,15 +417,30 @@ if type uname >/dev/null 2>&1; then
|
||||
# Check the availability of a compatible package manager
|
||||
if check_use_bin_variable; then
|
||||
PACKAGE_MANAGER="bin"
|
||||
elif [ -e /run/ostree-booted ]; then
|
||||
if [ -x "$(command -v rpm-ostree)" ]; then
|
||||
PACKAGE_MANAGER="rpm-ostree"
|
||||
echo "The installation will be performed using rpm-ostree package manager"
|
||||
elif [ -x "$(command -v bootc)" ]; then
|
||||
echo "Detected bootc system without rpm-ostree." >&2
|
||||
echo "NetBird cannot be installed via package manager on this system." >&2
|
||||
echo "Options:" >&2
|
||||
echo " 1. Install via Distrobox (instructions in the installation docs)" >&2
|
||||
echo " 2. Rebuild your base image with rpm-ostree included" >&2
|
||||
echo " 3. Bake NetBird into your Containerfile" >&2
|
||||
exit 1
|
||||
else
|
||||
echo "Detected ostree-booted system without rpm-ostree or bootc." >&2
|
||||
echo "NetBird cannot be installed automatically on this atomic system." >&2
|
||||
echo "Please install NetBird by rebuilding your base image or use a supported package manager." >&2
|
||||
exit 1
|
||||
fi
|
||||
elif [ -x "$(command -v apt-get)" ]; then
|
||||
PACKAGE_MANAGER="apt"
|
||||
echo "The installation will be performed using apt package manager"
|
||||
elif [ -x "$(command -v dnf)" ]; then
|
||||
PACKAGE_MANAGER="dnf"
|
||||
echo "The installation will be performed using dnf package manager"
|
||||
elif [ -x "$(command -v rpm-ostree)" ]; then
|
||||
PACKAGE_MANAGER="rpm-ostree"
|
||||
echo "The installation will be performed using rpm-ostree package manager"
|
||||
elif [ -x "$(command -v yum)" ]; then
|
||||
PACKAGE_MANAGER="yum"
|
||||
echo "The installation will be performed using yum package manager"
|
||||
|
||||
54
util/log.go
54
util/log.go
@@ -1,15 +1,16 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/DeRuina/timberjack"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc/grpclog"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
|
||||
"github.com/netbirdio/netbird/formatter"
|
||||
)
|
||||
@@ -37,8 +38,7 @@ func InitLog(logLevel string, logs ...string) error {
|
||||
func InitLogger(logger *log.Logger, logLevel string, logs ...string) error {
|
||||
level, err := log.ParseLevel(logLevel)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed parsing log-level %s: %s", logLevel, err)
|
||||
return err
|
||||
return fmt.Errorf("failed parsing log-level %s: %w", logLevel, err)
|
||||
}
|
||||
var writers []io.Writer
|
||||
logFmt := os.Getenv("NB_LOG_FORMAT")
|
||||
@@ -59,7 +59,11 @@ func InitLogger(logger *log.Logger, logLevel string, logs ...string) error {
|
||||
case "":
|
||||
logger.Warnf("empty log path received: %#v", logPath)
|
||||
default:
|
||||
writers = append(writers, newRotatedOutput(logPath))
|
||||
writer, err := setupLogFile(logPath, isRotationDisabled(logger))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed setting up log file: %s, %w", logPath, err)
|
||||
}
|
||||
writers = append(writers, writer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,17 +98,43 @@ func FindFirstLogPath(logs []string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func isRotationDisabled(logger *log.Logger) bool {
|
||||
v, _ := os.LookupEnv("NB_LOG_DISABLE_ROTATION")
|
||||
disabled, _ := strconv.ParseBool(v)
|
||||
if disabled {
|
||||
logger.Warnf("log rotation is disabled by env flag")
|
||||
return true
|
||||
}
|
||||
conflict, configPath := FindFirstLogrotateConflict()
|
||||
if conflict {
|
||||
logger.Warnf("log rotation conflict detected in: %#v, rotation is disabled", configPath)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func setupLogFile(logPath string, disableRotation bool) (io.Writer, error) {
|
||||
if disableRotation {
|
||||
file, err := os.OpenFile(logPath, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
return newRotatedOutput(logPath), nil
|
||||
}
|
||||
|
||||
func newRotatedOutput(logPath string) io.Writer {
|
||||
maxLogSize := getLogMaxSize()
|
||||
lumberjackLogger := &lumberjack.Logger{
|
||||
timberjackLogger := &timberjack.Logger{
|
||||
// Log file absolute path, os agnostic
|
||||
Filename: filepath.ToSlash(logPath),
|
||||
MaxSize: maxLogSize, // MB
|
||||
MaxBackups: 10,
|
||||
MaxAge: 30, // days
|
||||
Compress: true,
|
||||
Filename: filepath.ToSlash(logPath),
|
||||
MaxSize: maxLogSize, // MB
|
||||
MaxBackups: 10,
|
||||
MaxAge: 30, // days
|
||||
Compression: "gzip",
|
||||
}
|
||||
return lumberjackLogger
|
||||
return timberjackLogger
|
||||
}
|
||||
|
||||
func setGRPCLibLogger(logger *log.Logger) {
|
||||
@@ -127,7 +157,7 @@ func getLogMaxSize() int {
|
||||
if sizeVar, ok := os.LookupEnv("NB_LOG_MAX_SIZE_MB"); ok {
|
||||
size, err := strconv.ParseInt(sizeVar, 10, 64)
|
||||
if err != nil {
|
||||
log.Errorf("Failed parsing log-size %s: %s. Should be just an integer", sizeVar, err)
|
||||
log.Errorf("failed parsing log-size %s: %s. Should be just an integer", sizeVar, err)
|
||||
return defaultLogSize
|
||||
}
|
||||
|
||||
|
||||
96
util/log_test.go
Normal file
96
util/log_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestSetupLogFile_RotatesOnSize drives >MaxSize bytes through the writer
|
||||
// returned by setupLogFile and asserts a backup file appears.
|
||||
func TestSetupLogFile_RotatesOnSize(t *testing.T) {
|
||||
t.Setenv("NB_LOG_MAX_SIZE_MB", "1")
|
||||
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "netbird.log")
|
||||
|
||||
w, err := setupLogFile(logPath, false)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
if c, ok := w.(io.Closer); ok {
|
||||
_ = c.Close()
|
||||
}
|
||||
})
|
||||
|
||||
chunk := []byte(strings.Repeat("x", 64*1024) + "\n")
|
||||
for range 20 {
|
||||
_, err := w.Write(chunk)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(logPath)
|
||||
require.NoError(t, err)
|
||||
require.Less(t, info.Size(), int64(1<<20),
|
||||
"active log should be < 1 MB after rotation, got %d", info.Size())
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
entries, _ := os.ReadDir(dir)
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if name == filepath.Base(logPath) {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(name, "netbird-") && strings.HasSuffix(name, ".log.gz") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 5*time.Second, 50*time.Millisecond, "expected a rotated backup file in %s", dir)
|
||||
}
|
||||
|
||||
// TestSetupLogFile_RotationDisabled verifies that with rotation off, the file
|
||||
// grows past MaxSize and no backups are created.
|
||||
func TestSetupLogFile_RotationDisabled(t *testing.T) {
|
||||
t.Setenv("NB_LOG_MAX_SIZE_MB", "1")
|
||||
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "netbird.log")
|
||||
|
||||
w, err := setupLogFile(logPath, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
f, ok := w.(*os.File)
|
||||
require.True(t, ok, "expected plain *os.File when rotation is disabled, got %T", w)
|
||||
t.Cleanup(func() { _ = f.Close() })
|
||||
|
||||
chunk := []byte(strings.Repeat("y", 64*1024) + "\n")
|
||||
for range 20 {
|
||||
_, err := w.Write(chunk)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(logPath)
|
||||
require.NoError(t, err)
|
||||
require.GreaterOrEqual(t, info.Size(), int64(1<<20),
|
||||
"file should exceed MaxSize when rotation is disabled, got %d", info.Size())
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1, "no backup files should exist when rotation is disabled, got %v", entries)
|
||||
}
|
||||
|
||||
// TestIsRotationDisabled_EnvFlag covers the NB_LOG_DISABLE_ROTATION env path.
|
||||
// The logrotate-conflict branch is exercised separately on linux.
|
||||
func TestIsRotationDisabled_EnvFlag(t *testing.T) {
|
||||
logger := log.New()
|
||||
logger.SetOutput(io.Discard)
|
||||
|
||||
t.Setenv("NB_LOG_DISABLE_ROTATION", "true")
|
||||
require.True(t, isRotationDisabled(logger))
|
||||
}
|
||||
93
util/logrotate_linux.go
Normal file
93
util/logrotate_linux.go
Normal file
@@ -0,0 +1,93 @@
|
||||
//go:build linux
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultLogrotateConfPath = "/etc/logrotate.conf"
|
||||
defaultLogrotateConfDir = "/etc/logrotate.d"
|
||||
netbirdString = "netbird"
|
||||
)
|
||||
|
||||
// FindLogrotateConflicts scans the standard logrotate locations for
|
||||
// indications of conflict with netbird. It returns true and the config file
|
||||
// path if a conflict was found.
|
||||
func FindFirstLogrotateConflict() (bool, string) {
|
||||
return findFirstLogrotateConflictIn(defaultLogrotateConfPath, defaultLogrotateConfDir)
|
||||
}
|
||||
|
||||
func findFirstLogrotateConflictIn(confPath, confDir string) (bool, string) {
|
||||
for _, f := range listLogrotateConfigs(confPath, confDir) {
|
||||
present, err := scanLogrotateFile(f, netbirdString)
|
||||
if err != nil {
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
log.Debugf("scan %s: %v", f, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if present {
|
||||
return present, f
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// listLogrotateConfigs returns all config files for logrotate.
|
||||
func listLogrotateConfigs(confPath, confDir string) []string {
|
||||
files := []string{confPath}
|
||||
entries, err := os.ReadDir(confDir)
|
||||
if err != nil {
|
||||
return files
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
files = append(files, filepath.Join(confDir, e.Name()))
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// scanLogrotateFile reads a config and reports if a non-comment line
|
||||
// contains the given substring.
|
||||
func scanLogrotateFile(path string, substring string) (bool, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
log.Debugf("close %s: %v", path, err)
|
||||
}
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(stripLogrotateComment(scanner.Text()))
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(line, substring) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func stripLogrotateComment(line string) string {
|
||||
before, _, _ := strings.Cut(line, "#")
|
||||
return before
|
||||
}
|
||||
95
util/logrotate_linux_test.go
Normal file
95
util/logrotate_linux_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
//go:build linux
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFindFirstLogrotateConflict(t *testing.T) {
|
||||
t.Run("conflict in confDir", func(t *testing.T) {
|
||||
confPath, confDir := newLogrotateLayout(t)
|
||||
conflictPath := filepath.Join(confDir, "netbird")
|
||||
writeLogrotateConfig(t, conflictPath, `/var/log/netbird/*.log {
|
||||
daily
|
||||
rotate 7
|
||||
}`)
|
||||
writeLogrotateConfig(t, filepath.Join(confDir, "nginx"), `/var/log/nginx/*.log { daily }`)
|
||||
|
||||
got, path := findFirstLogrotateConflictIn(confPath, confDir)
|
||||
require.True(t, got)
|
||||
require.Equal(t, conflictPath, path)
|
||||
})
|
||||
|
||||
t.Run("conflict in main conf file", func(t *testing.T) {
|
||||
confPath, confDir := newLogrotateLayout(t)
|
||||
writeLogrotateConfig(t, confPath, `weekly
|
||||
rotate 4
|
||||
include /etc/logrotate.d
|
||||
/var/log/netbird/client.log { rotate 5 }`)
|
||||
|
||||
got, path := findFirstLogrotateConflictIn(confPath, confDir)
|
||||
require.True(t, got)
|
||||
require.Equal(t, confPath, path)
|
||||
})
|
||||
|
||||
t.Run("no conflict when netbird is absent", func(t *testing.T) {
|
||||
confPath, confDir := newLogrotateLayout(t)
|
||||
writeLogrotateConfig(t, filepath.Join(confDir, "nginx"), `/var/log/nginx/*.log { daily }`)
|
||||
writeLogrotateConfig(t, filepath.Join(confDir, "syslog"), `/var/log/syslog { weekly }`)
|
||||
|
||||
got, path := findFirstLogrotateConflictIn(confPath, confDir)
|
||||
require.False(t, got)
|
||||
require.Empty(t, path)
|
||||
})
|
||||
|
||||
t.Run("commented-out netbird line is ignored", func(t *testing.T) {
|
||||
confPath, confDir := newLogrotateLayout(t)
|
||||
writeLogrotateConfig(t, filepath.Join(confDir, "misc"), `# /var/log/netbird/*.log { daily }
|
||||
/var/log/other.log { weekly }`)
|
||||
|
||||
got, path := findFirstLogrotateConflictIn(confPath, confDir)
|
||||
require.False(t, got)
|
||||
require.Empty(t, path)
|
||||
})
|
||||
|
||||
t.Run("subdirectories in confDir are ignored", func(t *testing.T) {
|
||||
confPath, confDir := newLogrotateLayout(t)
|
||||
sub := filepath.Join(confDir, "nested")
|
||||
require.NoError(t, os.MkdirAll(sub, 0o755))
|
||||
writeLogrotateConfig(t, filepath.Join(sub, "netbird"), `/var/log/netbird/*.log { daily }`)
|
||||
|
||||
got, path := findFirstLogrotateConflictIn(confPath, confDir)
|
||||
require.False(t, got)
|
||||
require.Empty(t, path)
|
||||
})
|
||||
|
||||
t.Run("missing paths return no conflict", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
got, path := findFirstLogrotateConflictIn(
|
||||
filepath.Join(dir, "does-not-exist.conf"),
|
||||
filepath.Join(dir, "does-not-exist.d"),
|
||||
)
|
||||
require.False(t, got)
|
||||
require.Empty(t, path)
|
||||
})
|
||||
}
|
||||
|
||||
// newLogrotateLayout creates a temp logrotate.conf path and logrotate.d dir,
|
||||
// returning their paths. The conf file itself is not created.
|
||||
func newLogrotateLayout(t *testing.T) (confPath, confDir string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
confDir = filepath.Join(root, "logrotate.d")
|
||||
require.NoError(t, os.MkdirAll(confDir, 0o755))
|
||||
return filepath.Join(root, "logrotate.conf"), confDir
|
||||
}
|
||||
|
||||
func writeLogrotateConfig(t *testing.T, path, body string) {
|
||||
t.Helper()
|
||||
require.NoError(t, os.WriteFile(path, []byte(body), 0o644))
|
||||
}
|
||||
10
util/logrotate_nonlinux.go
Normal file
10
util/logrotate_nonlinux.go
Normal file
@@ -0,0 +1,10 @@
|
||||
//go:build !linux
|
||||
|
||||
package util
|
||||
|
||||
// FindLogrotateConflicts scans the standard logrotate locations for
|
||||
// indications of conflict with netbird. It will always return false for
|
||||
// non-linux devices.
|
||||
func FindFirstLogrotateConflict() (bool, string) {
|
||||
return false, ""
|
||||
}
|
||||
Reference in New Issue
Block a user