diff --git a/cmd/diagd/diagd.go b/cmd/diagd/diagd.go
new file mode 100644
index 0000000..e46c1b3
--- /dev/null
+++ b/cmd/diagd/diagd.go
@@ -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, "
%s %s: %s", symbol, html.EscapeString(re.Name), html.EscapeString(re.Status))
+ for _, ch := range re.Children {
+ dump(w, ch)
+ }
+ fmt.Fprintf(w, "
")
+}
+
+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, ``)
+ 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)
+ }
+}
diff --git a/internal/diag/dhcp.go b/internal/diag/dhcp.go
new file mode 100644
index 0000000..cc4f19a
--- /dev/null
+++ b/internal/diag/dhcp.go
@@ -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{}
+}
diff --git a/internal/diag/diag.go b/internal/diag/diag.go
new file mode 100644
index 0000000..c4fcf26
--- /dev/null
+++ b/internal/diag/diag.go
@@ -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, "")
+}
diff --git a/internal/diag/diag_test.go b/internal/diag/diag_test.go
new file mode 100644
index 0000000..692117c
--- /dev/null
+++ b/internal/diag/diag_test.go
@@ -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)
+ }
+}
diff --git a/internal/diag/ipv6.go b/internal/diag/ipv6.go
new file mode 100644
index 0000000..5446e1e
--- /dev/null
+++ b/internal/diag/ipv6.go
@@ -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
+}
diff --git a/internal/diag/link.go b/internal/diag/link.go
new file mode 100644
index 0000000..89fa38a
--- /dev/null
+++ b/internal/diag/link.go
@@ -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}
+}
diff --git a/internal/diag/ping.go b/internal/diag/ping.go
new file mode 100644
index 0000000..a9695a0
--- /dev/null
+++ b/internal/diag/ping.go
@@ -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/"
+}
+
+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/"
+}
+
+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,
+ }
+}
diff --git a/internal/diag/ra6.go b/internal/diag/ra6.go
new file mode 100644
index 0000000..0883ca9
--- /dev/null
+++ b/internal/diag/ra6.go
@@ -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}
+}
diff --git a/internal/diag/tcp.go b/internal/diag/tcp.go
new file mode 100644
index 0000000..bea7f73
--- /dev/null
+++ b/internal/diag/tcp.go
@@ -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}
+}