awskeyring 0.4.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 045e119cd7be746d03d7b12c73d804958cc5331b
4
- data.tar.gz: 1e7bc87c08887ed8625a80d629d5822ca60a3d08
3
+ metadata.gz: c6053b960d27395acf21d82a71883cdcf5788959
4
+ data.tar.gz: 0f8efce2feb5af0a87d7be239a85733448eee90d
5
5
  SHA512:
6
- metadata.gz: 92b6df41a05aef2d2c4246b86daad7a6fdc4c8092192e05341167e6b707663ca73d9babbcc8ef871644743d40b2e84207f985eb4cfc140296a8297c18afebe94
7
- data.tar.gz: 4c32c86b6ed103a50e4218e1f6a258e0bbe29ddd79de7470a56b149567fa2a66ca5515540092714c57cde7c98987a211d7ac87dfed17c11c682039db92455728
6
+ metadata.gz: 8449e554b6d4543d851d4caf488807965e5a5ee2cb656a4eb5535662254bc273ca457f76b1a8b0f435ecd771d7b5fb1cd6a8f1521fd6cca5c6d09b70f13246a1
7
+ data.tar.gz: 71d5d0e493329b3b6dc3c203175c79baf3a5caba618885863e9bc358c934f9d5215161fbe0a609b8c7cbc69d8f02aa6615960c6b1fde36e2c78f4fc37ea1332d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Change Log
2
2
 
3
+ ## [v0.5.0](https://github.com/vibrato/awskeyring/tree/v0.5.0) (2018-09-10)
4
+ [Full Changelog](https://github.com/vibrato/awskeyring/compare/v0.4.0...v0.5.0)
5
+
6
+ **Implemented enhancements:**
7
+
8
+ - Separate update account from add account. [\#28](https://github.com/vibrato/awskeyring/pull/28) ([tristanmorgan](https://github.com/tristanmorgan))
9
+
10
+ **Merged pull requests:**
11
+
12
+ - Refactor [\#27](https://github.com/vibrato/awskeyring/pull/27) ([tristanmorgan](https://github.com/tristanmorgan))
13
+
3
14
  ## [v0.4.0](https://github.com/vibrato/awskeyring/tree/v0.4.0) (2018-08-21)
4
15
  [Full Changelog](https://github.com/vibrato/awskeyring/compare/v0.3.1...v0.4.0)
5
16
 
@@ -73,7 +84,7 @@
73
84
  ## [v0.0.5](https://github.com/vibrato/awskeyring/tree/v0.0.5) (2018-02-15)
74
85
  [Full Changelog](https://github.com/vibrato/awskeyring/compare/v0.0.4...v0.0.5)
75
86
 
76
- **Closed issues:**
87
+ **Fixed bugs:**
77
88
 
78
89
  - Issue on add [\#7](https://github.com/vibrato/awskeyring/issues/7)
79
90
 
data/README.md CHANGED
@@ -67,6 +67,7 @@ The CLI is using [Thor](http://whatisthor.com) with help provided interactively.
67
67
  awskeyring remove-token ACCOUNT # Removes a token for ACCOUNT from the keyring
68
68
  awskeyring rotate ACCOUNT # Rotate access keys for an ACCOUNT
69
69
  awskeyring token ACCOUNT [ROLE] [MFA] # Create an STS Token from a ROLE or an MFA code
70
+ awskeyring update ACCOUNT # Updates an ACCOUNT in the keyring
70
71
 
71
72
  and autocomplete that can be installed with:
72
73
 
data/Rakefile CHANGED
@@ -2,6 +2,7 @@ require 'bundler/gem_tasks'
2
2
  require 'rspec/core/rake_task'
3
3
  require 'rubocop/rake_task'
4
4
  require 'github_changelog_generator/task'
5
+ require 'yard'
5
6
 
6
7
  GitHubChangelogGenerator::RakeTask.new :changelog do |config|
7
8
  config.future_release = "v#{Awskeyring::VERSION}"
@@ -29,4 +30,9 @@ task :filemode do
29
30
  print "\n"
30
31
  end
31
32
 
32
- task default: %i[filemode rubocop spec]
33
+ YARD::Rake::YardocTask.new do |t|
34
+ t.options = ['--fail-on-warning', '--no-progress']
35
+ t.stats_options = ['--list-undoc']
36
+ end
37
+
38
+ task default: %i[filemode rubocop spec yard]
data/awskeyring.gemspec CHANGED
@@ -29,4 +29,5 @@ Gem::Specification.new do |spec|
29
29
  spec.add_development_dependency 'rake'
30
30
  spec.add_development_dependency 'rspec'
31
31
  spec.add_development_dependency 'rubocop'
32
+ spec.add_development_dependency 'yard'
32
33
  end
data/i18n/en.yml CHANGED
@@ -32,6 +32,8 @@ en:
32
32
  desc: Rotate access keys for an ACCOUNT
33
33
  token:
34
34
  desc: Create an STS Token from a ROLE or an MFA code
35
+ update:
36
+ desc: Updates an ACCOUNT in the keyring
35
37
  method_option:
36
38
  arn: 'AWS role arn.'
37
39
  code: 'Virtual mfa CODE.'
@@ -40,11 +42,11 @@ en:
40
42
  keychain: 'Name of KEYCHAIN to initialise.'
41
43
  local: 'Only validate locally.'
42
44
  mfa: 'AWS virtual mfa arn.'
45
+ noopen: 'Do not open the url.'
43
46
  notoken: 'Do not use saved token.'
44
47
  path: 'The service PATH to open.'
45
48
  role: 'The ROLE to assume.'
46
49
  secret: 'AWS account secret.'
47
- update: 'Update existing.'
48
50
  message:
49
51
  keychain: 'Name for new keychain (default: awskeyring)'
50
52
  account: 'account name'
@@ -17,7 +17,9 @@ module Awskeyring
17
17
  }]
18
18
  }.to_json.freeze
19
19
 
20
+ # Twelve hours in seconds
20
21
  TWELVE_HOUR = (60 * 60 * 12)
22
+ # One hour in seconds
21
23
  ONE_HOUR = (60 * 60 * 1)
22
24
  # Days in seconds
23
25
  ONE_DAY = (24 * 60 * 60)
@@ -100,7 +102,6 @@ module Awskeyring
100
102
  #
101
103
  # @param [String] key The aws_access_key_id
102
104
  # @param [String] secret The aws_secret_access_key
103
- # @param [String] token The aws_session_token
104
105
  def self.verify_cred(key:, secret:)
105
106
  begin
106
107
  ENV['AWS_DEFAULT_REGION'] = 'us-east-1' unless region
@@ -8,6 +8,7 @@ module Awskeyring
8
8
  # @param [String] account_name the associated account name.
9
9
  def self.account_name(account_name)
10
10
  raise 'Invalid Account Name' unless account_name =~ /\S+/
11
+
11
12
  account_name
12
13
  end
13
14
 
@@ -16,6 +17,7 @@ module Awskeyring
16
17
  # @param [String] aws_access_key The aws_access_key_id
17
18
  def self.access_key(aws_access_key)
18
19
  raise 'Invalid Access Key' unless aws_access_key =~ /\AAKIA[A-Z0-9]{12,16}\z/
20
+
19
21
  aws_access_key
20
22
  end
21
23
 
@@ -24,6 +26,7 @@ module Awskeyring
24
26
  # @param [String] aws_secret_access_key The aws_secret_access_key
25
27
  def self.secret_access_key(aws_secret_access_key)
26
28
  raise 'Secret Access Key is not 40 chars' if aws_secret_access_key.length != 40
29
+
27
30
  aws_secret_access_key
28
31
  end
29
32
 
@@ -32,6 +35,7 @@ module Awskeyring
32
35
  # @param [String] mfa_arn The users MFA arn
33
36
  def self.mfa_arn(mfa_arn)
34
37
  raise 'Invalid MFA ARN' unless mfa_arn =~ %r(\Aarn:aws:iam::[0-9]{12}:mfa\/\S*\z)
38
+
35
39
  mfa_arn
36
40
  end
37
41
 
@@ -40,6 +44,7 @@ module Awskeyring
40
44
  # @param [String] role_name
41
45
  def self.role_name(role_name)
42
46
  raise 'Invalid Role Name' unless role_name =~ /\S+/
47
+
43
48
  role_name
44
49
  end
45
50
 
@@ -48,6 +53,7 @@ module Awskeyring
48
53
  # @param [String] role_arn The role arn
49
54
  def self.role_arn(role_arn)
50
55
  raise 'Invalid Role ARN' unless role_arn =~ %r(\Aarn:aws:iam::[0-9]{12}:role\/\S*\z)
56
+
51
57
  role_arn
52
58
  end
53
59
 
@@ -56,6 +62,7 @@ module Awskeyring
56
62
  # @param [String] mfa_code The mfa code
57
63
  def self.mfa_code(mfa_code)
58
64
  raise 'Invalid MFA CODE' unless mfa_code =~ /\A\d{6}\z/
65
+
59
66
  mfa_code
60
67
  end
61
68
  end
@@ -1,4 +1,4 @@
1
1
  module Awskeyring
2
2
  # The Gems version number
3
- VERSION = '0.4.0'.freeze
3
+ VERSION = '0.5.0'.freeze
4
4
  end
data/lib/awskeyring.rb CHANGED
@@ -128,7 +128,7 @@ module Awskeyring # rubocop:disable Metrics/ModuleLength
128
128
  end
129
129
 
130
130
  # Return a session token pair of items by name
131
- private_class_method def self.get_pair(account:)
131
+ private_class_method def self.get_token_pair(account:)
132
132
  session_key = all_items.where(label: SESSION_KEY_PREFIX + account).first
133
133
  session_token = all_items.where(label: SESSION_TOKEN_PREFIX + account).first if session_key
134
134
  [session_key, session_token]
@@ -145,11 +145,11 @@ module Awskeyring # rubocop:disable Metrics/ModuleLength
145
145
  end
146
146
 
147
147
  # Return a session token if available or a static key
148
- private_class_method def self.get_valid_item_pair(account:)
149
- session_key, session_token = get_pair(account: account)
148
+ private_class_method def self.get_valid_item_pair(account:, no_token: false)
149
+ session_key, session_token = get_token_pair(account: account)
150
150
  session_key, session_token = delete_expired(key: session_key, token: session_token) if session_key
151
151
 
152
- if session_key && session_token
152
+ if session_key && session_token && !no_token
153
153
  puts I18n.t('message.temporary')
154
154
  return session_key, session_token
155
155
  end
@@ -164,33 +164,16 @@ module Awskeyring # rubocop:disable Metrics/ModuleLength
164
164
 
165
165
  # Return valid creds for account
166
166
  def self.get_valid_creds(account:, no_token: false)
167
- if no_token
168
- cred = get_item(account: account)
169
- temp_cred = nil
170
- else
171
- cred, temp_cred = get_valid_item_pair(account: account)
172
- end
167
+ cred, temp_cred = get_valid_item_pair(account: account, no_token: no_token)
173
168
  token = temp_cred.password unless temp_cred.nil?
174
169
  expiry = temp_cred.attributes[:account].to_i unless temp_cred.nil?
175
170
  {
176
171
  account: account,
177
- key: cred.attributes[:account],
178
- secret: cred.password,
179
- token: token,
180
172
  expiry: expiry,
181
- updated: cred.attributes[:updated_at]
182
- }
183
- end
184
-
185
- # Return a hash for account (skip tokens)
186
- def self.get_account_hash(account:)
187
- cred = get_item(account: account)
188
- return unless cred
189
- {
190
- account: account,
191
173
  key: cred.attributes[:account],
174
+ mfa: no_token ? cred.attributes[:comment] : nil,
192
175
  secret: cred.password,
193
- mfa: cred.attributes[:comment],
176
+ token: token,
194
177
  updated: cred.attributes[:updated_at]
195
178
  }
196
179
  end
@@ -215,6 +198,7 @@ module Awskeyring # rubocop:disable Metrics/ModuleLength
215
198
  # Delete session token items
216
199
  private_class_method def self.delete_pair(key:, token:, message:)
217
200
  return unless key
201
+
218
202
  puts message if message
219
203
  token.delete if token
220
204
  key.delete
@@ -222,7 +206,7 @@ module Awskeyring # rubocop:disable Metrics/ModuleLength
222
206
 
223
207
  # Delete a session token
224
208
  def self.delete_token(account:, message:)
225
- session_key, session_token = get_pair(account: account)
209
+ session_key, session_token = get_token_pair(account: account)
226
210
  delete_pair(key: session_key, token: session_token, message: message)
227
211
  end
228
212
 
@@ -231,6 +215,7 @@ module Awskeyring # rubocop:disable Metrics/ModuleLength
231
215
  delete_token(account: account, message: I18n.t('message.delexpired'))
232
216
  cred = get_item(account: account)
233
217
  return unless cred
218
+
234
219
  puts message if message
235
220
  cred.delete
236
221
  end
@@ -239,6 +224,7 @@ module Awskeyring # rubocop:disable Metrics/ModuleLength
239
224
  def self.delete_role(role_name:, message:)
240
225
  role = get_role(role_name: role_name)
241
226
  return unless role
227
+
242
228
  puts message if message
243
229
  role.delete
244
230
  end
@@ -21,6 +21,7 @@ class AwskeyringCommand < Thor # rubocop:disable Metrics/ClassLength
21
21
  map ['rmr'] => :remove_role
22
22
  map ['rmt'] => :remove_token
23
23
  map ['rot'] => :rotate
24
+ map ['up'] => :update
24
25
 
25
26
  desc '--version, -v', I18n.t('__version.desc')
26
27
  # print the version number
@@ -68,8 +69,7 @@ class AwskeyringCommand < Thor # rubocop:disable Metrics/ClassLength
68
69
  account = ask_check(
69
70
  existing: account, message: I18n.t('message.account'), validator: Awskeyring::Validate.method(:account_name)
70
71
  )
71
- cred = Awskeyring.get_valid_creds(account: account, no_token: options['no-token'])
72
- age_check(account, cred[:updated])
72
+ cred = age_check_and_get(account: account, no_token: options['no-token'])
73
73
  put_env_string(
74
74
  account: cred[:account],
75
75
  key: cred[:key],
@@ -85,8 +85,7 @@ class AwskeyringCommand < Thor # rubocop:disable Metrics/ClassLength
85
85
  account = ask_check(
86
86
  existing: account, message: I18n.t('message.account'), validator: Awskeyring::Validate.method(:account_name)
87
87
  )
88
- cred = Awskeyring.get_valid_creds(account: account, no_token: options['no-token'])
89
- age_check(account, cred[:updated])
88
+ cred = age_check_and_get(account: account, no_token: options['no-token'])
90
89
  expiry = Time.at(cred[:expiry]) unless cred[:expiry].nil?
91
90
  puts Awskeyring::Awsapi.get_cred_json(
92
91
  key: cred[:key],
@@ -100,8 +99,7 @@ class AwskeyringCommand < Thor # rubocop:disable Metrics/ClassLength
100
99
  method_option 'no-token', type: :boolean, aliases: '-n', desc: I18n.t('method_option.notoken'), default: false
101
100
  # execute an external command with env set
102
101
  def exec(account, *command)
103
- cred = Awskeyring.get_valid_creds(account: account, no_token: options['no-token'])
104
- age_check(account, cred[:updated])
102
+ cred = age_check_and_get(account: account, no_token: options['no-token'])
105
103
  env_vars = env_vars(
106
104
  account: cred[:account],
107
105
  key: cred[:key],
@@ -117,7 +115,6 @@ class AwskeyringCommand < Thor # rubocop:disable Metrics/ClassLength
117
115
  method_option :secret, type: :string, aliases: '-s', desc: I18n.t('method_option.secret')
118
116
  method_option :mfa, type: :string, aliases: '-m', desc: I18n.t('method_option.mfa')
119
117
  method_option :local, type: :boolean, aliases: '-l', desc: I18n.t('method_option.local'), default: false
120
- method_option :update, type: :boolean, aliases: '-u', desc: I18n.t('method_option.update'), default: false
121
118
  # Add an Account
122
119
  def add(account = nil) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
123
120
  account = ask_check(
@@ -130,28 +127,43 @@ class AwskeyringCommand < Thor # rubocop:disable Metrics/ClassLength
130
127
  existing: options[:secret], message: I18n.t('message.secret'),
131
128
  secure: true, validator: Awskeyring::Validate.method(:secret_access_key)
132
129
  )
133
- if options[:update]
134
- Awskeyring::Awsapi.verify_cred(key: key, secret: secret) unless options[:local]
135
- Awskeyring.update_account(
136
- account: account,
137
- key: key,
138
- secret: secret
139
- )
140
- puts I18n.t('message.upaccount', account: account)
141
- else
142
- mfa = ask_check(
143
- existing: options[:mfa], message: I18n.t('message.mfa'),
144
- optional: true, validator: Awskeyring::Validate.method(:mfa_arn)
145
- )
146
- Awskeyring::Awsapi.verify_cred(key: key, secret: secret) unless options[:local]
147
- Awskeyring.add_account(
148
- account: account,
149
- key: key,
150
- secret: secret,
151
- mfa: mfa
152
- )
153
- puts I18n.t('message.addaccount', account: account)
154
- end
130
+ mfa = ask_check(
131
+ existing: options[:mfa], message: I18n.t('message.mfa'),
132
+ optional: true, validator: Awskeyring::Validate.method(:mfa_arn)
133
+ )
134
+ Awskeyring::Awsapi.verify_cred(key: key, secret: secret) unless options[:local]
135
+ Awskeyring.add_account(
136
+ account: account,
137
+ key: key,
138
+ secret: secret,
139
+ mfa: mfa
140
+ )
141
+ puts I18n.t('message.addaccount', account: account)
142
+ end
143
+
144
+ desc 'update ACCOUNT', I18n.t('update.desc')
145
+ method_option :key, type: :string, aliases: '-k', desc: I18n.t('method_option.key')
146
+ method_option :secret, type: :string, aliases: '-s', desc: I18n.t('method_option.secret')
147
+ method_option :local, type: :boolean, aliases: '-l', desc: I18n.t('method_option.local'), default: false
148
+ # Update an Account
149
+ def update(account = nil) # rubocop:disable Metrics/MethodLength
150
+ account = ask_check(
151
+ existing: account, message: I18n.t('message.account'), validator: Awskeyring::Validate.method(:account_name)
152
+ )
153
+ key = ask_check(
154
+ existing: options[:key], message: I18n.t('message.key'), validator: Awskeyring::Validate.method(:access_key)
155
+ )
156
+ secret = ask_check(
157
+ existing: options[:secret], message: I18n.t('message.secret'),
158
+ secure: true, validator: Awskeyring::Validate.method(:secret_access_key)
159
+ )
160
+ Awskeyring::Awsapi.verify_cred(key: key, secret: secret) unless options[:local]
161
+ Awskeyring.update_account(
162
+ account: account,
163
+ key: key,
164
+ secret: secret
165
+ )
166
+ puts I18n.t('message.upaccount', account: account)
155
167
  end
156
168
 
157
169
  map 'add-role' => :add_role
@@ -214,13 +226,13 @@ class AwskeyringCommand < Thor # rubocop:disable Metrics/ClassLength
214
226
  account = ask_check(
215
227
  existing: account, message: I18n.t('message.account'), validator: Awskeyring::Validate.method(:account_name)
216
228
  )
217
- item_hash = Awskeyring.get_account_hash(account: account)
229
+ cred = Awskeyring.get_valid_creds(account: account, no_token: true)
218
230
 
219
231
  begin
220
232
  new_key = Awskeyring::Awsapi.rotate(
221
- account: item_hash[:account],
222
- key: item_hash[:key],
223
- secret: item_hash[:secret],
233
+ account: cred[:account],
234
+ key: cred[:key],
235
+ secret: cred[:secret],
224
236
  key_message: I18n.t('message.rotate', account: account)
225
237
  )
226
238
  rescue Aws::Errors::ServiceError => err
@@ -263,8 +275,7 @@ class AwskeyringCommand < Thor # rubocop:disable Metrics/ClassLength
263
275
  duration ||= Awskeyring::Awsapi::TWELVE_HOUR.to_s if code
264
276
  duration ||= Awskeyring::Awsapi::ONE_HOUR.to_s
265
277
 
266
- item_hash = Awskeyring.get_account_hash(account: account)
267
- age_check(account, item_hash[:updated])
278
+ item_hash = age_check_and_get(account: account, no_token: true)
268
279
  role_arn = Awskeyring.get_role_arn(role_name: role) if role
269
280
 
270
281
  begin
@@ -298,13 +309,13 @@ class AwskeyringCommand < Thor # rubocop:disable Metrics/ClassLength
298
309
  desc 'console ACCOUNT', I18n.t('console.desc')
299
310
  method_option :path, type: :string, aliases: '-p', desc: I18n.t('method_option.path')
300
311
  method_option 'no-token', type: :boolean, aliases: '-n', desc: I18n.t('method_option.notoken'), default: false
312
+ method_option 'no-open', type: :boolean, aliases: '-o', desc: I18n.t('method_option.noopen'), default: false
301
313
  # Open the AWS Console
302
314
  def console(account = nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
303
315
  account = ask_check(
304
316
  existing: account, message: I18n.t('message.account'), validator: Awskeyring::Validate.method(:account_name)
305
317
  )
306
- cred = Awskeyring.get_valid_creds(account: account, no_token: options['no-token'])
307
- age_check(account, cred[:updated])
318
+ cred = age_check_and_get(account: account, no_token: options['no-token'])
308
319
 
309
320
  path = options[:path] || 'console'
310
321
 
@@ -321,8 +332,12 @@ class AwskeyringCommand < Thor # rubocop:disable Metrics/ClassLength
321
332
  exit 1
322
333
  end
323
334
 
324
- pid = Process.spawn("open \"#{login_url}\"")
325
- Process.wait pid
335
+ if options['no-open']
336
+ puts login_url
337
+ else
338
+ pid = Process.spawn("open \"#{login_url}\"")
339
+ Process.wait pid
340
+ end
326
341
  end
327
342
 
328
343
  desc 'awskeyring CURR PREV', I18n.t('awskeyring.desc'), hide: true
@@ -348,10 +363,14 @@ class AwskeyringCommand < Thor # rubocop:disable Metrics/ClassLength
348
363
 
349
364
  private
350
365
 
351
- def age_check(account, updated)
366
+ def age_check_and_get(account:, no_token:)
367
+ cred = Awskeyring.get_valid_creds(account: account, no_token: no_token)
368
+
352
369
  maxage = Awskeyring.prefs[:keyage] || Awskeyring::DEFAULT_KEY_AGE
353
- age = (Time.new - updated).div Awskeyring::Awsapi::ONE_DAY
370
+ age = (Time.new - cred[:updated]).div Awskeyring::Awsapi::ONE_DAY
354
371
  warn I18n.t('message.age_check', account: account, age: age) unless age < maxage
372
+
373
+ cred
355
374
  end
356
375
 
357
376
  def print_auto_resp(curr, len)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: awskeyring
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tristan Morgan
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2018-08-21 00:00:00.000000000 Z
11
+ date: 2018-09-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: aws-sdk-iam
@@ -150,6 +150,20 @@ dependencies:
150
150
  - - ">="
151
151
  - !ruby/object:Gem::Version
152
152
  version: '0'
153
+ - !ruby/object:Gem::Dependency
154
+ name: yard
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
153
167
  description: Manages AWS credentials in the macOS keychain
154
168
  email:
155
169
  - tristan@vibrato.com.au