diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22b920f --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +tailscale-*.tar + +# Temporary files, for example, from tests. +/tmp/ diff --git a/lib/tailscale.ex b/lib/tailscale.ex new file mode 100644 index 0000000..39b164e --- /dev/null +++ b/lib/tailscale.ex @@ -0,0 +1,8 @@ +defmodule Tailscale do + defdelegate child_spec(opts \\ []), to: Tailscale.Supervisor + defdelegate start_link(opts \\ []), to: Tailscale.Supervisor + + def status do + Tailscale.Local.Status.get!() + end +end diff --git a/lib/tailscale/change_server.ex b/lib/tailscale/change_server.ex new file mode 100644 index 0000000..7679f53 --- /dev/null +++ b/lib/tailscale/change_server.ex @@ -0,0 +1,117 @@ +defmodule Tailscale.ChangeServer do + use GenServer + alias Tailscale.Event + require Logger + + @initial_subscribers_map Event.events_available_for_subscription() + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + def init(opts) do + refresh_interval = Application.get_env(:tailscale, :refresh_interval, 30_000) + refresh_interval = Keyword.get(opts, :refresh_interval, refresh_interval) + + state = %{ + refresh_interval: refresh_interval, + status_map: nil, + subscribers: @initial_subscribers_map + } + + {:ok, state, {:continue, :refresh}} + end + + def handle_continue(:refresh, %{refresh_interval: refresh_interval} = state) do + Logger.debug("Refreshing Tailscale Status") + + old_status = state.status_map + new_status = Tailscale.Status.get!() + + if old_status != nil do + events = Tailscale.Status.diff(old_status, new_status) + fire_events(events, state) + end + + state = %{state | status_map: new_status} + Process.send_after(self(), :refresh, refresh_interval) + {:noreply, state} + end + + def handle_info(:refresh, state), do: {:noreply, state, {:continue, :refresh}} + + # --------------------------- + # --- GENSERVER CALLBACKS --- + # --------------------------- + + def handle_call(:get_status, _from, state) do + reply = if state.status_map == nil, do: Tailscale.Local.Status.get!(), else: state.status_map + {:reply, reply, state} + end + + def handle_call({:subscribe, target, event}, {pid, _ref}, state) do + state = + update_in(state.subscribers[target][event], fn pids -> MapSet.put(pids, pid) end) + + {:reply, :ok, state} + end + + def handle_call(:subscribe_all, {pid, _ref}, state) do + state = update_in(state.subscribers.all, fn pids -> MapSet.put(pids, pid) end) + {:reply, :ok, state} + end + + # ------------------ + # --- PUBLIC API --- + # ------------------ + + def get_status, do: GenServer.call(__MODULE__, :get_status) + + def subscribe(target), do: subscribe(target, :all) + + @spec subscribe(Event.target(), Event.events()) :: :ok + def subscribe(target, event) when target in [:self, :peer, :tailnet, :user] do + GenServer.call(__MODULE__, {:subscribe, target, event}) + end + + def subscribe_all, do: GenServer.call(__MODULE__, :subscribe_all) + + # --------------------- + # --- PRIVATE FUNCS --- + # --------------------- + + defp fire_events(:no_change, _), do: nil + + defp fire_events(events, state) do + events + |> Enum.map(fn + %Event.Tailnet{} = event -> {:tailnet, event.event, event} + %Event.Self{} = event -> {:self, event.event, event} + %Event.User{} = event -> {:user, event.event, event} + %Event.Peer{} = event -> {:peer, event.event, event} + end) + |> Enum.each(fn + # peer#changed and self#changed are composite events. + # they're emitted along with other change events. + # so here we prevent the :all firehoses from getting these events. + {:peer, :changed, payload} -> + trigger(state, :peer, :changed, payload) + + {:self, :changed, payload} -> + trigger(state, :peer, :changed, payload) + + {target, event, payload} -> + trigger(state, target, event, payload) + trigger(state, target, :all, payload) + trigger_all(state, payload) + end) + end + + defp trigger(state, target, event, payload) do + Enum.each(state.subscribers[target][event], fn pid -> send(pid, {:tailscale, payload}) end) + end + + defp trigger_all(state, payload) do + Enum.each(state.subscribers.all, fn pid -> send(pid, {:tailscale, payload}) end) + end +end diff --git a/lib/tailscale/cluster.ex b/lib/tailscale/cluster.ex new file mode 100644 index 0000000..d8d4ed3 --- /dev/null +++ b/lib/tailscale/cluster.ex @@ -0,0 +1,215 @@ +defmodule Tailscale.Cluster do + use GenServer + alias Tailscale.ChangeServer + alias Tailscale.Event + require Logger + + @ensure_interval 30_000 + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + def init(opts) do + tags = opts[:tags] + match = opts[:match_tags] || :all + + if opts[:start_distribution] != false do + start_distribution(Tailscale.ChangeServer.get_status().self) + end + + cond do + match not in [:all, :any] -> + raise ArgumentError, "Option `match_tags` needs to be either `:all` or `:any`." + + tags == nil -> + raise ArgumentError, "Option `tags` is required." + + not is_list(tags) -> + raise ArgumentError, + "Option `tags` needs to be a list of strings (without the Tailscale \"tag:\" qualifier)." + + true -> + nil + end + + tags = + Enum.map(tags, fn + "tag:" <> tag -> tag + tag -> tag + end) + |> Enum.uniq() + + state = %{ + tags: tags, + match: match, + cluster_topology: %{}, + disconnect_self_handler: opts[:disconnect_self_handler] + } + + {:ok, state, {:continue, :start}} + end + + def handle_continue(:start, state) do + # Subscribe to peer changes + ChangeServer.subscribe(:peer, :added) + ChangeServer.subscribe(:peer, :removed) + ChangeServer.subscribe(:peer, :online) + ChangeServer.subscribe(:peer, :offline) + ChangeServer.subscribe(:peer, :tags_changed) + + # Subscribe to self changes + ChangeServer.subscribe(:self, :offline) + ChangeServer.subscribe(:self, :tags_changed) + ChangeServer.subscribe(:self, :node_changed) + + # Connect to all nodes that are currently online + state = ensure_connected(state) + + # Ensure connected repeatedly + Process.send_after(self(), :ensure_connected, @ensure_interval) + + {:noreply, state} + end + + # --------------------------- + # --- EVENT SUBSCRIPTIONS --- + # --------------------------- + + def handle_info({:tailscale, %Event.Self{event: :offline}}, state) do + disconnect_self(:offline, "Tailscale is offline", state) + end + + def handle_info({:tailscale, %Event.Self{event: :tags_changed, self: self}}, state) do + case check_if_peer_matches_tags(self, state) do + true -> nil + false -> disconnect_self(:tags_changed, "Machine tags changed on Tailscale", state) + end + end + + def handle_info({:tailscale, %Event.Self{event: :node_changed}}, state) do + disconnect_self(:hostname_changed, "Machine hostname changed on Tailscale", state) + end + + def handle_info({:tailscale, %Event.Peer{event: :added, peer: peer}}, state) do + connect_to_node(peer, state) + end + + def handle_info({:tailscale, %Event.Peer{event: :online, peer: peer}}, state) do + connect_to_node(peer, state) + end + + def handle_info({:tailscale, %Event.Peer{event: :removed, peer: peer}}, state) do + disconnect_from_node(peer, state) + end + + def handle_info({:tailscale, %Event.Peer{event: :offline, peer: peer}}, state) do + disconnect_from_node(peer, state) + end + + def handle_info({:tailscale, %Event.Peer{event: :tags_changed, peer: peer}}, state) do + case check_if_peer_matches_tags(peer, state) do + true -> connect_to_node(peer, state) + false -> disconnect_from_node(peer, state) + end + end + + def handle_info(:ensure_connected, state) do + Process.send_after(self(), :ensure_connected, @ensure_interval) + {:noreply, ensure_connected(state)} + end + + def terminate(reason, _state) do + Logger.debug("Tailscale.Cluster is terminating: #{inspect(reason)}.") + Node.stop() + end + + # --------------------- + # --- PRIVATE FUNCS --- + # --------------------- + + defp start_distribution(%Tailscale.Self{} = self) do + case Node.stop() do + {:error, :not_allowed} -> + raise """ + Elixir was configured to start the distribution. + It cannot be stopped. + Do not pass --sname or --name to avoid starting the distribution. + Tailscale.Cluster will automatically setup the distribution for you. + """ + + _ -> + case Node.start(self.node, :longnames) do + {:ok, _pid} -> + :ok + + {:error, _} -> + raise "Failed to start Erlang distribution." + end + end + end + + defp ensure_connected(state) do + Tailscale.ChangeServer.get_status() + |> Map.get(:peers) + |> Enum.filter(&check_if_peer_matches_tags(&1, state)) + |> Enum.filter(fn peer -> peer.online == true end) + |> Enum.map(fn peer -> + case Node.connect(peer.node) do + true -> {peer.id, peer} + false -> nil + end + end) + |> Enum.filter(&(&1 != nil)) + |> Enum.into(%{}) + |> then(fn cluster_topology -> + %{state | cluster_topology: cluster_topology} + end) + end + + defp disconnect_self(reason, msg, state) do + if state.disconnect_self_handler != nil do + state.disconnect_self_handler.(reason) + else + Logger.debug("Restarting Application: #{msg}") + System.stop(1) + end + + {:noreply, state} + end + + defp connect_to_node(%Tailscale.Peer{} = peer, state) do + state = + case Node.connect(peer.node) do + true -> + update_in(state.cluster_topology, fn topology -> Map.put(topology, peer.id, peer) end) + + false -> + state + end + + {:noreply, state} + end + + defp disconnect_from_node(%Tailscale.Peer{} = peer, state) do + state = + case Node.disconnect(peer.node) do + true -> + update_in(state.cluster_topology, fn topology -> Map.delete(topology, peer.id) end) + + false -> + state + end + + {:noreply, state} + end + + defp check_if_peer_matches_tags(%{tags: nil} = _peer, _state), do: false + + defp check_if_peer_matches_tags(peer, state) do + case state.match do + :all -> peer.tags |> Enum.all?(fn tag -> tag in state.tags end) + :any -> peer.tags |> Enum.any?(fn tag -> tag in state.tags end) + end + end +end diff --git a/lib/tailscale/event.ex b/lib/tailscale/event.ex new file mode 100644 index 0000000..e59e28e --- /dev/null +++ b/lib/tailscale/event.ex @@ -0,0 +1,58 @@ +defmodule Tailscale.Event do + @type t :: + Tailscale.Event.Tailnet.t() + | Tailscale.Event.Self.t() + | Tailscale.Event.User.t() + | Tailscale.Event.Peer.t() + + @type targets :: :tailnet | :self | :user | :peer + + @type events :: + :all + | :added + | :removed + | :changed + | :online + | :offline + | :active + | :inactive + | :node_changed + | :tags_changed + | :version_changed + | :domain_changed + + def events_available_for_subscription do + %{ + all: MapSet.new(), + tailnet: %{all: MapSet.new(), version_changed: MapSet.new(), domain_changed: MapSet.new()}, + user: %{ + all: MapSet.new(), + added: MapSet.new(), + removed: MapSet.new(), + changed: MapSet.new() + }, + self: %{ + all: MapSet.new(), + changed: MapSet.new(), + online: MapSet.new(), + offline: MapSet.new(), + active: MapSet.new(), + inactive: MapSet.new(), + node_changed: MapSet.new(), + tags_changed: MapSet.new() + }, + peer: %{ + all: MapSet.new(), + added: MapSet.new(), + removed: MapSet.new(), + changed: MapSet.new(), + online: MapSet.new(), + offline: MapSet.new(), + active: MapSet.new(), + inactive: MapSet.new(), + node_changed: MapSet.new(), + tags_changed: MapSet.new() + } + } + end +end diff --git a/lib/tailscale/event/peer.ex b/lib/tailscale/event/peer.ex new file mode 100644 index 0000000..fcfc31c --- /dev/null +++ b/lib/tailscale/event/peer.ex @@ -0,0 +1,83 @@ +defmodule Tailscale.Event.Peer do + alias Tailscale.Peer + + @type events :: + :added + | :removed + | :changed + | :online + | :offline + | :active + | :inactive + | :node_changed + | :tags_changed + + @type t :: %__MODULE__{ + event: events(), + peer: Tailscale.Peer.t() + } + + defstruct event: nil, peer: nil + + @spec compare_lists([Peer.t()], [Peer.t()]) :: [__MODULE__.t()] + def compare_lists(old_peers, new_peers) do + old_set = MapSet.new(old_peers, fn peer -> peer.id end) + new_set = MapSet.new(new_peers, fn peer -> peer.id end) + + added_ids = MapSet.difference(new_set, old_set) + removed_ids = MapSet.difference(old_set, new_set) + unchanged_ids = MapSet.intersection(old_set, new_set) |> MapSet.to_list() + + added_events = + new_peers + |> Enum.filter(fn p -> p.id in added_ids end) + |> Enum.map(fn p -> %__MODULE__{event: :added, peer: p} end) + + removed_events = + old_peers + |> Enum.filter(fn p -> p.id in removed_ids end) + |> Enum.map(fn p -> %__MODULE__{event: :removed, peer: p} end) + + changed_events = + unchanged_ids + |> Enum.map(fn id -> + old_peer = Enum.find(old_peers, fn peer -> peer.id == id end) + new_peer = Enum.find(new_peers, fn peer -> peer.id == id end) + + case Map.equal?(old_peer, new_peer) do + true -> nil + false -> compare(old_peer, new_peer) + end + end) + |> List.flatten() + |> Enum.filter(&(&1 != nil)) + + events = added_events ++ removed_events ++ changed_events + + events + end + + def compare(%Peer{} = old, %Peer{} = new) do + [ + if(old.online? != new.online? and new.online? == true, do: online(new)), + if(old.online? != new.online? and new.online? == false, do: offline(new)), + if(old.active? != new.active? and new.active? == true, do: active(new)), + if(old.active? != new.active? and new.active? == false, do: inactive(new)), + if(old.node != new.node, do: node_changed(new)), + if(old.tags != new.tags, do: tags_changed(new)) + ] + |> Enum.filter(&(&1 != nil)) + |> then(fn + [] -> [] + events -> events ++ [changed(new)] + end) + end + + def changed(peer), do: %__MODULE__{event: :changed, peer: peer} + def online(peer), do: %__MODULE__{event: :online, peer: peer} + def offline(peer), do: %__MODULE__{event: :offline, peer: peer} + def active(peer), do: %__MODULE__{event: :active, peer: peer} + def inactive(peer), do: %__MODULE__{event: :inactive, peer: peer} + def node_changed(peer), do: %__MODULE__{event: :node_changed, peer: peer} + def tags_changed(peer), do: %__MODULE__{event: :tags_changed, peer: peer} +end diff --git a/lib/tailscale/event/self.ex b/lib/tailscale/event/self.ex new file mode 100644 index 0000000..26b72db --- /dev/null +++ b/lib/tailscale/event/self.ex @@ -0,0 +1,37 @@ +defmodule Tailscale.Event.Self do + alias Tailscale.Self + + @type events :: + :changed | :online | :offline | :active | :inactive | :node_changed | :tags_changed + + @type t :: %__MODULE__{ + event: events(), + self: Tailscale.Self.t() + } + + defstruct event: nil, self: nil + + def compare(%Self{} = old, %Self{} = new) do + [ + if(old.online? != new.online? and new.online? == true, do: online(new)), + if(old.online? != new.online? and new.online? == false, do: offline(new)), + if(old.active? != new.active? and new.active? == true, do: active(new)), + if(old.active? != new.active? and new.active? == false, do: inactive(new)), + if(old.node != new.node, do: node_changed(new)), + if(old.tags != new.tags, do: tags_changed(new)) + ] + |> Enum.filter(&(&1 != nil)) + |> then(fn + [] -> [] + events -> events ++ [changed(new)] + end) + end + + defp changed(self), do: %__MODULE__{event: :changed, self: self} + defp online(self), do: %__MODULE__{event: :online, self: self} + defp offline(self), do: %__MODULE__{event: :offline, self: self} + defp active(self), do: %__MODULE__{event: :active, self: self} + defp inactive(self), do: %__MODULE__{event: :inactive, self: self} + defp node_changed(self), do: %__MODULE__{event: :node_changed, self: self} + defp tags_changed(self), do: %__MODULE__{event: :tags_changed, self: self} +end diff --git a/lib/tailscale/event/tailnet.ex b/lib/tailscale/event/tailnet.ex new file mode 100644 index 0000000..589975a --- /dev/null +++ b/lib/tailscale/event/tailnet.ex @@ -0,0 +1,26 @@ +defmodule Tailscale.Event.Tailnet do + alias Tailscale.Tailnet + + @type t :: %__MODULE__{ + event: :version_changed | :domain_changed, + old_value: Tailnet.t(), + new_value: Tailnet.t() + } + + defstruct event: nil, old_value: nil, new_value: nil + + def compare(%Tailnet{} = old, %Tailnet{} = new) do + [version_changed(old, new), domain_changed(old, new)] + end + + defp make(_event, old_value, new_value) when old_value == new_value, do: nil + + defp make(event, old_value, new_value), + do: %__MODULE__{event: event, old_value: old_value, new_value: new_value} + + defp version_changed(old, new), + do: :version_changed |> make(old.tailscale_version, new.tailscale_version) + + defp domain_changed(old, new), + do: :domain_changed |> make(old.domain, new.domain) +end diff --git a/lib/tailscale/event/user.ex b/lib/tailscale/event/user.ex new file mode 100644 index 0000000..4e923d1 --- /dev/null +++ b/lib/tailscale/event/user.ex @@ -0,0 +1,63 @@ +defmodule Tailscale.Event.User do + alias Tailscale.User + + @type t :: %__MODULE__{ + event: :added | :removed | :changed, + user_id: String.t(), + username: String.t(), + display_name: String.t() + } + + defstruct event: nil, user_id: nil, username: nil, display_name: nil + + @spec compare_lists([User.t()], [User.t()]) :: [__MODULE__.t()] + def compare_lists(old_users, new_users) do + old_set = MapSet.new(old_users, fn user -> user.id end) + new_set = MapSet.new(new_users, fn user -> user.id end) + + added_ids = MapSet.difference(new_set, old_set) + removed_ids = MapSet.difference(old_set, new_set) + unchanged_ids = MapSet.intersection(old_set, new_set) |> MapSet.to_list() + + added_events = + new_users + |> Enum.filter(fn u -> u.id in added_ids end) + |> Enum.map(fn u -> added(u) end) + + removed_events = + old_users + |> Enum.filter(fn u -> u.id in removed_ids end) + |> Enum.map(fn u -> removed(u) end) + + changed_events = + unchanged_ids + |> Enum.map(fn id -> + old_user = Enum.find(old_users, fn user -> user.id == id end) + new_user = Enum.find(new_users, fn user -> user.id == id end) + + case Map.equal?(old_user, new_user) do + true -> nil + false -> new_user + end + end) + |> Enum.filter(&(&1 != nil)) + |> Enum.map(fn u -> changed(u) end) + + events = added_events ++ removed_events ++ changed_events + + events + end + + defp make(event, %User{} = user) do + %__MODULE__{ + event: event, + user_id: user.id, + username: user.username, + display_name: user.display_name + } + end + + def added(%User{} = user), do: :added |> make(user) + def removed(%User{} = user), do: :removed |> make(user) + def changed(%User{} = user), do: :changed |> make(user) +end diff --git a/lib/tailscale/exceptions.ex b/lib/tailscale/exceptions.ex new file mode 100644 index 0000000..52a0b7b --- /dev/null +++ b/lib/tailscale/exceptions.ex @@ -0,0 +1,15 @@ +defmodule Tailscale.Exceptions do + defmodule TailscaleNotRunning do + @msg "Tailscale is not running on the host." + defexception message: @msg + end + + defmodule TailscaleNotFound do + @msg "Cannot find Tailscale binary. Ensure Tailscale is installed on your operating system." + defexception message: @msg + end + + defmodule TailscaleCLIError do + defexception message: "Unhandled Tailscale CLI Error", code: 1 + end +end diff --git a/lib/tailscale/local/cmd.ex b/lib/tailscale/local/cmd.ex new file mode 100644 index 0000000..85bcacd --- /dev/null +++ b/lib/tailscale/local/cmd.ex @@ -0,0 +1,38 @@ +defmodule Tailscale.Local.Cmd do + alias Tailscale.Exceptions.{TailscaleCLIError, TailscaleNotFound, TailscaleNotRunning} + + def exec(args, opts \\ []) + + def exec(args, opts) when is_binary(args) do + exec(String.split(args, " "), opts) + end + + def exec(args, opts) when is_list(args) do + decode_json? = if opts[:json] == true, do: true, else: false + + case System.cmd(find_tailscale_executable(), args, stderr_to_stdout: true) do + {output, 0} -> + if(decode_json?, do: Jason.decode!(output), else: output) + + {output, code} -> + if String.contains?(output, "is Tailscale running?") do + raise TailscaleNotRunning + else + raise TailscaleCLIError, message: output, code: code + end + end + end + + defp find_tailscale_executable do + executable = + case :os.type() do + {:unix, :darwin} -> "/Applications/Tailscale.app/Contents/MacOS/Tailscale" + _ -> "tailscale" + end + + case System.find_executable(executable) do + nil -> raise TailscaleNotFound + path -> path + end + end +end diff --git a/lib/tailscale/local/status.ex b/lib/tailscale/local/status.ex new file mode 100644 index 0000000..4411285 --- /dev/null +++ b/lib/tailscale/local/status.ex @@ -0,0 +1,64 @@ +defmodule Tailscale.Local.Status do + alias Tailscale.Local.Cmd + + def get!, do: parse_status(get_raw_map!()) + def get_raw!, do: Cmd.exec(~w[status --json], json: false) + def get_raw_map!, do: Cmd.exec(~w[status --json], json: true) + + defp parse_status(status_json) do + tailnet = %Tailscale.Tailnet{ + domain: status_json["MagicDNSSuffix"], + name: status_json["CurrentTailnet"]["Name"], + tailscale_version: status_json["Version"] + } + + self = parse_peer(status_json["Self"], true) + peers = Enum.map(status_json["Peer"], fn {_k, v} -> parse_peer(v) end) + users = parse_users(status_json["User"]) + + %Tailscale.Status{tailnet: tailnet, users: users, self: self, peers: peers} + end + + defp parse_users(users_map) do + users_map + |> Enum.filter(fn {_k, v} -> v["LoginName"] != "tagged-devices" end) + |> Enum.map(fn {_k, v} -> + %Tailscale.User{id: v["ID"], username: v["LoginName"], display_name: v["DisplayName"]} + end) + end + + defp parse_peer(peer_map, is_self \\ false) do + id = peer_map["ID"] + hostname = peer_map["HostName"] + online? = peer_map["Online"] + active? = peer_map["Active"] + tags = peer_map["Tags"] + [ipv4, _ipv6] = peer_map["TailscaleIPs"] + _domain = peer_map["DNSName"] + _os = peer_map["OS"] + _is_exit_node? = peer_map["ExitNode"] + _relay = peer_map["Relay"] + + peer = [ + id: id, + hostname: hostname, + ip: ipv4, + node: :"#{hostname}@#{ipv4}", + online?: online?, + active?: active?, + tags: parse_tags(tags) + ] + + case is_self do + true -> struct!(Tailscale.Self, peer) + false -> struct!(Tailscale.Peer, peer) + end + end + + defp parse_tags(nil), do: nil + + defp parse_tags(tags) do + Enum.map(tags, fn "tag:" <> tag -> tag end) + |> Enum.sort() + end +end diff --git a/lib/tailscale/status.ex b/lib/tailscale/status.ex new file mode 100644 index 0000000..6da5b53 --- /dev/null +++ b/lib/tailscale/status.ex @@ -0,0 +1,31 @@ +defmodule Tailscale.Status do + alias Tailscale.{Tailnet, User, Self, Peer, Event} + + @type t :: %__MODULE__{ + tailnet: Tailnet.t(), + users: list(User.t()), + self: Self.t(), + peers: list(Peer.t()) + } + + defstruct tailnet: nil, users: nil, self: nil, peers: nil + + def get! do + Tailscale.Local.Status.get!() + end + + def diff(%__MODULE__{} = old, %__MODULE__{} = new) do + [ + Event.Tailnet.compare(old.tailnet, new.tailnet), + Event.Self.compare(old.self, new.self), + Event.User.compare_lists(old.users, new.users), + Event.Peer.compare_lists(old.peers, new.peers) + ] + |> List.flatten() + |> Enum.filter(&(&1 != nil)) + |> then(fn + [] -> :no_change + events -> events + end) + end +end diff --git a/lib/tailscale/structs.ex b/lib/tailscale/structs.ex new file mode 100644 index 0000000..940ae42 --- /dev/null +++ b/lib/tailscale/structs.ex @@ -0,0 +1,44 @@ +defmodule Tailscale.User do + @type t :: %__MODULE__{ + id: integer(), + display_name: binary(), + username: binary() + } + + defstruct id: nil, display_name: nil, username: nil +end + +defmodule Tailscale.Tailnet do + @type t :: %__MODULE__{ + name: binary(), + domain: binary(), + tailscale_version: binary() + } + + defstruct name: nil, domain: nil, tailscale_version: nil +end + +defmodule Tailscale.Peer do + @type t :: %__MODULE__{ + id: binary(), + hostname: binary(), + ip: binary(), + node: atom(), + online?: boolean(), + active?: boolean(), + tags: list(binary()) + } + + defstruct id: nil, + hostname: nil, + ip: nil, + node: nil, + online?: nil, + active?: nil, + tags: nil +end + +defmodule Tailscale.Self do + @type t :: Tailscale.Peer.t() + defstruct Map.to_list(%Tailscale.Peer{}) +end diff --git a/lib/tailscale/supervisor.ex b/lib/tailscale/supervisor.ex new file mode 100644 index 0000000..9da81b6 --- /dev/null +++ b/lib/tailscale/supervisor.ex @@ -0,0 +1,31 @@ +defmodule Tailscale.Supervisor do + use Supervisor + + def start_link(opts) do + Supervisor.start_link(__MODULE__, opts, name: __MODULE__) + end + + @impl true + def init(opts) do + change_server = + {Tailscale.ChangeServer, [refresh_interval: opts[:refresh_interval] || 30_000]} + + cluster = + {Tailscale.Cluster, + [ + tags: opts[:tags], + match_tags: opts[:match_tags] || :all, + disconnect_self_handler: opts[:disconnect_self_handler], + start_distribution: opts[:start_distribution] || true + ]} + + children = + if opts[:start_cluster] == true do + [change_server, cluster] + else + [change_server] + end + + Supervisor.init(children, strategy: :rest_for_one) + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..a370931 --- /dev/null +++ b/mix.exs @@ -0,0 +1,25 @@ +defmodule Tailscale.MixProject do + use Mix.Project + + def project do + [ + app: :tailscale, + version: "0.1.0", + elixir: "~> 1.15", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + def application do + [ + extra_applications: [:logger] + ] + end + + defp deps do + [ + {:jason, "~> 1.4"} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..ddb949c --- /dev/null +++ b/mix.lock @@ -0,0 +1,3 @@ +%{ + "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..6de6110 --- /dev/null +++ b/readme.md @@ -0,0 +1,24 @@ +# Tailscale + +**[WIP]** + +This library helps you build an Elixir cluster using Tailscale. You need to have the `tailscale` CLI installed on the machine and authenticated. This library calls the `tailscale status --json` command to get all the information. The status is diffed for changes, which can be subscribed to. The library also implements the `Tailscale.Cluster` module which enables you to connect to any other node on the network simply by having the same tag on Tailscale. + +There are plans to support the Tailscale API in the future for reading the status instead of the CLI. The plan is to make it a very powerful library to interact with Tailscale from Elixir. + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `tailscale` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:tailscale, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at . diff --git a/test/tailscale_test.exs b/test/tailscale_test.exs new file mode 100644 index 0000000..a5985a2 --- /dev/null +++ b/test/tailscale_test.exs @@ -0,0 +1,4 @@ +defmodule TailscaleTest do + use ExUnit.Case + doctest Tailscale +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()