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:
29
API.md
29
API.md
@@ -49,6 +49,7 @@ user; the tenant is always taken from the session and never from the request.
|
||||
| `DELETE /api/visitors/{id}` — erasure | manager |
|
||||
| `GET /api/sites` · `GET /api/sites/{site}/check` · `GET /api/cameras` · `GET /api/cameras/{id}/snapshot.jpg` · `GET /api/cameras/{id}/live` | authed |
|
||||
| `POST /api/sites/{site}/cameras` · `PATCH` / `DELETE /api/cameras/{id}` · `POST /api/cameras/{id}/check` | manager |
|
||||
| `POST /api/sites` — open a shop | owner |
|
||||
| `POST /api/sites/{site}/enrolment-code` | manager |
|
||||
| `GET /api/team` | authed (tenant users only) |
|
||||
| `POST /api/team/members` · `POST /api/team/{id}/password` · `PATCH /api/team/{id}` · `/api/team/invitations*` | manager |
|
||||
@@ -164,9 +165,9 @@ PATCH /api/team/{id} { "role": "manager" } → promote
|
||||
PATCH /api/team/{id} { "active": false } → they have left; signs them out now
|
||||
```
|
||||
|
||||
The owner also sets the shop up from the same login — `POST
|
||||
/api/sites/{site}/enrolment-code` for the shop PC, `POST
|
||||
/api/sites/{site}/cameras` for cameras — see §8.
|
||||
The owner also opens shops and sets them up from the same login — `POST
|
||||
/api/sites` to open one, `POST /api/sites/{site}/enrolment-code` for its
|
||||
shop PC, `POST /api/sites/{site}/cameras` for cameras — see §8.
|
||||
|
||||
### Tier 3 — the salesperson gets their mobile login
|
||||
|
||||
@@ -718,6 +719,28 @@ unplugged PC.
|
||||
footfall lost because that queue overflowed. Non-zero `dropped` is a report
|
||||
that is wrong in a way the report itself cannot show.
|
||||
|
||||
### `POST /api/sites` — open a shop (owner)
|
||||
|
||||
```
|
||||
{ "name": "TeNext Bengaluru", "slug": "bengaluru", "timezone": "Asia/Kolkata" }
|
||||
→ 201 { "site_id": "…", "slug": "bengaluru", "name": "TeNext Bengaluru",
|
||||
"timezone": "Asia/Kolkata", "broker_username": "tenext-retail.bengaluru" }
|
||||
```
|
||||
|
||||
`slug` and `timezone` are optional: the slug is made from the name (lower-case,
|
||||
digits and dashes, 3–32 characters) and the timezone defaults to Asia/Kolkata.
|
||||
The slug is the shop PC's identity and an MQTT topic segment; it **cannot be
|
||||
changed afterwards**. The server registers the shop's broker login with
|
||||
Mosquitto in the same request, so the next step is simply
|
||||
`POST /api/sites/{slug}/enrolment-code` for the PC.
|
||||
|
||||
| status | code | meaning |
|
||||
|---|---|---|
|
||||
| 403 | `forbidden` | not the owner |
|
||||
| 409 | `conflict` | a shop with that slug exists |
|
||||
| 502 | `broker_unavailable` | the broker did not accept the login; **nothing was created** — try again |
|
||||
| 503 | `broker_unavailable` / `no_encryption_key` | this server cannot create shops; contact support |
|
||||
|
||||
### `GET /api/sites/{site}/check`
|
||||
|
||||
Five ordered steps that answer *is this shop working*, assembled from what head
|
||||
|
||||
45
CLAUDE.md
45
CLAUDE.md
@@ -1696,20 +1696,41 @@ The enrol response gained `client_slug` and `topic_prefix`, both **derived from
|
||||
the broker username** rather than looked up separately, so the agent's topic
|
||||
prefix and the broker's ACL are equal by construction.
|
||||
|
||||
### Still a command: creating the shop itself
|
||||
### Opening a shop is an API call, and the broker learns of it in the same request
|
||||
|
||||
`provision site` prints a broker password that a human then has to add to
|
||||
Mosquitto. So a tenant cannot open their second shop without us, and that is the
|
||||
one remaining hole in self-service onboarding. Closing it needs a decision, not
|
||||
code:
|
||||
`POST /api/sites` (owner), and `provision site` behind the same code. This was
|
||||
the last piece 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
|
||||
— which turned out to be mounted read-only in the container, so the first
|
||||
attempt failed silently and the password had to be re-rolled. No tenant could
|
||||
open a second branch without us.
|
||||
|
||||
- **the server manages Mosquitto's `passwd`/`acl` and reloads it** — possible
|
||||
because they are co-located, and it couples the API to the broker's
|
||||
filesystem; or
|
||||
- **one broker user per CLIENT rather than per site** — then adding a shop needs
|
||||
no broker change at all. Cross-tenant isolation is unchanged; what is given up
|
||||
is that one of a customer's own PCs could publish as another of their sites.
|
||||
Every deployed site would need re-provisioning.
|
||||
Neither option recorded here before was taken. The server does not write the
|
||||
broker's files, and there is still one broker user per site. Mosquitto 2.0's
|
||||
**dynamic-security plugin** takes the same operations as commands on
|
||||
`$CONTROL/dynamic-security/v1`, from a client holding the `admin` role;
|
||||
`server/internal/broker` drives it over the server's own broker login.
|
||||
|
||||
- **A role per site, with literal topics.** The 2.0 plugin does **not**
|
||||
substitute `%u` in ACL topics (measured: the publish was denied), so
|
||||
`site.<client>.<site>` is created with the client and deleted with it.
|
||||
- **Idempotent.** Re-running `EnsureSite` on an existing login sets the password
|
||||
to the one the database holds and confirms the role. `addClientRole` on a
|
||||
client that already has the role answers "Internal error", so the role is
|
||||
checked with `getClient` rather than inferred from prose.
|
||||
- **The row and the login are created together, or not at all.** If the broker
|
||||
refuses, the just-created row is removed and the caller gets 502. A shop that
|
||||
exists in the database and not on the broker is one whose PC enrols fine and
|
||||
never delivers a visit — the silent-failure class this whole endpoint ends.
|
||||
- **Its own connection**, not the ingest client's: that one has
|
||||
`SetOrderMatters` and blocking handlers, and a provisioning call must neither
|
||||
wait behind a slow visit nor delay one.
|
||||
- **Cutover keeps every password.** `behavision-server broker-init` converts the
|
||||
passwd file into the plugin's store: `$7$` lines are PBKDF2-SHA512 with a
|
||||
salt and iteration count, which is exactly what the plugin stores, so no shop
|
||||
PC re-claims and no credential changes hands. Rehearsed locally against a
|
||||
file `mosquitto_passwd` wrote; `run-local.sh` now brings the broker up the
|
||||
same way as production.
|
||||
|
||||
## Running it against the real office camera: four dead wires
|
||||
|
||||
|
||||
49
run-local.sh
49
run-local.sh
@@ -72,15 +72,34 @@ step "3b. Schema"
|
||||
"./$STATE/bv-server" migrate
|
||||
|
||||
step "4. Mosquitto"
|
||||
if [ ! -f "$STATE/mosquitto/mosquitto.conf" ]; then
|
||||
# Dynamic security, not a passwd file - the same shape as production. The
|
||||
# server registers each site's broker login itself over the control topic, so
|
||||
# there is no per-site password to type here and nothing to restart. The store
|
||||
# is seeded once with the server's own login as the plugin admin; after that
|
||||
# the plugin owns the file.
|
||||
mkdir -p "$STATE/mosquitto/data"
|
||||
# Rewritten when it is the pre-plugin shape, so a checkout that ran the old
|
||||
# script comes up in the new one rather than half of each.
|
||||
if ! grep -q mosquitto_dynamic_security "$STATE/mosquitto/mosquitto.conf" 2>/dev/null; then
|
||||
rm -f "$STATE/mosquitto/passwd" "$STATE/mosquitto/acl"
|
||||
docker rm -f bv-mqtt >/dev/null 2>&1 || true
|
||||
cat > "$STATE/mosquitto/mosquitto.conf" <<EOF
|
||||
per_listener_settings false
|
||||
listener 1883
|
||||
allow_anonymous false
|
||||
password_file /mosquitto/config/passwd
|
||||
acl_file /mosquitto/config/acl
|
||||
plugin /usr/lib/mosquitto_dynamic_security.so
|
||||
plugin_opt_config_file /mosquitto/data/dynamic-security.json
|
||||
EOF
|
||||
printf 'user behavision-server\ntopic read bv/#\n' > "$STATE/mosquitto/acl"
|
||||
: > "$STATE/mosquitto/passwd"
|
||||
fi
|
||||
if [ ! -f "$STATE/mosquitto/data/dynamic-security.json" ]; then
|
||||
: > "$STATE/mosquitto/passwd.seed"
|
||||
docker run --rm -v "$PWD/$STATE/mosquitto:/m" eclipse-mosquitto:2 \
|
||||
mosquitto_passwd -b /m/passwd.seed behavision-server "$MQTT_PASSWORD" 2>/dev/null
|
||||
"./$STATE/bv-server" broker-init -passwd "$STATE/mosquitto/passwd.seed" \
|
||||
-out "$STATE/mosquitto/data/dynamic-security.json" -backend-user behavision-server >/dev/null
|
||||
rm -f "$STATE/mosquitto/passwd.seed"
|
||||
# The plugin rewrites this file, so the broker's user (1883) must own it.
|
||||
chmod 666 "$STATE/mosquitto/data/dynamic-security.json"
|
||||
fi
|
||||
# A container is reused only if its config mount still points HERE. The bind
|
||||
# source is baked in when the container is created, so one made while the
|
||||
@@ -98,6 +117,7 @@ if docker inspect bv-mqtt >/dev/null 2>&1; then
|
||||
fi
|
||||
docker inspect bv-mqtt >/dev/null 2>&1 || docker run -d --name bv-mqtt \
|
||||
-p "${MQTT_PORT}:1883" -v "$MQTT_CONF:/mosquitto/config" \
|
||||
-v "$MQTT_CONF/data:/mosquitto/data" \
|
||||
eclipse-mosquitto:2 >/dev/null
|
||||
docker start bv-mqtt >/dev/null 2>&1 || true
|
||||
|
||||
@@ -114,13 +134,7 @@ if ! docker exec bv-mqtt sh -c 'exit 0' >/dev/null 2>&1; then
|
||||
docker logs --tail 5 bv-mqtt >&2
|
||||
exit 1
|
||||
fi
|
||||
# stderr is NOT discarded here. A failure means the server cannot authenticate
|
||||
# to its own broker, and the whole point of this script is that you find that
|
||||
# out now rather than from an empty arrivals feed.
|
||||
docker exec bv-mqtt mosquitto_passwd -b /mosquitto/config/passwd \
|
||||
behavision-server "$MQTT_PASSWORD" >/dev/null
|
||||
docker restart bv-mqtt >/dev/null
|
||||
echo " broker on ${MQTT_PORT}"
|
||||
echo " broker on ${MQTT_PORT} (dynamic security)"
|
||||
|
||||
step "5. First accounts"
|
||||
# Idempotent throughout: every provision subcommand upserts, so re-running this
|
||||
@@ -140,14 +154,9 @@ step "5. First accounts"
|
||||
# never readable again - so it is pushed into Mosquitto here in the same breath.
|
||||
# A shop PC enrolled on an earlier run therefore has to be claimed again, which
|
||||
# is the right trade locally and is why this is not how production works.
|
||||
SITE_OUT=$("./$STATE/bv-server" provision site -client tenext-retail -slug chennai \
|
||||
-name "TeNext Chennai" -tz Asia/Kolkata)
|
||||
BUSER=$(printf '%s' "$SITE_OUT" | sed -n "s/.*passwd \([^ ]*\) .*/\1/p")
|
||||
BPASS=$(printf '%s' "$SITE_OUT" | sed -n "s/.*passwd [^ ]* '\(.*\)'.*/\1/p")
|
||||
docker exec bv-mqtt mosquitto_passwd -b /mosquitto/config/passwd "$BUSER" "$BPASS" >/dev/null
|
||||
grep -q "^user $BUSER$" "$STATE/mosquitto/acl" || \
|
||||
printf '\nuser %s\ntopic write bv/%s/#\n' "$BUSER" "$BUSER" >> "$STATE/mosquitto/acl"
|
||||
docker restart bv-mqtt >/dev/null
|
||||
MQTT_URL="tcp://127.0.0.1:${MQTT_PORT}" MQTT_USERNAME=behavision-server MQTT_PASSWORD="$MQTT_PASSWORD" \
|
||||
"./$STATE/bv-server" provision site -client tenext-retail -slug chennai \
|
||||
-name "TeNext Chennai" -tz Asia/Kolkata | sed 's/^/ /'
|
||||
|
||||
printf ' platform admin admin@loyaly.ai / loyaly-platform-2026 (Companies only)\n'
|
||||
printf ' TeNext owner suriya@tenext.in / tenext-2026 (Shops, Live, Cameras, Customers, Reports)\n'
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -36,6 +36,15 @@ import (
|
||||
type Store interface {
|
||||
// --- identity ---
|
||||
UserByEmail(ctx context.Context, email string) (UserRecord, error)
|
||||
|
||||
// --- shops ---
|
||||
// CreateSite writes the shop and its sealed broker password in one
|
||||
// transaction and returns the plaintext once, for the broker registration
|
||||
// that must follow. DeleteNewSite is the compensation when that
|
||||
// registration fails: a shop whose PC can enrol but never publish is the
|
||||
// silent failure this whole endpoint exists to end.
|
||||
CreateSite(ctx context.Context, clientID, slug, name, tz string) (NewSite, error)
|
||||
DeleteNewSite(ctx context.Context, clientID, siteID string) error
|
||||
TouchUserLogin(ctx context.Context, userID string) error
|
||||
CreateSession(ctx context.Context, s NewSession) error
|
||||
SessionByAccess(ctx context.Context, hash []byte) (auth.Principal, time.Time, error)
|
||||
@@ -168,6 +177,10 @@ type Server struct {
|
||||
// business questions the screens ask. Nil means this deployment has no
|
||||
// API key, which is supported: the UI hides the panel.
|
||||
Assistant Assistant
|
||||
// Broker registers a shop's login with Mosquitto at the moment the shop is
|
||||
// created. Nil means this deployment cannot create shops through the API
|
||||
// and says so, rather than creating one that can never publish.
|
||||
Broker SiteBroker
|
||||
// Live relays camera frames from a shop PC to whoever is watching, on
|
||||
// demand. Created on first use.
|
||||
Live *LiveHub
|
||||
@@ -264,6 +277,7 @@ func (s *Server) Routes() *http.ServeMux {
|
||||
mux.HandleFunc("GET /api/reports/footfall", s.authed(s.handleFootfall))
|
||||
mux.HandleFunc("GET /api/reports/conversion", s.authed(s.handleConversion))
|
||||
mux.HandleFunc("GET /api/sites", s.authed(s.handleSites))
|
||||
mux.HandleFunc("POST /api/sites", s.authed(s.handleCreateSite))
|
||||
|
||||
// Cameras, onboarded from head office. The shop PC still does the
|
||||
// connecting - it is the only thing on the camera's network - so these
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -261,6 +262,32 @@ func (f *fakeStore) Conversion(_ context.Context, q ReportQuery) (SalesReport, e
|
||||
return f.sales, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateSite(_ context.Context, clientID, slug, name, tz string) (NewSite, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, s := range f.sites {
|
||||
if s.Slug == slug {
|
||||
return NewSite{}, &pgconn.PgError{Code: "23505"}
|
||||
}
|
||||
}
|
||||
id := "site-" + slug
|
||||
f.sites = append(f.sites, SiteHealth{SiteID: id, Slug: slug, Name: name, Timezone: tz})
|
||||
return NewSite{SiteID: id, Slug: slug, Name: name, Timezone: tz, Username: "acme." + slug, Password: "pw-" + slug}, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteNewSite(_ context.Context, _ string, siteID string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
kept := f.sites[:0]
|
||||
for _, s := range f.sites {
|
||||
if s.SiteID != siteID {
|
||||
kept = append(kept, s)
|
||||
}
|
||||
}
|
||||
f.sites = kept
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SiteHealth(_ context.Context, _ string) ([]SiteHealth, error) {
|
||||
return f.sites, nil
|
||||
}
|
||||
|
||||
107
server/internal/api/handlers_sites.go
Normal file
107
server/internal/api/handlers_sites.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// SiteBroker is the broker-side half of creating a shop. It is the
|
||||
// internal/broker package's interface, redeclared here so this package does
|
||||
// not import a paho dependency for the sake of one method.
|
||||
type SiteBroker interface {
|
||||
EnsureSite(ctx context.Context, username, password string) error
|
||||
DeleteSite(ctx context.Context, username string) error
|
||||
}
|
||||
|
||||
// Same rule the database enforces (sites_slug_format), checked here so the
|
||||
// caller gets a sentence instead of a constraint name.
|
||||
var slugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$`)
|
||||
|
||||
// POST /api/sites - an owner opens a shop.
|
||||
//
|
||||
// Until this existed a shop was `provision site` on the server's command line
|
||||
// followed by a hand edit of the broker's password file. That made every new
|
||||
// branch a support ticket, and it was the last piece of onboarding that could
|
||||
// not be done from the product. The row and the broker login are created
|
||||
// together here; if the broker will not take the login, the row is removed
|
||||
// again and the caller is told, because a shop that exists in the database and
|
||||
// not on the broker is one whose PC enrols fine and never delivers a visit.
|
||||
func (s *Server) handleCreateSite(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
// Owner, not manager: a shop is a billing and tenancy object, not a
|
||||
// setting. Managers can set up the PC and cameras once it exists.
|
||||
if p.Role != "owner" || p.ClientID == "" {
|
||||
writeErr(w, http.StatusForbidden, "forbidden", "Only the owner can open a new shop.")
|
||||
return
|
||||
}
|
||||
if s.Broker == nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, "broker_unavailable",
|
||||
"This server is not connected to a broker that can register shops. Contact support.")
|
||||
return
|
||||
}
|
||||
var in NewSiteInput
|
||||
if err := decode(w, r, &in); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
in.Name = clip(trim(in.Name), 120)
|
||||
if in.Name == "" {
|
||||
badRequest(w, "Give the shop a name.")
|
||||
return
|
||||
}
|
||||
in.Slug = slugify(in.Slug)
|
||||
if in.Slug == "" {
|
||||
in.Slug = slugify(in.Name)
|
||||
}
|
||||
if !slugRe.MatchString(in.Slug) {
|
||||
badRequest(w, "The short name must be 3-32 characters: lower-case letters, digits and dashes.")
|
||||
return
|
||||
}
|
||||
tz := strings.TrimSpace(in.Timezone)
|
||||
if tz == "" {
|
||||
tz = "Asia/Kolkata"
|
||||
}
|
||||
if _, err := time.LoadLocation(tz); err != nil {
|
||||
badRequest(w, "Unknown timezone. Use an IANA name such as Asia/Kolkata.")
|
||||
return
|
||||
}
|
||||
|
||||
site, err := s.Store.CreateSite(r.Context(), p.ClientID, in.Slug, in.Name, tz)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
writeErr(w, http.StatusConflict, "conflict", "A shop with that short name already exists.")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrNoSecrets) {
|
||||
writeErr(w, http.StatusServiceUnavailable, "no_encryption_key",
|
||||
"This server has no encryption key, so a shop's broker password cannot be stored. Contact support.")
|
||||
return
|
||||
}
|
||||
s.serverError(w, "create site", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.Broker.EnsureSite(r.Context(), site.Username, site.Password); err != nil {
|
||||
s.logf("create site %s: broker registration failed, removing the row: %v", site.Slug, err)
|
||||
if derr := s.Store.DeleteNewSite(r.Context(), p.ClientID, site.SiteID); derr != nil {
|
||||
s.logf("create site %s: could not remove the row after broker failure: %v", site.Slug, derr)
|
||||
}
|
||||
writeErr(w, http.StatusBadGateway, "broker_unavailable",
|
||||
"The broker did not accept the new shop, so it was not created. Try again in a moment; if it keeps failing, contact support.")
|
||||
return
|
||||
}
|
||||
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "site.created", Entity: "site", EntityID: site.SiteID,
|
||||
Detail: map[string]any{"slug": site.Slug, "name": site.Name, "timezone": site.Timezone},
|
||||
})
|
||||
writeJSON(w, http.StatusCreated, site)
|
||||
}
|
||||
110
server/internal/api/sites_test.go
Normal file
110
server/internal/api/sites_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeBroker records what the server asked the broker to do.
|
||||
type fakeBroker struct {
|
||||
ensured map[string]string
|
||||
deleted []string
|
||||
fail error
|
||||
}
|
||||
|
||||
func (b *fakeBroker) EnsureSite(_ context.Context, user, pass string) error {
|
||||
if b.fail != nil {
|
||||
return b.fail
|
||||
}
|
||||
if b.ensured == nil {
|
||||
b.ensured = map[string]string{}
|
||||
}
|
||||
b.ensured[user] = pass
|
||||
return nil
|
||||
}
|
||||
func (b *fakeBroker) DeleteSite(_ context.Context, user string) error {
|
||||
b.deleted = append(b.deleted, user)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ownerSession(t *testing.T, s *Server, fs *fakeStore) Session {
|
||||
t.Helper()
|
||||
fs.addUser("owner@acme.com", "correct horse battery", UserRecord{
|
||||
ID: "u-owner", ClientID: "client-acme", Role: "owner", Active: true,
|
||||
})
|
||||
return login(t, s, "owner@acme.com", "correct horse battery")
|
||||
}
|
||||
|
||||
func TestAnOwnerOpensAShopAndTheBrokerLearnsOfIt(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
b := &fakeBroker{}
|
||||
s.Broker = b
|
||||
sess := ownerSession(t, s, fs)
|
||||
|
||||
rec := do(t, s, "POST", "/api/sites", sess.Token, map[string]any{"name": "Acme Bengaluru!"})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out["slug"] != "acme-bengaluru" {
|
||||
t.Errorf("slug not derived from the name: %v", out["slug"])
|
||||
}
|
||||
if _, leaked := out["password"]; leaked {
|
||||
t.Fatal("the broker password was serialised")
|
||||
}
|
||||
if b.ensured["acme.acme-bengaluru"] == "" {
|
||||
t.Fatalf("broker was not told about the shop: %+v", b.ensured)
|
||||
}
|
||||
if len(fs.sites) != 1 {
|
||||
t.Fatalf("expected one site, have %d", len(fs.sites))
|
||||
}
|
||||
}
|
||||
|
||||
func TestABrokerFailureLeavesNoHalfMadeShop(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Broker = &fakeBroker{fail: errors.New("no answer on the control topic")}
|
||||
sess := ownerSession(t, s, fs)
|
||||
|
||||
rec := do(t, s, "POST", "/api/sites", sess.Token, map[string]any{"name": "Ghost"})
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(fs.sites) != 0 {
|
||||
t.Fatalf("a shop the broker never accepted was kept: %+v", fs.sites)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAManagerCannotOpenAShop(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Broker = &fakeBroker{}
|
||||
seedUser(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
rec := do(t, s, "POST", "/api/sites", sess.Token, map[string]any{"name": "Nope"})
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("manager opened a shop: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADuplicateShortNameIsAConflict(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
s.Broker = &fakeBroker{}
|
||||
seedSite(fs)
|
||||
sess := ownerSession(t, s, fs)
|
||||
rec := do(t, s, "POST", "/api/sites", sess.Token, map[string]any{"name": "Again", "slug": "chennai"})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoBrokerConfiguredSaysSo(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
sess := ownerSession(t, s, fs)
|
||||
rec := do(t, s, "POST", "/api/sites", sess.Token, map[string]any{"name": "Shop"})
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -160,6 +160,30 @@ type PurchaseInput struct {
|
||||
// SiteHealth is what the dashboard needs to distinguish "no customers" from
|
||||
// "this shop's PC has been unplugged for a week" - two identical rows of zeroes
|
||||
// with completely different responses.
|
||||
// NewSiteInput is what an owner types to open a shop. The slug is derived
|
||||
// from the name when absent, because it becomes the shop PC's identity and
|
||||
// an MQTT topic segment, and a person asked to invent one invents a bad one.
|
||||
type NewSiteInput struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
}
|
||||
|
||||
// NewSite is the created shop plus the broker login the server registered for
|
||||
// it. The password is sealed in the database and handed to a shop PC at
|
||||
// enrolment; it is NOT in the API response, because nobody needs to see it -
|
||||
// the enrolment code is the credential a person handles.
|
||||
type NewSite struct {
|
||||
SiteID string `json:"site_id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Timezone string `json:"timezone"`
|
||||
// Username is the broker login; Password is returned to the caller in
|
||||
// Go only, for the broker registration, and never serialised.
|
||||
Username string `json:"broker_username"`
|
||||
Password string `json:"-"`
|
||||
}
|
||||
|
||||
type SiteHealth struct {
|
||||
SiteID string `json:"site_id"`
|
||||
Slug string `json:"slug"`
|
||||
|
||||
325
server/internal/broker/dynsec.go
Normal file
325
server/internal/broker/dynsec.go
Normal file
@@ -0,0 +1,325 @@
|
||||
// Package broker creates and removes a site's broker login at runtime.
|
||||
//
|
||||
// Until this existed a shop was created in two places by two mechanisms:
|
||||
// `provision site` wrote the row and printed a password, and a person then
|
||||
// typed that password into Mosquitto's passwd file on the host and reloaded
|
||||
// the broker. Nothing else in the product needed a shell, so this one step
|
||||
// was what stopped a tenant opening a second branch on their own - and it was
|
||||
// fragile even for us: the file was mounted read-only in the container, the
|
||||
// first attempt failed, and the password had to be re-rolled.
|
||||
//
|
||||
// Mosquitto 2.0's dynamic-security plugin takes the same operations as
|
||||
// commands on a control topic, from a client that holds the `admin` role.
|
||||
// The server already holds a broker login; this gives it that role and uses
|
||||
// it. No file, no reload, no docker socket, and the per-site credential
|
||||
// model is unchanged - one username per shop, topics only under its own
|
||||
// prefix.
|
||||
//
|
||||
// Isolation is a ROLE PER SITE with literal topics, not one role with `%u`:
|
||||
// the 2.0 plugin does not substitute `%u` in ACL topics (measured - the
|
||||
// publish was denied). The role is created and deleted with the client, so
|
||||
// there is still exactly one thing to get right and it is done in one place.
|
||||
package broker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
paho "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
const (
|
||||
controlTopic = "$CONTROL/dynamic-security/v1"
|
||||
responseTopic = "$CONTROL/dynamic-security/v1/response"
|
||||
// A control round trip on a healthy broker is milliseconds; this is a
|
||||
// bound on a broker that is up but not answering control commands, which
|
||||
// is what a broker WITHOUT the plugin looks like.
|
||||
commandTimeout = 8 * time.Second
|
||||
connectTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// ErrUnavailable is returned when the broker cannot be reached or does not
|
||||
// answer control commands. Callers turn it into a 502/503, never a 500: the
|
||||
// operator's next step is to look at the broker, not at the server.
|
||||
var ErrUnavailable = errors.New("broker: dynamic security not available")
|
||||
|
||||
// SiteBroker is what the API and the provisioner depend on. A fake satisfies
|
||||
// it in tests; Dynsec satisfies it in production.
|
||||
type SiteBroker interface {
|
||||
// EnsureSite creates the broker login for one site, or resets its password
|
||||
// if it already exists. Idempotent: safe to run again after any failure.
|
||||
EnsureSite(ctx context.Context, username, password string) error
|
||||
// DeleteSite removes the login and its role. Missing is not an error.
|
||||
DeleteSite(ctx context.Context, username string) error
|
||||
}
|
||||
|
||||
// Dynsec drives the plugin over its own connection - not the ingest client's.
|
||||
// That one has SetOrderMatters and blocking handlers, and a provisioning call
|
||||
// must neither wait behind a slow visit nor delay one.
|
||||
type Dynsec struct {
|
||||
url, user, pass string
|
||||
log *log.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
client paho.Client
|
||||
pending map[string]chan []response // keyed by batch id
|
||||
}
|
||||
|
||||
func New(url, user, pass string, logger *log.Logger) *Dynsec {
|
||||
if logger == nil {
|
||||
logger = log.Default()
|
||||
}
|
||||
return &Dynsec{url: url, user: user, pass: pass, log: logger, pending: map[string]chan []response{}}
|
||||
}
|
||||
|
||||
type response struct {
|
||||
Command string `json:"command"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CorrelationData string `json:"correlationData,omitempty"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// SiteTopics are exactly what a shop PC may do: publish its own visits,
|
||||
// heartbeats and status, and read its own commands. This mirrors the acl
|
||||
// file the broker used to be configured with, rule for rule.
|
||||
func siteACLs(username string) []map[string]any {
|
||||
prefix := "bv/" + username
|
||||
acl := func(kind, topic string) map[string]any {
|
||||
return map[string]any{"acltype": kind, "topic": topic, "allow": true, "priority": 0}
|
||||
}
|
||||
return []map[string]any{
|
||||
acl("publishClientSend", prefix+"/visit"),
|
||||
acl("publishClientSend", prefix+"/heartbeat"),
|
||||
acl("publishClientSend", prefix+"/status"),
|
||||
acl("publishClientReceive", prefix+"/cmd/#"),
|
||||
acl("subscribePattern", prefix+"/cmd/#"),
|
||||
}
|
||||
}
|
||||
|
||||
// RoleName is the per-site role. Exported so the migration can name it the
|
||||
// same way.
|
||||
func RoleName(username string) string { return "site." + username }
|
||||
|
||||
func (d *Dynsec) EnsureSite(ctx context.Context, username, password string) error {
|
||||
if strings.TrimSpace(username) == "" || password == "" {
|
||||
return errors.New("broker: username and password are required")
|
||||
}
|
||||
role := RoleName(username)
|
||||
|
||||
cmds := []map[string]any{{"command": "createRole", "rolename": role}}
|
||||
for _, a := range siteACLs(username) {
|
||||
c := map[string]any{"command": "addRoleACL", "rolename": role}
|
||||
for k, v := range a {
|
||||
c[k] = v
|
||||
}
|
||||
cmds = append(cmds, c)
|
||||
}
|
||||
cmds = append(cmds, map[string]any{
|
||||
"command": "createClient", "username": username, "password": password,
|
||||
"roles": []map[string]any{{"rolename": role}},
|
||||
})
|
||||
res, err := d.run(ctx, cmds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
clientExisted := false
|
||||
for _, r := range res {
|
||||
switch {
|
||||
case r.Error == "":
|
||||
case strings.Contains(r.Error, "already exists") && r.Command != "createClient":
|
||||
// A re-run after a partial failure. Fine.
|
||||
case r.Command == "createClient" && strings.Contains(r.Error, "already exists"):
|
||||
clientExisted = true
|
||||
default:
|
||||
return fmt.Errorf("broker: %s: %s", r.Command, r.Error)
|
||||
}
|
||||
}
|
||||
if !clientExisted {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The login exists from an earlier run: make its password THIS one - the
|
||||
// database holds this one and the enrolment hands it out - and make sure
|
||||
// it carries the role. addClientRole on a client that already has it
|
||||
// answers "Internal error", so check first rather than guess from prose.
|
||||
res, err = d.run(ctx, []map[string]any{
|
||||
{"command": "setClientPassword", "username": username, "password": password},
|
||||
{"command": "getClient", "username": username},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasRole := false
|
||||
for _, r := range res {
|
||||
if r.Error != "" {
|
||||
return fmt.Errorf("broker: %s: %s", r.Command, r.Error)
|
||||
}
|
||||
if r.Command == "getClient" {
|
||||
var got struct {
|
||||
Client struct {
|
||||
Roles []struct{ Rolename string } `json:"roles"`
|
||||
} `json:"client"`
|
||||
}
|
||||
_ = json.Unmarshal(r.Data, &got)
|
||||
for _, rr := range got.Client.Roles {
|
||||
if rr.Rolename == role {
|
||||
hasRole = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if hasRole {
|
||||
return nil
|
||||
}
|
||||
res, err = d.run(ctx, []map[string]any{{"command": "addClientRole", "username": username, "rolename": role}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res[0].Error != "" {
|
||||
return fmt.Errorf("broker: addClientRole: %s", res[0].Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Dynsec) DeleteSite(ctx context.Context, username string) error {
|
||||
res, err := d.run(ctx, []map[string]any{
|
||||
{"command": "deleteClient", "username": username},
|
||||
{"command": "deleteRole", "rolename": RoleName(username)},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range res {
|
||||
if r.Error != "" && !strings.Contains(r.Error, "not found") {
|
||||
return fmt.Errorf("broker: %s: %s", r.Command, r.Error)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close drops the control connection. Safe when never connected.
|
||||
func (d *Dynsec) Close() {
|
||||
d.mu.Lock()
|
||||
c := d.client
|
||||
d.client = nil
|
||||
d.mu.Unlock()
|
||||
if c != nil && c.IsConnected() {
|
||||
c.Disconnect(250)
|
||||
}
|
||||
}
|
||||
|
||||
// run sends one batch and waits for its one response message. Every command
|
||||
// carries the batch id as correlationData, and the plugin echoes it, so a
|
||||
// response for somebody else's batch - two servers, or a retry - is never
|
||||
// mistaken for ours.
|
||||
func (d *Dynsec) run(ctx context.Context, cmds []map[string]any) ([]response, error) {
|
||||
if err := d.connect(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id := newID()
|
||||
for _, c := range cmds {
|
||||
c["correlationData"] = id
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{"commands": cmds})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan []response, 1)
|
||||
d.mu.Lock()
|
||||
d.pending[id] = ch
|
||||
client := d.client
|
||||
d.mu.Unlock()
|
||||
defer func() {
|
||||
d.mu.Lock()
|
||||
delete(d.pending, id)
|
||||
d.mu.Unlock()
|
||||
}()
|
||||
|
||||
tok := client.Publish(controlTopic, 1, false, body)
|
||||
if !tok.WaitTimeout(commandTimeout) {
|
||||
return nil, fmt.Errorf("%w: publish timed out", ErrUnavailable)
|
||||
}
|
||||
if tok.Error() != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrUnavailable, tok.Error())
|
||||
}
|
||||
select {
|
||||
case res := <-ch:
|
||||
return res, nil
|
||||
case <-time.After(commandTimeout):
|
||||
return nil, fmt.Errorf("%w: no answer on %s - is the dynamic-security plugin enabled and does %q hold the admin role?", ErrUnavailable, responseTopic, d.user)
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dynsec) onResponse(_ paho.Client, m paho.Message) {
|
||||
var env struct {
|
||||
Responses []response `json:"responses"`
|
||||
}
|
||||
if err := json.Unmarshal(m.Payload(), &env); err != nil || len(env.Responses) == 0 {
|
||||
return
|
||||
}
|
||||
id := env.Responses[0].CorrelationData
|
||||
d.mu.Lock()
|
||||
ch, ok := d.pending[id]
|
||||
d.mu.Unlock()
|
||||
if ok {
|
||||
select {
|
||||
case ch <- env.Responses:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dynsec) connect(ctx context.Context) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.client != nil && d.client.IsConnectionOpen() {
|
||||
return nil
|
||||
}
|
||||
opts := paho.NewClientOptions().
|
||||
AddBroker(d.url).
|
||||
SetClientID(fmt.Sprintf("behavision-dynsec-%s", newID()[:8])).
|
||||
SetUsername(d.user).
|
||||
SetPassword(d.pass).
|
||||
SetAutoReconnect(true).
|
||||
SetCleanSession(true).
|
||||
SetKeepAlive(30 * time.Second).
|
||||
SetConnectTimeout(connectTimeout)
|
||||
opts.OnConnect = func(c paho.Client) {
|
||||
// Re-subscribed on every (re)connect: clean session keeps nothing.
|
||||
c.Subscribe(responseTopic, 1, d.onResponse)
|
||||
}
|
||||
c := paho.NewClient(opts)
|
||||
tok := c.Connect()
|
||||
if !tok.WaitTimeout(connectTimeout) {
|
||||
return fmt.Errorf("%w: connect to %s timed out", ErrUnavailable, d.url)
|
||||
}
|
||||
if tok.Error() != nil {
|
||||
return fmt.Errorf("%w: %v", ErrUnavailable, tok.Error())
|
||||
}
|
||||
// The subscription must be in place before the first command is sent, or
|
||||
// its answer is published to nobody.
|
||||
st := c.Subscribe(responseTopic, 1, d.onResponse)
|
||||
if !st.WaitTimeout(connectTimeout) || st.Error() != nil {
|
||||
c.Disconnect(100)
|
||||
return fmt.Errorf("%w: cannot subscribe to %s (does %q hold the admin role?)", ErrUnavailable, responseTopic, d.user)
|
||||
}
|
||||
d.client = c
|
||||
return nil
|
||||
}
|
||||
|
||||
func newID() string {
|
||||
var b [12]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
96
server/internal/broker/dynsec_live_test.go
Normal file
96
server/internal/broker/dynsec_live_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
paho "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
// Runs against a real Mosquitto with the dynamic-security plugin, because a
|
||||
// fake broker would only prove the JSON matches what I believe the plugin
|
||||
// wants. Gated on the environment like the store's live tests:
|
||||
//
|
||||
// DYNSEC_TEST_URL=tcp://127.0.0.1:51884 DYNSEC_TEST_USER=behavision-backend \
|
||||
// DYNSEC_TEST_PASS=pw-backend go test ./internal/broker -run Live -v
|
||||
func TestLiveEnsureAndDeleteSite(t *testing.T) {
|
||||
url, user, pass := os.Getenv("DYNSEC_TEST_URL"), os.Getenv("DYNSEC_TEST_USER"), os.Getenv("DYNSEC_TEST_PASS")
|
||||
if url == "" {
|
||||
t.Skip("DYNSEC_TEST_URL not set")
|
||||
}
|
||||
d := New(url, user, pass, nil)
|
||||
defer d.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
site := "livetest.shop1"
|
||||
if err := d.EnsureSite(ctx, site, "first-pw"); err != nil {
|
||||
t.Fatalf("EnsureSite: %v", err)
|
||||
}
|
||||
// Idempotent, and the password becomes the one given THIS time.
|
||||
if err := d.EnsureSite(ctx, site, "second-pw"); err != nil {
|
||||
t.Fatalf("EnsureSite again: %v", err)
|
||||
}
|
||||
if err := canConnect(url, site, "first-pw"); err == nil {
|
||||
t.Fatalf("old password still accepted after re-ensure")
|
||||
}
|
||||
if err := canConnect(url, site, "second-pw"); err != nil {
|
||||
t.Fatalf("new password refused: %v", err)
|
||||
}
|
||||
// Own topic allowed, another site's refused. A denied publish at QoS 1
|
||||
// still gets a PUBACK, so this is observed through the backend's inbox.
|
||||
got := make(chan string, 4)
|
||||
backend := connect(t, url, user, pass)
|
||||
defer backend.Disconnect(100)
|
||||
backend.Subscribe("bv/#", 1, func(_ paho.Client, m paho.Message) { got <- m.Topic() }).Wait()
|
||||
shop := connect(t, url, site, "second-pw")
|
||||
defer shop.Disconnect(100)
|
||||
shop.Publish("bv/other.shop/visit", 1, false, "leak").Wait()
|
||||
shop.Publish("bv/"+site+"/visit", 1, false, "ok").Wait()
|
||||
select {
|
||||
case topic := <-got:
|
||||
if topic != "bv/"+site+"/visit" {
|
||||
t.Fatalf("first delivered topic was %s", topic)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("own-topic publish never arrived")
|
||||
}
|
||||
select {
|
||||
case topic := <-got:
|
||||
t.Fatalf("unexpected second delivery: %s", topic)
|
||||
case <-time.After(1500 * time.Millisecond):
|
||||
}
|
||||
|
||||
if err := d.DeleteSite(ctx, site); err != nil {
|
||||
t.Fatalf("DeleteSite: %v", err)
|
||||
}
|
||||
if err := d.DeleteSite(ctx, site); err != nil {
|
||||
t.Fatalf("DeleteSite twice: %v", err)
|
||||
}
|
||||
if err := canConnect(url, site, "second-pw"); err == nil {
|
||||
t.Fatal("deleted site can still connect")
|
||||
}
|
||||
}
|
||||
|
||||
func connect(t *testing.T, url, user, pass string) paho.Client {
|
||||
t.Helper()
|
||||
c := paho.NewClient(paho.NewClientOptions().AddBroker(url).SetUsername(user).SetPassword(pass).SetConnectTimeout(5 * time.Second))
|
||||
tok := c.Connect()
|
||||
tok.Wait()
|
||||
if tok.Error() != nil {
|
||||
t.Fatalf("connect as %s: %v", user, tok.Error())
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func canConnect(url, user, pass string) error {
|
||||
c := paho.NewClient(paho.NewClientOptions().AddBroker(url).SetUsername(user).SetPassword(pass).SetConnectTimeout(5 * time.Second))
|
||||
tok := c.Connect()
|
||||
tok.Wait()
|
||||
if tok.Error() == nil {
|
||||
c.Disconnect(50)
|
||||
}
|
||||
return tok.Error()
|
||||
}
|
||||
132
server/internal/broker/migrate.go
Normal file
132
server/internal/broker/migrate.go
Normal file
@@ -0,0 +1,132 @@
|
||||
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$<iterations>$<salt>$<digest>, 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)
|
||||
}
|
||||
73
server/internal/broker/migrate_test.go
Normal file
73
server/internal/broker/migrate_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const passwd = `behavision-backend:$7$101$c2FsdA==$ZGlnZXN0
|
||||
health:$7$101$aGVhbHRo$aGFzaA==
|
||||
acme.store1:$7$101$c2l0ZQ==$c2l0ZWhhc2g=
|
||||
`
|
||||
|
||||
func TestPasswdBecomesTheStoreWithHashesIntact(t *testing.T) {
|
||||
st, err := FromPasswd(strings.NewReader(passwd), "behavision-backend", "health")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(st.Clients) != 3 {
|
||||
t.Fatalf("clients: %d", len(st.Clients))
|
||||
}
|
||||
site := st.Clients[2]
|
||||
if site.Password != "c2l0ZWhhc2g=" || site.Salt != "c2l0ZQ==" || site.Iterations != 101 {
|
||||
t.Fatalf("hash not carried over intact: %+v", site)
|
||||
}
|
||||
if site.Roles[0].Rolename != "site.acme.store1" {
|
||||
t.Fatalf("site role: %+v", site.Roles)
|
||||
}
|
||||
var siteRole *Role
|
||||
for i := range st.Roles {
|
||||
if st.Roles[i].Rolename == "site.acme.store1" {
|
||||
siteRole = &st.Roles[i]
|
||||
}
|
||||
}
|
||||
if siteRole == nil {
|
||||
t.Fatal("no role for the site")
|
||||
}
|
||||
topics := map[string]bool{}
|
||||
for _, a := range siteRole.ACLs {
|
||||
topics[a.ACLType+" "+a.Topic] = true
|
||||
}
|
||||
for _, want := range []string{
|
||||
"publishClientSend bv/acme.store1/visit",
|
||||
"publishClientSend bv/acme.store1/heartbeat",
|
||||
"publishClientSend bv/acme.store1/status",
|
||||
"subscribePattern bv/acme.store1/cmd/#",
|
||||
} {
|
||||
if !topics[want] {
|
||||
t.Errorf("missing %q in %v", want, topics)
|
||||
}
|
||||
}
|
||||
if st.Clients[0].Roles[0].Rolename != "admin" {
|
||||
t.Fatalf("backend is not an admin: %+v", st.Clients[0].Roles)
|
||||
}
|
||||
if st.DefaultACLAccess["publishClientSend"] || st.DefaultACLAccess["subscribe"] {
|
||||
t.Fatal("default access must be deny")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := st.Encode(&buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !json.Valid(buf.Bytes()) {
|
||||
t.Fatal("encoded store is not valid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestANonPBKDF2LineIsRefusedByName(t *testing.T) {
|
||||
_, err := FromPasswd(strings.NewReader("old:$6$abc$def\n"), "b", "h")
|
||||
if err == nil || !strings.Contains(err.Error(), "old") {
|
||||
t.Fatalf("expected a named refusal, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,15 @@ import (
|
||||
type Provisioner struct {
|
||||
Pool *pgxpool.Pool
|
||||
Secrets *secret.Box
|
||||
// Broker registers a site's login with Mosquitto when set. Without it the
|
||||
// command prints the password for a hand edit of the passwd file, which
|
||||
// is the pre-dynamic-security fallback and nothing more.
|
||||
Broker SiteBroker
|
||||
}
|
||||
|
||||
// SiteBroker mirrors internal/broker's interface without importing it.
|
||||
type SiteBroker interface {
|
||||
EnsureSite(ctx context.Context, username, password string) error
|
||||
}
|
||||
|
||||
func (p *Provisioner) CreateClient(ctx context.Context, slug, name string) (string, error) {
|
||||
@@ -48,6 +57,9 @@ type SiteResult struct {
|
||||
AgentID string
|
||||
Username string
|
||||
Password string
|
||||
// BrokerRegistered is true when the login was pushed to Mosquitto here,
|
||||
// so nothing remains for a person to type.
|
||||
BrokerRegistered bool
|
||||
}
|
||||
|
||||
// CreateSite makes a site, its agent row, and the broker password.
|
||||
@@ -120,7 +132,16 @@ func (p *Provisioner) CreateSite(ctx context.Context, clientSlug, siteSlug, name
|
||||
out.AgentID, sealed); err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, tx.Commit(ctx)
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if p.Broker != nil {
|
||||
if err := p.Broker.EnsureSite(ctx, out.Username, out.Password); err != nil {
|
||||
return out, fmt.Errorf("site %s created, but the broker did not accept its login: %w", out.Username, err)
|
||||
}
|
||||
out.BrokerRegistered = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Provisioner) CreateUser(ctx context.Context, clientSlug, email, role,
|
||||
|
||||
115
server/internal/store/api_sites.go
Normal file
115
server/internal/store/api_sites.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/api"
|
||||
)
|
||||
|
||||
// CreateSite is the row half of opening a shop. The broker half follows it in
|
||||
// the handler and the provisioner, and both use this so there is one
|
||||
// definition of what a site is.
|
||||
func (s *Store) CreateSite(ctx context.Context, clientID, slug, name, tz string) (api.NewSite, error) {
|
||||
var out api.NewSite
|
||||
if s.secrets == nil {
|
||||
return out, ErrNoSecrets
|
||||
}
|
||||
slug = strings.ToLower(strings.TrimSpace(slug))
|
||||
if tz == "" {
|
||||
tz = "UTC"
|
||||
}
|
||||
if _, err := time.LoadLocation(tz); err != nil {
|
||||
return out, fmt.Errorf("unknown timezone %q", tz)
|
||||
}
|
||||
|
||||
var clientSlug string
|
||||
if err := s.pool.QueryRow(ctx, `SELECT slug FROM clients WHERE id = $1::uuid`, clientID).Scan(&clientSlug); err != nil {
|
||||
return out, fmt.Errorf("client %s: %w", clientID, err)
|
||||
}
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
|
||||
// A plain INSERT, not an upsert: the API must not let an owner silently
|
||||
// rename an existing shop by re-posting its slug. A duplicate surfaces as
|
||||
// 23505 and the handler turns it into 409.
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO sites (client_id, slug, name, timezone)
|
||||
VALUES ($1::uuid, $2, $3, $4)
|
||||
RETURNING id::text`, clientID, slug, name, tz).Scan(&out.SiteID); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Slug, out.Name, out.Timezone = slug, name, tz
|
||||
// The broker username IS the topic namespace: <client>.<site>. The ACL is
|
||||
// written against it, so it is derived, never chosen.
|
||||
out.Username = clientSlug + "." + slug
|
||||
|
||||
var agentID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO agents (client_id, site_id, mqtt_username)
|
||||
VALUES ($1::uuid, $2::uuid, $3)
|
||||
RETURNING id::text`, clientID, out.SiteID, out.Username).Scan(&agentID); err != nil {
|
||||
return out, fmt.Errorf("create agent: %w", err)
|
||||
}
|
||||
|
||||
out.Password, err = randomSecret(24)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
// Sealed with the agent id as aad, so a row copied between agents does not
|
||||
// decrypt into a working credential.
|
||||
sealed, err := s.secrets.SealString(out.Password, agentID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE agents SET mqtt_password_enc = $2 WHERE id = $1::uuid`, agentID, sealed); err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// DeleteNewSite removes a shop that was created moments ago and could not be
|
||||
// registered with the broker. Scoped to the tenant and refused once the shop
|
||||
// has anything under it: this is compensation for a failed create, not a
|
||||
// delete-shop feature.
|
||||
func (s *Store) DeleteNewSite(ctx context.Context, clientID, siteID string) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
var used bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT EXISTS (SELECT 1 FROM visits WHERE site_id = $1::uuid)
|
||||
OR EXISTS (SELECT 1 FROM site_cameras WHERE site_id = $1::uuid)`, siteID).Scan(&used); err != nil {
|
||||
return err
|
||||
}
|
||||
if used {
|
||||
return fmt.Errorf("site %s is in use", siteID)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM agents WHERE site_id = $1::uuid AND client_id = $2::uuid`, siteID, clientID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM sites WHERE id = $1::uuid AND client_id = $2::uuid`, siteID, clientID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func randomSecret(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// base32 without padding: this gets typed, pasted into config files and
|
||||
// read down a phone line, and base64's + / = survive none of that.
|
||||
return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)), nil
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
2
server/internal/web/dist/index.html
vendored
2
server/internal/web/dist/index.html
vendored
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Behavision</title>
|
||||
<script type="module" crossorigin src="/assets/index-BI5JLIeo.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-xQ5C-n-U.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bgt5SnW3.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -205,6 +205,7 @@ export const api = {
|
||||
me: () => send('GET', '/api/auth/me'),
|
||||
|
||||
sites: () => send('GET', '/api/sites'),
|
||||
createSite: (input) => send('POST', '/api/sites', input),
|
||||
|
||||
// The live arrivals feed. `cursor` is opaque and must be echoed back.
|
||||
arrivals: (params) => send('GET', '/api/visits' + qs(params)),
|
||||
|
||||
@@ -17,18 +17,22 @@ import SiteCheck from './SiteCheck.jsx'
|
||||
// shops. So the shop's own camera view is the card, the numbers sit under it,
|
||||
// and one line says what to do — with the technical detail one click away
|
||||
// rather than on the surface.
|
||||
export default function Sites() {
|
||||
const { data, error, loading } = usePolled(() => api.sites(), 20000, [])
|
||||
export default function Sites({ user }) {
|
||||
const { data, error, loading, reload } = usePolled(() => api.sites(), 20000, [])
|
||||
// Cameras come from a second call and are joined here rather than server-side:
|
||||
// the picture is decoration on this screen, so it must never be able to make
|
||||
// the health list fail. If this errors the cards simply have no photograph.
|
||||
const { data: cams } = usePolled(() => api.cameras(), 60000, [])
|
||||
const [checking, setChecking] = useState(null)
|
||||
const [opening, setOpening] = useState(false)
|
||||
const sites = data || []
|
||||
// Opening a shop is the owner's: it is a billing and tenancy object, not a
|
||||
// setting. Managers set up the PC and cameras once it exists.
|
||||
const canOpen = user?.role === 'owner'
|
||||
|
||||
if (loading && !data) return <Loading />
|
||||
if (error) return <Problem error={error} />
|
||||
if (!sites.length) return <Empty />
|
||||
if (!sites.length && !opening) return <Empty canOpen={canOpen} onOpen={() => setOpening(true)} />
|
||||
|
||||
// Counted from the same verdicts the cards show. Summarising with a second,
|
||||
// simpler rule up here is how a header ends up reading "all working" over a
|
||||
@@ -51,6 +55,9 @@ export default function Sites() {
|
||||
{fresh > 0 && <> · {fresh} not set up yet</>}
|
||||
{!broken && !watch && !fresh && <> · <b className="ok">all working</b></>}
|
||||
</p>
|
||||
{canOpen && (
|
||||
<button className="primary" onClick={() => setOpening(true)}>Open a new shop</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="grid sites">
|
||||
@@ -64,10 +71,66 @@ export default function Sites() {
|
||||
button on the camera screen that always checked sites[0], so with two
|
||||
shops the second could not be checked at all. */}
|
||||
{checking && <SiteCheck site={checking} onClose={() => setChecking(null)} />}
|
||||
{opening && <NewShop onClose={() => setOpening(false)}
|
||||
onCreated={() => { setOpening(false); reload() }} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Until this existed a shop was a command on the server plus a hand edit of
|
||||
// the broker's password file - every new branch a support ticket, and the one
|
||||
// piece of onboarding that could not be done from the product. The server now
|
||||
// registers the shop's broker login as it creates the row, so the next step
|
||||
// really is just "Set up a shop PC" on the new card.
|
||||
function NewShop({ onClose, onCreated }) {
|
||||
const [form, setForm] = useState({ name: '', slug: '', timezone: 'Asia/Kolkata' })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const set = (k) => (e) => setForm({ ...form, [k]: e.target.value })
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
onCreated(await api.createSite({ name: form.name, slug: form.slug || undefined, timezone: form.timezone }))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={onClose}>
|
||||
<aside className="drawer narrow" onClick={e => e.stopPropagation()}>
|
||||
<header className="drawer-head">
|
||||
<h2>Open a new shop</h2>
|
||||
<button className="ghost" onClick={onClose}>Close</button>
|
||||
</header>
|
||||
<form className="drawer-body" onSubmit={submit}>
|
||||
<label>Shop name
|
||||
<input value={form.name} onChange={set('name')} required autoFocus placeholder="TeNext Bengaluru" />
|
||||
</label>
|
||||
<label>Short name
|
||||
<input value={form.slug} onChange={set('slug')} placeholder="made from the name if left empty" />
|
||||
<span className="hint">
|
||||
Lower-case letters, digits and dashes. It becomes the shop PC’s
|
||||
identity and cannot be changed afterwards.
|
||||
</span>
|
||||
</label>
|
||||
<label>Timezone
|
||||
<input value={form.timezone} onChange={set('timezone')} required />
|
||||
<span className="hint">Footfall is bucketed by the shop’s own clock, e.g. Asia/Kolkata.</span>
|
||||
</label>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
<button className="primary" disabled={busy || !form.name.trim()}>
|
||||
{busy ? 'Opening…' : 'Open shop'}
|
||||
</button>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// The one line the card leads with, in severity order. Only the first is shown:
|
||||
// a shop that is offline AND has a bad camera needs its PC turned on first, and
|
||||
// listing both invites someone to start with the wrong one.
|
||||
@@ -272,13 +335,15 @@ export function Problem({ error }) {
|
||||
)
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
function Empty({ canOpen, onOpen }) {
|
||||
return (
|
||||
<div className="state">
|
||||
<h2>No shops yet</h2>
|
||||
<p className="sub">
|
||||
A shop appears here once its PC has been claimed with an enrolment code.
|
||||
{canOpen ? 'Open your first shop, then set up its PC and cameras from its card.'
|
||||
: 'The owner opens shops; they appear here once created.'}
|
||||
</p>
|
||||
{canOpen && <button className="primary" onClick={onOpen}>Open a new shop</button>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user