From 67ec0b6d885925aee4943612beea68084b6047b3 Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Sun, 25 Oct 2026 14:37:03 -0400 Subject: [PATCH] Add more net settings --- internal/core/net_air.go | 170 ++++++++++++++++ internal/core/net_air_test.go | 122 ++++++++++++ internal/core/nets.go | 18 ++ internal/core/templates/companion.html | 1 + internal/core/templates/org_net.html | 66 +++++++ internal/core/web.go | 1 + internal/e2e/checkins_test.go | 45 +++++ internal/ingest/ingest_test.go | 4 +- internal/ingest/ingester.go | 1 + internal/mesh/grouptext.go | 169 ++++++++++++++++ internal/mesh/grouptext_test.go | 181 ++++++++++++++++++ internal/store/checkins.go | 8 +- internal/store/companions.go | 7 +- internal/store/migrations/0051_net_on_air.sql | 41 ++++ internal/store/net_air.go | 83 ++++++++ internal/store/net_air_test.go | 116 +++++++++++ 16 files changed, 1027 insertions(+), 6 deletions(-) create mode 100644 internal/core/net_air.go create mode 100644 internal/core/net_air_test.go create mode 100644 internal/mesh/grouptext.go create mode 100644 internal/mesh/grouptext_test.go create mode 100644 internal/store/migrations/0051_net_on_air.sql create mode 100644 internal/store/net_air.go create mode 100644 internal/store/net_air_test.go diff --git a/internal/core/net_air.go b/internal/core/net_air.go new file mode 100644 index 0000000..1cbe3b4 --- /dev/null +++ b/internal/core/net_air.go @@ -0,0 +1,170 @@ +package core + +import ( + "encoding/base64" + "encoding/hex" + "errors" + "net/http" + "strconv" + "strings" + "unicode" + "unicode/utf8" + + "github.com/go-chi/chi/v5" + + "github.com/MeshTender/MeshTender/internal/secretbox" + "github.com/MeshTender/MeshTender/internal/store" + "github.com/MeshTender/MeshTender/internal/web" +) + +// A net's on-air settings: how net control's modem talks to it (store.NetAir). + +const ( + // maxChannelName is a channel name's limit in the companion firmware + // (ChannelDetails::name is 32 bytes with its terminator). + maxChannelName = 31 + // maxOnAirName keeps the sender name short enough to leave most of a + // 160-byte message for the text. + maxOnAirName = 32 + maxLogPrefix = 16 +) + +// netAirView is the on-air card's data. +type netAirView struct { + *store.NetAir + Regions []store.Region // admins' scope choices +} + +// ScopeID is the scope region's id, 0 when unscoped. +func (v netAirView) ScopeID() int64 { + if v.ScopeRegionID == nil { + return 0 + } + return *v.ScopeRegionID +} + +// parseChannelKey reads a private channel's 16-byte key as companion apps +// share it: 32 hex digits, or base64. +func parseChannelKey(s string) ([16]byte, bool) { + var key [16]byte + s = strings.TrimSpace(s) + b, err := hex.DecodeString(s) + if err != nil || len(b) != 16 { + if b, err = base64.StdEncoding.DecodeString(s); err != nil || len(b) != 16 { + return key, false + } + } + copy(key[:], b) + return key, true +} + +// plainText reports whether s has no control characters. +func plainText(s string) bool { + return strings.IndexFunc(s, unicode.IsControl) < 0 +} + +// parseNetAirForm validates the on-air form against the org's regions. key is +// the private channel key when one was entered. msg is a user-facing error. +func parseNetAirForm(r *http.Request, regions []store.Region) (a store.NetAir, key []byte, msg string) { + a.ChannelKind = r.FormValue("channel_kind") + a.ChannelName = strings.TrimSpace(r.FormValue("channel_name")) + switch a.ChannelKind { + case store.ChannelPublic: + a.ChannelName = "" + case store.ChannelHashtag: + // The key is SHA256 of the exact "#name", so keep it as typed. + a.ChannelName = strings.TrimPrefix(a.ChannelName, "#") + if a.ChannelName == "" || len(a.ChannelName)+1 > maxChannelName || strings.ContainsFunc(a.ChannelName, unicode.IsSpace) || + !plainText(a.ChannelName) { + return a, nil, "Enter the hashtag channel's name, without spaces (up to 30 characters)." + } + case store.ChannelPrivate: + if len(a.ChannelName) > maxChannelName || !plainText(a.ChannelName) { + return a, nil, "The channel name is too long." + } + if raw := r.FormValue("channel_key"); strings.TrimSpace(raw) != "" { + k, ok := parseChannelKey(raw) + if !ok { + return a, nil, "The channel key must be 32 hex digits (or base64), as the companion app shows it." + } + key = k[:] + } + default: + return a, nil, "Choose a channel." + } + if v := r.FormValue("scope_region"); v != "" { + id, err := strconv.ParseInt(v, 10, 64) + found := false + for _, rg := range regions { + found = found || (err == nil && rg.ID == id) + } + if !found { + return a, nil, "Choose one of this organization's regions." + } + a.ScopeRegionID = &id + } + a.OnAirName = strings.TrimSpace(r.FormValue("on_air_name")) + if len(a.OnAirName) > maxOnAirName || strings.Contains(a.OnAirName, ":") || !plainText(a.OnAirName) { + // Receivers split "Sender: text" at the first ": ". + return a, nil, "The on-air name can be up to 32 characters, without a colon." + } + a.LogPrefix = strings.TrimSpace(r.FormValue("log_prefix")) + if utf8.RuneCountInString(a.LogPrefix) > maxLogPrefix || !plainText(a.LogPrefix) { + return a, nil, "The log prefix can be up to 16 characters." + } + return a, key, "" +} + +func (s *Handlers) handleUpdateNetAir(w http.ResponseWriter, r *http.Request) { + id, ok := s.requireOrgAdmin(w, r) + if !ok { + return + } + nid, err := strconv.ParseInt(chi.URLParam(r, "nid"), 10, 64) + if err != nil { + s.NotFound(w, r) + return + } + ctx := r.Context() + regions, err := s.Store.ListRegions(ctx, id) + if err != nil { + s.ServerError(w, r, "Couldn't load regions.", err) + return + } + a, key, msg := parseNetAirForm(r, regions) + if msg != "" { + web.RedirectErr(w, r, netURL(r), msg) + return + } + var sealed []byte + if key != nil { + if sealed, err = secretbox.Seal(s.Cfg.MasterKey, key); err != nil { + s.ServerError(w, r, "Couldn't store the channel key.", err) + return + } + } else if a.ChannelKind == store.ChannelPrivate { + current, err := s.Store.GetNetAir(ctx, id, nid) + if errors.Is(err, store.ErrNotFound) { + s.NotFound(w, r) + return + } + if err != nil { + s.ServerError(w, r, "Couldn't load the net.", err) + return + } + if current.ChannelKind != store.ChannelPrivate || !current.HasKey() { + web.RedirectErr(w, r, netURL(r), "Enter the private channel's key.") + return + } + } + err = s.Store.UpdateNetAir(ctx, id, nid, a, sealed) + if errors.Is(err, store.ErrNotFound) { + s.NotFound(w, r) + return + } + if err != nil { + s.ServerError(w, r, "Couldn't save the on-air settings.", err) + return + } + web.RedirectFlash(w, r, netURL(r), "ok", "On-air settings saved.") +} diff --git a/internal/core/net_air_test.go b/internal/core/net_air_test.go new file mode 100644 index 0000000..53f20e4 --- /dev/null +++ b/internal/core/net_air_test.go @@ -0,0 +1,122 @@ +package core + +import ( + "bytes" + "encoding/hex" + "net/http" + "net/url" + "strconv" + "strings" + "testing" + "time" + + "github.com/MeshTender/MeshTender/internal/secretbox" + "github.com/MeshTender/MeshTender/internal/store" +) + +// TestNetAirSettings: an admin sets where net control's messages go out. A +// private channel's key is sealed and write-only — kept when the field is left +// blank, never rendered — and members see the summary but not the form. +func TestNetAirSettings(t *testing.T) { + t.Parallel() + st, ctx, ts, h := splitServer(t) + admin, adminSess := appLogin(t, ts, st, ctx, h.app, "airadmin") + member, memberSess := appLogin(t, ts, st, ctx, h.app, "airmember") + org, err := st.CreateOrg(ctx, "Air Org", admin.ID) + if err != nil { + t.Fatal(err) + } + if err := st.AddOrgMember(ctx, org.ID, member.ID, "member"); err != nil { + t.Fatal(err) + } + other, err := st.CreateOrg(ctx, "Air Other", admin.ID) + if err != nil { + t.Fatal(err) + } + net, err := st.CreateNet(ctx, org.ID, store.NetInput{Name: "Sunday Net", Weekday: time.Sunday, StartMinute: 19 * 60, + DurationMinutes: 60, Timezone: "UTC", Active: true}, admin.ID, time.Now()) + if err != nil { + t.Fatal(err) + } + region, err := st.CreateRegion(ctx, org.ID, store.RegionInput{Token: "us-ny", DisplayName: "New York", AllowFlood: true}) + if err != nil { + t.Fatal(err) + } + foreign, err := st.CreateRegion(ctx, other.ID, store.RegionInput{Token: "us-ca", DisplayName: "California", AllowFlood: true}) + if err != nil { + t.Fatal(err) + } + netPath := "/orgs/" + org.Slug + "/nets/" + strconv.FormatInt(net.ID, 10) + save := func(sess *http.Cookie, form url.Values) *http.Response { + t.Helper() + resp := post(t, ts, h.app, netPath+"/air", form, sess) + resp.Body.Close() + return resp + } + air := func() *store.NetAir { + t.Helper() + a, err := st.GetNetAir(ctx, org.ID, net.ID) + if err != nil { + t.Fatal(err) + } + return a + } + + page := readBody(t, do(t, ts, h.app, netPath, adminSess)) + if !strings.Contains(page, `data-testid="net-air-no-radio"`) || !strings.Contains(page, "net-air-save") { + t.Fatal("admin's net page should show the on-air form and the missing org radio") + } + + // A member can't change them. + if r := save(memberSess, url.Values{"channel_kind": {"hashtag"}, "channel_name": {"x"}}); r.StatusCode != http.StatusNotFound { + t.Fatalf("member save = %d", r.StatusCode) + } + + // Rejected: another org's region, a colon in the sender name, a hashtag with no name. + for _, form := range []url.Values{ + {"channel_kind": {"public"}, "scope_region": {strconv.FormatInt(foreign, 10)}}, + {"channel_kind": {"public"}, "on_air_name": {"NC: Bob"}}, + {"channel_kind": {"hashtag"}, "channel_name": {"#"}}, + {"channel_kind": {"private"}, "channel_name": {"Ops"}}, // no key stored yet + {"channel_kind": {"private"}, "channel_key": {"not a key"}}, + } { + if r := save(adminSess, form); r.StatusCode != http.StatusSeeOther || !strings.Contains(r.Header.Get("Location"), "error=") { + t.Errorf("%v: status %d, Location %q", form, r.StatusCode, r.Header.Get("Location")) + } + } + if a := air(); a.ChannelKind != store.ChannelPublic || a.OnAirName != "" || a.ScopeRegionID != nil { + t.Fatalf("a rejected form changed the settings: %+v", a) + } + + save(adminSess, url.Values{"channel_kind": {"hashtag"}, "channel_name": {"#WNYnet"}, + "scope_region": {strconv.FormatInt(region, 10)}, "on_air_name": {"Net Control"}, "log_prefix": {"news:"}}) + if a := air(); a.ChannelName != "WNYnet" || a.ScopeToken != "us-ny" || a.OnAirName != "Net Control" { + t.Fatalf("hashtag settings = %+v", a) + } + + // A private channel: the key is sealed, and leaving the field blank keeps it. + key := "00112233445566778899aabbccddeeff" + save(adminSess, url.Values{"channel_kind": {"private"}, "channel_name": {"Ops"}, "channel_key": {key}}) + sealed := air().PSKSealed + want, _ := hex.DecodeString(key) + if pt, err := secretbox.Open(testMasterKey, sealed); err != nil || !bytes.Equal(pt, want) { + t.Fatalf("stored key opens to %x, %v", pt, err) + } + save(adminSess, url.Values{"channel_kind": {"private"}, "channel_name": {"Ops 2"}}) + if a := air(); !bytes.Equal(a.PSKSealed, sealed) || a.ChannelName != "Ops 2" { + t.Fatalf("resave without a key = %+v", a) + } + + for _, sess := range []*http.Cookie{adminSess, memberSess} { + page := readBody(t, do(t, ts, h.app, netPath, sess)) + if strings.Contains(page, key) || strings.Contains(page, hex.EncodeToString(sealed)) { + t.Fatal("the net page leaks the channel key") + } + if !strings.Contains(page, "Ops 2") || !strings.Contains(page, `data-testid="net-air-summary"`) { + t.Error("net page missing the on-air summary") + } + } + if page := readBody(t, do(t, ts, h.app, netPath, memberSess)); strings.Contains(page, "net-air-save") { + t.Error("a member sees the on-air form") + } +} diff --git a/internal/core/nets.go b/internal/core/nets.go index cb03b5c..04a598e 100644 --- a/internal/core/nets.go +++ b/internal/core/nets.go @@ -184,12 +184,24 @@ func (s *Handlers) pageOrgNet(w http.ResponseWriter, r *http.Request) { s.ServerError(w, r, "Couldn't load net control.", err) return } + air, err := s.Store.GetNetAir(ctx, org.ID, net.ID) + if err != nil { + s.ServerError(w, r, "Couldn't load the on-air settings.", err) + return + } + radio, err := s.Store.OrgRadio(ctx, org.ID) + if err != nil { + s.ServerError(w, r, "Couldn't load the radio settings.", err) + return + } data := map[string]any{ "Org": org, "Nav": s.orgNav(ctx, org, "nets", isAdmin), "IsAdmin": isAdmin, "Net": net, "Controls": controls, + "Air": netAirView{NetAir: air}, + "Radio": radio, "Schedule": netSchedule(net), "Next": next, "Now": now, @@ -221,6 +233,12 @@ func (s *Handlers) pageOrgNet(w http.ResponseWriter, r *http.Request) { } } data["Candidates"] = candidates + regions, err := s.Store.ListRegions(ctx, org.ID) + if err != nil { + s.ServerError(w, r, "Couldn't load regions.", err) + return + } + data["Air"] = netAirView{NetAir: air, Regions: regions} } s.Render(w, r, "org_net.html", data) } diff --git a/internal/core/templates/companion.html b/internal/core/templates/companion.html index 76bd2b3..e077ded 100644 --- a/internal/core/templates/companion.html +++ b/internal/core/templates/companion.html @@ -32,6 +32,7 @@
Status
{{if .Confirmed}}Confirmed{{else}}Waiting for an advert{{end}}
+ {{with .AdvertName}}
Advertises as {{.}}
{{end}}
diff --git a/internal/core/templates/org_net.html b/internal/core/templates/org_net.html index db2455c..ada55c8 100644 --- a/internal/core/templates/org_net.html +++ b/internal/core/templates/org_net.html @@ -56,6 +56,72 @@
+ {{/* Members see where net control's messages go out, so they can listen; + a private channel's key never leaves the server. */}} +
+

On the air

+
+

When net control connects a modem during a session, announcements and acknowledgements go out as channel messages here.

+
+
Channel
+
{{with .Air}}{{if eq .ChannelKind "hashtag"}}#{{.ChannelName}}{{else if eq .ChannelKind "private"}}{{if .ChannelName}}{{.ChannelName}}{{else}}A private channel{{end}} (private){{else}}Public{{end}}{{end}}
+
Scope
+
{{if .Air.ScopeToken}}Region {{.Air.ScopeToken}}{{else}}Unscoped{{end}}
+
Sent as
+
{{if .Air.OnAirName}}{{.Air.OnAirName}}{{else}}{{.Net.Name}}{{end}}
+
Logged
+
{{if .Air.LogPrefix}}Channel messages starting with {{.Air.LogPrefix}}{{else}}Nothing{{end}}
+
Radio
+
{{with .Radio}}{{.MHz}} MHz · BW{{.KHz}} · SF{{.SF}} · CR{{.CR}}{{else}}Not set — an admin sets the organization's radio on the Configuration tab.{{end}}
+
+ {{if .IsAdmin}} +
+
+
+ + +
+
+ + +
A hashtag channel's name (exactly as members join it — case matters), or a label for a private channel.
+
+
+
+ + +
Only for a private channel: the secret the companion app shares. It's stored encrypted and never shown again.
+
+
+
+ + +
Repeaters that deny unscoped floods still repeat messages scoped to a region they allow.
+
+
+ + +
+
+
+ + +
Channel messages starting with this are logged to the session (marked unverified — anyone can send under any name). Leave blank to log nothing.
+
+ +
+ {{end}} +
+
+

Sessions

{{if .Sessions}} diff --git a/internal/core/web.go b/internal/core/web.go index b4ec5b0..3db448e 100644 --- a/internal/core/web.go +++ b/internal/core/web.go @@ -257,6 +257,7 @@ func (s *Handlers) appRouter() chi.Router { r.Get("/orgs/{id}/nets/{nid}", s.pageOrgNet) r.Post("/orgs/{id}/nets/{nid}", s.handleUpdateNet) r.Post("/orgs/{id}/nets/{nid}/delete", s.handleDeleteNet) + r.Post("/orgs/{id}/nets/{nid}/air", s.handleUpdateNetAir) r.Get("/orgs/{id}/nets/{nid}/sessions/{sid}", s.pageNetSession) r.Post("/orgs/{id}/nets/{nid}/controls", s.handleAddNetControl) r.Post("/orgs/{id}/nets/{nid}/controls/{uid}/delete", s.handleRemoveNetControl) diff --git a/internal/e2e/checkins_test.go b/internal/e2e/checkins_test.go index 62a6b14..20aa36e 100644 --- a/internal/e2e/checkins_test.go +++ b/internal/e2e/checkins_test.go @@ -247,3 +247,48 @@ func TestE2ENetControlAcknowledgesAndRosterRefreshes(t *testing.T) { } watch.assertClean(t) } + +// TestE2ENetAirSettings: an admin saves a net's on-air settings through the +// real form on the net page, and the summary reflects them. +func TestE2ENetAirSettings(t *testing.T) { + srv := newE2EServer(t) + user, cookie := srv.login(t, "e2enetair") + org, err := srv.store.CreateOrg(srv.ctx, "Air Org", user.ID) + if err != nil { + t.Fatalf("create org: %v", err) + } + net, err := srv.store.CreateNet(srv.ctx, org.ID, store.NetInput{Name: "Air Net", Weekday: time.Sunday, StartMinute: 19 * 60, + DurationMinutes: 60, Timezone: "UTC", Active: true}, user.ID, time.Now()) + if err != nil { + t.Fatal(err) + } + if _, err := srv.store.CreateRegion(srv.ctx, org.ID, store.RegionInput{Token: "us-ny", DisplayName: "New York", AllowFlood: true}); err != nil { + t.Fatal(err) + } + + bctx, cancel, watch := startBrowser(t) + defer cancel() + var summary string + if err := chromedp.Run(bctx, + network.Enable(), + cdplog.Enable(), + setSessionCookie(cookie), + chromedp.Navigate(fmt.Sprintf("%s/orgs/%s/nets/%d", srv.appURL, org.Slug, net.ID)), + chromedp.WaitVisible(`[data-testid="net-air-save"]`, chromedp.ByQuery), + chromedp.SetValue(`#air-kind`, "hashtag", chromedp.ByQuery), + chromedp.SetValue(`#air-name`, "wnynet", chromedp.ByQuery), + chromedp.Evaluate(`(() => { const s = document.getElementById("air-scope"); s.value = s.options[1].value; })()`, nil), + chromedp.SetValue(`#air-sender`, "Net Control", chromedp.ByQuery), + chromedp.Click(`[data-testid="net-air-save"]`, chromedp.ByQuery), + chromedp.WaitVisible(`.alert-success`, chromedp.ByQuery), + chromedp.Text(`[data-testid="net-air-summary"]`, &summary, chromedp.ByQuery), + ); err != nil { + t.Fatalf("save on-air settings: %v", err) + } + for _, want := range []string{"#wnynet", "us-ny", "Net Control", "news:"} { + if !strings.Contains(summary, want) { + t.Errorf("summary missing %q: %q", want, summary) + } + } + watch.assertClean(t) +} diff --git a/internal/ingest/ingest_test.go b/internal/ingest/ingest_test.go index c3a50c8..3ba9e23 100644 --- a/internal/ingest/ingest_test.go +++ b/internal/ingest/ingest_test.go @@ -136,8 +136,8 @@ func TestIngesterRecordsVerifiedAdvert(t *testing.T) { if k := got[0]; k.Observers != 1 || k.Spread != 2 || k.ZeroHop || k.AdvertTS != uint32(now.Unix()) { //nolint:gosec // test clock t.Fatalf("check-in = %+v", k) } - if !f.companionState(t, ctx).Confirmed() { - t.Fatal("companion not confirmed") + if c := f.companionState(t, ctx); !c.Confirmed() || c.AdvertName != "Owner T1" { + t.Fatalf("companion confirmed %v, advertised name %q", c.Confirmed(), c.AdvertName) } } diff --git a/internal/ingest/ingester.go b/internal/ingest/ingester.go index 7566655..5881136 100644 --- a/internal/ingest/ingester.go +++ b/internal/ingest/ingester.go @@ -161,6 +161,7 @@ func (in *Ingester) handlePacket(ctx context.Context, m Message, observer string ReceivedAt: m.ReceivedAt, ZeroHop: adv.ZeroHop(), Relays: adv.Path, + Name: adv.Name, }) if err != nil { in.Log.Warn("ingest: record check-in", "err", err) diff --git a/internal/mesh/grouptext.go b/internal/mesh/grouptext.go new file mode 100644 index 0000000..41c226a --- /dev/null +++ b/internal/mesh/grouptext.go @@ -0,0 +1,169 @@ +package mesh + +import ( + "encoding/base64" + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" + + meshcore "github.com/meshcore-go/meshcore-go" +) + +// Channel messages (GRP_TXT) are what a MeshCore companion shows in a channel: +// [channel hash][2-byte MAC][AES-128-ECB(timestamp LE | flags | "Sender: text")], +// flood-routed, never acknowledged. Anyone with the channel key can send one +// under any sender name — the name is unauthenticated text. +// (firmware: BaseChatMesh::sendGroupMessage, Mesh::createGroupDatagram.) + +// MaxGroupTextLen is the most a channel message can carry, counting the +// "Sender: " prefix: the firmware's MAX_TEXT_LEN (BaseChatMesh.h), which it +// truncates to on send. +const MaxGroupTextLen = 160 + +// publicChannelPSK is the well-known key of the Public channel every companion +// ships with. +const publicChannelPSK = "izOH6cXN6mrJ5e26oRXNcg==" + +// Channel is a group channel's key and the 1-byte hash a packet names it by. +type Channel struct { + PSK [16]byte + Hash byte +} + +// PublicChannel is the default Public channel. +func PublicChannel() Channel { + psk, err := base64.StdEncoding.DecodeString(publicChannelPSK) + if err != nil { + panic(err) // a constant + } + var k [16]byte + copy(k[:], psk) + return PrivateChannel(k) +} + +// HashtagChannel is a public "#name" channel, keyed by its name. +func HashtagChannel(name string) Channel { + e := meshcore.NewChannelFromHashtag(name) + return Channel{PSK: e.PSK, Hash: e.Hash} +} + +// PrivateChannel is a channel with a shared secret key. +func PrivateChannel(psk [16]byte) Channel { + return Channel{PSK: psk, Hash: meshcore.DeriveChannelHash(psk)} +} + +// ErrPrivateRegion is returned when asked to scope to a "$" region: those +// have secret keys a repeater loads separately, which we don't hold. +var ErrPrivateRegion = errors.New("mesh: can't scope to a private ($) region") + +// RegionScope is the transport key for a region token as used in `region def`. +// The firmware treats a bare token as the public hashtag region "#token" +// (RegionMap::getTransportKeysFor) and keys it SHA256("#token")[:16]. +func RegionScope(token string) (meshcore.RegionKey, error) { + if strings.HasPrefix(token, "$") { + return meshcore.RegionKey{}, ErrPrivateRegion + } + return meshcore.NewRegionFromHashtag(token).Key, nil +} + +// GroupTextRoom is how many bytes of text fit in one message from sender. +func GroupTextRoom(sender string) int { + return MaxGroupTextLen - len(sender) - len(": ") +} + +// FitGroupText trims text to fit one message from sender, cutting on a +// character boundary and marking the cut with an ellipsis. +func FitGroupText(sender, text string) string { + room := GroupTextRoom(sender) + if len(text) <= room { + return text + } + const more = "…" + return truncateUTF8(text, room-len(more)) + more +} + +// truncateUTF8 returns the longest prefix of s of at most n bytes that doesn't +// split a character. +func truncateUTF8(s string, n int) string { + if n <= 0 { + return "" + } + if len(s) <= n { + return s + } + for n > 0 && !utf8.RuneStart(s[n]) { + n-- + } + return s[:n] +} + +// BuildGroupText encodes a channel message from sender, as a companion sends +// it: flags 0 (plain text), text trimmed to fit (FitGroupText), flooded with +// our usual 3-byte path hashes. A non-nil scope sends it as a transport flood +// scoped to that region — transport code 0 is the region's code for this +// packet, code 1 is 0, exactly as a companion's sendFloodScoped does — so +// repeaters that deny unscoped floods still repeat it. +func BuildGroupText(ch Channel, sender, text string, scope *meshcore.RegionKey, now time.Time) ([]byte, error) { + msg := meshcore.GroupTextPayload{ + Timestamp: uint32(now.Unix()), //nolint:gosec // unix seconds fit until 2106, as the firmware's + Sender: sender, + Text: FitGroupText(sender, text), + } + gt, err := msg.Encrypt(ch.Hash, ch.PSK[:]) + if err != nil { + return nil, fmt.Errorf("encrypt group text: %w", err) + } + payload, err := gt.ToBytes() + if err != nil { + return nil, fmt.Errorf("encode group text: %w", err) + } + pkt := &meshcore.Packet{ + Header: meshcore.MakeHeader(meshcore.RouteTypeFlood, meshcore.PayloadTypeGrpTxt, 0), + PathLength: floodPathLen, + Payload: payload, + } + if scope != nil { + pkt.Header = meshcore.MakeHeader(meshcore.RouteTypeTransportFlood, meshcore.PayloadTypeGrpTxt, 0) + pkt.TransportCode1 = scope.CalcTransportCode(meshcore.PayloadTypeGrpTxt, payload) + } + raw, err := pkt.ToBytes() + if err != nil { + return nil, fmt.Errorf("encode packet: %w", err) + } + return raw, nil +} + +// GroupText is a channel message as heard. Sender is whatever name the sender +// typed — unauthenticated, anyone with the key can claim any name. +type GroupText struct { + Sender string + Text string + Timestamp uint32 +} + +// DecodeGroupText reads a received packet as a plain-text message on ch. ok is +// false for anything else: another packet type or channel, a MAC that doesn't +// verify under ch's key, or a non-text flags type (the firmware drops +// flags>>2 != 0, BaseChatMesh::onGroupDataRecv). The text is capped at +// MaxGroupTextLen like the firmware's own buffer. +func DecodeGroupText(raw []byte, ch Channel) (GroupText, bool) { + pkt, err := meshcore.PacketFromBytes(raw) + if err != nil || pkt.PayloadType() != meshcore.PayloadTypeGrpTxt { + return GroupText{}, false + } + gt, err := meshcore.GroupTextFromBytes(pkt.Payload) + if err != nil || gt.ChannelHash != ch.Hash { + return GroupText{}, false + } + p, err := gt.DecryptStruct(ch.PSK[:]) + if err != nil || p.Flags>>2 != 0 { + return GroupText{}, false + } + return GroupText{ + Sender: p.Sender, + Text: truncateUTF8(p.Text, MaxGroupTextLen), + Timestamp: p.Timestamp, + }, true +} diff --git a/internal/mesh/grouptext_test.go b/internal/mesh/grouptext_test.go new file mode 100644 index 0000000..0114974 --- /dev/null +++ b/internal/mesh/grouptext_test.go @@ -0,0 +1,181 @@ +package mesh + +import ( + "bytes" + "crypto/aes" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "errors" + "strings" + "testing" + "time" + "unicode/utf8" + + meshcore "github.com/meshcore-go/meshcore-go" +) + +// firmwareGroupPayload builds a GRP_TXT payload the way the firmware does, +// from the standard library alone (Mesh::createGroupDatagram, +// Utils::encryptThenMAC): AES-128-ECB over the zero-padded plaintext, then a +// 2-byte HMAC-SHA256 of the ciphertext keyed with the 32-byte secret (the PSK +// zero-extended, GroupChannel::secret[PUB_KEY_SIZE]). +func firmwareGroupPayload(t *testing.T, psk [16]byte, ts uint32, flags byte, text string) []byte { + t.Helper() + plain := binary.LittleEndian.AppendUint32(nil, ts) + plain = append(plain, flags) + plain = append(plain, text...) + for len(plain)%16 != 0 { + plain = append(plain, 0) + } + block, err := aes.NewCipher(psk[:]) + if err != nil { + t.Fatal(err) + } + enc := make([]byte, len(plain)) + for i := 0; i < len(plain); i += 16 { + block.Encrypt(enc[i:i+16], plain[i:i+16]) + } + secret := make([]byte, 32) + copy(secret, psk[:]) + mac := hmac.New(sha256.New, secret) + mac.Write(enc) + hash := sha256.Sum256(psk[:]) + return append(append([]byte{hash[0]}, mac.Sum(nil)[:2]...), enc...) +} + +func TestBuildGroupTextMatchesFirmware(t *testing.T) { + t.Parallel() + ch := PublicChannel() + raw, _ := base64.StdEncoding.DecodeString(publicChannelPSK) + if !bytes.Equal(ch.PSK[:], raw) { + t.Fatalf("public PSK = %x", ch.PSK) + } + now := time.Unix(1_790_000_000, 0) + got, err := BuildGroupText(ch, "Sunday Net", "Net is open", nil, now) + if err != nil { + t.Fatal(err) + } + want := append([]byte{ + meshcore.MakeHeader(meshcore.RouteTypeFlood, meshcore.PayloadTypeGrpTxt, 0), + floodPathLen, + }, firmwareGroupPayload(t, ch.PSK, uint32(now.Unix()), 0, "Sunday Net: Net is open")...) + if !bytes.Equal(got, want) { + t.Fatalf("packet\n got %x\nwant %x", got, want) + } +} + +func TestHashtagChannelKey(t *testing.T) { + t.Parallel() + sum := sha256.Sum256([]byte("#wnynet")) + for _, name := range []string{"wnynet", "#wnynet"} { + if ch := HashtagChannel(name); !bytes.Equal(ch.PSK[:], sum[:16]) { + t.Errorf("%q PSK = %x, want SHA256(\"#wnynet\")[:16]", name, ch.PSK) + } + } +} + +func TestGroupTextRoundTrip(t *testing.T) { + t.Parallel() + ch := HashtagChannel("#wnynet") + now := time.Unix(1_790_000_000, 0) + raw, err := BuildGroupText(ch, "Net Control", "Copied: @[Xero T1]", nil, now) + if err != nil { + t.Fatal(err) + } + got, ok := DecodeGroupText(raw, ch) + if !ok || got.Sender != "Net Control" || got.Text != "Copied: @[Xero T1]" || got.Timestamp != uint32(now.Unix()) { + t.Fatalf("decoded %+v, %v", got, ok) + } + if _, ok := DecodeGroupText(raw, PublicChannel()); ok { + t.Error("decoded under another channel's key") + } + // Same hash byte, wrong key: the MAC must reject it. + other := ch + other.PSK[0] ^= 1 + if _, ok := DecodeGroupText(raw, other); ok { + t.Error("decoded under a wrong key with a matching hash") + } + // A non-text type (flags>>2 != 0) is dropped, as the firmware does. + data := append([]byte{ + meshcore.MakeHeader(meshcore.RouteTypeFlood, meshcore.PayloadTypeGrpTxt, 0), 0, + }, firmwareGroupPayload(t, ch.PSK, 1, 1<<2, "x: y")...) + if _, ok := DecodeGroupText(data, ch); ok { + t.Error("decoded a non-plain-text type") + } + if _, ok := DecodeGroupText([]byte{1, 2}, ch); ok { + t.Error("decoded garbage") + } +} + +func TestGroupTextFitsFirmwareCap(t *testing.T) { + t.Parallel() + ch := PublicChannel() + sender := "Sunday Net" + short := "Copied: @[A]" + if FitGroupText(sender, short) != short { + t.Fatal("trimmed a message that fits") + } + // Multi-byte characters straddling the cut must not be split. + long := strings.Repeat("é", 200) + raw, err := BuildGroupText(ch, sender, long, nil, time.Unix(1, 0)) + if err != nil { + t.Fatal(err) + } + got, ok := DecodeGroupText(raw, ch) + if !ok { + t.Fatal("didn't decode") + } + if n := len(sender + ": " + got.Text); n > MaxGroupTextLen { + t.Errorf("message is %d bytes, over the firmware's %d", n, MaxGroupTextLen) + } + if !utf8.ValidString(got.Text) || !strings.HasSuffix(got.Text, "…") { + t.Errorf("trimmed text %q", got.Text) + } + if exact := strings.Repeat("a", GroupTextRoom(sender)); FitGroupText(sender, exact) != exact { + t.Error("trimmed a message of exactly the room") + } +} + +func TestScopedGroupText(t *testing.T) { + t.Parallel() + key, err := RegionScope("us-ny") + if err != nil { + t.Fatal(err) + } + // The firmware keys a bare token as the hashtag region "#us-ny". + sum := sha256.Sum256([]byte("#us-ny")) + if !bytes.Equal(key[:], sum[:16]) { + t.Fatalf("region key = %x", key) + } + ch := PublicChannel() + raw, err := BuildGroupText(ch, "NC", "hi", &key, time.Unix(1_790_000_000, 0)) + if err != nil { + t.Fatal(err) + } + pkt, err := meshcore.PacketFromBytes(raw) + if err != nil { + t.Fatal(err) + } + if pkt.RouteType() != meshcore.RouteTypeTransportFlood || pkt.TransportCode2 != 0 { + t.Fatalf("route %d, code2 %#x", pkt.RouteType(), pkt.TransportCode2) + } + // TransportKey::calcTransportCode: HMAC-SHA256(key, type ‖ payload), first + // two bytes little-endian. + mac := hmac.New(sha256.New, key[:]) + mac.Write([]byte{meshcore.PayloadTypeGrpTxt}) + mac.Write(pkt.Payload) + if want := binary.LittleEndian.Uint16(mac.Sum(nil)); pkt.TransportCode1 != want { + t.Errorf("transport code = %#x, want %#x", pkt.TransportCode1, want) + } + if !meshcore.NewRegionFromHashtag("us-ny").MatchesPacket(pkt) { + t.Error("a repeater with region us-ny wouldn't match it") + } + if got, ok := DecodeGroupText(raw, ch); !ok || got.Text != "hi" { + t.Errorf("scoped message didn't decode: %+v", got) + } + if _, err := RegionScope("$secret"); !errors.Is(err, ErrPrivateRegion) { + t.Errorf("private region err = %v", err) + } +} diff --git a/internal/store/checkins.go b/internal/store/checkins.go index 7f156db..0eec75a 100644 --- a/internal/store/checkins.go +++ b/internal/store/checkins.go @@ -38,6 +38,8 @@ type AdvertHeard struct { ZeroHop bool // Relays are the path entries: one hash per repeater that forwarded it. Relays [][]byte + // Name is the name the advert carries (signed with it). + Name string } // RecordAdvertHeard adds one copy of an advert to its check-in: the check-in @@ -86,8 +88,10 @@ func (s *Store) RecordAdvertHeard(ctx context.Context, h AdvertHeard) (bool, err UPDATE companions SET last_heard_at = GREATEST(last_heard_at, $2), last_advert_ts = GREATEST(last_advert_ts, $3), - confirmed_at = COALESCE(confirmed_at, CASE WHEN $2 >= created_at THEN $2 END) - WHERE id = $1`, h.CompanionID, h.ReceivedAt, int64(h.AdvertTS)) + confirmed_at = COALESCE(confirmed_at, CASE WHEN $2 >= created_at THEN $2 END), + -- The newest advert's name wins; a late copy of an older one doesn't. + advert_name = CASE WHEN $4 <> '' AND $3 >= COALESCE(last_advert_ts, 0) THEN $4 ELSE advert_name END + WHERE id = $1`, h.CompanionID, h.ReceivedAt, int64(h.AdvertTS), h.Name) if err != nil { return fmt.Errorf("advance companion: %w", err) } diff --git a/internal/store/companions.go b/internal/store/companions.go index 8a9e021..1c8caee 100644 --- a/internal/store/companions.go +++ b/internal/store/companions.go @@ -41,6 +41,9 @@ type Companion struct { // heard but didn't count because the companion's clock was too far off. LastRejectedAt *time.Time LastRejectedSkew *time.Duration + // AdvertName is the name its newest advert carries — what other companions + // list it as ("" until one is heard). + AdvertName string } // Confirmed reports whether an advert from this companion has been heard since @@ -50,7 +53,7 @@ func (c *Companion) Confirmed() bool { return c.ConfirmedAt != nil } const companionSelect = ` SELECT c.id, c.public_id, c.owner_id, COALESCE(NULLIF(u.display_name, ''), u.username), c.name, c.public_key_hex, c.created_at, c.confirmed_at, c.last_heard_at, - c.last_advert_ts, c.last_rejected_at, c.last_rejected_skew_s + c.last_advert_ts, c.last_rejected_at, c.last_rejected_skew_s, c.advert_name FROM companions c JOIN users u ON u.id = c.owner_id` func scanCompanion(row pgx.Row) (*Companion, error) { @@ -58,7 +61,7 @@ func scanCompanion(row pgx.Row) (*Companion, error) { var advertTS *int64 var skewS *int32 err := row.Scan(&c.ID, &c.PublicID, &c.OwnerID, &c.OwnerName, &c.Name, &c.PublicKeyHex, - &c.CreatedAt, &c.ConfirmedAt, &c.LastHeardAt, &advertTS, &c.LastRejectedAt, &skewS) + &c.CreatedAt, &c.ConfirmedAt, &c.LastHeardAt, &advertTS, &c.LastRejectedAt, &skewS, &c.AdvertName) if err != nil { return nil, err } diff --git a/internal/store/migrations/0051_net_on_air.sql b/internal/store/migrations/0051_net_on_air.sql new file mode 100644 index 0000000..6bc9992 --- /dev/null +++ b/internal/store/migrations/0051_net_on_air.sql @@ -0,0 +1,41 @@ +-- +goose Up +-- On the air: how net control's modem talks to the net. Announcements and +-- acknowledgements go out as channel messages (GRP_TXT) on the net's channel, +-- sent as on_air_name, optionally scoped to one of the org's regions so +-- repeaters that deny unscoped floods still carry them. Channel messages +-- starting with log_prefix are logged to the session (empty logs nothing). +-- The radio itself is the org's (organizations.radio_*). +ALTER TABLE check_in_nets + -- public: the default Public channel. hashtag: "#channel_name", keyed by + -- its name. private: a shared key, sealed under MESHTENDER_MASTER_KEY + -- (internal/secretbox) and never read back to the UI; channel_name is + -- then just a label. + ADD COLUMN channel_kind TEXT NOT NULL DEFAULT 'public' + CHECK (channel_kind IN ('public', 'hashtag', 'private')), + ADD COLUMN channel_name TEXT NOT NULL DEFAULT '', + ADD COLUMN channel_psk_sealed BYTEA, + -- The org's region; the store checks it's the same org's. A deleted + -- region leaves the net unscoped. + ADD COLUMN scope_region_id BIGINT REFERENCES config_regions(id) ON DELETE SET NULL, + -- Empty means the net's name. + ADD COLUMN on_air_name TEXT NOT NULL DEFAULT '', + ADD COLUMN log_prefix TEXT NOT NULL DEFAULT 'news:', + ADD CONSTRAINT check_in_nets_channel_complete CHECK ( + (channel_kind <> 'hashtag' OR channel_name <> '') AND + (channel_kind <> 'private' OR channel_psk_sealed IS NOT NULL)); + +-- The name a companion advertises (signed, public, from its latest advert): +-- what other companions list it as, so it's what an on-air acknowledgement +-- mentions (@[name]). The member's own label for it stays in name. +ALTER TABLE companions ADD COLUMN advert_name TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE companions DROP COLUMN advert_name; +ALTER TABLE check_in_nets + DROP CONSTRAINT check_in_nets_channel_complete, + DROP COLUMN log_prefix, + DROP COLUMN on_air_name, + DROP COLUMN scope_region_id, + DROP COLUMN channel_psk_sealed, + DROP COLUMN channel_name, + DROP COLUMN channel_kind; diff --git a/internal/store/net_air.go b/internal/store/net_air.go new file mode 100644 index 0000000..fe8642a --- /dev/null +++ b/internal/store/net_air.go @@ -0,0 +1,83 @@ +package store + +import ( + "context" + "fmt" +) + +// Channel kinds a net can send on (check_in_nets.channel_kind). +const ( + ChannelPublic = "public" + ChannelHashtag = "hashtag" + ChannelPrivate = "private" +) + +// NetAir is how net control's modem talks to a net: the channel its messages +// go out on, the region they're scoped to, the name they're sent as, and the +// prefix of channel messages worth logging. See migration 0051. +type NetAir struct { + ChannelKind string + // ChannelName is the hashtag channel's name (without "#"), or a private + // channel's label. + ChannelName string + // PSKSealed is a private channel's key, secretbox-sealed. It never leaves + // the server. + PSKSealed []byte + ScopeRegionID *int64 + // ScopeToken is the scope region's `region def` token ("" when unscoped). + ScopeToken string + // OnAirName is the sender name; empty means the net's name. + OnAirName string + // LogPrefix starts the channel messages logged to a session; empty logs none. + LogPrefix string +} + +// HasKey reports whether a private channel key is stored. +func (a *NetAir) HasKey() bool { return len(a.PSKSealed) > 0 } + +// GetNetAir returns the org's net's on-air settings, or ErrNotFound. +func (s *Store) GetNetAir(ctx context.Context, orgID, netID int64) (*NetAir, error) { + var a NetAir + var token *string + err := s.pool.QueryRow(ctx, ` + SELECT n.channel_kind, n.channel_name, n.channel_psk_sealed, n.scope_region_id, r.token, + n.on_air_name, n.log_prefix + FROM check_in_nets n + LEFT JOIN config_regions r ON r.id = n.scope_region_id + WHERE n.org_id = $1 AND n.id = $2`, orgID, netID). + Scan(&a.ChannelKind, &a.ChannelName, &a.PSKSealed, &a.ScopeRegionID, &token, &a.OnAirName, &a.LogPrefix) + if err != nil { + return nil, notFoundOr(err, "get net air") + } + if token != nil { + a.ScopeToken = *token + } + return &a, nil +} + +// UpdateNetAir saves a net's on-air settings. pskSealed replaces the stored +// private key when non-nil and is otherwise kept, the way the broker password +// is; switching away from a private channel drops it. The scope region must be +// one of the org's — anything else is ErrNotFound, as is a net that isn't the +// org's. The schema refuses a private channel with no key. +func (s *Store) UpdateNetAir(ctx context.Context, orgID, netID int64, a NetAir, pskSealed []byte) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE check_in_nets SET + channel_kind = $3, + channel_name = $4, + channel_psk_sealed = CASE WHEN $3 <> 'private' THEN NULL + ELSE COALESCE($5, channel_psk_sealed) END, + scope_region_id = $6, + on_air_name = $7, + log_prefix = $8 + WHERE org_id = $1 AND id = $2 + AND ($6::bigint IS NULL OR EXISTS (SELECT 1 FROM config_regions WHERE id = $6 AND org_id = $1))`, + orgID, netID, a.ChannelKind, a.ChannelName, pskSealed, a.ScopeRegionID, a.OnAirName, a.LogPrefix) + if err != nil { + return fmt.Errorf("update net air: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/store/net_air_test.go b/internal/store/net_air_test.go new file mode 100644 index 0000000..a06f5ac --- /dev/null +++ b/internal/store/net_air_test.go @@ -0,0 +1,116 @@ +package store + +import ( + "bytes" + "errors" + "testing" + "time" +) + +func TestNetAir(t *testing.T) { + t.Parallel() + st, ctx := orgTestStore(t) + admin := mustUser(t, st, ctx, "airadmin") + org := mustOrg(t, st, ctx, "AirOrg", admin) + other := mustOrg(t, st, ctx, "AirOther", admin) + net, err := st.CreateNet(ctx, org, testNetInput(), admin, time.Now()) + if err != nil { + t.Fatal(err) + } + region, err := st.CreateRegion(ctx, org, RegionInput{Token: "us-ny", DisplayName: "New York", AllowFlood: true}) + if err != nil { + t.Fatal(err) + } + foreign, err := st.CreateRegion(ctx, other, RegionInput{Token: "us-ca", DisplayName: "California", AllowFlood: true}) + if err != nil { + t.Fatal(err) + } + + a, err := st.GetNetAir(ctx, org, net.ID) + if err != nil || a.ChannelKind != ChannelPublic || a.LogPrefix != "news:" || a.ScopeRegionID != nil || a.OnAirName != "" { + t.Fatalf("defaults = %+v, %v", a, err) + } + if _, err := st.GetNetAir(ctx, other, net.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("another org's net = %v", err) + } + + hashtag := NetAir{ChannelKind: ChannelHashtag, ChannelName: "wnynet", ScopeRegionID: ®ion, OnAirName: "NC", LogPrefix: ""} + if err := st.UpdateNetAir(ctx, org, net.ID, hashtag, nil); err != nil { + t.Fatal(err) + } + a, _ = st.GetNetAir(ctx, org, net.ID) + if a.ChannelName != "wnynet" || a.ScopeToken != "us-ny" || a.OnAirName != "NC" || a.LogPrefix != "" { + t.Fatalf("after hashtag = %+v", a) + } + + // Scoping to another org's region is refused. + bad := hashtag + bad.ScopeRegionID = &foreign + if err := st.UpdateNetAir(ctx, org, net.ID, bad, nil); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign region = %v", err) + } + // A private channel needs a key, and keeps it when none is given. + private := NetAir{ChannelKind: ChannelPrivate, ChannelName: "Ops"} + if err := st.UpdateNetAir(ctx, org, net.ID, private, nil); err == nil { + t.Fatal("a private channel without a key was saved") + } + if err := st.UpdateNetAir(ctx, org, net.ID, private, []byte("sealed-key")); err != nil { + t.Fatal(err) + } + if err := st.UpdateNetAir(ctx, org, net.ID, private, nil); err != nil { + t.Fatal(err) + } + if a, _ = st.GetNetAir(ctx, org, net.ID); !bytes.Equal(a.PSKSealed, []byte("sealed-key")) { + t.Fatalf("key after resave = %q", a.PSKSealed) + } + // Leaving the private channel drops its key. + if err := st.UpdateNetAir(ctx, org, net.ID, NetAir{ChannelKind: ChannelPublic, ScopeRegionID: ®ion}, nil); err != nil { + t.Fatal(err) + } + if a, _ = st.GetNetAir(ctx, org, net.ID); a.HasKey() { + t.Fatal("public channel kept the private key") + } + // Deleting the region leaves the net unscoped. + if err := st.DeleteRegion(ctx, org, region); err != nil { + t.Fatal(err) + } + if a, _ = st.GetNetAir(ctx, org, net.ID); a.ScopeRegionID != nil || a.ScopeToken != "" { + t.Fatalf("after region delete = %+v", a) + } +} + +func TestRecordAdvertHeardKeepsNewestName(t *testing.T) { + t.Parallel() + st, ctx := orgTestStore(t) + u := mustUser(t, st, ctx, "namer") + c := mustCompanion(t, st, ctx, u, "My radio", 1) + at := c.CreatedAt.Add(time.Minute) + advert := func(ts uint32, name string) { + t.Helper() + heard(t, st, ctx, AdvertHeard{CompanionID: c.ID, AdvertTS: ts, ReceivedAt: at, ObserverPublicKeyHex: testKey(9), Name: name}) + } + name := func() string { + t.Helper() + got, err := st.GetCompanionForViewer(ctx, c.PublicID, u) + if err != nil { + t.Fatal(err) + } + return got.AdvertName + } + advert(1000, "Xero T1") + if got := name(); got != "Xero T1" { + t.Fatalf("advert name = %q", got) + } + advert(900, "Old Name") // a straggling copy of an older advert + if got := name(); got != "Xero T1" { + t.Fatalf("an older advert renamed it to %q", got) + } + advert(1100, "Xero T1 Pro") + if got := name(); got != "Xero T1 Pro" { + t.Fatalf("a newer advert didn't rename it: %q", got) + } + advert(1200, "") // no name in the advert + if got := name(); got != "Xero T1 Pro" { + t.Fatalf("a nameless advert cleared it: %q", got) + } +}