net-natpmp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9450b999925ebdbe97f8cf23f24bbdc5dc5177bc29a4782a88b667ab5bbeeb06
4
+ data.tar.gz: b0afc58710a87288c518878a7079583651c0d10f3eba5ac1b3c17a7b9b2b91e9
5
+ SHA512:
6
+ metadata.gz: d5aac53925fc713efbebe52bfc94f3f2b8ea632e7fe407e828c02e367aa6b9509a519b6a0117b66e0c75c6e234085933464bd8a5accd17f5bab728da12213dc4
7
+ data.tar.gz: cd672c6ca071227b65d9aac5e74f3f7db5dd3cf0d4ddb2d2a879fb9f9b85d499bba22e67616f62c63ed265486ee99d5b7b1138470a2527bb0cfd33ee02e80306
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Octavian Vaideanu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,6 @@
1
+ # net-natpmp
2
+ NATPMP client implementation in ruby
3
+
4
+ TODO: Write Readme file
5
+ TODO: Maybe add tests
6
+ TODO: Give the option to use the default gateway instead of requiring it as an argument
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module NATPMP
5
+ # Client class
6
+ class Client
7
+ include Constants
8
+
9
+ attr_reader :config
10
+
11
+ def initialize(config)
12
+ @config = config
13
+ end
14
+
15
+ # Replies with the external address of the gateway
16
+ def external_address
17
+ ExternalAddressRequest.req(@config)
18
+ end
19
+
20
+ # Maps a port on the gateway
21
+ def map_port(
22
+ proto: DEFAULT_PROTO,
23
+ inside_port: DEFAULT_INSIDE_PORT,
24
+ outside_port: DEFAULT_OUTSIDE_PORT,
25
+ lifetime: DEFAULT_LIFETIME
26
+ )
27
+ MappingRequest.req(
28
+ @config,
29
+ proto: proto,
30
+ inside_port: inside_port,
31
+ outside_port: outside_port,
32
+ lifetime: lifetime
33
+ )
34
+ end
35
+
36
+ # Destroys a port mapping on the gateway
37
+ def destroy_mapping(port: 0, proto: DEFAULT_PROTO)
38
+ MappingRequest.req(@config, proto: proto, inside_port: port, outside_port: 0, lifetime: 0)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module NATPMP
5
+ # Provides all necessary configuration with some defaults
6
+ class Config
7
+ NATPMP_PORT = 5351
8
+ BIND_ADDRESS = '0.0.0.0'
9
+ BIND_PORT = 5350
10
+
11
+ attr_reader :bind_address, :bind_port, :gw, :port
12
+
13
+ def initialize(
14
+ gw:,
15
+ port: NATPMP_PORT,
16
+ bind_address: BIND_ADDRESS,
17
+ bind_port: BIND_PORT
18
+ )
19
+
20
+ @bind_address = IPAddr.new(bind_address)
21
+ @bind_port = bind_port
22
+ @gw = IPAddr.new(gw)
23
+ @port = port
24
+ rescue IPAddr::InvalidAddressError
25
+ raise InvalidParameter, 'Value must be a valid IP Address'
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module NATPMP
5
+ module Constants
6
+ VERSION = 0
7
+
8
+ PROTO_CODES = {
9
+ udp: :map_udp,
10
+ tcp: :map_tcp
11
+ }.freeze
12
+
13
+ OP_CODES = {
14
+ address: 0,
15
+ map_udp: 1,
16
+ map_tcp: 2
17
+ }.freeze
18
+
19
+ # 0 - Success
20
+ # 1 - Unsupported Version
21
+ # 2 - Not Authorized/Refused
22
+ # (e.g., box supports mapping, but user has turned feature off)
23
+ # 3 - Network Failure
24
+ # (e.g., NAT box itself has not obtained a DHCP lease)
25
+ # 4 - Out of resources
26
+ # (NAT box cannot create any more mappings at this time)
27
+ # 5 - Unsupported opcode
28
+ RESULT_CODES = {
29
+ success: 0,
30
+ unsupported_version: 1,
31
+ not_authorized: 2,
32
+ network_failure: 3,
33
+ out_of_resources: 4,
34
+ unsupported_opcode: 5
35
+ }.freeze
36
+
37
+ RESULT_CODES_DESC = {
38
+ 1 => 'Unsupported Version',
39
+ 2 => 'Not Authorized/Refused',
40
+ 3 => 'Network Failure',
41
+ 4 => 'Out of resources',
42
+ 5 => 'Unsupported opcode'
43
+ }.freeze
44
+
45
+ # This is the initial delay before doubling it
46
+ BASE_DELAY = 0.25
47
+ MAX_WAIT = 60
48
+
49
+ DEFAULT_INSIDE_PORT = 0
50
+ DEFAULT_OUTSIDE_PORT = 0
51
+ DEFAULT_LIFETIME = 7200
52
+ DEFAULT_PROTO = :udp
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module NATPMP
5
+ # General exception
6
+ class Exception < ::RuntimeError; end
7
+
8
+ class InvalidParameter < Exception; end
9
+
10
+ # Timeout exception
11
+ class TimeoutException < Exception
12
+ def initialize(msg = 'Request timed out')
13
+ super
14
+ end
15
+ end
16
+
17
+ class InvalidVersion < Exception; end
18
+
19
+ class InvalidReply < Exception; end
20
+
21
+ # Exception should be thrown when the request fails
22
+ class RequestFailed < Exception
23
+ include Constants
24
+
25
+ def initialize(opts = {})
26
+ msg = opts[:msg] || 'Failed to send the request'
27
+
28
+ if (@result_code = opts[:result_code])
29
+ msg = "#{msg}. Server replied: #{RESULT_CODES_DESC[@result_code]}(#{@result_code})"
30
+ end
31
+
32
+ super(msg)
33
+ end
34
+ end
35
+
36
+ class ConnectionRefused < Exception; end
37
+ end
38
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'ipaddr'
5
+
6
+ module Net
7
+ module NATPMP
8
+ # Main request class
9
+ class Request
10
+ include Constants
11
+
12
+ attr_reader :socket, :config
13
+
14
+ def initialize(config)
15
+ @config = config
16
+
17
+ @socket = UDPSocket.new
18
+ @socket.bind(config.bind_address.to_s, config.bind_port)
19
+ @socket.connect(config.gw.to_s, config.port)
20
+ end
21
+
22
+ def self.req(config, _opts = {})
23
+ new(config)
24
+ end
25
+
26
+ # Send a message to the NAT-PMP server. Takes a message and an optional response size
27
+ def send(msg, expected_response_size = 16)
28
+ sent_op = msg.unpack1('xC') # To verify the response
29
+ size_sent = @socket.send(msg, 0)
30
+
31
+ raise RequestFailed unless size_sent == msg.size
32
+
33
+ delay = Constants::BASE_DELAY
34
+ attempts = 1
35
+
36
+ begin
37
+ sleep delay
38
+ reply, = @socket.recvfrom_nonblock(expected_response_size)
39
+
40
+ check_reply(reply, sent_op)
41
+
42
+ reply
43
+ rescue IO::WaitReadable
44
+ if delay < MAX_WAIT
45
+ delay *= 2
46
+ puts "Timeout, retrying after #{delay} seconds"
47
+ attempts += 1
48
+ retry
49
+ end
50
+
51
+ raise TimeoutException, "Timeout after #{attempts} attempts"
52
+ rescue Errno::ECONNREFUSED
53
+ raise ConnectionRefused, 'Connection refused'
54
+ end
55
+ ensure
56
+ sleep 0.25 # Sleep for a bit to make sure the socket is not closed too soon
57
+ @socket.close # Close the socket because we don't need it anymore
58
+ end
59
+
60
+ def check_reply(reply, sent_op)
61
+ # Check the first 4 bytes only (the rest are variable)
62
+ version, opcode, result = reply.unpack('CCn')
63
+
64
+ # Check the version in the reply
65
+ raise InvalidVersion, "Invalid version #{version}" unless version == VERSION
66
+
67
+ # Check the operation code in the reply. Always (128 + sent opcode)
68
+ expected_opcode = 128 + sent_op
69
+ if opcode != expected_opcode
70
+ raise InvalidReply,
71
+ "Invalid reply opcode. Was expecting #{expected_opcode}, got: #{opcode}"
72
+ end
73
+
74
+ # Check the result code in the reply
75
+ raise RequestFailed, result_code: result unless result == RESULT_CODES[:success]
76
+ end
77
+ end
78
+
79
+ # Request class specific to External address
80
+ class ExternalAddressRequest < Request
81
+ def self.req(config)
82
+ instance = super(config)
83
+ msg = [VERSION, OP_CODES[:address]].pack('CC')
84
+
85
+ ExternalAddressResponse.new(instance.send(msg, 12))
86
+ end
87
+ end
88
+
89
+ # Represents a port mapping request
90
+ class MappingRequest < Request
91
+ def self.req(config, proto:, inside_port:, outside_port:, lifetime:)
92
+ # TODO: Ensure proto is either :udp or :tcp
93
+
94
+ instance = super(config)
95
+ msg = [
96
+ VERSION, OP_CODES[PROTO_CODES[proto]], 0,
97
+ inside_port, outside_port,
98
+ lifetime
99
+ ].pack('CCnnnN')
100
+
101
+ MappingResponse.new(instance.send(msg, 16), proto: proto)
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module NATPMP
5
+ # Response class
6
+ class Response
7
+ attr_reader :raw_response
8
+
9
+ def initialize(response, _ = {})
10
+ @raw_response = response
11
+ end
12
+ end
13
+
14
+ # Response for the external address request
15
+ class ExternalAddressResponse < Response
16
+ attr_reader :ip_int
17
+
18
+ def initialize(response)
19
+ super
20
+ @ip_int = response.unpack1('@8N')
21
+ end
22
+
23
+ def address
24
+ IPAddr.new(ip_int, Socket::AF_INET)
25
+ end
26
+
27
+ def to_s
28
+ address.to_s
29
+ end
30
+
31
+ def inspect
32
+ "External address: #{address}"
33
+ end
34
+ end
35
+
36
+ # Response for the mapping request
37
+ class MappingResponse < Response
38
+ attr_reader :inside_port, :outside_port, :lifetime, :proto
39
+
40
+ def initialize(response, proto:)
41
+ super
42
+
43
+ @proto = proto
44
+ @inside_port, @outside_port, @lifetime = response.unpack('@8nnN')
45
+ end
46
+
47
+ def inspect
48
+ "Port mapping: #{@outside_port} -> #{@proto}:#{@inside_port} (lifetime: #{@lifetime})"
49
+ end
50
+ end
51
+ end
52
+ end
data/lib/net/natpmp.rb ADDED
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'natpmp/constants'
4
+ require_relative 'natpmp/config'
5
+ require_relative 'natpmp/client'
6
+ require_relative 'natpmp/errors'
7
+ require_relative 'natpmp/requests'
8
+ require_relative 'natpmp/responses'
9
+
10
+ module Net
11
+ # Main class
12
+ module NATPMP
13
+ # Instantiate class with default params.
14
+ # Takes a config instance or any other params.
15
+ # :gw is mandatory if config instance not provided
16
+ def self.client(config)
17
+ return Client.new(config) if config.is_a?(Config)
18
+
19
+ unless config[:gw]
20
+ raise Net::NATPMP::Exception,
21
+ 'Gateway is missing. Call with: Net::NATPMP.client(gw: "x.x.x.x")'
22
+ end
23
+
24
+ Client.new(Config.new(**config))
25
+ end
26
+ end
27
+ end
metadata ADDED
@@ -0,0 +1,53 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: net-natpmp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Octavian Vaideanu
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-07-26 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: |2
14
+ A NAT-PMP client for Ruby.
15
+ This gem allows you to interact with NAT-PMP enabled routers to map ports and get the external IP address.
16
+ email: octav@devroot.dev
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - LICENSE
22
+ - README.md
23
+ - lib/net/natpmp.rb
24
+ - lib/net/natpmp/client.rb
25
+ - lib/net/natpmp/config.rb
26
+ - lib/net/natpmp/constants.rb
27
+ - lib/net/natpmp/errors.rb
28
+ - lib/net/natpmp/requests.rb
29
+ - lib/net/natpmp/responses.rb
30
+ homepage: https://rubygems.org/gems/net-natpmp
31
+ licenses:
32
+ - MIT
33
+ metadata: {}
34
+ post_install_message:
35
+ rdoc_options: []
36
+ require_paths:
37
+ - lib
38
+ required_ruby_version: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '3.0'
43
+ required_rubygems_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ requirements: []
49
+ rubygems_version: 3.5.11
50
+ signing_key:
51
+ specification_version: 4
52
+ summary: NAT-PMP client for Ruby
53
+ test_files: []