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
This commit is contained in:
69
server/cmd/behavision-server/brokerinit.go
Normal file
69
server/cmd/behavision-server/brokerinit.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/broker"
|
||||
)
|
||||
|
||||
// broker-init converts Mosquitto's passwd file into the dynamic-security
|
||||
// plugin's store, once, at cutover. After it the broker is driven over MQTT
|
||||
// and the passwd and acl files are no longer read.
|
||||
func runBrokerInit(args []string) error {
|
||||
fs := flag.NewFlagSet("broker-init", flag.ExitOnError)
|
||||
passwd := fs.String("passwd", "", "path to the mosquitto passwd file to convert")
|
||||
out := fs.String("out", "", "where to write dynamic-security.json (must be writable by the broker)")
|
||||
backend := fs.String("backend-user", "behavision-backend", "the server's own broker username; becomes the plugin admin")
|
||||
health := fs.String("health-user", "health", "the healthcheck username")
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, `usage: behavision-server broker-init -passwd FILE -out FILE
|
||||
|
||||
Converts a mosquitto_passwd file into the dynamic-security plugin's store,
|
||||
keeping every password hash exactly as it is, so no shop PC has to be
|
||||
re-claimed. Then in mosquitto.conf replace password_file/acl_file with:
|
||||
|
||||
per_listener_settings false
|
||||
plugin /usr/lib/mosquitto_dynamic_security.so
|
||||
plugin_opt_config_file /mosquitto/data/dynamic-security.json
|
||||
|
||||
and restart the broker. From then on 'provision site' and POST /api/sites
|
||||
register a shop's login themselves.
|
||||
`)
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *passwd == "" || *out == "" {
|
||||
fs.Usage()
|
||||
return errors.New("-passwd and -out are required")
|
||||
}
|
||||
f, err := os.Open(*passwd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
st, err := broker.FromPasswd(f, *backend, *health)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(*out); err == nil {
|
||||
return fmt.Errorf("%s already exists - refusing to overwrite a live store", *out)
|
||||
}
|
||||
w, err := os.OpenFile(*out, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer w.Close()
|
||||
if err := st.Encode(w); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("wrote %s: %d clients, %d roles\n", *out, len(st.Clients), len(st.Roles))
|
||||
for _, c := range st.Clients {
|
||||
fmt.Printf(" %-28s %s\n", c.Username, c.Roles[0].Rolename)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -25,11 +25,12 @@ import (
|
||||
"github.com/loyaly/behavision-server/internal/api"
|
||||
"github.com/loyaly/behavision-server/internal/assistant"
|
||||
"github.com/loyaly/behavision-server/internal/blob"
|
||||
"github.com/loyaly/behavision-server/internal/broker"
|
||||
"github.com/loyaly/behavision-server/internal/ingest"
|
||||
"github.com/loyaly/behavision-server/internal/migrate"
|
||||
"github.com/loyaly/behavision-server/internal/web"
|
||||
"github.com/loyaly/behavision-server/internal/secret"
|
||||
"github.com/loyaly/behavision-server/internal/store"
|
||||
"github.com/loyaly/behavision-server/internal/web"
|
||||
"github.com/loyaly/behavision-server/migrations"
|
||||
)
|
||||
|
||||
@@ -46,6 +47,13 @@ func main() {
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(os.Args) > 1 && os.Args[1] == "broker-init" {
|
||||
if err := runBrokerInit(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(os.Args) > 1 && os.Args[1] == "migrate" {
|
||||
if err := runMigrate(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
@@ -194,8 +202,11 @@ func run() error {
|
||||
apiSrv := &api.Server{
|
||||
Store: st,
|
||||
Log: logger,
|
||||
Blob: objectStore(ctx, logger),
|
||||
Hub: hub,
|
||||
// Opening a shop registers its broker login at the same moment, over the
|
||||
// same broker credential the ingest side already holds.
|
||||
Broker: broker.New(brokerURL, brokerUser, brokerPass, logger),
|
||||
Blob: objectStore(ctx, logger),
|
||||
Hub: hub,
|
||||
Bootstrap: api.BootstrapConfig{
|
||||
// What an enrolling PC is told to connect to. From the server's own
|
||||
// environment, never from the request: an agent asking where to
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/broker"
|
||||
"github.com/loyaly/behavision-server/internal/provision"
|
||||
"github.com/loyaly/behavision-server/internal/secret"
|
||||
)
|
||||
@@ -43,6 +44,14 @@ func runProvision(args []string) error {
|
||||
|
||||
box, boxErr := secret.FromEnv("BEHAVISION_SECRET_KEY")
|
||||
p := &provision.Provisioner{Pool: pool, Secrets: box}
|
||||
// The broker, so a new site's login is registered here and now instead of
|
||||
// printed for somebody to type into a password file. Same variables the
|
||||
// server itself connects with.
|
||||
if u := os.Getenv("MQTT_URL"); u != "" && os.Getenv("MQTT_USERNAME") != "" {
|
||||
dyn := broker.New(u, os.Getenv("MQTT_USERNAME"), os.Getenv("MQTT_PASSWORD"), nil)
|
||||
defer dyn.Close()
|
||||
p.Broker = dyn
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "client":
|
||||
@@ -82,12 +91,16 @@ func runProvision(args []string) error {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("site created: %s\n", res.SiteID)
|
||||
if res.BrokerRegistered {
|
||||
fmt.Printf("broker login %s registered - this site can publish now.\n", res.Username)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("\nAdd this broker user to Mosquitto, then this site can publish:\n\n")
|
||||
fmt.Printf(" mosquitto_passwd -b /mosquitto/config/passwd %s '%s'\n\n",
|
||||
res.Username, res.Password)
|
||||
// The broker keeps a hash; we keep it sealed. Neither side can show it
|
||||
// again, which is why it is printed here in full.
|
||||
fmt.Printf("The password is stored encrypted and handed out only at "+
|
||||
fmt.Printf("The password is stored encrypted and handed out only at " +
|
||||
"enrolment.\nIt is not recoverable from the logs. Copy it now.\n")
|
||||
return nil
|
||||
|
||||
|
||||
Reference in New Issue
Block a user