Compare commits
No commits in common. "debug" and "master" have entirely different histories.
3 changed files with 152 additions and 44 deletions
51
README.md
51
README.md
|
@ -1,3 +1,50 @@
|
||||||
# natpmpcrystal
|
# natpmp-crystal
|
||||||
|
|
||||||
Work in progress library implementation of RFC6886 **NAT Port Mapping Protocol (NAT-PMP)**
|
Work in progress implementation of RFC 6886 **NAT Port Mapping Protocol (NAT-PMP)**, client side.
|
||||||
|
|
||||||
|
Since RFC 6886 is a pretty simple protocol, it should be ready to use to requests mappings
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Add the dependency to your `shard.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
dependencies:
|
||||||
|
natpmp-crystal:
|
||||||
|
github: fijxu/natpmp-crystal
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run `shards install`
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```crystal
|
||||||
|
# Create NAT-PMP client, replace the IP by the IP of your
|
||||||
|
# gateway
|
||||||
|
client = NatPMP::Client.new("192.168.1.1")
|
||||||
|
# Public address request
|
||||||
|
client.send_public_address_request # => {0, 128, 0, 22758, "104.0.0.0"}
|
||||||
|
client.send_public_address_request_as_bytes # => Bytes[0, 128, 0, 0, 0, 0, 88, 230, 104, 0, 0, 0]
|
||||||
|
|
||||||
|
# Maps the internal port 25565 to external port 25565, TCP
|
||||||
|
client.request_mapping(25565, 25565, 2) # => {0, 130, 0, 22758, 25565, 25565, 7200}
|
||||||
|
# Destroys the mapping with internal port 25565, TCP
|
||||||
|
client.destroy_mapping(25565, 2) # => {0, 130, 0, 22758, 25565, 0, 0}
|
||||||
|
|
||||||
|
# Maps the internal port 22000 to external port 22000, UDP
|
||||||
|
client.request_mapping(22000, 22000, 1) # => {0, 129, 0, 22758, 22000, 22000, 7200}
|
||||||
|
# Destroys the mapping with internal port 22000, UDP
|
||||||
|
client.destroy_mapping(22000, 1) # => {0, 129, 0, 22758, 22000, 0, 0}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork it (<https://github.com/fijxu/natpmp-crystal/fork>)
|
||||||
|
2. Create your feature branch (`git checkout -b my-new-feature`)
|
||||||
|
3. Commit your changes (`git commit -am 'Add some feature'`)
|
||||||
|
4. Push to the branch (`git push origin my-new-feature`)
|
||||||
|
5. Create a new Pull Request
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
- [Fijxu](https://github.com/fijxu) - creator and maintainer
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
name: natpmp-crystal
|
name: natpmp-crystal
|
||||||
version: 0.1.0
|
version: 0.1.1
|
||||||
|
|
||||||
authors:
|
authors:
|
||||||
- Fijxu <fijxu@nadeko.net>
|
- Fijxu <fijxu@nadeko.net>
|
||||||
|
|
143
src/natpmp.cr
143
src/natpmp.cr
|
@ -2,6 +2,9 @@ require "socket"
|
||||||
require "benchmark"
|
require "benchmark"
|
||||||
|
|
||||||
module NatPMP
|
module NatPMP
|
||||||
|
# Result codes defined by the RFC 6886
|
||||||
|
#
|
||||||
|
# [RFC 6886 - 3.5. Result Codes](https://datatracker.ietf.org/doc/html/rfc6886#section-3.5)
|
||||||
enum ResultCodes
|
enum ResultCodes
|
||||||
SUCCESS = 0
|
SUCCESS = 0
|
||||||
UNSUPPORTED_VERSION = 1
|
UNSUPPORTED_VERSION = 1
|
||||||
|
@ -11,18 +14,38 @@ module NatPMP
|
||||||
UNSUPPORTED_OPCODE = 5
|
UNSUPPORTED_OPCODE = 5
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Opcodes defined by the RFC 6886
|
||||||
|
#
|
||||||
|
# *"Otherwise, if the opcode in the request is less than 128, but is not a supported opcode **(currently 0, 1, or 2)**"*
|
||||||
|
#
|
||||||
|
# [RFC 6886 - 3.5. Result Codes](https://datatracker.ietf.org/doc/html/rfc6886#section-3.5)
|
||||||
|
enum OP : UInt8
|
||||||
|
NOOP = 0_u8
|
||||||
|
UDP = 1_u8
|
||||||
|
TCP = 2_u8
|
||||||
|
end
|
||||||
|
|
||||||
|
# You can use this struct to craft your own mapping packets in case you want
|
||||||
|
# to handle it all by yourself.
|
||||||
|
#
|
||||||
|
# ```
|
||||||
|
# # This creates a mapping that you can use to send trough a Socket
|
||||||
|
# packet_io = NatPMP::MappingPacket.new(25565, 25565, 1, 3600).to_io
|
||||||
|
# packet_slice = NatPMP::MappingPacket.new(25565, 25565, 1, 3600).to_slice
|
||||||
|
# ```
|
||||||
struct MappingPacket
|
struct MappingPacket
|
||||||
@vers : UInt8 = 0_u8
|
@vers : UInt8 = 0_u8
|
||||||
@op : UInt8
|
@op : UInt8
|
||||||
@reserved : UInt16 = 0_u16
|
@reserved : UInt16 = 0_u16
|
||||||
@internal_port : UInt16
|
@internal_port : UInt16
|
||||||
@external_port : UInt16
|
@external_port : UInt16
|
||||||
@lifetime : UInt32 = 0_u32
|
@lifetime : UInt32
|
||||||
|
|
||||||
def initialize(@internal_port, @external_port, @lifetime = 7200, @op = 1)
|
def initialize(@internal_port, @external_port, @op = 1, @lifetime = 7200)
|
||||||
raise ArgumentError.new("operation should be either 1_u8 for UDP or 2_u8 for TCP") if ![1, 2].includes?(@op)
|
raise ArgumentError.new("operation should be either 1_u8 for UDP or 2_u8 for TCP") if ![1, 2].includes?(@op)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Converts the struct instance variables to IO.
|
||||||
def to_io
|
def to_io
|
||||||
io = IO::Memory.new(12)
|
io = IO::Memory.new(12)
|
||||||
io.write_bytes(@vers, IO::ByteFormat::BigEndian)
|
io.write_bytes(@vers, IO::ByteFormat::BigEndian)
|
||||||
|
@ -31,12 +54,14 @@ module NatPMP
|
||||||
io.write_bytes(@internal_port, IO::ByteFormat::BigEndian)
|
io.write_bytes(@internal_port, IO::ByteFormat::BigEndian)
|
||||||
io.write_bytes(@external_port, IO::ByteFormat::BigEndian)
|
io.write_bytes(@external_port, IO::ByteFormat::BigEndian)
|
||||||
io.write_bytes(@lifetime, IO::ByteFormat::BigEndian)
|
io.write_bytes(@lifetime, IO::ByteFormat::BigEndian)
|
||||||
return io
|
io
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Converts the struct instance variables to an StaticArray.
|
||||||
|
#
|
||||||
|
# Side Note: This is not actually a Slice, it's an StaticArray so I don't
|
||||||
|
# think this member function should be called like this.
|
||||||
def to_slice
|
def to_slice
|
||||||
# This is not actually a Slice, it's an StaticArray so I don't
|
|
||||||
# think this member function should be called like this.
|
|
||||||
slice = uninitialized UInt8[12]
|
slice = uninitialized UInt8[12]
|
||||||
IO::ByteFormat::BigEndian.encode(@op, o = Bytes.new(1))
|
IO::ByteFormat::BigEndian.encode(@op, o = Bytes.new(1))
|
||||||
IO::ByteFormat::BigEndian.encode(@internal_port, i = Bytes.new(2))
|
IO::ByteFormat::BigEndian.encode(@internal_port, i = Bytes.new(2))
|
||||||
|
@ -54,7 +79,7 @@ module NatPMP
|
||||||
slice[9] = l[1]
|
slice[9] = l[1]
|
||||||
slice[10] = l[2]
|
slice[10] = l[2]
|
||||||
slice[11] = l[3]
|
slice[11] = l[3]
|
||||||
return slice
|
slice
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -62,44 +87,39 @@ module NatPMP
|
||||||
@socket : UDPSocket
|
@socket : UDPSocket
|
||||||
@gateway_ip : String
|
@gateway_ip : String
|
||||||
|
|
||||||
# Overload
|
def initialize(gateway_ip : URI, autoconnect : Bool = true)
|
||||||
def initialize(gateway_ip : URI)
|
initialize(gateway_ip.path, autoconnect)
|
||||||
initialize(gateway_ip.path)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Creates a new NAT-PMP Client, it's only able to connect trough IPV4 so
|
||||||
|
# if you supply a IPV6 address, it will fail;
|
||||||
|
# By default, it connects automatically to the NAT-PMP server, you can
|
||||||
|
# change this by setting `autoconnect` to false like this:
|
||||||
|
# `client = NatPMP::Client.new("192.168.1.1", false)`, that way, you can
|
||||||
|
# change the socket properties like `client.@socket.bind` to your liking
|
||||||
|
# before connecting.
|
||||||
|
#
|
||||||
|
# ```
|
||||||
|
# client = NatPMP::Client.new("192.168.1.1")
|
||||||
|
# ```
|
||||||
def initialize(@gateway_ip : String, autoconnect : Bool = true)
|
def initialize(@gateway_ip : String, autoconnect : Bool = true)
|
||||||
# The specification is IPV4 only!
|
# The specification is IPV4 only!
|
||||||
@socket = UDPSocket.new(Socket::Family::INET)
|
@socket = UDPSocket.new(Socket::Family::INET)
|
||||||
# A given host may have more than one independent
|
|
||||||
# NAT-PMP client running at the same time, and address announcements
|
|
||||||
# need to be available to all of them. Clients should therefore set
|
|
||||||
# the SO_REUSEPORT option or equivalent in order to allow other
|
|
||||||
# processes to also listen on port 5350.
|
|
||||||
@socket.reuse_port = true
|
@socket.reuse_port = true
|
||||||
@socket.reuse_address = true
|
@socket.reuse_address = true
|
||||||
# Additionally, implementers
|
|
||||||
# have encountered issues when one or more processes on the same device
|
|
||||||
# listen to port 5350 on *all* addresses. Clients should therefore
|
|
||||||
# bind specifically to 224.0.0.1:5350, not to 0.0.0.0:5350.
|
|
||||||
@socket.bind 5350
|
@socket.bind 5350
|
||||||
if autoconnect
|
if autoconnect
|
||||||
connect()
|
connect()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def connect
|
# Connects to the NAT-PMP server, you don't need to call this function
|
||||||
# @socket.join_group(Socket::IPAddress.new("224.0.0.1", 5351))
|
# unless you have setted `autoconnect` is false on the constructor.
|
||||||
|
def connect : Nil
|
||||||
@socket.connect(@gateway_ip, 5351)
|
@socket.connect(@gateway_ip, 5351)
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_public_address_request_raw : Bytes
|
private def send_external_address_request_ : Bytes
|
||||||
@socket.send("\x00\x00")
|
|
||||||
msg = Bytes.new(12)
|
|
||||||
@socket.receive(msg)
|
|
||||||
return msg
|
|
||||||
end
|
|
||||||
|
|
||||||
def send_public_address_request
|
|
||||||
@socket.send("\x00\x00")
|
@socket.send("\x00\x00")
|
||||||
msg = Bytes.new(12)
|
msg = Bytes.new(12)
|
||||||
@socket.read_timeout = 250.milliseconds
|
@socket.read_timeout = 250.milliseconds
|
||||||
|
@ -109,11 +129,6 @@ module NatPMP
|
||||||
@socket.receive(msg)
|
@socket.receive(msg)
|
||||||
break
|
break
|
||||||
rescue IO::TimeoutError
|
rescue IO::TimeoutError
|
||||||
# If no
|
|
||||||
# NAT-PMP response is received from the gateway after 250 ms, the
|
|
||||||
# client retransmits its request and waits 500 ms. The client SHOULD
|
|
||||||
# repeat this process with the interval between attempts doubling each
|
|
||||||
# time.
|
|
||||||
@socket.read_timeout = @socket.read_timeout.not_nil!*2
|
@socket.read_timeout = @socket.read_timeout.not_nil!*2
|
||||||
next
|
next
|
||||||
rescue
|
rescue
|
||||||
|
@ -122,14 +137,37 @@ module NatPMP
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
msg
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns the external address response as a `Slice(UInt8)`
|
||||||
|
#
|
||||||
|
# ```
|
||||||
|
# client.send_external_address_request_as_bytes # => Bytes[0, 128, 0, 0, 0, 0, 88, 230, 104, 0, 0, 0]
|
||||||
|
# ```
|
||||||
|
def send_external_address_request_as_bytes : Bytes
|
||||||
|
msg = send_external_address_request_
|
||||||
|
msg
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns the external address response as a `Tuple(UInt8, UInt8, UInt16, UInt32, String | Nil)`
|
||||||
|
#
|
||||||
|
# ```
|
||||||
|
# res = client.send_external_address_request # => {0, 128, 0, 177060, "104.0.0.0"}
|
||||||
|
# version = res[0]
|
||||||
|
# operation = res[1]
|
||||||
|
# result_code = res[2]
|
||||||
|
# epoch = res[3]
|
||||||
|
# external_address = res[4]
|
||||||
|
# ```
|
||||||
|
def send_external_address_request : Tuple(UInt8, UInt8, UInt16, UInt32, String | Nil)
|
||||||
|
msg = send_external_address_request_
|
||||||
|
|
||||||
vers : UInt8 = msg[0]
|
vers : UInt8 = msg[0]
|
||||||
op : UInt8 = msg[1]
|
op : UInt8 = msg[1]
|
||||||
result_code = decode_msg(UInt16, msg[2..3])
|
result_code = decode_msg(UInt16, msg[2..3])
|
||||||
epoch = decode_msg(UInt32, msg[4..7])
|
epoch = decode_msg(UInt32, msg[4..7])
|
||||||
|
|
||||||
# If the result code is non-zero, the value of the External
|
|
||||||
# IPv4 Address field is undefined (MUST be set to zero on transmission,
|
|
||||||
# and MUST be ignored on reception).
|
|
||||||
if result_code != 0
|
if result_code != 0
|
||||||
ip_address = nil
|
ip_address = nil
|
||||||
else
|
else
|
||||||
|
@ -138,8 +176,23 @@ module NatPMP
|
||||||
return vers, op, result_code, epoch, ip_address
|
return vers, op, result_code, epoch, ip_address
|
||||||
end
|
end
|
||||||
|
|
||||||
def request_mapping(internal_port : UInt16, external_port : UInt16, operation : UInt8, lifetime : UInt32 = 7200)
|
# Requests a mapping to the NAT-PMP server
|
||||||
request = MappingPacket.new(internal_port, external_port, lifetime, operation).to_slice
|
#
|
||||||
|
# More details about how requesting a mapping works here: [RFC 6886 - 3.3. Requesting a Mapping](https://datatracker.ietf.org/doc/html/rfc6886#section-3.3)
|
||||||
|
# ```
|
||||||
|
# # Maps the internal port 25565 to external port 25565, TCP, with a lifetime
|
||||||
|
# # of 7200 seconds (the default defined by the RFC)
|
||||||
|
# client.request_mapping(25565, 25565, 2) # => {0, 130, 0, 22758, 25565, 25565, 7200}
|
||||||
|
# # The same as above, but with a lifetime of 60 seconds
|
||||||
|
# client.request_mapping(25565, 25565, 2, 60) # => {0, 130, 0, 22758, 25565, 25565, 60}
|
||||||
|
# # Maps the internal port 25565 to external port 25565, UDP, with a lifetime
|
||||||
|
# # of 7200 seconds (the default defined by the RFC)
|
||||||
|
# client.request_mapping(25565, 25565, 1) # => {0, 129, 0, 22758, 25565, 25565, 7200}
|
||||||
|
# # The same as above, but with a lifetime of 60 seconds
|
||||||
|
# client.request_mapping(25565, 25565, 1, 60) # => {0, 129, 0, 22758, 25565, 25565, 60}
|
||||||
|
# ```
|
||||||
|
def request_mapping(internal_port : UInt16, external_port : UInt16, operation : UInt8, lifetime : UInt32 = 7200) : Tuple(UInt8, UInt8, UInt16, UInt32, UInt16, UInt16, UInt32)
|
||||||
|
request = MappingPacket.new(internal_port, external_port, operation, lifetime).to_slice
|
||||||
msg = Bytes.new(16)
|
msg = Bytes.new(16)
|
||||||
@socket.send(request)
|
@socket.send(request)
|
||||||
@socket.receive(msg)
|
@socket.receive(msg)
|
||||||
|
@ -155,9 +208,17 @@ module NatPMP
|
||||||
return vers, op, result_code, epoch, internal_port, external_port, lifetime
|
return vers, op, result_code, epoch, internal_port, external_port, lifetime
|
||||||
end
|
end
|
||||||
|
|
||||||
# https://datatracker.ietf.org/doc/html/rfc6886#section-3.4
|
# Destroys a mapping in the NAT-PMP server
|
||||||
def destroy_mapping(internal_port : UInt16, operation : UInt8)
|
#
|
||||||
request = MappingPacket.new(internal_port, 0, 0, operation).to_slice
|
# More details about how destroying a mapping works here: [RFC 6886 - 3.4. Destoying a Mapping](https://datatracker.ietf.org/doc/html/rfc6886#section-3.4)
|
||||||
|
# ```
|
||||||
|
# # Destroys the mapping with internal port 25565, TCP
|
||||||
|
# client.destroy_mapping(25565, 2) # => {0, 130, 0, 22758, 25565, 0, 0}
|
||||||
|
# # Destroys the mapping with internal port 25565, UDP
|
||||||
|
# client.destroy_mapping(25565, 1) # => {0, 130, 0, 22758, 25565, 0, 0}
|
||||||
|
# ```
|
||||||
|
def destroy_mapping(internal_port : UInt16, operation : UInt8) : Tuple(UInt8, UInt8, UInt16, UInt32, UInt16, UInt16, UInt32)
|
||||||
|
request = MappingPacket.new(internal_port, 0, operation, 0).to_slice
|
||||||
msg = Bytes.new(16)
|
msg = Bytes.new(16)
|
||||||
@socket.send(request)
|
@socket.send(request)
|
||||||
@socket.receive(msg)
|
@socket.receive(msg)
|
||||||
|
|
Loading…
Add table
Reference in a new issue