Wagyu
Wagyu is a user-mode WireGuard endpoint for
:gen_tcp and :gen_udp sockets, written in Elixir. It does not need a TUN
device, root or a kernel module. The sockets run on a userspace TCP/IP stack,
SmolNet. Wagyu sends their packets to
WireGuard peers through one UDP socket.
Why
A conventional WireGuard setup adds a TUN device to the host and routes the traffic of the host through it. That setup needs root or a kernel module. It also changes the network configuration for all programs on the machine.
Wagyu keeps the tunnel in the BEAM. Each interface has its own SmolNet stack, which is a userspace TCP/IP stack. Only the sockets that you open on that stack use the tunnel. Thus an Elixir application can reach hosts on a WireGuard network without root, a kernel module or a TUN device. The routes of the host do not change, and the other connections of the node do not change.
How
Requirements
- Elixir 1.18 or later.
- A platform for which SmolNet supplies native code: 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 keys in base64. Decode their output:
local_private_key = Base.decode64!("yAnz5TF+lXXJte14tji3zlMNq+hd2rYUIgJBgB3fBmk=")
You can also make a key pair in Elixir. 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 can also have a :preshared_key, which is the 32-byte key that
wg genpsk makes. The two sides must use the same preshared key.
Wagyu.Config describes all the options.
To change the peers of a running interface, call Wagyu.replace_peers/2 with a
new peer set. The stack, the sockets and the sessions of unchanged peers stay.
Wagyu.revoke_sessions/2 discards the sessions of one peer, for example after
you rotate its preshared key. To change the private key, the listen address or
the stack options, stop the interface and start it again.
To run the interface in your own supervision tree, add {Wagyu, options} as a
child.
Connect through the tunnel with :gen_tcp
Wagyu.stack/1 returns the network stack of the interface. Put the stack and
the TCP module of SmolNet in the options of a usual :gen_tcp call. The
traffic of that socket then 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 only to that socket. The other TCP connections of the node
do not change. For IPv6, use SmolNet.Inet6.Tcp with :inet6. The routes of
the stack and the allowed_ips of a peer must include the destination.
Send datagrams with :gen_udp
UDP works in the same way. Put the UDP module of SmolNet 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. The
:gen_udp guide of SmolNet
describes active mode, connected sockets and the limits on datagram size.
Connect with :ssl
:ssl runs on the same sockets. Put the TCP module of SmolNet in the
cb_info option as the transport. Put the stack in the options, 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. Thus, connect to an address.
:ssl.connect/4with a host name returns{:error, :einval}.server_name_indicationgives:sslthe name that it sends in the handshake.:sslalso compares the certificate with this name. - If the certificate of the server comes from a private CA, give that CA in
cacertfileinstead ofcacerts. - In an application, add
:ssltoextra_applications. Do not start:sslmanually. - For IPv6, use
SmolNet.Inet6.Tcpwith:inet6.
The :ssl guide of SmolNet describes
servers, how to upgrade a connected socket, and how errors and timeouts occur.
Inspect and stop an interface
Wagyu.info/1 gives the counters and the peer state. Wagyu.stop/1 stops
the interface. The module documentation for Wagyu and Wagyu.Config
describes these items:
- All the options.
- Names and handles.
- How to change the peers of a running interface.
- What occurs when a process fails, and how it restarts.
- The limits on queued work.
License
MIT. See LICENSE.
To work on Wagyu itself, see MAINTAINING.md.