Add region command placeholder

This commit is contained in:
Jonathon Leight
2026-07-06 19:13:35 -04:00
parent 25292848b2
commit 6026378e9d
9 changed files with 309 additions and 57 deletions
+28 -1
View File
@@ -426,6 +426,7 @@ func regionGeofence(zv configRegionView, name string, errs *[]string) ([]byte, b
// errs and risky commands in risky. It returns view steps (with rendered fields)
// and the store steps to persist.
func parseConfigSteps(text string, catalog []*store.Command, label string, errs, risky *[]string) (view, persist []store.ConfigStep) {
markerSeen := false
for _, raw := range strings.Split(text, "\n") {
line := strings.TrimSpace(strings.TrimRight(raw, "\r"))
if line == "" {
@@ -438,6 +439,17 @@ func parseConfigSteps(text string, catalog []*store.Command, label string, errs,
persist = append(persist, step)
continue
}
if isRegionMarkerLine(line) {
if markerSeen {
*errs = append(*errs, fmt.Sprintf("%s: the %s placeholder can only appear once.", label, store.RegionMarker))
continue
}
markerSeen = true
step := store.ConfigStep{CommandLine: store.RegionMarker}
view = append(view, step)
persist = append(persist, step)
continue
}
if !validCommandText(line) {
*errs = append(*errs, fmt.Sprintf("%s: invalid command %q.", label, line))
continue
@@ -458,8 +470,23 @@ func parseConfigSteps(text string, catalog []*store.Command, label string, errs,
return view, persist
}
// isRegionMarkerLine reports whether a profile line is the region placeholder,
// tolerating internal spacing and case (e.g. "{{region}}", "{{ REGION }}"). The
// canonical stored form is store.RegionMarker.
func isRegionMarkerLine(line string) bool {
inner, ok := strings.CutPrefix(line, "{{")
if !ok {
return false
}
inner, ok = strings.CutSuffix(inner, "}}")
if !ok {
return false
}
return strings.EqualFold(strings.TrimSpace(inner), "region")
}
// stepsToText renders stored steps back into editable textarea content: commands
// as-is, comment steps prefixed with "# ".
// (and the region marker) as-is, comment steps prefixed with "# ".
func stepsToText(steps []store.ConfigStep) string {
var b strings.Builder
for _, s := range steps {
+30
View File
@@ -51,6 +51,36 @@ func TestParseConfigSteps(t *testing.T) {
}
}
func TestParseConfigStepsRegionMarker(t *testing.T) {
t.Parallel()
catalog := configCatalog()
// A marker (in flexible spelling) becomes one marker step that canonicalizes
// and round-trips; it is neither a comment nor a runnable command.
var errs, risky []string
_, persist := parseConfigSteps("set tx 22\n{{region}}\nset tx 23", catalog, "base", &errs, &risky)
if len(errs) != 0 {
t.Fatalf("errs = %v, want none", errs)
}
if len(persist) != 3 || !persist[1].IsRegionMarker() {
t.Fatalf("steps = %+v, want a region marker at index 1", persist)
}
if persist[1].CommandLine != store.RegionMarker {
t.Fatalf("marker canonical form = %q, want %q", persist[1].CommandLine, store.RegionMarker)
}
if got := stepsToText(persist); !strings.Contains(got, store.RegionMarker) {
t.Fatalf("round-trip lost the marker: %q", got)
}
// A second marker is rejected.
errs, risky = nil, nil
_, _ = parseConfigSteps("{{ region }}\n{{ REGION }}", catalog, "base", &errs, &risky)
if len(errs) != 1 || !strings.Contains(errs[0], "once") {
t.Fatalf("errs = %v, want one 'only once' error", errs)
}
_ = risky
}
func TestRegionGeofence(t *testing.T) {
t.Parallel()
+14 -6
View File
@@ -27,7 +27,6 @@ type consoleConfig struct {
type consoleConfigCommand struct {
Line string `json:"line"` // CLI text; empty for a comment-only note
Comment string `json:"comment,omitempty"` // optional note text
Kind string `json:"kind"` // "profile" | "region"
Runnable bool `json:"runnable"`
Reason string `json:"reason,omitempty"` // why it isn't runnable
}
@@ -117,9 +116,12 @@ func (s *Handlers) consoleConfigJSON(w http.ResponseWriter, r *http.Request) {
return true, ""
}
// Profile base settings (verbatim; comment-only steps are notes, not commands).
for _, step := range cv.SelectedSteps {
c := consoleConfigCommand{Kind: "profile", Line: step.CommandLine, Comment: step.Comment}
// Profile base settings (verbatim; comment-only steps are notes, not commands),
// with the location-derived region commands spliced at the profile's
// {{ region }} marker or appended after all steps when it has none. The marker
// itself is dropped by SplitAtRegionMarker.
emitStep := func(step store.ConfigStep) {
c := consoleConfigCommand{Line: step.CommandLine, Comment: step.Comment}
if step.IsComment() {
c.Reason = "note"
} else {
@@ -127,12 +129,18 @@ func (s *Handlers) consoleConfigJSON(w http.ResponseWriter, r *http.Request) {
}
resp.Commands = append(resp.Commands, c)
}
// Region commands derived from the location.
before, after := store.SplitAtRegionMarker(cv.SelectedSteps)
for _, step := range before {
emitStep(step)
}
for _, line := range cv.RegionDef {
c := consoleConfigCommand{Kind: "region", Line: line}
c := consoleConfigCommand{Line: line}
c.Runnable, c.Reason = runnable(line)
resp.Commands = append(resp.Commands, c)
}
for _, step := range after {
emitStep(step)
}
resp.Location = consoleConfigLocation{
Known: lat != nil && lon != nil,
+71 -5
View File
@@ -29,6 +29,11 @@ func cmdIDByKey(t *testing.T, st *store.Store, key string) int64 {
return 0
}
// isRegionLine reports whether a recommended-config line came from the region
// commands (every one starts with "region "), used by tests to tell region lines
// apart from profile steps now that the payload carries no per-line kind.
func isRegionLine(line string) bool { return strings.HasPrefix(line, "region ") }
func getConsoleConfig(t *testing.T, ts *httptest.Server, host, path string, cookie *http.Cookie) consoleConfig {
t.Helper()
resp := do(t, ts, host, path, cookie)
@@ -101,7 +106,7 @@ func TestConsoleConfigJSON(t *testing.T) {
var regionLines, noteCount int
for _, c := range cc.Commands {
byLine[c.Line] = c
if c.Kind == "region" {
if isRegionLine(c.Line) {
regionLines++
}
if c.Line == "" && c.Comment != "" {
@@ -109,8 +114,8 @@ func TestConsoleConfigJSON(t *testing.T) {
}
}
// Profile command + the note both appear.
if cmd, ok := byLine["set tx 22"]; !ok || cmd.Kind != "profile" || !cmd.Runnable {
t.Fatalf("set tx 22 = %+v, want profile+runnable", cmd)
if cmd, ok := byLine["set tx 22"]; !ok || !cmd.Runnable {
t.Fatalf("set tx 22 = %+v, want present + runnable", cmd)
}
if noteCount != 1 {
t.Fatalf("note count = %d, want 1 (the comment step)", noteCount)
@@ -120,7 +125,7 @@ func TestConsoleConfigJSON(t *testing.T) {
t.Fatal("no region commands emitted for a covered location")
}
for _, c := range cc.Commands {
if c.Kind == "region" && !c.Runnable {
if isRegionLine(c.Line) && !c.Runnable {
t.Fatalf("region line %q not runnable for owner", c.Line)
}
}
@@ -138,7 +143,7 @@ func TestConsoleConfigJSON(t *testing.T) {
switch {
case c.Line == "set tx 22" && !c.Runnable:
t.Error("member should be allowed to run member-tier set tx 22")
case c.Kind == "region":
case isRegionLine(c.Line):
sawRegion = true
if c.Runnable {
t.Errorf("member should NOT be allowed to run admin-tier %q", c.Line)
@@ -153,6 +158,67 @@ func TestConsoleConfigJSON(t *testing.T) {
}
}
// TestConsoleConfigRegionMarkerInline covers a profile with a {{ region }} marker:
// the region commands are spliced at the marker (between the surrounding steps),
// the marker itself is never emitted as a command.
func TestConsoleConfigRegionMarkerInline(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
owner, ownerCookie := appLogin(t, ts, st, ctx, h.app, "cc-marker")
rep, err := st.CreateRepeater(ctx, &store.Repeater{
OwnerID: owner.ID, Name: "R", PublicKeyHex: strings.Repeat("d", 64),
RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5,
})
if err != nil {
t.Fatal(err)
}
org, err := st.CreateOrg(ctx, "MarkerOrg", owner.ID)
if err != nil {
t.Fatal(err)
}
profiles := []store.ProfileInput{{Name: "P", Steps: []store.ConfigStep{
{CommandLine: "set tx 22"},
{CommandLine: store.RegionMarker},
{CommandLine: "set tx 23"},
}}}
regions := []store.RegionInput{{
Token: "buf", DisplayName: "Buffalo", Layer: 0, AllowFlood: true,
GeofenceJSON: geo.Rectangle(40, -80, 45, -75),
}}
if err := st.ReplaceOrgConfig(ctx, org.ID, profiles, regions); err != nil {
t.Fatal(err)
}
cc := getConsoleConfig(t, ts, h.app, "/repeaters/"+rep.PublicID+"/config.json?lat=42&lon=-78", ownerCookie)
idx := func(line string) int {
for i, c := range cc.Commands {
if c.Line == line {
return i
}
}
return -1
}
pre, post := idx("set tx 22"), idx("set tx 23")
region := -1
for i, c := range cc.Commands {
if isRegionLine(c.Line) {
region = i
break
}
}
if pre < 0 || post < 0 || region < 0 {
t.Fatalf("missing lines: pre=%d region=%d post=%d\n%+v", pre, region, post, cc.Commands)
}
if pre >= region || region >= post {
t.Fatalf("region not spliced between steps: pre=%d region=%d post=%d", pre, region, post)
}
if idx(store.RegionMarker) != -1 {
t.Fatalf("marker leaked into commands:\n%+v", cc.Commands)
}
}
// TestSetRepeaterLocation covers the map-pick persistence endpoint used when the
// repeater has no location: valid coordinates save and surface via config.json;
// invalid coordinates are rejected.
+52 -44
View File
@@ -62,20 +62,18 @@ func (s *Handlers) handleSetupCommands(w http.ResponseWriter, r *http.Request) {
}
req.Name = name
var cmds []string
cmds = append(cmds, "set name "+req.Name)
// Resolve the authoritative radio settings (echoed back so the client can
// store them on the repeater record). Radio + region come from the chosen org
// config; a standalone repeater (no org) uses the radio preset and gets no
// region hierarchy.
// store them on the repeater record) and gather the org's profile + region
// commands. Radio + region come from the chosen org config; a standalone
// repeater (no org) uses the request's radio and gets no region hierarchy.
radio := setupRadio{FreqMHz: req.FreqMHz, BwKHz: req.BwKHz, SF: req.SF, CR: req.CR}
var steps []store.ConfigStep
var regionCmds []string
if req.OrgID != 0 {
if _, isMember, err := s.Store.OrgRole(r.Context(), req.OrgID, uid); err != nil || !isMember {
http.Error(w, "no access to that organization", http.StatusForbidden)
return
}
var steps []string
if req.Profile != "" {
s2, err := s.profileSteps(r.Context(), req.OrgID, req.Profile)
if err != nil {
@@ -84,15 +82,13 @@ func (s *Handlers) handleSetupCommands(w http.ResponseWriter, r *http.Request) {
}
steps = s2
}
cmds = append(cmds, steps...)
// Prefer the radio the profile sets on the device; if it sets none, fall
// back to the default preset and add the command so the device still ends
// up tunable (and the record radio is accurate).
if rad, ok := parseProfileRadio(steps); ok {
// back to the default preset so the device still ends up tunable (and the
// record radio is accurate).
if rad, ok := parseProfileRadio(profileCommandLines(steps)); ok {
radio = rad
} else {
radio = defaultSetupRadio()
cmds = append(cmds, radioCommand(radio))
}
regions, err := s.Store.ListRegions(r.Context(), req.OrgID)
if err != nil {
@@ -104,27 +100,13 @@ func (s *Handlers) handleSetupCommands(w http.ResponseWriter, r *http.Request) {
s.ServerError(w, r, "could not load regions", err)
return
}
cmds = append(cmds, store.RegionDefCommands(regions, rootAllow, req.Lat, req.Lon)...)
} else {
if radio.FreqMHz <= 0 || radio.BwKHz <= 0 {
http.Error(w, "radio settings are required", http.StatusBadRequest)
return
}
cmds = append(cmds, radioCommand(radio))
regionCmds = store.RegionDefCommands(regions, rootAllow, req.Lat, req.Lon)
} else if radio.FreqMHz <= 0 || radio.BwKHz <= 0 {
http.Error(w, "radio settings are required", http.StatusBadRequest)
return
}
// Location: only set when the user picked a point.
if req.Lat != nil && req.Lon != nil {
cmds = append(cmds,
"set lat "+strconv.FormatFloat(*req.Lat, 'f', 6, 64),
"set lon "+strconv.FormatFloat(*req.Lon, 'f', 6, 64))
}
// Identity (client-spliced), then grant MeshTender admin, then reboot to
// apply the new identity and radio.
cmds = append(cmds, identityPlaceholder)
cmds = append(cmds, s.Identity.SetPermCommand())
cmds = append(cmds, "reboot")
cmds := buildSetupCommands(req.Name, identityPlaceholder, s.Identity.SetPermCommand(), req.Lat, req.Lon, radio, steps, regionCmds)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(setupCommandsResponse{
@@ -197,25 +179,51 @@ func radioCommand(rad setupRadio) string {
strconv.Itoa(rad.SF) + "," + strconv.Itoa(rad.CR)
}
// profileSteps returns the runnable command lines of a named profile in an org
// (comment-only steps are skipped).
func (s *Handlers) profileSteps(ctx context.Context, orgID int64, name string) ([]string, error) {
// buildSetupCommands assembles the ordered from-scratch USB setup command list:
// the header (name, client-spliced identity, location, radio, admin grant), then
// the profile's steps with the org's region commands spliced at the profile's
// {{ region }} marker (or appended after all steps when it has none), then a
// final reboot to apply the new identity and radio. Comment steps and the marker
// itself are not runnable, so they never reach the device.
func buildSetupCommands(name, identityCmd, setpermCmd string, lat, lon *float64, radio setupRadio, steps []store.ConfigStep, regionCmds []string) []string {
cmds := []string{"set name " + name, identityCmd}
if lat != nil && lon != nil {
cmds = append(cmds,
"set lat "+strconv.FormatFloat(*lat, 'f', 6, 64),
"set lon "+strconv.FormatFloat(*lon, 'f', 6, 64))
}
cmds = append(cmds, radioCommand(radio), setpermCmd)
before, after := store.SplitAtRegionMarker(steps)
cmds = append(cmds, profileCommandLines(before)...)
cmds = append(cmds, regionCmds...)
cmds = append(cmds, profileCommandLines(after)...)
return append(cmds, "reboot")
}
// profileCommandLines returns the runnable command lines of a step slice,
// skipping comment steps and the region marker.
func profileCommandLines(steps []store.ConfigStep) []string {
var out []string
for _, st := range steps {
if st.IsComment() || st.IsRegionMarker() {
continue
}
out = append(out, st.CommandLine)
}
return out
}
// profileSteps returns the ordered steps of a named profile in an org (nil when
// no profile by that name exists).
func (s *Handlers) profileSteps(ctx context.Context, orgID int64, name string) ([]store.ConfigStep, error) {
profiles, err := s.Store.ListProfiles(ctx, orgID)
if err != nil {
return nil, err
}
for _, p := range profiles {
if p.Name != name {
continue
if p.Name == name {
return p.Steps, nil
}
var out []string
for _, st := range p.Steps {
if st.IsComment() {
continue
}
out = append(out, st.CommandLine)
}
return out, nil
}
return nil, nil
}
+45
View File
@@ -5,8 +5,11 @@ import (
"io"
"net/http"
"net/url"
"slices"
"strings"
"testing"
"github.com/jleight/meshtender/internal/store"
)
// jsonPost issues a JSON POST with an explicit Host header and cookies.
@@ -159,6 +162,48 @@ func TestSerialSetupFlow(t *testing.T) {
}
}
// TestBuildSetupCommands locks the from-scratch command order (header → profile
// → region → reboot) and the two region-placement modes: appended when the
// profile has no marker, spliced in place when it does.
func TestBuildSetupCommands(t *testing.T) {
t.Parallel()
radio := setupRadio{FreqMHz: 910.525, BwKHz: 62.5, SF: 7, CR: 5}
lat, lon := 15.0, 35.0
region := []string{"region def zone", "region denyf *", "region allowf zone", "region save"}
header := []string{
"set name Hilltop", "set prv.key <key>",
"set lat 15.000000", "set lon 35.000000",
"set radio 910.525,62.5,7,5", "setperm srv 3",
}
// No marker: region commands land after all profile steps, before reboot. The
// comment step is not runnable and is skipped.
steps := []store.ConfigStep{{CommandLine: "set tx 22"}, {Comment: "note"}}
got := buildSetupCommands("Hilltop", "set prv.key <key>", "setperm srv 3", &lat, &lon, radio, steps, region)
want := append(append(append([]string{}, header...), "set tx 22"), append(append([]string{}, region...), "reboot")...)
if !slices.Equal(got, want) {
t.Fatalf("no-marker order:\n got %v\nwant %v", got, want)
}
// With a marker: region commands splice between the surrounding steps.
steps = []store.ConfigStep{{CommandLine: "set tx 22"}, {CommandLine: store.RegionMarker}, {CommandLine: "set repeat on"}}
got = buildSetupCommands("Hilltop", "set prv.key <key>", "setperm srv 3", &lat, &lon, radio, steps, region)
want = append(append([]string{}, header...), "set tx 22")
want = append(want, region...)
want = append(want, "set repeat on", "reboot")
if !slices.Equal(got, want) {
t.Fatalf("marker-splice order:\n got %v\nwant %v", got, want)
}
// No location + empty region (standalone): header skips lat/lon and nothing is
// spliced.
got = buildSetupCommands("Solo", "set prv.key <key>", "setperm srv 3", nil, nil, radio, nil, nil)
want = []string{"set name Solo", "set prv.key <key>", "set radio 910.525,62.5,7,5", "setperm srv 3", "reboot"}
if !slices.Equal(got, want) {
t.Fatalf("standalone order:\n got %v\nwant %v", got, want)
}
}
func TestParseProfileRadio(t *testing.T) {
steps := []string{"set tx 22", "set radio 910.525,62.5,7,5", "set repeat on"}
rad, ok := parseProfileRadio(steps)
@@ -37,7 +37,7 @@
<div class="mb-1">
<label class="form-label" for="profile_steps">Base settings</label>
<textarea class="form-control font-monospace" id="profile_steps" name="profile_steps" rows="10" spellcheck="false" placeholder="set tx 22&#10;# notes start with #">{{.StepsText}}</textarea>
<small class="form-hint">One command per line; lines starting with <code>#</code> are notes.</small>
<small class="form-hint">One command per line; lines starting with <code>#</code> are notes. Put <code>{{"{{ region }}"}}</code> on its own line to insert this org's region settings there — otherwise they run after all steps.</small>
</div>
<div class="btn-list mt-3">
<button type="submit" class="btn btn-primary">{{if .ProfileID}}Save profile{{else}}Create profile{{end}}</button>
+32
View File
@@ -32,6 +32,38 @@ type ConfigStep struct {
// IsComment reports whether the step is a note rather than a runnable command.
func (s ConfigStep) IsComment() bool { return s.CommandLine == "" }
// RegionMarker is the placeholder a profile step can hold on its own line to
// control where an org's region commands are spliced into the profile. Without
// it, region commands are appended after all profile steps (the default). It is
// never sent to a device — SplitAtRegionMarker removes it before either assembly
// path emits commands. It is stored as a step's CommandLine (CommandID nil), so
// it round-trips through the profile text editor like any other line.
const RegionMarker = "{{ region }}"
// IsRegionMarker reports whether the step is the region placeholder rather than a
// runnable command or a comment.
func (s ConfigStep) IsRegionMarker() bool { return s.CommandLine == RegionMarker }
// SplitAtRegionMarker partitions a profile's steps around the region marker: the
// steps before the first marker and the steps after it, with the marker itself
// (and any duplicates) dropped. When no marker is present every step lands in
// before and after is empty — so a caller that emits before, then the region
// commands, then after, appends the region block at the end (the default).
func SplitAtRegionMarker(steps []ConfigStep) (before, after []ConfigStep) {
found := false
for _, st := range steps {
switch {
case st.IsRegionMarker():
found = true
case found:
after = append(after, st)
default:
before = append(before, st)
}
}
return before, after
}
// Profile is a named set of base-setting steps.
type Profile struct {
ID int64
+36
View File
@@ -279,6 +279,42 @@ func TestRegionDefFloodCommands(t *testing.T) {
}
}
func TestSplitAtRegionMarker(t *testing.T) {
t.Parallel()
cmd := func(line string) ConfigStep { return ConfigStep{CommandLine: line} }
marker := ConfigStep{CommandLine: RegionMarker}
lines := func(steps []ConfigStep) []string {
out := []string{}
for _, s := range steps {
out = append(out, s.CommandLine)
}
return out
}
cases := []struct {
name string
steps []ConfigStep
before, after []string
}{
{"middle", []ConfigStep{cmd("a"), marker, cmd("b")}, []string{"a"}, []string{"b"}},
{"no marker (region appends at end)", []ConfigStep{cmd("a"), cmd("b")}, []string{"a", "b"}, []string{}},
{"marker first", []ConfigStep{marker, cmd("a")}, []string{}, []string{"a"}},
{"marker last", []ConfigStep{cmd("a"), marker}, []string{"a"}, []string{}},
{"duplicate markers dropped", []ConfigStep{cmd("a"), marker, cmd("b"), marker, cmd("c")}, []string{"a"}, []string{"b", "c"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
before, after := SplitAtRegionMarker(tc.steps)
if got := lines(before); !strings.EqualFold(strings.Join(got, ","), strings.Join(tc.before, ",")) {
t.Errorf("before = %v, want %v", got, tc.before)
}
if got := lines(after); !strings.EqualFold(strings.Join(got, ","), strings.Join(tc.after, ",")) {
t.Errorf("after = %v, want %v", got, tc.after)
}
})
}
}
// TestListRepeaterConfigOrgs covers which orgs surface in the console config
// picker: the repeater must participate (owner is a member, not excluded) and the
// org must have config. Profile names come back per org (empty for region-only).