Files
Behavision/server/internal/broker/migrate.go
Suriyakumarvijayanayagam 4c750cb2ac Opening a shop is an API call; the broker learns of it in the same request
The last step of onboarding that needed a shell: provision site printed
a broker password and a person typed it into Mosquitto's passwd file on
the host - mounted read-only in the container, so the first attempt
failed silently and the password was re-rolled. No tenant could open a
second branch without us.

The server now drives Mosquitto's dynamic-security plugin over its own
broker login: POST /api/sites (owner) writes the row and the sealed
password, registers the login and a per-site role with literal topics
(the 2.0 plugin does not substitute %u - measured), and removes the row
again if the broker refuses, so a shop cannot exist in the database and
not on the broker. provision site goes through the same path. The
head-office Shops screen gets 'Open a new shop'.

broker-init converts the existing passwd file into the plugin's store
with every hash intact - PBKDF2-SHA512 both sides - so the cutover
re-claims no shop PC. Rehearsed locally: old logins keep working,
isolation holds, the health probe works, and a PC claiming a shop opened
through the API connects as that shop. run-local.sh now brings the
broker up the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 11:55:26 +05:30

133 lines
4.0 KiB
Go

package broker
import (
"bufio"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
)
// Store is the plugin's on-disk shape - the part of it this code writes.
type Store struct {
Clients []Client `json:"clients"`
Roles []Role `json:"roles"`
DefaultACLAccess map[string]bool `json:"defaultACLAccess"`
}
type Client struct {
Username string `json:"username"`
TextName string `json:"textName,omitempty"`
Password string `json:"password"`
Salt string `json:"salt"`
Iterations int `json:"iterations"`
Roles []RoleRef `json:"roles"`
}
type RoleRef struct {
Rolename string `json:"rolename"`
}
type Role struct {
Rolename string `json:"rolename"`
ACLs []ACL `json:"acls"`
}
type ACL struct {
ACLType string `json:"acltype"`
Topic string `json:"topic"`
Allow bool `json:"allow"`
Priority int `json:"priority"`
}
// FromPasswd converts a mosquitto_passwd file into the plugin's store,
// keeping every password exactly as it is.
//
// This is the cutover for a broker that already has sites: the hashes in the
// passwd file are PBKDF2-SHA512 ($7$<iterations>$<salt>$<digest>, base64),
// which is the same thing the plugin stores as password/salt/iterations - so
// no shop PC has to be re-claimed and no credential changes hands. The roles
// reproduce the acl file rule for rule: the backend reads everything and
// drives the plugin, the health probe reads uptime, and every other user is a
// site that may write under its own prefix and read its own commands.
func FromPasswd(r io.Reader, backendUser, healthUser string) (*Store, error) {
st := &Store{
DefaultACLAccess: map[string]bool{
"publishClientSend": false, "publishClientReceive": false,
"subscribe": false, "unsubscribe": true,
},
}
st.Roles = append(st.Roles,
Role{Rolename: "admin", ACLs: []ACL{
{"publishClientSend", "$CONTROL/dynamic-security/#", true, 0},
{"publishClientReceive", "$CONTROL/dynamic-security/#", true, 0},
{"subscribePattern", "$CONTROL/dynamic-security/#", true, 0},
}},
Role{Rolename: "backend", ACLs: []ACL{
{"publishClientSend", "bv/#", true, 0},
{"publishClientReceive", "bv/#", true, 0},
{"subscribePattern", "bv/#", true, 0},
{"publishClientReceive", "$SYS/#", true, 0},
{"subscribePattern", "$SYS/#", true, 0},
}},
Role{Rolename: "health", ACLs: []ACL{
{"publishClientReceive", "$SYS/broker/uptime", true, 0},
{"subscribePattern", "$SYS/broker/uptime", true, 0},
}},
)
sc := bufio.NewScanner(r)
line := 0
for sc.Scan() {
line++
text := strings.TrimSpace(sc.Text())
if text == "" || strings.HasPrefix(text, "#") {
continue
}
user, hash, ok := strings.Cut(text, ":")
if !ok {
return nil, fmt.Errorf("passwd line %d: no ':'", line)
}
parts := strings.Split(hash, "$")
// "", "7", iterations, salt, digest
if len(parts) != 5 || parts[1] != "7" {
return nil, fmt.Errorf("passwd line %d (%s): not a $7$ PBKDF2 hash; re-set that password with mosquitto_passwd first", line, user)
}
iters, err := strconv.Atoi(parts[2])
if err != nil {
return nil, fmt.Errorf("passwd line %d (%s): iterations %q", line, user, parts[2])
}
c := Client{Username: user, Password: parts[4], Salt: parts[3], Iterations: iters}
switch user {
case backendUser:
c.TextName = "Behavision server"
c.Roles = []RoleRef{{"admin"}, {"backend"}}
case healthUser:
c.TextName = "health probe"
c.Roles = []RoleRef{{"health"}}
default:
role := RoleName(user)
var acls []ACL
for _, a := range siteACLs(user) {
acls = append(acls, ACL{a["acltype"].(string), a["topic"].(string), true, 0})
}
st.Roles = append(st.Roles, Role{Rolename: role, ACLs: acls})
c.TextName = "site " + user
c.Roles = []RoleRef{{role}}
}
st.Clients = append(st.Clients, c)
}
if err := sc.Err(); err != nil {
return nil, err
}
return st, nil
}
// Encode writes the store as the plugin reads it.
func (s *Store) Encode(w io.Writer) error {
enc := json.NewEncoder(w)
enc.SetIndent("", "\t")
return enc.Encode(s)
}