Look twice before saying a probe failed, and say conditions in machine names (hq issue 277)
D2 raised a resolver urgent on one query that timed out while its machine was loaded, and its summary carried the resolver's address and socket text, so the operator channel withheld the whole alert. - D2 asks every question up to three times, all at once; a resolver that answers nothing is held for the next run and raised urgent when two runs in a row find it silent. A wrong answer is still raised at once. - Findings a single look can be wrong about carry Confirm: raised on the second look in a row, kept while open, never cleared-and-reraised. Used by D2 silence, D3 (also asks discovery twice), D6 behind, D9, D13 unmeasured, probe-failed of the doctor, and blind watchdog rows. - Probe seat asks (D8, D13) are asked again when the bus brought no answer. - Summaries name machines and say things in words; addresses, paths, domains and raw errors move to the evidence (D2, D5, D8, D9, D13, S12). - internal/outward mirrors the messenger's content rule, allowing the mesh's machine names; the keeper rewords a summary that would be withheld and keeps it whole in the evidence; a TestMain lint fails the suite on any raised or linted finding that would be withheld.
This commit is contained in:
@@ -170,10 +170,19 @@ type Observation struct {
|
||||
Also []string
|
||||
Severity Severity
|
||||
Summary string
|
||||
// Said is this observation's evidence, in the mesh's words; Summary when empty.
|
||||
// Said is this observation's evidence, in the mesh's words; Summary when empty. **Detail goes
|
||||
// here, never in Summary**: an address, a socket's error, a path or a name with its domain is
|
||||
// kept in the condition's evidence, which stays inside the mesh. The summary leaves it — to the
|
||||
// operator's channel, whose content rule withholds a message that carries any of them (ADR 0234
|
||||
// §6), and names machines in words.
|
||||
Said string
|
||||
Source string
|
||||
Resolver string
|
||||
// Confirm says a single look can be wrong about this finding — a question over the network that
|
||||
// went unanswered, a time measured once on a loaded machine. The keeper does not read it: the
|
||||
// source that looks again does, and raises it only when the next look sees it too, or while it is
|
||||
// already open (novox/hq issue 277).
|
||||
Confirm bool
|
||||
}
|
||||
|
||||
// Key is where the observation's condition is kept: `<scope>.<id>.<kind>`, so the same fault said
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package conditions
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/outward"
|
||||
)
|
||||
|
||||
// **A summary the operator's channel would withhold is said in words, and kept whole in the evidence**
|
||||
// (novox/hq issue 277, ADR 0234 §6): the condition that carried a resolver's address reached the
|
||||
// operator as "its words are withheld" instead of the alert.
|
||||
func TestASummaryCarryingAnAddressIsSaidInWordsAndKeptInTheEvidence(t *testing.T) {
|
||||
k, _, _, _ := keeper(t)
|
||||
before := Unsayable
|
||||
var told []string
|
||||
Unsayable = func(o Observation, field string, r outward.Refusal) { told = append(told, field+": "+r.What) }
|
||||
t.Cleanup(func() { Unsayable = before })
|
||||
|
||||
raw := "AAAA for anchor.internal: no answer from 10.77.0.1: read udp 10.77.0.3:41234->10.77.0.1:53: i/o timeout"
|
||||
c, err := k.Observe(t.Context(), Observation{Scope: ScopeSeat, ID: "mesh-dns-resolver.anchor", Token: "wrong",
|
||||
Kind: "resolver-wrong", Machine: "anchor", Severity: Urgent, Source: "D2",
|
||||
Summary: "the mesh's resolver on anchor does not answer: " + raw})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r, ok := outward.Check(c.Summary, "anchor"); !ok {
|
||||
t.Fatalf("the summary kept is still withheld (%s): %q", r, c.Summary)
|
||||
}
|
||||
if !strings.HasPrefix(c.Summary, "the mesh's resolver on anchor does not answer: ") {
|
||||
t.Errorf("the summary's words were not kept: %q", c.Summary)
|
||||
}
|
||||
if !strings.Contains(c.Evidence[0].Said, raw) {
|
||||
t.Errorf("the evidence lost what the summary carried: %q", c.Evidence[0].Said)
|
||||
}
|
||||
if len(told) != 1 || !strings.HasPrefix(told[0], "summary") {
|
||||
t.Errorf("the producer was not named to the test: %v", told)
|
||||
}
|
||||
|
||||
// A summary that may leave is kept as it is said, machine names and all.
|
||||
ok, err := k.Observe(t.Context(), Observation{Scope: ScopeMachine, ID: "anchor", Kind: "silent", Machine: "anchor",
|
||||
Severity: Warning, Source: "S1", Summary: "anchor has not been heard from since 12:00 UTC (bound 3m0s)"})
|
||||
if err != nil || ok.Summary != "anchor has not been heard from since 12:00 UTC (bound 3m0s)" {
|
||||
t.Fatalf("%q %v", ok.Summary, err)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/outward"
|
||||
)
|
||||
|
||||
// Backend is where the open conditions are kept: one value per key, written by compare-and-set.
|
||||
@@ -70,6 +72,8 @@ type Keeper struct {
|
||||
closing sync.Once
|
||||
// Unsaid counts the transitions given up on, for the self-check to say.
|
||||
unsaid int
|
||||
// reworded are the keys whose summary was said in words for the operator's channel, said once each.
|
||||
reworded map[string]bool
|
||||
}
|
||||
|
||||
type clearing struct {
|
||||
@@ -105,7 +109,7 @@ var TellFor = 10 * time.Minute
|
||||
func NewKeeper(ctx context.Context, o Options) *Keeper {
|
||||
k := &Keeper{store: o.Store, history: o.History, teller: o.Teller, now: o.Now, say: o.Say, changed: o.Changed,
|
||||
epoch: o.Epoch,
|
||||
cleared: map[string]clearing{}, out: make(chan Event, 1024), drained: make(chan struct{})}
|
||||
cleared: map[string]clearing{}, reworded: map[string]bool{}, out: make(chan Event, 1024), drained: make(chan struct{})}
|
||||
if k.now == nil {
|
||||
k.now = time.Now
|
||||
}
|
||||
@@ -170,6 +174,7 @@ func (k *Keeper) Observe(ctx context.Context, o Observation) (Condition, error)
|
||||
if err := o.check(); err != nil {
|
||||
return Condition{}, err
|
||||
}
|
||||
o = k.sayable(o)
|
||||
key := o.Key()
|
||||
for i := 0; i < tries; i++ {
|
||||
now := k.now().UTC()
|
||||
@@ -627,3 +632,49 @@ func orSelf(resolver string) string {
|
||||
}
|
||||
return resolver
|
||||
}
|
||||
|
||||
// Unsayable is told of every observation whose words the operator's channel would withhold (novox/hq
|
||||
// ADR 0234 §6): its summary or its key carries an address, a domain, a path or a secret's shape. The
|
||||
// keeper says such a summary in words itself, and keeps what it said whole in the evidence; a test
|
||||
// suite sets this to fail the producer, which is where the summary should have been said rightly.
|
||||
var Unsayable func(o Observation, field string, r outward.Refusal)
|
||||
|
||||
// sayable is an observation whose summary may leave the mesh: **a summary says things in machine names
|
||||
// and the mesh's words** (novox/hq issue 277). One that carries what the channel withholds — a raw error
|
||||
// with an address in it, a path — is said in words here, as the last stand before the operator would
|
||||
// read "its words are withheld" instead of the alert; what it carried goes to the evidence, which
|
||||
// stays inside the mesh.
|
||||
func (k *Keeper) sayable(o Observation) Observation {
|
||||
machines := append([]string{o.Machine}, o.Also...)
|
||||
if r, ok := outward.Check(o.Key(), machines...); !ok && Unsayable != nil {
|
||||
Unsayable(o, "key", r)
|
||||
}
|
||||
r, ok := outward.Check(o.Summary, machines...)
|
||||
if ok {
|
||||
return o
|
||||
}
|
||||
if Unsayable != nil {
|
||||
Unsayable(o, "summary", r)
|
||||
}
|
||||
whole := o.Summary
|
||||
o.Summary = outward.Scrub(whole, fmt.Sprintf("a %s condition about %s %s: what it says is kept in its evidence "+
|
||||
"(`conditions show`)", o.Kind, o.Scope, o.ID), machines...)
|
||||
switch {
|
||||
case o.Said == "":
|
||||
o.Said = whole
|
||||
case !strings.Contains(o.Said, whole):
|
||||
o.Said += " — as raised: " + whole
|
||||
}
|
||||
k.mu.Lock()
|
||||
if k.reworded == nil {
|
||||
k.reworded = map[string]bool{}
|
||||
}
|
||||
first := !k.reworded[o.Key()]
|
||||
k.reworded[o.Key()] = true
|
||||
k.mu.Unlock()
|
||||
if first {
|
||||
k.say("the condition %s's summary carried %s, which the operator's channel withholds: said in words, "+
|
||||
"and kept whole in its evidence — its source should say it so", o.Key(), r.What)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
// Package outward is the operator channel's content rule, as the controller holds its own words to it
|
||||
// (novox/hq ADR 0234 §6, to-be 45 §5).
|
||||
//
|
||||
// **What may leave the mesh is machine names and words.** The messenger refuses a message that carries
|
||||
// an address, a domain, a path or anything shaped like a secret, and withholds its words — so a
|
||||
// condition whose summary carried a resolver's address reached the operator as "this message carried an
|
||||
// IPv4 address, so its words are withheld" instead of the alert (2026-10-06). The rule is the
|
||||
// messenger's, and stays the messenger's: this package mirrors its patterns so that the controller can
|
||||
// hold a condition's summary to it where the summary is made, and a test can fail a summary that would
|
||||
// be withheld. Detail — an address, a socket's error, a path — belongs in a condition's evidence, which
|
||||
// stays inside the mesh.
|
||||
//
|
||||
// Mirrored, not imported: the messenger is a module of the catalogue with its own module path, and a
|
||||
// pattern changed there is changed here (the table in outward_test.go names the shapes both refuse).
|
||||
// Where the two differ, this one may only be the stricter: what passes here passes there.
|
||||
package outward
|
||||
|
||||
import (
|
||||
"math"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Refusal says why a text may not leave: the class of what it carried, never the text itself.
|
||||
type Refusal struct {
|
||||
Class string // address, path or secret
|
||||
What string // a few words: "an IPv4 address", "a URL", …
|
||||
}
|
||||
|
||||
func (r Refusal) String() string { return r.Class + " (" + r.What + ")" }
|
||||
|
||||
// The messenger's patterns (mesh-catalog modules/messenger content.go), one for one.
|
||||
var (
|
||||
reURL = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://`)
|
||||
reEmail = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}`)
|
||||
reIPv4 = regexp.MustCompile(`\b\d{1,3}(\.\d{1,3}){3}\b`)
|
||||
reIPv6 = regexp.MustCompile(`(?i)(^|[^0-9a-z:])(([0-9a-f]{1,4}:){4,7}[0-9a-f]{1,4}|([0-9a-f]{1,4}:)*[0-9a-f]{0,4}::([0-9a-f]{1,4}:)*[0-9a-f]{0,4})([^0-9a-z:]|$)`)
|
||||
reMAC = regexp.MustCompile(`(?i)\b([0-9a-f]{2}[:-]){5}[0-9a-f]{2}\b`)
|
||||
rePEM = regexp.MustCompile(`-----BEGIN [A-Z ]+-----`)
|
||||
reJWT = regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}`)
|
||||
reBotToken = regexp.MustCompile(`\b\d{6,}:[A-Za-z0-9_-]{30,}`)
|
||||
reKnown = regexp.MustCompile(`\b(gh[pousr]_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9_-]{16,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16})`)
|
||||
reAssigned = regexp.MustCompile(`(?i)\b(password|passwd|passphrase|secret|token|api[_-]?key|apikey|credential|private[_-]?key)\s*[=:]\s*\S`)
|
||||
reHex = regexp.MustCompile(`(?i)\b[0-9a-f]{32,}\b`)
|
||||
reRun = regexp.MustCompile(`[A-Za-z0-9+/=_]{20,}`)
|
||||
reWinPath = regexp.MustCompile(`(?i)\b[a-z]:\\`)
|
||||
)
|
||||
|
||||
// topLevel are names that end a host name, as the messenger reads them.
|
||||
var topLevel = map[string]bool{}
|
||||
|
||||
func init() {
|
||||
for _, t := range strings.Fields(`com net org edu gov mil int io dev app cloud ai co me info biz xyz
|
||||
site online tech page link
|
||||
be nl de fr uk lu eu ch at it es pt se no dk fi pl cz us ca au nz jp cn ru in br ie
|
||||
internal lan home local localdomain corp intranet private arpa test example invalid localhost`) {
|
||||
topLevel[t] = true
|
||||
}
|
||||
}
|
||||
|
||||
// wordSeparators split a text into the words the messenger reads one by one.
|
||||
const wordSeparators = "\"'`()[]{}<>,;|"
|
||||
|
||||
func isSeparator(r rune) bool { return unicode.IsSpace(r) || strings.ContainsRune(wordSeparators, r) }
|
||||
|
||||
// Check says whether a text may leave the mesh, and when not, why. **The mesh's own machine names may
|
||||
// appear** (ADR 0234 §6): a word that is one of machines is read as a name, whatever its shape —
|
||||
// never as a host name, a path or a random string. A machine name joined to a domain is a domain.
|
||||
func Check(text string, machines ...string) (Refusal, bool) {
|
||||
text = withoutMachines(text, machines)
|
||||
switch {
|
||||
case reURL.MatchString(text):
|
||||
return Refusal{"address", "a URL"}, false
|
||||
case reEmail.MatchString(text):
|
||||
return Refusal{"address", "a mail address"}, false
|
||||
case reIPv4.MatchString(text):
|
||||
return Refusal{"address", "an IPv4 address"}, false
|
||||
case reMAC.MatchString(text):
|
||||
return Refusal{"address", "a hardware address"}, false
|
||||
case reIPv6.MatchString(text):
|
||||
return Refusal{"address", "an IPv6 address"}, false
|
||||
case rePEM.MatchString(text):
|
||||
return Refusal{"secret", "a key block"}, false
|
||||
case reJWT.MatchString(text):
|
||||
return Refusal{"secret", "a signed token"}, false
|
||||
case reBotToken.MatchString(text):
|
||||
return Refusal{"secret", "a bot token"}, false
|
||||
case reKnown.MatchString(text):
|
||||
return Refusal{"secret", "a known token shape"}, false
|
||||
case reAssigned.MatchString(text):
|
||||
return Refusal{"secret", "a value given to a secret's name"}, false
|
||||
case reHex.MatchString(text):
|
||||
return Refusal{"secret", "a long hexadecimal string"}, false
|
||||
case reWinPath.MatchString(text):
|
||||
return Refusal{"path", "a drive path"}, false
|
||||
}
|
||||
for _, run := range reRun.FindAllString(text, -1) {
|
||||
if looksRandom(run) {
|
||||
return Refusal{"secret", "a long random-looking string"}, false
|
||||
}
|
||||
}
|
||||
for _, word := range strings.FieldsFunc(text, isSeparator) {
|
||||
w := strings.TrimRight(word, ".:!?")
|
||||
if isPath(w) {
|
||||
return Refusal{"path", "a file path"}, false
|
||||
}
|
||||
if isHostName(w) {
|
||||
return Refusal{"address", "a host name"}, false
|
||||
}
|
||||
}
|
||||
return Refusal{}, true
|
||||
}
|
||||
|
||||
// What Scrub says in words, beyond the messenger's patterns: an address with its port and what a
|
||||
// socket says around it ("udp 192.0.2.1:53"), a bracketed IPv6 address, a key block whole, a secret's
|
||||
// value, a drive path whole.
|
||||
var (
|
||||
scrubURL = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://\S*`)
|
||||
scrubIPv4 = regexp.MustCompile(`\b\d{1,3}(\.\d{1,3}){3}(:\d+)?\b`)
|
||||
scrubIPv6 = regexp.MustCompile(`\[[0-9a-fA-F:.%]*:[0-9a-fA-F:.%]*\](:\d+)?`)
|
||||
scrubPEM = regexp.MustCompile(`(?s)-----BEGIN [A-Z ]+-----.*?(-----END [A-Z ]+-----|$)`)
|
||||
scrubAssigned = regexp.MustCompile(`(?i)\b(password|passwd|passphrase|secret|token|api[_-]?key|apikey|credential|private[_-]?key)\s*[=:]\s*\S+`)
|
||||
scrubWinPath = regexp.MustCompile(`(?i)\b[a-z]:\\\S*`)
|
||||
)
|
||||
|
||||
// Scrub is a text with everything Check refuses said in words instead: an address as "an address", a
|
||||
// path as "a path", a secret's shape as "(withheld)". The text's own words, and the machine names, are
|
||||
// kept. What Scrub cannot make pass is replaced whole by fallback — never sent as it was.
|
||||
func Scrub(text, fallback string, machines ...string) string {
|
||||
if _, ok := Check(text, machines...); ok {
|
||||
return text
|
||||
}
|
||||
out := scrubURL.ReplaceAllString(text, "an address")
|
||||
out = reEmail.ReplaceAllString(out, "an address")
|
||||
out = scrubIPv4.ReplaceAllString(out, "an address")
|
||||
out = reMAC.ReplaceAllString(out, "a hardware address")
|
||||
out = scrubIPv6.ReplaceAllString(out, "an address")
|
||||
for i := 0; i < 4 && reIPv6.MatchString(out); i++ {
|
||||
out = reIPv6.ReplaceAllString(out, "${1}an address${6}")
|
||||
}
|
||||
out = scrubPEM.ReplaceAllString(out, "(withheld)")
|
||||
for _, re := range []*regexp.Regexp{reJWT, reBotToken, reKnown, reHex} {
|
||||
out = re.ReplaceAllString(out, "(withheld)")
|
||||
}
|
||||
out = scrubAssigned.ReplaceAllString(out, "${1} (withheld)")
|
||||
out = scrubWinPath.ReplaceAllString(out, "a path")
|
||||
out = reRun.ReplaceAllStringFunc(out, func(run string) string {
|
||||
if looksRandom(run) {
|
||||
return "(withheld)"
|
||||
}
|
||||
return run
|
||||
})
|
||||
out = eachWord(out, func(w string) string {
|
||||
switch {
|
||||
case isMachine(w, machines):
|
||||
return w
|
||||
case isPath(w):
|
||||
return "a path"
|
||||
case isHostName(w):
|
||||
return "a host name"
|
||||
}
|
||||
return w
|
||||
})
|
||||
if _, ok := Check(out, machines...); ok {
|
||||
return out
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// withoutMachines is a text with every machine name that stands as a word of its own read as a plain
|
||||
// word, so the patterns do not read a name as anything else.
|
||||
func withoutMachines(text string, machines []string) string {
|
||||
if len(machines) == 0 {
|
||||
return text
|
||||
}
|
||||
return eachWord(text, func(w string) string {
|
||||
if isMachine(w, machines) {
|
||||
return "machine"
|
||||
}
|
||||
return w
|
||||
})
|
||||
}
|
||||
|
||||
// eachWord is a text with each word, as the messenger splits and trims it, put through say; what
|
||||
// separates the words, and the punctuation a word ends in, are kept.
|
||||
func eachWord(text string, say func(w string) string) string {
|
||||
var b strings.Builder
|
||||
start := -1
|
||||
flush := func(end int) {
|
||||
if start < 0 {
|
||||
return
|
||||
}
|
||||
word := text[start:end]
|
||||
w := strings.TrimRight(word, ".:!?")
|
||||
if w == "" {
|
||||
b.WriteString(word)
|
||||
} else {
|
||||
b.WriteString(say(w) + word[len(w):])
|
||||
}
|
||||
start = -1
|
||||
}
|
||||
for i, r := range text {
|
||||
if isSeparator(r) {
|
||||
flush(i)
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
if start < 0 {
|
||||
start = i
|
||||
}
|
||||
}
|
||||
flush(len(text))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func isMachine(w string, machines []string) bool {
|
||||
for _, m := range machines {
|
||||
if m != "" && strings.EqualFold(w, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isPath: absolute, home-relative or dot-relative, or two separators deep. A mesh address names one
|
||||
// machine and one tool (`ace/postgres.query`) and has one; a ratio ("3/4") has digits only.
|
||||
func isPath(w string) bool {
|
||||
if w == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(w, "/") && len(w) > 1 {
|
||||
return true
|
||||
}
|
||||
for _, p := range []string{"~/", "./", "../", "$HOME", "${"} {
|
||||
if strings.HasPrefix(w, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return strings.Count(w, "/") >= 2 || strings.Contains(w, "\\")
|
||||
}
|
||||
|
||||
// isHostName: two names or more, the last a top-level one. A condition key's last name is its kind
|
||||
// (`machine.ace.silent`), which none of these is.
|
||||
func isHostName(w string) bool {
|
||||
w = strings.ToLower(w)
|
||||
if w == "localhost" {
|
||||
return true
|
||||
}
|
||||
parts := strings.Split(w, ".")
|
||||
if len(parts) < 2 {
|
||||
return false
|
||||
}
|
||||
for _, p := range parts {
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return topLevel[parts[len(parts)-1]]
|
||||
}
|
||||
|
||||
// looksRandom: letters and digits mixed, and the characters spread as a random string's are.
|
||||
func looksRandom(s string) bool {
|
||||
var letters, digits int
|
||||
counts := map[rune]int{}
|
||||
for _, r := range s {
|
||||
counts[r]++
|
||||
switch {
|
||||
case unicode.IsLetter(r):
|
||||
letters++
|
||||
case unicode.IsDigit(r):
|
||||
digits++
|
||||
}
|
||||
}
|
||||
if letters == 0 || digits == 0 {
|
||||
return letters > 0 && hasUpperAndLower(s) && entropy(counts, len(s)) >= 4.0
|
||||
}
|
||||
return entropy(counts, len(s)) >= 3.3
|
||||
}
|
||||
|
||||
func hasUpperAndLower(s string) bool {
|
||||
return strings.IndexFunc(s, unicode.IsUpper) >= 0 && strings.IndexFunc(s, unicode.IsLower) >= 0
|
||||
}
|
||||
|
||||
func entropy(counts map[rune]int, n int) float64 {
|
||||
e := 0.0
|
||||
for _, c := range counts {
|
||||
p := float64(c) / float64(n)
|
||||
e -= p * math.Log2(p)
|
||||
}
|
||||
return e
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package outward
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// machines are the names a mesh in these tests has.
|
||||
var machines = []string{"anchor", "laptop", "g14", "home-server"}
|
||||
|
||||
// **The content rule's table** (novox/hq ADR 0234, "machine names pass the content rule; domains,
|
||||
// addresses, paths and secrets do not"): the shapes the messenger refuses are refused here, and the
|
||||
// words a condition is made of pass — the mesh's machine names among them.
|
||||
func TestMachineNamesPassAndAddressesDomainsPathsAndSecretsDoNot(t *testing.T) {
|
||||
pass := []string{
|
||||
"the mesh's resolver on anchor has not answered for two runs of the self-check",
|
||||
"laptop has not been heard from since 2026-10-06 15:02 UTC (bound 3m0s)",
|
||||
"g14's node-engine would refuse its declaration whole",
|
||||
"home-server runs the node-engine 3f2a9c1b7d0e, the mesh holds 5e6f7a8b9c0d",
|
||||
"seat.mesh-dns-resolver.anchor.wrong",
|
||||
"anchor/postgres.query answers",
|
||||
"3/4 of the consumers are behind; 1200 message(s) behind its stream's head",
|
||||
"the plan for novox/app c0ffee00 has been at tier 1 of 2",
|
||||
"mesh-controller.conditions key=machine.anchor.silent",
|
||||
"backed-up 2 days ago: tank/data shrank to 1.2 GiB",
|
||||
}
|
||||
for _, text := range pass {
|
||||
if r, ok := Check(text, machines...); !ok {
|
||||
t.Errorf("refused as %s: %q", r, text)
|
||||
}
|
||||
}
|
||||
refuse := map[string]string{
|
||||
"AAAA for anchor: no answer from 10.77.0.1: read udp 10.77.0.3:41234->10.77.0.1:53: i/o timeout": "address",
|
||||
"the resolver at [fd00::1]:53 did not answer": "address",
|
||||
"fe80::1ff:fe23:4567:890a is banned": "address",
|
||||
"anchor.internal answered NXDOMAIN": "address",
|
||||
"the store at https://artifacts.example.org/v2 is away": "address",
|
||||
"write to jochen@example.org": "address",
|
||||
"aa:bb:cc:dd:ee:ff": "address",
|
||||
"data at /srv/postgres/data is gone": "path",
|
||||
"kept at ~/backups": "path",
|
||||
`kept at C:\backups`: "path",
|
||||
"password=hunter2": "secret",
|
||||
"the token glpat-abcdefghijklmnop12 leaked": "secret",
|
||||
"0123456789abcdef0123456789abcdef01": "secret",
|
||||
}
|
||||
for text, class := range refuse {
|
||||
r, ok := Check(text, machines...)
|
||||
if ok || r.Class != class {
|
||||
t.Errorf("%q: want refused as %s, got %v %v", text, class, r, ok)
|
||||
}
|
||||
}
|
||||
// A machine name joined to a domain is a domain, the machine's name notwithstanding.
|
||||
if _, ok := Check("anchor.lan answered", machines...); ok {
|
||||
t.Error("a machine's name with a domain passed")
|
||||
}
|
||||
// And a machine whose name has a shape the rule would otherwise refuse still passes as a name.
|
||||
if _, ok := Check("node.internal is silent", "node.internal"); !ok {
|
||||
t.Error("a machine name was refused")
|
||||
}
|
||||
}
|
||||
|
||||
// **Scrub says what Check refuses in words, and what it gives back always passes** — or is the
|
||||
// fallback, never the text as it was.
|
||||
func TestScrubAlwaysGivesWhatMayLeave(t *testing.T) {
|
||||
for _, text := range []string{
|
||||
"AAAA for anchor.internal: no answer from 10.77.0.1: read udp 10.77.0.3:41234->10.77.0.1:53: i/o timeout",
|
||||
"dial tcp [fd00::1]:4222: connect: connection refused",
|
||||
"open /var/lib/mesh/data/x.db: no such file or directory",
|
||||
"GET https://artifacts.example.org/v2/blobs/sha256:0123456789abcdef0123456789abcdef0123456789abcdef: 404",
|
||||
"password=hunter2 and a key -----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----",
|
||||
"laptop's ban list holds 192.0.2.7 (anchor's endpoint)",
|
||||
} {
|
||||
got := Scrub(text, "FALLBACK", machines...)
|
||||
if r, ok := Check(got, machines...); !ok {
|
||||
t.Errorf("Scrub(%q) = %q still refused as %s", text, got, r)
|
||||
}
|
||||
if got == "FALLBACK" {
|
||||
t.Errorf("Scrub(%q) fell back where words could be kept", text)
|
||||
}
|
||||
}
|
||||
if got := Scrub("laptop's ban list holds 192.0.2.7", "", machines...); !strings.HasPrefix(got, "laptop's ban list holds an address") {
|
||||
t.Errorf("the machine's name was not kept: %q", got)
|
||||
}
|
||||
if got := Scrub("nothing wrong with anchor", "", machines...); got != "nothing wrong with anchor" {
|
||||
t.Errorf("a text that passes was changed: %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user