⛳ alerts for any ctfd instance via ntfy

Compare changes

Choose any two refs to compare.

+19
.direnv/bin/nix-direnv-reload
···
+
#!/usr/bin/env bash
+
set -e
+
if [[ ! -d "/home/kierank/code/personal/ctfd-alerts" ]]; then
+
echo "Cannot find source directory; Did you move it?"
+
echo "(Looking for "/home/kierank/code/personal/ctfd-alerts")"
+
echo 'Cannot force reload with this script - use "direnv reload" manually and then try again'
+
exit 1
+
fi
+
+
# rebuild the cache forcefully
+
_nix_direnv_force_reload=1 direnv exec "/home/kierank/code/personal/ctfd-alerts" true
+
+
# Update the mtime for .envrc.
+
# This will cause direnv to reload again - but without re-building.
+
touch "/home/kierank/code/personal/ctfd-alerts/.envrc"
+
+
# Also update the timestamp of whatever profile_rc we have.
+
# This makes sure that we know we are up to date.
+
touch -r "/home/kierank/code/personal/ctfd-alerts/.envrc" "/home/kierank/code/personal/ctfd-alerts/.direnv"/*.rc
+1
.envrc
···
+
use flake
.github/images/out.gif

This is a binary file and will not be displayed.

+75
.github/workflows/release.yml
···
+
name: Release
+
+
on:
+
release:
+
types: [created]
+
+
jobs:
+
build-and-upload:
+
runs-on: ubuntu-latest
+
permissions:
+
contents: write
+
+
steps:
+
- name: Checkout repository
+
uses: actions/checkout@v4
+
+
- name: Install Nix
+
uses: DeterminateSystems/nix-installer-action@main
+
+
- name: Configure Nix cache
+
uses: DeterminateSystems/magic-nix-cache-action@main
+
+
- name: Build for all platforms
+
run: |
+
nix develop -c ctfd-alerts-build
+
+
- name: Upload Linux AMD64 binary
+
uses: actions/upload-release-asset@v1
+
env:
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
with:
+
upload_url: ${{ github.event.release.upload_url }}
+
asset_path: ./bin/ctfd-alerts-linux-amd64
+
asset_name: ctfd-alerts-linux-amd64
+
asset_content_type: application/octet-stream
+
+
- name: Upload Linux ARM64 binary
+
uses: actions/upload-release-asset@v1
+
env:
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
with:
+
upload_url: ${{ github.event.release.upload_url }}
+
asset_path: ./bin/ctfd-alerts-linux-arm64
+
asset_name: ctfd-alerts-linux-arm64
+
asset_content_type: application/octet-stream
+
+
- name: Upload macOS AMD64 binary
+
uses: actions/upload-release-asset@v1
+
env:
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
with:
+
upload_url: ${{ github.event.release.upload_url }}
+
asset_path: ./bin/ctfd-alerts-darwin-amd64
+
asset_name: ctfd-alerts-darwin-amd64
+
asset_content_type: application/octet-stream
+
+
- name: Upload macOS ARM64 binary
+
uses: actions/upload-release-asset@v1
+
env:
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
with:
+
upload_url: ${{ github.event.release.upload_url }}
+
asset_path: ./bin/ctfd-alerts-darwin-arm64
+
asset_name: ctfd-alerts-darwin-arm64
+
asset_content_type: application/octet-stream
+
+
- name: Upload Windows AMD64 binary
+
uses: actions/upload-release-asset@v1
+
env:
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
with:
+
upload_url: ${{ github.event.release.upload_url }}
+
asset_path: ./bin/ctfd-alerts-windows-amd64.exe
+
asset_name: ctfd-alerts-windows-amd64.exe
+
asset_content_type: application/octet-stream
+6
.gitignore
···
+
bin
+
.direnv
+
.envrc
+
config.toml
+
cache.json
+
vendor
+25 -2
README.md
···
Sends alerts for any arbitrary [CTFd](https://ctfd.io/) instance via [ntfy](https://ntfy.sh/)
+
![vhs gif of the command being run](https://github.com/taciturnaxolotl/ctfd-alerts/blob/main/.github/images/out.gif?raw=true)
+
## Install
You can download a pre-built binary from the releases or you can use the following options
···
# Go
go install github.com/taciturnaxolotl/ctfd-alerts@latest
```
+
+
If you need a systemd service file there is one in `ctfd-alerts.service`
### Nix
···
```nix
# In your flake.nix
{
-
inputs.akami.url = "github:taciturnaxolotl/ctfd-alerts";
+
inputs.ctfd-alerts.url = "github:taciturnaxolotl/ctfd-alerts";
-
outputs = { self, nixpkgs, akami, ... }: {
+
outputs = { self, nixpkgs, ctfd-alerts, ... }: {
# Access the package as:
# ctfd-alerts.packages.${system}.default
};
}
+
```
+
+
## Config
+
+
The config for the bot is quite simple. Create a `config.toml` file in the same directory as the binary (or link to the config location with `-c ./path/to/config/config.toml`) with the following format:
+
+
```toml
+
debug = true
+
interval = 100 # defaults to 300 if unset
+
user = "echo_kieran"
+
+
[ctfd]
+
api_base = "http://163.11.237.79/api/v1"
+
api_key = "ctfd_10698fd44950bf7556bc3f5e1012832dae5bddcffb1fe82191d8dd3be3641393"
+
+
[ntfy]
+
api_base = "https://ntfy.sh/"
+
acess_token = ""
+
topic = "youralert"
```
Written in go. If you have any suggestions or issues feel free to open an issue on my [tangled](https://tangled.sh/@dunkirk.sh/ctfd-alerts) knot
+15
cassette.tape
···
+
Output .github/images/out.gif
+
Set Shell zsh
+
Set Width 1400
+
Set Height 900
+
Require ctfd-alerts-dev
+
Sleep 1s
+
Type "ctfd-alerts-dev"
+
Enter
+
Sleep 3s
+
Type "ctfd-alerts-dev status"
+
Enter
+
Sleep 4s
+
Type "ctfd-alerts-dev serve"
+
Enter
+
Sleep 5s
+184
clients/ctfd.go
···
+
package clients
+
+
import (
+
"crypto/tls"
+
"encoding/json"
+
"fmt"
+
"io"
+
"net/http"
+
"sort"
+
"strings"
+
"time"
+
)
+
+
// CTFdClient interface defines the methods required for interacting with CTFd
+
type CTFdClient interface {
+
GetScoreboard() (*ScoreboardResponse, error)
+
GetChallengeList() (*ChallengeListResponse, error)
+
}
+
+
// ctfdClient represents a CTFd API client implementation
+
type ctfdClient struct {
+
baseURL string
+
apiToken string
+
httpClient *http.Client
+
}
+
+
// ScoreboardResponse represents the top-level response from the CTFd API for scoreboard
+
type ScoreboardResponse struct {
+
Success bool `json:"success"`
+
Data []TeamStanding `json:"data"`
+
}
+
+
// TeamStanding represents a team's standing on the scoreboard
+
type TeamStanding struct {
+
Position int `json:"pos"`
+
AccountID int `json:"account_id"`
+
AccountURL string `json:"account_url"`
+
AccountType string `json:"account_type"`
+
OAuthID *string `json:"oauth_id"`
+
Name string `json:"name"`
+
Score int `json:"score"`
+
BracketID *string `json:"bracket_id"`
+
BracketName *string `json:"bracket_name"`
+
Members []Member `json:"members"`
+
}
+
+
// Member represents a team member
+
type Member struct {
+
ID int `json:"id"`
+
OAuthID *string `json:"oauth_id"`
+
Name string `json:"name"`
+
Score int `json:"score"`
+
BracketID *string `json:"bracket_id"`
+
BracketName *string `json:"bracket_name"`
+
}
+
+
// ChallengeListResponse represents the top-level response from the CTFd API for challenges
+
type ChallengeListResponse struct {
+
Success bool `json:"success"`
+
Data []Challenge `json:"data"`
+
}
+
+
// Challenge represents a CTFd challenge
+
type Challenge struct {
+
ID int `json:"id"`
+
Name string `json:"name"`
+
Description string `json:"description"`
+
Attribution string `json:"attribution"`
+
ConnectionInfo string `json:"connection_info"`
+
NextID int `json:"next_id"`
+
MaxAttempts int `json:"max_attempts"`
+
Value int `json:"value"`
+
Category string `json:"category"`
+
Type string `json:"type"`
+
State string `json:"state"`
+
Requirements map[string]any `json:"requirements"`
+
Solves int `json:"solves"`
+
SolvedByMe bool `json:"solved_by_me"`
+
}
+
+
// NewCTFdClient creates a new CTFd client with the specified base URL and API token.
+
// It configures an HTTP client with a 10-second timeout and insecure TLS verification.
+
func NewCTFdClient(baseURL, apiToken string) CTFdClient {
+
baseURL = strings.TrimSuffix(baseURL, "/")
+
+
return &ctfdClient{
+
baseURL: baseURL,
+
apiToken: apiToken,
+
httpClient: &http.Client{
+
Timeout: 10 * time.Second,
+
Transport: &http.Transport{
+
TLSClientConfig: &tls.Config{
+
InsecureSkipVerify: true,
+
},
+
},
+
},
+
}
+
}
+
+
// GetScoreboard fetches the CTFd scoreboard data from the API.
+
// Returns a ScoreboardResponse containing team standings or an error if the request fails.
+
func (c *ctfdClient) GetScoreboard() (*ScoreboardResponse, error) {
+
endpoint := "/scoreboard"
+
+
req, err := http.NewRequest("GET", c.baseURL+endpoint, nil)
+
if err != nil {
+
return nil, fmt.Errorf("error creating request: %v", err)
+
}
+
+
req.Header.Add("Accept", "application/json")
+
req.Header.Add("Authorization", "Token "+c.apiToken)
+
req.Header.Add("Content-Type", "application/json")
+
+
resp, err := c.httpClient.Do(req)
+
if err != nil {
+
return nil, fmt.Errorf("error executing request: %v", err)
+
}
+
defer resp.Body.Close()
+
+
body, err := io.ReadAll(resp.Body)
+
if err != nil {
+
return nil, fmt.Errorf("error reading response body: %v", err)
+
}
+
+
if resp.StatusCode != http.StatusOK {
+
return nil, fmt.Errorf("error response: %s", string(body))
+
}
+
+
var scoreboard ScoreboardResponse
+
if err := json.Unmarshal(body, &scoreboard); err != nil {
+
return nil, fmt.Errorf("error parsing JSON response: %v", err)
+
}
+
+
if !scoreboard.Success {
+
return nil, fmt.Errorf("API returned success=false")
+
}
+
+
return &scoreboard, nil
+
}
+
+
// GetChallengeList fetches the list of challenges from the CTFd API.
+
// Returns a ChallengeListResponse containing all challenges sorted by ID or an error if the request fails.
+
func (c *ctfdClient) GetChallengeList() (*ChallengeListResponse, error) {
+
endpoint := "/challenges"
+
+
req, err := http.NewRequest("GET", c.baseURL+endpoint, nil)
+
if err != nil {
+
return nil, fmt.Errorf("error creating request: %v", err)
+
}
+
+
req.Header.Add("Accept", "application/json")
+
req.Header.Add("Authorization", "Token "+c.apiToken)
+
req.Header.Add("Content-Type", "application/json")
+
+
resp, err := c.httpClient.Do(req)
+
if err != nil {
+
return nil, fmt.Errorf("error executing request: %v", err)
+
}
+
defer resp.Body.Close()
+
+
body, err := io.ReadAll(resp.Body)
+
if err != nil {
+
return nil, fmt.Errorf("error reading response body: %v", err)
+
}
+
+
if resp.StatusCode != http.StatusOK {
+
return nil, fmt.Errorf("error response: %s", string(body))
+
}
+
+
var challengeList ChallengeListResponse
+
if err := json.Unmarshal(body, &challengeList); err != nil {
+
return nil, fmt.Errorf("error parsing JSON response: %v", err)
+
}
+
+
sort.Slice(challengeList.Data, func(i, j int) bool {
+
return challengeList.Data[i].ID < challengeList.Data[j].ID
+
})
+
+
if !challengeList.Success {
+
return nil, fmt.Errorf("API returned success=false")
+
}
+
+
return &challengeList, nil
+
}
+107
clients/ntfy.go
···
+
package clients
+
+
import (
+
"bytes"
+
"crypto/tls"
+
"encoding/json"
+
"fmt"
+
"io"
+
"net/http"
+
"strings"
+
"time"
+
)
+
+
// NtfyMessage represents a notification message to be sent via ntfy
+
type NtfyMessage struct {
+
Topic string `json:"topic"`
+
Message string `json:"message,omitempty"`
+
Title string `json:"title,omitempty"`
+
Tags []string `json:"tags,omitempty"`
+
Priority int `json:"priority,omitempty"`
+
Click string `json:"click,omitempty"`
+
Actions []map[string]any `json:"actions,omitempty"`
+
Attach string `json:"attach,omitempty"`
+
Filename string `json:"filename,omitempty"`
+
Markdown bool `json:"markdown,omitempty"`
+
Icon string `json:"icon,omitempty"`
+
Email string `json:"email,omitempty"`
+
Call string `json:"call,omitempty"`
+
Delay string `json:"delay,omitempty"`
+
}
+
+
// NtfyClient represents a client for sending notifications via ntfy.sh
+
type NtfyClient struct {
+
Topic string
+
ServerURL string
+
HTTPClient *http.Client
+
// Optional authentication token
+
AccessToken string
+
}
+
+
// NewNtfyClient creates a new ntfy client with the specified topic and server URL.
+
// It configures an HTTP client with a 10-second timeout and insecure TLS verification.
+
func NewNtfyClient(topic, serverURL string, accessToken string) *NtfyClient {
+
serverURL = strings.TrimSuffix(serverURL, "/")
+
if serverURL == "" {
+
serverURL = "https://ntfy.sh"
+
}
+
+
return &NtfyClient{
+
Topic: topic,
+
ServerURL: serverURL,
+
AccessToken: accessToken,
+
HTTPClient: &http.Client{
+
Timeout: 10 * time.Second,
+
Transport: &http.Transport{
+
TLSClientConfig: &tls.Config{
+
InsecureSkipVerify: true,
+
},
+
},
+
},
+
}
+
}
+
+
// NewMessage creates a new NtfyMessage with the specified message content
+
func (c *NtfyClient) NewMessage(messageText string) *NtfyMessage {
+
return &NtfyMessage{
+
Topic: c.Topic,
+
Message: messageText,
+
}
+
}
+
+
// SendMessage sends a structured NtfyMessage
+
func (c *NtfyClient) SendMessage(msg *NtfyMessage) error {
+
// Ensure topic is set
+
if msg.Topic == "" {
+
msg.Topic = c.Topic
+
}
+
+
payload, err := json.Marshal(msg)
+
if err != nil {
+
return fmt.Errorf("error marshaling message: %v", err)
+
}
+
+
req, err := http.NewRequest("POST", c.ServerURL, bytes.NewBuffer(payload))
+
if err != nil {
+
return fmt.Errorf("error creating request: %v", err)
+
}
+
+
req.Header.Add("Content-Type", "application/json")
+
+
if c.AccessToken != "" {
+
req.Header.Add("Authorization", "Bearer "+c.AccessToken)
+
}
+
+
resp, err := c.HTTPClient.Do(req)
+
if err != nil {
+
return fmt.Errorf("error executing request: %v", err)
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+
body, _ := io.ReadAll(resp.Body)
+
return fmt.Errorf("error response (status %d): %s", resp.StatusCode, string(body))
+
}
+
+
return nil
+
}
+249
cmd/serve/serve.go
···
+
package serve
+
+
import (
+
"encoding/json"
+
"fmt"
+
"log"
+
"os"
+
"os/signal"
+
"path/filepath"
+
"reflect"
+
"syscall"
+
"time"
+
+
"github.com/spf13/cobra"
+
"github.com/taciturnaxolotl/ctfd-alerts/clients"
+
)
+
+
type MonitorState struct {
+
LastScoreboard *clients.ScoreboardResponse `json:"last_scoreboard"`
+
LastChallenges *clients.ChallengeListResponse `json:"last_challenges"`
+
UserPosition int `json:"user_position"`
+
}
+
+
func getCacheFilePath() string {
+
return filepath.Join(".", "cache.json")
+
}
+
+
func loadStateFromCache() *MonitorState {
+
cachePath := getCacheFilePath()
+
data, err := os.ReadFile(cachePath)
+
if err != nil {
+
log.Printf("No cache file found or error reading cache: %v", err)
+
return &MonitorState{}
+
}
+
+
var state MonitorState
+
if err := json.Unmarshal(data, &state); err != nil {
+
log.Printf("Error parsing cache file: %v", err)
+
return &MonitorState{}
+
}
+
+
log.Printf("Loaded state from cache: %s", cachePath)
+
return &state
+
}
+
+
func saveStateToCache(state *MonitorState) error {
+
cachePath := getCacheFilePath()
+
data, err := json.MarshalIndent(state, "", " ")
+
if err != nil {
+
return fmt.Errorf("error marshaling state: %v", err)
+
}
+
+
if err := os.WriteFile(cachePath, data, 0644); err != nil {
+
return fmt.Errorf("error writing cache file: %v", err)
+
}
+
+
return nil
+
}
+
+
var ServeCmd = &cobra.Command{
+
Use: "serve",
+
Short: "Run monitoring server",
+
Long: "Continuously monitors CTFd for leaderboard changes and new challenges, sending alerts when events occur",
+
Run: runServer,
+
}
+
+
func runServer(cmd *cobra.Command, args []string) {
+
ctx := cmd.Context()
+
+
// Get CTFd client from context
+
ctfdClient, ok := ctx.Value("ctfd_client").(clients.CTFdClient)
+
if !ok {
+
log.Fatal("CTFd client not found in context")
+
}
+
+
// Get config from context
+
config := ctx.Value("config")
+
+
// Use reflection to access config fields
+
configValue := reflect.ValueOf(config).Elem()
+
userField := configValue.FieldByName("User").String()
+
intervalField := int(configValue.FieldByName("MonitorInterval").Int())
+
+
ntfyConfigField := configValue.FieldByName("NtfyConfig")
+
ntfyTopic := ntfyConfigField.FieldByName("Topic").String()
+
ntfyApiBase := ntfyConfigField.FieldByName("ApiBase").String()
+
ntfyAccessToken := ntfyConfigField.FieldByName("AccessToken").String()
+
+
// Create ntfy client
+
ntfyClient := clients.NewNtfyClient(ntfyTopic, ntfyApiBase, ntfyAccessToken)
+
+
// Initialize monitoring state - try to load from cache first
+
state := loadStateFromCache()
+
+
// If cache is empty or we want fresh data, get initial state from API
+
if state.LastScoreboard == nil || state.LastChallenges == nil {
+
log.Println("No cached state found, fetching initial state from API...")
+
if err := updateState(ctfdClient, state, userField); err != nil {
+
log.Printf("Error getting initial state: %v", err)
+
}
+
} else {
+
log.Println("Using cached state")
+
// Still update user position in case it changed
+
if state.LastScoreboard != nil {
+
state.UserPosition = findUserPosition(state.LastScoreboard, userField)
+
}
+
}
+
+
log.Printf("Starting monitoring server (interval: %d seconds)", intervalField)
+
log.Printf("Monitoring user: %s", userField)
+
+
// Set up signal handling for graceful shutdown
+
sigChan := make(chan os.Signal, 1)
+
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
+
+
// Main monitoring loop
+
ticker := time.NewTicker(time.Duration(intervalField) * time.Second)
+
defer ticker.Stop()
+
+
for {
+
select {
+
case <-ticker.C:
+
if err := monitorAndAlert(ctfdClient, ntfyClient, state, userField); err != nil {
+
log.Printf("Error during monitoring: %v", err)
+
} else {
+
// Save state to cache after successful monitoring
+
if err := saveStateToCache(state); err != nil {
+
log.Printf("Error saving state to cache: %v", err)
+
}
+
}
+
case <-sigChan:
+
log.Println("Received shutdown signal, saving state and stopping server...")
+
if err := saveStateToCache(state); err != nil {
+
log.Printf("Error saving final state to cache: %v", err)
+
} else {
+
log.Printf("State saved to cache: %s", getCacheFilePath())
+
}
+
return
+
}
+
}
+
}
+
+
func updateState(client clients.CTFdClient, state *MonitorState, username string) error {
+
// Get scoreboard
+
scoreboard, err := client.GetScoreboard()
+
if err != nil {
+
return fmt.Errorf("failed to get scoreboard: %v", err)
+
}
+
state.LastScoreboard = scoreboard
+
+
// Find user position
+
state.UserPosition = findUserPosition(scoreboard, username)
+
+
// Get challenges
+
challenges, err := client.GetChallengeList()
+
if err != nil {
+
return fmt.Errorf("failed to get challenges: %v", err)
+
}
+
state.LastChallenges = challenges
+
+
return nil
+
}
+
+
func monitorAndAlert(client clients.CTFdClient, ntfy *clients.NtfyClient, state *MonitorState, username string) error {
+
// Get current scoreboard
+
currentScoreboard, err := client.GetScoreboard()
+
if err != nil {
+
return fmt.Errorf("failed to get scoreboard: %v", err)
+
}
+
+
// Get current challenges
+
currentChallenges, err := client.GetChallengeList()
+
if err != nil {
+
return fmt.Errorf("failed to get challenges: %v", err)
+
}
+
+
// Check for leaderboard bypass
+
if state.LastScoreboard != nil {
+
currentPosition := findUserPosition(currentScoreboard, username)
+
if currentPosition > state.UserPosition && state.UserPosition > 0 {
+
// User was bypassed
+
msg := ntfy.NewMessage(fmt.Sprintf("🏆 You've been bypassed on the leaderboard! New position: #%d (was #%d)", currentPosition, state.UserPosition))
+
msg.Title = "CTFd Leaderboard Alert"
+
msg.Tags = []string{"warning", "leaderboard"}
+
msg.Priority = 4
+
+
if err := ntfy.SendMessage(msg); err != nil {
+
log.Printf("Failed to send bypass alert: %v", err)
+
} else {
+
log.Printf("Sent bypass alert: %s -> %d", username, currentPosition)
+
}
+
}
+
state.UserPosition = currentPosition
+
}
+
+
// Check for new challenges
+
if state.LastChallenges != nil {
+
newChallenges := findNewChallenges(state.LastChallenges, currentChallenges)
+
for _, challenge := range newChallenges {
+
msg := ntfy.NewMessage(fmt.Sprintf("🎯 New challenge released: %s (%s) - %d points", challenge.Name, challenge.Category, challenge.Value))
+
msg.Title = "New CTFd Challenge"
+
msg.Tags = []string{"challenge", "new"}
+
msg.Priority = 3
+
+
if err := ntfy.SendMessage(msg); err != nil {
+
log.Printf("Failed to send new challenge alert: %v", err)
+
} else {
+
log.Printf("Sent new challenge alert: %s", challenge.Name)
+
}
+
}
+
}
+
+
// Update state
+
state.LastScoreboard = currentScoreboard
+
state.LastChallenges = currentChallenges
+
+
return nil
+
}
+
+
func findUserPosition(scoreboard *clients.ScoreboardResponse, username string) int {
+
for _, team := range scoreboard.Data {
+
if team.Name == username {
+
return team.Position
+
}
+
// Also check team members
+
for _, member := range team.Members {
+
if member.Name == username {
+
return team.Position
+
}
+
}
+
}
+
return 0 // User not found
+
}
+
+
func findNewChallenges(oldChallenges, newChallenges *clients.ChallengeListResponse) []clients.Challenge {
+
oldMap := make(map[int]bool)
+
for _, challenge := range oldChallenges.Data {
+
oldMap[challenge.ID] = true
+
}
+
+
var newOnes []clients.Challenge
+
for _, challenge := range newChallenges.Data {
+
if !oldMap[challenge.ID] {
+
newOnes = append(newOnes, challenge)
+
}
+
}
+
+
return newOnes
+
}
+146
cmd/status/dashboard.go
···
+
package status
+
+
import (
+
"fmt"
+
"log"
+
"strings"
+
+
"github.com/charmbracelet/lipgloss"
+
"github.com/charmbracelet/lipgloss/table"
+
"github.com/spf13/cobra"
+
"github.com/taciturnaxolotl/ctfd-alerts/clients"
+
)
+
+
var (
+
// Colors
+
purple = lipgloss.AdaptiveColor{Light: "#9D8EFF", Dark: "#7D56F4"}
+
gray = lipgloss.AdaptiveColor{Light: "#BEBEBE", Dark: "#4A4A4A"}
+
lightGray = lipgloss.AdaptiveColor{Light: "#CCCCCC", Dark: "#3A3A3A"}
+
green = lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}
+
red = lipgloss.AdaptiveColor{Light: "#F52D4F", Dark: "#FF5F7A"}
+
+
// Styles
+
titleStyle = lipgloss.NewStyle().
+
Foreground(purple).
+
Bold(true)
+
+
containerStyle = lipgloss.NewStyle().
+
BorderStyle(lipgloss.RoundedBorder()).
+
BorderForeground(purple).
+
Padding(1)
+
+
headerStyle = lipgloss.NewStyle().
+
Foreground(purple).
+
Bold(true).
+
Align(lipgloss.Center)
+
+
oddRowStyle = lipgloss.NewStyle()
+
+
evenRowStyle = lipgloss.NewStyle()
+
// Status indicators
+
solvedStyle = lipgloss.NewStyle().Foreground(green).SetString("✓")
+
unsolvedStyle = lipgloss.NewStyle().Foreground(red).SetString("✗")
+
)
+
+
func truncateString(s string, maxLen int) string {
+
if len(s) <= maxLen {
+
return s
+
}
+
return s[:maxLen-3] + "..."
+
}
+
+
func createTable(headers []string, rows [][]string) string {
+
+
t := table.New().
+
Border(lipgloss.ASCIIBorder()).
+
BorderStyle(lipgloss.NewStyle().Foreground(lightGray)).
+
StyleFunc(func(row, col int) lipgloss.Style {
+
switch {
+
case row == table.HeaderRow:
+
return headerStyle
+
case row%2 == 0:
+
return evenRowStyle
+
default:
+
return oddRowStyle
+
}
+
}).
+
Headers(headers...).
+
Rows(rows...)
+
+
return t.Render()
+
}
+
+
func runDashboard(cmd *cobra.Command, args []string) {
+
// Get CTFd client from root command context
+
ctfdClient := cmd.Context().Value("ctfd_client").(CTFdClient)
+
+
// Get scoreboard data
+
scoreboard, err := ctfdClient.GetScoreboard()
+
if err != nil {
+
log.Fatalf("Error fetching scoreboard: %v", err)
+
}
+
+
// Prepare scoreboard data
+
scoreboardHeaders := []string{"Pos", "Team", "Score", "Members"}
+
scoreboardRows := make([][]string, len(scoreboard.Data))
+
+
for i, team := range scoreboard.Data {
+
memberNames := make([]string, 0, len(team.Members))
+
for _, member := range team.Members {
+
memberNames = append(memberNames, member.Name)
+
}
+
scoreboardRows[i] = []string{
+
fmt.Sprintf("%d", team.Position),
+
truncateString(team.Name, 24),
+
fmt.Sprintf("%d", team.Score),
+
truncateString(strings.Join(memberNames, ", "), 39),
+
}
+
}
+
+
// Get challenge list
+
challenges, err := ctfdClient.GetChallengeList()
+
if err != nil {
+
log.Fatalf("Error fetching challenges: %v", err)
+
}
+
+
// Prepare challenge data
+
challengeHeaders := []string{"ID", "Name", "Category", "Value", "Solves", "Solved"}
+
challengeRows := make([][]string, len(challenges.Data))
+
+
for i, challenge := range challenges.Data {
+
solvedStatus := unsolvedStyle.String()
+
if challenge.SolvedByMe {
+
solvedStatus = solvedStyle.String()
+
}
+
challengeRows[i] = []string{
+
fmt.Sprintf("%d", challenge.ID),
+
truncateString(challenge.Name, 24),
+
truncateString(challenge.Category, 14),
+
fmt.Sprintf("%d", challenge.Value),
+
fmt.Sprintf("%d", challenge.Solves),
+
solvedStatus,
+
}
+
}
+
+
// Build and render the complete dashboard
+
var dashboard strings.Builder
+
+
// Scoreboard section
+
dashboard.WriteString(titleStyle.Render(fmt.Sprintf("CTFd Scoreboard [%d]", len(scoreboard.Data))))
+
dashboard.WriteString("\n")
+
dashboard.WriteString(createTable(scoreboardHeaders, scoreboardRows))
+
+
// Challenges section
+
dashboard.WriteString("\n\n")
+
dashboard.WriteString(titleStyle.Render(fmt.Sprintf("CTFd Challenges [%d]", len(challenges.Data))))
+
dashboard.WriteString("\n")
+
dashboard.WriteString(createTable(challengeHeaders, challengeRows))
+
+
// Render the final output
+
fmt.Print("\n")
+
fmt.Print(dashboard.String())
+
fmt.Print("\n")
+
}
+
+
// CTFdClient alias for the client interface from the clients package
+
type CTFdClient = clients.CTFdClient
+13
cmd/status/status.go
···
+
package status
+
+
import (
+
"github.com/spf13/cobra"
+
)
+
+
// StatusCmd represents the status command
+
var StatusCmd = &cobra.Command{
+
Use: "status",
+
Short: "Show CTFd status information",
+
Long: "Shows the current CTFd scoreboard and list of challenges in a tabular format",
+
Run: runDashboard,
+
}
+75
config.go
···
+
package main
+
+
import (
+
"errors"
+
"fmt"
+
"os"
+
"strings"
+
+
"github.com/pelletier/go-toml/v2"
+
)
+
+
type CTFdConfig struct {
+
ApiBase string `toml:"api_base"`
+
ApiKey string `toml:"api_key"`
+
}
+
+
type NtfyConfig struct {
+
ApiBase string `toml:"api_base"`
+
AccessToken string `toml:"acess_token"`
+
Topic string `toml:"topic"`
+
}
+
+
type Config struct {
+
Debug bool `toml:"debug"`
+
User string `toml:"user"`
+
CTFdConfig CTFdConfig `toml:"ctfd"`
+
NtfyConfig NtfyConfig `toml:"ntfy"`
+
MonitorInterval int `toml:"interval"`
+
}
+
+
var config *Config
+
+
func loadConfig(path string) (*Config, error) {
+
data, err := os.ReadFile(path)
+
if err != nil {
+
return nil, err
+
}
+
+
var cfg Config
+
if err := toml.Unmarshal(data, &cfg); err != nil {
+
return nil, err
+
}
+
+
if cfg.CTFdConfig.ApiBase == "" {
+
return nil, errors.New("ctfd api_base URL cannot be empty")
+
}
+
+
if cfg.CTFdConfig.ApiKey == "" {
+
return nil, errors.New("ctfd api_key cannot be empty")
+
}
+
+
// Check API key format (should start with ctfd_ followed by 64 hex characters)
+
if len(cfg.CTFdConfig.ApiKey) != 69 || !strings.HasPrefix(cfg.CTFdConfig.ApiKey, "ctfd_") {
+
return nil, errors.New("ctfd api_key must be in the format ctfd_<64 hex characters> not " + cfg.CTFdConfig.ApiKey)
+
}
+
+
if cfg.NtfyConfig.ApiBase == "" {
+
return nil, errors.New("ntfy api_base URL cannot be empty")
+
}
+
+
if cfg.NtfyConfig.Topic == "" {
+
return nil, errors.New("ntfy topic cannot be empty")
+
}
+
+
if cfg.User == "" {
+
return nil, errors.New("user cannot be empty")
+
}
+
+
if cfg.MonitorInterval == 0 {
+
cfg.MonitorInterval = 300
+
fmt.Println("you haven't set a monitor interval; setting to 300")
+
}
+
+
return &cfg, nil
+
}
+16
ctfd-alerts.service
···
+
[Unit]
+
Description=ctfd alerts server
+
After=network.target
+
+
+
[Service]
+
ExecStart=/home/pi/go/bin/ctfd-alerts serve
+
Restart=always
+
User=pi
+
WorkingDirectory=/home/pi/ctfd-alerts
+
StandardOutput=journal
+
StandardError=journal
+
LimitNOFILE=65536
+
+
[Install]
+
WantedBy=multi-user.target
+27
flake.lock
···
+
{
+
"nodes": {
+
"nixpkgs": {
+
"locked": {
+
"lastModified": 1750741721,
+
"narHash": "sha256-Z0djmTa1YmnGMfE9jEe05oO4zggjDmxOGKwt844bUhE=",
+
"owner": "NixOS",
+
"repo": "nixpkgs",
+
"rev": "4b1164c3215f018c4442463a27689d973cffd750",
+
"type": "github"
+
},
+
"original": {
+
"owner": "NixOS",
+
"ref": "nixos-unstable",
+
"repo": "nixpkgs",
+
"type": "github"
+
}
+
},
+
"root": {
+
"inputs": {
+
"nixpkgs": "nixpkgs"
+
}
+
}
+
},
+
"root": "root",
+
"version": 7
+
}
+96
flake.nix
···
+
{
+
description = "⛳ sending alerts for leaderboard changes and new challenges on any ctfd.io instance";
+
+
inputs = {
+
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+
};
+
+
outputs = { self, nixpkgs }:
+
let
+
allSystems = [
+
"x86_64-linux" # 64-bit Intel/AMD Linux
+
"aarch64-linux" # 64-bit ARM Linux
+
"x86_64-darwin" # 64-bit Intel macOS
+
"aarch64-darwin" # 64-bit ARM macOS
+
];
+
forAllSystems = f: nixpkgs.lib.genAttrs allSystems (system: f {
+
pkgs = import nixpkgs { inherit system; };
+
});
+
in
+
{
+
packages = forAllSystems ({ pkgs }: {
+
default = pkgs.buildGoModule {
+
pname = "ctfd-alerts";
+
version = "0.1.0";
+
subPackages = [ "." ]; # Build from root directory
+
src = self;
+
vendorHash = null;
+
};
+
});
+
+
devShells = forAllSystems ({ pkgs }: {
+
default = pkgs.mkShell {
+
buildInputs = with pkgs; [
+
go
+
gopls
+
gotools
+
go-tools
+
(pkgs.writeShellScriptBin "ctfd-alerts-dev" ''
+
go build -o ./bin/ctfd-alerts
+
./bin/ctfd-alerts "$@" || true
+
'')
+
(pkgs.writeShellScriptBin "ctfd-alerts-build" ''
+
echo "Building ctfd-alerts binaries for all platforms..."
+
mkdir -p $PWD/bin
+
+
# Build for Linux (64-bit)
+
echo "Building for Linux (x86_64)..."
+
GOOS=linux GOARCH=amd64 go build -o $PWD/bin/ctfd-alerts-linux-amd64
+
+
# Build for Linux ARM (64-bit)
+
echo "Building for Linux (aarch64)..."
+
GOOS=linux GOARCH=arm64 go build -o $PWD/bin/ctfd-alerts-linux-arm64
+
+
# Build for macOS (64-bit Intel)
+
echo "Building for macOS (x86_64)..."
+
GOOS=darwin GOARCH=amd64 go build -o $PWD/bin/ctfd-alerts-darwin-amd64
+
+
# Build for macOS ARM (64-bit)
+
echo "Building for macOS (aarch64)..."
+
GOOS=darwin GOARCH=arm64 go build -o $PWD/bin/ctfd-alerts-darwin-arm64
+
+
# Build for Windows (64-bit)
+
echo "Building for Windows (x86_64)..."
+
GOOS=windows GOARCH=amd64 go build -o $PWD/bin/ctfd-alerts-windows-amd64.exe
+
+
echo "All binaries built successfully in $PWD/bin/"
+
ls -la $PWD/bin/
+
'')
+
];
+
+
shellHook = ''
+
export PATH=$PATH:$PWD/bin
+
mkdir -p $PWD/bin
+
'';
+
};
+
});
+
+
apps = forAllSystems ({ pkgs }: {
+
default = {
+
type = "app";
+
program = "${self.packages.${pkgs.system}.default}/bin/ctfd-alerts";
+
};
+
ctfd-alerts-dev = {
+
type = "app";
+
program = toString (pkgs.writeShellScript "ctfd-alerts-dev" ''
+
go build -o ./bin/ctfd-alerts ./main.go
+
./bin/ctfd-alerts $* || true
+
'');
+
};
+
ctfd-alerts-build = {
+
type = "app";
+
program = "${self.devShells.${pkgs.system}.default.inputDerivation}/bin/ctfd-alerts-build";
+
};
+
});
+
};
+
}
+36
go.mod
···
+
module github.com/taciturnaxolotl/ctfd-alerts
+
+
go 1.24.3
+
+
require (
+
github.com/charmbracelet/fang v0.2.0
+
github.com/charmbracelet/lipgloss v1.1.0
+
github.com/pelletier/go-toml/v2 v2.2.4
+
github.com/spf13/cobra v1.9.1
+
)
+
+
require (
+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+
github.com/charmbracelet/colorprofile v0.3.1 // indirect
+
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 // indirect
+
github.com/charmbracelet/x/ansi v0.9.3 // indirect
+
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
+
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250623112707-45752038d08d // indirect
+
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 // indirect
+
github.com/charmbracelet/x/term v0.2.1 // indirect
+
github.com/inconshreveable/mousetrap v1.1.0 // indirect
+
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+
github.com/mattn/go-isatty v0.0.20 // indirect
+
github.com/mattn/go-runewidth v0.0.16 // indirect
+
github.com/muesli/cancelreader v0.2.2 // indirect
+
github.com/muesli/mango v0.2.0 // indirect
+
github.com/muesli/mango-cobra v1.2.0 // indirect
+
github.com/muesli/mango-pflag v0.1.0 // indirect
+
github.com/muesli/roff v0.1.0 // indirect
+
github.com/muesli/termenv v0.16.0 // indirect
+
github.com/rivo/uniseg v0.4.7 // indirect
+
github.com/spf13/pflag v1.0.6 // indirect
+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+
golang.org/x/sys v0.33.0 // indirect
+
golang.org/x/text v0.26.0 // indirect
+
)
+71
go.sum
···
+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
+
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
+
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
+
github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
+
github.com/charmbracelet/fang v0.2.0 h1:F2sK2Zjy9kRYz/xUSF1o89DNj2BHKpxVKT7TA21KZi0=
+
github.com/charmbracelet/fang v0.2.0/go.mod h1:TPpME1GkB6/4uR4wXmPnugTCkqRLgZkWSH+aMds6454=
+
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 h1:SOylT6+BQzPHEjn15TIzawBPVD0QmhKXbcb3jY0ZIKU=
+
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
+
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
+
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
+
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250623112707-45752038d08d h1:Y34SmGfNOuc7NxbbSXJDvIDv3BFNhj4LGWPxqk+YoNo=
+
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250623112707-45752038d08d/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
+
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
+
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
+
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+
github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ=
+
github.com/muesli/mango v0.2.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
+
github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg=
+
github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA=
+
github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
+
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
+
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
+
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
+
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
+
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
+
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
+
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
+
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
+
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
+
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+66
main.go
···
+
package main
+
+
import (
+
"context"
+
"log"
+
"os"
+
+
"github.com/charmbracelet/fang"
+
"github.com/spf13/cobra"
+
"github.com/taciturnaxolotl/ctfd-alerts/clients"
+
"github.com/taciturnaxolotl/ctfd-alerts/cmd/serve"
+
"github.com/taciturnaxolotl/ctfd-alerts/cmd/status"
+
)
+
+
var (
+
debugLog *log.Logger
+
)
+
+
// rootCmd represents the base command
+
var cmd = &cobra.Command{
+
Use: "ctfd-alerts",
+
Short: "A tool for monitoring CTFd competitions",
+
Long: ` _ __ _ _ _
+
___| |_ / _| __| | __ _| | ___ _ __| |_ ___
+
/ __| __| |_ / _, |_____ / _ | |/ _ \ '__| __/ __|
+
| (__| |_| _| (_| |_____| (_| | | __/ | | |_\__ \
+
\___|\__|_| \__,_| \__,_|_|\___|_| \__|___/
+
+
ctfd-alerts is a command-line tool that helps you monitor CTFd-based
+
competitions by sending you ntfy notifications when someone bypasses
+
you or a new challenge is announced. You can also use the fancy status command :)`,
+
PersistentPreRun: func(cmd *cobra.Command, args []string) {
+
configFile, _ := cmd.Flags().GetString("config")
+
var err error
+
config, err = loadConfig(configFile)
+
if err != nil {
+
log.Fatalf("Error loading config: %v", err)
+
}
+
+
setupLogging(config.Debug)
+
+
// Create a new CTFd client and add it to context
+
ctfdClient := clients.NewCTFdClient(config.CTFdConfig.ApiBase, config.CTFdConfig.ApiKey)
+
ctx := context.WithValue(cmd.Context(), "ctfd_client", ctfdClient)
+
ctx = context.WithValue(ctx, "config", config)
+
cmd.SetContext(ctx)
+
},
+
}
+
+
func init() {
+
// Add persistent flags that work across all commands
+
cmd.PersistentFlags().StringP("config", "c", "config.toml", "config file path")
+
+
// Add commands
+
cmd.AddCommand(status.StatusCmd)
+
cmd.AddCommand(serve.ServeCmd)
+
}
+
+
func main() {
+
if err := fang.Execute(
+
context.Background(),
+
cmd,
+
); err != nil {
+
os.Exit(1)
+
}
+
}
+55
types/types.go
···
+
package types
+
+
// ScoreboardResponse represents the top-level response from the CTFd API for scoreboard
+
type ScoreboardResponse struct {
+
Success bool `json:"success"`
+
Data []TeamStanding `json:"data"`
+
}
+
+
// TeamStanding represents a team's standing on the scoreboard
+
type TeamStanding struct {
+
Position int `json:"pos"`
+
AccountID int `json:"account_id"`
+
AccountURL string `json:"account_url"`
+
AccountType string `json:"account_type"`
+
OAuthID *string `json:"oauth_id"`
+
Name string `json:"name"`
+
Score int `json:"score"`
+
BracketID *string `json:"bracket_id"`
+
BracketName *string `json:"bracket_name"`
+
Members []Member `json:"members"`
+
}
+
+
// Member represents a team member
+
type Member struct {
+
ID int `json:"id"`
+
OAuthID *string `json:"oauth_id"`
+
Name string `json:"name"`
+
Score int `json:"score"`
+
BracketID *string `json:"bracket_id"`
+
BracketName *string `json:"bracket_name"`
+
}
+
+
// ChallengeListResponse represents the top-level response from the CTFd API for challenges
+
type ChallengeListResponse struct {
+
Success bool `json:"success"`
+
Data []Challenge `json:"data"`
+
}
+
+
// Challenge represents a CTFd challenge
+
type Challenge struct {
+
ID int `json:"id"`
+
Name string `json:"name"`
+
Description string `json:"description"`
+
Attribution string `json:"attribution"`
+
ConnectionInfo string `json:"connection_info"`
+
NextID int `json:"next_id"`
+
MaxAttempts int `json:"max_attempts"`
+
Value int `json:"value"`
+
Category string `json:"category"`
+
Type string `json:"type"`
+
State string `json:"state"`
+
Requirements map[string]any `json:"requirements"`
+
Solves int `json:"solves"`
+
SolvedByMe bool `json:"solved_by_me"`
+
}
+15
utils.go
···
+
package main
+
+
import (
+
"io"
+
"log"
+
"os"
+
)
+
+
func setupLogging(debug bool) {
+
if debug {
+
debugLog = log.New(os.Stdout, "DEBUG: ", log.Ltime|log.Lmicroseconds)
+
} else {
+
debugLog = log.New(io.Discard, "", 0)
+
}
+
}