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,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
|
data/lib/htb/cli/vpn.rb
ADDED
|
@@ -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
|