How to send a HTTP request through an UNIX socket

Hi Everyone!

I’m still fairly new to the Elixir world, so sorry if this is a stupid question. A few weeks ago I tried to send a request to the Docker daemon with elixir, and I think I got the connection to work, but I could not figure it out how to send a HTTP request through it.

Any help would be appreciated!

1 Like

Depending on your HTTP client you may be able to use a “http+unix” URL, e.g. “http+unix://%2Fvar%2Frun%2Fdocker.sock/”. Hackney and its derivatives (HTTPoison and, depending on the selected backend, Tesla) should support this.

2 Likes

Awesome! Thank you so much!

When it comes with working with docker engine I implemented the http client my self rather than using a ready made client. I needed more control when working interactively with docker images and read their responses which the normal HTTP clients abstracted away.

If only for docker it is quite easy to implement the HTTP1.1 client and erlang does have great http decoding capabilities thanks to the {:packet, :http_bin} option to gen_tcp.connect. Perhaps I should create a free-standing library of this.

1 Like

Thank you for your answer!

I started trying to work with HTTPoison, because I still could not figure out how to send a request through a socket with gen_tcp. Could you share a code snippet maybe so I can learn how it’s done? If you have code on how to attach to a container that would be lovely as well!

This sounds like a great idea!

Here is small module which can sort of interact with the docker socket. This is just an example and error handling and other things are not taken care of here but it should give you an idea. I hope I can get the time to extract my “real” docker api code into a library. It is actually fun to work on but I have little time :confused:

For more information on http format such as the request line, how to read and write headers, how chunked reading work please consult: https://tools.ietf.org/html/rfc2616

Any question on this please feel free to ask.

Use D.request to send data to the docker daemon. I also added in how to attach and read and write to a container.

An addition. For real code you might want to change the connection type to {:active, true} or {:active, 1}, when in stream mode. Then the process controlling the socket will get sent a message whenever there is data instead of having to read it all the time. So read_stream/1 could have looked like this: def read_stream(s), do: :inet.setopts(s, [{:active, true}, {:packet, :line}])

ex(1)> D.request("GET", "/containers/json")
{:ok,
 %D{
   body: "",
   headers: [
     {:"Content-Length", "795"},
     {:Date, "Sat, 28 Nov 2020 23:05:52 GMT"},
     {:Server, "Docker/19.03.12 (linux)"},
     {"Ostype", "linux"},
     {"Docker-Experimental", "false"},
     {:"Content-Type", "application/json"},
     {"Api-Version", "1.40"}
   ],
   status: 200
 },
 [
   %{
     "Command" => "/bin/bash",
     "Created" => 1606603421,
     "HostConfig" => %{"NetworkMode" => "default"},
     "Id" => "4ae5d1c540dba82541dbdc1226764f18c0c9aa257a777503f588abb0295f2630",
     "Image" => "esbuild",
     "ImageID" => "sha256:0aeb5e50e13bd782db9f3815355c189dd9b49f6c825a56e7a27a898daec2e1d6",
     "Labels" => %{},
     "Mounts" => [],
     "Names" => ["/vigorous_brown"],
     "NetworkSettings" => %{
       "Networks" => %{
         "bridge" => %{
           "Aliases" => nil,
           "DriverOpts" => nil,
           "EndpointID" => "40e36f11ce41c964f823d3d7b11abd6b63ae76812e16fcaf2ba63de0585b51db",
           "Gateway" => "172.17.0.1",
           "GlobalIPv6Address" => "",
           "GlobalIPv6PrefixLen" => 0,
           "IPAMConfig" => nil,
           "IPAddress" => "172.17.0.2",
           "IPPrefixLen" => 16,
           "IPv6Gateway" => "",
           "Links" => nil,
           "MacAddress" => "02:42:ac:11:00:02",
           "NetworkID" => "ac553b1326b9ac9c0beebdc0c35362aa8f9bf84ee86c7c54b89d045c23fb6ba6"
         }
       }
     },
     "Ports" => [],
     "State" => "running",
     "Status" => "Up 22 minutes"
   }
 ]}

iex(2)> {:stream, _, socket} = D.request("POST", "/containers/4ae5d1c540dba82541dbdc1226764f18c0c9aa257a777503f588abb0295f2630/attach?stdin=1&stdout=1&stream=1")
{:stream,
 %D{
   body: "",
   headers: ["Content-Type": "application/vnd.docker.raw-stream"],
   status: 200
 }, #Port<0.24>}
iex(3)> D.write_stream(socket, "echo \"Hello World!\"\r\n")
:ok
iex(4)> D.read_stream(socket)
{:ok, "echo \"Hello World!\"\r\n"}
iex(5)> D.read_stream(socket)
{:ok, "Hello World!\r\n"}
iex(6)> D.read_stream(socket)
{:ok, "bash-5.0# \r\n"}
iex(7)> D.read_stream(socket)
{:error, :timeout}
iex(8)>

Here is the module I’ve been working with

defmodule D do

  defstruct status: 0, headers: [], body: ""

  # Send requests to the docker daemon.
  # request("GET", "/containers/json")
  # request("POST", "/containers/abc/attach?stream=1&stdin=1&stdout=1")
  # 
  # To post data you need to add a Content-Type and a Content-Length header to the
  # request and then send the data to the socket
  def request(method, path) do 
    {:ok, socket} = :gen_tcp.connect({:local, "/var/run/docker.sock"}, 0, [
      :binary,
      {:active, false},
      {:packet, :http_bin}
    ])
    :gen_tcp.send(socket, "#{method} #{path} HTTP/1.1\r\nHost: #{:net_adm.localhost()}\r\n\r\n")
    do_recv(socket)
  end

  # Reads from tty. In case of non tty you need to read
  # {:packet, :raw} and decode as described under Stream Format here: https://docs.docker.com/engine/api/v1.40/#operation/ContainerAttach
  # For now just read lines from TTY or timeout after 5 seconds if nothing to be read
  # This requires an attached socket:
  # {:stream, _, socket} = request("POST", "/containers/abc/attach?stream=1&stdin=1&stdout=1")
  # read_stream(socket)
  def read_stream(socket) do
    :inet.setopts(socket, [{:packet, :line}])
    :gen_tcp.recv(socket, 0, 5000)
  end

  # Writes to attached container
  # This requires an attached socket:
  # {:stream, _, socket} = request("POST", "/containers/abc/attach?stream=1&stdin=1&stdout=1")
  # write_stream(socket, "echo \"Hello World\"\r\n")
  # read_stream(socket)
  def write_stream(socket, data) do
    :gen_tcp.send(socket, data)
  end

  def do_recv(socket), do: do_recv(socket, :gen_tcp.recv(socket, 0 , 5000), %D{})

  def do_recv(socket, {:ok, {:http_response, {1,1}, code, _}}, resp) do
    do_recv(socket, :gen_tcp.recv(socket, 0, 5000), %D{resp | status: code})
  end
  def do_recv(socket, {:ok, {:http_header, _, h, _, v}}, resp) do
    do_recv(socket, :gen_tcp.recv(socket, 0, 5000), %D{resp | headers: [{h, v} | resp.headers]})
  end
  def do_recv(socket, {:ok, :http_eoh}, resp) do
      # Now we only have body left.
      # # Depending on headers here you may want to do different things.
      # # The response might be chunked, or upgraded in case you have attached to the container
      # # Now I can receive the response. Because of `:active, false} I need to explicitly ask for data, otherwise it gets send to the process as messages.
    case :proplists.get_value(:"Content-Type", resp.headers) do
      "application/vnd.docker.raw-stream" -> {:stream, resp, socket} # Return the socket for bi-directional communication
      "application/json" -> {:ok, resp, Jason.decode!(read_body(socket, resp))}
      _ -> {:ok, resp, read_body(socket, resp)}
    end
  end

  def read_body(socket, resp) do
    case :proplists.get_value(:"Content-Length", resp.headers) do
      :undefined -> 
        # No content length. Checked if chunked
        case :proplists.get_value(:"Transfer-Encoding", resp.headers) do
          "chunked" -> read_chunked_body(socket, resp)
          _ -> "" # No body
        end
      content_length ->
        bytes_to_read = String.to_integer(content_length)
        :inet.setopts(socket, [{:packet, :raw}]) # No longer line based http, just read data
        case :gen_tcp.recv(socket, bytes_to_read, 5000) do
          {:ok, data} ->
            data
          {:error, reason} ->
            {:error, reason}
        end
    end
  end

  def read_chunked_body(socket, resp), do: read_chunked_body(socket, resp, [])

  def read_chunked_body(socket, resp, acc) do
    :inet.setopts(socket, [{:packet, :line}])

    case :gen_tcp.recv(socket, 0, 5000) do
      {:ok, length} ->
        length = String.trim_trailing(length, "\r\n") |> String.to_integer(16)

        if length == 0 do
          {:ok, :erlang.iolist_to_binary(Enum.reverse(acc))}
        else
          :inet.setopts(socket, [{:packet, :raw}])
          {:ok, data} = :gen_tcp.recv(socket, length, 5000)
          :gen_tcp.recv(socket, 2, 5000)
          read_chunked_body(socket, resp, [data | acc])
        end

      other ->
        {:error, other}
    end
  end
end
2 Likes

Thank you so much!