package main import ( "context" "encoding/json" "errors" "flag" "fmt" "os" "time" "github.com/jackc/pgx/v5/pgxpool" "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} 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) 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`