package main import ( "embed" "encoding/json" "fmt" "io" "io/fs" "net/http" "os" "os/signal" "path/filepath" "slices" "strconv" "strings" "sync" "syscall" "time" ) //go:embed web/dist var webFS embed.FS type CommandPayload struct { Command string `json:"command"` } type InstanceStatusResponse struct { Name string `json:"name"` Version string `json:"version"` Port int `json:"port"` Status string `json:"status"` } type DaemonServer struct { sync.RWMutex cfg *AppConfig configPath string procManager *ProcessManager } func StartDaemon(cfg *AppConfig, configPath string) error { ds := &DaemonServer{ cfg: cfg, configPath: configPath, procManager: NewProcessManager(), } mux := http.NewServeMux() mux.HandleFunc("/instances/create", ds.handleCreate) mux.HandleFunc("/instances/start", ds.handleStart) mux.HandleFunc("/instances/stop", ds.handleStop) mux.HandleFunc("/instances/command", ds.handleCommand) mux.HandleFunc("/instances/list", ds.handleList) mux.HandleFunc("/instances/logs", ds.handleGetLogs) mux.HandleFunc("/instances/delete", ds.handleDelete) mux.HandleFunc("/instances/modify", ds.handleModify) mux.HandleFunc("/instances/getConfig", ds.handleGetConfig) mux.HandleFunc("/instances/setConfig", ds.handleSetConfig) webContent, err := fs.Sub(webFS, "web/dist") if err != nil { return fmt.Errorf("failed to load embedded web assets: %w", err) } mux.Handle("/", http.FileServer(http.FS(webContent))) corsWrappedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return } authCheck := func() bool { if !strings.HasPrefix(r.URL.Path, "/instances/") { return true } ds.RLock() defer ds.RUnlock() if ds.cfg.Daemon.AuthToken != "" { authHeader := r.Header.Get("Authorization") expected := "Bearer " + ds.cfg.Daemon.AuthToken if authHeader != expected { http.Error(w, "Unauthorized", http.StatusUnauthorized) return false } } return true }() if !authCheck { return } mux.ServeHTTP(w, r) }) listenAddress := cfg.Daemon.ListenAddress + ":" + strconv.Itoa(cfg.Daemon.Port) server := &http.Server{ Addr: listenAddress, Handler: corsWrappedHandler, } sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) go func() { fmt.Printf("Engine daemon actively listening on http://%s\n", listenAddress) if err := server.ListenAndServe(); err != http.ErrServerClosed { fmt.Printf("Daemon runtime failure: %v\n", err) } }() <-sigChan fmt.Println("\n[Daemon] Shutdown signal caught! Initializing graceful teardown sequence...") _ = server.Close() ds.shutdownAllRunningServers() fmt.Println("[Daemon] All threads gracefully shut down. Exiting supervisor cleanly.") return nil } func (ds *DaemonServer) reloadConfig() error { cfg, err := LoadOrCreateConfig(ds.configPath) if err != nil { fmt.Printf("Could not reload configuration file: %v\n", err) return err } ds.Lock() defer ds.Unlock() ds.cfg = cfg return nil } func (ds *DaemonServer) shutdownAllRunningServers() { ds.procManager.Lock() var activeNames []string for name := range ds.procManager.ActiveInstances { activeNames = append(activeNames, name) } ds.procManager.Unlock() if len(activeNames) == 0 { fmt.Println("[Daemon] No active server instances to tear down.") return } fmt.Printf("[Daemon] Flushing stop instructions to %d running instance(s)...\n", len(activeNames)) var wg sync.WaitGroup for _, name := range activeNames { wg.Add(1) go func(instanceName string) { defer wg.Done() fmt.Printf("[Daemon] Sending graceful /stop to instance '%s'...\n", instanceName) err := ds.procManager.SendCommand(instanceName, "/stop") if err != nil { fmt.Printf("[Daemon Error] Could not send stop to %s: %v\n", instanceName, err) return } ticker := time.NewTicker(250 * time.Millisecond) defer ticker.Stop() timeout := time.After(15 * time.Second) for { select { case <-ticker.C: ds.procManager.RLock() _, running := ds.procManager.ActiveInstances[instanceName] ds.procManager.RUnlock() if !running { fmt.Printf("[Daemon] Instance '%s' has successfully exited.\n", instanceName) return } case <-timeout: fmt.Printf("[Daemon Warning] Instance '%s' timed out while trying to stop safely.\n", instanceName) return } } }(name) } wg.Wait() } func (ds *DaemonServer) handleCreate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } name := r.URL.Query().Get("name") version := r.URL.Query().Get("version") port := r.URL.Query().Get("port") if name == "" || version == "" || port == "" { http.Error(w, "Missing name, version, or port parameters", http.StatusBadRequest) return } converted_port, err := strconv.Atoi(port) if err != nil { http.Error(w, "Could not convert provided port to a valid port number", http.StatusBadRequest) return } if ds.reloadConfig() != nil { http.Error(w, "Could not reload config", http.StatusInternalServerError) return } ds.Lock() defer ds.Unlock() if _, exists := ds.cfg.Instances[name]; exists { http.Error(w, fmt.Sprintf("Instance '%s' already exists in configuration", name), http.StatusConflict) return } for name, options := range ds.cfg.Instances { if options.Port == converted_port { http.Error(w, fmt.Sprintf("Port already occupied by instance '%s'", name), http.StatusConflict) return } } metadata := InstanceMetadata{ Version: version, ServerName: name, Port: converted_port, } err = DownloadAndExtractServer(version, ds.cfg.Storage.InstallDir) if err != nil { http.Error(w, fmt.Sprintf("Installation failed: %v", err), http.StatusInternalServerError) return } err = CreateNewInstance(name, version, metadata, ds.cfg) if err != nil { http.Error(w, fmt.Sprintf("Instance provisioning failed: %v", err), http.StatusInternalServerError) return } ds.cfg.Instances[name] = metadata data, err := json.MarshalIndent(ds.cfg, "", " ") if err != nil { http.Error(w, fmt.Sprintf("Failed processing profile adjustments: %v", err), http.StatusInternalServerError) return } if err := os.WriteFile(ds.configPath, data, 0644); err != nil { http.Error(w, fmt.Sprintf("Failed saving configuration adjustments to disk: %v", err), http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) fmt.Fprintf(w, "Successfully created and stored profile for instance %s", name) } func (ds *DaemonServer) handleStart(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } name := r.URL.Query().Get("name") if name == "" { http.Error(w, "Missing name parameter", http.StatusBadRequest) return } if ds.reloadConfig() != nil { http.Error(w, "Could not reload config", http.StatusInternalServerError) return } ds.RLock() defer ds.RUnlock() options, exists := ds.cfg.Instances[name] if !exists { http.Error(w, fmt.Sprintf("Instance '%s' does not exist. Run 'create' first", name), http.StatusNotFound) return } instanceConfigPath := filepath.Join(ds.cfg.Storage.InstancesDir, name, "serverconfig.json") err := SyncInstanceConfig(options.Version, instanceConfigPath, options, ds.cfg) if err != nil { http.Error(w, "Failed to sync config: "+err.Error(), http.StatusInternalServerError) return } err = ds.procManager.StartInstance(name, options.Version, options, ds.cfg) if err != nil { http.Error(w, fmt.Sprintf("Process startup failed: %v", err), http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) fmt.Fprintf(w, "Successfully started instance %s", name) } func (ds *DaemonServer) handleStop(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } name := r.URL.Query().Get("name") if name == "" { http.Error(w, "Missing name parameter", http.StatusBadRequest) return } err := ds.procManager.SendCommand(name, "/stop") if err != nil { http.Error(w, fmt.Sprintf("Failed to dispatch stop command: %v", err), http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) fmt.Fprintf(w, "Termination signal routed to instance %s", name) } func (ds *DaemonServer) handleCommand(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } name := r.URL.Query().Get("name") if name == "" { http.Error(w, "Missing name parameter", http.StatusBadRequest) return } var payload CommandPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, "Malformed JSON body", http.StatusBadRequest) return } err := ds.procManager.SendCommand(name, payload.Command) if err != nil { http.Error(w, fmt.Sprintf("Command delivery failed: %v", err), http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) fmt.Fprintf(w, "Command delivered successfully to %s", name) } func (ds *DaemonServer) handleList(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if ds.reloadConfig() != nil { http.Error(w, "Could not reload config", http.StatusInternalServerError) return } ds.RLock() defer ds.RUnlock() ds.procManager.RLock() defer ds.procManager.RUnlock() var responseList []InstanceStatusResponse for name, options := range ds.cfg.Instances { status := "STOPPED" if _, running := ds.procManager.ActiveInstances[name]; running { status = "RUNNING" } responseList = append(responseList, InstanceStatusResponse{ Name: name, Version: options.Version, Port: options.Port, Status: status, }) } slices.SortFunc(responseList, func(a, b InstanceStatusResponse) int { if a.Name < b.Name { return -1 } if a.Name > b.Name { return 1 } return 0 }) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(responseList) } func (ds *DaemonServer) handleGetLogs(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } name := r.URL.Query().Get("name") if name == "" { http.Error(w, "Missing name parameter", http.StatusBadRequest) return } ds.procManager.RLock() buf, exists := ds.procManager.LogBuffers[name] ds.procManager.RUnlock() if !exists || buf == nil { w.Header().Set("Content-Type", "application/json") w.Write([]byte("[]")) return } logLines := buf.GetSnapshot() w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(logLines); err != nil { http.Error(w, fmt.Sprintf("Failed encoding log matrix: %v", err), http.StatusInternalServerError) } } func (ds *DaemonServer) handleDelete(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } name := r.URL.Query().Get("name") if name == "" { http.Error(w, "Missing name parameter", http.StatusBadRequest) return } purge := false purgeParam := r.URL.Query().Get("purge") if purgeParam != "" { result, err := strconv.ParseBool(purgeParam) if err != nil { http.Error(w, "Invalid value for 'purge' parameter", http.StatusBadRequest) return } purge = result } if ds.reloadConfig() != nil { http.Error(w, "Could not reload config", http.StatusInternalServerError) return } ds.Lock() defer ds.Unlock() ds.procManager.RLock() defer ds.procManager.RUnlock() _, exists := ds.cfg.Instances[name] if !exists { http.Error(w, fmt.Sprintf("Cannot delete instance '%s'; Does not exist.", name), http.StatusBadRequest) return } if _, running := ds.procManager.ActiveInstances[name]; running { http.Error(w, fmt.Sprintf("Cannot delete instance '%s'; It is currently running.", name), http.StatusBadRequest) return } delete(ds.cfg.Instances, name) data, err := json.MarshalIndent(ds.cfg, "", " ") if err != nil { http.Error(w, fmt.Sprintf("Failed processing profile adjustments: %v", err), http.StatusInternalServerError) return } if err := os.WriteFile(ds.configPath, data, 0644); err != nil { http.Error(w, fmt.Sprintf("Failed saving configuration adjustments to disk: %v", err), http.StatusInternalServerError) return } if purge { path := filepath.Join(ds.cfg.Storage.InstancesDir, name) err := os.RemoveAll(path) if err != nil { http.Error(w, fmt.Sprintf("Failed to delete instance from disk: %v", err), http.StatusInternalServerError) return } } w.WriteHeader(http.StatusOK) fmt.Fprintf(w, "Successfully deleted instance %s", name) } func (ds *DaemonServer) handleModify(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } name := r.URL.Query().Get("name") if name == "" { http.Error(w, "Missing name parameter", http.StatusBadRequest) return } newName := r.URL.Query().Get("newName") if newName == "" { http.Error(w, "Missing newName parameter", http.StatusBadRequest) return } newPort := r.URL.Query().Get("newPort") if newPort == "" { http.Error(w, "Missing newPort parameter", http.StatusBadRequest) return } convertedPort, err := strconv.Atoi(newPort) if err != nil { http.Error(w, "Could not convert provided port to a valid port number", http.StatusBadRequest) return } ds.Lock() defer ds.Unlock() ds.procManager.RLock() defer ds.procManager.RUnlock() instance, exists := ds.cfg.Instances[name] if !exists { http.Error(w, fmt.Sprintf("Cannot modify instance '%s'; Does not exist.", name), http.StatusBadRequest) return } if _, running := ds.procManager.ActiveInstances[name]; running { http.Error(w, fmt.Sprintf("Cannot modify instance '%s'; It is currently running.", name), http.StatusBadRequest) return } instance.ServerName = newName instance.Port = convertedPort ds.cfg.Instances[name] = instance data, err := json.MarshalIndent(ds.cfg, "", " ") if err != nil { http.Error(w, fmt.Sprintf("Failed processing profile adjustments: %v", err), http.StatusInternalServerError) return } if err := os.WriteFile(ds.configPath, data, 0644); err != nil { http.Error(w, fmt.Sprintf("Failed saving configuration adjustments to disk: %v", err), http.StatusInternalServerError) return } instanceConfigPath := filepath.Join(ds.cfg.Storage.InstancesDir, name, "serverconfig.json") err = SyncInstanceConfig(instance.Version, instanceConfigPath, instance, ds.cfg) if err != nil { http.Error(w, "Failed to sync config: "+err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) fmt.Fprintf(w, "Successfully modified instance %s", name) } func (ds *DaemonServer) handleGetConfig(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } name := r.URL.Query().Get("name") if name == "" { http.Error(w, "Missing name parameter", http.StatusBadRequest) return } exists := func() bool { ds.RLock() defer ds.RUnlock() _, ok := ds.cfg.Instances[name] return ok }() if !exists { http.Error(w, fmt.Sprintf("Cannot modify instance config for: '%s'; Does not exist.", name), http.StatusBadRequest) return } confPath := filepath.Join(ds.cfg.Storage.InstancesDir, name, "serverconfig.json") data, err := os.ReadFile(confPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to get instance config: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Write(data) } func (ds *DaemonServer) handleSetConfig(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } name := r.URL.Query().Get("name") if name == "" { http.Error(w, "Missing name parameter", http.StatusBadRequest) return } exists := func() bool { ds.RLock() defer ds.RUnlock() _, ok := ds.cfg.Instances[name] return ok }() if !exists { http.Error(w, fmt.Sprintf("Cannot modify instance config for: '%s'; Does not exist.", name), http.StatusBadRequest) return } running := func() bool { ds.procManager.RLock() defer ds.procManager.RUnlock() _, ok := ds.procManager.ActiveInstances[name] return ok }() if running { http.Error(w, fmt.Sprintf("Cannot modify instance config for: '%s'; It is currently running.", name), http.StatusBadRequest) return } data, err := io.ReadAll(r.Body) if err != nil { http.Error(w, fmt.Sprintf("Failed to set instance config: %v", err), http.StatusInternalServerError) return } confPath := filepath.Join(ds.cfg.Storage.InstancesDir, name, "serverconfig.json") if err = os.WriteFile(confPath, data, 0644); err != nil { http.Error(w, fmt.Sprintf("Failed to get instance config: %v", err), http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) }