Files
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

187 lines
6.4 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"time"
"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"
)
// runProvision handles `behavision-server provision ...`.
//
// Everything it prints that is a secret is printed ONCE and never stored in
// recoverable form afterwards, so the operator has to copy it now. That is the
// point: a credential a support engineer can look up later is a credential
// anyone with support access has.
func runProvision(args []string) error {
if len(args) == 0 {
return errors.New(provisionUsage)
}
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
return errors.New("DATABASE_URL is required")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return err
}
defer pool.Close()
if err := pool.Ping(ctx); err != nil {
return fmt.Errorf("database unreachable: %w", err)
}
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":
fs := flag.NewFlagSet("provision client", flag.ContinueOnError)
slug := fs.String("slug", "", "short name used in MQTT topics, e.g. acme")
name := fs.String("name", "", "display name")
if err := fs.Parse(args[1:]); err != nil {
return err
}
if *slug == "" || *name == "" {
return errors.New("provision client -slug acme -name \"Acme Retail\"")
}
id, err := p.CreateClient(ctx, *slug, *name)
if err != nil {
return err
}
fmt.Printf("client %s created: %s\n", *slug, id)
return nil
case "site":
fs := flag.NewFlagSet("provision site", flag.ContinueOnError)
client := fs.String("client", "", "client slug")
slug := fs.String("slug", "", "site slug, e.g. store1")
name := fs.String("name", "", "display name")
tz := fs.String("tz", "Asia/Kolkata", "IANA timezone; footfall is bucketed in it")
if err := fs.Parse(args[1:]); err != nil {
return err
}
if *client == "" || *slug == "" || *name == "" {
return errors.New("provision site -client acme -slug store1 -name \"Acme Chennai\"")
}
if boxErr != nil {
return boxErr
}
res, err := p.CreateSite(ctx, *client, *slug, *name, *tz)
if err != nil {
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 " +
"enrolment.\nIt is not recoverable from the logs. Copy it now.\n")
return nil
case "user":
fs := flag.NewFlagSet("provision user", flag.ContinueOnError)
client := fs.String("client", "", "client slug (omit for a platform admin)")
email := fs.String("email", "", "sign-in address")
role := fs.String("role", "manager", "owner | manager | staff | admin")
name := fs.String("name", "", "full name")
pw := fs.String("password", "", "leave empty to generate one")
if err := fs.Parse(args[1:]); err != nil {
return err
}
id, password, err := p.CreateUser(ctx, *client, *email, *role, *name, *pw)
if err != nil {
return err
}
fmt.Printf("user %s created: %s (%s)\n", *email, id, *role)
if *pw == "" {
fmt.Printf("\n password: %s\n\n", password)
fmt.Printf("Stored only as a bcrypt hash. Copy it now.\n")
}
return nil
case "token":
fs := flag.NewFlagSet("provision token", flag.ContinueOnError)
client := fs.String("client", "", "client slug")
site := fs.String("site", "", "site slug")
label := fs.String("label", "", "note, e.g. \"front counter PC\"")
days := fs.Int("days", 7, "how long the code stays valid")
if err := fs.Parse(args[1:]); err != nil {
return err
}
if *client == "" || *site == "" {
return errors.New("provision token -client acme -site store1")
}
code, expires, err := p.IssueEnrolmentToken(ctx, *client, *site, *label,
time.Duration(*days)*24*time.Hour)
if err != nil {
return err
}
fmt.Printf("\n installation code: %s\n\n", code)
fmt.Printf("Valid once, until %s.\n", expires.Format(time.RFC1123))
return nil
case "key":
// JSON by default so it can be piped straight into an env file without
// somebody retyping 44 base64 characters and getting one wrong - and
// -raw for a shell, because the obvious `export KEY=$(... | tail -1)`
// captures the whole JSON object and hands the server a key it cannot
// parse. That was in RUN.md, on the first step of the first setup.
fs := flag.NewFlagSet("provision key", flag.ContinueOnError)
raw := fs.Bool("raw", false, "print only the key, for $(...) in a shell")
if err := fs.Parse(args[1:]); err != nil {
return err
}
k, err := secret.NewKey()
if err != nil {
return err
}
if *raw {
fmt.Println(k)
fmt.Fprintln(os.Stderr,
"\nStore this with the database backups' key material, NOT beside them.")
return nil
}
out, _ := json.Marshal(map[string]string{"BEHAVISION_SECRET_KEY": k})
fmt.Println(string(out))
fmt.Fprintln(os.Stderr,
"\nStore this with the database backups' key material, NOT beside them.\n"+
"Losing it makes every site's broker password unrecoverable;\n"+
"leaking it with a database dump hands them all over.")
return nil
}
return errors.New(provisionUsage)
}
const provisionUsage = `usage:
behavision-server provision key
behavision-server provision client -slug acme -name "Acme Retail"
behavision-server provision site -client acme -slug store1 -name "Chennai" -tz Asia/Kolkata
behavision-server provision user -client acme -email a@acme.com -role manager
behavision-server provision token -client acme -site store1`