diff options
| author | hachem <im@hachem.wtf> | 2026-08-29 19:15:28 +0200 |
|---|---|---|
| committer | hachem <im@hachem.wtf> | 2026-08-29 19:15:28 +0200 |
| commit | 3eba8fbd243c0ffcb1c396526a4e19d1abc1eaf9 (patch) | |
| tree | 31613c6da3a3da3c8777d094f5e1466804dbf3a9 | |
| parent | fcbbeccaa80c7e120933682b330272a1490c1cf5 (diff) | |
add: control flow
| -rw-r--r-- | internal/cli/commands.go | 20 | ||||
| -rw-r--r-- | internal/cli/server.go | 84 | ||||
| -rw-r--r-- | internal/server/ping.go | 134 | ||||
| -rw-r--r-- | internal/server/server.go | 196 | ||||
| -rw-r--r-- | internal/server/sysproc_unix.go | 36 | ||||
| -rw-r--r-- | internal/server/sysproc_windows.go | 33 |
6 files changed, 503 insertions, 0 deletions
diff --git a/internal/cli/commands.go b/internal/cli/commands.go index d76402d..437fff2 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -17,6 +17,26 @@ func defaultCommands() []command { run: runMods, }, { + name: "start", + desc: "Start the server in the background", + run: runStart, + }, + { + name: "stop", + desc: "Stop the running server", + run: runStop, + }, + { + name: "restart", + desc: "Restart the server", + run: runRestart, + }, + { + name: "status", + desc: "Show whether the server is running, players, and version", + run: runStatus, + }, + { name: "check", desc: "Check whether the configured mods are compatible", run: runCheck, diff --git a/internal/cli/server.go b/internal/cli/server.go new file mode 100644 index 0000000..9ed0fea --- /dev/null +++ b/internal/cli/server.go @@ -0,0 +1,84 @@ +package cli + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/hachem-wtf/benson/internal/config" + "github.com/hachem-wtf/benson/internal/server" +) + +func runStart(m *model) tea.Cmd { + pid, err := server.Start(m.cfg) + if err != nil { + return tea.Println(errorStyle.Render(err.Error())) + } + return tea.Println(successStyle.Render("Server started ") + + mutedStyle.Render(fmt.Sprintf("(pid %d) · logs: %s", pid, server.LogPath(m.cfg)))) +} + +func runStop(m *model) tea.Cmd { + return m.runServerJob(func(cfg *config.Config, emit func(string)) tea.Msg { + if err := server.Stop(cfg, emit); err != nil { + return streamDoneMsg{errorStyle.Render(err.Error())} + } + return streamDoneMsg{successStyle.Render("Server stopped.")} + }) +} + +func runRestart(m *model) tea.Cmd { + return m.runServerJob(func(cfg *config.Config, emit func(string)) tea.Msg { + if err := server.Restart(cfg, emit); err != nil { + return streamDoneMsg{errorStyle.Render(err.Error())} + } + return streamDoneMsg{successStyle.Render("Server restarted.")} + }) +} + +func runStatus(m *model) tea.Cmd { + m.busy = true + cfg := m.cfg + return tea.Batch(m.spin.Tick, func() tea.Msg { + return outputMsg{text: renderStatus(server.GetStatus(cfg))} + }) +} + +func (m *model) runServerJob(job func(cfg *config.Config, emit func(string)) tea.Msg) tea.Cmd { + m.busy = true + ch := make(chan tea.Msg, 64) + m.events = ch + cfg := m.cfg + go func() { + emit := func(line string) { ch <- streamMsg{" " + mutedStyle.Render(line)} } + ch <- job(cfg, emit) + }() + return tea.Batch(m.spin.Tick, waitForEvent(ch)) +} + +func renderStatus(s server.Status) string { + rows := []string{headingStyle.Render("Server status"), ""} + + if s.Running { + rows = append(rows, " "+successStyle.Render("● running")+mutedStyle.Render(fmt.Sprintf(" (pid %d)", s.PID))) + } else { + rows = append(rows, " "+errorStyle.Render("● stopped")) + } + + rows = append(rows, " "+mutedStyle.Render("address ")+s.Address) + + if s.Reachable { + rows = append(rows, + " "+mutedStyle.Render("version ")+s.Version, + " "+mutedStyle.Render("players ")+fmt.Sprintf("%d / %d", s.Players, s.MaxPlayers), + ) + if s.MOTD != "" { + rows = append(rows, " "+mutedStyle.Render("motd ")+s.MOTD) + } + } else if s.Running { + rows = append(rows, " "+warnStyle.Render("not yet accepting connections (still loading?)")) + } + + return lipgloss.JoinVertical(lipgloss.Left, rows...) +} diff --git a/internal/server/ping.go b/internal/server/ping.go new file mode 100644 index 0000000..4783ec7 --- /dev/null +++ b/internal/server/ping.go @@ -0,0 +1,134 @@ +package server + +import ( + "bufio" + "bytes" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "time" +) + +type pingResponse struct { + Version struct { + Name string `json:"name"` + } `json:"version"` + Players struct { + Online int `json:"online"` + Max int `json:"max"` + } `json:"players"` + Description json.RawMessage `json:"description"` +} + +func (p pingResponse) motdText() string { + var text string + if json.Unmarshal(p.Description, &text) == nil { + return text + } + + var obj struct { + Text string `json:"text"` + } + if json.Unmarshal(p.Description, &obj) == nil { + return obj.Text + } + return "" +} + +func pingServer(host string, port int, timeout time.Duration) (*pingResponse, error) { + conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), timeout) + if err != nil { + return nil, err + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(timeout)) + + var handshake bytes.Buffer + writeVarInt(&handshake, 0x00) + writeVarInt(&handshake, -1) + writeString(&handshake, host) + binary.Write(&handshake, binary.BigEndian, uint16(port)) + writeVarInt(&handshake, 1) + if err := writePacket(conn, handshake.Bytes()); err != nil { + return nil, err + } + + if err := writePacket(conn, []byte{0x00}); err != nil { + return nil, err + } + + reader := bufio.NewReader(conn) + if _, err := readVarInt(reader); err != nil { + return nil, err + } + packetID, err := readVarInt(reader) + if err != nil { + return nil, err + } + if packetID != 0x00 { + return nil, fmt.Errorf("unexpected packet id %d", packetID) + } + + jsonLen, err := readVarInt(reader) + if err != nil { + return nil, err + } + payload := make([]byte, jsonLen) + if _, err := io.ReadFull(reader, payload); err != nil { + return nil, err + } + + var resp pingResponse + if err := json.Unmarshal(payload, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +func writePacket(conn net.Conn, data []byte) error { + var buf bytes.Buffer + writeVarInt(&buf, len(data)) + buf.Write(data) + _, err := conn.Write(buf.Bytes()) + return err +} + +func writeString(buf *bytes.Buffer, s string) { + writeVarInt(buf, len(s)) + buf.WriteString(s) +} + +func writeVarInt(buf *bytes.Buffer, value int) { + uv := uint32(value) + for { + if uv&^uint32(0x7F) == 0 { + buf.WriteByte(byte(uv)) + return + } + buf.WriteByte(byte(uv&0x7F | 0x80)) + uv >>= 7 + } +} + +func readVarInt(r io.ByteReader) (int, error) { + var result uint32 + var shift uint + for { + b, err := r.ReadByte() + if err != nil { + return 0, err + } + result |= uint32(b&0x7F) << shift + if b&0x80 == 0 { + break + } + shift += 7 + if shift >= 32 { + return 0, errors.New("varint too long") + } + } + return int(result), nil +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..8e2019d --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,196 @@ +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 +} diff --git a/internal/server/sysproc_unix.go b/internal/server/sysproc_unix.go new file mode 100644 index 0000000..91dee0e --- /dev/null +++ b/internal/server/sysproc_unix.go @@ -0,0 +1,36 @@ +//go:build !windows + +package server + +import ( + "os" + "syscall" +) + +func detachAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{Setsid: true} +} + +func signalStop(pid int) error { + proc, err := os.FindProcess(pid) + if err != nil { + return err + } + return proc.Signal(syscall.SIGTERM) +} + +func forceKill(pid int) error { + proc, err := os.FindProcess(pid) + if err != nil { + return err + } + return proc.Kill() +} + +func isAlive(pid int) bool { + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + return proc.Signal(syscall.Signal(0)) == nil +} diff --git a/internal/server/sysproc_windows.go b/internal/server/sysproc_windows.go new file mode 100644 index 0000000..0f7977f --- /dev/null +++ b/internal/server/sysproc_windows.go @@ -0,0 +1,33 @@ +//go:build windows + +package server + +import ( + "os" + "syscall" +) + +func detachAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{} +} + +func signalStop(pid int) error { + proc, err := os.FindProcess(pid) + if err != nil { + return err + } + return proc.Kill() +} + +func forceKill(pid int) error { + proc, err := os.FindProcess(pid) + if err != nil { + return err + } + return proc.Kill() +} + +func isAlive(pid int) bool { + _, err := os.FindProcess(pid) + return err == nil +} |
