package v1 import ( "encoding/json" "log/slog" "net/http" "github.com/wild-cloud/wild-central/internal/config" "github.com/wild-cloud/wild-central/internal/storage" ) // loadState loads state from disk, returning an empty state if the file doesn't exist. func (api *API) loadState() *config.State { state, err := config.LoadState(api.statePath()) if err != nil { return &config.State{} } return state } // modifyState atomically reads state, applies fn, and writes it back under a // file lock. This prevents concurrent handlers from silently dropping each // other's changes via overlapping read-modify-write cycles. func (api *API) modifyState(fn func(state *config.State) error) error { lockPath := api.statePath() + ".lock" return storage.WithLock(lockPath, func() error { state, err := config.LoadState(api.statePath()) if err != nil { state = &config.State{} } if err := fn(state); err != nil { return err } return config.SaveState(state, api.statePath()) }) } // --- Operator --- func (api *API) GetOperator(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, api.loadState().Operator) } func (api *API) UpdateOperator(w http.ResponseWriter, r *http.Request) { var updates struct { Email string `json:"email"` } if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { respondError(w, http.StatusBadRequest, "Invalid request body") return } var result any if err := api.modifyState(func(state *config.State) error { state.Operator.Email = updates.Email result = state.Operator return nil }); err != nil { respondError(w, http.StatusInternalServerError, "Failed to save state") return } slog.Info("operator updated", "email", updates.Email) respondJSON(w, http.StatusOK, result) } // --- Central Domain --- func (api *API) GetCentralDomain(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]string{ "domain": api.loadState().Cloud.Central.Domain, }) } func (api *API) UpdateCentralDomain(w http.ResponseWriter, r *http.Request) { var updates struct { Domain string `json:"domain"` } if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { respondError(w, http.StatusBadRequest, "Invalid request body") return } if err := api.modifyState(func(state *config.State) error { state.Cloud.Central.Domain = updates.Domain return nil }); err != nil { respondError(w, http.StatusInternalServerError, "Failed to save state") return } go api.EnsureCentralDomain() slog.Info("central domain updated", "domain", updates.Domain) respondJSON(w, http.StatusOK, map[string]string{"domain": updates.Domain}) } // --- DDNS Config --- func (api *API) GetDDNSConfig(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, api.loadState().Cloud.DDNS) } func (api *API) UpdateDDNSConfig(w http.ResponseWriter, r *http.Request) { var updates struct { Enabled *bool `json:"enabled,omitempty"` Provider string `json:"provider,omitempty"` IntervalMinutes *int `json:"intervalMinutes,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { respondError(w, http.StatusBadRequest, "Invalid request body") return } var result any if err := api.modifyState(func(state *config.State) error { if updates.Enabled != nil { state.Cloud.DDNS.Enabled = *updates.Enabled } if updates.Provider != "" { state.Cloud.DDNS.Provider = updates.Provider } if updates.IntervalMinutes != nil { state.Cloud.DDNS.IntervalMinutes = *updates.IntervalMinutes } result = state.Cloud.DDNS return nil }); err != nil { respondError(w, http.StatusInternalServerError, "Failed to save state") return } go api.reloadDDNSIfEnabled() slog.Info("DDNS config updated") respondJSON(w, http.StatusOK, result) } // --- DNS Settings (dnsmasq IP/interface) --- func (api *API) GetDNSSettings(w http.ResponseWriter, r *http.Request) { d := api.loadState().Cloud.Dnsmasq respondJSON(w, http.StatusOK, map[string]string{ "ip": d.IP, "interface": d.Interface, }) } func (api *API) UpdateDNSSettings(w http.ResponseWriter, r *http.Request) { var updates struct { IP string `json:"ip,omitempty"` Interface string `json:"interface,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { respondError(w, http.StatusBadRequest, "Invalid request body") return } var result map[string]string if err := api.modifyState(func(state *config.State) error { if updates.IP != "" { state.Cloud.Dnsmasq.IP = updates.IP } if updates.Interface != "" { state.Cloud.Dnsmasq.Interface = updates.Interface } result = map[string]string{ "ip": state.Cloud.Dnsmasq.IP, "interface": state.Cloud.Dnsmasq.Interface, } return nil }); err != nil { respondError(w, http.StatusInternalServerError, "Failed to save state") return } slog.Info("DNS settings updated", "ip", result["ip"]) respondJSON(w, http.StatusOK, result) } // --- DHCP Config --- func (api *API) GetDHCPConfig(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, api.loadState().Cloud.Dnsmasq.DHCP) } func (api *API) UpdateDHCPConfig(w http.ResponseWriter, r *http.Request) { var updates struct { Enabled *bool `json:"enabled,omitempty"` RangeStart string `json:"rangeStart,omitempty"` RangeEnd string `json:"rangeEnd,omitempty"` LeaseTime string `json:"leaseTime,omitempty"` Gateway string `json:"gateway,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { respondError(w, http.StatusBadRequest, "Invalid request body") return } var result any if err := api.modifyState(func(state *config.State) error { dhcp := &state.Cloud.Dnsmasq.DHCP if updates.Enabled != nil { dhcp.Enabled = *updates.Enabled } if updates.RangeStart != "" { dhcp.RangeStart = updates.RangeStart } if updates.RangeEnd != "" { dhcp.RangeEnd = updates.RangeEnd } if updates.LeaseTime != "" { dhcp.LeaseTime = updates.LeaseTime } if updates.Gateway != "" { dhcp.Gateway = updates.Gateway } result = state.Cloud.Dnsmasq.DHCP return nil }); err != nil { respondError(w, http.StatusInternalServerError, "Failed to save state") return } slog.Info("DHCP config updated") respondJSON(w, http.StatusOK, result) } // --- nftables Config --- func (api *API) GetNftablesConfig(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, api.loadState().Cloud.Nftables) } func (api *API) UpdateNftablesConfig(w http.ResponseWriter, r *http.Request) { var updates struct { Enabled *bool `json:"enabled,omitempty"` WANInterface string `json:"wanInterface,omitempty"` ExtraPorts []config.ExtraPort `json:"extraPorts,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { respondError(w, http.StatusBadRequest, "Invalid request body") return } var result any if err := api.modifyState(func(state *config.State) error { if updates.Enabled != nil { state.Cloud.Nftables.Enabled = updates.Enabled } if updates.WANInterface != "" { state.Cloud.Nftables.WANInterface = updates.WANInterface } if updates.ExtraPorts != nil { state.Cloud.Nftables.ExtraPorts = updates.ExtraPorts } result = state.Cloud.Nftables return nil }); err != nil { respondError(w, http.StatusInternalServerError, "Failed to save state") return } slog.Info("nftables config updated") respondJSON(w, http.StatusOK, result) } // --- HAProxy Custom Routes --- func (api *API) GetHAProxyRoutes(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]any{ "customRoutes": api.loadState().Cloud.HAProxy.CustomRoutes, }) } func (api *API) UpdateHAProxyRoutes(w http.ResponseWriter, r *http.Request) { var updates struct { CustomRoutes []config.HAProxyCustomRoute `json:"customRoutes"` } if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { respondError(w, http.StatusBadRequest, "Invalid request body") return } var result any if err := api.modifyState(func(state *config.State) error { state.Cloud.HAProxy.CustomRoutes = updates.CustomRoutes result = map[string]any{ "customRoutes": state.Cloud.HAProxy.CustomRoutes, } return nil }); err != nil { respondError(w, http.StatusInternalServerError, "Failed to save state") return } slog.Info("HAProxy custom routes updated", "count", len(updates.CustomRoutes)) respondJSON(w, http.StatusOK, result) }