Skip to content

Commit dcceee4

Browse files
authored
Add TLS support (#2)
* Add SSL support to Sip2::Client * Move XXX_with_timeout methods from NonBlockingSocket to the Connection class * Fix checksum validation regex (full match instead of line match) * Fix bug in test server where optional locationCode was required * Switch to named parameters for most methods
1 parent 91d302a commit dcceee4

25 files changed

+664
-349
lines changed

.rubocop.yml

+4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
AllCops:
22
TargetRubyVersion: 2.4
3+
NewCops: enable
4+
5+
Layout/DotPosition:
6+
EnforcedStyle: trailing
37

48
Layout/LineLength:
59
Max: 100

.travis.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ install:
1515
- gem install rubocop
1616

1717
script:
18-
- rubocop
18+
- bundle exec rubocop
1919
# Blackhole 127.0.0.2 for testing connection timeouts
2020
- sudo iptables -I INPUT -s 127.0.0.2 -j DROP
2121
- bundle exec rake

bin/console

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
require 'bundler/setup'
5+
require 'sip2'
6+
7+
require 'irb'
8+
IRB.start

lib/sip2.rb

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
require 'sip2/version'
44

5+
require 'openssl'
6+
57
require 'sip2/patron_information'
68

79
require 'sip2/messages/login'

lib/sip2/client.rb

+18-3
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,33 @@ module Sip2
55
# Sip2 Client
66
#
77
class Client
8-
def initialize(host:, port:, ignore_error_detection: false, timeout: nil)
8+
def initialize(host:, port:, ignore_error_detection: false, timeout: nil, ssl_context: nil)
99
@host = host
1010
@port = port
1111
@ignore_error_detection = ignore_error_detection
1212
@timeout = timeout || NonBlockingSocket::DEFAULT_TIMEOUT
13+
@ssl_context = ssl_context
1314
end
1415

16+
# rubocop:disable Metrics/MethodLength
1517
def connect
16-
socket = NonBlockingSocket.connect @host, @port, @timeout
17-
yield Connection.new(socket, @ignore_error_detection) if block_given?
18+
socket = NonBlockingSocket.connect host: @host, port: @port, timeout: @timeout
19+
20+
# If we've been provided with an SSL context then use it to wrap out existing connection
21+
if @ssl_context
22+
socket = ::OpenSSL::SSL::SSLSocket.new socket, @ssl_context
23+
socket.hostname = @host # Needed for SNI
24+
socket.sync_close = true
25+
socket.connect
26+
socket.post_connection_check @host # Validate the peer certificate matches the host
27+
end
28+
29+
if block_given?
30+
yield Connection.new(socket: socket, ignore_error_detection: @ignore_error_detection)
31+
end
1832
ensure
1933
socket&.close
2034
end
35+
# rubocop:enable Metrics/MethodLength
2136
end
2237
end

lib/sip2/connection.rb

+27-6
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ module Sip2
55
# Sip2 Connection
66
#
77
class Connection
8+
LINE_SEPARATOR = "\r"
9+
810
@connection_modules = []
911

1012
class << self
@@ -18,12 +20,17 @@ def add_connection_module(module_name)
1820
include Messages::Login
1921
include Messages::PatronInformation
2022

21-
def initialize(socket, ignore_error_detection)
23+
def initialize(socket:, ignore_error_detection: false)
2224
@socket = socket
2325
@ignore_error_detection = ignore_error_detection
2426
@sequence = 1
2527
end
2628

29+
def send_message(message)
30+
write_with_timeout message
31+
read_with_timeout
32+
end
33+
2734
def method_missing(method_name, *args)
2835
if Connection.connection_modules.include?(method_name.to_sym)
2936
send_and_handle_message(method_name, *args)
@@ -47,13 +54,27 @@ def send_and_handle_message(message_type, *args)
4754
send "handle_#{message_type}_response", response
4855
end
4956

50-
def send_message(message)
51-
@socket.send_with_timeout message
52-
@socket.gets_with_timeout
57+
def write_with_timeout(message, separator: LINE_SEPARATOR)
58+
::Timeout.timeout connection_timeout, WriteTimeout do
59+
@socket.write message + separator
60+
end
61+
end
62+
63+
def read_with_timeout(separator: LINE_SEPARATOR)
64+
::Timeout.timeout connection_timeout, ReadTimeout do
65+
@socket.gets(separator)&.chomp(separator)
66+
end
67+
end
68+
69+
def connection_timeout
70+
# We want the underlying connection where the timeout is configured,
71+
# so if we're dealing with an SSLSocket then we need to unwrap it
72+
io = @socket.respond_to?(:io) ? @socket.io : @socket
73+
io.connection_timeout || NonBlockingSocket::DEFAULT_TIMEOUT
5374
end
5475

5576
def with_error_detection(message)
56-
message + '|AY' + @sequence.to_s
77+
"#{message}|AY#{@sequence}"
5778
end
5879

5980
def with_checksum(message)
@@ -72,7 +93,7 @@ def checksum_for(message)
7293
def sequence_and_checksum_valid?(response)
7394
return true if @ignore_error_detection
7495

75-
sequence_regex = /^(?<message>.*?AY(?<sequence>[0-9]+)AZ)(?<checksum>[A-F0-9]{4})$/
96+
sequence_regex = /\A(?<message>.*?AY(?<sequence>[0-9]+)AZ)(?<checksum>[A-F0-9]{4})\z/
7697
match = response.strip.match sequence_regex
7798
match &&
7899
match[:sequence] == @sequence.to_s &&

lib/sip2/messages/login.rb

+4-4
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ def self.included(klass)
1212

1313
private
1414

15-
def build_login_message(username, password, location_code = nil)
15+
def build_login_message(username:, password:, location_code: nil)
1616
code = '93' # Login
1717
uid_algorithm = pw_algorithm = '0' # Plain text
18-
username_field = 'CN' + username
19-
password_field = 'CO' + password
18+
username_field = "CN#{username}"
19+
password_field = "CO#{password}"
2020
location_code = location_code.strip if location_code.is_a? String
2121
location_field = location_code ? "|CP#{location_code}" : ''
2222

@@ -26,7 +26,7 @@ def build_login_message(username, password, location_code = nil)
2626
end
2727

2828
def handle_login_response(response)
29-
sequence_and_checksum_valid?(response) && response[/^94([01])AY/, 1] == '1'
29+
sequence_and_checksum_valid?(response) && response[/\A94([01])AY/, 1] == '1'
3030
end
3131
end
3232
end

lib/sip2/messages/patron_information.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ def self.included(klass)
1212

1313
private
1414

15-
def build_patron_information_message(uid, password, terminal_password = nil)
15+
def build_patron_information_message(uid:, password:, terminal_password: nil)
1616
code = '63' # Patron information
1717
language = '000' # Unknown
1818
timestamp = Time.now.strftime('%Y%m%d %H%M%S')

lib/sip2/non_blocking_socket.rb

+1-14
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,11 @@ module Sip2
1010
#
1111
class NonBlockingSocket < Socket
1212
DEFAULT_TIMEOUT = 5
13-
SEPARATOR = "\r"
1413

1514
attr_accessor :connection_timeout
1615

17-
def send_with_timeout(message, separator = SEPARATOR)
18-
::Timeout.timeout (connection_timeout || DEFAULT_TIMEOUT), WriteTimeout do
19-
send message + separator, 0
20-
end
21-
end
22-
23-
def gets_with_timeout(separator = SEPARATOR)
24-
::Timeout.timeout (connection_timeout || DEFAULT_TIMEOUT), ReadTimeout do
25-
gets separator
26-
end
27-
end
28-
2916
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
30-
def self.connect(host, port, timeout = DEFAULT_TIMEOUT)
17+
def self.connect(host:, port:, timeout: DEFAULT_TIMEOUT)
3118
# Convert the passed host into structures the non-blocking calls can deal with
3219
addr = Socket.getaddrinfo(host, nil)
3320
sockaddr = Socket.pack_sockaddr_in(port, addr[0][3])

sip2.gemspec

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Gem::Specification.new do |spec|
1616
spec.homepage = 'https://github.com/Studiosity/sip2-ruby'
1717
spec.license = 'MIT'
1818

19-
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(spec)/}) }
19+
spec.files = `git ls-files lib`.split("\n") + ['LICENSE']
2020
spec.require_paths = ['lib']
2121

2222
spec.required_ruby_version = '>= 2.4.0'

spec/client_spec.rb

-62
This file was deleted.

0 commit comments

Comments
 (0)