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$$$, 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) }