diff --git a/README.md b/README.md index 2e4b46d..f2f3cf6 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ RCON. Without it the panel still works, but shows the server as offline and can' | See IP addresses | `@css/showip` | | Change map | `@css/changemap` | | Server cvars | `@css/cvar` | + | Raw console commands (Server settings > Console; sent without waiting, output only logged) | `@css/rcon` | | See the Staff & ranks page | `@zio/webpanel` | | Give and remove verification overrides | `@zio/verify` (not on themselves, unless Root) | | Ranks, staff, SimpleAdmin settings, reload admins, GroupVerify's group and on/off | `@css/root` | diff --git a/internal/web/actions.go b/internal/web/actions.go index 8630d9d..278ca90 100644 --- a/internal/web/actions.go +++ b/internal/web/actions.go @@ -353,6 +353,43 @@ func (s *Server) restartRound(w http.ResponseWriter, r *http.Request, id *Identi s.console(w, id, "@css/generic", "css_rr", "Restarting the match.") } +// rcon runs a raw console command for staff with @css/rcon, the same as css_rcon in game. It doesn't +// wait for the server: the command is sent in the background and what it prints is only logged. +func (s *Server) rcon(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { + if !id.Has("@css/rcon") { + fail(w, http.StatusForbidden, "Your rank can't do that.") + return + } + var req struct { + Command string `json:"command"` + } + if err := readJSON(r, &req); err != nil { + fail(w, http.StatusBadRequest, err.Error()) + return + } + // Newlines would send several commands; ';' still does, as it does in the console. + cmd := strings.TrimSpace(strings.NewReplacer("\r", " ", "\n", " ").Replace(req.Command)) + if cmd == "" { + fail(w, http.StatusBadRequest, "Enter a command first.") + return + } + if len(cmd) > 512 { + fail(w, http.StatusBadRequest, "That command is too long.") + return + } + slog.Info("rcon", "admin", id.SteamID, "cmd", cmd) + go func() { + out, err := s.rc.Exec(cmd) + if err != nil { + slog.Warn("rcon failed", "admin", id.SteamID, "cmd", cmd, "err", err) + return + } + slog.Info("rcon output", "admin", id.SteamID, "out", strings.TrimSpace(out)) + s.poll.Refresh() + }() + done(w, "Sent.") +} + func (s *Server) reloadAdmins(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { s.staff.invalidate() s.console(w, id, "@css/root", "css_reloadadmins", "Reloaded admins.") diff --git a/internal/web/admin.go b/internal/web/admin.go index 577b267..867281e 100644 --- a/internal/web/admin.go +++ b/internal/web/admin.go @@ -397,7 +397,7 @@ func (s *Server) readCvar(key string) (string, bool) { } func (s *Server) adminSettings(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { - resp := map[string]any{"canCvar": id.Has("@css/cvar"), "canRoot": id.Has("@css/root")} + resp := map[string]any{"canCvar": id.Has("@css/cvar"), "canRoot": id.Has("@css/root"), "canRcon": id.Has("@css/rcon")} if id.Has("@css/cvar") && s.poll.Current().Online { defs := make([]cvarDef, 0, len(cvars)) diff --git a/internal/web/assets/admin.js b/internal/web/assets/admin.js index 4e9cb6e..cc2b5c9 100644 --- a/internal/web/assets/admin.js +++ b/internal/web/assets/admin.js @@ -23,6 +23,8 @@ const state = { query: "", num: 1, group: null, + // Collapsed panels, see foldHead. + fold: { ranks: false, rank: true, members: true, sa: true, connections: true, maintenance: true, rcon: true }, me: null, counts: {}, }; @@ -274,6 +276,15 @@ async function loadResults() { } } +/* ---------- Collapsible panels ---------- */ + +// A collapsible panel's head: clicking anywhere on it but its own buttons opens or closes it. +// state.fold remembers which are closed until the page is reloaded; a panel not in it starts open. +const foldHead = (key, title, aside = "") => `
Immunity
Immunity
${esc(g.label)}
`)}