mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-27 09:09:07 +02:00
[management,client] Gate remote jobs behind an admin opt-in with MDM support (#7153)
This introduces a disabled-by-default allow-remote-jobs setting that controls whether the management server may run jobs (such as debug bundles) on a peer. The flag propagates end to end: through client configuration, the daemon SetConfig and Login requests, authentication, and system info, up to management, where it is stored on the peer and exposed on the peers API as remote_jobs_allowed. The client refuses any management-requested job unless the peer has opted in. Because enabling remote jobs crosses the user-to-root boundary, turning it on requires privilege, mirroring the SSH-server gate. Administrators can enforce the setting through MDM policy on both macOS and Windows, and MDM can also override the debug-bundle upload URL. The change ships policy documentation and generated profile templates, and adds configuration, conflict, and enforcement tests covering the opt-in, privilege, and MDM paths.
This commit is contained in:
+20
-3
@@ -31,6 +31,9 @@ const (
|
||||
// Client is a running NetBird client container joined to the combined server.
|
||||
type Client struct {
|
||||
container testcontainers.Container
|
||||
// name is the container hostname the agent reports to management at
|
||||
// registration — the name the peer appears under in the peers API.
|
||||
name string
|
||||
}
|
||||
|
||||
// clientOptions is what the ClientOption values assemble.
|
||||
@@ -99,24 +102,38 @@ func StartClient(ctx context.Context, c *Combined, setupKey string, opts ...Clie
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("start client container: %w", err)
|
||||
}
|
||||
return &Client{container: ctr}, nil
|
||||
return &Client{container: ctr, name: o.name}, nil
|
||||
}
|
||||
|
||||
// Hostname returns the container hostname the agent reports to management —
|
||||
// the name the registered peer appears under in the peers API.
|
||||
func (cl *Client) Hostname() string {
|
||||
return cl.name
|
||||
}
|
||||
|
||||
// Restart bounces the client connection (netbird down/up) so it pulls a fresh
|
||||
// network map — the documented workaround for a freshly-joined client not yet
|
||||
// seeing a synthesized agent-network service.
|
||||
func (cl *Client) Restart(ctx context.Context) error {
|
||||
return cl.Up(ctx)
|
||||
}
|
||||
|
||||
// Up re-runs `netbird up` inside the client with the given extra flags (e.g.
|
||||
// "--allow-remote-jobs"), bouncing the connection first so the new config is
|
||||
// picked up and re-synced to management. Used to toggle peer options that ride
|
||||
// on the login/sync request without recreating the container.
|
||||
func (cl *Client) Up(ctx context.Context, extraArgs ...string) error {
|
||||
if _, _, err := cl.container.Exec(ctx, []string{"netbird", "down"}, tcexec.Multiplexed()); err != nil {
|
||||
return fmt.Errorf("netbird down: %w", err)
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
code, reader, err := cl.container.Exec(ctx, []string{"netbird", "up"}, tcexec.Multiplexed())
|
||||
code, reader, err := cl.container.Exec(ctx, append([]string{"netbird", "up"}, extraArgs...), tcexec.Multiplexed())
|
||||
if err != nil {
|
||||
return fmt.Errorf("netbird up: %w", err)
|
||||
}
|
||||
if code != 0 {
|
||||
out, _ := io.ReadAll(reader)
|
||||
return fmt.Errorf("netbird up exited %d: %s", code, string(out))
|
||||
return fmt.Errorf("netbird up %v exited %d: %s", extraArgs, code, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//go:build e2e
|
||||
|
||||
// Package remotejobs holds the container-based e2e suite for the remote-jobs
|
||||
// opt-in (PR #7153) and the debug-bundle job parameters anonymize_level /
|
||||
// upload_url (PR #7147). A combined server is built and bootstrapped once per
|
||||
// package run (TestMain) and shared via srv; each test registers its own client
|
||||
// and cleans it up.
|
||||
package remotejobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/e2e/harness"
|
||||
)
|
||||
|
||||
// srv is the shared combined server for the package, PAT-authenticated by the
|
||||
// time any Test runs.
|
||||
var srv *harness.Combined
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(run(m))
|
||||
}
|
||||
|
||||
func run(m *testing.M) int {
|
||||
// Generous timeout to cover a cold image build on first run.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
var err error
|
||||
srv, err = harness.StartCombined(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "e2e: start combined server: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
defer func() { _ = srv.Terminate(context.Background()) }()
|
||||
|
||||
if _, err := srv.Bootstrap(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "e2e: bootstrap admin PAT: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
return m.Run()
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//go:build e2e
|
||||
|
||||
package remotejobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/e2e/harness"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
const (
|
||||
refusedReason = "remote jobs are not enabled on this peer"
|
||||
// testUploadURL is the debug-bundle upload URL the job subtests pass; it only
|
||||
// needs to be a well-formed https URL with a host (see ValidateBundleUploadURL).
|
||||
testUploadURL = "https://uploads.example.com/bundle"
|
||||
)
|
||||
|
||||
// TestRemoteJobsOptInAndBundleParams exercises the two PRs end-to-end against a
|
||||
// live management server and a real client:
|
||||
//
|
||||
// - #7153: the peer's remote-jobs opt-in defaults off, is reported to
|
||||
// management (visible via the peers API as remote_jobs_allowed), and gates
|
||||
// job execution on the client — a streamed job is refused until the peer
|
||||
// opts in with `netbird up --allow-remote-jobs`, after which it runs.
|
||||
// - #7147: the debug-bundle job's anonymize_level is validated (an unknown
|
||||
// value is rejected at creation) and normalized (trimmed + lowercased) in
|
||||
// the stored job the API returns.
|
||||
func TestRemoteJobsOptInAndBundleParams(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// A group for the setup key to auto-assign; peers must land in some group.
|
||||
grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-remotejobs"})
|
||||
require.NoError(t, err, "create group")
|
||||
t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) })
|
||||
|
||||
sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{
|
||||
Name: "e2e-remotejobs",
|
||||
Type: "reusable",
|
||||
ExpiresIn: 86400,
|
||||
UsageLimit: 0,
|
||||
AutoGroups: []string{grp.Id},
|
||||
})
|
||||
require.NoError(t, err, "mint setup key")
|
||||
require.NotEmpty(t, sk.Key, "setup key plaintext")
|
||||
t.Cleanup(func() { _ = srv.API().SetupKeys.Delete(context.Background(), sk.Id) })
|
||||
|
||||
// Start the client with a plain `netbird up` (remote jobs NOT enabled).
|
||||
cl, err := harness.StartClient(ctx, srv, sk.Key)
|
||||
require.NoError(t, err, "start client")
|
||||
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
|
||||
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
|
||||
|
||||
peerID := waitForPeer(ctx, t, cl.Hostname())
|
||||
|
||||
t.Run("opt-in flag defaults to false and is reported to management (#7153)", func(t *testing.T) {
|
||||
p, err := srv.API().Peers.Get(ctx, peerID)
|
||||
require.NoError(t, err)
|
||||
allowed := remoteJobsAllowed(p)
|
||||
require.NotNil(t, allowed, "remote_jobs_allowed must be present on the peer API")
|
||||
assert.False(t, *allowed, "a peer that ran plain `netbird up` must default to opt-out")
|
||||
})
|
||||
|
||||
t.Run("anonymize_level is validated and normalized (#7147)", func(t *testing.T) {
|
||||
// Unknown level is rejected at job creation.
|
||||
_, err := srv.API().Peers.Jobs(peerID).Create(ctx, bundleJob("bogus", testUploadURL))
|
||||
require.Error(t, err, "an unknown anonymize_level must be rejected")
|
||||
assert.Contains(t, strings.ToLower(err.Error()), "anonymize_level",
|
||||
"the rejection must name the offending field")
|
||||
|
||||
// A messy but valid level is normalized (trimmed + lowercased) in the
|
||||
// stored job the API echoes back.
|
||||
job, err := srv.API().Peers.Jobs(peerID).Create(ctx, bundleJob(" Strict ", testUploadURL))
|
||||
require.NoError(t, err, "a valid anonymize_level must be accepted")
|
||||
bw, err := job.Workload.AsBundleWorkloadResponse()
|
||||
require.NoError(t, err, "job workload must be a bundle")
|
||||
require.NotNil(t, bw.Parameters.AnonymizeLevel)
|
||||
assert.Equal(t, "strict", *bw.Parameters.AnonymizeLevel,
|
||||
"anonymize_level must be normalized to trimmed lowercase")
|
||||
waitForJobTerminal(ctx, t, peerID, job.Id) // let it settle before the next create
|
||||
})
|
||||
|
||||
t.Run("a job is refused while the peer has not opted in (#7153 enforcement)", func(t *testing.T) {
|
||||
job, err := srv.API().Peers.Jobs(peerID).Create(ctx, bundleJob("default", testUploadURL))
|
||||
require.NoError(t, err, "job creation itself is allowed; enforcement is on the client")
|
||||
final := waitForJobTerminal(ctx, t, peerID, job.Id)
|
||||
assert.Equal(t, api.JobResponseStatusFailed, final.Status, "the client must refuse the job")
|
||||
require.NotNil(t, final.FailedReason)
|
||||
assert.Contains(t, *final.FailedReason, refusedReason,
|
||||
"the failure must be the opt-out refusal, not some other error")
|
||||
})
|
||||
|
||||
t.Run("opting in flips the flag and lets the job run (#7153)", func(t *testing.T) {
|
||||
require.NoError(t, cl.Up(ctx, "--allow-remote-jobs"), "re-run up with --allow-remote-jobs")
|
||||
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must reconnect")
|
||||
|
||||
// The new opt-in must round-trip to management and surface on the API.
|
||||
require.Eventually(t, func() bool {
|
||||
p, err := srv.API().Peers.Get(ctx, peerID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
allowed := remoteJobsAllowed(p)
|
||||
return allowed != nil && *allowed
|
||||
}, 60*time.Second, 2*time.Second, "remote_jobs_allowed must become true after opt-in")
|
||||
|
||||
// The same job that was refused before must now be accepted for
|
||||
// execution: whatever its outcome, it must NOT be the opt-out refusal.
|
||||
job, err := srv.API().Peers.Jobs(peerID).Create(ctx, bundleJob("default", testUploadURL))
|
||||
require.NoError(t, err)
|
||||
final := waitForJobTerminal(ctx, t, peerID, job.Id)
|
||||
if final.Status == api.JobResponseStatusFailed && final.FailedReason != nil {
|
||||
assert.NotContains(t, *final.FailedReason, refusedReason,
|
||||
"once opted in, the job must not be refused for opt-out; any failure must be for another reason (e.g. upload)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// remoteJobsAllowed returns the peer's remote-jobs opt-in flag from the API
|
||||
// response (nil if the peer or its local flags are absent).
|
||||
func remoteJobsAllowed(p *api.Peer) *bool {
|
||||
if p == nil || p.LocalFlags == nil {
|
||||
return nil
|
||||
}
|
||||
return p.LocalFlags.RemoteJobsAllowed
|
||||
}
|
||||
|
||||
// bundleJob builds a debug-bundle job request with the given anonymize_level
|
||||
// (omitted when empty) and upload_url (omitted when empty).
|
||||
func bundleJob(anonymizeLevel, uploadURL string) api.JobRequest {
|
||||
params := api.BundleParameters{
|
||||
Anonymize: true,
|
||||
LogFileCount: 1,
|
||||
}
|
||||
if anonymizeLevel != "" {
|
||||
params.AnonymizeLevel = &anonymizeLevel
|
||||
}
|
||||
if uploadURL != "" {
|
||||
params.UploadUrl = &uploadURL
|
||||
}
|
||||
var wl api.WorkloadRequest
|
||||
// FromBundleWorkloadRequest cannot fail for a well-formed value.
|
||||
_ = wl.FromBundleWorkloadRequest(api.BundleWorkloadRequest{
|
||||
Type: api.WorkloadTypeBundle,
|
||||
Parameters: params,
|
||||
})
|
||||
return api.JobRequest{Workload: wl}
|
||||
}
|
||||
|
||||
// waitForPeer polls the peers API until the client that registered under the
|
||||
// given hostname appears and returns its ID. Matching by hostname rather than
|
||||
// taking the first list entry keeps the test correct if the account ever holds
|
||||
// more than one peer (a shared bootstrap account, or a second client added to
|
||||
// the package).
|
||||
func waitForPeer(ctx context.Context, t *testing.T, hostname string) string {
|
||||
t.Helper()
|
||||
var peerID string
|
||||
require.Eventually(t, func() bool {
|
||||
peers, err := srv.API().Peers.List(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, p := range peers {
|
||||
if p.Hostname == hostname {
|
||||
peerID = p.Id
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 60*time.Second, 2*time.Second, "the client peer must register with management")
|
||||
return peerID
|
||||
}
|
||||
|
||||
// waitForJobTerminal polls a job until it leaves the pending state, then returns
|
||||
// the final response.
|
||||
func waitForJobTerminal(ctx context.Context, t *testing.T, peerID, jobID string) *api.JobResponse {
|
||||
t.Helper()
|
||||
var final *api.JobResponse
|
||||
require.Eventually(t, func() bool {
|
||||
j, err := srv.API().Peers.Jobs(peerID).Get(ctx, jobID)
|
||||
if err != nil || j == nil {
|
||||
return false
|
||||
}
|
||||
if j.Status == api.JobResponseStatusPending {
|
||||
return false
|
||||
}
|
||||
final = j
|
||||
return true
|
||||
}, 120*time.Second, 2*time.Second, "job must reach a terminal state")
|
||||
return final
|
||||
}
|
||||
Reference in New Issue
Block a user