From 99a079f5cbbfc4c18eaec0a2d0180f8339b32f69 Mon Sep 17 00:00:00 2001 From: Fijxu Date: Wed, 25 Dec 2024 23:43:25 -0300 Subject: [PATCH] 0.1.0 --- LICENSE | 21 +++++ README.md | 50 +++++++++++ shard.yml | 9 ++ spec/natpmpcrystal_spec.cr | 9 ++ spec/spec_helper.cr | 2 + src/natpmp.cr | 167 +++++++++++++++++++++++++++++++++++++ 6 files changed, 258 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 shard.yml create mode 100644 spec/natpmpcrystal_spec.cr create mode 100644 spec/spec_helper.cr create mode 100644 src/natpmp.cr diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..86b560e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Fijxu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7dd10ae --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# natpmp-crystal + +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: + test: + 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_raw # => 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 25565, TCP +client.destroy_mapping(22000, 1) # => {0, 129, 0, 22758, 22000, 0, 0} +``` + +## Contributing + +1. Fork it () +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 diff --git a/shard.yml b/shard.yml new file mode 100644 index 0000000..e4af489 --- /dev/null +++ b/shard.yml @@ -0,0 +1,9 @@ +name: natpmp-crystal +version: 0.1.0 + +authors: + - Fijxu + +crystal: '>= 1.14.0' + +license: MIT diff --git a/spec/natpmpcrystal_spec.cr b/spec/natpmpcrystal_spec.cr new file mode 100644 index 0000000..62a0e85 --- /dev/null +++ b/spec/natpmpcrystal_spec.cr @@ -0,0 +1,9 @@ +require "./spec_helper" + +describe Natpmpcrystal do + # TODO: Write tests + + it "works" do + false.should eq(true) + end +end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr new file mode 100644 index 0000000..bd2c759 --- /dev/null +++ b/spec/spec_helper.cr @@ -0,0 +1,2 @@ +require "spec" +require "../src/natpmpcrystal" diff --git a/src/natpmp.cr b/src/natpmp.cr new file mode 100644 index 0000000..6781f74 --- /dev/null +++ b/src/natpmp.cr @@ -0,0 +1,167 @@ +require "socket" +require "benchmark" + +module NatPMP + enum ResultCodes + SUCCESS = 0 + UNSUPPORTED_VERSION = 1 + NOT_AUTHORIZED_OR_REFUSED = 2 + NETWORK_FAILURE = 3 + OUT_OF_RESOURCES = 4 + UNSUPPORTED_OPCODE = 5 + end + + struct MappingPacket + @vers : UInt8 = 0_u8 + @op : UInt8 + @reserved : UInt16 = 0_u16 + @internal_port : UInt16 + @external_port : UInt16 + @lifetime : UInt32 = 0_u32 + + def initialize(@internal_port, @external_port, @lifetime = 7200, @op = 1) + raise ArgumentError.new("operation should be either 1_u8 for UDP or 2_u8 for TCP") if ![1, 2].includes?(@op) + end + + def to_io + io = IO::Memory.new(12) + io.write_bytes(@vers, IO::ByteFormat::BigEndian) + io.write_bytes(@op, IO::ByteFormat::BigEndian) + io.write_bytes(@reserved, IO::ByteFormat::BigEndian) + io.write_bytes(@internal_port, IO::ByteFormat::BigEndian) + io.write_bytes(@external_port, IO::ByteFormat::BigEndian) + io.write_bytes(@lifetime, IO::ByteFormat::BigEndian) + return io + end + + 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] + IO::ByteFormat::BigEndian.encode(@op, o = Bytes.new(1)) + IO::ByteFormat::BigEndian.encode(@internal_port, i = Bytes.new(2)) + IO::ByteFormat::BigEndian.encode(@external_port, e = Bytes.new(2)) + IO::ByteFormat::BigEndian.encode(@lifetime, l = Bytes.new(4)) + slice[0] = 0 # vers is always 0 + slice[1] = o[0] + slice[2] = 0 # RESERVED, always 0 + slice[3] = 0 # RESERVED, always 0 + slice[4] = i[0] + slice[5] = i[1] + slice[6] = e[0] + slice[7] = e[1] + slice[8] = l[0] + slice[9] = l[1] + slice[10] = l[2] + slice[11] = l[3] + return slice + end + end + + class Client + @socket : UDPSocket + @gateway_ip : String + + # Overload + def initialize(gateway_ip : URI) + initialize(gateway_ip.path) + end + + def initialize(@gateway_ip : String, autoconnect : Bool = true) + # The specification is IPV4 only! + @socket = UDPSocket.new(Socket::Family::INET) + @socket.reuse_port = true + @socket.reuse_address = true + @socket.bind 5350 + if autoconnect + connect() + end + end + + def connect + @socket.connect(@gateway_ip, 5351) + end + + def send_public_address_request_raw : Bytes + @socket.send("\x00\x00") + msg = Bytes.new(12) + @socket.receive(msg) + return msg + end + + def send_public_address_request + @socket.send("\x00\x00") + msg = Bytes.new(12) + @socket.read_timeout = 250.milliseconds + + 8.times do |i| + begin + @socket.receive(msg) + break + rescue IO::TimeoutError + @socket.read_timeout = @socket.read_timeout.not_nil!*2 + next + rescue + raise "The gateway '#{@gateway_ip}' does not support NAT-PMP" + break + end + end + + vers : UInt8 = msg[0] + op : UInt8 = msg[1] + result_code = decode_msg(UInt16, msg[2..3]) + epoch = decode_msg(UInt32, msg[4..7]) + + if result_code != 0 + ip_address = nil + else + ip_address = get_ip_address(msg[8..11]) + end + return vers, op, result_code, epoch, ip_address + end + + # https://datatracker.ietf.org/doc/html/rfc6886#section-3.3 + def request_mapping(internal_port : UInt16, external_port : UInt16, operation : UInt8, lifetime : UInt32 = 7200) + request = MappingPacket.new(internal_port, external_port, lifetime, operation).to_slice + msg = Bytes.new(16) + @socket.send(request) + @socket.receive(msg) + + vers : UInt8 = msg[0] + op : UInt8 = msg[1] + result_code = decode_msg(UInt16, msg[2..3]) + epoch = decode_msg(UInt32, msg[4..7]) + internal_port = decode_msg(UInt16, msg[8..9]) + external_port = decode_msg(UInt16, msg[10..11]) + lifetime = decode_msg(UInt32, msg[12..15]) + + return vers, op, result_code, epoch, internal_port, external_port, lifetime + end + + # https://datatracker.ietf.org/doc/html/rfc6886#section-3.4 + def destroy_mapping(internal_port : UInt16, operation : UInt8) + request = MappingPacket.new(internal_port, 0, 0, operation).to_slice + msg = Bytes.new(16) + @socket.send(request) + @socket.receive(msg) + + vers : UInt8 = msg[0] + op : UInt8 = msg[1] + result_code = decode_msg(UInt16, msg[2..3]) + epoch = decode_msg(UInt32, msg[4..7]) + internal_port = decode_msg(UInt16, msg[8..9]) + external_port = decode_msg(UInt16, msg[10..11]) + lifetime = decode_msg(UInt32, msg[12..15]) + + return vers, op, result_code, epoch, internal_port, external_port, lifetime + end + + private macro decode_msg(type, msg) + IO::ByteFormat::BigEndian.decode({{type}}, {{msg}}) + end + + private def get_ip_address(msg) + "#{msg[0]}.#{msg[1]}.#{msg[2]}.#{msg[3]}" + end + end +end