aws-asmr 0.0.4 → 0.0.5
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 +4 -4
- data/README.md +40 -0
- data/bin/asmr +3 -63
- data/bin/asmr-login +18 -0
- data/lib/aws/asmr/alias.rb +1 -1
- data/lib/aws/asmr/cli.rb +92 -0
- data/lib/aws/asmr/version.rb +1 -1
- data/lib/aws/asmr/web_login.rb +99 -0
- data/lib/aws/asmr.rb +1 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6cc279ebf6f98696525bd333297e3d9e07f4546235685e4cc56ede2f6e5936df
|
|
4
|
+
data.tar.gz: 5e8ba9aa25320658bbe860b0733141d4bda426a75330bd52603922bb6e0274fb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3ac9215a9c0ddb69a23eb94e7aa30eb91b07ea2e4bdf1fe2be1f03f2ec4fdbb4890bd329432e443044c70360b4aba568f03e5294a9f311779aac95ec334273f2
|
|
7
|
+
data.tar.gz: 52d2bf0b95b0f377a2507ed9e72372edce4a6dcd1aeacc1e231558bf1f4b2b228a9798c9e6d63f54f18b02c28d77c572b405aa0272ce134aaa2417f84d76254f
|
data/README.md
CHANGED
|
@@ -69,6 +69,7 @@ Here is the example of alias file. `arn` is the only required attribute.
|
|
|
69
69
|
[awesome-app-staging]
|
|
70
70
|
arn = arn:aws:iam::0001:role/AwesomeRole
|
|
71
71
|
profile = custodian
|
|
72
|
+
region = ap-northeast-1
|
|
72
73
|
|
|
73
74
|
[awesome-app-production]
|
|
74
75
|
arn = arn:aws:iam::0002:role/AwesomeRole
|
|
@@ -79,6 +80,8 @@ secret_access_key = yyyy
|
|
|
79
80
|
# arn = test
|
|
80
81
|
```
|
|
81
82
|
|
|
83
|
+
`region` is optional and is only used by `asmr-login` (see below) to pick the console landing region.
|
|
84
|
+
|
|
82
85
|
Then, you can choose one of the alias.
|
|
83
86
|
|
|
84
87
|
```
|
|
@@ -91,3 +94,40 @@ Or you can specify alias name.
|
|
|
91
94
|
```
|
|
92
95
|
asmr --name=awesome-app-staging aws sts get-caller-identity
|
|
93
96
|
```
|
|
97
|
+
|
|
98
|
+
## Web Login (AWS Management Console)
|
|
99
|
+
|
|
100
|
+
The companion command `asmr-login` opens the **AWS Management Console** in your browser as the assumed role, using the [AWS federation endpoint](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_enable-console-custom-url.html). This is handy when you want a *browser* session for a role you normally only use from the CLI.
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
asmr-login --name=awesome-app-staging
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
It assumes the role exactly like `asmr` does — sharing the same alias resolution (`--name`/`-n`), MFA prompt and credential cache — then exchanges the temporary credentials for a sign-in token at `https://signin.aws.amazon.com/federation` and opens the resulting console URL in your default browser. On a headless host where no browser opener is available, the URL is printed instead (it is valid for 15 minutes — treat it as a secret).
|
|
107
|
+
|
|
108
|
+
### Landing region
|
|
109
|
+
|
|
110
|
+
The console page you land on is derived from the `region` of the chosen alias. Add `region` to the alias:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
[my-awesome-project]
|
|
114
|
+
arn = arn:aws:iam::xxxx:role/AdminRole
|
|
115
|
+
profile = smcdk-prejp
|
|
116
|
+
region = ap-northeast-1
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Then `asmr-login --name=my-awesome-project` opens the console home of `ap-northeast-1`. When the alias has no `region` (or you pass an ARN directly), it falls back to the global console home (`https://console.aws.amazon.com/`).
|
|
120
|
+
|
|
121
|
+
### Session duration
|
|
122
|
+
|
|
123
|
+
The console session duration (seconds, 900-43200) is set via the alias's optional `session_duration`. When omitted, it defaults to 43200 (12h).
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
[my-awesome-project]
|
|
127
|
+
arn = arn:aws:iam::xxxx:role/AdminRole
|
|
128
|
+
profile = smcdk-prejp
|
|
129
|
+
region = ap-northeast-1
|
|
130
|
+
session_duration = 3600
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Note: the requested duration must be **less than the assumed role's maximum session duration** (1 hour by default). When the federation endpoint rejects it (e.g. the role's max is shorter, or you reached the role via role chaining), `asmr-login` automatically retries *without* it, falling back to the lifetime of the temporary credentials. To get a full 12-hour console session, raise the role's *Maximum session duration* in IAM accordingly.
|
data/bin/asmr
CHANGED
|
@@ -1,41 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
|
|
3
|
-
require "aws/asmr"
|
|
4
|
-
require "aws/asmr/options"
|
|
5
|
-
require "aws/asmr/prompt"
|
|
6
|
-
|
|
7
|
-
asmr_args, command_args = Aws::ASMR::Options.partition(ARGV)
|
|
8
|
-
options = Aws::ASMR::Options.parse(asmr_args)
|
|
9
|
-
|
|
10
|
-
if options[:version]
|
|
11
|
-
require "aws/asmr/version"
|
|
12
|
-
puts Aws::ASMR::VERSION
|
|
13
|
-
exit(0)
|
|
14
|
-
elsif options[:clear]
|
|
15
|
-
Aws::ASMR::Cache.destroy!
|
|
16
|
-
exit(0)
|
|
17
|
-
else
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
prompt = Aws::ASMR::Prompt.safe
|
|
21
|
-
|
|
22
|
-
name = if options[:name]
|
|
23
|
-
options[:name]
|
|
24
|
-
else
|
|
25
|
-
alias_keys = Aws::ASMR::Alias.base.keys
|
|
26
|
-
if alias_keys.empty?
|
|
27
|
-
STDERR.puts "Please specify --name=ARN to assume_role or make alias at #{Aws::ASMR::ROOT}/alias"
|
|
28
|
-
exit(1)
|
|
29
|
-
end
|
|
30
|
-
prompt.select("Choose a role you're going to assume:", alias_keys)
|
|
31
|
-
end
|
|
32
|
-
asmr_alias = Aws::ASMR::Alias.get(name)
|
|
33
|
-
assume_role_arn = if asmr_alias
|
|
34
|
-
asmr_alias.set_environment_variables!
|
|
35
|
-
asmr_alias.arn
|
|
36
|
-
else
|
|
37
|
-
name
|
|
38
|
-
end
|
|
3
|
+
require "aws/asmr/cli"
|
|
39
4
|
|
|
40
5
|
def run(command, shell_variables=[])
|
|
41
6
|
if command.empty?
|
|
@@ -57,33 +22,8 @@ def run(command, shell_variables=[])
|
|
|
57
22
|
end
|
|
58
23
|
exec([*shell_variables, *command].join(' '))
|
|
59
24
|
end
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
if cache = Aws::ASMR::Cache.get(assume_role_arn)
|
|
63
|
-
run(command_args, cache.shell_variables)
|
|
64
25
|
end
|
|
65
26
|
|
|
66
|
-
|
|
67
|
-
begin
|
|
68
|
-
serial_number = Aws::ASMR.detect_mfa_device_serial_number
|
|
69
|
-
assume_role_args = asmr_alias ? asmr_alias.assume_role_args : {}
|
|
70
|
-
assume_role_args = if serial_number
|
|
71
|
-
token_code = prompt.ask("Type MFA token code:")
|
|
72
|
-
assume_role_args.merge({serial_number: serial_number, token_code: token_code})
|
|
73
|
-
else
|
|
74
|
-
assume_role_args
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
res = Aws::ASMR.assume_role(assume_role_arn, **assume_role_args)
|
|
78
|
-
cache = Aws::ASMR::Cache.new(**res.credentials.to_h)
|
|
79
|
-
cache.save!(assume_role_arn)
|
|
27
|
+
Aws::ASMR::CLI.main(ARGV) do |cache, command_args, _options|
|
|
80
28
|
run(command_args, cache.shell_variables)
|
|
81
|
-
|
|
82
|
-
STDERR.puts e.message
|
|
83
|
-
if options[:verbose]
|
|
84
|
-
STDERR.puts e.backtrace
|
|
85
|
-
else
|
|
86
|
-
STDERR.puts "Add --verbose for more error information."
|
|
87
|
-
end
|
|
88
|
-
exit(1)
|
|
89
|
-
end
|
|
29
|
+
end
|
data/bin/asmr-login
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
require "aws/asmr/cli"
|
|
4
|
+
|
|
5
|
+
# Session duration is read from the alias only (a raw, possibly empty string).
|
|
6
|
+
# Returns nil to let WebLogin fall back to its default.
|
|
7
|
+
def alias_session_duration(asmr_alias)
|
|
8
|
+
raw = asmr_alias&.session_duration
|
|
9
|
+
raw.to_i if raw && !raw.empty?
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
Aws::ASMR::CLI.main(ARGV) do |cache, _command_args, _options, asmr_alias|
|
|
13
|
+
args = { region: asmr_alias&.region }
|
|
14
|
+
duration = alias_session_duration(asmr_alias)
|
|
15
|
+
args[:session_duration] = duration if duration
|
|
16
|
+
url = Aws::ASMR::WebLogin.signin_url(cache, **args)
|
|
17
|
+
Aws::ASMR::WebLogin.open_browser(url)
|
|
18
|
+
end
|
data/lib/aws/asmr/alias.rb
CHANGED
|
@@ -3,7 +3,7 @@ require 'aws/asmr'
|
|
|
3
3
|
|
|
4
4
|
module Aws
|
|
5
5
|
module ASMR
|
|
6
|
-
class Alias < Struct.new(:arn, :access_key_id, :secret_access_key, :profile, :external_id, :role_session_name, keyword_init: true)
|
|
6
|
+
class Alias < Struct.new(:arn, :access_key_id, :secret_access_key, :profile, :external_id, :role_session_name, :region, :session_duration, keyword_init: true)
|
|
7
7
|
|
|
8
8
|
PATH = "#{Aws::ASMR::ROOT}/alias"
|
|
9
9
|
|
data/lib/aws/asmr/cli.rb
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
require "aws/asmr"
|
|
2
|
+
require "aws/asmr/options"
|
|
3
|
+
require "aws/asmr/prompt"
|
|
4
|
+
|
|
5
|
+
module Aws
|
|
6
|
+
module ASMR
|
|
7
|
+
# The parts shared by every asmr executable: option parsing, --version /
|
|
8
|
+
# --clear, resolving the role (alias or ARN), and obtaining temporary
|
|
9
|
+
# credentials via assume_role (with MFA prompt and caching).
|
|
10
|
+
#
|
|
11
|
+
# Each executable is just a thin wrapper that decides what to do with the
|
|
12
|
+
# resolved credentials:
|
|
13
|
+
#
|
|
14
|
+
# Aws::ASMR::CLI.main(ARGV) do |cache, command_args, options, asmr_alias|
|
|
15
|
+
# # ... use cache.shell_variables / build a login URL / etc.
|
|
16
|
+
# end
|
|
17
|
+
module CLI
|
|
18
|
+
# Parses asmr-level args, handles --version/--clear, resolves credentials
|
|
19
|
+
# and yields (cache, command_args, options, asmr_alias) to the block; the
|
|
20
|
+
# resolved alias is nil when an ARN was given directly. Top-level errors
|
|
21
|
+
# are reported here (with a backtrace under --verbose) and exit non-zero.
|
|
22
|
+
def main(argv)
|
|
23
|
+
asmr_args, command_args = Options.partition(argv)
|
|
24
|
+
options = Options.parse(asmr_args)
|
|
25
|
+
|
|
26
|
+
if options[:version]
|
|
27
|
+
require "aws/asmr/version"
|
|
28
|
+
puts VERSION
|
|
29
|
+
exit(0)
|
|
30
|
+
elsif options[:clear]
|
|
31
|
+
Cache.destroy!
|
|
32
|
+
exit(0)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
prompt = Prompt.safe
|
|
36
|
+
begin
|
|
37
|
+
cache, asmr_alias = resolve_credentials(options, prompt)
|
|
38
|
+
yield cache, command_args, options, asmr_alias
|
|
39
|
+
rescue => e
|
|
40
|
+
STDERR.puts e.message
|
|
41
|
+
if options[:verbose]
|
|
42
|
+
STDERR.puts e.backtrace
|
|
43
|
+
else
|
|
44
|
+
STDERR.puts "Add --verbose for more error information."
|
|
45
|
+
end
|
|
46
|
+
exit(1)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Resolves the role to assume (from --name/-n or an interactive alias
|
|
51
|
+
# selection) and returns [cache, asmr_alias]: a Cache holding temporary
|
|
52
|
+
# credentials (reusing a valid cache entry or performing assume_role with an
|
|
53
|
+
# MFA prompt), and the resolved Alias (nil when an ARN was given directly).
|
|
54
|
+
def resolve_credentials(options, prompt)
|
|
55
|
+
name = options[:name] || begin
|
|
56
|
+
alias_keys = Alias.base.keys
|
|
57
|
+
if alias_keys.empty?
|
|
58
|
+
STDERR.puts "Please specify --name=ARN to assume_role or make alias at #{ROOT}/alias"
|
|
59
|
+
exit(1)
|
|
60
|
+
end
|
|
61
|
+
prompt.select("Choose a role you're going to assume:", alias_keys)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
asmr_alias = Alias.get(name)
|
|
65
|
+
assume_role_arn = if asmr_alias
|
|
66
|
+
asmr_alias.set_environment_variables!
|
|
67
|
+
asmr_alias.arn
|
|
68
|
+
else
|
|
69
|
+
name
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if cache = Cache.get(assume_role_arn)
|
|
73
|
+
return [cache, asmr_alias]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
serial_number = Aws::ASMR.detect_mfa_device_serial_number
|
|
77
|
+
assume_role_args = asmr_alias ? asmr_alias.assume_role_args : {}
|
|
78
|
+
if serial_number
|
|
79
|
+
token_code = prompt.ask("Type MFA token code:")
|
|
80
|
+
assume_role_args = assume_role_args.merge(serial_number: serial_number, token_code: token_code)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
res = Aws::ASMR.assume_role(assume_role_arn, **assume_role_args)
|
|
84
|
+
cache = Cache.new(**res.credentials.to_h)
|
|
85
|
+
cache.save!(assume_role_arn)
|
|
86
|
+
[cache, asmr_alias]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
module_function :main, :resolve_credentials
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/aws/asmr/version.rb
CHANGED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'uri'
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'rbconfig'
|
|
5
|
+
require 'aws/asmr'
|
|
6
|
+
|
|
7
|
+
module Aws
|
|
8
|
+
module ASMR
|
|
9
|
+
# Builds a sign-in URL for the AWS Management Console out of the temporary
|
|
10
|
+
# credentials obtained by assume_role, using the AWS federation endpoint.
|
|
11
|
+
# See: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_enable-console-custom-url.html
|
|
12
|
+
module WebLogin
|
|
13
|
+
ENDPOINT = "https://signin.aws.amazon.com/federation"
|
|
14
|
+
DEFAULT_DESTINATION = "https://console.aws.amazon.com/"
|
|
15
|
+
DEFAULT_ISSUER = "aws-asmr"
|
|
16
|
+
# Console session duration. Up to 43200 (12h), but it must be LESS than the
|
|
17
|
+
# max session duration setting of the role being assumed (default 1h), so
|
|
18
|
+
# request_signin_token falls back to omitting it when the endpoint rejects it.
|
|
19
|
+
DEFAULT_SESSION_DURATION = 43200
|
|
20
|
+
|
|
21
|
+
# Exchanges the temporary credentials for a sign-in token at the federation
|
|
22
|
+
# endpoint. Retries without SessionDuration when it is rejected (e.g. the
|
|
23
|
+
# role's max session duration is shorter, or the credentials come from role
|
|
24
|
+
# chaining, both of which make SessionDuration invalid).
|
|
25
|
+
def signin_token(cache, session_duration: DEFAULT_SESSION_DURATION)
|
|
26
|
+
res = request_signin_token(cache, session_duration)
|
|
27
|
+
if !res.is_a?(Net::HTTPSuccess) && session_duration
|
|
28
|
+
STDERR.puts "getSigninToken with SessionDuration=#{session_duration} was rejected (HTTP #{res.code}). Retrying without SessionDuration..."
|
|
29
|
+
res = request_signin_token(cache, nil)
|
|
30
|
+
end
|
|
31
|
+
unless res.is_a?(Net::HTTPSuccess)
|
|
32
|
+
raise "Federation endpoint returned HTTP #{res.code}: #{res.body}"
|
|
33
|
+
end
|
|
34
|
+
token = JSON.parse(res.body)["SigninToken"]
|
|
35
|
+
raise "Federation endpoint did not return a SigninToken: #{res.body}" unless token
|
|
36
|
+
token
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Builds the final console login URL. Valid for 15 minutes after creation.
|
|
40
|
+
# The landing page defaults to the given region's console home, or the
|
|
41
|
+
# global console home when no region is given (e.g. ARN passed directly).
|
|
42
|
+
def signin_url(cache, region: nil, issuer: DEFAULT_ISSUER, session_duration: DEFAULT_SESSION_DURATION)
|
|
43
|
+
token = signin_token(cache, session_duration: session_duration)
|
|
44
|
+
build_url(
|
|
45
|
+
"Action" => "login",
|
|
46
|
+
"Issuer" => issuer,
|
|
47
|
+
"Destination" => destination_for(region),
|
|
48
|
+
"SigninToken" => token,
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Console home URL to land on after sign-in.
|
|
53
|
+
def destination_for(region)
|
|
54
|
+
return DEFAULT_DESTINATION if region.nil? || region.empty?
|
|
55
|
+
"https://#{region}.console.aws.amazon.com/console/home?region=#{region}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Opens the given URL in the default browser. Falls back to printing it
|
|
59
|
+
# (e.g. on a headless host where no opener is available).
|
|
60
|
+
def open_browser(url)
|
|
61
|
+
opener = browser_opener
|
|
62
|
+
if opener && system(*opener, url)
|
|
63
|
+
STDERR.puts "Opened the AWS Management Console in your browser."
|
|
64
|
+
else
|
|
65
|
+
STDERR.puts "Open the following URL in your browser to sign in (valid for 15 minutes):"
|
|
66
|
+
puts url
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def request_signin_token(cache, session_duration)
|
|
71
|
+
session = {
|
|
72
|
+
sessionId: cache.access_key_id,
|
|
73
|
+
sessionKey: cache.secret_access_key,
|
|
74
|
+
sessionToken: cache.session_token,
|
|
75
|
+
}.to_json
|
|
76
|
+
|
|
77
|
+
params = { "Action" => "getSigninToken", "Session" => session }
|
|
78
|
+
params["SessionDuration"] = session_duration.to_s if session_duration
|
|
79
|
+
Net::HTTP.get_response(URI(build_url(params)))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def build_url(params)
|
|
83
|
+
uri = URI(ENDPOINT)
|
|
84
|
+
uri.query = URI.encode_www_form(params)
|
|
85
|
+
uri.to_s
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def browser_opener
|
|
89
|
+
case RbConfig::CONFIG['host_os']
|
|
90
|
+
when /darwin/ then ["open"]
|
|
91
|
+
when /mswin|mingw|cygwin/ then ["cmd", "/c", "start", ""]
|
|
92
|
+
else ["xdg-open"]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
module_function :signin_token, :signin_url, :destination_for, :open_browser, :request_signin_token, :build_url, :browser_opener
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
data/lib/aws/asmr.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: aws-asmr
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- metheglin
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-04
|
|
11
|
+
date: 2026-06-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rake
|
|
@@ -70,17 +70,21 @@ description: ''
|
|
|
70
70
|
email: pigmybank@gmail.com
|
|
71
71
|
executables:
|
|
72
72
|
- asmr
|
|
73
|
+
- asmr-login
|
|
73
74
|
extensions: []
|
|
74
75
|
extra_rdoc_files: []
|
|
75
76
|
files:
|
|
76
77
|
- README.md
|
|
77
78
|
- bin/asmr
|
|
79
|
+
- bin/asmr-login
|
|
78
80
|
- lib/aws/asmr.rb
|
|
79
81
|
- lib/aws/asmr/alias.rb
|
|
80
82
|
- lib/aws/asmr/cache.rb
|
|
83
|
+
- lib/aws/asmr/cli.rb
|
|
81
84
|
- lib/aws/asmr/options.rb
|
|
82
85
|
- lib/aws/asmr/prompt.rb
|
|
83
86
|
- lib/aws/asmr/version.rb
|
|
87
|
+
- lib/aws/asmr/web_login.rb
|
|
84
88
|
homepage: https://rubygems.org/gems/aws-asmr
|
|
85
89
|
licenses:
|
|
86
90
|
- MIT
|