Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(elixir): udp hole punching example #6588

Open
wants to merge 2 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions examples/elixir/udp_hole_punching/.formatter.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Used by "mix format"
[
inputs: ["*.exs"],
locals_without_parens: [step: 1, step: 2]
]
24 changes: 24 additions & 0 deletions examples/elixir/udp_hole_punching/01-puncher.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
["setup.exs", "puncher.exs"] |> Enum.map(&Code.require_file/1)

[my_name, their_name, addr] = System.argv()
[rendezvous_host, port_s] = String.split(addr, ":")
{rendezvous_port, ""} = Integer.parse(port_s)

rendezvous_address = Ockam.Transport.UDPAddress.new(rendezvous_host, rendezvous_port)

{:ok, _name} =
Puncher.create(
address: my_name,
attributes: %{
target: their_name,
rendezvous_address: rendezvous_address
}
)

# Port 0 will cause the OS to assign a random port
{:ok, _udp} = Ockam.Transport.UDP.start(port: 0)

Ockam.Node.whereis(my_name)
|> GenServer.call(:punch_hole)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd have expected here to send a msg to the other end, and wait till receiving a message from the other end as well, to show how user code can make use of the "Puncher" to send messages traversing NAT. Not necessary like this, but this is what would come to my mind:

# Register this process as worker address "app".  
Ockam.Node.register_address("app")

{:ok, puncher_addr} = Puncher.create(...)

# Send a msg to the puncher worker.   It will forward it to the other end
# and from there it will be routed to the "app" address on the other' end node..
Ockam.Router.route(%{onward_route: [puncher_addr, "app"],    message: "hello", ..})

# Since on this example there is no "client" and "server" but both are 01-puncher.exs instances,  the peer will send
# a msg to us as well (the previous command).  Receive it and print it.
receive do
   msg -> print the msg
end

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm glad you brought this one up, I saw that self registration and receive block in other examples, but in practice I've never gone to production with code containing an explicit receive block. As such I just made the worker module (Puncher in this case) handle receiving since its already wired to do so. But as the other examples are more along the lines as yours, I'm curious if that is providing additional benefit that I'm not seeing.

Process.sleep(30_000)
11 changes: 11 additions & 0 deletions examples/elixir/udp_hole_punching/01-rendezvous.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
["setup.exs", "rendezvous_worker.exs"] |> Enum.map(&Code.require_file/1)

require Logger

port = 5000
Logger.info("Starting Rendezvous Worker on port #{port}")

{:ok, _rendezvous} = RendezvousWorker.setup()
{:ok, _udp_t} = Ockam.Transport.UDP.start(ip: {0, 0, 0, 0}, port: port)

Process.sleep(:infinity)
34 changes: 34 additions & 0 deletions examples/elixir/udp_hole_punching/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# UDP Hole Punching Setup

This directory contains examples of how UDP hole punching might be
achieved using the Ockam Elixir UDP transport and simple workers.

## Overview

UDP hole punching is a technique used to establish a direct communication channel between two devices that are behind NAT (Network Address Translation) routers. This is achieved by sending UDP packets to a third-party rendezvous server that provides
each node with the other's external facing address.

## Usage

- Start the rendezvous server
```
elixir 01-rendezvous.exs
```

- Start two (or more) clients (punchers)
```
elixir 01-puncher.exs my_name their_name rendezvous_host:port
elixir 01-puncher.exs my_name their_name rendezvous_host:port
```

## Known Issues

- Generally unresilient
- doesn't handle address changes
- doesn't implement keep alives
- doesn't handle multi hop
- Hard coded rendezvous node name
- Identifiers are just simple strings
- Uses underlying Elixir UDP transport which is currently incompatible with the Rust impl


71 changes: 71 additions & 0 deletions examples/elixir/udp_hole_punching/puncher.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
defmodule Puncher do
@moduledoc """
An Ockam Worker that acts as a client in a UDP hole punching scenario.
"""

use Ockam.Worker

require Logger

alias Ockam.{Message, Router}

@rendezvous_node "rendezvous"

@impl true
def handle_call(:punch_hole, _, state) do
Logger.debug("Request address from rendezvous server")

Router.route(%{
payload: "my address",
onward_route: [state.attributes.rendezvous_address, @rendezvous_node],
return_route: [state.address]
})

{:reply, :ok, state}
end

@impl true
def handle_message(%{payload: "address: " <> address} = message, state) do
Logger.debug("Received address: #{inspect(address)}")

state = put_in(state, [:attributes, :external_address], address)

Router.route(%{
payload: "connect",
onward_route: [
state.attributes.rendezvous_address,
@rendezvous_node,
state.attributes.target
],
return_route: [state.address]
})

{:ok, state}
end

def handle_message(%{payload: "connected"} = message, state) do
Logger.debug("Received connected message")

Router.route(%{
payload: "hello",
onward_route: message.return_route |> tl(),
return_route: [state.attributes.external_address, state.address]
})

{:ok, state}
end

def handle_message(%{payload: "hello"} = message, state) do
Logger.info("Received hello message! Hole punching successful!")

Router.route(Message.reply(message, state.attributes.external_address, "hello"))

{:ok, state}
end

def handle_message(message, state) do
Logger.warning("Unknown message #{inspect(message)}}")

{:ok, state}
end
end
86 changes: 86 additions & 0 deletions examples/elixir/udp_hole_punching/rendezvous_worker.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
defmodule RendezvousWorker do
@moduledoc """
An Ockam Worker that acts as a rendezvous server for UDP hole punching.
"""

use Ockam.Worker

require Logger

alias Ockam.{Message, Router}

@rendezvous_node "rendezvous"

def setup do
__MODULE__.create(
address: @rendezvous_node,
attributes: %{addresses: %{}, pending_connections: []}
)
end

@impl true
def handle_message(%{payload: "my address"} = message, state) do
[external_address, source] = message.return_route

Logger.debug("Replying with address: #{external_address.value}")

Router.route(Message.reply(message, state.address, "address: #{external_address.value}"))

state = put_in(state, [:attributes, :addresses, source], external_address)
{:ok, state}
end

def handle_message(%{payload: "connect"} = message, state) do
source = message.return_route |> Enum.reverse() |> hd()
target = message.onward_route |> Enum.reverse() |> hd()

Logger.debug("Received connect message from #{inspect(source)} to #{inspect(target)}")

state =
state.attributes.addresses
|> Map.get(target)
|> case do
nil ->
Logger.debug("Target #{target} not found")
pending = [{source, target} | state.attributes.pending_connections]
put_in(state, [:attributes, :pending_connections], pending)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand the intention is to let unmatched connection request on the :pending_connections list, but that is never used latter on, there is some missing there.


target_address ->
Logger.debug("Target #{target} address found: #{inspect(target_address)}")

pending =
if {target, source} in state.attributes.pending_connections do
Logger.debug("Pending connection found")

Router.route(%{
payload: "connected",
onward_route: [target_address, target],
return_route: message.return_route
})

Router.route(%{
payload: "connected",
onward_route: message.return_route,
return_route: [target_address, target]
})

state.attributes.pending_connections
|> Enum.reject(&(&1 == {target, source}))
else
Logger.debug("Pending connection not found")

[{source, target} | state.attributes.pending_connections]
end

put_in(state, [:attributes, :pending_connections], pending)
end

{:ok, state}
end

def handle_message(message, state) do
Logger.warning("Unknown message #{inspect(message)}")

{:ok, state}
end
end
8 changes: 8 additions & 0 deletions examples/elixir/udp_hole_punching/setup.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Mix.install([
{:ockam, path: "../../../implementations/elixir/ockam/ockam"},
{:ockam_typed_cbor,
override: true, path: "../../../implementations/elixir/ockam/ockam_typed_cbor"},
{:ranch, "~> 2.1"}
])

Application.load(:ockam)