Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions backend_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package dnsconfig

import (
"context"
"errors"
"os"

"github.com/DefinedNet/dnsconfig/internal/backend"
"github.com/DefinedNet/dnsconfig/internal/backend/darwin"
Expand All @@ -20,3 +22,17 @@ func newNativeBackend(ctx context.Context, kind BackendKind, opts Options) (back
}
return b, nil
}

// isRoot is replaced by tests so the declined path does not depend on the
// test runner's user.
var isRoot = func() bool { return os.Geteuid() == 0 }

// nativeDeclined reports why this process cannot manage kind's DNS, or nil.
// The dynamic store, /etc/resolver, and the lock directory all require root,
// so a non-root darwin Manager could only fail and retry every attempt.
func nativeDeclined(kind BackendKind) error {
if kind == BackendDarwin && !isRoot() {
return errors.New("darwin: requires root")
}
return nil
}
3 changes: 3 additions & 0 deletions backend_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ import (
func newNativeBackend(_ context.Context, kind BackendKind, _ Options) (backend.Backend, error) {
return nil, errBackendUnsupported(kind)
}

// nativeDeclined reports why this process cannot manage kind's DNS, or nil.
func nativeDeclined(BackendKind) error { return nil }
3 changes: 3 additions & 0 deletions backend_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ func newNativeBackend(ctx context.Context, kind BackendKind, opts Options) (back
}
return b, nil
}

// nativeDeclined reports why this process cannot manage kind's DNS, or nil.
func nativeDeclined(BackendKind) error { return nil }
83 changes: 83 additions & 0 deletions declined_darwin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package dnsconfig

import (
"bytes"
"context"
"log/slog"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/DefinedNet/dnsconfig/internal/backend/noop"
)

// withRoot answers the process root check for the duration of t.
func withRoot(t *testing.T, root bool) {
t.Helper()
prev := isRoot
isRoot = func() bool { return root }
t.Cleanup(func() { isRoot = prev })
}

// TestNew_DarwinWithoutRootDeclines checks that a non-root macOS process,
// which can write none of the store, /etc/resolver, or the lock directory,
// gets a declined Manager instead of one that retries failing attempts.
func TestNew_DarwinWithoutRootDeclines(t *testing.T) {
withRoot(t, false)
var logs bytes.Buffer
m, err := New(context.Background(), Options{
Identity: "facade-declined",
ForceBackend: BackendDarwin,
Logger: slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})),
})
require.NoError(t, err)
t.Cleanup(func() { _ = m.Close() })

s := m.Status()
require.ErrorIs(t, s.LastError, ErrConfigurationDeclined, "the refusal is visible from New on")
assert.ErrorContains(t, s.LastError, "requires root")
assert.False(t, s.LastConvergedCleared)

assert.Equal(t, BackendDarwin, m.kind)
assert.Nil(t, m.locker, "a declined Manager takes no host locks")
assert.IsType(t, &noop.Backend{}, m.backend, "the darwin backend is never built")

err = m.Set(sampleConfig(t))
require.ErrorIs(t, err, ErrConfigurationDeclined)
assert.ErrorContains(t, err, "requires root")
var e *Error
require.ErrorAs(t, err, &e)
assert.Equal(t, BackendDarwin, e.Backend)
s = m.Status()
assert.Equal(t, GoalNone, s.Goal)
assert.Same(t, err, s.LastError)

require.NoError(t, m.Clear())
s = waitStatus(t, m, func(s Status) bool { return s.Converged })
assert.Equal(t, GoalClear, s.Goal)
assert.True(t, s.LastChangedAt.IsZero(), "a declined Clear never changes the host")
assert.ErrorIs(t, s.LastError, ErrConfigurationDeclined, "a declined Clear verified nothing")
assert.False(t, s.LastConvergedCleared)

require.NoError(t, m.Close())
s = m.Status()
assert.ErrorIs(t, s.LastError, ErrConfigurationDeclined)
assert.ErrorContains(t, s.LastError, "requires root")
assert.False(t, s.LastConvergedCleared)
out := logs.String()
assert.Equal(t, 1, strings.Count(out, "level=INFO"), out)
assert.Contains(t, out, "DNS management declined")
assert.NotContains(t, out, "level=WARN")
}

// TestNew_DarwinWithRootBuildsBackend pins that root keeps the live backend.
func TestNew_DarwinWithRootBuildsBackend(t *testing.T) {
withRoot(t, true)
m, err := New(context.Background(), Options{Identity: "facade-root", ForceBackend: BackendDarwin})
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, m.Close()) })
assert.NotNil(t, m.locker)
assert.Nil(t, m.declined)
}
11 changes: 8 additions & 3 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@
// are expected to run as LocalSystem so Group Policy changes can be notified
// directly; elevated Administrator operation falls back to a full policy
// refresh; see "Convergence and repair".
// New logs a warning through Options.Logger when the macOS process is not root,
// or when the Windows process is not LocalSystem or its identity cannot be
// checked; construction still succeeds.
// On macOS, New declines DNS management for a process that is not root: it
// logs one Info line through Options.Logger, Set returns
// [ErrConfigurationDeclined], and Clear converges without touching the host.
// Status.LastError keeps that refusal for the Manager's life, so a converged
// Clear does not read as a verified clean host.
// On Windows, the first attempt that writes to the host logs a warning when
// the process is not LocalSystem or its identity cannot be checked; the
// attempt proceeds.
// The OS tools the backends invoke are resolved by absolute path and never
// through PATH. Unsupported platforms select [BackendNoop], which refuses
// [Manager.Set] unless explicitly forced.
Expand Down
12 changes: 9 additions & 3 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,15 @@ var (

// ErrConfigurationDeclined is returned by Set when the active backend
// applies nothing and the caller did not ask for that. Detection answers
// BackendNoop on unsupported platforms. Set records no goal. Explicitly
// selecting BackendNoop through Options.ForceBackend permits a goal that
// converges without touching the host.
// BackendNoop on unsupported platforms, and New declines the macOS backend
// for a process that is not root, since every write it would attempt
// needs root. A declined macOS Manager names BackendDarwin in its errors,
// its refusal says why, and its Clear converges without touching the host or
// taking locks. Set records no goal. Status.LastError holds this error
// from New on and LastConvergedCleared stays false, since a converged
// Clear checked nothing. Explicitly selecting BackendNoop
// through Options.ForceBackend permits a goal that converges without
// touching the host.
//
// Retrying on the same Manager cannot change this condition.
ErrConfigurationDeclined = errors.New("backend applies no DNS configuration")
Expand Down
3 changes: 3 additions & 0 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ func ExampleManager_Clear_recovery() {
for {
s := m.Status()
switch {
case errors.Is(s.LastError, dnsconfig.ErrConfigurationDeclined):
log.Printf("DNS management declined; nothing was checked: %v", s.LastError)
return
case s.Converged && s.LastChangedAt.IsZero():
log.Print("no DNS configuration attributable to myapp was found")
return
Expand Down
2 changes: 1 addition & 1 deletion internal/backend/darwin/conflict_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func (h *sharedHost) backend(identity string) *Backend {
if b, ok := h.backends[identity]; ok {
return b
}
b, err := New(context.Background(), Options{Identity: identity, FS: h.fs, Runner: h.fake, Logger: discardLogger(), IsRoot: asRoot})
b, err := New(context.Background(), Options{Identity: identity, FS: h.fs, Runner: h.fake, Logger: discardLogger()})
require.NoError(h.t, err)
useFS(b, h.fs)
h.t.Cleanup(func() { _ = b.Close() })
Expand Down
1 change: 0 additions & 1 deletion internal/backend/darwin/converge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -830,7 +830,6 @@ func TestConvergeLogsWhatChanged(t *testing.T) {
FS: fs,
Runner: fake,
Logger: slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})),
IsRoot: asRoot,
})
require.NoError(t, err)
useFS(b, fs)
Expand Down
10 changes: 0 additions & 10 deletions internal/backend/darwin/darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,6 @@ type Options struct {
Runner runner.Runner
// Logger receives non-fatal warnings. It is required.
Logger *slog.Logger
// IsRoot reports whether the process runs as root, for the startup
// diagnostic. Nil checks the effective user ID.
IsRoot func() bool
}

type stateKeyConfig struct {
Expand Down Expand Up @@ -121,13 +118,6 @@ func New(_ context.Context, opts Options) (*Backend, error) {
if fs == nil {
fs = backend.DiskFS{}
}
isRoot := opts.IsRoot
if isRoot == nil {
isRoot = func() bool { return os.Geteuid() == 0 }
}
if !isRoot() {
opts.Logger.Warn("darwin: DNS configuration requires root for the dynamic store, /etc/resolver, and the lock directory; without it every Set and Clear attempt is refused before writing")
}
r := opts.Runner
if r == nil {
r = runner.ExecRunner{}
Expand Down
43 changes: 0 additions & 43 deletions internal/backend/darwin/darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -522,61 +522,19 @@ func discardLogger() *slog.Logger {
return slog.New(slog.DiscardHandler)
}

// asRoot keeps the startup diagnostic independent of the test runner's user.
func asRoot() bool { return true }

func newTestBackend(t *testing.T, fs dirFS, fake runner.Runner) *Backend {
t.Helper()
b, err := New(context.Background(), Options{
Identity: testIdentity,
FS: fs,
Runner: fake,
Logger: discardLogger(),
IsRoot: asRoot,
})
require.NoError(t, err)
t.Cleanup(func() { _ = b.Close() })
return useFS(b, fs)
}

func TestNewWarnsWithoutRoot(t *testing.T) {
for _, tc := range []struct {
name string
root bool
want string
}{
{name: "root", root: true},
{name: "not root", want: "requires root"},
} {
t.Run(tc.name, func(t *testing.T) {
var logs bytes.Buffer
fs := newTestFS(t)
fake := newScutilFake()
calls := 0
b, err := New(context.Background(), Options{
Identity: testIdentity,
FS: fs,
Runner: fake,
Logger: slog.New(slog.NewTextHandler(&logs, nil)),
IsRoot: func() bool {
calls++
return tc.root
},
})
require.NoError(t, err)
t.Cleanup(func() { _ = b.Close() })
assert.Equal(t, 1, calls)
assertQuiet(t, observeHost(fs, fake), "construction must not change DNS")
if tc.want == "" {
assert.Empty(t, logs.String())
return
}
assert.Equal(t, 1, strings.Count(logs.String(), "level=WARN"))
assert.Contains(t, logs.String(), tc.want)
})
}
}

var (
newGoal = backend.Attempt{NewGoal: true}
repeat = backend.Attempt{}
Expand Down Expand Up @@ -990,7 +948,6 @@ func TestClearLeavesKeysItCannotAttribute(t *testing.T) {
FS: fs,
Runner: fr,
Logger: discardLogger(),
IsRoot: asRoot,
})
require.NoError(t, err)
useFS(b, fs)
Expand Down
3 changes: 3 additions & 0 deletions internal/backend/windows/converge.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ type mutation struct {
// notification for a write to the GP store.

func (x *mutation) begin(base string) {
if !x.started {
x.b.warnIdentityOnce()
}
x.started = true
x.b.flushOwed = true
if base == gpoNrptBase {
Expand Down
38 changes: 28 additions & 10 deletions internal/backend/windows/windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,8 @@ type Options struct {
// Logger receives structured diagnostics. It is required.
Logger *slog.Logger

// IsLocalSystem checks the execution identity for the startup diagnostic.
// IsLocalSystem checks the execution identity for the diagnostic logged
// before the first registry write, including a reload trigger.
// Nil uses the process token on Windows and skips the check elsewhere.
IsLocalSystem func() (bool, error)

Expand Down Expand Up @@ -278,6 +279,10 @@ type Backend struct {
// The fields from here to mu are touched only by Set and Clear, which
// the Manager never runs concurrently.

// checkIdentity is the LocalSystem check warnIdentityOnce runs, nil once
// it has run or where no check exists.
checkIdentity func() (bool, error)

// adapterTarget is the adapter whose NameServer and SearchList this
// instance still owes cleanup: one it wrote to, or a recorded
// predecessor's adapter whose clear failed. It survives external record
Expand Down Expand Up @@ -319,15 +324,6 @@ func New(_ context.Context, opts Options) (*Backend, error) {
if checkIdentity == nil {
checkIdentity = newDefaultLocalSystemCheck()
}
if checkIdentity != nil {
system, err := checkIdentity()
switch {
case err != nil:
logger.Warn("windows: could not verify execution identity; DNS configuration expects LocalSystem", "error", err)
case !system:
logger.Warn("windows: DNS configuration expects LocalSystem; running without it is best effort, and Group Policy DNS rules may remain effective after cleanup")
}
}
r := opts.Runner
if r == nil {
r = runner.ExecRunner{}
Expand Down Expand Up @@ -363,10 +359,29 @@ func New(_ context.Context, opts Options) (*Backend, error) {
resolveInterface: resolve,
notifier: notifier,
domainJoined: domainJoined,
checkIdentity: checkIdentity,
changes: make(chan struct{}, 1),
}, nil
}

// warnIdentityOnce logs the execution-identity diagnostic before the first
// registry write, from mutation.begin and reload. Every Windows process builds a backend, but only one that writes
// can leave Group Policy rules effective after cleanup.
func (b *Backend) warnIdentityOnce() {
check := b.checkIdentity
if check == nil {
return
}
b.checkIdentity = nil
system, err := check()
switch {
case err != nil:
b.logger.Warn("windows: could not verify execution identity; DNS configuration expects LocalSystem", "error", err)
case !system:
b.logger.Warn("windows: DNS configuration expects LocalSystem; running without it is best effort, and Group Policy DNS rules may remain effective after cleanup")
}
}

// Changes returns the coalesced change signal. It is closed by Close.
func (b *Backend) Changes() <-chan struct{} { return b.changes }

Expand Down Expand Up @@ -454,6 +469,9 @@ func (b *Backend) reload(ctx context.Context, mut *mutation) error {
if err := ctx.Err(); err != nil {
return err
}
// A reload can be an attempt's only write, when a predecessor's artifacts
// already match.
b.warnIdentityOnce()
if err := b.reg.SetString(nrptBase, reloadTriggerValue, "1"); err != nil {
return fmt.Errorf("windows: trigger adapter reload: %w", err)
}
Expand Down
Loading
Loading