mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-11-04 11:17:19 +00:00
Add more net settings
This commit is contained in:
1 parent
8176e47ca4
commit
67ec0b6d88
16 files changed
+1027
-6
No files matched your search
@@ -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.")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user