sty 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
[![Build Status](https://travis-ci.org/sozonnyk/sty.svg?branch=master)](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)
|