# Copyright 2020 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 # 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 = 'api'.freeze # The JamfPro version must be this or higher MIN_JAMF_VERSION = Gem::Version.new('10.25.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 5 minutes if not specified DFT_TOKEN_REFRESH = 300 # pre-existing tokens must have this many seconds before # before they expire TOKEN_REUSE_MIN_LIFE = 60 HTTP_ACCEPT_HEADER = 'Accept'.freeze HTTP_CONTENT_TYPE_HEADER = 'Content-Type'.freeze MIME_JSON = 'application/json'.freeze 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 @pw_fallback ].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] if token refresh/keepaliave fails, try to get a new one # with the passwd used with .connect. Defaults to true attr_reader :pw_fallback # @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 @singleton_cache = {} @collection_cache = {} @ext_attr_cache = {} connect(url, params) unless params[:do_not_connect] 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:. # NOTE if using pw_fallback:true (the default) while providing a token # you will also need to provide the password for the token user in the pw: # parameter, or be prompted for it. If you don't have the pw for the # token user, be sure to set pw_fallback to false. # # @param token_refresh: [Integer] Refresh the token this many seconds before # it expires. Must be >= DFT_TOKEN_REFRESH # # @pararm pw_fallback: [Boolean] Default is true. Use the password provided # to refresh the token if regular refresh doesn't work (e.g. token is expired). # NOTE: This causes the password to be kept in memory. If you don't want # this, explicitly set pw_fallback to false, however long-running processes # may lose connection if token refresh fails for any reason. # # @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 # If there's a Token object in :token, this sets @token, # and adds host, port, user from that token 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 # Once we're here, all params have been parsed & defaulted into the # params hash, so # 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 unless @token if params[:token].is_a? String @token = token_from :token_string, params[:token] # get the user from the token @user = @toke.user # if @pw_fallback, the pw must be acquired, since it isn't in # the token @pw = acquire_password(params[:pw]) if @pw_fallback else pw = acquire_password(params[:pw]) @token = token_from :pw, pw @pw = pw if @pw_fallback end 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_jamf_version @connected = true # start keepalive if needed start_keep_alive if @keep_alive # return our string output to_s end # connect # reset all values to nil or empty def disconnect @connected = false @name = NOT_CONNECTED @login_time = nil stop_keep_alive @host = nil @port = nil @user = nil @timeout = nil @open_timeout = nil @base_url = nil @token = nil @rest_cnx = nil @ssl_options = {} @keep_alive = nil @token_refresh = nil @pw_fallback = nil @pw = nil flushcache end # Same as disconnect, but invalidates the token on the server first def logout @token.destroy disconnect end # Get a resource def get(rsrc) validate_connected resp = @rest_cnx.get rsrc @last_http_response = resp return resp.body if resp.success? raise Jamf::Connection::APIError, 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, 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, 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, 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, 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, resp end # A useful string about this connection # # @return [String] # def to_s str = if connected? "#{@base_url}, Token expires: #{@token ? @token.expires : ''}" else NOT_CONNECTED end "Jamf::Connection '#{@name == NOT_CONNECTED ? object_id : @name}': #{str}" end # Reset the name def name=(new_name) @name = new_name.to_s end # Are we keeping the connection alive? def keep_alive? return false unless @keep_alive_thread @keep_alive_thread.alive? end # @return [Jamf::Timestamp, nil] def next_refresh return unless keep_alive? @token.expires - @token_refresh end # @return [Float, nil] def secs_to_refresh return unless keep_alive? next_refresh - Time.now end # @return [String, nil] e.g. "1 week 6 days 23 hours 49 minutes 56 seconds" def time_to_refresh return unless keep_alive? return 0 if secs_to_refresh.negative? Jamf.humanize_secs secs_to_refresh end # Turn keepalive on or offs 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 jamf_version @token.jamf_version end def jamf_build @token.jamf_build 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_jamf_version vers = jamf_version return if Gem::Version.new(vers) >= MIN_JAMF_VERSION raise Jamf::InvalidConnectionError, "API version '#{vers}' too low, must be >= '#{MIN_JAMF_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, overriding any 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 to the unset params for the #connect method # First apply them from from the Jamf.config, # then from the Jamf::Client (read from the jamf binary config), # then from the Jamf module defaults # # @param params[Hash] The params for #connect # # @return [Hash] The params with defaults applied # def apply_connection_defaults(params) # must have a host, but accept legacy :server as well as :host params[:host] ||= params[:server] # if we have no port set by this point, set to cloud port # if host is a cloud host. But leave port nil for other hosts # (will be set via client defaults or module defaults) params[:port] ||= JAMFCLOUD_PORT if params[:host].to_s.end_with?(JAMFCLOUD_DOMAIN) apply_defaults_from_config(params) # TODO: when clients are moved over to Jamf module # 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) # if we have no port set by this point, assume on-prem. params[:port] ||= ON_PREM_SSL_PORT params[:timeout] ||= DFT_TIMEOUT params[:open_timeout] ||= DFT_OPEN_TIMEOUT params[:ssl_version] ||= DFT_SSL_VERSION params[:token_refresh] ||= DFT_TOKEN_REFRESH # 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 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 # must have user and pw 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 # Turn the connection parameters into instance vars def parse_connect_params(params) @host = params[:host] @port = params[:port] @user = params[:user] @keep_alive = params[:keep_alive].nil? ? false : params[:keep_alive] @pw_fallback = params[:pw_fallback].nil? ? true : params[:pw_fallback] @token_refresh = params[:token_refresh].to_i @timeout = params[:timeout] @open_timeout = params[:open_timeout] # TEMPORARY ? the tryitout host still uses `uapi` rather than the # new `api` for regular servers rsrc_base = @host == Token::JAMF_TRYITOUT_HOST ? 'uapi' : RSRC_BASE @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) 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 pw 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.adapter 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 begin next if @token.secs_remaining > @token_refresh @token.refresh @pw # make sure faraday uses the new token @rest_cnx.headers[:authorization] = @token.auth_token rescue # TODO: Some kind of error reporting next end 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 if @keep_alive_thread.alive? @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 do_not_connect: true 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 def self.disconnect @active_connection.disconnect if @active_connection end end # module Jamf require 'jamf/api/connection/token' require 'jamf/api/connection/api_error'