simpleadmin-web 0.1.0: web panel for CS2-SimpleAdmin
This commit is contained in:
commit
4f38daf0e6
31 changed files with 6870 additions and 0 deletions
221
main.go
Normal file
221
main.go
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue