Explicitly test that firewall rules are being updated with garage ports

This commit is contained in:
Brian Picciano 2025-01-08 12:00:49 +01:00
parent 8073a05415
commit 46a3fd5a22
9 changed files with 222 additions and 33 deletions

View File

@ -45,6 +45,9 @@ type Opts struct {
// new allocations. If not given then garagesrv.DBEngineSqlite will be used // new allocations. If not given then garagesrv.DBEngineSqlite will be used
// for new allocations. // for new allocations.
GarageDefaultDBEngine garagesrv.DBEngine GarageDefaultDBEngine garagesrv.DBEngine
// TestBlocker is used by tests to set blockpoints.
TestBlocker *toolkit.TestBlocker
} }
func (o *Opts) withDefaults() *Opts { func (o *Opts) withDefaults() *Opts {
@ -341,6 +344,8 @@ func (c *Children) Reload(
return fmt.Errorf("reloading nebula: %w", err) return fmt.Errorf("reloading nebula: %w", err)
} }
c.opts.TestBlocker.Blockpoint(ctx, "Children.Reload.postReloadNebula")
var errs []error var errs []error
if err := c.reloadDNSMasq(ctx, newNetworkConfig, newBootstrap); err != nil { if err := c.reloadDNSMasq(ctx, newNetworkConfig, newBootstrap); err != nil {

View File

@ -532,6 +532,8 @@ func (n *network) glmStateTransitionUnsafe(ctx context.Context) error {
return fmt.Errorf("reloading children: %w", err) return fmt.Errorf("reloading children: %w", err)
} }
n.opts.testBlocker.Blockpoint(ctx, "glmStateTransition.postChildrenReload")
if adminClient, ok := n.children.GarageAdminClient(); ok { if adminClient, ok := n.children.GarageAdminClient(); ok {
defer adminClient.Close() defer adminClient.Close()
@ -768,6 +770,7 @@ func (n *network) initialize(ctx context.Context, isCreate bool) error {
GarageNewCluster: isCreate, GarageNewCluster: isCreate,
GarageBootstrapPeers: garageBootstrapPeers, GarageBootstrapPeers: garageBootstrapPeers,
GarageDefaultDBEngine: n.opts.garageDefaultDBEngine, GarageDefaultDBEngine: n.opts.garageDefaultDBEngine,
TestBlocker: n.opts.testBlocker,
}, },
) )
if err != nil { if err != nil {

View File

@ -109,17 +109,35 @@ func TestJoin(t *testing.T) {
assert.Equal(t, primus.getHostsByName(t), secondus.getHostsByName(t)) assert.Equal(t, primus.getHostsByName(t), secondus.getHostsByName(t))
}) })
t.Run("with alloc/simple", func(t *testing.T) { t.Run("with alloc", func(t *testing.T) {
var ( var (
h = newIntegrationHarness(t) h = newIntegrationHarness(t)
primus = h.createNetwork(t, "primus", nil) primus = h.createNetwork(t, "primus", nil)
secondus = h.joinNetwork(t, primus, "secondus", &joinNetworkOpts{ secondusBlocker = toolkit.NewTestBlocker(t)
networkConfigOpts: &networkConfigOpts{ secondusRuntimeDir = h.mkDir(t, "runtime")
numStorageAllocs: 1,
},
})
) )
secondusBlocker.Expect("Children.Reload.postReloadNebula").Then(
t, h.ctx, func() {
h.logger.Info(h.ctx, "Checking that firewall was updated with new alloc")
assertFirewallInboundEquals(
t, secondusRuntimeDir, []daecommon.ConfigFirewallRule{
{Port: "any", Proto: "icmp", Host: "any"},
{Port: "3900", Proto: "tcp", Host: "any"},
{Port: "3901", Proto: "tcp", Host: "any"},
},
)
},
)
secondus := h.joinNetwork(t, primus, "secondus", &joinNetworkOpts{
networkConfigOpts: &networkConfigOpts{
numStorageAllocs: 1,
},
blocker: secondusBlocker,
runtimeDir: secondusRuntimeDir,
})
h.logger.Info(h.ctx, "reloading primus' hosts") h.logger.Info(h.ctx, "reloading primus' hosts")
assert.NoError(t, primus.Network.(*network).reloadHosts(h.ctx)) assert.NoError(t, primus.Network.(*network).reloadHosts(h.ctx))
@ -184,8 +202,11 @@ func TestNetwork_SetConfig(t *testing.T) {
t.Run("add storage alloc/simple", func(t *testing.T) { t.Run("add storage alloc/simple", func(t *testing.T) {
var ( var (
h = newIntegrationHarness(t) h = newIntegrationHarness(t)
network = h.createNetwork(t, "primus", nil) blocker = toolkit.NewTestBlocker(t)
network = h.createNetwork(t, "primus", &createNetworkOpts{
blocker: blocker,
})
networkConfig = network.getConfig(t) networkConfig = network.getConfig(t)
metaPath = h.mkDir(t, "meta").Path metaPath = h.mkDir(t, "meta").Path
) )
@ -196,12 +217,31 @@ func TestNetwork_SetConfig(t *testing.T) {
DataPath: h.mkDir(t, "data").Path, DataPath: h.mkDir(t, "data").Path,
MetaPath: metaPath, MetaPath: metaPath,
Capacity: 1, Capacity: 1,
S3APIPort: 4900, S3APIPort: 4901,
RPCPort: 4901, RPCPort: 4900,
AdminPort: 4902, AdminPort: 4902,
}, },
) )
blocker.Expect("Children.Reload.postReloadNebula").Then(
t, h.ctx, func() {
h.logger.Info(h.ctx, "Checking that firewall was updated with new alloc")
assertFirewallInboundEquals(
t, network.runtimeDir, []daecommon.ConfigFirewallRule{
{Port: "any", Proto: "icmp", Host: "any"},
{Port: "3900", Proto: "tcp", Host: "any"},
{Port: "3901", Proto: "tcp", Host: "any"},
{Port: "3910", Proto: "tcp", Host: "any"},
{Port: "3911", Proto: "tcp", Host: "any"},
{Port: "3920", Proto: "tcp", Host: "any"},
{Port: "3921", Proto: "tcp", Host: "any"},
{Port: "4900", Proto: "tcp", Host: "any"},
{Port: "4901", Proto: "tcp", Host: "any"},
},
)
},
)
assert.NoError(t, network.SetConfig(h.ctx, networkConfig)) assert.NoError(t, network.SetConfig(h.ctx, networkConfig))
h.logger.Info(h.ctx, "Checking that the Host information was updated") h.logger.Info(h.ctx, "Checking that the Host information was updated")
@ -216,8 +256,8 @@ func TestNetwork_SetConfig(t *testing.T) {
assert.NotEmpty(t, newAlloc.ID) assert.NotEmpty(t, newAlloc.ID)
newAlloc.ID = "" newAlloc.ID = ""
assert.Equal(t, bootstrap.GarageHostInstance{ assert.Equal(t, bootstrap.GarageHostInstance{
S3APIPort: 4900, S3APIPort: 4901,
RPCPort: 4901, RPCPort: 4900,
}, newAlloc) }, newAlloc)
h.logger.Info(h.ctx, "Checking that the bootstrap file was written with the new host config") h.logger.Info(h.ctx, "Checking that the bootstrap file was written with the new host config")
@ -235,7 +275,7 @@ func TestNetwork_SetConfig(t *testing.T) {
h.logger.Info(h.ctx, "Checking that garage is using the expected db engine") h.logger.Info(h.ctx, "Checking that garage is using the expected db engine")
garageConfig, err := os.ReadFile( garageConfig, err := os.ReadFile(
filepath.Join(network.runtimeDir.Path, "garage-4901.toml"), filepath.Join(network.runtimeDir.Path, "garage-4900.toml"),
) )
assert.NoError(t, err) assert.NoError(t, err)
assert.Contains(t, assert.Contains(t,
@ -245,6 +285,50 @@ func TestNetwork_SetConfig(t *testing.T) {
assert.FileExists(t, filepath.Join(metaPath, "db.sqlite")) assert.FileExists(t, filepath.Join(metaPath, "db.sqlite"))
}) })
t.Run("add storage alloc/on second host", func(t *testing.T) {
var (
h = newIntegrationHarness(t)
primus = h.createNetwork(t, "primus", nil)
secondusBlocker = toolkit.NewTestBlocker(t)
secondus = h.joinNetwork(t, primus, "secondus", &joinNetworkOpts{
blocker: secondusBlocker,
})
secondusNetworkConfig = secondus.getConfig(t)
)
secondusBlocker.Expect("Children.Reload.postReloadNebula").Then(
t, h.ctx, func() {
h.logger.Info(h.ctx, "Checking that firewall was updated with new alloc")
assertFirewallInboundEquals(
t, secondus.runtimeDir, []daecommon.ConfigFirewallRule{
{Port: "any", Proto: "icmp", Host: "any"},
{Port: "3900", Proto: "tcp", Host: "any"},
{Port: "3901", Proto: "tcp", Host: "any"},
},
)
},
)
secondusNetworkConfig.Storage.Allocations = append(
secondusNetworkConfig.Storage.Allocations,
daecommon.ConfigStorageAllocation{
DataPath: h.mkDir(t, "data").Path,
MetaPath: h.mkDir(t, "meta").Path,
Capacity: 1,
S3APIPort: 3901,
RPCPort: 3900,
AdminPort: 3902,
},
)
assert.NoError(t, secondus.SetConfig(h.ctx, secondusNetworkConfig))
assertGarageLayout(t, map[*integrationHarnessNetwork]int{
primus: 3,
secondus: 1,
})
})
t.Run("remove storage alloc", func(t *testing.T) { t.Run("remove storage alloc", func(t *testing.T) {
var ( var (
h = newIntegrationHarness(t) h = newIntegrationHarness(t)

View File

@ -11,6 +11,7 @@ import (
"isle/garage/garagesrv" "isle/garage/garagesrv"
"isle/nebula" "isle/nebula"
"isle/toolkit" "isle/toolkit"
"isle/yamlutil"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
@ -91,7 +92,7 @@ func newIntegrationHarness(t *testing.T) *integrationHarness {
t.Cleanup(func() { t.Cleanup(func() {
if t.Failed() { if t.Failed() {
logger.Info(ctx, "Test failed, temporarty test root directory NOT deleted") logger.Info(ctx, "Test failed, temporary test root directory NOT deleted")
return return
} }
@ -166,6 +167,7 @@ type createNetworkOpts struct {
manualShutdown bool manualShutdown bool
numStorageAllocs int numStorageAllocs int
garageDefaultDBEngine garagesrv.DBEngine garageDefaultDBEngine garagesrv.DBEngine
blocker *toolkit.TestBlocker
} }
func (o *createNetworkOpts) withDefaults() *createNetworkOpts { func (o *createNetworkOpts) withDefaults() *createNetworkOpts {
@ -219,6 +221,7 @@ func (h *integrationHarness) createNetwork(
networkOpts = &Opts{ networkOpts = &Opts{
GarageAdminToken: "admin_token", GarageAdminToken: "admin_token",
Config: &networkConfig, Config: &networkConfig,
testBlocker: opts.blocker,
garageDefaultDBEngine: opts.garageDefaultDBEngine, garageDefaultDBEngine: opts.garageDefaultDBEngine,
} }
) )
@ -269,6 +272,7 @@ type joinNetworkOpts struct {
canCreateHosts bool canCreateHosts bool
manualShutdown bool manualShutdown bool
blocker *toolkit.TestBlocker blocker *toolkit.TestBlocker
runtimeDir toolkit.Dir
} }
func (o *joinNetworkOpts) withDefaults() *joinNetworkOpts { func (o *joinNetworkOpts) withDefaults() *joinNetworkOpts {
@ -308,7 +312,7 @@ func (h *integrationHarness) joinNetwork(
logger = h.logger.WithNamespace("networks").WithNamespace(hostNameStr) logger = h.logger.WithNamespace("networks").WithNamespace(hostNameStr)
networkConfig = h.mkNetworkConfig(t, opts.networkConfigOpts) networkConfig = h.mkNetworkConfig(t, opts.networkConfigOpts)
stateDir = h.mkDir(t, "state") stateDir = h.mkDir(t, "state")
runtimeDir = h.mkDir(t, "runtime") runtimeDir = opts.runtimeDir
networkOpts = &Opts{ networkOpts = &Opts{
GarageAdminToken: "admin_token", GarageAdminToken: "admin_token",
Config: &networkConfig, Config: &networkConfig,
@ -316,6 +320,10 @@ func (h *integrationHarness) joinNetwork(
} }
) )
if runtimeDir == (toolkit.Dir{}) {
runtimeDir = h.mkDir(t, "runtime")
}
logger.Info(h.ctx, "Joining") logger.Info(h.ctx, "Joining")
joinedNetwork, err := h.constructors.join( joinedNetwork, err := h.constructors.join(
h.ctx, h.ctx,
@ -456,3 +464,32 @@ func assertGarageLayout(
lastLayout = layout lastLayout = layout
} }
} }
func assertFirewallInboundEquals(
t *testing.T,
runtimeDir toolkit.Dir,
wantRules []daecommon.ConfigFirewallRule,
) {
var (
config struct {
Firewall daecommon.ConfigFirewall `yaml:"firewall"`
}
path = filepath.Join(runtimeDir.Path, "nebula.yml")
)
normalizeRules := func(rules []daecommon.ConfigFirewallRule) {
slices.SortFunc(rules, func(a, b daecommon.ConfigFirewallRule) int {
return cmp.Or(
cmp.Compare(a.Port, b.Port),
cmp.Compare(a.Code, b.Code),
cmp.Compare(a.Proto, b.Proto),
cmp.Compare(a.Host, b.Host),
)
})
}
require.NoError(t, yamlutil.LoadYamlFile(&config, path))
normalizeRules(wantRules)
normalizeRules(config.Firewall.Inbound)
assert.Equal(t, wantRules, config.Firewall.Inbound)
}

View File

@ -34,13 +34,13 @@ func (eb ExpectedBlockpoint) Unblock() {
close(eb.unblockCh) close(eb.unblockCh)
} }
// On is a helper which will spawn a go-routine, call Wait on the // Then is a helper which will spawn a go-routine, call Wait on the
// ExpectedBlockpoint, call the given callback, and then Unblock the // ExpectedBlockpoint, call the given callback, and then Unblock the
// ExpectedBlockpoint. // ExpectedBlockpoint.
// //
// If Wait returns an error (due to context cancellation) then this fails the // If Wait returns an error (due to context cancellation) then this fails the
// test and returns without calling the callback. // test and returns without calling the callback.
func (eb ExpectedBlockpoint) On(t *testing.T, ctx context.Context, cb func()) { func (eb ExpectedBlockpoint) Then(t *testing.T, ctx context.Context, cb func()) {
go func() { go func() {
defer eb.Unblock() defer eb.Unblock()
if !assert.NoError(t, eb.Wait(ctx)) { if !assert.NoError(t, eb.Wait(ctx)) {
@ -69,9 +69,9 @@ func NewTestBlocker(t *testing.T) *TestBlocker {
return b return b
} }
// Blockpoint will block if and only if TestBlocker is non-nil and // Blockpoint will block if and only if TestBlocker is non-nil and Expect has
// ExpectBlockpoint has been called with the same ID previously. If the context // been called with the same ID previously. If the context is canceled while
// is canceled while blocking then this call will return. // blocking then this call will return.
func (b *TestBlocker) Blockpoint(ctx context.Context, id string) { func (b *TestBlocker) Blockpoint(ctx context.Context, id string) {
if b == nil { if b == nil {
return return
@ -99,10 +99,10 @@ func (b *TestBlocker) Blockpoint(ctx context.Context, id string) {
} }
} }
// ExpectBlockpoint will cause the TestBlocker to block upon the next call to // Expect will cause the TestBlocker to block upon the next call to Blockpoint
// Blockpoint using the same id. The returned ExpectBlockpoint can be used to // using the same id. The returned ExpectedBlockpoint can be used to wait until
// wait until Blockpoint is called, as well as to unblock it. // Blockpoint is called, as well as to unblock it.
func (b *TestBlocker) ExpectBlockpoint(id string) ExpectedBlockpoint { func (b *TestBlocker) Expect(id string) ExpectedBlockpoint {
b.l.Lock() b.l.Lock()
defer b.l.Unlock() defer b.l.Unlock()
@ -124,8 +124,8 @@ func (b *TestBlocker) ExpectBlockpoint(id string) ExpectedBlockpoint {
return blockpoint return blockpoint
} }
// AssertExpectations will Fail the test and return false if any calls to // AssertExpectations will Fail the test and return false if any calls to Expect
// ExpectBlockpoint have not had a corresponding Blockpoint call made. // have not had a corresponding Blockpoint call made.
func (b *TestBlocker) AssertExpectations(t *testing.T) bool { func (b *TestBlocker) AssertExpectations(t *testing.T) bool {
b.l.Lock() b.l.Lock()
defer b.l.Unlock() defer b.l.Unlock()

View File

@ -0,0 +1,27 @@
---
type: task
---
The host's firewall should be auto-configured, by default, to allow all incoming
traffic for a network's CIDR. For Linux this will (probably) mean making
(system?) calls to iptables.
- There must be a mechanism for the user to disable this behavior if they want,
likely just as part of the `daemon.yml` and not CLI for MVP.
- The operator "Configuring Firewall" documentation must be updated.
- A network's Shutdown method should clear out all enabled rules.
- On startup the network needs to properly handle the rules already being
present, either because the user added them manually previously or there was a
previous unclean shutdown.
- Check if systemd service file needs any updates with respect to capabilities
or `After` directives.
- Keep in mind that IPv6 overlay networks will need to be supported in the
future, so ip6tables needs to be investigated.
- Make sure that using alternative firewalls, like ufw, works as expected. Also,
how does nftables fit in here?

View File

@ -0,0 +1,30 @@
---
type: task
after:
- /nats/rpc.md
---
A mechanism should be developed, using NATS, for a host A to double check that
host B can connect to it on a certain `host:port`. This can be used for a couple
use-cases:
- Make sure that VPN public address is connectable prior to being configured.
- Make sure that garage RPC port is connectable prior to being configured.
- Make sure that HTTP(s) ports are connectable prior to being configured
(future).
The host which is configuring a port to be opened will always be the one to
initiate the process. It will:
- Open a dummy HTTP server on the port in question, with a handler which always
returns a randomly generated token.
- Publish an RPC request on NATS which indicates which `host:port` it wishes to
confirm connectability and the token.
- Waits for a response to the NATS request indicating either that the connection
was successful, or an error indicating why it wasn't. Errors could include:
- Timeout (probably blocked by firewall)
- Challenge token not returned (something else is listening at that port)

View File

@ -1,5 +1,7 @@
--- ---
type: task type: task
after:
- /misc/host-firewall-autoconfig.md
--- ---
Introduce [NATS][nats] as a new service run by Isle. All hosts should join the Introduce [NATS][nats] as a new service run by Isle. All hosts should join the

View File

@ -5,9 +5,10 @@ after:
--- ---
A general RPC mechanism should be developed which allows one group of hosts to A general RPC mechanism should be developed which allows one group of hosts to
handle RPC calls made by other hosts. Each RPC request should be signed by the handle RPC calls made by other hosts.
host which is making it, and the response should be signed and encrypted by the
responder.
The JSONRPC2 framework already developed for communication between CLI and - Each RPC request should be signed by the host which is making it, and the
daemon can be re-used here. response should be signed and encrypted by the responder.
- The JSONRPC2 framework already developed for communication between CLI and
daemon can be re-used here.