checker-smtp/checker/report.go

663 lines
22 KiB
Go

package checker
import (
"encoding/json"
"fmt"
"html/template"
"sort"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
type reportFix struct {
Severity string
Code string
Message string
Fix string
Endpoint string
Target string
}
type reportMX struct {
Preference uint16
Target string
IPv4 []string
IPv6 []string
IsCNAME bool
CNAMEChain []string
IsIPLiteral bool
ResolveErr string
}
type reportEndpoint struct {
Target string
Address string
IP string
IsIPv6 bool
StatusLabel string
StatusClass string
AnyFail bool
TCPConnected bool
BannerLine string
BannerHostname string
BannerCode int
EHLOReceived bool
EHLOFallbackHELO bool
EHLOHostname string
STARTTLSOffered bool
STARTTLSUpgraded bool
TLSVersion string
TLSCipher string
SizeLimit uint64
HasPipelining bool
Has8BITMIME bool
HasSMTPUTF8 bool
HasCHUNKING bool
HasDSN bool
HasENHANCEDCODE bool
AUTHPreTLS []string
AUTHPostTLS []string
PTR string
PTRError string
FCrDNSPass bool
NullSenderState string
NullSenderClass string
NullSenderResponse string
PostmasterState string
PostmasterClass string
PostmasterResponse string
OpenRelayState string
OpenRelayClass string
OpenRelayResponse string
OpenRelayRecipient string
ElapsedMS int64
Error string
// TLS posture (from a related tls_probes observation, when available).
TLSPosture *reportTLSPosture
}
type reportTLSPosture struct {
CheckedAt time.Time
ChainValid *bool
HostnameMatch *bool
NotAfter time.Time
Issues []reportFix
}
type reportData struct {
Domain string
RunAt string
StatusLabel string
StatusClass string
HasIssues bool
Fixes []reportFix
MX []reportMX
NullMX bool
ImplicitMX bool
MXError string
Endpoints []reportEndpoint
HasIPv4 bool
HasIPv6 bool
AnySTARTTLS bool
AllSTARTTLS bool
HasTLSPosture bool
}
var reportTpl = template.Must(template.New("smtp").Funcs(template.FuncMap{
"deref": func(b *bool) bool { return b != nil && *b },
"humanBytes": func(n uint64) string {
if n == 0 {
return "no limit"
}
units := []string{"B", "KiB", "MiB", "GiB", "TiB"}
f := float64(n)
u := 0
for f >= 1024 && u < len(units)-1 {
f /= 1024
u++
}
return fmt.Sprintf("%.1f %s", f, units[u])
},
}).Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SMTP Report: {{.Domain}}</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
color: #1f2937;
background: #f3f4f6;
}
body { margin: 0; padding: 1rem; }
code { font-family: ui-monospace, monospace; font-size: .9em; }
h1 { margin: 0 0 .4rem; font-size: 1.15rem; font-weight: 700; }
h2 { font-size: 1rem; font-weight: 700; margin: 0 0 .6rem; }
h3 { font-size: .9rem; font-weight: 600; margin: 0 0 .4rem; }
.hd, .section, details {
background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.hd { border-radius: 10px; padding: 1rem 1.25rem; margin-bottom: .75rem; }
.section { border-radius: 8px; padding: .85rem 1rem; margin-bottom: .6rem; }
details { border-radius: 8px; margin-bottom: .45rem; overflow: hidden; }
.badge {
display: inline-flex; align-items: center;
padding: .2em .65em; border-radius: 9999px;
font-size: .78rem; font-weight: 700; letter-spacing: .02em;
}
.ok { background: #d1fae5; color: #065f46; }
.warn { background: #fef3c7; color: #92400e; }
.fail { background: #fee2e2; color: #991b1b; }
.muted { background: #e5e7eb; color: #374151; }
.info { background: #dbeafe; color: #1e3a8a; }
.meta { color: #6b7280; font-size: .82rem; margin-top: .35rem; }
summary {
display: flex; align-items: center; gap: .5rem;
padding: .65rem 1rem; cursor: pointer; user-select: none; list-style: none;
}
summary::-webkit-details-marker { display: none; }
summary::before { content: "▶"; font-size: .65rem; color: #9ca3af; transition: transform .15s; flex-shrink: 0; }
details[open] > summary::before { transform: rotate(90deg); }
.conn-addr { font-weight: 600; flex: 1; font-size: .9rem; font-family: ui-monospace, monospace; }
.details-body { padding: .6rem 1rem .85rem; border-top: 1px solid #f3f4f6; }
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
th, td { text-align: left; padding: .3rem .5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
th { font-weight: 600; color: #6b7280; }
.fix {
border-left: 3px solid #dc2626;
padding: .5rem .75rem; margin-bottom: .5rem;
background: #fef2f2; border-radius: 0 6px 6px 0;
}
.fix.warn { border-color: #f59e0b; background: #fffbeb; }
.fix.info { border-color: #3b82f6; background: #eff6ff; }
.fix .code { font-family: ui-monospace, monospace; font-size: .75rem; color: #6b7280; }
.fix .msg { font-weight: 600; margin: .1rem 0 .2rem; }
.fix .how { font-size: .88rem; }
.fix .ep { font-size: .78rem; color: #6b7280; font-family: ui-monospace, monospace; }
.chiprow { display: flex; flex-wrap: wrap; gap: .25rem; }
.chip {
display: inline-block; padding: .12em .5em;
background: #e0e7ff; color: #3730a3;
border-radius: 4px; font-size: .78rem; font-family: ui-monospace, monospace;
}
.chip.danger { background: #fee2e2; color: #991b1b; }
.chip.good { background: #d1fae5; color: #065f46; }
.kv { display: grid; grid-template-columns: auto 1fr; gap: .3rem 1rem; font-size: .86rem; }
.kv dt { color: #6b7280; }
.kv dd { margin: 0; }
.note { color: #6b7280; font-size: .85rem; }
.banner-text { font-family: ui-monospace, monospace; font-size: .78rem;
background: #f9fafb; border: 1px solid #e5e7eb; padding: .3rem .5rem;
border-radius: 4px; color: #374151; word-break: break-all; }
.footer { color: #6b7280; font-size: .78rem; text-align: center; margin-top: 1rem; padding-bottom: 2rem; }
.check-ok { color: #059669; }
.check-fail { color: #dc2626; }
.check-info { color: #6b7280; }
.relay-alert {
background: #fef2f2; border: 2px solid #dc2626;
border-radius: 8px; padding: .85rem 1rem; margin-bottom: .6rem;
}
.relay-alert strong { color: #991b1b; }
</style>
</head>
<body>
<div class="hd">
<h1>SMTP: <code>{{.Domain}}</code></h1>
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
<div class="meta">
{{if .NullMX}}<span class="badge info">null MX (refuses mail)</span>{{else}}
{{if .AllSTARTTLS}}<span class="badge ok">all STARTTLS</span>
{{else if .AnySTARTTLS}}<span class="badge warn">partial STARTTLS</span>
{{else}}<span class="badge fail">no STARTTLS</span>{{end}}
{{if .HasIPv4}}<span class="badge muted">IPv4</span>{{end}}
{{if .HasIPv6}}<span class="badge muted">IPv6</span>{{end}}
{{end}}
</div>
<div class="meta">Checked {{.RunAt}}</div>
</div>
{{if .HasIssues}}
<div class="section">
<h2>What to fix</h2>
{{range .Fixes}}
<div class="fix {{.Severity}}">
<div class="code">{{.Code}}{{if .Target}} · {{.Target}}{{end}}{{if .Endpoint}}{{if not .Target}} · {{.Endpoint}}{{else}} ({{.Endpoint}}){{end}}{{end}}</div>
<div class="msg">{{.Message}}</div>
{{if .Fix}}<div class="how">&rarr; {{.Fix}}</div>{{end}}
</div>
{{end}}
</div>
{{end}}
<div class="section">
<h2>DNS / MX</h2>
{{if .NullMX}}
<p class="note">This domain publishes a <strong>null MX record</strong>: it explicitly does not accept email (RFC 7505).</p>
{{else if .ImplicitMX}}
<p class="note">No MX record is published; senders will fall back to the domain's A/AAAA (implicit MX, discouraged).</p>
{{else if .MXError}}
<p class="note">MX lookup failed: <code>{{.MXError}}</code></p>
{{else if .MX}}
<table>
<tr><th>Pref</th><th>Target</th><th>IPv4</th><th>IPv6</th><th>Issues</th></tr>
{{range .MX}}
<tr>
<td>{{.Preference}}</td>
<td><code>{{.Target}}</code></td>
<td>{{range .IPv4}}<code>{{.}}</code> {{end}}</td>
<td>{{range .IPv6}}<code>{{.}}</code> {{end}}</td>
<td>
{{if .IsIPLiteral}}<span class="check-fail">IP literal</span>{{end}}
{{if .IsCNAME}}<span class="check-fail">CNAME chain: {{range .CNAMEChain}}<code>{{.}}</code> {{end}}</span>{{end}}
{{if .ResolveErr}}<span class="check-fail">resolve: {{.ResolveErr}}</span>{{end}}
</td>
</tr>
{{end}}
</table>
{{else}}
<p class="note">No MX records found.</p>
{{end}}
</div>
{{if .Endpoints}}
<div class="section">
<h2>Endpoints ({{len .Endpoints}})</h2>
{{range .Endpoints}}
<details{{if .AnyFail}} open{{end}}>
<summary>
<span class="conn-addr">{{.Target}} · {{.Address}}</span>
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
</summary>
<div class="details-body">
<dl class="kv">
<dt>Family</dt><dd>{{if .IsIPv6}}IPv6{{else}}IPv4{{end}}</dd>
<dt>TCP :25</dt><dd>{{if .TCPConnected}}<span class="check-ok">&#10003; connected</span>{{else}}<span class="check-fail">&#10007; failed</span>{{end}}</dd>
{{if .BannerLine}}
<dt>Banner</dt><dd>
<div class="banner-text">{{.BannerCode}} {{.BannerLine}}</div>
{{if .BannerHostname}}<div class="note">announced name: <code>{{.BannerHostname}}</code></div>{{end}}
</dd>
{{end}}
<dt>EHLO</dt><dd>
{{if .EHLOFallbackHELO}}<span class="check-fail">&#10007; EHLO rejected, only HELO works</span>
{{else if .EHLOReceived}}<span class="check-ok">&#10003; accepted{{if .EHLOHostname}} (<code>{{.EHLOHostname}}</code>){{end}}</span>
{{else}}<span class="check-fail">&#10007; failed</span>{{end}}
</dd>
{{if .EHLOReceived}}
<dt>Extensions</dt><dd>
<div class="chiprow">
{{if .STARTTLSOffered}}<span class="chip good">STARTTLS</span>{{else}}<span class="chip danger">no STARTTLS</span>{{end}}
{{if .HasPipelining}}<span class="chip good">PIPELINING</span>{{else}}<span class="chip danger">no PIPELINING</span>{{end}}
{{if .Has8BITMIME}}<span class="chip">8BITMIME</span>{{end}}
{{if .HasSMTPUTF8}}<span class="chip">SMTPUTF8</span>{{end}}
{{if .HasCHUNKING}}<span class="chip">CHUNKING</span>{{end}}
{{if .HasDSN}}<span class="chip">DSN</span>{{end}}
{{if .HasENHANCEDCODE}}<span class="chip">ENHANCEDSTATUSCODES</span>{{end}}
{{if .SizeLimit}}<span class="chip">SIZE {{humanBytes .SizeLimit}}</span>{{end}}
</div>
</dd>
{{end}}
{{if .AUTHPreTLS}}
<dt>AUTH pre-TLS</dt><dd>
<span class="check-fail">&#10007; advertised without TLS:</span>
{{range .AUTHPreTLS}}<span class="chip danger">{{.}}</span> {{end}}
</dd>
{{end}}
{{if .AUTHPostTLS}}
<dt>AUTH post-TLS</dt><dd>{{range .AUTHPostTLS}}<span class="chip">{{.}}</span> {{end}}</dd>
{{end}}
<dt>STARTTLS</dt><dd>
{{if .STARTTLSUpgraded}}<span class="check-ok">&#10003; {{.TLSVersion}}{{if .TLSCipher}} ({{.TLSCipher}}){{end}}</span>
{{else if .STARTTLSOffered}}<span class="check-fail">&#10007; handshake failed</span>
{{else}}<span class="check-fail">&#10007; not offered</span>{{end}}
</dd>
{{with .TLSPosture}}
<dt>TLS cert</dt><dd>
{{if .ChainValid}}{{if deref .ChainValid}}<span class="check-ok">&#10003; chain valid</span>{{else}}<span class="check-fail">&#10007; chain invalid</span>{{end}}{{end}}
{{if .HostnameMatch}} &middot; {{if deref .HostnameMatch}}<span class="check-ok">&#10003; hostname match</span>{{else}}<span class="check-fail">&#10007; hostname mismatch</span>{{end}}{{end}}
{{if not .NotAfter.IsZero}} &middot; expires <code>{{.NotAfter.Format "2006-01-02"}}</code>{{end}}
{{if not .CheckedAt.IsZero}}<div class="note">TLS checked {{.CheckedAt.Format "2006-01-02 15:04 MST"}}</div>{{end}}
{{range .Issues}}
<div class="fix {{.Severity}}" style="margin-top:.3rem">
<div class="code">{{.Code}}</div>
<div class="msg">{{.Message}}</div>
{{if .Fix}}<div class="how">&rarr; {{.Fix}}</div>{{end}}
</div>
{{end}}
</dd>
{{end}}
<dt>PTR</dt><dd>
{{if .PTR}}<code>{{.PTR}}</code>
{{if .FCrDNSPass}}&middot; <span class="check-ok">&#10003; FCrDNS</span>
{{else}}&middot; <span class="check-fail">&#10007; FCrDNS mismatch</span>{{end}}
{{else}}<span class="check-fail">&#10007; no PTR</span>{{if .PTRError}} <span class="note">({{.PTRError}})</span>{{end}}{{end}}
</dd>
{{if .NullSenderState}}
<dt>Null sender</dt><dd>
<span class="check-{{.NullSenderClass}}">{{.NullSenderState}}</span>
<div class="note">{{.NullSenderResponse}}</div>
</dd>
{{end}}
{{if .PostmasterState}}
<dt>Postmaster</dt><dd>
<span class="check-{{.PostmasterClass}}">{{.PostmasterState}}</span>
<div class="note">{{.PostmasterResponse}}</div>
</dd>
{{end}}
{{if .OpenRelayState}}
<dt>Open relay</dt><dd>
<span class="check-{{.OpenRelayClass}}">{{.OpenRelayState}}</span>
<div class="note">rcpt=<code>{{.OpenRelayRecipient}}</code>: {{.OpenRelayResponse}}</div>
</dd>
{{end}}
<dt>Duration</dt><dd>{{.ElapsedMS}} ms</dd>
{{if .Error}}<dt>Error</dt><dd><span class="check-fail">{{.Error}}</span></dd>{{end}}
</dl>
</div>
</details>
{{end}}
</div>
{{end}}
<p class="footer">{{if .HasTLSPosture}}Certificate posture above comes from the TLS checker, which probed the same endpoints after we discovered them.{{else}}For certificate chain, SAN match, expiry and cipher posture, run the TLS checker on port 25 with STARTTLS=smtp.{{end}}</p>
</body>
</html>`))
// GetHTMLReport implements sdk.CheckerHTMLReporter.
func (p *smtpProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) {
var d SMTPData
if err := json.Unmarshal(rctx.Data(), &d); err != nil {
return "", fmt.Errorf("unmarshal smtp observation: %w", err)
}
view := buildReportData(&d, rctx.Related(TLSRelatedKey), rctx.States())
return renderReport(view)
}
func renderReport(view reportData) (string, error) {
var buf strings.Builder
if err := reportTpl.Execute(&buf, view); err != nil {
return "", fmt.Errorf("render smtp report: %w", err)
}
return buf.String(), nil
}
func buildReportData(d *SMTPData, related []sdk.RelatedObservation, states []sdk.CheckState) reportData {
tlsByAddr := indexTLSByAddress(related)
fixes := fixesFromStates(states)
view := reportData{
Domain: d.Domain,
RunAt: d.RunAt,
NullMX: d.MX.NullMX,
ImplicitMX: d.MX.ImplicitMX,
MXError: d.MX.Error,
HasIPv4: d.Coverage.HasIPv4,
HasIPv6: d.Coverage.HasIPv6,
AnySTARTTLS: d.Coverage.AnySTARTTLS,
AllSTARTTLS: d.Coverage.AllSTARTTLS,
HasIssues: len(fixes) > 0,
HasTLSPosture: len(tlsByAddr) > 0,
}
view.StatusLabel, view.StatusClass = overallStatus(d, states, fixes)
sevRank := func(s string) int {
switch s {
case SeverityCrit:
return 0
case SeverityWarn:
return 1
default:
return 2
}
}
sort.SliceStable(fixes, func(i, j int) bool { return sevRank(fixes[i].Severity) < sevRank(fixes[j].Severity) })
view.Fixes = fixes
for _, rec := range d.MX.Records {
view.MX = append(view.MX, reportMX{
Preference: rec.Preference,
Target: rec.Target,
IPv4: rec.IPv4,
IPv6: rec.IPv6,
IsCNAME: rec.IsCNAME,
CNAMEChain: rec.CNAMEChain,
IsIPLiteral: rec.IsIPLiteral,
ResolveErr: rec.ResolveError,
})
}
for _, ep := range d.Endpoints {
re := reportEndpoint{
Target: ep.Target,
Address: ep.Address,
IP: ep.IP,
IsIPv6: ep.IsIPv6,
TCPConnected: ep.TCPConnected,
BannerLine: ep.BannerLine,
BannerHostname: ep.BannerHostname,
BannerCode: ep.BannerCode,
EHLOReceived: ep.EHLOReceived,
EHLOFallbackHELO: ep.EHLOFallbackHELO,
EHLOHostname: ep.EHLOHostname,
STARTTLSOffered: ep.STARTTLSOffered,
STARTTLSUpgraded: ep.STARTTLSUpgraded,
TLSVersion: ep.TLSVersion,
TLSCipher: ep.TLSCipher,
SizeLimit: ep.SizeLimit,
HasPipelining: ep.HasPipelining,
Has8BITMIME: ep.Has8BITMIME,
HasSMTPUTF8: ep.HasSMTPUTF8,
HasCHUNKING: ep.HasCHUNKING,
HasDSN: ep.HasDSN,
HasENHANCEDCODE: ep.HasENHANCEDCODE,
AUTHPreTLS: ep.AUTHPreTLS,
AUTHPostTLS: ep.AUTHPostTLS,
PTR: ep.PTR,
PTRError: ep.PTRError,
FCrDNSPass: ep.FCrDNSPass,
NullSenderResponse: ep.NullSenderResponse,
PostmasterResponse: ep.PostmasterResponse,
OpenRelayResponse: ep.OpenRelayResponse,
OpenRelayRecipient: ep.OpenRelayRecipient,
ElapsedMS: ep.ElapsedMS,
Error: ep.Error,
}
if ep.NullSenderAccepted != nil {
if *ep.NullSenderAccepted {
re.NullSenderState = "accepted"
re.NullSenderClass = "ok"
} else {
re.NullSenderState = "REJECTED"
re.NullSenderClass = "fail"
}
}
if ep.PostmasterAccepted != nil {
if *ep.PostmasterAccepted {
re.PostmasterState = "accepted"
re.PostmasterClass = "ok"
} else {
re.PostmasterState = "REJECTED"
re.PostmasterClass = "fail"
}
}
if ep.OpenRelay != nil {
if *ep.OpenRelay {
re.OpenRelayState = "OPEN RELAY"
re.OpenRelayClass = "fail"
} else {
re.OpenRelayState = "properly refused"
re.OpenRelayClass = "ok"
}
}
if meta, hit := tlsByAddr[ep.Address]; hit {
re.TLSPosture = meta
} else if meta, hit := tlsByAddr[endpointKey(ep.Target, ep.Port)]; hit {
re.TLSPosture = meta
}
ok := ep.TCPConnected && ep.EHLOReceived
if ep.STARTTLSOffered {
ok = ok && ep.STARTTLSUpgraded
}
if ep.NullSenderAccepted != nil && !*ep.NullSenderAccepted {
ok = false
}
if ep.PostmasterAccepted != nil && !*ep.PostmasterAccepted {
ok = false
}
if ep.OpenRelay != nil && *ep.OpenRelay {
ok = false
}
re.AnyFail = !ok
switch {
case !ep.TCPConnected:
re.StatusLabel = "unreachable"
re.StatusClass = "fail"
case ep.OpenRelay != nil && *ep.OpenRelay:
re.StatusLabel = "OPEN RELAY"
re.StatusClass = "fail"
case !ok:
re.StatusLabel = "partial"
re.StatusClass = "warn"
default:
re.StatusLabel = "OK"
re.StatusClass = "ok"
}
view.Endpoints = append(view.Endpoints, re)
}
return view
}
// fixesFromStates turns the rule-driven CheckStates into the hint/fix
// entries the report renders. It consumes Message, Meta["fix"], and Status
// exclusively, the derivation of those fields lives in the rules, not
// here. States that do not represent a finding (OK, Unknown) are skipped.
func fixesFromStates(states []sdk.CheckState) []reportFix {
out := make([]reportFix, 0, len(states))
for _, st := range states {
sev := statusToSeverity(st.Status)
if sev == "" {
continue
}
fix := ""
endpoint := ""
target := ""
if st.Meta != nil {
if s, ok := st.Meta["fix"].(string); ok {
fix = s
}
if s, ok := st.Meta["endpoint"].(string); ok {
endpoint = s
}
if s, ok := st.Meta["target"].(string); ok {
target = s
}
}
out = append(out, reportFix{
Severity: sev,
Code: st.Code,
Message: st.Message,
Fix: fix,
Endpoint: endpoint,
Target: target,
})
}
return out
}
// statusToSeverity maps an sdk.Status to the severity strings used by the
// HTML template. Status values that represent a non-finding (OK, Unknown)
// return "" so the caller can skip them.
func statusToSeverity(s sdk.Status) string {
switch s {
case sdk.StatusCrit, sdk.StatusError:
return SeverityCrit
case sdk.StatusWarn:
return SeverityWarn
case sdk.StatusInfo:
return SeverityInfo
default:
return ""
}
}
// overallStatus picks the overall badge label/class. When there are no
// states at all (data-only render), we fall back to a neutral "data only"
// badge instead of claiming "OK", we can't assert anything we haven't
// actually evaluated.
func overallStatus(d *SMTPData, states []sdk.CheckState, fixes []reportFix) (string, string) {
if d.MX.NullMX {
return "NULL MX", "info"
}
if len(states) == 0 {
return "data only", "muted"
}
worst := ""
for _, f := range fixes {
if f.Severity == SeverityCrit {
worst = SeverityCrit
break
}
if f.Severity == SeverityWarn {
worst = SeverityWarn
} else if worst == "" && f.Severity == SeverityInfo {
worst = SeverityInfo
}
}
switch worst {
case SeverityCrit:
return "FAIL", "fail"
case SeverityWarn:
return "WARN", "warn"
case SeverityInfo:
return "INFO", "info"
default:
return "OK", "ok"
}
}
func indexTLSByAddress(related []sdk.RelatedObservation) map[string]*reportTLSPosture {
out := map[string]*reportTLSPosture{}
for _, r := range related {
v := parseTLSRelated(r)
if v == nil {
continue
}
addr := v.address()
if addr == "" {
continue
}
posture := &reportTLSPosture{
CheckedAt: r.CollectedAt,
ChainValid: v.ChainValid,
HostnameMatch: v.HostnameMatch,
NotAfter: v.NotAfter,
}
for _, is := range v.Issues {
sev := strings.ToLower(is.Severity)
if sev != SeverityCrit && sev != SeverityWarn && sev != SeverityInfo {
continue
}
posture.Issues = append(posture.Issues, reportFix{
Severity: sev,
Code: is.Code,
Message: is.Message,
Fix: is.Fix,
})
}
out[addr] = posture
}
return out
}