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
187 lines
6.4 KiB
Go
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`
|