package main import ( "bytes" "compress/gzip" "container/heap" _ "embed" "fmt" "html/template" "io" "log" "net" "net/http" "net/netip" "os" "sort" "strconv" "strings" "sync" "time" "github.com/lemmi/closer" "git.freifunk-franken.de/jkimmel/abbel/packet" "git.freifunk-franken.de/jkimmel/abbel/tlv" ) var ( startTime = time.Now() ) type route struct { Dst netip.Prefix UnreachableDuration time.Duration UnreachableSince time.Time ReachableSince time.Time Counter uint Hostname string LastUpdate time.Time gqIdx int } func (r route) DurationUntilRounded(t time.Time) time.Duration { return r.DurationUntil(t).Round(time.Second) } func (r route) DurationUntil(t time.Time) time.Duration { ret := r.UnreachableDuration if !r.UnreachableSince.IsZero() { ret += t.Sub(r.UnreachableSince) } return ret } func (r route) Downtime(now time.Time) float64 { return float64(r.DurationUntil(now)) / float64(now.Sub(startTime)) * 100 } func (r route) Noise(now time.Time) bool { return r.UnreachableSince.IsZero() && r.Counter <= uint(now.Sub(startTime).Hours())+10 && r.Downtime(now) < 0.5 } func (r route) LastUpdateUntil(t time.Time) time.Duration { return t.Sub(r.LastUpdate).Round(time.Second) } func (r route) String() string { now := time.Now() return fmt.Sprintf("%44s %5d %2.3f%% %s", r.Dst, r.Counter, r.Downtime(now), r.DurationUntil(now)) } type gcQueue []*route var _ heap.Interface = &gcQueue{} func (gcq gcQueue) Len() int { return len(gcq) } func (gcq gcQueue) Less(i, j int) bool { return gcq[i].LastUpdate.Before(gcq[j].LastUpdate) } func (gcq gcQueue) Swap(i, j int) { gcq[i], gcq[j] = gcq[j], gcq[i] gcq[i].gqIdx = i gcq[j].gqIdx = j } func (gcq *gcQueue) Push(x interface{}) { r := x.(*route) r.gqIdx = len(*gcq) *gcq = append(*gcq, r) } func (gcq *gcQueue) Pop() interface{} { last := len(*gcq) - 1 ret := (*gcq)[last] ret.gqIdx = -1 (*gcq)[last] = nil *gcq = (*gcq)[:last] return ret } type routeStats struct { sync.Mutex stats map[netip.Prefix]*route gcstats map[netip.Prefix]*route gq gcQueue } func newRouteStats() *routeStats { rs := &routeStats{ stats: make(map[netip.Prefix]*route), gcstats: make(map[netip.Prefix]*route), gq: gcQueue{}, } heap.Init(&rs.gq) return rs } func (rs *routeStats) add(prefix netip.Prefix) { rs.Lock() defer rs.Unlock() r := rs.stats[prefix] if r == nil { r = rs.gcstats[prefix] if r == nil { r = &route{Dst: prefix} go func(r *route, rs *routeStats) { names, err := net.LookupAddr(r.Dst.Addr().String()) if err != nil || len(names) == 0 { return } if strings.HasSuffix(names[0], ".rdns.f3netze.de.") { return } rs.Lock() r.Hostname = strings.TrimRight(names[0], ".") rs.Unlock() }(r, rs) } else { delete(rs.gcstats, r.Dst) r.LastUpdate = time.Time{} r.UnreachableSince = time.Time{} } } now := time.Now() if r.UnreachableSince.IsZero() { r.UnreachableSince = now r.ReachableSince = time.Time{} r.Counter++ } oldLastUpdate := r.LastUpdate r.LastUpdate = now if oldLastUpdate.IsZero() { heap.Push(&rs.gq, r) } else { heap.Fix(&rs.gq, r.gqIdx) } rs.stats[prefix] = r } func (rs *routeStats) del(prefix netip.Prefix) { rs.Lock() defer rs.Unlock() var r *route if r = rs.gcstats[prefix]; r != nil { delete(rs.gcstats, prefix) return } if r = rs.stats[prefix]; r == nil { return } now := time.Now() if !r.UnreachableSince.IsZero() { r.UnreachableDuration += time.Since(r.UnreachableSince) r.UnreachableSince = time.Time{} r.ReachableSince = now } r.LastUpdate = now heap.Fix(&rs.gq, r.gqIdx) rs.stats[prefix] = r } func (rs *routeStats) runGC() { rs.Lock() defer rs.Unlock() now := time.Now() for len(rs.gq) > 0 { first := rs.gq[0] if first.LastUpdate.Add(3 * time.Minute).Before(now) { heap.Pop(&rs.gq) delete(rs.stats, first.Dst) rs.gcstats[first.Dst] = first } else { break } } } func (rs *routeStats) getCollected() []route { ret := make([]route, 0, len(rs.gcstats)) rs.Lock() defer rs.Unlock() for _, v := range rs.gcstats { ret = append(ret, *v) } sort.Slice(ret, func(i, j int) bool { return ret[i].LastUpdate.Before(ret[j].LastUpdate) }) return ret } func (rs *routeStats) getAll() []route { ret := make([]route, 0, len(rs.stats)) rs.Lock() defer rs.Unlock() for _, v := range rs.stats { ret = append(ret, *v) } return ret } func (rs *routeStats) getLongest() []route { ret := rs.getAll() t := time.Now() sort.Slice(ret, func(i, j int) bool { id := ret[i].Downtime(t) jd := ret[j].Downtime(t) if ret[i].Counter != ret[j].Counter && (id == jd || (id < 0.5 && jd < 0.5)) { return ret[i].Counter > ret[j].Counter } return id > jd }) return ret } func monitor(done <-chan struct{}, rs *routeStats) error { conn, err := packet.Listen("ff02:0:0:0:0:0:1:6", 6696, "any") if err != nil { return err } go func() { <-done closer.Do(conn) }() buf := [4096]byte{} var s tlv.PacketDecoder for { b, src, ifindex, err := conn.ReadFrom(buf[:]) if err != nil { fmt.Println("Skipping packet:", err) continue } s.Reset(b, src, ifindex) for s.Scan() { switch t := s.TLV().(type) { case tlv.Update: if t.Metric == 0xffff { rs.add(t.Prefix) } else { rs.del(t.Prefix) } } } rs.runGC() } } func render(w io.Writer, rs []route, gc []route) error { const tmplHTML = ` FFF Wall-of-not-so-reachable-networks

FFF Wall-of-not-so-reachable-networks

Start:{{ .Start.Format "2006-01-02 15:04:05" }}
Update:{{ .Now.Format "2006-01-02 15:04:05" }}
Show All {{- $now := .Now -}} {{- range .R }} {{- end}} {{with .GC}} {{- range . }} {{- end}} {{- end}}
Net Hostname Downtime Last
Counts rel abs Update

{{.Dst.String}} {{with .Hostname}}{{.}}{{end}} {{.Counter}} {{.Downtime $now | printf "%.3f%%"}} {{.DurationUntilRounded $now}} {{.LastUpdateUntil $now}}
 
Garbage Collected

{{.Dst.String}} {{.LastUpdate.Format "2006-01-02 15:04:05"}}
` data := struct { Now time.Time Start time.Time R []route GC []route }{ time.Now(), startTime, rs, gc, } t := template.Must(template.New("main").Parse(tmplHTML)) return t.Execute(w, data) } func compress(cw io.Writer, cr io.Reader, err error) error { if err != nil { return err } gzw, err := gzip.NewWriterLevel(cw, gzip.BestCompression) if err != nil { return err } if _, err = io.Copy(gzw, cr); err != nil { return err } return gzw.Close() } func acceptGzip(h http.Header) bool { for _, s := range strings.Split(h.Get("Accept-Encoding"), ",") { if strings.TrimSpace(s) == "gzip" { return true } } return false } func cachedRender(rs *routeStats) http.HandlerFunc { var m sync.Mutex var timestamp time.Time var content bytes.Buffer var compressed bytes.Buffer return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) return } if strings.TrimLeft(r.URL.Path, "/") == "favicon.ico" { w.Header().Add("Cache-Control", "max-age=300") w.Header().Set("Content-Length", strconv.FormatInt(int64(len(favico)), 10)) http.ServeContent(w, r, r.URL.Path, startTime, bytes.NewReader(favico)) return } if r.URL.Path != "/" { http.NotFound(w, r) return } var err error m.Lock() if time.Since(timestamp) > 5*time.Second { content.Reset() compressed.Reset() err = render(&content, rs.getLongest(), rs.getCollected()) err = compress(&compressed, bytes.NewReader(content.Bytes()), err) timestamp = time.Now() } m.Unlock() if err != nil { fmt.Fprintln(os.Stderr, err) fmt.Fprintln(w, err) return } w.Header().Add("Cache-Control", "max-age=5") w.Header().Add("Content-Type", "text/html; charset=utf-8") c := content if acceptGzip(r.Header) { c = compressed w.Header().Add("Content-Encoding", "gzip") } w.Header().Set("Content-Length", strconv.FormatInt(int64(c.Len()), 10)) http.ServeContent(w, r, "/", timestamp, bytes.NewReader(c.Bytes())) } } func main() { rs := newRouteStats() done := make(chan struct{}) go func() { err := monitor(done, rs) if err != nil { log.Fatal(err) } }() log.Fatal(http.ListenAndServe(":8080", cachedRender(rs))) } //go:embed favicon.ico var favico []byte