// 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 }