⛳ 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 status command

+929
+19
.direnv/bin/nix-direnv-reload
··· 1 + #!/usr/bin/env bash 2 + set -e 3 + if [[ ! -d "/home/kierank/Projects/ctfd-alerts" ]]; then 4 + echo "Cannot find source directory; Did you move it?" 5 + echo "(Looking for "/home/kierank/Projects/ctfd-alerts")" 6 + echo 'Cannot force reload with this script - use "direnv reload" manually and then try again' 7 + exit 1 8 + fi 9 + 10 + # rebuild the cache forcefully 11 + _nix_direnv_force_reload=1 direnv exec "/home/kierank/Projects/ctfd-alerts" true 12 + 13 + # Update the mtime for .envrc. 14 + # This will cause direnv to reload again - but without re-building. 15 + touch "/home/kierank/Projects/ctfd-alerts/.envrc" 16 + 17 + # Also update the timestamp of whatever profile_rc we have. 18 + # This makes sure that we know we are up to date. 19 + touch -r "/home/kierank/Projects/ctfd-alerts/.envrc" "/home/kierank/Projects/ctfd-alerts/.direnv"/*.rc
+1
.envrc
··· 1 + use flake
+4
.gitignore
··· 1 + bin 2 + .direnv 3 + .envrc 4 + config.toml
+184
clients/ctfd.go
··· 1 + package clients 2 + 3 + import ( 4 + "crypto/tls" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "sort" 10 + "strings" 11 + "time" 12 + ) 13 + 14 + // CTFdClient interface defines the methods required for interacting with CTFd 15 + type CTFdClient interface { 16 + GetScoreboard() (*ScoreboardResponse, error) 17 + GetChallengeList() (*ChallengeListResponse, error) 18 + } 19 + 20 + // ctfdClient represents a CTFd API client implementation 21 + type ctfdClient struct { 22 + baseURL string 23 + apiToken string 24 + httpClient *http.Client 25 + } 26 + 27 + // ScoreboardResponse represents the top-level response from the CTFd API for scoreboard 28 + type ScoreboardResponse struct { 29 + Success bool `json:"success"` 30 + Data []TeamStanding `json:"data"` 31 + } 32 + 33 + // TeamStanding represents a team's standing on the scoreboard 34 + type TeamStanding struct { 35 + Position int `json:"pos"` 36 + AccountID int `json:"account_id"` 37 + AccountURL string `json:"account_url"` 38 + AccountType string `json:"account_type"` 39 + OAuthID *string `json:"oauth_id"` 40 + Name string `json:"name"` 41 + Score int `json:"score"` 42 + BracketID *string `json:"bracket_id"` 43 + BracketName *string `json:"bracket_name"` 44 + Members []Member `json:"members"` 45 + } 46 + 47 + // Member represents a team member 48 + type Member struct { 49 + ID int `json:"id"` 50 + OAuthID *string `json:"oauth_id"` 51 + Name string `json:"name"` 52 + Score int `json:"score"` 53 + BracketID *string `json:"bracket_id"` 54 + BracketName *string `json:"bracket_name"` 55 + } 56 + 57 + // ChallengeListResponse represents the top-level response from the CTFd API for challenges 58 + type ChallengeListResponse struct { 59 + Success bool `json:"success"` 60 + Data []Challenge `json:"data"` 61 + } 62 + 63 + // Challenge represents a CTFd challenge 64 + type Challenge struct { 65 + ID int `json:"id"` 66 + Name string `json:"name"` 67 + Description string `json:"description"` 68 + Attribution string `json:"attribution"` 69 + ConnectionInfo string `json:"connection_info"` 70 + NextID int `json:"next_id"` 71 + MaxAttempts int `json:"max_attempts"` 72 + Value int `json:"value"` 73 + Category string `json:"category"` 74 + Type string `json:"type"` 75 + State string `json:"state"` 76 + Requirements map[string]any `json:"requirements"` 77 + Solves int `json:"solves"` 78 + SolvedByMe bool `json:"solved_by_me"` 79 + } 80 + 81 + // NewCTFdClient creates a new CTFd client with the specified base URL and API token. 82 + // It configures an HTTP client with a 10-second timeout and insecure TLS verification. 83 + func NewCTFdClient(baseURL, apiToken string) CTFdClient { 84 + baseURL = strings.TrimSuffix(baseURL, "/") 85 + 86 + return &ctfdClient{ 87 + baseURL: baseURL, 88 + apiToken: apiToken, 89 + httpClient: &http.Client{ 90 + Timeout: 10 * time.Second, 91 + Transport: &http.Transport{ 92 + TLSClientConfig: &tls.Config{ 93 + InsecureSkipVerify: true, 94 + }, 95 + }, 96 + }, 97 + } 98 + } 99 + 100 + // GetScoreboard fetches the CTFd scoreboard data from the API. 101 + // Returns a ScoreboardResponse containing team standings or an error if the request fails. 102 + func (c *ctfdClient) GetScoreboard() (*ScoreboardResponse, error) { 103 + endpoint := "/scoreboard" 104 + 105 + req, err := http.NewRequest("GET", c.baseURL+endpoint, nil) 106 + if err != nil { 107 + return nil, fmt.Errorf("error creating request: %v", err) 108 + } 109 + 110 + req.Header.Add("Accept", "application/json") 111 + req.Header.Add("Authorization", "Token "+c.apiToken) 112 + req.Header.Add("Content-Type", "application/json") 113 + 114 + resp, err := c.httpClient.Do(req) 115 + if err != nil { 116 + return nil, fmt.Errorf("error executing request: %v", err) 117 + } 118 + defer resp.Body.Close() 119 + 120 + body, err := io.ReadAll(resp.Body) 121 + if err != nil { 122 + return nil, fmt.Errorf("error reading response body: %v", err) 123 + } 124 + 125 + if resp.StatusCode != http.StatusOK { 126 + return nil, fmt.Errorf("error response: %s", string(body)) 127 + } 128 + 129 + var scoreboard ScoreboardResponse 130 + if err := json.Unmarshal(body, &scoreboard); err != nil { 131 + return nil, fmt.Errorf("error parsing JSON response: %v", err) 132 + } 133 + 134 + if !scoreboard.Success { 135 + return nil, fmt.Errorf("API returned success=false") 136 + } 137 + 138 + return &scoreboard, nil 139 + } 140 + 141 + // GetChallengeList fetches the list of challenges from the CTFd API. 142 + // Returns a ChallengeListResponse containing all challenges sorted by ID or an error if the request fails. 143 + func (c *ctfdClient) GetChallengeList() (*ChallengeListResponse, error) { 144 + endpoint := "/challenges" 145 + 146 + req, err := http.NewRequest("GET", c.baseURL+endpoint, nil) 147 + if err != nil { 148 + return nil, fmt.Errorf("error creating request: %v", err) 149 + } 150 + 151 + req.Header.Add("Accept", "application/json") 152 + req.Header.Add("Authorization", "Token "+c.apiToken) 153 + req.Header.Add("Content-Type", "application/json") 154 + 155 + resp, err := c.httpClient.Do(req) 156 + if err != nil { 157 + return nil, fmt.Errorf("error executing request: %v", err) 158 + } 159 + defer resp.Body.Close() 160 + 161 + body, err := io.ReadAll(resp.Body) 162 + if err != nil { 163 + return nil, fmt.Errorf("error reading response body: %v", err) 164 + } 165 + 166 + if resp.StatusCode != http.StatusOK { 167 + return nil, fmt.Errorf("error response: %s", string(body)) 168 + } 169 + 170 + var challengeList ChallengeListResponse 171 + if err := json.Unmarshal(body, &challengeList); err != nil { 172 + return nil, fmt.Errorf("error parsing JSON response: %v", err) 173 + } 174 + 175 + sort.Slice(challengeList.Data, func(i, j int) bool { 176 + return challengeList.Data[i].ID < challengeList.Data[j].ID 177 + }) 178 + 179 + if !challengeList.Success { 180 + return nil, fmt.Errorf("API returned success=false") 181 + } 182 + 183 + return &challengeList, nil 184 + }
+107
clients/ntfy.go
··· 1 + package clients 2 + 3 + import ( 4 + "bytes" 5 + "crypto/tls" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "net/http" 10 + "strings" 11 + "time" 12 + ) 13 + 14 + // NtfyMessage represents a notification message to be sent via ntfy 15 + type NtfyMessage struct { 16 + Topic string `json:"topic"` 17 + Message string `json:"message,omitempty"` 18 + Title string `json:"title,omitempty"` 19 + Tags []string `json:"tags,omitempty"` 20 + Priority int `json:"priority,omitempty"` 21 + Click string `json:"click,omitempty"` 22 + Actions []map[string]any `json:"actions,omitempty"` 23 + Attach string `json:"attach,omitempty"` 24 + Filename string `json:"filename,omitempty"` 25 + Markdown bool `json:"markdown,omitempty"` 26 + Icon string `json:"icon,omitempty"` 27 + Email string `json:"email,omitempty"` 28 + Call string `json:"call,omitempty"` 29 + Delay string `json:"delay,omitempty"` 30 + } 31 + 32 + // NtfyClient represents a client for sending notifications via ntfy.sh 33 + type NtfyClient struct { 34 + Topic string 35 + ServerURL string 36 + HTTPClient *http.Client 37 + // Optional authentication token 38 + AccessToken string 39 + } 40 + 41 + // NewNtfyClient creates a new ntfy client with the specified topic and server URL. 42 + // It configures an HTTP client with a 10-second timeout and insecure TLS verification. 43 + func NewNtfyClient(topic, serverURL string, accessToken string) *NtfyClient { 44 + serverURL = strings.TrimSuffix(serverURL, "/") 45 + if serverURL == "" { 46 + serverURL = "https://ntfy.sh" 47 + } 48 + 49 + return &NtfyClient{ 50 + Topic: topic, 51 + ServerURL: serverURL, 52 + AccessToken: accessToken, 53 + HTTPClient: &http.Client{ 54 + Timeout: 10 * time.Second, 55 + Transport: &http.Transport{ 56 + TLSClientConfig: &tls.Config{ 57 + InsecureSkipVerify: true, 58 + }, 59 + }, 60 + }, 61 + } 62 + } 63 + 64 + // NewMessage creates a new NtfyMessage with the specified message content 65 + func (c *NtfyClient) NewMessage(messageText string) *NtfyMessage { 66 + return &NtfyMessage{ 67 + Topic: c.Topic, 68 + Message: messageText, 69 + } 70 + } 71 + 72 + // SendMessage sends a structured NtfyMessage 73 + func (c *NtfyClient) SendMessage(msg *NtfyMessage) error { 74 + // Ensure topic is set 75 + if msg.Topic == "" { 76 + msg.Topic = c.Topic 77 + } 78 + 79 + payload, err := json.Marshal(msg) 80 + if err != nil { 81 + return fmt.Errorf("error marshaling message: %v", err) 82 + } 83 + 84 + req, err := http.NewRequest("POST", c.ServerURL, bytes.NewBuffer(payload)) 85 + if err != nil { 86 + return fmt.Errorf("error creating request: %v", err) 87 + } 88 + 89 + req.Header.Add("Content-Type", "application/json") 90 + 91 + if c.AccessToken != "" { 92 + req.Header.Add("Authorization", "Bearer "+c.AccessToken) 93 + } 94 + 95 + resp, err := c.HTTPClient.Do(req) 96 + if err != nil { 97 + return fmt.Errorf("error executing request: %v", err) 98 + } 99 + defer resp.Body.Close() 100 + 101 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { 102 + body, _ := io.ReadAll(resp.Body) 103 + return fmt.Errorf("error response (status %d): %s", resp.StatusCode, string(body)) 104 + } 105 + 106 + return nil 107 + }
+146
cmd/status/dashboard.go
··· 1 + package status 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "strings" 7 + 8 + "github.com/charmbracelet/lipgloss" 9 + "github.com/charmbracelet/lipgloss/table" 10 + "github.com/spf13/cobra" 11 + "github.com/taciturnaxolotl/ctfd-alerts/clients" 12 + ) 13 + 14 + var ( 15 + // Colors 16 + purple = lipgloss.AdaptiveColor{Light: "#9D8EFF", Dark: "#7D56F4"} 17 + gray = lipgloss.AdaptiveColor{Light: "#BEBEBE", Dark: "#4A4A4A"} 18 + lightGray = lipgloss.AdaptiveColor{Light: "#CCCCCC", Dark: "#3A3A3A"} 19 + green = lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"} 20 + red = lipgloss.AdaptiveColor{Light: "#F52D4F", Dark: "#FF5F7A"} 21 + 22 + // Styles 23 + titleStyle = lipgloss.NewStyle(). 24 + Foreground(purple). 25 + Bold(true) 26 + 27 + containerStyle = lipgloss.NewStyle(). 28 + BorderStyle(lipgloss.RoundedBorder()). 29 + BorderForeground(purple). 30 + Padding(1) 31 + 32 + headerStyle = lipgloss.NewStyle(). 33 + Foreground(purple). 34 + Bold(true). 35 + Align(lipgloss.Center) 36 + 37 + oddRowStyle = lipgloss.NewStyle() 38 + 39 + evenRowStyle = lipgloss.NewStyle() 40 + // Status indicators 41 + solvedStyle = lipgloss.NewStyle().Foreground(green).SetString("✓") 42 + unsolvedStyle = lipgloss.NewStyle().Foreground(red).SetString("✗") 43 + ) 44 + 45 + func truncateString(s string, maxLen int) string { 46 + if len(s) <= maxLen { 47 + return s 48 + } 49 + return s[:maxLen-3] + "..." 50 + } 51 + 52 + func createTable(headers []string, rows [][]string) string { 53 + 54 + t := table.New(). 55 + Border(lipgloss.ASCIIBorder()). 56 + BorderStyle(lipgloss.NewStyle().Foreground(lightGray)). 57 + StyleFunc(func(row, col int) lipgloss.Style { 58 + switch { 59 + case row == table.HeaderRow: 60 + return headerStyle 61 + case row%2 == 0: 62 + return evenRowStyle 63 + default: 64 + return oddRowStyle 65 + } 66 + }). 67 + Headers(headers...). 68 + Rows(rows...) 69 + 70 + return t.Render() 71 + } 72 + 73 + func runDashboard(cmd *cobra.Command, args []string) { 74 + // Get CTFd client from root command context 75 + ctfdClient := cmd.Context().Value("ctfd_client").(CTFdClient) 76 + 77 + // Get scoreboard data 78 + scoreboard, err := ctfdClient.GetScoreboard() 79 + if err != nil { 80 + log.Fatalf("Error fetching scoreboard: %v", err) 81 + } 82 + 83 + // Prepare scoreboard data 84 + scoreboardHeaders := []string{"Pos", "Team", "Score", "Members"} 85 + scoreboardRows := make([][]string, len(scoreboard.Data)) 86 + 87 + for i, team := range scoreboard.Data { 88 + memberNames := make([]string, 0, len(team.Members)) 89 + for _, member := range team.Members { 90 + memberNames = append(memberNames, member.Name) 91 + } 92 + scoreboardRows[i] = []string{ 93 + fmt.Sprintf("%d", team.Position), 94 + truncateString(team.Name, 24), 95 + fmt.Sprintf("%d", team.Score), 96 + truncateString(strings.Join(memberNames, ", "), 39), 97 + } 98 + } 99 + 100 + // Get challenge list 101 + challenges, err := ctfdClient.GetChallengeList() 102 + if err != nil { 103 + log.Fatalf("Error fetching challenges: %v", err) 104 + } 105 + 106 + // Prepare challenge data 107 + challengeHeaders := []string{"ID", "Name", "Category", "Value", "Solves", "Solved"} 108 + challengeRows := make([][]string, len(challenges.Data)) 109 + 110 + for i, challenge := range challenges.Data { 111 + solvedStatus := unsolvedStyle.String() 112 + if challenge.SolvedByMe { 113 + solvedStatus = solvedStyle.String() 114 + } 115 + challengeRows[i] = []string{ 116 + fmt.Sprintf("%d", challenge.ID), 117 + truncateString(challenge.Name, 24), 118 + truncateString(challenge.Category, 14), 119 + fmt.Sprintf("%d", challenge.Value), 120 + fmt.Sprintf("%d", challenge.Solves), 121 + solvedStatus, 122 + } 123 + } 124 + 125 + // Build and render the complete dashboard 126 + var dashboard strings.Builder 127 + 128 + // Scoreboard section 129 + dashboard.WriteString(titleStyle.Render(fmt.Sprintf("CTFd Scoreboard [%d]", len(scoreboard.Data)))) 130 + dashboard.WriteString("\n") 131 + dashboard.WriteString(createTable(scoreboardHeaders, scoreboardRows)) 132 + 133 + // Challenges section 134 + dashboard.WriteString("\n\n") 135 + dashboard.WriteString(titleStyle.Render(fmt.Sprintf("CTFd Challenges [%d]", len(challenges.Data)))) 136 + dashboard.WriteString("\n") 137 + dashboard.WriteString(createTable(challengeHeaders, challengeRows)) 138 + 139 + // Render the final output 140 + fmt.Print("\n") 141 + fmt.Print(dashboard.String()) 142 + fmt.Print("\n") 143 + } 144 + 145 + // CTFdClient alias for the client interface from the clients package 146 + type CTFdClient = clients.CTFdClient
+13
cmd/status/status.go
··· 1 + package status 2 + 3 + import ( 4 + "github.com/spf13/cobra" 5 + ) 6 + 7 + // StatusCmd represents the status command 8 + var StatusCmd = &cobra.Command{ 9 + Use: "status", 10 + Short: "Show CTFd status information", 11 + Long: "Shows the current CTFd scoreboard and list of challenges in a tabular format", 12 + Run: runDashboard, 13 + }
+70
config.go
··· 1 + package main 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "os" 7 + "strings" 8 + 9 + "github.com/pelletier/go-toml/v2" 10 + ) 11 + 12 + type CTFdConfig struct { 13 + ApiBase string `toml:"api_base"` 14 + ApiKey string `toml:"api_key"` 15 + } 16 + 17 + type NtfyConfig struct { 18 + ApiBase string `toml:"api_base"` 19 + AccessToken string `toml:"acess_token"` 20 + Topic string `toml:"topic"` 21 + } 22 + 23 + type Config struct { 24 + Debug bool `toml:"debug"` 25 + CTFdConfig CTFdConfig `toml:"ctfd"` 26 + NtfyConfig NtfyConfig `toml:"ntfy"` 27 + MonitorInterval int `toml:"interval"` 28 + } 29 + 30 + var config *Config 31 + 32 + func loadConfig(path string) (*Config, error) { 33 + data, err := os.ReadFile(path) 34 + if err != nil { 35 + return nil, err 36 + } 37 + 38 + var cfg Config 39 + if err := toml.Unmarshal(data, &cfg); err != nil { 40 + return nil, err 41 + } 42 + 43 + if cfg.CTFdConfig.ApiBase == "" { 44 + return nil, errors.New("ctfd api_base URL cannot be empty") 45 + } 46 + 47 + if cfg.CTFdConfig.ApiKey == "" { 48 + return nil, errors.New("ctfd api_key cannot be empty") 49 + } 50 + 51 + // Check API key format (should start with ctfd_ followed by 64 hex characters) 52 + if len(cfg.CTFdConfig.ApiKey) != 69 || !strings.HasPrefix(cfg.CTFdConfig.ApiKey, "ctfd_") { 53 + return nil, errors.New("ctfd api_key must be in the format ctfd_<64 hex characters> not " + cfg.CTFdConfig.ApiKey) 54 + } 55 + 56 + if cfg.NtfyConfig.ApiBase == "" { 57 + return nil, errors.New("ntfy api_base URL cannot be empty") 58 + } 59 + 60 + if cfg.NtfyConfig.Topic == "" { 61 + return nil, errors.New("ntfy topic cannot be empty") 62 + } 63 + 64 + if cfg.MonitorInterval == 0 { 65 + cfg.MonitorInterval = 300 66 + fmt.Println("you haven't set a monitor interval; setting to 300") 67 + } 68 + 69 + return &cfg, nil 70 + }
+11
config.toml
··· 1 + debug = true 2 + interval = 100 3 + 4 + [ctfd] 5 + api_base = "http://163.11.237.79/api/v1" 6 + api_key = "ctfd_10698fd44950bf7556bc3f5e1012832dae5bddcffb1fe82191d8dd3be3641393" 7 + 8 + [ntfy] 9 + api_base = "https://ntfy.sh/" 10 + acess_token = "" 11 + topic = "cugencyber_ctfd"
+27
flake.lock
··· 1 + { 2 + "nodes": { 3 + "nixpkgs": { 4 + "locked": { 5 + "lastModified": 1750741721, 6 + "narHash": "sha256-Z0djmTa1YmnGMfE9jEe05oO4zggjDmxOGKwt844bUhE=", 7 + "owner": "NixOS", 8 + "repo": "nixpkgs", 9 + "rev": "4b1164c3215f018c4442463a27689d973cffd750", 10 + "type": "github" 11 + }, 12 + "original": { 13 + "owner": "NixOS", 14 + "ref": "nixos-unstable", 15 + "repo": "nixpkgs", 16 + "type": "github" 17 + } 18 + }, 19 + "root": { 20 + "inputs": { 21 + "nixpkgs": "nixpkgs" 22 + } 23 + } 24 + }, 25 + "root": "root", 26 + "version": 7 27 + }
+96
flake.nix
··· 1 + { 2 + description = "⛳ sending alerts for leaderboard changes and new challenges on any ctfd.io instance"; 3 + 4 + inputs = { 5 + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 + }; 7 + 8 + outputs = { self, nixpkgs }: 9 + let 10 + allSystems = [ 11 + "x86_64-linux" # 64-bit Intel/AMD Linux 12 + "aarch64-linux" # 64-bit ARM Linux 13 + "x86_64-darwin" # 64-bit Intel macOS 14 + "aarch64-darwin" # 64-bit ARM macOS 15 + ]; 16 + forAllSystems = f: nixpkgs.lib.genAttrs allSystems (system: f { 17 + pkgs = import nixpkgs { inherit system; }; 18 + }); 19 + in 20 + { 21 + packages = forAllSystems ({ pkgs }: { 22 + default = pkgs.buildGoModule { 23 + pname = "ctfd-alerts"; 24 + version = "0.0.1"; 25 + subPackages = [ "." ]; # Build from root directory 26 + src = self; 27 + vendorHash = null; 28 + }; 29 + }); 30 + 31 + devShells = forAllSystems ({ pkgs }: { 32 + default = pkgs.mkShell { 33 + buildInputs = with pkgs; [ 34 + go 35 + gopls 36 + gotools 37 + go-tools 38 + (pkgs.writeShellScriptBin "ctfd-alerts-dev" '' 39 + go build -o ./bin/ctfd-alerts 40 + ./bin/ctfd-alerts "$@" || true 41 + '') 42 + (pkgs.writeShellScriptBin "ctfd-alerts-build" '' 43 + echo "Building ctfd-alerts binaries for all platforms..." 44 + mkdir -p $PWD/bin 45 + 46 + # Build for Linux (64-bit) 47 + echo "Building for Linux (x86_64)..." 48 + GOOS=linux GOARCH=amd64 go build -o $PWD/bin/ctfd-alerts-linux-amd64 49 + 50 + # Build for Linux ARM (64-bit) 51 + echo "Building for Linux (aarch64)..." 52 + GOOS=linux GOARCH=arm64 go build -o $PWD/bin/ctfd-alerts-linux-arm64 53 + 54 + # Build for macOS (64-bit Intel) 55 + echo "Building for macOS (x86_64)..." 56 + GOOS=darwin GOARCH=amd64 go build -o $PWD/bin/ctfd-alerts-darwin-amd64 57 + 58 + # Build for macOS ARM (64-bit) 59 + echo "Building for macOS (aarch64)..." 60 + GOOS=darwin GOARCH=arm64 go build -o $PWD/bin/ctfd-alerts-darwin-arm64 61 + 62 + # Build for Windows (64-bit) 63 + echo "Building for Windows (x86_64)..." 64 + GOOS=windows GOARCH=amd64 go build -o $PWD/bin/ctfd-alerts-windows-amd64.exe 65 + 66 + echo "All binaries built successfully in $PWD/bin/" 67 + ls -la $PWD/bin/ 68 + '') 69 + ]; 70 + 71 + shellHook = '' 72 + export PATH=$PATH:$PWD/bin 73 + mkdir -p $PWD/bin 74 + ''; 75 + }; 76 + }); 77 + 78 + apps = forAllSystems ({ pkgs }: { 79 + default = { 80 + type = "app"; 81 + program = "${self.packages.${pkgs.system}.default}/bin/ctfd-alerts"; 82 + }; 83 + ctfd-alerts-dev = { 84 + type = "app"; 85 + program = toString (pkgs.writeShellScript "ctfd-alerts-dev" '' 86 + go build -o ./bin/ctfd-alerts ./main.go 87 + ./bin/ctfd-alerts $* || true 88 + ''); 89 + }; 90 + ctfd-alerts-build = { 91 + type = "app"; 92 + program = "${self.devShells.${pkgs.system}.default.inputDerivation}/bin/ctfd-alerts-build"; 93 + }; 94 + }); 95 + }; 96 + }
+41
go.mod
··· 1 + module github.com/taciturnaxolotl/ctfd-alerts 2 + 3 + go 1.24.3 4 + 5 + require ( 6 + github.com/charmbracelet/fang v0.2.0 7 + github.com/pelletier/go-toml/v2 v2.2.4 8 + github.com/spf13/cobra v1.9.1 9 + ) 10 + 11 + require ( 12 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 13 + github.com/charmbracelet/bubbles v0.21.0 // indirect 14 + github.com/charmbracelet/bubbletea v1.3.4 // indirect 15 + github.com/charmbracelet/colorprofile v0.3.1 // indirect 16 + github.com/charmbracelet/lipgloss v1.1.0 // indirect 17 + github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 // indirect 18 + github.com/charmbracelet/x/ansi v0.9.3 // indirect 19 + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 20 + github.com/charmbracelet/x/exp/charmtone v0.0.0-20250623112707-45752038d08d // indirect 21 + github.com/charmbracelet/x/term v0.2.1 // indirect 22 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 23 + github.com/inconshreveable/mousetrap v1.1.0 // indirect 24 + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 25 + github.com/mattn/go-isatty v0.0.20 // indirect 26 + github.com/mattn/go-localereader v0.0.1 // indirect 27 + github.com/mattn/go-runewidth v0.0.16 // indirect 28 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 29 + github.com/muesli/cancelreader v0.2.2 // indirect 30 + github.com/muesli/mango v0.2.0 // indirect 31 + github.com/muesli/mango-cobra v1.2.0 // indirect 32 + github.com/muesli/mango-pflag v0.1.0 // indirect 33 + github.com/muesli/roff v0.1.0 // indirect 34 + github.com/muesli/termenv v0.16.0 // indirect 35 + github.com/rivo/uniseg v0.4.7 // indirect 36 + github.com/spf13/pflag v1.0.6 // indirect 37 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 38 + golang.org/x/sync v0.15.0 // indirect 39 + golang.org/x/sys v0.33.0 // indirect 40 + golang.org/x/text v0.26.0 // indirect 41 + )
+85
go.sum
··· 1 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 2 + github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 3 + github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 4 + github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 5 + github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 6 + github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 7 + github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= 8 + github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= 9 + github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= 10 + github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= 11 + github.com/charmbracelet/fang v0.2.0 h1:F2sK2Zjy9kRYz/xUSF1o89DNj2BHKpxVKT7TA21KZi0= 12 + github.com/charmbracelet/fang v0.2.0/go.mod h1:TPpME1GkB6/4uR4wXmPnugTCkqRLgZkWSH+aMds6454= 13 + github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 14 + github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 15 + github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 h1:SOylT6+BQzPHEjn15TIzawBPVD0QmhKXbcb3jY0ZIKU= 16 + github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc= 17 + github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= 18 + github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 19 + github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 20 + github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 21 + github.com/charmbracelet/x/exp/charmtone v0.0.0-20250623112707-45752038d08d h1:Y34SmGfNOuc7NxbbSXJDvIDv3BFNhj4LGWPxqk+YoNo= 22 + github.com/charmbracelet/x/exp/charmtone v0.0.0-20250623112707-45752038d08d/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= 23 + github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= 24 + github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 25 + github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 26 + github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 27 + github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 28 + github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 29 + github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 30 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 31 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 32 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 33 + github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 34 + github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 35 + github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 36 + github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 37 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 38 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 39 + github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 40 + github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 41 + github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 42 + github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 43 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 44 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 45 + github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 46 + github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 47 + github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ= 48 + github.com/muesli/mango v0.2.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= 49 + github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg= 50 + github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= 51 + github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= 52 + github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= 53 + github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= 54 + github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= 55 + github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 56 + github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 57 + github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 58 + github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 59 + github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 60 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 61 + github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 62 + github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 63 + github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 64 + github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 65 + github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 66 + github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 67 + github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 68 + github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 69 + github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 70 + github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 71 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 72 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 73 + golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 74 + golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 75 + golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= 76 + golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 77 + golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 78 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 79 + golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 80 + golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 81 + golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= 82 + golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 83 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 84 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 85 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+55
main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "log" 6 + "os" 7 + 8 + "github.com/charmbracelet/fang" 9 + "github.com/spf13/cobra" 10 + "github.com/taciturnaxolotl/ctfd-alerts/clients" 11 + "github.com/taciturnaxolotl/ctfd-alerts/cmd/status" 12 + ) 13 + 14 + var ( 15 + debugLog *log.Logger 16 + ) 17 + 18 + // rootCmd represents the base command 19 + var cmd = &cobra.Command{ 20 + Use: "ctfd-alerts", 21 + Short: "A tool for monitoring CTFd competitions", 22 + Long: `ctfd-alerts is a command-line tool that helps you monitor CTFd-based 23 + competitions by providing real-time updates, notifications, and status information.`, 24 + PersistentPreRun: func(cmd *cobra.Command, args []string) { 25 + configFile, _ := cmd.Flags().GetString("config") 26 + var err error 27 + config, err = loadConfig(configFile) 28 + if err != nil { 29 + log.Fatalf("Error loading config: %v", err) 30 + } 31 + 32 + setupLogging(config.Debug) 33 + 34 + // Create a new CTFd client and add it to context 35 + ctfdClient := clients.NewCTFdClient(config.CTFdConfig.ApiBase, config.CTFdConfig.ApiKey) 36 + cmd.SetContext(context.WithValue(cmd.Context(), "ctfd_client", ctfdClient)) 37 + }, 38 + } 39 + 40 + func init() { 41 + // Add persistent flags that work across all commands 42 + cmd.PersistentFlags().StringP("config", "c", "config.toml", "config file path") 43 + 44 + // Add commands 45 + cmd.AddCommand(status.StatusCmd) 46 + } 47 + 48 + func main() { 49 + if err := fang.Execute( 50 + context.Background(), 51 + cmd, 52 + ); err != nil { 53 + os.Exit(1) 54 + } 55 + }
+55
types/types.go
··· 1 + package types 2 + 3 + // ScoreboardResponse represents the top-level response from the CTFd API for scoreboard 4 + type ScoreboardResponse struct { 5 + Success bool `json:"success"` 6 + Data []TeamStanding `json:"data"` 7 + } 8 + 9 + // TeamStanding represents a team's standing on the scoreboard 10 + type TeamStanding struct { 11 + Position int `json:"pos"` 12 + AccountID int `json:"account_id"` 13 + AccountURL string `json:"account_url"` 14 + AccountType string `json:"account_type"` 15 + OAuthID *string `json:"oauth_id"` 16 + Name string `json:"name"` 17 + Score int `json:"score"` 18 + BracketID *string `json:"bracket_id"` 19 + BracketName *string `json:"bracket_name"` 20 + Members []Member `json:"members"` 21 + } 22 + 23 + // Member represents a team member 24 + type Member struct { 25 + ID int `json:"id"` 26 + OAuthID *string `json:"oauth_id"` 27 + Name string `json:"name"` 28 + Score int `json:"score"` 29 + BracketID *string `json:"bracket_id"` 30 + BracketName *string `json:"bracket_name"` 31 + } 32 + 33 + // ChallengeListResponse represents the top-level response from the CTFd API for challenges 34 + type ChallengeListResponse struct { 35 + Success bool `json:"success"` 36 + Data []Challenge `json:"data"` 37 + } 38 + 39 + // Challenge represents a CTFd challenge 40 + type Challenge struct { 41 + ID int `json:"id"` 42 + Name string `json:"name"` 43 + Description string `json:"description"` 44 + Attribution string `json:"attribution"` 45 + ConnectionInfo string `json:"connection_info"` 46 + NextID int `json:"next_id"` 47 + MaxAttempts int `json:"max_attempts"` 48 + Value int `json:"value"` 49 + Category string `json:"category"` 50 + Type string `json:"type"` 51 + State string `json:"state"` 52 + Requirements map[string]any `json:"requirements"` 53 + Solves int `json:"solves"` 54 + SolvedByMe bool `json:"solved_by_me"` 55 + }
+15
utils.go
··· 1 + package main 2 + 3 + import ( 4 + "io" 5 + "log" 6 + "os" 7 + ) 8 + 9 + func setupLogging(debug bool) { 10 + if debug { 11 + debugLog = log.New(os.Stdout, "DEBUG: ", log.Ltime|log.Lmicroseconds) 12 + } else { 13 + debugLog = log.New(io.Discard, "", 0) 14 + } 15 + }