add the diagnostics daemon

This commit is contained in:
Michael Stapelberg 2018-06-03 20:04:11 +02:00
parent 7164b27041
commit 518b9f843c
9 changed files with 690 additions and 0 deletions

63
cmd/diagd/diagd.go Normal file
View File

@ -0,0 +1,63 @@
// Binary diagd provides automated network diagnostics.
package main
import (
"flag"
"fmt"
"html"
"io"
"log"
"net/http"
"sync"
"router7/internal/diag"
)
func dump(w io.Writer, re *diag.EvalResult) {
symbol := "✔"
if re.Error {
symbol = "✘"
}
fmt.Fprintf(w, "<li>%s %s: %s<ul>", symbol, html.EscapeString(re.Name), html.EscapeString(re.Status))
for _, ch := range re.Children {
dump(w, ch)
}
fmt.Fprintf(w, "</ul></li>")
}
func logic() error {
const (
uplink = "uplink0" /* enp0s31f6 */
ip6allrouters = "ff02::2" // no /etc/hosts on gokrazy
)
m := diag.NewMonitor(diag.Link(uplink).
Then(diag.DHCPv4().
Then(diag.Ping4Gateway().
Then(diag.Ping4("google.ch").
Then(diag.TCP4("www.google.ch:80"))))).
Then(diag.DHCPv6().
Then(diag.Ping6("lan0", "google.ch"))).
Then(diag.RouterAdvertisments(uplink).
Then(diag.Ping6Gateway().
Then(diag.Ping6(uplink, "google.ch").
Then(diag.TCP6("www.google.ch:80"))))).
Then(diag.Ping6("", ip6allrouters+"%"+uplink)))
var mu sync.Mutex
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
re := m.Evaluate()
mu.Unlock()
fmt.Fprintf(w, `<!DOCTYPE html><style type="text/css">ul { list-style-type: none; }</style><ul>`)
dump(w, re)
})
// TODO: only listen on private IP addresses
return http.ListenAndServe(":7733", nil)
}
func main() {
flag.Parse()
if err := logic(); err != nil {
log.Fatal(err)
}
}

79
internal/diag/dhcp.go Normal file
View File

@ -0,0 +1,79 @@
package diag
import (
"encoding/json"
"fmt"
"io/ioutil"
"time"
)
func leaseValid(fn string) (status string, _ error) {
var lease struct {
ValidUntil time.Time `json:"valid_until"`
}
b, err := ioutil.ReadFile(fn)
if err != nil {
return "", err
}
if err := json.Unmarshal(b, &lease); err != nil {
return "", err
}
if time.Now().After(lease.ValidUntil) {
return "", fmt.Errorf("lease expired at %v", lease.ValidUntil)
}
return fmt.Sprintf("lease valid until %v", lease.ValidUntil), nil
}
type dhcpv4 struct {
children []Node
}
func (d *dhcpv4) String() string {
return "dhcp4"
}
func (d *dhcpv4) Then(t Node) Node {
d.children = append(d.children, t)
return d
}
func (d *dhcpv4) Children() []Node {
return d.children
}
func (d *dhcpv4) Evaluate() (string, error) {
return leaseValid("/perm/dhcp4/wire/lease.json")
}
// DHCPv4 returns a Node which succeeds if /perm/dhcp4/wire/lease.json contains
// a non-expired DHCPv4 lease.
func DHCPv4() Node {
return &dhcpv4{}
}
type dhcpv6 struct {
children []Node
}
func (d *dhcpv6) String() string {
return "dhcp6"
}
func (d *dhcpv6) Then(t Node) Node {
d.children = append(d.children, t)
return d
}
func (d *dhcpv6) Children() []Node {
return d.children
}
func (d *dhcpv6) Evaluate() (string, error) {
return leaseValid("/perm/dhcp6/wire/lease.json")
}
// DHCPv6 returns a Node which succeeds if /perm/dhcp6/wire/lease.json contains
// a non-expired DHCPv6 lease.
func DHCPv6() Node {
return &dhcpv6{}
}

54
internal/diag/diag.go Normal file
View File

@ -0,0 +1,54 @@
// Package diag implements network diagnostics.
package diag
import "fmt"
type Node interface {
Then(t Node) Node
Children() []Node
Evaluate() (status string, _ error)
}
type Monitor struct {
root Node
}
func NewMonitor(n Node) *Monitor {
return &Monitor{root: n}
}
type EvalResult struct {
Name string
Error bool
Status string
Children []*EvalResult
}
func evaluate(n Node, err string) *EvalResult {
r := EvalResult{
Name: fmt.Sprintf("%s", n),
Status: err,
Error: err != "",
}
if r.Status == "" {
status, err := n.Evaluate()
if err != nil {
r.Error = true
r.Status = err.Error()
} else {
r.Status = status
}
}
var childErr string
if r.Error {
childErr = fmt.Sprintf("dependency %s failed", r.Name)
}
for _, n := range n.Children() {
r.Children = append(r.Children, evaluate(n, childErr))
}
return &r
}
func (m *Monitor) Evaluate() *EvalResult {
return evaluate(m.root, "")
}

View File

@ -0,0 +1,39 @@
package diag_test
import (
"router7/internal/diag"
"testing"
"github.com/google/go-cmp/cmp"
)
func TestDiagLink(t *testing.T) {
if _, err := diag.Link("nonexistant").Evaluate(); err == nil {
t.Errorf("Link(nonexistant).Evaluate = nil, want non-nil")
}
if _, err := diag.Link("lo").Evaluate(); err != nil {
t.Errorf("Link(lo).Evaluate = %v, want nil", err)
}
}
func TestDiagMonitor(t *testing.T) {
m := diag.NewMonitor(diag.Link("nonexistant").
Then(diag.DHCPv4()))
got := m.Evaluate()
want := &diag.EvalResult{
Name: "link/nonexistant",
Error: true,
Status: "Link not found",
Children: []*diag.EvalResult{
{
Name: "dhcp4",
Error: true,
Status: "dependency link/nonexistant failed",
},
},
}
if diff := cmp.Diff(got, want); diff != "" {
t.Fatalf("Evaluate(): unexpected result: diff (-got +want):\n%s", diff)
}
}

12
internal/diag/ipv6.go Normal file
View File

@ -0,0 +1,12 @@
package diag
import "net"
var (
global = mustParseCIDR("2000::/3") // RFC 4291
)
func mustParseCIDR(s string) *net.IPNet {
_, ipnet, _ := net.ParseCIDR(s)
return ipnet
}

47
internal/diag/link.go Normal file
View File

@ -0,0 +1,47 @@
package diag
import (
"fmt"
"net"
"github.com/vishvananda/netlink"
)
type link struct {
children []Node
ifname string
}
func (l *link) String() string {
return "link/" + l.ifname
}
func (l *link) Then(t Node) Node {
l.children = append(l.children, t)
return l
}
func (l *link) Children() []Node {
return l.children
}
func (l *link) Evaluate() (string, error) {
link, err := netlink.LinkByName(l.ifname)
if err != nil {
return "", err
}
attrs := link.Attrs()
// TODO: check RUNNING as well?
if attrs.Flags&net.FlagUp == 0 {
return "", fmt.Errorf("link %s not UP", l.ifname)
}
return fmt.Sprintf("%d rx, %d tx", attrs.Statistics.RxPackets, attrs.Statistics.TxPackets), nil
}
// Link returns a Node which succeeds when the specified network interface is in
// state UP and RUNNING.
func Link(ifname string) Node {
return &link{ifname: ifname}
}

254
internal/diag/ping.go Normal file
View File

@ -0,0 +1,254 @@
package diag
import (
"fmt"
"net"
"time"
"github.com/digineo/go-ping"
"github.com/vishvananda/netlink"
)
func formatRTT(rtt time.Duration) string {
return fmt.Sprintf("%.2fms", float64(rtt)/float64(time.Millisecond))
}
type ping4gw struct {
children []Node
}
func (d *ping4gw) String() string {
return "ping4/<default-gateway>"
}
func (d *ping4gw) Then(t Node) Node {
d.children = append(d.children, t)
return d
}
func (d *ping4gw) Children() []Node {
return d.children
}
func defaultIPv4Gateway() (string, error) {
rl, err := netlink.RouteGet(net.ParseIP("8.8.8.8"))
if err != nil {
return "", err
}
if got, want := len(rl), 1; got != want {
return "", fmt.Errorf("unexpected number of default routes: got %d, want %d", got, want)
}
r := rl[0]
return r.Gw.String(), nil
}
func (d *ping4gw) Evaluate() (string, error) {
const timeout = 1 * time.Second
gw, err := defaultIPv4Gateway()
if err != nil {
return "", nil
}
addr, err := net.ResolveIPAddr("ip4", gw)
if err != nil {
return "", err
}
p, err := ping.New("0.0.0.0", "")
if err != nil {
return "", err
}
rtt, err := p.Ping(addr, timeout)
if err != nil {
return "", err
//return fmt.Errorf("%s did not respond within %v", gw, timeout)
}
return formatRTT(rtt), nil
}
// Ping4Gateway returns a Node which succeeds when the default gateway responds
// to an ICMPv4 ping.
func Ping4Gateway() Node {
return &ping4gw{}
}
type ping4 struct {
children []Node
addr string
}
func (d *ping4) String() string {
return "ping4/" + d.addr
}
func (d *ping4) Then(t Node) Node {
d.children = append(d.children, t)
return d
}
func (d *ping4) Children() []Node {
return d.children
}
func (d *ping4) Evaluate() (string, error) {
const timeout = 1 * time.Second
addr, err := net.ResolveIPAddr("ip4", d.addr)
if err != nil {
return "", err
}
p, err := ping.New("0.0.0.0", "")
if err != nil {
return "", err
}
rtt, err := p.Ping(addr, timeout)
if err != nil {
return "", err
//return fmt.Errorf("%s did not respond within %v", gw, timeout)
}
return formatRTT(rtt), nil
}
// Ping4 returns a Node which succeeds when the specified address responds to an
// ICMPv4 ping.
func Ping4(addr string) Node {
return &ping4{addr: addr}
}
type ping6gw struct {
children []Node
}
func (d *ping6gw) String() string {
return "ping6gw/<default-gateway>"
}
func (d *ping6gw) Then(t Node) Node {
d.children = append(d.children, t)
return d
}
func (d *ping6gw) Children() []Node {
return d.children
}
func defaultIPv6Gateway() (string, error) {
rl, err := netlink.RouteGet(net.IPv6zero)
if err != nil {
return "", err
}
if got, want := len(rl), 1; got != want {
return "", fmt.Errorf("unexpected number of default routes: got %d, want %d", got, want)
}
r := rl[0]
iface, err := net.InterfaceByIndex(r.LinkIndex)
if err != nil {
return "", err
}
return r.Gw.String() + "%" + iface.Name, nil
}
func (d *ping6gw) Evaluate() (string, error) {
const timeout = 1 * time.Second
gw, err := defaultIPv6Gateway()
if err != nil {
return "", err
}
addr, err := net.ResolveIPAddr("ip6", gw)
if err != nil {
return "", err
}
p, err := ping.New("", "::")
if err != nil {
return "", err
}
rtt, err := p.Ping(addr, timeout)
if err != nil {
return "", err
//return fmt.Errorf("%s did not respond within %v", gw, timeout)
}
return formatRTT(rtt), nil
}
// Ping6Gateway returns a Node which succeeds when the default gateway responds
// to an ICMPv6 ping.
func Ping6Gateway() Node {
return &ping6gw{}
}
type ping6 struct {
children []Node
addr string
ifname string
}
func (d *ping6) String() string {
if d.ifname == "" {
return "ping6/" + d.addr
}
return fmt.Sprintf("ping6/%s→%s", d.ifname, d.addr)
}
func (d *ping6) Then(t Node) Node {
d.children = append(d.children, t)
return d
}
func (d *ping6) Children() []Node {
return d.children
}
func (d *ping6) Evaluate() (string, error) {
const timeout = 1 * time.Second
addr, err := net.ResolveIPAddr("ip6", d.addr)
if err != nil {
return "", err
}
bind6 := "::"
if d.ifname != "" {
iface, err := net.InterfaceByName(d.ifname)
if err != nil {
return "", err
}
addrs, err := iface.Addrs()
if err != nil {
return "", err
}
for _, addr := range addrs {
ipnet, ok := addr.(*net.IPNet)
if !ok {
continue
}
if ipnet.IP.To4() != nil {
continue // skip IPv4 addresses
}
if !global.Contains(ipnet.IP) {
continue // skip local IPv6 addresses
}
bind6 = ipnet.IP.String()
break
}
}
p, err := ping.New("", bind6)
if err != nil {
return "", err
}
rtt, err := p.Ping(addr, timeout)
if err != nil {
return "", err
//return fmt.Errorf("%s did not respond within %v", gw, timeout)
}
return formatRTT(rtt), nil
}
// Ping6 returns a Node which succeeds when the specified address responds to an
// ICMPv6 ping.
func Ping6(ifname, addr string) Node {
return &ping6{
ifname: ifname,
addr: addr,
}
}

71
internal/diag/ra6.go Normal file
View File

@ -0,0 +1,71 @@
package diag
import (
"fmt"
"net"
)
type ra6 struct {
children []Node
ifname string
}
func (d *ra6) String() string {
return "ra6/" + d.ifname
}
func (d *ra6) Then(t Node) Node {
d.children = append(d.children, t)
return d
}
func (d *ra6) Children() []Node {
return d.children
}
func isEUI64(ip net.IP) bool {
if ip.To16() == nil {
return false
}
ip = ip.To16()
return ip[11] == 0xff && ip[12] == 0xfe
}
func (d *ra6) Evaluate() (string, error) {
iface, err := net.InterfaceByName(d.ifname)
if err != nil {
return "", err
}
addrs, err := iface.Addrs()
if err != nil {
return "", err
}
var first string
for _, addr := range addrs {
ipnet, ok := addr.(*net.IPNet)
if !ok {
continue
}
if ones, _ := ipnet.Mask.Size(); ones != 64 {
continue
}
if !global.Contains(ipnet.IP) {
continue // skip local IPv6 addresses
}
if !isEUI64(ipnet.IP) {
continue // skip non-autoconf addresses (e.g. DHCPv6 temporary IP)
}
first = ipnet.String()
}
if first == "" {
return "", fmt.Errorf("no SLAAC address found")
}
return first, nil
}
// RouterAdvertisments returns a Node which succeeds if the specified interface
// obtained at least one address from IPv6 router advertisments.
func RouterAdvertisments(ifname string) Node {
return &ra6{ifname: ifname}
}

71
internal/diag/tcp.go Normal file
View File

@ -0,0 +1,71 @@
package diag
import (
"net"
)
type tcp4 struct {
children []Node
addr string
}
func (d *tcp4) String() string {
return "tcp4/" + d.addr
}
func (d *tcp4) Then(t Node) Node {
d.children = append(d.children, t)
return d
}
func (d *tcp4) Children() []Node {
return d.children
}
func (d *tcp4) Evaluate() (string, error) {
conn, err := net.Dial("tcp4", d.addr)
if err != nil {
return "", err
}
defer conn.Close()
return "connection established", nil
}
// TCP4 returns a Node which succeeds when the specified address accepts a TCPv4
// connection.
func TCP4(addr string) Node {
return &tcp4{addr: addr}
}
type tcp6 struct {
children []Node
addr string
}
func (d *tcp6) String() string {
return "tcp6/" + d.addr
}
func (d *tcp6) Then(t Node) Node {
d.children = append(d.children, t)
return d
}
func (d *tcp6) Children() []Node {
return d.children
}
func (d *tcp6) Evaluate() (string, error) {
conn, err := net.Dial("tcp6", d.addr)
if err != nil {
return "", err
}
defer conn.Close()
return "connection established", nil
}
// TCP6 returns a Node which succeeds when the specified address accepts a TCPv6
// connection.
func TCP6(addr string) Node {
return &tcp6{addr: addr}
}