⛳ alerts for any ctfd instance via ntfy
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add monitoring

dunkirk.sh 5eaba51a e72f31f0

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