mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-26 14:29:08 +01:00
[client] Support arbitrary UIDs in rootless image
This commit is contained in:
@@ -1,20 +1,23 @@
|
|||||||
# build & run locally with:
|
# build & run locally with:
|
||||||
# cd "$(git rev-parse --show-toplevel)"
|
# cd "$(git rev-parse --show-toplevel)"
|
||||||
# CGO_ENABLED=0 go build -o netbird ./client
|
# CGO_ENABLED=0 go build -o netbird ./client
|
||||||
# podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client .
|
# podman build -t localhost/netbird:latest -f client/Dockerfile-rootless --ignorefile .dockerignore-client .
|
||||||
# podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest
|
# podman run --rm -it --user 1001230000:0 --cap-drop=ALL --security-opt=no-new-privileges localhost/netbird:latest
|
||||||
|
|
||||||
FROM alpine:3.24
|
FROM alpine:3.24
|
||||||
|
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
bash \
|
bash \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
&& adduser -D -h /var/lib/netbird netbird
|
&& adduser -D -u 1000 -h /var/lib/netbird netbird \
|
||||||
|
&& chgrp -R 0 /var/lib/netbird \
|
||||||
|
&& chmod -R g=u /var/lib/netbird
|
||||||
|
|
||||||
WORKDIR /var/lib/netbird
|
WORKDIR /var/lib/netbird
|
||||||
USER netbird:netbird
|
USER 1000:0
|
||||||
|
|
||||||
ENV \
|
ENV \
|
||||||
|
HOME="/var/lib/netbird" \
|
||||||
NETBIRD_BIN="/usr/local/bin/netbird" \
|
NETBIRD_BIN="/usr/local/bin/netbird" \
|
||||||
NB_USE_NETSTACK_MODE="true" \
|
NB_USE_NETSTACK_MODE="true" \
|
||||||
NB_ENABLE_NETSTACK_LOCAL_FORWARDING="true" \
|
NB_ENABLE_NETSTACK_LOCAL_FORWARDING="true" \
|
||||||
@@ -29,5 +32,5 @@ ENV \
|
|||||||
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
|
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird
|
ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird
|
||||||
COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
|
COPY --chown=1000:0 --chmod=0750 client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
|
||||||
COPY "${NETBIRD_BINARY}" /usr/local/bin/netbird
|
COPY --chown=1000:0 --chmod=0750 "${NETBIRD_BINARY}" /usr/local/bin/netbird
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"os/user"
|
"os/user"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
@@ -13,17 +14,21 @@ import (
|
|||||||
const envSudoUser = "SUDO_USER"
|
const envSudoUser = "SUDO_USER"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
geteuid = os.Geteuid
|
currentUser = user.Current
|
||||||
lookupUser = user.Lookup
|
getegid = os.Getegid
|
||||||
|
geteuid = os.Geteuid
|
||||||
|
lookupUser = user.Lookup
|
||||||
)
|
)
|
||||||
|
|
||||||
// InvokingUser returns the user a CLI invocation acts for. Under sudo that is
|
// InvokingUser returns the user a CLI invocation acts for. Under sudo that is
|
||||||
// the user who ran sudo, not root: privileged flags force commands through
|
// the user who ran sudo, not root: privileged flags force commands through
|
||||||
// sudo, and resolving profiles as root would silently switch the daemon to
|
// sudo, and resolving profiles as root would silently switch the daemon to
|
||||||
// root's (default) profile instead of the invoking user's. Privilege decisions
|
// root's (default) profile instead of the invoking user's. An unmapped positive
|
||||||
// are not made here — those stay on the kernel credentials of the daemon
|
// process UID uses its numeric kernel identity; root, sudo lookup failures, and
|
||||||
// connection, which SUDO_USER (a plain environment variable) can never
|
// unavailable platform identities still fail closed. Privilege decisions stay
|
||||||
// influence; a forged value only selects a profile root could select anyway.
|
// on the kernel credentials of the daemon connection, which SUDO_USER (a plain
|
||||||
|
// environment variable) can never influence; a forged value only selects a
|
||||||
|
// profile root could select anyway.
|
||||||
func InvokingUser() (*user.User, error) {
|
func InvokingUser() (*user.User, error) {
|
||||||
if u, ok := sudoInvokingUser(); ok {
|
if u, ok := sudoInvokingUser(); ok {
|
||||||
return u, nil
|
return u, nil
|
||||||
@@ -35,7 +40,24 @@ func InvokingUser() (*user.User, error) {
|
|||||||
if sudoActive() {
|
if sudoActive() {
|
||||||
return nil, fmt.Errorf("resolve sudo invoking user %q: refusing to fall back to root", os.Getenv(envSudoUser))
|
return nil, fmt.Errorf("resolve sudo invoking user %q: refusing to fall back to root", os.Getenv(envSudoUser))
|
||||||
}
|
}
|
||||||
return user.Current()
|
u, err := currentUser()
|
||||||
|
if err == nil {
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := geteuid()
|
||||||
|
if uid <= 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("current user lookup for UID %d: %v; using numeric UID", uid, err)
|
||||||
|
uidString := strconv.Itoa(uid)
|
||||||
|
return &user.User{
|
||||||
|
Username: uidString,
|
||||||
|
Uid: uidString,
|
||||||
|
Gid: strconv.Itoa(getegid()),
|
||||||
|
HomeDir: os.Getenv("HOME"),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsPlainRoot reports that the process runs as root with no usable sudo
|
// IsPlainRoot reports that the process runs as root with no usable sudo
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package profilemanager
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"os/user"
|
"os/user"
|
||||||
@@ -13,15 +14,82 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestInvokingUserFallsBackToProcessUser(t *testing.T) {
|
func TestInvokingUserReturnsResolvedCurrentUser(t *testing.T) {
|
||||||
t.Setenv(envSudoUser, "")
|
t.Setenv(envSudoUser, "")
|
||||||
|
|
||||||
|
want := &user.User{
|
||||||
|
Username: "misha",
|
||||||
|
Uid: "1234",
|
||||||
|
Gid: "1234",
|
||||||
|
HomeDir: filepath.Join("/home", "misha"),
|
||||||
|
}
|
||||||
|
origCurrentUser := currentUser
|
||||||
|
currentUser = func() (*user.User, error) { return want, nil }
|
||||||
|
t.Cleanup(func() { currentUser = origCurrentUser })
|
||||||
|
|
||||||
got, err := InvokingUser()
|
got, err := InvokingUser()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
assert.Same(t, want, got, "resolved process user should be returned unchanged")
|
||||||
|
}
|
||||||
|
|
||||||
current, err := user.Current()
|
func TestInvokingUserUsesNumericIdentityForUnmappedNonRoot(t *testing.T) {
|
||||||
|
t.Setenv(envSudoUser, "")
|
||||||
|
t.Setenv("HOME", "/var/lib/netbird")
|
||||||
|
fakeUnmappedUser(t, 1001230000, 0, errors.New("user: unknown userid 1001230000"))
|
||||||
|
|
||||||
|
got, err := InvokingUser()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, current.Username, got.Username)
|
assert.Equal(t, &user.User{
|
||||||
|
Username: "1001230000",
|
||||||
|
Uid: "1001230000",
|
||||||
|
Gid: "0",
|
||||||
|
HomeDir: "/var/lib/netbird",
|
||||||
|
}, got, "unmapped non-root identity should use kernel credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvokingUserFailsClosedWithoutPositiveUID(t *testing.T) {
|
||||||
|
for _, uid := range []int{0, -1} {
|
||||||
|
t.Run(fmt.Sprintf("UID%d", uid), func(t *testing.T) {
|
||||||
|
t.Setenv(envSudoUser, "")
|
||||||
|
lookupErr := errors.New("current user unavailable")
|
||||||
|
fakeUnmappedUser(t, uid, 0, lookupErr)
|
||||||
|
|
||||||
|
got, err := InvokingUser()
|
||||||
|
require.ErrorIs(t, err, lookupErr)
|
||||||
|
assert.Nil(t, got, "root or unavailable UID must not become a synthetic identity")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfileFilePathUsesNumericIdentityForUnmappedNonRoot(t *testing.T) {
|
||||||
|
t.Setenv(envSudoUser, "")
|
||||||
|
t.Setenv("HOME", "/var/lib/netbird")
|
||||||
|
fakeUnmappedUser(t, 1001230000, 0, errors.New("user: unknown userid 1001230000"))
|
||||||
|
|
||||||
|
profilesRoot := t.TempDir()
|
||||||
|
origDir := DefaultConfigPathDir
|
||||||
|
origOverride := ConfigDirOverride
|
||||||
|
DefaultConfigPathDir = profilesRoot
|
||||||
|
ConfigDirOverride = ""
|
||||||
|
t.Cleanup(func() {
|
||||||
|
DefaultConfigPathDir = origDir
|
||||||
|
ConfigDirOverride = origOverride
|
||||||
|
})
|
||||||
|
|
||||||
|
profileID := ID("0123456789abcdef0123456789abcdef")
|
||||||
|
got, err := (&Profile{ID: profileID}).FilePath()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t,
|
||||||
|
filepath.Join(profilesRoot, "1001230000", profileID.String()+".json"),
|
||||||
|
got,
|
||||||
|
"profile path should use the numeric UID namespace",
|
||||||
|
)
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(profilesRoot)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, entries, 1, "only the numeric UID directory should be created")
|
||||||
|
assert.Equal(t, "1001230000", entries[0].Name(), "profile namespace should be numeric")
|
||||||
|
assert.True(t, entries[0].IsDir(), "profile namespace should be a directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSudoInvokingUserInactiveWithoutSudoContext(t *testing.T) {
|
func TestSudoInvokingUserInactiveWithoutSudoContext(t *testing.T) {
|
||||||
@@ -60,6 +128,13 @@ func TestInvokingUserFailsClosedWhenSudoLookupFails(t *testing.T) {
|
|||||||
fakeSudo(t, filepath.Join("/home", "misha"))
|
fakeSudo(t, filepath.Join("/home", "misha"))
|
||||||
lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") }
|
lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") }
|
||||||
|
|
||||||
|
origCurrentUser := currentUser
|
||||||
|
currentUser = func() (*user.User, error) {
|
||||||
|
t.Fatal("currentUser must not be called after a sudo lookup failure")
|
||||||
|
return nil, errors.New("currentUser called unexpectedly")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { currentUser = origCurrentUser })
|
||||||
|
|
||||||
got, err := InvokingUser()
|
got, err := InvokingUser()
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Nil(t, got, "must not resolve to the root process user")
|
assert.Nil(t, got, "must not resolve to the root process user")
|
||||||
@@ -215,6 +290,22 @@ func fakeSudo(t *testing.T, home string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fakeUnmappedUser(t *testing.T, uid, gid int, lookupErr error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
origCurrentUser := currentUser
|
||||||
|
origEuid := geteuid
|
||||||
|
origEgid := getegid
|
||||||
|
currentUser = func() (*user.User, error) { return nil, lookupErr }
|
||||||
|
geteuid = func() int { return uid }
|
||||||
|
getegid = func() int { return gid }
|
||||||
|
t.Cleanup(func() {
|
||||||
|
currentUser = origCurrentUser
|
||||||
|
geteuid = origEuid
|
||||||
|
getegid = origEgid
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func assertNoEntries(t *testing.T, root string) {
|
func assertNoEntries(t *testing.T, root string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
err := filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error {
|
err := filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error {
|
||||||
|
|||||||
Executable
+117
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -eEuo pipefail
|
||||||
|
|
||||||
|
RUNTIME="${CONTAINER_RUNTIME:-}"
|
||||||
|
if [[ -z "${RUNTIME}" ]]; then
|
||||||
|
if command -v docker >/dev/null 2>&1; then
|
||||||
|
RUNTIME=docker
|
||||||
|
elif command -v podman >/dev/null 2>&1; then
|
||||||
|
RUNTIME=podman
|
||||||
|
else
|
||||||
|
echo "docker or podman is required" >&2
|
||||||
|
exit 127
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if ! command -v "${RUNTIME}" >/dev/null 2>&1; then
|
||||||
|
echo "container runtime not found: ${RUNTIME}" >&2
|
||||||
|
exit 127
|
||||||
|
fi
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
IMAGE="${IMAGE:-netbird-rootless-arbitrary-uid-test:local}"
|
||||||
|
TARGETARCH="${TARGETARCH:-$(go env GOARCH)}"
|
||||||
|
PLATFORM="${PLATFORM:-linux/${TARGETARCH}}"
|
||||||
|
WAIT_TIMEOUT="${WAIT_TIMEOUT:-30}"
|
||||||
|
TMP_DIR="$(mktemp -d)"
|
||||||
|
CONTAINER="netbird-rootless-uid-${RANDOM}-$$"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local status=$?
|
||||||
|
"${RUNTIME}" rm -f "${CONTAINER}" >/dev/null 2>&1 || true
|
||||||
|
rm -rf "${TMP_DIR}"
|
||||||
|
exit "${status}"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
container_logs() {
|
||||||
|
echo "---- ${CONTAINER} logs ----" >&2
|
||||||
|
"${RUNTIME}" logs "${CONTAINER}" >&2 || true
|
||||||
|
echo "----------------------------" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
build_image() {
|
||||||
|
echo "==> Building Linux ${TARGETARCH} netbird binary"
|
||||||
|
mkdir -p "${TMP_DIR}/context/client"
|
||||||
|
cp "${ROOT_DIR}/client/Dockerfile-rootless" "${TMP_DIR}/context/Dockerfile"
|
||||||
|
cp "${ROOT_DIR}/client/netbird-entrypoint.sh" "${TMP_DIR}/context/client/netbird-entrypoint.sh"
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "${ROOT_DIR}"
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH="${TARGETARCH}" \
|
||||||
|
go build -o "${TMP_DIR}/context/netbird" ./client
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "==> Building ${IMAGE} for ${PLATFORM}"
|
||||||
|
"${RUNTIME}" build \
|
||||||
|
--platform "${PLATFORM}" \
|
||||||
|
--build-arg NETBIRD_BINARY=netbird \
|
||||||
|
-t "${IMAGE}" \
|
||||||
|
-f "${TMP_DIR}/context/Dockerfile" \
|
||||||
|
"${TMP_DIR}/context"
|
||||||
|
}
|
||||||
|
|
||||||
|
start_container() {
|
||||||
|
echo "==> Starting ${CONTAINER} as unmapped UID 1001230000"
|
||||||
|
"${RUNTIME}" run --rm -d \
|
||||||
|
--name "${CONTAINER}" \
|
||||||
|
--user 1001230000:0 \
|
||||||
|
--cap-drop=ALL \
|
||||||
|
--security-opt=no-new-privileges \
|
||||||
|
--entrypoint /usr/local/bin/netbird \
|
||||||
|
"${IMAGE}" \
|
||||||
|
--log-file console \
|
||||||
|
service run >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_until_live() {
|
||||||
|
local deadline=$((SECONDS + WAIT_TIMEOUT))
|
||||||
|
|
||||||
|
while (( SECONDS < deadline )); do
|
||||||
|
if "${RUNTIME}" exec "${CONTAINER}" \
|
||||||
|
/usr/local/bin/netbird status --check live >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$("${RUNTIME}" inspect -f '{{.State.Running}}' "${CONTAINER}" 2>/dev/null || true)" != "true" ]]; then
|
||||||
|
echo "container exited before the daemon became live" >&2
|
||||||
|
container_logs
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "timed out waiting for the daemon after ${WAIT_TIMEOUT}s" >&2
|
||||||
|
container_logs
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_arbitrary_uid_contract() {
|
||||||
|
echo "==> Verifying arbitrary UID image contract"
|
||||||
|
"${RUNTIME}" exec "${CONTAINER}" sh -ec '
|
||||||
|
test "$(id -u)" = 1001230000
|
||||||
|
test "$(id -g)" = 0
|
||||||
|
test "${HOME}" = /var/lib/netbird
|
||||||
|
touch /var/lib/netbird/.uid-smoke
|
||||||
|
rm /var/lib/netbird/.uid-smoke
|
||||||
|
test -S /var/lib/netbird/netbird.sock
|
||||||
|
'
|
||||||
|
"${RUNTIME}" exec "${CONTAINER}" \
|
||||||
|
/usr/local/bin/netbird profile list >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
build_image
|
||||||
|
start_container
|
||||||
|
wait_until_live
|
||||||
|
assert_arbitrary_uid_contract
|
||||||
|
|
||||||
|
echo "==> Rootless arbitrary UID validation passed"
|
||||||
Reference in New Issue
Block a user