221 lines
6.1 KiB
Go
221 lines
6.1 KiB
Go
// simpleadmin-web is a web panel for CS2-SimpleAdmin: a public ban list, staff list and live
|
|
// scoreboard, and a staff panel for bans, gags and mutes, ranks and server settings.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
_ "time/tzdata" // SimpleAdmin's Timezone must load even in a minimal container
|
|
|
|
"github.com/go-sql-driver/mysql"
|
|
|
|
"git.zio.sh/cs2/simpleadmin-web/internal/live"
|
|
"git.zio.sh/cs2/simpleadmin-web/internal/rcon"
|
|
"git.zio.sh/cs2/simpleadmin-web/internal/saconfig"
|
|
"git.zio.sh/cs2/simpleadmin-web/internal/store"
|
|
"git.zio.sh/cs2/simpleadmin-web/internal/web"
|
|
)
|
|
|
|
func env(key, def string) string {
|
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
func envInt(key string, def int) (int, error) {
|
|
v := env(key, "")
|
|
if v == "" {
|
|
return def, nil
|
|
}
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s must be a number", key)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func main() {
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
|
if err := run(); err != nil {
|
|
slog.Error("simpleadmin-web", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run() error {
|
|
baseURL := strings.TrimRight(env("SAW_BASE_URL", ""), "/")
|
|
if baseURL == "" {
|
|
return errors.New("set SAW_BASE_URL to the panel's public address, like https://bans.example.com")
|
|
}
|
|
secret := env("SAW_SESSION_SECRET", "")
|
|
if len(secret) < 32 {
|
|
return errors.New("set SAW_SESSION_SECRET to at least 32 random characters (e.g. openssl rand -hex 32)")
|
|
}
|
|
|
|
var sacfg *saconfig.File
|
|
general := saconfig.General{Timezone: "UTC", MultiServerMode: true}
|
|
if path := env("SAW_SA_CONFIG", ""); path != "" {
|
|
sacfg = saconfig.Open(path)
|
|
g, err := sacfg.General()
|
|
if err != nil {
|
|
return fmt.Errorf("SAW_SA_CONFIG: %w", err)
|
|
}
|
|
general = g
|
|
}
|
|
|
|
tzName := env("SAW_TIMEZONE", general.Timezone)
|
|
loc, err := time.LoadLocation(tzName)
|
|
if err != nil {
|
|
return fmt.Errorf("timezone %q: %w", tzName, err)
|
|
}
|
|
|
|
db, err := openDB(sacfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer db.Close()
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
serverID, err := envInt("SAW_SERVER_ID", 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
timeMode := 1
|
|
maxBan, maxMute := 60*24*7, 60*24*7
|
|
if sacfg != nil {
|
|
if v, err := sacfg.Values(); err == nil {
|
|
timeMode, maxBan, maxMute = v["TimeMode"].(int), v["MaxBanDuration"].(int), v["MaxMuteDuration"].(int)
|
|
}
|
|
}
|
|
|
|
// Find this server in sa_servers. SimpleAdmin records its address and RCON password there.
|
|
st := store.New(db, loc, 0, timeMode)
|
|
servers, err := st.Servers(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("read sa_servers (is this SimpleAdmin's database?): %w", err)
|
|
}
|
|
var srv store.Server
|
|
switch {
|
|
case serverID > 0:
|
|
for _, s := range servers {
|
|
if s.ID == int64(serverID) {
|
|
srv = s
|
|
}
|
|
}
|
|
if srv.ID == 0 {
|
|
return fmt.Errorf("SAW_SERVER_ID %d isn't in sa_servers", serverID)
|
|
}
|
|
case len(servers) == 1:
|
|
srv = servers[0]
|
|
case len(servers) == 0:
|
|
slog.Warn("sa_servers is empty; SimpleAdmin hasn't registered the server yet")
|
|
default:
|
|
return errors.New("sa_servers lists several servers; set SAW_SERVER_ID to this server's id")
|
|
}
|
|
st = store.New(db, loc, srv.ID, timeMode)
|
|
|
|
rconAddr := env("SAW_RCON_ADDR", srv.Address)
|
|
rconPass := env("SAW_RCON_PASSWORD", srv.RconPassword)
|
|
if rconAddr == "" || rconPass == "" {
|
|
slog.Warn("no RCON address or password; live status and in-game actions are off. Set SAW_RCON_ADDR and SAW_RCON_PASSWORD.")
|
|
}
|
|
rc := rcon.New(rconAddr, rconPass)
|
|
defer rc.Close()
|
|
|
|
pollSecs, err := envInt("SAW_POLL_SECONDS", 5)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
poller := live.NewPoller(rc, time.Duration(max(pollSecs, 2))*time.Second)
|
|
go poller.Run(ctx)
|
|
|
|
joinAddr := env("SAW_JOIN_ADDRESS", srv.Address)
|
|
app, err := web.New(web.Config{
|
|
BaseURL: baseURL,
|
|
SessionSecret: []byte(secret),
|
|
SiteFile: env("SAW_SITE_CONFIG", ""),
|
|
ServerAddress: joinAddr,
|
|
MaxBanDuration: maxBan,
|
|
MaxMuteDuration: maxMute,
|
|
}, st, rc, poller, sacfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
listen := env("SAW_LISTEN", ":8080")
|
|
hs := &http.Server{
|
|
Addr: listen,
|
|
Handler: app.Handler(),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
BaseContext: func(net.Listener) context.Context { return ctx },
|
|
}
|
|
go func() {
|
|
<-ctx.Done()
|
|
shut, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = hs.Shutdown(shut)
|
|
}()
|
|
slog.Info("listening", "addr", listen, "base", baseURL, "server", srv.ID, "rcon", rconAddr, "timezone", loc.String())
|
|
if err := hs.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// openDB connects with SAW_DB_DSN, or with the DatabaseConfig in CS2-SimpleAdmin.json.
|
|
func openDB(sacfg *saconfig.File) (*sql.DB, error) {
|
|
var cfg *mysql.Config
|
|
if dsn := env("SAW_DB_DSN", ""); dsn != "" {
|
|
c, err := mysql.ParseDSN(dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("SAW_DB_DSN: %w", err)
|
|
}
|
|
cfg = c
|
|
} else if sacfg != nil {
|
|
d, err := sacfg.Database()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !strings.EqualFold(d.Type, "mysql") {
|
|
return nil, fmt.Errorf("SimpleAdmin uses %s; the panel only supports MySQL", d.Type)
|
|
}
|
|
cfg = mysql.NewConfig()
|
|
cfg.User, cfg.Passwd, cfg.DBName = d.User, d.Password, d.Name
|
|
cfg.Net, cfg.Addr = "tcp", net.JoinHostPort(d.Host, strconv.Itoa(d.Port))
|
|
if h := env("SAW_DB_HOST", ""); h != "" {
|
|
cfg.Addr = h
|
|
}
|
|
} else {
|
|
return nil, errors.New("set SAW_DB_DSN, or SAW_SA_CONFIG to CS2-SimpleAdmin.json to use its database settings")
|
|
}
|
|
// Timestamps are SimpleAdmin's wall-clock values; the store interprets them in SimpleAdmin's
|
|
// Timezone, so the driver must pass them through unconverted.
|
|
cfg.ParseTime = true
|
|
cfg.Loc = time.UTC
|
|
db, err := sql.Open("mysql", cfg.FormatDSN())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
db.SetMaxOpenConns(10)
|
|
db.SetConnMaxLifetime(5 * time.Minute)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
if err := db.PingContext(ctx); err != nil {
|
|
return nil, fmt.Errorf("connect to the database at %s: %w", cfg.Addr, err)
|
|
}
|
|
return db, nil
|
|
}
|