From 2920fec5b7c9b836c33d8a0d55b19f1c056ac611 Mon Sep 17 00:00:00 2001 From: Krisna Dewa <59174531+krisdewa@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:53:41 +0800 Subject: [PATCH] fix: adapt machines, info and sherlocks to current HTB API v4 Several endpoints used by the CLI no longer exist upstream, which made the affected commands panic with nil pointer / interface conversion errors instead of failing gracefully. Endpoints observed as removed (404): - /machine/unreleased/ -> machines: panic in createFlex - /user/profile/activity/ -> info -u: nil map -> TUI segfault - /user/profile/basic/ (no id) -> replaced by /user/profile/basic/{id} Changes: - machines: tolerate missing/empty payloads, drop the hard exit when the scheduled list is unavailable, and add a plain-text renderer for non-TTY output instead of crashing under a pipe. - info: stop requesting the removed activity endpoint, guard against nil JSON bodies, and print the user profile (rank, owns, fortresses/pro labs) as plain text when stdout is not a terminal. - sherlocks: add the same non-TTY plain-text listing fallback. - tui: never hand a nil tview primitive to a Flex (placeholder panels), which removes the segfault seen when a section could not be loaded. --- cmd/info.go | 52 +++++++++++++++- cmd/machines.go | 153 ++++++++++++++++++++++++++++++++++++++--------- cmd/sherlocks.go | 51 ++++++++++++++++ lib/utils/tui.go | 19 +++++- 4 files changed, 243 insertions(+), 32 deletions(-) diff --git a/cmd/info.go b/cmd/info.go index 7b6b394..2f624e6 100644 --- a/cmd/info.go +++ b/cmd/info.go @@ -79,7 +79,10 @@ func fetchAndDisplayInfo(url, header string, params []string, elementType string config.GlobalConfig.Logger.Debug(fmt.Sprintf("InfoKey: %s", infoKey)) info := utils.ParseJsonMessage(resp, infoKey) - data := info.(map[string]interface{}) + data, ok := info.(map[string]interface{}) + if !ok { + return fmt.Errorf("could not read '%s' data from the HTB API (endpoint may have changed upstream)", infoKey) + } endpoints := []struct { name string @@ -87,7 +90,8 @@ func fetchAndDisplayInfo(url, header string, params []string, elementType string }{ {"Fortresses", "/user/profile/progress/fortress/"}, {"Prolabs", "/user/profile/progress/prolab/"}, - {"Activity", "/user/profile/activity/"}, + // "Activity" (/user/profile/activity/) was removed from the HTB API v4 + // and now always returns 404, so it is no longer requested. } dataMaps := make(map[string]map[string]interface{}) @@ -122,6 +126,10 @@ func fetchAndDisplayInfo(url, header string, params []string, elementType string } bodyData = fmt.Sprintf("%v\t%v\t%v\t%v\t%v\t%v\t%v\t%v\n", data["name"], data["category_name"], retiredStatus, data["difficulty"], data["stars"], data["solves"], status, datetime) } else if elementType == "Username" { + if !stdoutIsTerminal() { + printUserProfilePlain(data, dataMaps) + return nil + } utils.DisplayInformationsGUI(data, dataMaps) return nil } @@ -132,6 +140,46 @@ func fetchAndDisplayInfo(url, header string, params []string, elementType string return nil } +// printUserProfilePlain renders a user profile as plain text for non-TTY output. +func printUserProfilePlain(profile map[string]interface{}, dataMaps map[string]map[string]interface{}) { + fmt.Printf("%-18s: %v\n", "Name", profile["name"]) + fmt.Printf("%-18s: %v (%v)\n", "Rank", profile["rank"], profile["points"]) + fmt.Printf("%-18s: %v\n", "Global Rank", profile["ranking"]) + fmt.Printf("%-18s: %v\n", "User Owns", profile["user_owns"]) + fmt.Printf("%-18s: %v\n", "System Owns", profile["system_owns"]) + fmt.Printf("%-18s: %v / %v\n", "User Bloods", profile["user_bloods"], profile["system_bloods"]) + fmt.Printf("%-18s: %v%% (req %v)\n", "Rank Ownership", profile["rank_ownership"], profile["rank_requirement"]) + fmt.Printf("%-18s: %v\n", "Country", profile["country_name"]) + if team, ok := profile["team"].(map[string]interface{}); ok && team != nil { + fmt.Printf("%-18s: %v (#%v)\n", "Team", team["name"], team["ranking"]) + } else { + fmt.Printf("%-18s: %v\n", "Team", "N/A") + } + fmt.Printf("%-18s: %v\n", "Subscription", profile["labs_membership"]) + + for _, section := range []struct{ key, label, itemKey string }{ + {"Fortresses", "Fortresses", "fortresses"}, + {"Prolabs", "Pro Labs", "prolabs"}, + } { + sectionData, ok := dataMaps[section.key] + if !ok { + continue + } + items, ok := sectionData[section.itemKey].([]interface{}) + if !ok || len(items) == 0 { + continue + } + fmt.Printf("\n%s:\n", section.label) + for _, item := range items { + entry, ok := item.(map[string]interface{}) + if !ok { + continue + } + fmt.Printf(" - %v (%v/%v)\n", entry["name"], entry["owned_flags"], entry["total_flags"]) + } + } +} + // coreInfoCmd is the core of the info command; it checks the parameters and displays corresponding information. func coreInfoCmd(machineName []string, challengeName []string, usernameName []string) error { machineHeader := "Name\tOS\tRetired\tDifficulty\tStars\tIP\tStatus\tLast Reset\tRelease" diff --git a/cmd/machines.go b/cmd/machines.go index 6a9c951..4264351 100644 --- a/cmd/machines.go +++ b/cmd/machines.go @@ -24,8 +24,80 @@ const ( CrossMark = "\U0000274C" Penguin = "\U0001F427" Computer = "\U0001F5A5 " + + scheduledUnavailableMsg = "No scheduled machine data available (this API endpoint is currently disabled/renamed upstream)" ) +// stdoutIsTerminal reports whether stdout is attached to a real terminal. +// TUI rendering requires a TTY; without one we fall back to plain text. +func stdoutIsTerminal() bool { + fileInfo, err := os.Stdout.Stat() + if err != nil { + return false + } + return fileInfo.Mode()&os.ModeCharDevice != 0 +} + +// printMachinesPlain renders the machine lists as plain text for non-TTY output. +func printMachinesPlain() { + sections := []struct { + url string + title string + scheduled bool + }{ + {machineURL, activeTitle, false}, + {retiredURL, retiredTitle, false}, + {scheduledURL, scheduledTitle, true}, + } + + for _, section := range sections { + resp, err := utils.HtbRequest(http.MethodGet, section.url, nil) + fmt.Printf("\n== %s ==\n", section.title) + if err != nil { + fmt.Printf(" unavailable: %v\n", err) + continue + } + + info := utils.ParseJsonMessage(resp, "data") + resp.Body.Close() + + items, ok := info.([]interface{}) + if !ok || len(items) == 0 { + fmt.Printf(" %s\n", scheduledUnavailableMsg) + continue + } + + for _, value := range items { + data, ok := value.(map[string]interface{}) + if !ok { + continue + } + name := getStr(data, "name") + osName := getStr(data, "os") + difficulty := getStr(data, "difficultyText") + if section.scheduled { + difficulty = getStr(data, "difficulty_text") + fmt.Printf(" %-20s %-8s %s\n", name, osName, difficulty) + continue + } + + userOwned, rootOwned := " -", " -" + if getBool(data, "authUserInUserOwns") { + userOwned = "user" + } + if getBool(data, "authUserInRootOwns") { + rootOwned = "root" + } + release := getStr(data, "release") + if parsed, err := time.Parse(time.RFC3339Nano, release); err == nil { + release = parsed.Format("2006-01-02") + } + fmt.Printf(" %-20s %-8s %-8s user:%-4s root:%-4s %s\n", + name, osName, difficulty, userOwned, rootOwned, release) + } + } +} + // getColorFromDifficultyText returns the color corresponding to the given difficulty. func getColorFromDifficultyText(difficultyText string) string { switch difficultyText { @@ -54,56 +126,75 @@ func getOSEmoji(os string) string { } } +// getStr safely extracts a string field from a decoded JSON object. +func getStr(data map[string]interface{}, key string) string { + if value, ok := data[key].(string); ok { + return value + } + return "" +} + +// getBool safely extracts a boolean field from a decoded JSON object. +func getBool(data map[string]interface{}, key string) bool { + if value, ok := data[key].(bool); ok { + return value + } + return false +} + // createFlex creates and returns a Flex view with machine information func createFlex(info interface{}, title string, isScheduled bool) (*tview.Flex, error) { flex := tview.NewFlex().SetDirection(tview.FlexRow) flex.SetBorder(true).SetTitle(title).SetTitleAlign(tview.AlignLeft) - for _, value := range info.([]interface{}) { - data := value.(map[string]interface{}) + items, ok := info.([]interface{}) + if !ok || len(items) == 0 { + flex.AddItem(tview.NewTextView().SetText(scheduledUnavailableMsg), 1, 0, false) + return flex, nil + } - // Determining the color according to difficulty + for _, value := range items { + data, ok := value.(map[string]interface{}) + if !ok { + continue + } - key := "Undefined" - _ = key - if title == "Scheduled" { - key = data["difficulty_text"].(string) - } else { - key = data["difficultyText"].(string) + // Determining the color according to difficulty + key := getStr(data, "difficultyText") + if isScheduled { + key = getStr(data, "difficulty_text") } color := getColorFromDifficultyText(key) - osEmoji := getOSEmoji(data["os"].(string)) + osName := getStr(data, "os") + osEmoji := getOSEmoji(osName) var formatString string // Choice of display format depending on the nature of the information if isScheduled { formatString = fmt.Sprintf("%-10s %s%-10s %s%-10s[-]", - data["name"], osEmoji, data["os"], color, data["difficulty_text"]) + getStr(data, "name"), osEmoji, osName, color, getStr(data, "difficulty_text")) } else { - // Convert and format date - parsedDate, err := time.Parse(time.RFC3339Nano, data["release"].(string)) - if err != nil { - return nil, fmt.Errorf("error parsing date: %v", err) + // Convert and format date (tolerate unexpected/missing values) + formattedDate := "unknown" + if release := getStr(data, "release"); release != "" { + if parsedDate, err := time.Parse(time.RFC3339Nano, release); err == nil { + formattedDate = parsedDate.Format("02 January 2006") + } } - formattedDate := parsedDate.Format("02 January 2006") userEmoji := CrossMark + "User" - if value, ok := data["authUserInUserOwns"]; ok && value != nil { - if value.(bool) { - userEmoji = CheckMark + "User" - } + if getBool(data, "authUserInUserOwns") { + userEmoji = CheckMark + "User" } rootEmoji := CrossMark + "Root" - if value, ok := data["authUserInRootOwns"]; ok && value != nil { - if value.(bool) { - rootEmoji = CheckMark + "Root" - } + if getBool(data, "authUserInRootOwns") { + rootEmoji = CheckMark + "Root" } formatString = fmt.Sprintf("%-15s %s%-10s %s%-10s[-] %-5v %-5v %-7v %-30s", - data["name"], osEmoji, data["os"], color, data["difficultyText"], + getStr(data, "name"), osEmoji, osName, color, getStr(data, "difficultyText"), data["star"], userEmoji, rootEmoji, formattedDate) } @@ -117,6 +208,13 @@ var machinesCmd = &cobra.Command{ Use: "machines", Short: "Displays active / retired machines and next machines to be released", Run: func(cmd *cobra.Command, args []string) { + // The scheduled-machines endpoint has been retired upstream, so this + // view must degrade gracefully instead of panicking on nil data. + if !stdoutIsTerminal() { + printMachinesPlain() + return + } + app := tview.NewApplication() getAndDisplayFlex := func(url, title string, isScheduled bool, flex *tview.Flex) error { @@ -150,8 +248,9 @@ var machinesCmd = &cobra.Command{ } if err := getAndDisplayFlex(scheduledURL, scheduledTitle, true, rightFlex); err != nil { - config.GlobalConfig.Logger.Error("", zap.Error(err)) - os.Exit(1) + // Endpoint removed upstream -> show a notice instead of exiting. + config.GlobalConfig.Logger.Warn("scheduled machines unavailable", zap.Error(err)) + rightFlex.AddItem(tview.NewTextView().SetText(scheduledUnavailableMsg), 1, 0, false) } rightFlex.AddItem(tview.NewTextView().SetText("").SetDynamicColors(true), 0, 0, false) diff --git a/cmd/sherlocks.go b/cmd/sherlocks.go index fee216a..a01b8bb 100644 --- a/cmd/sherlocks.go +++ b/cmd/sherlocks.go @@ -13,6 +13,50 @@ import ( "go.uber.org/zap" ) +// printSherlocksPlain renders Sherlock lists as plain text for non-TTY output. +func printSherlocksPlain() { + sections := []struct { + url string + title string + }{ + {sherlocks.SherlocksURL, sherlocks.ActiveSherlocksTitle}, + {sherlocks.RetiredSherlocksURL, sherlocks.RetiredSherlocksTitle}, + {sherlocks.ScheduledSherlocksURL, sherlocks.ScheduledSherlocksTitle}, + } + + for _, section := range sections { + fmt.Printf("\n== %s ==\n", section.title) + resp, err := utils.HtbRequest(http.MethodGet, section.url, nil) + if err != nil { + fmt.Printf(" unavailable: %v\n", err) + continue + } + + info := utils.ParseJsonMessage(resp, "data") + resp.Body.Close() + + items, ok := info.([]interface{}) + if !ok || len(items) == 0 { + fmt.Println(" (no data)") + continue + } + + for _, value := range items { + data, ok := value.(map[string]interface{}) + if !ok { + continue + } + owned := " -" + if getBool(data, "is_owned") { + owned = "own" + } + fmt.Printf(" %-28s %-10s %-6s solves:%-6v rating:%-4v %s\n", + getStr(data, "name"), getStr(data, "difficulty"), owned, + data["solves"], data["rating"], getStr(data, "category_name")) + } + } +} + var sherlocksCmd = &cobra.Command{ Use: "sherlocks", Short: "Play Sherlocks mode (blue team)", @@ -79,6 +123,13 @@ var sherlocksCmd = &cobra.Command{ } return } + + // Listing Sherlocks requires a TUI; without a TTY print plain text instead. + if !stdoutIsTerminal() { + printSherlocksPlain() + return + } + app := tview.NewApplication() getAndDisplayFlex := func(url, title string, isScheduled bool, flex *tview.Flex) error { diff --git a/lib/utils/tui.go b/lib/utils/tui.go index f7c89f5..a3b2889 100644 --- a/lib/utils/tui.go +++ b/lib/utils/tui.go @@ -110,6 +110,15 @@ func displayInfo(dataMaps map[string]map[string]interface{}, dataMapKey string, return displayInfoPanel(title, items, formatterFunc, paddingBottom) } +// placeholderPanel is returned when an API section is unavailable, so the TUI +// never receives a nil primitive (which would segfault while drawing). +func placeholderPanel(title, message string) *tview.Flex { + panel := tview.NewFlex().SetDirection(tview.FlexRow) + panel.SetBorder(true).SetTitle(title).SetTitleAlign(tview.AlignLeft) + panel.AddItem(tview.NewTextView().SetText(message), 1, 0, false) + return panel +} + // Initializes and displays user information in tview func DisplayInformationsGUI(profile map[string]interface{}, advancedLabsMap map[string]map[string]interface{}) { @@ -175,7 +184,12 @@ func DisplayInformationsGUI(profile map[string]interface{}, advancedLabsMap map[ fortressesPanel := displayInfo(advancedLabsMap, "fortresses", "Fortresses", "\U0001F3F0", maxFortressNameLength, 4) prolabsPanel := displayInfo(advancedLabsMap, "prolabs", "Pro Labs", "\U0001F47D", maxProlabNameLength, 4) - activityPanel := displayInfo(advancedLabsMap, "activity", "Activity", "", maxActivityNameLength, 3) + if fortressesPanel == nil { + fortressesPanel = placeholderPanel("Fortresses", "No fortress data available") + } + if prolabsPanel == nil { + prolabsPanel = placeholderPanel("Pro Labs", "No Pro Lab data available") + } advancedLabsFlex := tview.NewFlex(). SetDirection(tview.FlexRow). @@ -186,8 +200,7 @@ func DisplayInformationsGUI(profile map[string]interface{}, advancedLabsMap map[ leftFlex := tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(userInformationsContainer, 0, 1, false). - AddItem(advancedLabsFlex, 0, 1, false). - AddItem(activityPanel, 0, 2, false) + AddItem(advancedLabsFlex, 0, 1, false) mainFlex := tview.NewFlex(). AddItem(leftFlex, 0, 2, false)