htb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTB
4
+ module CLI
5
+ class Users < Thor
6
+ def self.exit_on_failure?
7
+ true
8
+ end
9
+
10
+ desc "me", "Show your profile"
11
+ def me
12
+ client = CLI.client
13
+ data = CLI.spinner("Fetching profile...") { client.users.me }
14
+
15
+ if data && data["info"]
16
+ info = data["info"]
17
+ puts CLI.pastel.bold("\n=== Your Profile ===")
18
+ CLI.print_table(
19
+ ["Field", "Value"],
20
+ [
21
+ ["Username", info["name"] || "N/A"],
22
+ ["ID", info["id"] || "N/A"],
23
+ ["Rank", info["rank"] || "N/A"],
24
+ ["Ranking", info["ranking"] || "N/A"],
25
+ ["Points", info["points"] || 0],
26
+ ["User Owns", info["user_owns"] || 0],
27
+ ["Root Owns", info["root_owns"] || 0],
28
+ ["Challenge Owns", info["challenge_owns"] || 0],
29
+ ["Respect", info["respects"] || 0],
30
+ ["Country", info["country_name"] || "N/A"]
31
+ ]
32
+ )
33
+ else
34
+ CLI.print_error("Could not fetch profile")
35
+ end
36
+ rescue HTB::Error => e
37
+ CLI.print_error(e.message)
38
+ end
39
+
40
+ desc "info USER_ID", "Show user profile by ID"
41
+ def info(user_id)
42
+ client = CLI.client
43
+ data = CLI.spinner("Fetching user...") { client.users.profile(user_id) }
44
+
45
+ if data && data["profile"]
46
+ info = data["profile"]
47
+ puts CLI.pastel.bold("\n=== #{info['name']} ===")
48
+ CLI.print_table(
49
+ ["Field", "Value"],
50
+ [
51
+ ["ID", info["id"] || "N/A"],
52
+ ["Rank", info["rank"] || "N/A"],
53
+ ["Ranking", info["ranking"] || "N/A"],
54
+ ["Points", info["points"] || 0],
55
+ ["User Owns", info["user_owns"] || 0],
56
+ ["Root Owns", info["root_owns"] || 0],
57
+ ["Respect", info["respects"] || 0],
58
+ ["Country", info["country_name"] || "N/A"]
59
+ ]
60
+ )
61
+ else
62
+ CLI.print_error("User not found")
63
+ end
64
+ rescue HTB::Error => e
65
+ CLI.print_error(e.message)
66
+ end
67
+
68
+ desc "activity USER_ID", "Show user activity"
69
+ def activity(user_id)
70
+ client = CLI.client
71
+ data = CLI.spinner("Fetching activity...") { client.users.activity(user_id) }
72
+
73
+ if data && data["profile"] && data["profile"]["activity"]
74
+ activities = data["profile"]["activity"]
75
+ puts CLI.pastel.bold("\n=== Recent Activity ===")
76
+
77
+ activities.first(10).each do |act|
78
+ type = act["type"] || "unknown"
79
+ name = act["name"] || "N/A"
80
+ date = act["date"] || "N/A"
81
+ puts " #{CLI.pastel.cyan(type)}: #{name} (#{date})"
82
+ end
83
+ else
84
+ CLI.print_info("No activity found")
85
+ end
86
+ rescue HTB::Error => e
87
+ CLI.print_error(e.message)
88
+ end
89
+
90
+ desc "bloods USER_ID", "Show user's first bloods"
91
+ def bloods(user_id)
92
+ client = CLI.client
93
+ data = CLI.spinner("Fetching bloods...") { client.users.bloods(user_id) }
94
+
95
+ if data && data["profile"]
96
+ bloods = data["profile"]
97
+ puts CLI.pastel.bold("\n=== First Bloods ===")
98
+
99
+ if bloods["bloods"] && !bloods["bloods"].empty?
100
+ rows = bloods["bloods"].map do |b|
101
+ [b["name"], b["type"], b["date"]]
102
+ end
103
+ CLI.print_table(["Name", "Type", "Date"], rows)
104
+ else
105
+ CLI.print_info("No first bloods")
106
+ end
107
+ else
108
+ CLI.print_info("No bloods found")
109
+ end
110
+ rescue HTB::Error => e
111
+ CLI.print_error(e.message)
112
+ end
113
+
114
+ desc "badges USER_ID", "Show user's badges"
115
+ def badges(user_id)
116
+ client = CLI.client
117
+ data = CLI.spinner("Fetching badges...") { client.users.badges(user_id) }
118
+
119
+ if data && data["badges"]
120
+ puts CLI.pastel.bold("\n=== Badges ===")
121
+ data["badges"].each do |badge|
122
+ puts " #{CLI.pastel.yellow('★')} #{badge['name']}"
123
+ end
124
+ else
125
+ CLI.print_info("No badges found")
126
+ end
127
+ rescue HTB::Error => e
128
+ CLI.print_error(e.message)
129
+ end
130
+
131
+ desc "subscriptions", "Show your subscriptions"
132
+ def subscriptions
133
+ client = CLI.client
134
+ data = CLI.spinner("Fetching subscriptions...") { client.users.subscriptions }
135
+
136
+ if data
137
+ puts CLI.pastel.bold("\n=== Subscriptions ===")
138
+ puts " Plan: #{data['plan'] || 'Free'}"
139
+ puts " VIP: #{data['vip'] ? 'Yes' : 'No'}"
140
+ puts " Expires: #{data['expires_at'] || 'N/A'}"
141
+ else
142
+ CLI.print_info("No subscription info")
143
+ end
144
+ rescue HTB::Error => e
145
+ CLI.print_error(e.message)
146
+ end
147
+ end
148
+ end
149
+ end
data/lib/htb/cli/vm.rb ADDED
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTB
4
+ module CLI
5
+ class VM < Thor
6
+ def self.exit_on_failure?
7
+ true
8
+ end
9
+
10
+ desc "spawn MACHINE_ID", "Spawn a machine (VIP/Starting Point)"
11
+ def spawn(machine_id)
12
+ client = CLI.client
13
+ result = CLI.spinner("Spawning machine #{machine_id}...") do
14
+ client.vm.spawn(machine_id.to_i)
15
+ end
16
+
17
+ if result
18
+ if result["success"] == 1 || result["success"] == "1"
19
+ CLI.print_success("Machine is being spawned!")
20
+ CLI.print_info(result["message"]) if result["message"]
21
+ elsif result["message"]
22
+ CLI.print_info(result["message"])
23
+ else
24
+ CLI.print_success("Spawn request sent")
25
+ end
26
+ end
27
+ rescue HTB::Error => e
28
+ CLI.print_error(e.message)
29
+ end
30
+
31
+ desc "terminate MACHINE_ID", "Terminate a running machine"
32
+ def terminate(machine_id)
33
+ client = CLI.client
34
+ result = CLI.spinner("Terminating machine #{machine_id}...") do
35
+ client.vm.terminate(machine_id.to_i)
36
+ end
37
+
38
+ if result && (result["success"] == 1 || result["success"] == "1")
39
+ CLI.print_success("Machine terminated!")
40
+ else
41
+ CLI.print_info(result["message"] || "Terminate request sent")
42
+ end
43
+ rescue HTB::Error => e
44
+ CLI.print_error(e.message)
45
+ end
46
+
47
+ desc "reset MACHINE_ID", "Reset a machine"
48
+ def reset(machine_id)
49
+ client = CLI.client
50
+ result = CLI.spinner("Resetting machine #{machine_id}...") do
51
+ client.vm.reset(machine_id.to_i)
52
+ end
53
+
54
+ if result && (result["success"] == 1 || result["success"] == "1")
55
+ CLI.print_success("Machine reset requested!")
56
+ else
57
+ CLI.print_info(result["message"] || "Reset request sent")
58
+ end
59
+ rescue HTB::Error => e
60
+ CLI.print_error(e.message)
61
+ end
62
+
63
+ desc "extend MACHINE_ID", "Extend machine time"
64
+ def extend(machine_id)
65
+ client = CLI.client
66
+ result = CLI.spinner("Extending machine time...") do
67
+ client.vm.extend(machine_id.to_i)
68
+ end
69
+
70
+ if result && (result["success"] == 1 || result["success"] == "1")
71
+ CLI.print_success("Machine time extended!")
72
+ else
73
+ CLI.print_info(result["message"] || "Extension request sent")
74
+ end
75
+ rescue HTB::Error => e
76
+ CLI.print_error(e.message)
77
+ end
78
+
79
+ desc "status", "Show VM status"
80
+ def status
81
+ client = CLI.client
82
+ data = CLI.spinner("Fetching VM status...") { client.vm.status }
83
+
84
+ if data && data["info"]
85
+ info = data["info"]
86
+ puts CLI.pastel.bold("\n=== VM Status ===")
87
+ CLI.print_table(
88
+ ["Field", "Value"],
89
+ [
90
+ ["Machine", info["name"] || "N/A"],
91
+ ["IP", info["ip"] || "N/A"],
92
+ ["Lab", info["lab"] || "N/A"],
93
+ ["Type", info["type"] || "N/A"]
94
+ ]
95
+ )
96
+ else
97
+ CLI.print_info("No active VM")
98
+ end
99
+ rescue HTB::Error => e
100
+ CLI.print_info("No active VM")
101
+ end
102
+
103
+ desc "active", "Show active VM"
104
+ def active
105
+ client = CLI.client
106
+ data = CLI.spinner("Fetching active VM...") { client.vm.active }
107
+
108
+ if data && data["info"]
109
+ info = data["info"]
110
+ puts CLI.pastel.bold("\n=== Active VM ===")
111
+ CLI.print_table(
112
+ ["Field", "Value"],
113
+ [
114
+ ["Machine", info["name"] || "N/A"],
115
+ ["ID", info["id"] || "N/A"],
116
+ ["IP", CLI.pastel.green(info["ip"]) || "N/A"],
117
+ ["Type", info["type"] || "N/A"],
118
+ ["Expires", info["expires_at"] || "N/A"]
119
+ ]
120
+ )
121
+ else
122
+ CLI.print_info("No active VM")
123
+ end
124
+ rescue HTB::Error => e
125
+ CLI.print_info("No active VM")
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTB
4
+ module CLI
5
+ class VPN < Thor
6
+ def self.exit_on_failure?
7
+ true
8
+ end
9
+
10
+ desc "status", "Show VPN connection status"
11
+ def status
12
+ client = CLI.client
13
+ data = CLI.spinner("Fetching VPN status...") { client.vpn.status }
14
+
15
+ if data
16
+ puts CLI.pastel.bold("\n=== VPN Status ===")
17
+ if data["data"]
18
+ d = data["data"]
19
+ CLI.print_table(
20
+ ["Field", "Value"],
21
+ [
22
+ ["Connected", d["connection"] ? CLI.pastel.green("Yes") : CLI.pastel.red("No")],
23
+ ["Server", d["server"] || "N/A"],
24
+ ["IP", d["ip"] || "N/A"],
25
+ ["Location", d["location"] || "N/A"]
26
+ ]
27
+ )
28
+ else
29
+ CLI.print_info("Not connected")
30
+ end
31
+ end
32
+ rescue HTB::Error => e
33
+ CLI.print_error(e.message)
34
+ end
35
+
36
+ desc "servers", "List VPN servers"
37
+ def servers
38
+ client = CLI.client
39
+ data = CLI.spinner("Fetching servers...") { client.vpn.servers }
40
+
41
+ if data && data["data"]
42
+ rows = data["data"].map do |s|
43
+ [
44
+ s["id"],
45
+ s["friendly_name"] || s["name"],
46
+ s["location"] || "N/A",
47
+ s["current_clients"] || 0,
48
+ s["status"] || "N/A"
49
+ ]
50
+ end
51
+
52
+ puts CLI.pastel.bold("\nVPN Servers:")
53
+ CLI.print_table(["ID", "Name", "Location", "Clients", "Status"], rows)
54
+ else
55
+ CLI.print_info("No servers found")
56
+ end
57
+ rescue HTB::Error => e
58
+ CLI.print_error(e.message)
59
+ end
60
+
61
+ desc "switch SERVER_ID", "Switch to a different VPN server"
62
+ def switch(server_id)
63
+ client = CLI.client
64
+ result = CLI.spinner("Switching server...") { client.vpn.switch(server_id) }
65
+
66
+ if result && result["success"]
67
+ CLI.print_success("Switched to server #{server_id}")
68
+ else
69
+ CLI.print_info(result["message"] || "Switch request sent")
70
+ end
71
+ rescue HTB::Error => e
72
+ CLI.print_error(e.message)
73
+ end
74
+
75
+ desc "regenerate", "Regenerate VPN config"
76
+ def regenerate
77
+ client = CLI.client
78
+ result = CLI.spinner("Regenerating VPN config...") { client.vpn.regenerate }
79
+
80
+ if result && result["success"]
81
+ CLI.print_success("VPN config regenerated!")
82
+ else
83
+ CLI.print_info(result["message"] || "Regeneration request sent")
84
+ end
85
+ rescue HTB::Error => e
86
+ CLI.print_error(e.message)
87
+ end
88
+
89
+ desc "labs", "List available labs"
90
+ def labs
91
+ client = CLI.client
92
+ data = CLI.spinner("Fetching labs...") { client.vpn.labs }
93
+
94
+ if data && data["data"]
95
+ rows = data["data"].map do |l|
96
+ [l["id"], l["name"], l["description"] || "N/A"]
97
+ end
98
+
99
+ puts CLI.pastel.bold("\nLabs:")
100
+ CLI.print_table(["ID", "Name", "Description"], rows)
101
+ else
102
+ CLI.print_info("No labs found")
103
+ end
104
+ rescue HTB::Error => e
105
+ CLI.print_error(e.message)
106
+ end
107
+
108
+ desc "prolabs", "List Pro Labs"
109
+ def prolabs
110
+ client = CLI.client
111
+ data = CLI.spinner("Fetching Pro Labs...") { client.vpn.prolabs }
112
+
113
+ if data && data["data"]
114
+ rows = data["data"].map do |p|
115
+ [
116
+ p["id"],
117
+ p["name"],
118
+ p["difficulty"] || "N/A",
119
+ p["machines_count"] || 0,
120
+ p["flags"] || 0
121
+ ]
122
+ end
123
+
124
+ puts CLI.pastel.bold("\nPro Labs:")
125
+ CLI.print_table(["ID", "Name", "Difficulty", "Machines", "Flags"], rows)
126
+ else
127
+ CLI.print_info("No Pro Labs found")
128
+ end
129
+ rescue HTB::Error => e
130
+ CLI.print_error(e.message)
131
+ end
132
+
133
+ desc "endgames", "List Endgames"
134
+ def endgames
135
+ client = CLI.client
136
+ data = CLI.spinner("Fetching Endgames...") { client.vpn.endgames }
137
+
138
+ if data && data["data"]
139
+ rows = data["data"].map do |e|
140
+ [
141
+ e["id"],
142
+ e["name"],
143
+ e["difficulty"] || "N/A",
144
+ e["flags"] || 0
145
+ ]
146
+ end
147
+
148
+ puts CLI.pastel.bold("\nEndgames:")
149
+ CLI.print_table(["ID", "Name", "Difficulty", "Flags"], rows)
150
+ else
151
+ CLI.print_info("No Endgames found")
152
+ end
153
+ rescue HTB::Error => e
154
+ CLI.print_error(e.message)
155
+ end
156
+
157
+ desc "fortresses", "List Fortresses"
158
+ def fortresses
159
+ client = CLI.client
160
+ data = CLI.spinner("Fetching Fortresses...") { client.vpn.fortresses }
161
+
162
+ if data && data["data"]
163
+ rows = data["data"].map do |f|
164
+ [
165
+ f["id"],
166
+ f["name"],
167
+ f["company"] || "N/A",
168
+ f["flags"] || 0
169
+ ]
170
+ end
171
+
172
+ puts CLI.pastel.bold("\nFortresses:")
173
+ CLI.print_table(["ID", "Name", "Company", "Flags"], rows)
174
+ else
175
+ CLI.print_info("No Fortresses found")
176
+ end
177
+ rescue HTB::Error => e
178
+ CLI.print_error(e.message)
179
+ end
180
+ end
181
+ end
182
+ end
data/lib/htb/cli.rb ADDED
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "tty-table"
5
+ require "tty-spinner"
6
+ require "tty-prompt"
7
+ require "pastel"
8
+
9
+ require_relative "cli/main"
10
+ require_relative "cli/machines"
11
+ require_relative "cli/vm"
12
+ require_relative "cli/users"
13
+ require_relative "cli/challenges"
14
+ require_relative "cli/vpn"
15
+
16
+ module HTB
17
+ module CLI
18
+ TOKEN_URL = "https://app.hackthebox.com/profile/settings"
19
+
20
+ class << self
21
+ def pastel
22
+ @pastel ||= Pastel.new
23
+ end
24
+
25
+ def prompt
26
+ @prompt ||= TTY::Prompt.new
27
+ end
28
+
29
+ def client
30
+ token = HTB::Config.api_token
31
+
32
+ unless token
33
+ puts pastel.yellow("No API token configured.")
34
+ puts ""
35
+ puts "To get your API token:"
36
+ puts " 1. Go to #{pastel.cyan(TOKEN_URL)}"
37
+ puts " 2. Scroll down to 'App Tokens'"
38
+ puts " 3. Create a new token or copy an existing one"
39
+ puts ""
40
+
41
+ if prompt.yes?("Would you like to enter your API token now?")
42
+ token = prompt.mask("Paste your API token:")
43
+
44
+ if token && !token.strip.empty?
45
+ token = token.strip
46
+ # Validate the token by making a test request
47
+ print "Validating token... "
48
+ begin
49
+ test_client = HTB::Client.new(api_token: token)
50
+ test_client.users.me
51
+ puts pastel.green("valid!")
52
+
53
+ HTB::Config.api_token = token
54
+ puts pastel.green("Token saved to #{HTB::Config.config_file}")
55
+ puts ""
56
+ rescue HTB::AuthenticationError
57
+ puts pastel.red("invalid!")
58
+ puts pastel.red("The token appears to be invalid. Please check and try again.")
59
+ exit 1
60
+ rescue StandardError => e
61
+ puts pastel.red("error!")
62
+ puts pastel.red("Could not validate token: #{e.message}")
63
+ exit 1
64
+ end
65
+ else
66
+ puts pastel.red("No token provided.")
67
+ exit 1
68
+ end
69
+ else
70
+ puts ""
71
+ puts "You can set your token later with:"
72
+ puts " #{pastel.cyan('htb login')}"
73
+ puts ""
74
+ puts "Or set the environment variable:"
75
+ puts " #{pastel.cyan('export HTB_API_TOKEN=your_token')}"
76
+ exit 1
77
+ end
78
+ end
79
+
80
+ @client ||= HTB::Client.new(api_token: token)
81
+ end
82
+
83
+ def reset_client!
84
+ @client = nil
85
+ end
86
+
87
+ def spinner(message)
88
+ spinner = TTY::Spinner.new("[:spinner] #{message}", format: :dots)
89
+ spinner.auto_spin
90
+ result = yield
91
+ spinner.success(pastel.green("done"))
92
+ result
93
+ rescue StandardError => e
94
+ spinner.error(pastel.red("failed"))
95
+ raise e
96
+ end
97
+
98
+ def print_table(headers, rows)
99
+ table = TTY::Table.new(header: headers, rows: rows)
100
+ puts table.render(:unicode, padding: [0, 1])
101
+ end
102
+
103
+ def print_error(message)
104
+ puts pastel.red("Error: #{message}")
105
+ end
106
+
107
+ def print_success(message)
108
+ puts pastel.green(message)
109
+ end
110
+
111
+ def print_info(message)
112
+ puts pastel.cyan(message)
113
+ end
114
+ end
115
+ end
116
+ end
data/lib/htb/client.rb ADDED
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTB
4
+ class Client
5
+ attr_reader :api_token
6
+
7
+ def initialize(api_token: nil)
8
+ @api_token = api_token || HTB.api_token
9
+ raise AuthenticationError, "API token is required" unless @api_token
10
+ end
11
+
12
+ def connection
13
+ @connection ||= Faraday.new(url: BASE_URI) do |conn|
14
+ conn.request :json
15
+ conn.response :json, content_type: /\bjson$/
16
+ conn.headers["Authorization"] = "Bearer #{api_token}"
17
+ conn.headers["Accept"] = "application/json, text/plain, */*"
18
+ conn.headers["Content-Type"] = "application/json"
19
+ conn.headers["User-Agent"] = "htb-ruby/#{VERSION}"
20
+ conn.adapter Faraday.default_adapter
21
+ end
22
+ end
23
+
24
+ def get(path, params = {})
25
+ handle_response(connection.get("/api/#{API_VERSION}#{path}", params))
26
+ end
27
+
28
+ def post(path, body = {})
29
+ handle_response(connection.post("/api/#{API_VERSION}#{path}", body))
30
+ end
31
+
32
+ def put(path, body = {})
33
+ handle_response(connection.put("/api/#{API_VERSION}#{path}", body))
34
+ end
35
+
36
+ def delete(path, params = {})
37
+ handle_response(connection.delete("/api/#{API_VERSION}#{path}", params))
38
+ end
39
+
40
+ # Module accessors
41
+ def machines
42
+ @machines ||= Machines.new(self)
43
+ end
44
+
45
+ def users
46
+ @users ||= Users.new(self)
47
+ end
48
+
49
+ def vm
50
+ @vm ||= VM.new(self)
51
+ end
52
+
53
+ def challenges
54
+ @challenges ||= Challenges.new(self)
55
+ end
56
+
57
+ def vpn
58
+ @vpn ||= VPN.new(self)
59
+ end
60
+
61
+ private
62
+
63
+ def handle_response(response)
64
+ case response.status
65
+ when 200..299
66
+ response.body
67
+ when 401
68
+ raise AuthenticationError, "Invalid or expired API token"
69
+ when 404
70
+ raise NotFoundError, "Resource not found: #{response.body}"
71
+ when 429
72
+ raise RateLimitError, "Rate limit exceeded"
73
+ else
74
+ raise Error, "API error (#{response.status}): #{response.body}"
75
+ end
76
+ end
77
+ end
78
+ end
data/lib/htb/config.rb ADDED
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module HTB
7
+ class Config
8
+ CONFIG_FILE = File.expand_path("~/.htbrc")
9
+
10
+ class << self
11
+ def load
12
+ return {} unless File.exist?(CONFIG_FILE)
13
+
14
+ content = File.read(CONFIG_FILE)
15
+ YAML.safe_load(content, symbolize_names: true) || {}
16
+ rescue StandardError
17
+ {}
18
+ end
19
+
20
+ def save(config)
21
+ FileUtils.mkdir_p(File.dirname(CONFIG_FILE))
22
+ File.write(CONFIG_FILE, YAML.dump(config.transform_keys(&:to_s)))
23
+ File.chmod(0600, CONFIG_FILE) # Secure permissions
24
+ end
25
+
26
+ def api_token
27
+ # Priority: ENV > config file
28
+ ENV["HTB_API_TOKEN"] || load[:api_token]
29
+ end
30
+
31
+ def api_token=(token)
32
+ config = load
33
+ config[:api_token] = token
34
+ save(config)
35
+ end
36
+
37
+ def configured?
38
+ !api_token.nil? && !api_token.empty?
39
+ end
40
+
41
+ def clear!
42
+ File.delete(CONFIG_FILE) if File.exist?(CONFIG_FILE)
43
+ end
44
+
45
+ def config_file
46
+ CONFIG_FILE
47
+ end
48
+ end
49
+ end
50
+ end