feat: Extract Wild Central as standalone Go service
Extract the Central networking functionality from wild-cloud/api into a standalone service. Wild Central manages DNS (dnsmasq), gateway (HAProxy), firewall (nftables), VPN (WireGuard), TLS (certbot), security (CrowdSec), and DDNS — all the network appliance concerns. All Cloud-specific code (instances, clusters, nodes, apps, backups, operations, kubectl/talosctl tooling) has been removed. The API struct and route registration contain only Central endpoints. Tests updated to match the new API signature. Builds and all tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
498
internal/api/v1/handlers.go
Normal file
498
internal/api/v1/handlers.go
Normal file
@@ -0,0 +1,498 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
gocontext "context"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/wild-cloud/wild-central/internal/certbot"
|
||||
"github.com/wild-cloud/wild-central/internal/config"
|
||||
"github.com/wild-cloud/wild-central/internal/crowdsec"
|
||||
"github.com/wild-cloud/wild-central/internal/ddns"
|
||||
"github.com/wild-cloud/wild-central/internal/dnsmasq"
|
||||
"github.com/wild-cloud/wild-central/internal/haproxy"
|
||||
"github.com/wild-cloud/wild-central/internal/network"
|
||||
"github.com/wild-cloud/wild-central/internal/nftables"
|
||||
"github.com/wild-cloud/wild-central/internal/secrets"
|
||||
"github.com/wild-cloud/wild-central/internal/sse"
|
||||
"github.com/wild-cloud/wild-central/internal/storage"
|
||||
"github.com/wild-cloud/wild-central/internal/wireguard"
|
||||
)
|
||||
|
||||
// API holds all dependencies for Central API handlers
|
||||
type API struct {
|
||||
dataDir string
|
||||
version string
|
||||
allowedOrigins []string
|
||||
ctx gocontext.Context // parent context for restartable goroutines
|
||||
config *config.Manager
|
||||
secrets *secrets.Manager
|
||||
dnsmasq *dnsmasq.ConfigGenerator
|
||||
haproxy *haproxy.Manager
|
||||
nftables *nftables.Manager
|
||||
ddns *ddns.Runner
|
||||
crowdsec *crowdsec.Manager
|
||||
vpn *wireguard.Manager
|
||||
certbot *certbot.Manager
|
||||
sseManager *sse.Manager // SSE manager for real-time events
|
||||
}
|
||||
|
||||
// NewAPI creates a new Central API handler with all dependencies
|
||||
func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) {
|
||||
configMgr := config.NewManager()
|
||||
|
||||
if err := configMgr.EnsureGlobalConfig(dataDir); err != nil {
|
||||
return nil, fmt.Errorf("failed to ensure global config: %w", err)
|
||||
}
|
||||
|
||||
// Determine config paths from env or defaults
|
||||
dnsmasqConfigPath := envOrDefault("WILD_CENTRAL_DNSMASQ_CONFIG_PATH", "/etc/dnsmasq.d/wild-central.conf")
|
||||
haproxyConfigPath := envOrDefault("WILD_CENTRAL_HAPROXY_CONFIG_PATH", "/etc/haproxy/haproxy.cfg")
|
||||
nftablesRulesPath := envOrDefault("WILD_CENTRAL_NFTABLES_RULES_PATH", "/etc/nftables.d/wild-central.nft")
|
||||
vpnConfigPath := envOrDefault("WILD_CENTRAL_VPN_CONFIG_PATH", "/etc/wireguard/wg0.conf")
|
||||
|
||||
sseManager := sse.NewManager()
|
||||
|
||||
api := &API{
|
||||
dataDir: dataDir,
|
||||
version: version,
|
||||
allowedOrigins: allowedOrigins,
|
||||
config: configMgr,
|
||||
secrets: secrets.NewManager(),
|
||||
dnsmasq: dnsmasq.NewConfigGenerator(dnsmasqConfigPath),
|
||||
haproxy: haproxy.NewManager(haproxyConfigPath),
|
||||
nftables: nftables.NewManager(nftablesRulesPath),
|
||||
ddns: ddns.NewRunner(),
|
||||
crowdsec: crowdsec.NewManager(),
|
||||
vpn: wireguard.NewManager(dataDir, vpnConfigPath),
|
||||
certbot: certbot.NewManager(""),
|
||||
sseManager: sseManager,
|
||||
}
|
||||
|
||||
return api, nil
|
||||
}
|
||||
|
||||
func envOrDefault(key, defaultVal string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
slog.Info("using custom config path", "key", key, "path", v)
|
||||
return v
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
// StartDDNS loads DDNS config and starts the background goroutine.
|
||||
func (api *API) StartDDNS(ctx gocontext.Context) {
|
||||
api.ctx = ctx
|
||||
api.reloadDDNSIfEnabled()
|
||||
}
|
||||
|
||||
// reloadDDNSIfEnabled re-reads DDNS config and secrets and restarts the DDNS goroutine.
|
||||
func (api *API) reloadDDNSIfEnabled() {
|
||||
globalConfigPath := filepath.Join(api.dataDir, "config.yaml")
|
||||
globalCfg, err := config.LoadGlobalConfig(globalConfigPath)
|
||||
if err != nil || !globalCfg.Cloud.DDNS.Enabled {
|
||||
api.ddns.Stop()
|
||||
return
|
||||
}
|
||||
|
||||
apiToken := api.getCloudflareToken()
|
||||
|
||||
cfg := ddns.Config{
|
||||
Enabled: true,
|
||||
APIToken: apiToken,
|
||||
Records: globalCfg.Cloud.DDNS.Records,
|
||||
IntervalMinutes: globalCfg.Cloud.DDNS.IntervalMinutes,
|
||||
}
|
||||
|
||||
api.ddns.Start(api.ctx, cfg)
|
||||
}
|
||||
|
||||
// StartCentralStatusBroadcaster starts periodic broadcasting of central status
|
||||
func (api *API) StartCentralStatusBroadcaster(startTime time.Time) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
api.broadcastCentralStatusEvent(startTime)
|
||||
|
||||
for range ticker.C {
|
||||
api.broadcastCentralStatusEvent(startTime)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// RegisterRoutes registers all Central API routes
|
||||
func (api *API) RegisterRoutes(r *mux.Router) {
|
||||
r.Use(RequestLoggingMiddleware)
|
||||
|
||||
// Global Configuration
|
||||
r.HandleFunc("/api/v1/config", api.GetGlobalConfig).Methods("GET")
|
||||
r.HandleFunc("/api/v1/config", api.UpdateGlobalConfig).Methods("PUT")
|
||||
|
||||
// Global Secrets
|
||||
r.HandleFunc("/api/v1/secrets", api.GetGlobalSecrets).Methods("GET")
|
||||
r.HandleFunc("/api/v1/secrets", api.UpdateGlobalSecrets).Methods("PUT")
|
||||
|
||||
// Daemon control
|
||||
r.HandleFunc("/api/v1/daemon/restart", api.DaemonRestart).Methods("POST")
|
||||
|
||||
// Network detection
|
||||
r.HandleFunc("/api/v1/network/info", api.NetworkInfoHandler).Methods("GET")
|
||||
r.HandleFunc("/api/v1/network/resolve", api.NetworkResolveHandler).Methods("GET")
|
||||
|
||||
// dnsmasq management
|
||||
r.HandleFunc("/api/v1/dnsmasq/status", api.DnsmasqStatus).Methods("GET")
|
||||
r.HandleFunc("/api/v1/dnsmasq/config", api.DnsmasqGetConfig).Methods("GET")
|
||||
r.HandleFunc("/api/v1/dnsmasq/config", api.DnsmasqWriteConfig).Methods("PUT")
|
||||
r.HandleFunc("/api/v1/dnsmasq/restart", api.DnsmasqRestart).Methods("POST")
|
||||
r.HandleFunc("/api/v1/dnsmasq/generate", api.DnsmasqGenerate).Methods("POST")
|
||||
r.HandleFunc("/api/v1/dnsmasq/dhcp/leases", api.DnsmasqDHCPLeases).Methods("GET")
|
||||
r.HandleFunc("/api/v1/dnsmasq/dhcp/static", api.DnsmasqDHCPAddStatic).Methods("POST")
|
||||
r.HandleFunc("/api/v1/dnsmasq/dhcp/static/{mac}", api.DnsmasqDHCPDeleteStatic).Methods("DELETE")
|
||||
|
||||
// HAProxy ingress management
|
||||
r.HandleFunc("/api/v1/haproxy/status", api.HaproxyStatus).Methods("GET")
|
||||
r.HandleFunc("/api/v1/haproxy/config", api.HaproxyGetConfig).Methods("GET")
|
||||
r.HandleFunc("/api/v1/haproxy/generate", api.HaproxyGenerate).Methods("POST")
|
||||
r.HandleFunc("/api/v1/haproxy/restart", api.HaproxyRestart).Methods("POST")
|
||||
r.HandleFunc("/api/v1/haproxy/stats", api.HaproxyStats).Methods("GET")
|
||||
|
||||
// nftables firewall management
|
||||
r.HandleFunc("/api/v1/nftables/status", api.NftablesStatus).Methods("GET")
|
||||
r.HandleFunc("/api/v1/nftables/apply", api.NftablesApply).Methods("POST")
|
||||
r.HandleFunc("/api/v1/nftables/interfaces", api.NftablesGetInterfaces).Methods("GET")
|
||||
|
||||
// WireGuard VPN management
|
||||
r.HandleFunc("/api/v1/vpn/status", api.VpnStatus).Methods("GET")
|
||||
r.HandleFunc("/api/v1/vpn/config", api.VpnGetConfig).Methods("GET")
|
||||
r.HandleFunc("/api/v1/vpn/config", api.VpnUpdateConfig).Methods("PUT")
|
||||
r.HandleFunc("/api/v1/vpn/keygen", api.VpnGenerateKeypair).Methods("POST")
|
||||
r.HandleFunc("/api/v1/vpn/apply", api.VpnApply).Methods("POST")
|
||||
r.HandleFunc("/api/v1/vpn/peers", api.VpnListPeers).Methods("GET")
|
||||
r.HandleFunc("/api/v1/vpn/peers", api.VpnAddPeer).Methods("POST")
|
||||
r.HandleFunc("/api/v1/vpn/peers/{id}/config", api.VpnGetPeerConfig).Methods("GET")
|
||||
r.HandleFunc("/api/v1/vpn/peers/{id}", api.VpnDeletePeer).Methods("DELETE")
|
||||
|
||||
// TLS certificate management
|
||||
r.HandleFunc("/api/v1/cert/status", api.CertStatus).Methods("GET")
|
||||
r.HandleFunc("/api/v1/cert/provision", api.CertProvision).Methods("POST")
|
||||
r.HandleFunc("/api/v1/cert/renew", api.CertRenew).Methods("POST")
|
||||
|
||||
// DDNS management
|
||||
r.HandleFunc("/api/v1/ddns/status", api.DDNSStatus).Methods("GET")
|
||||
r.HandleFunc("/api/v1/ddns/trigger", api.DDNSTrigger).Methods("POST")
|
||||
|
||||
// CrowdSec LAPI management
|
||||
r.HandleFunc("/api/v1/crowdsec/status", api.CrowdSecStatus).Methods("GET")
|
||||
r.HandleFunc("/api/v1/crowdsec/summary", api.CrowdSecGetSummary).Methods("GET")
|
||||
r.HandleFunc("/api/v1/crowdsec/alerts", api.CrowdSecGetAlerts).Methods("GET")
|
||||
r.HandleFunc("/api/v1/crowdsec/decisions", api.CrowdSecGetDecisions).Methods("GET")
|
||||
r.HandleFunc("/api/v1/crowdsec/decisions", api.CrowdSecAddDecision).Methods("POST")
|
||||
r.HandleFunc("/api/v1/crowdsec/decisions/{id}", api.CrowdSecDeleteDecision).Methods("DELETE")
|
||||
r.HandleFunc("/api/v1/crowdsec/decisions/ip/{ip}", api.CrowdSecDeleteDecisionByIP).Methods("DELETE")
|
||||
r.HandleFunc("/api/v1/crowdsec/machines", api.CrowdSecGetMachines).Methods("GET")
|
||||
r.HandleFunc("/api/v1/crowdsec/machines/{name}", api.CrowdSecDeleteMachine).Methods("DELETE")
|
||||
r.HandleFunc("/api/v1/crowdsec/bouncers", api.CrowdSecGetBouncers).Methods("GET")
|
||||
r.HandleFunc("/api/v1/crowdsec/bouncers/{name}", api.CrowdSecDeleteBouncer).Methods("DELETE")
|
||||
|
||||
// SSE events
|
||||
r.HandleFunc("/api/v1/events", api.GlobalEventStream).Methods("GET")
|
||||
}
|
||||
|
||||
// --- Global Config/Secrets handlers ---
|
||||
|
||||
// GetGlobalConfig returns the global config
|
||||
func (api *API) GetGlobalConfig(w http.ResponseWriter, r *http.Request) {
|
||||
configPath := filepath.Join(api.dataDir, "config.yaml")
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to read config")
|
||||
return
|
||||
}
|
||||
|
||||
var configMap map[string]any
|
||||
if err := yaml.Unmarshal(data, &configMap); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to parse config")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, configMap)
|
||||
}
|
||||
|
||||
// UpdateGlobalConfig updates the global config
|
||||
func (api *API) UpdateGlobalConfig(w http.ResponseWriter, r *http.Request) {
|
||||
configPath := filepath.Join(api.dataDir, "config.yaml")
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Failed to read request body")
|
||||
return
|
||||
}
|
||||
|
||||
var updates map[string]any
|
||||
if err := yaml.Unmarshal(body, &updates); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid YAML format")
|
||||
return
|
||||
}
|
||||
|
||||
// Read existing config
|
||||
existingContent, err := storage.ReadFile(configPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to read existing config")
|
||||
return
|
||||
}
|
||||
|
||||
var existingConfig map[string]any
|
||||
if len(existingContent) > 0 {
|
||||
if err := yaml.Unmarshal(existingContent, &existingConfig); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Failed to parse existing config")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
existingConfig = make(map[string]any)
|
||||
}
|
||||
|
||||
for key, value := range updates {
|
||||
existingConfig[key] = value
|
||||
}
|
||||
|
||||
yamlContent, err := yaml.Marshal(existingConfig)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to marshal YAML")
|
||||
return
|
||||
}
|
||||
|
||||
lockPath := configPath + ".lock"
|
||||
if err := storage.WithLock(lockPath, func() error {
|
||||
return storage.WriteFile(configPath, yamlContent, 0644)
|
||||
}); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to write config")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("global config updated")
|
||||
|
||||
// Reload DDNS if config changed
|
||||
go api.reloadDDNSIfEnabled()
|
||||
|
||||
respondMessage(w, http.StatusOK, "Config updated successfully")
|
||||
}
|
||||
|
||||
// GetGlobalSecrets returns the global secrets (redacted by default)
|
||||
func (api *API) GetGlobalSecrets(w http.ResponseWriter, r *http.Request) {
|
||||
secretsPath := filepath.Join(api.dataDir, "secrets.yaml")
|
||||
data, err := os.ReadFile(secretsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
respondJSON(w, http.StatusOK, map[string]any{})
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "Failed to read secrets")
|
||||
return
|
||||
}
|
||||
|
||||
var secretsMap map[string]any
|
||||
if err := yaml.Unmarshal(data, &secretsMap); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to parse secrets")
|
||||
return
|
||||
}
|
||||
if secretsMap == nil {
|
||||
secretsMap = map[string]any{}
|
||||
}
|
||||
|
||||
showRaw := r.URL.Query().Get("raw") == "true"
|
||||
if !showRaw {
|
||||
for key := range secretsMap {
|
||||
secretsMap[key] = "********"
|
||||
}
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, secretsMap)
|
||||
}
|
||||
|
||||
// UpdateGlobalSecrets updates the global secrets
|
||||
func (api *API) UpdateGlobalSecrets(w http.ResponseWriter, r *http.Request) {
|
||||
secretsPath := filepath.Join(api.dataDir, "secrets.yaml")
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Failed to read request body")
|
||||
return
|
||||
}
|
||||
|
||||
var updates map[string]any
|
||||
if err := yaml.Unmarshal(body, &updates); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid YAML format")
|
||||
return
|
||||
}
|
||||
|
||||
existingContent, err := storage.ReadFile(secretsPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to read existing secrets")
|
||||
return
|
||||
}
|
||||
|
||||
var existing map[string]any
|
||||
if len(existingContent) > 0 {
|
||||
if err := yaml.Unmarshal(existingContent, &existing); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Failed to parse existing secrets")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
existing = make(map[string]any)
|
||||
}
|
||||
|
||||
for key, value := range updates {
|
||||
existing[key] = value
|
||||
}
|
||||
|
||||
yamlContent, err := yaml.Marshal(existing)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to marshal YAML")
|
||||
return
|
||||
}
|
||||
|
||||
lockPath := secretsPath + ".lock"
|
||||
if err := storage.WithLock(lockPath, func() error {
|
||||
return storage.WriteFile(secretsPath, yamlContent, 0600)
|
||||
}); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to write secrets")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("global secrets updated")
|
||||
|
||||
// Reload DDNS if secrets changed (may contain API tokens)
|
||||
go api.reloadDDNSIfEnabled()
|
||||
|
||||
respondMessage(w, http.StatusOK, "Secrets updated successfully")
|
||||
}
|
||||
|
||||
// --- Utility handlers ---
|
||||
|
||||
// StatusHandler returns daemon status information
|
||||
func (api *API) StatusHandler(w http.ResponseWriter, r *http.Request, startTime time.Time, dataDir string) {
|
||||
uptime := time.Since(startTime)
|
||||
|
||||
pendingRestart := false
|
||||
if info, err := os.Stat(filepath.Join(dataDir, "config.yaml")); err == nil {
|
||||
pendingRestart = info.ModTime().After(startTime)
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "running",
|
||||
"version": api.version,
|
||||
"uptime": uptime.String(),
|
||||
"uptimeSeconds": int(uptime.Seconds()),
|
||||
"dataDir": dataDir,
|
||||
"pendingRestart": pendingRestart,
|
||||
})
|
||||
}
|
||||
|
||||
// DaemonRestart triggers a graceful self-restart via SIGTERM
|
||||
func (api *API) DaemonRestart(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusAccepted, map[string]any{"message": "Restarting Wild Central..."})
|
||||
|
||||
go func() {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if err := syscall.Kill(syscall.Getpid(), syscall.SIGTERM); err != nil {
|
||||
slog.Error("failed to send SIGTERM to self", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// NetworkInfoHandler returns detected network configuration
|
||||
func (api *API) NetworkInfoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
info, err := network.DetectNetworkInfo()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to detect network info: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, info)
|
||||
}
|
||||
|
||||
// NetworkResolveHandler resolves a domain to an IP
|
||||
func (api *API) NetworkResolveHandler(w http.ResponseWriter, r *http.Request) {
|
||||
domain := r.URL.Query().Get("domain")
|
||||
if domain == "" {
|
||||
respondError(w, http.StatusBadRequest, "domain parameter is required")
|
||||
return
|
||||
}
|
||||
|
||||
ip, err := network.ResolveDomain(domain)
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true,
|
||||
"ip": ip,
|
||||
})
|
||||
}
|
||||
|
||||
// getCloudflareToken reads the Cloudflare API token from global secrets
|
||||
func (api *API) getCloudflareToken() string {
|
||||
secretsPath := filepath.Join(api.dataDir, "secrets.yaml")
|
||||
data, err := os.ReadFile(secretsPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var secretsMap map[string]any
|
||||
if err := yaml.Unmarshal(data, &secretsMap); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
cloudflare, ok := secretsMap["cloudflare"].(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
token, _ := cloudflare["apiToken"].(string)
|
||||
return token
|
||||
}
|
||||
|
||||
// getCloudflareZoneID reads the Cloudflare Zone ID from global secrets
|
||||
func (api *API) getCloudflareZoneID() string {
|
||||
secretsPath := filepath.Join(api.dataDir, "secrets.yaml")
|
||||
data, err := os.ReadFile(secretsPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var secretsMap map[string]any
|
||||
if err := yaml.Unmarshal(data, &secretsMap); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
cloudflare, ok := secretsMap["cloudflare"].(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
zoneID, _ := cloudflare["zoneId"].(string)
|
||||
return zoneID
|
||||
}
|
||||
|
||||
// respondAccepted writes a 202 Accepted response for async operations
|
||||
func respondAccepted(w http.ResponseWriter, operationID, message string) {
|
||||
respondJSON(w, http.StatusAccepted, map[string]string{
|
||||
"operation_id": operationID,
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user