package configgen import ( "crypto/rand" "encoding/hex" "fmt" "math/big" "strings" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/store" "gopkg.in/yaml.v3" ) // Generate takes the template YAML and a customer config, // then produces a complete controller.yaml with customer-specific values // merged in. The returned string is valid YAML ready for deployment. // // claimState (v0.50.0, nil-safe): when present, the ACTIVE claim-code bcrypt hash + generation // are baked into web.claim_code_* so a Day-0 box is claim-gated from its FIRST boot (the // controller's precedence: a set password always wins; the hash alone never overrides one). func Generate(templateYAML string, cfg *store.CustomerConfig, claimState *store.ClaimState) (string, error) { // Parse template into generic map var base map[string]interface{} if err := yaml.Unmarshal([]byte(templateYAML), &base); err != nil { return "", fmt.Errorf("parsing template YAML: %w", err) } if base == nil { base = make(map[string]interface{}) } // Parse customer config_json overrides var overrides map[string]interface{} if cfg.ConfigJSON != "" && cfg.ConfigJSON != "{}" { if err := yaml.Unmarshal([]byte(cfg.ConfigJSON), &overrides); err != nil { return "", fmt.Errorf("parsing config overrides: %w", err) } } // Apply config_json overrides first (deep merge) if len(overrides) > 0 { base = deepMerge(base, overrides) } // Apply programmatic overrides — these always win over config_json setNested(base, []string{"customer", "id"}, cfg.CustomerID) setNested(base, []string{"customer", "name"}, cfg.CustomerName) setNested(base, []string{"customer", "domain"}, cfg.Domain) setNested(base, []string{"customer", "email"}, cfg.Email) setNested(base, []string{"hub", "enabled"}, true) setNested(base, []string{"hub", "url"}, "https://hub.felhom.eu") setNested(base, []string{"hub", "api_key"}, cfg.APIKey) // Generate session secret sessionSecret, err := RandomHex(32) if err != nil { return "", fmt.Errorf("generating session secret: %w", err) } setNested(base, []string{"web", "session_secret"}, sessionSecret) // Customer-claim arc (v0.50.0): bake the active claim-code hash so the gate is armed from // first boot. bcrypt only — the plaintext code never reaches any config. if claimState != nil && claimState.CodeHash != "" { setNested(base, []string{"web", "claim_code_hash"}, claimState.CodeHash) setNested(base, []string{"web", "claim_code_generation"}, claimState.Generation) setNested(base, []string{"web", "claim_code_issued_at"}, claimState.IssuedAt.UTC().Format(time.RFC3339)) } // Marshal back to YAML out, err := yaml.Marshal(base) if err != nil { return "", fmt.Errorf("marshaling YAML: %w", err) } // Add header comment header := fmt.Sprintf( "# Felhom Controller Configuration\n# Generated by Felhom Hub for %q on %s\n# Download URL: https://hub.felhom.eu/api/v1/config/%s\n\n", cfg.CustomerID, time.Now().UTC().Format(time.RFC3339), cfg.CustomerID, ) return header + string(out), nil } // deepMerge recursively merges overlay into base. // When both base and overlay have a map at the same key, they are merged recursively. // Otherwise, the overlay value wins. func deepMerge(base, overlay map[string]interface{}) map[string]interface{} { result := make(map[string]interface{}, len(base)) for k, v := range base { result[k] = v } for k, v := range overlay { if baseMap, ok := result[k].(map[string]interface{}); ok { if overlayMap, ok := v.(map[string]interface{}); ok { result[k] = deepMerge(baseMap, overlayMap) continue } } result[k] = v } return result } // setNested sets a value at a nested path in a map, creating intermediate maps as needed. func setNested(m map[string]interface{}, path []string, value interface{}) { for i, key := range path { if i == len(path)-1 { m[key] = value return } sub, ok := m[key].(map[string]interface{}) if !ok { sub = make(map[string]interface{}) m[key] = sub } m = sub } } // RandomHex generates n random bytes and returns them as a hex string. func RandomHex(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } // pairingAlphabet excludes visually ambiguous characters (0/O, 1/I/L) so a customer can read the code // off the box's console banner and type it into the self-bind page without confusion. const pairingAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" // RandomPairingCode returns a 6-char appliance pairing code from the unambiguous alphabet (rendered // "ABC-DEF" for display; stored/compared without the dash). It is NOT a secret on its own — the // self-bind flow also requires the customer's retrieval passphrase. func RandomPairingCode() (string, error) { b := make([]byte, 6) max := big.NewInt(int64(len(pairingAlphabet))) for i := range b { idx, err := rand.Int(rand.Reader, max) if err != nil { return "", err } b[i] = pairingAlphabet[idx.Int64()] } return string(b), nil } // FormatPairingCode renders a stored 6-char code as "ABC-DEF" for the console banner + the operator UI. func FormatPairingCode(code string) string { if len(code) == 6 { return code[:3] + "-" + code[3:] } return code } // NormalizePairingCode strips separators/whitespace and upper-cases (the customer may type "abc-def", // "abc def", or "ABCDEF") so the compare is against a canonical form. func NormalizePairingCode(s string) string { var b strings.Builder for _, r := range strings.ToUpper(s) { if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { b.WriteRune(r) } } return b.String() } // NormalizePassphrase canonicalizes a diceware passphrase for the constant-time compare: trim, // lower-case, and collapse any run of dashes/whitespace to a single dash. The WORDS themselves are // compared exactly (no accent-folding — the Hungarian wordlist is the source of truth), so a mistyped // word fails. func NormalizePassphrase(s string) string { s = strings.ToLower(strings.TrimSpace(s)) fields := strings.FieldsFunc(s, func(r rune) bool { return r == '-' || r == ' ' || r == '\t' || r == '\n' || r == '\r' }) return strings.Join(fields, "-") }