From 9f8d34f614f57e0752246d5635183aac10572ff6 Mon Sep 17 00:00:00 2001 From: Jan Vitturi Date: Sun, 14 May 2023 15:39:19 +0200 Subject: [PATCH] Initial commit --- .gitignore | 1 + Makefile | 15 ++++++ README.md | 53 +++++++++++++++++++++ arp/arp.go | 114 +++++++++++++++++++++++++++++++++++++++++++++ arphttp/arphttp.go | 56 ++++++++++++++++++++++ arphttp/client.go | 51 ++++++++++++++++++++ arphttp/server.go | 79 +++++++++++++++++++++++++++++++ go.mod | 15 ++++++ go.sum | 25 ++++++++++ main.go | 74 +++++++++++++++++++++++++++++ net.go | 28 +++++++++++ 11 files changed, 511 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 arp/arp.go create mode 100644 arphttp/arphttp.go create mode 100644 arphttp/client.go create mode 100644 arphttp/server.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 net.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f062d6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/release diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5f415e1 --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +release: \ + linux/amd64 \ + linux/arm \ + linux/arm64 \ + +clean: + $(RM) -r release + +NAME := $(shell go list) +VERSION := $(shell git name-rev --tags --name-only HEAD) +DISTS := $(shell go tool dist list) +$(DISTS): GOOS = $(firstword $(subst /, ,$@)) +$(DISTS): GOARCH = $(lastword $(subst /, ,$@)) +$(DISTS): + GOOS=$(GOOS) GOARCH=$(GOARCH) CGO_ENABLED=0 go build -ldflags="-buildid= -s -w" -trimpath -o release/$(NAME)-$(VERSION)-$(GOOS)-$(GOARCH) diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ec7900 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# xarpd + +xarpd bridges ARP requests between separate networks. + +xarpd listens for incoming ARP requests, and when a request matches a configured subnetwork, it asks other peers running xarpd to resolve the request. When a peer can resolve the ARP request, xarpd responds to the initial ARP request with the hardware address of the host it is running on, so that traffic can be forwarded through it. + +## Motivation + +### Scenario + +- `network1` is on `10.0.0.0/24` and has the hosts: + - `server1` with IP address `10.0.0.1` + - `device1` with IP address `10.0.0.42` +- `network2` is on `10.0.0.0/24` and has the hosts: + - `server2` with IP address `10.0.0.129` + - `device2` with IP address `10.0.0.170` + +`network1` and `network2` have the same address but are different networks. + +`server1` has a route to the subnetwork `10.0.0.128/25` of `network2` via a VPN and can therefore reach `server2` and `device2`. + +It is assumed that `server1` and `server2` have IP forwarding enabled. + +### Goal + +`device1` needs to reach `device2` without the ability to join VPNs or configure custom routes. Therefore, the traffic would need to flow: +`device1` → `server1` → `server2` → `device2`. + +### Solution + +xarpd running on `server1` intercepts ARP requests from `device1` for `10.0.0.129` and replies with the hardware address of `server1`. Before replying, it asks `server2` to check whether `10.0.0.129` exists on `network2`. + +## Usage + +xarpd must be running on at least two networks to be useful. + +On `server1` with IP address `10.0.0.1/24` in `network1`: + +```console +$ xarpd 10.0.0.129/25 +Forwarding ARP requests for 10.0.0.128/25 to 10.0.0.129 +Listening for ARP on eth0 +Listening for HTTP on 10.0.0.1:2707 +``` + +On `server2` with IP address `10.0.0.129/24` in `network2`: + +```console +$ xarpd 10.0.0.1/25 +Forwarding ARP requests for 10.0.0.0/25 to 10.0.0.1 +Listening for ARP on eth0 +Listening for HTTP on 10.0.0.129:2707 +``` diff --git a/arp/arp.go b/arp/arp.go new file mode 100644 index 0000000..9ac7f6d --- /dev/null +++ b/arp/arp.go @@ -0,0 +1,114 @@ +package arp + +import ( + "net" + "net/netip" + "sync" + + "github.com/mdlayher/arp" +) + +type Client struct { + client *arp.Client + mutex sync.Mutex + subs map[netip.Addr][]chan net.HardwareAddr + handleRequest RequestHandler +} + +type RequestHandler func(ip netip.Addr) (shouldReply bool) + +func NewClient(iface *net.Interface, handleRequest RequestHandler) (*Client, error) { + client, err := arp.Dial(iface) + if err != nil { + return nil, err + } + return &Client{ + client: client, + subs: make(map[netip.Addr][]chan net.HardwareAddr), + handleRequest: handleRequest, + }, nil +} + +func (c *Client) HardwareAddr() net.HardwareAddr { + return c.client.HardwareAddr() +} + +func (c *Client) Subscribe(ip netip.Addr, ch chan net.HardwareAddr) { + c.mutex.Lock() + defer c.mutex.Unlock() + + for _, existingCh := range c.subs[ip] { + if ch == existingCh { + return + } + } + c.subs[ip] = append(c.subs[ip], ch) +} + +func (c *Client) Unsubscribe(ip netip.Addr, ch chan net.HardwareAddr) { + c.mutex.Lock() + defer c.mutex.Unlock() + + for i, replyCh := range c.subs[ip] { + if replyCh == ch { + c.subs[ip][i] = c.subs[ip][len(c.subs[ip])-1] + c.subs[ip][len(c.subs[ip])-1] = nil + c.subs[ip] = c.subs[ip][:len(c.subs[ip])-1] + break + } + } + if len(c.subs[ip]) == 0 { + delete(c.subs, ip) + } +} + +func (c *Client) Request(ip netip.Addr) { + c.client.Request(ip) +} + +func (c *Client) Run() { + go func() { + for { + pkt, _, err := c.client.Read() + if err != nil { + continue + } + + switch pkt.Operation { + case arp.OperationReply: + go c.handleReply(pkt) + case arp.OperationRequest: + go func() { + shouldReply := c.handleRequest(pkt.TargetIP) + if shouldReply { + c.reply(pkt) + } + }() + } + } + }() +} + +func (c *Client) handleReply(pkt *arp.Packet) { + c.mutex.Lock() + defer c.mutex.Unlock() + + for _, ch := range c.subs[pkt.SenderIP] { + select { + case ch <- pkt.SenderHardwareAddr: + default: + } + } +} + +func (c *Client) reply(requestPkt *arp.Packet) error { + reply, err := arp.NewPacket(arp.OperationReply, + c.HardwareAddr(), requestPkt.TargetIP, + requestPkt.SenderHardwareAddr, requestPkt.SenderIP, + ) + if err != nil { + return err + } + + return c.client.WriteTo(reply, requestPkt.SenderHardwareAddr) +} diff --git a/arphttp/arphttp.go b/arphttp/arphttp.go new file mode 100644 index 0000000..feb9807 --- /dev/null +++ b/arphttp/arphttp.go @@ -0,0 +1,56 @@ +package arphttp + +import ( + "context" + "fmt" + "net" + "net/netip" + "sync" + "time" +) + +const ( + port = 2707 + resolvePath = "/resolve" + selfPath = "/self" + + resolveTimeout = 3 * time.Second +) + +func Resolve(ip netip.Addr, clients ...*Client) (net.HardwareAddr, error) { + var result net.HardwareAddr + resolved := false + + var wg sync.WaitGroup + var once sync.Once + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + for _, client := range clients { + client := client + if !client.ServerPrefix().Contains(ip) { + continue + } + + wg.Add(1) + go func() { + defer wg.Done() + + mac, err := client.ResolveWithContext(ctx, ip) + if err != nil { + return + } + once.Do(func() { + result = mac + resolved = true + cancel() + }) + }() + } + wg.Wait() + + if resolved { + return result, nil + } + return nil, fmt.Errorf("%s: cannot resolve", ip) +} diff --git a/arphttp/client.go b/arphttp/client.go new file mode 100644 index 0000000..7fe13f1 --- /dev/null +++ b/arphttp/client.go @@ -0,0 +1,51 @@ +package arphttp + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "strings" +) + +type Client struct { + serverPrefix netip.Prefix + httpClient *http.Client +} + +func NewClient(serverPrefix netip.Prefix) *Client { + return &Client{ + serverPrefix: serverPrefix, + httpClient: &http.Client{Timeout: resolveTimeout}, + } +} + +func (c *Client) ServerPrefix() netip.Prefix { + return c.serverPrefix +} + +func (c *Client) ResolveWithContext(ctx context.Context, ip netip.Addr) (net.HardwareAddr, error) { + url := fmt.Sprintf("http://%s:%d%s?ip=%s", c.serverPrefix.Addr(), port, resolvePath, ip) + if c.serverPrefix.Addr() == ip { + url = fmt.Sprintf("http://%s:%d%s", c.serverPrefix.Addr(), port, selfPath) + } + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + response, err := c.httpClient.Do(request) + if err != nil { + return nil, err + } + + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + return net.ParseMAC(strings.TrimSpace(string(body))) +} diff --git a/arphttp/server.go b/arphttp/server.go new file mode 100644 index 0000000..2b57b7a --- /dev/null +++ b/arphttp/server.go @@ -0,0 +1,79 @@ +package arphttp + +import ( + "fmt" + "net" + "net/http" + "net/netip" + "xarpd/arp" +) + +type Server struct { + arpClient *arp.Client + addr string +} + +func NewServer(arpClient *arp.Client, iface *net.Interface) (*Server, error) { + addrs, err := iface.Addrs() + if err != nil { + return nil, err + } + if len(addrs) < 1 { + return nil, fmt.Errorf("no address available for interface %s", iface.Name) + } + prefix := netip.MustParsePrefix(addrs[0].String()) + return &Server{ + arpClient: arpClient, + addr: fmt.Sprintf("%s:%d", prefix.Addr(), port), + }, nil +} + +func (s *Server) Addr() string { + return s.addr +} + +func (s *Server) Start() error { + return http.ListenAndServe(s.addr, s) +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + switch r.URL.Path { + case resolvePath: + s.resolve(w, r) + return + case selfPath: + s.self(w, r) + return + } + } + + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintln(w, "bad request") +} + +func (s *Server) resolve(w http.ResponseWriter, r *http.Request) { + param := r.URL.Query().Get("ip") + ip, err := netip.ParseAddr(param) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintln(w, "invalid 'ip' query param") + return + } + + arpReplies := make(chan net.HardwareAddr) + s.arpClient.Subscribe(ip, arpReplies) + defer s.arpClient.Unsubscribe(ip, arpReplies) + + s.arpClient.Request(ip) + + select { + case mac := <-arpReplies: + fmt.Fprintln(w, mac) + case <-r.Context().Done(): + } +} + +func (s *Server) self(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, s.arpClient.HardwareAddr()) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c0d26f9 --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module xarpd + +go 1.19 + +require github.com/mdlayher/arp v0.0.0-20220512170110-6706a2966875 + +require ( + github.com/josharian/native v1.0.0 // indirect + github.com/mdlayher/ethernet v0.0.0-20220221185849-529eae5b6118 // indirect + github.com/mdlayher/packet v1.0.0 // indirect + github.com/mdlayher/socket v0.2.1 // indirect + golang.org/x/net v0.0.0-20190603091049-60506f45cf65 // indirect + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect + golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..140c182 --- /dev/null +++ b/go.sum @@ -0,0 +1,25 @@ +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/josharian/native v1.0.0 h1:Ts/E8zCSEsG17dUqv7joXJFybuMLjQfWE04tsBODTxk= +github.com/josharian/native v1.0.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= +github.com/mdlayher/arp v0.0.0-20220512170110-6706a2966875 h1:ql8x//rJsHMjS+qqEag8n3i4azw1QneKh5PieH9UEbY= +github.com/mdlayher/arp v0.0.0-20220512170110-6706a2966875/go.mod h1:kfOoFJuHWp76v1RgZCb9/gVUc7XdY877S2uVYbNliGc= +github.com/mdlayher/ethernet v0.0.0-20220221185849-529eae5b6118 h1:2oDp6OOhLxQ9JBoUuysVz9UZ9uI6oLUbvAZu0x8o+vE= +github.com/mdlayher/ethernet v0.0.0-20220221185849-529eae5b6118/go.mod h1:ZFUnHIVchZ9lJoWoEGUg8Q3M4U8aNNWA3CVSUTkW4og= +github.com/mdlayher/packet v1.0.0 h1:InhZJbdShQYt6XV2GPj5XHxChzOfhJJOMbvnGAmOfQ8= +github.com/mdlayher/packet v1.0.0/go.mod h1:eE7/ctqDhoiRhQ44ko5JZU2zxB88g+JH/6jmnjzPjOU= +github.com/mdlayher/socket v0.2.1 h1:F2aaOwb53VsBE+ebRS9bLd7yPOfYUMC8lOODdCBDY6w= +github.com/mdlayher/socket v0.2.1/go.mod h1:QLlNPkFR88mRUNQIzRBMfXxwKal8H7u1h3bL1CV+f0E= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65 h1:+rhAzEzT3f4JtomfC371qB+0Ola2caSKcY69NUBZrRQ= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/main.go b/main.go new file mode 100644 index 0000000..2572b8c --- /dev/null +++ b/main.go @@ -0,0 +1,74 @@ +package main + +import ( + "flag" + "fmt" + "log" + "net" + "net/netip" + "xarpd/arp" + "xarpd/arphttp" +) + +var ( + resolvers []*arphttp.Client + iface *net.Interface +) + +func init() { + log.SetFlags(0) + + flag.Usage = func() { + fmt.Print(` +Usage: + xarpd [-i INTERFACE] RESOLVER... + +Example: + Forward local ARP requests for 10.0.0.128-10.0.0.255 to another xarpd instance running on 10.0.0.130: + xarpd -i eth0 10.0.0.130/25 +`[1:]) + } + ifaceName := flag.String("i", defaultIfaceName(), "") + flag.Parse() + + for _, resolverIP := range flag.Args() { + prefix, err := netip.ParsePrefix(resolverIP) + if err != nil { + log.Fatalf("invalid resolver %q: %s\n", resolverIP, err) + } + resolvers = append(resolvers, arphttp.NewClient(prefix)) + } + + foundIface, err := net.InterfaceByName(*ifaceName) + if err != nil { + log.Fatalf("invalid interface %q: %s\n", *ifaceName, err) + } + iface = foundIface +} + +func main() { + for _, resolver := range resolvers { + log.Printf("Forwarding ARP requests for %s to %s\n", + resolver.ServerPrefix().Masked(), + resolver.ServerPrefix().Addr()) + } + + arpClient, err := arp.NewClient(iface, shouldReplyToARPRequest) + if err != nil { + log.Fatalln(err) + } + log.Printf("Listening for ARP on %s\n", iface.Name) + arpClient.Run() + + httpResolver, err := arphttp.NewServer(arpClient, iface) + if err != nil { + log.Fatalln(err) + } + log.Printf("Listening for HTTP on %s\n", httpResolver.Addr()) + log.Fatalln(httpResolver.Start()) +} + +func shouldReplyToARPRequest(ip netip.Addr) bool { + _, err := arphttp.Resolve(ip, resolvers...) + return err == nil +} diff --git a/net.go b/net.go new file mode 100644 index 0000000..599c702 --- /dev/null +++ b/net.go @@ -0,0 +1,28 @@ +package main + +import ( + "bufio" + "os" + "strings" +) + +func defaultIfaceName() string { + file, err := os.Open("/proc/net/route") + if err != nil { + return "" + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + fields := strings.Split(scanner.Text(), "\t") + if len(fields) < 8 { + continue + } + name, destination, mask := fields[0], fields[1], fields[7] + if destination == "00000000" && mask == "00000000" { + return name + } + } + return "" +}