diff --git a/README.md b/README.md index 13c0f5c..c93eefb 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ By default, the agent is configured to reload its configuration from the `/etc/c The agent configuration file should be written in yaml or json syntax, and may contain three optional keys: - `nonMasqueradeCIDRs []string`: A list strings in CIDR notation that specify the non-masquerade ranges. - `masqLinkLocal bool`: Whether to masquerade traffic to `169.254.0.0/16`. False by default. +- `masqLinkLocalIPv6 bool`: Whether to masquerade traffic to `fe80::/10`. False by default. - `resyncInterval string`: The interval at which the agent attempts to reload config from disk. The syntax is any format accepted by Go's [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration) function. The agent will look for a config file in its container at `/etc/config/ip-masq-agent`. This file can be provided via a `ConfigMap`, plumbed into the container via a `ConfigMapVolumeSource`. As a result, the agent can be reconfigured in a live cluster by creating or editing this `ConfigMap`. @@ -50,6 +51,8 @@ The agent accepts two flags, which may be specified in the yaml file. `nomasq-all-reserved-ranges` : Whether or not to masquerade all RFC reserved ranges when the configmap is empty. The default is `false`. When `false`, the agent will masquerade to every destination except the ranges reserved by RFC 1918 (namely `10.0.0.0/8`, `172.16.0.0/12`, and `192.168.0.0/16`). When `true`, the agent will masquerade to every destination that is not marked reserved by an RFC. The full list of ranges is (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `100.64.0.0/10`, `192.0.0.0/24`, `192.0.2.0/24`, `192.88.99.0/24`, `198.18.0.0/15`, `198.51.100.0/24`, `203.0.113.0/24`, and `240.0.0.0/4`). Note however, that this list of ranges is overridden by specifying the nonMasqueradeCIDRs key in the agent configmap. +`enable-ipv6` +: Whether to configurate ip6tables rules. By default `enable-ipv6` is false. ## Rationale (from the [incubator proposal](https://gist.github.com/mtaufen/253309166e7d5aa9e9b560600a438447)) diff --git a/agent-config/config b/agent-config/config index 2b0e828..c34a990 100644 --- a/agent-config/config +++ b/agent-config/config @@ -3,4 +3,5 @@ nonMasqueradeCIDRs: - 172.16.0.0/12 - 192.168.0.0/16 masqLinkLocal: false +masqLinkLocalIPv6: false resyncInterval: 60s \ No newline at end of file diff --git a/cmd/ip-masq-agent/ip-masq-agent.go b/cmd/ip-masq-agent/ip-masq-agent.go index ed5dbfc..e4cd97d 100644 --- a/cmd/ip-masq-agent/ip-masq-agent.go +++ b/cmd/ip-masq-agent/ip-masq-agent.go @@ -39,6 +39,8 @@ import ( const ( linkLocalCIDR = "169.254.0.0/16" + // RFC 4291 + linkLocalCIDRIPv6 = "fe80::/10" // path to a yaml or json file configPath = "/etc/config/ip-masq-agent" ) @@ -48,18 +50,21 @@ var ( masqChain utiliptables.Chain masqChainFlag = flag.String("masq-chain", "IP-MASQ-AGENT", `Name of nat chain for iptables masquerade rules.`) noMasqueradeAllReservedRangesFlag = flag.Bool("nomasq-all-reserved-ranges", false, "Whether to disable masquerade for all IPv4 ranges reserved by RFCs.") + enableIPv6 = flag.Bool("enable-ipv6", false, "Whether to enable IPv6.") ) -// config object +// MasqConfig object type MasqConfig struct { NonMasqueradeCIDRs []string `json:"nonMasqueradeCIDRs"` MasqLinkLocal bool `json:"masqLinkLocal"` + MasqLinkLocalIPv6 bool `json:"masqLinkLocalIPv6"` ResyncInterval Duration `json:"resyncInterval"` } -// Go's JSON unmarshaler can't handle time.ParseDuration syntax when unmarshaling into time.Duration, so we do it here +// Duration - Go's JSON unmarshaler can't handle time.ParseDuration syntax when unmarshaling into time.Duration, so we do it here type Duration time.Duration +// UnmarshalJSON ... func (d *Duration) UnmarshalJSON(json []byte) error { if json[0] == '"' { s := string(json[1 : len(json)-1]) @@ -74,7 +79,7 @@ func (d *Duration) UnmarshalJSON(json []byte) error { return fmt.Errorf("expected string value for unmarshal to field of type Duration, got %q", s) } -// returns a MasqConfig with default values +// NewMasqConfig returns a MasqConfig with default values func NewMasqConfig(masqAllReservedRanges bool) *MasqConfig { // RFC 1918 defines the private ip address space as 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 nonMasq := []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"} @@ -94,25 +99,30 @@ func NewMasqConfig(masqAllReservedRanges bool) *MasqConfig { return &MasqConfig{ NonMasqueradeCIDRs: nonMasq, MasqLinkLocal: false, + MasqLinkLocalIPv6: false, ResyncInterval: Duration(60 * time.Second), } } -// daemon object +// MasqDaemon object type MasqDaemon struct { - config *MasqConfig - iptables utiliptables.Interface + config *MasqConfig + iptables utiliptables.Interface + ip6tables utiliptables.Interface } -// returns a MasqDaemon with default values, including an initialized utiliptables.Interface +// NewMasqDaemon returns a MasqDaemon with default values, including an initialized utiliptables.Interface func NewMasqDaemon(c *MasqConfig) *MasqDaemon { execer := utilexec.New() dbus := utildbus.New() - protocol := utiliptables.ProtocolIpv4 - iptables := utiliptables.New(execer, dbus, protocol) + protocolv4 := utiliptables.ProtocolIpv4 + protocolv6 := utiliptables.ProtocolIpv6 + iptables := utiliptables.New(execer, dbus, protocolv4) + ip6tables := utiliptables.New(execer, dbus, protocolv6) return &MasqDaemon{ - config: c, - iptables: iptables, + config: c, + iptables: iptables, + ip6tables: ip6tables, } } @@ -131,6 +141,7 @@ func main() { m.Run() } +// Run ... func (m *MasqDaemon) Run() { // Periodically resync to reconfigure or heal from any rule decay for { @@ -146,6 +157,11 @@ func (m *MasqDaemon) Run() { glog.Errorf("error syncing masquerade rules: %v", err) return } + // resync ipv6 rules + if err := m.syncMasqRulesIPv6(); err != nil { + glog.Errorf("error syncing masquerade rules for ipv6: %v", err) + return + } }() } } @@ -174,6 +190,7 @@ func (m *MasqDaemon) syncConfig(fs fakefs.FileSystem) error { // file does not exist, use defaults m.config.NonMasqueradeCIDRs = c.NonMasqueradeCIDRs m.config.MasqLinkLocal = c.MasqLinkLocal + m.config.MasqLinkLocalIPv6 = c.MasqLinkLocalIPv6 m.config.ResyncInterval = c.ResyncInterval glog.V(2).Infof("no config file found at %q, using default values", configPath) return nil @@ -210,13 +227,17 @@ func (c *MasqConfig) validate() error { // limit to 64 CIDRs (excluding link-local) to protect against really bad mistakes n := len(c.NonMasqueradeCIDRs) if n > 64 { - return fmt.Errorf("The daemon can only accept up to 64 CIDRs (excluding link-local), but got %d CIDRs (excluding link local).", n) + return fmt.Errorf("the daemon can only accept up to 64 CIDRs (excluding link-local), but got %d CIDRs (excluding link local)", n) } // check CIDRs are valid for _, cidr := range c.NonMasqueradeCIDRs { if err := validateCIDR(cidr); err != nil { return err } + // can't configure ipv6 cidr if ipv6 is not enabled + if !*enableIPv6 && isIPv6CIDR(cidr) { + return fmt.Errorf("ipv6 is not enabled, but ipv6 cidr %s provided. Enable ipv6 using --enable-ipv6 agent flag", cidr) + } } return nil } @@ -258,19 +279,63 @@ func (m *MasqDaemon) syncMasqRules() error { // non-masquerade for user-provided CIDRs for _, cidr := range m.config.NonMasqueradeCIDRs { - writeNonMasqRule(lines, cidr) + if !isIPv6CIDR(cidr) { + writeNonMasqRule(lines, cidr) + } } // masquerade all other traffic that is not bound for a --dst-type LOCAL destination writeMasqRule(lines) writeLine(lines, "COMMIT") + if err := m.iptables.RestoreAll(lines.Bytes(), utiliptables.NoFlushTables, utiliptables.NoRestoreCounters); err != nil { return err } return nil } +func (m *MasqDaemon) syncMasqRulesIPv6() error { + isIPv6Enabled := *enableIPv6 + + if isIPv6Enabled { + // make sure our custom chain for ipv6 non-masquerade exists + _, err := m.ip6tables.EnsureChain(utiliptables.TableNAT, masqChain) + if err != nil { + return err + } + // ensure that any non-local in POSTROUTING jumps to masqChain + if err := m.ensurePostroutingJumpIPv6(); err != nil { + return err + } + // build up lines to pass to ip6tables-restore + lines6 := bytes.NewBuffer(nil) + writeLine(lines6, "*nat") + writeLine(lines6, utiliptables.MakeChainLine(masqChain)) // effectively flushes masqChain atomically with rule restore + + // link-local IPv6 CIDR is non-masquerade by default + if !m.config.MasqLinkLocalIPv6 { + writeNonMasqRule(lines6, linkLocalCIDRIPv6) + } + + for _, cidr := range m.config.NonMasqueradeCIDRs { + if isIPv6CIDR(cidr) { + writeNonMasqRule(lines6, cidr) + } + } + + // masquerade all other traffic that is not bound for a --dst-type LOCAL destination + writeMasqRule(lines6) + + writeLine(lines6, "COMMIT") + + if err := m.ip6tables.RestoreAll(lines6.Bytes(), utiliptables.NoFlushTables, utiliptables.NoRestoreCounters); err != nil { + return err + } + } + return nil +} + // NOTE(mtaufen): iptables requires names to be <= 28 characters, and somehow prepending "-m comment --comment " to this string makes it think this condition is violated // Feel free to dig around in iptables and see if you can figure out exactly why; I haven't had time to fully trace how it parses and handle subcommands. // If you want to investigate, get the source via `git clone git://git.netfilter.org/iptables.git`, `git checkout v1.4.21` (the version I've seen this issue on, @@ -288,6 +353,15 @@ func (m *MasqDaemon) ensurePostroutingJump() error { return nil } +func (m *MasqDaemon) ensurePostroutingJumpIPv6() error { + if _, err := m.ip6tables.EnsureRule(utiliptables.Append, utiliptables.TableNAT, utiliptables.ChainPostrouting, + "-m", "comment", "--comment", postroutingJumpComment(), + "-m", "addrtype", "!", "--dst-type", "LOCAL", "-j", string(masqChain)); err != nil { + return fmt.Errorf("failed to ensure that %s chain %s jumps to MASQUERADE: %v for ipv6", utiliptables.TableNAT, masqChain, err) + } + return nil +} + const nonMasqRuleComment = `-m comment --comment "ip-masq-agent: local traffic is not subject to MASQUERADE"` func writeNonMasqRule(lines *bytes.Buffer, cidr string) { @@ -311,3 +385,18 @@ func writeRule(lines *bytes.Buffer, position utiliptables.RulePosition, chain ut func writeLine(lines *bytes.Buffer, words ...string) { lines.WriteString(strings.Join(words, " ") + "\n") } + +// isIPv6CIDR checks if the provided cidr block belongs to ipv6 family. +// If cidr belongs to ipv6 family, return true else it returns false +// which means the cidr belongs to ipv4 family +func isIPv6CIDR(cidr string) bool { + ip, _, _ := net.ParseCIDR(cidr) + return isIPv6(ip.String()) +} + +// isIPv6 checks if the provided ip belongs to ipv6 family. +// If ip belongs to ipv6 family, return true else it returns false +// which means the ip belongs to ipv4 family +func isIPv6(ip string) bool { + return net.ParseIP(ip).To4() == nil +} diff --git a/cmd/ip-masq-agent/ip-masq-agent_test.go b/cmd/ip-masq-agent/ip-masq-agent_test.go index 6c1a83b..4440a2e 100644 --- a/cmd/ip-masq-agent/ip-masq-agent_test.go +++ b/cmd/ip-masq-agent/ip-masq-agent_test.go @@ -40,8 +40,9 @@ func TestMain(m *testing.M) { // returns a MasqDaemon with empty config values and a fake iptables interface func NewFakeMasqDaemon() *MasqDaemon { return &MasqDaemon{ - config: &MasqConfig{}, - iptables: iptest.NewFake(), + config: &MasqConfig{}, + iptables: iptest.NewFake(), + ip6tables: iptest.NewFake(), } } @@ -197,11 +198,25 @@ resyncInterval: 5m // file does not exist {"no config file", fakefs.NotExistFS{}, nil, NewMasqConfigNoReservedRanges()}, // If the file does not exist, defaults should be used + + // valid json with ipv6 non masquerade cidr + {"valid json file, all keys with ipv6 cidr", fakefs.StringFS{File: ` + { + "nonMasqueradeCIDRs": ["172.16.0.0/12", "10.0.0.0/8", "fc00::/7"], + "masqLinkLocal": true, + "resyncInterval": "5s" + } + `}, + nil, &MasqConfig{ + NonMasqueradeCIDRs: []string{"172.16.0.0/12", "10.0.0.0/8", "fc00::/7"}, + MasqLinkLocal: true, + ResyncInterval: Duration(5 * time.Second)}}, } // tests MasqDaemon.syncConfig func TestSyncConfig(t *testing.T) { for _, tt := range syncConfigTests { + flag.Set("enable-ipv6", "true") m := NewFakeMasqDaemon() m.config = NewMasqConfigNoReservedRanges() err := m.syncConfig(tt.fs) @@ -263,6 +278,22 @@ COMMIT -A ` + string(masqChain) + ` ` + nonMasqRuleComment + ` -d 240.0.0.0/4 -j RETURN -A ` + string(masqChain) + ` ` + masqRuleComment + ` -j MASQUERADE COMMIT +`, + }, + { + desc: "config has ipv4 and ipv6 non masquerade cidr", + cfg: &MasqConfig{ + NonMasqueradeCIDRs: []string{ + "10.244.0.0/16", + "fc00::/7", + }, + }, + want: `*nat +:` + string(masqChain) + ` - [0:0] +-A ` + string(masqChain) + ` ` + nonMasqRuleComment + ` -d 169.254.0.0/16 -j RETURN +-A ` + string(masqChain) + ` ` + nonMasqRuleComment + ` -d 10.244.0.0/16 -j RETURN +-A ` + string(masqChain) + ` ` + masqRuleComment + ` -j MASQUERADE +COMMIT `, }, } @@ -283,6 +314,68 @@ COMMIT } } +// tests MasqDaemon.syncMasqRulesIPv6 +func TestSyncMasqRulesIPv6(t *testing.T) { + var syncMasqRulesIPv6Tests = []struct { + desc string // human readable description of the test + cfg *MasqConfig // Masq configuration to use + err error // expected error, if any. If nil, no error expected + want string // String expected to be sent to iptables-restore + }{ + { + desc: "empty config", + cfg: &MasqConfig{}, + want: `*nat +:` + string(masqChain) + ` - [0:0] +-A ` + string(masqChain) + ` ` + nonMasqRuleComment + ` -d fe80::/10 -j RETURN +-A ` + string(masqChain) + ` ` + masqRuleComment + ` -j MASQUERADE +COMMIT +`, + }, + { + desc: "config has ipv4 and ipv6 non masquerade cidr", + cfg: &MasqConfig{ + NonMasqueradeCIDRs: []string{ + "10.244.0.0/16", + "fc00::/7", + }, + }, + want: `*nat +:` + string(masqChain) + ` - [0:0] +-A ` + string(masqChain) + ` ` + nonMasqRuleComment + ` -d fe80::/10 -j RETURN +-A ` + string(masqChain) + ` ` + nonMasqRuleComment + ` -d fc00::/7 -j RETURN +-A ` + string(masqChain) + ` ` + masqRuleComment + ` -j MASQUERADE +COMMIT +`, + }, + { + desc: "config has masqLinkLocalIPv6: true", + cfg: &MasqConfig{MasqLinkLocalIPv6: true}, + want: `*nat +:` + string(masqChain) + ` - [0:0] +-A ` + string(masqChain) + ` ` + masqRuleComment + ` -j MASQUERADE +COMMIT +`, + }, + } + + for _, tt := range syncMasqRulesIPv6Tests { + t.Run(tt.desc, func(t *testing.T) { + flag.Set("enable-ipv6", "true") + m := NewFakeMasqDaemon() + m.config = tt.cfg + m.syncMasqRulesIPv6() + fipt6, ok := m.ip6tables.(*iptest.FakeIPTables) + if !ok { + t.Errorf("MasqDaemon wasn't using the expected iptables mock") + } + if string(fipt6.Lines) != tt.want { + t.Errorf("syncMasqRulesIPv6 wrote %q, want %q", string(fipt6.Lines), tt.want) + } + }) + } +} + // TODO(mtaufen): switch to an iptables mock that allows us to check the results of EnsureRule // tests m.ensurePostroutingJump func TestEnsurePostroutingJump(t *testing.T) { @@ -294,19 +387,40 @@ func TestEnsurePostroutingJump(t *testing.T) { // tests writeNonMasqRule func TestWriteNonMasqRule(t *testing.T) { - lines := bytes.NewBuffer(nil) - cidr := "10.0.0.0/8" - want := string(utiliptables.Append) + " " + string(masqChain) + - ` -m comment --comment "ip-masq-agent: local traffic is not subject to MASQUERADE"` + - " -d " + cidr + " -j RETURN\n" - writeNonMasqRule(lines, cidr) - - s, err := lines.ReadString('\n') - if err != nil { - t.Error("writeRule did not append a newline") + var writeNonMasqRuleTests = []struct { + desc string + cidr string + want string + }{ + { + desc: "with ipv4 non masquerade cidr", + cidr: "10.0.0.0/8", + want: string(utiliptables.Append) + " " + string(masqChain) + + ` -m comment --comment "ip-masq-agent: local traffic is not subject to MASQUERADE"` + + " -d 10.0.0.0/8 -j RETURN\n", + }, + { + desc: "with ipv6 non masquerade cidr", + cidr: "fc00::/7", + want: string(utiliptables.Append) + " " + string(masqChain) + + ` -m comment --comment "ip-masq-agent: local traffic is not subject to MASQUERADE"` + + " -d fc00::/7 -j RETURN\n", + }, } - if s != want { - t.Errorf("writeNonMasqRule(lines, "+cidr+"):\n got: %q\n want: %q", s, want) + + for _, tt := range writeNonMasqRuleTests { + t.Run(tt.desc, func(t *testing.T) { + lines := bytes.NewBuffer(nil) + writeNonMasqRule(lines, tt.cidr) + + s, err := lines.ReadString('\n') + if err != nil { + t.Error("writeRule did not append a newline") + } + if s != tt.want { + t.Errorf("writeNonMasqRule(lines, "+tt.cidr+"):\n got: %q\n want: %q", s, tt.want) + } + }) } }