// Package enrol links a PC to a shop, using the one-shot code an operator is // given. // // It existed only inside the desktop app, which meant a HEADLESS install - a // back-office PC with no window, the configuration the agent binary is for - // could not be claimed at all. The only route was hand-editing agent.json, // which is exactly the state the desktop's Setup screen was built to end. // // The endpoint behind this is deliberately unauthenticated: the PC doing it has // nobody signed in yet, and requiring a login would mean shipping a password to // every shop that installs the software. package enrol import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) // Bootstrap is what the server hands back: which shop this PC is, and the // credentials it needs to say so. type Bootstrap struct { ClientSlug string `json:"client_slug"` SiteSlug string `json:"site_slug"` SiteName string `json:"site_name"` MQTTURL string `json:"mqtt_url"` MQTTUser string `json:"mqtt_username"` MQTTPass string `json:"mqtt_password"` CAPem string `json:"ca_pem,omitempty"` AgentToken string `json:"agent_token"` } // Claim redeems an installation code. // // The code is read aloud down a phone and photographed off screens, so what is // typed here can be as untidy as it needs to be: the server strips spaces, // dashes and case at its end. Sending it as typed keeps ONE implementation of // that normalisation, on the side that also issued the code - two would // eventually disagree and hash to something the redeemer never produces. func Claim(ctx context.Context, base, code string) (Bootstrap, error) { var out Bootstrap base = strings.TrimRight(base, "/") if base == "" { return out, fmt.Errorf("no server address configured (set cloud_base or BEHAVISION_CLOUD)") } body, err := json.Marshal(map[string]string{"site_token": code}) if err != nil { return out, err } ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/api/agent/enrol", bytes.NewReader(body)) if err != nil { return out, err } req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return out, fmt.Errorf("could not reach %s: %w", base, err) } defer resp.Body.Close() blob, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) if resp.StatusCode != http.StatusOK { // The server answers unknown, expired and already-used identically on // purpose - the difference only helps somebody guessing codes, and the // operator's next step is the same in all three cases. Its own words // are passed through rather than reworded here. var e struct { Message string `json:"message"` } _ = json.Unmarshal(blob, &e) if e.Message != "" { return out, fmt.Errorf("%s", e.Message) } return out, fmt.Errorf("head office: %s", resp.Status) } if err := json.Unmarshal(blob, &out); err != nil { return out, err } if out.SiteSlug == "" || out.MQTTURL == "" { return out, fmt.Errorf("head office returned an incomplete setup") } return out, nil }