Packages

A user-mode WireGuard endpoint for :gen_tcp and :gen_udp sockets.

Current section

Files

Jump to
wagyu README.md
Raw

README.md

# Wagyu

Wagyu is a user-mode [WireGuard](https://www.wireguard.com/) endpoint for
`:gen_tcp` and `:gen_udp` sockets, written in Elixir. It needs no TUN device,
root or kernel module: the sockets run on a userspace TCP/IP stack,
[SmolNet](https://github.com/ausimian/smolnet), and Wagyu carries their packets
to WireGuard peers over one UDP socket.

## Why

A conventional WireGuard setup adds a TUN device to the host and routes host
traffic through it. That needs root or a kernel module, and it changes
networking for everything on the machine.

Wagyu keeps the tunnel inside the BEAM. Each interface has its own SmolNet
stack, a userspace TCP/IP stack, and only sockets opened on that stack use the
tunnel. An Elixir application can therefore reach hosts on a WireGuard network
without root, a kernel module or a TUN device, and without changing the host's
routing or how the rest of the node connects.

## How

### Requirements

- Elixir 1.18 or later.
- A platform SmolNet ships native code for: macOS on Apple silicon, or Linux
  (glibc) on x86_64 or ARM64.

### Installation

Add `wagyu` to your dependencies in `mix.exs`:

```elixir
def deps do
  [
    {:wagyu, "~> 0.2.0"}
  ]
end
```

### Keys

Keys are raw 32-byte binaries. `wg genkey`, `wg pubkey` and `wg genpsk`
print them in base64, so decode those:

```elixir
local_private_key = Base.decode64!("yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk=")
```

Or generate a key pair in Elixir, and give the public key to the peer as
`Base.encode64(public_key)`:

```elixir
{public_key, local_private_key} = :crypto.generate_key(:ecdh, :x25519)
```

### Start an interface

`Wagyu.start_link/1` validates the configuration and starts the interface
under its own supervisor:

```elixir
{:ok, interface} =
  Wagyu.start_link(
    name: :wg0,
    private_key: local_private_key,
    listen: %{address: {0, 0, 0, 0}, port: 51820},
    stack: [
      addresses: [{{10, 13, 0, 2}, 32}],
      routes: [{{0, 0, 0, 0}, 0, {10, 13, 0, 1}}],
      mtu: 1280
    ],
    peers: [
      %{
        public_key: remote_public_key,
        endpoint: %{address: {192, 0, 2, 1}, port: 51820},
        allowed_ips: [{{0, 0, 0, 0}, 0}]
      }
    ]
  )
```

A peer may also have a `:preshared_key`, the 32-byte key `wg genpsk` makes,
which both sides must configure alike. `Wagyu.Config` describes every option.
An interface's configuration is fixed once it starts; to change it, stop the
interface and start it again.

To run it in your own supervision tree instead, list `{Wagyu, options}` as a
child.

### Connect through the tunnel with `:gen_tcp`

`Wagyu.stack/1` returns the interface's network stack. Pass it, together with
SmolNet's TCP module, in the options of an ordinary `:gen_tcp` call, and that
socket's traffic goes through the tunnel:

```elixir
{:ok, stack} = Wagyu.stack(:wg0)

options = [
  {:tcp_module, SmolNet.Inet.Tcp},
  {:smolnet_stack, stack},
  :inet,
  :binary,
  {:active, false}
]

{:ok, socket} = :gen_tcp.connect({10, 13, 0, 1}, 443, options, 5_000)
:ok = :gen_tcp.send(socket, "hello")
{:ok, reply} = :gen_tcp.recv(socket, 0, 5_000)
:ok = :gen_tcp.close(socket)
```

The options apply to that socket only; the node's other TCP connections are
unaffected. For IPv6, use `SmolNet.Inet6.Tcp` with `:inet6`. The destination
must be reachable through the stack's routes and a peer's `allowed_ips`.

### Send datagrams with `:gen_udp`

UDP works the same way, with SmolNet's UDP module in the `udp_module`
option:

```elixir
options = [
  {:udp_module, SmolNet.Inet.Udp},
  {:smolnet_stack, stack},
  :inet,
  :binary,
  {:active, false}
]

{:ok, socket} = :gen_udp.open(0, options)
:ok = :gen_udp.send(socket, {10, 13, 0, 1}, 53, "query")
{:ok, {_address, _port, reply}} = :gen_udp.recv(socket, 0, 5_000)
:ok = :gen_udp.close(socket)
```

For IPv6, use `SmolNet.Inet6.Udp` with `:inet6`. SmolNet's
[`:gen_udp` guide](https://hexdocs.pm/smolnet/gen_udp.html) covers active
mode, connected sockets and the limits on datagram size.

### Connect with `:ssl`

`:ssl` runs over the same sockets. Give it SmolNet's TCP module as its
transport, in the `cb_info` option, and the stack as for `:gen_tcp`; the TLS
options go in the same list:

```elixir
{:ok, _apps} = Application.ensure_all_started(:ssl)
{:ok, stack} = Wagyu.stack(:wg0)

options = [
  {:cb_info, {SmolNet.Inet.Tcp, :tcp, :tcp_closed, :tcp_error}},
  {:smolnet_stack, stack},
  :inet,
  :binary,
  {:active, false},
  {:verify, :verify_peer},
  {:cacerts, :public_key.cacerts_get()},
  {:server_name_indication, ~c"service.example.com"}
]

{:ok, socket} = :ssl.connect({10, 13, 0, 1}, 443, options, 5_000)
:ok = :ssl.send(socket, "hello")
{:ok, reply} = :ssl.recv(socket, 0, 5_000)
:ok = :ssl.close(socket)
```

- The stack does not resolve names, so connect to an address;
  `:ssl.connect/4` with a host name returns `{:error, :einval}`.
  `server_name_indication` gives `:ssl` the name to send in the handshake and
  to check the certificate against.
- For a server with a certificate from a private CA, pass that CA with
  `cacertfile` instead of `cacerts`.
- In an application, list `:ssl` in `extra_applications` rather than starting
  it by hand.
- For IPv6, use `SmolNet.Inet6.Tcp` with `:inet6`.

SmolNet's [`:ssl` guide](https://hexdocs.pm/smolnet/ssl.html) covers servers,
upgrading a connected socket, and how errors and timeouts appear.

### Inspect and stop an interface

`Wagyu.info/1` reports counters and peer state, and `Wagyu.stop/1` stops the
interface. The `Wagyu` and `Wagyu.Config` module documentation covers every
option, names and handles, failure and restart behaviour, and the limits on
queued work.

## License

MIT. See [LICENSE](https://github.com/ausimian/wagyu/blob/main/LICENSE).

Working on Wagyu itself? See
[MAINTAINING.md](https://github.com/ausimian/wagyu/blob/main/MAINTAINING.md).