diff --git a/src/lib.rs b/src/lib.rs index 8d32039c..9c942e5b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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"; diff --git a/ts_elixir/README.md b/ts_elixir/README.md index 7004a152..ceef8830 100644 --- a/ts_elixir/README.md +++ b/ts_elixir/README.md @@ -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") -``` \ No newline at end of file +``` diff --git a/ts_elixir/lib/erlang.ex b/ts_elixir/lib/erlang.ex index 610eb143..fca97b99 100644 --- a/ts_elixir/lib/erlang.ex +++ b/ts_elixir/lib/erlang.ex @@ -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 diff --git a/ts_elixir/lib/tailscale.ex b/ts_elixir/lib/tailscale.ex index b0775b37..528cda3a 100644 --- a/ts_elixir/lib/tailscale.ex +++ b/ts_elixir/lib/tailscale.ex @@ -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() @@ -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. @@ -70,8 +105,8 @@ 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) @@ -79,7 +114,8 @@ defmodule Tailscale do @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) diff --git a/ts_elixir/lib/tailscale/keystate.ex b/ts_elixir/lib/tailscale/keystate.ex new file mode 100644 index 00000000..4887fc78 --- /dev/null +++ b/ts_elixir/lib/tailscale/keystate.ex @@ -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 diff --git a/ts_elixir/lib/tailscale/native.ex b/ts_elixir/lib/tailscale/native.ex index 057a5b91..5efbdb5f 100644 --- a/ts_elixir/lib/tailscale/native.ex +++ b/ts_elixir/lib/tailscale/native.ex @@ -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. @@ -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. @@ -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 diff --git a/ts_elixir/native/ts_elixir/src/config.rs b/ts_elixir/native/ts_elixir/src/config.rs new file mode 100644 index 00000000..f03190b4 --- /dev/null +++ b/ts_elixir/native/ts_elixir/src/config.rs @@ -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, +) -> NifResult<(tailscale::Config, Option)> { + 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::()? + .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, + pub node: Vec, + pub disco: Vec, + pub network_lock: Vec, +} + +impl From 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 for tailscale::keys::NodeState { + type Error = (); + + fn try_from(value: Keystate) -> Result { + fn key(v: Vec) -> Result + where + T: From<[u8; 32]>, + { + Ok(<[u8; 32]>::try_from(v).map_err(|_| ())?.into()) + } + + Ok(Self { + machine_keys: key::(value.machine)?.into(), + node_keys: key::(value.node)?.into(), + disco_keys: key::(value.disco)?.into(), + network_lock_keys: key::(value.network_lock)?.into(), + }) + } +} diff --git a/ts_elixir/native/ts_elixir/src/lib.rs b/ts_elixir/native/ts_elixir/src/lib.rs index 4de90cc2..7cc78f0e 100644 --- a/ts_elixir/native/ts_elixir/src/lib.rs +++ b/ts_elixir/native/ts_elixir/src/lib.rs @@ -1,19 +1,23 @@ #![doc = include_str!("../README.md")] use std::{ + collections::HashMap, net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, str::FromStr, sync::{Arc, LazyLock}, }; -use rustler::{Encoder, ResourceArc, Term}; +use rustler::{Encoder, NifResult, ResourceArc, Term}; +mod config; mod tcp; mod udp; use tcp::{TcpListener, TcpStream}; use udp::UdpSocket; +use crate::config::Keystate; + mod atoms { rustler::atoms! { ok, @@ -100,13 +104,13 @@ where } #[rustler::nif(schedule = "DirtyIo")] -fn connect(env: rustler::Env, config_path: String, auth_key: Option) -> impl Encoder { - let dev = TOKIO_RUNTIME.block_on(async move { - let config = tailscale::Config { - client_name: Some("ts_elixir".to_owned()), - ..tailscale::Config::default_with_key_file(config_path).await? - }; +fn connect<'env>( + env: rustler::Env<'env>, + opts: HashMap>, +) -> NifResult<(rustler::Atom, Term<'env>)> { + let (config, auth_key) = config::config_from_erl(&opts)?; + let dev = TOKIO_RUNTIME.block_on(async move { let dev = tailscale::Device::new(&config, auth_key).await?; ok_arc(Device { @@ -114,7 +118,23 @@ fn connect(env: rustler::Env, config_path: String, auth_key: Option) -> }) }); - erl_result(env, dev) + match dev { + Ok(dev) => Ok((atoms::ok(), dev.encode(env))), + Err(e) => Err(rustler::Error::Term(Box::new(e.to_string()))), + } +} + +#[rustler::nif(schedule = "DirtyIo")] +fn load_key_file(env: rustler::Env, path: &str) -> impl Encoder { + let result = TOKIO_RUNTIME + .block_on(tailscale::config::load_key_file(path, Default::default())) + .map(|keys| { + let result: Keystate = keys.into(); + result + }) + .map_err(Into::into); + + erl_result(env, result) } #[rustler::nif(schedule = "DirtyIo")] diff --git a/ts_elixir/test/basic_test.exs b/ts_elixir/test/basic_test.exs index ebbd1093..520473aa 100644 --- a/ts_elixir/test/basic_test.exs +++ b/ts_elixir/test/basic_test.exs @@ -11,7 +11,7 @@ defmodule Tailscale.Test do @tag skip: @net_skip test "connect", %{state_file: state_file, auth_key: auth_key} do - {:ok, dev} = Tailscale.connect(state_file, auth_key) + {:ok, dev} = Tailscale.connect(state_file, auth_key: auth_key) IO.puts("connected!") {:ok, ip} = Tailscale.ipv4_addr(dev) @@ -23,26 +23,22 @@ defmodule Tailscale.Test do setup [:connected_client] @tag skip: @net_skip - test "ip4", %{ts: dev} do - {:ok, ip} = Tailscale.ipv4_addr(dev) + test "ip4", %{ipv4: ip} do assert :inet.is_ipv4_address(ip) end @tag skip: @net_skip - test "ip6", %{ts: dev} do - {:ok, ip} = Tailscale.ipv6_addr(dev) + test "ip6", %{ipv6: ip} do assert :inet.is_ipv6_address(ip) end @tag skip: @net_skip - test "udp bind", %{ts: dev} do - {:ok, ip} = Tailscale.ipv4_addr(dev) + test "udp bind", %{ts: dev, ipv4: ip} do {:ok, _sock} = Tailscale.Udp.bind(dev, ip, 1234) end @tag skip: @net_skip - test "tcp listen", %{ts: dev} do - {:ok, ip} = Tailscale.ipv4_addr(dev) + test "tcp listen", %{ts: dev, ipv4: ip} do {:ok, _sock} = Tailscale.Tcp.listen(dev, ip, 1234) end end diff --git a/ts_elixir/test/util/helper.ex b/ts_elixir/test/util/helper.ex index 2d497efa..c3190fb0 100644 --- a/ts_elixir/test/util/helper.ex +++ b/ts_elixir/test/util/helper.ex @@ -13,7 +13,7 @@ defmodule Tailscale.Test.Helpers do def check_net(_ctx) do case :gen_tcp.connect(~c"controlplane.tailscale.com", 443, [:binary, active: false]) do {:ok, _} -> - :ok + {:ok, net_ok: true} _ -> {:error, "couldn't tcp connect"} @@ -40,15 +40,17 @@ defmodule Tailscale.Test.Helpers do end def connected_client(ctx) do - :ok = check_net(ctx) + {:ok, _} = check_net(ctx) {:ok, auth_key: auth_key} = auth_key(ctx) {:ok, state_file: state_file} = state_file(ctx) - case Tailscale.connect(state_file, auth_key) do + case Tailscale.connect(state_file, auth_key: auth_key) do {:ok, dev} -> - # wait for ipv4 to be available (successful control connection) - {:ok, _ip} = Tailscale.ipv4_addr(dev) - {:ok, ts: dev, state_file: state_file, auth_key: auth_key} + # wait for ips to be available (successful control connection) + {:ok, ipv4} = Tailscale.ipv4_addr(dev) + {:ok, ipv6} = Tailscale.ipv6_addr(dev) + + {:ok, ts: dev, ipv4: ipv4, ipv6: ipv6, auth_key: auth_key, state_file: state_file} _ -> {:error, "failed to start tailscale client"}