package server import ( "bufio" "errors" "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" "time" "github.com/hachem-wtf/benson/internal/config" ) const ( stopTimeout = 60 * time.Second pingTimeout = 2 * time.Second ) type Status struct { Running bool PID int Reachable bool Version string MOTD string Players int MaxPlayers int Address string } func pidPath(cfg *config.Config) string { return filepath.Join(cfg.ServerLocation, "benson-server.pid") } func LogPath(cfg *config.Config) string { return filepath.Join(cfg.ServerLocation, "benson-server.log") } func javaCmd(cfg *config.Config) string { if cfg.JavaPath != "" { return cfg.JavaPath } return "java" } func readPID(cfg *config.Config) (int, bool) { data, err := os.ReadFile(pidPath(cfg)) if err != nil { return 0, false } pid, err := strconv.Atoi(strings.TrimSpace(string(data))) if err != nil { return 0, false } return pid, true } func Running(cfg *config.Config) (int, bool) { pid, ok := readPID(cfg) if !ok { return 0, false } if !isAlive(pid) { os.Remove(pidPath(cfg)) return 0, false } return pid, true } func Start(cfg *config.Config) (int, error) { if cfg.ServerLocation == "" { return 0, errors.New("server_location must be set in config") } if pid, ok := Running(cfg); ok { return 0, fmt.Errorf("server already running (pid %d)", pid) } jar := filepath.Join(cfg.ServerLocation, "server.jar") if _, err := os.Stat(jar); err != nil { return 0, errors.New("no server.jar in server_location — run generate and merge first") } eula := filepath.Join(cfg.ServerLocation, "eula.txt") if _, err := os.Stat(eula); os.IsNotExist(err) { if err := os.WriteFile(eula, []byte("eula=true\n"), 0o644); err != nil { return 0, err } } logFile, err := os.Create(LogPath(cfg)) if err != nil { return 0, err } defer logFile.Close() cmd := exec.Command(javaCmd(cfg), "-jar", "server.jar", "nogui") cmd.Dir = cfg.ServerLocation cmd.Stdout = logFile cmd.Stderr = logFile cmd.SysProcAttr = detachAttr() if err := cmd.Start(); err != nil { return 0, err } pid := cmd.Process.Pid if err := os.WriteFile(pidPath(cfg), []byte(strconv.Itoa(pid)+"\n"), 0o644); err != nil { return pid, err } go cmd.Wait() return pid, nil } func Stop(cfg *config.Config, progress func(string)) error { pid, ok := Running(cfg) if !ok { return errors.New("server is not running") } progress(fmt.Sprintf("Sending stop signal to pid %d…", pid)) if err := signalStop(pid); err != nil { return err } progress("Waiting for the server to save and shut down…") deadline := time.Now().Add(stopTimeout) for time.Now().Before(deadline) { if !isAlive(pid) { os.Remove(pidPath(cfg)) return nil } time.Sleep(500 * time.Millisecond) } progress("Timed out — forcing shutdown…") forceKill(pid) os.Remove(pidPath(cfg)) return nil } func Restart(cfg *config.Config, progress func(string)) error { if _, ok := Running(cfg); ok { if err := Stop(cfg, progress); err != nil { return err } time.Sleep(1 * time.Second) } pid, err := Start(cfg) if err != nil { return err } progress(fmt.Sprintf("Server started (pid %d).", pid)) return nil } func GetStatus(cfg *config.Config) Status { pid, running := Running(cfg) port := serverPort(cfg) s := Status{ Running: running, PID: pid, Address: fmt.Sprintf("localhost:%d", port), } if ping, err := pingServer("localhost", port, pingTimeout); err == nil { s.Reachable = true s.Version = ping.Version.Name s.Players = ping.Players.Online s.MaxPlayers = ping.Players.Max s.MOTD = ping.motdText() } return s } func serverPort(cfg *config.Config) int { f, err := os.Open(filepath.Join(cfg.ServerLocation, "server.properties")) if err != nil { return 25565 } defer f.Close() scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if value, ok := strings.CutPrefix(line, "server-port="); ok { if port, err := strconv.Atoi(strings.TrimSpace(value)); err == nil { return port } } } return 25565 }