kamal-insecure 2.7.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/MIT-LICENSE +20 -0
- data/README.md +13 -0
- data/bin/kamal +18 -0
- data/lib/kamal/cli/accessory.rb +313 -0
- data/lib/kamal/cli/alias/command.rb +10 -0
- data/lib/kamal/cli/app/assets.rb +24 -0
- data/lib/kamal/cli/app/boot.rb +126 -0
- data/lib/kamal/cli/app/error_pages.rb +33 -0
- data/lib/kamal/cli/app/ssl_certificates.rb +28 -0
- data/lib/kamal/cli/app.rb +400 -0
- data/lib/kamal/cli/base.rb +223 -0
- data/lib/kamal/cli/build/clone.rb +61 -0
- data/lib/kamal/cli/build.rb +204 -0
- data/lib/kamal/cli/healthcheck/barrier.rb +33 -0
- data/lib/kamal/cli/healthcheck/error.rb +2 -0
- data/lib/kamal/cli/healthcheck/poller.rb +42 -0
- data/lib/kamal/cli/lock.rb +45 -0
- data/lib/kamal/cli/main.rb +277 -0
- data/lib/kamal/cli/proxy.rb +290 -0
- data/lib/kamal/cli/prune.rb +34 -0
- data/lib/kamal/cli/registry.rb +19 -0
- data/lib/kamal/cli/secrets.rb +49 -0
- data/lib/kamal/cli/server.rb +50 -0
- data/lib/kamal/cli/templates/deploy.yml +101 -0
- data/lib/kamal/cli/templates/sample_hooks/docker-setup.sample +3 -0
- data/lib/kamal/cli/templates/sample_hooks/post-app-boot.sample +3 -0
- data/lib/kamal/cli/templates/sample_hooks/post-deploy.sample +14 -0
- data/lib/kamal/cli/templates/sample_hooks/post-proxy-reboot.sample +3 -0
- data/lib/kamal/cli/templates/sample_hooks/pre-app-boot.sample +3 -0
- data/lib/kamal/cli/templates/sample_hooks/pre-build.sample +51 -0
- data/lib/kamal/cli/templates/sample_hooks/pre-connect.sample +47 -0
- data/lib/kamal/cli/templates/sample_hooks/pre-deploy.sample +122 -0
- data/lib/kamal/cli/templates/sample_hooks/pre-proxy-reboot.sample +3 -0
- data/lib/kamal/cli/templates/secrets +17 -0
- data/lib/kamal/cli.rb +9 -0
- data/lib/kamal/commander/specifics.rb +62 -0
- data/lib/kamal/commander.rb +167 -0
- data/lib/kamal/commands/accessory/proxy.rb +16 -0
- data/lib/kamal/commands/accessory.rb +113 -0
- data/lib/kamal/commands/app/assets.rb +51 -0
- data/lib/kamal/commands/app/containers.rb +31 -0
- data/lib/kamal/commands/app/error_pages.rb +9 -0
- data/lib/kamal/commands/app/execution.rb +32 -0
- data/lib/kamal/commands/app/images.rb +13 -0
- data/lib/kamal/commands/app/logging.rb +28 -0
- data/lib/kamal/commands/app/proxy.rb +32 -0
- data/lib/kamal/commands/app.rb +124 -0
- data/lib/kamal/commands/auditor.rb +39 -0
- data/lib/kamal/commands/base.rb +134 -0
- data/lib/kamal/commands/builder/base.rb +124 -0
- data/lib/kamal/commands/builder/clone.rb +31 -0
- data/lib/kamal/commands/builder/cloud.rb +22 -0
- data/lib/kamal/commands/builder/hybrid.rb +21 -0
- data/lib/kamal/commands/builder/local.rb +14 -0
- data/lib/kamal/commands/builder/pack.rb +46 -0
- data/lib/kamal/commands/builder/remote.rb +63 -0
- data/lib/kamal/commands/builder.rb +48 -0
- data/lib/kamal/commands/docker.rb +34 -0
- data/lib/kamal/commands/hook.rb +20 -0
- data/lib/kamal/commands/lock.rb +70 -0
- data/lib/kamal/commands/proxy.rb +127 -0
- data/lib/kamal/commands/prune.rb +38 -0
- data/lib/kamal/commands/registry.rb +16 -0
- data/lib/kamal/commands/server.rb +15 -0
- data/lib/kamal/commands.rb +2 -0
- data/lib/kamal/configuration/accessory.rb +241 -0
- data/lib/kamal/configuration/alias.rb +15 -0
- data/lib/kamal/configuration/boot.rb +25 -0
- data/lib/kamal/configuration/builder.rb +211 -0
- data/lib/kamal/configuration/docs/accessory.yml +128 -0
- data/lib/kamal/configuration/docs/alias.yml +26 -0
- data/lib/kamal/configuration/docs/boot.yml +19 -0
- data/lib/kamal/configuration/docs/builder.yml +132 -0
- data/lib/kamal/configuration/docs/configuration.yml +184 -0
- data/lib/kamal/configuration/docs/env.yml +116 -0
- data/lib/kamal/configuration/docs/logging.yml +21 -0
- data/lib/kamal/configuration/docs/proxy.yml +164 -0
- data/lib/kamal/configuration/docs/registry.yml +56 -0
- data/lib/kamal/configuration/docs/role.yml +53 -0
- data/lib/kamal/configuration/docs/servers.yml +27 -0
- data/lib/kamal/configuration/docs/ssh.yml +70 -0
- data/lib/kamal/configuration/docs/sshkit.yml +23 -0
- data/lib/kamal/configuration/env/tag.rb +13 -0
- data/lib/kamal/configuration/env.rb +38 -0
- data/lib/kamal/configuration/logging.rb +33 -0
- data/lib/kamal/configuration/proxy/boot.rb +129 -0
- data/lib/kamal/configuration/proxy.rb +124 -0
- data/lib/kamal/configuration/registry.rb +32 -0
- data/lib/kamal/configuration/role.rb +222 -0
- data/lib/kamal/configuration/servers.rb +25 -0
- data/lib/kamal/configuration/ssh.rb +57 -0
- data/lib/kamal/configuration/sshkit.rb +22 -0
- data/lib/kamal/configuration/validation.rb +27 -0
- data/lib/kamal/configuration/validator/accessory.rb +13 -0
- data/lib/kamal/configuration/validator/alias.rb +15 -0
- data/lib/kamal/configuration/validator/builder.rb +15 -0
- data/lib/kamal/configuration/validator/configuration.rb +6 -0
- data/lib/kamal/configuration/validator/env.rb +54 -0
- data/lib/kamal/configuration/validator/proxy.rb +25 -0
- data/lib/kamal/configuration/validator/registry.rb +25 -0
- data/lib/kamal/configuration/validator/role.rb +13 -0
- data/lib/kamal/configuration/validator/servers.rb +7 -0
- data/lib/kamal/configuration/validator.rb +191 -0
- data/lib/kamal/configuration/volume.rb +22 -0
- data/lib/kamal/configuration.rb +372 -0
- data/lib/kamal/docker.rb +30 -0
- data/lib/kamal/env_file.rb +44 -0
- data/lib/kamal/git.rb +37 -0
- data/lib/kamal/secrets/adapters/aws_secrets_manager.rb +51 -0
- data/lib/kamal/secrets/adapters/base.rb +33 -0
- data/lib/kamal/secrets/adapters/bitwarden.rb +81 -0
- data/lib/kamal/secrets/adapters/bitwarden_secrets_manager.rb +66 -0
- data/lib/kamal/secrets/adapters/doppler.rb +57 -0
- data/lib/kamal/secrets/adapters/enpass.rb +71 -0
- data/lib/kamal/secrets/adapters/gcp_secret_manager.rb +112 -0
- data/lib/kamal/secrets/adapters/last_pass.rb +40 -0
- data/lib/kamal/secrets/adapters/one_password.rb +104 -0
- data/lib/kamal/secrets/adapters/passbolt.rb +130 -0
- data/lib/kamal/secrets/adapters/test.rb +14 -0
- data/lib/kamal/secrets/adapters.rb +16 -0
- data/lib/kamal/secrets/dotenv/inline_command_substitution.rb +33 -0
- data/lib/kamal/secrets.rb +42 -0
- data/lib/kamal/sshkit_with_ext.rb +142 -0
- data/lib/kamal/tags.rb +40 -0
- data/lib/kamal/utils/sensitive.rb +20 -0
- data/lib/kamal/utils.rb +110 -0
- data/lib/kamal/version.rb +3 -0
- data/lib/kamal.rb +14 -0
- metadata +365 -0
data/lib/kamal/docker.rb
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
require "tempfile"
|
2
|
+
require "open3"
|
3
|
+
|
4
|
+
module Kamal::Docker
|
5
|
+
extend self
|
6
|
+
BUILD_CHECK_TAG = "kamal-local-build-check"
|
7
|
+
|
8
|
+
def included_files
|
9
|
+
Tempfile.create do |dockerfile|
|
10
|
+
dockerfile.write(<<~DOCKERFILE)
|
11
|
+
FROM busybox
|
12
|
+
COPY . app
|
13
|
+
WORKDIR app
|
14
|
+
CMD find . -type f | sed "s|^\./||"
|
15
|
+
DOCKERFILE
|
16
|
+
dockerfile.close
|
17
|
+
|
18
|
+
cmd = "docker buildx build -t=#{BUILD_CHECK_TAG} -f=#{dockerfile.path} ."
|
19
|
+
system(cmd) || raise("failed to build check image")
|
20
|
+
end
|
21
|
+
|
22
|
+
cmd = "docker run --rm #{BUILD_CHECK_TAG}"
|
23
|
+
out, err, status = Open3.capture3(cmd)
|
24
|
+
unless status
|
25
|
+
raise "failed to run check image:\n#{err}"
|
26
|
+
end
|
27
|
+
|
28
|
+
out.lines.map(&:strip)
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# Encode an env hash as a string where secret values have been looked up and all values escaped for Docker.
|
2
|
+
class Kamal::EnvFile
|
3
|
+
def initialize(env)
|
4
|
+
@env = env
|
5
|
+
end
|
6
|
+
|
7
|
+
def to_s
|
8
|
+
env_file = StringIO.new.tap do |contents|
|
9
|
+
@env.each do |key, value|
|
10
|
+
contents << docker_env_file_line(key, value)
|
11
|
+
end
|
12
|
+
end.string
|
13
|
+
|
14
|
+
# Ensure the file has some contents to avoid the SSHKIT empty file warning
|
15
|
+
env_file.presence || "\n"
|
16
|
+
end
|
17
|
+
|
18
|
+
def to_io
|
19
|
+
StringIO.new(to_s)
|
20
|
+
end
|
21
|
+
|
22
|
+
alias to_str to_s
|
23
|
+
|
24
|
+
private
|
25
|
+
def docker_env_file_line(key, value)
|
26
|
+
"#{key}=#{escape_docker_env_file_value(value)}\n"
|
27
|
+
end
|
28
|
+
|
29
|
+
# Escape a value to make it safe to dump in a docker file.
|
30
|
+
def escape_docker_env_file_value(value)
|
31
|
+
# keep non-ascii(UTF-8) characters as it is
|
32
|
+
value.to_s.scan(/[\x00-\x7F]+|[^\x00-\x7F]+/).map do |part|
|
33
|
+
part.ascii_only? ? escape_docker_env_file_ascii_value(part) : part
|
34
|
+
end.join
|
35
|
+
end
|
36
|
+
|
37
|
+
def escape_docker_env_file_ascii_value(value)
|
38
|
+
# Doublequotes are treated literally in docker env files
|
39
|
+
# so remove leading and trailing ones and unescape any others
|
40
|
+
value.to_s.dump[1..-2]
|
41
|
+
.gsub(/\\"/, "\"")
|
42
|
+
.gsub(/\\#/, "#")
|
43
|
+
end
|
44
|
+
end
|
data/lib/kamal/git.rb
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
module Kamal::Git
|
2
|
+
extend self
|
3
|
+
|
4
|
+
def used?
|
5
|
+
system("git rev-parse")
|
6
|
+
end
|
7
|
+
|
8
|
+
def user_name
|
9
|
+
`git config user.name`.strip
|
10
|
+
end
|
11
|
+
|
12
|
+
def email
|
13
|
+
`git config user.email`.strip
|
14
|
+
end
|
15
|
+
|
16
|
+
def revision
|
17
|
+
`git rev-parse HEAD`.strip
|
18
|
+
end
|
19
|
+
|
20
|
+
def uncommitted_changes
|
21
|
+
`git status --porcelain`.strip
|
22
|
+
end
|
23
|
+
|
24
|
+
def root
|
25
|
+
`git rev-parse --show-toplevel`.strip
|
26
|
+
end
|
27
|
+
|
28
|
+
# returns an array of relative path names of files with uncommitted changes
|
29
|
+
def uncommitted_files
|
30
|
+
`git ls-files --modified`.lines.map(&:strip)
|
31
|
+
end
|
32
|
+
|
33
|
+
# returns an array of relative path names of untracked files, including gitignored files
|
34
|
+
def untracked_files
|
35
|
+
`git ls-files --others`.lines.map(&:strip)
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
class Kamal::Secrets::Adapters::AwsSecretsManager < Kamal::Secrets::Adapters::Base
|
2
|
+
def requires_account?
|
3
|
+
false
|
4
|
+
end
|
5
|
+
|
6
|
+
private
|
7
|
+
def login(_account)
|
8
|
+
nil
|
9
|
+
end
|
10
|
+
|
11
|
+
def fetch_secrets(secrets, from:, account: nil, session:)
|
12
|
+
{}.tap do |results|
|
13
|
+
get_from_secrets_manager(prefixed_secrets(secrets, from: from), account: account).each do |secret|
|
14
|
+
secret_name = secret["Name"]
|
15
|
+
secret_string = JSON.parse(secret["SecretString"])
|
16
|
+
|
17
|
+
secret_string.each do |key, value|
|
18
|
+
results["#{secret_name}/#{key}"] = value
|
19
|
+
end
|
20
|
+
rescue JSON::ParserError
|
21
|
+
results["#{secret_name}"] = secret["SecretString"]
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def get_from_secrets_manager(secrets, account: nil)
|
27
|
+
args = [ "aws", "secretsmanager", "batch-get-secret-value", "--secret-id-list" ] + secrets.map(&:shellescape)
|
28
|
+
args += [ "--profile", account.shellescape ] if account
|
29
|
+
args += [ "--output", "json" ]
|
30
|
+
cmd = args.join(" ")
|
31
|
+
|
32
|
+
`#{cmd}`.tap do |secrets|
|
33
|
+
raise RuntimeError, "Could not read #{secrets} from AWS Secrets Manager" unless $?.success?
|
34
|
+
|
35
|
+
secrets = JSON.parse(secrets)
|
36
|
+
|
37
|
+
return secrets["SecretValues"] unless secrets["Errors"].present?
|
38
|
+
|
39
|
+
raise RuntimeError, secrets["Errors"].map { |error| "#{error['SecretId']}: #{error['Message']}" }.join(" ")
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def check_dependencies!
|
44
|
+
raise RuntimeError, "AWS CLI is not installed" unless cli_installed?
|
45
|
+
end
|
46
|
+
|
47
|
+
def cli_installed?
|
48
|
+
`aws --version 2> /dev/null`
|
49
|
+
$?.success?
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
class Kamal::Secrets::Adapters::Base
|
2
|
+
delegate :optionize, to: Kamal::Utils
|
3
|
+
|
4
|
+
def fetch(secrets, account: nil, from: nil)
|
5
|
+
raise RuntimeError, "Missing required option '--account'" if requires_account? && account.blank?
|
6
|
+
|
7
|
+
check_dependencies!
|
8
|
+
|
9
|
+
session = login(account)
|
10
|
+
fetch_secrets(secrets, from: from, account: account, session: session)
|
11
|
+
end
|
12
|
+
|
13
|
+
def requires_account?
|
14
|
+
true
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
def login(...)
|
19
|
+
raise NotImplementedError
|
20
|
+
end
|
21
|
+
|
22
|
+
def fetch_secrets(...)
|
23
|
+
raise NotImplementedError
|
24
|
+
end
|
25
|
+
|
26
|
+
def check_dependencies!
|
27
|
+
raise NotImplementedError
|
28
|
+
end
|
29
|
+
|
30
|
+
def prefixed_secrets(secrets, from:)
|
31
|
+
secrets.map { |secret| [ from, secret ].compact.join("/") }
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
class Kamal::Secrets::Adapters::Bitwarden < Kamal::Secrets::Adapters::Base
|
2
|
+
private
|
3
|
+
def login(account)
|
4
|
+
status = run_command("status")
|
5
|
+
|
6
|
+
if status["status"] == "unauthenticated"
|
7
|
+
run_command("login #{account.shellescape}", raw: true)
|
8
|
+
status = run_command("status")
|
9
|
+
end
|
10
|
+
|
11
|
+
if status["status"] == "locked"
|
12
|
+
session = run_command("unlock --raw", raw: true).presence
|
13
|
+
status = run_command("status", session: session)
|
14
|
+
end
|
15
|
+
|
16
|
+
raise RuntimeError, "Failed to login to and unlock Bitwarden" unless status["status"] == "unlocked"
|
17
|
+
|
18
|
+
run_command("sync", session: session, raw: true)
|
19
|
+
raise RuntimeError, "Failed to sync Bitwarden" unless $?.success?
|
20
|
+
|
21
|
+
session
|
22
|
+
end
|
23
|
+
|
24
|
+
def fetch_secrets(secrets, from:, account:, session:)
|
25
|
+
{}.tap do |results|
|
26
|
+
items_fields(prefixed_secrets(secrets, from: from)).each do |item, fields|
|
27
|
+
item_json = run_command("get item #{item.shellescape}", session: session, raw: true)
|
28
|
+
raise RuntimeError, "Could not read #{item} from Bitwarden" unless $?.success?
|
29
|
+
item_json = JSON.parse(item_json)
|
30
|
+
if fields.any?
|
31
|
+
results.merge! fetch_secrets_from_fields(fields, item, item_json)
|
32
|
+
elsif item_json.dig("login", "password")
|
33
|
+
results[item] = item_json.dig("login", "password")
|
34
|
+
elsif item_json["fields"]&.any?
|
35
|
+
fields = item_json["fields"].pluck("name")
|
36
|
+
results.merge! fetch_secrets_from_fields(fields, item, item_json)
|
37
|
+
else
|
38
|
+
raise RuntimeError, "Item #{item} is not a login type item and no fields were specified"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def fetch_secrets_from_fields(fields, item, item_json)
|
45
|
+
fields.to_h do |field|
|
46
|
+
item_field = item_json["fields"].find { |f| f["name"] == field }
|
47
|
+
raise RuntimeError, "Could not find field #{field} in item #{item} in Bitwarden" unless item_field
|
48
|
+
value = item_field["value"]
|
49
|
+
[ "#{item}/#{field}", value ]
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def items_fields(secrets)
|
54
|
+
{}.tap do |items|
|
55
|
+
secrets.each do |secret|
|
56
|
+
item, field = secret.split("/")
|
57
|
+
items[item] ||= []
|
58
|
+
items[item] << field
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def signedin?(account)
|
64
|
+
run_command("status")["status"] != "unauthenticated"
|
65
|
+
end
|
66
|
+
|
67
|
+
def run_command(command, session: nil, raw: false)
|
68
|
+
full_command = [ *("BW_SESSION=#{session.shellescape}" if session), "bw", command ].join(" ")
|
69
|
+
result = `#{full_command}`.strip
|
70
|
+
raw ? result : JSON.parse(result)
|
71
|
+
end
|
72
|
+
|
73
|
+
def check_dependencies!
|
74
|
+
raise RuntimeError, "Bitwarden CLI is not installed" unless cli_installed?
|
75
|
+
end
|
76
|
+
|
77
|
+
def cli_installed?
|
78
|
+
`bw --version 2> /dev/null`
|
79
|
+
$?.success?
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
class Kamal::Secrets::Adapters::BitwardenSecretsManager < Kamal::Secrets::Adapters::Base
|
2
|
+
def requires_account?
|
3
|
+
false
|
4
|
+
end
|
5
|
+
|
6
|
+
private
|
7
|
+
LIST_ALL_SELECTOR = "all"
|
8
|
+
LIST_ALL_FROM_PROJECT_SUFFIX = "/all"
|
9
|
+
LIST_COMMAND = "secret list"
|
10
|
+
GET_COMMAND = "secret get"
|
11
|
+
|
12
|
+
def fetch_secrets(secrets, from:, account:, session:)
|
13
|
+
raise RuntimeError, "You must specify what to retrieve from Bitwarden Secrets Manager" if secrets.length == 0
|
14
|
+
|
15
|
+
secrets = prefixed_secrets(secrets, from: from)
|
16
|
+
command, project = extract_command_and_project(secrets)
|
17
|
+
|
18
|
+
{}.tap do |results|
|
19
|
+
if command.nil?
|
20
|
+
secrets.each do |secret_uuid|
|
21
|
+
item_json = run_command("#{GET_COMMAND} #{secret_uuid.shellescape}")
|
22
|
+
raise RuntimeError, "Could not read #{secret_uuid} from Bitwarden Secrets Manager" unless $?.success?
|
23
|
+
item_json = JSON.parse(item_json)
|
24
|
+
results[item_json["key"]] = item_json["value"]
|
25
|
+
end
|
26
|
+
else
|
27
|
+
items_json = run_command(command)
|
28
|
+
raise RuntimeError, "Could not read secrets from Bitwarden Secrets Manager" unless $?.success?
|
29
|
+
|
30
|
+
JSON.parse(items_json).each do |item_json|
|
31
|
+
results[item_json["key"]] = item_json["value"]
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def extract_command_and_project(secrets)
|
38
|
+
if secrets.length == 1
|
39
|
+
if secrets[0] == LIST_ALL_SELECTOR
|
40
|
+
[ LIST_COMMAND, nil ]
|
41
|
+
elsif secrets[0].end_with?(LIST_ALL_FROM_PROJECT_SUFFIX)
|
42
|
+
project = secrets[0].split(LIST_ALL_FROM_PROJECT_SUFFIX).first
|
43
|
+
[ "#{LIST_COMMAND} #{project.shellescape}", project ]
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def run_command(command, session: nil)
|
49
|
+
full_command = [ "bws", command ].join(" ")
|
50
|
+
`#{full_command}`
|
51
|
+
end
|
52
|
+
|
53
|
+
def login(account)
|
54
|
+
run_command("project list")
|
55
|
+
raise RuntimeError, "Could not authenticate to Bitwarden Secrets Manager. Did you set a valid access token?" unless $?.success?
|
56
|
+
end
|
57
|
+
|
58
|
+
def check_dependencies!
|
59
|
+
raise RuntimeError, "Bitwarden Secrets Manager CLI is not installed" unless cli_installed?
|
60
|
+
end
|
61
|
+
|
62
|
+
def cli_installed?
|
63
|
+
`bws --version 2> /dev/null`
|
64
|
+
$?.success?
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
class Kamal::Secrets::Adapters::Doppler < Kamal::Secrets::Adapters::Base
|
2
|
+
def requires_account?
|
3
|
+
false
|
4
|
+
end
|
5
|
+
|
6
|
+
private
|
7
|
+
def login(*)
|
8
|
+
unless loggedin?
|
9
|
+
`doppler login -y`
|
10
|
+
raise RuntimeError, "Failed to login to Doppler" unless $?.success?
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def loggedin?
|
15
|
+
`doppler me --json 2> /dev/null`
|
16
|
+
$?.success?
|
17
|
+
end
|
18
|
+
|
19
|
+
def fetch_secrets(secrets, from:, **)
|
20
|
+
secrets = prefixed_secrets(secrets, from: from)
|
21
|
+
flags = secrets_get_flags(secrets)
|
22
|
+
|
23
|
+
secret_names = secrets.collect { |s| s.split("/").last }
|
24
|
+
|
25
|
+
items = `doppler secrets get #{secret_names.map(&:shellescape).join(" ")} --json #{flags}`
|
26
|
+
raise RuntimeError, "Could not read #{secrets} from Doppler" unless $?.success?
|
27
|
+
|
28
|
+
items = JSON.parse(items)
|
29
|
+
|
30
|
+
items.transform_values { |value| value["computed"] }
|
31
|
+
end
|
32
|
+
|
33
|
+
def secrets_get_flags(secrets)
|
34
|
+
unless service_token_set?
|
35
|
+
project, config, _ = secrets.first.split("/")
|
36
|
+
|
37
|
+
unless project && config
|
38
|
+
raise RuntimeError, "Missing project or config from '--from=project/config' option"
|
39
|
+
end
|
40
|
+
|
41
|
+
project_and_config_flags = "-p #{project.shellescape} -c #{config.shellescape}"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def service_token_set?
|
46
|
+
ENV["DOPPLER_TOKEN"] && ENV["DOPPLER_TOKEN"][0, 5] == "dp.st"
|
47
|
+
end
|
48
|
+
|
49
|
+
def check_dependencies!
|
50
|
+
raise RuntimeError, "Doppler CLI is not installed" unless cli_installed?
|
51
|
+
end
|
52
|
+
|
53
|
+
def cli_installed?
|
54
|
+
`doppler --version 2> /dev/null`
|
55
|
+
$?.success?
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
##
|
2
|
+
# Enpass is different from most password managers, in a way that it's offline and doesn't need an account.
|
3
|
+
#
|
4
|
+
# Usage
|
5
|
+
#
|
6
|
+
# Fetch all password from FooBar item
|
7
|
+
# `kamal secrets fetch --adapter enpass --from /Users/YOUR_USERNAME/Library/Containers/in.sinew.Enpass-Desktop/Data/Documents/Vaults/primary FooBar`
|
8
|
+
#
|
9
|
+
# Fetch only DB_PASSWORD from FooBar item
|
10
|
+
# `kamal secrets fetch --adapter enpass --from /Users/YOUR_USERNAME/Library/Containers/in.sinew.Enpass-Desktop/Data/Documents/Vaults/primary FooBar/DB_PASSWORD`
|
11
|
+
class Kamal::Secrets::Adapters::Enpass < Kamal::Secrets::Adapters::Base
|
12
|
+
def requires_account?
|
13
|
+
false
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
def fetch_secrets(secrets, from:, account:, session:)
|
18
|
+
secrets_titles = fetch_secret_titles(secrets)
|
19
|
+
|
20
|
+
result = `enpass-cli -json -vault #{from.shellescape} show #{secrets_titles.map(&:shellescape).join(" ")}`.strip
|
21
|
+
|
22
|
+
parse_result_and_take_secrets(result, secrets)
|
23
|
+
end
|
24
|
+
|
25
|
+
def check_dependencies!
|
26
|
+
raise RuntimeError, "Enpass CLI is not installed" unless cli_installed?
|
27
|
+
end
|
28
|
+
|
29
|
+
def cli_installed?
|
30
|
+
`enpass-cli version 2> /dev/null`
|
31
|
+
$?.success?
|
32
|
+
end
|
33
|
+
|
34
|
+
def login(account)
|
35
|
+
nil
|
36
|
+
end
|
37
|
+
|
38
|
+
def fetch_secret_titles(secrets)
|
39
|
+
secrets.reduce(Set.new) do |secret_titles, secret|
|
40
|
+
# Sometimes secrets contain a '/', when the intent is to fetch a single password for an item. Example: FooBar/DB_PASSWORD
|
41
|
+
# Another case is, when the intent is to fetch all passwords for an item. Example: FooBar (and FooBar may have multiple different passwords)
|
42
|
+
key, separator, value = secret.rpartition("/")
|
43
|
+
if key.empty?
|
44
|
+
secret_titles << value
|
45
|
+
else
|
46
|
+
secret_titles << key
|
47
|
+
end
|
48
|
+
end.to_a
|
49
|
+
end
|
50
|
+
|
51
|
+
def parse_result_and_take_secrets(unparsed_result, secrets)
|
52
|
+
result = JSON.parse(unparsed_result)
|
53
|
+
|
54
|
+
result.reduce({}) do |secrets_with_passwords, item|
|
55
|
+
title = item["title"]
|
56
|
+
label = item["label"]
|
57
|
+
password = item["password"]
|
58
|
+
|
59
|
+
if title && password.present?
|
60
|
+
key = [ title, label ].compact.reject(&:empty?).join("/")
|
61
|
+
|
62
|
+
if secrets.include?(title) || secrets.include?(key)
|
63
|
+
raise RuntimeError, "#{key} is present more than once" if secrets_with_passwords[key]
|
64
|
+
secrets_with_passwords[key] = password
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
secrets_with_passwords
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
class Kamal::Secrets::Adapters::GcpSecretManager < Kamal::Secrets::Adapters::Base
|
2
|
+
private
|
3
|
+
def login(account)
|
4
|
+
# Since only the account option is passed from the cli, we'll use it for both account and service account
|
5
|
+
# impersonation.
|
6
|
+
#
|
7
|
+
# Syntax:
|
8
|
+
# ACCOUNT: USER | USER "|" DELEGATION_CHAIN
|
9
|
+
# USER: DEFAULT_USER | EMAIL
|
10
|
+
# DELEGATION_CHAIN: EMAIL | EMAIL "," DELEGATION_CHAIN
|
11
|
+
# EMAIL: <The email address of the user or service account, like "my-user@example.com" >
|
12
|
+
# DEFAULT_USER: "default"
|
13
|
+
#
|
14
|
+
# Some valid examples:
|
15
|
+
# - "my-user@example.com" sets the user
|
16
|
+
# - "my-user@example.com|my-service-user@example.com" will use my-user and enable service account impersonation as my-service-user
|
17
|
+
# - "default" will use the default user and no impersonation
|
18
|
+
# - "default|my-service-user@example.com" will use the default user, and enable service account impersonation as my-service-user
|
19
|
+
# - "default|my-service-user@example.com,another-service-user@example.com" same as above, but with an impersonation delegation chain
|
20
|
+
|
21
|
+
unless logged_in?
|
22
|
+
`gcloud auth login`
|
23
|
+
raise RuntimeError, "could not login to gcloud" unless logged_in?
|
24
|
+
end
|
25
|
+
|
26
|
+
nil
|
27
|
+
end
|
28
|
+
|
29
|
+
def fetch_secrets(secrets, from:, account:, session:)
|
30
|
+
user, service_account = parse_account(account)
|
31
|
+
|
32
|
+
{}.tap do |results|
|
33
|
+
secrets_with_metadata(prefixed_secrets(secrets, from: from)).each do |secret, (project, secret_name, secret_version)|
|
34
|
+
item_name = "#{project}/#{secret_name}"
|
35
|
+
results[item_name] = fetch_secret(project, secret_name, secret_version, user, service_account)
|
36
|
+
raise RuntimeError, "Could not read #{item_name} from Google Secret Manager" unless $?.success?
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def fetch_secret(project, secret_name, secret_version, user, service_account)
|
42
|
+
secret = run_command(
|
43
|
+
"secrets versions access #{secret_version.shellescape} --secret=#{secret_name.shellescape}",
|
44
|
+
project: project,
|
45
|
+
user: user,
|
46
|
+
service_account: service_account
|
47
|
+
)
|
48
|
+
Base64.decode64(secret.dig("payload", "data"))
|
49
|
+
end
|
50
|
+
|
51
|
+
# The secret needs to at least contain a secret name, but project name, and secret version can also be specified.
|
52
|
+
#
|
53
|
+
# The string "default" can be used to refer to the default project configured for gcloud.
|
54
|
+
#
|
55
|
+
# The version can be either the string "latest", or a version number.
|
56
|
+
#
|
57
|
+
# The following formats are valid:
|
58
|
+
#
|
59
|
+
# - The following are all equivalent, and sets project: default, secret name: my-secret, version: latest
|
60
|
+
# - "my-secret"
|
61
|
+
# - "default/my-secret"
|
62
|
+
# - "default/my-secret/latest"
|
63
|
+
# - "my-secret/latest" in combination with --from=default
|
64
|
+
# - "my-secret/123" (only in combination with --from=some-project) -> project: some-project, secret name: my-secret, version: 123
|
65
|
+
# - "some-project/my-secret/123" -> project: some-project, secret name: my-secret, version: 123
|
66
|
+
def secrets_with_metadata(secrets)
|
67
|
+
{}.tap do |items|
|
68
|
+
secrets.each do |secret|
|
69
|
+
parts = secret.split("/")
|
70
|
+
parts.unshift("default") if parts.length == 1
|
71
|
+
project = parts.shift
|
72
|
+
secret_name = parts.shift
|
73
|
+
secret_version = parts.shift || "latest"
|
74
|
+
|
75
|
+
items[secret] = [ project, secret_name, secret_version ]
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def run_command(command, project: "default", user: "default", service_account: nil)
|
81
|
+
full_command = [ "gcloud", command ]
|
82
|
+
full_command << "--project=#{project.shellescape}" unless project == "default"
|
83
|
+
full_command << "--account=#{user.shellescape}" unless user == "default"
|
84
|
+
full_command << "--impersonate-service-account=#{service_account.shellescape}" if service_account
|
85
|
+
full_command << "--format=json"
|
86
|
+
full_command = full_command.join(" ")
|
87
|
+
|
88
|
+
result = `#{full_command}`.strip
|
89
|
+
JSON.parse(result)
|
90
|
+
end
|
91
|
+
|
92
|
+
def check_dependencies!
|
93
|
+
raise RuntimeError, "gcloud CLI is not installed" unless cli_installed?
|
94
|
+
end
|
95
|
+
|
96
|
+
def cli_installed?
|
97
|
+
`gcloud --version 2> /dev/null`
|
98
|
+
$?.success?
|
99
|
+
end
|
100
|
+
|
101
|
+
def logged_in?
|
102
|
+
JSON.parse(`gcloud auth list --format=json`).any?
|
103
|
+
end
|
104
|
+
|
105
|
+
def parse_account(account)
|
106
|
+
account.split("|", 2)
|
107
|
+
end
|
108
|
+
|
109
|
+
def is_user?(candidate)
|
110
|
+
candidate.include?("@")
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
class Kamal::Secrets::Adapters::LastPass < Kamal::Secrets::Adapters::Base
|
2
|
+
private
|
3
|
+
def login(account)
|
4
|
+
unless loggedin?(account)
|
5
|
+
`lpass login #{account.shellescape}`
|
6
|
+
raise RuntimeError, "Failed to login to LastPass" unless $?.success?
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
def loggedin?(account)
|
11
|
+
`lpass status --color never`.strip == "Logged in as #{account}."
|
12
|
+
end
|
13
|
+
|
14
|
+
def fetch_secrets(secrets, from:, account:, session:)
|
15
|
+
secrets = prefixed_secrets(secrets, from: from)
|
16
|
+
items = `lpass show #{secrets.map(&:shellescape).join(" ")} --json`
|
17
|
+
raise RuntimeError, "Could not read #{secrets} from LastPass" unless $?.success?
|
18
|
+
|
19
|
+
items = JSON.parse(items)
|
20
|
+
|
21
|
+
{}.tap do |results|
|
22
|
+
items.each do |item|
|
23
|
+
results[item["fullname"]] = item["password"]
|
24
|
+
end
|
25
|
+
|
26
|
+
if (missing_items = secrets - results.keys).any?
|
27
|
+
raise RuntimeError, "Could not find #{missing_items.join(", ")} in LastPass"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def check_dependencies!
|
33
|
+
raise RuntimeError, "LastPass CLI is not installed" unless cli_installed?
|
34
|
+
end
|
35
|
+
|
36
|
+
def cli_installed?
|
37
|
+
`lpass --version 2> /dev/null`
|
38
|
+
$?.success?
|
39
|
+
end
|
40
|
+
end
|