sip2 0.0.10 → 0.2.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: '0383747c254aba1493539ca5465e870b7d06ddb3'
4
- data.tar.gz: 3553ea22a5b3e1aea055a36cefb13ffa983e54c5
2
+ SHA256:
3
+ metadata.gz: a1e096d30ed5962669bb45c1747f83339682f4a3fa8067e35301ea3a975de13c
4
+ data.tar.gz: 7be6116ba6402c005ea78a0dc131dca72762cb593d083529b63dac82ed88de00
5
5
  SHA512:
6
- metadata.gz: 2e284505e8d319abe25635185f7e22c1686f52657d9deddeaa0e2c616fa4b0a0e54865f1402c95fa3437ad9c73109ad0d92316edcfd970bbfaa004b28da7309a
7
- data.tar.gz: 9b4d682b6b91bf12236cb8349fccdc6e3cca155d18218b74a93c0a2738dbf36d03a193f0f78d85adfce53c523b7fa25f51fd20c8f3bdd0e5405e4997c2c19c5b
6
+ metadata.gz: 03ad63e45484c5cf767ba8f261b2b8b90155721b40d789d1d8b2418ad26396f768272a3584f987d628779bce09f83f39ac94d0ab04f109dc8c981340fabfd1c3
7
+ data.tar.gz: 7c1e703837a65efd8c4fa02bad6f589fbc4e8f48d9d81627f90da31bf3f66fe7216704d448756c62e3159bfca7d2cf7d99566aba34797eaa562de6c7c8c63cd1
data/README.md CHANGED
@@ -42,6 +42,45 @@ patron =
42
42
  puts 'Valid patron' if patron && patron.authenticated?
43
43
  ```
44
44
 
45
+ ### Using TLS
46
+
47
+ The Sip2::Client will use TLS if a
48
+ [SSLContext](https://ruby-doc.org/stdlib-2.4.10/libdoc/openssl/rdoc/OpenSSL/SSL/SSLContext.html)
49
+ is passed in the `ssl_context` parameter. There are quite a few ways this can be configured, but that will
50
+ depend on how the server being connected to is configured. A basic example is:
51
+
52
+ ```ruby
53
+ # Setup a cert store using the system certificates/trust chain
54
+ cert_store = OpenSSL::X509::Store.new
55
+ cert_store.set_default_paths
56
+
57
+ # Setup the SSL context
58
+ ssl_context = OpenSSL::SSL::SSLContext.new
59
+ ssl_context.verify_mode = OpenSSL::SSL::VERIFY_PEER # This is important. We want to verify the certificate is legitimate
60
+ ssl_context.min_version = OpenSSL::SSL::TLS1_2_VERSION # Generally good practice to enforce the most recent TLS version
61
+ ssl_context.cert_store = cert_store # Use the certificate store we configured above
62
+
63
+ # Raise an exception if the certificate doesn't check out
64
+ ssl_context.verify_callback = proc do |preverify_ok, context|
65
+ raise OpenSSL::SSL::SSLError, <<~ERROR.strip if preverify_ok != true || context.error != 0
66
+ SSL Verification failed -- Preverify: #{preverify_ok}, Error: #{context.error_string} (#{context.error})
67
+ ERROR
68
+
69
+ true
70
+ end
71
+
72
+ client = Sip2::Client.new(host: 'my.sip2.host.net', port: 6001, ssl_context: ssl_context)
73
+ ```
74
+
75
+ If you needed to explicitly specify the certificate to be used there are a few options available
76
+ to use instead of cert_store (see the documentation for full details and other options):
77
+ * ca_file - path to a file containing a CA certificate
78
+ * ca_path - path to a directory containing CA certificates
79
+ * client_ca - a certificate or array of certificates
80
+ * client_cert_cb - callback where an array containing an X509 certificate and key are returned
81
+
82
+ Be sure to validate that your setup behaves the way you expect it to.
83
+ Pass in an invalid certificate and see it fails. Pass a mismatching hostname and see it fails. etc.
45
84
 
46
85
  ## Contributing
47
86
 
@@ -1,9 +1,17 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'sip2/version'
2
4
 
3
- require 'sip2/patron_information'
5
+ require 'openssl'
6
+
7
+ require 'sip2/responses/base'
8
+ require 'sip2/responses/patron_information'
9
+ require 'sip2/responses/status'
4
10
 
11
+ require 'sip2/messages/base'
5
12
  require 'sip2/messages/login'
6
13
  require 'sip2/messages/patron_information'
14
+ require 'sip2/messages/status'
7
15
 
8
16
  module Sip2
9
17
  class TimeoutError < StandardError; end
@@ -1,19 +1,35 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Sip2
2
4
  #
3
5
  # Sip2 Client
4
6
  #
5
7
  class Client
6
- def initialize(host:, port:, ignore_error_detection: false)
8
+ def initialize(host:, port:, ignore_error_detection: false, timeout: nil, ssl_context: nil)
7
9
  @host = host
8
10
  @port = port
9
11
  @ignore_error_detection = ignore_error_detection
12
+ @timeout = timeout || NonBlockingSocket::DEFAULT_TIMEOUT
13
+ @ssl_context = ssl_context
10
14
  end
11
15
 
12
- def connect
13
- socket = NonBlockingSocket.connect @host, @port
14
- yield Connection.new(socket, @ignore_error_detection) if block_given?
16
+ def connect # rubocop:disable Metrics/MethodLength
17
+ socket = NonBlockingSocket.connect host: @host, port: @port, timeout: @timeout
18
+
19
+ # If we've been provided with an SSL context then use it to wrap out existing connection
20
+ if @ssl_context
21
+ socket = ::OpenSSL::SSL::SSLSocket.new socket, @ssl_context
22
+ socket.hostname = @host # Needed for SNI
23
+ socket.sync_close = true
24
+ socket.connect
25
+ socket.post_connection_check @host # Validate the peer certificate matches the host
26
+ end
27
+
28
+ if block_given?
29
+ yield Connection.new(socket: socket, ignore_error_detection: @ignore_error_detection)
30
+ end
15
31
  ensure
16
- socket.close if socket
32
+ socket&.close
17
33
  end
18
34
  end
19
35
  end
@@ -1,56 +1,63 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Sip2
2
4
  #
3
5
  # Sip2 Connection
4
6
  #
5
7
  class Connection
6
- @connection_modules = []
7
-
8
- class << self
9
- attr_reader :connection_modules
10
-
11
- def add_connection_module(module_name)
12
- @connection_modules << module_name
13
- end
14
- end
15
-
16
- include Messages::Login
17
- include Messages::PatronInformation
8
+ LINE_SEPARATOR = "\r"
18
9
 
19
- def initialize(socket, ignore_error_detection)
10
+ def initialize(socket:, ignore_error_detection: false)
20
11
  @socket = socket
21
12
  @ignore_error_detection = ignore_error_detection
22
13
  @sequence = 1
23
14
  end
24
15
 
16
+ def send_message(message)
17
+ message = with_checksum with_error_detection message
18
+ write_with_timeout message
19
+ response = read_with_timeout
20
+ response if sequence_and_checksum_valid? response
21
+ ensure
22
+ @sequence += 1
23
+ end
24
+
25
25
  def method_missing(method_name, *args)
26
- if Connection.connection_modules.include?(method_name.to_sym)
27
- send_and_handle_message(method_name, *args)
28
- else
26
+ message_class = Messages::Base.message_class_for_method(method_name)
27
+ if message_class.nil?
29
28
  super
29
+ else
30
+ message_class.new(self).action_message(*args)
30
31
  end
31
32
  end
32
33
 
33
34
  def respond_to_missing?(method_name, _include_private = false)
34
- Connection.connection_modules.include?(method_name.to_sym) || super
35
+ !Messages::Base.message_class_for_method(method_name).nil? || super
35
36
  end
36
37
 
37
38
  private
38
39
 
39
- def send_and_handle_message(message_type, *args)
40
- message = send "build_#{message_type}_message", *args
41
- message = with_checksum with_error_detection message
42
- response = send_message message
43
- return if response.nil?
44
- send "handle_#{message_type}_response", response
40
+ def write_with_timeout(message, separator: LINE_SEPARATOR)
41
+ ::Timeout.timeout connection_timeout, WriteTimeout do
42
+ @socket.write message + separator
43
+ end
45
44
  end
46
45
 
47
- def send_message(message)
48
- @socket.send_with_timeout message
49
- @socket.gets_with_timeout
46
+ def read_with_timeout(separator: LINE_SEPARATOR)
47
+ ::Timeout.timeout connection_timeout, ReadTimeout do
48
+ @socket.gets(separator)&.chomp(separator)
49
+ end
50
+ end
51
+
52
+ def connection_timeout
53
+ # We want the underlying connection where the timeout is configured,
54
+ # so if we're dealing with an SSLSocket then we need to unwrap it
55
+ io = @socket.respond_to?(:io) ? @socket.io : @socket
56
+ io.connection_timeout || NonBlockingSocket::DEFAULT_TIMEOUT
50
57
  end
51
58
 
52
59
  def with_error_detection(message)
53
- message + '|AY' + @sequence.to_s
60
+ "#{message}|AY#{@sequence}"
54
61
  end
55
62
 
56
63
  def with_checksum(message)
@@ -63,18 +70,18 @@ module Sip2
63
70
  message.each_char { |m| check += m.ord }
64
71
  check += "\0".ord
65
72
  check = (check ^ 0xFFFF) + 1
66
- format '%4.4X', check
73
+ format '%<check>4.4X', check: check
67
74
  end
68
75
 
69
76
  def sequence_and_checksum_valid?(response)
70
77
  return true if @ignore_error_detection
71
- sequence_regex = /^(?<message>.*?AY(?<sequence>[0-9]+)AZ)(?<checksum>[A-F0-9]{4})$/
78
+ return false unless response.is_a? String
79
+
80
+ sequence_regex = /\A(?<message>.*?AY(?<sequence>[0-9]+)AZ)(?<checksum>[A-F0-9]{4})\z/
72
81
  match = response.strip.match sequence_regex
73
82
  match &&
74
83
  match[:sequence] == @sequence.to_s &&
75
84
  match[:checksum] == checksum_for(match[:message])
76
- ensure
77
- @sequence += 1
78
85
  end
79
86
  end
80
87
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sip2
4
+ module Messages
5
+ #
6
+ # Sip2 Base message handler
7
+ #
8
+ class Base
9
+ @message_class_lookup = {}
10
+
11
+ # Class helper to fetch the descendant "message" class based on a method name
12
+ #
13
+ # @param [String] method_name the underscore case name of the class to fetch
14
+ # @return [Class] the message class fetched based on the method_name,
15
+ # `nil` if no descendant was found
16
+ # @example
17
+ # message_class_for_method('patron_information')
18
+ # => Sip2::Messages::PatronInformation
19
+ def self.message_class_for_method(method_name) # rubocop:disable Metrics/MethodLength
20
+ return @message_class_lookup[method_name] if @message_class_lookup.key? :method_name
21
+
22
+ @message_class_lookup[method_name] =
23
+ begin
24
+ # classify the method name so we can fetch the message class of the same name
25
+ class_name = method_name.to_s.capitalize.gsub(%r{(?:_|(/))([a-z\d]*)}i) do
26
+ "#{Regexp.last_match(1)}#{Regexp.last_match(2).capitalize}"
27
+ end
28
+ message_class = Messages.const_get(class_name)
29
+ message_class if message_class && message_class < self
30
+ rescue NameError
31
+ nil
32
+ end
33
+ end
34
+
35
+ def initialize(connection)
36
+ @connection = connection
37
+ end
38
+
39
+ # Action the message, passing the dynamic arguments to the specific message implementation
40
+ #
41
+ # @param [*various] args Arguments to pass to the specific message implementation
42
+ # @return returns `nil` if there was no valid message returned.
43
+ # Otherwise value will depend on the specific message. See the `handle_response`
44
+ # method in those classes for more information
45
+ def action_message(*args)
46
+ message = build_message(*args)
47
+ response = @connection.send_message message
48
+ return if response.nil?
49
+
50
+ handle_response(response)
51
+ end
52
+
53
+ private
54
+
55
+ def build_message(*)
56
+ raise NotImplementedError, "#{self.class} must implement `build_message` method"
57
+ end
58
+
59
+ def handle_response(_response)
60
+ raise NotImplementedError, "#{self.class} must implement `handle_response` method"
61
+ end
62
+ end
63
+ end
64
+ end
@@ -1,20 +1,32 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Sip2
2
4
  module Messages
3
5
  #
4
- # Sip2 Login message module
6
+ # Sip2 Login message
5
7
  #
6
- module Login
7
- def self.included(klass)
8
- klass.add_connection_module :login
9
- end
10
-
8
+ # https://developers.exlibrisgroup.com/wp-content/uploads/2020/01/3M-Standard-Interchange-Protocol-Version-2.00.pdf
9
+ #
10
+ # Request message 93
11
+ # * UID algorithm - 1 char, fixed-length required field; the algorithm used
12
+ # to encrypt the user id
13
+ # * PWD algorithm - 1 char, fixed-length required field; the algorithm used
14
+ # to encrypt the password
15
+ # * login user id - CN - variable-length required field
16
+ # * login password - CO - variable-length required field
17
+ # * location code - CP - variable-length required field
18
+ #
19
+ # Response message 94
20
+ # * ok - 1 char, fixed-length required field: 0 or 1
21
+ #
22
+ class Login < Base
11
23
  private
12
24
 
13
- def build_login_message(username, password, location_code = nil)
25
+ def build_message(username:, password:, location_code: nil)
14
26
  code = '93' # Login
15
27
  uid_algorithm = pw_algorithm = '0' # Plain text
16
- username_field = 'CN' + username
17
- password_field = 'CO' + password
28
+ username_field = "CN#{username}"
29
+ password_field = "CO#{password}"
18
30
  location_code = location_code.strip if location_code.is_a? String
19
31
  location_field = location_code ? "|CP#{location_code}" : ''
20
32
 
@@ -23,8 +35,8 @@ module Sip2
23
35
  ].join
24
36
  end
25
37
 
26
- def handle_login_response(response)
27
- sequence_and_checksum_valid?(response) && response[/^94([01])AY/, 1] == '1'
38
+ def handle_response(response)
39
+ response[/\A94([01])AY/, 1] == '1'
28
40
  end
29
41
  end
30
42
  end
@@ -1,16 +1,27 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Sip2
2
4
  module Messages
3
5
  #
4
- # Sip2 Patron information message module
6
+ # Sip2 Patron information message
5
7
  #
6
- module PatronInformation
7
- def self.included(klass)
8
- klass.add_connection_module :patron_information
9
- end
10
-
8
+ # https://developers.exlibrisgroup.com/wp-content/uploads/2020/01/3M-Standard-Interchange-Protocol-Version-2.00.pdf
9
+ #
10
+ # Request message 63
11
+ # * language - 3 char, fixed-length required field
12
+ # * transaction date - 18 char, fixed-length required field: YYYYMMDDZZZZHHMMSS
13
+ # * summary - 10 char, fixed-length required field
14
+ # * institution id - AO - variable-length required field
15
+ # * patron identifier - AA - variable-length required field
16
+ # * terminal password - AC - variable-length optional field
17
+ # * patron password - AD - variable-length optional field
18
+ # * start item - BP - variable-length optional field
19
+ # * end item - BQ - variable-length optional field
20
+ #
21
+ class PatronInformation < Base
11
22
  private
12
23
 
13
- def build_patron_information_message(uid, password, terminal_password = nil)
24
+ def build_message(uid:, password:, terminal_password: nil)
14
25
  code = '63' # Patron information
15
26
  language = '000' # Unknown
16
27
  timestamp = Time.now.strftime('%Y%m%d %H%M%S')
@@ -21,9 +32,10 @@ module Sip2
21
32
  ].join
22
33
  end
23
34
 
24
- def handle_patron_information_response(response)
25
- return unless sequence_and_checksum_valid?(response)
26
- Sip2::PatronInformation.new response
35
+ def handle_response(response)
36
+ return unless /\A#{Sip2::Responses::PatronInformation::RESPONSE_ID}/o.match?(response)
37
+
38
+ Sip2::Responses::PatronInformation.new response
27
39
  end
28
40
  end
29
41
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sip2
4
+ module Messages
5
+ #
6
+ # Sip2 Patron information message
7
+ #
8
+ # https://developers.exlibrisgroup.com/wp-content/uploads/2020/01/3M-Standard-Interchange-Protocol-Version-2.00.pdf
9
+ #
10
+ # Request message 99
11
+ # * status code - 1 char, fixed-length required field: 0, 1 or 2
12
+ # * max print width - 3 char, fixed-length required field
13
+ # * protocol version - 4 char, fixed-length required field: x.xx
14
+ #
15
+ class Status < Base
16
+ STATUS_CODE_LOOKUP = {
17
+ ok: 0,
18
+ out_of_paper: 1,
19
+ about_to_shut_down: 2
20
+ }.freeze
21
+
22
+ private
23
+
24
+ def build_message(status_code: :ok, max_print_width: 999, protocol_version: 2)
25
+ [
26
+ '99', # SC Status
27
+ normalize_status_code(status_code),
28
+ normalize_print_width(max_print_width),
29
+ normalize_protocol_version(protocol_version)
30
+ ].join
31
+ end
32
+
33
+ def normalize_status_code(code)
34
+ format(
35
+ '%<code>d',
36
+ code: (code.is_a?(Symbol) ? STATUS_CODE_LOOKUP[code] : code).to_i.abs
37
+ )[0]
38
+ end
39
+
40
+ def normalize_print_width(width)
41
+ format('%03<width>d', width: width % 1000)
42
+ end
43
+
44
+ def normalize_protocol_version(version)
45
+ format('%.2<version>f', version: version % 10)
46
+ end
47
+
48
+ def handle_response(response)
49
+ return unless /\A#{Sip2::Responses::Status::RESPONSE_ID}/o.match?(response)
50
+
51
+ Sip2::Responses::Status.new response
52
+ end
53
+ end
54
+ end
55
+ end