Add more net settings

This commit is contained in:
Jonathon Leight committed 2026-10-25 14:37:03 -04:00
1 parent 8176e47ca4
commit 67ec0b6d88
16 files changed
+1027 -6

No files matched your search

+170
View File
@@ -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.")
}
+122
View File
@@ -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")
}
}
+18
View File
@@ -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)
}
+1
View File
@@ -32,6 +32,7 @@
<div class="card"><div class="card-body">
<div class="subheader">Status</div>
<div class="h3 m-0">{{if .Confirmed}}Confirmed{{else}}Waiting for an advert{{end}}</div>
{{with .AdvertName}}<div class="text-secondary small mt-1" data-testid="companion-advert-name">Advertises as <strong>{{.}}</strong></div>{{end}}
</div></div>
</div>
<div class="col-md-4">
+66
View File
@@ -56,6 +56,72 @@
</div>
</div>
{{/* Members see where net control's messages go out, so they can listen;
a private channel's key never leaves the server. */}}
<div class="card mb-3" data-testid="net-air">
<div class="card-header"><h2 class="card-title">On the air</h2></div>
<div class="card-body">
<p class="text-secondary small">When net control connects a modem during a session, announcements and acknowledgements go out as channel messages here.</p>
<dl class="row mb-0" data-testid="net-air-summary">
<dt class="col-sm-4">Channel</dt>
<dd class="col-sm-8">{{with .Air}}{{if eq .ChannelKind "hashtag"}}<code>#{{.ChannelName}}</code>{{else if eq .ChannelKind "private"}}{{if .ChannelName}}{{.ChannelName}}{{else}}A private channel{{end}} <span class="text-secondary">(private)</span>{{else}}Public{{end}}{{end}}</dd>
<dt class="col-sm-4">Scope</dt>
<dd class="col-sm-8">{{if .Air.ScopeToken}}Region <code>{{.Air.ScopeToken}}</code>{{else}}Unscoped{{end}}</dd>
<dt class="col-sm-4">Sent as</dt>
<dd class="col-sm-8">{{if .Air.OnAirName}}{{.Air.OnAirName}}{{else}}{{.Net.Name}}{{end}}</dd>
<dt class="col-sm-4">Logged</dt>
<dd class="col-sm-8">{{if .Air.LogPrefix}}Channel messages starting with <code>{{.Air.LogPrefix}}</code>{{else}}Nothing{{end}}</dd>
<dt class="col-sm-4">Radio</dt>
<dd class="col-sm-8">{{with .Radio}}{{.MHz}} MHz · BW{{.KHz}} · SF{{.SF}} · CR{{.CR}}{{else}}<span class="text-danger" data-testid="net-air-no-radio">Not set — an admin sets the organization's radio on the Configuration tab.</span>{{end}}</dd>
</dl>
{{if .IsAdmin}}
<form method="post" action="/orgs/{{.Org.Slug}}/nets/{{.Net.ID}}/air" class="mt-3 border-top pt-3">
<div class="row g-2 mb-3">
<div class="col-md-4">
<label class="form-label" for="air-kind">Channel</label>
<select id="air-kind" name="channel_kind" class="form-select">
<option value="public"{{if eq .Air.ChannelKind "public"}} selected{{end}}>Public</option>
<option value="hashtag"{{if eq .Air.ChannelKind "hashtag"}} selected{{end}}>Hashtag channel</option>
<option value="private"{{if eq .Air.ChannelKind "private"}} selected{{end}}>Private channel</option>
</select>
</div>
<div class="col-md-8">
<label class="form-label" for="air-name">Channel name</label>
<input type="text" id="air-name" name="channel_name" class="form-control" maxlength="31" value="{{.Air.ChannelName}}" placeholder="wnynet">
<div class="form-hint">A hashtag channel's name (exactly as members join it — case matters), or a label for a private channel.</div>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="air-key">Private channel key</label>
<input type="password" id="air-key" name="channel_key" class="form-control font-monospace" autocomplete="off"
placeholder="{{if .Air.HasKey}}Stored — leave blank to keep it{{else}}32 hex digits{{end}}">
<div class="form-hint">Only for a private channel: the secret the companion app shares. It's stored encrypted and never shown again.</div>
</div>
<div class="row g-2 mb-3">
<div class="col-md-6">
<label class="form-label" for="air-scope">Scope to region</label>
<select id="air-scope" name="scope_region" class="form-select">
<option value="">Unscoped</option>
{{range .Air.Regions}}<option value="{{.ID}}"{{if eq .ID $.Air.ScopeID}} selected{{end}}>{{.DisplayName}} ({{.Token}})</option>{{end}}
</select>
<div class="form-hint">Repeaters that deny unscoped floods still repeat messages scoped to a region they allow.</div>
</div>
<div class="col-md-6">
<label class="form-label" for="air-sender">On-air name</label>
<input type="text" id="air-sender" name="on_air_name" class="form-control" maxlength="32" value="{{.Air.OnAirName}}" placeholder="{{.Net.Name}}">
</div>
</div>
<div class="mb-3">
<label class="form-label" for="air-prefix">Log prefix</label>
<input type="text" id="air-prefix" name="log_prefix" class="form-control" maxlength="16" value="{{.Air.LogPrefix}}">
<div class="form-hint">Channel messages starting with this are logged to the session (marked unverified — anyone can send under any name). Leave blank to log nothing.</div>
</div>
<button type="submit" class="btn btn-primary" data-testid="net-air-save">Save on-air settings</button>
</form>
{{end}}
</div>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title">Sessions</h2></div>
{{if .Sessions}}
+1
View File
@@ -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)
+45
View File
@@ -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)
}
+2 -2
View File
@@ -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)
}
}
+1
View File
@@ -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)
+169
View File
@@ -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
}
+181
View File
@@ -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)
}
}
+6 -2
View File
@@ -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)
}
+5 -2
View File
@@ -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
}
@@ -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;
+83
View File
@@ -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
}
+116
View File
@@ -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: &region, 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: &region}, 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)
}
}