package web import ( "errors" "fmt" "log/slog" "net/http" "regexp" "slices" "strconv" "strings" "time" "git.zio.sh/cs2/simpleadmin-web/internal/rcon" "git.zio.sh/cs2/simpleadmin-web/internal/steam" "git.zio.sh/cs2/simpleadmin-web/internal/store" ) // Commands sent over RCON run as SimpleAdmin's "Console", which skips every permission, immunity // and length check SimpleAdmin would apply to a player. Each handler here enforces them instead, // using the same flags SimpleAdmin checks. type result struct { OK bool `json:"ok"` Message string `json:"message"` } func done(w http.ResponseWriter, msg string) { writeJSON(w, http.StatusOK, result{OK: true, Message: msg}) } func (s *Server) limits() (maxBan, maxMute int) { maxBan, maxMute = s.cfg.MaxBanDuration, s.cfg.MaxMuteDuration if s.sacfg != nil { if v, err := s.sacfg.Values(); err == nil { maxBan, maxMute = v["MaxBanDuration"].(int), v["MaxMuteDuration"].(int) } } return } func fmtMinutes(m int) string { switch { case m%(60*24) == 0: d := m / (60 * 24) if d == 1 { return "1 day" } return strconv.Itoa(d) + " days" case m%60 == 0: return strconv.Itoa(m/60) + " h" } return strconv.Itoa(m) + " min" } var commCommands = map[string]struct{ add, lift, label string }{ "GAG": {"css_addgag", "css_ungag", "gag"}, "MUTE": {"css_addmute", "css_unmute", "mute"}, "SILENCE": {"css_addsilence", "css_unsilence", "silence"}, } // consoleRejected spots SimpleAdmin's replies to a command it refused. func consoleRejected(out string) string { for _, bad := range []string{"Invalid SteamID64", "Invalid player", "Unknown command", "not found"} { if strings.Contains(out, bad) { return strings.TrimSpace(out) } } return "" } type penaltyReq struct { Kind string `json:"kind"` // ban, comm, warn Type string `json:"type"` // GAG, MUTE, SILENCE SteamID string `json:"steamid"` Duration int `json:"duration"` Reason string `json:"reason"` } func (s *Server) addPenalty(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { var req penaltyReq if err := readJSON(r, &req); err != nil { fail(w, http.StatusBadRequest, err.Error()) return } if !steam.ValidID(req.SteamID) { fail(w, http.StatusBadRequest, "Enter the player's SteamID64 (17 digits, starting 7656119).") return } reason := rcon.Arg(req.Reason, 200) if reason == "" { fail(w, http.StatusBadRequest, "Give a reason. The player sees it.") return } if req.Duration < 0 || req.Duration > 60*24*365*10 { fail(w, http.StatusBadRequest, "Pick a length between 1 minute and 10 years, or permanent.") return } if !id.CanTarget(x, req.SteamID) { if req.SteamID == id.SteamID { fail(w, http.StatusForbidden, "You can't punish yourself from the panel.") } else { fail(w, http.StatusForbidden, "This player's rank has higher immunity than yours.") } return } maxBan, maxMute := s.limits() var table, cmd, what string switch req.Kind { case "ban": if !id.Has("@css/ban") { fail(w, http.StatusForbidden, "Your rank can't ban players.") return } if (req.Duration == 0 || req.Duration > maxBan) && !id.Has("@css/permban") { fail(w, http.StatusForbidden, fmt.Sprintf("Your rank can ban for up to %s, and not permanently.", fmtMinutes(maxBan))) return } table, what = "sa_bans", "Ban" cmd = fmt.Sprintf(`css_addban %s %d "%s"`, req.SteamID, req.Duration, reason) case "comm": c, ok := commCommands[req.Type] if !ok { fail(w, http.StatusBadRequest, "Choose gag, mute or silence.") return } if !id.Has("@css/chat") { fail(w, http.StatusForbidden, "Your rank can't gag or mute players.") return } if (req.Duration == 0 || req.Duration > maxMute) && !id.Has("@css/permmute") { fail(w, http.StatusForbidden, fmt.Sprintf("Your rank can block for up to %s, and not permanently.", fmtMinutes(maxMute))) return } table, what = "sa_mutes", strings.ToUpper(c.label[:1])+c.label[1:] cmd = fmt.Sprintf(`%s %s %d "%s"`, c.add, req.SteamID, req.Duration, reason) case "warn": if !id.Has("@css/kick") { fail(w, http.StatusForbidden, "Your rank can't warn players.") return } p, online := s.poll.Refresh().Find(req.SteamID) if !online { fail(w, http.StatusConflict, "Warnings can only be given to players on the server.") return } table, what = "sa_warns", "Warning" cmd = fmt.Sprintf(`css_warn #%d %d "%s"`, p.UserID, req.Duration, reason) default: fail(w, http.StatusBadRequest, "Unknown penalty.") return } ctx := r.Context() before := s.st.MaxID(ctx, table) out, err := s.rc.Exec(cmd) if err != nil { slog.Warn("rcon failed, writing penalty to the database", "err", err) if req.Kind == "warn" { fail(w, http.StatusBadGateway, "The game server isn't answering, and warnings can only be given in-game.") return } np := store.NewPenalty{SteamID: req.SteamID, Name: s.st.LatestName(ctx, req.SteamID), Type: req.Type, AdminSteamID: id.SteamID, AdminName: id.Name, Reason: reason, Duration: req.Duration} if req.Kind == "ban" { err = s.st.InsertBan(ctx, np) } else { err = s.st.InsertComm(ctx, np) } if err != nil { internal(w, r, err) return } done(w, what+" saved. The game server isn't answering, so it applies when the player next joins.") return } if msg := consoleRejected(out); msg != "" { fail(w, http.StatusBadGateway, "SimpleAdmin refused: "+msg) return } commType := "" if req.Kind == "comm" { commType = req.Type } claimed := s.st.ClaimConsoleRow(ctx, table, before, req.SteamID, commType, id.SteamID, id.Name) go s.poll.Refresh() slog.Info("penalty issued", "admin", id.SteamID, "kind", req.Kind, "type", req.Type, "player", req.SteamID, "minutes", req.Duration) if claimed == 0 { done(w, what+" sent to the server, but its record hasn't appeared yet. Check the list in a moment.") return } done(w, what+" applied.") } type liftReq struct { Kind string `json:"kind"` // ban, comm ID int64 `json:"id"` Reason string `json:"reason"` } func (s *Server) liftPenalty(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { var req liftReq if err := readJSON(r, &req); err != nil { fail(w, http.StatusBadRequest, err.Error()) return } reason := rcon.Arg(req.Reason, 200) if reason == "" { reason = "Lifted from the web panel" } table := "sa_bans" switch req.Kind { case "ban": if !id.Has("@css/unban") { fail(w, http.StatusForbidden, "Your rank can't unban players.") return } case "comm": if !id.Has("@css/chat") { fail(w, http.StatusForbidden, "Your rank can't lift gags or mutes.") return } table = "sa_mutes" default: fail(w, http.StatusBadRequest, "Unknown penalty.") return } ctx := r.Context() steamid, commType, err := s.st.PenaltyOwner(ctx, table, req.ID) if errors.Is(err, store.ErrNotFound) { fail(w, http.StatusNotFound, "That record no longer exists.") return } else if err != nil { internal(w, r, err) return } if !steam.ValidID(steamid) { fail(w, http.StatusConflict, "This record has no SteamID, so it can only be lifted in-game.") return } ids := s.st.ActivePenaltyIDs(ctx, table, steamid, commType) if !slices.Contains(ids, req.ID) { fail(w, http.StatusConflict, "That's already expired or lifted.") return } var cmd, what string if req.Kind == "ban" { cmd, what = fmt.Sprintf(`css_unban %s "%s"`, steamid, reason), "Unbanned" } else { c := commCommands[commType] cmd, what = fmt.Sprintf(`%s %s "%s"`, c.lift, steamid, reason), "Lifted the "+c.label } if _, err := s.rc.Exec(cmd); err != nil { slog.Warn("rcon failed, lifting in the database", "err", err) if _, err := s.st.LiftDirect(ctx, table, steamid, commType, id.RowID, reason); err != nil { internal(w, r, err) return } done(w, what+". The game server isn't answering, so it applies when the player next joins.") return } s.st.ClaimLift(ctx, table, ids, id.RowID) go s.poll.Refresh() slog.Info("penalty lifted", "admin", id.SteamID, "kind", req.Kind, "player", steamid) done(w, what+".") } func (s *Server) kick(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { var req struct { SteamID string `json:"steamid"` Reason string `json:"reason"` } if err := readJSON(r, &req); err != nil { fail(w, http.StatusBadRequest, err.Error()) return } if !id.Has("@css/kick") { fail(w, http.StatusForbidden, "Your rank can't kick players.") return } if !steam.ValidID(req.SteamID) || !id.CanTarget(x, req.SteamID) { fail(w, http.StatusForbidden, "You can't kick this player.") return } p, online := s.poll.Refresh().Find(req.SteamID) if !online { fail(w, http.StatusConflict, "That player isn't on the server.") return } reason := rcon.Arg(req.Reason, 200) if reason == "" { reason = "Kicked by an admin" } if _, err := s.rc.Exec(fmt.Sprintf(`css_kick #%d "%s"`, p.UserID, reason)); err != nil { fail(w, http.StatusBadGateway, "The game server isn't answering.") return } slog.Info("kick", "admin", id.SteamID, "player", req.SteamID) go s.poll.Refresh() done(w, "Kicked "+p.Name+".") } // console runs a command that needs no target, after a permission check. func (s *Server) console(w http.ResponseWriter, id *Identity, perm, cmd, ok string) { if !id.Has(perm) { fail(w, http.StatusForbidden, "Your rank can't do that.") return } if _, err := s.rc.Exec(cmd); err != nil { fail(w, http.StatusBadGateway, "The game server isn't answering.") return } slog.Info("console", "admin", id.SteamID, "cmd", cmd) go s.poll.Refresh() done(w, ok) } func (s *Server) say(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { var req struct { Message string `json:"message"` } if err := readJSON(r, &req); err != nil { fail(w, http.StatusBadRequest, err.Error()) return } msg := rcon.Arg(req.Message, 200) if msg == "" { fail(w, http.StatusBadRequest, "Write a message first.") return } s.console(w, id, "@css/chat", fmt.Sprintf(`css_say "%s"`, msg), "Sent.") } var mapName = regexp.MustCompile(`^(ws:)?[A-Za-z0-9_\-]{1,64}$`) func (s *Server) changeMap(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { var req struct { Map string `json:"map"` } if err := readJSON(r, &req); err != nil { fail(w, http.StatusBadRequest, err.Error()) return } if !mapName.MatchString(req.Map) { fail(w, http.StatusBadRequest, "Enter a map name like de_mirage, or ws:.") return } s.console(w, id, "@css/changemap", "css_map "+req.Map, "Changing map to "+req.Map+".") } func (s *Server) restartRound(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { s.console(w, id, "@css/generic", "css_rr", "Restarting the match.") } 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.") } // ---------------------------------------------------------------- Ranks and staff var ( groupName = regexp.MustCompile(`^#[A-Za-z0-9_./\-]{1,63}$`) flagName = regexp.MustCompile(`^@[a-z0-9_./\-]{1,63}$`) ) type groupReq struct { Name string `json:"name"` Immunity int `json:"immunity"` Flags []string `json:"flags"` } func (req *groupReq) validate(id *Identity) string { if !groupName.MatchString(req.Name) { return "Rank names start with # and use letters, numbers and _ . / -, like #rank/mod." } if req.Immunity < 0 || req.Immunity > 9999 { return "Immunity must be between 0 and 9999." } if req.Immunity > id.Immunity { return fmt.Sprintf("You can't create a rank with more immunity than your own (%d).", id.Immunity) } if len(req.Flags) == 0 { return "Give the rank at least one permission." } seen := map[string]bool{} for _, f := range req.Flags { if !flagName.MatchString(f) { return "Permissions look like @css/kick." } seen[f] = true } req.Flags = req.Flags[:0] for f := range seen { req.Flags = append(req.Flags, f) } slices.Sort(req.Flags) return "" } // afterStaffChange tells SimpleAdmin to reload admins, and says so if the server is down. func (s *Server) afterStaffChange(w http.ResponseWriter, ok string) { s.staff.invalidate() if _, err := s.rc.Exec("css_reloadadmins"); err != nil { done(w, ok+" The game server isn't answering, so it applies when SimpleAdmin next loads admins.") return } done(w, ok) } func (s *Server) createGroup(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { if !id.Has("@css/root") { fail(w, http.StatusForbidden, "Only Root can manage ranks.") return } var req groupReq if err := readJSON(r, &req); err != nil { fail(w, http.StatusBadRequest, err.Error()) return } if msg := req.validate(id); msg != "" { fail(w, http.StatusBadRequest, msg) return } if _, err := s.st.CreateGroup(r.Context(), req.Name, req.Immunity, req.Flags); err != nil { fail(w, http.StatusConflict, err.Error()) return } slog.Info("group created", "admin", id.SteamID, "group", req.Name) s.afterStaffChange(w, "Created "+req.Name+".") } func (s *Server) groupByID(x *staffIndex, raw string) *groupRef { n, err := strconv.ParseInt(raw, 10, 64) if err != nil { return nil } for i := range x.groups { if x.groups[i].ID == n { return &groupRef{ID: n, Name: x.groups[i].Name, Immunity: x.groups[i].Immunity, Flags: x.groups[i].Flags} } } return nil } type groupRef struct { ID int64 Name string Immunity int Flags []string } // ownsGroup reports whether the signed-in admin holds this rank. func ownsGroup(x *staffIndex, id *Identity, name string) bool { a, ok := x.bySteam[id.SteamID] return ok && slices.Contains(a.Groups, name) } func (s *Server) updateGroup(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { if !id.Has("@css/root") { fail(w, http.StatusForbidden, "Only Root can manage ranks.") return } g := s.groupByID(x, r.PathValue("id")) if g == nil { fail(w, http.StatusNotFound, "That rank no longer exists.") return } if g.Immunity > id.Immunity { fail(w, http.StatusForbidden, "This rank has more immunity than yours.") return } var req groupReq if err := readJSON(r, &req); err != nil { fail(w, http.StatusBadRequest, err.Error()) return } if msg := req.validate(id); msg != "" { fail(w, http.StatusBadRequest, msg) return } if ownsGroup(x, id, g.Name) { a := x.bySteam[id.SteamID] if slices.Contains(g.Flags, "@css/root") && !slices.Contains(req.Flags, "@css/root") && !slices.Contains(a.Flags, "@css/root") { fail(w, http.StatusForbidden, "That would take Root away from your own rank and lock you out. Ask another Root admin.") return } } if err := s.st.UpdateGroup(r.Context(), g.ID, req.Name, req.Immunity, req.Flags); err != nil { if errors.Is(err, store.ErrNotFound) { fail(w, http.StatusNotFound, "That rank no longer exists.") return } fail(w, http.StatusConflict, err.Error()) return } slog.Info("group updated", "admin", id.SteamID, "group", req.Name, "flags", req.Flags, "immunity", req.Immunity) s.afterStaffChange(w, "Saved "+req.Name+".") } func (s *Server) deleteGroup(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { if !id.Has("@css/root") { fail(w, http.StatusForbidden, "Only Root can manage ranks.") return } g := s.groupByID(x, r.PathValue("id")) if g == nil { fail(w, http.StatusNotFound, "That rank no longer exists.") return } if g.Immunity > id.Immunity { fail(w, http.StatusForbidden, "This rank has more immunity than yours.") return } if ownsGroup(x, id, g.Name) { fail(w, http.StatusForbidden, "You can't delete your own rank.") return } if err := s.st.DeleteGroup(r.Context(), g.ID); err != nil { internal(w, r, err) return } slog.Info("group deleted", "admin", id.SteamID, "group", g.Name) s.afterStaffChange(w, "Deleted "+g.Name+". Its members are regular players now.") } type staffReq struct { SteamID string `json:"steamid"` Group string `json:"group"` Days int `json:"days"` } // checkStaffTarget applies the rules for giving, changing or removing someone's rank. func (s *Server) checkStaffTarget(w http.ResponseWriter, id *Identity, x *staffIndex, steamid string) bool { if !id.Has("@css/root") { fail(w, http.StatusForbidden, "Only Root can manage staff.") return false } if !steam.ValidID(steamid) { fail(w, http.StatusBadRequest, "Enter the player's SteamID64 (17 digits, starting 7656119).") return false } if steamid == id.SteamID { fail(w, http.StatusForbidden, "You can't change your own rank. Ask another Root admin.") return false } if x.immunity(steamid) > id.Immunity { fail(w, http.StatusForbidden, "This person has more immunity than you.") return false } return true } func (s *Server) applyRank(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex, req staffReq, verb string) { grp, ok := x.byName[req.Group] if !ok { fail(w, http.StatusBadRequest, "Pick a rank that exists.") return } if grp.Immunity > id.Immunity { fail(w, http.StatusForbidden, "You can't give a rank with more immunity than your own.") return } if req.Days < 0 || req.Days > 3650 { fail(w, http.StatusBadRequest, "Expiry must be between 1 and 3650 days, or never.") return } var ends *time.Time if req.Days > 0 { t := time.Now().Add(time.Duration(req.Days) * 24 * time.Hour) ends = &t } // SimpleAdmin builds its admin list keyed by name, so an admin must never have an empty one. name := s.st.LatestName(r.Context(), req.SteamID) if name == "" { name = req.SteamID } if err := s.st.SetAdminGroup(r.Context(), req.SteamID, name, req.Group, grp.Immunity, ends); err != nil { internal(w, r, err) return } slog.Info("rank set", "admin", id.SteamID, "player", req.SteamID, "group", req.Group, "days", req.Days) s.afterStaffChange(w, verb+" "+name+" to "+s.site.rank(req.Group, x.groupIndex(req.Group)).Label+".") } func (s *Server) addStaff(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { var req staffReq if err := readJSON(r, &req); err != nil { fail(w, http.StatusBadRequest, err.Error()) return } if !s.checkStaffTarget(w, id, x, req.SteamID) { return } s.applyRank(w, r, id, x, req, "Added") } func (s *Server) setStaffRank(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { var req staffReq if err := readJSON(r, &req); err != nil { fail(w, http.StatusBadRequest, err.Error()) return } req.SteamID = r.PathValue("steamid") if !s.checkStaffTarget(w, id, x, req.SteamID) { return } if req.Group == "" { s.removeStaff(w, r, id, x) return } s.applyRank(w, r, id, x, req, "Moved") } func (s *Server) removeStaff(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { steamid := r.PathValue("steamid") if !s.checkStaffTarget(w, id, x, steamid) { return } if err := s.st.RemoveAdmin(r.Context(), steamid); err != nil { internal(w, r, err) return } slog.Info("staff removed", "admin", id.SteamID, "player", steamid) s.afterStaffChange(w, "Removed their rank.") } // ---------------------------------------------------------------- Settings func (s *Server) saveCvars(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { if !id.Has("@css/cvar") { fail(w, http.StatusForbidden, "Your rank can't change server settings.") return } var req map[string]any if err := readJSON(r, &req); err != nil { fail(w, http.StatusBadRequest, err.Error()) return } var cmds []string for key, raw := range req { i := slices.IndexFunc(cvars, func(c cvarDef) bool { return c.Key == key }) if i < 0 { fail(w, http.StatusBadRequest, key+" can't be changed here.") return } def := cvars[i] var val string switch def.Type { case "bool": b, ok := raw.(bool) if !ok { fail(w, http.StatusBadRequest, def.Label+" must be on or off.") return } val = "0" if b { val = "1" } case "int": f, ok := raw.(float64) if !ok || f != float64(int(f)) || f < -1 || f > 100000 { fail(w, http.StatusBadRequest, def.Label+" must be a whole number.") return } val = strconv.Itoa(int(f)) default: str, ok := raw.(string) if !ok { fail(w, http.StatusBadRequest, def.Label+" must be text.") return } val = rcon.Arg(str, 128) if key == "hostname" && val == "" { fail(w, http.StatusBadRequest, "The server needs a name.") return } } cmds = append(cmds, fmt.Sprintf(`%s "%s"`, key, val)) } for _, c := range cmds { if _, err := s.rc.Exec(c); err != nil { fail(w, http.StatusBadGateway, "The game server isn't answering.") return } } slog.Info("cvars changed", "admin", id.SteamID, "cmds", cmds) go s.poll.Refresh() done(w, "Saved. These reset when the server restarts; put permanent values in server.cfg.") } func (s *Server) saveSimpleAdmin(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) { if !id.Has("@css/root") { fail(w, http.StatusForbidden, "Only Root can change SimpleAdmin's settings.") return } if s.sacfg == nil { fail(w, http.StatusNotFound, "The panel wasn't given CS2-SimpleAdmin.json, so these can't be changed here.") return } var req map[string]any if err := readJSON(r, &req); err != nil { fail(w, http.StatusBadRequest, err.Error()) return } if err := s.sacfg.Update(req); err != nil { fail(w, http.StatusBadRequest, err.Error()) return } slog.Info("simpleadmin config changed", "admin", id.SteamID, "changes", req) done(w, "Saved. SimpleAdmin reads its config when it loads, so this applies after the next server restart.") }