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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE.txt +21 -0
- data/README.md +369 -0
- data/Rakefile +8 -0
- data/exe/htb +7 -0
- data/htb.gemspec +42 -0
- data/lib/htb/challenges.rb +111 -0
- data/lib/htb/cli/challenges.rb +195 -0
- data/lib/htb/cli/machines.rb +211 -0
- data/lib/htb/cli/main.rb +202 -0
- data/lib/htb/cli/users.rb +149 -0
- data/lib/htb/cli/vm.rb +129 -0
- data/lib/htb/cli/vpn.rb +182 -0
- data/lib/htb/cli.rb +116 -0
- data/lib/htb/client.rb +78 -0
- data/lib/htb/config.rb +50 -0
- data/lib/htb/machines.rb +142 -0
- data/lib/htb/users.rb +100 -0
- data/lib/htb/version.rb +5 -0
- data/lib/htb/vm.rb +63 -0
- data/lib/htb/vpn.rb +83 -0
- data/lib/htb.rb +42 -0
- metadata +225 -0
|
@@ -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
|
data/lib/htb/cli/main.rb
ADDED
|
@@ -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
|