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
108 lines
3.9 KiB
Go
108 lines
3.9 KiB
Go
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)
|
|
}
|