dhcp4d: display MAC vendor of each lease’s HardwareAddr

This commit is contained in:
Michael Stapelberg 2019-01-06 18:02:01 +01:00
parent 8df6329209
commit 6320b6c3a7
3 changed files with 326 additions and 0 deletions

View File

@ -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
}

168
internal/oui/oui.go Normal file
View File

@ -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, were 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
}

146
internal/oui/oui_test.go Normal file
View File

@ -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)
}
})
}