sty 0.0.1
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/.document +2 -0
- data/README.md +5 -0
- data/auth-keys.yaml +8 -0
- data/auth.yaml +19 -0
- data/bin/sty +21 -0
- data/ec2.yaml +7 -0
- data/ext/install/Rakefile +39 -0
- data/lib/sty.rb +7 -0
- data/lib/sty/account.rb +18 -0
- data/lib/sty/auth-rotate.rb +69 -0
- data/lib/sty/auth.rb +188 -0
- data/lib/sty/cli.rb +71 -0
- data/lib/sty/config.rb +9 -0
- data/lib/sty/console.rb +106 -0
- data/lib/sty/dig.rb +22 -0
- data/lib/sty/functions.rb +94 -0
- data/lib/sty/info.rb +42 -0
- data/lib/sty/keychain.rb +3 -0
- data/lib/sty/proxy.rb +55 -0
- data/lib/sty/ssh.rb +270 -0
- data/lib/sty/ssm.rb +164 -0
- data/lib/sty/store.rb +3 -0
- data/proxy.yaml +7 -0
- data/sty.gemspec +27 -0
- metadata +157 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: be460e9dd95543a4956eddfaa6e45b8f1d8f9f89d74560bc769e23ad8284fd71
|
4
|
+
data.tar.gz: 0f6383f3d4913d6997420b78b6a30a9c266d029a45b2539c2fee6fa0c33aba77
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 1a6cab7166156c2be6e4f4ca0e7c66fe34687ec30b738ad4b35cf229f04852540fcc29bd2488f583e09a666c3f837bad12c1eb01c7c12c065ce282dce53e4d90
|
7
|
+
data.tar.gz: 67468b0793e2073b989d91d20d4e0e81e067b3e7cc9429bed8cb67de829e2161ce9cc0c898a9c97141dee04d9200e910bda0e6f8aa0d27bb902e0d19e8ce4400
|
data/.document
ADDED
data/README.md
ADDED
@@ -0,0 +1,5 @@
|
|
1
|
+
# Sty
|
2
|
+
[](https://travis-ci.org/sozonnyk/sty)
|
3
|
+
|
4
|
+
<a title="Jean-Pol GRANDMONT [CC BY 3.0 (https://creativecommons.org/licenses/by/3.0)], via Wikimedia Commons" href="https://commons.wikimedia.org/wiki/File:Fourneau_St-Michel_-_Porcherie_(Forri%C3%A8res).JPG">
|
5
|
+
<img alt="Fourneau St-Michel - Porcherie (Forrières)" src="https://upload.wikimedia.org/wikipedia/commons/thumb/7/7c/Fourneau_St-Michel_-_Porcherie_%28Forri%C3%A8res%29.JPG/1024px-Fourneau_St-Michel_-_Porcherie_%28Forri%C3%A8res%29.JPG"></a>
|
data/auth-keys.yaml
ADDED
data/auth.yaml
ADDED
data/bin/sty
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
#!/bin/bash
|
2
|
+
|
3
|
+
if [ -z "$ZSH_VERSION" ] && [ -z "$BASH_VERSION" ]; then
|
4
|
+
echo "Your shell is neither bash nor zsh. Sty will not work."
|
5
|
+
exit 1
|
6
|
+
fi
|
7
|
+
|
8
|
+
if [[ $0 == "$BASH_SOURCE" ]]; then
|
9
|
+
export STY_SOURCE_RUN=false
|
10
|
+
else
|
11
|
+
export STY_SOURCE_RUN=true
|
12
|
+
fi
|
13
|
+
|
14
|
+
result=`ruby -e "require 'sty'" $@`
|
15
|
+
|
16
|
+
marker="#EVAL#"
|
17
|
+
if [[ "$result" =~ $marker ]]; then
|
18
|
+
eval "$result"
|
19
|
+
else
|
20
|
+
printf "$result\n"
|
21
|
+
fi
|
data/ec2.yaml
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'fileutils'
|
3
|
+
|
4
|
+
task default: ['install_script']
|
5
|
+
|
6
|
+
task :install_script do
|
7
|
+
|
8
|
+
fail "Sty doesnt work on Windows" if Gem.win_platform?
|
9
|
+
|
10
|
+
# Create /usr/local/bin/sty
|
11
|
+
src = "#{Dir.pwd}/../../bin/sty"
|
12
|
+
dst = '/usr/local/bin/sty'
|
13
|
+
|
14
|
+
#TODO check if /usr/local/bin exists
|
15
|
+
begin
|
16
|
+
FileUtils.cp(src, dst)
|
17
|
+
rescue Errno::EPERM, Errno::EACCES => e
|
18
|
+
puts "No permission to create #{dst}, let's try with sudo."
|
19
|
+
`sudo cp #{src} #{dst}`
|
20
|
+
end
|
21
|
+
|
22
|
+
# Create ~/.sty
|
23
|
+
home = File.expand_path('~')
|
24
|
+
sty_home = "#{home}/.sty/"
|
25
|
+
FileUtils.mkdir_p(sty_home)
|
26
|
+
FileUtils.mkdir_p("#{sty_home}/auth-cache")
|
27
|
+
|
28
|
+
# Copy config file examples
|
29
|
+
yamls = Dir.glob("#{Dir.pwd}/../../*.yaml")
|
30
|
+
dest_yamls = Dir.glob("#{sty_home}/*.yaml").map{|f| File.basename(f)}
|
31
|
+
|
32
|
+
yamls.reject! do |y|
|
33
|
+
dest_yamls.include?(File.basename(y))
|
34
|
+
end
|
35
|
+
|
36
|
+
FileUtils.cp(yamls, sty_home)
|
37
|
+
|
38
|
+
puts "All done"
|
39
|
+
end
|
data/lib/sty.rb
ADDED
data/lib/sty/account.rb
ADDED
@@ -0,0 +1,69 @@
|
|
1
|
+
require_relative 'functions'
|
2
|
+
|
3
|
+
require 'aws-sdk-iam'
|
4
|
+
|
5
|
+
DEFAULT_KEY_AGE = 90
|
6
|
+
Aws.config.update(:http_proxy => ENV['https_proxy'])
|
7
|
+
|
8
|
+
class Rotator
|
9
|
+
|
10
|
+
def rotate
|
11
|
+
keys = yaml('auth-keys')
|
12
|
+
path = to_path(act_acc)
|
13
|
+
current_key = keys['accounts'].dig(*path)
|
14
|
+
puts "Current account #{white(act_acc)}"
|
15
|
+
|
16
|
+
unless current_key
|
17
|
+
puts red("You need to authenticate to userstore account to rotate keys.")
|
18
|
+
exit(1)
|
19
|
+
end
|
20
|
+
puts "Current key #{white(current_key['key_id'])}"
|
21
|
+
|
22
|
+
iam = Aws::IAM::Client.new(region: region)
|
23
|
+
|
24
|
+
account_keys = iam.list_access_keys.access_key_metadata
|
25
|
+
|
26
|
+
key_to_rotate = account_keys.select {|k| k.access_key_id == current_key['key_id']}.first
|
27
|
+
|
28
|
+
unless key_to_rotate
|
29
|
+
puts red("Key #{current_key['key_id']} for account #{ act_acc } doesn't exist in AWS.")
|
30
|
+
exit(1)
|
31
|
+
end
|
32
|
+
|
33
|
+
if account_keys.size > 1
|
34
|
+
puts "You have #{white(account_keys.size)} keys already. Remove other keys before trying to rotate."
|
35
|
+
account_keys.each do |k|
|
36
|
+
key = k.access_key_id == current_key['key_id'] ? "#{white(k.access_key_id)} <-- Keep" : "#{red(k.access_key_id)} <-- Remove"
|
37
|
+
puts key
|
38
|
+
end
|
39
|
+
exit(1)
|
40
|
+
end
|
41
|
+
|
42
|
+
key_age_days = ((Time.now - key_to_rotate.create_date)/3600/24).round
|
43
|
+
puts "The key is #{white(key_age_days)} days old."
|
44
|
+
|
45
|
+
if key_age_days < DEFAULT_KEY_AGE
|
46
|
+
puts green('All good.')
|
47
|
+
exit(0)
|
48
|
+
else
|
49
|
+
puts 'Key needs rotation.'
|
50
|
+
end
|
51
|
+
|
52
|
+
new_key = iam.create_access_key()
|
53
|
+
current_key['key_id'] = new_key.access_key.access_key_id
|
54
|
+
current_key['secret_key'] = new_key.access_key.secret_access_key
|
55
|
+
|
56
|
+
puts "New key #{white(current_key['key_id'])} was created"
|
57
|
+
|
58
|
+
dump(keys, 'auth-keys')
|
59
|
+
|
60
|
+
account_keys.each do |k|
|
61
|
+
puts "Removing old key #{white(k.access_key_id)}"
|
62
|
+
iam.delete_access_key(access_key_id: k.access_key_id)
|
63
|
+
end
|
64
|
+
|
65
|
+
puts green('Key was rotated successfully.')
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
Rotator.new.rotate
|
data/lib/sty/auth.rb
ADDED
@@ -0,0 +1,188 @@
|
|
1
|
+
require_relative 'functions'
|
2
|
+
require_relative 'dig'
|
3
|
+
|
4
|
+
SESSION_DURATION_SECONDS = 43_200
|
5
|
+
DEFAULT_ROLE_NAME = 'ReadOnlyRole'
|
6
|
+
DEFAULT_REGION = 'ap-southeast-2'
|
7
|
+
|
8
|
+
class Auth
|
9
|
+
|
10
|
+
def logout
|
11
|
+
current = ENV['AWS_ACTIVE_ACCOUNT']
|
12
|
+
identity = ENV['AWS_ACTIVE_IDENTITY']
|
13
|
+
STDERR.puts "Logging off from: #{white(current)}"
|
14
|
+
if current
|
15
|
+
cache = cache_file(to_path(current),identity)
|
16
|
+
begin
|
17
|
+
File.delete(cache)
|
18
|
+
rescue Errno::ENOENT => e
|
19
|
+
end
|
20
|
+
end
|
21
|
+
puts "#EVAL#"
|
22
|
+
puts "unset AWS_ACTIVE_ACCOUNT AWS_SESSION_EXPIRY AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_ACTIVE_IDENTITY"
|
23
|
+
end
|
24
|
+
|
25
|
+
def deep_merge(h1,h2)
|
26
|
+
h1.merge(h2){|k,v1,v2| v1.is_a?(Hash) && v2.is_a?(Hash) ? deep_merge(v1,v2) : v2}
|
27
|
+
end
|
28
|
+
|
29
|
+
def initialize
|
30
|
+
#aws-sdk is slow, so load it only when needed
|
31
|
+
require 'aws-sdk-core'
|
32
|
+
Aws.config.update(:http_proxy => ENV['https_proxy'])
|
33
|
+
@config = deep_merge(yaml('auth'),yaml('auth-keys'))
|
34
|
+
end
|
35
|
+
|
36
|
+
def check_proxy
|
37
|
+
unless ENV.find { |k,v| k =~ /HTTPS_PROXY/i }
|
38
|
+
STDERR.puts red("WARNING! \"https_proxy\" env variable is not set.")
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def user
|
43
|
+
@config['username']
|
44
|
+
end
|
45
|
+
|
46
|
+
def account(path)
|
47
|
+
acc = @config['accounts'].dig(*path)
|
48
|
+
unless acc
|
49
|
+
STDERR.puts red("ERROR! Account #{to_fqn(path)} not found in config")
|
50
|
+
exit 1
|
51
|
+
end
|
52
|
+
|
53
|
+
acc['path'] = path
|
54
|
+
acc
|
55
|
+
end
|
56
|
+
|
57
|
+
def parent(acc)
|
58
|
+
acc['parent']
|
59
|
+
end
|
60
|
+
|
61
|
+
def cached_creds(path, identity)
|
62
|
+
acc_fqn = to_fqn(path)
|
63
|
+
begin
|
64
|
+
cached_creds = Psych.load_file(cache_file(path, identity))
|
65
|
+
raise(RuntimeError) unless cached_creds
|
66
|
+
rescue Errno::ENOENT, RuntimeError
|
67
|
+
STDERR.puts "No cached creds for #{acc_fqn}"
|
68
|
+
return nil
|
69
|
+
end
|
70
|
+
|
71
|
+
remained_minutes = ((cached_creds['expiration'] - Time.now) / 60).to_i
|
72
|
+
|
73
|
+
if remained_minutes > 0
|
74
|
+
STDERR.puts "Loaded cached creds for #{acc_fqn}"
|
75
|
+
STDERR.puts "Credentials will stay active for the next #{remained_minutes} min"
|
76
|
+
return {creds: Aws::Credentials.new(cached_creds['access_key_id'],
|
77
|
+
cached_creds['secret_access_key'],
|
78
|
+
cached_creds['session_token']),
|
79
|
+
expiry: cached_creds['expiration']}
|
80
|
+
else
|
81
|
+
STDERR.puts "Cached creds for #{acc_fqn} expired"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def save_creds(acc, creds, expiration, identity)
|
86
|
+
creds_hash = {'access_key_id' => creds.access_key_id,
|
87
|
+
'secret_access_key' => creds.secret_access_key,
|
88
|
+
'session_token' => creds.session_token,
|
89
|
+
'expiration' => expiration
|
90
|
+
}
|
91
|
+
File.open(cache_file(acc['path'], identity), 'w') do |file|
|
92
|
+
file.write(Psych.dump(creds_hash))
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def login_bare(acc)
|
97
|
+
|
98
|
+
path = acc['path']
|
99
|
+
acc_fqn = to_fqn(path)
|
100
|
+
|
101
|
+
cached = cached_creds(path, user)
|
102
|
+
return { creds: cached[:creds], expiry: cached[:expiry], identity: user } if cached
|
103
|
+
|
104
|
+
STDERR.puts "Enter MFA for #{acc_fqn}"
|
105
|
+
token = STDIN.gets.chomp
|
106
|
+
|
107
|
+
mfa = "arn:aws:iam::#{acc['acc_id']}:mfa/#{user}"
|
108
|
+
|
109
|
+
bare_creds = Aws::Credentials.new(acc['key_id'], acc['secret_key'])
|
110
|
+
|
111
|
+
sts = Aws::STS::Client.new(credentials: bare_creds, region: region)
|
112
|
+
|
113
|
+
begin
|
114
|
+
session = sts.get_session_token(duration_seconds: SESSION_DURATION_SECONDS,
|
115
|
+
serial_number: mfa,
|
116
|
+
token_code: token)
|
117
|
+
|
118
|
+
creds = Aws::Credentials.new(session.credentials.access_key_id,
|
119
|
+
session.credentials.secret_access_key,
|
120
|
+
session.credentials.session_token)
|
121
|
+
rescue Exception => e
|
122
|
+
STDERR.puts red("ERROR! Unable to obtain credentials for #{acc_fqn}")
|
123
|
+
STDERR.puts white(e.message)
|
124
|
+
exit 1
|
125
|
+
end
|
126
|
+
|
127
|
+
STDERR.puts green("Successfully obtained creds for #{acc_fqn}")
|
128
|
+
|
129
|
+
save_creds(acc, creds, session.credentials.expiration, user)
|
130
|
+
|
131
|
+
{creds: creds, expiry: session.credentials.expiration, identity: user}
|
132
|
+
end
|
133
|
+
|
134
|
+
def login_role(acc, role)
|
135
|
+
path = acc['path']
|
136
|
+
active_role = role || acc['role'] || DEFAULT_ROLE_NAME
|
137
|
+
role_arn = "arn:aws:iam::#{acc['acc_id']}:role/#{active_role}"
|
138
|
+
|
139
|
+
cached = cached_creds(path, active_role)
|
140
|
+
return { creds: cached[:creds], expiry: cached[:expiry], identity: active_role } if cached
|
141
|
+
|
142
|
+
parent_path = to_path(parent(acc))
|
143
|
+
parent_acc = account(parent_path)
|
144
|
+
parent_creds = login_bare(parent_acc)[:creds]
|
145
|
+
sts = Aws::STS::Client.new(
|
146
|
+
credentials: parent_creds,
|
147
|
+
endpoint: 'https://sts.ap-southeast-2.amazonaws.com',
|
148
|
+
region: region
|
149
|
+
)
|
150
|
+
begin
|
151
|
+
creds = sts.assume_role(role_arn: role_arn,
|
152
|
+
role_session_name: "#{user}-#{parent_path.join('-')}",
|
153
|
+
duration_seconds: 3600).credentials
|
154
|
+
rescue Exception => e
|
155
|
+
STDERR.puts red("ERROR! Unable to obtain credentials for #{to_fqn(path)}")
|
156
|
+
STDERR.puts white(e.message)
|
157
|
+
exit 1
|
158
|
+
end
|
159
|
+
STDERR.puts green("Successfully obtained creds for #{to_fqn(path)}")
|
160
|
+
save_creds(acc, creds, creds.expiration, active_role)
|
161
|
+
|
162
|
+
{creds: creds, expiry: creds.expiration, identity: active_role}
|
163
|
+
end
|
164
|
+
|
165
|
+
def print_creds(acc, creds)
|
166
|
+
puts "#EVAL#"
|
167
|
+
puts "export AWS_ACTIVE_ACCOUNT=#{to_fqn(acc['path'])}"
|
168
|
+
puts "export AWS_ACTIVE_IDENTITY=#{creds[:identity]}"
|
169
|
+
puts "export AWS_SESSION_EXPIRY=\"#{creds[:expiry]}\""
|
170
|
+
puts "export AWS_ACCESS_KEY_ID=#{creds[:creds].access_key_id}"
|
171
|
+
puts "export AWS_SECRET_ACCESS_KEY=#{creds[:creds].secret_access_key}"
|
172
|
+
puts "export AWS_SESSION_TOKEN=#{creds[:creds].session_token}"
|
173
|
+
end
|
174
|
+
|
175
|
+
def login(fqn, role = nil)
|
176
|
+
check_proxy
|
177
|
+
acc = account(to_path(fqn))
|
178
|
+
|
179
|
+
if parent(acc)
|
180
|
+
creds = login_role(acc, role)
|
181
|
+
else
|
182
|
+
creds = login_bare(acc)
|
183
|
+
end
|
184
|
+
|
185
|
+
print_creds(acc, creds) if creds
|
186
|
+
end
|
187
|
+
|
188
|
+
end
|
data/lib/sty/cli.rb
ADDED
@@ -0,0 +1,71 @@
|
|
1
|
+
require 'thor'
|
2
|
+
require_relative 'info'
|
3
|
+
require_relative 'auth'
|
4
|
+
require_relative 'console'
|
5
|
+
require_relative 'proxy'
|
6
|
+
require_relative 'ssh'
|
7
|
+
|
8
|
+
class Cli < Thor
|
9
|
+
map auth: :login, acct: :account, px: :proxy
|
10
|
+
|
11
|
+
def self.basename
|
12
|
+
'sty'
|
13
|
+
end
|
14
|
+
|
15
|
+
desc "ssh [OPTIONS] <SEARCH_TERM...>","Creates ssh connection to desired ec2 instance through existing jumphost. SEARCH_TERM to search in EC2 instance ID, name or IP address"
|
16
|
+
method_option :no_jumphost, type: :boolean, default: false, aliases: "-n", desc: "Connect directly without jumphost"
|
17
|
+
method_option :select_jumphost, type: :boolean, aliases: "-s", desc: "Select jumphost instance"
|
18
|
+
method_option :use_key, type: :boolean, aliases: "-k", desc: "Use private key auth for target instance. Keys are searched recursively in ~/.sty/keys"
|
19
|
+
def ssh(*search_term)
|
20
|
+
Ssh.new.connect(search_term, options[:no_jumphost], options[:select_jumphost], options[:use_key])
|
21
|
+
end
|
22
|
+
|
23
|
+
desc "console", "Opens AWS console in browser for currently authenticated session"
|
24
|
+
method_option :browser, type: :string, aliases: "-b", enum: Console::BROWSERS, desc: "Use specific browser"
|
25
|
+
method_option :incognito, type: :boolean, aliases: "-i", desc: "Create new incognito window"
|
26
|
+
method_option :logout, type: :boolean, aliases: "-l", dssc: "Logout from current session"
|
27
|
+
def console
|
28
|
+
Console.new.action(options[:browser], options[:incognito], options[:logout])
|
29
|
+
end
|
30
|
+
|
31
|
+
desc "login ACCOUNT_PATH", "Authenticate to the account"
|
32
|
+
method_option :role, aliases: "-r", dssc: "Override role name"
|
33
|
+
def login(path)
|
34
|
+
source_run(__method__)
|
35
|
+
Auth.new.login(path, options[:browser])
|
36
|
+
end
|
37
|
+
|
38
|
+
desc "logout", "Forget current credentials and clear cache"
|
39
|
+
def logout
|
40
|
+
source_run(__method__)
|
41
|
+
Auth.new.logout
|
42
|
+
end
|
43
|
+
|
44
|
+
desc "info", "Get current session information"
|
45
|
+
def info
|
46
|
+
Info.new.session_info
|
47
|
+
end
|
48
|
+
|
49
|
+
desc "account ACCOUNT_ID", "Find account information"
|
50
|
+
def account(path)
|
51
|
+
Info.new.account_info(path)
|
52
|
+
end
|
53
|
+
|
54
|
+
desc "proxy [PROXY_ID]", "Switch session proxy (use 'off' to disable)"
|
55
|
+
def proxy(px = nil)
|
56
|
+
source_run(__method__)
|
57
|
+
Proxy.new.action(px)
|
58
|
+
end
|
59
|
+
|
60
|
+
no_tasks do
|
61
|
+
def source_run(method)
|
62
|
+
unless ENV['STY_SOURCE_RUN'] == 'true'
|
63
|
+
puts "When using '#{method.to_s}' command, you must source it, i.e.: '. sty #{method.to_s}'"
|
64
|
+
exit 128
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
end
|
70
|
+
|
71
|
+
Cli.start(ARGV)
|