From d1c15231357d424d653c77675d69c818c003fcc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Kolsj=C3=B6?= Date: Wed, 11 Dec 2024 13:52:03 +0100 Subject: [PATCH] Move salt and config into cloud init model --- README.md | 6 +++-- app/controllers/api/cloud_inits_controller.rb | 8 +++---- app/controllers/cloud_inits_controller.rb | 2 +- app/models/cloud_init.rb | 8 ++++++- app/models/cloud_init_template_helper.rb | 22 +++++-------------- app/views/cloud_inits/_form.html.slim | 8 +++---- app/views/cloud_inits/index.html.slim | 2 +- .../20241210133507_change_cloud_inits.rb | 14 ++++++++++++ db/schema.rb | 6 +++-- lib/tasks/app.rake | 13 ++++++----- spec/features/cloud_inits_spec.rb | 2 +- spec/requests/api/cloud_inits_spec.rb | 17 +++++++------- 12 files changed, 62 insertions(+), 46 deletions(-) create mode 100644 db/migrate/20241210133507_change_cloud_inits.rb diff --git a/README.md b/README.md index b6ed646..36d197b 100644 --- a/README.md +++ b/README.md @@ -108,9 +108,11 @@ A build is unlocked by posting to `/api/build/unlock` with the following attribu ## Cloud-init support -The app can be used to provision new servers using cloud-init. You can create a new script in the WEB UI which is then served by the API. In addition to this there is a rake task `rake app:cloud_init_login[remote_ip]` for accessing login credentials to the servers provisioned by the cloud-init script. +The app can be used to provision new servers using cloud-init. You can create a new script in the WEB UI which is then served by the API. -There is no example of how this script looks at this time, but you can use official docs and use variables in the template to access to public methods and config on CloudInitTemplateHelper. +In addition to this there is a rake task `rake app:cloud_init_password[template_name, remote_ip]` for accessing password for the servers provisioned by the cloud-init script (given you have used {{password}} in the template). + +For more information see the [cloud-init docs](https://cloudinit.readthedocs.io/en/latest/). ## ENVs diff --git a/app/controllers/api/cloud_inits_controller.rb b/app/controllers/api/cloud_inits_controller.rb index 126f2ae..cce996c 100644 --- a/app/controllers/api/cloud_inits_controller.rb +++ b/app/controllers/api/cloud_inits_controller.rb @@ -1,15 +1,15 @@ class Api::CloudInitsController < ApiController def show - template = CloudInit.find_by!(name: params[:name]).data + cloud_init = CloudInit.find_by!(name: params[:name]) - helper = CloudInitTemplateHelper.new(request.remote_ip) + helper = CloudInitTemplateHelper.new(cloud_init:, remote_ip: request.remote_ip) # We don't run any code in the template itself since we don't need to # and it removes one possible attack vector. - data = template.gsub(/{{(.*?)}}/) { + data = cloud_init.template.gsub(/{{(.*?)}}/) { variable = $1 - if helper.public_methods.include?(variable.to_sym) + if [ :password ].include?(variable.to_sym) helper.public_send(variable) else helper.config(variable) diff --git a/app/controllers/cloud_inits_controller.rb b/app/controllers/cloud_inits_controller.rb index 0c04a36..6125221 100644 --- a/app/controllers/cloud_inits_controller.rb +++ b/app/controllers/cloud_inits_controller.rb @@ -44,6 +44,6 @@ def setup_menu end def cloud_init_params - params.require(:cloud_init).permit(:name, :data) + params.require(:cloud_init).permit(:name, :template) end end diff --git a/app/models/cloud_init.rb b/app/models/cloud_init.rb index 4061813..b22372a 100644 --- a/app/models/cloud_init.rb +++ b/app/models/cloud_init.rb @@ -1,3 +1,9 @@ class CloudInit < ActiveRecord::Base - validates :name, :data, presence: true + validates :name, :template, presence: true + + before_validation :generate_password_salt, on: :create + + def generate_password_salt + self.password_salt = SecureRandom.hex(32) + end end diff --git a/app/models/cloud_init_template_helper.rb b/app/models/cloud_init_template_helper.rb index 525b1c2..8646c36 100644 --- a/app/models/cloud_init_template_helper.rb +++ b/app/models/cloud_init_template_helper.rb @@ -1,29 +1,19 @@ class CloudInitTemplateHelper - pattr_initialize :remote_ip + pattr_initialize [ :cloud_init!, :remote_ip! ] def config(name) - name = name.to_s.upcase - - if Rails.env.test? || Rails.env.development? - "test-config-#{name}" - else - ENV["CLOUD_INIT_CONFIG_#{name}"] || raise("Missing ENV: CLOUD_INIT_CONFIG_#{name}") - end - end - - def username - config(:username) + cloud_init.config.fetch(name) end def password - password_salt = + secret = if Rails.env.test? || Rails.env.development? - "test-salt" + "test-secret" else - ENV.fetch("CLOUD_INIT_PASSWORD_SALT") + ENV.fetch("CLOUD_INIT_PASSWORD_SECRET") end - combined = "#{password_salt}:#{remote_ip}" + combined = "#{secret}:#{cloud_init.password_salt}:#{remote_ip}" hash = Digest::SHA256.hexdigest(combined) hash.first(32) end diff --git a/app/views/cloud_inits/_form.html.slim b/app/views/cloud_inits/_form.html.slim index 27c2a53..f344656 100644 --- a/app/views/cloud_inits/_form.html.slim +++ b/app/views/cloud_inits/_form.html.slim @@ -6,12 +6,12 @@ = bootstrap_form_for(cloud_init) do |f| = f.text_field :name - = f.label :data, "Content" + = f.label :template, "Content" p strong NOTE: Keep a backup copy of this since it does not store old versions or handle if the pipeline session times out before you save. - #editor style="width: 90%; height: 1200px;" = f.object.data + #editor style="width: 90%; height: 1200px;" = f.object.template - = f.text_area :data, id: "cloud_init_data", style: (Rails.env.test? ? "" : "display: none;") + = f.text_area :template, id: "cloud_init_template", style: (Rails.env.test? ? "" : "display: none;") script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.14/ace.js" script | var editor = ace.edit("editor"); @@ -19,7 +19,7 @@ | editor.session.setMode("ace/mode/yaml"); | editor.setFontSize(16); | editor.session.on("change", function() { - | document.getElementById("cloud_init_data").value = editor.getValue(); + | document.getElementById("cloud_init_template").value = editor.getValue(); | }); p = link_to "Cloud-init docs", "https://cloudinit.readthedocs.io/en/latest/", target: "_blank" diff --git a/app/views/cloud_inits/index.html.slim b/app/views/cloud_inits/index.html.slim index 6345efe..d3b70ee 100644 --- a/app/views/cloud_inits/index.html.slim +++ b/app/views/cloud_inits/index.html.slim @@ -12,5 +12,5 @@ h1 Cloud-inits pre = "#include\n" + api_cloud_init_url(name: cloud_init.name, token: App.api_token) p Contents: - pre = cloud_init.data + pre = cloud_init.template p = link_to "New cloud-init", new_cloud_init_path diff --git a/db/migrate/20241210133507_change_cloud_inits.rb b/db/migrate/20241210133507_change_cloud_inits.rb new file mode 100644 index 0000000..9bafef6 --- /dev/null +++ b/db/migrate/20241210133507_change_cloud_inits.rb @@ -0,0 +1,14 @@ +class ChangeCloudInits < ActiveRecord::Migration[7.1] + def change + rename_column :cloud_inits, :data, :template + add_column :cloud_inits, :password_salt, :string + add_column :cloud_inits, :config, :jsonb, null: false, default: {} + + CloudInit.reset_column_information + CloudInit.all.each do |cloud_init| + cloud_init.update_column(:password_salt, SecureRandom.hex(32)) + end + + change_column :cloud_inits, :password_salt, :string, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index b833f4a..329d38f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_12_03_104945) do +ActiveRecord::Schema[7.1].define(version: 2024_12_10_133507) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -25,9 +25,11 @@ create_table "cloud_inits", force: :cascade do |t| t.string "name", null: false - t.text "data", null: false + t.text "template", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "password_salt", null: false + t.jsonb "config", default: {}, null: false end create_table "projects", force: :cascade do |t| diff --git a/lib/tasks/app.rake b/lib/tasks/app.rake index 5b4eb3f..c31f01b 100644 --- a/lib/tasks/app.rake +++ b/lib/tasks/app.rake @@ -26,11 +26,14 @@ namespace :app do end end - desc "Show the username and password for a cloud-init server" - task :cloud_init_login, [:remote_ip] => :environment do |_t, args| + desc "Show the password for a cloud-init server" + task :cloud_init_password, [:name, :remote_ip] => :environment do |_t, args| + name = args[:name] remote_ip = args[:remote_ip] - helper = CloudInitTemplateHelper.new(remote_ip) - puts "Cloud init server #{remote_ip} has username \"#{helper.username}\" and password \"#{helper.password}\" (if the template uses them)" - end + cloud_init = CloudInit.find_by!(name: name) + helper = CloudInitTemplateHelper.new(remote_ip: remote_ip, cloud_init:) + # intentionally print it in a easily parsable format since people might build tooling around this + puts "template=#{name} ip=#{remote_ip} password=#{helper.password}" + end end diff --git a/spec/features/cloud_inits_spec.rb b/spec/features/cloud_inits_spec.rb index e6091e3..41d346e 100644 --- a/spec/features/cloud_inits_spec.rb +++ b/spec/features/cloud_inits_spec.rb @@ -12,7 +12,7 @@ expect(current_path).to eq(cloud_inits_path) expect(page).to have_content("Cloud-init was successfully created.") cloud_init = CloudInit.first! - expect(cloud_init.data).to eq("content") + expect(cloud_init.template).to eq("content") click_link "Edit" fill_in "Name", with: "bar" diff --git a/spec/requests/api/cloud_inits_spec.rb b/spec/requests/api/cloud_inits_spec.rb index bede38b..2add529 100644 --- a/spec/requests/api/cloud_inits_spec.rb +++ b/spec/requests/api/cloud_inits_spec.rb @@ -2,28 +2,27 @@ RSpec.describe "GET /api/cloud_init", type: :request do it "gets a cloud-init config if you have the right api token" do - cloud_init = CloudInit.create!(name: "foo", data: %{ + cloud_init = CloudInit.create!(name: "foo", template: %{ #cloud-config - username: {{username}} password: {{password}} extra: {{extra}} - }) + }, config: { "extra" => "test-config-extra" }) allow(App).to receive(:api_token).and_return("secret") get "/api/cloud_init?token=secret&name=foo" - password = Digest::SHA256.hexdigest("test-salt:127.0.0.1").first(32) - expect(CloudInitTemplateHelper.new("127.0.0.1").password).to eq(password) + expect(cloud_init.password_salt.size).to eq(64) + password = Digest::SHA256.hexdigest("test-secret:#{cloud_init.password_salt}:127.0.0.1").first(32) + expect(CloudInitTemplateHelper.new(cloud_init:, remote_ip:"127.0.0.1").password).to eq(password) expect(response).to be_successful expect(response.body).to include("#cloud-config") - expect(response.body).to include("username: test-config-USERNAME") expect(response.body).to include("password: #{password}\n") - expect(response.body).to include("extra: test-config-EXTRA\n") + expect(response.body).to include("extra: test-config-extra\n") end it "fails when the api token is wrong" do - cloud_init = CloudInit.create!(name: "foo", data: "#cloud-config...") + cloud_init = CloudInit.create!(name: "foo", template: "#cloud-config...") allow(App).to receive(:api_token).and_return("secret") get "/api/cloud_init?token=wrong&name=foo" @@ -33,7 +32,7 @@ end it "fails when the name is unknown" do - cloud_init = CloudInit.create!(name: "foo", data: "#cloud-config...") + cloud_init = CloudInit.create!(name: "foo", template: "#cloud-config...") allow(App).to receive(:api_token).and_return("secret") get "/api/cloud_init?token=wrong&name=bar"