⛳ alerts for any ctfd instance via ntfy
at v0.1.0 7.3 kB view raw
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}