Add event logging via database and webhook

This commit is contained in:
Palapeli
2026-04-05 09:00:02 -04:00
parent b3bca6e62a
commit e30d89cd1e
7 changed files with 331 additions and 59 deletions

View File

@@ -48,6 +48,20 @@ type Config struct {
AllowConnectWithoutDeviceID bool `xml:"allowConnectWithoutDeviceID"`
ServerName string `xml:"serverName,omitempty"`
EventReporting EventReportingConfig `xml:"eventReporting"`
}
type EventReportingConfig struct {
LogToDatabase bool `xml:"logToDatabase"`
Webhooks []WebhookConfig `xml:"discord>webhook"`
}
type WebhookConfig struct {
Enabled bool `xml:"enabled"`
URL string `xml:"url"`
Author string `xml:"author,omitempty"`
EventTypes []string `xml:"eventTypes>event"`
}
var (
@@ -140,3 +154,9 @@ func GetConfig() Config {
return config
}
func (c Config) RegisterWebhooks() {
for _, webhook := range c.EventReporting.Webhooks {
webhook.RegisterEventReporting()
}
}

97
common/webhook.go Normal file
View File

@@ -0,0 +1,97 @@
package common
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"wwfc/logging"
)
func contains(slice []string, item string) bool {
for _, v := range slice {
if v == item {
return true
}
}
return false
}
type webhookAuthor struct {
Name string `json:"name,omitempty"`
}
type webhookEmbed struct {
Author webhookAuthor `json:"author,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
}
type webhookPayload struct {
Embeds []webhookEmbed `json:"embeds"`
}
func encodeWebhookValue(value any) string {
switch v := value.(type) {
case string:
return " - ``" + strings.ReplaceAll(v, "``", "` `") + "``"
case int:
return " - " + strconv.Itoa(v)
case int32:
return " - " + strconv.Itoa(int(v))
case int64:
return " - " + strconv.FormatInt(v, 10)
case float32:
return " - " + strconv.FormatFloat(float64(v), 'f', -1, 32)
case float64:
return " - " + strconv.FormatFloat(v, 'f', -1, 64)
case []any:
var sb strings.Builder
for i, item := range v {
sb.WriteString(encodeWebhookValue(item))
if i < len(v)-1 {
sb.WriteString("\n")
}
}
return sb.String()
default:
return " - (unknown)"
}
}
func (w WebhookConfig) ReportEvent(eventType string, eventData map[string]any) {
embed := webhookEmbed{
Title: "> **" + eventType + "**",
}
if w.Author != "" {
embed.Author = webhookAuthor{Name: w.Author}
}
for key, value := range eventData {
embed.Description += "- " + key + "\n" + encodeWebhookValue(value) + "\n"
}
// Send HTTP POST request
jsonData, err := json.Marshal(webhookPayload{Embeds: []webhookEmbed{embed}})
if err != nil {
panic(err)
}
resp, err := http.Post(w.URL, "application/json", strings.NewReader(string(jsonData)))
if err != nil {
panic(err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic("received non-2xx response: " + resp.Status)
}
resp.Body.Close()
}
func (w WebhookConfig) RegisterEventReporting() {
if !w.Enabled {
return
}
logging.RegisterEventCallback(w.EventTypes, func(eventType string, eventData map[string]any) {
w.ReportEvent(eventType, eventData)
})
}

View File

@@ -1,73 +1,118 @@
<Config>
<!-- The address the GameSpy services will bind to -->
<gsAddress>127.0.0.1</gsAddress>
<!-- The address the GameSpy services will bind to -->
<gsAddress>127.0.0.1</gsAddress>
<!-- The address the frontend RPC server will bind to -->
<frontendAddress>127.0.0.1:29998</frontendAddress>
<!-- The address the NAS HTTP server will bind to -->
<nasAddress>127.0.0.1</nasAddress>
<nasPort>80</nasPort>
<!-- The address the frontend can reach the backend from -->
<frontendBackendAddress>127.0.0.1:29999</frontendBackendAddress>
<!-- The address the NAS HTTPS proxy server will bind to -->
<nasAddressHttps>127.0.0.1</nasAddressHttps>
<nasPortHttps>443</nasPortHttps>
<enableHttps>false</enableHttps>
<!-- The address the backend RPC server will bind to -->
<backendAddress>127.0.0.1:29999</backendAddress>
<!--
The address the payload server will be located at,
leave blank to use the deprecated integrated server
-->
<payloadServerAddress>127.0.0.1:29997</payloadServerAddress>
<!-- The address the backend can reach the frontend from -->
<backendFrontendAddress>127.0.0.1:29998</backendFrontendAddress>
<!--
The address the frontend RPC server will bind to.
This is a local channel for internal server communication,
that _MUST NOT_ be exposed to a public network or the internet.
Do NOT change this address unless you are running the frontend
and backend on separate servers or containers.
-->
<frontendAddress>127.0.0.1:29998</frontendAddress>
<!-- The address the NAS HTTP server will bind to -->
<nasAddress>127.0.0.1</nasAddress>
<nasPort>80</nasPort>
<!-- The address the frontend can reach the backend from -->
<frontendBackendAddress>127.0.0.1:29999</frontendBackendAddress>
<!-- The address the NAS HTTPS proxy server will bind to -->
<nasAddressHttps>127.0.0.1</nasAddressHttps>
<nasPortHttps>443</nasPortHttps>
<enableHttps>false</enableHttps>
<enableHttpsExploitWii>false</enableHttpsExploitWii>
<enableHttpsExploitDS>false</enableHttpsExploitDS>
<!--
The address the backend RPC server will bind to.
This is a local channel for internal server communication,
please note the above warning on frontendAddress.
-->
<backendAddress>127.0.0.1:29999</backendAddress>
<!-- The address the payload server will be located at, leave blank to use legacy integrated payload server -->
<payloadServerAddress>127.0.0.1:29997</payloadServerAddress>
<!-- The address the backend can reach the frontend from -->
<backendFrontendAddress>127.0.0.1:29998</backendFrontendAddress>
<!-- Path to the certificate and key used for modern web browser requests -->
<certPath>fullchain.pem</certPath>
<keyPath>privkey.pem</keyPath>
<!-- Path to the certificate and key used for modern web browser requests -->
<certPath>fullchain.pem</certPath>
<keyPath>privkey.pem</keyPath>
<!-- Path to the certificate and key used for the Wii DNS exploit -->
<certDerPathWii>naswii-cert.der</certDerPathWii>
<keyPathWii>naswii-key.pem</keyPathWii>
<!-- SSL/TLS exploit configuration -->
<enableHttpsExploitWii>false</enableHttpsExploitWii>
<enableHttpsExploitDS>false</enableHttpsExploitDS>
<!-- Path to the certificate, Wii client certificate and key used for the DS DNS exploit -->
<certDerPathDS>nas-cert.der</certDerPathDS>
<wiiCertDerPathDS>nwc.der</wiiCertDerPathDS>
<keyPathDS>nas-key.pem</keyPathDS>
<!-- Path to the certificate and key used for the Wii DNS exploit -->
<certDerPathWii>naswii-cert.der</certDerPathWii>
<keyPathWii>naswii-key.pem</keyPathWii>
<!-- Allow default Dolphin device keys to be used -->
<allowDefaultDolphinKeys>true</allowDefaultDolphinKeys>
<!-- Path to the certificate, Wii client certificate and key used for the DS DNS exploit -->
<certDerPathDS>nas-cert.der</certDerPathDS>
<wiiCertDerPathDS>nwc.der</wiiCertDerPathDS>
<keyPathDS>nas-key.pem</keyPathDS>
<!-- Database Credentials -->
<username>username</username>
<password>password</password>
<!-- Allow default Dolphin device keys to be used -->
<allowDefaultDolphinKeys>true</allowDefaultDolphinKeys>
<!-- Database information -->
<databaseAddress>127.0.0.1</databaseAddress>
<databaseName>wwfc</databaseName>
<!-- Logging configuration -->
<!-- Log verbosity
0: No messages are logged.
1: General messages are logged.
2: General and error messages are logged.
3: General, error, and warning messages are logged.
4: General, error, warning, and informational messages are logged.
-->
<logLevel>4</logLevel>
<!-- Log output
None : Messages are discarded.
StdOut : Messages are written to standard output.
StdOutAndFile: Messages are written to both standard output and a file.
-->
<logOutput>StdOutAndFile</logOutput>
<!-- Database Credentials -->
<username>username</username>
<password>password</password>
<!-- API secret -->
<apiSecret>hQ3f57b3tW2WnjJH3v</apiSecret>
<!-- Database information -->
<databaseAddress>127.0.0.1</databaseAddress>
<databaseName>wwfc</databaseName>
<!-- Logging configuration -->
<!-- Log verbosity
0: No messages are logged.
1: General messages are logged.
2: General and error messages are logged.
3: General, error, and warning messages are logged.
4: General, error, warning, and informational messages are logged.
-->
<logLevel>4</logLevel>
<!-- Log output
None : Messages are discarded.
StdOut : Messages are written to standard output.
StdOutAndFile: Messages are written to both standard output and a file.
-->
<logOutput>StdOutAndFile</logOutput>
<!-- API secret.
This is used for API authentication and should only be shared with
trusted administrators.
-->
<apiSecret>hQ3f57b3tW2WnjJH3v</apiSecret>
<eventReporting>
<!-- Enable to log events to the "events" table in the database -->
<logToDatabase>true</logToDatabase>
<!-- Discord logging via webhook -->
<discord>
<!-- Multiple <webhook> elements can be added here -->
<webhook>
<!-- Enable sending events to this webhook -->
<enabled>true</enabled>
<!-- The URL of the webhook to send events to -->
<url>https://discord.com/api/webhooks/...</url>
<!-- The author tag to be displayed in the webhook embed, can be omitted -->
<author>wfc-server event reporting</author>
<!--
The list of events that will be reported on this webhook.
Hint: Use the event type "all" to report all events to this webhook.
The following list contains all possible event types that can be reported:
-->
<eventTypes>
<!-- <event>all</event> -->
<event>backend_started</event>
<event>backend_stopped</event>
</eventTypes>
</webhook>
</discord>
</eventReporting>
</Config>

34
database/events.go Normal file
View File

@@ -0,0 +1,34 @@
package database
import (
"wwfc/common"
"wwfc/logging"
)
const (
insertEventQuery = `
INSERT INTO events (event_type, event_data)
VALUES ($1, $2)
RETURNING id`
)
func (c *Connection) InsertEvent(eventType string, eventData map[string]any) (int, error) {
var eventId int
err := c.pool.QueryRow(c.ctx, insertEventQuery, eventType, eventData).Scan(&eventId)
if err != nil {
return 0, err
}
return eventId, nil
}
func (c *Connection) RegisterEventLogging(config common.Config, eventTypes []string) {
if !config.EventReporting.LogToDatabase {
return
}
logging.RegisterEventCallback(eventTypes, func(eventType string, eventData map[string]any) {
_, err := c.InsertEvent(eventType, eventData)
if err != nil {
panic(err)
}
})
}

47
logging/event.go Normal file
View File

@@ -0,0 +1,47 @@
package logging
import "sync"
type eventCallbackConfig struct {
Function func(eventType string, eventData map[string]any)
EventTypes map[string]struct{}
AllEvents bool
}
var (
eventCallbacks []eventCallbackConfig
mutex sync.RWMutex
)
func Event(eventType string, eventData map[string]any) {
mutex.RLock()
defer mutex.RUnlock()
for _, callback := range eventCallbacks {
if callback.AllEvents {
callback.Function(eventType, eventData)
} else if _, ok := callback.EventTypes[eventType]; ok {
callback.Function(eventType, eventData)
}
}
}
func RegisterEventCallback(eventTypes []string, callback func(eventType string, eventData map[string]any)) {
eventTypeSet := make(map[string]struct{})
allEvents := false
for _, eventType := range eventTypes {
if eventType == "all" {
allEvents = true
eventTypeSet = make(map[string]struct{})
break
}
eventTypeSet[eventType] = struct{}{}
}
mutex.Lock()
defer mutex.Unlock()
eventCallbacks = append(eventCallbacks, eventCallbackConfig{
Function: callback,
EventTypes: eventTypeSet,
AllEvents: allEvents,
})
}

23
main.go
View File

@@ -15,6 +15,7 @@ import (
"time"
"wwfc/api"
"wwfc/common"
"wwfc/database"
"wwfc/gamestats"
"wwfc/gpcm"
"wwfc/gpsp"
@@ -70,12 +71,27 @@ type RPCPacket struct {
Data []byte
}
func connectAndLogEvent(eventType string) {
var db *database.Connection
defer func() {
logging.Event(eventType, map[string]any{})
if db != nil {
db.Close()
}
}()
dbObject := database.Start(config)
db = &dbObject
dbObject.RegisterEventLogging(config, []string{eventType})
}
// backendMain starts all the servers and creates an RPC server to communicate with the frontend
func backendMain(noSignal, noReload bool) {
config.RegisterWebhooks()
defer connectAndLogEvent("backend_stopped")
err := os.Mkdir("state", 0755)
if err != nil && !os.IsExist(err) {
logging.Error("BACKEN", err)
os.Exit(1)
panic(err)
}
sigExit := make(chan os.Signal, 1)
@@ -119,6 +135,9 @@ func backendMain(noSignal, noReload bool) {
// Wait for all servers to start
wg.Wait()
// Log via event that the backend has started
go connectAndLogEvent("backend_started")
go func() {
for {
conn, err := l.Accept()

View File

@@ -149,6 +149,16 @@ ALTER TABLE ONLY public.users ALTER COLUMN profile_id SET DEFAULT nextval('publi
ALTER TABLE ONLY public.users
ADD CONSTRAINT users_pkey PRIMARY KEY (profile_id);
--
-- Name: events; Type: TABLE; Schema: public; Owner: wiilink
--
CREATE TABLE IF NOT EXISTS public.events (
id serial PRIMARY KEY,
event_type character varying NOT NULL,
event_data jsonb NOT NULL,
event_time timestamp without time zone DEFAULT CURRENT_TIMESTAMP
);
--
-- PostgreSQL database dump complete