⛳ alerts for any ctfd instance via ntfy
1package serve
2
3import (
4 "encoding/json"
5 "fmt"
6 "log"
7 "os"
8 "os/signal"
9 "path/filepath"
10 "reflect"
11 "syscall"
12 "time"
13
14 "github.com/spf13/cobra"
15 "github.com/taciturnaxolotl/ctfd-alerts/clients"
16)
17
18type MonitorState struct {
19 LastScoreboard *clients.ScoreboardResponse `json:"last_scoreboard"`
20 LastChallenges *clients.ChallengeListResponse `json:"last_challenges"`
21 UserPosition int `json:"user_position"`
22}
23
24func getCacheFilePath() string {
25 return filepath.Join(".", "cache.json")
26}
27
28func loadStateFromCache() *MonitorState {
29 cachePath := getCacheFilePath()
30 data, err := os.ReadFile(cachePath)
31 if err != nil {
32 log.Printf("No cache file found or error reading cache: %v", err)
33 return &MonitorState{}
34 }
35
36 var state MonitorState
37 if err := json.Unmarshal(data, &state); err != nil {
38 log.Printf("Error parsing cache file: %v", err)
39 return &MonitorState{}
40 }
41
42 log.Printf("Loaded state from cache: %s", cachePath)
43 return &state
44}
45
46func saveStateToCache(state *MonitorState) error {
47 cachePath := getCacheFilePath()
48 data, err := json.MarshalIndent(state, "", " ")
49 if err != nil {
50 return fmt.Errorf("error marshaling state: %v", err)
51 }
52
53 if err := os.WriteFile(cachePath, data, 0644); err != nil {
54 return fmt.Errorf("error writing cache file: %v", err)
55 }
56
57 return nil
58}
59
60var ServeCmd = &cobra.Command{
61 Use: "serve",
62 Short: "Run monitoring server",
63 Long: "Continuously monitors CTFd for leaderboard changes and new challenges, sending alerts when events occur",
64 Run: runServer,
65}
66
67func runServer(cmd *cobra.Command, args []string) {
68 ctx := cmd.Context()
69
70 // Get CTFd client from context
71 ctfdClient, ok := ctx.Value("ctfd_client").(clients.CTFdClient)
72 if !ok {
73 log.Fatal("CTFd client not found in context")
74 }
75
76 // Get config from context
77 config := ctx.Value("config")
78
79 // Use reflection to access config fields
80 configValue := reflect.ValueOf(config).Elem()
81 userField := configValue.FieldByName("User").String()
82 intervalField := int(configValue.FieldByName("MonitorInterval").Int())
83
84 ntfyConfigField := configValue.FieldByName("NtfyConfig")
85 ntfyTopic := ntfyConfigField.FieldByName("Topic").String()
86 ntfyApiBase := ntfyConfigField.FieldByName("ApiBase").String()
87 ntfyAccessToken := ntfyConfigField.FieldByName("AccessToken").String()
88
89 // Create ntfy client
90 ntfyClient := clients.NewNtfyClient(ntfyTopic, ntfyApiBase, ntfyAccessToken)
91
92 // Initialize monitoring state - try to load from cache first
93 state := loadStateFromCache()
94
95 // If cache is empty or we want fresh data, get initial state from API
96 if state.LastScoreboard == nil || state.LastChallenges == nil {
97 log.Println("No cached state found, fetching initial state from API...")
98 if err := updateState(ctfdClient, state, userField); err != nil {
99 log.Printf("Error getting initial state: %v", err)
100 }
101 } else {
102 log.Println("Using cached state")
103 // Still update user position in case it changed
104 if state.LastScoreboard != nil {
105 state.UserPosition = findUserPosition(state.LastScoreboard, userField)
106 }
107 }
108
109 log.Printf("Starting monitoring server (interval: %d seconds)", intervalField)
110 log.Printf("Monitoring user: %s", userField)
111
112 // Set up signal handling for graceful shutdown
113 sigChan := make(chan os.Signal, 1)
114 signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
115
116 // Main monitoring loop
117 ticker := time.NewTicker(time.Duration(intervalField) * time.Second)
118 defer ticker.Stop()
119
120 for {
121 select {
122 case <-ticker.C:
123 if err := monitorAndAlert(ctfdClient, ntfyClient, state, userField); err != nil {
124 log.Printf("Error during monitoring: %v", err)
125 } else {
126 // Save state to cache after successful monitoring
127 if err := saveStateToCache(state); err != nil {
128 log.Printf("Error saving state to cache: %v", err)
129 }
130 }
131 case <-sigChan:
132 log.Println("Received shutdown signal, saving state and stopping server...")
133 if err := saveStateToCache(state); err != nil {
134 log.Printf("Error saving final state to cache: %v", err)
135 } else {
136 log.Printf("State saved to cache: %s", getCacheFilePath())
137 }
138 return
139 }
140 }
141}
142
143func updateState(client clients.CTFdClient, state *MonitorState, username string) error {
144 // Get scoreboard
145 scoreboard, err := client.GetScoreboard()
146 if err != nil {
147 return fmt.Errorf("failed to get scoreboard: %v", err)
148 }
149 state.LastScoreboard = scoreboard
150
151 // Find user position
152 state.UserPosition = findUserPosition(scoreboard, username)
153
154 // Get challenges
155 challenges, err := client.GetChallengeList()
156 if err != nil {
157 return fmt.Errorf("failed to get challenges: %v", err)
158 }
159 state.LastChallenges = challenges
160
161 return nil
162}
163
164func monitorAndAlert(client clients.CTFdClient, ntfy *clients.NtfyClient, state *MonitorState, username string) error {
165 // Get current scoreboard
166 currentScoreboard, err := client.GetScoreboard()
167 if err != nil {
168 return fmt.Errorf("failed to get scoreboard: %v", err)
169 }
170
171 // Get current challenges
172 currentChallenges, err := client.GetChallengeList()
173 if err != nil {
174 return fmt.Errorf("failed to get challenges: %v", err)
175 }
176
177 // Check for leaderboard bypass
178 if state.LastScoreboard != nil {
179 currentPosition := findUserPosition(currentScoreboard, username)
180 if currentPosition > state.UserPosition && state.UserPosition > 0 {
181 // User was bypassed
182 msg := ntfy.NewMessage(fmt.Sprintf("🏆 You've been bypassed on the leaderboard! New position: #%d (was #%d)", currentPosition, state.UserPosition))
183 msg.Title = "CTFd Leaderboard Alert"
184 msg.Tags = []string{"warning", "leaderboard"}
185 msg.Priority = 4
186
187 if err := ntfy.SendMessage(msg); err != nil {
188 log.Printf("Failed to send bypass alert: %v", err)
189 } else {
190 log.Printf("Sent bypass alert: %s -> %d", username, currentPosition)
191 }
192 }
193 state.UserPosition = currentPosition
194 }
195
196 // Check for new challenges
197 if state.LastChallenges != nil {
198 newChallenges := findNewChallenges(state.LastChallenges, currentChallenges)
199 for _, challenge := range newChallenges {
200 msg := ntfy.NewMessage(fmt.Sprintf("🎯 New challenge released: %s (%s) - %d points", challenge.Name, challenge.Category, challenge.Value))
201 msg.Title = "New CTFd Challenge"
202 msg.Tags = []string{"challenge", "new"}
203 msg.Priority = 3
204
205 if err := ntfy.SendMessage(msg); err != nil {
206 log.Printf("Failed to send new challenge alert: %v", err)
207 } else {
208 log.Printf("Sent new challenge alert: %s", challenge.Name)
209 }
210 }
211 }
212
213 // Update state
214 state.LastScoreboard = currentScoreboard
215 state.LastChallenges = currentChallenges
216
217 return nil
218}
219
220func findUserPosition(scoreboard *clients.ScoreboardResponse, username string) int {
221 for _, team := range scoreboard.Data {
222 if team.Name == username {
223 return team.Position
224 }
225 // Also check team members
226 for _, member := range team.Members {
227 if member.Name == username {
228 return team.Position
229 }
230 }
231 }
232 return 0 // User not found
233}
234
235func findNewChallenges(oldChallenges, newChallenges *clients.ChallengeListResponse) []clients.Challenge {
236 oldMap := make(map[int]bool)
237 for _, challenge := range oldChallenges.Data {
238 oldMap[challenge.ID] = true
239 }
240
241 var newOnes []clients.Challenge
242 for _, challenge := range newChallenges.Data {
243 if !oldMap[challenge.ID] {
244 newOnes = append(newOnes, challenge)
245 }
246 }
247
248 return newOnes
249}