carlos / platform Public

egressaddr: the egress-allow address rules and the fence table (#328)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 3ff2a7880a06b6718bd5b5af8ad529425fa5aaf9 parent 87a580b
3 files changed, +366 −0
  • internal/egressaddr/egressaddr.go +178 −0
  • internal/egressaddr/egressaddr_test.go +129 −0
  • internal/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