# # Author:: John Keiser () # Copyright:: Copyright (c) 2012-2017, Chef Software Inc. # License:: Apache License, Version 2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # require "openssl" unless defined?(OpenSSL) require "open-uri" unless defined?(OpenURI) require "rubygems" unless defined?(Gem) require "timeout" unless defined?(Timeout) require "stringio" unless defined?(StringIO) require "rack" unless defined?(Rack) require "webrick" unless defined?(WEBrick) require "webrick/https" require_relative "../chef_zero" require_relative "socketless_server_map" require_relative "chef_data/cookbook_data" require_relative "chef_data/acl_path" require_relative "rest_router" require_relative "data_store/memory_store_v2" require_relative "data_store/v1_to_v2_adapter" require_relative "data_store/default_facade" require_relative "version" require "chef_zero/dist.rb" require_relative "endpoints/rest_list_endpoint" require_relative "endpoints/authenticate_user_endpoint" require_relative "endpoints/acls_endpoint" require_relative "endpoints/acl_endpoint" require_relative "endpoints/actor_endpoint" require_relative "endpoints/actors_endpoint" require_relative "endpoints/actor_key_endpoint" require_relative "endpoints/organization_user_key_endpoint" require_relative "endpoints/organization_user_default_key_endpoint" require_relative "endpoints/organization_user_keys_endpoint" require_relative "endpoints/actor_default_key_endpoint" require_relative "endpoints/actor_keys_endpoint" require_relative "endpoints/cookbooks_endpoint" require_relative "endpoints/cookbook_endpoint" require_relative "endpoints/cookbook_version_endpoint" require_relative "endpoints/cookbook_artifacts_endpoint" require_relative "endpoints/cookbook_artifact_endpoint" require_relative "endpoints/cookbook_artifact_identifier_endpoint" require_relative "endpoints/containers_endpoint" require_relative "endpoints/container_endpoint" require_relative "endpoints/controls_endpoint" require_relative "endpoints/dummy_endpoint" require_relative "endpoints/data_bags_endpoint" require_relative "endpoints/data_bag_endpoint" require_relative "endpoints/data_bag_item_endpoint" require_relative "endpoints/groups_endpoint" require_relative "endpoints/group_endpoint" require_relative "endpoints/environment_endpoint" require_relative "endpoints/environment_cookbooks_endpoint" require_relative "endpoints/environment_cookbook_endpoint" require_relative "endpoints/environment_cookbook_versions_endpoint" require_relative "endpoints/environment_nodes_endpoint" require_relative "endpoints/environment_recipes_endpoint" require_relative "endpoints/environment_role_endpoint" require_relative "endpoints/license_endpoint" require_relative "endpoints/node_endpoint" require_relative "endpoints/nodes_endpoint" require_relative "endpoints/node_identifiers_endpoint" require_relative "endpoints/organizations_endpoint" require_relative "endpoints/organization_endpoint" require_relative "endpoints/organization_association_requests_endpoint" require_relative "endpoints/organization_association_request_endpoint" require_relative "endpoints/organization_authenticate_user_endpoint" require_relative "endpoints/organization_users_endpoint" require_relative "endpoints/organization_user_endpoint" require_relative "endpoints/organization_validator_key_endpoint" require_relative "endpoints/policies_endpoint" require_relative "endpoints/policy_endpoint" require_relative "endpoints/policy_revisions_endpoint" require_relative "endpoints/policy_revision_endpoint" require_relative "endpoints/policy_groups_endpoint" require_relative "endpoints/policy_group_endpoint" require_relative "endpoints/policy_group_policy_endpoint" require_relative "endpoints/principal_endpoint" require_relative "endpoints/role_endpoint" require_relative "endpoints/role_environments_endpoint" require_relative "endpoints/sandboxes_endpoint" require_relative "endpoints/sandbox_endpoint" require_relative "endpoints/searches_endpoint" require_relative "endpoints/search_endpoint" require_relative "endpoints/system_recovery_endpoint" require_relative "endpoints/user_association_requests_endpoint" require_relative "endpoints/user_association_requests_count_endpoint" require_relative "endpoints/user_association_request_endpoint" require_relative "endpoints/user_organizations_endpoint" require_relative "endpoints/file_store_file_endpoint" require_relative "endpoints/not_found_endpoint" require_relative "endpoints/version_endpoint" require_relative "endpoints/server_api_version_endpoint" require_relative "endpoints/universe_endpoint" module ChefZero class Server DEFAULT_OPTIONS = { host: ["127.0.0.1"], port: 8889, log_level: :warn, generate_real_keys: true, single_org: "chef", ssl: false, }.freeze GLOBAL_ENDPOINTS = [ "/license", "/version", "/server_api_version", ].freeze def initialize(options = {}) @options = DEFAULT_OPTIONS.merge(options) if @options[:single_org] && !@options.key?(:osc_compat) @options[:osc_compat] = true end @options.freeze ChefZero::Log.level = @options[:log_level].to_sym @app = nil end # @return [Hash] attr_reader :options # @return [Integer] def port if @port @port # If options[:port] is not an Array or an Enumerable, it is just an Integer. elsif !options[:port].respond_to?(:each) options[:port] else raise "port cannot be determined until server is started" end end # @return [WEBrick::HTTPServer] attr_reader :server include ChefZero::Endpoints # # The URL for this Chef Zero server. If the given host is an IPV6 address, # it is escaped in brackets according to RFC-2732. # # @see http://www.ietf.org/rfc/rfc2732.txt RFC-2732 # # @return [String] # def url sch = @options[:ssl] ? "https" : "http" hosts = Array(@options[:host]) @url ||= if hosts.first.include?(":") URI("#{sch}://[#{hosts.first}]:#{port}").to_s else URI("#{sch}://#{hosts.first}:#{port}").to_s end end def local_mode_url raise "Port not yet set, cannot generate URL" unless port.is_a?(Integer) "chefzero://localhost:#{port}" end # # The data store for this server (default is in-memory). # # @return [ChefZero::DataStore] # def data_store @data_store ||= begin result = @options[:data_store] || DataStore::DefaultFacade.new(DataStore::MemoryStoreV2.new, options[:single_org], options[:osc_compat]) if options[:single_org] if !result.respond_to?(:interface_version) || result.interface_version == 1 result = ChefZero::DataStore::V1ToV2Adapter.new(result, options[:single_org]) result = ChefZero::DataStore::DefaultFacade.new(result, options[:single_org], options[:osc_compat]) end else if !result.respond_to?(:interface_version) || result.interface_version == 1 raise "Multi-org not supported by data store #{result}!" end end result end end # # Boolean method to determine if real Public/Private keys should be # generated. # # @return [Boolean] # true if real keys should be created, false otherwise # def generate_real_keys? !!@options[:generate_real_keys] end # # Start a Chef Zero server in the current thread. You can stop this server # by canceling the current thread. # # @param [Boolean|IO] publish # publish the server information to the publish parameter or to STDOUT if it's "true" # # @return [nil] # this method will block the main thread until interrupted # def start(publish = true) publish = publish[:publish] if publish.is_a?(Hash) # Legacy API if publish output = publish.respond_to?(:puts) ? publish : STDOUT output.puts <<-EOH.gsub(/^ {10}/, "") >> Starting #{ChefZero::Dist::PRODUCT} (v#{ChefZero::VERSION})... EOH end thread = start_background if publish output = publish.respond_to?(:puts) ? publish : STDOUT output.puts <<-EOH.gsub(/^ {10}/, "") >> WEBrick (v#{WEBrick::VERSION}) on Rack (v#{Rack.release}) is listening at #{url} >> Press CTRL+C to stop EOH end %w{INT TERM}.each do |signal| Signal.trap(signal) do puts "\n>> Stopping #{ChefZero::Dist::PRODUCT}..." @server.shutdown end end # Move the background process to the main thread thread.join end # # Start a Chef Zero server in a forked process. This method returns the PID # to the forked process. # # @param [Fixnum] wait # the number of seconds to wait for the server to start # # @return [Thread] # the thread the background process is running in # def listen(hosts, port) hosts.each do |host| @server.listen(host, port) end true rescue Errno::EADDRINUSE ChefZero::Log.warn("Port #{port} not available") @server.listeners.each(&:close) @server.listeners.clear false end def start_background(wait = 5) @server = WEBrick::HTTPServer.new( DoNotListen: true, AccessLog: [], Logger: WEBrick::Log.new(StringIO.new, 7), RequestTimeout: 300, SSLEnable: options[:ssl], SSLOptions: ssl_opts, SSLCertName: [ [ "CN", WEBrick::Utils.getservername ] ], StartCallback: proc do @running = true end ) ENV["HTTPS"] = "on" if options[:ssl] @server.mount("/", Rack::Handler::WEBrick, app) # Pick a port # If options[:port] can be an Enumerator, an Array, or an Integer, # we need something that can respond to .each (Enum and Array can already). Array(options[:port]).each do |port| if listen(Array(options[:host]), port) @port = port break end end unless @port raise Errno::EADDRINUSE, "No port in :port range #{options[:port]} is available" end # Start the server in the background @thread = Thread.new do begin Thread.current.abort_on_exception = true @server.start ensure @port = nil @running = false end end # Do not return until the web server is genuinely started. sleep(0.01) while !@running && @thread.alive? SocketlessServerMap.instance.register_port(@port, self) @thread end def start_socketless @port = SocketlessServerMap.instance.register_no_listen_server(self) end def handle_socketless_request(request_env) app.call(request_env) end # # Boolean method to determine if the server is currently ready to accept # requests. This method will attempt to make an HTTP request against the # server. If this method returns true, you are safe to make a request. # # @return [Boolean] # true if the server is accepting requests, false otherwise # def running? !@server.nil? && @running && @server.status == :Running end # # Gracefully stop the Chef Zero server. # # @param [Fixnum] wait # the number of seconds to wait before raising force-terminating the # server # def stop(wait = 5) if @running @server.shutdown if @server @thread.join(wait) if @thread end rescue Timeout::Error if @thread ChefZero::Log.error("#{ChefZero::Dist::PRODUCT} did not stop within #{wait} seconds! Killing...") @thread.kill SocketlessServerMap.deregister(port) end ensure @server = nil @thread = nil end def gen_key_pair if generate_real_keys? private_key = OpenSSL::PKey::RSA.new(2048) public_key = private_key.public_key.to_s public_key.sub!(/^-----BEGIN RSA PUBLIC KEY-----/, "-----BEGIN PUBLIC KEY-----") public_key.sub!(/-----END RSA PUBLIC KEY-----(\s+)$/, '-----END PUBLIC KEY-----\1') [private_key.to_s, public_key] else [PRIVATE_KEY, PUBLIC_KEY] end end def on_request(&block) @on_request_proc = block end def on_response(&block) @on_response_proc = block end # Load data in a nice, friendly form: # { # 'roles' => { # 'desert' => '{ "description": "Hot and dry"' }, # 'rainforest' => { "description" => 'Wet and humid' } # }, # 'cookbooks' => { # 'apache2-1.0.1' => { # 'templates' => { 'default' => { 'blah.txt' => 'hi' }} # 'recipes' => { 'default.rb' => 'template "blah.txt"' } # 'metadata.rb' => 'depends "mysql"' # }, # 'apache2-1.2.0' => { # 'templates' => { 'default' => { 'blah.txt' => 'lo' }} # 'recipes' => { 'default.rb' => 'template "blah.txt"' } # 'metadata.rb' => 'depends "mysql"' # }, # 'mysql' => { # 'recipes' => { 'default.rb' => 'file { contents "hi" }' }, # 'metadata.rb' => 'version "1.0.0"' # } # } # } def load_data(contents, org_name = nil) org_name ||= options[:single_org] if org_name.nil? && contents.keys != [ "users" ] raise "Must pass an org name to load_data or run in single_org mode" end %w{clients containers environments groups nodes roles sandboxes}.each do |data_type| if contents[data_type] dejsonize_children(contents[data_type]).each_pair do |name, data| data_store.set(["organizations", org_name, data_type, name], data, :create) end end end if contents["users"] dejsonize_children(contents["users"]).each_pair do |name, data| if options[:osc_compat] data_store.set(["organizations", org_name, "users", name], data, :create) else # Create the user and put them in the org data_store.set(["users", name], data, :create) if org_name data_store.set(["organizations", org_name, "users", name], "{}", :create) end end end end if contents["members"] contents["members"].each do |name| data_store.set(["organizations", org_name, "users", name], "{}", :create) end end if contents["invites"] contents["invites"].each do |name| data_store.set(["organizations", org_name, "association_requests", name], "{}", :create) end end if contents["acls"] dejsonize_children(contents["acls"]).each do |path, acl| path = [ "organizations", org_name ] + path.split("/") path = ChefData::AclPath.get_acl_data_path(path) ChefZero::RSpec.server.data_store.set(path, acl) end end if contents["data"] contents["data"].each_pair do |key, data_bag| data_store.create_dir(["organizations", org_name, "data"], key, :recursive) dejsonize_children(data_bag).each do |item_name, item| data_store.set(["organizations", org_name, "data", key, item_name], item, :create) end end end if contents["policies"] contents["policies"].each_pair do |policy_name, policy_struct| # data_store.create_dir(['organizations', org_name, 'policies', policy_name], "revisions", :recursive) dejsonize_children(policy_struct).each do |revision, policy_data| data_store.set(["organizations", org_name, "policies", policy_name, "revisions", revision], policy_data, :create, :create_dir) end end end if contents["policy_groups"] contents["policy_groups"].each_pair do |group_name, group| group["policies"].each do |policy_name, policy_revision| data_store.set(["organizations", org_name, "policy_groups", group_name, "policies", policy_name], FFI_Yajl::Encoder.encode(policy_revision["revision_id"], pretty: true), :create, :create_dir) end end end %w{cookbooks cookbook_artifacts}.each do |cookbook_type| if contents[cookbook_type] contents[cookbook_type].each_pair do |name_version, cookbook| if cookbook_type == "cookbook_artifacts" name, _, identifier = name_version.rpartition("-") cookbook_data = ChefData::CookbookData.to_hash(cookbook, name, identifier) elsif name_version =~ /(.+)-(\d+\.\d+\.\d+)$/ cookbook_data = ChefData::CookbookData.to_hash(cookbook, $1, $2) else cookbook_data = ChefData::CookbookData.to_hash(cookbook, name_version) end raise "No version specified" unless cookbook_data[:version] data_store.create_dir(["organizations", org_name, cookbook_type], cookbook_data[:cookbook_name], :recursive) data_store.set(["organizations", org_name, cookbook_type, cookbook_data[:cookbook_name], cookbook_data[:version]], FFI_Yajl::Encoder.encode(cookbook_data, pretty: true), :create) cookbook_data.values.each do |files| next unless files.is_a? Array files.each do |file| data_store.set(["organizations", org_name, "file_store", "checksums", file[:checksum]], get_file(cookbook, file[:path]), :create) end end end end end end def clear_data data_store.clear end def request_handler(&block) @request_handler = block end def to_s "#<#{self.class} #{url}>" end def inspect "#<#{self.class} @url=#{url.inspect}>" end private def endpoints result = if options[:osc_compat] # OSC-only [ [ "/organizations/*/users", ActorsEndpoint.new(self) ], [ "/organizations/*/users/*", ActorEndpoint.new(self) ], [ "/organizations/*/authenticate_user", OrganizationAuthenticateUserEndpoint.new(self) ], ] else # EC-only [ [ "/organizations/*/users", OrganizationUsersEndpoint.new(self) ], [ "/organizations/*/users/*", OrganizationUserEndpoint.new(self) ], [ "/users", ActorsEndpoint.new(self, "username") ], [ "/users/*", ActorEndpoint.new(self, "username") ], [ "/users/*/_acl", AclsEndpoint.new(self) ], [ "/users/*/_acl/*", AclEndpoint.new(self) ], [ "/users/*/association_requests", UserAssociationRequestsEndpoint.new(self) ], [ "/users/*/association_requests/count", UserAssociationRequestsCountEndpoint.new(self) ], [ "/users/*/association_requests/*", UserAssociationRequestEndpoint.new(self) ], [ "/users/*/keys", ActorKeysEndpoint.new(self) ], [ "/users/*/keys/default", ActorDefaultKeyEndpoint.new(self) ], [ "/users/*/keys/*", ActorKeyEndpoint.new(self) ], [ "/users/*/organizations", UserOrganizationsEndpoint.new(self) ], [ "/authenticate_user", AuthenticateUserEndpoint.new(self) ], [ "/system_recovery", SystemRecoveryEndpoint.new(self) ], [ "/license", LicenseEndpoint.new(self) ], [ "/organizations", OrganizationsEndpoint.new(self) ], [ "/organizations/*", OrganizationEndpoint.new(self) ], [ "/organizations/*/_validator_key", OrganizationValidatorKeyEndpoint.new(self) ], [ "/organizations/*/association_requests", OrganizationAssociationRequestsEndpoint.new(self) ], [ "/organizations/*/association_requests/*", OrganizationAssociationRequestEndpoint.new(self) ], [ "/organizations/*/containers", ContainersEndpoint.new(self) ], [ "/organizations/*/containers/*", ContainerEndpoint.new(self) ], [ "/organizations/*/groups", GroupsEndpoint.new(self) ], [ "/organizations/*/groups/*", GroupEndpoint.new(self) ], [ "/organizations/*/organization/_acl", AclsEndpoint.new(self) ], [ "/organizations/*/organizations/_acl", AclsEndpoint.new(self) ], [ "/organizations/*/*/*/_acl", AclsEndpoint.new(self) ], [ "/organizations/*/organization/_acl/*", AclEndpoint.new(self) ], [ "/organizations/*/organizations/_acl/*", AclEndpoint.new(self) ], [ "/organizations/*/*/*/_acl/*", AclEndpoint.new(self) ], ] end result + [ # Both [ "/dummy", DummyEndpoint.new(self) ], [ "/organizations/*/clients", ActorsEndpoint.new(self) ], [ "/organizations/*/clients/*", ActorEndpoint.new(self) ], [ "/organizations/*/clients/*/keys", ActorKeysEndpoint.new(self) ], [ "/organizations/*/clients/*/keys/default", ActorDefaultKeyEndpoint.new(self) ], [ "/organizations/*/clients/*/keys/*", ActorKeyEndpoint.new(self) ], [ "/organizations/*/users/*/keys", OrganizationUserKeysEndpoint.new(self) ], [ "/organizations/*/users/*/keys/default", OrganizationUserDefaultKeyEndpoint.new(self) ], [ "/organizations/*/users/*/keys/*", OrganizationUserKeyEndpoint.new(self) ], [ "/organizations/*/controls", ControlsEndpoint.new(self) ], [ "/organizations/*/cookbooks", CookbooksEndpoint.new(self) ], [ "/organizations/*/cookbooks/*", CookbookEndpoint.new(self) ], [ "/organizations/*/cookbooks/*/*", CookbookVersionEndpoint.new(self) ], [ "/organizations/*/cookbook_artifacts", CookbookArtifactsEndpoint.new(self) ], [ "/organizations/*/cookbook_artifacts/*", CookbookArtifactEndpoint.new(self) ], [ "/organizations/*/cookbook_artifacts/*/*", CookbookArtifactIdentifierEndpoint.new(self) ], [ "/organizations/*/data", DataBagsEndpoint.new(self) ], [ "/organizations/*/data/*", DataBagEndpoint.new(self) ], [ "/organizations/*/data/*/*", DataBagItemEndpoint.new(self) ], [ "/organizations/*/environments", RestListEndpoint.new(self) ], [ "/organizations/*/environments/*", EnvironmentEndpoint.new(self) ], [ "/organizations/*/environments/*/cookbooks", EnvironmentCookbooksEndpoint.new(self) ], [ "/organizations/*/environments/*/cookbooks/*", EnvironmentCookbookEndpoint.new(self) ], [ "/organizations/*/environments/*/cookbook_versions", EnvironmentCookbookVersionsEndpoint.new(self) ], [ "/organizations/*/environments/*/nodes", EnvironmentNodesEndpoint.new(self) ], [ "/organizations/*/environments/*/recipes", EnvironmentRecipesEndpoint.new(self) ], [ "/organizations/*/environments/*/roles/*", EnvironmentRoleEndpoint.new(self) ], [ "/organizations/*/nodes", NodesEndpoint.new(self) ], [ "/organizations/*/nodes/*", NodeEndpoint.new(self) ], [ "/organizations/*/nodes/*/_identifiers", NodeIdentifiersEndpoint.new(self) ], [ "/organizations/*/policies", PoliciesEndpoint.new(self) ], [ "/organizations/*/policies/*", PolicyEndpoint.new(self) ], [ "/organizations/*/policies/*/revisions", PolicyRevisionsEndpoint.new(self) ], [ "/organizations/*/policies/*/revisions/*", PolicyRevisionEndpoint.new(self) ], [ "/organizations/*/policy_groups", PolicyGroupsEndpoint.new(self) ], [ "/organizations/*/policy_groups/*", PolicyGroupEndpoint.new(self) ], [ "/organizations/*/policy_groups/*/policies/*", PolicyGroupPolicyEndpoint.new(self) ], [ "/organizations/*/principals/*", PrincipalEndpoint.new(self) ], [ "/organizations/*/roles", RestListEndpoint.new(self) ], [ "/organizations/*/roles/*", RoleEndpoint.new(self) ], [ "/organizations/*/roles/*/environments", RoleEnvironmentsEndpoint.new(self) ], [ "/organizations/*/roles/*/environments/*", EnvironmentRoleEndpoint.new(self) ], [ "/organizations/*/sandboxes", SandboxesEndpoint.new(self) ], [ "/organizations/*/sandboxes/*", SandboxEndpoint.new(self) ], [ "/organizations/*/search", SearchesEndpoint.new(self) ], [ "/organizations/*/search/*", SearchEndpoint.new(self) ], [ "/organizations/*/universe", UniverseEndpoint.new(self) ], [ "/version", VersionEndpoint.new(self) ], [ "/server_api_version", ServerAPIVersionEndpoint.new(self) ], # Internal [ "/organizations/*/file_store/**", FileStoreFileEndpoint.new(self) ], ] end def global_endpoint?(ep) GLOBAL_ENDPOINTS.any? do |g_ep| ep.start_with?(g_ep) end end def app return @app if @app router = RestRouter.new(endpoints) router.not_found = NotFoundEndpoint.new if options[:single_org] rest_base_prefix = [ "organizations", options[:single_org] ] else rest_base_prefix = [] end @app = proc do |env| begin prefix = global_endpoint?(env["PATH_INFO"]) ? [] : rest_base_prefix request = RestRequest.new(env, prefix) if @on_request_proc @on_request_proc.call(request) end response = nil if @request_handler response = @request_handler.call(request) end unless response response = router.call(request) end if @on_response_proc @on_response_proc.call(request, response) end # Insert Server header response[1]["Server"] = "chef-zero" # Add CORS header response[1]["Access-Control-Allow-Origin"] = "*" # Puma expects the response to be an array (chunked responses). Since # we are statically generating data, we won't ever have said chunked # response, so fake it. response[-1] = Array(response[-1]) response rescue if options[:log_level] == :debug STDERR.puts "Request Error: #{$!}" STDERR.puts $!.backtrace.join("\n") end end end @app end def dejsonize_children(hash) result = {} hash.each_pair do |key, value| result[key] = dejsonize(value) end result end def dejsonize(value) value.is_a?(Hash) ? FFI_Yajl::Encoder.encode(value, pretty: true) : value end def get_file(directory, path) value = directory path.split("/").each do |part| value = value[part] end value end ## Disable unsecure ssl ## Ref: https://www.ruby-lang.org/en/news/2014/10/27/changing-default-settings-of-ext-openssl/ def ssl_opts ssl_opts = OpenSSL::SSL::OP_ALL ssl_opts &= ~OpenSSL::SSL::OP_DONT_INSERT_EMPTY_FRAGMENTS if defined?(OpenSSL::SSL::OP_DONT_INSERT_EMPTY_FRAGMENTS) ssl_opts |= OpenSSL::SSL::OP_NO_COMPRESSION if defined?(OpenSSL::SSL::OP_NO_COMPRESSION) ssl_opts |= OpenSSL::SSL::OP_NO_SSLv2 if defined?(OpenSSL::SSL::OP_NO_SSLv2) ssl_opts |= OpenSSL::SSL::OP_NO_SSLv3 if defined?(OpenSSL::SSL::OP_NO_SSLv3) ssl_opts end end end