···
14
+
"github.com/spf13/cobra"
15
+
"github.com/taciturnaxolotl/ctfd-alerts/clients"
18
+
type MonitorState struct {
19
+
LastScoreboard *clients.ScoreboardResponse `json:"last_scoreboard"`
20
+
LastChallenges *clients.ChallengeListResponse `json:"last_challenges"`
21
+
UserPosition int `json:"user_position"`
24
+
func getCacheFilePath() string {
25
+
return filepath.Join(".", "cache.json")
28
+
func loadStateFromCache() *MonitorState {
29
+
cachePath := getCacheFilePath()
30
+
data, err := os.ReadFile(cachePath)
32
+
log.Printf("No cache file found or error reading cache: %v", err)
33
+
return &MonitorState{}
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{}
42
+
log.Printf("Loaded state from cache: %s", cachePath)
46
+
func saveStateToCache(state *MonitorState) error {
47
+
cachePath := getCacheFilePath()
48
+
data, err := json.MarshalIndent(state, "", " ")
50
+
return fmt.Errorf("error marshaling state: %v", err)
53
+
if err := os.WriteFile(cachePath, data, 0644); err != nil {
54
+
return fmt.Errorf("error writing cache file: %v", err)
60
+
// ServerCmd represents the server command
61
+
var ServerCmd = &cobra.Command{
63
+
Short: "Run monitoring server",
64
+
Long: "Continuously monitors CTFd for leaderboard changes and new challenges, sending alerts when events occur",
68
+
func runServer(cmd *cobra.Command, args []string) {
69
+
ctx := cmd.Context()
71
+
// Get CTFd client from context
72
+
ctfdClient, ok := ctx.Value("ctfd_client").(clients.CTFdClient)
74
+
log.Fatal("CTFd client not found in context")
77
+
// Get config from context
78
+
config := ctx.Value("config")
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())
85
+
ntfyConfigField := configValue.FieldByName("NtfyConfig")
86
+
ntfyTopic := ntfyConfigField.FieldByName("Topic").String()
87
+
ntfyApiBase := ntfyConfigField.FieldByName("ApiBase").String()
88
+
ntfyAccessToken := ntfyConfigField.FieldByName("AccessToken").String()
90
+
// Create ntfy client
91
+
ntfyClient := clients.NewNtfyClient(ntfyTopic, ntfyApiBase, ntfyAccessToken)
93
+
// Initialize monitoring state - try to load from cache first
94
+
state := loadStateFromCache()
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)
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)
110
+
log.Printf("Starting monitoring server (interval: %d seconds)", intervalField)
111
+
log.Printf("Monitoring user: %s", userField)
113
+
// Set up signal handling for graceful shutdown
114
+
sigChan := make(chan os.Signal, 1)
115
+
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
117
+
// Main monitoring loop
118
+
ticker := time.NewTicker(time.Duration(intervalField) * time.Second)
119
+
defer ticker.Stop()
124
+
if err := monitorAndAlert(ctfdClient, ntfyClient, state, userField); err != nil {
125
+
log.Printf("Error during monitoring: %v", err)
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)
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)
137
+
log.Printf("State saved to cache: %s", getCacheFilePath())
144
+
func updateState(client clients.CTFdClient, state *MonitorState, username string) error {
146
+
scoreboard, err := client.GetScoreboard()
148
+
return fmt.Errorf("failed to get scoreboard: %v", err)
150
+
state.LastScoreboard = scoreboard
152
+
// Find user position
153
+
state.UserPosition = findUserPosition(scoreboard, username)
156
+
challenges, err := client.GetChallengeList()
158
+
return fmt.Errorf("failed to get challenges: %v", err)
160
+
state.LastChallenges = challenges
165
+
func monitorAndAlert(client clients.CTFdClient, ntfy *clients.NtfyClient, state *MonitorState, username string) error {
166
+
// Get current scoreboard
167
+
currentScoreboard, err := client.GetScoreboard()
169
+
return fmt.Errorf("failed to get scoreboard: %v", err)
172
+
// Get current challenges
173
+
currentChallenges, err := client.GetChallengeList()
175
+
return fmt.Errorf("failed to get challenges: %v", err)
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"}
188
+
if err := ntfy.SendMessage(msg); err != nil {
189
+
log.Printf("Failed to send bypass alert: %v", err)
191
+
log.Printf("Sent bypass alert: %s -> %d", username, currentPosition)
194
+
state.UserPosition = currentPosition
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"}
206
+
if err := ntfy.SendMessage(msg); err != nil {
207
+
log.Printf("Failed to send new challenge alert: %v", err)
209
+
log.Printf("Sent new challenge alert: %s", challenge.Name)
215
+
state.LastScoreboard = currentScoreboard
216
+
state.LastChallenges = currentChallenges
221
+
func findUserPosition(scoreboard *clients.ScoreboardResponse, username string) int {
222
+
for _, team := range scoreboard.Data {
223
+
if team.Name == username {
224
+
return team.Position
226
+
// Also check team members
227
+
for _, member := range team.Members {
228
+
if member.Name == username {
229
+
return team.Position
233
+
return 0 // User not found
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
242
+
var newOnes []clients.Challenge
243
+
for _, challenge := range newChallenges.Data {
244
+
if !oldMap[challenge.ID] {
245
+
newOnes = append(newOnes, challenge)