# Copyright 2019 Pixar # # Licensed under the Apache License, Version 2.0 (the "Apache License") # with the following modification; you may not use this file except in # compliance with the Apache License and the following modification to it: # Section 6. Trademarks. is deleted and replaced with: # # 6. Trademarks. This License does not grant permission to use the trade # names, trademarks, service marks, or product names of the Licensor # and its affiliates, except as required to comply with Section 4(c) of # the License and to reproduce the content of the NOTICE file. # # You may obtain a copy of the Apache License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the Apache License with the above modification is # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY # KIND, either express or implied. See the Apache License for the specific # language governing permissions and limitations under the Apache License. require 'faraday' # >= 0.17.0 require 'faraday_middleware' # >= 0.13.0 require 'jamf/api/connection/token' require 'jamf/api/connection/api_error' # The module module Jamf # Changes from classic Jamf::APIconnection # - uses Faraday as the REST engine # - accepts a url with connect/initialize # - only supports https, no http # - no xml # - tokens & keep_alive # - no object class method wrappers in connection objects, # only passing connection objects into the class methods # class Connection # Class Constants ##################################### # The start of the path for API resources RSRC_BASE = 'uapi'.freeze # The API version must be this or higher MIN_API_VERSION = Gem::Version.new('1.0') HTTPS_SCHEME = 'https'.freeze # The https default SSL port, default for Jamf Cloud servers HTTPS_SSL_PORT = 443 # The Jamf default SSL port, default for on-prem servers ON_PREM_SSL_PORT = 8443 # if either of these is specified, we'll default to SSL SSL_PORTS = [ON_PREM_SSL_PORT, HTTPS_SSL_PORT].freeze # Recognize Jamf Cloud servers JAMFCLOUD_DOMAIN = '.jamfcloud.com'.freeze # JamfCloud connections default to 443, not 8443 JAMFCLOUD_PORT = HTTPS_SSL_PORT # Default open-connection timeout in seconds DFT_OPEN_TIMEOUT = 60 # Default response timeout in seconds DFT_TIMEOUT = 60 # The Default SSL Version DFT_SSL_VERSION = 'TLSv1_2'.freeze # refresh token if less than this many seconds until # expiration. Default is 30 minutes if not specified DFT_TOKEN_REFRESH = 60 * 30 # pre-existing tokens must have this many seconds before # before they expire TOKEN_REUSE_MIN_LIFE = 60 HTTP_ACCEPT_HEADER = 'Accept' HTTP_CONTENT_TYPE_HEADER = 'Content-Type' MIME_JSON = 'application/json' SLASH = '/'.freeze VALID_URL_REGEX = /\A#{URI.regexp(%w[https])}\z/.freeze NOT_CONNECTED = 'Not Connected'.freeze # Only these variables are displayed with PrettyPrint # This avoids, especially, the caches, which are available # as attr_readers PP_VARS = %i[ @name @connected @host @port @user @base_url @ssl_options @open_timeout @timeout @login_time @keep_alive @token_refresh ].freeze # Attributes ##################################### # @return [String, nil] attr_reader :name # @return [String, nil] attr_reader :host # @return [Integer, nil] attr_reader :port # @return [String, nil] attr_reader :user # @return [Integer, nil] attr_reader :timeout # @return [Jamf::Connection::Token, nil] attr_reader :token # @return [Integer] Refresh the token this many seconds before it expires attr_reader :token_refresh # @return [String, nil] attr_reader :base_url # @return [Boolean] attr_reader :connected alias connected? connected # @return [RestClient::Resource] the underlying rest resource attr_reader :rest_cnx # when was this connection logged in? attr_reader :login_time # @return [Hash] # This Hash holds the most recently fetched instance of a SingletonResource # subclass, keyed by the subclass itself. # # SingletonResource.fetch will return the instance from here, if it exists, # unless the first parameter is truthy. attr_reader :singleton_cache # @return [Hash] # This Hash holds the most recent API data (an Array of Hashes) for the list # of all items in a CollectionResource subclass, keyed by the subclass itself. # # CollectionResource.all return the appropriate data from here, if it exists, # # See the CollectionResource.all class method. attr_reader :collection_cache # @return [Hash] # This hash holds ExtensionAttribute instances, which are used # for validating values passed to Extendable.set_ext_attr. attr_reader :ext_attr_cache # @return [Faraday::Response] The response object from the last API access. attr_reader :last_http_response # Constructor ##################################### # @see #connect def initialize(url = nil, **params) @name = params.delete :name @name ||= NOT_CONNECTED connect(url, params) unless params[:at_load] end # Public Instance Methods ##################################### # Connect this Connection object to an Jamf Pro API # # The first parameter may be a URL (must be https) from which # the host & port will be used, and if present, the user and password # E.g. # connect 'https://myuser:pass@host.domain.edu:8443' # # which is the same as: # connect host: 'host.domain.edu', port: 8443, user: 'myuser', pw: 'pass' # # When using a URL, other parameters below may be specified, however # host: and port: parameters will be ignored, since they came from the URL, # as will user: and :pw, if they are present in the URL. If the URL doesn't # contain user and pw, they can be provided via the parameters, or left # to default values. # # ### Passwords # The pw: parameter also accepts the symbols :prompt, and :stdin[X] # # If :prompt, the user is promted on the commandline to enter the password # for the :user. # # If :stdin the password is read from the first line of stdin # # If :stdinX (where X is an integer) the password is read from the Xth # line of stdin.see {Jamf.stdin} # # If omitted, and running from an interactive terminal, the user is # prompted as with :prompt # # ### Tokens # Instead of a user and password, you may specify a valid 'token:', either: # # A Jamf::Connection::Token object, which can be extracted from an active # Jamf::Connection via its #token method # # or # # A token string e.g. "eyJhdXR...6EKoo" from any source can also be used. # # # Any values available via Jamf.config will be used if they are not provided # in the parameters. # # @param host: [String] The API server hostname. The param 'server:' is a # synonym # # @param port: [Integer] The API server port. If omitted, the value from # Jamf.config will be used. If no config value, defaults to 443 if the # host ends with 'jamfcloud.com' or 8443 otherwise # # @param user: [String] The username for the API connection # # @param pw: [String, Symbol] The password for the user, :prompt, or :stdin[X] # # @param token: [Jamf::Connection::Token, String] An existing, valid token. # When used, there's no need to provide user: or pw:. # # @param open_timeout: [Integer] The number of seconds for initial contact # with the host. # # @param timeout: [Integer] The number of seconds for a full response from # the host. # # @param ssl_version: [Symbol] The SSL version, e.g. :TLSv1_2 # # @param verify_cert: [Boolean] Should the SSL certificate be verified? # Default is true, should only be set to false if using a on-prem # server with a self-signed certificate, which is rare # def connect(url = nil, **params) # This sets all the instance vars to nil, and flushes/creates the caches disconnect # This sets @token, and adds host, port, user to params from a Token object parse_token params # Get host, port, user and pw from a URL, add to params if needed parse_url url, params # apply defaults from config, client, and then this class. apply_connection_defaults params # make sure we have the minimum needed params for a connection verify_basic_params params # turn the params into instance vars parse_connect_params params # if no @token already, get one from from # either a token string or a pw @token ||= if params[:token].is_a? String tk = token_from :token_string, params[:token] # get the user from the token @user = tk.user tk else token_from :pw, acquire_password(params[:pw]) end # Now get some values from our token @base_url = @token.base_url @login_time = @token.login_time # and make our actual connection @rest_cnx = create_connection # make sure versions are good validate_api_version @connected = true # start keepalive if needed @keep_alive = params[:keep_alive].nil? ? false : params[:keep_alive] start_keep_alive if @keep_alive # return our string output to_s end # connect def disconnect # reset everything except the name & timeouts @connected = false @login_time = nil @host = nil @port = nil @user = nil @token = nil @base_url = nil @rest_cnx = nil @ssl_options = {} @keep_alive = nil flushcache end # Same as disconnect, but invalidates the token def logout @token.destroy disconnect end def get(rsrc) validate_connected resp = @rest_cnx.get rsrc @last_http_response = resp return resp.body if resp.success? raise Jamf::Connection::APIError.new(resp) end # GET a rsrc without doing any JSON parsing, using # a temporary Faraday connection object def download(rsrc) temp_cnx = create_connection(false) resp = temp_cnx.get rsrc @last_http_response = resp return resp.body if resp.success? raise Jamf::Connection::APIError.new(resp) end def post(rsrc, data) validate_connected resp = @rest_cnx.post(rsrc) do |req| req.body = data end @last_http_response = resp return resp.body if resp.success? raise Jamf::Connection::APIError.new(resp) end def put(rsrc, data) validate_connected resp = @rest_cnx.put(rsrc) do |req| req.body = data end @last_http_response = resp return resp.body if resp.success? raise Jamf::Connection::APIError.new(resp) end def patch(rsrc, data) validate_connected resp = @rest_cnx.patch(rsrc) do |req| req.body = data end @last_http_response = resp return resp.body if resp.success? raise Jamf::Connection::APIError.new(resp) end def delete(rsrc) validate_connected resp = @rest_cnx.delete rsrc @last_http_response = resp return resp.body if resp.success? raise Jamf::Connection::APIError.new(resp) end # A useful string about this connection # # @return [String] # def to_s "Jamf::Connection: https://#{@user}@#{@host}:#{@port}" end def keep_alive? !@keep_alive_thread.nil? end def keep_alive=(bool) bool ? start_keep_alive : stop_keep_alive end # This should take effect even if we're already running the keep_alive thread # def token_refresh=(secs) raise ArgumentError, 'Value must be an Integer number of seconds' unless secs.is_a? Integer @token_refresh = secs end def api_version @token.api_version end # Flush the collection and/or ea cache for the given class, # or all cached data # @param klass[Class] the class of cache to flush # # @return [void] # def flushcache(klass = nil) if klass @collection_cache.delete klass @singleton_cache.delete klass @ext_attr_cache.delete klass else @collection_cache = {} @singleton_cache = {} @ext_attr_cache = {} end end # Remove large cached items from # the instance_variables used to create # pretty-print (pp) output. # # @return [Array] the desired instance_variables # def pretty_print_instance_variables PP_VARS end # Private Insance Methods #################################### private # raise exception if not connected def validate_connected raise Jamf::InvalidConnectionError, 'Not Connected. Use .connect first.' unless connected? end # raise exception if API version is too low. def validate_api_version vers = api_version return if Gem::Version.new(vers) >= MIN_API_VERSION raise Jamf::InvalidConnectionError, "API version '#{vers}' too low, must be >= '#{MIN_API_VERSION}'" end ##### Parse Params ################################### # Get host, port, & user from a Token object # or just the user from a token string. def parse_token(params) return unless params[:token].is_a? self.class::Token verify_token params[:token] @token = params[:token] params[:host] = @token.host params[:port] = @token.port params[:user] = @token.user end # Raise execeptions if we were given an unusable token object # # @param params[Hash] The params for #connect # # @return [void] # def verify_token(token) raise 'Cannot use token: it has expired' if token.expired? raise 'Cannot use token: it is invalid' unless token.valid? raise "Cannot use token: it expires in less than #{TOKEN_REUSE_MIN_LIFE} seconds" if token.secs_remaining < TOKEN_REUSE_MIN_LIFE end # Get host, port, user and pw from a URL, unless they are already in the params # # @return [String, nil] the pw if present # def parse_url(url, params) return unless url url = URI.parse url.to_s raise ArgumentError, 'Invalid url, scheme must be https' unless url.scheme == HTTPS_SCHEME params[:host] ||= url.host params[:port] ||= url.port params[:user] ||= url.user if url.user params[:pw] ||= url.password if url.password end # Apply defaults from the Jamf.config, # then from the Jamf::Client, # then from the module defaults # to the params for the #connect method # # @param params[Hash] The params for #connect # # @return [Hash] The params with defaults applied # def apply_connection_defaults(params) apply_defaults_from_config(params) # TODO: when clients are moved over # apply_defaults_from_client(params) apply_module_defaults(params) end # Apply defaults from the Jamf.config # to the params for the #connect method # # @param params[Hash] The params for #connect # # @return [Hash] The params with defaults applied # def apply_defaults_from_config(params) # settings from config if they aren't in the params params[:host] ||= Jamf.config.api_server_name params[:port] ||= Jamf.config.api_server_port params[:user] ||= Jamf.config.api_username params[:timeout] ||= Jamf.config.api_timeout params[:open_timeout] ||= Jamf.config.api_timeout_open params[:ssl_version] ||= Jamf.config.api_ssl_version # if verify cert was not in the params, get it from the prefs. # We can't use ||= because the desired value might be 'false' params[:verify_cert] = Jamf.config.api_verify_cert if params[:verify_cert].nil? end # apply_defaults_from_config # Apply defaults from the Jamf::Client # to the params for the #connect method # # @param params[Hash] The params for #connect # # @return [Hash] The params with defaults applied # def apply_defaults_from_client(params) return unless Jamf::Client.installed? # these settings can come from the jamf binary config, # if this machine is a Jamf client. params[:host] ||= Jamf::Client.jss_server params[:port] ||= Jamf::Client.jss_port.to_i end # Apply the module defaults to the params for the #connect method # # @param params[Hash] The params for #connect # # @return [Hash] The params with defaults applied # def apply_module_defaults(params) params[:port] ||= params[:host].to_s.end_with?(JAMFCLOUD_DOMAIN) ? JAMFCLOUD_PORT : ON_PREM_SSL_PORT params[:timeout] ||= DFT_TIMEOUT params[:open_timeout] ||= DFT_OPEN_TIMEOUT params[:ssl_version] ||= DFT_SSL_VERSION # if we have a TTY, pw defaults to :prompt params[:pw] ||= :prompt if STDIN.tty? end # Raise execeptions if we don't have essential data for a new connection # namely a host, user, and pw # @param params[Hash] The params for #connect # # @return [void] # def verify_basic_params(params) # if given a Token object, it has host, port, user, and base_url # and is already parsed return if @token # must have a host, but accept legacy :server as well as :host params[:host] ||= params[:server] raise Jamf::MissingDataError, 'No Jamf :host specified, or in configuration.' unless params[:host] # no need for user or pass if using a token string return if params[:token].is_a? String raise Jamf::MissingDataError, 'No Jamf :user specified, or in configuration.' unless params[:user] raise Jamf::MissingDataError, "No :pw specified for user '#{params[:user]}'" unless params[:pw] end def parse_connect_params(params) @host = params[:host] @port = params[:port] @port ||= @host.end_with?(JAMFCLOUD_DOMAIN) ? JAMFCLOUD_PORT : ON_PREM_SSL_PORT @user = params[:user] @token_refresh = params[:token_refresh] || DFT_TOKEN_REFRESH @timeout = params[:timeout] || DFT_TIMEOUT @open_timeout = params[:open_timeout] || DFT_TIMEOUT @base_url = URI.parse "https://#{@host}:#{@port}/#{RSRC_BASE}" # ssl opts for faraday # TODO: implement all of faraday's options @ssl_options = { verify: params[:verify_cert], version: params[:ssl_version] } @name = "#{@user}@#{@host}:#{@port}" if @name == NOT_CONNECTED end # given a token string or a password, get a valid token # Token.new will raise an exception if the token string or # credentials are invalid def token_from(type, data) token_params = { user: @user, base_url: @base_url, timeout: @timeout, ssl_options: @ssl_options } case type when :token_string token_params[:token_string] = data when :pw token_params[:pw] = data end self.class::Token.new token_params end # From whatever was given in params[:pw], figure out the password to use # # @param params[Hash] The params for #connect # # @return [String] The password for the connection # def acquire_password(param_pw) if param_pw == :prompt Jamf.prompt_for_password "Enter the password for Jamf user #{@user}@#{@host}:" elsif param_pw.is_a?(Symbol) && param_pw.to_s.start_with?('stdin') param_pw.to_s =~ /^stdin(\d+)$/ line = Regexp.last_match(1) line ||= 1 Jamf.stdin line else param_pw end # if end # acquire pw # create the faraday connection object def create_connection(parse_json = true) Faraday.new(@base_url, ssl: @ssl_options) do |cnx| cnx.headers[HTTP_ACCEPT_HEADER] = MIME_JSON cnx.headers[:authorization] = @token.auth_token cnx.request :json if parse_json cnx.response :json, parser_options: { symbolize_names: true } if parse_json cnx.options[:timeout] = @timeout cnx.options[:open_timeout] = @open_timeout cnx.use Faraday::Adapter::NetHttp end end # creates a thread that loops forever, sleeping most of the time, but # waking up every 60 seconds to see if the token is expiring in the # next @token_refresh seconds. # # If so, the token is refreshed, and we keep looping and sleeping. # # Sets @keep_alive_thread to the Thread object # # @return [void] # def start_keep_alive return if @keep_alive_thread raise 'Token expired' if @token.expired? @keep_alive_thread = Thread.new do loop do sleep 60 next if @token.secs_remaining > @token_refresh @token.keep_alive end # loop end # thread end # Kills the @keep_alive_thread, if it exists, and sets # @keep_alive_thread to nil # # @return [void] # def stop_keep_alive return unless @keep_alive_thread @keep_alive_thread.kill @keep_alive_thread = nil end end # class Connection # Jamf module methods dealing with the active connection # @return [Jamf::Connection] the active connection # def self.cnx @active_connection ||= Connection.new end # Create a new Connection object and use it as the active_connection, # replacing the current active_connection. If connection options are provided, # they are passed to the connect method immediately, otherwise # Jamf.cnx.connect must be called before attemting to use the # connection. # # @param (See Jamf::Connection#connect) # # @return [APIConnection] the new, active connection # def self.connect(url = nil, **params) @active_connection = Connection.new url, params @active_connection.to_s end # Switch the connection used for all API interactions to the # one provided. See {Jamf::APIConnection} for details and examples # of using multiple connections # # @param connection [APIConnection] The APIConnection to use for future # API calls. If omitted, use the default connection created when ruby-jss # was loaded (which may or may not yet be connected) # # @return [APIConnection] The connection now being used. # def self.cnx=(connection) raise 'API connections must be instances of Jamf::Connection' unless connection.is_a? Jamf::Connection @active_connection = connection end # create the default connection connect(at_load: true) unless @active_connection end # module Jamf