From 6320b6c3a76e2727d35cd4e2324ddfc43d747ea5 Mon Sep 17 00:00:00 2001 From: Michael Stapelberg Date: Sun, 6 Jan 2019 18:02:01 +0100 Subject: [PATCH] =?UTF-8?q?dhcp4d:=20display=20MAC=20vendor=20of=20each=20?= =?UTF-8?q?lease=E2=80=99s=20HardwareAddr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/dhcp4d/dhcp4d.go | 12 +++ internal/oui/oui.go | 168 +++++++++++++++++++++++++++++++++++++++ internal/oui/oui_test.go | 146 ++++++++++++++++++++++++++++++++++ 3 files changed, 326 insertions(+) create mode 100644 internal/oui/oui.go create mode 100644 internal/oui/oui_test.go diff --git a/cmd/dhcp4d/dhcp4d.go b/cmd/dhcp4d/dhcp4d.go index 10fdb14..2ee037e 100644 --- a/cmd/dhcp4d/dhcp4d.go +++ b/cmd/dhcp4d/dhcp4d.go @@ -18,6 +18,7 @@ package main import ( "encoding/json" "flag" + "fmt" "io/ioutil" "net" "net/http" @@ -37,6 +38,7 @@ import ( "github.com/rtr7/router7/internal/dhcp4d" "github.com/rtr7/router7/internal/multilisten" "github.com/rtr7/router7/internal/notify" + "github.com/rtr7/router7/internal/oui" "github.com/rtr7/router7/internal/teelogger" ) @@ -59,6 +61,8 @@ func updateNonExpired(leases []*dhcp4d.Lease) { nonExpiredLeases.Set(float64(nonExpired)) } +var ouiDB = oui.NewDB("/perm/dhcp4d/oui") + func loadLeases(h *dhcp4d.Handler, fn string) error { b, err := ioutil.ReadFile(fn) if err != nil { @@ -73,6 +77,14 @@ func loadLeases(h *dhcp4d.Handler, fn string) error { } h.SetLeases(leases) updateNonExpired(leases) + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // TODO: html template + for _, l := range leases { + fmt.Fprintf(w, "• %+v (vendor %v)\n", l, ouiDB.Lookup(l.HardwareAddr[:8])) + } + }) + return nil } diff --git a/internal/oui/oui.go b/internal/oui/oui.go new file mode 100644 index 0000000..56abca5 --- /dev/null +++ b/internal/oui/oui.go @@ -0,0 +1,168 @@ +package oui + +import ( + "encoding/csv" + "encoding/hex" + "fmt" + "io" + "io/ioutil" + "log" + "net/http" + "os" + "path/filepath" + "sync" + + "github.com/google/renameio" +) + +// DB is a IEEE MA-L (MAC Address Block Large, formerly known as OUI) database. +type DB struct { + dir string // where to store our cache of oui.csv + ouiURL string // can be overridden for testing + + sync.Mutex + cond *sync.Cond + loaded bool + err error + + // orgs is a map from assignment (e.g. f0:9f:c2) to organization name + // (e.g. Ubiquiti Networks Inc.), gathered from the IEEE MA-L (MAC Address + // Block Large, formerly known as OUI): + // https://regauth.standards.ieee.org/standards-ra-web/pub/view.html#registries + orgs map[string]string +} + +// NewDB loads a database from the cached version in dir, if any, and +// asynchronously triggers an update. Use WaitUntilLoaded() to ensure Lookup() +// will work, or use Lookup() opportunistically at any time. +func NewDB(dir string) *DB { + db := &DB{ + dir: dir, + ouiURL: "http://standards-oui.ieee.org/oui/oui.csv", + } + db.cond = sync.NewCond(&db.Mutex) + go db.update() + return db +} + +// Lookup returns the organization name for the specified assignment, if +// found. Assignment is a large MAC address block assignment, e.g. f0:9f:c2. +func (d *DB) Lookup(assignment string) string { + d.Lock() + defer d.Unlock() + return d.orgs[assignment] +} + +// WaitUntilLoaded blocks until the database was loaded. +func (d *DB) WaitUntilLoaded() error { + d.Lock() + defer d.Unlock() + for !d.loaded { + d.cond.Wait() + } + return d.err +} + +func (d *DB) setErr(err error) { + d.Lock() + defer d.Unlock() + d.loaded = true + d.cond.Broadcast() + d.err = err +} + +func (d *DB) update() { + req, err := http.NewRequest("GET", d.ouiURL, nil) + if err != nil { + d.setErr(err) + return + } + + csvPath := filepath.Join(d.dir, "oui.csv") + // If any version exists, load it so that lookups work ASAP: + if f, err := os.Open(csvPath); err == nil { + if st, err := f.Stat(); err == nil { + req.Header.Set("If-Modified-Since", st.ModTime().UTC().Format(http.TimeFormat)) + } + defer f.Close() + if err := d.load(f); err != nil { + // Force a re-download in case our file is corrupted: + req.Header.Del("If-Modified-Since") + } + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + d.setErr(err) + return + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotModified { + d.setErr(nil) + return // already up-to-date + } + if got, want := resp.StatusCode, http.StatusOK; got != want { + body, _ := ioutil.ReadAll(resp.Body) + d.setErr(fmt.Errorf("%s: unexpected HTTP status: got %v, want %v (%v)", d.ouiURL, resp.Status, want, body)) + return + } + if err := os.MkdirAll(d.dir, 0755); err != nil { + d.setErr(err) + return + } + f, err := renameio.TempFile(d.dir, csvPath) + if err != nil { + d.setErr(err) + return + } + defer f.Cleanup() + if _, err := io.Copy(f, resp.Body); err != nil { + d.setErr(err) + return + } + if t, err := http.ParseTime(resp.Header.Get("Last-Modified")); err == nil { + if err := os.Chtimes(f.Name(), t, t); err != nil { + log.Print(err) + } + } + if err := f.CloseAtomicallyReplace(); err != nil { + d.setErr(err) + return + } + { + f, err := os.Open(csvPath) + if err != nil { + d.setErr(err) + return + } + defer f.Close() + d.setErr(d.load(f)) + } +} + +func (d *DB) load(r io.Reader) error { + // As of 2019-01, we’re talking < 30000 records. + records, err := csv.NewReader(r).ReadAll() + if err != nil { + return err + } + orgs := make(map[string]string, len(records)) + var buf [3]byte + for _, record := range records[1:] { + assignment, org := record[1], record[2] + n, err := hex.Decode(buf[:], []byte(assignment)) + if err != nil { + return fmt.Errorf("hex.Decode(%s): %v", assignment, err) + } + if got, want := n, 3; got != want { + return fmt.Errorf("decode assignment %q: got %d bytes, want %d bytes", assignment, got, want) + } + orgs[fmt.Sprintf("%02x:%02x:%02x", buf[0], buf[1], buf[2])] = org + } + d.Lock() + defer d.Unlock() + d.orgs = orgs + d.loaded = true + d.cond.Broadcast() + return nil +} diff --git a/internal/oui/oui_test.go b/internal/oui/oui_test.go new file mode 100644 index 0000000..a37fcb6 --- /dev/null +++ b/internal/oui/oui_test.go @@ -0,0 +1,146 @@ +package oui + +import ( + "io" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +func TestDB(t *testing.T) { + t.Parallel() + + const ( + ubiquitiBlock = "f0:9f:c2" + salcompBlock = "44:09:b8" + ) + + tmpdir, err := ioutil.TempDir("", "oui") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpdir) + + t.Run("FromScratch", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Last-Modified", "Sun, 06 Jan 2019 15:03:46 GMT") + io.WriteString(w, `Registry,Assignment,Organization Name,Organization Address +MA-L,F09FC2,Ubiquiti Networks Inc.,2580 Orchard Parkway San Jose CA US 95131 +MA-L,4409B8,"Salcomp (Shenzhen) CO., LTD.","Salcomp Road, Furong Industrial Area, Xinqiao, Shajing, Baoan District Shenzhen Guangdong CN 518125 " +`) + })) + defer srv.Close() + + db := NewDB(tmpdir) + db.ouiURL = srv.URL + if err := db.WaitUntilLoaded(); err != nil { + t.Fatal(err) + } + + if got, want := db.Lookup(ubiquitiBlock), "Ubiquiti Networks Inc."; got != want { + t.Errorf("db.Lookup(%q) = %v, want %v", ubiquitiBlock, got, want) + } + + if got, want := db.Lookup(salcompBlock), "Salcomp (Shenzhen) CO., LTD."; got != want { + t.Errorf("db.Lookup(%q) = %v, want %v", salcompBlock, got, want) + } + }) + + t.Run("BrokenUpstream", func(t *testing.T) { + unblock := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-unblock + http.Error(w, "yuck!", http.StatusInternalServerError) + })) + defer srv.Close() + + db := NewDB(tmpdir) + db.ouiURL = srv.URL + if err := db.WaitUntilLoaded(); err != nil { + t.Fatal(err) + } + + if got, want := db.Lookup(ubiquitiBlock), "Ubiquiti Networks Inc."; got != want { + t.Errorf("db.Lookup(%q) = %v, want %v", ubiquitiBlock, got, want) + } + + db.Lock() + db.loaded = false // reset so that we can wait again + db.Unlock() + unblock <- struct{}{} + if err := db.WaitUntilLoaded(); err == nil { + t.Fatal("db.WaitUntilLoaded returned no error despite HTTP 500") + } + + if got, want := db.Lookup(ubiquitiBlock), "Ubiquiti Networks Inc."; got != want { + t.Errorf("db.Lookup(%q) = %v, want %v", ubiquitiBlock, got, want) + } + }) + + t.Run("NoUpdates", func(t *testing.T) { + unblock := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-unblock + w.WriteHeader(http.StatusNotModified) + })) + defer srv.Close() + + db := NewDB(tmpdir) + db.ouiURL = srv.URL + if err := db.WaitUntilLoaded(); err != nil { + t.Fatal(err) + } + + if got, want := db.Lookup(ubiquitiBlock), "Ubiquiti Networks Inc."; got != want { + t.Errorf("db.Lookup(%q) = %v, want %v", ubiquitiBlock, got, want) + } + + db.Lock() + db.loaded = false // reset so that we can wait again + db.Unlock() + unblock <- struct{}{} + if err := db.WaitUntilLoaded(); err != nil { + t.Fatal(err) + } + + if got, want := db.Lookup(ubiquitiBlock), "Ubiquiti Networks Inc."; got != want { + t.Errorf("db.Lookup(%q) = %v, want %v", ubiquitiBlock, got, want) + } + }) + + t.Run("Update", func(t *testing.T) { + unblock := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-unblock + w.Header().Set("Last-Modified", "Sun, 06 Jan 2019 15:03:49 GMT") + io.WriteString(w, `Registry,Assignment,Organization Name,Organization Address +MA-L,F09FC2,Obiquiti Networks Inc.,2580 Orchard Parkway San Jose CA US 95131 +`) + })) + defer srv.Close() + + db := NewDB(tmpdir) + db.ouiURL = srv.URL + if err := db.WaitUntilLoaded(); err != nil { + t.Fatal(err) + } + + if got, want := db.Lookup(ubiquitiBlock), "Ubiquiti Networks Inc."; got != want { + t.Errorf("db.Lookup(%q) = %v, want %v", ubiquitiBlock, got, want) + } + + db.Lock() + db.loaded = false // reset so that we can wait again + db.Unlock() + unblock <- struct{}{} + if err := db.WaitUntilLoaded(); err != nil { + t.Fatal(err) + } + + if got, want := db.Lookup(ubiquitiBlock), "Obiquiti Networks Inc."; got != want { + t.Errorf("db.Lookup(%q) = %v, want %v", ubiquitiBlock, got, want) + } + }) +}