⛳ alerts for any ctfd instance via ntfy

feat: add status command

dunkirk.sh e72f31f0 11f3a270

verified
+19
.direnv/bin/nix-direnv-reload
···
+
#!/usr/bin/env bash
+
set -e
+
if [[ ! -d "/home/kierank/Projects/ctfd-alerts" ]]; then
+
echo "Cannot find source directory; Did you move it?"
+
echo "(Looking for "/home/kierank/Projects/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/Projects/ctfd-alerts" true
+
+
# Update the mtime for .envrc.
+
# This will cause direnv to reload again - but without re-building.
+
touch "/home/kierank/Projects/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/Projects/ctfd-alerts/.envrc" "/home/kierank/Projects/ctfd-alerts/.direnv"/*.rc
+1
.envrc
···
+
use flake
+4
.gitignore
···
+
bin
+
.direnv
+
.envrc
+
config.toml
+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
+
}
+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,
+
}
+70
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"`
+
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.MonitorInterval == 0 {
+
cfg.MonitorInterval = 300
+
fmt.Println("you haven't set a monitor interval; setting to 300")
+
}
+
+
return &cfg, nil
+
}
+11
config.toml
···
+
debug = true
+
interval = 100
+
+
[ctfd]
+
api_base = "http://163.11.237.79/api/v1"
+
api_key = "ctfd_10698fd44950bf7556bc3f5e1012832dae5bddcffb1fe82191d8dd3be3641393"
+
+
[ntfy]
+
api_base = "https://ntfy.sh/"
+
acess_token = ""
+
topic = "cugencyber_ctfd"
+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.0.1";
+
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";
+
};
+
});
+
};
+
}
+41
go.mod
···
+
module github.com/taciturnaxolotl/ctfd-alerts
+
+
go 1.24.3
+
+
require (
+
github.com/charmbracelet/fang v0.2.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/bubbles v0.21.0 // indirect
+
github.com/charmbracelet/bubbletea v1.3.4 // indirect
+
github.com/charmbracelet/colorprofile v0.3.1 // indirect
+
github.com/charmbracelet/lipgloss v1.1.0 // 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/term v0.2.1 // indirect
+
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 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-localereader v0.0.1 // indirect
+
github.com/mattn/go-runewidth v0.0.16 // indirect
+
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // 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/sync v0.15.0 // indirect
+
golang.org/x/sys v0.33.0 // indirect
+
golang.org/x/text v0.26.0 // indirect
+
)
+85
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/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
+
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
+
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
+
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
+
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-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
+
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
+
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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
+
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-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
+
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/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+
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/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
+
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+
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=
+55
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/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 providing real-time updates, notifications, and status information.`,
+
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)
+
cmd.SetContext(context.WithValue(cmd.Context(), "ctfd_client", ctfdClient))
+
},
+
}
+
+
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)
+
}
+
+
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)
+
}
+
}