simpleadmin-web/main.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
}