Wagyu
Wagyu is a user-mode WireGuard 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, 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:
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:
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):
{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:
{: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:
{: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:
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 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:
{: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/4with a host name returns{:error, :einval}.server_name_indicationgives:sslthe 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
cacertfileinstead ofcacerts. - In an application, list
:sslinextra_applicationsrather than starting it by hand. - For IPv6, use
SmolNet.Inet6.Tcpwith:inet6.
SmolNet's :ssl guide 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.
Working on Wagyu itself? See MAINTAINING.md.