diff --git a/common/config.go b/common/config.go index 683f90d..7678477 100644 --- a/common/config.go +++ b/common/config.go @@ -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() + } +} diff --git a/common/webhook.go b/common/webhook.go new file mode 100644 index 0000000..00295ae --- /dev/null +++ b/common/webhook.go @@ -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) + }) +} diff --git a/config_example.xml b/config_example.xml index c2e8c1f..9f70f5f 100644 --- a/config_example.xml +++ b/config_example.xml @@ -1,73 +1,118 @@ - - 127.0.0.1 + + 127.0.0.1 - - 127.0.0.1:29998 + + 127.0.0.1 + 80 - - 127.0.0.1:29999 + + 127.0.0.1 + 443 + false - - 127.0.0.1:29999 + + 127.0.0.1:29997 - - 127.0.0.1:29998 + + 127.0.0.1:29998 - - 127.0.0.1 - 80 + + 127.0.0.1:29999 - - 127.0.0.1 - 443 - false - false - false + + 127.0.0.1:29999 - - 127.0.0.1:29997 + + 127.0.0.1:29998 - - fullchain.pem - privkey.pem + + fullchain.pem + privkey.pem - - naswii-cert.der - naswii-key.pem + + false + false - - nas-cert.der - nwc.der - nas-key.pem + + naswii-cert.der + naswii-key.pem - - true + + nas-cert.der + nwc.der + nas-key.pem - - username - password + + true - - 127.0.0.1 - wwfc - - - - 4 - - StdOutAndFile + + username + password - - hQ3f57b3tW2WnjJH3v + + 127.0.0.1 + wwfc + + + + 4 + + StdOutAndFile + + + hQ3f57b3tW2WnjJH3v + + + + true + + + + + + true + + https://discord.com/api/webhooks/... + + wfc-server event reporting + + + + backend_started + backend_stopped + + + + diff --git a/database/events.go b/database/events.go new file mode 100644 index 0000000..f46b295 --- /dev/null +++ b/database/events.go @@ -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) + } + }) +} diff --git a/logging/event.go b/logging/event.go new file mode 100644 index 0000000..7f6f421 --- /dev/null +++ b/logging/event.go @@ -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, + }) +} diff --git a/main.go b/main.go index a93f506..2a68cec 100644 --- a/main.go +++ b/main.go @@ -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() diff --git a/schema.sql b/schema.sql index e846d50..99a416d 100644 --- a/schema.sql +++ b/schema.sql @@ -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