From d0201995cbb06110ab6b5f205ad58a7a68b84199 Mon Sep 17 00:00:00 2001 From: Hans Krutzer Date: Mon, 18 Sep 2023 12:25:34 +0200 Subject: [PATCH] Add Tailscale ZTA module (#2207) --- lib/livebook/config.ex | 3 +- lib/livebook/zta/tailscale.ex | 107 +++++++++++++++++++++++++++ mix.exs | 3 +- mix.lock | 2 + test/livebook/zta/tailscale_test.exs | 107 +++++++++++++++++++++++++++ 5 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 lib/livebook/zta/tailscale.ex create mode 100644 test/livebook/zta/tailscale_test.exs diff --git a/lib/livebook/config.ex b/lib/livebook/config.ex index c9e46c335..f34a26c2e 100644 --- a/lib/livebook/config.ex +++ b/lib/livebook/config.ex @@ -7,8 +7,9 @@ defmodule Livebook.Config do identity_providers = %{ session: LivebookWeb.SessionIdentity, + cloudflare: Livebook.ZTA.Cloudflare, google_iap: Livebook.ZTA.GoogleIAP, - cloudflare: Livebook.ZTA.Cloudflare + tailscale: Livebook.ZTA.Tailscale } @identity_provider_type_to_module Map.new(identity_providers, fn {key, value} -> diff --git a/lib/livebook/zta/tailscale.ex b/lib/livebook/zta/tailscale.ex new file mode 100644 index 000000000..4415ddb97 --- /dev/null +++ b/lib/livebook/zta/tailscale.ex @@ -0,0 +1,107 @@ +defmodule Livebook.ZTA.Tailscale do + @moduledoc """ + To integrate Tailscale authentication with Livebook, + set the `LIVEBOOK_IDENTITY_PROVIDER` environment variable to `tailscale:tailscale-socket-path`. + + If you want to access the Livebook on the same machine as you are hosting it on, + you will also need to set the LIVEBOOK_IP variable to your Tailscale IP. + + To do both of these things, run + + ```bash + LIVEBOOK_IP=$(tailscale ip -1 | tr -d '\n') LIVEBOOK_IDENTITY_PROVIDER=tailscale:/var/run/tailscale/tailscaled.sock livebook server + ``` + + See https://tailscale.com/blog/tailscale-auth-nginx/ for more information + on how Tailscale authorization works. + + ## MacOS + + On MacOS, when Tailscale is installed via the Mac App Store, no unix socket is exposed. + Instead, a TCP port is made available, protected via a password, which needs to be located. + Tailscale itself uses lsof for this. This method is replicated in the bash script below, + which will start Livebook with your Tailscale IP and correct port and password. + + ```bash + #!/bin/bash + addr_info=$(lsof -n -a -c IPNExtension -F | sed -n 's/.*sameuserproof-\([[:digit:]]*-.*\).*/\1/p') + port=$(echo "$addr_info" | cut -d '-' -f 1) + pass=$(echo "$addr_info" | cut -d '-' -f 2) + LIVEBOOK_IP=$(exec $(ps -xo comm | grep MacOS/Tailscale$) ip | head -1 | tr -d '\n') LIVEBOOK_IDENTITY_PROVIDER=tailscale:http://:$pass@127.0.0.1:$port livebook server + ``` + """ + + use GenServer + require Logger + + defstruct [:name, :address] + + def start_link(opts) do + options = [address: opts[:identity][:key]] + GenServer.start_link(__MODULE__, options, name: opts[:name]) + end + + def authenticate(name, conn, _) do + remote_ip = to_string(:inet_parse.ntoa(conn.remote_ip)) + tailscale_address = GenServer.call(name, :get_address) + user = authenticate_ip(remote_ip, tailscale_address) + {conn, user} + end + + @impl true + def init(options) do + state = struct!(__MODULE__, options) + {:ok, state} + end + + @impl true + def handle_call(:get_address, _from, state) do + {:reply, state.address, state} + end + + defp authenticate_ip(remote_ip, address) do + {url, options} = + if String.starts_with?(address, "http") do + uri = URI.parse(address) + + options = + if uri.userinfo do + # Req does not handle userinfo as part of the URL + [auth: "Basic #{Base.encode64(uri.userinfo)}"] + else + [] + end + + url = to_string(%{uri | userinfo: nil, path: "/localapi/v0/whois?addr=#{remote_ip}:1"}) + + {url, options} + else + # Assume address not starting with http is a Unix socket + unless File.exists?(address) do + raise "Tailscale socket does not exist: #{inspect(address)}" + end + + { + "http://local-tailscaled.sock/localapi/v0/whois?addr=#{remote_ip}:1", + [ + unix_socket: address, + # Req or Finch do not pass on the host from the URL when using a unix socket, + # so we set the host header explicitly + headers: [host: "local-tailscaled.sock"] + ] + } + end + + with {:ok, response} <- Req.get(url, options), + 200 <- response.status, + %{"UserProfile" => user} <- response.body do + %{ + id: to_string(user["ID"]), + name: user["DisplayName"], + email: user["LoginName"] + } + else + _ -> nil + end + end +end diff --git a/mix.exs b/mix.exs index 60420c415..063d1410e 100644 --- a/mix.exs +++ b/mix.exs @@ -111,7 +111,8 @@ defmodule Livebook.MixProject do {:bypass, "~> 2.1", only: :test}, # ZTA deps {:jose, "~> 1.11.5"}, - {:req, "~> 0.3.8"} + {:req, "~> 0.3.8"}, + {:bandit, "~> 0.7", only: :test} ] end diff --git a/mix.lock b/mix.lock index 1e161e6bf..d32aa76df 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,6 @@ %{ "aws_signature": {:hex, :aws_signature, "0.3.1", "67f369094cbd55ffa2bbd8cc713ede14b195fcfb45c86665cd7c5ad010276148", [:rebar3], [], "hexpm", "50fc4dc1d1f7c2d0a8c63f455b3c66ecd74c1cf4c915c768a636f9227704a674"}, + "bandit": {:hex, :bandit, "0.7.7", "48456d09022607a312cf723a91992236aeaffe4af50615e6e2d2e383fb6bef10", [:mix], [{:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 0.6.7", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "772f0a32632c2ce41026d85e24b13a469151bb8cea1891e597fb38fde103640a"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, "castore": {:hex, :castore, "1.0.3", "7130ba6d24c8424014194676d608cb989f62ef8039efd50ff4b3f33286d06db8", [:mix], [], "hexpm", "680ab01ef5d15b161ed6a95449fac5c6b8f60055677a8e79acf01b27baa4390b"}, "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, @@ -36,6 +37,7 @@ "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, + "thousand_island": {:hex, :thousand_island, "0.6.7", "3a91a7e362ca407036c6691e8a4f6e01ac8e901db3598875863a149279ac8571", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "541a5cb26b88adf8d8180b6b96a90f09566b4aad7a6b3608dcac969648cf6765"}, "websock": {:hex, :websock, "0.5.1", "c496036ce95bc26d08ba086b2a827b212c67e7cabaa1c06473cd26b40ed8cf10", [:mix], [], "hexpm", "b9f785108b81cd457b06e5f5dabe5f65453d86a99118b2c0a515e1e296dc2d2c"}, "websock_adapter": {:hex, :websock_adapter, "0.5.1", "292e6c56724e3457e808e525af0e9bcfa088cc7b9c798218e78658c7f9b85066", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8e2e1544bfde5f9d0442f9cec2f5235398b224f75c9e06b60557debf64248ec1"}, } diff --git a/test/livebook/zta/tailscale_test.exs b/test/livebook/zta/tailscale_test.exs new file mode 100644 index 000000000..ee3c7f97f --- /dev/null +++ b/test/livebook/zta/tailscale_test.exs @@ -0,0 +1,107 @@ +defmodule Livebook.ZTA.TailscaleTest do + use ExUnit.Case, async: true + use Plug.Test + + alias Livebook.ZTA.Tailscale + + @fields [:id, :name, :email] + @name Context.Test.Tailscale + @path "/localapi/v0/whois" + + def valid_user_response(conn) do + conn + |> put_resp_content_type("application/json") + |> send_resp( + 200, + Jason.encode!(%{ + UserProfile: %{ + ID: 1_234_567_890, + DisplayName: "John", + LoginName: "john@example.org" + } + }) + ) + end + + setup do + bypass = Bypass.open() + + conn = %Plug.Conn{conn(:get, @path) | remote_ip: {151, 236, 219, 228}} + + options = [ + name: @name, + identity: [ + key: "http://localhost:#{bypass.port}" + ] + ] + + {:ok, bypass: bypass, options: options, conn: conn} + end + + test "returns the user when it's valid", %{bypass: bypass, options: options, conn: conn} do + Bypass.expect(bypass, fn conn -> + assert %{"addr" => "151.236.219.228:1"} = conn.query_params + valid_user_response(conn) + end) + + start_supervised!({Tailscale, options}) + {_conn, user} = Tailscale.authenticate(@name, conn, @fields) + assert %{id: "1234567890", email: "john@example.org", name: "John"} = user + end + + @tag :tmp_dir + test "returns valid user via unix socket", %{options: options, conn: conn, tmp_dir: tmp_dir} do + defmodule TestPlug do + def init(options), do: options + def call(conn, _opts), do: Livebook.ZTA.TailscaleTest.valid_user_response(conn) + end + + socket = Path.relative_to_cwd("#{tmp_dir}/bandit.sock") + options = Keyword.put(options, :identity, key: socket) + start_supervised!({Bandit, plug: TestPlug, ip: {:local, socket}, port: 0}) + start_supervised!({Tailscale, options}) + {_conn, user} = Tailscale.authenticate(@name, conn, @fields) + assert %{id: "1234567890", email: "john@example.org", name: "John"} = user + end + + test "raises when configured with missing unix socket", %{options: options, conn: conn} do + options = Keyword.put(options, :identity, key: "./invalid-socket.sock") + start_supervised!({Tailscale, options}) + assert_raise RuntimeError, fn -> + {_conn, user} = Tailscale.authenticate(@name, conn, @fields) + end + end + + test "returns nil when it's invalid", %{bypass: bypass, options: options} do + Bypass.expect_once(bypass, fn conn -> + assert %{"addr" => "151.236.219.229:1"} = conn.query_params + + conn + |> send_resp(404, "no match for IP:port") + end) + + conn = %Plug.Conn{conn(:get, @path) | remote_ip: {151, 236, 219, 229}} + + start_supervised!({Tailscale, options}) + assert {_conn, nil} = Tailscale.authenticate(@name, conn, @fields) + end + + test "includes an authorization header when userinfo is provided", %{ + bypass: bypass, + options: options, + conn: conn + } do + options = Keyword.put(options, :identity, key: "http://:foobar@localhost:#{bypass.port}") + + Bypass.expect_once(bypass, fn conn -> + assert %{"addr" => "151.236.219.228:1"} = conn.query_params + assert Plug.Conn.get_req_header(conn, "authorization") == ["Basic OmZvb2Jhcg=="] + + conn + |> send_resp(404, "no match for IP:port") + end) + + start_supervised!({Tailscale, options}) + assert {_conn, nil} = Tailscale.authenticate(@name, conn, @fields) + end +end