package api import ( "net/http" "strings" "github.com/loyaly/behavision-server/internal/auth" ) // handleEnrol turns an anonymous install into a known site. // // The installer ships with no credentials of any kind, so a leaked build hands // out nothing at all. An operator types a one-shot code once; the server // answers with the broker login for exactly one site and marks the code spent. // // Deliberately NOT behind a session: the PC doing this has no user signed in // yet, and requiring one would mean shipping a password to every shop that // installs the software. func (s *Server) handleEnrol(w http.ResponseWriter, r *http.Request) { var body struct { SiteToken string `json:"site_token"` Device string `json:"device"` } if err := decode(w, r, &body); err != nil { badRequest(w, err.Error()) return } token := trim(body.SiteToken) if token == "" { badRequest(w, "site_token is required") return } token = auth.NormalizeCode(token) en, err := s.Store.RedeemEnrolment(r.Context(), auth.HashToken(token)) if err != nil { // One message for unknown, expired and already-used. The difference is // only useful to somebody guessing codes, and an operator's next step // is the same in all three cases: ask for a new one. s.logf("enrolment refused: %v", err) writeErr(w, http.StatusUnauthorized, "bad_token", "That installation code is not valid. Ask for a new one.") return } // Mint the agent's own HTTPS credential now, while we have a proven // one-shot token in hand. Separate from the broker password because they // authenticate different things, so rotating one must not break the other. agentToken, err := auth.NewToken() if err != nil { s.serverError(w, "mint agent token", err) return } if err := s.Store.SetAgentAPIToken(r.Context(), en.AgentID, agentToken.Hash); err != nil { s.serverError(w, "store agent token", err) return } s.Store.Audit(r.Context(), AuditEntry{ ClientID: en.ClientID, ActorKind: "agent", Action: "agent.enrol", Entity: "site", EntityID: en.SiteID, Detail: map[string]any{"device": clip(trim(body.Device), 120)}, }) models := s.Bootstrap.Models if models == nil { // An empty list, not null: the agent then falls back to its own built-in // download list rather than treating the field as missing. models = []ModelRef{} } writeJSON(w, http.StatusOK, map[string]any{ "site_id": en.SiteID, "site_name": en.SiteName, "site_slug": en.SiteSlug, // Derived from the broker username rather than looked up separately, // because the agent uses . as its topic prefix and the // broker's ACL is written against that exact username. Deriving it // makes the two equal by construction; a second lookup could drift. "client_slug": clientSlugOf(en.MQTTUser), "topic_prefix": "bv/" + en.MQTTUser, "mqtt_url": s.Bootstrap.MQTTURL, "mqtt_username": en.MQTTUser, "mqtt_password": en.MQTTPass, // Used for HTTPS calls the PC makes on its own behalf, such as asking // for an image upload URL. Shown once and never returned again. "agent_token": agentToken.Plain, "ca_cert": s.Bootstrap.CACert, "models": models, }) } // clientSlugOf pulls the tenant out of a broker username of the form // ".". Split on the FIRST dot only in the sense that a username // always has exactly one - the same rule contract.ParseTopic enforces on the // way back in. func clientSlugOf(brokerUser string) string { if i := strings.Index(brokerUser, "."); i > 0 { return brokerUser[:i] } return "" }