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,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTB
4
+ module CLI
5
+ class Challenges < Thor
6
+ def self.exit_on_failure?
7
+ true
8
+ end
9
+
10
+ desc "list", "List all challenges"
11
+ option :retired, type: :boolean, desc: "Show retired challenges"
12
+ def list
13
+ client = CLI.client
14
+
15
+ data = CLI.spinner("Fetching challenges...") do
16
+ if options[:retired]
17
+ client.challenges.retired
18
+ else
19
+ client.challenges.list
20
+ end
21
+ end
22
+
23
+ if data && data["challenges"]
24
+ rows = data["challenges"].map do |c|
25
+ [
26
+ c["id"],
27
+ c["name"],
28
+ c["category_name"] || c["category"] || "N/A",
29
+ c["difficulty"] || "N/A",
30
+ c["solves"] || 0,
31
+ c["points"] || 0
32
+ ]
33
+ end
34
+
35
+ puts CLI.pastel.bold("\nChallenges:")
36
+ CLI.print_table(
37
+ ["ID", "Name", "Category", "Difficulty", "Solves", "Points"],
38
+ rows
39
+ )
40
+ puts "\nTotal: #{rows.size} challenges"
41
+ else
42
+ CLI.print_info("No challenges found")
43
+ end
44
+ rescue HTB::Error => e
45
+ CLI.print_error(e.message)
46
+ end
47
+
48
+ desc "info CHALLENGE_ID", "Show challenge details"
49
+ def info(challenge_id)
50
+ client = CLI.client
51
+ data = CLI.spinner("Fetching challenge...") { client.challenges.info(challenge_id) }
52
+
53
+ if data && data["challenge"]
54
+ c = data["challenge"]
55
+ puts CLI.pastel.bold("\n=== #{c['name']} ===")
56
+ CLI.print_table(
57
+ ["Field", "Value"],
58
+ [
59
+ ["ID", c["id"]],
60
+ ["Name", c["name"]],
61
+ ["Category", c["category_name"] || c["category"]],
62
+ ["Difficulty", c["difficulty"]],
63
+ ["Points", c["points"]],
64
+ ["Solves", c["solves"]],
65
+ ["Rating", "#{c['stars']}/5.0"],
66
+ ["Released", c["release_date"]],
67
+ ["Retired", c["retired"] ? "Yes" : "No"]
68
+ ]
69
+ )
70
+
71
+ if c["description"]
72
+ puts CLI.pastel.bold("\nDescription:")
73
+ puts " #{c['description']}"
74
+ end
75
+
76
+ if c["maker"]
77
+ puts CLI.pastel.bold("\nCreator:")
78
+ puts " #{c['maker']['name']}"
79
+ end
80
+ else
81
+ CLI.print_error("Challenge not found")
82
+ end
83
+ rescue HTB::Error => e
84
+ CLI.print_error(e.message)
85
+ end
86
+
87
+ desc "start CHALLENGE_ID", "Start a challenge"
88
+ def start(challenge_id)
89
+ client = CLI.client
90
+ result = CLI.spinner("Starting challenge...") { client.challenges.start(challenge_id) }
91
+
92
+ if result && result["success"]
93
+ CLI.print_success("Challenge started!")
94
+ if result["ip"] || result["port"]
95
+ puts " Connection: #{result['ip']}:#{result['port']}"
96
+ end
97
+ else
98
+ CLI.print_info(result["message"] || "Start request sent")
99
+ end
100
+ rescue HTB::Error => e
101
+ CLI.print_error(e.message)
102
+ end
103
+
104
+ desc "stop CHALLENGE_ID", "Stop a challenge"
105
+ def stop(challenge_id)
106
+ client = CLI.client
107
+ result = CLI.spinner("Stopping challenge...") { client.challenges.stop(challenge_id) }
108
+
109
+ if result && result["success"]
110
+ CLI.print_success("Challenge stopped!")
111
+ else
112
+ CLI.print_info(result["message"] || "Stop request sent")
113
+ end
114
+ rescue HTB::Error => e
115
+ CLI.print_error(e.message)
116
+ end
117
+
118
+ desc "spawn CHALLENGE_ID", "Spawn a challenge instance"
119
+ def spawn(challenge_id)
120
+ client = CLI.client
121
+ result = CLI.spinner("Spawning challenge...") { client.challenges.spawn(challenge_id) }
122
+
123
+ if result
124
+ CLI.print_success("Challenge instance spawning!")
125
+ CLI.print_info(result["message"]) if result["message"]
126
+ end
127
+ rescue HTB::Error => e
128
+ CLI.print_error(e.message)
129
+ end
130
+
131
+ desc "own CHALLENGE_ID --flag FLAG", "Submit a flag"
132
+ option :flag, required: true, desc: "The flag to submit"
133
+ option :difficulty, type: :numeric, default: 50, desc: "Difficulty rating"
134
+ def own(challenge_id)
135
+ client = CLI.client
136
+ result = CLI.spinner("Submitting flag...") do
137
+ client.challenges.own(
138
+ challenge_id: challenge_id.to_i,
139
+ flag: options[:flag],
140
+ difficulty: options[:difficulty]
141
+ )
142
+ end
143
+
144
+ if result && result["success"]
145
+ CLI.print_success("Flag accepted! #{result['message']}")
146
+ else
147
+ CLI.print_error(result["message"] || "Flag rejected")
148
+ end
149
+ rescue HTB::Error => e
150
+ CLI.print_error(e.message)
151
+ end
152
+
153
+ desc "categories", "List challenge categories"
154
+ def categories
155
+ client = CLI.client
156
+ data = CLI.spinner("Fetching categories...") { client.challenges.categories }
157
+
158
+ if data && data["info"]
159
+ puts CLI.pastel.bold("\nChallenge Categories:")
160
+ data["info"].each do |cat|
161
+ puts " #{CLI.pastel.cyan(cat['name'])} (#{cat['challenges_count']} challenges)"
162
+ end
163
+ else
164
+ CLI.print_info("No categories found")
165
+ end
166
+ rescue HTB::Error => e
167
+ CLI.print_error(e.message)
168
+ end
169
+
170
+ desc "active", "Show active challenge"
171
+ def active
172
+ client = CLI.client
173
+ data = CLI.spinner("Fetching active challenge...") { client.challenges.active }
174
+
175
+ if data && data["info"]
176
+ info = data["info"]
177
+ puts CLI.pastel.bold("\n=== Active Challenge ===")
178
+ CLI.print_table(
179
+ ["Field", "Value"],
180
+ [
181
+ ["Name", info["name"]],
182
+ ["Category", info["category_name"] || info["category"]],
183
+ ["IP", info["ip"] || "N/A"],
184
+ ["Port", info["port"] || "N/A"]
185
+ ]
186
+ )
187
+ else
188
+ CLI.print_info("No active challenge")
189
+ end
190
+ rescue HTB::Error => e
191
+ CLI.print_info("No active challenge")
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTB
4
+ module CLI
5
+ class Machines < Thor
6
+ def self.exit_on_failure?
7
+ true
8
+ end
9
+
10
+ desc "list", "List all active machines"
11
+ option :retired, type: :boolean, desc: "Show retired machines instead"
12
+ option :sp, type: :boolean, desc: "Show Starting Point machines"
13
+ def list
14
+ client = CLI.client
15
+
16
+ machines = CLI.spinner("Fetching machines...") do
17
+ if options[:retired]
18
+ client.machines.retired
19
+ elsif options[:sp]
20
+ client.machines.starting_point
21
+ else
22
+ client.machines.list
23
+ end
24
+ end
25
+
26
+ if machines && machines["info"]
27
+ rows = machines["info"].map do |m|
28
+ difficulty = m["difficultyText"] || m["difficulty"] || "N/A"
29
+ os = m["os"] || "N/A"
30
+ [
31
+ m["id"],
32
+ m["name"],
33
+ os,
34
+ difficulty,
35
+ m["user_owns_count"] || 0,
36
+ m["root_owns_count"] || 0,
37
+ m["release"] || "N/A"
38
+ ]
39
+ end
40
+
41
+ puts CLI.pastel.bold("\nMachines:")
42
+ CLI.print_table(
43
+ ["ID", "Name", "OS", "Difficulty", "User Owns", "Root Owns", "Released"],
44
+ rows
45
+ )
46
+ puts "\nTotal: #{rows.size} machines"
47
+ else
48
+ CLI.print_info("No machines found")
49
+ end
50
+ rescue HTB::Error => e
51
+ CLI.print_error(e.message)
52
+ end
53
+
54
+ desc "info MACHINE", "Show machine details (by ID or name)"
55
+ def info(machine)
56
+ client = CLI.client
57
+ data = CLI.spinner("Fetching machine info...") { client.machines.profile(machine) }
58
+
59
+ if data && data["info"]
60
+ m = data["info"]
61
+ puts CLI.pastel.bold("\n=== #{m['name']} ===")
62
+ CLI.print_table(
63
+ ["Field", "Value"],
64
+ [
65
+ ["ID", m["id"]],
66
+ ["Name", m["name"]],
67
+ ["OS", m["os"]],
68
+ ["Difficulty", m["difficultyText"] || m["difficulty"]],
69
+ ["Points", m["points"]],
70
+ ["User Owns", m["user_owns_count"]],
71
+ ["Root Owns", m["root_owns_count"]],
72
+ ["Rating", "#{m['stars']}/5.0"],
73
+ ["Released", m["release"]],
74
+ ["Retired", m["retired"] ? "Yes" : "No"],
75
+ ["Free", m["free"] ? "Yes" : "No"]
76
+ ]
77
+ )
78
+
79
+ if m["ip"]
80
+ puts CLI.pastel.bold("\nConnection:")
81
+ puts " IP: #{CLI.pastel.green(m['ip'])}"
82
+ end
83
+
84
+ if m["maker"]
85
+ puts CLI.pastel.bold("\nCreator:")
86
+ puts " #{m['maker']['name']}"
87
+ end
88
+ else
89
+ CLI.print_error("Machine not found")
90
+ end
91
+ rescue HTB::Error => e
92
+ CLI.print_error(e.message)
93
+ end
94
+
95
+ desc "active", "Show currently active machine"
96
+ def active
97
+ client = CLI.client
98
+ data = CLI.spinner("Fetching active machine...") { client.machines.active }
99
+
100
+ if data && data["info"]
101
+ m = data["info"]
102
+ puts CLI.pastel.bold("\n=== Active Machine ===")
103
+ CLI.print_table(
104
+ ["Field", "Value"],
105
+ [
106
+ ["Name", m["name"]],
107
+ ["IP", m["ip"] || "N/A"],
108
+ ["OS", m["os"]],
109
+ ["Difficulty", m["difficultyText"] || m["difficulty"]]
110
+ ]
111
+ )
112
+ else
113
+ CLI.print_info("No active machine")
114
+ end
115
+ rescue HTB::Error => e
116
+ CLI.print_info("No active machine")
117
+ end
118
+
119
+ desc "play MACHINE_ID", "Start playing a machine (free VPN)"
120
+ def play(machine_id)
121
+ client = CLI.client
122
+ result = CLI.spinner("Starting machine...") { client.machines.play(machine_id) }
123
+
124
+ if result && result["success"]
125
+ CLI.print_success("Machine started successfully!")
126
+ else
127
+ CLI.print_error(result["message"] || "Failed to start machine")
128
+ end
129
+ rescue HTB::Error => e
130
+ CLI.print_error(e.message)
131
+ end
132
+
133
+ desc "stop", "Stop current machine"
134
+ def stop
135
+ client = CLI.client
136
+ result = CLI.spinner("Stopping machine...") { client.machines.stop }
137
+
138
+ if result && result["success"]
139
+ CLI.print_success("Machine stopped!")
140
+ else
141
+ CLI.print_error(result["message"] || "Failed to stop machine")
142
+ end
143
+ rescue HTB::Error => e
144
+ CLI.print_error(e.message)
145
+ end
146
+
147
+ desc "own MACHINE_ID --flag FLAG", "Submit a flag for ownership"
148
+ option :flag, required: true, desc: "The flag to submit"
149
+ option :type, type: :string, enum: %w[user root auto], default: "auto", desc: "Flag type: user, root, or auto (default)"
150
+ option :difficulty, type: :numeric, default: 50, desc: "Difficulty rating (10-100)"
151
+ def own(machine_id)
152
+ client = CLI.client
153
+ flag_type = options[:type]
154
+
155
+ result = CLI.spinner("Submitting #{flag_type} flag...") do
156
+ case flag_type
157
+ when "user"
158
+ client.machines.own_user(machine_id.to_i, flag: options[:flag], difficulty: options[:difficulty])
159
+ when "root"
160
+ client.machines.own_root(machine_id.to_i, flag: options[:flag], difficulty: options[:difficulty])
161
+ else
162
+ client.machines.own(machine_id: machine_id.to_i, flag: options[:flag], difficulty: options[:difficulty])
163
+ end
164
+ end
165
+
166
+ if result && result["success"]
167
+ CLI.print_success("Flag accepted! #{result['message']}")
168
+ else
169
+ CLI.print_error(result["message"] || "Flag rejected")
170
+ end
171
+ rescue HTB::Error => e
172
+ CLI.print_error(e.message)
173
+ end
174
+
175
+ desc "search QUERY", "Search for machines"
176
+ def search(query)
177
+ client = CLI.client
178
+ results = CLI.spinner("Searching...") { client.machines.search(query) }
179
+
180
+ if results && results["machines"]
181
+ rows = results["machines"].map do |m|
182
+ [m["id"], m["name"], m["os"], m["difficulty"]]
183
+ end
184
+ CLI.print_table(["ID", "Name", "OS", "Difficulty"], rows)
185
+ else
186
+ CLI.print_info("No results found")
187
+ end
188
+ rescue HTB::Error => e
189
+ CLI.print_error(e.message)
190
+ end
191
+
192
+ desc "recommended", "Show recommended machines"
193
+ def recommended
194
+ client = CLI.client
195
+ data = CLI.spinner("Fetching recommendations...") { client.machines.recommended }
196
+
197
+ if data && data["info"]
198
+ rows = data["info"].map do |m|
199
+ [m["id"], m["name"], m["os"], m["difficultyText"] || m["difficulty"]]
200
+ end
201
+ puts CLI.pastel.bold("\nRecommended Machines:")
202
+ CLI.print_table(["ID", "Name", "OS", "Difficulty"], rows)
203
+ else
204
+ CLI.print_info("No recommendations available")
205
+ end
206
+ rescue HTB::Error => e
207
+ CLI.print_error(e.message)
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTB
4
+ module CLI
5
+ class Main < Thor
6
+ def self.exit_on_failure?
7
+ true
8
+ end
9
+
10
+ desc "machines SUBCOMMAND", "Manage machines"
11
+ subcommand "machines", Machines
12
+
13
+ desc "vm SUBCOMMAND", "Manage VMs (spawn, terminate, reset)"
14
+ subcommand "vm", VM
15
+
16
+ desc "users SUBCOMMAND", "User profiles and info"
17
+ subcommand "users", Users
18
+
19
+ desc "challenges SUBCOMMAND", "Manage challenges"
20
+ subcommand "challenges", Challenges
21
+
22
+ desc "vpn SUBCOMMAND", "VPN and lab management"
23
+ subcommand "vpn", VPN
24
+
25
+ desc "spawn MACHINE_ID", "Spawn a machine (shortcut for vm spawn)"
26
+ def spawn(machine_id)
27
+ invoke "vm:spawn", [machine_id]
28
+ end
29
+
30
+ desc "terminate MACHINE_ID", "Terminate a machine (shortcut for vm terminate)"
31
+ def terminate(machine_id)
32
+ invoke "vm:terminate", [machine_id]
33
+ end
34
+
35
+ desc "status", "Show current VM and VPN status"
36
+ def status
37
+ client = CLI.client
38
+
39
+ puts CLI.pastel.bold("\n=== VM Status ===")
40
+ begin
41
+ vm_status = CLI.spinner("Fetching VM status...") { client.vm.active }
42
+ if vm_status && vm_status["info"]
43
+ info = vm_status["info"]
44
+ CLI.print_table(
45
+ ["Field", "Value"],
46
+ [
47
+ ["Machine", info["name"] || "N/A"],
48
+ ["IP", info["ip"] || "N/A"],
49
+ ["Type", info["type"] || "N/A"],
50
+ ["Expires", info["expires_at"] || "N/A"]
51
+ ]
52
+ )
53
+ else
54
+ CLI.print_info("No active VM")
55
+ end
56
+ rescue HTB::Error => e
57
+ CLI.print_info("No active VM")
58
+ end
59
+
60
+ puts CLI.pastel.bold("\n=== VPN Status ===")
61
+ begin
62
+ vpn_status = CLI.spinner("Fetching VPN status...") { client.vpn.status }
63
+ if vpn_status && vpn_status["data"]
64
+ data = vpn_status["data"]
65
+ CLI.print_table(
66
+ ["Field", "Value"],
67
+ [
68
+ ["Connected", data["connection"] ? "Yes" : "No"],
69
+ ["Server", data["server"] || "N/A"],
70
+ ["IP", data["ip"] || "N/A"]
71
+ ]
72
+ )
73
+ else
74
+ CLI.print_info("VPN status unavailable")
75
+ end
76
+ rescue HTB::Error => e
77
+ CLI.print_error(e.message)
78
+ end
79
+ end
80
+
81
+ desc "me", "Show current user info"
82
+ def me
83
+ client = CLI.client
84
+ user = CLI.spinner("Fetching profile...") { client.users.me }
85
+
86
+ if user && user["info"]
87
+ info = user["info"]
88
+ puts CLI.pastel.bold("\n=== Your Profile ===")
89
+ CLI.print_table(
90
+ ["Field", "Value"],
91
+ [
92
+ ["Username", info["name"] || "N/A"],
93
+ ["ID", info["id"] || "N/A"],
94
+ ["Rank", info["rank"] || "N/A"],
95
+ ["Points", info["points"] || "N/A"],
96
+ ["User Owns", info["user_owns"] || 0],
97
+ ["Root Owns", info["root_owns"] || 0],
98
+ ["Respect", info["respects"] || 0]
99
+ ]
100
+ )
101
+ else
102
+ CLI.print_error("Could not fetch profile")
103
+ end
104
+ rescue HTB::Error => e
105
+ CLI.print_error(e.message)
106
+ end
107
+
108
+ desc "version", "Show version"
109
+ def version
110
+ puts "htb #{HTB::VERSION}"
111
+ end
112
+
113
+ desc "login", "Configure API token"
114
+ def login
115
+ puts CLI.pastel.bold("HTB Login")
116
+ puts ""
117
+ puts "To get your API token:"
118
+ puts " 1. Go to #{CLI.pastel.cyan(TOKEN_URL)}"
119
+ puts " 2. Scroll down to 'App Tokens'"
120
+ puts " 3. Create a new token or copy an existing one"
121
+ puts ""
122
+
123
+ token = CLI.prompt.mask("Paste your API token:")
124
+
125
+ if token && !token.strip.empty?
126
+ token = token.strip
127
+ print "Validating token... "
128
+ begin
129
+ test_client = HTB::Client.new(api_token: token)
130
+ user = test_client.users.me
131
+ puts CLI.pastel.green("valid!")
132
+
133
+ HTB::Config.api_token = token
134
+ CLI.reset_client!
135
+
136
+ puts ""
137
+ CLI.print_success("Token saved to #{HTB::Config.config_file}")
138
+
139
+ if user && user["info"]
140
+ puts ""
141
+ puts "Welcome, #{CLI.pastel.bold(user['info']['name'])}!"
142
+ puts "Rank: #{user['info']['rank']}"
143
+ end
144
+ rescue HTB::AuthenticationError
145
+ puts CLI.pastel.red("invalid!")
146
+ CLI.print_error("The token appears to be invalid. Please check and try again.")
147
+ exit 1
148
+ rescue StandardError => e
149
+ puts CLI.pastel.red("error!")
150
+ CLI.print_error("Could not validate token: #{e.message}")
151
+ exit 1
152
+ end
153
+ else
154
+ CLI.print_error("No token provided.")
155
+ exit 1
156
+ end
157
+ end
158
+
159
+ desc "logout", "Remove saved API token"
160
+ def logout
161
+ if HTB::Config.api_token
162
+ if CLI.prompt.yes?("Remove saved API token from #{HTB::Config.config_file}?")
163
+ HTB::Config.clear!
164
+ CLI.reset_client!
165
+ CLI.print_success("Logged out. Token removed.")
166
+ else
167
+ CLI.print_info("Cancelled.")
168
+ end
169
+ else
170
+ CLI.print_info("No saved token found.")
171
+ end
172
+ end
173
+
174
+ desc "config", "Show current configuration"
175
+ def config
176
+ puts CLI.pastel.bold("\n=== HTB Configuration ===")
177
+ puts ""
178
+ puts "Config file: #{HTB::Config.config_file}"
179
+
180
+ if File.exist?(HTB::Config.config_file)
181
+ puts "File exists: #{CLI.pastel.green('Yes')}"
182
+ else
183
+ puts "File exists: #{CLI.pastel.yellow('No')}"
184
+ end
185
+
186
+ token = HTB::Config.api_token
187
+ if token
188
+ # Show masked token
189
+ masked = token.length > 20 ? "#{token[0..10]}...#{token[-10..]}" : "***"
190
+ source = ENV["HTB_API_TOKEN"] ? "environment variable" : "config file"
191
+ puts "Token: #{CLI.pastel.green(masked)} (from #{source})"
192
+ else
193
+ puts "Token: #{CLI.pastel.red('Not configured')}"
194
+ puts ""
195
+ puts "Run #{CLI.pastel.cyan('htb login')} to configure."
196
+ end
197
+ end
198
+
199
+ map %w[-v --version] => :version
200
+ end
201
+ end
202
+ end