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
133 lines
4.0 KiB
Go
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)
|
|
}
|