← Back to team overview

sts-sponsors team mailing list archive

[Merge] ~cgrabowski/maas:go_network_discovery into maas:master

 

Christian Grabowski has proposed merging ~cgrabowski/maas:go_network_discovery into maas:master.

Commit message:
print results as json

add netmon service

add package for parsing ARP packets


Requested reviews:
  MAAS Maintainers (maas-maintainers)

For more details, see:
https://code.launchpad.net/~cgrabowski/maas/+git/maas/+merge/441702
-- 
Your team MAAS Maintainers is requested to review the proposed merge of ~cgrabowski/maas:go_network_discovery into maas:master.
diff --git a/src/maasagent/cmd/netmon/main.go b/src/maasagent/cmd/netmon/main.go
index e83655f..c6e79ea 100644
--- a/src/maasagent/cmd/netmon/main.go
+++ b/src/maasagent/cmd/netmon/main.go
@@ -1,9 +1,78 @@
 package main
 
 import (
+	"context"
+	"encoding/json"
+	"errors"
+	"os"
+	"strconv"
+
+	"github.com/rs/zerolog"
+	"github.com/rs/zerolog/log"
+
 	"launchpad.net/maas/maas/src/maasagent/internal/netmon"
 )
 
+var (
+	ErrMissingIface = errors.New("Missing interface argument")
+)
+
+func Run() int {
+	var (
+		debug bool
+		err   error
+	)
+
+	log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
+
+	debugStr, ok := os.LookupEnv("DEBUG")
+	if ok {
+		debug, err = strconv.ParseBool(debugStr)
+		if err != nil {
+			log.Error().Err(err).Msg("Unable to parse debug flag")
+			return 2
+		}
+	}
+
+	if debug {
+		zerolog.SetGlobalLevel(zerolog.DebugLevel)
+	} else {
+		zerolog.SetGlobalLevel(zerolog.InfoLevel)
+	}
+
+	if len(os.Args) < 2 {
+		log.Error().Err(ErrMissingIface).Msg("Please provide an interface to monitor")
+		return 2
+	}
+	iface := os.Args[1]
+
+	bkg := context.Background()
+	ctx, cancel := context.WithCancel(bkg)
+
+	sigC := make(chan os.Signal)
+
+	svc := netmon.NewService(iface)
+	go svc.Start(ctx)
+	log.Info().Msg("Service netmon started")
+
+	for {
+		select {
+		case <-sigC:
+			cancel()
+			return 0
+		case res := <-svc.ResC:
+			err = json.NewEncoder(os.Stdout).Encode(res)
+			if err != nil {
+				log.Error().Err(err).Msg("")
+				return 1
+			}
+		case err = <-svc.ErrC:
+			log.Error().Err(err).Msg("")
+			return 1
+		}
+	}
+}
+
 func main() {
-	netmon.NewService()
+	os.Exit(Run())
 }
diff --git a/src/maasagent/go.mod b/src/maasagent/go.mod
index 9bee24c..6adeafc 100644
--- a/src/maasagent/go.mod
+++ b/src/maasagent/go.mod
@@ -1,3 +1,30 @@
 module launchpad.net/maas/maas/src/maasagent
 
 go 1.18
+
+require (
+	github.com/mdlayher/arp v0.0.0-20220512170110-6706a2966875
+	github.com/packetcap/go-pcap v0.0.0-20230225181818-eba71accde5b
+	github.com/rs/zerolog v1.29.1
+	github.com/stretchr/testify v1.7.0
+)
+
+require (
+	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/google/go-cmp v0.5.8 // indirect
+	github.com/google/gopacket v1.1.17 // indirect
+	github.com/josharian/native v1.0.0 // indirect
+	github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect
+	github.com/mattn/go-colorable v0.1.12 // indirect
+	github.com/mattn/go-isatty v0.0.14 // 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
+	github.com/pmezard/go-difflib v1.0.0 // indirect
+	github.com/sirupsen/logrus v1.4.2 // indirect
+	golang.org/x/net v0.7.0 // indirect
+	golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
+	golang.org/x/sys v0.5.0 // indirect
+	gopkg.in/yaml.v2 v2.2.8 // indirect
+	gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
+)
diff --git a/src/maasagent/go.sum b/src/maasagent/go.sum
index e69de29..045d92e 100644
--- a/src/maasagent/go.sum
+++ b/src/maasagent/go.sum
@@ -0,0 +1,210 @@
+cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
+github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
+github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
+github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
+github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
+github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
+github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
+github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
+github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
+github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
+github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
+github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
+github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
+github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
+github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
+github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
+github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
+github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
+github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
+github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
+github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
+github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/gopacket v1.1.17 h1:rMrlX2ZY2UbvT+sdz3+6J+pp2z+msCq9MxTU6ymxbBY=
+github.com/google/gopacket v1.1.17/go.mod h1:UdDNZ1OO62aGYVnPhxT1U6aI7ukYtA/kB8vaU0diBUM=
+github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
+github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
+github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
+github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
+github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
+github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
+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/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
+github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s=
+github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
+github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
+github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
+github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
+github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
+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=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
+github.com/packetcap/go-pcap v0.0.0-20230225181818-eba71accde5b h1:M2uMUEhJoonOZxXMFb9xs5Bq573t7xt6b2waNsluzCU=
+github.com/packetcap/go-pcap v0.0.0-20230225181818-eba71accde5b/go.mod h1:IwL7NJSMD5mvRco6A6uxPca1Zv0OJp0tY5Gf++9LBYQ=
+github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
+github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
+github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
+github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
+github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
+github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
+github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
+github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
+github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
+github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
+github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
+github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc=
+github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU=
+github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
+github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
+github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
+github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
+github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
+github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
+github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
+github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
+github.com/spf13/cobra v0.0.6/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
+github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
+github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
+github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
+github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
+github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
+go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
+go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
+go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
+golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
+golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190405154228-4b34438f7a67/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
+google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
+google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
+google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
+gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
+gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/src/maasagent/internal/arp/ethernet.go b/src/maasagent/internal/arp/ethernet.go
new file mode 100644
index 0000000..e2cfc5b
--- /dev/null
+++ b/src/maasagent/internal/arp/ethernet.go
@@ -0,0 +1,99 @@
+package arp
+
+import (
+	"encoding/binary"
+	"errors"
+	"io"
+	"net"
+)
+
+const (
+	minEthernetLen = 14
+)
+
+const (
+	EthernetTypeLLC  uint16 = 0
+	EthernetTypeIPv4 uint16 = 0x0800
+	EthernetTypeARP  uint16 = 0x0806
+	EthernetTypeIPv6 uint16 = 0x086dd
+	EthernetTypeVLAN uint16 = 0x8100
+
+	NonStdLenEthernetTypes uint16 = 0x600
+)
+
+var (
+	ErrNotVLAN       = errors.New("ethernet frame not of type VLAN")
+	ErrMalformedVLAN = errors.New("VLAN tag is malformed")
+)
+
+type VLAN struct {
+	Priority     uint8
+	DropEligible bool
+	ID           uint16
+	EthernetType uint16
+}
+
+func UnmarshalVLAN(buf []byte) (*VLAN, error) {
+	if len(buf) < 4 {
+		return nil, ErrMalformedVLAN
+	}
+
+	var v VLAN
+
+	// extract the first 3 bits
+	v.Priority = (buf[0] & 0xe0) >> 5
+	// extract the next bit and turn it into a bool
+	v.DropEligible = buf[0]&0x10 != 0
+	// extract the next 12 bits for an ID
+	v.ID = binary.BigEndian.Uint16(buf[:2]) & 0x0fff
+	// last 2 bytes are ethernet type
+	v.EthernetType = binary.BigEndian.Uint16(buf[2:])
+	return &v, nil
+}
+
+type EthernetFrame struct {
+	SrcMAC       net.HardwareAddr
+	DstMAC       net.HardwareAddr
+	EthernetType uint16
+	Len          uint16
+	Payload      []byte
+}
+
+func (e *EthernetFrame) ExtractARPPacket() (*Packet, error) {
+	if e.EthernetType == EthernetTypeVLAN {
+		return Unmarshal(e.Payload[4:])
+	}
+	return Unmarshal(e.Payload)
+}
+
+func (e *EthernetFrame) ExtractVLAN() (*VLAN, error) {
+	if e.EthernetType != EthernetTypeVLAN {
+		return nil, ErrNotVLAN
+	}
+	return UnmarshalVLAN(e.Payload[0:4])
+}
+
+func UnmarshalEthernet(buf []byte) (*EthernetFrame, error) {
+	if len(buf) < minEthernetLen {
+		return nil, io.ErrUnexpectedEOF
+	}
+
+	var eth EthernetFrame
+
+	eth.DstMAC = buf[0:6]
+	eth.SrcMAC = buf[6:12]
+	eth.EthernetType = binary.BigEndian.Uint16(buf[12:14])
+	eth.Payload = buf[14:]
+	if eth.EthernetType < NonStdLenEthernetTypes {
+		eth.Len = eth.EthernetType
+		eth.EthernetType = EthernetTypeLLC
+		cmp := len(eth.Payload) - int(eth.Len)
+		if cmp < 0 {
+			return nil, io.ErrUnexpectedEOF
+		} else if cmp > 0 {
+			eth.Payload = eth.Payload[:len(eth.Payload)-cmp]
+		}
+	}
+
+	return &eth, nil
+}
diff --git a/src/maasagent/internal/arp/ethernet_test.go b/src/maasagent/internal/arp/ethernet_test.go
new file mode 100644
index 0000000..2bb301b
--- /dev/null
+++ b/src/maasagent/internal/arp/ethernet_test.go
@@ -0,0 +1,231 @@
+package arp
+
+import (
+	"io"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestUnmarshalVLAN(t *testing.T) {
+	table := []unmarshalVLANCase{
+		{
+			basePacketTestCase: basePacketTestCase{
+				Name: "ValidVLANTag",
+				In:   []byte{0x00, 0x02, 0x08, 0x06},
+			},
+			Out: &VLAN{
+				Priority:     0,
+				DropEligible: false,
+				ID:           2,
+				EthernetType: EthernetTypeARP,
+			},
+		},
+		{
+			basePacketTestCase: basePacketTestCase{
+				Name: "InvalidVLANTag",
+				In:   []byte{0x00, 0x02},
+				Err:  ErrMalformedVLAN,
+			},
+		},
+	}
+
+	for _, tcase := range table {
+		t.Run(tcase.Name, func(tt *testing.T) {
+			res, err := UnmarshalVLAN(tcase.In)
+			assert.ErrorIsf(tt, err, tcase.Err, "expected UnmarshalVLAN to return an error of %s", tcase.Err)
+			if tcase.Out != nil {
+				compareVLANs(tt, tcase.Out, res)
+			} else {
+				assert.Nil(tt, res, "expected UnmarshalVLAN to not return a VLAN")
+			}
+		})
+	}
+}
+
+func TestUnmarshalEthernet(t *testing.T) {
+	table := []unmarshalEthernetCase{
+		{
+			basePacketTestCase: basePacketTestCase{
+				Name: "ValidEthernetFrameWithoutVLAN",
+				In: []byte{
+					0x24, 0x4b, 0xfe, 0xe1, 0xea, 0x26, 0x80, 0x61, 0x5f, 0x08, 0xfc, 0x16, 0x08, 0x06, 0x00, 0x01,
+					0x08, 0x00, 0x06, 0x04, 0x00, 0x02, 0x80, 0x61, 0x5f, 0x08, 0xfc, 0x16, 0xc0, 0xa8, 0x01, 0x6c,
+					0x24, 0x4b, 0xfe, 0xe1, 0xea, 0x26, 0xc0, 0xa8, 0x01, 0x50,
+				},
+			},
+			Out: &EthernetFrame{
+				SrcMAC:       parseMACNoError("80:61:5f:08:fc:16"),
+				DstMAC:       parseMACNoError("24:4b:fe:e1:ea:26"),
+				EthernetType: EthernetTypeARP,
+				Payload: []byte{
+					0x00, 0x01,
+					0x08, 0x00, 0x06, 0x04, 0x00, 0x02, 0x80, 0x61, 0x5f, 0x08, 0xfc, 0x16, 0xc0, 0xa8, 0x01, 0x6c,
+					0x24, 0x4b, 0xfe, 0xe1, 0xea, 0x26, 0xc0, 0xa8, 0x01, 0x50,
+				},
+			},
+		},
+		{
+			basePacketTestCase: basePacketTestCase{
+				Name: "ValidEthernetFrameWithVLAN",
+				In: []byte{
+					0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x84, 0x39, 0xc0, 0x0b, 0x22, 0x25, 0x81, 0x00, 0x00, 0x02,
+					0x08, 0x06, 0x00, 0x01, 0x08, 0x00, 0x06, 0x04, 0x00, 0x01, 0x84, 0x39, 0xc0, 0x0b, 0x22, 0x25,
+					0xc0, 0xa8, 0x0a, 0x1a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xa8, 0x0a, 0x19, 0x00, 0x00,
+					0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+				},
+			},
+			Out: &EthernetFrame{
+				SrcMAC:       parseMACNoError("84:39:c0:0b:22:25"),
+				DstMAC:       parseMACNoError("ff:ff:ff:ff:ff:ff"),
+				EthernetType: EthernetTypeVLAN,
+				Payload: []byte{
+					0x00, 0x02,
+					0x08, 0x06, 0x00, 0x01, 0x08, 0x00, 0x06, 0x04, 0x00, 0x01, 0x84, 0x39, 0xc0, 0x0b, 0x22, 0x25,
+					0xc0, 0xa8, 0x0a, 0x1a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xa8, 0x0a, 0x19, 0x00, 0x00,
+					0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+				},
+			},
+		},
+		{
+			basePacketTestCase: basePacketTestCase{
+				Name: "NoEthernetFrame",
+				Err:  io.ErrUnexpectedEOF,
+			},
+		},
+		{
+			basePacketTestCase: basePacketTestCase{
+				Name: "InvalidEthernetFrame",
+				In: []byte{
+					0xff, 0xff, 0xff, 0xff, 0xff, 0x84, 0x39, 0xc0, 0x0b, 0x22, 0x25, 0x00, 0x02,
+					0x08, 0x06, 0x00, 0x01, 0x08, 0x00, 0x06, 0x04, 0x00, 0x01, 0x84, 0x39, 0xc0, 0x0b, 0x22, 0x25,
+					0xc0, 0xa8, 0x0a, 0x1a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xa8, 0x0a, 0x19,
+				},
+				Err: io.ErrUnexpectedEOF,
+			},
+		},
+	}
+
+	for _, tcase := range table {
+		t.Run(tcase.Name, func(tt *testing.T) {
+			res, err := UnmarshalEthernet(tcase.In)
+			assert.ErrorIsf(tt, err, tcase.Err, "expected UnmarshalEthernet to return an error of %s", tcase.Err)
+			if tcase.Out != nil {
+				compareEthernetFrames(tt, tcase.Out, res)
+			} else {
+				assert.Nil(tt, res, "did not expect an ethernet frame to be returned from UnmarshalEthernet")
+			}
+		})
+	}
+}
+
+func TestEthernetFrameExtractVLAN(t *testing.T) {
+	table := []unmarshalVLANCase{
+		{
+			basePacketTestCase: basePacketTestCase{
+				Name: "EthernetFrameIsVLAN",
+				In: []byte{
+					0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x84, 0x39, 0xc0, 0x0b, 0x22, 0x25, 0x81, 0x00, 0x00, 0x02,
+					0x08, 0x06, 0x00, 0x01, 0x08, 0x00, 0x06, 0x04, 0x00, 0x01, 0x84, 0x39, 0xc0, 0x0b, 0x22, 0x25,
+					0xc0, 0xa8, 0x0a, 0x1a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xa8, 0x0a, 0x19, 0x00, 0x00,
+					0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+				},
+			},
+			Out: &VLAN{
+				ID:           2,
+				EthernetType: EthernetTypeARP,
+			},
+		},
+		{
+			basePacketTestCase: basePacketTestCase{
+				Name: "EthernetFrameIsNotVLAN",
+				In: []byte{
+					0x24, 0x4b, 0xfe, 0xe1, 0xea, 0x26, 0x80, 0x61, 0x5f, 0x08, 0xfc, 0x16, 0x08, 0x06, 0x00, 0x01,
+					0x08, 0x00, 0x06, 0x04, 0x00, 0x02, 0x80, 0x61, 0x5f, 0x08, 0xfc, 0x16, 0xc0, 0xa8, 0x01, 0x6c,
+					0x24, 0x4b, 0xfe, 0xe1, 0xea, 0x26, 0xc0, 0xa8, 0x01, 0x50,
+				},
+				Err: ErrNotVLAN,
+			},
+		},
+	}
+
+	for _, tcase := range table {
+		t.Run(tcase.Name, func(tt *testing.T) {
+			eth, err := UnmarshalEthernet(tcase.In)
+			if err != nil {
+				tt.Fatal(err)
+			}
+			vlan, err := eth.ExtractVLAN()
+			assert.ErrorIsf(tt, err, tcase.Err, "expected ExtractVLAN to return an error of: %s", tcase.Err)
+			if tcase.Out != nil {
+				compareVLANs(tt, tcase.Out, vlan)
+			} else {
+				assert.Nil(tt, vlan, "expected no VLAN to be extracted")
+			}
+		})
+	}
+}
+
+func TestEthernetFrameExtractARP(t *testing.T) {
+	table := []unmarshalCase{
+		{
+			basePacketTestCase: basePacketTestCase{
+				Name: "EthernetFrameIsVLAN",
+				In: []byte{
+					0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x84, 0x39, 0xc0, 0x0b, 0x22, 0x25, 0x81, 0x00, 0x00, 0x02,
+					0x08, 0x06, 0x00, 0x01, 0x08, 0x00, 0x06, 0x04, 0x00, 0x01, 0x84, 0x39, 0xc0, 0x0b, 0x22, 0x25,
+					0xc0, 0xa8, 0x0a, 0x1a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xa8, 0x0a, 0x19, 0x00, 0x00,
+					0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+				},
+			},
+			Out: &Packet{
+				HardwareType:    HardwareTypeEthernet,
+				ProtocolType:    ProtocolTypeIPv4,
+				HardwareAddrLen: 6,
+				ProtocolAddrLen: 4,
+				OpCode:          OpRequest,
+				SendHwdAddr:     parseMACNoError("84:39:c0:0b:22:25"),
+				SendIPAddr:      parseAddrNoError("192.168.10.26"),
+				TgtHwdAddr:      parseMACNoError("00:00:00:00:00:00"),
+				TgtIPAddr:       parseAddrNoError("192.168.10.25"),
+			},
+		},
+		{
+			basePacketTestCase: basePacketTestCase{
+				Name: "EthernetFrameIsNotVLAN",
+				In: []byte{
+					0x24, 0x4b, 0xfe, 0xe1, 0xea, 0x26, 0x80, 0x61, 0x5f, 0x08, 0xfc, 0x16, 0x08, 0x06, 0x00, 0x01,
+					0x08, 0x00, 0x06, 0x04, 0x00, 0x02, 0x80, 0x61, 0x5f, 0x08, 0xfc, 0x16, 0xc0, 0xa8, 0x01, 0x6c,
+					0x24, 0x4b, 0xfe, 0xe1, 0xea, 0x26, 0xc0, 0xa8, 0x01, 0x50,
+				},
+			},
+			Out: &Packet{
+				HardwareType:    HardwareTypeEthernet,
+				ProtocolType:    ProtocolTypeIPv4,
+				HardwareAddrLen: 6,
+				ProtocolAddrLen: 4,
+				OpCode:          OpReply,
+				SendHwdAddr:     parseMACNoError("80:61:5f:08:fc:16"),
+				SendIPAddr:      parseAddrNoError("192.168.1.108"),
+				TgtHwdAddr:      parseMACNoError("24:4b:fe:e1:ea:26"),
+				TgtIPAddr:       parseAddrNoError("192.168.1.80"),
+			},
+		},
+	}
+
+	for _, tcase := range table {
+		t.Run(tcase.Name, func(tt *testing.T) {
+			eth, err := UnmarshalEthernet(tcase.In)
+			if err != nil {
+				tt.Fatal(err)
+			}
+			pkt, err := eth.ExtractARPPacket()
+			assert.ErrorIsf(tt, err, tcase.Err, "expected ExtractARPPacket to return an error of: %s", tcase.Err)
+			if tcase.Out != nil {
+				compareARPPacket(tt, tcase.Out, pkt)
+			} else {
+				assert.Nil(tt, pkt, "did not expect ExtractARPPacket to return a Packet")
+			}
+		})
+	}
+}
diff --git a/src/maasagent/internal/arp/helpers_test.go b/src/maasagent/internal/arp/helpers_test.go
new file mode 100644
index 0000000..acdc1dd
--- /dev/null
+++ b/src/maasagent/internal/arp/helpers_test.go
@@ -0,0 +1,99 @@
+package arp
+
+import (
+	"net"
+	"net/netip"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+type basePacketTestCase struct {
+	Name string
+	In   []byte
+	Err  error
+}
+
+type unmarshalVLANCase struct {
+	basePacketTestCase
+	Out *VLAN
+}
+
+type unmarshalEthernetCase struct {
+	basePacketTestCase
+	Out *EthernetFrame
+}
+
+type unmarshalCase struct {
+	basePacketTestCase
+	Out *Packet
+}
+
+func parseMACNoError(s string) net.HardwareAddr {
+	addr, _ := net.ParseMAC(s)
+	return addr
+}
+
+func parseAddrNoError(s string) netip.Addr {
+	addr, _ := netip.ParseAddr(s)
+	return addr
+}
+
+func ethernetTypeToString(t uint16) string {
+	switch t {
+	case EthernetTypeLLC:
+		return "LLC"
+	case EthernetTypeIPv4:
+		return "IPv4"
+	case EthernetTypeARP:
+		return "ARP"
+	case EthernetTypeIPv6:
+		return "IPv6"
+	case EthernetTypeVLAN:
+		return "VLAN"
+	}
+	return "unknown"
+}
+
+func hardwareTypeToString(t uint16) string {
+	switch t {
+	case HardwareTypeEthernet:
+		return "Ethernet"
+	}
+	return "unknown"
+}
+
+func protocolTypeToString(t uint16) string {
+	switch t {
+	case ProtocolTypeIPv4:
+		return "IPv4"
+	}
+	return "unknown"
+}
+
+func compareVLANs(t *testing.T, expected, actual *VLAN) {
+	assert.Equalf(t, expected.Priority, actual.Priority, "expected Priority to be %d", int(expected.Priority))
+	assert.Equalf(t, expected.DropEligible, actual.DropEligible, "exptected DropEligible to be %v", expected.DropEligible)
+	assert.Equalf(t, expected.ID, actual.ID, "expected ID to be %d", int(expected.ID))
+	assert.Equalf(t, expected.EthernetType, actual.EthernetType, "expected EthernetType to be %s", ethernetTypeToString(expected.EthernetType))
+}
+
+func compareEthernetFrames(t *testing.T, expected, actual *EthernetFrame) {
+	assert.Equalf(t, expected.SrcMAC, actual.SrcMAC, "expected SrcMAC to be %s", expected.SrcMAC)
+	assert.Equalf(t, expected.DstMAC, actual.DstMAC, "expected DstMAC to be %s", expected.DstMAC)
+	assert.Equalf(t, expected.EthernetType, actual.EthernetType, "expected EthernetType to be %s", ethernetTypeToString(expected.EthernetType))
+	assert.Equalf(t, expected.Len, actual.Len, "expected a length of %d", int(expected.Len))
+	assert.Equalf(t, expected.Payload, actual.Payload, "expected a payload of %x", expected.Payload)
+}
+
+func compareARPPacket(t *testing.T, expected, actual *Packet) {
+	assert.Equalf(t, expected.HardwareType, actual.HardwareType, "expected a HardwareType of %s", hardwareTypeToString(expected.HardwareType))
+	assert.Equalf(t, expected.ProtocolType, actual.ProtocolType, "expected a ProtocolType of %s", protocolTypeToString(expected.ProtocolType))
+	assert.Equalf(t, expected.HardwareAddrLen, actual.HardwareAddrLen, "expected a HardwareAddrLen of %d", int(expected.HardwareAddrLen))
+	assert.Equalf(t, expected.ProtocolAddrLen, actual.ProtocolAddrLen, "expected a ProtocolAddrLen of %d", int(expected.ProtocolAddrLen))
+	assert.Equalf(t, expected.OpCode, actual.OpCode, "expected a ProtocolLen of %d", int(expected.OpCode))
+	assert.Equalf(t, expected.SendHwdAddr, actual.SendHwdAddr, "expected a SendHwdAddr of %s", expected.SendHwdAddr)
+	assert.Equalf(t, expected.SendIPAddr, actual.SendIPAddr, "expected a SendIPAddr of %s", expected.SendIPAddr)
+	assert.Equalf(t, expected.TgtHwdAddr, actual.TgtHwdAddr, "expected a TgtHwdAddr of %s", expected.TgtHwdAddr)
+	assert.Equalf(t, expected.TgtIPAddr, actual.TgtIPAddr, "expected a TgtIPAddr of %s", expected.TgtIPAddr)
+}
diff --git a/src/maasagent/internal/arp/packet.go b/src/maasagent/internal/arp/packet.go
new file mode 100644
index 0000000..a81f036
--- /dev/null
+++ b/src/maasagent/internal/arp/packet.go
@@ -0,0 +1,118 @@
+package arp
+
+import (
+	"encoding/binary"
+	"errors"
+	"fmt"
+	"io"
+	"net"
+	"net/netip"
+)
+
+const (
+	HardwareTypeEthernet uint16 = iota + 1
+)
+
+const (
+	ProtocolTypeIPv4 uint16 = 0x0800
+)
+
+const (
+	OpRequest uint16 = iota + 1
+	OpReply
+)
+
+var (
+	ErrMalformedPacket = errors.New("malformed ARP packet")
+)
+
+// Packet is a struct containing the data of an ARP packet
+type Packet struct {
+	HardwareType    uint16
+	ProtocolType    uint16
+	HardwareAddrLen uint8
+	ProtocolAddrLen uint8
+	OpCode          uint16
+	SendHwdAddr     net.HardwareAddr
+	SendIPAddr      netip.Addr
+	TgtHwdAddr      net.HardwareAddr
+	TgtIPAddr       netip.Addr
+}
+
+func checkPacketLen(buf []byte, bytesRead, length int) error {
+	if len(buf[bytesRead:]) < length {
+		return io.ErrUnexpectedEOF
+	}
+	return nil
+}
+
+func Unmarshal(buf []byte) (*Packet, error) {
+	var (
+		pkt       Packet
+		bytesRead int
+	)
+
+	err := checkPacketLen(buf, bytesRead, 8)
+	if err != nil {
+		return nil, fmt.Errorf("%w: packet missing initial ARP fields", err)
+	}
+
+	pkt.HardwareType = binary.BigEndian.Uint16(buf[0:2])
+	pkt.ProtocolType = binary.BigEndian.Uint16(buf[2:4])
+	pkt.HardwareAddrLen = buf[4]
+	pkt.ProtocolAddrLen = buf[5]
+	pkt.OpCode = binary.BigEndian.Uint16(buf[6:8])
+
+	bytesRead = 8
+	hwdAddrLen := int(pkt.HardwareAddrLen)
+	ipAddrLen := int(pkt.ProtocolAddrLen)
+
+	err = checkPacketLen(buf, bytesRead, hwdAddrLen)
+	if err != nil {
+		return nil, fmt.Errorf("%w: packet too short for sender hardware address", err)
+	}
+
+	sendHwdAddrBuf := make([]byte, hwdAddrLen)
+	copy(sendHwdAddrBuf[:], buf[bytesRead:bytesRead+hwdAddrLen])
+	pkt.SendHwdAddr = sendHwdAddrBuf
+	bytesRead += hwdAddrLen
+
+	err = checkPacketLen(buf, bytesRead, ipAddrLen)
+	if err != nil {
+		return nil, fmt.Errorf("%w: packet too short for sender IP address", err)
+	}
+
+	var ok bool
+
+	sendIPAddrBuf := make([]byte, ipAddrLen)
+	copy(sendIPAddrBuf[:], buf[bytesRead:bytesRead+ipAddrLen])
+	pkt.SendIPAddr, ok = netip.AddrFromSlice(sendIPAddrBuf)
+	if !ok {
+		return nil, fmt.Errorf("%w: invalid sender IP address", ErrMalformedPacket)
+	}
+	bytesRead += ipAddrLen
+
+	err = checkPacketLen(buf, bytesRead, hwdAddrLen)
+	if err != nil {
+		return nil, fmt.Errorf("%w: packet too short for target hardware address", err)
+	}
+
+	tgtHwdAddrBuf := make([]byte, hwdAddrLen)
+	copy(tgtHwdAddrBuf[:], buf[bytesRead:bytesRead+hwdAddrLen])
+	pkt.TgtHwdAddr = tgtHwdAddrBuf
+	bytesRead += hwdAddrLen
+
+	err = checkPacketLen(buf, bytesRead, ipAddrLen)
+	if err != nil {
+		return nil, fmt.Errorf("%w: packet too short for target IP address", err)
+	}
+
+	tgtIPAddrBuf := make([]byte, ipAddrLen)
+	copy(tgtIPAddrBuf[:], buf[bytesRead:bytesRead+ipAddrLen])
+	pkt.TgtIPAddr, ok = netip.AddrFromSlice(tgtIPAddrBuf)
+	if !ok {
+		return nil, fmt.Errorf("%w: invalid target IP address", ErrMalformedPacket)
+	}
+
+	return &pkt, nil
+}
diff --git a/src/maasagent/internal/arp/packet_test.go b/src/maasagent/internal/arp/packet_test.go
new file mode 100644
index 0000000..992d9a7
--- /dev/null
+++ b/src/maasagent/internal/arp/packet_test.go
@@ -0,0 +1,83 @@
+package arp
+
+import (
+	"io"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestUnmarshal(t *testing.T) {
+	table := []unmarshalCase{
+		{
+			basePacketTestCase: basePacketTestCase{
+				Name: "ValidRequestPacket",
+				// generated from tcpdump
+				In: []byte{
+					0x00, 0x01, 0x08, 0x00, 0x06, 0x04, 0x00, 0x01, 0x84, 0x39, 0xc0, 0x0b, 0x22, 0x25,
+					0xc0, 0xa8, 0x0a, 0x1a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xa8, 0x0a, 0x19,
+				},
+			},
+			Out: &Packet{
+				HardwareType:    HardwareTypeEthernet,
+				ProtocolType:    ProtocolTypeIPv4,
+				HardwareAddrLen: 6,
+				ProtocolAddrLen: 4,
+				OpCode:          OpRequest,
+				SendHwdAddr:     parseMACNoError("84:39:c0:0b:22:25"),
+				SendIPAddr:      parseAddrNoError("192.168.10.26"),
+				TgtHwdAddr:      parseMACNoError("00:00:00:00:00:00"),
+				TgtIPAddr:       parseAddrNoError("192.168.10.25"),
+			},
+		},
+		{
+			basePacketTestCase: basePacketTestCase{
+				Name: "ValidReplyPacket",
+				// generated from tcpdump
+				In: []byte{
+					0x00, 0x01, 0x08, 0x00, 0x06, 0x04, 0x00, 0x02, 0x80, 0x61, 0x5f, 0x08, 0xfc, 0x16, 0xc0, 0xa8,
+					0x01, 0x6c, 0x24, 0x4b, 0xfe, 0xe1, 0xea, 0x26, 0xc0, 0xa8, 0x01, 0x50,
+				},
+			},
+			Out: &Packet{
+				HardwareType:    HardwareTypeEthernet,
+				ProtocolType:    ProtocolTypeIPv4,
+				HardwareAddrLen: 6,
+				ProtocolAddrLen: 4,
+				OpCode:          OpReply,
+				SendHwdAddr:     parseMACNoError("80:61:5f:08:fc:16"),
+				SendIPAddr:      parseAddrNoError("192.168.1.108"),
+				TgtHwdAddr:      parseMACNoError("24:4b:fe:e1:ea:26"),
+				TgtIPAddr:       parseAddrNoError("192.168.1.80"),
+			},
+		},
+		{
+			basePacketTestCase: basePacketTestCase{
+				Name: "EmptyPacket",
+				Err:  io.ErrUnexpectedEOF,
+			},
+		},
+		{
+			basePacketTestCase: basePacketTestCase{
+				Name: "TooShortPacket",
+				In: []byte{
+					0x00, 0x01, 0x08, 0x00, 0x06, 0x04, 0x00, 0x01, 0x84, 0x39, 0xc0, 0x0b, 0x22, 0x25,
+					0xc0, 0xa8,
+				},
+				Err: io.ErrUnexpectedEOF,
+			},
+		},
+	}
+
+	for _, tcase := range table {
+		t.Run(tcase.Name, func(tt *testing.T) {
+			res, err := Unmarshal(tcase.In)
+			assert.ErrorIsf(tt, err, tcase.Err, "expected Unmarshal to return an error of %s", tcase.Err)
+			if tcase.Out != nil {
+				compareARPPacket(tt, tcase.Out, res)
+			} else {
+				assert.Nil(tt, res, "did not expect a packet to be returned from Unmarshal")
+			}
+		})
+	}
+}
diff --git a/src/maasagent/internal/netmon/service.go b/src/maasagent/internal/netmon/service.go
index 14ebdc6..1792028 100644
--- a/src/maasagent/internal/netmon/service.go
+++ b/src/maasagent/internal/netmon/service.go
@@ -1,3 +1,202 @@
 package netmon
 
-func NewService() {}
+import (
+	"bytes"
+	"context"
+	"errors"
+	"fmt"
+	"net"
+	"net/netip"
+	"time"
+
+	pcap "github.com/packetcap/go-pcap"
+	"github.com/rs/zerolog/log"
+
+	"launchpad.net/maas/maas/src/maasagent/internal/arp"
+)
+
+const (
+	snapLen            = 64
+	timeout            = 5 * time.Minute
+	seenAgainThreshold = 600 * time.Second
+)
+
+const (
+	EventNew       = "NEW"
+	EventRefreshed = "REFRESHED"
+	EventMoved     = "MOVED"
+)
+
+var (
+	emptyTime time.Time
+)
+
+var (
+	ErrEmptyPacket = errors.New("received an empty packet")
+)
+
+type Binding struct {
+	IP   netip.Addr
+	MAC  net.HardwareAddr
+	VID  *uint16
+	Time time.Time
+}
+
+type Result struct {
+	IP          string  `json:"ip"`
+	MAC         string  `json:"mac"`
+	PreviousMAC string  `json:"previous_mac,omitempty"`
+	Event       string  `json:"event"`
+	Time        int64   `json:"time"`
+	VID         *uint16 `json:"vid"`
+}
+
+type Service struct {
+	iface    string
+	bindings map[string]Binding
+	ResC     chan Result
+	ErrC     chan error
+}
+
+func NewService(iface string) *Service {
+	return &Service{
+		iface:    iface,
+		bindings: make(map[string]Binding),
+		ResC:     make(chan Result),
+		ErrC:     make(chan error),
+	}
+}
+
+func (s *Service) updateBindings(pkt *arp.Packet, vid *uint16, timestamp time.Time) (res []Result) {
+	if timestamp == emptyTime {
+		timestamp = time.Now()
+	}
+
+	var vidLabel int
+	if vid == nil {
+		vidLabel = 0
+	} else {
+		vidLabel = int(*vid)
+	}
+
+	discoveredBindings := []Binding{
+		{
+			IP:   pkt.SendIPAddr,
+			MAC:  pkt.SendHwdAddr,
+			VID:  vid,
+			Time: timestamp,
+		},
+	}
+	if pkt.OpCode == arp.OpReply {
+		discoveredBindings = append(discoveredBindings, Binding{
+			IP:   pkt.TgtIPAddr,
+			MAC:  pkt.TgtHwdAddr,
+			VID:  vid,
+			Time: timestamp,
+		})
+	}
+
+	for _, discoveredBinding := range discoveredBindings {
+		key := fmt.Sprintf("%d_%s", vidLabel, discoveredBinding.IP.String())
+		binding, ok := s.bindings[key]
+		if ok {
+			if bytes.Compare(binding.MAC, discoveredBinding.MAC) != 0 {
+				s.bindings[key] = discoveredBinding
+				res = append(res, Result{
+					IP:          discoveredBinding.IP.String(),
+					PreviousMAC: binding.MAC.String(),
+					MAC:         discoveredBinding.MAC.String(),
+					VID:         discoveredBinding.VID,
+					Time:        discoveredBinding.Time.Unix(),
+					Event:       EventMoved,
+				})
+			} else if discoveredBinding.Time.Sub(binding.Time) >= seenAgainThreshold {
+				s.bindings[key] = discoveredBinding
+				res = append(res, Result{
+					IP:    discoveredBinding.IP.String(),
+					MAC:   discoveredBinding.MAC.String(),
+					VID:   discoveredBinding.VID,
+					Time:  discoveredBinding.Time.Unix(),
+					Event: EventRefreshed,
+				})
+			}
+		} else {
+			s.bindings[key] = discoveredBinding
+			res = append(res, Result{
+				IP:    discoveredBinding.IP.String(),
+				MAC:   discoveredBinding.MAC.String(),
+				VID:   discoveredBinding.VID,
+				Time:  discoveredBinding.Time.Unix(),
+				Event: EventNew,
+			})
+		}
+	}
+
+	return res
+}
+
+func (s *Service) handlePacket(pkt pcap.Packet) ([]Result, error) {
+	if pkt.Error != nil {
+		return nil, pkt.Error
+	}
+	if len(pkt.B) == 0 {
+		return nil, ErrEmptyPacket
+	}
+	eth, err := arp.UnmarshalEthernet(pkt.B)
+	if err != nil {
+		return nil, err
+	}
+
+	if eth.EthernetType != arp.EthernetTypeVLAN && eth.EthernetType != arp.EthernetTypeARP {
+		log.Debug().Msg("skipping non-ARP packet")
+		return nil, nil
+	}
+
+	var vid *uint16
+	if eth.EthernetType == arp.EthernetTypeVLAN {
+		vlan, err := eth.ExtractVLAN()
+		if err != nil {
+			return nil, err
+		}
+		*vid = vlan.ID
+	}
+
+	arpPkt, err := eth.ExtractARPPacket()
+	if err != nil {
+		return nil, err
+	}
+	return s.updateBindings(arpPkt, vid, pkt.Info.Timestamp), nil
+}
+
+func (s *Service) Start(ctx context.Context) {
+	defer func() {
+		close(s.ResC)
+		close(s.ErrC)
+	}()
+
+	hndlr, err := pcap.OpenLive(s.iface, snapLen, false, timeout, true)
+	if err != nil {
+		s.ErrC <- err
+		return
+	}
+	defer hndlr.Close()
+	pkts := hndlr.Listen()
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case pkt, ok := <-pkts:
+			if !ok {
+				log.Debug().Msg("packet capture has closed")
+			}
+			res, err := s.handlePacket(pkt)
+			if err != nil {
+				s.ErrC <- err
+				return
+			}
+			for _, r := range res {
+				s.ResC <- r
+			}
+		}
+	}
+}
diff --git a/src/maasagent/internal/netmon/service_test.go b/src/maasagent/internal/netmon/service_test.go
new file mode 100644
index 0000000..20c4fae
--- /dev/null
+++ b/src/maasagent/internal/netmon/service_test.go
@@ -0,0 +1,84 @@
+package netmon
+
+import (
+	"io"
+	"testing"
+
+	pcap "github.com/packetcap/go-pcap"
+	"github.com/stretchr/testify/assert"
+
+	"launchpad.net/maas/maas/src/maasagent/internal/arp"
+)
+
+type handlePacketCase struct {
+	Name string
+	In   pcap.Packet
+	Out  *Result
+	Err  error
+}
+
+func TestServiceHandlePacket(t *testing.T) {
+	table := []handlePacketCase{
+		{
+			Name: "ValidRequestPacket",
+			In: pcap.Packet{
+				// generated from tcpdump
+				B: []byte{
+					0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x84, 0x39, 0xc0, 0x0b, 0x22, 0x25, 0x81, 0x00, 0x00, 0x02,
+					0x08, 0x06, 0x00, 0x01, 0x08, 0x00, 0x06, 0x04, 0x00, 0x01, 0x84, 0x39, 0xc0, 0x0b, 0x22, 0x25,
+					0xc0, 0xa8, 0x0a, 0x1a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xa8, 0x0a, 0x19, 0x00, 0x00,
+					0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+				},
+			},
+			Out: &Result{},
+		},
+		{
+			Name: "ValidReplyPacket",
+			In: pcap.Packet{
+				B: []byte{
+					0x24, 0x4b, 0xfe, 0xe1, 0xea, 0x26, 0x80, 0x61, 0x5f, 0x08, 0xfc, 0x16, 0x08, 0x06, 0x00, 0x01,
+					0x08, 0x00, 0x06, 0x04, 0x00, 0x02, 0x80, 0x61, 0x5f, 0x08, 0xfc, 0x16, 0xc0, 0xa8, 0x01, 0x6c,
+					0x24, 0x4b, 0xfe, 0xe1, 0xea, 0x26, 0xc0, 0xa8, 0x01, 0x50,
+				},
+			},
+			Out: &Result{},
+		},
+		{
+			Name: "EmptyPacket",
+			Err:  ErrEmptyPacket,
+		},
+		{
+			Name: "MalformedPacket",
+			In: pcap.Packet{
+				B: []byte{
+					0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x84, 0x39, 0xc0, 0x0b, 0x22,
+					0x08, 0x06, 0x00, 0x01, 0x08, 0x00, 0x06, 0x04, 0x00, 0x01, 0x84,
+					0xc0, 0xa8, 0x0a, 0x1a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0,
+					0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+				},
+			},
+			Err: arp.ErrMalformedPacket,
+		},
+		{
+			Name: "ShortPacket",
+			In: pcap.Packet{
+				B: []byte{
+					0x24, 0x4b, 0xfe, 0xe1, 0xea, 0x26, 0x80, 0x61, 0x5f, 0x08, 0xfc, 0x16, 0x08, 0x06, 0x00, 0x01,
+					0x08, 0x00, 0x06, 0x04, 0x00, 0x02, 0x80, 0xfc, 0x16, 0xc0, 0xa8, 0x01, 0x6c,
+					0x24, 0x4b, 0xfe, 0xe1, 0xea, 0x26, 0xc0, 0xa8, 0x01, 0x50,
+				},
+			},
+			Err: io.ErrUnexpectedEOF,
+		},
+	}
+
+	svc := NewService("")
+
+	for _, tcase := range table {
+		t.Run(tcase.Name, func(tt *testing.T) {
+			res, err := svc.handlePacket(tcase.In)
+			assert.ErrorIsf(tt, err, tcase.Err, "expected handlePacket to return an error of: %s", tcase.Err)
+			assert.Equalf(tt, tcase.Out, res, "expected handlePacket to return a value of: %+v", tcase.Out)
+		})
+	}
+}
diff --git a/src/maasagent/scripts/get_golangci-lint.sh b/src/maasagent/scripts/get_golangci-lint.sh
new file mode 100644
index 0000000..291c47a
--- /dev/null
+++ b/src/maasagent/scripts/get_golangci-lint.sh
@@ -0,0 +1,412 @@
+#!/bin/sh
+set -e
+
+usage() {
+  this=$1
+  cat <<EOF
+$this: download go binaries for golangci/golangci-lint
+
+Usage: $this [-b] bindir [-d] [tag]
+  -b sets bindir or installation directory, Defaults to ./bin
+  -d turns on debug logging
+   [tag] is a tag from
+   https://github.com/golangci/golangci-lint/releases
+   If tag is missing, then the latest will be used.
+
+ Generated by godownloader
+  https://github.com/goreleaser/godownloader
+
+EOF
+  exit 2
+}
+
+parse_args() {
+  #BINDIR is ./bin unless set be ENV
+  # over-ridden by flag below
+
+  BINDIR=${BINDIR:-./bin}
+  while getopts "b:dh?x" arg; do
+    case "$arg" in
+      b) BINDIR="$OPTARG" ;;
+      d) log_set_priority 10 ;;
+      h | \?) usage "$0" ;;
+      x) set -x ;;
+    esac
+  done
+  shift $((OPTIND - 1))
+  TAG=$1
+}
+# this function wraps all the destructive operations
+# if a curl|bash cuts off the end of the script due to
+# network, either nothing will happen or will syntax error
+# out preventing half-done work
+execute() {
+  tmpdir=$(mktemp -d)
+  log_debug "downloading files into ${tmpdir}"
+  http_download "${tmpdir}/${TARBALL}" "${TARBALL_URL}"
+  http_download "${tmpdir}/${CHECKSUM}" "${CHECKSUM_URL}"
+  hash_sha256_verify "${tmpdir}/${TARBALL}" "${tmpdir}/${CHECKSUM}"
+  srcdir="${tmpdir}/${NAME}"
+  rm -rf "${srcdir}"
+  (cd "${tmpdir}" && untar "${TARBALL}")
+  test ! -d "${BINDIR}" && install -d "${BINDIR}"
+  for binexe in $BINARIES; do
+    if [ "$OS" = "windows" ]; then
+      binexe="${binexe}.exe"
+    fi
+    install "${srcdir}/${binexe}" "${BINDIR}/"
+    log_info "installed ${BINDIR}/${binexe}"
+  done
+  rm -rf "${tmpdir}"
+}
+get_binaries() {
+  case "$PLATFORM" in
+    darwin/amd64) BINARIES="golangci-lint" ;;
+    darwin/arm64) BINARIES="golangci-lint" ;;
+    darwin/armv6) BINARIES="golangci-lint" ;;
+    darwin/armv7) BINARIES="golangci-lint" ;;
+    darwin/mips64) BINARIES="golangci-lint" ;;
+    darwin/mips64le) BINARIES="golangci-lint" ;;
+    darwin/ppc64le) BINARIES="golangci-lint" ;;
+    darwin/s390x) BINARIES="golangci-lint" ;;
+    freebsd/386) BINARIES="golangci-lint" ;;
+    freebsd/amd64) BINARIES="golangci-lint" ;;
+    freebsd/armv6) BINARIES="golangci-lint" ;;
+    freebsd/armv7) BINARIES="golangci-lint" ;;
+    freebsd/mips64) BINARIES="golangci-lint" ;;
+    freebsd/mips64le) BINARIES="golangci-lint" ;;
+    freebsd/ppc64le) BINARIES="golangci-lint" ;;
+    freebsd/s390x) BINARIES="golangci-lint" ;;
+    linux/386) BINARIES="golangci-lint" ;;
+    linux/amd64) BINARIES="golangci-lint" ;;
+    linux/arm64) BINARIES="golangci-lint" ;;
+    linux/armv6) BINARIES="golangci-lint" ;;
+    linux/armv7) BINARIES="golangci-lint" ;;
+    linux/mips64) BINARIES="golangci-lint" ;;
+    linux/mips64le) BINARIES="golangci-lint" ;;
+    linux/ppc64le) BINARIES="golangci-lint" ;;
+    linux/s390x) BINARIES="golangci-lint" ;;
+    linux/riscv64) BINARIES="golangci-lint" ;;
+    netbsd/386) BINARIES="golangci-lint" ;;
+    netbsd/amd64) BINARIES="golangci-lint" ;;
+    netbsd/armv6) BINARIES="golangci-lint" ;;
+    netbsd/armv7) BINARIES="golangci-lint" ;;
+    windows/386) BINARIES="golangci-lint" ;;
+    windows/amd64) BINARIES="golangci-lint" ;;
+    windows/arm64) BINARIES="golangci-lint" ;;
+    windows/armv6) BINARIES="golangci-lint" ;;
+    windows/armv7) BINARIES="golangci-lint" ;;
+    windows/mips64) BINARIES="golangci-lint" ;;
+    windows/mips64le) BINARIES="golangci-lint" ;;
+    windows/ppc64le) BINARIES="golangci-lint" ;;
+    windows/s390x) BINARIES="golangci-lint" ;;
+    *)
+      log_crit "platform $PLATFORM is not supported.  Make sure this script is up-to-date and file request at https://github.com/${PREFIX}/issues/new";
+      exit 1
+      ;;
+  esac
+}
+tag_to_version() {
+  if [ -z "${TAG}" ]; then
+    log_info "checking GitHub for latest tag"
+  else
+    log_info "checking GitHub for tag '${TAG}'"
+  fi
+  REALTAG=$(github_release "$OWNER/$REPO" "${TAG}") && true
+  if test -z "$REALTAG"; then
+    log_crit "unable to find '${TAG}' - use 'latest' or see https://github.com/${PREFIX}/releases for details"
+    exit 1
+  fi
+  # if version starts with 'v', remove it
+  TAG="$REALTAG"
+  VERSION=${TAG#v}
+}
+adjust_format() {
+  # change format (tar.gz or zip) based on OS
+  case ${OS} in
+    windows) FORMAT=zip ;;
+  esac
+  true
+}
+adjust_os() {
+  # adjust archive name based on OS
+  true
+}
+adjust_arch() {
+  # adjust archive name based on ARCH
+  true
+}
+
+cat /dev/null <<EOF
+------------------------------------------------------------------------
+https://github.com/client9/shlib - portable posix shell functions
+Public domain - http://unlicense.org
+https://github.com/client9/shlib/blob/master/LICENSE.md
+but credit (and pull requests) appreciated.
+------------------------------------------------------------------------
+EOF
+is_command() {
+  command -v "$1" >/dev/null
+}
+echoerr() {
+  echo "$@" 1>&2
+}
+log_prefix() {
+  echo "$0"
+}
+_logp=6
+log_set_priority() {
+  _logp="$1"
+}
+log_priority() {
+  if test -z "$1"; then
+    echo "$_logp"
+    return
+  fi
+  [ "$1" -le "$_logp" ]
+}
+log_tag() {
+  case $1 in
+    0) echo "emerg" ;;
+    1) echo "alert" ;;
+    2) echo "crit" ;;
+    3) echo "err" ;;
+    4) echo "warning" ;;
+    5) echo "notice" ;;
+    6) echo "info" ;;
+    7) echo "debug" ;;
+    *) echo "$1" ;;
+  esac
+}
+log_debug() {
+  log_priority 7 || return 0
+  echoerr "$(log_prefix)" "$(log_tag 7)" "$@"
+}
+log_info() {
+  log_priority 6 || return 0
+  echoerr "$(log_prefix)" "$(log_tag 6)" "$@"
+}
+log_err() {
+  log_priority 3 || return 0
+  echoerr "$(log_prefix)" "$(log_tag 3)" "$@"
+}
+log_crit() {
+  log_priority 2 || return 0
+  echoerr "$(log_prefix)" "$(log_tag 2)" "$@"
+}
+uname_os() {
+  os=$(uname -s | tr '[:upper:]' '[:lower:]')
+  case "$os" in
+    msys*) os="windows" ;;
+    mingw*) os="windows" ;;
+    cygwin*) os="windows" ;;
+    win*) os="windows" ;;
+  esac
+  echo "$os"
+}
+uname_arch() {
+  arch=$(uname -m)
+  case $arch in
+    x86_64) arch="amd64" ;;
+    x86) arch="386" ;;
+    i686) arch="386" ;;
+    i386) arch="386" ;;
+    aarch64) arch="arm64" ;;
+    armv5*) arch="armv5" ;;
+    armv6*) arch="armv6" ;;
+    armv7*) arch="armv7" ;;
+  esac
+  echo ${arch}
+}
+uname_os_check() {
+  os=$(uname_os)
+  case "$os" in
+    darwin) return 0 ;;
+    dragonfly) return 0 ;;
+    freebsd) return 0 ;;
+    linux) return 0 ;;
+    android) return 0 ;;
+    nacl) return 0 ;;
+    netbsd) return 0 ;;
+    openbsd) return 0 ;;
+    plan9) return 0 ;;
+    solaris) return 0 ;;
+    windows) return 0 ;;
+  esac
+  log_crit "uname_os_check '$(uname -s)' got converted to '$os' which is not a GOOS value."
+  return 1
+}
+uname_arch_check() {
+  arch=$(uname_arch)
+  case "$arch" in
+    386) return 0 ;;
+    amd64) return 0 ;;
+    arm64) return 0 ;;
+    armv5) return 0 ;;
+    armv6) return 0 ;;
+    armv7) return 0 ;;
+    ppc64) return 0 ;;
+    ppc64le) return 0 ;;
+    mips) return 0 ;;
+    mipsle) return 0 ;;
+    mips64) return 0 ;;
+    mips64le) return 0 ;;
+    s390x) return 0 ;;
+    riscv64) return 0 ;;
+    amd64p32) return 0 ;;
+  esac
+  log_crit "uname_arch_check '$(uname -m)' got converted to '$arch' which is not a GOARCH value."
+  return 1
+}
+untar() {
+  tarball=$1
+  case "${tarball}" in
+    *.tar.gz | *.tgz) tar --no-same-owner -xzf "${tarball}" ;;
+    *.tar) tar --no-same-owner -xf "${tarball}" ;;
+    *.zip) unzip "${tarball}" ;;
+    *)
+      log_err "untar unknown archive format for ${tarball}"
+      return 1
+      ;;
+  esac
+}
+http_download_curl() {
+  local_file=$1
+  source_url=$2
+  header=$3
+  if [ -z "$header" ]; then
+    code=$(curl -w '%{http_code}' -sL -o "$local_file" "$source_url")
+  else
+    code=$(curl -w '%{http_code}' -sL -H "$header" -o "$local_file" "$source_url")
+  fi
+  if [ "$code" != "200" ]; then
+    log_debug "http_download_curl received HTTP status $code"
+    return 1
+  fi
+  return 0
+}
+http_download_wget() {
+  local_file=$1
+  source_url=$2
+  header=$3
+  if [ -z "$header" ]; then
+    wget -q -O "$local_file" "$source_url"
+  else
+    wget -q --header "$header" -O "$local_file" "$source_url"
+  fi
+}
+http_download() {
+  log_debug "http_download $2"
+  if is_command curl; then
+    http_download_curl "$@"
+    return
+  elif is_command wget; then
+    http_download_wget "$@"
+    return
+  fi
+  log_crit "http_download unable to find wget or curl"
+  return 1
+}
+http_copy() {
+  tmp=$(mktemp)
+  http_download "${tmp}" "$1" "$2" || return 1
+  body=$(cat "$tmp")
+  rm -f "${tmp}"
+  echo "$body"
+}
+github_release() {
+  owner_repo=$1
+  version=$2
+  test -z "$version" && version="latest"
+  giturl="https://github.com/${owner_repo}/releases/${version}";
+  json=$(http_copy "$giturl" "Accept:application/json")
+  test -z "$json" && return 1
+  version=$(echo "$json" | tr -s '\n' ' ' | sed 's/.*"tag_name":"//' | sed 's/".*//')
+  test -z "$version" && return 1
+  echo "$version"
+}
+hash_sha256() {
+  TARGET=${1:-/dev/stdin}
+  if is_command gsha256sum; then
+    hash=$(gsha256sum "$TARGET") || return 1
+    echo "$hash" | cut -d ' ' -f 1
+  elif is_command sha256sum; then
+    hash=$(sha256sum "$TARGET") || return 1
+    echo "$hash" | cut -d ' ' -f 1
+  elif is_command shasum; then
+    hash=$(shasum -a 256 "$TARGET" 2>/dev/null) || return 1
+    echo "$hash" | cut -d ' ' -f 1
+  elif is_command openssl; then
+    hash=$(openssl -dst openssl dgst -sha256 "$TARGET") || return 1
+    echo "$hash" | cut -d ' ' -f a
+  else
+    log_crit "hash_sha256 unable to find command to compute sha-256 hash"
+    return 1
+  fi
+}
+hash_sha256_verify() {
+  TARGET=$1
+  checksums=$2
+  if [ -z "$checksums" ]; then
+    log_err "hash_sha256_verify checksum file not specified in arg2"
+    return 1
+  fi
+  BASENAME=${TARGET##*/}
+  want=$(grep "${BASENAME}" "${checksums}" 2>/dev/null | tr '\t' ' ' | cut -d ' ' -f 1)
+  if [ -z "$want" ]; then
+    log_err "hash_sha256_verify unable to find checksum for '${TARGET}' in '${checksums}'"
+    return 1
+  fi
+  got=$(hash_sha256 "$TARGET")
+  if [ "$want" != "$got" ]; then
+    log_err "hash_sha256_verify checksum for '$TARGET' did not verify ${want} vs $got"
+    return 1
+  fi
+}
+cat /dev/null <<EOF
+------------------------------------------------------------------------
+End of functions from https://github.com/client9/shlib
+------------------------------------------------------------------------
+EOF
+
+PROJECT_NAME="golangci-lint"
+OWNER=golangci
+REPO="golangci-lint"
+BINARY=golangci-lint
+FORMAT=tar.gz
+OS=$(uname_os)
+ARCH=$(uname_arch)
+PREFIX="$OWNER/$REPO"
+
+# use in logging routines
+log_prefix() {
+	echo "$PREFIX"
+}
+PLATFORM="${OS}/${ARCH}"
+GITHUB_DOWNLOAD=https://github.com/${OWNER}/${REPO}/releases/download
+
+uname_os_check "$OS"
+uname_arch_check "$ARCH"
+
+parse_args "$@"
+
+get_binaries
+
+tag_to_version
+
+adjust_format
+
+adjust_os
+
+adjust_arch
+
+log_info "found version: ${VERSION} for ${TAG}/${OS}/${ARCH}"
+
+NAME=${BINARY}-${VERSION}-${OS}-${ARCH}
+TARBALL=${NAME}.${FORMAT}
+TARBALL_URL=${GITHUB_DOWNLOAD}/${TAG}/${TARBALL}
+CHECKSUM=${PROJECT_NAME}-${VERSION}-checksums.txt
+CHECKSUM_URL=${GITHUB_DOWNLOAD}/${TAG}/${CHECKSUM}
+
+
+execute

References