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 @@
{{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. */}}
+
+
+
+
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}}
+
+ {{end}}
+
+
+
{{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)
+ }
+}