Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,11 @@ pub mod netstack {
/// Tailscale cryptographic key types.
pub mod keys {
#[doc(inline)]
pub use ts_keys::{DiscoKeyPair, MachineKeyPair, NetworkLockKeyPair, NodeKeyPair, NodeState};
pub use ts_keys::{
DiscoKeyPair, DiscoPrivateKey, DiscoPublicKey, MachineKeyPair, MachinePrivateKey,
MachinePublicKey, NetworkLockKeyPair, NetworkLockPrivateKey, NetworkLockPublicKey,
NodeKeyPair, NodePrivateKey, NodePublicKey, NodeState,
};
}

const ENV_MAGIC_VAR: &str = "TS_RS_EXPERIMENT";
Expand Down
6 changes: 3 additions & 3 deletions ts_elixir/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ the API as we iterate.

```elixir
# Connect to tailscale:
{:ok, dev} = Tailscale.connect("tsrs_keys.json", "YOUR_AUTH_KEY")
{:ok, dev} = Tailscale.connect("tsrs_keys.json", auth_key: "YOUR_AUTH_KEY")
# Fetch our tailnet IPv4:
{:ok, ip} = Tailscale.ip4(dev)
{:ok, ip} = Tailscale.ipv4_addr(dev)

# Bind a udp socket:
{:ok, sock} = Tailscale.Udp.bind(dev, ip, 1234)
# Send a udp message over the tailnet
:ok = Tailscale.Udp.send(sock, "100.64.0.1", 5678, "hello")
```
```
4 changes: 2 additions & 2 deletions ts_elixir/lib/erlang.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ defmodule :tailscale do
Erlang-friendly re-export of `Tailscale`.
"""

defdelegate connect(config_path), to: Tailscale
defdelegate connect(config_path, auth_key), to: Tailscale
defdelegate connect(options), to: Tailscale
defdelegate connect(config_path, options), to: Tailscale
defdelegate ipv4_addr(dev), to: Tailscale
defdelegate ipv6_addr(dev), to: Tailscale
defdelegate peer_by_name(dev, name), to: Tailscale
Expand Down
90 changes: 63 additions & 27 deletions ts_elixir/lib/tailscale.ex
Original file line number Diff line number Diff line change
@@ -1,26 +1,37 @@
defmodule Tailscale do
@moduledoc """
Elixir bindings for the Tailscale Rust client.
Elixir bindings for the Tailscale Rust client.

## Nomenclature (devices, peers, nodes, etc.)

In our parlance, anything that shows up on console.tailscale.com
and gets a tailnet IP is known canonically as a "device", though these are also variously been
referred to as "nodes" or "peers". Conventionally, each of these would be a device running
`tailscaled`, but with the advent of `tsnet` and now `tailscale-rs` and its derivative
cross-language clients, a single computer can have many Tailscale connections simultaneously,
possibly to many different tailnets. As an attempt to capture the whole ontology of "things that
have a persistent identity and tailnet IP", we try to refer to them uniformly by the umbrella term
"device".
"""

@typedoc """
An IPv4 address.

`tailscale` is capable of interpreting either the `inet` format or a `String`.
`tailscale` is capable of interpreting either the `m::inet` format or a `String`.
"""
@type ip4_addr() :: :inet.ip4_address() | String.t()

@typedoc """
An IPv6 address.

`tailscale` is capable of interpreting either the `inet` format or a `String`.
`tailscale` is capable of interpreting either the `m::inet` format or a `String`.
"""
@type ip6_addr() :: :inet.ip6_address() | String.t()

@typedoc """
An IP address (v4 or v6).

`tailscale` is capable of interpreting either the `inet` format or a `String`.
`tailscale` is capable of interpreting either the `m::inet` format or a `String`.
"""
@type ip_addr() :: ip4_addr() | ip6_addr()

Expand All @@ -30,32 +41,56 @@ defmodule Tailscale do
"""
@opaque t() :: Tailscale.Native.device()

@spec connect(String.t(), String.t() | nil) :: {:ok, t()}
@doc """
Open a connection to tailscale, creating a device connected to a tailnet.
@typedoc """
Options for connecting to Tailscale:

## Parameters

- `config_path`: the path of the config/state file to load. This contains the node's cryptographic
keys and therefore defines the identity of this device. It will be created if it doesn't exist.
- `auth_key`: the auth key to be used to authorize this device. You only need to supply this if
the device state in `config_path` has not been authorized.
- `auth_key`: the auth key to use to authorize this device. You only need to supply this if the
device's keys aren't authorized.
- `keys`: the `m:Tailscale.Keystate` to use to connect. This defines the device identity.
- `hostname`: the hostname this device will request. If omitted, uses the hostname the OS reports.
- `tags`: tags the device will request.
- `control_url`: the url of the control server to use.
"""
@type options :: [
auth_key: String.t(),
keys: Tailscale.Keystate.t(),
control_url: String.t(),
hostname: String.t(),
tags: [String.t()]
]

## Nomenclature (devices, peers, nodes, etc.)
@spec connect(String.t(), options()) :: {:ok, t()} | {:error, any()}
@doc """
Open a connection to tailscale, creating a device connected to a tailnet. Loads key state from
the given path, creating it if it doesn't exist.

In our parlance, anything that shows up on console.tailscale.com
and gets a tailnet IP is known canonically as a "device", though these are also variously been
referred to as "nodes" or "peers". Conventionally, each of these would be a device running
`tailscaled`, but with the advent of `tsnet` and now `tailscale-rs` and its derivative
cross-language clients, a single computer can have many Tailscale connections simultaneously,
possibly to many different tailnets. As an attempt to capture the whole ontology of "things that
have a persistent identity and tailnet IP", we try to refer to them uniformly by the umbrella term
"device".
See `t:options/0` for details on available options.
"""
def connect(config_path, auth_key \\ nil) do
Tailscale.Native.connect(config_path, auth_key)
def connect(key_file_path, options) when is_binary(key_file_path) do
case Tailscale.Native.load_key_file(key_file_path) do
{:ok, keys} ->
Keyword.put(options, :keys, keys) |> connect()

err ->
err
end
end

@spec connect(options() | String.t()) :: {:ok, t()} | {:error, any()}
@doc """
Open a connection to Tailscale, creating a device connected to a tailnet. If the argument is a
`m:String`, this is equivalent to `connect/2` with an empty option list.

See `t:options/0` for details on available options. You may want to call `connect/2` for an easier
way to load key state from a file.
"""
def connect(options \\ [])

def connect(options) when is_list(options),
do: :proplists.to_map(options) |> Tailscale.Native.connect()

def connect(key_file_path) when is_binary(key_file_path), do: connect(key_file_path, [])

@spec ipv4_addr(t()) :: {:ok, :inet.ip4_address()} | {:error, any()}
@doc """
Get the current IPv4 address of this Tailscale node.
Expand All @@ -70,16 +105,17 @@ defmodule Tailscale do

Blocks until the address is available.

Note that this address is in `:inet` format (16-bit segments), which may be difficult to read.
See `:inet.ntoa` to format to a string.
Note that this address is in `t::inet.ip6_address/0` format (16-bit segments), which may be
difficult to read. See `:inet.ntoa/1` to format to a string.
"""
def ipv6_addr(dev), do: Tailscale.Native.ipv6_addr(dev)

@spec peer_by_name(t(), String.t()) :: {:ok, Tailscale.NodeInfo.t() | nil} | {:error, any()}
@doc """
Look up a peer by name.

Returns `{:ok, nil}` if there was no such peer. `:error` if the lookup encountered an error.
Returns `{:ok, nil}` if there was no such peer, and `{:error, reason}` if the lookup encountered
an error.
"""
def peer_by_name(dev, name), do: Tailscale.Native.peer_by_name(dev, name)

Expand Down
32 changes: 32 additions & 0 deletions ts_elixir/lib/tailscale/keystate.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
defmodule Tailscale.Keystate do
@moduledoc """
Key state for a Tailscale device.
"""

@typedoc """
32-byte X25519 private key.
"""
@type private_key() :: <<_::256>>

@typedoc """
32-byte X25519 public key.
"""
@type public_key() :: <<_::256>>

@typedoc """
Key state for a Tailscale device.
"""
@type t() :: %__MODULE__{
machine: private_key(),
node: private_key(),
disco: private_key(),
network_lock: private_key()
}

defstruct [
:machine,
:node,
:disco,
:network_lock
]
end
29 changes: 17 additions & 12 deletions ts_elixir/lib/tailscale/native.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ defmodule Tailscale.Native do
otp_app: :tailscale,
crate: :ts_elixir

@moduledoc """
The Elixir side of the Rustler bindings to `tailscale-rs`.
@moduledoc false

The rest of this package adapts these bindings to a more Elixir-friendly module layout -- this is
where Rustler actually connects the Rust nifs to their Elixir names, so it's a flat module.
"""
# The Elixir side of the Rustler bindings to `tailscale-rs`.
#
# The rest of this package adapts these bindings to a more Elixir-friendly module layout -- this is
# where Rustler actually connects the Rust nifs to their Elixir names, so it's a flat module.
#
# Consider this module an internal implementation detail: we may break its API at our convenience
# without a semver bump.

@typedoc """
A handle to a unique tailscale "identity" on a given tailnet.
Expand All @@ -34,14 +37,10 @@ defmodule Tailscale.Native do
@doc """
Open a new tailnet connection.

## Parameters

- `config_path`: the path to the state file to load (created if it doesn't exist)
- `auth_key`: the auth key to use to authorize this device (may be `nil` if the device is already
authorized)
See `t:Tailscale.options/0` for details on what options are supported.
"""
@spec connect(String.t(), String.t() | nil) :: {:ok, device()} | {:error, any()}
def connect(_config_path, _auth_key), do: err()
@spec connect(%{}) :: {:ok, device()} | {:error, any()}
def connect(_opts), do: err()

@doc """
Bind a new udp socket.
Expand Down Expand Up @@ -169,4 +168,10 @@ defmodule Tailscale.Native do
"""
@spec self_node(device()) :: {:ok, %{}} | {:error, any()}
def self_node(_dev), do: err()

@doc """
Load key state from the specified path, generating a new state if the file doesn't exist.
"""
@spec load_key_file(String.t()) :: {:ok, Tailscale.Keystate.t()} | {:error, any()}
def load_key_file(_path), do: err()
end
97 changes: 97 additions & 0 deletions ts_elixir/native/ts_elixir/src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
use std::collections::HashMap;

use rustler::{Atom, NifResult, Term};
use tailscale::keys::{DiscoPrivateKey, MachinePrivateKey, NetworkLockPrivateKey, NodePrivateKey};

mod atoms {
rustler::atoms! {
keys,
control_url,
hostname,
tags,
auth_key,
}
}

/// Load a [`tailscale::Config`] from the specified `erl_config`.
///
/// `erl_config` is expected to be a keyword list. Any keys missing from the list will adopt
/// default values.
pub fn config_from_erl(
erl_config: &HashMap<Atom, Term>,
) -> NifResult<(tailscale::Config, Option<String>)> {
let mut config = tailscale::Config {
client_name: Some("ts_elixir".to_owned()),
..Default::default()
};
let mut auth_key = None;

if let Some(value) = erl_config.get(&atoms::keys()) {
config.key_state = value
.decode::<Keystate>()?
.try_into()
.map_err(|_| rustler::Error::Atom("badkeys"))?;
}

if let Some(value) = erl_config.get(&atoms::control_url()) {
config.control_server_url = value.decode::<&str>()?.parse().map_err(|e| {
tracing::error!(error = %e, "parsing control server url");

rustler::Error::Atom("bad_url")
})?;
}

if let Some(value) = erl_config.get(&atoms::hostname()) {
config.requested_hostname = value.decode()?;
}

if let Some(value) = erl_config.get(&atoms::tags()) {
config.requested_tags = value.decode()?;
}

if let Some(value) = erl_config.get(&atoms::auth_key()) {
auth_key = Some(value.decode()?);
}

Ok((config, auth_key))
}

#[derive(rustler::NifStruct, Debug, Clone)]
#[module = "Tailscale.Keystate"]
pub struct Keystate {
pub machine: Vec<u8>,
pub node: Vec<u8>,
pub disco: Vec<u8>,
pub network_lock: Vec<u8>,
}

impl From<tailscale::keys::NodeState> for Keystate {
fn from(value: tailscale::keys::NodeState) -> Self {
Self {
machine: value.machine_keys.private.to_bytes().into(),
node: value.node_keys.private.to_bytes().into(),
disco: value.disco_keys.private.to_bytes().into(),
network_lock: value.network_lock_keys.private.to_bytes().into(),
}
}
}

impl TryFrom<Keystate> for tailscale::keys::NodeState {
type Error = ();

fn try_from(value: Keystate) -> Result<Self, ()> {
fn key<T>(v: Vec<u8>) -> Result<T, ()>
where
T: From<[u8; 32]>,
{
Ok(<[u8; 32]>::try_from(v).map_err(|_| ())?.into())
}

Ok(Self {
machine_keys: key::<MachinePrivateKey>(value.machine)?.into(),
node_keys: key::<NodePrivateKey>(value.node)?.into(),
disco_keys: key::<DiscoPrivateKey>(value.disco)?.into(),
network_lock_keys: key::<NetworkLockPrivateKey>(value.network_lock)?.into(),
})
}
}
Loading