egressaddr: the egress-allow address rules and the fence table (#328)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+366
−0
internal/egressaddr/egressaddr.go+178 −0internal/egressaddr/egressaddr_test.go+129 −0internal/host/units_hardening_test.go+59 −0
diff --git a/internal/egressaddr/egressaddr.go b/internal/egressaddr/egressaddr.go| new file mode 100644 |
| index 000000000..c209d4a5b |
| --- /dev/null |
| +++ b/internal/egressaddr/egressaddr.go |
| @@ -0,0 +1,178 @@ |
| +// Package egressaddr holds the rules for an operator-granted egress allow |
| +// (#328, spec 2026-10-21-instance-sizes-and-egress-design §4.1): the one |
| +// rule set the console door applies when an operator grants an address and |
| +// root applies again on the box before it writes the allow. |
| +// |
| +// An allow re-opens one address the instance fence closes. So an allow must |
| +// be a single address, inside the fence (a public address is already |
| +// reachable), and never one of the classes no tenant may reach from any |
| +// box: loopback, unspecified, link-local, the metadata service, multicast, |
| +// the reserved ranges and the NAT64 prefix. |
| +// |
| +// FenceDeny is the fence itself, the IPAddressDeny= line the instance unit |
| +// templates write. internal/host's TestInstanceFenceIsEgressaddrFenceDeny |
| +// pins the two to one list, so a range added to the fence is at once a |
| +// range an allow may fall inside. |
| +// |
| +// The package is a leaf: pure Go, no imports from the platform. It does not |
| +// call internal/instancectl's ResolverAllowed (root's package); the |
| +// refusals it shares with it (loopback, unspecified, the two metadata |
| +// addresses, link-local) are mirrored here, not called. |
| +package egressaddr |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| + "net/netip" |
| + "slices" |
| + "strings" |
| +) |
| + |
| +// MaxAllow is the most addresses one app may be granted. |
| +const MaxAllow = 8 |
| + |
| +// Refusal text. A person reads these at the door and in root's report. |
| +const ( |
| + msgEmpty = "an address is empty" |
| + msgRange = "%q is a range. Allow single addresses only" |
| + msgPort = "%q has a port. An allow opens an address, never a port" |
| + msgZone = "%q has a zone. Name the address without it" |
| + msgNotAddr = "%q is not an IP address" |
| + msgLoopback = "%s is a loopback address" |
| + msgUnspecified = "%s is the unspecified address" |
| + msgMetadata = "%s is the cloud metadata service" |
| + msgLinkLocal = "%s is link-local" |
| + msgMulticast = "%s is a multicast address" |
| + msgReserved = "%s is in %s, a reserved range" |
| + msgNAT64 = "%s is in the NAT64 prefix 64:ff9b::/96, which reaches any IPv4 address" |
| + msgPublic = "%s is a public address and is already reachable. No allow is needed" |
| + msgTooMany = "at most %d addresses per app (%d given)" |
| +) |
| + |
| +// FenceDeny is the instance fence: the IPAddressDeny= line of the instance |
| +// unit templates, in its order, with systemd's "localhost" and "multicast" |
| +// expanded the way systemd expands them. |
| +var FenceDeny = mustPrefixes( |
| + "127.0.0.0/8", "::1/128", // localhost |
| + "224.0.0.0/4", "ff00::/8", // multicast |
| + "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "169.254.0.0/16", "169.254.169.254/32", |
| + "172.16.0.0/12", "192.0.0.0/24", "192.168.0.0/16", "198.18.0.0/15", "240.0.0.0/4", |
| + "64:ff9b::/96", "fc00::/7", "fd00:ec2::254/128", "fe80::/10", "fec0::/10", |
| +) |
| + |
| +var ( |
| + // The cloud metadata service's two addresses (as ResolverAllowed). |
| + metadataV4 = netip.MustParseAddr("169.254.169.254") |
| + metadataV6 = netip.MustParseAddr("fd00:ec2::254") |
| + |
| + // reserved is the part of the fence no allow may open: ranges that are |
| + // not a private network a tenant's service could sit on. |
| + reserved = mustPrefixes("0.0.0.0/8", "192.0.0.0/24", "198.18.0.0/15", "240.0.0.0/4", "fec0::/10") |
| + |
| + // nat64 maps to any IPv4 address, the fence's private ranges included. |
| + nat64 = netip.MustParsePrefix("64:ff9b::/96") |
| +) |
| + |
| +func mustPrefixes(ss ...string) []netip.Prefix { |
| + out := make([]netip.Prefix, len(ss)) |
| + for i, s := range ss { |
| + out[i] = netip.MustParsePrefix(s) |
| + } |
| + return out |
| +} |
| + |
| +// Parse reads one address as an operator writes it: surrounding space |
| +// trimmed, a single address with no range, port or zone, and an |
| +// IPv4-mapped IPv6 address unmapped to IPv4. |
| +func Parse(s string) (netip.Addr, error) { |
| + s = strings.TrimSpace(s) |
| + if s == "" { |
| + return netip.Addr{}, errors.New(msgEmpty) |
| + } |
| + if strings.Contains(s, "/") { |
| + return netip.Addr{}, fmt.Errorf(msgRange, s) |
| + } |
| + if _, err := netip.ParseAddrPort(s); err == nil { |
| + return netip.Addr{}, fmt.Errorf(msgPort, s) |
| + } |
| + a, err := netip.ParseAddr(s) |
| + if err != nil { |
| + return netip.Addr{}, fmt.Errorf(msgNotAddr, s) |
| + } |
| + if a.Zone() != "" { |
| + return netip.Addr{}, fmt.Errorf(msgZone, s) |
| + } |
| + return a.Unmap(), nil |
| +} |
| + |
| +// Allowed reports why a may not be granted, or nil if it may: it is inside |
| +// FenceDeny and in none of the classes refused anywhere. |
| +func Allowed(a netip.Addr) error { |
| + a = a.Unmap() |
| + switch { |
| + case !a.IsValid(): |
| + return fmt.Errorf(msgNotAddr, a.String()) |
| + case a.IsLoopback(): |
| + return fmt.Errorf(msgLoopback, a) |
| + case a.IsUnspecified(): |
| + return fmt.Errorf(msgUnspecified, a) |
| + case a == metadataV4, a == metadataV6: |
| + return fmt.Errorf(msgMetadata, a) |
| + case a.IsLinkLocalUnicast(): |
| + return fmt.Errorf(msgLinkLocal, a) |
| + case a.IsMulticast(): |
| + return fmt.Errorf(msgMulticast, a) |
| + } |
| + for _, p := range reserved { |
| + if p.Contains(a) { |
| + return fmt.Errorf(msgReserved, a, p) |
| + } |
| + } |
| + if nat64.Contains(a) { |
| + return fmt.Errorf(msgNAT64, a) |
| + } |
| + for _, p := range FenceDeny { |
| + if p.Contains(a) { |
| + return nil |
| + } |
| + } |
| + return fmt.Errorf(msgPublic, a) |
| +} |
| + |
| +// Normalize turns an operator's list into the stored form: every |
| +// non-empty entry parsed and allowed, duplicates collapsed, sorted (IPv4 |
| +// before IPv6), at most MaxAllow after the collapse, each in its |
| +// canonical text. An empty result is nil. The first refused entry is the |
| +// error. |
| +func Normalize(in []string) ([]string, error) { |
| + seen := map[netip.Addr]bool{} |
| + var addrs []netip.Addr |
| + for _, s := range in { |
| + if strings.TrimSpace(s) == "" { |
| + continue |
| + } |
| + a, err := Parse(s) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if err := Allowed(a); err != nil { |
| + return nil, err |
| + } |
| + if !seen[a] { |
| + seen[a] = true |
| + addrs = append(addrs, a) |
| + } |
| + } |
| + if len(addrs) > MaxAllow { |
| + return nil, fmt.Errorf(msgTooMany, MaxAllow, len(addrs)) |
| + } |
| + if len(addrs) == 0 { |
| + return nil, nil |
| + } |
| + slices.SortFunc(addrs, netip.Addr.Compare) |
| + out := make([]string, len(addrs)) |
| + for i, a := range addrs { |
| + out[i] = a.String() |
| + } |
| + return out, nil |
| +} |
diff --git a/internal/egressaddr/egressaddr_test.go b/internal/egressaddr/egressaddr_test.go| new file mode 100644 |
| index 000000000..b3ec2f03e |
| --- /dev/null |
| +++ b/internal/egressaddr/egressaddr_test.go |
| @@ -0,0 +1,129 @@ |
| +package egressaddr |
| + |
| +import ( |
| + "fmt" |
| + "net/netip" |
| + "reflect" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// allowed is every class of address an operator may open: RFC 1918, |
| +// CGNAT, ULA, and a ULA neighbour of the metadata address. |
| +var allowed = []string{"10.0.5.20", "100.64.0.9", "172.16.3.4", "192.168.1.10", "fd00:5::20", "fc00::1", "fd00:ec2::253"} |
| + |
| +func TestParseRefusesNonAddresses(t *testing.T) { |
| + for in, want := range map[string]string{ |
| + "": "an address is empty", |
| + " ": "an address is empty", |
| + "10.0.5.0/24": "is a range", |
| + "10.0.5.20:5432": "has a port", |
| + "[fd00:5::20]:80": "has a port", |
| + "fe80::1%eth0": "has a zone", |
| + "db.example.com": "is not an IP address", |
| + "010.0.5.20": "is not an IP address", |
| + } { |
| + a, err := Parse(in) |
| + if err == nil { |
| + t.Errorf("Parse(%q) = %s, want an error containing %q", in, a, want) |
| + continue |
| + } |
| + if !strings.Contains(err.Error(), want) { |
| + t.Errorf("Parse(%q) error %q, want it to contain %q", in, err, want) |
| + } |
| + } |
| +} |
| + |
| +func TestParseUnmapsAndCanonicalizes(t *testing.T) { |
| + for in, want := range map[string]string{ |
| + "::ffff:10.0.5.20": "10.0.5.20", |
| + " 10.0.5.20 ": "10.0.5.20", |
| + "FD00:5::20": "fd00:5::20", |
| + } { |
| + a, err := Parse(in) |
| + if err != nil { |
| + t.Errorf("Parse(%q): %v", in, err) |
| + continue |
| + } |
| + if a.String() != want { |
| + t.Errorf("Parse(%q) = %s, want %s", in, a, want) |
| + } |
| + } |
| + if a, _ := Parse("::ffff:10.0.5.20"); !a.Is4() { |
| + t.Errorf("Parse(::ffff:10.0.5.20) = %s, not unmapped to IPv4", a) |
| + } |
| +} |
| + |
| +func TestAllowedRefusesEveryClass(t *testing.T) { |
| + refused := map[string]string{ |
| + "127.0.0.1": "loopback", "::1": "loopback", "0.0.0.0": "unspecified", "::": "unspecified", |
| + "169.254.169.254": "metadata", "fd00:ec2::254": "metadata", "169.254.1.1": "link-local", |
| + "fe80::1": "link-local", "224.0.0.1": "multicast", "ff02::1": "multicast", |
| + "0.1.2.3": "reserved", "192.0.0.8": "reserved", "198.18.0.1": "reserved", "240.0.0.1": "reserved", |
| + "255.255.255.255": "reserved", "fec0::1": "reserved", "64:ff9b::a00:514": "NAT64", |
| + "8.8.8.8": "already reachable", "2001:db8::1": "already reachable", |
| + } |
| + for in, want := range refused { |
| + err := Allowed(netip.MustParseAddr(in)) |
| + if err == nil { |
| + t.Errorf("Allowed(%s) = nil, want an error containing %q", in, want) |
| + continue |
| + } |
| + if !strings.Contains(err.Error(), want) { |
| + t.Errorf("Allowed(%s) error %q, want it to contain %q", in, err, want) |
| + } |
| + } |
| + for _, in := range allowed { |
| + if err := Allowed(netip.MustParseAddr(in)); err != nil { |
| + t.Errorf("Allowed(%s) = %v, want nil", in, err) |
| + } |
| + } |
| +} |
| + |
| +// Allowed's last rule, read the other way round: nothing it lets through |
| +// lies outside the fence the templates write. |
| +func TestEveryAllowedAddressIsInsideTheFence(t *testing.T) { |
| + for _, in := range allowed { |
| + a := netip.MustParseAddr(in) |
| + inside := false |
| + for _, p := range FenceDeny { |
| + if p.Contains(a) { |
| + inside = true |
| + break |
| + } |
| + } |
| + if !inside { |
| + t.Errorf("%s is allowed but no FenceDeny prefix contains it", in) |
| + } |
| + } |
| +} |
| + |
| +func TestNormalize(t *testing.T) { |
| + got, err := Normalize([]string{"fd00:5::20", "10.0.5.20", "10.0.5.20", ""}) |
| + if err != nil { |
| + t.Fatalf("Normalize: %v", err) |
| + } |
| + if want := []string{"10.0.5.20", "fd00:5::20"}; !reflect.DeepEqual(got, want) { |
| + t.Errorf("Normalize = %q, want %q", got, want) |
| + } |
| + for _, in := range [][]string{nil, {""}} { |
| + got, err := Normalize(in) |
| + if got != nil || err != nil { |
| + t.Errorf("Normalize(%q) = %q, %v; want nil, nil", in, got, err) |
| + } |
| + } |
| + var nine []string |
| + for i := 1; i <= 9; i++ { |
| + nine = append(nine, fmt.Sprintf("10.0.5.%d", i)) |
| + } |
| + if _, err := Normalize(nine); err == nil || !strings.Contains(err.Error(), "at most 8 addresses per app (9 given)") { |
| + t.Errorf("Normalize(nine) error %v, want the cap", err) |
| + } |
| + // The cap counts after the dedupe: eight addresses given twice each fit. |
| + if got, err := Normalize(append(nine[:8:8], nine[:8]...)); err != nil || len(got) != MaxAllow { |
| + t.Errorf("Normalize(eight twice) = %q, %v; want eight and no error", got, err) |
| + } |
| + if _, err := Normalize([]string{"10.0.5.20", "8.8.8.8"}); err == nil || !strings.Contains(err.Error(), "8.8.8.8 is a public address") { |
| + t.Errorf("Normalize with a public address error %v, want it refused", err) |
| + } |
| +} |
diff --git a/internal/host/units_hardening_test.go b/internal/host/units_hardening_test.go| index 776b93b7b..943263954 100644 |
| --- a/internal/host/units_hardening_test.go |
| +++ b/internal/host/units_hardening_test.go |
| @@ -1,8 +1,11 @@ |
| package host |
| import ( |
| + "net/netip" |
| "strings" |
| "testing" |
| + |
| + "github.com/carlosframework/platform/internal/egressaddr" |
| ) |
| // directives returns every "Key=value" line of a unit, comments and |
| @@ -101,6 +104,62 @@ func TestInstanceDenyCoversTheDocEngineFence(t *testing.T) { |
| } |
| } |
| +// The fence the templates write and the table an allow must fall inside |
| +// (egressaddr.FenceDeny) are one list. |
| +func TestInstanceFenceIsEgressaddrFenceDeny(t *testing.T) { |
| + t.Parallel() |
| + want := map[netip.Prefix]bool{} |
| + for _, p := range egressaddr.FenceDeny { |
| + want[p] = true |
| + } |
| + if len(want) != len(egressaddr.FenceDeny) { |
| + t.Errorf("egressaddr.FenceDeny repeats a prefix: %v", egressaddr.FenceDeny) |
| + } |
| + // systemd's names, expanded the way it does. |
| + named := map[string][]string{"localhost": {"127.0.0.0/8", "::1/128"}, "multicast": {"224.0.0.0/4", "ff00::/8"}} |
| + units := map[string]string{"claimBoundTemplate": claimBoundTemplate} |
| + for _, name := range []string{"carlos-instance@.service", "carlos-instance-gen@.service", "carlos-instance-seed@.service", "carlos-instance-sidecar@.service", "carlos-instance-cmd@.service", "carlos-instance-probe.service"} { |
| + units[name] = readUnit(t, name) |
| + } |
| + for name, u := range units { |
| + var lines []string |
| + for _, d := range directives(u) { |
| + if strings.HasPrefix(d, "IPAddressDeny=") { |
| + lines = append(lines, d) |
| + } |
| + } |
| + if len(lines) != 1 { |
| + t.Errorf("%s has %d IPAddressDeny= lines, want 1", name, len(lines)) |
| + continue |
| + } |
| + have := map[netip.Prefix]bool{} |
| + for _, f := range strings.Fields(strings.TrimPrefix(lines[0], "IPAddressDeny=")) { |
| + fs, ok := named[f] |
| + if !ok { |
| + fs = []string{f} |
| + } |
| + for _, s := range fs { |
| + p, err := netip.ParsePrefix(s) |
| + if err != nil { |
| + t.Errorf("%s: IPAddressDeny= entry %q: %v", name, s, err) |
| + continue |
| + } |
| + have[p] = true |
| + } |
| + } |
| + for p := range have { |
| + if !want[p] { |
| + t.Errorf("%s denies %s, which egressaddr.FenceDeny lacks", name, p) |
| + } |
| + } |
| + for p := range want { |
| + if !have[p] { |
| + t.Errorf("%s does not deny %s, which egressaddr.FenceDeny lists", name, p) |
| + } |
| + } |
| + } |
| +} |
| + |
| // Each template binds back exactly its own build (#214 item 2): the control |
| // service writes the two sources before every start (instancectl ensureExe). |
| // The cmd template binds sources of its own, cmd-app and cmd-live, which no |