Add optional mothership IP override to the provisioning flow so nodes on networks where mDNS is blocked (enterprise WiFi, mesh, VLANs) can connect on first boot without manual intervention. - Add ms_ip field to provisioning Payload and request structs - Firmware writes ms_ip to both NVS_KEY_MS_IP and NVS_KEY_MS_IP_PROV - Discovery prefers provisioned IP on first attempt, falls back to mDNS, then cached IP - Web Serial wizard adds Mothership IP field in Network Troubleshooting - Auto-populates IP when browser accesses dashboard by IP address - Document when/how to use the override in docs/notes/mdns-override.md Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
200 lines
6 KiB
Go
200 lines
6 KiB
Go
// Package provisioning handles the /api/provision endpoint used by the
|
|
// dashboard Web Serial onboarding wizard to generate per-node configuration blobs.
|
|
package provisioning
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Payload is the provisioning blob written to the node's NVS via Web Serial.
|
|
type Payload struct {
|
|
Version int `json:"version"`
|
|
WifiSSID string `json:"wifi_ssid"`
|
|
WifiPass string `json:"wifi_pass"`
|
|
NodeID string `json:"node_id"`
|
|
NodeToken string `json:"node_token"`
|
|
MsMDNS string `json:"ms_mdns"`
|
|
MsIP string `json:"ms_ip,omitempty"` // Direct IPv4 override for mDNS-less networks
|
|
MsPort int `json:"ms_port"`
|
|
NTPServer string `json:"ntp_server"`
|
|
Debug bool `json:"debug"`
|
|
}
|
|
|
|
// provisionRequest is the optional POST body for /api/provision.
|
|
type provisionRequest struct {
|
|
WifiSSID string `json:"wifi_ssid"`
|
|
WifiPass string `json:"wifi_pass"`
|
|
MAC string `json:"mac,omitempty"` // optional; used to derive deterministic node_token
|
|
MsIP string `json:"ms_ip,omitempty"`
|
|
Debug bool `json:"debug,omitempty"`
|
|
}
|
|
|
|
// Server handles provisioning payload generation.
|
|
type Server struct {
|
|
mu sync.RWMutex
|
|
secretFile string
|
|
installSecret []byte // 32-byte HMAC key; persisted to secretFile
|
|
mdnsName string
|
|
msPort int
|
|
ntpServer string
|
|
}
|
|
|
|
// NewServer creates a provisioning server.
|
|
// dataDir is where the install secret is persisted.
|
|
// mdnsName and msPort are embedded in the payload so the node can find the mothership.
|
|
// ntpServer is the NTP server hostname to embed in the provisioning payload.
|
|
// installSecretHex is an optional 64-char hex string; if provided, it overrides the persisted secret.
|
|
func NewServer(dataDir, mdnsName string, msPort int, ntpServer string, installSecretHex string) *Server {
|
|
s := &Server{
|
|
secretFile: filepath.Join(dataDir, "install_secret.bin"),
|
|
mdnsName: mdnsName,
|
|
msPort: msPort,
|
|
ntpServer: ntpServer,
|
|
}
|
|
// If install secret provided via config, use it instead of loading/creating
|
|
if installSecretHex != "" {
|
|
decoded, err := hex.DecodeString(installSecretHex)
|
|
if err == nil && len(decoded) == 32 {
|
|
s.installSecret = decoded
|
|
log.Printf("[INFO] provisioning: using install secret from SPAXEL_INSTALL_SECRET")
|
|
} else {
|
|
log.Printf("[WARN] provisioning: invalid SPAXEL_INSTALL_SECRET, will use persisted secret")
|
|
}
|
|
}
|
|
if err := s.loadOrCreateSecret(); err != nil {
|
|
log.Printf("[ERROR] provisioning: could not load/create install secret: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func envOr(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// loadOrCreateSecret reads or generates the 32-byte install secret.
|
|
func (s *Server) loadOrCreateSecret() error {
|
|
data, err := os.ReadFile(s.secretFile)
|
|
if err == nil && len(data) == 32 {
|
|
s.installSecret = data
|
|
log.Printf("[INFO] provisioning: loaded install secret from %s", s.secretFile)
|
|
return nil
|
|
}
|
|
|
|
// Generate a new secret
|
|
secret := make([]byte, 32)
|
|
if _, err := rand.Read(secret); err != nil {
|
|
return fmt.Errorf("generate secret: %w", err)
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(s.secretFile), 0700); err != nil {
|
|
return fmt.Errorf("mkdir: %w", err)
|
|
}
|
|
if err := os.WriteFile(s.secretFile, secret, 0600); err != nil {
|
|
return fmt.Errorf("write secret: %w", err)
|
|
}
|
|
s.installSecret = secret
|
|
log.Printf("[INFO] provisioning: generated new install secret at %s", s.secretFile)
|
|
return nil
|
|
}
|
|
|
|
// deriveToken computes HMAC-SHA256(installSecret, mac) and returns 64-char hex.
|
|
func (s *Server) deriveToken(mac string) string {
|
|
mac = strings.ToUpper(strings.ReplaceAll(mac, ":", ""))
|
|
h := hmac.New(sha256.New, s.installSecret)
|
|
h.Write([]byte(mac))
|
|
return hex.EncodeToString(h.Sum(nil))
|
|
}
|
|
|
|
// GetInstallSecret returns the 32-byte installation secret.
|
|
// Used by the ingestion server to create a token validator.
|
|
func (s *Server) GetInstallSecret() []byte {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.installSecret
|
|
}
|
|
|
|
// ValidateToken checks whether the provided token matches the expected
|
|
// HMAC-SHA256(installSecret, mac) using constant-time comparison.
|
|
func (s *Server) ValidateToken(mac, token string) bool {
|
|
s.mu.RLock()
|
|
secret := s.installSecret
|
|
s.mu.RUnlock()
|
|
if secret == nil {
|
|
return false
|
|
}
|
|
expected := s.deriveToken(mac)
|
|
return subtle.ConstantTimeCompare([]byte(expected), []byte(token)) == 1
|
|
}
|
|
|
|
// HandleProvision serves POST /api/provision.
|
|
//
|
|
// Request body (JSON, all optional):
|
|
//
|
|
// { "wifi_ssid": "...", "wifi_pass": "...", "mac": "AA:BB:CC:DD:EE:FF", "debug": false }
|
|
//
|
|
// Returns the provisioning payload to be written to NVS via Web Serial.
|
|
func (s *Server) HandleProvision(w http.ResponseWriter, r *http.Request) {
|
|
var req provisionRequest
|
|
if r.Body != nil && r.ContentLength != 0 {
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
s.mu.RLock()
|
|
secret := s.installSecret
|
|
s.mu.RUnlock()
|
|
|
|
if secret == nil {
|
|
http.Error(w, "provisioning not ready (no install secret)", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
nodeID := uuid.NewString()
|
|
var token string
|
|
if req.MAC != "" {
|
|
token = s.deriveToken(req.MAC)
|
|
} else {
|
|
// Placeholder token — will be re-derived when the node sends hello with its MAC.
|
|
// The ingestion server has a 120-second grace window.
|
|
raw := make([]byte, 32)
|
|
rand.Read(raw) //nolint:errcheck // best-effort placeholder
|
|
token = hex.EncodeToString(raw)
|
|
}
|
|
|
|
payload := Payload{
|
|
Version: 1,
|
|
WifiSSID: req.WifiSSID,
|
|
WifiPass: req.WifiPass,
|
|
NodeID: nodeID,
|
|
NodeToken: token,
|
|
MsMDNS: s.mdnsName,
|
|
MsIP: req.MsIP,
|
|
MsPort: s.msPort,
|
|
NTPServer: s.ntpServer,
|
|
Debug: req.Debug,
|
|
}
|
|
|
|
log.Printf("[INFO] provisioning: generated payload node_id=%s mac=%s", nodeID, req.MAC)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(payload)
|
|
}
|