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
10 changes: 5 additions & 5 deletions cmd/agent_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ func setupAgent(

execManager := exec.New(log, appConfig.Agent.PrivilegeEscalation.Enabled)

// --- File provider (created early — DNS, sysctl, cron, etc. depend on it) ---
hostname, _ := job.GetAgentHostname(appConfig.Agent.Hostname)
fileProvider, fileStateKV := createFileProvider(ctx, log, b, namespace, hostname)

// --- Node providers ---
var hostProvider nodeHost.Provider
switch plat {
Expand Down Expand Up @@ -136,7 +140,7 @@ func setupAgent(
if platform.IsContainer() {
dnsProvider = dns.NewDebianDockerProvider(log, appFs)
} else {
dnsProvider = dns.NewDebianProvider(log, execManager)
dnsProvider = dns.NewDebianProvider(log, appFs, fileStateKV, execManager, hostname)
}
case "darwin":
dnsProvider = dns.NewDarwinProvider(log, execManager)
Expand Down Expand Up @@ -180,10 +184,6 @@ func setupAgent(
slog.String("error", err.Error()))
}

// --- File provider ---
hostname, _ := job.GetAgentHostname(appConfig.Agent.Hostname)
fileProvider, fileStateKV := createFileProvider(ctx, log, b, namespace, hostname)

// --- Cron provider ---
cronProvider := createCronProvider(log, fileProvider, fileStateKV, hostname)

Expand Down
6 changes: 4 additions & 2 deletions docs/docs/sidebar/features/network-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ unprivileged while agents execute the actual changes.
## How It Works

**DNS** -- queries read the current nameserver configuration for a network
interface. Updates modify the nameservers and search domains, applying changes
through the host's network manager. The `--interface-name` parameter supports
interface via `resolvectl`. Updates generate a persistent Netplan configuration
file (`/etc/netplan/osapi-dns.yaml`) targeting the primary interface, validate
with `netplan generate`, and apply with `netplan apply`. This ensures DNS
changes survive reboots. The `--interface-name` parameter supports
[fact references](system-facts.md) — use `@fact.interface.primary` to
automatically target the default route interface.

Expand Down
26 changes: 22 additions & 4 deletions docs/docs/sidebar/features/system-facts.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,29 @@ which reference could not be resolved.

### Supported Contexts

Fact references work in any string value within job request data:
Fact references work in **any string value** in any API request that reaches an
agent. The agent resolves all `@fact.*` tokens before passing data to the
provider — no special handling is needed per endpoint. If a field accepts a
string, it accepts a fact reference.

- **Command arguments** — `--args "@fact.hostname"`
- **DNS interface name** — `--interface-name @fact.interface.primary`
- **Nested values** — references inside maps and arrays are resolved recursively
This includes:

- **Standalone strings** — `--interface-name @fact.interface.primary`
- **Arrays** — `--servers "1.1.1.1,@fact.custom.dns_server"` resolves the fact
while keeping the literal IP
- **Nested maps** — references inside JSON objects are resolved recursively
- **Any domain** — commands, DNS, sysctl, services, packages, etc.

```bash
# Fact reference in an array field
osapi client node network dns update \
--servers 1.1.1.1 --servers @fact.custom.backup_dns \
--interface-name @fact.interface.primary

# Each agent resolves its own facts, so broadcast works:
# web-01 might resolve @fact.interface.primary to "eth0"
# web-02 might resolve it to "ens3"
```

Non-string values (numbers, booleans) are not modified.

Expand Down
158 changes: 49 additions & 109 deletions internal/exec/mocks/dns.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ package mocks

import (
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
)

const (
Expand All @@ -32,6 +31,9 @@ const (

// ResolveCommand represents the `resolvectl` command used for resolving network settings.
ResolveCommand = "resolvectl"

// NetplanCommand represents the `netplan` command used for applying network configuration.
NetplanCommand = "netplan"
)

// NewPlainMockManager creates a Mock without defaults.
Expand Down Expand Up @@ -81,138 +83,85 @@ DNS Servers: 192.168.1.1 8.8.8.8 8.8.4.4 2001:4860:4860::8888 2001:4860:4860::88
return mock
}

// NewSetResolvConfMockManager creates a DNS Mock for SetResolvConf.
// NewSetResolvConfMockManager creates a DNS Mock for UpdateResolvConfByInterface
// with new servers and domains that differ from the existing config.
func NewSetResolvConfMockManager(ctrl *gomock.Controller) *MockManager {
output := `
Current Scopes: DNS
Protocols: +DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
Current DNS Server: 8.8.8.8
DNS Servers: 8.8.8.8 9.9.9.9
DNS Domain: foo.local bar.local
Current DNS Server: 1.1.1.1
DNS Servers: 1.1.1.1 2.2.2.2
DNS Domain: old.local
`

mock := NewMockManager(ctrl)

mockRunCmdStatus(mock, output)
mockRunCmdDNS(mock, []string{"8.8.8.8", "9.9.9.9"}, nil)
mockRunCmdDomain(mock, []string{"foo.local", "bar.local"}, nil)
mockNetplanApply(mock, nil, nil)

return mock
}

// NewSetResolvConfPreserveDNSServersMockManager creates a DNS Mock for SetResolvConf
// with existing DNS Servers.
// NewSetResolvConfPreserveDNSServersMockManager creates a DNS Mock for
// UpdateResolvConfByInterface with existing DNS Servers preserved.
func NewSetResolvConfPreserveDNSServersMockManager(ctrl *gomock.Controller) *MockManager {
initialOutput := `
output := `
Current Scopes: DNS
Protocols: +DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
Current DNS Server: 192.168.1.1
DNS Servers: 1.1.1.1 2.2.2.2
DNS Domain: example.com local.lan
`

subsequentOutput := `
Current Scopes: DNS
Protocols: +DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
Current DNS Server: 1.1.1.1
DNS Servers: 1.1.1.1 2.2.2.2
DNS Domain: foo.local bar.local
`

mock := NewMockManager(ctrl)

gomock.InOrder(
mock.EXPECT().
RunCmd(ResolveCommand, []string{"status", NetworkInterfaceName}).
Return(initialOutput, nil),

mock.EXPECT().
RunCmd(ResolveCommand, []string{"status", NetworkInterfaceName}).
Return(subsequentOutput, nil),
)

mockRunCmdDNS(mock, []string{"1.1.1.1", "2.2.2.2"}, nil)
mockRunCmdDomain(mock, []string{"foo.local", "bar.local"}, nil)
mockRunCmdStatus(mock, output)
mockNetplanApply(mock, nil, nil)

return mock
}

// NewSetResolvConfPreserveDNSDomainMockManager creates a DNS Mock for SetResolvConf
// with existing DNS Domain.
// NewSetResolvConfPreserveDNSDomainMockManager creates a DNS Mock for
// UpdateResolvConfByInterface with existing DNS Domain preserved.
func NewSetResolvConfPreserveDNSDomainMockManager(ctrl *gomock.Controller) *MockManager {
initialOutput := `
output := `
Current Scopes: DNS
Protocols: +DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
Current DNS Server: 192.168.1.1
DNS Servers: 1.1.1.1 2.2.2.2
DNS Domain: foo.example.com bar.example.com
`

subsequentOutput := `
Current Scopes: DNS
Protocols: +DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
Current DNS Server: 1.1.1.1
DNS Servers: 8.8.8.8 9.9.9.9
DNS Domain: foo.example.com bar.example.com
`

mock := NewMockManager(ctrl)

gomock.InOrder(
mock.EXPECT().
RunCmd(ResolveCommand, []string{"status", NetworkInterfaceName}).
Return(initialOutput, nil),

mock.EXPECT().
RunCmd(ResolveCommand, []string{"status", NetworkInterfaceName}).
Return(subsequentOutput, nil),
)

mockRunCmdDNS(mock, []string{"8.8.8.8", "9.9.9.9"}, nil)
mockRunCmdDomain(mock, []string{"foo.example.com", "bar.example.com"}, nil)
mockRunCmdStatus(mock, output)
mockNetplanApply(mock, nil, nil)

return mock
}

// NewSetResolvConfFiltersRootDNSDomainMockManager creates a DNS Mock for SetResolvConf
// with no DNS Domain.
// NewSetResolvConfFiltersRootDNSDomainMockManager creates a DNS Mock for
// UpdateResolvConfByInterface with no DNS Domain (only root ".").
func NewSetResolvConfFiltersRootDNSDomainMockManager(ctrl *gomock.Controller) *MockManager {
initialOutput := `
output := `
Current Scopes: DNS
Protocols: +DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
Current DNS Server: 192.168.1.1
DNS Servers: 1.1.1.1 2.2.2.2
`

subsequentOutput := `
Current Scopes: DNS
Protocols: +DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
Current DNS Server: 1.1.1.1
DNS Servers: 8.8.8.8 9.9.9.9
`

mock := NewMockManager(ctrl)

gomock.InOrder(
mock.EXPECT().
RunCmd(ResolveCommand, []string{"status", NetworkInterfaceName}).
Return(initialOutput, nil),

mock.EXPECT().
RunCmd(ResolveCommand, []string{"status", NetworkInterfaceName}).
Return(subsequentOutput, nil),
)

mockRunCmdDNS(mock, []string{"8.8.8.8", "9.9.9.9"}, nil)
mockRunCmdDomain(mock, []string{"foo.example.com", "bar.example.com"}, nil)
mockRunCmdStatus(mock, output)
mockNetplanApply(mock, nil, nil)

return mock
}

// NewSetResolvConfSetDNSDomainErrorMockManager creates a DNS Mock for SetResolvConf
// when exec.RunCmd errors setting DNS Domain.
func NewSetResolvConfSetDNSDomainErrorMockManager(ctrl *gomock.Controller) *MockManager {
// Initial state must differ from desired so the no-op check does not skip the update.
// NewSetResolvConfNetplanGenerateErrorMockManager creates a DNS Mock for
// UpdateResolvConfByInterface when `netplan generate` fails.
func NewSetResolvConfNetplanGenerateErrorMockManager(ctrl *gomock.Controller) *MockManager {
// Initial state must differ from desired so the update proceeds.
output := `
Current Scopes: DNS
Protocols: +DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
Expand All @@ -224,30 +173,22 @@ DNS Domain: old.local
mock := NewMockManager(ctrl)

mockRunCmdStatus(mock, output)
mockRunCmdDNS(mock, []string{"8.8.8.8", "9.9.9.9"}, nil)
mockRunCmdDomain(mock, []string{"foo.local", "bar.local"}, assert.AnError)

return mock
}

// NewSetResolvConfSetDNSServersErrorMockManager creates a DNS Mock for SetResolvConf
// when exec.RunCmd errors setting DNS Servers.
func NewSetResolvConfSetDNSServersErrorMockManager(ctrl *gomock.Controller) *MockManager {
// Initial state must differ from desired so the no-op check does not skip the update.
output := `
Current Scopes: DNS
Protocols: +DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
Current DNS Server: 1.1.1.1
DNS Servers: 1.1.1.1 2.2.2.2
DNS Domain: old.local
`

mock := NewMockManager(ctrl)

mockRunCmdStatus(mock, output)
mockRunCmdDNS(mock, []string{"8.8.8.8", "9.9.9.9"}, assert.AnError)
// NewSetResolvConfSetDNSDomainErrorMockManager creates a DNS Mock for
// UpdateResolvConfByInterface when the write path fails. Kept for
// backwards compatibility with existing test names.
func NewSetResolvConfSetDNSDomainErrorMockManager(ctrl *gomock.Controller) *MockManager {
return NewSetResolvConfNetplanGenerateErrorMockManager(ctrl)
}

return mock
// NewSetResolvConfSetDNSServersErrorMockManager creates a DNS Mock for
// UpdateResolvConfByInterface when the write path fails. Kept for
// backwards compatibility with existing test names.
func NewSetResolvConfSetDNSServersErrorMockManager(ctrl *gomock.Controller) *MockManager {
return NewSetResolvConfNetplanGenerateErrorMockManager(ctrl)
}

// mockRunCmdStatus sets up a mock for the "status" RunCmd call.
Expand All @@ -258,18 +199,17 @@ func mockRunCmdStatus(mock *MockManager, output string) {
AnyTimes()
}

// mockRunCmdDNS sets up a mock for the "dns" RunPrivilegedCmd call.
func mockRunCmdDNS(mock *MockManager, dnsServers []string, err error) {
// mockNetplanApply sets up mocks for `netplan generate` and `netplan apply`.
func mockNetplanApply(mock *MockManager, genErr error, applyErr error) {
mock.EXPECT().
RunPrivilegedCmd(ResolveCommand, append([]string{"dns", NetworkInterfaceName}, dnsServers...)).
Return("", err).
RunPrivilegedCmd(NetplanCommand, []string{"generate"}).
Return("", genErr).
AnyTimes()
}

// mockRunCmdDomain sets up a mock for the "domain" RunPrivilegedCmd call.
func mockRunCmdDomain(mock *MockManager, domains []string, err error) {
mock.EXPECT().
RunPrivilegedCmd(ResolveCommand, append([]string{"domain", NetworkInterfaceName}, domains...)).
Return("", err).
AnyTimes()
if genErr == nil {
mock.EXPECT().
RunPrivilegedCmd(NetplanCommand, []string{"apply"}).
Return("", applyErr).
AnyTimes()
}
}
12 changes: 12 additions & 0 deletions internal/provider/network/dns/debian.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ package dns
import (
"log/slog"

"github.com/avfs/avfs"
"github.com/nats-io/nats.go/jetstream"

"github.com/retr0h/osapi/internal/exec"
"github.com/retr0h/osapi/internal/provider"
)
Expand All @@ -35,16 +38,25 @@ type Debian struct {
provider.FactsAware

logger *slog.Logger
fs avfs.VFS
stateKV jetstream.KeyValue
execManager exec.Manager
hostname string
}

// NewDebianProvider factory to create a new Debian instance.
func NewDebianProvider(
logger *slog.Logger,
fs avfs.VFS,
stateKV jetstream.KeyValue,
em exec.Manager,
hostname string,
) *Debian {
return &Debian{
logger: logger.With(slog.String("subsystem", "provider.dns")),
fs: fs,
stateKV: stateKV,
execManager: em,
hostname: hostname,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"os"
"testing"

"github.com/avfs/avfs/vfs/memfs"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
Expand Down Expand Up @@ -148,7 +149,7 @@ func (suite *DebianGetResolvConfPublicTestSuite) TestGetResolvConfByInterface()
suite.Run(tc.name, func() {
mock := tc.setupMock()

net := dns.NewDebianProvider(suite.logger, mock)
net := dns.NewDebianProvider(suite.logger, memfs.New(), nil, mock, "test-host")
got, err := net.GetResolvConfByInterface(tc.interfaceName)

if !tc.wantErr {
Expand Down
Loading
Loading