package main import ( "context" "fmt" "slices" "strings" "time" stdio "git.novox.be/novox/mesh-sdk/go" ) // Stale is how old the watch's last look may be before the health tool says it stopped looking. const Stale = 2 * time.Minute // Health is the module's health answer (novox/hq ADR 0240): healthy only when the resolver answers its // real question on every listen address, over UDP and TCP, and the watch is looking. type Health struct { Healthy bool `json:"healthy"` Why string `json:"why,omitempty"` Look Look `json:"look"` // Watch is what the watch last saw and did; absent when it has written nothing yet. Watch *State `json:"watch,omitempty"` } // Health asks the resolver now, and reads what the watch did. func (m *Machine) Health(ctx context.Context) Health { l := m.Look(ctx) h := Health{Healthy: l.Healthy, Look: l} var why []string if !l.Healthy { why = append(why, "the resolver does not answer as it must: "+l.Why()) } s, had, err := m.ReadState() switch { case err != nil: h.Healthy = false why = append(why, "what the watch saw cannot be read: "+err.Error()) case !had: h.Healthy = false why = append(why, "the watch has written nothing yet, so nothing restarts the resolver when it stops answering") default: h.Watch = &s if age := m.Now().Sub(s.Last.At); age > Stale { h.Healthy = false why = append(why, fmt.Sprintf("the watch last looked %s ago, so nothing restarts the resolver when it "+ "stops answering", age.Round(time.Second))) } if !l.Healthy { why = append(why, restartWords(s)) } if s.Unit != "" && s.Unit != "active" { why = append(why, "the unit is "+s.Unit) } } h.Why = strings.Join(nonEmpty(why), "; ") return h } // restartWords is what the watch did about it, in words. func restartWords(s State) string { if len(s.Restarts) == 0 { return "the watch has not restarted it" + orEmpty("; ", s.Held) } r := s.Restarts[len(s.Restarts)-1] words := fmt.Sprintf("the watch restarted it at %s", r.At.Format("15:04:05 UTC")) switch { case r.Error != "": words += ", and the restart failed: " + r.Error case r.Helped != nil && !*r.Helped: words += ", which did not help" case r.Helped == nil: words += ", and its next looks decide whether that helped" } if s.Held != "" { words += "; " + s.Held } return words } func nonEmpty(s []string) []string { var out []string for _, x := range s { if x != "" { out = append(out, x) } } return out } func str(description string) map[string]any { return map[string]any{"type": "string", "description": description} } func arg(a map[string]any, k string) string { v, _ := a[k].(string) return strings.TrimSpace(v) } // Tools are the module's tools. func Tools(m *Machine) []stdio.Tool { return []stdio.Tool{ {Name: "dnsmasq_health", Description: "Whether this machine's mesh resolver answers: its real question — this " + "machine's own name — asked now on every address its configuration listens on, over UDP and TCP, each " + "answer with its code, addresses and time; and what the module's watch last saw, the restarts it made " + "within the hour, whether each helped, and why it holds back from another. Healthy only when every " + "question was answered rightly and the watch is looking (novox/hq ADR 0240, issue 348). (r)", Input: map[string]any{}, Run: func(map[string]any) (any, error) { return m.Health(context.Background()), nil }}, {Name: "dnsmasq_names", Description: "The mesh's machine names this machine's resolver answers, each with its " + "private address, as the roster wrote them (" + NodesFile + "). (r)", Input: map[string]any{}, Run: func(map[string]any) (any, error) { text, err := m.read(NodesFile) if err != nil { return nil, fmt.Errorf("the roster %s cannot be read: %w", NodesFile, err) } return map[string]any{"names": AnsweredNames(text)}, nil }}, {Name: "dnsmasq_resolve", Description: "Ask this machine's resolver one name's IPv4 address, directly, on one " + "address it listens on (default every one) over udp or tcp (default both): the answer code, the addresses " + "and the time each took, or why none came — refused when nothing listens there. Replaces dig @address. (r)", Input: map[string]any{"name": str("the name to ask, e.g. plex.anchor.internal"), "address": str("one listen address to ask (default: every one in its configuration)"), "over": str("udp or tcp (default: both)")}, Run: func(a map[string]any) (any, error) { return m.Resolve(context.Background(), arg(a, "name"), arg(a, "address"), arg(a, "over")) }}, } } // Resolve asks name on one listen address or every one, over one transport or both. func (m *Machine) Resolve(ctx context.Context, name, address, over string) (any, error) { if name == "" { return nil, fmt.Errorf("name is required") } config, err := m.read(ConfigFile) if err != nil { return nil, fmt.Errorf("its configuration %s cannot be read: %w", ConfigFile, err) } addresses := ListenAddresses(config) if address != "" { if !slices.Contains(addresses, address) { return nil, fmt.Errorf("address %q is not one the resolver listens on (%s); netcheck_dns asks any resolver", address, strings.Join(addresses, ", ")) } addresses = []string{address} } overs := []string{"udp", "tcp"} switch over { case "": case "udp", "tcp": overs = []string{over} default: return nil, fmt.Errorf("over is udp or tcp, not %q", over) } var answers []Answer for _, a := range addresses { for _, o := range overs { answers = append(answers, m.Ask(ctx, a+":"+m.Port, o, name, AskWithin)) } } return map[string]any{"name": name, "answers": answers}, nil }