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
4 changes: 4 additions & 0 deletions agent/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,10 @@ func (boot bootstrap) Run() (err error) { //nolint:gocyclo
}
}

if err = boot.platform.SetupFirewall(); err != nil {
return bosherr.WrapError(err, "Setting up firewall")
}

if err = boot.platform.SetupMonitUser(); err != nil {
return bosherr.WrapError(err, "Setting up monit user")
}
Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ require (
github.com/coreos/go-iptables v0.8.0
github.com/gofrs/uuid v4.4.0+incompatible
github.com/golang/mock v1.6.0
github.com/google/nftables v0.3.0
github.com/google/uuid v1.6.0
github.com/kevinburke/ssh_config v1.6.0
github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321
Expand Down Expand Up @@ -70,6 +71,8 @@ require (
github.com/jpillora/backoff v1.0.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786 // indirect
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
github.com/mdlayher/socket v0.5.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg=
github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM=
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno=
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
Expand Down Expand Up @@ -157,6 +159,10 @@ github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321 h1:AKIJL2PfBX2uie0
github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321/go.mod h1:JajVhkiG2bYSNYYPYuWG7WZHr42CTjMTcCjfInRNCqc=
github.com/maxbrunsfeld/counterfeiter/v6 v6.12.1 h1:D4O2wLxB384TS3ohBJMfolnxb4qGmoZ1PnWNtit8LYo=
github.com/maxbrunsfeld/counterfeiter/v6 v6.12.1/go.mod h1:RuJdxo0oI6dClIaMzdl3hewq3a065RH65dofJP03h8I=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o=
github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI=
github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI=
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
Expand Down Expand Up @@ -220,6 +226,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde h1:AMNpJRc7P+GTwVbl8DkK2I9I8BBUzNiHuH/tlxrpan0=
github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde/go.mod h1:MvrEmduDUz4ST5pGZ7CABCnOU5f3ZiOAZzT6b1A6nX8=
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
Expand Down
1 change: 1 addition & 0 deletions handler/common_event_format.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ func (cef concreteCommonEventFormat) ProduceHTTPRequestEventLog(request *http.Re

buffer.WriteString(extension)
fmt.Fprintf(&buffer, "cs4=%s cs4Label=statusReason", respBody)

extension = buffer.String()
}

Expand Down
9 changes: 7 additions & 2 deletions main/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
boshapp "github.com/cloudfoundry/bosh-agent/v2/app"
"github.com/cloudfoundry/bosh-agent/v2/infrastructure/agentlogger"
"github.com/cloudfoundry/bosh-agent/v2/platform"
"github.com/cloudfoundry/bosh-agent/v2/platform/firewall"
)

const mainLogTag = "main"
Expand Down Expand Up @@ -76,15 +77,19 @@ func startAgent(logger logger.Logger) error {
}

func main() {
asyncLog := logger.NewAsyncWriterLogger(logger.LevelDebug, os.Stderr)
logger := newSignalableLogger(asyncLog)

if len(os.Args) > 1 {
switch cmd := os.Args[1]; cmd {
case "compile":
compileTarball(cmd, os.Args[2:])
return
case "enable-monit-access":
firewall.EnableMonitAccess(logger, cmd)
return
}
}
asyncLog := logger.NewAsyncWriterLogger(logger.LevelDebug, os.Stderr)
logger := newSignalableLogger(asyncLog)

exitCode := 0
if err := startAgent(logger); err != nil {
Expand Down
41 changes: 35 additions & 6 deletions mbus/nats_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,14 +99,29 @@ func NewNatsHandler(
func (h *natsHandler) arpClean() {
connectionInfo, err := h.getConnectionInfo()
if err != nil {
h.logger.Error(h.logTag, "%v", bosherr.WrapError(err, "Getting connection info"))
h.logger.Error(h.logTag, "Failed to get connection info for ARP clean: %v", err)
return
}
err = h.platform.DeleteARPEntryWithIP(connectionInfo.IP)
if err != nil {
if err := h.platform.DeleteARPEntryWithIP(connectionInfo.IP); err != nil {
h.logger.Error(h.logTag, "Cleaning ip-mac address cache for: %s. Error: %v", connectionInfo.IP, err)
}
}

h.logger.Debug(h.logTag, "Cleaned ip-mac address cache for: %s.", connectionInfo.IP)
// updateFirewallForNATS calls the firewall hook to update NATS rules before connection/reconnection.
// This allows DNS to be re-resolved, supporting HA failover where the director may have moved.
func (h *natsHandler) updateFirewallForNATS() {
hook := h.platform.GetNatsFirewallHook()
if hook == nil {
return
}

settings := h.settingsService.GetSettings()
mbusURL := settings.GetMbusURL()

if err := hook.BeforeConnect(mbusURL); err != nil {
// Log but don't fail - firewall update failure shouldn't prevent connection attempt
h.logger.Warn(h.logTag, "Failed to update NATS firewall rules: %v", err)
}
}

func (h *natsHandler) Run(handlerFunc boshhandler.Func) error {
Expand All @@ -131,11 +146,21 @@ func (h *natsHandler) Start(handlerFunc boshhandler.Func) error {
if net.ParseIP(connectionInfo.IP) != nil {
h.arpClean()
}

// Update firewall rules before initial connection
h.updateFirewallForNATS()

var natsOptions = []nats.Option{
nats.RetryOnFailedConnect(true),
nats.DisconnectErrHandler(func(c *nats.Conn, err error) {
h.logger.Debug(natsHandlerLogTag, "Nats disconnected with Error: %v", err.Error())
if err != nil {
h.logger.Debug(natsHandlerLogTag, "Nats disconnected with Error: %v", err.Error())
} else {
h.logger.Debug(natsHandlerLogTag, "Nats disconnected")
}
h.logger.Debug(natsHandlerLogTag, "Attempting to reconnect: %v", c.IsReconnecting())
// Update firewall rules before reconnection attempts (allows DNS re-resolution)
h.updateFirewallForNATS()
for c.IsReconnecting() {
h.arpClean()
h.logger.Debug(natsHandlerLogTag, "Waiting to reconnect to nats.. Current attempt: %v, Connected: %v", c.Reconnects, c.IsConnected())
Expand All @@ -146,7 +171,11 @@ func (h *natsHandler) Start(handlerFunc boshhandler.Func) error {
h.logger.Debug(natsHandlerLogTag, "Reconnected to %v", c.ConnectedAddr())
}),
nats.ClosedHandler(func(c *nats.Conn) {
h.logger.Debug(natsHandlerLogTag, "Connection Closed with: %v", c.LastError().Error())
if err := c.LastError(); err != nil {
h.logger.Debug(natsHandlerLogTag, "Connection Closed with: %v", err.Error())
} else {
h.logger.Debug(natsHandlerLogTag, "Connection Closed")
}
}),
nats.ErrorHandler(func(c *nats.Conn, s *nats.Subscription, err error) {
h.logger.Debug(natsHandlerLogTag, err.Error())
Expand Down
49 changes: 49 additions & 0 deletions mbus/nats_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
boshhandler "github.com/cloudfoundry/bosh-agent/v2/handler"
"github.com/cloudfoundry/bosh-agent/v2/mbus"
"github.com/cloudfoundry/bosh-agent/v2/mbus/mbusfakes"
"github.com/cloudfoundry/bosh-agent/v2/platform/firewall/firewallfakes"
"github.com/cloudfoundry/bosh-agent/v2/platform/platformfakes"
boshsettings "github.com/cloudfoundry/bosh-agent/v2/settings"
fakesettings "github.com/cloudfoundry/bosh-agent/v2/settings/fakes"
Expand Down Expand Up @@ -407,6 +408,54 @@ func init() { //nolint:funlen,gochecknoinits
})
})
})

Context("Firewall hook", func() {
var fakeFirewallHook *firewallfakes.FakeNatsFirewallHook

BeforeEach(func() {
fakeFirewallHook = &firewallfakes.FakeNatsFirewallHook{}
platform.GetNatsFirewallHookReturns(fakeFirewallHook)
})

It("calls GetNatsFirewallHook on Start", func() {
err := handler.Start(func(req boshhandler.Request) (res boshhandler.Response) { return })
Expect(err).NotTo(HaveOccurred())
defer handler.Stop()

Expect(platform.GetNatsFirewallHookCallCount()).To(BeNumerically(">=", 1))
})

It("calls BeforeConnect with the mbus URL before initial connection", func() {
err := handler.Start(func(req boshhandler.Request) (res boshhandler.Response) { return })
Expect(err).NotTo(HaveOccurred())
defer handler.Stop()

Expect(fakeFirewallHook.BeforeConnectCallCount()).To(Equal(1))
mbusURL := fakeFirewallHook.BeforeConnectArgsForCall(0)
Expect(mbusURL).To(Equal("nats://fake-username:fake-password@127.0.0.1:1234"))
})

It("does not fail if hook returns nil", func() {
platform.GetNatsFirewallHookReturns(nil)

err := handler.Start(func(req boshhandler.Request) (res boshhandler.Response) { return })
Expect(err).NotTo(HaveOccurred())
defer handler.Stop()
})

It("logs warning but does not fail if BeforeConnect returns error", func() {
fakeFirewallHook.BeforeConnectReturns(errors.New("firewall update failed"))
loggerOutBuf = bytes.NewBufferString("")
logger = boshlog.NewWriterLogger(boshlog.LevelWarn, loggerOutBuf)
handler = mbus.NewNatsHandler(settingsService, connector, logger, platform)

err := handler.Start(func(req boshhandler.Request) (res boshhandler.Response) { return })
Expect(err).NotTo(HaveOccurred())
defer handler.Stop()

Expect(loggerOutBuf.String()).To(ContainSubstring("Failed to update NATS firewall rules"))
})
})
})

Describe("Send", func() {
Expand Down
9 changes: 9 additions & 0 deletions platform/dummy_platform.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
boshlogstarprovider "github.com/cloudfoundry/bosh-agent/v2/agent/logstarprovider"
boshdpresolv "github.com/cloudfoundry/bosh-agent/v2/infrastructure/devicepathresolver"
boshcert "github.com/cloudfoundry/bosh-agent/v2/platform/cert"
"github.com/cloudfoundry/bosh-agent/v2/platform/firewall"
boship "github.com/cloudfoundry/bosh-agent/v2/platform/net/ip"
boshstats "github.com/cloudfoundry/bosh-agent/v2/platform/stats"
boshvitals "github.com/cloudfoundry/bosh-agent/v2/platform/vitals"
Expand Down Expand Up @@ -562,6 +563,14 @@ func (p dummyPlatform) SetupRecordsJSONPermission(path string) error {
return nil
}

func (p dummyPlatform) SetupFirewall() error {
return nil
}

func (p dummyPlatform) Shutdown() error {
return nil
}

func (p dummyPlatform) GetNatsFirewallHook() firewall.NatsFirewallHook {
return nil
}
79 changes: 79 additions & 0 deletions platform/firewall/cgroup_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package firewall

import (
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
)

// getCurrentCgroupPath reads /proc/self/cgroup and extracts the cgroupv2 path.
// Returns path WITHOUT leading slash (e.g., "system.slice/runc-bpm-galera-agent.scope")
// to match the format used by the nft CLI.
func getCurrentCgroupPath() (string, error) {
data, err := os.ReadFile("/proc/self/cgroup")
if err != nil {
return "", fmt.Errorf("reading /proc/self/cgroup: %w", err)
}

// Find line starting with "0::" (cgroupv2)
// Format: "0::/system.slice/runc-bpm-galera-agent.scope"
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "0::") {
path := strings.TrimPrefix(line, "0::")
// Strip leading slash to match Noble script format
path = strings.TrimPrefix(path, "/")
return path, nil
}
}

return "", fmt.Errorf("cgroupv2 path not found in /proc/self/cgroup")
}

// isCgroupAccessible checks if the cgroup path is accessible and functional
// for nftables socket cgroupv2 matching.
//
// This returns false in these cases:
// - Cgroup path doesn't exist in /sys/fs/cgroup
// - Hybrid cgroup system (cgroupv2 mounted but no controllers delegated)
// - Nested containers where cgroup path is different from host view
func isCgroupAccessible(cgroupPath string) bool {
// Check if cgroup path exists
fullPath := filepath.Join("/sys/fs/cgroup", cgroupPath)
if _, err := os.Stat(fullPath); err != nil {
fmt.Printf("bosh-monit-access: Cgroup path doesn't exist: %s\n", fullPath)
return false
}

// Check if this is a hybrid cgroup system (cgroupv2 mounted but no controllers)
// On hybrid systems, /sys/fs/cgroup/cgroup.controllers exists but is empty
controllers, err := os.ReadFile("/sys/fs/cgroup/cgroup.controllers")
if err != nil {
fmt.Printf("bosh-monit-access: Cannot read cgroup.controllers: %v\n", err)
return false
}

if len(strings.TrimSpace(string(controllers))) == 0 {
fmt.Println("bosh-monit-access: Hybrid cgroup system detected (no controllers in cgroupv2)")
return false
}

return true
}

// getCgroupInodeID returns the inode ID for the cgroup path.
// The nftables kernel expects an 8-byte cgroup inode ID for 'socket cgroupv2'
// matching, NOT the path string. The nft CLI translates paths to inode IDs
// automatically, but the Go library requires manual lookup.
func getCgroupInodeID(cgroupPath string) (uint64, error) {
fullPath := filepath.Join("/sys/fs/cgroup", cgroupPath)

var stat syscall.Stat_t
if err := syscall.Stat(fullPath, &stat); err != nil {
return 0, fmt.Errorf("stat %s: %w", fullPath, err)
}

return stat.Ino, nil
}
62 changes: 62 additions & 0 deletions platform/firewall/firewall.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Package firewall provides nftables-based firewall management for the BOSH agent.
//
// The firewall protects access to:
// - Monit (port 2822 on localhost): Used by the agent to manage job processes
// - NATS (director's message bus): Used for agent-director communication
//
// Security Model:
// The firewall uses UID-based matching (meta skuid 0) to allow only root processes
// to access these services. This blocks non-root BOSH job workloads (vcap user)
// while allowing the agent and operators to access monit/NATS.
//
// This approach is simpler and more reliable than cgroup-based matching, which
// fails in nested container environments due to cgroup filesystem bind-mount issues.
package firewall

import "fmt"

const (
TableName = "bosh_agent"
MonitChainName = "monit_access"
MonitJobsChainName = "monit_access_jobs"
NATSChainName = "nats_access"
MonitPort = 2822
MonitAccessLogPrefix = "bosh-monit-access: "
)

var (
ErrMonitJobsChainNotFound = fmt.Errorf("%s chain not found", MonitJobsChainName)
ErrBoshTableNotFound = fmt.Errorf("%s table not found", TableName)
)

// Manager handles firewall setup
//
//go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate
//counterfeiter:generate . Manager
type Manager interface {
// SetupMonitFirewall creates firewall rules to protect monit (port 2822).
// Only root (UID 0) is allowed to connect.
SetupMonitFirewall() error

// EnableMonitAccess enables monit access by adding firewall rules.
// It first tries to use cgroup-based matching, then falls back to UID-based matching.
EnableMonitAccess() error

// SetupNATSFirewall creates firewall rules to protect NATS.
// Only root (UID 0) is allowed to connect to the resolved NATS address.
// This method resolves DNS and should be called before each connection attempt.
SetupNATSFirewall(mbusURL string) error

// Cleanup closes the nftables connection.
Cleanup() error
}

// NatsFirewallHook is called by the NATS handler before connection/reconnection.
// This allows DNS to be re-resolved, supporting HA failover scenarios.
//
//counterfeiter:generate . NatsFirewallHook
type NatsFirewallHook interface {
// BeforeConnect is called before each NATS connection/reconnection attempt.
// It resolves the NATS URL and updates firewall rules with the resolved IP.
BeforeConnect(mbusURL string) error
}
Loading