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 +5 -5
- data/README.md +39 -0
- data/lib/sip2.rb +9 -1
- data/lib/sip2/client.rb +21 -5
- data/lib/sip2/connection.rb +38 -31
- data/lib/sip2/messages/base.rb +64 -0
- data/lib/sip2/messages/login.rb +23 -11
- data/lib/sip2/messages/patron_information.rb +22 -10
- data/lib/sip2/messages/status.rb +55 -0
- data/lib/sip2/non_blocking_socket.rb +5 -18
- data/lib/sip2/responses/base.rb +114 -0
- data/lib/sip2/responses/patron_information.rb +144 -0
- data/lib/sip2/responses/status.rb +133 -0
- data/lib/sip2/version.rb +3 -1
- metadata +52 -27
- data/.gitignore +0 -2
- data/.rubocop.yml +0 -9
- data/.travis.yml +0 -17
- data/Gemfile +0 -4
- data/Rakefile +0 -8
- data/lib/sip2/patron_information.rb +0 -199
- data/sip2.gemspec +0 -27
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: a1e096d30ed5962669bb45c1747f83339682f4a3fa8067e35301ea3a975de13c
|
4
|
+
data.tar.gz: 7be6116ba6402c005ea78a0dc131dca72762cb593d083529b63dac82ed88de00
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
|
data/lib/sip2.rb
CHANGED
@@ -1,9 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'sip2/version'
|
2
4
|
|
3
|
-
require '
|
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
|
data/lib/sip2/client.rb
CHANGED
@@ -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
|
-
|
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
|
32
|
+
socket&.close
|
17
33
|
end
|
18
34
|
end
|
19
35
|
end
|
data/lib/sip2/connection.rb
CHANGED
@@ -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
|
-
|
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
|
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
|
-
|
27
|
-
|
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
|
-
|
35
|
+
!Messages::Base.message_class_for_method(method_name).nil? || super
|
35
36
|
end
|
36
37
|
|
37
38
|
private
|
38
39
|
|
39
|
-
def
|
40
|
-
|
41
|
-
|
42
|
-
|
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
|
48
|
-
|
49
|
-
|
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
|
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 '
|
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
|
-
|
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
|
data/lib/sip2/messages/login.rb
CHANGED
@@ -1,20 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Sip2
|
2
4
|
module Messages
|
3
5
|
#
|
4
|
-
# Sip2 Login message
|
6
|
+
# Sip2 Login message
|
5
7
|
#
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
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
|
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 =
|
17
|
-
password_field =
|
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
|
27
|
-
|
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
|
6
|
+
# Sip2 Patron information message
|
5
7
|
#
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
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
|
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
|
25
|
-
return unless
|
26
|
-
|
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
|