518 lines
10 KiB
Go
518 lines
10 KiB
Go
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 = `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>FFF Wall-of-not-so-reachable-networks</title>
|
|
<style>
|
|
html {
|
|
margin: 0 auto;
|
|
padding: 0;
|
|
font-size: 11pt;
|
|
}
|
|
body {
|
|
font-family: sans-serif;
|
|
text-align: center;
|
|
}
|
|
table {
|
|
border-spacing: 0;
|
|
margin: auto;
|
|
}
|
|
td, th {
|
|
padding: 0.1em 2em;
|
|
}
|
|
td {
|
|
text-align: right;
|
|
font-family: monospace;
|
|
}
|
|
.dst, .hostname {
|
|
text-align: left;
|
|
}
|
|
.unreachable {
|
|
color: darkred;
|
|
background-color: lightgray;
|
|
}
|
|
table tr.noise {
|
|
display: none;
|
|
}
|
|
tbody tr:nth-child(n+2):hover {
|
|
background-color: silver;
|
|
}
|
|
#showAll:checked ~ table tr.noise {
|
|
color: midnightblue;
|
|
display: table-row;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>FFF Wall-of-not-so-reachable-networks</h1>
|
|
|
|
<table>
|
|
<tr><td> Start:</td><td>{{ .Start.Format "2006-01-02 15:04:05" }}</td></tr>
|
|
<tr><td>Update:</td><td>{{ .Now.Format "2006-01-02 15:04:05" }}</td></tr>
|
|
</table>
|
|
|
|
Show All <input id="showAll" type="checkbox">
|
|
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Net</th>
|
|
<th>Hostname</th>
|
|
<th colspan="3">Downtime</th>
|
|
<th>Last</th>
|
|
</tr>
|
|
<tr>
|
|
<th></th>
|
|
<th></th>
|
|
<th>Counts</th>
|
|
<th>rel</th>
|
|
<th>abs</th>
|
|
<th>Update</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr><td colspan="6"><hr/></td></tr>
|
|
{{- $now := .Now -}}
|
|
{{- range .R }}
|
|
<tr class="
|
|
{{- if not .UnreachableSince.IsZero }}unreachable{{end -}}
|
|
{{- if .Noise $now }} noise{{end -}}
|
|
">
|
|
<td class="dst">{{.Dst.String}}</td>
|
|
<td class="hostname">{{with .Hostname}}<a href="//{{.}}" target="_blank">{{.}}</a>{{end}}</td>
|
|
<td class="counter">{{.Counter}}</td>
|
|
<td class="downtime">{{.Downtime $now | printf "%.3f%%"}}</td>
|
|
<td class="duration">{{.DurationUntilRounded $now}}</td>
|
|
<td class="duration">{{.LastUpdateUntil $now}}</td>
|
|
</tr>
|
|
{{- end}}
|
|
</tbody>
|
|
{{with .GC}}
|
|
<tbody>
|
|
<tr><td colspan="6"> </td></tr>
|
|
</tbody>
|
|
<thead>
|
|
<tr><th colspan="6">Garbage Collected</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr><td colspan="6"><hr/></td></tr>
|
|
{{- range . }}
|
|
<tr>
|
|
<td class="dst">{{.Dst.String}}</td>
|
|
<td colspan="3"></td>
|
|
<td colspan="2">{{.LastUpdate.Format "2006-01-02 15:04:05"}}</td>
|
|
</tr>
|
|
{{- end}}
|
|
</tbody>
|
|
{{- end}}
|
|
</table>
|
|
</body>
|
|
</html>
|
|
`
|
|
|
|
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
|